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 + ); +}