Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions apps/desktop/src/main/__tests__/expected-failure-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* 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 { readFileSync } from 'node:fs';
import { test } from 'node:test';
import {
getProviderSettingsCopy,
providerPanelActionErrorMessage,
} from '../../renderer/features/connection-settings/index.js';
import {
getSettingsProjectsCopy,
runtimeHostManagementErrorMessage,
} 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'),
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.',
});
// Commit-outcome-unknown codes fold into the generic log-pointer (managed-deployment.ts, update-policy-store.ts).
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',
]) {
assert.deepEqual(rendered(code), unknownFallback);
}
});

function extractErrorCodes(source: string, className: string): string[] {
const classIndex = source.indexOf(`class ${className}`);
assert.ok(classIndex !== -1, `expected ${className} in CLI source`);
const classBlock = source.slice(classIndex);
const codeIndex = classBlock.indexOf('readonly code:');
const messageIndex = classBlock.indexOf('message: string');
assert.ok(codeIndex !== -1 && messageIndex !== -1 && codeIndex < messageIndex);
const codes = classBlock
.slice(codeIndex, messageIndex)
.match(/'([a-z_]+)'/gu)
?.map((literal) => literal.slice(1, -1));
assert.ok(codes && codes.length > 0, `expected the ${className} code union`);
return codes;
}

function readCliSource(fileName: string): string {
return readFileSync(
new URL(`../../../../../packages/cli/src/${fileName}`, import.meta.url),
'utf8',
);
}

// [source file, error class] pairs presented distinctly. The two inline codes
// below have no error class (reconciliation, update-command).
const CLI_ERROR_SOURCES = [
['runtime-host-service-manager.ts', 'RuntimeHostServiceManagerError'],
['runtime-host-registry-update.ts', 'RuntimeHostUpdateDiscoveryError'],
['runtime-host-update-package.ts', 'RuntimeHostUpdatePackageError'],
['runtime-host-update-policy-store.ts', 'RuntimeHostUpdatePolicyError'],
] as const;

test('presenter maps every management code the CLI can emit', () => {
const extracted = CLI_ERROR_SOURCES.flatMap(([file, className]) =>
extractErrorCodes(readCliSource(file), className),
);
const codes = [...new Set([...extracted, 'update_policy_changed', 'update_not_admitted'])];
assert.ok(codes.length >= 20, `expected the CLI code set, got ${codes.length}`);
const presented = Object.keys(getSettingsProjectsCopy('en').runtimeHost.managementError);
assert.deepEqual(codes.filter((code) => !presented.includes(code)), [
'update_policy_commit_outcome_unknown',
]);
// Deployment-internal codes (managed-deployment.ts, …) intentionally fall back to unknown.
});

