From 7ed23199a5aa2b41ea52beccaf1cf0fbf6d3828e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:23:59 -0700 Subject: [PATCH] refactor(orcad-migration): migration source abstraction, capability-gated source.kind, path-rewrite stage (WSL-via-orcad D0) (#27790) * test(orcad-migration): pin the SSH manifest digest before the source abstraction * feat(orcad-migration): capability-gated source.kind discriminator, SSH wire unchanged * refactor(orcad-migration): MigrationSource abstraction with SSH relay as the only source; table-driven path-rewrite stage (identity for SSH) * fix(orcad-migration): name the wire source type * chore(orcad-migration): exhaustive path-kind switch; no test assertion * test(ssh): run the relay migration source's SQLite test in the Node runtime project * perf(orcad-migration): rename record keys in linear time and skip the walk for the identity SSH exports run the path-rewrite stage on every stage and commit check; re-reading Object.keys per entry made a 10k-key record cost seconds on the main thread. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + ...gration-catalog-client-source-kind.test.ts | 119 ++++++++++ .../ssh/orcad-migration-catalog-client.ts | 17 +- .../orcad-migration-cutover-coordinator.ts | 15 +- .../ssh/orcad-migration-manifest-export.ts | 38 +-- .../orcad-migration-manifest-golden.test.ts | 129 +++++++++++ src/main/ssh/orcad-migration-path-fields.ts | 216 ++++++++++++++++++ .../ssh/orcad-migration-path-rewrite.test.ts | 211 +++++++++++++++++ src/main/ssh/orcad-migration-path-rewrite.ts | 198 ++++++++++++++++ src/main/ssh/orcad-migration-source.ts | 56 +++++ .../orcad-migration-ssh-relay-source.test.ts | 93 ++++++++ .../ssh/orcad-migration-ssh-relay-source.ts | 84 +++++++ src/main/ssh/orcad-runtime-conversion.ts | 49 ++-- .../orcad-migration-manifest-validation.ts | 4 + src/shared/orcad-migration-manifest.ts | 5 +- .../orcad-migration-source-kind.test.ts | 93 ++++++++ src/shared/orcad-migration-source-kind.ts | 54 +++++ src/shared/orcad-runtime-capabilities.ts | 6 + 18 files changed, 1341 insertions(+), 47 deletions(-) create mode 100644 src/main/ssh/orcad-migration-catalog-client-source-kind.test.ts create mode 100644 src/main/ssh/orcad-migration-manifest-golden.test.ts create mode 100644 src/main/ssh/orcad-migration-path-fields.ts create mode 100644 src/main/ssh/orcad-migration-path-rewrite.test.ts create mode 100644 src/main/ssh/orcad-migration-path-rewrite.ts create mode 100644 src/main/ssh/orcad-migration-source.ts create mode 100644 src/main/ssh/orcad-migration-ssh-relay-source.test.ts create mode 100644 src/main/ssh/orcad-migration-ssh-relay-source.ts create mode 100644 src/shared/orcad-migration-source-kind.test.ts create mode 100644 src/shared/orcad-migration-source-kind.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 40a668f58eb..7dba547d6ff 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -260,6 +260,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/ssh/orcad-migration-delta-snapshot.test.ts', 'src/main/ssh/orcad-migration-snapshot-resume.test.ts', 'src/main/ssh/orcad-migration-source-fence.test.ts', + 'src/main/ssh/orcad-migration-ssh-relay-source.test.ts', 'src/main/ssh/orcad-runtime-conversion.test.ts', 'src/main/ssh/orcad-unreachable-setup-release.test.ts', 'src/main/ssh/ssh-target-orcad-preflight.test.ts', diff --git a/src/main/ssh/orcad-migration-catalog-client-source-kind.test.ts b/src/main/ssh/orcad-migration-catalog-client-source-kind.test.ts new file mode 100644 index 00000000000..5934de8b90e --- /dev/null +++ b/src/main/ssh/orcad-migration-catalog-client-source-kind.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + ORCAD_MIGRATION_MANIFEST_VERSION, + type OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' +import { ORCAD_MIGRATION_SOURCE_KIND_UNKNOWN } from '../../shared/orcad-migration-source-kind' +import { + ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY, + ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY +} from '../../shared/orcad-runtime-capabilities' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' +import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest' + +const sendRequest = vi.hoisted(() => vi.fn()) +const status = vi.hoisted((): { capabilities: string[] } => ({ capabilities: [] })) +vi.mock('../../shared/remote-runtime-client', () => ({ + sendRemoteRuntimeRequestWithStatusPreflight: async ( + pairing: unknown, + method: string, + params: unknown, + _timeoutMs: number, + validate: (response: unknown) => void + ) => { + validate({ + ok: true, + _meta: { runtimeId: 'runtime' }, + result: { capabilities: status.capabilities } + }) + return sendRequest(pairing, method, params) + } +})) + +const { + ORCAD_MIGRATION_DESTINATION_SOURCE_KIND_UNSUPPORTED, + abortRemoteOrcadMigrationCatalog, + commitRemoteOrcadMigrationCatalog, + readRemoteOrcadMigrationCatalogState, + stageRemoteOrcadMigrationCatalog +} = await import('./orcad-migration-catalog-client') + +const OPERATIONS = [ + stageRemoteOrcadMigrationCatalog, + commitRemoteOrcadMigrationCatalog, + readRemoteOrcadMigrationCatalogState, + abortRemoteOrcadMigrationCatalog +] + +function sshManifest(): OrcadMigrationManifest { + const unsigned = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: 'migration-kind', + createdAt: '2026-10-01T00:00:00.000Z', + source: { sshTargetId: 'ssh-prod', sshTargetGeneration: 7, targetLabel: 'Production' }, + payload: { repositories: [], projectGroups: [], folderWorkspaces: [] } + } + return { ...unsigned, manifestSha256: computeOrcadMigrationManifestSha256(unsigned) } +} + +/** A future kind as the wire carries it; this build's types know only the SSH source. */ +function manifestOfKind(kind: string): OrcadMigrationManifest { + const base = sshManifest() + const source = { ...base.source, kind } + return { ...base, source } +} + +const pairingCode = encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: 'ws://127.0.0.1:46768/runtime', + deviceToken: 'device-token', + publicKeyB64: 'public-key', + pairedDeviceId: 'paired-desktop' +}) + +beforeEach(() => { + vi.clearAllMocks() + sendRequest.mockResolvedValue({ ok: false, error: { code: 'x', message: 'sent' } }) + // What an older destination advertises. + status.capabilities = [ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY] +}) + +describe('orcad migration source-kind capability gate', () => { + it.each(OPERATIONS)('sends an SSH manifest unchanged to an older destination', async (op) => { + await expect(op(pairingCode, sshManifest())).rejects.toThrow('sent') + expect(sendRequest).toHaveBeenCalledTimes(1) + const params = sendRequest.mock.calls[0][2] + expect(params).toEqual({ manifest: sshManifest() }) + expect(JSON.stringify(params)).not.toContain('"kind"') + }) + + it.each(OPERATIONS)('never sends a WSL manifest to an older destination', async (op) => { + await expect(op(pairingCode, manifestOfKind('local-wsl'))).rejects.toThrow( + ORCAD_MIGRATION_DESTINATION_SOURCE_KIND_UNSUPPORTED + ) + expect(sendRequest).not.toHaveBeenCalled() + }) + + it.each(OPERATIONS)('sends a WSL manifest once the destination advertises it', async (op) => { + status.capabilities = [ + ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY, + ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY + ] + await expect(op(pairingCode, manifestOfKind('local-wsl'))).rejects.toThrow('sent') + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it.each(['ssh-relay', 'wsl', ''])( + 'refuses an unregistered kind %j before any request', + async (kind) => { + status.capabilities = [ + ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY, + ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY + ] + await expect( + stageRemoteOrcadMigrationCatalog(pairingCode, manifestOfKind(kind)) + ).rejects.toThrow(ORCAD_MIGRATION_SOURCE_KIND_UNKNOWN) + expect(sendRequest).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/ssh/orcad-migration-catalog-client.ts b/src/main/ssh/orcad-migration-catalog-client.ts index 8c4807525d7..d23a320d4c1 100644 --- a/src/main/ssh/orcad-migration-catalog-client.ts +++ b/src/main/ssh/orcad-migration-catalog-client.ts @@ -20,6 +20,7 @@ import { sendRemoteRuntimeRequestWithStatusPreflight } from '../../shared/remote import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import { AUTOMATION_EXTRA_AGENT_ARGS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import { hasExtraAgentArgs } from '../../shared/automation-extra-agent-args' +import { requiredOrcadMigrationSourceCapability } from '../../shared/orcad-migration-source-kind' type OrcadCatalogMigrationOperation = 'abort' | 'commit' | 'stage' | 'state' @@ -32,6 +33,9 @@ export const ORCAD_MIGRATION_DESTINATION_UNSUPPORTED = 'orcad_migration_destinat /** An older destination would strip automations' extra agent arguments, so the move is refused. */ export const ORCAD_MIGRATION_DESTINATION_EXTRA_AGENT_ARGS_UNSUPPORTED = 'orcad_migration_destination_extra_agent_args_unsupported:Update Orca on this host to use extra arguments.' +/** The destination does not parse this manifest's source kind; nothing was sent, so nothing staged. */ +export const ORCAD_MIGRATION_DESTINATION_SOURCE_KIND_UNSUPPORTED = + 'orcad_migration_destination_source_kind_unsupported:Update Orca on this host to move this project to it.' type CatalogRequestOptions = { signal?: AbortSignal @@ -135,7 +139,9 @@ async function request( METHOD_BY_OPERATION[operation], { manifest }, options, - carriesExtraAgentArgs + carriesExtraAgentArgs, + // Every operation: an older destination would parse a non-SSH source as malformed or as SSH. + requiredOrcadMigrationSourceCapability(manifest.source) ) if (!response.ok) { throw new Error(`orcad_migration_${operation}_failed:${response.error.message}`) @@ -148,7 +154,8 @@ async function sendSupportedMigrationRequest( method: string, params: unknown, options: CatalogRequestOptions, - requiresExtraAgentArgs = false + requiresExtraAgentArgs = false, + requiredSourceCapability: string | null = null ): Promise> { const response = await sendRemoteRuntimeRequestWithStatusPreflight( pairedRuntimeConnectionFromOffer(pairing), @@ -174,6 +181,12 @@ async function sendSupportedMigrationRequest( ) { throw new Error(ORCAD_MIGRATION_DESTINATION_EXTRA_AGENT_ARGS_UNSUPPORTED) } + if ( + requiredSourceCapability !== null && + !status.result.capabilities?.includes(requiredSourceCapability) + ) { + throw new Error(ORCAD_MIGRATION_DESTINATION_SOURCE_KIND_UNSUPPORTED) + } }, undefined, ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, diff --git a/src/main/ssh/orcad-migration-cutover-coordinator.ts b/src/main/ssh/orcad-migration-cutover-coordinator.ts index 1ccef9dac92..c82fd00afe6 100644 --- a/src/main/ssh/orcad-migration-cutover-coordinator.ts +++ b/src/main/ssh/orcad-migration-cutover-coordinator.ts @@ -26,13 +26,13 @@ import { listOrcadMigrationSourceCutovers, writeOrcadMigrationSourceCutover } from './orcad-migration-cutover-journal' -import { releaseOrcadMigrationFence } from './orcad-migration-source-fence' +import type { OrcadMigrationSourceCutoverPort } from './orcad-migration-source' import { transferOrcadMigrationSnapshots, type OrcadMigrationSnapshotSource } from './orcad-migration-snapshot-coordinator' -import { assertOrcadMigrationSourceUnchanged } from './orcad-migration-source-assertions' import type { OrcadMigrationPreflightStore } from './ssh-target-orcad-preflight' +import { sshRelayMigrationCutoverPort } from './orcad-migration-ssh-relay-source' import type { SshTargetOrcadClaims } from './ssh-target-orcad-claims' /** The destination's catalog operations, served by the T6-9 orcad.migration.* client. */ @@ -53,9 +53,15 @@ export type OrcadMigrationCutoverContext = { destination: OrcadMigrationDestinationCatalog /** Rebuilt for every check when `store` is a copy that would never see a later edit. */ freshSource?: () => OrcadMigrationPreflightStore + /** The fenced source; defaults to the relay-era SSH target the journal names. */ + source?: OrcadMigrationSourceCutoverPort now?: () => Date } +function cutoverSource(context: OrcadMigrationCutoverContext): OrcadMigrationSourceCutoverPort { + return context.source ?? sshRelayMigrationCutoverPort(context) +} + export async function stageOrcadMigrationDestination( context: OrcadMigrationCutoverContext, migrationId: string @@ -121,7 +127,7 @@ export async function abortOrcadMigrationCutover( context: OrcadMigrationCutoverContext, migrationId: string, release: (cutover: OrcadMigrationSourceCutover) => Promise = (cutover) => - releaseOrcadMigrationFence(context, cutover) + cutoverSource(context).releaseFence(cutover) ): Promise { const cutover = requireCutover(context.userDataPath, migrationId) if (cutover.phase === 'destination-committed' || cutover.phase === 'source-retired') { @@ -172,8 +178,7 @@ function assertSourceUnchanged( context: OrcadMigrationCutoverContext, cutover: OrcadMigrationSourceCutover ): void { - const store = context.freshSource?.() ?? context.store - assertOrcadMigrationSourceUnchanged({ userDataPath: context.userDataPath, store }, cutover) + cutoverSource(context).verifyFence(cutover) } /** Moves the journal to what the destination reported; durable before it returns. */ diff --git a/src/main/ssh/orcad-migration-manifest-export.ts b/src/main/ssh/orcad-migration-manifest-export.ts index 9983be807a6..513d48312e8 100644 --- a/src/main/ssh/orcad-migration-manifest-export.ts +++ b/src/main/ssh/orcad-migration-manifest-export.ts @@ -17,6 +17,11 @@ import type { SshTarget } from '../../shared/ssh-types' import type { Store } from '../persistence' import { collectOrcadMigrationSourceCatalog } from '../persistence/migrating-orcad-catalog/orcad-source-catalog' import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest' +import { + IDENTITY_ORCAD_MIGRATION_PATH_REWRITER, + rewriteOrcadMigrationPayloadPaths, + type OrcadMigrationPathRewriter +} from './orcad-migration-path-rewrite' export type OrcadMigrationExportStore = Pick< Store, @@ -26,18 +31,23 @@ export type OrcadMigrationExportStore = Pick< | 'getRepos' > +export type OrcadMigrationManifestExportOptions = { + migrationId?: string + now?: () => Date + destinationEnvironmentId?: string + /** Only these rows: a re-export of what earlier migrations of the host already moved. */ + onlyCatalog?: OrcadMigrationCatalogIds + /** Maps source paths to destination paths before signing; identity for an SSH host. */ + rewrite?: OrcadMigrationPathRewriter +} + export function createOrcadMigrationManifest( store: OrcadMigrationExportStore, target: SshTarget, - options: { - migrationId?: string - now?: () => Date - destinationEnvironmentId?: string - /** Only these rows: a re-export of what earlier migrations of the host already moved. */ - onlyCatalog?: OrcadMigrationCatalogIds - } = {} + options: OrcadMigrationManifestExportOptions = {} ): OrcadMigrationManifest { - const payload = restrictCatalog( + const rewrite = options.rewrite ?? IDENTITY_ORCAD_MIGRATION_PATH_REWRITER + const sourcePayload = restrictCatalog( collectOrcadMigrationSourceCatalog(store, target), options.onlyCatalog ) @@ -48,18 +58,20 @@ export function createOrcadMigrationManifest( } const dormantState = store.collectOrcadMigrationSourceDormantState( source, - payload, + sourcePayload, options.destinationEnvironmentId ) + // After collection: dormant state is selected by the source's own paths and ids. + const payload = rewriteOrcadMigrationPayloadPaths( + { ...sourcePayload, ...(hasDormantState(dormantState) ? { dormantState } : {}) }, + rewrite + ) const unsigned = { version: ORCAD_MIGRATION_MANIFEST_VERSION, migrationId: options.migrationId ?? randomUUID(), createdAt: (options.now ?? (() => new Date()))().toISOString(), source, - payload: { - ...payload, - ...(hasDormantState(dormantState) ? { dormantState } : {}) - }, + payload, ...(options.destinationEnvironmentId ? { destinationEnvironmentId: options.destinationEnvironmentId } : {}) diff --git a/src/main/ssh/orcad-migration-manifest-golden.test.ts b/src/main/ssh/orcad-migration-manifest-golden.test.ts new file mode 100644 index 00000000000..eebff545b06 --- /dev/null +++ b/src/main/ssh/orcad-migration-manifest-golden.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { parseOrcadMigrationManifest } from '../../shared/orcad-migration-manifest' +import type { SshTarget } from '../../shared/ssh-types' +import { assertOrcadMigrationManifestDigest } from '../orcad/orcad-migration-manifest-digest' +import { emptyDormantPayload } from '../persistence/migrating-orcad-catalog/orcad-source-dormant-state' +import { createOrcadMigrationManifest } from './orcad-migration-manifest-export' + +const TARGET: SshTarget = { + id: 'ssh-golden', + label: 'Golden', + host: 'golden.example.com', + port: 22, + username: 'deploy', + generation: 4 +} +const WORKTREE_ID = 'repo-golden::/srv/repo-golden/feature' + +function goldenManifest() { + const dormant = emptyDormantPayload() + dormant.workspaceSession = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-golden', + activeWorktreeId: WORKTREE_ID, + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'tab-golden', + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'Shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + startupCwd: '/srv/repo-golden/feature/src' + } + ] + }, + openFilesByWorktree: { + [WORKTREE_ID]: [ + { + filePath: '/srv/repo-golden/feature/README.md', + relativePath: 'README.md', + worktreeId: WORKTREE_ID, + language: 'markdown' + } + ] + } + } + return createOrcadMigrationManifest( + { + collectOrcadMigrationSourceDormantState: () => dormant, + getRepos: () => [ + { + id: 'repo-golden', + path: '/srv/repo-golden', + displayName: 'Golden', + badgeColor: '#737373', + addedAt: 1, + kind: 'git', + connectionId: TARGET.id, + projectGroupId: 'group-golden' + } + ], + getProjectGroups: () => [ + { + id: 'group-golden', + name: 'Golden', + parentPath: '/srv', + connectionId: TARGET.id, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + getFolderWorkspaces: () => [ + { + id: 'folder-golden', + projectGroupId: 'group-golden', + name: 'Notes', + folderPath: '/srv/notes', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ] + }, + TARGET, + { + migrationId: 'migration-golden', + now: () => new Date('2026-10-01T00:00:00.000Z'), + destinationEnvironmentId: 'env-golden' + } + ) +} + +// Pinned before the source abstraction landed: any drift breaks older destinations' digests. +const GOLDEN_SSH_MANIFEST_SHA256 = + 'fe6defc1b987c39948d5d89e310690dda7b8c21e1dfa1cf6c253bdce7abf4d68' + +describe('SSH migration manifest wire shape', () => { + it('keeps the SSH digest byte-identical to the pre-abstraction export', () => { + expect(goldenManifest().manifestSha256).toBe(GOLDEN_SSH_MANIFEST_SHA256) + }) + + it('sends the SSH source with exactly the fields older destinations parse', () => { + const wire = JSON.parse(JSON.stringify(goldenManifest())) + expect(Object.keys(wire.source).sort()).toEqual([ + 'sshTargetGeneration', + 'sshTargetId', + 'targetLabel' + ]) + expect(wire.source).not.toHaveProperty('kind') + // What a destination does with a received manifest, older and newer alike. + expect(() => assertOrcadMigrationManifestDigest(wire)).not.toThrow() + expect(parseOrcadMigrationManifest(wire)).toEqual(goldenManifest()) + }) +}) diff --git a/src/main/ssh/orcad-migration-path-fields.ts b/src/main/ssh/orcad-migration-path-fields.ts new file mode 100644 index 00000000000..46fedfd4301 --- /dev/null +++ b/src/main/ssh/orcad-migration-path-fields.ts @@ -0,0 +1,216 @@ +/** + * Every field of a migration payload that is, contains or is keyed by a source-host path, in one + * table, with the few that are deliberately never rewritten. Consumed by the export's path-rewrite + * stage (orcad-migration-path-rewrite.ts). + */ + +/** + * `absolute`: always an absolute path. `maybe-absolute`: an absolute path only when it is one + * (a relative base path, a terminal tab id). `relative`: relative to a worktree or repo root. + * `url`: a page URL, a `file:` URL only when it is one. + */ +export type OrcadMigrationPathForm = 'absolute' | 'maybe-absolute' | 'relative' | 'url' + +/** Composite values a path hides inside; each rebuilds only when its path actually changed. */ +type CompositeKind = + /** `::`, optionally `::workspace:` for a folder project. */ + | 'worktree-id' + /** A worktree id, `worktree:` or `folder:`. */ + | 'owner-key' + /** `@@` when minted by Orca; anything else is opaque. */ + | 'pty-session-id' + /** `editor:::`, a raw absolute path, or an opaque id. */ + | 'editor-file-id' + +export type OrcadMigrationPathFieldKind = OrcadMigrationPathForm | CompositeKind + +/** + * Field locations: `.` steps into a property, `[]` into array items, `{}` into record values, and + * a final `{key}` names the record's keys. Absent and null values are left alone. + */ +export const ORCAD_MIGRATION_PATH_FIELDS: readonly { + at: string + kind: OrcadMigrationPathFieldKind +}[] = [ + { at: 'repositories[].path', kind: 'absolute' }, + { at: 'repositories[].folderUpgradeGitRootPath', kind: 'absolute' }, + { at: 'repositories[].worktreeBasePath', kind: 'maybe-absolute' }, + { at: 'repositories[].externalWorktreeInboxBaselinePaths[]', kind: 'absolute' }, + { at: 'repositories[].importedExternalWorktreePaths[]', kind: 'absolute' }, + { at: 'repositories[].symlinkPaths[]', kind: 'relative' }, + { at: 'repositories[].customWorktreeVisibilitySources[].rootPath', kind: 'absolute' }, + { at: 'projectGroups[].parentPath', kind: 'absolute' }, + { at: 'folderWorkspaces[].folderPath', kind: 'absolute' }, + { at: 'folderWorkspaces[].diffComments[].worktreeId', kind: 'worktree-id' }, + { at: 'folderWorkspaces[].diffComments[].filePath', kind: 'relative' }, + { at: 'folderWorkspaces[].diffComments[].oldPath', kind: 'relative' }, + + { at: 'dormantState.worktreeMeta[].worktreeId', kind: 'worktree-id' }, + { at: 'dormantState.worktreeMeta[].meta.priorWorktreeIds[]', kind: 'worktree-id' }, + { at: 'dormantState.worktreeMeta[].meta.sparseDirectories[]', kind: 'relative' }, + { at: 'dormantState.worktreeMeta[].meta.diffComments[].worktreeId', kind: 'worktree-id' }, + { at: 'dormantState.worktreeMeta[].meta.diffComments[].filePath', kind: 'relative' }, + { at: 'dormantState.worktreeMeta[].meta.diffComments[].oldPath', kind: 'relative' }, + { at: 'dormantState.worktreeMeta[].meta.mobileDiffReview.files{}.filePath', kind: 'relative' }, + { at: 'dormantState.worktreeMeta[].meta.mobileDiffReview.files{}.oldPath', kind: 'relative' }, + { at: 'dormantState.worktreeLineage[].worktreeId', kind: 'worktree-id' }, + { at: 'dormantState.worktreeLineage[].lineage.worktreeId', kind: 'worktree-id' }, + { at: 'dormantState.worktreeLineage[].lineage.parentWorktreeId', kind: 'worktree-id' }, + { at: 'dormantState.workspaceLineage[].childWorkspaceKey', kind: 'owner-key' }, + { at: 'dormantState.workspaceLineage[].lineage.childWorkspaceKey', kind: 'owner-key' }, + { at: 'dormantState.workspaceLineage[].lineage.parentWorkspaceKey', kind: 'owner-key' }, + + { at: 'dormantState.workspaceSession.activeWorktreeId', kind: 'worktree-id' }, + { at: 'dormantState.workspaceSession.activeWorkspaceKey', kind: 'owner-key' }, + { at: 'dormantState.workspaceSession.tabsByWorktree{key}', kind: 'owner-key' }, + { at: 'dormantState.workspaceSession.tabsByWorktree{}[].worktreeId', kind: 'worktree-id' }, + { at: 'dormantState.workspaceSession.tabsByWorktree{}[].startupCwd', kind: 'absolute' }, + { at: 'dormantState.workspaceSession.tabsByWorktree{}[].ptyId', kind: 'pty-session-id' }, + { at: 'dormantState.workspaceSession.activeWorktreeIdsOnShutdown[]', kind: 'worktree-id' }, + { at: 'dormantState.workspaceSession.openFilesByWorktree{key}', kind: 'owner-key' }, + { at: 'dormantState.workspaceSession.openFilesByWorktree{}[].filePath', kind: 'absolute' }, + { at: 'dormantState.workspaceSession.openFilesByWorktree{}[].relativePath', kind: 'relative' }, + { at: 'dormantState.workspaceSession.openFilesByWorktree{}[].worktreeId', kind: 'worktree-id' }, + { at: 'dormantState.workspaceSession.activeFileIdByWorktree{key}', kind: 'owner-key' }, + { at: 'dormantState.workspaceSession.activeFileIdByWorktree{}', kind: 'editor-file-id' }, + { at: 'dormantState.workspaceSession.markdownFrontmatterVisible{key}', kind: 'editor-file-id' }, + { at: 'dormantState.workspaceSession.browserTabsByWorktree{key}', kind: 'owner-key' }, + { at: 'dormantState.workspaceSession.browserTabsByWorktree{}[].worktreeId', kind: 'worktree-id' }, + { at: 'dormantState.workspaceSession.browserTabsByWorktree{}[].url', kind: 'url' }, + { + at: 'dormantState.workspaceSession.browserTabsByWorktree{}[].docLocation.worktreeId', + kind: 'worktree-id' + }, + { + at: 'dormantState.workspaceSession.browserTabsByWorktree{}[].docLocation.filePath', + kind: 'absolute' + }, + { + at: 'dormantState.workspaceSession.browserPagesByWorkspace{}[].worktreeId', + kind: 'worktree-id' + }, + { at: 'dormantState.workspaceSession.browserPagesByWorkspace{}[].url', kind: 'url' }, + ...docLocationFields('dormantState.workspaceSession.browserPagesByWorkspace{}[].docLocation'), + ...conversionOriginFields( + 'dormantState.workspaceSession.browserPagesByWorkspace{}[].convertedFrom' + ), + ...conversionOriginFields( + 'dormantState.workspaceSession.browserPagesByWorkspace{}[].convertedTo' + ), + { at: 'dormantState.workspaceSession.activeBrowserTabIdByWorktree{key}', kind: 'owner-key' }, + { + at: 'dormantState.workspaceSession.clientHostedBrowserPagesByWorktree{key}', + kind: 'owner-key' + }, + { + at: 'dormantState.workspaceSession.clientHostedBrowserCloseIntentsByEnvironment{}[].worktreeId', + kind: 'worktree-id' + }, + { at: 'dormantState.workspaceSession.activeTabTypeByWorktree{key}', kind: 'owner-key' }, + { at: 'dormantState.workspaceSession.browserUrlHistory[].url', kind: 'url' }, + ...docLocationFields('dormantState.workspaceSession.workspaceDocHistory[].docLocation'), + { at: 'dormantState.workspaceSession.activeTabIdByWorktree{key}', kind: 'owner-key' }, + { at: 'dormantState.workspaceSession.unifiedTabs{key}', kind: 'owner-key' }, + { at: 'dormantState.workspaceSession.unifiedTabs{}[].id', kind: 'editor-file-id' }, + { at: 'dormantState.workspaceSession.unifiedTabs{}[].entityId', kind: 'editor-file-id' }, + { at: 'dormantState.workspaceSession.unifiedTabs{}[].worktreeId', kind: 'worktree-id' }, + { at: 'dormantState.workspaceSession.tabGroups{key}', kind: 'owner-key' }, + { at: 'dormantState.workspaceSession.tabGroups{}[].worktreeId', kind: 'worktree-id' }, + { at: 'dormantState.workspaceSession.tabGroups{}[].activeTabId', kind: 'editor-file-id' }, + { at: 'dormantState.workspaceSession.tabGroups{}[].tabOrder[]', kind: 'editor-file-id' }, + { at: 'dormantState.workspaceSession.tabGroups{}[].recentTabIds[]', kind: 'editor-file-id' }, + { at: 'dormantState.workspaceSession.tabGroupLayouts{key}', kind: 'owner-key' }, + { at: 'dormantState.workspaceSession.activeGroupIdByWorktree{key}', kind: 'owner-key' }, + { at: 'dormantState.workspaceSession.lastVisitedAtByWorktreeId{key}', kind: 'owner-key' }, + { + at: 'dormantState.workspaceSession.defaultTerminalTabsAppliedByWorktreeId{key}', + kind: 'owner-key' + }, + { + at: 'dormantState.workspaceSession.sleepingAgentSessionsByPaneKey{}.worktreeId', + kind: 'worktree-id' + }, + { + at: 'dormantState.workspaceSession.sleepingAgentSessionsByPaneKey{}.providerSession.transcriptPath', + kind: 'absolute' + }, + { + at: 'dormantState.workspaceSession.sleepingAgentSessionsByPaneKey{}.launchConfig.ompResumeFilePath', + kind: 'absolute' + }, + { + at: 'dormantState.workspaceSession.terminalSurfaceTombstonesByPaneKey{}.worktreeId', + kind: 'worktree-id' + }, + { + at: 'dormantState.workspaceSession.terminalSurfaceTombstonesByPaneKey{}.ptyId', + kind: 'pty-session-id' + }, + { + at: 'dormantState.workspaceSession.closedTerminalTabTombstonesByTabId{}.worktreeId', + kind: 'worktree-id' + }, + + { at: 'dormantState.automations[].workspaceId', kind: 'owner-key' }, + { at: 'dormantState.automations[].runContext.path', kind: 'absolute' }, + { at: 'dormantState.automationRuns[].workspaceId', kind: 'owner-key' }, + { at: 'dormantState.automationRuns[].runContext.path', kind: 'absolute' }, + { at: 'dormantState.automationRuns[].terminalPtyId', kind: 'pty-session-id' }, + { at: 'dormantState.automationRuns[].terminalSessionId', kind: 'pty-session-id' }, + + { + at: 'dormantState.clientState.mobileClientTabSelectionsByDeviceId{}{key}', + kind: 'owner-key' + }, + { + at: 'dormantState.clientState.mobileClientTabSelectionsByDeviceId{}{}.activeTabId', + kind: 'editor-file-id' + }, + { + at: 'dormantState.clientState.mobileClientTabSelectionsByDeviceId{}{}.activeTabIdByGroupId{}', + kind: 'editor-file-id' + }, + { at: 'dormantState.clientState.uiRouting.lastActiveWorktreeId', kind: 'worktree-id' }, + { at: 'dormantState.clientState.uiRouting.showDotfilesByWorktree{key}', kind: 'owner-key' }, + { + at: 'dormantState.clientState.clientHostedBrowserCloseIntents[].worktreeId', + kind: 'worktree-id' + } +] + +/** + * Path-bearing fields the stage deliberately leaves alone. Source keys name rows on the source, + * which the desktop subtracts after commit; derived keys must be recomputed, not string-mapped. + */ +export const ORCAD_MIGRATION_UNREWRITTEN_PATH_FIELDS: readonly { at: string; why: string }[] = [ + { at: 'dormantState.worktreeMeta[].sourceKey', why: 'source-side key' }, + { at: 'dormantState.worktreeLineage[].sourceKey', why: 'source-side key' }, + { at: 'dormantState.workspaceLineage[].sourceKey', why: 'source-side key' }, + { at: 'dormantState.retiredWorktreeNamespaces[].sourceNamespaceKeys[]', why: 'source-side key' }, + { + at: 'dormantState.retiredWorktreeNamespaces[].namespaceKey', + why: 'normalized comparison key: recompute for the destination host' + }, + { + at: 'dormantState.worktreeMeta[].meta.mobileDiffReview.files{key}', + why: 'relative paths only' + }, + { at: 'repositories[].repoIcon.label', why: 'relative path only' }, + { + at: 'dormantState.workspaceSession.sleepingAgentSessionsByPaneKey{}.launchConfig.agentEnv{}', + why: 'free-form' + }, + { at: 'repositories[].hookSettings', why: 'free-form shell text' }, + { at: 'repositories[].gitRemoteIdentity.remoteUrl', why: 'git remote URL' } +] + +function docLocationFields(at: string): { at: string; kind: OrcadMigrationPathFieldKind }[] { + return [ + { at: `${at}.worktreeId`, kind: 'worktree-id' }, + { at: `${at}.filePath`, kind: 'absolute' } + ] +} + +function conversionOriginFields(at: string): { at: string; kind: OrcadMigrationPathFieldKind }[] { + return [...docLocationFields(`${at}.docLocation`), { at: `${at}.url`, kind: 'url' }] +} diff --git a/src/main/ssh/orcad-migration-path-rewrite.test.ts b/src/main/ssh/orcad-migration-path-rewrite.test.ts new file mode 100644 index 00000000000..558a225ab6d --- /dev/null +++ b/src/main/ssh/orcad-migration-path-rewrite.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest' +import { + serializeOrcadMigrationValue, + type OrcadMigrationCatalogPayload +} from '../../shared/orcad-migration-manifest' +import { isRecord } from '../../shared/orcad-migration-manifest-fields' +import { ownedEditorFileId } from '../../shared/workspace-session-pane-ownership' +import { + ORCAD_MIGRATION_PATH_FIELDS, + ORCAD_MIGRATION_UNREWRITTEN_PATH_FIELDS, + type OrcadMigrationPathFieldKind +} from './orcad-migration-path-fields' +import { + IDENTITY_ORCAD_MIGRATION_PATH_REWRITER, + parseFieldLocation, + rewriteOrcadMigrationPayloadPaths, + type OrcadMigrationPathRewriter +} from './orcad-migration-path-rewrite' + +const WT = 'repo-1::/src/wt' +const SAMPLE: Record = { + absolute: '/src/a', + 'maybe-absolute': '/src/b', + relative: 'rel/c', + url: 'file:///src/d', + 'worktree-id': WT, + 'owner-key': `worktree:${WT}`, + 'pty-session-id': `${WT}@@pty-1`, + 'editor-file-id': ownedEditorFileId('/src/wt/f.ts', WT, 'env-1') +} + +/** Moves `/src` to `/dst` and marks relative paths, so every rewritten field is visible. */ +const MARKING: OrcadMigrationPathRewriter = { + path: (value, form) => { + if (form === 'relative') { + return value.startsWith('rel/') ? `REL/${value.slice(4)}` : value + } + if (form === 'url') { + return value.replace(/^file:\/\/\/src\//, 'file:///dst/') + } + return value.startsWith('/src/') || value === '/src' ? `/dst${value.slice(4)}` : value + } +} + +/** One payload holding a sample at every location the table names. */ +function payloadWithEveryField(): OrcadMigrationCatalogPayload { + const root: Record = { + repositories: [], + projectGroups: [], + folderWorkspaces: [] + } + for (const field of ORCAD_MIGRATION_PATH_FIELDS) { + place(root, parseFieldLocation(field.at), SAMPLE[field.kind]) + } + // The stage is shape-agnostic, so a sparse object stands in for a full payload. + return { repositories: [], projectGroups: [], folderWorkspaces: [], ...root } +} + +function payloadWithManyOwnerKeys(count: number): OrcadMigrationCatalogPayload { + const root: Record = {} + const steps = parseFieldLocation('dormantState.workspaceSession.tabsByWorktree{key}') + for (let index = 0; index < count; index++) { + place(root, steps, `repo::/src/w${index}`) + } + return { repositories: [], projectGroups: [], folderWorkspaces: [], ...root } +} + +function place( + node: Record, + steps: ReturnType, + sample: string +): void { + const [step, next, ...rest] = steps + if (step?.type !== 'prop') { + throw new Error('table locations start with a property') + } + if (!next) { + node[step.name] = sample + return + } + node[step.name] = placeInto(node[step.name], [next, ...rest], sample) +} + +function placeInto( + existing: unknown, + steps: ReturnType, + sample: string +): unknown { + const [step, ...rest] = steps + if (!step) { + return sample + } + if (step.type === 'items') { + const items = Array.isArray(existing) ? existing : [] + items[0] = placeInto(items[0], rest, sample) + return items + } + const record = isRecord(existing) ? existing : {} + if (step.type === 'keys') { + record[sample] = true + } else if (step.type === 'values') { + record.entry = placeInto(record.entry, rest, sample) + } else { + record[step.name] = placeInto(record[step.name], rest, sample) + } + return record +} + +function valuesAt(node: unknown, steps: ReturnType): unknown[] { + const [step, ...rest] = steps + if (!step) { + return [node] + } + if (step.type === 'items') { + return Array.isArray(node) ? node.flatMap((item) => valuesAt(item, rest)) : [] + } + if (!isRecord(node)) { + return [] + } + if (step.type === 'keys') { + return Object.keys(node).filter((key) => key !== 'entry') + } + if (step.type === 'values') { + return Object.values(node).flatMap((value) => valuesAt(value, rest)) + } + return valuesAt(node[step.name], rest) +} + +function deepFreeze(value: T): T { + if (typeof value === 'object' && value !== null) { + Object.values(value).forEach(deepFreeze) + Object.freeze(value) + } + return value +} + +describe('orcad migration path-rewrite stage', () => { + it('is exact under the identity: same value, same bytes, same key order, input untouched', () => { + const input = deepFreeze(payloadWithEveryField()) + const output = rewriteOrcadMigrationPayloadPaths(input, IDENTITY_ORCAD_MIGRATION_PATH_REWRITER) + expect(output).not.toBe(input) + expect(output).toEqual(input) + expect(JSON.stringify(output)).toBe(JSON.stringify(input)) + expect(serializeOrcadMigrationValue(output)).toBe(serializeOrcadMigrationValue(input)) + }) + + // Regression: renaming re-read Object.keys per entry, so a 20k-key record took seconds. + it('walks a large keyed record in linear time', () => { + const started = performance.now() + rewriteOrcadMigrationPayloadPaths(payloadWithManyOwnerKeys(20_000), MARKING) + expect(performance.now() - started).toBeLessThan(2_000) + }) + + it('reaches every field the table names', () => { + const input = deepFreeze(payloadWithEveryField()) + const output = rewriteOrcadMigrationPayloadPaths(input, MARKING) + for (const field of ORCAD_MIGRATION_PATH_FIELDS) { + const values = valuesAt(output, parseFieldLocation(field.at)) + expect(values, field.at).not.toEqual([]) + for (const value of values) { + expect(value, field.at).not.toBe(SAMPLE[field.kind]) + expect(String(value), field.at).not.toMatch(/\/src\/|%2Fsrc%2F|^rel\//) + } + } + }) + + it('names every location once, and never both rewrites and skips one', () => { + const rewritten = ORCAD_MIGRATION_PATH_FIELDS.map((field) => field.at) + expect(new Set(rewritten).size).toBe(rewritten.length) + for (const skipped of ORCAD_MIGRATION_UNREWRITTEN_PATH_FIELDS) { + expect(rewritten).not.toContain(skipped.at) + } + }) + + it('rewrites only the path half of composite ids', () => { + const map = (kind: OrcadMigrationPathFieldKind, value: string): unknown => { + const field = ORCAD_MIGRATION_PATH_FIELDS.find((entry) => entry.kind === kind)! + const payload = { repositories: [], projectGroups: [], folderWorkspaces: [] } + place(payload, parseFieldLocation(field.at), value) + return valuesAt( + rewriteOrcadMigrationPayloadPaths(payload, MARKING), + parseFieldLocation(field.at) + )[0] + } + const instance = '::workspace:0b6c2c1e-8a39-4c55-9a52-0d7a7f2b1c11' + expect(map('worktree-id', `${WT}${instance}`)).toBe(`repo-1::/dst/wt${instance}`) + expect(map('worktree-id', 'repo-1')).toBe('repo-1') + expect(map('owner-key', 'folder:/src/not-a-path')).toBe('folder:/src/not-a-path') + expect(map('owner-key', WT)).toBe('repo-1::/dst/wt') + expect(map('pty-session-id', `folder:f-1@@pty`)).toBe('folder:f-1@@pty') + expect(map('pty-session-id', 'opaque-relay-id')).toBe('opaque-relay-id') + expect(map('editor-file-id', SAMPLE['editor-file-id'])).toBe( + ownedEditorFileId('/dst/wt/f.ts', 'repo-1::/dst/wt', 'env-1') + ) + expect(map('editor-file-id', '/src/wt/raw.ts')).toBe('/dst/wt/raw.ts') + expect(map('editor-file-id', 'tab-uuid')).toBe('tab-uuid') + }) + + it('refuses a rewrite that would merge two records', () => { + const payload = payloadWithEveryField() + const session = payload.dormantState?.workspaceSession + expect(session).toBeDefined() + session!.tabsByWorktree = { 'repo-1::/src/a': [], 'repo-1::/src/b': [] } + const merging: OrcadMigrationPathRewriter = { + path: (value) => (value.startsWith('/src') ? '/dst' : value) + } + expect(() => rewriteOrcadMigrationPayloadPaths(payload, merging)).toThrow( + 'orcad_migration_path_rewrite_key_collision' + ) + }) +}) diff --git a/src/main/ssh/orcad-migration-path-rewrite.ts b/src/main/ssh/orcad-migration-path-rewrite.ts new file mode 100644 index 00000000000..9ffe2b4fb52 --- /dev/null +++ b/src/main/ssh/orcad-migration-path-rewrite.ts @@ -0,0 +1,198 @@ +/** + * The export's path-rewrite stage over the fields in orcad-migration-path-fields.ts. A source whose + * paths differ on the destination (a local WSL distro reached through `\\wsl.localhost`) supplies a + * rewriter; the relay-era SSH host uses the identity, which leaves the payload byte-identical. + */ +import type { OrcadMigrationCatalogPayload } from '../../shared/orcad-migration-manifest' +import { isRecord } from '../../shared/orcad-migration-manifest-fields' +import { PTY_SESSION_ID_SEPARATOR } from '../../shared/pty-session-id-format' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { + FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + splitWorktreeId, + WORKTREE_ID_SEPARATOR +} from '../../shared/worktree/id' +import { + ORCAD_MIGRATION_PATH_FIELDS, + type OrcadMigrationPathFieldKind, + type OrcadMigrationPathForm +} from './orcad-migration-path-fields' + +export type OrcadMigrationPathRewriter = { + /** Must return anything it does not recognise as a source path unchanged. */ + path: (value: string, form: OrcadMigrationPathForm) => string +} + +export const IDENTITY_ORCAD_MIGRATION_PATH_REWRITER: OrcadMigrationPathRewriter = { + path: (value) => value +} + +/** A rewritten copy; the input is never mutated. Throws when two keys of a record would merge. */ +export function rewriteOrcadMigrationPayloadPaths( + payload: OrcadMigrationCatalogPayload, + rewriter: OrcadMigrationPathRewriter +): OrcadMigrationCatalogPayload { + const copy = structuredClone(payload) + // Why: SSH exports run this on every stage and commit check; the identity changes nothing. + if (rewriter === IDENTITY_ORCAD_MIGRATION_PATH_REWRITER) { + return copy + } + for (const field of ORCAD_MIGRATION_PATH_FIELDS) { + const map = (value: string): string => rewriteValue(value, field.kind, rewriter) + visit(copy, parseFieldLocation(field.at), map) + } + return copy +} + +type Step = + | { type: 'prop'; name: string } + | { type: 'items' } + | { type: 'values' } + | { type: 'keys' } + +export function parseFieldLocation(at: string): Step[] { + const steps: Step[] = [] + for (const token of at.match(/[^.[\]{}]+|\[\]|\{\}|\{key\}/g) ?? []) { + steps.push( + token === '[]' + ? { type: 'items' } + : token === '{}' + ? { type: 'values' } + : token === '{key}' + ? { type: 'keys' } + : { type: 'prop', name: token } + ) + } + return steps +} + +function visit(node: unknown, steps: Step[], map: (value: string) => string): unknown { + const [step, ...rest] = steps + if (!step) { + return typeof node === 'string' ? map(node) : node + } + if (step.type === 'items') { + if (Array.isArray(node)) { + node.forEach((item, index) => { + node[index] = visit(item, rest, map) + }) + } + return node + } + if (!isRecord(node)) { + return node + } + if (step.type === 'prop') { + if (Object.hasOwn(node, step.name)) { + node[step.name] = visit(node[step.name], rest, map) + } + return node + } + if (step.type === 'values') { + for (const key of Object.keys(node)) { + node[key] = visit(node[key], rest, map) + } + return node + } + renameKeys(node, map) + return node +} + +function renameKeys(node: Record, map: (value: string) => string): void { + const keys = Object.keys(node) + const entries = keys.map((key) => [map(key), node[key]] as const) + if (entries.every(([key], index) => key === keys[index])) { + return + } + if (new Set(entries.map(([key]) => key)).size !== entries.length) { + throw new Error('orcad_migration_path_rewrite_key_collision') + } + for (const key of Object.keys(node)) { + delete node[key] + } + for (const [key, value] of entries) { + node[key] = value + } +} + +function rewriteValue( + value: string, + kind: OrcadMigrationPathFieldKind, + rewriter: OrcadMigrationPathRewriter +): string { + switch (kind) { + case 'worktree-id': + return rewriteWorktreeId(value, rewriter) + case 'owner-key': + return rewriteOwnerKey(value, rewriter) + case 'pty-session-id': + return rewritePtySessionId(value, rewriter) + case 'editor-file-id': + return rewriteEditorFileId(value, rewriter) + case 'absolute': + case 'maybe-absolute': + case 'relative': + case 'url': + return rewriter.path(value, kind) + } +} + +function rewriteWorktreeId(value: string, rewriter: OrcadMigrationPathRewriter): string { + const parsed = splitWorktreeId(value) + if (!parsed || !parsed.repoId || !parsed.worktreePath) { + return value + } + const suffixAt = parsed.worktreePath.lastIndexOf(FOLDER_WORKSPACE_INSTANCE_SEPARATOR) + const instanceId = parsed.worktreePath.slice( + suffixAt + FOLDER_WORKSPACE_INSTANCE_SEPARATOR.length + ) + const suffix = + suffixAt > 0 && /^[0-9a-f-]{36}$/.test(instanceId) ? parsed.worktreePath.slice(suffixAt) : '' + const path = parsed.worktreePath.slice(0, parsed.worktreePath.length - suffix.length) + const mapped = rewriter.path(path, 'absolute') + return mapped === path ? value : `${parsed.repoId}${WORKTREE_ID_SEPARATOR}${mapped}${suffix}` +} + +function rewriteOwnerKey(value: string, rewriter: OrcadMigrationPathRewriter): string { + const scope = parseWorkspaceKey(value) + if (scope?.type === 'folder') { + return value + } + if (scope?.type === 'worktree') { + const mapped = rewriteWorktreeId(scope.worktreeId, rewriter) + return mapped === scope.worktreeId ? value : `worktree:${mapped}` + } + return rewriteWorktreeId(value, rewriter) +} + +function rewritePtySessionId(value: string, rewriter: OrcadMigrationPathRewriter): string { + const at = value.lastIndexOf(PTY_SESSION_ID_SEPARATOR) + if (at <= 0) { + return value + } + const owner = value.slice(0, at) + const mapped = rewriteOwnerKey(owner, rewriter) + return mapped === owner ? value : `${mapped}${value.slice(at)}` +} + +function rewriteEditorFileId(value: string, rewriter: OrcadMigrationPathRewriter): string { + const parts = value.split(':') + if (parts[0] !== 'editor' || parts.length !== 4) { + return rewriter.path(value, 'maybe-absolute') + } + let decoded: string[] + try { + decoded = parts.slice(1).map((part) => decodeURIComponent(part)) + } catch { + return value + } + const [worktreeId, runtime, filePath] = decoded + const mappedWorktreeId = rewriteWorktreeId(worktreeId, rewriter) + const mappedFilePath = rewriter.path(filePath, 'absolute') + if (mappedWorktreeId === worktreeId && mappedFilePath === filePath) { + return value + } + return ['editor', mappedWorktreeId, runtime, mappedFilePath] + .map((part, index) => (index === 0 ? part : encodeURIComponent(part))) + .join(':') +} diff --git a/src/main/ssh/orcad-migration-source.ts b/src/main/ssh/orcad-migration-source.ts new file mode 100644 index 00000000000..19834328608 --- /dev/null +++ b/src/main/ssh/orcad-migration-source.ts @@ -0,0 +1,56 @@ +/** + * Where a dormant catalog migrates out of, into a managed orcad. + * + * The destination half (catalog stage/commit and the cutover coordinator) reaches a source only + * through `OrcadMigrationSourceCutoverPort`; the conversion drives the rest. The relay-era SSH + * target (orcad-migration-ssh-relay-source.ts) is the only implementation. + */ +import type { OrcadMigrationSourceKind } from '../../shared/orcad-migration-source-kind' +import type { + OrcadMigrationCatalogPayload, + OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' +import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover' +import type { OrcadMigrationManifestExportOptions } from './orcad-migration-manifest-export' +import type { + OrcadMigrationFenceResult, + OrcadMigrationFenceState +} from './orcad-migration-source-fence' +import type { OrcadMigrationTerminalVerdict } from './orcad-migration-terminal-gate' + +/** What a cutover needs from its source once fenced, keyed by the journaled cutover alone. */ +export type OrcadMigrationSourceCutoverPort = { + /** Before every stage and commit; throws unless the fenced source still matches the journal. */ + verifyFence: (cutover: OrcadMigrationSourceCutover) => void + /** Drops the fence, then the journal; only on proof the destination holds nothing. */ + releaseFence: (cutover: OrcadMigrationSourceCutover) => Promise + /** After a proven commit: the source rows stay, hidden, for a downgraded build. */ + retireAfterCommit: (migrationId: string, now?: () => Date) => OrcadMigrationSourceCutover +} + +export type OrcadMigrationFenceAcquireArgs = { + destinationEnvironmentId: string + destinationName: string + /** Taken by `assessTerminals` while the source could still answer. */ + terminalProof: OrcadMigrationTerminalVerdict + now?: () => Date + signal?: AbortSignal +} + +export type OrcadMigrationSource = OrcadMigrationSourceCutoverPort & { + readonly kind: OrcadMigrationSourceKind + /** What every journal entry of this source is keyed by. */ + readonly journalKey: string + /** Scope selection: the catalog rows this source owns. Read-only. */ + collectCatalog: () => OrcadMigrationCatalogPayload + /** The signed manifest, after the source's path-rewrite stage. Read-only. */ + exportManifest: (options?: OrcadMigrationManifestExportOptions) => OrcadMigrationManifest + /** Before the fence. Loss of contact is `unverifiable`, never `exited`. */ + assessTerminals: () => Promise + /** Marks what an exited proof covers as ended, so later checks stop counting it. */ + retireProvenExitedTerminals: (proof: OrcadMigrationTerminalVerdict) => void + /** Throws when the journal cannot be read: an unreadable record keeps the source fenced. */ + resolveFence: () => OrcadMigrationFenceState + /** Journal, then fence, then flush, then the terminal re-check under the fence. */ + acquireFence: (args: OrcadMigrationFenceAcquireArgs) => Promise +} diff --git a/src/main/ssh/orcad-migration-ssh-relay-source.test.ts b/src/main/ssh/orcad-migration-ssh-relay-source.test.ts new file mode 100644 index 00000000000..3a650935f52 --- /dev/null +++ b/src/main/ssh/orcad-migration-ssh-relay-source.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +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 { createOrcadMigrationManifest } from './orcad-migration-manifest-export' +import { createSshRelayMigrationSource } from './orcad-migration-ssh-relay-source' +import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' + +const TARGET: SshTarget = { + id: 'ssh-prod', + label: 'Production', + host: 'prod.example.com', + port: 22, + username: 'deploy', + generation: 2 +} + +const directories: string[] = [] +afterEach(async () => { + await closeTestStores() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function setup() { + const userDataPath = mkdtempSync(join(tmpdir(), 'orcad-ssh-relay-source-')) + directories.push(userDataPath) + const 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 + }) + const claims = new SshTargetOrcadClaims(store) + const source = createSshRelayMigrationSource({ + userDataPath, + store, + claims, + target: store.getSshTarget(TARGET.id)! + }) + return { userDataPath, store, source } +} + +describe('relay-era SSH migration source', () => { + it('is the SSH kind, keyed by its target id', () => { + expect(setup().source).toMatchObject({ kind: 'ssh-relay', journalKey: TARGET.id }) + }) + + it('exports exactly what the pre-abstraction export produced', () => { + const { store, source } = setup() + const options = { + migrationId: 'migration-1', + now: () => new Date('2026-10-01T00:00:00.000Z'), + destinationEnvironmentId: 'env-1' + } + expect(source.exportManifest(options)).toEqual( + createOrcadMigrationManifest(store, TARGET, options) + ) + expect(source.collectCatalog().repositories.map((repo) => repo.id)).toEqual(['repo-1']) + }) + + it('never proves exit without a host-wide census', async () => { + await expect(setup().source.assessTerminals()).resolves.toMatchObject({ + verdict: 'unverifiable' + }) + }) + + it('acquires, verifies, and releases its fence through the journal it is keyed by', async () => { + const { userDataPath, source } = setup() + expect(source.resolveFence()).toEqual({ state: 'none' }) + const fenced = await source.acquireFence({ + destinationEnvironmentId: 'env-1', + destinationName: 'Managed', + terminalProof: { verdict: 'exited', provenPtyIds: [] } + }) + expect(fenced.outcome).toBe('fenced') + const cutover = listOrcadMigrationSourceCutovers(userDataPath)[0] + expect(cutover.sshTargetId).toBe(source.journalKey) + expect(() => source.verifyFence(cutover)).not.toThrow() + await source.releaseFence(cutover) + expect(listOrcadMigrationSourceCutovers(userDataPath)).toEqual([]) + }) +}) diff --git a/src/main/ssh/orcad-migration-ssh-relay-source.ts b/src/main/ssh/orcad-migration-ssh-relay-source.ts new file mode 100644 index 00000000000..e64d9b0e730 --- /dev/null +++ b/src/main/ssh/orcad-migration-ssh-relay-source.ts @@ -0,0 +1,84 @@ +/** The relay-era SSH target as a migration source: keyed by its target id, paths unchanged. */ +import { ORCAD_MIGRATION_SSH_RELAY_SOURCE_KIND } from '../../shared/orcad-migration-source-kind' +import type { SshTarget } from '../../shared/ssh-types' +import { collectOrcadMigrationSourceCatalog } from '../persistence/migrating-orcad-catalog/orcad-source-catalog' +import type { Store } from '../persistence' +import { createOrcadMigrationManifest } from './orcad-migration-manifest-export' +import { IDENTITY_ORCAD_MIGRATION_PATH_REWRITER } from './orcad-migration-path-rewrite' +import type { + OrcadMigrationSource, + OrcadMigrationSourceCutoverPort +} from './orcad-migration-source' +import { assertOrcadMigrationSourceUnchanged } from './orcad-migration-source-assertions' +import { + fenceOrcadMigrationSource, + releaseOrcadMigrationFence, + resolveOrcadMigrationFence +} from './orcad-migration-source-fence' +import { retainOrcadMigrationSource } from './orcad-migration-source-retention' +import { + assessOrcadMigrationTerminals, + retireProvenExitedLeases, + type CensusHostRelayTerminals +} from './orcad-migration-terminal-gate' +import type { SshTargetOrcadClaims } from './ssh-target-orcad-claims' +import type { OrcadMigrationPreflightStore } from './ssh-target-orcad-preflight' + +type SshRelayCutoverDeps = { + userDataPath: string + store: OrcadMigrationPreflightStore + claims: SshTargetOrcadClaims + /** Rebuilt for every check when `store` is a copy that would never see a later edit. */ + freshSource?: () => OrcadMigrationPreflightStore + signal?: AbortSignal +} + +/** The fenced SSH target a journaled cutover names. */ +export function sshRelayMigrationCutoverPort( + deps: SshRelayCutoverDeps +): OrcadMigrationSourceCutoverPort { + return { + verifyFence: (cutover) => + assertOrcadMigrationSourceUnchanged( + { userDataPath: deps.userDataPath, store: deps.freshSource?.() ?? deps.store }, + cutover + ), + releaseFence: (cutover) => releaseOrcadMigrationFence(deps, cutover), + retireAfterCommit: (migrationId, now) => + retainOrcadMigrationSource(deps.userDataPath, migrationId, now) + } +} + +export function createSshRelayMigrationSource( + deps: SshRelayCutoverDeps & { + store: OrcadMigrationPreflightStore & + Pick + target: SshTarget + /** Every relay endpoint on the host's account; without it exit can never be proven. */ + censusHost?: CensusHostRelayTerminals | null + } +): OrcadMigrationSource { + const { userDataPath, store, target } = deps + return { + ...sshRelayMigrationCutoverPort(deps), + kind: ORCAD_MIGRATION_SSH_RELAY_SOURCE_KIND, + journalKey: target.id, + collectCatalog: () => collectOrcadMigrationSourceCatalog(store, target), + exportManifest: (options = {}) => + createOrcadMigrationManifest(store, target, { + ...options, + rewrite: IDENTITY_ORCAD_MIGRATION_PATH_REWRITER + }), + assessTerminals: () => assessOrcadMigrationTerminals(store, target.id, deps.censusHost), + retireProvenExitedTerminals: (proof) => retireProvenExitedLeases(store, target.id, proof), + resolveFence: () => resolveOrcadMigrationFence(userDataPath, target), + acquireFence: (args) => + fenceOrcadMigrationSource({ + userDataPath, + store, + claims: deps.claims, + targetId: target.id, + ...args + }) + } +} diff --git a/src/main/ssh/orcad-runtime-conversion.ts b/src/main/ssh/orcad-runtime-conversion.ts index 615d46ca5ab..295e4a7f570 100644 --- a/src/main/ssh/orcad-runtime-conversion.ts +++ b/src/main/ssh/orcad-runtime-conversion.ts @@ -26,18 +26,13 @@ import { type OrcadMigrationDestinationCatalog } from './orcad-migration-cutover-coordinator' import { removeOrcadMigrationJournalsForDestination } from './orcad-migration-cutover-journal' -import { - fenceOrcadMigrationSource, - resolveOrcadMigrationFence -} from './orcad-migration-source-fence' import type { SshTarget } from '../../shared/ssh-types' -import { - assessOrcadMigrationTerminals, - type CensusHostRelayTerminals, - retireProvenExitedLeases -} from './orcad-migration-terminal-gate' +import type { CensusHostRelayTerminals } from './orcad-migration-terminal-gate' import { createManagedOrcadEnvironment } from './orcad-runtime-deployment' -import { retainOrcadMigrationSource } from './orcad-migration-source-retention' +import { + createSshRelayMigrationSource, + sshRelayMigrationCutoverPort +} from './orcad-migration-ssh-relay-source' export type OrcadManagedConversionArgs = { sshTargetId: string @@ -83,12 +78,15 @@ export async function convertSshTargetToManagedOrcad( ) await ensureOrcadManagedTunnel(userDataPath, environment.id) const { claims, targetStore } = requireManagedOrcadInfrastructure() + const store = targetStore.getOrcadMigrationSource() + const source = sshRelayMigrationCutoverPort({ userDataPath, store, claims }) const committed = await runTargetLifecycle(fenced.sshTargetId, () => commitOrcadMigrationDestination( { userDataPath, - store: targetStore.getOrcadMigrationSource(), + store, claims, + source, destination: args.destinationFor(marked), now: args.now }, @@ -99,7 +97,7 @@ export async function convertSshTargetToManagedOrcad( throw new Error(`orcad_migration_commit_not_proven:${committed.phase}`) } if (committed.phase === 'destination-committed') { - retainOrcadMigrationSource(userDataPath, committed.migrationId, args.now) + source.retireAfterCommit(committed.migrationId, args.now) } return { outcome: 'converted', @@ -119,7 +117,16 @@ async function fenceOrResume( if (!target) { return refuse('unverifiable', 'orcad_migration_target_not_found', 'The SSH host is gone.') } - const existing = resolveOrcadMigrationFence(userDataPath, target) + const store = targetStore.getOrcadMigrationSource() + const censusHost = args.censusHost + const source = createSshRelayMigrationSource({ + userDataPath, + store, + claims, + target, + censusHost: censusHost ? () => censusHost(target) : null + }) + const existing = source.resolveFence() if (existing.state === 'fenced') { return existing.cutover } @@ -142,25 +149,15 @@ async function fenceOrResume( 'This SSH host is already a managed server.' ) } - const store = targetStore.getOrcadMigrationSource() // The fence re-checks leases once it holds. - const censusHost = args.censusHost - const terminalProof = await assessOrcadMigrationTerminals( - store, - target.id, - censusHost ? () => censusHost(target) : null - ) + const terminalProof = await source.assessTerminals() if (terminalProof.verdict === 'live' || terminalProof.verdict === 'unverifiable') { return refuse(terminalProof.verdict, 'orcad_migration_terminals', terminalProof.reason) } - retireProvenExitedLeases(store, target.id, terminalProof) + source.retireProvenExitedTerminals(terminalProof) await args.releaseDirectSession?.(target.id) const result = await runTargetLifecycle(target.id, () => - fenceOrcadMigrationSource({ - userDataPath, - store, - claims, - targetId: target.id, + source.acquireFence({ destinationEnvironmentId: randomUUID(), destinationName: args.name, terminalProof, diff --git a/src/shared/orcad-migration-manifest-validation.ts b/src/shared/orcad-migration-manifest-validation.ts index 20f215fe809..eaf4c912707 100644 --- a/src/shared/orcad-migration-manifest-validation.ts +++ b/src/shared/orcad-migration-manifest-validation.ts @@ -144,6 +144,10 @@ function parseSource(value: unknown): OrcadMigrationManifestSource { if (!isRecord(value)) { throw new Error('orcad_migration_manifest_source_invalid') } + // Only the relay-era SSH source (no `kind`) is parsed here; other kinds ship with their capability. + if (value.kind !== undefined) { + throw new Error('orcad_migration_manifest_source_kind_unsupported') + } const generation = value.sshTargetGeneration if (generation !== null && (!Number.isSafeInteger(generation) || Number(generation) < 1)) { throw new Error('orcad_migration_manifest_source_generation_invalid') diff --git a/src/shared/orcad-migration-manifest.ts b/src/shared/orcad-migration-manifest.ts index d0b8095c947..bed98017e47 100644 --- a/src/shared/orcad-migration-manifest.ts +++ b/src/shared/orcad-migration-manifest.ts @@ -35,12 +35,15 @@ export { } from './orcad-migration-staged-catalog-validation' import { isRecord } from './orcad-migration-manifest-fields' -export type OrcadMigrationManifestSource = { +/** The relay-era SSH source: on the wire it never carries `kind` (see orcad-migration-source-kind). */ +export type OrcadMigrationSshManifestSource = { sshTargetId: string sshTargetGeneration: number | null targetLabel: string } +export type OrcadMigrationManifestSource = OrcadMigrationSshManifestSource + export type OrcadMigrationCatalogPayload = { repositories: Repo[] projectGroups: ProjectGroup[] diff --git a/src/shared/orcad-migration-source-kind.test.ts b/src/shared/orcad-migration-source-kind.test.ts new file mode 100644 index 00000000000..d4010cc10e7 --- /dev/null +++ b/src/shared/orcad-migration-source-kind.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { + ORCAD_MIGRATION_MANIFEST_VERSION, + normalizeOrcadMigrationImportReceipts, + parseOrcadMigrationManifest +} from './orcad-migration-manifest' +import { + ORCAD_MIGRATION_SOURCE_KIND_UNKNOWN, + orcadMigrationManifestSourceKind, + requiredOrcadMigrationSourceCapability +} from './orcad-migration-source-kind' +import { + ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY, + ORCAD_RUNTIME_CAPABILITIES +} from './orcad-runtime-capabilities' + +const SSH_SOURCE = { sshTargetId: 'ssh-1', sshTargetGeneration: 2, targetLabel: 'Prod' } + +/** Exactly what a desktop that predates `source.kind` sends. */ +function manifestWithSource(source: unknown) { + return { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: 'migration-kind', + createdAt: '2026-10-01T00:00:00.000Z', + source, + payload: { repositories: [], projectGroups: [], folderWorkspaces: [] }, + manifestSha256: 'a'.repeat(64) + } +} + +describe('orcad migration source kind', () => { + it('reads an absent kind as the relay-era SSH source, which needs no extra capability', () => { + expect(orcadMigrationManifestSourceKind(SSH_SOURCE)).toBe('ssh-relay') + expect(orcadMigrationManifestSourceKind({ ...SSH_SOURCE, kind: undefined })).toBe('ssh-relay') + expect(requiredOrcadMigrationSourceCapability(SSH_SOURCE)).toBeNull() + }) + + it('gates a WSL source on its own capability, which no destination advertises yet', () => { + expect(requiredOrcadMigrationSourceCapability({ kind: 'local-wsl' })).toBe( + ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY + ) + expect(ORCAD_RUNTIME_CAPABILITIES).not.toContain( + ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY + ) + }) + + it.each(['ssh-relay', 'toString', 'wsl', 7, null])('refuses unregistered kind %j', (kind) => { + expect(() => orcadMigrationManifestSourceKind({ kind })).toThrow( + ORCAD_MIGRATION_SOURCE_KIND_UNKNOWN + ) + }) +}) + +describe('orcad migration manifest source on the destination', () => { + it('accepts an older desktop manifest and its receipt unchanged', () => { + expect(parseOrcadMigrationManifest(manifestWithSource(SSH_SOURCE)).source).toEqual(SSH_SOURCE) + const receipt = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: 'migration-kind', + manifestSha256: 'a'.repeat(64), + source: SSH_SOURCE, + importedAt: '2026-10-01T00:00:00.000Z', + repositoryIds: [], + projectGroupIds: [], + folderWorkspaceIds: [] + } + expect(normalizeOrcadMigrationImportReceipts([receipt])).toEqual([receipt]) + }) + + it('refuses any source kind until it ships the parser for that kind', () => { + for (const source of [ + { ...SSH_SOURCE, kind: 'local-wsl' }, + { kind: 'local-wsl', distro: 'Ubuntu', targetLabel: 'Ubuntu' }, + { ...SSH_SOURCE, kind: 'ssh-relay' } + ]) { + expect(() => parseOrcadMigrationManifest(manifestWithSource(source))).toThrow( + 'orcad_migration_manifest_source_kind_unsupported' + ) + } + }) + + it('fails a WSL source closed on a destination that drops the kind', () => { + // Without the SSH fields, even a parser that ignores `kind` cannot read it as an SSH source. + const { kind: _kind, ...asOlderDestinationSeesIt } = { + kind: 'local-wsl', + distro: 'Ubuntu', + targetLabel: 'Ubuntu' + } + expect(() => parseOrcadMigrationManifest(manifestWithSource(asOlderDestinationSeesIt))).toThrow( + /^orcad_migration_manifest_source/ + ) + }) +}) diff --git a/src/shared/orcad-migration-source-kind.ts b/src/shared/orcad-migration-source-kind.ts new file mode 100644 index 00000000000..42b669647fd --- /dev/null +++ b/src/shared/orcad-migration-source-kind.ts @@ -0,0 +1,54 @@ +/** + * Which kind of source a migration manifest moves state out of. + * + * The relay-era SSH target is encoded by the ABSENCE of `source.kind`, so its manifests stay + * byte-identical (and digest-identical) for destinations that predate the field. Every other kind + * sets `kind` and must never carry `sshTargetId`: an older destination strips unknown fields, so a + * non-SSH source without it fails that destination's parse instead of importing as an SSH host. + */ +import { ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY } from './orcad-runtime-capabilities' + +export const ORCAD_MIGRATION_SSH_RELAY_SOURCE_KIND = 'ssh-relay' as const + +/** Non-SSH kinds and the destination capability that proves a destination parses each one. */ +export const ORCAD_MIGRATION_SOURCE_KIND_CAPABILITIES = { + 'local-wsl': ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY +} as const + +export type OrcadMigrationNonSshSourceKind = keyof typeof ORCAD_MIGRATION_SOURCE_KIND_CAPABILITIES +export type OrcadMigrationSourceKind = + | typeof ORCAD_MIGRATION_SSH_RELAY_SOURCE_KIND + | OrcadMigrationNonSshSourceKind + +export const ORCAD_MIGRATION_SOURCE_KIND_UNKNOWN = 'orcad_migration_source_kind_unknown' + +/** A manifest source as it travels: this build may not know every field a newer peer signed. */ +export type OrcadMigrationWireSource = Readonly> + +/** Reads the wire discriminator; a source without one is the relay-era SSH source. */ +export function orcadMigrationManifestSourceKind( + source: OrcadMigrationWireSource +): OrcadMigrationSourceKind { + if (source.kind === undefined) { + return ORCAD_MIGRATION_SSH_RELAY_SOURCE_KIND + } + // An explicit 'ssh-relay' is refused too: SSH has exactly one encoding, so one digest. + if (isNonSshSourceKind(source.kind)) { + return source.kind + } + throw new Error(ORCAD_MIGRATION_SOURCE_KIND_UNKNOWN) +} + +function isNonSshSourceKind(kind: unknown): kind is OrcadMigrationNonSshSourceKind { + return typeof kind === 'string' && Object.hasOwn(ORCAD_MIGRATION_SOURCE_KIND_CAPABILITIES, kind) +} + +/** The capability a destination must advertise before it may see this source; null for SSH. */ +export function requiredOrcadMigrationSourceCapability( + source: OrcadMigrationWireSource +): string | null { + const kind = orcadMigrationManifestSourceKind(source) + return kind === ORCAD_MIGRATION_SSH_RELAY_SOURCE_KIND + ? null + : ORCAD_MIGRATION_SOURCE_KIND_CAPABILITIES[kind] +} diff --git a/src/shared/orcad-runtime-capabilities.ts b/src/shared/orcad-runtime-capabilities.ts index 51f5444a4c2..2816219b5e8 100644 --- a/src/shared/orcad-runtime-capabilities.ts +++ b/src/shared/orcad-runtime-capabilities.ts @@ -6,6 +6,12 @@ export const ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY = 'orcad.terminal-census.v // reads a catalog only on a host advertising this, and treats its absence as a refusal. export const ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY = 'orcad.migration-catalog.v1' as const +// Why per kind: an older destination drops `source.kind` and cannot tell a WSL source from an SSH +// one, so a client sends a non-SSH manifest only to a host advertising that kind. Not advertised +// until a destination parses `source.kind: 'local-wsl'`. +export const ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY = + 'orcad.migration-source.local-wsl.v1' as const + export const ORCAD_RUNTIME_CAPABILITIES = [ ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY, ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY