Skip to content
Merged
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
31 changes: 31 additions & 0 deletions apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
showFatalStartupError,
showMainRendererProcessGoneDialog,
showMessageBoxWithDiagnostics,
showRuntimeHostStartupRecoveryDialog,
} from '../native-diagnostic-dialog.js';

const diagnosticEnvironment = () => ({
Expand Down Expand Up @@ -149,3 +150,33 @@ test('main Renderer loss keeps Copy Diagnostics auxiliary to recovery', async ()
assert.match(clipboard, /Recent main-process logs \(1\)/);
assert.doesNotMatch(clipboard, /very-secret-token/);
});

test('managed Host recovery preserves the workspace and confirms active-work interruption', async () => {
let shown: MessageBoxOptions | undefined;
const decision = await showRuntimeHostStartupRecoveryDialog(
{
startupError: new Error('managed service unavailable'),
repairError: new Error('service update failed'),
activeTasks: true,
},
{
locale: 'en',
copyDiagnostics() {},
showMessageBox: async (options): Promise<MessageBoxReturnValue> => {
shown = options;
return { response: 0, checkboxChecked: false };
},
},
);

assert.equal(decision, 'repair');
assert.deepEqual(shown?.buttons, [
'Repair and Restart Host',
'Exit',
'Copy Diagnostics',
]);
assert.match(shown?.detail ?? '', /workspace, Host identity, credentials, and settings/);
assert.match(shown?.detail ?? '', /automatic update compatibility cannot be confirmed/);
assert.match(shown?.detail ?? '', /interrupt that work/);
assert.match(shown?.detail ?? '', /service update failed/);
});
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,66 @@ test('keeps the managed service visible when Direct peer support is unavailable'
assert.equal(snapshot.managedService, true);
});

test('repairs an existing managed Host with the current setup package and restarts it when already current', async (t) => {
const base = await mkdtemp(join(tmpdir(), 'maka-local-managed-repair-'));
t.after(() => rm(base, { recursive: true, force: true }));
const clientDataRoot = join(base, 'client');
const rootPath = join(clientDataRoot, 'workspaces', 'default');
const rootId = 'a'.repeat(64);
const setupPackage = { kind: 'npm' as const, specifier: 'maka-agent@0.2.0' };
await mkdir(rootPath, { recursive: true });
await writeManagedLifecycle(clientDataRoot, rootPath, rootId);
const actions: string[] = [];
const service = createDesktopLocalRuntimeHostRemoteAccess({
ipcMain: { handle() {}, removeHandler() {} },
clientDataRoot,
rootPath,
rootId,
directPeerAvailable: true,
manager: () => undefined,
resolveSetupPackage: async () => setupPackage,
operator: {
async runUpdate(input: {
readonly setupPackage: unknown;
readonly target: { readonly rootId: string; readonly deploymentId?: string };
readonly allowManualUpdate?: boolean;
readonly allowInterruptActiveTasks?: boolean;
}) {
actions.push('update');
assert.equal(input.setupPackage, setupPackage);
assert.equal(input.target.rootId, rootId);
assert.equal(input.target.deploymentId, RECOVERY_DEPLOYMENT_ID);
assert.equal(input.allowManualUpdate, true);
assert.equal(input.allowInterruptActiveTasks, undefined);
return {
kind: 'result',
action: 'update',
update: { kind: 'already_current', version: '0.2.0' },
} as never;
},
async runService(input: {
readonly action: string;
readonly target: { readonly rootId: string; readonly deploymentId?: string };
readonly allowInterruptActiveTasks?: boolean;
}) {
actions.push(input.action);
assert.equal(input.action, 'restart');
assert.equal(input.target.rootId, rootId);
assert.equal(input.target.deploymentId, RECOVERY_DEPLOYMENT_ID);
assert.equal(input.allowInterruptActiveTasks, undefined);
return { kind: 'result', action: 'restart' } as never;
},
async close() {},
} as unknown as ReturnType<typeof createDesktopRuntimeHostLocalOperator>,
});
t.after(() => service.close());

assert.deepEqual(await service.repairManagedStartup({ allowManualUpdate: true }), {
kind: 'repaired',
});
assert.deepEqual(actions, ['update', 'restart']);
});

