From c5b89b438efef00a7355c7fde52a77d2dd60bb85 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 2 Oct 2026 00:00:12 -0700 Subject: [PATCH 1/3] feat(ssh): convert an SSH host with Orca state into a managed server through a journaled migration (#16741 T8-c4) The conversion entry resumes or takes the fence, deploys and pairs the managed server into it, marks the server as migrated, then stages and commits the dormant catalog. Every step is keyed by the journal, so a repeat after a crash, deferral or lost reply resumes the same migration. Status reports an unfinished migration, and rollback is refused while one runs or when the rollback snapshot predates the migrated catalog. Inert until T6-10. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ssh/orcad-managed-migration-status.ts | 53 ++++++ ...rcad-migration-cutover-coordinator.test.ts | 60 +----- .../ssh/orcad-migration-cutover-fixture.ts | 36 ++++ .../orcad-migration-cutover-journal.test.ts | 30 +-- .../ssh/orcad-migration-destination-fake.ts | 58 ++++++ src/main/ssh/orcad-runtime-conversion.test.ts | 174 ++++++++++++++++++ src/main/ssh/orcad-runtime-conversion.ts | 149 +++++++++++++++ src/main/ssh/orcad-runtime-deployment.test.ts | 61 +++++- src/main/ssh/orcad-runtime-deployment.ts | 38 +++- .../ssh/orcad-runtime-maintenance.test.ts | 30 ++- src/main/ssh/orcad-runtime-maintenance.ts | 37 ++++ src/main/ssh/orcad-runtime-status.ts | 12 ++ src/main/ssh/ssh-connection-store.ts | 5 + src/shared/orcad-managed-runtime.ts | 20 ++ ...me-environment-managed-orcad-store.test.ts | 20 ++ ...runtime-environment-managed-orcad-store.ts | 28 +++ src/shared/runtime-environment-sidecar.ts | 4 + src/shared/runtime-environments.ts | 2 + 18 files changed, 724 insertions(+), 93 deletions(-) create mode 100644 src/main/ssh/orcad-managed-migration-status.ts create mode 100644 src/main/ssh/orcad-migration-cutover-fixture.ts create mode 100644 src/main/ssh/orcad-migration-destination-fake.ts create mode 100644 src/main/ssh/orcad-runtime-conversion.test.ts create mode 100644 src/main/ssh/orcad-runtime-conversion.ts diff --git a/src/main/ssh/orcad-managed-migration-status.ts b/src/main/ssh/orcad-managed-migration-status.ts new file mode 100644 index 000000000000..a05675ef8017 --- /dev/null +++ b/src/main/ssh/orcad-managed-migration-status.ts @@ -0,0 +1,53 @@ +/** Which dormant migrations into managed servers have not finished, for status and resume. */ +import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover' +import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' +import { listOrcadMigrationSourceCutovers } from './orcad-migration-cutover-journal' + +export type OrcadManagedPendingMigration = { + migrationId: string + environmentId: string + name: string + sshTargetId: string + phase: OrcadMigrationSourceCutover['phase'] + startedAt: string +} + +/** Throws on an unreadable journal: a migration we cannot read is not "none". */ +export function listPendingManagedOrcadMigrations( + userDataPath: string +): OrcadManagedPendingMigration[] { + return listOrcadMigrationSourceCutovers(userDataPath) + .filter((cutover) => cutover.phase !== 'source-retired') + .map((cutover) => ({ + migrationId: cutover.migrationId, + environmentId: cutover.destinationEnvironmentId, + name: cutover.destinationName, + sshTargetId: cutover.sshTargetId, + phase: cutover.phase, + startedAt: cutover.startedAt + })) +} + +export function findIncompleteManagedOrcadMigration( + userDataPath: string, + environmentId: string +): OrcadManagedPendingMigration | null { + return ( + listPendingManagedOrcadMigrations(userDataPath).find( + (migration) => migration.environmentId === environmentId + ) ?? null + ) +} + +/** The registered server is the one the journal deploys into, on the journaled registration. */ +export function environmentMatchesManagedOrcadCutover( + environment: KnownRuntimeEnvironment, + cutover: OrcadMigrationSourceCutover +): boolean { + return ( + environment.id === cutover.destinationEnvironmentId && + environment.name === cutover.destinationName && + environment.orcadDeployment?.sshTargetId === cutover.sshTargetId && + environment.orcadDeployment.sshTargetGeneration === cutover.sshTargetGeneration + ) +} diff --git a/src/main/ssh/orcad-migration-cutover-coordinator.test.ts b/src/main/ssh/orcad-migration-cutover-coordinator.test.ts index 11a767be7ad6..95dba4a4c430 100644 --- a/src/main/ssh/orcad-migration-cutover-coordinator.test.ts +++ b/src/main/ssh/orcad-migration-cutover-coordinator.test.ts @@ -1,12 +1,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { - OrcadMigrationCatalogState, - OrcadMigrationManifest -} from '../../shared/orcad-migration-manifest' import type { SshTarget } from '../../shared/ssh-types' import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' import { Store } from '../persistence/loading-store/store' @@ -20,6 +16,7 @@ import { ORCAD_MIGRATION_DESTINATION_UNSUPPORTED } from './orcad-migration-catal import { listOrcadMigrationSourceCutovers } from './orcad-migration-cutover-journal' import { fenceOrcadMigrationSource } from './orcad-migration-source-fence' import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' +import { fakeOrcadMigrationDestination as fakeDestination } from './orcad-migration-destination-fake' const TARGET: SshTarget = { id: 'ssh-prod', @@ -38,59 +35,6 @@ afterEach(async () => { } }) -/** An in-memory destination with the T6-9 semantics: idempotent stage, receipt-keyed commit. */ -function fakeDestination() { - let state: 'absent' | 'staged' | 'committed' = 'absent' - const view = (manifest: OrcadMigrationManifest): OrcadMigrationCatalogState => { - const base = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } - if (state === 'committed') { - return { - ...base, - state, - receipt: { - version: 1, - migrationId: manifest.migrationId, - manifestSha256: manifest.manifestSha256, - source: manifest.source, - importedAt: '2026-10-01T00:00:00.000Z', - repositoryIds: [], - projectGroupIds: [], - folderWorkspaceIds: [] - } - } - } - return state === 'staged' - ? { ...base, state, stagedAt: '2026-10-01T00:00:00.000Z', snapshotUploads: [] } - : { ...base, state } - } - const destination = { - commits: 0, - readState: vi.fn(async (manifest: OrcadMigrationManifest) => view(manifest)), - stage: vi.fn(async (manifest: OrcadMigrationManifest) => { - if (state === 'absent') { - state = 'staged' - } - return view(manifest) - }), - commit: vi.fn(async (manifest: OrcadMigrationManifest) => { - if (state === 'staged') { - state = 'committed' - destination.commits += 1 - } - return view(manifest) - }), - abort: vi.fn(async (manifest: OrcadMigrationManifest) => { - const aborted = state === 'staged' - if (aborted) { - state = 'absent' - } - return { ...view(manifest), aborted, ...(aborted ? {} : { durableAbsent: true as const }) } - }), - stageChunk: vi.fn() - } - return destination -} - async function setup() { const userDataPath = mkdtempSync(join(tmpdir(), 'orcad-cutover-coordinator-')) directories.push(userDataPath) diff --git a/src/main/ssh/orcad-migration-cutover-fixture.ts b/src/main/ssh/orcad-migration-cutover-fixture.ts new file mode 100644 index 000000000000..df56e7a5814b --- /dev/null +++ b/src/main/ssh/orcad-migration-cutover-fixture.ts @@ -0,0 +1,36 @@ +import { ORCAD_MIGRATION_MANIFEST_VERSION } from '../../shared/orcad-migration-manifest' +import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover' +import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest' + +/** A valid journal entry whose manifest is bound to its target, generation and destination. */ +export function orcadMigrationCutoverFixture( + migrationId = 'migration-1', + sshTargetId = 'ssh-1', + binding: { generation?: number; environmentId?: string; name?: string } = {} +): OrcadMigrationSourceCutover { + const generation = binding.generation ?? 2 + const environmentId = binding.environmentId ?? 'env-1' + const unsigned = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId, + createdAt: '2026-10-01T00:00:00.000Z', + source: { sshTargetId, sshTargetGeneration: generation, targetLabel: 'Prod' }, + payload: { repositories: [], projectGroups: [], folderWorkspaces: [] }, + destinationEnvironmentId: environmentId + } + const manifest = { ...unsigned, manifestSha256: computeOrcadMigrationManifestSha256(unsigned) } + return { + version: 1, + migrationId, + phase: 'source-fenced', + startedAt: '2026-10-01T00:00:00.000Z', + updatedAt: '2026-10-01T00:00:00.000Z', + destinationEnvironmentId: environmentId, + destinationName: binding.name ?? 'Managed', + sshTargetId, + sshTargetGeneration: generation, + manifestSha256: manifest.manifestSha256, + provenPtyIds: [], + manifest + } +} diff --git a/src/main/ssh/orcad-migration-cutover-journal.test.ts b/src/main/ssh/orcad-migration-cutover-journal.test.ts index ef8656b2c87f..b8abb53a73b3 100644 --- a/src/main/ssh/orcad-migration-cutover-journal.test.ts +++ b/src/main/ssh/orcad-migration-cutover-journal.test.ts @@ -2,9 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { ORCAD_MIGRATION_MANIFEST_VERSION } from '../../shared/orcad-migration-manifest' -import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover' -import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest' +import { orcadMigrationCutoverFixture as cutover } from './orcad-migration-cutover-fixture' import { findOrcadMigrationSourceCutoverForTarget, listOrcadMigrationSourceCutovers, @@ -19,32 +17,6 @@ beforeEach(() => { }) afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) -function cutover(migrationId = 'migration-1', sshTargetId = 'ssh-1'): OrcadMigrationSourceCutover { - const unsigned = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId, - createdAt: '2026-10-01T00:00:00.000Z', - source: { sshTargetId, sshTargetGeneration: 2, targetLabel: 'Prod' }, - payload: { repositories: [], projectGroups: [], folderWorkspaces: [] }, - destinationEnvironmentId: 'env-1' - } - const manifest = { ...unsigned, manifestSha256: computeOrcadMigrationManifestSha256(unsigned) } - return { - version: 1, - migrationId, - phase: 'source-fenced', - startedAt: '2026-10-01T00:00:00.000Z', - updatedAt: '2026-10-01T00:00:00.000Z', - destinationEnvironmentId: 'env-1', - destinationName: 'Managed', - sshTargetId, - sshTargetGeneration: 2, - manifestSha256: manifest.manifestSha256, - provenPtyIds: [], - manifest - } -} - const journalPath = (id: string) => join(orcadMigrationCutoverJournalDirectory(userDataPath), `${id}.json`) diff --git a/src/main/ssh/orcad-migration-destination-fake.ts b/src/main/ssh/orcad-migration-destination-fake.ts new file mode 100644 index 000000000000..08208f78146d --- /dev/null +++ b/src/main/ssh/orcad-migration-destination-fake.ts @@ -0,0 +1,58 @@ +import { vi } from 'vitest' +import type { + OrcadMigrationCatalogState, + OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' + +/** An in-memory destination with the T6-9 semantics: idempotent stage, receipt-keyed commit. */ +export function fakeOrcadMigrationDestination() { + let state: 'absent' | 'staged' | 'committed' = 'absent' + const view = (manifest: OrcadMigrationManifest): OrcadMigrationCatalogState => { + const base = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } + if (state === 'committed') { + return { + ...base, + state, + receipt: { + version: 1, + migrationId: manifest.migrationId, + manifestSha256: manifest.manifestSha256, + source: manifest.source, + importedAt: '2026-10-01T00:00:00.000Z', + repositoryIds: [], + projectGroupIds: [], + folderWorkspaceIds: [] + } + } + } + return state === 'staged' + ? { ...base, state, stagedAt: '2026-10-01T00:00:00.000Z', snapshotUploads: [] } + : { ...base, state } + } + const destination = { + commits: 0, + readState: vi.fn(async (manifest: OrcadMigrationManifest) => view(manifest)), + stage: vi.fn(async (manifest: OrcadMigrationManifest) => { + if (state === 'absent') { + state = 'staged' + } + return view(manifest) + }), + commit: vi.fn(async (manifest: OrcadMigrationManifest) => { + if (state === 'staged') { + state = 'committed' + destination.commits += 1 + } + return view(manifest) + }), + abort: vi.fn(async (manifest: OrcadMigrationManifest) => { + const aborted = state === 'staged' + if (aborted) { + state = 'absent' + } + return { ...view(manifest), aborted, ...(aborted ? {} : { durableAbsent: true as const }) } + }), + stageChunk: vi.fn() + } + return destination +} diff --git a/src/main/ssh/orcad-runtime-conversion.test.ts b/src/main/ssh/orcad-runtime-conversion.test.ts new file mode 100644 index 000000000000..bbe2abbecb48 --- /dev/null +++ b/src/main/ssh/orcad-runtime-conversion.test.ts @@ -0,0 +1,174 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' +import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' +import { listEnvironments } from '../../shared/runtime-environment-store' +import type { SshTarget } from '../../shared/ssh-types' +import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { Store } from '../persistence/loading-store/store' +import { listOrcadMigrationSourceCutovers } from './orcad-migration-cutover-journal' +import { fakeOrcadMigrationDestination } from './orcad-migration-destination-fake' +import { SshConnectionStore } from './ssh-connection-store' + +const mocks = vi.hoisted(() => { + const state: { targetStore: unknown } = { targetStore: null } + return { state, deploy: vi.fn(), ensureTunnel: vi.fn(), directAuthority: vi.fn() } +}) +vi.mock('./ssh-target-registry', () => ({ + getSshConnectionManager: () => ({}), + getSshTargetRegistryStore: () => mocks.state.targetStore, + hasRegisteredDirectSshAuthority: mocks.directAuthority +})) +vi.mock('./orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) +vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensureTunnel })) + +const { convertSshTargetToManagedOrcad } = await import('./orcad-runtime-conversion') + +const TARGET: SshTarget = { + id: 'ssh-prod', + label: 'Production', + host: 'prod.example.com', + port: 22, + username: 'deploy', + generation: 2 +} + +let userDataPath: string +let store: Store +let destination: ReturnType + +beforeEach(() => { + vi.resetAllMocks() + userDataPath = mkdtempSync(join(tmpdir(), 'orcad-conversion-')) + store = createSqliteTestStore(Store, { dataFile: join(userDataPath, 'orca-data.json') }) + store.addSshTarget(TARGET) + store.addRepo({ + id: 'repo-1', + path: '/srv/app', + displayName: 'App', + badgeColor: '#737373', + addedAt: 1, + kind: 'git', + connectionId: TARGET.id + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SshConnectionStore wraps the real test store it is given. + mocks.state.targetStore = new SshConnectionStore(store as never) + destination = fakeOrcadMigrationDestination() + mocks.directAuthority.mockReturnValue(false) + mocks.ensureTunnel.mockResolvedValue(undefined) + // Registers the server the fence named, as the real deploy would after pairing. + mocks.deploy.mockImplementation(async (path: string, args: { name: string }) => { + const owner = getManagedOrcadOwnerEnvironmentId(store.getSshTarget(TARGET.id)?.owner) + if (!owner) { + throw new Error('deploy without a fence') + } + if (!listEnvironments(path).some((entry) => entry.id === owner)) { + addManagedOrcadEnvironment(path, { + id: owner, + name: args.name, + pairingCode: encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: 'ws://127.0.0.1:46768/', + deviceToken: 'device-token', + publicKeyB64: 'public-key' + }), + orcadDeployment: { + sshTargetId: TARGET.id, + sshTargetGeneration: 2, + localPort: 46_768, + remotePort: 6_768 + } + }) + } + return { outcome: 'created', environment: {}, activeVersion: '1.0.0' } + }) +}) + +afterEach(async () => { + await closeTestStores() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +const releaseDirectSession = vi.fn(async () => {}) +const convert = (listRelayPtyIds: (() => Promise) | null = async () => []) => + convertSshTargetToManagedOrcad(userDataPath, { + sshTargetId: TARGET.id, + name: 'Managed', + listRelayPtyIds, + destinationFor: () => destination, + releaseDirectSession, + now: () => new Date('2026-10-02T00:00:00.000Z') + }) + +describe('converting an SSH host into a managed server', () => { + it('fences, deploys, marks the server, then commits the catalog exactly once', async () => { + const result = await convert() + expect(result).toMatchObject({ outcome: 'committed' }) + expect(releaseDirectSession).toHaveBeenCalledWith(TARGET.id) + expect(mocks.deploy).toHaveBeenCalledWith( + userDataPath, + expect.objectContaining({ migration: true }) + ) + expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.phase).toBe('destination-committed') + const [environment] = listEnvironments(userDataPath) + expect(environment?.orcadMigratedAt).toBe('2026-10-02T00:00:00.000Z') + expect(destination.commits).toBe(1) + // Retiring the source is the next step; until then it keeps every row. + expect(store.getRepos().map((repo) => repo.id)).toEqual(['repo-1']) + }) + + it('refuses before touching the host while terminals run or cannot be counted', async () => { + store.upsertSshRemotePtyLease({ targetId: TARGET.id, ptyId: 'p', state: 'expired' }) + await expect(convert(null)).resolves.toMatchObject({ + outcome: 'refused', + verdict: 'unverifiable' + }) + expect(releaseDirectSession).not.toHaveBeenCalled() + expect(store.getSshTarget(TARGET.id)?.owner).toBeUndefined() + expect(listOrcadMigrationSourceCutovers(userDataPath)).toEqual([]) + }) + + it('keeps the fence across a deferred deploy and resumes the same migration', async () => { + mocks.deploy.mockResolvedValueOnce({ + outcome: 'deferred', + candidateVersion: '1.0.0', + code: 'orcad_update_terminals_running', + reason: 'busy' + }) + await expect(convert()).resolves.toMatchObject({ outcome: 'deferred' }) + const [fenced] = listOrcadMigrationSourceCutovers(userDataPath) + expect(fenced?.phase).toBe('source-fenced') + await expect(convert(null)).resolves.toMatchObject({ + outcome: 'committed', + migrationId: fenced?.migrationId + }) + expect(listOrcadMigrationSourceCutovers(userDataPath)).toHaveLength(1) + }) + + it('resumes after a lost commit reply without committing twice', async () => { + const read = destination.readState.getMockImplementation() + const commit = destination.commit.getMockImplementation() + let reachable = true + destination.readState.mockImplementation(async (manifest) => { + if (!reachable) { + throw new Error('socket closed') + } + return read!(manifest) + }) + // The commit lands, then contact is lost before either the reply or a re-read arrives. + destination.commit.mockImplementationOnce(async (manifest) => { + await commit!(manifest) + reachable = false + throw new Error('socket closed') + }) + await expect(convert()).rejects.toThrow('socket closed') + expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.phase).toBe('destination-staged') + reachable = true + await expect(convert(null)).resolves.toMatchObject({ outcome: 'committed' }) + expect(destination.commits).toBe(1) + expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.phase).toBe('destination-committed') + }) +}) diff --git a/src/main/ssh/orcad-runtime-conversion.ts b/src/main/ssh/orcad-runtime-conversion.ts new file mode 100644 index 000000000000..959f9a0b30b4 --- /dev/null +++ b/src/main/ssh/orcad-runtime-conversion.ts @@ -0,0 +1,149 @@ +/** + * Converting a relay-hosted SSH target that holds Orca state into a managed orcad server: + * fence the source, deploy and pair the server, then stage and commit the dormant catalog. + * + * Every step is keyed by the journal, so calling this again after a crash or lost contact resumes + * the same migration instead of starting another. The source stays authoritative until the + * destination proves its commit; retiring the source is a separate, later step. + */ +import { randomUUID } from 'node:crypto' +import type { OrcadManagedConversionResult } from '../../shared/orcad-managed-runtime' +import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover' +import { recordManagedOrcadMigration } from '../../shared/runtime-environment-managed-orcad-store' +import { listEnvironments } from '../../shared/runtime-environment-store' +import { + redactRuntimeEnvironment, + type KnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' +import { environmentMatchesManagedOrcadCutover } from './orcad-managed-migration-status' +import { requireManagedOrcadInfrastructure } from './orcad-managed-runtime-context' +import { ensureOrcadManagedTunnel } from './orcad-managed-tunnel' +import { + commitOrcadMigrationDestination, + type OrcadMigrationDestinationCatalog +} from './orcad-migration-cutover-coordinator' +import { fenceOrcadMigrationSource, resolveOrcadMigrationFence } from './orcad-migration-source-fence' +import { + assessOrcadMigrationTerminals, + type ListRelayPtyIds +} from './orcad-migration-terminal-gate' +import { createManagedOrcadEnvironment } from './orcad-runtime-deployment' +import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' + +export type OrcadManagedConversionArgs = { + sshTargetId: string + name: string + /** The relay's process list, asked while the host is still connected directly. */ + listRelayPtyIds: ListRelayPtyIds | null + /** The destination's T6-9 catalog client, reached through the server's tunnel. */ + destinationFor: (environment: KnownRuntimeEnvironment) => OrcadMigrationDestinationCatalog + /** Releases the direct SSH session after the terminal check, before the fence. */ + releaseDirectSession: (sshTargetId: string) => Promise + now?: () => Date + signal?: AbortSignal +} + +export async function convertSshTargetToManagedOrcad( + userDataPath: string, + args: OrcadManagedConversionArgs +): Promise { + const fenced = await fenceOrResume(userDataPath, args) + if ('outcome' in fenced) { + return fenced + } + const deployed = await createManagedOrcadEnvironment(userDataPath, { + name: fenced.destinationName, + sshTargetId: fenced.sshTargetId, + migration: true, + signal: args.signal + }) + if (deployed.outcome === 'deferred') { + return deployed // The fence and journal stay; a later call resumes here. + } + const environment = listEnvironments(userDataPath).find( + (entry) => entry.id === fenced.destinationEnvironmentId + ) + if (!environment || !environmentMatchesManagedOrcadCutover(environment, fenced)) { + throw new Error('orcad_migration_destination_environment_mismatch') + } + // Before any commit can land, so a crash after it still blocks rollbacks across it. + const marked = recordManagedOrcadMigration( + userDataPath, + environment.id, + (args.now ?? (() => new Date()))().toISOString() + ) + await ensureOrcadManagedTunnel(userDataPath, environment.id) + const { claims, targetStore } = requireManagedOrcadInfrastructure() + const committed = await runTargetLifecycle(fenced.sshTargetId, () => + commitOrcadMigrationDestination( + { + userDataPath, + store: targetStore.getOrcadMigrationSource(), + claims, + destination: args.destinationFor(marked), + now: args.now + }, + fenced.migrationId + ) + ) + if (committed.phase !== 'destination-committed' && committed.phase !== 'source-retired') { + throw new Error(`orcad_migration_commit_not_proven:${committed.phase}`) + } + return { + outcome: 'committed', + environment: redactRuntimeEnvironment(marked), + migrationId: committed.migrationId + } +} + +async function fenceOrResume( + userDataPath: string, + args: OrcadManagedConversionArgs +): Promise> { + const { claims, targetStore } = requireManagedOrcadInfrastructure() + const target = targetStore.getTarget(args.sshTargetId) + if (!target) { + return refuse('unverifiable', 'orcad_migration_target_not_found', 'The SSH host is gone.') + } + const existing = resolveOrcadMigrationFence(userDataPath, target) + if (existing.state === 'fenced') { + return existing.cutover + } + const store = targetStore.getOrcadMigrationSource() + // Asked while the relay still answers; the fence re-checks leases once it holds. + const terminalProof = await assessOrcadMigrationTerminals( + store, + target.id, + args.listRelayPtyIds + ) + if (terminalProof.verdict !== 'exited') { + return refuse(terminalProof.verdict, 'orcad_migration_terminals', terminalProof.reason) + } + await args.releaseDirectSession(target.id) + const result = await runTargetLifecycle(target.id, () => + fenceOrcadMigrationSource({ + userDataPath, + store, + claims, + targetId: target.id, + destinationEnvironmentId: randomUUID(), + destinationName: args.name, + terminalProof, + hasDirectSshAuthority: hasRegisteredDirectSshAuthority, + now: args.now, + signal: args.signal + }) + ) + return result.outcome === 'fenced' + ? result.cutover + : refuse(result.verdict, result.code, result.reason) +} + +function refuse( + verdict: 'live' | 'unverifiable', + code: string, + reason: string +): Extract { + return { outcome: 'refused', verdict, code, reason } +} diff --git a/src/main/ssh/orcad-runtime-deployment.test.ts b/src/main/ssh/orcad-runtime-deployment.test.ts index f0f66ec5e5c4..0efc18f53c4e 100644 --- a/src/main/ssh/orcad-runtime-deployment.test.ts +++ b/src/main/ssh/orcad-runtime-deployment.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import { + createManagedOrcadSshOwner, + getManagedOrcadOwnerEnvironmentId +} from '../../shared/managed-orcad-ssh-owner' +import { writeOrcadMigrationSourceCutover } from './orcad-migration-cutover-journal' +import { orcadMigrationCutoverFixture } from './orcad-migration-cutover-fixture' import { listEnvironments } from '../../shared/runtime-environment-store' import type { SshTarget } from '../../shared/ssh-types' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -308,6 +313,37 @@ describe('createManagedOrcadEnvironment', () => { }) }) +describe('createManagedOrcadEnvironment for a migration', () => { + it('deploys into its own journaled fence without leaving a provisioning intent', async () => { + target = { ...target, owner: createManagedOrcadSshOwner('env-m'), generation: 9 } + writeOrcadMigrationSourceCutover( + userDataPath, + orcadMigrationCutoverFixture('m-1', 'ssh-1', { generation: 9, environmentId: 'env-m' }) + ) + await expect( + createManagedOrcadEnvironment(userDataPath, { + name: 'Managed', + sshTargetId: 'ssh-1', + migration: true + }) + ).resolves.toMatchObject({ outcome: 'created' }) + expect(listEnvironments(userDataPath).map((entry) => entry.id)).toEqual(['env-m']) + expect(target.orcadProvisioning).toBeUndefined() + }) + + it('refuses a migration deploy whose fence has no journal, before connecting', async () => { + target = { ...target, owner: createManagedOrcadSshOwner('env-m'), generation: 9 } + await expect( + createManagedOrcadEnvironment(userDataPath, { + name: 'Managed', + sshTargetId: 'ssh-1', + migration: true + }) + ).rejects.toThrow('orcad_migration_fence_required') + expect(mocks.connect).not.toHaveBeenCalled() + }) +}) + describe('getManagedOrcadRuntimeStatus', () => { it('reports the activation record and an interrupted transaction without repairing it', async () => { await deploy() @@ -339,6 +375,7 @@ describe('getManagedOrcadRuntimeStatus', () => { startedAt: 'then' }, terminals: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 39 }, + migration: null, deferredUpdate: null }) expect(mocks.recover).toHaveBeenCalledTimes(1) @@ -357,6 +394,28 @@ describe('getManagedOrcadRuntimeStatus', () => { }) }) + it('reports an unfinished migration into the server', async () => { + await deploy() + const [environment] = listEnvironments(userDataPath) + writeOrcadMigrationSourceCutover(userDataPath, { + ...orcadMigrationCutoverFixture('m-2', 'ssh-1', { + generation: 9, + environmentId: environment!.id + }), + phase: 'destination-staged' + }) + mocks.resolveContext.mockImplementation(async (claimed: SshTarget) => ({ + activationRecord: { ...emptyRecord, active: VERSION }, + connection: {}, + host: getRemoteHostPlatform('linux-x64'), + remoteHome: '/home/dev', + target: claimed + })) + await expect(getManagedOrcadRuntimeStatus(userDataPath, 'Managed')).resolves.toMatchObject({ + migration: { migrationId: 'm-2', phase: 'destination-staged' } + }) + }) + it('refuses a server whose SSH registration was re-created', async () => { await deploy() target = { ...target, generation: 10 } diff --git a/src/main/ssh/orcad-runtime-deployment.ts b/src/main/ssh/orcad-runtime-deployment.ts index b4d4cffe82c8..086525e13b81 100644 --- a/src/main/ssh/orcad-runtime-deployment.ts +++ b/src/main/ssh/orcad-runtime-deployment.ts @@ -26,6 +26,8 @@ import { resolveOrcadRemoteContext } from './orcad-remote-context' import { deployOrcad } from './orcad-remote-deploy' import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' +import { resolveOrcadMigrationFence } from './orcad-migration-source-fence' +import type { SshTarget } from '../../shared/ssh-types' import { isForceableOrcadDeferral, managedOrcadSlot, @@ -35,7 +37,14 @@ import { export async function createManagedOrcadEnvironment( userDataPath: string, - args: { name: string; sshTargetId: string; force?: boolean; signal?: AbortSignal } + args: { + name: string + sshTargetId: string + force?: boolean + signal?: AbortSignal + /** Deploying into a target fenced by a migration journal rather than claiming an empty one. */ + migration?: boolean + } ): Promise { return runTargetLifecycle(args.sshTargetId, async () => { const { connectionManager, targetStore, claims } = requireManagedOrcadInfrastructure() @@ -57,10 +66,15 @@ export async function createManagedOrcadEnvironment( throw new Error('Disconnect this SSH host before converting it to a managed Orca server.') } const current = targetStore.getTarget(args.sshTargetId) + if (args.migration) { + assertMigrationFence(userDataPath, current, environmentId, args.name) + } const claimed = claims.claim(args.sshTargetId, environmentId, { - deployName: args.name, - // Why: this deploy's own claim left a provisioning intent, or the server it registered. - ownerRecorded: Boolean(current?.orcadProvisioning) || Boolean(registered) + // A migration records itself in its journal, not as a provisioning intent. + ...(args.migration ? {} : { deployName: args.name }), + // Why: this deploy's own claim left a provisioning intent or a journal, or registered a server. + ownerRecorded: + Boolean(args.migration) || Boolean(current?.orcadProvisioning) || Boolean(registered) }) await claims.flush(args.signal) const targetGeneration = claimed.generation @@ -155,3 +169,19 @@ export async function createManagedOrcadEnvironment( } }) } + +function assertMigrationFence( + userDataPath: string, + target: SshTarget | undefined, + environmentId: string, + name: string +): void { + const fence = target ? resolveOrcadMigrationFence(userDataPath, target) : null + if ( + fence?.state !== 'fenced' || + fence.cutover.destinationEnvironmentId !== environmentId || + fence.cutover.destinationName !== name + ) { + throw new Error('orcad_migration_fence_required') + } +} diff --git a/src/main/ssh/orcad-runtime-maintenance.test.ts b/src/main/ssh/orcad-runtime-maintenance.test.ts index c150dc7b59dd..b9d89adec054 100644 --- a/src/main/ssh/orcad-runtime-maintenance.test.ts +++ b/src/main/ssh/orcad-runtime-maintenance.test.ts @@ -1,6 +1,7 @@ import { rmSync } from 'node:fs' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { listEnvironments } from '../../shared/runtime-environment-store' +import { recordManagedOrcadMigration } from '../../shared/runtime-environment-managed-orcad-store' import { createManagedLifecycleHarness, MANAGED_PREVIOUS_VERSION, @@ -19,7 +20,8 @@ const mocks = vi.hoisted(() => { recover: vi.fn(), probe: vi.fn(), buildHash: vi.fn(), - ensureTunnel: vi.fn() + ensureTunnel: vi.fn(), + pendingMigration: vi.fn() } }) @@ -39,6 +41,9 @@ vi.mock('./orcad-artifact-materializer', () => ({ })) vi.mock('./orcad-local-build-hash', () => ({ computeLocalOrcadBuildHash: () => 'local-hash' })) vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensureTunnel })) +vi.mock('./orcad-managed-migration-status', () => ({ + findIncompleteManagedOrcadMigration: mocks.pendingMigration +})) vi.mock('./orcad-activation-transaction-store', () => ({ readOrcadActivationTransaction: async () => null })) @@ -150,6 +155,29 @@ describe('rollbackManagedOrcadEnvironment', () => { expect(mocks.census).not.toHaveBeenCalled() }) + it('refuses while a migration into the server is unfinished', async () => { + mocks.pendingMigration.mockReturnValueOnce({ migrationId: 'm-1', phase: 'destination-staged' }) + await expect( + rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) + ).resolves.toMatchObject({ outcome: 'refused', code: 'orcad_rollback_migration_in_progress' }) + expect(mocks.rollback).not.toHaveBeenCalled() + }) + + it('refuses a rollback to a snapshot older than the migrated catalog, not a newer one', async () => { + // The harness's active version activated 2026-01-01. + recordManagedOrcadMigration(harness.userDataPath, 'environment-1', '2026-02-01T00:00:00.000Z') + await expect( + rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) + ).resolves.toMatchObject({ outcome: 'refused', code: 'orcad_rollback_crosses_migration' }) + expect(mocks.rollback).not.toHaveBeenCalled() + mocks.resolveContext.mockImplementation(async () => + harness.context({ activatedAt: '2026-03-01T00:00:00.000Z' }) + ) + mocks.rollback.mockResolvedValueOnce({ outcome: 'refused', code: 'x', reason: 'y' }) + await rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) + expect(mocks.rollback).toHaveBeenCalledOnce() + }) + it('rolls back against the installed bytes of the previous slot', async () => { mocks.rollback.mockResolvedValueOnce({ outcome: 'rolled-back', diff --git a/src/main/ssh/orcad-runtime-maintenance.ts b/src/main/ssh/orcad-runtime-maintenance.ts index 712d0d67ae00..d8c0f35e8353 100644 --- a/src/main/ssh/orcad-runtime-maintenance.ts +++ b/src/main/ssh/orcad-runtime-maintenance.ts @@ -37,6 +37,7 @@ import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' import { deployOrcad } from './orcad-remote-deploy' import { rollbackOrcad } from './orcad-remote-rollback' import { collectManagedTerminalCensus } from './orcad-terminal-census-client' +import { findIncompleteManagedOrcadMigration } from './orcad-managed-migration-status' import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' type LifecycleArgs = { selector: string; signal?: AbortSignal } @@ -138,6 +139,10 @@ export function rollbackManagedOrcadEnvironment( reason: 'This server has no previous version to roll back to.' } } + const crossing = migrationRollbackRefusal(userDataPath, environment, record.activatedAt) + if (crossing) { + return crossing + } const census = await collectManagedTerminalCensus(userDataPath, environment, record) // Why idle only: this client cannot read the older build's daemon protocol, so it cannot show // that build would reach terminals that are still running. @@ -213,3 +218,35 @@ export function recoverManagedOrcadEnvironment( } ) } + +/** + * A rollback restores the snapshot taken when the current version activated. If a migration + * began after that, the snapshot predates the imported catalog and restoring it would drop it. + */ +function migrationRollbackRefusal( + userDataPath: string, + environment: KnownRuntimeEnvironment, + activatedAt: string | null +): OrcadManagedRollbackResult | null { + if (findIncompleteManagedOrcadMigration(userDataPath, environment.id)) { + return { + outcome: 'refused', + code: 'orcad_rollback_migration_in_progress', + reason: 'A migration into this server is still running. Finish it before rolling back.' + } + } + const migratedAt = environment.orcadMigratedAt + if ( + migratedAt && + (activatedAt === null || Date.parse(activatedAt) < Date.parse(migratedAt)) + ) { + return { + outcome: 'refused', + code: 'orcad_rollback_crosses_migration', + reason: + 'The previous version\'s state predates the projects migrated onto this server; ' + + 'rolling back would lose them. Deploy forward instead.' + } + } + return null +} diff --git a/src/main/ssh/orcad-runtime-status.ts b/src/main/ssh/orcad-runtime-status.ts index 101eb6d81e58..18a5bfaf3988 100644 --- a/src/main/ssh/orcad-runtime-status.ts +++ b/src/main/ssh/orcad-runtime-status.ts @@ -7,6 +7,7 @@ import { resolveLinkedOrcadContext } from './orcad-managed-runtime-context' import { readManagedOrcadUpdateDeferral } from './orcad-managed-update-deferrals' +import { findIncompleteManagedOrcadMigration } from './orcad-managed-migration-status' import { collectManagedTerminalCensus } from './orcad-terminal-census-client' /** Read-only: what the host's activation record and journal say, without repairing either. */ @@ -34,6 +35,7 @@ export async function getManagedOrcadRuntimeStatus( rollbackAvailable: Boolean(record.previous && record.snapshot), recovery: transaction ? managedRecoveryStatus(transaction) : null, terminals: await collectManagedTerminalCensus(userDataPath, environment, record), + migration: migrationStatus(userDataPath, environment.id), deferredUpdate: readManagedOrcadUpdateDeferral(environment.id) } }) @@ -66,3 +68,13 @@ function managedRecoveryStatus( } } } + +function migrationStatus( + userDataPath: string, + environmentId: string +): OrcadManagedRuntimeStatus['migration'] { + const migration = findIncompleteManagedOrcadMigration(userDataPath, environmentId) + return migration + ? { migrationId: migration.migrationId, phase: migration.phase, startedAt: migration.startedAt } + : null +} diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index a329c935fee1..dcf8c934aa00 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -90,6 +90,11 @@ export class SshConnectionStore { return new SshTargetOrcadClaims(this.store) } + /** The profile reads and scrollback reservations a migration exports from. */ + getOrcadMigrationSource(): Store { + return this.store + } + updateTarget(id: string, updates: Partial>): SshTarget | null { const existing = this.store.getSshTarget(id) // Why: a new runtime choice or endpoint must re-run the ladder, not replay the old rung. diff --git a/src/shared/orcad-managed-runtime.ts b/src/shared/orcad-managed-runtime.ts index 85a13fb2ddee..11292d0a8a8a 100644 --- a/src/shared/orcad-managed-runtime.ts +++ b/src/shared/orcad-managed-runtime.ts @@ -94,6 +94,26 @@ export type OrcadManagedRuntimeStatus = { | null /** Terminals the daemon runs; `null` counts are unverifiable and block updates and stops. */ terminals: OrcadTerminalCensus + /** An unfinished dormant migration into this server; a rollback is refused while it runs. */ + migration: { + migrationId: string + phase: 'source-fenced' | 'destination-staged' | 'destination-committed' | 'source-retired' + startedAt: string + } | null /** The last update this client deferred for this server, cleared once one goes through. */ deferredUpdate: (OrcadManagedDeferral & { deferredAt: string }) | null } + +export type OrcadManagedConversionResult = + | { + outcome: 'committed' + environment: PublicKnownRuntimeEnvironment + migrationId: string + } + | OrcadManagedDeferral + | { + outcome: 'refused' + verdict: 'live' | 'unverifiable' + code: string + reason: string + } diff --git a/src/shared/runtime-environment-managed-orcad-store.test.ts b/src/shared/runtime-environment-managed-orcad-store.test.ts index 2458ad176fa2..6e2f8b491702 100644 --- a/src/shared/runtime-environment-managed-orcad-store.test.ts +++ b/src/shared/runtime-environment-managed-orcad-store.test.ts @@ -11,6 +11,7 @@ import { } from './runtime-environment-store' import { addManagedOrcadEnvironment, + recordManagedOrcadMigration, refreshManagedOrcadPairing, removeManagedOrcadEnvironment } from './runtime-environment-managed-orcad-store' @@ -130,4 +131,23 @@ describe('managed orcad environment store', () => { refreshManagedOrcadPairing(userDataPath, 'environment-1', pairingCode('ws://127.0.0.1:1')) ).toThrow('does not point at its SSH tunnel') }) + + it('keeps the earliest migration mark, across a downgrade rewrite and a re-pair', () => { + add() + recordManagedOrcadMigration(userDataPath, 'environment-1', '2026-02-01T00:00:00.000Z') + recordManagedOrcadMigration(userDataPath, 'environment-1', '2026-03-01T00:00:00.000Z') + shippedBuildRewrite(userDataPath, (environments) => { + environments[0]!.lastUsedAt = 900 + }) + expect(listEnvironments(userDataPath)[0]?.orcadMigratedAt).toBe('2026-02-01T00:00:00.000Z') + const rotated = encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:46768', + deviceToken: 'rotated-token', + publicKeyB64: Buffer.alloc(32, 1).toString('base64') + }) + expect( + refreshManagedOrcadPairing(userDataPath, 'environment-1', rotated, 500).orcadMigratedAt + ).toBe('2026-02-01T00:00:00.000Z') + }) }) diff --git a/src/shared/runtime-environment-managed-orcad-store.ts b/src/shared/runtime-environment-managed-orcad-store.ts index 7ca7e1dd9b5c..f3463bf0628a 100644 --- a/src/shared/runtime-environment-managed-orcad-store.ts +++ b/src/shared/runtime-environment-managed-orcad-store.ts @@ -159,3 +159,31 @@ function samePairing(left: PairingOffer, right: PairingOffer): boolean { left.pairedDeviceId === right.pairedDeviceId ) } + +/** + * Marks a managed server as holding migrated state from `at` on. Written before a commit is + * attempted, so a crash after the commit can never leave a rollback across it unblocked. + */ +export function recordManagedOrcadMigration( + userDataPath: string, + environmentId: string, + at: string +): KnownRuntimeEnvironment { + const store = readPersistedEnvironmentStore(userDataPath) + const persisted = resolveEnvironmentFromStore(store, environmentId) + const entry = readCurrentRuntimeEnvironmentSidecarEntry(userDataPath, persisted) + if (!entry?.orcadDeployment) { + throw new RuntimeEnvironmentStoreError('invalid_argument', 'This server is not managed.') + } + const { binding: _binding, ...state } = entry + // The earliest mark wins: a snapshot older than any migration must stay out of reach. + const earliest = + state.orcadMigratedAt && Date.parse(state.orcadMigratedAt) <= Date.parse(at) + ? state.orcadMigratedAt + : at + writeRuntimeEnvironmentSidecarEntry(userDataPath, store.environments, persisted, { + ...state, + orcadMigratedAt: earliest + }) + return resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environmentId) +} diff --git a/src/shared/runtime-environment-sidecar.ts b/src/shared/runtime-environment-sidecar.ts index d421d0e87947..ae27fe411954 100644 --- a/src/shared/runtime-environment-sidecar.ts +++ b/src/shared/runtime-environment-sidecar.ts @@ -38,6 +38,8 @@ const SidecarEntrySchema = z.object({ pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(), // The managed deployment's tunnel; its loopback endpoint is the persisted pairing itself. orcadDeployment: OrcadDeploymentLinkSchema.optional(), + // A rollback to a snapshot older than this would discard the migrated catalog. + orcadMigratedAt: z.string().datetime().optional(), // Keeps the overlaid pairing revision monotonic after the access that raised it is removed. pairingRevisionFloor: z.number().finite().optional(), reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional() @@ -143,6 +145,7 @@ export function overlayRuntimeEnvironmentSidecar( ? { pendingSshAccessOperation: entry.pendingSshAccessOperation } : {}), ...(entry.orcadDeployment ? { orcadDeployment: entry.orcadDeployment } : {}), + ...(entry.orcadMigratedAt ? { orcadMigratedAt: entry.orcadMigratedAt } : {}), ...(entry.reconciliation ? { reconciliation: entry.reconciliation } : {}) }) return overlaid.success ? overlaid.data : base @@ -171,6 +174,7 @@ export function writeRuntimeEnvironmentSidecarEntry( entry.sshAccess !== undefined || entry.pendingSshAccessOperation !== undefined || entry.orcadDeployment !== undefined || + entry.orcadMigratedAt !== undefined || entry.reconciliation !== undefined || (entry.pairingRevisionFloor ?? basePairingRevision) > basePairingRevision) if (entry && hasState) { diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts index a7f19dd9788b..fb677cc8172a 100644 --- a/src/shared/runtime-environments.ts +++ b/src/shared/runtime-environments.ts @@ -82,6 +82,8 @@ export const KnownRuntimeEnvironmentSchema = PersistedRuntimeEnvironmentSchema.e sshAccess: RuntimeSshAccessLinkSchema.optional(), pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(), orcadDeployment: OrcadDeploymentLinkSchema.optional(), + /** When a migration into this managed server began committing; older snapshots predate it. */ + orcadMigratedAt: z.string().datetime().optional(), reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional() }) .refine( From e0e489105d4f9ccfb54c73a42cbdc6b49d9d607e Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 2 Oct 2026 01:36:06 -0700 Subject: [PATCH 2/3] style: oxfmt the c4 conversion and maintenance files --- src/main/ssh/orcad-runtime-conversion.ts | 15 ++++++++------- src/main/ssh/orcad-runtime-maintenance.ts | 7 ++----- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/main/ssh/orcad-runtime-conversion.ts b/src/main/ssh/orcad-runtime-conversion.ts index 959f9a0b30b4..a9e81a74950a 100644 --- a/src/main/ssh/orcad-runtime-conversion.ts +++ b/src/main/ssh/orcad-runtime-conversion.ts @@ -23,7 +23,10 @@ import { commitOrcadMigrationDestination, type OrcadMigrationDestinationCatalog } from './orcad-migration-cutover-coordinator' -import { fenceOrcadMigrationSource, resolveOrcadMigrationFence } from './orcad-migration-source-fence' +import { + fenceOrcadMigrationSource, + resolveOrcadMigrationFence +} from './orcad-migration-source-fence' import { assessOrcadMigrationTerminals, type ListRelayPtyIds @@ -100,7 +103,9 @@ export async function convertSshTargetToManagedOrcad( async function fenceOrResume( userDataPath: string, args: OrcadManagedConversionArgs -): Promise> { +): Promise< + OrcadMigrationSourceCutover | Extract +> { const { claims, targetStore } = requireManagedOrcadInfrastructure() const target = targetStore.getTarget(args.sshTargetId) if (!target) { @@ -112,11 +117,7 @@ async function fenceOrResume( } const store = targetStore.getOrcadMigrationSource() // Asked while the relay still answers; the fence re-checks leases once it holds. - const terminalProof = await assessOrcadMigrationTerminals( - store, - target.id, - args.listRelayPtyIds - ) + const terminalProof = await assessOrcadMigrationTerminals(store, target.id, args.listRelayPtyIds) if (terminalProof.verdict !== 'exited') { return refuse(terminalProof.verdict, 'orcad_migration_terminals', terminalProof.reason) } diff --git a/src/main/ssh/orcad-runtime-maintenance.ts b/src/main/ssh/orcad-runtime-maintenance.ts index d8c0f35e8353..94d685f89ab4 100644 --- a/src/main/ssh/orcad-runtime-maintenance.ts +++ b/src/main/ssh/orcad-runtime-maintenance.ts @@ -236,15 +236,12 @@ function migrationRollbackRefusal( } } const migratedAt = environment.orcadMigratedAt - if ( - migratedAt && - (activatedAt === null || Date.parse(activatedAt) < Date.parse(migratedAt)) - ) { + if (migratedAt && (activatedAt === null || Date.parse(activatedAt) < Date.parse(migratedAt))) { return { outcome: 'refused', code: 'orcad_rollback_crosses_migration', reason: - 'The previous version\'s state predates the projects migrated onto this server; ' + + "The previous version's state predates the projects migrated onto this server; " + 'rolling back would lose them. Deploy forward instead.' } } From 0b0a34e2e71a90d1c7c2d2d3c1e626f0d1ecf449 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 2 Oct 2026 05:09:04 -0700 Subject: [PATCH 3/3] fix(ssh): name the fake migration destination's type so declarations stay portable --- .../ssh/orcad-migration-destination-fake.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/main/ssh/orcad-migration-destination-fake.ts b/src/main/ssh/orcad-migration-destination-fake.ts index 08208f78146d..13794f5ff499 100644 --- a/src/main/ssh/orcad-migration-destination-fake.ts +++ b/src/main/ssh/orcad-migration-destination-fake.ts @@ -1,11 +1,18 @@ -import { vi } from 'vitest' +import { vi, type Mock } from 'vitest' import type { OrcadMigrationCatalogState, OrcadMigrationManifest } from '../../shared/orcad-migration-manifest' +import type { OrcadMigrationDestinationCatalog } from './orcad-migration-cutover-coordinator' + +type Catalog = OrcadMigrationDestinationCatalog + +export type FakeOrcadMigrationDestination = { commits: number } & { + [Method in keyof Catalog]: Mock +} /** An in-memory destination with the T6-9 semantics: idempotent stage, receipt-keyed commit. */ -export function fakeOrcadMigrationDestination() { +export function fakeOrcadMigrationDestination(): FakeOrcadMigrationDestination { let state: 'absent' | 'staged' | 'committed' = 'absent' const view = (manifest: OrcadMigrationManifest): OrcadMigrationCatalogState => { const base = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } @@ -29,30 +36,32 @@ export function fakeOrcadMigrationDestination() { ? { ...base, state, stagedAt: '2026-10-01T00:00:00.000Z', snapshotUploads: [] } : { ...base, state } } - const destination = { + const destination: FakeOrcadMigrationDestination = { commits: 0, - readState: vi.fn(async (manifest: OrcadMigrationManifest) => view(manifest)), - stage: vi.fn(async (manifest: OrcadMigrationManifest) => { + readState: vi.fn(async (manifest: OrcadMigrationManifest) => + view(manifest) + ), + stage: vi.fn(async (manifest: OrcadMigrationManifest) => { if (state === 'absent') { state = 'staged' } return view(manifest) }), - commit: vi.fn(async (manifest: OrcadMigrationManifest) => { + commit: vi.fn(async (manifest: OrcadMigrationManifest) => { if (state === 'staged') { state = 'committed' destination.commits += 1 } return view(manifest) }), - abort: vi.fn(async (manifest: OrcadMigrationManifest) => { + abort: vi.fn(async (manifest: OrcadMigrationManifest) => { const aborted = state === 'staged' if (aborted) { state = 'absent' } return { ...view(manifest), aborted, ...(aborted ? {} : { durableAbsent: true as const }) } }), - stageChunk: vi.fn() + stageChunk: vi.fn() } return destination }