diff --git a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts index 8593f557fa..734041d325 100644 --- a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts +++ b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts @@ -38,9 +38,11 @@ import { RUNTIME_HOST_PROTOCOL_VERSION, RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, type HostIncompatible, + type HostRegistration, } from '@maka/runtime-host/protocol'; import { connectRuntimeHostCli, + resolveRuntimeHostCliConflictDecision, RuntimeHostCliConflictError, shouldRetryRuntimeHostConflict, } from '../runtime-host-cli-context.js'; @@ -96,6 +98,31 @@ test('CLI Runtime Host bootstrap launches the execution composition', async () = assert.equal(closes, 1); }); +test('CLI refuses a staged Host whose durable installation claim is missing', async () => { + let closes = 0; + await assert.rejects( + connectRuntimeHostCli( + { rootPath: '/runtime-host-root' }, + { + connectOrSpawn: async () => ({ + kind: 'connected', + registration: hostRegistration({ + generation: `npm-global-handoff:${'a'.repeat(64)}`, + }), + connection: { + close: async () => { + closes += 1; + }, + } as RuntimeHostConnection, + }), + readDeploymentRecord: async () => undefined, + }, + ), + /RUNTIME_HOST_RECOVERY_REQUIRED/u, + ); + assert.equal(closes, 1); +}); + test('non-interactive CLI reports how to retire an incompatible Runtime Host', async () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > V0_1_11_HOST_COMPATIBILITY_EPOCH); await assert.rejects( @@ -181,6 +208,11 @@ test('Runtime Host conflict waits only after an explicit wait answer', () => { assert.equal(shouldRetryRuntimeHostConflict('c'), false); assert.equal(shouldRetryRuntimeHostConflict('cancel'), false); assert.equal(shouldRetryRuntimeHostConflict('unexpected'), false); + assert.equal(resolveRuntimeHostCliConflictDecision('r', true), 'restart'); + assert.equal(resolveRuntimeHostCliConflictDecision(' restart ', true), 'restart'); + assert.equal(resolveRuntimeHostCliConflictDecision('r', false), 'cancel'); + assert.equal(resolveRuntimeHostCliConflictDecision('w', true), 'wait'); + assert.equal(resolveRuntimeHostCliConflictDecision('', true), 'cancel'); }); test('CLI reports an actionable stored-data startup failure', async () => { @@ -476,12 +508,7 @@ test('remote profiles preserve shared compatibility errors', async () => { } }); -function hostRegistration( - overrides: Partial<{ - compatibilityEpoch: number; - lifecycleMode: 'ephemeral' | 'service'; - }> = {}, -) { +function hostRegistration(overrides: Partial = {}): HostRegistration { return { kind: 'maka-runtime-host' as const, schemaVersion: RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, diff --git a/packages/cli/src/__tests__/runtime-host-local-handoff.test.ts b/packages/cli/src/__tests__/runtime-host-local-handoff.test.ts new file mode 100644 index 0000000000..559d60f564 --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-local-handoff.test.ts @@ -0,0 +1,500 @@ +/* + * 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 { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + applyLocalHostDeploymentTransition, + readLocalHostDeploymentRecord, + type RuntimeHostInstallationOwner, +} from '@maka/runtime-host/operator'; +import { + INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + RUNTIME_HOST_COMPATIBILITY_EPOCH, + RUNTIME_HOST_PROTOCOL_VERSION, + RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, + type HostRegistration, +} from '@maka/runtime-host/protocol'; +import { + reconcileRuntimeHostNpmGlobalDeployment, + resolveRuntimeHostLocalCliDeploymentRoot, + restartRuntimeHostNpmGlobalDeployment, + RuntimeHostLocalHandoffError, + stageRuntimeHostNpmGlobalDeploymentTarget, +} from '../runtime-host-local-handoff.js'; +import { prepareRuntimeHostPackageDeployment } from '../runtime-host-package-deployment.js'; + +const ROOT_ID = 'a'.repeat(64); +const INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString('base64')}`; +const TARGET = { + kind: 'npm_registry' as const, + version: '2.0.0', + integrity: INTEGRITY, +}; +const CLI_OWNER = { + kind: 'cli' as const, + installationId: 'npm-global:stable-slot', +}; +const DESKTOP_OWNER: RuntimeHostInstallationOwner = { + kind: 'desktop', + installationId: 'desktop:stable', +}; +const PREVIOUS = { + kind: 'npm_registry' as const, + version: '1.0.0', + integrity: `sha512-${Buffer.alloc(64, 3).toString('base64')}`, +}; + +test('local CLI deployment roots are stable for one OS account and isolated by owner and root', () => { + const first = resolveRuntimeHostLocalCliDeploymentRoot(ROOT_ID, CLI_OWNER, { + platform: 'linux', + homeDir: '/home/maka', + }); + assert.equal( + resolveRuntimeHostLocalCliDeploymentRoot(ROOT_ID, CLI_OWNER, { + platform: 'linux', + homeDir: '/home/maka', + }), + first, + ); + assert.match(first, /^\/home\/maka\/\.local\/share\/Maka\/runtime-host-deployments\/cli\//u); + assert.notEqual( + resolveRuntimeHostLocalCliDeploymentRoot( + ROOT_ID, + { ...CLI_OWNER, installationId: 'npm-global:other-slot' }, + { platform: 'linux', homeDir: '/home/maka' }, + ), + first, + ); + assert.notEqual( + resolveRuntimeHostLocalCliDeploymentRoot('b'.repeat(64), CLI_OWNER, { + platform: 'linux', + homeDir: '/home/maka', + }), + first, + ); +}); + +test('exact registry evidence becomes a persistent transaction-fenced Host candidate', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-handoff-stage-')); + t.after(() => rm(base, { recursive: true, force: true })); + const sourcePackageRoot = await selfContainedPackage(base, TARGET.version); + const pathOptions = { platform: 'linux' as const, homeDir: join(base, 'home') }; + const staged = await stageRuntimeHostNpmGlobalDeploymentTarget( + { + rootId: ROOT_ID, + owner: CLI_OWNER, + target: TARGET, + transactionId: 'transaction-one', + }, + pathOptions, + { + withPackage: async (candidate, use) => { + assert.deepEqual(candidate, TARGET); + return use(sourcePackageRoot); + }, + prepareDeployment: prepareRuntimeHostPackageDeployment, + }, + ); + + await rm(sourcePackageRoot, { recursive: true, force: true }); + assert.equal((await stat(staged.candidateEntrypoint)).isFile(), true); + assert.match(staged.root, /runtime-host-deployments\/cli/u); + assert.match(staged.root, new RegExp(`${ROOT_ID}$`, 'u')); + assert.match(staged.packageRoot, /registry-[a-f0-9]{64}$/u); + assert.match(staged.launchGeneration, /^npm-global-handoff:[a-f0-9]{64}$/u); + + const retried = await stageRuntimeHostNpmGlobalDeploymentTarget( + { + rootId: ROOT_ID, + owner: CLI_OWNER, + target: TARGET, + transactionId: 'transaction-one', + }, + pathOptions, + { + withPackage: async (_candidate, use) => use(staged.packageRoot), + prepareDeployment: prepareRuntimeHostPackageDeployment, + }, + ); + assert.equal(retried.packageRoot, staged.packageRoot); + assert.equal(retried.launchGeneration, staged.launchGeneration); +}); + +test('npm-global handoff stages before the one durable owner transaction', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-handoff-compose-')); + t.after(() => rm(base, { recursive: true, force: true })); + const sourcePackageRoot = await selfContainedPackage(base, TARGET.version); + const authorityRoot = join(base, 'authority'); + const claimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP_OWNER, selected: PREVIOUS }, + { authorityRoot }, + ); + assert.equal(claimed.kind, 'applied'); + const events: string[] = []; + + const result = await reconcileRuntimeHostNpmGlobalDeployment( + { + rootId: ROOT_ID, + transactionId: 'desktop-to-cli', + target: TARGET, + activeWorkPolicy: 'refuse_active_work', + deploymentPathOptions: { platform: 'linux', homeDir: join(base, 'home') }, + }, + { + prepareUnownedHostCutover: async () => assert.fail('owner record already exists'), + async prepareHostCutover(rootId, _selected, target, staged, policy) { + const intent = await readLocalHostDeploymentRecord(rootId, { authorityRoot }); + assert.equal(intent?.state.kind, 'handoff'); + assert.equal((await stat(staged.candidateEntrypoint)).isFile(), true); + assert.deepEqual(target, TARGET); + events.push(`retire:${policy}`); + return { kind: 'target_absent' }; + }, + async observeWriterRelease() { + events.push('writer-released'); + }, + async activateTarget(_rootId, staged) { + events.push(`activate:${staged.launchGeneration}`); + }, + async verifyTargetReady(_rootId, target, staged) { + assert.deepEqual(target, TARGET); + assert.equal((await stat(staged.candidateEntrypoint)).isFile(), true); + events.push('ready'); + }, + }, + { authorityRoot }, + { + resolveInstallation: async () => ({ + owner: CLI_OWNER, + observedRelease: { + version: TARGET.version, + packageRoot: sourcePackageRoot, + cliPath: join(sourcePackageRoot, 'dist', 'cli.js'), + }, + }), + withPackage: async (_candidate, use) => use(sourcePackageRoot), + prepareDeployment: prepareRuntimeHostPackageDeployment, + }, + ); + + assert.equal(result.kind, 'completed'); + assert.equal(events.length, 4); + assert.equal(events[0], 'retire:refuse_active_work'); + assert.equal(events[1], 'writer-released'); + assert.match(events[2] ?? '', /^activate:npm-global-handoff:/u); + assert.equal(events[3], 'ready'); + assert.equal(result.record.state.kind, 'owned'); + assert.deepEqual(result.record.state.owner, CLI_OWNER); + assert.deepEqual(result.record.state.selected, TARGET); +}); + +test('package verification failure leaves deployment authority unchanged', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-handoff-stage-failure-')); + t.after(() => rm(base, { recursive: true, force: true })); + const authorityRoot = join(base, 'authority'); + const claimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP_OWNER, selected: PREVIOUS }, + { authorityRoot }, + ); + assert.equal(claimed.kind, 'applied'); + + await assert.rejects( + reconcileRuntimeHostNpmGlobalDeployment( + { + rootId: ROOT_ID, + transactionId: 'failed-staging', + target: TARGET, + activeWorkPolicy: 'refuse_active_work', + deploymentPathOptions: { platform: 'linux', homeDir: join(base, 'home') }, + }, + { + prepareUnownedHostCutover: async () => assert.fail('cutover must not begin'), + prepareHostCutover: async () => assert.fail('retirement must not begin'), + observeWriterRelease: async () => assert.fail('writer observation must not begin'), + activateTarget: async () => assert.fail('activation must not begin'), + verifyTargetReady: async () => assert.fail('Ready verification must not begin'), + }, + { authorityRoot }, + { + resolveInstallation: async () => ({ + owner: CLI_OWNER, + observedRelease: { + version: TARGET.version, + packageRoot: base, + cliPath: join(base, 'dist', 'cli.js'), + }, + }), + withPackage: async () => { + throw new Error('registry verification failed'); + }, + }, + ), + /registry verification failed/u, + ); + assert.deepEqual(await readLocalHostDeploymentRecord(ROOT_ID, { authorityRoot }), claimed.record); +}); + +test('installed release skew is rejected before staging or authority mutation', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-handoff-installation-skew-')); + t.after(() => rm(base, { recursive: true, force: true })); + const authorityRoot = join(base, 'authority'); + const claimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP_OWNER, selected: PREVIOUS }, + { authorityRoot }, + ); + assert.equal(claimed.kind, 'applied'); + let staged = false; + + await assert.rejects( + reconcileRuntimeHostNpmGlobalDeployment( + { + rootId: ROOT_ID, + transactionId: 'stale-installed-release', + target: TARGET, + activeWorkPolicy: 'refuse_active_work', + }, + { + prepareUnownedHostCutover: async () => assert.fail('cutover must not begin'), + prepareHostCutover: async () => assert.fail('retirement must not begin'), + observeWriterRelease: async () => assert.fail('writer observation must not begin'), + activateTarget: async () => assert.fail('activation must not begin'), + verifyTargetReady: async () => assert.fail('Ready verification must not begin'), + }, + { authorityRoot }, + { + resolveInstallation: async () => ({ + owner: CLI_OWNER, + observedRelease: { + version: '2.1.0', + packageRoot: base, + cliPath: join(base, 'dist', 'cli.js'), + }, + }), + withPackage: async () => { + staged = true; + throw new Error('must not stage'); + }, + }, + ), + (error: unknown) => + error instanceof RuntimeHostLocalHandoffError && error.code === 'installed_release_mismatch', + ); + assert.equal(staged, false); + assert.deepEqual(await readLocalHostDeploymentRecord(ROOT_ID, { authorityRoot }), claimed.record); +}); + +test('explicit npm-global restart claims an exact staged legacy takeover', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-restart-')); + t.after(() => rm(base, { recursive: true, force: true })); + const sourcePackageRoot = await selfContainedPackage(base, TARGET.version); + const authorityRoot = join(base, 'authority'); + let closed = 0; + let launchedEntrypoint = ''; + + const result = await restartRuntimeHostNpmGlobalDeployment( + { + rootPath: join(base, 'root'), + registration: hostRegistration(), + deploymentPathOptions: { platform: 'linux', homeDir: join(base, 'home') }, + }, + { authorityRoot }, + { + resolveInstallation: async () => ({ + owner: CLI_OWNER, + observedRelease: { + version: TARGET.version, + packageRoot: sourcePackageRoot, + cliPath: join(sourcePackageRoot, 'dist', 'cli.js'), + }, + }), + resolveCandidate: async () => TARGET, + withPackage: async (_candidate, use) => use(sourcePackageRoot), + prepareDeployment: prepareRuntimeHostPackageDeployment, + connectOrSpawn: async (input) => { + launchedEntrypoint = String(input.candidateEntrypoint); + const registration = hostRegistration({ + hostEpoch: 'new-host', + pid: 84, + generation: input.generation, + }); + return { + kind: 'connected', + registration, + spawnedProcess: { + pid: 84, + exited: new Promise(() => undefined), + }, + connection: { + close: async () => { + closed += 1; + }, + } as never, + }; + }, + }, + ); + + assert.equal(result.kind, 'completed'); + assert.match(launchedEntrypoint, /execution-candidate-main\.js$/u); + assert.equal(closed >= 1, true); + const record = await readLocalHostDeploymentRecord(ROOT_ID, { authorityRoot }); + assert.deepEqual(record?.state, { kind: 'owned', owner: CLI_OWNER, selected: TARGET }); +}); + +test('legacy restart reports active work without claiming deployment authority', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-restart-active-')); + t.after(() => rm(base, { recursive: true, force: true })); + const sourcePackageRoot = await selfContainedPackage(base, TARGET.version); + const authorityRoot = join(base, 'authority'); + const observed = hostRegistration(); + + const result = await restartRuntimeHostNpmGlobalDeployment( + { + rootPath: join(base, 'root'), + registration: observed, + deploymentPathOptions: { platform: 'linux', homeDir: join(base, 'home') }, + }, + { authorityRoot }, + { + resolveInstallation: async () => ({ + owner: CLI_OWNER, + observedRelease: { + version: TARGET.version, + packageRoot: sourcePackageRoot, + cliPath: join(sourcePackageRoot, 'dist', 'cli.js'), + }, + }), + resolveCandidate: async () => TARGET, + withPackage: async (_candidate, use) => use(sourcePackageRoot), + prepareDeployment: prepareRuntimeHostPackageDeployment, + connectOrSpawn: async () => ({ + kind: 'incompatible', + registration: observed, + handshake: { + kind: 'incompatible', + hostEpoch: observed.hostEpoch, + protocolMin: 0, + protocolMax: 0, + compatibilityEpoch: observed.compatibilityEpoch, + compositionId: observed.compositionId, + compositionRevision: observed.compositionRevision, + state: 'ready', + replacement: 'blocked_by_residency', + }, + }), + }, + ); + + assert.equal(result.kind, 'active_work'); + assert.equal(await readLocalHostDeploymentRecord(ROOT_ID, { authorityRoot }), undefined); +}); + +test('local restart keeps service Hosts under operator authority', async () => { + assert.deepEqual( + await restartRuntimeHostNpmGlobalDeployment({ + rootPath: '/managed-root', + registration: hostRegistration({ lifecycleMode: 'service' }), + }), + { kind: 'operator_required', reason: 'service_host' }, + ); + assert.deepEqual( + await restartRuntimeHostNpmGlobalDeployment({ + rootPath: '/legacy-root', + registration: hostRegistration({ lifecycleMode: undefined }), + }), + { kind: 'operator_required', reason: 'unowned_host' }, + ); +}); + +test('committed target conflicting with the observed Host fails closed', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-restart-observation-conflict-')); + t.after(() => rm(base, { recursive: true, force: true })); + const authorityRoot = join(base, 'authority'); + const claimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: CLI_OWNER, selected: TARGET }, + { authorityRoot }, + ); + assert.equal(claimed.kind, 'applied'); + + await assert.rejects( + restartRuntimeHostNpmGlobalDeployment( + { rootPath: join(base, 'root'), registration: hostRegistration() }, + { authorityRoot }, + { + resolveInstallation: async () => ({ + owner: CLI_OWNER, + observedRelease: { + version: TARGET.version, + packageRoot: base, + cliPath: join(base, 'dist', 'cli.js'), + }, + }), + resolveCandidate: async () => TARGET, + withPackage: async () => assert.fail('conflicting committed target must not be staged'), + }, + ), + (error: unknown) => + error instanceof RuntimeHostLocalHandoffError && + error.code === 'selected_target_observation_conflict', + ); +}); + +async function selfContainedPackage(base: string, version: string): Promise { + const root = join(base, `source-${version}`); + const runtimeHostRoot = join(root, 'node_modules', '@maka', 'runtime-host'); + await Promise.all([ + mkdir(join(root, 'dist'), { recursive: true }), + mkdir(join(runtimeHostRoot, 'dist'), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(root, 'package.json'), JSON.stringify({ name: 'maka-agent', version })), + writeFile(join(root, 'dist', 'cli.js'), ''), + writeFile(join(runtimeHostRoot, 'package.json'), '{}'), + writeFile(join(runtimeHostRoot, 'dist', 'execution-candidate-main.js'), ''), + ]); + return root; +} + +function hostRegistration(overrides: Partial = {}): HostRegistration { + return { + kind: 'maka-runtime-host', + schemaVersion: RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, + rootId: ROOT_ID, + hostEpoch: 'old-host', + endpoint: '/tmp/maka-host.sock', + protocolMin: RUNTIME_HOST_PROTOCOL_VERSION, + protocolMax: RUNTIME_HOST_PROTOCOL_VERSION, + compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + compositionRevision: 'legacy', + lifecycleMode: 'ephemeral', + state: 'ready', + pid: 42, + createdAt: new Date(0).toISOString(), + ...overrides, + }; +} diff --git a/packages/cli/src/runtime-host-cli-context.ts b/packages/cli/src/runtime-host-cli-context.ts index dd4cc4bb38..5bc5ba6739 100644 --- a/packages/cli/src/runtime-host-cli-context.ts +++ b/packages/cli/src/runtime-host-cli-context.ts @@ -44,6 +44,7 @@ import { type HostRegistration, type HostIncompatible, } from '@maka/runtime-host/protocol'; +import { readLocalHostDeploymentRecord } from '@maka/runtime-host/operator'; import { resolveMakaClientDataRoot } from '@maka/storage/workspace-root'; /** @@ -73,7 +74,7 @@ export class RuntimeHostCliConflictError extends RuntimeHostPermanentReconnectEr constructor( readonly handshake: HostIncompatible, - registration: HostRegistration, + readonly registration: HostRegistration, ) { super(formatRuntimeHostCliConflict(handshake, registration)); this.name = 'RuntimeHostCliConflictError'; @@ -98,6 +99,7 @@ interface RuntimeHostCliContextDeps { readonly readConnectionCatalog: typeof readRuntimeHostConnectionCatalog; readonly loadClientInstanceId: typeof loadOrCreateRuntimeHostClientInstanceId; readonly executionCandidateEntrypoint: URL; + readonly readDeploymentRecord: typeof readLocalHostDeploymentRecord; readonly profileCatalog?: RuntimeHostProfileCatalog; } @@ -118,6 +120,7 @@ export async function connectRuntimeHostCli( executionCandidateEntrypoint: new URL( import.meta.resolve('@maka/runtime-host/execution-candidate-main'), ), + readDeploymentRecord: readLocalHostDeploymentRecord, ...overrides, }; const resolvedProfile = await resolveHostProfile(input, deps); @@ -163,6 +166,15 @@ export async function connectRuntimeHostCli( if (connected.kind === 'failed') { throw runtimeHostStartupError(connected.reason, connected.diagnostic); } + if (connected.registration.generation?.startsWith('npm-global-handoff:')) { + const record = await deps.readDeploymentRecord(connected.registration.rootId); + if (record?.state.kind !== 'owned' || record.state.owner.kind !== 'cli') { + await connected.connection.close().catch(() => undefined); + throw new RuntimeHostPermanentReconnectError( + 'RUNTIME_HOST_RECOVERY_REQUIRED: The staged local Runtime Host is Ready, but its installation ownership was not durably committed.', + ); + } + } return connected.connection; }; const initialConnection = await connect( @@ -234,6 +246,18 @@ export function shouldRetryRuntimeHostConflict(answer: string): boolean { return normalized === 'w' || normalized === 'wait'; } +export type RuntimeHostCliConflictDecision = 'restart' | 'wait' | 'cancel'; + +export function resolveRuntimeHostCliConflictDecision( + answer: string, + canRestart: boolean, +): RuntimeHostCliConflictDecision { + const normalized = answer.trim().toLowerCase(); + if (canRestart && (normalized === 'r' || normalized === 'restart')) return 'restart'; + if (normalized === 'w' || normalized === 'wait') return 'wait'; + return 'cancel'; +} + export function resolveRuntimeHostCliTarget( catalog: ConnectionCatalogSnapshot, input: { readonly connectionSlug?: string; readonly model?: string } = {}, diff --git a/packages/cli/src/runtime-host-local-handoff.ts b/packages/cli/src/runtime-host-local-handoff.ts new file mode 100644 index 0000000000..cf6543d051 --- /dev/null +++ b/packages/cli/src/runtime-host-local-handoff.ts @@ -0,0 +1,436 @@ +/* + * 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 { createHash, randomUUID } from 'node:crypto'; +import { realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, posix, resolve, win32 } from 'node:path'; +import { + claimLocalHostProcessDeployment, + handoffLocalHostProcessDeployment, + readLocalHostDeploymentRecord, + type LocalHostDeploymentAuthorityOptions, + type LocalHostProcessDeploymentClaimAdapter, + type LocalHostProcessDeploymentClaimResult, + type LocalHostProcessDeploymentHandoffAdapter, + type LocalHostProcessDeploymentHandoffResult, + type RuntimeHostInstallationOwner, +} from '@maka/runtime-host/operator'; +import { connectOrSpawnRuntimeHost, runtimeHostStartupError } from '@maka/runtime-host/client'; +import { + INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + RUNTIME_HOST_PROTOCOL_VERSION, + type HostRegistration, +} from '@maka/runtime-host/protocol'; +import { resolveRuntimeHostNpmGlobalInstallation } from './runtime-host-cli-installation.js'; +import { + prepareRuntimeHostPackageDeployment, + type RuntimeHostPackageDeployment, +} from './runtime-host-package-deployment.js'; +import { + RuntimeHostUpdatePackageError, + withRuntimeHostRegistryUpdatePackage, +} from './runtime-host-update-package.js'; +import type { RuntimeHostUpdateCandidate } from './runtime-host-update-discovery.js'; +import { resolveRuntimeHostRegistryUpdateCandidate } from './runtime-host-update-discovery.js'; + +const ROOT_ID = /^[a-f0-9]{64}$/u; +const CANDIDATE_RELATIVE_PATH = [ + 'node_modules', + '@maka', + 'runtime-host', + 'dist', + 'execution-candidate-main.js', +] as const; + +export interface RuntimeHostLocalStagedDeployment extends RuntimeHostPackageDeployment { + readonly candidateEntrypoint: string; + /** Transaction-scoped launch fence, not artifact identity or owner authority. */ + readonly launchGeneration: string; +} + +export class RuntimeHostLocalHandoffError extends Error { + constructor( + readonly code: + | 'installed_release_mismatch' + | 'root_changed' + | 'selected_target_observation_conflict', + message: string, + ) { + super(message); + this.name = 'RuntimeHostLocalHandoffError'; + } +} + +export interface RuntimeHostLocalDeploymentPathOptions { + readonly homeDir?: string; + readonly platform?: NodeJS.Platform; +} + +interface RuntimeHostLocalHandoffDeps { + readonly resolveInstallation: typeof resolveRuntimeHostNpmGlobalInstallation; + readonly withPackage: typeof withRuntimeHostRegistryUpdatePackage; + readonly prepareDeployment: typeof prepareRuntimeHostPackageDeployment; + readonly readRecord: typeof readLocalHostDeploymentRecord; + readonly claim: typeof claimLocalHostProcessDeployment; + readonly handoff: typeof handoffLocalHostProcessDeployment; +} + +interface RuntimeHostLocalRestartDeps extends RuntimeHostLocalHandoffDeps { + readonly resolveCandidate: typeof resolveRuntimeHostRegistryUpdateCandidate; + readonly connectOrSpawn: typeof connectOrSpawnRuntimeHost; +} + +export type RuntimeHostLocalProcessLifecycleAdapter = Omit< + LocalHostProcessDeploymentHandoffAdapter, + 'stageTarget' +> & + Pick< + LocalHostProcessDeploymentClaimAdapter, + 'prepareUnownedHostCutover' + >; + +export interface RuntimeHostNpmGlobalReconciliationRequest { + readonly rootId: string; + readonly transactionId: string; + readonly target: RuntimeHostUpdateCandidate; + readonly activeWorkPolicy: 'refuse_active_work' | 'interrupt_active_work'; + readonly installationOptions?: Parameters[0]; + readonly deploymentPathOptions?: RuntimeHostLocalDeploymentPathOptions; +} + +export type RuntimeHostNpmGlobalRestartResult = + | LocalHostProcessDeploymentClaimResult + | LocalHostProcessDeploymentHandoffResult + | { + readonly kind: 'operator_required'; + readonly reason: 'service_host' | 'unowned_host'; + }; + +/** + * Explicitly restarts one local ephemeral Host from the exact artifact matching + * the installed npm-global CLI. The released-Host takeover is a bounded adapter: + * it can replace only the observed exact Host when that Host reports true idle. + */ +export async function restartRuntimeHostNpmGlobalDeployment( + input: { + readonly rootPath: string; + readonly registration: HostRegistration; + readonly installationOptions?: Parameters[0]; + readonly deploymentPathOptions?: RuntimeHostLocalDeploymentPathOptions; + }, + authorityOptions: LocalHostDeploymentAuthorityOptions = {}, + overrides: Partial = {}, +): Promise { + if (input.registration.lifecycleMode !== 'ephemeral') { + return { + kind: 'operator_required', + reason: input.registration.lifecycleMode === 'service' ? 'service_host' : 'unowned_host', + }; + } + const deps: RuntimeHostLocalRestartDeps = { + resolveInstallation: resolveRuntimeHostNpmGlobalInstallation, + withPackage: withRuntimeHostRegistryUpdatePackage, + prepareDeployment: prepareRuntimeHostPackageDeployment, + readRecord: readLocalHostDeploymentRecord, + claim: claimLocalHostProcessDeployment, + handoff: handoffLocalHostProcessDeployment, + resolveCandidate: resolveRuntimeHostRegistryUpdateCandidate, + connectOrSpawn: connectOrSpawnRuntimeHost, + ...overrides, + }; + const installation = await deps.resolveInstallation(input.installationOptions); + const target = await deps.resolveCandidate({ + kind: 'exact', + version: installation.observedRelease.version, + }); + const current = await deps.readRecord(input.registration.rootId, authorityOptions); + if ( + current?.state.kind === 'owned' && + current.state.owner.kind === installation.owner.kind && + current.state.owner.installationId === installation.owner.installationId && + current.state.selected.kind === target.kind && + current.state.selected.version === target.version && + current.state.selected.integrity === target.integrity + ) { + throw new RuntimeHostLocalHandoffError( + 'selected_target_observation_conflict', + 'The active Runtime Host conflicts with the deployment already committed for this installation', + ); + } + const transactionId = restartTransactionId(input.registration.rootId, installation.owner, target); + let connectedTarget: + | Extract>, { kind: 'connected' }> + | undefined; + const prepare = async ( + rootId: string, + staged: RuntimeHostLocalStagedDeployment, + ): Promise<{ readonly kind: 'target_present' | 'active_work' }> => { + if (rootId !== input.registration.rootId) { + throw new RuntimeHostLocalHandoffError( + 'root_changed', + 'The local Runtime Host State Root changed before restart', + ); + } + const result = await deps.connectOrSpawn({ + rootPath: input.rootPath, + protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + generation: staged.launchGeneration, + takeoverHostEpoch: input.registration.hostEpoch, + clientInstanceId: randomUUID(), + candidateEntrypoint: staged.candidateEntrypoint, + }); + if (result.kind === 'connected') { + if ( + result.registration.rootId !== rootId || + result.registration.generation !== staged.launchGeneration || + (result.spawnedProcess !== undefined && + result.spawnedProcess.pid !== result.registration.pid) + ) { + await result.connection.close().catch(() => undefined); + throw new Error('The restarted Runtime Host does not match the exact staged process'); + } + connectedTarget = result; + return { kind: 'target_present' }; + } + if ( + (result.kind === 'incompatible' || result.kind === 'upgrade_required') && + result.registration.hostEpoch === input.registration.hostEpoch + ) { + return { kind: 'active_work' }; + } + if (result.kind === 'failed') { + throw runtimeHostStartupError(result.reason, result.diagnostic); + } + throw new Error('The observed Runtime Host changed before exact local restart completed'); + }; + const unreachable = async (): Promise => { + throw new Error('Legacy local restart must converge during exact takeover'); + }; + try { + return await reconcileRuntimeHostNpmGlobalDeployment( + { + rootId: input.registration.rootId, + transactionId, + target, + activeWorkPolicy: 'refuse_active_work', + ...(input.installationOptions ? { installationOptions: input.installationOptions } : {}), + ...(input.deploymentPathOptions + ? { deploymentPathOptions: input.deploymentPathOptions } + : {}), + }, + { + prepareUnownedHostCutover: (rootId, _target, staged) => prepare(rootId, staged), + prepareHostCutover: (rootId, _selected, _target, staged) => prepare(rootId, staged), + observeWriterRelease: unreachable, + activateTarget: unreachable, + async verifyTargetReady(rootId, _target, staged) { + if ( + connectedTarget?.registration.rootId !== rootId || + connectedTarget.registration.generation !== staged.launchGeneration + ) { + throw new Error('Exact restarted Runtime Host Ready evidence is unavailable'); + } + await connectedTarget.connection.close(); + }, + }, + authorityOptions, + deps, + ); + } finally { + await connectedTarget?.connection.close().catch(() => undefined); + } +} + +/** + * Resolves the persistent npm-global owner, stages the exact registry target, + * and delegates the only authority mutation to the shared local handoff. + * Lifecycle policy and process control stay in the caller-provided adapter. + */ +export async function reconcileRuntimeHostNpmGlobalDeployment( + request: RuntimeHostNpmGlobalReconciliationRequest, + lifecycle: RuntimeHostLocalProcessLifecycleAdapter, + authorityOptions: LocalHostDeploymentAuthorityOptions = {}, + overrides: Partial = {}, +): Promise { + const deps: RuntimeHostLocalHandoffDeps = { + resolveInstallation: resolveRuntimeHostNpmGlobalInstallation, + withPackage: withRuntimeHostRegistryUpdatePackage, + prepareDeployment: prepareRuntimeHostPackageDeployment, + readRecord: readLocalHostDeploymentRecord, + claim: claimLocalHostProcessDeployment, + handoff: handoffLocalHostProcessDeployment, + ...overrides, + }; + const installation = await deps.resolveInstallation(request.installationOptions); + if (installation.observedRelease.version !== request.target.version) { + throw new RuntimeHostLocalHandoffError( + 'installed_release_mismatch', + `The installed Maka release changed from ${request.target.version} to ${installation.observedRelease.version} before local Host reconciliation`, + ); + } + const stageTarget = (target: RuntimeHostUpdateCandidate, transactionId: string) => + stageRuntimeHostNpmGlobalDeploymentTarget( + { + rootId: request.rootId, + owner: installation.owner, + target, + transactionId, + }, + request.deploymentPathOptions, + deps, + ); + const current = await deps.readRecord(request.rootId, authorityOptions); + if (!current) { + return deps.claim( + { + rootId: request.rootId, + transactionId: request.transactionId, + owner: installation.owner, + target: request.target, + activeWorkPolicy: request.activeWorkPolicy, + }, + { ...lifecycle, stageTarget }, + authorityOptions, + ); + } + return deps.handoff( + { + rootId: request.rootId, + expectedRevision: current.revision, + transactionId: + current.state.kind === 'handoff' ? current.state.transactionId : request.transactionId, + from: current.state.kind === 'handoff' ? current.state.from : current.state.owner, + to: installation.owner, + target: request.target, + activeWorkPolicy: request.activeWorkPolicy, + }, + { + ...lifecycle, + stageTarget, + }, + authorityOptions, + ); +} + +export async function stageRuntimeHostNpmGlobalDeploymentTarget( + input: { + readonly rootId: string; + readonly owner: RuntimeHostInstallationOwner & { readonly kind: 'cli' }; + readonly target: RuntimeHostUpdateCandidate; + readonly transactionId: string; + }, + pathOptions: RuntimeHostLocalDeploymentPathOptions = {}, + overrides: Pick = { + withPackage: withRuntimeHostRegistryUpdatePackage, + prepareDeployment: prepareRuntimeHostPackageDeployment, + }, +): Promise { + const deploymentRoot = resolveRuntimeHostLocalCliDeploymentRoot( + input.rootId, + input.owner, + pathOptions, + ); + return overrides.withPackage(input.target, async (sourcePackageRoot) => { + const staged = await overrides.prepareDeployment({ + deploymentRoot, + sourcePackageRoot, + version: input.target.version, + packageIntegrity: input.target.integrity, + }); + const candidateEntrypoint = await requireCandidateEntrypoint(staged.packageRoot); + return { + ...staged, + candidateEntrypoint, + launchGeneration: launchGeneration(input.transactionId, input.target), + }; + }); +} + +export function resolveRuntimeHostLocalCliDeploymentRoot( + rootId: string, + owner: RuntimeHostInstallationOwner & { readonly kind: 'cli' }, + options: RuntimeHostLocalDeploymentPathOptions = {}, +): string { + if (!ROOT_ID.test(rootId) || owner.kind !== 'cli' || owner.installationId.length === 0) { + throw new TypeError('Invalid local Runtime Host CLI deployment identity'); + } + const platform = options.platform ?? process.platform; + const path = platform === 'win32' ? win32 : posix; + const accountHome = path.normalize(options.homeDir ?? homedir()); + if (!path.isAbsolute(accountHome)) { + throw new TypeError('The OS account home must be absolute'); + } + const dataRoot = + platform === 'darwin' + ? path.join(accountHome, 'Library', 'Application Support') + : platform === 'win32' + ? path.join(accountHome, 'AppData', 'Local') + : path.join(accountHome, '.local', 'share'); + const ownerKey = createHash('sha256').update(owner.installationId).digest('hex'); + return path.join(dataRoot, 'Maka', 'runtime-host-deployments', 'cli', ownerKey, rootId); +} + +async function requireCandidateEntrypoint(packageRoot: string): Promise { + const requested = join(packageRoot, ...CANDIDATE_RELATIVE_PATH); + let candidate: string; + try { + candidate = await realpath(requested); + if (!(await stat(candidate)).isFile()) throw new Error('Not a file'); + } catch (cause) { + throw invalidStagedPackage('The staged Maka package has no Runtime Host candidate', cause); + } + if (candidate !== resolve(requested)) { + throw invalidStagedPackage('The staged Runtime Host candidate is redirected'); + } + return candidate; +} + +function launchGeneration(transactionId: string, target: RuntimeHostUpdateCandidate): string { + return `npm-global-handoff:${createHash('sha256') + .update(transactionId) + .update('\0') + .update(target.version) + .update('\0') + .update(target.integrity) + .digest('hex')}`; +} + +function restartTransactionId( + rootId: string, + owner: RuntimeHostInstallationOwner, + target: RuntimeHostUpdateCandidate, +): string { + return `npm-global-restart:${createHash('sha256') + .update(rootId) + .update('\0') + .update(owner.kind) + .update('\0') + .update(owner.installationId) + .update('\0') + .update(target.version) + .update('\0') + .update(target.integrity) + .digest('hex')}`; +} + +function invalidStagedPackage(message: string, cause?: unknown): RuntimeHostUpdatePackageError { + return new RuntimeHostUpdatePackageError('invalid_package', message, { cause }); +} diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts index 33a27d1dcb..788fb4b958 100644 --- a/packages/cli/src/runtime-host-managed-deployment.ts +++ b/packages/cli/src/runtime-host-managed-deployment.ts @@ -17,35 +17,19 @@ * under the License. */ -import { createHash, randomUUID } from 'node:crypto'; -import { - cp, - lstat, - mkdir, - open, - readFile, - readdir, - realpath, - rename, - rm, - stat, -} from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { lstat, open, readdir, realpath, rename, rm } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { isSha512PackageIntegrity } from '@maka/runtime-host/operator'; - -const PACKAGE_NAME = 'maka-agent'; +import { + openRuntimeHostPackageDeployment, + prepareRuntimeHostPackageDeployment, + resolveRuntimeHostPackageCliPath, + RuntimeHostPackageDeploymentError as RuntimeHostManagedDeploymentError, + type RuntimeHostPackageDeployment, +} from './runtime-host-package-deployment.js'; -export class RuntimeHostManagedDeploymentError extends Error { - constructor( - readonly code: 'invalid_package' | 'deployment_failed', - message: string, - options?: ErrorOptions, - ) { - super(message, options); - this.name = 'RuntimeHostManagedDeploymentError'; - } -} +export { RuntimeHostPackageDeploymentError as RuntimeHostManagedDeploymentError } from './runtime-host-package-deployment.js'; export interface RuntimeHostManagedPackageDeployment { readonly version: string; @@ -62,9 +46,7 @@ export function resolveRuntimeHostManagedPackageCliPath( version: string, packageIntegrity?: string, ): string { - assertVersion(version); - const packageDirectory = packageIntegrity ? registryPackageDirectory(packageIntegrity) : version; - return join(resolve(deploymentRoot), 'versions', packageDirectory, 'dist', 'cli.js'); + return resolveRuntimeHostPackageCliPath(deploymentRoot, version, packageIntegrity); } export function isRuntimeHostDevelopmentPackageVersion(value: unknown): value is string { @@ -81,55 +63,14 @@ export async function prepareRuntimeHostManagedPackageDeployment( }, options: RuntimeHostManagedDeploymentPathOptions = {}, ): Promise { - assertVersion(input.version); - const sourcePackageRoot = await validatePackage(input.sourcePackageRoot, input.version); - const requestedDeploymentRoot = resolveRuntimeHostManagedDeploymentRoot(input.serviceId, options); - await mkdir(join(requestedDeploymentRoot, 'versions'), { recursive: true, mode: 0o700 }); - const deploymentRoot = await realpath(requestedDeploymentRoot); - const versionsRoot = join(deploymentRoot, 'versions'); - const packageDirectory = input.packageIntegrity - ? registryPackageDirectory(input.packageIntegrity) - : input.version; - const packageRoot = join(versionsRoot, packageDirectory); - const cliPath = resolveRuntimeHostManagedPackageCliPath( - deploymentRoot, - input.version, - input.packageIntegrity, - ); const clientDataRoot = resolve(input.clientDataRoot); - if (await pathExists(packageRoot)) { - await validatePackage(packageRoot, input.version); - return deployment(input.version, deploymentRoot, packageRoot, cliPath, clientDataRoot, false); - } - - await removeAbandonedPackageWorkspaces(versionsRoot, packageDirectory); - const stagingRoot = join(versionsRoot, `.${packageDirectory}.${randomUUID()}.tmp`); - try { - await cp(sourcePackageRoot, stagingRoot, { - recursive: true, - force: false, - errorOnExist: true, - preserveTimestamps: true, - }); - await validatePackage(stagingRoot, input.version); - try { - await rename(stagingRoot, packageRoot); - } catch (error) { - if (!isNodeError(error, 'EEXIST') && !isNodeError(error, 'ENOTEMPTY')) throw error; - await validatePackage(packageRoot, input.version); - await rm(stagingRoot, { recursive: true, force: true }); - return deployment(input.version, deploymentRoot, packageRoot, cliPath, clientDataRoot, false); - } - return deployment(input.version, deploymentRoot, packageRoot, cliPath, clientDataRoot, true); - } catch (error) { - await rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined); - if (error instanceof RuntimeHostManagedDeploymentError) throw error; - throw new RuntimeHostManagedDeploymentError( - 'deployment_failed', - `Unable to install Maka ${input.version} into the managed Runtime Host deployment`, - { cause: error }, - ); - } + const staged = await prepareRuntimeHostPackageDeployment({ + deploymentRoot: resolveRuntimeHostManagedDeploymentRoot(input.serviceId, options), + sourcePackageRoot: input.sourcePackageRoot, + version: input.version, + ...(input.packageIntegrity ? { packageIntegrity: input.packageIntegrity } : {}), + }); + return managedDeployment(staged, clientDataRoot); } export async function openRuntimeHostManagedPackageDeployment(input: { @@ -139,7 +80,6 @@ export async function openRuntimeHostManagedPackageDeployment(input: { readonly cliPath: string; readonly version: string; }): Promise { - assertVersion(input.version); let deploymentRoot: string; let cliPath: string; try { @@ -158,36 +98,13 @@ export async function openRuntimeHostManagedPackageDeployment(input: { 'The configured Runtime Host package does not belong to its managed deployment', ); } - const packageRoot = await validatePackage(dirname(dirname(cliPath)), input.version); - if (cliPath !== join(packageRoot, 'dist', 'cli.js')) { - throw new RuntimeHostManagedDeploymentError( - 'invalid_package', - 'The configured Runtime Host CLI does not match its managed package', - ); - } - return deployment( - input.version, - deploymentRoot, - packageRoot, - cliPath, + return managedDeployment( + await openRuntimeHostPackageDeployment({ + deploymentRoot, + cliPath, + version: input.version, + }), resolve(input.clientDataRoot), - false, - ); -} - -async function removeAbandonedPackageWorkspaces( - versionsRoot: string, - packageDirectory: string, -): Promise { - const prefix = `.${packageDirectory}.`; - await Promise.all( - (await readdir(versionsRoot, { withFileTypes: true })) - .filter( - (entry) => - entry.name.startsWith(prefix) && - (entry.name.endsWith('.tmp') || entry.name.endsWith('.deleted')), - ) - .map((entry) => rm(join(versionsRoot, entry.name), { recursive: true, force: true })), ); } @@ -292,63 +209,20 @@ export async function removeRuntimeHostManagedDeployment( await rm(requestedRoot, { recursive: true, force: true }); } -async function validatePackage(path: string, version: string): Promise { - let packageRoot: string; - let manifest: unknown; - try { - packageRoot = await realpath(resolve(path)); - manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) as unknown; - const cli = await stat(join(packageRoot, 'dist', 'cli.js')); - const runtimeHost = await stat( - join(packageRoot, 'node_modules', '@maka', 'runtime-host', 'package.json'), - ); - if (!cli.isFile() || !runtimeHost.isFile()) throw new Error('Package payload is incomplete'); - } catch (error) { - throw new RuntimeHostManagedDeploymentError( - 'invalid_package', - `Maka ${version} is not a self-contained release package`, - { cause: error }, - ); - } - if (!isRecord(manifest) || manifest.name !== PACKAGE_NAME || manifest.version !== version) { - throw new RuntimeHostManagedDeploymentError( - 'invalid_package', - `The setup package does not contain ${PACKAGE_NAME}@${version}`, - ); - } - return packageRoot; -} - -async function pathExists(path: string): Promise { - try { - await stat(path); - return true; - } catch (error) { - if (isNodeError(error, 'ENOENT')) return false; - throw error; - } -} - -function deployment( - version: string, - root: string, - packageRoot: string, - cliPath: string, +function managedDeployment( + staged: RuntimeHostPackageDeployment, clientDataRoot: string, - created: boolean, ): RuntimeHostManagedPackageDeployment { - const operatorPath = join(root, 'operator'); + const operatorPath = join(staged.root, 'operator'); return { - version, - root, - cliPath, + version: staged.version, + root: staged.root, + cliPath: staged.cliPath, operatorPath, - activate: () => writeOperatorLauncher(operatorPath, process.execPath, cliPath, clientDataRoot), - cleanup: () => pruneInactivePackages(dirname(packageRoot), basename(packageRoot)), - rollback: () => - created - ? removePackageAtomically(dirname(packageRoot), basename(packageRoot)) - : Promise.resolve(), + activate: () => + writeOperatorLauncher(operatorPath, process.execPath, staged.cliPath, clientDataRoot), + cleanup: staged.cleanup, + rollback: staged.rollback, }; } @@ -396,57 +270,6 @@ function quotePosix(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } -async function pruneInactivePackages(versionsRoot: string, retainedPackage: string): Promise { - await Promise.all( - (await readdir(versionsRoot, { withFileTypes: true })) - .filter((entry) => entry.name !== retainedPackage) - .map((entry) => removePackageAtomically(versionsRoot, entry.name)), - ); -} - -async function removePackageAtomically(versionsRoot: string, packageName: string): Promise { - const packageRoot = join(versionsRoot, packageName); - try { - if (packageName.startsWith('.') && packageName.endsWith('.deleted')) { - await rm(packageRoot, { recursive: true, force: true }); - return; - } - const tombstone = join(versionsRoot, `.${packageName}.${randomUUID()}.deleted`); - await rename(packageRoot, tombstone); - await rm(tombstone, { recursive: true, force: true }); - } catch (error) { - if (isNodeError(error, 'ENOENT')) return; - throw new RuntimeHostManagedDeploymentError( - 'deployment_failed', - 'Unable to remove an inactive managed Runtime Host package', - { cause: error }, - ); - } -} - -function registryPackageDirectory(integrity: string): string { - if (!isSha512PackageIntegrity(integrity)) { - throw new RuntimeHostManagedDeploymentError( - 'invalid_package', - 'The managed Runtime Host package integrity is invalid', - ); - } - return `registry-${createHash('sha256').update(integrity).digest('hex')}`; -} - -function assertVersion(version: string): void { - if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$/u.test(version)) { - throw new RuntimeHostManagedDeploymentError( - 'invalid_package', - 'The Maka package version cannot be used as a managed deployment identity', - ); - } -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { return error instanceof Error && 'code' in error && error.code === code; } diff --git a/packages/cli/src/runtime-host-package-deployment.ts b/packages/cli/src/runtime-host-package-deployment.ts new file mode 100644 index 0000000000..bc84884a33 --- /dev/null +++ b/packages/cli/src/runtime-host-package-deployment.ts @@ -0,0 +1,282 @@ +/* + * 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 { createHash, randomUUID } from 'node:crypto'; +import { cp, mkdir, readFile, readdir, realpath, rename, rm, stat } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { isSha512PackageIntegrity } from '@maka/runtime-host/operator'; + +const PACKAGE_NAME = 'maka-agent'; + +export class RuntimeHostPackageDeploymentError extends Error { + constructor( + readonly code: 'invalid_package' | 'deployment_failed', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'RuntimeHostPackageDeploymentError'; + } +} + +export interface RuntimeHostPackageDeployment { + readonly version: string; + readonly root: string; + readonly packageRoot: string; + readonly cliPath: string; + cleanup(): Promise; + rollback(): Promise; +} + +export function resolveRuntimeHostPackageCliPath( + deploymentRoot: string, + version: string, + packageIntegrity?: string, +): string { + assertVersion(version); + const packageDirectory = packageIntegrity ? registryPackageDirectory(packageIntegrity) : version; + return join(resolve(deploymentRoot), 'versions', packageDirectory, 'dist', 'cli.js'); +} + +export async function prepareRuntimeHostPackageDeployment(input: { + readonly deploymentRoot: string; + readonly sourcePackageRoot: string; + readonly version: string; + readonly packageIntegrity?: string; +}): Promise { + assertVersion(input.version); + const sourcePackageRoot = await validatePackage(input.sourcePackageRoot, input.version); + await mkdir(join(input.deploymentRoot, 'versions'), { recursive: true, mode: 0o700 }); + const deploymentRoot = await realpath(resolve(input.deploymentRoot)); + const versionsRoot = join(deploymentRoot, 'versions'); + const packageDirectory = input.packageIntegrity + ? registryPackageDirectory(input.packageIntegrity) + : input.version; + const packageRoot = join(versionsRoot, packageDirectory); + const cliPath = resolveRuntimeHostPackageCliPath( + deploymentRoot, + input.version, + input.packageIntegrity, + ); + if (await pathExists(packageRoot)) { + await validatePackage(packageRoot, input.version); + return deployment(input.version, deploymentRoot, packageRoot, cliPath, false); + } + + await removeAbandonedPackageWorkspaces(versionsRoot, packageDirectory); + const stagingRoot = join(versionsRoot, `.${packageDirectory}.${randomUUID()}.tmp`); + try { + await cp(sourcePackageRoot, stagingRoot, { + recursive: true, + force: false, + errorOnExist: true, + preserveTimestamps: true, + }); + await validatePackage(stagingRoot, input.version); + try { + await rename(stagingRoot, packageRoot); + } catch (error) { + if (!isNodeError(error, 'EEXIST') && !isNodeError(error, 'ENOTEMPTY')) throw error; + await validatePackage(packageRoot, input.version); + await rm(stagingRoot, { recursive: true, force: true }); + return deployment(input.version, deploymentRoot, packageRoot, cliPath, false); + } + return deployment(input.version, deploymentRoot, packageRoot, cliPath, true); + } catch (error) { + await rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined); + if (error instanceof RuntimeHostPackageDeploymentError) throw error; + throw new RuntimeHostPackageDeploymentError( + 'deployment_failed', + `Unable to install Maka ${input.version} into the Runtime Host package store`, + { cause: error }, + ); + } +} + +export async function openRuntimeHostPackageDeployment(input: { + readonly deploymentRoot: string; + readonly cliPath: string; + readonly version: string; +}): Promise { + assertVersion(input.version); + let deploymentRoot: string; + let cliPath: string; + try { + deploymentRoot = await realpath(resolve(input.deploymentRoot)); + cliPath = await realpath(input.cliPath); + } catch (error) { + throw new RuntimeHostPackageDeploymentError( + 'invalid_package', + `The staged Maka ${input.version} package is unavailable`, + { cause: error }, + ); + } + if (!isRuntimeHostPackageDeploymentCli(deploymentRoot, cliPath)) { + throw new RuntimeHostPackageDeploymentError( + 'invalid_package', + 'The configured Runtime Host package does not belong to its package store', + ); + } + const packageRoot = await validatePackage(dirname(dirname(cliPath)), input.version); + if (cliPath !== join(packageRoot, 'dist', 'cli.js')) { + throw new RuntimeHostPackageDeploymentError( + 'invalid_package', + 'The configured Runtime Host CLI does not match its staged package', + ); + } + return deployment(input.version, deploymentRoot, packageRoot, cliPath, false); +} + +export function isRuntimeHostPackageDeploymentCli(root: string, cliPath: string): boolean { + const pathFromVersions = relative(join(resolve(root), 'versions'), resolve(cliPath)); + return ( + pathFromVersions !== '' && + pathFromVersions !== '..' && + !pathFromVersions.startsWith(`..${sep}`) && + !isAbsolute(pathFromVersions) + ); +} + +async function removeAbandonedPackageWorkspaces( + versionsRoot: string, + packageDirectory: string, +): Promise { + const prefix = `.${packageDirectory}.`; + await Promise.all( + (await readdir(versionsRoot, { withFileTypes: true })) + .filter( + (entry) => + entry.name.startsWith(prefix) && + (entry.name.endsWith('.tmp') || entry.name.endsWith('.deleted')), + ) + .map((entry) => rm(join(versionsRoot, entry.name), { recursive: true, force: true })), + ); +} + +async function validatePackage(path: string, version: string): Promise { + let packageRoot: string; + let manifest: unknown; + try { + packageRoot = await realpath(resolve(path)); + manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) as unknown; + const cli = await stat(join(packageRoot, 'dist', 'cli.js')); + const runtimeHost = await stat( + join(packageRoot, 'node_modules', '@maka', 'runtime-host', 'package.json'), + ); + if (!cli.isFile() || !runtimeHost.isFile()) throw new Error('Package payload is incomplete'); + } catch (error) { + throw new RuntimeHostPackageDeploymentError( + 'invalid_package', + `Maka ${version} is not a self-contained release package`, + { cause: error }, + ); + } + if (!isRecord(manifest) || manifest.name !== PACKAGE_NAME || manifest.version !== version) { + throw new RuntimeHostPackageDeploymentError( + 'invalid_package', + `The staged package does not contain ${PACKAGE_NAME}@${version}`, + ); + } + return packageRoot; +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if (isNodeError(error, 'ENOENT')) return false; + throw error; + } +} + +function deployment( + version: string, + root: string, + packageRoot: string, + cliPath: string, + created: boolean, +): RuntimeHostPackageDeployment { + return { + version, + root, + packageRoot, + cliPath, + cleanup: () => pruneInactivePackages(dirname(packageRoot), basename(packageRoot)), + rollback: () => + created + ? removePackageAtomically(dirname(packageRoot), basename(packageRoot)) + : Promise.resolve(), + }; +} + +async function pruneInactivePackages(versionsRoot: string, retainedPackage: string): Promise { + await Promise.all( + (await readdir(versionsRoot, { withFileTypes: true })) + .filter((entry) => entry.name !== retainedPackage) + .map((entry) => removePackageAtomically(versionsRoot, entry.name)), + ); +} + +async function removePackageAtomically(versionsRoot: string, packageName: string): Promise { + const packageRoot = join(versionsRoot, packageName); + try { + if (packageName.startsWith('.') && packageName.endsWith('.deleted')) { + await rm(packageRoot, { recursive: true, force: true }); + return; + } + const tombstone = join(versionsRoot, `.${packageName}.${randomUUID()}.deleted`); + await rename(packageRoot, tombstone); + await rm(tombstone, { recursive: true, force: true }); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return; + throw new RuntimeHostPackageDeploymentError( + 'deployment_failed', + 'Unable to remove an inactive Runtime Host package', + { cause: error }, + ); + } +} + +function registryPackageDirectory(integrity: string): string { + if (!isSha512PackageIntegrity(integrity)) { + throw new RuntimeHostPackageDeploymentError( + 'invalid_package', + 'The Runtime Host package integrity is invalid', + ); + } + return `registry-${createHash('sha256').update(integrity).digest('hex')}`; +} + +function assertVersion(version: string): void { + if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$/u.test(version)) { + throw new RuntimeHostPackageDeploymentError( + 'invalid_package', + 'The Maka package version cannot be used as a deployment identity', + ); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} diff --git a/packages/cli/src/runtime-host-tui-command.ts b/packages/cli/src/runtime-host-tui-command.ts index edf426b526..e55eb8b71e 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -26,9 +26,11 @@ import { createForeignSessionStore } from '@maka/storage/foreign-session-store'; import { formatMakaResumeHint } from './cli-invocation.js'; import { connectRuntimeHostCli, + resolveRuntimeHostCliConflictDecision, RuntimeHostCliConflictError, - shouldRetryRuntimeHostConflict, } from './runtime-host-cli-context.js'; +import { resolveRuntimeHostNpmGlobalInstallation } from './runtime-host-cli-installation.js'; +import { restartRuntimeHostNpmGlobalDeployment } from './runtime-host-local-handoff.js'; import { createRuntimeHostOnboardingSurface } from './runtime-host-onboarding.js'; import type { MakaPiTuiTurnActivitySurface } from './pi-tui-contracts.js'; import { runMakaPiTui } from './pi-tui-runner.js'; @@ -126,26 +128,66 @@ export async function runRuntimeHostTui(input: RunRuntimeHostTuiInput): Promise< async function createTuiContextWithHostConflictPrompt( input: Parameters[0], ): Promise> | null> { + const blockedRestartEpochs = new Set(); while (true) { try { return await createRuntimeHostTuiContext(input); } catch (error) { if (!(error instanceof RuntimeHostCliConflictError) || !process.stdin.isTTY) throw error; process.stderr.write(`${error.message}\n`); + const canRestart = + error.registration.lifecycleMode === 'ephemeral' && + !blockedRestartEpochs.has(error.registration.hostEpoch) && + (await isPersistentNpmGlobalCli()); const readline = createInterface({ input: process.stdin, output: process.stderr }); + let decision; try { const answer = await readline.question( - 'Wait only if the existing Host is expected to become idle, or cancel? [w/C] ', + canRestart + ? 'Restart this local Host if it is idle, wait for it to exit, or cancel? [r/w/C] ' + : 'Wait only if the existing Host is expected to exit, or cancel? [w/C] ', ); - if (!shouldRetryRuntimeHostConflict(answer)) return null; + decision = resolveRuntimeHostCliConflictDecision(answer, canRestart); } finally { readline.close(); } + if (decision === 'cancel') return null; + if (decision === 'restart') { + const result = await restartRuntimeHostNpmGlobalDeployment({ + rootPath: input.rootPath, + registration: error.registration, + }); + if (result.kind === 'completed') continue; + if (result.kind === 'active_work') { + blockedRestartEpochs.add(error.registration.hostEpoch); + process.stderr.write( + 'The existing Runtime Host still owns active or durable work and was not interrupted.\n', + ); + continue; + } + if (result.kind === 'operator_required') { + blockedRestartEpochs.add(error.registration.hostEpoch); + continue; + } + if (result.kind === 'rejected') continue; + throw new Error(`Local Runtime Host restart requires recovery at ${result.phase}`, { + cause: result.cause, + }); + } await waitForHostRetry(); } } } +async function isPersistentNpmGlobalCli(): Promise { + try { + await resolveRuntimeHostNpmGlobalInstallation(); + return true; + } catch { + return false; + } +} + function waitForHostRetry(): Promise { return new Promise((resolve) => setTimeout(resolve, 2_000)); } diff --git a/packages/runtime-host/src/__tests__/local-process-deployment-handoff.test.ts b/packages/runtime-host/src/__tests__/local-process-deployment-handoff.test.ts index 573524f5bd..b6d5ace6ce 100644 --- a/packages/runtime-host/src/__tests__/local-process-deployment-handoff.test.ts +++ b/packages/runtime-host/src/__tests__/local-process-deployment-handoff.test.ts @@ -29,7 +29,9 @@ import { type RuntimeHostInstallationOwner, } from '../operator/local-deployment-owner.js'; import { + claimLocalHostProcessDeployment, handoffLocalHostProcessDeployment, + type LocalHostProcessDeploymentClaimAdapter, type LocalHostProcessDeploymentHandoffAdapter, } from '../operator/local-process-deployment-handoff.js'; import type { RuntimeHostDeploymentIdentity } from '../operator/update-package-evidence.js'; @@ -469,3 +471,99 @@ test('serializes the whole cutover so a competing owner mutation cannot enter mi 'owner_changed', ); }); + +function claimAdapter( + events: string[], + host: 'target_absent' | 'target_present' | 'active_work' = 'target_absent', +): LocalHostProcessDeploymentClaimAdapter<{ readonly path: string }> { + return { + async stageTarget(target, transactionId) { + events.push(`stage:${target.version}:${transactionId}`); + return { path: '/verified/maka' }; + }, + async prepareUnownedHostCutover(_rootId, _target, _staged, policy) { + events.push(`retire:${policy}`); + return { kind: host }; + }, + async observeWriterRelease() { + events.push('writer_released'); + }, + async activateTarget(_rootId, staged) { + events.push(`activate:${staged.path}`); + }, + async verifyTargetReady(_rootId, target) { + events.push(`ready:${target.version}`); + }, + }; +} + +test('establishes the first owner only after exact target Ready', async (t) => { + const options = await authority(t); + const events: string[] = []; + const result = await claimLocalHostProcessDeployment( + { + rootId: ROOT_ID, + transactionId: 'initial-cli-claim', + owner: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work', + }, + claimAdapter(events), + options, + ); + + assert.equal(result.kind, 'completed'); + assert.deepEqual(events, [ + 'stage:2.0.0:initial-cli-claim', + 'retire:refuse_active_work', + 'writer_released', + 'activate:/verified/maka', + 'ready:2.0.0', + ]); + assert.deepEqual(result.kind === 'completed' ? result.record.state : undefined, { + kind: 'owned', + owner: CLI, + selected: TARGET_DEPLOYMENT, + }); +}); + +test('does not invent initial authority when legacy active work refuses cutover', async (t) => { + const options = await authority(t); + const events: string[] = []; + const result = await claimLocalHostProcessDeployment( + { + rootId: ROOT_ID, + transactionId: 'blocked-initial-claim', + owner: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work', + }, + claimAdapter(events, 'active_work'), + options, + ); + + assert.equal(result.kind, 'active_work'); + assert.deepEqual(events, ['stage:2.0.0:blocked-initial-claim', 'retire:refuse_active_work']); + assert.equal(await readLocalHostDeploymentRecord(ROOT_ID, options), undefined); +}); + +test('rejects a raced initial claim before touching the observed Host', async (t) => { + const options = await authority(t); + await claimed(options); + const events: string[] = []; + const result = await claimLocalHostProcessDeployment( + { + rootId: ROOT_ID, + transactionId: 'raced-initial-claim', + owner: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work', + }, + claimAdapter(events), + options, + ); + + assert.equal(result.kind, 'rejected'); + assert.equal(result.kind === 'rejected' ? result.reason : undefined, 'owner_exists'); + assert.deepEqual(events, ['stage:2.0.0:raced-initial-claim']); +}); diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index 977ae0eb34..c59f7d8a5e 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -80,7 +80,12 @@ export { type RuntimeHostInstallationOwner, } from './local-deployment-owner.js'; export { + claimLocalHostProcessDeployment, handoffLocalHostProcessDeployment, + type LocalHostProcessDeploymentClaimAdapter, + type LocalHostProcessDeploymentClaimPhase, + type LocalHostProcessDeploymentClaimRequest, + type LocalHostProcessDeploymentClaimResult, type LocalHostProcessDeploymentHandoffAdapter, type LocalHostProcessDeploymentHandoffPhase, type LocalHostProcessDeploymentHandoffRequest, diff --git a/packages/runtime-host/src/operator/local-process-deployment-handoff.ts b/packages/runtime-host/src/operator/local-process-deployment-handoff.ts index 30793b5997..3fb7f4de1f 100644 --- a/packages/runtime-host/src/operator/local-process-deployment-handoff.ts +++ b/packages/runtime-host/src/operator/local-process-deployment-handoff.ts @@ -71,6 +71,59 @@ export interface LocalHostProcessDeploymentHandoffAdapter { ): Promise; } +export interface LocalHostProcessDeploymentClaimAdapter { + /** Stages and verifies the exact runnable closure before authority mutation. */ + stageTarget(target: RuntimeHostDeploymentIdentity, transactionId: string): Promise; + /** + * Retires or recognizes an unowned legacy local Host. `active_work` guarantees + * that the previous Host remains authoritative and initial claim did not begin. + */ + prepareUnownedHostCutover( + rootId: string, + target: RuntimeHostDeploymentIdentity, + staged: StagedTarget, + policy: LocalHostHandoffActiveWorkPolicy, + ): Promise<{ + readonly kind: 'target_absent' | 'target_present' | 'active_work'; + }>; + observeWriterRelease(rootId: string): Promise; + activateTarget(rootId: string, staged: StagedTarget): Promise; + verifyTargetReady( + rootId: string, + target: RuntimeHostDeploymentIdentity, + staged: StagedTarget, + ): Promise; +} + +export interface LocalHostProcessDeploymentClaimRequest { + readonly rootId: string; + readonly transactionId: string; + readonly owner: RuntimeHostInstallationOwner; + readonly target: RuntimeHostDeploymentIdentity; + readonly activeWorkPolicy: LocalHostHandoffActiveWorkPolicy; +} + +export type LocalHostProcessDeploymentClaimPhase = + | 'prepare_host_cutover' + | 'observe_writer_release' + | 'activate_target' + | 'verify_target_ready' + | 'claim'; + +export type LocalHostProcessDeploymentClaimResult = + | { readonly kind: 'completed'; readonly record: LocalHostDeploymentRecord } + | { readonly kind: 'active_work' } + | { + readonly kind: 'rejected'; + readonly reason: LocalHostDeploymentTransitionRejection; + readonly record: LocalHostDeploymentRecord | undefined; + } + | { + readonly kind: 'recovery_required'; + readonly phase: LocalHostProcessDeploymentClaimPhase; + readonly cause: unknown; + }; + export type LocalHostProcessDeploymentHandoffPhase = | 'prepare_host_cutover' | 'observe_writer_release' @@ -241,6 +294,99 @@ export async function handoffLocalHostProcessDeployment( ); } +/** + * Establishes the first durable owner after an exact target is running and + * Ready. No pre-existing deployment identity is invented for legacy Hosts. + * A crash before claim leaves no false durable record; the transaction-scoped + * staged target can be re-observed by the adapter on retry. + */ +export async function claimLocalHostProcessDeployment( + request: LocalHostProcessDeploymentClaimRequest, + adapter: LocalHostProcessDeploymentClaimAdapter, + authorityOptions: LocalHostDeploymentAuthorityOptions = {}, +): Promise { + const staged = await adapter.stageTarget(request.target, request.transactionId); + return withLocalHostDeploymentAuthority( + request.rootId, + async (authority) => { + const current = await authority.read(); + if (current) { + return { + kind: 'rejected', + reason: current.state.kind === 'handoff' ? 'handoff_in_progress' : 'owner_exists', + record: current, + }; + } + + let host: Awaited>; + try { + host = await adapter.prepareUnownedHostCutover( + request.rootId, + request.target, + staged, + request.activeWorkPolicy, + ); + } catch (cause) { + return claimRecoveryRequired('prepare_host_cutover', cause); + } + if (host.kind === 'active_work') return { kind: 'active_work' }; + if (host.kind !== 'target_present') { + const writerRelease = await runClaimPhase('observe_writer_release', () => + adapter.observeWriterRelease(request.rootId), + ); + if (writerRelease) return writerRelease; + const activation = await runClaimPhase('activate_target', () => + adapter.activateTarget(request.rootId, staged), + ); + if (activation) return activation; + } + const verification = await runClaimPhase('verify_target_ready', () => + adapter.verifyTargetReady(request.rootId, request.target, staged), + ); + if (verification) return verification; + + try { + const claimed = await authority.apply({ + kind: 'claim', + owner: request.owner, + selected: request.target, + }); + if (claimed.kind === 'rejected' || !claimed.record) { + return claimRecoveryRequired( + 'claim', + new Error('Verified local Host deployment claim could not be committed'), + ); + } + return { kind: 'completed', record: claimed.record }; + } catch (cause) { + return claimRecoveryRequired('claim', cause); + } + }, + authorityOptions, + ); +} + +async function runClaimPhase( + phase: Exclude, + operation: () => Promise, +): Promise< + Extract | undefined +> { + try { + await operation(); + return undefined; + } catch (cause) { + return claimRecoveryRequired(phase, cause); + } +} + +function claimRecoveryRequired( + phase: LocalHostProcessDeploymentClaimPhase, + cause: unknown, +): Extract { + return { kind: 'recovery_required', phase, cause }; +} + async function runPhase( phase: Exclude< LocalHostProcessDeploymentHandoffPhase,