mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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. */
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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(':')
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user