diff --git a/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts b/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts index be78154c77..2030626f6c 100644 --- a/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts +++ b/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts @@ -43,6 +43,7 @@ import type { import { assertRuntimeHostManagedOperatorConfig } from '../runtime-host-managed-deployment.js'; import { applyRuntimeHostLifecycleTransition, + convergeRuntimeHostLifecycleControlProjection, recoverRuntimeHostLifecycleTransition, replaceRuntimeHostLifecycle, resolveRecoverableRuntimeHostManagedDeployment, @@ -56,6 +57,42 @@ import { const INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString('base64')}`; const UPDATED_INTEGRITY = `sha512-${Buffer.alloc(64, 8).toString('base64')}`; +test('control projection repair leaves the running Host supervisor untouched', async () => { + const current = config('/workspace', 'a'.repeat(64), 1, 'launch_agent'); + const calls: string[] = []; + const provider = new FakeLifecycleProvider('launch_agent', 'launch_agent_timer'); + const originalActivate = provider.reconciliationTrigger.activate; + provider.reconciliationTrigger.activate = async () => { + calls.push('scheduler.activate'); + await originalActivate(); + }; + + await convergeRuntimeHostLifecycleControlProjection(current, { + convergeOperator: async (from, to) => { + assert.deepEqual(from, current); + assert.deepEqual(to, current); + calls.push('operator.converge'); + }, + verifyOperator: async () => undefined, + resolveProvider: () => ({ + ...provider, + supervisor: { + ...provider.supervisor, + converge: async () => assert.fail('the running supervisor must not be replaced'), + }, + reconciliationTrigger: { + ...provider.reconciliationTrigger, + converge: async (definition) => { + calls.push('scheduler.converge'); + await provider.reconciliationTrigger.converge(definition); + }, + }, + }), + }); + + assert.deepEqual(calls, ['operator.converge', 'scheduler.converge', 'scheduler.activate']); +}); + test('one authority record recovers provider cutover failures without a journal', async (t) => { const stateRoot = await mkdtemp(join(tmpdir(), 'maka-lifecycle-root-')); const authorityRoot = await mkdtemp(join(tmpdir(), 'maka-lifecycle-authority-')); diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts index 09564bc2ec..dfc025ab9d 100644 --- a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -84,6 +84,7 @@ describe('managed Runtime Host selected update', () => { let output = ''; let managedReads = 0; let pruned = false; + const projection: string[] = []; const exitCode = await runManagedRuntimeHostUpdateCli( { ...OPTIONS, @@ -106,7 +107,13 @@ describe('managed Runtime Host selected update', () => { assert.deepEqual(options?.expectedOwner, expectedHost); return { kind: 'active', config: current } as never; }, - verifyProjection: async () => {}, + convergeControlProjection: async (config) => { + assert.equal(config, current); + projection.push('converge'); + }, + verifyProjection: async () => { + projection.push('verify'); + }, assertOperatorConfig: () => {}, manageLifecycle: async () => { managedReads += 1; @@ -130,6 +137,7 @@ describe('managed Runtime Host selected update', () => { assert.equal(exitCode, 0); assert.equal(managedReads, 2); assert.equal(pruned, true); + assert.deepEqual(projection, ['converge', 'verify']); assert.doesNotMatch(output, /"phase":"staging"/u); const result = decodeRuntimeHostServiceManagementFrame(output.trim().split('\n').at(-1) ?? ''); assert.equal( diff --git a/packages/cli/src/runtime-host-lifecycle-transaction.ts b/packages/cli/src/runtime-host-lifecycle-transaction.ts index 48b3247cdf..8015c39235 100644 --- a/packages/cli/src/runtime-host-lifecycle-transaction.ts +++ b/packages/cli/src/runtime-host-lifecycle-transaction.ts @@ -806,6 +806,25 @@ export async function verifyRuntimeHostLifecycleReady( ); } +/** Repairs update-control artifacts without interrupting the running Host supervisor. */ +export async function convergeRuntimeHostLifecycleControlProjection( + config: RuntimeHostManagedDeploymentConfig, + deps: RuntimeHostLifecycleTransactionDeps, +): Promise { + const canonical = decodeRuntimeHostManagedDeploymentConfig(config); + await deps.convergeOperator(canonical, canonical); + if (canonical.lifecycle.mode !== 'supervised') return; + const provider = deps.resolveProvider(canonical); + if (canonical.reconciliation.trigger === 'scheduled') { + await provider.reconciliationTrigger.converge( + runtimeHostReconciliationTriggerDefinition(canonical), + ); + await provider.reconciliationTrigger.activate(); + } else { + await provider.reconciliationTrigger.uninstall(); + } +} + /** Verifies the durable operator and supervisor projection without requiring a compatible Host. */ export async function verifyRuntimeHostLifecycleProjection( config: RuntimeHostManagedDeploymentConfig, diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index dc8d755767..6c20ac2f66 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -23,7 +23,6 @@ import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { activateRuntimeHostManagedDeployment } from '@maka/runtime-host/client'; import { createRuntimeHostLegacyPosixOperatorCommand, - runtimeHostManagedOperatorCommand, decodeRuntimeHostServiceManagementFrame, encodeRuntimeHostServiceManagementFrame, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES, @@ -81,6 +80,7 @@ import { import type { RuntimeHostExpectedHost, RuntimeHostUpdateSelector } from './runtime-host-cli.js'; import { canDiscardRuntimeHostLifecycleDesiredArtifacts, + convergeRuntimeHostLifecycleControlProjection, replaceRuntimeHostLifecycle, resolveRecoverableRuntimeHostManagedDeployment, RuntimeHostLifecycleTransactionError, @@ -142,6 +142,7 @@ interface RuntimeHostUpdateCliDeps { readonly createLifecycleDeps: (rootId: string) => RuntimeHostLifecycleTransactionDeps; readonly assertOperatorDeployment: typeof assertRuntimeHostManagedOperatorDeployment; readonly recoverDeployment: typeof resolveRecoverableRuntimeHostManagedDeployment; + readonly convergeControlProjection: typeof convergeRuntimeHostLifecycleControlProjection; readonly verifyProjection: typeof verifyRuntimeHostLifecycleProjection; readonly assertOperatorConfig: typeof assertRuntimeHostManagedOperatorConfig; readonly manageLifecycle: typeof manageRuntimeHostManagedLifecycle; @@ -237,6 +238,7 @@ export async function runManagedRuntimeHostUpdateCli( }), assertOperatorDeployment: assertRuntimeHostManagedOperatorDeployment, recoverDeployment: resolveRecoverableRuntimeHostManagedDeployment, + convergeControlProjection: convergeRuntimeHostLifecycleControlProjection, verifyProjection: verifyRuntimeHostLifecycleProjection, assertOperatorConfig: assertRuntimeHostManagedOperatorConfig, manageLifecycle: manageRuntimeHostManagedLifecycle, @@ -651,6 +653,7 @@ async function runCanonicalRuntimeHostUpdate( ); } const current = recovered.config; + await deps.canonical.convergeControlProjection(current, lifecycleDeps); await deps.canonical.verifyProjection(current, lifecycleDeps); deps.canonical.assertOperatorConfig( current,