diff --git a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts index d43b28a061..022c5e6e77 100644 --- a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts +++ b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts @@ -56,7 +56,7 @@ test('retains process lifetime before a standalone startup dialog can close', () ); const windowAllClosed = bootSource.slice( windowAllClosedStart, - bootSource.indexOf('app.on("before-quit"', windowAllClosedStart), + bootSource.indexOf('powerMonitor.on("resume"', windowAllClosedStart), ); assert.match( windowAllClosed, @@ -64,6 +64,33 @@ test('retains process lifetime before a standalone startup dialog can close', () ); }); +test('registers one shared quit cleanup before the initial Host handoff', () => { + const hostStart = bootSource.indexOf('runtimeHostManager = await startLocalRuntimeHostManager'); + const quitRegistration = bootSource.indexOf('app.on("before-quit", quitCoordinator.handleBeforeQuit)'); + const workBoardDeclaration = bootSource.indexOf('let workBoardIpc:'); + assert.ok(workBoardDeclaration >= 0 && workBoardDeclaration < quitRegistration); + assert.ok(quitRegistration >= 0 && quitRegistration < hostStart); + assert.equal(bootSource.match(/createAppQuitCoordinator\(\{/gu)?.length, 1); + assert.equal(bootSource.match(/app\.on\("before-quit"/gu)?.length, 1); + assert.match(bootSource, /cleanup: closeRuntimeHostDesktop/u); + assert.match(bootSource, /return runtimeHostDesktopShutdown \?\?= disposeRuntimeHostDesktop\(\)/u); + assert.match(bootSource, /workBoardIpc\?\.close\(\)/u); +}); + +test('drains startup resources before cancellation quit or fatal presentation', () => { + const callbackStart = bootSource.indexOf('onFatalError: (error, target) => {'); + const callback = bootSource.slice(callbackStart, bootSource.indexOf('\n);', callbackStart)); + assert.ok(callback.indexOf('if (!runtimeHostManager) return;') < callback.indexOf('app.quit()')); + + const hostStart = bootSource.indexOf('runtimeHostManager = await startLocalRuntimeHostManager'); + const failure = bootSource.slice(hostStart, bootSource.indexOf('// Runtime Host is the only', hostStart)); + const cleanup = failure.indexOf('await closeRuntimeHostDesktop()'); + assert.ok(cleanup >= 0 && cleanup < failure.indexOf('app.quit()')); + assert.ok(cleanup < failure.indexOf('throw error')); + assert.doesNotMatch(failure, /retireOwnedLocalHost|forceTerminate/u); + assert.match(bootSource, /await runtimeHostPeerMeshComponent\?\.close\(\)[\s\S]*await runtimeHostPeerEndpointOwner\?\.close\(\)/u); +}); + test('presents startup before Host boot and hands off only when the main window is shown', () => { const ready = mainSource.indexOf("console.log('[startup] app ready')"); const presentation = mainSource.indexOf('showDesktopStartupProgress(', ready); @@ -79,27 +106,21 @@ test('resolves persisted locale before first post-settings recovery prompt', () rendererRecoveryStart, bootSource.indexOf('resolveBrowserDialogParent =', rendererRecoveryStart), ); - const hostRecoveryStart = bootSource.indexOf('prompt: async (input)'); - const hostRecovery = bootSource.slice( - hostRecoveryStart, - bootSource.indexOf('}).catch((error: unknown)', hostRecoveryStart), - ); const defaultHostRecoveryStart = bootSource.indexOf( 'async function promptForDefaultRuntimeHostRecovery', ); const defaultHostRecovery = bootSource.slice(defaultHostRecoveryStart); assert.match(rendererRecovery, /const locale = await desktopLocale\.resolve\(\)/u); - assert.match(hostRecovery, /const locale = await desktopLocale\.resolve\(\)/u); + assert.match(bootSource, /handoffSurface: createDesktopHostHandoffSurface\(\(\) => desktopLocale\.resolve\(\)\)/u); assert.match(defaultHostRecovery, /const locale = await desktopLocale\.resolve\(\)/u); assert.doesNotMatch(rendererRecovery, /desktopLocale\.current\(\)/u); - assert.doesNotMatch(hostRecovery, /desktopLocale\.current\(\)/u); assert.doesNotMatch(defaultHostRecovery, /resolveSystemUiLocale/u); }); test('lets the Runtime Host migrate its State Root before Desktop opens shared tables', () => { const hostStart = bootSource.indexOf( - 'runtimeHostManager = await startDesktopRuntimeHostWithRecovery', + 'runtimeHostManager = await startLocalRuntimeHostManager', ); const workBoardOpen = bootSource.indexOf( 'store: createWorkBoardStore(workspaceRoot', diff --git a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts index 954f0d89b6..c105e0f27d 100644 --- a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts @@ -30,7 +30,6 @@ import { showFatalStartupError, showMainRendererProcessGoneDialog, showMessageBoxWithDiagnostics, - showRuntimeHostStartupRecoveryDialog, } from '../native-diagnostic-dialog.js'; const diagnosticEnvironment = () => ({ @@ -193,54 +192,3 @@ 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 => { - shown = options; - return { response: 0, checkboxChecked: false }; - }, - }, - ); - - assert.equal(decision, 'repair'); - assert.deepEqual(shown?.buttons, [ - 'Repair and Restart Host', - 'Exit', - 'Copy Diagnostics', - ]); - assert.equal(shown?.defaultId, shown?.cancelId); - 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 ?? '', /Copy diagnostics to inspect the details/); - assert.doesNotMatch(shown?.detail ?? '', /service update failed/); - - let unknownShown: MessageBoxOptions | undefined; - const unknownDecision = await showRuntimeHostStartupRecoveryDialog( - { - startupError: new Error('managed service unavailable'), - repairError: new Error('safe repair could not verify Host activity'), - activeTasks: false, - }, - { - locale: 'en', - copyDiagnostics() {}, - showMessageBox: async (options): Promise => { - unknownShown = options; - return { response: 1, checkboxChecked: false }; - }, - }, - ); - assert.equal(unknownDecision, 'exit'); - assert.equal(unknownShown?.defaultId, unknownShown?.cancelId); -}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index 03c14672be..fe13b9953a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -35,7 +35,7 @@ import type { ConnectOrSpawnRuntimeHostInput, RuntimeHostConnection, } from '@maka/runtime-host/client'; -import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import { RuntimeHostOperationError, runHostHandoff } from '@maka/runtime-host/client'; import { SESSION_CONTINUITY_SCHEMA_VERSION, type ClientCapabilityCallFrame, @@ -60,7 +60,7 @@ import { RuntimeHostSessionObservationRegistry } from '../runtime-host-session-o import { RuntimeHostReconnectingIpcMain } from '../runtime-host-reconnecting-ipc-main.js'; import { desktopSessionResourceKey } from '../../shared/runtime-host-identity.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; -import { startDesktopRuntimeHostWithRecovery } from '../runtime-host-startup-recovery.js'; +import { canRepairManagedRuntimeHostStartup } from '../runtime-host-startup-recovery.js'; const TEST_HOST_ID = 'a'.repeat(64); const TEST_TARGET_EPOCH = 'test-target-epoch'; @@ -113,52 +113,115 @@ test('updates a protocol-compatible managed Host before exposing a candidate ove const updated = connectionHarness('updated'); let starts = 0; let repairs = 0; - const candidate = await startDesktopRuntimeHostWithRecovery({ - start: async () => { - const host = starts++ === 0 ? old : updated; - const result = await startDesktopRuntimeHostCandidate({ - ...deps(ipc), - workspaceRoot: root, - rootPath: root, - candidateEntrypoint: 'unused.js', - candidateLaunchBarrier: { - connect: async () => ({ - kind: 'connected', - connection: host.connection, - registration: { lifecycleMode: 'supervised', pid: 123 }, - }), - }, - } as unknown as DesktopRuntimeHostCandidateStartInput); - assert.equal(result.kind, 'ready'); - if (result.kind !== 'ready') throw new Error('Expected a ready candidate'); - return result.candidate; - }, - repair: async (authority) => { - repairs += 1; - assert.deepEqual(authority, { allowManualUpdate: false, allowInterruptActiveTasks: false }); - assert.equal(old.closeCalls, 1, 'release the old connection before managed update'); - assert.equal(old.capabilityRegistrations, 0, 'do not expose capabilities before storage admission'); - assert.equal(ipc.size, 0); - const preserved = new DatabaseSync(databasePath, { readOnly: true }); + const candidate = await runHostHandoff({ + observe: async () => { try { - assert.equal(preserved.prepare("SELECT version FROM operational_schema_migrations WHERE scope = 'usage'").get()?.version, 6); - assert.equal(preserved.prepare("SELECT record_json FROM usage_model_call_attempts WHERE attempt_id = 'retained'").get()?.record_json, '{}'); - } finally { - preserved.close(); + const host = starts++ === 0 ? old : updated; + const result = await startDesktopRuntimeHostCandidate({ + ...deps(ipc), + workspaceRoot: root, + rootPath: root, + candidateEntrypoint: 'unused.js', + candidateLaunchBarrier: { + connect: async () => ({ + kind: 'connected', + connection: host.connection, + registration: { lifecycleMode: 'supervised', pid: 123 }, + }), + }, + } as unknown as DesktopRuntimeHostCandidateStartInput); + assert.equal(result.kind, 'ready'); + if (result.kind !== 'ready') + throw new Error('Expected a ready candidate'); + return { kind: 'ready', value: result.candidate }; + } catch (error) { + assert.ok( + error instanceof Error && canRepairManagedRuntimeHostStartup(error), + ); + return { + kind: 'blocked', + blocker: { + identity: 'managed-schema-before', + target: { name: 'Local', location: 'local' }, + reason: 'repair', + mayExitNaturally: false, + activity: { + connections: 0, + activeOperations: 0, + processUptimeSeconds: 1, + residencies: [], + }, + replacement: { + kind: 'repair', + canReplaceIdle: true, + canInterrupt: true, + execute: async (authority) => { + repairs += 1; + assert.equal(authority, 'refuse_active_work'); + assert.equal( + old.closeCalls, + 1, + 'release the old connection before managed update', + ); + assert.equal( + old.capabilityRegistrations, + 0, + 'do not expose capabilities before storage admission', + ); + assert.equal(ipc.size, 0); + const preserved = new DatabaseSync(databasePath, { + readOnly: true, + }); + try { + assert.equal( + preserved + .prepare( + "SELECT version FROM operational_schema_migrations WHERE scope = 'usage'", + ) + .get()?.version, + 6, + ); + assert.equal( + preserved + .prepare( + "SELECT record_json FROM usage_model_call_attempts WHERE attempt_id = 'retained'", + ) + .get()?.record_json, + '{}', + ); + } finally { + preserved.close(); + } + // Simulate the updated owning Host, not Desktop, performing migration. + acquireOperationalStateDatabase(root).close(); + return { kind: 'completed' }; + }, + }, + }, + }; } - // Simulate the updated owning Host, not Desktop, performing migration. - acquireOperationalStateDatabase(root).close(); - return { kind: 'repaired' }; }, - prompt: async () => { throw new Error('No prompt needed for an idle automatically updatable Host'); }, + openSurface: () => ({ + update: (view) => assert.equal(view.state, 'progress', 'idle repair needs no consent'), + close: () => {}, + }), }); t.after(() => candidate.close()); assert.equal(starts, 2); assert.equal(repairs, 1); assert.equal(updated.capabilityRegistrations, 1); - const current = acquireOperationalStateDatabase(root, { schemaMigration: 'require_current' }); + const current = acquireOperationalStateDatabase(root, { + schemaMigration: 'require_current', + }); try { - assert.equal(current.database.prepare("SELECT session_id FROM usage_model_call_attempts WHERE attempt_id = 'retained'").get()?.session_id, 'deleted-session'); + assert.equal( + current.database + .prepare( + "SELECT session_id FROM usage_model_call_attempts WHERE attempt_id = 'retained'", + ) + .get()?.session_id, + 'deleted-session', + ); } finally { current.close(); } diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index 0bcb2c7de6..9fb28dac17 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -26,6 +26,10 @@ import { RuntimeHostPermanentReconnectError, RuntimeHostRequestInterruptedError, type RuntimeHostSpawnedProcess, + type HostHandoffView, + type HostHandoffAction, + type OpenHostHandoffSurface, + HostHandoffRequiredError, } from '@maka/runtime-host/client'; import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, @@ -680,14 +684,12 @@ test('does not poll a remote service PID when the Host cannot be replaced', asyn { startCandidate: async (input) => input.profileTarget ? conflict : ready(local.candidate), - upgradePrompts: { - restartable: async () => assert.fail('non-restartable conflict used restart prompt'), - nonRestartable: async (_conflict, action) => { - assert.equal(action, 'cancel_only'); - return 'cancel'; - }, - }, - waitForHostRetirement: async () => assert.fail('remote PID must not be polled locally'), + handoffSurface: decideHandoff((view) => { + assert.deepEqual(view.actions, ['cancel', 'retry']); + assert.equal(view.target.location, 'remote'); + assert.equal(view.mayExitNaturally, false); + return 'cancel'; + }), }, ); @@ -1243,9 +1245,11 @@ test('keeps an initially unavailable Direct target live and wakes it on new rout await manager.close(); }); -test('does not activate a Direct target whose immediate retry fails permanently', async () => { +test('marks a retrying Direct target unavailable on permanent failure', async () => { const local = candidateHarness(); const permanent = new RuntimeHostPermanentReconnectError('credential rejected'); + let reportFatal!: (error: Error) => void; + const failed = new Promise((resolve) => { reportFatal = resolve; }); let starts = 0; const manager = await startRuntimeHostDesktopManager( {} as DesktopRuntimeHostCandidateStartInput, @@ -1256,10 +1260,12 @@ test('does not activate a Direct target whose immediate retry fails permanently' if (starts === 2) throw new Error('route is temporarily unavailable'); throw permanent; }, + onFatalError: reportFatal, }, ); - await assert.rejects(manager.enable(peerTarget('office')), (error: unknown) => error === permanent); + await manager.enable(peerTarget('office')).catch((error) => assert.equal(error, permanent)); + assert.equal(await failed, permanent); assert.equal(starts, 3); assert.equal(manager.current('office'), undefined); const state = manager.entries().find((entry) => entry.target.profile.id === 'office'); @@ -1369,7 +1375,7 @@ test('stops reconnecting when the replacement Host is incompatible', async () => await first.candidate.close(); const fatal = await fatalReported; - assert.match(fatal.message, /older Runtime Host/); + assert.ok(fatal instanceof HostHandoffRequiredError); await assert.rejects( owner.retireOwnedLocalHost('interrupt_active_work'), (error: unknown) => @@ -1429,341 +1435,176 @@ test('does not block quit after a supervised Local Host becomes permanently unav await owner.close(); }); -test('restarts an idle generation-aware Host without prompting', async () => { +test('automatically replaces a proven-idle managed Host without an interruption decision', async () => { + const observed = upgradeRequired(true); + const conflict = { ...observed, restartable: false as const, + registration: { ...observed.registration, lifecycleMode: 'service' as const } }; const replacement = candidateHarness(); - const starts: DesktopRuntimeHostCandidateStartInput[] = []; - const conflict = upgradeRequired(true); + let replaced = false; const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async (input) => { - starts.push(input); - return starts.length === 1 ? conflict : ready(replacement.candidate); - }, - upgradePrompts: { - restartable: async () => assert.fail('idle Host must not prompt before restart'), - nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'), - }, + startCandidate: async () => replaced ? ready(replacement.candidate) : conflict, + handoffSurface: () => ({ + update: (view) => { assert.equal(view.state, 'progress'); assert.ok(!view.actions.includes('interrupt')); }, + close: () => undefined, + }), + resolveLocalHostReplacement: async (registration) => ({ + identity: 'managed-owner', canReplaceIdle: true, + replace: async (policy) => { + assert.equal(registration.hostEpoch, conflict.registration.hostEpoch); + assert.equal(policy, 'refuse_active_work'); + replaced = true; + return 'replaced'; + }, + }), }); - - assert.equal(starts.length, 2); - assert.equal(starts[1]?.takeoverHostEpoch, conflict.registration.hostEpoch); await owner.close(); }); -test('prompts before restarting a generation-aware Host with observed activity', async () => { - const conflicts = [ - ['operation', upgradeRequired(true, 1)], - ['residency', upgradeRequired(true, 0, [{ label: 'goal', count: 1 }])], - ['connection', upgradeRequired(true, 0, [], 1)], - ] as const; - - for (const [activity, conflict] of conflicts) { +test('active and unknown work use the shared explicit interruption decision', async () => { + for (const observed of [ + upgradeRequired(true, 1), upgradeRequired(true, 0, [{ label: 'goal', count: 1 }]), + upgradeRequired(true, 0, [], 1), upgradeRequired(false), + ]) { + const conflict = { ...observed, restartable: false as const, + registration: { ...observed.registration, lifecycleMode: 'service' as const } }; const replacement = candidateHarness(); - let prompts = 0; - const owner = await startRuntimeHostDesktopManager( - {} as DesktopRuntimeHostCandidateStartInput, - { - startCandidate: async (input) => - input.takeoverHostEpoch ? ready(replacement.candidate) : conflict, - upgradePrompts: { - restartable: async () => { - prompts += 1; - return 'restart'; - }, - nonRestartable: async () => - assert.fail('restartable conflict used non-restartable prompt'), + let replaced = false; + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => replaced ? ready(replacement.candidate) : conflict, + handoffSurface: decideHandoff((view) => { + assert.equal(view.defaultAction, 'cancel'); + assert.ok(view.actions.includes('interrupt')); + return 'interrupt'; + }), + resolveLocalHostReplacement: async () => ({ + canReplaceIdle: true, + replace: async (policy) => { + assert.equal(policy, 'interrupt_active_work'); + replaced = true; + return 'replaced'; }, - }, - ); - - assert.equal(prompts, 1, activity); + }), + }); await owner.close(); } }); -test('prompts when a restartable Host has no activity snapshot', async () => { +test('an admission race needs fresh explicit consent instead of repeated automatic replacement', async () => { + const observed = upgradeRequired(true); + const conflict = { ...observed, restartable: false as const, + registration: { ...observed.registration, lifecycleMode: 'service' as const } }; const replacement = candidateHarness(); - const conflict = upgradeRequired(true, 0, [], 0, false); - let prompts = 0; + let replaced = false; + const policies: string[] = []; const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async (input) => - input.takeoverHostEpoch ? ready(replacement.candidate) : conflict, - upgradePrompts: { - restartable: async () => { - prompts += 1; - return 'restart'; - }, - nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'), - }, - }); - - assert.equal(prompts, 1); - await owner.close(); -}); - -test('waits passively for a Host that cannot be taken over', async () => { - const observed = upgradeRequired(false); - const conflict = { - ...observed, - registration: { ...observed.registration, lifecycleMode: 'service' as const }, - }; - let starts = 0; - let finishRetirement!: () => void; - const retirement = new Promise((resolve) => { - finishRetirement = resolve; - }); - const replacement = candidateHarness(); - const ownerTask = startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async () => { - starts += 1; - return starts === 1 ? conflict : ready(replacement.candidate); - }, - upgradePrompts: { - restartable: async () => assert.fail('wait-only conflict used restart prompt'), - nonRestartable: async (_conflict, action) => { - assert.equal(action, 'wait'); - return 'wait'; + startCandidate: async () => replaced ? ready(replacement.candidate) : conflict, + handoffSurface: decideHandoff(() => 'interrupt'), + resolveLocalHostReplacement: async () => ({ + canReplaceIdle: true, + replace: async (policy) => { + policies.push(policy); + if (policy === 'refuse_active_work') return 'active_tasks'; + replaced = true; + return 'replaced'; }, - }, - waitForHostRetirement: async (registration) => { - assert.equal(registration.hostEpoch, conflict.registration.hostEpoch); - await retirement; - }, + }), }); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(starts, 1); - finishRetirement(); - const owner = await ownerTask; - assert.equal(starts, 2); + assert.deepEqual(policies, ['refuse_active_work', 'interrupt_active_work']); await owner.close(); }); -test('offers to stop an exact local ephemeral Host before retrying startup', async () => { +test('an exact legacy ephemeral stop always requires consent and preserves the OS lifetime fence', async () => { const observed = incompatibleHost('blocked_by_residency'); - const conflict = { - ...observed, + const conflict = { ...observed, registration: { ...observed.registration, lifecycleMode: 'ephemeral' as const }, - processIdentity: { - startIdentity: 'darwin:1700000000:123456', - }, - handshake: { - ...observed.handshake, - activity: { - connections: 0, - activeOperations: 0, - processUptimeSeconds: 60, - residencies: [], - }, - }, + processIdentity: { startIdentity: 'darwin:1700000000:123456' }, + handshake: { ...observed.handshake, activity: { + connections: 0, activeOperations: 0, processUptimeSeconds: 60, residencies: [], + } }, }; const replacement = candidateHarness(); - let starts = 0; - let prompts = 0; - let terminations = 0; + let terminated = false; const owner = await startRuntimeHostDesktopManager( - { rootPath: '/workspace' } as DesktopRuntimeHostCandidateStartInput, - { - startCandidate: async () => { - starts += 1; - return starts === 1 ? conflict : ready(replacement.candidate); - }, - upgradePrompts: { - restartable: async () => assert.fail('incompatible Host used restart prompt'), - nonRestartable: async (_conflict, action) => { - prompts += 1; - assert.equal(action, 'replace_may_interrupt_work'); - return 'replace'; - }, - }, + { rootPath: '/workspace' } as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => terminated ? ready(replacement.candidate) : conflict, + handoffSurface: decideHandoff((view) => { + assert.equal(terminated, false); + assert.ok(view.actions.includes('interrupt')); + return 'interrupt'; + }), forceTerminateObservedHost: async (identity, authority) => { - terminations += 1; - assert.deepEqual(identity, { - rootPath: '/workspace', - registration: conflict.registration, - }); + assert.deepEqual(identity, { rootPath: '/workspace', registration: conflict.registration }); assert.deepEqual(authority.processIdentity, conflict.processIdentity); assert.equal(authority.isCurrent(), true); + terminated = true; return true; }, }, ); - - assert.equal(prompts, 1, 'even an idle snapshot must not authorize a forced stop'); - assert.equal(terminations, 1); - assert.equal(starts, 2); - await owner.close(); -}); - -test('silently replaces an idle non-restartable Local Host and retries', async () => { - const observed = upgradeRequired(true); - const conflict = { - ...observed, - restartable: false as const, - registration: { ...observed.registration, lifecycleMode: 'service' as const }, - }; - const replacement = candidateHarness(); - let starts = 0; - let replaced: typeof observed.registration | undefined; - const policies: string[] = []; - const owner = await startRuntimeHostDesktopManager( - {} as DesktopRuntimeHostCandidateStartInput, - { - startCandidate: async () => { - starts += 1; - return starts === 1 ? conflict : ready(replacement.candidate); - }, - upgradePrompts: { - restartable: async () => assert.fail('non-restartable conflict used restart prompt'), - nonRestartable: async () => assert.fail('idle replaceable Host must not prompt'), - }, - resolveLocalHostReplacement: async (registration) => ({ - replace: async (policy) => { - policies.push(policy); - replaced = registration; - return 'replaced'; - }, - }), - }, - ); - assert.equal(starts, 2); - assert.equal(replaced?.hostEpoch, conflict.registration.hostEpoch); - assert.deepEqual(policies, ['refuse_active_work']); + assert.equal(terminated, true); await owner.close(); }); -test('prompts if a non-restartable Local Host omits its activity snapshot', async () => { +test('cancelling a live handoff does not authorize any replacement', async () => { const observed = upgradeRequired(false); - const conflict = { - ...observed, - registration: { ...observed.registration, lifecycleMode: 'service' as const }, - }; - const replacement = candidateHarness(); - const policies: string[] = []; - let starts = 0; - let prompts = 0; - const owner = await startRuntimeHostDesktopManager( - {} as DesktopRuntimeHostCandidateStartInput, - { - startCandidate: async () => { - starts += 1; - return starts === 1 ? conflict : ready(replacement.candidate); - }, - upgradePrompts: { - restartable: async () => assert.fail('non-restartable conflict used restart prompt'), - nonRestartable: async (_conflict, action) => { - prompts += 1; - assert.equal(action, 'replace_may_interrupt_work'); - return 'replace'; - }, - }, - resolveLocalHostReplacement: async () => ({ - replace: async (policy) => { - policies.push(policy); - return 'replaced'; - }, - }), - }, - ); - - assert.equal(prompts, 1); - assert.equal(starts, 2); - assert.deepEqual(policies, ['interrupt_active_work']); - await owner.close(); -}); - -test('prompts if an observed-idle Host becomes active before replacement', async () => { - const observed = upgradeRequired(true); - const conflict = { - ...observed, - restartable: false as const, - registration: { ...observed.registration, lifecycleMode: 'service' as const }, - }; - const replacement = candidateHarness(); - const policies: string[] = []; - let starts = 0; - let prompts = 0; - const owner = await startRuntimeHostDesktopManager( - {} as DesktopRuntimeHostCandidateStartInput, - { - startCandidate: async () => { - starts += 1; - return starts === 1 ? conflict : ready(replacement.candidate); - }, - upgradePrompts: { - restartable: async () => assert.fail('non-restartable conflict used restart prompt'), - nonRestartable: async (_conflict, action) => { - prompts += 1; - assert.equal(action, 'replace_may_interrupt_work'); - return 'replace'; - }, - }, - resolveLocalHostReplacement: async () => ({ - replace: async (policy) => { - policies.push(policy); - return policy === 'refuse_active_work' ? 'active_tasks' : 'replaced'; - }, - }), - }, - ); - - assert.equal(prompts, 1); - assert.equal(starts, 2); - assert.deepEqual(policies, ['refuse_active_work', 'interrupt_active_work']); - await owner.close(); -}); - -test('does not authorize active-work interruption when replacement is cancelled', async () => { - const observed = upgradeRequired(false); - const conflict = { - ...observed, - registration: { ...observed.registration, lifecycleMode: 'service' as const }, - }; - const policies: string[] = []; - - await assert.rejects( - startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async () => conflict, - upgradePrompts: { - restartable: async () => assert.fail('non-restartable conflict used restart prompt'), - nonRestartable: async () => 'cancel', - }, - resolveLocalHostReplacement: async () => ({ - replace: async (policy) => { - policies.push(policy); - return 'active_tasks'; - }, - }), - onFatalError: () => undefined, + const conflict = { ...observed, + registration: { ...observed.registration, lifecycleMode: 'service' as const } }; + await assert.rejects(startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => conflict, + handoffSurface: decideHandoff(() => 'cancel'), + resolveLocalHostReplacement: async () => ({ + canReplaceIdle: true, + replace: async () => assert.fail('cancel must not mutate the service'), }), - RuntimeHostUpgradeCancelledError, - ); - assert.deepEqual(policies, []); + onFatalError: () => undefined, + }), RuntimeHostUpgradeCancelledError); }); -test('lets the user cancel startup when an incompatible Host owns the root', async () => { - const conflict = incompatibleHost('blocked_by_residency'); - let presented: DesktopRuntimeHostCandidateStartResult | undefined; - await assert.rejects( - startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async () => conflict, - upgradePrompts: { - restartable: async () => assert.fail('incompatible Host used restart prompt'), - nonRestartable: async (actual, action) => { - presented = actual; - assert.equal(action, 'wait'); - return 'cancel'; - }, - }, - onFatalError: () => undefined, +test('keeps a known repair actionable when its first authority inspection fails', async () => { + const repaired = candidateHarness({ ownership: 'supervised' }); + let inspected = false; + let didRepair = false; + let showedUnavailable = false; + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => didRepair ? ready(repaired.candidate) + : { kind: 'failed', reason: 'deployment_needs_repair' }, + resolveStartupRepair: async () => { + if (!inspected) { + inspected = true; + throw new Error('Authority inspection temporarily unavailable'); + } + return { + identity: 'verified-repair', target: { name: 'Local', location: 'local' }, + reason: 'repair', mayExitNaturally: false, + activity: { connections: 0, activeOperations: 0, processUptimeSeconds: 1, residencies: [] }, + replacement: { kind: 'repair', canReplaceIdle: true, canInterrupt: false, + execute: async () => { didRepair = true; return { kind: 'completed' }; } }, + }; + }, + handoffSurface: decideHandoff((view) => { + if (!showedUnavailable) { + assert.equal(didRepair, false); + assert.match(view.diagnostic ?? '', /Authority inspection temporarily unavailable/); + assert.deepEqual(view.actions, ['cancel', 'retry']); + showedUnavailable = true; + } + return 'retry'; }), - (error: unknown) => { - assert.ok(error instanceof RuntimeHostUpgradeCancelledError); - assert.equal(error.message, 'Runtime Host restart was cancelled'); - return true; - }, - ); - assert.equal(presented, conflict); + }); + assert.equal(showedUnavailable, true); + assert.equal(didRepair, true); + await owner.close(); }); +function decideHandoff( + choose: (view: HostHandoffView) => HostHandoffAction, +): OpenHostHandoffSurface { + return (submit) => ({ + update(view) { if (view.state === 'attention') submit(view.revision, choose(view)); }, + close() {}, + }); +} + function incompatibleHost( replacement: 'wait_for_idle_exit' | 'blocked_by_residency', ): Extract { diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts index 5a08954585..36aa3328e2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts @@ -31,6 +31,7 @@ import { encodeRuntimeHostServiceManagementFrame, encodeRuntimeHostSetupFrame, RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, + RUNTIME_HOST_OPERATOR_RETIREMENT_CANCELLATION_ENV, RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV, } from '@maka/runtime-host/operator'; import { @@ -45,6 +46,46 @@ const OPERATOR = { modulePath: '/tmp/maka/operator.mjs', }; +test('retirement cancellation sends EOF without terminating or abandoning the operator transaction', async (t) => { + const cancellation = new AbortController(); + const child = new EventEmitter() as ReturnType; + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + Object.assign(child, { pid: 1234, stdin, stdout, stderr }); + let spawned!: () => void; + const started = new Promise((resolve) => { spawned = resolve; }); + const operator = createDesktopRuntimeHostLocalOperator({ + spawnProcess: ((_command, _args, options) => { + assert.equal(options?.env?.[RUNTIME_HOST_OPERATOR_RETIREMENT_CANCELLATION_ENV], '1'); + assert.deepEqual(options?.stdio, ['pipe', 'pipe', 'pipe']); + spawned(); + return child; + }) as typeof spawn, + terminateProcess: async () => assert.fail('cancellation cannot kill the deployment transaction'), + }); + t.after(() => operator.close()); + let settled = false; + const result = operator.runService({ + operator: OPERATOR, action: 'restart', + target: { serviceId: 'a'.repeat(64), rootId: 'a'.repeat(64), rootPath: '/tmp/maka/root' }, + retirementSignal: cancellation.signal, + }).then((frame) => { settled = true; return frame; }); + await started; + cancellation.abort(); + await Promise.resolve(); + assert.equal(stdin.writableEnded, true); + assert.equal(settled, false); + stdout.end(encodeRuntimeHostServiceManagementFrame({ + schemaVersion: 1, kind: 'result', action: 'restart', + service: { platform: 'linux', arch: 'x64', osRelease: 'test', state: 'running', + pid: 42, lastExitCode: 0, installedVersion: '0.3.0', projectDirectoryRoots: [] }, + })); + stderr.end(); + child.emit('close', 0, null); + assert.equal((await result).kind, 'result'); +}); + test('local setup installs one managed service for the Desktop root with Direct peer enabled', () => { assert.deepEqual( runtimeHostLocalSetupCommand({ diff --git a/apps/desktop/src/main/__tests__/runtime-host-startup-recovery.test.ts b/apps/desktop/src/main/__tests__/runtime-host-startup-recovery.test.ts deleted file mode 100644 index 8b8cc43a91..0000000000 --- a/apps/desktop/src/main/__tests__/runtime-host-startup-recovery.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * 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 { OperationalStateMigrationBlockedError } from '@maka/storage/operational-state-store'; -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); -}); - -for (const failure of [ - runtimeHostStartupError('managed_root_requires_operator'), - new OperationalStateMigrationBlockedError(new Error('Host migration required'), 'requires_host_migration'), -]) test(`separates manual update consent from active-work interruption: ${failure.name}`, 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 failure; - 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]); -}); - -for (const failure of [ - new Error('renderer prerequisites failed'), - new OperationalStateMigrationBlockedError(new Error('unsupported newer schema')), -]) test(`does not offer managed repair for an unrelated startup failure: ${failure.name}`, async () => { - 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); -}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts b/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts deleted file mode 100644 index 9eea55398e..0000000000 --- a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -/* - * 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 { buildRuntimeHostUpgradeDialog } from '../runtime-host-upgrade-copy.js'; -import { createRuntimeHostUpgradePrompts } from '../runtime-host-upgrade-dialog.js'; - -const conflict = { - kind: 'upgrade_required', - restartable: true, - registration: { pid: 42, lifecycleMode: 'ephemeral' }, - handshake: { - activity: { - connections: 2, - activeOperations: 1, - processUptimeSeconds: 120, - residencies: [ - { label: 'scheduled-task', count: 2 }, - { label: 'daily-review', count: 1 }, - ], - }, - }, -} as unknown as Parameters[0]; - -test('localizes upgrade activity without changing decision indexes', () => { - const en = buildRuntimeHostUpgradeDialog( - conflict, - 'restart', - 'en', - ).options; - const zh = buildRuntimeHostUpgradeDialog( - conflict, - 'restart', - 'zh-CN', - ).options; - const zhTw = buildRuntimeHostUpgradeDialog( - conflict, - 'restart', - 'zh-TW', - ).options; - assert.deepEqual(en.buttons, ['Restart Runtime Host', 'Wait', 'Cancel Startup']); - assert.deepEqual(zh.buttons, ['重启 Runtime Host', '等待', '取消启动']); - assert.equal(en.defaultId, en.cancelId); - assert.equal(zh.defaultId, zh.cancelId); - assert.match(zh.detail ?? '', /仍有 2 个其他客户端连接/); - assert.match(zh.detail ?? '', /每日回顾: 1/); - assert.match(en.detail ?? '', /Scheduled Task: 2/); - assert.match(zh.detail ?? '', /计划任务: 2/); - assert.deepEqual(zhTw.buttons, ['重啟 Runtime Host', '等待', '取消啟動']); - assert.match(zhTw.detail ?? '', /仍有 2 個其他客戶端連線/); - assert.match(zhTw.detail ?? '', /每日回顧: 1/); - assert.match(en.detail ?? '', /Process ID \(PID\):/); -}); - -test('maps the non-default replacement choice to the replace decision', async () => { - const prompts = createRuntimeHostUpgradePrompts( - async () => 'en', - async (options) => { - assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Wait', 'Cancel Startup']); - assert.equal(options.defaultId, 2); - assert.equal(options.cancelId, 2); - assert.match(options.detail ?? '', /Maka will stop this Host/); - return { response: 0, checkboxChecked: false }; - }, - ); - assert.equal( - await prompts.nonRestartable( - { - kind: 'upgrade_required', - restartable: false, - registration: { pid: 42, lifecycleMode: 'ephemeral' }, - } as never, - 'replace_may_interrupt_work', - ), - 'replace', - ); -}); - -test('defaults non-restartable prompts to cancellation', async () => { - const conflict = { - kind: 'upgrade_required', - restartable: false, - registration: { pid: 42, lifecycleMode: 'service' }, - } as never; - const prompts = createRuntimeHostUpgradePrompts( - async () => 'en', - async (options) => { - assert.deepEqual(options.buttons, ['Cancel Startup']); - assert.equal(options.defaultId, 0); - assert.equal(options.cancelId, 0); - assert.doesNotMatch(options.detail ?? '', /If you wait/u); - return { response: 0, checkboxChecked: false }; - }, - ); - assert.equal( - await prompts.nonRestartable(conflict, 'cancel_only'), - 'cancel', - ); - - const waitDialog = buildRuntimeHostUpgradeDialog( - { - kind: 'upgrade_required', - restartable: false, - registration: { pid: 42, lifecycleMode: 'ephemeral' }, - } as never, - 'wait', - 'en', - ).options; - assert.deepEqual(waitDialog.buttons, ['Wait', 'Cancel Startup']); - assert.equal(waitDialog.defaultId, waitDialog.cancelId); -}); - -test('does not offer passive waiting when a supervised Host can restart', async () => { - const prompts = createRuntimeHostUpgradePrompts( - async () => 'en', - async (options) => { - assert.deepEqual(options.buttons, ['Restart Runtime Host', 'Cancel Startup']); - assert.equal(options.defaultId, options.cancelId); - assert.doesNotMatch(options.detail ?? '', /If you wait/u); - return { response: 0, checkboxChecked: false }; - }, - ); - - assert.equal( - await prompts.restartable({ - ...conflict, - registration: { pid: 42, lifecycleMode: 'service' }, - } as never), - 'restart', - ); -}); - -test('explains when the safe replacement check could not verify idle state', () => { - const conflict = { - kind: 'upgrade_required' as const, - restartable: false as const, - registration: { pid: 42, lifecycleMode: 'service' as const }, - } as Parameters[0]; - const dialog = buildRuntimeHostUpgradeDialog( - conflict, - 'replace_may_interrupt_work', - 'zh-CN', - ); - - assert.match(dialog.options.detail ?? '', /无法确认此 Host 是否处于空闲状态/u); - assert.doesNotMatch(dialog.options.detail ?? '', /无法报告后台活动/u); - assert.equal(dialog.options.defaultId, dialog.options.cancelId); -}); diff --git a/apps/desktop/src/main/__tests__/startup-progress-window.test.ts b/apps/desktop/src/main/__tests__/startup-progress-window.test.ts index 728dde9b20..a432c21663 100644 --- a/apps/desktop/src/main/__tests__/startup-progress-window.test.ts +++ b/apps/desktop/src/main/__tests__/startup-progress-window.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { test } from 'node:test'; import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; +import type { HostHandoffView } from '@maka/runtime-host/client'; import { createStartupProgressWindow, renderStartupProgressHtml } from '../startup-progress-window.js'; function harness() { @@ -30,18 +31,23 @@ function harness() { let minimized = false; let visible = false; let copied = 0; + let copiedHandoff: HostHandoffView | undefined; let documentUrl = ''; let options: BrowserWindowConstructorOptions | undefined; let openWindow!: () => { action: string }; + let contentSize = [520, 350]; + let measuredHeight = 350; const scripts: string[] = []; const errors: unknown[] = []; const contents = Object.assign(new EventEmitter(), { setWindowOpenHandler(handler: typeof openWindow) { openWindow = handler; }, - async executeJavaScript(source: string) { scripts.push(source); }, + async executeJavaScript(source: string) { scripts.push(source); return measuredHeight; }, }); const window = Object.assign(new EventEmitter(), { webContents: contents, setMenuBarVisibility() {}, + getContentSize() { return contentSize; }, + setContentSize(width: number, height: number) { contentSize = [width, height]; }, isDestroyed: () => destroyed, isMinimized: () => minimized, destroy() { destroyed = true; }, @@ -61,22 +67,47 @@ function harness() { const progress = createStartupProgressWindow({ locale: 'en', dark: false, icon: '/test/icon.png', createWindow(input) { options = input; return window as unknown as BrowserWindow; }, - copyDiagnostics() { copied += 1; }, + copyDiagnostics(_phase, handoff) { copied += 1; copiedHandoff = handoff; }, onError(error) { errors.push(error); }, }); return { progress, window, contents, scripts, errors, resolveLoad, rejectLoad, get options() { return options; }, get copied() { return copied; }, + get copiedHandoff() { return copiedHandoff; }, get documentUrl() { return documentUrl; }, get destroyed() { return destroyed; }, get minimized() { return minimized; }, get visible() { return visible; }, get openWindow() { return openWindow; }, + get contentSize() { return contentSize; }, + setMeasuredHeight(height: number) { measuredHeight = height; }, }; } const flush = () => new Promise((resolve) => setImmediate(resolve)); +test('fits content instead of reserving an empty handoff panel and bounds long diagnoses', async () => { + const h = harness(); + h.resolveLoad(); + await flush(); + const view: HostHandoffView = { revision: 'sized', state: 'attention', + reason: 'busy', mayExitNaturally: false, defaultAction: 'cancel', + actions: ['cancel'], target: { name: 'Local', location: 'local' } }; + h.setMeasuredHeight(368); + h.progress.handoff(view, () => {}, 'en'); + await flush(); + assert.deepEqual(h.contentSize, [560, 368]); + h.setMeasuredHeight(900); + h.progress.handoff({ ...view, revision: 'long' }, () => {}, 'en'); + await flush(); + assert.deepEqual(h.contentSize, [560, 640]); + h.setMeasuredHeight(350); + h.progress.clearHandoff(); + await flush(); + assert.deepEqual(h.contentSize, [520, 350]); + h.progress.close(); +}); + test('shows the latest real phase after loading and minimizes without terminating startup', async () => { const h = harness(); h.progress.update('staging'); @@ -152,3 +183,24 @@ test('localized progress stays self-contained, accessible and has no fabricated } } }); + +test('live handoff accepts only current allowed actions and copies the current diagnosis', async () => { + const h = harness(); + const actions: string[] = []; + const view: HostHandoffView = { revision: 'first', target: { name: 'local', location: 'local' }, + state: 'attention', reason: 'busy', mayExitNaturally: false, + actions: ['cancel', 'retry', 'interrupt'], defaultAction: 'cancel', diagnostic: 'current host is busy' }; + const submit = (revision: string, action: string) => { actions.push(`${revision}:${action}`); }; + h.progress.handoff(view, submit, 'en'); + h.resolveLoad(); await flush(); + h.progress.handoff({ ...view, revision: 'second', state: 'progress', phase: 'pausing', actions: ['cancel'] }, submit, 'en'); + for (const url of ['maka-startup://handoff/first/interrupt', 'maka-startup://handoff/second/interrupt', + 'maka-startup://handoff/second/cancel', 'maka-startup://copy']) { + h.contents.emit('will-navigate', { preventDefault() {} }, url); + } + await flush(); + assert.deepEqual(actions, ['second:cancel']); + assert.equal(h.copiedHandoff?.revision, 'second'); + assert.equal(h.copiedHandoff?.diagnostic, view.diagnostic); + h.progress.close(); +}); diff --git a/apps/desktop/src/main/native-diagnostic-dialog-copy.ts b/apps/desktop/src/main/native-diagnostic-dialog-copy.ts index bd9ccee1eb..522033559c 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog-copy.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog-copy.ts @@ -39,16 +39,6 @@ interface NativeDiagnosticDialogCopy { readonly recover: string; readonly exit: string; }; - readonly runtimeHostRecovery: { - readonly title: string; - readonly message: string; - readonly detail: string; - readonly activeTasks: string; - readonly repairFailed: string; - readonly repair: string; - readonly repairAndRestart: string; - readonly exit: string; - }; readonly defaultRuntimeHostRecovery: { readonly title: string; connectFailed(profileName: string): string; @@ -88,18 +78,6 @@ const COPY = { recover: 'Recover Interface', exit: 'Exit', }, - runtimeHostRecovery: { - 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. Copy diagnostics to inspect the details.', - repair: 'Repair Runtime Host', - repairAndRestart: 'Repair and Restart Host', - exit: 'Exit', - }, defaultRuntimeHostRecovery: { title: 'Default Runtime Host is unavailable', connectFailed: (profileName) => `Could not connect to ${profileName}`, @@ -138,17 +116,6 @@ const COPY = { recover: '恢复界面', exit: '退出', }, - runtimeHostRecovery: { - 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: '退出', - }, defaultRuntimeHostRecovery: { title: '默认 Runtime Host 无法连接', connectFailed: (profileName) => `无法连接 ${profileName}`, @@ -187,17 +154,6 @@ const COPY = { recover: '復原介面', exit: '退出', }, - runtimeHostRecovery: { - 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: '退出', - }, defaultRuntimeHostRecovery: { title: '預設 Runtime Host 無法連線', connectFailed: (profileName) => `無法連線至 ${profileName}`, diff --git a/apps/desktop/src/main/native-diagnostic-dialog.ts b/apps/desktop/src/main/native-diagnostic-dialog.ts index 231c30a2e1..7106a45240 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog.ts @@ -44,11 +44,6 @@ interface FatalStartupDiagnosticDialogDeps { readonly showMessageBox: (options: MessageBoxOptions) => Promise; } -export interface RuntimeHostStartupRecoveryDialogInput { - readonly startupError: Error; - readonly repairError?: Error; - readonly activeTasks: boolean; -} export function defaultRuntimeHostRecoveryDialog(input: { readonly locale: UiLocale; @@ -143,33 +138,6 @@ export async function showMainRendererProcessGoneDialog( return result.response === 0 ? 'recover' : 'exit'; } -export async function showRuntimeHostStartupRecoveryDialog( - input: RuntimeHostStartupRecoveryDialogInput, - deps: DiagnosticDialogDeps, -): Promise<'repair' | 'exit'> { - const copy = getNativeDiagnosticDialogCopy(deps.locale).runtimeHostRecovery; - const detail = [ - copy.detail, - input.activeTasks ? copy.activeTasks : undefined, - input.repairError ? copy.repairFailed : 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: input.activeTasks || input.repairError ? 1 : 0, - cancelId: 1, - noLink: true, - }, - deps, - ); - return result.response === 0 ? 'repair' : 'exit'; -} async function copyDiagnostics( copy: () => void | Promise, diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index db1b353f0c..6a5a4d8a7d 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -134,7 +134,6 @@ import { defaultRuntimeHostRecoveryDialog, showMainRendererProcessGoneDialog, showMessageBoxWithDiagnostics, - showRuntimeHostStartupRecoveryDialog, } from "./native-diagnostic-dialog.js"; import { getNativeDiagnosticDialogCopy } from "./native-diagnostic-dialog-copy.js"; import { @@ -188,16 +187,11 @@ import { startRuntimeHostDesktopManager, type RuntimeHostDesktopManager, } from "./runtime-host-desktop-manager.js"; -import { - canRepairManagedRuntimeHostStartup, - DesktopRuntimeHostStartupRecoveryCancelledError, - startDesktopRuntimeHostWithRecovery, -} from "./runtime-host-startup-recovery.js"; import { buildRuntimeHostActiveQuitDialog, } from "./runtime-host-quit-copy.js"; import { prepareRuntimeHostQuit } from "./runtime-host-quit.js"; -import { createRuntimeHostUpgradePrompts } from "./runtime-host-upgrade-dialog.js"; +import { createDesktopHostHandoffSurface } from './startup-presentation.js'; import { registerRuntimeHostMemoryIpc } from "./runtime-host-memory-ipc-main.js"; import { createDesktopRuntimeHostProfileService, @@ -1137,10 +1131,7 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( runtimeHostProfileService.resolveCollaborationConnectionTarget(profile), }, { - upgradePrompts: createRuntimeHostUpgradePrompts( - () => desktopLocale.resolve(), - showStartupDiagnosticDialog, - ), + handoffSurface: createDesktopHostHandoffSurface(() => desktopLocale.resolve()), onTargetStateChanged: (state) => { const profileAccess = runtimeHostProfileAccess(state.target.profile); const hostId = state.readiness === "ready" @@ -1236,14 +1227,13 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( }); }, recoverLocalHost: (signal) => localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(signal), + resolveStartupRepair: (error, signal) => localRuntimeHostRemoteAccess.resolveStartupRepair(error, signal), resolveLocalHostReplacement: (registration, signal) => localRuntimeHostRemoteAccess.resolveConflictingHostReplacement(registration, signal), onFatalError: (error, target) => { - if ( - !runtimeHostManager && - target.profile.kind === "local" && - canRepairManagedRuntimeHostStartup(error) - ) return; + // Initial failure is handled after manager.start() has closed its own + // observations. Do not quit before startup-owned resources are drained. + if (!runtimeHostManager) return; if (error instanceof RuntimeHostUpgradeCancelledError) { if (target.profile.kind === "local") app.quit(); return; @@ -1253,51 +1243,32 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( }, }, ); -runtimeHostManager = await startDesktopRuntimeHostWithRecovery({ - start: async () => { - updateDesktopStartupProgress('connect'); - await localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(); - return startLocalRuntimeHostManager(); - }, - repair: async ({ allowManualUpdate, allowInterruptActiveTasks }) => { - updateDesktopStartupProgress('package'); - console.warn('[runtime-host] repairing the managed Local Host before startup'); - const result = await localRuntimeHostRemoteAccess.repairManagedStartup({ - allowManualUpdate, - allowInterruptActiveTasks, - }); - console.log(`[runtime-host] managed Local Host repair result: ${result.kind}`); - return result; +let workBoardIpc: ReturnType | undefined; +let runtimeHostDesktopShutdown: Promise | undefined; +// The first Host handoff can be cancelled before the main window exists. +// Install the same cleanup owner used by normal quit before that handoff. +const quitCoordinator = createAppQuitCoordinator({ + prepareToQuit: prepareRuntimeHostDesktopQuit, + cleanup: closeRuntimeHostDesktop, + focusOrCreateWindow: (signal) => { + if (!runtimeHostManager) return; + if (mainWindowController.hasOpenWindows()) mainWindowController.focus(); + else return mainWindowController.createWindow(signal); }, - prompt: async (input) => { - updateDesktopStartupProgress('attention'); - console.error('[runtime-host] managed Local Host startup recovery requires attention:', { - startupError: input.startupError, - repairError: input.repairError, - activeTasks: input.activeTasks, - }); - const locale = await desktopLocale.resolve(); - return showRuntimeHostStartupRecoveryDialog(input, { - locale, - showMessageBox: (options) => showDesktopMessageBox(options, { locale }), - copyDiagnostics: () => - copyDesktopDiagnosticReport( - desktopDiagnostics, - createDesktopStartupDiagnosticInput({ - title: 'Runtime Host startup recovery', - description: input.startupError.message, - details: [input.startupError.stack, input.repairError?.stack] - .filter(Boolean) - .join('\n\n'), - }), - ), - }); + onPreparationError: (error) => { + console.error("[runtime-host] quit retirement failed:", error); }, -}).catch((error: unknown) => { - if ( - error instanceof RuntimeHostUpgradeCancelledError || - error instanceof DesktopRuntimeHostStartupRecoveryCancelledError - ) { + onCleanupError: (error) => + console.error("[runtime-host] shutdown failed:", error), + onWindowCreationError: (error) => + console.error("[window] creation failed:", error), + resumeQuit: () => app.quit(), +}); +app.on("before-quit", quitCoordinator.handleBeforeQuit); +updateDesktopStartupProgress('connect'); +runtimeHostManager = await startLocalRuntimeHostManager().catch(async (error: unknown) => { + await closeRuntimeHostDesktop(); + if (error instanceof RuntimeHostUpgradeCancelledError) { app.quit(); return new Promise(() => undefined); } @@ -1306,7 +1277,7 @@ runtimeHostManager = await startDesktopRuntimeHostWithRecovery({ // Runtime Host is the only schema-migration authority for its State Root. // Work Board remains a Desktop-owned table, but it opens only after the Host is // ready and verifies the schema instead of changing it behind a resident Host. -const workBoardIpc = registerWorkBoardIpc({ +workBoardIpc = registerWorkBoardIpc({ ipcMain, workspaceRoot, mainWindowController, @@ -1958,22 +1929,6 @@ function emitSessionsChanged( } function wireLifecycle(): void { - const quitCoordinator = createAppQuitCoordinator({ - prepareToQuit: prepareRuntimeHostDesktopQuit, - cleanup: closeRuntimeHostDesktop, - focusOrCreateWindow: (signal) => { - if (mainWindowController.hasOpenWindows()) mainWindowController.focus(); - else return mainWindowController.createWindow(signal); - }, - onPreparationError: (error) => { - console.error("[runtime-host] quit retirement failed:", error); - }, - onCleanupError: (error) => - console.error("[runtime-host] shutdown failed:", error), - onWindowCreationError: (error) => - console.error("[window] creation failed:", error), - resumeQuit: () => app.quit(), - }); installDesktopShellPresentation({ mainWindowController, focusOrCreateWindow: quitCoordinator.focusOrCreateWindow, @@ -1989,7 +1944,6 @@ function wireLifecycle(): void { if (process.platform !== "darwin" && !isBrowserMessageBoxPresentationActive() && !isDesktopStartupInProgress()) app.quit(); }); - app.on("before-quit", quitCoordinator.handleBeforeQuit); powerMonitor.on("resume", wakePeerRecoveryAfterResume); quitCoordinator.focusOrCreateWindow(); } @@ -2007,7 +1961,11 @@ async function prepareRuntimeHostDesktopQuit(): Promise<'ready' | 'cancelled'> { return preparation; } -async function closeRuntimeHostDesktop(): Promise { +function closeRuntimeHostDesktop(): Promise { + return runtimeHostDesktopShutdown ??= disposeRuntimeHostDesktop(); +} + +async function disposeRuntimeHostDesktop(): Promise { powerMonitor.off("resume", wakePeerRecoveryAfterResume); clientSettingsWatcher.stop(); updateService.dispose(); @@ -2037,7 +1995,7 @@ async function closeRuntimeHostDesktop(): Promise { runtimeHostOnboarding.close(), localRuntimeHostRemoteAccess.close(), runtimeHostSetupPackage.close(), - Promise.resolve().then(() => workBoardIpc.close()), + Promise.resolve().then(() => workBoardIpc?.close()), runtimeHostSshTerminal.close(), botRegistry.stopAll(), mcpManager.close(), diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 7abc9327e4..cc7aa35e1a 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -160,7 +160,7 @@ const decodeStoredMessage = (value: unknown): StoredMessage => const MAX_OPTIMISTIC_ATTEMPTS = 3; const MAX_SESSION_REVISION_ATTEMPTS = 8; const MAX_PRICING_SNAPSHOT_ATTEMPTS = 3; -const RUNTIME_HOST_RETIREMENT_TIMEOUT_MS = 5_000; +const RUNTIME_HOST_RETIREMENT_TIMEOUT_MS = 15_000; export type DesktopSessionConfigurationPatch = SessionConfigurationPatch; diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index a053213e32..db9a709858 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -21,9 +21,17 @@ import { randomUUID } from 'node:crypto'; import type { BotIncomingMessage } from '@maka/runtime/bots'; import { abortable, + connectExistingRuntimeHost, + prepareConnectedRuntimeHostRetirement, + runHostHandoff, + HostHandoffCancelledError, + type OpenHostHandoffSurface, + type HostHandoffObservation, + type HostHandoffBlocker, forceTerminateObservedRegisteredRuntimeHost, RuntimeHostOperationError, RuntimeHostPermanentReconnectError, + RuntimeHostRemoteCompatibilityError, RuntimeHostPeerError, RuntimeHostRequestInterruptedError, runtimeHostStartupError, @@ -39,7 +47,7 @@ import { type RuntimeHostSshInteraction, } from '@maka/runtime-host/client'; import { - isHostActivityIdle, + RUNTIME_HOST_PROTOCOL_VERSION, type HostRegistration, type HostStatusResult, } from '@maka/runtime-host/protocol'; @@ -53,6 +61,7 @@ import { } from './runtime-host-desktop-candidate.js'; import { RuntimeHostReconnectingIpcMain } from './runtime-host-reconnecting-ipc-main.js'; import { RuntimeHostSessionObservationRegistry } from './runtime-host-session-observation-registry.js'; +import { canRepairManagedRuntimeHostStartup } from './runtime-host-startup-recovery.js'; export interface RuntimeHostDesktopManager { current(profileId?: string): RuntimeHostDesktopTargetSnapshot | undefined; @@ -172,15 +181,14 @@ export class DesktopLocalHostRetirementError extends Error { } } -export type RuntimeHostRestartDecision = 'restart' | 'wait' | 'cancel'; -export type RuntimeHostNonRestartableDecision = 'replace' | 'wait' | 'cancel'; -export type RuntimeHostNonRestartableAction = - | 'replace_may_interrupt_work' - | 'wait' - | 'cancel_only'; - export interface RuntimeHostLocalReplacement { - replace(activeWorkPolicy: RuntimeHostRetirementMode): Promise<'replaced' | 'active_tasks'>; + readonly identity?: string; + readonly canReplaceIdle?: boolean; + replace( + activeWorkPolicy: RuntimeHostRetirementMode, + progress?: (phase: 'checking' | 'staging' | 'retiring' | 'replacing') => void, + signal?: AbortSignal, + ): Promise<'replaced' | 'active_tasks'>; } export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconnectError { @@ -214,16 +222,6 @@ export type RuntimeHostWaitConflict = > | Extract; -export interface RuntimeHostUpgradePrompts { - restartable( - conflict: RuntimeHostRestartableConflict, - ): Promise; - nonRestartable( - conflict: RuntimeHostWaitConflict, - action: RuntimeHostNonRestartableAction, - ): Promise; -} - interface DesktopRuntimeHostTargetGeneration { readonly epoch: string; readonly input: DesktopRuntimeHostCandidateStartInput; @@ -257,18 +255,15 @@ export async function startRuntimeHostDesktopManager( observationRegistry: RuntimeHostSessionObservationRegistry, ) => Promise; onFatalError?: (error: Error, target: ResolvedRuntimeHostProfile) => void; - upgradePrompts?: RuntimeHostUpgradePrompts; + handoffSurface?: OpenHostHandoffSurface; waitForHostExit?: (pid: number) => Promise; forceTerminateObservedHost?: typeof forceTerminateObservedRegisteredRuntimeHost; - waitForHostRetirement?: ( - registration: HostRegistration, - signal: AbortSignal, - ) => Promise; resolveLocalHostReplacement?: ( registration: HostRegistration, signal: AbortSignal, ) => Promise; recoverLocalHost?: (signal: AbortSignal) => Promise; + resolveStartupRepair?: (error: Error, signal: AbortSignal) => Promise; reconnectBackoff?: RuntimeHostReconnectBackoff; pairingFinalizationTimeoutMs?: number; onTargetStateChanged?: (state: RuntimeHostDesktopTargetState) => void; @@ -281,12 +276,12 @@ export async function startRuntimeHostDesktopManager( input, options.startCandidate ?? startDesktopRuntimeHostCandidate, options.onFatalError ?? ((error) => console.error('[runtime-host] reconnect failed:', error)), - options.upgradePrompts, + options.handoffSurface, options.waitForHostExit ?? waitForProcessExit, options.forceTerminateObservedHost ?? forceTerminateObservedRegisteredRuntimeHost, - options.waitForHostRetirement ?? waitForProcessRetirement, options.resolveLocalHostReplacement, options.recoverLocalHost, + options.resolveStartupRepair, options.reconnectBackoff, options.pairingFinalizationTimeoutMs ?? DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS, options.onTargetStateChanged, @@ -320,13 +315,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { error: Error, target: ResolvedRuntimeHostProfile, ) => void, - private readonly upgradePrompts: RuntimeHostUpgradePrompts | undefined, + private readonly handoffSurface: OpenHostHandoffSurface | undefined, private readonly waitForHostExit: (pid: number) => Promise, private readonly forceTerminateObservedHost: typeof forceTerminateObservedRegisteredRuntimeHost, - private readonly waitForHostRetirement: ( - registration: HostRegistration, - signal: AbortSignal, - ) => Promise, private readonly resolveLocalHostReplacement: | (( registration: HostRegistration, @@ -336,6 +327,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { private readonly recoverLocalHost: | ((signal: AbortSignal) => Promise) | undefined, + private readonly resolveStartupRepair: + | ((error: Error, signal: AbortSignal) => Promise) + | undefined, private readonly reconnectBackoff: RuntimeHostReconnectBackoff | undefined, private readonly pairingFinalizationTimeoutMs: number, private readonly onTargetStateChanged: @@ -1036,7 +1030,6 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { sshInteraction: RuntimeHostSshInteraction | undefined, onConnectionPhase: ((phase: RuntimeHostConnectionPhase) => void) | undefined, ): Promise { - let takeoverHostEpoch: string | undefined; let localRecoveryAttempted = false; const inheritedExit = target.input.onExit; let refreshPeerRoutes = target.skipPeerRouteRefreshOnce !== true; @@ -1048,6 +1041,26 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { localRecoveryAttempted = true; return this.recoverLocalHost(signal); }; + const resolveRepair = async (error: unknown): Promise | undefined> => { + if (target.input.profileTarget || !(error instanceof Error) || + !canRepairManagedRuntimeHostStartup(error) || !this.resolveStartupRepair) return undefined; + let blocker: HostHandoffBlocker | undefined; + let diagnostic = error.message; + try { + blocker = await this.resolveStartupRepair(error, signal); + } catch (inspectionError) { + signal.throwIfAborted(); + // The repair need is known; unavailable authority evidence must not + // grant replacement rights or discard the live Retry/Cancel journey. + diagnostic += '\n' + (inspectionError instanceof Error ? inspectionError.message : String(inspectionError)); + } + return { kind: 'blocked', blocker: blocker ?? { + identity: JSON.stringify([target.epoch, error.name, diagnostic]), + target: { name: target.target.profile.name, location: 'local' }, + reason: 'repair', mayExitNaturally: false, diagnostic, + } }; + }; + const observe = async (): Promise> => { while (true) { let result: DesktopRuntimeHostCandidateStartResult; const ipcMain = this.#ipcMain.createTarget(target.epoch); @@ -1073,14 +1086,24 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { onConnectionPhase: (phase) => onConnectionPhase?.(phase), ...(refreshPeerRoutes ? {} : { refreshPeerRoutes: false }), signal, - ...(takeoverHostEpoch === undefined ? {} : { takeoverHostEpoch }), }, target.observations, ); refreshPeerRoutes = true; } catch (error) { signal.throwIfAborted(); + if (target.input.profileTarget && error instanceof RuntimeHostRemoteCompatibilityError) { + return { kind: 'blocked', blocker: { + identity: JSON.stringify([target.epoch, error.hostEpoch, error.details]), + target: { name: target.target.profile.name, location: 'remote', + ...(target.target.profile.kind === 'local' ? {} : { rootId: target.target.profile.rootId }), + hostEpoch: error.hostEpoch }, + reason: 'upgrade', mayExitNaturally: false, diagnostic: error.message, + } }; + } if (await tryRecoverLocalHost()) continue; + const repair = await resolveRepair(error); + if (repair) return repair; throw error; } if (result.kind === 'ready') { @@ -1105,81 +1128,97 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ? { ownedProcess: retainedOwnedProcess } : {}), }; - return result.candidate; - } - if (result.kind === 'upgrade_required' && result.restartable) { - const activity = result.handshake?.activity; - const decision = - activity && isHostActivityIdle(activity) - ? 'restart' - : await this.#resolveRestartable(result); - if (decision === 'cancel') { - throw new RuntimeHostUpgradeCancelledError(); - } - if (decision === 'restart') { - takeoverHostEpoch = result.registration.hostEpoch; - continue; - } - takeoverHostEpoch = undefined; - await this.waitForHostRetirement(result.registration, signal); - continue; + return { kind: 'ready', value: result.candidate }; } - if ( - result.kind === 'incompatible' || - (result.kind === 'upgrade_required' && !result.restartable) - ) { + if (result.kind === 'incompatible' || result.kind === 'upgrade_required') { + const conflict = result; + const idleTakeover = result.kind === 'upgrade_required' && result.restartable && + !target.input.profileTarget && target.input.generation !== undefined; + const cooperativeRetirement = !target.input.profileTarget && + result.registration.lifecycleMode === 'ephemeral' && + result.handshake?.activity?.cooperativeHandoff === true; const replacement = target.input.profileTarget ? undefined : this.#registeredEphemeralHostReplacement(target, result, signal) ?? (await this.resolveLocalHostReplacement?.(result.registration, signal)); - const activity = result.handshake?.activity; - if (replacement && activity && isHostActivityIdle(activity)) { - // Only a complete, observed snapshot can authorize silent - // replacement. The replacement transaction closes races with new - // operations; an absent snapshot requires explicit consent because - // it cannot prove that other clients are disconnected. - const attempt = await replacement.replace('refuse_active_work'); - if (attempt === 'replaced') { - takeoverHostEpoch = undefined; - continue; - } - } - // The retirement waiter observes a local PID. Remote targets cannot - // use it to prove that a Host on another machine has exited. - const action: RuntimeHostNonRestartableAction = replacement - ? 'replace_may_interrupt_work' - : target.input.profileTarget - ? 'cancel_only' - : 'wait'; - const decision = await this.#resolveNonRestartable(result, action); - if (decision === 'cancel') throw new RuntimeHostUpgradeCancelledError(); - if (decision === 'replace') { - if (!replacement) { - throw new RuntimeHostPermanentReconnectError( - 'This Runtime Host cannot be replaced from the current target', - ); - } - const replaced = await replacement.replace('interrupt_active_work'); - if (replaced === 'active_tasks') { - throw new RuntimeHostPermanentReconnectError( - 'This Runtime Host still owns work that cannot be interrupted safely', - ); - } - takeoverHostEpoch = undefined; - continue; - } - takeoverHostEpoch = undefined; - await this.waitForHostRetirement(result.registration, signal); - continue; + return { kind: 'blocked', blocker: { + identity: JSON.stringify([target.epoch, result.registration, result.processIdentity, replacement?.identity]), + target: { + name: target.target.profile.name, + location: target.input.profileTarget ? 'remote' : 'local', + rootId: result.registration.rootId, + hostEpoch: result.registration.hostEpoch, + }, + reason: 'upgrade', + ...(result.handshake?.activity ? { activity: result.handshake.activity } : {}), + mayExitNaturally: result.registration.lifecycleMode === 'ephemeral' && + result.handshake?.replacement === 'wait_for_idle_exit', + ...((replacement || idleTakeover || cooperativeRetirement) ? { replacement: { + kind: 'replace', + canReplaceIdle: result.handshake?.state === 'ready' && + (cooperativeRetirement || idleTakeover || replacement?.canReplaceIdle === true), + canInterrupt: replacement !== undefined, + execute: async (policy, progress, _consent, attemptSignal) => { + const retirementSignal = attemptSignal ? AbortSignal.any([signal, attemptSignal]) : signal; + retirementSignal.throwIfAborted(); + progress('retiring'); + if (policy === 'refuse_active_work' && cooperativeRetirement) { + const observed = await connectExistingRuntimeHost({ + rootPath: target.input.rootPath, + protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, + compositionId: conflict.registration.compositionId, + }); + if (observed.kind !== 'connected') return { kind: 'changed' }; + try { + if (observed.registration.rootId !== conflict.registration.rootId || + observed.registration.hostEpoch !== conflict.registration.hostEpoch || + !observed.connection.cooperativeHandoff) return { kind: 'changed' }; + const prepared = await prepareConnectedRuntimeHostRetirement( + observed.connection, policy, 60_000, retirementSignal, + ); + return { kind: prepared.kind === 'prepared' ? 'completed' : 'active_work' }; + } finally { + await observed.connection.close(); + } + } + if (policy === 'refuse_active_work' && idleTakeover) { + const retired = await connectExistingRuntimeHost({ + rootPath: target.input.rootPath, + protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, + compositionId: conflict.registration.compositionId, + generation: target.input.generation, + takeoverHostEpoch: conflict.registration.hostEpoch, + }); + if (retired.kind === 'connected') await retired.connection.close(); + if (retired.kind === 'draining') return { kind: 'completed' }; + return { kind: 'registration' in retired && + retired.registration?.hostEpoch === conflict.registration.hostEpoch ? 'active_work' : 'changed' }; + } + if (!replacement) return { kind: 'changed' }; + const replaced = await replacement.replace(policy, progress, retirementSignal); + return { kind: replaced === 'replaced' ? 'completed' : 'active_work' }; + }, + } } : {}), + } }; } if (await tryRecoverLocalHost()) continue; - throw runtimeHostStartupError(result.reason, result.diagnostic); + const failure = runtimeHostStartupError(result.reason, result.diagnostic); + const repair = await resolveRepair(failure); + if (repair) return repair; + throw failure; + } + }; + try { + return await runHostHandoff({ observe, openSurface: this.handoffSurface, signal }); + } catch (error) { + if (error instanceof HostHandoffCancelledError) throw new RuntimeHostUpgradeCancelledError(); + throw error; } } #registeredEphemeralHostReplacement( target: DesktopRuntimeHostTargetGeneration, - conflict: RuntimeHostWaitConflict, + conflict: RuntimeHostWaitConflict | RuntimeHostRestartableConflict, signal: AbortSignal, ): RuntimeHostLocalReplacement | undefined { const { registration, processIdentity } = conflict; @@ -1190,18 +1229,20 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { target.valid && this.#targets.get(target.target.profile.id) === target; return { - replace: async (activeWorkPolicy) => { + canReplaceIdle: false, + replace: async (activeWorkPolicy, _progress, attemptSignal) => { // This Host cannot participate in the current retirement protocol, so // an earlier idle snapshot cannot prove that it remains idle. Require // explicit consent before using the identity-fenced termination path. if (activeWorkPolicy === 'refuse_active_work') return 'active_tasks'; signal.throwIfAborted(); + attemptSignal?.throwIfAborted(); const terminated = await this.forceTerminateObservedHost( { rootPath: this.#baseInput.rootPath, registration, }, - { processIdentity, isCurrent: stillAuthorized }, + { processIdentity, isCurrent: () => stillAuthorized() && !attemptSignal?.aborted }, ); signal.throwIfAborted(); if (!terminated) { @@ -1214,27 +1255,6 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { }; } - #resolveRestartable( - conflict: RuntimeHostRestartableConflict, - ): Promise { - if (this.upgradePrompts) return this.upgradePrompts.restartable(conflict); - return this.#missingUpgradePrompt(); - } - - #resolveNonRestartable( - conflict: RuntimeHostWaitConflict, - action: RuntimeHostNonRestartableAction, - ): Promise { - if (this.upgradePrompts) return this.upgradePrompts.nonRestartable(conflict, action); - return this.#missingUpgradePrompt(); - } - - #missingUpgradePrompt(): never { - throw new RuntimeHostPermanentReconnectError( - 'An older Runtime Host is still running. Restart it or wait for its background work to finish.', - ); - } - #requireLifecycle( target: DesktopRuntimeHostTargetGeneration, ): RuntimeHostReconnectLifecycle { @@ -1488,30 +1508,6 @@ function isSessionGuestProfile( return profile.kind === 'remote' && profile.access === 'session_guest'; } -function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(signal.reason); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal.removeEventListener('abort', onAbort); - resolve(); - }, ms); - const onAbort = () => { - clearTimeout(timer); - reject(signal.reason); - }; - signal.addEventListener('abort', onAbort, { once: true }); - }); -} - -async function waitForProcessRetirement( - registration: HostRegistration, - signal: AbortSignal, -): Promise { - while (isProcessAlive(registration.pid)) { - await waitForAbortableDelay(250, signal); - } -} - async function waitForProcessExit(pid: number): Promise { // The Host owns a 10-second graceful-shutdown deadline. Keep a separate // observation margin so Desktop cannot race the Host's final process.exit. diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts index 1ade55cedc..8f42ae890d 100644 --- a/apps/desktop/src/main/runtime-host-local-operator.ts +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -28,6 +28,7 @@ import { } from '@maka/runtime/process-tree-terminator'; import { decodeRuntimeHostAccessManagementFrame, + RUNTIME_HOST_OPERATOR_RETIREMENT_CANCELLATION_ENV, decodeRuntimeHostPeerManagementFrame, decodeRuntimeHostPeerMeshManagementFrame, decodeRuntimeHostServiceManagementFrame, @@ -126,6 +127,7 @@ export interface DesktopRuntimeHostLocalSetupCommand { } export interface DesktopRuntimeHostLocalServiceManagementInput { + readonly retirementSignal?: AbortSignal; readonly operator: RuntimeHostOperatorCommand; readonly action: | 'status' @@ -139,6 +141,7 @@ export interface DesktopRuntimeHostLocalServiceManagementInput { readonly target: DesktopRuntimeHostLocalServiceTarget; readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; readonly expectedConfigFingerprint?: string; + readonly expectedHost?: { readonly hostEpoch: string; readonly pid: number }; readonly allowInterruptActiveTasks?: boolean; readonly retainManagedDeployment?: boolean; readonly signal?: AbortSignal; @@ -235,6 +238,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { ): Promise; runUpdate( input: { + readonly retirementSignal?: AbortSignal; readonly setupPackage: DesktopRuntimeHostSetupPackage; readonly target: DesktopRuntimeHostLocalServiceTarget; readonly expectedHost?: { readonly hostEpoch: string; readonly pid: number }; @@ -421,6 +425,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { ...(command.expectedConfigFingerprint ? ['--expected-config-fingerprint', command.expectedConfigFingerprint] : []), + ...(command.expectedHost ? ['--expected-host-json', JSON.stringify(command.expectedHost)] : []), ...(command.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), ...(command.retainManagedDeployment ? ['--retain-managed-deployment'] : []), ...managedTargetArgs(command.target), @@ -436,6 +441,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, terminate, signal: combinedSignal(command.signal, closing.signal), + retirementSignal: command.retirementSignal, active, }).then((frame) => requireServiceFrame(frame, command.action)); }, @@ -482,6 +488,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, terminate, signal: combinedSignal(command.signal, closing.signal), + retirementSignal: command.retirementSignal, active, action: 'update', onProgress, @@ -620,6 +627,7 @@ function requireServiceFrame( } function runServiceFrameProcess(input: { + readonly retirementSignal?: AbortSignal; readonly command: DesktopRuntimeHostLocalSetupCommand; readonly environment: NodeJS.ProcessEnv; readonly spawnProcess: typeof spawn; @@ -790,6 +798,7 @@ function managedTargetArgs(target: DesktopRuntimeHostLocalServiceTarget): string } function runSingleFrameProcess(input: { + readonly retirementSignal?: AbortSignal; readonly command: DesktopRuntimeHostLocalSetupCommand; readonly prefix: string; readonly decode: (line: string) => Frame | undefined; @@ -866,6 +875,7 @@ function runSetupProcess(input: { } async function runFramedProcess(input: { + readonly retirementSignal?: AbortSignal; readonly command: DesktopRuntimeHostLocalSetupCommand; readonly cwd?: string; readonly prefix: string; @@ -886,6 +896,7 @@ async function runFramedProcess(input: { }): Promise { const deadline = Date.now() + input.timeoutMs; input.signal?.throwIfAborted(); + input.retirementSignal?.throwIfAborted(); const lookupTimeoutMs = deadline - Date.now(); if (lookupTimeoutMs <= 0) throw new Error(`${input.label} timed out`); const command = await resolveLocalNpmCommand( @@ -902,12 +913,19 @@ async function runFramedProcess(input: { const child = input.spawnProcess(command.executable, [...command.args], { ...(input.cwd ? { cwd: input.cwd } : {}), detached: process.platform !== 'win32', - env: input.environment, - stdio: [input.inputLine === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + env: { ...input.environment, ...(input.retirementSignal + ? { [RUNTIME_HOST_OPERATOR_RETIREMENT_CANCELLATION_ENV]: '1' } : {}) }, + stdio: [input.inputLine === undefined && !input.retirementSignal ? 'ignore' : 'pipe', 'pipe', 'pipe'], windowsHide: true, }); input.active.add(child); if (input.inputLine !== undefined) child.stdin?.end(`${input.inputLine}\n`); + const cancelRetirement = () => { child.stdin?.end(); }; + // EOF is a cooperative request, not permission to kill the lifecycle owner. + // Old operators may ignore it; their final transaction result is still awaited. + child.stdin?.on('error', () => undefined); + input.retirementSignal?.addEventListener('abort', cancelRetirement, { once: true }); + if (input.retirementSignal?.aborted) cancelRetirement(); let filterFailure: Error | undefined; let stopFailure: Error | undefined; let stderr = ''; @@ -931,6 +949,7 @@ async function runFramedProcess(input: { const cleanup = () => { clearTimeout(timeout); input.signal?.removeEventListener('abort', onAbort); + input.retirementSignal?.removeEventListener('abort', cancelRetirement); input.active.delete(child); }; const finish = (result: Result | undefined, error?: Error) => { diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts index 214a0f7d77..4f9a3fd861 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -25,6 +25,8 @@ import type { IpcMain } from 'electron'; import { encodeRuntimeHostOwnerConnectionCode, issueRuntimeHostOwnerConnectionCode, + connectExistingRuntimeHost, + type HostHandoffBlocker, } from '@maka/runtime-host/client'; import { createRuntimeHostLegacyPosixOperatorCommand, @@ -34,7 +36,7 @@ import { type RuntimeHostOperatorCommand, type RuntimeHostServiceUpdatePhase, } from '@maka/runtime-host/operator'; -import type { HostPeerEndpoint, HostRegistration } from '@maka/runtime-host/protocol'; +import { RUNTIME_HOST_PROTOCOL_VERSION, type HostActivitySnapshot, type HostPeerEndpoint, type HostRegistration } from '@maka/runtime-host/protocol'; import type { DesktopLocalRuntimeHostRemoteAccessEnableResult, DesktopLocalRuntimeHostRemoteAccessSnapshot, @@ -131,10 +133,17 @@ export interface DesktopLocalRuntimeHostRemoteAccess { registration: HostRegistration, signal: AbortSignal, ): Promise; + resolveStartupRepair(error: Error, signal: AbortSignal): Promise; repairManagedStartup(input?: { + readonly retirementSignal?: AbortSignal; readonly allowManualUpdate?: boolean; readonly allowInterruptActiveTasks?: boolean; readonly signal?: AbortSignal; + readonly onProgress?: (phase: RuntimeHostServiceUpdatePhase | 'restart') => void; + readonly expected?: { + readonly lifecycle: DesktopRuntimeHostLocalManagementTarget; + readonly host?: HostRegistration; + }; }): Promise; recoverBeforeLocalHostStart(signal?: AbortSignal): Promise; recover(): Promise; @@ -187,6 +196,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { readonly resolveManagedDeploymentAuthority?: ( rootId: string, ) => Promise; + readonly inspectHost?: typeof connectExistingRuntimeHost; }): DesktopLocalRuntimeHostRemoteAccess { const lifecyclePath = join(input.clientDataRoot, LIFECYCLE_FILE); const closing = new AbortController(); @@ -708,7 +718,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { input.ipcMain.handle(channels[3], revokeSharedAccess); input.ipcMain.handle(channels[4], disable); - return { + const service: DesktopLocalRuntimeHostRemoteAccess = { getSnapshot, createCollaborationConnectionTarget, enable, @@ -746,10 +756,14 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { } const target = authority.target; return { - replace: (activeWorkPolicy) => + identity: JSON.stringify(target), + canReplaceIdle: true, + replace: (activeWorkPolicy, progress, retirementSignal) => serialize(async () => { signal.throwIfAborted(); - const setupPackage = await input.resolveSetupPackage(signal); + retirementSignal?.throwIfAborted(); + const setupPackage = await input.resolveSetupPackage(retirementSignal + ? AbortSignal.any([signal, retirementSignal]) : signal); const frame = await input.operator.runUpdate( { setupPackage, @@ -762,8 +776,12 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ? { allowInterruptActiveTasks: true } : {}), signal, + ...(retirementSignal ? { retirementSignal } : {}), + }, + (phase) => { + input.onUpdateProgress?.(phase); + progress?.(phase); }, - (phase) => input.onUpdateProgress?.(phase), ); if (frame.kind === 'error') { if (frame.error.code === 'active_tasks') return 'active_tasks'; @@ -789,31 +807,100 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { }), }; }, + resolveStartupRepair: async (error, signal) => { + const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (lifecycle?.state !== 'managed') return undefined; + const authority = await resolveManagedDeploymentAuthority(input.rootId); + const observed = await (input.inspectHost ?? connectExistingRuntimeHost)({ + rootPath: input.rootPath, + protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, + }); + const registration = 'registration' in observed ? observed.registration : undefined; + let activity: HostActivitySnapshot | undefined; + if (observed.kind === 'connected') { + try { + const facts = await observed.connection.request('host.diagnostics.query', {}); + if (facts.hostEpoch === observed.connection.hostEpoch && facts.pid === observed.registration.pid && + facts.state === 'ready' && facts.connections >= 1 && facts.activeOperations >= 1) { + // The accepted diagnostic connection and query are our own, not work + // requiring consent. Legacy residency evidence stays conservative. + activity = { connections: facts.connections - 1, activeOperations: facts.activeOperations - 1, + processUptimeSeconds: facts.processUptimeSeconds, residencies: facts.residencies, + ...(observed.connection.cooperativeHandoff ? { cooperativeHandoff: true } : {}) }; + } + } catch { /* Missing activity evidence never authorizes automatic interruption. */ } + finally { await observed.connection.close(); } + } else if (observed.kind === 'incompatible' || observed.kind === 'upgrade_required') { + if (observed.handshake?.state === 'ready') activity = observed.handshake.activity; + } + signal.throwIfAborted(); + const identity = JSON.stringify([lifecycle, authority, registration]); + return { + identity, + target: { name: hostname(), location: 'local', rootId: input.rootId, + ...(registration ? { hostEpoch: registration.hostEpoch } : {}) }, + reason: 'repair', mayExitNaturally: false, + ...(activity ? { activity } : {}), + diagnostic: error.message, + replacement: { + kind: 'repair', canReplaceIdle: true, canInterrupt: registration !== undefined, + execute: async (policy, progress, consent, retirementSignal) => { + retirementSignal?.throwIfAborted(); + const current = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + const currentAuthority = await resolveManagedDeploymentAuthority(input.rootId); + if (JSON.stringify([current, currentAuthority, registration]) !== identity) return { kind: 'changed' }; + progress('staging'); + const result = await service.repairManagedStartup({ + allowManualUpdate: consent === 'explicit', + allowInterruptActiveTasks: policy === 'interrupt_active_work', + signal, + ...(retirementSignal ? { retirementSignal } : {}), + onProgress: (phase) => progress(phase === 'restart' ? 'replacing' : phase), + expected: { lifecycle, ...(registration ? { host: registration } : {}) }, + }); + return { kind: result.kind === 'repaired' ? 'completed' : result.kind === 'active_tasks' ? 'active_work' : 'changed' }; + }, + }, + }; + }, repairManagedStartup: (options = {}) => serialize(async () => { const signal = options.signal ? AbortSignal.any([options.signal, closing.signal]) : closing.signal; signal.throwIfAborted(); + options.retirementSignal?.throwIfAborted(); const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); if (lifecycle?.state !== 'managed') return { kind: 'unavailable' }; + if (options.expected && JSON.stringify(lifecycle) !== JSON.stringify(options.expected.lifecycle)) { + return { kind: 'unavailable' }; + } const setupPackage = await input.resolveSetupPackage(signal); - input.onUpdateProgress?.('checking'); + const progress = (phase: RuntimeHostServiceUpdatePhase | 'restart') => { + input.onUpdateProgress?.(phase); + options.onProgress?.(phase); + }; + progress('checking'); const frame = await input.operator.runUpdate( { setupPackage, target: lifecycle, + ...(options.expected?.host ? { expectedHost: { + hostEpoch: options.expected.host.hostEpoch, pid: options.expected.host.pid, + } } : {}), ...(options.allowManualUpdate ? { allowManualUpdate: true } : {}), ...(options.allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), signal, + ...(options.retirementSignal ? { retirementSignal: options.retirementSignal } : {}), }, - (phase) => input.onUpdateProgress?.(phase), + (phase) => progress(phase), ); if (frame.kind === 'error') { if (frame.error.code === 'active_tasks') return { kind: 'active_tasks' }; + if (frame.error.code === 'target_mismatch') return { kind: 'unavailable' }; throw new Error(`Runtime Host repair failed: ${frame.error.message}`); } if (frame.kind === 'progress' || frame.action !== 'update') { @@ -822,18 +909,23 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { if (frame.update.kind === 'active_tasks') return { kind: 'active_tasks' }; if (frame.update.kind === 'already_current') { - input.onUpdateProgress?.('restart'); + progress('restart'); const restarted = await input.operator.runService({ operator: lifecycle.operator, action: 'restart', target: lifecycle, + ...(options.expected?.host ? { expectedHost: { + hostEpoch: options.expected.host.hostEpoch, pid: options.expected.host.pid, + } } : {}), ...(options.allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), signal, + ...(options.retirementSignal ? { retirementSignal: options.retirementSignal } : {}), }); if (restarted.kind === 'error') { if (restarted.error.code === 'active_tasks') return { kind: 'active_tasks' }; + if (restarted.error.code === 'target_mismatch') return { kind: 'unavailable' }; throw new Error(`Runtime Host restart failed: ${restarted.error.message}`); } if (restarted.kind === 'progress' || restarted.action !== 'restart') { @@ -912,6 +1004,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { await mutation; }, }; + return service; } function conflictReplacementError(pid: number, reason: string): Error { diff --git a/apps/desktop/src/main/runtime-host-startup-recovery.ts b/apps/desktop/src/main/runtime-host-startup-recovery.ts index 5d68697609..7455116f05 100644 --- a/apps/desktop/src/main/runtime-host-startup-recovery.ts +++ b/apps/desktop/src/main/runtime-host-startup-recovery.ts @@ -20,106 +20,6 @@ import { RuntimeHostStartupError } from "@maka/runtime-host/client"; import { OperationalStateMigrationBlockedError } from '@maka/storage/operational-state-store'; -export type DesktopRuntimeHostStartupRepairResult = - | { readonly kind: "repaired" } - | { readonly kind: "active_tasks" } - | { readonly kind: "unavailable" }; - -export interface DesktopRuntimeHostStartupRecoveryPrompt { - readonly startupError: Error; - readonly repairError?: Error; - readonly activeTasks: boolean; -} - -export interface DesktopRuntimeHostStartupRepairAuthority { - readonly allowManualUpdate: boolean; - readonly allowInterruptActiveTasks: boolean; -} - -export class DesktopRuntimeHostStartupRecoveryCancelledError extends Error { - readonly name = "DesktopRuntimeHostStartupRecoveryCancelledError"; - - constructor(options?: ErrorOptions) { - super("Runtime Host startup recovery was cancelled", options); - } -} - -export async function startDesktopRuntimeHostWithRecovery(input: { - readonly start: () => Promise; - readonly repair: ( - authority: DesktopRuntimeHostStartupRepairAuthority, - ) => Promise; - readonly prompt: ( - input: DesktopRuntimeHostStartupRecoveryPrompt, - ) => Promise<"repair" | "exit">; -}): Promise { - let startupError: Error; - try { - return await input.start(); - } catch (error) { - startupError = asError(error); - if (!canRepairManagedRuntimeHostStartup(startupError)) throw startupError; - } - - let activeTasks = false; - let repairError: Error | undefined; - let automatic: DesktopRuntimeHostStartupRepairResult | undefined; - try { - automatic = await input.repair({ - allowManualUpdate: false, - allowInterruptActiveTasks: false, - }); - } catch (error) { - repairError = asError(error); - } - if (automatic?.kind === "unavailable") throw startupError; - if (automatic?.kind === "active_tasks") activeTasks = true; - if (automatic?.kind === "repaired") { - try { - return await input.start(); - } catch (error) { - startupError = asError(error); - if (!canRepairManagedRuntimeHostStartup(startupError)) throw startupError; - } - } - - for (;;) { - const decision = await input.prompt({ - startupError, - ...(repairError ? { repairError } : {}), - activeTasks, - }); - if (decision === "exit") { - throw new DesktopRuntimeHostStartupRecoveryCancelledError({ - cause: startupError, - }); - } - - let repaired: DesktopRuntimeHostStartupRepairResult; - try { - repaired = await input.repair({ - allowManualUpdate: true, - allowInterruptActiveTasks: activeTasks, - }); - } catch (error) { - repairError = asError(error); - continue; - } - if (repaired.kind === "unavailable") throw startupError; - if (repaired.kind === "active_tasks") { - activeTasks = true; - repairError = undefined; - continue; - } - try { - return await input.start(); - } catch (error) { - startupError = asError(error); - if (!canRepairManagedRuntimeHostStartup(startupError)) throw startupError; - repairError = undefined; - } - } -} export function canRepairManagedRuntimeHostStartup(error: Error): boolean { if (error instanceof OperationalStateMigrationBlockedError) { @@ -135,7 +35,3 @@ export function canRepairManagedRuntimeHostStartup(error: Error): boolean { error.reason === "deployment_needs_repair") ); } - -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} diff --git a/apps/desktop/src/main/runtime-host-upgrade-copy.ts b/apps/desktop/src/main/runtime-host-upgrade-copy.ts deleted file mode 100644 index e08d2574cd..0000000000 --- a/apps/desktop/src/main/runtime-host-upgrade-copy.ts +++ /dev/null @@ -1,212 +0,0 @@ -/* - * 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 type { UiLocale } from '@maka/core/ui-locale'; -import type { MessageBoxOptions } from 'electron'; -import type { - RuntimeHostNonRestartableAction, - RuntimeHostRestartableConflict, - RuntimeHostWaitConflict, -} from './runtime-host-desktop-manager.js'; - -type Conflict = RuntimeHostRestartableConflict | RuntimeHostWaitConflict; -export type RuntimeHostUpgradeDialogDecision = 'restart' | 'replace' | 'wait' | 'cancel'; - -type RuntimeHostUpgradeAvailability = RuntimeHostNonRestartableAction | 'restart'; - -export interface RuntimeHostUpgradeDialog { - readonly options: MessageBoxOptions; - readonly decisions: readonly RuntimeHostUpgradeDialogDecision[]; -} -type ActivityKey = - | 'goal' - | 'scheduledTask' - | 'dailyReview' - | 'execution' - | 'resource' - | 'graph' - | 'other'; - -export function buildRuntimeHostUpgradeDialog( - conflict: Conflict, - availability: RuntimeHostUpgradeAvailability, - locale: UiLocale, -): RuntimeHostUpgradeDialog { - const copy = UPGRADE_COPY[locale]; - const choices: { readonly label: string; readonly decision: RuntimeHostUpgradeDialogDecision }[] = - []; - const action = - availability === 'restart' - ? 'restart' - : availability === 'replace_may_interrupt_work' - ? 'replace' - : undefined; - const canWait = - availability === 'wait' || - (availability === 'restart' && conflict.registration.lifecycleMode !== 'service') || - (availability === 'replace_may_interrupt_work' && - conflict.registration.lifecycleMode === 'ephemeral'); - if (action) { - choices.push({ - label: action === 'restart' ? copy.restart : copy.replace, - decision: action, - }); - } - if (canWait) choices.push({ label: copy.wait, decision: 'wait' }); - choices.push({ label: copy.cancel, decision: 'cancel' }); - const cancelId = choices.length - 1; - return { - options: { - type: 'warning', - title: copy.title, - message: copy.message, - detail: formatActivity(conflict, availability, canWait, locale), - buttons: choices.map((choice) => choice.label), - defaultId: cancelId, - cancelId, - noLink: true, - }, - decisions: choices.map((choice) => choice.decision), - }; -} - -function formatActivity( - conflict: Conflict, - availability: RuntimeHostUpgradeAvailability, - canWait: boolean, - locale: UiLocale, -): string { - const activity = conflict.handshake?.activity; - const copy = UPGRADE_COPY[locale]; - const lines: string[] = []; - lines.push(copy.processId(conflict.registration.pid)); - if (activity) { - const minutes = Math.max(1, Math.round(activity.processUptimeSeconds / 60)); - lines.push(copy.uptime(minutes)); - if (activity.connections > 0) lines.push(copy.connections(activity.connections)); - if (activity.activeOperations > 0) lines.push(copy.operations(activity.activeOperations)); - for (const residency of activity.residencies) { - lines.push(`${copy.activity[activityKey(residency.label)]}: ${residency.count}`); - } - } else if (availability === 'replace_may_interrupt_work') { - lines.push(copy.idleNotVerified); - } else lines.push(copy.unknownActivity); - if (availability === 'replace_may_interrupt_work') { - lines.push('', copy.replaceWarning, copy.replaceExplanation); - } else if (availability === 'restart') { - lines.push('', copy.restartWarning); - } else if (conflict.kind !== 'upgrade_required' || !conflict.restartable) { - lines.push(''); - lines.push(copy.exitOwner(conflict.registration.pid)); - } - if (canWait) { - lines.push(copy.waitExplanation); - } - return lines.join('\n'); -} - -function activityKey(label: string): ActivityKey { - const keys: Record = { - goal: 'goal', - 'scheduled-task': 'scheduledTask', - 'daily-review': 'dailyReview', - 'hosted-execution': 'execution', - 'runtime-resource': 'resource', - 'agent-graph': 'graph', - 'agent-graph-supervisor': 'graph', - }; - return keys[label] ?? 'other'; -} - -const UPGRADE_COPY = { - en: { - title: 'Older Runtime Host is running', - message: 'Another Runtime Host process still owns this workspace.', - restart: 'Restart Runtime Host', - replace: 'Stop Host and Continue', - wait: 'Wait', - cancel: 'Cancel Startup', - uptime: (n: number) => `Running for about ${n} ${n === 1 ? 'minute' : 'minutes'}`, - connections: (n: number) => `${n} other client(s) are still connected`, - operations: (n: number) => `${n} operation(s) are running`, - idleNotVerified: 'Maka could not verify that this Host is idle during the safe replacement check.', - unknownActivity: 'This Host version cannot report its background activity.', - processId: (pid: number) => `Process ID (PID): ${pid}`, - restartWarning: - 'Restarting preserves durable state, but it can interrupt in-flight external work.', - replaceWarning: - 'Stopping preserves durable state, but it can interrupt in-flight external work.', - replaceExplanation: 'Maka will stop this Host, replace it safely, and continue startup.', - exitOwner: (pid: number) => - `End process ${pid} with your system process manager to replace this Host safely.`, - waitExplanation: 'If you wait, Maka will continue automatically when this Host exits.', - activity: { - goal: 'Goal', scheduledTask: 'Scheduled Task', dailyReview: 'Daily Review', - execution: 'Active execution', resource: 'Runtime resource', graph: 'Agent Graph', - other: 'Other background activity', - }, - }, - 'zh-CN': { - title: '旧版 Runtime Host 正在运行', - message: '另一个 Runtime Host 进程仍占用此工作区。', - restart: '重启 Runtime Host', - replace: '停止 Host 并继续', - wait: '等待', - cancel: '取消启动', - uptime: (n: number) => `已运行约 ${n} 分钟`, - connections: (n: number) => `仍有 ${n} 个其他客户端连接`, - operations: (n: number) => `有 ${n} 个操作正在运行`, - idleNotVerified: '安全替换检查无法确认此 Host 是否处于空闲状态。', - unknownActivity: '此 Host 版本无法报告后台活动。', - processId: (pid: number) => `进程 ID (PID):${pid}`, - restartWarning: '重启会保留持久化状态,但可能中断正在进行的外部工作。', - replaceWarning: '停止 Host 会保留持久化状态,但可能中断正在进行的外部工作。', - replaceExplanation: 'Maka 将停止并安全替换此 Host,然后继续启动。', - exitOwner: (pid: number) => - `请使用系统进程管理工具结束进程 ${pid},以便安全替换此 Host。`, - waitExplanation: '若选择等待,当前 Host 退出后 Maka 将自动继续。', - activity: { - goal: '目标', scheduledTask: '计划任务', dailyReview: '每日回顾', execution: '活动执行', - resource: 'Runtime 资源', graph: 'Agent Graph', other: '其他后台活动', - }, - }, - 'zh-TW': { - title: '舊版 Runtime Host 正在執行', - message: '另一個 Runtime Host 程序仍佔用此工作區。', - restart: '重啟 Runtime Host', - replace: '停止 Host 並繼續', - wait: '等待', - cancel: '取消啟動', - uptime: (n: number) => `已執行約 ${n} 分鐘`, - connections: (n: number) => `仍有 ${n} 個其他客戶端連線`, - operations: (n: number) => `有 ${n} 個操作正在執行`, - idleNotVerified: '安全替換檢查無法確認此 Host 是否處於閒置狀態。', - unknownActivity: '此 Host 版本無法報告後臺活動。', - processId: (pid: number) => `程序 ID (PID):${pid}`, - restartWarning: '重啟會保留持久化狀態,但可能中斷正在進行的外部工作。', - replaceWarning: '停止 Host 會保留持久化狀態,但可能中斷正在進行的外部工作。', - replaceExplanation: 'Maka 將停止並安全替換此 Host,然後繼續啟動。', - exitOwner: (pid: number) => `請使用系統程序管理工具結束程序 ${pid},以便安全替換此 Host。`, - waitExplanation: '若選擇等待,目前 Host 退出後 Maka 將自動繼續。', - activity: { - goal: '目標', scheduledTask: '計劃任務', dailyReview: '每日回顧', execution: '活動執行', - resource: 'Runtime 資源', graph: 'Agent Graph', other: '其他後臺活動', - }, - }, -} as const; diff --git a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts deleted file mode 100644 index 488307dc13..0000000000 --- a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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 type { UiLocale } from '@maka/core/ui-locale'; -import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'; -import type { - RuntimeHostRestartDecision, - RuntimeHostUpgradePrompts, - RuntimeHostNonRestartableDecision, -} from './runtime-host-desktop-manager.js'; -import { buildRuntimeHostUpgradeDialog } from './runtime-host-upgrade-copy.js'; - -export function createRuntimeHostUpgradePrompts( - resolveLocale: () => Promise, - showDialog: ( - options: MessageBoxOptions, - locale: UiLocale, - ) => Promise, -): RuntimeHostUpgradePrompts { - return { - restartable: async (conflict): Promise => { - const locale = await resolveLocale(); - const dialog = buildRuntimeHostUpgradeDialog(conflict, 'restart', locale); - const { response } = await showDialog( - dialog.options, - locale, - ); - const decision = dialog.decisions[response] ?? 'cancel'; - return decision === 'restart' || decision === 'wait' ? decision : 'cancel'; - }, - nonRestartable: async ( - conflict, - action, - ): Promise => { - const locale = await resolveLocale(); - const dialog = buildRuntimeHostUpgradeDialog(conflict, action, locale); - const { response } = await showDialog( - dialog.options, - locale, - ); - const decision = dialog.decisions[response] ?? 'cancel'; - return decision === 'replace' || decision === 'wait' ? decision : 'cancel'; - }, - }; -} diff --git a/apps/desktop/src/main/startup-presentation.ts b/apps/desktop/src/main/startup-presentation.ts index 20dbb17920..9f5a5376db 100644 --- a/apps/desktop/src/main/startup-presentation.ts +++ b/apps/desktop/src/main/startup-presentation.ts @@ -17,8 +17,9 @@ * under the License. */ -import { app, BrowserWindow, nativeTheme } from 'electron'; -import { resolveSystemUiLocale } from '@maka/core/ui-locale'; +import { app, BrowserWindow, clipboard, nativeTheme } from 'electron'; +import { resolveSystemUiLocale, type UiLocale } from '@maka/core/ui-locale'; +import type { HostHandoffView, OpenHostHandoffSurface } from '@maka/runtime-host/client'; import { readableAppIconPath } from './app-icon-surface.js'; import { installApplicationMenu } from './application-menu.js'; import { installDesktopStartupBranding } from './desktop-shell-presentation.js'; @@ -31,6 +32,7 @@ import { } from './startup-progress-window.js'; let progress: StartupProgressWindow | undefined; +let handoffUsesStartup = false; const focus = () => progress?.focus(); @@ -55,7 +57,8 @@ export function showDesktopStartupProgress( dark: nativeTheme.shouldUseDarkColors, icon: readableAppIconPath('default'), createWindow: (options) => new BrowserWindow(options), - copyDiagnostics, + copyDiagnostics: (phase, handoff) => handoff + ? clipboard.writeText(JSON.stringify(handoff, null, 2)) : copyDiagnostics(phase), onError: (error) => console.error('[startup] progress presentation failed:', error), }); app.on('activate', focus); @@ -71,6 +74,52 @@ export function updateDesktopStartupProgress(phase: StartupPhase): void { progress?.update(phase); } +/** One presentation lifetime per attempt; startup reuses its already visible window. */ +export function createDesktopHostHandoffSurface(resolveLocale: () => Promise): OpenHostHandoffSurface { + return (submit) => { + let latest: HostHandoffView | undefined; + let window: StartupProgressWindow | undefined; + let locale: UiLocale | undefined; + let ownWindow = false; + let closed = false; + void resolveLocale().then((resolved) => { + if (closed) return; + locale = resolved; + if (progress?.window() && !handoffUsesStartup) { + window = progress; + handoffUsesStartup = true; + } else { + ownWindow = true; + window = createStartupProgressWindow({ + locale, dark: nativeTheme.shouldUseDarkColors, icon: readableAppIconPath('default'), + createWindow: (options) => new BrowserWindow(options), + copyDiagnostics: () => clipboard.writeText(JSON.stringify(latest, null, 2)), + onError: (error) => console.error('[runtime-host] handoff presentation failed:', error), + }); + } + if (latest) window.handoff(latest, submit, locale); + window.focus(); + }).catch((error) => { + console.error('[runtime-host] handoff presentation failed:', error); + if (latest) submit(latest.revision, 'cancel'); + }); + return { + update(view) { + latest = view; + if (window && locale) window.handoff(view, submit, locale); + }, + close() { + closed = true; + if (ownWindow) window?.close(); + else if (window) { + window.clearHandoff(); + handoffUsesStartup = false; + } + }, + }; + }; +} + export function desktopStartupProgressWindow(): BrowserWindow | undefined { return progress?.window(); } diff --git a/apps/desktop/src/main/startup-progress-window.ts b/apps/desktop/src/main/startup-progress-window.ts index 88efd713cd..d935f96fdc 100644 --- a/apps/desktop/src/main/startup-progress-window.ts +++ b/apps/desktop/src/main/startup-progress-window.ts @@ -20,6 +20,7 @@ import { randomUUID } from 'node:crypto'; import { MAKA_WORDMARK_PATH } from '@maka/core/maka-wordmark'; import type { UiLocale } from '@maka/core/ui-locale'; +import { formatHostHandoff, type HostHandoffView, type HostHandoffAction } from '@maka/runtime-host/client'; import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; export type StartupPhase = @@ -70,6 +71,8 @@ const COPY = { export interface StartupProgressWindow { update(phase: StartupPhase): void; + handoff(view: HostHandoffView, submit: (revision: string, action: HostHandoffAction) => void, locale: UiLocale): void; + clearHandoff(): void; focus(): void; close(): void; window(): BrowserWindow | undefined; @@ -81,12 +84,12 @@ export function createStartupProgressWindow(input: { dark: boolean; icon: string; createWindow(options: BrowserWindowConstructorOptions): BrowserWindow; - copyDiagnostics(phase: StartupPhase): void | Promise; + copyDiagnostics(phase: StartupPhase, handoff?: HostHandoffView): void | Promise; onError(error: unknown): void; }): StartupProgressWindow { const copy = COPY[input.locale]; const win = input.createWindow({ - width: 520, height: 390, title: 'Maka', icon: input.icon, + width: 520, height: 350, useContentSize: true, title: 'Maka', icon: input.icon, show: false, resizable: false, maximizable: false, fullscreenable: false, backgroundColor: input.dark ? '#1c1d21' : '#ffffff', webPreferences: { @@ -96,16 +99,37 @@ export function createStartupProgressWindow(input: { }); let closed = false; let loaded = false; + let presentationRevision = 0; let phase: StartupPhase = 'prepare'; - const execute = (source: string) => { + let handoff: { view: HostHandoffView; submit(revision: string, action: HostHandoffAction): void; locale: UiLocale } | undefined; + const execute = (source: string, accept?: (result: unknown) => void) => { if (!closed && loaded && !win.isDestroyed()) { - void win.webContents.executeJavaScript(source).catch(input.onError); + void win.webContents.executeJavaScript(source).then(accept).catch(input.onError); } }; - const publish = () => execute( - 'document.getElementById("phase").textContent = ' + JSON.stringify(copy.phases[phase]) + - '; document.body.dataset.phase = ' + JSON.stringify(phase) + ';', - ); + const publish = () => { + if (closed || !loaded || win.isDestroyed()) return; + const revision = ++presentationRevision; + const width = handoff ? 560 : 520; + const [currentWidth, currentHeight] = win.getContentSize(); + if (currentWidth !== width) win.setContentSize(width, currentHeight); + const fitContent = (height: unknown) => { + if (closed || win.isDestroyed() || revision !== presentationRevision || + typeof height !== 'number' || !Number.isFinite(height)) return; + const fittedHeight = Math.max(240, Math.min(640, Math.ceil(height))); + if (win.getContentSize()[1] !== fittedHeight) win.setContentSize(width, fittedHeight); + }; + if (handoff) { + const presentation = formatHostHandoff(handoff.view, handoff.locale); + execute('window.renderHandoff(' + JSON.stringify({ ...presentation, revision: handoff.view.revision, + state: handoff.view.state, diagnostic: handoff.view.diagnostic }) + + '); document.body.getBoundingClientRect().height;', fitContent); + } else execute( + 'window.renderHandoff(null); document.getElementById("phase").textContent = ' + JSON.stringify(copy.phases[phase]) + + '; document.body.dataset.phase = ' + JSON.stringify(phase) + + '; document.body.getBoundingClientRect().height;', fitContent, + ); + }; const close = () => { if (closed) return; closed = true; @@ -121,8 +145,15 @@ export function createStartupProgressWindow(input: { }); win.webContents.on('will-navigate', (event, url) => { event.preventDefault(); + if (url.startsWith('maka-startup://handoff/')) { + const match = /^maka-startup:\/\/handoff\/([a-z0-9-]+)\/(cancel|retry|interrupt)$/.exec(url); + if (match && handoff?.view.revision === match[1] && handoff.view.actions.includes(match[2] as HostHandoffAction)) { + handoff.submit(match[1], match[2] as HostHandoffAction); + } + return; + } if (url !== 'maka-startup://copy') return; - void Promise.resolve().then(() => input.copyDiagnostics(phase)).then( + void Promise.resolve().then(() => input.copyDiagnostics(phase, handoff?.view)).then( () => execute('document.getElementById("copy").textContent = ' + JSON.stringify(copy.copied)), (error) => { input.onError(error); @@ -133,6 +164,7 @@ export function createStartupProgressWindow(input: { win.webContents.on('will-redirect', (event) => event.preventDefault()); win.webContents.on('render-process-gone', (_event, details) => { input.onError(new Error('Startup renderer exited: ' + details.reason)); + handoff?.submit(handoff.view.revision, 'cancel'); close(); }); // No await: failure to present progress must never block Host recovery. @@ -142,14 +174,29 @@ export function createStartupProgressWindow(input: { if (closed || win.isDestroyed()) return; loaded = true; publish(); - // A confirmation may already be open; never raise progress above it. - win.showInactive(); + if (handoff?.view.state === 'attention') { win.show(); win.focus(); } + else win.showInactive(); }).catch((error) => { input.onError(error); + handoff?.submit(handoff.view.revision, 'cancel'); close(); }); return { update(next) { phase = next; publish(); }, + handoff(view, submit, locale) { + if (closed || win.isDestroyed()) { submit(view.revision, 'cancel'); return; } + const needsAttention = handoff?.view.state !== 'attention' && view.state === 'attention'; + handoff = { view, submit, locale }; + publish(); + if (loaded && needsAttention) { + if (win.isMinimized()) win.restore(); + win.show(); win.focus(); + } + }, + clearHandoff() { + handoff = undefined; + publish(); + }, focus() { if (closed || !loaded || win.isDestroyed()) return; if (win.isMinimized()) win.restore(); @@ -180,15 +227,47 @@ footer { display: flex; justify-content: space-between; align-items: center; mar #elapsed { opacity: .55; font-variant-numeric: tabular-nums; } button { font: inherit; color: inherit; background: transparent; border: 1px solid ${dark ? '#48494f' : '#dedee3'}; border-radius: 7px; padding: 6px 10px; cursor: pointer; } button:hover { background: ${dark ? '#303137' : '#f4f4f6'}; } button:focus-visible { outline: 2px solid #788aff; outline-offset: 3px; } +#handoff-detail { white-space: pre-line; margin-top: 18px; } #actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 24px; } +#actions .destructive { border-color: ${dark ? '#c77976' : '#bc443d'}; color: ${dark ? '#ffada7' : '#a42c25'}; } +#handoff-diagnostic { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 70px; overflow: auto; font: 11px/1.4 monospace; opacity: .6; } +body[data-handoff] #slow, body[data-handoff] .status { display: none; } @keyframes spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } -

${copy.title}

${copy.detail}

+

${copy.title}

${copy.detail}

+
${copy.phases.prepare}

${copy.slow}