From 992c617482f6060d754e99cee18872f7b1512b6c Mon Sep 17 00:00:00 2001 From: Wang Date: Sun, 23 Aug 2026 17:57:11 +0800 Subject: [PATCH 1/5] feat(runtime-host): add safe managed Host updates Add one exact-package replacement transaction that stages the target, retires the current Host under exact identity fences, and reports success only after replacement readiness. Keep unsafe rollback disabled once cutover may have started, and expose the same operation through Desktop SSH management. Generated-by: Codex --- .../__tests__/runtime-host-management.test.ts | 107 +++++ .../runtime-host-ssh-terminal.test.ts | 62 +++ apps/desktop/src/main/runtime-host-boot.ts | 4 + .../src/main/runtime-host-management.ts | 94 ++++- .../src/main/runtime-host-ssh-terminal.ts | 114 ++++- apps/desktop/src/preload/bridge-contract.d.ts | 12 + apps/desktop/src/preload/preload.ts | 19 + .../locales/settings-projects-copy.ts | 34 ++ .../runtime-host-management-dialog.tsx | 91 ++++ .../runtime-host-service-manager.test.ts | 240 +++++++++++ .../src/__tests__/runtime-host-setup.test.ts | 3 +- packages/cli/src/cli-core.ts | 17 + packages/cli/src/runtime-host-cli.ts | 27 +- .../src/runtime-host-managed-deployment.ts | 9 +- ...runtime-host-service-management-command.ts | 46 +- .../cli/src/runtime-host-service-manager.ts | 71 +++- .../cli/src/runtime-host-setup-command.ts | 13 +- .../cli/src/runtime-host-systemd-service.ts | 3 +- .../cli/src/runtime-host-update-command.ts | 393 ++++++++++++++++++ packages/runtime-host/src/operator/index.ts | 1 + .../src/operator/service-management-frame.ts | 47 +++ 21 files changed, 1359 insertions(+), 48 deletions(-) create mode 100644 packages/cli/src/runtime-host-update-command.ts 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..e5612b875c 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,84 @@ test('manages only the service identity bound by Desktop onboarding', async () = assert.equal(handlers.size, 0); }); +test('updates the managed profile through its exact package and publishes progress', async () => { + const handlers = new Map unknown>(); + const updates: DesktopRuntimeHostSshUpdateInput[] = []; + const progress: unknown[] = []; + 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 () => ({ profile, service, state: 'active' as const }), + 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'); + 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' }), + 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' }]); +}); + test('resumes deployment cleanup without invoking the removed operator', async () => { const handlers = new Map unknown>(); const profile = { @@ -333,6 +430,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 +482,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 +563,14 @@ 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), + sendProgress: () => undefined, + }; +} + function accessCredential( credentialId: string, principalId: string, 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..6e5844c704 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,68 @@ 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( + 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.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..3fff6a0481 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -443,6 +443,10 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ ipcMain, profiles: runtimeHostProfileService, runServiceManagement: runtimeHostSshTerminal.runServiceManagement, + runUpdate: runtimeHostSshTerminal.runUpdate, + resolveUpdatePackage: runtimeHostSetupPackage, + sendProgress: (progress) => + mainWindowController.send("runtime-host-management:progress", progress), runAccessManagement: runtimeHostSshTerminal.runAccessManagement, cleanupManagedDeployment: runtimeHostSshTerminal.cleanupManagedDeployment, }); diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index 75a700d0a2..a676ce38e6 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -28,12 +28,16 @@ import type { DesktopRuntimeHostAccessSnapshot, DesktopRuntimeHostManagementAction, DesktopRuntimeHostManagementResponse, + DesktopRuntimeHostManagementProgress, } from '../preload/bridge-contract.js'; import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js'; import type { DesktopRuntimeHostSshCleanupInput, DesktopRuntimeHostSshAccessInput, DesktopRuntimeHostSshManagementInput, + DesktopRuntimeHostSshUpdateInput, + DesktopRuntimeHostSetupPackage, + RuntimeHostServiceUpdateTerminalFrame, } from './runtime-host-ssh-terminal.js'; const MANAGEMENT_ACTIONS = new Set([ @@ -63,10 +67,16 @@ 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 sendProgress: (progress: DesktopRuntimeHostManagementProgress) => void; readonly cleanupManagedDeployment: ( input: DesktopRuntimeHostSshCleanupInput, ) => Promise; @@ -83,14 +93,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 +159,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 +208,45 @@ 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 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 }), + ); + return response.kind === 'result' + ? { + ...response, + accessManagementAvailable: + response.operatorCapabilities?.includes( + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + ) ?? false, + } + : response; + }; + const accessSnapshot = ( credentials: Extract< RuntimeHostAccessManagementFrame, @@ -298,18 +364,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-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index cc415e7614..12fe8ec272 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 { @@ -83,7 +84,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 +97,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 +138,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 +167,11 @@ export function createDesktopRuntimeHostSshTerminal(input: { ): Promise; runServiceManagement( input: DesktopRuntimeHostSshManagementInput, - ): Promise; + ): Promise>; + runUpdate( + input: DesktopRuntimeHostSshUpdateInput, + onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, + ): Promise; runAccessManagement( input: DesktopRuntimeHostSshAccessInput, ): Promise; @@ -364,7 +384,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(); @@ -384,6 +407,10 @@ export function createDesktopRuntimeHostSshTerminal(input: { failure = new Error(`${options.label} returned ${action} for ${options.action}`); return; } + if (options.isTerminalFrame && !options.isTerminalFrame(next)) { + options.onProgress?.(next); + return; + } if (frame) { failure = new Error(`${options.label} returned multiple results`); return; @@ -405,7 +432,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { if (frame) completePresentation(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 +537,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 +547,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 +933,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 +1012,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..d1a382a1bf 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: '继续更新会中断当前执行,但会保留可恢复的任务状态和无法确认的外部效果。', + 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 still owns active work', + updateBlockedBody: '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' ? ( + <> +