From d61bc153dc6f8251ddd26e7f00b4327010f88238 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 14:32:54 +0800 Subject: [PATCH 1/5] feat(desktop): manage Runtime Host update policy Expose each managed Host's update policy and one-shot reconciliation through its bound SSH operator. Require explicit scheduler support before presenting automatic updates as available. Generated-by: Codex --- .../__tests__/runtime-host-management.test.ts | 170 ++++++++- .../__tests__/runtime-host-onboarding.test.ts | 2 +- .../runtime-host-setup-package.test.ts | 64 ++++ .../runtime-host-ssh-terminal.test.ts | 85 +++++ apps/desktop/src/main/runtime-host-boot.ts | 36 +- .../src/main/runtime-host-management.ts | 234 ++++++++++-- .../src/main/runtime-host-onboarding.ts | 11 +- .../src/main/runtime-host-setup-package.ts | 125 +++++++ .../src/main/runtime-host-ssh-terminal.ts | 108 ++++++ apps/desktop/src/preload/bridge-contract.d.ts | 44 ++- apps/desktop/src/preload/preload.ts | 12 + .../locales/settings-projects-copy.ts | 107 +++++- .../runtime-host-management-dialog.tsx | 342 +++++++++++++++--- .../renderer/styles/settings/runtime-host.css | 28 ++ docs/astryx-surface-file-inventory.md | 2 +- ...runtime-host-update-reconciliation.test.ts | 125 ++++++- packages/cli/src/cli-core.ts | 1 + packages/cli/src/runtime-host-cli.ts | 11 +- .../src/runtime-host-update-reconciliation.ts | 185 +++++++--- packages/runtime-host/src/operator/index.ts | 2 + .../src/operator/service-management-frame.ts | 31 +- scripts/release-cli-package.mjs | 26 +- 22 files changed, 1568 insertions(+), 183 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts create mode 100644 apps/desktop/src/main/runtime-host-setup-package.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 cfe37cda97..f3f03bb770 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -20,6 +20,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, runtimeHostAccessCredentialFingerprint, type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; @@ -29,6 +30,8 @@ import type { DesktopRuntimeHostSshCleanupInput, DesktopRuntimeHostSshManagementInput, DesktopRuntimeHostSshUpdateInput, + DesktopRuntimeHostSshUpdatePolicyInput, + DesktopRuntimeHostSshUpdateReconciliationInput, } from '../runtime-host-ssh-terminal.js'; test('identifies, rotates, and revokes managed credentials without exposing secrets', async () => { @@ -390,6 +393,9 @@ test('publishes update progress and waits for the managed profile to reconnect', update: { kind: 'updated', previousVersion: '1.2.3', targetVersion: '1.3.0' }, }; }, + runUpdatePolicy: async () => assert.fail('update policy is not expected'), + runUpdateReconciliation: async () => + assert.fail('update reconciliation is not expected'), resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.3.0' }), currentHostEpoch: () => 'host-before-update', awaitUpdatedConnection: async (...args) => { @@ -414,7 +420,10 @@ test('publishes update progress and waits for the managed profile to reconnect', rootId: profile.rootId, }, }]); - assert.deepEqual(progress, [{ profileId: profile.id, phase: 'staging' }]); + assert.deepEqual(progress, [ + { profileId: profile.id, phase: 'preparing_cli' }, + { profileId: profile.id, phase: 'staging' }, + ]); assert.deepEqual(connectionCompletions, [ [profile.id, profile.rootId, 'host-before-update', true], ]); @@ -443,6 +452,149 @@ test('publishes update progress and waits for the managed profile to reconnect', }); }); +test('manages one Host update policy and reconciles it through the bound operator', async () => { + const handlers = new Map unknown>(); + const policyInputs: DesktopRuntimeHostSshUpdatePolicyInput[] = []; + const reconciliationInputs: DesktopRuntimeHostSshUpdateReconciliationInput[] = []; + const progress: unknown[] = []; + const connections: 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({ + ...unusedUpdateDependencies(), + 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'), + runUpdatePolicy: async (input) => { + policyInputs.push(input); + const policy = input.policy ?? { kind: 'manual' as const }; + return { + schemaVersion: 1, + kind: 'result', + action: 'update_policy', + operatorCapabilities: [RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY], + updateSchedulerState: 'ready', + updatePolicy: { + policy, + ...(policy.kind === 'manual' ? {} : { target: input.expectedTarget! }), + }, + }; + }, + runUpdateReconciliation: async (input, onProgress) => { + reconciliationInputs.push(input); + onProgress('replacing'); + return { + schemaVersion: 1, + kind: 'result', + action: 'reconcile_update', + operatorCapabilities: [RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY], + updateSchedulerState: 'ready', + updatePolicy: { + policy: { kind: 'channel', channel: 'latest' }, + target: { + serviceId: service.id, + rootPath: service.rootPath, + rootId: profile.rootId, + }, + }, + service: serviceSummary('1.3.0'), + reconciliation: { + kind: 'updated', + previousVersion: '1.2.3', + targetVersion: '1.3.0', + }, + }; + }, + currentHostEpoch: () => 'host-before-update', + awaitUpdatedConnection: async (...args) => { + connections.push(args); + }, + sendProgress: (event) => progress.push(event), + runAccessManagement: async () => assert.fail('access management is not expected'), + cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'), + }); + + const getPolicy = handlers.get('runtime-host-management:get-update-policy'); + const setPolicy = handlers.get('runtime-host-management:set-update-policy'); + const reconcile = handlers.get('runtime-host-management:reconcile-update'); + assert.ok(getPolicy && setPolicy && reconcile); + + assert.deepEqual(await getPolicy({}, profile.id), { + policy: { kind: 'manual' }, + schedulingState: 'ready', + }); + assert.deepEqual( + await setPolicy({}, profile.id, { kind: 'channel', channel: 'latest' }), + { + policy: { kind: 'channel', channel: 'latest' }, + target: { + serviceId: service.id, + rootPath: service.rootPath, + rootId: profile.rootId, + }, + schedulingState: 'ready', + }, + ); + await assert.rejects( + setPolicy({}, profile.id, { kind: 'fixed', version: '' }) as Promise, + /update policy is invalid/u, + ); + for (const policyInput of policyInputs) { + assert.deepEqual(policyInput.expectedTarget, { + serviceId: service.id, + rootPath: service.rootPath, + rootId: profile.rootId, + }); + } + assert.deepEqual(reconciliationInputs, []); + + const response = await reconcile({}, profile.id); + assert.equal( + (response as { reconciliation?: { kind: string } }).reconciliation?.kind, + 'updated', + ); + assert.equal( + (response as { updatePolicy?: { schedulingState: string } }).updatePolicy?.schedulingState, + 'ready', + ); + assert.deepEqual(reconciliationInputs, [{ + destination: profile.transport.destination, + operatorPath: service.operatorPath, + expectedTarget: { + serviceId: service.id, + rootPath: service.rootPath, + rootId: profile.rootId, + }, + }]); + assert.deepEqual(progress, [{ profileId: profile.id, phase: 'replacing' }]); + assert.deepEqual(connections, [[profile.id, profile.rootId, 'host-before-update', true]]); +}); + test('resumes deployment cleanup without invoking the removed operator', async () => { const handlers = new Map unknown>(); const profile = { @@ -603,9 +755,25 @@ function serviceResult( : { ...result, action }; } +function serviceSummary(installedVersion: string) { + return { + platform: 'linux', + arch: 'x64', + osRelease: '6.8.0', + state: 'running' as const, + pid: 42, + lastExitCode: 0, + installedVersion, + projectDirectoryRoots: [], + }; +} + function unusedUpdateDependencies() { return { runUpdate: async (): Promise => assert.fail('update is not expected'), + runUpdatePolicy: async (): Promise => assert.fail('update policy is not expected'), + runUpdateReconciliation: async (): Promise => + assert.fail('update reconciliation is not expected'), resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.2.3' } as const), currentHostEpoch: () => undefined, awaitUpdatedConnection: async () => undefined, diff --git a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts index 0f4450bc8f..846c25ec0c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts @@ -148,7 +148,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn while (!pairingStarted) await Promise.resolve(); finishPairing({ profileId: 'office' }); - assert.deepEqual(await setup, { kind: 'complete', profileId: 'office', revision: 3 }); + assert.deepEqual(await setup, { kind: 'complete', profileId: 'office', revision: 4 }); await harness.onboarding.close(); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts b/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts new file mode 100644 index 0000000000..9ca2b2af97 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { test } from 'node:test'; +import { createRuntimeHostSetupPackageResolver } from '../runtime-host-setup-package.js'; + +test('development setup lazily builds one local CLI archive unless explicitly overridden', async () => { + const repoRoot = resolve('/workspace'); + const archive = join(repoRoot, 'packages', 'cli', 'release', 'maka-agent-dev.tgz'); + let builds = 0; + const resolvePackage = createRuntimeHostSetupPackageResolver({ + isPackaged: false, + appPath: join(repoRoot, 'apps', 'desktop'), + environment: {}, + buildDevelopmentArchive: async (resolvedRoot) => { + builds += 1; + assert.equal(resolvedRoot, repoRoot); + return archive; + }, + }); + + assert.deepEqual(await Promise.all([resolvePackage(), resolvePackage()]), [ + { + kind: 'development_archive', + path: archive, + }, + { + kind: 'development_archive', + path: archive, + }, + ]); + assert.equal(builds, 1); + + const override = join(tmpdir(), 'explicit.tgz'); + const resolveOverride = createRuntimeHostSetupPackageResolver({ + isPackaged: false, + appPath: join(repoRoot, 'apps', 'desktop'), + environment: { MAKA_RUNTIME_HOST_SETUP_ARCHIVE: override }, + buildDevelopmentArchive: async () => assert.fail('override must bypass the local build'), + }); + assert.deepEqual(await resolveOverride(), { + kind: 'development_archive', + path: override, + }); +}); 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 d8458b7250..4aeb29c086 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 @@ -31,6 +31,7 @@ import { encodeRuntimeHostServiceManagementFrame, encodeRuntimeHostSetupFrame, runtimeHostAccessCredentialFingerprint, + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, RUNTIME_HOST_SETUP_FRAME_PREFIX, } from '@maka/runtime-host/operator'; import { createDesktopRuntimeHostSshTerminal } from '../runtime-host-ssh-terminal.js'; @@ -374,6 +375,90 @@ test('runs an exact update package and reports progress before an active-work re await harness.terminal.close(); }); +test('uses the managed operator for update policy and one-shot reconciliation', async () => { + const target = { + serviceId: 'b'.repeat(64), + rootPath: '/srv/maka', + rootId: 'a'.repeat(64), + }; + const policyHarness = createHarness('pending'); + const policy = policyHarness.terminal.runUpdatePolicy({ + destination: 'operator@example.com', + operatorPath: '/home/operator/.local/share/maka/operator', + policy: { kind: 'channel', channel: 'latest' }, + expectedTarget: target, + }); + await waitFor(() => policyHarness.pty.hasDataListener()); + const policyCommand = policyHarness.launchArgs.at(-1)?.at(-1) ?? ''; + assert.match(policyCommand, /operator.*update-policy.*--target.*latest/u); + assert.match(policyCommand, /--expected-service-id/u); + assert.match(policyCommand, /update-scheduler-v1/u); + policyHarness.pty.emitData(encodeRuntimeHostServiceManagementFrame({ + schemaVersion: 1, + kind: 'result', + action: 'update_policy', + operatorCapabilities: [RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY], + updateSchedulerState: 'ready', + updatePolicy: { + policy: { kind: 'channel', channel: 'latest' }, + target, + }, + })); + policyHarness.pty.exit(0); + assert.equal((await policy).kind, 'result'); + await policyHarness.terminal.close(); + + const reconcileHarness = createHarness('pending'); + const phases: string[] = []; + const reconciliation = reconcileHarness.terminal.runUpdateReconciliation( + { + destination: 'operator@example.com', + operatorPath: '/home/operator/.local/share/maka/operator', + expectedTarget: target, + }, + (phase) => phases.push(phase), + ); + await waitFor(() => reconcileHarness.pty.hasDataListener()); + const reconcileCommand = reconcileHarness.launchArgs.at(-1)?.at(-1) ?? ''; + assert.match(reconcileCommand, /operator.*reconcile-update.*--framed/u); + assert.match(reconcileCommand, /--expected-service-id/u); + assert.match(reconcileCommand, /update-scheduler-v1/u); + reconcileHarness.pty.emitData(encodeRuntimeHostServiceManagementFrame({ + schemaVersion: 1, + kind: 'progress', + action: 'reconcile_update', + phase: 'checking', + currentVersion: '1.2.3', + targetVersion: '1.3.0', + })); + reconcileHarness.pty.emitData(encodeRuntimeHostServiceManagementFrame({ + schemaVersion: 1, + kind: 'result', + action: 'reconcile_update', + operatorCapabilities: [RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY], + updateSchedulerState: 'ready', + updatePolicy: { + policy: { kind: 'channel', channel: 'latest' }, + target, + }, + service: { + platform: 'linux', + arch: 'x64', + osRelease: '6.8.0', + state: 'running', + pid: 42, + lastExitCode: 0, + installedVersion: '1.2.3', + projectDirectoryRoots: [], + }, + reconciliation: { kind: 'already_current', version: '1.2.3' }, + })); + reconcileHarness.pty.exit(0); + assert.equal((await reconciliation).kind, 'result'); + assert.deepEqual(phases, ['checking']); + await reconcileHarness.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 32ac4b5c9e..b84dec928a 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -28,7 +28,6 @@ import { type MessageBoxReturnValue, } from "electron"; import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; import { basename, join } from "node:path"; import { type ConnectionEvent } from '@maka/core/connections'; import type { UsageRange } from '@maka/core/settings'; @@ -167,9 +166,8 @@ import { } from "./runtime-host-profile-service.js"; import { createDesktopRuntimeHostSshTerminal, - isExactRuntimeHostSetupPackageSpecifier, - type DesktopRuntimeHostSetupPackage, } from "./runtime-host-ssh-terminal.js"; +import { createRuntimeHostSetupPackageResolver } from "./runtime-host-setup-package.js"; import { createDesktopRuntimeHostOnboarding } from "./runtime-host-onboarding.js"; import { createDesktopRuntimeHostManagement } from "./runtime-host-management.js"; import { registerRuntimeHostOAuthIpc } from "./runtime-host-oauth-ipc-main.js"; @@ -449,6 +447,8 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ profiles: runtimeHostProfileService, runServiceManagement: runtimeHostSshTerminal.runServiceManagement, runUpdate: runtimeHostSshTerminal.runUpdate, + runUpdatePolicy: runtimeHostSshTerminal.runUpdatePolicy, + runUpdateReconciliation: runtimeHostSshTerminal.runUpdateReconciliation, resolveUpdatePackage: runtimeHostSetupPackage, currentHostEpoch: (profileId) => runtimeHostManager?.current(profileId)?.candidate?.client.hostEpoch, @@ -495,27 +495,15 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ cleanupManagedDeployment: runtimeHostSshTerminal.cleanupManagedDeployment, }); -function runtimeHostSetupPackage(): DesktopRuntimeHostSetupPackage { - if (!app.isPackaged && process.env.MAKA_RUNTIME_HOST_SETUP_ARCHIVE) { - return { kind: "development_archive", path: process.env.MAKA_RUNTIME_HOST_SETUP_ARCHIVE }; - } - const manifestPath = app.isPackaged - ? join(app.getAppPath(), "package.json") - : join(app.getAppPath(), "..", "..", "packages", "cli", "package.json"); - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { - name?: unknown; - version?: unknown; - runtimeHostSetupPackage?: unknown; - }; - const specifier = app.isPackaged - ? manifest.runtimeHostSetupPackage - : manifest.name === "maka-agent" && typeof manifest.version === "string" - ? `maka-agent@${manifest.version}` - : undefined; - if (!isExactRuntimeHostSetupPackageSpecifier(specifier)) { - throw new Error("Desktop does not declare an exact Runtime Host setup package"); - } - return { kind: "npm", specifier }; +let setupPackageResolver: ReturnType | undefined; + +function runtimeHostSetupPackage(signal?: AbortSignal) { + setupPackageResolver ??= createRuntimeHostSetupPackageResolver({ + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + environment: process.env, + }); + return setupPackageResolver(signal); } const defaultRuntimeHostRecovery = createRuntimeHostDefaultRecovery({ defaultProfileId: () => diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index 66e209ffef..eec12d913f 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -20,7 +20,10 @@ import type { IpcMain } from 'electron'; import { RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, + isProductReleaseVersion, runtimeHostAccessCredentialFingerprint, + type RuntimeHostManagedUpdatePolicy, type RuntimeHostAccessManagementFrame, type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; @@ -29,6 +32,8 @@ import type { DesktopRuntimeHostManagementAction, DesktopRuntimeHostManagementResponse, DesktopRuntimeHostManagementProgress, + DesktopRuntimeHostUpdatePolicySnapshot, + DesktopRuntimeHostUpdateReconciliationResponse, } from '../preload/bridge-contract.js'; import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js'; import { sameDesktopRuntimeHostManagedServiceBinding } from './runtime-host-managed-services.js'; @@ -37,7 +42,11 @@ import type { DesktopRuntimeHostSshAccessInput, DesktopRuntimeHostSshManagementInput, DesktopRuntimeHostSshUpdateInput, + DesktopRuntimeHostSshUpdatePolicyInput, + DesktopRuntimeHostSshUpdateReconciliationInput, DesktopRuntimeHostSetupPackage, + RuntimeHostServiceUpdatePolicyTerminalFrame, + RuntimeHostServiceUpdateReconciliationTerminalFrame, RuntimeHostServiceUpdateTerminalFrame, } from './runtime-host-ssh-terminal.js'; @@ -76,7 +85,16 @@ export function createDesktopRuntimeHostManagement(input: { input: DesktopRuntimeHostSshUpdateInput, onProgress: (phase: DesktopRuntimeHostManagementProgress['phase']) => void, ) => Promise; - readonly resolveUpdatePackage: () => DesktopRuntimeHostSetupPackage; + readonly runUpdatePolicy: ( + input: DesktopRuntimeHostSshUpdatePolicyInput, + ) => Promise; + readonly runUpdateReconciliation: ( + input: DesktopRuntimeHostSshUpdateReconciliationInput, + onProgress: (phase: DesktopRuntimeHostManagementProgress['phase']) => void, + ) => Promise; + readonly resolveUpdatePackage: () => + | DesktopRuntimeHostSetupPackage + | Promise; readonly currentHostEpoch: (profileId: string) => string | undefined; readonly awaitUpdatedConnection: ( profileId: string, @@ -236,13 +254,15 @@ export function createDesktopRuntimeHostManagement(input: { throw new Error('This Runtime Host profile is not available for managed updates'); } const previousHostEpoch = input.currentHostEpoch(profileId); + input.sendProgress({ profileId, phase: 'preparing_cli' }); + const setupPackage = await input.resolveUpdatePackage(); const response = await input.runUpdate( { destination: managed.profile.transport.destination, ...(managed.profile.transport.sshPort === undefined ? {} : { sshPort: managed.profile.transport.sshPort }), - setupPackage: input.resolveUpdatePackage(), + setupPackage, expectedTarget: { serviceId: managed.service.id, rootPath: managed.service.rootPath, @@ -252,32 +272,19 @@ export function createDesktopRuntimeHostManagement(input: { }, (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) { + if (response.kind === 'result' && response.update.kind !== 'active_tasks') { + const reconnectError = await reconnectUpdatedTarget( + profileId, + managed, + previousHostEpoch, + response.update.kind !== 'already_current', + ); + if (reconnectError) { 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)), - }, + error: reconnectError, }; } } @@ -292,6 +299,127 @@ export function createDesktopRuntimeHostManagement(input: { : response; }; + const updateTarget = async (profileIdValue: unknown) => { + const profileId = requireProfileId(profileIdValue); + const managed = await resolveManagedService(profileId); + const transport = managed.profile.transport; + if (managed.state !== 'active' || transport.kind !== 'ssh') { + throw new Error('This Runtime Host profile is not available for managed updates'); + } + return { + profileId, + managed, + transport, + expectedTarget: { + serviceId: managed.service.id, + rootPath: managed.service.rootPath, + rootId: managed.profile.rootId, + }, + }; + }; + + const reconnectUpdatedTarget = async ( + profileId: string, + managed: Awaited>, + previousHostEpoch: string | undefined, + replacementExpected: boolean, + ): Promise<{ readonly code: string; readonly message: string } | undefined> => { + 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, + replacementExpected, + ); + return undefined; + } catch (error) { + return { + code: 'desktop_reconnect_failed', + message: + 'The Runtime Host update completed, but Desktop could not reconnect: ' + + (error instanceof Error ? error.message : String(error)), + }; + } + }; + + const updatePolicy = async ( + profileIdValue: unknown, + policyValue?: unknown, + ): Promise => { + const { managed, transport, expectedTarget } = await updateTarget(profileIdValue); + const policy = policyValue === undefined ? undefined : requireUpdatePolicy(policyValue); + const common = { + destination: transport.destination, + ...(transport.sshPort === undefined + ? {} + : { sshPort: transport.sshPort }), + operatorPath: managed.service.operatorPath, + expectedTarget, + }; + if (policy && policy.kind !== 'manual') { + const current = await input.runUpdatePolicy(common); + if (current.kind === 'error') throw new Error(current.error.message); + if (!supportsUpdateScheduler(current)) { + throw new Error( + 'Update or repair this Runtime Host before enabling automatic updates', + ); + } + } + const response = await input.runUpdatePolicy({ + ...common, + ...(policy ? { policy } : {}), + }); + if (response.kind === 'error') throw new Error(response.error.message); + return projectUpdatePolicy(response); + }; + + const reconcileUpdate = async ( + profileIdValue: unknown, + ): Promise => { + const { profileId, managed, transport, expectedTarget } = await updateTarget(profileIdValue); + const previousHostEpoch = input.currentHostEpoch(profileId); + const response = await input.runUpdateReconciliation( + { + destination: transport.destination, + ...(transport.sshPort === undefined + ? {} + : { sshPort: transport.sshPort }), + operatorPath: managed.service.operatorPath, + expectedTarget, + }, + (phase) => input.sendProgress({ profileId, phase }), + ); + if ( + response.kind === 'result' && + (response.reconciliation.kind === 'updated' || + response.reconciliation.kind === 'repaired') + ) { + const reconnectError = await reconnectUpdatedTarget( + profileId, + managed, + previousHostEpoch, + true, + ); + if (reconnectError) { + return { + kind: 'error', + error: reconnectError, + }; + } + } + return response.kind === 'result' + ? { + kind: 'result', + updatePolicy: projectUpdatePolicy(response), + reconciliation: response.reconciliation, + } + : { kind: 'error', error: response.error }; + }; + const accessSnapshot = ( credentials: Extract< RuntimeHostAccessManagementFrame, @@ -413,6 +541,9 @@ export function createDesktopRuntimeHostManagement(input: { 'runtime-host-management:list-credentials', 'runtime-host-management:rotate-credential', 'runtime-host-management:revoke-credential', + 'runtime-host-management:get-update-policy', + 'runtime-host-management:set-update-policy', + 'runtime-host-management:reconcile-update', ] as const; input.ipcMain.handle(channels[0], (_event, profileId: unknown, action: unknown) => run(profileId, action)); @@ -430,6 +561,12 @@ export function createDesktopRuntimeHostManagement(input: { (_event, profileId: unknown, credentialId: unknown) => revokeCredential(profileId, credentialId), ); + input.ipcMain.handle(channels[5], (_event, profileId: unknown) => + updatePolicy(profileId)); + input.ipcMain.handle(channels[6], (_event, profileId: unknown, policy: unknown) => + updatePolicy(profileId, policy)); + input.ipcMain.handle(channels[7], (_event, profileId: unknown) => + reconcileUpdate(profileId)); return { close() { @@ -438,6 +575,55 @@ export function createDesktopRuntimeHostManagement(input: { }; } +function supportsUpdateScheduler(frame: { + readonly operatorCapabilities?: readonly string[]; +}): boolean { + return frame.operatorCapabilities?.includes( + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, + ) === true; +} + +function projectUpdatePolicy( + frame: Extract, +): DesktopRuntimeHostUpdatePolicySnapshot { + if (!supportsUpdateScheduler(frame)) { + return { ...frame.updatePolicy, schedulingState: 'unsupported' }; + } + if (!frame.updateSchedulerState) { + throw new Error('Remote Runtime Host omitted its update scheduler state'); + } + return { ...frame.updatePolicy, schedulingState: frame.updateSchedulerState }; +} + +function requireUpdatePolicy(value: unknown): RuntimeHostManagedUpdatePolicy { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Runtime Host update policy is invalid'); + } + const policy = value as Record; + if (policy.kind === 'manual' && Object.keys(policy).length === 1) { + return { kind: 'manual' }; + } + if ( + policy.kind === 'fixed' && + Object.keys(policy).length === 2 && + typeof policy.version === 'string' && + isProductReleaseVersion(policy.version) + ) { + return { kind: 'fixed', version: policy.version }; + } + if ( + policy.kind === 'channel' && + Object.keys(policy).length === 2 && + (policy.channel === 'latest' || policy.channel === 'next') + ) { + return { kind: 'channel', channel: policy.channel }; + } + throw new Error('Runtime Host update policy is invalid'); +} + function sameCredentialAuthority( current: RuntimeHostAccessCredentialMetadata, replacement: RuntimeHostAccessCredentialMetadata, diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts index c443c18ef4..546e23a13a 100644 --- a/apps/desktop/src/main/runtime-host-onboarding.ts +++ b/apps/desktop/src/main/runtime-host-onboarding.ts @@ -56,7 +56,9 @@ export function createDesktopRuntimeHostOnboarding(input: { readonly credential: string; }>; readonly send: (snapshot: DesktopRuntimeHostOnboardingSnapshot) => void; - readonly resolveSetupPackage: () => DesktopRuntimeHostSetupPackage; + readonly resolveSetupPackage: ( + signal?: AbortSignal, + ) => DesktopRuntimeHostSetupPackage | Promise; }): { close(): Promise } { let revision = 0; let snapshot: DesktopRuntimeHostOnboardingSnapshot = { kind: 'idle', revision }; @@ -89,7 +91,7 @@ export function createDesktopRuntimeHostOnboarding(input: { })); } const abort = new AbortController(); - publish({ kind: 'running', phase: 'connecting_ssh' }); + publish({ kind: 'running', phase: 'preparing_cli' }); const task = Promise.resolve().then(() => run(request, abort.signal)).finally(() => { if (active?.task === task) active = undefined; }); @@ -102,6 +104,9 @@ export function createDesktopRuntimeHostOnboarding(input: { signal: AbortSignal, ): Promise => { try { + const setupPackage = await input.resolveSetupPackage(signal); + signal.throwIfAborted(); + publish({ kind: 'running', phase: 'connecting_ssh' }); let commitStarted = false; const beginCommit = () => { if (commitStarted) return; @@ -116,7 +121,7 @@ export function createDesktopRuntimeHostOnboarding(input: { { destination: request.destination, ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }), - setupPackage: input.resolveSetupPackage(), + setupPackage, principalId: `desktop:${input.clientInstanceId}`, signal, }, diff --git a/apps/desktop/src/main/runtime-host-setup-package.ts b/apps/desktop/src/main/runtime-host-setup-package.ts new file mode 100644 index 0000000000..cc29ba81c7 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-setup-package.ts @@ -0,0 +1,125 @@ +/* + * 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 { execFile } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { + isExactRuntimeHostSetupPackageSpecifier, + type DesktopRuntimeHostSetupPackage, +} from './runtime-host-ssh-terminal.js'; + +const DEVELOPMENT_ARCHIVE_ENV = 'MAKA_RUNTIME_HOST_SETUP_ARCHIVE'; + +export function createRuntimeHostSetupPackageResolver(input: { + readonly isPackaged: boolean; + readonly appPath: string; + readonly environment: NodeJS.ProcessEnv; + readonly buildDevelopmentArchive?: (repoRoot: string) => Promise; +}): (signal?: AbortSignal) => Promise { + let developmentArchive: Promise | undefined; + + return async (signal) => { + if (input.isPackaged) return packagedSetupPackage(input.appPath); + + const override = input.environment[DEVELOPMENT_ARCHIVE_ENV]; + if (override) return { kind: 'development_archive', path: override }; + + const repoRoot = resolve(input.appPath, '..', '..'); + if (!developmentArchive) { + const build = + input.buildDevelopmentArchive ?? + ((root: string) => buildDevelopmentArchive(root, input.environment)); + const pending = build(repoRoot).then((path) => ({ + kind: 'development_archive' as const, + path, + })); + developmentArchive = pending; + void pending.catch(() => { + if (developmentArchive === pending) developmentArchive = undefined; + }); + } + return waitForPackage(developmentArchive, signal); + }; +} + +function packagedSetupPackage(appPath: string): DesktopRuntimeHostSetupPackage { + const manifest = JSON.parse(readFileSync(join(appPath, 'package.json'), 'utf8')) as { + runtimeHostSetupPackage?: unknown; + }; + if (!isExactRuntimeHostSetupPackageSpecifier(manifest.runtimeHostSetupPackage)) { + throw new Error('Desktop does not declare an exact Runtime Host setup package'); + } + return { kind: 'npm', specifier: manifest.runtimeHostSetupPackage }; +} + +async function buildDevelopmentArchive( + repoRoot: string, + environment: NodeJS.ProcessEnv, +): Promise { + const script = join(repoRoot, 'scripts', 'release-cli-package.mjs'); + const nodeExecutable = environment.npm_node_execpath?.trim() || 'node'; + const stdout = await new Promise((resolveOutput, reject) => { + execFile( + nodeExecutable, + [script, '--development'], + { + cwd: repoRoot, + encoding: 'utf8', + env: environment, + maxBuffer: 64 * 1024 * 1024, + }, + (error, output, stderr) => { + if (!error) { + resolveOutput(output); + return; + } + const detail = (stderr.trim() || error.message).slice(-2_000); + reject(new Error(`Failed to prepare the local Runtime Host CLI: ${detail}`)); + }, + ); + }); + const archive = Array.from(stdout.matchAll(/^\[release-cli\] tarball: (.+)$/gmu)).at(-1)?.[1]; + if (!archive) throw new Error('The local Runtime Host CLI build did not report an archive'); + const resolvedArchive = resolve(archive.trim()); + const releaseRoot = join(repoRoot, 'packages', 'cli', 'release'); + const relativeArchive = relative(releaseRoot, resolvedArchive); + if ( + !relativeArchive || + relativeArchive.startsWith('..') || + isAbsolute(relativeArchive) || + !resolvedArchive.endsWith('.tgz') || + !existsSync(resolvedArchive) + ) { + throw new Error('The local Runtime Host CLI build returned an invalid archive path'); + } + return resolvedArchive; +} + +function waitForPackage(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + signal.throwIfAborted(); + return new Promise((resolvePackage, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + void promise.then(resolvePackage, reject).finally(() => { + signal.removeEventListener('abort', onAbort); + }); + }); +} diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index e2a563cb7e..d9c09fa1e9 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -39,9 +39,11 @@ import { RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SETUP_FRAME_PREFIX, type RuntimeHostAccessManagementFrame, + type RuntimeHostManagedUpdatePolicy, type RuntimeHostServiceManagementAction, type RuntimeHostServiceManagementFrame, type RuntimeHostServiceUpdatePhase, @@ -110,6 +112,23 @@ export interface DesktopRuntimeHostSshUpdateInput { readonly signal?: AbortSignal; } +export interface DesktopRuntimeHostSshUpdatePolicyInput { + readonly destination: string; + readonly sshPort?: number; + readonly operatorPath: string; + readonly policy?: RuntimeHostManagedUpdatePolicy; + readonly expectedTarget?: DesktopRuntimeHostSshManagementInput['expectedTarget']; + readonly signal?: AbortSignal; +} + +export interface DesktopRuntimeHostSshUpdateReconciliationInput { + readonly destination: string; + readonly sshPort?: number; + readonly operatorPath: string; + readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; + readonly signal?: AbortSignal; +} + export interface DesktopRuntimeHostSshCleanupInput { readonly destination: string; readonly sshPort?: number; @@ -148,6 +167,16 @@ export type RuntimeHostServiceUpdateTerminalFrame = readonly action: 'update'; }); +export type RuntimeHostServiceUpdatePolicyTerminalFrame = Extract< + RuntimeHostServiceManagementFrame, + { kind: 'result' | 'error'; action: 'update_policy' } +>; + +export type RuntimeHostServiceUpdateReconciliationTerminalFrame = Extract< + RuntimeHostServiceManagementFrame, + { kind: 'result' | 'error'; action: 'reconcile_update' } +>; + export function isExactRuntimeHostSetupPackageSpecifier(value: unknown): value is string { return typeof value === 'string' && /^maka-agent@[0-9][0-9A-Za-z.+-]*$/u.test(value); } @@ -176,6 +205,13 @@ export function createDesktopRuntimeHostSshTerminal(input: { input: DesktopRuntimeHostSshUpdateInput, onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, ): Promise; + runUpdatePolicy( + input: DesktopRuntimeHostSshUpdatePolicyInput, + ): Promise; + runUpdateReconciliation( + input: DesktopRuntimeHostSshUpdateReconciliationInput, + onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, + ): Promise; runAccessManagement( input: DesktopRuntimeHostSshAccessInput, ): Promise; @@ -620,6 +656,41 @@ export function createDesktopRuntimeHostSshTerminal(input: { } throw new Error('Remote Runtime Host update returned an invalid result'); }, + runUpdatePolicy: async (policyInput) => { + const frame = await runFramedManagement({ + ...policyInput, + remoteCommand: runtimeHostUpdatePolicyRemoteCommand(policyInput), + prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + pendingMaxBytes: MANAGEMENT_FRAME_PENDING_MAX, + decode: decodeRuntimeHostServiceManagementFrame, + action: 'update_policy', + frameAction: (candidate) => candidate.action, + label: 'Remote Runtime Host update policy', + }); + if (frame.kind === 'result' && frame.action === 'update_policy') return frame; + if (frame.kind === 'error' && frame.action === 'update_policy') return frame; + throw new Error('Remote Runtime Host update policy returned an invalid result'); + }, + runUpdateReconciliation: async (reconciliationInput, onProgress) => { + const frame = await runFramedManagement({ + ...reconciliationInput, + remoteCommand: runtimeHostUpdateReconciliationRemoteCommand(reconciliationInput), + prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + pendingMaxBytes: MANAGEMENT_FRAME_PENDING_MAX, + decode: decodeRuntimeHostServiceManagementFrame, + action: 'reconcile_update', + frameAction: (candidate) => candidate.action, + isTerminalFrame: (candidate) => candidate.kind !== 'progress', + onProgress: (candidate) => { + if (candidate.kind === 'progress') onProgress(candidate.phase); + }, + label: 'Remote Runtime Host update reconciliation', + timeoutMs: SETUP_TIMEOUT_MS, + }); + if (frame.kind === 'result' && frame.action === 'reconcile_update') return frame; + if (frame.kind === 'error' && frame.action === 'reconcile_update') return frame; + throw new Error('Remote Runtime Host update reconciliation returned an invalid result'); + }, runAccessManagement: (accessInput) => runFramedManagement({ ...accessInput, @@ -981,6 +1052,43 @@ function runtimeHostUpdateRemoteCommand( ); } +function runtimeHostUpdatePolicyRemoteCommand( + input: DesktopRuntimeHostSshUpdatePolicyInput, +): string { + const policy = input.policy; + const target = policy === undefined + ? [] + : ['--target', policy.kind === 'channel' ? policy.channel : policy.kind === 'fixed' ? policy.version : 'manual']; + const command = [ + input.operatorPath, + 'update-policy', + '--framed', + ...target, + ...(input.expectedTarget ? managedServiceTargetArgs(input.expectedTarget) : []), + ].map(quotePosix).join(' '); + const invocation = + `${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` + + `${quotePosix(RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY)} exec ${command}`; + return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; +} + +function runtimeHostUpdateReconciliationRemoteCommand( + input: DesktopRuntimeHostSshUpdateReconciliationInput, +): string { + const command = [ + input.operatorPath, + 'reconcile-update', + '--framed', + ...managedServiceTargetArgs(input.expectedTarget), + ] + .map(quotePosix) + .join(' '); + const invocation = + `${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` + + `${quotePosix(RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY)} exec ${command}`; + return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; +} + function runtimeHostAccessManagementRemoteCommand( input: DesktopRuntimeHostSshAccessInput, ): string { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d478543721..03229ae7ab 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -397,6 +397,7 @@ export interface DesktopRuntimeHostOnboardingInput { } export type DesktopRuntimeHostOnboardingPhase = + | 'preparing_cli' | 'connecting_ssh' | RuntimeHostSetupPhase | 'connecting_host'; @@ -447,9 +448,44 @@ export type DesktopRuntimeHostManagementResponse = export interface DesktopRuntimeHostManagementProgress { readonly profileId: string; - readonly phase: import('@maka/runtime-host/operator').RuntimeHostServiceUpdatePhase; + readonly phase: + | 'preparing_cli' + | import('@maka/runtime-host/operator').RuntimeHostServiceUpdatePhase; } +type RuntimeHostUpdatePolicyResult = Extract< + RuntimeHostServiceManagementFrame, + { kind: 'result'; action: 'update_policy' } +>; + +type RuntimeHostUpdateReconciliationResult = Extract< + RuntimeHostServiceManagementFrame, + { kind: 'result'; action: 'reconcile_update' } +>; + +export type DesktopRuntimeHostUpdateSchedulingState = + | 'unsupported' + | import('@maka/runtime-host/operator').RuntimeHostUpdateSchedulerState; + +export type DesktopRuntimeHostUpdatePolicySnapshot = + RuntimeHostUpdatePolicyResult['updatePolicy'] & { + readonly schedulingState: DesktopRuntimeHostUpdateSchedulingState; + }; + +export type DesktopRuntimeHostUpdateReconciliationOutcome = + RuntimeHostUpdateReconciliationResult['reconciliation']; + +export type DesktopRuntimeHostUpdateReconciliationResponse = + | { + readonly kind: 'error'; + readonly error: { readonly code: string; readonly message: string }; + } + | { + readonly kind: 'result'; + readonly updatePolicy: DesktopRuntimeHostUpdatePolicySnapshot; + readonly reconciliation: DesktopRuntimeHostUpdateReconciliationOutcome; + }; + export interface DesktopRuntimeHostAccessCredential { readonly credentialId: string; readonly principalKind: 'remote_owner' | 'capability_provider'; @@ -582,6 +618,12 @@ export interface MakaBridge { subscribeProgress( handler: (progress: DesktopRuntimeHostManagementProgress) => void, ): () => void; + getUpdatePolicy(profileId: string): Promise; + setUpdatePolicy( + profileId: string, + policy: import('@maka/runtime-host/operator').RuntimeHostManagedUpdatePolicy, + ): Promise; + reconcileUpdate(profileId: string): Promise; 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 656472bf20..d232fb0641 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1257,6 +1257,18 @@ const makaBridge = { ipcRenderer.on('runtime-host-management:progress', listener); return () => ipcRenderer.off('runtime-host-management:progress', listener); }, + getUpdatePolicy(profileId: string) { + return ipcRenderer.invoke('runtime-host-management:get-update-policy', profileId); + }, + setUpdatePolicy( + profileId: string, + policy: import('@maka/runtime-host/operator').RuntimeHostManagedUpdatePolicy, + ) { + return ipcRenderer.invoke('runtime-host-management:set-update-policy', profileId, policy); + }, + reconcileUpdate(profileId: string) { + return ipcRenderer.invoke('runtime-host-management:reconcile-update', profileId); + }, 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 64f43ddee5..54c1bd7252 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -92,7 +92,41 @@ export type SettingsProjectsCopy = { restartService: string; repairService: string; updateService: string; - updatePhase: Record; + updatePolicy: string; + updatePolicyDescription: string; + updatePolicyManual: string; + updatePolicyAutomatic: string; + updatePolicyOptions: { + manual: string; + fixed: string; + latest: string; + next: string; + }; + updatePolicyFixedVersion: string; + updatePolicySave: string; + updatePolicyCheckNow: string; + updatePolicyUnavailable: string; + updateSchedulerUnavailable: string; + updateSchedulerUnavailableBody: string; + updateSchedulerUnsupported: string; + updateSchedulerInactive: string; + updateSchedulerInactiveBody: string; + updateSchedulerNeedsRepair: string; + updateSchedulerNeedsRepairBody: string; + updatePolicyDisabled: string; + updatePolicyActiveTasks: string; + updatePolicyNotNewer(version: string): string; + updatePolicyManualAction(version: string): string; + updatePolicyManualReason: Record< + | 'current_compatibility_unknown' + | 'target_compatibility_unknown' + | 'compatibility_mismatch', + string + >; + updatePhase: Record< + 'preparing_cli' | import('@maka/runtime-host/operator').RuntimeHostServiceUpdatePhase, + string + >; updateBlockedTitle: string; updateBlockedBody: string; updateInterrupt: string; @@ -198,6 +232,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { setupChooseProject: '选择项目', setupComplete: 'Runtime Host 已连接', setupPhase: { + preparing_cli: '正在准备本地 CLI…', connecting_ssh: '正在连接 SSH…', checking_environment: '正在检查远程环境…', installing_package: '正在安装 Maka…', @@ -263,8 +298,39 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { startService: '启动', restartService: '重启', repairService: '修复', - updateService: '安装 Desktop 版本', + updateService: '安装配套版本', + updatePolicy: '更新策略', + updatePolicyDescription: '选择这个 Host 跟随的 Maka 版本', + updatePolicyManual: '手动', + updatePolicyAutomatic: '自动', + updatePolicyOptions: { + manual: '手动更新', + fixed: '固定版本', + latest: 'Latest 稳定频道', + next: 'Next 预览频道', + }, + updatePolicyFixedVersion: '版本', + updatePolicySave: '保存策略', + updatePolicyCheckNow: '立即检查', + updatePolicyUnavailable: '无法读取自动更新策略', + updateSchedulerUnavailable: '此 Runtime Host 尚不支持自动更新', + updateSchedulerUnavailableBody: '请先更新或修复服务,再启用固定版本或发布频道', + updateSchedulerUnsupported: '不支持', + updateSchedulerInactive: '未运行', + updateSchedulerInactiveBody: '更新调度器未在运行,请启动或修复服务后再启用自动更新', + updateSchedulerNeedsRepair: '需要修复', + updateSchedulerNeedsRepairBody: '更新调度器未在运行,请修复服务后再启用自动更新', + updatePolicyDisabled: '自动更新已关闭', + updatePolicyActiveTasks: 'Runtime Host 正在执行任务,本次更新已推迟', + updatePolicyNotNewer: (version: string) => `Maka ${version} 不高于当前版本`, + updatePolicyManualAction: (version: string) => `Maka ${version} 需要手动更新`, + updatePolicyManualReason: { + current_compatibility_unknown: '无法确认当前版本的存储兼容性', + target_compatibility_unknown: '无法确认目标版本的存储兼容性', + compatibility_mismatch: '目标版本需要手动处理存储兼容性', + }, updatePhase: { + preparing_cli: '正在准备本地 CLI…', checking: '正在检查版本…', staging: '正在准备新版本…', retiring: '正在安全停止当前 Runtime Host…', @@ -373,6 +439,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { setupChooseProject: 'Choose project', setupComplete: 'Runtime Host connected', setupPhase: { + preparing_cli: 'Preparing the local CLI…', connecting_ssh: 'Connecting over SSH…', checking_environment: 'Checking the remote environment…', installing_package: 'Installing Maka…', @@ -438,8 +505,42 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { startService: 'Start', restartService: 'Restart', repairService: 'Repair', - updateService: 'Install Desktop version', + updateService: 'Install matching version', + updatePolicy: 'Update policy', + updatePolicyDescription: 'Choose which Maka release this Host follows', + updatePolicyManual: 'Manual', + updatePolicyAutomatic: 'Automatic', + updatePolicyOptions: { + manual: 'Manual updates', + fixed: 'Fixed version', + latest: 'Latest stable channel', + next: 'Next preview channel', + }, + updatePolicyFixedVersion: 'Version', + updatePolicySave: 'Save policy', + updatePolicyCheckNow: 'Check now', + updatePolicyUnavailable: 'Automatic update policy is unavailable', + updateSchedulerUnavailable: 'Automatic updates are not available on this Runtime Host', + updateSchedulerUnavailableBody: + 'Update or repair the service before choosing a fixed version or release channel', + updateSchedulerUnsupported: 'Unsupported', + updateSchedulerInactive: 'Inactive', + updateSchedulerInactiveBody: + 'The update scheduler is not running. Start or repair the service before enabling automatic updates', + updateSchedulerNeedsRepair: 'Needs repair', + updateSchedulerNeedsRepairBody: + 'The update scheduler is not running. Repair the service before enabling automatic updates', + updatePolicyDisabled: 'Automatic updates are off', + updatePolicyActiveTasks: 'Runtime Host owns active work, so this update was deferred', + updatePolicyNotNewer: (version: string) => `Maka ${version} is not newer than this Host`, + updatePolicyManualAction: (version: string) => `Maka ${version} needs a manual update`, + updatePolicyManualReason: { + current_compatibility_unknown: 'The installed version has unknown storage compatibility', + target_compatibility_unknown: 'The target version has unknown storage compatibility', + compatibility_mismatch: 'The target requires a manual storage compatibility decision', + }, updatePhase: { + preparing_cli: 'Preparing the local CLI…', checking: 'Checking versions…', staging: 'Staging the new version…', retiring: 'Safely stopping the current Runtime Host…', 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 be1780e6bd..9121510e74 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx @@ -21,7 +21,17 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { Text } from '@astryxdesign/core/Text'; -import { Badge, Banner, Button, Spinner, useToast, useUiLocale } from '@maka/ui'; +import { + Badge, + Banner, + Button, + MoreMenu, + Selector, + Spinner, + TextInput, + useToast, + useUiLocale, +} from '@maka/ui'; import { uiLocaleToIntlLocale, type UiLocale } from '@maka/core/ui-locale'; import type { RemoteRuntimeHostProfile } from '@maka/runtime-host/client'; import type { @@ -30,6 +40,9 @@ import type { DesktopRuntimeHostManagementProgress, DesktopRuntimeHostAccessCredential, DesktopRuntimeHostAccessSnapshot, + DesktopRuntimeHostUpdatePolicySnapshot, + DesktopRuntimeHostUpdateReconciliationOutcome, + DesktopRuntimeHostUpdateReconciliationResponse, } from '../../preload/bridge-contract.js'; import { getSettingsProjectsCopy } from '../locales/settings-projects-copy.js'; import { settingsActionErrorMessage } from './settings-error-copy.js'; @@ -43,6 +56,8 @@ type RuntimeHostManagementConfirmation = readonly credential: DesktopRuntimeHostAccessCredential; }; +type UpdatePolicyChoice = 'manual' | 'fixed' | 'latest' | 'next'; + export function RuntimeHostManagementDialog(props: { readonly profile: RemoteRuntimeHostProfile | undefined; readonly onClose: () => void; @@ -57,6 +72,12 @@ export function RuntimeHostManagementDialog(props: { const [access, setAccess] = useState(); const [confirmation, setConfirmation] = useState(); const [updatePhase, setUpdatePhase] = useState(); + const [updatePolicy, setUpdatePolicy] = useState(); + const [updatePolicyChoice, setUpdatePolicyChoice] = useState('manual'); + const [fixedVersion, setFixedVersion] = useState(''); + const [updatePolicyError, setUpdatePolicyError] = useState(); + const [reconciliation, setReconciliation] = + useState(); const logsRef = useRef(null); const profile = props.profile; @@ -69,20 +90,30 @@ export function RuntimeHostManagementDialog(props: { setAccess(undefined); setConfirmation(undefined); setUpdatePhase(undefined); + setUpdatePolicy(undefined); + setUpdatePolicyChoice('manual'); + setFixedVersion(''); + setUpdatePolicyError(undefined); + setReconciliation(undefined); setLoading(true); - void window.maka.runtimeHostManagement.run(profile.id, 'status').then( - (response) => { + void (async () => { + try { + const response = await window.maka.runtimeHostManagement.run(profile.id, 'status'); if (disposed) return; if (response.kind === 'result') setResult(response); else if (response.kind === 'error') setError(response.error.message); else setUninstalledRoot(response.retainedStateRoot); - }, - (failure) => { + } catch (failure) { if (!disposed) setError(settingsActionErrorMessage(failure, locale)); - }, - ).finally(() => { + } + try { + const policy = await window.maka.runtimeHostManagement.getUpdatePolicy(profile.id); + if (!disposed) applyUpdatePolicy(policy); + } catch (failure) { + if (!disposed) setUpdatePolicyError(settingsActionErrorMessage(failure, locale)); + } if (!disposed) setLoading(false); - }); + })(); return () => { disposed = true; }; @@ -115,6 +146,7 @@ export function RuntimeHostManagementDialog(props: { return; } setResult(response); + if (action !== 'logs') await reloadUpdatePolicy(profile.id); } catch (failure) { const message = settingsActionErrorMessage(failure, locale); setError(message); @@ -163,6 +195,9 @@ export function RuntimeHostManagementDialog(props: { ? { kind: 'update' } : undefined, ); + if (response.action === 'update' && response.update.kind !== 'active_tasks') { + await reloadUpdatePolicy(profile.id); + } } catch (failure) { const message = settingsActionErrorMessage(failure, locale); setError(message); @@ -173,6 +208,84 @@ export function RuntimeHostManagementDialog(props: { } } + function applyUpdatePolicy(snapshot: DesktopRuntimeHostUpdatePolicySnapshot): void { + setUpdatePolicy(snapshot); + const policy = snapshot.policy; + if (policy.kind === 'manual') { + setUpdatePolicyChoice('manual'); + } else if (policy.kind === 'fixed') { + setUpdatePolicyChoice('fixed'); + setFixedVersion(policy.version); + } else { + setUpdatePolicyChoice(policy.channel); + } + setUpdatePolicyError(undefined); + } + + async function reloadUpdatePolicy(profileId: string): Promise { + try { + applyUpdatePolicy(await window.maka.runtimeHostManagement.getUpdatePolicy(profileId)); + } catch (failure) { + setUpdatePolicyError(settingsActionErrorMessage(failure, locale)); + } + } + + async function saveUpdatePolicy(): Promise { + if (!profile) return; + setLoading(true); + setError(undefined); + setUpdatePolicyError(undefined); + try { + const policy = updatePolicyChoice === 'manual' + ? { kind: 'manual' as const } + : updatePolicyChoice === 'fixed' + ? { kind: 'fixed' as const, version: fixedVersion.trim() } + : { kind: 'channel' as const, channel: updatePolicyChoice }; + applyUpdatePolicy( + await window.maka.runtimeHostManagement.setUpdatePolicy(profile.id, policy), + ); + setReconciliation(undefined); + } catch (failure) { + const message = settingsActionErrorMessage(failure, locale); + setUpdatePolicyError(message); + toast.error(copy.managementActionFailed, message); + } finally { + setLoading(false); + } + } + + async function reconcileUpdate(): Promise { + if (!profile) return; + setLoading(true); + setError(undefined); + setUpdatePolicyError(undefined); + setUpdatePhase('checking'); + try { + const response = await window.maka.runtimeHostManagement.reconcileUpdate(profile.id); + if (response.kind === 'error') { + setUpdatePolicyError(response.error.message); + toast.error(copy.managementActionFailed, response.error.message); + return; + } + setReconciliation(response.reconciliation); + applyUpdatePolicy(response.updatePolicy); + if ( + response.reconciliation.kind === 'updated' || + response.reconciliation.kind === 'repaired' + ) { + const status = await window.maka.runtimeHostManagement.run(profile.id, 'status'); + if (status.kind === 'result') setResult(status); + } + } catch (failure) { + const message = settingsActionErrorMessage(failure, locale); + setUpdatePolicyError(message); + toast.error(copy.managementActionFailed, message); + } finally { + setLoading(false); + setUpdatePhase(undefined); + } + } + async function rotateCredential(): Promise { if (!profile) return; setLoading(true); @@ -216,6 +329,14 @@ export function RuntimeHostManagementDialog(props: { const uninstalled = uninstalledRoot !== undefined; const serviceInstalled = service !== undefined && service.state !== 'not_installed'; const serviceActive = service?.state === 'running'; + const savedPolicyChoice = updatePolicy ? updatePolicyChoiceOf(updatePolicy) : undefined; + const updatePolicyDirty = savedPolicyChoice !== updatePolicyChoice || + (updatePolicyChoice === 'fixed' && + updatePolicy?.policy.kind === 'fixed' && + updatePolicy.policy.version !== fixedVersion.trim()); + const automaticPolicySelected = updatePolicyChoice !== 'manual'; + const automaticUpdatesAvailable = updatePolicy?.schedulingState === 'ready'; + const reconciliationResult = reconciliation; return ( ) : null} + {reconciliationResult?.kind === 'disabled' ? ( + + ) : null} + {reconciliationResult?.kind === 'manual_action' ? ( + + ) : null} + {reconciliationResult?.kind === 'active_tasks' ? ( + + ) : null} + {reconciliationResult?.kind === 'already_current' ? ( + + ) : null} + {reconciliationResult?.kind === 'repaired' ? ( + + ) : null} + {reconciliationResult?.kind === 'updated' ? ( + + ) : null} {!access && service ? ( <>
@@ -309,6 +462,114 @@ export function RuntimeHostManagementDialog(props: { ) : null}
+ {serviceInstalled ? ( +
+
+
+ {copy.updatePolicy} + + {copy.updatePolicyDescription} + +
+ {updatePolicy ? ( + + ) : null} +
+ {updatePolicyError ? ( + + ) : null} + {updatePolicy?.schedulingState === 'unsupported' ? ( + + ) : null} + {updatePolicy?.schedulingState === 'needs_repair' ? ( + + ) : null} + {updatePolicy?.schedulingState === 'inactive' ? ( + + ) : null} + {updatePolicy ? ( +
+ setUpdatePolicyChoice(value as UpdatePolicyChoice)} + /> + {updatePolicyChoice === 'fixed' ? ( + + ) : null} +
+
+
+ ) : null} +
+ ) : null}
{copy.directoryRoots} {service.projectDirectoryRoots.length > 0 ? ( @@ -506,20 +767,29 @@ export function RuntimeHostManagementDialog(props: { isDisabled={loading} onClick={props.onClose} /> - {profile?.transport.kind === 'ssh' && !uninstalled ? ( -
@@ -581,6 +827,12 @@ export function RuntimeHostManagementDialog(props: { ); } +function updatePolicyChoiceOf(snapshot: DesktopRuntimeHostUpdatePolicySnapshot): UpdatePolicyChoice { + const policy = snapshot.policy; + if (policy.kind === 'manual' || policy.kind === 'fixed') return policy.kind; + return policy.channel; +} + function Fact(props: { readonly label: string; readonly value: string; diff --git a/apps/desktop/src/renderer/styles/settings/runtime-host.css b/apps/desktop/src/renderer/styles/settings/runtime-host.css index d5d648e677..2f8158fa85 100644 --- a/apps/desktop/src/renderer/styles/settings/runtime-host.css +++ b/apps/desktop/src/renderer/styles/settings/runtime-host.css @@ -134,6 +134,34 @@ gap: var(--space-2); } +.settingsRuntimeHostUpdatePolicy { + display: grid; + gap: var(--space-3); + padding: var(--space-3); + border: var(--border-width-hairline) solid var(--border-soft); + border-radius: var(--radius-container); + background: var(--background-secondary); +} + +.settingsRuntimeHostUpdatePolicyHeading, +.settingsRuntimeHostUpdatePolicyActions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.settingsRuntimeHostUpdatePolicyHeading > div:first-child, +.settingsRuntimeHostUpdatePolicyControls { + display: grid; + gap: var(--space-2); + min-width: 0; +} + +.settingsRuntimeHostUpdatePolicyActions { + justify-content: flex-end; +} + .settingsRuntimeHostManagementRoots li { display: grid; gap: var(--space-1); diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 834cd00c6d..185fa9cb08 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -97,7 +97,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/settings/providers-panel.tsx` | settings-module | Badge, Banner, Button, EmptyState, HStack, Heading, List, ListItem, Text, VStack | aligned — uses Astryx (Badge, Banner, Button, EmptyState, HStack, Heading, List, ListItem) | aligned | | `apps/desktop/src/renderer/settings/request-customization-editor.tsx` | settings-module | Button, HStack, IconButton, Text, VStack | aligned — uses Astryx (Button, HStack, IconButton, Text, VStack) | aligned | | `apps/desktop/src/renderer/settings/runtime-host-interaction-boundary.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned | -| `apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx` | settings-module | Badge, Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text | aligned — uses Astryx (Badge, Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner) | aligned | +| `apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx` | settings-module | Badge, Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Selector, Spinner, Text | aligned — uses Astryx (Badge, Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Selector) | aligned | | `apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx` | settings-module | Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text | aligned — uses Astryx (Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text) | aligned | | `apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx` | settings-module | Badge, Banner, Button, HStack, List, ListItem, SegmentedControl, SegmentedControlItem, Selector, Switch | aligned — uses Astryx (Badge, Banner, Button, HStack, List, ListItem, SegmentedControl, SegmentedControlItem) | aligned | | `apps/desktop/src/renderer/settings/runtime-host-settings-target.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts index c7120006a0..bc93d6684a 100644 --- a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts @@ -24,6 +24,8 @@ import { join } from 'node:path'; import { describe, it } from 'node:test'; import { decodeRuntimeHostServiceManagementFrame, + RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; import { parseRuntimeHostCommand } from '../runtime-host-cli.js'; @@ -31,6 +33,7 @@ import { runManagedRuntimeHostUpdatePolicyCli, runManagedRuntimeHostUpdateReconcileCli, } from '../runtime-host-update-reconciliation.js'; +import { RuntimeHostServiceManagerError } from '../runtime-host-service-manager.js'; import { readRuntimeHostManagedUpdatePolicy, resolveRuntimeHostManagedUpdatePolicyPath, @@ -58,7 +61,7 @@ const SERVICE = { }; describe('managed Runtime Host update reconciliation', () => { - it('parses one mutually exclusive policy and a target-free reconcile command', () => { + it('parses update policy and reconciliation commands against an optional expected target', () => { assert.deepEqual( parseRuntimeHostCommand([ 'service', @@ -79,10 +82,44 @@ describe('managed Runtime Host update reconciliation', () => { expectedTarget: TARGET, }, ); - assert.deepEqual(parseRuntimeHostCommand(['service', 'reconcile-update', '--json']), { - kind: 'runtime-host-service-reconcile-update', - json: true, - }); + assert.deepEqual( + parseRuntimeHostCommand([ + 'service', + 'reconcile-update', + '--json', + '--expected-service-id', + TARGET.serviceId, + '--expected-root-path', + TARGET.rootPath, + '--expected-root-id', + TARGET.rootId, + ]), + { + kind: 'runtime-host-service-reconcile-update', + json: true, + expectedTarget: TARGET, + }, + ); + assert.deepEqual( + parseRuntimeHostCommand([ + 'service', + 'update-policy', + '--target', + 'manual', + '--expected-service-id', + TARGET.serviceId, + '--expected-root-path', + TARGET.rootPath, + '--expected-root-id', + TARGET.rootId, + ]), + { + kind: 'runtime-host-service-update-policy', + json: false, + policy: { kind: 'manual' }, + expectedTarget: TARGET, + }, + ); assert.equal( parseRuntimeHostCommand(['service', 'update-policy', '--target', 'latest']).kind, 'error', @@ -128,7 +165,7 @@ describe('managed Runtime Host update reconciliation', () => { assert.equal( await runManagedRuntimeHostUpdatePolicyCli( - { ...common, policy: { kind: 'manual' } }, + { ...common, policy: { kind: 'manual' }, expectedTarget: TARGET }, { withDeploymentLock: async (_root, operation) => operation(), manage, @@ -139,6 +176,16 @@ describe('managed Runtime Host update reconciliation', () => { 0, ); assert.equal(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), null); + const previousCapabilityRequest = process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]; + process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] = + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY; + t.after(() => { + if (previousCapabilityRequest === undefined) { + delete process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]; + } else { + process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] = previousCapabilityRequest; + } + }); let manualOutput = ''; assert.equal( await runManagedRuntimeHostUpdateReconcileCli( @@ -161,6 +208,72 @@ describe('managed Runtime Host update reconciliation', () => { : undefined, 'disabled', ); + assert.deepEqual( + manualFrame?.kind === 'result' ? manualFrame.operatorCapabilities : undefined, + [RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY], + ); + assert.equal( + manualFrame?.kind === 'result' && manualFrame.action === 'reconcile_update' + ? manualFrame.updateSchedulerState + : undefined, + 'ready', + ); + }); + + it('fences policy reads and reports a drifted update scheduler', async (t) => { + const previousCapabilityRequest = process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]; + process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] = + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY; + t.after(() => { + if (previousCapabilityRequest === undefined) { + delete process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]; + } else { + process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] = previousCapabilityRequest; + } + }); + let locked = false; + let output = ''; + assert.equal( + await runManagedRuntimeHostUpdatePolicyCli( + { + json: true, + framed: false, + clientDataRoot: '/client', + defaultRootPath: '/workspace', + expectedTarget: TARGET, + }, + { + withDeploymentLock: async (_root, operation) => { + locked = true; + try { + return await operation(); + } finally { + locked = false; + } + }, + manage: async (input) => { + assert.equal(locked, true); + assert.deepEqual(input.expectedTarget, TARGET); + return managedStatus('/managed'); + }, + readPolicy: async () => null, + createBackend: () => ({ + ...unusedBackend(), + verifyDeployment: async () => { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'Update scheduler is not loaded', + ); + }, + }), + writeOutput: (value) => { + output += value; + }, + }, + ), + 0, + ); + assert.equal(JSON.parse(output).updateSchedulerState, 'needs_repair'); }); it('distinguishes an uncertain policy commit and makes an absent-policy retry durable', async (t) => { diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index b9892392cf..4189bd1d6a 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -345,6 +345,7 @@ export async function runMakaCli( framed: command.framed ?? false, clientDataRoot: serviceDataRoots.clientDataRoot, defaultRootPath: serviceDataRoots.workspaceRoot, + ...(command.expectedTarget ? { expectedTarget: command.expectedTarget } : {}), }); } case 'runtime-host-managed-deployment-cleanup': { diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index 9153e1e703..d00a333ae6 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -105,6 +105,7 @@ export type RuntimeHostCliCommand = json: boolean; framed?: true; clientDataRoot?: string; + expectedTarget?: RuntimeHostManagedServiceTarget; } | { kind: 'runtime-host-managed-deployment-cleanup'; @@ -327,15 +328,9 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { if (action === 'update-policy') { const policy = updateTarget === undefined ? undefined : parseUpdatePolicy(updateTarget); if (policy && 'exitCode' in policy) return policy; - if (policy?.kind === 'manual' && options.expectedTarget) { - return error('runtime-host service update-policy manual does not accept an expected target'); - } if (policy && policy.kind !== 'manual' && !options.expectedTarget) { return error('runtime-host service update-policy requires an expected target'); } - if (!policy && options.expectedTarget) { - return error('runtime-host service update-policy requires --target when setting a target'); - } return { kind: 'runtime-host-service-update-policy', json: options.json, @@ -346,14 +341,12 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { }; } if (action === 'reconcile-update') { - if (options.expectedTarget) { - return error('runtime-host service reconcile-update does not accept an expected target'); - } return { kind: 'runtime-host-service-reconcile-update', json: options.json, ...(options.framed ? { framed: true } : {}), ...(clientDataRoot ? { clientDataRoot } : {}), + ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), }; } if (action === 'check-update') { diff --git a/packages/cli/src/runtime-host-update-reconciliation.ts b/packages/cli/src/runtime-host-update-reconciliation.ts index 4e81190a1d..05b4468e85 100644 --- a/packages/cli/src/runtime-host-update-reconciliation.ts +++ b/packages/cli/src/runtime-host-update-reconciliation.ts @@ -21,10 +21,14 @@ import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { isDeepStrictEqual } from 'node:util'; import { encodeRuntimeHostServiceManagementFrame, + RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES, + type RuntimeHostOperatorCapability, type RuntimeHostManagedUpdatePolicy, type RuntimeHostServiceManagementFrame, + type RuntimeHostUpdateSchedulerState, } from '@maka/runtime-host/operator'; import { manageRuntimeHostService, @@ -75,6 +79,7 @@ interface RuntimeHostUpdateReconcileCliOptions { readonly framed: boolean; readonly clientDataRoot: string; readonly defaultRootPath: string; + readonly expectedTarget?: RuntimeHostManagedServiceTarget; } interface RuntimeHostUpdateReconciliationDeps { @@ -96,44 +101,54 @@ export async function runManagedRuntimeHostUpdatePolicyCli( const deps = reconciliationDeps(overrides); try { const requestedPolicy = options.policy; - const record = requestedPolicy - ? await deps.withDeploymentLock(options.clientDataRoot, async () => { - if (requestedPolicy.kind === 'manual') { - const status = await readManagedServiceStatus(options, deps); - const managedDeploymentRoot = status.service.config?.managedDeploymentRoot; - if (managedDeploymentRoot) { - await deps.writePolicy(managedDeploymentRoot, null); - } - return null; - } - const expectedTarget = options.expectedTarget; - if (!expectedTarget) { - throw new RuntimeHostUpdatePolicyError( - 'invalid_update_policy', - 'An automatic Runtime Host update policy requires the expected managed service target', - ); - } - const status = await readManagedServiceStatus(options, deps, expectedTarget); - const managedDeploymentRoot = status.service.config?.managedDeploymentRoot; - if (!status.service.installed || !managedDeploymentRoot) { - throw new RuntimeHostServiceManagerError( - 'not_installed', - 'An automatic update policy requires a Maka-managed Runtime Host service', - ); - } - const next: RuntimeHostManagedUpdatePolicyRecord = { - schemaVersion: 1, - policy: requestedPolicy, - target: { - ...expectedTarget, - rootPath: status.service.config.rootPath, - }, - }; - await deps.writePolicy(managedDeploymentRoot, next); - return next; - }) - : await readCurrentUpdatePolicy(options, deps); - writeFrame(updatePolicyResult(record), options, deps); + const snapshot = await deps.withDeploymentLock(options.clientDataRoot, async () => { + const expectedTarget = options.expectedTarget; + const status = await readManagedServiceStatus(options, deps, expectedTarget); + const managedDeploymentRoot = status.service.config?.managedDeploymentRoot; + const updateSchedulerState = await inspectUpdateScheduler(options, status, deps); + if (!requestedPolicy) { + return { + record: + status.service.installed && managedDeploymentRoot + ? await deps.readPolicy(managedDeploymentRoot) + : null, + updateSchedulerState, + }; + } + if (requestedPolicy.kind === 'manual') { + if (managedDeploymentRoot) await deps.writePolicy(managedDeploymentRoot, null); + return { record: null, updateSchedulerState }; + } + if (!expectedTarget) { + throw new RuntimeHostUpdatePolicyError( + 'invalid_update_policy', + 'An automatic Runtime Host update policy requires the expected managed service target', + ); + } + if (!status.service.installed || !managedDeploymentRoot) { + throw new RuntimeHostServiceManagerError( + 'not_installed', + 'An automatic update policy requires a Maka-managed Runtime Host service', + ); + } + if (updateSchedulerState !== 'ready') { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The Runtime Host update scheduler must be running before enabling automatic updates', + ); + } + const record: RuntimeHostManagedUpdatePolicyRecord = { + schemaVersion: 1, + policy: requestedPolicy, + target: { + ...expectedTarget, + rootPath: status.service.config.rootPath, + }, + }; + await deps.writePolicy(managedDeploymentRoot, record); + return { record, updateSchedulerState }; + }); + writeFrame(updatePolicyResult(snapshot.record, snapshot.updateSchedulerState), options, deps); return 0; } catch (error) { writeFrame(updatePolicyError(error), options, deps); @@ -147,21 +162,28 @@ export async function runManagedRuntimeHostUpdateReconcileCli( ): Promise { const deps = reconciliationDeps(overrides); try { - const status = await readManagedServiceStatus(options, deps); - const managedDeploymentRoot = status.service.config?.managedDeploymentRoot; - const policy = - status.service.installed && managedDeploymentRoot - ? { - root: managedDeploymentRoot, - record: await deps.readPolicy(managedDeploymentRoot), - } - : undefined; + const snapshot = await deps.withDeploymentLock(options.clientDataRoot, async () => { + const status = await readManagedServiceStatus(options, deps, options.expectedTarget); + const managedDeploymentRoot = status.service.config?.managedDeploymentRoot; + return { + updateSchedulerState: await inspectUpdateScheduler(options, status, deps), + policy: + status.service.installed && managedDeploymentRoot + ? { + root: managedDeploymentRoot, + record: await deps.readPolicy(managedDeploymentRoot), + } + : undefined, + }; + }); + const { policy, updateSchedulerState } = snapshot; if (!policy?.record) { writeFrame( { schemaVersion: 1, kind: 'result', action: 'reconcile_update', + ...requestedUpdateSchedulerCapability(updateSchedulerState), updatePolicy: { policy: { kind: 'manual' } }, reconciliation: { kind: 'disabled' }, }, @@ -184,6 +206,7 @@ export async function runManagedRuntimeHostUpdateReconcileCli( schemaVersion: 1, kind: 'result', action: 'reconcile_update', + ...requestedUpdateSchedulerCapability(updateSchedulerState), updatePolicy, service: selection.service, reconciliation: { @@ -232,7 +255,7 @@ export async function runManagedRuntimeHostUpdateReconcileCli( }, }, (frame) => { - const mapped = reconcileFrame(frame, updatePolicy); + const mapped = reconcileFrame(frame, updatePolicy, updateSchedulerState); writeFrame(mapped, options, deps); if (mapped.kind !== 'progress') terminal = mapped; }, @@ -247,17 +270,6 @@ export async function runManagedRuntimeHostUpdateReconcileCli( } } -async function readCurrentUpdatePolicy( - options: RuntimeHostUpdatePolicyCliOptions, - deps: RuntimeHostUpdateReconciliationDeps, -): Promise { - const status = await readManagedServiceStatus(options, deps); - const managedDeploymentRoot = status.service.config?.managedDeploymentRoot; - return status.service.installed && managedDeploymentRoot - ? await deps.readPolicy(managedDeploymentRoot) - : null; -} - function readManagedServiceStatus( options: Pick< RuntimeHostUpdatePolicyCliOptions | RuntimeHostUpdateReconcileCliOptions, @@ -280,6 +292,36 @@ function readManagedServiceStatus( ); } +async function inspectUpdateScheduler( + options: Pick, + status: Awaited>, + deps: RuntimeHostUpdateReconciliationDeps, +): Promise { + const config = status.service.config; + if (!status.service.installed || !config?.managedDeploymentRoot) return 'needs_repair'; + const backend = deps.createBackend(resolveRuntimeHostManagedServiceId(options.clientDataRoot)); + try { + await backend.verifyDeployment(config, { requireSchedulerReady: true }); + return 'ready'; + } catch (error) { + if (!(error instanceof RuntimeHostServiceManagerError)) throw error; + if (error.code === 'invalid_launch') return 'needs_repair'; + if (error.code !== 'target_mismatch') throw error; + } + try { + await backend.verifyDeployment(config); + return 'inactive'; + } catch (error) { + if ( + error instanceof RuntimeHostServiceManagerError && + (error.code === 'target_mismatch' || error.code === 'invalid_launch') + ) { + return 'needs_repair'; + } + throw error; + } +} + function reconciliationDeps( overrides: Partial, ): RuntimeHostUpdateReconciliationDeps { @@ -299,11 +341,13 @@ function reconciliationDeps( function updatePolicyResult( record: RuntimeHostManagedUpdatePolicyRecord | null, + updateSchedulerState: RuntimeHostUpdateSchedulerState, ): UpdatePolicyFrame { return { schemaVersion: 1, kind: 'result', action: 'update_policy', + ...requestedUpdateSchedulerCapability(updateSchedulerState), updatePolicy: policyResult(record), }; } @@ -325,6 +369,7 @@ function policySelector( function reconcileFrame( frame: RuntimeHostUpdateFrame, updatePolicy: ReturnType, + observedSchedulerState: RuntimeHostUpdateSchedulerState, ): ReconcileUpdateFrame { if (frame.kind === 'progress') { return { ...frame, action: 'reconcile_update' }; @@ -336,12 +381,34 @@ function reconcileFrame( schemaVersion: 1, kind: 'result', action: 'reconcile_update', + ...requestedUpdateSchedulerCapability( + frame.update.kind === 'updated' || + frame.update.kind === 'repaired' || + frame.update.kind === 'already_current' + ? 'ready' + : observedSchedulerState, + ), updatePolicy, service: frame.service, reconciliation: frame.update, }; } +function requestedUpdateSchedulerCapability( + updateSchedulerState: RuntimeHostUpdateSchedulerState, +): { + readonly operatorCapabilities?: RuntimeHostOperatorCapability[]; + readonly updateSchedulerState?: RuntimeHostUpdateSchedulerState; +} { + return process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] === + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY + ? { + operatorCapabilities: [RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY], + updateSchedulerState, + } + : {}; +} + function updatePolicyError(error: unknown): UpdatePolicyFrame { return { schemaVersion: 1, diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index b9ea599ef7..f5c37bcd1e 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -32,6 +32,7 @@ export { RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY, + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES, RUNTIME_HOST_SERVICE_LOG_MAX_BYTES, @@ -42,6 +43,7 @@ export { type RuntimeHostServiceManagementFrame, type RuntimeHostManagedUpdatePolicy, type RuntimeHostServiceUpdatePhase, + type RuntimeHostUpdateSchedulerState, type RuntimeHostOperatorCapability, type RuntimeHostServiceSummary, } from './service-management-frame.js'; diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts index 27e53ecc34..8cee86dbd0 100644 --- a/packages/runtime-host/src/operator/service-management-frame.ts +++ b/packages/runtime-host/src/operator/service-management-frame.ts @@ -31,6 +31,7 @@ export const RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES = 128; export const RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES = 2 * 1024; export const RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY = 'access-management-v1'; export const RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY = 'process-lifetime-lock-v1'; +export const RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY = 'update-scheduler-v1'; export const RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV = 'MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST'; @@ -73,6 +74,7 @@ const NON_UPDATE_SERVICE_ACTIONS = [ ] as const; const UPDATE_PHASES = ['checking', 'staging', 'retiring', 'replacing'] as const; const UPDATE_CHANNELS = ['latest', 'next'] as const; +const UPDATE_SCHEDULER_STATES = ['ready', 'inactive', 'needs_repair'] as const; const MANUAL_ACTION_REASONS = [ 'target_not_newer', 'current_compatibility_unknown', @@ -84,6 +86,7 @@ const SERVICE_STATES = ['not_installed', ...INSTALLED_SERVICE_STATES] as const; const OPERATOR_CAPABILITIES = [ RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY, + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, ] as const; const boundedString = (maxBytes: number) => @@ -256,6 +259,25 @@ const SERVICE_ERROR_SCHEMA = z }) .strict(); +function requireSchedulerStateWithCapability( + frame: { + readonly operatorCapabilities?: readonly RuntimeHostOperatorCapability[]; + readonly updateSchedulerState?: RuntimeHostUpdateSchedulerState; + }, + context: z.RefinementCtx, +): void { + const exposesScheduler = + frame.operatorCapabilities?.includes(RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY) === + true; + if (exposesScheduler !== (frame.updateSchedulerState !== undefined)) { + context.addIssue({ + code: 'custom', + path: ['updateSchedulerState'], + message: 'Update scheduler capability and state must be projected together', + }); + } +} + const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ z .object({ @@ -320,14 +342,19 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ kind: z.literal('result'), action: z.literal('update_policy'), updatePolicy: UPDATE_POLICY_RESULT_SCHEMA, + operatorCapabilities: z.array(z.enum(OPERATOR_CAPABILITIES)).max(16).optional(), + updateSchedulerState: z.enum(UPDATE_SCHEDULER_STATES).optional(), }) - .strict(), + .strict() + .superRefine(requireSchedulerStateWithCapability), z .object({ schemaVersion: z.literal(1), kind: z.literal('result'), action: z.literal('reconcile_update'), updatePolicy: UPDATE_POLICY_RESULT_SCHEMA, + operatorCapabilities: z.array(z.enum(OPERATOR_CAPABILITIES)).max(16).optional(), + updateSchedulerState: z.enum(UPDATE_SCHEDULER_STATES).optional(), service: SERVICE_SUMMARY_SCHEMA.optional(), reconciliation: z.discriminatedUnion('kind', [ z.object({ kind: z.literal('disabled') }).strict(), @@ -348,6 +375,7 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ }) .strict() .superRefine((frame, context) => { + requireSchedulerStateWithCapability(frame, context); if ((frame.reconciliation.kind === 'disabled') !== (frame.service === undefined)) { context.addIssue({ code: 'custom', @@ -408,6 +436,7 @@ export type RuntimeHostServiceManagementFrame = z.infer; export type RuntimeHostOperatorCapability = (typeof OPERATOR_CAPABILITIES)[number]; +export type RuntimeHostUpdateSchedulerState = (typeof UPDATE_SCHEDULER_STATES)[number]; export type RuntimeHostServiceSummary = z.infer; export function encodeRuntimeHostServiceManagementFrame( diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index d58375c9e9..5340a2ea85 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -53,10 +53,11 @@ const cliSource = join(repoRoot, 'packages/cli'); const releaseRoot = join(cliSource, 'release'); const stageRoot = join(releaseRoot, 'package'); const allowDirty = process.argv.includes('--allow-dirty'); +const developmentBuild = process.argv.includes('--development'); const preparedTree = process.env.MAKA_CLI_RELEASE_PREPARED_TREE === '1'; const unsupportedArguments = process.argv .slice(2) - .filter((argument) => argument !== '--allow-dirty'); + .filter((argument) => !['--allow-dirty', '--development'].includes(argument)); if (unsupportedArguments.length > 0) { throw new Error(`Unsupported release argument: ${unsupportedArguments.join(', ')}`); } @@ -78,6 +79,15 @@ main(); function main() { validateToolchain(); + if (developmentBuild) { + if (allowDirty || preparedTree) { + throw new Error('--development cannot be combined with release build options'); + } + console.warn('[release-cli] producing a private development tarball'); + buildRuntimeWorkspaces({ clean: false }); + packageCli(false); + return; + } if (!preparedTree && !allowDirty) { validateCleanWorktree(); buildFromCleanDependencyTree(); @@ -94,10 +104,14 @@ function main() { '[release-cli] WARNING: producing a private development tarball with publishing disabled', ); } - buildRuntimeWorkspaces(); + buildRuntimeWorkspaces({ clean: true }); checkProductionAudit(); runNpm(['run', 'check:cli-third-party-notices']); + packageCli(preparedTree); +} + +function packageCli(publishable) { const dependencyTree = readCliDependencyTree(); const cli = dependencyTree.dependencies?.['maka-agent']; if (!cli) throw new Error('npm ls did not return the maka-agent workspace'); @@ -108,7 +122,7 @@ function main() { const expectedDependencyManifests = copyDependencyClosure(cli); copyEvalMirror(); copyReleaseDocuments(); - writeReleaseManifest(cli, preparedTree); + writeReleaseManifest(cli, publishable); validateStaging(); const [pack] = JSON.parse( @@ -206,8 +220,10 @@ function validateCleanWorktree() { } } -function buildRuntimeWorkspaces() { - for (const workspace of buildOrder) runNpm(['--workspace', workspace, 'run', 'clean']); +function buildRuntimeWorkspaces(options) { + if (options.clean) { + for (const workspace of buildOrder) runNpm(['--workspace', workspace, 'run', 'clean']); + } for (const workspace of buildOrder) runNpm(['--workspace', workspace, 'run', 'build']); } From ec20b1edb77ed03d965c1870ffb4177c13d97e59 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 16:52:34 +0800 Subject: [PATCH 2/5] fix(desktop): harden Runtime Host update preparation Exclude stale build output from development CLI archives, own the packaging child lifecycle, and keep managed update targeting and scheduler support represented once. Generated-by: Codex --- .../__tests__/runtime-host-management.test.ts | 3 - .../runtime-host-setup-package.test.ts | 37 ++- .../runtime-host-ssh-terminal.test.ts | 3 - apps/desktop/src/main/runtime-host-boot.ts | 21 +- .../src/main/runtime-host-management.ts | 72 ++---- .../src/main/runtime-host-setup-package.ts | 238 +++++++++++++----- .../src/main/runtime-host-ssh-terminal.ts | 4 +- ...runtime-host-update-reconciliation.test.ts | 4 - .../src/runtime-host-update-reconciliation.ts | 15 +- .../src/operator/service-management-frame.ts | 25 +- scripts/release-cli-file-policy.mjs | 16 +- scripts/release-cli-file-policy.test.mjs | 31 ++- scripts/release-cli-package.mjs | 18 +- 13 files changed, 313 insertions(+), 174 deletions(-) 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 f3f03bb770..54dc00529c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -20,7 +20,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { - RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, runtimeHostAccessCredentialFingerprint, type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; @@ -497,7 +496,6 @@ test('manages one Host update policy and reconciles it through the bound operato schemaVersion: 1, kind: 'result', action: 'update_policy', - operatorCapabilities: [RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY], updateSchedulerState: 'ready', updatePolicy: { policy, @@ -512,7 +510,6 @@ test('manages one Host update policy and reconciles it through the bound operato schemaVersion: 1, kind: 'result', action: 'reconcile_update', - operatorCapabilities: [RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY], updateSchedulerState: 'ready', updatePolicy: { policy: { kind: 'channel', channel: 'latest' }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts b/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts index 9ca2b2af97..4fb020ae8e 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts @@ -31,14 +31,14 @@ test('development setup lazily builds one local CLI archive unless explicitly ov isPackaged: false, appPath: join(repoRoot, 'apps', 'desktop'), environment: {}, - buildDevelopmentArchive: async (resolvedRoot) => { + startDevelopmentArchiveBuild: (resolvedRoot) => { builds += 1; assert.equal(resolvedRoot, repoRoot); - return archive; + return { result: Promise.resolve(archive), close: async () => undefined }; }, }); - assert.deepEqual(await Promise.all([resolvePackage(), resolvePackage()]), [ + assert.deepEqual(await Promise.all([resolvePackage.resolve(), resolvePackage.resolve()]), [ { kind: 'development_archive', path: archive, @@ -55,10 +55,37 @@ test('development setup lazily builds one local CLI archive unless explicitly ov isPackaged: false, appPath: join(repoRoot, 'apps', 'desktop'), environment: { MAKA_RUNTIME_HOST_SETUP_ARCHIVE: override }, - buildDevelopmentArchive: async () => assert.fail('override must bypass the local build'), + startDevelopmentArchiveBuild: () => assert.fail('override must bypass the local build'), }); - assert.deepEqual(await resolveOverride(), { + assert.deepEqual(await resolveOverride.resolve(), { kind: 'development_archive', path: override, }); + await Promise.all([resolvePackage.close(), resolveOverride.close()]); +}); + +test('cancelling the last setup-package waiter stops its shared build', async () => { + const cancelled = new AbortController(); + let rejectBuild!: (error: Error) => void; + let closes = 0; + const resolver = createRuntimeHostSetupPackageResolver({ + isPackaged: false, + appPath: '/workspace/apps/desktop', + environment: {}, + startDevelopmentArchiveBuild: () => ({ + result: new Promise((_resolve, reject) => { + rejectBuild = reject; + }), + close: async () => { + closes += 1; + rejectBuild(new Error('build stopped')); + }, + }), + }); + + const pending = resolver.resolve(cancelled.signal); + cancelled.abort(new Error('setup cancelled')); + await assert.rejects(pending, /setup cancelled/u); + assert.equal(closes, 1); + await resolver.close(); }); 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 4aeb29c086..a1b0a9ca3e 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 @@ -31,7 +31,6 @@ import { encodeRuntimeHostServiceManagementFrame, encodeRuntimeHostSetupFrame, runtimeHostAccessCredentialFingerprint, - RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, RUNTIME_HOST_SETUP_FRAME_PREFIX, } from '@maka/runtime-host/operator'; import { createDesktopRuntimeHostSshTerminal } from '../runtime-host-ssh-terminal.js'; @@ -397,7 +396,6 @@ test('uses the managed operator for update policy and one-shot reconciliation', schemaVersion: 1, kind: 'result', action: 'update_policy', - operatorCapabilities: [RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY], updateSchedulerState: 'ready', updatePolicy: { policy: { kind: 'channel', channel: 'latest' }, @@ -435,7 +433,6 @@ test('uses the managed operator for update policy and one-shot reconciliation', schemaVersion: 1, kind: 'result', action: 'reconcile_update', - operatorCapabilities: [RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY], updateSchedulerState: 'ready', updatePolicy: { policy: { kind: 'channel', channel: 'latest' }, diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index b84dec928a..f4d88123ec 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -373,6 +373,11 @@ const runtimeHostSshTerminal = createDesktopRuntimeHostSshTerminal({ ipcMain, send: (channel, event) => mainWindowController.send(channel, event), }); +const runtimeHostSetupPackage = createRuntimeHostSetupPackageResolver({ + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + environment: process.env, +}); const native = assembleDesktopNativeCapabilities({ isComputerUseRealModelE2e, locale: desktopLocale, @@ -438,7 +443,7 @@ const runtimeHostOnboarding = createDesktopRuntimeHostOnboarding({ clientInstanceId: runtimeHostClientInstanceId, profiles: runtimeHostProfileService, runSetup: runtimeHostSshTerminal.runSetup, - resolveSetupPackage: runtimeHostSetupPackage, + resolveSetupPackage: runtimeHostSetupPackage.resolve, send: (snapshot) => mainWindowController.send("runtime-host-onboarding:changed", snapshot), }); @@ -449,7 +454,7 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ runUpdate: runtimeHostSshTerminal.runUpdate, runUpdatePolicy: runtimeHostSshTerminal.runUpdatePolicy, runUpdateReconciliation: runtimeHostSshTerminal.runUpdateReconciliation, - resolveUpdatePackage: runtimeHostSetupPackage, + resolveUpdatePackage: runtimeHostSetupPackage.resolve, currentHostEpoch: (profileId) => runtimeHostManager?.current(profileId)?.candidate?.client.hostEpoch, awaitUpdatedConnection: async ( @@ -494,17 +499,6 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ runAccessManagement: runtimeHostSshTerminal.runAccessManagement, cleanupManagedDeployment: runtimeHostSshTerminal.cleanupManagedDeployment, }); - -let setupPackageResolver: ReturnType | undefined; - -function runtimeHostSetupPackage(signal?: AbortSignal) { - setupPackageResolver ??= createRuntimeHostSetupPackageResolver({ - isPackaged: app.isPackaged, - appPath: app.getAppPath(), - environment: process.env, - }); - return setupPackageResolver(signal); -} const defaultRuntimeHostRecovery = createRuntimeHostDefaultRecovery({ defaultProfileId: () => runtimeHostManager?.defaultProfileId() ?? @@ -1586,6 +1580,7 @@ async function closeRuntimeHostDesktop(): Promise { Promise.resolve().then(() => runtimeHostManagement.close()), runtimeHostManager?.close(), runtimeHostOnboarding.close(), + runtimeHostSetupPackage.close(), Promise.resolve().then(() => workBoardIpc.close()), runtimeHostSshTerminal.close(), botRegistry.stopAll(), diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index eec12d913f..b215036463 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -20,7 +20,6 @@ import type { IpcMain } from 'electron'; import { RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, - RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, isProductReleaseVersion, runtimeHostAccessCredentialFingerprint, type RuntimeHostManagedUpdatePolicy, @@ -241,6 +240,25 @@ export function createDesktopRuntimeHostManagement(input: { }; }; + const updateTarget = async (profileIdValue: unknown) => { + const profileId = requireProfileId(profileIdValue); + const managed = await resolveManagedService(profileId); + const transport = managed.profile.transport; + if (managed.state !== 'active' || transport.kind !== 'ssh') { + throw new Error('This Runtime Host profile is not available for managed updates'); + } + return { + profileId, + managed, + transport, + expectedTarget: { + serviceId: managed.service.id, + rootPath: managed.service.rootPath, + rootId: managed.profile.rootId, + }, + }; + }; + const update = async ( profileIdValue: unknown, allowInterruptActiveTasksValue: unknown, @@ -248,26 +266,16 @@ export function createDesktopRuntimeHostManagement(input: { 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 { profileId, managed, transport, expectedTarget } = await updateTarget(profileIdValue); const previousHostEpoch = input.currentHostEpoch(profileId); input.sendProgress({ profileId, phase: 'preparing_cli' }); const setupPackage = await input.resolveUpdatePackage(); const response = await input.runUpdate( { - destination: managed.profile.transport.destination, - ...(managed.profile.transport.sshPort === undefined - ? {} - : { sshPort: managed.profile.transport.sshPort }), + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), setupPackage, - expectedTarget: { - serviceId: managed.service.id, - rootPath: managed.service.rootPath, - rootId: managed.profile.rootId, - }, + expectedTarget, ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), }, (phase) => input.sendProgress({ profileId, phase }), @@ -299,25 +307,6 @@ export function createDesktopRuntimeHostManagement(input: { : response; }; - const updateTarget = async (profileIdValue: unknown) => { - const profileId = requireProfileId(profileIdValue); - const managed = await resolveManagedService(profileId); - const transport = managed.profile.transport; - if (managed.state !== 'active' || transport.kind !== 'ssh') { - throw new Error('This Runtime Host profile is not available for managed updates'); - } - return { - profileId, - managed, - transport, - expectedTarget: { - serviceId: managed.service.id, - rootPath: managed.service.rootPath, - rootId: managed.profile.rootId, - }, - }; - }; - const reconnectUpdatedTarget = async ( profileId: string, managed: Awaited>, @@ -363,7 +352,7 @@ export function createDesktopRuntimeHostManagement(input: { if (policy && policy.kind !== 'manual') { const current = await input.runUpdatePolicy(common); if (current.kind === 'error') throw new Error(current.error.message); - if (!supportsUpdateScheduler(current)) { + if (current.updateSchedulerState === undefined) { throw new Error( 'Update or repair this Runtime Host before enabling automatic updates', ); @@ -575,26 +564,15 @@ export function createDesktopRuntimeHostManagement(input: { }; } -function supportsUpdateScheduler(frame: { - readonly operatorCapabilities?: readonly string[]; -}): boolean { - return frame.operatorCapabilities?.includes( - RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, - ) === true; -} - function projectUpdatePolicy( frame: Extract, ): DesktopRuntimeHostUpdatePolicySnapshot { - if (!supportsUpdateScheduler(frame)) { + if (frame.updateSchedulerState === undefined) { return { ...frame.updatePolicy, schedulingState: 'unsupported' }; } - if (!frame.updateSchedulerState) { - throw new Error('Remote Runtime Host omitted its update scheduler state'); - } return { ...frame.updatePolicy, schedulingState: frame.updateSchedulerState }; } diff --git a/apps/desktop/src/main/runtime-host-setup-package.ts b/apps/desktop/src/main/runtime-host-setup-package.ts index cc29ba81c7..b9468bc560 100644 --- a/apps/desktop/src/main/runtime-host-setup-package.ts +++ b/apps/desktop/src/main/runtime-host-setup-package.ts @@ -17,9 +17,13 @@ * under the License. */ -import { execFile } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { isAbsolute, join, relative, resolve } from 'node:path'; +import { + DEFAULT_PROCESS_TERMINATION_GRACE_MS, + terminateChildProcessTree, +} from '@maka/runtime/process-tree-terminator'; import { isExactRuntimeHostSetupPackageSpecifier, type DesktopRuntimeHostSetupPackage, @@ -27,35 +31,94 @@ import { const DEVELOPMENT_ARCHIVE_ENV = 'MAKA_RUNTIME_HOST_SETUP_ARCHIVE'; +interface DevelopmentArchiveBuild { + readonly result: Promise; + close(): Promise; +} + +export interface RuntimeHostSetupPackageResolver { + resolve(signal?: AbortSignal): Promise; + close(): Promise; +} + export function createRuntimeHostSetupPackageResolver(input: { readonly isPackaged: boolean; readonly appPath: string; readonly environment: NodeJS.ProcessEnv; - readonly buildDevelopmentArchive?: (repoRoot: string) => Promise; -}): (signal?: AbortSignal) => Promise { - let developmentArchive: Promise | undefined; - - return async (signal) => { - if (input.isPackaged) return packagedSetupPackage(input.appPath); - - const override = input.environment[DEVELOPMENT_ARCHIVE_ENV]; - if (override) return { kind: 'development_archive', path: override }; + readonly startDevelopmentArchiveBuild?: (repoRoot: string) => DevelopmentArchiveBuild; +}): RuntimeHostSetupPackageResolver { + let closed = false; + let developmentBuild: + | { + readonly task: DevelopmentArchiveBuild; + readonly result: Promise; + waiters: number; + settled: boolean; + closing?: Promise; + } + | undefined; + const startBuild = () => { const repoRoot = resolve(input.appPath, '..', '..'); - if (!developmentArchive) { - const build = - input.buildDevelopmentArchive ?? - ((root: string) => buildDevelopmentArchive(root, input.environment)); - const pending = build(repoRoot).then((path) => ({ + const task = input.startDevelopmentArchiveBuild?.(repoRoot) ?? + startDevelopmentArchiveBuild(repoRoot, input.environment); + const build = { + task, + result: task.result.then((path) => ({ kind: 'development_archive' as const, path, - })); - developmentArchive = pending; - void pending.catch(() => { - if (developmentArchive === pending) developmentArchive = undefined; - }); - } - return waitForPackage(developmentArchive, signal); + })), + waiters: 0, + settled: false, + }; + developmentBuild = build; + void build.result.then( + () => { + build.settled = true; + }, + () => { + build.settled = true; + if (developmentBuild === build) developmentBuild = undefined; + }, + ); + return build; + }; + + const stopBuild = async (build: NonNullable) => { + if (build.settled) return; + build.closing ??= build.task.close().finally(() => { + if (developmentBuild === build) developmentBuild = undefined; + }); + await build.closing; + await build.result.catch(() => undefined); + }; + + return { + async resolve(signal) { + if (closed) throw new Error('Runtime Host setup package resolver is closed'); + if (input.isPackaged) return packagedSetupPackage(input.appPath); + + const override = input.environment[DEVELOPMENT_ARCHIVE_ENV]; + if (override) return { kind: 'development_archive', path: override }; + + const build = developmentBuild ?? startBuild(); + build.waiters += 1; + try { + return await waitForPackage(build.result, signal); + } finally { + build.waiters -= 1; + if (signal?.aborted && build.waiters === 0 && !build.settled) { + await stopBuild(build); + } + } + }, + async close() { + if (closed) return; + closed = true; + const build = developmentBuild; + developmentBuild = undefined; + if (build) await stopBuild(build); + }, }; } @@ -69,47 +132,80 @@ function packagedSetupPackage(appPath: string): DesktopRuntimeHostSetupPackage { return { kind: 'npm', specifier: manifest.runtimeHostSetupPackage }; } -async function buildDevelopmentArchive( +function startDevelopmentArchiveBuild( repoRoot: string, environment: NodeJS.ProcessEnv, -): Promise { +): DevelopmentArchiveBuild { const script = join(repoRoot, 'scripts', 'release-cli-package.mjs'); const nodeExecutable = environment.npm_node_execpath?.trim() || 'node'; - const stdout = await new Promise((resolveOutput, reject) => { - execFile( - nodeExecutable, - [script, '--development'], - { - cwd: repoRoot, - encoding: 'utf8', - env: environment, - maxBuffer: 64 * 1024 * 1024, - }, - (error, output, stderr) => { - if (!error) { - resolveOutput(output); - return; - } - const detail = (stderr.trim() || error.message).slice(-2_000); - reject(new Error(`Failed to prepare the local Runtime Host CLI: ${detail}`)); - }, - ); + const child = spawn(nodeExecutable, [script, '--development'], { + cwd: repoRoot, + detached: process.platform !== 'win32', + env: environment, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, }); - const archive = Array.from(stdout.matchAll(/^\[release-cli\] tarball: (.+)$/gmu)).at(-1)?.[1]; - if (!archive) throw new Error('The local Runtime Host CLI build did not report an archive'); - const resolvedArchive = resolve(archive.trim()); - const releaseRoot = join(repoRoot, 'packages', 'cli', 'release'); - const relativeArchive = relative(releaseRoot, resolvedArchive); - if ( - !relativeArchive || - relativeArchive.startsWith('..') || - isAbsolute(relativeArchive) || - !resolvedArchive.endsWith('.tgz') || - !existsSync(resolvedArchive) - ) { - throw new Error('The local Runtime Host CLI build returned an invalid archive path'); - } - return resolvedArchive; + let settled = false; + let stdout = ''; + let stderr = ''; + let processError: Error | undefined; + const appendOutput = (current: string, chunk: Buffer): string => { + const next = current + chunk.toString('utf8'); + if (Buffer.byteLength(next, 'utf8') <= 64 * 1024 * 1024) return next; + processError = new Error('Local Runtime Host CLI build output exceeded 64 MiB'); + void terminateChildProcessTree(child, 'SIGKILL'); + return current; + }; + const output = new Promise((resolveOutput, reject) => { + child.stdout?.on('data', (chunk: Buffer) => { + stdout = appendOutput(stdout, chunk); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr = appendOutput(stderr, chunk); + }); + child.once('error', (error) => { + processError = error; + }); + child.once('close', (code, signal) => { + settled = true; + if (code === 0 && !processError) { + resolveOutput(stdout); + return; + } + const detail = ( + stderr.trim() || + processError?.message || + `process exited with ${code === null ? signal ?? 'an unknown status' : `code ${code}`}` + ).slice(-2_000); + reject(new Error(`Failed to prepare the local Runtime Host CLI: ${detail}`)); + }); + }); + const result = output.then((stdout) => { + const archive = Array.from(stdout.matchAll(/^\[release-cli\] tarball: (.+)$/gmu)).at(-1)?.[1]; + if (!archive) throw new Error('The local Runtime Host CLI build did not report an archive'); + const resolvedArchive = resolve(archive.trim()); + const releaseRoot = join(repoRoot, 'packages', 'cli', 'release'); + const relativeArchive = relative(releaseRoot, resolvedArchive); + if ( + !relativeArchive || + relativeArchive.startsWith('..') || + isAbsolute(relativeArchive) || + !resolvedArchive.endsWith('.tgz') || + !existsSync(resolvedArchive) + ) { + throw new Error('The local Runtime Host CLI build returned an invalid archive path'); + } + return resolvedArchive; + }); + let closing: Promise | undefined; + return { + result, + close() { + if (settled) return Promise.resolve(); + closing ??= terminateBuildProcess(child, result); + return closing; + }, + }; } function waitForPackage(promise: Promise, signal?: AbortSignal): Promise { @@ -123,3 +219,29 @@ function waitForPackage(promise: Promise, signal?: AbortSignal): Promise): Promise { + await terminateChildProcessTree(child, 'SIGTERM'); + if (await settlesWithin(result, DEFAULT_PROCESS_TERMINATION_GRACE_MS)) return; + await terminateChildProcessTree(child, 'SIGKILL'); + if (!(await settlesWithin(result, DEFAULT_PROCESS_TERMINATION_GRACE_MS))) { + throw new Error('Local Runtime Host CLI build did not exit after forced termination'); + } +} + +async function settlesWithin(promise: Promise, timeoutMs: number): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise.then( + () => true, + () => true, + ), + new Promise((resolveTimeout) => { + timeout = setTimeout(() => resolveTimeout(false), timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index d9c09fa1e9..a661c3ff6d 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -117,7 +117,7 @@ export interface DesktopRuntimeHostSshUpdatePolicyInput { readonly sshPort?: number; readonly operatorPath: string; readonly policy?: RuntimeHostManagedUpdatePolicy; - readonly expectedTarget?: DesktopRuntimeHostSshManagementInput['expectedTarget']; + readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; readonly signal?: AbortSignal; } @@ -1064,7 +1064,7 @@ function runtimeHostUpdatePolicyRemoteCommand( 'update-policy', '--framed', ...target, - ...(input.expectedTarget ? managedServiceTargetArgs(input.expectedTarget) : []), + ...managedServiceTargetArgs(input.expectedTarget), ].map(quotePosix).join(' '); const invocation = `${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` + diff --git a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts index bc93d6684a..bc9c47b36c 100644 --- a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts @@ -208,10 +208,6 @@ describe('managed Runtime Host update reconciliation', () => { : undefined, 'disabled', ); - assert.deepEqual( - manualFrame?.kind === 'result' ? manualFrame.operatorCapabilities : undefined, - [RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY], - ); assert.equal( manualFrame?.kind === 'result' && manualFrame.action === 'reconcile_update' ? manualFrame.updateSchedulerState diff --git a/packages/cli/src/runtime-host-update-reconciliation.ts b/packages/cli/src/runtime-host-update-reconciliation.ts index 05b4468e85..e77331a6e6 100644 --- a/packages/cli/src/runtime-host-update-reconciliation.ts +++ b/packages/cli/src/runtime-host-update-reconciliation.ts @@ -25,7 +25,6 @@ import { RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES, - type RuntimeHostOperatorCapability, type RuntimeHostManagedUpdatePolicy, type RuntimeHostServiceManagementFrame, type RuntimeHostUpdateSchedulerState, @@ -183,7 +182,7 @@ export async function runManagedRuntimeHostUpdateReconcileCli( schemaVersion: 1, kind: 'result', action: 'reconcile_update', - ...requestedUpdateSchedulerCapability(updateSchedulerState), + ...requestedUpdateSchedulerState(updateSchedulerState), updatePolicy: { policy: { kind: 'manual' } }, reconciliation: { kind: 'disabled' }, }, @@ -206,7 +205,7 @@ export async function runManagedRuntimeHostUpdateReconcileCli( schemaVersion: 1, kind: 'result', action: 'reconcile_update', - ...requestedUpdateSchedulerCapability(updateSchedulerState), + ...requestedUpdateSchedulerState(updateSchedulerState), updatePolicy, service: selection.service, reconciliation: { @@ -347,7 +346,7 @@ function updatePolicyResult( schemaVersion: 1, kind: 'result', action: 'update_policy', - ...requestedUpdateSchedulerCapability(updateSchedulerState), + ...requestedUpdateSchedulerState(updateSchedulerState), updatePolicy: policyResult(record), }; } @@ -381,7 +380,7 @@ function reconcileFrame( schemaVersion: 1, kind: 'result', action: 'reconcile_update', - ...requestedUpdateSchedulerCapability( + ...requestedUpdateSchedulerState( frame.update.kind === 'updated' || frame.update.kind === 'repaired' || frame.update.kind === 'already_current' @@ -394,16 +393,12 @@ function reconcileFrame( }; } -function requestedUpdateSchedulerCapability( - updateSchedulerState: RuntimeHostUpdateSchedulerState, -): { - readonly operatorCapabilities?: RuntimeHostOperatorCapability[]; +function requestedUpdateSchedulerState(updateSchedulerState: RuntimeHostUpdateSchedulerState): { readonly updateSchedulerState?: RuntimeHostUpdateSchedulerState; } { return process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] === RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY ? { - operatorCapabilities: [RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY], updateSchedulerState, } : {}; diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts index 8cee86dbd0..1608ebe0c3 100644 --- a/packages/runtime-host/src/operator/service-management-frame.ts +++ b/packages/runtime-host/src/operator/service-management-frame.ts @@ -259,25 +259,6 @@ const SERVICE_ERROR_SCHEMA = z }) .strict(); -function requireSchedulerStateWithCapability( - frame: { - readonly operatorCapabilities?: readonly RuntimeHostOperatorCapability[]; - readonly updateSchedulerState?: RuntimeHostUpdateSchedulerState; - }, - context: z.RefinementCtx, -): void { - const exposesScheduler = - frame.operatorCapabilities?.includes(RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY) === - true; - if (exposesScheduler !== (frame.updateSchedulerState !== undefined)) { - context.addIssue({ - code: 'custom', - path: ['updateSchedulerState'], - message: 'Update scheduler capability and state must be projected together', - }); - } -} - const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ z .object({ @@ -342,18 +323,15 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ kind: z.literal('result'), action: z.literal('update_policy'), updatePolicy: UPDATE_POLICY_RESULT_SCHEMA, - operatorCapabilities: z.array(z.enum(OPERATOR_CAPABILITIES)).max(16).optional(), updateSchedulerState: z.enum(UPDATE_SCHEDULER_STATES).optional(), }) - .strict() - .superRefine(requireSchedulerStateWithCapability), + .strict(), z .object({ schemaVersion: z.literal(1), kind: z.literal('result'), action: z.literal('reconcile_update'), updatePolicy: UPDATE_POLICY_RESULT_SCHEMA, - operatorCapabilities: z.array(z.enum(OPERATOR_CAPABILITIES)).max(16).optional(), updateSchedulerState: z.enum(UPDATE_SCHEDULER_STATES).optional(), service: SERVICE_SUMMARY_SCHEMA.optional(), reconciliation: z.discriminatedUnion('kind', [ @@ -375,7 +353,6 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ }) .strict() .superRefine((frame, context) => { - requireSchedulerStateWithCapability(frame, context); if ((frame.reconciliation.kind === 'disabled') !== (frame.service === undefined)) { context.addIssue({ code: 'custom', diff --git a/scripts/release-cli-file-policy.mjs b/scripts/release-cli-file-policy.mjs index ca0e584b8d..5f1d58acba 100644 --- a/scripts/release-cli-file-policy.mjs +++ b/scripts/release-cli-file-policy.mjs @@ -17,7 +17,7 @@ * under the License. */ -import { lstatSync, readFileSync, realpathSync } from 'node:fs'; +import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'; import { isAbsolute, join, relative, resolve, sep } from 'node:path'; const DEVELOPMENT_DIRECTORIES = new Set([ @@ -244,3 +244,17 @@ export function isMakaDevelopmentArtifact(relativePath) { /\.js\.map$/.test(file) ); } + +export function isCurrentDevelopmentJavaScript( + workspaceRoot, + relativePath, + generatedFiles = new Set(), +) { + const portablePath = relativePath.split(/[\\/]/u).join('/'); + if (generatedFiles.has(portablePath)) return true; + if (!portablePath.endsWith('.js')) return false; + const sourcePath = portablePath.slice(0, -'.js'.length); + return ['.ts', '.tsx', '.mts', '.cts'].some((extension) => + existsSync(join(workspaceRoot, 'src', `${sourcePath}${extension}`)), + ); +} diff --git a/scripts/release-cli-file-policy.test.mjs b/scripts/release-cli-file-policy.test.mjs index 08db8bf2b2..4f321c86c4 100644 --- a/scripts/release-cli-file-policy.test.mjs +++ b/scripts/release-cli-file-policy.test.mjs @@ -18,12 +18,21 @@ */ import assert from 'node:assert/strict'; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { describe, test } from 'node:test'; import { collectWorkspaceDependencyClosure, + isCurrentDevelopmentJavaScript, isMakaDevelopmentArtifact, isThirdPartyDevelopmentArtifact, orderWorkspaceBuilds, @@ -133,6 +142,26 @@ describe('CLI release file policy', () => { assert.equal(isMakaDevelopmentArtifact('dist/index.js'), false); }); + test('development packages exclude JavaScript left by deleted sources', () => { + const workspace = mkdtempSync(join(tmpdir(), 'maka-development-output-')); + try { + mkdirSync(join(workspace, 'src'), { recursive: true }); + writeFileSync(join(workspace, 'src', 'current.ts'), 'export {}\n'); + assert.equal(isCurrentDevelopmentJavaScript(workspace, 'current.js'), true); + assert.equal(isCurrentDevelopmentJavaScript(workspace, 'deleted.js'), false); + assert.equal( + isCurrentDevelopmentJavaScript( + workspace, + 'workers\\generated.js', + new Set(['workers/generated.js']), + ), + true, + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + test('rejects Maka test-only modules so no test backend can ship', () => { for (const path of [ 'dist/test-only/fake-backend.js', diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index 5340a2ea85..1aba45ea12 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -39,6 +39,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod import { npmSpawnOptions } from './npm-spawn.mjs'; import { validateCliReleaseArtifactMetrics } from './release-cli-artifact-policy.mjs'; import { + isCurrentDevelopmentJavaScript, isMakaDevelopmentArtifact, isThirdPartyDevelopmentArtifact, orderWorkspaceBuilds, @@ -67,6 +68,9 @@ const internalPackageNames = workspacePackages .filter((name) => name !== 'maka-agent'); const internalPackageSet = new Set(internalPackageNames); const buildOrder = orderWorkspaceBuilds(workspacePackages); +const developmentGeneratedFiles = new Map([ + ['@maka/runtime', new Set(['workers/filesystem-worker.js'])], +]); const strippedInstallScripts = new Map([ // The clean repository install has already produced every generated file and // platform prebuild copied below. Do not run advisory postinstalls on an end @@ -380,16 +384,24 @@ function copyInternalPackage(source, destination) { ); writeFileSync(join(destination, 'package.json'), `${JSON.stringify(releaseManifest, null, 2)}\n`); for (const releaseFile of resolveWorkspaceReleaseFiles(source, manifest)) { - if (releaseFile === 'dist') copyRuntimeDist(source, destination); + if (releaseFile === 'dist') copyRuntimeDist(source, destination, manifest.name); else copyDeclaredFile(source, destination, releaseFile); } } -function copyRuntimeDist(source, destination) { +function copyRuntimeDist(source, destination, packageName = 'maka-agent') { const sourceDist = join(source, 'dist'); if (!existsSync(sourceDist)) throw new Error(`Missing build output: ${sourceDist}`); copyTreeFiles(sourceDist, join(destination, 'dist'), (relativePath) => { - return !isMakaDevelopmentArtifact(join('dist', relativePath)); + if (isMakaDevelopmentArtifact(join('dist', relativePath))) return false; + return ( + !developmentBuild || + isCurrentDevelopmentJavaScript( + source, + relativePath, + developmentGeneratedFiles.get(packageName), + ) + ); }); } From ca70545a12fac825cc8d662fa44a1c9ebdbaae88 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 17:19:55 +0800 Subject: [PATCH 3/5] fix(desktop): keep Runtime Host update state coherent Apply reconciliation service snapshots atomically, avoid redundant SSH reads after failed status, and fail closed when policy state becomes uncertain. Generated-by: Codex --- .../__tests__/runtime-host-management.test.ts | 4 + .../src/main/runtime-host-management.ts | 1 + apps/desktop/src/preload/bridge-contract.d.ts | 1 + .../runtime-host-management-dialog.tsx | 99 ++++++++++--------- 4 files changed, 58 insertions(+), 47 deletions(-) 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 54dc00529c..a7ca78abf1 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -579,6 +579,10 @@ test('manages one Host update policy and reconciles it through the bound operato (response as { updatePolicy?: { schedulingState: string } }).updatePolicy?.schedulingState, 'ready', ); + assert.deepEqual( + (response as { service?: unknown }).service, + serviceSummary('1.3.0'), + ); assert.deepEqual(reconciliationInputs, [{ destination: profile.transport.destination, operatorPath: service.operatorPath, diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index b215036463..be6e26154b 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -405,6 +405,7 @@ export function createDesktopRuntimeHostManagement(input: { kind: 'result', updatePolicy: projectUpdatePolicy(response), reconciliation: response.reconciliation, + ...(response.service ? { service: response.service } : {}), } : { kind: 'error', error: response.error }; }; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 03229ae7ab..9740acb984 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -484,6 +484,7 @@ export type DesktopRuntimeHostUpdateReconciliationResponse = readonly kind: 'result'; readonly updatePolicy: DesktopRuntimeHostUpdatePolicySnapshot; readonly reconciliation: DesktopRuntimeHostUpdateReconciliationOutcome; + readonly service?: NonNullable; }; export interface DesktopRuntimeHostAccessCredential { 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 9121510e74..cc401f6c13 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx @@ -76,7 +76,7 @@ export function RuntimeHostManagementDialog(props: { const [updatePolicyChoice, setUpdatePolicyChoice] = useState('manual'); const [fixedVersion, setFixedVersion] = useState(''); const [updatePolicyError, setUpdatePolicyError] = useState(); - const [reconciliation, setReconciliation] = + const [lastUpdateOutcome, setLastUpdateOutcome] = useState(); const logsRef = useRef(null); @@ -94,23 +94,32 @@ export function RuntimeHostManagementDialog(props: { setUpdatePolicyChoice('manual'); setFixedVersion(''); setUpdatePolicyError(undefined); - setReconciliation(undefined); + setLastUpdateOutcome(undefined); setLoading(true); void (async () => { + let shouldLoadUpdatePolicy = false; try { const response = await window.maka.runtimeHostManagement.run(profile.id, 'status'); if (disposed) return; - if (response.kind === 'result') setResult(response); + if (response.kind === 'result') { + setResult(response); + shouldLoadUpdatePolicy = response.service.state !== 'not_installed'; + } else if (response.kind === 'error') setError(response.error.message); else setUninstalledRoot(response.retainedStateRoot); } catch (failure) { if (!disposed) setError(settingsActionErrorMessage(failure, locale)); } - try { - const policy = await window.maka.runtimeHostManagement.getUpdatePolicy(profile.id); - if (!disposed) applyUpdatePolicy(policy); - } catch (failure) { - if (!disposed) setUpdatePolicyError(settingsActionErrorMessage(failure, locale)); + if (shouldLoadUpdatePolicy) { + try { + const policy = await window.maka.runtimeHostManagement.getUpdatePolicy(profile.id); + if (!disposed) applyUpdatePolicy(policy); + } catch (failure) { + if (!disposed) { + setUpdatePolicy(undefined); + setUpdatePolicyError(settingsActionErrorMessage(failure, locale)); + } + } } if (!disposed) setLoading(false); })(); @@ -133,22 +142,27 @@ export function RuntimeHostManagementDialog(props: { if (!profile) return; setLoading(true); setError(undefined); + setLastUpdateOutcome(undefined); try { const response = await window.maka.runtimeHostManagement.run(profile.id, action); if (response.kind === 'error') { + setUpdatePolicy(undefined); setError(response.error.message); toast.error(copy.managementActionFailed, response.error.message); return; } if (response.kind === 'uninstalled') { setResult(undefined); + setUpdatePolicy(undefined); setUninstalledRoot(response.retainedStateRoot); return; } setResult(response); - if (action !== 'logs') await reloadUpdatePolicy(profile.id); + if (response.service.state === 'not_installed') setUpdatePolicy(undefined); + else if (action !== 'logs') await reloadUpdatePolicy(profile.id); } catch (failure) { const message = settingsActionErrorMessage(failure, locale); + setUpdatePolicy(undefined); setError(message); toast.error(copy.managementActionFailed, message); } finally { @@ -176,12 +190,14 @@ export function RuntimeHostManagementDialog(props: { setLoading(true); setError(undefined); setUpdatePhase('checking'); + setLastUpdateOutcome(undefined); try { const response = await window.maka.runtimeHostManagement.update( profile.id, allowInterruptActiveTasks, ); if (response.kind === 'error') { + setUpdatePolicy(undefined); setError(response.error.message); toast.error(copy.managementActionFailed, response.error.message); return; @@ -190,6 +206,7 @@ export function RuntimeHostManagementDialog(props: { throw new Error('Runtime Host update returned an uninstall result'); } setResult(response); + if (response.action === 'update') setLastUpdateOutcome(response.update); setConfirmation( response.action === 'update' && response.update.kind === 'active_tasks' ? { kind: 'update' } @@ -200,6 +217,7 @@ export function RuntimeHostManagementDialog(props: { } } catch (failure) { const message = settingsActionErrorMessage(failure, locale); + setUpdatePolicy(undefined); setError(message); toast.error(copy.managementActionFailed, message); } finally { @@ -226,6 +244,7 @@ export function RuntimeHostManagementDialog(props: { try { applyUpdatePolicy(await window.maka.runtimeHostManagement.getUpdatePolicy(profileId)); } catch (failure) { + setUpdatePolicy(undefined); setUpdatePolicyError(settingsActionErrorMessage(failure, locale)); } } @@ -235,6 +254,7 @@ export function RuntimeHostManagementDialog(props: { setLoading(true); setError(undefined); setUpdatePolicyError(undefined); + setLastUpdateOutcome(undefined); try { const policy = updatePolicyChoice === 'manual' ? { kind: 'manual' as const } @@ -244,9 +264,9 @@ export function RuntimeHostManagementDialog(props: { applyUpdatePolicy( await window.maka.runtimeHostManagement.setUpdatePolicy(profile.id, policy), ); - setReconciliation(undefined); } catch (failure) { const message = settingsActionErrorMessage(failure, locale); + setUpdatePolicy(undefined); setUpdatePolicyError(message); toast.error(copy.managementActionFailed, message); } finally { @@ -260,24 +280,24 @@ export function RuntimeHostManagementDialog(props: { setError(undefined); setUpdatePolicyError(undefined); setUpdatePhase('checking'); + setLastUpdateOutcome(undefined); try { const response = await window.maka.runtimeHostManagement.reconcileUpdate(profile.id); if (response.kind === 'error') { + setUpdatePolicy(undefined); setUpdatePolicyError(response.error.message); toast.error(copy.managementActionFailed, response.error.message); return; } - setReconciliation(response.reconciliation); + setLastUpdateOutcome(response.reconciliation); applyUpdatePolicy(response.updatePolicy); - if ( - response.reconciliation.kind === 'updated' || - response.reconciliation.kind === 'repaired' - ) { - const status = await window.maka.runtimeHostManagement.run(profile.id, 'status'); - if (status.kind === 'result') setResult(status); + const reconciledService = response.service; + if (reconciledService) { + setResult((current) => current ? { ...current, service: reconciledService } : current); } } catch (failure) { const message = settingsActionErrorMessage(failure, locale); + setUpdatePolicy(undefined); setUpdatePolicyError(message); toast.error(copy.managementActionFailed, message); } finally { @@ -336,7 +356,7 @@ export function RuntimeHostManagementDialog(props: { updatePolicy.policy.version !== fixedVersion.trim()); const automaticPolicySelected = updatePolicyChoice !== 'manual'; const automaticUpdatesAvailable = updatePolicy?.schedulingState === 'ready'; - const reconciliationResult = reconciliation; + const updateOutcome = lastUpdateOutcome; return ( ) : null} - {result?.action === 'update' && result.update.kind === 'updated' ? ( + {updateOutcome?.kind === 'updated' ? ( ) : null} - {result?.action === 'update' && result.update.kind === 'already_current' ? ( + {updateOutcome?.kind === 'already_current' ? ( ) : null} - {result?.action === 'update' && result.update.kind === 'repaired' ? ( - + {updateOutcome?.kind === 'repaired' ? ( + ) : null} - {reconciliationResult?.kind === 'disabled' ? ( + {updateOutcome?.kind === 'disabled' ? ( ) : null} - {reconciliationResult?.kind === 'manual_action' ? ( + {updateOutcome?.kind === 'manual_action' ? ( ) : null} - {reconciliationResult?.kind === 'active_tasks' ? ( + {updateOutcome?.kind === 'active_tasks' && confirmation?.kind !== 'update' ? ( ) : null} - {reconciliationResult?.kind === 'already_current' ? ( - - ) : null} - {reconciliationResult?.kind === 'repaired' ? ( - - ) : null} - {reconciliationResult?.kind === 'updated' ? ( - - ) : null} {!access && service ? ( <>
From 6cafd6fac778d82141bd1d118869862db5ef0fa3 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 17:45:53 +0800 Subject: [PATCH 4/5] fix(desktop): contain development Runtime Host setup Terminate interactive SSH process trees through the shared platform abstraction.\n\nBuild transient CLI archives outside formal release outputs and clean them with the Desktop resolver.\n\nGenerated-by: OpenAI Codex --- .gitignore | 1 + .../runtime-host-setup-package.test.ts | 9 +++- .../runtime-host-ssh-terminal.test.ts | 11 ++++ .../src/main/runtime-host-setup-package.ts | 21 +++++--- .../src/main/runtime-host-ssh-terminal.ts | 51 +++++++++++++++---- scripts/release-cli-package.mjs | 38 +++++++++++--- 6 files changed, 106 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 0f6f738912..76bf8f1b71 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,5 @@ apps/desktop/resources/tools/ apps/desktop/release/ apps/desktop/release-sources/ packages/cli/release/ +packages/cli/.development/ release/asf/ diff --git a/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts b/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts index 4fb020ae8e..c2e046d583 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts @@ -27,6 +27,7 @@ test('development setup lazily builds one local CLI archive unless explicitly ov const repoRoot = resolve('/workspace'); const archive = join(repoRoot, 'packages', 'cli', 'release', 'maka-agent-dev.tgz'); let builds = 0; + let closes = 0; const resolvePackage = createRuntimeHostSetupPackageResolver({ isPackaged: false, appPath: join(repoRoot, 'apps', 'desktop'), @@ -34,7 +35,12 @@ test('development setup lazily builds one local CLI archive unless explicitly ov startDevelopmentArchiveBuild: (resolvedRoot) => { builds += 1; assert.equal(resolvedRoot, repoRoot); - return { result: Promise.resolve(archive), close: async () => undefined }; + return { + result: Promise.resolve(archive), + close: async () => { + closes += 1; + }, + }; }, }); @@ -62,6 +68,7 @@ test('development setup lazily builds one local CLI archive unless explicitly ov path: override, }); await Promise.all([resolvePackage.close(), resolveOverride.close()]); + assert.equal(closes, 1); }); test('cancelling the last setup-package waiter stops its shared build', async () => { 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 a1b0a9ca3e..68a512d17b 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 @@ -218,6 +218,10 @@ test('force-stops a cancelled setup when SSH ignores graceful termination', asyn await assert.rejects(setup, /aborted/u); assert.deepEqual(harness.pty.killSignals, ['SIGTERM', 'SIGKILL']); + assert.deepEqual(harness.terminatedProcesses, [ + { pid: 42, signal: 'SIGTERM' }, + { pid: 42, signal: 'SIGKILL' }, + ]); assert.deepEqual(harness.eventKinds(), ['opened', 'data', 'dismissed']); assert.deepEqual(await harness.getSnapshot(), { kind: 'idle', revision: 3 }); await harness.terminal.close(); @@ -646,6 +650,7 @@ function createHarness( ) { const handlers = new Map unknown>(); const events: Array<{ kind: string }> = []; + const terminatedProcesses: Array<{ pid: number; signal: string }> = []; const pty = new FakePty(); const launchArgs: string[][] = []; let releaseTunnel!: () => void; @@ -666,6 +671,11 @@ function createHarness( revealDelayMs: 0, ...options, processStopGraceMs: 1, + terminateProcessTree: async ({ pid, signal, fallback }) => { + terminatedProcesses.push({ pid, signal }); + fallback?.(); + return true; + }, openSshTunnel: async (input, overrides) => { const spawnProcess = overrides?.spawnProcess as RuntimeHostSshProcessFactory; const process = spawnProcess({ executable: 'ssh', args: [], interaction: input.interaction }); @@ -690,6 +700,7 @@ function createHarness( releaseTunnel, eventKinds: () => events.map(({ kind }) => kind), events, + terminatedProcesses, getSnapshot: () => invoke('runtime-host-ssh-terminal:getSnapshot'), cancel: (sessionId: string) => invoke('runtime-host-ssh-terminal:cancel', sessionId), }; diff --git a/apps/desktop/src/main/runtime-host-setup-package.ts b/apps/desktop/src/main/runtime-host-setup-package.ts index b9468bc560..bb98dabeec 100644 --- a/apps/desktop/src/main/runtime-host-setup-package.ts +++ b/apps/desktop/src/main/runtime-host-setup-package.ts @@ -18,7 +18,7 @@ */ import { spawn, type ChildProcess } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { isAbsolute, join, relative, resolve } from 'node:path'; import { DEFAULT_PROCESS_TERMINATION_GRACE_MS, @@ -85,7 +85,6 @@ export function createRuntimeHostSetupPackageResolver(input: { }; const stopBuild = async (build: NonNullable) => { - if (build.settled) return; build.closing ??= build.task.close().finally(() => { if (developmentBuild === build) developmentBuild = undefined; }); @@ -138,10 +137,13 @@ function startDevelopmentArchiveBuild( ): DevelopmentArchiveBuild { const script = join(repoRoot, 'scripts', 'release-cli-package.mjs'); const nodeExecutable = environment.npm_node_execpath?.trim() || 'node'; + const outputBase = join(repoRoot, 'packages', 'cli', '.development'); + mkdirSync(outputBase, { recursive: true, mode: 0o755 }); + const outputRoot = mkdtempSync(join(outputBase, 'desktop-')); const child = spawn(nodeExecutable, [script, '--development'], { cwd: repoRoot, detached: process.platform !== 'win32', - env: environment, + env: { ...environment, MAKA_CLI_DEVELOPMENT_OUTPUT_ROOT: outputRoot }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); @@ -184,8 +186,7 @@ function startDevelopmentArchiveBuild( const archive = Array.from(stdout.matchAll(/^\[release-cli\] tarball: (.+)$/gmu)).at(-1)?.[1]; if (!archive) throw new Error('The local Runtime Host CLI build did not report an archive'); const resolvedArchive = resolve(archive.trim()); - const releaseRoot = join(repoRoot, 'packages', 'cli', 'release'); - const relativeArchive = relative(releaseRoot, resolvedArchive); + const relativeArchive = relative(outputRoot, resolvedArchive); if ( !relativeArchive || relativeArchive.startsWith('..') || @@ -197,12 +198,18 @@ function startDevelopmentArchiveBuild( } return resolvedArchive; }); + void result.catch(() => rmSync(outputRoot, { recursive: true, force: true })); let closing: Promise | undefined; return { result, close() { - if (settled) return Promise.resolve(); - closing ??= terminateBuildProcess(child, result); + closing ??= (async () => { + try { + if (!settled) await terminateBuildProcess(child, result); + } finally { + rmSync(outputRoot, { recursive: true, force: true }); + } + })(); return closing; }, }; diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index a661c3ff6d..92f852da8a 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -24,6 +24,7 @@ import { posix as pathPosix } from 'node:path'; import type { IpcMain } from 'electron'; import type { IPty } from 'node-pty'; import { spawn as spawnPty } from 'node-pty'; +import { terminateProcessTree } from '@maka/runtime/process-tree-terminator'; import { normalizeRuntimeHostSshDestination, openRuntimeHostSshTunnel, @@ -191,6 +192,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { readonly revealDelayMs?: number; readonly managementTimeoutMs?: number; readonly processStopGraceMs?: number; + readonly terminateProcessTree?: typeof terminateProcessTree; }): { openSshTunnel(input: RuntimeHostSshTunnelInput): Promise; runSetup( @@ -430,7 +432,11 @@ export function createDesktopRuntimeHostSshTerminal(input: { return; } dismissPresentation(terminal); - await terminateActiveTerminal(terminal, input.processStopGraceMs); + await terminateActiveTerminal( + terminal, + input.processStopGraceMs, + input.terminateProcessTree, + ); }); const runFramedManagement = async (options: { @@ -498,7 +504,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { timeoutMs: options.timeoutMs ?? input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS, stopGraceMs: input.processStopGraceMs, onAbort: () => dismissPresentation(terminal), - }); + }, input.terminateProcessTree); filter.finish(); if (failure) throw failure; if (!frame) { @@ -545,6 +551,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { cancellation.signal, input.processStopGraceMs, dismissPresentation, + input.terminateProcessTree, ); const remoteCommand = runtimeHostSetupRemoteCommand(setupPackage, setupInput); let complete: RuntimeHostSetupCompleteFrame | undefined; @@ -580,7 +587,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { timeoutMs: SETUP_TIMEOUT_MS, stopGraceMs: input.processStopGraceMs, onAbort: () => dismissPresentation(terminal), - }); + }, input.terminateProcessTree); filter.finish(); if (setupFailure) throw setupFailure; if (!complete) { @@ -632,6 +639,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { updateInput.signal, input.processStopGraceMs, dismissPresentation, + input.terminateProcessTree, ); const frame = await runFramedManagement({ ...updateInput, @@ -724,7 +732,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { timeoutMs: input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS, stopGraceMs: input.processStopGraceMs, onAbort: () => dismissPresentation(terminal), - }); + }, input.terminateProcessTree); if (wait.timedOut) { throw new Error('Remote Runtime Host deployment cleanup timed out'); } @@ -744,7 +752,11 @@ export function createDesktopRuntimeHostSshTerminal(input: { presentation = undefined; terminal.dismissed = true; if (terminal.revealTimer !== undefined) clearTimeout(terminal.revealTimer); - await terminateActiveTerminal(terminal, input.processStopGraceMs).catch(() => undefined); + await terminateActiveTerminal( + terminal, + input.processStopGraceMs, + input.terminateProcessTree, + ).catch(() => undefined); }, }; } @@ -870,6 +882,7 @@ async function prepareSetupPackage( signal: AbortSignal | undefined, stopGraceMs: number | undefined, dismissPresentation: (terminal: ActiveTerminal) => void, + terminateTree: typeof terminateProcessTree | undefined, ): Promise { if (setupPackage.kind === 'npm') { if (!isExactRuntimeHostSetupPackageSpecifier(setupPackage.specifier)) { @@ -903,7 +916,7 @@ async function prepareSetupPackage( timeoutMs: SETUP_TIMEOUT_MS, stopGraceMs, onAbort: () => dismissPresentation(terminal), - }); + }, terminateTree); if (wait.timedOut) { throw new Error('Uploading the Runtime Host development package timed out'); } @@ -929,6 +942,7 @@ async function waitForTerminalProcess( readonly stopGraceMs?: number; readonly onAbort?: () => void; }, + terminateTree: typeof terminateProcessTree = terminateProcessTree, ): Promise<{ readonly exit: Awaited; readonly timedOut: boolean; @@ -951,7 +965,7 @@ async function waitForTerminalProcess( process.exited, stopRequested.then(async (reason) => { if (reason === 'aborted') input.onAbort?.(); - await terminateTerminalProcess(process, input.stopGraceMs); + await terminateTerminalProcess(process, input.stopGraceMs, terminateTree); return process.exited; }), ]); @@ -964,16 +978,30 @@ async function waitForTerminalProcess( } async function terminateTerminalProcess( - process: Pick, + process: Pick, graceMs = PROCESS_STOP_GRACE_MS, + terminateTree: typeof terminateProcessTree = terminateProcessTree, ): Promise { - process.kill('SIGTERM'); + await signalTerminalProcess(process, 'SIGTERM', terminateTree); if (await settlesWithin(process.exited, graceMs)) return; - process.kill('SIGKILL'); + await signalTerminalProcess(process, 'SIGKILL', terminateTree); if (await settlesWithin(process.exited, graceMs)) return; throw new Error('SSH process did not exit after forced termination'); } +async function signalTerminalProcess( + process: Pick, + signal: 'SIGTERM' | 'SIGKILL', + terminateTree: typeof terminateProcessTree, +): Promise { + const fallback = () => process.kill(signal); + if (process.pid === undefined) { + fallback(); + return; + } + await terminateTree({ pid: process.pid, signal, fallback }); +} + async function settlesWithin(promise: Promise, timeoutMs: number): Promise { let timeout: ReturnType | undefined; try { @@ -1217,9 +1245,11 @@ function findActive( function terminateActiveTerminal( terminal: ActiveTerminal, graceMs: number | undefined, + terminateTree: typeof terminateProcessTree | undefined, ): Promise { return terminateTerminalProcess( { + pid: terminal.pty.pid, exited: terminal.exited.then(() => ({ code: null, signal: null })), kill: (signal) => { try { @@ -1230,6 +1260,7 @@ function terminateActiveTerminal( }, }, graceMs, + terminateTree, ); } diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index 1aba45ea12..56302bfd23 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -51,11 +51,12 @@ import { const repoRoot = resolve(import.meta.dirname, '..'); const cliSource = join(repoRoot, 'packages/cli'); -const releaseRoot = join(cliSource, 'release'); -const stageRoot = join(releaseRoot, 'package'); const allowDirty = process.argv.includes('--allow-dirty'); const developmentBuild = process.argv.includes('--development'); const preparedTree = process.env.MAKA_CLI_RELEASE_PREPARED_TREE === '1'; +const releaseRoot = join(cliSource, 'release'); +const artifactRoot = developmentBuild ? createDevelopmentArtifactRoot() : releaseRoot; +const stageRoot = join(artifactRoot, 'package'); const unsupportedArguments = process.argv .slice(2) .filter((argument) => !['--allow-dirty', '--development'].includes(argument)); @@ -79,7 +80,12 @@ const strippedInstallScripts = new Map([ ['protobufjs@7.6.5', new Set(['postinstall'])], ]); -main(); +try { + main(); +} catch (error) { + if (developmentBuild) rmSync(artifactRoot, { recursive: true, force: true }); + throw error; +} function main() { validateToolchain(); @@ -120,7 +126,7 @@ function packageCli(publishable) { const cli = dependencyTree.dependencies?.['maka-agent']; if (!cli) throw new Error('npm ls did not return the maka-agent workspace'); - rmSync(releaseRoot, { recursive: true, force: true }); + rmSync(artifactRoot, { recursive: true, force: true }); mkdirSync(stageRoot, { recursive: true, mode: 0o755 }); copyCliRuntime(); const expectedDependencyManifests = copyDependencyClosure(cli); @@ -130,7 +136,7 @@ function packageCli(publishable) { validateStaging(); const [pack] = JSON.parse( - runNpm(['pack', stageRoot, '--json', '--pack-destination', releaseRoot], { + runNpm(['pack', stageRoot, '--json', '--pack-destination', artifactRoot], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, }), @@ -143,12 +149,12 @@ function packageCli(publishable) { unpackedBytes: pack.unpackedSize, entryCount: pack.entryCount, }); - const tarballPath = join(releaseRoot, pack.filename); + const tarballPath = join(artifactRoot, pack.filename); validatePackedFiles(pack.files, expectedDependencyManifests); const sha256 = digestFile(tarballPath); writeFileSync(`${tarballPath}.sha256`, `${sha256} ${pack.filename}\n`, 'utf8'); writeFileSync( - join(releaseRoot, `${pack.filename}.files.json`), + join(artifactRoot, `${pack.filename}.files.json`), `${JSON.stringify(pack.files, null, 2)}\n`, 'utf8', ); @@ -160,6 +166,24 @@ function packageCli(publishable) { ); } +function createDevelopmentArtifactRoot() { + const developmentRoot = join(cliSource, '.development'); + mkdirSync(developmentRoot, { recursive: true, mode: 0o755 }); + const parent = process.env.MAKA_CLI_DEVELOPMENT_OUTPUT_ROOT?.trim(); + if (!parent) return mkdtempSync(join(developmentRoot, 'artifact-')); + const resolvedParent = realpathSync(parent); + const relativeParent = relative(realpathSync(developmentRoot), resolvedParent); + if ( + !relativeParent || + relativeParent.startsWith('..') || + isAbsolute(relativeParent) || + !statSync(resolvedParent).isDirectory() + ) { + throw new Error('The CLI development output root must be a directory'); + } + return join(resolvedParent, 'artifact'); +} + function buildFromCleanDependencyTree() { const temporaryRoot = mkdtempSync(join(tmpdir(), 'maka-cli-release-build-')); const archivePath = join(temporaryRoot, 'source.tar'); From f2ca7082c63451c666edcdc48afe7dc1da744ef2 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 18:04:46 +0800 Subject: [PATCH 5/5] fix(desktop): close Runtime Host setup races Keep closing development builds as serialization barriers without letting new callers join their doomed result.\n\nFence process-tree termination with the PTY exit identity before acting on its PID.\n\nGenerated-by: OpenAI Codex --- .../runtime-host-setup-package.test.ts | 52 ++++++++++++++----- .../runtime-host-ssh-terminal.test.ts | 27 +++++++++- .../src/main/runtime-host-setup-package.ts | 12 ++++- .../src/main/runtime-host-ssh-terminal.ts | 31 ++++++++--- 4 files changed, 101 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts b/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts index c2e046d583..c5e9ecec46 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts @@ -71,28 +71,56 @@ test('development setup lazily builds one local CLI archive unless explicitly ov assert.equal(closes, 1); }); -test('cancelling the last setup-package waiter stops its shared build', async () => { +test('cancelling the last waiter closes its build before a new setup starts', async () => { const cancelled = new AbortController(); + let builds = 0; let rejectBuild!: (error: Error) => void; + let releaseClose!: () => void; + let signalClose!: () => void; let closes = 0; + const closeStarted = new Promise((resolveClose) => { + signalClose = resolveClose; + }); + const closeBarrier = new Promise((resolveClose) => { + releaseClose = resolveClose; + }); const resolver = createRuntimeHostSetupPackageResolver({ isPackaged: false, appPath: '/workspace/apps/desktop', environment: {}, - startDevelopmentArchiveBuild: () => ({ - result: new Promise((_resolve, reject) => { - rejectBuild = reject; - }), - close: async () => { - closes += 1; - rejectBuild(new Error('build stopped')); - }, - }), + startDevelopmentArchiveBuild: () => { + builds += 1; + if (builds > 1) { + return { result: Promise.resolve('/workspace/fresh.tgz'), close: async () => undefined }; + } + return { + result: new Promise((_resolve, reject) => { + rejectBuild = reject; + }), + close: async () => { + closes += 1; + signalClose(); + await closeBarrier; + rejectBuild(new Error('build stopped')); + }, + }; + }, }); - const pending = resolver.resolve(cancelled.signal); + const first = resolver.resolve(cancelled.signal); cancelled.abort(new Error('setup cancelled')); - await assert.rejects(pending, /setup cancelled/u); + await closeStarted; + const second = resolver.resolve(); + await Promise.resolve(); + assert.equal(builds, 1); + + releaseClose(); + await assert.rejects(first, /setup cancelled/u); + assert.deepEqual(await second, { + kind: 'development_archive', + path: '/workspace/fresh.tgz', + }); + assert.equal(builds, 2); assert.equal(closes, 1); await resolver.close(); }); 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 68a512d17b..644cf8014d 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 @@ -227,6 +227,29 @@ test('force-stops a cancelled setup when SSH ignores graceful termination', asyn await harness.terminal.close(); }); +test('does not signal a reused process identity when cancellation races SSH exit', async () => { + const harness = createHarness('pending'); + const controller = new AbortController(); + const setup = harness.terminal.runSetup( + { + destination: 'operator@example.com', + setupPackage: { kind: 'npm', specifier: 'maka-agent@1.2.3' }, + principalId: 'desktop:stable-client', + signal: controller.signal, + }, + () => undefined, + ); + await waitFor(() => harness.pty.hasDataListener()); + + controller.abort(); + harness.pty.exit(0); + + await assert.rejects(setup, /aborted/u); + await Promise.resolve(); + assert.deepEqual(harness.pty.killSignals, []); + await harness.terminal.close(); +}); + test('reads a framed service result without projecting it into the SSH terminal', async () => { const harness = createHarness('pending'); const management = harness.terminal.runServiceManagement({ @@ -671,8 +694,10 @@ function createHarness( revealDelayMs: 0, ...options, processStopGraceMs: 1, - terminateProcessTree: async ({ pid, signal, fallback }) => { + terminateProcessTree: async ({ pid, signal, fallback, hasExited, beforeSignal }) => { terminatedProcesses.push({ pid, signal }); + await Promise.resolve(); + if (hasExited?.() || beforeSignal?.() === false) return false; fallback?.(); return true; }, diff --git a/apps/desktop/src/main/runtime-host-setup-package.ts b/apps/desktop/src/main/runtime-host-setup-package.ts index bb98dabeec..7a9ca7f10c 100644 --- a/apps/desktop/src/main/runtime-host-setup-package.ts +++ b/apps/desktop/src/main/runtime-host-setup-package.ts @@ -92,6 +92,16 @@ export function createRuntimeHostSetupPackageResolver(input: { await build.result.catch(() => undefined); }; + const acquireBuild = async (signal?: AbortSignal) => { + while (true) { + if (closed) throw new Error('Runtime Host setup package resolver is closed'); + const build = developmentBuild; + if (!build) return startBuild(); + if (!build.closing) return build; + await waitForPackage(build.closing, signal); + } + }; + return { async resolve(signal) { if (closed) throw new Error('Runtime Host setup package resolver is closed'); @@ -100,7 +110,7 @@ export function createRuntimeHostSetupPackageResolver(input: { const override = input.environment[DEVELOPMENT_ARCHIVE_ENV]; if (override) return { kind: 'development_archive', path: override }; - const build = developmentBuild ?? startBuild(); + const build = await acquireBuild(signal); build.waiters += 1; try { return await waitForPackage(build.result, signal); diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 92f852da8a..a186f26374 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -59,6 +59,7 @@ interface ActiveTerminal { readonly sessionId: string; readonly pty: IPty; readonly exited: Promise; + readonly hasExited: () => boolean; revealTimer: ReturnType | undefined; phase: 'connecting' | 'connected'; revealed: boolean; @@ -67,6 +68,10 @@ interface ActiveTerminal { output: string; } +type DesktopRuntimeHostSshProcess = RuntimeHostSshProcess & { + readonly hasExited: () => boolean; +}; + const TERMINAL_REVEAL_DELAY_MS = 500; const TERMINAL_OUTPUT_MAX = 64 * 1024; const SETUP_FRAME_PENDING_MAX = 20 * 1024; @@ -279,7 +284,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { args: readonly string[], transformOutput: (data: string) => string = (data) => data, successfulExitCompletes = false, - ): { readonly process: RuntimeHostSshProcess; readonly terminal: ActiveTerminal } => { + ): { readonly process: DesktopRuntimeHostSshProcess; readonly terminal: ActiveTerminal } => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); if (active) throw new Error('Another Runtime Host SSH terminal is already active'); const sessionId = randomUUID(); @@ -300,10 +305,13 @@ export function createDesktopRuntimeHostSshTerminal(input: { }>((resolve) => { resolveExit = resolve; }); + let processExited = false; + const hasExited = () => processExited; const terminal: ActiveTerminal = { sessionId, pty, exited: exited.then(() => undefined), + hasExited, revealTimer: undefined, phase: 'connecting', revealed: false, @@ -346,6 +354,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { }); }); pty.onExit(({ exitCode, signal }) => { + processExited = true; if (terminal.revealTimer !== undefined) clearTimeout(terminal.revealTimer); if (successfulExitCompletes && exitCode === 0) completePresentation(terminal); if (active === terminal) active = undefined; @@ -379,7 +388,8 @@ export function createDesktopRuntimeHostSshTerminal(input: { // The exit event is the authority; a concurrent exit makes kill a no-op. } }, - } satisfies RuntimeHostSshProcess; + hasExited, + } satisfies DesktopRuntimeHostSshProcess; return { process, terminal }; }; const spawnProcess: RuntimeHostSshProcessFactory = ({ executable, args, interaction }) => { @@ -878,7 +888,7 @@ async function prepareSetupPackage( args: readonly string[], transformOutput?: (data: string) => string, successfulExitCompletes?: boolean, - ) => { readonly process: RuntimeHostSshProcess; readonly terminal: ActiveTerminal }, + ) => { readonly process: DesktopRuntimeHostSshProcess; readonly terminal: ActiveTerminal }, signal: AbortSignal | undefined, stopGraceMs: number | undefined, dismissPresentation: (terminal: ActiveTerminal) => void, @@ -935,7 +945,7 @@ function remoteDevelopmentArchivePath(principalId: string): string { } async function waitForTerminalProcess( - process: RuntimeHostSshProcess, + process: DesktopRuntimeHostSshProcess, input: { readonly signal?: AbortSignal; readonly timeoutMs: number; @@ -978,7 +988,7 @@ async function waitForTerminalProcess( } async function terminateTerminalProcess( - process: Pick, + process: Pick, graceMs = PROCESS_STOP_GRACE_MS, terminateTree: typeof terminateProcessTree = terminateProcessTree, ): Promise { @@ -990,7 +1000,7 @@ async function terminateTerminalProcess( } async function signalTerminalProcess( - process: Pick, + process: Pick, signal: 'SIGTERM' | 'SIGKILL', terminateTree: typeof terminateProcessTree, ): Promise { @@ -999,7 +1009,13 @@ async function signalTerminalProcess( fallback(); return; } - await terminateTree({ pid: process.pid, signal, fallback }); + await terminateTree({ + pid: process.pid, + signal, + fallback, + hasExited: process.hasExited, + beforeSignal: () => !process.hasExited(), + }); } async function settlesWithin(promise: Promise, timeoutMs: number): Promise { @@ -1251,6 +1267,7 @@ function terminateActiveTerminal( { pid: terminal.pty.pid, exited: terminal.exited.then(() => ({ code: null, signal: null })), + hasExited: terminal.hasExited, kill: (signal) => { try { terminal.pty.kill(signal);