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-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts index cfe37cda97..a7ca78abf1 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -29,6 +29,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 +392,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 +419,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 +451,151 @@ 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', + 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', + 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( + (response as { service?: unknown }).service, + serviceSummary('1.3.0'), + ); + 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 +756,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..c5e9ecec46 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts @@ -0,0 +1,126 @@ +/* + * 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; + let closes = 0; + const resolvePackage = createRuntimeHostSetupPackageResolver({ + isPackaged: false, + appPath: join(repoRoot, 'apps', 'desktop'), + environment: {}, + startDevelopmentArchiveBuild: (resolvedRoot) => { + builds += 1; + assert.equal(resolvedRoot, repoRoot); + return { + result: Promise.resolve(archive), + close: async () => { + closes += 1; + }, + }; + }, + }); + + assert.deepEqual(await Promise.all([resolvePackage.resolve(), resolvePackage.resolve()]), [ + { + 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 }, + startDevelopmentArchiveBuild: () => assert.fail('override must bypass the local build'), + }); + assert.deepEqual(await resolveOverride.resolve(), { + kind: 'development_archive', + path: override, + }); + await Promise.all([resolvePackage.close(), resolveOverride.close()]); + assert.equal(closes, 1); +}); + +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: () => { + 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 first = resolver.resolve(cancelled.signal); + cancelled.abort(new Error('setup cancelled')); + 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 d8458b7250..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 @@ -218,11 +218,38 @@ 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(); }); +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({ @@ -374,6 +401,88 @@ 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', + 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', + 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'; @@ -564,6 +673,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; @@ -584,6 +694,13 @@ function createHarness( revealDelayMs: 0, ...options, processStopGraceMs: 1, + terminateProcessTree: async ({ pid, signal, fallback, hasExited, beforeSignal }) => { + terminatedProcesses.push({ pid, signal }); + await Promise.resolve(); + if (hasExited?.() || beforeSignal?.() === false) return false; + fallback?.(); + return true; + }, openSshTunnel: async (input, overrides) => { const spawnProcess = overrides?.spawnProcess as RuntimeHostSshProcessFactory; const process = spawnProcess({ executable: 'ssh', args: [], interaction: input.interaction }); @@ -608,6 +725,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-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 32ac4b5c9e..f4d88123ec 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"; @@ -375,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, @@ -440,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 +452,9 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ profiles: runtimeHostProfileService, runServiceManagement: runtimeHostSshTerminal.runServiceManagement, runUpdate: runtimeHostSshTerminal.runUpdate, - resolveUpdatePackage: runtimeHostSetupPackage, + runUpdatePolicy: runtimeHostSshTerminal.runUpdatePolicy, + runUpdateReconciliation: runtimeHostSshTerminal.runUpdateReconciliation, + resolveUpdatePackage: runtimeHostSetupPackage.resolve, currentHostEpoch: (profileId) => runtimeHostManager?.current(profileId)?.candidate?.client.hostEpoch, awaitUpdatedConnection: async ( @@ -494,29 +499,6 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ runAccessManagement: runtimeHostSshTerminal.runAccessManagement, 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 }; -} const defaultRuntimeHostRecovery = createRuntimeHostDefaultRecovery({ defaultProfileId: () => runtimeHostManager?.defaultProfileId() ?? @@ -1598,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 66e209ffef..be6e26154b 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -20,7 +20,9 @@ import type { IpcMain } from 'electron'; import { RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + isProductReleaseVersion, runtimeHostAccessCredentialFingerprint, + type RuntimeHostManagedUpdatePolicy, type RuntimeHostAccessManagementFrame, type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; @@ -29,6 +31,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 +41,11 @@ import type { DesktopRuntimeHostSshAccessInput, DesktopRuntimeHostSshManagementInput, DesktopRuntimeHostSshUpdateInput, + DesktopRuntimeHostSshUpdatePolicyInput, + DesktopRuntimeHostSshUpdateReconciliationInput, DesktopRuntimeHostSetupPackage, + RuntimeHostServiceUpdatePolicyTerminalFrame, + RuntimeHostServiceUpdateReconciliationTerminalFrame, RuntimeHostServiceUpdateTerminalFrame, } from './runtime-host-ssh-terminal.js'; @@ -76,7 +84,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, @@ -223,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, @@ -230,54 +266,33 @@ 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 }), - setupPackage: input.resolveUpdatePackage(), - expectedTarget: { - serviceId: managed.service.id, - rootPath: managed.service.rootPath, - rootId: managed.profile.rootId, - }, + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + setupPackage, + expectedTarget, ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), }, (phase) => input.sendProgress({ profileId, phase }), ); - if ( - response.kind === 'result' && - response.update.kind !== 'active_tasks' - ) { - try { - const current = await input.profiles.resolveManagedService(profileId); - if (!current || !sameDesktopRuntimeHostManagedServiceBinding(current, managed)) { - throw new Error('Runtime Host profile changed while its service was updating'); - } - await input.awaitUpdatedConnection( - profileId, - managed.profile.rootId, - previousHostEpoch, - response.update.kind !== 'already_current', - ); - } catch (error) { + 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 +307,109 @@ export function createDesktopRuntimeHostManagement(input: { : response; }; + 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 (current.updateSchedulerState === undefined) { + 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, + ...(response.service ? { service: response.service } : {}), + } + : { kind: 'error', error: response.error }; + }; + const accessSnapshot = ( credentials: Extract< RuntimeHostAccessManagementFrame, @@ -413,6 +531,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 +551,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 +565,44 @@ export function createDesktopRuntimeHostManagement(input: { }; } +function projectUpdatePolicy( + frame: Extract, +): DesktopRuntimeHostUpdatePolicySnapshot { + if (frame.updateSchedulerState === undefined) { + return { ...frame.updatePolicy, schedulingState: 'unsupported' }; + } + 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..7a9ca7f10c --- /dev/null +++ b/apps/desktop/src/main/runtime-host-setup-package.ts @@ -0,0 +1,264 @@ +/* + * 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 { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } 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, +} from './runtime-host-ssh-terminal.js'; + +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 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, '..', '..'); + const task = input.startDevelopmentArchiveBuild?.(repoRoot) ?? + startDevelopmentArchiveBuild(repoRoot, input.environment); + const build = { + task, + result: task.result.then((path) => ({ + kind: 'development_archive' as const, + path, + })), + 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) => { + build.closing ??= build.task.close().finally(() => { + if (developmentBuild === build) developmentBuild = undefined; + }); + await build.closing; + 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'); + if (input.isPackaged) return packagedSetupPackage(input.appPath); + + const override = input.environment[DEVELOPMENT_ARCHIVE_ENV]; + if (override) return { kind: 'development_archive', path: override }; + + const build = await acquireBuild(signal); + 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); + }, + }; +} + +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 }; +} + +function startDevelopmentArchiveBuild( + repoRoot: string, + environment: NodeJS.ProcessEnv, +): 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, MAKA_CLI_DEVELOPMENT_OUTPUT_ROOT: outputRoot }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + 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 relativeArchive = relative(outputRoot, 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; + }); + void result.catch(() => rmSync(outputRoot, { recursive: true, force: true })); + let closing: Promise | undefined; + return { + result, + close() { + closing ??= (async () => { + try { + if (!settled) await terminateBuildProcess(child, result); + } finally { + rmSync(outputRoot, { recursive: true, force: true }); + } + })(); + return closing; + }, + }; +} + +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); + }); + }); +} + +async function terminateBuildProcess(child: ChildProcess, result: 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 e2a563cb7e..a186f26374 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, @@ -39,9 +40,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, @@ -56,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; @@ -64,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; @@ -110,6 +118,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 +173,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); } @@ -162,6 +197,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { readonly revealDelayMs?: number; readonly managementTimeoutMs?: number; readonly processStopGraceMs?: number; + readonly terminateProcessTree?: typeof terminateProcessTree; }): { openSshTunnel(input: RuntimeHostSshTunnelInput): Promise; runSetup( @@ -176,6 +212,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; @@ -241,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(); @@ -262,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, @@ -308,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; @@ -341,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 }) => { @@ -394,7 +442,11 @@ export function createDesktopRuntimeHostSshTerminal(input: { return; } dismissPresentation(terminal); - await terminateActiveTerminal(terminal, input.processStopGraceMs); + await terminateActiveTerminal( + terminal, + input.processStopGraceMs, + input.terminateProcessTree, + ); }); const runFramedManagement = async (options: { @@ -462,7 +514,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) { @@ -509,6 +561,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { cancellation.signal, input.processStopGraceMs, dismissPresentation, + input.terminateProcessTree, ); const remoteCommand = runtimeHostSetupRemoteCommand(setupPackage, setupInput); let complete: RuntimeHostSetupCompleteFrame | undefined; @@ -544,7 +597,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) { @@ -596,6 +649,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { updateInput.signal, input.processStopGraceMs, dismissPresentation, + input.terminateProcessTree, ); const frame = await runFramedManagement({ ...updateInput, @@ -620,6 +674,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, @@ -653,7 +742,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'); } @@ -673,7 +762,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); }, }; } @@ -795,10 +888,11 @@ 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, + terminateTree: typeof terminateProcessTree | undefined, ): Promise { if (setupPackage.kind === 'npm') { if (!isExactRuntimeHostSetupPackageSpecifier(setupPackage.specifier)) { @@ -832,7 +926,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'); } @@ -851,13 +945,14 @@ function remoteDevelopmentArchivePath(principalId: string): string { } async function waitForTerminalProcess( - process: RuntimeHostSshProcess, + process: DesktopRuntimeHostSshProcess, input: { readonly signal?: AbortSignal; readonly timeoutMs: number; readonly stopGraceMs?: number; readonly onAbort?: () => void; }, + terminateTree: typeof terminateProcessTree = terminateProcessTree, ): Promise<{ readonly exit: Awaited; readonly timedOut: boolean; @@ -880,7 +975,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; }), ]); @@ -893,16 +988,36 @@ 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, + hasExited: process.hasExited, + beforeSignal: () => !process.hasExited(), + }); +} + async function settlesWithin(promise: Promise, timeoutMs: number): Promise { let timeout: ReturnType | undefined; try { @@ -981,6 +1096,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, + ...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 { @@ -1109,10 +1261,13 @@ 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 })), + hasExited: terminal.hasExited, kill: (signal) => { try { terminal.pty.kill(signal); @@ -1122,6 +1277,7 @@ function terminateActiveTerminal( }, }, graceMs, + terminateTree, ); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d478543721..9740acb984 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,45 @@ 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; + readonly service?: NonNullable; + }; + export interface DesktopRuntimeHostAccessCredential { readonly credentialId: string; readonly principalKind: 'remote_owner' | 'capability_provider'; @@ -582,6 +619,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..cc401f6c13 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 [lastUpdateOutcome, setLastUpdateOutcome] = + useState(); const logsRef = useRef(null); const profile = props.profile; @@ -69,20 +90,39 @@ export function RuntimeHostManagementDialog(props: { setAccess(undefined); setConfirmation(undefined); setUpdatePhase(undefined); + setUpdatePolicy(undefined); + setUpdatePolicyChoice('manual'); + setFixedVersion(''); + setUpdatePolicyError(undefined); + setLastUpdateOutcome(undefined); setLoading(true); - void window.maka.runtimeHostManagement.run(profile.id, 'status').then( - (response) => { + 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); - }, - (failure) => { + } catch (failure) { if (!disposed) setError(settingsActionErrorMessage(failure, locale)); - }, - ).finally(() => { + } + 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); - }); + })(); return () => { disposed = true; }; @@ -102,21 +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 (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 { @@ -144,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; @@ -158,13 +206,18 @@ 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' } : undefined, ); + if (response.action === 'update' && response.update.kind !== 'active_tasks') { + await reloadUpdatePolicy(profile.id); + } } catch (failure) { const message = settingsActionErrorMessage(failure, locale); + setUpdatePolicy(undefined); setError(message); toast.error(copy.managementActionFailed, message); } finally { @@ -173,6 +226,86 @@ 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) { + setUpdatePolicy(undefined); + setUpdatePolicyError(settingsActionErrorMessage(failure, locale)); + } + } + + async function saveUpdatePolicy(): Promise { + if (!profile) return; + setLoading(true); + setError(undefined); + setUpdatePolicyError(undefined); + setLastUpdateOutcome(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), + ); + } catch (failure) { + const message = settingsActionErrorMessage(failure, locale); + setUpdatePolicy(undefined); + 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'); + 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; + } + setLastUpdateOutcome(response.reconciliation); + applyUpdatePolicy(response.updatePolicy); + 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 { + setLoading(false); + setUpdatePhase(undefined); + } + } + async function rotateCredential(): Promise { if (!profile) return; setLoading(true); @@ -216,6 +349,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 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} + {updateOutcome?.kind === 'disabled' ? ( + + ) : null} + {updateOutcome?.kind === 'manual_action' ? ( + + ) : null} + {updateOutcome?.kind === 'active_tasks' && confirmation?.kind !== 'update' ? ( + ) : null} {!access && service ? ( <> @@ -309,6 +467,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 +772,29 @@ export function RuntimeHostManagementDialog(props: { isDisabled={loading} onClick={props.onClose} /> - {profile?.transport.kind === 'ssh' && !uninstalled ? ( -
@@ -581,6 +832,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..bc9c47b36c 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,68 @@ describe('managed Runtime Host update reconciliation', () => { : undefined, 'disabled', ); + 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..e77331a6e6 100644 --- a/packages/cli/src/runtime-host-update-reconciliation.ts +++ b/packages/cli/src/runtime-host-update-reconciliation.ts @@ -21,10 +21,13 @@ 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 RuntimeHostManagedUpdatePolicy, type RuntimeHostServiceManagementFrame, + type RuntimeHostUpdateSchedulerState, } from '@maka/runtime-host/operator'; import { manageRuntimeHostService, @@ -75,6 +78,7 @@ interface RuntimeHostUpdateReconcileCliOptions { readonly framed: boolean; readonly clientDataRoot: string; readonly defaultRootPath: string; + readonly expectedTarget?: RuntimeHostManagedServiceTarget; } interface RuntimeHostUpdateReconciliationDeps { @@ -96,44 +100,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 +161,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', + ...requestedUpdateSchedulerState(updateSchedulerState), updatePolicy: { policy: { kind: 'manual' } }, reconciliation: { kind: 'disabled' }, }, @@ -184,6 +205,7 @@ export async function runManagedRuntimeHostUpdateReconcileCli( schemaVersion: 1, kind: 'result', action: 'reconcile_update', + ...requestedUpdateSchedulerState(updateSchedulerState), updatePolicy, service: selection.service, reconciliation: { @@ -232,7 +254,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 +269,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 +291,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 +340,13 @@ function reconciliationDeps( function updatePolicyResult( record: RuntimeHostManagedUpdatePolicyRecord | null, + updateSchedulerState: RuntimeHostUpdateSchedulerState, ): UpdatePolicyFrame { return { schemaVersion: 1, kind: 'result', action: 'update_policy', + ...requestedUpdateSchedulerState(updateSchedulerState), updatePolicy: policyResult(record), }; } @@ -325,6 +368,7 @@ function policySelector( function reconcileFrame( frame: RuntimeHostUpdateFrame, updatePolicy: ReturnType, + observedSchedulerState: RuntimeHostUpdateSchedulerState, ): ReconcileUpdateFrame { if (frame.kind === 'progress') { return { ...frame, action: 'reconcile_update' }; @@ -336,12 +380,30 @@ function reconcileFrame( schemaVersion: 1, kind: 'result', action: 'reconcile_update', + ...requestedUpdateSchedulerState( + frame.update.kind === 'updated' || + frame.update.kind === 'repaired' || + frame.update.kind === 'already_current' + ? 'ready' + : observedSchedulerState, + ), updatePolicy, service: frame.service, reconciliation: frame.update, }; } +function requestedUpdateSchedulerState(updateSchedulerState: RuntimeHostUpdateSchedulerState): { + readonly updateSchedulerState?: RuntimeHostUpdateSchedulerState; +} { + return process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] === + 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..1608ebe0c3 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) => @@ -320,6 +323,7 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ kind: z.literal('result'), action: z.literal('update_policy'), updatePolicy: UPDATE_POLICY_RESULT_SCHEMA, + updateSchedulerState: z.enum(UPDATE_SCHEDULER_STATES).optional(), }) .strict(), z @@ -328,6 +332,7 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ kind: z.literal('result'), action: z.literal('reconcile_update'), updatePolicy: UPDATE_POLICY_RESULT_SCHEMA, + updateSchedulerState: z.enum(UPDATE_SCHEDULER_STATES).optional(), service: SERVICE_SUMMARY_SCHEMA.optional(), reconciliation: z.discriminatedUnion('kind', [ z.object({ kind: z.literal('disabled') }).strict(), @@ -408,6 +413,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-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 d58375c9e9..56302bfd23 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, @@ -50,13 +51,15 @@ 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) => argument !== '--allow-dirty'); + .filter((argument) => !['--allow-dirty', '--development'].includes(argument)); if (unsupportedArguments.length > 0) { throw new Error(`Unsupported release argument: ${unsupportedArguments.join(', ')}`); } @@ -66,6 +69,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 @@ -74,10 +80,24 @@ 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(); + 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,25 +114,29 @@ 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'); - rmSync(releaseRoot, { recursive: true, force: true }); + rmSync(artifactRoot, { recursive: true, force: true }); mkdirSync(stageRoot, { recursive: true, mode: 0o755 }); copyCliRuntime(); const expectedDependencyManifests = copyDependencyClosure(cli); copyEvalMirror(); copyReleaseDocuments(); - writeReleaseManifest(cli, preparedTree); + writeReleaseManifest(cli, 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, }), @@ -125,12 +149,12 @@ function main() { 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', ); @@ -142,6 +166,24 @@ function main() { ); } +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'); @@ -206,8 +248,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']); } @@ -364,16 +408,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), + ) + ); }); }