Skip to content

Commit 5857cc4

Browse files
committed
fix(desktop): recover managed Runtime Host startup
1 parent b9128e1 commit 5857cc4

9 files changed

Lines changed: 508 additions & 8 deletions

apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
showFatalStartupError,
2929
showMainRendererProcessGoneDialog,
3030
showMessageBoxWithDiagnostics,
31+
showRuntimeHostStartupRecoveryDialog,
3132
} from '../native-diagnostic-dialog.js';
3233

3334
const diagnosticEnvironment = () => ({
@@ -149,3 +150,32 @@ test('main Renderer loss keeps Copy Diagnostics auxiliary to recovery', async ()
149150
assert.match(clipboard, /Recent main-process logs \(1\)/);
150151
assert.doesNotMatch(clipboard, /very-secret-token/);
151152
});
153+
154+
test('managed Host recovery preserves the workspace and confirms active-work interruption', async () => {
155+
let shown: MessageBoxOptions | undefined;
156+
const decision = await showRuntimeHostStartupRecoveryDialog(
157+
{
158+
startupError: new Error('managed service unavailable'),
159+
repairError: new Error('service update failed'),
160+
activeTasks: true,
161+
},
162+
{
163+
locale: 'en',
164+
copyDiagnostics() {},
165+
showMessageBox: async (options): Promise<MessageBoxReturnValue> => {
166+
shown = options;
167+
return { response: 0, checkboxChecked: false };
168+
},
169+
},
170+
);
171+
172+
assert.equal(decision, 'repair');
173+
assert.deepEqual(shown?.buttons, [
174+
'Repair and Restart Host',
175+
'Exit',
176+
'Copy Diagnostics',
177+
]);
178+
assert.match(shown?.detail ?? '', /workspace, Host identity, credentials, and settings/);
179+
assert.match(shown?.detail ?? '', /interrupt that work/);
180+
assert.match(shown?.detail ?? '', /service update failed/);
181+
});

apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,62 @@ test('keeps the managed service visible when Direct peer support is unavailable'
234234
assert.equal(snapshot.managedService, true);
235235
});
236236

