diff --git a/packages/runtime-host/src/__tests__/local-deployment-owner.test.ts b/packages/runtime-host/src/__tests__/local-deployment-owner.test.ts index 6ca0224a80..a3becf3b81 100644 --- a/packages/runtime-host/src/__tests__/local-deployment-owner.test.ts +++ b/packages/runtime-host/src/__tests__/local-deployment-owner.test.ts @@ -311,7 +311,7 @@ test('removes abandoned record workspaces before applying the next transition', assert.equal(entries.includes(unrelated), true); }); -test('persists transfer intent before cutover and commits the exact target', async (t) => { +test('persists handoff intent before cutover and commits the exact target', async (t) => { const options = await authority(t); const claimed = await applyLocalHostDeploymentTransition( ROOT_ID, @@ -323,7 +323,7 @@ test('persists transfer intent before cutover and commits the exact target', asy const begun = await applyLocalHostDeploymentTransition( ROOT_ID, { - kind: 'begin_transfer', + kind: 'begin_handoff', expectedRevision: claimed.record!.revision, transactionId: 'desktop-to-cli', from: DESKTOP, @@ -334,12 +334,12 @@ test('persists transfer intent before cutover and commits the exact target', asy ); assert.equal(begun.kind, 'applied'); assert.deepEqual(await readLocalHostDeploymentRecord(ROOT_ID, options), begun.record); - assert.equal(begun.record?.state.kind, 'transferring'); + assert.equal(begun.record?.state.kind, 'handoff'); const committed = await applyLocalHostDeploymentTransition( ROOT_ID, { - kind: 'commit_transfer', + kind: 'commit_handoff', expectedRevision: begun.record!.revision, transactionId: 'desktop-to-cli', to: CLI, @@ -358,7 +358,7 @@ test('persists transfer intent before cutover and commits the exact target', asy const retried = await applyLocalHostDeploymentTransition( ROOT_ID, { - kind: 'commit_transfer', + kind: 'commit_handoff', expectedRevision: begun.record!.revision, transactionId: 'desktop-to-cli', to: CLI, @@ -377,26 +377,27 @@ test('rejects stale confirmation after the owner revision changes', async (t) => options, ); assert.equal(claimed.kind, 'applied'); - const selected = await applyLocalHostDeploymentTransition( + const released = await applyLocalHostDeploymentTransition( ROOT_ID, { - kind: 'select', + kind: 'release', expectedRevision: claimed.record!.revision, owner: DESKTOP, - selected: { - ...DESKTOP_DEPLOYMENT, - version: '1.1.0', - integrity: CLI_DEPLOYMENT.integrity, - }, }, options, ); - assert.equal(selected.kind, 'applied'); + assert.equal(released.kind, 'applied'); + const reclaimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT }, + options, + ); + assert.equal(reclaimed.kind, 'applied'); const stale = await applyLocalHostDeploymentTransition( ROOT_ID, { - kind: 'begin_transfer', + kind: 'begin_handoff', expectedRevision: claimed.record!.revision, transactionId: 'stale-prompt', from: DESKTOP, @@ -417,7 +418,7 @@ test('rejects stale confirmation after the owner revision changes', async (t) => ); }); -test('rolls an interrupted transfer back to the exact previous owner state', async (t) => { +test('rolls an interrupted handoff back to the exact previous owner state', async (t) => { const options = await authority(t); const claimed = await applyLocalHostDeploymentTransition( ROOT_ID, @@ -427,7 +428,7 @@ test('rolls an interrupted transfer back to the exact previous owner state', asy const begun = await applyLocalHostDeploymentTransition( ROOT_ID, { - kind: 'begin_transfer', + kind: 'begin_handoff', expectedRevision: claimed.record!.revision, transactionId: 'recover-me', from: DESKTOP, @@ -439,7 +440,7 @@ test('rolls an interrupted transfer back to the exact previous owner state', asy const rolledBack = await applyLocalHostDeploymentTransition( ROOT_ID, { - kind: 'rollback_transfer', + kind: 'rollback_handoff', expectedRevision: begun.record!.revision, transactionId: 'recover-me', from: DESKTOP, @@ -455,13 +456,15 @@ test('rolls an interrupted transfer back to the exact previous owner state', asy selected: DESKTOP_DEPLOYMENT, }); - const changedSelection = await applyLocalHostDeploymentTransition( + const changedHandoff = await applyLocalHostDeploymentTransition( ROOT_ID, { - kind: 'select', + kind: 'begin_handoff', expectedRevision: rolledBack.record!.revision, - owner: DESKTOP, - selected: { + transactionId: 'replacement', + from: DESKTOP, + to: DESKTOP, + target: { ...DESKTOP_DEPLOYMENT, version: '1.1.0', integrity: CLI_DEPLOYMENT.integrity, @@ -472,7 +475,7 @@ test('rolls an interrupted transfer back to the exact previous owner state', asy const staleRetry = await applyLocalHostDeploymentTransition( ROOT_ID, { - kind: 'rollback_transfer', + kind: 'rollback_handoff', expectedRevision: begun.record!.revision, transactionId: 'recover-me', from: DESKTOP, @@ -480,9 +483,49 @@ test('rolls an interrupted transfer back to the exact previous owner state', asy }, options, ); - assert.equal(changedSelection.kind, 'applied'); + assert.equal(changedHandoff.kind, 'applied'); assert.equal(staleRetry.kind, 'rejected'); - assert.equal(staleRetry.kind === 'rejected' ? staleRetry.reason : undefined, 'transfer_changed'); + assert.equal(staleRetry.kind === 'rejected' ? staleRetry.reason : undefined, 'handoff_changed'); +}); + +test('uses the same durable handoff for an owner-preserving deployment replacement', async (t) => { + const options = await authority(t); + const claimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: CLI, selected: DESKTOP_DEPLOYMENT }, + options, + ); + const begun = await applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'begin_handoff', + expectedRevision: claimed.record!.revision, + transactionId: 'cli-upgrade', + from: CLI, + to: CLI, + target: CLI_DEPLOYMENT, + }, + options, + ); + const committed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'commit_handoff', + expectedRevision: begun.record!.revision, + transactionId: 'cli-upgrade', + to: CLI, + target: CLI_DEPLOYMENT, + }, + options, + ); + + assert.equal(committed.kind, 'applied'); + assert.deepEqual(committed.record?.state, { + kind: 'owned', + owner: CLI, + selected: CLI_DEPLOYMENT, + previous: DESKTOP_DEPLOYMENT, + }); }); test('requires exact owner and revision before releasing durable authority', async (t) => { diff --git a/packages/runtime-host/src/__tests__/local-process-owner-transfer.test.ts b/packages/runtime-host/src/__tests__/local-process-deployment-handoff.test.ts similarity index 81% rename from packages/runtime-host/src/__tests__/local-process-owner-transfer.test.ts rename to packages/runtime-host/src/__tests__/local-process-deployment-handoff.test.ts index 3eefe4fd38..573524f5bd 100644 --- a/packages/runtime-host/src/__tests__/local-process-owner-transfer.test.ts +++ b/packages/runtime-host/src/__tests__/local-process-deployment-handoff.test.ts @@ -29,9 +29,9 @@ import { type RuntimeHostInstallationOwner, } from '../operator/local-deployment-owner.js'; import { - transferLocalHostProcessOwner, - type LocalHostProcessOwnerTransferAdapter, -} from '../operator/local-process-owner-transfer.js'; + handoffLocalHostProcessDeployment, + type LocalHostProcessDeploymentHandoffAdapter, +} from '../operator/local-process-deployment-handoff.js'; import type { RuntimeHostDeploymentIdentity } from '../operator/update-package-evidence.js'; const ROOT_ID = 'b'.repeat(64); @@ -73,7 +73,7 @@ async function claimed(options: LocalHostDeploymentAuthorityOptions) { function adapter( events: string[], host: 'target_absent' | 'target_present' | 'active_work' = 'target_absent', -): LocalHostProcessOwnerTransferAdapter<{ readonly path: string }> { +): LocalHostProcessDeploymentHandoffAdapter<{ readonly path: string }> { return { async stageTarget(target, transactionId) { events.push(`stage:${target.version}:${transactionId}`); @@ -100,7 +100,7 @@ test('stages first and commits only after retirement, writer release, and exact const initial = await claimed(options); const events: string[] = []; - const result = await transferLocalHostProcessOwner( + const result = await handoffLocalHostProcessDeployment( { rootId: ROOT_ID, expectedRevision: initial.revision, @@ -114,7 +114,7 @@ test('stages first and commits only after retirement, writer release, and exact ...adapter(events), async prepareHostCutover(rootId, _selected, _target, _staged, policy) { const intent = await readLocalHostDeploymentRecord(rootId, options); - assert.equal(intent?.state.kind, 'transferring'); + assert.equal(intent?.state.kind, 'handoff'); events.push(`retire:${policy}`); return { kind: 'target_absent' }; }, @@ -138,7 +138,7 @@ test('stages first and commits only after retirement, writer release, and exact }); const retryEvents: string[] = []; - const retried = await transferLocalHostProcessOwner( + const retried = await handoffLocalHostProcessDeployment( { rootId: ROOT_ID, expectedRevision: initial.revision, @@ -156,6 +156,41 @@ test('stages first and commits only after retirement, writer release, and exact assert.deepEqual(retryEvents, ['stage:2.0.0:desktop-to-cli']); }); +test('replaces a deployment without inventing a second same-owner transaction', async (t) => { + const options = await authority(t); + const initial = await claimed(options); + const events: string[] = []; + + const result = await handoffLocalHostProcessDeployment( + { + rootId: ROOT_ID, + expectedRevision: initial.revision, + transactionId: 'desktop-upgrade', + from: DESKTOP, + to: DESKTOP, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work', + }, + adapter(events), + options, + ); + + assert.equal(result.kind, 'completed'); + assert.deepEqual(result.record.state, { + kind: 'owned', + owner: DESKTOP, + selected: TARGET_DEPLOYMENT, + previous: OLD_DEPLOYMENT, + }); + assert.deepEqual(events, [ + 'stage:2.0.0:desktop-upgrade', + 'retire:refuse_active_work', + 'writer_released', + 'activate:/verified/maka', + 'ready:2.0.0', + ]); +}); + test('replays commit to confirm durability instead of trusting read-back state', async (t) => { if (process.platform === 'win32') return; const base = await authority(t); @@ -185,12 +220,16 @@ test('replays commit to confirm durability instead of trusting read-back state', activeWorkPolicy: 'refuse_active_work' as const, }; - const first = await transferLocalHostProcessOwner(request, adapter([]), options); + const first = await handoffLocalHostProcessDeployment(request, adapter([]), options); assert.equal(first.kind, 'recovery_required'); - assert.equal(first.kind === 'recovery_required' ? first.phase : undefined, 'commit_owner'); + assert.equal(first.kind === 'recovery_required' ? first.phase : undefined, 'commit_handoff'); const recoveryEvents: string[] = []; - const recovered = await transferLocalHostProcessOwner(request, adapter(recoveryEvents), options); + const recovered = await handoffLocalHostProcessDeployment( + request, + adapter(recoveryEvents), + options, + ); assert.equal(recovered.kind, 'completed'); assert.deepEqual(recoveryEvents, ['stage:2.0.0:durability-recovery']); }); @@ -198,20 +237,25 @@ test('replays commit to confirm durability instead of trusting read-back state', test('rejects a stale confirmation before retiring any Host', async (t) => { const options = await authority(t); const initial = await claimed(options); - const changed = await applyLocalHostDeploymentTransition( + const released = await applyLocalHostDeploymentTransition( ROOT_ID, { - kind: 'select', + kind: 'release', expectedRevision: initial.revision, owner: DESKTOP, - selected: { ...OLD_DEPLOYMENT, version: '1.1.0', integrity: TARGET_DEPLOYMENT.integrity }, }, options, ); - assert.equal(changed.kind, 'applied'); + assert.equal(released.kind, 'applied'); + const reclaimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: OLD_DEPLOYMENT }, + options, + ); + assert.equal(reclaimed.kind, 'applied'); const events: string[] = []; - const result = await transferLocalHostProcessOwner( + const result = await handoffLocalHostProcessDeployment( { rootId: ROOT_ID, expectedRevision: initial.revision, @@ -239,7 +283,7 @@ test('leaves the previous owner authoritative when target staging fails', async }; await assert.rejects( - transferLocalHostProcessOwner( + handoffLocalHostProcessDeployment( { rootId: ROOT_ID, expectedRevision: initial.revision, @@ -266,7 +310,7 @@ test('rolls back durable intent when the exact Host refuses active work', async const initial = await claimed(options); const events: string[] = []; - const result = await transferLocalHostProcessOwner( + const result = await handoffLocalHostProcessDeployment( { rootId: ROOT_ID, expectedRevision: initial.revision, @@ -289,7 +333,7 @@ test('rolls back durable intent when the exact Host refuses active work', async assert.deepEqual(events, ['stage:2.0.0:active-work', 'retire:refuse_active_work']); }); -test('keeps transferring truth after a post-retirement failure and resumes by re-observing', async (t) => { +test('keeps handoff truth after a post-retirement failure and resumes by re-observing', async (t) => { const options = await authority(t); const initial = await claimed(options); const firstEvents: string[] = []; @@ -308,14 +352,14 @@ test('keeps transferring truth after a post-retirement failure and resumes by re target: TARGET_DEPLOYMENT, activeWorkPolicy: 'interrupt_active_work' as const, }; - const failed = await transferLocalHostProcessOwner(request, firstAdapter, options); + const failed = await handoffLocalHostProcessDeployment(request, firstAdapter, options); assert.equal(failed.kind, 'recovery_required'); assert.equal(failed.kind === 'recovery_required' ? failed.phase : undefined, 'activate_target'); - assert.equal((await readLocalHostDeploymentRecord(ROOT_ID, options))?.state.kind, 'transferring'); + assert.equal((await readLocalHostDeploymentRecord(ROOT_ID, options))?.state.kind, 'handoff'); const recoveryEvents: string[] = []; - const recovered = await transferLocalHostProcessOwner( + const recovered = await handoffLocalHostProcessDeployment( request, adapter(recoveryEvents, 'target_absent'), options, @@ -349,12 +393,12 @@ test('recognizes an already-started exact target without retiring or launching i activeWorkPolicy: 'refuse_active_work' as const, }; assert.equal( - (await transferLocalHostProcessOwner(request, firstAdapter, options)).kind, + (await handoffLocalHostProcessDeployment(request, firstAdapter, options)).kind, 'recovery_required', ); const recoveryEvents: string[] = []; - const recovered = await transferLocalHostProcessOwner( + const recovered = await handoffLocalHostProcessDeployment( request, adapter(recoveryEvents, 'target_present'), options, @@ -368,7 +412,7 @@ test('recognizes an already-started exact target without retiring or launching i ]); }); -test('serializes the whole cutover so a competing owner mutation cannot enter mid-transfer', async (t) => { +test('serializes the whole cutover so a competing owner mutation cannot enter mid-handoff', async (t) => { const options = await authority(t); const initial = await claimed(options); let releaseRetirement!: () => void; @@ -387,11 +431,11 @@ test('serializes the whole cutover so a competing owner mutation cannot enter mi return { kind: 'target_absent' }; }; - const transfer = transferLocalHostProcessOwner( + const handoff = handoffLocalHostProcessDeployment( { rootId: ROOT_ID, expectedRevision: initial.revision, - transactionId: 'serialized-transfer', + transactionId: 'serialized-handoff', from: DESKTOP, to: CLI, target: TARGET_DEPLOYMENT, @@ -417,7 +461,7 @@ test('serializes the whole cutover so a competing owner mutation cannot enter mi assert.equal(competitorSettled, false); releaseRetirement(); - assert.equal((await transfer).kind, 'completed'); + assert.equal((await handoff).kind, 'completed'); const competingResult = await competitor; assert.equal(competingResult.kind, 'rejected'); assert.equal( diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index 030e33b285..977ae0eb34 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -80,10 +80,10 @@ export { type RuntimeHostInstallationOwner, } from './local-deployment-owner.js'; export { - transferLocalHostProcessOwner, - type LocalHostProcessOwnerTransferAdapter, - type LocalHostProcessOwnerTransferPhase, - type LocalHostProcessOwnerTransferRequest, - type LocalHostProcessOwnerTransferResult, - type LocalHostTransferActiveWorkPolicy, -} from './local-process-owner-transfer.js'; + handoffLocalHostProcessDeployment, + type LocalHostProcessDeploymentHandoffAdapter, + type LocalHostProcessDeploymentHandoffPhase, + type LocalHostProcessDeploymentHandoffRequest, + type LocalHostProcessDeploymentHandoffResult, + type LocalHostHandoffActiveWorkPolicy, +} from './local-process-deployment-handoff.js'; diff --git a/packages/runtime-host/src/operator/local-deployment-owner.ts b/packages/runtime-host/src/operator/local-deployment-owner.ts index 72a7b4e160..73b97df412 100644 --- a/packages/runtime-host/src/operator/local-deployment-owner.ts +++ b/packages/runtime-host/src/operator/local-deployment-owner.ts @@ -69,9 +69,9 @@ const OWNED_STATE_SCHEMA = z }) .strict(); -const TRANSFERRING_STATE_SCHEMA = z +const HANDOFF_STATE_SCHEMA = z .object({ - kind: z.literal('transferring'), + kind: z.literal('handoff'), transactionId: boundedText(512), from: OWNER_SCHEMA, to: OWNER_SCHEMA, @@ -86,7 +86,7 @@ const RECORD_SCHEMA = z schemaVersion: z.literal(RECORD_SCHEMA_VERSION), rootId: z.string().regex(ROOT_ID), revision: z.string().regex(REVISION), - state: z.discriminatedUnion('kind', [OWNED_STATE_SCHEMA, TRANSFERRING_STATE_SCHEMA]), + state: z.discriminatedUnion('kind', [OWNED_STATE_SCHEMA, HANDOFF_STATE_SCHEMA]), }) .strict(); @@ -100,15 +100,7 @@ const TRANSITION_SCHEMA = z.discriminatedUnion('kind', [ .strict(), z .object({ - kind: z.literal('select'), - expectedRevision: z.string().regex(REVISION), - owner: OWNER_SCHEMA, - selected: DEPLOYMENT_IDENTITY_SCHEMA, - }) - .strict(), - z - .object({ - kind: z.literal('begin_transfer'), + kind: z.literal('begin_handoff'), expectedRevision: z.string().regex(REVISION), transactionId: boundedText(512), from: OWNER_SCHEMA, @@ -118,7 +110,7 @@ const TRANSITION_SCHEMA = z.discriminatedUnion('kind', [ .strict(), z .object({ - kind: z.literal('commit_transfer'), + kind: z.literal('commit_handoff'), expectedRevision: z.string().regex(REVISION), transactionId: boundedText(512), to: OWNER_SCHEMA, @@ -127,7 +119,7 @@ const TRANSITION_SCHEMA = z.discriminatedUnion('kind', [ .strict(), z .object({ - kind: z.literal('rollback_transfer'), + kind: z.literal('rollback_handoff'), expectedRevision: z.string().regex(REVISION), transactionId: boundedText(512), from: OWNER_SCHEMA, @@ -156,7 +148,7 @@ export type LocalHostDeploymentState = readonly previous?: RuntimeHostDeploymentIdentity; } | { - readonly kind: 'transferring'; + readonly kind: 'handoff'; readonly transactionId: string; readonly from: RuntimeHostInstallationOwner; readonly to: RuntimeHostInstallationOwner; @@ -180,13 +172,7 @@ export type LocalHostDeploymentTransition = readonly selected: RuntimeHostDeploymentIdentity; } | { - readonly kind: 'select'; - readonly expectedRevision: string; - readonly owner: RuntimeHostInstallationOwner; - readonly selected: RuntimeHostDeploymentIdentity; - } - | { - readonly kind: 'begin_transfer'; + readonly kind: 'begin_handoff'; readonly expectedRevision: string; readonly transactionId: string; readonly from: RuntimeHostInstallationOwner; @@ -194,14 +180,14 @@ export type LocalHostDeploymentTransition = readonly target: RuntimeHostDeploymentIdentity; } | { - readonly kind: 'commit_transfer'; + readonly kind: 'commit_handoff'; readonly expectedRevision: string; readonly transactionId: string; readonly to: RuntimeHostInstallationOwner; readonly target: RuntimeHostDeploymentIdentity; } | { - readonly kind: 'rollback_transfer'; + readonly kind: 'rollback_handoff'; readonly expectedRevision: string; readonly transactionId: string; readonly from: RuntimeHostInstallationOwner; @@ -218,8 +204,8 @@ export type LocalHostDeploymentTransitionRejection = | 'not_owned' | 'owner_changed' | 'revision_changed' - | 'transfer_in_progress' - | 'transfer_changed'; + | 'handoff_in_progress' + | 'handoff_changed'; export type LocalHostDeploymentTransitionResult = | { @@ -401,28 +387,16 @@ function reduceTransition( return unchanged(current); } return rejected( - current.state.kind === 'transferring' ? 'transfer_in_progress' : 'owner_exists', + current.state.kind === 'handoff' ? 'handoff_in_progress' : 'owner_exists', current, ); } - case 'select': { - if (!current) return rejected('not_owned', current); - if (current.state.kind === 'transferring') return rejected('transfer_in_progress', current); - if (!sameOwner(current.state.owner, transition.owner)) - return rejected('owner_changed', current); - if (sameDeployment(current.state.selected, transition.selected)) return unchanged(current); - if (current.revision !== transition.expectedRevision) - return rejected('revision_changed', current); - return applied( - record(rootId, owned(transition.owner, transition.selected, current.state.selected)), - ); - } - case 'begin_transfer': { + case 'begin_handoff': { if (!current) return rejected('not_owned', current); - if (current.state.kind === 'transferring') { - return sameTransfer(current.state, transition) + if (current.state.kind === 'handoff') { + return sameHandoff(current.state, transition) ? unchanged(current) - : rejected('transfer_changed', current); + : rejected('handoff_changed', current); } if (!sameOwner(current.state.owner, transition.from)) return rejected('owner_changed', current); @@ -430,7 +404,7 @@ function reduceTransition( return rejected('revision_changed', current); return applied( record(rootId, { - kind: 'transferring', + kind: 'handoff', transactionId: transition.transactionId, from: transition.from, to: transition.to, @@ -440,36 +414,36 @@ function reduceTransition( }), ); } - case 'commit_transfer': { + case 'commit_handoff': { if (!current) return rejected('not_owned', current); if (current.state.kind === 'owned') { return sameOwner(current.state.owner, transition.to) && sameDeployment(current.state.selected, transition.target) ? unchanged(current) - : rejected('transfer_changed', current); + : rejected('handoff_changed', current); } - if (!matchesTransferTarget(current.state, transition)) - return rejected('transfer_changed', current); + if (!matchesHandoffTarget(current.state, transition)) + return rejected('handoff_changed', current); if (current.revision !== transition.expectedRevision) return rejected('revision_changed', current); return applied( record(rootId, owned(transition.to, transition.target, current.state.selected)), ); } - case 'rollback_transfer': { + case 'rollback_handoff': { if (!current) return rejected('not_owned', current); if (current.state.kind === 'owned') { return sameOwner(current.state.owner, transition.from) && sameDeployment(current.state.selected, transition.selected) ? unchanged(current) - : rejected('transfer_changed', current); + : rejected('handoff_changed', current); } if ( current.state.transactionId !== transition.transactionId || !sameOwner(current.state.from, transition.from) || !sameDeployment(current.state.selected, transition.selected) ) { - return rejected('transfer_changed', current); + return rejected('handoff_changed', current); } if (current.revision !== transition.expectedRevision) return rejected('revision_changed', current); @@ -479,7 +453,7 @@ function reduceTransition( } case 'release': { if (!current) return unchanged(undefined); - if (current.state.kind === 'transferring') return rejected('transfer_in_progress', current); + if (current.state.kind === 'handoff') return rejected('handoff_in_progress', current); if (!sameOwner(current.state.owner, transition.owner)) return rejected('owner_changed', current); if (current.revision !== transition.expectedRevision) @@ -511,9 +485,9 @@ function owned( }; } -function sameTransfer( - current: Extract, - transition: Extract, +function sameHandoff( + current: Extract, + transition: Extract, ): boolean { return ( current.transactionId === transition.transactionId && @@ -523,9 +497,9 @@ function sameTransfer( ); } -function matchesTransferTarget( - current: Extract, - transition: Extract, +function matchesHandoffTarget( + current: Extract, + transition: Extract, ): boolean { return ( current.transactionId === transition.transactionId && @@ -580,11 +554,7 @@ function assertRootId(rootId: string): void { function parseTransition(transition: LocalHostDeploymentTransition): LocalHostDeploymentTransition { try { - const parsed = TRANSITION_SCHEMA.parse(transition) as LocalHostDeploymentTransition; - if (parsed.kind === 'begin_transfer' && sameOwner(parsed.from, parsed.to)) { - throw new Error('Transfer owners must differ'); - } - return parsed; + return TRANSITION_SCHEMA.parse(transition) as LocalHostDeploymentTransition; } catch (error) { if (error instanceof LocalHostDeploymentAuthorityError) throw error; throw new LocalHostDeploymentAuthorityError( diff --git a/packages/runtime-host/src/operator/local-process-owner-transfer.ts b/packages/runtime-host/src/operator/local-process-deployment-handoff.ts similarity index 72% rename from packages/runtime-host/src/operator/local-process-owner-transfer.ts rename to packages/runtime-host/src/operator/local-process-deployment-handoff.ts index 09da56ccd4..30793b5997 100644 --- a/packages/runtime-host/src/operator/local-process-owner-transfer.ts +++ b/packages/runtime-host/src/operator/local-process-deployment-handoff.ts @@ -26,19 +26,19 @@ import { type RuntimeHostInstallationOwner, } from './local-deployment-owner.js'; -export type LocalHostTransferActiveWorkPolicy = 'refuse_active_work' | 'interrupt_active_work'; +export type LocalHostHandoffActiveWorkPolicy = 'refuse_active_work' | 'interrupt_active_work'; -export interface LocalHostProcessOwnerTransferRequest { +export interface LocalHostProcessDeploymentHandoffRequest { readonly rootId: string; readonly expectedRevision: string; readonly transactionId: string; readonly from: RuntimeHostInstallationOwner; readonly to: RuntimeHostInstallationOwner; readonly target: RuntimeHostDeploymentIdentity; - readonly activeWorkPolicy: LocalHostTransferActiveWorkPolicy; + readonly activeWorkPolicy: LocalHostHandoffActiveWorkPolicy; } -export interface LocalHostProcessOwnerTransferAdapter { +export interface LocalHostProcessDeploymentHandoffAdapter { /** * Stages and verifies the exact runnable closure without changing Host authority. * A retry must reconstruct the same transaction-scoped launch fence in the staged handle. @@ -48,20 +48,20 @@ export interface LocalHostProcessOwnerTransferAdapter { * Re-observes the actual local Host and retires it only when it is the previous deployment. * `target_present` means the exact transaction-scoped staged target is already running. * Source-specific supervisors must be quiesced before cutover. `active_work` guarantees that - * retirement did not begin and the previous owner remains runnable, so restoring it is safe. + * retirement did not begin and the previous deployment remains runnable, so restoring it is safe. */ prepareHostCutover( rootId: string, selected: RuntimeHostDeploymentIdentity, target: RuntimeHostDeploymentIdentity, staged: StagedTarget, - policy: LocalHostTransferActiveWorkPolicy, + policy: LocalHostHandoffActiveWorkPolicy, ): Promise<{ readonly kind: 'target_absent' | 'target_present' | 'active_work'; }>; /** Resolves only after the State Root writer fence proves that the old writer is gone. */ observeWriterRelease(rootId: string): Promise; - /** Starts the already staged target without selecting it as durable owner yet. */ + /** Starts the already staged target without selecting it in durable authority yet. */ activateTarget(rootId: string, staged: StagedTarget): Promise; /** Verifies Ready, root identity, and the exact deployment selected by the request. */ verifyTargetReady( @@ -71,15 +71,15 @@ export interface LocalHostProcessOwnerTransferAdapter { ): Promise; } -export type LocalHostProcessOwnerTransferPhase = +export type LocalHostProcessDeploymentHandoffPhase = | 'prepare_host_cutover' | 'observe_writer_release' | 'activate_target' | 'verify_target_ready' - | 'commit_owner' + | 'commit_handoff' | 'rollback_active_work'; -export type LocalHostProcessOwnerTransferResult = +export type LocalHostProcessDeploymentHandoffResult = | { readonly kind: 'completed'; readonly record: LocalHostDeploymentRecord; @@ -95,22 +95,22 @@ export type LocalHostProcessOwnerTransferResult = } | { readonly kind: 'recovery_required'; - readonly phase: LocalHostProcessOwnerTransferPhase; + readonly phase: LocalHostProcessDeploymentHandoffPhase; readonly record: LocalHostDeploymentRecord; readonly cause: unknown; }; /** - * Transfers one local-process deployment slot between persistent installation - * owners. Staging is deliberately outside the authority lock; all operations - * after durable transfer intent remain serialized until commit or a safe - * active-work rollback. + * Hands one local-process deployment slot to an exact target. The installation + * owner may stay the same or change. Staging is deliberately outside the + * authority lock; all operations after durable handoff intent remain serialized + * until commit or a safe active-work rollback. */ -export async function transferLocalHostProcessOwner( - request: LocalHostProcessOwnerTransferRequest, - adapter: LocalHostProcessOwnerTransferAdapter, +export async function handoffLocalHostProcessDeployment( + request: LocalHostProcessDeploymentHandoffRequest, + adapter: LocalHostProcessDeploymentHandoffAdapter, authorityOptions: LocalHostDeploymentAuthorityOptions = {}, -): Promise { +): Promise { const staged = await adapter.stageTarget(request.target, request.transactionId); return withLocalHostDeploymentAuthority( request.rootId, @@ -123,7 +123,7 @@ export async function transferLocalHostProcessOwner( ) { try { const confirmed = await authority.apply({ - kind: 'commit_transfer', + kind: 'commit_handoff', expectedRevision: request.expectedRevision, transactionId: request.transactionId, to: request.to, @@ -131,20 +131,20 @@ export async function transferLocalHostProcessOwner( }); if (confirmed.kind === 'rejected' || !confirmed.record) { return recoveryRequired( - 'commit_owner', + 'commit_handoff', current, - new Error('Committed local Host owner durability could not be confirmed'), + new Error('Committed local Host handoff durability could not be confirmed'), ); } return { kind: 'completed', record: confirmed.record }; } catch (cause) { - return recoveryRequired('commit_owner', current, cause); + return recoveryRequired('commit_handoff', current, cause); } } let begun: Awaited>; const beginTransition = { - kind: 'begin_transfer', + kind: 'begin_handoff', expectedRevision: request.expectedRevision, transactionId: request.transactionId, from: request.from, @@ -157,64 +157,64 @@ export async function transferLocalHostProcessOwner( begun = await authority.apply(beginTransition); } if (begun.kind === 'rejected') return begun; - const transferRecord = begun.record; - if (!transferRecord || transferRecord.state.kind !== 'transferring') { - throw new Error('Local Host owner transfer did not persist transfer intent'); + const handoffRecord = begun.record; + if (!handoffRecord || handoffRecord.state.kind !== 'handoff') { + throw new Error('Local Host deployment handoff did not persist its intent'); } let host: Awaited>; try { host = await adapter.prepareHostCutover( request.rootId, - transferRecord.state.selected, + handoffRecord.state.selected, request.target, staged, request.activeWorkPolicy, ); } catch (cause) { - return recoveryRequired('prepare_host_cutover', transferRecord, cause); + return recoveryRequired('prepare_host_cutover', handoffRecord, cause); } if (host.kind === 'active_work') { try { const rolledBack = await authority.apply({ - kind: 'rollback_transfer', - expectedRevision: transferRecord.revision, + kind: 'rollback_handoff', + expectedRevision: handoffRecord.revision, transactionId: request.transactionId, from: request.from, - selected: transferRecord.state.selected, + selected: handoffRecord.state.selected, }); if (rolledBack.kind === 'rejected' || !rolledBack.record) { return recoveryRequired( 'rollback_active_work', - transferRecord, + handoffRecord, new Error('Active-work rollback was rejected'), ); } return { kind: 'active_work', record: rolledBack.record }; } catch (cause) { - return recoveryRequired('rollback_active_work', transferRecord, cause); + return recoveryRequired('rollback_active_work', handoffRecord, cause); } } if (host.kind !== 'target_present') { - const writerRelease = await runPhase('observe_writer_release', transferRecord, () => + const writerRelease = await runPhase('observe_writer_release', handoffRecord, () => adapter.observeWriterRelease(request.rootId), ); if (writerRelease) return writerRelease; - const activation = await runPhase('activate_target', transferRecord, () => + const activation = await runPhase('activate_target', handoffRecord, () => adapter.activateTarget(request.rootId, staged), ); if (activation) return activation; } - const verification = await runPhase('verify_target_ready', transferRecord, () => + const verification = await runPhase('verify_target_ready', handoffRecord, () => adapter.verifyTargetReady(request.rootId, request.target, staged), ); if (verification) return verification; let committed: Awaited>; const commitTransition = { - kind: 'commit_transfer', - expectedRevision: transferRecord.revision, + kind: 'commit_handoff', + expectedRevision: handoffRecord.revision, transactionId: request.transactionId, to: request.to, target: request.target, @@ -225,14 +225,14 @@ export async function transferLocalHostProcessOwner( try { committed = await authority.apply(commitTransition); } catch (cause) { - return recoveryRequired('commit_owner', transferRecord, cause); + return recoveryRequired('commit_handoff', handoffRecord, cause); } } if (committed.kind === 'rejected' || !committed.record) { return recoveryRequired( - 'commit_owner', - transferRecord, - new Error('Verified local Host owner transfer could not be committed'), + 'commit_handoff', + handoffRecord, + new Error('Verified local Host deployment handoff could not be committed'), ); } return { kind: 'completed', record: committed.record }; @@ -243,13 +243,13 @@ export async function transferLocalHostProcessOwner( async function runPhase( phase: Exclude< - LocalHostProcessOwnerTransferPhase, - 'prepare_host_cutover' | 'commit_owner' | 'rollback_active_work' + LocalHostProcessDeploymentHandoffPhase, + 'prepare_host_cutover' | 'commit_handoff' | 'rollback_active_work' >, record: LocalHostDeploymentRecord, operation: () => Promise, ): Promise< - Extract | undefined + Extract | undefined > { try { await operation(); @@ -260,10 +260,10 @@ async function runPhase( } function recoveryRequired( - phase: LocalHostProcessOwnerTransferPhase, + phase: LocalHostProcessDeploymentHandoffPhase, record: LocalHostDeploymentRecord, cause: unknown, -): Extract { +): Extract { return { kind: 'recovery_required', phase, record, cause }; }