diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts index 427d87655ce..6751e65d93a 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts @@ -26,6 +26,8 @@ export type OrcadMigrationSourceAutomationInspection = { automationRuns: AutomationRun[] blockedAutomationCount: number blockedRunCount: number + /** Moved disabled, as the wire requires; the move re-enables these on the host after commit. */ + enabledAtSourceIds: string[] } export function collectOrcadMigrationSourceAutomationState( @@ -47,6 +49,7 @@ export function collectOrcadMigrationSourceAutomationState( const automationRuns: AutomationRun[] = [] let blockedAutomationCount = 0 let blockedRunCount = 0 + const enabledAtSourceIds: string[] = [] for (const [automationId, matching] of automationsById) { const runs = runsByAutomationId.get(automationId) ?? [] @@ -59,7 +62,12 @@ export function collectOrcadMigrationSourceAutomationState( blockedRunCount += runs.length continue } - automations.push(projectAutomationToDestination(matching[0])) + const projected = projectAutomationToDestination(matching[0]) + if (scope.kind === 'local-wsl' && projected.enabled) { + enabledAtSourceIds.push(projected.id) + projected.enabled = false + } + automations.push(projected) automationRuns.push(...runs.map(projectAutomationRunToDestination)) } @@ -70,7 +78,14 @@ export function collectOrcadMigrationSourceAutomationState( } automations.sort((left, right) => compareKeys(left.id, right.id)) automationRuns.sort((left, right) => compareKeys(left.id, right.id)) - return { automations, automationRuns, blockedAutomationCount, blockedRunCount } + enabledAtSourceIds.sort(compareKeys) + return { + automations, + automationRuns, + blockedAutomationCount, + blockedRunCount, + enabledAtSourceIds + } } export function automationTouchesScope( diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts index 42fc2b861e4..2118e87e5ad 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts @@ -45,6 +45,8 @@ export type OrcadMigrationSourceDormantInspection = { blockedSessionItems: string[] /** What a confirmed "Move anyway" leaves in this desktop's retained profile. */ leftBehindItems: string[] + /** Automations a local WSL move carries disabled, to re-enable on the host once it commits. */ + automationsEnabledAtSource: string[] } /** The user confirmed "Move anyway" for this host: what can't move stays on this desktop. */ @@ -53,8 +55,11 @@ export function orcadMigrationLeavesBehind( targetId: string | null ): boolean { // A local WSL move has no "Move anyway": what can't move blocks it. - return targetId !== null && state.sshTargets.some( - (target) => target.id === targetId && target.managedServerLeaveBehind !== undefined + return ( + targetId !== null && + state.sshTargets.some( + (target) => target.id === targetId && target.managedServerLeaveBehind !== undefined + ) ) } @@ -241,7 +246,8 @@ export function collectOrcadMigrationSourceDormantState( }, blockedCounts, blockedSessionItems, - leftBehindItems + leftBehindItems, + automationsEnabledAtSource: automationState.enabledAtSourceIds } } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts index 4301a673af2..8c112acbae5 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts @@ -20,7 +20,10 @@ import { collectOrcadMigrationUntransferredDependencyCensus, type OrcadMigrationSourceDependencyCensus } from './orcad-source-dependency-census' -import { collectOrcadMigrationSourceDormantState } from './orcad-source-dormant-state' +import { + collectOrcadMigrationSourceDormantState, + type OrcadMigrationSourceDormantInspection +} from './orcad-source-dormant-state' import { readOrcadMigrationSourceScrollbackChunk } from './orcad-source-scrollback-state' import { createOrcadMigrationDeltaView, @@ -62,6 +65,22 @@ export class OrcadSourceExportPersistence { ).payload } + /** The export plus what it could not carry, for a move that must refuse rather than drop rows. */ + inspectOrcadMigrationSourceDormantState( + source: OrcadMigrationManifestSource, + catalog: OrcadMigrationCatalogPayload, + destinationEnvironmentId?: string + ): OrcadMigrationSourceDormantInspection { + const runtime = this[orcadSourceExportContext] + return collectOrcadMigrationSourceDormantState( + runtime.state, + source, + catalog, + runtime.terminalScrollbackSnapshotStorage, + destinationEnvironmentId + ) + } + /** This desktop's drafts for what `manifest` moves, keyed as the server names the worktrees. */ collectOrcadMigrationSourceDrafts(manifest: OrcadMigrationManifest): OrcadMigrationSourceDrafts { return collectOrcadMigrationSourceDrafts(this[orcadSourceExportContext].state, manifest) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts new file mode 100644 index 00000000000..819a91abd2d --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Repo } from '../../../shared/repo-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import { assertOrcadMigrationManifestDigest } from '../../orcad/orcad-migration-manifest-digest' +import { + DORMANT_AUTOMATION, + DORMANT_AUTOMATION_RUN +} from '../../persistence-orcad-migration-catalog-fixture' +import { createOrcadMigrationSourceScope } from '../migrating-orcad-catalog/orcad-source-scope' +import { collectOrcadMigrationSourceDormantState } from '../migrating-orcad-catalog/orcad-source-dormant-state' +import { + exportWslMoveManifest, + WslMoveScopeChangedError, + type WslMoveManifestExportRequest, + type WslMoveManifestExportStore +} from './wsl-move-manifest-export' + +const DISTRO = 'Ubuntu' +const UNC_REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\app' +const UNC_WORKTREE = '\\\\wsl.localhost\\Ubuntu\\home\\me\\app-feature' +const WORKTREE = `app::${UNC_WORKTREE}` +const MAIN_WORKTREE = `app::${UNC_REPO}` +const OTHER_WORKTREE = 'win::C:\\code\\win' + +function repo(id: string, path: string, extra: Partial = {}): Repo { + return { id, path, displayName: id, badgeColor: '#737373', addedAt: 1, ...extra } +} + +function tab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + startupCwd: worktreeId.split('::')[1] + } +} + +function sourceState(): PersistedState { + const state = getDefaultPersistedState('C:\\Users\\me') + state.repos = [ + repo('app', UNC_REPO), + repo('win', 'C:\\code\\win'), + repo('elsewhere', '\\\\wsl.localhost\\Debian\\home\\me\\other') + ] + state.worktreeMeta[WORKTREE] = { + displayName: 'Feature', + comment: 'keep', + isUnread: false, + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isPinned: true, + sortOrder: 1, + lastActivityAt: 2 + } + state.worktreeMeta[OTHER_WORKTREE] = { ...state.worktreeMeta[WORKTREE], displayName: 'Windows' } + state.automations = [ + { + ...DORMANT_AUTOMATION, + id: 'automation-app', + enabled: true, + projectId: 'app', + runContext: { + ...DORMANT_AUTOMATION.runContext!, + repoId: 'app', + projectHostSetupId: 'app', + path: UNC_REPO + }, + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service' + }, + { + ...DORMANT_AUTOMATION, + id: 'automation-win', + enabled: true, + projectId: 'win', + runContext: { + ...DORMANT_AUTOMATION.runContext!, + repoId: 'win', + projectHostSetupId: 'win', + path: 'C:\\code\\win' + }, + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service' + } + ] + state.automationRuns = [ + { + ...DORMANT_AUTOMATION_RUN, + automationId: 'automation-app', + runContext: state.automations[0]!.runContext + } + ] + state.workspaceSession = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE]: [tab('feature-shell', WORKTREE)], + [OTHER_WORKTREE]: [tab('windows-shell', OTHER_WORKTREE)] + } + } + return state +} + +function storeFor(state: PersistedState): WslMoveManifestExportStore { + return { + getRepos: () => state.repos, + getFolderWorkspaces: () => state.folderWorkspaces, + getProjectGroups: () => state.projectGroups, + inspectOrcadMigrationSourceDormantState: (source, catalog, destination) => + collectOrcadMigrationSourceDormantState(state, source, catalog, undefined, destination) + } +} + +const REQUEST: WslMoveManifestExportRequest = { + migrationId: 'move-1', + now: () => new Date('2026-10-20T00:00:00.000Z'), + distro: DISTRO, + distroGuid: '{guid}', + destinationEnvironmentId: 'wsl:Ubuntu', + generation: 2, + repoIds: ['app'], + folderWorkspaceIds: [] +} + +describe('exportWslMoveManifest', () => { + it('signs a manifest whose source is exactly the request, with Linux paths', () => { + const result = exportWslMoveManifest(storeFor(sourceState()), REQUEST) + expect(result.blockers).toEqual([]) + expect(result.manifest.source).toEqual({ + kind: 'local-wsl', + distro: DISTRO, + distroGuid: '{guid}', + scopeRepoIds: ['app'], + scopeFolderWorkspaceIds: [], + generation: 2 + }) + expect(result.manifest.destinationEnvironmentId).toBe('wsl:Ubuntu') + expect(result.manifest.payload.repositories.map((row) => row.path)).toEqual(['/home/me/app']) + expect(() => assertOrcadMigrationManifestDigest(result.manifest)).not.toThrow() + }) + + it('is deterministic for one state and request', () => { + const state = sourceState() + expect(exportWslMoveManifest(storeFor(state), REQUEST).manifest.manifestSha256).toBe( + exportWslMoveManifest(storeFor(structuredClone(state)), REQUEST).manifest.manifestSha256 + ) + }) + + it("carries the moved project's dormant state and none of another local project's", () => { + const dormant = exportWslMoveManifest(storeFor(sourceState()), REQUEST).manifest.payload + .dormantState + const linuxWorktree = 'app::/home/me/app-feature' + expect(dormant?.worktreeMeta.map((row) => row.worktreeId)).toEqual([linuxWorktree]) + expect(Object.keys(dormant?.workspaceSession?.tabsByWorktree ?? {})).toEqual([linuxWorktree]) + expect(dormant?.automations?.map((row) => row.id)).toEqual(['automation-app']) + expect(dormant?.automations?.[0]).toMatchObject({ + enabled: false, + schedulerOwner: 'remote_host_service', + executionTargetType: 'local' + }) + expect(dormant?.automationRuns?.map((row) => row.automationId)).toEqual(['automation-app']) + }) + + it('re-enables on the host what this desktop ran enabled', () => { + expect(exportWslMoveManifest(storeFor(sourceState()), REQUEST).automationsToReenable).toEqual([ + 'automation-app' + ]) + }) + + it('names the workspaces to quiesce and where each worktree lands', () => { + const result = exportWslMoveManifest(storeFor(sourceState()), REQUEST) + expect(result.workspaceKeys).toEqual([MAIN_WORKTREE, WORKTREE].sort()) + expect(result.workspaceIds).toEqual(result.workspaceKeys) + expect(result.worktrees).toContainEqual({ + sourceWorktreeId: WORKTREE, + destinationWorktreeId: 'app::/home/me/app-feature' + }) + }) + + it('never takes another host’s row of the same repo id', () => { + const state = sourceState() + state.repos.push(repo('app', '/srv/app', { connectionId: 'box', executionHostId: 'ssh:box' })) + const result = exportWslMoveManifest(storeFor(state), REQUEST) + expect(result.manifest.payload.repositories.map((row) => row.path)).toEqual(['/home/me/app']) + }) + + it('refuses with a reason instead of dropping state the move cannot carry', () => { + const state = sourceState() + const record: SleepingAgentSessionRecord = { + paneKey: 'feature-shell:missing-leaf', + tabId: 'feature-shell', + worktreeId: WORKTREE, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'Saved prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + connectionId: null, + origin: 'quit' + } + state.workspaceSession.terminalLayoutsByTabId = { + 'feature-shell': { + root: { type: 'leaf', leafId: 'other' }, + activeLeafId: 'other', + expandedLeafId: null + } + } + state.workspaceSession.sleepingAgentSessionsByPaneKey = { [record.paneKey]: record } + expect(exportWslMoveManifest(storeFor(state), REQUEST).blockers.length).toBeGreaterThan(0) + }) + + it('refuses to sign a scope naming a project that is no longer here', () => { + expect(() => + exportWslMoveManifest(storeFor(sourceState()), { ...REQUEST, repoIds: ['app', 'gone'] }) + ).toThrow(WslMoveScopeChangedError) + }) + + it('still keeps the SSH conversion steps from accepting a WSL manifest', () => { + const { manifest } = exportWslMoveManifest(storeFor(sourceState()), REQUEST) + expect(() => + createOrcadMigrationSourceScope({ + source: manifest.source, + catalog: manifest.payload, + repos: [] + }) + ).toThrow('orcad_migration_source_not_ssh') + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.ts b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.ts new file mode 100644 index 00000000000..d0136992062 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.ts @@ -0,0 +1,246 @@ +/** + * The signed manifest a local WSL move carries into the distro's orcad (mover design, section 2). + * + * Reads only. The same dormant-state collectors the SSH conversion uses, through a local-WSL scope, + * then the UNC→Linux rewrite. Anything those collectors cannot carry comes back as a blocker: a move + * refuses instead of dropping a row. Deterministic for one store state and request, so the driver can + * re-export under the fence and compare digests. + */ +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { resolveFolderWorkspaceHost } from '../../../shared/folder-workspace-execution-host' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { + OrcadMigrationCatalogPayload, + OrcadMigrationLocalWslManifestSource, + OrcadMigrationManifest +} from '../../../shared/orcad-migration/orcad-migration-manifest' +import { + ORCAD_MIGRATION_MANIFEST_VERSION, + parseOrcadMigrationManifest +} from '../../../shared/orcad-migration/orcad-migration-manifest-validation' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { Repo } from '../../../shared/repo-types' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { getRepoIdFromWorktreeId, getRepoMainWorktreeId } from '../../../shared/worktree/id' +import { computeOrcadMigrationManifestSha256 } from '../../orcad/orcad-migration-manifest-digest' +import { hasDormantState } from '../../ssh/orcad-migration-manifest-export' +import { + rewriteOrcadMigrationPayloadPaths, + rewriteOrcadMigrationWorktreeId +} from '../../ssh/orcad-migration-path-rewrite' +import type { Store } from '../../persistence' +import { compareKeys } from '../migrating-orcad-catalog/orcad-source-key-order' +import { createUncToLinuxPathRewriter, wslUncPathToLinux } from './wsl-move-path-rewriter' + +export type WslMoveManifestExportRequest = { + migrationId: string + now: () => Date + distro: string + /** The host record's registry id, when known. */ + distroGuid?: string + /** The `wsl:` environment id. */ + destinationEnvironmentId: string + /** planWslMove's plan.generation. */ + generation: number + /** plan.repoIds: the exact scope. */ + repoIds: readonly string[] + folderWorkspaceIds: readonly string[] +} + +export type WslMoveManifestExport = { + /** Signed; its source scope is exactly the request's, its paths rewritten to the distro's. */ + manifest: OrcadMigrationManifest + /** User-facing reasons for state the manifest cannot carry. Non-empty: the driver refuses. */ + blockers: string[] + /** What the Sleep relay takes: every worktree id and `folder:` key in scope (UNC form). */ + workspaceIds: string[] + /** The same workspaces as pty session ids mint them: worktree ids and `folder:` keys. */ + workspaceKeys: string[] + /** For shell-history blobs: each UNC worktree id and the Linux one the host holds. */ + worktrees: { sourceWorktreeId: string; destinationWorktreeId: string }[] + /** Moved disabled (the wire carries no enabled automation); re-enable on the host after commit. */ + automationsToReenable: string[] +} + +/** The plan names a project or folder that is no longer a local row of this distro. */ +export class WslMoveScopeChangedError extends Error { + constructor(readonly missingIds: readonly string[]) { + super('Some projects in this move changed since it was planned. Review the move again.') + this.name = 'WslMoveScopeChangedError' + } +} + +export type WslMoveManifestExportStore = Pick< + Store, + | 'getRepos' + | 'getFolderWorkspaces' + | 'getProjectGroups' + | 'inspectOrcadMigrationSourceDormantState' +> + +const DORMANT_KIND_WORDS: Record = { + 'worktree-metadata': 'saved workspace details', + 'worktree-lineage': 'workspace history', + 'workspace-lineage': 'workspace history', + 'workspace-session': 'saved tabs and panes', + automation: 'automations', + 'automation-run': 'automation run history', + 'sparse-preset': 'sparse checkout presets', + 'retired-worktree-name': 'retired workspace names', + 'mobile-tab-selection': 'phone tab selections', + 'ui-routing': 'sidebar settings', + 'saved-port-forward': 'saved port forwards' +} + +export function exportWslMoveManifest( + store: WslMoveManifestExportStore, + request: WslMoveManifestExportRequest +): WslMoveManifestExport { + const blockers: string[] = [] + const missing: string[] = [] + const repos = pickLocalRows(store.getRepos(), request.repoIds, isLocalRepo, (id) => + missing.push(id) + ) + const allGroups = store.getProjectGroups() + const allFolders = store.getFolderWorkspaces() + const folderWorkspaces = pickLocalRows( + allFolders, + request.folderWorkspaceIds, + (workspace) => + resolveFolderWorkspaceHost( + { repos: store.getRepos(), projectGroups: allGroups, folderWorkspaces: [workspace] }, + workspace.id + ).kind === 'local', + (id) => missing.push(id) + ) + // The signed scope must name exactly the rows it carries: a stale plan is replanned, not signed. + if (missing.length > 0) { + throw new WslMoveScopeChangedError(missing) + } + const catalog: OrcadMigrationCatalogPayload = { + repositories: repos, + projectGroups: referencedGroups(allGroups, [...repos, ...folderWorkspaces]), + folderWorkspaces + } + const source: OrcadMigrationLocalWslManifestSource = { + kind: 'local-wsl', + distro: request.distro, + ...(request.distroGuid ? { distroGuid: request.distroGuid } : {}), + scopeRepoIds: [...request.repoIds], + scopeFolderWorkspaceIds: [...request.folderWorkspaceIds], + generation: request.generation + } + const inspection = store.inspectOrcadMigrationSourceDormantState( + source, + catalog, + request.destinationEnvironmentId + ) + blockers.push(...inspection.blockedSessionItems, ...inspection.leftBehindItems) + for (const [kind, count] of Object.entries(inspection.blockedCounts)) { + if (count > 0 && kind !== 'workspace-session') { + blockers.push( + `${count} ${DORMANT_KIND_WORDS[kind] ?? kind} can't move with this project yet.` + ) + } + } + const dormantState = inspection.payload + const rewriter = createUncToLinuxPathRewriter(request.distro) + const payload = rewriteOrcadMigrationPayloadPaths( + { ...catalog, ...(hasDormantState(dormantState) ? { dormantState } : {}) }, + rewriter + ) + const unsigned = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: request.migrationId, + createdAt: request.now().toISOString(), + source, + payload, + destinationEnvironmentId: request.destinationEnvironmentId + } + const manifest = parseOrcadMigrationManifest({ + ...unsigned, + manifestSha256: computeOrcadMigrationManifestSha256(unsigned) + }) + const worktreeIds = scopedWorktreeIds(repos, { ...catalog, dormantState }) + const workspaceKeys = [ + ...worktreeIds, + ...folderWorkspaces.map((workspace) => `folder:${workspace.id}`).sort(compareKeys) + ] + return { + manifest, + blockers, + workspaceIds: workspaceKeys, + workspaceKeys, + worktrees: worktreeIds.map((sourceWorktreeId) => ({ + sourceWorktreeId, + destinationWorktreeId: rewriteOrcadMigrationWorktreeId(sourceWorktreeId, rewriter) + })), + automationsToReenable: inspection.automationsEnabledAtSource + } + + function isLocalRepo(repo: Repo): boolean { + return ( + getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID && + wslUncPathToLinux(repo.path, request.distro) !== null + ) + } +} + +/** The scope's own rows, in store order; another host's row of the same id is never taken. */ +function pickLocalRows( + rows: readonly T[], + ids: readonly string[], + isLocal: (row: T) => boolean, + onMissing: (id: string) => void +): T[] { + const wanted = new Set(ids) + const picked = rows.filter((row) => wanted.has(row.id) && isLocal(row)) + const found = new Set(picked.map((row) => row.id)) + for (const id of ids) { + if (!found.has(id)) { + onMissing(id) + } + } + return picked.map((row) => structuredClone(row)) +} + +/** Groups the moved rows sit in, with their ancestors; copied, so rows left here keep theirs. */ +function referencedGroups( + groups: readonly ProjectGroup[], + rows: readonly (Pick | Pick)[] +): ProjectGroup[] { + const byId = new Map(groups.map((group) => [group.id, group])) + const taken = new Set() + for (const row of rows) { + let id: string | null | undefined = row.projectGroupId + while (id && !taken.has(id) && byId.has(id)) { + taken.add(id) + id = byId.get(id)?.parentGroupId + } + } + return groups.filter((group) => taken.has(group.id)).map((group) => structuredClone(group)) +} + +/** Every worktree id in scope the store names: each repo's main worktree and any saved for it. */ +function scopedWorktreeIds( + repos: readonly Repo[], + payload: OrcadMigrationCatalogPayload +): string[] { + const repoIds = new Set(repos.map((repo) => repo.id)) + const ids = new Set(repos.map((repo) => getRepoMainWorktreeId(repo))) + const dormant = payload.dormantState + const named = [ + ...(dormant?.worktreeMeta.map((row) => row.worktreeId) ?? []), + ...(dormant?.worktreeLineage.map((row) => row.worktreeId) ?? []), + ...Object.keys(dormant?.workspaceSession?.tabsByWorktree ?? {}), + ...Object.keys(dormant?.workspaceSession?.openFilesByWorktree ?? {}) + ] + for (const key of named) { + const workspace = parseWorkspaceKey(key) + const worktreeId = workspace?.type === 'worktree' ? workspace.worktreeId : key + if (workspace?.type !== 'folder' && repoIds.has(getRepoIdFromWorktreeId(worktreeId))) { + ids.add(worktreeId) + } + } + return [...ids].sort(compareKeys) +} diff --git a/src/main/ssh/orcad-migration-manifest-collector-golden.test.ts b/src/main/ssh/orcad-migration-manifest-collector-golden.test.ts index 2dd1416db5f..4dc8efabe23 100644 --- a/src/main/ssh/orcad-migration-manifest-collector-golden.test.ts +++ b/src/main/ssh/orcad-migration-manifest-collector-golden.test.ts @@ -92,7 +92,14 @@ function richState(): PersistedState { createdAt: 1 } state.sparsePresetsByRepo['repo-1'] = [ - { id: 'preset', repoId: 'repo-1', name: 'Src', directories: ['src'], createdAt: 1, updatedAt: 2 } + { + id: 'preset', + repoId: 'repo-1', + name: 'Src', + directories: ['src'], + createdAt: 1, + updatedAt: 2 + } ] state.automations = [ { @@ -105,7 +112,10 @@ function richState(): PersistedState { } ] state.automationRuns = [ - { ...DORMANT_AUTOMATION_RUN, runContext: { ...DORMANT_AUTOMATION_RUN.runContext!, hostId: HOST } } + { + ...DORMANT_AUTOMATION_RUN, + runContext: { ...DORMANT_AUTOMATION_RUN.runContext!, hostId: HOST } + } ] const host = getDefaultWorkspaceSession() host.activeRepoId = 'repo-1' @@ -137,7 +147,12 @@ function richState(): PersistedState { function sharedRepoState(): PersistedState { const state = richState() // The same repo id registered on this desktop too: legacy unqualified keys stay put. - state.repos.push({ ...REPOSITORY, connectionId: undefined, executionHostId: 'local', path: '/home/user/repo-1' }) + state.repos.push({ + ...REPOSITORY, + connectionId: undefined, + executionHostId: 'local', + path: '/home/user/repo-1' + }) state.worktreeMeta['repo-1::/srv/repo-1-unqualified'] = meta() const child = worktreeWorkspaceKey(WORKTREE) state.workspaceLineageByChildKey[child] = { diff --git a/src/main/ssh/orcad-migration-manifest-export.ts b/src/main/ssh/orcad-migration-manifest-export.ts index 249650096a0..0b394ac4087 100644 --- a/src/main/ssh/orcad-migration-manifest-export.ts +++ b/src/main/ssh/orcad-migration-manifest-export.ts @@ -207,7 +207,7 @@ function restrictCatalog( } } -function hasDormantState(state: OrcadMigrationDormantStatePayload): boolean { +export function hasDormantState(state: OrcadMigrationDormantStatePayload): boolean { return ( state.worktreeMeta.length > 0 || state.worktreeLineage.length > 0 ||