From 45b27f9849460377f5af392ac4a86efdf33e0adf Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 14:43:07 +0800 Subject: [PATCH] feat(runtime-host): coordinate local owner transfer Stage an exact target before persisting transfer intent, then hold the single deployment-authority lock through exact Host retirement, writer release, activation, Ready verification, and owner commit. Preserve transferring state for crash recovery and safely restore the previous owner only when active work refused retirement before cutover. Generated-by: Codex --- .../local-process-owner-transfer.test.ts | 427 ++++++++++++++++++ packages/runtime-host/src/operator/index.ts | 8 + .../src/operator/local-deployment-owner.ts | 51 ++- .../operator/local-process-owner-transfer.ts | 284 ++++++++++++ 4 files changed, 759 insertions(+), 11 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/local-process-owner-transfer.test.ts create mode 100644 packages/runtime-host/src/operator/local-process-owner-transfer.ts diff --git a/packages/runtime-host/src/__tests__/local-process-owner-transfer.test.ts b/packages/runtime-host/src/__tests__/local-process-owner-transfer.test.ts new file mode 100644 index 0000000000..3eefe4fd38 --- /dev/null +++ b/packages/runtime-host/src/__tests__/local-process-owner-transfer.test.ts @@ -0,0 +1,427 @@ +/* + * 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 } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + applyLocalHostDeploymentTransition, + readLocalHostDeploymentRecord, + type LocalHostDeploymentAuthorityOptions, + type RuntimeHostInstallationOwner, +} from '../operator/local-deployment-owner.js'; +import { + transferLocalHostProcessOwner, + type LocalHostProcessOwnerTransferAdapter, +} from '../operator/local-process-owner-transfer.js'; +import type { RuntimeHostDeploymentIdentity } from '../operator/update-package-evidence.js'; + +const ROOT_ID = 'b'.repeat(64); +const DESKTOP: RuntimeHostInstallationOwner = { + kind: 'desktop', + installationId: 'desktop:stable', +}; +const CLI: RuntimeHostInstallationOwner = { + kind: 'cli', + installationId: 'cli:global', +}; +const OLD_DEPLOYMENT: RuntimeHostDeploymentIdentity = { + kind: 'npm_registry', + version: '1.0.0', + integrity: `sha512-${Buffer.alloc(64, 1).toString('base64')}`, +}; +const TARGET_DEPLOYMENT: RuntimeHostDeploymentIdentity = { + kind: 'npm_registry', + version: '2.0.0', + integrity: `sha512-${Buffer.alloc(64, 2).toString('base64')}`, +}; + +async function authority(t: test.TestContext): Promise { + const authorityRoot = await mkdtemp(join(tmpdir(), 'maka-local-process-transfer-')); + t.after(() => rm(authorityRoot, { recursive: true, force: true })); + return { authorityRoot }; +} + +async function claimed(options: LocalHostDeploymentAuthorityOptions) { + const result = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: OLD_DEPLOYMENT }, + options, + ); + assert.equal(result.kind, 'applied'); + return result.record!; +} + +function adapter( + events: string[], + host: 'target_absent' | 'target_present' | 'active_work' = 'target_absent', +): LocalHostProcessOwnerTransferAdapter<{ readonly path: string }> { + return { + async stageTarget(target, transactionId) { + events.push(`stage:${target.version}:${transactionId}`); + return { path: '/verified/maka' }; + }, + async prepareHostCutover(_rootId, _selected, _target, _staged, policy) { + events.push(`retire:${policy}`); + return { kind: host }; + }, + async observeWriterRelease() { + events.push('writer_released'); + }, + async activateTarget(_rootId, staged) { + events.push(`activate:${staged.path}`); + }, + async verifyTargetReady(_rootId, target) { + events.push(`ready:${target.version}`); + }, + }; +} + +test('stages first and commits only after retirement, writer release, and exact Ready verification', async (t) => { + const options = await authority(t); + const initial = await claimed(options); + const events: string[] = []; + + const result = await transferLocalHostProcessOwner( + { + rootId: ROOT_ID, + expectedRevision: initial.revision, + transactionId: 'desktop-to-cli', + from: DESKTOP, + to: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work', + }, + { + ...adapter(events), + async prepareHostCutover(rootId, _selected, _target, _staged, policy) { + const intent = await readLocalHostDeploymentRecord(rootId, options); + assert.equal(intent?.state.kind, 'transferring'); + events.push(`retire:${policy}`); + return { kind: 'target_absent' }; + }, + }, + options, + ); + + assert.equal(result.kind, 'completed'); + assert.deepEqual(events, [ + 'stage:2.0.0:desktop-to-cli', + 'retire:refuse_active_work', + 'writer_released', + 'activate:/verified/maka', + 'ready:2.0.0', + ]); + assert.deepEqual(result.record.state, { + kind: 'owned', + owner: CLI, + selected: TARGET_DEPLOYMENT, + previous: OLD_DEPLOYMENT, + }); + + const retryEvents: string[] = []; + const retried = await transferLocalHostProcessOwner( + { + rootId: ROOT_ID, + expectedRevision: initial.revision, + transactionId: 'desktop-to-cli', + from: DESKTOP, + to: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work', + }, + adapter(retryEvents), + options, + ); + assert.equal(retried.kind, 'completed'); + assert.equal(retried.record.revision, result.record.revision); + assert.deepEqual(retryEvents, ['stage:2.0.0:desktop-to-cli']); +}); + +test('replays commit to confirm durability instead of trusting read-back state', async (t) => { + if (process.platform === 'win32') return; + const base = await authority(t); + const initial = await claimed(base); + let publishCount = 0; + let confirmationFailure = true; + const options: LocalHostDeploymentAuthorityOptions = { + ...base, + beforeDirectorySync: (_path, purpose) => { + if (purpose === 'record_publish') { + publishCount += 1; + if (publishCount === 2) throw new Error('injected commit sync failure'); + } + if (purpose === 'unchanged_confirmation' && confirmationFailure) { + confirmationFailure = false; + throw new Error('injected confirmation sync failure'); + } + }, + }; + const request = { + rootId: ROOT_ID, + expectedRevision: initial.revision, + transactionId: 'durability-recovery', + from: DESKTOP, + to: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work' as const, + }; + + const first = await transferLocalHostProcessOwner(request, adapter([]), options); + assert.equal(first.kind, 'recovery_required'); + assert.equal(first.kind === 'recovery_required' ? first.phase : undefined, 'commit_owner'); + + const recoveryEvents: string[] = []; + const recovered = await transferLocalHostProcessOwner(request, adapter(recoveryEvents), options); + assert.equal(recovered.kind, 'completed'); + assert.deepEqual(recoveryEvents, ['stage:2.0.0:durability-recovery']); +}); + +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( + ROOT_ID, + { + kind: 'select', + expectedRevision: initial.revision, + owner: DESKTOP, + selected: { ...OLD_DEPLOYMENT, version: '1.1.0', integrity: TARGET_DEPLOYMENT.integrity }, + }, + options, + ); + assert.equal(changed.kind, 'applied'); + const events: string[] = []; + + const result = await transferLocalHostProcessOwner( + { + rootId: ROOT_ID, + expectedRevision: initial.revision, + transactionId: 'stale-confirmation', + from: DESKTOP, + to: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work', + }, + adapter(events), + options, + ); + + assert.equal(result.kind, 'rejected'); + assert.equal(result.kind === 'rejected' ? result.reason : undefined, 'revision_changed'); + assert.deepEqual(events, ['stage:2.0.0:stale-confirmation']); +}); + +test('leaves the previous owner authoritative when target staging fails', async (t) => { + const options = await authority(t); + const initial = await claimed(options); + const failingAdapter = adapter([]); + failingAdapter.stageTarget = async () => { + throw new Error('integrity verification failed'); + }; + + await assert.rejects( + transferLocalHostProcessOwner( + { + rootId: ROOT_ID, + expectedRevision: initial.revision, + transactionId: 'stage-failed', + from: DESKTOP, + to: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work', + }, + failingAdapter, + options, + ), + /integrity verification failed/, + ); + assert.deepEqual((await readLocalHostDeploymentRecord(ROOT_ID, options))?.state, { + kind: 'owned', + owner: DESKTOP, + selected: OLD_DEPLOYMENT, + }); +}); + +test('rolls back durable intent when the exact Host refuses active work', async (t) => { + const options = await authority(t); + const initial = await claimed(options); + const events: string[] = []; + + const result = await transferLocalHostProcessOwner( + { + rootId: ROOT_ID, + expectedRevision: initial.revision, + transactionId: 'active-work', + from: DESKTOP, + to: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work', + }, + adapter(events, 'active_work'), + options, + ); + + assert.equal(result.kind, 'active_work'); + assert.deepEqual(result.record.state, { + kind: 'owned', + owner: DESKTOP, + selected: OLD_DEPLOYMENT, + }); + 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) => { + const options = await authority(t); + const initial = await claimed(options); + const firstEvents: string[] = []; + const firstAdapter = adapter(firstEvents); + firstAdapter.activateTarget = async () => { + firstEvents.push('activate:failed'); + throw new Error('launch failed'); + }; + + const request = { + rootId: ROOT_ID, + expectedRevision: initial.revision, + transactionId: 'recover-after-retirement', + from: DESKTOP, + to: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'interrupt_active_work' as const, + }; + const failed = await transferLocalHostProcessOwner(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'); + + const recoveryEvents: string[] = []; + const recovered = await transferLocalHostProcessOwner( + request, + adapter(recoveryEvents, 'target_absent'), + options, + ); + assert.equal(recovered.kind, 'completed'); + assert.deepEqual(recoveryEvents, [ + 'stage:2.0.0:recover-after-retirement', + 'retire:interrupt_active_work', + 'writer_released', + 'activate:/verified/maka', + 'ready:2.0.0', + ]); +}); + +test('recognizes an already-started exact target without retiring or launching it again', async (t) => { + const options = await authority(t); + const initial = await claimed(options); + const firstEvents: string[] = []; + const firstAdapter = adapter(firstEvents); + firstAdapter.verifyTargetReady = async () => { + firstEvents.push('ready:failed'); + throw new Error('caller lost the Ready result'); + }; + const request = { + rootId: ROOT_ID, + expectedRevision: initial.revision, + transactionId: 'recover-running-target', + from: DESKTOP, + to: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work' as const, + }; + assert.equal( + (await transferLocalHostProcessOwner(request, firstAdapter, options)).kind, + 'recovery_required', + ); + + const recoveryEvents: string[] = []; + const recovered = await transferLocalHostProcessOwner( + request, + adapter(recoveryEvents, 'target_present'), + options, + ); + + assert.equal(recovered.kind, 'completed'); + assert.deepEqual(recoveryEvents, [ + 'stage:2.0.0:recover-running-target', + 'retire:refuse_active_work', + 'ready:2.0.0', + ]); +}); + +test('serializes the whole cutover so a competing owner mutation cannot enter mid-transfer', async (t) => { + const options = await authority(t); + const initial = await claimed(options); + let releaseRetirement!: () => void; + let retirementEntered!: () => void; + const entered = new Promise((resolve) => { + retirementEntered = resolve; + }); + const held = new Promise((resolve) => { + releaseRetirement = resolve; + }); + const events: string[] = []; + const heldAdapter = adapter(events); + heldAdapter.prepareHostCutover = async () => { + retirementEntered(); + await held; + return { kind: 'target_absent' }; + }; + + const transfer = transferLocalHostProcessOwner( + { + rootId: ROOT_ID, + expectedRevision: initial.revision, + transactionId: 'serialized-transfer', + from: DESKTOP, + to: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work', + }, + heldAdapter, + options, + ); + await entered; + let competitorSettled = false; + const competitor = applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'release', + expectedRevision: initial.revision, + owner: DESKTOP, + }, + options, + ).finally(() => { + competitorSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(competitorSettled, false); + + releaseRetirement(); + assert.equal((await transfer).kind, 'completed'); + const competingResult = await competitor; + assert.equal(competingResult.kind, 'rejected'); + assert.equal( + competingResult.kind === 'rejected' ? competingResult.reason : undefined, + 'owner_changed', + ); +}); diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index 57744aad0e..030e33b285 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -79,3 +79,11 @@ export { type LocalHostDeploymentTransitionResult, 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'; diff --git a/packages/runtime-host/src/operator/local-deployment-owner.ts b/packages/runtime-host/src/operator/local-deployment-owner.ts index 4c7c222cda..72a7b4e160 100644 --- a/packages/runtime-host/src/operator/local-deployment-owner.ts +++ b/packages/runtime-host/src/operator/local-deployment-owner.ts @@ -251,6 +251,11 @@ export type LocalHostDeploymentDirectorySyncPurpose = | 'unchanged_confirmation' | 'workspace_cleanup'; +export interface LocalHostDeploymentAuthority { + read(): Promise; + apply(transition: LocalHostDeploymentTransition): Promise; +} + export class LocalHostDeploymentAuthorityError extends Error { constructor( readonly code: 'invalid_input' | 'invalid_record' | 'authority_io_failed' | 'commit_unknown', @@ -322,8 +327,25 @@ export async function applyLocalHostDeploymentTransition( transition: LocalHostDeploymentTransition, options: LocalHostDeploymentAuthorityOptions = {}, ): Promise { - assertRootId(rootId); const canonicalTransition = parseTransition(transition); + return withLocalHostDeploymentAuthority( + rootId, + (authority) => authority.apply(canonicalTransition), + options, + ); +} + +/** + * Holds the one deployment-authority lock while a caller coordinates a complete + * owner transition. The callback receives the only mutation capability so it + * cannot accidentally acquire a second lock for the same record. + */ +export async function withLocalHostDeploymentAuthority( + rootId: string, + operation: (authority: LocalHostDeploymentAuthority) => Promise, + options: LocalHostDeploymentAuthorityOptions = {}, +): Promise { + assertRootId(rootId); const { authorityRoot, durabilityBoundary } = resolveLocalHostDeploymentAuthorityLocation(options); await preparePrivateDirectory(authorityRoot, durabilityBoundary, options); @@ -333,16 +355,23 @@ export async function applyLocalHostDeploymentTransition( path, async () => { await removeAbandonedRecordWorkspaces(authorityRoot, rootId, options); - const current = await readRecord(path, rootId); - const result = reduceTransition(rootId, current, canonicalTransition); - if (result.kind === 'unchanged') { - await confirmUnchangedDurability(authorityRoot, options); - return result; - } - if (result.kind === 'rejected') return result; - if (result.record) await writeRecord(path, result.record, options); - else await removeRecord(path, options); - return result; + const authority: LocalHostDeploymentAuthority = { + read: () => readRecord(path, rootId), + apply: async (nextTransition) => { + const canonicalTransition = parseTransition(nextTransition); + const current = await readRecord(path, rootId); + const result = reduceTransition(rootId, current, canonicalTransition); + if (result.kind === 'unchanged') { + await confirmUnchangedDurability(authorityRoot, options); + return result; + } + if (result.kind === 'rejected') return result; + if (result.record) await writeRecord(path, result.record, options); + else await removeRecord(path, options); + return result; + }, + }; + return operation(authority); }, AUTHORITY_LOCK_TIMEOUT_MS, ); diff --git a/packages/runtime-host/src/operator/local-process-owner-transfer.ts b/packages/runtime-host/src/operator/local-process-owner-transfer.ts new file mode 100644 index 0000000000..09da56ccd4 --- /dev/null +++ b/packages/runtime-host/src/operator/local-process-owner-transfer.ts @@ -0,0 +1,284 @@ +/* + * 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 { RuntimeHostDeploymentIdentity } from './update-package-evidence.js'; +import { + withLocalHostDeploymentAuthority, + type LocalHostDeploymentAuthorityOptions, + type LocalHostDeploymentRecord, + type LocalHostDeploymentTransitionRejection, + type RuntimeHostInstallationOwner, +} from './local-deployment-owner.js'; + +export type LocalHostTransferActiveWorkPolicy = 'refuse_active_work' | 'interrupt_active_work'; + +export interface LocalHostProcessOwnerTransferRequest { + readonly rootId: string; + readonly expectedRevision: string; + readonly transactionId: string; + readonly from: RuntimeHostInstallationOwner; + readonly to: RuntimeHostInstallationOwner; + readonly target: RuntimeHostDeploymentIdentity; + readonly activeWorkPolicy: LocalHostTransferActiveWorkPolicy; +} + +export interface LocalHostProcessOwnerTransferAdapter { + /** + * 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. + */ + stageTarget(target: RuntimeHostDeploymentIdentity, transactionId: string): Promise; + /** + * 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. + */ + prepareHostCutover( + rootId: string, + selected: RuntimeHostDeploymentIdentity, + target: RuntimeHostDeploymentIdentity, + staged: StagedTarget, + policy: LocalHostTransferActiveWorkPolicy, + ): 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. */ + activateTarget(rootId: string, staged: StagedTarget): Promise; + /** Verifies Ready, root identity, and the exact deployment selected by the request. */ + verifyTargetReady( + rootId: string, + target: RuntimeHostDeploymentIdentity, + staged: StagedTarget, + ): Promise; +} + +export type LocalHostProcessOwnerTransferPhase = + | 'prepare_host_cutover' + | 'observe_writer_release' + | 'activate_target' + | 'verify_target_ready' + | 'commit_owner' + | 'rollback_active_work'; + +export type LocalHostProcessOwnerTransferResult = + | { + readonly kind: 'completed'; + readonly record: LocalHostDeploymentRecord; + } + | { + readonly kind: 'active_work'; + readonly record: LocalHostDeploymentRecord; + } + | { + readonly kind: 'rejected'; + readonly reason: LocalHostDeploymentTransitionRejection; + readonly record: LocalHostDeploymentRecord | undefined; + } + | { + readonly kind: 'recovery_required'; + readonly phase: LocalHostProcessOwnerTransferPhase; + 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. + */ +export async function transferLocalHostProcessOwner( + request: LocalHostProcessOwnerTransferRequest, + adapter: LocalHostProcessOwnerTransferAdapter, + authorityOptions: LocalHostDeploymentAuthorityOptions = {}, +): Promise { + const staged = await adapter.stageTarget(request.target, request.transactionId); + return withLocalHostDeploymentAuthority( + request.rootId, + async (authority) => { + const current = await authority.read(); + if ( + current?.state.kind === 'owned' && + sameOwner(current.state.owner, request.to) && + sameDeployment(current.state.selected, request.target) + ) { + try { + const confirmed = await authority.apply({ + kind: 'commit_transfer', + expectedRevision: request.expectedRevision, + transactionId: request.transactionId, + to: request.to, + target: request.target, + }); + if (confirmed.kind === 'rejected' || !confirmed.record) { + return recoveryRequired( + 'commit_owner', + current, + new Error('Committed local Host owner durability could not be confirmed'), + ); + } + return { kind: 'completed', record: confirmed.record }; + } catch (cause) { + return recoveryRequired('commit_owner', current, cause); + } + } + + let begun: Awaited>; + const beginTransition = { + kind: 'begin_transfer', + expectedRevision: request.expectedRevision, + transactionId: request.transactionId, + from: request.from, + to: request.to, + target: request.target, + } as const; + try { + begun = await authority.apply(beginTransition); + } catch { + 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'); + } + + let host: Awaited>; + try { + host = await adapter.prepareHostCutover( + request.rootId, + transferRecord.state.selected, + request.target, + staged, + request.activeWorkPolicy, + ); + } catch (cause) { + return recoveryRequired('prepare_host_cutover', transferRecord, cause); + } + if (host.kind === 'active_work') { + try { + const rolledBack = await authority.apply({ + kind: 'rollback_transfer', + expectedRevision: transferRecord.revision, + transactionId: request.transactionId, + from: request.from, + selected: transferRecord.state.selected, + }); + if (rolledBack.kind === 'rejected' || !rolledBack.record) { + return recoveryRequired( + 'rollback_active_work', + transferRecord, + new Error('Active-work rollback was rejected'), + ); + } + return { kind: 'active_work', record: rolledBack.record }; + } catch (cause) { + return recoveryRequired('rollback_active_work', transferRecord, cause); + } + } + + if (host.kind !== 'target_present') { + const writerRelease = await runPhase('observe_writer_release', transferRecord, () => + adapter.observeWriterRelease(request.rootId), + ); + if (writerRelease) return writerRelease; + const activation = await runPhase('activate_target', transferRecord, () => + adapter.activateTarget(request.rootId, staged), + ); + if (activation) return activation; + } + const verification = await runPhase('verify_target_ready', transferRecord, () => + adapter.verifyTargetReady(request.rootId, request.target, staged), + ); + if (verification) return verification; + + let committed: Awaited>; + const commitTransition = { + kind: 'commit_transfer', + expectedRevision: transferRecord.revision, + transactionId: request.transactionId, + to: request.to, + target: request.target, + } as const; + try { + committed = await authority.apply(commitTransition); + } catch { + try { + committed = await authority.apply(commitTransition); + } catch (cause) { + return recoveryRequired('commit_owner', transferRecord, cause); + } + } + if (committed.kind === 'rejected' || !committed.record) { + return recoveryRequired( + 'commit_owner', + transferRecord, + new Error('Verified local Host owner transfer could not be committed'), + ); + } + return { kind: 'completed', record: committed.record }; + }, + authorityOptions, + ); +} + +async function runPhase( + phase: Exclude< + LocalHostProcessOwnerTransferPhase, + 'prepare_host_cutover' | 'commit_owner' | 'rollback_active_work' + >, + record: LocalHostDeploymentRecord, + operation: () => Promise, +): Promise< + Extract | undefined +> { + try { + await operation(); + return undefined; + } catch (cause) { + return recoveryRequired(phase, record, cause); + } +} + +function recoveryRequired( + phase: LocalHostProcessOwnerTransferPhase, + record: LocalHostDeploymentRecord, + cause: unknown, +): Extract { + return { kind: 'recovery_required', phase, record, cause }; +} + +function sameOwner( + left: RuntimeHostInstallationOwner, + right: RuntimeHostInstallationOwner, +): boolean { + return left.kind === right.kind && left.installationId === right.installationId; +} + +function sameDeployment( + left: RuntimeHostDeploymentIdentity, + right: RuntimeHostDeploymentIdentity, +): boolean { + return ( + left.kind === right.kind && left.version === right.version && left.integrity === right.integrity + ); +}