From 05a8ee87c0c10ddffe6a85d0596854a86ef30b0c Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 21:57:42 -0700 Subject: [PATCH 1/2] feat(orcad): stage and commit a dormant migration catalog on the managed server (#16741 T6-9) The destination half of a catalog migration: an orcad stages a T6-7 manifest (repositories, project groups, folder workspaces, dormant session, client, automation and worktree metadata, retired names, scrollback snapshots) with exclusive claims, then commits it with a receipt so a retried commit returns the same receipt and never imports twice. Served as orcad.migration.* runtime RPC behind the orcad.migration-catalog.v1 capability; the client refuses a host without it or with method-not-found, and any other failure is left for the caller to recheck. Dormant only: no live PTY projection. Inert on the desktop until T6-10. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../profile-project-session-state.ts | 12 + ...istence-orcad-migration-catalog-fixture.ts | 256 ++++++++ ...ersistence-orcad-migration-catalog.test.ts | 581 ++++++++++++++++++ .../normalize-loaded-profile-state.ts | 8 + .../loading-store/store-domain-composition.ts | 12 +- .../orcad-catalog-import.ts | 311 ++++++++++ .../orcad-catalog-records.ts | 224 +++++++ .../orcad-destination-automation-state.ts | 124 ++++ .../orcad-destination-client-state.test.ts | 99 +++ .../orcad-destination-client-state.ts | 268 ++++++++ ...rcad-destination-workspace-session.test.ts | 131 ++++ .../orcad-destination-workspace-session.ts | 185 ++++++ .../orcad-destination-worktree-metadata.ts | 41 ++ .../orcad-dormant-metadata-defaults.test.ts | 71 +++ .../orcad-dormant-state-records.ts | 311 ++++++++++ .../orcad-staged-catalog-claims.test.ts | 137 +++++ .../orcad-staged-catalog-claims.ts | 61 ++ .../deregistered-repo-residue.ts | 6 + .../runtime/orca-runtime-migration-catalog.ts | 134 ++++ src/main/runtime/orca-runtime.ts | 6 +- .../orcad-migration-catalog-import.test.ts | 110 ++++ .../runtime/orcad-migration-catalog-import.ts | 82 +++ src/main/runtime/rpc/methods/index.ts | 2 + .../rpc/methods/orcad-migration.test.ts | 194 ++++++ .../runtime/rpc/methods/orcad-migration.ts | 78 +++ src/main/runtime/runtime-store-contract.ts | 6 + .../orcad-migration-catalog-client.test.ts | 345 +++++++++++ .../ssh/orcad-migration-catalog-client.ts | 173 ++++++ src/shared/persisted-state-types.ts | 8 + src/shared/protocol-version.ts | 5 + src/shared/remote-runtime-client.ts | 5 +- .../rpc-params-catalog.generated.ts | 7 + 32 files changed, 3987 insertions(+), 6 deletions(-) create mode 100644 src/main/persistence-orcad-migration-catalog-fixture.ts create mode 100644 src/main/persistence-orcad-migration-catalog.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-destination-automation-state.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-destination-client-state.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-destination-client-state.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-destination-worktree-metadata.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-dormant-metadata-defaults.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-dormant-state-records.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-staged-catalog-claims.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-staged-catalog-claims.ts create mode 100644 src/main/runtime/orca-runtime-migration-catalog.ts create mode 100644 src/main/runtime/orcad-migration-catalog-import.test.ts create mode 100644 src/main/runtime/orcad-migration-catalog-import.ts create mode 100644 src/main/runtime/rpc/methods/orcad-migration.test.ts create mode 100644 src/main/runtime/rpc/methods/orcad-migration.ts create mode 100644 src/main/ssh/orcad-migration-catalog-client.test.ts create mode 100644 src/main/ssh/orcad-migration-catalog-client.ts diff --git a/src/main/orca-profiles/profile-project-session-state.ts b/src/main/orca-profiles/profile-project-session-state.ts index d17c3b243026..b1ccc6c488f5 100644 --- a/src/main/orca-profiles/profile-project-session-state.ts +++ b/src/main/orca-profiles/profile-project-session-state.ts @@ -45,6 +45,10 @@ export function mergeWorkspaceSessions( const base = existing ?? getDefaultWorkspaceSession() return { ...base, + // Why: the focus scalars below fall back to incoming together, so a borrowed worktree keeps its repo and host. + activeRepoId: base.activeRepoId ?? incoming.activeRepoId, + activeWorkspaceExecutionHostId: + base.activeWorkspaceExecutionHostId ?? incoming.activeWorkspaceExecutionHostId, tabsByWorktree: { ...base.tabsByWorktree, ...incoming.tabsByWorktree }, terminalLayoutsByTabId: { ...base.terminalLayoutsByTabId, @@ -111,6 +115,14 @@ export function mergeWorkspaceSessions( ...base.terminalSurfaceTombstonesByPaneKey, ...incoming.terminalSurfaceTombstonesByPaneKey }, + ...(base.sleepingAgentSessionsByPaneKey || incoming.sleepingAgentSessionsByPaneKey + ? { + sleepingAgentSessionsByPaneKey: { + ...base.sleepingAgentSessionsByPaneKey, + ...incoming.sleepingAgentSessionsByPaneKey + } + } + : {}), activeWorktreeIdsOnShutdown: [ ...(base.activeWorktreeIdsOnShutdown ?? []), ...(incoming.activeWorktreeIdsOnShutdown ?? []) diff --git a/src/main/persistence-orcad-migration-catalog-fixture.ts b/src/main/persistence-orcad-migration-catalog-fixture.ts new file mode 100644 index 000000000000..f837413374a9 --- /dev/null +++ b/src/main/persistence-orcad-migration-catalog-fixture.ts @@ -0,0 +1,256 @@ +import type { FolderWorkspace } from '../shared/folder-workspace-types' +import type { Automation, AutomationRun } from '../shared/automations-types' +import { + ORCAD_MIGRATION_MANIFEST_VERSION, + type OrcadMigrationDormantStatePayload, + type OrcadMigrationManifest +} from '../shared/orcad-migration-manifest' +import type { ProjectGroup } from '../shared/project-group-types' +import type { Repo } from '../shared/repo-types' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import { computeOrcadMigrationManifestSha256 } from './orcad/orcad-migration-manifest-digest' + +export const PROJECT_GROUP: ProjectGroup = { + id: 'group-1', + name: 'Production', + parentPath: '/srv', + connectionId: 'ssh-prod', + executionHostId: 'ssh:ssh-prod', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 1, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 2 +} + +export const REPOSITORY: Repo = { + id: 'repo-1', + path: '/srv/repo-1', + displayName: 'Repository', + badgeColor: '#737373', + addedAt: 3, + kind: 'git', + connectionId: 'ssh-prod', + executionHostId: 'ssh:ssh-prod', + projectGroupId: 'group-1' +} + +export const FOLDER_WORKSPACE: FolderWorkspace = { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Investigate', + folderPath: '/srv/investigate', + connectionId: 'ssh-prod', + executionHostId: 'ssh:ssh-prod', + linkedTask: null, + comment: 'Keep this note', + isArchived: false, + isUnread: true, + isPinned: true, + sortOrder: 4, + lastActivityAt: 5, + createdAt: 6, + updatedAt: 7 +} + +export const DORMANT_WORKTREE_ID = 'repo-1::/srv/repo-1-worktree' +export const DORMANT_NAMESPACE = 'local:/srv/orca-worktrees' +export const DORMANT_LEAF_ID = '11111111-1111-4111-8111-111111111111' +export const DORMANT_AUTOMATION: Automation = { + id: 'automation-dormant', + name: 'Nightly checks', + prompt: 'Run tests', + precheck: null, + agentId: 'codex', + runContext: { + kind: 'workspace-run', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: REPOSITORY.id, + repoId: REPOSITORY.id, + path: REPOSITORY.path + }, + sourceContext: null, + projectId: REPOSITORY.id, + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'remote_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: 'main', + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: false, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 60, + createdAt: 1, + updatedAt: 2 +} +export const DORMANT_AUTOMATION_RUN: AutomationRun = { + id: 'run-dormant', + automationId: DORMANT_AUTOMATION.id, + runContext: DORMANT_AUTOMATION.runContext, + sourceContext: null, + title: 'Nightly checks run 1', + scheduledFor: 1, + status: 'completed', + trigger: 'scheduled', + workspaceId: null, + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: 'tab-history', + terminalPaneKey: null, + terminalPtyId: 'pty-history', + outputSnapshot: { + format: 'plain_text', + content: 'all green', + capturedAt: 2, + truncated: false + }, + precheckResult: null, + usage: null, + error: null, + startedAt: 1, + dispatchedAt: 1, + createdAt: 1, + runNumber: 1 +} + +export function dormantState(): OrcadMigrationDormantStatePayload { + return { + version: 1, + worktreeMeta: [ + { + sourceKey: DORMANT_WORKTREE_ID, + worktreeId: DORMANT_WORKTREE_ID, + meta: { + instanceId: 'instance-1', + displayName: 'Dormant worktree', + comment: 'Preserve me', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: true, + isPinned: false, + sortOrder: 1, + lastActivityAt: 4_102_444_800_000, + hostId: 'local' + } + } + ], + worktreeLineage: [ + { + sourceKey: DORMANT_WORKTREE_ID, + worktreeId: DORMANT_WORKTREE_ID, + lineage: { + worktreeId: DORMANT_WORKTREE_ID, + worktreeInstanceId: 'instance-1', + parentWorktreeId: REPOSITORY.id, + parentWorktreeInstanceId: 'main-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 3 + } + } + ], + workspaceLineage: [ + { + sourceKey: worktreeWorkspaceKey(DORMANT_WORKTREE_ID), + childWorkspaceKey: worktreeWorkspaceKey(DORMANT_WORKTREE_ID), + lineage: { + childWorkspaceKey: worktreeWorkspaceKey(DORMANT_WORKTREE_ID), + childInstanceId: 'instance-1', + parentWorkspaceKey: folderWorkspaceKey(FOLDER_WORKSPACE.id), + parentInstanceId: null, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 4 + } + } + ], + sparsePresets: [ + { + id: 'preset-1', + repoId: REPOSITORY.id, + name: 'Renderer', + directories: ['src/renderer'], + createdAt: 5, + updatedAt: 6 + } + ], + retiredWorktreeNames: [ + { repoId: REPOSITORY.id, registry: { exhaustedTiers: 0, names: ['nautilus'] } } + ], + retiredWorktreeNamespaces: [ + { + sourceNamespaceKeys: ['ssh:source:/srv/orca-worktrees'], + namespaceKey: DORMANT_NAMESPACE, + registry: { exhaustedTiers: 0, names: ['seahorse'] } + } + ], + workspaceSession: { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [DORMANT_WORKTREE_ID]: [ + { + id: 'tab-dormant', + ptyId: null, + worktreeId: DORMANT_WORKTREE_ID, + title: 'Dormant terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 7 + } + ] + }, + terminalLayoutsByTabId: { + 'tab-dormant': { + root: { type: 'leaf', leafId: DORMANT_LEAF_ID }, + activeLeafId: DORMANT_LEAF_ID, + expandedLeafId: null, + titlesByLeafId: { [DORMANT_LEAF_ID]: 'Investigating' } + } + } + }, + automations: [structuredClone(DORMANT_AUTOMATION)], + automationRuns: [structuredClone(DORMANT_AUTOMATION_RUN)] + } +} + +export function manifest(overrides: Partial = {}): OrcadMigrationManifest { + const unsigned = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: 'migration-1', + createdAt: '2026-08-30T12:00:00.000Z', + source: { + sshTargetId: 'ssh-prod', + sshTargetGeneration: 8, + targetLabel: 'Production' + }, + payload: { + repositories: [REPOSITORY], + projectGroups: [PROJECT_GROUP], + folderWorkspaces: [FOLDER_WORKSPACE] + }, + ...withoutDigest(overrides) + } + return { + ...unsigned, + manifestSha256: overrides.manifestSha256 ?? computeOrcadMigrationManifestSha256(unsigned) + } +} + +export function withoutDigest( + value: Partial +): Partial> { + const { manifestSha256: _digest, ...rest } = value + return rest +} diff --git a/src/main/persistence-orcad-migration-catalog.test.ts b/src/main/persistence-orcad-migration-catalog.test.ts new file mode 100644 index 000000000000..f99e32c202c0 --- /dev/null +++ b/src/main/persistence-orcad-migration-catalog.test.ts @@ -0,0 +1,581 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MAX_ORCAD_MIGRATION_STAGED_CATALOGS } from '../shared/orcad-migration-manifest' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import { MAX_RETIREMENT_NAMESPACES } from './worktree-retirement-namespace' +import { + DORMANT_AUTOMATION, + DORMANT_AUTOMATION_RUN, + DORMANT_LEAF_ID, + DORMANT_NAMESPACE, + DORMANT_WORKTREE_ID, + FOLDER_WORKSPACE, + REPOSITORY, + dormantState, + manifest +} from './persistence-orcad-migration-catalog-fixture' +import { + createStore, + makeRepo, + readDataFile, + testState, + writeDataFile +} from './persistence-test-harness' + +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 1 })) +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +vi.mock('./telemetry/client', () => ({ track: trackMock })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +describe('orcad migration catalog persistence', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-migration-catalog-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('imports the catalog durably, strips source ownership, and replays idempotently', async () => { + const store = createStore() + const input = manifest() + + const first = store.importOrcadMigrationCatalog(input, { + now: () => new Date('2026-08-30T12:05:00.000Z') + }) + await store.flushPendingOrThrowAsync() + const restored = createStore() + const replay = restored.importOrcadMigrationCatalog(input) + + expect(first).toMatchObject({ + status: 'imported', + receipt: { + migrationId: 'migration-1', + importedAt: '2026-08-30T12:05:00.000Z', + repositoryIds: ['repo-1'], + projectGroupIds: ['group-1'], + folderWorkspaceIds: ['folder-1'] + } + }) + expect(replay.status).toBe('already-imported') + expect(restored.getRepos()).toEqual([ + expect.not.objectContaining({ + connectionId: expect.anything(), + executionHostId: expect.anything() + }) + ]) + expect(restored.getProjectGroups()).toEqual([ + expect.not.objectContaining({ + connectionId: expect.anything(), + executionHostId: expect.anything() + }) + ]) + expect(restored.getFolderWorkspaces()).toEqual([ + expect.objectContaining({ id: 'folder-1', comment: 'Keep this note' }) + ]) + expect(restored.getFolderWorkspaces()[0]?.connectionId).toBeNull() + expect(restored.getFolderWorkspaces()[0]).not.toHaveProperty('executionHostId') + }) + + it('imports dormant source focus scalars alongside the inactive session', () => { + const store = createStore() + const incomingDormant = dormantState() + incomingDormant.workspaceSession = { + ...incomingDormant.workspaceSession!, + activeRepoId: REPOSITORY.id, + activeWorktreeId: DORMANT_WORKTREE_ID, + activeWorkspaceKey: worktreeWorkspaceKey(DORMANT_WORKTREE_ID), + activeWorkspaceExecutionHostId: 'local', + activeTabId: 'tab-dormant' + } + const base = manifest() + const input = manifest({ + payload: { ...base.payload, dormantState: incomingDormant } + }) + + store.importOrcadMigrationCatalog(input) + + expect(store.getWorkspaceSession()).toMatchObject({ + activeRepoId: REPOSITORY.id, + activeWorktreeId: DORMANT_WORKTREE_ID, + activeWorkspaceKey: worktreeWorkspaceKey(DORMANT_WORKTREE_ID), + activeWorkspaceExecutionHostId: 'local', + activeTabId: 'tab-dormant' + }) + }) + + it('stages durably without publishing rows, then commits catalog and receipt together', async () => { + const store = createStore() + const input = manifest() + + expect( + store.stageOrcadMigrationCatalog(input, { + now: () => new Date('2026-08-30T12:03:00.000Z') + }) + ).toEqual({ + state: 'staged', + migrationId: input.migrationId, + manifestSha256: input.manifestSha256, + stagedAt: '2026-08-30T12:03:00.000Z' + }) + expect(store.getRepos()).toEqual([]) + await store.flushPendingOrThrowAsync() + + const restored = createStore() + expect(restored.getOrcadMigrationCatalogState(input)).toMatchObject({ state: 'staged' }) + expect( + restored.commitStagedOrcadMigrationCatalog(input, { + now: () => new Date('2026-08-30T12:05:00.000Z') + }) + ).toMatchObject({ state: 'committed', receipt: { importedAt: '2026-08-30T12:05:00.000Z' } }) + expect(restored.getRepos()).toHaveLength(1) + await restored.flushPendingOrThrowAsync() + + const committed = createStore() + expect(committed.getOrcadMigrationCatalogState(input)).toMatchObject({ + state: 'committed', + receipt: { repositoryIds: ['repo-1'] } + }) + expect(readDataFile()).not.toHaveProperty('orcadMigrationStagedCatalogs.0') + }) + + it('commits dormant metadata, lineage, presets, and retirement state with the receipt', async () => { + const store = createStore() + const base = manifest() + const input = manifest({ payload: { ...base.payload, dormantState: dormantState() } }) + store.mergeRetiredWorktreeNamesForNamespace(DORMANT_NAMESPACE, ['octopus']) + + store.stageOrcadMigrationCatalog(input) + expect(store.getAllWorktreeMeta()).toEqual({}) + expect(store.getSparsePresets(REPOSITORY.id)).toEqual([]) + + store.commitStagedOrcadMigrationCatalog(input) + expect(store.getWorktreeMeta(DORMANT_WORKTREE_ID)).toMatchObject({ + comment: 'Preserve me', + hostId: 'local' + }) + expect(store.getWorktreeLineage(DORMANT_WORKTREE_ID)?.parentWorktreeId).toBe(REPOSITORY.id) + expect( + store.getWorkspaceLineage(worktreeWorkspaceKey(DORMANT_WORKTREE_ID))?.parentWorkspaceKey + ).toBe(folderWorkspaceKey(FOLDER_WORKSPACE.id)) + expect(store.getSparsePresets(REPOSITORY.id).map((preset) => preset.id)).toEqual(['preset-1']) + expect(store.getWorkspaceSession().tabsByWorktree[DORMANT_WORKTREE_ID]?.[0]).toMatchObject({ + id: 'tab-dormant', + ptyId: null + }) + expect( + store.getWorkspaceSession().terminalLayoutsByTabId['tab-dormant']?.titlesByLeafId + ).toEqual({ [DORMANT_LEAF_ID]: 'Investigating' }) + expect(store.listAutomations()).toEqual([ + expect.objectContaining({ + id: DORMANT_AUTOMATION.id, + enabled: false, + schedulerOwner: 'remote_host_service' + }) + ]) + expect(store.listAutomationRuns(DORMANT_AUTOMATION.id)).toEqual([ + expect.objectContaining({ + id: DORMANT_AUTOMATION_RUN.id, + outputSnapshot: expect.objectContaining({ content: 'all green' }) + }) + ]) + expect(store.getRetiredWorktreeNameRegistry(REPOSITORY.id).names).toContain('nautilus') + expect(store.getRetiredWorktreeNameRegistryForNamespace(DORMANT_NAMESPACE).names).toEqual( + expect.arrayContaining(['octopus', 'seahorse']) + ) + await store.flushPendingOrThrowAsync() + + const restored = createStore() + expect(restored.getOrcadMigrationCatalogState(input)).toMatchObject({ state: 'committed' }) + expect(restored.commitStagedOrcadMigrationCatalog(input)).toMatchObject({ state: 'committed' }) + expect(restored.getWorktreeMeta(DORMANT_WORKTREE_ID)?.comment).toBe('Preserve me') + expect(restored.getSparsePresets(REPOSITORY.id)).toHaveLength(1) + expect(restored.getWorkspaceSession().tabsByWorktree[DORMANT_WORKTREE_ID]).toHaveLength(1) + expect(restored.listAutomationRuns(DORMANT_AUTOMATION.id)).toHaveLength(1) + }) + + it('recovers a dormant commit lost before flush from the durable stage', async () => { + const store = createStore() + const base = manifest() + const input = manifest({ payload: { ...base.payload, dormantState: dormantState() } }) + store.stageOrcadMigrationCatalog(input) + await store.flushPendingOrThrowAsync() + const stagedDiskState = readDataFile() + + store.commitStagedOrcadMigrationCatalog(input) + expect(store.getWorktreeMeta(DORMANT_WORKTREE_ID)?.comment).toBe('Preserve me') + writeDataFile(stagedDiskState) + + const restored = createStore() + expect(restored.getOrcadMigrationCatalogState(input)).toMatchObject({ state: 'staged' }) + expect(restored.getWorktreeMeta(DORMANT_WORKTREE_ID)).toBeUndefined() + restored.commitStagedOrcadMigrationCatalog(input) + await restored.flushPendingOrThrowAsync() + + const committed = createStore() + expect(committed.getOrcadMigrationCatalogState(input)).toMatchObject({ state: 'committed' }) + expect(committed.getWorktreeMeta(DORMANT_WORKTREE_ID)?.comment).toBe('Preserve me') + }) + + it('merges a migrated retirement namespace while preserving the storage cap', async () => { + const store = createStore() + for (let index = 0; index < MAX_RETIREMENT_NAMESPACES; index += 1) { + store.mergeRetiredWorktreeNamesForNamespace(`local:/existing/${index}`, ['nautilus']) + } + const base = manifest() + const input = manifest({ payload: { ...base.payload, dormantState: dormantState() } }) + + store.stageOrcadMigrationCatalog(input) + store.commitStagedOrcadMigrationCatalog(input) + await store.flushPendingOrThrowAsync() + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the raw data file is untyped JSON this test edits to simulate an older writer. + const diskState = readDataFile() as Record + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the raw data file is untyped JSON this test edits to simulate an older writer. + const namespaces = (diskState.retiredWorktreeNamesByNamespace ?? {}) as Record + expect(Object.keys(namespaces)).toHaveLength(MAX_RETIREMENT_NAMESPACES) + expect(store.getRetiredWorktreeNameRegistryForNamespace(DORMANT_NAMESPACE).names).toContain( + 'seahorse' + ) + expect(store.getRetiredWorktreeNameRegistryForNamespace('local:/existing/0').names).toEqual([]) + }) + + it('rejects dormant-state conflicts before publishing any catalog row', () => { + const store = createStore() + store.setWorktreeMeta(DORMANT_WORKTREE_ID, { + ...dormantState().worktreeMeta[0].meta, + comment: 'Different owner' + }) + const base = manifest() + const input = manifest({ payload: { ...base.payload, dormantState: dormantState() } }) + + expect(() => store.stageOrcadMigrationCatalog(input)).toThrow( + `orcad_migration_dormant_id_conflict:worktree_meta:${DORMANT_WORKTREE_ID}` + ) + expect(store.getRepos()).toEqual([]) + expect(store.getProjectGroups()).toEqual([]) + expect(store.getFolderWorkspaces()).toEqual([]) + }) + + it('rejects a dormant workspace-session owner conflict before publication', () => { + const store = createStore() + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [DORMANT_WORKTREE_ID]: [ + { + ...dormantState().workspaceSession!.tabsByWorktree[DORMANT_WORKTREE_ID][0], + title: 'Destination terminal' + } + ] + } + }) + const base = manifest() + const input = manifest({ payload: { ...base.payload, dormantState: dormantState() } }) + + expect(() => store.stageOrcadMigrationCatalog(input)).toThrow( + `orcad_migration_dormant_id_conflict:workspace_session:tabsByWorktree:${DORMANT_WORKTREE_ID}` + ) + expect(store.getRepos()).toEqual([]) + }) + + it('rejects a dormant automation conflict before publishing a second migration', () => { + const store = createStore() + const base = manifest() + const first = manifest({ payload: { ...base.payload, dormantState: dormantState() } }) + store.importOrcadMigrationCatalog(first) + const changedDormant = dormantState() + const changedAutomation = changedDormant.automations?.[0] + if (!changedAutomation) { + throw new Error('expected dormant automation') + } + changedDormant.automations = [{ ...changedAutomation, name: 'Different destination owner' }] + const second = manifest({ + migrationId: 'migration-automation-conflict', + payload: { ...base.payload, dormantState: changedDormant } + }) + + expect(() => store.stageOrcadMigrationCatalog(second)).toThrow( + `orcad_migration_dormant_id_conflict:automation:${DORMANT_AUTOMATION.id}` + ) + expect(store.listAutomations()).toEqual([ + expect.objectContaining({ id: DORMANT_AUTOMATION.id, name: 'Nightly checks' }) + ]) + }) + + it('rejects destination automation state with an in-flight run', () => { + const store = createStore() + const base = manifest() + const first = manifest({ payload: { ...base.payload, dormantState: dormantState() } }) + store.importOrcadMigrationCatalog(first) + store.createAutomationRun(DORMANT_AUTOMATION, 10, 'manual') + const nextDormant = dormantState() + delete nextDormant.automationRuns + const second = manifest({ + migrationId: 'migration-automation-active', + payload: { ...base.payload, dormantState: nextDormant } + }) + + expect(() => store.stageOrcadMigrationCatalog(second)).toThrow( + `orcad_migration_dormant_automation_run_active:${DORMANT_AUTOMATION.id}` + ) + expect(store.getOrcadMigrationCatalogState(second)).toMatchObject({ state: 'absent' }) + }) + + it('stages idempotently and rejects migration-id reuse before catalog mutation', () => { + const store = createStore() + const input = manifest() + const first = store.stageOrcadMigrationCatalog(input) + + expect(store.stageOrcadMigrationCatalog(input)).toEqual(first) + expect(() => + store.stageOrcadMigrationCatalog( + manifest({ payload: { ...input.payload, repositories: [] } }) + ) + ).toThrow('orcad_migration_id_reused_with_different_manifest') + expect(store.getRepos()).toEqual([]) + }) + + it('aborts only matching staged state and never rolls back a committed catalog', () => { + const store = createStore() + const input = manifest() + + expect(store.abortStagedOrcadMigrationCatalog(input)).toMatchObject({ + state: 'absent', + aborted: false + }) + store.stageOrcadMigrationCatalog(input) + expect(store.abortStagedOrcadMigrationCatalog(input)).toMatchObject({ + state: 'absent', + aborted: true + }) + store.stageOrcadMigrationCatalog(input) + store.commitStagedOrcadMigrationCatalog(input) + expect(store.abortStagedOrcadMigrationCatalog(input)).toMatchObject({ + state: 'committed', + aborted: false + }) + expect(store.getRepos()).toHaveLength(1) + }) + + it('requires a durable stage before commit and bounds concurrent dormant catalogs', () => { + const store = createStore() + const input = manifest() + expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( + 'orcad_migration_catalog_not_staged' + ) + + for (let index = 0; index < MAX_ORCAD_MIGRATION_STAGED_CATALOGS; index++) { + store.stageOrcadMigrationCatalog( + manifest({ + migrationId: `migration-${index + 1}`, + payload: { repositories: [], projectGroups: [], folderWorkspaces: [] } + }) + ) + } + expect(() => + store.stageOrcadMigrationCatalog(manifest({ migrationId: 'migration-overflow' })) + ).toThrow('orcad_migration_staging_capacity_exceeded') + expect(store.getRepos()).toEqual([]) + }) + + it('retains exclusive staged claims after reload and releases them on dormant abort', async () => { + const store = createStore() + const first = manifest() + const second = manifest({ migrationId: 'competing-migration' }) + store.stageOrcadMigrationCatalog(first) + await store.flushPendingOrThrowAsync() + const restored = createStore() + expect(() => restored.stageOrcadMigrationCatalog(second)).toThrow( + 'orcad_migration_staged_claim_conflict:' + ) + expect(() => restored.importOrcadMigrationCatalog(second)).toThrow( + 'orcad_migration_staged_claim_conflict:' + ) + expect(restored.getOrcadMigrationCatalogState(first)).toMatchObject({ state: 'staged' }) + expect(restored.getOrcadMigrationCatalogState(second)).toMatchObject({ state: 'absent' }) + expect(restored.getRepos()).toEqual([]) + expect(restored.abortStagedOrcadMigrationCatalog(first).aborted).toBe(true) + await restored.flushPendingOrThrowAsync() + const afterAbort = createStore() + expect(afterAbort.stageOrcadMigrationCatalog(second)).toMatchObject({ state: 'staged' }) + expect(afterAbort.commitStagedOrcadMigrationCatalog(second)).toMatchObject({ + state: 'committed' + }) + }) + + it('refuses direct import with a changed manifest under an existing staged identity', () => { + const store = createStore() + const first = manifest() + store.stageOrcadMigrationCatalog(first) + const changed = manifest({ payload: { ...first.payload, repositories: [] } }) + expect(() => store.importOrcadMigrationCatalog(changed)).toThrow( + 'orcad_migration_id_reused_with_different_manifest' + ) + expect(store.getOrcadMigrationCatalogState(first)).toMatchObject({ state: 'staged' }) + expect(store.getRepos()).toEqual([]) + }) + + it('refuses committing overlapping stages loaded from an older writer', async () => { + const store = createStore() + const first = manifest() + const second = manifest({ migrationId: 'older-competing-stage' }) + store.stageOrcadMigrationCatalog(first) + await store.flushPendingOrThrowAsync() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the raw data file is untyped JSON this test edits to simulate an older writer. + const persisted = readDataFile() as Record + persisted.orcadMigrationStagedCatalogs = [first, second].map((entry) => ({ + version: 1, + manifest: entry, + stagedAt: entry.createdAt + })) + writeDataFile(persisted) + const restored = createStore() + for (const entry of [first, second]) { + expect(() => restored.commitStagedOrcadMigrationCatalog(entry)).toThrow( + 'orcad_migration_staged_claim_conflict:' + ) + expect(restored.getOrcadMigrationCatalogState(entry)).toMatchObject({ state: 'staged' }) + } + expect(restored.getRepos()).toEqual([]) + expect(restored.abortStagedOrcadMigrationCatalog(second).aborted).toBe(true) + expect(restored.commitStagedOrcadMigrationCatalog(first)).toMatchObject({ state: 'committed' }) + }) + + it('reconstructs a missing receipt without duplicating identical catalog or dormant state', async () => { + const store = createStore() + const base = manifest() + const input = manifest({ payload: { ...base.payload, dormantState: dormantState() } }) + store.importOrcadMigrationCatalog(input) + await store.flushPendingOrThrowAsync() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the raw data file is untyped JSON this test edits to simulate an older writer. + const persisted = readDataFile() as Record + delete persisted.orcadMigrationImportReceipts + writeDataFile(persisted) + + const restored = createStore() + const replay = restored.importOrcadMigrationCatalog(input) + + expect(replay.status).toBe('imported') + expect(restored.getRepos()).toHaveLength(1) + expect(restored.getProjectGroups()).toHaveLength(1) + expect(restored.getFolderWorkspaces()).toHaveLength(1) + expect(restored.getWorktreeMeta(DORMANT_WORKTREE_ID)?.comment).toBe('Preserve me') + expect(restored.getSparsePresets(REPOSITORY.id)).toHaveLength(1) + }) + + it('rejects a stale receipt when its imported catalog no longer matches', () => { + const store = createStore() + const input = manifest() + store.importOrcadMigrationCatalog(input) + store.removeProject(REPOSITORY.id) + + expect(() => store.importOrcadMigrationCatalog(input)).toThrow( + 'orcad_migration_receipt_catalog_mismatch:repository:repo-1' + ) + }) + + it('rejects an id reuse with a different manifest without duplicating state', () => { + const store = createStore() + const input = manifest() + store.importOrcadMigrationCatalog(input) + const changed = manifest({ + payload: { + ...input.payload, + repositories: [{ ...REPOSITORY, displayName: 'Changed' }] + } + }) + + expect(() => store.importOrcadMigrationCatalog(changed)).toThrow( + 'orcad_migration_id_reused_with_different_manifest' + ) + expect(store.getRepos()).toHaveLength(1) + }) + + it('validates all path conflicts before adding any manifest row', () => { + const store = createStore() + store.addRepo(makeRepo({ id: 'existing', path: REPOSITORY.path })) + + expect(() => store.importOrcadMigrationCatalog(manifest())).toThrow( + `orcad_migration_repository_path_conflict:${REPOSITORY.path}` + ) + expect(store.getRepos().map((repo) => repo.id)).toEqual(['existing']) + expect(store.getProjectGroups()).toEqual([]) + expect(store.getFolderWorkspaces()).toEqual([]) + }) + + it('validates all id conflicts before adding any manifest row', () => { + const store = createStore() + store.addRepo(makeRepo({ id: REPOSITORY.id, path: '/srv/different' })) + + expect(() => store.importOrcadMigrationCatalog(manifest())).toThrow( + `orcad_migration_repository_id_conflict:${REPOSITORY.id}` + ) + expect(store.getRepos().map((repo) => repo.path)).toEqual(['/srv/different']) + expect(store.getProjectGroups()).toEqual([]) + expect(store.getFolderWorkspaces()).toEqual([]) + }) + + it('rejects missing group references before mutation', () => { + const store = createStore() + const input = manifest() + const invalid = manifest({ + payload: { ...input.payload, projectGroups: [] } + }) + + expect(() => store.importOrcadMigrationCatalog(invalid)).toThrow( + 'orcad_migration_repository_project_group_missing:repo-1' + ) + expect(store.getRepos()).toEqual([]) + expect(store.getProjectGroups()).toEqual([]) + expect(store.getFolderWorkspaces()).toEqual([]) + }) + + it('rejects a digest mismatch before mutation', () => { + const store = createStore() + const input = manifest({ manifestSha256: '0'.repeat(64) }) + + expect(() => store.importOrcadMigrationCatalog(input)).toThrow( + 'orcad_migration_manifest_digest_mismatch' + ) + expect(store.getRepos()).toEqual([]) + }) + + it('rejects a peer that strips optional automation state before mutation', () => { + const store = createStore() + const base = manifest() + const input = manifest({ + payload: { ...base.payload, dormantState: dormantState() } + }) + const strippedDormantState = structuredClone(input.payload.dormantState) + if (!strippedDormantState) { + throw new Error('expected dormant state') + } + delete strippedDormantState.automations + delete strippedDormantState.automationRuns + + expect(() => + store.importOrcadMigrationCatalog({ + ...input, + payload: { ...input.payload, dormantState: strippedDormantState } + }) + ).toThrow('orcad_migration_manifest_digest_mismatch') + expect(store.getRepos()).toEqual([]) + expect(store.listAutomations()).toEqual([]) + }) +}) diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts index 987a6e81a177..277d7cda1c1c 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -3,6 +3,8 @@ import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { normalizeFeatureInteractionTelemetryBuckets } from '../../../shared/feature-interactions' import { normalizeFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' import { normalizeFolderWorkspaces } from '../../../shared/folder-workspaces' +import { normalizeOrcadMigrationImportReceipts } from '../../../shared/orcad-migration-manifest-validation' +import { normalizeOrcadMigrationStagedCatalogs } from '../../../shared/orcad-migration-staged-catalog-validation' import { normalizeWorkspaceLineageByChildKey } from '../applying-settings/ui-interaction-merge' import { normalizeSshRemotePtyLease, @@ -106,6 +108,12 @@ export function normalizeLoadedProfileState( legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries(parsed.legacyPaneKeyAliasEntries), automations: Array.isArray(parsed.automations) ? parsed.automations : [], automationRuns: normalizeLoadedAutomationRuns(parsed, markNeedsSave), + orcadMigrationImportReceipts: normalizeOrcadMigrationImportReceipts( + parsed.orcadMigrationImportReceipts + ), + orcadMigrationStagedCatalogs: normalizeOrcadMigrationStagedCatalogs( + parsed.orcadMigrationStagedCatalogs + ), onboarding: normalizedOnboarding } } diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 437dced88387..4ba2e58881ac 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -67,6 +67,10 @@ import { OrcadSourceExportPersistence, installOrcadSourceExportPersistenceContext } from '../migrating-orcad-catalog/orcad-source-export' +import { + OrcadCatalogImportPersistence, + installOrcadCatalogImportPersistenceContext +} from '../migrating-orcad-catalog/orcad-catalog-import' export type StoreDomainOperations = WriteSchedulingOperations & PrimaryStateWriteOperations & @@ -84,6 +88,7 @@ export type StoreDomainOperations = WriteSchedulingOperations & RetiredWorktreeNamePersistence & SshLeaseRecoveryOperations & OrcadSourceExportPersistence & + OrcadCatalogImportPersistence & WriteFlushBarrierOperations export type StoreDomains = { @@ -109,6 +114,7 @@ export type StoreDomains = { retiredWorktreeNames: RetiredWorktreeNamePersistence sshLeases: SshLeaseRecoveryOperations orcadSourceExport: OrcadSourceExportPersistence + orcadCatalogImports: OrcadCatalogImportPersistence } export const STORE_DOMAIN_OPERATION_CLASSES = [ @@ -128,6 +134,7 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [ RetiredWorktreeNamePersistence, SshLeaseRecoveryOperations, OrcadSourceExportPersistence, + OrcadCatalogImportPersistence, WriteFlushBarrierOperations ] as const @@ -148,6 +155,7 @@ export function installStoreDomainContexts(target: Store, domains: StoreDomains) installRetiredWorktreeNamePersistenceContext(target, domains.retiredWorktreeNames) installSshLeaseRecoveryOperationsContext(target, domains.sshLeases) installOrcadSourceExportPersistenceContext(target, domains.orcadSourceExport) + installOrcadCatalogImportPersistenceContext(target, domains.orcadCatalogImports) installWriteFlushBarrierOperationsContext(target, domains.flushBarriers) } @@ -183,6 +191,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { bindingRecovery, scheduling ) + const orcadCatalogImports = new OrcadCatalogImportPersistence(runtime, repos, scheduling) return { adaptation, cohorts, @@ -206,6 +215,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { retiredWorktreeNames, sshLeases, // Read-only: holds the runtime state and nothing that writes. - orcadSourceExport: new OrcadSourceExportPersistence(runtime) + orcadSourceExport: new OrcadSourceExportPersistence(runtime), + orcadCatalogImports } } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts new file mode 100644 index 000000000000..598fe45535dc --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts @@ -0,0 +1,311 @@ +import { + MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS, + MAX_ORCAD_MIGRATION_STAGED_CATALOGS, + ORCAD_MIGRATION_MANIFEST_VERSION, + type OrcadMigrationCatalogAbortResult, + type OrcadMigrationCatalogState, + type OrcadMigrationImportReceipt, + type OrcadMigrationImportResult, + type OrcadMigrationManifest +} from '../../../shared/orcad-migration-manifest' +import type { + OrcadMigrationSnapshotChunkRequest, + OrcadMigrationSnapshotChunkResult +} from '../../../shared/orcad-migration-scrollback' +import { assertOrcadMigrationManifestDigest } from '../../orcad/orcad-migration-manifest-digest' +import type { RepoLifecycleOperations } from '../loading-store/repo-lifecycle-operations' +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' +import type { WriteSchedulingOperations } from '../loading-store/write-scheduling' +import { scheduleSave } from '../loading-store/write-scheduling' +import { + applyPreparedOrcadMigrationCatalog, + assertCommittedOrcadMigrationCatalog, + assertOrcadMigrationReceiptMatchesManifest, + assertSameOrcadMigrationManifest, + prepareOrcadMigrationCatalog, + type PreparedOrcadMigrationCatalog +} from './orcad-catalog-records' +import { + abortOrcadMigrationSnapshots, + assertOrcadMigrationSnapshotsReady, + commitOrcadMigrationSnapshots, + inspectOrcadMigrationSnapshotUploads, + pruneOrcadMigrationSnapshotStaging, + stageOrcadMigrationSnapshotChunk +} from './orcad-scrollback-snapshot-transfer' + +type OrcadCatalogImportRuntime = Pick< + StoreRuntimeState, + 'state' | 'terminalScrollbackSnapshotStorage' +> + +const orcadCatalogImportContext = Symbol('OrcadCatalogImportPersistence') +type OrcadCatalogImportContext = { + runtime: OrcadCatalogImportRuntime + repos: RepoLifecycleOperations + scheduling: WriteSchedulingOperations +} + +export class OrcadCatalogImportPersistence { + readonly [orcadCatalogImportContext]: OrcadCatalogImportContext + + constructor( + runtime: OrcadCatalogImportRuntime, + repos: RepoLifecycleOperations, + scheduling: WriteSchedulingOperations + ) { + this[orcadCatalogImportContext] = { runtime, repos, scheduling } + } + + importOrcadMigrationCatalog( + manifest: OrcadMigrationManifest, + options: { now?: () => Date } = {} + ): OrcadMigrationImportResult { + assertOrcadMigrationManifestDigest(manifest) + const context = this[orcadCatalogImportContext] + const existingReceipt = findImportReceipt(context.runtime.state, manifest.migrationId) + if (existingReceipt) { + assertCommittedReceipt( + existingReceipt, + manifest, + context.runtime.state, + context.runtime.terminalScrollbackSnapshotStorage + ) + return { status: 'already-imported', receipt: structuredClone(existingReceipt) } + } + if ((manifest.payload.dormantState?.terminalScrollbackSnapshots?.length ?? 0) > 0) { + throw new Error('orcad_migration_snapshot_stage_required') + } + const prepared = prepareOrcadMigrationCatalog(manifest, context.runtime.state) + const receipt = commitPreparedCatalog(context, manifest, prepared, options.now) + scheduleSave(context.scheduling) + return { status: 'imported', receipt: structuredClone(receipt) } + } + + stageOrcadMigrationCatalog( + manifest: OrcadMigrationManifest, + options: { now?: () => Date } = {} + ): OrcadMigrationCatalogState { + assertOrcadMigrationManifestDigest(manifest) + const context = this[orcadCatalogImportContext] + pruneOrcadMigrationSnapshotStaging( + stagedManifests(context.runtime.state), + context.runtime.terminalScrollbackSnapshotStorage + ) + const current = migrationCatalogState( + context.runtime.state, + manifest, + context.runtime.terminalScrollbackSnapshotStorage + ) + if (current.state === 'committed') { + return current + } + prepareOrcadMigrationCatalog(manifest, context.runtime.state) + if (current.state === 'staged') { + return current + } + const staged = context.runtime.state.orcadMigrationStagedCatalogs ?? [] + if (staged.length >= MAX_ORCAD_MIGRATION_STAGED_CATALOGS) { + throw new Error('orcad_migration_staging_capacity_exceeded') + } + const stagedAt = (options.now ?? (() => new Date()))().toISOString() + context.runtime.state.orcadMigrationStagedCatalogs = [ + ...staged, + { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + manifest: structuredClone(manifest), + stagedAt + } + ] + scheduleSave(context.scheduling) + return stagedCatalogState(manifest, stagedAt, context.runtime.terminalScrollbackSnapshotStorage) + } + + stageOrcadMigrationSnapshotChunk( + request: OrcadMigrationSnapshotChunkRequest + ): OrcadMigrationSnapshotChunkResult { + const context = this[orcadCatalogImportContext] + return stageOrcadMigrationSnapshotChunk({ + stagedManifest: + stagedManifests(context.runtime.state).find( + (manifest) => manifest.migrationId === request.migrationId + ) ?? null, + storage: context.runtime.terminalScrollbackSnapshotStorage, + request + }) + } + + commitStagedOrcadMigrationCatalog( + manifest: OrcadMigrationManifest, + options: { now?: () => Date } = {} + ): OrcadMigrationCatalogState { + assertOrcadMigrationManifestDigest(manifest) + const context = this[orcadCatalogImportContext] + const current = migrationCatalogState( + context.runtime.state, + manifest, + context.runtime.terminalScrollbackSnapshotStorage + ) + if (current.state === 'committed') { + return current + } + if (current.state !== 'staged') { + throw new Error('orcad_migration_catalog_not_staged') + } + const prepared = prepareOrcadMigrationCatalog(manifest, context.runtime.state) + assertOrcadMigrationSnapshotsReady(manifest, context.runtime.terminalScrollbackSnapshotStorage) + commitOrcadMigrationSnapshots(manifest, context.runtime.terminalScrollbackSnapshotStorage) + const receipt = commitPreparedCatalog(context, manifest, prepared, options.now) + scheduleSave(context.scheduling) + return committedCatalogState(receipt) + } + + abortStagedOrcadMigrationCatalog( + manifest: OrcadMigrationManifest + ): OrcadMigrationCatalogAbortResult { + assertOrcadMigrationManifestDigest(manifest) + const context = this[orcadCatalogImportContext] + const current = migrationCatalogState( + context.runtime.state, + manifest, + context.runtime.terminalScrollbackSnapshotStorage + ) + if (current.state === 'committed' || current.state === 'absent') { + return { ...current, aborted: false } + } + // Dormant import only: nothing but this stage references the staged rows before commit. + context.runtime.state.orcadMigrationStagedCatalogs = ( + context.runtime.state.orcadMigrationStagedCatalogs ?? [] + ).filter((entry) => entry.manifest.migrationId !== manifest.migrationId) + abortOrcadMigrationSnapshots(manifest, context.runtime.terminalScrollbackSnapshotStorage) + scheduleSave(context.scheduling) + return { ...absentCatalogState(manifest), aborted: true } + } + + getOrcadMigrationCatalogState(manifest: OrcadMigrationManifest): OrcadMigrationCatalogState { + assertOrcadMigrationManifestDigest(manifest) + const context = this[orcadCatalogImportContext] + return migrationCatalogState( + context.runtime.state, + manifest, + context.runtime.terminalScrollbackSnapshotStorage + ) + } +} + +function commitPreparedCatalog( + context: OrcadCatalogImportContext, + manifest: OrcadMigrationManifest, + prepared: PreparedOrcadMigrationCatalog, + now?: () => Date +): OrcadMigrationImportReceipt { + applyPreparedOrcadMigrationCatalog(prepared, context.runtime.state, context.repos) + const receipt: OrcadMigrationImportReceipt = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: manifest.migrationId, + manifestSha256: manifest.manifestSha256, + source: structuredClone(manifest.source), + importedAt: (now ?? (() => new Date()))().toISOString(), + repositoryIds: prepared.repositories.map((repo) => repo.id), + projectGroupIds: prepared.projectGroups.map((group) => group.id), + folderWorkspaceIds: prepared.folderWorkspaces.map((workspace) => workspace.id) + } + context.runtime.state.orcadMigrationStagedCatalogs = ( + context.runtime.state.orcadMigrationStagedCatalogs ?? [] + ).filter((entry) => entry.manifest.migrationId !== manifest.migrationId) + context.runtime.state.orcadMigrationImportReceipts = [ + ...(context.runtime.state.orcadMigrationImportReceipts ?? []), + receipt + ].slice(-MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS) + return receipt +} + +function migrationCatalogState( + state: StoreRuntimeState['state'], + manifest: OrcadMigrationManifest, + storage?: StoreRuntimeState['terminalScrollbackSnapshotStorage'] +): OrcadMigrationCatalogState { + const receipt = findImportReceipt(state, manifest.migrationId) + if (receipt) { + assertCommittedReceipt(receipt, manifest, state, storage) + return committedCatalogState(receipt) + } + const staged = state.orcadMigrationStagedCatalogs?.find( + (entry) => entry.manifest.migrationId === manifest.migrationId + ) + if (!staged) { + return absentCatalogState(manifest) + } + assertSameOrcadMigrationManifest(staged.manifest, manifest) + return stagedCatalogState(manifest, staged.stagedAt, storage) +} + +function stagedManifests(state: StoreRuntimeState['state']): OrcadMigrationManifest[] { + return (state.orcadMigrationStagedCatalogs ?? []).map((entry) => entry.manifest) +} + +function findImportReceipt( + state: StoreRuntimeState['state'], + migrationId: string +): OrcadMigrationImportReceipt | undefined { + return state.orcadMigrationImportReceipts?.find((receipt) => receipt.migrationId === migrationId) +} + +function assertCommittedReceipt( + receipt: OrcadMigrationImportReceipt, + manifest: OrcadMigrationManifest, + state: StoreRuntimeState['state'], + storage?: StoreRuntimeState['terminalScrollbackSnapshotStorage'] +): void { + if (receipt.manifestSha256 !== manifest.manifestSha256) { + throw new Error('orcad_migration_id_reused_with_different_manifest') + } + assertOrcadMigrationReceiptMatchesManifest(receipt, manifest) + assertCommittedOrcadMigrationCatalog(manifest, state) + if (storage) { + assertOrcadMigrationSnapshotsReady(manifest, storage) + } +} + +function absentCatalogState(manifest: OrcadMigrationManifest): OrcadMigrationCatalogState { + return { + state: 'absent', + migrationId: manifest.migrationId, + manifestSha256: manifest.manifestSha256 + } +} + +function stagedCatalogState( + manifest: OrcadMigrationManifest, + stagedAt: string, + storage?: StoreRuntimeState['terminalScrollbackSnapshotStorage'] +): OrcadMigrationCatalogState { + const snapshotUploads = storage + ? inspectOrcadMigrationSnapshotUploads(manifest, storage) + : undefined + return { + state: 'staged', + migrationId: manifest.migrationId, + manifestSha256: manifest.manifestSha256, + stagedAt, + ...(snapshotUploads ? { snapshotUploads } : {}) + } +} + +function committedCatalogState(receipt: OrcadMigrationImportReceipt): OrcadMigrationCatalogState { + return { + state: 'committed', + migrationId: receipt.migrationId, + manifestSha256: receipt.manifestSha256, + receipt: structuredClone(receipt) + } +} + +export function installOrcadCatalogImportPersistenceContext( + target: object, + source: OrcadCatalogImportPersistence +): void { + Object.defineProperty(target, orcadCatalogImportContext, { + value: source[orcadCatalogImportContext] + }) +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts new file mode 100644 index 000000000000..59f5dbc4bab4 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts @@ -0,0 +1,224 @@ +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import { + serializeOrcadMigrationValue, + type OrcadMigrationImportReceipt, + type OrcadMigrationManifest +} from '../../../shared/orcad-migration-manifest' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { Repo } from '../../../shared/repo-types' +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' +import { assertOrcadMigrationStagedCatalogClaims } from './orcad-staged-catalog-claims' +import { + syncProjectHostSetupCompatibilityState, + type RepoLifecycleOperations +} from '../loading-store/repo-lifecycle-operations' +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' +import { + applyPreparedOrcadMigrationDormantState, + assertCommittedOrcadMigrationDormantState, + prepareOrcadMigrationDormantState, + type PreparedOrcadMigrationDormantState +} from './orcad-dormant-state-records' + +export type PreparedOrcadMigrationCatalog = { + repositories: Repo[] + projectGroups: ProjectGroup[] + folderWorkspaces: FolderWorkspace[] + newRepositories: Repo[] + newProjectGroups: ProjectGroup[] + newFolderWorkspaces: FolderWorkspace[] + dormantState: PreparedOrcadMigrationDormantState +} + +export function assertSameOrcadMigrationManifest( + staged: OrcadMigrationManifest, + requested: OrcadMigrationManifest +): void { + if (staged.manifestSha256 !== requested.manifestSha256) { + throw new Error('orcad_migration_id_reused_with_different_manifest') + } +} + +// Dormant only: no live PTY reaches the destination, so the incoming session is compared as is. +export function prepareOrcadMigrationCatalog( + manifest: OrcadMigrationManifest, + state: StoreRuntimeState['state'] +): PreparedOrcadMigrationCatalog { + assertOrcadMigrationStagedCatalogClaims(manifest, state.orcadMigrationStagedCatalogs ?? []) + const repositories = manifest.payload.repositories.map(toOrcadDestinationRepository) + const projectGroups = manifest.payload.projectGroups.map(toOrcadDestinationProjectGroup) + const folderWorkspaces = manifest.payload.folderWorkspaces.map(toOrcadDestinationFolderWorkspace) + const newRepositories = selectNewRows(repositories, state.repos, 'repository') + const newProjectGroups = selectNewRows(projectGroups, state.projectGroups, 'project_group') + const newFolderWorkspaces = selectNewRows( + folderWorkspaces, + state.folderWorkspaces, + 'folder_workspace' + ) + assertNoRepositoryPathConflicts(repositories, state.repos) + assertCatalogReferences({ + repositories, + projectGroups, + folderWorkspaces, + existingProjectGroups: state.projectGroups + }) + const dormantState = prepareOrcadMigrationDormantState(manifest, state) + return { + repositories, + projectGroups, + folderWorkspaces, + newRepositories, + newProjectGroups, + newFolderWorkspaces, + dormantState + } +} + +export function applyPreparedOrcadMigrationCatalog( + prepared: PreparedOrcadMigrationCatalog, + state: StoreRuntimeState['state'], + repos: RepoLifecycleOperations +): void { + state.projectGroups.push(...prepared.newProjectGroups) + state.repos.push(...prepared.newRepositories) + state.folderWorkspaces.push(...prepared.newFolderWorkspaces) + applyPreparedOrcadMigrationDormantState(prepared.dormantState, state) + if (prepared.newRepositories.length > 0) { + syncProjectHostSetupCompatibilityState(repos) + } +} + +export function assertOrcadMigrationReceiptMatchesManifest( + receipt: OrcadMigrationImportReceipt, + manifest: OrcadMigrationManifest +): void { + const expected = { + source: manifest.source, + repositoryIds: manifest.payload.repositories.map((repo) => repo.id), + projectGroupIds: manifest.payload.projectGroups.map((group) => group.id), + folderWorkspaceIds: manifest.payload.folderWorkspaces.map((workspace) => workspace.id) + } + const actual = { + source: receipt.source, + repositoryIds: receipt.repositoryIds, + projectGroupIds: receipt.projectGroupIds, + folderWorkspaceIds: receipt.folderWorkspaceIds + } + if (serializeOrcadMigrationValue(actual) !== serializeOrcadMigrationValue(expected)) { + throw new Error('orcad_migration_receipt_manifest_mismatch') + } +} + +export function assertCommittedOrcadMigrationCatalog( + manifest: OrcadMigrationManifest, + state: StoreRuntimeState['state'] +): void { + const repositories = manifest.payload.repositories.map(toOrcadDestinationRepository) + const projectGroups = manifest.payload.projectGroups.map(toOrcadDestinationProjectGroup) + const folderWorkspaces = manifest.payload.folderWorkspaces.map(toOrcadDestinationFolderWorkspace) + assertRowsExist(repositories, state.repos, 'repository') + assertRowsExist(projectGroups, state.projectGroups, 'project_group') + assertRowsExist(folderWorkspaces, state.folderWorkspaces, 'folder_workspace') + assertNoRepositoryPathConflicts(repositories, state.repos) + assertCatalogReferences({ + repositories, + projectGroups, + folderWorkspaces, + existingProjectGroups: state.projectGroups + }) + assertCommittedOrcadMigrationDormantState(manifest, state) +} + +export function toOrcadDestinationRepository(source: Repo): Repo { + const destination = structuredClone(source) + delete destination.connectionId + delete destination.executionHostId + return destination +} + +export function toOrcadDestinationProjectGroup(source: ProjectGroup): ProjectGroup { + const destination = structuredClone(source) + destination.connectionId = null + delete destination.executionHostId + return destination +} + +export function toOrcadDestinationFolderWorkspace(source: FolderWorkspace): FolderWorkspace { + const destination = structuredClone(source) + destination.connectionId = null + delete destination.executionHostId + destination.linkedTaskSourceContext ??= null + return destination +} + +function selectNewRows(incoming: T[], existing: T[], label: string): T[] { + const existingById = new Map(existing.map((row) => [row.id, row])) + return incoming.filter((row) => { + const current = existingById.get(row.id) + if (!current) { + return true + } + if (serializeOrcadMigrationValue(current) !== serializeOrcadMigrationValue(row)) { + throw new Error(`orcad_migration_${label}_id_conflict:${row.id}`) + } + return false + }) +} + +function assertRowsExist(incoming: T[], existing: T[], label: string) { + const existingById = new Map(existing.map((row) => [row.id, row])) + for (const row of incoming) { + const current = existingById.get(row.id) + if (!current || serializeOrcadMigrationValue(current) !== serializeOrcadMigrationValue(row)) { + throw new Error(`orcad_migration_receipt_catalog_mismatch:${label}:${row.id}`) + } + } +} + +function assertNoRepositoryPathConflicts(incoming: Repo[], existing: Repo[]): void { + const incomingIdSet = new Set(incoming.map((repo) => repo.id)) + const incomingByPath = new Map() + for (const repo of incoming) { + const key = normalizeRuntimePathForComparison(repo.path) + const priorId = incomingByPath.get(key) + if (priorId && priorId !== repo.id) { + throw new Error(`orcad_migration_repository_path_conflict:${repo.path}`) + } + incomingByPath.set(key, repo.id) + } + for (const repo of existing) { + if ( + !incomingIdSet.has(repo.id) && + incomingByPath.has(normalizeRuntimePathForComparison(repo.path)) + ) { + throw new Error(`orcad_migration_repository_path_conflict:${repo.path}`) + } + } +} + +function assertCatalogReferences(args: { + repositories: Repo[] + projectGroups: ProjectGroup[] + folderWorkspaces: FolderWorkspace[] + existingProjectGroups: ProjectGroup[] +}): void { + const groupIds = new Set([ + ...args.existingProjectGroups.map((group) => group.id), + ...args.projectGroups.map((group) => group.id) + ]) + for (const group of args.projectGroups) { + if (group.parentGroupId && !groupIds.has(group.parentGroupId)) { + throw new Error(`orcad_migration_project_group_parent_missing:${group.id}`) + } + } + for (const repo of args.repositories) { + if (repo.projectGroupId && !groupIds.has(repo.projectGroupId)) { + throw new Error(`orcad_migration_repository_project_group_missing:${repo.id}`) + } + } + for (const workspace of args.folderWorkspaces) { + if (!groupIds.has(workspace.projectGroupId)) { + throw new Error(`orcad_migration_folder_workspace_project_group_missing:${workspace.id}`) + } + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-automation-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-automation-state.ts new file mode 100644 index 000000000000..d1520e6dd3f2 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-destination-automation-state.ts @@ -0,0 +1,124 @@ +import { MAX_AUTOMATION_RUNS_PER_AUTOMATION } from '../../../shared/automation-run-retention' +import { + isFinalAutomationRunStatus, + type Automation, + type AutomationRun +} from '../../../shared/automations-types' +import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' +import type { PersistedState } from '../../../shared/persisted-state-types' + +export type PreparedOrcadMigrationAutomationState = { + incomingAutomations: Automation[] + incomingRuns: AutomationRun[] + newAutomations: Automation[] + newRuns: AutomationRun[] +} + +export function prepareOrcadMigrationAutomationState( + automations: readonly Automation[] | undefined, + runs: readonly AutomationRun[] | undefined, + state: PersistedState +): PreparedOrcadMigrationAutomationState { + const incomingAutomations = (automations ?? []).map((entry) => structuredClone(entry)) + const incomingRuns = (runs ?? []).map((entry) => structuredClone(entry)) + const newAutomations = selectNewRows(incomingAutomations, state.automations, 'automation') + const newRuns = selectNewRows(incomingRuns, state.automationRuns, 'automation_run') + assertRunOwnersExist(incomingAutomations, incomingRuns, state.automations) + assertRunRetentionCapacity(incomingAutomations, incomingRuns, state.automationRuns) + return { incomingAutomations, incomingRuns, newAutomations, newRuns } +} + +export function applyPreparedOrcadMigrationAutomationState( + prepared: PreparedOrcadMigrationAutomationState, + state: PersistedState +): void { + if (prepared.newAutomations.length > 0) { + state.automations = [...state.automations, ...prepared.newAutomations] + } + if (prepared.newRuns.length > 0) { + state.automationRuns = [...state.automationRuns, ...prepared.newRuns] + } +} + +export function assertCommittedOrcadMigrationAutomationState( + automations: readonly Automation[] | undefined, + runs: readonly AutomationRun[] | undefined, + state: PersistedState +): void { + assertRowsExist(automations ?? [], state.automations, 'automation') + assertRowsExist(runs ?? [], state.automationRuns, 'automation_run') +} + +function selectNewRows(incoming: T[], existing: T[], label: string): T[] { + const existingById = new Map(existing.map((entry) => [entry.id, entry])) + return incoming.filter((entry) => { + const current = existingById.get(entry.id) + if (!current) { + return true + } + assertSameValue(current, entry, `${label}:${entry.id}`) + return false + }) +} + +function assertRowsExist( + incoming: readonly T[], + existing: readonly T[], + label: string +): void { + const existingById = new Map(existing.map((entry) => [entry.id, entry])) + for (const entry of incoming) { + const current = existingById.get(entry.id) + if (!current) { + throw new Error(`orcad_migration_receipt_dormant_mismatch:${label}:${entry.id}`) + } + assertSameValue(current, entry, `receipt_dormant_mismatch:${label}:${entry.id}`) + } +} + +function assertRunOwnersExist( + incomingAutomations: readonly Automation[], + incomingRuns: readonly AutomationRun[], + existingAutomations: readonly Automation[] +): void { + const automationIds = new Set([ + ...existingAutomations.map((entry) => entry.id), + ...incomingAutomations.map((entry) => entry.id) + ]) + for (const run of incomingRuns) { + if (!automationIds.has(run.automationId)) { + throw new Error(`orcad_migration_dormant_automation_run_owner_missing:${run.id}`) + } + } +} + +function assertRunRetentionCapacity( + incomingAutomations: readonly Automation[], + incomingRuns: readonly AutomationRun[], + existingRuns: readonly AutomationRun[] +): void { + const incomingAutomationIds = new Set(incomingAutomations.map((entry) => entry.id)) + const idsByAutomation = new Map>() + for (const run of [ + ...existingRuns.filter((entry) => incomingAutomationIds.has(entry.automationId)), + ...incomingRuns + ]) { + if (!isFinalAutomationRunStatus(run.status)) { + throw new Error(`orcad_migration_dormant_automation_run_active:${run.automationId}`) + } + const ids = idsByAutomation.get(run.automationId) ?? new Set() + ids.add(run.id) + idsByAutomation.set(run.automationId, ids) + } + for (const [automationId, ids] of idsByAutomation) { + if (ids.size > MAX_AUTOMATION_RUNS_PER_AUTOMATION) { + throw new Error(`orcad_migration_dormant_automation_run_capacity:${automationId}`) + } + } +} + +function assertSameValue(left: unknown, right: unknown, label: string): void { + if (serializeOrcadMigrationValue(left) !== serializeOrcadMigrationValue(right)) { + throw new Error(`orcad_migration_dormant_id_conflict:${label}`) + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-client-state.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-client-state.test.ts new file mode 100644 index 000000000000..599642a635a7 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-destination-client-state.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { OrcadMigrationClientStatePayload } from '../../../shared/orcad-migration-client-state' +import { + applyPreparedOrcadMigrationClientState, + assertCommittedOrcadMigrationClientState, + prepareOrcadMigrationClientState +} from './orcad-destination-client-state' + +function state(): PersistedState { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a minimal persisted state; the code under test reads only the client-state fields set here. + return { + ui: { + lastActiveRepoId: null, + lastActiveWorktreeId: null, + filterRepoIds: [], + showDotfilesByWorktree: {}, + setupScriptPromptDismissedRepoIds: [], + manualRepoOrder: [], + workspaceHostScope: undefined, + visibleWorkspaceHostIds: null, + workspaceHostOrder: [], + automationHostFilter: { kind: 'all' }, + acknowledgedAgentsByPaneKey: {} + } + } as unknown as PersistedState +} + +const incoming: OrcadMigrationClientStatePayload = { + mobileClientTabSelectionsByDeviceId: { + phone: { + 'repo-1::/worktree': { + activeTabId: 'tab-1', + activeGroupId: null, + activeTabIdByGroupId: {} + } + } + }, + uiRouting: { + lastActiveRepoId: 'repo-1', + lastActiveWorktreeId: 'repo-1::/worktree', + filterRepoIds: ['repo-1'], + showDotfilesByWorktree: { 'repo-1::/worktree': false }, + manualRepoOrder: [{ hostId: 'local', repoId: 'repo-1' }] + } +} + +describe('destination client-state migration', () => { + it('applies selections and routing while preserving unrelated device state', () => { + const destination = state() + destination.mobileClientTabSelectionsByDeviceId = { + tablet: { + 'repo-other::/worktree': { + activeTabId: null, + activeGroupId: null, + activeTabIdByGroupId: {} + } + } + } + const prepared = prepareOrcadMigrationClientState(incoming, destination) + applyPreparedOrcadMigrationClientState(prepared, destination) + + expect(destination.mobileClientTabSelectionsByDeviceId).toMatchObject({ + phone: incoming.mobileClientTabSelectionsByDeviceId?.phone, + tablet: expect.any(Object) + }) + expect(destination.ui).toMatchObject({ + lastActiveRepoId: 'repo-1', + lastActiveWorktreeId: 'repo-1::/worktree', + filterRepoIds: ['repo-1'], + showDotfilesByWorktree: { 'repo-1::/worktree': false } + }) + expect(() => assertCommittedOrcadMigrationClientState(incoming, destination)).not.toThrow() + }) + + it('rejects a conflicting destination selection before publication', () => { + const destination = state() + destination.mobileClientTabSelectionsByDeviceId = { + phone: { + 'repo-1::/worktree': { + activeTabId: 'different-tab', + activeGroupId: null, + activeTabIdByGroupId: {} + } + } + } + expect(() => prepareOrcadMigrationClientState(incoming, destination)).toThrow( + 'orcad_migration_client_state_conflict:mobile' + ) + }) + + it('rejects non-default UI routing conflicts', () => { + const destination = state() + destination.ui.lastActiveRepoId = 'other-repo' + expect(() => prepareOrcadMigrationClientState(incoming, destination)).toThrow( + 'orcad_migration_client_state_conflict:ui:last-active-repo' + ) + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-client-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-client-state.ts new file mode 100644 index 000000000000..b14bf0c2648f --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-destination-client-state.ts @@ -0,0 +1,268 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import { hostStableKey, parseHostStableKey } from '../../../shared/automation-owner-key' +import type { + OrcadMigrationClientStatePayload, + OrcadMigrationUiRoutingState +} from '../../../shared/orcad-migration-client-state' +import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' + +export type PreparedOrcadMigrationClientState = { + incoming: OrcadMigrationClientStatePayload | undefined + mobileSelections: NonNullable< + OrcadMigrationClientStatePayload['mobileClientTabSelectionsByDeviceId'] + > + uiRouting: OrcadMigrationUiRoutingState | undefined +} + +export function prepareOrcadMigrationClientState( + incoming: OrcadMigrationClientStatePayload | undefined, + state: PersistedState +): PreparedOrcadMigrationClientState { + const mobileSelections = prepareMobileSelections( + incoming?.mobileClientTabSelectionsByDeviceId, + state + ) + const uiRouting = incoming?.uiRouting ? normalizeUiRouting(incoming.uiRouting) : undefined + if (uiRouting) { + assertUiRoutingCompatible(uiRouting, state) + } + return { + incoming, + mobileSelections, + uiRouting + } +} + +export function applyPreparedOrcadMigrationClientState( + prepared: PreparedOrcadMigrationClientState, + state: PersistedState +): void { + if (Object.keys(prepared.mobileSelections).length > 0) { + state.mobileClientTabSelectionsByDeviceId = { + ...state.mobileClientTabSelectionsByDeviceId, + ...prepared.mobileSelections + } + for (const [deviceId, selections] of Object.entries(prepared.mobileSelections)) { + state.mobileClientTabSelectionsByDeviceId[deviceId] = { + ...state.mobileClientTabSelectionsByDeviceId[deviceId], + ...selections + } + } + } + if (prepared.uiRouting) { + applyUiRouting(prepared.uiRouting, state) + } +} + +export function assertCommittedOrcadMigrationClientState( + incoming: OrcadMigrationClientStatePayload | undefined, + state: PersistedState +): void { + if (!incoming) { + return + } + prepareOrcadMigrationClientState(incoming, state) + const current = state.mobileClientTabSelectionsByDeviceId ?? {} + for (const [deviceId, selections] of Object.entries( + incoming.mobileClientTabSelectionsByDeviceId ?? {} + )) { + for (const [worktreeId, selection] of Object.entries(selections)) { + const actual = current[deviceId]?.[worktreeId] + if ( + !actual || + serializeOrcadMigrationValue(actual) !== serializeOrcadMigrationValue(selection) + ) { + throw new Error( + `orcad_migration_receipt_client_state_mismatch:mobile:${deviceId}:${worktreeId}` + ) + } + } + } + const uiRouting = incoming.uiRouting ? normalizeUiRouting(incoming.uiRouting) : undefined + if (uiRouting) { + assertUiRoutingApplied(uiRouting, state) + } +} + +function normalizeUiRouting(route: OrcadMigrationUiRoutingState): OrcadMigrationUiRoutingState { + const filter = route.automationHostFilter + if (filter?.kind !== 'host') { + return structuredClone(route) + } + const parsed = parseHostStableKey(filter.hostKey) + if (parsed?.authority.kind !== 'runtime' || parsed.selector.kind !== 'self') { + return structuredClone(route) + } + return { + ...structuredClone(route), + automationHostFilter: { + kind: 'host', + hostKey: hostStableKey({ authority: { kind: 'desktop' }, selector: { kind: 'self' } }) + } + } +} + +function prepareMobileSelections( + incoming: OrcadMigrationClientStatePayload['mobileClientTabSelectionsByDeviceId'], + state: PersistedState +): NonNullable { + const result: NonNullable< + OrcadMigrationClientStatePayload['mobileClientTabSelectionsByDeviceId'] + > = {} + const current = state.mobileClientTabSelectionsByDeviceId ?? {} + for (const [deviceId, selections] of Object.entries(incoming ?? {})) { + for (const [worktreeId, selection] of Object.entries(selections)) { + const existing = current[deviceId]?.[worktreeId] + if ( + existing && + serializeOrcadMigrationValue(existing) !== serializeOrcadMigrationValue(selection) + ) { + throw new Error(`orcad_migration_client_state_conflict:mobile:${deviceId}:${worktreeId}`) + } + const deviceSelections = (result[deviceId] ??= {}) + deviceSelections[worktreeId] = structuredClone(selection) + } + } + return result +} + +function assertUiRoutingCompatible( + route: OrcadMigrationUiRoutingState, + state: PersistedState +): void { + const ui = state.ui + assertOptionalValue(route.lastActiveRepoId, ui.lastActiveRepoId, null, 'last-active-repo') + assertOptionalValue( + route.lastActiveWorktreeId, + ui.lastActiveWorktreeId, + null, + 'last-active-worktree' + ) + assertOptionalArray(route.filterRepoIds, ui.filterRepoIds, 'filter-repos') + assertOptionalValue( + route.showDotfilesByWorktree, + ui.showDotfilesByWorktree ?? {}, + {}, + 'show-dotfiles' + ) + assertOptionalArray( + route.setupScriptPromptDismissedRepoIds, + ui.setupScriptPromptDismissedRepoIds ?? [], + 'setup-dismissed' + ) + assertOptionalArray(route.manualRepoOrder, ui.manualRepoOrder ?? [], 'manual-order') + assertOptionalValue(route.workspaceHostScope, ui.workspaceHostScope, undefined, 'host-scope') + assertOptionalValue( + route.visibleWorkspaceHostIds, + ui.visibleWorkspaceHostIds, + null, + 'visible-hosts' + ) + assertOptionalArray(route.workspaceHostOrder, ui.workspaceHostOrder ?? [], 'host-order') + assertOptionalValue( + route.automationHostFilter, + ui.automationHostFilter, + undefined, + 'automation-filter' + ) + assertOptionalValue( + route.acknowledgedAgentsByPaneKey, + ui.acknowledgedAgentsByPaneKey ?? {}, + {}, + 'acknowledgements' + ) +} + +function assertUiRoutingApplied(route: OrcadMigrationUiRoutingState, state: PersistedState): void { + const ui = state.ui + const fields: [keyof OrcadMigrationUiRoutingState, unknown][] = [ + ['lastActiveRepoId', ui.lastActiveRepoId], + ['lastActiveWorktreeId', ui.lastActiveWorktreeId], + ['filterRepoIds', ui.filterRepoIds], + ['showDotfilesByWorktree', ui.showDotfilesByWorktree ?? {}], + ['setupScriptPromptDismissedRepoIds', ui.setupScriptPromptDismissedRepoIds ?? []], + ['manualRepoOrder', ui.manualRepoOrder ?? []], + ['workspaceHostScope', ui.workspaceHostScope], + ['visibleWorkspaceHostIds', ui.visibleWorkspaceHostIds], + ['workspaceHostOrder', ui.workspaceHostOrder ?? []], + ['automationHostFilter', ui.automationHostFilter], + ['acknowledgedAgentsByPaneKey', ui.acknowledgedAgentsByPaneKey ?? {}] + ] + for (const [key, actual] of fields) { + const expected = route[key] + if ( + expected !== undefined && + serializeOrcadMigrationValue(actual) !== serializeOrcadMigrationValue(expected) + ) { + throw new Error(`orcad_migration_receipt_client_state_mismatch:ui:${String(key)}`) + } + } +} + +function applyUiRouting(route: OrcadMigrationUiRoutingState, state: PersistedState): void { + const ui = state.ui + if (route.lastActiveRepoId !== undefined) { + ui.lastActiveRepoId = route.lastActiveRepoId + } + if (route.lastActiveWorktreeId !== undefined) { + ui.lastActiveWorktreeId = route.lastActiveWorktreeId + } + if (route.filterRepoIds !== undefined) { + ui.filterRepoIds = structuredClone(route.filterRepoIds) + } + if (route.showDotfilesByWorktree !== undefined) { + ui.showDotfilesByWorktree = { + ...ui.showDotfilesByWorktree, + ...structuredClone(route.showDotfilesByWorktree) + } + } + if (route.setupScriptPromptDismissedRepoIds !== undefined) { + ui.setupScriptPromptDismissedRepoIds = structuredClone(route.setupScriptPromptDismissedRepoIds) + } + if (route.manualRepoOrder !== undefined) { + ui.manualRepoOrder = structuredClone(route.manualRepoOrder) + } + if (route.workspaceHostScope !== undefined) { + ui.workspaceHostScope = route.workspaceHostScope + } + if (route.visibleWorkspaceHostIds !== undefined) { + ui.visibleWorkspaceHostIds = structuredClone(route.visibleWorkspaceHostIds) + } + if (route.workspaceHostOrder !== undefined) { + ui.workspaceHostOrder = structuredClone(route.workspaceHostOrder) + } + if (route.automationHostFilter !== undefined) { + ui.automationHostFilter = structuredClone(route.automationHostFilter) + } + if (route.acknowledgedAgentsByPaneKey !== undefined) { + ui.acknowledgedAgentsByPaneKey = { + ...ui.acknowledgedAgentsByPaneKey, + ...structuredClone(route.acknowledgedAgentsByPaneKey) + } + } +} + +function assertOptionalValue( + incoming: unknown, + current: unknown, + empty: unknown, + field: string +): void { + if ( + incoming === undefined || + serializeOrcadMigrationValue(current) === serializeOrcadMigrationValue(incoming) + ) { + return + } + if (serializeOrcadMigrationValue(current) !== serializeOrcadMigrationValue(empty)) { + throw new Error(`orcad_migration_client_state_conflict:ui:${field}`) + } +} + +function assertOptionalArray( + incoming: unknown[] | undefined, + current: unknown[], + field: string +): void { + assertOptionalValue(incoming, current, [], field) +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.test.ts new file mode 100644 index 000000000000..882eca77c8ff --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import { + CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, + type PersistedClientHostedBrowserPage +} from '../../../shared/client-hosted-browser-page-record' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import { prepareOrcadMigrationWorkspaceSession } from './orcad-destination-workspace-session' + +const OWNER = 'repo-1::/srv/worktree' +const LEAF = '11111111-1111-4111-8111-111111111111' +const OTHER_LEAF = '22222222-2222-4222-8222-222222222222' + +function layout(leafId = LEAF): TerminalLayoutSnapshot { + return { root: { type: 'leaf', leafId }, activeLeafId: leafId, expandedLeafId: null } +} + +function page(browserPageId: string): PersistedClientHostedBrowserPage { + return { + v: CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, + browserPageId, + workspaceId: 'browser-workspace-1', + browserProfileId: 'profile-1', + url: 'https://example.test/', + title: 'Example', + pairedDeviceId: 'device-1', + savedAt: 1 + } +} + +function state(): PersistedState { + return getDefaultPersistedState('/tmp/orca-test') +} + +function incoming(pages: ReturnType[]): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + clientHostedBrowserPagesByWorktree: { [OWNER]: pages } + } +} + +describe('destination workspace-session merge validation', () => { + it('accepts a unique client-hosted page identity', () => { + expect(() => + prepareOrcadMigrationWorkspaceSession(incoming([page('page-1')]), state()) + ).not.toThrow() + }) + + it('rejects duplicate client-hosted page identities before merge', () => { + expect(() => + prepareOrcadMigrationWorkspaceSession(incoming([page('page-1'), page('page-1')]), state()) + ).toThrow('orcad_migration_dormant_id_conflict:workspace_session:client-browser-page:page-1') + }) + + it.each(['existing', 'incoming'] as const)( + 'rejects a pane identity already claimed by another %s tab without mutation', + (location) => { + const current = state() + const next = incoming([]) + next.terminalLayoutsByTabId['incoming-tab'] = layout() + const competing = location === 'existing' ? current.workspaceSession : next + competing.terminalLayoutsByTabId['other-tab'] = layout() + const before = structuredClone({ current, next }) + expect(() => prepareOrcadMigrationWorkspaceSession(next, current)).toThrow( + `orcad_migration_dormant_id_conflict:workspace_session:terminal-leaf:${LEAF}` + ) + expect({ current, next }).toEqual(before) + } + ) + + it('rejects duplicate leaves within an incoming split', () => { + const next = incoming([]) + next.terminalLayoutsByTabId.tab = { + ...layout(), + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF }, + second: { type: 'leaf', leafId: LEAF } + } + } + expect(() => prepareOrcadMigrationWorkspaceSession(next, state())).toThrow( + `orcad_migration_dormant_id_conflict:workspace_session:terminal-leaf:${LEAF}` + ) + }) + + it.each([ + 'ptyIdsByLeafId', + 'buffersByLeafId', + 'scrollbackRefsByLeafId', + 'titlesByLeafId' + ] as const)('preserves a competing pane claim in %s even outside its topology', (field) => { + const current = state() + current.workspaceSession.terminalLayoutsByTabId.other = { + ...layout(OTHER_LEAF), + [field]: { [LEAF]: 'preserved-value' } + } + const next = incoming([]) + next.terminalLayoutsByTabId.tab = layout() + const before = structuredClone(current) + expect(() => prepareOrcadMigrationWorkspaceSession(next, current)).toThrow( + `orcad_migration_dormant_id_conflict:workspace_session:terminal-leaf:${LEAF}` + ) + expect(current).toEqual(before) + }) + + it('preserves a unique split and accepts an exact retry with per-pane records', () => { + const next = incoming([]) + next.terminalLayoutsByTabId.tab = { + ...layout(), + root: { + type: 'split', + direction: 'vertical', + ratio: 0.3, + first: { type: 'leaf', leafId: LEAF }, + second: { type: 'leaf', leafId: OTHER_LEAF } + }, + buffersByLeafId: { [LEAF]: 'first output', [OTHER_LEAF]: 'second output' }, + titlesByLeafId: { [LEAF]: 'first', [OTHER_LEAF]: 'second' } + } + const current = state() + const prepared = prepareOrcadMigrationWorkspaceSession(next, current) + expect(prepared.merged?.terminalLayoutsByTabId).toEqual(next.terminalLayoutsByTabId) + current.workspaceSession = prepared.merged! + expect(prepareOrcadMigrationWorkspaceSession(next, current).merged).toEqual( + current.workspaceSession + ) + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts new file mode 100644 index 000000000000..bd6fb385ea1a --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts @@ -0,0 +1,185 @@ +import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' +import { isRecord } from '../../../shared/orcad-migration-manifest-fields' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { mergeWorkspaceSessions } from '../../orca-profiles/profile-project-session-state' +import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' + +const MERGED_SESSION_RECORD_FIELDS = [ + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'openFilesByWorktree', + 'markdownFrontmatterVisible', + 'browserTabsByWorktree', + 'browserPagesByWorkspace', + 'clientHostedBrowserPagesByWorktree', + 'activeBrowserTabIdByWorktree', + 'activeFileIdByWorktree', + 'activeTabTypeByWorktree', + 'activeTabIdByWorktree', + 'unifiedTabs', + 'tabGroups', + 'tabGroupLayouts', + 'activeGroupIdByWorktree', + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId', + 'terminalPtyIncarnationsByPaneKey', + 'terminalSurfaceTombstonesByPaneKey', + 'sleepingAgentSessionsByPaneKey' +] as const satisfies readonly (keyof WorkspaceSessionState)[] + +export type PreparedOrcadMigrationWorkspaceSession = { + incoming: WorkspaceSessionState | undefined + merged: WorkspaceSessionState | undefined +} + +export function prepareOrcadMigrationWorkspaceSession( + incoming: WorkspaceSessionState | undefined, + state: PersistedState +): PreparedOrcadMigrationWorkspaceSession { + if (!incoming) { + return { incoming: undefined, merged: undefined } + } + assertNoSessionRecordConflicts(state.workspaceSession, incoming) + assertNoSessionFocusConflicts(state.workspaceSession, incoming) + assertNoSessionEntityConflicts(state.workspaceSession, incoming) + return { + incoming, + merged: mergeWorkspaceSessions(state.workspaceSession, incoming) + } +} + +function assertNoSessionFocusConflicts( + existing: WorkspaceSessionState, + incoming: WorkspaceSessionState +): void { + const fields = [ + 'activeRepoId', + 'activeWorktreeId', + 'activeWorkspaceKey', + 'activeWorkspaceExecutionHostId', + 'activeTabId' + ] as const + for (const field of fields) { + const current = existing[field] + const next = incoming[field] + if ( + next !== undefined && + next !== null && + current !== undefined && + current !== null && + serializeOrcadMigrationValue(current) !== serializeOrcadMigrationValue(next) + ) { + throw new Error(`orcad_migration_dormant_focus_conflict:${field}`) + } + } +} + +export function applyPreparedOrcadMigrationWorkspaceSession( + prepared: PreparedOrcadMigrationWorkspaceSession, + state: PersistedState +): void { + if (prepared.merged) { + state.workspaceSession = prepared.merged + } +} + +export function assertCommittedOrcadMigrationWorkspaceSession( + incoming: WorkspaceSessionState | undefined, + state: PersistedState +): void { + if ( + incoming && + serializeOrcadMigrationValue(mergeWorkspaceSessions(state.workspaceSession, incoming)) !== + serializeOrcadMigrationValue(state.workspaceSession) + ) { + throw new Error('orcad_migration_receipt_dormant_mismatch:workspace_session') + } +} + +function assertNoSessionRecordConflicts( + existing: WorkspaceSessionState, + incoming: WorkspaceSessionState +): void { + for (const field of MERGED_SESSION_RECORD_FIELDS) { + const existingValue: unknown = existing[field] + const incomingValue: unknown = incoming[field] + const existingRecord = isRecord(existingValue) ? existingValue : {} + const incomingRecord = isRecord(incomingValue) ? incomingValue : {} + for (const [key, value] of Object.entries(incomingRecord)) { + const current = existingRecord[key] + if ( + current !== undefined && + serializeOrcadMigrationValue(current) !== serializeOrcadMigrationValue(value) + ) { + throw new Error( + `orcad_migration_dormant_id_conflict:workspace_session:${String(field)}:${key}` + ) + } + } + } +} + +function assertNoSessionEntityConflicts( + existing: WorkspaceSessionState, + incoming: WorkspaceSessionState +): void { + const existingOwners = collectOrcadMigrationSessionEntityOwners(existing) + for (const [key, owner] of collectOrcadMigrationSessionEntityOwners(incoming)) { + if (owner === '\0') { + throw new Error(`orcad_migration_dormant_id_conflict:workspace_session:${key}`) + } + const currentOwner = existingOwners.get(key) + if (currentOwner !== undefined && currentOwner !== owner) { + throw new Error(`orcad_migration_dormant_id_conflict:workspace_session:${key}`) + } + } +} + +export function collectOrcadMigrationSessionEntityOwners( + session: WorkspaceSessionState +): Map { + const owners = new Map() + const add = (key: string, owner: string): void => { + const current = owners.get(key) + // A duplicate entity id is invalid even when both rows claim the same owner. Marking every + // repeat lets the caller reject duplicates within the incoming payload as well as conflicts + // with already-committed state. + owners.set(key, current === undefined ? owner : '\0') + } + for (const [owner, tabs] of Object.entries(session.tabsByWorktree)) { + tabs.forEach((tab) => add(`terminal:${tab.id}`, owner)) + } + for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId)) { + const topologyLeaves = collectLayoutLeafIdsInOrder(layout.root) + topologyLeaves.forEach((leafId) => add(`terminal-leaf:${leafId}`, tabId)) + const topologyIds = new Set(topologyLeaves) + const recordIds = new Set( + [ + layout.ptyIdsByLeafId, + layout.buffersByLeafId, + layout.scrollbackRefsByLeafId, + layout.titlesByLeafId + ].flatMap((record) => Object.keys(record ?? {})) + ) + // Stale per-pane records still own data; importing their identity must not overwrite it. + for (const leafId of recordIds) { + if (!topologyIds.has(leafId)) { + add(`terminal-leaf:${leafId}`, tabId) + } + } + } + for (const [owner, workspaces] of Object.entries(session.browserTabsByWorktree ?? {})) { + workspaces.forEach((workspace) => add(`browser:${workspace.id}`, owner)) + } + for (const [owner, pages] of Object.entries(session.clientHostedBrowserPagesByWorktree ?? {})) { + pages.forEach((page) => add(`client-browser-page:${page.browserPageId}`, owner)) + } + for (const [owner, tabs] of Object.entries(session.unifiedTabs ?? {})) { + tabs.forEach((tab) => add(`tab:${tab.id}`, owner)) + } + for (const [owner, groups] of Object.entries(session.tabGroups ?? {})) { + groups.forEach((group) => add(`group:${group.id}`, owner)) + } + return owners +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-worktree-metadata.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-worktree-metadata.ts new file mode 100644 index 000000000000..7315229a8732 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-destination-worktree-metadata.ts @@ -0,0 +1,41 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { OrcadMigrationDormantStatePayload } from '../../../shared/orcad-migration-manifest' +import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' +import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity' +import { composeWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' +import { omitDefaultWorktreeMetaFields } from '../../../shared/worktree/meta-persisted-defaults' + +export function assertOrcadDestinationCanonicalMetadata( + state: PersistedState, + entries: OrcadMigrationDormantStatePayload['worktreeMeta'] +): void { + for (const { worktreeId, meta } of entries) { + const alias = composeWorktreeHostIdentity('local', worktreeId) + const identities = state.worktreeIdentityAliases?.[alias] ?? [] + const expectedKey = meta.instanceId + ? canonicalWorktreeIdentity({ + worktreeId, + executionHostId: 'local', + instanceId: meta.instanceId + }) + : undefined + const candidates = new Set([ + ...identities, + ...(expectedKey && state.worktreeMetaByIdentity?.[expectedKey] ? [expectedKey] : []) + ]) + if ( + candidates.size > 1 || + [...candidates].some((key) => { + const current = state.worktreeMetaByIdentity?.[key] + return ( + !current || + key !== expectedKey || + serializeOrcadMigrationValue(omitDefaultWorktreeMetaFields(current)) !== + serializeOrcadMigrationValue(omitDefaultWorktreeMetaFields(meta)) + ) + }) + ) { + throw new Error(`orcad_migration_dormant_id_conflict:worktree_meta:${worktreeId}`) + } + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-dormant-metadata-defaults.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-dormant-metadata-defaults.test.ts new file mode 100644 index 000000000000..93afe5661719 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-dormant-metadata-defaults.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import { fillDefaultWorktreeMetaFields } from '../../../shared/worktree/meta-persisted-defaults' +import { + assertCommittedOrcadMigrationDormantState, + prepareOrcadMigrationDormantState +} from './orcad-dormant-state-records' + +const KEY = 'repo::/srv/worktree' + +function fixture() { + const state = getDefaultPersistedState('/tmp/orcad-metadata-defaults') + const meta = { + displayName: 'Workspace', + comment: 'preserve', + isUnread: true, + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1 + } + const manifest: OrcadMigrationManifest = { + version: 1, + migrationId: 'migration', + createdAt: '2026-09-06T00:00:00.000Z', + source: { sshTargetId: 'ssh', sshTargetGeneration: 1, targetLabel: 'host' }, + manifestSha256: 'unused-by-dormant-validator', + payload: { + repositories: [], + projectGroups: [], + folderWorkspaces: [], + dormantState: { + version: 1, + worktreeMeta: [{ sourceKey: KEY, worktreeId: KEY, meta }], + worktreeLineage: [], + workspaceLineage: [], + sparsePresets: [], + retiredWorktreeNames: [], + retiredWorktreeNamespaces: [] + } + } + } + state.worktreeMeta[KEY] = { ...meta } + fillDefaultWorktreeMetaFields(state.worktreeMeta[KEY]) + return { state, manifest } +} + +describe('migration metadata default equivalence', () => { + it('accepts load-materialized defaults without changing the manifest or state', () => { + const { state, manifest } = fixture() + const before = structuredClone({ state, manifest }) + expect(prepareOrcadMigrationDormantState(manifest, state).newWorktreeMeta).toEqual([]) + expect(() => assertCommittedOrcadMigrationDormantState(manifest, state)).not.toThrow() + expect({ state, manifest }).toEqual(before) + }) + + it.each(['isPinned', 'isArchived'] as const)('still refuses a changed %s value', (field) => { + const { state, manifest } = fixture() + state.worktreeMeta[KEY][field] = true + expect(() => prepareOrcadMigrationDormantState(manifest, state)).toThrow( + `orcad_migration_dormant_id_conflict:worktree_meta:${KEY}` + ) + expect(() => assertCommittedOrcadMigrationDormantState(manifest, state)).toThrow( + `orcad_migration_dormant_id_conflict:receipt_dormant_mismatch:worktree_meta:${KEY}` + ) + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-dormant-state-records.ts b/src/main/persistence/migrating-orcad-catalog/orcad-dormant-state-records.ts new file mode 100644 index 000000000000..468b704aa678 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-dormant-state-records.ts @@ -0,0 +1,311 @@ +import { + serializeOrcadMigrationValue, + type OrcadMigrationDormantStatePayload, + type OrcadMigrationManifest +} from '../../../shared/orcad-migration-manifest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { SparsePreset } from '../../../shared/worktree/create-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { omitDefaultWorktreeMetaFields } from '../../../shared/worktree/meta-persisted-defaults' +import { assertOrcadDestinationCanonicalMetadata } from './orcad-destination-worktree-metadata' +import { + mergeRetiredNameRegistries, + type RetiredNameRegistry +} from '../../../shared/worktree/retired-name-registry' +import { recordRetirementNamespaceRegistry } from '../../worktree-retirement-namespace' +import { + applyPreparedOrcadMigrationWorkspaceSession, + assertCommittedOrcadMigrationWorkspaceSession, + prepareOrcadMigrationWorkspaceSession, + type PreparedOrcadMigrationWorkspaceSession +} from './orcad-destination-workspace-session' +import { + applyPreparedOrcadMigrationAutomationState, + assertCommittedOrcadMigrationAutomationState, + prepareOrcadMigrationAutomationState, + type PreparedOrcadMigrationAutomationState +} from './orcad-destination-automation-state' +import { + applyPreparedOrcadMigrationClientState, + assertCommittedOrcadMigrationClientState, + prepareOrcadMigrationClientState, + type PreparedOrcadMigrationClientState +} from './orcad-destination-client-state' + +type KeyedRow = { key: string; value: T } +type RegistryUpdate = { key: string; value: RetiredNameRegistry } + +export type PreparedOrcadMigrationDormantState = { + payload: OrcadMigrationDormantStatePayload | undefined + newWorktreeMeta: KeyedRow[] + newWorktreeLineage: KeyedRow[] + newWorkspaceLineage: KeyedRow[] + newSparsePresets: SparsePreset[] + retiredNameUpdates: RegistryUpdate[] + retirementNamespaceUpdates: RegistryUpdate[] + workspaceSession: PreparedOrcadMigrationWorkspaceSession + automationState: PreparedOrcadMigrationAutomationState + clientState: PreparedOrcadMigrationClientState +} + +export function prepareOrcadMigrationDormantState( + manifest: OrcadMigrationManifest, + state: PersistedState +): PreparedOrcadMigrationDormantState { + const payload = manifest.payload.dormantState + if (!payload) { + return emptyPreparedDormantState() + } + assertOrcadDestinationCanonicalMetadata(state, payload.worktreeMeta) + const worktreeMeta = payload.worktreeMeta.map((entry) => ({ + key: entry.worktreeId, + value: structuredClone(entry.meta) + })) + const worktreeLineage = payload.worktreeLineage.map((entry) => ({ + key: entry.worktreeId, + value: structuredClone(entry.lineage) + })) + const workspaceLineage = payload.workspaceLineage.map((entry) => ({ + key: entry.childWorkspaceKey, + value: structuredClone(entry.lineage) + })) + const existingPresets = new Map( + Object.values(state.sparsePresetsByRepo) + .flat() + .map((preset) => [sparsePresetKey(preset), preset]) + ) + const newSparsePresets = payload.sparsePresets.filter((preset) => { + const existing = existingPresets.get(sparsePresetKey(preset)) + if (!existing) { + return true + } + assertSameValue(existing, preset, `sparse_preset:${preset.repoId}:${preset.id}`) + return false + }) + return { + payload, + newWorktreeMeta: selectNewKeyedRows( + worktreeMeta, + state.worktreeMeta, + 'worktree_meta', + omitDefaultWorktreeMetaFields + ), + newWorktreeLineage: selectNewKeyedRows( + worktreeLineage, + state.worktreeLineageById, + 'worktree_lineage' + ), + newWorkspaceLineage: selectNewKeyedRows( + workspaceLineage, + state.workspaceLineageByChildKey, + 'workspace_lineage' + ), + newSparsePresets, + retiredNameUpdates: payload.retiredWorktreeNames.map((entry) => ({ + key: entry.repoId, + value: mergeRetiredNameRegistries( + state.retiredWorktreeNamesByRepo?.[entry.repoId] ?? { exhaustedTiers: 0, names: [] }, + entry.registry + ) + })), + retirementNamespaceUpdates: payload.retiredWorktreeNamespaces.map((entry) => ({ + key: entry.namespaceKey, + value: mergeRetiredNameRegistries( + state.retiredWorktreeNamesByNamespace?.[entry.namespaceKey] ?? { + exhaustedTiers: 0, + names: [] + }, + entry.registry + ) + })), + workspaceSession: prepareOrcadMigrationWorkspaceSession(payload.workspaceSession, state), + automationState: prepareOrcadMigrationAutomationState( + payload.automations, + payload.automationRuns, + state + ), + clientState: prepareOrcadMigrationClientState(payload.clientState, state) + } +} + +export function applyPreparedOrcadMigrationDormantState( + prepared: PreparedOrcadMigrationDormantState, + state: PersistedState +): void { + for (const entry of prepared.newWorktreeMeta) { + state.worktreeMeta[entry.key] = entry.value + } + for (const entry of prepared.newWorktreeLineage) { + state.worktreeLineageById[entry.key] = entry.value + } + for (const entry of prepared.newWorkspaceLineage) { + state.workspaceLineageByChildKey[entry.key] = entry.value + } + for (const preset of prepared.newSparsePresets) { + state.sparsePresetsByRepo[preset.repoId] = [ + ...(state.sparsePresetsByRepo[preset.repoId] ?? []), + preset + ] + } + if (prepared.retiredNameUpdates.length > 0) { + state.retiredWorktreeNamesByRepo ??= {} + for (const entry of prepared.retiredNameUpdates) { + state.retiredWorktreeNamesByRepo[entry.key] = entry.value + } + } + if (prepared.retirementNamespaceUpdates.length > 0) { + state.retiredWorktreeNamesByNamespace ??= {} + for (const entry of prepared.retirementNamespaceUpdates) { + recordRetirementNamespaceRegistry( + state.retiredWorktreeNamesByNamespace, + entry.key, + entry.value + ) + } + } + applyPreparedOrcadMigrationWorkspaceSession(prepared.workspaceSession, state) + applyPreparedOrcadMigrationAutomationState(prepared.automationState, state) + applyPreparedOrcadMigrationClientState(prepared.clientState, state) +} + +export function assertCommittedOrcadMigrationDormantState( + manifest: OrcadMigrationManifest, + state: PersistedState +): void { + const payload = manifest.payload.dormantState + if (!payload) { + return + } + assertOrcadDestinationCanonicalMetadata(state, payload.worktreeMeta) + assertKeyedRows( + payload.worktreeMeta.map((entry) => ({ key: entry.worktreeId, value: entry.meta })), + state.worktreeMeta, + 'worktree_meta', + omitDefaultWorktreeMetaFields + ) + assertKeyedRows( + payload.worktreeLineage.map((entry) => ({ key: entry.worktreeId, value: entry.lineage })), + state.worktreeLineageById, + 'worktree_lineage' + ) + assertKeyedRows( + payload.workspaceLineage.map((entry) => ({ + key: entry.childWorkspaceKey, + value: entry.lineage + })), + state.workspaceLineageByChildKey, + 'workspace_lineage' + ) + const presetByKey = new Map( + Object.values(state.sparsePresetsByRepo) + .flat() + .map((preset) => [sparsePresetKey(preset), preset]) + ) + for (const preset of payload.sparsePresets) { + const current = presetByKey.get(sparsePresetKey(preset)) + if (!current) { + throw new Error(`orcad_migration_receipt_dormant_mismatch:sparse_preset:${preset.id}`) + } + assertSameValue(current, preset, `receipt_dormant_mismatch:sparse_preset:${preset.id}`) + } + for (const entry of payload.retiredWorktreeNames) { + assertRegistryContains( + state.retiredWorktreeNamesByRepo?.[entry.repoId], + entry.registry, + `retired_names:${entry.repoId}` + ) + } + for (const entry of payload.retiredWorktreeNamespaces) { + assertRegistryContains( + state.retiredWorktreeNamesByNamespace?.[entry.namespaceKey], + entry.registry, + `retirement_namespace:${entry.namespaceKey}` + ) + } + assertCommittedOrcadMigrationWorkspaceSession(payload.workspaceSession, state) + assertCommittedOrcadMigrationAutomationState(payload.automations, payload.automationRuns, state) + assertCommittedOrcadMigrationClientState(payload.clientState, state) +} + +function selectNewKeyedRows( + incoming: KeyedRow[], + existing: Record, + label: string, + canonicalize: (value: T) => T = (value) => value +): KeyedRow[] { + return incoming.filter((entry) => { + const current = existing[entry.key] + if (current === undefined) { + return true + } + assertSameValue(canonicalize(current), canonicalize(entry.value), `${label}:${entry.key}`) + return false + }) +} + +function assertKeyedRows( + incoming: KeyedRow[], + existing: Record, + label: string, + canonicalize: (value: T) => T = (value) => value +): void { + for (const entry of incoming) { + const current = existing[entry.key] + if (current === undefined) { + throw new Error(`orcad_migration_receipt_dormant_mismatch:${label}:${entry.key}`) + } + assertSameValue( + canonicalize(current), + canonicalize(entry.value), + `receipt_dormant_mismatch:${label}:${entry.key}` + ) + } +} + +function assertRegistryContains( + current: RetiredNameRegistry | undefined, + incoming: RetiredNameRegistry, + label: string +): void { + if ( + !current || + serializeOrcadMigrationValue(mergeRetiredNameRegistries(current, incoming)) !== + serializeOrcadMigrationValue(current) + ) { + throw new Error(`orcad_migration_receipt_dormant_mismatch:${label}`) + } +} + +function assertSameValue(left: unknown, right: unknown, label: string): void { + if (serializeOrcadMigrationValue(left) !== serializeOrcadMigrationValue(right)) { + throw new Error(`orcad_migration_dormant_id_conflict:${label}`) + } +} + +const sparsePresetKey = (preset: Pick): string => + `${preset.repoId}\0${preset.id}` + +function emptyPreparedDormantState(): PreparedOrcadMigrationDormantState { + return { + payload: undefined, + newWorktreeMeta: [], + newWorktreeLineage: [], + newWorkspaceLineage: [], + newSparsePresets: [], + retiredNameUpdates: [], + retirementNamespaceUpdates: [], + workspaceSession: { incoming: undefined, merged: undefined }, + automationState: { + incomingAutomations: [], + incomingRuns: [], + newAutomations: [], + newRuns: [] + }, + clientState: { + incoming: undefined, + mobileSelections: {}, + uiRouting: undefined + } + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-staged-catalog-claims.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-staged-catalog-claims.test.ts new file mode 100644 index 000000000000..80f270835a61 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-staged-catalog-claims.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import { assertOrcadMigrationStagedCatalogClaims } from './orcad-staged-catalog-claims' + +const LEAF = '11111111-1111-4111-8111-111111111111' + +function manifest(id: string): OrcadMigrationManifest { + return { + version: 1, + migrationId: id, + manifestSha256: id.repeat(64), + createdAt: '2026-09-06T00:00:00.000Z', + source: { sshTargetId: id, sshTargetGeneration: 1, targetLabel: id }, + payload: { + repositories: [{ id, path: `/srv/${id}`, displayName: id, badgeColor: '', addedAt: 1 }], + projectGroups: [], + folderWorkspaces: [], + dormantState: { + version: 1, + worktreeMeta: [], + worktreeLineage: [], + workspaceLineage: [], + sparsePresets: [], + retiredWorktreeNames: [], + retiredWorktreeNamespaces: [], + workspaceSession: getDefaultWorkspaceSession() + } + } + } +} + +function assertClaims(next: OrcadMigrationManifest, prior: OrcadMigrationManifest) { + return assertOrcadMigrationStagedCatalogClaims(next, [ + { + version: 1, + manifest: prior, + stagedAt: prior.createdAt + } + ]) +} + +describe('staged migration catalog claims', () => { + it('allows disjoint stages and exact same-transaction retries without mutation', () => { + const first = manifest('a') + const second = manifest('b') + const before = structuredClone({ first, second }) + expect(() => assertClaims(second, first)).not.toThrow() + expect(() => assertClaims(structuredClone(first), first)).not.toThrow() + expect({ first, second }).toEqual(before) + }) + + it('rejects a reused transaction ID with a different digest', () => { + const first = manifest('a') + expect(() => assertClaims({ ...first, manifestSha256: 'b'.repeat(64) }, first)).toThrow( + 'orcad_migration_id_reused_with_different_manifest' + ) + }) + + it('reserves the repository path even when another migration changes its ID', () => { + const first = manifest('a') + const second = manifest('b') + second.payload.repositories[0].path = first.payload.repositories[0].path + expect(() => assertClaims(second, first)).toThrow( + 'orcad_migration_staged_claim_conflict:repository-path:/srv/a' + ) + }) + + it('reserves folder identity without treating a shared folder path as identity', () => { + const first = manifest('a') + const second = manifest('b') + const folder = { + id: 'folder', + projectGroupId: 'group', + name: 'Folder', + folderPath: '/srv/folder', + connectionId: null, + linkedTask: null, + linkedTaskSourceContext: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + first.payload.folderWorkspaces = [folder] + second.payload.folderWorkspaces = [{ ...folder, id: 'distinct-folder' }] + expect(() => assertClaims(second, first)).not.toThrow() + second.payload.folderWorkspaces = [{ ...folder }] + expect(() => assertClaims(second, first)).toThrow( + 'orcad_migration_staged_claim_conflict:folder-workspace:folder' + ) + }) + + it.each(['topology', 'scrollback'] as const)( + 'reserves a stable pane claimed by another tab through %s', + (claim) => { + const first = manifest('a') + const second = manifest('b') + const firstSession = first.payload.dormantState!.workspaceSession! + firstSession.terminalLayoutsByTabId.first = { + root: claim === 'topology' ? { type: 'leaf', leafId: LEAF } : null, + activeLeafId: null, + expandedLeafId: null, + ...(claim === 'scrollback' ? { scrollbackRefsByLeafId: { [LEAF]: 'snapshot' } } : {}) + } + second.payload.dormantState!.workspaceSession!.terminalLayoutsByTabId.second = { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: null, + expandedLeafId: null + } + const before = structuredClone({ first, second }) + expect(() => assertClaims(second, first)).toThrow( + `orcad_migration_staged_claim_conflict:session:terminal-leaf:${LEAF}` + ) + expect({ first, second }).toEqual(before) + } + ) + + it('reserves an empty tab layout before it contains any pane', () => { + const first = manifest('a') + const second = manifest('b') + for (const entry of [first, second]) { + entry.payload.dormantState!.workspaceSession!.terminalLayoutsByTabId.tab = { + root: null, + activeLeafId: null, + expandedLeafId: null + } + } + expect(() => assertClaims(second, first)).toThrow( + 'orcad_migration_staged_claim_conflict:session:terminal-layout:tab' + ) + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-staged-catalog-claims.ts b/src/main/persistence/migrating-orcad-catalog/orcad-staged-catalog-claims.ts new file mode 100644 index 000000000000..cca1f840221c --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-staged-catalog-claims.ts @@ -0,0 +1,61 @@ +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' +import type { + OrcadMigrationManifest, + OrcadMigrationStagedCatalog +} from '../../../shared/orcad-migration-manifest' +import { collectOrcadMigrationSessionEntityOwners } from './orcad-destination-workspace-session' + +export function assertOrcadMigrationStagedCatalogClaims( + manifest: OrcadMigrationManifest, + staged: readonly OrcadMigrationStagedCatalog[] +): void { + const requested = catalogClaims(manifest) + for (const entry of staged) { + if (entry.manifest.migrationId === manifest.migrationId) { + if (entry.manifest.manifestSha256 !== manifest.manifestSha256) { + throw new Error('orcad_migration_id_reused_with_different_manifest') + } + continue + } + for (const claim of catalogClaims(entry.manifest)) { + if (requested.has(claim)) { + throw new Error(`orcad_migration_staged_claim_conflict:${claim}`) + } + } + } +} + +function catalogClaims(manifest: OrcadMigrationManifest): Set { + const { payload } = manifest + const claims = new Set() + for (const repo of payload.repositories) { + claims.add(`repository:${repo.id}`) + claims.add(`repository-path:${normalizeRuntimePathForComparison(repo.path)}`) + } + payload.projectGroups.forEach((group) => claims.add(`project-group:${group.id}`)) + payload.folderWorkspaces.forEach((folder) => claims.add(`folder-workspace:${folder.id}`)) + const dormant = payload.dormantState + if (dormant) { + dormant.worktreeMeta.forEach((entry) => claims.add(`worktree-meta:${entry.worktreeId}`)) + dormant.worktreeLineage.forEach((entry) => claims.add(`worktree-lineage:${entry.worktreeId}`)) + dormant.workspaceLineage.forEach((entry) => + claims.add(`workspace-lineage:${entry.childWorkspaceKey}`) + ) + dormant.sparsePresets.forEach((preset) => + claims.add(`sparse-preset:${preset.repoId}:${preset.id}`) + ) + dormant.automations?.forEach((automation) => claims.add(`automation:${automation.id}`)) + dormant.automationRuns?.forEach((run) => claims.add(`automation-run:${run.id}`)) + if (dormant.workspaceSession) { + for (const tabId of Object.keys(dormant.workspaceSession.terminalLayoutsByTabId)) { + claims.add(`session:terminal-layout:${tabId}`) + } + for (const entity of collectOrcadMigrationSessionEntityOwners( + dormant.workspaceSession + ).keys()) { + claims.add(`session:${entity}`) + } + } + } + return claims +} diff --git a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts index e26d43c6cc73..5e2a4de40a45 100644 --- a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts +++ b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts @@ -56,6 +56,12 @@ export function collectDeregisteredRepoIds(state: PersistedState): Set { retainOwner(getWorktreeIdFromHostIdentity(alias)) } } + // A staged catalog owns recovery rows before its repositories become registered. + for (const staged of state.orcadMigrationStagedCatalogs ?? []) { + for (const repo of staged.manifest.payload.repositories) { + liveRepoIds.add(repo.id) + } + } const orphanRepoIds = new Set() // Only a full `::` locator seeds the set. A bare key -- a folder workspace id, a // repo-keyed topology revision, a test-shaped locator -- cannot be told apart from a repo id, and diff --git a/src/main/runtime/orca-runtime-migration-catalog.ts b/src/main/runtime/orca-runtime-migration-catalog.ts new file mode 100644 index 000000000000..2a5f6bd31743 --- /dev/null +++ b/src/main/runtime/orca-runtime-migration-catalog.ts @@ -0,0 +1,134 @@ +import type { + OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogState, + OrcadMigrationImportResult, + OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' +import type { + OrcadMigrationSnapshotChunkRequest, + OrcadMigrationSnapshotChunkResult +} from '../../shared/orcad-migration-scrollback' +import { + abortStagedOrcadMigrationCatalogDurably, + commitStagedOrcadMigrationCatalogDurably, + getOrcadMigrationCatalogState, + importOrcadMigrationCatalogDurably, + stageOrcadMigrationCatalogDurably +} from './orcad-migration-catalog-import' +import { OrcaRuntimeWithResolveWaiter } from './orca-runtime-resolve-waiter' + +/** The destination half of a dormant catalog migration; every mutation is flushed before it answers. */ +export class OrcaRuntimeWithMigrationCatalog extends OrcaRuntimeWithResolveWaiter { + async importOrcadMigrationCatalog( + manifest: OrcadMigrationManifest, + options: { signal?: AbortSignal } = {} + ): Promise { + const store = this.store + const importCatalog = store?.importOrcadMigrationCatalog + const flushPending = store?.flushPendingOrThrowAsync + if (!store || !importCatalog || !flushPending) { + throw new Error('runtime_unavailable') + } + return importOrcadMigrationCatalogDurably({ + store: { + importOrcadMigrationCatalog: (input, inputOptions) => + importCatalog.call(store, input, inputOptions), + flushPendingOrThrowAsync: (flushOptions) => flushPending.call(store, flushOptions) + }, + manifest, + signal: options.signal, + onDurableImport: () => { + this.invalidateResolvedWorktreeCache() + this.notifyReposChanged() + } + }) + } + + async stageOrcadMigrationCatalog( + manifest: OrcadMigrationManifest, + options: { signal?: AbortSignal } = {} + ): Promise { + const store = this.store + const stage = store?.stageOrcadMigrationCatalog + const flush = store?.flushPendingOrThrowAsync + if (!store || !stage || !flush) { + throw new Error('runtime_unavailable') + } + return stageOrcadMigrationCatalogDurably({ + store: { + stageOrcadMigrationCatalog: (input, inputOptions) => stage.call(store, input, inputOptions), + flushPendingOrThrowAsync: (flushOptions) => flush.call(store, flushOptions) + }, + manifest, + signal: options.signal + }) + } + + async commitStagedOrcadMigrationCatalog( + manifest: OrcadMigrationManifest, + options: { signal?: AbortSignal } = {} + ): Promise { + const store = this.store + const commit = store?.commitStagedOrcadMigrationCatalog + const flush = store?.flushPendingOrThrowAsync + if (!store || !commit || !flush) { + throw new Error('runtime_unavailable') + } + return commitStagedOrcadMigrationCatalogDurably({ + store: { + commitStagedOrcadMigrationCatalog: (input, inputOptions) => + commit.call(store, input, inputOptions), + flushPendingOrThrowAsync: (flushOptions) => flush.call(store, flushOptions) + }, + manifest, + signal: options.signal, + onDurableCommit: () => { + this.invalidateResolvedWorktreeCache() + this.notifyReposChanged() + } + }) + } + + stageOrcadMigrationSnapshotChunk( + request: OrcadMigrationSnapshotChunkRequest + ): OrcadMigrationSnapshotChunkResult { + const store = this.store + const stageChunk = store?.stageOrcadMigrationSnapshotChunk + if (!store || !stageChunk) { + throw new Error('runtime_unavailable') + } + return stageChunk.call(store, request) + } + + async abortStagedOrcadMigrationCatalog( + manifest: OrcadMigrationManifest, + options: { signal?: AbortSignal } = {} + ): Promise { + const store = this.store + const abort = store?.abortStagedOrcadMigrationCatalog + const flush = store?.flushPendingOrThrowAsync + if (!store || !abort || !flush) { + throw new Error('runtime_unavailable') + } + return abortStagedOrcadMigrationCatalogDurably({ + store: { + abortStagedOrcadMigrationCatalog: (input) => abort.call(store, input), + flushPendingOrThrowAsync: (flushOptions) => flush.call(store, flushOptions) + }, + manifest, + signal: options.signal + }) + } + + getOrcadMigrationCatalogState(manifest: OrcadMigrationManifest): OrcadMigrationCatalogState { + const store = this.store + const readState = store?.getOrcadMigrationCatalogState + if (!store || !readState) { + throw new Error('runtime_unavailable') + } + return getOrcadMigrationCatalogState({ + store: { getOrcadMigrationCatalogState: (input) => readState.call(store, input) }, + manifest + }) + } +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 287285c8ea70..d90587e8e5c1 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,11 +1,11 @@ import { installRuntimeLinearCommandSurface } from './runtime-linear-command-surface' -import { OrcaRuntimeWithResolveWaiter } from './orca-runtime-resolve-waiter' +import { OrcaRuntimeWithMigrationCatalog } from './orca-runtime-migration-catalog' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' import { registerWorktreeChangeInvalidator } from '../ipc/worktree-change-invalidators' import { registerDetectedWorktreeScanInvalidation } from '../ipc/worktrees/listing/register-detected-worktree-scan-invalidation' -class OrcaRuntimeService extends OrcaRuntimeWithResolveWaiter { - constructor(...args: ConstructorParameters) { +class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog { + constructor(...args: ConstructorParameters) { super(...args) // Why: the runtime listing re-runs a scan the worktree-change generation overtook and re-lists // through this runtime's scan cache, so a worktree change must reach both. The desktop IPC diff --git a/src/main/runtime/orcad-migration-catalog-import.test.ts b/src/main/runtime/orcad-migration-catalog-import.test.ts new file mode 100644 index 000000000000..bb49283a0b29 --- /dev/null +++ b/src/main/runtime/orcad-migration-catalog-import.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogState, + OrcadMigrationImportResult, + OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' +import { + abortStagedOrcadMigrationCatalogDurably, + commitStagedOrcadMigrationCatalogDurably, + importOrcadMigrationCatalogDurably, + stageOrcadMigrationCatalogDurably +} from './orcad-migration-catalog-import' + +describe('durable orcad migration catalog import', () => { + it('acknowledges only after a durable flush and safely retries a failed acknowledgement', async () => { + const diskError = new Error('disk full') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the durable wrappers only pass these values through to the stubbed store. + const imported = { status: 'imported' } as OrcadMigrationImportResult + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the durable wrappers only pass these values through to the stubbed store. + const replayed = { status: 'already-imported' } as OrcadMigrationImportResult + const importCatalog = vi.fn().mockReturnValueOnce(imported).mockReturnValueOnce(replayed) + const flush = vi.fn().mockRejectedValueOnce(diskError).mockResolvedValueOnce(undefined) + const onDurableImport = vi.fn() + const args = { + store: { + importOrcadMigrationCatalog: importCatalog, + flushPendingOrThrowAsync: flush + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the durable wrappers only pass these values through to the stubbed store. + manifest: {} as OrcadMigrationManifest, + onDurableImport + } + + await expect(importOrcadMigrationCatalogDurably(args)).rejects.toBe(diskError) + expect(onDurableImport).not.toHaveBeenCalled() + await expect(importOrcadMigrationCatalogDurably(args)).resolves.toBe(replayed) + expect(onDurableImport).toHaveBeenCalledOnce() + expect(importCatalog).toHaveBeenCalledTimes(2) + expect(flush).toHaveBeenCalledTimes(2) + }) + + it('does not acknowledge staging until its dormant manifest is durable', async () => { + const diskError = new Error('disk full') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the durable wrappers only pass these values through to the stubbed store. + const staged = { state: 'staged' } as OrcadMigrationCatalogState + const stage = vi.fn().mockReturnValue(staged) + const flush = vi.fn().mockRejectedValueOnce(diskError).mockResolvedValueOnce(undefined) + const args = { + store: { + stageOrcadMigrationCatalog: stage, + flushPendingOrThrowAsync: flush + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the durable wrappers only pass these values through to the stubbed store. + manifest: {} as OrcadMigrationManifest + } + + await expect(stageOrcadMigrationCatalogDurably(args)).rejects.toBe(diskError) + await expect(stageOrcadMigrationCatalogDurably(args)).resolves.toBe(staged) + expect(stage).toHaveBeenCalledTimes(2) + expect(flush).toHaveBeenCalledTimes(2) + }) + + it('publishes a committed catalog only after its receipt is durable', async () => { + const diskError = new Error('disk full') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the durable wrappers only pass these values through to the stubbed store. + const committed = { state: 'committed' } as OrcadMigrationCatalogState + const commit = vi.fn().mockReturnValue(committed) + const flush = vi.fn().mockRejectedValueOnce(diskError).mockResolvedValueOnce(undefined) + const onDurableCommit = vi.fn() + const args = { + store: { + commitStagedOrcadMigrationCatalog: commit, + flushPendingOrThrowAsync: flush + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the durable wrappers only pass these values through to the stubbed store. + manifest: {} as OrcadMigrationManifest, + onDurableCommit + } + + await expect(commitStagedOrcadMigrationCatalogDurably(args)).rejects.toBe(diskError) + expect(onDurableCommit).not.toHaveBeenCalled() + await expect(commitStagedOrcadMigrationCatalogDurably(args)).resolves.toBe(committed) + expect(onDurableCommit).toHaveBeenCalledOnce() + }) + + it('reflushes an already-absent abort after the first flush failed', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the durable wrappers only pass these values through to the stubbed store. + const aborted = { state: 'absent', aborted: true } as OrcadMigrationCatalogAbortResult + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the durable wrappers only pass these values through to the stubbed store. + const unchanged = { state: 'absent', aborted: false } as OrcadMigrationCatalogAbortResult + const abort = vi.fn().mockReturnValueOnce(aborted).mockReturnValueOnce(unchanged) + const flush = vi.fn().mockRejectedValueOnce(new Error('disk full')).mockResolvedValue(undefined) + const args = { + store: { + abortStagedOrcadMigrationCatalog: abort, + flushPendingOrThrowAsync: flush + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the durable wrappers only pass these values through to the stubbed store. + manifest: {} as OrcadMigrationManifest + } + + await expect(abortStagedOrcadMigrationCatalogDurably(args)).rejects.toThrow('disk full') + await expect(abortStagedOrcadMigrationCatalogDurably(args)).resolves.toEqual({ + ...unchanged, + durableAbsent: true + }) + expect(flush).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/runtime/orcad-migration-catalog-import.ts b/src/main/runtime/orcad-migration-catalog-import.ts new file mode 100644 index 000000000000..9502bb8199d7 --- /dev/null +++ b/src/main/runtime/orcad-migration-catalog-import.ts @@ -0,0 +1,82 @@ +import type { + OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogState, + OrcadMigrationImportResult, + OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' +import type { Store } from '../persistence' + +type OrcadMigrationCatalogFlushStore = Pick +type OrcadMigrationCatalogImportStore = OrcadMigrationCatalogFlushStore & + Pick +type OrcadMigrationCatalogStageStore = OrcadMigrationCatalogFlushStore & + Pick +type OrcadMigrationCatalogCommitStore = OrcadMigrationCatalogFlushStore & + Pick +type OrcadMigrationCatalogAbortStore = OrcadMigrationCatalogFlushStore & + Pick + +export async function importOrcadMigrationCatalogDurably(args: { + store: OrcadMigrationCatalogImportStore + manifest: OrcadMigrationManifest + signal?: AbortSignal + onDurableImport: () => void +}): Promise { + const result = args.store.importOrcadMigrationCatalog(args.manifest) + await args.store.flushPendingOrThrowAsync({ + signal: args.signal, + drainToStableGeneration: false + }) + args.onDurableImport() + return result +} + +export async function stageOrcadMigrationCatalogDurably(args: { + store: OrcadMigrationCatalogStageStore + manifest: OrcadMigrationManifest + signal?: AbortSignal +}): Promise { + const result = args.store.stageOrcadMigrationCatalog(args.manifest) + await flushMigrationState(args.store, args.signal) + return result +} + +export async function commitStagedOrcadMigrationCatalogDurably(args: { + store: OrcadMigrationCatalogCommitStore + manifest: OrcadMigrationManifest + signal?: AbortSignal + onDurableCommit: () => void +}): Promise { + const result = args.store.commitStagedOrcadMigrationCatalog(args.manifest) + await flushMigrationState(args.store, args.signal) + args.onDurableCommit() + return result +} + +export async function abortStagedOrcadMigrationCatalogDurably(args: { + store: OrcadMigrationCatalogAbortStore + manifest: OrcadMigrationManifest + signal?: AbortSignal +}): Promise { + const result = args.store.abortStagedOrcadMigrationCatalog(args.manifest) + // An already-absent retry may follow an in-memory abort whose flush failed. + if (result.state === 'absent') { + await flushMigrationState(args.store, args.signal) + return { ...result, durableAbsent: true } + } + return result +} + +export function getOrcadMigrationCatalogState(args: { + store: Pick + manifest: OrcadMigrationManifest +}): OrcadMigrationCatalogState { + return args.store.getOrcadMigrationCatalogState(args.manifest) +} + +function flushMigrationState( + store: OrcadMigrationCatalogFlushStore, + signal?: AbortSignal +): Promise { + return store.flushPendingOrThrowAsync({ signal, drainToStableGeneration: false }) +} diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 762d61cf3aad..850378b86b70 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -1,5 +1,6 @@ import { STATUS_METHODS } from './status' import { ORCAD_TERMINAL_CENSUS_METHODS } from './orcad-terminal-census' +import { ORCAD_MIGRATION_METHODS } from './orcad-migration' import { AI_VAULT_METHODS } from './ai-vault' import { AUTOMATION_METHODS } from './automations' import { REPO_METHODS } from './repo' @@ -56,6 +57,7 @@ import { AGENT_LAUNCH_METHODS } from './agent-launch' export const ALL_RPC_METHODS = [ ...STATUS_METHODS, ...ORCAD_TERMINAL_CENSUS_METHODS, + ...ORCAD_MIGRATION_METHODS, ...AGENT_HOOK_METHODS, ...AI_VAULT_METHODS, ...ARTIFACT_METHODS, diff --git a/src/main/runtime/rpc/methods/orcad-migration.test.ts b/src/main/runtime/rpc/methods/orcad-migration.test.ts new file mode 100644 index 000000000000..2d6a435af0eb --- /dev/null +++ b/src/main/runtime/rpc/methods/orcad-migration.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ORCAD_MIGRATION_MANIFEST_VERSION, + type OrcadMigrationManifest +} from '../../../../shared/orcad-migration-manifest' +import { computeOrcadMigrationManifestSha256 } from '../../../orcad/orcad-migration-manifest-digest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { eraseRpcMethods, isStreamingMethod, type RpcContext } from '../core' +import { ALL_RPC_METHODS } from './index' +import { ORCAD_MIGRATION_METHODS } from './orcad-migration' + +function manifest(): OrcadMigrationManifest { + const unsigned = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: 'migration-1', + createdAt: '2026-08-30T12:00:00.000Z', + source: { + sshTargetId: 'ssh-prod', + sshTargetGeneration: 7, + targetLabel: 'Production' + }, + payload: { repositories: [], projectGroups: [], folderWorkspaces: [] } + } + return { + ...unsigned, + manifestSha256: computeOrcadMigrationManifestSha256(unsigned) + } +} + +const MIGRATION_METHOD_NAMES = [ + 'orcad.migration.importCatalog', + 'orcad.migration.stageCatalog', + 'orcad.migration.commitCatalog', + 'orcad.migration.stageSnapshotChunk', + 'orcad.migration.abortCatalog', + 'orcad.migration.catalogState' +] as const + +function migrationMethod( + name: (typeof MIGRATION_METHOD_NAMES)[number] = MIGRATION_METHOD_NAMES[0] +) { + const method = eraseRpcMethods(ORCAD_MIGRATION_METHODS).find( + (candidate) => candidate.name === name + ) + if (!method || isStreamingMethod(method)) { + throw new Error(`Missing or invalid ${name} method`) + } + return method +} + +function context( + importOrcadMigrationCatalog: ReturnType, + overrides: Partial = {}, + runtimeOverrides: Record = {} +): RpcContext { + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the migration methods call only the runtime members stubbed here. + runtime: { importOrcadMigrationCatalog, ...runtimeOverrides } as unknown as OrcaRuntimeService, + clientKind: 'runtime', + pairedDeviceId: 'paired-desktop', + ...overrides + } +} + +describe('orcad migration RPC', () => { + it('registers every staged-cutover method exactly once', () => { + for (const name of MIGRATION_METHOD_NAMES) { + expect(ALL_RPC_METHODS.filter((method) => method.name === name)).toHaveLength(1) + } + }) + + it('stages, commits, aborts, and reconciles through the authenticated runtime client', async () => { + const input = manifest() + const signal = new AbortController().signal + const stage = vi.fn().mockResolvedValue({ state: 'staged' }) + const commit = vi.fn().mockResolvedValue({ state: 'committed' }) + const snapshot = vi.fn().mockReturnValue({ acknowledgedOffset: 1 }) + const abort = vi.fn().mockResolvedValue({ state: 'absent', aborted: true }) + const state = vi.fn().mockReturnValue({ state: 'staged' }) + const runtime = { + stageOrcadMigrationCatalog: stage, + commitStagedOrcadMigrationCatalog: commit, + stageOrcadMigrationSnapshotChunk: snapshot, + abortStagedOrcadMigrationCatalog: abort, + getOrcadMigrationCatalogState: state + } + const caller = context(vi.fn(), { signal }, runtime) + + await expect( + migrationMethod('orcad.migration.stageCatalog').handler({ manifest: input }, caller) + ).resolves.toEqual({ state: 'staged' }) + await expect( + migrationMethod('orcad.migration.commitCatalog').handler({ manifest: input }, caller) + ).resolves.toEqual({ state: 'committed' }) + const snapshotRequest = migrationSnapshotRequest(input) + await expect( + migrationMethod('orcad.migration.stageSnapshotChunk').handler(snapshotRequest, caller) + ).resolves.toEqual({ acknowledgedOffset: 1 }) + await expect( + migrationMethod('orcad.migration.abortCatalog').handler({ manifest: input }, caller) + ).resolves.toEqual({ state: 'absent', aborted: true }) + await expect( + migrationMethod('orcad.migration.catalogState').handler({ manifest: input }, caller) + ).resolves.toEqual({ state: 'staged' }) + expect(stage).toHaveBeenCalledWith(input, { signal }) + expect(commit).toHaveBeenCalledWith(input, { signal }) + expect(snapshot).toHaveBeenCalledWith(snapshotRequest) + expect(abort).toHaveBeenCalledWith(input, { signal }) + expect(state).toHaveBeenCalledWith(input) + }) + + it('accepts an authenticated runtime client and forwards cancellation', async () => { + const input = manifest() + const expected = { status: 'imported', receipt: { migrationId: input.migrationId } } + const importCatalog = vi.fn().mockResolvedValue(expected) + const signal = new AbortController().signal + const method = migrationMethod() + + await expect( + method.handler(method.params?.parse({ manifest: input }), context(importCatalog, { signal })) + ).resolves.toBe(expected) + expect(importCatalog).toHaveBeenCalledWith(input, { signal }) + }) + + it.each([ + ['mobile client', { clientKind: 'mobile', pairedDeviceId: 'paired-phone' }], + ['unpaired runtime client', { clientKind: 'runtime', pairedDeviceId: undefined }], + ['local caller', { clientKind: undefined, pairedDeviceId: undefined }] + ] as const)('rejects a %s before any migration mutation', async (_label, caller) => { + for (const name of MIGRATION_METHOD_NAMES) { + const invoke = vi.fn() + const method = migrationMethod(name) + const runtimeMethod = + name === 'orcad.migration.stageCatalog' + ? 'stageOrcadMigrationCatalog' + : name === 'orcad.migration.commitCatalog' + ? 'commitStagedOrcadMigrationCatalog' + : name === 'orcad.migration.stageSnapshotChunk' + ? 'stageOrcadMigrationSnapshotChunk' + : name === 'orcad.migration.abortCatalog' + ? 'abortStagedOrcadMigrationCatalog' + : name === 'orcad.migration.catalogState' + ? 'getOrcadMigrationCatalogState' + : 'importOrcadMigrationCatalog' + + await expect( + method.handler( + method.params?.parse(migrationMethodInput(name)), + context(invoke, caller, { [runtimeMethod]: invoke }) + ) + ).rejects.toThrow('orcad_migration_runtime_client_required') + expect(invoke).not.toHaveBeenCalled() + } + }) + + it('rejects malformed and tampered manifests before importing', async () => { + const importCatalog = vi.fn() + const method = migrationMethod() + const input = manifest() + + await expect( + method.handler( + method.params?.parse({ manifest: { ...input, manifestSha256: 'invalid' } }), + context(importCatalog) + ) + ).rejects.toThrow('orcad_migration_manifest_digest_invalid') + await expect( + method.handler( + method.params?.parse({ + manifest: { ...input, source: { ...input.source, targetLabel: 'Tampered' } } + }), + context(importCatalog) + ) + ).rejects.toThrow('orcad_migration_manifest_digest_mismatch') + expect(importCatalog).not.toHaveBeenCalled() + }) +}) + +function migrationSnapshotRequest(input: OrcadMigrationManifest) { + return { + migrationId: input.migrationId, + manifestSha256: input.manifestSha256, + ref: `v1-${'1'.repeat(32)}`, + offset: 0, + bytesBase64: 'YQ==' + } +} + +function migrationMethodInput(name: (typeof MIGRATION_METHOD_NAMES)[number]) { + const input = manifest() + return name === 'orcad.migration.stageSnapshotChunk' + ? migrationSnapshotRequest(input) + : { manifest: input } +} diff --git a/src/main/runtime/rpc/methods/orcad-migration.ts b/src/main/runtime/rpc/methods/orcad-migration.ts new file mode 100644 index 000000000000..ec968b14d650 --- /dev/null +++ b/src/main/runtime/rpc/methods/orcad-migration.ts @@ -0,0 +1,78 @@ +import { z } from 'zod' +import { parseOrcadMigrationManifest } from '../../../../shared/orcad-migration-manifest' +import { OrcadMigrationSnapshotChunkRequestSchema } from '../../../../shared/orcad-migration-scrollback' +import { assertOrcadMigrationManifestDigest } from '../../../orcad/orcad-migration-manifest-digest' +import { defineMethod, type RpcContext } from '../core' + +const OrcadMigrationImport = z.object({ manifest: z.unknown() }) + +export const ORCAD_MIGRATION_METHODS = [ + defineMethod({ + name: 'orcad.migration.importCatalog', + params: OrcadMigrationImport, + handler: async (params, context) => { + requireMigrationRuntimeClient(context) + return context.runtime.importOrcadMigrationCatalog(migrationManifest(params), { + signal: context.signal + }) + } + }), + defineMethod({ + name: 'orcad.migration.stageCatalog', + params: OrcadMigrationImport, + handler: async (params, context) => { + requireMigrationRuntimeClient(context) + return context.runtime.stageOrcadMigrationCatalog(migrationManifest(params), { + signal: context.signal + }) + } + }), + defineMethod({ + name: 'orcad.migration.commitCatalog', + params: OrcadMigrationImport, + handler: async (params, context) => { + requireMigrationRuntimeClient(context) + return context.runtime.commitStagedOrcadMigrationCatalog(migrationManifest(params), { + signal: context.signal + }) + } + }), + defineMethod({ + name: 'orcad.migration.stageSnapshotChunk', + params: OrcadMigrationSnapshotChunkRequestSchema, + handler: async (params, context) => { + requireMigrationRuntimeClient(context) + return context.runtime.stageOrcadMigrationSnapshotChunk(params) + } + }), + defineMethod({ + name: 'orcad.migration.abortCatalog', + params: OrcadMigrationImport, + handler: async (params, context) => { + requireMigrationRuntimeClient(context) + return context.runtime.abortStagedOrcadMigrationCatalog(migrationManifest(params), { + signal: context.signal + }) + } + }), + defineMethod({ + name: 'orcad.migration.catalogState', + params: OrcadMigrationImport, + handler: async (params, context) => { + requireMigrationRuntimeClient(context) + return context.runtime.getOrcadMigrationCatalogState(migrationManifest(params)) + } + }) +] + +function migrationManifest(params: z.infer) { + const manifest = parseOrcadMigrationManifest(params.manifest) + assertOrcadMigrationManifestDigest(manifest) + return manifest +} + +function requireMigrationRuntimeClient(context: RpcContext): void { + if (context.clientKind !== 'runtime' || !context.pairedDeviceId) { + throw new Error('orcad_migration_runtime_client_required') + } +} diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 43fe59b34e1a..b28de69063c4 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -51,6 +51,12 @@ export type RuntimeStore = { runDurableMutation?: Store['runDurableMutation'] flushOrThrow?: Store['flushOrThrow'] flushPendingOrThrowAsync?: Store['flushPendingOrThrowAsync'] + importOrcadMigrationCatalog?: Store['importOrcadMigrationCatalog'] + stageOrcadMigrationCatalog?: Store['stageOrcadMigrationCatalog'] + commitStagedOrcadMigrationCatalog?: Store['commitStagedOrcadMigrationCatalog'] + stageOrcadMigrationSnapshotChunk?: Store['stageOrcadMigrationSnapshotChunk'] + abortStagedOrcadMigrationCatalog?: Store['abortStagedOrcadMigrationCatalog'] + getOrcadMigrationCatalogState?: Store['getOrcadMigrationCatalogState'] persistPtyBinding?: Store['persistPtyBinding'] getSshRemotePtyLeases?: Store['getSshRemotePtyLeases'] getUI?: Store['getUI'] diff --git a/src/main/ssh/orcad-migration-catalog-client.test.ts b/src/main/ssh/orcad-migration-catalog-client.test.ts new file mode 100644 index 000000000000..2aa44cb7549f --- /dev/null +++ b/src/main/ssh/orcad-migration-catalog-client.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + ORCAD_MIGRATION_MANIFEST_VERSION, + type OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' +import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' + +const sendRequest = vi.hoisted(() => vi.fn()) +const status = vi.hoisted(() => ({ capabilities: ['orcad.migration-catalog.v1'] })) +// Runs the status preflight, then hands the request to the old single-call shape the cases assert. +vi.mock('../../shared/remote-runtime-client', () => ({ + sendRemoteRuntimeRequestWithStatusPreflight: async ( + pairing: unknown, + method: string, + params: unknown, + timeoutMs: number, + validate: (response: unknown) => void, + envelope: unknown, + capabilities: unknown, + signal: unknown + ) => { + validate({ + ok: true, + _meta: { runtimeId: 'runtime' }, + result: { capabilities: status.capabilities } + }) + return sendRequest(pairing, method, params, timeoutMs, envelope, signal, capabilities) + } +})) + +const { + abortRemoteOrcadMigrationCatalog, + commitRemoteOrcadMigrationCatalog, + readRemoteOrcadMigrationCatalogState, + stageRemoteOrcadMigrationCatalog, + stageRemoteOrcadMigrationSnapshotChunk +} = await import('./orcad-migration-catalog-client') + +function manifest(): OrcadMigrationManifest { + const unsigned = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: 'migration-1', + createdAt: '2026-08-30T12:00:00.000Z', + source: { + sshTargetId: 'ssh-prod', + sshTargetGeneration: 7, + targetLabel: 'Production' + }, + payload: { repositories: [], projectGroups: [], folderWorkspaces: [] } + } + return { ...unsigned, manifestSha256: computeOrcadMigrationManifestSha256(unsigned) } +} + +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() + status.capabilities = ['orcad.migration-catalog.v1'] +}) + +describe('remote orcad migration catalog client', () => { + it.each([ + stageRemoteOrcadMigrationCatalog, + commitRemoteOrcadMigrationCatalog, + readRemoteOrcadMigrationCatalogState, + abortRemoteOrcadMigrationCatalog + ])( + 'refuses another runtime or missing authenticated runtime metadata when pinned', + async (request) => { + for (const runtimeId of ['other-runtime', undefined]) { + sendRequest.mockResolvedValue({ ok: true, _meta: { runtimeId }, result: {} }) + await expect( + request(pairingCode, manifest(), { expectedRuntimeId: 'runtime' }) + ).rejects.toThrow('destination_runtime_mismatch') + } + } + ) + + it('pins snapshot acknowledgments to the admitted destination runtime', async () => { + const request = { + migrationId: 'migration-1', + manifestSha256: manifest().manifestSha256, + ref: `v1-${'1'.repeat(32)}`, + offset: 0, + bytesBase64: 'YQ==' + } + sendRequest.mockResolvedValue({ + ok: true, + _meta: { runtimeId: 'other' }, + result: { ...request, acknowledgedOffset: 1 } + }) + await expect( + stageRemoteOrcadMigrationSnapshotChunk(pairingCode, request, { expectedRuntimeId: 'runtime' }) + ).rejects.toThrow('destination_runtime_mismatch') + sendRequest.mockResolvedValue({ + ok: true, + _meta: { runtimeId: 'runtime' }, + result: { ...request, acknowledgedOffset: 1 } + }) + await expect( + stageRemoteOrcadMigrationSnapshotChunk(pairingCode, request, { expectedRuntimeId: 'runtime' }) + ).resolves.toMatchObject({ acknowledgedOffset: 1 }) + }) + + it.each([ + ['stage', 'orcad.migration.stageCatalog', stageRemoteOrcadMigrationCatalog], + ['commit', 'orcad.migration.commitCatalog', commitRemoteOrcadMigrationCatalog], + ['state', 'orcad.migration.catalogState', readRemoteOrcadMigrationCatalogState] + ] as const)('sends and validates the %s operation', async (_label, method, request) => { + const input = manifest() + const result = { + state: 'staged', + migrationId: input.migrationId, + manifestSha256: input.manifestSha256, + stagedAt: '2026-08-30T12:01:00.000Z' + } + sendRequest.mockResolvedValue({ ok: true, result }) + const signal = new AbortController().signal + + await expect(request(pairingCode, input, { signal, timeoutMs: 1234 })).resolves.toEqual(result) + expect(sendRequest).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: 'ws://127.0.0.1:46768/runtime' }), + method, + { manifest: input }, + 1234, + undefined, + signal, + expect.any(Object) + ) + }) + + it('validates an abort result without letting it erase committed state', async () => { + const input = manifest() + sendRequest.mockResolvedValue({ + ok: true, + result: { + state: 'absent', + migrationId: input.migrationId, + manifestSha256: input.manifestSha256, + aborted: true + } + }) + + await expect(abortRemoteOrcadMigrationCatalog(pairingCode, input)).resolves.toMatchObject({ + state: 'absent', + aborted: true + }) + }) + + it.each([undefined, true, false, 'true'])( + 'requires explicit persistence evidence for an already-absent abort (%s)', + async (durableAbsent) => { + const input = manifest() + sendRequest.mockResolvedValue({ + ok: true, + result: { + state: 'absent', + migrationId: input.migrationId, + manifestSha256: input.manifestSha256, + aborted: false, + ...(durableAbsent === undefined ? {} : { durableAbsent }) + } + }) + const result = abortRemoteOrcadMigrationCatalog(pairingCode, input) + if (durableAbsent === true) { + await expect(result).resolves.toMatchObject({ durableAbsent: true, aborted: false }) + return + } + await expect(result).rejects.toThrow(/durability/) + } + ) + + it('validates a committed receipt against the requested manifest', async () => { + const input = manifest() + const receipt = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: input.migrationId, + manifestSha256: input.manifestSha256, + source: input.source, + importedAt: '2026-08-30T12:02:00.000Z', + repositoryIds: [], + projectGroupIds: [], + folderWorkspaceIds: [] + } + sendRequest.mockResolvedValue({ + ok: true, + result: { + state: 'committed', + migrationId: input.migrationId, + manifestSha256: input.manifestSha256, + receipt + } + }) + + await expect(commitRemoteOrcadMigrationCatalog(pairingCode, input)).resolves.toMatchObject({ + state: 'committed', + receipt + }) + }) + + it('uploads a snapshot chunk and requires the exact acknowledgment', async () => { + const input = manifest() + const request = { + migrationId: input.migrationId, + manifestSha256: input.manifestSha256, + ref: `v1-${'1'.repeat(32)}`, + offset: 4, + bytesBase64: 'YQ==' + } + sendRequest.mockResolvedValueOnce({ + ok: true, + result: { ...request, acknowledgedOffset: 5 } + }) + await expect( + stageRemoteOrcadMigrationSnapshotChunk(pairingCode, request) + ).resolves.toMatchObject({ acknowledgedOffset: 5 }) + expect(sendRequest).toHaveBeenLastCalledWith( + expect.any(Object), + 'orcad.migration.stageSnapshotChunk', + request, + 15_000, + undefined, + undefined, + expect.any(Object) + ) + + sendRequest.mockResolvedValueOnce({ + ok: true, + result: { ...request, acknowledgedOffset: 4 } + }) + await expect(stageRemoteOrcadMigrationSnapshotChunk(pairingCode, request)).rejects.toThrow( + 'orcad_migration_snapshot_chunk_result_invalid' + ) + }) + + it('fails closed when a staged old host omits required snapshot upload state', async () => { + const input = manifest() + input.payload.dormantState = { + version: 1, + worktreeMeta: [], + worktreeLineage: [], + workspaceLineage: [], + sparsePresets: [], + retiredWorktreeNames: [], + retiredWorktreeNamespaces: [], + terminalScrollbackSnapshots: [ + { + tabId: 'tab-1', + leafId: 'leaf-1', + ref: `v1-${'1'.repeat(32)}`, + sha256: 'b'.repeat(64), + byteLength: 1 + } + ] + } + sendRequest.mockResolvedValue({ + ok: true, + result: { + state: 'staged', + migrationId: input.migrationId, + manifestSha256: input.manifestSha256, + stagedAt: '2026-08-30T12:01:00.000Z' + } + }) + + await expect(readRemoteOrcadMigrationCatalogState(pairingCode, input)).rejects.toThrow( + 'orcad_migration_snapshot_transfer_unsupported' + ) + }) + + it('rejects malformed stage timestamps and abort envelopes', async () => { + const input = manifest() + sendRequest.mockResolvedValueOnce({ + ok: true, + result: { + state: 'staged', + migrationId: input.migrationId, + manifestSha256: input.manifestSha256, + stagedAt: 'not-a-date' + } + }) + await expect(stageRemoteOrcadMigrationCatalog(pairingCode, input)).rejects.toThrow( + 'orcad_migration_catalog_state_staged_at_invalid' + ) + + sendRequest.mockResolvedValueOnce({ + ok: true, + result: { + state: 'absent', + migrationId: input.migrationId, + manifestSha256: input.manifestSha256 + } + }) + await expect(abortRemoteOrcadMigrationCatalog(pairingCode, input)).rejects.toThrow( + 'orcad_migration_catalog_abort_result_invalid' + ) + }) + + it('fails closed for method absence, malformed identity, and invalid pairing', async () => { + const input = manifest() + sendRequest.mockResolvedValueOnce({ ok: false, error: { message: 'method not found' } }) + await expect(stageRemoteOrcadMigrationCatalog(pairingCode, input)).rejects.toThrow( + 'orcad_migration_stage_failed:method not found' + ) + + sendRequest.mockResolvedValueOnce({ + ok: true, + result: { state: 'absent', migrationId: 'other', manifestSha256: input.manifestSha256 } + }) + await expect(readRemoteOrcadMigrationCatalogState(pairingCode, input)).rejects.toThrow( + 'orcad_migration_catalog_state_identity_mismatch' + ) + + await expect(stageRemoteOrcadMigrationCatalog('invalid', input)).rejects.toThrow( + 'orcad_migration_pairing_code_invalid' + ) + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('refuses a destination without the capability before sending any migration request', async () => { + status.capabilities = [] + await expect(stageRemoteOrcadMigrationCatalog(pairingCode, manifest())).rejects.toThrow( + 'orcad_migration_destination_unsupported' + ) + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('reads method-not-found as the same refusal, not as a lost answer', async () => { + sendRequest.mockResolvedValue({ + ok: false, + _meta: { runtimeId: 'runtime' }, + error: { code: 'method_not_found', message: 'Unknown method' } + }) + await expect(commitRemoteOrcadMigrationCatalog(pairingCode, manifest())).rejects.toThrow( + 'orcad_migration_destination_unsupported' + ) + }) +}) diff --git a/src/main/ssh/orcad-migration-catalog-client.ts b/src/main/ssh/orcad-migration-catalog-client.ts new file mode 100644 index 000000000000..1679e83c83e6 --- /dev/null +++ b/src/main/ssh/orcad-migration-catalog-client.ts @@ -0,0 +1,173 @@ +import { + parseOrcadMigrationCatalogAbortResult, + parseOrcadMigrationCatalogState +} from '../../shared/orcad-migration-catalog-state' +import type { + OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogState, + OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' +import { + parseOrcadMigrationSnapshotChunkResult, + type OrcadMigrationSnapshotChunkRequest, + type OrcadMigrationSnapshotChunkResult +} from '../../shared/orcad-migration-scrollback' +import { parsePairingCode } from '../../shared/pairing' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' +import { ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { sendRemoteRuntimeRequestWithStatusPreflight } from '../../shared/remote-runtime-client' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' + +type OrcadCatalogMigrationOperation = 'abort' | 'commit' | 'stage' | 'state' + +/** + * A destination that does not offer catalog migration: an older orcad without the capability or + * the method. This is a definite refusal; any other failure, including a lost answer, is not, and + * the caller re-reads the catalog state before deciding anything. + */ +export const ORCAD_MIGRATION_DESTINATION_UNSUPPORTED = 'orcad_migration_destination_unsupported' + +type CatalogRequestOptions = { + signal?: AbortSignal + timeoutMs?: number + expectedRuntimeId?: string +} + +const METHOD_BY_OPERATION: Record = { + abort: 'orcad.migration.abortCatalog', + commit: 'orcad.migration.commitCatalog', + stage: 'orcad.migration.stageCatalog', + state: 'orcad.migration.catalogState' +} + +export function stageRemoteOrcadMigrationCatalog( + pairingCode: string, + manifest: OrcadMigrationManifest, + options: CatalogRequestOptions = {} +): Promise { + return requestCatalogState(pairingCode, 'stage', manifest, options) +} + +export function commitRemoteOrcadMigrationCatalog( + pairingCode: string, + manifest: OrcadMigrationManifest, + options: CatalogRequestOptions = {} +): Promise { + return requestCatalogState(pairingCode, 'commit', manifest, options) +} + +export function readRemoteOrcadMigrationCatalogState( + pairingCode: string, + manifest: OrcadMigrationManifest, + options: CatalogRequestOptions = {} +): Promise { + return requestCatalogState(pairingCode, 'state', manifest, options) +} + +export async function abortRemoteOrcadMigrationCatalog( + pairingCode: string, + manifest: OrcadMigrationManifest, + options: CatalogRequestOptions = {} +): Promise { + const result = await request(pairingCode, 'abort', manifest, options) + const parsed = parseOrcadMigrationCatalogAbortResult(result, manifest) + // Older hosts flush real aborts, but their already-absent responses prove no persistence. + if (parsed.state === 'absent' && !parsed.aborted && parsed.durableAbsent !== true) { + throw new Error('orcad_migration_abort_durability_unverifiable:destination_update_required') + } + return parsed +} + +export async function stageRemoteOrcadMigrationSnapshotChunk( + pairingCode: string, + request: OrcadMigrationSnapshotChunkRequest, + options: CatalogRequestOptions = {} +): Promise { + const pairing = parsePairingCode(pairingCode) + if (!pairing) { + throw new Error('orcad_migration_pairing_code_invalid') + } + const response = await sendSupportedMigrationRequest( + pairing, + 'orcad.migration.stageSnapshotChunk', + request, + options + ) + if (!response.ok) { + throw new Error(`orcad_migration_snapshot_failed:${response.error.message}`) + } + return parseOrcadMigrationSnapshotChunkResult(response.result, request) +} + +async function requestCatalogState( + pairingCode: string, + operation: Exclude, + manifest: OrcadMigrationManifest, + options: CatalogRequestOptions +): Promise { + const result = await request(pairingCode, operation, manifest, options) + return parseOrcadMigrationCatalogState(result, manifest) +} + +async function request( + pairingCode: string, + operation: OrcadCatalogMigrationOperation, + manifest: OrcadMigrationManifest, + options: CatalogRequestOptions +): Promise { + const pairing = parsePairingCode(pairingCode) + if (!pairing) { + throw new Error('orcad_migration_pairing_code_invalid') + } + const response = await sendSupportedMigrationRequest( + pairing, + METHOD_BY_OPERATION[operation], + { manifest }, + options + ) + if (!response.ok) { + throw new Error(`orcad_migration_${operation}_failed:${response.error.message}`) + } + return response.result +} + +async function sendSupportedMigrationRequest( + pairing: NonNullable>, + method: string, + params: unknown, + options: CatalogRequestOptions +): Promise> { + const response = await sendRemoteRuntimeRequestWithStatusPreflight( + pairing, + method, + params, + options.timeoutMs ?? 15_000, + (status) => { + if (!status.ok) { + throw new Error(`orcad_migration_status_failed:${status.error.message}`) + } + if ( + options.expectedRuntimeId !== undefined && + status._meta?.runtimeId !== options.expectedRuntimeId + ) { + throw new Error('orcad_migration_destination_runtime_mismatch') + } + if (!status.result.capabilities?.includes(ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY)) { + throw new Error(ORCAD_MIGRATION_DESTINATION_UNSUPPORTED) + } + }, + undefined, + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + options.signal + ) + if (!response.ok && response.error.code === 'method_not_found') { + throw new Error(ORCAD_MIGRATION_DESTINATION_UNSUPPORTED) + } + if ( + options.expectedRuntimeId !== undefined && + response._meta?.runtimeId !== options.expectedRuntimeId + ) { + throw new Error('orcad_migration_destination_runtime_mismatch') + } + return response +} diff --git a/src/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts index a081eee76ab3..423d5fcc7fce 100644 --- a/src/shared/persisted-state-types.ts +++ b/src/shared/persisted-state-types.ts @@ -23,6 +23,10 @@ import type { RetiredNameRegistry } from './worktree/retired-name-registry' import type { WorkspaceLineage, WorktreeLineage } from './worktree/lineage-types' import type { WorktreeMeta } from './worktree/meta-types' import type { WorkspaceSessionState } from './workspace-session-state-types' +import type { + OrcadMigrationImportReceipt, + OrcadMigrationStagedCatalog +} from './orcad-migration-manifest' export type LegacyPaneKeyAliasEntry = { ptyId: string @@ -109,6 +113,10 @@ export type PersistedState = { legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] automations: Automation[] automationRuns: AutomationRun[] + /** Catalogs an orcad imported, so a retried commit returns the same receipt. */ + orcadMigrationImportReceipts?: OrcadMigrationImportReceipt[] + /** Catalogs staged on an orcad and not yet committed or aborted. */ + orcadMigrationStagedCatalogs?: OrcadMigrationStagedCatalog[] onboarding: OnboardingState /** Main-owned telemetry de-dupe marker; never exposed through PersistedUIState. */ featureInteractionTelemetryBuckets?: FeatureInteractionTelemetryBucketState diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index b44e78b82fd8..b4aeee41758b 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -342,8 +342,13 @@ export const AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY = // this is advertised and otherwise treats the census as unverifiable, never as zero. export const ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY = 'orcad.terminal-census.v1' as const +// Why: an older host answers orcad.migration.* with method-not-found; a client stages, commits or +// 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 + export const RUNTIME_CAPABILITIES = [ ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY, + ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY, ...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES, AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index 07de17250a5a..a87f6f498ca3 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -63,7 +63,8 @@ export function sendRemoteRuntimeRequestWithStatusPreflight( timeoutMs: number, validateStatus: (response: RuntimeRpcResponse) => void, envelope?: RuntimeOrchestrationEnvelope, - clientCapabilities: readonly RuntimeCapability[] = [] + clientCapabilities: readonly RuntimeCapability[] = [], + signal?: AbortSignal ): Promise> { return sendRemoteRuntimeRequestOnSocket( pairing, @@ -72,7 +73,7 @@ export function sendRemoteRuntimeRequestWithStatusPreflight( timeoutMs, envelope, validateStatus, - undefined, + signal, clientCapabilities ) } diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index ca4f857e0fab..93f2e0070d5a 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -23,6 +23,7 @@ import { PairingProvisionRelayParamsSchema } from '../mobile-relay-credential-contract' import { MobileWebBundleChunkParamsSchema } from '../mobile-web-bundle/bundle-rpc-contract' +import { OrcadMigrationSnapshotChunkRequestSchema } from '../orcad-migration-scrollback' import { OrcadTerminalCensusParamsSchema } from '../orcad-terminal-census' import { pluginConsentRequestSchema } from '../plugins/plugin-consent-request' import { @@ -988,6 +989,7 @@ export const RPC_PARAMS_BY_METHOD = { 'notifications.testPush': null, 'notifications.unregisterPush': null, 'notifications.unsubscribe': NotificationUnsubscribeParams, + 'orcad.migration.stageSnapshotChunk': OrcadMigrationSnapshotChunkRequestSchema, 'orcad.terminalCensus': OrcadTerminalCensusParamsSchema, 'orchestration.ask': AskParams, 'orchestration.callerShow': null, @@ -1200,6 +1202,11 @@ export const RPC_PARAMS_BY_METHOD = { // graph reaches into src/main. Listing them keeps the gap visible instead of absent. export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [ 'emulator.install', + 'orcad.migration.abortCatalog', + 'orcad.migration.catalogState', + 'orcad.migration.commitCatalog', + 'orcad.migration.importCatalog', + 'orcad.migration.stageCatalog', 'orchestration.send', 'orchestration.taskUpdate' ] From d205d8483b09185d7e2c3fd9f5413fcfe408f372 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 22:15:16 -0700 Subject: [PATCH 2/2] fix(rpc): catalog the orcad.migration params in the shared contract; name the catalog-import install target Co-Authored-By: Claude Opus 5.5 (1M context) --- .../orcad-catalog-import.ts | 2 +- src/main/runtime/rpc/methods/orcad-migration.ts | 17 ++++++++--------- .../rpc-contract/orcad-migration-params.ts | 5 +++++ .../rpc-params-catalog.generated.ts | 11 ++++++----- 4 files changed, 20 insertions(+), 15 deletions(-) create mode 100644 src/shared/rpc-contract/orcad-migration-params.ts diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts index 598fe45535dc..d6fdf15122d5 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts @@ -302,7 +302,7 @@ function committedCatalogState(receipt: OrcadMigrationImportReceipt): OrcadMigra } export function installOrcadCatalogImportPersistenceContext( - target: object, + target: OrcadCatalogImportPersistence, source: OrcadCatalogImportPersistence ): void { Object.defineProperty(target, orcadCatalogImportContext, { diff --git a/src/main/runtime/rpc/methods/orcad-migration.ts b/src/main/runtime/rpc/methods/orcad-migration.ts index ec968b14d650..9d01b8318e98 100644 --- a/src/main/runtime/rpc/methods/orcad-migration.ts +++ b/src/main/runtime/rpc/methods/orcad-migration.ts @@ -1,15 +1,14 @@ -import { z } from 'zod' +import type { z } from 'zod' +import { OrcadMigrationCatalogParams } from '../../../../shared/rpc-contract/orcad-migration-params' import { parseOrcadMigrationManifest } from '../../../../shared/orcad-migration-manifest' import { OrcadMigrationSnapshotChunkRequestSchema } from '../../../../shared/orcad-migration-scrollback' import { assertOrcadMigrationManifestDigest } from '../../../orcad/orcad-migration-manifest-digest' import { defineMethod, type RpcContext } from '../core' -const OrcadMigrationImport = z.object({ manifest: z.unknown() }) - export const ORCAD_MIGRATION_METHODS = [ defineMethod({ name: 'orcad.migration.importCatalog', - params: OrcadMigrationImport, + params: OrcadMigrationCatalogParams, handler: async (params, context) => { requireMigrationRuntimeClient(context) return context.runtime.importOrcadMigrationCatalog(migrationManifest(params), { @@ -19,7 +18,7 @@ export const ORCAD_MIGRATION_METHODS = [ }), defineMethod({ name: 'orcad.migration.stageCatalog', - params: OrcadMigrationImport, + params: OrcadMigrationCatalogParams, handler: async (params, context) => { requireMigrationRuntimeClient(context) return context.runtime.stageOrcadMigrationCatalog(migrationManifest(params), { @@ -29,7 +28,7 @@ export const ORCAD_MIGRATION_METHODS = [ }), defineMethod({ name: 'orcad.migration.commitCatalog', - params: OrcadMigrationImport, + params: OrcadMigrationCatalogParams, handler: async (params, context) => { requireMigrationRuntimeClient(context) return context.runtime.commitStagedOrcadMigrationCatalog(migrationManifest(params), { @@ -47,7 +46,7 @@ export const ORCAD_MIGRATION_METHODS = [ }), defineMethod({ name: 'orcad.migration.abortCatalog', - params: OrcadMigrationImport, + params: OrcadMigrationCatalogParams, handler: async (params, context) => { requireMigrationRuntimeClient(context) return context.runtime.abortStagedOrcadMigrationCatalog(migrationManifest(params), { @@ -57,7 +56,7 @@ export const ORCAD_MIGRATION_METHODS = [ }), defineMethod({ name: 'orcad.migration.catalogState', - params: OrcadMigrationImport, + params: OrcadMigrationCatalogParams, handler: async (params, context) => { requireMigrationRuntimeClient(context) return context.runtime.getOrcadMigrationCatalogState(migrationManifest(params)) @@ -65,7 +64,7 @@ export const ORCAD_MIGRATION_METHODS = [ }) ] -function migrationManifest(params: z.infer) { +function migrationManifest(params: z.infer) { const manifest = parseOrcadMigrationManifest(params.manifest) assertOrcadMigrationManifestDigest(manifest) return manifest diff --git a/src/shared/rpc-contract/orcad-migration-params.ts b/src/shared/rpc-contract/orcad-migration-params.ts new file mode 100644 index 000000000000..4a9b6087abab --- /dev/null +++ b/src/shared/rpc-contract/orcad-migration-params.ts @@ -0,0 +1,5 @@ +import { z } from 'zod' + +// Why unknown: the handler parses and digest-checks the manifest itself, so a malformed one is +// refused with a migration error rather than a generic params failure. +export const OrcadMigrationCatalogParams = z.object({ manifest: z.unknown() }) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 93f2e0070d5a..d6e2eae2ee8a 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -354,6 +354,7 @@ import { NotificationUnsubscribeParams, NotificationsSubscribeParams } from './notifications-params' +import { OrcadMigrationCatalogParams } from './orcad-migration-params' import { FederationDispatchParams, FederationFleetSnapshotParams, @@ -989,6 +990,11 @@ export const RPC_PARAMS_BY_METHOD = { 'notifications.testPush': null, 'notifications.unregisterPush': null, 'notifications.unsubscribe': NotificationUnsubscribeParams, + 'orcad.migration.abortCatalog': OrcadMigrationCatalogParams, + 'orcad.migration.catalogState': OrcadMigrationCatalogParams, + 'orcad.migration.commitCatalog': OrcadMigrationCatalogParams, + 'orcad.migration.importCatalog': OrcadMigrationCatalogParams, + 'orcad.migration.stageCatalog': OrcadMigrationCatalogParams, 'orcad.migration.stageSnapshotChunk': OrcadMigrationSnapshotChunkRequestSchema, 'orcad.terminalCensus': OrcadTerminalCensusParamsSchema, 'orchestration.ask': AskParams, @@ -1202,11 +1208,6 @@ export const RPC_PARAMS_BY_METHOD = { // graph reaches into src/main. Listing them keeps the gap visible instead of absent. export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [ 'emulator.install', - 'orcad.migration.abortCatalog', - 'orcad.migration.catalogState', - 'orcad.migration.commitCatalog', - 'orcad.migration.importCatalog', - 'orcad.migration.stageCatalog', 'orchestration.send', 'orchestration.taskUpdate' ]