test('provider action errors never echo a raw Chinese message', () => {
const error = new Error('连接失败,请稍后重试');
assert.equal(
providerPanelActionErrorMessage(error, 'zh'),
getProviderSettingsCopy('zh').shared.actionFallback,
);
assert.equal(
providerPanelActionErrorMessage(error, 'en'),
getProviderSettingsCopy('en').shared.actionFallback,
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ export function providerPanelActionErrorMessage(error: unknown, locale: UiLocale
const cleaned = redactSecrets(cleanErrorMessage(error)).trim();
const known = (shared.lastTest as Readonly<Record<string, string>>)[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 (/[\u3400-\u9fff]/.test(cleaned)) return cleaned;
if (/connection_stale|Unable to delete Connection: connection_stale/i.test(cleaned)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: this is the exact pattern the PR title targets, left in the file the PR edits. Main throws new Error('Unable to delete Connection: connection_stale') at runtime-host-connections-ipc-main.ts:333 and the renderer regexes the code back out of the sentence. Same for the lastTest lookup on line 38, which is keyed on lowercased message text. Not asking you to fix it here, but the body should say that this surface and artifact-pane.tsx only lost their CJK passthrough this round and are still raw-message classified, otherwise it reads as if the whole path is code-driven now.

return locale === 'zh'
? '连接状态已更新,请刷新列表后再删除。'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -634,8 +634,7 @@ function artifactActionErrorMessage(error: unknown, locale: UiLocale, copy: Arti
const classified = locale === 'zh'
? generalizedErrorMessageChinese(new Error(raw), '')
: generalizedErrorMessage(new Error(raw), '');
if (classified) return classified;
return locale === 'zh' && /[\u4e00-\u9fff]/.test(raw) ? raw : copy.pane.actionFailed;
return classified || copy.pane.actionFailed;
}

function KindIcon(props: { kind: ArtifactKind }) {
Expand Down
96 changes: 96 additions & 0 deletions apps/desktop/src/renderer/locales/settings-projects-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,35 @@

import type { UiCatalog, UiLocale } from '@maka/core/ui-locale';

// The operator CLI keeps code an open string for version skew; this is the subset Desktop presents distinctly.
export type RuntimeHostManagementErrorCode =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: this re-declares a code list the producers already own, so there are now two spellings of it and no compiler link between them. The four CLI error classes keep their unions inline in the constructor (packages/cli/src/runtime-host-service-manager.ts:287), and the only thing tying the two together is extractErrorCodes() scraping that source with a regex. A rename on the CLI side fails the desktop suite with an opaque assertion, and if either anchor moves (readonly code: / message: string, e.g. someone adds a field between them) the scrape silently stops covering anything while the test stays green and new codes fall to unknown.

@maka/runtime-host/operator is a seam both sides already depend on: packages/cli imports from it, and bridge-contract.d.ts already pulls types from it. Declaring the union next to SERVICE_ERROR_SCHEMA there, having the CLI error classes reference it, and typing managementError as Record<Code | 'unknown', string> gets exhaustiveness from tsc and lets extractErrorCodes/readCliSource be deleted.

| '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'
| 'target_unavailable'
| 'registry_unavailable'
| 'invalid_registry_metadata'
| 'package_download_failed'
| 'package_integrity_mismatch'
| 'invalid_package'
| 'invalid_update_policy'
| 'update_policy_write_failed'
| 'update_policy_changed'
| 'update_not_admitted';

export type SettingsProjectsCopy = {
runtimeHost: {
title: string;
Expand Down Expand Up @@ -234,6 +263,7 @@ export type SettingsProjectsCopy = {
uninstallConfirm: string;
uninstallRetained(path: string): string;
managementActionFailed: string;
managementError: Record<RuntimeHostManagementErrorCode | 'unknown', string>;
managementReconnectFailed: string;
manageAccess: string;
accessTitle: string;
Expand Down Expand Up @@ -553,6 +583,35 @@ 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 部署文件',
target_unavailable: '找不到所选版本',
registry_unavailable: '无法连接更新源,请检查网络',
invalid_registry_metadata: '更新源返回了无效的版本信息',
package_download_failed: '更新包下载失败',
package_integrity_mismatch: '更新包校验失败',
invalid_package: '更新包无效',
invalid_update_policy: '更新策略无效',
update_policy_write_failed: '无法保存更新策略',
update_policy_changed: '更新策略已变化,请刷新后重试',
update_not_admitted: '当前版本不允许此更新',
unknown: '请查看服务日志了解详情',
},
managementReconnectFailed: '更改已应用,但 Desktop 未能重新连接',
manageAccess: '管理访问权限',
accessTitle: '访问权限',
Expand Down Expand Up @@ -872,6 +931,38 @@ 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.',
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_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',
Expand Down Expand Up @@ -948,3 +1039,8 @@ 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 (messages as Record<string, string>)[code] ?? messages.unknown;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: this ?? reaches Object.prototype. The catalog is a plain object literal, so a code of constructor, toString or valueOf indexes to the inherited function, ?? never fires, and setError() renders function Object() { [native code] } into the banner. The wire schema bounds the code's length but not its characters, and the code is chosen by the operator side, so this is reachable across a trust boundary rather than only by a local bug. The PR body says this uses an Object.hasOwn fallback; the code does not. Smallest fix: return Object.hasOwn(messages, code) ? (messages as Record<string, string>)[code]! : messages.unknown; which is the shape already used in model-connection-errors.ts:51. searchErrorText in packages/ui/src/search-modal.tsx:128 has the same shape and can take the same fix.

}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -188,7 +191,9 @@ export function RuntimeHostManagementDialog(props: {
reconcileDirectoryPolicy(response.service);
shouldLoadUpdatePolicy = response.service.state !== 'not_installed';
}
else if (response.kind === 'error') setError(response.error.message);
else if (response.kind === 'error') {
setError(runtimeHostManagementErrorMessage(response.error.code, locale));
}
else setUninstalledRoot(response.retainedStateRoot);
} catch (failure) {
if (!disposed) setError(settingsActionErrorMessage(failure, locale));
Expand Down Expand Up @@ -229,6 +234,12 @@ export function RuntimeHostManagementDialog(props: {
if (logs) logs.scrollTop = logs.scrollHeight;
}, [result]);

function reportManagementError(code: string): string {
const message = runtimeHostManagementErrorMessage(code, locale);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: response.error.message is now dropped entirely here and at the load path around line 193, with nothing logged. That message is the only place the operator says which path or unit failed (deployment_io_failed carries the EACCES path, for example), so a user hitting this has nothing to report and a maintainer has nothing to read. This PR added console.error for the reconnect path at line 599 and for thread search at search-modal.tsx:112, so this is also inconsistent with its own choices. Smallest fix: console.error('[runtime-host] management failed', response.error); inside reportManagementError, and the same at the load path.

toast.error(copy.managementActionFailed, message);
return message;
}

async function run(
action: DesktopRuntimeHostManagementAction,
allowInterruptActiveTasks = false,
Expand Down Expand Up @@ -256,8 +267,7 @@ export function RuntimeHostManagementDialog(props: {
return;
}
setUpdatePolicy(undefined);
setError(response.error.message);
toast.error(copy.managementActionFailed, response.error.message);
setError(reportManagementError(response.error.code));
return;
}
if (response.kind === 'uninstalled') {
Expand Down Expand Up @@ -375,8 +385,7 @@ export function RuntimeHostManagementDialog(props: {
);
if (response.kind === 'error') {
setUpdatePolicy(undefined);
setError(response.error.message);
toast.error(copy.managementActionFailed, response.error.message);
setError(reportManagementError(response.error.code));
return;
}
if (response.kind === 'uninstalled') {
Expand Down Expand Up @@ -461,8 +470,7 @@ export function RuntimeHostManagementDialog(props: {
allowInterruptActiveTasks,
);
if (response.kind === 'error') {
setError(response.error.message);
toast.error(copy.managementActionFailed, response.error.message);
setError(reportManagementError(response.error.code));
return;
}
if (response.kind === 'uninstalled' || response.action !== 'configure') {
Expand Down Expand Up @@ -545,8 +553,7 @@ export function RuntimeHostManagementDialog(props: {
const response = await window.maka.runtimeHostManagement.reconcileUpdate(target.id);
if (response.kind === 'error') {
setUpdatePolicy(undefined);
setUpdatePolicyError(response.error.message);
toast.error(copy.managementActionFailed, response.error.message);
setUpdatePolicyError(reportManagementError(response.error.code));
return;
}
setLastUpdateOutcome(response.reconciliation);
Expand Down Expand Up @@ -584,12 +591,13 @@ export function RuntimeHostManagementDialog(props: {
}

function applyReconnectWarning(
reconnectError: { readonly message: string } | undefined,
reconnectError: { readonly code: string; readonly message: string } | undefined,
): void {
setReconnectWarning(reconnectError?.message);
if (reconnectError) {
toast.warning(copy.managementReconnectFailed, reconnectError.message);
}
setReconnectWarning(reconnectError ? copy.managementReconnectFailed : undefined);
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<void> {
Expand Down Expand Up @@ -669,8 +677,7 @@ export function RuntimeHostManagementDialog(props: {
{reconnectWarning ? (
<Banner
status="warning"
title={copy.managementReconnectFailed}
description={reconnectWarning}
title={reconnectWarning}
/>
) : null}
{confirmation?.kind === 'configureDirectories' ? (
Expand Down
Loading