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 eec6a5f71c..fcca308dd2 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 @@ -41,8 +41,8 @@ import { } from '../runtime-host-desktop-manager.js'; test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, async () => { - const first = candidateHarness({ delayDisconnect: true }); - const second = candidateHarness(); + const first = candidateHarness({ delayDisconnect: true, hostEpoch: 'host-before' }); + const second = candidateHarness({ hostEpoch: 'host-after' }); const queue = [ready(first.candidate), ready(second.candidate)]; let starts = 0; const interactions: Array = []; @@ -71,6 +71,7 @@ test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, asy }); first.disconnect(); + const replacementReady = owner.waitUntilReady(owner.defaultProfileId(), 'host-before'); const botMessage = owner.handleBotIncomingMessage({ text: 'hello' } as BotIncomingMessage); const stop = owner.stopSession({ hostId: 'test-host', @@ -95,7 +96,7 @@ test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, asy assert.equal(second.botMessages, 0); assert.deepEqual(second.stoppedSessions, []); releaseSecond(); - await Promise.all([botMessage, stop]); + await Promise.all([botMessage, stop, replacementReady]); assert.equal(first.botMessages, 0); assert.equal(second.botMessages, 1); @@ -815,6 +816,7 @@ function candidateHarness( activeTasks?: boolean; lifecycleMode?: 'ephemeral' | 'service' | 'remote'; hostId?: string; + hostEpoch?: string; finalizeFailures?: Error[]; disconnectOnFinalizeFailure?: boolean; onPrepare?: () => void; @@ -837,6 +839,7 @@ function candidateHarness( hostLifecycleMode: options.lifecycleMode ?? 'ephemeral', client: { hostId: options.hostId ?? 'test-host', + hostEpoch: options.hostEpoch ?? 'test-host-epoch', get lifecycleState() { return lifecycleState; }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts index 26f03917e2..ba58046405 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -28,6 +28,7 @@ import type { DesktopRuntimeHostSshAccessInput, DesktopRuntimeHostSshCleanupInput, DesktopRuntimeHostSshManagementInput, + DesktopRuntimeHostSshUpdateInput, } from '../runtime-host-ssh-terminal.js'; test('identifies, rotates, and revokes managed credentials without exposing secrets', async () => { @@ -64,6 +65,7 @@ test('identifies, rotates, and revokes managed credentials without exposing secr ]; createDesktopRuntimeHostManagement({ + ...unusedUpdateDependencies(), ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), removeHandler: (channel) => handlers.delete(channel), @@ -185,6 +187,8 @@ test('manages only the service identity bound by Desktop onboarding', async () = const uninstallOrder: string[] = []; let operatorAccess = false; let cleared = 0; + let statusGate: Promise | undefined; + let releaseStatus: (() => void) | undefined; const managedProfile = { id: 'office', name: 'Office', @@ -203,6 +207,7 @@ test('manages only the service identity bound by Desktop onboarding', async () = operatorPath: '/home/operator/.local/share/maka/operator', }; const management = createDesktopRuntimeHostManagement({ + ...unusedUpdateDependencies(), ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), removeHandler: (channel) => handlers.delete(channel), @@ -229,6 +234,7 @@ test('manages only the service identity bound by Desktop onboarding', async () = }, runServiceManagement: async (input) => { managementInputs.push(input); + if (input.action === 'status') await statusGate; if (input.action === 'uninstall') { uninstallOrder.push('uninstall-service'); } @@ -243,6 +249,19 @@ test('manages only the service identity bound by Desktop onboarding', async () = const run = handlers.get('runtime-host-management:run'); assert.ok(run); + statusGate = new Promise((resolve) => { + releaseStatus = resolve; + }); + const firstStatus = run({}, 'office', 'status'); + const secondStatus = run({}, 'office', 'status'); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(managementInputs.length, 1); + releaseStatus?.(); + await Promise.all([firstStatus, secondStatus]); + statusGate = undefined; + managementInputs.length = 0; + await assert.rejects( run({}, 'manual', 'uninstall') as Promise, /not bound to a managed service/u, @@ -309,6 +328,121 @@ test('manages only the service identity bound by Desktop onboarding', async () = assert.equal(handlers.size, 0); }); +test('publishes update progress and waits for the managed profile to reconnect', async () => { + const handlers = new Map unknown>(); + const updates: DesktopRuntimeHostSshUpdateInput[] = []; + const progress: unknown[] = []; + const connectionCompletions: unknown[] = []; + let failConnection = false; + let bindingPresent = true; + let removeBindingAfterUpdate = false; + const profile = { + id: 'office', + name: 'Office', + kind: 'remote' as const, + rootId: 'a'.repeat(64), + transport: { + kind: 'ssh' as const, + destination: 'operator@example.com', + remotePort: 7443, + websocketPath: '/runtime-host', + }, + }; + const service = { + id: 'b'.repeat(64), + rootPath: '/srv/maka', + operatorPath: '/home/operator/.local/share/maka/operator', + }; + createDesktopRuntimeHostManagement({ + ipcMain: { + handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), + removeHandler: (channel) => handlers.delete(channel), + }, + profiles: { + resolveManagedService: async () => + bindingPresent ? { profile, service, state: 'active' as const } : undefined, + resolveManagedAccess: async () => undefined, + rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), + markManagedServiceUninstalling: async (binding) => binding, + markManagedServiceCleanupPending: async (binding) => binding, + clearManagedServiceBinding: async () => undefined, + }, + runServiceManagement: async () => assert.fail('ordinary management is not expected'), + runUpdate: async (input, onProgress) => { + updates.push(input); + onProgress('staging'); + if (removeBindingAfterUpdate) bindingPresent = false; + return { + schemaVersion: 1, + kind: 'result', + action: 'update', + service: { + platform: 'linux', + arch: 'x64', + osRelease: '6.8.0', + state: 'running', + pid: 43, + lastExitCode: 0, + installedVersion: '1.3.0', + projectDirectoryRoots: [], + }, + operatorCapabilities: ['access-management-v1'], + update: { kind: 'updated', previousVersion: '1.2.3', targetVersion: '1.3.0' }, + }; + }, + resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.3.0' }), + currentHostEpoch: () => 'host-before-update', + awaitUpdatedConnection: async (...args) => { + connectionCompletions.push(args); + if (failConnection) throw new Error('authentication required'); + }, + sendProgress: (event) => progress.push(event), + runAccessManagement: async () => assert.fail('access management is not expected'), + cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'), + }); + + const update = handlers.get('runtime-host-management:update'); + assert.ok(update); + const response = await update({}, profile.id, false); + assert.equal((response as { accessManagementAvailable: boolean }).accessManagementAvailable, true); + assert.deepEqual(updates, [{ + destination: profile.transport.destination, + setupPackage: { kind: 'npm', specifier: 'maka-agent@1.3.0' }, + expectedTarget: { + serviceId: service.id, + rootPath: service.rootPath, + rootId: profile.rootId, + }, + }]); + assert.deepEqual(progress, [{ profileId: profile.id, phase: 'staging' }]); + assert.deepEqual(connectionCompletions, [ + [profile.id, profile.rootId, 'host-before-update', true], + ]); + + removeBindingAfterUpdate = true; + const changedProfile = await update({}, profile.id, false); + assert.equal( + (changedProfile as { kind: string; error?: { message: string } }).error?.message, + 'The Runtime Host update completed, but Desktop could not reconnect: ' + + 'Runtime Host profile changed while its service was updating', + ); + + bindingPresent = true; + removeBindingAfterUpdate = false; + failConnection = true; + const reconnectFailure = await update({}, profile.id, false); + assert.deepEqual(reconnectFailure, { + schemaVersion: 1, + kind: 'error', + action: 'update', + error: { + code: 'desktop_reconnect_failed', + message: + 'The Runtime Host update completed, but Desktop could not reconnect: authentication required', + }, + }); +}); + test('resumes deployment cleanup without invoking the removed operator', async () => { const handlers = new Map unknown>(); const profile = { @@ -333,6 +467,7 @@ test('resumes deployment cleanup without invoking the removed operator', async ( let state: 'active' | 'uninstalling' | 'cleanup_pending' = 'active'; let clearAttempts = 0; createDesktopRuntimeHostManagement({ + ...unusedUpdateDependencies(), ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), removeHandler: (channel) => handlers.delete(channel), @@ -384,6 +519,7 @@ test('rechecks uninstall intent before retrying the remote service', async () => const handlers = new Map unknown>(); let marked = false; createDesktopRuntimeHostManagement({ + ...unusedUpdateDependencies(), ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), removeHandler: (channel) => handlers.delete(channel), @@ -464,6 +600,16 @@ function serviceResult( : { ...result, action }; } +function unusedUpdateDependencies() { + return { + runUpdate: async (): Promise => assert.fail('update is not expected'), + resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.2.3' } as const), + currentHostEpoch: () => undefined, + awaitUpdatedConnection: async () => undefined, + sendProgress: () => undefined, + }; +} + function accessCredential( credentialId: string, principalId: string, diff --git a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts index abb39cece7..3ccb04e3fa 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts @@ -225,6 +225,43 @@ test("keeps Local enabled while a new remote Host connects", async () => { ); }); +test("reconnects an enabled remote Host with interactive SSH", async () => { + const root = await clientRoot(); + const catalog = createClientRuntimeHostProfileCatalog(root); + await catalog.create(MANAGED_PROFILE, "opaque-token"); + const calls: string[] = []; + const service = createDesktopRuntimeHostProfileService({ + clientDataRoot: root, + startup: { + preferences: { + schemaVersion: 2, + defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id, + enabledRemoteProfileIds: [MANAGED_PROFILE.id], + }, + pairingIntents: [], + remotes: [{ profile: MANAGED_PROFILE, credential: "opaque-token" }], + unavailable: new Map(), + }, + catalog, + states: () => [connectingLocal()], + enable: async (target, interaction) => { + calls.push(`enable:${target.profile.id}:${interaction}`); + }, + disable: async (profileId) => { + calls.push(`disable:${profileId}`); + }, + setDefault: () => undefined, + finalizePairing: async () => undefined, + }); + + await service.reconnect(MANAGED_PROFILE.id, MANAGED_PROFILE.rootId); + + assert.deepEqual(calls, [ + `disable:${MANAGED_PROFILE.id}`, + `enable:${MANAGED_PROFILE.id}:terminal`, + ]); +}); + test("does not enable the same State Root twice", async () => { const root = await clientRoot(); const startup = await resolveDesktopRuntimeHostStartup(root); diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts index 0ea4e53a36..fd688ee6d9 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts @@ -308,6 +308,70 @@ test('keeps a received management result when SSH teardown times out', async () await harness.terminal.close(); }); +test('runs an exact update package and reports progress before an active-work result', async () => { + const harness = createHarness('pending'); + const phases: string[] = []; + const update = harness.terminal.runUpdate( + { + destination: 'operator@example.com', + setupPackage: { kind: 'npm', specifier: 'maka-agent@1.3.0' }, + expectedTarget: { + serviceId: 'b'.repeat(64), + rootPath: '/srv/maka', + rootId: 'a'.repeat(64), + }, + }, + (phase) => phases.push(phase), + ); + await waitFor(() => harness.pty.hasDataListener()); + const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? ''; + assert.match(remoteCommand, /--package.*maka-agent@1\.3\.0/u); + assert.match(remoteCommand, /runtime-host.*service.*update/u); + assert.match(remoteCommand, /MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST/u); + harness.pty.emitData('Password: '); + harness.pty.emitData( + encodeRuntimeHostServiceManagementFrame({ + schemaVersion: 1, + kind: 'progress', + action: 'update', + phase: 'retiring', + currentVersion: '1.2.3', + targetVersion: '1.3.0', + }), + ); + harness.pty.emitData( + encodeRuntimeHostServiceManagementFrame({ + schemaVersion: 1, + kind: 'result', + action: 'update', + service: { + platform: 'linux', + arch: 'x64', + osRelease: '6.8.0', + state: 'running', + pid: 42, + lastExitCode: 0, + installedVersion: '1.2.3', + projectDirectoryRoots: [], + }, + update: { + kind: 'active_tasks', + currentVersion: '1.2.3', + targetVersion: '1.3.0', + }, + }), + ); + harness.pty.exit(1); + + const result = await update; + assert.equal(result.kind, 'result'); + assert.equal(result.kind === 'result' ? result.update.kind : undefined, 'active_tasks'); + assert.deepEqual(phases, ['retiring']); + assert.deepEqual(harness.events.map(({ kind }) => kind), ['opened', 'data', 'connected']); + assert.doesNotMatch(JSON.stringify(harness.events), /MAKA_RUNTIME_HOST_SERVICE/u); + await harness.terminal.close(); +}); + test('keeps a prepared access credential out of the SSH terminal projection', async () => { const harness = createHarness('pending'); const credential = 'maka_rh_secret-replacement'; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 333dbe303d..1121556729 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -210,6 +210,7 @@ import { await resolveShellEnv(); +const MANAGED_UPDATE_RECONNECT_TIMEOUT_MS = 10_000; const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath()); const userDataDir = app.getPath("userData"); const runtimeHostClientInstanceId = await loadOrCreateRuntimeHostClientInstanceId( @@ -443,6 +444,49 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ ipcMain, profiles: runtimeHostProfileService, runServiceManagement: runtimeHostSshTerminal.runServiceManagement, + runUpdate: runtimeHostSshTerminal.runUpdate, + resolveUpdatePackage: runtimeHostSetupPackage, + currentHostEpoch: (profileId) => + runtimeHostManager?.current(profileId)?.candidate?.client.hostEpoch, + awaitUpdatedConnection: async ( + profileId, + expectedHostId, + previousHostEpoch, + replacementExpected, + ) => { + if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable'); + const manager = runtimeHostManager; + const expectedPrevious = replacementExpected ? previousHostEpoch : undefined; + const reconnectExactTarget = async () => { + await runtimeHostProfileService.reconnect(profileId, expectedHostId); + const current = manager.current(profileId); + if ( + current?.hostId !== expectedHostId || + !current.candidate || + (expectedPrevious !== undefined && current.candidate.client.hostEpoch === expectedPrevious) + ) { + throw new Error('Desktop reconnected to an unexpected Runtime Host generation'); + } + }; + if (replacementExpected && previousHostEpoch === undefined) { + await reconnectExactTarget(); + return; + } + try { + await manager.waitUntilReady( + profileId, + expectedPrevious, + AbortSignal.timeout(MANAGED_UPDATE_RECONNECT_TIMEOUT_MS), + ); + if (manager.current(profileId)?.hostId !== expectedHostId) { + throw new Error('Runtime Host profile changed while its service was updating'); + } + } catch { + await reconnectExactTarget(); + } + }, + sendProgress: (progress) => + mainWindowController.send("runtime-host-management:progress", progress), runAccessManagement: runtimeHostSshTerminal.runAccessManagement, cleanupManagedDeployment: runtimeHostSshTerminal.cleanupManagedDeployment, }); diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 78b6061457..e69bd8a405 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -64,6 +64,11 @@ export interface RuntimeHostDesktopManager { remote: DesktopRuntimeHostCandidateStartInput['remote'], ): Promise; disable(profileId: string): Promise; + waitUntilReady( + profileId: string, + previousHostEpoch?: string, + signal?: AbortSignal, + ): Promise; setDefaultProfile(profileId: string): void; prepareForUpdate( allowInterruptActiveTasks: boolean, @@ -457,6 +462,26 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return this.#mutateTarget(profileId, () => this.#disable(profileId)); } + async waitUntilReady( + profileId: string, + previousHostEpoch?: string, + signal?: AbortSignal, + ): Promise { + const target = this.#requireTarget(profileId); + let candidate = await this.#waitForReadyCandidate( + this.#requireLifecycle(target), + undefined, + signal, + ); + while (previousHostEpoch !== undefined && candidate.client.hostEpoch === previousHostEpoch) { + candidate = await this.#waitForReadyCandidate( + this.#requireLifecycle(target), + candidate, + signal, + ); + } + } + async #disable(profileId: string): Promise { if (profileId === LOCAL_RUNTIME_HOST_PROFILE.id) { throw new Error('Local Runtime Host cannot be disabled'); diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index 75a700d0a2..12d4751e60 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -28,12 +28,17 @@ import type { DesktopRuntimeHostAccessSnapshot, DesktopRuntimeHostManagementAction, DesktopRuntimeHostManagementResponse, + DesktopRuntimeHostManagementProgress, } from '../preload/bridge-contract.js'; import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js'; +import { sameDesktopRuntimeHostManagedServiceBinding } from './runtime-host-managed-services.js'; import type { DesktopRuntimeHostSshCleanupInput, DesktopRuntimeHostSshAccessInput, DesktopRuntimeHostSshManagementInput, + DesktopRuntimeHostSshUpdateInput, + DesktopRuntimeHostSetupPackage, + RuntimeHostServiceUpdateTerminalFrame, } from './runtime-host-ssh-terminal.js'; const MANAGEMENT_ACTIONS = new Set([ @@ -63,10 +68,23 @@ export function createDesktopRuntimeHostManagement(input: { >; readonly runServiceManagement: ( input: DesktopRuntimeHostSshManagementInput, - ) => Promise; + ) => Promise>; readonly runAccessManagement: ( input: DesktopRuntimeHostSshAccessInput, ) => Promise; + readonly runUpdate: ( + input: DesktopRuntimeHostSshUpdateInput, + onProgress: (phase: DesktopRuntimeHostManagementProgress['phase']) => void, + ) => Promise; + readonly resolveUpdatePackage: () => DesktopRuntimeHostSetupPackage; + readonly currentHostEpoch: (profileId: string) => string | undefined; + readonly awaitUpdatedConnection: ( + profileId: string, + expectedHostId: string, + previousHostEpoch: string | undefined, + replacementExpected: boolean, + ) => Promise; + readonly sendProgress: (progress: DesktopRuntimeHostManagementProgress) => void; readonly cleanupManagedDeployment: ( input: DesktopRuntimeHostSshCleanupInput, ) => Promise; @@ -83,14 +101,11 @@ export function createDesktopRuntimeHostManagement(input: { return managed; }; - const run = async ( - profileId: unknown, - action: unknown, + const statusRequests = new Map>(); + const runManagedAction = async ( + profileId: string, + managementAction: DesktopRuntimeHostManagementAction, ): Promise => { - if (!MANAGEMENT_ACTIONS.has(action as DesktopRuntimeHostManagementAction)) { - throw new Error('Runtime Host service management action is invalid'); - } - const managementAction = action as DesktopRuntimeHostManagementAction; const managed = await resolveManagedService(profileId); const { profile, service } = managed; if (profile.transport.kind !== 'ssh') { @@ -152,6 +167,26 @@ export function createDesktopRuntimeHostManagement(input: { await input.profiles.clearManagedServiceBinding(pending); return { kind: 'uninstalled', retainedStateRoot: service.rootPath }; }; + const run = ( + profileIdValue: unknown, + action: unknown, + ): Promise => { + if (!MANAGEMENT_ACTIONS.has(action as DesktopRuntimeHostManagementAction)) { + throw new Error('Runtime Host service management action is invalid'); + } + const profileId = requireProfileId(profileIdValue); + const managementAction = action as DesktopRuntimeHostManagementAction; + if (managementAction !== 'status') return runManagedAction(profileId, managementAction); + const existing = statusRequests.get(profileId); + if (existing) return existing; + const request = runManagedAction(profileId, managementAction); + statusRequests.set(profileId, request); + const forget = () => { + if (statusRequests.get(profileId) === request) statusRequests.delete(profileId); + }; + void request.then(forget, forget); + return request; + }; const resolveAccess = async (value: unknown) => { const profileId = requireProfileId(value); @@ -181,6 +216,75 @@ export function createDesktopRuntimeHostManagement(input: { }; }; + const update = async ( + profileIdValue: unknown, + allowInterruptActiveTasksValue: unknown, + ): Promise => { + if (typeof allowInterruptActiveTasksValue !== 'boolean') { + throw new Error('Runtime Host update interruption authority is invalid'); + } + const profileId = requireProfileId(profileIdValue); + const managed = await resolveManagedService(profileId); + if (managed.state !== 'active' || managed.profile.transport.kind !== 'ssh') { + throw new Error('This Runtime Host profile is not available for managed updates'); + } + const previousHostEpoch = input.currentHostEpoch(profileId); + const response = await input.runUpdate( + { + destination: managed.profile.transport.destination, + ...(managed.profile.transport.sshPort === undefined + ? {} + : { sshPort: managed.profile.transport.sshPort }), + setupPackage: input.resolveUpdatePackage(), + expectedTarget: { + serviceId: managed.service.id, + rootPath: managed.service.rootPath, + rootId: managed.profile.rootId, + }, + ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), + }, + (phase) => input.sendProgress({ profileId, phase }), + ); + if ( + response.kind === 'result' && + response.update.kind !== 'active_tasks' + ) { + try { + const current = await input.profiles.resolveManagedService(profileId); + if (!current || !sameDesktopRuntimeHostManagedServiceBinding(current, managed)) { + throw new Error('Runtime Host profile changed while its service was updating'); + } + await input.awaitUpdatedConnection( + profileId, + managed.profile.rootId, + previousHostEpoch, + response.update.kind !== 'already_current', + ); + } catch (error) { + return { + schemaVersion: 1, + kind: 'error', + action: 'update', + error: { + code: 'desktop_reconnect_failed', + message: + 'The Runtime Host update completed, but Desktop could not reconnect: ' + + (error instanceof Error ? error.message : String(error)), + }, + }; + } + } + return response.kind === 'result' + ? { + ...response, + accessManagementAvailable: + response.operatorCapabilities?.includes( + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + ) ?? false, + } + : response; + }; + const accessSnapshot = ( credentials: Extract< RuntimeHostAccessManagementFrame, @@ -298,18 +402,24 @@ export function createDesktopRuntimeHostManagement(input: { const channels = [ 'runtime-host-management:run', + 'runtime-host-management:update', 'runtime-host-management:list-credentials', 'runtime-host-management:rotate-credential', 'runtime-host-management:revoke-credential', ] as const; input.ipcMain.handle(channels[0], (_event, profileId: unknown, action: unknown) => run(profileId, action)); - input.ipcMain.handle(channels[1], (_event, profileId: unknown) => - listCredentials(profileId)); + input.ipcMain.handle( + channels[1], + (_event, profileId: unknown, allowInterruptActiveTasks: unknown) => + update(profileId, allowInterruptActiveTasks), + ); input.ipcMain.handle(channels[2], (_event, profileId: unknown) => + listCredentials(profileId)); + input.ipcMain.handle(channels[3], (_event, profileId: unknown) => rotateCredential(profileId)); input.ipcMain.handle( - channels[3], + channels[4], (_event, profileId: unknown, credentialId: unknown) => revokeCredential(profileId, credentialId), ); diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index 38b2fcee22..92c9aff152 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -113,6 +113,7 @@ export interface DesktopRuntimeHostProfileService { startEnabledProfiles(): Promise; resolvePairingRecovery(): Promise; setEnabled(profileId: string, enabled: boolean): Promise; + reconnect(profileId: string, expectedRootId: string): Promise; setDefault(profileId: string): Promise; remove(profileId: string): Promise; } @@ -875,6 +876,20 @@ export function createDesktopRuntimeHostProfileService(input: { return snapshot(); }); }, + reconnect(profileId, expectedRootId) { + return mutateProfiles(async () => { + assertPairingComplete(profileId); + if (!preferences.enabledRemoteProfileIds.includes(profileId)) { + throw new Error('Enable this Runtime Host before reconnecting it'); + } + const target = await catalog.resolve(profileId); + if (target.profile.kind !== 'remote' || target.profile.rootId !== expectedRootId) { + throw new Error('Runtime Host profile changed before it could reconnect'); + } + await input.disable(profileId); + await activateTarget(target, 'terminal'); + }); + }, setDefault(profileId) { return mutateProfiles(async () => { if ( diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index cc415e7614..071b8794c0 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -44,6 +44,7 @@ import { type RuntimeHostAccessManagementFrame, type RuntimeHostServiceManagementAction, type RuntimeHostServiceManagementFrame, + type RuntimeHostServiceUpdatePhase, type RuntimeHostSetupFrame, } from '@maka/runtime-host/operator'; import type { @@ -59,6 +60,7 @@ interface ActiveTerminal { phase: 'connecting' | 'connected'; revealed: boolean; dismissed: boolean; + presentationSuppressed: boolean; output: string; } @@ -83,7 +85,7 @@ export interface DesktopRuntimeHostSshManagementInput { readonly destination: string; readonly sshPort?: number; readonly operatorPath: string; - readonly action: RuntimeHostServiceManagementAction; + readonly action: Exclude; readonly expectedTarget: { readonly serviceId: string; readonly rootPath: string; @@ -96,6 +98,15 @@ export interface DesktopRuntimeHostSshManagementInput { readonly signal?: AbortSignal; } +export interface DesktopRuntimeHostSshUpdateInput { + readonly destination: string; + readonly sshPort?: number; + readonly setupPackage: DesktopRuntimeHostSetupPackage; + readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; + readonly allowInterruptActiveTasks?: boolean; + readonly signal?: AbortSignal; +} + export interface DesktopRuntimeHostSshCleanupInput { readonly destination: string; readonly sshPort?: number; @@ -128,6 +139,12 @@ export type DesktopRuntimeHostSetupPackage = | { readonly kind: 'npm'; readonly specifier: string } | { readonly kind: 'development_archive'; readonly path: string }; +export type RuntimeHostServiceUpdateTerminalFrame = + | Extract + | (Extract & { + readonly action: 'update'; + }); + export function isExactRuntimeHostSetupPackageSpecifier(value: unknown): value is string { return typeof value === 'string' && /^maka-agent@[0-9][0-9A-Za-z.+-]*$/u.test(value); } @@ -151,7 +168,11 @@ export function createDesktopRuntimeHostSshTerminal(input: { ): Promise; runServiceManagement( input: DesktopRuntimeHostSshManagementInput, - ): Promise; + ): Promise>; + runUpdate( + input: DesktopRuntimeHostSshUpdateInput, + onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, + ): Promise; runAccessManagement( input: DesktopRuntimeHostSshAccessInput, ): Promise; @@ -187,6 +208,23 @@ export function createDesktopRuntimeHostSshTerminal(input: { clearTimeout(terminal.revealTimer); terminal.revealTimer = undefined; } + if (terminal.revealed && !terminal.presentationSuppressed) { + input.send('runtime-host-ssh-terminal:event', { + kind: 'connected', + revision, + sessionId: terminal.sessionId, + }); + } + } + function suppressPresentation(terminal: ActiveTerminal): void { + if (active !== terminal || terminal.phase !== 'connecting') return; + terminal.presentationSuppressed = true; + presentation = undefined; + revision += 1; + if (terminal.revealTimer !== undefined) { + clearTimeout(terminal.revealTimer); + terminal.revealTimer = undefined; + } if (terminal.revealed) { input.send('runtime-host-ssh-terminal:event', { kind: 'connected', @@ -229,6 +267,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { phase: 'connecting', revealed: false, dismissed: false, + presentationSuppressed: false, output: '', }; active = terminal; @@ -237,7 +276,8 @@ export function createDesktopRuntimeHostSshTerminal(input: { active !== terminal || terminal.phase !== 'connecting' || terminal.revealed || - terminal.dismissed + terminal.dismissed || + terminal.presentationSuppressed ) { return; } @@ -250,7 +290,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { pty.onData((data) => { if (active !== terminal || terminal.phase !== 'connecting' || terminal.dismissed) return; const visible = transformOutput(data); - if (!visible) return; + if (!visible || terminal.presentationSuppressed) return; terminal.output = `${terminal.output}${visible}`.slice(-TERMINAL_OUTPUT_MAX); reveal(); revision += 1; @@ -364,7 +404,10 @@ export function createDesktopRuntimeHostSshTerminal(input: { readonly decode: (line: string) => Frame | undefined; readonly action: string; readonly frameAction: (frame: Frame) => string; + readonly isTerminalFrame?: (frame: Frame) => boolean; + readonly onProgress?: (frame: Frame) => void; readonly label: string; + readonly timeoutMs?: number; }): Promise => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); options.signal?.throwIfAborted(); @@ -373,6 +416,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { let frame: Frame | undefined; let failure: Error | undefined; let activeTerminal: ActiveTerminal | undefined; + let receivedProgress = false; const filter = createFramedOutputFilter({ prefix: options.prefix, pendingMaxBytes: options.pendingMaxBytes, @@ -384,6 +428,12 @@ export function createDesktopRuntimeHostSshTerminal(input: { failure = new Error(`${options.label} returned ${action} for ${options.action}`); return; } + if (options.isTerminalFrame && !options.isTerminalFrame(next)) { + receivedProgress = true; + if (activeTerminal) suppressPresentation(activeTerminal); + options.onProgress?.(next); + return; + } if (frame) { failure = new Error(`${options.label} returned multiple results`); return; @@ -403,9 +453,10 @@ export function createDesktopRuntimeHostSshTerminal(input: { ); activeTerminal = terminal; if (frame) completePresentation(terminal); + else if (receivedProgress) suppressPresentation(terminal); const wait = await waitForTerminalProcess(process, { signal: options.signal, - timeoutMs: input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS, + timeoutMs: options.timeoutMs ?? input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS, stopGraceMs: input.processStopGraceMs, onAbort: () => dismissPresentation(terminal), }); @@ -510,8 +561,8 @@ export function createDesktopRuntimeHostSshTerminal(input: { cancellation.close(); } }, - runServiceManagement: (managementInput) => - runFramedManagement({ + runServiceManagement: async (managementInput) => { + const frame = await runFramedManagement({ ...managementInput, remoteCommand: runtimeHostServiceManagementRemoteCommand(managementInput), prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, @@ -520,7 +571,52 @@ export function createDesktopRuntimeHostSshTerminal(input: { action: managementInput.action, frameAction: (frame) => frame.action, label: 'Remote Runtime Host service management', - }), + }); + if (frame.kind === 'progress') { + throw new Error('Remote Runtime Host service management returned update progress'); + } + return frame; + }, + runUpdate: async (updateInput, onProgress) => { + if (closed) throw new Error('Runtime Host SSH terminal is closed'); + updateInput.signal?.throwIfAborted(); + const destination = normalizeRuntimeHostSshDestination(updateInput.destination); + const sshPort = updateInput.sshPort === undefined + ? undefined + : requireSetupPort(updateInput.sshPort); + const setupPackage = await prepareSetupPackage( + updateInput.setupPackage, + destination, + sshPort, + updateInput.expectedTarget.serviceId, + startTerminalProcess, + updateInput.signal, + input.processStopGraceMs, + dismissPresentation, + ); + const frame = await runFramedManagement({ + ...updateInput, + destination, + ...(sshPort === undefined ? {} : { sshPort }), + remoteCommand: runtimeHostUpdateRemoteCommand(setupPackage, updateInput), + prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + pendingMaxBytes: MANAGEMENT_FRAME_PENDING_MAX, + decode: decodeRuntimeHostServiceManagementFrame, + action: 'update', + frameAction: (candidate) => candidate.action, + isTerminalFrame: (candidate) => candidate.kind !== 'progress', + onProgress: (candidate) => { + if (candidate.kind === 'progress') onProgress(candidate.phase); + }, + label: 'Remote Runtime Host update', + timeoutMs: SETUP_TIMEOUT_MS, + }); + if (frame.kind === 'result' && frame.action === 'update') return frame; + if (frame.kind === 'error' && frame.action === 'update') { + return { ...frame, action: 'update' }; + } + throw new Error('Remote Runtime Host update returned an invalid result'); + }, runAccessManagement: (accessInput) => runFramedManagement({ ...accessInput, @@ -861,6 +957,27 @@ function runtimeHostServiceManagementRemoteCommand( return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; } +function runtimeHostUpdateRemoteCommand( + setupPackage: PreparedSetupPackage, + input: DesktopRuntimeHostSshUpdateInput, +): string { + return runtimeHostPackageRemoteCommand( + setupPackage, + [ + 'runtime-host', + 'service', + 'update', + '--framed', + ...managedServiceTargetArgs(input.expectedTarget), + ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), + ], + { + [RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]: + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + }, + ); +} + function runtimeHostAccessManagementRemoteCommand( input: DesktopRuntimeHostSshAccessInput, ): string { @@ -919,11 +1036,16 @@ function managedServiceTargetArgs(input: { function runtimeHostPackageRemoteCommand( setupPackage: PreparedSetupPackage, args: readonly string[], + environment: Readonly> = {}, ): string { const commandArgs = ['maka', ...args].map(quotePosix).join(' '); + const environmentPrefix = Object.entries(environment) + .map(([name, value]) => `${name}=${quotePosix(value)}`) + .join(' '); + const invocationPrefix = environmentPrefix ? `${environmentPrefix} ` : ''; const commandInvocation = setupPackage.removeAfterSetup - ? `npx --yes --package ${quotePosix(setupPackage.specifier)} ${commandArgs}` - : `npx --yes --prefix "$maka_command_prefix" --package ${quotePosix(setupPackage.specifier)} ${commandArgs}`; + ? `${invocationPrefix}npx --yes --package ${quotePosix(setupPackage.specifier)} ${commandArgs}` + : `${invocationPrefix}npx --yes --prefix "$maka_command_prefix" --package ${quotePosix(setupPackage.specifier)} ${commandArgs}`; const command = setupPackage.removeAfterSetup ? `cd "$HOME" || exit 1; maka_command_exit=0; ${commandInvocation} || maka_command_exit=$?; rm -f -- ${quotePosix(setupPackage.removeAfterSetup)}; exit "$maka_command_exit"` : `maka_command_prefix=$(mktemp -d) || exit 1; trap 'rm -rf -- "$maka_command_prefix"' EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM; cd "$maka_command_prefix" || exit 1; ${commandInvocation}`; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 1230edc609..04516630a1 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -435,6 +435,11 @@ export type DesktopRuntimeHostManagementResponse = readonly retainedStateRoot: string; }; +export interface DesktopRuntimeHostManagementProgress { + readonly profileId: string; + readonly phase: import('@maka/runtime-host/operator').RuntimeHostServiceUpdatePhase; +} + export interface DesktopRuntimeHostAccessCredential { readonly credentialId: string; readonly principalKind: 'remote_owner' | 'capability_provider'; @@ -562,6 +567,13 @@ export interface MakaBridge { profileId: string, action: DesktopRuntimeHostManagementAction, ): Promise; + update( + profileId: string, + allowInterruptActiveTasks: boolean, + ): Promise; + subscribeProgress( + handler: (progress: DesktopRuntimeHostManagementProgress) => void, + ): () => void; listCredentials(profileId: string): Promise; rotateCredential(profileId: string): Promise; revokeCredential( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index a9ea2df06c..0c712f3a48 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -45,6 +45,7 @@ import type { DesktopRuntimeHostOnboardingSnapshot, DesktopRuntimeHostManagementAction, DesktopRuntimeHostManagementResponse, + DesktopRuntimeHostManagementProgress, DesktopRuntimeHostAccessSnapshot, DesktopNewTaskCatalog, DesktopNewTaskHost, @@ -1199,6 +1200,24 @@ const makaBridge = { ): Promise { return ipcRenderer.invoke('runtime-host-management:run', profileId, action); }, + update( + profileId: string, + allowInterruptActiveTasks: boolean, + ): Promise { + return ipcRenderer.invoke( + 'runtime-host-management:update', + profileId, + allowInterruptActiveTasks, + ); + }, + subscribeProgress(handler: (progress: DesktopRuntimeHostManagementProgress) => void) { + const listener = ( + _event: Electron.IpcRendererEvent, + progress: DesktopRuntimeHostManagementProgress, + ) => handler(progress); + ipcRenderer.on('runtime-host-management:progress', listener); + return () => ipcRenderer.off('runtime-host-management:progress', listener); + }, listCredentials(profileId: string): Promise { return ipcRenderer.invoke('runtime-host-management:list-credentials', profileId); }, diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index 68af99b200..64f43ddee5 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -91,6 +91,14 @@ export type SettingsProjectsCopy = { startService: string; restartService: string; repairService: string; + updateService: string; + updatePhase: Record; + updateBlockedTitle: string; + updateBlockedBody: string; + updateInterrupt: string; + updateComplete(from: string, to: string): string; + updateRepaired(version: string): string; + updateAlreadyCurrent(version: string): string; showLogs: string; noLogs: string; uninstallService: string; @@ -255,6 +263,19 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { startService: '启动', restartService: '重启', repairService: '修复', + updateService: '安装 Desktop 版本', + updatePhase: { + checking: '正在检查版本…', + staging: '正在准备新版本…', + retiring: '正在安全停止当前 Runtime Host…', + replacing: '正在启动并验证新版本…', + }, + updateBlockedTitle: 'Runtime Host 可能仍在执行任务', + updateBlockedBody: '无法确认当前 Host 可以安全停止。继续更新会中断当前执行,但会保留可恢复的任务状态和无法确认的外部效果。', + updateInterrupt: '中断任务并更新', + updateComplete: (from: string, to: string) => `Runtime Host 已从 ${from} 更新到 ${to}`, + updateRepaired: (version: string) => `Runtime Host ${version} 已恢复运行`, + updateAlreadyCurrent: (version: string) => `Runtime Host 已是 ${version}`, showLogs: '查看日志', noLogs: '没有服务日志', uninstallService: '卸载服务', @@ -417,6 +438,19 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { startService: 'Start', restartService: 'Restart', repairService: 'Repair', + updateService: 'Install Desktop version', + updatePhase: { + checking: 'Checking versions…', + staging: 'Staging the new version…', + retiring: 'Safely stopping the current Runtime Host…', + replacing: 'Starting and verifying the new version…', + }, + updateBlockedTitle: 'Runtime Host may still own active work', + updateBlockedBody: 'Desktop could not prove that the current Host can stop safely. Continuing will interrupt current execution while preserving recoverable task state and unresolved external effects.', + updateInterrupt: 'Interrupt and update', + updateComplete: (from: string, to: string) => `Runtime Host was updated from ${from} to ${to}`, + updateRepaired: (version: string) => `Runtime Host ${version} is running again`, + updateAlreadyCurrent: (version: string) => `Runtime Host is already on ${version}`, showLogs: 'View logs', noLogs: 'No service logs were found', uninstallService: 'Uninstall service', diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx index ee78cc87ba..be1780e6bd 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx @@ -27,6 +27,7 @@ import type { RemoteRuntimeHostProfile } from '@maka/runtime-host/client'; import type { DesktopRuntimeHostManagementAction, DesktopRuntimeHostManagementResult, + DesktopRuntimeHostManagementProgress, DesktopRuntimeHostAccessCredential, DesktopRuntimeHostAccessSnapshot, } from '../../preload/bridge-contract.js'; @@ -35,6 +36,7 @@ import { settingsActionErrorMessage } from './settings-error-copy.js'; type RuntimeHostManagementConfirmation = | { readonly kind: 'uninstall' } + | { readonly kind: 'update' } | { readonly kind: 'rotate' } | { readonly kind: 'revoke'; @@ -54,6 +56,7 @@ export function RuntimeHostManagementDialog(props: { const [uninstalledRoot, setUninstalledRoot] = useState(); const [access, setAccess] = useState(); const [confirmation, setConfirmation] = useState(); + const [updatePhase, setUpdatePhase] = useState(); const logsRef = useRef(null); const profile = props.profile; @@ -65,6 +68,7 @@ export function RuntimeHostManagementDialog(props: { setUninstalledRoot(undefined); setAccess(undefined); setConfirmation(undefined); + setUpdatePhase(undefined); setLoading(true); void window.maka.runtimeHostManagement.run(profile.id, 'status').then( (response) => { @@ -84,6 +88,10 @@ export function RuntimeHostManagementDialog(props: { }; }, [locale, profile]); + useEffect(() => window.maka.runtimeHostManagement.subscribeProgress((progress) => { + if (progress.profileId === profile?.id) setUpdatePhase(progress.phase); + }), [profile?.id]); + useLayoutEffect(() => { if (result?.action !== 'logs') return; const logs = logsRef.current; @@ -131,6 +139,40 @@ export function RuntimeHostManagementDialog(props: { } } + async function update(allowInterruptActiveTasks: boolean): Promise { + if (!profile) return; + setLoading(true); + setError(undefined); + setUpdatePhase('checking'); + try { + const response = await window.maka.runtimeHostManagement.update( + profile.id, + allowInterruptActiveTasks, + ); + if (response.kind === 'error') { + setError(response.error.message); + toast.error(copy.managementActionFailed, response.error.message); + return; + } + if (response.kind === 'uninstalled') { + throw new Error('Runtime Host update returned an uninstall result'); + } + setResult(response); + setConfirmation( + response.action === 'update' && response.update.kind === 'active_tasks' + ? { kind: 'update' } + : undefined, + ); + } catch (failure) { + const message = settingsActionErrorMessage(failure, locale); + setError(message); + toast.error(copy.managementActionFailed, message); + } finally { + setLoading(false); + setUpdatePhase(undefined); + } + } + async function rotateCredential(): Promise { if (!profile) return; setLoading(true); @@ -200,6 +242,7 @@ export function RuntimeHostManagementDialog(props: { {loading ? (
+ {updatePhase ? {copy.updatePhase[updatePhase]} : null}
) : null} {error ? : null} @@ -210,6 +253,13 @@ export function RuntimeHostManagementDialog(props: { description={copy.uninstallConfirmBody} /> ) : null} + {confirmation?.kind === 'update' ? ( + + ) : null} {confirmation?.kind === 'rotate' ? ( ) : null} + {result?.action === 'update' && result.update.kind === 'updated' ? ( + + ) : null} + {result?.action === 'update' && result.update.kind === 'already_current' ? ( + + ) : null} + {result?.action === 'update' && result.update.kind === 'repaired' ? ( + + ) : null} {!access && service ? ( <>
@@ -366,6 +434,21 @@ export function RuntimeHostManagementDialog(props: { onClick={() => void revokeCredential()} /> + ) : confirmation?.kind === 'update' ? ( + <> +