diff --git a/apps/desktop/src/main/__tests__/expected-failure-copy.test.ts b/apps/desktop/src/main/__tests__/expected-failure-copy.test.ts new file mode 100644 index 0000000000..58e141f6d4 --- /dev/null +++ b/apps/desktop/src/main/__tests__/expected-failure-copy.test.ts @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + getProviderSettingsCopy, + providerPanelActionErrorMessage, +} from '../../renderer/features/connection-settings/index.js'; +import type { RuntimeHostServiceErrorCode } from '@maka/runtime-host/operator'; +import { + getSettingsProjectsCopy, + runtimeHostManagementErrorMessage, + type SettingsProjectsCopy, +} from '../../renderer/locales/settings-projects-copy.js'; + +test('Runtime Host management codes render per locale and unknown codes fall back', () => { + const rendered = (code: string) => ({ + zh: runtimeHostManagementErrorMessage(code, 'zh-CN'), + en: runtimeHostManagementErrorMessage(code, 'en'), + }); + assert.deepEqual(rendered('active_tasks'), { + zh: 'Runtime Host 正在执行任务,请稍后再试', + en: 'Runtime Host still owns active work. Try again later.', + }); + assert.deepEqual(rendered('linger_disabled'), { + zh: '请先为当前用户启用 systemd linger,服务才能在登出后继续运行', + en: 'Enable systemd linger for this user so the service keeps running after logout.', + }); + assert.deepEqual(rendered('package_integrity_mismatch'), { + zh: '更新包校验失败', + en: 'The update package failed its integrity check.', + }); + const unknownFallback = { + zh: '请查看服务日志了解详情', + en: 'Check the service logs for details.', + }; + for (const code of [ + 'deployment_commit_unknown', + 'update_policy_commit_outcome_unknown', + 'some_future_code', + 'constructor', + ]) { + assert.deepEqual(rendered(code), unknownFallback); + } +}); + +type ManagementErrorCopy = SettingsProjectsCopy['runtimeHost']['managementError']; +type OperatorErrorCopy = Record; + +// The catalog cannot import the operator type (renderer dependency ratchet), so tsc pins the two +// unions here: each assignment compiles only while its source keys cover the target's. +const presentsEveryOperatorCode = (copy: ManagementErrorCopy): OperatorErrorCopy => copy; +const presentsOnlyOperatorCodes = (copy: OperatorErrorCopy): ManagementErrorCopy => copy; + +test('presenter maps exactly the codes the operator commits to', () => { + const copy = getSettingsProjectsCopy('en').runtimeHost.managementError; + assert.equal(presentsEveryOperatorCode(copy), copy); + assert.equal(presentsOnlyOperatorCodes(copy), copy); +}); + +test('provider action errors never echo a raw Chinese message', () => { + const error = new Error('连接失败,请稍后重试'); + assert.equal( + providerPanelActionErrorMessage(error, 'zh-CN'), + getProviderSettingsCopy('zh-CN').shared.actionFallback, + ); + assert.equal( + providerPanelActionErrorMessage(error, 'en'), + getProviderSettingsCopy('en').shared.actionFallback, + ); +}); diff --git a/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts index b30f2d1a6f..64b4043643 100644 --- a/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts @@ -34,9 +34,6 @@ export function providerPanelActionErrorMessage(error: unknown, locale: UiLocale const cleaned = redactSecrets(cleanErrorMessage(error)).trim(); const known = (shared.lastTest as Readonly>)[cleaned.toLowerCase()]; if (known) return known; - // Main-process handlers throw display-ready Chinese copy; keep it instead - // of flattening it into a coarser classification or the generic fallback. - if (locale === 'zh-CN' && /[\u3400-\u9fff]/.test(cleaned)) return cleaned; if (/connection_stale|Unable to delete Connection: connection_stale/i.test(cleaned)) { if (locale === 'zh-CN') return '连接状态已更新,请刷新列表后再删除。'; if (locale === 'zh-TW') return '連線狀態已更新,請重新整理清單後再刪除。'; diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx index 89566fe11c..89fd331441 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx @@ -632,8 +632,7 @@ function artifactActionErrorMessage(error: unknown, locale: UiLocale, copy: Arti const raw = redactSecrets(error instanceof Error ? error.message : String(error ?? '')).trim(); if (!raw) return copy.pane.actionFailed; const classified = generalizedErrorMessageForLocale(new Error(raw), '', locale); - if (classified) return classified; - return locale === 'zh-CN' && /[\u4e00-\u9fff]/.test(raw) ? raw : copy.pane.actionFailed; + return classified || copy.pane.actionFailed; } function KindIcon(props: { kind: ArtifactKind }) { diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index c393ee1f08..6b57dea213 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -19,6 +19,37 @@ import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +// Mirrors RuntimeHostServiceErrorCode from @maka/runtime-host/operator; expected-failure-copy.test.ts pins the two together. +export type RuntimeHostManagementErrorCode = + | 'active_tasks' + | 'not_installed' + | 'unsupported_platform' + | 'service_manager_unavailable' + | 'linger_disabled' + | 'invalid_config' + | 'invalid_launch' + | 'target_mismatch' + | 'configuration_changed' + | 'configuration_incomplete' + | 'retirement_failed' + | 'update_requires_retirement' + | 'update_incomplete' + | 'service_manager_operation_failed' + | 'uninstall_incomplete' + | 'deployment_io_failed' + | 'deployment_commit_unknown' + | 'target_unavailable' + | 'registry_unavailable' + | 'invalid_registry_metadata' + | 'package_download_failed' + | 'package_integrity_mismatch' + | 'invalid_package' + | 'invalid_update_policy' + | 'update_policy_write_failed' + | 'update_policy_commit_outcome_unknown' + | 'update_policy_changed' + | 'update_not_admitted'; + export type SettingsProjectsCopy = { runtimeHost: { title: string; @@ -235,6 +266,7 @@ export type SettingsProjectsCopy = { uninstallConfirm: string; uninstallRetained(path: string): string; managementActionFailed: string; + managementError: Record; managementReconnectFailed: string; manageAccess: string; accessTitle: string; @@ -555,6 +587,37 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { uninstallConfirm: '卸载服务', uninstallRetained: (path: string) => `服务已卸载,数据保留在 ${path}`, managementActionFailed: '无法管理 Runtime Host 服务', + managementError: { + active_tasks: 'Runtime Host 正在执行任务,请稍后再试', + not_installed: '此 Runtime Host 服务尚未安装', + unsupported_platform: '当前系统不支持受管理的 Runtime Host 服务', + service_manager_unavailable: '系统服务管理器(systemd、launchd 或 OpenRC)不可用', + linger_disabled: '请先为当前用户启用 systemd linger,服务才能在登出后继续运行', + invalid_config: 'Runtime Host 服务配置无效', + invalid_launch: 'Runtime Host 服务启动参数无效,请重新安装', + target_mismatch: '服务已被其他安装接管,请刷新后重试', + configuration_changed: '服务配置已在别处修改,请刷新后重试', + configuration_incomplete: '服务配置不完整,请重新安装', + retirement_failed: '无法安全停止当前 Runtime Host', + update_requires_retirement: '更新前需要先停止当前 Runtime Host', + update_incomplete: '更新未完成,请查看服务日志', + service_manager_operation_failed: '系统服务管理器操作失败,请查看服务日志', + uninstall_incomplete: '卸载未完成,请重试', + deployment_io_failed: '无法写入 Runtime Host 部署文件', + deployment_commit_unknown: '请查看服务日志了解详情', + target_unavailable: '找不到所选版本', + registry_unavailable: '无法连接更新源,请检查网络', + invalid_registry_metadata: '更新源返回了无效的版本信息', + package_download_failed: '更新包下载失败', + package_integrity_mismatch: '更新包校验失败', + invalid_package: '更新包无效', + invalid_update_policy: '更新策略无效', + update_policy_write_failed: '无法保存更新策略', + update_policy_commit_outcome_unknown: '请查看服务日志了解详情', + update_policy_changed: '更新策略已变化,请刷新后重试', + update_not_admitted: '当前版本不允许此更新', + unknown: '请查看服务日志了解详情', + }, managementReconnectFailed: '更改已应用,但 Desktop 未能重新连接', manageAccess: '管理访问权限', accessTitle: '访问权限', @@ -858,6 +921,37 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { uninstallConfirm: '解除安裝服務', uninstallRetained: (path: string) => `服務已解除安裝,資料保留在 ${path}`, managementActionFailed: '無法管理 Runtime Host 服務', + managementError: { + active_tasks: 'Runtime Host 正在執行任務,請稍後再試', + not_installed: '此 Runtime Host 服務尚未安裝', + unsupported_platform: '目前系統不支援受管理的 Runtime Host 服務', + service_manager_unavailable: '系統服務管理器(systemd、launchd 或 OpenRC)無法使用', + linger_disabled: '請先為目前使用者啟用 systemd linger,服務才能在登出後繼續執行', + invalid_config: 'Runtime Host 服務設定無效', + invalid_launch: 'Runtime Host 服務啟動參數無效,請重新安裝', + target_mismatch: '服務已被其他安裝接管,請重新整理後重試', + configuration_changed: '服務設定已在別處修改,請重新整理後重試', + configuration_incomplete: '服務設定不完整,請重新安裝', + retirement_failed: '無法安全停止目前的 Runtime Host', + update_requires_retirement: '更新前需要先停止目前的 Runtime Host', + update_incomplete: '更新未完成,請查看服務日誌', + service_manager_operation_failed: '系統服務管理器操作失敗,請查看服務日誌', + uninstall_incomplete: '解除安裝未完成,請重試', + deployment_io_failed: '無法寫入 Runtime Host 部署檔案', + deployment_commit_unknown: '請查看服務日誌了解詳情', + target_unavailable: '找不到所選版本', + registry_unavailable: '無法連線更新來源,請檢查網路', + invalid_registry_metadata: '更新來源回傳了無效的版本資訊', + package_download_failed: '更新套件下載失敗', + package_integrity_mismatch: '更新套件校驗失敗', + invalid_package: '更新套件無效', + invalid_update_policy: '更新策略無效', + update_policy_write_failed: '無法儲存更新策略', + update_policy_commit_outcome_unknown: '請查看服務日誌了解詳情', + update_policy_changed: '更新策略已變更,請重新整理後重試', + update_not_admitted: '目前版本不允許此更新', + unknown: '請查看服務日誌了解詳情', + }, managementReconnectFailed: '變更已套用,但 Desktop 無法重新連線', manageAccess: '管理存取權限', accessTitle: '存取權限', @@ -1178,6 +1272,40 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { uninstallConfirm: 'Uninstall service', uninstallRetained: (path: string) => `Service uninstalled. Data was retained at ${path}`, managementActionFailed: 'Unable to manage the Runtime Host service', + managementError: { + active_tasks: 'Runtime Host still owns active work. Try again later.', + not_installed: 'This Runtime Host service is not installed.', + unsupported_platform: 'Managed Runtime Host services are not supported on this platform.', + service_manager_unavailable: + 'The system service manager (systemd, launchd, or OpenRC) is unavailable.', + linger_disabled: + 'Enable systemd linger for this user so the service keeps running after logout.', + invalid_config: 'The Runtime Host service configuration is invalid.', + invalid_launch: 'The Runtime Host service launch definition is invalid. Reinstall the service.', + target_mismatch: 'Another installation now owns this service. Refresh and try again.', + configuration_changed: 'The service configuration changed elsewhere. Refresh and try again.', + configuration_incomplete: 'The service configuration is incomplete. Reinstall the service.', + retirement_failed: 'The current Runtime Host could not be stopped safely.', + update_requires_retirement: 'Stop the current Runtime Host before updating.', + update_incomplete: 'The update did not complete. Check the service logs.', + service_manager_operation_failed: + 'The system service manager operation failed. Check the service logs.', + uninstall_incomplete: 'The uninstall did not complete. Try again.', + deployment_io_failed: 'Runtime Host deployment files could not be written.', + deployment_commit_unknown: 'Check the service logs for details.', + target_unavailable: 'The selected version is unavailable.', + registry_unavailable: 'The update registry is unreachable. Check the network.', + invalid_registry_metadata: 'The update registry returned invalid version metadata.', + package_download_failed: 'The update package could not be downloaded.', + package_integrity_mismatch: 'The update package failed its integrity check.', + invalid_package: 'The update package is invalid.', + invalid_update_policy: 'The update policy is invalid.', + update_policy_write_failed: 'The update policy could not be saved.', + update_policy_commit_outcome_unknown: 'Check the service logs for details.', + update_policy_changed: 'The update policy changed. Refresh and try again.', + update_not_admitted: 'This update is not permitted for the installed version.', + unknown: 'Check the service logs for details.', + }, managementReconnectFailed: 'Change applied, but Desktop could not reconnect', manageAccess: 'Manage access', accessTitle: 'Access', @@ -1254,3 +1382,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { export function getSettingsProjectsCopy(locale: UiLocale): SettingsProjectsCopy { return SETTINGS_PROJECTS_COPY_BY_LOCALE[locale]; } + +export function runtimeHostManagementErrorMessage(code: string, locale: UiLocale): string { + const messages = getSettingsProjectsCopy(locale).runtimeHost.managementError; + return Object.hasOwn(messages, code) + ? (messages as Record)[code]! + : messages.unknown; +} diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx index fafbab3b46..4d345edc53 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx @@ -48,7 +48,10 @@ import type { DesktopRuntimeHostUpdateReconciliationOutcome, DesktopRuntimeHostUpdateReconciliationResponse, } from '../../preload/bridge-contract.js'; -import { getSettingsProjectsCopy } from '../locales/settings-projects-copy.js'; +import { + getSettingsProjectsCopy, + runtimeHostManagementErrorMessage, +} from '../locales/settings-projects-copy.js'; import { canonicalProjectDirectoryRoots, projectDirectoryRootsValid, @@ -139,7 +142,7 @@ export function RuntimeHostManagementDialog(props: { const [result, setResult] = useState(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); - const [reconnectWarning, setReconnectWarning] = useState(); + const [reconnectWarning, setReconnectWarning] = useState(false); const [uninstalledRoot, setUninstalledRoot] = useState(); const [access, setAccess] = useState(); const [confirmation, setConfirmation] = useState(); @@ -158,8 +161,6 @@ export function RuntimeHostManagementDialog(props: { useState(createWebRtcStunPolicyDraft); const nextDirectoryRootId = useRef(1); const logsRef = useRef(null); - const localizedError = (message: string): string => - settingsActionErrorMessage(new Error(message), locale); const target = props.target; useEffect(() => { @@ -167,7 +168,7 @@ export function RuntimeHostManagementDialog(props: { let disposed = false; setResult(undefined); setError(undefined); - setReconnectWarning(undefined); + setReconnectWarning(false); setUninstalledRoot(undefined); setAccess(undefined); setConfirmation(undefined); @@ -193,7 +194,10 @@ export function RuntimeHostManagementDialog(props: { reconcileDirectoryPolicy(response.service); shouldLoadUpdatePolicy = response.service.state !== 'not_installed'; } - else if (response.kind === 'error') setError(localizedError(response.error.message)); + else if (response.kind === 'error') { + console.error('[runtime-host] management status failed', response.error); + setError(runtimeHostManagementErrorMessage(response.error.code, locale)); + } else setUninstalledRoot(response.retainedStateRoot); } catch (failure) { if (!disposed) setError(settingsActionErrorMessage(failure, locale)); @@ -234,6 +238,13 @@ export function RuntimeHostManagementDialog(props: { if (logs) logs.scrollTop = logs.scrollHeight; }, [result]); + function reportManagementError(error: { readonly code: string; readonly message: string }): string { + console.error('[runtime-host] management failed', error); + const message = runtimeHostManagementErrorMessage(error.code, locale); + toast.error(copy.managementActionFailed, message); + return message; + } + async function run( action: DesktopRuntimeHostManagementAction, allowInterruptActiveTasks = false, @@ -241,7 +252,7 @@ export function RuntimeHostManagementDialog(props: { if (!target) return; setLoading(true); setError(undefined); - setReconnectWarning(undefined); + setReconnectWarning(false); setLastUpdateOutcome(undefined); try { const response = await window.maka.runtimeHostManagement.run( @@ -260,10 +271,8 @@ export function RuntimeHostManagementDialog(props: { setConfirmation({ kind: 'restart' }); return; } - const message = settingsActionErrorMessage(new Error(response.error.message), locale); setUpdatePolicy(undefined); - setError(message); - toast.error(copy.managementActionFailed, message); + setError(reportManagementError(response.error)); return; } if (response.kind === 'uninstalled') { @@ -371,7 +380,7 @@ export function RuntimeHostManagementDialog(props: { if (!target) return; setLoading(true); setError(undefined); - setReconnectWarning(undefined); + setReconnectWarning(false); setUpdatePhase('checking'); setLastUpdateOutcome(undefined); try { @@ -380,10 +389,8 @@ export function RuntimeHostManagementDialog(props: { allowInterruptActiveTasks, ); if (response.kind === 'error') { - const message = localizedError(response.error.message); setUpdatePolicy(undefined); - setError(message); - toast.error(copy.managementActionFailed, message); + setError(reportManagementError(response.error)); return; } if (response.kind === 'uninstalled') { @@ -459,7 +466,7 @@ export function RuntimeHostManagementDialog(props: { if (!target || !directoryPolicyEdit || directoryPolicyEdit.conflict) return; setLoading(true); setError(undefined); - setReconnectWarning(undefined); + setReconnectWarning(false); try { const response = await window.maka.runtimeHostManagement.configureProjectDirectories( target.id, @@ -468,9 +475,7 @@ export function RuntimeHostManagementDialog(props: { allowInterruptActiveTasks, ); if (response.kind === 'error') { - const message = localizedError(response.error.message); - setError(message); - toast.error(copy.managementActionFailed, message); + setError(reportManagementError(response.error)); return; } if (response.kind === 'uninstalled' || response.action !== 'configure') { @@ -545,17 +550,15 @@ export function RuntimeHostManagementDialog(props: { if (!target) return; setLoading(true); setError(undefined); - setReconnectWarning(undefined); + setReconnectWarning(false); setUpdatePolicyError(undefined); setUpdatePhase('checking'); setLastUpdateOutcome(undefined); try { const response = await window.maka.runtimeHostManagement.reconcileUpdate(target.id); if (response.kind === 'error') { - const message = localizedError(response.error.message); setUpdatePolicy(undefined); - setUpdatePolicyError(message); - toast.error(copy.managementActionFailed, message); + setUpdatePolicyError(reportManagementError(response.error)); return; } setLastUpdateOutcome(response.reconciliation); @@ -593,12 +596,13 @@ export function RuntimeHostManagementDialog(props: { } function applyReconnectWarning( - reconnectError: { readonly message: string } | undefined, + reconnectError: DesktopRuntimeHostManagementResult['reconnectError'], ): void { - setReconnectWarning(reconnectError ? localizedError(reconnectError.message) : undefined); - if (reconnectError) { - toast.warning(copy.managementReconnectFailed, localizedError(reconnectError.message)); - } + setReconnectWarning(Boolean(reconnectError)); + if (!reconnectError) return; + // Raw operator detail goes to diagnostics only; users see the catalog line. + console.error('[runtime-host] reconnect failed', reconnectError); + toast.warning(copy.managementReconnectFailed); } async function revokeCredential(): Promise { @@ -676,11 +680,7 @@ export function RuntimeHostManagementDialog(props: { ) : null} {error ? : null} {reconnectWarning ? ( - + ) : null} {confirmation?.kind === 'configureDirectories' ? ( , message: string, options?: ErrorOptions, ) { diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 3c1ec26500..96a9eadc38 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -50,6 +50,7 @@ import { resolveRuntimeHostManagedServiceId, RUNTIME_HOST_SERVICE_LOG_MAX_BYTES, type RuntimeHostReconciliationProvider, + type RuntimeHostServiceErrorCode, type RuntimeHostSupervisorProvider, } from '@maka/runtime-host/operator'; import { @@ -287,7 +288,8 @@ export type RuntimeHostServiceManagerOverrides = Partial, message: string, options?: ErrorOptions, ) { diff --git a/packages/cli/src/runtime-host-update-package.ts b/packages/cli/src/runtime-host-update-package.ts index a9d0c7282b..9faabf1146 100644 --- a/packages/cli/src/runtime-host-update-package.ts +++ b/packages/cli/src/runtime-host-update-package.ts @@ -24,6 +24,7 @@ import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat } from 'no import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createGunzip } from 'node:zlib'; +import type { RuntimeHostServiceErrorCode } from '@maka/runtime-host/operator'; import { isRuntimeHostNpmDeploymentIdentity } from '@maka/runtime-host/operator/update-package-evidence'; import type { RuntimeHostUpdateCandidate } from './runtime-host-registry-update.js'; @@ -45,7 +46,10 @@ const MANIFEST_MAX_BYTES = 64 * 1024; export class RuntimeHostUpdatePackageError extends Error { constructor( - readonly code: 'package_download_failed' | 'package_integrity_mismatch' | 'invalid_package', + readonly code: Extract< + RuntimeHostServiceErrorCode, + 'package_download_failed' | 'package_integrity_mismatch' | 'invalid_package' + >, message: string, options?: ErrorOptions, ) { diff --git a/packages/cli/src/runtime-host-update-policy-store.ts b/packages/cli/src/runtime-host-update-policy-store.ts index 1250825a6e..d897d4d9fa 100644 --- a/packages/cli/src/runtime-host-update-policy-store.ts +++ b/packages/cli/src/runtime-host-update-policy-store.ts @@ -23,6 +23,7 @@ import { dirname, isAbsolute, join } from 'node:path'; import { isProductReleaseVersion, type RuntimeHostManagedUpdatePolicy, + type RuntimeHostServiceErrorCode, } from '@maka/runtime-host/operator'; import type { RuntimeHostManagedServiceTarget } from './runtime-host-service-manager.js'; @@ -43,10 +44,12 @@ export interface RuntimeHostManagedUpdatePolicyRecord { export class RuntimeHostUpdatePolicyError extends Error { constructor( - readonly code: + readonly code: Extract< + RuntimeHostServiceErrorCode, | 'invalid_update_policy' | 'update_policy_write_failed' - | 'update_policy_commit_outcome_unknown', + | 'update_policy_commit_outcome_unknown' + >, message: string, options?: ErrorOptions, ) { diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index 56d7be6ebc..8b16af62ef 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -75,6 +75,7 @@ export { RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, decodeRuntimeHostServiceManagementFrame, encodeRuntimeHostServiceManagementFrame, + type RuntimeHostServiceErrorCode, type RuntimeHostServiceManagementAction, type RuntimeHostServiceManagementFrame, type RuntimeHostManagedUpdatePolicy, diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts index 1c78513238..7bb5462ba6 100644 --- a/packages/runtime-host/src/operator/service-management-frame.ts +++ b/packages/runtime-host/src/operator/service-management-frame.ts @@ -34,6 +34,38 @@ export const RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX = export const RUNTIME_HOST_SERVICE_LOG_MAX_BYTES = 48 * 1024; export const RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES = 128; export const RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES = 2 * 1024; + +// Failure codes the operator CLI commits to; the wire keeps `code` an open string so older +// Clients still decode frames from a newer operator, and presenters fold codes they do not know. +export type RuntimeHostServiceErrorCode = + | 'unsupported_platform' + | 'service_manager_unavailable' + | 'linger_disabled' + | 'not_installed' + | 'invalid_config' + | 'invalid_launch' + | 'target_mismatch' + | 'configuration_changed' + | 'configuration_incomplete' + | 'active_tasks' + | 'retirement_failed' + | 'update_requires_retirement' + | 'update_incomplete' + | 'service_manager_operation_failed' + | 'uninstall_incomplete' + | 'deployment_io_failed' + | 'deployment_commit_unknown' + | 'target_unavailable' + | 'registry_unavailable' + | 'invalid_registry_metadata' + | 'package_download_failed' + | 'package_integrity_mismatch' + | 'invalid_package' + | 'invalid_update_policy' + | 'update_policy_write_failed' + | 'update_policy_commit_outcome_unknown' + | 'update_policy_changed' + | 'update_not_admitted'; export const RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY = 'access-management-v1'; export const RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV = 'MAKA_RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST'; diff --git a/packages/ui/src/__tests__/search-modal-source.test.ts b/packages/ui/src/__tests__/search-modal-source.test.ts index 01c45b0672..b398166263 100644 --- a/packages/ui/src/__tests__/search-modal-source.test.ts +++ b/packages/ui/src/__tests__/search-modal-source.test.ts @@ -20,8 +20,9 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import type { SearchResult } from '@maka/core/search'; -import { createThreadSearchSource } from '../search-modal.js'; +import type { SearchErrorReason, SearchResult } from '@maka/core/search'; +import { createThreadSearchSource, searchErrorText } from '../search-modal.js'; +import { getShellControlsCopy } from '../shell-controls-copy.js'; function result(sessionId: string): SearchResult { return { source: 'thread', @@ -61,12 +62,11 @@ function createHarness() { resultsLabel: 'Results', onQueryChange: () => {}, onErrorChange: (error) => { - visibleError = error?.message ?? null; + visibleError = error?.reason ?? null; }, onItemsChange: (items) => { visibleItemIds = items.map((item) => item.id); }, - thrownErrorMessage: () => 'Thrown error', }); return { source, @@ -124,3 +124,24 @@ describe('thread search source', () => { assert.deepEqual(harness.getVisibleItemIds(), ['current-session::0']); }); }); + +describe('search error copy', () => { + it('maps the reasons thread search emits per locale and falls back for the rest', () => { + const zh = getShellControlsCopy('zh-CN').search; + const en = getShellControlsCopy('en').search; + const mapped = ['incognito_active', 'invalid_query', 'aborted', 'disabled', 'provider_error']; + assert.deepEqual(Object.keys(zh.errorByReason).sort(), [...mapped].sort()); + assert.deepEqual(Object.keys(en.errorByReason).sort(), [...mapped].sort()); + assert.equal(searchErrorText('incognito_active', zh), '关闭隐私模式后可以继续按关键词查找历史任务。'); + assert.equal(searchErrorText('invalid_query', zh), '搜索词包含凭据内容,无法搜索。'); + assert.equal(searchErrorText('disabled', zh), '搜索当前不可用。'); + assert.equal(searchErrorText('aborted', en), 'Search was canceled.'); + assert.equal(searchErrorText('provider_error', en), 'Search failed. Try again.'); + assert.equal(searchErrorText('timeout', en), 'Search needs to be refreshed. Try again.'); + assert.equal(searchErrorText('timeout', zh), '搜索服务需要刷新,请重试。'); + assert.equal( + searchErrorText('constructor' as SearchErrorReason, en), + 'Search needs to be refreshed. Try again.', + ); + }); +}); diff --git a/packages/ui/src/search-modal.tsx b/packages/ui/src/search-modal.tsx index a2977254f9..414c232f75 100644 --- a/packages/ui/src/search-modal.tsx +++ b/packages/ui/src/search-modal.tsx @@ -19,8 +19,6 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search'; -import type { UiLocale } from '@maka/core/ui-locale'; -import { generalizedErrorMessageForLocale } from '@maka/core/redaction'; import { CommandPalette as AstryxCommandPalette, CommandPaletteFooter, @@ -55,11 +53,8 @@ interface ThreadSearchSourceInput { canNavigate: boolean; resultsLabel: string; onQueryChange(query: string): void; - onErrorChange( - error: { reason: SearchErrorReason; message: string } | null, - ): void; + onErrorChange(error: { reason: SearchErrorReason } | null): void; onItemsChange(items: SearchItem[]): void; - thrownErrorMessage(error: unknown): string; } export function createThreadSearchSource( @@ -88,10 +83,8 @@ export function createThreadSearchSource( }); if (generation !== requestGeneration) return []; if (!Array.isArray(response)) { - input.onErrorChange({ - reason: response.reason, - message: response.message, - }); + console.error('[search] thread search failed', response); + input.onErrorChange({ reason: response.reason }); input.onItemsChange([]); return []; } @@ -112,10 +105,8 @@ export function createThreadSearchSource( return items; } catch (caught) { if (generation !== requestGeneration) return []; - input.onErrorChange({ - reason: 'provider_error', - message: input.thrownErrorMessage(caught), - }); + console.error('[search] thread search failed', caught); + input.onErrorChange({ reason: 'provider_error' }); input.onItemsChange([]); return []; } @@ -123,12 +114,13 @@ export function createThreadSearchSource( }; } -function searchModalThrownErrorMessage( - error: unknown, - locale: UiLocale, - fallback: string, +export function searchErrorText( + reason: SearchErrorReason, + copy: ReturnType['search'], ): string { - return generalizedErrorMessageForLocale(error, fallback, locale); + return Object.hasOwn(copy.errorByReason, reason) + ? (copy.errorByReason as Record)[reason]! + : copy.errorFallback; } /** @@ -151,10 +143,7 @@ export function SearchModal(props: { }), [copy.resultsLabel], ); - const [error, setError] = useState<{ - reason: SearchErrorReason; - message: string; - } | null>(null); + const [error, setError] = useState<{ reason: SearchErrorReason } | null>(null); const [activeQuery, setActiveQuery] = useState(''); const itemByIdRef = useRef(new Map()); const pendingNavigationRef = useRef<{ @@ -191,26 +180,12 @@ export function SearchModal(props: { items.map((item) => [item.id, item]), ); }, - thrownErrorMessage: (caught) => - searchModalThrownErrorMessage( - caught, - locale, - copy.errorFallback, - ), }), - [ - copy.errorFallback, - copy.resultsLabel, - locale, - props.deps, - props.onNavigateToSession, - ], + [copy.resultsLabel, props.deps, props.onNavigateToSession], ); const emptySearchText = error - ? error.reason === 'incognito_active' - ? copy.privacyDetail - : error.message + ? searchErrorText(error.reason, copy) : copy.empty; return ( diff --git a/packages/ui/src/shell-controls-copy.ts b/packages/ui/src/shell-controls-copy.ts index 87a1b7c79a..f2f09e8455 100644 --- a/packages/ui/src/shell-controls-copy.ts +++ b/packages/ui/src/shell-controls-copy.ts @@ -17,8 +17,14 @@ * under the License. */ +import type { SearchErrorReason } from '@maka/core/search'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +export type ThreadSearchErrorReason = Extract< + SearchErrorReason, + 'incognito_active' | 'invalid_query' | 'aborted' | 'disabled' | 'provider_error' +>; + type ShellControlsCopy = { shared: { close: string; @@ -37,18 +43,11 @@ type ShellControlsCopy = { title: string; conversationsLabel: string; placeholder: string; - clearLabel: string; - statusRegionLabel: string; unavailable: string; - privacyTitle: string; - privacyDetail: string; - errorTitle: string; + errorByReason: Record; errorFallback: string; introduction: string; - searching: string; empty: string; - results(count: number): string; - truncatedResults(count: number): string; resultsLabel: string; }; }; @@ -70,18 +69,17 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { title: '搜索', conversationsLabel: '搜索任务', placeholder: '搜索任务标题和内容…', - clearLabel: '清空搜索', - statusRegionLabel: '搜索状态和结果', unavailable: '当前环境无法连接搜索后端,请稍后重试。', - privacyTitle: '隐私模式已关闭搜索。', - privacyDetail: '关闭隐私模式后可以继续按关键词查找历史任务。', - errorTitle: '搜索暂时无法完成。', + errorByReason: { + incognito_active: '关闭隐私模式后可以继续按关键词查找历史任务。', + invalid_query: '搜索词包含凭据内容,无法搜索。', + aborted: '搜索已取消。', + disabled: '搜索当前不可用。', + provider_error: '搜索服务出错,请重试。', + }, errorFallback: '搜索服务需要刷新,请重试。', introduction: '开始输入以按关键词查找历史任务。结果只包含任务标题和内容文本,不进入网络。', - searching: '正在搜索…', empty: '没有匹配的任务标题或内容。换个关键词试试。', - results: (count: number) => `找到 ${count} 条匹配`, - truncatedResults: (count: number) => `结果较多,已显示前 ${count} 条`, resultsLabel: '搜索结果', }, }, @@ -101,18 +99,17 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { title: '搜尋', conversationsLabel: '搜尋任務', placeholder: '搜尋任務標題和內容…', - clearLabel: '清空搜尋', - statusRegionLabel: '搜尋狀態和結果', unavailable: '目前環境無法連線搜尋後端,請稍後重試。', - privacyTitle: '隱私模式已關閉搜尋。', - privacyDetail: '關閉隱私模式後可以繼續按關鍵詞查詢歷史任務。', - errorTitle: '搜尋暫時無法完成。', + errorByReason: { + incognito_active: '關閉隱私模式後可以繼續按關鍵詞查詢歷史任務。', + invalid_query: '搜尋詞包含憑證內容,無法搜尋。', + aborted: '搜尋已取消。', + disabled: '搜尋目前無法使用。', + provider_error: '搜尋服務發生錯誤,請重試。', + }, errorFallback: '搜尋服務需要重新整理,請重試。', introduction: '開始輸入以按關鍵詞查詢歷史任務。結果只包含任務標題和內容文本,不進入網路。', - searching: '正在搜尋…', empty: '沒有符合的任務標題或內容。換個關鍵詞試試。', - results: (count: number) => `找到 ${count} 條符合`, - truncatedResults: (count: number) => `結果較多,已顯示前 ${count} 條`, resultsLabel: '搜尋結果', }, }, @@ -132,19 +129,18 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { title: 'Search', conversationsLabel: 'Search tasks', placeholder: 'Search task titles and content…', - clearLabel: 'Clear search', - statusRegionLabel: 'Search status and results', unavailable: 'Search is unavailable in the current environment. Try again later.', - privacyTitle: 'Search is disabled in privacy mode.', - privacyDetail: 'Turn off privacy mode to search previous tasks by keyword.', - errorTitle: 'Search could not be completed.', + errorByReason: { + incognito_active: 'Turn off privacy mode to search previous tasks by keyword.', + invalid_query: 'The query contains credential material and cannot be searched.', + aborted: 'Search was canceled.', + disabled: 'Search is unavailable right now.', + provider_error: 'Search failed. Try again.', + }, errorFallback: 'Search needs to be refreshed. Try again.', introduction: 'Start typing to search previous tasks by keyword. Results include local task titles and content only and are not sent over the network.', - searching: 'Searching…', empty: 'No matching task titles or content. Try another keyword.', - results: (count: number) => `${count} ${count === 1 ? 'match' : 'matches'}`, - truncatedResults: (count: number) => `Many results; showing the first ${count}`, resultsLabel: 'Search results', }, },