From 719757170eb382fa488cf659b44c80053203fdaf Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 28 Aug 2026 09:01:34 +0800 Subject: [PATCH 1/7] feat(runtime-host): fence lifecycle transitions Persist transition and blocked states in the canonical managed deployment authority, and require the same State Root owner for exact commit or rollback. Generated-by: OpenAI Codex --- .../src/__tests__/managed-deployment.test.ts | 96 +++++ .../src/candidate-startup-failure.ts | 2 + .../runtime-host/src/client/startup-error.ts | 10 + packages/runtime-host/src/operator/index.ts | 11 + .../src/operator/managed-deployment.ts | 362 +++++++++++++++++- 5 files changed, 477 insertions(+), 4 deletions(-) diff --git a/packages/runtime-host/src/__tests__/managed-deployment.test.ts b/packages/runtime-host/src/__tests__/managed-deployment.test.ts index e522a1537c..22dfad18e2 100644 --- a/packages/runtime-host/src/__tests__/managed-deployment.test.ts +++ b/packages/runtime-host/src/__tests__/managed-deployment.test.ts @@ -26,13 +26,19 @@ import { resolveRootControlNamespace, resolveRootOwnershipNamespace, resolveStorageRoot, + tryAcquireStateRootOwner, } from '@maka/storage/root-authority'; import { RuntimeHostManagedDeploymentError, + beginRuntimeHostManagedDeploymentTransition, + blockRuntimeHostManagedDeploymentTransition, claimRuntimeHostManagedDeployment, + commitRuntimeHostManagedDeploymentTransition, decodeRuntimeHostManagedDeploymentConfig, + readRuntimeHostManagedDeploymentAuthorityRecord, readRuntimeHostManagedDeploymentConfig, resolveRuntimeHostManagedDeploymentConfigPath, + rollbackRuntimeHostManagedDeploymentTransition, runtimeHostManagedLaunchClaim, tryAcquireRuntimeHostLaunch, tryAcquireRuntimeHostLaunchOwner, @@ -239,6 +245,96 @@ test('claims one canonical deployment while fencing State Root ownership', async ); }); +test('deployment transitions fail closed and preserve exact commit or rollback authority', async (t) => { + const input = await fixture(t); + await claimRuntimeHostManagedDeployment(input.capability, input.config, input.authority); + const desired: RuntimeHostManagedDeploymentConfig = { + ...input.config, + configRevision: 2, + lifecycle: { mode: 'supervised', provider: 'systemd_user', availability: 'machine' }, + reconciliation: { trigger: 'scheduled', provider: 'systemd_timer' }, + }; + + const rollbackTransactionId = '00000000-0000-4000-8000-000000000010'; + const firstOwner = await tryAcquireStateRootOwner(input.capability); + assert.ok(firstOwner); + await beginRuntimeHostManagedDeploymentTransition( + firstOwner, + { + transactionId: rollbackTransactionId, + operation: 'lifecycle_change', + expected: input.config, + desired, + }, + input.authority, + ); + await firstOwner.close(); + + await assert.rejects( + tryAcquireRuntimeHostLaunch( + input.capability, + launchRequest(input.config, 'on_demand', runtimeHostManagedLaunchClaim(input.config)), + input.authority, + ), + { code: 'deployment_transition_in_progress' }, + ); + const repairOwner = await tryAcquireStateRootOwner(input.capability); + assert.ok(repairOwner); + await blockRuntimeHostManagedDeploymentTransition( + repairOwner, + rollbackTransactionId, + 'injected provider rollback failure', + input.authority, + ); + await repairOwner.close(); + await assert.rejects(readRuntimeHostManagedDeploymentConfig(input.capability, input.authority), { + code: 'deployment_needs_repair', + }); + + const rollbackOwner = await tryAcquireStateRootOwner(input.capability); + assert.ok(rollbackOwner); + await rollbackRuntimeHostManagedDeploymentTransition( + rollbackOwner, + rollbackTransactionId, + input.config, + input.authority, + ); + await rollbackOwner.close(); + assert.deepEqual( + await readRuntimeHostManagedDeploymentConfig(input.capability, input.authority), + input.config, + ); + + const commitTransactionId = '00000000-0000-4000-8000-000000000011'; + const commitOwner = await tryAcquireStateRootOwner(input.capability); + assert.ok(commitOwner); + await beginRuntimeHostManagedDeploymentTransition( + commitOwner, + { + transactionId: commitTransactionId, + operation: 'lifecycle_change', + expected: input.config, + desired, + }, + input.authority, + ); + await commitRuntimeHostManagedDeploymentTransition( + commitOwner, + commitTransactionId, + desired, + input.authority, + ); + await commitOwner.close(); + assert.deepEqual( + await readRuntimeHostManagedDeploymentConfig(input.capability, input.authority), + desired, + ); + assert.deepEqual( + await readRuntimeHostManagedDeploymentAuthorityRecord(input.capability, input.authority), + desired, + ); +}); + test('launch acquisition atomically joins deployment authorization and State Root ownership', async (t) => { const input = await fixture(t); const unmanagedOwner = await tryAcquireRuntimeHostLaunchOwner( diff --git a/packages/runtime-host/src/candidate-startup-failure.ts b/packages/runtime-host/src/candidate-startup-failure.ts index 7d7728530c..0ae8015204 100644 --- a/packages/runtime-host/src/candidate-startup-failure.ts +++ b/packages/runtime-host/src/candidate-startup-failure.ts @@ -55,6 +55,8 @@ const EXIT_CODE_BY_REASON: Readonly; + readonly from: z.infer; + readonly to: z.infer; + }, + context: z.RefinementCtx, +): void { + const valid = + (value.operation === 'legacy_migration' && value.from === null && value.to !== null) || + (value.operation === 'uninstall' && value.from !== null && value.to === null) || + ((value.operation === 'lifecycle_change' || value.operation === 'provider_change') && + value.from !== null && + value.to !== null && + value.from.deploymentId === value.to.deploymentId && + value.to.configRevision > value.from.configRevision); + if (!valid) { + context.addIssue({ + code: 'custom', + message: 'The managed deployment transition endpoints do not match its operation', + path: ['operation'], + }); + } +} + export type RuntimeHostSupervisorProvider = z.infer; export type RuntimeHostReconciliationProvider = z.infer; export type RuntimeHostManagedDeploymentConfig = z.infer; export type RuntimeHostManagedLaunchClaim = z.infer; +export type RuntimeHostManagedDeploymentTransitionOperation = z.infer< + typeof deploymentTransitionOperationSchema +>; +export type RuntimeHostManagedDeploymentTransition = z.infer< + typeof managedDeploymentTransitionSchema +>; +export type RuntimeHostManagedDeploymentBlocked = z.infer; +export type RuntimeHostManagedDeploymentAuthorityRecord = + | RuntimeHostManagedDeploymentConfig + | RuntimeHostManagedDeploymentTransition + | RuntimeHostManagedDeploymentBlocked; + +export interface RuntimeHostManagedDeploymentTransitionInput { + readonly transactionId: string; + readonly operation: RuntimeHostManagedDeploymentTransitionOperation; + readonly expected?: RuntimeHostManagedDeploymentConfig; + readonly desired?: RuntimeHostManagedDeploymentConfig; +} export interface RuntimeHostManagedDeploymentAuthorityOptions { /** Test-only or embedding override. Production uses the account-local durable default. */ @@ -228,6 +320,8 @@ export const RUNTIME_HOST_MANAGED_LAUNCH_REJECTIONS = [ 'deployment_lifecycle_mismatch', 'deployment_launch_mismatch', 'deployment_record_invalid', + 'deployment_transition_in_progress', + 'deployment_needs_repair', ] as const; export type RuntimeHostManagedLaunchRejection = @@ -241,6 +335,7 @@ export class RuntimeHostManagedDeploymentError extends Error { | 'deployment_commit_unknown' | 'lifecycle_owner_exists' | 'state_root_owned' + | 'deployment_transaction_mismatch' | RuntimeHostManagedLaunchRejection, message: string, options?: ErrorOptions, @@ -264,6 +359,22 @@ export function decodeRuntimeHostManagedDeploymentConfig( } } +export function decodeRuntimeHostManagedDeploymentAuthorityRecord( + value: unknown, +): RuntimeHostManagedDeploymentAuthorityRecord { + try { + return managedDeploymentAuthorityRecordSchema.parse( + value, + ) as RuntimeHostManagedDeploymentAuthorityRecord; + } catch (error) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_config', + 'The Runtime Host managed deployment authority record is invalid', + { cause: error }, + ); + } +} + export function decodeRuntimeHostManagedLaunchClaim(value: unknown): RuntimeHostManagedLaunchClaim { try { return managedLaunchClaimSchema.parse(value); @@ -338,6 +449,16 @@ export async function readRuntimeHostManagedDeploymentConfig( ); } +export async function readRuntimeHostManagedDeploymentAuthorityRecord( + capability: StorageRootCapability<'interactive'>, + options: RuntimeHostManagedDeploymentAuthorityOptions = {}, +): Promise { + return readDeploymentAuthorityForCapability( + resolveRuntimeHostManagedDeploymentConfigPath(capability.rootId, options), + capability, + ); +} + export async function resolveRuntimeHostManagedDeployment( rootId: string, options: RuntimeHostManagedDeploymentAuthorityOptions = {}, @@ -354,7 +475,7 @@ export async function resolveRuntimeHostManagedDeployment( 'The managed Runtime Host deployment is not installed', ); } - const initial = decodeRuntimeHostManagedDeploymentConfig(value); + const initial = decodeRuntimeHostManagedDeploymentAuthorityRecord(value); if (initial.root.id !== rootId) { throw new RuntimeHostManagedDeploymentError( 'invalid_config', @@ -449,6 +570,186 @@ export async function commitRuntimeHostManagedDeployment( }; } +export async function beginRuntimeHostManagedDeploymentTransition( + owner: StateRootOwner<'interactive'>, + input: RuntimeHostManagedDeploymentTransitionInput, + options: RuntimeHostManagedDeploymentAuthorityOptions = {}, +): Promise<{ + readonly kind: 'applied' | 'unchanged'; + readonly record: RuntimeHostManagedDeploymentTransition; +}> { + const transition = deploymentTransitionRecord(owner.capability, input); + await assertManagedDeploymentOwner(owner); + const path = await prepareManagedDeploymentAuthorityPath(owner.capability.rootId, options); + const current = await readDeploymentAuthorityForCapability(path, owner.capability); + if (current?.schemaVersion === 2) { + if (current.state === 'blocked') throw deploymentNeedsRepair(current); + if (isDeepStrictEqual(current, transition)) return { kind: 'unchanged', record: current }; + throw deploymentTransactionMismatch('A different managed deployment transition is active'); + } + const expected = input.expected && decodeRuntimeHostManagedDeploymentConfig(input.expected); + if (!isDeepStrictEqual(current, expected)) { + throw deploymentTransactionMismatch('The managed deployment changed before transition began'); + } + await writePrivateJson(path, transition); + return { kind: 'applied', record: transition }; +} + +export async function commitRuntimeHostManagedDeploymentTransition( + owner: StateRootOwner<'interactive'>, + transactionId: string, + desired: RuntimeHostManagedDeploymentConfig | undefined, + options: RuntimeHostManagedDeploymentAuthorityOptions = {}, +): Promise<{ + readonly kind: 'applied' | 'unchanged'; + readonly config?: RuntimeHostManagedDeploymentConfig; +}> { + return finishRuntimeHostManagedDeploymentTransition(owner, transactionId, 'to', desired, options); +} + +export async function rollbackRuntimeHostManagedDeploymentTransition( + owner: StateRootOwner<'interactive'>, + transactionId: string, + previous: RuntimeHostManagedDeploymentConfig | undefined, + options: RuntimeHostManagedDeploymentAuthorityOptions = {}, +): Promise<{ + readonly kind: 'applied' | 'unchanged'; + readonly config?: RuntimeHostManagedDeploymentConfig; +}> { + return finishRuntimeHostManagedDeploymentTransition( + owner, + transactionId, + 'from', + previous, + options, + ); +} + +export async function blockRuntimeHostManagedDeploymentTransition( + owner: StateRootOwner<'interactive'>, + transactionId: string, + reason: string, + options: RuntimeHostManagedDeploymentAuthorityOptions = {}, +): Promise<{ + readonly kind: 'applied' | 'unchanged'; + readonly record: RuntimeHostManagedDeploymentBlocked; +}> { + await assertManagedDeploymentOwner(owner); + const path = await prepareManagedDeploymentAuthorityPath(owner.capability.rootId, options); + const current = await readDeploymentAuthorityForCapability(path, owner.capability); + if (!current || current.schemaVersion !== 2 || current.transactionId !== transactionId) { + throw deploymentTransactionMismatch('The managed deployment transition is no longer current'); + } + const record = decodeRuntimeHostManagedDeploymentAuthorityRecord({ + ...current, + state: 'blocked', + reason, + }); + if (record.schemaVersion !== 2 || record.state !== 'blocked') { + throw deploymentTransactionMismatch('The managed deployment blocked record is invalid'); + } + if (isDeepStrictEqual(current, record)) return { kind: 'unchanged', record }; + await writePrivateJson(path, record); + return { kind: 'applied', record }; +} + +async function finishRuntimeHostManagedDeploymentTransition( + owner: StateRootOwner<'interactive'>, + transactionId: string, + endpoint: 'from' | 'to', + value: RuntimeHostManagedDeploymentConfig | undefined, + options: RuntimeHostManagedDeploymentAuthorityOptions, +): Promise<{ + readonly kind: 'applied' | 'unchanged'; + readonly config?: RuntimeHostManagedDeploymentConfig; +}> { + await assertManagedDeploymentOwner(owner); + const config = value && decodeRuntimeHostManagedDeploymentConfig(value); + if (config) assertConfigTargetsCapability(config, owner.capability); + const path = await prepareManagedDeploymentAuthorityPath(owner.capability.rootId, options); + const current = await readDeploymentAuthorityForCapability(path, owner.capability); + if (current === undefined) { + if (config === undefined) return { kind: 'unchanged' }; + throw deploymentTransactionMismatch('The managed deployment transition record is missing'); + } + if (current.schemaVersion === 1) { + if (config && isDeepStrictEqual(current, config)) { + return { kind: 'unchanged', config: current }; + } + throw deploymentTransactionMismatch('The managed deployment transition is no longer current'); + } + if (current.transactionId !== transactionId) { + throw deploymentTransactionMismatch('The managed deployment transaction identity changed'); + } + const claim = config ? runtimeHostManagedLaunchClaim(config) : null; + if (!isDeepStrictEqual(current[endpoint], claim)) { + throw deploymentTransactionMismatch('The managed deployment transition target changed'); + } + if (config) await writePrivateJson(path, config); + else await removePrivateJson(path); + return { kind: 'applied', ...(config ? { config } : {}) }; +} + +function deploymentTransitionRecord( + capability: StorageRootCapability<'interactive'>, + input: RuntimeHostManagedDeploymentTransitionInput, +): RuntimeHostManagedDeploymentTransition { + const expected = input.expected && decodeRuntimeHostManagedDeploymentConfig(input.expected); + const desired = input.desired && decodeRuntimeHostManagedDeploymentConfig(input.desired); + if (expected) assertConfigTargetsCapability(expected, capability); + if (desired) assertConfigTargetsCapability(desired, capability); + const record = decodeRuntimeHostManagedDeploymentAuthorityRecord({ + schemaVersion: 2, + state: 'transition', + transactionId: input.transactionId, + operation: input.operation, + root: { path: capability.canonicalPath, id: capability.rootId }, + from: expected ? runtimeHostManagedLaunchClaim(expected) : null, + to: desired ? runtimeHostManagedLaunchClaim(desired) : null, + }); + if (record.schemaVersion !== 2 || record.state !== 'transition') { + throw deploymentTransactionMismatch('The managed deployment transition is invalid'); + } + return record; +} + +async function assertManagedDeploymentOwner(owner: StateRootOwner<'interactive'>): Promise { + if (owner.closed) { + throw new RuntimeHostManagedDeploymentError( + 'state_root_owned', + 'The State Root deployment owner is no longer active', + ); + } + await assertStorageRootLease(owner.lease, 'interactive', 'write'); +} + +async function prepareManagedDeploymentAuthorityPath( + rootId: string, + options: RuntimeHostManagedDeploymentAuthorityOptions, +): Promise { + const authorityRoot = resolveRuntimeHostManagedDeploymentAuthorityRoot(options); + const path = resolveRuntimeHostManagedDeploymentConfigPath(rootId, options); + await prepareAuthorityDirectory( + dirname(path), + resolveAuthorityDurabilityBoundary(authorityRoot, options), + options, + ); + return path; +} + +function deploymentTransactionMismatch(message: string): RuntimeHostManagedDeploymentError { + return new RuntimeHostManagedDeploymentError('deployment_transaction_mismatch', message); +} + +function deploymentNeedsRepair( + record: RuntimeHostManagedDeploymentBlocked, +): RuntimeHostManagedDeploymentError { + return new RuntimeHostManagedDeploymentError( + 'deployment_needs_repair', + `The managed deployment transaction ${record.transactionId} requires repair`, + ); +} + export interface RuntimeHostLaunchOwnership { readonly owner: StateRootOwner<'interactive'>; readonly managedConfig?: RuntimeHostManagedDeploymentConfig; @@ -562,11 +863,26 @@ async function readDeploymentConfigForCapability( path: string, capability: StorageRootCapability<'interactive'>, ): Promise { + const record = await readDeploymentAuthorityForCapability(path, capability); + if (record === undefined || record.schemaVersion === 1) return record; + if (record.state === 'transition') { + throw new RuntimeHostManagedDeploymentError( + 'deployment_transition_in_progress', + `The managed deployment transaction ${record.transactionId} is in progress`, + ); + } + throw deploymentNeedsRepair(record); +} + +async function readDeploymentAuthorityForCapability( + path: string, + capability: StorageRootCapability<'interactive'>, +): Promise { const value = await readBoundedJson(path); if (value === undefined) return undefined; - const config = decodeRuntimeHostManagedDeploymentConfig(value); - assertConfigTargetsCapability(config, capability); - return config; + const record = decodeRuntimeHostManagedDeploymentAuthorityRecord(value); + assertAuthorityTargetsCapability(record, capability); + return record; } function managedLaunchRejectionMessage(rejection: RuntimeHostManagedLaunchRejection): string { @@ -583,6 +899,10 @@ function managedLaunchRejectionMessage(rejection: RuntimeHostManagedLaunchReject return 'The Runtime Host process was not launched from the configured exact package'; case 'deployment_record_invalid': return 'The Runtime Host managed deployment record is invalid'; + case 'deployment_transition_in_progress': + return 'The Runtime Host managed deployment is changing and cannot be launched'; + case 'deployment_needs_repair': + return 'The Runtime Host managed deployment requires repair before it can be launched'; } } @@ -641,6 +961,21 @@ function assertConfigTargetsCapability( } } +function assertAuthorityTargetsCapability( + record: RuntimeHostManagedDeploymentAuthorityRecord, + capability: StorageRootCapability<'interactive'>, +): void { + if ( + record.root.id !== capability.rootId || + resolve(record.root.path) !== capability.canonicalPath + ) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_config', + 'The Runtime Host managed deployment authority targets a different State Root', + ); + } +} + async function readBoundedJson(path: string): Promise { let document: Buffer; try { @@ -716,6 +1051,25 @@ async function writePrivateJson(path: string, value: unknown): Promise { } } +async function removePrivateJson(path: string): Promise { + let removed = false; + try { + await rm(path); + removed = true; + await syncDirectory(dirname(path)); + } catch (error) { + if (removed) { + throw new RuntimeHostManagedDeploymentError( + 'deployment_commit_unknown', + 'The Runtime Host managed deployment may have been removed; re-read it before retrying', + { cause: error }, + ); + } + if (isNodeError(error, 'ENOENT')) return; + throw deploymentIo('Unable to remove the Runtime Host managed deployment record', error); + } +} + async function prepareAuthorityDirectory( path: string, durabilityBoundary: string, From 85c5372cfdb70b93718c75b7f4e8889af1a2e014 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 28 Aug 2026 10:34:17 +0800 Subject: [PATCH 2/7] feat(runtime-host): migrate lifecycle ownership Generated-by: OpenAI Codex --- ...runtime-host-lifecycle-transaction.test.ts | 390 ++++++++ .../src/__tests__/runtime-host-setup.test.ts | 404 +------- packages/cli/src/cli-core.ts | 78 +- packages/cli/src/runtime-host-cli.ts | 100 +- .../src/runtime-host-launch-agent-service.ts | 190 +++- .../src/runtime-host-lifecycle-provider.ts | 91 ++ .../src/runtime-host-lifecycle-transaction.ts | 510 ++++++++++ .../src/runtime-host-managed-deployment.ts | 21 +- .../runtime-host-managed-lifecycle-manager.ts | 358 +++++++ .../runtime-host-peer-management-command.ts | 271 +++++- .../cli/src/runtime-host-service-command.ts | 3 + ...runtime-host-service-management-command.ts | 122 ++- .../cli/src/runtime-host-service-manager.ts | 48 + .../cli/src/runtime-host-setup-command.ts | 904 +++++++++++++++--- .../cli/src/runtime-host-systemd-service.ts | 238 ++++- .../cli/src/runtime-host-update-command.ts | 227 ++++- .../cli/src/runtime-host-update-discovery.ts | 36 +- .../src/runtime-host-update-reconciliation.ts | 49 +- .../src/operator/managed-deployment.ts | 87 +- .../src/operator/service-management-frame.ts | 19 + 20 files changed, 3507 insertions(+), 639 deletions(-) create mode 100644 packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts create mode 100644 packages/cli/src/runtime-host-lifecycle-provider.ts create mode 100644 packages/cli/src/runtime-host-lifecycle-transaction.ts create mode 100644 packages/cli/src/runtime-host-managed-lifecycle-manager.ts diff --git a/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts b/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts new file mode 100644 index 0000000000..8ffe8dacc8 --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts @@ -0,0 +1,390 @@ +/* + * 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, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + beginRuntimeHostManagedDeploymentTransition, + claimRuntimeHostManagedDeployment, + readRuntimeHostManagedDeploymentAuthorityRecord, + resolveRuntimeHostNpmDeploymentLayout, + type RuntimeHostManagedDeploymentConfig, + type RuntimeHostSupervisorProvider, +} from '@maka/runtime-host/operator'; +import { resolveStorageRoot, tryAcquireStateRootOwner } from '@maka/storage/root-authority'; +import type { + RuntimeHostLifecycleProvider, + RuntimeHostProviderDefinition, +} from '../runtime-host-lifecycle-provider.js'; +import { + applyRuntimeHostLifecycleTransition, + recoverRuntimeHostLifecycleTransition, + runtimeHostReconciliationTriggerDefinition, + runtimeHostSupervisorDefinition, +} from '../runtime-host-lifecycle-transaction.js'; + +const INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString('base64')}`; + +test('one authority record recovers every provider cutover boundary without a journal', async (t) => { + const stateRoot = await mkdtemp(join(tmpdir(), 'maka-lifecycle-root-')); + const authorityRoot = await mkdtemp(join(tmpdir(), 'maka-lifecycle-authority-')); + t.after(() => rm(stateRoot, { recursive: true, force: true })); + t.after(() => rm(authorityRoot, { recursive: true, force: true })); + const capability = await resolveStorageRoot({ path: stateRoot, kind: 'interactive' }); + const authority = { authorityRoot, durabilityBoundary: authorityRoot }; + const onDemand = config(capability.canonicalPath, capability.rootId, 1, 'on_demand'); + const systemd = config(capability.canonicalPath, capability.rootId, 2, 'systemd_user'); + const launchAgent = config(capability.canonicalPath, capability.rootId, 3, 'launch_agent'); + const systemdSupervisor = runtimeHostSupervisorDefinition(systemd); + assert.deepEqual(systemdSupervisor.command.slice(0, 3), [ + process.execPath, + resolveRuntimeHostNpmDeploymentLayout(systemd.deploymentRoot, systemd.launch.package.integrity) + .cliPath, + 'runtime-host', + ]); + await claimRuntimeHostManagedDeployment(capability, onDemand, authority); + + const providers = new Map([ + ['systemd_user', new FakeLifecycleProvider('systemd_user', 'systemd_timer')], + ['launch_agent', new FakeLifecycleProvider('launch_agent', 'launch_agent_timer')], + ]); + const deps = { + resolveProvider: (provider: RuntimeHostSupervisorProvider) => providers.get(provider)!, + }; + const firstOwner = await tryAcquireStateRootOwner(capability); + assert.ok(firstOwner); + await applyRuntimeHostLifecycleTransition( + firstOwner, + { operation: 'lifecycle_change', current: onDemand, desired: systemd }, + deps, + authority, + ); + await firstOwner.close(); + assert.deepEqual( + await readRuntimeHostManagedDeploymentAuthorityRecord(capability, authority), + systemd, + ); + + const systemdProvider = providers.get('systemd_user')!; + const launchAgentProvider = providers.get('launch_agent')!; + const failureBoundaries = [ + 'systemd_timer.uninstall', + 'systemd_user.uninstall', + 'launch_agent.converge', + 'launch_agent.verify', + 'launch_agent_timer.converge', + 'launch_agent_timer.verify', + ]; + for (const boundary of failureBoundaries) { + launchAgentProvider.clear(); + systemdProvider.install(systemd); + FakeLifecycleProvider.failure = boundary; + const owner = await tryAcquireStateRootOwner(capability); + assert.ok(owner); + await assert.rejects( + applyRuntimeHostLifecycleTransition( + owner, + { + operation: 'provider_change', + current: systemd, + desired: launchAgent, + transactionId: `00000000-0000-4000-8000-${String(failureBoundaries.indexOf(boundary) + 1).padStart(12, '0')}`, + }, + deps, + authority, + ), + ); + await owner.close(); + assert.deepEqual( + await readRuntimeHostManagedDeploymentAuthorityRecord(capability, authority), + systemd, + boundary, + ); + systemdProvider.assertInstalled(systemd); + launchAgentProvider.assertAbsent(); + } + + const interruptedId = '00000000-0000-4000-8000-000000000099'; + const interruptedOwner = await tryAcquireStateRootOwner(capability); + assert.ok(interruptedOwner); + const { record } = await beginRuntimeHostManagedDeploymentTransition( + interruptedOwner, + { + transactionId: interruptedId, + operation: 'provider_change', + expected: systemd, + desired: launchAgent, + }, + authority, + ); + systemdProvider.clear(); + launchAgentProvider.install(launchAgent); + await interruptedOwner.close(); + + const recoveryOwner = await tryAcquireStateRootOwner(capability); + assert.ok(recoveryOwner); + await recoverRuntimeHostLifecycleTransition(recoveryOwner, record, deps, authority); + await recoveryOwner.close(); + assert.deepEqual( + await readRuntimeHostManagedDeploymentAuthorityRecord(capability, authority), + systemd, + ); + systemdProvider.assertInstalled(systemd); + launchAgentProvider.assertAbsent(); + + const uninstallOwner = await tryAcquireStateRootOwner(capability); + assert.ok(uninstallOwner); + await applyRuntimeHostLifecycleTransition( + uninstallOwner, + { operation: 'uninstall', current: systemd }, + deps, + authority, + ); + await uninstallOwner.close(); + assert.equal( + await readRuntimeHostManagedDeploymentAuthorityRecord(capability, authority), + undefined, + ); + + const migratedSystemd = config(capability.canonicalPath, capability.rootId, 1, 'systemd_user'); + let legacyInstalled = true; + const legacyDeps = { + ...deps, + uninstallLegacy: async () => { + legacyInstalled = false; + }, + restoreLegacy: async () => { + legacyInstalled = true; + }, + }; + FakeLifecycleProvider.failure = 'systemd_user.converge'; + const failedMigrationOwner = await tryAcquireStateRootOwner(capability); + assert.ok(failedMigrationOwner); + await assert.rejects( + applyRuntimeHostLifecycleTransition( + failedMigrationOwner, + { operation: 'legacy_migration', desired: migratedSystemd }, + legacyDeps, + authority, + ), + ); + await failedMigrationOwner.close(); + assert.equal(legacyInstalled, true); + systemdProvider.assertAbsent(); + assert.equal( + await readRuntimeHostManagedDeploymentAuthorityRecord(capability, authority), + undefined, + ); + + const migrationOwner = await tryAcquireStateRootOwner(capability); + assert.ok(migrationOwner); + await applyRuntimeHostLifecycleTransition( + migrationOwner, + { operation: 'legacy_migration', desired: migratedSystemd }, + legacyDeps, + authority, + ); + await migrationOwner.close(); + assert.equal(legacyInstalled, false); + systemdProvider.assertInstalled(migratedSystemd); + + const migratedUninstallOwner = await tryAcquireStateRootOwner(capability); + assert.ok(migratedUninstallOwner); + await applyRuntimeHostLifecycleTransition( + migratedUninstallOwner, + { operation: 'uninstall', current: migratedSystemd }, + deps, + authority, + ); + await migratedUninstallOwner.close(); + + await claimRuntimeHostManagedDeployment(capability, onDemand, authority); + systemdProvider.clear(); + const uncertainCommitOwner = await tryAcquireStateRootOwner(capability); + assert.ok(uncertainCommitOwner); + await assert.rejects( + applyRuntimeHostLifecycleTransition( + uncertainCommitOwner, + { operation: 'lifecycle_change', current: onDemand, desired: systemd }, + deps, + { + ...authority, + beforeDirectorySync: async () => { + const published = await readRuntimeHostManagedDeploymentAuthorityRecord( + capability, + authority, + ); + if (published?.schemaVersion === 1 && published.lifecycle.mode === 'supervised') { + throw new Error('Injected directory sync failure'); + } + }, + }, + ), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'deployment_commit_unknown', + ); + await uncertainCommitOwner.close(); + assert.deepEqual( + await readRuntimeHostManagedDeploymentAuthorityRecord(capability, authority), + systemd, + ); + systemdProvider.assertInstalled(systemd); + + const finalOwner = await tryAcquireStateRootOwner(capability); + assert.ok(finalOwner); + await applyRuntimeHostLifecycleTransition( + finalOwner, + { operation: 'uninstall', current: systemd }, + deps, + authority, + ); + await finalOwner.close(); + assert.ok((await stat(capability.canonicalPath)).isDirectory()); +}); + +class FakeLifecycleProvider implements RuntimeHostLifecycleProvider { + static failure: string | undefined; + readonly supervisor; + readonly reconciliationTrigger; + #supervisorDefinition: RuntimeHostProviderDefinition | undefined; + #triggerDefinition: RuntimeHostProviderDefinition | undefined; + + constructor( + supervisorProvider: 'systemd_user' | 'launch_agent', + triggerProvider: 'systemd_timer' | 'launch_agent_timer', + ) { + this.supervisor = { + provider: supervisorProvider, + preflight: async () => undefined, + converge: async (definition: RuntimeHostProviderDefinition) => { + this.#supervisorDefinition = definition; + this.#fail(`${supervisorProvider}.converge`); + }, + verify: async (definition: RuntimeHostProviderDefinition) => { + assert.deepEqual(this.#supervisorDefinition, definition); + this.#fail(`${supervisorProvider}.verify`); + }, + status: async () => ({ + provider: supervisorProvider, + installed: this.#supervisorDefinition !== undefined, + enabled: this.#supervisorDefinition !== undefined, + active: false, + state: this.#supervisorDefinition ? ('stopped' as const) : ('not_installed' as const), + pid: null, + lastExitCode: null, + }), + activate: async () => undefined, + retire: async () => undefined, + logs: async () => '', + uninstall: async () => { + this.#supervisorDefinition = undefined; + this.#fail(`${supervisorProvider}.uninstall`); + }, + }; + this.reconciliationTrigger = { + provider: triggerProvider, + converge: async (definition: RuntimeHostProviderDefinition) => { + this.#triggerDefinition = definition; + this.#fail(`${triggerProvider}.converge`); + }, + verify: async (definition: RuntimeHostProviderDefinition) => { + assert.deepEqual(this.#triggerDefinition, definition); + this.#fail(`${triggerProvider}.verify`); + }, + status: async () => ({ + installed: this.#triggerDefinition !== undefined, + active: this.#triggerDefinition !== undefined, + }), + activate: async () => undefined, + logs: async () => '', + uninstall: async () => { + this.#triggerDefinition = undefined; + this.#fail(`${triggerProvider}.uninstall`); + }, + }; + } + + install(config: RuntimeHostManagedDeploymentConfig): void { + this.#supervisorDefinition = runtimeHostSupervisorDefinition(config); + this.#triggerDefinition = runtimeHostReconciliationTriggerDefinition(config); + } + + clear(): void { + this.#supervisorDefinition = undefined; + this.#triggerDefinition = undefined; + } + + assertInstalled(config: RuntimeHostManagedDeploymentConfig): void { + assert.deepEqual(this.#supervisorDefinition, runtimeHostSupervisorDefinition(config)); + assert.deepEqual(this.#triggerDefinition, runtimeHostReconciliationTriggerDefinition(config)); + } + + assertAbsent(): void { + assert.equal(this.#supervisorDefinition, undefined); + assert.equal(this.#triggerDefinition, undefined); + } + + #fail(boundary: string): void { + if (FakeLifecycleProvider.failure !== boundary) return; + FakeLifecycleProvider.failure = undefined; + throw new Error(`Injected failure after ${boundary}`); + } +} + +function config( + rootPath: string, + rootId: string, + revision: number, + lifecycle: 'on_demand' | 'systemd_user' | 'launch_agent', +): RuntimeHostManagedDeploymentConfig { + const supervised = lifecycle !== 'on_demand'; + return { + schemaVersion: 1, + deploymentId: '00000000-0000-4000-8000-000000000001', + configRevision: revision, + deploymentRoot: '/opt/maka/runtime-host', + root: { path: rootPath, id: rootId }, + projectDirectoryRoots: [{ label: 'projects', path: '/srv/projects' }], + launch: { + kind: 'exact_package', + nodePath: process.execPath, + package: { kind: 'npm_registry', version: '1.2.3', integrity: INTEGRITY }, + }, + listeners: { + localIpc: true, + websocket: { host: '127.0.0.1', port: 43_210, path: '/runtime-host' }, + }, + lifecycle: supervised + ? { + mode: 'supervised', + provider: lifecycle, + availability: lifecycle === 'systemd_user' ? 'machine' : 'session', + } + : { mode: 'on_demand', availability: 'activation' }, + reconciliation: supervised + ? { + trigger: 'scheduled', + provider: lifecycle === 'systemd_user' ? 'systemd_timer' : 'launch_agent_timer', + } + : { trigger: 'activation' }, + }; +} diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index 70357a7921..b385755113 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -19,17 +19,7 @@ import assert from 'node:assert/strict'; import { execFile as execFileCallback } from 'node:child_process'; -import { - access, - mkdir, - mkdtemp, - readFile, - readdir, - realpath, - rename, - rm, - writeFile, -} from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { test } from 'node:test'; @@ -51,9 +41,6 @@ import { import { runRuntimeHostSetupCli } from '../runtime-host-setup-command.js'; import { resolveRuntimeHostManagedServiceId, - RuntimeHostServiceManagerError, - type RuntimeHostManagedServiceConfig, - type RuntimeHostManagedServiceResult, type RuntimeHostServiceBackend, } from '../runtime-host-service-manager.js'; @@ -77,10 +64,15 @@ test('on-demand setup installs one exact deployment without a service backend', }) : Promise.resolve(), rootId - ? rm(join(resolveRootControlNamespace(), rootId), { recursive: true, force: true }) + ? rm(join(resolveRootControlNamespace(), rootId), { + recursive: true, + force: true, + }) : Promise.resolve(), rootId - ? rm(join(resolveRootOwnershipNamespace(), `${rootId}.lock`), { force: true }) + ? rm(join(resolveRootOwnershipNamespace(), `${rootId}.lock`), { + force: true, + }) : Promise.resolve(), ]); }); @@ -128,7 +120,11 @@ test('on-demand setup installs one exact deployment without a service backend', hostEpoch: 'host-epoch', pid: 1234, protocolVersion: 1, - endpoint: { host: '127.0.0.1', port: 43_210, websocketPath: '/runtime-host' }, + endpoint: { + host: '127.0.0.1', + port: 43_210, + websocketPath: '/runtime-host', + }, }; }, replaceCredential: async () => ({ @@ -155,7 +151,10 @@ test('on-demand setup installs one exact deployment without a service backend', assert.equal(complete?.kind, 'complete'); const persisted = JSON.parse( await readFile(resolveRuntimeHostManagedDeploymentConfigPath(rootId), 'utf8'), - ) as { lifecycle: { mode: string }; listeners: { websocket: { port: number } } }; + ) as { + lifecycle: { mode: string }; + listeners: { websocket: { port: number } }; + }; assert.equal(persisted.lifecycle.mode, 'on_demand'); assert.equal(persisted.listeners.websocket.port, 0); @@ -176,139 +175,6 @@ test('on-demand setup installs one exact deployment without a service backend', ); }); -test('managed setup converges on one exact package and verified Client pairing', async (t) => { - const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-setup-')); - t.after(() => rm(base, { recursive: true, force: true })); - const sourcePackageRoot = await createReleasePackage(base, '0.2.0'); - const clientDataRoot = join(base, 'config', 'Maka'); - const stateRoot = join(clientDataRoot, 'workspaces', 'default'); - const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot); - const deploymentPathOptions = { - env: { XDG_DATA_HOME: join(base, 'data') }, - homeDir: join(base, 'home'), - platform: 'linux' as const, - }; - const deploymentRoot = resolveRuntimeHostManagedDeploymentRoot(serviceId, deploymentPathOptions); - await mkdir(join(deploymentRoot, 'versions', '.0.2.0.interrupted.tmp'), { recursive: true }); - let config: RuntimeHostManagedServiceConfig | null = null; - let installCount = 0; - let pairCount = 0; - let rejectVerification = false; - const revokedCredentialIds: string[] = []; - let installedCliPath = ''; - const outputs: string[] = []; - const options = { - json: true, - clientDataRoot, - defaultRootPath: stateRoot, - sourcePackageRoot, - version: '0.2.0', - principalId: 'desktop.client-1', - preset: 'desktop-client', - directPeer: { coordinationRelays: [] }, - } as const; - const overrides = { - createBackend: () => unusedBackend(), - manageService: async (input: { readonly action: string; readonly cliPath: string }) => { - if (input.action === 'status') return serviceResult('status', config, '0.2.0'); - installCount += 1; - installedCliPath = input.cliPath; - config = { - schemaVersion: 1, - rootPath: stateRoot, - projectDirectoryRoots: [], - websocket: { host: '127.0.0.1', port: 42_111, path: '/runtime-host' }, - launch: { nodePath: process.execPath, cliPath: input.cliPath }, - peer: { - enabled: true, - peerId: '12D3KooWpeer', - listenAddresses: ['/ip4/192.0.2.10/udp/41000/quic-v1'], - coordinationRelays: [], - }, - }; - return serviceResult('install', config, '0.2.0'); - }, - prepareDeployment: (input: Parameters[0]) => - prepareRuntimeHostManagedPackageDeployment(input, deploymentPathOptions), - replaceCredential: async () => { - pairCount += 1; - return { - rootId: 'a'.repeat(64), - credential: `secret-${pairCount}`, - credentialId: `credential-${pairCount}`, - principalKind: 'remote_owner' as const, - principalId: 'desktop.client-1', - operationGrants: ['host.status'] as const, - canPublishClientCapabilities: true, - canUseHostPaths: false, - }; - }, - prepareCredential: async () => { - pairCount += 1; - return { - rootId: 'a'.repeat(64), - credential: `secret-${pairCount}`, - credentialId: `credential-${pairCount}`, - principalKind: 'remote_owner' as const, - principalId: 'desktop.client-1', - operationGrants: ['host.status'] as const, - canPublishClientCapabilities: true, - canUseHostPaths: false, - }; - }, - verifyCredential: async (input: { readonly endpoint: string; readonly credential: string }) => { - assert.equal(input.endpoint, 'ws://127.0.0.1:42111/runtime-host'); - assert.match(input.credential, /^secret-/u); - if (rejectVerification) throw new Error('verification failed'); - }, - revokeCredential: async ({ credentialId }: { readonly credentialId: string }) => { - revokedCredentialIds.push(credentialId); - return { credentialId, revoked: true }; - }, - writeOutput: (value: string) => outputs.push(value), - }; - - assert.equal(await runRuntimeHostSetupCli(options, overrides), 0); - assert.equal(await runRuntimeHostSetupCli(options, overrides), 0); - rejectVerification = true; - assert.equal( - await runRuntimeHostSetupCli({ ...options, deferPairingCommit: true }, overrides), - 1, - ); - assert.equal(installCount, 3); - assert.equal(pairCount, 3); - assert.deepEqual(revokedCredentialIds, ['credential-3']); - const canonicalDeploymentRoot = await realpath(deploymentRoot); - assert.ok(installedCliPath.startsWith(canonicalDeploymentRoot)); - assert.equal( - outputs.some((output) => output.includes('secret-')), - false, - ); - const frames = outputs.map((output) => decodeRuntimeHostSetupFrame(output)); - assert.equal(frames.filter((frame) => frame?.kind === 'complete').length, 2); - const complete = frames.find((frame) => frame?.kind === 'complete'); - assert.equal(complete?.kind === 'complete' ? complete.credential : undefined, 'secret-1'); - assert.deepEqual(complete?.kind === 'complete' ? complete.directPeer : undefined, { - peerId: '12D3KooWpeer', - routeHints: ['/ip4/192.0.2.10/udp/41000/quic-v1'], - coordinationRelays: [], - }); - const operatorPath = complete?.kind === 'complete' ? complete.operatorPath : undefined; - assert.equal(operatorPath, join(canonicalDeploymentRoot, 'operator')); - const operator = await readFile(operatorPath!, 'utf8'); - assert.match(operator, /versions\/0\.2\.0\/dist\/cli\.js/u); - assert.match(operator, /--client-data-root/u); - assert.equal(operator.includes(clientDataRoot), true); - - assert.deepEqual(await readdir(join(canonicalDeploymentRoot, 'versions')), ['0.2.0']); - assert.equal( - JSON.parse( - await readFile(join(canonicalDeploymentRoot, 'versions', '0.2.0', 'package.json'), 'utf8'), - ).version, - '0.2.0', - ); -}); - test('managed setup frames reject malformed machine output', () => { assert.equal( decodeRuntimeHostSetupFrame( @@ -340,82 +206,6 @@ test('managed setup frames reject malformed machine output', () => { ); }); -test('managed setup replaces one exact development package with another', async (t) => { - const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-setup-development-')); - t.after(() => rm(base, { recursive: true, force: true })); - const previousVersion = '0.2.0-dev-111111111111'; - const nextVersion = '0.2.0-dev-222222222222'; - const previousPackage = await createReleasePackage(base, previousVersion); - const nextPackage = await createReleasePackage(base, nextVersion); - const clientDataRoot = join(base, 'config', 'Maka'); - const stateRoot = join(clientDataRoot, 'workspaces', 'default'); - const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot); - const deploymentPathOptions = { - env: { XDG_DATA_HOME: join(base, 'data') }, - homeDir: join(base, 'home'), - platform: 'linux' as const, - }; - const previousDeployment = await prepareRuntimeHostManagedPackageDeployment( - { serviceId, clientDataRoot, sourcePackageRoot: previousPackage, version: previousVersion }, - deploymentPathOptions, - ); - const previousConfig: RuntimeHostManagedServiceConfig = { - schemaVersion: 1, - rootPath: stateRoot, - projectDirectoryRoots: [], - websocket: { host: '127.0.0.1', port: 42_111, path: '/runtime-host' }, - launch: { nodePath: process.execPath, cliPath: previousDeployment.cliPath }, - }; - let installedCliPath: string | undefined; - - const exitCode = await runRuntimeHostSetupCli( - { - json: true, - clientDataRoot, - defaultRootPath: stateRoot, - sourcePackageRoot: nextPackage, - version: nextVersion, - principalId: 'desktop.client-1', - preset: 'desktop-client', - }, - { - createBackend: () => unusedBackend(), - manageService: async (input: { readonly action: string; readonly cliPath: string }) => { - if (input.action === 'status') { - return serviceResult('status', previousConfig, previousVersion); - } - installedCliPath = input.cliPath; - return serviceResult( - 'install', - { - ...previousConfig, - launch: { ...previousConfig.launch, cliPath: input.cliPath }, - }, - nextVersion, - ); - }, - prepareDeployment: (input) => - prepareRuntimeHostManagedPackageDeployment(input, deploymentPathOptions), - replaceCredential: async () => ({ - rootId: 'a'.repeat(64), - credential: 'new-development-secret', - credentialId: 'new-development-credential', - principalKind: 'remote_owner', - principalId: 'desktop.client-1', - operationGrants: ['host.status'], - canPublishClientCapabilities: true, - canUseHostPaths: false, - }), - verifyCredential: async () => undefined, - writeOutput: () => undefined, - }, - ); - - assert.equal(exitCode, 0); - assert.match(installedCliPath ?? '', /0\.2\.0-dev-222222222222/u); - assert.deepEqual(await readdir(join(previousDeployment.root, 'versions')), [nextVersion]); -}); - test('registry package identity avoids local content and recovers an interrupted removal', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-registry-package-')); t.after(() => rm(base, { recursive: true, force: true })); @@ -469,136 +259,6 @@ test('registry package identity avoids local content and recovers an interrupted ); assert.equal(await readFile(recovered.cliPath, 'utf8'), 'registry package\n'); assert.deepEqual(await readdir(versionsRoot), [basename(registryRoot)]); - await mkdir(join(versionsRoot, 'stale-package')); - - const stateRoot = join(clientDataRoot, 'workspaces', 'default'); - const config: RuntimeHostManagedServiceConfig = { - schemaVersion: 1, - managedDeploymentRoot: recovered.root, - rootPath: stateRoot, - projectDirectoryRoots: [], - websocket: { host: '127.0.0.1', port: 42_111, path: '/runtime-host' }, - launch: { nodePath: process.execPath, cliPath: recovered.cliPath }, - }; - let installedCliPath = ''; - assert.equal( - await runRuntimeHostSetupCli( - { - json: true, - clientDataRoot, - defaultRootPath: stateRoot, - sourcePackageRoot: localPackage, - version, - principalId: 'desktop.client-1', - preset: 'desktop-client', - }, - { - createBackend: () => unusedBackend(), - manageService: async (input: { readonly action: string; readonly cliPath: string }) => { - if (input.action === 'status') return serviceResult('status', config, version); - installedCliPath = input.cliPath; - return serviceResult('install', config, version); - }, - prepareDeployment: async () => assert.fail('same-version setup must reuse the deployment'), - replaceCredential: async () => ({ - rootId: 'a'.repeat(64), - credential: 'new-secret', - credentialId: 'new-credential', - principalKind: 'remote_owner', - principalId: 'desktop.client-1', - operationGrants: ['host.status'], - canPublishClientCapabilities: true, - canUseHostPaths: false, - }), - verifyCredential: async () => undefined, - writeOutput: () => undefined, - }, - ), - 0, - ); - assert.equal(installedCliPath, recovered.cliPath); - const repairedOperator = await readFile(join(recovered.root, 'operator'), 'utf8'); - assert.equal(repairedOperator.includes(recovered.cliPath), true); - assert.equal(repairedOperator.includes(clientDataRoot), true); - assert.deepEqual(await readdir(versionsRoot), [basename(registryRoot)]); -}); - -test('managed setup leaves no inactive package when service installation fails', async (t) => { - const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-setup-failure-')); - t.after(() => rm(base, { recursive: true, force: true })); - const sourcePackageRoot = await createReleasePackage(base, '0.2.0'); - const clientDataRoot = join(base, 'config', 'Maka'); - const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot); - const deploymentPathOptions = { - env: { XDG_DATA_HOME: join(base, 'data') }, - homeDir: join(base, 'home'), - platform: 'linux' as const, - }; - const outputs: string[] = []; - let rollbackFails = false; - const options = { - json: true, - clientDataRoot, - defaultRootPath: join(clientDataRoot, 'workspaces', 'default'), - sourcePackageRoot, - version: '0.2.0', - principalId: 'desktop.client-1', - preset: 'desktop-client', - } as const; - const overrides = { - createBackend: () => unusedBackend(), - manageService: async (input: { readonly action: string }) => { - if (input.action === 'status') return serviceResult('status', null, null); - throw new RuntimeHostServiceManagerError( - 'service_manager_operation_failed', - `Injected service failure ${'x'.repeat(2_000)}`, - ); - }, - prepareDeployment: async ( - input: Parameters[0], - ) => { - const deployment = await prepareRuntimeHostManagedPackageDeployment( - input, - deploymentPathOptions, - ); - return rollbackFails - ? { - ...deployment, - rollback: async () => { - await deployment.rollback(); - throw new Error('Injected rollback failure'); - }, - } - : deployment; - }, - writeOutput: (value: string) => outputs.push(value), - }; - const exitCode = await runRuntimeHostSetupCli(options, overrides); - assert.equal(exitCode, 1); - const failure = decodeRuntimeHostSetupFrame(outputs.at(-1) ?? ''); - assert.equal(failure?.kind, 'error'); - assert.equal( - failure?.kind === 'error' ? Buffer.byteLength(failure.error.message, 'utf8') : 0, - 1_024, - ); - await assert.rejects( - access( - join( - resolveRuntimeHostManagedDeploymentRoot(serviceId, deploymentPathOptions), - 'versions', - '0.2.0', - ), - ), - ); - - rollbackFails = true; - outputs.length = 0; - assert.equal(await runRuntimeHostSetupCli(options, overrides), 1); - const rollbackFailure = decodeRuntimeHostSetupFrame(outputs.at(-1) ?? ''); - assert.deepEqual(rollbackFailure?.kind === 'error' ? rollbackFailure.error : undefined, { - code: 'deployment_failed', - message: 'Runtime Host setup failed and its staged package could not be removed', - }); }); test('managed operator binds its Client Data Root and routes deployment cleanup', { @@ -644,6 +304,8 @@ test('managed operator binds its Client Data Root and routes deployment cleanup' 'status', '--client-data-root', clientDataRoot, + '--managed-root-id', + serviceId, ]); await execFile( @@ -700,13 +362,17 @@ test('managed operator binds its Client Data Root and routes deployment cleanup' 'a'.repeat(64), '--client-data-root', clientDataRoot, + '--managed-root-id', + serviceId, ]); }); async function createReleasePackage(base: string, version: string): Promise { const root = join(base, `source-package-${version}`); await mkdir(join(root, 'dist'), { recursive: true }); - await mkdir(join(root, 'node_modules', '@maka', 'runtime-host'), { recursive: true }); + await mkdir(join(root, 'node_modules', '@maka', 'runtime-host'), { + recursive: true, + }); await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'maka-agent', version })); await writeFile(join(root, 'dist', 'cli.js'), '#!/usr/bin/env node\n'); await writeFile( @@ -716,28 +382,6 @@ async function createReleasePackage(base: string, version: string): Promise, - config: RuntimeHostManagedServiceConfig | null, - installedVersion: string | null, -): RuntimeHostManagedServiceResult { - return { - schemaVersion: 1, - action, - service: { - manager: 'systemd_user', - installed: config !== null, - enabled: config !== null, - active: config !== null, - state: config ? 'running' : 'not_installed', - pid: config ? 42 : null, - lastExitCode: null, - installedVersion, - config, - }, - }; -} - function unusedBackend(): RuntimeHostServiceBackend { return { preflightDeployment: async () => undefined, diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 61ab52fcb6..f9c4404129 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -231,7 +231,9 @@ export async function runMakaCli( ): Promise { const version = await readPackageVersion(); const command = parseMakaCliArgs(argv, version, options.cliCommand); - const dataRoots = resolveMakaDataRoots({ profileName: options.dataProfileName }); + const dataRoots = resolveMakaDataRoots({ + profileName: options.dataProfileName, + }); await configureRuntimeHostPeerClient({ cliPath: process.argv[1] ?? '', clientDataRoot: dataRoots.clientDataRoot, @@ -249,7 +251,10 @@ export async function runMakaCli( command.args, { workspaceRoot: () => dataRoots.workspaceRoot }, {}, - { clientDataRoot: dataRoots.clientDataRoot, cliCommand: options.cliCommand }, + { + clientDataRoot: dataRoots.clientDataRoot, + cliCommand: options.cliCommand, + }, ); } case 'activate': { @@ -268,6 +273,39 @@ export async function runMakaCli( } case 'runtime-host-serve': { const { runRuntimeHostServiceCli } = await import('./runtime-host-service-command.js'); + if (command.managedDeployment) { + const { resolveRuntimeHostManagedDeployment, resolveRuntimeHostNpmDeploymentLayout } = + await import('@maka/runtime-host/operator'); + const { config } = await resolveRuntimeHostManagedDeployment( + command.managedDeployment.rootId, + ); + const peer = config.listeners.directPeer?.enabled ? config.listeners.directPeer : undefined; + const packageLayout = resolveRuntimeHostNpmDeploymentLayout( + config.deploymentRoot, + config.launch.package.integrity, + ); + return runRuntimeHostServiceCli({ + rootPath: config.root.path, + json: command.json, + managedLaunchClaim: { + deploymentId: command.managedDeployment.deploymentId, + configRevision: command.managedDeployment.configRevision, + }, + projectDirectoryRoots: config.projectDirectoryRoots, + ...(config.listeners.websocket ? { websocket: config.listeners.websocket } : {}), + ...(peer + ? { + peer: { + nativePath: await resolveRuntimeHostPeerNativePath(packageLayout.cliPath), + keyPath: peer.keyPath, + expectedPeerId: peer.peerId, + listenAddresses: peer.listenAddresses, + coordinationRelays: peer.coordinationRelays, + }, + } + : {}), + }); + } if (command.managedServiceConfigPath) { const { effectiveRuntimeHostProjectDirectoryRoots, readRuntimeHostManagedServiceConfig } = await import('./runtime-host-service-manager.js'); @@ -339,6 +377,7 @@ export async function runMakaCli( defaultRootPath: serviceDataRoots.workspaceRoot, nodePath: process.execPath, cliPath: process.argv[1] ?? '', + ...(command.managedRootId ? { managedRootId: command.managedRootId } : {}), ...(command.rootPath ? { rootPath: command.rootPath } : {}), ...(command.projectDirectoryRoots ? { projectDirectoryRoots: command.projectDirectoryRoots } @@ -368,6 +407,7 @@ export async function runMakaCli( defaultRootPath: serviceDataRoots.workspaceRoot, nodePath: process.execPath, cliPath: process.argv[1] ?? '', + ...(command.managedRootId ? { managedRootId: command.managedRootId } : {}), listenAddresses: command.listenAddresses, ...(command.coordinationRelays ? { coordinationRelays: command.coordinationRelays } : {}), ...(command.expectedTarget ? { expectedTarget: command.expectedTarget } : {}), @@ -388,6 +428,7 @@ export async function runMakaCli( defaultRootPath: serviceDataRoots.workspaceRoot, selector: command.selector, expectedTarget: command.expectedTarget, + ...(command.managedRootId ? { managedRootId: command.managedRootId } : {}), ...(command.allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), }); } @@ -399,6 +440,7 @@ export async function runMakaCli( sourcePackageRoot: fileURLToPath(new URL('..', import.meta.url)), version, expectedTarget: command.expectedTarget, + ...(command.managedRootId ? { managedRootId: command.managedRootId } : {}), ...(command.allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), }); } @@ -415,6 +457,7 @@ export async function runMakaCli( clientDataRoot: serviceDataRoots.clientDataRoot, defaultRootPath: serviceDataRoots.workspaceRoot, selector: command.selector, + ...(command.managedRootId ? { managedRootId: command.managedRootId } : {}), ...(command.expectedTarget ? { expectedTarget: command.expectedTarget } : {}), }); } @@ -433,6 +476,7 @@ export async function runMakaCli( defaultRootPath: serviceDataRoots.workspaceRoot, ...(command.policy ? { policy: command.policy } : {}), ...(command.expectedTarget ? { expectedTarget: command.expectedTarget } : {}), + ...(command.managedRootId ? { managedRootId: command.managedRootId } : {}), }); } return runManagedRuntimeHostUpdateReconcileCli({ @@ -441,6 +485,7 @@ export async function runMakaCli( clientDataRoot: serviceDataRoots.clientDataRoot, defaultRootPath: serviceDataRoots.workspaceRoot, ...(command.expectedTarget ? { expectedTarget: command.expectedTarget } : {}), + ...(command.managedRootId ? { managedRootId: command.managedRootId } : {}), }); } case 'runtime-host-managed-deployment-cleanup': { @@ -453,6 +498,7 @@ export async function runMakaCli( return runManagedRuntimeHostDeploymentCleanupCli({ clientDataRoot: serviceDataRoots.clientDataRoot, cliPath: process.argv[1] ?? '', + ...(command.managedRootId ? { managedRootId: command.managedRootId } : {}), expectedTarget: command.expectedTarget, }); } @@ -495,7 +541,9 @@ export async function runMakaCli( ...(command.expectedRootId ? { expectedRootId: command.expectedRootId } : {}), credentialId: command.credentialId, ...(command.currentCredentialFingerprint - ? { currentCredentialFingerprint: command.currentCredentialFingerprint } + ? { + currentCredentialFingerprint: command.currentCredentialFingerprint, + } : {}), }, command.framed, @@ -600,16 +648,28 @@ function parseTuiArgs(argv: string[]): MakaCliCommand { for (let index = 0; index < argv.length; index += 1) { const option = argv[index]; if (!option || !supported.has(option)) { - return { kind: 'error', message: `Unexpected argument: ${option ?? ''}`, exitCode: 2 }; + return { + kind: 'error', + message: `Unexpected argument: ${option ?? ''}`, + exitCode: 2, + }; } if (values.has(option)) { - return { kind: 'error', message: `Option repeated: ${option}`, exitCode: 2 }; + return { + kind: 'error', + message: `Option repeated: ${option}`, + exitCode: 2, + }; } const value = argv[index + 1]; if (!value || value.startsWith('-')) { const expected = option === '--resume' ? 'a session id' : option === '--cwd' ? 'a directory' : 'a value'; - return { kind: 'error', message: `${option} requires ${expected}`, exitCode: 2 }; + return { + kind: 'error', + message: `${option} requires ${expected}`, + exitCode: 2, + }; } values.set(option, value); index += 1; @@ -618,7 +678,11 @@ function parseTuiArgs(argv: string[]): MakaCliCommand { return { kind: 'error', message: '--cwd requires --resume', exitCode: 2 }; } if (values.has('--project') && values.has('--resume')) { - return { kind: 'error', message: '--project cannot be used with --resume', exitCode: 2 }; + return { + kind: 'error', + message: '--project cannot be used with --resume', + exitCode: 2, + }; } if (values.has('--cwd') && values.has('--host') && values.get('--host') !== 'local') { return { diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index 2c7044814f..bb56c77843 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -46,6 +46,11 @@ export type RuntimeHostCliCommand = kind: 'runtime-host-serve'; rootPath?: string; managedServiceConfigPath?: string; + managedDeployment?: { + rootId: string; + deploymentId: string; + configRevision: number; + }; json: boolean; projectDirectoryRoots?: { label: string; path: string }[]; websocket?: { @@ -98,6 +103,7 @@ export type RuntimeHostCliCommand = json: boolean; framed?: true; clientDataRoot?: string; + managedRootId?: string; rootPath?: string; projectDirectoryRoots?: { label: string; path: string }[]; websocketPort?: number; @@ -113,6 +119,7 @@ export type RuntimeHostCliCommand = json: boolean; framed?: true; clientDataRoot?: string; + managedRootId?: string; listenAddresses: string[]; coordinationRelays?: string[]; expectedTarget?: RuntimeHostManagedServiceTarget; @@ -123,6 +130,7 @@ export type RuntimeHostCliCommand = json: boolean; framed?: true; clientDataRoot?: string; + managedRootId?: string; selector: RuntimeHostUpdateSelector; expectedTarget?: RuntimeHostManagedServiceTarget; } @@ -131,6 +139,7 @@ export type RuntimeHostCliCommand = json: boolean; framed?: true; clientDataRoot?: string; + managedRootId?: string; expectedTarget: RuntimeHostManagedServiceTarget; selector?: RuntimeHostUpdateSelector; allowInterruptActiveTasks?: true; @@ -140,6 +149,7 @@ export type RuntimeHostCliCommand = json: boolean; framed?: true; clientDataRoot?: string; + managedRootId?: string; policy?: RuntimeHostManagedUpdatePolicy; expectedTarget?: RuntimeHostManagedServiceTarget; } @@ -148,11 +158,13 @@ export type RuntimeHostCliCommand = json: boolean; framed?: true; clientDataRoot?: string; + managedRootId?: string; expectedTarget?: RuntimeHostManagedServiceTarget; } | { kind: 'runtime-host-managed-deployment-cleanup'; clientDataRoot?: string; + managedRootId?: string; expectedTarget: RuntimeHostManagedServiceTarget; } | { @@ -377,6 +389,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { return { kind: 'runtime-host-managed-deployment-cleanup', ...(clientDataRoot ? { clientDataRoot } : {}), + ...(options.managedRootId ? { managedRootId: options.managedRootId } : {}), expectedTarget: options.expectedTarget, }; } @@ -493,6 +506,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { json: options.json, ...(options.framed ? { framed: true } : {}), ...(clientDataRoot ? { clientDataRoot } : {}), + ...(options.managedRootId ? { managedRootId: options.managedRootId } : {}), ...(policy ? { policy } : {}), ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), }; @@ -503,6 +517,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { json: options.json, ...(options.framed ? { framed: true } : {}), ...(clientDataRoot ? { clientDataRoot } : {}), + ...(options.managedRootId ? { managedRootId: options.managedRootId } : {}), ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), }; } @@ -514,6 +529,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { json: options.json, ...(options.framed ? { framed: true } : {}), ...(clientDataRoot ? { clientDataRoot } : {}), + ...(options.managedRootId ? { managedRootId: options.managedRootId } : {}), selector, ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), }; @@ -527,6 +543,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { json: options.json, ...(options.framed ? { framed: true } : {}), ...(clientDataRoot ? { clientDataRoot } : {}), + ...(options.managedRootId ? { managedRootId: options.managedRootId } : {}), expectedTarget: options.expectedTarget!, ...(selector ? { selector } : {}), ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), @@ -537,6 +554,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { action, ...options, ...(clientDataRoot ? { clientDataRoot } : {}), + ...(options.managedRootId ? { managedRootId: options.managedRootId } : {}), ...(retainManagedDeployment ? { retainManagedDeployment: true } : {}), ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), ...(action === 'configure' @@ -631,6 +649,7 @@ function parseServicePeerCommand(argv: string[]): RuntimeHostCliCommand { json: options.json, ...(options.framed ? { framed: true as const } : {}), ...(clientDataRoot ? { clientDataRoot } : {}), + ...(options.managedRootId ? { managedRootId: options.managedRootId } : {}), listenAddresses, ...(clearCoordinationRelays ? { coordinationRelays: [] } @@ -666,6 +685,7 @@ function parseUpdateSelector( interface ManagedServiceOptions { readonly json: boolean; readonly framed?: true; + readonly managedRootId?: string; readonly rootPath?: string; readonly projectDirectoryRoots?: { readonly label: string; @@ -693,6 +713,7 @@ function parseManagedServiceOptions( let expectedServiceId: string | undefined; let expectedRootPath: string | undefined; let expectedRootId: string | undefined; + let managedRootId: string | undefined; let projectDirectoryPolicySpecified = false; const projectDirectoryRoots: { label: string; path: string }[] = []; for (let index = 0; index < argv.length; index += 1) { @@ -726,7 +747,8 @@ function parseManagedServiceOptions( const isTargetOption = argument === '--expected-service-id' || argument === '--expected-root-path' || - argument === '--expected-root-id'; + argument === '--expected-root-id' || + argument === '--managed-root-id'; const isExplicitlyAllowedOption = Object.hasOwn(input.valueOptions ?? {}, argument ?? ''); if (input.allowConfiguration === false && !isTargetOption && !isExplicitlyAllowedOption) { return error(`Unexpected argument: ${argument ?? ''}`); @@ -740,6 +762,7 @@ function parseManagedServiceOptions( argument === '--expected-service-id' || argument === '--expected-root-path' || argument === '--expected-root-id' || + argument === '--managed-root-id' || Object.hasOwn(input.valueOptions ?? {}, argument ?? '') ) { const parsed = optionValue(argv, index, argument ?? ''); @@ -750,6 +773,7 @@ function parseManagedServiceOptions( else if (argument === '--expected-service-id') expectedServiceId = parsed; else if (argument === '--expected-root-path') expectedRootPath = parsed; else if (argument === '--expected-root-id') expectedRootId = parsed; + else if (argument === '--managed-root-id') managedRootId = parsed; else if (argument === '--project-root' || argument === '--project-root-json') { if (projectDirectoryPolicySpecified && projectDirectoryRoots.length === 0) { return error('--project-root cannot be combined with --no-project-roots'); @@ -793,6 +817,9 @@ function parseManagedServiceOptions( if (expectedRootId !== undefined && !/^[a-f0-9]{64}$/u.test(expectedRootId)) { return error('--expected-root-id must be a Runtime Host State Root identity'); } + if (managedRootId !== undefined && !/^[a-f0-9]{64}$/u.test(managedRootId)) { + return error('--managed-root-id must be a Runtime Host State Root identity'); + } if ( expectedRootPath !== undefined && (expectedRootPath.length === 0 || @@ -813,6 +840,7 @@ function parseManagedServiceOptions( return { json, ...(framed ? { framed: true as const } : {}), + ...(managedRootId ? { managedRootId } : {}), ...(rootPath ? { rootPath } : {}), ...(projectDirectoryPolicySpecified ? { projectDirectoryRoots } : {}), ...(websocketPort === undefined ? {} : { websocketPort }), @@ -1082,6 +1110,9 @@ function parseCapabilityProviderCommand(argv: string[]): RuntimeHostCliCommand { function parseServeCommand(argv: string[]): RuntimeHostCliCommand { let rootPath: string | undefined; let managedServiceConfigPath: string | undefined; + let managedRootId: string | undefined; + let managedDeploymentId: string | undefined; + let managedConfigRevision: number | undefined; let json = false; let websocketHost = '127.0.0.1'; let websocketConfigured = false; @@ -1129,6 +1160,26 @@ function parseServeCommand(argv: string[]): RuntimeHostCliCommand { index += 1; continue; } + if ( + argument === '--root-id' || + argument === '--deployment-id' || + argument === '--config-revision' + ) { + const parsed = optionValue(argv, index, argument); + if (typeof parsed !== 'string') return parsed; + if (argument === '--root-id') { + if (managedRootId !== undefined) return error('Duplicate --root-id'); + managedRootId = parsed; + } else if (argument === '--deployment-id') { + if (managedDeploymentId !== undefined) return error('Duplicate --deployment-id'); + managedDeploymentId = parsed; + } else { + if (managedConfigRevision !== undefined) return error('Duplicate --config-revision'); + managedConfigRevision = Number(parsed); + } + index += 1; + continue; + } if (argument === '--project-root' || argument === '--project-root-json') { if (projectDirectoryPolicySpecified && projectDirectoryRoots.length === 0) { return error('--project-root cannot be combined with --no-project-roots'); @@ -1251,10 +1302,52 @@ function parseServeCommand(argv: string[]): RuntimeHostCliCommand { ) { return error('--managed-service-config cannot be combined with Runtime Host settings'); } + const managedDeploymentSpecified = + managedRootId !== undefined || + managedDeploymentId !== undefined || + managedConfigRevision !== undefined; + if ( + managedDeploymentSpecified && + (managedRootId === undefined || + managedDeploymentId === undefined || + managedConfigRevision === undefined) + ) { + return error('--root-id, --deployment-id, and --config-revision must be provided together'); + } + if ( + managedRootId !== undefined && + (!/^[a-f0-9]{64}$/u.test(managedRootId) || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test( + managedDeploymentId!, + ) || + !Number.isSafeInteger(managedConfigRevision) || + managedConfigRevision! < 1) + ) { + return error('Managed deployment identity is invalid'); + } + if ( + managedRootId !== undefined && + (managedServiceConfigPath !== undefined || + rootPath !== undefined || + projectDirectoryPolicySpecified || + websocketConfigured || + peerNativePath !== undefined) + ) { + return error('Managed deployment identity cannot be combined with Runtime Host settings'); + } return { kind: 'runtime-host-serve', json, ...(managedServiceConfigPath ? { managedServiceConfigPath } : {}), + ...(managedRootId + ? { + managedDeployment: { + rootId: managedRootId, + deploymentId: managedDeploymentId!, + configRevision: managedConfigRevision!, + }, + } + : {}), ...(rootPath ? { rootPath } : {}), ...(projectDirectoryPolicySpecified ? { projectDirectoryRoots } : {}), ...(websocketPort === undefined @@ -1327,7 +1420,10 @@ function parseProjectRootJson( ) { return error('--project-root-json must be a JSON object with label and path'); } - const canonical = canonicalProjectDirectoryRootSpec({ label: root.label, path: root.path }); + const canonical = canonicalProjectDirectoryRootSpec({ + label: root.label, + path: root.path, + }); if (!projectRootValid(canonical, pathKind)) { return error( `--project-root-json must use a valid label and absolute ${pathKind === 'posix' ? 'POSIX' : 'Host'} path`, diff --git a/packages/cli/src/runtime-host-launch-agent-service.ts b/packages/cli/src/runtime-host-launch-agent-service.ts index 68bfcb5132..a112a6b102 100644 --- a/packages/cli/src/runtime-host-launch-agent-service.ts +++ b/packages/cli/src/runtime-host-launch-agent-service.ts @@ -42,6 +42,12 @@ import { runRuntimeHostServiceManagerCommand, type RuntimeHostServiceManagerCommandResult, } from './runtime-host-service-manager-process.js'; +import { + assertRuntimeHostProviderDefinition, + type RuntimeHostLifecycleProvider, + type RuntimeHostProviderDefinition, + type RuntimeHostSupervisorStatus, +} from './runtime-host-lifecycle-provider.js'; const SERVICE_EXIT_TIMEOUT_SECONDS = 45; const SERVICE_BOOTOUT_TIMEOUT_MS = (SERVICE_EXIT_TIMEOUT_SECONDS + 5) * 1_000; @@ -227,19 +233,100 @@ export function createLaunchAgentRuntimeHostService( }, uninstall: async () => { await removeLaunchAgentUpdateScheduler(scheduler); - await bootoutLaunchAgent(context); - await Promise.all([ - removeRuntimeHostServiceFile(context.plistPath, 'LaunchAgent plist'), - removeRuntimeHostServiceFile(context.stdoutPath, 'LaunchAgent stdout log'), - removeRuntimeHostServiceFile(context.stderrPath, 'LaunchAgent stderr log'), - ]); - const after = await readStatus(); - if (after.installed || after.active || after.enabled) { - throw new RuntimeHostServiceManagerError( - 'uninstall_incomplete', - `Runtime Host LaunchAgent still has managed state: ${after.state}`, + await uninstallLaunchAgentSupervisor(context); + }, + }; +} + +export function createLaunchAgentRuntimeHostLifecycleProvider( + serviceId: string, + options: Omit = {}, +): RuntimeHostLifecycleProvider { + const homeDir = options.homeDir ?? homedir(); + const uid = options.uid ?? process.getuid?.(); + if (uid === undefined || !Number.isSafeInteger(uid) || uid < 0) { + throw new RuntimeHostServiceManagerError( + 'service_manager_unavailable', + 'The current macOS user identity could not be determined', + ); + } + const runLaunchctl = options.runLaunchctl ?? defaultRunLaunchctl; + const isProcessAlive = options.isProcessAlive ?? defaultIsProcessAlive; + const label = resolveLaunchAgentLabel(serviceId); + const context: LaunchAgentContext = { + domain: `gui/${String(uid)}`, + label, + serviceTarget: `gui/${String(uid)}/${label}`, + plistPath: resolveLaunchAgentPath(serviceId, homeDir), + stdoutPath: resolveLaunchAgentLogPath(serviceId, 'stdout', homeDir), + stderrPath: resolveLaunchAgentLogPath(serviceId, 'stderr', homeDir), + runLaunchctl, + isProcessAlive, + }; + const scheduler = resolveLaunchAgentUpdateSchedulerContext( + serviceId, + homeDir, + uid, + runLaunchctl, + isProcessAlive, + ); + const status = async (): Promise => { + const { + loaded: _loaded, + manager: _manager, + ...observed + } = await readLaunchAgentStatus(context); + return { provider: 'launch_agent', ...observed }; + }; + return { + supervisor: { + provider: 'launch_agent', + preflight: () => assertLaunchAgentDomain(context), + converge: async (definition) => { + assertRuntimeHostProviderDefinition(definition); + await bootoutLaunchAgent(context); + await prepareLaunchAgentLogs(context); + await writeRuntimeHostServiceFile( + context.plistPath, + renderLaunchAgentSupervisorDefinition(definition, context), + 0o600, ); - } + }, + verify: (definition) => verifyLaunchAgentSupervisorDefinition(context, definition), + status, + activate: () => startLaunchAgent(context), + retire: () => bootoutLaunchAgent(context), + logs: async () => + formatRuntimeHostServiceLogs([ + { label: 'stdout', logs: await readLogTail(context.stdoutPath) }, + { label: 'stderr', logs: await readLogTail(context.stderrPath) }, + ]), + uninstall: () => uninstallLaunchAgentSupervisor(context), + }, + reconciliationTrigger: { + provider: 'launch_agent_timer', + converge: async (definition) => { + assertRuntimeHostProviderDefinition(definition); + await bootoutLaunchAgent(scheduler); + await prepareLaunchAgentLogs(scheduler); + await writeRuntimeHostServiceFile( + scheduler.plistPath, + renderLaunchAgentReconciliationDefinition(definition, scheduler), + 0o600, + ); + }, + verify: (definition) => verifyLaunchAgentReconciliationDefinition(scheduler, definition), + status: async () => { + const observed = await readLaunchAgentStatus(scheduler); + return { installed: observed.installed, active: observed.loaded }; + }, + activate: () => ensureLaunchAgentLoadedIfInstalled(scheduler), + logs: async () => + formatRuntimeHostServiceLogs([ + { label: 'stdout', logs: await readLogTail(scheduler.stdoutPath) }, + { label: 'stderr', logs: await readLogTail(scheduler.stderrPath) }, + ]), + uninstall: () => removeLaunchAgentUpdateScheduler(scheduler), }, }; } @@ -268,6 +355,14 @@ export function renderLaunchAgentPlist( ); } +export function renderLaunchAgentSupervisorDefinition( + definition: RuntimeHostProviderDefinition, + paths: Pick, +): string { + assertRuntimeHostProviderDefinition(definition); + return renderLaunchAgentPlistWithArguments(definition.command, paths); +} + function launchAgentPlistMatchesConfig( plist: string | null, config: RuntimeHostManagedServiceConfig, @@ -328,6 +423,21 @@ export function renderLaunchAgentUpdatePlist( ): string { const args = runtimeHostUpdateReconcileLaunchArguments(config); if (!args) throw new TypeError('Managed deployment root is required for update scheduling'); + return renderLaunchAgentUpdatePlistWithArguments(args, paths); +} + +export function renderLaunchAgentReconciliationDefinition( + definition: RuntimeHostProviderDefinition, + paths: Pick, +): string { + assertRuntimeHostProviderDefinition(definition); + return renderLaunchAgentUpdatePlistWithArguments(definition.command, paths); +} + +function renderLaunchAgentUpdatePlistWithArguments( + args: readonly string[], + paths: Pick, +): string { const stringEntry = (value: string) => ` ${escapeXml(value)}`; return [ '', @@ -561,6 +671,62 @@ function launchAgentSchedulerMismatch(): RuntimeHostServiceManagerError { ); } +async function verifyLaunchAgentSupervisorDefinition( + context: LaunchAgentContext, + definition: RuntimeHostProviderDefinition, +): Promise { + assertRuntimeHostProviderDefinition(definition); + const [status, plist] = await Promise.all([ + readLaunchAgentStatus(context), + readFile(context.plistPath, 'utf8').catch((error: unknown) => { + if (isNodeError(error, 'ENOENT')) return null; + throw error; + }), + ]); + if (!status.installed || plist !== renderLaunchAgentSupervisorDefinition(definition, context)) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The LaunchAgent supervisor does not match its managed deployment', + ); + } +} + +async function verifyLaunchAgentReconciliationDefinition( + context: LaunchAgentContext, + definition: RuntimeHostProviderDefinition, +): Promise { + assertRuntimeHostProviderDefinition(definition); + const [status, plist] = await Promise.all([ + readLaunchAgentStatus(context), + readFile(context.plistPath, 'utf8').catch((error: unknown) => { + if (isNodeError(error, 'ENOENT')) return null; + throw error; + }), + ]); + if ( + !status.installed || + plist !== renderLaunchAgentReconciliationDefinition(definition, context) + ) { + throw launchAgentSchedulerMismatch(); + } +} + +async function uninstallLaunchAgentSupervisor(context: LaunchAgentContext): Promise { + await bootoutLaunchAgent(context); + await Promise.all([ + removeRuntimeHostServiceFile(context.plistPath, 'LaunchAgent plist'), + removeRuntimeHostServiceFile(context.stdoutPath, 'LaunchAgent stdout log'), + removeRuntimeHostServiceFile(context.stderrPath, 'LaunchAgent stderr log'), + ]); + const after = await readLaunchAgentStatus(context); + if (after.installed || after.active || after.enabled) { + throw new RuntimeHostServiceManagerError( + 'uninstall_incomplete', + `Runtime Host LaunchAgent still has managed state: ${after.state}`, + ); + } +} + function isTargetMismatch(error: unknown): boolean { return error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch'; } diff --git a/packages/cli/src/runtime-host-lifecycle-provider.ts b/packages/cli/src/runtime-host-lifecycle-provider.ts new file mode 100644 index 0000000000..fd8dbc736a --- /dev/null +++ b/packages/cli/src/runtime-host-lifecycle-provider.ts @@ -0,0 +1,91 @@ +/* + * 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 type { + RuntimeHostReconciliationProvider, + RuntimeHostSupervisorProvider, +} from '@maka/runtime-host/operator'; +import { isAbsolute } from 'node:path'; + +export type RuntimeHostSupervisorState = + | 'not_installed' + | 'stopped' + | 'starting' + | 'running' + | 'failed'; + +/** A provider receives an exact command, not Host lifecycle configuration. */ +export interface RuntimeHostProviderDefinition { + readonly command: readonly [string, ...string[]]; +} + +export interface RuntimeHostSupervisorStatus { + readonly provider: RuntimeHostSupervisorProvider; + readonly installed: boolean; + readonly enabled: boolean; + readonly active: boolean; + readonly state: RuntimeHostSupervisorState; + readonly pid: number | null; + readonly lastExitCode: number | null; +} + +/** Owns only the OS artifact and process supervision for one Runtime Host. */ +export interface RuntimeHostSupervisor { + readonly provider: RuntimeHostSupervisorProvider; + preflight(): Promise; + converge(definition: RuntimeHostProviderDefinition): Promise; + verify(definition: RuntimeHostProviderDefinition): Promise; + status(): Promise; + activate(): Promise; + retire(): Promise; + logs(): Promise; + uninstall(): Promise; +} + +/** Owns only the OS artifact that invokes the one-shot reconciler. */ +export interface RuntimeHostReconciliationTrigger { + readonly provider: RuntimeHostReconciliationProvider; + converge(definition: RuntimeHostProviderDefinition): Promise; + verify(definition: RuntimeHostProviderDefinition): Promise; + status(): Promise<{ readonly installed: boolean; readonly active: boolean }>; + activate(): Promise; + logs(): Promise; + uninstall(): Promise; +} + +export interface RuntimeHostLifecycleProvider { + readonly supervisor: RuntimeHostSupervisor; + readonly reconciliationTrigger: RuntimeHostReconciliationTrigger; +} + +export function assertRuntimeHostProviderDefinition(value: RuntimeHostProviderDefinition): void { + if ( + value.command.length === 0 || + value.command.length > 64 || + !isAbsolute(value.command[0]) || + value.command.some( + (argument) => + argument.length === 0 || + Buffer.byteLength(argument, 'utf8') > 4_096 || + /[\u0000-\u001f\u007f]/u.test(argument), + ) + ) { + throw new TypeError('Runtime Host provider command is invalid'); + } +} diff --git a/packages/cli/src/runtime-host-lifecycle-transaction.ts b/packages/cli/src/runtime-host-lifecycle-transaction.ts new file mode 100644 index 0000000000..83950d975f --- /dev/null +++ b/packages/cli/src/runtime-host-lifecycle-transaction.ts @@ -0,0 +1,510 @@ +/* + * 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 { join } from 'node:path'; +import { + resolveExistingStorageRoot, + tryAcquireStateRootOwner, + type StateRootOwner, +} from '@maka/storage/root-authority'; +import { + connectExistingRuntimeHost, + prepareConnectedRuntimeHostRetirement, +} from '@maka/runtime-host/client'; +import { RUNTIME_HOST_PROTOCOL_VERSION } from '@maka/runtime-host/protocol'; +import { + beginRuntimeHostManagedDeploymentTransition, + blockRuntimeHostManagedDeploymentTransition, + commitRuntimeHostManagedDeploymentTransition, + decodeRuntimeHostManagedDeploymentConfig, + resolveRuntimeHostNpmDeploymentLayout, + rollbackRuntimeHostManagedDeploymentTransition, + RuntimeHostManagedDeploymentError, + type RuntimeHostManagedDeploymentAuthorityOptions, + type RuntimeHostManagedDeploymentBlocked, + type RuntimeHostManagedDeploymentConfig, + type RuntimeHostManagedDeploymentTransition, + type RuntimeHostManagedDeploymentTransitionOperation, + type RuntimeHostSupervisorProvider, +} from '@maka/runtime-host/operator'; +import type { + RuntimeHostLifecycleProvider, + RuntimeHostProviderDefinition, +} from './runtime-host-lifecycle-provider.js'; + +export interface RuntimeHostLifecycleTransactionDeps { + readonly resolveProvider: ( + provider: RuntimeHostSupervisorProvider, + ) => RuntimeHostLifecycleProvider; + /** Legacy migration keeps the validated old config until commit as its deterministic receipt. */ + readonly uninstallLegacy?: () => Promise; + readonly restoreLegacy?: ( + transition: RuntimeHostManagedDeploymentTransition | RuntimeHostManagedDeploymentBlocked, + ) => Promise; +} + +export interface RuntimeHostLifecycleTransitionInput { + readonly operation: RuntimeHostManagedDeploymentTransitionOperation; + readonly current?: RuntimeHostManagedDeploymentConfig; + readonly desired?: RuntimeHostManagedDeploymentConfig; + readonly transactionId?: string; +} + +export class RuntimeHostLifecycleTransactionError extends Error { + constructor( + readonly code: 'transition_failed' | 'recovery_failed', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'RuntimeHostLifecycleTransactionError'; + } +} + +export type RuntimeHostLifecycleRetirement = + | { readonly kind: 'active_tasks' } + | { readonly kind: 'retired'; readonly owner: StateRootOwner<'interactive'> }; + +export type RuntimeHostLifecycleReplacement = + | { readonly kind: 'active_tasks' } + | { + readonly kind: 'replaced'; + readonly config: RuntimeHostManagedDeploymentConfig; + }; + +export async function retireRuntimeHostLifecycleOwner(input: { + readonly rootPath: string; + readonly rootId: string; + readonly allowInterruptActiveTasks?: boolean; + readonly supervisor?: { + status(): Promise<{ + readonly active: boolean; + readonly pid: number | null; + }>; + retire(): Promise; + }; + readonly timeoutMs?: number; +}): Promise { + const capability = await resolveExistingStorageRoot({ + path: input.rootPath, + kind: 'interactive', + expectedRootId: input.rootId, + }); + const idleOwner = await tryAcquireStateRootOwner(capability); + if (idleOwner) return { kind: 'retired', owner: idleOwner }; + const connected = await connectExistingRuntimeHost({ + rootPath: capability.canonicalPath, + protocol: { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, + }, + }); + if (connected.kind !== 'connected') { + throw new RuntimeHostLifecycleTransactionError( + 'transition_failed', + `Runtime Host cannot prepare for retirement: ${connected.kind}`, + ); + } + try { + const diagnostics = await connected.connection.request('host.diagnostics.query', {}); + const supervisorStatus = await input.supervisor?.status(); + if ( + supervisorStatus && + (!supervisorStatus.active || supervisorStatus.pid !== diagnostics.pid) + ) { + throw new RuntimeHostLifecycleTransactionError( + 'transition_failed', + 'The supervisor and State Root report different Runtime Host processes', + ); + } + const prepared = await prepareConnectedRuntimeHostRetirement( + connected.connection, + input.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', + ); + if (prepared.kind === 'active_tasks') return prepared; + if (prepared.pid !== diagnostics.pid) { + throw new RuntimeHostLifecycleTransactionError( + 'transition_failed', + 'The Runtime Host process changed while retirement was prepared', + ); + } + await input.supervisor?.retire(); + } finally { + await connected.connection.close().catch(() => undefined); + } + const deadline = Date.now() + (input.timeoutMs ?? 45_000); + while (Date.now() < deadline) { + const owner = await tryAcquireStateRootOwner(capability); + if (owner) return { kind: 'retired', owner }; + await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + } + throw new RuntimeHostLifecycleTransactionError( + 'transition_failed', + 'Runtime Host retirement did not release the State Root', + ); +} + +/** + * Replaces one active lifecycle definition and restores its semantics at a fresh revision when + * post-commit activation fails. The deployment record remains the only recovery authority. + */ +export async function replaceRuntimeHostLifecycle(input: { + readonly operation: Extract< + RuntimeHostManagedDeploymentTransitionOperation, + 'lifecycle_change' | 'provider_change' | 'configure' | 'update' + >; + readonly current: RuntimeHostManagedDeploymentConfig; + readonly desired: RuntimeHostManagedDeploymentConfig; + readonly allowInterruptActiveTasks?: boolean; + readonly deps: RuntimeHostLifecycleTransactionDeps; + readonly prepareDesired?: () => Promise; + readonly prepareRollback?: () => Promise; +}): Promise { + const current = decodeRuntimeHostManagedDeploymentConfig(input.current); + const desired = decodeRuntimeHostManagedDeploymentConfig(input.desired); + const currentProvider = supervisedProvider(current, input.deps); + const retirement = await retireRuntimeHostLifecycleOwner({ + rootPath: current.root.path, + rootId: current.root.id, + ...(currentProvider ? { supervisor: currentProvider.supervisor } : {}), + allowInterruptActiveTasks: input.allowInterruptActiveTasks ?? false, + }); + if (retirement.kind === 'active_tasks') return retirement; + try { + await input.prepareDesired?.(); + await applyRuntimeHostLifecycleTransition( + retirement.owner, + { operation: input.operation, current, desired }, + input.deps, + ); + } finally { + await retirement.owner.close(); + } + try { + await activateRuntimeHostLifecycle(desired, input.deps); + await verifyRuntimeHostLifecycleReady(desired, input.deps); + return { kind: 'replaced', config: desired }; + } catch (activationError) { + const rollback: RuntimeHostManagedDeploymentConfig = { + ...current, + configRevision: desired.configRevision + 1, + }; + try { + const desiredProvider = supervisedProvider(desired, input.deps); + const recovery = await retireRuntimeHostLifecycleOwner({ + rootPath: desired.root.path, + rootId: desired.root.id, + ...(desiredProvider ? { supervisor: desiredProvider.supervisor } : {}), + allowInterruptActiveTasks: true, + }); + if (recovery.kind === 'active_tasks') throw new Error('Recovery retirement was refused'); + try { + await input.prepareRollback?.(); + await applyRuntimeHostLifecycleTransition( + recovery.owner, + { operation: input.operation, current: desired, desired: rollback }, + input.deps, + ); + } finally { + await recovery.owner.close(); + } + await activateRuntimeHostLifecycle(rollback, input.deps); + await verifyRuntimeHostLifecycleReady(rollback, input.deps); + } catch (recoveryError) { + throw new RuntimeHostLifecycleTransactionError( + 'recovery_failed', + 'The Runtime Host replacement failed and its previous lifecycle could not be restored', + { cause: new AggregateError([activationError, recoveryError]) }, + ); + } + throw new RuntimeHostLifecycleTransactionError( + 'transition_failed', + 'The Runtime Host replacement failed; its previous lifecycle was restored', + { cause: activationError }, + ); + } +} + +/** + * Changes the only eligible lifecycle owner while the caller holds the State Root fence. + * Provider artifacts are deterministic projections of the authority record, never a journal. + */ +export async function applyRuntimeHostLifecycleTransition( + owner: StateRootOwner<'interactive'>, + input: RuntimeHostLifecycleTransitionInput, + deps: RuntimeHostLifecycleTransactionDeps, + authorityOptions: RuntimeHostManagedDeploymentAuthorityOptions = {}, +): Promise { + const current = input.current && decodeRuntimeHostManagedDeploymentConfig(input.current); + const desired = input.desired && decodeRuntimeHostManagedDeploymentConfig(input.desired); + const transactionId = input.transactionId ?? randomUUID(); + if (desired?.lifecycle.mode === 'supervised') { + await deps.resolveProvider(desired.lifecycle.provider).supervisor.preflight(); + } + const { record } = await beginRuntimeHostManagedDeploymentTransition( + owner, + { + transactionId, + operation: input.operation, + ...(current ? { expected: current } : {}), + ...(desired ? { desired } : {}), + }, + authorityOptions, + ); + try { + if (record.operation === 'legacy_migration') { + if (!deps.uninstallLegacy) throw new Error('Legacy deployment removal is unavailable'); + await deps.uninstallLegacy(); + } + await convergeLifecycleArtifacts(record.from, record.to, deps); + await commitRuntimeHostManagedDeploymentTransition( + owner, + transactionId, + desired, + authorityOptions, + ); + return desired; + } catch (error) { + if (isCommitUnknown(error)) throw error; + try { + await restoreTransition(record, deps); + await rollbackRuntimeHostManagedDeploymentTransition( + owner, + transactionId, + current, + authorityOptions, + ); + } catch (recoveryError) { + if (isCommitUnknown(recoveryError)) throw recoveryError; + await blockRuntimeHostManagedDeploymentTransition( + owner, + transactionId, + recoveryError instanceof Error ? recoveryError.message : 'Lifecycle recovery failed', + authorityOptions, + ).catch(() => undefined); + throw new RuntimeHostLifecycleTransactionError( + 'recovery_failed', + 'The Runtime Host lifecycle transition failed and requires explicit repair', + { cause: new AggregateError([error, recoveryError]) }, + ); + } + throw new RuntimeHostLifecycleTransactionError( + 'transition_failed', + 'The Runtime Host lifecycle transition failed; the previous owner was restored', + { cause: error }, + ); + } +} + +function isCommitUnknown(error: unknown): error is RuntimeHostManagedDeploymentError { + return ( + error instanceof RuntimeHostManagedDeploymentError && error.code === 'deployment_commit_unknown' + ); +} + +/** Rolls an interrupted transition back to its complete previous owner. */ +export async function recoverRuntimeHostLifecycleTransition( + owner: StateRootOwner<'interactive'>, + record: RuntimeHostManagedDeploymentTransition | RuntimeHostManagedDeploymentBlocked, + deps: RuntimeHostLifecycleTransactionDeps, + authorityOptions: RuntimeHostManagedDeploymentAuthorityOptions = {}, +): Promise { + try { + await restoreTransition(record, deps); + await rollbackRuntimeHostManagedDeploymentTransition( + owner, + record.transactionId, + record.from ?? undefined, + authorityOptions, + ); + return record.from ?? undefined; + } catch (error) { + await blockRuntimeHostManagedDeploymentTransition( + owner, + record.transactionId, + error instanceof Error ? error.message : 'Lifecycle recovery failed', + authorityOptions, + ).catch(() => undefined); + throw new RuntimeHostLifecycleTransactionError( + 'recovery_failed', + 'The Runtime Host lifecycle transition requires explicit repair', + { cause: error }, + ); + } +} + +/** Activates only after the canonical active record has committed and the fence is released. */ +export async function activateRuntimeHostLifecycle( + config: RuntimeHostManagedDeploymentConfig, + deps: RuntimeHostLifecycleTransactionDeps, +): Promise { + const canonical = decodeRuntimeHostManagedDeploymentConfig(config); + if (canonical.lifecycle.mode !== 'supervised') return; + const provider = deps.resolveProvider(canonical.lifecycle.provider); + await provider.supervisor.activate(); + if (canonical.reconciliation.trigger === 'scheduled') { + await provider.reconciliationTrigger.activate(); + } +} + +export async function verifyRuntimeHostLifecycleReady( + config: RuntimeHostManagedDeploymentConfig, + deps: RuntimeHostLifecycleTransactionDeps, + timeoutMs = 45_000, +): Promise { + const canonical = decodeRuntimeHostManagedDeploymentConfig(config); + if (canonical.lifecycle.mode !== 'supervised') return; + const provider = deps.resolveProvider(canonical.lifecycle.provider); + const supervisorDefinition = runtimeHostSupervisorDefinition(canonical); + await provider.supervisor.verify(supervisorDefinition); + if (canonical.reconciliation.trigger === 'scheduled') { + await provider.reconciliationTrigger.verify( + runtimeHostReconciliationTriggerDefinition(canonical), + ); + const trigger = await provider.reconciliationTrigger.status(); + if (!trigger.installed || !trigger.active) { + throw new RuntimeHostLifecycleTransactionError( + 'transition_failed', + 'Runtime Host reconciliation scheduling is not active', + ); + } + } + const deadline = Date.now() + timeoutMs; + let lastFailure: unknown = new Error('Runtime Host is not ready'); + while (Date.now() < deadline) { + const status = await provider.supervisor.status(); + if (status.pid !== null && status.active) { + const connected = await connectExistingRuntimeHost({ + rootPath: canonical.root.path, + protocol: { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, + }, + }).catch((error: unknown) => { + lastFailure = error; + return undefined; + }); + if (connected?.kind === 'connected') { + try { + const diagnostics = await connected.connection.request('host.diagnostics.query', {}); + if (diagnostics.pid === status.pid && connected.connection.rootId === canonical.root.id) { + return; + } + lastFailure = new Error('Runtime Host process or Root identity did not match'); + } finally { + await connected.connection.close().catch(() => undefined); + } + } else if (connected) { + lastFailure = new Error(`Runtime Host connection is ${connected.kind}`); + } + } else { + lastFailure = new Error(`Runtime Host supervisor is ${status.state}`); + } + await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + } + throw new RuntimeHostLifecycleTransactionError( + 'transition_failed', + `Runtime Host did not become ready: ${lastFailure instanceof Error ? lastFailure.message : String(lastFailure)}`, + { cause: lastFailure }, + ); +} + +export function runtimeHostSupervisorDefinition( + config: RuntimeHostManagedDeploymentConfig, +): RuntimeHostProviderDefinition { + const canonical = decodeRuntimeHostManagedDeploymentConfig(config); + const layout = resolveRuntimeHostNpmDeploymentLayout( + canonical.deploymentRoot, + canonical.launch.package.integrity, + ); + return { + command: [ + canonical.launch.nodePath, + layout.cliPath, + 'runtime-host', + 'serve', + '--root-id', + canonical.root.id, + '--deployment-id', + canonical.deploymentId, + '--config-revision', + String(canonical.configRevision), + ], + }; +} + +export function runtimeHostReconciliationTriggerDefinition( + config: RuntimeHostManagedDeploymentConfig, +): RuntimeHostProviderDefinition { + const canonical = decodeRuntimeHostManagedDeploymentConfig(config); + return { + command: [join(canonical.deploymentRoot, 'operator'), 'reconcile-update', '--framed'], + }; +} + +async function convergeLifecycleArtifacts( + from: RuntimeHostManagedDeploymentConfig | null, + to: RuntimeHostManagedDeploymentConfig | null, + deps: RuntimeHostLifecycleTransactionDeps, +): Promise { + const fromProvider = supervisedProvider(from, deps); + const toProvider = supervisedProvider(to, deps); + if (fromProvider && fromProvider.supervisor.provider !== toProvider?.supervisor.provider) { + await fromProvider.reconciliationTrigger.uninstall(); + await fromProvider.supervisor.uninstall(); + } + if (!to || !toProvider) return; + const supervisor = runtimeHostSupervisorDefinition(to); + await toProvider.supervisor.converge(supervisor); + await toProvider.supervisor.verify(supervisor); + if (to.reconciliation.trigger === 'scheduled') { + const trigger = runtimeHostReconciliationTriggerDefinition(to); + await toProvider.reconciliationTrigger.converge(trigger); + await toProvider.reconciliationTrigger.verify(trigger); + } else { + await toProvider.reconciliationTrigger.uninstall(); + } +} + +async function restoreTransition( + record: RuntimeHostManagedDeploymentTransition | RuntimeHostManagedDeploymentBlocked, + deps: RuntimeHostLifecycleTransactionDeps, +): Promise { + if (record.operation === 'legacy_migration') { + if (!deps.restoreLegacy) throw new Error('Legacy deployment recovery is unavailable'); + const desiredProvider = supervisedProvider(record.to, deps); + if (desiredProvider) { + await desiredProvider.reconciliationTrigger.uninstall(); + await desiredProvider.supervisor.uninstall(); + } + await deps.restoreLegacy(record); + return; + } + await convergeLifecycleArtifacts(record.to, record.from, deps); +} + +function supervisedProvider( + config: RuntimeHostManagedDeploymentConfig | null, + deps: RuntimeHostLifecycleTransactionDeps, +): RuntimeHostLifecycleProvider | undefined { + return config?.lifecycle.mode === 'supervised' + ? deps.resolveProvider(config.lifecycle.provider) + : undefined; +} diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts index 60ac691227..8325fdf1e2 100644 --- a/packages/cli/src/runtime-host-managed-deployment.ts +++ b/packages/cli/src/runtime-host-managed-deployment.ts @@ -70,7 +70,7 @@ export async function prepareRuntimeHostManagedPackageDeployment( version: input.version, ...(input.packageIntegrity ? { packageIntegrity: input.packageIntegrity } : {}), }); - return managedDeployment(staged, clientDataRoot); + return managedDeployment(staged, clientDataRoot, input.serviceId); } export async function openRuntimeHostManagedPackageDeployment(input: { @@ -105,6 +105,7 @@ export async function openRuntimeHostManagedPackageDeployment(input: { version: input.version, }), resolve(input.clientDataRoot), + input.serviceId, ); } @@ -212,6 +213,7 @@ export async function removeRuntimeHostManagedDeployment( function managedDeployment( staged: RuntimeHostPackageDeployment, clientDataRoot: string, + managedRootId: string, ): RuntimeHostManagedPackageDeployment { const operatorPath = join(staged.root, 'operator'); return { @@ -220,7 +222,13 @@ function managedDeployment( cliPath: staged.cliPath, operatorPath, activate: () => - writeOperatorLauncher(operatorPath, process.execPath, staged.cliPath, clientDataRoot), + writeOperatorLauncher( + operatorPath, + process.execPath, + staged.cliPath, + clientDataRoot, + managedRootId, + ), cleanup: staged.cleanup, rollback: staged.rollback, }; @@ -231,13 +239,14 @@ async function writeOperatorLauncher( nodePath: string, cliPath: string, clientDataRoot: string, + managedRootId: string, ): Promise { const temporaryPath = `${path}.${randomUUID()}.tmp`; const contents = [ '#!/bin/sh', 'if [ "$#" -ge 1 ] && [ "$1" = "__cleanup-managed-deployment" ]; then', ' shift', - ` exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host service cleanup-deployment "$@" --client-data-root ${quotePosix(clientDataRoot)}`, + ` exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host service cleanup-deployment "$@" --client-data-root ${quotePosix(clientDataRoot)} --managed-root-id ${quotePosix(managedRootId)}`, 'fi', 'if [ "$#" -ge 1 ] && [ "$1" = "access" ]; then', ' shift', @@ -247,7 +256,11 @@ async function writeOperatorLauncher( ' shift', ` exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host activate "$@"`, 'fi', - `exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host service "$@" --client-data-root ${quotePosix(clientDataRoot)}`, + 'if [ "$#" -ge 1 ] && [ "$1" = "serve" ]; then', + ' shift', + ` exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host serve "$@"`, + 'fi', + `exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host service "$@" --client-data-root ${quotePosix(clientDataRoot)} --managed-root-id ${quotePosix(managedRootId)}`, '', ].join('\n'); try { diff --git a/packages/cli/src/runtime-host-managed-lifecycle-manager.ts b/packages/cli/src/runtime-host-managed-lifecycle-manager.ts new file mode 100644 index 0000000000..cef6abf8d6 --- /dev/null +++ b/packages/cli/src/runtime-host-managed-lifecycle-manager.ts @@ -0,0 +1,358 @@ +/* + * 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 { rm } from 'node:fs/promises'; +import { resolveRuntimeHostNpmDeploymentLayout } from '@maka/runtime-host/operator'; +import { + resolveRuntimeHostManagedDeployment, + type RuntimeHostManagedDeploymentConfig, +} from '@maka/runtime-host/operator'; +import { + applyRuntimeHostLifecycleTransition, + activateRuntimeHostLifecycle, + replaceRuntimeHostLifecycle, + retireRuntimeHostLifecycleOwner, + runtimeHostReconciliationTriggerDefinition, + runtimeHostSupervisorDefinition, + verifyRuntimeHostLifecycleReady, + type RuntimeHostLifecycleTransactionDeps, +} from './runtime-host-lifecycle-transaction.js'; +import type { RuntimeHostLifecycleProvider } from './runtime-host-lifecycle-provider.js'; +import { + effectiveRuntimeHostProjectDirectoryRoots, + resolveRuntimeHostManagedProjectDirectoryRoots, + runtimeHostManagedServiceConfigFingerprint, + RuntimeHostServiceManagerError, + type RuntimeHostManagedServiceConfig, + type RuntimeHostManagedServiceInput, + type RuntimeHostManagedServiceResult, + type RuntimeHostManagedServiceStatus, + type RuntimeHostRetirementResult, +} from './runtime-host-service-manager.js'; + +export interface RuntimeHostManagedLifecycleManagerDeps { + readonly createProvider: (rootId: string) => RuntimeHostLifecycleProvider; + readonly applyTransition?: typeof applyRuntimeHostLifecycleTransition; + readonly activate?: typeof activateRuntimeHostLifecycle; + readonly verifyReady?: typeof verifyRuntimeHostLifecycleReady; + readonly retire?: typeof retireRuntimeHostLifecycleOwner; + readonly replace?: typeof replaceRuntimeHostLifecycle; +} + +export async function manageRuntimeHostManagedLifecycle( + rootId: string, + input: RuntimeHostManagedServiceInput, + dependencies: RuntimeHostManagedLifecycleManagerDeps, +): Promise { + const { config } = await resolveRuntimeHostManagedDeployment(rootId); + if (input.expectedTarget) assertExpectedTarget(input.expectedTarget, config); + if (config.lifecycle.mode !== 'supervised') { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'This managed Runtime Host uses on-demand lifecycle activation', + ); + } + const provider = dependencies.createProvider(rootId); + if (provider.supervisor.provider !== config.lifecycle.provider) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + `The persisted Runtime Host provider ${config.lifecycle.provider} is unavailable`, + ); + } + const lifecycleDeps: RuntimeHostLifecycleTransactionDeps = { + resolveProvider: (requested) => { + if (requested !== provider.supervisor.provider) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + `The persisted Runtime Host provider ${requested} is unavailable`, + ); + } + return provider; + }, + }; + const applyTransition = dependencies.applyTransition ?? applyRuntimeHostLifecycleTransition; + const activate = dependencies.activate ?? activateRuntimeHostLifecycle; + const verifyReady = dependencies.verifyReady ?? verifyRuntimeHostLifecycleReady; + const retire = dependencies.retire ?? retireRuntimeHostLifecycleOwner; + const replace = dependencies.replace ?? replaceRuntimeHostLifecycle; + + if (input.action === 'install') { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'Managed Runtime Host installation must use runtime-host setup', + ); + } + if (input.action === 'status') { + await verifyProviderDefinitions(config, provider); + return result('status', await status(config, provider)); + } + if (input.action === 'logs') { + await verifyProviderDefinitions(config, provider); + const [host, reconciliation] = await Promise.all([ + provider.supervisor.logs(), + provider.reconciliationTrigger.logs(), + ]); + return { + ...result('logs', await status(config, provider)), + logs: [host, reconciliation].filter(Boolean).join('\n'), + }; + } + if (input.action === 'start' || input.action === 'restart') { + if (input.action === 'restart') { + const retirement = await retire({ + rootPath: config.root.path, + rootId, + supervisor: provider.supervisor, + allowInterruptActiveTasks: true, + }); + if (retirement.kind === 'active_tasks') throw new Error('Unexpected active-task refusal'); + await retirement.owner.close(); + } + await activate(config, lifecycleDeps); + await verifyReady(config, lifecycleDeps); + return result(input.action, await status(config, provider)); + } + if (input.action === 'stop' || input.action === 'retire') { + const retirement = await retire({ + rootPath: config.root.path, + rootId, + supervisor: provider.supervisor, + allowInterruptActiveTasks: input.allowInterruptActiveTasks ?? false, + }); + if (retirement.kind === 'active_tasks') { + if (input.action === 'stop') { + throw new RuntimeHostServiceManagerError( + 'retirement_failed', + 'Runtime Host still owns active work; it was not stopped', + ); + } + const current = await status(config, provider); + return resultWithRetirement('retire', current, retirement); + } + await retirement.owner.close(); + const stopped = await status(config, provider); + return input.action === 'retire' + ? resultWithRetirement('retire', stopped, { kind: 'stopped' }) + : result('stop', stopped); + } + if (input.action === 'configure') { + if (!input.expectedConfigFingerprint) { + throw new RuntimeHostServiceManagerError( + 'configuration_changed', + 'Runtime Host configuration requires its observed fingerprint', + ); + } + const currentStatus = await status(config, provider); + if ( + runtimeHostManagedServiceConfigFingerprint(currentStatus.config!) !== + input.expectedConfigFingerprint + ) { + throw new RuntimeHostServiceManagerError( + 'configuration_changed', + 'The Runtime Host configuration changed before it could be updated', + ); + } + const projectDirectoryRoots = await resolveRuntimeHostManagedProjectDirectoryRoots( + input.projectDirectoryRoots ?? + effectiveRuntimeHostProjectDirectoryRoots(currentStatus.config!), + ); + if (JSON.stringify(projectDirectoryRoots) === JSON.stringify(config.projectDirectoryRoots)) { + return configurationResult('unchanged', currentStatus); + } + const desired: RuntimeHostManagedDeploymentConfig = { + ...config, + configRevision: config.configRevision + 1, + projectDirectoryRoots: [...projectDirectoryRoots], + }; + const replacement = await replace({ + operation: 'configure', + current: config, + desired, + allowInterruptActiveTasks: input.allowInterruptActiveTasks ?? false, + deps: lifecycleDeps, + }); + if (replacement.kind === 'active_tasks') { + return configurationResult('active_tasks', currentStatus); + } + return configurationResult('configured', await status(desired, provider)); + } + if (input.action === 'uninstall') { + const retirement = await retire({ + rootPath: config.root.path, + rootId, + supervisor: provider.supervisor, + allowInterruptActiveTasks: input.allowInterruptActiveTasks ?? false, + }); + if (retirement.kind === 'active_tasks') { + return { + ...resultWithRetirement('uninstall', await status(config, provider), retirement), + retainedStateRoot: config.root.path, + }; + } + try { + await applyTransition( + retirement.owner, + { operation: 'uninstall', current: config }, + lifecycleDeps, + ); + } finally { + await retirement.owner.close(); + } + if (config.listeners.directPeer) { + await rm(config.listeners.directPeer.keyPath, { force: true }); + } + return { + ...resultWithRetirement('uninstall', absentStatus(config), { + kind: 'stopped', + }), + retainedStateRoot: config.root.path, + }; + } + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + `Unsupported managed Runtime Host action: ${input.action}`, + ); +} + +async function verifyProviderDefinitions( + config: RuntimeHostManagedDeploymentConfig, + provider: RuntimeHostLifecycleProvider, +): Promise { + await provider.supervisor.verify(runtimeHostSupervisorDefinition(config)); + if (config.reconciliation.trigger === 'scheduled') { + await provider.reconciliationTrigger.verify(runtimeHostReconciliationTriggerDefinition(config)); + } +} + +async function status( + config: RuntimeHostManagedDeploymentConfig, + provider: RuntimeHostLifecycleProvider, +): Promise { + const observed = await provider.supervisor.status(); + return { + manager: observed.provider === 'systemd_user' ? 'systemd_user' : 'launch_agent', + installed: observed.installed, + enabled: observed.enabled, + active: observed.active, + state: observed.state, + pid: observed.pid, + lastExitCode: observed.lastExitCode, + config: projectLegacyConfig(config), + installedVersion: config.launch.package.version, + lifecycle: { ...config.lifecycle }, + reconciliation: { ...config.reconciliation }, + }; +} + +function absentStatus(config: RuntimeHostManagedDeploymentConfig): RuntimeHostManagedServiceStatus { + return { + manager: + config.lifecycle.mode === 'supervised' && config.lifecycle.provider === 'systemd_user' + ? 'systemd_user' + : 'launch_agent', + installed: false, + enabled: false, + active: false, + state: 'not_installed', + pid: null, + lastExitCode: null, + config: null, + installedVersion: null, + lifecycle: { ...config.lifecycle }, + reconciliation: { ...config.reconciliation }, + }; +} + +function projectLegacyConfig( + config: RuntimeHostManagedDeploymentConfig, +): RuntimeHostManagedServiceConfig { + const layout = resolveRuntimeHostNpmDeploymentLayout( + config.deploymentRoot, + config.launch.package.integrity, + ); + const websocket = config.listeners.websocket; + if (!websocket || websocket.port === 0) { + throw new RuntimeHostServiceManagerError( + 'invalid_config', + 'A supervised Runtime Host requires a stable WebSocket endpoint', + ); + } + const peer = config.listeners.directPeer; + return { + schemaVersion: 2, + managedDeploymentRoot: config.deploymentRoot, + rootPath: config.root.path, + projectDirectoryRoots: [...config.projectDirectoryRoots], + websocket, + launch: { nodePath: config.launch.nodePath, cliPath: layout.cliPath }, + ...(peer + ? { + peer: { + enabled: peer.enabled, + peerId: peer.peerId, + listenAddresses: [...peer.listenAddresses], + coordinationRelays: [...peer.coordinationRelays], + }, + } + : {}), + }; +} + +function assertExpectedTarget( + expected: NonNullable, + config: RuntimeHostManagedDeploymentConfig, +): void { + if ( + expected.serviceId !== config.root.id || + expected.rootId !== config.root.id || + expected.rootPath !== config.root.path + ) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The managed Runtime Host does not match the expected deployment identity', + ); + } +} + +function result( + action: Exclude, + service: RuntimeHostManagedServiceStatus, +): RuntimeHostManagedServiceResult { + return { schemaVersion: 1, action, service }; +} + +function resultWithRetirement( + action: 'retire' | 'uninstall', + service: RuntimeHostManagedServiceStatus, + retirement: RuntimeHostRetirementResult, +): RuntimeHostManagedServiceResult { + return { schemaVersion: 1, action, service, retirement }; +} + +function configurationResult( + kind: 'unchanged' | 'configured' | 'active_tasks', + service: RuntimeHostManagedServiceStatus, +): RuntimeHostManagedServiceResult { + return { + schemaVersion: 1, + action: 'configure', + service, + configuration: { kind }, + }; +} diff --git a/packages/cli/src/runtime-host-peer-management-command.ts b/packages/cli/src/runtime-host-peer-management-command.ts index 0782ecc981..15271f9620 100644 --- a/packages/cli/src/runtime-host-peer-management-command.ts +++ b/packages/cli/src/runtime-host-peer-management-command.ts @@ -17,15 +17,23 @@ * under the License. */ +import { randomUUID } from 'node:crypto'; +import { rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; import { networkInterfaces } from 'node:os'; import { encodeRuntimeHostPeerManagementFrame, + resolveRuntimeHostManagedDeployment, + resolveRuntimeHostNpmDeploymentLayout, type RuntimeHostPeerManagementFrame, type RuntimeHostPeerStatus, } from '@maka/runtime-host/operator'; +import { ensureRuntimeHostPeerIdentity } from '@maka/runtime-host/client'; import { configureRuntimeHostManagedPeer, manageRuntimeHostService, + allocateRuntimeHostPeerPort, assertRuntimeHostManagedPeerMutationComplete, resolveRuntimeHostManagedServiceId, rotateRuntimeHostManagedPeerIdentity, @@ -36,6 +44,13 @@ import { type RuntimeHostManagedServiceTarget, } from './runtime-host-service-manager.js'; import { createPlatformRuntimeHostServiceBackend } from './runtime-host-service-management-command.js'; +import { createPlatformRuntimeHostLifecycleProvider } from './runtime-host-service-management-command.js'; +import { replaceRuntimeHostLifecycle } from './runtime-host-lifecycle-transaction.js'; +import { manageRuntimeHostManagedLifecycle } from './runtime-host-managed-lifecycle-manager.js'; +import { + resolveRuntimeHostManagedPeerKeyPath, + resolveRuntimeHostPeerNativePath, +} from './runtime-host-peer-artifact.js'; export interface RuntimeHostPeerManagementCliOptions { readonly action: 'enable' | 'disable' | 'status' | 'rotate' | 'descriptor'; @@ -45,6 +60,7 @@ export interface RuntimeHostPeerManagementCliOptions { readonly defaultRootPath: string; readonly nodePath: string; readonly cliPath: string; + readonly managedRootId?: string; readonly listenAddresses: readonly string[]; readonly coordinationRelays?: readonly string[]; readonly expectedTarget?: RuntimeHostManagedServiceTarget; @@ -70,6 +86,17 @@ export async function runRuntimeHostPeerManagementCli( ...overrides, }; try { + if (options.managedRootId) { + const canonicalOptions = { + ...options, + managedRootId: options.managedRootId, + }; + return await withRuntimeHostManagedServiceDeploymentLock(options.clientDataRoot, () => + withRuntimeHostManagedServiceLifecycleLock(options.clientDataRoot, () => + runCanonicalRuntimeHostPeerManagementLocked(canonicalOptions, deps), + ), + ); + } const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); const backend = deps.createBackend(serviceId, options.clientDataRoot); return await withRuntimeHostManagedServiceDeploymentLock(options.clientDataRoot, () => @@ -83,6 +110,243 @@ export async function runRuntimeHostPeerManagementCli( } } +async function runCanonicalRuntimeHostPeerManagementLocked( + options: RuntimeHostPeerManagementCliOptions & { + readonly managedRootId: string; + }, + deps: RuntimeHostPeerManagementCliDeps, +): Promise { + const rootId = options.managedRootId; + const { config } = await resolveRuntimeHostManagedDeployment(rootId); + if ( + options.expectedTarget && + (options.expectedTarget.serviceId !== rootId || + options.expectedTarget.rootId !== rootId || + options.expectedTarget.rootPath !== config.root.path) + ) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The managed Runtime Host does not match the expected deployment identity', + ); + } + if (config.lifecycle.mode !== 'supervised') { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'Direct peer management requires a supervised Runtime Host deployment', + ); + } + const provider = createPlatformRuntimeHostLifecycleProvider(rootId); + const lifecycleDeps = { + resolveProvider: (requested: typeof config.lifecycle.provider) => { + if (requested !== provider.supervisor.provider) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + `The persisted Runtime Host provider ${requested} is unavailable`, + ); + } + return provider; + }, + }; + let desired = config; + let stagedKeyPath: string | undefined; + let previousPeerId: string | undefined; + let restarted: boolean | undefined; + if (options.action === 'enable') { + const peer = await prepareCanonicalPeer(options, config, config.listeners.directPeer); + desired = { + ...config, + configRevision: config.configRevision + 1, + listeners: { ...config.listeners, directPeer: peer }, + }; + } else if (options.action === 'disable' && config.listeners.directPeer?.enabled) { + desired = { + ...config, + configRevision: config.configRevision + 1, + listeners: { + ...config.listeners, + directPeer: { ...config.listeners.directPeer, enabled: false }, + }, + }; + } else if (options.action === 'rotate') { + const current = config.listeners.directPeer; + if (!current?.enabled) { + throw new RuntimeHostServiceManagerError( + 'not_installed', + 'Direct peer is not enabled for the managed Runtime Host deployment', + ); + } + previousPeerId = current.peerId; + stagedKeyPath = join(dirname(current.keyPath), `runtime-host-peer.${randomUUID()}.key`); + const layout = resolveRuntimeHostNpmDeploymentLayout( + config.deploymentRoot, + config.launch.package.integrity, + ); + const peerId = await ensureRuntimeHostPeerIdentity({ + nativePath: await resolveRuntimeHostPeerNativePath(layout.cliPath), + keyPath: stagedKeyPath, + }); + desired = { + ...config, + configRevision: config.configRevision + 1, + listeners: { + ...config.listeners, + directPeer: { ...current, keyPath: stagedKeyPath, peerId }, + }, + }; + } + + if (!isDeepStrictEqual(desired, config)) { + const replacement = await replaceRuntimeHostLifecycle({ + operation: 'configure', + current: config, + desired, + allowInterruptActiveTasks: options.allowInterruptActiveTasks ?? false, + deps: lifecycleDeps, + }).catch(async (error: unknown) => { + if (stagedKeyPath) await rm(stagedKeyPath, { force: true }).catch(() => undefined); + throw error; + }); + if (replacement.kind === 'active_tasks') { + if (stagedKeyPath) await rm(stagedKeyPath, { force: true }).catch(() => undefined); + return writePeerActiveTasks( + options, + options.action === 'rotate' + ? 'Runtime Host still owns active work; its peer identity was not rotated.' + : 'Runtime Host still owns active work; direct-peer configuration was not changed.', + deps, + ); + } + restarted = true; + if (options.action === 'rotate' && config.listeners.directPeer) { + await rm(config.listeners.directPeer.keyPath, { force: true }).catch(() => undefined); + } + } else if (options.action === 'enable' || options.action === 'disable') { + restarted = false; + } + + const status = await readCanonicalPeerStatus(options, desired); + if (options.action === 'descriptor' && status.state !== 'enabled') { + throw new RuntimeHostServiceManagerError( + 'not_installed', + 'Direct peer is not enabled for the managed Runtime Host deployment', + ); + } + if (options.action === 'rotate') { + if (options.framed) throw new TypeError('Direct-peer rotation does not support framed output'); + if (options.json) { + deps.writeStdout( + `${JSON.stringify({ schemaVersion: 1, ok: true, action: options.action, previousPeerId, peerId: status.peerId })}\n`, + ); + } else { + deps.writeStdout(`Direct peer identity changed: ${previousPeerId} -> ${status.peerId}.\n`); + } + return 0; + } + if (options.framed) { + if (options.action === 'descriptor') { + throw new TypeError('Direct-peer descriptor does not support framed output'); + } + writePeerFrame( + options.action === 'status' + ? { kind: 'result', action: options.action, status } + : { + kind: 'result', + action: options.action, + status, + restarted: restarted!, + }, + deps, + ); + } else if (options.json) { + deps.writeStdout( + `${JSON.stringify({ schemaVersion: 1, ...status, ok: true, action: options.action })}\n`, + ); + } else if (options.action === 'descriptor') { + deps.writeStdout(`${JSON.stringify({ schemaVersion: 1, ...status })}\n`); + } else { + deps.writeStdout(formatPeerStatus(status)); + } + return 0; +} + +async function prepareCanonicalPeer( + options: RuntimeHostPeerManagementCliOptions, + config: Awaited>['config'], + current: Awaited< + ReturnType + >['config']['listeners']['directPeer'], +): Promise> { + const layout = resolveRuntimeHostNpmDeploymentLayout( + config.deploymentRoot, + config.launch.package.integrity, + ); + const keyPath = current?.keyPath ?? resolveRuntimeHostManagedPeerKeyPath(options.clientDataRoot); + const peerId = await ensureRuntimeHostPeerIdentity({ + nativePath: await resolveRuntimeHostPeerNativePath(layout.cliPath), + keyPath, + }); + if (current && current.peerId !== peerId) { + throw new RuntimeHostServiceManagerError( + 'invalid_config', + 'The managed Runtime Host peer identity does not match its deployment', + ); + } + return { + enabled: true, + keyPath, + peerId, + listenAddresses: [ + ...new Set( + options.listenAddresses.length > 0 + ? options.listenAddresses + : (current?.listenAddresses ?? [ + `/ip4/0.0.0.0/udp/${String(await allocateRuntimeHostPeerPort())}/quic-v1`, + ]), + ), + ], + coordinationRelays: [ + ...new Set(options.coordinationRelays ?? current?.coordinationRelays ?? []), + ], + }; +} + +async function readCanonicalPeerStatus( + options: RuntimeHostPeerManagementCliOptions & { + readonly managedRootId: string; + }, + config: Awaited>['config'], +): Promise { + const result = await manageRuntimeHostManagedLifecycle( + options.managedRootId, + { + action: 'status', + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + nodePath: options.nodePath, + cliPath: options.cliPath, + ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), + }, + { createProvider: createPlatformRuntimeHostLifecycleProvider }, + ); + const peer = config.listeners.directPeer; + if (!peer) { + return { + state: 'not_configured', + serviceState: result.service.state, + routeHints: [], + coordinationRelays: [], + }; + } + return { + state: peer.enabled ? 'enabled' : 'disabled', + serviceState: result.service.state, + peerId: peer.peerId, + rootId: config.root.id, + routeHints: expandWildcardListenAddresses(peer.listenAddresses), + coordinationRelays: [...peer.coordinationRelays], + }; +} + async function runRuntimeHostPeerManagementLocked( options: RuntimeHostPeerManagementCliOptions, backend: ReturnType, @@ -172,7 +436,12 @@ async function runRuntimeHostPeerManagementLocked( writePeerFrame( options.action === 'status' ? { kind: 'result', action: options.action, status } - : { kind: 'result', action: options.action, status, restarted: restarted! }, + : { + kind: 'result', + action: options.action, + status, + restarted: restarted!, + }, deps, ); } else if (options.json) { diff --git a/packages/cli/src/runtime-host-service-command.ts b/packages/cli/src/runtime-host-service-command.ts index 1e20d9a6ee..2fb88edee4 100644 --- a/packages/cli/src/runtime-host-service-command.ts +++ b/packages/cli/src/runtime-host-service-command.ts @@ -26,11 +26,13 @@ import { RUNTIME_HOST_COMPATIBILITY_EPOCH, RUNTIME_HOST_PROTOCOL_VERSION, } from '@maka/runtime-host/protocol'; +import type { RuntimeHostManagedLaunchClaim } from '@maka/runtime-host/operator'; import { readFile } from 'node:fs/promises'; export interface RuntimeHostServiceCliOptions { readonly rootPath: string; readonly json?: boolean; + readonly managedLaunchClaim?: RuntimeHostManagedLaunchClaim; readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; readonly websocket?: { readonly host: string; @@ -75,6 +77,7 @@ export async function runRuntimeHostServiceCli( : undefined; const host = await startExecutionRuntimeHostService({ rootPath: options.rootPath, + ...(options.managedLaunchClaim ? { managedLaunchClaim: options.managedLaunchClaim } : {}), ...(options.projectDirectoryRoots ? { projectDirectoryRoots: options.projectDirectoryRoots } : {}), diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts index 103bd52b6b..5b9dc397c3 100644 --- a/packages/cli/src/runtime-host-service-management-command.ts +++ b/packages/cli/src/runtime-host-service-management-command.ts @@ -21,6 +21,7 @@ import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { release } from 'node:os'; import { encodeRuntimeHostServiceManagementFrame, + readRuntimeHostManagedDeploymentAuthorityRecord, RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY, @@ -32,6 +33,7 @@ import { type RuntimeHostServiceManagementFrame, type RuntimeHostServiceSummary, } from '@maka/runtime-host/operator'; +import { resolveExistingStorageRoot } from '@maka/storage/root-authority'; import { cleanupRuntimeHostManagedDeployment, effectiveRuntimeHostProjectDirectoryRoots, @@ -48,17 +50,29 @@ import { type RuntimeHostServiceBackend, } from './runtime-host-service-manager.js'; import { createLaunchAgentRuntimeHostService } from './runtime-host-launch-agent-service.js'; -import { createSystemdUserRuntimeHostService } from './runtime-host-systemd-service.js'; +import { createLaunchAgentRuntimeHostLifecycleProvider } from './runtime-host-launch-agent-service.js'; +import { + createSystemdUserRuntimeHostLifecycleProvider, + createSystemdUserRuntimeHostService, +} from './runtime-host-systemd-service.js'; +import type { RuntimeHostLifecycleProvider } from './runtime-host-lifecycle-provider.js'; +import { manageRuntimeHostManagedLifecycle } from './runtime-host-managed-lifecycle-manager.js'; +import { + removeRuntimeHostManagedDeployment, + resolveRuntimeHostManagedDeploymentForCli, +} from './runtime-host-managed-deployment.js'; export interface RuntimeHostServiceManagementCliOptions extends Omit { readonly action: RuntimeHostManagedServiceInput['action']; readonly json: boolean; readonly framed?: boolean; + readonly managedRootId?: string; } export interface RuntimeHostServiceManagementCliDeps { readonly manage: typeof manageRuntimeHostService; + readonly manageLifecycle: typeof manageRuntimeHostManagedLifecycle; readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock; readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock; readonly createBackend: (serviceId: string, clientDataRoot: string) => RuntimeHostServiceBackend; @@ -72,6 +86,7 @@ export async function runManagedRuntimeHostServiceCli( ): Promise { const deps: RuntimeHostServiceManagementCliDeps = { manage: manageRuntimeHostService, + manageLifecycle: manageRuntimeHostManagedLifecycle, withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock, withLifecycleLock: withRuntimeHostManagedServiceLifecycleLock, createBackend: createPlatformRuntimeHostServiceBackend, @@ -82,7 +97,12 @@ export async function runManagedRuntimeHostServiceCli( try { const { json: _json, framed: _framed, ...input } = options; const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); - const manage = () => deps.manage(input, deps.createBackend(serviceId, options.clientDataRoot)); + const manage = () => + options.managedRootId + ? deps.manageLifecycle(options.managedRootId, input, { + createProvider: createPlatformRuntimeHostLifecycleProvider, + }) + : deps.manage(input, deps.createBackend(serviceId, options.clientDataRoot)); const mutate = () => deps.withDeploymentLock(options.clientDataRoot, () => deps.withLifecycleLock(options.clientDataRoot, manage), @@ -141,9 +161,17 @@ export async function runManagedRuntimeHostServiceCli( export async function runManagedRuntimeHostDeploymentCleanupCli(options: { readonly clientDataRoot: string; readonly cliPath: string; + readonly managedRootId?: string; readonly expectedTarget: RuntimeHostManagedServiceTarget; }): Promise { try { + if (options.managedRootId) { + await cleanupCanonicalRuntimeHostManagedDeployment({ + ...options, + managedRootId: options.managedRootId, + }); + return 0; + } const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); await cleanupRuntimeHostManagedDeployment( options, @@ -156,6 +184,56 @@ export async function runManagedRuntimeHostDeploymentCleanupCli(options: { } } +async function cleanupCanonicalRuntimeHostManagedDeployment(options: { + readonly cliPath: string; + readonly managedRootId: string; + readonly expectedTarget: RuntimeHostManagedServiceTarget; +}): Promise { + const { managedRootId: rootId, expectedTarget } = options; + if (expectedTarget.serviceId !== rootId || expectedTarget.rootId !== rootId) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The managed Runtime Host does not match the expected deployment identity', + ); + } + const capability = await resolveExistingStorageRoot({ + path: expectedTarget.rootPath, + kind: 'interactive', + expectedRootId: rootId, + }); + if ((await readRuntimeHostManagedDeploymentAuthorityRecord(capability)) !== undefined) { + throw new RuntimeHostServiceManagerError( + 'uninstall_incomplete', + 'Runtime Host lifecycle authority still exists; refusing to remove its package', + ); + } + const provider = createPlatformRuntimeHostLifecycleProvider(rootId); + const [supervisor, trigger] = await Promise.all([ + provider.supervisor.status(), + provider.reconciliationTrigger.status(), + ]); + if ( + supervisor.installed || + supervisor.enabled || + supervisor.active || + trigger.installed || + trigger.active + ) { + throw new RuntimeHostServiceManagerError( + 'uninstall_incomplete', + 'Runtime Host lifecycle artifacts still exist; refusing to remove their package', + ); + } + const deploymentRoot = resolveRuntimeHostManagedDeploymentForCli(rootId, options.cliPath); + if (!deploymentRoot) { + throw new RuntimeHostServiceManagerError( + 'invalid_launch', + 'The Runtime Host operator does not belong to the expected managed deployment', + ); + } + await removeRuntimeHostManagedDeployment(deploymentRoot, rootId); +} + function formatHumanResult(result: RuntimeHostManagedServiceResult): string { const service = result.service; if (result.action === 'uninstall') { @@ -202,10 +280,18 @@ function successFrame(result: RuntimeHostManagedServiceResult): RuntimeHostServi ...(result.logs !== undefined ? { logs: result.logs } : {}), } as const; if (result.action === 'retire' || result.action === 'uninstall') { - return { ...common, action: result.action, retirement: { ...result.retirement } }; + return { + ...common, + action: result.action, + retirement: { ...result.retirement }, + }; } if (result.action === 'configure') { - return { ...common, action: result.action, configuration: { ...result.configuration } }; + return { + ...common, + action: result.action, + configuration: { ...result.configuration }, + }; } return { ...common, action: result.action }; } @@ -237,10 +323,16 @@ export function runtimeHostServiceSummary( pid: result.service.pid, lastExitCode: result.service.lastExitCode, installedVersion: result.service.installedVersion, + ...(result.service.lifecycle ? { lifecycle: { ...result.service.lifecycle } } : {}), + ...(result.service.reconciliation + ? { reconciliation: { ...result.service.reconciliation } } + : {}), ...(config ? { stateRoot: config.rootPath } : {}), ...(config && process.env[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV] === '1' - ? { configurationFingerprint: runtimeHostManagedServiceConfigFingerprint(config) } + ? { + configurationFingerprint: runtimeHostManagedServiceConfigFingerprint(config), + } : {}), projectDirectoryRoots: config ? [...effectiveRuntimeHostProjectDirectoryRoots(config)] : [], }; @@ -253,10 +345,14 @@ export function createPlatformRuntimeHostServiceBackend( ): RuntimeHostServiceBackend { const serviceConfigPath = resolveRuntimeHostManagedServiceConfigPath(clientDataRoot); if (platform === 'linux') { - return createSystemdUserRuntimeHostService(serviceId, { serviceConfigPath }); + return createSystemdUserRuntimeHostService(serviceId, { + serviceConfigPath, + }); } if (platform === 'darwin') { - return createLaunchAgentRuntimeHostService(serviceId, { serviceConfigPath }); + return createLaunchAgentRuntimeHostService(serviceId, { + serviceConfigPath, + }); } throw new RuntimeHostServiceManagerError( 'unsupported_platform', @@ -264,6 +360,18 @@ export function createPlatformRuntimeHostServiceBackend( ); } +export function createPlatformRuntimeHostLifecycleProvider( + rootId: string, + platform: NodeJS.Platform = process.platform, +): RuntimeHostLifecycleProvider { + if (platform === 'linux') return createSystemdUserRuntimeHostLifecycleProvider(rootId, {}); + if (platform === 'darwin') return createLaunchAgentRuntimeHostLifecycleProvider(rootId); + throw new RuntimeHostServiceManagerError( + 'unsupported_platform', + 'Supervised Runtime Host deployments currently require Linux or macOS', + ); +} + function websocketUrl(service: RuntimeHostManagedServiceResult['service']): string { const websocket = service.config?.websocket; return websocket diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 5a3100ff2d..3bc337f5e8 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -152,6 +152,15 @@ export interface RuntimeHostServiceDeployment { export interface RuntimeHostManagedServiceStatus extends RuntimeHostServiceBackendStatus { readonly config: RuntimeHostManagedServiceConfig | null; readonly installedVersion: string | null; + readonly lifecycle?: { + readonly mode: 'on_demand' | 'supervised'; + readonly availability: 'activation' | 'session' | 'environment' | 'machine'; + readonly provider?: 'systemd_user' | 'launch_agent' | 'openrc_user' | 'openrc_system'; + }; + readonly reconciliation?: { + readonly trigger: 'manual' | 'activation' | 'scheduled'; + readonly provider?: 'systemd_timer' | 'launch_agent_timer' | 'openrc_supervised_loop'; + }; } export type RuntimeHostManagedServiceAction = @@ -1622,6 +1631,27 @@ async function readServiceStatus( ...backendStatus, config, installedVersion: config ? await readInstalledVersion(config.launch.cliPath) : null, + ...(config + ? { + lifecycle: { + mode: 'supervised' as const, + availability: + backendStatus.manager === 'systemd_user' + ? ('machine' as const) + : ('session' as const), + provider: backendStatus.manager, + }, + reconciliation: config.managedDeploymentRoot + ? { + trigger: 'scheduled' as const, + provider: + backendStatus.manager === 'systemd_user' + ? ('systemd_timer' as const) + : ('launch_agent_timer' as const), + } + : { trigger: 'manual' as const }, + } + : {}), }; } @@ -1830,6 +1860,10 @@ async function normalizeStateRoot(requestedRoot: string): Promise { } } +export async function resolveRuntimeHostManagedStateRoot(requestedRoot: string): Promise { + return normalizeStateRoot(requestedRoot); +} + async function normalizeProjectDirectoryRoots( roots: readonly { readonly label: string; readonly path: string }[], ): Promise { @@ -1863,6 +1897,12 @@ async function normalizeProjectDirectoryRoots( return canonicalRoots; } +export async function resolveRuntimeHostManagedProjectDirectoryRoots( + roots: readonly { readonly label: string; readonly path: string }[], +): Promise { + return normalizeProjectDirectoryRoots(roots); +} + async function assertPersistentCliInstallation( cliPath: string, environment: NodeJS.ProcessEnv, @@ -2384,6 +2424,10 @@ async function allocateLoopbackPort(): Promise { }); } +export function allocateRuntimeHostLoopbackPort(): Promise { + return allocateLoopbackPort(); +} + async function allocatePeerPort(): Promise { return new Promise((resolvePort, reject) => { const socket = createSocket('udp4'); @@ -2397,6 +2441,10 @@ async function allocatePeerPort(): Promise { }); } +export function allocateRuntimeHostPeerPort(): Promise { + return allocatePeerPort(); +} + function result( action: Exclude, service: RuntimeHostManagedServiceStatus, diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts index 6374f84e4f..6c6fe94872 100644 --- a/packages/cli/src/runtime-host-setup-command.ts +++ b/packages/cli/src/runtime-host-setup-command.ts @@ -25,10 +25,11 @@ import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { activateRuntimeHostManagedDeployment, connectRemoteRuntimeHost, + ensureRuntimeHostPeerIdentity, } from '@maka/runtime-host/client'; import { commitRuntimeHostManagedDeployment, - readRuntimeHostManagedDeploymentConfig, + readRuntimeHostManagedDeploymentAuthorityRecord, RuntimeHostManagedDeploymentError as RuntimeHostDeploymentAuthorityError, encodeRuntimeHostSetupFrame, RUNTIME_HOST_SETUP_ERROR_CODE_MAX_BYTES, @@ -48,7 +49,6 @@ import { type RuntimeHostAccessPreset, } from './runtime-host-access-command.js'; import { - isRuntimeHostManagedDeploymentCli, isRuntimeHostDevelopmentPackageVersion, openRuntimeHostManagedPackageDeployment, prepareRuntimeHostManagedPackageDeployment, @@ -66,20 +66,45 @@ import { RuntimeHostUpdateDiscoveryError, } from './runtime-host-update-discovery.js'; import { resolveStorageRoot, tryAcquireStateRootOwner } from '@maka/storage/root-authority'; -import { createPlatformRuntimeHostServiceBackend } from './runtime-host-service-management-command.js'; import { + createPlatformRuntimeHostLifecycleProvider, + createPlatformRuntimeHostServiceBackend, +} from './runtime-host-service-management-command.js'; +import { + allocateRuntimeHostLoopbackPort, + allocateRuntimeHostPeerPort, + effectiveRuntimeHostProjectDirectoryRoots, manageRuntimeHostService, readRuntimeHostManagedServiceConfig, + removeRuntimeHostServiceFile, resolveRuntimeHostManagedServiceConfigPath, resolveRuntimeHostManagedServiceId, + resolveRuntimeHostManagedProjectDirectoryRoots, RuntimeHostServiceManagerError, withRuntimeHostManagedServiceDeploymentLock, withRuntimeHostManagedServiceLifecycleLock, type RuntimeHostManagedServiceResult, + type RuntimeHostManagedServiceConfig, type RuntimeHostManagedServiceTarget, type RuntimeHostServiceBackend, } from './runtime-host-service-manager.js'; import { expandWildcardListenAddresses } from './runtime-host-peer-management-command.js'; +import { + applyRuntimeHostLifecycleTransition, + activateRuntimeHostLifecycle, + recoverRuntimeHostLifecycleTransition, + replaceRuntimeHostLifecycle, + retireRuntimeHostLifecycleOwner, + runtimeHostReconciliationTriggerDefinition, + runtimeHostSupervisorDefinition, + verifyRuntimeHostLifecycleReady, + type RuntimeHostLifecycleTransactionDeps, +} from './runtime-host-lifecycle-transaction.js'; +import type { RuntimeHostLifecycleProvider } from './runtime-host-lifecycle-provider.js'; +import { + resolveRuntimeHostManagedPeerKeyPath, + resolveRuntimeHostPeerNativePath, +} from './runtime-host-peer-artifact.js'; const SETUP_LOCK_TIMEOUT_MS = 5 * 60_000; @@ -95,7 +120,10 @@ export interface RuntimeHostSetupCliOptions { readonly deferPairingCommit?: boolean; readonly bindPairingToClient?: boolean; readonly rootPath?: string; - readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; + readonly projectDirectoryRoots?: readonly { + readonly label: string; + readonly path: string; + }[]; readonly websocketPort?: number; readonly websocketPath?: string; readonly directPeer?: { @@ -107,6 +135,13 @@ export interface RuntimeHostSetupCliOptions { interface RuntimeHostSetupDeps { readonly manageService: typeof manageRuntimeHostService; readonly createBackend: (serviceId: string, clientDataRoot: string) => RuntimeHostServiceBackend; + readonly createLifecycleProvider: (rootId: string) => RuntimeHostLifecycleProvider; + readonly applyLifecycleTransition: typeof applyRuntimeHostLifecycleTransition; + readonly activateLifecycle: typeof activateRuntimeHostLifecycle; + readonly recoverLifecycleTransition: typeof recoverRuntimeHostLifecycleTransition; + readonly retireLifecycleOwner: typeof retireRuntimeHostLifecycleOwner; + readonly replaceLifecycle: typeof replaceRuntimeHostLifecycle; + readonly verifyLifecycleReady: typeof verifyRuntimeHostLifecycleReady; readonly openDeployment: typeof openRuntimeHostManagedPackageDeployment; readonly prepareDeployment: typeof prepareRuntimeHostManagedPackageDeployment; readonly prepareCredential: typeof prepareRuntimeHostAccessCredential; @@ -116,6 +151,10 @@ interface RuntimeHostSetupDeps { readonly activateManaged: typeof activateRuntimeHostManagedDeployment; readonly resolveRegistryCandidate: typeof resolveRuntimeHostRegistryUpdateCandidate; readonly withRegistryPackage: typeof withRuntimeHostRegistryUpdatePackage; + readonly ensurePeerIdentity: typeof ensureRuntimeHostPeerIdentity; + readonly resolvePeerNativePath: typeof resolveRuntimeHostPeerNativePath; + readonly allocateLoopbackPort: typeof allocateRuntimeHostLoopbackPort; + readonly allocatePeerPort: typeof allocateRuntimeHostPeerPort; readonly writeOutput: (value: string) => unknown; readonly writeError: (value: string) => unknown; } @@ -138,6 +177,13 @@ export async function runRuntimeHostSetupCli( const deps: RuntimeHostSetupDeps = { manageService: manageRuntimeHostService, createBackend: createPlatformRuntimeHostServiceBackend, + createLifecycleProvider: createPlatformRuntimeHostLifecycleProvider, + applyLifecycleTransition: applyRuntimeHostLifecycleTransition, + activateLifecycle: activateRuntimeHostLifecycle, + recoverLifecycleTransition: recoverRuntimeHostLifecycleTransition, + retireLifecycleOwner: retireRuntimeHostLifecycleOwner, + replaceLifecycle: replaceRuntimeHostLifecycle, + verifyLifecycleReady: verifyRuntimeHostLifecycleReady, openDeployment: openRuntimeHostManagedPackageDeployment, prepareDeployment: prepareRuntimeHostManagedPackageDeployment, prepareCredential: prepareRuntimeHostAccessCredential, @@ -147,6 +193,10 @@ export async function runRuntimeHostSetupCli( activateManaged: activateRuntimeHostManagedDeployment, resolveRegistryCandidate: resolveRuntimeHostRegistryUpdateCandidate, withRegistryPackage: withRuntimeHostRegistryUpdatePackage, + ensurePeerIdentity: ensureRuntimeHostPeerIdentity, + resolvePeerNativePath: resolveRuntimeHostPeerNativePath, + allocateLoopbackPort: allocateRuntimeHostLoopbackPort, + allocatePeerPort: allocateRuntimeHostPeerPort, writeOutput: (value) => process.stdout.write(value), writeError: (value) => process.stderr.write(value), ...overrides, @@ -180,154 +230,608 @@ async function runRuntimeHostSetupLocked( await runRuntimeHostOnDemandSetupLocked(options, deps, emit); return; } + const target = await runRuntimeHostSupervisedSetupLocked(options, deps, emit); + await pairAndVerifyRuntimeHostSetup(options, target, deps, emit); +} + +async function runRuntimeHostSupervisedSetupLocked( + options: RuntimeHostSetupCliOptions, + deps: RuntimeHostSetupDeps, + emit: SetupEmitter, +): Promise<{ + readonly serviceId: string; + readonly operatorPath: string; + readonly rootPath: string; + readonly endpoint: string; + readonly directPeer?: { + readonly peerId: string; + readonly routeHints: readonly string[]; + readonly coordinationRelays: readonly string[]; + }; +}> { emit({ kind: 'progress', phase: 'checking_environment' }); - const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); - const backend = deps.createBackend(serviceId, options.clientDataRoot); - const common = { + const legacyServiceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); + const legacyBackend = deps.createBackend(legacyServiceId, options.clientDataRoot); + const legacyCommon = { clientDataRoot: options.clientDataRoot, defaultRootPath: options.defaultRootPath, nodePath: process.execPath, cliPath: join(options.sourcePackageRoot, 'dist', 'cli.js'), ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), } as const; - const status = await deps.manageService({ ...common, action: 'status' }, backend); - await assertCompatibleExistingVersion(status, options.version); - const currentPackage = currentManagedPackage(status, serviceId, options.version); - - emit({ kind: 'progress', phase: 'installing_package' }); - const deployment = currentPackage - ? await deps.openDeployment({ - serviceId, - clientDataRoot: options.clientDataRoot, - deploymentRoot: currentPackage.deploymentRoot, - cliPath: currentPackage.cliPath, - version: options.version, - }) - : await deps.prepareDeployment({ - serviceId, - clientDataRoot: options.clientDataRoot, - sourcePackageRoot: options.sourcePackageRoot, - version: options.version, - }); - - emit({ kind: 'progress', phase: 'installing_service' }); - let installed: RuntimeHostManagedServiceResult; - try { - installed = await deps.manageService( - { - ...common, - action: 'install', - cliPath: deployment.cliPath, - ...(options.rootPath ? { rootPath: options.rootPath } : {}), - ...(options.projectDirectoryRoots - ? { projectDirectoryRoots: options.projectDirectoryRoots } - : {}), - ...(options.websocketPort === undefined ? {} : { websocketPort: options.websocketPort }), - ...(options.websocketPath ? { websocketPath: options.websocketPath } : {}), - ...(options.directPeer - ? { peer: { coordinationRelays: options.directPeer.coordinationRelays } } + const legacyStatus = await deps.manageService( + { ...legacyCommon, action: 'status' }, + legacyBackend, + ); + const legacyConfig = legacyStatus.service.config; + const capability = await resolveStorageRoot({ + path: resolve( + options.rootPath ?? + legacyConfig?.rootPath ?? + options.expectedTarget?.rootPath ?? + options.defaultRootPath, + ), + kind: 'interactive', + }); + const lifecycleProvider = deps.createLifecycleProvider(capability.rootId); + const lifecycleDeps: RuntimeHostLifecycleTransactionDeps = { + resolveProvider: (provider) => { + if (provider !== lifecycleProvider.supervisor.provider) { + throw new RuntimeHostSetupError( + 'unsupported_lifecycle_configuration', + `The persisted Runtime Host provider ${provider} is unavailable on this computer`, + ); + } + return lifecycleProvider; + }, + ...(legacyConfig ? legacyMigrationDeps(legacyConfig, legacyBackend) : {}), + }; + let authority = await readRuntimeHostManagedDeploymentAuthorityRecord(capability); + if (authority?.schemaVersion === 2) { + const retirement = await deps.retireLifecycleOwner({ + rootPath: capability.canonicalPath, + rootId: capability.rootId, + ...(authority.from?.lifecycle.mode === 'supervised' + ? { + supervisor: lifecycleDeps.resolveProvider(authority.from.lifecycle.provider).supervisor, + } + : legacyConfig + ? { supervisor: legacyBackend } : {}), - }, - backend, - ); - } catch (error) { - try { - await deployment.rollback(); - } catch (rollbackError) { + }); + if (retirement.kind === 'active_tasks') { throw new RuntimeHostSetupError( - 'deployment_failed', - 'Runtime Host setup failed and its staged package could not be removed', - { cause: new AggregateError([error, rollbackError]) }, + 'active_tasks', + 'Runtime Host lifecycle recovery is waiting for active work to finish', ); } - throw error; + const recovered = await deps.recoverLifecycleTransition( + retirement.owner, + authority, + lifecycleDeps, + ); + await retirement.owner.close(); + if (recovered) { + await deps.activateLifecycle(recovered, lifecycleDeps); + await deps.verifyLifecycleReady(recovered, lifecycleDeps); + } else if (legacyConfig) { + await deps.manageService({ ...legacyCommon, action: 'start' }, legacyBackend); + } + authority = await readRuntimeHostManagedDeploymentAuthorityRecord(capability); } - const config = installed.service.config; - if (!config || !installed.service.active) { + const current = authority?.schemaVersion === 1 ? authority : undefined; + const legacyToMigrate = current ? null : legacyConfig; + if (current && legacyConfig) await assertLegacyArtifactsAbsent(legacyBackend); + if (legacyToMigrate) await assertCompatibleExistingVersion(legacyStatus, options.version); + if (current && current.launch.package.version !== options.version) { throw new RuntimeHostSetupError( - 'service_not_ready', - 'Managed Runtime Host service did not become ready', + 'version_change_requires_update', + `Runtime Host ${current.launch.package.version} is already installed; changing to ${options.version} requires the update workflow`, ); } - await deployment.activate(); - await deployment.cleanup(); - await pairAndVerifyRuntimeHostSetup( - options, - { - serviceId, + const candidate = await deps.resolveRegistryCandidate({ + kind: 'exact', + version: options.version, + }); + if (current && !sameExactPackage(current, candidate)) { + throw new RuntimeHostSetupError( + 'version_change_requires_update', + `Runtime Host ${current.launch.package.version} is already installed; changing its exact package requires the update workflow`, + ); + } + return deps.withRegistryPackage(candidate, async (packageRoot) => { + emit({ kind: 'progress', phase: 'installing_package' }); + const deployment = await deps.prepareDeployment({ + serviceId: capability.rootId, + clientDataRoot: options.clientDataRoot, + sourcePackageRoot: packageRoot, + version: candidate.version, + packageIntegrity: candidate.integrity, + }); + const desired = await prepareSupervisedDeploymentConfig( + options, + deps, + capability, + deployment.cliPath, + deployment.root, + candidate, + current, + legacyToMigrate, + lifecycleProvider, + ); + if (current && !sameDesiredManagedDeployment(current, desired)) { + if (current.lifecycle.mode === 'supervised') { + throw new RuntimeHostSetupError( + 'configuration_changed', + 'Change an existing supervised Runtime Host through its explicit configure or update workflow', + ); + } + } + const ownershipChanged = !current || !isDeepStrictEqual(current, desired); + await deployment.activate(); + emit({ kind: 'progress', phase: 'installing_service' }); + let retirement: Awaited>; + if (legacyToMigrate) { + await legacyBackend.verifyDeployment(legacyToMigrate, { + acceptLegacyConfigLaunch: true, + }); + retirement = await deps.retireLifecycleOwner({ + rootPath: capability.canonicalPath, + rootId: capability.rootId, + supervisor: legacyBackend, + }); + } else if (current) { + retirement = await deps.retireLifecycleOwner({ + rootPath: capability.canonicalPath, + rootId: capability.rootId, + ...(current.lifecycle.mode === 'supervised' + ? { + supervisor: lifecycleDeps.resolveProvider(current.lifecycle.provider).supervisor, + } + : {}), + }); + } else { + const owner = await tryAcquireStateRootOwner(capability); + if (!owner) { + throw new RuntimeHostSetupError( + 'state_root_owned', + 'The State Root must be idle before supervised setup', + ); + } + retirement = { kind: 'retired', owner }; + } + if (retirement.kind === 'active_tasks') { + throw new RuntimeHostSetupError( + 'active_tasks', + 'Runtime Host setup is waiting for active work to finish', + ); + } + const owner = retirement.owner; + try { + if (ownershipChanged) { + await deps.applyLifecycleTransition( + owner, + { + operation: legacyToMigrate + ? 'legacy_migration' + : current + ? 'lifecycle_change' + : 'install', + ...(current ? { current } : {}), + desired, + }, + lifecycleDeps, + ); + } else { + await lifecycleProvider.supervisor.preflight(); + await lifecycleProvider.supervisor.converge(runtimeHostSupervisorDefinition(desired)); + await lifecycleProvider.reconciliationTrigger.converge( + runtimeHostReconciliationTriggerDefinition(desired), + ); + } + } finally { + await owner.close(); + } + try { + await deps.activateLifecycle(desired, lifecycleDeps); + await deps.verifyLifecycleReady(desired, lifecycleDeps); + } catch (error) { + if (ownershipChanged) { + await rollbackActivatedManagedSetup( + current, + desired, + legacyToMigrate, + legacyBackend, + lifecycleDeps, + deps, + ).catch((rollbackError) => { + throw new RuntimeHostSetupError( + 'deployment_failed', + 'Runtime Host activation failed and the previous lifecycle owner could not be restored', + { cause: new AggregateError([error, rollbackError]) }, + ); + }); + } + throw error; + } + if (legacyConfig) { + await removeRuntimeHostServiceFile( + resolveRuntimeHostManagedServiceConfigPath(options.clientDataRoot), + 'legacy service config', + ); + if ( + legacyConfig.managedDeploymentRoot && + resolve(legacyConfig.managedDeploymentRoot) !== resolve(deployment.root) + ) { + await removeRuntimeHostManagedDeployment( + legacyConfig.managedDeploymentRoot, + legacyServiceId, + ); + } + } + await deployment.cleanup(); + const websocket = desired.listeners.websocket; + if (!websocket) { + throw new RuntimeHostSetupError( + 'service_not_ready', + 'Supervised Runtime Host setup requires a WebSocket listener', + ); + } + const directPeer = desired.listeners.directPeer?.enabled + ? desired.listeners.directPeer + : undefined; + return { + serviceId: capability.rootId, operatorPath: deployment.operatorPath, - rootPath: config.rootPath, - endpoint: websocketUrl(config.websocket), - ...(config.peer?.enabled + rootPath: capability.canonicalPath, + endpoint: websocketUrl(websocket), + ...(directPeer ? { directPeer: { - peerId: config.peer.peerId, - routeHints: expandWildcardListenAddresses(config.peer.listenAddresses), - coordinationRelays: [...config.peer.coordinationRelays], + peerId: directPeer.peerId, + routeHints: expandWildcardListenAddresses(directPeer.listenAddresses), + coordinationRelays: [...directPeer.coordinationRelays], }, } : {}), - }, + }; + }); +} + +async function prepareSupervisedDeploymentConfig( + options: RuntimeHostSetupCliOptions, + deps: RuntimeHostSetupDeps, + capability: Awaited>, + cliPath: string, + deploymentRoot: string, + candidate: { readonly version: string; readonly integrity: string }, + current: RuntimeHostManagedDeploymentConfig | undefined, + legacy: RuntimeHostManagedServiceConfig | null, + provider: RuntimeHostLifecycleProvider, +): Promise { + const projectDirectoryRoots = await resolveRuntimeHostManagedProjectDirectoryRoots( + options.projectDirectoryRoots ?? + current?.projectDirectoryRoots ?? + (legacy + ? effectiveRuntimeHostProjectDirectoryRoots(legacy) + : [{ label: '~', path: resolve(homedir()) }]), + ); + const currentWebSocket = current?.listeners.websocket; + const websocketPort = + options.websocketPort ?? + (currentWebSocket && currentWebSocket.port > 0 + ? currentWebSocket.port + : legacy?.websocket.port) ?? + (await deps.allocateLoopbackPort()); + const directPeer = await prepareSupervisedDirectPeer( + options, deps, - emit, + cliPath, + current?.listeners.directPeer, + legacy, ); + const draft: RuntimeHostManagedDeploymentConfig = { + schemaVersion: 1, + deploymentId: current?.deploymentId ?? randomUUID(), + configRevision: current ? current.configRevision + 1 : 1, + deploymentRoot, + root: { path: capability.canonicalPath, id: capability.rootId }, + projectDirectoryRoots: [...projectDirectoryRoots], + launch: { + kind: 'exact_package', + nodePath: current?.launch.nodePath ?? process.execPath, + package: { + kind: 'npm_registry', + version: candidate.version, + integrity: candidate.integrity, + }, + }, + listeners: { + localIpc: true, + websocket: { + host: '127.0.0.1', + port: websocketPort, + path: + options.websocketPath ?? + currentWebSocket?.path ?? + legacy?.websocket.path ?? + '/runtime-host', + }, + ...(directPeer ? { directPeer } : {}), + }, + lifecycle: { + mode: 'supervised', + provider: provider.supervisor.provider, + availability: provider.supervisor.provider === 'systemd_user' ? 'machine' : 'session', + }, + reconciliation: { + trigger: 'scheduled', + provider: provider.reconciliationTrigger.provider, + }, + }; + return current && sameDesiredManagedDeployment(current, draft) + ? { ...draft, configRevision: current.configRevision } + : draft; } -async function runRuntimeHostOnDemandSetupLocked( +async function prepareSupervisedDirectPeer( options: RuntimeHostSetupCliOptions, deps: RuntimeHostSetupDeps, - emit: SetupEmitter, -): Promise { - if (options.expectedTarget) { + cliPath: string, + current: RuntimeHostManagedDeploymentConfig['listeners']['directPeer'], + legacy: RuntimeHostManagedServiceConfig | null, +): Promise { + const legacyPeer = legacy?.peer?.enabled ? legacy.peer : undefined; + if (!options.directPeer && !current && !legacyPeer) return undefined; + const keyPath = current?.keyPath ?? resolveRuntimeHostManagedPeerKeyPath(options.clientDataRoot); + const peerId = await deps.ensurePeerIdentity({ + nativePath: await deps.resolvePeerNativePath(cliPath), + keyPath, + }); + const expectedPeerId = current?.peerId ?? legacyPeer?.peerId; + if (expectedPeerId && expectedPeerId !== peerId) { throw new RuntimeHostSetupError( - 'lifecycle_owner_exists', - 'On-demand setup cannot replace an existing managed service', + 'invalid_config', + 'The Runtime Host peer identity does not match its persisted deployment', ); } + return { + enabled: options.directPeer ? true : (current?.enabled ?? true), + keyPath, + peerId, + listenAddresses: [ + ...(current?.listenAddresses ?? + legacyPeer?.listenAddresses ?? [ + `/ip4/0.0.0.0/udp/${String(await deps.allocatePeerPort())}/quic-v1`, + ]), + ], + coordinationRelays: [ + ...(options.directPeer?.coordinationRelays ?? + current?.coordinationRelays ?? + legacyPeer?.coordinationRelays ?? + []), + ], + }; +} + +function sameDesiredManagedDeployment( + current: RuntimeHostManagedDeploymentConfig, + desired: RuntimeHostManagedDeploymentConfig, +): boolean { + const { configRevision: _currentRevision, ...currentState } = current; + const { configRevision: _desiredRevision, ...desiredState } = desired; + return isDeepStrictEqual(currentState, desiredState); +} + +async function rollbackActivatedManagedSetup( + previous: RuntimeHostManagedDeploymentConfig | undefined, + desired: RuntimeHostManagedDeploymentConfig, + legacy: RuntimeHostManagedServiceConfig | null, + legacyBackend: RuntimeHostServiceBackend, + lifecycleDeps: RuntimeHostLifecycleTransactionDeps, + deps: RuntimeHostSetupDeps, +): Promise { + const provider = + desired.lifecycle.mode === 'supervised' + ? lifecycleDeps.resolveProvider(desired.lifecycle.provider) + : undefined; + const retirement = await deps.retireLifecycleOwner({ + rootPath: desired.root.path, + rootId: desired.root.id, + ...(provider ? { supervisor: provider.supervisor } : {}), + allowInterruptActiveTasks: true, + }); + if (retirement.kind === 'active_tasks') { + throw new Error('Runtime Host activation rollback could not retire active work'); + } + let restored: RuntimeHostManagedDeploymentConfig | undefined; + try { + if (previous) { + restored = { ...previous, configRevision: desired.configRevision + 1 }; + await deps.applyLifecycleTransition( + retirement.owner, + { + operation: + previous.lifecycle.mode === desired.lifecycle.mode && + previous.lifecycle.mode === 'supervised' && + desired.lifecycle.mode === 'supervised' && + previous.lifecycle.provider !== desired.lifecycle.provider + ? 'provider_change' + : 'lifecycle_change', + current: desired, + desired: restored, + }, + lifecycleDeps, + ); + } else { + await deps.applyLifecycleTransition( + retirement.owner, + { operation: 'uninstall', current: desired }, + lifecycleDeps, + ); + if (legacy) { + const restoration = await legacyBackend.stageDeployment(); + await restoration.apply(legacy, false); + } + } + } finally { + await retirement.owner.close(); + } + if (restored) { + await deps.activateLifecycle(restored, lifecycleDeps); + await deps.verifyLifecycleReady(restored, lifecycleDeps); + } else if (legacy) { + await legacyBackend.start(); + } +} + +async function runRuntimeHostOnDemandSetupLocked( + options: RuntimeHostSetupCliOptions, + deps: RuntimeHostSetupDeps, + emit: SetupEmitter, +): Promise { if (options.directPeer) { throw new RuntimeHostSetupError( 'unsupported_lifecycle_configuration', 'On-demand setup does not support a Direct peer listener', ); } + emit({ kind: 'progress', phase: 'checking_environment' }); + const legacyServiceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); + const legacyConfigPath = resolveRuntimeHostManagedServiceConfigPath(options.clientDataRoot); + let legacyConfig: RuntimeHostManagedServiceConfig | null = null; try { - await readRuntimeHostManagedServiceConfig( - resolveRuntimeHostManagedServiceConfigPath(options.clientDataRoot), - ); - throw new RuntimeHostSetupError( - 'lifecycle_owner_exists', - 'Remove or migrate the existing managed Runtime Host service before on-demand setup', - ); + legacyConfig = await readRuntimeHostManagedServiceConfig(legacyConfigPath); } catch (error) { if (!(error instanceof RuntimeHostServiceManagerError) || error.code !== 'not_installed') { throw error; } } - emit({ kind: 'progress', phase: 'checking_environment' }); + const legacyBackend = legacyConfig + ? deps.createBackend(legacyServiceId, options.clientDataRoot) + : undefined; + let legacyStatus: RuntimeHostManagedServiceResult | undefined; + if (legacyBackend) { + legacyStatus = await deps.manageService( + { + action: 'status', + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + nodePath: process.execPath, + cliPath: join(options.sourcePackageRoot, 'dist', 'cli.js'), + ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), + }, + legacyBackend, + ); + } const capability = await resolveStorageRoot({ - path: resolve(options.rootPath ?? options.defaultRootPath), + path: resolve( + options.rootPath ?? + legacyConfig?.rootPath ?? + options.expectedTarget?.rootPath ?? + options.defaultRootPath, + ), kind: 'interactive', }); + if ( + !legacyConfig && + options.expectedTarget && + (options.expectedTarget.serviceId !== capability.rootId || + options.expectedTarget.rootId !== capability.rootId || + options.expectedTarget.rootPath !== capability.canonicalPath) + ) { + throw new RuntimeHostSetupError( + 'target_mismatch', + 'The managed Runtime Host does not match the expected deployment identity', + ); + } + let authority = await readRuntimeHostManagedDeploymentAuthorityRecord(capability); + if (authority?.schemaVersion === 2) { + const previous = authority.from; + const provider = + previous?.lifecycle.mode === 'supervised' + ? deps.createLifecycleProvider(capability.rootId) + : undefined; + const lifecycleDeps: RuntimeHostLifecycleTransactionDeps = { + resolveProvider: (requested) => { + if (!provider || requested !== provider.supervisor.provider) { + throw new RuntimeHostSetupError( + 'unsupported_lifecycle_configuration', + `The persisted Runtime Host provider ${requested} is unavailable on this computer`, + ); + } + return provider; + }, + }; + const retirement = await deps.retireLifecycleOwner({ + rootPath: capability.canonicalPath, + rootId: capability.rootId, + ...(provider ? { supervisor: provider.supervisor } : {}), + }); + if (retirement.kind === 'active_tasks') { + throw new RuntimeHostSetupError( + 'active_tasks', + 'Runtime Host lifecycle recovery is waiting for active work to finish', + ); + } + let recovered: RuntimeHostManagedDeploymentConfig | undefined; + try { + recovered = await deps.recoverLifecycleTransition(retirement.owner, authority, lifecycleDeps); + } finally { + await retirement.owner.close(); + } + if (recovered) { + await deps.activateLifecycle(recovered, lifecycleDeps); + await deps.verifyLifecycleReady(recovered, lifecycleDeps); + } + authority = await readRuntimeHostManagedDeploymentAuthorityRecord(capability); + } + const current = authority?.schemaVersion === 1 ? authority : undefined; + const legacyToMigrate = current ? null : legacyConfig; + if (current && legacyBackend) await assertLegacyArtifactsAbsent(legacyBackend); + if (legacyToMigrate && legacyStatus) { + await assertCompatibleExistingVersion(legacyStatus, options.version); + } + if ( + current && + options.expectedTarget && + (options.expectedTarget.serviceId !== capability.rootId || + options.expectedTarget.rootId !== capability.rootId || + options.expectedTarget.rootPath !== capability.canonicalPath) + ) { + throw new RuntimeHostSetupError( + 'target_mismatch', + 'The managed Runtime Host does not match the expected deployment identity', + ); + } const candidate = await deps.resolveRegistryCandidate({ kind: 'exact', version: options.version, }); const serviceId = capability.rootId; - const deploymentRoot = resolveRuntimeHostManagedDeploymentRoot(serviceId); + const deploymentRoot = + current?.deploymentRoot ?? resolveRuntimeHostManagedDeploymentRoot(serviceId); + if (current && !sameExactPackage(current, candidate)) { + throw new RuntimeHostSetupError( + 'version_change_requires_update', + `Runtime Host ${current.launch.package.version} is already installed; changing its exact package requires the update workflow`, + ); + } let config: RuntimeHostManagedDeploymentConfig = { schemaVersion: 1, - deploymentId: randomUUID(), - configRevision: 1, + deploymentId: current?.deploymentId ?? randomUUID(), + configRevision: current ? current.configRevision + 1 : 1, deploymentRoot, root: { path: capability.canonicalPath, id: capability.rootId }, projectDirectoryRoots: options.projectDirectoryRoots?.map(({ label, path }) => ({ label, path: resolve(path), - })) ?? [{ label: '~', path: resolve(homedir()) }], + })) ?? + current?.projectDirectoryRoots ?? [{ label: '~', path: resolve(homedir()) }], launch: { kind: 'exact_package', - nodePath: process.execPath, + nodePath: current?.launch.nodePath ?? process.execPath, package: { kind: 'npm_registry', version: candidate.version, @@ -339,35 +843,24 @@ async function runRuntimeHostOnDemandSetupLocked( websocket: { host: '127.0.0.1', port: options.websocketPort ?? 0, - path: options.websocketPath ?? '/runtime-host', + path: options.websocketPath ?? current?.listeners.websocket?.path ?? '/runtime-host', }, + ...(current?.listeners.directPeer + ? { directPeer: { ...current.listeners.directPeer, enabled: false } } + : {}), }, lifecycle: { mode: 'on_demand', availability: 'activation' }, - reconciliation: { trigger: 'manual' }, + reconciliation: { trigger: 'activation' }, }; + if (current && sameDesiredOnDemandDeployment(current, config)) config = current; let operatorPath: string | undefined; + let migratedLegacy = false; await deps.withRegistryPackage(candidate, async (packageRoot) => { - const owner = await tryAcquireStateRootOwner(capability); - if (!owner) { - throw new RuntimeHostSetupError( - 'state_root_owned', - 'The State Root must be idle before on-demand setup', - ); - } let committed = false; - let created = false; + const created = !current; try { emit({ kind: 'progress', phase: 'installing_package' }); - const existing = await readRuntimeHostManagedDeploymentConfig(capability); - if (existing && !sameDesiredOnDemandDeployment(existing, config)) { - throw new RuntimeHostSetupError( - 'lifecycle_owner_exists', - 'The State Root already has a different managed deployment', - ); - } - if (existing) config = existing; - created = !existing; - const deployment = existing + const deployment = current ? await deps.openDeployment({ serviceId, clientDataRoot: options.clientDataRoot, @@ -389,7 +882,78 @@ async function runRuntimeHostOnDemandSetupLocked( operatorPath = deployment.operatorPath; emit({ kind: 'progress', phase: 'installing_service' }); await deployment.activate(); - await commitRuntimeHostManagedDeployment(owner, config); + if (legacyToMigrate && legacyBackend) { + await legacyBackend.verifyDeployment(legacyToMigrate, { + acceptLegacyConfigLaunch: true, + }); + const retirement = await deps.retireLifecycleOwner({ + rootPath: capability.canonicalPath, + rootId: capability.rootId, + supervisor: legacyBackend, + }); + if (retirement.kind === 'active_tasks') { + throw new RuntimeHostSetupError( + 'active_tasks', + 'Runtime Host setup is waiting for active work to finish', + ); + } + try { + await deps.applyLifecycleTransition( + retirement.owner, + { operation: 'legacy_migration', desired: config }, + { + resolveProvider: () => { + throw new Error('On-demand deployment has no supervisor provider'); + }, + ...legacyMigrationDeps(legacyToMigrate, legacyBackend), + }, + ); + } finally { + await retirement.owner.close(); + } + migratedLegacy = true; + } else if (!current) { + const owner = await tryAcquireStateRootOwner(capability); + if (!owner) { + throw new RuntimeHostSetupError( + 'state_root_owned', + 'The State Root must be idle before on-demand setup', + ); + } + try { + await commitRuntimeHostManagedDeployment(owner, config); + } finally { + await owner.close(); + } + } else if (!isDeepStrictEqual(current, config)) { + const provider = + current.lifecycle.mode === 'supervised' + ? deps.createLifecycleProvider(serviceId) + : undefined; + const replacement = await deps.replaceLifecycle({ + operation: + current.lifecycle.mode === config.lifecycle.mode ? 'configure' : 'lifecycle_change', + current, + desired: config, + deps: { + resolveProvider: (requested) => { + if (!provider || requested !== provider.supervisor.provider) { + throw new RuntimeHostSetupError( + 'unsupported_lifecycle_configuration', + `The persisted Runtime Host provider ${requested} is unavailable on this computer`, + ); + } + return provider; + }, + }, + }); + if (replacement.kind === 'active_tasks') { + throw new RuntimeHostSetupError( + 'active_tasks', + 'Runtime Host setup is waiting for active work to finish', + ); + } + } committed = true; await deployment.cleanup(); } catch (error) { @@ -404,14 +968,50 @@ async function runRuntimeHostOnDemandSetupLocked( await removeRuntimeHostManagedDeployment(deploymentRoot, serviceId).catch(() => undefined); } throw error; - } finally { - await owner.close(); } }); if (!operatorPath) throw new RuntimeHostSetupError('deployment_failed', 'Setup did not install an operator'); - const activation = await deps.activateManaged({ rootId: capability.rootId }); + let activation: Awaited>; + try { + activation = await deps.activateManaged({ rootId: capability.rootId }); + } catch (error) { + if (migratedLegacy && legacyToMigrate && legacyBackend) { + await rollbackActivatedManagedSetup( + undefined, + config, + legacyToMigrate, + legacyBackend, + { + resolveProvider: () => { + throw new Error('On-demand deployment has no supervisor provider'); + }, + ...legacyMigrationDeps(legacyToMigrate, legacyBackend), + }, + deps, + ).catch((rollbackError) => { + throw new RuntimeHostSetupError( + 'deployment_failed', + 'Runtime Host activation failed and the previous lifecycle owner could not be restored', + { cause: new AggregateError([error, rollbackError]) }, + ); + }); + await removeRuntimeHostManagedDeployment(config.deploymentRoot, serviceId).catch( + () => undefined, + ); + } + throw error; + } + if (legacyConfig) { + await removeRuntimeHostServiceFile(legacyConfigPath, 'legacy service config'); + if ( + legacyConfig.managedDeploymentRoot && + resolve(legacyConfig.managedDeploymentRoot) !== resolve(config.deploymentRoot) + ) { + await removeRuntimeHostManagedDeployment(legacyConfig.managedDeploymentRoot, legacyServiceId); + } + } await pairAndVerifyRuntimeHostSetup( options, { @@ -429,6 +1029,39 @@ async function runRuntimeHostOnDemandSetupLocked( ); } +function legacyMigrationDeps( + config: RuntimeHostManagedServiceConfig, + backend: RuntimeHostServiceBackend, +): Pick { + return { + uninstallLegacy: () => backend.uninstall(), + restoreLegacy: async () => { + const restoration = await backend.stageDeployment(); + await restoration.apply(config, false); + }, + }; +} + +async function assertLegacyArtifactsAbsent(backend: RuntimeHostServiceBackend): Promise { + const status = await backend.status(); + if (status.installed || status.enabled || status.active) { + throw new RuntimeHostSetupError( + 'lifecycle_owner_exists', + 'The canonical deployment is active but its legacy lifecycle artifact can still start', + ); + } +} + +function sameExactPackage( + config: RuntimeHostManagedDeploymentConfig, + candidate: { readonly version: string; readonly integrity: string }, +): boolean { + return ( + config.launch.package.version === candidate.version && + config.launch.package.integrity === candidate.integrity + ); +} + async function pairAndVerifyRuntimeHostSetup( options: RuntimeHostSetupCliOptions, target: { @@ -523,34 +1156,6 @@ function sameDesiredOnDemandDeployment( return isDeepStrictEqual(currentDesiredState, requestedDesiredState); } -function currentManagedPackage( - status: RuntimeHostManagedServiceResult, - serviceId: string, - version: string, -): - | { - readonly deploymentRoot: string; - readonly cliPath: string; - } - | undefined { - const config = status.service.config; - if ( - status.service.installedVersion !== version || - !config?.managedDeploymentRoot || - !isRuntimeHostManagedDeploymentCli( - config.managedDeploymentRoot, - serviceId, - config.launch.cliPath, - ) - ) { - return undefined; - } - return { - deploymentRoot: config.managedDeploymentRoot, - cliPath: config.launch.cliPath, - }; -} - async function assertCompatibleExistingVersion( status: RuntimeHostManagedServiceResult, version: string, @@ -593,7 +1198,10 @@ async function verifyRuntimeHostSetupCredential(input: { credential: input.credential, expectedRootId: input.rootId, compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, - protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, + protocol: { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, + }, }); if (result.kind !== 'connected') { throw new RuntimeHostSetupError( @@ -624,7 +1232,11 @@ type SetupEmitter = ( function createEmitter(json: boolean, deps: RuntimeHostSetupDeps): SetupEmitter { let sequence = 0; return (input) => { - const frame = { schemaVersion: 1, sequence: sequence++, ...input } as RuntimeHostSetupFrame; + const frame = { + schemaVersion: 1, + sequence: sequence++, + ...input, + } as RuntimeHostSetupFrame; if (json) { deps.writeOutput(encodeRuntimeHostSetupFrame(frame)); return; diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts index 7eb420713a..f7247dc3c7 100644 --- a/packages/cli/src/runtime-host-systemd-service.ts +++ b/packages/cli/src/runtime-host-systemd-service.ts @@ -44,6 +44,12 @@ import { runRuntimeHostServiceManagerCommand, type RuntimeHostServiceManagerCommandResult, } from './runtime-host-service-manager-process.js'; +import { + assertRuntimeHostProviderDefinition, + type RuntimeHostLifecycleProvider, + type RuntimeHostProviderDefinition, + type RuntimeHostSupervisorStatus, +} from './runtime-host-lifecycle-provider.js'; interface SystemdUnitContext { readonly unitName: string; @@ -238,35 +244,123 @@ export function createSystemdUserRuntimeHostService( }, uninstall: async () => { await removeSystemdUpdateScheduler(scheduler); - const before = await readSystemdStatus(context); - if (before.loadState !== 'not-found') { + await uninstallSystemdSupervisor(context); + }, + }; +} + +export function createSystemdUserRuntimeHostLifecycleProvider( + serviceId: string, + options: Omit = {}, +): RuntimeHostLifecycleProvider { + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? homedir(); + const runSystemctl = options.runSystemctl ?? defaultRunSystemctl; + const context: SystemdUnitContext = { + unitName: resolveSystemdUserRuntimeHostServiceName(serviceId), + unitPath: resolveSystemdUserRuntimeHostServicePath(serviceId, env, homeDir), + runSystemctl, + }; + const scheduler = resolveSystemdUpdateSchedulerContext(serviceId, env, homeDir, runSystemctl); + const runLoginctl = options.runLoginctl ?? defaultRunLoginctl; + const runJournalctl = options.runJournalctl ?? defaultRunJournalctl; + const uid = options.uid ?? process.getuid?.(); + const status = async (): Promise => { + const raw = await readSystemdStatus(context); + return { + provider: 'systemd_user', + installed: raw.loadState !== 'not-found', + enabled: raw.unitFileState === 'enabled' || raw.unitFileState === 'enabled-runtime', + active: raw.activeState === 'active', + state: systemdServiceState(raw.loadState, raw.activeState), + pid: positiveInteger(raw.mainPid), + lastExitCode: nonNegativeInteger(raw.execMainStatus), + }; + }; + const readJournal = async (unitName: string): Promise => { + const result = await runJournalctl([ + '--user-unit', + unitName, + '--no-pager', + '--lines=200', + '--output=short-iso', + ]).catch((error) => { + throw new RuntimeHostServiceManagerError( + 'service_manager_unavailable', + 'Unable to read Runtime Host service logs', + { cause: error }, + ); + }); + if (result.exitCode !== 0) + throw managerError('Reading Runtime Host service logs failed', result); + return result.stdout; + }; + return { + supervisor: { + provider: 'systemd_user', + preflight: async () => { + await assertUserSystemd(runSystemctl); + await assertUserLinger(uid, runLoginctl); + }, + converge: async (definition) => { + assertRuntimeHostProviderDefinition(definition); + const current = await readSystemdStatus(context); + if (isSystemdUnitRunning(current)) await runLifecycleAction(context, 'stop'); + await writeRuntimeHostServiceFile( + context.unitPath, + renderSystemdSupervisorDefinition(definition), + 0o600, + ); + await requireSystemctl(runSystemctl, ['daemon-reload'], 'Reloading systemd failed'); await requireSystemctl( runSystemctl, - ['stop', context.unitName], - 'Stopping the Runtime Host service failed', + ['enable', context.unitName], + 'Enabling the Runtime Host service failed', ); - } - if ( - before.loadState !== 'not-found' || - before.unitFileState === 'enabled' || - before.unitFileState === 'enabled-runtime' - ) { + }, + verify: (definition) => verifySystemdSupervisorDefinition(context, definition), + status, + activate: () => runLifecycleAction(context, 'start'), + retire: () => runLifecycleAction(context, 'stop'), + logs: () => readJournal(context.unitName), + uninstall: () => uninstallSystemdSupervisor(context), + }, + reconciliationTrigger: { + provider: 'systemd_timer', + converge: async (definition) => { + assertRuntimeHostProviderDefinition(definition); + await assertNoSystemdUpdateSchedulerDropIns(scheduler); + await stopSystemdUpdateScheduler(scheduler); + await Promise.all([ + writeRuntimeHostServiceFile( + scheduler.service.unitPath, + renderSystemdReconciliationService(definition), + 0o600, + ), + writeRuntimeHostServiceFile( + scheduler.timer.unitPath, + renderSystemdUpdateTimer(scheduler.serviceId), + 0o600, + ), + ]); + await requireSystemctl(runSystemctl, ['daemon-reload'], 'Reloading systemd failed'); await requireSystemctl( runSystemctl, - ['disable', context.unitName], - 'Disabling the Runtime Host service failed', + ['enable', scheduler.timer.unitName], + 'Enabling Runtime Host update reconciliation failed', ); - await runSystemctl(['reset-failed', context.unitName]); - } - await removeRuntimeHostServiceFile(context.unitPath, 'systemd unit'); - await requireSystemctl(runSystemctl, ['daemon-reload'], 'Reloading systemd failed'); - const after = await readStatus(); - if (after.installed || after.active || after.enabled) { - throw new RuntimeHostServiceManagerError( - 'uninstall_incomplete', - `Runtime Host systemd service still has managed state: ${after.state}`, - ); - } + }, + verify: (definition) => verifySystemdReconciliationDefinition(scheduler, definition), + status: async () => { + const observed = await readSystemdStatus(scheduler.timer); + return { + installed: observed.loadState !== 'not-found', + active: isSystemdUnitRunning(observed), + }; + }, + activate: () => ensureSystemdUpdateSchedulerStartedIfInstalled(scheduler), + logs: () => readJournal(scheduler.service.unitName), + uninstall: () => removeSystemdUpdateScheduler(scheduler), }, }; } @@ -335,6 +429,13 @@ export function renderSystemdUnit( ); } +export function renderSystemdSupervisorDefinition( + definition: RuntimeHostProviderDefinition, +): string { + assertRuntimeHostProviderDefinition(definition); + return renderSystemdUnitWithArguments(definition.command); +} + function systemdUnitMatchesConfig( unit: string | null, config: RuntimeHostManagedServiceConfig, @@ -375,6 +476,17 @@ function renderSystemdUnitWithArguments(args: readonly string[]): string { export function renderSystemdUpdateService(config: RuntimeHostManagedServiceConfig): string { const args = runtimeHostUpdateReconcileLaunchArguments(config); if (!args) throw new TypeError('Managed deployment root is required for update scheduling'); + return renderSystemdUpdateServiceWithArguments(args); +} + +export function renderSystemdReconciliationService( + definition: RuntimeHostProviderDefinition, +): string { + assertRuntimeHostProviderDefinition(definition); + return renderSystemdUpdateServiceWithArguments(definition.command); +} + +function renderSystemdUpdateServiceWithArguments(args: readonly string[]): string { return [ '[Unit]', 'Description=Maka Runtime Host update reconciliation', @@ -750,6 +862,86 @@ function isLoadedManagedSystemdUnit(status: SystemdStatus, path: string): boolea ); } +async function verifySystemdSupervisorDefinition( + context: SystemdUnitContext, + definition: RuntimeHostProviderDefinition, +): Promise { + assertRuntimeHostProviderDefinition(definition); + const [unit, status] = await Promise.all([ + readOptionalFile(context.unitPath), + readSystemdStatus(context), + ]); + if ( + unit !== renderSystemdSupervisorDefinition(definition) || + !isLoadedManagedSystemdUnit(status, context.unitPath) || + (status.unitFileState !== 'enabled' && status.unitFileState !== 'enabled-runtime') + ) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The systemd supervisor does not match its managed deployment', + ); + } +} + +async function verifySystemdReconciliationDefinition( + context: SystemdUpdateSchedulerContext, + definition: RuntimeHostProviderDefinition, +): Promise { + assertRuntimeHostProviderDefinition(definition); + const [serviceUnit, timerUnit, serviceStatus, timerStatus] = await Promise.all([ + readOptionalFile(context.service.unitPath), + readOptionalFile(context.timer.unitPath), + readSystemdStatus(context.service), + readSystemdStatus(context.timer), + ]); + if ( + serviceUnit !== renderSystemdReconciliationService(definition) || + timerUnit !== renderSystemdUpdateTimer(context.serviceId) || + !isLoadedManagedSystemdUnit(serviceStatus, context.service.unitPath) || + !isLoadedManagedSystemdUnit(timerStatus, context.timer.unitPath) || + (timerStatus.unitFileState !== 'enabled' && timerStatus.unitFileState !== 'enabled-runtime') + ) { + throw schedulerMismatch(); + } +} + +async function uninstallSystemdSupervisor(context: SystemdUnitContext): Promise { + const before = await readSystemdStatus(context); + if (before.loadState !== 'not-found') { + await requireSystemctl( + context.runSystemctl, + ['stop', context.unitName], + 'Stopping the Runtime Host service failed', + ); + } + if ( + before.loadState !== 'not-found' || + before.unitFileState === 'enabled' || + before.unitFileState === 'enabled-runtime' + ) { + await requireSystemctl( + context.runSystemctl, + ['disable', context.unitName], + 'Disabling the Runtime Host service failed', + ); + await context.runSystemctl(['reset-failed', context.unitName]); + } + await removeRuntimeHostServiceFile(context.unitPath, 'systemd unit'); + await requireSystemctl(context.runSystemctl, ['daemon-reload'], 'Reloading systemd failed'); + const after = await readSystemdStatus(context); + if ( + after.loadState !== 'not-found' || + after.activeState === 'active' || + after.unitFileState === 'enabled' || + after.unitFileState === 'enabled-runtime' + ) { + throw new RuntimeHostServiceManagerError( + 'uninstall_incomplete', + 'Runtime Host systemd supervisor still has managed state', + ); + } +} + function isSystemdUnitRunning(status: SystemdStatus): boolean { return status.activeState !== 'inactive' && status.activeState !== 'failed'; } diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 250d949706..35702f37b7 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -31,6 +31,8 @@ import { type RuntimeHostOperatorCapability, type RuntimeHostServiceManagementFrame, type RuntimeHostServiceUpdatePhase, + resolveRuntimeHostManagedDeployment, + RuntimeHostManagedDeploymentError as RuntimeHostDeploymentAuthorityError, } from '@maka/runtime-host/operator'; import { openRuntimeHostManagedPackageDeployment, @@ -54,6 +56,7 @@ import { } from './runtime-host-service-manager.js'; import { createPlatformRuntimeHostServiceBackend, + createPlatformRuntimeHostLifecycleProvider, runtimeHostServiceSummary, } from './runtime-host-service-management-command.js'; import { @@ -66,6 +69,11 @@ import { withRuntimeHostRegistryUpdatePackage, } from './runtime-host-update-package.js'; import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js'; +import { + replaceRuntimeHostLifecycle, + type RuntimeHostLifecycleTransactionDeps, +} from './runtime-host-lifecycle-transaction.js'; +import { manageRuntimeHostManagedLifecycle } from './runtime-host-managed-lifecycle-manager.js'; const OPERATOR_TIMEOUT_MS = 2 * 60_000; const OPERATOR_OUTPUT_MAX_BYTES = 256 * 1024; @@ -78,6 +86,7 @@ export interface RuntimeHostUpdateCliOptions { readonly sourcePackageRoot: string; readonly version: string; readonly expectedTarget: RuntimeHostManagedServiceTarget; + readonly managedRootId?: string; readonly registrySelection?: { readonly integrity: string; readonly current: { @@ -179,6 +188,13 @@ export async function runManagedRuntimeHostUpdateCli( let retired = false; const emit = frameSink ?? ((frame: RuntimeHostUpdateFrame) => presentUpdateFrame(frame, options, deps)); + if (options.managedRootId) { + return runCanonicalRuntimeHostUpdate( + { ...options, managedRootId: options.managedRootId }, + deps, + emit, + ); + } try { return await deps.withDeploymentLock(options.clientDataRoot, async () => { try { @@ -270,7 +286,9 @@ export async function runManagedRuntimeHostUpdateCli( await deps.runOperator( currentOperatorPath, ['status', '--framed', ...expectedTargetArgs(options.expectedTarget)], - { capabilityRequest: RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY }, + { + capabilityRequest: RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY, + }, ), ); } catch (error) { @@ -318,7 +336,9 @@ export async function runManagedRuntimeHostUpdateCli( currentOperatorUsesProcessLifetimeLock ? deps.runOperator(currentOperatorPath, args) : deps.withLegacyOperatorLeases(options.clientDataRoot, (inheritedFds) => - deps.runOperator(currentOperatorPath, args, { inheritedFds }), + deps.runOperator(currentOperatorPath, args, { + inheritedFds, + }), ); let retirement: RuntimeHostServiceManagementFrame = currentOperatorUnavailable ? { @@ -396,7 +416,11 @@ export async function runManagedRuntimeHostUpdateCli( action: 'update', service: retirement.service, ...operatorCapabilities(), - update: { kind: 'active_tasks', currentVersion, targetVersion: options.version }, + update: { + kind: 'active_tasks', + currentVersion, + targetVersion: options.version, + }, }); return 1; } @@ -440,7 +464,11 @@ export async function runManagedRuntimeHostUpdateCli( ); } await targetDeployment.cleanup(); - return { schemaVersion: 1, action: 'status', service: updatedService } as const; + return { + schemaVersion: 1, + action: 'status', + service: updatedService, + } as const; }); emit({ schemaVersion: 1, @@ -506,6 +534,192 @@ export async function runManagedRuntimeHostUpdateCli( } } +async function runCanonicalRuntimeHostUpdate( + options: RuntimeHostUpdateCliOptions & { readonly managedRootId: string }, + deps: RuntimeHostUpdateCliDeps, + emit: RuntimeHostUpdateFrameSink, +): Promise { + let staged: RuntimeHostManagedPackageDeployment | undefined; + try { + return await deps.withDeploymentLock(options.clientDataRoot, async () => { + const { config: current } = await resolveRuntimeHostManagedDeployment(options.managedRootId); + if (current.lifecycle.mode !== 'supervised') { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The selected Runtime Host does not use supervised lifecycle', + ); + } + const provider = createPlatformRuntimeHostLifecycleProvider(options.managedRootId); + const lifecycleDeps: RuntimeHostLifecycleTransactionDeps = { + resolveProvider: (requested) => { + if (requested !== provider.supervisor.provider) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + `The persisted Runtime Host provider ${requested} is unavailable`, + ); + } + return provider; + }, + }; + const currentStatus = await manageRuntimeHostManagedLifecycle( + options.managedRootId, + { + action: 'status', + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + nodePath: process.execPath, + cliPath: process.argv[1] ?? '', + expectedTarget: options.expectedTarget, + }, + { createProvider: createPlatformRuntimeHostLifecycleProvider }, + ); + const targetIntegrity = + options.registrySelection?.integrity ?? + (options.version === current.launch.package.version + ? current.launch.package.integrity + : undefined); + if (!targetIntegrity) { + throw new RuntimeHostServiceManagerError( + 'invalid_launch', + 'An exact registry package identity is required for a managed update', + ); + } + if ( + options.version === current.launch.package.version && + targetIntegrity === current.launch.package.integrity + ) { + emit({ + schemaVersion: 1, + kind: 'result', + action: 'update', + service: runtimeHostServiceSummary(currentStatus), + ...operatorCapabilities(), + update: { kind: 'already_current', version: options.version }, + }); + return 0; + } + emit(progress('checking', current.launch.package.version, options.version)); + emit(progress('staging', current.launch.package.version, options.version)); + staged = await deps.prepareDeployment({ + serviceId: options.managedRootId, + clientDataRoot: options.clientDataRoot, + sourcePackageRoot: options.sourcePackageRoot, + version: options.version, + packageIntegrity: targetIntegrity, + }); + const desired = { + ...current, + configRevision: current.configRevision + 1, + launch: { + ...current.launch, + package: { + kind: 'npm_registry' as const, + version: options.version, + integrity: targetIntegrity, + }, + }, + }; + emit(progress('retiring', current.launch.package.version, options.version)); + emit(progress('replacing', current.launch.package.version, options.version)); + const replacement = await replaceRuntimeHostLifecycle({ + operation: 'update', + current, + desired, + allowInterruptActiveTasks: options.allowInterruptActiveTasks ?? false, + deps: lifecycleDeps, + prepareDesired: () => staged!.activate(), + prepareRollback: async () => { + const previousPackage = await deps.openDeployment({ + serviceId: options.managedRootId, + clientDataRoot: options.clientDataRoot, + deploymentRoot: current.deploymentRoot, + cliPath: resolveRuntimeHostManagedPackageCliPath( + current.deploymentRoot, + current.launch.package.version, + current.launch.package.integrity, + ), + version: current.launch.package.version, + }); + await previousPackage.activate(); + }, + }); + if (replacement.kind === 'active_tasks') { + await staged.rollback(); + staged = undefined; + emit({ + schemaVersion: 1, + kind: 'result', + action: 'update', + service: runtimeHostServiceSummary(currentStatus), + ...operatorCapabilities(), + update: { + kind: 'active_tasks', + currentVersion: current.launch.package.version, + targetVersion: options.version, + }, + }); + return 1; + } + await staged.cleanup(); + staged = undefined; + const updated = await manageRuntimeHostManagedLifecycle( + options.managedRootId, + { + action: 'status', + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + nodePath: process.execPath, + cliPath: process.argv[1] ?? '', + expectedTarget: options.expectedTarget, + }, + { createProvider: createPlatformRuntimeHostLifecycleProvider }, + ); + emit({ + schemaVersion: 1, + kind: 'result', + action: 'update', + service: runtimeHostServiceSummary(updated), + ...operatorCapabilities(), + update: { + kind: 'updated', + previousVersion: current.launch.package.version, + targetVersion: options.version, + }, + }); + return 0; + }); + } catch (error) { + if ( + staged && + !( + error instanceof RuntimeHostDeploymentAuthorityError && + error.code === 'deployment_commit_unknown' + ) + ) { + await staged.rollback().catch(() => undefined); + } + const code = + error instanceof RuntimeHostServiceManagerError || + error instanceof RuntimeHostManagedDeploymentError || + error instanceof RuntimeHostDeploymentAuthorityError + ? error.code + : 'update_incomplete'; + emit({ + schemaVersion: 1, + kind: 'error', + action: 'update', + error: { + code: truncateUtf8(code, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES), + message: truncateUtf8( + error instanceof Error ? error.message : String(error), + RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES, + ), + }, + }); + return 1; + } +} + export async function runManagedRuntimeHostSelectedUpdateCli( options: RuntimeHostSelectedUpdateCliOptions, overrides: Partial = {}, @@ -529,6 +743,7 @@ export async function runManagedRuntimeHostSelectedUpdateCli( defaultRootPath: options.defaultRootPath, selector: options.selector, expectedTarget: options.expectedTarget, + ...(options.managedRootId ? { managedRootId: options.managedRootId } : {}), }); return await runManagedRuntimeHostResolvedUpdateCli(options, selection, deps, emit); } catch (error) { @@ -730,7 +945,9 @@ function operatorCapabilities(): { } { return process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] === RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY - ? { operatorCapabilities: [RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY] } + ? { + operatorCapabilities: [RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY], + } : {}; } diff --git a/packages/cli/src/runtime-host-update-discovery.ts b/packages/cli/src/runtime-host-update-discovery.ts index 0ffe6c9ee5..3bf45867f2 100644 --- a/packages/cli/src/runtime-host-update-discovery.ts +++ b/packages/cli/src/runtime-host-update-discovery.ts @@ -39,9 +39,11 @@ import { type RuntimeHostManagedServiceTarget, } from './runtime-host-service-manager.js'; import { + createPlatformRuntimeHostLifecycleProvider, createPlatformRuntimeHostServiceBackend, runtimeHostServiceSummary, } from './runtime-host-service-management-command.js'; +import { manageRuntimeHostManagedLifecycle } from './runtime-host-managed-lifecycle-manager.js'; import { resolveRuntimeHostManagedPackageCliPath } from './runtime-host-managed-deployment.js'; import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js'; @@ -67,6 +69,7 @@ export interface RuntimeHostUpdateCheckOptions { readonly defaultRootPath: string; readonly selector: RuntimeHostUpdateSelector; readonly expectedTarget?: RuntimeHostManagedServiceTarget; + readonly managedRootId?: string; } export interface RuntimeHostUpdateCheckCliOptions extends RuntimeHostUpdateCheckOptions { @@ -128,19 +131,24 @@ async function resolveManagedRuntimeHostUpdate( options: RuntimeHostUpdateCheckOptions, verifyDeployment: boolean, ): Promise { - const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); - const backend = createPlatformRuntimeHostServiceBackend(serviceId, options.clientDataRoot); - const status = await manageRuntimeHostService( - { - action: 'status', - clientDataRoot: options.clientDataRoot, - defaultRootPath: options.defaultRootPath, - nodePath: process.execPath, - cliPath: process.argv[1] ?? '', - ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), - }, - backend, - ); + const serviceId = + options.managedRootId ?? resolveRuntimeHostManagedServiceId(options.clientDataRoot); + const statusInput = { + action: 'status' as const, + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + nodePath: process.execPath, + cliPath: process.argv[1] ?? '', + ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), + }; + const backend = options.managedRootId + ? undefined + : createPlatformRuntimeHostServiceBackend(serviceId, options.clientDataRoot); + const status = options.managedRootId + ? await manageRuntimeHostManagedLifecycle(options.managedRootId, statusInput, { + createProvider: createPlatformRuntimeHostLifecycleProvider, + }) + : await manageRuntimeHostService(statusInput, backend!); const currentVersion = status.service.installedVersion; const config = status.service.config; const service = runtimeHostServiceSummary(status); @@ -156,7 +164,7 @@ async function resolveManagedRuntimeHostUpdate( 'A Maka-managed Runtime Host service is required to check for updates', ); } - if (verifyDeployment) await backend.verifyDeployment(config); + if (verifyDeployment && backend) await backend.verifyDeployment(config); const [candidate, currentCompatibility] = await Promise.all([ resolveRuntimeHostRegistryUpdateCandidate(options.selector), readPackageCompatibility(config.launch.cliPath, currentVersion), diff --git a/packages/cli/src/runtime-host-update-reconciliation.ts b/packages/cli/src/runtime-host-update-reconciliation.ts index 84d4561283..93fddc31e7 100644 --- a/packages/cli/src/runtime-host-update-reconciliation.ts +++ b/packages/cli/src/runtime-host-update-reconciliation.ts @@ -38,9 +38,11 @@ import { type RuntimeHostServiceBackend, } from './runtime-host-service-manager.js'; import { + createPlatformRuntimeHostLifecycleProvider, createPlatformRuntimeHostServiceBackend, runtimeHostServiceSummary, } from './runtime-host-service-management-command.js'; +import { manageRuntimeHostManagedLifecycle } from './runtime-host-managed-lifecycle-manager.js'; import { readRuntimeHostManagedUpdatePolicy, RuntimeHostUpdatePolicyError, @@ -71,6 +73,7 @@ interface RuntimeHostUpdatePolicyCliOptions { readonly defaultRootPath: string; readonly policy?: RuntimeHostManagedUpdatePolicy; readonly expectedTarget?: RuntimeHostManagedServiceTarget; + readonly managedRootId?: string; } interface RuntimeHostUpdateReconcileCliOptions { @@ -79,6 +82,7 @@ interface RuntimeHostUpdateReconcileCliOptions { readonly clientDataRoot: string; readonly defaultRootPath: string; readonly expectedTarget?: RuntimeHostManagedServiceTarget; + readonly managedRootId?: string; } interface RuntimeHostUpdateReconciliationDeps { @@ -86,6 +90,7 @@ interface RuntimeHostUpdateReconciliationDeps { readonly readPolicy: typeof readRuntimeHostManagedUpdatePolicy; readonly writePolicy: typeof writeRuntimeHostManagedUpdatePolicy; readonly manage: typeof manageRuntimeHostService; + readonly manageLifecycle: typeof manageRuntimeHostManagedLifecycle; readonly createBackend: (serviceId: string, clientDataRoot: string) => RuntimeHostServiceBackend; readonly resolveSelection: typeof resolveManagedRuntimeHostUpdateSelection; readonly applySelection: typeof runManagedRuntimeHostResolvedUpdateCli; @@ -197,6 +202,7 @@ export async function runManagedRuntimeHostUpdateReconcileCli( defaultRootPath: options.defaultRootPath, selector: policySelector(record.policy), expectedTarget: record.target, + ...(options.managedRootId ? { managedRootId: options.managedRootId } : {}), }); const updatePolicy = policyResult(record); if (selection.outcome.kind === 'manual_action') { @@ -231,6 +237,7 @@ export async function runManagedRuntimeHostUpdateReconcileCli( defaultRootPath: options.defaultRootPath, selector: selection.selector, expectedTarget: record.target, + ...(options.managedRootId ? { managedRootId: options.managedRootId } : {}), }; const exitCode = await deps.applySelection( selectedOptions, @@ -272,32 +279,45 @@ export async function runManagedRuntimeHostUpdateReconcileCli( function readManagedServiceStatus( options: Pick< RuntimeHostUpdatePolicyCliOptions | RuntimeHostUpdateReconcileCliOptions, - 'clientDataRoot' | 'defaultRootPath' + 'clientDataRoot' | 'defaultRootPath' | 'managedRootId' >, 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, options.clientDataRoot), - ); + const statusInput = { + action: 'status' as const, + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + nodePath: process.execPath, + cliPath: process.argv[1] ?? '', + ...(expectedTarget ? { expectedTarget } : {}), + }; + return options.managedRootId + ? deps.manageLifecycle(options.managedRootId, statusInput, { + createProvider: createPlatformRuntimeHostLifecycleProvider, + }) + : deps.manage( + statusInput, + deps.createBackend( + resolveRuntimeHostManagedServiceId(options.clientDataRoot), + options.clientDataRoot, + ), + ); } async function inspectUpdateScheduler( - options: Pick, + options: Pick, status: Awaited>, deps: RuntimeHostUpdateReconciliationDeps, ): Promise { const config = status.service.config; if (!status.service.installed || !config?.managedDeploymentRoot) return 'needs_repair'; + if (options.managedRootId) { + const trigger = await createPlatformRuntimeHostLifecycleProvider( + options.managedRootId, + ).reconciliationTrigger.status(); + return trigger.installed ? (trigger.active ? 'ready' : 'inactive') : 'needs_repair'; + } const backend = deps.createBackend( resolveRuntimeHostManagedServiceId(options.clientDataRoot), options.clientDataRoot, @@ -332,6 +352,7 @@ function reconciliationDeps( readPolicy: readRuntimeHostManagedUpdatePolicy, writePolicy: writeRuntimeHostManagedUpdatePolicy, manage: manageRuntimeHostService, + manageLifecycle: manageRuntimeHostManagedLifecycle, createBackend: createPlatformRuntimeHostServiceBackend, resolveSelection: resolveManagedRuntimeHostUpdateSelection, applySelection: runManagedRuntimeHostResolvedUpdateCli, diff --git a/packages/runtime-host/src/operator/managed-deployment.ts b/packages/runtime-host/src/operator/managed-deployment.ts index 327f5e355a..66917ee4ca 100644 --- a/packages/runtime-host/src/operator/managed-deployment.ts +++ b/packages/runtime-host/src/operator/managed-deployment.ts @@ -45,7 +45,7 @@ import { export const RUNTIME_HOST_MANAGED_DEPLOYMENT_CONFIG_FILE = 'runtime-host-deployment.json'; const SCHEMA_VERSION = 1 as const; -const MAX_DOCUMENT_BYTES = 64 * 1024; +const MAX_DOCUMENT_BYTES = 256 * 1024; const ROOT_ID_PATTERN = /^[a-f0-9]{64}$/u; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; @@ -110,14 +110,15 @@ const managedLaunchClaimSchema = z .strict(); const deploymentTransitionOperationSchema = z.enum([ + 'install', 'legacy_migration', 'lifecycle_change', 'provider_change', + 'configure', + 'update', 'uninstall', ]); -const deploymentTransitionEndpointSchema = managedLaunchClaimSchema.nullable(); - const deploymentAuthorityRootSchema = z .object({ path: absolutePathSchema, @@ -165,6 +166,16 @@ const managedDeploymentConfigSchema = z }) .strict() .optional(), + directPeer: z + .object({ + enabled: z.boolean(), + keyPath: absolutePathSchema, + peerId: boundedText(256), + listenAddresses: z.array(boundedText(2_048)).min(1).max(16), + coordinationRelays: z.array(boundedText(2_048)).max(16), + }) + .strict() + .optional(), }) .strict(), lifecycle: lifecycleSchema, @@ -179,6 +190,13 @@ const managedDeploymentConfigSchema = z path: ['reconciliation'], }); } + if (value.lifecycle.mode === 'on_demand' && value.listeners.directPeer?.enabled === true) { + context.addIssue({ + code: 'custom', + message: 'An on-demand deployment cannot enable a direct peer listener', + path: ['listeners', 'directPeer'], + }); + } if (value.lifecycle.mode === 'supervised' && value.reconciliation.trigger === 'activation') { context.addIssue({ code: 'custom', @@ -210,8 +228,8 @@ const managedDeploymentTransitionSchema = z transactionId: deploymentIdSchema, operation: deploymentTransitionOperationSchema, root: deploymentAuthorityRootSchema, - from: deploymentTransitionEndpointSchema, - to: deploymentTransitionEndpointSchema, + from: managedDeploymentConfigSchema.nullable(), + to: managedDeploymentConfigSchema.nullable(), }) .strict() .superRefine(validateDeploymentTransitionEndpoints); @@ -223,8 +241,8 @@ const managedDeploymentBlockedSchema = z transactionId: deploymentIdSchema, operation: deploymentTransitionOperationSchema, root: deploymentAuthorityRootSchema, - from: deploymentTransitionEndpointSchema, - to: deploymentTransitionEndpointSchema, + from: managedDeploymentConfigSchema.nullable(), + to: managedDeploymentConfigSchema.nullable(), reason: boundedText(1_024), }) .strict() @@ -239,19 +257,30 @@ const managedDeploymentAuthorityRecordSchema = z.union([ function validateDeploymentTransitionEndpoints( value: { readonly operation: z.infer; - readonly from: z.infer; - readonly to: z.infer; + readonly root: z.infer; + readonly from: RuntimeHostManagedDeploymentConfig | null; + readonly to: RuntimeHostManagedDeploymentConfig | null; }, context: z.RefinementCtx, ): void { const valid = - (value.operation === 'legacy_migration' && value.from === null && value.to !== null) || + ((value.operation === 'install' || value.operation === 'legacy_migration') && + value.from === null && + value.to !== null) || (value.operation === 'uninstall' && value.from !== null && value.to === null) || - ((value.operation === 'lifecycle_change' || value.operation === 'provider_change') && + ((value.operation === 'lifecycle_change' || + value.operation === 'provider_change' || + value.operation === 'configure' || + value.operation === 'update') && value.from !== null && value.to !== null && value.from.deploymentId === value.to.deploymentId && - value.to.configRevision > value.from.configRevision); + value.to.configRevision > value.from.configRevision && + [value.from, value.to].every( + (config) => + config === null || + (config.root.id === value.root.id && config.root.path === value.root.path), + )); if (!valid) { context.addIssue({ code: 'custom', @@ -562,7 +591,7 @@ export async function commitRuntimeHostManagedDeployment( ); const current = await readDeploymentConfigForCapability(path, owner.capability); if (current !== undefined) return existingDeploymentClaim(current, canonical); - await writePrivateJson(path, canonical); + await writePrivateJson(path, canonical, options.beforeDirectorySync); return { kind: 'applied', config: canonical, @@ -591,7 +620,7 @@ export async function beginRuntimeHostManagedDeploymentTransition( if (!isDeepStrictEqual(current, expected)) { throw deploymentTransactionMismatch('The managed deployment changed before transition began'); } - await writePrivateJson(path, transition); + await writePrivateJson(path, transition, options.beforeDirectorySync); return { kind: 'applied', record: transition }; } @@ -649,7 +678,7 @@ export async function blockRuntimeHostManagedDeploymentTransition( throw deploymentTransactionMismatch('The managed deployment blocked record is invalid'); } if (isDeepStrictEqual(current, record)) return { kind: 'unchanged', record }; - await writePrivateJson(path, record); + await writePrivateJson(path, record, options.beforeDirectorySync); return { kind: 'applied', record }; } @@ -681,12 +710,11 @@ async function finishRuntimeHostManagedDeploymentTransition( if (current.transactionId !== transactionId) { throw deploymentTransactionMismatch('The managed deployment transaction identity changed'); } - const claim = config ? runtimeHostManagedLaunchClaim(config) : null; - if (!isDeepStrictEqual(current[endpoint], claim)) { + if (!isDeepStrictEqual(current[endpoint], config ?? null)) { throw deploymentTransactionMismatch('The managed deployment transition target changed'); } - if (config) await writePrivateJson(path, config); - else await removePrivateJson(path); + if (config) await writePrivateJson(path, config, options.beforeDirectorySync); + else await removePrivateJson(path, options.beforeDirectorySync); return { kind: 'applied', ...(config ? { config } : {}) }; } @@ -704,8 +732,8 @@ function deploymentTransitionRecord( transactionId: input.transactionId, operation: input.operation, root: { path: capability.canonicalPath, id: capability.rootId }, - from: expected ? runtimeHostManagedLaunchClaim(expected) : null, - to: desired ? runtimeHostManagedLaunchClaim(desired) : null, + from: expected ?? null, + to: desired ?? null, }); if (record.schemaVersion !== 2 || record.state !== 'transition') { throw deploymentTransactionMismatch('The managed deployment transition is invalid'); @@ -1014,7 +1042,11 @@ async function readBoundedJson(path: string): Promise { } } -async function writePrivateJson(path: string, value: unknown): Promise { +async function writePrivateJson( + path: string, + value: unknown, + beforeDirectorySync?: (path: string) => void | Promise, +): Promise { const contents = JSON.stringify(value, null, 2) + '\n'; if (Buffer.byteLength(contents, 'utf8') > MAX_DOCUMENT_BYTES) { throw new RuntimeHostManagedDeploymentError( @@ -1035,6 +1067,7 @@ async function writePrivateJson(path: string, value: unknown): Promise { if (process.platform !== 'win32') await chmod(temporaryPath, 0o600); await rename(temporaryPath, path); published = true; + await beforeDirectorySync?.(dirname(path)); await syncDirectory(dirname(path)); } catch (error) { if (error instanceof RuntimeHostManagedDeploymentError) throw error; @@ -1051,11 +1084,15 @@ async function writePrivateJson(path: string, value: unknown): Promise { } } -async function removePrivateJson(path: string): Promise { +async function removePrivateJson( + path: string, + beforeDirectorySync?: (path: string) => void | Promise, +): Promise { let removed = false; try { await rm(path); removed = true; + await beforeDirectorySync?.(dirname(path)); await syncDirectory(dirname(path)); } catch (error) { if (removed) { @@ -1139,7 +1176,9 @@ function requireRootId(rootId: string): void { function deploymentIo(message: string, cause: unknown): RuntimeHostManagedDeploymentError { return cause instanceof RuntimeHostManagedDeploymentError ? cause - : new RuntimeHostManagedDeploymentError('deployment_io_failed', message, { cause }); + : new RuntimeHostManagedDeploymentError('deployment_io_failed', message, { + cause, + }); } function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts index 1da6573569..5295f60d40 100644 --- a/packages/runtime-host/src/operator/service-management-frame.ts +++ b/packages/runtime-host/src/operator/service-management-frame.ts @@ -232,6 +232,25 @@ const SERVICE_SUMMARY_SCHEMA = z pid: z.number().int().positive().nullable(), lastExitCode: z.number().int().nonnegative().nullable(), installedVersion: boundedString(FIELD_MAX_BYTES).nullable(), + lifecycle: z + .object({ + mode: z.enum(['on_demand', 'supervised']), + availability: z.enum(['activation', 'session', 'environment', 'machine']), + provider: z + .enum(['systemd_user', 'launch_agent', 'openrc_user', 'openrc_system']) + .optional(), + }) + .strict() + .optional(), + reconciliation: z + .object({ + trigger: z.enum(['manual', 'activation', 'scheduled']), + provider: z + .enum(['systemd_timer', 'launch_agent_timer', 'openrc_supervised_loop']) + .optional(), + }) + .strict() + .optional(), stateRoot: boundedString(PATH_MAX_BYTES).optional(), configurationFingerprint: z .string() From b0b7af159d1cd6a62d786f46d6325c3c943121a7 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 28 Aug 2026 10:34:21 +0800 Subject: [PATCH 3/7] refactor(desktop): persist managed deployment bindings Generated-by: OpenAI Codex --- .../runtime-host-managed-services.test.ts | 70 ++++- .../__tests__/runtime-host-management.test.ts | 55 ++-- .../src/main/runtime-host-managed-services.ts | 240 ++++++++++++++---- .../src/main/runtime-host-management.ts | 34 +-- .../src/main/runtime-host-profile-service.ts | 18 +- 5 files changed, 319 insertions(+), 98 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts b/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts index 321ca55bc8..d1b0a6aee1 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts @@ -18,7 +18,7 @@ */ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, test } from "node:test"; @@ -48,17 +48,31 @@ const service = { }; afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true }))); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true })), + ); }); test("keeps Desktop service bindings outside the shared profile catalog", async () => { const root = await mkdtemp(join(tmpdir(), "maka-managed-host-services-")); roots.push(root); const catalog = createClientRuntimeHostProfileCatalog(root); + await writeFile( + join(root, "runtime-host-managed-services.json"), + `${JSON.stringify({ + schemaVersion: 1, + bindings: [{ profile, service, state: "active" }], + })}\n`, + ); const managedServices = createDesktopRuntimeHostManagedServiceStore(root); const concurrentStore = createDesktopRuntimeHostManagedServiceStore(root); await catalog.create(profile, "secret"); + assert.equal((await managedServices.read()).bindings[0]?.deployment.id, service.id); + await assert.rejects(readFile(join(root, "runtime-host-managed-services.json"), "utf8"), { + code: "ENOENT", + }); + await Promise.all([ managedServices.save(profile, service), concurrentStore.save( @@ -72,28 +86,60 @@ test("keeps Desktop service bindings outside the shared profile catalog", async /managedService/u, ); assert.deepEqual( - findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), profile), - { profile: { ...profile, transport: { ...profile.transport } }, service, state: "active" }, + findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + profile, + ), + { + profile: { ...profile, transport: { ...profile.transport } }, + deployment: { id: service.id, rootPath: service.rootPath }, + control: { kind: "ssh_operator", operatorPath: service.operatorPath }, + state: "active", + }, ); assert.equal((await managedServices.read()).bindings.length, 2); assert.equal( findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), { ...profile, - transport: { ...profile.transport, destination: "operator@new.example.com" }, + transport: { + ...profile.transport, + destination: "operator@new.example.com", + }, }), undefined, ); - assert.equal(await managedServices.markUninstallingIfCurrent(profile, service), true); assert.equal( - findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), profile)?.state, + await managedServices.markUninstallingIfCurrent(profile, service), + true, + ); + assert.equal( + findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + profile, + )?.state, "uninstalling", ); - assert.equal(await managedServices.removeCleanupPendingIfCurrent(profile, service), false); - assert.equal(await managedServices.markCleanupPendingIfCurrent(profile, service), true); assert.equal( - findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), profile)?.state, + await managedServices.removeCleanupPendingIfCurrent(profile, service), + false, + ); + assert.equal( + await managedServices.markCleanupPendingIfCurrent(profile, service), + true, + ); + assert.equal( + findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + profile, + )?.state, "cleanup_pending", ); - assert.equal(await managedServices.markUninstallingIfCurrent(profile, service), false); - assert.equal(await managedServices.removeCleanupPendingIfCurrent(profile, service), true); + assert.equal( + await managedServices.markUninstallingIfCurrent(profile, service), + false, + ); + assert.equal( + await managedServices.removeCleanupPendingIfCurrent(profile, service), + true, + ); }); 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 ce7731ea0a..44ac1b4828 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -75,17 +75,19 @@ test('identifies, rotates, and revokes managed credentials without exposing secr }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => ({ profile, service, state: 'active' as const }), + resolveManagedService: async () => managedBinding(profile, service, 'active'), resolveManagedAccess: async () => ({ - profile, - service, - state: 'active' as const, + ...managedBinding(profile, service, 'active'), credentialFingerprint: currentFingerprint, enabled: profileEnabled, }), rotateManagedCredential: async (expected, credential) => { assert.equal(expected.profile, profile); - assert.equal(expected.service, service); + assert.deepEqual(expected.deployment, { id: service.id, rootPath: service.rootPath }); + assert.deepEqual(expected.control, { + kind: 'ssh_operator', + operatorPath: service.operatorPath, + }); assert.equal(expected.credentialFingerprint, currentFingerprint); assert.equal(credential, replacement); }, @@ -220,7 +222,7 @@ test('manages only the service identity bound by Desktop onboarding', async () = ...unusedDirectPeerProfileDependencies(), resolveManagedService: async (profileId) => profileId === managedProfile.id - ? { profile: managedProfile, service: managedService, state: 'active' as const } + ? managedBinding(managedProfile, managedService, 'active') : undefined, resolveManagedAccess: async () => undefined, markManagedServiceUninstalling: async (binding) => { @@ -366,7 +368,7 @@ test('publishes update progress and waits for the managed profile to reconnect', profiles: { ...unusedDirectPeerProfileDependencies(), resolveManagedService: async () => - bindingPresent ? { profile, service, state: 'active' as const } : undefined, + bindingPresent ? managedBinding(profile, service, 'active') : undefined, resolveManagedAccess: async () => undefined, rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), markManagedServiceUninstalling: async (binding) => binding, @@ -488,7 +490,7 @@ test('configures Project roots with CAS and reconnects only after a committed cu }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => ({ profile, service, state: 'active' as const }), + resolveManagedService: async () => managedBinding(profile, service, 'active'), resolveManagedAccess: async () => undefined, rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), markManagedServiceUninstalling: async (binding) => binding, @@ -596,7 +598,7 @@ test('manages one Host update policy and reconciles it through the bound operato }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => ({ profile, service, state: 'active' as const }), + resolveManagedService: async () => managedBinding(profile, service, 'active'), resolveManagedAccess: async () => undefined, rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), markManagedServiceUninstalling: async (binding) => binding, @@ -742,7 +744,7 @@ test('resumes deployment cleanup without invoking the removed operator', async ( }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => ({ profile, service, state }), + resolveManagedService: async () => managedBinding(profile, service, state), resolveManagedAccess: async () => undefined, markManagedServiceUninstalling: async (binding) => { state = 'uninstalling'; @@ -795,8 +797,8 @@ test('rechecks uninstall intent before retrying the remote service', async () => }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => ({ - profile: { + resolveManagedService: async () => managedBinding( + { id: 'office', name: 'Office', kind: 'remote' as const, @@ -808,13 +810,13 @@ test('rechecks uninstall intent before retrying the remote service', async () => websocketPath: '/runtime-host', }, }, - service: { + { id: 'b'.repeat(64), rootPath: '/srv/maka', operatorPath: '/home/operator/.local/share/maka/operator', }, - state: 'uninstalling' as const, - }), + 'uninstalling', + ), resolveManagedAccess: async () => undefined, markManagedServiceUninstalling: async (binding) => { marked = true; @@ -874,7 +876,7 @@ test('keeps the SSH profile while adding and removing its managed Direct peer', }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => ({ profile, service, state: 'active' as const }), + resolveManagedService: async () => managedBinding(profile, service, 'active'), resolveManagedAccess: async () => undefined, rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), markManagedServiceUninstalling: async (binding) => binding, @@ -1049,8 +1051,8 @@ test('does not invoke peer management when the remote operator lacks its capabil }); function managedSshBinding() { - return { - profile: { + return managedBinding( + { id: 'office', name: 'Office', kind: 'remote' as const, @@ -1062,12 +1064,25 @@ function managedSshBinding() { websocketPath: '/runtime-host', }, }, - service: { + { id: 'b'.repeat(64), rootPath: '/srv/maka', operatorPath: '/home/operator/.local/share/maka/operator', }, - state: 'active' as const, + 'active', + ); +} + +function managedBinding< + Profile, + Service extends { readonly id: string; readonly rootPath: string; readonly operatorPath: string }, + State extends 'active' | 'uninstalling' | 'cleanup_pending', +>(profile: Profile, service: Service, state: State) { + return { + profile, + deployment: { id: service.id, rootPath: service.rootPath }, + control: { kind: 'ssh_operator' as const, operatorPath: service.operatorPath }, + state, }; } diff --git a/apps/desktop/src/main/runtime-host-managed-services.ts b/apps/desktop/src/main/runtime-host-managed-services.ts index 33b24de6fa..5d8bd58eed 100644 --- a/apps/desktop/src/main/runtime-host-managed-services.ts +++ b/apps/desktop/src/main/runtime-host-managed-services.ts @@ -40,9 +40,20 @@ export interface DesktopRuntimeHostManagedService { readonly operatorPath: string; } +export interface DesktopRuntimeHostDeploymentBinding { + readonly id: string; + readonly rootPath: string; +} + +export interface DesktopRuntimeHostControlRoute { + readonly kind: "ssh_operator"; + readonly operatorPath: string; +} + export interface DesktopRuntimeHostManagedServiceBinding { readonly profile: RemoteRuntimeHostProfile; - readonly service: DesktopRuntimeHostManagedService; + readonly deployment: DesktopRuntimeHostDeploymentBinding; + readonly control: DesktopRuntimeHostControlRoute; readonly state: "active" | "uninstalling" | "cleanup_pending"; } @@ -61,7 +72,9 @@ export interface DesktopRuntimeHostManagedServiceStore { profile: RemoteRuntimeHostProfile, service: DesktopRuntimeHostManagedService, ): Promise; - removeForProfileIfCurrent(profile: RemoteRuntimeHostProfile): Promise; + removeForProfileIfCurrent( + profile: RemoteRuntimeHostProfile, + ): Promise; markUninstallingIfCurrent( profile: RemoteRuntimeHostProfile, service: DesktopRuntimeHostManagedService, @@ -80,6 +93,7 @@ export function createDesktopRuntimeHostManagedServiceStore( clientDataRoot: string, ): DesktopRuntimeHostManagedServiceStore { return new FileDesktopRuntimeHostManagedServiceStore( + join(clientDataRoot, "runtime-host-deployments.json"), join(clientDataRoot, "runtime-host-managed-services.json"), ); } @@ -88,7 +102,9 @@ export function findDesktopRuntimeHostManagedServiceBinding( document: DesktopRuntimeHostManagedServiceDocument, profile: RemoteRuntimeHostProfile, ): DesktopRuntimeHostManagedServiceBinding | undefined { - const binding = document.bindings.find((candidate) => candidate.profile.id === profile.id); + const binding = document.bindings.find( + (candidate) => candidate.profile.id === profile.id, + ); return binding && sameRemoteRuntimeHostProfileTarget(binding.profile, profile) ? binding : undefined; @@ -102,26 +118,40 @@ export function sameDesktopRuntimeHostManagedServiceBinding( left.state === right.state && left.profile.id === right.profile.id && sameRemoteRuntimeHostProfileTarget(left.profile, right.profile) && - sameService(left.service, right.service) + sameBindingTarget(left, right) ); } -class FileDesktopRuntimeHostManagedServiceStore - implements DesktopRuntimeHostManagedServiceStore -{ +class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostManagedServiceStore { readonly #path: string; + readonly #legacyPath: string; - constructor(path: string) { + constructor(path: string, legacyPath: string) { this.#path = path; + this.#legacyPath = legacyPath; } async read(): Promise { + return this.#exclusive(() => this.#readUnlocked()); + } + + async #readUnlocked(): Promise { let contents: string; try { contents = await readFile(this.#path, "utf8"); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyDocument(); - throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + try { + contents = await readFile(this.#legacyPath, "utf8"); + } catch (legacyError) { + if ((legacyError as NodeJS.ErrnoException).code === "ENOENT") + return emptyDocument(); + throw legacyError; + } + const migrated = decodeLegacyDocument(JSON.parse(contents)); + await writeDocument(this.#path, migrated); + await rm(this.#legacyPath, { force: true }); + return migrated; } if (Buffer.byteLength(contents, "utf8") > DOCUMENT_MAX_BYTES) { throw new Error("Runtime Host managed service document is too large"); @@ -135,20 +165,35 @@ class FileDesktopRuntimeHostManagedServiceStore ): Promise { const profile = decodeRemoteRuntimeHostProfile(value); if (profile.transport.kind !== "ssh") { - return Promise.reject(new Error("A managed Runtime Host service requires SSH")); + return Promise.reject( + new Error("A managed Runtime Host service requires SSH"), + ); } const service = decodeService(managedService); return this.#exclusive(async () => { - const current = await this.read(); + const current = await this.#readUnlocked(); const bindings = current.bindings.filter( (binding) => binding.profile.id !== profile.id, ); if (bindings.length >= BINDING_COUNT_MAX) { - throw new Error("Too many managed Runtime Host services are configured"); + throw new Error( + "Too many managed Runtime Host services are configured", + ); } await writeDocument(this.#path, { schemaVersion: SCHEMA_VERSION, - bindings: [...bindings, { profile, service, state: "active" }], + bindings: [ + ...bindings, + { + profile, + deployment: { id: service.id, rootPath: service.rootPath }, + control: { + kind: "ssh_operator", + operatorPath: service.operatorPath, + }, + state: "active", + }, + ], }); }); } @@ -207,14 +252,14 @@ class FileDesktopRuntimeHostManagedServiceStore state?: DesktopRuntimeHostManagedServiceBinding["state"], ): Promise { return this.#exclusive(async () => { - const current = await this.read(); + const current = await this.#readUnlocked(); const binding = current.bindings.find( (candidate) => candidate.profile.id === profile.id, ); if ( !binding || !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) || - (service && !sameService(binding.service, service)) || + (service && !sameServiceBinding(binding, service)) || (state && binding.state !== state) ) { return false; @@ -238,14 +283,14 @@ class FileDesktopRuntimeHostManagedServiceStore const profile = decodeRemoteRuntimeHostProfile(value); const service = decodeService(managedService); return this.#exclusive(async () => { - const current = await this.read(); + const current = await this.#readUnlocked(); const binding = current.bindings.find( (candidate) => candidate.profile.id === profile.id, ); if ( !binding || !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) || - !sameService(binding.service, service) || + !sameServiceBinding(binding, service) || !allowedStates.includes(binding.state) ) { return false; @@ -267,23 +312,31 @@ class FileDesktopRuntimeHostManagedServiceStore } } -function decodeDocument(value: unknown): DesktopRuntimeHostManagedServiceDocument { - const record = requireExactRecord(value, "Runtime Host managed service document", [ - "schemaVersion", - "bindings", - ]); - if (record.schemaVersion !== SCHEMA_VERSION || !Array.isArray(record.bindings)) { +function decodeDocument( + value: unknown, +): DesktopRuntimeHostManagedServiceDocument { + const record = requireExactRecord( + value, + "Runtime Host managed service document", + ["schemaVersion", "bindings"], + ); + if ( + record.schemaVersion !== SCHEMA_VERSION || + !Array.isArray(record.bindings) + ) { throw new Error("Runtime Host managed service document is invalid"); } if (record.bindings.length > BINDING_COUNT_MAX) { - throw new Error("Runtime Host managed service document has too many bindings"); + throw new Error( + "Runtime Host managed service document has too many bindings", + ); } const bindings = record.bindings.map((candidate) => { - const binding = requireExactRecord(candidate, "Runtime Host managed service binding", [ - "profile", - "service", - "state", - ]); + const binding = requireExactRecord( + candidate, + "Runtime Host managed service binding", + ["control", "deployment", "profile", "state"], + ); const profile = decodeRemoteRuntimeHostProfile(binding.profile); if (profile.transport.kind !== "ssh") { throw new Error("A managed Runtime Host service requires SSH"); @@ -297,14 +350,83 @@ function decodeDocument(value: unknown): DesktopRuntimeHostManagedServiceDocumen } return Object.freeze({ profile, - service: decodeService(binding.service), + deployment: decodeDeployment(binding.deployment), + control: decodeControlRoute(binding.control), state: binding.state, }); }); - if (new Set(bindings.map((binding) => binding.profile.id)).size !== bindings.length) { - throw new Error("Runtime Host managed service bindings must have unique profile IDs"); + if ( + new Set(bindings.map((binding) => binding.profile.id)).size !== + bindings.length + ) { + throw new Error( + "Runtime Host managed service bindings must have unique profile IDs", + ); } - return Object.freeze({ schemaVersion: SCHEMA_VERSION, bindings: Object.freeze(bindings) }); + return Object.freeze({ + schemaVersion: SCHEMA_VERSION, + bindings: Object.freeze(bindings), + }); +} + +function decodeLegacyDocument( + value: unknown, +): DesktopRuntimeHostManagedServiceDocument { + const record = requireExactRecord( + value, + "Legacy Runtime Host managed service document", + ["schemaVersion", "bindings"], + ); + if (record.schemaVersion !== 1 || !Array.isArray(record.bindings)) { + throw new Error("Legacy Runtime Host managed service document is invalid"); + } + return decodeDocument({ + schemaVersion: SCHEMA_VERSION, + bindings: record.bindings.map((candidate) => { + const binding = requireExactRecord( + candidate, + "Legacy Runtime Host service binding", + ["profile", "service", "state"], + ); + const service = decodeService(binding.service); + return { + profile: binding.profile, + deployment: { id: service.id, rootPath: service.rootPath }, + control: { kind: "ssh_operator", operatorPath: service.operatorPath }, + state: binding.state, + }; + }), + }); +} + +function decodeDeployment(value: unknown): DesktopRuntimeHostDeploymentBinding { + const record = requireExactRecord(value, "Managed Runtime Host deployment", [ + "id", + "rootPath", + ]); + return Object.freeze({ + id: requireHostRootId(record.id), + rootPath: requirePath(record.rootPath, "Managed Runtime Host State Root"), + }); +} + +function decodeControlRoute(value: unknown): DesktopRuntimeHostControlRoute { + const record = requireExactRecord( + value, + "Managed Runtime Host control route", + ["kind", "operatorPath"], + ); + if (record.kind !== "ssh_operator") { + throw new Error("Managed Runtime Host control route is invalid"); + } + const operatorPath = requirePath( + record.operatorPath, + "Managed Runtime Host operator path", + ); + if (!operatorPath.startsWith("/")) { + throw new Error("Managed Runtime Host operator path must be absolute"); + } + return Object.freeze({ kind: "ssh_operator", operatorPath }); } function decodeService(value: unknown): DesktopRuntimeHostManagedService { @@ -313,8 +435,14 @@ function decodeService(value: unknown): DesktopRuntimeHostManagedService { "rootPath", "operatorPath", ]); - const rootPath = requirePath(record.rootPath, "Managed Runtime Host State Root"); - const operatorPath = requirePath(record.operatorPath, "Managed Runtime Host operator path"); + const rootPath = requirePath( + record.rootPath, + "Managed Runtime Host State Root", + ); + const operatorPath = requirePath( + record.operatorPath, + "Managed Runtime Host operator path", + ); if (!operatorPath.startsWith("/")) { throw new Error("Managed Runtime Host operator path must be absolute"); } @@ -348,25 +476,44 @@ function requireExactRecord( const record = value as Record; const actual = Object.keys(record).sort(); const expected = [...keys].sort(); - if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { throw new Error(`${label} has unexpected fields`); } return record; } -function sameService( - left: DesktopRuntimeHostManagedService, - right: DesktopRuntimeHostManagedService, +function sameServiceBinding( + binding: DesktopRuntimeHostManagedServiceBinding, + service: DesktopRuntimeHostManagedService, ): boolean { return ( - left.id === right.id && - left.rootPath === right.rootPath && - left.operatorPath === right.operatorPath + binding.deployment.id === service.id && + binding.deployment.rootPath === service.rootPath && + binding.control.kind === "ssh_operator" && + binding.control.operatorPath === service.operatorPath + ); +} + +function sameBindingTarget( + left: DesktopRuntimeHostManagedServiceBinding, + right: DesktopRuntimeHostManagedServiceBinding, +): boolean { + return ( + left.deployment.id === right.deployment.id && + left.deployment.rootPath === right.deployment.rootPath && + left.control.kind === right.control.kind && + left.control.operatorPath === right.control.operatorPath ); } function emptyDocument(): DesktopRuntimeHostManagedServiceDocument { - return Object.freeze({ schemaVersion: SCHEMA_VERSION, bindings: Object.freeze([]) }); + return Object.freeze({ + schemaVersion: SCHEMA_VERSION, + bindings: Object.freeze([]), + }); } async function writeDocument( @@ -374,7 +521,10 @@ async function writeDocument( document: DesktopRuntimeHostManagedServiceDocument, ): Promise { const validated = decodeDocument(document); - const temporaryPath = join(dirname(path), `.runtime-host-managed-services-${randomUUID()}.tmp`); + const temporaryPath = join( + dirname(path), + `.runtime-host-deployments-${randomUUID()}.tmp`, + ); const handle = await open(temporaryPath, "wx", 0o600); try { try { diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index 33bc920d7f..c212529d8c 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -137,7 +137,7 @@ export function createDesktopRuntimeHostManagement(input: { managementAction: DesktopRuntimeHostManagementAction, ): Promise => { const managed = await resolveManagedService(profileId); - const { profile, service } = managed; + const { profile, deployment, control } = managed; if (profile.transport.kind !== 'ssh') { throw new Error('This Runtime Host profile is not bound to a managed service'); } @@ -147,16 +147,16 @@ export function createDesktopRuntimeHostManagement(input: { const managementInput: DesktopRuntimeHostSshManagementInput = { destination: profile.transport.destination, ...(profile.transport.sshPort === undefined ? {} : { sshPort: profile.transport.sshPort }), - operatorPath: service.operatorPath, + operatorPath: control.operatorPath, action: managementAction, expectedTarget: { - serviceId: service.id, - rootPath: service.rootPath, + serviceId: deployment.id, + rootPath: deployment.rootPath, rootId: profile.rootId, }, ...(managementAction === 'install' ? { - rootPath: service.rootPath, + rootPath: deployment.rootPath, websocketPort: profile.transport.remotePort, websocketPath: profile.transport.websocketPath, } @@ -202,7 +202,7 @@ export function createDesktopRuntimeHostManagement(input: { expectedTarget: managementInput.expectedTarget, }); await input.profiles.clearManagedServiceBinding(pending); - return { kind: 'uninstalled', retainedStateRoot: service.rootPath }; + return { kind: 'uninstalled', retainedStateRoot: deployment.rootPath }; }; const run = ( profileIdValue: unknown, @@ -246,8 +246,8 @@ export function createDesktopRuntimeHostManagement(input: { ...(managed.profile.transport.sshPort === undefined ? {} : { sshPort: managed.profile.transport.sshPort }), - operatorPath: managed.service.operatorPath, - rootPath: managed.service.rootPath, + operatorPath: managed.control.operatorPath, + rootPath: managed.deployment.rootPath, expectedRootId: managed.profile.rootId, }, }; @@ -265,8 +265,8 @@ export function createDesktopRuntimeHostManagement(input: { managed, transport, expectedTarget: { - serviceId: managed.service.id, - rootPath: managed.service.rootPath, + serviceId: managed.deployment.id, + rootPath: managed.deployment.rootPath, rootId: managed.profile.rootId, }, }; @@ -296,7 +296,7 @@ export function createDesktopRuntimeHostManagement(input: { ...(target.transport.sshPort === undefined ? {} : { sshPort: target.transport.sshPort }), - operatorPath: target.managed.service.operatorPath, + operatorPath: target.managed.control.operatorPath, action: 'status', expectedTarget: target.expectedTarget, capabilityRequest: RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY, @@ -337,7 +337,7 @@ export function createDesktopRuntimeHostManagement(input: { const response = await input.runPeerManagement({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.service.operatorPath, + operatorPath: managed.control.operatorPath, action: 'status', expectedTarget, }); @@ -372,7 +372,7 @@ export function createDesktopRuntimeHostManagement(input: { const response = await input.runPeerManagement({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.service.operatorPath, + operatorPath: managed.control.operatorPath, action: enabledValue ? 'enable' : 'disable', ...(enabledValue ? { coordinationRelays } : {}), expectedTarget, @@ -404,7 +404,7 @@ export function createDesktopRuntimeHostManagement(input: { const rollback = await input.runPeerManagement({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.service.operatorPath, + operatorPath: managed.control.operatorPath, action: 'disable', expectedTarget, }); @@ -494,7 +494,7 @@ export function createDesktopRuntimeHostManagement(input: { const response = await input.runServiceManagement({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.service.operatorPath, + operatorPath: managed.control.operatorPath, action: 'configure', expectedTarget, projectDirectoryRoots: roots, @@ -564,7 +564,7 @@ export function createDesktopRuntimeHostManagement(input: { ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.service.operatorPath, + operatorPath: managed.control.operatorPath, expectedTarget, }; if (policy && policy.kind !== 'manual') { @@ -596,7 +596,7 @@ export function createDesktopRuntimeHostManagement(input: { ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.service.operatorPath, + operatorPath: managed.control.operatorPath, expectedTarget, }, (phase) => input.sendProgress({ profileId, phase }), diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index 0d6fe86db7..d23e49deb4 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -70,6 +70,16 @@ const PREFERENCES_SCHEMA_VERSION = 2; const PREFERENCES_FILE = "runtime-host-profile-selection.json"; const PROFILE_FILE = "runtime-host-profiles.json"; +function serviceFromBinding( + binding: DesktopRuntimeHostManagedServiceBinding, +): DesktopRuntimeHostManagedService { + return { + id: binding.deployment.id, + rootPath: binding.deployment.rootPath, + operatorPath: binding.control.operatorPath, + }; +} + export interface DesktopRuntimeHostPreferences { readonly schemaVersion: 2; readonly defaultProfileId: string; @@ -950,7 +960,7 @@ export function createDesktopRuntimeHostProfileService(input: { if ( !(await managedServices.markUninstallingIfCurrent( expected.profile, - expected.service, + serviceFromBinding(expected), )) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); @@ -969,7 +979,7 @@ export function createDesktopRuntimeHostProfileService(input: { !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || !(await managedServices.markCleanupPendingIfCurrent( expected.profile, - expected.service, + serviceFromBinding(expected), )) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); @@ -988,7 +998,7 @@ export function createDesktopRuntimeHostProfileService(input: { !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || !(await managedServices.removeCleanupPendingIfCurrent( expected.profile, - expected.service, + serviceFromBinding(expected), )) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); @@ -1127,7 +1137,7 @@ export function createDesktopRuntimeHostProfileService(input: { await catalog.remove(profileId); if (managedBinding) { await managedServices - .removeIfCurrent(profile, managedBinding.service) + .removeIfCurrent(profile, serviceFromBinding(managedBinding)) .catch((error) => console.error("[runtime-host] removed Profile left stale service metadata:", error), ); From 57278ce5b46f0163abf1aa27053d531357b8e2d0 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 28 Aug 2026 13:10:17 +0800 Subject: [PATCH 4/7] refactor(runtime-host): unify managed lifecycle transactions Generated-by: OpenAI Codex --- .../runtime-host-local-remote-access.test.ts | 46 +- .../runtime-host-managed-services.test.ts | 75 +- .../__tests__/runtime-host-management.test.ts | 131 ++- .../__tests__/runtime-host-onboarding.test.ts | 20 +- .../runtime-host-profile-service.test.ts | 18 +- .../runtime-host-ssh-terminal.test.ts | 12 +- .../src/main/runtime-host-local-operator.ts | 11 +- .../main/runtime-host-local-remote-access.ts | 62 +- .../src/main/runtime-host-managed-services.ts | 161 ++-- .../src/main/runtime-host-management.ts | 26 + .../src/main/runtime-host-onboarding.ts | 21 +- .../src/main/runtime-host-profile-service.ts | 41 +- .../src/main/runtime-host-ssh-terminal.ts | 15 +- .../runtime-host-management-dialog.tsx | 8 +- .../runtime-host-launch-agent-service.test.ts | 59 ++ ...runtime-host-lifecycle-transaction.test.ts | 172 +++- .../runtime-host-service-manager.test.ts | 339 +------ .../src/__tests__/runtime-host-setup.test.ts | 418 ++++++++- ...runtime-host-update-reconciliation.test.ts | 27 +- packages/cli/src/cli-core.ts | 40 +- .../src/runtime-host-activation-command.ts | 17 +- packages/cli/src/runtime-host-cli.ts | 71 +- .../src/runtime-host-launch-agent-service.ts | 102 ++- .../src/runtime-host-lifecycle-transaction.ts | 423 +++++++-- .../src/runtime-host-managed-deployment.ts | 551 +++++++++++- .../runtime-host-managed-lifecycle-manager.ts | 251 ++++-- .../src/runtime-host-package-deployment.ts | 104 ++- .../runtime-host-peer-management-command.ts | 272 ++---- .../cli/src/runtime-host-service-launch.ts | 18 +- ...runtime-host-service-management-command.ts | 191 +++- .../cli/src/runtime-host-service-manager.ts | 737 +--------------- .../cli/src/runtime-host-setup-command.ts | 834 ++++++++---------- .../cli/src/runtime-host-systemd-service.ts | 60 +- .../cli/src/runtime-host-update-command.ts | 322 ++++--- .../cli/src/runtime-host-update-discovery.ts | 33 +- .../src/runtime-host-update-policy-store.ts | 14 +- .../src/runtime-host-update-reconciliation.ts | 239 +++-- .../src/__tests__/managed-deployment.test.ts | 2 + .../src/client/connect-or-spawn.ts | 4 + .../src/client/managed-activation.ts | 1 + packages/runtime-host/src/operator/index.ts | 3 + .../src/operator/managed-deployment.ts | 114 ++- .../src/operator/service-management-frame.ts | 1 + .../runtime-host/src/operator/setup-frame.ts | 1 + 44 files changed, 3570 insertions(+), 2497 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts index a21c272c8c..1fbc1844b5 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts @@ -23,8 +23,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { decodeRuntimeHostOwnerConnectionCode } from '@maka/runtime-host/client'; -import { resolveRuntimeHostManagedServiceId } from '@maka/runtime-host/operator'; import type { RuntimeHostDesktopManager } from '../runtime-host-desktop-manager.js'; + +const RECOVERY_DEPLOYMENT_ID = '33333333-3333-4333-8333-333333333333'; import { createDesktopLocalRuntimeHostRemoteAccess } from '../runtime-host-local-remote-access.js'; import type { createDesktopRuntimeHostLocalOperator } from '../runtime-host-local-operator.js'; @@ -33,7 +34,6 @@ test('enabling remote access hands the same root to one managed service before D t.after(() => rm(base, { recursive: true, force: true })); const clientDataRoot = join(base, 'client'); const rootPath = join(clientDataRoot, 'workspaces', 'default'); - const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot); await mkdir(rootPath, { recursive: true }); const handlers = new Map[1]>(); let retired = false; @@ -49,16 +49,27 @@ test('enabling remote access hands the same root to one managed service before D routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'], coordinationRelays: [], }; + const deploymentId = '11111111-1111-4111-8111-111111111111'; const operator = { - async runSetup(input: { readonly rootPath: string; readonly principalId: string }) { + async runSetup(input: { + readonly rootPath: string; + readonly principalId: string; + readonly expectedTarget: { readonly serviceId: string; readonly rootId: string }; + }) { assert.equal(retired, true); assert.equal(input.rootPath, rootPath); assert.equal(input.principalId, 'desktop-owner:local-runtime-host-sharing'); + assert.deepEqual(input.expectedTarget, { + serviceId: 'a'.repeat(64), + rootPath, + rootId: 'a'.repeat(64), + }); return { - serviceId, + serviceId: 'a'.repeat(64), operatorPath: join(base, 'operator'), rootPath, rootId: 'a'.repeat(64), + deploymentId, credential: 'pending-credential', directPeer: peer, }; @@ -109,11 +120,12 @@ test('enabling remote access hands the same root to one managed service before D transport: { kind: 'libp2p-direct', ...peer }, credential: 'pending-credential', }); - assert.equal( - JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')) - .state, - 'managed', - ); + const lifecycle = JSON.parse( + await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8'), + ) as { readonly state: string; readonly deploymentId: string }; + assert.equal(lifecycle.state, 'managed'); + assert.equal((lifecycle as { serviceId?: string }).serviceId, 'a'.repeat(64)); + assert.equal(lifecycle.deploymentId, deploymentId); }); test('revokes the one Local sharing authority without changing peer connectivity', async (t) => { @@ -192,14 +204,12 @@ test('an interrupted Local Host handoff converges to its exact managed service', const clientDataRoot = join(base, 'client'); const rootPath = join(clientDataRoot, 'workspaces', 'default'); const rootId = 'a'.repeat(64); - const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot); await mkdir(rootPath, { recursive: true }); await writeFile( join(clientDataRoot, 'runtime-host-local-service.json'), `${JSON.stringify({ schemaVersion: 1, state: 'handoff', - serviceId, rootPath, rootId, coordinationRelays: [], @@ -225,10 +235,11 @@ test('an interrupted Local Host handoff converges to its exact managed service', async runSetup() { setupCalls += 1; return { - serviceId, + serviceId: rootId, operatorPath: join(base, 'operator'), rootPath, rootId, + deploymentId: '22222222-2222-4222-8222-222222222222', credential: 'unused-pending-credential', directPeer: { peerId: '12D3KooWpeer', @@ -268,6 +279,7 @@ test('startup replays the persisted peer intent instead of gating recovery on st operatorPath: join(clientDataRoot, 'operator'), rootPath, rootId, + deploymentId: RECOVERY_DEPLOYMENT_ID, peerEnabled: true, coordinationRelays: ['/dns4/discovery.example/udp/443/quic-v1'], allowInterruptActiveTasks: false, @@ -401,10 +413,12 @@ test('startup completes an exact persisted uninstall intent after Desktop interr operatorPath: join(base, 'operator'), rootPath, rootId, + deploymentId: RECOVERY_DEPLOYMENT_ID, allowInterruptActiveTasks: false, })}\n`, ); const actions: string[] = []; + const cleanupPhases: boolean[] = []; const service = createDesktopLocalRuntimeHostRemoteAccess({ ipcMain: { handle() {}, removeHandler() {} }, clientDataRoot, @@ -433,8 +447,9 @@ test('startup completes an exact persisted uninstall intent after Desktop interr service: { state: 'not_installed' }, }; }, - async cleanupManagedDeployment() { + async cleanupManagedDeployment(input: { readonly finalize?: boolean }) { actions.push('cleanup'); + cleanupPhases.push(input.finalize ?? false); }, async close() {}, } as unknown as ReturnType, @@ -442,7 +457,8 @@ test('startup completes an exact persisted uninstall intent after Desktop interr t.after(() => service.close()); await service.recover(); - assert.deepEqual(actions, ['uninstall', 'cleanup']); + assert.deepEqual(actions, ['uninstall', 'cleanup', 'cleanup']); + assert.deepEqual(cleanupPhases, [false, true]); await assert.rejects(readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8'), { code: 'ENOENT', }); @@ -463,6 +479,7 @@ test('startup resumes deployment cleanup without repeating a completed uninstall operatorPath: join(base, 'operator'), rootPath, rootId: 'a'.repeat(64), + deploymentId: RECOVERY_DEPLOYMENT_ID, allowInterruptActiveTasks: false, })}\n`, ); @@ -508,6 +525,7 @@ async function writeManagedLifecycle( operatorPath: join(clientDataRoot, 'operator'), rootPath, rootId, + deploymentId: RECOVERY_DEPLOYMENT_ID, })}\n`, ); } diff --git a/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts b/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts index d1b0a6aee1..fc044de4d0 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts @@ -27,6 +27,10 @@ import { createDesktopRuntimeHostManagedServiceStore, findDesktopRuntimeHostManagedServiceBinding, } from "../runtime-host-managed-services.js"; +import { + createDesktopRuntimeHostProfileService, + resolveDesktopRuntimeHostStartup, +} from "../runtime-host-profile-service.js"; const roots: string[] = []; const profile = { @@ -46,6 +50,11 @@ const service = { rootPath: "/srv/maka", operatorPath: "/home/operator/.local/share/maka/operator", }; +const deploymentId = "11111111-1111-4111-8111-111111111111"; +const deployedService = { + deployment: { id: service.id, rootPath: service.rootPath, deploymentId }, + control: { kind: "ssh_operator" as const, operatorPath: service.operatorPath }, +}; afterEach(async () => { await Promise.all( @@ -57,27 +66,59 @@ test("keeps Desktop service bindings outside the shared profile catalog", async const root = await mkdtemp(join(tmpdir(), "maka-managed-host-services-")); roots.push(root); const catalog = createClientRuntimeHostProfileCatalog(root); + const legacyPath = join(root, "runtime-host-managed-services.json"); + const legacyDocument = `${JSON.stringify({ + schemaVersion: 1, + bindings: [{ profile, service, state: "uninstalling" }], + })}\n`; await writeFile( - join(root, "runtime-host-managed-services.json"), - `${JSON.stringify({ - schemaVersion: 1, - bindings: [{ profile, service, state: "active" }], - })}\n`, + legacyPath, + legacyDocument, ); const managedServices = createDesktopRuntimeHostManagedServiceStore(root); const concurrentStore = createDesktopRuntimeHostManagedServiceStore(root); await catalog.create(profile, "secret"); assert.equal((await managedServices.read()).bindings[0]?.deployment.id, service.id); - await assert.rejects(readFile(join(root, "runtime-host-managed-services.json"), "utf8"), { + await assert.rejects(readFile(legacyPath, "utf8"), { code: "ENOENT", }); + await writeFile(legacyPath, legacyDocument); + await managedServices.read(); + await assert.rejects(readFile(legacyPath, "utf8"), { code: "ENOENT" }); + + const profileService = createDesktopRuntimeHostProfileService({ + clientDataRoot: root, + startup: await resolveDesktopRuntimeHostStartup(root, { catalog }), + catalog, + managedServices, + states: () => [], + enable: async () => undefined, + disable: async () => undefined, + setDefault: () => undefined, + finalizePairing: async () => undefined, + }); + const legacyUninstall = await profileService.resolveManagedService(profile.id); + assert.ok(legacyUninstall); + assert.equal(legacyUninstall.deployment.deploymentId, undefined); + assert.equal(legacyUninstall.state, "uninstalling"); + assert.equal( + (await profileService.markManagedServiceUninstalling(legacyUninstall)).state, + "uninstalling", + ); await Promise.all([ - managedServices.save(profile, service), + managedServices.save(profile, deployedService), concurrentStore.save( { ...profile, id: "lab", rootId: "d".repeat(64) }, - { ...service, id: "e".repeat(64) }, + { + ...deployedService, + deployment: { + ...deployedService.deployment, + id: "e".repeat(64), + deploymentId: "22222222-2222-4222-8222-222222222222", + }, + }, ), ]); @@ -92,7 +133,7 @@ test("keeps Desktop service bindings outside the shared profile catalog", async ), { profile: { ...profile, transport: { ...profile.transport } }, - deployment: { id: service.id, rootPath: service.rootPath }, + deployment: { id: service.id, rootPath: service.rootPath, deploymentId }, control: { kind: "ssh_operator", operatorPath: service.operatorPath }, state: "active", }, @@ -108,10 +149,12 @@ test("keeps Desktop service bindings outside the shared profile catalog", async }), undefined, ); - assert.equal( - await managedServices.markUninstallingIfCurrent(profile, service), - true, + const binding = findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + profile, ); + assert.ok(binding); + assert.equal(await managedServices.markUninstallingIfCurrent(binding), true); assert.equal( findDesktopRuntimeHostManagedServiceBinding( await managedServices.read(), @@ -120,11 +163,11 @@ test("keeps Desktop service bindings outside the shared profile catalog", async "uninstalling", ); assert.equal( - await managedServices.removeCleanupPendingIfCurrent(profile, service), + await managedServices.removeCleanupPendingIfCurrent(binding), false, ); assert.equal( - await managedServices.markCleanupPendingIfCurrent(profile, service), + await managedServices.markCleanupPendingIfCurrent(binding), true, ); assert.equal( @@ -135,11 +178,11 @@ test("keeps Desktop service bindings outside the shared profile catalog", async "cleanup_pending", ); assert.equal( - await managedServices.markUninstallingIfCurrent(profile, service), + await managedServices.markUninstallingIfCurrent(binding), false, ); assert.equal( - await managedServices.removeCleanupPendingIfCurrent(profile, service), + await managedServices.removeCleanupPendingIfCurrent(binding), true, ); }); 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 44ac1b4828..b82385efd2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -34,6 +34,8 @@ import type { DesktopRuntimeHostSshUpdateReconciliationInput, } from '../runtime-host-ssh-terminal.js'; +const DEPLOYMENT_ID = '11111111-1111-4111-8111-111111111111'; + test('identifies, rotates, and revokes managed credentials without exposing secrets', async () => { const handlers = new Map unknown>(); const profile = { @@ -83,7 +85,11 @@ test('identifies, rotates, and revokes managed credentials without exposing secr }), rotateManagedCredential: async (expected, credential) => { assert.equal(expected.profile, profile); - assert.deepEqual(expected.deployment, { id: service.id, rootPath: service.rootPath }); + assert.deepEqual(expected.deployment, { + id: service.id, + rootPath: service.rootPath, + deploymentId: DEPLOYMENT_ID, + }); assert.deepEqual(expected.control, { kind: 'ssh_operator', operatorPath: service.operatorPath, @@ -296,6 +302,7 @@ test('manages only the service identity bound by Desktop onboarding', async () = serviceId: managedService.id, rootPath: managedService.rootPath, rootId: managedProfile.rootId, + deploymentId: DEPLOYMENT_ID, }, }); @@ -320,17 +327,32 @@ test('manages only the service identity bound by Desktop onboarding', async () = 'uninstall-service', 'mark-cleanup-pending', 'cleanup-deployment', + 'cleanup-deployment', 'clear-binding', ]); - assert.deepEqual(cleanupInputs, [{ - destination: managedProfile.transport.destination, - operatorPath: managedService.operatorPath, - expectedTarget: { - serviceId: managedService.id, - rootPath: managedService.rootPath, - rootId: managedProfile.rootId, + assert.deepEqual(cleanupInputs, [ + { + destination: managedProfile.transport.destination, + operatorPath: managedService.operatorPath, + expectedTarget: { + serviceId: managedService.id, + rootPath: managedService.rootPath, + rootId: managedProfile.rootId, + deploymentId: DEPLOYMENT_ID, + }, }, - }]); + { + destination: managedProfile.transport.destination, + operatorPath: managedService.operatorPath, + expectedTarget: { + serviceId: managedService.id, + rootPath: managedService.rootPath, + rootId: managedProfile.rootId, + deploymentId: DEPLOYMENT_ID, + }, + finalize: true, + }, + ]); management.close(); assert.equal(handlers.size, 0); }); @@ -425,6 +447,7 @@ test('publishes update progress and waits for the managed profile to reconnect', serviceId: service.id, rootPath: service.rootPath, rootId: profile.rootId, + deploymentId: DEPLOYMENT_ID, }, }]); assert.deepEqual(progress, [ @@ -634,6 +657,7 @@ test('manages one Host update policy and reconciles it through the bound operato serviceId: service.id, rootPath: service.rootPath, rootId: profile.rootId, + deploymentId: DEPLOYMENT_ID, }, }, service: serviceSummary('1.3.0'), @@ -670,6 +694,7 @@ test('manages one Host update policy and reconciles it through the bound operato serviceId: service.id, rootPath: service.rootPath, rootId: profile.rootId, + deploymentId: DEPLOYMENT_ID, }, schedulingState: 'ready', }, @@ -683,6 +708,7 @@ test('manages one Host update policy and reconciles it through the bound operato serviceId: service.id, rootPath: service.rootPath, rootId: profile.rootId, + deploymentId: DEPLOYMENT_ID, }); } assert.deepEqual(reconciliationInputs, []); @@ -707,13 +733,14 @@ test('manages one Host update policy and reconciles it through the bound operato serviceId: service.id, rootPath: service.rootPath, rootId: profile.rootId, + deploymentId: DEPLOYMENT_ID, }, }]); assert.deepEqual(progress, [{ profileId: profile.id, phase: 'replacing' }]); assert.deepEqual(connections, [[profile.id, profile.rootId, 'host-before-update', true]]); }); -test('resumes deployment cleanup without invoking the removed operator', async () => { +test('retries acknowledged deployment cleanup without repeating uninstall', async () => { const handlers = new Map unknown>(); const profile = { id: 'office', @@ -733,8 +760,6 @@ test('resumes deployment cleanup without invoking the removed operator', async ( operatorPath: '/home/operator/.local/share/maka/operator', }; const calls: DesktopRuntimeHostSshManagementInput[] = []; - let cleanups = 0; - let state: 'active' | 'uninstalling' | 'cleanup_pending' = 'active'; let clearAttempts = 0; createDesktopRuntimeHostManagement({ ...unusedUpdateDependencies(), @@ -744,16 +769,21 @@ test('resumes deployment cleanup without invoking the removed operator', async ( }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => managedBinding(profile, service, state), - resolveManagedAccess: async () => undefined, - markManagedServiceUninstalling: async (binding) => { - state = 'uninstalling'; - return { ...binding, state }; - }, - markManagedServiceCleanupPending: async (binding) => { - state = 'cleanup_pending'; - return { ...binding, state }; + resolveManagedService: async () => { + const binding = managedBinding(profile, service, 'cleanup_pending'); + return { + ...binding, + deployment: { + id: binding.deployment.id, + rootPath: binding.deployment.rootPath, + }, + }; }, + resolveManagedAccess: async () => undefined, + markManagedServiceUninstalling: async () => + assert.fail('remote uninstall must not repeat'), + markManagedServiceCleanupPending: async () => + assert.fail('cleanup intent is already acknowledged'), clearManagedServiceBinding: async () => { clearAttempts += 1; if (clearAttempts === 1) throw new Error('local metadata is unavailable'); @@ -765,9 +795,7 @@ test('resumes deployment cleanup without invoking the removed operator', async ( return serviceResult(input.action); }, runAccessManagement: async () => assert.fail('access management is not expected'), - cleanupManagedDeployment: async () => { - cleanups += 1; - }, + cleanupManagedDeployment: async () => undefined, }); const run = handlers.get('runtime-host-management:run'); @@ -776,14 +804,12 @@ test('resumes deployment cleanup without invoking the removed operator', async ( run({}, profile.id, 'uninstall') as Promise, /local metadata is unavailable/u, ); - assert.equal(calls.length, 1); - assert.equal(calls[0]?.retainManagedDeployment, true); + assert.equal(calls.length, 0); assert.deepEqual(await run({}, profile.id, 'uninstall'), { kind: 'uninstalled', retainedStateRoot: service.rootPath, }); - assert.equal(calls.length, 1); - assert.equal(cleanups, 2); + assert.equal(calls.length, 0); }); test('rechecks uninstall intent before retrying the remote service', async () => { @@ -797,26 +823,35 @@ test('rechecks uninstall intent before retrying the remote service', async () => }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => managedBinding( - { - id: 'office', - name: 'Office', - kind: 'remote' as const, - rootId: 'a'.repeat(64), - transport: { - kind: 'ssh' as const, - destination: 'operator@example.com', - remotePort: 7443, - websocketPath: '/runtime-host', + resolveManagedService: async () => { + const binding = managedBinding( + { + id: 'office', + name: 'Office', + kind: 'remote' as const, + rootId: 'a'.repeat(64), + transport: { + kind: 'ssh' as const, + destination: 'operator@example.com', + remotePort: 7443, + websocketPath: '/runtime-host', + }, }, - }, - { - id: 'b'.repeat(64), - rootPath: '/srv/maka', - operatorPath: '/home/operator/.local/share/maka/operator', - }, - 'uninstalling', - ), + { + id: 'b'.repeat(64), + rootPath: '/srv/maka', + operatorPath: '/home/operator/.local/share/maka/operator', + }, + 'uninstalling', + ); + return { + ...binding, + deployment: { + id: binding.deployment.id, + rootPath: binding.deployment.rootPath, + }, + }; + }, resolveManagedAccess: async () => undefined, markManagedServiceUninstalling: async (binding) => { marked = true; @@ -1080,7 +1115,7 @@ function managedBinding< >(profile: Profile, service: Service, state: State) { return { profile, - deployment: { id: service.id, rootPath: service.rootPath }, + deployment: { id: service.id, rootPath: service.rootPath, deploymentId: DEPLOYMENT_ID }, control: { kind: 'ssh_operator' as const, operatorPath: service.operatorPath }, state, }; diff --git a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts index 6f3c4c9f46..d90583a9ce 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts @@ -20,14 +20,14 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import type { DesktopRuntimeHostProfileAddInput } from '../../preload/bridge-contract.js'; -import type { DesktopRuntimeHostManagedService } from '../runtime-host-managed-services.js'; +import type { DesktopRuntimeHostManagedServiceTarget } from '../runtime-host-managed-services.js'; import { createDesktopRuntimeHostOnboarding } from '../runtime-host-onboarding.js'; test('persists a verified on-demand SSH profile without endpoint or credential projection', async () => { let setupInput: unknown; let saved: | (DesktopRuntimeHostProfileAddInput & { - readonly managedService?: DesktopRuntimeHostManagedService; + readonly managedService?: DesktopRuntimeHostManagedServiceTarget; }) | undefined; const harness = createHarness({ @@ -42,6 +42,7 @@ test('persists a verified on-demand SSH profile without endpoint or credential p onProgress({ phase: 'installing_service' }); return { serviceId: 'b'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', rootPath: '/home/operator/.config/Maka/workspaces/default', operatorPath: '/home/operator/.local/share/maka/operator', rootId: 'a'.repeat(64), @@ -67,7 +68,17 @@ test('persists a verified on-demand SSH profile without endpoint or credential p operatorPath: '/home/operator/.local/share/maka/operator', }, }); - assert.equal(saved?.managedService, undefined); + assert.deepEqual(saved?.managedService, { + deployment: { + id: 'b'.repeat(64), + rootPath: '/home/operator/.config/Maka/workspaces/default', + deploymentId: '00000000-0000-4000-8000-000000000001', + }, + control: { + kind: 'ssh_operator', + operatorPath: '/home/operator/.local/share/maka/operator', + }, + }); assert.equal(saved?.credential, 'secret-access-token'); assert.deepEqual( (setupInput as { projectDirectoryRoots?: unknown }).projectDirectoryRoots, @@ -122,6 +133,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn let completeReceived = false; let finishSetup!: (value: { serviceId: string; + deploymentId: string; rootPath: string; operatorPath: string; rootId: string; @@ -130,6 +142,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn }) => void; const setupDrain = new Promise<{ serviceId: string; + deploymentId: string; rootPath: string; operatorPath: string; rootId: string; @@ -160,6 +173,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn finishSetup({ serviceId: 'b'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', rootPath: '/home/operator/.config/Maka/workspaces/default', operatorPath: '/home/operator/.local/share/maka/operator', rootId: 'a'.repeat(64), diff --git a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts index 219cbded36..8af1443e71 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts @@ -69,9 +69,15 @@ const MANAGED_PROFILE = { }, }; const MANAGED_SERVICE = { - id: "c".repeat(64), - rootPath: "/srv/maka", - operatorPath: "/home/operator/.local/share/maka/operator", + deployment: { + id: "c".repeat(64), + rootPath: "/srv/maka", + deploymentId: "11111111-1111-4111-8111-111111111111", + }, + control: { + kind: "ssh_operator" as const, + operatorPath: "/home/operator/.local/share/maka/operator", + }, }; const READY_PROFILE = { id: "backup", @@ -782,8 +788,10 @@ test("does not rotate a managed credential after its profile target changes", as }; const replacementService = { ...MANAGED_SERVICE, - id: "e".repeat(64), - rootPath: "/srv/other-maka", + deployment: { + id: "e".repeat(64), + rootPath: "/srv/other-maka", + }, }; await catalog.remove(MANAGED_PROFILE.id); await catalog.create(replacementProfile, "other-token"); 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 93e52e761b..3bbc6c5649 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 @@ -118,6 +118,7 @@ test('keeps setup credentials out of the interactive terminal projection', async kind: 'complete', version: '0.1.0-beta.1', serviceId: 'b'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', operatorPath: '/home/operator/.local/share/maka/operator', rootPath: '/home/operator/.config/Maka/workspaces/default', rootId: 'a'.repeat(64), @@ -183,6 +184,7 @@ test('keeps a completed setup process owned until it exits', async () => { kind: 'complete', version: '1.2.3', serviceId: 'b'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', operatorPath: '/home/operator/.local/share/maka/operator', rootPath: '/home/operator/.config/Maka/workspaces/default', rootId: 'a'.repeat(64), @@ -417,6 +419,7 @@ test('runs an exact update package and reports progress before an active-work re serviceId: 'b'.repeat(64), rootPath: '/srv/maka', rootId: 'a'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', }, }, (phase) => phases.push(phase), @@ -425,6 +428,11 @@ test('runs an exact update package and reports progress before an active-work re const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? ''; assert.match(remoteCommand, /--package.*maka-agent@1\.3\.0/u); assert.match(remoteCommand, /runtime-host.*service.*update/u); + assert.match(remoteCommand, /--managed-root-id.*a{64}/u); + assert.match( + remoteCommand, + /--operator-deployment-id.*00000000-0000-4000-8000-000000000001/u, + ); assert.match(remoteCommand, /MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST/u); harness.pty.emitData('Password: '); harness.pty.emitData( @@ -635,7 +643,7 @@ test('rejects a framed service result for a different action', async () => { await harness.terminal.close(); }); -test('requires an absent operator deployment root to be absent or empty', async () => { +test('requires an absent operator deployment root to be absent', async () => { const harness = createHarness('pending'); const cleanup = harness.terminal.cleanupManagedDeployment({ destination: 'operator@example.com', @@ -649,7 +657,7 @@ test('requires an absent operator deployment root to be absent or empty', async await waitFor(() => harness.pty.hasDataListener()); const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? ''; assert.match(remoteCommand, /if \[ ! -e/u); - assert.match(remoteCommand, /rmdir --/u); + assert.doesNotMatch(remoteCommand, /rmdir --/u); assert.match(remoteCommand, /home\/operator\/\.local\/share\/maka/u); assert.match(remoteCommand, /__cleanup-managed-deployment/u); assert.match(remoteCommand, /--expected-service-id/u); diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts index a6dd2c6d25..d4e03524eb 100644 --- a/apps/desktop/src/main/runtime-host-local-operator.ts +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -56,6 +56,7 @@ export interface DesktopRuntimeHostLocalServiceTarget { readonly serviceId: string; readonly rootPath: string; readonly rootId: string; + readonly deploymentId?: string; } export interface DesktopRuntimeHostLocalSetupInput { @@ -160,6 +161,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { cleanupManagedDeployment(input: { readonly operatorPath: string; readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly finalize?: boolean; readonly signal?: AbortSignal; }): Promise; close(): Promise; @@ -293,7 +295,11 @@ export function createDesktopRuntimeHostLocalOperator(input: { await runExitProcess({ command: { executable: command.operatorPath, - args: ['__cleanup-managed-deployment', ...managedTargetArgs(command.target)], + args: [ + '__cleanup-managed-deployment', + ...(command.finalize ? ['--finalize'] : []), + ...managedTargetArgs(command.target), + ], }, label: 'Local Runtime Host deployment cleanup', environment: input.environment ?? process.env, @@ -381,6 +387,9 @@ function managedTargetArgs(target: DesktopRuntimeHostLocalServiceTarget): string target.rootPath, '--expected-root-id', target.rootId, + ...(target.deploymentId + ? ['--expected-deployment-id', target.deploymentId] + : []), ]; } diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts index 5a1117a980..0496147dae 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -26,7 +26,6 @@ import { consumeAccessCredentialDelivery, encodeRuntimeHostOwnerConnectionCode, } from '@maka/runtime-host/client'; -import { resolveRuntimeHostManagedServiceId } from '@maka/runtime-host/operator'; import { REMOTE_OWNER_OPERATION_GRANTS } from '@maka/runtime-host/protocol'; import type { DesktopLocalRuntimeHostRemoteAccessEnableResult, @@ -43,6 +42,8 @@ import type { DesktopRuntimeHostSetupPackage } from './runtime-host-ssh-terminal const LIFECYCLE_FILE = 'runtime-host-local-service.json'; const SERVICE_ID_PATTERN = /^[a-f0-9]{64}$/u; const ROOT_ID_PATTERN = /^[a-f0-9]{64}$/u; +const DEPLOYMENT_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; const ADDRESS_MAX_BYTES = 2 * 1024; const ADDRESS_MAX_COUNT = 16; const LOCAL_REMOTE_ACCESS_PRINCIPAL_ID = 'desktop-owner:local-runtime-host-sharing'; @@ -50,12 +51,12 @@ const LOCAL_REMOTE_ACCESS_PRINCIPAL_ID = 'desktop-owner:local-runtime-host-shari interface LocalServiceTarget extends DesktopRuntimeHostLocalServiceTarget { readonly schemaVersion: 1; readonly operatorPath: string; + readonly deploymentId: string; } interface LocalServiceHandoff { readonly schemaVersion: 1; readonly state: 'handoff'; - readonly serviceId: string; readonly rootPath: string; readonly rootId: string; readonly coordinationRelays: readonly string[]; @@ -201,7 +202,6 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { const handoff: LocalServiceHandoff = { schemaVersion: 1, state: 'handoff', - serviceId: resolveRuntimeHostManagedServiceId(input.clientDataRoot), rootPath: input.rootPath, rootId: input.rootId, coordinationRelays: request.coordinationRelays, @@ -254,15 +254,20 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { rootPath: handoff.rootPath, principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, coordinationRelays: handoff.coordinationRelays, - expectedTarget: handoff, + expectedTarget: { + serviceId: handoff.rootId, + rootPath: handoff.rootPath, + rootId: handoff.rootId, + }, signal: closing.signal, }, () => undefined, ); if ( - complete.serviceId !== handoff.serviceId || + complete.serviceId !== handoff.rootId || complete.rootPath !== handoff.rootPath || complete.rootId !== handoff.rootId || + !DEPLOYMENT_ID_PATTERN.test(complete.deploymentId) || !complete.directPeer ) { throw new Error('Local Runtime Host setup returned an unrelated service'); @@ -274,6 +279,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { operatorPath: complete.operatorPath, rootPath: complete.rootPath, rootId: complete.rootId, + deploymentId: complete.deploymentId, }, handoff.rootPath, ); @@ -435,6 +441,12 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { target: intent, signal: closing.signal, }); + await input.operator.cleanupManagedDeployment({ + operatorPath: intent.operatorPath, + target: intent, + finalize: true, + signal: closing.signal, + }); await removeDocument(lifecyclePath); return { kind: 'uninstalled' }; }; @@ -657,6 +669,8 @@ function requireServiceTarget(value: unknown, rootPath: string): LocalServiceTar !SERVICE_ID_PATTERN.test(value.serviceId) || typeof value.rootId !== 'string' || !ROOT_ID_PATTERN.test(value.rootId) || + typeof value.deploymentId !== 'string' || + !DEPLOYMENT_ID_PATTERN.test(value.deploymentId) || value.rootPath !== rootPath || typeof value.operatorPath !== 'string' || !isAbsolute(value.operatorPath) @@ -669,6 +683,7 @@ function requireServiceTarget(value: unknown, rootPath: string): LocalServiceTar rootPath, rootId: value.rootId, operatorPath: value.operatorPath, + deploymentId: value.deploymentId, }; } @@ -687,6 +702,7 @@ function managedLifecycle(intent: LocalServiceTarget): LocalServiceManaged { operatorPath: intent.operatorPath, rootPath: intent.rootPath, rootId: intent.rootId, + deploymentId: intent.deploymentId, }; } @@ -714,15 +730,12 @@ async function readLifecycle( assertExactKeys(value, [ 'schemaVersion', 'state', - 'serviceId', 'rootPath', 'rootId', 'coordinationRelays', 'allowInterruptActiveTasks', ]); if ( - typeof value.serviceId !== 'string' || - !SERVICE_ID_PATTERN.test(value.serviceId) || typeof value.allowInterruptActiveTasks !== 'boolean' ) { throw new Error('Local Runtime Host handoff intent is invalid'); @@ -730,7 +743,6 @@ async function readLifecycle( return { schemaVersion: 1, state: 'handoff', - serviceId: value.serviceId, rootPath, rootId, coordinationRelays: requireAddresses(value.coordinationRelays), @@ -738,36 +750,28 @@ async function readLifecycle( }; } const target = requireServiceTarget(value, rootPath); + const targetKeys = [ + 'schemaVersion', + 'state', + 'serviceId', + 'operatorPath', + 'rootPath', + 'rootId', + 'deploymentId', + ]; assertExactKeys( value, value.state === 'managed' - ? [ - 'schemaVersion', - 'state', - 'serviceId', - 'operatorPath', - 'rootPath', - 'rootId', - ] + ? targetKeys : value.state === 'peerChanging' ? [ - 'schemaVersion', - 'state', - 'serviceId', - 'operatorPath', - 'rootPath', - 'rootId', + ...targetKeys, 'peerEnabled', 'coordinationRelays', 'allowInterruptActiveTasks', ] : [ - 'schemaVersion', - 'state', - 'serviceId', - 'operatorPath', - 'rootPath', - 'rootId', + ...targetKeys, 'allowInterruptActiveTasks', ], ); diff --git a/apps/desktop/src/main/runtime-host-managed-services.ts b/apps/desktop/src/main/runtime-host-managed-services.ts index 5d8bd58eed..2c9202543d 100644 --- a/apps/desktop/src/main/runtime-host-managed-services.ts +++ b/apps/desktop/src/main/runtime-host-managed-services.ts @@ -34,15 +34,10 @@ const DOCUMENT_MAX_BYTES = 256 * 1024; const BINDING_COUNT_MAX = 32; const PATH_MAX_BYTES = 4 * 1024; -export interface DesktopRuntimeHostManagedService { - readonly id: string; - readonly rootPath: string; - readonly operatorPath: string; -} - export interface DesktopRuntimeHostDeploymentBinding { readonly id: string; readonly rootPath: string; + readonly deploymentId?: string; } export interface DesktopRuntimeHostControlRoute { @@ -50,6 +45,11 @@ export interface DesktopRuntimeHostControlRoute { readonly operatorPath: string; } +export interface DesktopRuntimeHostManagedServiceTarget { + readonly deployment: DesktopRuntimeHostDeploymentBinding; + readonly control: DesktopRuntimeHostControlRoute; +} + export interface DesktopRuntimeHostManagedServiceBinding { readonly profile: RemoteRuntimeHostProfile; readonly deployment: DesktopRuntimeHostDeploymentBinding; @@ -66,26 +66,22 @@ export interface DesktopRuntimeHostManagedServiceStore { read(): Promise; save( profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + target: DesktopRuntimeHostManagedServiceTarget, ): Promise; removeIfCurrent( - profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, ): Promise; removeForProfileIfCurrent( profile: RemoteRuntimeHostProfile, ): Promise; markUninstallingIfCurrent( - profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, ): Promise; markCleanupPendingIfCurrent( - profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, ): Promise; removeCleanupPendingIfCurrent( - profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, ): Promise; } @@ -150,18 +146,20 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan } const migrated = decodeLegacyDocument(JSON.parse(contents)); await writeDocument(this.#path, migrated); - await rm(this.#legacyPath, { force: true }); + await removeLegacyDocument(this.#legacyPath); return migrated; } if (Buffer.byteLength(contents, "utf8") > DOCUMENT_MAX_BYTES) { throw new Error("Runtime Host managed service document is too large"); } - return decodeDocument(JSON.parse(contents)); + const document = decodeDocument(JSON.parse(contents)); + await removeLegacyDocument(this.#legacyPath); + return document; } save( value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + managedTarget: DesktopRuntimeHostManagedServiceTarget, ): Promise { const profile = decodeRemoteRuntimeHostProfile(value); if (profile.transport.kind !== "ssh") { @@ -169,7 +167,8 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan new Error("A managed Runtime Host service requires SSH"), ); } - const service = decodeService(managedService); + const deployment = decodeDeployment(managedTarget.deployment); + const control = decodeControlRoute(managedTarget.control); return this.#exclusive(async () => { const current = await this.#readUnlocked(); const bindings = current.bindings.filter( @@ -186,11 +185,8 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan ...bindings, { profile, - deployment: { id: service.id, rootPath: service.rootPath }, - control: { - kind: "ssh_operator", - operatorPath: service.operatorPath, - }, + deployment, + control, state: "active", }, ], @@ -199,58 +195,45 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan } markUninstallingIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, ): Promise { return this.#setStateIfCurrent( - value, - managedService, + binding, ["active", "uninstalling"], "uninstalling", ); } markCleanupPendingIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, ): Promise { return this.#setStateIfCurrent( - value, - managedService, + binding, ["uninstalling", "cleanup_pending"], "cleanup_pending", ); } removeCleanupPendingIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, ): Promise { - return this.#remove( - decodeRemoteRuntimeHostProfile(value), - decodeService(managedService), - "cleanup_pending", - ); + return this.#remove(binding, "cleanup_pending"); } - removeIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, - ): Promise { - const profile = decodeRemoteRuntimeHostProfile(value); - const service = decodeService(managedService); - return this.#remove(profile, service); + removeIfCurrent(binding: DesktopRuntimeHostManagedServiceBinding): Promise { + return this.#remove(binding); } removeForProfileIfCurrent(value: RemoteRuntimeHostProfile): Promise { - return this.#remove(decodeRemoteRuntimeHostProfile(value)); + return this.#remove(undefined, undefined, decodeRemoteRuntimeHostProfile(value)); } #remove( - profile: RemoteRuntimeHostProfile, - service?: DesktopRuntimeHostManagedService, + expected?: DesktopRuntimeHostManagedServiceBinding, state?: DesktopRuntimeHostManagedServiceBinding["state"], + profileOverride?: RemoteRuntimeHostProfile, ): Promise { + const profile = expected?.profile ?? profileOverride!; return this.#exclusive(async () => { const current = await this.#readUnlocked(); const binding = current.bindings.find( @@ -259,7 +242,7 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan if ( !binding || !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) || - (service && !sameServiceBinding(binding, service)) || + (expected && !sameBindingTarget(binding, expected)) || (state && binding.state !== state) ) { return false; @@ -275,13 +258,11 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan } #setStateIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + expected: DesktopRuntimeHostManagedServiceBinding, allowedStates: readonly DesktopRuntimeHostManagedServiceBinding["state"][], state: DesktopRuntimeHostManagedServiceBinding["state"], ): Promise { - const profile = decodeRemoteRuntimeHostProfile(value); - const service = decodeService(managedService); + const profile = expected.profile; return this.#exclusive(async () => { const current = await this.#readUnlocked(); const binding = current.bindings.find( @@ -290,7 +271,7 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan if ( !binding || !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) || - !sameServiceBinding(binding, service) || + !sameBindingTarget(binding, expected) || !allowedStates.includes(binding.state) ) { return false; @@ -388,7 +369,7 @@ function decodeLegacyDocument( "Legacy Runtime Host service binding", ["profile", "service", "state"], ); - const service = decodeService(binding.service); + const service = decodeLegacyService(binding.service); return { profile: binding.profile, deployment: { id: service.id, rootPath: service.rootPath }, @@ -400,13 +381,22 @@ function decodeLegacyDocument( } function decodeDeployment(value: unknown): DesktopRuntimeHostDeploymentBinding { - const record = requireExactRecord(value, "Managed Runtime Host deployment", [ - "id", - "rootPath", - ]); + const hasDeploymentId = + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.hasOwn(value, "deploymentId"); + const record = requireExactRecord( + value, + "Managed Runtime Host deployment", + hasDeploymentId ? ["deploymentId", "id", "rootPath"] : ["id", "rootPath"], + ); return Object.freeze({ id: requireHostRootId(record.id), rootPath: requirePath(record.rootPath, "Managed Runtime Host State Root"), + ...(record.deploymentId === undefined + ? {} + : { deploymentId: requireDeploymentId(record.deploymentId) }), }); } @@ -429,12 +419,16 @@ function decodeControlRoute(value: unknown): DesktopRuntimeHostControlRoute { return Object.freeze({ kind: "ssh_operator", operatorPath }); } -function decodeService(value: unknown): DesktopRuntimeHostManagedService { - const record = requireExactRecord(value, "Managed Runtime Host service", [ - "id", - "rootPath", - "operatorPath", - ]); +function decodeLegacyService(value: unknown): { + readonly id: string; + readonly rootPath: string; + readonly operatorPath: string; +} { + const record = requireExactRecord( + value, + "Managed Runtime Host service", + ["id", "operatorPath", "rootPath"], + ); const rootPath = requirePath( record.rootPath, "Managed Runtime Host State Root", @@ -453,6 +447,18 @@ function decodeService(value: unknown): DesktopRuntimeHostManagedService { }); } +function requireDeploymentId(value: unknown): string { + if ( + typeof value !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test( + value, + ) + ) { + throw new Error("Managed Runtime Host deployment identity is invalid"); + } + return value; +} + function requirePath(value: unknown, label: string): string { if ( typeof value !== "string" || @@ -485,18 +491,6 @@ function requireExactRecord( return record; } -function sameServiceBinding( - binding: DesktopRuntimeHostManagedServiceBinding, - service: DesktopRuntimeHostManagedService, -): boolean { - return ( - binding.deployment.id === service.id && - binding.deployment.rootPath === service.rootPath && - binding.control.kind === "ssh_operator" && - binding.control.operatorPath === service.operatorPath - ); -} - function sameBindingTarget( left: DesktopRuntimeHostManagedServiceBinding, right: DesktopRuntimeHostManagedServiceBinding, @@ -504,6 +498,7 @@ function sameBindingTarget( return ( left.deployment.id === right.deployment.id && left.deployment.rootPath === right.deployment.rootPath && + left.deployment.deploymentId === right.deployment.deploymentId && left.control.kind === right.control.kind && left.control.operatorPath === right.control.operatorPath ); @@ -539,3 +534,13 @@ async function writeDocument( await rm(temporaryPath, { force: true }); } } + +async function removeLegacyDocument(path: string): Promise { + try { + await rm(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + await syncDirectory(dirname(path)); +} diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index c212529d8c..3b82eb0b9f 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -144,6 +144,16 @@ export function createDesktopRuntimeHostManagement(input: { if (managed.state !== 'active' && managementAction !== 'uninstall') { throw new Error('Finish uninstalling this Runtime Host service before managing it'); } + if ( + managementAction !== 'status' && + managementAction !== 'logs' && + !deployment.deploymentId && + !(managementAction === 'uninstall' && managed.state !== 'active') + ) { + throw new Error( + 'Re-onboard this Runtime Host before changing it; its legacy binding has no deployment generation', + ); + } const managementInput: DesktopRuntimeHostSshManagementInput = { destination: profile.transport.destination, ...(profile.transport.sshPort === undefined ? {} : { sshPort: profile.transport.sshPort }), @@ -153,6 +163,7 @@ export function createDesktopRuntimeHostManagement(input: { serviceId: deployment.id, rootPath: deployment.rootPath, rootId: profile.rootId, + ...(deployment.deploymentId ? { deploymentId: deployment.deploymentId } : {}), }, ...(managementAction === 'install' ? { @@ -201,6 +212,15 @@ export function createDesktopRuntimeHostManagement(input: { operatorPath: managementInput.operatorPath, expectedTarget: managementInput.expectedTarget, }); + await input.cleanupManagedDeployment({ + destination: managementInput.destination, + ...(managementInput.sshPort === undefined + ? {} + : { sshPort: managementInput.sshPort }), + operatorPath: managementInput.operatorPath, + expectedTarget: managementInput.expectedTarget, + finalize: true, + }); await input.profiles.clearManagedServiceBinding(pending); return { kind: 'uninstalled', retainedStateRoot: deployment.rootPath }; }; @@ -260,6 +280,11 @@ export function createDesktopRuntimeHostManagement(input: { if (managed.state !== 'active' || transport.kind !== 'ssh') { throw new Error('This Runtime Host profile is not available for managed service changes'); } + if (!managed.deployment.deploymentId) { + throw new Error( + 'Re-onboard this Runtime Host before changing it; its legacy binding has no deployment generation', + ); + } return { profileId, managed, @@ -268,6 +293,7 @@ export function createDesktopRuntimeHostManagement(input: { serviceId: managed.deployment.id, rootPath: managed.deployment.rootPath, rootId: managed.profile.rootId, + deploymentId: managed.deployment.deploymentId, }, }; }; diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts index 6a97f2684a..a3518fbe85 100644 --- a/apps/desktop/src/main/runtime-host-onboarding.ts +++ b/apps/desktop/src/main/runtime-host-onboarding.ts @@ -52,6 +52,7 @@ export function createDesktopRuntimeHostOnboarding(input: { readonly rootId: string; readonly rootPath: string; readonly serviceId: string; + readonly deploymentId: string; readonly operatorPath: string; readonly endpoint: string; readonly credential: string; @@ -169,15 +170,17 @@ export function createDesktopRuntimeHostOnboarding(input: { }, }, credential: complete.credential, - ...(lifecycle === 'supervised' - ? { - managedService: { - id: complete.serviceId, - rootPath: complete.rootPath, - operatorPath: complete.operatorPath, - }, - } - : {}), + managedService: { + deployment: { + id: complete.serviceId, + rootPath: complete.rootPath, + deploymentId: complete.deploymentId, + }, + control: { + kind: 'ssh_operator', + operatorPath: complete.operatorPath, + }, + }, }); return publish({ kind: 'complete', diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index d23e49deb4..e40ee34917 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -61,7 +61,7 @@ import { createDesktopRuntimeHostManagedServiceStore, findDesktopRuntimeHostManagedServiceBinding, sameDesktopRuntimeHostManagedServiceBinding, - type DesktopRuntimeHostManagedService, + type DesktopRuntimeHostManagedServiceTarget, type DesktopRuntimeHostManagedServiceBinding, type DesktopRuntimeHostManagedServiceStore, } from "./runtime-host-managed-services.js"; @@ -70,16 +70,6 @@ const PREFERENCES_SCHEMA_VERSION = 2; const PREFERENCES_FILE = "runtime-host-profile-selection.json"; const PROFILE_FILE = "runtime-host-profiles.json"; -function serviceFromBinding( - binding: DesktopRuntimeHostManagedServiceBinding, -): DesktopRuntimeHostManagedService { - return { - id: binding.deployment.id, - rootPath: binding.deployment.rootPath, - operatorPath: binding.control.operatorPath, - }; -} - export interface DesktopRuntimeHostPreferences { readonly schemaVersion: 2; readonly defaultProfileId: string; @@ -103,7 +93,7 @@ export interface DesktopRuntimeHostProfileService { addAndEnableVerified( input: DesktopRuntimeHostProfileAddInput & { readonly credential: string; - readonly managedService?: DesktopRuntimeHostManagedService; + readonly managedService?: DesktopRuntimeHostManagedServiceTarget; }, ): Promise<{ readonly profileId: string }>; importConnectionCode(code: string): Promise; @@ -669,7 +659,7 @@ export function createDesktopRuntimeHostProfileService(input: { const addAndEnableVerified = ( value: DesktopRuntimeHostProfileAddInput & { readonly credential: string; - readonly managedService?: DesktopRuntimeHostManagedService; + readonly managedService?: DesktopRuntimeHostManagedServiceTarget; }, ): Promise<{ readonly profileId: string }> => { requireSaveInput(value); @@ -939,6 +929,14 @@ export function createDesktopRuntimeHostProfileService(input: { }, markManagedServiceUninstalling(expected) { return mutateProfiles(async () => { + if ( + !expected.deployment.deploymentId && + expected.state !== 'uninstalling' + ) { + throw new Error( + 'Re-onboard this Runtime Host before uninstalling it; its legacy binding has no deployment generation', + ); + } assertPairingComplete(expected.profile.id); const document = await catalog.read(); const current = document.profiles.find( @@ -958,10 +956,7 @@ export function createDesktopRuntimeHostProfileService(input: { throw new Error('Disable and remove the Direct peer profile before uninstalling this service'); } if ( - !(await managedServices.markUninstallingIfCurrent( - expected.profile, - serviceFromBinding(expected), - )) + !(await managedServices.markUninstallingIfCurrent(expected)) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); } @@ -977,10 +972,7 @@ export function createDesktopRuntimeHostProfileService(input: { if ( !current || !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || - !(await managedServices.markCleanupPendingIfCurrent( - expected.profile, - serviceFromBinding(expected), - )) + !(await managedServices.markCleanupPendingIfCurrent(expected)) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); } @@ -996,10 +988,7 @@ export function createDesktopRuntimeHostProfileService(input: { if ( !current || !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || - !(await managedServices.removeCleanupPendingIfCurrent( - expected.profile, - serviceFromBinding(expected), - )) + !(await managedServices.removeCleanupPendingIfCurrent(expected)) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); } @@ -1137,7 +1126,7 @@ export function createDesktopRuntimeHostProfileService(input: { await catalog.remove(profileId); if (managedBinding) { await managedServices - .removeIfCurrent(profile, serviceFromBinding(managedBinding)) + .removeIfCurrent(managedBinding) .catch((error) => console.error("[runtime-host] removed Profile left stale service metadata:", error), ); diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index d0a7e6ab46..1b2d82e21d 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -117,6 +117,7 @@ export interface DesktopRuntimeHostSshManagementInput { readonly serviceId: string; readonly rootPath: string; readonly rootId: string; + readonly deploymentId?: string; }; readonly rootPath?: string; readonly websocketPort?: number; @@ -170,6 +171,7 @@ export interface DesktopRuntimeHostSshCleanupInput { readonly sshPort?: number; readonly operatorPath: string; readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; + readonly finalize?: boolean; readonly signal?: AbortSignal; } @@ -1112,6 +1114,10 @@ function runtimeHostUpdateRemoteCommand( setupPackage: PreparedSetupPackage, input: DesktopRuntimeHostSshUpdateInput, ): string { + const deploymentId = input.expectedTarget.deploymentId; + if (!deploymentId) { + throw new Error('Runtime Host update requires a deployment generation'); + } return runtimeHostPackageRemoteCommand( setupPackage, [ @@ -1119,6 +1125,10 @@ function runtimeHostUpdateRemoteCommand( 'service', 'update', '--framed', + '--managed-root-id', + input.expectedTarget.rootId, + '--operator-deployment-id', + deploymentId, ...managedServiceTargetArgs(input.expectedTarget), ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), ], @@ -1219,12 +1229,13 @@ function runtimeHostManagedDeploymentCleanupRemoteCommand( const cleanup = [ input.operatorPath, '__cleanup-managed-deployment', + ...(input.finalize ? ['--finalize'] : []), ...managedServiceTargetArgs(input.expectedTarget), ].map(quotePosix).join(' '); const invocation = `if [ ! -e ${operator} ]; then ` + `if [ ! -e ${deploymentRoot} ]; then exit 0; fi; ` + - `exec rmdir -- ${deploymentRoot}; fi; ` + + `exit 1; fi; ` + `exec ${cleanup}`; return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; } @@ -1233,11 +1244,13 @@ function managedServiceTargetArgs(input: { readonly serviceId: string; readonly rootPath: string; readonly rootId: string; + readonly deploymentId?: string; }): string[] { return [ '--expected-service-id', input.serviceId, '--expected-root-path', input.rootPath, '--expected-root-id', input.rootId, + ...(input.deploymentId ? ['--expected-deployment-id', input.deploymentId] : []), ]; } diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx index 95585c395e..951d391bea 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx @@ -533,6 +533,7 @@ export function RuntimeHostManagementDialog(props: { const uninstalled = uninstalledRoot !== undefined; const serviceInstalled = service !== undefined && service.state !== 'not_installed'; const serviceActive = service?.state === 'running'; + const supervised = service?.lifecycle?.mode === 'supervised'; const savedPolicyChoice = updatePolicy ? updatePolicyChoiceOf(updatePolicy) : undefined; const updatePolicyDirty = savedPolicyChoice !== updatePolicyChoice || (updatePolicyChoice === 'fixed' && @@ -1157,9 +1158,6 @@ export function RuntimeHostManagementDialog(props: { size="sm" isDisabled={loading} items={[ - ...(profile.transport.kind === 'ssh' - ? [{ label: copy.repairService, onClick: () => void run('install') }] - : []), ...(serviceInstalled && result?.accessManagementAvailable ? [{ label: copy.manageAccess, onClick: () => void loadAccess() }] : []), @@ -1184,14 +1182,14 @@ export function RuntimeHostManagementDialog(props: { isDisabled={loading} onClick={() => void run('status')} /> - {serviceInstalled && serviceActive ? ( + {serviceInstalled && supervised && serviceActive ? (