237+
test('repairs an existing managed Host with the current setup package and restarts it when already current', async (t) => {
238+
const base = await mkdtemp(join(tmpdir(), 'maka-local-managed-repair-'));
239+
t.after(() => rm(base, { recursive: true, force: true }));
240+
const clientDataRoot = join(base, 'client');
241+
const rootPath = join(clientDataRoot, 'workspaces', 'default');
242+
const rootId = 'a'.repeat(64);
243+
const setupPackage = { kind: 'npm' as const, specifier: 'maka-agent@0.2.0' };
244+
await mkdir(rootPath, { recursive: true });
245+
await writeManagedLifecycle(clientDataRoot, rootPath, rootId);
246+
const actions: string[] = [];
247+
const service = createDesktopLocalRuntimeHostRemoteAccess({
248+
ipcMain: { handle() {}, removeHandler() {} },
249+
clientDataRoot,
250+
rootPath,
251+
rootId,
252+
directPeerAvailable: true,
253+
manager: () => undefined,
254+
resolveSetupPackage: async () => setupPackage,
255+
operator: {
256+
async runUpdate(input: {
257+
readonly setupPackage: unknown;
258+
readonly target: { readonly rootId: string; readonly deploymentId?: string };
259+
readonly allowInterruptActiveTasks?: boolean;
260+
}) {
261+
actions.push('update');
262+
assert.equal(input.setupPackage, setupPackage);
263+
assert.equal(input.target.rootId, rootId);
264+
assert.equal(input.target.deploymentId, RECOVERY_DEPLOYMENT_ID);
265+
assert.equal(input.allowInterruptActiveTasks, undefined);
266+
return {
267+
kind: 'result',
268+
action: 'update',
269+
update: { kind: 'already_current', version: '0.2.0' },
270+
} as never;
271+
},
272+
async runService(input: {
273+
readonly action: string;
274+
readonly target: { readonly rootId: string; readonly deploymentId?: string };
275+
readonly allowInterruptActiveTasks?: boolean;
276+
}) {
277+
actions.push(input.action);
278+
assert.equal(input.action, 'restart');
279+
assert.equal(input.target.rootId, rootId);
280+
assert.equal(input.target.deploymentId, RECOVERY_DEPLOYMENT_ID);
281+
assert.equal(input.allowInterruptActiveTasks, undefined);
282+
return { kind: 'result', action: 'restart' } as never;
283+
},
284+
async close() {},
285+
} as unknown as ReturnType<typeof createDesktopRuntimeHostLocalOperator>,
286+
});
287+
t.after(() => service.close());
288+
289+
assert.deepEqual(await service.repairManagedStartup(), { kind: 'repaired' });
290+
assert.deepEqual(actions, ['update', 'restart']);
291+
});
292+
237293
test('replaces a conflicting supervised Host through canonical authority without a receipt', async (t) => {
238294
const base = await mkdtemp(join(tmpdir(), 'maka-local-managed-conflict-'));
239295
t.after(() => rm(base, { recursive: true, force: true }));
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import assert from "node:assert/strict";
21+
import test from "node:test";
22+
import { runtimeHostStartupError } from "@maka/runtime-host/client";
23+
import { startDesktopRuntimeHostWithRecovery } from "../runtime-host-startup-recovery.js";
24+
25+
test("repairs a managed Host once and resumes startup without asking the user", async () => {
26+
let starts = 0;
27+
const repairModes: boolean[] = [];
28+
let prompts = 0;
29+
30+
const result = await startDesktopRuntimeHostWithRecovery({
31+
start: async () => {
32+
starts += 1;
33+
if (starts === 1)
34+
throw runtimeHostStartupError("managed_root_requires_operator");
35+
return "ready";
36+
},
37+
repair: async (allowInterruptActiveTasks) => {
38+
repairModes.push(allowInterruptActiveTasks);
39+
return { kind: "repaired" };
40+
},
41+
prompt: async () => {
42+
prompts += 1;
43+
return "exit";
44+
},
45+
});
46+
47+
assert.equal(result, "ready");
48+
assert.equal(starts, 2);
49+
assert.deepEqual(repairModes, [false]);
50+
assert.equal(prompts, 0);
51+
});
52+
53+
test("requires an explicit decision before interrupting active Host work", async () => {
54+
let starts = 0;
55+
const repairModes: boolean[] = [];
56+
const prompts: boolean[] = [];
57+
58+
const result = await startDesktopRuntimeHostWithRecovery({
59+
start: async () => {
60+
starts += 1;
61+
if (starts === 1)
62+
throw runtimeHostStartupError("managed_root_requires_operator");
63+
return "ready";
64+
},
65+
repair: async (allowInterruptActiveTasks) => {
66+
repairModes.push(allowInterruptActiveTasks);
67+
return allowInterruptActiveTasks
68+
? { kind: "repaired" }
69+
: { kind: "active_tasks" };
70+
},
71+
prompt: async (input) => {
72+
prompts.push(input.activeTasks);
73+
return "repair";
74+
},
75+
});
76+
77+
assert.equal(result, "ready");
78+
assert.deepEqual(repairModes, [false, true]);
79+
assert.deepEqual(prompts, [true]);
80+
});
81+
82+
test("does not offer managed repair for an unrelated startup failure", async () => {
83+
const failure = new Error("renderer prerequisites failed");
84+
let repairs = 0;
85+
let prompts = 0;
86+
87+
await assert.rejects(
88+
startDesktopRuntimeHostWithRecovery({
89+
start: async () => {
90+
throw failure;
91+
},
92+
repair: async () => {
93+
repairs += 1;
94+
return { kind: "repaired" };
95+
},
96+
prompt: async () => {
97+
prompts += 1;
98+
return "repair";
99+
},
100+
}),
101+
failure,
102+
);
103+
assert.equal(repairs, 0);
104+
assert.equal(prompts, 0);
105+
});

apps/desktop/src/main/native-diagnostic-dialog.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ interface FatalStartupDiagnosticDialogDeps {
4343
readonly showMessageBox: (options: MessageBoxOptions) => Promise<MessageBoxReturnValue>;
4444
}
4545

46+
export interface RuntimeHostStartupRecoveryDialogInput {
47+
readonly startupError: Error;
48+
readonly repairError?: Error;
49+
readonly activeTasks: boolean;
50+
}
51+
4652
export async function showMessageBoxWithDiagnostics(
4753
options: MessageBoxOptions,
4854
deps: DiagnosticDialogDeps,
@@ -114,6 +120,36 @@ export async function showMainRendererProcessGoneDialog(
114120
return result.response === 0 ? 'relaunch' : 'exit';
115121
}
116122

123+
export async function showRuntimeHostStartupRecoveryDialog(
124+
input: RuntimeHostStartupRecoveryDialogInput,
125+
deps: DiagnosticDialogDeps,
126+
): Promise<'repair' | 'exit'> {
127+
const copy = RUNTIME_HOST_STARTUP_RECOVERY_COPY[deps.locale];
128+
const detail = [
129+
copy.detail,
130+
input.activeTasks ? copy.activeTasks : undefined,
131+
input.repairError
132+
? `${copy.repairFailed}\n${input.repairError.message}`
133+
: undefined,
134+
]
135+
.filter(Boolean)
136+
.join('\n\n');
137+
const result = await showMessageBoxWithDiagnostics(
138+
{
139+
type: 'warning',
140+
title: copy.title,
141+
message: copy.message,
142+
detail,
143+
buttons: [input.activeTasks ? copy.repairAndRestart : copy.repair, copy.exit],
144+
defaultId: 0,
145+
cancelId: 1,
146+
noLink: true,
147+
},
148+
deps,
149+
);
150+
return result.response === 0 ? 'repair' : 'exit';
151+
}
152+
117153
async function copyDiagnostics(
118154
copy: () => void | Promise<void>,
119155
locale: UiLocale,
@@ -195,3 +231,29 @@ const MAIN_RENDERER_GONE_COPY = {
195231
exit: '退出',
196232
},
197233
} as const;
234+
235+
const RUNTIME_HOST_STARTUP_RECOVERY_COPY = {
236+
en: {
237+
title: 'Maka needs to repair Runtime Host',
238+
message: 'The Runtime Host for this workspace could not start.',
239+
detail:
240+
'Maka can repair the managed Runtime Host selected by this Desktop. Your workspace, Host identity, credentials, and settings will be preserved.',
241+
activeTasks:
242+
'The Host may still own active work. Continuing can interrupt that work before the Host restarts.',
243+
repairFailed: 'The previous repair attempt did not finish:',
244+
repair: 'Repair Runtime Host',
245+
repairAndRestart: 'Repair and Restart Host',
246+
exit: 'Exit',
247+
},
248+
zh: {
249+
title: 'Maka 需要修复 Runtime Host',
250+
message: '管理此工作区的 Runtime Host 无法启动。',
251+
detail:
252+
'Maka 可以修复此 Desktop 选择的托管 Runtime Host。工作区、Host 身份、凭证和设置都会保留。',
253+
activeTasks: 'Host 可能仍有正在运行的任务。继续会先中断这些任务,再重启 Host。',
254+
repairFailed: '上一次修复未能完成:',
255+
repair: '修复 Runtime Host',
256+
repairAndRestart: '修复并重启 Host',
257+
exit: '退出',
258+
},
259+
} as const;

apps/desktop/src/main/runtime-host-boot.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ import {
116116
import {
117117
showMainRendererProcessGoneDialog,
118118
showMessageBoxWithDiagnostics,
119+
showRuntimeHostStartupRecoveryDialog,
119120
} from "./native-diagnostic-dialog.js";
120121
import {
121122
resolveDesktopSessionWorkspace,
@@ -168,6 +169,10 @@ import {
168169
startRuntimeHostDesktopManager,
169170
type RuntimeHostDesktopManager,
170171
} from "./runtime-host-desktop-manager.js";
172+
import {
173+
DesktopRuntimeHostStartupRecoveryCancelledError,
174+
startDesktopRuntimeHostWithRecovery,
175+
} from "./runtime-host-startup-recovery.js";
171176
import { buildRuntimeHostQuitFailureDialog } from "./runtime-host-quit-copy.js";
172177
import { createRuntimeHostUpgradePrompts } from "./runtime-host-upgrade-dialog.js";
173178
import { registerRuntimeHostMemoryIpc } from "./runtime-host-memory-ipc-main.js";
@@ -850,8 +855,8 @@ registerNotificationsIpc({
850855
});
851856

852857
const sessionCopyOwnerProcessId = randomUUID();
853-
await localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart();
854-
runtimeHostManager = await startRuntimeHostDesktopManager(
858+
let runtimeHostStartupSettled = false;
859+
const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager(
855860
{
856861
rootPath: workspaceRoot,
857862
clientInstanceId: runtimeHostClientInstanceId,
@@ -1074,6 +1079,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager(
10741079
resolveLocalHostReplacement: (registration, signal) =>
10751080
localRuntimeHostRemoteAccess.resolveConflictingHostReplacement(registration, signal),
10761081
onFatalError: (error, target) => {
1082+
if (!runtimeHostStartupSettled && target.profile.kind === "local") return;
10771083
if (error instanceof RuntimeHostUpgradeCancelledError) {
10781084
if (target.profile.kind === "local") app.quit();
10791085
return;
@@ -1082,13 +1088,53 @@ runtimeHostManager = await startRuntimeHostDesktopManager(
10821088
if (target.profile.kind === "local") app.quit();
10831089
},
10841090
},
1085-
).catch((error: unknown) => {
1086-
if (error instanceof RuntimeHostUpgradeCancelledError) {
1091+
);
1092+
runtimeHostManager = await startDesktopRuntimeHostWithRecovery({
1093+
start: async () => {
1094+
await localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart();
1095+
return startLocalRuntimeHostManager();
1096+
},
1097+
repair: async (allowInterruptActiveTasks) => {
1098+
console.warn('[runtime-host] repairing the managed Local Host before startup');
1099+
const result = await localRuntimeHostRemoteAccess.repairManagedStartup({
1100+
allowInterruptActiveTasks,
1101+
});
1102+
console.log(`[runtime-host] managed Local Host repair result: ${result.kind}`);
1103+
return result;
1104+
},
1105+
prompt: async (input) => {
1106+
console.error('[runtime-host] managed Local Host startup recovery requires attention:', {
1107+
startupError: input.startupError,
1108+
repairError: input.repairError,
1109+
activeTasks: input.activeTasks,
1110+
});
1111+
return showRuntimeHostStartupRecoveryDialog(input, {
1112+
locale: desktopLocale.current(),
1113+
showMessageBox: (options) => dialog.showMessageBox(options),
1114+
copyDiagnostics: () =>
1115+
copyDesktopDiagnosticReport(
1116+
desktopDiagnostics,
1117+
createDesktopStartupDiagnosticInput({
1118+
title: 'Runtime Host startup recovery',
1119+
description: input.startupError.message,
1120+
details: [input.startupError.stack, input.repairError?.stack]
1121+
.filter(Boolean)
1122+
.join('\n\n'),
1123+
}),
1124+
),
1125+
});
1126+
},
1127+
}).catch((error: unknown) => {
1128+
if (
1129+
error instanceof RuntimeHostUpgradeCancelledError ||
1130+
error instanceof DesktopRuntimeHostStartupRecoveryCancelledError
1131+
) {
10871132
app.quit();
10881133
return new Promise<never>(() => undefined);
10891134
}
10901135
throw error;
10911136
});
1137+
runtimeHostStartupSettled = true;
10921138
wireLifecycle();
10931139
runtimeHostManager.setDefaultProfile(runtimeHostStartup.preferences.defaultProfileId);
10941140
await guestSessionMountService.start().catch((error: unknown) => {

0 commit comments

Comments
 (0)