test('replaces a conflicting supervised Host through canonical authority without a receipt', async (t) => {
const base = await mkdtemp(join(tmpdir(), 'maka-local-managed-conflict-'));
t.after(() => rm(base, { recursive: true, force: true }));
Expand Down
118 changes: 118 additions & 0 deletions apps/desktop/src/main/__tests__/runtime-host-startup-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* 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 { runtimeHostStartupError } from "@maka/runtime-host/client";
import { startDesktopRuntimeHostWithRecovery } from "../runtime-host-startup-recovery.js";

test("repairs a managed Host once and resumes startup without asking the user", async () => {
let starts = 0;
const repairModes: Array<{
readonly allowManualUpdate: boolean;
readonly allowInterruptActiveTasks: boolean;
}> = [];
let prompts = 0;

const result = await startDesktopRuntimeHostWithRecovery({
start: async () => {
starts += 1;
if (starts === 1)
throw runtimeHostStartupError("managed_root_requires_operator");
return "ready";
},
repair: async (authority) => {
repairModes.push(authority);
return { kind: "repaired" };
},
prompt: async () => {
prompts += 1;
return "exit";
},
});

assert.equal(result, "ready");
assert.equal(starts, 2);
assert.deepEqual(repairModes, [
{ allowManualUpdate: false, allowInterruptActiveTasks: false },
]);
assert.equal(prompts, 0);
});

test("separates manual update consent from active-work interruption", async () => {
let starts = 0;
const repairModes: Array<{
readonly allowManualUpdate: boolean;
readonly allowInterruptActiveTasks: boolean;
}> = [];
const prompts: boolean[] = [];

const result = await startDesktopRuntimeHostWithRecovery({
start: async () => {
starts += 1;
if (starts === 1)
throw runtimeHostStartupError("managed_root_requires_operator");
return "ready";
},
repair: async (authority) => {
repairModes.push(authority);
if (!authority.allowManualUpdate) throw new Error("manual update confirmation required");
return authority.allowInterruptActiveTasks
? { kind: "repaired" }
: { kind: "active_tasks" };
},
prompt: async (input) => {
prompts.push(input.activeTasks);
return "repair";
},
});

assert.equal(result, "ready");
assert.deepEqual(repairModes, [
{ allowManualUpdate: false, allowInterruptActiveTasks: false },
{ allowManualUpdate: true, allowInterruptActiveTasks: false },
{ allowManualUpdate: true, allowInterruptActiveTasks: true },
]);
assert.deepEqual(prompts, [false, true]);
});

test("does not offer managed repair for an unrelated startup failure", async () => {
const failure = new Error("renderer prerequisites failed");
let repairs = 0;
let prompts = 0;

await assert.rejects(
startDesktopRuntimeHostWithRecovery({
start: async () => {
throw failure;
},
repair: async () => {
repairs += 1;
return { kind: "repaired" };
},
prompt: async () => {
prompts += 1;
return "repair";
},
}),
failure,
);
assert.equal(repairs, 0);
assert.equal(prompts, 0);
});
62 changes: 62 additions & 0 deletions apps/desktop/src/main/native-diagnostic-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ interface FatalStartupDiagnosticDialogDeps {
readonly showMessageBox: (options: MessageBoxOptions) => Promise<MessageBoxReturnValue>;
}

export interface RuntimeHostStartupRecoveryDialogInput {
readonly startupError: Error;
readonly repairError?: Error;
readonly activeTasks: boolean;
}

export async function showMessageBoxWithDiagnostics(
options: MessageBoxOptions,
deps: DiagnosticDialogDeps,
Expand Down Expand Up @@ -114,6 +120,36 @@ export async function showMainRendererProcessGoneDialog(
return result.response === 0 ? 'relaunch' : 'exit';
}

export async function showRuntimeHostStartupRecoveryDialog(
input: RuntimeHostStartupRecoveryDialogInput,
deps: DiagnosticDialogDeps,
): Promise<'repair' | 'exit'> {
const copy = RUNTIME_HOST_STARTUP_RECOVERY_COPY[deps.locale];
const detail = [
copy.detail,
input.activeTasks ? copy.activeTasks : undefined,
input.repairError
? `${copy.repairFailed}\n${input.repairError.message}`
: undefined,
]
.filter(Boolean)
.join('\n\n');
const result = await showMessageBoxWithDiagnostics(
{
type: 'warning',
title: copy.title,
message: copy.message,
detail,
buttons: [input.activeTasks ? copy.repairAndRestart : copy.repair, copy.exit],
defaultId: 0,
cancelId: 1,
noLink: true,
},
deps,
);
return result.response === 0 ? 'repair' : 'exit';
}

async function copyDiagnostics(
copy: () => void | Promise<void>,
locale: UiLocale,
Expand Down Expand Up @@ -195,3 +231,29 @@ const MAIN_RENDERER_GONE_COPY = {
exit: '退出',
},
} as const;

const RUNTIME_HOST_STARTUP_RECOVERY_COPY = {
en: {
title: 'Maka needs to repair Runtime Host',
message: 'The Runtime Host for this workspace could not start.',
detail:
'Maka can repair the managed Runtime Host selected by this Desktop. Your workspace, Host identity, credentials, and settings will be preserved. Repair may replace the installed Host with the version selected for this Desktop even when automatic update compatibility cannot be confirmed.',
activeTasks:
'The Host may still own active work. Continuing can interrupt that work before the Host restarts.',
repairFailed: 'The previous repair attempt did not finish:',
repair: 'Repair Runtime Host',
repairAndRestart: 'Repair and Restart Host',
exit: 'Exit',
},
zh: {
title: 'Maka 需要修复 Runtime Host',
message: '管理此工作区的 Runtime Host 无法启动。',
detail:
'Maka 可以修复此 Desktop 选择的托管 Runtime Host。工作区、Host 身份、凭证和设置都会保留。即使无法确认自动更新兼容性,修复也可能使用此 Desktop 选择的版本替换当前 Host。',
activeTasks: 'Host 可能仍有正在运行的任务。继续会先中断这些任务,再重启 Host。',
repairFailed: '上一次修复未能完成:',
repair: '修复 Runtime Host',
repairAndRestart: '修复并重启 Host',
exit: '退出',
},
} as const;
Loading
Loading