From a0d341fa4632a0cde8afdfce2a4405099c6e8dd9 Mon Sep 17 00:00:00 2001 From: Wang Date: Mon, 24 Aug 2026 21:57:36 +0800 Subject: [PATCH 1/5] feat(runtime-host): add managed update reconciliation Persist one owner-bound update policy and resolve it to an exact, verified deployment before delegating to the existing update transaction. Keep manual mode state-free, serialize policy changes with reconciliation and uninstall, and expose bounded machine outcomes for future schedulers and clients. Generated-by: Codex --- .../__tests__/runtime-host-management.test.ts | 2 +- .../runtime-host-ssh-terminal.test.ts | 4 +- .../src/main/runtime-host-ssh-terminal.ts | 5 +- packages/cli/README.md | 14 + packages/cli/README.zh-CN.md | 13 + .../runtime-host-selected-update.test.ts | 2 +- .../runtime-host-service-manager.test.ts | 17 +- ...runtime-host-update-reconciliation.test.ts | 362 +++++++++++++++++ packages/cli/src/cli-core.ts | 26 ++ packages/cli/src/runtime-host-cli.ts | 62 ++- ...runtime-host-service-management-command.ts | 21 +- .../cli/src/runtime-host-service-manager.ts | 14 + .../cli/src/runtime-host-update-command.ts | 131 ++++-- .../src/runtime-host-update-policy-store.ts | 190 +++++++++ .../src/runtime-host-update-reconciliation.ts | 379 ++++++++++++++++++ packages/runtime-host/src/operator/index.ts | 1 + .../src/operator/service-management-frame.ts | 154 +++++-- 17 files changed, 1328 insertions(+), 69 deletions(-) create mode 100644 packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts create mode 100644 packages/cli/src/runtime-host-update-policy-store.ts create mode 100644 packages/cli/src/runtime-host-update-reconciliation.ts diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts index 9123219a52..cfe37cda97 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -579,7 +579,7 @@ function serviceResult( operatorAccess = false, ): Exclude< Extract, - { action: 'check_update' | 'update' } + { action: 'check_update' | 'update' | 'update_policy' | 'reconcile_update' } > { const result = { schemaVersion: 1 as const, 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 fd688ee6d9..d8458b7250 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 @@ -264,7 +264,9 @@ test('reads a framed service result without projecting it into the SSH terminal' const result = await management; assert.equal(result.kind, 'result'); - if (result.kind !== 'result') assert.fail('expected service management result'); + if (result.kind !== 'result' || result.action !== 'status') { + assert.fail('expected service status result'); + } assert.equal(result.service.installedVersion, '1.2.3'); assert.doesNotMatch(JSON.stringify(harness.events), /MAKA_RUNTIME_HOST_SERVICE/u); assert.match(JSON.stringify(harness.events), /Password/u); diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 43c133a6b6..e2a563cb7e 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -85,7 +85,10 @@ export interface DesktopRuntimeHostSshManagementInput { readonly destination: string; readonly sshPort?: number; readonly operatorPath: string; - readonly action: Exclude; + readonly action: Exclude< + RuntimeHostServiceManagementAction, + 'check_update' | 'update' | 'update_policy' | 'reconcile_update' + >; readonly expectedTarget: { readonly serviceId: string; readonly rootPath: string; diff --git a/packages/cli/README.md b/packages/cli/README.md index 0e2008c1c8..58666b8d2b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -139,6 +139,20 @@ installs or switches a package. Installation-management callers can pass the sam to the existing exact-package update transaction, and does not mutate a candidate that requires manual review. +The installation owner can persist one update target and reconcile it with the same verified +transaction: + +```sh +maka runtime-host service update-policy --target latest \ + --expected-service-id \ + --expected-root-path \ + --expected-root-id +maka runtime-host service reconcile-update --json +``` + +Use `update-policy --target manual` to disable automatic reconciliation. Reconciliation is a +bounded one-shot command: it never interrupts active work and does not install a scheduler. + ## Uninstall ```sh diff --git a/packages/cli/README.zh-CN.md b/packages/cli/README.zh-CN.md index 244830149a..c6650f46af 100644 --- a/packages/cli/README.zh-CN.md +++ b/packages/cli/README.zh-CN.md @@ -129,6 +129,19 @@ maka runtime-host service check-update --target next --json selector 传给 `service update --target`。该路径会先校验 archive 与解包后的 manifest,再委托给 现有的精确 package 更新事务;需要人工审查的候选不会改变当前 Host。 +Installation owner 可以持久化一个更新目标,并通过同一套已验证事务执行 reconciliation: + +```sh +maka runtime-host service update-policy --target latest \ + --expected-service-id \ + --expected-root-path \ + --expected-root-id +maka runtime-host service reconcile-update --json +``` + +使用 `update-policy --target manual` 关闭自动 reconciliation。Reconciliation 是有界的单次命令: +它不会中断 active work,也不会安装 scheduler。 + ## 卸载 ```sh diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts index 8ce5ac0194..0c9002a9d6 100644 --- a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -111,7 +111,7 @@ describe('managed Runtime Host selected update', () => { }); }); - it('lets the exact transaction decide whether a current candidate needs repair', async () => { + it('lets the exact transaction inspect the current deployment without downloading it again', async () => { const selection = updateSelection({ kind: 'current' }); let updateInput: RuntimeHostUpdateCliOptions | undefined; assert.equal( diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index 244bdb7c93..3012f15e54 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -822,7 +822,9 @@ describe('managed Runtime Host service', () => { const legacyFrame = decodeRuntimeHostServiceManagementFrame(await run()); assert.equal(legacyFrame?.kind, 'result'); assert.equal( - legacyFrame?.kind === 'result' ? legacyFrame.operatorCapabilities : undefined, + legacyFrame?.kind === 'result' && legacyFrame.action === 'status' + ? legacyFrame.operatorCapabilities + : undefined, undefined, ); @@ -830,7 +832,9 @@ describe('managed Runtime Host service', () => { RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY; const frame = decodeRuntimeHostServiceManagementFrame(await run()); assert.equal(frame?.kind, 'result'); - if (frame?.kind !== 'result') assert.fail('Expected a service result frame'); + if (frame?.kind !== 'result' || frame.action !== 'status') { + assert.fail('Expected a service status result frame'); + } assert.equal(frame.service.installedVersion, '1.2.3'); assert.deepEqual(frame.operatorCapabilities, ['access-management-v1']); assert.equal(frame.service.stateRoot, '/srv/maka'); @@ -839,9 +843,12 @@ describe('managed Runtime Host service', () => { process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] = RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY; const lockFrame = decodeRuntimeHostServiceManagementFrame(await run()); - assert.deepEqual(lockFrame?.kind === 'result' ? lockFrame.operatorCapabilities : undefined, [ - 'process-lifetime-lock-v1', - ]); + assert.deepEqual( + lockFrame?.kind === 'result' && lockFrame.action === 'status' + ? lockFrame.operatorCapabilities + : undefined, + ['process-lifetime-lock-v1'], + ); }); it('reads service logs when an interrupted install left no config', async (t) => { diff --git a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts new file mode 100644 index 0000000000..277c4db701 --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts @@ -0,0 +1,362 @@ +/* + * 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 { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { + decodeRuntimeHostServiceManagementFrame, + type RuntimeHostServiceManagementFrame, +} from '@maka/runtime-host/operator'; +import { parseRuntimeHostCommand } from '../runtime-host-cli.js'; +import { runManagedRuntimeHostServiceCli } from '../runtime-host-service-management-command.js'; +import { + runManagedRuntimeHostUpdatePolicyCli, + runManagedRuntimeHostUpdateReconcileCli, +} from '../runtime-host-update-reconciliation.js'; +import { + readRuntimeHostManagedUpdatePolicy, + resolveRuntimeHostManagedUpdatePolicyPath, + writeRuntimeHostManagedUpdatePolicy, +} from '../runtime-host-update-policy-store.js'; + +const INTEGRITY = + 'sha512-jUKdo/5dbM94KXq+kOZ1d+obhDLAENfI/QWr1PnXWcdu2PqDyLklJBtiVO6HRwoL1l40z1NE9Rq+hLAxCN0Fyg=='; +const TARGET = { + serviceId: 'b'.repeat(64), + rootPath: '/srv/maka-link', + rootId: 'a'.repeat(64), +}; +const SERVICE = { + platform: 'linux', + arch: 'x64', + osRelease: 'test', + state: 'running' as const, + pid: 42, + lastExitCode: null, + installedVersion: '1.0.0', + stateRoot: '/srv/maka', + projectDirectoryRoots: [], +}; + +describe('managed Runtime Host update reconciliation', () => { + it('parses one mutually exclusive policy and a target-free reconcile command', () => { + assert.deepEqual( + parseRuntimeHostCommand([ + 'service', + 'update-policy', + '--target', + 'next', + '--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: 'channel', channel: 'next' }, + expectedTarget: TARGET, + }, + ); + assert.deepEqual(parseRuntimeHostCommand(['service', 'reconcile-update', '--json']), { + kind: 'runtime-host-service-reconcile-update', + json: true, + }); + assert.equal( + parseRuntimeHostCommand(['service', 'update-policy', '--target', 'latest']).kind, + 'error', + ); + }); + + it('persists an automatic policy against the canonical managed target and removes manual state', async (t) => { + const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-policy-')); + t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + const common = { + json: true, + framed: false, + clientDataRoot, + defaultRootPath: '/workspace', + }; + let output = ''; + assert.equal( + await runManagedRuntimeHostUpdatePolicyCli( + { + ...common, + policy: { kind: 'fixed', version: '2.0.0' }, + expectedTarget: TARGET, + }, + { + withReconciliationLock: async (_root, operation) => operation(), + createBackend: () => unusedBackend(), + manage: async () => ({ + schemaVersion: 1, + action: 'status', + service: { + manager: 'systemd_user', + installed: true, + enabled: true, + active: true, + state: 'running', + pid: 42, + lastExitCode: null, + installedVersion: '1.0.0', + config: { + schemaVersion: 1, + managedDeploymentRoot: '/managed', + rootPath: '/srv/maka', + projectDirectoryRoots: [], + websocket: { host: '127.0.0.1', port: 7443, path: '/runtime-host' }, + launch: { nodePath: '/node', cliPath: '/managed/cli.js' }, + }, + }, + }), + writeOutput: (value) => { + output += value; + }, + }, + ), + 0, + ); + assert.deepEqual(await readRuntimeHostManagedUpdatePolicy(clientDataRoot), { + schemaVersion: 1, + policy: { kind: 'fixed', version: '2.0.0' }, + target: { ...TARGET, rootPath: '/srv/maka' }, + }); + assert.equal(JSON.parse(output).updatePolicy.policy.kind, 'fixed'); + + assert.equal( + await runManagedRuntimeHostUpdatePolicyCli( + { ...common, policy: { kind: 'manual' } }, + { + withReconciliationLock: async (_root, operation) => operation(), + writeOutput: () => undefined, + }, + ), + 0, + ); + assert.equal(await readRuntimeHostManagedUpdatePolicy(clientDataRoot), null); + let manualOutput = ''; + assert.equal( + await runManagedRuntimeHostUpdateReconcileCli( + { ...common, json: false, framed: true }, + { + withReconciliationLock: async (_root, operation) => operation(), + resolveSelection: async () => assert.fail('manual policy must not resolve a target'), + writeOutput: (value) => { + manualOutput += value; + }, + }, + ), + 0, + ); + const manualFrame = decodeRuntimeHostServiceManagementFrame(manualOutput.trim()); + assert.equal( + manualFrame?.kind === 'result' && manualFrame.action === 'reconcile_update' + ? manualFrame.reconciliation.kind + : undefined, + 'disabled', + ); + }); + + it('resolves one policy snapshot and delegates an admitted exact target to the update transaction', async (t) => { + const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-reconcile-')); + t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + await writeRuntimeHostManagedUpdatePolicy(clientDataRoot, { + schemaVersion: 1, + policy: { kind: 'fixed', version: '2.0.0' }, + target: TARGET, + }); + let output = ''; + let applied = false; + const exitCode = await runManagedRuntimeHostUpdateReconcileCli( + { + json: true, + framed: false, + clientDataRoot, + defaultRootPath: '/workspace', + }, + { + withReconciliationLock: async (_root, operation) => operation(), + resolveSelection: async (options) => { + assert.deepEqual(options.selector, { kind: 'exact', version: '2.0.0' }); + assert.deepEqual(options.expectedTarget, TARGET); + return { + selector: options.selector, + candidate: { version: '2.0.0', integrity: INTEGRITY, compatibility: 1 }, + outcome: { kind: 'unattended_update', compatibility: 1 }, + currentCliPath: '/managed/current/cli.js', + service: SERVICE, + }; + }, + applySelection: async (_options, _selection, _overrides, observer) => { + applied = true; + observer?.onFrame?.({ + schemaVersion: 1, + kind: 'result', + action: 'update', + service: { ...SERVICE, installedVersion: '2.0.0' }, + update: { + kind: 'updated', + previousVersion: '1.0.0', + targetVersion: '2.0.0', + }, + }); + return 0; + }, + writeOutput: (value) => { + output += value; + }, + }, + ); + assert.equal(exitCode, 0); + assert.equal(applied, true); + const frame = JSON.parse(output) as RuntimeHostServiceManagementFrame; + assert.equal(frame.action, 'reconcile_update'); + assert.deepEqual( + frame.kind === 'result' && frame.action === 'reconcile_update' + ? frame.reconciliation + : undefined, + { kind: 'updated', previousVersion: '1.0.0', targetVersion: '2.0.0' }, + ); + }); + + it('fails closed on corrupt policy and returns manual-action candidates without mutation', async (t) => { + const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-reconcile-')); + t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + await writeFile(resolveRuntimeHostManagedUpdatePolicyPath(clientDataRoot), '{"bad":true}\n'); + let errorOutput = ''; + assert.equal( + await runManagedRuntimeHostUpdateReconcileCli( + { json: true, framed: false, clientDataRoot, defaultRootPath: '/workspace' }, + { + withReconciliationLock: async (_root, operation) => operation(), + writeOutput: (value) => { + errorOutput += value; + }, + }, + ), + 1, + ); + assert.equal(JSON.parse(errorOutput).error.code, 'invalid_update_policy'); + + await writeRuntimeHostManagedUpdatePolicy(clientDataRoot, { + schemaVersion: 1, + policy: { kind: 'channel', channel: 'latest' }, + target: TARGET, + }); + let output = ''; + assert.equal( + await runManagedRuntimeHostUpdateReconcileCli( + { json: true, framed: false, clientDataRoot, defaultRootPath: '/workspace' }, + { + withReconciliationLock: async (_root, operation) => operation(), + resolveSelection: async (options) => ({ + selector: options.selector, + candidate: { version: '2.0.0', integrity: INTEGRITY }, + outcome: { kind: 'manual_action', reason: 'target_compatibility_unknown' }, + currentCliPath: '/managed/current/cli.js', + service: SERVICE, + }), + applySelection: async () => assert.fail('update transaction is not expected'), + writeOutput: (value) => { + output += value; + }, + }, + ), + 1, + ); + assert.equal(JSON.parse(output).reconciliation.kind, 'manual_action'); + }); + + it('removes installation-owned policy when the managed service is uninstalled', async (t) => { + const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-uninstall-')); + t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + await writeRuntimeHostManagedUpdatePolicy(clientDataRoot, { + schemaVersion: 1, + policy: { kind: 'channel', channel: 'next' }, + target: TARGET, + }); + const immediate = async (_root: string, operation: () => Promise) => operation(); + assert.equal( + await runManagedRuntimeHostServiceCli( + { + action: 'uninstall', + json: false, + clientDataRoot, + defaultRootPath: '/workspace', + nodePath: '/node', + cliPath: '/cli.js', + }, + { + withReconciliationLock: immediate, + withDeploymentLock: immediate, + withLifecycleLock: immediate, + createBackend: () => unusedBackend(), + manage: async () => ({ + schemaVersion: 1, + action: 'uninstall', + service: { + manager: 'systemd_user', + installed: false, + enabled: false, + active: false, + state: 'not_installed', + pid: null, + lastExitCode: null, + installedVersion: null, + config: null, + }, + retainedStateRoot: '/srv/maka', + }), + writeOutput: () => undefined, + }, + ), + 0, + ); + assert.equal(await readRuntimeHostManagedUpdatePolicy(clientDataRoot), null); + }); +}); + +function unusedBackend() { + return { + preflightInstall: async () => undefined, + install: async () => ({ rollback: async () => undefined }), + replace: async () => undefined, + verifyDeployment: async () => undefined, + status: async () => ({ + manager: 'systemd_user' as const, + installed: false, + enabled: false, + active: false, + state: 'not_installed' as const, + pid: null, + lastExitCode: null, + }), + start: async () => undefined, + stop: async () => undefined, + restart: async () => undefined, + logs: async () => '', + uninstall: async () => undefined, + }; +} diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 7b341013b4..b9892392cf 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -124,6 +124,8 @@ function helpText(cliCommand: string): string { ` ${cliCommand} runtime-host service retire --expected-service-id --expected-root-path --expected-root-id [--allow-interrupt-active-tasks]`, ` ${cliCommand} runtime-host service check-update --target [--json]`, ` ${cliCommand} runtime-host service update [--target ] --expected-service-id --expected-root-path --expected-root-id [--allow-interrupt-active-tasks]`, + ` ${cliCommand} runtime-host service update-policy [--target ] [--json]`, + ` ${cliCommand} runtime-host service reconcile-update [--json]`, ` ${cliCommand} runtime-host access issue --principal --grant `, ` ${cliCommand} runtime-host access issue --principal --preset `, ` ${cliCommand} runtime-host access list`, @@ -321,6 +323,30 @@ export async function runMakaCli( ...(command.expectedTarget ? { expectedTarget: command.expectedTarget } : {}), }); } + case 'runtime-host-service-update-policy': + case 'runtime-host-service-reconcile-update': { + const { runManagedRuntimeHostUpdatePolicyCli, runManagedRuntimeHostUpdateReconcileCli } = + await import('./runtime-host-update-reconciliation.js'); + const serviceDataRoots = command.clientDataRoot + ? deriveMakaDataRoots(command.clientDataRoot) + : dataRoots; + if (command.kind === 'runtime-host-service-update-policy') { + return runManagedRuntimeHostUpdatePolicyCli({ + json: command.json, + framed: command.framed ?? false, + clientDataRoot: serviceDataRoots.clientDataRoot, + defaultRootPath: serviceDataRoots.workspaceRoot, + ...(command.policy ? { policy: command.policy } : {}), + ...(command.expectedTarget ? { expectedTarget: command.expectedTarget } : {}), + }); + } + return runManagedRuntimeHostUpdateReconcileCli({ + json: command.json, + framed: command.framed ?? false, + clientDataRoot: serviceDataRoots.clientDataRoot, + defaultRootPath: serviceDataRoots.workspaceRoot, + }); + } case 'runtime-host-managed-deployment-cleanup': { const { runManagedRuntimeHostDeploymentCleanupCli } = await import( './runtime-host-service-management-command.js' diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index ce50ad595f..9153e1e703 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -19,6 +19,7 @@ import { isAbsolute } from 'node:path'; import { isProductReleaseVersion } from '@maka/runtime-host/operator'; +import type { RuntimeHostManagedUpdatePolicy } from '@maka/runtime-host/operator'; import { isCanonicalRuntimeHostWebSocketPath, PROJECT_DIRECTORY_MAX_ROOTS, @@ -91,6 +92,20 @@ export type RuntimeHostCliCommand = selector?: RuntimeHostUpdateSelector; allowInterruptActiveTasks?: true; } + | { + kind: 'runtime-host-service-update-policy'; + json: boolean; + framed?: true; + clientDataRoot?: string; + policy?: RuntimeHostManagedUpdatePolicy; + expectedTarget?: RuntimeHostManagedServiceTarget; + } + | { + kind: 'runtime-host-service-reconcile-update'; + json: boolean; + framed?: true; + clientDataRoot?: string; + } | { kind: 'runtime-host-managed-deployment-cleanup'; clientDataRoot?: string; @@ -251,13 +266,15 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { action !== 'retire' && action !== 'check-update' && action !== 'update' && + action !== 'update-policy' && + action !== 'reconcile-update' && action !== 'logs' && action !== 'uninstall' ) { return error( action ? `Unexpected runtime-host service command: ${action}` - : 'runtime-host service requires install, status, start, stop, restart, retire, check-update, update, logs, or uninstall', + : 'runtime-host service requires install, status, start, stop, restart, retire, check-update, update, update-policy, reconcile-update, logs, or uninstall', ); } @@ -292,7 +309,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { if (!isSafeAbsolutePath(value)) return error('--client-data-root must be an absolute path'); clientDataRoot = value; }, - ...(action === 'check-update' || action === 'update' + ...(action === 'check-update' || action === 'update' || action === 'update-policy' ? { '--target': (value: string) => { if (updateTarget !== undefined) return error('Duplicate --target'); @@ -307,6 +324,38 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { if ((action === 'retire' || action === 'update') && !options.expectedTarget) { return error(`runtime-host service ${action} requires an expected target`); } + 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, + ...(options.framed ? { framed: true } : {}), + ...(clientDataRoot ? { clientDataRoot } : {}), + ...(policy ? { policy } : {}), + ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), + }; + } + 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 } : {}), + }; + } if (action === 'check-update') { const selector = parseUpdateSelector(updateTarget); if ('kind' in selector && selector.kind === 'error') return selector; @@ -343,9 +392,16 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { }; } +function parseUpdatePolicy(value: string): RuntimeHostManagedUpdatePolicy | RuntimeHostCliError { + if (value === 'manual') return { kind: 'manual' }; + const selector = parseUpdateSelector(value, 'update-policy'); + if ('exitCode' in selector) return selector; + return selector.kind === 'exact' ? { kind: 'fixed', version: selector.version } : selector; +} + function parseUpdateSelector( value: string | undefined, - action: 'check-update' | 'update' = 'check-update', + action: 'check-update' | 'update' | 'update-policy' = 'check-update', ): RuntimeHostUpdateSelector | RuntimeHostCliError { if (!value) return error(`runtime-host service ${action} requires --target`); if (value === 'latest' || value === 'next') { diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts index 81078c06e3..026ab303fd 100644 --- a/packages/cli/src/runtime-host-service-management-command.ts +++ b/packages/cli/src/runtime-host-service-management-command.ts @@ -37,11 +37,13 @@ import { RuntimeHostServiceManagerError, withRuntimeHostManagedServiceDeploymentLock, withRuntimeHostManagedServiceLifecycleLock, + withRuntimeHostManagedServiceReconciliationLock, type RuntimeHostManagedServiceInput, type RuntimeHostManagedServiceResult, type RuntimeHostManagedServiceTarget, type RuntimeHostServiceBackend, } from './runtime-host-service-manager.js'; +import { writeRuntimeHostManagedUpdatePolicy } from './runtime-host-update-policy-store.js'; import { createLaunchAgentRuntimeHostService } from './runtime-host-launch-agent-service.js'; import { createSystemdUserRuntimeHostService } from './runtime-host-systemd-service.js'; @@ -56,6 +58,8 @@ export interface RuntimeHostServiceManagementCliDeps { readonly manage: typeof manageRuntimeHostService; readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock; readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock; + readonly withReconciliationLock: typeof withRuntimeHostManagedServiceReconciliationLock; + readonly clearUpdatePolicy: (clientDataRoot: string) => Promise; readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend; readonly writeOutput: (value: string) => unknown; readonly writeError: (value: string) => unknown; @@ -69,6 +73,9 @@ export async function runManagedRuntimeHostServiceCli( manage: manageRuntimeHostService, withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock, withLifecycleLock: withRuntimeHostManagedServiceLifecycleLock, + withReconciliationLock: withRuntimeHostManagedServiceReconciliationLock, + clearUpdatePolicy: (clientDataRoot) => + writeRuntimeHostManagedUpdatePolicy(clientDataRoot, null), createBackend: createPlatformRuntimeHostServiceBackend, writeOutput: (value) => process.stdout.write(value), writeError: (value) => process.stderr.write(value), @@ -78,14 +85,22 @@ export async function runManagedRuntimeHostServiceCli( const { json: _json, framed: _framed, ...input } = options; const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); const manage = () => deps.manage(input, deps.createBackend(serviceId)); + const mutate = () => + deps.withDeploymentLock(options.clientDataRoot, () => + deps.withLifecycleLock(options.clientDataRoot, manage), + ); const result = options.action === 'status' || options.action === 'logs' ? await manage() : options.action === 'retire' ? await deps.withLifecycleLock(options.clientDataRoot, manage) - : await deps.withDeploymentLock(options.clientDataRoot, () => - deps.withLifecycleLock(options.clientDataRoot, manage), - ); + : options.action === 'uninstall' + ? await deps.withReconciliationLock(options.clientDataRoot, async () => { + const uninstalled = await mutate(); + await deps.clearUpdatePolicy(options.clientDataRoot); + return uninstalled; + }) + : await mutate(); const blocked = result.action === 'retire' && result.retirement.kind === 'active_tasks'; if (options.framed) { deps.writeOutput(encodeRuntimeHostServiceManagementFrame(successFrame(result))); diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 4444b43a4f..3fe500dc19 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -53,6 +53,7 @@ import { const SERVICE_CONFIG_FILE = 'runtime-host-service.json'; const SERVICE_LIFECYCLE_LOCK_FILE = 'runtime-host-setup'; const SERVICE_DEPLOYMENT_LOCK_FILE = 'runtime-host-deployment'; +const SERVICE_RECONCILIATION_LOCK_FILE = 'runtime-host-reconciliation'; const DEFAULT_WEBSOCKET_PATH = '/runtime-host'; const SERVICE_OPERATION_LOCK_TIMEOUT_MS = 60_000; const SERVICE_READY_TIMEOUT_MS = 45_000; @@ -279,6 +280,19 @@ export async function withRuntimeHostManagedServiceDeploymentLock( ); } +export async function withRuntimeHostManagedServiceReconciliationLock( + clientDataRoot: string, + operation: () => Promise, + timeoutMs = SERVICE_OPERATION_LOCK_TIMEOUT_MS, +): Promise { + await mkdir(clientDataRoot, { recursive: true, mode: 0o700 }); + return withProcessLifetimeFileUpdateLock( + join(clientDataRoot, SERVICE_RECONCILIATION_LOCK_FILE), + operation, + timeoutMs, + ); +} + export async function withRuntimeHostManagedServiceLegacyOperatorLeases( clientDataRoot: string, operation: (inheritedFds: readonly number[]) => Promise, diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index aca22ad34c..2319a4b819 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -59,6 +59,7 @@ import { import { resolveManagedRuntimeHostUpdateSelection, RuntimeHostUpdateDiscoveryError, + type RuntimeHostUpdateSelection, } from './runtime-host-update-discovery.js'; import { RuntimeHostUpdatePackageError, @@ -111,14 +112,27 @@ interface RuntimeHostUpdateCliDeps { readonly writeError: (value: string) => unknown; } -interface RuntimeHostSelectedUpdateCliDeps { - readonly resolveSelection: typeof resolveManagedRuntimeHostUpdateSelection; +interface RuntimeHostResolvedUpdateCliDeps { readonly withPackage: typeof withRuntimeHostRegistryUpdatePackage; readonly update: typeof runManagedRuntimeHostUpdateCli; readonly writeOutput: (value: string) => unknown; readonly writeError: (value: string) => unknown; } +interface RuntimeHostSelectedUpdateCliDeps extends RuntimeHostResolvedUpdateCliDeps { + readonly resolveSelection: typeof resolveManagedRuntimeHostUpdateSelection; +} + +export type RuntimeHostUpdateFrame = Extract< + RuntimeHostServiceManagementFrame, + { action: 'update' } +>; + +export interface RuntimeHostUpdateCliObserver { + readonly onFrame?: (frame: RuntimeHostUpdateFrame) => void; + readonly suppressPresentation?: boolean; +} + interface RuntimeHostOperatorInvocation { readonly inheritedFds?: readonly number[]; readonly capabilityRequest?: RuntimeHostOperatorCapability; @@ -127,6 +141,7 @@ interface RuntimeHostOperatorInvocation { export async function runManagedRuntimeHostUpdateCli( options: RuntimeHostUpdateCliOptions, overrides: Partial = {}, + observer: RuntimeHostUpdateCliObserver = {}, ): Promise { const deps: RuntimeHostUpdateCliDeps = { manage: manageRuntimeHostService, @@ -147,7 +162,9 @@ export async function runManagedRuntimeHostUpdateCli( let exactTargetObserved = false; let cutoverStarted = false; let retired = false; - const emit = (frame: RuntimeHostServiceManagementFrame): void => { + const emit = (frame: RuntimeHostUpdateFrame): void => { + observer.onFrame?.(frame); + if (observer.suppressPresentation) return; if (options.framed) { deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); return; @@ -488,6 +505,7 @@ export async function runManagedRuntimeHostUpdateCli( export async function runManagedRuntimeHostSelectedUpdateCli( options: RuntimeHostSelectedUpdateCliOptions, overrides: Partial = {}, + observer: RuntimeHostUpdateCliObserver = {}, ): Promise { const deps: RuntimeHostSelectedUpdateCliDeps = { resolveSelection: resolveManagedRuntimeHostUpdateSelection, @@ -497,20 +515,6 @@ export async function runManagedRuntimeHostSelectedUpdateCli( writeError: (value) => process.stderr.write(value), ...overrides, }; - const emit = (frame: Exclude): void => { - if (options.framed) { - deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); - } else if (options.json) { - deps.writeOutput(`${JSON.stringify(frame)}\n`); - } else if (frame.kind === 'error') { - deps.writeError(`${frame.error.message}\n`); - } else { - if (frame.action !== 'update') { - throw new TypeError('Managed Runtime Host update returned an unrelated result'); - } - deps.writeOutput(`${humanResult(frame)}\n`); - } - }; try { const selection = await deps.resolveSelection({ @@ -519,8 +523,49 @@ export async function runManagedRuntimeHostSelectedUpdateCli( selector: options.selector, expectedTarget: options.expectedTarget, }); + return await runManagedRuntimeHostResolvedUpdateCli(options, selection, deps, observer); + } catch (error) { + const code = + error instanceof RuntimeHostUpdateDiscoveryError || + error instanceof RuntimeHostServiceManagerError || + error instanceof RuntimeHostUpdatePackageError + ? error.code + : 'update_resolution_failed'; + const message = error instanceof Error ? error.message : String(error); + emitSelectedUpdateTerminal(options, deps, observer, { + schemaVersion: 1, + kind: 'error', + action: 'update', + error: { + code: + truncateUtf8(code, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES) || + 'update_resolution_failed', + message: + truncateUtf8(message, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES) || + 'Unable to prepare the Runtime Host update', + }, + }); + return 1; + } +} + +export async function runManagedRuntimeHostResolvedUpdateCli( + options: RuntimeHostSelectedUpdateCliOptions, + selection: RuntimeHostUpdateSelection, + overrides: Partial = {}, + observer: RuntimeHostUpdateCliObserver = {}, +): Promise { + const deps: RuntimeHostResolvedUpdateCliDeps = { + withPackage: withRuntimeHostRegistryUpdatePackage, + update: runManagedRuntimeHostUpdateCli, + writeOutput: (value) => process.stdout.write(value), + writeError: (value) => process.stderr.write(value), + ...overrides, + }; + + try { if (selection.outcome.kind === 'manual_action') { - emit({ + emitSelectedUpdateTerminal(options, deps, observer, { schemaVersion: 1, kind: 'error', action: 'update', @@ -537,18 +582,22 @@ export async function runManagedRuntimeHostSelectedUpdateCli( const apply = async (packageRoot: string) => { const { selector: _selector, ...updateOptions } = options; - return await deps.update({ - ...updateOptions, - sourcePackageRoot: packageRoot, - version: selection.candidate.version, - registrySelection: { - integrity: selection.candidate.integrity, - current: { - version: selection.service.installedVersion, - cliPath: selection.currentCliPath, + return await deps.update( + { + ...updateOptions, + sourcePackageRoot: packageRoot, + version: selection.candidate.version, + registrySelection: { + integrity: selection.candidate.integrity, + current: { + version: selection.service.installedVersion, + cliPath: selection.currentCliPath, + }, }, }, - }); + {}, + observer, + ); }; return selection.outcome.kind === 'current' ? await apply(dirname(dirname(selection.currentCliPath))) @@ -561,7 +610,7 @@ export async function runManagedRuntimeHostSelectedUpdateCli( ? error.code : 'update_resolution_failed'; const message = error instanceof Error ? error.message : String(error); - emit({ + emitSelectedUpdateTerminal(options, deps, observer, { schemaVersion: 1, kind: 'error', action: 'update', @@ -578,11 +627,33 @@ export async function runManagedRuntimeHostSelectedUpdateCli( } } +function emitSelectedUpdateTerminal( + options: RuntimeHostSelectedUpdateCliOptions, + deps: Pick, + observer: RuntimeHostUpdateCliObserver, + frame: Exclude, +): void { + observer.onFrame?.(frame); + if (observer.suppressPresentation) return; + if (options.framed) { + deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); + } else if (options.json) { + deps.writeOutput(`${JSON.stringify(frame)}\n`); + } else if (frame.kind === 'error') { + deps.writeError(`${frame.error.message}\n`); + } else { + if (frame.action !== 'update') { + throw new TypeError('Managed Runtime Host update returned an unrelated result'); + } + deps.writeOutput(`${humanResult(frame)}\n`); + } +} + function progress( phase: RuntimeHostServiceUpdatePhase, currentVersion: string, targetVersion: string, -): RuntimeHostServiceManagementFrame { +): RuntimeHostUpdateFrame { return { schemaVersion: 1, kind: 'progress', diff --git a/packages/cli/src/runtime-host-update-policy-store.ts b/packages/cli/src/runtime-host-update-policy-store.ts new file mode 100644 index 0000000000..e637972d64 --- /dev/null +++ b/packages/cli/src/runtime-host-update-policy-store.ts @@ -0,0 +1,190 @@ +/* + * 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 { randomUUID } from 'node:crypto'; +import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { dirname, isAbsolute, join } from 'node:path'; +import { + isProductReleaseVersion, + type RuntimeHostManagedUpdatePolicy, +} from '@maka/runtime-host/operator'; +import type { RuntimeHostManagedServiceTarget } from './runtime-host-service-manager.js'; + +const UPDATE_POLICY_FILE = 'runtime-host-update-policy.json'; +const UPDATE_POLICY_MAX_BYTES = 16 * 1024; + +type AutomaticUpdatePolicy = Exclude; + +export interface RuntimeHostManagedUpdatePolicyRecord { + readonly schemaVersion: 1; + readonly policy: AutomaticUpdatePolicy; + readonly target: RuntimeHostManagedServiceTarget; +} + +export class RuntimeHostUpdatePolicyError extends Error { + constructor( + readonly code: 'invalid_update_policy' | 'update_policy_write_failed', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'RuntimeHostUpdatePolicyError'; + } +} + +export function resolveRuntimeHostManagedUpdatePolicyPath(clientDataRoot: string): string { + return join(clientDataRoot, UPDATE_POLICY_FILE); +} + +export async function readRuntimeHostManagedUpdatePolicy( + clientDataRoot: string, +): Promise { + const path = resolveRuntimeHostManagedUpdatePolicyPath(clientDataRoot); + let raw: string; + try { + raw = await readFile(path, 'utf8'); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return null; + throw new RuntimeHostUpdatePolicyError( + 'invalid_update_policy', + `Unable to read the managed Runtime Host update policy at ${path}`, + { cause: error }, + ); + } + try { + if (Buffer.byteLength(raw, 'utf8') > UPDATE_POLICY_MAX_BYTES) { + throw new TypeError('Update policy exceeds its size limit'); + } + const parsed: unknown = JSON.parse(raw); + assertUpdatePolicyRecord(parsed); + return parsed; + } catch (error) { + throw new RuntimeHostUpdatePolicyError( + 'invalid_update_policy', + `Invalid managed Runtime Host update policy at ${path}`, + { cause: error }, + ); + } +} + +export async function writeRuntimeHostManagedUpdatePolicy( + clientDataRoot: string, + record: RuntimeHostManagedUpdatePolicyRecord | null, +): Promise { + const path = resolveRuntimeHostManagedUpdatePolicyPath(clientDataRoot); + if (record === null) { + try { + await rm(path, { force: true }); + return; + } catch (error) { + throw new RuntimeHostUpdatePolicyError( + 'update_policy_write_failed', + `Unable to remove the managed Runtime Host update policy at ${path}`, + { cause: error }, + ); + } + } + assertUpdatePolicyRecord(record); + const directory = dirname(path); + const temporaryPath = `${path}.${randomUUID()}.tmp`; + try { + await mkdir(directory, { recursive: true, mode: 0o700 }); + const file = await open(temporaryPath, 'wx', 0o600); + try { + await file.writeFile(`${JSON.stringify(record, null, 2)}\n`, 'utf8'); + await file.sync(); + } finally { + await file.close(); + } + await rename(temporaryPath, path); + const parent = await open(directory, 'r'); + try { + await parent.sync(); + } finally { + await parent.close(); + } + } catch (error) { + throw new RuntimeHostUpdatePolicyError( + 'update_policy_write_failed', + `Unable to persist the managed Runtime Host update policy at ${path}`, + { cause: error }, + ); + } finally { + await rm(temporaryPath, { force: true }); + } +} + +function assertUpdatePolicyRecord( + value: unknown, +): asserts value is RuntimeHostManagedUpdatePolicyRecord { + if ( + !isRecord(value) || + value.schemaVersion !== 1 || + !hasOnlyKeys(value, ['schemaVersion', 'policy', 'target']) || + !isAutomaticUpdatePolicy(value.policy) || + !isManagedServiceTarget(value.target) + ) { + throw new TypeError('Invalid managed Runtime Host update policy record'); + } +} + +function isAutomaticUpdatePolicy(value: unknown): value is AutomaticUpdatePolicy { + if (!isRecord(value)) return false; + if (value.kind === 'channel') { + return ( + hasOnlyKeys(value, ['kind', 'channel']) && + (value.channel === 'latest' || value.channel === 'next') + ); + } + return ( + value.kind === 'fixed' && + hasOnlyKeys(value, ['kind', 'version']) && + typeof value.version === 'string' && + isProductReleaseVersion(value.version) + ); +} + +function isManagedServiceTarget(value: unknown): value is RuntimeHostManagedServiceTarget { + return ( + isRecord(value) && + hasOnlyKeys(value, ['serviceId', 'rootPath', 'rootId']) && + typeof value.serviceId === 'string' && + /^[a-f0-9]{64}$/u.test(value.serviceId) && + typeof value.rootId === 'string' && + /^[a-f0-9]{64}$/u.test(value.rootId) && + typeof value.rootPath === 'string' && + isAbsolute(value.rootPath) && + value.rootPath.length > 0 && + Buffer.byteLength(value.rootPath, 'utf8') <= 4 * 1024 && + !/[\u0000-\u001f\u007f]/u.test(value.rootPath) + ); +} + +function hasOnlyKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value); + return actual.length === keys.length && actual.every((key) => keys.includes(key)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNodeError(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code; +} diff --git a/packages/cli/src/runtime-host-update-reconciliation.ts b/packages/cli/src/runtime-host-update-reconciliation.ts new file mode 100644 index 0000000000..179393b141 --- /dev/null +++ b/packages/cli/src/runtime-host-update-reconciliation.ts @@ -0,0 +1,379 @@ +/* + * 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 { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { + encodeRuntimeHostServiceManagementFrame, + RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES, + RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES, + type RuntimeHostManagedUpdatePolicy, + type RuntimeHostServiceManagementFrame, +} from '@maka/runtime-host/operator'; +import { + manageRuntimeHostService, + resolveRuntimeHostManagedServiceId, + RuntimeHostServiceManagerError, + withRuntimeHostManagedServiceReconciliationLock, + type RuntimeHostManagedServiceTarget, + type RuntimeHostServiceBackend, +} from './runtime-host-service-manager.js'; +import { + createPlatformRuntimeHostServiceBackend, + runtimeHostServiceSummary, +} from './runtime-host-service-management-command.js'; +import { + readRuntimeHostManagedUpdatePolicy, + RuntimeHostUpdatePolicyError, + writeRuntimeHostManagedUpdatePolicy, + type RuntimeHostManagedUpdatePolicyRecord, +} from './runtime-host-update-policy-store.js'; +import { + runManagedRuntimeHostResolvedUpdateCli, + type RuntimeHostSelectedUpdateCliOptions, + type RuntimeHostUpdateFrame, +} from './runtime-host-update-command.js'; +import { + resolveManagedRuntimeHostUpdateSelection, + RuntimeHostUpdateDiscoveryError, +} from './runtime-host-update-discovery.js'; +import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js'; + +type UpdatePolicyFrame = Extract; +type ReconcileUpdateFrame = Extract< + RuntimeHostServiceManagementFrame, + { action: 'reconcile_update' } +>; + +interface RuntimeHostUpdatePolicyCliOptions { + readonly json: boolean; + readonly framed: boolean; + readonly clientDataRoot: string; + readonly defaultRootPath: string; + readonly policy?: RuntimeHostManagedUpdatePolicy; + readonly expectedTarget?: RuntimeHostManagedServiceTarget; +} + +interface RuntimeHostUpdateReconcileCliOptions { + readonly json: boolean; + readonly framed: boolean; + readonly clientDataRoot: string; + readonly defaultRootPath: string; +} + +interface RuntimeHostUpdateReconciliationDeps { + readonly withReconciliationLock: typeof withRuntimeHostManagedServiceReconciliationLock; + readonly readPolicy: typeof readRuntimeHostManagedUpdatePolicy; + readonly writePolicy: typeof writeRuntimeHostManagedUpdatePolicy; + readonly manage: typeof manageRuntimeHostService; + readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend; + readonly resolveSelection: typeof resolveManagedRuntimeHostUpdateSelection; + readonly applySelection: typeof runManagedRuntimeHostResolvedUpdateCli; + readonly writeOutput: (value: string) => unknown; + readonly writeError: (value: string) => unknown; +} + +export async function runManagedRuntimeHostUpdatePolicyCli( + options: RuntimeHostUpdatePolicyCliOptions, + overrides: Partial = {}, +): Promise { + const deps = reconciliationDeps(overrides); + try { + const requestedPolicy = options.policy; + const record = requestedPolicy + ? await deps.withReconciliationLock(options.clientDataRoot, async () => { + if (requestedPolicy.kind === 'manual') { + await deps.writePolicy(options.clientDataRoot, null); + return null; + } + if (!options.expectedTarget) { + throw new RuntimeHostUpdatePolicyError( + 'invalid_update_policy', + 'An automatic Runtime Host update policy requires the expected managed service target', + ); + } + const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); + const status = await deps.manage( + { + action: 'status', + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + nodePath: process.execPath, + cliPath: process.argv[1] ?? '', + expectedTarget: options.expectedTarget, + }, + deps.createBackend(serviceId), + ); + if (!status.service.installed || !status.service.config?.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: { + ...options.expectedTarget, + rootPath: status.service.config.rootPath, + }, + }; + await deps.writePolicy(options.clientDataRoot, next); + return next; + }) + : await deps.readPolicy(options.clientDataRoot); + writeFrame(updatePolicyResult(record), options, deps); + return 0; + } catch (error) { + writeFrame(updatePolicyError(error), options, deps); + return 1; + } +} + +export async function runManagedRuntimeHostUpdateReconcileCli( + options: RuntimeHostUpdateReconcileCliOptions, + overrides: Partial = {}, +): Promise { + const deps = reconciliationDeps(overrides); + try { + return await deps.withReconciliationLock(options.clientDataRoot, async () => { + const record = await deps.readPolicy(options.clientDataRoot); + if (!record) { + writeFrame( + { + schemaVersion: 1, + kind: 'result', + action: 'reconcile_update', + updatePolicy: { policy: { kind: 'manual' } }, + reconciliation: { kind: 'disabled' }, + }, + options, + deps, + ); + return 0; + } + const selection = await deps.resolveSelection({ + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + selector: policySelector(record.policy), + expectedTarget: record.target, + }); + const updatePolicy = policyResult(record); + if (selection.outcome.kind === 'manual_action') { + writeFrame( + { + schemaVersion: 1, + kind: 'result', + action: 'reconcile_update', + updatePolicy, + service: selection.service, + reconciliation: { + kind: 'manual_action', + candidate: { + version: selection.candidate.version, + integrity: selection.candidate.integrity, + }, + reason: selection.outcome.reason, + }, + }, + options, + deps, + ); + return 1; + } + + let terminal: ReconcileUpdateFrame | undefined; + const selectedOptions: RuntimeHostSelectedUpdateCliOptions = { + json: false, + framed: false, + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + selector: selection.selector, + expectedTarget: record.target, + }; + const exitCode = await deps.applySelection( + selectedOptions, + selection, + {}, + { + suppressPresentation: true, + onFrame: (frame) => { + const mapped = reconcileFrame(frame, updatePolicy); + writeFrame(mapped, options, deps); + if (mapped.kind !== 'progress') terminal = mapped; + }, + }, + ); + if (!terminal) { + throw new Error('The managed Runtime Host update did not return a terminal result'); + } + return exitCode; + }); + } catch (error) { + writeFrame(reconcileError(error), options, deps); + return 1; + } +} + +function reconciliationDeps( + overrides: Partial, +): RuntimeHostUpdateReconciliationDeps { + return { + withReconciliationLock: withRuntimeHostManagedServiceReconciliationLock, + readPolicy: readRuntimeHostManagedUpdatePolicy, + writePolicy: writeRuntimeHostManagedUpdatePolicy, + manage: manageRuntimeHostService, + createBackend: createPlatformRuntimeHostServiceBackend, + resolveSelection: resolveManagedRuntimeHostUpdateSelection, + applySelection: runManagedRuntimeHostResolvedUpdateCli, + writeOutput: (value) => process.stdout.write(value), + writeError: (value) => process.stderr.write(value), + ...overrides, + }; +} + +function updatePolicyResult( + record: RuntimeHostManagedUpdatePolicyRecord | null, +): UpdatePolicyFrame { + return { + schemaVersion: 1, + kind: 'result', + action: 'update_policy', + updatePolicy: policyResult(record), + }; +} + +function policyResult(record: RuntimeHostManagedUpdatePolicyRecord | null) { + return record + ? { policy: record.policy, target: record.target } + : { policy: { kind: 'manual' as const } }; +} + +function policySelector( + policy: RuntimeHostManagedUpdatePolicyRecord['policy'], +): RuntimeHostUpdateSelector { + return policy.kind === 'fixed' + ? { kind: 'exact', version: policy.version } + : { kind: 'channel', channel: policy.channel }; +} + +function reconcileFrame( + frame: RuntimeHostUpdateFrame, + updatePolicy: ReturnType, +): ReconcileUpdateFrame { + if (frame.kind === 'progress') { + return { ...frame, action: 'reconcile_update' }; + } + if (frame.kind === 'error') { + return { ...frame, action: 'reconcile_update' }; + } + return { + schemaVersion: 1, + kind: 'result', + action: 'reconcile_update', + updatePolicy, + service: frame.service, + reconciliation: frame.update, + }; +} + +function updatePolicyError(error: unknown): UpdatePolicyFrame { + return { + schemaVersion: 1, + kind: 'error', + action: 'update_policy', + error: boundedError(error, 'Unable to manage the Runtime Host update policy'), + }; +} + +function reconcileError(error: unknown): ReconcileUpdateFrame { + return { + schemaVersion: 1, + kind: 'error', + action: 'reconcile_update', + error: boundedError(error, 'Unable to reconcile the managed Runtime Host update'), + }; +} + +function boundedError(error: unknown, fallback: string): { code: string; message: string } { + const code = + error instanceof RuntimeHostUpdatePolicyError || + error instanceof RuntimeHostUpdateDiscoveryError || + error instanceof RuntimeHostServiceManagerError + ? error.code + : 'update_reconciliation_failed'; + const message = error instanceof Error ? error.message : String(error); + return { + code: + truncateUtf8(code, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES) || + 'update_reconciliation_failed', + message: truncateUtf8(message, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES) || fallback, + }; +} + +function writeFrame( + frame: UpdatePolicyFrame | ReconcileUpdateFrame, + options: { readonly json: boolean; readonly framed: boolean }, + deps: Pick, +): void { + if (options.framed) { + deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); + return; + } + if (options.json) { + deps.writeOutput(`${JSON.stringify(frame)}\n`); + return; + } + if (frame.kind === 'error') { + deps.writeError(`${frame.error.message}\n`); + return; + } + if (frame.kind === 'progress') { + deps.writeError( + `Reconciling Maka ${frame.currentVersion} toward ${frame.targetVersion} (${frame.phase})...\n`, + ); + return; + } + deps.writeOutput(`${humanResult(frame)}\n`); +} + +function humanResult(frame: Extract) { + if (frame.action === 'update_policy') { + const policy = frame.updatePolicy.policy; + return policy.kind === 'manual' + ? 'Managed Runtime Host updates are manual.' + : policy.kind === 'fixed' + ? `Managed Runtime Host updates are fixed at ${policy.version}.` + : `Managed Runtime Host updates follow the ${policy.channel} channel.`; + } + const result = frame.reconciliation; + if (result.kind === 'disabled') return 'Managed Runtime Host automatic updates are disabled.'; + if (result.kind === 'manual_action') { + return `Maka ${result.candidate.version} requires manual update action (${result.reason}).`; + } + if (result.kind === 'active_tasks') { + return 'The managed Runtime Host still owns active work; the update remains pending.'; + } + if (result.kind === 'already_current') { + return `The managed Runtime Host is current at Maka ${result.version}.`; + } + if (result.kind === 'repaired') { + return `The managed Runtime Host deployment for Maka ${result.version} was repaired.`; + } + return `The managed Runtime Host was updated from Maka ${result.previousVersion} to ${result.targetVersion}.`; +} diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index 9a4f83c791..b9ea599ef7 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -40,6 +40,7 @@ export { encodeRuntimeHostServiceManagementFrame, type RuntimeHostServiceManagementAction, type RuntimeHostServiceManagementFrame, + type RuntimeHostManagedUpdatePolicy, type RuntimeHostServiceUpdatePhase, type RuntimeHostOperatorCapability, type RuntimeHostServiceSummary, diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts index c857bef788..27e53ecc34 100644 --- a/packages/runtime-host/src/operator/service-management-frame.ts +++ b/packages/runtime-host/src/operator/service-management-frame.ts @@ -46,6 +46,8 @@ const SERVICE_ACTIONS = [ 'retire', 'check_update', 'update', + 'update_policy', + 'reconcile_update', 'logs', 'uninstall', ] as const; @@ -93,6 +95,23 @@ const boundedNonEmptyString = (maxBytes: number) => .refine((value) => Buffer.byteLength(value, 'utf8') <= maxBytes); const PRODUCT_RELEASE_VERSION_SCHEMA = z.string().refine(isProductReleaseVersion); const PACKAGE_INTEGRITY_SCHEMA = z.string().refine(isSha512PackageIntegrity); +const UPDATE_POLICY_SCHEMA = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('manual') }).strict(), + z + .object({ + kind: z.literal('fixed'), + version: PRODUCT_RELEASE_VERSION_SCHEMA, + }) + .strict(), + z.object({ kind: z.literal('channel'), channel: z.enum(UPDATE_CHANNELS) }).strict(), +]); +const MANAGED_SERVICE_TARGET_SCHEMA = z + .object({ + serviceId: z.string().regex(/^[a-f0-9]{64}$/u), + rootPath: boundedNonEmptyString(PATH_MAX_BYTES), + rootId: z.string().regex(/^[a-f0-9]{64}$/u), + }) + .strict(); const UPDATE_CHECK_SCHEMA = z .object({ @@ -150,6 +169,51 @@ const RETIREMENT_RESULT_SCHEMA = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('stopped') }).strict(), ]); +const UPDATE_RESULT_SCHEMA = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('already_current'), + version: boundedNonEmptyString(FIELD_MAX_BYTES), + }) + .strict(), + z + .object({ + kind: z.literal('active_tasks'), + currentVersion: boundedNonEmptyString(FIELD_MAX_BYTES), + targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES), + }) + .strict(), + z + .object({ + kind: z.literal('updated'), + previousVersion: boundedNonEmptyString(FIELD_MAX_BYTES), + targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES), + }) + .strict(), + z + .object({ + kind: z.literal('repaired'), + version: boundedNonEmptyString(FIELD_MAX_BYTES), + }) + .strict(), +]); + +const UPDATE_POLICY_RESULT_SCHEMA = z + .object({ + policy: UPDATE_POLICY_SCHEMA, + target: MANAGED_SERVICE_TARGET_SCHEMA.optional(), + }) + .strict() + .superRefine((value, context) => { + if ((value.policy.kind === 'manual') !== (value.target === undefined)) { + context.addIssue({ + code: 'custom', + path: ['target'], + message: 'Only an automatic update policy has a managed service target', + }); + } + }); + const SERVICE_SUMMARY_SCHEMA = z .object({ platform: boundedNonEmptyString(FIELD_MAX_BYTES), @@ -203,6 +267,16 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES), }) .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('progress'), + action: z.literal('reconcile_update'), + phase: z.enum(UPDATE_PHASES), + currentVersion: boundedNonEmptyString(FIELD_MAX_BYTES), + targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES), + }) + .strict(), z .object({ ...SERVICE_RESULT_COMMON, @@ -237,36 +311,51 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ .object({ ...SERVICE_RESULT_COMMON, action: z.literal('update'), - update: z.discriminatedUnion('kind', [ - z - .object({ - kind: z.literal('already_current'), - version: boundedNonEmptyString(FIELD_MAX_BYTES), - }) - .strict(), - z - .object({ - kind: z.literal('active_tasks'), - currentVersion: boundedNonEmptyString(FIELD_MAX_BYTES), - targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES), - }) - .strict(), - z - .object({ - kind: z.literal('updated'), - previousVersion: boundedNonEmptyString(FIELD_MAX_BYTES), - targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES), - }) - .strict(), + update: UPDATE_RESULT_SCHEMA, + }) + .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('result'), + action: z.literal('update_policy'), + updatePolicy: UPDATE_POLICY_RESULT_SCHEMA, + }) + .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('result'), + action: z.literal('reconcile_update'), + updatePolicy: UPDATE_POLICY_RESULT_SCHEMA, + service: SERVICE_SUMMARY_SCHEMA.optional(), + reconciliation: z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('disabled') }).strict(), z .object({ - kind: z.literal('repaired'), - version: boundedNonEmptyString(FIELD_MAX_BYTES), + kind: z.literal('manual_action'), + candidate: z + .object({ + version: PRODUCT_RELEASE_VERSION_SCHEMA, + integrity: PACKAGE_INTEGRITY_SCHEMA, + }) + .strict(), + reason: z.enum(MANUAL_ACTION_REASONS), }) .strict(), + ...UPDATE_RESULT_SCHEMA.options, ]), }) - .strict(), + .strict() + .superRefine((frame, context) => { + if ((frame.reconciliation.kind === 'disabled') !== (frame.service === undefined)) { + context.addIssue({ + code: 'custom', + path: ['service'], + message: 'Only disabled reconciliation omits the managed service summary', + }); + } + }), z .object({ ...SERVICE_RESULT_COMMON, @@ -288,6 +377,22 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ error: SERVICE_ERROR_SCHEMA, }) .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('error'), + action: z.literal('reconcile_update'), + error: SERVICE_ERROR_SCHEMA, + }) + .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('error'), + action: z.literal('update_policy'), + error: SERVICE_ERROR_SCHEMA, + }) + .strict(), z .object({ schemaVersion: z.literal(1), @@ -301,6 +406,7 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ export type RuntimeHostServiceManagementAction = (typeof SERVICE_ACTIONS)[number]; export type RuntimeHostServiceManagementFrame = z.infer; export type RuntimeHostServiceUpdatePhase = (typeof UPDATE_PHASES)[number]; +export type RuntimeHostManagedUpdatePolicy = z.infer; export type RuntimeHostOperatorCapability = (typeof OPERATOR_CAPABILITIES)[number]; export type RuntimeHostServiceSummary = z.infer; From 841b4918e9683279e5cd4fcd4c008cdea6d01fad Mon Sep 17 00:00:00 2001 From: Wang Date: Mon, 24 Aug 2026 22:49:53 +0800 Subject: [PATCH 2/5] fix(runtime-host): tighten update reconciliation boundaries Make policy removal crash-durable and revoke unattended update authority before destructive uninstall work begins. Reuse the deployment lock for policy mutations and route update frames through one sink so JSON reconciliation emits one terminal document. Generated-by: Codex --- ...runtime-host-update-reconciliation.test.ts | 47 +++--- ...runtime-host-service-management-command.ts | 8 +- .../cli/src/runtime-host-service-manager.ts | 14 -- .../cli/src/runtime-host-update-command.ts | 58 +++---- .../src/runtime-host-update-policy-store.ts | 32 ++-- .../src/runtime-host-update-reconciliation.ts | 150 +++++++++--------- 6 files changed, 136 insertions(+), 173 deletions(-) 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 277c4db701..f90fa0574b 100644 --- a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts @@ -107,7 +107,7 @@ describe('managed Runtime Host update reconciliation', () => { expectedTarget: TARGET, }, { - withReconciliationLock: async (_root, operation) => operation(), + withDeploymentLock: async (_root, operation) => operation(), createBackend: () => unusedBackend(), manage: async () => ({ schemaVersion: 1, @@ -149,7 +149,7 @@ describe('managed Runtime Host update reconciliation', () => { await runManagedRuntimeHostUpdatePolicyCli( { ...common, policy: { kind: 'manual' } }, { - withReconciliationLock: async (_root, operation) => operation(), + withDeploymentLock: async (_root, operation) => operation(), writeOutput: () => undefined, }, ), @@ -161,7 +161,6 @@ describe('managed Runtime Host update reconciliation', () => { await runManagedRuntimeHostUpdateReconcileCli( { ...common, json: false, framed: true }, { - withReconciliationLock: async (_root, operation) => operation(), resolveSelection: async () => assert.fail('manual policy must not resolve a target'), writeOutput: (value) => { manualOutput += value; @@ -197,7 +196,6 @@ describe('managed Runtime Host update reconciliation', () => { defaultRootPath: '/workspace', }, { - withReconciliationLock: async (_root, operation) => operation(), resolveSelection: async (options) => { assert.deepEqual(options.selector, { kind: 'exact', version: '2.0.0' }); assert.deepEqual(options.expectedTarget, TARGET); @@ -209,9 +207,17 @@ describe('managed Runtime Host update reconciliation', () => { service: SERVICE, }; }, - applySelection: async (_options, _selection, _overrides, observer) => { + applySelection: async (_options, _selection, _overrides, emit) => { applied = true; - observer?.onFrame?.({ + emit?.({ + schemaVersion: 1, + kind: 'progress', + action: 'update', + phase: 'checking', + currentVersion: '1.0.0', + targetVersion: '2.0.0', + }); + emit?.({ schemaVersion: 1, kind: 'result', action: 'update', @@ -250,7 +256,6 @@ describe('managed Runtime Host update reconciliation', () => { await runManagedRuntimeHostUpdateReconcileCli( { json: true, framed: false, clientDataRoot, defaultRootPath: '/workspace' }, { - withReconciliationLock: async (_root, operation) => operation(), writeOutput: (value) => { errorOutput += value; }, @@ -270,7 +275,6 @@ describe('managed Runtime Host update reconciliation', () => { await runManagedRuntimeHostUpdateReconcileCli( { json: true, framed: false, clientDataRoot, defaultRootPath: '/workspace' }, { - withReconciliationLock: async (_root, operation) => operation(), resolveSelection: async (options) => ({ selector: options.selector, candidate: { version: '2.0.0', integrity: INTEGRITY }, @@ -289,7 +293,7 @@ describe('managed Runtime Host update reconciliation', () => { assert.equal(JSON.parse(output).reconciliation.kind, 'manual_action'); }); - it('removes installation-owned policy when the managed service is uninstalled', async (t) => { + it('keeps automatic policy revoked when uninstall later fails', async (t) => { const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-uninstall-')); t.after(() => rm(clientDataRoot, { recursive: true, force: true })); await writeRuntimeHostManagedUpdatePolicy(clientDataRoot, { @@ -309,30 +313,17 @@ describe('managed Runtime Host update reconciliation', () => { cliPath: '/cli.js', }, { - withReconciliationLock: immediate, withDeploymentLock: immediate, withLifecycleLock: immediate, createBackend: () => unusedBackend(), - manage: async () => ({ - schemaVersion: 1, - action: 'uninstall', - service: { - manager: 'systemd_user', - installed: false, - enabled: false, - active: false, - state: 'not_installed', - pid: null, - lastExitCode: null, - installedVersion: null, - config: null, - }, - retainedStateRoot: '/srv/maka', - }), - writeOutput: () => undefined, + manage: async () => { + assert.equal(await readRuntimeHostManagedUpdatePolicy(clientDataRoot), null); + throw new Error('uninstall failed after policy revocation'); + }, + writeError: () => undefined, }, ), - 0, + 1, ); assert.equal(await readRuntimeHostManagedUpdatePolicy(clientDataRoot), null); }); diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts index 026ab303fd..e848ede4bc 100644 --- a/packages/cli/src/runtime-host-service-management-command.ts +++ b/packages/cli/src/runtime-host-service-management-command.ts @@ -37,7 +37,6 @@ import { RuntimeHostServiceManagerError, withRuntimeHostManagedServiceDeploymentLock, withRuntimeHostManagedServiceLifecycleLock, - withRuntimeHostManagedServiceReconciliationLock, type RuntimeHostManagedServiceInput, type RuntimeHostManagedServiceResult, type RuntimeHostManagedServiceTarget, @@ -58,7 +57,6 @@ export interface RuntimeHostServiceManagementCliDeps { readonly manage: typeof manageRuntimeHostService; readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock; readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock; - readonly withReconciliationLock: typeof withRuntimeHostManagedServiceReconciliationLock; readonly clearUpdatePolicy: (clientDataRoot: string) => Promise; readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend; readonly writeOutput: (value: string) => unknown; @@ -73,7 +71,6 @@ export async function runManagedRuntimeHostServiceCli( manage: manageRuntimeHostService, withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock, withLifecycleLock: withRuntimeHostManagedServiceLifecycleLock, - withReconciliationLock: withRuntimeHostManagedServiceReconciliationLock, clearUpdatePolicy: (clientDataRoot) => writeRuntimeHostManagedUpdatePolicy(clientDataRoot, null), createBackend: createPlatformRuntimeHostServiceBackend, @@ -95,10 +92,9 @@ export async function runManagedRuntimeHostServiceCli( : options.action === 'retire' ? await deps.withLifecycleLock(options.clientDataRoot, manage) : options.action === 'uninstall' - ? await deps.withReconciliationLock(options.clientDataRoot, async () => { - const uninstalled = await mutate(); + ? await deps.withDeploymentLock(options.clientDataRoot, async () => { await deps.clearUpdatePolicy(options.clientDataRoot); - return uninstalled; + return await deps.withLifecycleLock(options.clientDataRoot, manage); }) : await mutate(); const blocked = result.action === 'retire' && result.retirement.kind === 'active_tasks'; diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 3fe500dc19..4444b43a4f 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -53,7 +53,6 @@ import { const SERVICE_CONFIG_FILE = 'runtime-host-service.json'; const SERVICE_LIFECYCLE_LOCK_FILE = 'runtime-host-setup'; const SERVICE_DEPLOYMENT_LOCK_FILE = 'runtime-host-deployment'; -const SERVICE_RECONCILIATION_LOCK_FILE = 'runtime-host-reconciliation'; const DEFAULT_WEBSOCKET_PATH = '/runtime-host'; const SERVICE_OPERATION_LOCK_TIMEOUT_MS = 60_000; const SERVICE_READY_TIMEOUT_MS = 45_000; @@ -280,19 +279,6 @@ export async function withRuntimeHostManagedServiceDeploymentLock( ); } -export async function withRuntimeHostManagedServiceReconciliationLock( - clientDataRoot: string, - operation: () => Promise, - timeoutMs = SERVICE_OPERATION_LOCK_TIMEOUT_MS, -): Promise { - await mkdir(clientDataRoot, { recursive: true, mode: 0o700 }); - return withProcessLifetimeFileUpdateLock( - join(clientDataRoot, SERVICE_RECONCILIATION_LOCK_FILE), - operation, - timeoutMs, - ); -} - export async function withRuntimeHostManagedServiceLegacyOperatorLeases( clientDataRoot: string, operation: (inheritedFds: readonly number[]) => Promise, diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 2319a4b819..fab650d982 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -128,10 +128,7 @@ export type RuntimeHostUpdateFrame = Extract< { action: 'update' } >; -export interface RuntimeHostUpdateCliObserver { - readonly onFrame?: (frame: RuntimeHostUpdateFrame) => void; - readonly suppressPresentation?: boolean; -} +export type RuntimeHostUpdateFrameSink = (frame: RuntimeHostUpdateFrame) => void; interface RuntimeHostOperatorInvocation { readonly inheritedFds?: readonly number[]; @@ -141,7 +138,7 @@ interface RuntimeHostOperatorInvocation { export async function runManagedRuntimeHostUpdateCli( options: RuntimeHostUpdateCliOptions, overrides: Partial = {}, - observer: RuntimeHostUpdateCliObserver = {}, + frameSink?: RuntimeHostUpdateFrameSink, ): Promise { const deps: RuntimeHostUpdateCliDeps = { manage: manageRuntimeHostService, @@ -162,26 +159,8 @@ export async function runManagedRuntimeHostUpdateCli( let exactTargetObserved = false; let cutoverStarted = false; let retired = false; - const emit = (frame: RuntimeHostUpdateFrame): void => { - observer.onFrame?.(frame); - if (observer.suppressPresentation) return; - if (options.framed) { - deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); - return; - } - if (frame.kind === 'progress') { - if (!options.json) deps.writeError(`${humanPhase(frame.phase)}\n`); - return; - } - if (options.json) deps.writeOutput(`${JSON.stringify(frame)}\n`); - else if (frame.kind === 'error') deps.writeError(`${frame.error.message}\n`); - else { - if (frame.action !== 'update') { - throw new TypeError('Managed Runtime Host update returned an unrelated result'); - } - deps.writeOutput(`${humanResult(frame)}\n`); - } - }; + const emit = + frameSink ?? ((frame: RuntimeHostUpdateFrame) => presentUpdateFrame(frame, options, deps)); try { return await deps.withDeploymentLock(options.clientDataRoot, async () => { try { @@ -505,7 +484,7 @@ export async function runManagedRuntimeHostUpdateCli( export async function runManagedRuntimeHostSelectedUpdateCli( options: RuntimeHostSelectedUpdateCliOptions, overrides: Partial = {}, - observer: RuntimeHostUpdateCliObserver = {}, + frameSink?: RuntimeHostUpdateFrameSink, ): Promise { const deps: RuntimeHostSelectedUpdateCliDeps = { resolveSelection: resolveManagedRuntimeHostUpdateSelection, @@ -515,6 +494,8 @@ export async function runManagedRuntimeHostSelectedUpdateCli( writeError: (value) => process.stderr.write(value), ...overrides, }; + const emit = + frameSink ?? ((frame: RuntimeHostUpdateFrame) => presentUpdateFrame(frame, options, deps)); try { const selection = await deps.resolveSelection({ @@ -523,7 +504,7 @@ export async function runManagedRuntimeHostSelectedUpdateCli( selector: options.selector, expectedTarget: options.expectedTarget, }); - return await runManagedRuntimeHostResolvedUpdateCli(options, selection, deps, observer); + return await runManagedRuntimeHostResolvedUpdateCli(options, selection, deps, emit); } catch (error) { const code = error instanceof RuntimeHostUpdateDiscoveryError || @@ -532,7 +513,7 @@ export async function runManagedRuntimeHostSelectedUpdateCli( ? error.code : 'update_resolution_failed'; const message = error instanceof Error ? error.message : String(error); - emitSelectedUpdateTerminal(options, deps, observer, { + emit({ schemaVersion: 1, kind: 'error', action: 'update', @@ -553,7 +534,7 @@ export async function runManagedRuntimeHostResolvedUpdateCli( options: RuntimeHostSelectedUpdateCliOptions, selection: RuntimeHostUpdateSelection, overrides: Partial = {}, - observer: RuntimeHostUpdateCliObserver = {}, + frameSink?: RuntimeHostUpdateFrameSink, ): Promise { const deps: RuntimeHostResolvedUpdateCliDeps = { withPackage: withRuntimeHostRegistryUpdatePackage, @@ -562,10 +543,12 @@ export async function runManagedRuntimeHostResolvedUpdateCli( writeError: (value) => process.stderr.write(value), ...overrides, }; + const emit = + frameSink ?? ((frame: RuntimeHostUpdateFrame) => presentUpdateFrame(frame, options, deps)); try { if (selection.outcome.kind === 'manual_action') { - emitSelectedUpdateTerminal(options, deps, observer, { + emit({ schemaVersion: 1, kind: 'error', action: 'update', @@ -596,7 +579,7 @@ export async function runManagedRuntimeHostResolvedUpdateCli( }, }, {}, - observer, + emit, ); }; return selection.outcome.kind === 'current' @@ -610,7 +593,7 @@ export async function runManagedRuntimeHostResolvedUpdateCli( ? error.code : 'update_resolution_failed'; const message = error instanceof Error ? error.message : String(error); - emitSelectedUpdateTerminal(options, deps, observer, { + emit({ schemaVersion: 1, kind: 'error', action: 'update', @@ -627,16 +610,15 @@ export async function runManagedRuntimeHostResolvedUpdateCli( } } -function emitSelectedUpdateTerminal( - options: RuntimeHostSelectedUpdateCliOptions, +function presentUpdateFrame( + frame: RuntimeHostUpdateFrame, + options: Pick, deps: Pick, - observer: RuntimeHostUpdateCliObserver, - frame: Exclude, ): void { - observer.onFrame?.(frame); - if (observer.suppressPresentation) return; if (options.framed) { deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); + } else if (frame.kind === 'progress') { + if (!options.json) deps.writeError(`${humanPhase(frame.phase)}\n`); } else if (options.json) { deps.writeOutput(`${JSON.stringify(frame)}\n`); } else if (frame.kind === 'error') { diff --git a/packages/cli/src/runtime-host-update-policy-store.ts b/packages/cli/src/runtime-host-update-policy-store.ts index e637972d64..7b252ed6b7 100644 --- a/packages/cli/src/runtime-host-update-policy-store.ts +++ b/packages/cli/src/runtime-host-update-policy-store.ts @@ -18,7 +18,7 @@ */ import { randomUUID } from 'node:crypto'; -import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { mkdir, open, readFile, rename, rm, unlink } from 'node:fs/promises'; import { dirname, isAbsolute, join } from 'node:path'; import { isProductReleaseVersion, @@ -90,15 +90,25 @@ export async function writeRuntimeHostManagedUpdatePolicy( const path = resolveRuntimeHostManagedUpdatePolicyPath(clientDataRoot); if (record === null) { try { - await rm(path, { force: true }); - return; + await unlink(path); } catch (error) { + if (isNodeError(error, 'ENOENT')) return; throw new RuntimeHostUpdatePolicyError( 'update_policy_write_failed', `Unable to remove the managed Runtime Host update policy at ${path}`, { cause: error }, ); } + try { + await syncDirectory(dirname(path)); + return; + } catch (error) { + throw new RuntimeHostUpdatePolicyError( + 'update_policy_write_failed', + `Unable to persist removal of the managed Runtime Host update policy at ${path}`, + { cause: error }, + ); + } } assertUpdatePolicyRecord(record); const directory = dirname(path); @@ -113,12 +123,7 @@ export async function writeRuntimeHostManagedUpdatePolicy( await file.close(); } await rename(temporaryPath, path); - const parent = await open(directory, 'r'); - try { - await parent.sync(); - } finally { - await parent.close(); - } + await syncDirectory(directory); } catch (error) { throw new RuntimeHostUpdatePolicyError( 'update_policy_write_failed', @@ -130,6 +135,15 @@ export async function writeRuntimeHostManagedUpdatePolicy( } } +async function syncDirectory(path: string): Promise { + const directory = await open(path, 'r'); + try { + await directory.sync(); + } finally { + await directory.close(); + } +} + function assertUpdatePolicyRecord( value: unknown, ): asserts value is RuntimeHostManagedUpdatePolicyRecord { diff --git a/packages/cli/src/runtime-host-update-reconciliation.ts b/packages/cli/src/runtime-host-update-reconciliation.ts index 179393b141..0c7fa7dd27 100644 --- a/packages/cli/src/runtime-host-update-reconciliation.ts +++ b/packages/cli/src/runtime-host-update-reconciliation.ts @@ -29,7 +29,7 @@ import { manageRuntimeHostService, resolveRuntimeHostManagedServiceId, RuntimeHostServiceManagerError, - withRuntimeHostManagedServiceReconciliationLock, + withRuntimeHostManagedServiceDeploymentLock, type RuntimeHostManagedServiceTarget, type RuntimeHostServiceBackend, } from './runtime-host-service-manager.js'; @@ -77,7 +77,7 @@ interface RuntimeHostUpdateReconcileCliOptions { } interface RuntimeHostUpdateReconciliationDeps { - readonly withReconciliationLock: typeof withRuntimeHostManagedServiceReconciliationLock; + readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock; readonly readPolicy: typeof readRuntimeHostManagedUpdatePolicy; readonly writePolicy: typeof writeRuntimeHostManagedUpdatePolicy; readonly manage: typeof manageRuntimeHostService; @@ -96,7 +96,7 @@ export async function runManagedRuntimeHostUpdatePolicyCli( try { const requestedPolicy = options.policy; const record = requestedPolicy - ? await deps.withReconciliationLock(options.clientDataRoot, async () => { + ? await deps.withDeploymentLock(options.clientDataRoot, async () => { if (requestedPolicy.kind === 'manual') { await deps.writePolicy(options.clientDataRoot, null); return null; @@ -151,79 +151,69 @@ export async function runManagedRuntimeHostUpdateReconcileCli( ): Promise { const deps = reconciliationDeps(overrides); try { - return await deps.withReconciliationLock(options.clientDataRoot, async () => { - const record = await deps.readPolicy(options.clientDataRoot); - if (!record) { - writeFrame( - { - schemaVersion: 1, - kind: 'result', - action: 'reconcile_update', - updatePolicy: { policy: { kind: 'manual' } }, - reconciliation: { kind: 'disabled' }, - }, - options, - deps, - ); - return 0; - } - const selection = await deps.resolveSelection({ - clientDataRoot: options.clientDataRoot, - defaultRootPath: options.defaultRootPath, - selector: policySelector(record.policy), - expectedTarget: record.target, - }); - const updatePolicy = policyResult(record); - if (selection.outcome.kind === 'manual_action') { - writeFrame( - { - schemaVersion: 1, - kind: 'result', - action: 'reconcile_update', - updatePolicy, - service: selection.service, - reconciliation: { - kind: 'manual_action', - candidate: { - version: selection.candidate.version, - integrity: selection.candidate.integrity, - }, - reason: selection.outcome.reason, - }, - }, - options, - deps, - ); - return 1; - } - - let terminal: ReconcileUpdateFrame | undefined; - const selectedOptions: RuntimeHostSelectedUpdateCliOptions = { - json: false, - framed: false, - clientDataRoot: options.clientDataRoot, - defaultRootPath: options.defaultRootPath, - selector: selection.selector, - expectedTarget: record.target, - }; - const exitCode = await deps.applySelection( - selectedOptions, - selection, - {}, + const record = await deps.readPolicy(options.clientDataRoot); + if (!record) { + writeFrame( + { + schemaVersion: 1, + kind: 'result', + action: 'reconcile_update', + updatePolicy: { policy: { kind: 'manual' } }, + reconciliation: { kind: 'disabled' }, + }, + options, + deps, + ); + return 0; + } + const selection = await deps.resolveSelection({ + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + selector: policySelector(record.policy), + expectedTarget: record.target, + }); + const updatePolicy = policyResult(record); + if (selection.outcome.kind === 'manual_action') { + writeFrame( { - suppressPresentation: true, - onFrame: (frame) => { - const mapped = reconcileFrame(frame, updatePolicy); - writeFrame(mapped, options, deps); - if (mapped.kind !== 'progress') terminal = mapped; + schemaVersion: 1, + kind: 'result', + action: 'reconcile_update', + updatePolicy, + service: selection.service, + reconciliation: { + kind: 'manual_action', + candidate: { + version: selection.candidate.version, + integrity: selection.candidate.integrity, + }, + reason: selection.outcome.reason, }, }, + options, + deps, ); - if (!terminal) { - throw new Error('The managed Runtime Host update did not return a terminal result'); - } - return exitCode; + return 1; + } + + let terminal: ReconcileUpdateFrame | undefined; + const selectedOptions: RuntimeHostSelectedUpdateCliOptions = { + json: false, + framed: false, + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + selector: selection.selector, + expectedTarget: record.target, + }; + const exitCode = await deps.applySelection(selectedOptions, selection, {}, (frame) => { + const mapped = reconcileFrame(frame, updatePolicy); + writeFrame(mapped, options, deps); + if (mapped.kind !== 'progress') terminal = mapped; }); + if (!terminal) { + throw new Error('The managed Runtime Host update did not return a terminal result'); + } + return exitCode; } catch (error) { writeFrame(reconcileError(error), options, deps); return 1; @@ -234,7 +224,7 @@ function reconciliationDeps( overrides: Partial, ): RuntimeHostUpdateReconciliationDeps { return { - withReconciliationLock: withRuntimeHostManagedServiceReconciliationLock, + withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock, readPolicy: readRuntimeHostManagedUpdatePolicy, writePolicy: writeRuntimeHostManagedUpdatePolicy, manage: manageRuntimeHostService, @@ -331,6 +321,16 @@ function writeFrame( options: { readonly json: boolean; readonly framed: boolean }, deps: Pick, ): void { + if (frame.kind === 'progress') { + if (options.framed) { + deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); + } else if (!options.json) { + deps.writeError( + `Reconciling Maka ${frame.currentVersion} toward ${frame.targetVersion} (${frame.phase})...\n`, + ); + } + return; + } if (options.framed) { deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); return; @@ -343,12 +343,6 @@ function writeFrame( deps.writeError(`${frame.error.message}\n`); return; } - if (frame.kind === 'progress') { - deps.writeError( - `Reconciling Maka ${frame.currentVersion} toward ${frame.targetVersion} (${frame.phase})...\n`, - ); - return; - } deps.writeOutput(`${humanResult(frame)}\n`); } From 2eb3b1725070f8124ab9dad382c04461cd2e01b0 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 09:06:00 +0800 Subject: [PATCH 3/5] fix(runtime-host): fence stale update reconciliation Revalidate the selected managed-update policy inside the deployment lock before reading or mutating service state. This lets a newer manual or fixed policy revoke an in-flight unattended reconciliation without introducing another lock or scheduler state machine. Generated-by: Codex --- .../runtime-host-selected-update.test.ts | 36 +++++++++++++ ...runtime-host-update-reconciliation.test.ts | 48 ++++++++++++++++- .../cli/src/runtime-host-update-command.ts | 29 +++++++++- .../src/runtime-host-update-reconciliation.ts | 53 +++++++++++++++++-- 4 files changed, 158 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts index 0c9002a9d6..b758b83a54 100644 --- a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { decodeRuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator'; import { + runManagedRuntimeHostUpdateCli, runManagedRuntimeHostSelectedUpdateCli, type RuntimeHostSelectedUpdateCliOptions, type RuntimeHostUpdateCliOptions, @@ -45,6 +46,41 @@ const OPTIONS: RuntimeHostSelectedUpdateCliOptions = { }; describe('managed Runtime Host selected update', () => { + it('revalidates selection inside the deployment lock before reading service state', async () => { + let lockHeld = false; + let output = ''; + assert.equal( + await runManagedRuntimeHostUpdateCli( + { + ...OPTIONS, + sourcePackageRoot: '/verified/package', + version: '2.0.0', + }, + { + withDeploymentLock: async (_root, operation) => { + lockHeld = true; + try { + return await operation(); + } finally { + lockHeld = false; + } + }, + revalidateSelection: async () => { + assert.equal(lockHeld, true); + return { code: 'update_policy_changed', message: 'The policy changed' }; + }, + manage: async () => assert.fail('service state must not be read'), + writeOutput: (value) => { + output += value; + }, + }, + ), + 1, + ); + const frame = decodeRuntimeHostServiceManagementFrame(output.trim()); + assert.equal(frame?.kind === 'error' ? frame.error.code : undefined, 'update_policy_changed'); + }); + it('parses an optional target without changing the exact-package command', () => { assert.deepEqual( parseRuntimeHostCommand([ 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 f90fa0574b..b511791701 100644 --- a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts @@ -207,7 +207,8 @@ describe('managed Runtime Host update reconciliation', () => { service: SERVICE, }; }, - applySelection: async (_options, _selection, _overrides, emit) => { + applySelection: async (_options, _selection, overrides, emit) => { + assert.equal(await overrides?.revalidateSelection?.(), undefined); applied = true; emit?.({ schemaVersion: 1, @@ -247,6 +248,51 @@ describe('managed Runtime Host update reconciliation', () => { ); }); + it('revokes a stale reconcile when automatic policy changes to manual', async (t) => { + const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-policy-race-')); + t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + await writeRuntimeHostManagedUpdatePolicy(clientDataRoot, { + schemaVersion: 1, + policy: { kind: 'channel', channel: 'latest' }, + target: TARGET, + }); + let output = ''; + assert.equal( + await runManagedRuntimeHostUpdateReconcileCli( + { json: true, framed: false, clientDataRoot, defaultRootPath: '/workspace' }, + { + resolveSelection: async (options) => { + await writeRuntimeHostManagedUpdatePolicy(clientDataRoot, null); + return { + selector: options.selector, + candidate: { version: '2.0.0', integrity: INTEGRITY, compatibility: 1 }, + outcome: { kind: 'unattended_update', compatibility: 1 }, + currentCliPath: '/managed/current/cli.js', + service: SERVICE, + }; + }, + applySelection: async (_options, _selection, overrides, emit) => { + const rejection = await overrides?.revalidateSelection?.(); + assert.equal(rejection?.code, 'update_policy_changed'); + emit?.({ + schemaVersion: 1, + kind: 'error', + action: 'update', + error: rejection ?? assert.fail('policy revalidation is required'), + }); + return 1; + }, + writeOutput: (value) => { + output += value; + }, + }, + ), + 1, + ); + assert.equal(JSON.parse(output).error.code, 'update_policy_changed'); + assert.equal(await readRuntimeHostManagedUpdatePolicy(clientDataRoot), null); + }); + it('fails closed on corrupt policy and returns manual-action candidates without mutation', async (t) => { const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-reconcile-')); t.after(() => rm(clientDataRoot, { recursive: true, force: true })); diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index fab650d982..ac8354a5ae 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -94,6 +94,7 @@ export interface RuntimeHostSelectedUpdateCliOptions } interface RuntimeHostUpdateCliDeps { + readonly revalidateSelection: () => Promise; readonly manage: typeof manageRuntimeHostService; readonly replace: typeof replaceRuntimeHostManagedService; readonly openDeployment: typeof openRuntimeHostManagedPackageDeployment; @@ -113,6 +114,7 @@ interface RuntimeHostUpdateCliDeps { } interface RuntimeHostResolvedUpdateCliDeps { + readonly revalidateSelection: () => Promise; readonly withPackage: typeof withRuntimeHostRegistryUpdatePackage; readonly update: typeof runManagedRuntimeHostUpdateCli; readonly writeOutput: (value: string) => unknown; @@ -135,12 +137,28 @@ interface RuntimeHostOperatorInvocation { readonly capabilityRequest?: RuntimeHostOperatorCapability; } +interface RuntimeHostUpdateSelectionRejection { + readonly code: string; + readonly message: string; +} + +class RuntimeHostUpdateSelectionError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = 'RuntimeHostUpdateSelectionError'; + } +} + export async function runManagedRuntimeHostUpdateCli( options: RuntimeHostUpdateCliOptions, overrides: Partial = {}, frameSink?: RuntimeHostUpdateFrameSink, ): Promise { const deps: RuntimeHostUpdateCliDeps = { + revalidateSelection: async () => undefined, manage: manageRuntimeHostService, replace: replaceRuntimeHostManagedService, openDeployment: openRuntimeHostManagedPackageDeployment, @@ -164,6 +182,10 @@ export async function runManagedRuntimeHostUpdateCli( try { return await deps.withDeploymentLock(options.clientDataRoot, async () => { try { + const rejection = await deps.revalidateSelection(); + if (rejection) { + throw new RuntimeHostUpdateSelectionError(rejection.code, rejection.message); + } const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); if (serviceId !== options.expectedTarget.serviceId) { throw new RuntimeHostServiceManagerError( @@ -461,7 +483,8 @@ export async function runManagedRuntimeHostUpdateCli( : error; const code = reportedError instanceof RuntimeHostServiceManagerError || - reportedError instanceof RuntimeHostManagedDeploymentError + reportedError instanceof RuntimeHostManagedDeploymentError || + reportedError instanceof RuntimeHostUpdateSelectionError ? reportedError.code : 'internal_service_error'; const message = reportedError instanceof Error ? reportedError.message : String(reportedError); @@ -487,6 +510,7 @@ export async function runManagedRuntimeHostSelectedUpdateCli( frameSink?: RuntimeHostUpdateFrameSink, ): Promise { const deps: RuntimeHostSelectedUpdateCliDeps = { + revalidateSelection: async () => undefined, resolveSelection: resolveManagedRuntimeHostUpdateSelection, withPackage: withRuntimeHostRegistryUpdatePackage, update: runManagedRuntimeHostUpdateCli, @@ -537,6 +561,7 @@ export async function runManagedRuntimeHostResolvedUpdateCli( frameSink?: RuntimeHostUpdateFrameSink, ): Promise { const deps: RuntimeHostResolvedUpdateCliDeps = { + revalidateSelection: async () => undefined, withPackage: withRuntimeHostRegistryUpdatePackage, update: runManagedRuntimeHostUpdateCli, writeOutput: (value) => process.stdout.write(value), @@ -578,7 +603,7 @@ export async function runManagedRuntimeHostResolvedUpdateCli( }, }, }, - {}, + { revalidateSelection: deps.revalidateSelection }, emit, ); }; diff --git a/packages/cli/src/runtime-host-update-reconciliation.ts b/packages/cli/src/runtime-host-update-reconciliation.ts index 0c7fa7dd27..eaa0632888 100644 --- a/packages/cli/src/runtime-host-update-reconciliation.ts +++ b/packages/cli/src/runtime-host-update-reconciliation.ts @@ -205,11 +205,33 @@ export async function runManagedRuntimeHostUpdateReconcileCli( selector: selection.selector, expectedTarget: record.target, }; - const exitCode = await deps.applySelection(selectedOptions, selection, {}, (frame) => { - const mapped = reconcileFrame(frame, updatePolicy); - writeFrame(mapped, options, deps); - if (mapped.kind !== 'progress') terminal = mapped; - }); + const exitCode = await deps.applySelection( + selectedOptions, + selection, + { + revalidateSelection: async () => { + let current: RuntimeHostManagedUpdatePolicyRecord | null; + try { + current = await deps.readPolicy(options.clientDataRoot); + } catch (error) { + return boundedError(error, 'Unable to revalidate the managed update policy'); + } + if (!sameUpdatePolicyRecord(current, record)) { + return { + code: 'update_policy_changed', + message: + 'The managed Runtime Host update policy changed while its candidate was prepared; reconciliation made no changes', + }; + } + return undefined; + }, + }, + (frame) => { + const mapped = reconcileFrame(frame, updatePolicy); + writeFrame(mapped, options, deps); + if (mapped.kind !== 'progress') terminal = mapped; + }, + ); if (!terminal) { throw new Error('The managed Runtime Host update did not return a terminal result'); } @@ -262,6 +284,27 @@ function policySelector( : { kind: 'channel', channel: policy.channel }; } +function sameUpdatePolicyRecord( + left: RuntimeHostManagedUpdatePolicyRecord | null, + right: RuntimeHostManagedUpdatePolicyRecord | null, +): boolean { + if (!left || !right) return left === right; + if ( + left.schemaVersion !== right.schemaVersion || + left.target.serviceId !== right.target.serviceId || + left.target.rootPath !== right.target.rootPath || + left.target.rootId !== right.target.rootId || + left.policy.kind !== right.policy.kind + ) { + return false; + } + return left.policy.kind === 'fixed' && right.policy.kind === 'fixed' + ? left.policy.version === right.policy.version + : left.policy.kind === 'channel' && + right.policy.kind === 'channel' && + left.policy.channel === right.policy.channel; +} + function reconcileFrame( frame: RuntimeHostUpdateFrame, updatePolicy: ReturnType, From d0ac9d06831f3b8f0fc75be2acf918acb7bb5bfc Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 10:03:24 +0800 Subject: [PATCH 4/5] fix(runtime-host): bind update policy lifecycle Store automatic update authority with the managed deployment so uninstall and legacy cleanup remove it together. Validate the selected target before revocation, preserve truthful commit uncertainty, and keep reconciliation on one output and equality path. Generated-by: Codex --- .../runtime-host-service-manager.test.ts | 24 +++ ...runtime-host-update-reconciliation.test.ts | 157 ++++++++++-------- ...runtime-host-service-management-command.ts | 11 +- .../cli/src/runtime-host-service-manager.ts | 4 + .../cli/src/runtime-host-update-command.ts | 20 +-- .../src/runtime-host-update-policy-store.ts | 62 ++++--- .../src/runtime-host-update-reconciliation.ts | 103 +++++++----- 7 files changed, 229 insertions(+), 152 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index 3012f15e54..6003a033d9 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -62,6 +62,10 @@ import { type RuntimeHostManagedServiceResult, type RuntimeHostServiceBackend, } from '../runtime-host-service-manager.js'; +import { + readRuntimeHostManagedUpdatePolicy, + writeRuntimeHostManagedUpdatePolicy, +} from '../runtime-host-update-policy-store.js'; import { createSystemdUserRuntimeHostService, renderSystemdUnit, @@ -394,6 +398,25 @@ describe('managed Runtime Host service', () => { rootPath: root.canonicalPath, rootId: root.rootId, } as const; + const updatePolicy = { + schemaVersion: 1 as const, + policy: { kind: 'channel' as const, channel: 'latest' as const }, + target: expectedTarget, + }; + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, updatePolicy); + await assert.rejects( + manageRuntimeHostService( + { + ...common, + action: 'uninstall', + expectedTarget: { ...expectedTarget, rootId: 'f'.repeat(64) }, + }, + backend(), + ), + (error: unknown) => + error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch', + ); + assert.deepEqual(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), updatePolicy); await assert.rejects( cleanupRuntimeHostManagedDeployment( { clientDataRoot, cliPath: canonicalCliPath, expectedTarget }, @@ -412,6 +435,7 @@ describe('managed Runtime Host service', () => { }, backend(), ); + assert.equal(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), null); await cleanupRuntimeHostManagedDeployment( { clientDataRoot, cliPath: canonicalCliPath, expectedTarget }, backend(), 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 b511791701..5f668a0a3b 100644 --- a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts @@ -18,7 +18,7 @@ */ import assert from 'node:assert/strict'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; @@ -27,7 +27,6 @@ import { type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; import { parseRuntimeHostCommand } from '../runtime-host-cli.js'; -import { runManagedRuntimeHostServiceCli } from '../runtime-host-service-management-command.js'; import { runManagedRuntimeHostUpdatePolicyCli, runManagedRuntimeHostUpdateReconcileCli, @@ -35,6 +34,7 @@ import { import { readRuntimeHostManagedUpdatePolicy, resolveRuntimeHostManagedUpdatePolicyPath, + RuntimeHostUpdatePolicyError, writeRuntimeHostManagedUpdatePolicy, } from '../runtime-host-update-policy-store.js'; @@ -92,6 +92,7 @@ describe('managed Runtime Host update reconciliation', () => { it('persists an automatic policy against the canonical managed target and removes manual state', async (t) => { const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-policy-')); t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + const deploymentRoot = join(clientDataRoot, 'managed'); const common = { json: true, framed: false, @@ -99,6 +100,7 @@ describe('managed Runtime Host update reconciliation', () => { defaultRootPath: '/workspace', }; let output = ''; + const manage = async () => managedStatus(deploymentRoot); assert.equal( await runManagedRuntimeHostUpdatePolicyCli( { @@ -109,28 +111,7 @@ describe('managed Runtime Host update reconciliation', () => { { withDeploymentLock: async (_root, operation) => operation(), createBackend: () => unusedBackend(), - manage: async () => ({ - schemaVersion: 1, - action: 'status', - service: { - manager: 'systemd_user', - installed: true, - enabled: true, - active: true, - state: 'running', - pid: 42, - lastExitCode: null, - installedVersion: '1.0.0', - config: { - schemaVersion: 1, - managedDeploymentRoot: '/managed', - rootPath: '/srv/maka', - projectDirectoryRoots: [], - websocket: { host: '127.0.0.1', port: 7443, path: '/runtime-host' }, - launch: { nodePath: '/node', cliPath: '/managed/cli.js' }, - }, - }, - }), + manage, writeOutput: (value) => { output += value; }, @@ -138,7 +119,7 @@ describe('managed Runtime Host update reconciliation', () => { ), 0, ); - assert.deepEqual(await readRuntimeHostManagedUpdatePolicy(clientDataRoot), { + assert.deepEqual(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), { schemaVersion: 1, policy: { kind: 'fixed', version: '2.0.0' }, target: { ...TARGET, rootPath: '/srv/maka' }, @@ -150,17 +131,21 @@ describe('managed Runtime Host update reconciliation', () => { { ...common, policy: { kind: 'manual' } }, { withDeploymentLock: async (_root, operation) => operation(), + manage, + createBackend: () => unusedBackend(), writeOutput: () => undefined, }, ), 0, ); - assert.equal(await readRuntimeHostManagedUpdatePolicy(clientDataRoot), null); + assert.equal(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), null); let manualOutput = ''; assert.equal( await runManagedRuntimeHostUpdateReconcileCli( { ...common, json: false, framed: true }, { + manage, + createBackend: () => unusedBackend(), resolveSelection: async () => assert.fail('manual policy must not resolve a target'), writeOutput: (value) => { manualOutput += value; @@ -178,10 +163,47 @@ describe('managed Runtime Host update reconciliation', () => { ); }); + it('distinguishes an uncertain policy commit and makes an absent-policy retry durable', async (t) => { + const deploymentRoot = await mkdtemp(join(tmpdir(), 'maka-update-policy-commit-')); + t.after(() => rm(deploymentRoot, { recursive: true, force: true })); + const record = { + schemaVersion: 1 as const, + policy: { kind: 'fixed' as const, version: '2.0.0' }, + target: TARGET, + }; + const failSync = async () => { + throw new Error('directory sync failed'); + }; + + await assert.rejects( + writeRuntimeHostManagedUpdatePolicy(deploymentRoot, record, { syncDirectory: failSync }), + (error: unknown) => + error instanceof RuntimeHostUpdatePolicyError && + error.code === 'update_policy_commit_outcome_unknown', + ); + assert.deepEqual(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), record); + + await assert.rejects( + writeRuntimeHostManagedUpdatePolicy(deploymentRoot, null, { syncDirectory: failSync }), + (error: unknown) => + error instanceof RuntimeHostUpdatePolicyError && + error.code === 'update_policy_commit_outcome_unknown', + ); + assert.equal(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), null); + let syncs = 0; + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, null, { + syncDirectory: async () => { + syncs += 1; + }, + }); + assert.equal(syncs, 1); + }); + it('resolves one policy snapshot and delegates an admitted exact target to the update transaction', async (t) => { const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-reconcile-')); t.after(() => rm(clientDataRoot, { recursive: true, force: true })); - await writeRuntimeHostManagedUpdatePolicy(clientDataRoot, { + const deploymentRoot = join(clientDataRoot, 'managed'); + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, { schemaVersion: 1, policy: { kind: 'fixed', version: '2.0.0' }, target: TARGET, @@ -196,6 +218,8 @@ describe('managed Runtime Host update reconciliation', () => { defaultRootPath: '/workspace', }, { + manage: async () => managedStatus(deploymentRoot), + createBackend: () => unusedBackend(), resolveSelection: async (options) => { assert.deepEqual(options.selector, { kind: 'exact', version: '2.0.0' }); assert.deepEqual(options.expectedTarget, TARGET); @@ -251,7 +275,8 @@ describe('managed Runtime Host update reconciliation', () => { it('revokes a stale reconcile when automatic policy changes to manual', async (t) => { const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-policy-race-')); t.after(() => rm(clientDataRoot, { recursive: true, force: true })); - await writeRuntimeHostManagedUpdatePolicy(clientDataRoot, { + const deploymentRoot = join(clientDataRoot, 'managed'); + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, { schemaVersion: 1, policy: { kind: 'channel', channel: 'latest' }, target: TARGET, @@ -261,8 +286,10 @@ describe('managed Runtime Host update reconciliation', () => { await runManagedRuntimeHostUpdateReconcileCli( { json: true, framed: false, clientDataRoot, defaultRootPath: '/workspace' }, { + manage: async () => managedStatus(deploymentRoot), + createBackend: () => unusedBackend(), resolveSelection: async (options) => { - await writeRuntimeHostManagedUpdatePolicy(clientDataRoot, null); + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, null); return { selector: options.selector, candidate: { version: '2.0.0', integrity: INTEGRITY, compatibility: 1 }, @@ -290,18 +317,22 @@ describe('managed Runtime Host update reconciliation', () => { 1, ); assert.equal(JSON.parse(output).error.code, 'update_policy_changed'); - assert.equal(await readRuntimeHostManagedUpdatePolicy(clientDataRoot), null); + assert.equal(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), null); }); it('fails closed on corrupt policy and returns manual-action candidates without mutation', async (t) => { const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-reconcile-')); t.after(() => rm(clientDataRoot, { recursive: true, force: true })); - await writeFile(resolveRuntimeHostManagedUpdatePolicyPath(clientDataRoot), '{"bad":true}\n'); + const deploymentRoot = join(clientDataRoot, 'managed'); + await mkdir(deploymentRoot, { recursive: true }); + await writeFile(resolveRuntimeHostManagedUpdatePolicyPath(deploymentRoot), '{"bad":true}\n'); let errorOutput = ''; assert.equal( await runManagedRuntimeHostUpdateReconcileCli( { json: true, framed: false, clientDataRoot, defaultRootPath: '/workspace' }, { + manage: async () => managedStatus(deploymentRoot), + createBackend: () => unusedBackend(), writeOutput: (value) => { errorOutput += value; }, @@ -311,7 +342,7 @@ describe('managed Runtime Host update reconciliation', () => { ); assert.equal(JSON.parse(errorOutput).error.code, 'invalid_update_policy'); - await writeRuntimeHostManagedUpdatePolicy(clientDataRoot, { + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, { schemaVersion: 1, policy: { kind: 'channel', channel: 'latest' }, target: TARGET, @@ -321,6 +352,8 @@ describe('managed Runtime Host update reconciliation', () => { await runManagedRuntimeHostUpdateReconcileCli( { json: true, framed: false, clientDataRoot, defaultRootPath: '/workspace' }, { + manage: async () => managedStatus(deploymentRoot), + createBackend: () => unusedBackend(), resolveSelection: async (options) => ({ selector: options.selector, candidate: { version: '2.0.0', integrity: INTEGRITY }, @@ -338,43 +371,33 @@ describe('managed Runtime Host update reconciliation', () => { ); assert.equal(JSON.parse(output).reconciliation.kind, 'manual_action'); }); - - it('keeps automatic policy revoked when uninstall later fails', async (t) => { - const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-uninstall-')); - t.after(() => rm(clientDataRoot, { recursive: true, force: true })); - await writeRuntimeHostManagedUpdatePolicy(clientDataRoot, { - schemaVersion: 1, - policy: { kind: 'channel', channel: 'next' }, - target: TARGET, - }); - const immediate = async (_root: string, operation: () => Promise) => operation(); - assert.equal( - await runManagedRuntimeHostServiceCli( - { - action: 'uninstall', - json: false, - clientDataRoot, - defaultRootPath: '/workspace', - nodePath: '/node', - cliPath: '/cli.js', - }, - { - withDeploymentLock: immediate, - withLifecycleLock: immediate, - createBackend: () => unusedBackend(), - manage: async () => { - assert.equal(await readRuntimeHostManagedUpdatePolicy(clientDataRoot), null); - throw new Error('uninstall failed after policy revocation'); - }, - writeError: () => undefined, - }, - ), - 1, - ); - assert.equal(await readRuntimeHostManagedUpdatePolicy(clientDataRoot), null); - }); }); +function managedStatus(managedDeploymentRoot: string) { + return { + schemaVersion: 1 as const, + action: 'status' as const, + service: { + manager: 'systemd_user' as const, + installed: true, + enabled: true, + active: true, + state: 'running' as const, + pid: 42, + lastExitCode: null, + installedVersion: '1.0.0', + config: { + schemaVersion: 1 as const, + managedDeploymentRoot, + rootPath: '/srv/maka', + projectDirectoryRoots: [], + websocket: { host: '127.0.0.1' as const, port: 7443, path: '/runtime-host' }, + launch: { nodePath: '/node', cliPath: join(managedDeploymentRoot, 'cli.js') }, + }, + }, + }; +} + function unusedBackend() { return { preflightInstall: async () => undefined, diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts index e848ede4bc..867fabf23a 100644 --- a/packages/cli/src/runtime-host-service-management-command.ts +++ b/packages/cli/src/runtime-host-service-management-command.ts @@ -42,7 +42,6 @@ import { type RuntimeHostManagedServiceTarget, type RuntimeHostServiceBackend, } from './runtime-host-service-manager.js'; -import { writeRuntimeHostManagedUpdatePolicy } from './runtime-host-update-policy-store.js'; import { createLaunchAgentRuntimeHostService } from './runtime-host-launch-agent-service.js'; import { createSystemdUserRuntimeHostService } from './runtime-host-systemd-service.js'; @@ -57,7 +56,6 @@ export interface RuntimeHostServiceManagementCliDeps { readonly manage: typeof manageRuntimeHostService; readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock; readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock; - readonly clearUpdatePolicy: (clientDataRoot: string) => Promise; readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend; readonly writeOutput: (value: string) => unknown; readonly writeError: (value: string) => unknown; @@ -71,8 +69,6 @@ export async function runManagedRuntimeHostServiceCli( manage: manageRuntimeHostService, withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock, withLifecycleLock: withRuntimeHostManagedServiceLifecycleLock, - clearUpdatePolicy: (clientDataRoot) => - writeRuntimeHostManagedUpdatePolicy(clientDataRoot, null), createBackend: createPlatformRuntimeHostServiceBackend, writeOutput: (value) => process.stdout.write(value), writeError: (value) => process.stderr.write(value), @@ -91,12 +87,7 @@ export async function runManagedRuntimeHostServiceCli( ? await manage() : options.action === 'retire' ? await deps.withLifecycleLock(options.clientDataRoot, manage) - : options.action === 'uninstall' - ? await deps.withDeploymentLock(options.clientDataRoot, async () => { - await deps.clearUpdatePolicy(options.clientDataRoot); - return await deps.withLifecycleLock(options.clientDataRoot, manage); - }) - : await mutate(); + : await mutate(); const blocked = result.action === 'retire' && result.retirement.kind === 'active_tasks'; if (options.framed) { deps.writeOutput(encodeRuntimeHostServiceManagementFrame(successFrame(result))); diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 4444b43a4f..05e08384fb 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -49,6 +49,7 @@ import { removeRuntimeHostManagedDeployment, resolveRuntimeHostManagedDeploymentForCli, } from './runtime-host-managed-deployment.js'; +import { writeRuntimeHostManagedUpdatePolicy } from './runtime-host-update-policy-store.js'; const SERVICE_CONFIG_FILE = 'runtime-host-service.json'; const SERVICE_LIFECYCLE_LOCK_FILE = 'runtime-host-setup'; @@ -402,6 +403,9 @@ async function manageRuntimeHostServiceLocked( const managedDeploymentRoot = before?.managedDeploymentRoot ?? resolveRuntimeHostManagedDeploymentForCli(serviceId, input.cliPath); + if (managedDeploymentRoot) { + await writeRuntimeHostManagedUpdatePolicy(managedDeploymentRoot, null); + } await backend.uninstall(); await removeRuntimeHostServiceFile(configPath, 'service config'); if (managedDeploymentRoot && !input.retainManagedDeployment) { diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index ac8354a5ae..18a634b51a 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -117,12 +117,12 @@ interface RuntimeHostResolvedUpdateCliDeps { readonly revalidateSelection: () => Promise; readonly withPackage: typeof withRuntimeHostRegistryUpdatePackage; readonly update: typeof runManagedRuntimeHostUpdateCli; - readonly writeOutput: (value: string) => unknown; - readonly writeError: (value: string) => unknown; } interface RuntimeHostSelectedUpdateCliDeps extends RuntimeHostResolvedUpdateCliDeps { readonly resolveSelection: typeof resolveManagedRuntimeHostUpdateSelection; + readonly writeOutput: (value: string) => unknown; + readonly writeError: (value: string) => unknown; } export type RuntimeHostUpdateFrame = Extract< @@ -557,23 +557,19 @@ export async function runManagedRuntimeHostSelectedUpdateCli( export async function runManagedRuntimeHostResolvedUpdateCli( options: RuntimeHostSelectedUpdateCliOptions, selection: RuntimeHostUpdateSelection, - overrides: Partial = {}, - frameSink?: RuntimeHostUpdateFrameSink, + overrides: Partial, + frameSink: RuntimeHostUpdateFrameSink, ): Promise { const deps: RuntimeHostResolvedUpdateCliDeps = { revalidateSelection: async () => undefined, withPackage: withRuntimeHostRegistryUpdatePackage, update: runManagedRuntimeHostUpdateCli, - writeOutput: (value) => process.stdout.write(value), - writeError: (value) => process.stderr.write(value), ...overrides, }; - const emit = - frameSink ?? ((frame: RuntimeHostUpdateFrame) => presentUpdateFrame(frame, options, deps)); try { if (selection.outcome.kind === 'manual_action') { - emit({ + frameSink({ schemaVersion: 1, kind: 'error', action: 'update', @@ -604,7 +600,7 @@ export async function runManagedRuntimeHostResolvedUpdateCli( }, }, { revalidateSelection: deps.revalidateSelection }, - emit, + frameSink, ); }; return selection.outcome.kind === 'current' @@ -618,7 +614,7 @@ export async function runManagedRuntimeHostResolvedUpdateCli( ? error.code : 'update_resolution_failed'; const message = error instanceof Error ? error.message : String(error); - emit({ + frameSink({ schemaVersion: 1, kind: 'error', action: 'update', @@ -638,7 +634,7 @@ export async function runManagedRuntimeHostResolvedUpdateCli( function presentUpdateFrame( frame: RuntimeHostUpdateFrame, options: Pick, - deps: Pick, + deps: Pick, ): void { if (options.framed) { deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); diff --git a/packages/cli/src/runtime-host-update-policy-store.ts b/packages/cli/src/runtime-host-update-policy-store.ts index 7b252ed6b7..06f851c54d 100644 --- a/packages/cli/src/runtime-host-update-policy-store.ts +++ b/packages/cli/src/runtime-host-update-policy-store.ts @@ -29,6 +29,10 @@ import type { RuntimeHostManagedServiceTarget } from './runtime-host-service-man const UPDATE_POLICY_FILE = 'runtime-host-update-policy.json'; const UPDATE_POLICY_MAX_BYTES = 16 * 1024; +interface RuntimeHostUpdatePolicyStoreDeps { + readonly syncDirectory: (path: string) => Promise; +} + type AutomaticUpdatePolicy = Exclude; export interface RuntimeHostManagedUpdatePolicyRecord { @@ -39,7 +43,10 @@ export interface RuntimeHostManagedUpdatePolicyRecord { export class RuntimeHostUpdatePolicyError extends Error { constructor( - readonly code: 'invalid_update_policy' | 'update_policy_write_failed', + readonly code: + | 'invalid_update_policy' + | 'update_policy_write_failed' + | 'update_policy_commit_outcome_unknown', message: string, options?: ErrorOptions, ) { @@ -48,14 +55,14 @@ export class RuntimeHostUpdatePolicyError extends Error { } } -export function resolveRuntimeHostManagedUpdatePolicyPath(clientDataRoot: string): string { - return join(clientDataRoot, UPDATE_POLICY_FILE); +export function resolveRuntimeHostManagedUpdatePolicyPath(managedDeploymentRoot: string): string { + return join(managedDeploymentRoot, UPDATE_POLICY_FILE); } export async function readRuntimeHostManagedUpdatePolicy( - clientDataRoot: string, + managedDeploymentRoot: string, ): Promise { - const path = resolveRuntimeHostManagedUpdatePolicyPath(clientDataRoot); + const path = resolveRuntimeHostManagedUpdatePolicyPath(managedDeploymentRoot); let raw: string; try { raw = await readFile(path, 'utf8'); @@ -84,28 +91,39 @@ export async function readRuntimeHostManagedUpdatePolicy( } export async function writeRuntimeHostManagedUpdatePolicy( - clientDataRoot: string, + managedDeploymentRoot: string, record: RuntimeHostManagedUpdatePolicyRecord | null, + overrides: Partial = {}, ): Promise { - const path = resolveRuntimeHostManagedUpdatePolicyPath(clientDataRoot); + const deps: RuntimeHostUpdatePolicyStoreDeps = { + syncDirectory, + ...overrides, + }; + const path = resolveRuntimeHostManagedUpdatePolicyPath(managedDeploymentRoot); if (record === null) { + let removed = false; try { await unlink(path); + removed = true; } catch (error) { - if (isNodeError(error, 'ENOENT')) return; - throw new RuntimeHostUpdatePolicyError( - 'update_policy_write_failed', - `Unable to remove the managed Runtime Host update policy at ${path}`, - { cause: error }, - ); + if (!isNodeError(error, 'ENOENT')) { + throw new RuntimeHostUpdatePolicyError( + 'update_policy_write_failed', + `Unable to remove the managed Runtime Host update policy at ${path}`, + { cause: error }, + ); + } } try { - await syncDirectory(dirname(path)); + await deps.syncDirectory(dirname(path)); return; } catch (error) { + if (isNodeError(error, 'ENOENT')) return; throw new RuntimeHostUpdatePolicyError( - 'update_policy_write_failed', - `Unable to persist removal of the managed Runtime Host update policy at ${path}`, + 'update_policy_commit_outcome_unknown', + removed + ? `The managed Runtime Host update policy was removed at ${path}, but its durability could not be confirmed; inspect the current policy before retrying` + : `The managed Runtime Host update policy is absent at ${path}, but its removal could not be confirmed durable; inspect the current policy before retrying`, { cause: error }, ); } @@ -113,6 +131,7 @@ export async function writeRuntimeHostManagedUpdatePolicy( assertUpdatePolicyRecord(record); const directory = dirname(path); const temporaryPath = `${path}.${randomUUID()}.tmp`; + let published = false; try { await mkdir(directory, { recursive: true, mode: 0o700 }); const file = await open(temporaryPath, 'wx', 0o600); @@ -123,15 +142,18 @@ export async function writeRuntimeHostManagedUpdatePolicy( await file.close(); } await rename(temporaryPath, path); - await syncDirectory(directory); + published = true; + await deps.syncDirectory(directory); } catch (error) { throw new RuntimeHostUpdatePolicyError( - 'update_policy_write_failed', - `Unable to persist the managed Runtime Host update policy at ${path}`, + published ? 'update_policy_commit_outcome_unknown' : 'update_policy_write_failed', + published + ? `The managed Runtime Host update policy was published at ${path}, but its durability could not be confirmed; inspect the current policy before retrying` + : `Unable to persist the managed Runtime Host update policy at ${path}`, { cause: error }, ); } finally { - await rm(temporaryPath, { force: true }); + await rm(temporaryPath, { force: true }).catch(() => undefined); } } diff --git a/packages/cli/src/runtime-host-update-reconciliation.ts b/packages/cli/src/runtime-host-update-reconciliation.ts index eaa0632888..4e81190a1d 100644 --- a/packages/cli/src/runtime-host-update-reconciliation.ts +++ b/packages/cli/src/runtime-host-update-reconciliation.ts @@ -18,6 +18,7 @@ */ import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { isDeepStrictEqual } from 'node:util'; import { encodeRuntimeHostServiceManagementFrame, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES, @@ -98,28 +99,23 @@ export async function runManagedRuntimeHostUpdatePolicyCli( const record = requestedPolicy ? await deps.withDeploymentLock(options.clientDataRoot, async () => { if (requestedPolicy.kind === 'manual') { - await deps.writePolicy(options.clientDataRoot, null); + const status = await readManagedServiceStatus(options, deps); + const managedDeploymentRoot = status.service.config?.managedDeploymentRoot; + if (managedDeploymentRoot) { + await deps.writePolicy(managedDeploymentRoot, null); + } return null; } - if (!options.expectedTarget) { + 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 serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); - const status = await deps.manage( - { - action: 'status', - clientDataRoot: options.clientDataRoot, - defaultRootPath: options.defaultRootPath, - nodePath: process.execPath, - cliPath: process.argv[1] ?? '', - expectedTarget: options.expectedTarget, - }, - deps.createBackend(serviceId), - ); - if (!status.service.installed || !status.service.config?.managedDeploymentRoot) { + 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', @@ -129,14 +125,14 @@ export async function runManagedRuntimeHostUpdatePolicyCli( schemaVersion: 1, policy: requestedPolicy, target: { - ...options.expectedTarget, + ...expectedTarget, rootPath: status.service.config.rootPath, }, }; - await deps.writePolicy(options.clientDataRoot, next); + await deps.writePolicy(managedDeploymentRoot, next); return next; }) - : await deps.readPolicy(options.clientDataRoot); + : await readCurrentUpdatePolicy(options, deps); writeFrame(updatePolicyResult(record), options, deps); return 0; } catch (error) { @@ -151,8 +147,16 @@ export async function runManagedRuntimeHostUpdateReconcileCli( ): Promise { const deps = reconciliationDeps(overrides); try { - const record = await deps.readPolicy(options.clientDataRoot); - if (!record) { + 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; + if (!policy?.record) { writeFrame( { schemaVersion: 1, @@ -166,6 +170,7 @@ export async function runManagedRuntimeHostUpdateReconcileCli( ); return 0; } + const { root: policyRoot, record } = policy; const selection = await deps.resolveSelection({ clientDataRoot: options.clientDataRoot, defaultRootPath: options.defaultRootPath, @@ -212,11 +217,11 @@ export async function runManagedRuntimeHostUpdateReconcileCli( revalidateSelection: async () => { let current: RuntimeHostManagedUpdatePolicyRecord | null; try { - current = await deps.readPolicy(options.clientDataRoot); + current = await deps.readPolicy(policyRoot); } catch (error) { return boundedError(error, 'Unable to revalidate the managed update policy'); } - if (!sameUpdatePolicyRecord(current, record)) { + if (!isDeepStrictEqual(current, record)) { return { code: 'update_policy_changed', message: @@ -242,6 +247,39 @@ 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, + 'clientDataRoot' | 'defaultRootPath' + >, + deps: RuntimeHostUpdateReconciliationDeps, + expectedTarget?: RuntimeHostManagedServiceTarget, +) { + const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); + return deps.manage( + { + action: 'status', + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + nodePath: process.execPath, + cliPath: process.argv[1] ?? '', + ...(expectedTarget ? { expectedTarget } : {}), + }, + deps.createBackend(serviceId), + ); +} + function reconciliationDeps( overrides: Partial, ): RuntimeHostUpdateReconciliationDeps { @@ -284,27 +322,6 @@ function policySelector( : { kind: 'channel', channel: policy.channel }; } -function sameUpdatePolicyRecord( - left: RuntimeHostManagedUpdatePolicyRecord | null, - right: RuntimeHostManagedUpdatePolicyRecord | null, -): boolean { - if (!left || !right) return left === right; - if ( - left.schemaVersion !== right.schemaVersion || - left.target.serviceId !== right.target.serviceId || - left.target.rootPath !== right.target.rootPath || - left.target.rootId !== right.target.rootId || - left.policy.kind !== right.policy.kind - ) { - return false; - } - return left.policy.kind === 'fixed' && right.policy.kind === 'fixed' - ? left.policy.version === right.policy.version - : left.policy.kind === 'channel' && - right.policy.kind === 'channel' && - left.policy.channel === right.policy.channel; -} - function reconcileFrame( frame: RuntimeHostUpdateFrame, updatePolicy: ReturnType, From 3c8b08278cdb56bd96385d6422f09acf8f9e1c6f Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 11:10:16 +0800 Subject: [PATCH 5/5] fix(runtime-host): revoke update policy after config loss Treat automatic update policy revocation as a separate uninstall authority from deleting the managed deployment. A safely validated deterministic deployment can revoke policy when service configuration is corrupt, while destructive cleanup still requires stronger ownership evidence.\n\nGenerated-by: Codex --- .../runtime-host-service-manager.test.ts | 30 +++++++++++++++-- .../src/runtime-host-managed-deployment.ts | 21 ++++++++---- .../cli/src/runtime-host-service-manager.ts | 33 +++++++++++++++++-- 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index 6003a033d9..a301ba6f54 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -267,7 +267,7 @@ describe('managed Runtime Host service', () => { }); it('installs, reports, and cleanly uninstalls while retaining the State Root', async (t) => { - const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-service-')); + const base = await realpath(await mkdtemp(join(tmpdir(), 'maka-runtime-host-service-'))); t.after(() => rm(base, { recursive: true, force: true })); const homeDir = join(base, 'home'); const clientDataRoot = join(base, 'config', 'Maka'); @@ -275,11 +275,14 @@ describe('managed Runtime Host service', () => { const projectPath = join(base, 'projects'); await writeFile(join(base, 'placeholder'), '', 'utf8'); await mkdir(projectPath, { recursive: true }); - const env = { XDG_CONFIG_HOME: join(base, 'xdg-config') }; + const env = { + XDG_CONFIG_HOME: join(base, 'xdg-config'), + XDG_DATA_HOME: join(base, 'xdg-data'), + }; const configPath = resolveRuntimeHostManagedServiceConfigPath(clientDataRoot); const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot); const deploymentRoot = resolveRuntimeHostManagedDeploymentRoot(serviceId, { - env: { XDG_DATA_HOME: join(base, 'xdg-data') }, + env, homeDir, platform: 'linux', }); @@ -306,6 +309,9 @@ describe('managed Runtime Host service', () => { const managerDeps = { allocateLoopbackPort: async () => 49_999, waitForReady: async () => undefined, + environment: env, + homeDir, + platform: 'linux' as const, } as const; const installed = await manageRuntimeHostService( @@ -404,6 +410,24 @@ describe('managed Runtime Host service', () => { target: expectedTarget, }; await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, updatePolicy); + await writeFile(configPath, '{not-json', 'utf8'); + await manageRuntimeHostService( + { + ...common, + cliPath: globalCliPath, + action: 'uninstall', + retainManagedDeployment: true, + expectedTarget, + }, + backend(), + managerDeps, + ); + assert.equal(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), null); + await access(deploymentRoot); + + await manageRuntimeHostService({ ...common, action: 'install' }, backend(), managerDeps); + assert.equal(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), null); + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, updatePolicy); await assert.rejects( manageRuntimeHostService( { diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts index fdf0d73152..33a27d1dcb 100644 --- a/packages/cli/src/runtime-host-managed-deployment.ts +++ b/packages/cli/src/runtime-host-managed-deployment.ts @@ -245,14 +245,14 @@ export function resolveRuntimeHostManagedDeploymentForCli( return isRuntimeHostManagedDeploymentCli(root, serviceId, cliPath) ? root : undefined; } -export async function removeRuntimeHostManagedDeployment( +export async function resolveExistingRuntimeHostManagedDeploymentRoot( root: string, serviceId: string, -): Promise { +): Promise { if (!isRuntimeHostManagedDeploymentRoot(root, serviceId)) { throw new RuntimeHostManagedDeploymentError( 'deployment_failed', - 'Refusing to remove an invalid managed Runtime Host deployment path', + 'Refusing to inspect an invalid managed Runtime Host deployment path', ); } const requestedRoot = resolve(root); @@ -260,10 +260,10 @@ export async function removeRuntimeHostManagedDeployment( try { inspected = await Promise.all([realpath(requestedRoot), lstat(requestedRoot)]); } catch (error) { - if (isNodeError(error, 'ENOENT')) return; + if (isNodeError(error, 'ENOENT')) return undefined; throw new RuntimeHostManagedDeploymentError( 'deployment_failed', - 'Unable to inspect the managed Runtime Host deployment before removal', + 'Unable to inspect the managed Runtime Host deployment', { cause: error }, ); } @@ -271,9 +271,18 @@ export async function removeRuntimeHostManagedDeployment( if (canonicalRoot !== requestedRoot || !target.isDirectory() || target.isSymbolicLink()) { throw new RuntimeHostManagedDeploymentError( 'deployment_failed', - 'Refusing to remove a redirected managed Runtime Host deployment path', + 'Refusing to use a redirected managed Runtime Host deployment path', ); } + return canonicalRoot; +} + +export async function removeRuntimeHostManagedDeployment( + root: string, + serviceId: string, +): Promise { + const requestedRoot = await resolveExistingRuntimeHostManagedDeploymentRoot(root, serviceId); + if (!requestedRoot) return; const operatorPath = join(requestedRoot, 'operator'); for (const entry of await readdir(requestedRoot)) { if (entry === 'operator') continue; diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 05e08384fb..48507c07ed 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -47,7 +47,9 @@ import { import { isRuntimeHostManagedDeploymentCli, removeRuntimeHostManagedDeployment, + resolveExistingRuntimeHostManagedDeploymentRoot, resolveRuntimeHostManagedDeploymentForCli, + resolveRuntimeHostManagedDeploymentRoot, } from './runtime-host-managed-deployment.js'; import { writeRuntimeHostManagedUpdatePolicy } from './runtime-host-update-policy-store.js'; @@ -202,6 +204,7 @@ interface RuntimeHostServiceManagerDeps { >; readonly environment: NodeJS.ProcessEnv; readonly homeDir: string; + readonly platform: NodeJS.Platform; } export class RuntimeHostServiceManagerError extends Error { @@ -238,6 +241,7 @@ export async function manageRuntimeHostService( prepareRetirement: prepareRuntimeHostRetirement, environment: process.env, homeDir: homedir(), + platform: process.platform, ...overrides, }; const configPath = resolveRuntimeHostManagedServiceConfigPath(input.clientDataRoot); @@ -310,6 +314,7 @@ export async function replaceRuntimeHostManagedService( prepareRetirement: prepareRuntimeHostRetirement, environment: process.env, homeDir: homedir(), + platform: process.platform, ...overrides, }; const configPath = resolveRuntimeHostManagedServiceConfigPath(input.clientDataRoot); @@ -403,8 +408,32 @@ async function manageRuntimeHostServiceLocked( const managedDeploymentRoot = before?.managedDeploymentRoot ?? resolveRuntimeHostManagedDeploymentForCli(serviceId, input.cliPath); - if (managedDeploymentRoot) { - await writeRuntimeHostManagedUpdatePolicy(managedDeploymentRoot, null); + let policyDeploymentRoot: string | undefined; + try { + policyDeploymentRoot = await resolveExistingRuntimeHostManagedDeploymentRoot( + managedDeploymentRoot ?? + resolveRuntimeHostManagedDeploymentRoot(serviceId, { + env: deps.environment, + homeDir: deps.homeDir, + platform: deps.platform, + }), + serviceId, + ); + } catch (error) { + throw new RuntimeHostServiceManagerError( + 'uninstall_incomplete', + 'Unable to safely inspect the managed Runtime Host deployment before revoking automatic update policy', + { cause: error }, + ); + } + if (!policyDeploymentRoot && invalidConfig && !managedDeploymentRoot) { + throw new RuntimeHostServiceManagerError( + 'uninstall_incomplete', + 'Unable to confirm automatic update policy revocation because the service config is invalid and its managed deployment could not be located', + ); + } + if (policyDeploymentRoot) { + await writeRuntimeHostManagedUpdatePolicy(policyDeploymentRoot, null); } await backend.uninstall(); await removeRuntimeHostServiceFile(configPath, 'service config');