From 544cd49d857c8950947bf118aca6bf3dce926fa1 Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Thu, 3 Sep 2026 02:00:21 -0700 Subject: [PATCH] fix(i18n): map expected failure codes instead of raw messages Runtime Host management and thread search already carry stable failure codes, so the renderer maps them through locale catalogs with an explicit unknown fallback. The CJK sniffs in the provider and artifact error presenters guarded producers that no longer throw Chinese copy. Generated-by: Claude Code --- .../__tests__/expected-failure-copy.test.ts | 88 ++++++++++++ .../provider-panel-shared.ts | 3 - .../workbar/tools/artifacts/artifact-pane.tsx | 3 +- .../locales/settings-projects-copy.ts | 135 ++++++++++++++++++ .../runtime-host-management-dialog.tsx | 64 ++++----- .../cli/src/runtime-host-registry-update.ts | 6 +- .../cli/src/runtime-host-service-manager.ts | 7 +- .../cli/src/runtime-host-update-package.ts | 6 +- .../src/runtime-host-update-policy-store.ts | 7 +- packages/runtime-host/src/operator/index.ts | 1 + .../src/operator/service-management-frame.ts | 32 +++++ .../src/__tests__/search-modal-source.test.ts | 29 +++- packages/ui/src/search-modal.tsx | 53 ++----- packages/ui/src/shell-controls-copy.ts | 60 ++++---- 14 files changed, 376 insertions(+), 118 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/expected-failure-copy.test.ts 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', }, },