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.
This commit is contained in:
Neil
2026-10-13 09:23:59 -07:00
committed by GitHub
parent fc4bf86319
commit 7ed23199a5
18 changed files with 1341 additions and 47 deletions
@@ -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',
@@ -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()
}
)
})
+15 -2
View File
@@ -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<RuntimeRpcResponse<unknown>> {
const response = await sendRemoteRuntimeRequestWithStatusPreflight<unknown>(
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,
@@ -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<void> = (cutover) =>
releaseOrcadMigrationFence(context, cutover)
cutoverSource(context).releaseFence(cutover)
): Promise<OrcadMigrationAbortResult> {
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. */
+25 -13
View File
@@ -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 }
: {})
@@ -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())
})
})
+216
View File
@@ -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 =
/** `<repoId>::<path>`, optionally `::workspace:<uuid>` for a folder project. */
| 'worktree-id'
/** A worktree id, `worktree:<worktreeId>` or `folder:<id>`. */
| 'owner-key'
/** `<owner key>@@<id>` when minted by Orca; anything else is opaque. */
| 'pty-session-id'
/** `editor:<enc worktreeId>:<enc runtime>:<enc filePath>`, 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' }]
}
@@ -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<OrcadMigrationPathFieldKind, string> = {
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<string, unknown> = {
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<string, unknown> = {}
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<string, unknown>,
steps: ReturnType<typeof parseFieldLocation>,
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<typeof parseFieldLocation>,
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<typeof parseFieldLocation>): 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<T>(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'
)
})
})
@@ -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<string, unknown>, 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(':')
}
+56
View File
@@ -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<void>
/** 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<OrcadMigrationTerminalVerdict>
/** 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<OrcadMigrationFenceResult>
}
@@ -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([])
})
})
@@ -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<Store, 'getSshRemotePtyLeases' | 'markSshRemotePtyLease'>
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
})
}
}
+23 -26
View File
@@ -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,
@@ -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')
+4 -1
View File
@@ -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[]
@@ -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/
)
})
})
+54
View File
@@ -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<Record<string, unknown>>
/** 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]
}
+6
View File
@@ -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