mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
feat(wsl-mover): export a local WSL move's signed manifest with its dormant state (D1b-3c)
Collects the scoped projects, their groups and every dormant-state kind through the local-WSL export scope, rewrites UNC paths to the distro's, and returns blockers for anything the collectors can't carry instead of dropping it. Automations move disabled, as the wire requires, and come back as a list to re-enable on the host after commit. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> = {}): 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')
|
||||
})
|
||||
})
|
||||
@@ -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:<distro>` 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:<id>` key in scope (UNC form). */
|
||||
workspaceIds: string[]
|
||||
/** The same workspaces as pty session ids mint them: worktree ids and `folder:<id>` 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<string, string> = {
|
||||
'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<T extends { id: string }>(
|
||||
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<Repo, 'projectGroupId'> | Pick<FolderWorkspace, 'projectGroupId'>)[]
|
||||
): ProjectGroup[] {
|
||||
const byId = new Map(groups.map((group) => [group.id, group]))
|
||||
const taken = new Set<string>()
|
||||
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)
|
||||
}
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user