diff --git a/.github/workflows/wsl-orcad-e2e.yml b/.github/workflows/wsl-orcad-e2e.yml index a34f89e8258..fc96f7ea76f 100644 --- a/.github/workflows/wsl-orcad-e2e.yml +++ b/.github/workflows/wsl-orcad-e2e.yml @@ -34,6 +34,7 @@ on: - '!**/*.test.ts' - '!**/*.test.tsx' - 'src/main/wsl/wsl-orcad-real-distro-lane.test.ts' + - 'src/main/persistence/moving-wsl-projects/wsl-move-real-pause.test.ts' - 'src/main/wsl/wsl-orcad-real-distro-idle-release-lane.test.ts' - '.github/workflows/wsl-orcad-e2e.yml' - 'config/ci/wsl-lifecycle/**' @@ -341,6 +342,18 @@ jobs: run: | node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/wsl/wsl-orcad-real-distro-lane.test.ts --reporter=verbose 2>&1 | Tee-Object (Join-Path $env:ORCA_WSL_ORCAD_LANE_RECEIPTS 'survive.log') if ($global:LASTEXITCODE -ne 0) { throw "survive phase failed: $global:LASTEXITCODE" } + # The WSL move's pause on real distro shells: Sleep ends them, the daemon proves the exit, + # the journal keeps each pane's folder, and a new shell opens there. + - name: 'WSL move: pause real distro terminals' + if: ${{ !cancelled() && steps.import.outcome == 'success' }} + timeout-minutes: 10 + env: + ORCA_WSL_MOVE_REAL_PAUSE_DISTRO: ${{ env.PLAIN_DISTRO }} + run: | + New-Item -ItemType Directory -Force $env:ORCA_WSL_ORCAD_LANE_RECEIPTS | Out-Null + node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/persistence/moving-wsl-projects/wsl-move-real-pause.test.ts --reporter=verbose 2>&1 | Tee-Object (Join-Path $env:ORCA_WSL_ORCAD_LANE_RECEIPTS 'wsl-move-real-pause.log') + if ($global:LASTEXITCODE -ne 0) { throw "WSL move real pause failed: $global:LASTEXITCODE" } + wsl.exe --terminate $env:PLAIN_DISTRO # R40: with no terminal left, orcad idle-exits and the holder lets the distro stop on its own. - name: 'Phase 3: idle orcad releases the distro' timeout-minutes: 15 diff --git a/config/main-layout-writer-baseline.txt b/config/main-layout-writer-baseline.txt index ea882afe8c7..ee403fa89f4 100644 --- a/config/main-layout-writer-baseline.txt +++ b/config/main-layout-writer-baseline.txt @@ -21,7 +21,7 @@ 1 src/main/persistence/loading-store/metadata-lineage-operations.ts 1 src/main/persistence/loading-store/mobile-tab-selection-persistence.ts 1 src/main/persistence/loading-store/normalize-loaded-profile-state.ts -10 src/main/persistence/loading-store/pty-binding-persistence.ts +8 src/main/persistence/loading-store/pty-binding-persistence.ts 10 src/main/persistence/loading-store/pty-binding-session-update.ts 10 src/main/persistence/loading-store/repo-lifecycle-operations.ts 10 src/main/persistence/loading-store/session-host-partitions.ts @@ -40,6 +40,7 @@ 3 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts 4 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-subtraction.ts 1 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts +2 src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.ts 1 src/main/persistence/restoring-sessions/folder-workspace-operations.ts 1 src/main/persistence/restoring-sessions/invalid-workspace-layout-purge.ts 5 src/main/persistence/restoring-sessions/session-owner-fields.ts diff --git a/src/main/agent-hooks/managed-agent-hook-controls.test.ts b/src/main/agent-hooks/managed-agent-hook-controls.test.ts index a667d395434..663e49a0c32 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.test.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.test.ts @@ -315,6 +315,23 @@ describe('managed agent hook controls', () => { expect(mocks.removeCodex).toHaveBeenCalledTimes(1) }) + // R42: on a WSL host the bridge's panes in the same distro still run on these entries. + it('keeps shared entries when told to: off skips, and a disabled agent keeps its entry', async () => { + mocks.detect.mockResolvedValue({ codex: { state: 'found' } }) + mocks.statusClaude.mockReturnValue({ agent: 'claude', state: 'installed' }) + mocks.statusCodex.mockReturnValue({ agent: 'codex', state: 'installed' }) + + await applyAgentStatusHooksEnabled(false, null, { keepSharedEntries: true }) + await applyAgentStatusHooksEnabled( + true, + { agentCmdOverrides: {}, disabledTuiAgents: ['claude'] }, + { keepSharedEntries: true } + ) + + expect(mocks.removeClaude).not.toHaveBeenCalled() + expect(mocks.removeCodex).not.toHaveBeenCalled() + }) + it('awaits only the selected asynchronous removers during quit', async () => { let finishRemoval: ((value: ReturnType) => void) | undefined mocks.removeCodexAsync.mockImplementationOnce( diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 04f945bb86b..8ce0b92d611 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -40,6 +40,11 @@ type InstallOptions = { agents?: readonly AgentHookTarget[] } +type ApplyOptions = InstallOptions & { + /** Off means skip, never remove: another Orca's panes run on these same user-global entries. */ + keepSharedEntries?: boolean +} + type RemoveOptions = { agents?: readonly AgentHookTarget[] } @@ -244,10 +249,12 @@ export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] { export async function applyAgentStatusHooksEnabled( enabled: boolean, settings: ManagedHookSettings = null, - options: InstallOptions = {} + options: ApplyOptions = {} ): Promise { if (!enabled) { - return await removeManagedAgentHooks() + return options.keepSharedEntries + ? getManagedAgentHookStatuses() + : await removeManagedAgentHooks() } const disabled = normalizeDisabledTuiAgents(settings?.disabledTuiAgents).filter( isManagedAgentHookTarget @@ -256,7 +263,7 @@ export async function applyAgentStatusHooksEnabled( const disabledToRemove = options.shouldContinue ? disabled.filter((agent) => !options.shouldContinue?.(agent)) : disabled - if (disabledToRemove.length === 0) { + if (disabledToRemove.length === 0 || options.keepSharedEntries) { return installed } const removed = new Map( diff --git a/src/main/automations/automation-wsl-move-refusal.test.ts b/src/main/automations/automation-wsl-move-refusal.test.ts index 0fd48e43d2c..40d96900b32 100644 --- a/src/main/automations/automation-wsl-move-refusal.test.ts +++ b/src/main/automations/automation-wsl-move-refusal.test.ts @@ -13,6 +13,14 @@ import { loadWslMoveFence, resetWslMoveFenceForTests } from '../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { runWslMoveDesktopRewritePass } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite' +import { resetWslMoveDesktopRewriteStoresForTests } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores' +import { registerWslMoveMainRewriteStores } from '../persistence/moving-wsl-projects/wsl-move-main-rewrite-stores' +import { registerWslMoveOrchestrationHistoryStore } from '../persistence/moving-wsl-projects/wsl-move-orchestration-history-rewrite' +import { orchestrationDbFile } from '../runtime/orchestration/db/orchestration-db-file' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate' +import { OrchestrationDb } from '../runtime/orchestration/db' +import Database from '../sqlite/sync-database' import { AutomationService } from './service' import { WSL_MOVE_IN_PROGRESS, WSL_MOVE_OWNED_BY_HOST } from './dispatch-refusal' import type * as PrecheckRunner from './precheck-runner' @@ -146,6 +154,45 @@ describe('scheduled automations under a WSL move', () => { ) }) + it('keeps the local copy unchanged through the desktop rewrite, so it neither runs nor loses its next run', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + const store = await createStore() + const automation = seedAutomation(store) + seedWslMoveCutover( + fenceDir, + cutover('move-1', 'destination-committed', {}, { repoIds: ['r1'] }) + ) + const databasePath = orchestrationDbFile(fenceDir) + // A dispatch in the moved repo, so the orchestration store has real work to do. + new OrchestrationDb(databasePath).close() + const seed = new Database(databasePath) + seed + .prepare(`INSERT INTO worker_dispatches (dispatch_id, state, worktree_id) VALUES (?, ?, ?)`) + .run('d-1', 'succeeded', 'r1::\\\\wsl.localhost\\Ubuntu\\home\\dev\\r1') + seed.close() + registerWslMoveMainRewriteStores(store) + registerWslMoveOrchestrationHistoryStore(databasePath) + try { + await runWslMoveDesktopRewritePass({ userDataPath: fenceDir }) + const read = new Database(databasePath) + expect(read.prepare(`SELECT worktree_id FROM worker_dispatches`).get()).toEqual({ + worktree_id: 'r1::/home/dev/r1' + }) + read.close() + expect(store.listAutomations()).toEqual([automation]) + const service = attachedService(store) + + await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00') + + expect(store.listAutomationRuns(automation.id)).toEqual([]) + expect(store.listAutomations()[0]?.nextRunAt).toBe(new Date('2026-05-14T09:00:00').getTime()) + } finally { + resetWslMoveDesktopRewriteStoresForTests() + vi.unstubAllEnvs() + } + }) + it('skips, not fails, a headless run whose move starts during the precheck', async () => { vi.setSystemTime(new Date('2026-05-13T08:00:00')) const store = await createStore() diff --git a/src/main/browser/browser-cdp-cookie.ts b/src/main/browser/browser-cdp-cookie.ts new file mode 100644 index 00000000000..b72c956c964 --- /dev/null +++ b/src/main/browser/browser-cdp-cookie.ts @@ -0,0 +1,19 @@ +/** A cookie as CDP's Network domain reports it: unlike `cookies.get()`, with its partition key. */ +export type CdpCookiePartitionKey = { + topLevelSite?: string + hasCrossSiteAncestor?: boolean +} + +export type CdpCookie = { + name: string + value: string + domain?: string + path?: string + secure?: boolean + httpOnly?: boolean + session?: boolean + expires?: number + sameSite?: string + partitionKey?: CdpCookiePartitionKey | null + partitionKeyOpaque?: boolean +} diff --git a/src/main/browser/browser-partition-cookie-copy.test.ts b/src/main/browser/browser-partition-cookie-copy.test.ts new file mode 100644 index 00000000000..348aa637247 --- /dev/null +++ b/src/main/browser/browser-partition-cookie-copy.test.ts @@ -0,0 +1,247 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CdpCookie } from './browser-cdp-cookie' + +type FakeSession = { partition: string; cookies: { flushStore: () => Promise } } + +const electron = vi.hoisted(() => { + const jars = new Map[]>() + const sessions = new Map() + const flushed: string[] = [] + const windows: { shown: boolean; destroyed: boolean }[] = [] + return { jars, sessions, flushed, windows } +}) + +function jar(partition: string): Record[] { + let cookies = electron.jars.get(partition) + if (!cookies) { + cookies = [] + electron.jars.set(partition, cookies) + } + return cookies +} + +vi.mock('electron', () => { + class BrowserWindow { + private readonly state = { shown: false, destroyed: false } + readonly webContents: unknown + constructor(options: { show?: boolean; webPreferences: { session: FakeSession } }) { + this.state.shown = options.show !== false + electron.windows.push(this.state) + const partition = options.webPreferences.session.partition + this.webContents = { + isDestroyed: () => false, + debugger: { + sendCommand: async (method: string, params: Record = {}) => { + if (method === 'Network.getAllCookies') { + return { cookies: jar(partition).map((cookie) => ({ ...cookie })) } + } + if (method === 'Network.setCookie') { + if (String(params.value).includes('refused')) { + return { success: false } + } + const url = new URL(String(params.url)) + jar(partition).push({ + name: params.name, + value: params.value, + domain: params.domain ?? url.hostname, + path: params.path ?? '/', + secure: params.secure, + httpOnly: params.httpOnly, + sameSite: params.sameSite, + expires: params.expires ?? -1, + session: params.expires === undefined, + ...(params.partitionKey ? { partitionKey: params.partitionKey } : {}) + }) + return { success: true } + } + throw new Error(`unexpected CDP command ${method}`) + } + } + } + } + async loadURL(): Promise {} + show(): void { + this.state.shown = true + } + destroy(): void { + this.state.destroyed = true + } + } + return { + BrowserWindow, + webContents: { getAllWebContents: () => [] }, + session: { + fromPartition: (partition: string): FakeSession => { + let existing = electron.sessions.get(partition) + if (!existing) { + existing = { + partition, + cookies: { + flushStore: async () => { + electron.flushed.push(partition) + } + } + } + electron.sessions.set(partition, existing) + } + return existing + } + } + } +}) +vi.mock('./electron-debugger-lease', () => ({ + acquireElectronDebugger: () => ({ release: () => {} }) +})) + +import { + copyBrowserPartitionCookies, + flushBrowserPartitionCookies, + planBrowserPartitionCookieCopy +} from './browser-partition-cookie-copy' + +const SOURCE = 'persist:orca-browser-local' +const DESTINATION = `persist:orca-browser-v1-${'a'.repeat(64)}` + +function cookie(overrides: Partial & { name: string }): CdpCookie { + return { + value: 'v', + domain: '.example.com', + path: '/', + secure: true, + httpOnly: false, + session: false, + expires: 2_000_000_000, + sameSite: 'Lax', + ...overrides + } +} + +beforeEach(() => { + electron.jars.clear() + electron.sessions.clear() + electron.flushed.length = 0 + electron.windows.length = 0 +}) + +describe('planBrowserPartitionCookieCopy', () => { + it('copies httpOnly, partitioned and session-only cookies faithfully', () => { + const plan = planBrowserPartitionCookieCopy( + [ + cookie({ name: 'sid', httpOnly: true }), + cookie({ + name: 'chips', + partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: false } + }), + cookie({ name: 'tab', session: true, expires: -1 }), + cookie({ name: '__Host-id', domain: 'app.example.com' }) + ], + [] + ) + + expect(plan.counts.rejected).toBe(0) + expect(plan.writes.map((write) => write.name)).toEqual(['sid', 'chips', 'tab', '__Host-id']) + expect(plan.writes[0]).toMatchObject({ httpOnly: true, hostOnly: false }) + expect(plan.writes[1]?.partitionKey).toEqual({ + topLevelSite: 'https://top.example', + hasCrossSiteAncestor: false + }) + expect(plan.writes[2]?.expirationDate).toBeUndefined() + expect(plan.writes[3]).toMatchObject({ hostOnly: true, url: 'https://app.example.com/' }) + }) + + it('skips cookies the target already holds, source-bound sites, and opaque partitions', () => { + const plan = planBrowserPartitionCookieCopy( + [ + cookie({ name: 'sid', value: 'desktop' }), + cookie({ name: 'SID', domain: '.google.com' }), + cookie({ name: 'embed', partitionKeyOpaque: true }), + cookie({ name: 'bad', domain: undefined }), + cookie({ + name: 'chips', + partitionKey: { topLevelSite: 'not a site', hasCrossSiteAncestor: false } + }) + ], + [cookie({ name: 'sid', value: 'host' })] + ) + + expect(plan.writes).toEqual([]) + expect(plan.counts).toMatchObject({ + skippedPresent: 1, + skippedSourceBound: 1, + skippedOpaquePartition: 1, + rejected: 2 + }) + }) + + it('keeps a partitioned and an unpartitioned cookie of the same name apart', () => { + const plan = planBrowserPartitionCookieCopy( + [ + cookie({ + name: 'sid', + partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: false } + }) + ], + [cookie({ name: 'sid' })] + ) + expect(plan.writes).toHaveLength(1) + }) +}) + +describe('copyBrowserPartitionCookies', () => { + it('merges into the destination, never writes the source, and never shows a window', async () => { + jar(SOURCE).push( + cookie({ name: 'sid', httpOnly: true }), + cookie({ name: 'tab', session: true, expires: -1 }), + cookie({ + name: 'chips', + partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true } + }), + cookie({ name: 'kept', value: 'desktop' }), + cookie({ name: 'odd', value: 'refused' }) + ) + jar(DESTINATION).push(cookie({ name: 'kept', value: 'host' })) + const sourceBefore = structuredClone(jar(SOURCE)) + + const counts = await copyBrowserPartitionCookies(SOURCE, DESTINATION) + + expect(counts).toEqual({ + copied: 3, + copiedHttpOnly: 1, + copiedPartitioned: 1, + copiedSessionOnly: 1, + skippedPresent: 1, + skippedSourceBound: 0, + skippedOpaquePartition: 0, + rejected: 1 + }) + expect(jar(SOURCE)).toEqual(sourceBefore) + expect(jar(DESTINATION).find((entry) => entry.name === 'kept')?.value).toBe('host') + expect(jar(DESTINATION).find((entry) => entry.name === 'chips')?.partitionKey).toEqual({ + topLevelSite: 'https://top.example', + hasCrossSiteAncestor: true + }) + expect(electron.windows.every((window) => !window.shown && window.destroyed)).toBe(true) + }) + + it('writes nothing twice when re-run after a crash', async () => { + jar(SOURCE).push(cookie({ name: 'sid' }), cookie({ name: 'pref' })) + await copyBrowserPartitionCookies(SOURCE, DESTINATION) + + const again = await copyBrowserPartitionCookies(SOURCE, DESTINATION) + + expect(again.copied).toBe(0) + expect(again.skippedPresent).toBe(2) + expect(jar(DESTINATION)).toHaveLength(2) + }) + + it('refuses to copy a partition into itself', async () => { + await expect(copyBrowserPartitionCookies(SOURCE, SOURCE)).rejects.toThrow( + 'browser_partition_cookie_copy_same_partition' + ) + }) + + it('flushes the destination jar to disk', async () => { + await flushBrowserPartitionCookies(DESTINATION) + expect(electron.flushed).toEqual([DESTINATION]) + }) +}) diff --git a/src/main/browser/browser-partition-cookie-copy.ts b/src/main/browser/browser-partition-cookie-copy.ts new file mode 100644 index 00000000000..93622c21c42 --- /dev/null +++ b/src/main/browser/browser-partition-cookie-copy.ts @@ -0,0 +1,142 @@ +/** + * Copies one browser partition's cookies into another, without overwriting what the target holds + * and without touching the source. Reads and writes go through CDP: Electron's cookies API drops + * the partition key, which would turn a partitioned cookie into a cross-site one. + */ +import { session } from 'electron' +import { + emptyWslMoveBrowserCookieCopyCounts, + type WslMoveBrowserCookieCopyCounts +} from '../../shared/wsl-move/wsl-move-browser-cookie-copy' +import type { CdpCookie } from './browser-cdp-cookie' +import { identityFromCdpCookie, openCookieClearStore } from './cookies/browser-cookie-clear-store' +import { + withCookieMutationLock, + type CookieClearIdentity +} from './cookies/browser-cookie-import-clear' +import { + isNonTransplantableCookieDomain, + normalizeCookieDomain +} from './cookies/browser-cookie-import-policy' + +export type BrowserPartitionCookieCopyCounts = WslMoveBrowserCookieCopyCounts + +export type BrowserPartitionCookieCopyPlan = { + writes: CookieClearIdentity[] + /** Skip and reject counts; the copy adds the write results. */ + counts: BrowserPartitionCookieCopyCounts +} + +/** Pure: what to write from `source` into a jar that already holds `destination`. */ +export function planBrowserPartitionCookieCopy( + source: readonly CdpCookie[], + destination: readonly CdpCookie[] +): BrowserPartitionCookieCopyPlan { + const counts = emptyWslMoveBrowserCookieCopyCounts() + const present = new Set() + for (const cookie of destination) { + const key = cookieKey(cookie) + if (key) { + present.add(key) + } + } + const writes: CookieClearIdentity[] = [] + for (const cookie of source) { + const key = cookieKey(cookie) + const host = typeof cookie.domain === 'string' ? normalizeCookieDomain(cookie.domain) : null + if (!key || !host || typeof cookie.value !== 'string') { + counts.rejected += 1 + continue + } + if (isNonTransplantableCookieDomain(host)) { + counts.skippedSourceBound += 1 + continue + } + if (cookie.partitionKeyOpaque === true) { + counts.skippedOpaquePartition += 1 + continue + } + if (present.has(key)) { + counts.skippedPresent += 1 + continue + } + let identity: CookieClearIdentity + try { + identity = identityFromCdpCookie(cookieUrl(cookie, host), cookie) + } catch { + counts.rejected += 1 + continue + } + present.add(key) + writes.push(identity) + } + return { writes, counts } +} + +/** + * Merges `sourcePartition`'s cookies into `destinationPartition`. Re-running it after a crash + * writes nothing twice: every cookie the first run wrote is already present. + */ +export async function copyBrowserPartitionCookies( + sourcePartition: string, + destinationPartition: string +): Promise { + if (sourcePartition === destinationPartition) { + throw new Error('browser_partition_cookie_copy_same_partition') + } + const destinationSession = session.fromPartition(destinationPartition) + const source = openCookieClearStore(session.fromPartition(sourcePartition)) + const destination = openCookieClearStore(destinationSession) + try { + const sourceCookies = await source.readAllCdpCookies() + return await withCookieMutationLock(destinationSession, async () => { + const plan = planBrowserPartitionCookieCopy( + sourceCookies, + await destination.readAllCdpCookies() + ) + for (const identity of plan.writes) { + try { + await destination.writeCookieIdentity(identity) + } catch { + plan.counts.rejected += 1 + continue + } + plan.counts.copied += 1 + plan.counts.copiedHttpOnly += identity.httpOnly === true ? 1 : 0 + plan.counts.copiedPartitioned += identity.partitionKey ? 1 : 0 + plan.counts.copiedSessionOnly += identity.expirationDate === undefined ? 1 : 0 + } + return plan.counts + }) + } finally { + source.dispose() + destination.dispose() + } +} + +/** Resolves once the partition's cookie writes are on disk, not just in memory. */ +export function flushBrowserPartitionCookies(partition: string): Promise { + return session.fromPartition(partition).cookies.flushStore() +} + +function cookieKey(cookie: CdpCookie): string | null { + if (typeof cookie.name !== 'string' || typeof cookie.domain !== 'string' || !cookie.domain) { + return null + } + const partitionKey = cookie.partitionKey + ? [cookie.partitionKey.topLevelSite ?? null, cookie.partitionKey.hasCrossSiteAncestor ?? null] + : null + return JSON.stringify([ + cookie.name, + cookie.domain.toLowerCase(), + cookie.path || '/', + partitionKey, + cookie.partitionKeyOpaque === true + ]) +} + +function cookieUrl(cookie: CdpCookie, host: string): string { + const url = new URL(`${cookie.secure ? 'https' : 'http'}://${host}/`) + url.pathname = cookie.path?.startsWith('/') ? cookie.path : '/' + return url.toString() +} diff --git a/src/main/browser/browser-session-startup.ts b/src/main/browser/browser-session-startup.ts index 382c601a683..3e0159ab0f8 100644 --- a/src/main/browser/browser-session-startup.ts +++ b/src/main/browser/browser-session-startup.ts @@ -42,3 +42,8 @@ export function initializeBrowserSessionsForApp( browserSessionRegistry.initializeBrowserSessionsFromPersistedState() initialized = true } + +/** Whether a partition may be opened: before this, opening one would skip a pending cookie import. */ +export function browserSessionsInitialized(): boolean { + return initialized +} diff --git a/src/main/browser/cookies/browser-cookie-clear-store.ts b/src/main/browser/cookies/browser-cookie-clear-store.ts index c85bd51a2bf..db38c7141de 100644 --- a/src/main/browser/cookies/browser-cookie-clear-store.ts +++ b/src/main/browser/cookies/browser-cookie-clear-store.ts @@ -8,29 +8,12 @@ import type { CookieClearIdentity, CookieClearPartitionKey, CookieClearStore, - CookieImportWriteStore + CookieImportWriteStore, + CookieJarCdpReadStore } from './browser-cookie-import-clear' import { restoreEveryCookieIdentity } from './browser-cookie-identity-restore' import { normalizeCookiePartitionSite } from './sources/browser-cookie-source-partition' - -type CdpCookiePartitionKey = { - topLevelSite?: string - hasCrossSiteAncestor?: boolean -} - -type CdpCookie = { - name: string - value: string - domain?: string - path?: string - secure?: boolean - httpOnly?: boolean - session?: boolean - expires?: number - sameSite?: string - partitionKey?: CdpCookiePartitionKey | null - partitionKeyOpaque?: boolean -} +import type { CdpCookie } from '../browser-cdp-cookie' function cdpSameSite(sameSite: Cookie['sameSite']): 'Strict' | 'Lax' | 'None' | undefined { if (sameSite === 'strict') { @@ -96,7 +79,7 @@ function indexCdpCookies(cookies: readonly CdpCookie[]): Map void } { +): CookieClearStore & CookieImportWriteStore & CookieJarCdpReadStore & { dispose: () => void } { const { attach, dispose } = openCookieDebuggerSession(targetSession) return { get: (filter) => targetSession.cookies.get(filter), @@ -224,6 +207,8 @@ export function openCookieClearStore( }, writeCookieIdentity: async (identity) => writeIdentityWithCdp((await attach()).debugger, identity, 'import'), + readAllCdpCookies: async () => + cdpCookiesFromCommand(await (await attach()).debugger.sendCommand('Network.getAllCookies')), dispose } } diff --git a/src/main/browser/cookies/browser-cookie-import-clear.ts b/src/main/browser/cookies/browser-cookie-import-clear.ts index 8255a5f4372..cc7f1c49284 100644 --- a/src/main/browser/cookies/browser-cookie-import-clear.ts +++ b/src/main/browser/cookies/browser-cookie-import-clear.ts @@ -1,5 +1,6 @@ import type { Cookie, Cookies } from 'electron' import { mapSettledWithConcurrency } from '../../../shared/map-with-concurrency' +import type { CdpCookie } from '../browser-cdp-cookie' import type { ImportedDomainScope } from './browser-cookie-import-policy' import { cookieRemovalUrl, @@ -42,6 +43,11 @@ export type CookieImportWriteStore = Pick & { writeCookieIdentity(identity: CookieClearIdentity): Promise } +// Why: cookies.get() leaves out partitionKey, so a whole-jar copy reads through CDP too. +export type CookieJarCdpReadStore = { + readAllCdpCookies(): Promise +} + // Exclude cookies.set and bulk clearData: neither preserves this per-cookie rollback contract. export type CookieClearSession = { cookies: Pick diff --git a/src/main/browser/wsl-host-browser-jar-identity.test.ts b/src/main/browser/wsl-host-browser-jar-identity.test.ts new file mode 100644 index 00000000000..3732fa4e63f --- /dev/null +++ b/src/main/browser/wsl-host-browser-jar-identity.test.ts @@ -0,0 +1,111 @@ +/** + * R45: which in-app browser jar a WSL host's pages get, pinned by what can change about the host. + * The jar follows the environment (named by the distro) and orcad's key; the distro's registry id + * is deliberately not part of it (#29610). + */ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' +import { defaultWslOrcadEnvironmentId } from '../wsl/wsl-orcad-deploy' + +const bindings = vi.hoisted(() => ({ filePath: '' })) + +vi.mock('electron', () => ({ + app: { getPath: () => tmpdir() }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() } +})) +vi.mock('./browser-route-partition-binding-runtime', async () => { + const { BrowserRoutePartitionBindingStore } = + await import('./browser-route-partition-binding-store') + return { + currentBrowserRoutePartitionBindingStore: () => + new BrowserRoutePartitionBindingStore({ filePath: bindings.filePath }) + } +}) +vi.mock('./browser-route-partition-storage-dependencies', () => ({ + releaseEvictedBrowserRoutePartitionStorage: vi.fn(async () => {}) +})) + +import { + bindWslHostBrowserRoutePartition, + wslHostBrowserRouteStorage +} from './wsl-host-browser-route-partition' + +const ORCA_PROFILE = 'profile-a' + +function wslHost( + distro: string, + hostKey: string, + pairing = { revision: 1, device: 'device-a' } +): KnownRuntimeEnvironment { + return { + id: defaultWslOrcadEnvironmentId(distro), + name: distro, + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + pairingRevision: pairing.revision, + pairedDeviceId: pairing.device, + orcadDeployment: { kind: 'wsl', distro, localPort: 9999, remotePort: 7777 }, + preferredEndpointId: 'endpoint-a', + endpoints: [ + { + id: 'endpoint-a', + kind: 'websocket', + label: distro, + endpoint: 'ws://127.0.0.1:9999', + deviceToken: `token-${pairing.device}`, + publicKeyB64: hostKey + } + ] + } +} + +function jar(environment: KnownRuntimeEnvironment): string { + const result = wslHostBrowserRouteStorage(ORCA_PROFILE, environment) + if (result.kind !== 'stable') { + throw new Error(`expected a stable partition, got ${result.reason}`) + } + return bindWslHostBrowserRoutePartition(result.storage, 'default') +} + +let directory: string + +beforeEach(() => { + directory = realpathSync(mkdtempSync(join(realpathSync(tmpdir()), 'wsl-host-jar-'))) + bindings.filePath = join(directory, 'bindings.json') +}) +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +describe('WSL host browser jar identity', () => { + it('starts a new jar when the distro runs a newly deployed orcad (a new key)', () => { + const before = jar(wslHost('Ubuntu', 'orcad-key-a')) + + expect(jar(wslHost('Ubuntu', 'orcad-key-b'))).not.toBe(before) + // The old jar is still the old key's; nothing of it moved to the new one. + expect(jar(wslHost('Ubuntu', 'orcad-key-a'))).toBe(before) + }) + + it('starts a new jar for a renamed distro, even one that kept its orcad key', () => { + const before = jar(wslHost('Ubuntu', 'orcad-key-a')) + + expect(jar(wslHost('Ubuntu-work', 'orcad-key-a'))).not.toBe(before) + }) + + // A golden image with a baked-in orcad key, imported twice on one desktop. + it('keeps two distros that carry the same orcad key in separate jars', () => { + expect(jar(wslHost('Ubuntu', 'baked-key'))).not.toBe(jar(wslHost('Ubuntu-2', 'baked-key'))) + }) + + // Accepted (R45, Low): the same name and key is the same host to this desktop. + it('keeps the jar for a distro re-imported under its old name with its old key', () => { + const before = jar(wslHost('Ubuntu', 'orcad-key-a')) + + expect(jar(wslHost('Ubuntu', 'orcad-key-a', { revision: 7, device: 'device-b' }))).toBe(before) + }) +}) diff --git a/src/main/browser/wsl-host-browser-route-partition.test.ts b/src/main/browser/wsl-host-browser-route-partition.test.ts new file mode 100644 index 00000000000..0bee057490b --- /dev/null +++ b/src/main/browser/wsl-host-browser-route-partition.test.ts @@ -0,0 +1,241 @@ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + KnownRuntimeEnvironment, + OrcadDeploymentLink +} from '../../shared/runtime-environments' + +const bindings = vi.hoisted(() => ({ filePath: '' })) + +vi.mock('electron', () => ({ + app: { getPath: () => tmpdir() }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() } +})) +vi.mock('./paired-runtime-browser-client-host-composition', () => ({ + PairedRuntimeBrowserClientHostComposition: class { + start(): Promise { + return Promise.resolve({ authority: 'lease-a' }) + } + replaceAuthority(): Promise { + return Promise.resolve({ authority: 'lease-a' }) + } + close(): Promise { + return Promise.resolve(true) + } + whenClosed(): Promise { + return Promise.resolve() + } + } +})) +vi.mock('./browser-route-partition-binding-runtime', async () => { + const { BrowserRoutePartitionBindingStore } = + await import('./browser-route-partition-binding-store') + return { + currentBrowserRoutePartitionBindingStore: () => + new BrowserRoutePartitionBindingStore({ filePath: bindings.filePath }) + } +}) +vi.mock('./browser-route-partition-storage-dependencies', () => ({ + releaseEvictedBrowserRoutePartitionStorage: vi.fn(async () => {}) +})) + +import { BrowserRoutePartitionBindingStore } from './browser-route-partition-binding-store' +import { resolveBrowserRoutePartitionBinding } from './browser-route-partition-migration' +import { + closePairedRuntimeBrowserClientHostEnvironment, + configurePairedRuntimeBrowserClientHostsForOrcaProfile, + getPairedRuntimeBrowserClientRouteIdentity, + startPairedRuntimeBrowserClientHost, + type ClientHostRouteIdentity +} from './paired-runtime-browser-client-host-runtime' +import { + bindWslHostBrowserRoutePartition, + wslHostBrowserRouteStorage +} from './wsl-host-browser-route-partition' + +const ORCA_PROFILE = 'profile-a' +const WSL_DEPLOYMENT: OrcadDeploymentLink = { + kind: 'wsl', + distro: 'Ubuntu', + localPort: 9999, + remotePort: 7777 +} +const SSH_DEPLOYMENT: OrcadDeploymentLink = { + sshTargetId: 'target-a', + sshTargetGeneration: 1, + localPort: 9999, + remotePort: 7777 +} + +function environment( + pairing: { revision: number; device: string }, + orcadDeployment: OrcadDeploymentLink | null = WSL_DEPLOYMENT, + id = 'wsl:Ubuntu' +): KnownRuntimeEnvironment { + return { + id, + name: 'Ubuntu', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + pairingRevision: pairing.revision, + pairedDeviceId: pairing.device, + ...(orcadDeployment ? { orcadDeployment } : {}), + preferredEndpointId: 'endpoint-a', + endpoints: [ + { + id: 'endpoint-a', + kind: 'websocket', + label: 'Ubuntu', + endpoint: 'ws://127.0.0.1:9999', + deviceToken: `token-${pairing.device}`, + publicKeyB64: 'host-key-a' + } + ] + } +} + +function stableStorage(env: KnownRuntimeEnvironment) { + const result = wslHostBrowserRouteStorage(ORCA_PROFILE, env) + if (result.kind !== 'stable') { + throw new Error(`expected a stable partition, got ${result.reason}`) + } + return result.storage +} + +async function liveRouteIdentity( + env: KnownRuntimeEnvironment, + authorityRuntimeId: string +): Promise { + await startPairedRuntimeBrowserClientHost({ environment: env, authorityRuntimeId }) + const identity = getPairedRuntimeBrowserClientRouteIdentity(env.id) + await closePairedRuntimeBrowserClientHostEnvironment(env.id) + if (!identity) { + throw new Error('missing route identity') + } + return identity +} + +/** The partition the host's in-app page lands in, resolved the way page creation does it. */ +async function pagePartition( + env: KnownRuntimeEnvironment, + authorityRuntimeId: string, + browserProfileId = 'default' +): Promise { + const identity = await liveRouteIdentity(env, authorityRuntimeId) + const page = (authorityConnectionIdentity: string, executionHostIdentity: string) => ({ + orcaProfileId: identity.orcaProfileId, + browserProfileId, + authorityConnectionIdentity, + executionHostIdentity + }) + const store = new BrowserRoutePartitionBindingStore({ filePath: bindings.filePath }) + const derived = resolveBrowserRoutePartitionBinding({ + bindings: store, + identity: page(identity.authorityConnectionIdentity, identity.executionHostIdentity), + compatibleIdentity: page( + identity.pairingAuthorityConnectionIdentity, + identity.executionHostIdentity + ), + legacyIdentity: page( + identity.legacyAuthorityConnectionIdentity, + identity.legacyExecutionHostIdentity + ), + storageScope: identity.storageScope + }) + const persisted = store.get(derived.partition) + if (persisted === null) { + store.set( + derived.partition, + derived.bindingFingerprint, + identity.storageScope, + derived.aliasFingerprint + ) + } else if (persisted !== derived.bindingFingerprint) { + throw new Error('browser_route_partition_binding_conflict') + } + return derived.partition +} + +let directory: string + +beforeEach(() => { + configurePairedRuntimeBrowserClientHostsForOrcaProfile({ orcaProfileId: ORCA_PROFILE }) + directory = realpathSync(mkdtempSync(join(realpathSync(tmpdir()), 'wsl-host-route-'))) + bindings.filePath = join(directory, 'bindings.json') +}) +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +describe('WSL host browser route partition', () => { + it('is the partition the host pages later use, across an orcad relaunch and re-pair', async () => { + const seeded = bindWslHostBrowserRoutePartition( + stableStorage(environment({ revision: 10, device: 'a' })), + 'default' + ) + + const afterRelaunch = await pagePartition(environment({ revision: 20, device: 'b' }), 'rt-b') + const afterAnother = await pagePartition(environment({ revision: 30, device: 'c' }), 'rt-c') + + expect(afterRelaunch).toBe(seeded) + expect(afterAnother).toBe(seeded) + expect( + bindWslHostBrowserRoutePartition( + stableStorage(environment({ revision: 40, device: 'd' })), + 'default' + ) + ).toBe(seeded) + }) + + it('copies into the jar the host already uses instead of starting another', async () => { + const existing = await pagePartition(environment({ revision: 10, device: 'a' }), 'rt-a') + + expect( + bindWslHostBrowserRoutePartition( + stableStorage(environment({ revision: 20, device: 'b' })), + 'default' + ) + ).toBe(existing) + }) + + it('keeps one partition per browser profile', () => { + const storage = stableStorage(environment({ revision: 10, device: 'a' })) + expect(bindWslHostBrowserRoutePartition(storage, 'default')).not.toBe( + bindWslHostBrowserRoutePartition(storage, 'work') + ) + }) + + // Only this identity derives that name, so the page path re-adopts it; the copy must agree. + it('keeps its own partition after an older build rewrote the binding without its alias', async () => { + const storage = stableStorage(environment({ revision: 10, device: 'a' })) + const partition = bindWslHostBrowserRoutePartition(storage, 'default') + const store = new BrowserRoutePartitionBindingStore({ filePath: bindings.filePath }) + store.remove([partition]) + store.set(partition, 'f'.repeat(64), storage.storageScope) + + expect(bindWslHostBrowserRoutePartition(storage, 'default')).toBe(partition) + expect(await pagePartition(environment({ revision: 20, device: 'b' }), 'rt-b')).toBe(partition) + }) + + it('reports no stable partition for an environment without a deployment link', () => { + expect( + wslHostBrowserRouteStorage(ORCA_PROFILE, environment({ revision: 10, device: 'a' })) + ).toMatchObject({ kind: 'stable' }) + expect( + wslHostBrowserRouteStorage(ORCA_PROFILE, environment({ revision: 10, device: 'a' }, null)) + ).toEqual({ kind: 'unavailable', reason: 'no-stable-partition' }) + }) + + it('never resolves a managed SSH host, so its partitions stay untouched', () => { + expect( + wslHostBrowserRouteStorage( + ORCA_PROFILE, + environment({ revision: 10, device: 'a' }, SSH_DEPLOYMENT, 'ssh-managed') + ) + ).toEqual({ kind: 'unavailable', reason: 'destination-not-wsl-host' }) + }) +}) diff --git a/src/main/browser/wsl-host-browser-route-partition.ts b/src/main/browser/wsl-host-browser-route-partition.ts new file mode 100644 index 00000000000..e4f06e50dda --- /dev/null +++ b/src/main/browser/wsl-host-browser-route-partition.ts @@ -0,0 +1,122 @@ +/** + * The route partition a WSL host's in-app browser pages will use, resolved from the environment + * record alone so a caller can seed it while no page or orcad connection is live. + */ +import { classifyRuntimeEnvironmentHost } from '../../shared/runtime-environment-host-kind' +import { + getPreferredPairedRuntimeConnection, + type KnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import { browserAuthorityExecutionHostStorageIdentity } from './browser-execution-host-storage-identity' +import { + deriveBrowserRoutePartitionStorageScope, + type BrowserRoutePartitionIdentity +} from './browser-route-identity' +import { currentBrowserRoutePartitionBindingStore } from './browser-route-partition-binding-runtime' +import { resolveBrowserRoutePartitionBinding } from './browser-route-partition-migration' +import { isBrowserRoutePartitionRetainedByAnyOwner } from './browser-route-partition-retention' +import { releaseEvictedBrowserRoutePartitionStorage } from './browser-route-partition-storage-dependencies' +import { derivePairedRuntimeBrowserAuthorityIdentity } from './paired-runtime-browser-authority-identity' + +// Only the per-process legacy identity reads the runtime id, and it is not used here. +const OFFLINE_AUTHORITY_RUNTIME_ID = 'wsl-move-offline' + +export type WslHostBrowserRouteStorage = { + orcaProfileId: string + storageScope: string + /** Survives re-pairs; pages find the jar through it. */ + authorityConnectionIdentity: string + /** The current pairing's identity, which the binding keeps as its fingerprint for downgrades. */ + pairingAuthorityConnectionIdentity: string +} + +export type WslHostBrowserRouteStorageResult = + | { kind: 'stable'; storage: WslHostBrowserRouteStorage } + | { kind: 'unavailable'; reason: 'destination-not-wsl-host' | 'no-stable-partition' } + +/** `stable` only for a WSL host whose partition survives every orcad relaunch and re-pair. */ +export function wslHostBrowserRouteStorage( + orcaProfileId: string, + environment: KnownRuntimeEnvironment +): WslHostBrowserRouteStorageResult { + // Why first: without a deployment link the identity is pairing-scoped, so no host kind is stable. + if (!environment.orcadDeployment) { + return { kind: 'unavailable', reason: 'no-stable-partition' } + } + if (classifyRuntimeEnvironmentHost(environment).kind !== 'wsl') { + return { kind: 'unavailable', reason: 'destination-not-wsl-host' } + } + const identity = derivePairedRuntimeBrowserAuthorityIdentity({ + orcaProfileId, + environment, + pairingRevision: environment.pairingRevision ?? environment.createdAt, + authorityRuntimeId: OFFLINE_AUTHORITY_RUNTIME_ID, + connection: getPreferredPairedRuntimeConnection(environment) + }) + // Why: a pairing-scoped identity moves on the next orcad relaunch, stranding anything copied. + if (identity.authorityConnectionIdentity === identity.pairingAuthorityConnectionIdentity) { + return { kind: 'unavailable', reason: 'no-stable-partition' } + } + return { + kind: 'stable', + storage: { + orcaProfileId, + storageScope: deriveBrowserRoutePartitionStorageScope({ + orcaProfileId, + environmentId: environment.id + }), + authorityConnectionIdentity: identity.authorityConnectionIdentity, + pairingAuthorityConnectionIdentity: identity.pairingAuthorityConnectionIdentity + } + } +} + +/** + * Binds (or adopts, as the first page would) the host's partition for one browser profile. + * Throws `browser_route_partition_binding_conflict` when another identity owns the name. + */ +export function bindWslHostBrowserRoutePartition( + storage: WslHostBrowserRouteStorage, + browserProfileId: string +): string { + // Why: the host's own pages run natively inside the distro, so they name the authority itself. + const executionHostIdentity = browserAuthorityExecutionHostStorageIdentity(storage.storageScope) + const identity = (authorityConnectionIdentity: string): BrowserRoutePartitionIdentity => ({ + orcaProfileId: storage.orcaProfileId, + browserProfileId, + authorityConnectionIdentity, + executionHostIdentity + }) + const bindings = currentBrowserRoutePartitionBindingStore({ + isPartitionRetained: isBrowserRoutePartitionRetainedByAnyOwner + }) + // Why no legacy identity: it hashes the live orcad's runtime id, so a jar bound to it is only + // adoptable by a page of that same orcad process. + const derived = resolveBrowserRoutePartitionBinding({ + bindings, + identity: identity(storage.authorityConnectionIdentity), + compatibleIdentity: identity(storage.pairingAuthorityConnectionIdentity), + legacyIdentity: null, + storageScope: storage.storageScope + }) + const persisted = bindings.get(derived.partition) + if (persisted === null) { + const evicted = bindings.set( + derived.partition, + derived.bindingFingerprint, + storage.storageScope, + derived.aliasFingerprint + ) + if (evicted.length > 0) { + void releaseEvictedBrowserRoutePartitionStorage( + evicted, + isBrowserRoutePartitionRetainedByAnyOwner + ) + } + } else if (persisted !== derived.bindingFingerprint) { + throw new Error('browser_route_partition_binding_conflict') + } else { + bindings.touch(derived.partition) + } + return derived.partition +} diff --git a/src/main/claude/claude-hook-bridge-host-coexistence.test.ts b/src/main/claude/claude-hook-bridge-host-coexistence.test.ts new file mode 100644 index 00000000000..82723061069 --- /dev/null +++ b/src/main/claude/claude-hook-bridge-host-coexistence.test.ts @@ -0,0 +1,68 @@ +/** + * R42: during the move to WSL hosts, the WSL bridge (installing from Windows) and the distro's own + * orcad (installing locally) both reconcile Claude's hooks in the distro's ~/.claude. Both write + * the one shared entry, so in either order the file ends with exactly one Orca entry per event, + * and the user's own hooks survive. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getClaudeManagedHookPlan } from './claude-managed-hook-events' +import { buildManagedCommandHook, type HooksConfig } from '../agent-hooks/installer-utils' +import { + applyManagedHooks, + getManagedEventHook, + getPosixManagedScriptFileName, + getRemoteManagedEventCommand +} from './hook-settings' + +const HOME = '/home/dev' +const SCRIPT = `${HOME}/.orca/agent-hooks/${getPosixManagedScriptFileName()}` +const plan = getClaudeManagedHookPlan(null) +const userHook = { matcher: '', hooks: [{ type: 'command' as const, command: 'notify-send done' }] } + +const originalPlatform = process.platform +beforeEach(() => { + // orcad runs inside the distro: the local installer takes its Linux form. + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) +}) +afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) +}) + +const byHost = (config: HooksConfig): HooksConfig => + applyManagedHooks( + config, + (event) => getManagedEventHook(event, SCRIPT), + getPosixManagedScriptFileName(), + plan + ) +const byBridge = (config: HooksConfig): HooksConfig => + applyManagedHooks( + config, + (event) => buildManagedCommandHook(getRemoteManagedEventCommand(event, SCRIPT)), + getPosixManagedScriptFileName(), + plan + ) + +describe('Claude hooks written by the WSL bridge and the WSL host in one home', () => { + it.each([ + ['bridge then host', [byBridge, byHost]], + ['host then bridge', [byHost, byBridge]] + ] as const)('%s: one Orca entry per event, the user hook kept', (_order, writers) => { + const start: HooksConfig = { hooks: { Stop: [userHook] } } + const after = writers.reduce((config, write) => write(config), start) + + for (const { eventName } of plan.install) { + const commands = (after.hooks?.[eventName] ?? []).flatMap((entry) => + (entry.hooks ?? []).map((hook) => hook.command) + ) + expect( + commands.filter((command) => command.includes('agent-hooks/')), + eventName + ).toHaveLength(1) + } + expect(after.hooks?.Stop).toContainEqual(userHook) + // Each side rewrites the other's entry to the same bytes: no flapping between the two. + expect(byHost(after)).toEqual(after) + expect(byBridge(after)).toEqual(after) + }) +}) diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index d3672c5b312..7365402e9dc 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -178,6 +178,8 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { // Keep an unverified recovered cwd; fall back when the folder probe proves it missing. return { ...restoreInfo, + // Why one probe for WSL too: a stat over \\wsl.localhost waits for a stopped distro and + // fails for reasons other than absence, which woke panes blank and at the root. cwd: recoveredCwd && (await probeRecoveredStartupCwdDirectory(recoveredCwd)) !== false ? recoveredCwd diff --git a/src/main/daemon/daemon-relaunch-unattached-inspection.test.ts b/src/main/daemon/daemon-relaunch-unattached-inspection.test.ts new file mode 100644 index 00000000000..71ac9af57dc --- /dev/null +++ b/src/main/daemon/daemon-relaunch-unattached-inspection.test.ts @@ -0,0 +1,91 @@ +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { rmSync } from 'node:fs' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import type { DaemonServer } from './daemon-server' +import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness' +import { inspectPtyProviderProcessForRenderer } from '../providers/pty-process-inspection' +import { isClientOnlyUnverifiableInspection } from '../../shared/terminal-process-inspection' +import { probePtyRunningWorkWithInspection } from '../../shared/pty-running-work-probe' + +// After a relaunch every WSL bridge pane the window had not reopened read +// `unverifiable`, so no move could pass its terminal gate. +describe('inspecting a session that survived a relaunch unattached', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + let quittingApp: DaemonPtyAdapter + let relaunchedApp: DaemonPtyAdapter | null = null + const subprocesses: ReturnType[] = [] + + beforeEach(async () => { + subprocesses.length = 0 + const harness = await startDaemonAdapterHarness(() => { + const subprocess = createMockSubprocess() + subprocesses.push(subprocess) + return subprocess + }) + ;({ dir, socketPath, tokenPath, server } = harness) + quittingApp = harness.adapter + }) + + afterEach(async () => { + for (const subprocess of subprocesses) { + subprocess._simulateExit(0) + } + quittingApp.dispose() + relaunchedApp?.dispose() + relaunchedApp = null + await server.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + // With one daemon generation, the relaunched app's provider is the adapter itself. + async function relaunchWithSession(): Promise<{ provider: DaemonPtyAdapter; id: string }> { + const { id } = await quittingApp.spawn({ cols: 80, rows: 24, sessionId: 'wt@@idle' }) + quittingApp.dispose() + relaunchedApp = new DaemonPtyAdapter({ socketPath, tokenPath }) + // The relaunched runtime adopts surviving sessions from this inventory; no window attaches. + expect((await relaunchedApp.listProcesses()).map((entry) => entry.id)).toEqual([id]) + return { provider: relaunchedApp, id } + } + + it('answers the WSL move gate from the daemon', async () => { + const { provider, id } = await relaunchWithSession() + const inspection = await inspectPtyProviderProcessForRenderer(provider, id, { + scanChildProcesses: true, + includeUnattached: true + }) + expect(isClientOnlyUnverifiableInspection(inspection)).toBe(false) + expect(inspection.hasChildProcesses).toBe(false) + }) + + it('still reads as gone to every other caller', async () => { + const { provider, id } = await relaunchWithSession() + const inspection = await inspectPtyProviderProcessForRenderer(provider, id, { + scanChildProcesses: true + }) + expect(isClientOnlyUnverifiableInspection(inspection)).toBe(true) + }) + + it('reads a session the daemon no longer runs as unverifiable even for the gate', async () => { + const { provider, id } = await relaunchWithSession() + subprocesses[0]!._simulateExit(0) + const probe = (): Promise => + probePtyRunningWorkWithInspection([id], { timeoutMs: 3_000 }, (ptyId) => + inspectPtyProviderProcessForRenderer(provider, ptyId, { includeUnattached: true }) + ).then(([result]) => result?.verdict) + await vi.waitFor(async () => expect(await probe()).toBe('unverifiable')) + }) + + // Loss of contact is never evidence of exit. + it('reads an unreachable daemon as unverifiable, never exited', async () => { + const { provider, id } = await relaunchWithSession() + await server.shutdown() + const [result] = await probePtyRunningWorkWithInspection([id], { timeoutMs: 3_000 }, (ptyId) => + inspectPtyProviderProcessForRenderer(provider, ptyId, { includeUnattached: true }) + ) + expect(result?.verdict).toBe('unverifiable') + }) +}) diff --git a/src/main/daemon/terminal-history-checkpoint-reader.ts b/src/main/daemon/terminal-history-checkpoint-reader.ts index 13007fb60af..96bf149f071 100644 --- a/src/main/daemon/terminal-history-checkpoint-reader.ts +++ b/src/main/daemon/terminal-history-checkpoint-reader.ts @@ -30,7 +30,7 @@ export async function readTerminalHistoryCheckpoint( } } -function isTerminalCheckpointFile(value: unknown): value is TerminalCheckpointFile { +export function isTerminalCheckpointFile(value: unknown): value is TerminalCheckpointFile { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false } diff --git a/src/main/ipc/pty/host-env/spawn-cwd-wsl-move.test.ts b/src/main/ipc/pty/host-env/spawn-cwd-wsl-move.test.ts index ae7fdf06a5a..6d69400bd01 100644 --- a/src/main/ipc/pty/host-env/spawn-cwd-wsl-move.test.ts +++ b/src/main/ipc/pty/host-env/spawn-cwd-wsl-move.test.ts @@ -9,7 +9,7 @@ import { loadWslMoveFence, resetWslMoveFenceForTests } from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard' -import { assertWorkspaceAcceptsNewPty } from './spawn-cwd' +import { assertWorkspaceAcceptsNewPty, localPtySpawnCwd } from './spawn-cwd' const { listLiveDaemonPtyIds } = vi.hoisted(() => ({ listLiveDaemonPtyIds: vi.fn<() => Promise>() @@ -20,6 +20,8 @@ let userDataPath: string // No store: a readable journal never needs one. const accept = (worktreeId: string, sessionId?: string) => Promise.resolve(assertWorkspaceAcceptsNewPty(undefined, worktreeId, sessionId)) +const acceptCwd = (cwd: string | undefined, worktreeId?: string, sessionId?: string) => + Promise.resolve(assertWorkspaceAcceptsNewPty(undefined, worktreeId, sessionId, cwd)) beforeEach(() => { userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-spawn-')) @@ -57,3 +59,66 @@ describe('assertWorkspaceAcceptsNewPty under a WSL move', () => { await expect(accept('repo-2::/x')).resolves.toBeUndefined() }) }) + +// R46: a terminal the workspace fence misses would otherwise outlive the commit in a moved folder. +describe('a spawn by its folder, between quiesce and commit', () => { + beforeEach(() => { + seedWslMoveCutover(userDataPath, cutover('move-1', 'blobs-transferred')) + }) + + it.each([ + ['the project root', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-1'], + ['a folder inside it', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-1\\src'], + [ + 'the wsl$ alias, distro case and a trailing separator', + '\\\\wsl$\\UBUNTU\\home\\dev\\repo-1\\' + ], + ['the Linux spelling', '/home/dev/repo-1/src'] + ])('refuses one that starts in %s', async (_label, cwd) => { + await expect(acceptCwd(cwd)).rejects.toBeInstanceOf(WslMoveProjectFencedError) + }) + + it('lets a reload reattach a session the daemon still runs', async () => { + listLiveDaemonPtyIds.mockResolvedValue(['bare-session']) + await expect(acceptCwd('/home/dev/repo-1', undefined, 'bare-session')).resolves.toBeUndefined() + }) + + it.each([ + ['a sibling whose name shares the prefix', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-10'], + ['the parent folder', '\\\\wsl.localhost\\Ubuntu\\home\\dev'], + ['the same folder in another distro', '\\\\wsl.localhost\\Debian\\home\\dev\\repo-1'], + ['a Windows folder', 'C:\\Users\\dev\\repo-1'], + ['another Linux folder', '/home/dev/other'], + ['no folder at all', undefined] + ])('leaves one alone that starts in %s', async (_label, cwd) => { + await expect(acceptCwd(cwd)).resolves.toBeUndefined() + expect(listLiveDaemonPtyIds).not.toHaveBeenCalled() + }) + + it.each([ + ['a floating terminal', 'global-floating-terminal'], + ['another project', 'repo-2::/x'], + ['a folder workspace', 'folder:folder-9'] + ])('refuses %s that starts in a moving folder', async (_label, worktreeId) => { + await expect(acceptCwd('/home/dev/repo-1/src', worktreeId)).rejects.toBeInstanceOf( + WslMoveProjectFencedError + ) + }) + + it('leaves another workspace alone that starts outside the moving folders', async () => { + await expect(acceptCwd('/home/dev/other', 'repo-2::/x')).resolves.toBeUndefined() + await expect(acceptCwd('C:\\work', 'folder:folder-9')).resolves.toBeUndefined() + }) + + it('never reads the folder of an SSH spawn, which runs on another machine', () => { + expect(localPtySpawnCwd({ connectionId: 'ssh-1', cwd: '/home/dev/repo-1' })).toBeUndefined() + expect(localPtySpawnCwd({ connectionId: null, cwd: '/home/dev/repo-1' })).toBe( + '/home/dev/repo-1' + ) + }) + + it('stops at the commit, once the host owns the folder', async () => { + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + await expect(acceptCwd('/home/dev/repo-1')).resolves.toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty/host-env/spawn-cwd.ts b/src/main/ipc/pty/host-env/spawn-cwd.ts index 1b90b5f6a2f..37b8229eb20 100644 --- a/src/main/ipc/pty/host-env/spawn-cwd.ts +++ b/src/main/ipc/pty/host-env/spawn-cwd.ts @@ -11,18 +11,26 @@ import { getFolderWorkspacePathStatus } from '../../../project-groups/folder-workspace-path-status' import { + isWslMoveCwdFenced, isWslMoveWorkspaceBlocked, WslMoveProjectFencedError } from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard' import { listLiveDaemonPtyIds } from '../../../daemon/daemon-provider-state' -/** WSL moves block new PTYs, but proven daemon reattach must survive reload; folder workspaces still require usable paths. */ +/** + * A new PTY needs a workspace no WSL move is taking, and a folder that still exists. Reattaching to + * a session the daemon still runs is not a new PTY: a reload mid-move must not orphan a live pane. + * The requested folder counts too: a terminal from anywhere that starts inside a moving project + * would outlive the move there (a floating terminal, a Windows project's WSL tab, `--cwd`). + */ export function assertWorkspaceAcceptsNewPty( store: Store | undefined, worktreeId: string | undefined, - sessionId?: string + sessionId?: string, + cwd?: string ): Promise | void { - if (isWslMoveWorkspaceBlocked(store, worktreeId)) { + const fencedByCwd = !!cwd && isWslMoveCwdFenced(cwd) + if (fencedByCwd || isWslMoveWorkspaceBlocked(store, worktreeId)) { return assertReattachesLiveSession(sessionId).then(() => assertFolderWorkspacePtyPathUsable(store, worktreeId) ) @@ -30,6 +38,14 @@ export function assertWorkspaceAcceptsNewPty( return assertFolderWorkspacePtyPathUsable(store, worktreeId) } +/** Only a spawn on this machine starts in a folder a WSL move can be taking. */ +export function localPtySpawnCwd(args: { + connectionId?: string | null + cwd?: string +}): string | undefined { + return args.connectionId ? undefined : args.cwd +} + async function assertReattachesLiveSession(sessionId: string | undefined): Promise { // An unanswered census is never proof the session lives, so it refuses. const live = sessionId ? await listLiveDaemonPtyIds() : null diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 147c43ddd33..2f81c3839fb 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -9,6 +9,7 @@ import { import { getProvider } from '../provider/registry' import { resolvePtySpawnStartupTarget } from '../spawn-startup-target' import type { PtyIpcSpawnState } from './spawn-state' +import { localPtySpawnCwd } from '../host-env/spawn-cwd' import { getConnectionExecutionHostId } from '../../../../shared/execution-host' export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promise { @@ -30,7 +31,11 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) } if (!ctx.earlyStablePaneOwner) { - const admission = ctx.deps.assertWorkspaceAcceptsNewPty(args.worktreeId, args.sessionId) + const admission = ctx.deps.assertWorkspaceAcceptsNewPty( + args.worktreeId, + args.sessionId, + localPtySpawnCwd(args) + ) if (admission) { await admission } @@ -39,7 +44,11 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ctx.preAdoptedStablePane = null ctx.spawnTiming.mark('stable_adoption') if (ctx.earlyStablePaneOwner && !ctx.preAdoptedStablePane) { - const admission = ctx.deps.assertWorkspaceAcceptsNewPty(args.worktreeId, args.sessionId) + const admission = ctx.deps.assertWorkspaceAcceptsNewPty( + args.worktreeId, + args.sessionId, + localPtySpawnCwd(args) + ) if (admission) { await admission } diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 354968204ce..9ee5960c448 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -117,7 +117,8 @@ export type PtySpawnIpcDeps = PtySpawnCodexResumeDeps & { adoptStablePane: (args: AdoptStablePaneArgs) => Promise assertWorkspaceAcceptsNewPty: ( worktreeId: string | undefined, - sessionId?: string + sessionId?: string, + cwd?: string ) => Promise | void resolvePtySpawnStartupCwd: ( worktreeId: string | undefined, diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 25e205a51d8..799b544127b 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -217,8 +217,9 @@ export function registerPtyHandlers( const assertWorkspaceAcceptsNewPty = ( worktreeId: string | undefined, - sessionId?: string - ): Promise | void => assertWorkspaceAcceptsNewPtyImpl(store, worktreeId, sessionId) + sessionId?: string, + cwd?: string + ): Promise | void => assertWorkspaceAcceptsNewPtyImpl(store, worktreeId, sessionId, cwd) const resolvePtySpawnStartupCwd = ( worktreeId: string | undefined, cwd: string | undefined, diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 667900bf474..42fd2697d86 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -24,7 +24,8 @@ export type PtyRuntimeControllerDeps = PtySpawnCodexResumeDeps & { getLocalPtyProviderStartupPromise: (hostId?: ResolvedPtyHost) => Promise | undefined assertWorkspaceAcceptsNewPty: ( worktreeId: string | undefined, - sessionId?: string + sessionId?: string, + cwd?: string ) => Promise | void resolvePtySpawnStartupCwd: ( worktreeId: string | undefined, diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 05478f90fd8..69e20775bbd 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -15,6 +15,7 @@ import { } from '../../../daemon/pty-session-id' import { resolvePathEnvKey } from '../../../pty/windows-environment-path' import type { RuntimePtySpawnState } from './spawn-state' +import { localPtySpawnCwd } from '../host-env/spawn-cwd' import { resolvePtySpawnStartupTarget } from '../spawn-startup-target' import { resolvePtySpawnHostEnv } from '../spawn-host-env' import { ptySpawnPaneFromIds, stampPtySpawnPaneEnv } from '../spawn-pane-env' @@ -29,7 +30,11 @@ export async function prepareRuntimePtySpawn( ): Promise { const args = ctx.args if (!ctx.preAdoptedStablePane) { - const admission = ctx.deps.assertWorkspaceAcceptsNewPty(args.worktreeId, args.sessionId) + const admission = ctx.deps.assertWorkspaceAcceptsNewPty( + args.worktreeId, + args.sessionId, + localPtySpawnCwd(args) + ) if (admission) { await admission } diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 172310eac99..e82d9dcf403 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -148,7 +148,8 @@ vi.mock('electron', () => ({ app: { getPath: getPathMock, once: vi.fn() - } + }, + webContents: { fromId: () => undefined } })) vi.mock('../../shared/runtime-environment-store', () => ({ @@ -325,6 +326,10 @@ vi.mock('../wsl-move-handlers', () => ({ registerWslMoveHandlers: vi.fn() })) +vi.mock('../wsl-move-orchestrator-wiring', () => ({ + registerWslMoveOrchestrator: vi.fn() +})) + vi.mock('../ephemeral-vm', () => ({ registerEphemeralVmHandlers: registerEphemeralVmHandlersMock })) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index c5ea208ea6e..5dd0677ddfd 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -1,6 +1,7 @@ import { app } from 'electron' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { registerAppHandlers } from '../app' +import { registerWslMoveWiring } from '../wsl-move-driver-wiring' import { registerCliHandlers } from '../cli' import { registerPreflightHandlers } from '../preflight' import type { Store } from '../../persistence/loading-store/store' @@ -29,7 +30,6 @@ import { registerRuntimeEnvironmentHandlers } from '../runtime-environments/runtime-environments' import { registerWslHostHandlers } from '../wsl-host-handlers' -import { registerWslMoveHandlers } from '../wsl-move-handlers' import { registerEphemeralVmHandlers } from '../ephemeral-vm' import { registerAiVaultHandlers } from '../ai-vault' import { registerAiVaultSearchHandlers } from '../ai-vault-search' @@ -262,7 +262,7 @@ export function registerCoreHandlers( getUserDataPath: () => app.getPath('userData'), invalidateTransport: invalidateRuntimeEnvironmentTransport }) - registerWslMoveHandlers(() => app.getPath('userData')) + registerWslMoveWiring(store, runtime) registerEphemeralVmHandlers(store, pluginService) // Session history and terminal resume are not chats; a refused host leaves nothing to check. const ensureStructuredSessionOwnership = () => diff --git a/src/main/ipc/renderer-shutdown-checkpoint.test.ts b/src/main/ipc/renderer-shutdown-checkpoint.test.ts index 80b10ef62ea..8176d6421b8 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.test.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.test.ts @@ -31,6 +31,11 @@ import { registerRendererShutdownCheckpointHandler, SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS } from './renderer-shutdown-checkpoint' +import { + resetWslMoveWindowWriteHoldForTests, + holdWslMoveWindowWrites, + setWslMoveReloadableWindow +} from '../persistence/moving-wsl-projects/wsl-move-window-write-hold' const AWAIT_CHANNEL = 'app:await-before-unload-checkpoint' @@ -90,6 +95,39 @@ describe('registerRendererShutdownCheckpointHandler', () => { expect(event.returnValue).toEqual({ ok: true }) }) + it('stages nothing of a window a WSL move holds, and still flushes', () => { + const store = { + getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()), + stageWorkspaceSessionBeforeUnload: vi.fn(), + updateUI: vi.fn(), + flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only calls the four methods stubbed above. + registerRendererShutdownCheckpointHandler(store as never) + setWslMoveReloadableWindow(() => ({ + isDestroyed: () => false, + reload: () => {}, + allowUnload: () => () => {}, + onLoadedOrGone: () => () => {} + })) + holdWslMoveWindowWrites() + try { + const event: { returnValue?: unknown } = {} + syncHandlers.get('app:stage-before-unload-sync')?.(event, { + sessions: [{ state: {} }], + ui: { activeView: 'settings' } + }) + + expect(store.stageWorkspaceSessionBeforeUnload).not.toHaveBeenCalled() + expect(store.updateUI).not.toHaveBeenCalled() + expect(store.flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + expect(event.returnValue).toEqual({ ok: true }) + } finally { + resetWslMoveWindowWriteHoldForTests() + setWslMoveReloadableWindow(() => null) + } + }) + it('never stages the source partition of a fenced host', () => { const store = { getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()), diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index 30f06fe2fd4..1611112eff0 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -10,6 +10,7 @@ import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { Store } from '../persistence' import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' +import { isWslMoveWindowWriteHeld } from '../persistence/moving-wsl-projects/wsl-move-window-write-hold' import { windowViewSaveForMain, type ReconnectFactsFor } from '../persistence/window-save-view' import { reportWindowSaveDrift } from '../persistence/window-save-divergence' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' @@ -102,7 +103,9 @@ export function registerRendererShutdownCheckpointHandler( ipcMain.on('app:stage-before-unload-sync', (event, args: StageBeforeUnloadSyncArgs) => { let error: string | undefined try { - for (const { state, hostId } of args.sessions) { + // A window a WSL move rewrote under is stale; main reloads it and keeps what is on disk. + const sessions = isWslMoveWindowWriteHeld() ? [] : args.sessions + for (const { state, hostId } of sessions) { if (!isFrozenOrcadSourceSessionPartition(store, hostId)) { store.stageWorkspaceSessionBeforeUnload( ORCA_LAYOUT_OWNER_IS_MAIN @@ -112,7 +115,9 @@ export function registerRendererShutdownCheckpointHandler( ) } } - store.updateUI(args.ui) + if (!isWslMoveWindowWriteHeld()) { + store.updateUI(args.ui) + } } catch (stagingError) { console.error('[app] Failed to stage renderer state before unload:', stagingError) // Main's console is not persisted in packaged builds; the renderer toast is the only place users see the cause. diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 8725985dd7b..49c024b3054 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -21,6 +21,7 @@ export function registerRepoHandlers( ): void { // macOS window recreation re-registers these handlers. ipcMain.removeHandler('repos:list') + ipcMain.removeHandler('repos:listWslMoved') ipcMain.removeHandler('repos:add') ipcMain.removeHandler('repos:removeForHost') ipcMain.removeHandler('repos:reorder') diff --git a/src/main/ipc/repos/repo-catalog-handlers.ts b/src/main/ipc/repos/repo-catalog-handlers.ts index e667c1ff2ad..c758b7d8a88 100644 --- a/src/main/ipc/repos/repo-catalog-handlers.ts +++ b/src/main/ipc/repos/repo-catalog-handlers.ts @@ -20,6 +20,7 @@ import { assertWslMoveReposWritable, isWslMoveSourceHost } from '../../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { listWslMovedProjects } from '../../persistence/moving-wsl-projects/wsl-moved-projects' export function registerRepoCatalogHandlers( mainWindow: BrowserWindow, @@ -36,6 +37,11 @@ export function registerRepoCatalogHandlers( return visibleRepos(store) }) + // The local rows `repos:list` hides once their WSL host owns them; read-only, for their chats. + ipcMain.handle('repos:listWslMoved', () => + listWslMovedProjects(store.getRepos(), store.getFolderWorkspaces()) + ) + ipcMain.handle('projects:list', () => { enrichMissingRepoGitRemoteIdentities(store, { onChanged: broadcastReposChanged }) return store.getProjects() diff --git a/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts index 75065160d5d..5d930d9b832 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts @@ -50,6 +50,10 @@ import { wslOrcadHostSupports } from '../../wsl/wsl-orcad-host-environments' import { assertWslHostReachable, isGatedOffWslHost } from '../../wsl/wsl-host-gate' +import { + isHostSpawnFencedForWslMoveUndo, + wslMoveUndoSpawnFencedResponse +} from '../../persistence/moving-wsl-projects/wsl-move-undo-spawn-fence' export { isRuntimeEnvironmentManuallyDisconnected } @@ -229,6 +233,9 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void { if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { return manuallyDisconnectedResponse(environment) } + if (isHostSpawnFencedForWslMoveUndo(environment.id, args.method)) { + return wslMoveUndoSpawnFencedResponse(environment, args.method) + } void desktopChatCreationHistory(getUserDataPath()).recordCreate( environment.id, args.method, diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index b0b8860c633..a0c3fddf3f7 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -3,6 +3,7 @@ import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { parseTerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' +import { isWslMoveWindowWriteHeld } from '../persistence/moving-wsl-projects/wsl-move-window-write-hold' import { visibleSessionHostIds } from '../ssh/orcad-retained-source-session' import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment' import { windowViewPatchForMain, windowViewSaveForMain } from '../persistence/window-save-view' @@ -15,9 +16,10 @@ import type { } from '../../shared/workspace-session-state-types' export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeService): void { - // Renderer saves must not mutate a fenced source host partition. + // Why: renderer saves would change a fenced host's frozen source partition, or write back what a + // WSL move rewrote under this window before main reloads it. const isFenced = (hostId?: string | null): boolean => - isFrozenOrcadSourceSessionPartition(store, hostId) + isFrozenOrcadSourceSessionPartition(store, hostId) || isWslMoveWindowWriteHeld() // Optional hostId preserves local-partition behavior for older renderers. ipcMain.handle('session:get', (_event, hostId?: string | null) => { @@ -100,4 +102,13 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic typeof args?.ref === 'string' ? store.readTerminalScrollbackSnapshot(args.ref) : null } ) + + // Why: render-time planning only needs to know a local copy exists; a stat never reads bytes. + ipcMain.on( + 'session:has-terminal-scrollback-sync', + (event, args: { ref?: unknown } | undefined) => { + event.returnValue = + typeof args?.ref === 'string' ? store.hasTerminalScrollbackSnapshot(args.ref) : false + } + ) } diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index 625343321a7..27ad906e32e 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -35,6 +35,8 @@ const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ 'ssh_host_server_deploy_failed', 'ssh_host_server_move', 'ssh_host_server_legacy_relay_bridge', + 'wsl_move', + 'wsl_move_orchestrator', 'wsl_usage_snapshot' ]) diff --git a/src/main/ipc/ui.ts b/src/main/ipc/ui.ts index 11fd33aaa65..9e8b42d7b87 100644 --- a/src/main/ipc/ui.ts +++ b/src/main/ipc/ui.ts @@ -9,6 +9,7 @@ import { BrowserWindow, ipcMain, webContents, type WebContents } from 'electron' import type { Store } from '../persistence' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import { isFeatureInteractionId } from '../../shared/feature-interactions' +import { isWslMoveWindowWriteHeld } from '../persistence/moving-wsl-projects/wsl-move-window-write-hold' import { isTrustedMainRendererSender } from './main-renderer-sender-trust' let trustedUIRendererWebContentsId: number | null = null @@ -87,7 +88,10 @@ export function registerUIHandlers( }) ipcMain.handle('ui:set', (_event, args: Partial) => { - store.updateUI(args) + // A window a WSL move rewrote under is stale until main reloads it. + if (!isWslMoveWindowWriteHeld()) { + store.updateUI(args) + } }) ipcMain.handle('ui:recordFeatureInteraction', (_event, id: unknown) => { diff --git a/src/main/ipc/wsl-host-automation-disable.ts b/src/main/ipc/wsl-host-automation-disable.ts new file mode 100644 index 00000000000..9330ae492b9 --- /dev/null +++ b/src/main/ipc/wsl-host-automation-disable.ts @@ -0,0 +1,114 @@ +/** + * Undo's host step for automations: a released move's automations run here again, so the host's + * copies turn off first, or each would run twice, once on each side of the same files. + */ +import { + AUTOMATION_NOT_FOUND, + ShownAutomationSchema, + openWslHostAutomationApi, + type WslHostAutomationApi +} from './wsl-host-automation-enable' + +/** A disable that refused partway and could not turn back on `leftOff`, which stay off. */ +export class WslHostAutomationsLeftOffError extends Error { + constructor( + message: string, + readonly leftOff: string[] + ) { + super(message) + } +} + +/** Each one's state on the host, the user's latest choice; one deleted there reads as off. */ +export async function readWslHostAutomationStates( + userDataPath: string, + environmentId: string, + automationIds: readonly string[] +): Promise> { + const states: Record = {} + if (automationIds.length === 0) { + return states + } + const request = await openWslHostAutomationApi(userDataPath, environmentId) + for (const id of automationIds) { + const shown = await request('automation.show', { id }) + if (!shown.ok) { + if (!shown.error.message.includes(AUTOMATION_NOT_FOUND)) { + throw new Error(`wsl_move_automation_read_failed:${shown.error.message}`) + } + states[id] = false + continue + } + states[id] = ShownAutomationSchema.parse(shown.result).automation.enabled + } + return states +} + +/** + * Turns each off and returns them. On a refusal it turns back on the ones it had turned off, + * then throws; any it could not turn back on ride the error as `leftOff`. + */ +export async function disableWslHostAutomations( + userDataPath: string, + environmentId: string, + automationIds: readonly string[] +): Promise { + if (automationIds.length === 0) { + return [] + } + const request = await openWslHostAutomationApi(userDataPath, environmentId) + const turnedOff: string[] = [] + try { + for (const id of automationIds) { + const updated = await request('automation.update', { id, updates: { enabled: false } }) + if (!updated.ok) { + throw new Error(`wsl_move_automation_disable_failed:${updated.error.message}`) + } + turnedOff.push(id) + } + } catch (error) { + // A refused undo leaves the host as it found it (X1), as far as the host lets it. + const leftOff = await turnOnEach(request, turnedOff) + const message = error instanceof Error ? error.message : String(error) + throw leftOff.length > 0 ? new WslHostAutomationsLeftOffError(message, leftOff) : error + } + return turnedOff +} + +/** Turns back on what disableWslHostAutomations turned off, when undo then refuses. */ +export async function restoreWslHostAutomations( + userDataPath: string, + environmentId: string, + automationIds: readonly string[] +): Promise { + if (automationIds.length === 0) { + return + } + const request = await openWslHostAutomationApi(userDataPath, environmentId) + const leftOff = await turnOnEach(request, automationIds) + if (leftOff.length > 0) { + throw new WslHostAutomationsLeftOffError('wsl_move_automation_enable_failed', leftOff) + } +} + +/** + * The ids it could not turn on. A refusal skips just that one; a host that stops answering ends + * the pass, leaving the rest off, rather than hold undo's turn waiting on each. + */ +async function turnOnEach( + request: WslHostAutomationApi, + ids: readonly string[] +): Promise { + const leftOff: string[] = [] + for (const [index, id] of ids.entries()) { + try { + const updated = await request('automation.update', { id, updates: { enabled: true } }) + if (!updated.ok) { + leftOff.push(id) + } + } catch { + return [...leftOff, ...ids.slice(index)] + } + } + return leftOff +} diff --git a/src/main/ipc/wsl-host-automation-enable.test.ts b/src/main/ipc/wsl-host-automation-enable.test.ts new file mode 100644 index 00000000000..2e2fd377a35 --- /dev/null +++ b/src/main/ipc/wsl-host-automation-enable.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const hostRows = vi.hoisted(() => new Map()) +const updates = vi.hoisted((): string[] => []) +const failNextUpdate = vi.hoisted(() => ({ value: false })) +const throwOnUpdate = vi.hoisted(() => ({ value: false })) +const updateAttempts = vi.hoisted(() => ({ count: 0 })) +const failOnUpdateOf = vi.hoisted((): { id: string | null; thenAlso: string | null } => ({ + id: null, + thenAlso: null +})) + +vi.mock('../wsl/wsl-host-service-instance', () => ({ + getWslHostService: () => ({ + recordForEnvironment: () => ({ distro: 'Ubuntu' }), + ensureForRuntimeCall: async () => {} + }) +})) +vi.mock('../../shared/runtime-environment-store', () => ({ + resolveEnvironment: () => ({ id: 'wsl:Ubuntu', runtimeId: 'rt-1' }) +})) +vi.mock('../../shared/runtime-environments', () => ({ getPreferredPairingOffer: () => ({}) })) +vi.mock('../../shared/pairing', () => ({ + encodePairingOffer: () => 'code', + parsePairingCode: () => ({ code: 'code' }) +})) +vi.mock('../ssh/orcad-migration-catalog-client', () => ({ + sendSupportedMigrationRequest: async ( + _pairing: unknown, + method: string, + params: { id: string; updates?: { enabled: boolean } } + ) => { + const row = hostRows.get(params.id) + if (method === 'automation.show') { + return row + ? { ok: true, result: { automation: row } } + : { ok: false, error: { message: 'Automation not found.' } } + } + updateAttempts.count++ + if (throwOnUpdate.value) { + throw new Error('connect ECONNREFUSED') + } + if (failNextUpdate.value || failOnUpdateOf.id === params.id) { + failNextUpdate.value = false + // Then the host drops: the next update (turning one back on) fails too. + failOnUpdateOf.id = failOnUpdateOf.thenAlso + failOnUpdateOf.thenAlso = null + return { ok: false, error: { message: 'busy' } } + } + updates.push(params.id) + if (row) { + row.enabled = params.updates?.enabled ?? true + } + return { ok: true, result: {} } + } +})) + +import { enableWslHostAutomations } from './wsl-host-automation-enable' +import { + disableWslHostAutomations, + readWslHostAutomationStates, + restoreWslHostAutomations +} from './wsl-host-automation-disable' + +beforeEach(() => { + hostRows.clear() + updates.length = 0 + throwOnUpdate.value = false + updateAttempts.count = 0 +}) + +describe('enableWslHostAutomations', () => { + it('turns on only automations still exactly as the move left them', async () => { + hostRows.set('untouched', { enabled: false, updatedAt: 10 }) + hostRows.set('edited-on-host', { enabled: false, updatedAt: 99 }) + hostRows.set('already-on', { enabled: true, updatedAt: 10 }) + await enableWslHostAutomations('/user-data', 'wsl:Ubuntu', [ + { id: 'untouched', updatedAt: 10 }, + { id: 'edited-on-host', updatedAt: 10 }, + { id: 'already-on', updatedAt: 10 }, + { id: 'deleted-on-host', updatedAt: 10 } + ]) + expect(updates).toEqual(['untouched']) + }) + + it('throws when the host refuses one, so the step stays pending and retries', async () => { + hostRows.set('a', { enabled: false, updatedAt: 1 }) + failNextUpdate.value = true + await expect( + enableWslHostAutomations('/user-data', 'wsl:Ubuntu', [{ id: 'a', updatedAt: 1 }]) + ).rejects.toThrow('wsl_move_automation_enable_failed:busy') + await enableWslHostAutomations('/user-data', 'wsl:Ubuntu', [{ id: 'a', updatedAt: 1 }]) + expect(updates).toEqual(['a']) + }) +}) + +describe('undo on the host', () => { + it("reads each automation's state there; one deleted there reads as off", async () => { + hostRows.set('on', { enabled: true, updatedAt: 10 }) + hostRows.set('off', { enabled: false, updatedAt: 10 }) + expect( + await readWslHostAutomationStates('/user-data', 'wsl:Ubuntu', ['on', 'off', 'deleted']) + ).toEqual({ on: true, off: false, deleted: false }) + expect(updates).toEqual([]) + }) + + it('turns them off, and restore turns them back on', async () => { + hostRows.set('on', { enabled: true, updatedAt: 10 }) + expect(await disableWslHostAutomations('/user-data', 'wsl:Ubuntu', ['on'])).toEqual(['on']) + expect(hostRows.get('on')?.enabled).toBe(false) + await restoreWslHostAutomations('/user-data', 'wsl:Ubuntu', ['on']) + expect(hostRows.get('on')?.enabled).toBe(true) + }) + + it('turns back on what it turned off before throwing, so a refused undo changes nothing', async () => { + hostRows.set('a', { enabled: true, updatedAt: 1 }) + hostRows.set('b', { enabled: true, updatedAt: 1 }) + failOnUpdateOf.id = 'b' + await expect(disableWslHostAutomations('/user-data', 'wsl:Ubuntu', ['a', 'b'])).rejects.toThrow( + 'wsl_move_automation_disable_failed:busy' + ) + expect(hostRows.get('a')?.enabled).toBe(true) + expect(hostRows.get('b')?.enabled).toBe(true) + }) + + it('carries what it could not turn back on on the error', async () => { + hostRows.set('a', { enabled: true, updatedAt: 1 }) + hostRows.set('b', { enabled: true, updatedAt: 1 }) + failOnUpdateOf.id = 'b' + failOnUpdateOf.thenAlso = 'a' + const error = await disableWslHostAutomations('/user-data', 'wsl:Ubuntu', ['a', 'b']).catch( + (thrown: unknown) => thrown + ) + expect(error).toMatchObject({ leftOff: ['a'] }) + expect(hostRows.get('a')?.enabled).toBe(false) + }) + + it('asks the host nothing when no automation needs turning off', async () => { + expect(await disableWslHostAutomations('/user-data', 'wsl:Ubuntu', [])).toEqual([]) + expect(updates).toEqual([]) + }) + + it('stops at a host that stops answering, leaving the rest off rather than wait on each', async () => { + for (const id of ['a', 'b', 'c']) { + hostRows.set(id, { enabled: false, updatedAt: 1 }) + } + throwOnUpdate.value = true + const error = await restoreWslHostAutomations('/user-data', 'wsl:Ubuntu', [ + 'a', + 'b', + 'c' + ]).catch((thrown: unknown) => thrown) + expect(error).toMatchObject({ leftOff: ['a', 'b', 'c'] }) + expect(updateAttempts.count).toBe(1) + }) +}) diff --git a/src/main/ipc/wsl-host-automation-enable.ts b/src/main/ipc/wsl-host-automation-enable.ts new file mode 100644 index 00000000000..ee7362eb3d6 --- /dev/null +++ b/src/main/ipc/wsl-host-automation-enable.ts @@ -0,0 +1,66 @@ +/** A WSL move's last host step: the moved automations go back on, through the host's own API. */ +import { z } from 'zod' +import { encodePairingOffer, parsePairingCode } from '../../shared/pairing' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { sendSupportedMigrationRequest } from '../ssh/orcad-migration-catalog-client' +import { getWslHostService } from '../wsl/wsl-host-service-instance' +import type { WslMoveMovedAutomation } from '../persistence/moving-wsl-projects/wsl-move-host-automations-rewrite' + +// The host's automation service message for an id it does not hold. +export const AUTOMATION_NOT_FOUND = 'Automation not found' + +export const ShownAutomationSchema = z.object({ + automation: z.object({ enabled: z.boolean(), updatedAt: z.number() }) +}) + +export type WslHostAutomationApi = ( + method: 'automation.show' | 'automation.update', + params: unknown +) => ReturnType + +/** The host's automation API, never booting its distro; throws when the host is not reachable. */ +export async function openWslHostAutomationApi( + userDataPath: string, + environmentId: string +): Promise { + const service = getWslHostService() + if (!service?.recordForEnvironment(environmentId)) { + throw new Error('wsl_move_host_unavailable') + } + await service.ensureForRuntimeCall(environmentId) + const environment = resolveEnvironment(userDataPath, environmentId) + const pairing = parsePairingCode(encodePairingOffer(getPreferredPairingOffer(environment))) + if (!pairing) { + throw new Error('orcad_migration_pairing_code_invalid') + } + const options = environment.runtimeId ? { expectedRuntimeId: environment.runtimeId } : {} + return (method, params) => sendSupportedMigrationRequest(pairing, method, params, options) +} + +export async function enableWslHostAutomations( + userDataPath: string, + environmentId: string, + automations: readonly WslMoveMovedAutomation[] +): Promise { + const request = await openWslHostAutomationApi(userDataPath, environmentId) + for (const moved of automations) { + const shown = await request('automation.show', { id: moved.id }) + if (!shown.ok) { + // Deleted on the host since the move: nothing left to turn on. + if (shown.error.message.includes(AUTOMATION_NOT_FOUND)) { + continue + } + throw new Error(`wsl_move_automation_read_failed:${shown.error.message}`) + } + const row = ShownAutomationSchema.parse(shown.result).automation + // On already, or edited on the host since the move: the host's state is the user's choice. + if (row.enabled || moved.updatedAt === null || row.updatedAt !== moved.updatedAt) { + continue + } + const updated = await request('automation.update', { id: moved.id, updates: { enabled: true } }) + if (!updated.ok) { + throw new Error(`wsl_move_automation_enable_failed:${updated.error.message}`) + } + } +} diff --git a/src/main/ipc/wsl-host-handlers-user-actions.test.ts b/src/main/ipc/wsl-host-handlers-user-actions.test.ts new file mode 100644 index 00000000000..012dbc68874 --- /dev/null +++ b/src/main/ipc/wsl-host-handlers-user-actions.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' + +type Handler = (event: unknown, request?: unknown) => unknown + +const handlers = vi.hoisted(() => new Map()) +vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, + ipcMain: { + removeHandler: (channel: string) => handlers.delete(channel), + handle: (channel: string, handler: Handler) => handlers.set(channel, handler) + } +})) +vi.mock('../wsl/wsl-host-connector', () => ({ createWslHostConnector: vi.fn() })) +vi.mock('../wsl/wsl-host-e2e-fake-connector', () => ({ + createFakeWslHostConnector: vi.fn(), + readFakeWslHostsConfig: () => null +})) +vi.mock('../orcad/orcad-artifact-materializer', () => ({ hasOrcadTemplate: () => true })) +vi.mock('./runtime-environments/runtime-environment-removal-cleanup', () => ({ + retireRemovedRuntimeEnvironment: vi.fn() +})) +vi.mock('./runtime-environments/runtime-environment-status-probe', () => ({ + getRuntimeEnvironmentStatus: vi.fn() +})) +vi.mock('./runtime-environments/runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: vi.fn() +})) +vi.mock('./runtime-environments/runtime-environment-request-connections', () => ({ + isRemoteRuntimeConnectionReady: () => false +})) +vi.mock('../ssh/orcad-terminal-census-client', () => ({ requestOrcadTerminalCensus: vi.fn() })) +const service = vi.hoisted(() => ({ + add: vi.fn( + async (): Promise<{ + ok: boolean + environmentId?: string + kind?: string + message?: string + }> => ({ + ok: true, + environmentId: 'wsl:Ubuntu' + }) + ), + remove: vi.fn(async () => ({ stopped: true })), + recordForEnvironment: vi.fn(() => ({ distro: 'Ubuntu', environmentId: 'wsl:Ubuntu' })) +})) +vi.mock('../wsl/wsl-host-service-instance', () => ({ + getWslHostService: () => service, + setWslHostService: vi.fn() +})) + +import { registerWslHostHandlers } from './wsl-host-handlers' +import { onWslHostUserAction, type WslHostUserAction } from '../wsl/wsl-host-user-actions' + +function register(): void { + registerWslHostHandlers({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the add and remove handlers run, and they read the active runtime setting alone. + store: { getSettings: () => ({ activeRuntimeEnvironmentId: null }) } as never, + getUserDataPath: () => '/user-data', + invalidateTransport: vi.fn() + }) +} + +describe('WSL host IPC: the user Add and Remove (D3a)', () => { + // Removes are reported by the host service itself (see wsl-host-service.test.ts), so both the + // sidebar and Settings paths count; here the service is mocked and only the Add is the IPC's. + it('reports a successful Add, never a failed one', async () => { + register() + const heard: WslHostUserAction[] = [] + const stop = onWslHostUserAction((action) => heard.push(action)) + await handlers.get('wslHosts:remove')?.({}, { environmentId: 'wsl:Ubuntu' }) + await handlers.get('wslHosts:add')?.({}, { distro: 'Ubuntu' }) + service.add.mockResolvedValueOnce({ ok: false, kind: 'failed', message: 'no' }) + await handlers.get('wslHosts:add')?.({}, { distro: 'Debian' }) + stop() + expect(heard).toEqual([{ kind: 'added', distro: 'Ubuntu' }]) + }) +}) diff --git a/src/main/ipc/wsl-host-handlers.ts b/src/main/ipc/wsl-host-handlers.ts index d8a18d90a9d..9392bd0cd0a 100644 --- a/src/main/ipc/wsl-host-handlers.ts +++ b/src/main/ipc/wsl-host-handlers.ts @@ -1,4 +1,5 @@ import { BrowserWindow, ipcMain } from 'electron' +import { noteWslHostUserAction } from '../wsl/wsl-host-user-actions' import { WSL_HOST_STATUS_CHANNEL, type WslHostAddResult, @@ -218,8 +219,16 @@ export function registerWslHostHandlers(args: WslHostHandlerArgs): void { ipcMain.handle('wslHosts:listDistros', () => wsl.listDistros()) ipcMain.handle( 'wslHosts:add', - (_event, request: { distro: string; startIfStopped?: boolean }): Promise => - wsl.add(request) + async ( + _event, + request: { distro: string; startIfStopped?: boolean } + ): Promise => { + const result = await wsl.add(request) + if (result.ok) { + noteWslHostUserAction({ kind: 'added', distro: request.distro }) + } + return result + } ) ipcMain.handle('wslHosts:remove', async (_event, request: { environmentId: string }) => { if (args.store.getSettings().activeRuntimeEnvironmentId === request.environmentId) { diff --git a/src/main/ipc/wsl-move-driver-handlers.test.ts b/src/main/ipc/wsl-move-driver-handlers.test.ts new file mode 100644 index 00000000000..63d12888ddd --- /dev/null +++ b/src/main/ipc/wsl-move-driver-handlers.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate' + +type Handler = (event: unknown, request?: unknown) => unknown + +const handlers = vi.hoisted(() => new Map()) +vi.mock('electron', () => ({ + ipcMain: { + removeHandler: (channel: string) => handlers.delete(channel), + handle: (channel: string, handler: Handler) => handlers.set(channel, handler) + } +})) +const driver = vi.hoisted(() => ({ + previewWslMove: vi.fn(async () => ({ outcome: 'ready' })), + moveProjectsToWslHost: vi.fn(async () => ({ outcome: 'moved' })), + cancelWslMove: vi.fn(() => true) +})) +vi.mock('../persistence/moving-wsl-projects/wsl-move-driver', () => driver) +const track = vi.hoisted(() => vi.fn()) +vi.mock('../telemetry/client', () => ({ track })) + +import { registerWslMoveHandlers } from './wsl-move-handlers' + +const MAIN_WINDOW_ID = 7 +const progress = vi.fn() +const deps = vi.fn(() => ({ now: () => new Date(0), progress })) +const invoke = (channel: string, request?: unknown, senderId = MAIN_WINDOW_ID) => + handlers.get(channel)?.({ sender: { id: senderId } }, request) + +beforeEach(() => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + deps.mockClear() + track.mockClear() + for (const fn of Object.values(driver)) { + fn.mockClear() + } + registerWslMoveHandlers( + () => '/unused', + (senderId) => senderId === MAIN_WINDOW_ID, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The driver is mocked; the handler only passes deps through. + deps as never + ) +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('WSL move driver IPC', () => { + it('previews, starts and cancels a move from the main window', async () => { + expect(await invoke('wslMove:preview', { distro: 'Ubuntu', repoIds: ['r1'] })).toEqual({ + outcome: 'ready' + }) + expect(await invoke('wslMove:start', { distro: 'Ubuntu' })).toEqual({ outcome: 'moved' }) + expect(invoke('wslMove:cancel', 'Ubuntu')).toBe(true) + expect(driver.moveProjectsToWslHost).toHaveBeenCalledWith( + expect.objectContaining({ now: expect.any(Function) }), + { distro: 'Ubuntu' } + ) + // The move's start and outcome are reported. + expect(track.mock.calls.map(([, props]) => props.outcome)).toEqual(['started', 'finished']) + }) + + it('is unreachable with the internal WSL hosts gate closed: nothing is built or run', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + const refused = { outcome: 'refused', code: 'wsl_move_unavailable' } + expect(await invoke('wslMove:preview', { distro: 'Ubuntu' })).toEqual(refused) + expect(await invoke('wslMove:start', { distro: 'Ubuntu' })).toEqual(refused) + expect(invoke('wslMove:cancel', 'Ubuntu')).toBe(false) + expect(deps).not.toHaveBeenCalled() + expect(driver.previewWslMove).not.toHaveBeenCalled() + expect(driver.moveProjectsToWslHost).not.toHaveBeenCalled() + expect(driver.cancelWslMove).not.toHaveBeenCalled() + }) + + it('refuses another window, and a malformed request', async () => { + expect(await invoke('wslMove:start', { distro: 'Ubuntu' }, 99)).toMatchObject({ + code: 'wsl_move_unavailable', + detail: 'not-main-window' + }) + for (const bad of [ + undefined, + 'Ubuntu', + { distro: '' }, + { distro: '..\\x' }, + { distro: 'U', repoIds: [1] } + ]) { + expect(await invoke('wslMove:start', bad)).toMatchObject({ detail: 'invalid-request' }) + } + expect(driver.moveProjectsToWslHost).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/wsl-move-driver-wiring.ts b/src/main/ipc/wsl-move-driver-wiring.ts new file mode 100644 index 00000000000..a6ca02c965c --- /dev/null +++ b/src/main/ipc/wsl-move-driver-wiring.ts @@ -0,0 +1,283 @@ +/** + * The WSL move driver's production ports: the profile store, the runtime's live terminals, the hook + * server's agent status, the daemon, the window and the distro's WSL host. Built per call, and only + * after the internal WSL hosts gate is checked. + */ +import { randomUUID } from 'node:crypto' +import { app } from 'electron' +import { encodePairingOffer } from '../../shared/pairing' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { findWslHostDeployment } from '../../shared/runtime-environment-wsl-host-store' +import { + WSL_MOVE_PROGRESS_CHANNEL, + type WslMoveProgressEvent +} from '../../shared/wsl-move/wsl-move-driver-types' +import { agentHookServer } from '../agent-hooks/server' +import { getDaemonHistoryDir } from '../daemon/daemon-launch-paths' +import { + countInProcessFallbackTerminals, + listLiveDaemonSessions +} from '../daemon/daemon-provider-state' +import { HistoryReader } from '../daemon/history-reader' +import { orcadMigrationDestinationFor } from '../managed-server/lifecycle/orcad-runtime-conversion-wiring' +import type { Store } from '../persistence' +import { exportWslMoveManifest } from '../persistence/moving-wsl-projects/wsl-move-manifest-export' +import { runGatedWslMoveDesktopRewritePass } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite' +import type { + WslMoveDriverDeps, + WslMoveHost +} from '../persistence/moving-wsl-projects/wsl-move-driver-ports' +import { proveWslMoveTerminalsExited } from '../persistence/moving-wsl-projects/wsl-move-exit-proof' +import { + collectWslMoveGateInputs, + listWslMovePanes, + type WslMoveGateFactSources +} from '../persistence/moving-wsl-projects/wsl-move-gate-facts' +import { + classifyWslGuestPaneForeground, + readWslGuestPaneShellCwd +} from '../persistence/moving-wsl-projects/wsl-move-guest-foreground' +import { + listWslMoveSleptSessionPanes, + readWslMoveSleptScrollbackFromHistory, + withSleptSessionPanes, + wslMoveTabTitle, + wslMoveSleptSpoolRoot +} from '../persistence/moving-wsl-projects/wsl-move-slept-scrollback' +import { scheduleWslMoveRewriteRetry } from '../persistence/moving-wsl-projects/wsl-move-rewrite-retry' +import { + captureWslMoveRestoreHints, + type WslMoveRestoreHintPane +} from '../persistence/moving-wsl-projects/wsl-move-restore-hints' +import { + isWslMoveProjectFenced, + wslMoveProjectRefForWorkspace +} from '../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { buildWslMoveShellHistoryBlobs } from '../persistence/moving-wsl-projects/wsl-move-shell-history-blobs' +import { inspectPtyProviderProcessForRenderer } from '../providers/pty-process-inspection' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { getTrustedUIRendererWebContents, getTrustedUIRendererWindow } from './ui' +import { registerWslMoveHandlers } from './wsl-move-handlers' +import { registerWslMoveOrchestrator } from './wsl-move-orchestrator-wiring' +import { createWslMoveUndoPorts } from './wsl-move-undo-wiring' +import { connectWslMoveHostService, type WslMoveHostConnectMode } from './wsl-move-host-connect' +import { wslMoveReloadableWindow } from './wsl-move-reloadable-window' +import { setWslMoveReloadableWindow } from '../persistence/moving-wsl-projects/wsl-move-window-write-hold' +import { + commitRemoteOrcadMigrationCatalog, + listRemoteOrcadRepositories, + stageRemoteOrcadMigrationBlobChunk +} from '../ssh/orcad-migration-catalog-client' +import { listRemoteOrcadFolderCatalog } from '../ssh/orcad-server-folder-workspaces' +import { makeTerminalScrollbackSnapshotRef } from '../terminal-scrollback-snapshots' +import { pauseWorkspacesForProjectMove } from '../window/project-move-pause' +import { getWslHostService } from '../wsl/wsl-host-service-instance' +import { readWslGuestProcessTable } from '../wsl/wsl-guest-process-table' +import { isWslDistroRegistered } from '../wsl/wsl-distro-registry-id' +import { defaultWslOrcadEnvironmentId } from '../wsl/wsl-orcad-deploy' +import { ptyPaneKey } from './pty/pane/key-state' +import { tryGetProviderForPty } from './pty/provider/registry' +import { getRuntimeEnvironmentStatus } from './runtime-environments/runtime-environment-status-probe' +/** The move's IPC handlers; its driver reads every move dependency from this module. */ +export function registerWslMoveWiring(store: Store, runtime: OrcaRuntimeService): void { + setWslMoveReloadableWindow(() => { + const contents = getTrustedUIRendererWebContents() + return contents && wslMoveReloadableWindow(contents) + }) + registerWslMoveHandlers( + () => app.getPath('userData'), + (senderId) => getTrustedUIRendererWebContents()?.id === senderId, + () => createWslMoveDriverDeps(store, runtime, 'user'), + () => createWslMoveUndoPorts(store, app.getPath('userData')) + ) + registerWslMoveOrchestrator(store, () => createWslMoveDriverDeps(store, runtime, 'automatic')) +} + +/** Each live bridge pane's shell folder, one process read per distro. */ +async function readWslMovePaneShellCwds( + panes: readonly WslMoveRestoreHintPane[] +): Promise> { + const cwds = new Map() + const distros = new Set(panes.flatMap((pane) => (pane.wslDistro ? [pane.wslDistro] : []))) + for (const distro of distros) { + const rows = await readWslGuestProcessTable(distro) + for (const pane of panes) { + const cwd = + rows && pane.wslDistro === distro ? readWslGuestPaneShellCwd(rows, pane.paneKey) : null + if (cwd) { + cwds.set(pane.paneKey, cwd) + } + } + } + return cwds +} + +export function createWslMoveDriverDeps( + store: Store, + runtime: OrcaRuntimeService, + hostConnect: WslMoveHostConnectMode +): WslMoveDriverDeps { + const userDataPath = app.getPath('userData') + const facts: WslMoveGateFactSources = { + listTerminals: (workspaceIds) => runtime.listWslMoveLiveTerminals(workspaceIds), + paneKeyForPty: (ptyId) => ptyPaneKey.get(ptyId) ?? runtime.wslMovePaneKeyForPty(ptyId), + inspect: async (ptyId) => { + const provider = tryGetProviderForPty(ptyId) + if (!provider) { + throw new Error('terminal_gone') + } + // After a relaunch, a pane the window has not reopened is still live in the daemon. + return inspectPtyProviderProcessForRenderer(provider, ptyId, { + scanChildProcesses: true, + includeUnattached: true + }) + }, + agentRows: (paneKey) => agentHookServer.getStatusSnapshotForPane(paneKey), + ...(process.platform === 'win32' + ? { + guestForegrounds: async (distro: string, paneKeys: readonly string[]) => { + const rows = await readWslGuestProcessTable(distro) + if (!rows) { + console.warn('[wsl-move] Could not list processes in', distro) + return new Map() + } + return new Map(paneKeys.map((key) => [key, classifyWslGuestPaneForeground(rows, key)])) + } + } + : {}), + scrollbackBytes: async (tabId, leafId) => + (await store.statTerminalScrollbackSnapshotSize( + makeTerminalScrollbackSnapshotRef(tabId, leafId) + )) ?? 0, + automationRuns: () => store.listAutomationRuns(), + dispatches: () => + (runtime.getExistingOrchestrationDb()?.listLegacyWorkerTerminalRecoveryRows() ?? []).map( + (row) => ({ + dispatchId: row.dispatch_id, + worktreeId: row.worktree_id, + state: row.worker_state + }) + ) + } + return { + userDataPath, + readCatalog: () => ({ + repos: store.getRepos(), + folderWorkspaces: store.getFolderWorkspaces(), + projectGroups: store.getProjectGroups() + }), + connectHost: (distro) => connectWslMoveHost(userDataPath, distro, hostConnect), + isDistroRegistered: (distro, distroGuid) => isWslDistroRegistered(distro, distroGuid), + exportManifest: (request) => exportWslMoveManifest(store, request), + gateInputs: (scope) => collectWslMoveGateInputs(facts, scope), + listPanes: (scope) => listWslMovePanes(facts, scope), + ...(process.platform === 'win32' ? { shellCwds: readWslMovePaneShellCwds } : {}), + sleptSessionPanes: (workspaceIds, reported) => + listWslMoveSleptSessionPanes(store.getWorkspaceSession(), workspaceIds, reported), + quiesce: { + sleepWorkspaces: async (workspaceIds) => { + const window = getTrustedUIRendererWindow() + if (!window) { + throw new Error('renderer_unavailable') + } + // Main's pause barrier: keeps browser tabs and waits for the window's session to persist. + const outcomes = await pauseWorkspacesForProjectMove(window, runtime, workspaceIds) + return { + failedWorktreeIds: outcomes + .filter((outcome) => outcome.failure || outcome.chatFailed || outcome.vmSuspendFailure) + .map((outcome) => outcome.worktreeId) + } + }, + livePtyIds: (workspaceIds) => + runtime.listWslMoveLiveTerminals(workspaceIds).map((terminal) => terminal.ptyId), + captureRestoreHints: (panes, distro) => + captureWslMoveRestoreHints(new HistoryReader(getDaemonHistoryDir()), panes, distro), + proveExited: async (scope) => { + const fallback = await countInProcessFallbackTerminals() + return proveWslMoveTerminalsExited( + { + listSessions: listLiveDaemonSessions, + countFallbackSessions: () => fallback, + isScopeFenced: () => + [...scope.workspaceKeys].every((key) => + isWslMoveProjectFenced(store, wslMoveProjectRefForWorkspace(key)) + ) + }, + scope + ) + } + }, + snapshots: store, + sleptScrollback: (panes, distro, migrationId, workspaceIds, reported) => + readWslMoveSleptScrollbackFromHistory( + new HistoryReader(getDaemonHistoryDir()), + withSleptSessionPanes( + panes, + listWslMoveSleptSessionPanes(store.getWorkspaceSession(), workspaceIds, reported) + ), + distro, + wslMoveSleptSpoolRoot(userDataPath, migrationId), + (tabId) => wslMoveTabTitle(store.getWorkspaceSession(), tabId) + ), + shellHistory: (distro, worktrees) => buildWslMoveShellHistoryBlobs({ distro, worktrees }), + // The pass reloads the window it held at commit (wsl-move-window-write-hold.ts). + finishOnDesktop: async () => { + await runGatedWslMoveDesktopRewritePass({ userDataPath }) + // A step the host could not take yet (automations back on) retries in this run too. + scheduleWslMoveRewriteRetry(userDataPath) + }, + progress: (event: WslMoveProgressEvent) => { + const window = getTrustedUIRendererWindow() + if (window && !window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.send(WSL_MOVE_PROGRESS_CHANNEL, event) + } + }, + newMigrationId: () => randomUUID(), + now: () => new Date() + } +} + +async function connectWslMoveHost( + userDataPath: string, + distro: string, + mode: WslMoveHostConnectMode +): Promise { + const service = getWslHostService() + const environmentId = defaultWslOrcadEnvironmentId(distro) + if (!service || !(await connectWslMoveHostService(service, userDataPath, distro, mode))) { + return null + } + const environment = resolveEnvironment(userDataPath, environmentId) + const status = await getRuntimeEnvironmentStatus(userDataPath, environmentId) + if (!status.ok) { + return null + } + const pairingCode = encodePairingOffer(getPreferredPairingOffer(environment)) + const options = environment.runtimeId ? { expectedRuntimeId: environment.runtimeId } : {} + const distroGuid = findWslHostDeployment(userDataPath, environmentId)?.distroId ?? undefined + return { + environmentId, + name: environment.name, + ...(distroGuid ? { distroGuid } : {}), + capabilities: status.result.capabilities ?? [], + catalog: orcadMigrationDestinationFor(environment), + commitWithBlobs: (manifest, expectedBlobs) => + commitRemoteOrcadMigrationCatalog(pairingCode, manifest, { ...options, expectedBlobs }), + blobs: { + stageChunk: (request) => stageRemoteOrcadMigrationBlobChunk(pairingCode, request, options) + }, + readHolding: async () => { + const [repositories, folders] = await Promise.all([ + listRemoteOrcadRepositories(pairingCode, options), + listRemoteOrcadFolderCatalog(pairingCode, options) + ]) + return { + repoIds: repositories.map((repo) => repo.id), + folderWorkspaceIds: folders.folderWorkspaces.map((folder) => folder.id), + // The host keeps no per-distro generation it can report yet. + highestGeneration: 0 + } + } + } +} diff --git a/src/main/ipc/wsl-move-handlers.test.ts b/src/main/ipc/wsl-move-handlers.test.ts index b3c5647a747..2ba5d90fb64 100644 --- a/src/main/ipc/wsl-move-handlers.test.ts +++ b/src/main/ipc/wsl-move-handlers.test.ts @@ -6,6 +6,14 @@ import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' import { listWslMoveCutovers } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal' +import { + loadWslMoveFence, + resetWslMoveFenceForTests +} from '../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { + isWslMoveWindowWriteHeld, + setWslMoveReloadableWindow +} from '../persistence/moving-wsl-projects/wsl-move-window-write-hold' import { registerWslMoveDesktopRewriteStore, resetWslMoveDesktopRewriteStoresForTests @@ -29,15 +37,25 @@ beforeEach(() => { vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') registerWslMoveDesktopRewriteStore({ id: 'renderer-a', side: 'renderer' }) seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) - registerWslMoveHandlers(() => userDataPath) + flushStorageData.mockReset() + registerWslMoveHandlers( + () => userDataPath, + (senderId) => senderId === MAIN_WINDOW_ID + ) + loadWslMoveFence(userDataPath) }) afterEach(() => { vi.unstubAllEnvs() resetWslMoveDesktopRewriteStoresForTests() + resetWslMoveFenceForTests() rmSync(userDataPath, { recursive: true, force: true }) }) -const invoke = (channel: string, request?: unknown) => handlers.get(channel)?.({}, request) +const MAIN_WINDOW_ID = 7 +const flushStorageData = vi.fn() +const sender = (id: number) => ({ id, session: { flushStorageData } }) +const invoke = (channel: string, request?: unknown, senderId = MAIN_WINDOW_ID) => + handlers.get(channel)?.({ sender: sender(senderId) }, request) describe('WSL move IPC', () => { it('lists pending renderer rewrites and records one', () => { @@ -47,9 +65,9 @@ describe('WSL move IPC', () => { expect( invoke('wslMove:recordStoreRewritten', { migrationId: 'move-1', storeId: 'renderer-a' }) ).toEqual({ recorded: true }) - // One renderer acknowledgment does not complete the fixture's required rewrite stores. + // Its only registered store is now recorded, so the move finishes its rewrite. expect(listWslMoveCutovers(userDataPath)[0]).toMatchObject({ - phase: 'destination-committed', + phase: 'client-rewritten', rewrittenStores: ['renderer-a'] }) }) @@ -76,4 +94,154 @@ describe('WSL move IPC', () => { ).toEqual({ recorded: false, reason: 'gated-off' }) expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([]) }) + + it('writes the window storage to disk before the ledger entry', () => { + flushStorageData.mockImplementation(() => { + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([]) + }) + invoke('wslMove:recordStoreRewritten', { migrationId: 'move-1', storeId: 'renderer-a' }) + expect(flushStorageData).toHaveBeenCalledOnce() + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual(['renderer-a']) + }) + + it('records only for the main window, whose storage holds the renderer stores', () => { + expect( + invoke('wslMove:recordStoreRewritten', { migrationId: 'move-1', storeId: 'renderer-a' }, 8) + ).toEqual({ recorded: false, reason: 'not-main-window' }) + expect(flushStorageData).not.toHaveBeenCalled() + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([]) + }) + + it('brings a moved project back, validating the renderer input', async () => { + for (const bad of [undefined, null, { kind: 'repo' }, { kind: 'x', id: 'repo-1' }]) { + expect(await invoke('wslMove:useProjectHereAgain', bad)).toEqual({ + released: false, + reason: 'not-moved' + }) + } + expect(listWslMoveCutovers(userDataPath)).toHaveLength(1) + + const page = { loaded: () => {} } + const mainWindow = { + id: MAIN_WINDOW_ID, + isDestroyed: () => false, + reload: vi.fn(), + allowUnload: () => () => {}, + onLoadedOrGone: (listener: () => void) => { + page.loaded = listener + return () => {} + } + } + setWslMoveReloadableWindow(() => mainWindow) + const result = await handlers.get('wslMove:useProjectHereAgain')?.( + { sender: mainWindow }, + { kind: 'repo', id: 'repo-1' } + ) + expect(result).toMatchObject({ released: true }) + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + // The page held only chats: its saves are dropped until the reloaded page has loaded. + expect(isWslMoveWindowWriteHeld()).toBe(true) + await new Promise((resolve) => setImmediate(resolve)) + expect(mainWindow.reload).toHaveBeenCalledOnce() + page.loaded() + expect(isWslMoveWindowWriteHeld()).toBe(false) + setWslMoveReloadableWindow(() => null) + }) + + // "p5 moved after this project" refused the undo after killing p1's terminals. + it('refuses a superseded undo before it stops anything on the host', async () => { + const rewritten = { rewrittenStores: ['renderer-a'] } + invoke('wslMove:recordStoreRewritten', { migrationId: 'move-1', storeId: 'renderer-a' }) + seedWslMoveCutover(userDataPath, { ...cutover('move-1', 'retained'), ...rewritten }) + seedWslMoveCutover(userDataPath, { + ...cutover( + 'move-2', + 'retained', + { supersedesMigrationId: 'move-1' }, + { repoIds: ['repo-5'] } + ), + ...rewritten + }) + loadWslMoveFence(userDataPath) + const stopHostCopies = vi.fn(async () => 'stopped' as const) + registerWslMoveHandlers( + () => userDataPath, + (senderId) => senderId === MAIN_WINDOW_ID, + undefined, + () => ({ stopHostCopies }) + ) + expect(await invoke('wslMove:useProjectHereAgain', { kind: 'repo', id: 'repo-1' })).toEqual({ + released: false, + reason: 'superseded', + newerMove: { repoIds: ['repo-5'], folderWorkspaceIds: [] } + }) + expect(stopHostCopies).not.toHaveBeenCalled() + expect(listWslMoveCutovers(userDataPath)).toHaveLength(2) + }) + + // P6's unsaved README blocked bringing p9 back. + it("refuses only for unsaved files in the move's own workspaces, before stopping the host", async () => { + const stopHostCopies = vi.fn(async () => 'stopped' as const) + registerWslMoveHandlers( + () => userDataPath, + (senderId) => senderId === MAIN_WINDOW_ID, + undefined, + () => ({ stopHostCopies }) + ) + const request = (unsavedWorkspaceIds: unknown) => ({ + kind: 'repo', + id: 'repo-1', + unsavedWorkspaceIds + }) + expect( + await invoke('wslMove:useProjectHereAgain', request(['repo-2::/w', 'repo-1::/w', 7])) + ).toEqual({ released: false, reason: 'unsaved-files', unsavedWorkspaceIds: ['repo-1::/w'] }) + expect(stopHostCopies).not.toHaveBeenCalled() + expect(listWslMoveCutovers(userDataPath)).toHaveLength(1) + + setWslMoveReloadableWindow(() => null) + expect(await invoke('wslMove:useProjectHereAgain', request(['repo-2::/w']))).toMatchObject({ + released: true + }) + expect(stopHostCopies).toHaveBeenCalledOnce() + }) + + it('brings a project back only for the main window, and only with the gate open', async () => { + expect(await invoke('wslMove:useProjectHereAgain', { kind: 'repo', id: 'repo-1' }, 8)).toEqual({ + released: false, + reason: 'failed', + detail: 'not-main-window' + }) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + // Gate first: even malformed input from a non-main window gets the gated answer. + expect(await handlers.get('wslMove:useProjectHereAgain')?.({}, null)).toEqual({ + released: false, + reason: 'gated-off' + }) + expect(listWslMoveCutovers(userDataPath)).toHaveLength(1) + }) + + // The host's copy of the agent kept running beside the one resumed here. + it.each([ + ['unreachable', 'host-unreachable'], + ['still-running', 'host-still-running'] + ] as const)( + 'keeps the move when the host copies are %s, so no agent resumes here beside them', + async (stop, reason) => { + const stopHostCopies = vi.fn(async () => stop) + registerWslMoveHandlers( + () => userDataPath, + (senderId) => senderId === MAIN_WINDOW_ID, + undefined, + () => ({ stopHostCopies }) + ) + expect(await invoke('wslMove:useProjectHereAgain', { kind: 'repo', id: 'repo-1' })).toEqual({ + released: false, + reason + }) + expect(stopHostCopies).toHaveBeenCalledOnce() + expect(listWslMoveCutovers(userDataPath)).toHaveLength(1) + expect(isWslMoveWindowWriteHeld()).toBe(false) + } + ) }) diff --git a/src/main/ipc/wsl-move-handlers.ts b/src/main/ipc/wsl-move-handlers.ts index ddca604ed1f..9a3ab754a50 100644 --- a/src/main/ipc/wsl-move-handlers.ts +++ b/src/main/ipc/wsl-move-handlers.ts @@ -1,19 +1,58 @@ -import { ipcMain } from 'electron' +/** + * IPC for a WSL move: the move itself (preview, start, cancel), the renderer half of a committed + * move's desktop rewrite, and bringing a moved project back to this computer. Every channel answers + * empty / refuses while the internal WSL hosts gate is closed, before it touches anything. + */ +import { ipcMain, type WebContents } from 'electron' import type { WslMovePendingRendererRewrite, WslMoveRecordStoreRewrittenResult } from '../../shared/wsl-move/wsl-move-renderer-rewrite-types' +import type { + WslMovePreview, + WslMoveRefusal, + WslMoveResult, + WslMoveUnfinished +} from '../../shared/wsl-move/wsl-move-driver-types' +import { parseWslMoveRequest } from '../../shared/wsl-move/wsl-move-request' +import type { WslMoveUseLocallyResult } from '../../shared/wsl-move/wsl-moved-project' import { listPendingWslMoveRendererRewrites, recordWslMoveRendererStoreRewritten } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite' +import { + cancelWslMove, + moveProjectsToWslHost, + previewWslMove +} from '../persistence/moving-wsl-projects/wsl-move-driver' +import type { WslMoveDriverDeps } from '../persistence/moving-wsl-projects/wsl-move-driver-ports' +import { listUnfinishedWslMoves } from '../persistence/moving-wsl-projects/wsl-move-host-automations-rewrite' +import { runTrackedWslMove } from '../telemetry/wsl-move-telemetry' +import { wslHostsGateOpen } from '../wsl/wsl-host-gate' +import type { WslMoveUndoPorts } from './wsl-move-undo-wiring' +import { releaseWslMovedProjectHere } from './wsl-move-release-here' export const WSL_MOVE_HANDLER_CHANNELS = [ 'wslMove:listPendingRendererRewrites', - 'wslMove:recordStoreRewritten' + 'wslMove:recordStoreRewritten', + 'wslMove:preview', + 'wslMove:start', + 'wslMove:cancel', + 'wslMove:listUnfinished', + 'wslMove:useProjectHereAgain' ] as const -export function registerWslMoveHandlers(getUserDataPath: () => string): void { +type RendererEvent = { sender?: Pick } + +export function registerWslMoveHandlers( + getUserDataPath: () => string, + /** The renderer stores live in the main window's storage; another window's copy is not them. */ + isMainWindowRenderer: (senderId: number) => boolean, + /** Built per call, only once the gate is open. */ + driverDeps?: () => WslMoveDriverDeps, + /** Undo's reach into the host; absent, there is no host to stop. */ + undoPorts: () => WslMoveUndoPorts = () => ({ stopHostCopies: async () => 'stopped' }) +): void { for (const channel of WSL_MOVE_HANDLER_CHANNELS) { ipcMain.removeHandler(channel) } @@ -22,7 +61,8 @@ export function registerWslMoveHandlers(getUserDataPath: () => string): void { ) ipcMain.handle( 'wslMove:recordStoreRewritten', - (_event, request: unknown): WslMoveRecordStoreRewrittenResult => { + (event: RendererEvent, request: unknown): WslMoveRecordStoreRewrittenResult => { + // Renderer input: anything but two strings is an unknown store. if ( typeof request !== 'object' || request === null || @@ -33,10 +73,108 @@ export function registerWslMoveHandlers(getUserDataPath: () => string): void { ) { return { recorded: false, reason: 'unknown-store' } } - return recordWslMoveRendererStoreRewritten(getUserDataPath(), { - migrationId: request.migrationId, - storeId: request.storeId - }) + const sender = event.sender + if (!sender || !isMainWindowRenderer(sender.id)) { + return { recorded: false, reason: 'not-main-window' } + } + return recordWslMoveRendererStoreRewritten( + getUserDataPath(), + { migrationId: request.migrationId, storeId: request.storeId }, + () => sender.session.flushStorageData() + ) + } + ) + registerWslMoveDriverHandlers(getUserDataPath, isMainWindowRenderer, driverDeps) + registerWslMoveUseHereHandler(getUserDataPath, isMainWindowRenderer, undoPorts) +} + +function registerWslMoveDriverHandlers( + getUserDataPath: () => string, + isMainWindowRenderer: (senderId: number) => boolean, + driverDeps: (() => WslMoveDriverDeps) | undefined +): void { + // Gate first: with it closed, no dependency is built and nothing is read. + const refuseUnlessOpen = (event: RendererEvent): WslMoveRefusal | null => { + if (!wslHostsGateOpen() || !driverDeps) { + return { outcome: 'refused', code: 'wsl_move_unavailable' } + } + const sender = event.sender + return sender && isMainWindowRenderer(sender.id) + ? null + : { outcome: 'refused', code: 'wsl_move_unavailable', detail: 'not-main-window' } + } + ipcMain.handle( + 'wslMove:preview', + async (event: RendererEvent, request: unknown): Promise => { + const parsed = parseWslMoveRequest(request) + return ( + refuseUnlessOpen(event) ?? + (parsed && driverDeps ? previewWslMove(driverDeps(), parsed) : invalidRequest()) + ) + } + ) + ipcMain.handle( + 'wslMove:start', + async (event: RendererEvent, request: unknown): Promise => { + const parsed = parseWslMoveRequest(request) + return ( + refuseUnlessOpen(event) ?? + (parsed && driverDeps + ? runTrackedWslMove(driverDeps(), (deps) => moveProjectsToWslHost(deps, parsed)) + : invalidRequest()) + ) + } + ) + ipcMain.handle('wslMove:listUnfinished', (): WslMoveUnfinished[] => + wslHostsGateOpen() ? listUnfinishedWslMoves(getUserDataPath()) : [] + ) + ipcMain.handle( + 'wslMove:cancel', + (event: RendererEvent, distro: unknown): boolean => + refuseUnlessOpen(event) === null && typeof distro === 'string' && cancelWslMove(distro) + ) +} + +function invalidRequest(): WslMoveRefusal { + return { outcome: 'refused', code: 'wsl_move_unavailable', detail: 'invalid-request' } +} + +function registerWslMoveUseHereHandler( + getUserDataPath: () => string, + isMainWindowRenderer: (senderId: number) => boolean, + createUndoPorts: () => WslMoveUndoPorts +): void { + ipcMain.handle( + 'wslMove:useProjectHereAgain', + async (event: { sender?: WebContents }, request: unknown): Promise => { + // Gate first: with it closed, nothing is read. + if (!wslHostsGateOpen()) { + return { released: false, reason: 'gated-off' } + } + const sender = event.sender + if (!sender || !isMainWindowRenderer(sender.id)) { + return { released: false, reason: 'failed', detail: 'not-main-window' } + } + if ( + typeof request !== 'object' || + request === null || + !('kind' in request) || + !('id' in request) || + typeof request.id !== 'string' || + (request.kind !== 'repo' && request.kind !== 'folder') + ) { + return { released: false, reason: 'not-moved' } + } + const ref = + request.kind === 'repo' ? { repoId: request.id } : { folderWorkspaceId: request.id } + return releaseWslMovedProjectHere( + getUserDataPath(), + ref, + 'unsavedWorkspaceIds' in request && Array.isArray(request.unsavedWorkspaceIds) + ? request.unsavedWorkspaceIds.filter((id): id is string => typeof id === 'string') + : [], + createUndoPorts + ) } ) } diff --git a/src/main/ipc/wsl-move-host-connect.test.ts b/src/main/ipc/wsl-move-host-connect.test.ts new file mode 100644 index 00000000000..047e5303f6a --- /dev/null +++ b/src/main/ipc/wsl-move-host-connect.test.ts @@ -0,0 +1,114 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WslHostStore from '../../shared/runtime-environment-wsl-host-store' +import { WslHostService } from '../wsl/wsl-host-service' +import { UBUNTU, makeConnector } from '../wsl/wsl-host-service-test-connector' +import { connectWslMoveHostService } from './wsl-move-host-connect' + +const removal = vi.hoisted(() => ({ pending: false })) +vi.mock('../../shared/runtime-environment-wsl-host-store', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // E11 (#33843): `pendingStop` is optional until that PR lands, so the record gains it here. + findWslHostDeployment: (...args: Parameters) => { + const record = actual.findWslHostDeployment(...args) + return record && removal.pending ? { ...record, pendingStop: { reason: 'x' } } : record + } + } +}) + +describe('the move driver reaching its WSL host', () => { + let userDataPath: string + + function hostService(connector: ReturnType): WslHostService { + const service = new WslHostService({ + userDataPath: () => userDataPath, + isEnabled: () => true, + hasServerFiles: () => true, + publish: () => {}, + pairingChanged: () => {}, + ready: () => {}, + removed: () => {} + }) + service.attach(connector) + return service + } + + async function stoppedAndParkedHost() { + const connector = makeConnector([UBUNTU]) + const service = hostService(connector) + await service.add({ distro: 'Ubuntu' }) + await connector.disconnect('Ubuntu') + connector.distro.state = 'stopped' + service.handleConnectionChange('Ubuntu', null, { + state: 'distro-stopped', + terminals: 'exited', + recovering: false + }) + // A poll finding the distro stopped parks the host. + await expect(service.ensureForRuntimeCall('wsl:ubuntu')).rejects.toThrow('Ubuntu was stopped') + connector.connect.mockClear() + return { connector, service } + } + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-wsl-move-host-connect-')) + removal.pending = false + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + vi.restoreAllMocks() + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it("a user's move connects a parked host with no visible work (E9)", async () => { + const { connector, service } = await stoppedAndParkedHost() + expect(service.hasUserVisibleWork('Ubuntu')).toBe(false) + await expect(connectWslMoveHostService(service, userDataPath, 'Ubuntu', 'user')).resolves.toBe( + true + ) + expect(connector.connect).toHaveBeenCalledTimes(1) + }) + + it('an automatic move, and its startup recovery, never boots a stopped distro (A1)', async () => { + const { connector, service } = await stoppedAndParkedHost() + await expect( + connectWslMoveHostService(service, userDataPath, 'Ubuntu', 'automatic') + ).rejects.toThrow('Ubuntu was stopped') + // Running again is not enough while parked, even with visible work. + connector.distro.state = 'running' + service.setVisibleWork(['wsl:ubuntu']) + await expect( + connectWslMoveHostService(service, userDataPath, 'Ubuntu', 'automatic') + ).rejects.toThrow('Ubuntu was stopped') + expect(connector.connect).not.toHaveBeenCalled() + }) + + it('never connects a host whose Remove is pending, even for the user', async () => { + const { connector, service } = await stoppedAndParkedHost() + removal.pending = true + for (const mode of ['user', 'automatic'] as const) { + await expect(connectWslMoveHostService(service, userDataPath, 'Ubuntu', mode)).resolves.toBe( + false + ) + } + expect(connector.connect).not.toHaveBeenCalled() + }) + + it('leaves an already-connected host alone in either mode: no reconnect', async () => { + const connector = makeConnector([UBUNTU]) + const service = hostService(connector) + await service.add({ distro: 'Ubuntu' }) + connector.connect.mockClear() + for (const mode of ['user', 'automatic'] as const) { + await expect(connectWslMoveHostService(service, userDataPath, 'Ubuntu', mode)).resolves.toBe( + true + ) + } + expect(connector.connect).not.toHaveBeenCalled() + expect(connector.disconnect).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/wsl-move-host-connect.ts b/src/main/ipc/wsl-move-host-connect.ts new file mode 100644 index 00000000000..abc231e9d73 --- /dev/null +++ b/src/main/ipc/wsl-move-host-connect.ts @@ -0,0 +1,48 @@ +/** + * How the move driver reaches its WSL host. A move the user started (the dialog's preview or + * start) may boot the distro and lifts a park; an automatic one (the orchestrator, including its + * startup recovery) never boots it (A1, E9). Neither reconnects a host that is already serving, + * and a host with a Remove still pending is never touched. + */ +import { findWslHostDeployment } from '../../shared/runtime-environment-wsl-host-store' +import type { WslHostService } from '../wsl/wsl-host-service' +import { defaultWslOrcadEnvironmentId } from '../wsl/wsl-orcad-deploy' + +export type WslMoveHostConnectMode = 'user' | 'automatic' + +/** E11 (#33843): a Remove that could not prove the server stopped leaves `pendingStop` set. */ +export function wslHostRemovalPending(userDataPath: string, distro: string): boolean { + const record: object | null = findWslHostDeployment( + userDataPath, + defaultWslOrcadEnvironmentId(distro) + ) + // Optional until #33843 lands: older records have no such field. + return !!record && 'pendingStop' in record && !!record.pendingStop +} + +/** False when there is no host to use; throws when it would not connect. */ +export async function connectWslMoveHostService( + service: Pick< + WslHostService, + 'recordForEnvironment' | 'getStatuses' | 'connect' | 'ensureForRuntimeCall' + >, + userDataPath: string, + distro: string, + mode: WslMoveHostConnectMode +): Promise { + const environmentId = defaultWslOrcadEnvironmentId(distro) + if (!service.recordForEnvironment(environmentId) || wslHostRemovalPending(userDataPath, distro)) { + return false + } + if (mode === 'automatic') { + await service.ensureForRuntimeCall(environmentId) + } else if ( + // connect() would bump the liveness generation and re-ensure a server that already serves. + !service + .getStatuses() + .some((status) => status.environmentId === environmentId && status.state === 'ready') + ) { + await service.connect(environmentId) + } + return true +} diff --git a/src/main/ipc/wsl-move-orchestrator-host-connect.test.ts b/src/main/ipc/wsl-move-orchestrator-host-connect.test.ts new file mode 100644 index 00000000000..b023a276462 --- /dev/null +++ b/src/main/ipc/wsl-move-orchestrator-host-connect.test.ts @@ -0,0 +1,130 @@ +/** + * The move orchestrator's automatic path end to end over a real WSL host service: its own host + * readiness (prepareWslMoveHost), then the driver's automatic connect (E9). A host that is + * already serving is never reconnected, and a bridge user's host is added exactly once. + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WslMoveResult } from '../../shared/wsl-move/wsl-move-driver-types' +import { prepareWslMoveHost } from '../persistence/moving-wsl-projects/wsl-move-host-readiness' +import { WslMoveOrchestrator } from '../persistence/moving-wsl-projects/wsl-move-orchestrator' +import { WslHostService } from '../wsl/wsl-host-service' +import { UBUNTU, makeConnector } from '../wsl/wsl-host-service-test-connector' +import { connectWslMoveHostService, wslHostRemovalPending } from './wsl-move-host-connect' + +const moved: WslMoveResult = { + outcome: 'moved', + migrationId: 'm1', + phase: 'retained', + reloadRequired: true +} + +let userDataPath: string + +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-wsl-move-orchestrator-connect-')) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +/** The orchestrator over a real host service, readying hosts as the wiring does. */ +function orchestratorOverHost(connector: ReturnType) { + const service = new WslHostService({ + userDataPath: () => userDataPath, + isEnabled: () => true, + hasServerFiles: () => true, + publish: () => {}, + pairingChanged: () => {}, + ready: () => {}, + removed: () => {} + }) + service.attach(connector) + const isHostReady = (distro: string): boolean => + service + .getStatuses() + .some( + (status) => status.distro.toLowerCase() === distro.toLowerCase() && status.state === 'ready' + ) + const environmentId = 'wsl:ubuntu' + // The driver's own reach for the host, in the orchestrator's (automatic) mode. + const driverConnect = vi.fn((distro: string) => + connectWslMoveHostService(service, userDataPath, distro, 'automatic') + ) + const orchestrator = new WslMoveOrchestrator({ + now: () => 1_000, + appVersion: '1.0.0', + autoMoveEnabled: () => true, + prepareHost: (distro, stillWanted) => + prepareWslMoveHost( + { + readRunningDistros: async () => ({ status: 'confirmed', distros: ['Ubuntu'] }), + isHostReady, + removalPending: () => wslHostRemovalPending(userDataPath, distro), + hasHost: () => service.recordForEnvironment(environmentId) !== null, + connect: () => service.connect(environmentId), + add: () => service.add({ distro, startIfStopped: false }) + }, + distro, + stillWanted + ), + hostRemovalPending: (distro) => wslHostRemovalPending(userDataPath, distro), + hasHost: () => service.recordForEnvironment(environmentId) !== null, + isHostReady, + preview: async (distro) => { + await driverConnect(distro) + return { + outcome: 'ready', + distro, + repoIds: ['r1'], + folderWorkspaceIds: [], + changed: [], + heldByHost: [], + refused: [], + blockers: [] + } + }, + start: async (distro) => { + await driverConnect(distro) + return moved + }, + finishedMove: () => null, + checkMovedDistro: async () => 'present', + releaseFinishedMove: async () => ({ released: false, reason: 'not-moved' }), + load: () => new Map(), + save: () => {}, + schedule: () => () => {}, + track: () => {}, + publish: () => {} + }) + return { orchestrator, service, driverConnect } +} + +describe('the orchestrator readying a host, then the driver reaching it (E9)', () => { + it('never reconnects a host that is already serving', async () => { + const connector = makeConnector([UBUNTU]) + const { orchestrator, service, driverConnect } = orchestratorOverHost(connector) + await service.add({ distro: 'Ubuntu' }) + connector.connect.mockClear() + await orchestrator.trigger('Ubuntu', 'project-opened') + expect(orchestrator.list()[0]?.state).toEqual({ kind: 'done', migrationId: 'm1' }) + // Preview and start each reached the host through the automatic path. + expect(driverConnect).toHaveBeenCalledTimes(2) + expect(connector.connect).not.toHaveBeenCalled() + expect(connector.disconnect).not.toHaveBeenCalled() + }) + + it("adds a bridge user's host once, and the driver's automatic reach adds no connect", async () => { + const connector = makeConnector([UBUNTU]) + const { orchestrator, service } = orchestratorOverHost(connector) + expect(service.recordForEnvironment('wsl:ubuntu')).toBeNull() + await orchestrator.trigger('Ubuntu', 'project-opened') + expect(service.recordForEnvironment('wsl:ubuntu')).not.toBeNull() + expect(connector.connect).toHaveBeenCalledTimes(1) + expect(orchestrator.list()[0]?.state).toEqual({ kind: 'done', migrationId: 'm1' }) + }) +}) diff --git a/src/main/ipc/wsl-move-orchestrator-slept-panes.ts b/src/main/ipc/wsl-move-orchestrator-slept-panes.ts new file mode 100644 index 00000000000..d955f88649c --- /dev/null +++ b/src/main/ipc/wsl-move-orchestrator-slept-panes.ts @@ -0,0 +1,63 @@ +/** + * The window's slept-pane bindings for an automatic move (D3a): only the window knows them, and a + * move without them misses those panes' history. Main asks; the window answers by request id. + */ +import { randomUUID } from 'node:crypto' +import type { WslMoveRequest } from '../../shared/wsl-move/wsl-move-driver-types' +import { WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL } from '../../shared/wsl-move/wsl-move-orchestrator-types' +import { getTrustedUIRendererWindow } from './ui' + +const SLEPT_PANES_TIMEOUT_MS = 5_000 + +type SleptPanes = NonNullable + +const sleptPaneRequests = new Map void>() + +/** The window's slept-pane bindings, which only it knows; a move without them misses their history. */ +export function requestSleptPanes(): Promise { + const window = getTrustedUIRendererWindow() + if (!window || window.isDestroyed() || window.webContents.isDestroyed()) { + return Promise.reject(new Error('wsl_move_window_unavailable')) + } + const requestId = randomUUID() + return new Promise((resolve, reject) => { + const timer = setTimeout(() => answer(null), SLEPT_PANES_TIMEOUT_MS) + function answer(panes: SleptPanes | null): void { + clearTimeout(timer) + sleptPaneRequests.delete(requestId) + if (panes) { + resolve(panes) + } else { + reject(new Error('wsl_move_window_unavailable')) + } + } + sleptPaneRequests.set(requestId, answer) + window.webContents.send(WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL, { requestId }) + }) +} + +function parseSleptPanes(value: unknown): SleptPanes | null { + if (!Array.isArray(value)) { + return null + } + const panes: SleptPanes = [] + for (const entry of value) { + if ( + typeof entry !== 'object' || + entry === null || + !('paneKey' in entry) || + !('sessionId' in entry) || + typeof entry.paneKey !== 'string' || + typeof entry.sessionId !== 'string' + ) { + return null + } + panes.push({ paneKey: entry.paneKey, sessionId: entry.sessionId }) + } + return panes +} + +/** The window's answer; an unparseable one fails the request rather than move without panes. */ +export function answerWslMoveSleptPanes(requestId: string, value: unknown): void { + sleptPaneRequests.get(requestId)?.(parseSleptPanes(value)) +} diff --git a/src/main/ipc/wsl-move-orchestrator-wiring.test.ts b/src/main/ipc/wsl-move-orchestrator-wiring.test.ts new file mode 100644 index 00000000000..e981480bfd7 --- /dev/null +++ b/src/main/ipc/wsl-move-orchestrator-wiring.test.ts @@ -0,0 +1,430 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate' +import { WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL } from '../../shared/wsl-move/wsl-move-orchestrator-types' + +type Handler = (event: unknown, request?: unknown) => unknown + +const handlers = vi.hoisted(() => new Map()) +const sent = vi.hoisted((): { channel: string; payload: unknown }[] => []) +vi.mock('electron', () => ({ + app: { getPath: () => '/user-data', getVersion: () => '1.0.0' }, + ipcMain: { + removeHandler: (channel: string) => handlers.delete(channel), + handle: (channel: string, handler: Handler) => handlers.set(channel, handler) + } +})) +const MAIN_WINDOW_ID = 7 +vi.mock('./ui', () => { + const webContents = { + id: MAIN_WINDOW_ID, + isDestroyed: () => false, + send: (channel: string, payload: unknown) => sent.push({ channel, payload }) + } + return { + getTrustedUIRendererWebContents: () => webContents, + getTrustedUIRendererWindow: () => ({ isDestroyed: () => false, webContents }) + } +}) +type Status = { distro: string; state: string; environmentId: string; detail: null } +type RunningRead = { status: 'confirmed'; distros: string[] } | { status: 'unavailable' } +const host = vi.hoisted(() => { + const initial = (): RunningRead => ({ status: 'confirmed', distros: ['Ubuntu'] }) + return { + statuses: new Array(), + listeners: new Set<() => void>(), + running: initial(), + added: new Array() + } +}) +const deployments = vi.hoisted(() => new Map>()) +vi.mock('../../shared/runtime-environment-wsl-host-store', () => ({ + findWslHostDeployment: (_userData: string, environmentId: string) => + deployments.get(environmentId) ?? null +})) +vi.mock('../wsl/wsl-distro-registry-id', () => ({ readWslDistroRegistrations: async () => null })) +vi.mock('../wsl/wsl-orcad-deploy', () => ({ + defaultWslOrcadEnvironmentId: (distro: string) => `wsl:${distro}` +})) +vi.mock('../wsl-background-distro-gate', () => ({ + readConfirmedRunningWslDistros: async () => host.running +})) +vi.mock('../wsl/wsl-host-service-instance', () => ({ + getWslHostService: () => ({ + getStatuses: () => host.statuses, + onStatusesChanged: (listener: () => void) => { + host.listeners.add(listener) + return () => host.listeners.delete(listener) + }, + recordForEnvironment: () => null, + connect: async () => {}, + // A bridge user's host: adding it makes it ready. + add: async ({ distro }: { distro: string }) => { + host.added.push(distro) + host.statuses.push({ distro, state: 'ready', environmentId: `wsl:${distro}`, detail: null }) + return { ok: true, environmentId: `wsl:${distro}` } + } + }) +})) +const driver = vi.hoisted(() => ({ + previewWslMove: vi.fn(async (_deps: unknown, request: { distro: string }) => ({ + outcome: 'ready', + distro: request.distro, + repoIds: ['r1'], + folderWorkspaceIds: [], + changed: [], + heldByHost: [], + refused: [], + blockers: [] + })), + moveProjectsToWslHost: vi.fn(async () => ({ + outcome: 'moved', + migrationId: 'm1', + phase: 'retained', + reloadRequired: true + })) +})) +vi.mock('../persistence/moving-wsl-projects/wsl-move-driver', () => driver) +const journal = vi.hoisted(() => ({ unreadable: false })) +vi.mock('../persistence/moving-wsl-projects/wsl-move-cutover-journal', () => ({ + listWslMoveCutoverChain: () => { + if (journal.unreadable) { + throw new Error('wsl_move_journal_unreadable') + } + return [] + } +})) +const store = vi.hoisted(() => ({ reads: 0, records: new Map() })) +vi.mock('../persistence/moving-wsl-projects/wsl-move-orchestrator-store', () => ({ + readWslMoveOrchestratorRecords: () => { + store.reads++ + return store.records + }, + writeWslMoveOrchestratorRecords: () => {} +})) +const hostActions = vi.hoisted(() => ({ + listeners: new Array<(action: { kind: 'added' | 'removed'; distro: string }) => void>() +})) +vi.mock('../wsl/wsl-host-user-actions', () => ({ + onWslHostUserAction: ( + listener: (action: { kind: 'added' | 'removed'; distro: string }) => void + ) => { + hostActions.listeners.push(listener) + return () => {} + } +})) +const releases = vi.hoisted(() => ({ listeners: new Array<(distro: string) => void>() })) +vi.mock('./wsl-move-release-here', () => ({ + releaseWslMoveChainHere: vi.fn(), + onWslMoveReleasedByUser: (listener: (distro: string) => void) => { + releases.listeners.push(listener) + return () => {} + } +})) +vi.mock('./wsl-move-undo-wiring', () => ({ createWslMoveUndoPorts: vi.fn() })) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) + +const sleptPanes = [{ paneKey: 'tab:leaf', sessionId: 'wt@@a' }] + +function requestIdOf(payload: unknown): string { + return typeof payload === 'object' && + payload !== null && + 'requestId' in payload && + typeof payload.requestId === 'string' + ? payload.requestId + : '' +} + +async function register(): Promise { + const { registerWslMoveOrchestrator } = await import('./wsl-move-orchestrator-wiring') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The driver is mocked; the wiring only passes deps through. + registerWslMoveOrchestrator({} as never, () => ({ now: () => new Date(0) }) as never) +} + +/** Answers every slept-pane request the window has been sent. */ +async function answerWindow(): Promise { + for (let turn = 0; turn < 20; turn++) { + for (const message of sent.splice(0)) { + if (message.channel === WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL) { + const requestId = requestIdOf(message.payload) + await handlers.get('wslMoveOrchestrator:answerSleptPanes')?.( + { sender: { id: MAIN_WINDOW_ID } }, + { requestId, sleptPanes } + ) + } + } + await new Promise((resolve) => setImmediate(resolve)) + } +} + +// Why: the first import transforms the wiring's module graph, which alone can pass the 30 s test +// timeout on a loaded machine; later imports after resetModules reuse the transform. +beforeAll(async () => { + await import('./wsl-move-orchestrator-wiring') +}, 120_000) + +beforeEach(() => { + vi.resetModules() + handlers.clear() + sent.length = 0 + host.statuses = [] + host.listeners.clear() + host.running = { status: 'confirmed', distros: ['Ubuntu'] } + host.added = [] + releases.listeners.length = 0 + hostActions.listeners.length = 0 + deployments.clear() + store.reads = 0 + store.records = new Map() + journal.unreadable = false + driver.previewWslMove.mockClear() + driver.moveProjectsToWslHost.mockClear() +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('WSL move orchestrator wiring', () => { + it('stays dark with the gate closed: nothing read, nothing listed', async () => { + host.statuses = [ + { distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null } + ] + await register() + expect(store.reads).toBe(0) + expect(await handlers.get('wslMoveOrchestrator:list')?.({})).toEqual([]) + expect(sent).toEqual([]) + }) + + it('moves a distro whose host is ready at start, with the window slept panes', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + host.statuses = [ + { distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null } + ] + await register() + await answerWindow() + expect(driver.previewWslMove).toHaveBeenCalledWith(expect.anything(), { + distro: 'Ubuntu', + sleptPanes + }) + expect(driver.moveProjectsToWslHost).toHaveBeenCalledWith(expect.anything(), { + distro: 'Ubuntu', + sleptPanes + }) + }) + + it('starts on a host reaching ready, never on one that is not', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + host.statuses = [ + { distro: 'Ubuntu', state: 'connecting', environmentId: 'wsl:Ubuntu', detail: null } + ] + await register() + await answerWindow() + expect(driver.previewWslMove).not.toHaveBeenCalled() + host.statuses = [ + { distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null } + ] + for (const listener of host.listeners) { + listener() + } + await answerWindow() + expect(driver.moveProjectsToWslHost).toHaveBeenCalledTimes(1) + }) + + it('makes no automatic start with ORCA_WSL_MOVE_AUTO=0', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + vi.stubEnv('ORCA_WSL_MOVE_AUTO', '0') + host.statuses = [ + { distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null } + ] + await register() + await answerWindow() + expect(driver.previewWslMove).not.toHaveBeenCalled() + }) + + it('ignores a slept-pane answer from another window', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + host.statuses = [ + { distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null } + ] + await register() + const request = sent.find( + (message) => message.channel === WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL + ) + const requestId = requestIdOf(request?.payload) + await handlers.get('wslMoveOrchestrator:answerSleptPanes')?.( + { sender: { id: 99 } }, + { requestId, sleptPanes } + ) + await new Promise((resolve) => setImmediate(resolve)) + expect(driver.previewWslMove).not.toHaveBeenCalled() + }) + + it('T2: a bridge project opened on a running distro adds its host and moves it', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + await register() + await handlers.get('wslMoveOrchestrator:projectOpened')?.( + { sender: { id: MAIN_WINDOW_ID } }, + 'Ubuntu' + ) + await answerWindow() + expect(host.added).toEqual(['Ubuntu']) + expect(driver.moveProjectsToWslHost).toHaveBeenCalledTimes(1) + }) + + it('T2: never adds a host for a stopped distro or one whose state cannot be read', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + await register() + host.running = { status: 'confirmed', distros: [] } + await handlers.get('wslMoveOrchestrator:projectOpened')?.( + { sender: { id: MAIN_WINDOW_ID } }, + 'Ubuntu' + ) + host.running = { status: 'unavailable' } + await handlers.get('wslMoveOrchestrator:projectOpened')?.( + { sender: { id: MAIN_WINDOW_ID } }, + 'Debian' + ) + await answerWindow() + expect(host.added).toEqual([]) + expect(driver.previewWslMove).not.toHaveBeenCalled() + }) + + it('T3: a ready host whose distro is not confirmed running is not resumed', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + host.running = { status: 'unavailable' } + host.statuses = [ + { distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null } + ] + await register() + await answerWindow() + expect(driver.previewWslMove).not.toHaveBeenCalled() + }) + + // Review #2: "Use on this computer again" parks the distro, so opening it again moves nothing. + it('parks a distro the user brought back', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + await register() + for (const listener of releases.listeners) { + listener('Ubuntu') + } + const records = await handlers.get('wslMoveOrchestrator:list')?.({}) + expect(records).toEqual([ + expect.objectContaining({ distro: 'Ubuntu', state: { kind: 'parked', reason: 'declined' } }) + ]) + }) + + // E11 (#33843): `pendingStop` is optional until that PR lands, so the guard reads it loosely. + it('never adds, connects or moves a host whose Remove is pending', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + deployments.set('wsl:Ubuntu', { + environmentId: 'wsl:Ubuntu', + distro: 'Ubuntu', + pendingStop: { reason: 'server-still-running' } + }) + host.statuses = [ + { distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null } + ] + await register() + await handlers.get('wslMoveOrchestrator:projectOpened')?.( + { sender: { id: MAIN_WINDOW_ID } }, + 'Ubuntu' + ) + await answerWindow() + expect(host.added).toEqual([]) + expect(driver.previewWslMove).not.toHaveBeenCalled() + expect(await handlers.get('wslMoveOrchestrator:list')?.({})).toEqual([ + expect.objectContaining({ state: { kind: 'parked', reason: 'removal-pending' } }) + ]) + }) + + // Re-review 1: registerCoreHandlers calls this before many other handlers; it must not throw. + it('registers everything and keeps an offered Restore when the journal is unreadable', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + journal.unreadable = true + store.records = new Map([ + [ + 'local-wsl:ubuntu', + { + distro: 'Ubuntu', + state: { kind: 'restore-offered' }, + attempts: 0, + appVersion: '1.0.0', + updatedAt: 0 + } + ] + ]) + await expect(register()).resolves.toBeUndefined() + for (const channel of [ + 'wslMoveOrchestrator:list', + 'wslMoveOrchestrator:projectOpened', + 'wslMoveOrchestrator:userAction', + 'wslMoveOrchestrator:answerSleptPanes' + ]) { + expect(handlers.has(channel)).toBe(true) + } + expect(await handlers.get('wslMoveOrchestrator:list')?.({})).toEqual([ + expect.objectContaining({ state: { kind: 'restore-offered' } }) + ]) + }) + + // A user Remove declines the host: T2 must not add it back; the user's own Add resumes. + it('never re-adds a host the user removed, until the user adds it', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + await register() + for (const listener of hostActions.listeners) { + listener({ kind: 'removed', distro: 'Ubuntu' }) + } + await handlers.get('wslMoveOrchestrator:projectOpened')?.( + { sender: { id: MAIN_WINDOW_ID } }, + 'Ubuntu' + ) + await answerWindow() + expect(host.added).toEqual([]) + expect(driver.previewWslMove).not.toHaveBeenCalled() + for (const listener of hostActions.listeners) { + listener({ kind: 'added', distro: 'Ubuntu' }) + } + expect(await handlers.get('wslMoveOrchestrator:list')?.({})).toEqual([ + expect.objectContaining({ distro: 'Ubuntu', state: { kind: 'pending' } }) + ]) + }) + + // Review 3 #3: a release while the start waits for the window's panes must not be moved. + it('does not start a move the user released while the window answered', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + host.statuses = [ + { distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null } + ] + await register() + // Answer the preview's request only, then release before answering the start's. + const answerOne = async (): Promise => { + for (let turn = 0; turn < 50; turn++) { + const index = sent.findIndex( + (message) => message.channel === WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL + ) + if (index !== -1) { + const [message] = sent.splice(index, 1) + await handlers.get('wslMoveOrchestrator:answerSleptPanes')?.( + { sender: { id: MAIN_WINDOW_ID } }, + { requestId: requestIdOf(message?.payload), sleptPanes } + ) + return true + } + await new Promise((resolve) => setImmediate(resolve)) + } + return false + } + expect(await answerOne()).toBe(true) + await vi.waitFor(() => + expect( + sent.some( + (message) => message.channel === WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL + ) + ).toBe(true) + ) + for (const listener of releases.listeners) { + listener('Ubuntu') + } + expect(await answerOne()).toBe(true) + await answerWindow() + expect(driver.previewWslMove).toHaveBeenCalledTimes(1) + expect(driver.moveProjectsToWslHost).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/wsl-move-orchestrator-wiring.ts b/src/main/ipc/wsl-move-orchestrator-wiring.ts new file mode 100644 index 00000000000..350727b88b7 --- /dev/null +++ b/src/main/ipc/wsl-move-orchestrator-wiring.ts @@ -0,0 +1,279 @@ +/** + * The move orchestrator's production ports and IPC (D3a). Dark: with the internal WSL hosts gate + * closed, or without a WSL host service, nothing is constructed and the IPC answers empty. + * Automatic starts also stop when ORCA_WSL_MOVE_AUTO=0; explicit user actions still run. + */ +import { app, ipcMain, type WebContents } from 'electron' +import { WSL_MOVE_CUTOVER_PHASES, wslMoveJournalKey } from '../../shared/wsl-move/wsl-move-cutover' +import { + WSL_MOVE_ORCHESTRATOR_STATUS_CHANNEL, + type WslMoveOrchestratorRecord, + type WslMoveOrchestratorUserAction +} from '../../shared/wsl-move/wsl-move-orchestrator-types' +import type { Store } from '../persistence' +import { listWslMoveCutoverChain } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal' +import { + moveProjectsToWslHost, + previewWslMove +} from '../persistence/moving-wsl-projects/wsl-move-driver' +import { wslMovedDistroVerdict } from '../persistence/moving-wsl-projects/wsl-move-moved-distro-check' +import { WslMoveOrchestrator } from '../persistence/moving-wsl-projects/wsl-move-orchestrator' +import type { + WslMoveFinishedMove, + WslMoveHostReadiness +} from '../persistence/moving-wsl-projects/wsl-move-orchestrator-ports' +import { prepareWslMoveHost } from '../persistence/moving-wsl-projects/wsl-move-host-readiness' +import { readConfirmedRunningWslDistros } from '../wsl-background-distro-gate' +import { defaultWslOrcadEnvironmentId } from '../wsl/wsl-orcad-deploy' +import { + readWslMoveOrchestratorRecords, + writeWslMoveOrchestratorRecords +} from '../persistence/moving-wsl-projects/wsl-move-orchestrator-store' +import type { WslMoveDriverDeps } from '../persistence/moving-wsl-projects/wsl-move-driver-ports' +import { + trackWslMoveOrchestrator, + wslMoveOrchestratorEventProps +} from '../telemetry/wsl-move-orchestrator-telemetry' +import { runTrackedWslMove } from '../telemetry/wsl-move-telemetry' +import { readWslDistroRegistrations } from '../wsl/wsl-distro-registry-id' +import { wslHostsGateOpen } from '../wsl/wsl-host-gate' +import { getWslHostService } from '../wsl/wsl-host-service-instance' +import { onWslHostUserAction } from '../wsl/wsl-host-user-actions' +import { getTrustedUIRendererWebContents, getTrustedUIRendererWindow } from './ui' +import { onWslMoveReleasedByUser, releaseWslMoveChainHere } from './wsl-move-release-here' +import { createWslMoveUndoPorts } from './wsl-move-undo-wiring' +import { answerWslMoveSleptPanes, requestSleptPanes } from './wsl-move-orchestrator-slept-panes' +import { wslHostRemovalPending } from './wsl-move-host-connect' + +export const WSL_MOVE_ORCHESTRATOR_AUTO_ENV = 'ORCA_WSL_MOVE_AUTO' + +export const WSL_MOVE_ORCHESTRATOR_HANDLER_CHANNELS = [ + 'wslMoveOrchestrator:list', + 'wslMoveOrchestrator:projectOpened', + 'wslMoveOrchestrator:userAction', + 'wslMoveOrchestrator:answerSleptPanes' +] as const + +const COMMITTED_FROM = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') +const USER_ACTIONS: readonly WslMoveOrchestratorUserAction[] = ['retry', 'not-now', 'restore'] + +function finishedWslMove(userDataPath: string, distro: string): WslMoveFinishedMove | null { + const move = listWslMoveCutoverChain(userDataPath, wslMoveJournalKey(distro)).findLast( + (cutover) => WSL_MOVE_CUTOVER_PHASES.indexOf(cutover.phase) >= COMMITTED_FROM + ) + if (!move) { + return null + } + const { distroGuid } = move.manifest.source + return distroGuid + ? { migrationId: move.migrationId, distroGuid } + : { migrationId: move.migrationId } +} + +function isHostReady(distro: string): boolean { + const key = wslMoveJournalKey(distro) + return ( + getWslHostService() + ?.getStatuses() + .some((status) => wslMoveJournalKey(status.distro) === key && status.state === 'ready') ?? + false + ) +} + +function prepareHost( + userDataPath: string, + distro: string, + stillWanted: () => boolean +): Promise { + const service = getWslHostService() + if (!service) { + return Promise.resolve('failed') + } + const environmentId = defaultWslOrcadEnvironmentId(distro) + return prepareWslMoveHost( + { + readRunningDistros: readConfirmedRunningWslDistros, + isHostReady, + removalPending: () => wslHostRemovalPending(userDataPath, distro), + hasHost: () => service.recordForEnvironment(environmentId) !== null, + connect: () => service.connect(environmentId), + add: () => service.add({ distro, startIfStopped: false }) + }, + distro, + stillWanted + ) +} + +function publish(records: WslMoveOrchestratorRecord[]): void { + const window = getTrustedUIRendererWindow() + if (window && !window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.send(WSL_MOVE_ORCHESTRATOR_STATUS_CHANNEL, records) + } +} + +function createOrchestrator( + store: Store, + driverDeps: () => WslMoveDriverDeps +): WslMoveOrchestrator { + const userDataPath = app.getPath('userData') + return new WslMoveOrchestrator({ + now: () => Date.now(), + appVersion: app.getVersion(), + autoMoveEnabled: () => process.env[WSL_MOVE_ORCHESTRATOR_AUTO_ENV] !== '0', + prepareHost: (distro, stillWanted) => prepareHost(userDataPath, distro, stillWanted), + hostRemovalPending: (distro) => wslHostRemovalPending(userDataPath, distro), + hasHost: (distro) => + getWslHostService()?.recordForEnvironment(defaultWslOrcadEnvironmentId(distro)) != null, + isHostReady, + preview: async (distro) => + previewWslMove(driverDeps(), { + distro, + sleptPanes: await requestSleptPanes() + }), + start: async (distro, stillWanted) => { + const sleptPanes = await requestSleptPanes() + // The user may have released or snoozed the distro while the window answered. + if (!stillWanted()) { + return { outcome: 'refused', code: 'wsl_move_cancelled' } + } + return runTrackedWslMove(driverDeps(), (deps) => + moveProjectsToWslHost(deps, { distro, sleptPanes }) + ) + }, + finishedMove: (distro) => finishedWslMove(userDataPath, distro), + checkMovedDistro: async (distro, move) => + wslMovedDistroVerdict(await readWslDistroRegistrations(), distro, move.distroGuid), + releaseFinishedMove: (distro, unsavedWorkspaceIds) => + releaseWslMoveChainHere(userDataPath, distro, unsavedWorkspaceIds, () => + createWslMoveUndoPorts(store, userDataPath) + ), + load: () => readWslMoveOrchestratorRecords(userDataPath), + save: (records) => writeWslMoveOrchestratorRecords(userDataPath, records), + schedule: (delayMs, run) => { + const timer = setTimeout(run, delayMs) + timer.unref?.() + return () => clearTimeout(timer) + }, + track: (trigger, record, cause) => + trackWslMoveOrchestrator(wslMoveOrchestratorEventProps(trigger, record, cause)), + publish + }) +} + +/** T1 and T3: a host reaching ready, or already ready at start; T2 arrives from the window. */ +function followHostReadiness(orchestrator: WslMoveOrchestrator): void { + const service = getWslHostService() + if (!service) { + return + } + const ready = new Set() + const observe = (trigger: 'connect' | 'startup'): void => { + const now = new Set() + for (const status of service.getStatuses()) { + if (status.state !== 'ready') { + continue + } + const key = wslMoveJournalKey(status.distro) + now.add(key) + if (!ready.has(key)) { + void orchestrator.trigger(status.distro, trigger) + } + } + ready.clear() + for (const key of now) { + ready.add(key) + } + } + service.onStatusesChanged(() => observe('connect')) + observe('startup') +} + +let orchestrator: WslMoveOrchestrator | null = null + +/** `driverDeps` is the dialog's driver, but its host connect never boots a stopped distro. */ +export function registerWslMoveOrchestrator( + store: Store, + driverDeps: () => WslMoveDriverDeps +): void { + for (const channel of WSL_MOVE_ORCHESTRATOR_HANDLER_CHANNELS) { + ipcMain.removeHandler(channel) + } + const isMainWindow = (sender: WebContents | undefined): boolean => + !!sender && getTrustedUIRendererWebContents()?.id === sender.id + ipcMain.handle('wslMoveOrchestrator:list', (): WslMoveOrchestratorRecord[] => + orchestrator && wslHostsGateOpen() ? orchestrator.list() : [] + ) + // T2: the window opened a bridge project of this distro; the orchestrator checks it is running. + ipcMain.handle( + 'wslMoveOrchestrator:projectOpened', + (event: { sender?: WebContents }, distro: unknown): void => { + if ( + orchestrator && + wslHostsGateOpen() && + isMainWindow(event.sender) && + typeof distro === 'string' + ) { + void orchestrator.trigger(distro, 'project-opened') + } + } + ) + ipcMain.handle( + 'wslMoveOrchestrator:userAction', + async (event: { sender?: WebContents }, request: unknown) => { + if ( + !orchestrator || + !wslHostsGateOpen() || + !isMainWindow(event.sender) || + typeof request !== 'object' || + request === null || + !('distro' in request) || + !('action' in request) || + typeof request.distro !== 'string' + ) { + return null + } + const action = USER_ACTIONS.find((candidate) => candidate === request.action) + if (!action) { + return null + } + const unsaved = + 'unsavedWorkspaceIds' in request && Array.isArray(request.unsavedWorkspaceIds) + ? request.unsavedWorkspaceIds.filter((id): id is string => typeof id === 'string') + : [] + return orchestrator.userAction(request.distro, action, { unsavedWorkspaceIds: unsaved }) + } + ) + ipcMain.handle( + 'wslMoveOrchestrator:answerSleptPanes', + (event: { sender?: WebContents }, request: unknown): void => { + if ( + !isMainWindow(event.sender) || + typeof request !== 'object' || + request === null || + !('requestId' in request) || + typeof request.requestId !== 'string' || + !('sleptPanes' in request) + ) { + return + } + answerWslMoveSleptPanes(request.requestId, request.sleptPanes) + } + ) + // Dark: nothing is constructed or read while the gate is closed. + if (!wslHostsGateOpen() || !getWslHostService() || orchestrator) { + return + } + // Registered from registerCoreHandlers: a throw here would skip every handler after it. + try { + const created = createOrchestrator(store, driverDeps) + orchestrator = created + followHostReadiness(created) + onWslMoveReleasedByUser((distro) => created.releasedByUser(distro)) + // A Remove is the user declining the host; only their own Add (or Retry) takes it back. + onWslHostUserAction(({ kind, distro }) => + kind === 'removed' ? created.releasedByUser(distro) : created.hostAddedByUser(distro) + ) + } catch (error) { + console.warn('[wsl-move] Move orchestrator did not start:', error) + } +} diff --git a/src/main/ipc/wsl-move-release-here.test.ts b/src/main/ipc/wsl-move-release-here.test.ts new file mode 100644 index 00000000000..d86a7128b8d --- /dev/null +++ b/src/main/ipc/wsl-move-release-here.test.ts @@ -0,0 +1,312 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type Move = { + migrationId: string + phase: string + journalKey: string + destinationEnvironmentId: string + manifest: { + source: { distro: string; scopeRepoIds: string[]; scopeFolderWorkspaceIds: string[] } + } +} + +const journal = vi.hoisted(() => ({ chain: new Array(), released: new Array() })) +const window = vi.hoisted(() => ({ + reloads: 0, + fences: 0, + fencesReleased: 0, + steps: new Array() +})) +const releaseFails = vi.hoisted(() => ({ value: false })) + +function move(migrationId: string, repoId: string): Move { + return { + migrationId, + phase: 'retained', + journalKey: 'local-wsl:ubuntu', + destinationEnvironmentId: 'wsl:Ubuntu', + manifest: { + source: { distro: 'Ubuntu', scopeRepoIds: [repoId], scopeFolderWorkspaceIds: [] } + } + } +} + +vi.mock('../persistence/moving-wsl-projects/wsl-move-cutover-journal', () => ({ + listWslMoveCutoverChain: () => [...journal.chain] +})) +vi.mock('../persistence/moving-wsl-projects/wsl-move-use-locally', () => ({ + findWslMovedProjectMove: () => ({ move: journal.chain.at(-1) }), + wslMoveWorkspacesInScope: () => [], + // Only the newest move of the chain may come back, as the real journal enforces. + wslMoveReleaseRefusal: (_userData: string, candidate: Move) => + journal.chain.at(-1)?.migrationId === candidate.migrationId + ? null + : { released: false, reason: 'superseded' }, + releaseWslMove: (_userData: string, candidate: Move) => { + if (releaseFails.value) { + return { released: false, reason: 'failed', detail: 'journal' } + } + journal.chain = journal.chain.filter((entry) => entry.migrationId !== candidate.migrationId) + journal.released.push(candidate.migrationId) + return { + released: true, + distro: 'Ubuntu', + repoIds: candidate.manifest.source.scopeRepoIds, + folderWorkspaceIds: [] + } + } +})) +vi.mock('../persistence/moving-wsl-projects/wsl-move-undo-spawn-fence', () => ({ + fenceHostSpawnsForWslMoveUndo: () => { + window.fences++ + return () => { + window.fencesReleased++ + } + } +})) +vi.mock('../persistence/moving-wsl-projects/wsl-move-window-write-hold', () => ({ + holdWslMoveWindowWrites: () => { + window.steps.push('hold') + }, + reloadWslMoveWindowWithWritesHeld: () => { + window.reloads++ + window.steps.push('reload') + } +})) + +import { + onWslMoveReleasedByUser, + releaseWslMoveChainHere, + releaseWslMovedProjectHere +} from './wsl-move-release-here' + +const undoPorts = () => ({ stopHostCopies: vi.fn(async () => 'stopped' as const) }) + +beforeEach(() => { + journal.chain = [move('m1', 'r1'), move('m2', 'r2')] + journal.released = [] + window.reloads = 0 + window.fences = 0 + window.fencesReleased = 0 + releaseFails.value = false + window.steps = [] +}) + +describe('releasing moved projects here', () => { + // Review #5: one Restore must bring back every finished move, not only the newest. + it('releases a whole chain newest first with one reload', async () => { + const result = await releaseWslMoveChainHere('/user-data', 'Ubuntu', [], undoPorts) + expect(journal.released).toEqual(['m2', 'm1']) + expect(result).toEqual({ + released: true, + distro: 'Ubuntu', + repoIds: ['r2', 'r1'], + folderWorkspaceIds: [] + }) + expect(window.reloads).toBe(1) + }) + + it('reloads for what came back even when an older move is refused', async () => { + const stopHostCopies = vi + .fn<() => Promise<'stopped' | 'unreachable'>>() + .mockResolvedValueOnce('stopped') + .mockResolvedValueOnce('unreachable') + const result = await releaseWslMoveChainHere('/user-data', 'Ubuntu', [], () => ({ + stopHostCopies + })) + expect(journal.released).toEqual(['m2']) + expect(result).toMatchObject({ released: false, reason: 'host-unreachable' }) + expect(window.reloads).toBe(1) + }) + + // Review #2: only the user's "Use on this computer again" parks the distro; Restore does not. + it('tells listeners about a user release and not about a Restore', async () => { + const heard = vi.fn() + const stop = onWslMoveReleasedByUser(heard) + await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], undoPorts) + expect(heard).toHaveBeenCalledWith('Ubuntu') + heard.mockClear() + await releaseWslMoveChainHere('/user-data', 'Ubuntu', [], undoPorts) + expect(heard).not.toHaveBeenCalled() + stop() + }) + + // verify-15b 6c: focus left on the host copy reopened its pane after the reload and resumed + // the agent there beside the one restored here. + it('returns focus from each released host copy after holding writes and before the reload', async () => { + const refocusHere = vi.fn((released: Move) => { + window.steps.push(`refocus ${released.migrationId}`) + }) + const result = await releaseWslMoveChainHere('/user-data', 'Ubuntu', [], () => ({ + stopHostCopies: vi.fn(async () => 'stopped' as const), + refocusHere + })) + expect(result).toMatchObject({ released: true }) + expect(window.steps).toEqual(['hold', 'refocus m2', 'refocus m1', 'reload']) + }) + + it('returns no focus for a move that did not come back', async () => { + const refocusHere = vi.fn() + const stopHostCopies = vi + .fn<() => Promise<'stopped' | 'unreachable'>>() + .mockResolvedValueOnce('stopped') + .mockResolvedValueOnce('unreachable') + await releaseWslMoveChainHere('/user-data', 'Ubuntu', [], () => ({ + stopHostCopies, + refocusHere + })) + expect(refocusHere.mock.calls.map(([released]) => released.migrationId)).toEqual(['m2']) + }) + + const automationsOff = ( + onRestore = vi.fn(async () => {}), + applyHere = vi.fn((): (() => void) => () => {}) + ) => ({ + outcome: 'stopped' as const, + restore: onRestore, + applyHere + }) + + it("turns each move's host automations off, then stops its terminals, then releases it", async () => { + const steps: string[] = [] + const result = await releaseWslMoveChainHere('/user-data', 'Ubuntu', [], () => ({ + stopHostAutomations: vi.fn(async (stopped: Move) => { + steps.push(`automations ${stopped.migrationId}`) + return automationsOff() + }), + stopHostCopies: vi.fn(async (stopped: Move) => { + expect(journal.released).not.toContain(stopped.migrationId) + steps.push(`terminals ${stopped.migrationId}`) + return 'stopped' as const + }) + })) + expect(result).toMatchObject({ released: true }) + // Partial chains stay per move: each step names only the move being released. + expect(steps).toEqual(['automations m2', 'terminals m2', 'automations m1', 'terminals m1']) + }) + + // X1: a refused undo leaves the host's copies running. + it('refuses with the host terminals untouched when the automations cannot go off', async () => { + for (const [outcome, reason] of [ + ['unreachable', 'host-unreachable'], + ['failed', 'failed'] + ] as const) { + journal.chain = [move('m1', 'r1'), move('m2', 'r2')] + journal.released = [] + window.reloads = 0 + let operatorCloses = 0 + const result = await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({ + stopHostAutomations: vi.fn(async () => ({ outcome })), + stopHostCopies: vi.fn(async () => { + operatorCloses++ + return 'stopped' as const + }) + })) + expect(result).toMatchObject({ released: false, reason, detail: 'host-automations' }) + expect(operatorCloses).toBe(0) + expect(journal.released).toEqual([]) + expect(window.reloads).toBe(0) + } + }) + + it('turns the automations back on when the terminals then fail to stop', async () => { + const restore = vi.fn(async () => {}) + const result = await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({ + stopHostAutomations: vi.fn(async () => automationsOff(restore)), + stopHostCopies: vi.fn(async () => 'still-running' as const) + })) + expect(result).toMatchObject({ released: false, reason: 'host-still-running' }) + expect(restore).toHaveBeenCalledOnce() + expect(journal.released).toEqual([]) + }) + + it('turns the automations back on, and undoes the write here, when the release then fails', async () => { + releaseFails.value = true + const restore = vi.fn(async () => {}) + const revertHere = vi.fn() + const result = await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({ + stopHostAutomations: vi.fn(async () => + automationsOff( + restore, + vi.fn(() => revertHere) + ) + ), + stopHostCopies: vi.fn(async () => 'stopped' as const) + })) + expect(result).toMatchObject({ released: false, reason: 'failed', detail: 'journal' }) + expect(revertHere).toHaveBeenCalledOnce() + expect(restore).toHaveBeenCalledOnce() + expect(window.fencesReleased).toBe(1) + }) + + it('names a host whose Remove is pending, touching nothing on it', async () => { + const stopHostCopies = vi.fn(async () => 'stopped' as const) + const result = await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({ + stopHostAutomations: vi.fn(async () => ({ outcome: 'removal-pending' as const })), + stopHostCopies + })) + expect(result).toMatchObject({ + released: false, + reason: 'failed', + detail: 'host-removal-pending' + }) + expect(stopHostCopies).not.toHaveBeenCalled() + }) + + it('releases the spawn fence when a port throws', async () => { + await expect( + releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({ + stopHostAutomations: vi.fn(async () => { + throw new Error('boom') + }), + stopHostCopies: vi.fn(async () => 'stopped' as const) + })) + ).rejects.toThrow('boom') + expect(window.fences).toBe(1) + expect(window.fencesReleased).toBe(1) + expect(window.reloads).toBe(0) + }) + + it('applies the host automation states here as part of the release, never on a refusal', async () => { + const applyHere = vi.fn((): (() => void) => { + // After the terminals stopped, right before the release. + expect(journal.released).not.toContain('m2') + return () => {} + }) + await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({ + stopHostAutomations: vi.fn(async () => automationsOff(undefined, applyHere)), + stopHostCopies: vi.fn(async () => 'stopped' as const) + })) + expect(applyHere).toHaveBeenCalledOnce() + expect(journal.released).toContain('m2') + + journal.chain = [move('m1', 'r1'), move('m2', 'r2')] + journal.released = [] + const refusedApply = vi.fn((): (() => void) => () => {}) + await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({ + stopHostAutomations: vi.fn(async () => automationsOff(undefined, refusedApply)), + stopHostCopies: vi.fn(async () => 'still-running' as const) + })) + expect(refusedApply).not.toHaveBeenCalled() + }) + + it('still turns the host automations back on when putting the copies here back throws', async () => { + releaseFails.value = true + const restore = vi.fn(async () => {}) + await expect( + releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({ + stopHostAutomations: vi.fn(async () => + automationsOff( + restore, + vi.fn(() => () => { + throw new Error('store busy') + }) + ) + ), + stopHostCopies: vi.fn(async () => 'stopped' as const) + })) + ).rejects.toThrow('store busy') + expect(restore).toHaveBeenCalledOnce() + expect(window.fencesReleased).toBe(1) + }) +}) diff --git a/src/main/ipc/wsl-move-release-here.ts b/src/main/ipc/wsl-move-release-here.ts new file mode 100644 index 00000000000..ac62337d888 --- /dev/null +++ b/src/main/ipc/wsl-move-release-here.ts @@ -0,0 +1,194 @@ +/** + * Bringing moved projects back to this computer: one project's move ("Use on this computer + * again"), or a distro's whole chain (R38 Restore). Host copies stop first, then the moves are + * dropped newest first, then the window reloads once to hydrate the pre-move session. + */ +import { + WSL_MOVE_CUTOVER_PHASES, + wslMoveJournalKey, + type WslMoveCutover +} from '../../shared/wsl-move/wsl-move-cutover' +import type { WslMoveUseLocallyResult } from '../../shared/wsl-move/wsl-moved-project' +import { listWslMoveCutoverChain } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal' +import type { WslMoveProjectRef } from '../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { fenceHostSpawnsForWslMoveUndo } from '../persistence/moving-wsl-projects/wsl-move-undo-spawn-fence' +import { + findWslMovedProjectMove, + releaseWslMove, + wslMoveReleaseRefusal, + wslMoveWorkspacesInScope +} from '../persistence/moving-wsl-projects/wsl-move-use-locally' +import { + holdWslMoveWindowWrites, + reloadWslMoveWindowWithWritesHeld +} from '../persistence/moving-wsl-projects/wsl-move-window-write-hold' +import type { WslMoveUndoPorts } from './wsl-move-undo-wiring' +import { runWslMoveHostAutomationsExclusive } from '../persistence/moving-wsl-projects/wsl-move-undo-host-automations' + +const COMMITTED_FROM = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') + +const releasedByUserListeners = new Set<(distro: string) => void>() + +/** Hears each "Use on this computer again" that released a move; Restore is not one. */ +export function onWslMoveReleasedByUser(listener: (distro: string) => void): () => void { + releasedByUserListeners.add(listener) + return () => releasedByUserListeners.delete(listener) +} + +/** "Use on this computer again" for one project: its whole move comes back and the window reloads. */ +export async function releaseWslMovedProjectHere( + userDataPath: string, + ref: WslMoveProjectRef, + unsavedWorkspaceIdsAsked: readonly string[], + createUndoPorts: () => WslMoveUndoPorts +): Promise { + const found = findWslMovedProjectMove(userDataPath, ref) + if (!('move' in found)) { + return found + } + const result = await releaseWslMovesHere( + userDataPath, + [found.move], + unsavedWorkspaceIdsAsked, + createUndoPorts + ) + if (result.released) { + for (const listener of releasedByUserListeners) { + listener(result.distro) + } + } + return result +} + +/** R38 Restore: every finished move of the distro comes back in one go, newest first. */ +export async function releaseWslMoveChainHere( + userDataPath: string, + distro: string, + unsavedWorkspaceIdsAsked: readonly string[], + createUndoPorts: () => WslMoveUndoPorts +): Promise { + const moves = listWslMoveCutoverChain(userDataPath, wslMoveJournalKey(distro)) + .filter((move) => WSL_MOVE_CUTOVER_PHASES.indexOf(move.phase) >= COMMITTED_FROM) + .toReversed() + return moves.length > 0 + ? releaseWslMovesHere(userDataPath, moves, unsavedWorkspaceIdsAsked, createUndoPorts) + : { released: false, reason: 'not-moved' } +} + +async function releaseWslMovesHere( + userDataPath: string, + newestFirst: readonly WslMoveCutover[], + unsavedWorkspaceIdsAsked: readonly string[], + createUndoPorts: () => WslMoveUndoPorts +): Promise { + // Only the moves' own workspaces reload from their pre-move state; other unsaved files stay. + const unsavedWorkspaceIds = [ + ...new Set( + newestFirst.flatMap((move) => wslMoveWorkspacesInScope(move, unsavedWorkspaceIdsAsked)) + ) + ] + if (unsavedWorkspaceIds.length > 0) { + return { released: false, reason: 'unsaved-files', unsavedWorkspaceIds } + } + const first = newestFirst[0] + // Why before the stop: a refused undo must leave the host's copies running (X1). + const refusal = wslMoveReleaseRefusal(userDataPath, first) + if (refusal) { + return refusal + } + // Fenced until the reload: an open host pane would otherwise start its stopped agent again. + const releaseSpawnFence = fenceHostSpawnsForWslMoveUndo(first.destinationEnvironmentId) + let fenceHandedToReload = false + try { + const repoIds: string[] = [] + const folderWorkspaceIds: string[] = [] + const released: WslMoveCutover[] = [] + let failure: Extract | null = null + for (const move of newestFirst) { + const moveRefusal = move === first ? null : wslMoveReleaseRefusal(userDataPath, move) + if (moveRefusal) { + failure = moveRefusal + break + } + // A left-off retry must not turn a host copy back on between this move's steps. + const step = await runWslMoveHostAutomationsExclusive(() => + releaseOneMoveHere(userDataPath, move, createUndoPorts()) + ) + if (!step.released) { + failure = step + break + } + released.push(move) + repoIds.push(...step.repoIds) + folderWorkspaceIds.push(...step.folderWorkspaceIds) + } + if (repoIds.length === 0 && folderWorkspaceIds.length === 0) { + return failure ?? { released: false, reason: 'not-moved' } + } + // Released moves stay released even if an older one refused; the reload shows what came back. + holdWslMoveWindowWrites() + // Focus left on a host copy would reopen its panes after the reload and restart their agents. + const ports = createUndoPorts() + for (const move of released) { + ports.refocusHere?.(move) + } + fenceHandedToReload = true + reloadWslMoveWindowWithWritesHeld(releaseSpawnFence) + return ( + failure ?? { + released: true, + distro: first.manifest.source.distro, + repoIds, + folderWorkspaceIds + } + ) + } finally { + // Any refusal, or a throw, before the reload took the fence over releases it here. + if (!fenceHandedToReload) { + releaseSpawnFence() + } + } +} + +/** One move: host automations off, host terminals stopped, then released; refused, all undone. */ +async function releaseOneMoveHere( + userDataPath: string, + move: WslMoveCutover, + ports: WslMoveUndoPorts +): Promise { + // Automations first, as they undo cleanly: a refusal must leave the host's terminals running (X1). + const automations = (await ports.stopHostAutomations?.(move)) ?? { + outcome: 'stopped', + restore: async () => {}, + applyHere: () => () => {} + } + if (automations.outcome !== 'stopped') { + return { + released: false, + reason: automations.outcome === 'unreachable' ? 'host-unreachable' : 'failed', + detail: + automations.outcome === 'removal-pending' ? 'host-removal-pending' : 'host-automations' + } + } + // The host's copies stop next, or an agent the move resumed there runs beside this one. + const stop = await ports.stopHostCopies(move).catch(() => 'unreachable' as const) + if (stop !== 'stopped') { + await automations.restore() + return { + released: false, + reason: stop === 'unreachable' ? 'host-unreachable' : 'host-still-running' + } + } + // Written here just before the release (the record keeps the choice), and undone if it refuses. + const revertHere = automations.applyHere() + const release = releaseWslMove(userDataPath, move) + if (!release.released) { + try { + revertHere() + } finally { + // The host's copies go back on even if putting the copies here back failed. + await automations.restore() + } + } + return release +} diff --git a/src/main/ipc/wsl-move-reloadable-window.ts b/src/main/ipc/wsl-move-reloadable-window.ts new file mode 100644 index 00000000000..45c764c6916 --- /dev/null +++ b/src/main/ipc/wsl-move-reloadable-window.ts @@ -0,0 +1,23 @@ +/** The app window as the WSL move's write hold reloads it (wsl-move-window-write-hold.ts). */ +import type { WebContents } from 'electron' +import type { WslMoveReloadableWindow } from '../persistence/moving-wsl-projects/wsl-move-window-write-hold' + +export function wslMoveReloadableWindow(contents: WebContents): WslMoveReloadableWindow { + return { + isDestroyed: () => contents.isDestroyed(), + reload: () => contents.reload(), + allowUnload: () => { + const allow = (event: Electron.Event): void => event.preventDefault() + contents.on('will-prevent-unload', allow) + return () => contents.removeListener('will-prevent-unload', allow) + }, + onLoadedOrGone: (listener: () => void) => { + contents.once('did-finish-load', listener) + contents.once('destroyed', listener) + return () => { + contents.removeListener('did-finish-load', listener) + contents.removeListener('destroyed', listener) + } + } + } +} diff --git a/src/main/ipc/wsl-move-undo-host-pane-open.test.ts b/src/main/ipc/wsl-move-undo-host-pane-open.test.ts new file mode 100644 index 00000000000..06134ba6934 --- /dev/null +++ b/src/main/ipc/wsl-move-undo-host-pane-open.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate' +import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { + loadWslMoveFence, + resetWslMoveFenceForTests +} from '../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { setWslMoveReloadableWindow } from '../persistence/moving-wsl-projects/wsl-move-window-write-hold' +import { + registerWslMoveDesktopRewriteStore, + resetWslMoveDesktopRewriteStoresForTests +} from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores' +import { + isHostSpawnFencedForWslMoveUndo, + resetWslMoveUndoSpawnFencesForTests +} from '../persistence/moving-wsl-projects/wsl-move-undo-spawn-fence' +import type { WslMoveHostCopyStop } from '../persistence/moving-wsl-projects/wsl-move-host-copy-stop' + +type Handler = (event: unknown, request?: unknown) => unknown + +const handlers = vi.hoisted(() => new Map()) +vi.mock('electron', () => ({ + ipcMain: { + removeHandler: (channel: string) => handlers.delete(channel), + handle: (channel: string, handler: Handler) => handlers.set(channel, handler) + } +})) + +import { registerWslMoveHandlers } from './wsl-move-handlers' + +const MAIN_WINDOW_ID = 7 +const HOST = cutover('move-1', 'destination-committed').destinationEnvironmentId +let userDataPath: string + +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-undo-open-')) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + registerWslMoveDesktopRewriteStore({ id: 'renderer-a', side: 'renderer' }) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + loadWslMoveFence(userDataPath) +}) +afterEach(() => { + vi.unstubAllEnvs() + setWslMoveReloadableWindow(() => null) + resetWslMoveDesktopRewriteStoresForTests() + resetWslMoveFenceForTests() + resetWslMoveUndoSpawnFencesForTests() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +function register(stopHostCopies: () => Promise): void { + registerWslMoveHandlers( + () => userDataPath, + (senderId) => senderId === MAIN_WINDOW_ID, + undefined, + () => ({ stopHostCopies }) + ) +} + +const flushStorageData = vi.fn() +const undo = (sender: unknown = { id: MAIN_WINDOW_ID, session: { flushStorageData } }) => + handlers.get('wslMove:useProjectHereAgain')?.({ sender }, { kind: 'repo', id: 'repo-1' }) + +// The open host pane resumed its agent on the host 1.6 s after undo stopped it. +describe('undo with the host pane open', () => { + it("refuses the window's host starts from the stop until the reloaded page has loaded", async () => { + let fencedDuringStop = false + register(async () => { + // The open pane sees its terminal exit and starts it again on the host. + fencedDuringStop = isHostSpawnFencedForWslMoveUndo(HOST, 'terminal.create') + return 'stopped' + }) + const page = { loaded: () => {} } + const mainWindow = { + id: MAIN_WINDOW_ID, + session: { flushStorageData }, + isDestroyed: () => false, + reload: vi.fn(), + allowUnload: () => () => {}, + onLoadedOrGone: (listener: () => void) => { + page.loaded = listener + return () => {} + } + } + setWslMoveReloadableWindow(() => mainWindow) + + expect(await undo(mainWindow)).toMatchObject({ released: true }) + expect(fencedDuringStop).toBe(true) + // The old page can still start host panes until the reload replaces it. + expect(isHostSpawnFencedForWslMoveUndo(HOST, 'terminal.ensureAgentSession')).toBe(true) + expect(isHostSpawnFencedForWslMoveUndo(HOST, 'terminal.list')).toBe(false) + expect(isHostSpawnFencedForWslMoveUndo('wsl:other', 'terminal.create')).toBe(false) + page.loaded() + expect(isHostSpawnFencedForWslMoveUndo(HOST, 'terminal.create')).toBe(false) + }) + + it.each(['still-running', 'unreachable'] as const)( + 'lifts the fence when the host stop is %s, leaving the host usable', + async (stop) => { + register(async () => stop) + expect(await undo()).toMatchObject({ released: false }) + expect(isHostSpawnFencedForWslMoveUndo(HOST, 'terminal.create')).toBe(false) + } + ) +}) diff --git a/src/main/ipc/wsl-move-undo-wiring.test.ts b/src/main/ipc/wsl-move-undo-wiring.test.ts new file mode 100644 index 00000000000..a0ea29dde0e --- /dev/null +++ b/src/main/ipc/wsl-move-undo-wiring.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../shared/execution-host' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { wslMoveCutoverFixture } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { wslMoverProfile } from '../persistence/__fixtures__/wsl-mover/wsl-mover-profiles' +import { createWslMoveUndoPorts } from './wsl-move-undo-wiring' + +const rpc = vi.hoisted(() => ({ + call: vi.fn(async (..._args: unknown[]): Promise => null) +})) +vi.mock('./runtime-environments/runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: (...args: unknown[]) => rpc.call(...args) +})) +const host = vi.hoisted(() => ({ + disabled: new Array(), + enabledOnHost: new Map(), + listed: true, + unreadable: false, + removalPending: false +})) +vi.mock('../../shared/runtime-environment-store', () => ({ + listEnvironments: () => { + if (host.unreadable) { + throw new Error('environment store unreadable') + } + return host.listed ? [{ id: 'wsl:Ubuntu' }] : [] + } +})) +vi.mock('./wsl-move-host-connect', () => ({ + wslHostRemovalPending: () => host.removalPending +})) +vi.mock('./wsl-host-automation-disable', () => ({ + readWslHostAutomationStates: async (_userData: string, _environmentId: string, ids: string[]) => + Object.fromEntries(ids.map((id) => [id, host.enabledOnHost.get(id) ?? false])), + disableWslHostAutomations: async (_userData: string, _environmentId: string, ids: string[]) => { + host.disabled.push([...ids]) + return [...ids] + }, + restoreWslHostAutomations: async () => {} +})) + +const MOVED = wslMoverProfile('mixed-windows-wsl', 'source').session.activeWorktreeId ?? '' +const MOVED_ON_HOST = 'repo-a::/home/dev/app' + +// verify-15b 6c: after undo the window reopened the host copy it was focused on, and the host +// resumed the pane's agent there beside the one restored here. +describe("undo's return of focus", () => { + it('points the local session back at the project here before the window reloads', () => { + const move = wslMoveCutoverFixture( + 'move-1', + 'retained', + {}, + { repoIds: ['repo-a'], folderWorkspaceIds: [] } + ) + let session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [MOVED]: [] }, + activeWorktreeId: MOVED_ON_HOST, + activeWorkspaceKey: `worktree:${MOVED_ON_HOST}`, + activeWorkspaceExecutionHostId: toRuntimeExecutionHostId(move.destinationEnvironmentId) + } + const store = { + getWorkspaceSession: () => session, + listAutomations: () => [], + updateAutomation: vi.fn(), + patchWorkspaceSession: vi.fn((patch: Partial, hostId?: string) => { + expect(hostId).toBe(LOCAL_EXECUTION_HOST_ID) + session = { ...session, ...patch } + }) + } + createWslMoveUndoPorts(store, '/user-data').refocusHere?.(move) + expect(session).toMatchObject({ + activeWorkspaceExecutionHostId: LOCAL_EXECUTION_HOST_ID, + activeWorktreeId: MOVED, + activeWorkspaceKey: `worktree:${MOVED}` + }) + // A second call finds focus already here and writes nothing. + store.patchWorkspaceSession.mockClear() + createWslMoveUndoPorts(store, '/user-data').refocusHere?.(move) + expect(store.patchWorkspaceSession).not.toHaveBeenCalled() + }) +}) + +describe("undo's host automation port", () => { + const move = wslMoveCutoverFixture( + 'move-1', + 'retained', + {}, + { repoIds: ['repo-a'] }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the port reads only the moved automations' ids. + { automations: [{ id: 'off-on-host' }, { id: 'on-on-host' }, { id: 'same' }] } as never + ) + const localRows = () => [ + // d3a case 1: turned off on the host after the move, still on here. + { id: 'off-on-host', enabled: true }, + // d3a case 2: off here before the move, turned on on the host after. + { id: 'on-on-host', enabled: false }, + { id: 'same', enabled: true }, + { id: 'not-moved', enabled: true } + ] + const storeWith = () => { + const updateAutomation = vi.fn() + return { + store: { + getWorkspaceSession: () => getDefaultWorkspaceSession(), + patchWorkspaceSession: vi.fn(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the port reads only `id` and `enabled`. + listAutomations: () => localRows() as never, + updateAutomation + }, + updateAutomation + } + } + + it("turns off every host copy of the move's automations, then carries the host's states here", async () => { + host.disabled = [] + host.listed = true + host.enabledOnHost = new Map([ + ['off-on-host', false], + ['on-on-host', true], + ['same', true] + ]) + const { store, updateAutomation } = storeWith() + const stopped = await createWslMoveUndoPorts(store, '/user-data').stopHostAutomations?.(move) + // Every moved automation is read on the host; those on there are turned off. + expect(host.disabled).toEqual([['on-on-host', 'same']]) + if (stopped?.outcome !== 'stopped') { + throw new Error('expected stopped') + } + // Nothing changes here until the release applies it. + expect(updateAutomation).not.toHaveBeenCalled() + stopped.applyHere() + expect(updateAutomation.mock.calls).toEqual([ + ['off-on-host', { enabled: false }], + ['on-on-host', { enabled: true }] + ]) + }) + + it('asks a removed host nothing', async () => { + host.disabled = [] + host.listed = false + const { store } = storeWith() + expect( + (await createWslMoveUndoPorts(store, '/user-data').stopHostAutomations?.(move))?.outcome + ).toBe('stopped') + expect(host.disabled).toEqual([]) + }) +}) + +describe("undo's host automation port, refusing", () => { + const move = wslMoveCutoverFixture('move-1', 'retained', {}, { repoIds: ['repo-a'] }) + const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), + patchWorkspaceSession: vi.fn(), + listAutomations: () => [], + updateAutomation: vi.fn() + } + + it('fails, without throwing past the spawn fence, when the environment store is unreadable', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + host.unreadable = true + try { + expect(await createWslMoveUndoPorts(store, '/user-data').stopHostAutomations?.(move)).toEqual( + { outcome: 'failed' } + ) + } finally { + host.unreadable = false + } + }) + + it('names a host whose Remove is pending (E11) rather than calling it unreachable', async () => { + host.listed = true + host.removalPending = true + try { + expect(await createWslMoveUndoPorts(store, '/user-data').stopHostAutomations?.(move)).toEqual( + { outcome: 'removal-pending' } + ) + } finally { + host.removalPending = false + } + }) +}) + +// verify-15c: with the host's orcad stopped, undo of a moved project this computer kept no tabs +// for asked the host nothing, released it, and its host shell kept running. +describe("undo's stop of the host's terminals", () => { + const move = wslMoveCutoverFixture('move-1', 'retained', {}, { repoIds: ['repo-a'] }) + // A moved project keeps only its chats here, so this one names no workspace at all. + const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), + patchWorkspaceSession: vi.fn(), + listAutomations: () => [], + updateAutomation: vi.fn() + } + + it('refuses when the host does not answer, though this computer names none of its workspaces', async () => { + host.listed = true + rpc.call.mockReset() + rpc.call.mockRejectedValue(new Error('timed out')) + expect(await createWslMoveUndoPorts(store, '/user-data').stopHostCopies(move)).toBe( + 'unreachable' + ) + expect(rpc.call.mock.calls.map(([, , method]) => method)).toEqual(['terminal.list']) + }) + + it("sleeps the host's workspaces of the move that run a terminal, and only those", async () => { + host.listed = true + const live = new Set(['repo-a::/home/dev/repo-a']) + rpc.call.mockReset() + rpc.call.mockImplementation(async (...args: unknown[]) => { + const [, , method, params] = args + const worktree = + typeof params === 'object' && params !== null && 'worktree' in params + ? String(params.worktree).slice('id:'.length) + : null + if (method === 'worktree.sleep' && worktree) { + live.delete(worktree) + return { ok: true, result: {} } + } + const terminals = [ + { worktreeId: 'repo-a::/home/dev/repo-a', connected: live.has('repo-a::/home/dev/repo-a') }, + { worktreeId: 'repo-other::/home/dev/other', connected: true } + ].filter((terminal) => worktree === null || terminal.worktreeId === worktree) + return { ok: true, result: { terminals, truncated: false } } + }) + expect(await createWslMoveUndoPorts(store, '/user-data').stopHostCopies(move)).toBe('stopped') + const slept = rpc.call.mock.calls + .filter(([, , method]) => method === 'worktree.sleep') + .map(([, , , params]) => params) + expect(slept).toEqual([{ worktree: 'id:repo-a::/home/dev/repo-a' }]) + }) +}) diff --git a/src/main/ipc/wsl-move-undo-wiring.ts b/src/main/ipc/wsl-move-undo-wiring.ts new file mode 100644 index 00000000000..d1c55d41414 --- /dev/null +++ b/src/main/ipc/wsl-move-undo-wiring.ts @@ -0,0 +1,179 @@ +/** + * The production port of "Use on this computer again": the host's copies of the project's + * terminals, reached over its paired runtime. + */ +import { z } from 'zod' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { WslMoveCutover } from '../../shared/wsl-move/wsl-move-cutover' +import type { Store } from '../persistence' +import { wslMoveDesktopRewriteContext } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite' +import { + stopWslMoveHostCopies, + type WslMoveHostCopyStop +} from '../persistence/moving-wsl-projects/wsl-move-host-copy-stop' +import { wslMoveRewriteScope } from '../persistence/moving-wsl-projects/wsl-move-rewrite-scope' +import { wslMoveReleasedSessionFocusPatch } from '../persistence/moving-wsl-projects/wsl-move-session-rewrite' +import { applyWslMoveSessionFocus } from '../persistence/moving-wsl-projects/wsl-move-main-rewrite-stores' +import { listEnvironments } from '../../shared/runtime-environment-store' +import { callRuntimeEnvironment } from './runtime-environments/runtime-environment-transport-routing' +import { + stopWslMoveHostAutomations, + wslMoveMovedAutomationIds, + type WslMoveHostAutomationStop +} from '../persistence/moving-wsl-projects/wsl-move-undo-host-automations' +import { scheduleWslMoveRewriteRetry } from '../persistence/moving-wsl-projects/wsl-move-rewrite-retry' +import { wslHostRemovalPending } from './wsl-move-host-connect' +import { + disableWslHostAutomations, + readWslHostAutomationStates, + restoreWslHostAutomations +} from './wsl-host-automation-disable' + +export type WslMoveUndoPorts = { + stopHostCopies: (move: WslMoveCutover) => Promise + /** First, before the terminals stop: the host's copies of the move's automations turn off. */ + stopHostAutomations?: (move: WslMoveCutover) => Promise + /** After the release, with window writes held: focus on the host copy returns here. */ + refocusHere?: (move: WslMoveCutover) => void +} + +// What undo reads of the host's terminal list: whether each terminal is still running. +const HostTerminalList = z.object({ + terminals: z.array( + z.object({ connected: z.boolean(), worktreeId: z.string().optional() }).passthrough() + ), + truncated: z.boolean().optional() +}) + +// Far above any host's terminal count; a list cut off at it cannot prove what runs there. +const HOST_TERMINAL_LIST_LIMIT = 10_000 + +const SLEEP_TIMEOUT_MS = 60_000 +const LIST_TIMEOUT_MS = 10_000 + +export function createWslMoveUndoPorts( + store: Pick< + Store, + 'getWorkspaceSession' | 'patchWorkspaceSession' | 'listAutomations' | 'updateAutomation' + >, + userDataPath: string +): WslMoveUndoPorts { + return { + stopHostCopies: async (move) => { + // A host the user removed ran its terminals down with it; nothing of the project runs there. + if (!listEnvironments(userDataPath).some((env) => env.id === move.destinationEnvironmentId)) { + return 'stopped' + } + const scope = wslMoveRewriteScope(wslMoveDesktopRewriteContext(userDataPath, move)) + const local = store.getWorkspaceSession(LOCAL_EXECUTION_HOST_ID) + const hostIds = [...new Set(Object.keys(local.tabsByWorktree).filter(scope.inScope))].map( + (id) => scope.worktreeId(id) + ) + const call = async (method: string, params: unknown, timeoutMs: number) => { + try { + return await callRuntimeEnvironment( + userDataPath, + move.destinationEnvironmentId, + method, + params, + timeoutMs + ) + } catch { + return null + } + } + return stopWslMoveHostCopies(hostIds, { + workspacesWithTerminals: async () => { + const response = await call( + 'terminal.list', + { limit: HOST_TERMINAL_LIST_LIMIT, includeVisualLayouts: false }, + LIST_TIMEOUT_MS + ) + const listed = response?.ok ? HostTerminalList.safeParse(response.result) : null + // An error answer, an unreadable one or a cut-off list cannot prove what runs there. + if (!listed?.success || listed.data.truncated === true) { + return null + } + return [ + ...new Set( + listed.data.terminals + .map((terminal) => terminal.worktreeId) + .filter((id): id is string => typeof id === 'string' && scope.inScope(id)) + ) + ] + }, + sleepWorkspace: async (id) => { + const response = await call('worktree.sleep', { worktree: `id:${id}` }, SLEEP_TIMEOUT_MS) + return response === null ? null : response.ok + }, + liveTerminals: async (id) => { + const response = await call('terminal.list', { worktree: `id:${id}` }, LIST_TIMEOUT_MS) + if (response === null) { + return null + } + // A workspace the host no longer lists runs nothing there. + if (!response.ok) { + return 0 + } + const listed = HostTerminalList.safeParse(response.result) + return listed.success + ? listed.data.terminals.filter((terminal) => terminal.connected).length + : 0 + }, + wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + }) + }, + stopHostAutomations: async (move) => { + // Inside a try: a throw here would otherwise escape past undo's spawn fence. + try { + // A removed host took its automations with it. + if ( + !listEnvironments(userDataPath).some((env) => env.id === move.destinationEnvironmentId) + ) { + return { outcome: 'stopped', restore: async () => {}, applyHere: () => () => {} } + } + // E11: a Remove that could not prove the server stopped; the host is not to be touched. + if (wslHostRemovalPending(userDataPath, move.manifest.source.distro)) { + return { outcome: 'removal-pending' } + } + const environmentId = move.destinationEnvironmentId + const stop = await stopWslMoveHostAutomations( + userDataPath, + move.migrationId, + wslMoveMovedAutomationIds(move), + { + read: (ids) => readWslHostAutomationStates(userDataPath, environmentId, ids), + disable: (ids) => disableWslHostAutomations(userDataPath, environmentId, ids), + restore: (ids) => restoreWslHostAutomations(userDataPath, environmentId, ids), + listHere: () => store.listAutomations(), + setHere: (id, enabled) => { + store.updateAutomation(id, { enabled }) + } + } + ) + // Whatever a refusal left off on the host is retried until it is back on. + if (stop.outcome !== 'stopped') { + scheduleWslMoveRewriteRetry(userDataPath) + return stop + } + return { + ...stop, + restore: async () => { + await stop.restore() + scheduleWslMoveRewriteRetry(userDataPath) + } + } + } catch (error) { + console.warn('[wsl-move] Could not read what undo turns off on the host:', error) + return { outcome: 'failed' } + } + }, + refocusHere: (move) => { + applyWslMoveSessionFocus( + store, + wslMoveRewriteScope(wslMoveDesktopRewriteContext(userDataPath, move)), + wslMoveReleasedSessionFocusPatch + ) + } + } +} diff --git a/src/main/orcad/orcad-codex-account-homes.test.ts b/src/main/orcad/orcad-codex-account-homes.test.ts index 79975aefd73..00ebb9067f1 100644 --- a/src/main/orcad/orcad-codex-account-homes.test.ts +++ b/src/main/orcad/orcad-codex-account-homes.test.ts @@ -12,6 +12,8 @@ const { prepareHooks, resolveHome, listHomes } = vi.hoisted(() => ({ listHomes: vi.fn(() => ['/home/u/.local/share/orca/codex-accounts/a1/home']) })) +const wslHost = vi.hoisted(() => vi.fn(() => false)) +vi.mock('../ipc/wsl-guest-windows-drive-path', () => ({ isWslHostOrcadProcess: wslHost })) vi.mock('../codex/codex-account-home-hook', () => ({ reconcileCodexAccountHomeHook: prepareHooks })) @@ -41,6 +43,19 @@ describe('orcadCodexAccountHomes', () => { prepareHooks.mockClear() }) + it('on a WSL host with hooks off, leaves the bridge-shared entry alone', async () => { + wslHost.mockReturnValue(true) + const homes = orcadCodexAccountHomes( + () => settings({ agentStatusHooksEnabled: false }), + noCleanup + ) + await expect(homes.selectLaunchHome(undefined, { CODEX_HOME: ACCOUNT_HOME })).resolves.toBe( + ACCOUNT_HOME + ) + expect(prepareHooks).not.toHaveBeenCalled() + wslHost.mockReturnValue(false) + }) + it('puts the hook into a client-sent account home and launches on it', async () => { const homes = orcadCodexAccountHomes(() => settings({}), noCleanup) await expect( diff --git a/src/main/orcad/orcad-codex-account-homes.ts b/src/main/orcad/orcad-codex-account-homes.ts index 992b7eb51b8..790b92a8070 100644 --- a/src/main/orcad/orcad-codex-account-homes.ts +++ b/src/main/orcad/orcad-codex-account-homes.ts @@ -7,6 +7,7 @@ import { import { reconcileCodexAccountHomeHook } from '../codex/codex-account-home-hook' import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' import type { GetSelectedCodexHomePath } from '../ipc/pty/host-env/types' +import { isWslHostOrcadProcess } from '../ipc/wsl-guest-windows-drive-path' import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime' /** Keep host status hooks and session discovery in client-selected managed CODEX_HOME paths. */ @@ -21,11 +22,11 @@ export function orcadCodexAccountHomes( if (!home) { return null } - await reconcileCodexAccountHomeHook( - home, - () => isAgentStatusHooksEnabledForAgent(getSettings(), 'codex'), - context?.launchesCodex === true - ) + const isEnabled = () => isAgentStatusHooksEnabledForAgent(getSettings(), 'codex') + // R42, COMPAT(wsl-bridge): the bridge's panes share these homes; off skips until D4 drops it. + if (isEnabled() || !isWslHostOrcadProcess()) { + await reconcileCodexAccountHomeHook(home, isEnabled, context?.launchesCodex === true) + } return home } return { diff --git a/src/main/orcad/orcad-migration-destination-capabilities.ts b/src/main/orcad/orcad-migration-destination-capabilities.ts new file mode 100644 index 00000000000..dd10fd3a2ef --- /dev/null +++ b/src/main/orcad/orcad-migration-destination-capabilities.ts @@ -0,0 +1,15 @@ +import { advertisedOrcadMigrationBlobCapabilities } from '../../shared/orcad-migration/orcad-migration-blob-transfer' +import { SESSION_TERMINAL_SCROLLBACK_READ_RUNTIME_CAPABILITY } from '../../shared/session-tabs-runtime-capabilities' +import { servesWslMovedPaneHistory } from './orcad-migration-wsl-destination' + +/** + * Migration blob kinds this host installs, plus the stored-scrollback read a WSL move's destination + * serves. Terminal history and the scrollback read stay off everywhere but a WSL host. + */ +export function advertisedOrcadMigrationDestinationCapabilities(): string[] { + const wslHost = servesWslMovedPaneHistory() + return [ + ...advertisedOrcadMigrationBlobCapabilities({ wslHost }), + ...(wslHost ? [SESSION_TERMINAL_SCROLLBACK_READ_RUNTIME_CAPABILITY] : []) + ] +} diff --git a/src/main/orcad/orcad-migration-wsl-destination.ts b/src/main/orcad/orcad-migration-wsl-destination.ts index 9f082508f6b..d28009830d2 100644 --- a/src/main/orcad/orcad-migration-wsl-destination.ts +++ b/src/main/orcad/orcad-migration-wsl-destination.ts @@ -15,6 +15,17 @@ export function servesLocalWslMigrationSource( return isWslHostOrcadProcess(env, platform) } +// E2E hosts stand in for a WSL host's orcad; a real one is identified by isWslHostOrcadProcess. +export const ORCA_E2E_WSL_MOVE_DESTINATION_ENV = 'ORCA_E2E_WSL_MOVE_DESTINATION' + +/** + * Whether this host is a WSL move's destination: it installs moved panes' terminal history and + * serves their stored scrollback. Every other host keeps that wire surface off. + */ +export function servesWslMovedPaneHistory(env: NodeJS.ProcessEnv = process.env): boolean { + return isWslHostOrcadProcess(env) || env[ORCA_E2E_WSL_MOVE_DESTINATION_ENV] === '1' +} + export function assertOrcadMigrationSourceServedHere( source: OrcadMigrationManifestSource, env: NodeJS.ProcessEnv = process.env, diff --git a/src/main/persistence-orcad-migration-wsl-generation-reuse.test.ts b/src/main/persistence-orcad-migration-wsl-generation-reuse.test.ts new file mode 100644 index 00000000000..6914572720b --- /dev/null +++ b/src/main/persistence-orcad-migration-wsl-generation-reuse.test.ts @@ -0,0 +1,108 @@ +/** + * R47: "Use on this computer again" can hand a later WSL move the generation number an earlier one + * used. The host stages, commits and keeps receipts by migration id and manifest digest, so two + * moves sharing a generation never collide; only the desktop's planner reads the generation. + */ +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 type { OrcadMigrationManifest } from '../shared/orcad-migration/orcad-migration-manifest' +import type { Repo } from '../shared/repo-types' +import { manifest } from './persistence/migrating-orcad-catalog/persistence-orcad-migration-catalog-fixture' +import { createStore, testState } from './persistence/persistence-test-harness' + +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: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 1 })) +})) + +const GENERATION = 3 + +function wslMove(migrationId: string, repoId: string): OrcadMigrationManifest { + const repo: Repo = { + id: repoId, + path: `/home/dev/${repoId}`, + displayName: repoId, + badgeColor: '#737373', + addedAt: 1, + kind: 'git' + } + return manifest({ + migrationId, + source: { + kind: 'local-wsl', + distro: 'Ubuntu', + scopeRepoIds: [repoId], + scopeFolderWorkspaceIds: [], + generation: GENERATION + }, + payload: { repositories: [repo], projectGroups: [], folderWorkspaces: [] } + }) +} + +describe('two WSL moves that share a generation number', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-wsl-generation-reuse-')) + }) + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('stage and commit independently, each under its own receipt', async () => { + const store = createStore() + const earlier = wslMove('move-earlier', 'repo-a') + const later = wslMove('move-later', 'repo-b') + expect(earlier.manifestSha256).not.toBe(later.manifestSha256) + + expect(store.stageOrcadMigrationCatalog(earlier).state).toBe('staged') + expect(store.stageOrcadMigrationCatalog(later).state).toBe('staged') + expect(store.commitStagedOrcadMigrationCatalog(earlier)).toMatchObject({ + state: 'committed', + receipt: { migrationId: 'move-earlier', repositoryIds: ['repo-a'] } + }) + // The earlier commit neither committed nor dropped the later stage. + expect(store.getOrcadMigrationCatalogState(later).state).toBe('staged') + expect(store.commitStagedOrcadMigrationCatalog(later)).toMatchObject({ + state: 'committed', + receipt: { migrationId: 'move-later', repositoryIds: ['repo-b'] } + }) + await store.flushPendingOrThrowAsync() + + const restored = createStore() + for (const move of [earlier, later]) { + expect(restored.getOrcadMigrationCatalogState(move)).toMatchObject({ + state: 'committed', + migrationId: move.migrationId, + manifestSha256: move.manifestSha256 + }) + } + expect( + restored + .getRepos() + .map((repo) => repo.id) + .sort() + ).toEqual(['repo-a', 'repo-b']) + }) + + it('abort one without touching the other', () => { + const store = createStore() + const earlier = wslMove('move-earlier', 'repo-a') + const later = wslMove('move-later', 'repo-b') + store.stageOrcadMigrationCatalog(earlier) + store.stageOrcadMigrationCatalog(later) + + expect(store.abortStagedOrcadMigrationCatalog(later)).toMatchObject({ aborted: true }) + expect(store.getOrcadMigrationCatalogState(earlier).state).toBe('staged') + expect(store.commitStagedOrcadMigrationCatalog(earlier).state).toBe('committed') + expect(store.getOrcadMigrationCatalogState(later).state).toBe('absent') + }) +}) diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture.ts index 00a225d34bb..19462e0987f 100644 --- a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture.ts +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture.ts @@ -1,4 +1,5 @@ import { ORCAD_MIGRATION_MANIFEST_VERSION } from '../../../../shared/orcad-migration/orcad-migration-manifest-validation' +import type { OrcadMigrationDormantStatePayload } from '../../../../shared/orcad-migration/orcad-migration-manifest' import { wslMoveJournalKey, type WslMoveCutover, @@ -10,7 +11,9 @@ export function wslMoveCutoverFixture( migrationId = 'move-1', phase: WslMoveCutoverPhase = 'prepared', extra: Partial = {}, - scope: { repoIds?: string[]; folderWorkspaceIds?: string[] } = {} + scope: { repoIds?: string[]; folderWorkspaceIds?: string[] } = {}, + dormantState?: OrcadMigrationDormantStatePayload, + distro = 'Ubuntu' ): WslMoveCutover { const repoIds = scope.repoIds ?? ['repo-1'] const folderWorkspaceIds = scope.folderWorkspaceIds ?? [] @@ -20,7 +23,7 @@ export function wslMoveCutoverFixture( createdAt: '2026-10-13T00:00:00.000Z', source: { kind: 'local-wsl' as const, - distro: 'Ubuntu', + distro, scopeRepoIds: repoIds, scopeFolderWorkspaceIds: folderWorkspaceIds, generation: 1 @@ -48,9 +51,10 @@ export function wslMoveCutoverFixture( lastActivityAt: 0, createdAt: 0, updatedAt: 0 - })) + })), + ...(dormantState ? { dormantState } : {}) }, - destinationEnvironmentId: 'wsl:Ubuntu' + destinationEnvironmentId: `wsl:${distro}` } const manifest = { ...unsigned, manifestSha256: computeOrcadMigrationManifestSha256(unsigned) } return { @@ -59,9 +63,9 @@ export function wslMoveCutoverFixture( phase, startedAt: '2026-10-13T00:00:00.000Z', updatedAt: '2026-10-13T00:00:00.000Z', - destinationEnvironmentId: 'wsl:Ubuntu', - destinationName: 'Ubuntu', - journalKey: wslMoveJournalKey('Ubuntu'), + destinationEnvironmentId: `wsl:${distro}`, + destinationName: distro, + journalKey: wslMoveJournalKey(distro), manifestSha256: manifest.manifestSha256, provenExitedSessionIds: [], rewrittenStores: [], diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-driver-world.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-driver-world.ts new file mode 100644 index 00000000000..965816d356e --- /dev/null +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-driver-world.ts @@ -0,0 +1,282 @@ +/** + * A fake world for the WSL move driver: a profile with one WSL project, a WSL host whose catalog, + * blob staging and commit can fail at named points (the fault-injection pattern of #29826), and + * terminals that pause on request. The journal is the real one, in a temp directory. + */ +import type { + OrcadMigrationBlobDescriptor, + OrcadMigrationBlobUploadState +} from '../../../../shared/orcad-migration/orcad-migration-blob-transfer' +import { ORCAD_MIGRATION_BLOB_KIND_CONTRACTS } from '../../../../shared/orcad-migration/orcad-migration-blob-transfer' +import type { + OrcadMigrationCatalogState, + OrcadMigrationManifest +} from '../../../../shared/orcad-migration/orcad-migration-manifest' +import { + ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, + ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY +} from '../../../../shared/orcad-runtime-capabilities' +import { getDefaultPersistedState } from '../../../../shared/constants' +import { terminalTab } from './wsl-mover-profile-rows' +import type { Repo } from '../../../../shared/repo-types' +import type { WslMoveProgressEvent } from '../../../../shared/wsl-move/wsl-move-driver-types' +import { collectOrcadMigrationSourceDormantState } from '../../migrating-orcad-catalog/orcad-source-dormant-state' +import { exportWslMoveManifest } from '../../moving-wsl-projects/wsl-move-manifest-export' +import { runWslMoveDesktopRewritePass } from '../../moving-wsl-projects/wsl-move-desktop-rewrite' +import { + readWslMoveSleptScrollbackFromHistory, + wslMoveSleptSpoolRoot +} from '../../moving-wsl-projects/wsl-move-slept-scrollback' +import type { + WslMoveDriverDeps, + WslMoveHost, + WslMoveManifestExport +} from '../../moving-wsl-projects/wsl-move-driver-ports' +import type { WslMoveGateInputs } from '../../moving-wsl-projects/wsl-move-terminal-gate' + +export class WslMoveWorldCrash extends Error {} + +export const WSL_MOVE_WORLD_PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +export type WslMoveWorldState = { + /** Where the next failure fires; each fires once. */ + failAt: Set + points: string[] + catalog: Map + blobUploads: Map + committedWithBlobs: OrcadMigrationBlobDescriptor[] | null + progress: WslMoveProgressEvent[] + slept: string[][] + gate: WslMoveGateInputs + /** Overrides the export, e.g. to grow it after Sleep. */ + exportOverride: ((exported: WslMoveManifestExport, call: number) => WslMoveManifestExport) | null + repos: Repo[] +} + +export type WslMoveWorld = WslMoveWorldState & { deps: WslMoveDriverDeps } + +const unc = (linux: string): string => `\\\\wsl.localhost\\Ubuntu${linux.replaceAll('/', '\\')}` + +export function createWslMoveWorld(userDataPath: string): WslMoveWorld { + const world: WslMoveWorldState = { + failAt: new Set(), + points: [], + catalog: new Map(), + blobUploads: new Map(), + committedWithBlobs: null, + progress: [], + slept: [], + gate: { panes: [], runningAutomationRunIds: [], activeDispatchIds: [] }, + exportOverride: null, + repos: [ + { + id: 'repo-1', + path: unc('/home/dev/app'), + displayName: 'app', + badgeColor: '#000', + addedAt: 0 + } + ] + } + const point = (name: string): void => { + world.points.push(name) + if (world.failAt.delete(name)) { + throw new WslMoveWorldCrash(name) + } + } + const state = (manifest: OrcadMigrationManifest): OrcadMigrationCatalogState => { + const ids = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } + const held = world.catalog.get(manifest.migrationId) + if (held === 'committed') { + return { + state: 'committed', + ...ids, + receipt: { + version: manifest.version, + ...ids, + source: manifest.source, + importedAt: '2026-10-20T00:00:00.000Z', + repositoryIds: manifest.payload.repositories.map((repo) => repo.id), + projectGroupIds: [], + folderWorkspaceIds: [] + } + } + } + if (held === 'staged') { + return { + state: 'staged', + ...ids, + stagedAt: '2026-10-20T00:00:00.000Z', + blobUploads: [...world.blobUploads.values()] + } + } + return { state: 'absent', ...ids } + } + const commit = async (manifest: OrcadMigrationManifest): Promise => { + point('commit:before') + if (!world.catalog.has(manifest.migrationId)) { + throw new Error('orcad_migration_catalog_not_staged') + } + world.catalog.set(manifest.migrationId, 'committed') + point('commit:lost-reply') + return state(manifest) + } + const host: WslMoveHost = { + // The journal fixture's spelling, so a seeded earlier move names this same host. + environmentId: 'wsl:Ubuntu', + name: 'Ubuntu', + capabilities: [ + ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY, + ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, + ...Object.values(ORCAD_MIGRATION_BLOB_KIND_CONTRACTS).map((contract) => contract.capability) + ], + catalog: { + readState: async (manifest) => state(manifest), + stage: async (manifest) => { + point('stage:before') + if (!world.catalog.has(manifest.migrationId)) { + world.catalog.set(manifest.migrationId, 'staged') + } + point('stage:lost-reply') + return state(manifest) + }, + commit, + abort: async (manifest) => { + const aborted = world.catalog.get(manifest.migrationId) === 'staged' + if (aborted) { + world.catalog.delete(manifest.migrationId) + world.blobUploads.clear() + } + return { ...state(manifest), aborted, durableAbsent: true } + }, + abortByReference: async () => { + throw new Error('unused') + }, + stageChunk: async () => { + throw new Error('unused: the fixture carries no scrollback snapshots') + } + }, + commitWithBlobs: async (manifest, expectedBlobs) => { + world.committedWithBlobs = [...expectedBlobs] + return commit(manifest) + }, + blobs: { + stageChunk: async (request) => { + point(`blob:${request.blob.blobKind}`) + const bytes = Buffer.from(request.bytesBase64, 'base64').length + const received = request.offset + bytes + world.blobUploads.set(request.blob.key, { ...request.blob, receivedBytes: received }) + return { + migrationId: request.migrationId, + manifestSha256: request.manifestSha256, + blobKind: request.blob.blobKind, + key: request.blob.key, + receivedBytes: received + } + } + }, + readHolding: async () => ({ repoIds: [], folderWorkspaceIds: [], highestGeneration: 0 }) + } + let exports = 0 + let migrations = 0 + const deps: WslMoveDriverDeps = { + userDataPath, + readCatalog: () => ({ repos: world.repos, folderWorkspaces: [], projectGroups: [] }), + connectHost: async () => host, + isDistroRegistered: async () => true, + exportManifest: (request) => { + exports += 1 + const state = getDefaultPersistedState('C:\\Users\\dev') + state.repos = world.repos + // The paused pane's tab, so its terminal-history blob has a pane on the host to restore into. + const [tabId, leafId] = WSL_MOVE_WORLD_PANE.split(':') + const worktreeId = `repo-1::${unc('/home/dev/app')}` + state.workspaceSession = { + ...state.workspaceSession, + tabsByWorktree: { [worktreeId]: [terminalTab(tabId, worktreeId, null, null)] }, + terminalLayoutsByTabId: { + [tabId]: { root: { type: 'leaf', leafId }, activeLeafId: leafId, expandedLeafId: null } + } + } + const exported = exportWslMoveManifest( + { + getRepos: () => world.repos, + getFolderWorkspaces: () => [], + getProjectGroups: () => [], + inspectOrcadMigrationSourceDormantState: (source, catalog, destination) => + collectOrcadMigrationSourceDormantState(state, source, catalog, undefined, destination) + }, + request + ) + return world.exportOverride ? world.exportOverride(exported, exports) : exported + }, + gateInputs: async () => world.gate, + listPanes: () => [{ paneKey: WSL_MOVE_WORLD_PANE, sessionId: 'session-1', wslDistro: null }], + quiesce: { + sleepWorkspaces: async (ids) => { + world.slept.push([...ids]) + return { failedWorktreeIds: [] } + }, + livePtyIds: () => [], + captureRestoreHints: async () => ({ + hints: [{ paneKey: WSL_MOVE_WORLD_PANE, cwd: '/home/dev/app/src' }], + unreadablePaneKeys: [], + noCwdPaneKeys: [], + staleCwdPaneKeys: [] + }), + proveExited: async () => { + point('prove-exited') + return { verdict: 'exited' } + } + }, + // The paused pane's checkpoint, carried as its terminal-history blob. + sleptScrollback: (panes, distro, migrationId) => + readWslMoveSleptScrollbackFromHistory( + { + detectColdRestoreState: async (sessionId) => ({ + status: 'restored', + sessionId, + hasUnreadableRecovery: false, + restoreInfo: { + snapshotAnsi: '$ make\r\nok\r\n$ ', + scrollbackAnsi: '', + rehydrateSequences: '', + cwd: '/home/dev/app/src', + cols: 80, + rows: 24, + scrollbackLines: 2, + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: false + } + } + }) + }, + panes.map((pane) => ({ ...pane, wslDistro: distro })), + distro, + wslMoveSleptSpoolRoot(userDataPath, migrationId), + () => null, + () => new Date('2026-10-20T00:00:00.000Z') + ), + snapshots: { + readOrcadMigrationSourceSnapshotChunk: () => { + throw new Error('unused') + } + }, + shellHistory: async () => ({ + descriptors: [], + source: { + readChunk: async () => { + throw new Error('unused') + } + } + }), + finishOnDesktop: () => runWslMoveDesktopRewritePass({ userDataPath, warn: () => {} }), + progress: (event) => world.progress.push(event), + newMigrationId: () => `move-${(migrations += 1)}`, + now: () => new Date('2026-10-20T00:00:00.000Z') + } + return Object.assign(world, { deps }) +} diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-kill-rig.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-kill-rig.ts new file mode 100644 index 00000000000..139c84669ec --- /dev/null +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-kill-rig.ts @@ -0,0 +1,210 @@ +/** + * One desktop moving one project into a WSL host backed by a real orcad profile store, with every + * step a kill point (wsl-move-store-host). Shared by the fault, update, lost-distro and + * two-desktop tests. A test file mocks the journal itself and points its hook at `rig.at`. + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { wslMoveJournalKey } from '../../../../shared/wsl-move/wsl-move-cutover' +import { createStore, testState } from '../../persistence-test-harness' +import type { Store } from '../../loading-store/store' +import { + listWslMoveCutoverChain, + listWslMoveCutovers +} from '../../moving-wsl-projects/wsl-move-cutover-journal' +import { runWslMoveDesktopRewritePass } from '../../moving-wsl-projects/wsl-move-desktop-rewrite' +import { + registerWslMoveDesktopRewriteStore, + resetWslMoveDesktopRewriteStoresForTests, + setWslMoveDesktopRewriteRegistryCompleteForTests +} from '../../moving-wsl-projects/wsl-move-desktop-rewrite-stores' +import { resolveWslMoveFence } from '../../moving-wsl-projects/wsl-move-fence' +import { + listWslMoveOwedHostAborts, + settleWslMoveOwedHostAborts +} from '../../moving-wsl-projects/wsl-move-owed-host-aborts' +import { + recoverInterruptedWslMoves, + resetWslMoveToZero +} from '../../moving-wsl-projects/wsl-move-reset' +import { runWslMoveRetentionPass } from '../../moving-wsl-projects/wsl-move-retention' +import { createWslMoveWorld, type WslMoveWorld } from './wsl-move-driver-world' +import { + createWslMoveKillSwitch, + createWslMoveStoreHost, + passWslMoveKillPoint, + type WslMoveKillSwitch +} from './wsl-move-store-host' + +export const WSL_MOVE_RIG_STORE_IDS = ['main-profile', 'renderer-ui', 'browser'] as const +export const WSL_MOVE_RIG_REQUEST = { distro: 'Ubuntu' } + +/** `zero-abort-owed`: the desktop is at zero; the host's leftover stage goes on its next contact. */ +export type WslMoveRigOutcome = + | 'back-at-zero' + | 'zero-abort-owed' + | 'fully-moved' + | 'waiting-for-host' + | 'stuck' + +export type WslMoveRig = { + userDataPath: string + kill: WslMoveKillSwitch + world: WslMoveWorld + /** The host's current store; an orcad restart replaces it. */ + host: Store + /** False while the distro is unregistered: the host neither connects nor answers. */ + hostPresent: boolean + rewritten: Map + at: (name: string) => void +} + +const dirs: string[] = [] + +export function wslMoveRigTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)) + dirs.push(dir) + return dir +} + +export function removeWslMoveRigDirs(): void { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +} + +/** A fresh profile and host, with this rig's rewrite stores as the only registered ones. */ +export function createWslMoveKillRig(): WslMoveRig { + resetWslMoveDesktopRewriteStoresForTests() + setWslMoveDesktopRewriteRegistryCompleteForTests(true) + testState.dir = wslMoveRigTempDir('wsl-move-host-') + const userDataPath = wslMoveRigTempDir('wsl-move-desktop-') + const kill = createWslMoveKillSwitch() + const world = createWslMoveWorld(userDataPath) + const at = (name: string): void => passWslMoveKillPoint(kill, name) + const rig: WslMoveRig = { + userDataPath, + kill, + world, + host: createStore(), + hostPresent: true, + rewritten: new Map(), + at + } + const host = createWslMoveStoreHost({ + store: () => rig.host, + kill, + available: () => rig.hostPresent + }) + world.deps.connectHost = async () => { + at('connect') + return rig.hostPresent ? host : null + } + world.deps.isDistroRegistered = async () => rig.hostPresent + const { quiesce } = world.deps + world.deps.quiesce = { + sleepWorkspaces: async (ids) => (at('sleep'), quiesce.sleepWorkspaces(ids)), + livePtyIds: quiesce.livePtyIds, + captureRestoreHints: async (panes, distro) => ( + at('restore-hints'), + quiesce.captureRestoreHints(panes, distro) + ), + proveExited: async (scope) => (at('prove-exited'), quiesce.proveExited(scope)) + } + for (const id of WSL_MOVE_RIG_STORE_IDS) { + registerWslMoveDesktopRewriteStore({ + id, + side: 'main', + run: () => { + at(`store:${id}:run`) + rig.rewritten.set(id, 1) + }, + flush: () => at(`store:${id}:flush`) + }) + } + return rig +} + +export function wslMoveRigRetentionStore(rig: WslMoveRig) { + return { + getRepos: () => rig.world.repos, + getFolderWorkspaces: () => [], + setWslMoveRetainedMarks: () => 1, + flushPendingOrThrowAsync: async () => {} + } +} + +/** orcad stopped and started again over the same disk, as an update or a distro restart does. */ +export async function restartWslMoveRigHost(rig: WslMoveRig): Promise { + await rig.host.flushPendingOrThrowAsync().catch(() => {}) + rig.host = createStore() +} + +/** Startup, then the user's next move attempt reaching the host (which settles the old one). */ +export async function restartWslMoveRig( + rig: WslMoveRig, + options: { hostUp: boolean; orcadRestarted: boolean } +): Promise { + rig.kill.dead = false + if (options.orcadRestarted) { + await restartWslMoveRigHost(rig) + } + await recoverInterruptedWslMoves(rig.userDataPath, () => {}, rig.world.deps.isDistroRegistered) + await runWslMoveDesktopRewritePass({ userDataPath: rig.userDataPath, warn: () => {} }) + if (options.hostUp) { + // What the next move's prepare does once it reaches the host, before it stages anything. + const key = wslMoveJournalKey(WSL_MOVE_RIG_REQUEST.distro) + const host = await rig.world.deps.connectHost(WSL_MOVE_RIG_REQUEST.distro) + if (host) { + await settleWslMoveOwedHostAborts(rig.userDataPath, key, host.catalog) + } + const head = listWslMoveCutoverChain(rig.userDataPath, key).at(-1) + if (head) { + await resetWslMoveToZero( + { userDataPath: rig.userDataPath, destination: host?.catalog ?? null }, + head.migrationId + ) + await rig.world.deps.finishOnDesktop() + } + } + await runWslMoveRetentionPass({ + userDataPath: rig.userDataPath, + store: wslMoveRigRetentionStore(rig) + }) +} + +export function wslMoveRigOutcome(rig: WslMoveRig): WslMoveRigOutcome { + const records = listWslMoveCutovers(rig.userDataPath) + const fence = resolveWslMoveFence(rig.userDataPath) + const onHost = rig.host.getRepos().some((repo) => repo.id === 'repo-1') + if (records.length === 0) { + const staged = rig.host.hasStagedOrcadMigrationCatalog() + const owed = listWslMoveOwedHostAborts(rig.userDataPath).length > 0 + const clean = + !onHost && + (!staged || owed) && + fence.fenced.repoIds.size === 0 && + fence.hidden.repoIds.size === 0 && + fence.sessionFrozen.repoIds.size === 0 && + rig.rewritten.size === 0 + if (!clean) { + return 'stuck' + } + return owed ? 'zero-abort-owed' : 'back-at-zero' + } + const [record] = records + if (records.length === 1 && record.phase === 'retained') { + const allOnce = WSL_MOVE_RIG_STORE_IDS.every((id) => rig.rewritten.get(id) === 1) + return allOnce && + onHost && + fence.hidden.repoIds.has('repo-1') && + fence.fenced.repoIds.size === 0 + ? 'fully-moved' + : 'stuck' + } + if (records.length === 1 && record.phase === 'blobs-transferred') { + return 'waiting-for-host' + } + return 'stuck' +} diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-round-trip-normalization.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-round-trip-normalization.ts new file mode 100644 index 00000000000..7b2ce35ad0b --- /dev/null +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-round-trip-normalization.ts @@ -0,0 +1,160 @@ +/** + * What the real round trip may legitimately change, so the comparison catches everything else: + * schema defaults the host's load fills in, ids it derives, and representation changes the move + * makes on purpose. Each known gap is a real loss, named here so a fix has to remove it. + */ +import { isRecord } from '../../moving-wsl-projects/wsl-move-canonical-form' + +/** Session fields the real pipeline does not carry yet, with why; each still differs today. */ +export const WSL_MOVE_ROUND_TRIP_KNOWN_GAPS: Readonly> = { + activeWorktreeIdsOnShutdown: 'Never seeded: focus belongs to whoever looks at the host.', + terminalSurfaceTombstonesByPaneKey: 'Fences of local PTY incarnations; the host starts its own.' +} + +// Row keys the host's load fills in; dropped only where the source row left the key out. +const FILLED_IN: Readonly> = { + repos: ['gitUsername', 'hookSettings', 'kind'], + projects: ['kind'], + // Re-derived on the host from the project the setup resolves to. + worktreeMeta: ['projectId'] +} +// Moved disabled; the ledgered `host-automations` step turns them back on (checked separately). +const BOTH_SIDES: Readonly> = { automations: ['enabled'] } +const DEFAULTED_SESSION: Readonly>>> = { + browserTabsByWorktree: { sessionPartition: null, sessionProfileId: null }, + sleepingAgentSessionsByPaneKey: { connectionId: null } +} + +type Rows = Map> + +/** A field's rows by id (arrays) or by key (records). */ +function rowsOf(value: unknown): Rows { + const rows: Rows = new Map() + const entries = Array.isArray(value) + ? value.map((row): [unknown, unknown] => [isRecord(row) ? row.id : undefined, row]) + : Object.entries(isRecord(value) ? value : {}) + for (const [id, row] of entries) { + if (typeof id === 'string' && isRecord(row)) { + rows.set(id, row) + } + } + return rows +} + +/** Top-level row keys in `keys` dropped where `drop` says so; nested values are never touched. */ +function withoutRowKeys( + value: unknown, + keys: readonly string[], + drop: (id: string, key: string) => boolean +): unknown { + const next: unknown = structuredClone(value ?? null) + for (const [id, row] of rowsOf(next)) { + for (const key of keys) { + if (drop(id, key)) { + delete row[key] + } + } + } + return next +} + +function withoutDefaultValues( + value: unknown, + defaults: Readonly> +): unknown { + return JSON.parse(JSON.stringify(value ?? null), (key, entry: unknown) => + Object.hasOwn(defaults, key) && entry === defaults[key] ? undefined : entry + ) +} + +/** Empty per-repo lists are the same as none. */ +function withoutEmptyLists(value: unknown): unknown { + if (!isRecord(value)) { + return value + } + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => !(Array.isArray(entry) && entry.length === 0)) + ) +} + +/** + * Local-only scrollback travels as a host scrollback snapshot. Returns the destination session + * with exactly those snapshot refs removed, or null when a parked leaf did not become one. + */ +export function withoutParkedScrollbackRefs( + sourceSession: unknown, + destinationSession: unknown +): Record | null { + const parked = isRecord(sourceSession) ? sourceSession.localOnlyScrollbackByTabId : undefined + const next = structuredClone(isRecord(destinationSession) ? destinationSession : {}) + const layouts = next.terminalLayoutsByTabId + for (const [tabId, leaves] of Object.entries(isRecord(parked) ? parked : {})) { + const layout = isRecord(layouts) ? layouts[tabId] : undefined + const refs = isRecord(layout) ? layout.scrollbackRefsByLeafId : undefined + if (!isRecord(layout) || !isRecord(refs) || !isRecord(leaves)) { + return null + } + for (const leafId of Object.keys(leaves)) { + if (typeof refs[leafId] !== 'string') { + return null + } + delete refs[leafId] + } + if (Object.keys(refs).length === 0) { + delete layout.scrollbackRefsByLeafId + } + } + return next +} + +/** + * One projection with every allowed difference taken out. `source` is the source projection when + * normalizing the destination: a filled-in key goes only where that source row lacked it. + */ +export function normalizeWslMoveRoundTripProjection( + projection: unknown, + source?: unknown +): unknown { + if (!isRecord(projection)) { + return projection + } + const next: Record = {} + for (const [field, value] of Object.entries(projection)) { + if (field === 'session') { + continue + } + const sourceRows = rowsOf(isRecord(source) ? source[field] : undefined) + const filledIn = FILLED_IN[field] + const bothSides = BOTH_SIDES[field] + next[field] = + field === 'sparsePresetsByRepo' + ? withoutEmptyLists(value) + : bothSides + ? withoutRowKeys(value, bothSides, () => true) + : filledIn && source !== undefined + ? withoutRowKeys( + value, + filledIn, + (id, key) => !Object.hasOwn(sourceRows.get(id) ?? {}, key) + ) + : value + } + const session = isRecord(projection.session) ? projection.session : {} + const nextSession: Record = {} + for (const [field, value] of Object.entries(session)) { + if ( + Object.hasOwn(WSL_MOVE_ROUND_TRIP_KNOWN_GAPS, field) || + field === 'localOnlyScrollbackByTabId' + ) { + continue + } + // An empty record is the same as none. + if (isRecord(value) && Object.keys(value).length === 0) { + continue + } + const defaults = DEFAULTED_SESSION[field] + nextSession[field] = defaults ? withoutDefaultValues(value, defaults) : value + } + next.session = nextSession + return next +} diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-round-trip-stores.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-round-trip-stores.ts new file mode 100644 index 00000000000..98b09e05a0d --- /dev/null +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-round-trip-stores.ts @@ -0,0 +1,26 @@ +/** + * Real profile stores for a desktop and its WSL host side by side in one test: each binds its own + * data file at creation, so the shared test directory only has to point at it while it opens. + */ +import { join } from 'node:path' +import type { PersistedState } from '../../../../shared/persisted-state-types' +import { + createStore, + readPersistedStateJson, + testState, + writeDataFile +} from '../../persistence-test-harness' +import type { Store } from '../../loading-store/store' + +export function openWslMoveRoundTripStore(dir: string, seed?: PersistedState): Store { + testState.dir = dir + if (seed) { + writeDataFile(seed) + } + return createStore() +} + +/** The persisted JSON of the store opened over `dir`, as a reader of its profile gets it. */ +export function readWslMoveRoundTripStoreJson(dir: string): string { + return readPersistedStateJson(join(dir, 'orca-data.json')) +} diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-store-host.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-store-host.ts new file mode 100644 index 00000000000..95c82f1fea5 --- /dev/null +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-store-host.ts @@ -0,0 +1,132 @@ +/** + * A WSL host for the move driver backed by a real orcad profile store, so staging claims, blob + * staging, the commit's blob check and receipts are the production code. Every mutation passes a + * kill point before it lands and after it is durable (a lost reply). A kill stops the whole desktop + * process: from then on every call throws, so no catch handler can tidy up what a crash would leave. + */ +import type { + OrcadMigrationCatalogState, + OrcadMigrationManifest +} from '../../../../shared/orcad-migration/orcad-migration-manifest' +import { ORCAD_MIGRATION_BLOB_KIND_CONTRACTS } from '../../../../shared/orcad-migration/orcad-migration-blob-transfer' +import { + ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, + ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY +} from '../../../../shared/orcad-runtime-capabilities' +import type { Store } from '../../loading-store/store' +import type { WslMoveHost } from '../../moving-wsl-projects/wsl-move-driver-ports' + +export class WslMoveKilled extends Error {} + +export type WslMoveKillSwitch = { + /** The point the next kill fires at; null runs clean. */ + killAt: string | null + /** True from the kill until the test restarts the process. */ + dead: boolean + points: string[] + /** Runs at each point before the kill check: something else happening mid-move. */ + onPoint?: (name: string) => void +} + +export function createWslMoveKillSwitch(): WslMoveKillSwitch { + return { killAt: null, dead: false, points: [] } +} + +export function passWslMoveKillPoint(kill: WslMoveKillSwitch, name: string): void { + if (kill.dead) { + throw new WslMoveKilled(`dead at ${name}`) + } + kill.points.push(name) + kill.onPoint?.(name) + if (kill.killAt === name) { + kill.killAt = null + kill.dead = true + throw new WslMoveKilled(name) + } +} + +/** Every capability a current WSL host advertises for a move. */ +export const WSL_MOVE_STORE_HOST_CAPABILITIES: readonly string[] = [ + ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY, + ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, + ...Object.values(ORCAD_MIGRATION_BLOB_KIND_CONTRACTS).map((contract) => contract.capability) +] + +export type WslMoveStoreHostOptions = { + /** The host's current store; an orcad restart swaps it for one reopened from disk. */ + store: () => Store + kill: WslMoveKillSwitch + /** Points named `stage:before` etc., so two desktops on one host stay apart. */ + prefix?: string + environmentId?: string + name?: string + /** False once the distro is unregistered: every call fails like a vanished host. */ + available?: () => boolean +} + +export function createWslMoveStoreHost(options: WslMoveStoreHostOptions): WslMoveHost { + const { store, kill } = options + const prefix = options.prefix ?? '' + const at = (name: string): void => { + passWslMoveKillPoint(kill, `${prefix}${name}`) + if (options.available && !options.available()) { + throw new Error('wsl_distro_not_registered') + } + } + // Like the RPC layer: a mutation answers only once it is on disk. + const mutate = async (name: string, apply: () => T): Promise => { + at(`${name}:before`) + const result = apply() + await store().flushPendingOrThrowAsync() + at(`${name}:lost-reply`) + return result + } + const read = (manifest: OrcadMigrationManifest): OrcadMigrationCatalogState => { + at('read-state') + return store().getOrcadMigrationCatalogState(manifest) + } + return { + environmentId: options.environmentId ?? 'wsl:Ubuntu', + name: options.name ?? 'Ubuntu', + capabilities: WSL_MOVE_STORE_HOST_CAPABILITIES, + catalog: { + readState: async (manifest) => read(manifest), + stage: (manifest) => mutate('stage', () => store().stageOrcadMigrationCatalog(manifest)), + commit: (manifest) => + mutate('commit', () => store().commitStagedOrcadMigrationCatalog(manifest)), + abort: async (manifest) => ({ + ...(await mutate('abort', () => store().abortStagedOrcadMigrationCatalog(manifest))), + durableAbsent: true + }), + abortByReference: (manifest) => + mutate('abort-by-reference', () => + store().abortStagedOrcadMigrationCatalogByReference({ + migrationId: manifest.migrationId, + manifestSha256: manifest.manifestSha256 + }) + ), + stageChunk: (request) => + mutate('snapshot-chunk', () => store().stageOrcadMigrationSnapshotChunk(request)) + }, + commitWithBlobs: (manifest, expectedBlobs) => + mutate('commit', () => + store().commitStagedOrcadMigrationCatalog(manifest, { expectedBlobs }) + ), + blobs: { + stageChunk: (request) => + mutate(`blob:${request.blob.blobKind}`, () => store().stageOrcadMigrationBlobChunk(request)) + }, + readHolding: async () => { + at('read-holding') + return { + repoIds: store() + .getRepos() + .map((repo) => repo.id), + folderWorkspaceIds: store() + .getFolderWorkspaces() + .map((folder) => folder.id), + highestGeneration: 0 + } + } + } +} diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-mover-folders-profile.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-mover-folders-profile.ts index 1c2728ed9a2..481de8788d9 100644 --- a/src/main/persistence/__fixtures__/wsl-mover/wsl-mover-folders-profile.ts +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-mover-folders-profile.ts @@ -46,7 +46,8 @@ export function foldersAndAutomationsWslMoverProfile(paths: WslMoverPaths): WslM kind: 'workspace-run' as const, projectId: 'project-auto', hostId: 'local' as const, - projectHostSetupId: paths.side === 'source' ? 'setup-local' : 'setup-wsl', + // A repo's own setup is keyed by the repo id on both sides (createSetupFromRepo, the export). + projectHostSetupId: 'repo-auto', repoId: 'repo-auto', path: repoAuto.path } diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-mover-profiles.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-mover-profiles.ts index 4fae57a7dc9..06f95f44b73 100644 --- a/src/main/persistence/__fixtures__/wsl-mover/wsl-mover-profiles.ts +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-mover-profiles.ts @@ -32,7 +32,10 @@ export function wslMoverProfileScope( repoIds: new Set(name === 'mixed-windows-wsl' ? ['repo-a'] : repoIds), folderWorkspaceIds: new Set(name === 'folders-and-automations' ? ['folder-1'] : []), ownerHostId: 'local', - hostAliases: side === 'source' ? ['local'] : ['local', `runtime:wsl:${WSL_MOVER_DISTRO}`] + hostAliases: + side === 'source' + ? ['local'] + : ['local', `runtime:wsl:${WSL_MOVER_DISTRO}`, `wsl:${WSL_MOVER_DISTRO}`] } } diff --git a/src/main/persistence/loading-store/project-collection-operations.ts b/src/main/persistence/loading-store/project-collection-operations.ts index e52a8c8d110..3ed4b2a085d 100644 --- a/src/main/persistence/loading-store/project-collection-operations.ts +++ b/src/main/persistence/loading-store/project-collection-operations.ts @@ -32,6 +32,10 @@ import { removeWorkspaceLineageForFolderParent } from './metadata-lineage-operat import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation' import { dropOrcadTerminalRestoreHintsForWorktrees } from '../migrating-orcad-catalog/orcad-terminal-restore-hint-store' import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { + applyWslMoveRetainedMarks, + type WslMoveRetainedMark +} from '../moving-wsl-projects/wsl-move-retained-marks' type ProjectCollectionOperationsRuntime = Pick< StoreRuntimeState, @@ -168,6 +172,16 @@ export class ProjectCollectionOperations { moveProjectToGroup(target: RepoHostTarget, groupId: string | null, order?: number): Repo | null { return getFolderWorkspaceOperations(this).moveProjectToGroup(target, groupId, order) } + + /** The WSL mover's retention pass only; returns how many rows changed. */ + setWslMoveRetainedMarks(marks: readonly WslMoveRetainedMark[]): number { + const { runtime, scheduling } = this[projectCollectionOperationsContext] + const changed = applyWslMoveRetainedMarks(runtime.state, marks) + if (changed > 0) { + scheduleSave(scheduling) + } + return changed + } } export function getProjectHostOperations( diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index 8adf7bcd900..b8f200e0723 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -14,6 +14,11 @@ import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-s import { withoutRedundantGlobalFields } from '../../../shared/workspace-session-host-field-ownership' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { readTerminalScrollbackSnapshotSync } from '../../terminal-scrollback-snapshots' +import { + hasTerminalScrollbackSnapshotSync, + readTerminalScrollbackSnapshot, + statTerminalScrollbackSnapshotSize +} from '../../terminal-scrollback-snapshot-nonblocking-reads' import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields' import { dropClosedTerminalTabs } from '../closed-terminal-tab-write-fence' import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' @@ -58,7 +63,11 @@ export class SessionHostPartitionOperations { scheduling: WriteSchedulingOperations, bindingRecovery: TerminalBindingRecoveryOperations ) { - this[sessionHostPartitionOperationsContext] = { runtime, scheduling, bindingRecovery } + this[sessionHostPartitionOperationsContext] = { + runtime, + scheduling, + bindingRecovery + } } getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { @@ -112,6 +121,27 @@ export class SessionHostPartitionOperations { ) } + hasTerminalScrollbackSnapshot(ref: string): boolean { + return hasTerminalScrollbackSnapshotSync( + ref, + this[sessionHostPartitionOperationsContext].runtime.terminalScrollbackSnapshotStorage + ) + } + + readTerminalScrollbackSnapshotAsync(ref: string): Promise { + return readTerminalScrollbackSnapshot( + ref, + this[sessionHostPartitionOperationsContext].runtime.terminalScrollbackSnapshotStorage + ) + } + + statTerminalScrollbackSnapshotSize(ref: string): Promise { + return statTerminalScrollbackSnapshotSize( + ref, + this[sessionHostPartitionOperationsContext].runtime.terminalScrollbackSnapshotStorage + ) + } + getWorktreeIdForTab(tabId: string): string | undefined { return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) } diff --git a/src/main/persistence/migrating-orcad-catalog/migration-journal-directory.test.ts b/src/main/persistence/migrating-orcad-catalog/migration-journal-directory.test.ts new file mode 100644 index 00000000000..e1164292baa --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/migration-journal-directory.test.ts @@ -0,0 +1,72 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMigrationJournalDirectory } from './migration-journal-directory' + +type JournalRecord = { migrationId: string } + +const journalOptions = { + directoryName: 'journal', + maxFileBytes: 1024, + parse: (value: unknown): JournalRecord => { + if (typeof value !== 'object' || value === null || !('migrationId' in value)) { + throw new Error('not a record') + } + return { migrationId: String(value.migrationId) } + }, + unreadable: (detail: string) => new Error(detail) +} +const journal = createMigrationJournalDirectory({ + ...journalOptions, + sweepOrphanedTemporaries: true +}) + +// The sweep compares a file's mtime with the clock, so both read this fixed time. +const NOW = Date.UTC(2026, 9, 24, 12) + +let userDataPath: string +let dir: string +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + userDataPath = mkdtempSync(join(tmpdir(), 'migration-journal-')) + dir = join(userDataPath, 'journal') + mkdirSync(dir) +}) +afterEach(() => { + vi.useRealTimers() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +function temporary(pid: number, ageMs: number): string { + const path = join(dir, `move-1.json.${pid}.1792672615854.b6fca333.tmp`) + writeFileSync(path, '{') + const at = (NOW - ageMs) / 1000 + utimesSync(path, at, at) + return path +} + +describe('migration journal directory', () => { + it("removes another process's write that died before its rename", () => { + // A killed journal write left `.json...tmp` behind forever. + const orphan = temporary(process.pid + 1, 120_000) + expect(journal.list(userDataPath)).toEqual([]) + expect(existsSync(orphan)).toBe(false) + }) + + it("keeps this process's own write and one another process may still be finishing", () => { + const own = temporary(process.pid, 120_000) + const recent = temporary(process.pid + 1, 0) + expect(journal.list(userDataPath)).toEqual([]) + expect(existsSync(own)).toBe(true) + expect(existsSync(recent)).toBe(true) + }) + + it('leaves temporaries alone in a journal that does not sweep, as SSH migration journals do', () => { + const orphan = temporary(process.pid + 1, 120_000) + const unswept = createMigrationJournalDirectory(journalOptions) + expect(unswept.list(userDataPath)).toEqual([]) + expect(existsSync(orphan)).toBe(true) + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/migration-journal-directory.ts b/src/main/persistence/migrating-orcad-catalog/migration-journal-directory.ts index 3144fe8efed..9fe407afb51 100644 --- a/src/main/persistence/migrating-orcad-catalog/migration-journal-directory.ts +++ b/src/main/persistence/migrating-orcad-catalog/migration-journal-directory.ts @@ -7,6 +7,10 @@ import { readNodeFileSyncWithinLimit } from '../../../shared/node-bounded-file-r import { syncDirectoryDurablySync } from '../../durable-file-write' const JOURNAL_FILE = /^[A-Za-z0-9_-]{1,128}\.json$/ +// `.json....tmp`: a durable write that died before its rename. +const JOURNAL_TEMPORARY = /^[A-Za-z0-9_-]{1,128}\.json\.(\d+)\.\d+\.[0-9a-f]+\.tmp$/ +// Spares a write another instance may still be finishing. +const TEMPORARY_SWEEP_MIN_AGE_MS = 60_000 export type MigrationJournalDirectory = { directory: (userDataPath: string) => string @@ -23,9 +27,12 @@ export function createMigrationJournalDirectory T unreadable: (detail: string) => Error onChange?: (userDataPath: string) => void + /** Remove other processes' writes that died before their rename (the WSL move journal only). */ + sweepOrphanedTemporaries?: boolean }): MigrationJournalDirectory { // Frequent hidden-row checks would otherwise reparse full manifests. const parsed = new Map() + const swept = new Set() const directory = (userDataPath: string): string => join(userDataPath, options.directoryName) const fileVersion = (path: string): string => { try { @@ -69,6 +76,10 @@ export function createMigrationJournalDirectory= TEMPORARY_SWEEP_MIN_AGE_MS) { + rmSync(path, { force: true }) + } + } catch { + // Gone already, or unreadable; it carries no journal state either way. + } + } +} + /** Oldest-first delta chain; forks or gaps fail closed. */ export function orderMigrationJournalChain< T extends { migrationId: string; supersedesMigrationId?: string } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts index 597d7592c62..c24a233c35e 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts @@ -2,6 +2,7 @@ import { ORCAD_MIGRATION_BLOB_KIND_CONTRACTS } from '../../../shared/orcad-migra import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest' import type { PersistedState } from '../../../shared/persisted-state-types' import { parsePaneKey } from '../../../shared/stable-pane-id' +import { getDaemonHistoryDir } from '../../daemon/daemon-launch-paths' import { getHistoryRoot } from '../../terminal-history-paths' import type { StoreRuntimeState } from '../loading-store/store-runtime-state' import { @@ -135,10 +136,12 @@ function installCommitted( root: runtime.orcadMigrationBlobRoot, terminalRestoreHints: runtime.orcadTerminalRestoreHints, shellHistoryRoot: getHistoryRoot(), + terminalHistoryRoot: getDaemonHistoryDir(), paneShellPath: () => runtime.paneShellPath(runtime.state.settings?.terminalDefaultShell), worktreeHasLiveShells: options.worktreeHasLiveShells, deferredShellHistoryWorktreeIds: runtime.deferredShellHistoryWorktreeIds, paneWorktreeId: (paneKey) => findPaneWorktreeId(runtime.state, paneKey), + workspacePath: (worktreeId) => findWorkspacePath(runtime.state, worktreeId), now: clock(options) }) } @@ -147,6 +150,16 @@ function clock(options: ClockOptions): number { return (options.now ?? (() => new Date()))().getTime() } +// A git worktree id carries its path; a folder workspace's is on its record. +function findWorkspacePath(state: PersistedState, worktreeId: string): string | null { + const separator = worktreeId.indexOf('::') + if (separator > 0) { + return worktreeId.slice(separator + 2) || null + } + return state.folderWorkspaces.find((folder) => folder.id === worktreeId)?.folderPath ?? null +} + +// The commit merged the moved session in with its worktree ids unchanged. function findPaneWorktreeId(state: PersistedState, paneKey: string): string | null { const tabId = parsePaneKey(paneKey)?.tabId if (!tabId) { diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts index 95d20486005..d8a082a0698 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts @@ -1,4 +1,5 @@ // Validate blob contents before commit; install only advertised kinds after durable commit. +import { servesWslMovedPaneHistory } from '../../orcad/orcad-migration-wsl-destination' import { readFileSync, rmSync } from 'node:fs' import { dirname } from 'node:path' import type { OrcadMigrationBlobKind } from '../../../shared/orcad-migration/orcad-migration-blob-transfer' @@ -21,6 +22,10 @@ import { assertOrcadMigrationShellHistoryValid, installOrcadMigrationShellHistory } from './orcad-shell-history-installer' +import { + assertOrcadMigrationTerminalHistoryValid, + installOrcadMigrationTerminalHistory +} from './orcad-terminal-history-installer' import type { OrcadTerminalRestoreHintPlacement, OrcadTerminalRestoreHintStore @@ -43,6 +48,9 @@ const BLOB_CONTENT_CHECKS: Partial { assertOrcadMigrationShellHistoryValid(key, path, manifest.payload) + }, + 'terminal-history': ({ key, path, manifest }) => { + assertOrcadMigrationTerminalHistoryValid(key, path, manifest) } } @@ -71,11 +79,16 @@ export type OrcadMigrationBlobInstall = { terminalRestoreHints: OrcadTerminalRestoreHintStore /** This host's getHistoryRoot(). */ shellHistoryRoot: string + /** This host's `getDaemonHistoryDir()`, where its terminals' checkpoints live. */ + terminalHistoryRoot: string + /** The shell binary a new pane on this host would actually run. */ paneShellPath: () => string /** Defer without runtime evidence: shells surviving a restart could overwrite installed history. */ worktreeHasLiveShells?: (worktreeId: string) => boolean deferredShellHistoryWorktreeIds: Set paneWorktreeId: (paneKey: string) => string | null + /** The workspace's folder on this host, or null when it is not here. */ + workspacePath: (worktreeId: string) => string | null now: number } @@ -83,7 +96,11 @@ export type OrcadMigrationBlobInstall = { export function installOrcadMigrationReceivedBlobs(install: OrcadMigrationBlobInstall): void { let firstError: unknown = null // Keep failures independent so other blob kinds can still install. - for (const installKind of [installTerminalRestoreHints, installShellHistory]) { + for (const installKind of [ + installTerminalRestoreHints, + installShellHistory, + installTerminalHistory + ]) { try { installKind(install) } catch (error) { @@ -95,6 +112,21 @@ export function installOrcadMigrationReceivedBlobs(install: OrcadMigrationBlobIn } } +function installTerminalHistory(install: OrcadMigrationBlobInstall): void { + // Only a WSL host advertises terminal history, so no other destination should hold any. + if (!servesWslMovedPaneHistory()) { + return + } + installOrcadMigrationTerminalHistory({ + migration: install.migration, + root: install.root, + historyRoot: install.terminalHistoryRoot, + paneWorktreeId: install.paneWorktreeId, + workspacePath: install.workspacePath, + now: install.now + }) +} + function installShellHistory(install: OrcadMigrationBlobInstall): void { if (!install.worktreeHasLiveShells) { return diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts index b77fd83eb7f..4c87a19cc8e 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts @@ -49,7 +49,8 @@ const receivedDirectoryPath: ReceivedBlobPathResolver = (root, manifest, descrip const RECEIVED_BLOB_DESTINATIONS: Record = { 'terminal-restore-hints': receivedDirectoryPath, 'shell-history': receivedDirectoryPath, - 'agent-session-records': receivedDirectoryPath + 'agent-session-records': receivedDirectoryPath, + 'terminal-history': receivedDirectoryPath } export function getOrcadMigrationReceivedBlobPath( 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 fb73df7d12e..f8219351d06 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 @@ -12,7 +12,8 @@ import type { import type { PersistedState } from '../../../shared/persisted-state-types' import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' import { - createOrcadMigrationSourceScope, + createOrcadMigrationExportScope, + orcadMigrationScopeOwnsWholeHost, orcadMigrationOwnerMatchesScope, orcadMigrationOwnsRepoId, type OrcadMigrationSourceScope, @@ -25,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[] /** Automations held back only by a run still in flight; they move once it finishes. */ runningAutomationNames: string[] } @@ -34,7 +37,7 @@ export function collectOrcadMigrationSourceAutomationState( source: OrcadMigrationManifestSource, catalog: OrcadMigrationCatalogPayload ): OrcadMigrationSourceAutomationInspection { - const scope = createOrcadMigrationSourceScope({ source, catalog, repos: state.repos }) + const scope = createOrcadMigrationExportScope({ source, catalog, repos: state.repos }) const touchedAutomations = state.automations.filter((entry) => automationTouchesScope(entry, scope) ) @@ -48,6 +51,7 @@ export function collectOrcadMigrationSourceAutomationState( const automationRuns: AutomationRun[] = [] let blockedAutomationCount = 0 let blockedRunCount = 0 + const enabledAtSourceIds: string[] = [] const runningAutomationNames: string[] = [] for (const [automationId, matching] of automationsById) { @@ -64,7 +68,12 @@ export function collectOrcadMigrationSourceAutomationState( blockedRunCount += runs.length continue } - automations.push(projectAutomationToDestination(matching[0])) + const projected = projectAutomationToDestination(matching[0]) + // The projection always sends it disabled; a WSL move re-enables what ran here. + if (scope.kind === 'local-wsl' && matching[0].enabled) { + enabledAtSourceIds.push(projected.id) + } + automations.push(projected) automationRuns.push(...runs.map(projectAutomationRunToDestination)) } @@ -75,11 +84,13 @@ export function collectOrcadMigrationSourceAutomationState( } automations.sort((left, right) => compareKeys(left.id, right.id)) automationRuns.sort((left, right) => compareKeys(left.id, right.id)) + enabledAtSourceIds.sort(compareKeys) return { automations, automationRuns, blockedAutomationCount, blockedRunCount, + enabledAtSourceIds, runningAutomationNames: runningAutomationNames.sort(compareKeys) } } @@ -110,6 +121,17 @@ function runTouchesScope(run: AutomationRun, scope: OrcadMigrationSourceScope): } function automationCanTransfer(automation: Automation, scope: OrcadMigrationSourceScope): boolean { + if (scope.kind === 'local-wsl') { + // This desktop schedules it today; the host's scheduler takes over, re-enabled after commit. + return ( + automation.executionTargetType === 'local' && + automation.schedulerOwner === 'local_host_service' && + scope.repoIds.has(getAutomationRunRepoId(automation)) && + ownerCanTransfer(automation.workspaceId, scope) && + contextCanTransfer(automation.runContext, scope) && + contextCanTransfer(automation.sourceContext, scope) + ) + } return ( automation.executionTargetType === 'ssh' && automation.executionTargetId === scope.targetId && @@ -139,6 +161,12 @@ function contextTouchesScope( context: WorkspaceRunContext | TaskSourceContext | null | undefined, scope: OrcadMigrationSourceScope ): boolean { + if (!orcadMigrationScopeOwnsWholeHost(scope)) { + return ( + (!context?.hostId || context.hostId === scope.hostId) && + orcadMigrationOwnsRepoId(scope, context?.repoId) + ) + } return ( context?.hostId === scope.hostId || (!context?.hostId && orcadMigrationOwnsRepoId(scope, context?.repoId)) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts index 7619181cfb5..e9643ff9a6b 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts @@ -1,4 +1,5 @@ import type { OrcadMigrationCatalogPayload } from '../../../shared/orcad-migration/orcad-migration-manifest' +import { withoutOrcadRetainedMarker } from '../../../shared/orcad-retained-marker' import type { ProjectGroup } from '../../../shared/project-group-types' import type { SshTarget } from '../../../shared/ssh-types' import type { Store } from '../../persistence' @@ -15,14 +16,14 @@ export function collectOrcadMigrationSourceCatalog( const allFolders = store.getFolderWorkspaces() const repositories = allRepos .filter((repo) => repoBelongsToOrcadSource(repo, target.id)) - .map((repo) => structuredClone(repo)) + .map(withoutOrcadRetainedMarker) const ownedFolderIds = orcadSourceFolderWorkspaceIds( { repos: allRepos, projectGroups: allGroups, folderWorkspaces: allFolders }, target.id ) const folderWorkspaces = allFolders .filter((workspace) => ownedFolderIds.has(workspace.id)) - .map((workspace) => structuredClone(workspace)) + .map(withoutOrcadRetainedMarker) const projectGroups = collectProjectGroups( allGroups, new Set([ diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts index b4518bac5b0..851b9eb9ee1 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts @@ -14,7 +14,8 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st import { sessionPartitions } from './orcad-source-workspace-session-fragments' import { collectCloseIntents } from './orcad-source-client-browser-intents' import { - createOrcadMigrationSourceScope, + createOrcadMigrationExportScope, + orcadMigrationScopeOwnsWholeHost, orcadMigrationOwnerMatchesScope, orcadMigrationOwnsRepoId, unqualifyOrcadMigrationOwnerKey @@ -36,7 +37,7 @@ export function collectOrcadMigrationSourceClientState( destinationEnvironmentId: string | undefined, eligibleSession: WorkspaceSessionState | undefined ): OrcadMigrationSourceClientStateInspection { - const scope = createOrcadMigrationSourceScope({ source, catalog, repos: state.repos }) + const scope = createOrcadMigrationExportScope({ source, catalog, repos: state.repos }) const blockedCounts = { 'saved-port-forward': 0 } let blockedCount = 0 const mobile = collectMobileSelections(state, scope, eligibleSession) @@ -80,7 +81,7 @@ export function collectOrcadMigrationSourceClientState( function collectMobileSelections( state: PersistedState, - scope: ReturnType, + scope: ReturnType, session: WorkspaceSessionState | undefined ): | NonNullable @@ -115,7 +116,7 @@ function collectMobileSelections( function collectUiRouting( state: PersistedState, - scope: ReturnType, + scope: ReturnType, session: WorkspaceSessionState | undefined ): OrcadMigrationUiRoutingState | undefined { const ui = state.ui @@ -165,12 +166,15 @@ function collectUiRouting( /** Include source-owned tabs from every partition. */ function collectSourceOwnedTabIds( state: PersistedState, - scope: ReturnType + scope: ReturnType ): Set { const tabIds = new Set() for (const [hostId, session] of sessionPartitions(state, 'local')) { for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree ?? {})) { - if (hostId === scope.hostId || orcadMigrationOwnerMatchesScope(ownerKey, scope)) { + if ( + (orcadMigrationScopeOwnsWholeHost(scope) && hostId === scope.hostId) || + orcadMigrationOwnerMatchesScope(ownerKey, scope) + ) { tabs.forEach((tab) => tabIds.add(tab.id)) } } @@ -182,7 +186,7 @@ type EligibleSessionIdentity = { tabIds: ReadonlySet; groupIds: Readonly function collectEligibleSessionIdentity( session: WorkspaceSessionState, - scope: ReturnType + scope: ReturnType ): EligibleSessionIdentity { const tabIds = new Set() const groupIds = new Set() 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 91fd0dae9a1..1a87aef6e2a 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 @@ -14,7 +14,7 @@ import { collectOrcadMigrationRetiredWorktreeNamespaces } from './orcad-source-r import { collectOrcadMigrationSourceAutomationState } from './orcad-source-automation-state' import { collectOrcadMigrationSourceWorkspaceSession } from './orcad-source-workspace-session' import { - createOrcadMigrationSourceScope, + createOrcadMigrationExportScope, orcadMigrationOwnerMatchesScope, unqualifyOrcadMigrationOwnerKey } from './orcad-source-scope' @@ -47,12 +47,21 @@ export type OrcadMigrationSourceDormantInspection = { runningAutomationItems: string[] /** Retained on this desktop after confirmed "Move anyway". */ leftBehindItems: string[] + /** Automations a local WSL move carries disabled, to re-enable on the host once it commits. */ + automationsEnabledAtSource: string[] } /** Confirmed "Move anyway" permits retaining unsupported state; unsaved-draft blockers still apply. */ -export function orcadMigrationLeavesBehind(state: PersistedState, targetId: string): boolean { - return state.sshTargets.some( - (target) => target.id === targetId && target.managedServerLeaveBehind !== undefined +export function orcadMigrationLeavesBehind( + state: PersistedState, + 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 + ) ) } @@ -61,9 +70,11 @@ export function collectOrcadMigrationSourceDormantState( source: OrcadMigrationManifestSource, catalog: OrcadMigrationCatalogPayload, storage?: TerminalScrollbackSnapshotStorage, - destinationEnvironmentId?: string + destinationEnvironmentId?: string, + /** A WSL move's slept panes; see `projectOrcadMigrationSessionScrollback`. */ + daemonHistoryLeaf?: (tabId: string, leafId: string) => boolean ): OrcadMigrationSourceDormantInspection { - const scope = createOrcadMigrationSourceScope({ source, catalog, repos: state.repos }) + const scope = createOrcadMigrationExportScope({ source, catalog, repos: state.repos }) const blockedCounts = emptyBlockedCounts() const destinationRepos = catalog.repositories.map(toOrcadDestinationRepository) const setupByRepoId = new Map( @@ -173,7 +184,8 @@ export function collectOrcadMigrationSourceDormantState( source, catalog, storage, - leaveBehind + leaveBehind, + daemonHistoryLeaf ) const blockedSessionItems = [...workspaceSession.blockedItems] const automationState = collectOrcadMigrationSourceAutomationState(state, source, catalog) @@ -242,7 +254,8 @@ export function collectOrcadMigrationSourceDormantState( : automationState.runningAutomationNames.map( (name) => `${name} is running; wait for it to finish, then connect again` ), - 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 156e2bdc38f..95c728fedb5 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts @@ -15,7 +15,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, @@ -57,6 +60,24 @@ 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, + daemonHistoryLeaf?: (tabId: string, leafId: string) => boolean + ): OrcadMigrationSourceDormantInspection { + const runtime = this[orcadSourceExportContext] + return collectOrcadMigrationSourceDormantState( + runtime.state, + source, + catalog, + runtime.terminalScrollbackSnapshotStorage, + destinationEnvironmentId, + daemonHistoryLeaf + ) + } + collectOrcadMigrationSourceDrafts(manifest: OrcadMigrationManifest): OrcadMigrationSourceDrafts { return collectOrcadMigrationSourceDrafts(this[orcadSourceExportContext].state, manifest) } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-left-behind-session-rows.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-left-behind-session-rows.test.ts index 01f5f491a19..055d1b0f60e 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-left-behind-session-rows.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-left-behind-session-rows.test.ts @@ -7,6 +7,7 @@ import type { OrcadMigrationSourceScope } from './orcad-source-scope' const WORKTREE = 'repo-0::/home/dev/src/project-0' const SCOPE: OrcadMigrationSourceScope = { + kind: 'ssh-relay', targetId: 'ssh-box', targetGeneration: 1, hostId: toSshExecutionHostId('ssh-box'), diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts index 1a66336937c..ca5ad037ccf 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts @@ -1,5 +1,12 @@ -import { requireOrcadMigrationSshSource } from '../../../shared/orcad-migration/orcad-migration-source-kind' -import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../shared/execution-host' +import { + isOrcadMigrationLocalWslSource, + requireOrcadMigrationSshSource +} from '../../../shared/orcad-migration/orcad-migration-source-kind' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + toSshExecutionHostId +} from '../../../shared/execution-host' import type { OrcadMigrationCatalogPayload, OrcadMigrationManifestSource @@ -14,10 +21,7 @@ import type { Repo } from '../../../shared/repo-types' import { ownerKeyBelongsToRepo } from '../../orca-profiles/profile-project-worktree-identity' import { repoBelongsToOrcadSource } from './orcad-source-ownership' -export type OrcadMigrationSourceScope = { - targetId: string - targetGeneration: number | null - hostId: ReturnType +type OrcadMigrationScopeRows = { repoIds: ReadonlySet folderWorkspaceKeys: ReadonlySet /** Shared bare repo IDs cannot prove host ownership, so never move or subtract them. */ @@ -26,22 +30,57 @@ export type OrcadMigrationSourceScope = { partitionHostId?: string } -export function orcadMigrationPartitionScope( - scope: OrcadMigrationSourceScope, +/** A relay-era SSH target: its rows name the target, and live on the target's host id. */ +export type OrcadMigrationSshSourceScope = OrcadMigrationScopeRows & { + kind: 'ssh-relay' + targetId: string + targetGeneration: number | null + hostId: ReturnType +} + +/** Local projects inside one WSL distro: rows live on host `local`, and no SSH target is involved. */ +export type OrcadMigrationLocalWslSourceScope = OrcadMigrationScopeRows & { + kind: 'local-wsl' + targetId: null + targetGeneration: null + hostId: typeof LOCAL_EXECUTION_HOST_ID +} + +export type OrcadMigrationSourceScope = + | OrcadMigrationSshSourceScope + | OrcadMigrationLocalWslSourceScope + +/** + * An SSH conversion moves every row its host holds, so an unmatched host row is an error. A WSL move + * takes some of host `local`'s projects; the rest are another project's, never this move's. + */ +export function orcadMigrationScopeOwnsWholeHost(scope: OrcadMigrationSourceScope): boolean { + return scope.kind === 'ssh-relay' +} + +/** The scope as seen from one session partition. */ +export function orcadMigrationPartitionScope( + scope: T, partitionHostId: string -): OrcadMigrationSourceScope { +): T { return { ...scope, partitionHostId } } -export function createOrcadMigrationSourceScope(args: { +type OrcadMigrationScopeArgs = { source: OrcadMigrationManifestSource catalog: OrcadMigrationCatalogPayload /** Detect repo IDs shared by other hosts. */ repos: readonly Repo[] -}): OrcadMigrationSourceScope { +} + +/** For the SSH conversion's own steps (census, subtraction): a WSL manifest reaching them throws. */ +export function createOrcadMigrationSourceScope( + args: OrcadMigrationScopeArgs +): OrcadMigrationSshSourceScope { const repoIds = new Set(args.catalog.repositories.map((repo) => repo.id)) const source = requireOrcadMigrationSshSource(args.source) return { + kind: 'ssh-relay', targetId: source.sshTargetId, targetGeneration: source.sshTargetGeneration, hostId: toSshExecutionHostId(source.sshTargetId), @@ -59,6 +98,34 @@ export function createOrcadMigrationSourceScope(args: { } } +/** The scope the dormant-state export reads, for either source kind. */ +export function createOrcadMigrationExportScope( + args: OrcadMigrationScopeArgs +): OrcadMigrationSourceScope { + if (!isOrcadMigrationLocalWslSource(args.source)) { + return createOrcadMigrationSourceScope(args) + } + const repoIds = new Set(args.catalog.repositories.map((repo) => repo.id)) + return { + kind: 'local-wsl', + targetId: null, + targetGeneration: null, + hostId: LOCAL_EXECUTION_HOST_ID, + repoIds, + folderWorkspaceKeys: new Set( + args.catalog.folderWorkspaces.map((workspace) => `folder:${workspace.id}`) + ), + // Another host registering the same repo id makes its unqualified keys ambiguous. + sharedRepoIds: new Set( + args.repos + .filter( + (repo) => repoIds.has(repo.id) && getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID + ) + .map((repo) => repo.id) + ) + } +} + export function orcadMigrationOwnsRepoId( scope: OrcadMigrationSourceScope, repoId: string | null | undefined @@ -100,6 +167,22 @@ export function orcadMigrationOwnerMatchesScope( ) } +/** + * An unqualified key naming a moved repo that another host also registers: it may be this move's + * row or the other host's, so a WSL move can neither carry nor ignore what it owns. + */ +export function orcadMigrationOwnerKeyIsAmbiguous( + value: string | null | undefined, + scope: OrcadMigrationSourceScope +): boolean { + if (!value || isWorktreeHostIdentity(value)) { + return false + } + return [...scope.sharedRepoIds].some( + (repoId) => scope.repoIds.has(repoId) && ownerKeyBelongsToRepo(value, repoId) + ) +} + export function unqualifyOrcadMigrationOwnerKey(value: string): string { return isWorktreeHostIdentity(value) ? getWorktreeIdFromHostIdentity(value) : value } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-cleanup.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-cleanup.ts index 6630ea7c2be..daeddae7c97 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-cleanup.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-cleanup.ts @@ -14,6 +14,7 @@ export function deleteUnreferencedOrcadMigrationScrollback( >, refs: Iterable ): void { + // Host-run panes whose local copy this deletes repaint from the host (session.readTerminalScrollback). const live = new Set([ ...sessionPartitions(runtime.state, LOCAL_EXECUTION_HOST_ID).flatMap(([, session]) => [ ...collectTerminalScrollbackSnapshotRefs(session) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts index 88ec5f6b08e..a8440c36e1d 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts @@ -42,7 +42,12 @@ export function projectOrcadMigrationSessionScrollback( session: WorkspaceSessionState, storage?: TerminalScrollbackSnapshotStorage, /** "Move anyway" can retain untransferable history while moving its pane. */ - leaveBehind = false + leaveBehind = false, + /** + * A WSL move's panes whose daemon checkpoint travels as a `terminal-history` blob: the host's + * daemon restores it, so no renderer snapshot or ref may also paint it. + */ + daemonHistoryLeaf?: (tabId: string, leafId: string) => boolean ): ProjectedOrcadMigrationScrollback { const projected = structuredClone(session) const snapshots: OrcadMigrationTerminalScrollbackSnapshot[] = [] @@ -59,6 +64,10 @@ export function projectOrcadMigrationSessionScrollback( ...Object.keys(sourceLayout.scrollbackRefsByLeafId ?? {}) ]) for (const leafId of [...leafIds].sort(compareKeys)) { + // Its checkpoint is newer than any snapshot saved before, and the host restores it itself. + if (daemonHistoryLeaf?.(tabId, leafId)) { + continue + } const buffer = inlineLeafBuffer(localOnly, sourceLayout.buffersByLeafId, leafId) const sourceRef = sourceLayout.scrollbackRefsByLeafId?.[leafId] const ref = buffer ? makeTerminalScrollbackSnapshotRef(tabId, leafId) : sourceRef diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-session-copy-resolution.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-session-copy-resolution.ts index a525eb7447a..e5af443086f 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-session-copy-resolution.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-session-copy-resolution.ts @@ -12,7 +12,7 @@ import { } from '../../../shared/workspace-session-stranded-partition-adoption' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { - createOrcadMigrationSourceScope, + createOrcadMigrationExportScope, orcadMigrationOwnerMatchesScope, orcadMigrationPartitionScope } from './orcad-source-scope' @@ -31,7 +31,7 @@ export function desktopDrafts( catalog: OrcadMigrationCatalogPayload ): string[] { const scope = orcadMigrationPartitionScope( - createOrcadMigrationSourceScope({ source, catalog, repos: state.repos }), + createOrcadMigrationExportScope({ source, catalog, repos: state.repos }), LOCAL_EXECUTION_HOST_ID ) return Object.entries(state.workspaceSession.openFilesByWorktree ?? {}).flatMap( diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-session-partitions.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-session-partitions.ts index 5a678805c73..77b3f93b2e5 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-session-partitions.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-session-partitions.ts @@ -13,6 +13,7 @@ import { sessionPartitions } from './orcad-source-workspace-session-fragments' import { orcadMigrationOwnerMatchesScope, orcadMigrationPartitionScope, + orcadMigrationScopeOwnsWholeHost, type OrcadMigrationSourceScope } from './orcad-source-scope' @@ -22,6 +23,10 @@ export function orcadMigrationSessionPartitions( scope: OrcadMigrationSourceScope ): [string, WorkspaceSessionState][] { const partitions = sessionPartitions(state, LOCAL_EXECUTION_HOST_ID) + // A WSL move's host is `local` itself: no second copy to reconcile with. + if (!orcadMigrationScopeOwnsWholeHost(scope)) { + return partitions + } const host = partitions.find(([hostId]) => hostId === scope.hostId)?.[1] if (!host) { return partitions diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-ambiguity.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-ambiguity.test.ts new file mode 100644 index 00000000000..5f40c2c95be --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-ambiguity.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../shared/execution-host' +import { collectUnsupportedSessionItems } from './orcad-source-workspace-session-eligibility' +import type { OrcadMigrationSourceScope } from './orcad-source-scope' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +// `repo-1` is selected but another host registers it too, so its unqualified keys are ambiguous. +const ROWS = { + repoIds: new Set(['repo-1']), + folderWorkspaceKeys: new Set(), + sharedRepoIds: new Set(['repo-1']) +} +const SSH_SCOPE: OrcadMigrationSourceScope = { + ...ROWS, + kind: 'ssh-relay', + targetId: 'target-1', + targetGeneration: 1, + hostId: toSshExecutionHostId('target-1') +} +const WSL_SCOPE: OrcadMigrationSourceScope = { + ...ROWS, + kind: 'local-wsl', + targetId: null, + targetGeneration: null, + hostId: LOCAL_EXECUTION_HOST_ID +} + +function otherPartitionWithAmbiguousRecord(): WorkspaceSessionState { + const paneKey = 'tab-elsewhere:leaf-1' + const record: SleepingAgentSessionRecord = { + paneKey, + tabId: 'tab-elsewhere', + worktreeId: 'repo-1::/w', + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1 + } + return { ...getDefaultWorkspaceSession(), sleepingAgentSessionsByPaneKey: { [paneKey]: record } } +} + +describe("a session row whose owner key can't be attributed, in another host's partition", () => { + it('leaves an SSH conversion as it was: the row is not the conversion’s', () => { + expect( + collectUnsupportedSessionItems( + otherPartitionWithAmbiguousRecord(), + SSH_SCOPE, + false, + new Set(), + new Set() + ) + ).toEqual([]) + }) + + it('refuses a WSL move, since the row may be the selected project’s', () => { + expect( + collectUnsupportedSessionItems( + otherPartitionWithAmbiguousRecord(), + WSL_SCOPE, + false, + new Set(), + new Set() + ) + ).toHaveLength(1) + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts index 042c82fde0d..cde20bbb7cb 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts @@ -6,8 +6,10 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { + orcadMigrationOwnerKeyIsAmbiguous, orcadMigrationOwnerMatchesScope, orcadMigrationOwnsRepoId, + orcadMigrationScopeOwnsWholeHost, unqualifyOrcadMigrationOwnerKey, type OrcadMigrationSourceScope } from './orcad-source-scope' @@ -26,11 +28,19 @@ export function collectUnsupportedSessionItems( sourceTerminalTabIds: ReadonlySet ): string[] { const owns = (ownerKey: string): boolean => orcadMigrationOwnerMatchesScope(ownerKey, scope) + // A WSL move takes some of host `local`'s projects; the others, and rows a past move retained, + // stay on this desktop untouched, so only the selected projects' rows can refuse it. + const wholeHost = sourceHostPartition && orcadMigrationScopeOwnsWholeHost(scope) + // In a WSL move, an unattributable key that may be the selected project's refuses rather than + // staying behind; an SSH conversion keeps its own matching. const matches = (ownerKey: string): boolean => - Boolean(ownerKey) && (sourceHostPartition || owns(ownerKey)) + Boolean(ownerKey) && + (wholeHost || + owns(ownerKey) || + (scope.kind === 'local-wsl' && orcadMigrationOwnerKeyIsAmbiguous(ownerKey, scope))) const items: string[] = [] // Foreign projects stay behind; unsaved drafts still require preservation. - if (sourceHostPartition) { + if (wholeHost) { for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { if (!owns(ownerKey)) { items.push(...unsavedDraftItems(files, ownerKey)) @@ -39,7 +49,9 @@ export function collectUnsupportedSessionItems( } // The terminal gate proves exit before moves, so PTY bindings and shutdown markers can be dropped. for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { - const touchesSource = matches(record.worktreeId) || record.connectionId === scope.targetId + const touchesSource = + matches(record.worktreeId) || + (scope.targetId !== null && record.connectionId === scope.targetId) if ( touchesSource && !transferableSleepingAgentSession(record, session, scope, terminalTabIds) && diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts index c8afd67c2fd..e147fc4f719 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts @@ -11,7 +11,7 @@ import { } from '../../orca-profiles/profile-session-owner-transfer' import type { TerminalScrollbackSnapshotStorage } from '../../terminal-scrollback-snapshots' import { - createOrcadMigrationSourceScope, + createOrcadMigrationExportScope, orcadMigrationOwnerMatchesScope, orcadMigrationPartitionScope, unqualifyOrcadMigrationOwnerKey @@ -63,11 +63,19 @@ export function collectOrcadMigrationSourceWorkspaceSession( source: OrcadMigrationManifestSource, catalog: OrcadMigrationCatalogPayload, storage?: TerminalScrollbackSnapshotStorage, - leaveBehind = false + leaveBehind = false, + daemonHistoryLeaf?: (tabId: string, leafId: string) => boolean ): OrcadMigrationSourceWorkspaceSessionInspection { let collected: OrcadMigrationSourceWorkspaceSessionInspection try { - collected = collectWorkspaceSession(state, source, catalog, storage, leaveBehind) + collected = collectWorkspaceSession( + state, + source, + catalog, + storage, + leaveBehind, + daemonHistoryLeaf + ) } catch (error) { console.warn('[migration] Unreadable workspace session blocks the move:', error) collected = { @@ -97,9 +105,10 @@ function collectWorkspaceSession( source: OrcadMigrationManifestSource, catalog: OrcadMigrationCatalogPayload, storage: TerminalScrollbackSnapshotStorage | undefined, - leaveBehind: boolean + leaveBehind: boolean, + daemonHistoryLeaf: ((tabId: string, leafId: string) => boolean) | undefined ): OrcadMigrationSourceWorkspaceSessionInspection { - const sourceScope = createOrcadMigrationSourceScope({ source, catalog, repos: state.repos }) + const sourceScope = createOrcadMigrationExportScope({ source, catalog, repos: state.repos }) const fragments: SessionFragment[] = [] const blockedItems: string[] = [] const leftBehindItems: string[] = [] @@ -175,7 +184,8 @@ function collectWorkspaceSession( const projected = projectOrcadMigrationSessionScrollback( projectSessionToDestination(fragment, scope), storage, - leaveBehind + leaveBehind, + daemonHistoryLeaf ) blockedItems.push(...projected.blockedItems) leftBehindItems.push(...projected.leftBehindItems) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts index 0d4e405ee5f..3cb0260664a 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts @@ -11,6 +11,7 @@ import { pruneUnreferencedWorktreeIdentityMeta } from '../loading-store/worktree import { createOrcadMigrationSourceScope, orcadMigrationOwnerMatchesScope, + orcadMigrationScopeOwnsWholeHost, unqualifyOrcadMigrationOwnerKey, type OrcadMigrationSourceScope } from './orcad-source-scope' @@ -21,23 +22,28 @@ export function inspectOrcadSourceWorktreeMetadata( ) { const rows: { sourceKey: string; meta: WorktreeMeta }[] = [] let blockedCount = 0 + const wholeHost = orcadMigrationScopeOwnsWholeHost(scope) for (const [sourceKey, meta] of Object.entries(state.worktreeMeta)) { const host = getExecutionHostIdFromWorktreeHostIdentity(sourceKey) if ((host && host !== scope.hostId) || (meta.hostId && meta.hostId !== scope.hostId)) { - if (host === scope.hostId || meta.hostId === scope.hostId) { + if ( + (host === scope.hostId || meta.hostId === scope.hostId) && + (wholeHost || orcadMigrationOwnerMatchesScope(sourceKey, scope)) + ) { blockedCount++ } continue } if (orcadMigrationOwnerMatchesScope(sourceKey, scope, meta.hostId)) { rows.push({ sourceKey, meta }) - } else if (meta.hostId === scope.hostId || host === scope.hostId) { + } else if (wholeHost && (meta.hostId === scope.hostId || host === scope.hostId)) { blockedCount++ } } const referenced = new Set(Object.values(state.worktreeIdentityAliases ?? {}).flat()) for (const [identity, meta] of Object.entries(state.worktreeMetaByIdentity ?? {})) { - if (meta.hostId === scope.hostId && !referenced.has(identity)) { + // An unreferenced identity names no worktree, so only a whole-host move can claim it. + if (wholeHost && meta.hostId === scope.hostId && !referenced.has(identity)) { blockedCount++ } } @@ -45,6 +51,9 @@ export function inspectOrcadSourceWorktreeMetadata( if (getExecutionHostIdFromWorktreeHostIdentity(alias) !== scope.hostId) { continue } + if (!wholeHost && !orcadMigrationOwnerMatchesScope(alias, scope)) { + continue + } if (isDanglingAlias(state, identities) && orcadMigrationOwnerMatchesScope(alias, scope)) { continue // Dangling aliases carry no metadata; any legacy row migrates separately. } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-terminal-history-installer.ts b/src/main/persistence/migrating-orcad-catalog/orcad-terminal-history-installer.ts new file mode 100644 index 00000000000..f82a95b76d4 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-terminal-history-installer.ts @@ -0,0 +1,168 @@ +/** + * Installs a moved pane's `terminal-history` blob as an interrupted session in this host's daemon + * history folder, under an id derived from the pane. The pane's first start claims that session and + * cold-restores the checkpoint once (history, screen and cwd); the daemon's own checkpoints take over. + */ +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest' +import { + MOVED_PANE_HISTORY_SESSION_TAG, + parseOrcadMigrationTerminalHistoryText, + type OrcadMigrationTerminalHistoryDocument +} from '../../../shared/orcad-migration/orcad-migration-terminal-history' +import { PTY_SESSION_ID_SEPARATOR } from '../../../shared/pty-session-id-format' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { getHistorySessionDirName } from '../../daemon/history-paths' +import { isTerminalCheckpointFile } from '../../daemon/terminal-history-checkpoint-reader' +import type { SessionMeta } from '../../daemon/terminal-history-metadata' +import { syncDirectoryDurablySync } from '../../durable-file-write' +import { + getOrcadMigrationReceivedBlobPath, + listOrcadMigrationReceivedBlobNames +} from './orcad-migration-blob-staging' +import type { OrcadMigrationRequestIdentity } from './orcad-migration-staged-file' + +const UTF8 = new TextDecoder('utf-8', { fatal: true }) + +export function orcadMigrationTerminalHistoryKey(paneKey: string): string { + return createHash('sha256').update(paneKey).digest('hex').slice(0, 16) +} + +/** The session a moved pane restores as on this host; minted for its worktree like any other. */ +export function movedPaneHistorySessionId(worktreeId: string, paneKey: string): string { + return `${worktreeId}${PTY_SESSION_ID_SEPARATOR}${MOVED_PANE_HISTORY_SESSION_TAG}${orcadMigrationTerminalHistoryKey(paneKey)}` +} + +/** Before the commit: the bytes must be a checkpoint for a terminal pane this manifest moves. */ +export function assertOrcadMigrationTerminalHistoryValid( + key: string, + path: string, + manifest: OrcadMigrationManifest +): void { + const document = readDocument(path) + if (orcadMigrationTerminalHistoryKey(document.paneKey) !== key) { + throw new Error('orcad_migration_terminal_history_key_mismatch') + } + if (!isTerminalCheckpointFile(document.checkpoint)) { + throw new Error('orcad_migration_terminal_history_checkpoint_invalid') + } + if (findPaneWorktreeId(manifest, document.paneKey) === null) { + throw new Error('orcad_migration_terminal_history_pane_not_moved') + } +} + +export type OrcadMigrationTerminalHistoryInstall = { + migration: OrcadMigrationRequestIdentity + root: string + /** This host's daemon history folder (`getDaemonHistoryDir()`). */ + historyRoot: string + /** The worktree that owns the pane on this host, or null when the pane is not here. */ + paneWorktreeId: (paneKey: string) => string | null + /** Where a pane with no recorded folder starts: its workspace's folder on this host. */ + workspacePath: (worktreeId: string) => string | null + now: number +} + +/** + * Idempotent: a session folder that already holds `meta.json` is the pane's own by now (restored, + * or written since), so the blob is only deleted. A write failure keeps the blob for the retry. + */ +export function installOrcadMigrationTerminalHistory( + install: OrcadMigrationTerminalHistoryInstall +): void { + for (const key of listOrcadMigrationReceivedBlobNames( + install.root, + install.migration, + 'terminal-history' + )) { + const path = getOrcadMigrationReceivedBlobPath(install.root, install.migration, { + blobKind: 'terminal-history', + key + }) + let document: OrcadMigrationTerminalHistoryDocument | null = null + try { + document = readDocument(path) + } catch (error) { + // The pre-commit check passed these bytes, so only a damaged file lands here; the pane + // starts fresh rather than holding back the rest of the move. + console.error( + `[orcad-migration] dropped unreadable terminal history for ${install.migration.migrationId}:`, + error + ) + } + const worktreeId = document ? install.paneWorktreeId(document.paneKey) : null + const cwd = worktreeId ? (document?.cwd ?? install.workspacePath(worktreeId)) : null + if (document && worktreeId && cwd) { + writeSessionFolder( + install.historyRoot, + movedPaneHistorySessionId(worktreeId, document.paneKey), + { ...document, cwd }, + install.now + ) + } + rmSync(path, { force: true }) + syncDirectoryDurablySync(dirname(path)) + } +} + +function writeSessionFolder( + historyRoot: string, + sessionId: string, + document: OrcadMigrationTerminalHistoryDocument & { cwd: string }, + now: number +): void { + const dir = join(historyRoot, getHistorySessionDirName(sessionId)) + const metaPath = join(dir, 'meta.json') + if (existsSync(metaPath)) { + return + } + // A crash before meta.json left no session yet; redo the folder whole. + rmSync(dir, { recursive: true, force: true }) + mkdirSync(dir, { recursive: true, mode: 0o700 }) + writeAtomically( + join(dir, 'checkpoint.json'), + JSON.stringify({ ...document.checkpoint, cwd: document.cwd }) + ) + const meta: SessionMeta = { + cwd: document.cwd, + cols: document.cols, + rows: document.rows, + startedAt: new Date(now).toISOString(), + // Why null: only an unended session cold-restores, and this one never ran on this host. + endedAt: null, + exitCode: null + } + // Why last: meta.json is what makes the folder a session. + writeAtomically(metaPath, JSON.stringify(meta)) + syncDirectoryDurablySync(dir) +} + +function writeAtomically(path: string, text: string): void { + writeFileSync(`${path}.tmp`, text, { mode: 0o600 }) + renameSync(`${path}.tmp`, path) +} + +function readDocument(path: string): OrcadMigrationTerminalHistoryDocument { + let text: string + try { + text = UTF8.decode(readFileSync(path)) + } catch { + throw new Error('orcad_migration_terminal_history_not_utf8') + } + return parseOrcadMigrationTerminalHistoryText(text) +} + +function findPaneWorktreeId(manifest: OrcadMigrationManifest, paneKey: string): string | null { + const pane = parsePaneKey(paneKey) + const session = manifest.payload.dormantState?.workspaceSession + if (!pane || !session) { + return null + } + const owner = Object.entries(session.tabsByWorktree ?? {}).find(([, tabs]) => + tabs.some((tab) => tab.id === pane.tabId) + )?.[0] + const layout = session.terminalLayoutsByTabId?.[pane.tabId] + return owner && layout ? owner : null +} diff --git a/src/main/persistence/migrating-orcad-catalog/persistence-orcad-migration-terminal-restore-hints.test.ts b/src/main/persistence/migrating-orcad-catalog/persistence-orcad-migration-terminal-restore-hints.test.ts index f5b9a4ea937..93c6ec098e6 100644 --- a/src/main/persistence/migrating-orcad-catalog/persistence-orcad-migration-terminal-restore-hints.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/persistence-orcad-migration-terminal-restore-hints.test.ts @@ -6,7 +6,6 @@ import { join } from 'node:path' import type { OrcadMigrationBlobDescriptor } from '../../../shared/orcad-migration/orcad-migration-blob-transfer' import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest' import { getOrcadMigrationReceivedBlobPath } from './orcad-migration-blob-staging' -import { buildWslMoveRestoreHintsBlob } from '../moving-wsl-projects/wsl-move-restore-hints-blob' import { commitStagedOrcadMigrationCatalogDurably, getOrcadMigrationCatalogState @@ -82,15 +81,20 @@ function encodedHints(): { descriptor: OrcadMigrationBlobDescriptor bytes: Buffer } { - const blob = buildWslMoveRestoreHintsBlob([ - { paneKey: PANE, cwd: '/home/me/src' }, - { paneKey: OTHER_PANE, cwd: '/srv/app' }, - { paneKey: ABSENT_PANE, cwd: '/srv/gone' } - ]) - if (!blob) { - throw new Error('expected a hints blob') - } - return blob + // What an older desktop sends; this build installs it but no longer sends one. + return rawBlob( + Buffer.from( + JSON.stringify({ + version: 1, + hints: [ + { paneKey: PANE, cwd: '/home/me/src' }, + { paneKey: OTHER_PANE, cwd: '/srv/app' }, + { paneKey: ABSENT_PANE, cwd: '/srv/gone' } + ] + }), + 'utf8' + ) + ) } function rawBlob(bytes: Buffer): { diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-automation-filter-rewrite.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-automation-filter-rewrite.test.ts new file mode 100644 index 00000000000..6cc6f873b47 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-automation-filter-rewrite.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { toPersistedAutomationHostFilter } from '../../../shared/automation-host-filter' +import { wslMoveAutomationHostFilterUpdate } from './wsl-move-automation-filter-rewrite' + +const desktop = toPersistedAutomationHostFilter({ + kind: 'host', + host: { authority: { kind: 'desktop' }, selector: { kind: 'self' } } +}) +const otherHost = { kind: 'host' as const, hostKey: 'host:ssh:staging' } + +describe('wslMoveAutomationHostFilterUpdate', () => { + it('widens a filter on this desktop to all hosts when the move carried automations', () => { + expect(wslMoveAutomationHostFilterUpdate(desktop, 2)).toEqual({ kind: 'all' }) + }) + + it('keeps the filter when nothing moved, or it shows all hosts or another host', () => { + expect(wslMoveAutomationHostFilterUpdate(desktop, 0)).toBeNull() + expect(wslMoveAutomationHostFilterUpdate({ kind: 'all' }, 2)).toBeNull() + expect(wslMoveAutomationHostFilterUpdate(otherHost, 2)).toBeNull() + expect(wslMoveAutomationHostFilterUpdate(undefined, 2)).toBeNull() + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-automation-filter-rewrite.ts b/src/main/persistence/moving-wsl-projects/wsl-move-automation-filter-rewrite.ts new file mode 100644 index 00000000000..04c7e076ac6 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-automation-filter-rewrite.ts @@ -0,0 +1,25 @@ +/** + * The Automations page host filter after a WSL move. It selects one host or all of them, so it + * cannot also cover the WSL host; a filter on this desktop would hide every automation the move + * carried. Only then does it widen to All hosts, once: the ledgered run never repeats, so a + * later choice of this desktop stays. + */ +import type { PersistedAutomationHostFilter } from '../../../shared/automation-host-filter' +import { hostStableKey } from '../../../shared/automation-owner-key' + +const DESKTOP_SELF_HOST_KEY = hostStableKey({ + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +}) + +/** The filter to persist, or null when it stays. */ +export function wslMoveAutomationHostFilterUpdate( + filter: PersistedAutomationHostFilter | undefined, + movedAutomationCount: number +): PersistedAutomationHostFilter | null { + return movedAutomationCount > 0 && + filter?.kind === 'host' && + filter.hostKey === DESKTOP_SELF_HOST_KEY + ? { kind: 'all' } + : null +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-browser-cookie-copy.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-browser-cookie-copy.test.ts new file mode 100644 index 00000000000..c156eb83530 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-browser-cookie-copy.test.ts @@ -0,0 +1,402 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + KnownRuntimeEnvironment, + OrcadDeploymentLink +} from '../../../shared/runtime-environments' +import { + emptyWslMoveBrowserCookieCopyCounts, + WSL_MOVE_BROWSER_COOKIES_STORE_ID +} from '../../../shared/wsl-move/wsl-move-browser-cookie-copy' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import { parseWslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { deriveBrowserRoutePartitionStorageScope } from '../../browser/browser-route-identity' +import { wslHostBrowserRouteStorage } from '../../browser/wsl-host-browser-route-partition' +import { listWslMoveCutovers } from './wsl-move-cutover-journal' +import type { WslMoveBrowserCookieCopyDependencies } from './wsl-move-browser-cookie-copy' +import { previewWslMoveBrowserCookieCopy } from './wsl-move-browser-cookie-copy' +import { registerWslMoveBrowserCookieStore } from './wsl-move-browser-cookie-store' +import { + runGatedWslMoveDesktopRewritePass, + runWslMoveDesktopRewritePass +} from './wsl-move-desktop-rewrite' +import { + resetWslMoveDesktopRewriteStoresForTests, + setWslMoveDesktopRewriteRegistryCompleteForTests +} from './wsl-move-desktop-rewrite-stores' + +vi.mock('../../browser/browser-partition-cookie-copy', () => ({ + copyBrowserPartitionCookies: vi.fn(), + flushBrowserPartitionCookies: vi.fn() +})) +vi.mock('../../browser/browser-session-registry', () => ({ browserSessionRegistry: {} })) +vi.mock('../../browser/browser-session-startup', () => ({ browserSessionsInitialized: vi.fn() })) +vi.mock('../../browser/browser-route-partition-binding-runtime', () => ({ + activeBrowserRoutePartitionOrcaProfileId: vi.fn(), + currentBrowserRoutePartitionBindingStore: vi.fn() +})) +vi.mock('../../browser/browser-route-partition-storage-dependencies', () => ({ + releaseEvictedBrowserRoutePartitionStorage: vi.fn() +})) + +const LOCAL_DEFAULT = 'persist:orca-browser-default' +const LOCAL_WORK = 'persist:orca-browser-work' +const SSH_PARTITION = `persist:orca-browser-v1-${'5'.repeat(64)}` + +type Jar = Map + +/** One desktop: its partitions' cookie jars, its environments, and what touched them. */ +type Desktop = { + jars: Map + environments: KnownRuntimeEnvironment[] + sessionsReady: boolean + copies: { source: string; destination: string }[] + flushed: string[] + failFlush: boolean +} + +let userDataPath: string +let desktop: Desktop + +function jarOf(partition: string): Jar { + let jar = desktop.jars.get(partition) + if (!jar) { + jar = new Map() + desktop.jars.set(partition, jar) + } + return jar +} + +function hostPartition(storageScope: string, browserProfileId: string): string { + const digest = createHash('sha256').update(`${storageScope}|${browserProfileId}`).digest('hex') + return `persist:orca-browser-v1-${digest}` +} + +/** The moved-to WSL host's partition for one browser profile. */ +function hostPartitionOf(browserProfileId: string): string { + const scope = deriveBrowserRoutePartitionStorageScope({ + orcaProfileId: 'orca-profile-a', + environmentId: 'wsl:Ubuntu' + }) + return hostPartition(scope, browserProfileId) +} + +function dependencies( + overrides: Partial = {} +): WslMoveBrowserCookieCopyDependencies { + return { + userDataPath, + sessionsReady: () => desktop.sessionsReady, + orcaProfileId: () => 'orca-profile-a', + listEnvironments: () => desktop.environments, + listBrowserProfiles: () => [ + { id: 'default', partition: LOCAL_DEFAULT }, + { id: 'work', partition: LOCAL_WORK } + ], + resolveHostStorage: wslHostBrowserRouteStorage, + bindHostPartition: (storage, browserProfileId) => + hostPartition(storage.storageScope, browserProfileId), + copyCookies: async (source, destination) => { + desktop.copies.push({ source, destination }) + const counts = emptyWslMoveBrowserCookieCopyCounts() + const target = jarOf(destination) + for (const [name, value] of jarOf(source)) { + if (target.has(name)) { + counts.skippedPresent += 1 + } else { + target.set(name, value) + counts.copied += 1 + } + } + return counts + }, + flushCookies: async (partition) => { + if (desktop.failFlush) { + throw new Error('crashed before the ledger entry') + } + desktop.flushed.push(partition) + }, + ...overrides + } +} + +const DEPLOYMENTS = { + wsl: { kind: 'wsl', distro: 'Ubuntu', localPort: 41_001, remotePort: 6768 }, + 'managed-ssh': { + sshTargetId: 'target-a', + sshTargetGeneration: 1, + localPort: 41_001, + remotePort: 6768 + }, + unlinked: null +} satisfies Record + +/** A runtime environment as each host kind persists it; `unlinked` lost its deployment link. */ +function hostEnvironment(kind: keyof typeof DEPLOYMENTS, id: string): KnownRuntimeEnvironment { + const deployment = DEPLOYMENTS[kind] + return { + id, + name: id, + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + pairingRevision: 1, + ...(deployment ? { orcadDeployment: deployment } : {}), + preferredEndpointId: 'endpoint-a', + endpoints: [ + { + id: 'endpoint-a', + kind: 'websocket', + label: id, + endpoint: 'ws://127.0.0.1:41001', + deviceToken: 'token-a', + publicKeyB64: 'host-key-a' + } + ] + } +} + +const wslHost = (kind: 'wsl' | 'unlinked' = 'wsl'): KnownRuntimeEnvironment => + hostEnvironment(kind, 'wsl:Ubuntu') +const record = (migrationId = 'move-1') => + listWslMoveCutovers(userDataPath).find((entry) => entry.migrationId === migrationId) + +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-browser-cookies-')) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + setWslMoveDesktopRewriteRegistryCompleteForTests(true) + desktop = { + jars: new Map(), + environments: [ + wslHost(), + hostEnvironment('managed-ssh', 'ssh-managed'), + // Plain SSH and manually paired servers carry no deployment link. + hostEnvironment('unlinked', 'ssh-plain'), + hostEnvironment('unlinked', 'paired') + ], + sessionsReady: true, + copies: [], + flushed: [], + failFlush: false + } + jarOf(LOCAL_DEFAULT).set('sid', 'desktop-login') + jarOf(LOCAL_WORK).set('work-sid', 'work-login') + jarOf(SSH_PARTITION).set('ssh-sid', 'ssh-login') +}) +afterEach(() => { + vi.unstubAllEnvs() + resetWslMoveDesktopRewriteStoresForTests() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +function snapshot(partitions: readonly string[]): Record { + return Object.fromEntries(partitions.map((partition) => [partition, [...jarOf(partition)]])) +} + +describe('WSL move browser-cookies store', () => { + it('copies every browser profile into the WSL host partition and records it', async () => { + registerWslMoveBrowserCookieStore(dependencies()) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + const untouched = snapshot([LOCAL_DEFAULT, LOCAL_WORK, SSH_PARTITION]) + + await runWslMoveDesktopRewritePass({ userDataPath }) + + const defaultHost = hostPartitionOf('default') + const workHost = hostPartitionOf('work') + expect(desktop.copies).toEqual([ + { source: LOCAL_DEFAULT, destination: defaultHost }, + { source: LOCAL_WORK, destination: workHost } + ]) + expect(jarOf(defaultHost).get('sid')).toBe('desktop-login') + expect(jarOf(workHost).get('work-sid')).toBe('work-login') + expect(desktop.flushed.sort()).toEqual([defaultHost, workHost].sort()) + // Sources and every other host's jar are only ever read. + expect(snapshot([LOCAL_DEFAULT, LOCAL_WORK, SSH_PARTITION])).toEqual(untouched) + expect(record()?.rewrittenStores).toEqual([WSL_MOVE_BROWSER_COOKIES_STORE_ID]) + expect(record()?.phase).toBe('client-rewritten') + expect(record()?.browserCookieCopy).toMatchObject({ + outcome: 'copied', + profiles: [ + { browserProfileId: 'default', destinationPartition: defaultHost, alreadyCopied: false }, + { browserProfileId: 'work', destinationPartition: workHost, alreadyCopied: false } + ] + }) + }) + + it.each([ + { + name: 'a WSL environment without its deployment link', + environments: () => [wslHost('unlinked')], + reason: 'no-stable-partition' + }, + { + name: 'a destination that is not a WSL host', + environments: () => [hostEnvironment('managed-ssh', 'wsl:Ubuntu')], + reason: 'destination-not-wsl-host' + }, + { + name: 'a removed destination environment', + environments: () => [], + reason: 'destination-environment-missing' + } + ])('leaves $name on a fresh partition and says why', async ({ environments, reason }) => { + desktop.environments = environments() + registerWslMoveBrowserCookieStore(dependencies()) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + const before = snapshot([...desktop.jars.keys()]) + + await runWslMoveDesktopRewritePass({ userDataPath }) + + expect(desktop.copies).toEqual([]) + expect(snapshot([...desktop.jars.keys()])).toEqual(before) + expect(record()?.browserCookieCopy).toEqual({ outcome: 'fresh-partition', reason }) + expect(record()?.rewrittenStores).toEqual([WSL_MOVE_BROWSER_COOKIES_STORE_ID]) + }) + + it('leaves the host fresh when another identity owns its partition name', async () => { + registerWslMoveBrowserCookieStore( + dependencies({ + bindHostPartition: (_storage, browserProfileId) => { + if (browserProfileId === 'work') { + throw new Error('browser_route_partition_binding_conflict') + } + return hostPartitionOf(browserProfileId) + } + }) + ) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + + await runWslMoveDesktopRewritePass({ userDataPath }) + + expect(desktop.copies).toEqual([]) + expect(record()?.browserCookieCopy).toEqual({ + outcome: 'fresh-partition', + reason: 'destination-partition-conflict' + }) + }) + + it('re-runs idempotently after a crash between the copy and its ledger entry', async () => { + registerWslMoveBrowserCookieStore(dependencies()) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + desktop.failFlush = true + const warn = vi.fn() + + await runWslMoveDesktopRewritePass({ userDataPath, warn }) + expect(warn).toHaveBeenCalledTimes(1) + expect(record()?.rewrittenStores).toEqual([]) + const defaultHost = hostPartitionOf('default') + const afterCrash = snapshot([defaultHost]) + + desktop.failFlush = false + await runWslMoveDesktopRewritePass({ userDataPath, warn }) + + expect(snapshot([defaultHost])).toEqual(afterCrash) + expect(record()?.rewrittenStores).toEqual([WSL_MOVE_BROWSER_COOKIES_STORE_ID]) + const outcome = record()?.browserCookieCopy + expect(outcome?.outcome === 'copied' && outcome.profiles[0]?.counts).toMatchObject({ + copied: 0, + skippedPresent: 1 + }) + }) + + it('waits for browser sessions instead of opening a partition early', async () => { + registerWslMoveBrowserCookieStore(dependencies()) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + desktop.sessionsReady = false + const warn = vi.fn() + + await runWslMoveDesktopRewritePass({ userDataPath, warn }) + expect(desktop.copies).toEqual([]) + expect(record()?.rewrittenStores).toEqual([]) + expect(warn.mock.calls[0]?.[0]).toContain('wsl_move_browser_sessions_not_ready') + + desktop.sessionsReady = true + await runWslMoveDesktopRewritePass({ userDataPath, warn }) + expect(record()?.rewrittenStores).toEqual([WSL_MOVE_BROWSER_COOKIES_STORE_ID]) + }) + + it('copies once per host: a later move into the same host leaves its jar alone', async () => { + registerWslMoveBrowserCookieStore(dependencies()) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + await runWslMoveDesktopRewritePass({ userDataPath }) + const first = record() + if (!first) { + throw new Error('the first move has no journal record') + } + // Advancing keeps the record's outcome, as the journal itself does. + seedWslMoveCutover(userDataPath, { ...first, phase: 'retained' }) + const defaultHost = hostPartitionOf('default') + // The user signed out on the host; a second copy must not sign them back in. + jarOf(defaultHost).delete('sid') + desktop.copies.length = 0 + + seedWslMoveCutover( + userDataPath, + cutover('move-2', 'destination-committed', { supersedesMigrationId: 'move-1' }) + ) + await runWslMoveDesktopRewritePass({ userDataPath }) + + expect(desktop.copies).toEqual([]) + expect(jarOf(defaultHost).has('sid')).toBe(false) + expect(record('move-2')?.browserCookieCopy).toMatchObject({ + outcome: 'copied', + profiles: [{ alreadyCopied: true }, { alreadyCopied: true }] + }) + }) + + it('does nothing without a move journal', async () => { + const sessionsReady = vi.fn(() => true) + const copyCookies = vi.fn() + registerWslMoveBrowserCookieStore(dependencies({ sessionsReady, copyCookies })) + + await runWslMoveDesktopRewritePass({ userDataPath }) + + expect(sessionsReady).not.toHaveBeenCalled() + expect(copyCookies).not.toHaveBeenCalled() + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + }) + + it('does nothing while the internal WSL hosts gate is closed', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + const copyCookies = vi.fn() + registerWslMoveBrowserCookieStore(dependencies({ copyCookies })) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + + await runGatedWslMoveDesktopRewritePass({ userDataPath }) + + expect(copyCookies).not.toHaveBeenCalled() + expect(record()?.rewrittenStores).toEqual([]) + }) + + it('still reads a journal whose outcome a newer build wrote', () => { + const parsed = parseWslMoveCutover({ + ...cutover('move-1', 'destination-committed'), + browserCookieCopy: { outcome: 'fresh-partition', reason: 'a-reason-from-the-future' } + }) + expect(parsed.migrationId).toBe('move-1') + expect(parsed.browserCookieCopy).toBeUndefined() + }) + + it('previews the reason a move would start the host fresh', () => { + const resolve = dependencies() + expect(previewWslMoveBrowserCookieCopy(resolve, 'orca-profile-a', wslHost())).toEqual({ + kind: 'copy' + }) + expect(previewWslMoveBrowserCookieCopy(resolve, 'orca-profile-a', wslHost('unlinked'))).toEqual( + { + kind: 'fresh-partition', + reason: 'no-stable-partition' + } + ) + expect(previewWslMoveBrowserCookieCopy(resolve, 'orca-profile-a', undefined)).toEqual({ + kind: 'fresh-partition', + reason: 'destination-environment-missing' + }) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-browser-cookie-copy.ts b/src/main/persistence/moving-wsl-projects/wsl-move-browser-cookie-copy.ts new file mode 100644 index 00000000000..f1b2f58b5d9 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-browser-cookie-copy.ts @@ -0,0 +1,193 @@ +/** + * The `browser-cookies` rewrite store (owner decision 3): a committed WSL move copies the + * desktop's in-app browser cookies once into the WSL host's route partition, or records why the + * host starts on a fresh partition instead. Browser profiles are global, not per project, so each + * profile's whole jar is copied, once per host partition, by the first move into that host. + */ +import type { KnownRuntimeEnvironment } from '../../../shared/runtime-environments' +import { + emptyWslMoveBrowserCookieCopyCounts, + WSL_MOVE_BROWSER_COOKIE_COPY_MAX_PROFILES, + WSL_MOVE_BROWSER_COOKIES_STORE_ID, + type WslMoveBrowserCookieCopy, + type WslMoveBrowserCookieCopyCounts, + type WslMoveBrowserCookieFreshPartitionReason +} from '../../../shared/wsl-move/wsl-move-browser-cookie-copy' +import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import type { + WslHostBrowserRouteStorage, + WslHostBrowserRouteStorageResult +} from '../../browser/wsl-host-browser-route-partition' +import { listWslMoveCutovers, writeWslMoveCutover } from './wsl-move-cutover-journal' +import type { WslMoveDesktopRewriteContext } from './wsl-move-desktop-rewrite-stores' + +export type WslMoveBrowserCookieCopyDependencies = { + userDataPath: string + /** + * False until a partition may be opened (after the pending cookie import replay) and an app + * window exists, so destroying the copy's hidden windows never closes the last one. + */ + sessionsReady: () => boolean + orcaProfileId: () => string | null + listEnvironments: () => readonly KnownRuntimeEnvironment[] + listBrowserProfiles: () => readonly { id: string; partition: string }[] + resolveHostStorage: ( + orcaProfileId: string, + environment: KnownRuntimeEnvironment + ) => WslHostBrowserRouteStorageResult + bindHostPartition: (storage: WslHostBrowserRouteStorage, browserProfileId: string) => string + copyCookies: ( + sourcePartition: string, + destinationPartition: string + ) => Promise + flushCookies: (partition: string) => Promise +} + +export type WslMoveBrowserCookieCopyPreview = + | { kind: 'copy' } + | { kind: 'fresh-partition'; reason: WslMoveBrowserCookieFreshPartitionReason } + +/** What a move into `environment` would do with the logins; the move preview shows the reason. */ +export function previewWslMoveBrowserCookieCopy( + dependencies: Pick, + orcaProfileId: string, + environment: KnownRuntimeEnvironment | undefined +): WslMoveBrowserCookieCopyPreview { + if (!environment) { + return { kind: 'fresh-partition', reason: 'destination-environment-missing' } + } + const storage = dependencies.resolveHostStorage(orcaProfileId, environment) + return storage.kind === 'stable' + ? { kind: 'copy' } + : { kind: 'fresh-partition', reason: storage.reason } +} + +/** + * The store's `run`. Idempotent: a crash after the copy but before the ledger entry re-runs it, + * and the merge then finds every cookie already present. The source partition is only read. + * Returns the partitions written, which the store's `flush` makes durable. + */ +export async function runWslMoveBrowserCookieCopy( + dependencies: WslMoveBrowserCookieCopyDependencies, + context: Pick +): Promise { + if (!dependencies.sessionsReady()) { + // No ledger entry: the pass after the first window shows retries it. + throw new Error('wsl_move_browser_sessions_not_ready') + } + const orcaProfileId = dependencies.orcaProfileId() + if (!orcaProfileId) { + throw new Error('wsl_move_browser_sessions_not_ready') + } + const { cutover } = context + const environment = dependencies + .listEnvironments() + .find((entry) => entry.id === cutover.destinationEnvironmentId) + if (!environment) { + recordOutcome(dependencies.userDataPath, cutover.migrationId, { + outcome: 'fresh-partition', + reason: 'destination-environment-missing' + }) + return [] + } + const storage = dependencies.resolveHostStorage(orcaProfileId, environment) + if (storage.kind !== 'stable') { + recordOutcome(dependencies.userDataPath, cutover.migrationId, { + outcome: 'fresh-partition', + reason: storage.reason + }) + return [] + } + const profiles = dependencies.listBrowserProfiles() + let destinations: string[] + try { + // Every binding before any copy, so a conflict copies nothing; bindings match what a page makes. + destinations = profiles.map((profile) => + dependencies.bindHostPartition(storage.storage, profile.id) + ) + } catch (error) { + if (error instanceof Error && error.message === 'browser_route_partition_binding_conflict') { + recordOutcome(dependencies.userDataPath, cutover.migrationId, { + outcome: 'fresh-partition', + reason: 'destination-partition-conflict' + }) + return [] + } + throw error + } + const alreadyCopied = partitionsCopiedByEarlierMoves(dependencies.userDataPath, cutover) + const written: string[] = [] + const results: Extract['profiles'] = [] + for (const [index, profile] of profiles.entries()) { + const destinationPartition = destinations[index] + if (!destinationPartition) { + continue + } + // Why once per partition: a second copy would revive logins the user signed out of there. + if (alreadyCopied.has(destinationPartition)) { + results.push({ + browserProfileId: profile.id, + destinationPartition, + alreadyCopied: true, + counts: emptyWslMoveBrowserCookieCopyCounts() + }) + continue + } + const counts = await dependencies.copyCookies(profile.partition, destinationPartition) + written.push(destinationPartition) + results.push({ + browserProfileId: profile.id, + destinationPartition, + alreadyCopied: false, + counts + }) + } + recordOutcome(dependencies.userDataPath, cutover.migrationId, { + outcome: 'copied', + // Within the journal's bound, so a huge profile list never blocks the ledger entry. + profiles: results.slice(0, WSL_MOVE_BROWSER_COOKIE_COPY_MAX_PROFILES) + }) + return written +} + +/** Partitions another move durably copied into: its ledger already holds this store. */ +function partitionsCopiedByEarlierMoves( + userDataPath: string, + cutover: Readonly +): Set { + const partitions = new Set() + for (const entry of listWslMoveCutovers(userDataPath)) { + const record = entry.browserCookieCopy + if ( + entry.migrationId === cutover.migrationId || + entry.destinationEnvironmentId !== cutover.destinationEnvironmentId || + !entry.rewrittenStores.includes(WSL_MOVE_BROWSER_COOKIES_STORE_ID) || + record?.outcome !== 'copied' + ) { + continue + } + for (const profile of record.profiles) { + partitions.add(profile.destinationPartition) + } + } + return partitions +} + +/** Written before the ledger entry, from a fresh read, so the driver's append keeps it. */ +function recordOutcome( + userDataPath: string, + migrationId: string, + browserCookieCopy: WslMoveBrowserCookieCopy +): void { + const current = listWslMoveCutovers(userDataPath).find( + (entry) => entry.migrationId === migrationId + ) + if (current?.phase !== 'destination-committed') { + throw new Error('wsl_move_record_missing') + } + writeWslMoveCutover(userDataPath, { + ...current, + browserCookieCopy, + updatedAt: new Date().toISOString() + }) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-browser-cookie-store.ts b/src/main/persistence/moving-wsl-projects/wsl-move-browser-cookie-store.ts new file mode 100644 index 00000000000..35d68a417f4 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-browser-cookie-store.ts @@ -0,0 +1,60 @@ +/** Registers the `browser-cookies` rewrite store against the app's real browser sessions. */ +import { BrowserWindow } from 'electron' +import { listEnvironments } from '../../../shared/runtime-environment-store' +import { WSL_MOVE_BROWSER_COOKIES_STORE_ID } from '../../../shared/wsl-move/wsl-move-browser-cookie-copy' +import { + copyBrowserPartitionCookies, + flushBrowserPartitionCookies +} from '../../browser/browser-partition-cookie-copy' +import { activeBrowserRoutePartitionOrcaProfileId } from '../../browser/browser-route-partition-binding-runtime' +import { browserSessionRegistry } from '../../browser/browser-session-registry' +import { browserSessionsInitialized } from '../../browser/browser-session-startup' +import { + bindWslHostBrowserRoutePartition, + wslHostBrowserRouteStorage +} from '../../browser/wsl-host-browser-route-partition' +import { + runWslMoveBrowserCookieCopy, + type WslMoveBrowserCookieCopyDependencies +} from './wsl-move-browser-cookie-copy' +import { registerWslMoveDesktopRewriteStore } from './wsl-move-desktop-rewrite-stores' + +export function productionWslMoveBrowserCookieCopyDependencies( + userDataPath: string +): WslMoveBrowserCookieCopyDependencies { + return { + userDataPath, + // Why a window: win32 quits on window-all-closed, which the copy's hidden windows would trigger. + sessionsReady: () => + browserSessionsInitialized() && + BrowserWindow.getAllWindows().some((window) => !window.isDestroyed()), + orcaProfileId: activeBrowserRoutePartitionOrcaProfileId, + listEnvironments: () => listEnvironments(userDataPath), + listBrowserProfiles: () => browserSessionRegistry.listProfiles(), + resolveHostStorage: wslHostBrowserRouteStorage, + bindHostPartition: bindWslHostBrowserRoutePartition, + copyCookies: copyBrowserPartitionCookies, + flushCookies: flushBrowserPartitionCookies + } +} + +/** + * Register last among main stores: until browser sessions and a window exist it fails, which pauses + * the stores after it in that pass too. The pass started once the first window shows finishes it. + */ +export function registerWslMoveBrowserCookieStore( + dependencies: WslMoveBrowserCookieCopyDependencies +): void { + let unflushed: string[] = [] + registerWslMoveDesktopRewriteStore({ + id: WSL_MOVE_BROWSER_COOKIES_STORE_ID, + side: 'main', + run: async (context) => { + unflushed = await runWslMoveBrowserCookieCopy(dependencies, context) + }, + flush: async () => { + await Promise.all(unflushed.map((partition) => dependencies.flushCookies(partition))) + unflushed = [] + } + }) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-comparable-path.ts b/src/main/persistence/moving-wsl-projects/wsl-move-comparable-path.ts new file mode 100644 index 00000000000..9fa52062133 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-comparable-path.ts @@ -0,0 +1,19 @@ +/** + * One spelling for a WSL folder across UNC aliases (`wsl$`, `wsl.localhost`), distro case, Linux + * spelling and trailing separators, so the exit proof and the spawn fence agree on what is in scope. + */ +import { foldWslUncPathCaseInsensitiveParts, toWindowsWslUncPath } from '../../../shared/wsl-paths' + +/** Null for a path that is not in a WSL distro (a Windows drive path, say). */ +export function comparableWslPath(path: string, distro: string): string | null { + const unc = isLinuxSpelled(path) ? toWindowsWslUncPath(path, distro) : path + return foldWslUncPathCaseInsensitiveParts(unc)?.replace(/\/+$/, '') ?? null +} + +export function isLinuxSpelled(path: string): boolean { + return /^\/(?!\/)/.test(path) +} + +export function isAtOrUnder(path: string, root: string): boolean { + return path === root || path.startsWith(`${root}/`) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-copy-bytes.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-copy-bytes.test.ts new file mode 100644 index 00000000000..70a90a38004 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-copy-bytes.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { wslMoveCopyBytes } from './wsl-move-driver-transfer' + +describe('wslMoveCopyBytes', () => { + it('counts the terminal history sent while staging, not only the blobs', () => { + // A 262 KB move showed 413 B. + const cutover = wslMoveCutoverFixture('move-1', 'destination-staged') + const withHistory = { + manifest: { + ...cutover.manifest, + payload: { + ...cutover.manifest.payload, + dormantState: { + ...cutover.manifest.payload.dormantState!, + terminalScrollbackSnapshots: [ + { tabId: 't', leafId: 'l', ref: 'r', sha256: '0'.repeat(64), byteLength: 261_834 } + ] + } + } + } + } + expect(wslMoveCopyBytes(withHistory, [{ byteLength: 413 }])).toEqual({ + snapshotBytes: 261_834, + bytesTotal: 262_247 + }) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts index 3b4f6f486fe..261e0b028a8 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts @@ -183,4 +183,104 @@ describe('WSL move journal', () => { writeFileSync(journalPath('move-1'), JSON.stringify(edited)) expect(() => listWslMoveCutovers(userDataPath)).toThrow('stay fenced') }) + + it('refuses to remove a move a later move of its distro supersedes, so the chain stays readable', () => { + seedWslMoveCutover(userDataPath, cutover('move-1', 'retained')) + seedWslMoveCutover( + userDataPath, + cutover('move-2', 'retained', { supersedesMigrationId: 'move-1' }, { repoIds: ['repo-2'] }) + ) + seedWslMoveCutover( + userDataPath, + cutover('move-3', 'retained', { supersedesMigrationId: 'move-2' }, { repoIds: ['repo-3'] }) + ) + + expect(() => removeWslMoveCutover(userDataPath, 'move-2')).toThrow('wsl_move_superseded') + expect(() => removeWslMoveCutover(userDataPath, 'move-1')).toThrow('wsl_move_superseded') + expect( + listWslMoveCutoverChain(userDataPath, 'local-wsl:ubuntu').map((c) => c.migrationId) + ).toEqual(['move-1', 'move-2', 'move-3']) + // Newest first, one at a time, and the chain reads after every step. + removeWslMoveCutover(userDataPath, 'move-3') + expect(listWslMoveCutoverChain(userDataPath, 'local-wsl:ubuntu')).toHaveLength(2) + removeWslMoveCutover(userDataPath, 'move-2') + removeWslMoveCutover(userDataPath, 'move-1') + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + }) + describe('restore hints', () => { + const hints = [{ paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', cwd: '/home/dev/a' }] + + it('records them once, with the quiesced record, and keeps them unchanged after', () => { + seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) + writeWslMoveCutover( + userDataPath, + cutover('move-1', 'quiesced', { terminalRestoreHints: hints }) + ) + expect(listWslMoveCutovers(userDataPath)[0].terminalRestoreHints).toEqual(hints) + writeWslMoveCutover( + userDataPath, + cutover('move-1', 'destination-staged', { terminalRestoreHints: hints }) + ) + expect(() => + writeWslMoveCutover( + userDataPath, + cutover('move-1', 'blobs-transferred', { terminalRestoreHints: [] }) + ) + ).toThrow('wsl_move_restore_hints_changed') + expect(() => + writeWslMoveCutover(userDataPath, cutover('move-1', 'blobs-transferred')) + ).toThrow('wsl_move_restore_hints_changed') + }) + + it('refuses them on a new record or outside the quiesce step', () => { + expect(() => + writeWslMoveCutover( + userDataPath, + cutover('move-1', 'prepared', { terminalRestoreHints: hints }) + ) + ).toThrow('wsl_move_restore_hints_changed') + writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared')) + expect(() => + writeWslMoveCutover( + userDataPath, + cutover('move-1', 'source-fenced', { terminalRestoreHints: hints }) + ) + ).toThrow('wsl_move_restore_hints_changed') + }) + + it('reads a record without them (an older build) and a malformed list as absent', () => { + mkdirSync(wslMoveJournalDirectory(userDataPath), { recursive: true }) + writeFileSync(journalPath('move-1'), JSON.stringify(cutover('move-1', 'quiesced'))) + expect(listWslMoveCutovers(userDataPath)[0].terminalRestoreHints).toBeUndefined() + writeFileSync( + journalPath('move-1'), + JSON.stringify({ + ...cutover('move-1', 'quiesced'), + terminalRestoreHints: [{ paneKey: 'not a pane', cwd: 'relative' }] + }) + ) + expect(listWslMoveCutovers(userDataPath)[0].phase).toBe('quiesced') + expect(listWslMoveCutovers(userDataPath)[0].terminalRestoreHints).toBeUndefined() + }) + }) + it('keeps the automations to turn back on exactly as the move recorded them', () => { + writeWslMoveCutover( + userDataPath, + cutover('move-1', 'prepared', { automationsToReenable: ['auto-1'] }) + ) + expect(() => + writeWslMoveCutover( + userDataPath, + cutover('move-1', 'source-fenced', { automationsToReenable: [] }) + ) + ).toThrow('wsl_move_binding_changed') + expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))).toThrow( + 'wsl_move_binding_changed' + ) + writeWslMoveCutover( + userDataPath, + cutover('move-1', 'source-fenced', { automationsToReenable: ['auto-1'] }) + ) + expect(listWslMoveCutovers(userDataPath)[0].automationsToReenable).toEqual(['auto-1']) + }) }) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.ts b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.ts index 9a03c80e329..22e98ab85f6 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.ts @@ -11,6 +11,7 @@ import { createMigrationJournalDirectory, orderMigrationJournalChain } from '../migrating-orcad-catalog/migration-journal-directory' +import { assertWslMoveManifestFits } from './wsl-move-manifest-budget' import { isWslMoveDesktopRewriteRegistryComplete, pendingWslMoveDesktopRewriteStores @@ -37,6 +38,7 @@ const journals = createMigrationJournalDirectory({ maxFileBytes: 16 * 1024 * 1024, parse: parseVerifiedWslMoveCutover, unreadable: (detail) => new WslMoveJournalUnreadableError(detail), + sweepOrphanedTemporaries: true, onChange: (userDataPath) => { for (const listener of changeListeners) { listener(userDataPath) @@ -102,6 +104,13 @@ export function writeWslMoveCutover(userDataPath: string, cutover: WslMoveCutove if (!existing && parsed.rewrittenStores.length > 0) { throw new Error('wsl_move_ledger_changed') } + if (!existing && parsed.terminalRestoreHints !== undefined) { + throw new Error('wsl_move_restore_hints_changed') + } + if (!existing) { + // New moves only: a record already on disk must stay resumable whatever its size. + assertWslMoveManifestFits(parsed.manifest) + } if (existing) { assertWslMoveUpdate(existing, parsed, head) } else if (!head) { @@ -142,9 +151,43 @@ function assertWslMoveUpdate( throw new Error('wsl_move_superseded') } assertWslMoveLedgerUpdate(existing, next) + assertWslMoveRestoreHintsUpdate(existing, next) + assertWslMoveAutomationsUpdate(existing, next) } -/** Append-only during destination-committed; leaving requires every store in a complete registry. */ +/** Written once, with the `quiesced` record that captured them, and never changed after. */ +/** Part of the move's binding: written with the record and never changed. */ +function assertWslMoveAutomationsUpdate(existing: WslMoveCutover, next: WslMoveCutover): void { + if ( + JSON.stringify(existing.automationsToReenable) !== JSON.stringify(next.automationsToReenable) + ) { + throw new Error('wsl_move_binding_changed') + } +} + +function assertWslMoveRestoreHintsUpdate(existing: WslMoveCutover, next: WslMoveCutover): void { + const before = existing.terminalRestoreHints + const after = next.terminalRestoreHints + if (before === undefined && after === undefined) { + return + } + if (before !== undefined) { + if (JSON.stringify(before) !== JSON.stringify(after)) { + throw new Error('wsl_move_restore_hints_changed') + } + return + } + if (existing.phase !== 'source-fenced' || next.phase !== 'quiesced') { + throw new Error('wsl_move_restore_hints_changed') + } +} + +/** + * The rewrite ledger, and the seeded host workspaces beside it, only grow, and only while the + * desktop rewrites (`destination-committed`): + * append-only and without duplicates, so a stale writer cannot drop another's entry. Leaving that + * phase needs a complete registry and every registered store recorded. + */ function assertWslMoveLedgerUpdate(existing: WslMoveCutover, next: WslMoveCutover): void { const before = existing.rewrittenStores const after = next.rewrittenStores @@ -158,6 +201,15 @@ function assertWslMoveLedgerUpdate(existing: WslMoveCutover, next: WslMoveCutove ) { throw new Error('wsl_move_ledger_changed') } + const seededBefore = existing.seededHostWorkspaceIds ?? [] + const seededAfter = new Set(next.seededHostWorkspaceIds ?? []) + if ( + !seededBefore.every((id) => seededAfter.has(id)) || + (seededAfter.size > seededBefore.length && + (existing.phase !== 'destination-committed' || next.phase !== 'destination-committed')) + ) { + throw new Error('wsl_move_ledger_changed') + } if ( existing.phase === 'destination-committed' && next.phase === 'client-rewritten' && @@ -186,5 +238,58 @@ export function removeWslMoveCutover(userDataPath: string, migrationId: string): ) { throw new Error('wsl_move_in_flight') } - journals.remove(userDataPath, migrationId) + removeNewestInChain(userDataPath, record) +} + +/** + * Only the newest move of a distro goes: a later move names this one as what it supersedes, so + * removing it would break the chain and leave every project of that distro unreadable. + */ +function removeNewestInChain(userDataPath: string, record: WslMoveCutover | undefined): void { + if (!record) { + return + } + if ( + listWslMoveCutoverChain(userDataPath, record.journalKey).at(-1)?.migrationId !== + record.migrationId + ) { + throw new Error('wsl_move_superseded') + } + journals.remove(userDataPath, record.migrationId) +} + +/** + * Resets a move that never reached `destination-committed`; the caller has proved the destination + * holds no commit of it (wsl-move-reset). Its fence and session freeze lift with the record. + */ +export function removeUncommittedWslMoveCutover(userDataPath: string, migrationId: string): void { + const record = listWslMoveCutovers(userDataPath).find( + (entry) => entry.migrationId === migrationId + ) + if ( + record && + WSL_MOVE_CUTOVER_PHASES.indexOf(record.phase) >= + WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') + ) { + throw new Error('wsl_move_committed') + } + removeNewestInChain(userDataPath, record) +} + +/** + * "Use on this computer again": drops a committed move so its retained source rows show again as + * they were before it. Its fence, retained marks (reconcile) and stale-window re-apply end with it. + */ +export function removeCommittedWslMoveCutover(userDataPath: string, migrationId: string): void { + const record = listWslMoveCutovers(userDataPath).find( + (entry) => entry.migrationId === migrationId + ) + if ( + record && + WSL_MOVE_CUTOVER_PHASES.indexOf(record.phase) < + WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') + ) { + throw new Error('wsl_move_not_committed') + } + removeNewestInChain(userDataPath, record) } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-delta-plan.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-delta-plan.test.ts new file mode 100644 index 00000000000..69de96a4d83 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-delta-plan.test.ts @@ -0,0 +1,305 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { Repo } from '../../../shared/repo-types' +import { computeOrcadMigrationManifestSha256 } from '../../orcad/orcad-migration-manifest-digest' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { listWslMoveCutoverChain, removeWslMoveCutover } from './wsl-move-cutover-journal' +import { journalWslMove, planWslMove, type WslMovePlan } from './wsl-move-delta-plan' + +const UNC = '\\\\wsl.localhost\\Ubuntu' +const repo = (id: string, extra: Partial = {}): Repo => ({ + id, + path: `${UNC}\\home\\dev\\${id}`, + displayName: id, + badgeColor: '#000', + addedAt: 0, + ...extra +}) + +const NO_HOST = { repoIds: [], folderWorkspaceIds: [], highestGeneration: 0 } + +let dir: string +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'wsl-move-delta-plan-')) +}) +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function state(repos: Repo[], folderWorkspaces: FolderWorkspace[] = []) { + return { repos, folderWorkspaces, projectGroups: [] } +} + +/** The fixture's manifest for a scope and generation, re-signed. */ +function moveFor(migrationId: string, plan: Pick) { + const base = wslMoveCutoverFixture(migrationId, 'prepared', {}, { repoIds: plan.repoIds }) + const { manifestSha256: _unsigned, ...unsigned } = base.manifest + const resigned = { + ...unsigned, + source: { ...unsigned.source, generation: plan.generation } + } + return { ...resigned, manifestSha256: computeOrcadMigrationManifestSha256(resigned) } +} + +/** A finished move of `repoIds`, as a journal record. */ +function retainedMove( + migrationId: string, + repoIds: string[], + generation: number, + supersedesMigrationId?: string +) { + const manifest = moveFor(migrationId, { repoIds, generation }) + return { + ...wslMoveCutoverFixture( + migrationId, + 'retained', + supersedesMigrationId ? { supersedesMigrationId } : {} + ), + manifest, + manifestSha256: manifest.manifestSha256 + } +} + +function ready(result: ReturnType): WslMovePlan { + if (result.outcome !== 'ready') { + throw new Error(result.code) + } + return result.plan +} + +describe('planWslMove', () => { + it("plans the distro's first move with the selected projects, then journals it", () => { + const plan = ready( + planWslMove(dir, state([repo('repo-a'), repo('repo-b')]), 'Ubuntu', NO_HOST, { + repoIds: ['repo-a'], + folderWorkspaceIds: [] + }) + ) + expect(plan).toMatchObject({ repoIds: ['repo-a'], generation: 1, changed: [], removed: [] }) + expect(plan.supersedesMigrationId).toBeUndefined() + + journalWslMove({ + userDataPath: dir, + plan, + manifest: moveFor('move-1', plan), + destinationName: 'Ubuntu' + }) + + expect(listWslMoveCutoverChain(dir, plan.journalKey).map((entry) => entry.phase)).toEqual([ + 'prepared' + ]) + }) + + it('chains a delta move that takes only the new project', () => { + seedWslMoveCutover(dir, retainedMove('move-1', ['repo-a'], 1)) + + const plan = ready(planWslMove(dir, state([repo('repo-a'), repo('repo-b')]), 'ubuntu', NO_HOST)) + expect(plan).toMatchObject({ + repoIds: ['repo-b'], + supersedesMigrationId: 'move-1', + destinationEnvironmentId: 'wsl:Ubuntu', + generation: 2 + }) + journalWslMove({ + userDataPath: dir, + plan, + manifest: moveFor('move-2', plan), + destinationName: 'Ubuntu' + }) + seedWslMoveCutover(dir, { + ...listWslMoveCutoverChain(dir, plan.journalKey)[1], + phase: 'retained' + }) + + const chain = listWslMoveCutoverChain(dir, plan.journalKey) + expect(chain.map((entry) => [entry.migrationId, entry.supersedesMigrationId])).toEqual([ + ['move-1', undefined], + ['move-2', 'move-1'] + ]) + // A third move finds nothing left to take. + expect(planWslMove(dir, state([repo('repo-a'), repo('repo-b')]), 'Ubuntu', NO_HOST)).toEqual({ + outcome: 'refused', + code: 'wsl_move_nothing_new' + }) + }) + + it('refuses a second move while one of the distro is in flight', () => { + const rows = state([repo('repo-a'), repo('repo-b')]) + const first = ready( + planWslMove(dir, rows, 'Ubuntu', NO_HOST, { repoIds: ['repo-a'], folderWorkspaceIds: [] }) + ) + const second = ready( + planWslMove(dir, rows, 'Ubuntu', NO_HOST, { repoIds: ['repo-b'], folderWorkspaceIds: [] }) + ) + journalWslMove({ + userDataPath: dir, + plan: first, + manifest: moveFor('move-1', first), + destinationName: 'Ubuntu' + }) + + expect(planWslMove(dir, rows, 'Ubuntu', NO_HOST)).toEqual({ + outcome: 'refused', + code: 'wsl_move_already_in_flight' + }) + // A plan made before the first journaled loses at the journal's own check. + expect(() => + journalWslMove({ + userDataPath: dir, + plan: second, + manifest: moveFor('move-2', second), + destinationName: 'Ubuntu' + }) + ).toThrow('wsl_move_already_in_flight') + }) + + it('refuses a delta planned against a head that has since moved on', () => { + seedWslMoveCutover(dir, retainedMove('move-1', ['repo-a'], 1)) + const stale = ready( + planWslMove(dir, state([repo('repo-a'), repo('repo-b')]), 'Ubuntu', NO_HOST) + ) + seedWslMoveCutover(dir, retainedMove('move-2', ['repo-c'], 2, 'move-1')) + + expect(() => + journalWslMove({ + userDataPath: dir, + plan: stale, + manifest: moveFor('move-3', stale), + destinationName: 'Ubuntu' + }) + ).toThrow('wsl_move_must_extend_its_chain') + }) + + it('lists, but never takes again, a moved project an older build changed or removed', () => { + seedWslMoveCutover( + dir, + wslMoveCutoverFixture('move-1', 'retained', {}, { repoIds: ['repo-a', 'gone'] }) + ) + const result = planWslMove( + dir, + state([repo('repo-a', { displayName: 'renamed' }), repo('repo-b')]), + 'Ubuntu', + NO_HOST + ) + expect(ready(result)).toMatchObject({ + repoIds: ['repo-b'], + changed: [{ kind: 'repo', id: 'repo-a' }], + removed: [{ kind: 'repo', id: 'gone' }] + }) + }) + + it('ignores SSH rows and other hosts that share a moved id', () => { + seedWslMoveCutover(dir, retainedMove('move-1', ['repo-a'], 1)) + const rows = state([ + repo('repo-a'), + repo('repo-a', { path: '/home/dev/repo-a', executionHostId: 'runtime:wsl-ubuntu' }), + repo('repo-a', { connectionId: 'ssh-1', displayName: 'remote' }) + ]) + expect(planWslMove(dir, rows, 'Ubuntu', NO_HOST)).toEqual({ + outcome: 'refused', + code: 'wsl_move_nothing_new' + }) + }) + + it('refuses a manifest that is not the plan', () => { + const plan = ready(planWslMove(dir, state([repo('repo-a'), repo('repo-b')]), 'Ubuntu', NO_HOST)) + expect(() => + journalWslMove({ + userDataPath: dir, + plan, + manifest: moveFor('move-1', { ...plan, repoIds: ['repo-a'] }), + destinationName: 'Ubuntu' + }) + ).toThrow('wsl_move_manifest_not_planned') + }) + + it('never takes again what the host still holds after its move was dropped', () => { + seedWslMoveCutover(dir, retainedMove('move-1', ['repo-a'], 1)) + seedWslMoveCutover(dir, retainedMove('move-2', ['repo-b'], 2, 'move-1')) + // What "Use on this computer again" does: the host keeps repo-b and its generation. + removeWslMoveCutover(dir, 'move-2') + + const plan = ready( + planWslMove(dir, state([repo('repo-a'), repo('repo-b'), repo('repo-c')]), 'Ubuntu', { + repoIds: ['repo-a', 'repo-b'], + folderWorkspaceIds: [], + highestGeneration: 2 + }) + ) + expect(plan).toMatchObject({ + repoIds: ['repo-c'], + heldByHost: [{ kind: 'repo', id: 'repo-b' }], + supersedesMigrationId: 'move-1', + generation: 3 + }) + }) + + it("lists a project another distro's host owns instead of taking it twice", () => { + seedWslMoveCutover(dir, retainedMove('move-1', ['repo-a'], 1)) + const debian = (id: string) => repo(id, { path: `\\\\wsl.localhost\\Debian\\src\\${id}` }) + const result = planWslMove(dir, state([debian('repo-a'), debian('repo-d')]), 'Debian', NO_HOST) + expect(ready(result)).toMatchObject({ + repoIds: ['repo-d'], + changed: [{ kind: 'repo', id: 'repo-a' }] + }) + }) + + it('refuses a manifest whose scope doubles an id', () => { + const plan = ready(planWslMove(dir, state([repo('repo-a'), repo('repo-b')]), 'Ubuntu', NO_HOST)) + expect(() => + journalWslMove({ + userDataPath: dir, + plan: { ...plan, repoIds: ['repo-a', 'repo-a'] }, + manifest: moveFor('move-1', plan), + destinationName: 'Ubuntu' + }) + ).toThrow('wsl_move_manifest_not_planned') + }) + + it('lists a moved project whose path an older build took out of the distro as changed', () => { + seedWslMoveCutover(dir, retainedMove('move-1', ['repo-a', 'repo-m'], 1)) + const rows = state([ + repo('repo-a', { path: 'C:\\src\\repo-a' }), + // Partly on Windows now: refused by the scope rule, but still here. + repo('repo-m', { worktreeBasePath: 'C:\\trees' }), + repo('repo-b') + ]) + const plan = ready(planWslMove(dir, rows, 'Ubuntu', NO_HOST)) + expect(plan.repoIds).toEqual(['repo-b']) + expect(plan.removed).toEqual([]) + expect(plan.changed).toEqual([ + { kind: 'repo', id: 'repo-a' }, + { kind: 'repo', id: 'repo-m' } + ]) + expect(plan.refused).toEqual([{ kind: 'repo', id: 'repo-m', reason: 'mixed-locations' }]) + }) + + it('takes only the ticked projects and folder workspaces', () => { + const folder = { + id: 'notes', + projectGroupId: 'group', + name: 'notes', + folderPath: `${UNC}\\srv\\notes`, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + const plan = ready( + planWslMove(dir, state([repo('repo-a'), repo('repo-b')], [folder]), 'Ubuntu', NO_HOST, { + repoIds: ['repo-b'], + folderWorkspaceIds: ['notes'] + }) + ) + expect([plan.repoIds, plan.folderWorkspaceIds]).toEqual([['repo-b'], ['notes']]) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-delta-plan.ts b/src/main/persistence/moving-wsl-projects/wsl-move-delta-plan.ts new file mode 100644 index 00000000000..2865e016e33 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-delta-plan.ts @@ -0,0 +1,254 @@ +/** + * What a move into a distro's host takes (mover design, sections 4.3 and 5): the first move takes + * the selected projects; every later one is a delta move, a new journal record chained by + * `supersedesMigrationId`, that takes only projects no earlier move of the distro took. One move + * per distro is in flight at a time. + * + * A project an earlier move took and an older build has since changed is listed, not taken: the + * host already holds it under the same ids and refuses a second copy, so the change stays in the + * retained row (as SSH's delta does; see `planOrcadDeltaMove`). The same goes for a project the + * host still holds after its move was dropped from the journal. + */ +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest' +import { isOrcadMigrationLocalWslSource } from '../../../shared/orcad-migration/orcad-migration-source-kind' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { Repo } from '../../../shared/repo-types' +import { + WSL_MOVE_CUTOVER_PHASES, + WSL_MOVE_CUTOVER_VERSION, + isWslMoveCutoverFinished, + wslMoveJournalKey, + type WslMoveCutover +} from '../../../shared/wsl-move/wsl-move-cutover' +import { + listWslMoveCutoverChain, + scanWslMoveCutovers, + writeWslMoveCutover +} from './wsl-move-cutover-journal' +import { + wslMoveLocalRowIdentity, + wslMoveRowKey, + wslMoveScopeRefs, + wslMoveTakenRowIdentity, + type WslMoveRowRef +} from './wsl-move-retained-source' +import { collectWslMoveScope, type WslMoveScope } from './wsl-move-scope' +import { isLocalWslMoveRow } from './wsl-move-scope-guard' + +type WslMovePlanState = { + repos: readonly Repo[] + folderWorkspaces: readonly FolderWorkspace[] + projectGroups: readonly ProjectGroup[] +} + +/** What the destination host holds now, read before planning: the journal alone can't say. */ +export type WslMoveHostHolding = { + repoIds: readonly string[] + folderWorkspaceIds: readonly string[] + /** The highest source generation it has received for this distro; 0 for none. */ + highestGeneration: number +} + +export type WslMovePlanRefusal = + /** Another move of this distro has not finished; it resumes or resets first. */ + | 'wsl_move_already_in_flight' + /** Every selected project is already on the host, unchanged or not. */ + | 'wsl_move_nothing_new' + +export type WslMovePlan = { + distro: string + journalKey: string + /** The finished move this one extends; absent for the distro's first move. */ + supersedesMigrationId?: string + /** A delta goes to the host the chain already uses. */ + destinationEnvironmentId?: string + generation: number + repoIds: string[] + folderWorkspaceIds: string[] + /** Taken earlier, then changed by an older build: listed for the user, not moved again. */ + changed: WslMoveRowRef[] + /** Taken earlier, and no longer in this profile; the host keeps its copy. */ + removed: WslMoveRowRef[] + /** On the host with no move to show for it, e.g. after "Use on this computer again". */ + heldByHost: WslMoveRowRef[] + refused: WslMoveScope['refused'] +} + +export type WslMovePlanResult = + | { outcome: 'ready'; plan: WslMovePlan } + | { outcome: 'refused'; code: WslMovePlanRefusal } + +const COMMITTED_FROM = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') + +const refsOf = (scope: { + repoIds: readonly string[] + folderWorkspaceIds: readonly string[] +}): WslMoveRowRef[] => [ + ...scope.repoIds.map((id) => ({ kind: 'repo' as const, id })), + ...scope.folderWorkspaceIds.map((id) => ({ kind: 'folder' as const, id })) +] + +/** Throws only when the journal cannot be read. */ +export function planWslMove( + userDataPath: string, + state: WslMovePlanState, + distro: string, + host: WslMoveHostHolding, + /** The projects the user ticked; absent means the whole distro. */ + selection?: { repoIds: readonly string[]; folderWorkspaceIds: readonly string[] } +): WslMovePlanResult { + const journalKey = wslMoveJournalKey(distro) + const chain = listWslMoveCutoverChain(userDataPath, journalKey) + const head = chain.at(-1) + if (head && !isWslMoveCutoverFinished(head)) { + return { outcome: 'refused', code: 'wsl_move_already_in_flight' } + } + // Newest move per row: what the host holds for it now. + const taken = new Map }>() + for (const cutover of chain) { + for (const ref of wslMoveScopeRefs(cutover)) { + taken.set(wslMoveRowKey(ref), { ref, owner: cutover }) + } + } + // Another distro's host owns these, even if an older build has since pointed them here. + const ownedElsewhere = new Set( + scanWslMoveCutovers(userDataPath) + .filter( + (cutover) => + cutover.journalKey !== journalKey && + WSL_MOVE_CUTOVER_PHASES.indexOf(cutover.phase) >= COMMITTED_FROM + ) + .flatMap((cutover) => wslMoveScopeRefs(cutover).map(wslMoveRowKey)) + ) + const held = new Set(refsOf(host).map(wslMoveRowKey)) + const scope = collectWslMoveScope(state, distro) + const selected = (ref: WslMoveRowRef): boolean => + !selection || + (ref.kind === 'repo' ? selection.repoIds : selection.folderWorkspaceIds).includes(ref.id) + // Local rows only: the host's own row carries the same id. + const rows = new Map() + for (const repo of state.repos) { + if (isLocalWslMoveRow(repo)) { + rows.set(wslMoveRowKey({ kind: 'repo', id: repo.id }), repo) + } + } + for (const folder of state.folderWorkspaces) { + if (isLocalWslMoveRow(folder)) { + rows.set(wslMoveRowKey({ kind: 'folder', id: folder.id }), folder) + } + } + const candidates = refsOf(scope) + const inScope = new Set(candidates.map(wslMoveRowKey)) + const fresh: WslMoveRowRef[] = [] + const changed: WslMoveRowRef[] = [] + const heldByHost: WslMoveRowRef[] = [] + for (const ref of candidates) { + const key = wslMoveRowKey(ref) + if (taken.has(key)) { + continue + } + if (ownedElsewhere.has(key)) { + changed.push(ref) + } else if (held.has(key)) { + heldByHost.push(ref) + } else if (selected(ref)) { + fresh.push(ref) + } + } + const removed: WslMoveRowRef[] = [] + for (const [key, { ref, owner }] of taken) { + const row = rows.get(key) + if (!row) { + removed.push(ref) + } else if ( + // Out of scope now (a path left the distro), or another identity. + !inScope.has(key) || + wslMoveTakenRowIdentity(owner, ref) !== wslMoveLocalRowIdentity(row, distro) + ) { + changed.push(ref) + } + } + if (fresh.length === 0) { + return { outcome: 'refused', code: 'wsl_move_nothing_new' } + } + return { + outcome: 'ready', + plan: { + distro, + journalKey, + ...(head + ? { + supersedesMigrationId: head.migrationId, + destinationEnvironmentId: head.destinationEnvironmentId + } + : {}), + // Strictly increasing even past a dropped move, whose generation only the host remembers. + generation: Math.max(head?.manifest.source.generation ?? 0, host.highestGeneration) + 1, + repoIds: fresh.filter((ref) => ref.kind === 'repo').map((ref) => ref.id), + folderWorkspaceIds: fresh.filter((ref) => ref.kind === 'folder').map((ref) => ref.id), + changed, + removed, + heldByHost, + refused: scope.refused + } + } +} + +// Duplicates fail: a doubled id could hide a row the manifest carries but the journal never fences. +const sameIds = (left: readonly string[], right: readonly string[]): boolean => + new Set(left).size === left.length && + new Set(right).size === right.length && + left.length === right.length && + new Set([...left, ...right]).size === left.length + +/** + * Journals a planned move at `prepared`. The manifest must be exactly the plan's scope, and the + * journal refuses it if another move of the distro journaled or finished since the plan was made. + */ +export function journalWslMove(args: { + userDataPath: string + plan: WslMovePlan + manifest: OrcadMigrationManifest + destinationName: string + /** Carried disabled; the host turns them back on after commit. */ + automationsToReenable?: readonly string[] + now?: () => Date +}): WslMoveCutover { + const { plan, manifest } = args + const { source, destinationEnvironmentId } = manifest + if ( + !destinationEnvironmentId || + !isOrcadMigrationLocalWslSource(source) || + wslMoveJournalKey(source.distro) !== plan.journalKey || + source.generation !== plan.generation || + !sameIds(source.scopeRepoIds, plan.repoIds) || + !sameIds(source.scopeFolderWorkspaceIds, plan.folderWorkspaceIds) || + (plan.destinationEnvironmentId !== undefined && + destinationEnvironmentId !== plan.destinationEnvironmentId) + ) { + throw new Error('wsl_move_manifest_not_planned') + } + const timestamp = (args.now ?? (() => new Date()))().toISOString() + const cutover: WslMoveCutover = { + version: WSL_MOVE_CUTOVER_VERSION, + migrationId: manifest.migrationId, + phase: 'prepared', + startedAt: timestamp, + updatedAt: timestamp, + destinationEnvironmentId, + destinationName: args.destinationName, + journalKey: plan.journalKey, + manifestSha256: manifest.manifestSha256, + provenExitedSessionIds: [], + rewrittenStores: [], + ...(plan.supersedesMigrationId ? { supersedesMigrationId: plan.supersedesMigrationId } : {}), + ...(args.automationsToReenable?.length + ? { automationsToReenable: [...args.automationsToReenable] } + : {}), + manifest: { ...manifest, source } + } + // The journal re-checks under its own read: one in-flight move, extending the current head. + writeWslMoveCutover(args.userDataPath, cutover) + return cutover +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite-registry.ts b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite-registry.ts new file mode 100644 index 00000000000..d562548a068 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite-registry.ts @@ -0,0 +1,34 @@ +/** + * Every desktop rewrite store a committed WSL move runs, registered in run order. Startup calls it + * once, before the first rewrite pass; the coverage test checks it against the disposition tables. + */ +import type { WslMoveBrowserCookieCopyDependencies } from './wsl-move-browser-cookie-copy' +import { registerWslMoveBrowserCookieStore } from './wsl-move-browser-cookie-store' +import { registerWslMoveRendererRewriteStores } from './wsl-move-desktop-rewrite-stores' +import { + registerWslMoveHostAutomationsStore, + type WslMoveHostAutomations +} from './wsl-move-host-automations-rewrite' +import { + registerWslMoveMainRewriteStores, + type WslMoveMainRewriteProfile +} from './wsl-move-main-rewrite-stores' +import { registerWslMoveOrchestrationHistoryStore } from './wsl-move-orchestration-history-rewrite' + +export type WslMoveDesktopRewriteRegistryDependencies = { + profile: WslMoveMainRewriteProfile + orchestrationDatabasePath: string + hostAutomations: WslMoveHostAutomations + browserCookies: WslMoveBrowserCookieCopyDependencies +} + +export function registerWslMoveDesktopRewriteRegistry( + dependencies: WslMoveDesktopRewriteRegistryDependencies +): void { + registerWslMoveMainRewriteStores(dependencies.profile) + registerWslMoveOrchestrationHistoryStore(dependencies.orchestrationDatabasePath) + registerWslMoveRendererRewriteStores() + registerWslMoveHostAutomationsStore(dependencies.hostAutomations) + // Last among main-side stores: it throws until browser sessions exist, pausing any store after it. + registerWslMoveBrowserCookieStore(dependencies.browserCookies) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores.ts b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores.ts index 57418de8d09..c40679da108 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores.ts @@ -1,11 +1,14 @@ // Record each store only after durable rewrite so crashes retry the unfinished stores. import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import { WSL_MOVE_RENDERER_REWRITE_STORE_IDS } from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types' import type { OrcadMigrationPathRewriter } from '../../ssh/orcad-migration-path-rewrite' export type WslMoveDesktopRewriteContext = { cutover: Readonly rewritePath: OrcadMigrationPathRewriter destinationHostId: `runtime:${string}` + /** Durable before it returns; `cutover.seededHostWorkspaceIds` holds what earlier runs recorded. */ + recordSeededHostWorkspaceIds: (workspaceIds: readonly string[]) => void } export type WslMoveMainRewriteStore = { @@ -28,8 +31,8 @@ export type WslMoveDesktopRewriteStore = WslMoveMainRewriteStore | WslMoveRender const STORE_ID_PATTERN = /^[A-Za-z0-9:._-]{1,128}$/ const MAX_STORES = 256 -// Keep moves at destination-committed until all desktop stores are registered. -export const WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE: boolean = false +// Every desktop-rewrite disposition has its store (wsl-move-rewrite-store-coverage.test.ts proves it). +export const WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE: boolean = true let registryComplete = WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE @@ -59,6 +62,13 @@ export function registerWslMoveDesktopRewriteStore(store: WslMoveDesktopRewriteS stores.push(store) } +/** The renderer's localStorage stores; the renderer rewrites and records each. */ +export function registerWslMoveRendererRewriteStores(): void { + for (const id of WSL_MOVE_RENDERER_REWRITE_STORE_IDS) { + registerWslMoveDesktopRewriteStore({ id, side: 'renderer' }) + } +} + export function listWslMoveDesktopRewriteStores(): readonly WslMoveDesktopRewriteStore[] { return stores } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.test.ts index bcd0d962905..20e72bff968 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.test.ts @@ -3,6 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import type { WslMoveCutoverPhase } from '../../../shared/wsl-move/wsl-move-cutover' +import { WSL_MOVE_RENDERER_REWRITE_STORE_IDS } from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types' import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' import { listWslMoveCutovers, wslMoveJournalDirectory } from './wsl-move-cutover-journal' @@ -14,6 +16,7 @@ import { } from './wsl-move-desktop-rewrite' import { registerWslMoveDesktopRewriteStore, + registerWslMoveRendererRewriteStores, resetWslMoveDesktopRewriteStoresForTests, setWslMoveDesktopRewriteRegistryCompleteForTests, type WslMoveDesktopRewriteContext @@ -222,11 +225,38 @@ describe('WSL move renderer rewrites', () => { destinationHostId: 'runtime:wsl%3AUbuntu', distro: 'Ubuntu', scopeRepoIds: ['repo-1'], - scopeFolderWorkspaceIds: [] + scopeFolderWorkspaceIds: [], + scopeRootPaths: ['/home/dev/repo-1'] } ]) }) + it('lists a move from commit on for re-application, with nothing to record after its rewrite', () => { + // One move per distro at a time, so each phase gets its own journal. + const listedFor = (phase: WslMoveCutoverPhase) => { + rmSync(wslMoveJournalDirectory(userDataPath), { recursive: true, force: true }) + const rewrittenStores = + phase === 'blobs-transferred' ? [] : ['main-a', 'renderer-a', 'renderer-b'] + seedWslMoveCutover(userDataPath, cutover('move-1', phase, { rewrittenStores })) + return listPendingWslMoveRendererRewrites(userDataPath).map((move) => move.storeIds) + } + expect(listedFor('blobs-transferred')).toEqual([]) + expect(listedFor('client-rewritten')).toEqual([[]]) + expect(listedFor('retained')).toEqual([[]]) + }) + + it('writes the renderer storage to disk before the ledger entry, and not when refused', () => { + const flushed: string[][] = [] + const flush = () => { + flushed.push([...(record()?.rewrittenStores ?? [])]) + } + const request = { migrationId: 'move-1', storeId: 'renderer-a' } + recordWslMoveRendererStoreRewritten(userDataPath, request, flush) + recordWslMoveRendererStoreRewritten(userDataPath, request, flush) + expect(flushed).toEqual([[]]) + expect(record()?.rewrittenStores).toEqual(['renderer-a']) + }) + it('records a pending renderer store, and finishes the move with the last one', async () => { await runWslMoveDesktopRewritePass({ userDataPath }) expect( @@ -242,7 +272,7 @@ describe('WSL move renderer rewrites', () => { }) expect(record()?.rewrittenStores).toEqual(['main-a', 'renderer-a', 'renderer-b']) expect(record()?.phase).toBe('client-rewritten') - expect(listPendingWslMoveRendererRewrites(userDataPath)).toEqual([]) + expect(listPendingWslMoveRendererRewrites(userDataPath)).toMatchObject([{ storeIds: [] }]) }) it.each([ @@ -279,3 +309,13 @@ describe('WSL move renderer rewrites', () => { expect(record()?.rewrittenStores).toEqual([]) }) }) + +describe('WSL move renderer store registration', () => { + it('waits on every renderer store the renderer knows how to rewrite', () => { + registerWslMoveRendererRewriteStores() + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + expect(listPendingWslMoveRendererRewrites(userDataPath)[0]?.storeIds).toEqual([ + ...WSL_MOVE_RENDERER_REWRITE_STORE_IDS + ]) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.ts b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.ts index 6bcc398f24c..aaa47fbb0bf 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.ts @@ -1,6 +1,9 @@ // Persist each store before its ledger entry; restart retries only unrecorded rewrites. import { toRuntimeExecutionHostId } from '../../../shared/execution-host' -import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import { + WSL_MOVE_CUTOVER_PHASES, + type WslMoveCutover +} from '../../../shared/wsl-move/wsl-move-cutover' import type { WslMovePendingRendererRewrite, WslMoveRecordStoreRewrittenRequest, @@ -15,6 +18,8 @@ import { type WslMoveDesktopRewriteContext } from './wsl-move-desktop-rewrite-stores' import { createUncToLinuxPathRewriter } from './wsl-move-path-rewriter' +import { wslMoveScopeRootPaths } from './wsl-move-scope-roots' +import { reloadWslMoveWindowWithWritesHeld } from './wsl-move-window-write-hold' // One hung store would stall every serialized pass. export const WSL_MOVE_REWRITE_STORE_TIMEOUT_MS = 30_000 @@ -26,14 +31,42 @@ export type WslMoveDesktopRewritePassOptions = { warn?: (message: string) => void } -function rewriteContext(cutover: Readonly): WslMoveDesktopRewriteContext { +/** What each store is handed for a committed move; the re-apply after a stale window builds the same. */ +export function wslMoveDesktopRewriteContext( + userDataPath: string, + cutover: Readonly +): WslMoveDesktopRewriteContext { return { cutover, rewritePath: createUncToLinuxPathRewriter(cutover.manifest.source.distro), - destinationHostId: toRuntimeExecutionHostId(cutover.destinationEnvironmentId) + destinationHostId: toRuntimeExecutionHostId(cutover.destinationEnvironmentId), + recordSeededHostWorkspaceIds: (workspaceIds) => + recordSeededHostWorkspaces(userDataPath, cutover.migrationId, workspaceIds) } } +/** Through the journal's compare-and-swap, like a ledger entry: the record only grows. */ +function recordSeededHostWorkspaces( + userDataPath: string, + migrationId: string, + workspaceIds: readonly string[] +): void { + const current = readCommittedCutover(userDataPath, migrationId) + if (!current) { + throw new Error('wsl_move_record_missing') + } + const recorded = current.seededHostWorkspaceIds ?? [] + const added = workspaceIds.filter((id) => !recorded.includes(id)) + if (added.length === 0) { + return + } + writeWslMoveCutover(userDataPath, { + ...current, + seededHostWorkspaceIds: [...recorded, ...added], + updatedAt: new Date().toISOString() + }) +} + /** Read the current ledger so concurrent acknowledgements cannot be overwritten. */ function readCommittedCutover(userDataPath: string, migrationId: string): WslMoveCutover | null { const cutover = listWslMoveCutovers(userDataPath).find( @@ -101,7 +134,11 @@ async function rewriteMainStores( continue } const timeoutMs = options.storeTimeoutMs ?? WSL_MOVE_REWRITE_STORE_TIMEOUT_MS - await withStoreTimeout(() => store.run(rewriteContext(before)), timeoutMs, `${store.id} run`) + await withStoreTimeout( + () => store.run(wslMoveDesktopRewriteContext(userDataPath, before)), + timeoutMs, + `${store.id} run` + ) await withStoreTimeout(() => store.flush(), timeoutMs, `${store.id} flush`) const current = readCommittedCutover(userDataPath, migrationId) if (!current) { @@ -133,6 +170,8 @@ async function runPass(options: WslMoveDesktopRewritePassOptions): Promise warn(`[wsl-move] Desktop rewrite of move ${migrationId} paused: ${describe(error)}`) } } + // A store that rewrote window-owned state held the window; it reads the result from disk now. + reloadWslMoveWindowWithWritesHeld() } let passQueue: Promise = Promise.resolve() @@ -146,12 +185,20 @@ export function runWslMoveDesktopRewritePass( return next } +/** After a move commits: a no-op unless the internal WSL hosts gate is open. */ export function runGatedWslMoveDesktopRewritePass( options: WslMoveDesktopRewritePassOptions ): Promise { return wslHostsGateOpen() ? runWslMoveDesktopRewritePass(options) : Promise.resolve() } +const COMMITTED_PHASE_INDEX = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') + +/** + * Every committed move, for the renderer to re-apply its stores (its storage flushes lazily), with + * the renderer stores still to record while the move is in its rewrite phase. Empty with the gate + * closed. + */ export function listPendingWslMoveRendererRewrites( userDataPath: string ): WslMovePendingRendererRewrite[] { @@ -160,15 +207,15 @@ export function listPendingWslMoveRendererRewrites( } try { return listWslMoveCutovers(userDataPath).flatMap((cutover) => { - if (cutover.phase !== 'destination-committed') { - return [] - } - const storeIds = pendingWslMoveDesktopRewriteStores(cutover.rewrittenStores) - .filter((store) => store.side === 'renderer') - .map((store) => store.id) - if (storeIds.length === 0) { + if (WSL_MOVE_CUTOVER_PHASES.indexOf(cutover.phase) < COMMITTED_PHASE_INDEX) { return [] } + const storeIds = + cutover.phase === 'destination-committed' + ? pendingWslMoveDesktopRewriteStores(cutover.rewrittenStores) + .filter((store) => store.side === 'renderer') + .map((store) => store.id) + : [] const { source } = cutover.manifest return [ { @@ -178,7 +225,8 @@ export function listPendingWslMoveRendererRewrites( destinationHostId: toRuntimeExecutionHostId(cutover.destinationEnvironmentId), distro: source.distro, scopeRepoIds: [...source.scopeRepoIds], - scopeFolderWorkspaceIds: [...source.scopeFolderWorkspaceIds] + scopeFolderWorkspaceIds: [...source.scopeFolderWorkspaceIds], + scopeRootPaths: wslMoveScopeRootPaths(cutover.manifest) } ] }) @@ -191,7 +239,10 @@ export function listPendingWslMoveRendererRewrites( /** Record a durable renderer rewrite independently of main-store order. */ export function recordWslMoveRendererStoreRewritten( userDataPath: string, - request: WslMoveRecordStoreRewrittenRequest + request: WslMoveRecordStoreRewrittenRequest, + /** Asks for the renderer's storage on disk before the ledger entry. Best effort: it only schedules + * the write, so the renderer re-applies its stores at every startup regardless. */ + flushRendererStorage: () => void = () => {} ): WslMoveRecordStoreRewrittenResult { if (!wslHostsGateOpen()) { return { recorded: false, reason: 'gated-off' } @@ -208,6 +259,7 @@ export function recordWslMoveRendererStoreRewritten( if (!cutover || cutover.rewrittenStores.includes(store.id)) { return { recorded: false, reason: 'not-pending' } } + flushRendererStorage() appendLedgerEntry(userDataPath, cutover, store.id) } catch (error) { console.warn(`[wsl-move] Could not record a renderer rewrite: ${describe(error)}`) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-commit-reproof.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-commit-reproof.test.ts new file mode 100644 index 00000000000..b6cce8dc7d0 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-commit-reproof.test.ts @@ -0,0 +1,198 @@ +/** + * R46: the quiesce proves every terminal in scope exited, but the commit comes later. A terminal + * that started in between must be refused by the fence, or block the commit and reset the move. + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import { + createWslMoveWorld, + type WslMoveWorld +} from '../__fixtures__/wsl-mover/wsl-move-driver-world' +import { listWslMoveCutovers } from './wsl-move-cutover-journal' +import { + registerWslMoveDesktopRewriteStore, + resetWslMoveDesktopRewriteStoresForTests, + setWslMoveDesktopRewriteRegistryCompleteForTests +} from './wsl-move-desktop-rewrite-stores' +import { moveProjectsToWslHost } from './wsl-move-driver' +import { + proveWslMoveTerminalsExited, + type WslMoveExitProof, + type WslMoveExitProofScope +} from './wsl-move-exit-proof' +import { + isWslMoveCwdFenced, + loadWslMoveFence, + resetWslMoveFenceForTests +} from './wsl-move-scope-guard' + +let userDataPath: string +let world: WslMoveWorld + +beforeEach(() => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-reproof-')) + world = createWslMoveWorld(userDataPath) + loadWslMoveFence(userDataPath) + setWslMoveDesktopRewriteRegistryCompleteForTests(true) + registerWslMoveDesktopRewriteStore({ + id: 'ui-routing', + side: 'main', + run: () => {}, + flush: () => {} + }) +}) + +afterEach(() => { + vi.unstubAllEnvs() + resetWslMoveFenceForTests() + resetWslMoveDesktopRewriteStoresForTests() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +const request = { distro: 'Ubuntu' } + +/** The quiesce's proof answers `exited`; the next one answers `later`. */ +function proofsThen(later: WslMoveExitProof): WslMoveExitProofScope[] { + const scopes: WslMoveExitProofScope[] = [] + world.deps.quiesce.proveExited = async (scope) => { + scopes.push(scope) + world.points.push('prove-exited') + return scopes.length === 1 ? { verdict: 'exited' } : later + } + return scopes +} + +describe('the exit proof again before the commit', () => { + it('runs after the last blob and right before the commit, with the quiesce scope', async () => { + const scopes = proofsThen({ verdict: 'exited' }) + + expect(await moveProjectsToWslHost(world.deps, request)).toMatchObject({ outcome: 'moved' }) + expect(world.points.filter((point) => point === 'prove-exited')).toHaveLength(2) + expect(world.points.slice(-3, -1)).toEqual(['prove-exited', 'commit:before']) + expect(scopes[1]).toEqual(scopes[0]) + expect([...scopes[1].sessionIds]).toEqual(['session-1']) + }) + + it.each<[string, WslMoveExitProof, string]>([ + ['live', { verdict: 'live', sessionIds: ['bare-session'] }, 'terminals-started-after-pause'], + [ + 'unverifiable', + { verdict: 'unverifiable', reason: 'a daemon generation did not answer' }, + 'exit-unverifiable: a daemon generation did not answer' + ] + ])('blocks the commit and resets when a terminal reads %s', async (_label, proof, detail) => { + proofsThen(proof) + + const result = await moveProjectsToWslHost(world.deps, request) + + expect(result).toEqual({ outcome: 'refused', code: 'wsl_move_pause_failed', detail }) + expect(world.points).not.toContain('commit:before') + // Back to zero on both sides: the journal record is gone and the host's staging aborted. + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + expect([...world.catalog.values()]).toEqual([]) + expect(isWslMoveCwdFenced('/home/dev/app')).toBe(false) + }) + + it('resets without the host, since nothing asked it to commit yet', async () => { + proofsThen({ verdict: 'live', sessionIds: ['bare-session'] }) + const connect = world.deps.connectHost + world.deps.connectHost = async (distro) => { + const host = await connect(distro) + const readState = host?.catalog.readState + if (host && readState) { + // The host goes away once the blobs are sent; the reset must not wait for it. + host.catalog.readState = async (manifest) => { + if (world.points.filter((point) => point === 'prove-exited').length > 1) { + throw new Error('host unreachable') + } + return readState(manifest) + } + } + return host + } + + const result = await moveProjectsToWslHost(world.deps, request) + + expect(result).toMatchObject({ outcome: 'refused', code: 'wsl_move_pause_failed' }) + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + }) + + it('refuses a terminal with no workspace that starts in a moving folder after the quiesce', async () => { + let fencedAfterQuiesce: boolean[] = [] + world.deps.quiesce.proveExited = async () => { + world.points.push('prove-exited') + fencedAfterQuiesce = [ + isWslMoveCwdFenced('\\\\wsl.localhost\\Ubuntu\\home\\dev\\app\\src'), + isWslMoveCwdFenced('/home/dev/app'), + isWslMoveCwdFenced('\\\\wsl.localhost\\Ubuntu\\home\\dev\\other') + ] + return { verdict: 'exited' } + } + + expect(await moveProjectsToWslHost(world.deps, request)).toMatchObject({ outcome: 'moved' }) + // Read at the second proof, between the blobs and the commit request. + expect(fencedAfterQuiesce).toEqual([true, true, false]) + }) +}) + +// Users with several projects in one distro open terminals in the others while a move copies. +describe('the real exit proof, at the quiesce and again before the commit', () => { + type Census = { sessionId: string; cwd: string | null; wslDistro: string | null }[] + + /** The census answers `atQuiesce` first, then `beforeCommit`. */ + function census(atQuiesce: Census, beforeCommit: Census): void { + let calls = 0 + world.deps.quiesce.proveExited = (scope) => { + calls += 1 + const sessions = (calls === 1 ? atQuiesce : beforeCommit).map((session) => ({ + ...session, + isAlive: true + })) + return proveWslMoveTerminalsExited( + { + listSessions: async () => sessions, + countFallbackSessions: () => 0, + isScopeFenced: () => true + }, + scope + ) + } + } + + const otherProject = { sessionId: 'bare-1', cwd: '/home/dev/other', wslDistro: 'Ubuntu' } + + it('moves past terminals in other folders of the distro, open before and during the copy', async () => { + census( + [otherProject], + [otherProject, { ...otherProject, sessionId: 'bare-2', cwd: '/home/dev' }] + ) + + expect(await moveProjectsToWslHost(world.deps, request)).toMatchObject({ outcome: 'moved' }) + }) + + it.each<[string, Census[number], string]>([ + [ + 'in a moving folder', + { sessionId: 'bare-3', cwd: '/home/dev/app/src', wslDistro: 'Ubuntu' }, + 'terminals-started-after-pause' + ], + [ + 'with no folder to place it by', + { sessionId: 'bare-4', cwd: null, wslDistro: 'Ubuntu' }, + 'exit-unverifiable: live terminals with no known workspace: bare-4' + ] + ])('still resets for a terminal that starts %s during the copy', async (_l, session, detail) => { + census([otherProject], [otherProject, session]) + + expect(await moveProjectsToWslHost(world.deps, request)).toEqual({ + outcome: 'refused', + code: 'wsl_move_pause_failed', + detail + }) + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-fault-injection.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-fault-injection.test.ts new file mode 100644 index 00000000000..10e7818b498 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-fault-injection.test.ts @@ -0,0 +1,175 @@ +/** + * Fault injection over the real move driver and a real orcad profile store (an earlier + * pattern): the desktop is killed at every point a clean move passes (each journal write, each + * host call before it lands and after its reply is lost, each pause step, each desktop rewrite + * store), optionally with orcad restarted too. After the restart and the reconnect, the move is + * back at zero or fully moved, never in between; a second restart changes nothing; and a move + * reset to zero can be run again from scratch. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import { testState } from '../persistence-test-harness' +import { + createWslMoveKillRig, + removeWslMoveRigDirs, + restartWslMoveRig, + WSL_MOVE_RIG_REQUEST as request, + wslMoveRigOutcome as outcomeOf, + wslMoveRigRetentionStore as retentionStore, + type WslMoveRig, + type WslMoveRigOutcome as Outcome +} from '../__fixtures__/wsl-mover/wsl-move-kill-rig' +import type * as Journal from './wsl-move-cutover-journal' +import { resetWslMoveDesktopRewriteStoresForTests } from './wsl-move-desktop-rewrite-stores' +import { moveProjectsToWslHost } from './wsl-move-driver' +import { runWslMoveRetentionPass } from './wsl-move-retention' + +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: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 1 })) +})) + +// The journal's writes and removals are kill points too; the hook is set per test. +const journalKill = vi.hoisted(() => { + const hook: { at: ((name: string) => void) | null } = { at: null } + return hook +}) +vi.mock('./wsl-move-cutover-journal', async (importOriginal) => { + const actual = await importOriginal() + const around = ( + name: (...args: NoInfer) => string, + run: (...args: A) => void + ) => { + return (...args: A): void => { + journalKill.at?.(`${name(...args)}:before`) + run(...args) + journalKill.at?.(`${name(...args)}:after`) + } + } + return { + ...actual, + writeWslMoveCutover: around( + (_path: string, cutover: Parameters[1]) => + `journal:${cutover.phase}`, + actual.writeWslMoveCutover + ), + removeWslMoveCutover: around(() => 'journal:remove', actual.removeWslMoveCutover), + removeUncommittedWslMoveCutover: around( + () => 'journal:remove-uncommitted', + actual.removeUncommittedWslMoveCutover + ) + } +}) + +let rig: WslMoveRig + +function newRig(): WslMoveRig { + const next = createWslMoveKillRig() + journalKill.at = next.at + return next +} + +beforeEach(() => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + // A killed process's catch handlers warn about the calls that now throw. + vi.spyOn(console, 'warn').mockImplementation(() => {}) + rig = newRig() +}) + +afterEach(() => { + journalKill.at = null + vi.restoreAllMocks() + vi.unstubAllEnvs() + resetWslMoveDesktopRewriteStoresForTests() + removeWslMoveRigDirs() +}) + +async function killedMove(r: WslMoveRig, killAt: string): Promise { + r.kill.killAt = killAt + await moveProjectsToWslHost(r.world.deps, request).catch(() => {}) + // Retention runs on the journal change after the move, in the same process. + await runWslMoveRetentionPass({ userDataPath: r.userDataPath, store: retentionStore(r) }).catch( + () => {} + ) + expect(r.kill.dead, `kill point ${killAt} fired`).toBe(true) +} + +/** Every kill point a clean move passes, from a throwaway run. */ +async function killPoints(): Promise { + const probe = newRig() + const result = await moveProjectsToWslHost(probe.world.deps, request) + expect(result).toMatchObject({ outcome: 'moved' }) + await runWslMoveRetentionPass({ userDataPath: probe.userDataPath, store: retentionStore(probe) }) + return [...new Set(probe.kill.points)] +} + +describe('WSL move driver fault injection (real host store)', () => { + it('a clean move ends fully moved on a real host store', async () => { + expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({ + outcome: 'moved' + }) + await runWslMoveRetentionPass({ userDataPath: rig.userDataPath, store: retentionStore(rig) }) + expect(outcomeOf(rig)).toBe('fully-moved') + }) + + it.each([false, true])( + 'every kill point ends at zero or fully moved, and survives a second restart (orcad restarted: %s)', + { timeout: 120_000 }, + async (orcadRestarted) => { + const points = await killPoints() + expect(points.length).toBeGreaterThan(30) + const matrix: Record = {} + for (const killAt of points) { + const r = newRig() + await killedMove(r, killAt) + await restartWslMoveRig(r, { hostUp: false, orcadRestarted }) + const hostDown = outcomeOf(r) + await restartWslMoveRig(r, { hostUp: true, orcadRestarted }) + const hostUp = outcomeOf(r) + matrix[killAt] = { hostDown, hostUp } + await restartWslMoveRig(r, { hostUp: true, orcadRestarted }) + expect(outcomeOf(r), `${killAt}: a second restart changes nothing`).toBe(hostUp) + if (hostUp === 'back-at-zero') { + const again = await moveProjectsToWslHost(r.world.deps, request) + expect(again, `a fresh move after ${killAt}`).toMatchObject({ outcome: 'moved' }) + } + } + for (const [killAt, result] of Object.entries(matrix)) { + expect(['back-at-zero', 'fully-moved'], `${killAt} with the host up`).toContain( + result.hostUp + ) + // Without the host, only a possible commit waits; everything else resolves locally. + expect( + ['back-at-zero', 'zero-abort-owed', 'fully-moved', 'waiting-for-host'], + killAt + ).toContain(result.hostDown) + } + // The commit point: a kill before the commit lands resets; one after it moves forward. + expect(matrix['commit:before']).toEqual({ + hostDown: 'waiting-for-host', + hostUp: 'back-at-zero' + }) + expect(matrix['commit:lost-reply']).toEqual({ + hostDown: 'waiting-for-host', + hostUp: 'fully-moved' + }) + // A kill after the host staged: the desktop resets alone, and the stage goes on reconnect. + expect(matrix['journal:destination-staged:after']).toEqual({ + hostDown: 'zero-abort-owed', + hostUp: 'back-at-zero' + }) + expect(matrix['store:browser:run']).toEqual({ + hostDown: 'fully-moved', + hostUp: 'fully-moved' + }) + } + ) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-finish.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-finish.ts new file mode 100644 index 00000000000..d26bd32909e --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-finish.ts @@ -0,0 +1,34 @@ +import type { WslMoveResult } from '../../../shared/wsl-move/wsl-move-driver-types' +import { listWslMoveCutovers } from './wsl-move-cutover-journal' +import type { WslMoveDriverDeps } from './wsl-move-driver-ports' +import { wslMoveAutomationsPending } from './wsl-move-host-automations-rewrite' +import { settleWslMoveWindowWriteHoldForCommit } from './wsl-move-window-write-hold' + +/** A committed move's last step on this desktop, from the driver or a reset that found the commit. */ +export async function finishWslMove( + deps: WslMoveDriverDeps, + distro: string, + migrationId: string +): Promise { + deps.progress({ distro, step: 'finishing', migrationId, phase: 'destination-committed' }) + // Forward only from here: a failed rewrite resumes on the next pass or startup, never resets. + await deps.finishOnDesktop().catch((error: unknown) => { + console.warn('[wsl-move] Finishing the move on this desktop paused:', error) + }) + // Even after a paused rewrite: the window the commit request held must not keep its stale page. + settleWslMoveWindowWriteHoldForCommit(true) + const cutover = listWslMoveCutovers(deps.userDataPath).find( + (entry) => entry.migrationId === migrationId + ) + const phase = cutover?.phase ?? 'destination-committed' + // Never silently off: the result names them, and the rewrite pass keeps retrying them. + const automationsPending = cutover ? wslMoveAutomationsPending(cutover) : [] + deps.progress({ distro, step: 'done', migrationId, phase }) + return { + outcome: 'moved', + migrationId, + phase, + reloadRequired: true, + ...(automationsPending.length > 0 ? { automationsPending } : {}) + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-journal.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-journal.ts new file mode 100644 index 00000000000..ba3b2dca2b7 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-journal.ts @@ -0,0 +1,67 @@ +/** + * The driver's first durable step: export the slept scope and journal it as `prepared`. Nothing has + * reached the host yet, so every failure here is a refusal with its reason, never a put-back. + */ +import type { WslMoveRefusal } from '../../../shared/wsl-move/wsl-move-driver-types' +import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import { journalWslMove } from './wsl-move-delta-plan' +import type { WslMoveDriverDeps } from './wsl-move-driver-ports' +import { + exportRequestOf, + isWslMoveScopeChanged, + manifestTooLargeRefusal, + refusal, + type WslMovePrepared +} from './wsl-move-driver-plan' +import { resetWslMoveToZero } from './wsl-move-reset' +import { daemonHistoryLeafOf, type WslMoveSleptScrollback } from './wsl-move-slept-scrollback' + +export async function journalSleptWslMove( + deps: Pick, + prepared: WslMovePrepared, + migrationId: string, + startedAt: Date, + slept: WslMoveSleptScrollback +): Promise { + if (slept.problems.length > 0) { + return refusal('wsl_move_export_incomplete', { reasons: [...slept.problems] }) + } + try { + // The slept state, which the session freeze then holds for the rest of the move. + const exported = deps.exportManifest( + exportRequestOf(prepared, migrationId, startedAt, daemonHistoryLeafOf(slept)) + ) + if (exported.blockers.length > 0) { + return refusal('wsl_move_export_incomplete', { reasons: [...exported.blockers] }) + } + return journalWslMove({ + userDataPath: deps.userDataPath, + plan: prepared.plan, + manifest: exported.manifest, + destinationName: prepared.host.name, + automationsToReenable: exported.automationsToReenable, + now: () => startedAt + }) + } catch (error) { + // A write that landed before it threw would leave a record no host ever saw: drop it. + await resetWslMoveToZero( + { userDataPath: deps.userDataPath, destination: null }, + migrationId + ).catch(() => {}) + const tooLarge = manifestTooLargeRefusal(error) + if (tooLarge) { + return tooLarge + } + const message = error instanceof Error ? error.message : String(error) + if (message === 'wsl_move_already_in_flight') { + return refusal('wsl_move_already_in_flight') + } + if (isWslMoveScopeChanged(error)) { + return refusal('wsl_move_scope_changed') + } + console.warn('[wsl-move] Could not prepare the move:', error) + return refusal('wsl_move_export_incomplete', { + reasons: [`Orca could not prepare these projects' saved state (${message}).`] + }) + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-pause.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-pause.test.ts new file mode 100644 index 00000000000..e1242e4d054 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-pause.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { pauseWslMoveScope } from './wsl-move-driver-pause' +import type { WslMoveDriverDeps } from './wsl-move-driver-ports' + +const scope = { workspaceIds: ['wt'], workspaceKeys: new Set(['wt']) } +const pane = { paneKey: 'tab:leaf', sessionId: 'wt@@a', wslDistro: 'Ubuntu' } + +function deps(shellCwds?: WslMoveDriverDeps['shellCwds']): Parameters[0] { + return { + gateInputs: async () => ({ + panes: [{ paneKey: pane.paneKey, foreground: 'shell-idle', scrollbackBytes: 0 }], + runningAutomationRunIds: [], + activeDispatchIds: [] + }), + listPanes: () => [pane], + quiesce: { + sleepWorkspaces: vi.fn(async () => ({ failedWorktreeIds: [] })), + livePtyIds: () => [], + captureRestoreHints: vi.fn(), + proveExited: vi.fn(), + wait: async () => {} + }, + ...(shellCwds ? { shellCwds } : {}) + } +} + +describe('pauseWslMoveScope', () => { + // An open pane's `cd deep/er` was lost, since bash on the bridge reports no folder. + it('reads each live pane its shell folder before Sleep ends the shell', async () => { + const order: string[] = [] + const d = deps(async () => { + order.push('cwds') + return new Map([[pane.paneKey, '/home/dev/app/deep/er']]) + }) + d.quiesce.sleepWorkspaces = async () => { + order.push('sleep') + return { failedWorktreeIds: [] } + } + const paused = await pauseWslMoveScope(d, scope) + expect(order).toEqual(['cwds', 'sleep']) + expect(paused).toEqual({ panes: [{ ...pane, shellCwd: '/home/dev/app/deep/er' }] }) + }) + + it('still pauses when the folder read fails', async () => { + const paused = await pauseWslMoveScope( + deps(async () => { + throw new Error('distro gone') + }), + scope + ) + expect(paused).toEqual({ panes: [pane] }) + }) + + // A workspace that failed the first Sleep of a move refused it at once. + it('sleeps a failed workspace again before refusing', async () => { + const d = deps() + const asked: string[][] = [] + d.quiesce.sleepWorkspaces = async (ids) => { + asked.push([...ids]) + return { failedWorktreeIds: asked.length === 1 ? ['wt'] : [] } + } + expect(await pauseWslMoveScope(d, scope)).toEqual({ panes: [pane] }) + expect(asked).toEqual([['wt'], ['wt']]) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-pause.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-pause.ts new file mode 100644 index 00000000000..18e11c84a78 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-pause.ts @@ -0,0 +1,60 @@ +/** + * The first Sleep of a move, before its snapshot is taken: nothing is fenced yet, so the gate runs + * again right before it, and the panes it pauses are exactly the ones the gate just passed. A + * terminal opened in between refuses the move rather than being slept without its folder. + */ +import type { WslMoveRefusal } from '../../../shared/wsl-move/wsl-move-driver-types' +import type { WslMoveDriverDeps, WslMoveTerminalScope } from './wsl-move-driver-ports' +import { refusal } from './wsl-move-driver-plan' +import type { WslMoveRestoreHintPane } from './wsl-move-restore-hints' +import { assessWslMoveTerminals } from './wsl-move-terminal-gate' +import { withSleptSessionPanes } from './wsl-move-slept-scrollback' +import { sleepUntilPaused } from './wsl-move-quiesce' + +const sessionsOf = (panes: readonly WslMoveRestoreHintPane[]): string => + JSON.stringify(panes.map((pane) => pane.sessionId).sort()) + +export async function pauseWslMoveScope( + deps: Pick< + WslMoveDriverDeps, + 'gateInputs' | 'listPanes' | 'quiesce' | 'sleptSessionPanes' | 'shellCwds' + >, + scope: WslMoveTerminalScope, + /** The window's bindings of panes slept before the move, which main no longer holds. */ + reported: readonly WslMoveRestoreHintPane[] = [] +): Promise<{ panes: readonly WslMoveRestoreHintPane[] } | WslMoveRefusal> { + const before = deps.listPanes(scope) + const gate = assessWslMoveTerminals(await deps.gateInputs(scope)) + if (gate.verdict === 'blocked') { + return refusal('wsl_move_terminals_blocked', { blockers: gate.blockers }) + } + // Read before Sleep, since a slept pane no longer names its session; and unchanged since the gate. + const panes = deps.listPanes(scope) + if (sessionsOf(panes) !== sessionsOf(before)) { + return refusal('wsl_move_pause_failed', { detail: 'terminals-changed' }) + } + // Bash on the bridge reports no folder; once slept, its shell is gone and so is the folder. + const cwds = await (deps.shellCwds?.(panes) ?? Promise.resolve(new Map())).catch( + () => new Map() + ) + const withCwds = panes.map((pane) => { + const shellCwd = cwds.get(pane.paneKey) + return shellCwd ? { ...pane, shellCwd } : pane + }) + try { + const failedWorktreeIds = await sleepUntilPaused(deps.quiesce, scope.workspaceIds) + // Panes slept before the move too, so their folders reach the restore hints, not only history. + return failedWorktreeIds.length > 0 + ? refusal('wsl_move_pause_failed', { detail: 'sleep-failed' }) + : { + panes: withSleptSessionPanes( + withCwds, + deps.sleptSessionPanes?.(scope.workspaceIds, reported) ?? [] + ) + } + } catch (error) { + return refusal('wsl_move_pause_failed', { + detail: error instanceof Error ? error.message : String(error) + }) + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-plan.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-plan.ts new file mode 100644 index 00000000000..f69ac25af1a --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-plan.ts @@ -0,0 +1,243 @@ +/** + * Before anything is paused or fenced: the host, the plan (a full or delta move), the export and + * the terminal gate. The preview shows exactly this; the move re-runs it and starts only if it + * still passes. + */ +import type { WslMoveRefusal, WslMoveRequest } from '../../../shared/wsl-move/wsl-move-driver-types' +import { + WSL_MOVE_CUTOVER_PHASES, + wslMoveJournalKey +} from '../../../shared/wsl-move/wsl-move-cutover' +import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import { listWslMoveCutoverChain } from './wsl-move-cutover-journal' +import type { WslMovePlan } from './wsl-move-delta-plan' +import { planWslMove } from './wsl-move-delta-plan' +import type { + WslMoveDriverDeps, + WslMoveHost, + WslMoveManifestExport, + WslMoveManifestExportRequest, + WslMoveTerminalScope +} from './wsl-move-driver-ports' +import { assertWslMoveManifestFits } from './wsl-move-manifest-budget' +import { WslMoveScopeChangedError } from './wsl-move-manifest-export' +import { wslMoveHostReadiness } from './wsl-move-preflight' +import { settleWslMoveOwedHostAborts } from './wsl-move-owed-host-aborts' +import { resetWslMoveToZero } from './wsl-move-reset' +import { assessWslMoveTerminals, type WslMoveGateBlocker } from './wsl-move-terminal-gate' + +export const WSL_MOVE_MANIFEST_TOO_LARGE_PREFIX = 'wsl_move_manifest_too_large:' +// Thrown by manifest parsing past the absolute cap, before the journal's own budget can explain. +const MANIFEST_OVER_CAP = 'orcad_migration_manifest_too_large' +const MANIFEST_OVER_CAP_MESSAGE = + 'The saved state of these projects is too large to move in one step. Move fewer projects at a time, or close unused tabs and clear old terminal history first.' + +export type WslMovePrepared = { + host: WslMoveHost + plan: WslMovePlan + exported: WslMoveManifestExport + scope: WslMoveTerminalScope + blockers: WslMoveGateBlocker[] +} + +export function refusal( + code: WslMoveRefusal['code'], + extra: Omit = {} +): WslMoveRefusal { + return { outcome: 'refused', code, ...extra } +} + +/** The exporter's "a planned row is gone": the plan is stale, so the user plans again. */ +export function isWslMoveScopeChanged(error: unknown): boolean { + return error instanceof WslMoveScopeChangedError +} + +/** The too-large refusal carries its user-facing message after the code. */ +export function manifestTooLargeRefusal(error: unknown): WslMoveRefusal | null { + const message = error instanceof Error ? error.message : '' + if (message === MANIFEST_OVER_CAP) { + return refusal('wsl_move_manifest_too_large', { message: MANIFEST_OVER_CAP_MESSAGE }) + } + return message.startsWith(WSL_MOVE_MANIFEST_TOO_LARGE_PREFIX) + ? refusal('wsl_move_manifest_too_large', { + message: message.slice(WSL_MOVE_MANIFEST_TOO_LARGE_PREFIX.length) + }) + : null +} + +/** + * A move a crash or a lost host left short of commit (e.g. `blobs-transferred` with no host to + * ask at startup) keeps its projects fenced. With the host connected now, #29825's reset decides + * it: back to zero, or forward to the rewrite when the host committed. Only an answer it cannot + * get keeps the fence, and the new move waits. + */ +async function settleInterruptedWslMove( + deps: WslMoveDriverDeps, + host: WslMoveHost, + request: WslMoveRequest +): Promise { + const journalKey = wslMoveJournalKey(request.distro) + // Before this move stages: a leftover stage of an earlier reset would refuse it. + await settleWslMoveOwedHostAborts(deps.userDataPath, journalKey, host.catalog) + const head = listWslMoveCutoverChain(deps.userDataPath, journalKey).at(-1) + if ( + !head || + WSL_MOVE_CUTOVER_PHASES.indexOf(head.phase) >= + WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') + ) { + return null + } + const reset = await resetWslMoveToZero( + { userDataPath: deps.userDataPath, destination: host.catalog, now: deps.now }, + head.migrationId + ) + if (reset.outcome === 'unverifiable') { + return refusal('wsl_move_already_in_flight', { detail: reset.reason }) + } + if (reset.outcome === 'committed') { + await deps.finishOnDesktop().catch((error: unknown) => { + console.warn('[wsl-move] Finishing an interrupted move on this desktop paused:', error) + }) + // The window must reload before another move starts under it. + return refusal('wsl_move_finished_earlier_move') + } + return null +} + +/** + * A move left waiting on a host whose distro is now unregistered can never hear back: the commit, + * if any, went with the distro's disk. Back to zero, so the projects are usable here again. + */ +async function resetForUnregisteredDistro( + deps: WslMoveDriverDeps, + request: WslMoveRequest +): Promise { + const head = listWslMoveCutoverChain(deps.userDataPath, wslMoveJournalKey(request.distro)).at(-1) + if ( + !head || + WSL_MOVE_CUTOVER_PHASES.indexOf(head.phase) >= + WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') || + (await deps + .isDistroRegistered(request.distro, head.manifest.source.distroGuid) + .catch(() => null)) !== false + ) { + return false + } + const reset = await resetWslMoveToZero( + { + userDataPath: deps.userDataPath, + destination: null, + destinationUnregistered: true, + now: deps.now + }, + head.migrationId + ) + return reset.outcome === 'reset' +} + +/** Fixed id and clock, so a re-export of unchanged state signs the same digest. */ +export function exportRequestOf( + prepared: Pick, + migrationId: string, + startedAt: Date, + daemonHistoryLeaf?: (tabId: string, leafId: string) => boolean +): WslMoveManifestExportRequest { + const { plan, host } = prepared + return { + ...(daemonHistoryLeaf ? { daemonHistoryLeaf } : {}), + migrationId, + now: () => startedAt, + distro: plan.distro, + ...(host.distroGuid ? { distroGuid: host.distroGuid } : {}), + destinationEnvironmentId: host.environmentId, + generation: plan.generation, + repoIds: plan.repoIds, + folderWorkspaceIds: plan.folderWorkspaceIds + } +} + +export async function prepareWslMove( + deps: WslMoveDriverDeps, + request: WslMoveRequest, + migrationId: string, + startedAt: Date, + /** Move only: settle an earlier move of the distro an interruption left short of commit. */ + settleInterrupted = false +): Promise { + if (!wslHostsGateOpen()) { + return refusal('wsl_move_unavailable') + } + let host: WslMoveHost | null + try { + host = await deps.connectHost(request.distro) + } catch (error) { + // A kept host record whose distro is gone fails its connect rather than answering null. + if (settleInterrupted && (await resetForUnregisteredDistro(deps, request))) { + return refusal('wsl_move_host_unavailable', { detail: 'distro_unregistered' }) + } + // Nothing is recorded yet, so a host that will not connect is the user's to fix, not a failure. + console.warn('[wsl-move] The host did not connect:', error) + return refusal('wsl_move_host_unavailable', { detail: 'connect_failed' }) + } + if (!host) { + if (settleInterrupted && (await resetForUnregisteredDistro(deps, request))) { + return refusal('wsl_move_host_unavailable', { detail: 'distro_unregistered' }) + } + return refusal('wsl_move_host_unavailable') + } + if (wslMoveHostReadiness(host.capabilities) !== 'ready') { + return refusal('wsl_move_host_update_required') + } + const unsettled = settleInterrupted ? await settleInterruptedWslMove(deps, host, request) : null + if (unsettled) { + return unsettled + } + const selection = + request.repoIds || request.folderWorkspaceIds + ? { repoIds: request.repoIds ?? [], folderWorkspaceIds: request.folderWorkspaceIds ?? [] } + : undefined + const planned = planWslMove( + deps.userDataPath, + deps.readCatalog(), + request.distro, + await host.readHolding(), + selection + ) + if (planned.outcome === 'refused') { + return refusal(planned.code) + } + const { plan } = planned + // A delta goes to the host its chain already moved into; another host would split the projects. + if (plan.destinationEnvironmentId && plan.destinationEnvironmentId !== host.environmentId) { + return refusal('wsl_move_host_unavailable', { detail: 'destination_changed' }) + } + let exported: WslMoveManifestExport + try { + exported = deps.exportManifest(exportRequestOf({ plan, host }, migrationId, startedAt)) + if (exported.blockers.length > 0) { + return refusal('wsl_move_export_incomplete', { reasons: [...exported.blockers] }) + } + assertWslMoveManifestFits(exported.manifest) + } catch (error) { + const tooLarge = manifestTooLargeRefusal(error) + if (tooLarge) { + return tooLarge + } + if (isWslMoveScopeChanged(error)) { + return refusal('wsl_move_scope_changed') + } + throw error + } + const scope: WslMoveTerminalScope = { + workspaceIds: exported.workspaceIds, + workspaceKeys: new Set(exported.workspaceKeys) + } + const gate = assessWslMoveTerminals(await deps.gateInputs(scope)) + return { + host, + plan, + exported, + scope, + blockers: gate.verdict === 'blocked' ? gate.blockers : [] + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-ports.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-ports.ts new file mode 100644 index 00000000000..8b45486238e --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-ports.ts @@ -0,0 +1,120 @@ +/** + * What the WSL move driver needs from the rest of the app, as ports, so the whole run is testable + * with fakes and the production wiring stays in one place (wsl-move-driver-wiring). + */ +import type { OrcadMigrationBlobDescriptor } from '../../../shared/orcad-migration/orcad-migration-blob-transfer' +import type { + OrcadMigrationCatalogState, + OrcadMigrationManifest +} from '../../../shared/orcad-migration/orcad-migration-manifest' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { Repo } from '../../../shared/repo-types' +import type { WslMoveProgressEvent } from '../../../shared/wsl-move/wsl-move-driver-types' +import type { + OrcadMigrationBlobDestination, + OrcadMigrationBlobSource +} from '../../ssh/orcad-migration-blob-coordinator' +import type { OrcadMigrationDestinationCatalog } from '../../ssh/orcad-migration-cutover-coordinator' +import type { OrcadMigrationSnapshotSource } from '../../ssh/orcad-migration-snapshot-coordinator' +import type { WslMoveHostHolding } from './wsl-move-delta-plan' +import type { WslMoveQuiesceDeps } from './wsl-move-quiesce' +import type { WslMoveSleptScrollback } from './wsl-move-slept-scrollback' +import type { WslMoveRestoreHintPane } from './wsl-move-restore-hints' +import type { + WslMoveManifestExport, + WslMoveManifestExportRequest +} from './wsl-move-manifest-export' +import type { WslMoveGateInputs } from './wsl-move-terminal-gate' + +export type { + WslMoveManifestExport, + WslMoveManifestExportRequest +} from './wsl-move-manifest-export' + +/** Throws `WslMoveScopeChangedError` when a planned row is gone: plan again. */ +export type WslMoveManifestExporter = ( + request: WslMoveManifestExportRequest +) => WslMoveManifestExport + +/** The distro's WSL host, connected. */ +export type WslMoveHost = { + environmentId: string + name: string + /** The distro's registry id, which binds the move to this exact distro; absent when unknown. */ + distroGuid?: string + capabilities: readonly string[] + catalog: OrcadMigrationDestinationCatalog + /** Commit naming every blob sent, so the host refuses a commit missing one. */ + commitWithBlobs: ( + manifest: OrcadMigrationManifest, + expectedBlobs: readonly OrcadMigrationBlobDescriptor[] + ) => Promise + blobs: Pick + readHolding: () => Promise +} + +export type WslMoveCatalogRows = { + repos: readonly Repo[] + folderWorkspaces: readonly FolderWorkspace[] + projectGroups: readonly ProjectGroup[] +} + +export type WslMoveTerminalScope = { + workspaceIds: readonly string[] + workspaceKeys: ReadonlySet +} + +export type WslMoveDriverDeps = { + userDataPath: string + readCatalog: () => WslMoveCatalogRows + /** Null when the distro has no connected host. */ + connectHost: (distro: string) => Promise + /** + * From the distro registrations: false only when the name is gone and so is the recorded GUID + * (a rename keeps it, and the renamed host may hold the commit); null when unreadable. + */ + isDistroRegistered: (distro: string, distroGuid?: string) => Promise + exportManifest: WslMoveManifestExporter + /** Busy, working, automation and dispatch facts for the scope, from their owning stores. */ + gateInputs: (scope: WslMoveTerminalScope) => Promise + /** The panes the scope's live terminals belong to; read before Sleep ends their sessions. */ + listPanes: (scope: WslMoveTerminalScope) => readonly WslMoveRestoreHintPane[] + /** + * Panes slept before the move: bindings the saved session or the window still holds, checked + * against the scope. Their checkpoints give the folder the restore hints carry. + */ + sleptSessionPanes?: ( + workspaceIds: readonly string[], + reported: readonly WslMoveRestoreHintPane[] + ) => readonly WslMoveRestoreHintPane[] + /** Live bridge panes' shell folders by pane key, read in their distro; absent off Windows. */ + shellCwds?: (panes: readonly WslMoveRestoreHintPane[]) => Promise> + quiesce: Pick< + WslMoveQuiesceDeps, + 'sleepWorkspaces' | 'livePtyIds' | 'captureRestoreHints' | 'proveExited' | 'wait' + > + snapshots: OrcadMigrationSnapshotSource + /** + * After Sleep, each paused pane's daemon checkpoint as the buffer a terminal restores from. A + * slept pane has no renderer snapshot, so this is its only scrollback. + */ + sleptScrollback?: ( + panes: readonly WslMoveRestoreHintPane[], + distro: string, + migrationId: string, + /** The scope's workspaces, for panes slept before the move (no live terminal to list). */ + workspaceIds: readonly string[], + /** The window's bindings of slept panes, which main no longer holds; checked before use. */ + reported: readonly WslMoveRestoreHintPane[] + ) => Promise + shellHistory: ( + distro: string, + worktrees: WslMoveManifestExport['worktrees'] + ) => Promise<{ descriptors: OrcadMigrationBlobDescriptor[]; source: OrcadMigrationBlobSource }> + /** After commit: rewrite this desktop's main-process stores; the renderer then reloads. */ + finishOnDesktop: () => Promise + progress: (event: WslMoveProgressEvent) => void + newMigrationId: () => string + now: () => Date +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-run-end.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-run-end.ts new file mode 100644 index 00000000000..821bd3fcee5 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-run-end.ts @@ -0,0 +1,29 @@ +/** + * How a run ended, for a window that only follows its progress (it reloaded mid-move, or the move + * started elsewhere). A committed run says so with `done`; without this, any other end left that + * window's dialog on its last step until a reload. + */ +import type { + WslMoveProgressStep, + WslMoveResult +} from '../../../shared/wsl-move/wsl-move-driver-types' +import type { WslMoveDriverDeps } from './wsl-move-driver-ports' + +export async function reportWslMoveRunEnd( + deps: WslMoveDriverDeps, + distro: string, + run: (tracked: WslMoveDriverDeps) => Promise +): Promise { + let lastStep: WslMoveProgressStep = 'preparing' + const result = await run({ + ...deps, + progress: (event) => { + lastStep = event.step + deps.progress(event) + } + }) + if (result.outcome !== 'moved') { + deps.progress({ distro, step: lastStep, result }) + } + return result +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-transfer.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-transfer.ts new file mode 100644 index 00000000000..973edc3a269 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-transfer.ts @@ -0,0 +1,146 @@ +/** + * The host half of a WSL move, from `quiesced` to `destination-committed`: stage the catalog, send + * the scrollback snapshots and blobs, then ask for the commit. Each step is journaled before the + * next starts, and the commit is requested only from `blobs-transferred` (#29825's contract). + */ +import type { OrcadMigrationBlobDescriptor } from '../../../shared/orcad-migration/orcad-migration-blob-transfer' +import type { OrcadMigrationCatalogState } from '../../../shared/orcad-migration/orcad-migration-manifest' +import type { WslMoveCutover, WslMoveCutoverPhase } from '../../../shared/wsl-move/wsl-move-cutover' +import { + transferOrcadMigrationBlobs, + type OrcadMigrationBlobSource +} from '../../ssh/orcad-migration-blob-coordinator' +import { resolveDurableOrcadCatalogMutation } from '../../ssh/orcad-catalog-durable-mutation' +import { transferOrcadMigrationSnapshots } from '../../ssh/orcad-migration-snapshot-coordinator' +import { writeWslMoveCutover } from './wsl-move-cutover-journal' +import type { WslMoveDriverDeps, WslMoveHost, WslMoveManifestExport } from './wsl-move-driver-ports' +import { assertWslMoveCommitRequestAllowed } from './wsl-move-reset' +import { + wslMoveTerminalHistoryBlobs, + type WslMoveSleptScrollback +} from './wsl-move-slept-scrollback' + +type TransferDeps = Pick + +/** One phase forward through the journal's compare-and-swap; durable before it returns. */ +export function advanceWslMove( + deps: Pick, + cutover: WslMoveCutover, + phase: WslMoveCutoverPhase +): WslMoveCutover { + const next: WslMoveCutover = { ...cutover, phase, updatedAt: deps.now().toISOString() } + writeWslMoveCutover(deps.userDataPath, next) + return next +} + +/** The move's bytes as progress shows them: the terminal history sent while staging, then the blobs. */ +export function wslMoveCopyBytes( + quiesced: Pick, + blobs: readonly Pick[] +): { snapshotBytes: number; bytesTotal: number } { + const snapshots = quiesced.manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] + const snapshotBytes = snapshots.reduce((sum, snapshot) => sum + snapshot.byteLength, 0) + return { + snapshotBytes, + bytesTotal: snapshotBytes + blobs.reduce((sum, blob) => sum + blob.byteLength, 0) + } +} + +export async function stageWslMove( + deps: TransferDeps, + host: WslMoveHost, + quiesced: WslMoveCutover +): Promise { + const { catalog } = host + const state = await resolveDurableOrcadCatalogMutation( + () => catalog.stage(quiesced.manifest), + () => catalog.readState(quiesced.manifest), + (observed) => observed.state !== 'absent' + ) + // Nothing asked for a commit yet, so anything but a stage of this manifest is not ours. + if (state.state !== 'staged') { + throw new Error(`wsl_move_stage_not_staged:${state.state}`) + } + assertSameMigration(quiesced, state) + const staged = advanceWslMove(deps, quiesced, 'destination-staged') + await transferOrcadMigrationSnapshots({ + source: deps.snapshots, + manifest: staged.manifest, + state, + destination: { readState: catalog.readState, stageChunk: catalog.stageChunk } + }) + return staged +} + +/** Each pane's own checkpoint (history and folder) as a blob, plus the shells' history files. */ +export async function collectWslMoveBlobs( + deps: TransferDeps, + cutover: WslMoveCutover, + worktrees: WslMoveManifestExport['worktrees'], + slept: WslMoveSleptScrollback +): Promise<{ descriptors: OrcadMigrationBlobDescriptor[]; source: OrcadMigrationBlobSource }> { + const panes = wslMoveTerminalHistoryBlobs(slept, cutover.manifest) + const history = await deps.shellHistory(cutover.manifest.source.distro, worktrees) + const descriptors = [...panes.descriptors, ...history.descriptors] + return { + descriptors, + source: { + readChunk: (descriptor, offset, maxBytes) => + descriptor.blobKind === 'terminal-history' + ? panes.source.readChunk(descriptor, offset, maxBytes) + : history.source.readChunk(descriptor, offset, maxBytes) + } + } +} + +/** Sends every blob; the caller journals `blobs-transferred` once the terminals are proven again. */ +export async function sendWslMoveBlobs( + host: WslMoveHost, + staged: WslMoveCutover, + blobs: Awaited> +): Promise { + await transferOrcadMigrationBlobs({ + source: blobs.source, + manifest: staged.manifest, + blobs: blobs.descriptors, + destination: { + readCapabilities: async () => host.capabilities, + readState: host.catalog.readState, + stageChunk: host.blobs.stageChunk + } + }) +} + +/** + * Only an acknowledged commit moves the journal. A lost reply throws: the caller's reset asks the + * host, and records the commit from its acknowledgement if it landed. + */ +export async function commitWslMove( + deps: TransferDeps, + host: WslMoveHost, + transferred: WslMoveCutover, + expectedBlobs: readonly OrcadMigrationBlobDescriptor[] +): Promise { + assertWslMoveCommitRequestAllowed(transferred) + const state = await host.commitWithBlobs(transferred.manifest, expectedBlobs) + if (state.state !== 'committed') { + throw new Error(`wsl_move_commit_not_committed:${state.state}`) + } + assertSameMigration(transferred, state) + if ( + state.receipt.migrationId !== transferred.migrationId || + state.receipt.manifestSha256 !== transferred.manifestSha256 + ) { + throw new Error('wsl_move_destination_receipt_mismatch') + } + return advanceWslMove(deps, transferred, 'destination-committed') +} + +function assertSameMigration(cutover: WslMoveCutover, state: OrcadMigrationCatalogState): void { + if ( + state.migrationId !== cutover.migrationId || + state.manifestSha256 !== cutover.manifestSha256 + ) { + throw new Error('wsl_move_destination_state_mismatch') + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver.test.ts new file mode 100644 index 00000000000..cc498e876bc --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver.test.ts @@ -0,0 +1,667 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import { computeOrcadMigrationManifestSha256 } from '../../orcad/orcad-migration-manifest-digest' +import { + createWslMoveWorld, + WSL_MOVE_WORLD_PANE, + type WslMoveWorld +} from '../__fixtures__/wsl-mover/wsl-move-driver-world' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { listWslMoveCutovers } from './wsl-move-cutover-journal' +import { WslMoveScopeChangedError, type WslMoveManifestExport } from './wsl-move-manifest-export' +import { + registerWslMoveDesktopRewriteStore, + resetWslMoveDesktopRewriteStoresForTests, + setWslMoveDesktopRewriteRegistryCompleteForTests +} from './wsl-move-desktop-rewrite-stores' +import { cancelWslMove, moveProjectsToWslHost, previewWslMove } from './wsl-move-driver' +import { runWslMoveRetentionPass } from './wsl-move-retention' +import { runWslMoveDesktopRewritePass } from './wsl-move-desktop-rewrite' +import { + WSL_MOVE_HOST_AUTOMATIONS_STORE_ID, + listUnfinishedWslMoves, + registerWslMoveHostAutomationsStore, + type WslMoveMovedAutomation +} from './wsl-move-host-automations-rewrite' +import { recoverInterruptedWslMoves } from './wsl-move-reset' +import { bringWslMovedProjectBack } from './wsl-move-use-locally' +import { + isWslMoveWindowWriteHeld, + resetWslMoveWindowWriteHoldForTests, + setWslMoveReloadableWindow +} from './wsl-move-window-write-hold' +import { + cancelWslMoveRewriteRetryForTests, + scheduleWslMoveRewriteRetry +} from './wsl-move-rewrite-retry' + +let userDataPath: string +let world: WslMoveWorld + +beforeEach(() => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-driver-')) + world = createWslMoveWorld(userDataPath) + setWslMoveDesktopRewriteRegistryCompleteForTests(true) + registerWslMoveDesktopRewriteStore({ + id: 'ui-routing', + side: 'main', + run: () => {}, + flush: () => {} + }) +}) + +afterEach(() => { + vi.unstubAllEnvs() + resetWslMoveDesktopRewriteStoresForTests() + resetWslMoveWindowWriteHoldForTests() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +const request = { distro: 'Ubuntu' } +const journal = () => listWslMoveCutovers(userDataPath) + +describe('moveProjectsToWslHost', () => { + it('moves a project to the host, through every phase, and retains its source row', async () => { + const result = await moveProjectsToWslHost(world.deps, request) + + expect(result).toMatchObject({ outcome: 'moved', phase: 'client-rewritten' }) + // Sleep runs before the move is journaled, and again (a no-op) under the fence. + expect(world.slept).toEqual([ + ['repo-1::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app'], + ['repo-1::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app'] + ]) + expect([...world.catalog.values()]).toEqual(['committed']) + // The paused pane's own checkpoint (history and folder) went as its blob, and the commit named it. + expect(world.committedWithBlobs?.map((blob) => blob.blobKind)).toEqual(['terminal-history']) + const [cutover] = journal() + expect(cutover.manifest.payload.repositories[0].path).toBe('/home/dev/app') + expect(world.progress.map((event) => event.step)).toEqual([ + 'preparing', + 'pausing-terminals', + 'copying', + 'copying', + 'copying', + 'switching', + 'finishing', + 'done' + ]) + + const store = { + getRepos: () => world.repos, + getFolderWorkspaces: () => [], + setWslMoveRetainedMarks: vi.fn(() => 1), + flushPendingOrThrowAsync: vi.fn(async () => {}) + } + await runWslMoveRetentionPass({ userDataPath, store }) + expect(journal()[0].phase).toBe('retained') + expect(store.setWslMoveRetainedMarks).toHaveBeenCalled() + }) + + it.each([ + ['stage:before', 'reset'], + ['blob:terminal-history', 'reset'], + ['commit:before', 'reset'] + ])('resets to zero when %s fails', async (failAt, outcome) => { + world.failAt.add(failAt) + const result = await moveProjectsToWslHost(world.deps, request) + + expect(result).toMatchObject({ outcome, detail: failAt }) + expect(journal()).toEqual([]) + // The host's staging was aborted; nothing of the move is left on either side. + expect([...world.catalog.values()]).toEqual([]) + }) + + it("holds the open window's saves from the commit request and reloads it once, after the rewrite", async () => { + const page = { loaded: () => {} } + const window = { + isDestroyed: () => false, + reload: vi.fn(), + allowUnload: () => () => {}, + onLoadedOrGone: (listener: () => void) => { + page.loaded = listener + return () => {} + } + } + setWslMoveReloadableWindow(() => window) + const heldAt: string[] = [] + registerWslMoveDesktopRewriteStore({ + id: 'session-globals', + side: 'main', + run: () => { + heldAt.push(isWslMoveWindowWriteHeld() ? 'rewrite:held' : 'rewrite:open') + }, + flush: () => {} + }) + + expect(await moveProjectsToWslHost(world.deps, request)).toMatchObject({ outcome: 'moved' }) + expect(heldAt).toEqual(['rewrite:held']) + await new Promise((resolve) => setImmediate(resolve)) + expect(window.reload).toHaveBeenCalledOnce() + expect(isWslMoveWindowWriteHeld()).toBe(true) + page.loaded() + expect(isWslMoveWindowWriteHeld()).toBe(false) + }) + + it('leaves the window unheld and unreloaded when the commit request fails', async () => { + const window = { + isDestroyed: () => false, + reload: vi.fn(), + allowUnload: () => () => {}, + onLoadedOrGone: () => () => {} + } + setWslMoveReloadableWindow(() => window) + world.failAt.add('commit:before') + + expect(await moveProjectsToWslHost(world.deps, request)).not.toMatchObject({ outcome: 'moved' }) + await new Promise((resolve) => setImmediate(resolve)) + expect(isWslMoveWindowWriteHeld()).toBe(false) + expect(window.reload).not.toHaveBeenCalled() + }) + + it('recovers a stage whose reply was lost by reading the host, then moves', async () => { + world.failAt.add('stage:lost-reply') + expect(await moveProjectsToWslHost(world.deps, request)).toMatchObject({ outcome: 'moved' }) + }) + + it('goes forward when the commit landed but its reply was lost', async () => { + world.failAt.add('commit:lost-reply') + const result = await moveProjectsToWslHost(world.deps, request) + + expect(result).toMatchObject({ outcome: 'moved', phase: 'client-rewritten' }) + expect([...world.catalog.values()]).toEqual(['committed']) + }) + + it('lifts the fence when the exit proof fails, before anything reaches the host', async () => { + world.failAt.add('prove-exited') + const result = await moveProjectsToWslHost(world.deps, request) + + expect(result).toMatchObject({ outcome: 'refused', code: 'wsl_move_pause_failed' }) + expect(journal()).toEqual([]) + expect(world.points).not.toContain('stage:before') + }) + + it('resets when the project changed while it was paused', async () => { + world.exportOverride = (exported, call) => + call === 3 + ? { + ...exported, + manifest: { ...exported.manifest, manifestSha256: 'f'.repeat(64) } + } + : exported + const result = await moveProjectsToWslHost(world.deps, request) + + expect(result).toEqual({ outcome: 'refused', code: 'wsl_move_scope_changed' }) + expect(journal()).toEqual([]) + expect(world.points).not.toContain('stage:before') + }) + + it('surfaces the too-large message from the first journal write, with nothing fenced', async () => { + world.exportOverride = (exported, call) => { + if (call !== 2) { + return exported + } + const { manifestSha256: _signed, ...unsigned } = exported.manifest + const grown = { + ...unsigned, + payload: { + ...unsigned.payload, + repositories: unsigned.payload.repositories.map((repo) => ({ + ...repo, + symlinkPaths: Array.from( + { length: 845 }, + (_, i) => `/home/dev/app/${'x'.repeat(900)}${i}` + ) + })) + } + } + return { + ...exported, + manifest: { ...grown, manifestSha256: computeOrcadMigrationManifestSha256(grown) } + } + } + const result = await moveProjectsToWslHost(world.deps, request) + + expect(result).toMatchObject({ outcome: 'refused', code: 'wsl_move_manifest_too_large' }) + expect(result.outcome === 'refused' && result.message).toMatch(/too large to move in one step/) + expect(journal()).toEqual([]) + }) + + it('says why when the manifest it signed is not what the journal reads back', async () => { + // What a real-host run saw as a bare "everything was put back": no journal, no reason. + world.exportOverride = (exported, call) => + call === 2 + ? { ...exported, manifest: { ...exported.manifest, manifestSha256: '0'.repeat(64) } } + : exported + const result = await moveProjectsToWslHost(world.deps, request) + + expect(result).toMatchObject({ outcome: 'refused', code: 'wsl_move_export_incomplete' }) + expect(result.outcome === 'refused' && result.reasons?.[0]).toMatch(/digest_mismatch/) + expect(journal()).toEqual([]) + }) + + it("refuses rather than move a slept pane whose history it can't fully read", async () => { + const dispose = vi.fn() + const sleptPanes = [{ paneKey: 't1:leaf', sessionId: 'w@@slept' }] + let reported: unknown + world.deps.sleptScrollback = async (_panes, _distro, _id, _workspaces, fromWindow) => { + reported = fromWindow + return { + buffers: new Map(), + problems: ['saved terminal history Orca could not fully read for tab t1'], + dispose + } + } + const result = await moveProjectsToWslHost(world.deps, { ...request, sleptPanes }) + + expect(result).toMatchObject({ + outcome: 'refused', + code: 'wsl_move_export_incomplete', + reasons: ['saved terminal history Orca could not fully read for tab t1'] + }) + expect(journal()).toEqual([]) + expect(dispose).toHaveBeenCalledTimes(1) + // The window's bindings of panes slept earlier reach the reader. + expect(reported).toEqual(sleptPanes) + }) + + it("says in the preview that a slept pane's history can't be read", async () => { + // Only Start refused; the preview showed the move as ready. + const dispose = vi.fn() + world.deps.sleptScrollback = async () => ({ + buffers: new Map(), + problems: ['saved terminal history Orca could not fully read for the tab "build"'], + dispose + }) + expect(await previewWslMove(world.deps, request)).toMatchObject({ + outcome: 'refused', + code: 'wsl_move_export_incomplete', + reasons: ['saved terminal history Orca could not fully read for the tab "build"'] + }) + expect(dispose).toHaveBeenCalledTimes(1) + expect(journal()).toEqual([]) + }) + + it('refuses a second move of the same distro while one runs', async () => { + let release: () => void = () => {} + const sleep = world.deps.quiesce.sleepWorkspaces + world.deps.quiesce.sleepWorkspaces = async (ids) => { + await new Promise((resolve) => (release = resolve)) + return sleep(ids) + } + const first = moveProjectsToWslHost(world.deps, request) + await vi.waitFor(() => expect(world.progress.map((e) => e.step)).toContain('pausing-terminals')) + expect(await moveProjectsToWslHost(world.deps, { distro: 'ubuntu' })).toEqual({ + outcome: 'refused', + code: 'wsl_move_already_running' + }) + world.deps.quiesce.sleepWorkspaces = sleep + release() + expect(await first).toMatchObject({ outcome: 'moved' }) + }) + + it('refuses "Use on this computer again" while the committed move still runs here', async () => { + let release: () => void = () => {} + const finish = world.deps.finishOnDesktop + world.deps.finishOnDesktop = async () => { + await new Promise((resolve) => (release = resolve)) + return finish() + } + const run = moveProjectsToWslHost(world.deps, request) + await vi.waitFor(() => expect(world.progress.map((e) => e.step)).toContain('finishing')) + expect(journal()[0]?.phase).toBe('destination-committed') + + expect(bringWslMovedProjectBack(userDataPath, { repoId: 'repo-1' })).toEqual({ + released: false, + reason: 'move-running' + }) + expect(journal()).toHaveLength(1) + release() + expect(await run).toMatchObject({ outcome: 'moved' }) + expect(bringWslMovedProjectBack(userDataPath, { repoId: 'repo-1' }).released).toBe(true) + }) + + it('refuses a move whose terminals are busy, and shows the blockers in the preview', async () => { + world.gate = { + panes: [ + { + paneKey: WSL_MOVE_WORLD_PANE, + foreground: 'process-running', + agent: { state: 'working', resumable: true, ownsForeground: true }, + scrollbackBytes: 0 + } + ], + runningAutomationRunIds: ['run-1'], + activeDispatchIds: [] + } + const preview = await previewWslMove(world.deps, request) + expect(preview).toMatchObject({ + outcome: 'ready', + repoIds: ['repo-1'], + blockers: [ + { kind: 'agent-working', ids: [WSL_MOVE_WORLD_PANE] }, + { kind: 'automation-running', ids: ['run-1'] } + ] + }) + expect(await moveProjectsToWslHost(world.deps, request)).toMatchObject({ + outcome: 'refused', + code: 'wsl_move_terminals_blocked' + }) + expect(world.slept).toEqual([]) + expect(journal()).toEqual([]) + }) + + it('stops at a cancel before the commit and puts everything back', async () => { + const stage = world.deps.connectHost + world.deps.connectHost = async (distro) => { + const host = await stage(distro) + if (!host) { + return host + } + const original = host.catalog.stage + return { + ...host, + catalog: { + ...host.catalog, + stage: async (manifest) => { + cancelWslMove('Ubuntu') + return original(manifest) + } + } + } + } + expect(await moveProjectsToWslHost(world.deps, request)).toEqual({ + outcome: 'refused', + code: 'wsl_move_cancelled' + }) + expect(journal()).toEqual([]) + expect([...world.catalog.values()]).toEqual([]) + }) + + it('tells a window that only follows progress how a run ended without committing', async () => { + world.deps.connectHost = async () => { + throw new Error('ECONNREFUSED') + } + const result = await moveProjectsToWslHost(world.deps, request) + // A host that will not connect is a refusal the user can act on, not "could not check". + expect(result).toEqual({ + outcome: 'refused', + code: 'wsl_move_host_unavailable', + detail: 'connect_failed' + }) + expect(world.progress.at(-1)).toEqual({ distro: 'Ubuntu', step: 'preparing', result }) + await expect(previewWslMove(world.deps, request)).resolves.toMatchObject({ + code: 'wsl_move_host_unavailable' + }) + }) + + it('is unreachable with the internal WSL hosts gate closed', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + const connectHost = vi.spyOn(world.deps, 'connectHost') + expect(await previewWslMove(world.deps, request)).toEqual({ + outcome: 'refused', + code: 'wsl_move_unavailable' + }) + expect(await moveProjectsToWslHost(world.deps, request)).toEqual({ + outcome: 'refused', + code: 'wsl_move_unavailable' + }) + expect(connectHost).not.toHaveBeenCalled() + expect(world.slept).toEqual([]) + }) + it('turns the moved automations back on on the host after commit, ledgered', async () => { + const enable = vi.fn(async () => {}) + registerWslMoveHostAutomationsStore({ enable }) + world.exportOverride = (exported) => ({ ...exported, automationsToReenable: ['auto-1'] }) + expect(await moveProjectsToWslHost(world.deps, request)).toMatchObject({ + outcome: 'moved', + phase: 'client-rewritten' + }) + expect(enable).toHaveBeenCalledWith('wsl:Ubuntu', [{ id: 'auto-1', updatedAt: null }]) + expect(journal()[0]).toMatchObject({ + automationsToReenable: ['auto-1'], + rewrittenStores: expect.arrayContaining([WSL_MOVE_HOST_AUTOMATIONS_STORE_ID]) + }) + }) + + it.each([ + ['refuses', false], + ['applies the change but loses the reply', true] + ])( + 'never leaves automations silently off when the host %s: names them, then retries', + async (_label, landsAnyway) => { + const enabledOnHost = new Set() + let hostAnswers = false + const enable = vi.fn(async (_env: string, rows: readonly WslMoveMovedAutomation[]) => { + if (!hostAnswers) { + if (landsAnyway) { + rows.forEach((row) => enabledOnHost.add(row.id)) + } + throw new Error('host unreachable') + } + rows.forEach((row) => enabledOnHost.add(row.id)) + }) + registerWslMoveHostAutomationsStore({ enable }) + world.exportOverride = (exported) => ({ ...exported, automationsToReenable: ['auto-1'] }) + + const result = await moveProjectsToWslHost(world.deps, request) + expect(result).toMatchObject({ + outcome: 'moved', + phase: 'destination-committed', + automationsPending: ['auto-1'] + }) + expect(listUnfinishedWslMoves(userDataPath)).toEqual([ + { migrationId: journal()[0].migrationId, distro: 'Ubuntu', automationsPending: ['auto-1'] } + ]) + + // Restart: recovery leaves a committed move alone; the startup pass retries from the journal. + hostAnswers = true + await recoverInterruptedWslMoves(userDataPath) + await runWslMoveDesktopRewritePass({ userDataPath, warn: () => {} }) + expect(enabledOnHost).toEqual(new Set(['auto-1'])) + expect(journal()[0].phase).toBe('client-rewritten') + expect(listUnfinishedWslMoves(userDataPath)).toEqual([]) + } + ) + + it('retries the host step in the same run, backing off, until it lands', async () => { + vi.useFakeTimers() + try { + let attempts = 0 + registerWslMoveHostAutomationsStore({ + enable: async () => { + attempts += 1 + if (attempts < 3) { + throw new Error('host unreachable') + } + } + }) + world.exportOverride = (exported) => ({ ...exported, automationsToReenable: ['auto-1'] }) + expect(await moveProjectsToWslHost(world.deps, request)).toMatchObject({ + automationsPending: ['auto-1'] + }) + scheduleWslMoveRewriteRetry(userDataPath, [10, 20]) + await vi.advanceTimersByTimeAsync(10) + expect(attempts).toBe(2) + expect(journal()[0].phase).toBe('destination-committed') + await vi.advanceTimersByTimeAsync(20) + expect(attempts).toBe(3) + expect(journal()[0].phase).toBe('client-rewritten') + await vi.advanceTimersByTimeAsync(1_000) + expect(attempts).toBe(3) + } finally { + cancelWslMoveRewriteRetryForTests() + vi.useRealTimers() + } + }) + + it('asks for a new plan when a planned project is gone by the time it is paused', async () => { + world.exportOverride = (exported, call) => { + if (call === 2) { + throw new WslMoveScopeChangedError(['repo-1']) + } + return exported + } + expect(await moveProjectsToWslHost(world.deps, request)).toEqual({ + outcome: 'refused', + code: 'wsl_move_scope_changed' + }) + expect(journal()).toEqual([]) + }) + describe('the unfenced first Sleep', () => { + const busy = { + panes: [ + { + paneKey: WSL_MOVE_WORLD_PANE, + foreground: 'process-running' as const, + scrollbackBytes: 0 + } + ], + runningAutomationRunIds: [], + activeDispatchIds: [] + } + + it('gates again right before it, and sleeps nothing that turned busy', async () => { + let calls = 0 + world.deps.gateInputs = async () => ((calls += 1) === 2 ? busy : world.gate) + expect(await moveProjectsToWslHost(world.deps, request)).toMatchObject({ + outcome: 'refused', + code: 'wsl_move_terminals_blocked' + }) + expect(world.slept).toEqual([]) + expect(journal()).toEqual([]) + }) + + it('refuses when a terminal opened while the gate ran, rather than sleep it unrecorded', async () => { + let calls = 0 + world.deps.listPanes = () => + (calls += 1) === 1 + ? [] + : [{ paneKey: WSL_MOVE_WORLD_PANE, sessionId: 'late', wslDistro: null }] + expect(await moveProjectsToWslHost(world.deps, request)).toEqual({ + outcome: 'refused', + code: 'wsl_move_pause_failed', + detail: 'terminals-changed' + }) + expect(world.slept).toEqual([]) + }) + + it('gives panes slept before the move their folder, including ones only the window still binds', async () => { + // A pane slept earlier lost its `deep/` folder; only its history moved. + const saved = { paneKey: 'tab-0:saved', sessionId: 'saved-session' } + const reported = { paneKey: 'tab-0:reported', sessionId: 'reported-session' } + const offered: string[] = [] + world.deps.sleptSessionPanes = (_ids, fromWindow) => { + offered.push(...fromWindow.map((pane) => pane.sessionId)) + return [saved, ...fromWindow] + } + const hinted: string[] = [] + world.deps.quiesce.captureRestoreHints = async (panes) => { + hinted.push(...panes.map((pane) => pane.sessionId)) + return { hints: [], unreadablePaneKeys: [], noCwdPaneKeys: [], staleCwdPaneKeys: [] } + } + await moveProjectsToWslHost(world.deps, { ...request, sleptPanes: [reported] }) + expect(offered).toEqual(['reported-session']) + expect(hinted).toEqual(['session-1', 'saved-session', 'reported-session']) + }) + + it('removes the record when the quiesce refuses before its fence', async () => { + let calls = 0 + world.deps.gateInputs = async () => ((calls += 1) === 3 ? busy : world.gate) + expect(await moveProjectsToWslHost(world.deps, request)).toMatchObject({ + code: 'wsl_move_terminals_blocked' + }) + expect(journal()).toEqual([]) + }) + }) + + it.each<[string, (exported: WslMoveManifestExport) => WslMoveManifestExport]>([ + ['state it would leave behind', (exported) => ({ ...exported, blockers: ['1 saved tab'] })], + ['an automation switched on', (exported) => ({ ...exported, automationsToReenable: ['a'] })] + ])('resets when, under the fence, the project gained %s', async (_label, change) => { + world.exportOverride = (exported, call) => (call === 3 ? change(exported) : exported) + expect(await moveProjectsToWslHost(world.deps, request)).toEqual({ + outcome: 'refused', + code: 'wsl_move_scope_changed' + }) + expect(journal()).toEqual([]) + }) + + it('no longer cancels once the commit was requested', async () => { + const connect = world.deps.connectHost + let cancelled: boolean | null = null + world.deps.connectHost = async (distro) => { + const host = await connect(distro) + return host + ? { + ...host, + commitWithBlobs: (manifest, blobs) => { + cancelled = cancelWslMove('Ubuntu') + return host.commitWithBlobs(manifest, blobs) + } + } + : host + } + expect(await moveProjectsToWslHost(world.deps, request)).toMatchObject({ outcome: 'moved' }) + expect(cancelled).toBe(false) + }) + + describe('an earlier move an interruption left short of commit', () => { + const seedStuck = () => { + const stuck = wslMoveCutoverFixture('stuck', 'blobs-transferred', {}, { repoIds: ['repo-9'] }) + seedWslMoveCutover(userDataPath, stuck) + return stuck + } + + it('is reset through the host first, then the new move runs', async () => { + world.catalog.set(seedStuck().migrationId, 'staged') + expect(await moveProjectsToWslHost(world.deps, request)).toMatchObject({ outcome: 'moved' }) + expect(journal().map((cutover) => cutover.migrationId)).not.toContain('stuck') + expect(world.catalog.has('stuck')).toBe(false) + }) + + it('goes forward when the host committed it, and the new move waits for it to finish', async () => { + world.catalog.set(seedStuck().migrationId, 'committed') + expect(await moveProjectsToWslHost(world.deps, request)).toEqual({ + outcome: 'refused', + code: 'wsl_move_finished_earlier_move' + }) + expect(journal().find((cutover) => cutover.migrationId === 'stuck')?.phase).toBe( + 'client-rewritten' + ) + // Nothing of the new move started under the window that must reload first. + expect(world.slept).toEqual([]) + }) + + it('keeps the fence and refuses while the host cannot say', async () => { + seedStuck() + world.deps.connectHost = async () => { + const host = await createWslMoveWorld(userDataPath).deps.connectHost('Ubuntu') + if (!host) { + return host + } + return { + ...host, + catalog: { + ...host.catalog, + readState: async () => { + throw new Error('socket closed') + }, + abortByReference: async () => { + throw new Error('socket closed') + } + } + } + } + expect(await moveProjectsToWslHost(world.deps, request)).toMatchObject({ + outcome: 'refused', + code: 'wsl_move_already_in_flight' + }) + expect(journal().map((cutover) => cutover.phase)).toEqual(['blobs-transferred']) + }) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver.ts new file mode 100644 index 00000000000..4a4f904a0a2 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver.ts @@ -0,0 +1,324 @@ +/** + * The move driver: one "move projects to the WSL host" run, end to end (mover design, 3.3 and 4.4). + * + * Plan and gate, then Sleep the scope, export the slept state and journal it (`prepared`), fence + * and prove every terminal exited (`quiesced`, with the restore hints), re-export to prove nothing + * changed under the fence, then stage, send, and commit only from `blobs-transferred`. Any failure + * resets to zero through #29825's `resetWslMoveToZero`, which also carries a commit that landed + * forward. After commit the desktop rewrite runs; retention follows on the journal change. + * + * Sleep runs before the export because the journal binds the manifest at `prepared` and never + * rebinds it: a manifest taken before Sleep would carry scrollback Sleep then rewrites. + */ +import type { + WslMovePreview, + WslMoveRequest, + WslMoveResult +} from '../../../shared/wsl-move/wsl-move-driver-types' +import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import { removeWslMoveCutover, writeWslMoveCutover } from './wsl-move-cutover-journal' +import { journalSleptWslMove } from './wsl-move-driver-journal' +import type { WslMoveDriverDeps, WslMoveHost } from './wsl-move-driver-ports' +import { + readSleptScrollback, + unreadableSleptHistory, + daemonHistoryLeafOf, + type WslMoveSleptScrollback +} from './wsl-move-slept-scrollback' +import { reportWslMoveRunEnd } from './wsl-move-driver-run-end' +import { + exportRequestOf, + isWslMoveScopeChanged, + prepareWslMove, + refusal, + type WslMovePrepared +} from './wsl-move-driver-plan' +import { + advanceWslMove, + collectWslMoveBlobs, + commitWslMove, + sendWslMoveBlobs, + stageWslMove, + wslMoveCopyBytes +} from './wsl-move-driver-transfer' +import { pauseWslMoveScope } from './wsl-move-driver-pause' +import { quiesceWslMove, type WslMoveQuiesceRefusalReason } from './wsl-move-quiesce' +import { finishWslMove as finish } from './wsl-move-driver-finish' +import { resetWslMoveToZero } from './wsl-move-reset' +import { claimWslMoveRun, releaseWslMoveRun, type RunningWslMove } from './wsl-move-running-moves' +import { + holdWslMoveWindowWritesForCommit, + settleWslMoveWindowWriteHoldForCommit +} from './wsl-move-window-write-hold' + +class WslMoveStop extends Error {} + +/** A terminal started in scope after the quiesce proved every one exited. */ +const WSL_MOVE_TERMINALS_STARTED = 'terminals-started-after-pause' +/** The quiesce's own wording, so both proofs read alike in the result. */ +const WSL_MOVE_EXIT_UNVERIFIABLE = 'exit-unverifiable' + +export { cancelWslMove, isWslMoveRunning } from './wsl-move-running-moves' + +export async function previewWslMove( + deps: WslMoveDriverDeps, + request: WslMoveRequest +): Promise { + const migrationId = deps.newMigrationId() + const prepared = await prepareWslMove(deps, request, migrationId, deps.now()) + if ('outcome' in prepared) { + return prepared + } + const unreadable = await unreadableSleptHistory( + deps, + request, + migrationId, + prepared.scope.workspaceIds + ) + if (unreadable.length > 0) { + return refusal('wsl_move_export_incomplete', { reasons: unreadable }) + } + const { plan } = prepared + return { + outcome: 'ready', + distro: plan.distro, + repoIds: plan.repoIds, + folderWorkspaceIds: plan.folderWorkspaceIds, + changed: plan.changed, + heldByHost: plan.heldByHost, + refused: plan.refused, + blockers: prepared.blockers + } +} + +export async function moveProjectsToWslHost( + deps: WslMoveDriverDeps, + request: WslMoveRequest +): Promise { + const running = claimWslMoveRun(request.distro) + if (!running) { + return refusal('wsl_move_already_running') + } + const migrationId = deps.newMigrationId() + // Anything unforeseen: back to zero without the host, which never resets a possible commit. + return reportWslMoveRunEnd(deps, request.distro, (tracked) => + runMove(tracked, request, migrationId, running) + .catch((error: unknown) => putBack(tracked, null, migrationId, describe(error))) + .finally(() => { + releaseWslMoveRun(request.distro, running) + // Committed runs settled it in `finish`; any other end leaves the window as it was. + settleWslMoveWindowWriteHoldForCommit(false) + }) + ) +} + +async function runMove( + deps: WslMoveDriverDeps, + request: WslMoveRequest, + migrationId: string, + running: RunningWslMove +): Promise { + const { signal } = running.controller + const { distro } = request + deps.progress({ distro, step: 'preparing' }) + const startedAt = deps.now() + const prepared = await prepareWslMove(deps, request, migrationId, startedAt, true) + if ('outcome' in prepared) { + return prepared + } + if (prepared.blockers.length > 0) { + return refusal('wsl_move_terminals_blocked', { blockers: prepared.blockers }) + } + if (signal.aborted) { + return refusal('wsl_move_cancelled') + } + const { host, scope } = prepared + deps.progress({ distro, step: 'pausing-terminals', migrationId }) + const paused = await pauseWslMoveScope(deps, scope, request.sleptPanes) + if ('outcome' in paused) { + return paused + } + const { panes } = paused + const slept = await readSleptScrollback( + deps, + panes, + distro, + migrationId, + scope.workspaceIds, + request.sleptPanes + ) + try { + const journaled = await journalSleptWslMove(deps, prepared, migrationId, startedAt, slept) + if ('outcome' in journaled) { + return journaled + } + const quiesced = await quiesceWslMove( + journaled, + { workspaceIds: scope.workspaceIds, workspaceKeys: scope.workspaceKeys, panes }, + { + ...deps.quiesce, + gateInputs: () => deps.gateInputs(scope), + writeCutover: (cutover) => writeWslMoveCutover(deps.userDataPath, cutover), + removeCutover: (id) => removeWslMoveCutover(deps.userDataPath, id), + now: deps.now + } + ) + if (quiesced.outcome === 'refused') { + // A refusal before the fence leaves the `prepared` record; nothing reached the host. + await resetWslMoveToZero( + { userDataPath: deps.userDataPath, destination: null }, + migrationId + ).catch(() => {}) + if (!quiesced.fenceLifted) { + return putBack(deps, host, migrationId, `pause:${quiesced.reason}`) + } + return quiesced.reason === 'terminals-blocked' + ? refusal('wsl_move_terminals_blocked', { blockers: quiesced.blockers ?? [] }) + : refusal('wsl_move_pause_failed', { + detail: pauseDetail(quiesced.reason, quiesced.detail) + }) + } + try { + return await moveQuiesced(deps, host, quiesced.cutover, prepared, running, slept) + } catch (error) { + return putBack(deps, host, migrationId, describe(error)) + } + } finally { + slept.dispose() + } +} + +async function moveQuiesced( + deps: WslMoveDriverDeps, + host: WslMoveHost, + quiesced: WslMoveCutover, + prepared: WslMovePrepared, + running: RunningWslMove, + slept: WslMoveSleptScrollback +): Promise { + const { signal } = running.controller + const { distro } = prepared.plan + const { migrationId } = quiesced + const stopIfCancelled = (): void => { + if (signal.aborted) { + throw new WslMoveStop('wsl_move_cancelled') + } + } + // Under the fence: the same export must still come out, or something in scope changed. + const startedAt = new Date(quiesced.startedAt) + let again: ReturnType + try { + again = deps.exportManifest( + exportRequestOf(prepared, migrationId, startedAt, daemonHistoryLeafOf(slept)) + ) + } catch (error) { + throw isWslMoveScopeChanged(error) ? new WslMoveStop('wsl_move_scope_changed') : error + } + // State the payload leaves out (left-behind items, automations on here) is outside the digest. + if ( + again.manifest.manifestSha256 !== quiesced.manifestSha256 || + again.blockers.length > 0 || + JSON.stringify(again.automationsToReenable) !== + JSON.stringify(quiesced.automationsToReenable ?? []) + ) { + throw new WslMoveStop('wsl_move_scope_changed') + } + stopIfCancelled() + deps.progress({ distro, step: 'copying', migrationId, phase: quiesced.phase }) + const staged = await stageWslMove(deps, host, quiesced) + stopIfCancelled() + const blobs = await collectWslMoveBlobs(deps, quiesced, again.worktrees, slept) + const { snapshotBytes, bytesTotal } = wslMoveCopyBytes(quiesced, blobs.descriptors) + deps.progress({ + distro, + step: 'copying', + migrationId, + phase: staged.phase, + bytesSent: snapshotBytes, + bytesTotal + }) + await sendWslMoveBlobs(host, staged, blobs) + // Still short of `blobs-transferred`: no commit can exist yet, so a refusal here always resets. + await proveStillExited(deps, quiesced, prepared) + stopIfCancelled() + const transferred = advanceWslMove(deps, staged, 'blobs-transferred') + deps.progress({ + distro, + step: 'copying', + migrationId, + phase: transferred.phase, + bytesSent: bytesTotal, + bytesTotal + }) + // From here a cancel can no longer be honoured: the host may commit whatever this run sees. + running.commitRequested = true + // The host publishes the moved projects at commit; an open window must not save its view of them. + holdWslMoveWindowWritesForCommit() + deps.progress({ distro, step: 'switching', migrationId, phase: transferred.phase }) + const committed = await commitWslMove(deps, host, transferred, blobs.descriptors) + return finish(deps, distro, committed.migrationId) +} + +/** + * The quiesce proved exit once; a terminal in scope that started since would outlive the commit in + * a moved folder. Nothing awaits between this census and the commit request. + */ +async function proveStillExited( + deps: WslMoveDriverDeps, + quiesced: WslMoveCutover, + prepared: WslMovePrepared +): Promise { + const proof = await deps.quiesce.proveExited({ + distro: quiesced.manifest.source.distro, + workspaceKeys: prepared.scope.workspaceKeys, + sessionIds: new Set(quiesced.provenExitedSessionIds) + }) + if (proof.verdict === 'live') { + throw new WslMoveStop(WSL_MOVE_TERMINALS_STARTED) + } + if (proof.verdict === 'unverifiable') { + throw new WslMoveStop(`${WSL_MOVE_EXIT_UNVERIFIABLE}: ${proof.reason}`) + } +} + +/** Back to zero, or forward when the host proves it committed; never a guess. */ +async function putBack( + deps: WslMoveDriverDeps, + host: WslMoveHost | null, + migrationId: string, + detail: string +): Promise { + let reset: Awaited> + try { + reset = await resetWslMoveToZero( + { userDataPath: deps.userDataPath, destination: host?.catalog ?? null, now: deps.now }, + migrationId + ) + } catch (error) { + return { outcome: 'waiting-for-host', migrationId, detail: `${detail}; ${describe(error)}` } + } + if (reset.outcome === 'committed') { + return finish(deps, reset.cutover.manifest.source.distro, migrationId) + } + if (reset.outcome === 'unverifiable') { + return { outcome: 'waiting-for-host', migrationId, detail: `${detail}; ${reset.reason}` } + } + if (detail === 'wsl_move_cancelled' || detail === 'wsl_move_scope_changed') { + return refusal(detail) + } + if ( + detail === WSL_MOVE_TERMINALS_STARTED || + detail.startsWith(`${WSL_MOVE_EXIT_UNVERIFIABLE}: `) + ) { + return refusal('wsl_move_pause_failed', { detail }) + } + return { outcome: 'reset', migrationId, detail } +} + +function pauseDetail(reason: WslMoveQuiesceRefusalReason, detail: string | undefined): string { + return detail ? `${reason}: ${detail}` : reason +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.test.ts index c618015a59b..1198b7aad81 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.test.ts @@ -73,9 +73,7 @@ describe('proveWslMoveTerminalsExited', () => { }) it.each([ - ['no folder', { cwd: null, wslDistro: null }], - ['another folder in the moving distro', { cwd: '/home/dev/other', wslDistro: 'Ubuntu' }], - ['a UNC folder in the moving distro', { cwd: '\\\\wsl$\\Ubuntu\\tmp', wslDistro: null }], + ['no folder', { cwd: null, wslDistro: 'Ubuntu' }], [ 'a POSIX folder from a daemon that does not report its distro', { cwd: '/home/dev/app', wslDistro: undefined } @@ -90,7 +88,16 @@ describe('proveWslMoveTerminalsExited', () => { it.each([ ['a native Windows folder', { cwd: 'C:\\src', wslDistro: null }], ['a Git Bash folder', { cwd: '/c/src', wslDistro: null }], - ['another distro', { cwd: '/home/dev/app', wslDistro: 'Debian' }] + ['another distro', { cwd: '/home/dev/app', wslDistro: 'Debian' }], + // Other projects in the moving distro keep their terminals through a move. + ['another folder in the moving distro', { cwd: '/home/dev/other', wslDistro: 'Ubuntu' }], + ['the moving distro’s home, above the move', { cwd: '/home/dev', wslDistro: 'Ubuntu' }], + ['a sibling sharing the prefix', { cwd: '/home/dev/app2', wslDistro: 'Ubuntu' }], + ['a UNC folder in the moving distro', { cwd: '\\\\wsl$\\Ubuntu\\tmp', wslDistro: null }], + [ + 'a POSIX folder outside the move from a daemon that does not report its distro', + { cwd: '/home/dev/other', wslDistro: undefined } + ] ])('ignores an unminted live session in %s', async (_label, facts) => { await expect(prove([{ sessionId: 'uuid-3', isAlive: true, ...facts }])).resolves.toEqual({ verdict: 'exited' diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.ts b/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.ts index b5d02fd115e..0475a50a207 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.ts @@ -1,6 +1,6 @@ // Require a complete daemon census under the move fence; unavailable evidence cannot prove exit. import { parsePtySessionId, WORKTREE_ID_SEPARATOR } from '../../../shared/pty-session-id-format' -import { foldWslUncPathCaseInsensitiveParts, toWindowsWslUncPath } from '../../../shared/wsl-paths' +import { comparableWslPath, isAtOrUnder, isLinuxSpelled } from './wsl-move-comparable-path' import type { SessionInfo } from '../../daemon/types' export type WslMoveExitProof = @@ -119,16 +119,14 @@ function attributeByCwd( const inDistro = typeof wslDistro === 'string' && sameDistro(wslDistro, distro) const path = comparableWslPath(cwd, distro) const underRoot = path !== null && roots.some((root) => isAtOrUnder(path, root)) - // POSIX cwd alone cannot distinguish native Git Bash from wsl.exe sessions. + // A POSIX folder is a Linux path only inside WSL. A Git Bash `/c/...` is not, but a native-tagged + // or untagged `wsl.exe` shell's may be, so only one in a moved folder stays unknown. if (isLinuxSpelled(cwd) && !inDistro) { - return wslDistro === undefined || (wslDistro === null && underRoot) ? 'unknown' : 'elsewhere' + return typeof wslDistro !== 'string' && underRoot ? 'unknown' : 'elsewhere' } - if (underRoot) { - return 'in-scope' - } - const distroRoot = comparableWslPath('/', distro) - const pathInDistro = path !== null && distroRoot !== null && isAtOrUnder(path, distroRoot) - return inDistro || pathInDistro ? 'unknown' : 'elsewhere' + // A known folder outside the move is outside it, even in the moving distro: other projects there + // keep their terminals open through a move. + return underRoot ? 'in-scope' : 'elsewhere' } function keyPath(key: string): string | null { @@ -146,19 +144,6 @@ function comparableKey(key: string, distro: string): string { return `${key.slice(0, key.indexOf(WORKTREE_ID_SEPARATOR))}${WORKTREE_ID_SEPARATOR}${comparable}` } -function comparableWslPath(path: string, distro: string): string | null { - const unc = isLinuxSpelled(path) ? toWindowsWslUncPath(path, distro) : path - return foldWslUncPathCaseInsensitiveParts(unc)?.replace(/\/+$/, '') ?? null -} - -function isLinuxSpelled(path: string): boolean { - return /^\/(?!\/)/.test(path) -} - -function isAtOrUnder(path: string, root: string): boolean { - return path === root || path.startsWith(`${root}/`) -} - function sameDistro(left: string, right: string): boolean { return left.toLowerCase() === right.toLowerCase() } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-fence.ts b/src/main/persistence/moving-wsl-projects/wsl-move-fence.ts index 895a9cdf591..baecf24db7c 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-fence.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-fence.ts @@ -2,6 +2,8 @@ import type { WslMoveCutover, WslMoveCutoverPhase } from '../../../shared/wsl-move/wsl-move-cutover' import { WSL_MOVE_CUTOVER_PHASES } from '../../../shared/wsl-move/wsl-move-cutover' import { scanWslMoveCutovers } from './wsl-move-cutover-journal' +import { comparableWslPath } from './wsl-move-comparable-path' +import { wslMoveScopeRootPaths } from './wsl-move-scope-roots' export type WslMoveScopeIds = { repoIds: ReadonlySet @@ -15,8 +17,17 @@ export type WslMoveFenceState = { hidden: WslMoveScopeIds /** Preserve quiesced sessions through commit. */ sessionFrozen: WslMoveScopeIds + /** The fenced moves' folders, per distro and comparable, for a spawn that names no workspace. */ + fencedRoots: readonly WslMoveFencedRoots[] + /** The distro whose host owns each hidden row, for the retained entry's label. */ + hostDistro: { + repoIds: ReadonlyMap + folderWorkspaceIds: ReadonlyMap + } } +export type WslMoveFencedRoots = { distro: string; roots: readonly string[] } + const phaseIndex = (phase: WslMoveCutoverPhase): number => WSL_MOVE_CUTOVER_PHASES.indexOf(phase) const FENCED_FROM = phaseIndex('source-fenced') const HIDDEN_FROM = phaseIndex('destination-committed') @@ -34,6 +45,11 @@ export function wslMoveFenceFromCutovers( const fenced = { repoIds: new Set(), folderWorkspaceIds: new Set() } const hidden = { repoIds: new Set(), folderWorkspaceIds: new Set() } const sessionFrozen = { repoIds: new Set(), folderWorkspaceIds: new Set() } + const hostDistro = { + repoIds: new Map(), + folderWorkspaceIds: new Map() + } + const fencedRoots: WslMoveFencedRoots[] = [] for (const cutover of cutovers) { const index = phaseIndex(cutover.phase) const target = index >= HIDDEN_FROM ? hidden : index >= FENCED_FROM ? fenced : null @@ -41,12 +57,28 @@ export function wslMoveFenceFromCutovers( continue } addScope(target, cutover) + if (target === fenced) { + fencedRoots.push(fencedRootsOf(cutover)) + } + if (target === hidden) { + const { distro, scopeRepoIds, scopeFolderWorkspaceIds } = cutover.manifest.source + scopeRepoIds.forEach((id) => hostDistro.repoIds.set(id, distro)) + scopeFolderWorkspaceIds.forEach((id) => hostDistro.folderWorkspaceIds.set(id, distro)) + } // Keep retained sessions frozen even after the destination commits. if (index >= SESSION_FROZEN_FROM) { addScope(sessionFrozen, cutover) } } - return { fenced, hidden, sessionFrozen } + return { fenced, hidden, sessionFrozen, hostDistro, fencedRoots } +} + +function fencedRootsOf(cutover: Readonly): WslMoveFencedRoots { + const { distro } = cutover.manifest.source + const roots = wslMoveScopeRootPaths(cutover.manifest).flatMap( + (root) => comparableWslPath(root, distro) ?? [] + ) + return { distro, roots } } function addScope( diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-gate-facts.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-gate-facts.test.ts new file mode 100644 index 00000000000..d7154a09f2f --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-gate-facts.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-ipc-payload' +import type { AutomationRun } from '../../../shared/automations-types' +import { + collectWslMoveGateInputs, + listWslMovePanes, + type WslMoveGateFactSources, + type WslMoveLiveTerminal +} from './wsl-move-gate-facts' +import { assessWslMoveTerminals } from './wsl-move-terminal-gate' + +const WT = 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app' +const pane = (n: number) => `tab-${n}:${n}${n}${n}${n}${n}${n}${n}${n}-1111-4111-8111-111111111111` +const scope = { workspaceIds: [WT], workspaceKeys: new Set([WT]) } + +const terminal = (n: number, extra: Partial = {}): WslMoveLiveTerminal => ({ + ptyId: `pty-${n}`, + workspaceId: WT, + paneKey: pane(n), + wslDistro: 'Ubuntu', + foregroundAgent: null, + ...extra +}) + +const row = (paneKey: string, extra: Partial): AgentStatusIpcPayload => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The gate reads only the fields set here. + ({ + paneKey, + state: 'done', + prompt: '', + agentType: 'claude', + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + ...extra + }) as AgentStatusIpcPayload + +function sources(overrides: Partial = {}): WslMoveGateFactSources { + return { + listTerminals: () => [], + inspect: async () => ({ + foregroundProcess: 'bash', + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }), + agentRows: () => [], + scrollbackBytes: async () => 10, + automationRuns: () => [], + dispatches: () => [], + ...overrides + } +} + +describe('collectWslMoveGateInputs', () => { + it('passes idle shells and an idle resumable agent that owns its pane', async () => { + const inputs = await collectWslMoveGateInputs( + sources({ + listTerminals: () => [terminal(1), terminal(2, { foregroundAgent: 'claude' })], + inspect: async (ptyId) => + ptyId === 'pty-2' + ? { + foregroundProcess: 'claude', + hasChildProcesses: true, + childProcessEvidence: 'children' + } + : { + foregroundProcess: 'bash', + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }, + agentRows: (paneKey) => + paneKey === pane(2) + ? [ + row(pane(2), { + providerSession: { key: 'session_id', id: '0f0e0d0c-1111-4111-8111-111111111111' } + }) + ] + : [] + }), + scope + ) + expect(assessWslMoveTerminals(inputs).verdict).toBe('ready') + }) + + it('blocks a working agent, a dev server, an unanswered probe and a paneless terminal', async () => { + const inputs = await collectWslMoveGateInputs( + sources({ + listTerminals: () => [ + terminal(1, { foregroundAgent: 'claude' }), + terminal(2), + terminal(3), + terminal(4, { paneKey: null }) + ], + inspect: async (ptyId) => { + if (ptyId === 'pty-3') { + throw new Error('daemon gone') + } + return { + foregroundProcess: 'node', + hasChildProcesses: true, + childProcessEvidence: 'children' + } + }, + // The newest row wins over an older finished one. + agentRows: (paneKey) => + paneKey === pane(1) + ? [row(pane(1), { state: 'done' }), row(pane(1), { state: 'working', receivedAt: 2 })] + : [] + }), + scope + ) + const verdict = assessWslMoveTerminals(inputs) + expect(verdict).toEqual({ + verdict: 'blocked', + blockers: [ + { kind: 'agent-working', ids: [pane(1)], actions: ['wait', 'end', 'skip-project'] }, + { kind: 'process-running', ids: [pane(2)], actions: ['end', 'skip-project'] }, + { kind: 'unverifiable', ids: [pane(3), 'pty:pty-4'], actions: ['wait', 'skip-project'] } + ] + }) + }) + + it('never lets a stale agent row vouch for another foreground process', async () => { + const inputs = await collectWslMoveGateInputs( + sources({ + listTerminals: () => [terminal(1, { foregroundAgent: null })], + inspect: async () => ({ + foregroundProcess: 'vim', + hasChildProcesses: true, + childProcessEvidence: 'children' + }), + agentRows: () => [ + row(pane(1), { + providerSession: { key: 'session_id', id: '0f0e0d0c-1111-4111-8111-111111111111' } + }) + ] + }), + scope + ) + expect(assessWslMoveTerminals(inputs)).toMatchObject({ + verdict: 'blocked', + blockers: [{ kind: 'process-running' }] + }) + }) + + it('takes running automation runs and live dispatches in scope only', async () => { + const run = (id: string, status: AutomationRun['status'], workspaceId: string | null) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The gate reads only id, status and workspaceId. + ({ id, status, workspaceId }) as AutomationRun + const inputs = await collectWslMoveGateInputs( + sources({ + automationRuns: () => [ + run('running', 'dispatched', WT), + run('done', 'completed', WT), + run('elsewhere', 'pending', 'other::C:\\src') + ], + dispatches: () => [ + { dispatchId: 'live', worktreeId: WT, state: 'ready' }, + { dispatchId: 'settled', worktreeId: WT, state: 'stopped' }, + { dispatchId: 'other', worktreeId: 'other::C:\\src', state: 'ready' } + ] + }), + scope + ) + expect(inputs.runningAutomationRunIds).toEqual(['running']) + expect(inputs.activeDispatchIds).toEqual(['live']) + }) + + it('reads an unreadable scrollback size as unknown, which blocks', async () => { + const inputs = await collectWslMoveGateInputs( + sources({ + listTerminals: () => [terminal(1)], + scrollbackBytes: async () => { + throw new Error('EIO') + } + }), + scope + ) + expect(assessWslMoveTerminals(inputs)).toMatchObject({ + verdict: 'blocked', + blockers: [{ kind: 'unverifiable' }] + }) + }) +}) + +describe('listWslMovePanes', () => { + it('lists each pane with its session, skipping terminals with no pane', () => { + expect( + listWslMovePanes( + sources({ listTerminals: () => [terminal(1), terminal(2, { paneKey: null })] }), + scope + ) + ).toEqual([{ paneKey: pane(1), sessionId: 'pty-1', wslDistro: 'Ubuntu' }]) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-gate-facts.ts b/src/main/persistence/moving-wsl-projects/wsl-move-gate-facts.ts new file mode 100644 index 00000000000..6b9633b0505 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-gate-facts.ts @@ -0,0 +1,213 @@ +/** + * The terminal gate's production facts (mover design, section 3.2), each read from the store that + * owns it: agent state from the hook server's status store (the single owner, AGENTS.md: Agent + * Status), foreground work from the same process probe the close-tab warning uses, and running + * automation runs and live orchestration dispatches from their own stores. This only reads. + */ +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-ipc-payload' +import { getAgentResumeArgv } from '../../../shared/agent-resume-argv' +import { isResumableTuiAgent } from '../../../shared/agent-session-resume' +import { isFinalAutomationRunStatus, type AutomationRun } from '../../../shared/automations-types' +import { probePtyRunningWorkWithInspection } from '../../../shared/pty-running-work-probe' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { TerminalProcessInspection } from '../../../shared/terminal-process-inspection' +import type { WslMoveTerminalScope } from './wsl-move-driver-ports' +import type { WslGuestPaneForeground } from './wsl-move-guest-foreground' +import { WSL_MOVE_LIVE_WORKER_STATES } from './wsl-move-orchestration-history-rewrite' +import type { WslMoveRestoreHintPane } from './wsl-move-restore-hints' +import type { WslMoveGateInputs, WslMovePaneFacts } from './wsl-move-terminal-gate' + +export type WslMoveLiveTerminal = { + ptyId: string + workspaceId: string + paneKey: string | null + wslDistro: string | null + foregroundAgent: string | null +} + +export type WslMoveDispatchRow = { dispatchId: string; worktreeId: string | null; state: string } + +export type WslMoveGateFactSources = { + listTerminals: (workspaceIds: readonly string[]) => readonly WslMoveLiveTerminal[] + /** + * The pane a terminal is bound to, for one the runtime rediscovered after a relaunch and has not + * named yet; without it the terminal reads unverifiable and blocks every move. + */ + paneKeyForPty?: (ptyId: string) => string | null + inspect: (ptyId: string) => Promise + /** The hook server's rows for a pane. */ + agentRows: (paneKey: string) => readonly AgentStatusIpcPayload[] + /** The pane's saved scrollback in bytes; 0 when none. */ + scrollbackBytes: (tabId: string, leafId: string) => Promise + automationRuns: () => readonly AutomationRun[] + dispatches: () => readonly WslMoveDispatchRow[] + /** + * Bridge WSL panes read inside their distro: on Windows the host sees only `wsl.exe`, which + * every live pane has. Absent off Windows. Never rejects into an idle verdict. + */ + guestForegrounds?: ( + distro: string, + paneKeys: readonly string[] + ) => Promise> +} + +// The close-tab warning's budget; a probe that does not answer reads as unverifiable and blocks. +const PROBE_TIMEOUT_MS = 3_000 + +const LIVE_WORKER_STATES: ReadonlySet = new Set(WSL_MOVE_LIVE_WORKER_STATES) + +export async function collectWslMoveGateInputs( + sources: WslMoveGateFactSources, + scope: WslMoveTerminalScope +): Promise { + const terminals = withPaneKeys(sources, sources.listTerminals(scope.workspaceIds)) + const probes = await probePtyRunningWorkWithInspection( + terminals.map((terminal) => terminal.ptyId), + { timeoutMs: PROBE_TIMEOUT_MS }, + sources.inspect + ) + const verdicts = new Map(probes.map((probe) => [probe.ptyId, probe.verdict])) + // A slept pane's wsl.exe already exited and it has no guest shell left to find, so the distro is + // asked only about panes still running or ones Windows cannot observe: after a relaunch the + // reattached wsl.exe's children read unverifiable, and only the distro can tell idle from busy. + const guest = await readGuestForegrounds( + sources, + terminals.filter((terminal) => verdicts.get(terminal.ptyId) !== 'exited') + ) + const panes: WslMovePaneFacts[] = [] + for (const terminal of terminals) { + const verdict = verdicts.get(terminal.ptyId) ?? 'unverifiable' + const guestAnswer = guest.get(terminal.ptyId) + const answer = + verdict === 'live' + ? (guestAnswer ?? 'process-running') + : verdict === 'unverifiable' && guestAnswer && guestAnswer !== 'unverifiable' + ? guestAnswer + : null + const foreground = + verdict === 'exited' + ? 'shell-idle' + : answer === null + ? 'unverifiable' + : typeof answer === 'object' + ? 'process-running' + : answer + const pane = terminal.paneKey ? parsePaneKey(terminal.paneKey) : null + if (!terminal.paneKey || !pane) { + // A terminal with no pane cannot be restored on the host, so it can never pass as idle. + panes.push({ + paneKey: `pty:${terminal.ptyId}`, + foreground: 'unverifiable', + scrollbackBytes: 0 + }) + continue + } + const agent = agentFacts( + sources.agentRows(terminal.paneKey), + typeof answer === 'object' && answer !== null ? answer.agent : terminal.foregroundAgent + ) + panes.push({ + paneKey: terminal.paneKey, + foreground, + ...(agent ? { agent } : {}), + scrollbackBytes: await sources.scrollbackBytes(pane.tabId, pane.leafId).catch(() => -1) + }) + } + const inScope = workspaceMatcher(scope) + return { + panes, + runningAutomationRunIds: sources + .automationRuns() + .filter((run) => !isFinalAutomationRunStatus(run.status) && inScope(run.workspaceId)) + .map((run) => run.id), + activeDispatchIds: sources + .dispatches() + .filter((row) => LIVE_WORKER_STATES.has(row.state) && inScope(row.worktreeId)) + .map((row) => row.dispatchId) + } +} + +async function readGuestForegrounds( + sources: WslMoveGateFactSources, + terminals: readonly WslMoveLiveTerminal[] +): Promise> { + const byPty = new Map() + const read = sources.guestForegrounds + if (!read) { + return byPty + } + const byDistro = new Map() + for (const terminal of terminals) { + if (terminal.wslDistro && terminal.paneKey) { + byDistro.set(terminal.wslDistro, [...(byDistro.get(terminal.wslDistro) ?? []), terminal]) + } + } + await Promise.all( + [...byDistro].map(async ([distro, members]) => { + const answers = await read( + distro, + members.map((terminal) => terminal.paneKey ?? '') + ).catch(() => null) + for (const terminal of members) { + byPty.set( + terminal.ptyId, + (terminal.paneKey && answers?.get(terminal.paneKey)) || 'unverifiable' + ) + } + }) + ) + return byPty +} + +/** The panes the pause carries, read while their sessions still run. */ +export function listWslMovePanes( + sources: Pick, + scope: WslMoveTerminalScope +): WslMoveRestoreHintPane[] { + return withPaneKeys(sources, sources.listTerminals(scope.workspaceIds)).flatMap((terminal) => + terminal.paneKey + ? [{ paneKey: terminal.paneKey, sessionId: terminal.ptyId, wslDistro: terminal.wslDistro }] + : [] + ) +} + +function withPaneKeys( + sources: Pick, + terminals: readonly WslMoveLiveTerminal[] +): WslMoveLiveTerminal[] { + return terminals.map((terminal) => + terminal.paneKey + ? terminal + : { ...terminal, paneKey: sources.paneKeyForPty?.(terminal.ptyId) ?? null } + ) +} + +function agentFacts( + rows: readonly AgentStatusIpcPayload[], + foregroundAgent: string | null +): WslMovePaneFacts['agent'] | undefined { + const current = rows + .filter((row) => !row.providerSessionOnly) + .reduce( + (latest, row) => (!latest || row.receivedAt > latest.receivedAt ? row : latest), + undefined + ) + if (!current) { + return undefined + } + const { agentType, providerSession } = current + const resumable = + isResumableTuiAgent(agentType) && + providerSession !== undefined && + getAgentResumeArgv(agentType, providerSession) !== null + return { + state: current.state, + resumable, + ownsForeground: agentType !== undefined && foregroundAgent === agentType + } +} + +function workspaceMatcher(scope: WslMoveTerminalScope): (id: string | null) => boolean { + const ids = new Set([...scope.workspaceIds, ...scope.workspaceKeys]) + return (id) => id !== null && ids.has(id) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-gate-stub-agent-hooks.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-gate-stub-agent-hooks.test.ts new file mode 100644 index 00000000000..8d563b49290 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-gate-stub-agent-hooks.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentHookServer } from '../../agent-hooks/server' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { collectWslMoveGateInputs, type WslMoveGateFactSources } from './wsl-move-gate-facts' +import { assessWslMoveTerminals } from './wsl-move-terminal-gate' + +const WT = 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app' +const PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const scope = { workspaceIds: [WT], workspaceKeys: new Set([WT]) } + +// The verify stub "codex" posts real hook events from the distro. Its +// Stop must let an idle agent pass the gate; only a turn still running may block. (The stub fires +// SessionStart at launch, which reads as a turn start, so a stub never given a prompt blocks.) +describe('the WSL move gate with the stub agent hook events', () => { + let server: AgentHookServer + let dir: string + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'wsl-move-stub-agent-')) + server = new AgentHookServer() + await server.start({ env: 'production' }) + }) + + afterEach(() => { + server.stop() + rmSync(dir, { recursive: true, force: true }) + }) + + async function hook(event: string, prompt = ''): Promise { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: WT, + // The stub's payload: no turn ids, a transcript it writes itself. + payload: { + session_id: '0f0e0d0c-0b0a-4908-8706-050403020100', + transcript_path: join(dir, 'rollout-stub.jsonl'), + cwd: '/home/dev/app', + hook_event_name: event, + model: 'stub', + prompt, + last_assistant_message: event === 'Stop' ? `stub answer to: ${prompt}` : '' + } + }) + }) + expect(response.status).toBe(204) + } + + async function gate(): Promise> { + const sources: WslMoveGateFactSources = { + listTerminals: () => [ + { + ptyId: 'pty-1', + workspaceId: WT, + paneKey: PANE, + wslDistro: 'Ubuntu', + foregroundAgent: null + } + ], + inspect: async () => ({ + foregroundProcess: 'wsl.exe', + hasChildProcesses: true, + childProcessEvidence: 'children' + }), + agentRows: (paneKey) => server.getStatusSnapshotForPane(paneKey), + scrollbackBytes: async () => 10, + automationRuns: () => [], + dispatches: () => [], + // The distro sees the stub as the shell's one foreground job. + guestForegrounds: async () => new Map([[PANE, { agent: 'codex' }]]) + } + return assessWslMoveTerminals(await collectWslMoveGateInputs(sources, scope)) + } + + it('passes an agent whose turn ended with Stop', async () => { + await hook('SessionStart') + await hook('UserPromptSubmit', 'hello p4') + expect(await gate()).toMatchObject({ + verdict: 'blocked', + blockers: [{ kind: 'agent-working' }] + }) + await hook('Stop', 'hello p4') + expect(await gate()).toEqual({ verdict: 'ready', paneKeys: [PANE] }) + }) + + it('passes an agent resumed in its pane until its next prompt starts a turn', async () => { + await hook('SessionStart') + await hook('UserPromptSubmit', 'hello p4') + await hook('Stop', 'hello p4') + // Resumed after a move or a wake: Codex posts SessionStart only with the first prompt, so an + // idle resumed agent posts nothing and passes. (The real-host stub posts it at launch instead.) + expect(await gate()).toEqual({ verdict: 'ready', paneKeys: [PANE] }) + await hook('SessionStart') + await hook('UserPromptSubmit', 'next') + expect(await gate()).toMatchObject({ verdict: 'blocked' }) + await hook('Stop', 'next') + expect(await gate()).toEqual({ verdict: 'ready', paneKeys: [PANE] }) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.test.ts new file mode 100644 index 00000000000..9fee19525ff --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.test.ts @@ -0,0 +1,342 @@ +import { execFileSync } from 'node:child_process' +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-ipc-payload' +import type { TerminalProcessInspection } from '../../../shared/terminal-process-inspection' +import { describe, expect, it, vi } from 'vitest' +import { collectWslMoveGateInputs, type WslMoveGateFactSources } from './wsl-move-gate-facts' +import { + classifyWslGuestPaneForeground, + parseWslGuestPaneProcesses, + readWslGuestPaneShellCwd, + WSL_GUEST_PANE_PROCESS_SCRIPT, + type WslGuestProcessRow +} from './wsl-move-guest-foreground' +import { assessWslMoveTerminals } from './wsl-move-terminal-gate' + +const WT = 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app' +const KEY_A = 'tab-1:11111111-1111-4111-8111-111111111111' +const KEY_B = 'tab-2:22222222-1111-4111-8111-111111111111' +const scope = { workspaceIds: [WT], workspaceKeys: new Set([WT]) } + +const RELAY = 10 +// The WSL relay (`init`) starts the pane's login shell as a session leader. +const relay: WslGuestProcessRow = { + pid: RELAY, + ppid: 1, + sid: 1, + argc: 1, + comm: 'init', + paneKey: null +} +const shellA: WslGuestProcessRow = { + pid: 100, + ppid: RELAY, + sid: 100, + argc: 1, + comm: 'bash', + paneKey: KEY_A +} +const child = ( + pid: number, + comm: string, + extra: Partial = {} +): WslGuestProcessRow => ({ pid, ppid: 100, sid: 100, argc: 1, comm, paneKey: KEY_A, ...extra }) + +describe('classifyWslGuestPaneForeground', () => { + it('reads a pane whose shell has nothing else, or only an idle nested shell, as idle', () => { + expect(classifyWslGuestPaneForeground([relay, shellA], KEY_A)).toBe('shell-idle') + expect(classifyWslGuestPaneForeground([relay, shellA, child(110, 'zsh')], KEY_A)).toBe( + 'shell-idle' + ) + }) + + // Every one of these survived the old foreground-group check and would end at the move's Sleep. + it.each([ + ['a foreground program', [child(150, 'vim')]], + ['a background job', [child(150, 'npm')]], + ['a nohup or disowned job reparented to init', [child(150, 'node', { ppid: 1 })]], + ['a setsid daemon that kept the key', [child(150, 'redis-server', { ppid: 1, sid: 150 })]], + ['a child that cleared its environment', [child(150, 'python3', { paneKey: null })]], + ['a script waiting at `read`', [child(150, 'bash', { argc: 2 })]], + ['`sh -c` running a command', [child(150, 'sh', { argc: 3 })]] + ])('blocks %s', (_label, extra) => { + expect(classifyWslGuestPaneForeground([relay, shellA, ...extra], KEY_A)).toBe('process-running') + }) + + it("ignores another pane's work and the relay above the shell", () => { + const shellB = { ...shellA, pid: 200, sid: 200, paneKey: KEY_B } + const rows = [relay, shellA, shellB, child(250, 'vim', { ppid: 200, sid: 200, paneKey: KEY_B })] + expect(classifyWslGuestPaneForeground(rows, KEY_A)).toBe('shell-idle') + expect(classifyWslGuestPaneForeground(rows, KEY_B)).toBe('process-running') + }) + + it('finds the shell under a WSL relay that carries the pane key too', () => { + // A live pane read unverifiable because its relay held the key above the shell. + const taggedRelay = { ...relay, paneKey: KEY_A } + expect(classifyWslGuestPaneForeground([taggedRelay, shellA], KEY_A)).toBe('shell-idle') + expect(classifyWslGuestPaneForeground([taggedRelay, shellA, child(150, 'vim')], KEY_A)).toBe( + 'process-running' + ) + }) + + it("names an agent that is the shell's one job, with its own children, as the foreground", () => { + // An idle codex at its prompt read as a running program. + const codex = child(150, 'codex', { argc: 3 }) + const hook = child(160, 'sh', { ppid: 150, argc: 2 }) + expect(classifyWslGuestPaneForeground([relay, shellA, codex, hook], KEY_A)).toEqual({ + agent: 'codex' + }) + }) + + it('still blocks an agent with a job of the shell beside it', () => { + const codex = child(150, 'codex') + expect(classifyWslGuestPaneForeground([relay, shellA, codex, child(170, 'sleep')], KEY_A)).toBe( + 'process-running' + ) + expect( + classifyWslGuestPaneForeground( + [relay, shellA, codex, child(170, 'node', { ppid: 1, sid: 170 })], + KEY_A + ) + ).toBe('process-running') + }) + + it('never reads a pane whose shell was not found as idle', () => { + expect(classifyWslGuestPaneForeground([relay], KEY_A)).toBe('unverifiable') + }) +}) + +describe('readWslGuestPaneShellCwd', () => { + it("reads the pane's shell folder, not its relay's or a child's", () => { + const rows = [ + { ...relay, paneKey: KEY_A, cwd: '/' }, + { ...shellA, cwd: '/home/dev/app/deep/er' }, + child(150, 'vim', { cwd: '/tmp' }) + ] + expect(readWslGuestPaneShellCwd(rows, KEY_A)).toBe('/home/dev/app/deep/er') + }) + + it('reads nothing when the shell is missing, ambiguous, or its folder unreadable', () => { + expect(readWslGuestPaneShellCwd([relay], KEY_A)).toBeNull() + expect(readWslGuestPaneShellCwd([relay, shellA], KEY_A)).toBeNull() + const twin = { ...shellA, pid: 300, ppid: 1, sid: 300, cwd: '/b' } + expect(readWslGuestPaneShellCwd([relay, { ...shellA, cwd: '/a' }, twin], KEY_A)).toBeNull() + }) +}) + +const table = (stats: string[], tagged: string[]) => + [ + 'ORCA_PROCESS_TABLE_BEGIN', + ...stats, + 'ORCA_PROCESS_TABLE_TAGGED', + ...tagged, + 'ORCA_PROCESS_TABLE_END', + '' + ].join('\n') + +describe('parseWslGuestPaneProcesses', () => { + it('reads every stat line and adds pane key, argc and folder to the tagged ones', () => { + expect( + parseWslGuestPaneProcesses( + table( + ['10 (bash) S 1 10 10 0', '11 (my (odd) prog) S 10 11 10 0', '12 (zsh) S 1 12 12 0'], + [`10\t1\t${KEY_A}\t/home/a\tb`, '12\t2\t\t/gone (deleted)'] + ) + ) + ).toEqual([ + { pid: 10, ppid: 1, sid: 10, argc: 1, comm: 'bash', paneKey: KEY_A, cwd: '/home/a\tb' }, + { pid: 11, ppid: 10, sid: 10, argc: null, comm: 'my (odd) prog', paneKey: null }, + { pid: 12, ppid: 1, sid: 12, argc: 2, comm: 'zsh', paneKey: null } + ]) + }) + + it('drops malformed lines, and a table missing a fence counts as unread', () => { + expect(parseWslGuestPaneProcesses(table(['bad line', '10 (bash) S 1 10 10'], []))).toEqual([ + { pid: 10, ppid: 1, sid: 10, argc: null, comm: 'bash', paneKey: null } + ]) + expect(parseWslGuestPaneProcesses('ORCA_PROCESS_TABLE_BEGIN\n10 (bash) S 1 10 10\n')).toBeNull() + }) + + // Review of #33398: a job started without the pane key must still keep its pane busy. + it('keeps a pane busy for a job in its session that carries no pane key', () => { + const rows = parseWslGuestPaneProcesses( + table( + [`${RELAY} (init) S 1 1 1`, '100 (bash) S 10 100 100', '200 (sleep) S 100 200 100'], + [`100\t1\t${KEY_A}\t/home/a`] + ) + ) + expect(classifyWslGuestPaneForeground(rows ?? [], KEY_A)).toBe('process-running') + }) +}) + +describe.runIf(process.platform === 'linux')('WSL_GUEST_PANE_PROCESS_SCRIPT on Linux', () => { + it('lists every process with pid, ppid and sid', () => { + const out = execFileSync('sh', ['-c', WSL_GUEST_PANE_PROCESS_SCRIPT], { encoding: 'utf8' }) + const rows = parseWslGuestPaneProcesses(out) ?? [] + expect(rows.length).toBeGreaterThan(1) + for (const row of rows) { + expect(row.pid).toBeGreaterThan(0) + } + }) +}) + +describe('collectWslMoveGateInputs with a guest foreground reader', () => { + // The Windows host sees `wsl.exe` under every live bridge pane: this is what it reported. + const windowsSaysLive: WslMoveGateFactSources['inspect'] = async () => ({ + foregroundProcess: 'wsl.exe', + hasChildProcesses: true, + childProcessEvidence: 'children' + }) + const base: WslMoveGateFactSources = { + listTerminals: () => [ + { + ptyId: 'pty-1', + workspaceId: WT, + paneKey: KEY_A, + wslDistro: 'Ubuntu', + foregroundAgent: null + } + ], + inspect: windowsSaysLive, + agentRows: () => [], + scrollbackBytes: async () => 10, + automationRuns: () => [], + dispatches: () => [] + } + + it('passes an idle bridge shell the Windows host reads as running wsl.exe', async () => { + const inputs = await collectWslMoveGateInputs( + { ...base, guestForegrounds: async () => new Map([[KEY_A, 'shell-idle']]) }, + scope + ) + expect(inputs.panes[0]?.foreground).toBe('shell-idle') + expect(assessWslMoveTerminals(inputs).verdict).toBe('ready') + }) + + it('still blocks a real guest program, and a guest that cannot be read', async () => { + const busy = await collectWslMoveGateInputs( + { ...base, guestForegrounds: async () => new Map([[KEY_A, 'process-running']]) }, + scope + ) + expect(assessWslMoveTerminals(busy).verdict).toBe('blocked') + const failed = await collectWslMoveGateInputs( + { + ...base, + guestForegrounds: async () => { + throw new Error('wsl_unavailable') + } + }, + scope + ) + expect(failed.panes[0]?.foreground).toBe('unverifiable') + }) + + it('lets the hook status decide for an agent at its prompt, and keeps a working one blocking', async () => { + const rows = (state: AgentStatusIpcPayload['state']) => (): AgentStatusIpcPayload[] => [ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the gate reads only the fields set here. + { + paneKey: KEY_A, + state, + prompt: '', + agentType: 'codex', + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + providerSession: { key: 'session_id', id: '0f0e0d0c-1111-4111-8111-111111111111' } + } as AgentStatusIpcPayload + ] + const guestForegrounds = async () => new Map([[KEY_A, { agent: 'codex' }]]) + const idle = await collectWslMoveGateInputs( + { ...base, agentRows: rows('done'), guestForegrounds }, + scope + ) + expect(assessWslMoveTerminals(idle).verdict).toBe('ready') + const working = await collectWslMoveGateInputs( + { ...base, agentRows: rows('working'), guestForegrounds }, + scope + ) + expect(assessWslMoveTerminals(working)).toMatchObject({ + verdict: 'blocked', + blockers: [{ kind: 'agent-working' }] + }) + }) + + // After an app relaunch Windows could no longer observe the reattached wsl.exe's + // children, so every live bridge pane read unverifiable for good and blocked every move. + it('asks the distro when Windows cannot observe a reattached pane after a relaunch', async () => { + const hostBlind = async (): Promise => ({ + foregroundProcess: null, + hasChildProcesses: false, + childProcessEvidence: 'unverifiable' + }) + const idle = await collectWslMoveGateInputs( + { + ...base, + inspect: hostBlind, + guestForegrounds: async () => new Map([[KEY_A, 'shell-idle']]) + }, + scope + ) + expect(idle.panes[0]?.foreground).toBe('shell-idle') + const busy = await collectWslMoveGateInputs( + { + ...base, + inspect: hostBlind, + guestForegrounds: async () => new Map([[KEY_A, 'process-running']]) + }, + scope + ) + expect(busy.panes[0]?.foreground).toBe('process-running') + // No shell found in the distro either: still never idle. + const gone = await collectWslMoveGateInputs( + { + ...base, + inspect: hostBlind, + guestForegrounds: async () => new Map([[KEY_A, 'unverifiable' as const]]) + }, + scope + ) + expect(gone.panes[0]?.foreground).toBe('unverifiable') + }) + + // A terminal the runtime rediscovered after a relaunch carries no pane key until a layout names + // it again, and a paneless terminal reads unverifiable: the bound pane is asked for instead. + it('finds a reattached terminal its pane when the runtime has not named it yet', async () => { + const inputs = await collectWslMoveGateInputs( + { + ...base, + listTerminals: () => [ + { + ptyId: 'pty-1', + workspaceId: WT, + paneKey: null, + wslDistro: 'Ubuntu', + foregroundAgent: null + } + ], + paneKeyForPty: (ptyId) => (ptyId === 'pty-1' ? KEY_A : null), + guestForegrounds: async () => new Map([[KEY_A, 'shell-idle']]) + }, + scope + ) + expect(inputs.panes).toMatchObject([{ paneKey: KEY_A, foreground: 'shell-idle' }]) + }) + + it('leaves a slept pane idle without asking the distro, which has no shell of it left', async () => { + // Every slept pane read unverifiable, so Sleep could no longer quiet a project. + const guestForegrounds = vi.fn(async () => new Map([[KEY_A, 'unverifiable' as const]])) + const slept = await collectWslMoveGateInputs( + { + ...base, + inspect: async () => ({ + foregroundProcess: null, + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }), + guestForegrounds + }, + scope + ) + expect(slept.panes[0]?.foreground).toBe('shell-idle') + expect(guestForegrounds).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.ts b/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.ts new file mode 100644 index 00000000000..8e6111f8341 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.ts @@ -0,0 +1,215 @@ +/** + * What a bridge WSL pane is running, read inside its distro. On Windows the pane's own process is + * `wsl.exe`, which every live pane has, so the host process table cannot tell an idle shell from a + * running program. Each guest process of a pane inherits `ORCA_PANE_KEY`. + * + * Idle means the pane's shell has nothing else: no foreground program, no background job (`&`, + * `nohup`, `disown`), no daemon it started (`setsid` keeps the key), and no script it runs (a waiting + * `read` is still a script). The move's Sleep ends all of those, so any of them blocks. + */ +import { recognizeAgentProcess } from '../../../shared/agent-process-recognition' +import { isShellProcess } from '../../../shared/shell-process-detection' + +/** + * `agent`: the shell's one foreground job is an agent CLI and everything else running is its own + * subtree, so its hook status decides; Windows sees only `wsl.exe` and could never say so. + */ +export type WslGuestPaneForeground = + | 'shell-idle' + | 'process-running' + | 'unverifiable' + | { agent: string } + +export type WslGuestProcessRow = { + pid: number + ppid: number + /** Session id. */ + sid: number + /** + * Arguments in /proc//cmdline: an interactive shell has one, a script or `-c` more. Read + * for pane processes only; null for the rest, which a shell then counts as busy. + */ + argc: number | null + paneKey: string | null + comm: string + /** The process's working directory, from /proc//cwd; read for pane processes only. */ + cwd?: string +} + +const BEGIN = 'ORCA_PROCESS_TABLE_BEGIN' +const TAGGED = 'ORCA_PROCESS_TABLE_TAGGED' +const END = 'ORCA_PROCESS_TABLE_END' + +/** + * Every process's /proc//stat in one `cat`, then the pane key, argc and cwd of the Orca pane + * processes one `grep` over every environ finds. Untagged processes stay in the table, so a job + * started without the key (`env -i`) still keeps its pane busy; the per-process work follows the + * pane processes, not the distro's process count. POSIX sh and BusyBox tools only. + */ +export const WSL_GUEST_PANE_PROCESS_SCRIPT = [ + `echo ${BEGIN}`, + 'cat /proc/[0-9]*/stat 2>/dev/null', + `echo ${TAGGED}`, + "for e in $(grep -l -s 'ORCA_PANE_KEY=' /proc/[0-9]*/environ); do", + ' d=${e%/environ}', + ` k=$(tr '\\0' '\\n' < "$e" 2>/dev/null | sed -n 's/^ORCA_PANE_KEY=//p' | head -n 1)`, + ` n=$(tr '\\0' '\\n' < "$d/cmdline" 2>/dev/null | wc -l)`, + ' w=$(readlink "$d/cwd" 2>/dev/null)', + ` printf '%s\\t%s\\t%s\\t%s\\n' "\${d#/proc/}" "$n" "$k" "$w"`, + 'done', + `echo ${END}` +].join('\n') + +/** The table between its fences; null unless all three are there, so a cut-off table never counts. */ +export function parseWslGuestPaneProcesses(stdout: string): WslGuestProcessRow[] | null { + const lines = stdout.split('\n').map((line) => line.replace(/\r$/, '')) + const begin = lines.indexOf(BEGIN) + const tagged = lines.indexOf(TAGGED) + const end = lines.lastIndexOf(END) + if (begin === -1 || tagged <= begin || end <= tagged) { + return null + } + const rows = new Map() + for (const line of lines.slice(begin + 1, tagged)) { + // comm may hold spaces and parens; the fields after it start past its last `)`. + const open = line.indexOf(' (') + const close = line.lastIndexOf(')') + const pid = Number(line.slice(0, open)) + const [, ppid, , sid] = line + .slice(close + 2) + .split(' ') + .map(Number) + if (open <= 0 || close < open || ![pid, ppid, sid].every(Number.isInteger)) { + continue + } + const comm = line.slice(open + 2, close) + rows.set(pid, { pid, ppid, sid, argc: null, paneKey: null, comm }) + } + for (const line of lines.slice(tagged + 1, end)) { + // The cwd is last and may itself hold a tab. + const [pid, argc, paneKey, ...cwd] = line.split('\t') + const row = rows.get(Number(pid)) + if (!row || paneKey === undefined) { + continue + } + const folder = cwd.join('\t') + rows.set(row.pid, { + ...row, + argc: Number.isInteger(Number(argc?.trim())) ? Number(argc.trim()) : null, + paneKey: paneKey.trim() || null, + ...(readableCwd(folder) ? { cwd: folder } : {}) + }) + } + return [...rows.values()] +} + +/** A pane whose shell cannot be found is unverifiable, never idle. */ +export function classifyWslGuestPaneForeground( + rows: readonly WslGuestProcessRow[], + paneKey: string +): WslGuestPaneForeground { + const byPid = new Map(rows.map((row) => [row.pid, row])) + const shells = paneShells(rows, paneKey, byPid) + if (shells.length !== 1) { + // None, or a second root (a tagged daemon that is itself a shell): never read as idle. + return shells.length === 0 ? 'unverifiable' : 'process-running' + } + const [shell] = shells + const ancestors = new Set() + for (let row = byPid.get(shell.ppid); row && !ancestors.has(row.pid); row = byPid.get(row.ppid)) { + ancestors.add(row.pid) + } + const members = new Map() + for (const row of rows) { + if (row.pid === shell.pid || ancestors.has(row.pid)) { + continue + } + if (row.paneKey === paneKey || row.sid === shell.sid || descendsFrom(row, shell.pid, byPid)) { + members.set(row.pid, row) + } + } + // A shell whose arguments were not read may be running a script: never idle. + const busy = [...members.values()].filter( + (row) => !isShellProcess(row.comm) || (row.argc ?? 2) > 1 + ) + if (busy.length === 0) { + return 'shell-idle' + } + const agents = busy.flatMap((row) => { + const agent = row.ppid === shell.pid ? recognizeAgentProcess(row.comm)?.agent : undefined + return agent ? [{ pid: row.pid, agent }] : [] + }) + // A job beside the agent (`&`, nohup, a daemon it left behind) still dies with the Sleep. + if ( + agents.length === 1 && + busy.every((row) => row.pid === agents[0].pid || descendsFrom(row, agents[0].pid, byPid)) + ) { + return { agent: agents[0].agent } + } + return 'process-running' +} + +/** The folder the pane's shell is in; null unless exactly one shell owns the pane. */ +export function readWslGuestPaneShellCwd( + rows: readonly WslGuestProcessRow[], + paneKey: string +): string | null { + const shells = paneShells(rows, paneKey, new Map(rows.map((row) => [row.pid, row]))) + return shells.length === 1 ? (shells[0].cwd ?? null) : null +} + +// The pane's shell: the topmost tagged shell. The WSL relay above it may carry the key too, so +// tagged non-shell ancestors don't hide it. +function paneShells( + rows: readonly WslGuestProcessRow[], + paneKey: string, + byPid: ReadonlyMap +): WslGuestProcessRow[] { + return rows.filter( + (row) => + row.paneKey === paneKey && + isShellProcess(row.comm) && + !hasTaggedShellAncestor(row, paneKey, byPid) + ) +} + +// A removed folder reads back as ` (deleted)`, which no shell can return to. +function readableCwd(cwd: string): boolean { + return cwd.startsWith('/') && !cwd.endsWith(' (deleted)') +} + +function descendsFrom( + row: WslGuestProcessRow, + ancestorPid: number, + byPid: ReadonlyMap +): boolean { + const seen = new Set() + for (let current = byPid.get(row.ppid); current; current = byPid.get(current.ppid)) { + if (current.pid === ancestorPid) { + return true + } + if (seen.has(current.pid)) { + return false + } + seen.add(current.pid) + } + return false +} + +function hasTaggedShellAncestor( + row: WslGuestProcessRow, + paneKey: string, + byPid: ReadonlyMap +): boolean { + const seen = new Set() + for (let current = byPid.get(row.ppid); current; current = byPid.get(current.ppid)) { + if (seen.has(current.pid)) { + return false + } + seen.add(current.pid) + if (current.paneKey === paneKey && isShellProcess(current.comm)) { + return true + } + } + return false +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-guest-process-table-cost.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-guest-process-table-cost.test.ts new file mode 100644 index 00000000000..6ed355ed4ce --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-guest-process-table-cost.test.ts @@ -0,0 +1,74 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + classifyWslGuestPaneForeground, + parseWslGuestPaneProcesses, + WSL_GUEST_PANE_PROCESS_SCRIPT +} from './wsl-move-guest-foreground' + +const itOnPosix = process.platform === 'win32' ? it.skip : it +const TOOLS = ['cat', 'tr', 'sed', 'head', 'wc', 'readlink', 'grep'] + +let root: string | undefined +afterEach(() => { + if (root) { + rmSync(root, { recursive: true, force: true }) + } + root = undefined +}) + +/** A fake /proc: a pane shell and its untagged job among `others` processes; returns tool calls. */ +function runAgainst(others: number): { calls: number; stdout: string } { + root = mkdtempSync(join(tmpdir(), 'orca-guest-table-')) + const proc = join(root, 'proc') + const entry = (pid: number, comm: string, ppid: number, sid: number, environ: string) => { + const dir = join(proc, String(pid)) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'stat'), `${pid} (${comm}) S ${ppid} ${sid} ${sid} 0\n`) + writeFileSync(join(dir, 'environ'), environ) + writeFileSync(join(dir, 'cmdline'), `${comm}\0`) + symlinkSync('/home/a', join(dir, 'cwd')) + } + entry(100, 'bash', 1, 100, 'ORCA_PANE_KEY=tab:leaf\0') + // Started with `env -i`: no pane key, but in the pane shell's session. + entry(200, 'sleep', 100, 100, 'PATH=/bin\0') + for (let pid = 1000; pid < 1000 + others; pid += 1) { + entry(pid, 'other', 1, pid, 'PATH=/bin\0') + } + // Each tool is shimmed to log its call, then run the real one. + const bin = join(root, 'bin') + const log = join(root, 'calls') + mkdirSync(bin) + writeFileSync(log, '') + for (const tool of TOOLS) { + const real = execFileSync('sh', ['-c', `command -v ${tool}`]) + .toString() + .trim() + writeFileSync(join(bin, tool), `#!/bin/sh\necho ${tool} >> '${log}'\nexec '${real}' "$@"\n`) + chmodSync(join(bin, tool), 0o755) + } + const script = WSL_GUEST_PANE_PROCESS_SCRIPT.replaceAll('/proc/', `${proc}/`) + const stdout = execFileSync('sh', ['-c', script], { + env: { PATH: `${bin}:${process.env.PATH ?? ''}` } + }).toString() + const calls = execFileSync('cat', [log]).toString().split('\n').filter(Boolean).length + return { calls, stdout } +} + +describe("the move's guest process table", () => { + // Review of #33398: one fork storm per /proc entry made busy distros hit the read's cap. + itOnPosix( + 'costs the same however many processes the distro runs, and keeps untagged jobs', + () => { + const few = runAgainst(5) + const many = runAgainst(400) + expect(many.calls).toBe(few.calls) + const rows = parseWslGuestPaneProcesses(many.stdout) ?? [] + expect(rows).toHaveLength(402) + expect(classifyWslGuestPaneForeground(rows, 'tab:leaf')).toBe('process-running') + } + ) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-host-automations-rewrite.ts b/src/main/persistence/moving-wsl-projects/wsl-move-host-automations-rewrite.ts new file mode 100644 index 00000000000..8cf128f5fcd --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-host-automations-rewrite.ts @@ -0,0 +1,77 @@ +/** + * After a WSL move commits, the host turns back on the automations that were on here: the + * manifest can only carry them disabled. Ledgered like a desktop store, so a crash or an unreachable + * host retries on the next pass, and the move never finishes with them left off. + */ +import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import type { WslMoveUnfinished } from '../../../shared/wsl-move/wsl-move-driver-types' +import { listWslMoveCutovers } from './wsl-move-cutover-journal' +import { registerWslMoveDesktopRewriteStore } from './wsl-move-desktop-rewrite-stores' + +export const WSL_MOVE_HOST_AUTOMATIONS_STORE_ID = 'host-automations' + +/** One moved automation as the manifest carried it; the host row keeps this `updatedAt`. */ +export type WslMoveMovedAutomation = { id: string; updatedAt: number | null } + +export type WslMoveHostAutomations = { + /** + * Idempotent; throws unless the host answered for every one. An automation the host no longer + * has, already has on, or that changed there since the move (the user's own choice) is done. + */ + enable: (environmentId: string, automations: readonly WslMoveMovedAutomation[]) => Promise +} + +export function registerWslMoveHostAutomationsStore(host: WslMoveHostAutomations): void { + registerWslMoveDesktopRewriteStore({ + id: WSL_MOVE_HOST_AUTOMATIONS_STORE_ID, + side: 'main', + run: async ({ cutover }) => { + const ids = cutover.automationsToReenable ?? [] + if (ids.length === 0) { + return + } + const carried = new Map( + (cutover.manifest.payload.dormantState?.automations ?? []).map((row) => [ + row.id, + row.updatedAt + ]) + ) + await host.enable( + cutover.destinationEnvironmentId, + ids.map((id) => ({ id, updatedAt: carried.get(id) ?? null })) + ) + }, + // Nothing to flush here: the write is the host's. + flush: () => {} + }) +} + +/** What the host has not confirmed yet: the record's automations until its ledger entry lands. */ +export function wslMoveAutomationsPending( + cutover: Pick +): string[] { + const ids = cutover.automationsToReenable ?? [] + return cutover.phase === 'destination-committed' && + !cutover.rewrittenStores.includes(WSL_MOVE_HOST_AUTOMATIONS_STORE_ID) + ? [...ids] + : [] +} + +/** Committed moves whose host steps are not all confirmed, for the notice the UI shows. */ +export function listUnfinishedWslMoves(userDataPath: string): WslMoveUnfinished[] { + return listWslMoveCutovers(userDataPath).flatMap((cutover) => { + // Plus what a refused undo turned off on the host and could not turn back on yet. + const automationsPending = [ + ...new Set([...wslMoveAutomationsPending(cutover), ...(cutover.hostAutomationsLeftOff ?? [])]) + ] + return automationsPending.length > 0 + ? [ + { + migrationId: cutover.migrationId, + distro: cutover.manifest.source.distro, + automationsPending + } + ] + : [] + }) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-host-changes.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-host-changes.test.ts new file mode 100644 index 00000000000..483b39412cd --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-host-changes.test.ts @@ -0,0 +1,242 @@ +/** + * The WSL host changing under a move, against a real orcad profile store. + * R41: orcad updated mid-move, by this desktop's next connect or by another desktop on the distro. + * R38: the distro unregistered (or renamed: by name the two look the same) mid-move or after it. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import { + createStore, + readPersistedStateJson, + testState, + writeDataFile +} from '../persistence-test-harness' +import { + createWslMoveKillRig, + removeWslMoveRigDirs, + restartWslMoveRig, + WSL_MOVE_RIG_REQUEST as request, + wslMoveRigOutcome as outcomeOf, + wslMoveRigRetentionStore, + wslMoveRigTempDir, + type WslMoveRig +} from '../__fixtures__/wsl-mover/wsl-move-kill-rig' +import { isRecord } from './wsl-move-canonical-form' +import { listWslMoveCutovers, removeWslMoveCutover } from './wsl-move-cutover-journal' +import { resetWslMoveDesktopRewriteStoresForTests } from './wsl-move-desktop-rewrite-stores' +import { moveProjectsToWslHost } from './wsl-move-driver' +import { resolveWslMoveFence } from './wsl-move-fence' +import { listWslMoveOwedHostAborts } from './wsl-move-owed-host-aborts' +import { runWslMoveRetentionPass } from './wsl-move-retention' + +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: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 1 })) +})) + +let rig: WslMoveRig + +beforeEach(() => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + rig = createWslMoveKillRig() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + resetWslMoveDesktopRewriteStoresForTests() + removeWslMoveRigDirs() +}) + +const retain = () => + runWslMoveRetentionPass({ userDataPath: rig.userDataPath, store: wslMoveRigRetentionStore(rig) }) + +/** When the move reaches `point`, run `change` once. */ +function at(point: string, change: () => void): void { + rig.kill.onPoint = (name) => { + if (name === point) { + rig.kill.onPoint = undefined + change() + } + } +} + +/** The stage is in a format this orcad cannot read, as when versions differ: it is dropped on load. */ +function replaceOrcadWithNewerStagingFormat(): void { + const persisted: unknown = JSON.parse(readPersistedStateJson()) + const state = isRecord(persisted) ? persisted : {} + const staged = state.orcadMigrationStagedCatalogs + for (const entry of Array.isArray(staged) ? staged : []) { + if (isRecord(entry)) { + entry.version = 2 + } + } + writeDataFile(state) + rig.host = createStore() +} + +describe('R41: orcad updated during a move', () => { + it('between staging and the commit, with the same staging format: the move finishes', async () => { + at('blob:terminal-history:before', () => { + rig.host = createStore() + }) + expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({ + outcome: 'moved' + }) + await retain() + expect(outcomeOf(rig)).toBe('fully-moved') + }) + + it('between staging and the commit, by a build that cannot read the stage: back to zero, then a retry moves', async () => { + at('blob:terminal-history:before', replaceOrcadWithNewerStagingFormat) + expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({ + outcome: 'reset' + }) + // The new orcad answered "absent", so nothing is owed to it. + expect(outcomeOf(rig)).toBe('back-at-zero') + + expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({ + outcome: 'moved' + }) + }) + + it.each(['commit:lost-reply', 'store:main-profile:run', 'store:browser:flush'])( + 'between the commit and the desktop rewrite (%s), with the app updated too: fully moved', + async (killAt) => { + rig.kill.killAt = killAt + await moveProjectsToWslHost(rig.world.deps, request).catch(() => {}) + expect(rig.kill.dead).toBe(true) + // App update = restart; orcad updated on the next connect. The receipt survives it. + await restartWslMoveRig(rig, { hostUp: false, orcadRestarted: true }) + await restartWslMoveRig(rig, { hostUp: true, orcadRestarted: true }) + expect(outcomeOf(rig)).toBe('fully-moved') + } + ) +}) + +describe('R38: the distro unregistered or renamed', () => { + /** A fresh distro registered under the same name: a new, empty orcad. */ + function registerFreshDistro(): void { + testState.dir = wslMoveRigTempDir('wsl-move-fresh-host-') + rig.host = createStore() + rig.hostPresent = true + } + + it('before the commit request: back to zero; the leftover stage is owed to the next host', async () => { + at('blob:terminal-history:before', () => { + rig.hostPresent = false + }) + expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({ + outcome: 'reset' + }) + expect(listWslMoveCutovers(rig.userDataPath)).toEqual([]) + expect(resolveWslMoveFence(rig.userDataPath).fenced.repoIds.size).toBe(0) + expect(listWslMoveOwedHostAborts(rig.userDataPath)).toHaveLength(1) + + registerFreshDistro() + expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({ + outcome: 'moved' + }) + expect(listWslMoveOwedHostAborts(rig.userDataPath)).toEqual([]) + }) + + it('after the commit request: waits for the host, then resets once the distro is proven gone', async () => { + at('commit:before', () => { + rig.hostPresent = false + }) + expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({ + outcome: 'waiting-for-host' + }) + expect(outcomeOf(rig)).toBe('waiting-for-host') + + // Registrations unreadable: never a guess, still waiting. + rig.world.deps.isDistroRegistered = async () => null + expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({ + outcome: 'refused', + code: 'wsl_move_host_unavailable' + }) + expect(outcomeOf(rig)).toBe('waiting-for-host') + + rig.world.deps.isDistroRegistered = async () => false + expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({ + outcome: 'refused', + code: 'wsl_move_host_unavailable', + detail: 'distro_unregistered' + }) + expect(listWslMoveCutovers(rig.userDataPath)).toEqual([]) + expect(resolveWslMoveFence(rig.userDataPath).fenced.repoIds.size).toBe(0) + }) + + it('after the commit request, when the kept host record fails to connect: resets once proven gone', async () => { + at('commit:before', () => { + rig.hostPresent = false + }) + await moveProjectsToWslHost(rig.world.deps, request) + // Production keeps the host record; its connect throws rather than answering null. + rig.world.deps.connectHost = async () => { + throw new Error('wsl_host_not_connected') + } + rig.world.deps.isDistroRegistered = async () => false + + expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({ + outcome: 'refused', + detail: 'distro_unregistered' + }) + expect(listWslMoveCutovers(rig.userDataPath)).toEqual([]) + }) + + it('after the commit request, a renamed distro (its GUID still registered) keeps waiting', async () => { + at('commit:before', () => { + rig.hostPresent = false + }) + await moveProjectsToWslHost(rig.world.deps, request) + const asked: (string | undefined)[] = [] + rig.world.deps.isDistroRegistered = async (_distro, guid) => { + asked.push(guid) + return true + } + + await moveProjectsToWslHost(rig.world.deps, request) + expect(outcomeOf(rig)).toBe('waiting-for-host') + expect(asked).toHaveLength(1) + }) + + it('after the commit request, across a restart: startup resets once the distro is proven gone', async () => { + rig.kill.killAt = 'commit:before' + await moveProjectsToWslHost(rig.world.deps, request).catch(() => {}) + rig.hostPresent = false + await restartWslMoveRig(rig, { hostUp: false, orcadRestarted: false }) + + expect(listWslMoveCutovers(rig.userDataPath)).toEqual([]) + expect(resolveWslMoveFence(rig.userDataPath).fenced.repoIds.size).toBe(0) + }) + + it('after the move: the retained rows stay, and "Use on this computer again" needs no host', async () => { + expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({ + outcome: 'moved' + }) + await retain() + rig.hostPresent = false + await restartWslMoveRig(rig, { hostUp: false, orcadRestarted: false }) + + // A committed move is never reset by a lost distro: the host may only be renamed. + const [cutover] = listWslMoveCutovers(rig.userDataPath) + expect(cutover?.phase).toBe('retained') + expect(resolveWslMoveFence(rig.userDataPath).hidden.repoIds.has('repo-1')).toBe(true) + expect(rig.world.repos.map((repo) => repo.id)).toEqual(['repo-1']) + + // The escape hatch (#29833) drops the newest committed move locally. + removeWslMoveCutover(rig.userDataPath, cutover.migrationId) + await retain() + expect(resolveWslMoveFence(rig.userDataPath).hidden.repoIds.size).toBe(0) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-host-copy-stop.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-host-copy-stop.test.ts new file mode 100644 index 00000000000..0758214dac1 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-host-copy-stop.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest' +import { stopWslMoveHostCopies, type WslMoveHostCopyPort } from './wsl-move-host-copy-stop' + +function port(overrides: Partial = {}): WslMoveHostCopyPort { + return { + workspacesWithTerminals: vi.fn(async () => []), + sleepWorkspace: vi.fn(async () => true), + liveTerminals: vi.fn(async () => 0), + wait: vi.fn(async () => {}), + ...overrides + } +} + +describe("undo's stop of the host's copies", () => { + // The host's agent kept running beside the one undo resumed here. + it('sleeps every host workspace and reports stopped once none of their terminals runs', async () => { + let checks = 0 + const host = port({ liveTerminals: vi.fn(async () => (checks++ < 4 ? 1 : 0)) }) + + expect(await stopWslMoveHostCopies(['wt-a', 'wt-b'], host)).toBe('stopped') + expect(host.sleepWorkspace).toHaveBeenCalledWith('wt-a') + expect(host.sleepWorkspace).toHaveBeenCalledWith('wt-b') + expect(host.wait).toHaveBeenCalled() + }) + + it('reports still running when a terminal outlives the wait, never assuming it exited', async () => { + const host = port({ liveTerminals: vi.fn(async () => 1) }) + expect(await stopWslMoveHostCopies(['wt-a'], host, 1_000)).toBe('still-running') + }) + + it('reports unreachable when the host does not answer the sleep or the check', async () => { + expect(await stopWslMoveHostCopies(['wt-a'], port({ sleepWorkspace: async () => null }))).toBe( + 'unreachable' + ) + expect(await stopWslMoveHostCopies(['wt-a'], port({ liveTerminals: async () => null }))).toBe( + 'unreachable' + ) + }) + + // A host that answers for one workspace only must have none of them stopped. + it('stops nothing when the host does not answer for every workspace up front', async () => { + const host = port({ liveTerminals: vi.fn(async (id: string) => (id === 'wt-b' ? null : 1)) }) + expect(await stopWslMoveHostCopies(['wt-a', 'wt-b'], host)).toBe('unreachable') + expect(host.sleepWorkspace).not.toHaveBeenCalled() + }) + + it('checks exit even when the host refused the sleep itself', async () => { + const host = port({ sleepWorkspace: async () => false }) + expect(await stopWslMoveHostCopies(['wt-a'], host)).toBe('stopped') + }) + + // verify-15c: with the host's orcad stopped, undo of a project this computer kept no tabs for + // asked the host nothing, released it, and left its host shell running. + it('refuses an unanswering host even when this computer names none of its workspaces', async () => { + const host = port({ workspacesWithTerminals: vi.fn(async () => null) }) + expect(await stopWslMoveHostCopies([], host)).toBe('unreachable') + expect(host.sleepWorkspace).not.toHaveBeenCalled() + }) + + it("stops the host's terminals in workspaces this computer kept no tabs for", async () => { + let checks = 0 + const host = port({ + workspacesWithTerminals: vi.fn(async () => ['wt-host-only']), + liveTerminals: vi.fn(async () => (checks++ < 2 ? 1 : 0)) + }) + expect(await stopWslMoveHostCopies([], host)).toBe('stopped') + expect(host.sleepWorkspace).toHaveBeenCalledWith('wt-host-only') + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-host-copy-stop.ts b/src/main/persistence/moving-wsl-projects/wsl-move-host-copy-stop.ts new file mode 100644 index 00000000000..e24482b153f --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-host-copy-stop.ts @@ -0,0 +1,66 @@ +/** + * Before "Use on this computer again" resumes the project's agents here, the host's copies of its + * terminals must have exited: an agent the move resumed on the host would otherwise keep running + * beside the local one, two writers on one transcript. Unverifiable is not + * exited, so a host that does not answer is reported, never assumed stopped. + */ +export type WslMoveHostCopyStop = 'stopped' | 'still-running' | 'unreachable' + +export type WslMoveHostCopyPort = { + /** + * The host workspaces in the move's scope with a terminal there; null when the host does not + * answer. Asked first and always, so an unanswering host refuses even with nothing else to ask. + */ + workspacesWithTerminals: () => Promise + /** Sleeps the host workspace; false when the host refused, null when it did not answer. */ + sleepWorkspace: (hostWorkspaceId: string) => Promise + /** The host workspace's terminals still running; null when the host did not answer. */ + liveTerminals: (hostWorkspaceId: string) => Promise + wait: (ms: number) => Promise +} + +const EXIT_WAIT_MS = 20_000 +const EXIT_POLL_MS = 500 + +export async function stopWslMoveHostCopies( + sessionWorkspaceIds: readonly string[], + port: WslMoveHostCopyPort, + exitWaitMs = EXIT_WAIT_MS +): Promise { + // The session here names only workspaces with tabs kept after the move (a moved project keeps + // just its chats), so the host's own list adds the ones it runs that this computer never saw. + const listed = await port.workspacesWithTerminals() + if (listed === null) { + return 'unreachable' + } + const hostWorkspaceIds = [...new Set([...sessionWorkspaceIds, ...listed])] + // Why first: a host that cannot answer for every workspace must not have any of them stopped. + for (const id of hostWorkspaceIds) { + if ((await port.liveTerminals(id)) === null) { + return 'unreachable' + } + } + for (const id of hostWorkspaceIds) { + const slept = await port.sleepWorkspace(id) + if (slept === null) { + return 'unreachable' + } + } + for (let waited = 0; ; waited += EXIT_POLL_MS) { + let running = 0 + for (const id of hostWorkspaceIds) { + const live = await port.liveTerminals(id) + if (live === null) { + return 'unreachable' + } + running += live + } + if (running === 0) { + return 'stopped' + } + if (waited >= exitWaitMs) { + return 'still-running' + } + await port.wait(EXIT_POLL_MS) + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-host-owned-chat-tabs.ts b/src/main/persistence/moving-wsl-projects/wsl-move-host-owned-chat-tabs.ts new file mode 100644 index 00000000000..a598045d821 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-host-owned-chat-tabs.ts @@ -0,0 +1,106 @@ +/** + * Once the WSL host owns a moved project, its existing native chats stay on this computer and keep + * working (owner decision, option C), so their tabs take the renderer's writes again. Every other + * row of the workspace stays exactly as the quiesce left it: that slept state is what a downgrade + * restores, and its terminals and editors now live on the host. + */ +import type { Tab, TabGroup } from '../../../shared/tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +function isChatTab(tab: Pick): boolean { + return tab.contentType === 'agent-session' +} + +type HostOwnedChatTabRows = Pick< + WorkspaceSessionState, + 'unifiedTabs' | 'tabGroups' | 'tabGroupLayouts' +> + +/** + * `workspaceId`'s rows with `next`'s chat tabs in place of the frozen ones `frozen` holds, or null + * when neither side has a chat. Only the freeze splice merges them; this never writes a session. + */ +export function hostOwnedChatTabRows( + frozen: WorkspaceSessionState, + next: WorkspaceSessionState, + workspaceId: string +): HostOwnedChatTabRows | null { + const priorTabs = frozen.unifiedTabs?.[workspaceId] ?? [] + const nextChats = (next.unifiedTabs?.[workspaceId] ?? []).filter(isChatTab) + const priorChatIds = new Set(priorTabs.filter(isChatTab).map((tab) => tab.id)) + const nextChatIds = new Set(nextChats.map((tab) => tab.id)) + if (priorChatIds.size === 0 && nextChatIds.size === 0) { + return null + } + const priorGroups = frozen.tabGroups?.[workspaceId] ?? [] + // Why reuse prior groups: the frozen layout names them, so a new group would never render. + const groups = + priorGroups.length > 0 + ? placeChatsInPriorGroups(priorGroups, next.tabGroups?.[workspaceId] ?? [], { + priorChatIds, + nextChatIds, + nextChats + }) + : (next.tabGroups?.[workspaceId] ?? []).map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => nextChatIds.has(id)) + })) + const groupIdByTab = new Map( + groups.flatMap((group) => group.tabOrder.map((id) => [id, group.id])) + ) + const tabs = [ + ...priorTabs.filter((tab) => !isChatTab(tab)), + ...nextChats.map((tab) => { + const groupId = groupIdByTab.get(tab.id) ?? tab.groupId + return groupId === tab.groupId ? tab : { ...tab, groupId } + }) + ] + const nextLayout = priorGroups.length === 0 ? next.tabGroupLayouts?.[workspaceId] : undefined + return { + unifiedTabs: { [workspaceId]: tabs }, + tabGroups: { [workspaceId]: groups }, + ...(nextLayout ? { tabGroupLayouts: { [workspaceId]: nextLayout } } : {}) + } +} + +type ChatPlacement = { + priorChatIds: ReadonlySet + nextChatIds: ReadonlySet + nextChats: readonly Tab[] +} + +function placeChatsInPriorGroups( + priorGroups: readonly TabGroup[], + nextGroups: readonly TabGroup[], + { priorChatIds, nextChatIds, nextChats }: ChatPlacement +): TabGroup[] { + const groups = priorGroups.map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => !priorChatIds.has(id) || nextChatIds.has(id)) + })) + const placed = new Set(groups.flatMap((group) => group.tabOrder)) + const nextGroupByTab = new Map( + nextGroups.flatMap((group) => group.tabOrder.map((id) => [id, group.id])) + ) + for (const chat of nextChats) { + if (placed.has(chat.id)) { + continue + } + const wanted = nextGroupByTab.get(chat.id) ?? chat.groupId + const group = groups.find((candidate) => candidate.id === wanted) ?? groups[0] + group.tabOrder = [...group.tabOrder, chat.id] + placed.add(chat.id) + } + const nextActiveByGroup = new Map(nextGroups.map((group) => [group.id, group.activeTabId])) + return groups.map((group) => { + const nextActive = nextActiveByGroup.get(group.id) + const activeTabId = + nextActive && nextChatIds.has(nextActive) && group.tabOrder.includes(nextActive) + ? nextActive + : group.activeTabId && group.tabOrder.includes(group.activeTabId) + ? group.activeTabId + : (group.tabOrder.at(-1) ?? null) + const recentTabIds = group.recentTabIds?.filter((id) => group.tabOrder.includes(id)) + return { ...group, activeTabId, ...(recentTabIds ? { recentTabIds } : {}) } + }) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-host-readiness.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-host-readiness.test.ts new file mode 100644 index 00000000000..bc0a9a0ea38 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-host-readiness.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { prepareWslMoveHost, type WslMoveHostReadinessDeps } from './wsl-move-host-readiness' + +function deps(overrides: Partial = {}): WslMoveHostReadinessDeps { + let ready = false + return { + readRunningDistros: async () => ({ status: 'confirmed', distros: ['Ubuntu'] }), + isHostReady: () => ready, + removalPending: () => false, + hasHost: () => false, + connect: vi.fn(async () => { + ready = true + }), + add: vi.fn(async () => { + ready = true + return { ok: true as const, environmentId: 'wsl:Ubuntu' } + }), + ...overrides + } +} + +describe('prepareWslMoveHost (A1)', () => { + it('touches nothing when the running-distro read fails or throws', async () => { + const unavailable = deps({ readRunningDistros: async () => ({ status: 'unavailable' }) }) + expect(await prepareWslMoveHost(unavailable, 'Ubuntu')).toBe('unverifiable') + const throwing = deps({ + readRunningDistros: async () => { + throw new Error('wsl.exe hung') + } + }) + expect(await prepareWslMoveHost(throwing, 'Ubuntu')).toBe('unverifiable') + expect(unavailable.add).not.toHaveBeenCalled() + expect(throwing.connect).not.toHaveBeenCalled() + }) + + it('never connects or adds a host for a stopped distro', async () => { + const stopped = deps({ hasHost: () => true }) + expect(await prepareWslMoveHost(stopped, 'Debian')).toBe('not-running') + expect(stopped.connect).not.toHaveBeenCalled() + expect(stopped.add).not.toHaveBeenCalled() + }) + + it('adds a host for a running bridge distro, or connects the one it has', async () => { + const bridge = deps() + expect(await prepareWslMoveHost(bridge, 'ubuntu')).toBe('ready') + expect(bridge.add).toHaveBeenCalledWith('ubuntu') + const added = deps({ hasHost: () => true }) + expect(await prepareWslMoveHost(added, 'Ubuntu')).toBe('ready') + expect(added.connect).toHaveBeenCalled() + expect(added.add).not.toHaveBeenCalled() + }) + + it('reports a refused add or failed connect as failed', async () => { + const refused = deps({ + add: async () => ({ ok: false, kind: 'failed', message: 'nope' }) + }) + expect(await prepareWslMoveHost(refused, 'Ubuntu')).toBe('failed') + const broken = deps({ + hasHost: () => true, + connect: async () => { + throw new Error('pairing') + } + }) + expect(await prepareWslMoveHost(broken, 'Ubuntu')).toBe('failed') + }) + + // E11 (#33843): a host whose Remove is pending is never connected or added. + it('never connects or adds a host whose Remove is pending', async () => { + const pending = deps({ hasHost: () => true, removalPending: () => true }) + expect(await prepareWslMoveHost(pending, 'Ubuntu')).toBe('failed') + expect(pending.connect).not.toHaveBeenCalled() + expect(pending.add).not.toHaveBeenCalled() + }) + + it('re-reads the pending Remove after the running-distro read', async () => { + let removing = false + const racing = deps({ + readRunningDistros: async () => { + removing = true + return { status: 'confirmed', distros: ['Ubuntu'] } + }, + removalPending: () => removing + }) + expect(await prepareWslMoveHost(racing, 'Ubuntu')).toBe('failed') + expect(racing.add).not.toHaveBeenCalled() + }) + + // Re-review: a Remove that completes during the running-distro read must not be undone by an add. + it('adds no host once the distro stopped being wanted during the read', async () => { + let wanted = true + const racing = deps({ + readRunningDistros: async () => { + wanted = false + return { status: 'confirmed', distros: ['Ubuntu'] } + } + }) + expect(await prepareWslMoveHost(racing, 'Ubuntu', () => wanted)).toBe('failed') + expect(racing.add).not.toHaveBeenCalled() + expect(racing.connect).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-host-readiness.ts b/src/main/persistence/moving-wsl-projects/wsl-move-host-readiness.ts new file mode 100644 index 00000000000..e5a7eea56c2 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-host-readiness.ts @@ -0,0 +1,59 @@ +/** + * Readies a distro's WSL host for an automatic move step (D3a), never booting the distro (A1): + * only a distro the running-distro read confirms gets its host connected, or added for a bridge + * user who never added one. A failed read proves nothing, so it never counts as stopped. + */ +import type { WslRunningDistrosRead } from '../../wsl-background-distro-gate' +import { wslMoveJournalKey } from '../../../shared/wsl-move/wsl-move-cutover' +import type { WslHostAddResult } from '../../../shared/wsl-host-types' +import type { WslMoveHostReadiness } from './wsl-move-orchestrator-ports' + +export type WslMoveHostReadinessDeps = { + readRunningDistros: () => Promise + isHostReady: (distro: string) => boolean + /** E11: a Remove that could not prove the server stopped; such a host is never touched. */ + removalPending: (distro: string) => boolean + hasHost: (distro: string) => boolean + /** Connects an added host; called only for a confirmed-running distro. */ + connect: (distro: string) => Promise + /** Adds a host without starting a stopped distro. */ + add: (distro: string) => Promise +} + +export async function prepareWslMoveHost( + deps: WslMoveHostReadinessDeps, + distro: string, + /** False once the user declined the distro (a Remove, a release) during the waits here. */ + stillWanted: () => boolean = () => true +): Promise { + if (deps.removalPending(distro)) { + return 'failed' + } + const running = await deps + .readRunningDistros() + .catch((): WslRunningDistrosRead => ({ status: 'unavailable' })) + if (running.status !== 'confirmed') { + return 'unverifiable' + } + const key = wslMoveJournalKey(distro) + if (!running.distros.some((candidate) => wslMoveJournalKey(candidate) === key)) { + return 'not-running' + } + if (deps.isHostReady(distro)) { + return 'ready' + } + // Re-read after the running-distro await: a Remove may have started, or finished, meanwhile. + if (deps.removalPending(distro) || !stillWanted()) { + return 'failed' + } + try { + if (deps.hasHost(distro)) { + await deps.connect(distro) + } else if (!(await deps.add(distro)).ok) { + return 'failed' + } + } catch { + return 'failed' + } + return deps.isHostReady(distro) ? 'ready' : 'failed' +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.test.ts new file mode 100644 index 00000000000..bce34073a70 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.test.ts @@ -0,0 +1,238 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Tab } from '../../../shared/tab-types' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { Store } from '../../persistence' +import { + closeTestStores, + createStore, + readPersistedStateJson, + testState, + writeDataFile +} from '../persistence-test-harness' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { wslMoverProfile } from '../__fixtures__/wsl-mover/wsl-mover-profiles' +import { tabGroup, terminalTab, unifiedTab } from '../__fixtures__/wsl-mover/wsl-mover-profile-rows' +import { listWslMoveCutovers, wslMoveJournalDirectory } from './wsl-move-cutover-journal' +import { runWslMoveDesktopRewritePass } from './wsl-move-desktop-rewrite' +import { + listWslMoveDesktopRewriteStores, + resetWslMoveDesktopRewriteStoresForTests +} from './wsl-move-desktop-rewrite-stores' +import { registerWslMoveMainRewriteStores } from './wsl-move-main-rewrite-stores' +import { findCrossHostPaneTabIds } from '../restoring-sessions/cross-host-pane-tab-ids' +import { loadWslMoveFence, resetWslMoveFenceForTests } from './wsl-move-scope-guard' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +const HOST = toRuntimeExecutionHostId('wsl:Ubuntu') +const source = wslMoverProfile('mixed-windows-wsl', 'source') +const destination = wslMoverProfile('mixed-windows-wsl', 'destination') +const MOVED = source.session.activeWorktreeId ?? '' +const MOVED_ON_HOST = 'repo-a::/home/dev/app' +const STORE_IDS = ['ui-routing', 'session-globals', 'host-session-seed'] + +/** What a real manifest carries for repo-a: host ids, its rows only, no PTY ids (validated). */ +function carriedSession(): WorkspaceSessionState { + const chat: Tab = { + ...unifiedTab('chat-1', MOVED_ON_HOST, 'group-1'), + contentType: 'agent-session' + } + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [MOVED_ON_HOST]: [terminalTab('tab-1', MOVED_ON_HOST, '/home/dev/app', null)] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + } + }, + unifiedTabs: { + [MOVED_ON_HOST]: [unifiedTab('tab-1', MOVED_ON_HOST, 'group-1'), chat] + }, + tabGroups: { + [MOVED_ON_HOST]: [tabGroup('group-1', MOVED_ON_HOST, ['tab-1', 'chat-1'])] + }, + activeGroupIdByWorktree: { [MOVED_ON_HOST]: 'group-1' } + } +} + +function committedMove(rewrittenStores: string[] = []) { + return wslMoveCutoverFixture( + 'move-1', + 'destination-committed', + { rewrittenStores }, + { repoIds: ['repo-a'] }, + { + version: 1, + worktreeMeta: [], + worktreeLineage: [], + workspaceLineage: [], + sparsePresets: [], + retiredWorktreeNames: [], + retiredWorktreeNamespaces: [], + workspaceSession: carriedSession() + } + ) +} + +function persisted(): PersistedState { + return JSON.parse(readPersistedStateJson()) +} + +let store: Store +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'wsl-move-main-stores-')) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + repos: source.state.repos, + ui: { + ...getDefaultPersistedState(testState.dir).ui, + ...source.ui, + workspaceHostScope: 'local' + }, + workspaceSession: { + ...getDefaultWorkspaceSession(), + ...source.session, + activeWorkspaceExecutionHostId: 'local' + } + }) + store = createStore() + registerWslMoveMainRewriteStores(store) +}) +afterEach(async () => { + vi.unstubAllEnvs() + resetWslMoveDesktopRewriteStoresForTests() + resetWslMoveFenceForTests() + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) +}) + +describe('WSL move main rewrite stores', () => { + it('registers the three main stores in run order', () => { + expect(listWslMoveDesktopRewriteStores().map((entry) => [entry.id, entry.side])).toEqual( + STORE_IDS.map((id) => [id, 'main']) + ) + }) + + it('rewrites and records each store on disk, then finishes the rewrite', async () => { + seedWslMoveCutover(testState.dir, committedMove()) + loadWslMoveFence(testState.dir) + + await runWslMoveDesktopRewritePass({ userDataPath: testState.dir }) + + const [record] = listWslMoveCutovers(testState.dir) + expect(record?.rewrittenStores).toEqual(STORE_IDS) + // Only these three are registered here, so the ledger is complete. + expect(record?.phase).toBe('client-rewritten') + expect(record?.seededHostWorkspaceIds).toEqual([MOVED_ON_HOST]) + // Read back from the profile database: each store's flush made its write durable. + const saved = persisted() + expect(saved.ui.lastActiveWorktreeId).toBe(MOVED_ON_HOST) + // The local forms stay beside the host's, for a downgrade. + expect(saved.ui.explorerDisplayRootByWorktree).toEqual({ + ...source.ui?.explorerDisplayRootByWorktree, + ...destination.ui?.explorerDisplayRootByWorktree + }) + expect(saved.ui.manualRepoOrder).toEqual([ + { hostId: 'local', repoId: 'repo-a' }, + { hostId: HOST, repoId: 'repo-a' } + ]) + expect(saved.ui.visibleWorkspaceHostIds).toEqual(['local', HOST]) + expect(saved.workspaceSession.activeWorktreeId).toBe(MOVED_ON_HOST) + expect(saved.workspaceSession.activeWorkspaceExecutionHostId).toBe(HOST) + const hostSession = saved.workspaceSessionsByHostId?.[HOST] + expect(hostSession?.tabsByWorktree[MOVED_ON_HOST]?.map((tab) => [tab.id, tab.ptyId])).toEqual([ + ['tab-1', null] + ]) + // The chat stays listed on this computer's retained entry, not on the host. + expect(hostSession?.unifiedTabs?.[MOVED_ON_HOST]?.map((tab) => tab.id)).toEqual(['tab-1']) + expect(hostSession?.tabGroups?.[MOVED_ON_HOST]?.[0]?.tabOrder).toEqual(['tab-1']) + // The repo that stays on this computer keeps its local rows and gets no host rows. + expect(hostSession?.tabsByWorktree['repo-b::/home/dev/lib']).toBeUndefined() + expect(saved.workspaceSession.tabsByWorktree[MOVED]?.map((tab) => tab.id)).toEqual(['tab-1']) + // Both partitions hold tab-1, but the local one is the moved project's dormant copy. + expect(saved.workspaceSession.terminalLayoutsByTabId['tab-1']).toBeDefined() + expect(hostSession?.terminalLayoutsByTabId['tab-1']).toBeDefined() + expect(findCrossHostPaneTabIds(saved)).toEqual(new Set()) + }) + + it('re-runs a store whose ledger entry a crash lost, changing nothing', async () => { + seedWslMoveCutover(testState.dir, committedMove()) + loadWslMoveFence(testState.dir) + await runWslMoveDesktopRewritePass({ userDataPath: testState.dir }) + const before = persisted() + // As if the process died after host-session-seed flushed but before its ledger append. + resetWslMoveDesktopRewriteStoresForTests() + rmSync(wslMoveJournalDirectory(testState.dir), { + recursive: true, + force: true + }) + seedWslMoveCutover(testState.dir, committedMove(['ui-routing', 'session-globals'])) + registerWslMoveMainRewriteStores(store) + const seed = vi.spyOn(store, 'setWorkspaceSession') + + await runWslMoveDesktopRewritePass({ userDataPath: testState.dir }) + + expect(seed).not.toHaveBeenCalled() + expect(listWslMoveCutovers(testState.dir)[0]?.rewrittenStores).toEqual(STORE_IDS) + const after = persisted() + expect(after.ui).toEqual(before.ui) + expect(after.workspaceSession).toEqual(before.workspaceSession) + expect(after.workspaceSessionsByHostId).toEqual(before.workspaceSessionsByHostId) + }) + + it('never re-seeds a tab the user closed after a seed whose flush timed out', async () => { + seedWslMoveCutover(testState.dir, committedMove()) + loadWslMoveFence(testState.dir) + const flush = store.flushPendingOrThrowAsync.bind(store) + const hangThirdFlush = vi + .spyOn(store, 'flushPendingOrThrowAsync') + .mockImplementationOnce(flush) + .mockImplementationOnce(flush) + .mockImplementationOnce(() => new Promise(() => {})) + // Long enough that only the hung flush runs out, even on a loaded machine. + await runWslMoveDesktopRewritePass({ userDataPath: testState.dir, storeTimeoutMs: 2_000 }) + + const afterTimeout = listWslMoveCutovers(testState.dir)[0] + expect(afterTimeout?.rewrittenStores).toEqual(['ui-routing', 'session-globals']) + expect(afterTimeout?.seededHostWorkspaceIds).toEqual([MOVED_ON_HOST]) + // The user closes the seeded tabs before the next pass. + store.setWorkspaceSession(getDefaultWorkspaceSession(), HOST) + hangThirdFlush.mockRestore() + const seed = vi.spyOn(store, 'setWorkspaceSession') + + await runWslMoveDesktopRewritePass({ userDataPath: testState.dir }) + + expect(seed).not.toHaveBeenCalled() + expect(listWslMoveCutovers(testState.dir)[0]?.rewrittenStores).toEqual(STORE_IDS) + expect(store.getWorkspaceSession(HOST).tabsByWorktree[MOVED_ON_HOST]).toBeUndefined() + }) + + it('does nothing for a move that has not committed', async () => { + seedWslMoveCutover(testState.dir, { + ...committedMove(), + phase: 'blobs-transferred' + }) + const ui = store.getUI() + + await runWslMoveDesktopRewritePass({ userDataPath: testState.dir }) + + expect(store.getUI()).toEqual(ui) + expect(store.getWorkspaceSession(HOST).tabsByWorktree).toEqual({}) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.ts b/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.ts new file mode 100644 index 00000000000..72d04eccf3a --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.ts @@ -0,0 +1,136 @@ +/** + * The main-process stores a committed WSL move rewrites, in run order: the persisted UI's + * routing, the local session's focus, then the host partition's seed. Each re-reads the profile, + * so a run after a crash before its ledger entry finds its own earlier write and changes nothing. + */ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import type { Store } from '../loading-store/store' +import { wslMoveAutomationHostFilterUpdate } from './wsl-move-automation-filter-rewrite' +import { registerWslMoveDesktopRewriteStore } from './wsl-move-desktop-rewrite-stores' +import { wslMoveRewriteScope, type WslMoveRewriteScope } from './wsl-move-rewrite-scope' +import { withWslMoveSeededViewState } from './wsl-move-seeded-view-state' +import { wslMoveHostSessionSeed, wslMoveSessionGlobalsPatch } from './wsl-move-session-rewrite' +import { rewriteWslMoveUiRouting } from './wsl-move-ui-routing-rewrite' +import { holdWslMoveWindowWrites } from './wsl-move-window-write-hold' + +export type WslMoveMainRewriteProfile = Pick< + Store, + | 'getUI' + | 'updateUI' + | 'getWorkspaceSession' + | 'patchWorkspaceSession' + | 'setWorkspaceSession' + | 'flushPendingOrThrowAsync' +> + +/** + * Each of these rewrites state an open window also saves, so it first holds that window's saves; + * the pass reloads the window once they are on disk (wsl-move-window-write-hold.ts). + */ +export function registerWslMoveMainRewriteStores(profile: WslMoveMainRewriteProfile): void { + // Resolves once this pass's writes are on disk, not merely scheduled. + const flush = (): Promise => profile.flushPendingOrThrowAsync() + registerWslMoveDesktopRewriteStore({ + id: 'ui-routing', + side: 'main', + run: (context) => { + holdWslMoveWindowWrites() + const ui = profile.getUI() + const automationHostFilter = wslMoveAutomationHostFilterUpdate( + ui.automationHostFilter, + context.cutover.manifest.payload.dormantState?.automations?.length ?? 0 + ) + const updates = { + ...rewriteWslMoveUiRouting(ui, wslMoveRewriteScope(context)), + ...(automationHostFilter ? { automationHostFilter } : {}) + } + if (Object.keys(updates).length > 0) { + profile.updateUI(updates) + } + }, + flush + }) + registerWslMoveDesktopRewriteStore({ + id: 'session-globals', + side: 'main', + run: (context) => { + holdWslMoveWindowWrites() + applyWslMoveSessionFocus(profile, wslMoveRewriteScope(context)) + }, + flush + }) + registerWslMoveDesktopRewriteStore({ + id: 'host-session-seed', + side: 'main', + run: (context) => { + holdWslMoveWindowWrites() + const seeded = applyWslMoveHostSessionSeed( + profile, + context.cutover, + wslMoveRewriteScope(context), + new Set(context.cutover.seededHostWorkspaceIds) + ) + if (seeded.length > 0) { + // Recorded before the flush: a crash may then lose the seed, but never revives a closed tab. + context.recordSeededHostWorkspaceIds(seeded) + } + }, + flush + }) +} + +/** + * The local session's focus onto the host when it names a moved project, or (undo, given + * wslMoveReleasedSessionFocusPatch) back here from a released one; true when it wrote. + */ +export function applyWslMoveSessionFocus( + profile: Pick, + scope: WslMoveRewriteScope, + focusPatch: ( + session: WorkspaceSessionState, + scope: WslMoveRewriteScope + ) => WorkspaceSessionPatch | null = wslMoveSessionGlobalsPatch +): boolean { + const patch = focusPatch(profile.getWorkspaceSession(LOCAL_EXECUTION_HOST_ID), scope) + if (!patch) { + return false + } + // A move's (or its undo's) one-shot rewrite of focus; no LayoutCommand owns focus. + profile.patchWorkspaceSession(patch, LOCAL_EXECUTION_HOST_ID) + return true +} + +/** Seeds the host partition with the carried rows of moved workspaces it lacks; returns their ids. */ +export function applyWslMoveHostSessionSeed( + profile: Pick, + cutover: Pick, + scope: WslMoveRewriteScope, + alreadySeeded: ReadonlySet +): string[] { + const carried = cutover.manifest.payload.dormantState?.workspaceSession + if (!carried) { + return [] + } + const hostId = scope.destinationHostId + const existing = profile.getWorkspaceSession(hostId) + const seed = wslMoveHostSessionSeed(carried, existing, scope, alreadySeeded) + if (!seed) { + return [] + } + const seededIds = new Set(seed.workspaceIds) + const next = + withWslMoveSeededViewState( + seed.session, + profile.getWorkspaceSession(LOCAL_EXECUTION_HOST_ID), + { ...scope, destinationEnvironmentId: cutover.destinationEnvironmentId }, + seededIds + ) ?? seed.session + // A move's one-shot seed; layout commands refuse runtime hosts. + profile.setWorkspaceSession(next, hostId) + return [...seededIds] +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-budget.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-budget.test.ts new file mode 100644 index 00000000000..fcf12f5f04a --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-budget.test.ts @@ -0,0 +1,59 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { computeOrcadMigrationManifestSha256 } from '../../orcad/orcad-migration-manifest-digest' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { listWslMoveCutovers, writeWslMoveCutover } from './wsl-move-cutover-journal' +import { + WSL_MOVE_MANIFEST_REQUEST_BUDGET_BYTES, + wslMoveManifestRequestBytes +} from './wsl-move-manifest-budget' + +let userDataPath: string +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-budget-')) +}) +afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) +}) + +/** A new move whose manifest lands `delta` bytes from the budget. */ +function moveSized(delta: number) { + const base = wslMoveCutoverFixture('move-1', 'prepared') + const sized = (padding: number) => { + const { manifestSha256: _digest, ...unsigned } = base.manifest + const payload = { + ...unsigned.payload, + repositories: unsigned.payload.repositories.map((repo) => ({ + ...repo, + displayName: 'x'.repeat(padding) + })) + } + const signed = { ...unsigned, payload } + return { ...signed, manifestSha256: computeOrcadMigrationManifestSha256(signed) } + } + const overhead = wslMoveManifestRequestBytes(sized(0)) + const manifest = sized(WSL_MOVE_MANIFEST_REQUEST_BUDGET_BYTES - overhead + delta) + return { + ...base, + manifestSha256: manifest.manifestSha256, + manifest: { ...manifest, source: base.manifest.source } + } +} + +describe('WSL move manifest budget', () => { + it('records a move right at the budget', () => { + const move = moveSized(0) + expect(wslMoveManifestRequestBytes(move.manifest)).toBe(WSL_MOVE_MANIFEST_REQUEST_BUDGET_BYTES) + writeWslMoveCutover(userDataPath, move) + expect(listWslMoveCutovers(userDataPath)).toHaveLength(1) + }) + + it('refuses a move one byte over, with a plain message and nothing fenced', () => { + expect(() => writeWslMoveCutover(userDataPath, moveSized(1))).toThrow( + /too large to move in one step.*Move fewer projects at a time/ + ) + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-budget.ts b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-budget.ts new file mode 100644 index 00000000000..faf538ce332 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-budget.ts @@ -0,0 +1,32 @@ +/** + * A WSL move's manifest must fit one catalog request with room to spare: every stage, state and + * abort carries it whole, and a reset that cannot carry it can only ask the host by identity. + * Capping it when the move is recorded keeps new moves out of that gap entirely. + */ +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest' +import { ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES } from '../../ssh/orcad-migration-manifest-export' + +// Headroom for a host whose request limit is a little lower than this build's. +const WSL_MOVE_MANIFEST_MARGIN_BYTES = 16 * 1024 + +export const WSL_MOVE_MANIFEST_REQUEST_BUDGET_BYTES = + ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES - WSL_MOVE_MANIFEST_MARGIN_BYTES + +export function wslMoveManifestRequestBytes(manifest: OrcadMigrationManifest): number { + return Buffer.byteLength(JSON.stringify({ manifest }), 'utf8') +} + +/** Throws the user-facing refusal before a move is recorded, so nothing is fenced. */ +export function assertWslMoveManifestFits(manifest: OrcadMigrationManifest): void { + const bytes = wslMoveManifestRequestBytes(manifest) + if (bytes <= WSL_MOVE_MANIFEST_REQUEST_BUDGET_BYTES) { + return + } + const kib = (value: number): string => `${Math.ceil(value / 1024)} KiB` + const projects = manifest.payload.repositories.length + manifest.payload.folderWorkspaces.length + throw new Error( + `wsl_move_manifest_too_large:The saved state of ${projects === 1 ? 'this project' : `these ${projects} projects`} ` + + `is too large to move in one step (${kib(bytes)}, limit ${kib(WSL_MOVE_MANIFEST_REQUEST_BUDGET_BYTES)}). ` + + 'Move fewer projects at a time, or close unused tabs and clear old terminal history first.' + ) +} 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..1d90c960b33 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts @@ -0,0 +1,497 @@ +import { describe, expect, it, vi } 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 '../migrating-orcad-catalog/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("never signs this desktop's retained mark into the manifest", () => { + const state = sourceState() + const mark = { migrationId: 'move-0', environmentId: 'wsl:Ubuntu' } + state.repos = state.repos.map((row) => + row.id === 'app' ? { ...row, orcadRetained: mark } : row + ) + const store = storeFor(state) + const { manifest } = exportWslMoveManifest(store, REQUEST) + expect(manifest.payload.repositories.map((row) => row.orcadRetained)).toEqual([undefined]) + // The stored row keeps its mark. + expect(store.getRepos().find((row) => row.id === 'app')?.orcadRetained).toEqual(mark) + }) + + 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("is not refused by another project's agent session or unsaved draft", () => { + // Any agent or draft anywhere on this desktop refused every WSL move. + const state = sourceState() + const elsewhere: SleepingAgentSessionRecord = { + paneKey: 'windows-shell:missing-leaf', + tabId: 'windows-shell', + worktreeId: OTHER_WORKTREE, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-2' }, + prompt: 'Saved prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + connectionId: null, + origin: 'quit' + } + state.workspaceSession.sleepingAgentSessionsByPaneKey = { [elsewhere.paneKey]: elsewhere } + state.workspaceSession.openFilesByWorktree = { + [OTHER_WORKTREE]: [ + { + filePath: 'C:\\code\\win\\notes.md', + relativePath: 'notes.md', + worktreeId: OTHER_WORKTREE, + language: 'markdown', + dirtyDraftContent: 'unsaved' + } + ] + } + expect(exportWslMoveManifest(storeFor(state), REQUEST).blockers).toEqual([]) + }) + + it("carries a moving project's unsaved draft instead of refusing it", () => { + const state = sourceState() + state.workspaceSession.openFilesByWorktree = { + [WORKTREE]: [ + { + filePath: `${UNC_WORKTREE}\\todo.md`, + relativePath: 'todo.md', + worktreeId: WORKTREE, + language: 'markdown', + dirtyDraftContent: '# unsaved draft' + } + ] + } + const exported = exportWslMoveManifest(storeFor(state), REQUEST) + expect(exported.blockers).toEqual([]) + // The tab moves; its contents stay on this desktop until host-session-seed lays them back. + const files = Object.values( + exported.manifest.payload.dormantState?.workspaceSession?.openFilesByWorktree ?? {} + ).flat() + expect(files).toEqual([expect.objectContaining({ filePath: '/home/me/app-feature/todo.md' })]) + expect(files[0]).not.toHaveProperty('dirtyDraftContent') + }) + + it('is not refused by a project an earlier move retained, whose agent stays here', () => { + const state = sourceState() + const retainedWorktree = 'old::\\\\wsl.localhost\\Ubuntu\\home\\me\\old' + state.repos.push(repo('old', '\\\\wsl.localhost\\Ubuntu\\home\\me\\old')) + const record: SleepingAgentSessionRecord = { + paneKey: 'old-shell:missing-leaf', + tabId: 'old-shell', + worktreeId: retainedWorktree, + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-3' }, + prompt: 'Saved prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + connectionId: null, + origin: 'quit' + } + state.workspaceSession.sleepingAgentSessionsByPaneKey = { [record.paneKey]: record } + expect(exportWslMoveManifest(storeFor(state), REQUEST).blockers).toEqual([]) + }) + + it('signs what the host and the journal will read, even with in-memory dates', () => { + // A row added this session can still hold a Date the next load would read back as a string. The + // signature covered "{}" for it while the host hashed the string, so the first move after adding + // a project reset with a digest mismatch until a restart. + const state = sourceState() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the in-memory shape the load normalizes. + ;(state.repos[0] as unknown as Record).lastSeenAt = new Date( + '2026-10-20T00:00:00.000Z' + ) + const { manifest } = exportWslMoveManifest(storeFor(state), REQUEST) + expect(() => + assertOrcadMigrationManifestDigest(JSON.parse(JSON.stringify(manifest))) + ).not.toThrow() + }) + + it("shares no object with the live state, so a later sleep can't change what was signed", () => { + const state = sourceState() + const { manifest } = exportWslMoveManifest(storeFor(state), REQUEST) + // Sleep and the session freeze mutate the live session after the export signs it. + for (const tabs of Object.values(state.workspaceSession.tabsByWorktree)) { + for (const tab of tabs) { + tab.title = 'renamed by sleep' + } + } + expect(() => + assertOrcadMigrationManifestDigest(JSON.parse(JSON.stringify(manifest))) + ).not.toThrow() + }) + + describe('only the selected projects can refuse it', () => { + const agentIn = ( + worktreeId: string, + tabId: string, + leafId: string + ): SleepingAgentSessionRecord => ({ + paneKey: `${tabId}:${leafId}`, + tabId, + worktreeId, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${tabId}` }, + prompt: 'Saved prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + connectionId: null, + origin: 'live' + }) + const layout = (leafId: string) => ({ + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null + }) + + it('is not refused by a live agent with its tab open, and a draft, in another project', () => { + const state = sourceState() + const other = agentIn(OTHER_WORKTREE, 'windows-shell', 'win-leaf') + state.workspaceSession.terminalLayoutsByTabId = { 'windows-shell': layout('elsewhere') } + state.workspaceSession.sleepingAgentSessionsByPaneKey = { [other.paneKey]: other } + state.workspaceSession.openFilesByWorktree = { + [OTHER_WORKTREE]: [ + { + filePath: 'C:\\code\\win\\notes.md', + relativePath: 'notes.md', + worktreeId: OTHER_WORKTREE, + language: 'markdown', + dirtyDraftContent: 'unsaved' + } + ] + } + expect(exportWslMoveManifest(storeFor(state), REQUEST).blockers).toEqual([]) + }) + + it('is still refused by the same kinds of state in the selected project', () => { + const state = sourceState() + const own = agentIn(WORKTREE, 'feature-shell', 'feature-leaf') + state.workspaceSession.terminalLayoutsByTabId = { 'feature-shell': layout('elsewhere') } + state.workspaceSession.sleepingAgentSessionsByPaneKey = { [own.paneKey]: own } + state.workspaceSession.openFilesByWorktree = { + [WORKTREE]: [ + { + filePath: '/home/me/app-feature/notes.md', + relativePath: 'notes.md', + worktreeId: WORKTREE, + language: 'markdown', + dirtyDraftContent: 'unsaved', + runtimeEnvironmentId: 'some-other-server' + } + ] + } + const { blockers } = exportWslMoveManifest(storeFor(state), REQUEST) + expect(blockers).toEqual( + expect.arrayContaining([ + expect.stringMatching(/codex session saved in/), + expect.stringMatching(/unsaved changes to/) + ]) + ) + }) + + it('never silently leaves behind an agent whose key may be the selected project', () => { + // `app` is also registered by an SSH host, so an unqualified `app::` key names either. + const state = sourceState() + state.repos.push(repo('app', '/srv/app', { connectionId: 'box' })) + const own = agentIn(WORKTREE, 'feature-shell', 'feature-leaf') + state.workspaceSession.terminalLayoutsByTabId = { 'feature-shell': layout('elsewhere') } + state.workspaceSession.sleepingAgentSessionsByPaneKey = { [own.paneKey]: own } + expect(exportWslMoveManifest(storeFor(state), REQUEST).blockers).toEqual([ + expect.stringMatching(/codex session saved in/) + ]) + }) + }) + + it('signs the session as the host parses it, even with a field the parser drops', () => { + // On a real host, a move right after sleeping terminals reset with a digest mismatch, because the + // signature covered session fields the manifest parser does not keep. + const state = sourceState() + const sleptTab: Record = { + ...state.workspaceSession.tabsByWorktree[WORKTREE]![0] + } + sleptTab.fieldNoParserKeeps = 1 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an extra field on a real tab. + state.workspaceSession.tabsByWorktree[WORKTREE] = [sleptTab as unknown as TerminalTab] + const { manifest } = exportWslMoveManifest(storeFor(state), REQUEST) + expect(() => + assertOrcadMigrationManifestDigest(JSON.parse(JSON.stringify(manifest))) + ).not.toThrow() + }) + + it('carries no desktop PTY binding, so the moved pane reads its history from the host', () => { + // The host's session kept the desktop's PTY ids, so the client took each pane + // for a live terminal, skipped the scrollback read, and opened a fresh prompt. + const state = sourceState() + const leaf = '11111111-1111-4111-8111-111111111111' + const tab = state.workspaceSession.tabsByWorktree[WORKTREE]![0]! + state.workspaceSession.tabsByWorktree[WORKTREE] = [{ ...tab, ptyId: `${WORKTREE}@@81c7100a` }] + state.workspaceSession.terminalLayoutsByTabId = { + [tab.id]: { + root: { type: 'leaf', leafId: leaf }, + activeLeafId: leaf, + expandedLeafId: null, + ptyIdsByLeafId: { [leaf]: `${WORKTREE}@@81c7100a` }, + buffersByLeafId: { [leaf]: 'history' } + } + } + const { manifest } = exportWslMoveManifest(storeFor(state), REQUEST) + const session = manifest.payload.dormantState?.workspaceSession + const layout = Object.values(session?.terminalLayoutsByTabId ?? {})[0] + expect(layout?.scrollbackRefsByLeafId?.[leaf]).toBeTruthy() + expect(layout).not.toHaveProperty('ptyIdsByLeafId') + expect(Object.values(session?.tabsByWorktree ?? {}).flat()[0]?.ptyId).toBeNull() + }) + + it("drops a tab's Windows shell choice on purpose, never through the parser", () => { + // Every preview and move warned that the parser dropped shellOverride. + const state = sourceState() + const tab = state.workspaceSession.tabsByWorktree[WORKTREE]![0]! + state.workspaceSession.tabsByWorktree[WORKTREE] = [ + { ...tab, shellOverride: 'wsl.exe', forceHostRuntime: true } + ] + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { manifest } = exportWslMoveManifest(storeFor(state), REQUEST) + const moved = Object.values( + manifest.payload.dormantState?.workspaceSession?.tabsByWorktree ?? {} + ) + expect(moved.flat()).toHaveLength(1) + expect(moved.flat()[0]).not.toHaveProperty('shellOverride') + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + + 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..a406b279346 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.ts @@ -0,0 +1,307 @@ +/** + * 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 { withoutOrcadRetainedMarker } from '../../../shared/orcad-retained-marker' +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 { wslUncPathToLinux } from '../../../shared/wsl-paths' +import { createUncToLinuxPathRewriter } from './wsl-move-path-rewriter' +import { withoutWindowsShellChoices } from './wsl-move-windows-shell-choices' +import { withoutDesktopFocus, withoutPtyBindings } from './wsl-move-session-rewrite' + +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[] + /** Slept panes' daemon history: their only copy of the scrollback. */ + daemonHistoryLeaf?: (tabId: string, leafId: string) => boolean +} + +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' + } +} + +const UNSIGNED_DIGEST = '0'.repeat(64) + +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[] = [] + // The retained mark is this desktop's record; the host must never receive it. + const repos = pickLocalRows(store.getRepos(), request.repoIds, isLocalRepo, (id) => + missing.push(id) + ).map(withoutOrcadRetainedMarker) + 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) + ).map(withoutOrcadRetainedMarker) + // 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, + request.daemonHistoryLeaf + ) + 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.` + ) + } + } + // Desktop PTY ids name no host terminal: a moved pane bound to one never reads its history. + const workspaceSession = + inspection.payload.workspaceSession && + withoutDesktopFocus( + withoutWindowsShellChoices(withoutPtyBindings(inspection.payload.workspaceSession)) + ) + const dormantState = workspaceSession + ? { ...inspection.payload, workspaceSession } + : inspection.payload + const rewriter = createUncToLinuxPathRewriter(request.distro) + const payload = rewriteOrcadMigrationPayloadPaths( + { ...catalog, ...(hasDormantState(dormantState) ? { dormantState } : {}) }, + rewriter + ) + // Sign what the journal and the host read back: the parsed form (the parser keeps only the fields + // it knows, so a session field it drops would break the digest) of the JSON (an in-memory value + // with toJSON serializes differently), never the live objects. + const unsigned: Omit = JSON.parse( + JSON.stringify({ + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: request.migrationId, + createdAt: request.now().toISOString(), + source, + payload, + destinationEnvironmentId: request.destinationEnvironmentId + }) + ) + const { manifestSha256: _unsigned, ...normalized } = parseOrcadMigrationManifest({ + ...unsigned, + manifestSha256: UNSIGNED_DIGEST + }) + const manifestSha256 = computeOrcadMigrationManifestSha256(normalized) + if (manifestSha256 !== computeOrcadMigrationManifestSha256(unsigned)) { + // Not refused (the parsed form is what moves), but a field the parser drops does not move. + console.warn( + '[wsl-move] The manifest parser dropped fields of the exported state:', + differingPaths(unsigned, normalized).join(', ') + ) + } + const manifest: OrcadMigrationManifest = { ...normalized, manifestSha256 } + 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) +} + +/** The first few JSON paths where the parsed manifest differs from the export, for the log. */ +function differingPaths(left: unknown, right: unknown, path = '$', found: string[] = []): string[] { + if (found.length >= 8) { + return found + } + if (!isJsonRecord(left) || !isJsonRecord(right)) { + if (JSON.stringify(left) !== JSON.stringify(right)) { + found.push(path) + } + return found + } + const keys = new Set([...Object.keys(left), ...Object.keys(right)]) + for (const key of keys) { + // Array indexes are folded so one dropped field names its shape, not every row. + const step = Array.isArray(left) ? `${path}[]` : `${path}.${key}` + const before = found.length + differingPaths(left[key], right[key], step, found) + if (Array.isArray(left) && found.length > before) { + return found + } + } + return found +} + +// Arrays included: their indexes are keys like any record's. +function isJsonRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-moved-distro-check.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-moved-distro-check.test.ts new file mode 100644 index 00000000000..15e51c20fd3 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-moved-distro-check.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { wslMovedDistroVerdict } from './wsl-move-moved-distro-check' + +const ubuntu = { id: 'aaaa', distro: 'Ubuntu', version: 2 as const } + +describe('wslMovedDistroVerdict (R38)', () => { + it('concludes nothing when the registry cannot be read', () => { + expect(wslMovedDistroVerdict(null, 'Ubuntu', 'aaaa')).toBe('unknown') + }) + + it('finds the same distro by name and GUID, or by GUID after a rename', () => { + expect(wslMovedDistroVerdict([ubuntu], 'ubuntu', '{AAAA}')).toBe('present') + expect(wslMovedDistroVerdict([{ ...ubuntu, distro: 'Work' }], 'Ubuntu', 'aaaa')).toBe('present') + }) + + it('calls an unregistered distro gone', () => { + expect(wslMovedDistroVerdict([], 'Ubuntu', 'aaaa')).toBe('gone') + expect(wslMovedDistroVerdict([{ ...ubuntu, distro: 'Debian', id: 'bbbb' }], 'Ubuntu')).toBe( + 'gone' + ) + }) + + // An export and re-import under the same name gets a new GUID and keeps every file. + it('concludes nothing from a new GUID under the same name', () => { + expect(wslMovedDistroVerdict([{ ...ubuntu, id: 'cccc' }], 'Ubuntu', 'aaaa')).toBe('unknown') + }) + + it('cannot tell a re-register without a recorded GUID', () => { + expect(wslMovedDistroVerdict([{ ...ubuntu, id: 'cccc' }], 'Ubuntu')).toBe('present') + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-moved-distro-check.ts b/src/main/persistence/moving-wsl-projects/wsl-move-moved-distro-check.ts new file mode 100644 index 00000000000..9a2d9f9859c --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-moved-distro-check.ts @@ -0,0 +1,27 @@ +/** + * R38: whether a finished move's distro is still registered. A rename keeps the GUID, so it is the + * same distro. A new GUID under the old name is either a reinstall or a re-import of the same + * files, which the registry cannot tell apart. + */ +import type { WslDistroRegistration } from '../../wsl/wsl-distro-registry-id' +import type { WslMovedDistroVerdict } from './wsl-move-orchestrator-external-transitions' + +export function wslMovedDistroVerdict( + registrations: readonly WslDistroRegistration[] | null, + distro: string, + distroGuid?: string +): WslMovedDistroVerdict { + if (!registrations) { + return 'unknown' + } + const name = distro.trim().toLowerCase() + const guid = distroGuid?.trim().replace(/[{}]/g, '').toLowerCase() + const byName = registrations.find((entry) => entry.distro.toLowerCase() === name) + const byGuid = guid ? registrations.find((entry) => entry.id === guid) : undefined + if (!byName && !byGuid) { + return 'gone' + } + // A new GUID under the old name is also an export and re-import with the same files; only the + // host's catalog can tell a wiped distro apart, so until that check exists it proves nothing. + return byName && guid && !byGuid ? 'unknown' : 'present' +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestration-history-rewrite.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestration-history-rewrite.test.ts new file mode 100644 index 00000000000..f906573bff3 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestration-history-rewrite.test.ts @@ -0,0 +1,297 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { STRUCTURED_WORKER_HANDLE_PREFIX } from '../../../shared/structured-worker-handle' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import { OrchestrationDb } from '../../runtime/orchestration/db' +import { orchestrationDbFile } from '../../runtime/orchestration/db/orchestration-db-file' +import Database from '../../sqlite/sync-database' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { listWslMoveCutovers, wslMoveJournalDirectory } from './wsl-move-cutover-journal' +import { runWslMoveDesktopRewritePass } from './wsl-move-desktop-rewrite' +import { + listWslMoveDesktopRewriteStores, + resetWslMoveDesktopRewriteStoresForTests +} from './wsl-move-desktop-rewrite-stores' +import { + registerWslMoveOrchestrationHistoryStore, + rewriteWslMoveOrchestrationHistory, + WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID +} from './wsl-move-orchestration-history-rewrite' +import { createUncToLinuxPathRewriter } from './wsl-move-path-rewriter' +import { wslMoveRewriteScope } from './wsl-move-rewrite-scope' + +const MOVED = 'repo-a::\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-a' +const MOVED_ON_HOST = 'repo-a::/home/dev/repo-a' +const STAYING = 'repo-b::\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-b' +const OTHER_DISTRO = 'repo-a::\\\\wsl.localhost\\Debian\\home\\dev\\repo-a' +const SSH_PATH = 'repo-a::/srv/repo-a' +const MOVED_WSL_DOLLAR = 'repo-a::\\\\wsl$\\Ubuntu\\home\\dev\\repo-a' +const CHAT_HANDLE = `${STRUCTURED_WORKER_HANDLE_PREFIX}chat-1` + +let userDataPath: string +let databasePath: string + +function committedMove(rewrittenStores: string[] = []) { + return wslMoveCutoverFixture( + 'move-1', + 'destination-committed', + { rewrittenStores }, + { repoIds: ['repo-a'] } + ) +} + +function withDb(operate: (db: Database) => T): T { + const db = new Database(databasePath, { fileMustExist: true }) + try { + return operate(db) + } finally { + db.close() + } +} + +/** The current schema, through the real constructor, then rows as a desktop would hold them. */ +function seedOrchestrationHistory(): void { + new OrchestrationDb(databasePath).close() + withDb((db) => { + const dispatch = db.prepare( + `INSERT INTO worker_dispatches (dispatch_id, state, worktree_id, agent_terminal_handle) + VALUES (?, ?, ?, ?)` + ) + dispatch.run('d-moved', 'succeeded', MOVED, 'term_1') + dispatch.run('d-failed', 'failed', MOVED, 'term_2') + dispatch.run('d-wsl-dollar', 'stopped', MOVED_WSL_DOLLAR, 'term_3') + dispatch.run('d-chat', 'ready', MOVED, CHAT_HANDLE) + dispatch.run('d-federated', 'succeeded', MOVED, 'term_8') + dispatch.run('d-staying', 'succeeded', STAYING, 'term_4') + dispatch.run('d-other-distro', 'succeeded', OTHER_DISTRO, 'term_5') + dispatch.run('d-ssh', 'succeeded', SSH_PATH, 'term_6') + dispatch.run('d-no-worktree', 'abandoned', null, null) + const resource = db.prepare( + `INSERT INTO worker_terminal_resources + (id, origin_dispatch_id, owner_dispatch_id, worktree_id, terminal_handle) + VALUES (?, ?, ?, ?, ?)` + ) + resource.run('r-moved', 'd-moved', 'd-moved', MOVED, 'term_1') + resource.run('r-chat', 'd-chat', 'd-chat', MOVED, CHAT_HANDLE) + resource.run('r-remote', 'd-remote', 'd-remote', MOVED, 'term_7') + db.prepare( + `INSERT INTO remote_dispatch_attachments + (dispatch_id, task_id, home_peer_fingerprint, runtime_epoch, state, worktree_id) + VALUES (?, ?, ?, ?, ?, ?)` + ).run('d-remote', 'task-1', 'peer', 'epoch', 'succeeded', MOVED) + db.prepare( + `INSERT INTO federated_dispatches + (dispatch_id, environment_id, environment_name, peer_fingerprint, remote_worktree_id) + VALUES (?, ?, ?, ?, ?)` + ).run('d-federated', 'env-1', 'Remote', 'peer', MOVED) + }) +} + +function worktreeIds(table: string, key: string): Record { + return withDb((db) => + Object.fromEntries( + db + .prepare(`SELECT ${key} AS key, worktree_id FROM ${table} ORDER BY ${key}`) + .all() + .map((row) => [ + String(row.key), + typeof row.worktree_id === 'string' ? row.worktree_id : null + ]) + ) + ) +} + +const dispatchIds = () => worktreeIds('worker_dispatches', 'dispatch_id') +const resourceIds = () => worktreeIds('worker_terminal_resources', 'id') +const attachmentIds = () => worktreeIds('remote_dispatch_attachments', 'dispatch_id') + +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-orchestration-')) + databasePath = orchestrationDbFile(userDataPath) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + registerWslMoveOrchestrationHistoryStore(databasePath) +}) +afterEach(() => { + vi.unstubAllEnvs() + resetWslMoveDesktopRewriteStoresForTests() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +describe('WSL move orchestration history rewrite', () => { + it('registers one main-side store', () => { + expect(listWslMoveDesktopRewriteStores().map((store) => [store.id, store.side])).toEqual([ + [WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID, 'main'] + ]) + }) + + it("gives a moved project's settled local dispatches the host's worktree id", async () => { + seedOrchestrationHistory() + seedWslMoveCutover(userDataPath, committedMove()) + + await runWslMoveDesktopRewritePass({ userDataPath }) + + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([ + WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID + ]) + expect(dispatchIds()).toEqual({ + 'd-chat': MOVED, + 'd-failed': MOVED_ON_HOST, + 'd-federated': MOVED, + 'd-moved': MOVED_ON_HOST, + 'd-no-worktree': null, + 'd-other-distro': OTHER_DISTRO, + 'd-ssh': SSH_PATH, + 'd-staying': STAYING, + 'd-wsl-dollar': MOVED_ON_HOST + }) + expect(resourceIds()).toEqual({ + 'r-chat': MOVED, + 'r-moved': MOVED_ON_HOST, + 'r-remote': MOVED + }) + expect(attachmentIds()).toEqual({ 'd-remote': MOVED }) + }) + + it('leaves timestamps and JSON receipts as written', async () => { + seedOrchestrationHistory() + const before = withDb((db) => + db.prepare(`SELECT updated_at, effects, start_options FROM worker_dispatches`).all() + ) + seedWslMoveCutover(userDataPath, committedMove()) + + await runWslMoveDesktopRewritePass({ userDataPath }) + + const after = withDb((db) => + db.prepare(`SELECT updated_at, effects, start_options FROM worker_dispatches`).all() + ) + expect(after).toEqual(before) + }) + + it('changes nothing when a crash lost its ledger entry and it runs again', async () => { + seedOrchestrationHistory() + seedWslMoveCutover(userDataPath, committedMove()) + await runWslMoveDesktopRewritePass({ userDataPath }) + const dispatches = dispatchIds() + const resources = resourceIds() + rmSync(wslMoveJournalDirectory(userDataPath), { recursive: true, force: true }) + seedWslMoveCutover(userDataPath, committedMove()) + + await runWslMoveDesktopRewritePass({ userDataPath }) + + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([ + WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID + ]) + expect(dispatchIds()).toEqual(dispatches) + expect(resourceIds()).toEqual(resources) + const scope = wslMoveRewriteScope({ + cutover: committedMove(), + rewritePath: createUncToLinuxPathRewriter('Ubuntu'), + destinationHostId: 'runtime:wsl:Ubuntu', + recordSeededHostWorkspaceIds: () => {} + }) + expect(withDb((db) => rewriteWslMoveOrchestrationHistory(db, scope))).toBe(0) + }) + + it('touches nothing with no move journal', async () => { + seedOrchestrationHistory() + const bytes = readFileSync(databasePath) + const dispatches = dispatchIds() + + await runWslMoveDesktopRewritePass({ userDataPath }) + + expect(readFileSync(databasePath).equals(bytes)).toBe(true) + expect(dispatchIds()).toEqual(dispatches) + }) + + it('touches nothing for a move that has not committed', async () => { + seedOrchestrationHistory() + const dispatches = dispatchIds() + seedWslMoveCutover(userDataPath, { ...committedMove(), phase: 'blobs-transferred' }) + + await runWslMoveDesktopRewritePass({ userDataPath }) + + expect(dispatchIds()).toEqual(dispatches) + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([]) + }) + + it('records the store without creating a database the profile never had', async () => { + seedWslMoveCutover(userDataPath, committedMove()) + + await runWslMoveDesktopRewritePass({ userDataPath }) + + expect(existsSync(databasePath)).toBe(false) + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([ + WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID + ]) + }) + + it('waits, unrecorded, while a worker in scope is still live, then rewrites it once settled', async () => { + seedOrchestrationHistory() + withDb((db) => { + db.prepare( + `INSERT INTO worker_dispatches (dispatch_id, state, worktree_id, agent_terminal_handle) + VALUES ('d-live', 'start_unknown', ?, 'term_9')` + ).run(MOVED) + db.prepare( + `INSERT INTO worker_terminal_resources + (id, origin_dispatch_id, owner_dispatch_id, worktree_id, terminal_handle) + VALUES ('r-live', 'd-live', 'd-live', ?, 'term_9')` + ).run(MOVED) + }) + const dispatches = dispatchIds() + seedWslMoveCutover(userDataPath, committedMove()) + + await runWslMoveDesktopRewritePass({ userDataPath, warn: () => {} }) + + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([]) + expect(dispatchIds()).toEqual(dispatches) + withDb((db) => + db.prepare(`UPDATE worker_dispatches SET state = 'failed' WHERE dispatch_id = 'd-live'`).run() + ) + + await runWslMoveDesktopRewritePass({ userDataPath }) + + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([ + WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID + ]) + expect(dispatchIds()['d-live']).toBe(MOVED_ON_HOST) + expect(resourceIds()['r-live']).toBe(MOVED_ON_HOST) + }) + + it('rewrites a schema older than worker-terminal resources and remote attachments', async () => { + const db = new Database(databasePath) + db.exec( + `CREATE TABLE worker_dispatches ( + dispatch_id TEXT PRIMARY KEY, state TEXT NOT NULL, + worktree_id TEXT, agent_terminal_handle TEXT)` + ) + db.prepare(`INSERT INTO worker_dispatches VALUES ('d-old', 'succeeded', ?, 'term_1')`).run( + MOVED + ) + db.close() + seedWslMoveCutover(userDataPath, committedMove()) + + await runWslMoveDesktopRewritePass({ userDataPath }) + + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([ + WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID + ]) + expect(dispatchIds()).toEqual({ 'd-old': MOVED_ON_HOST }) + }) + + it('records a schema with no worktree column, changing nothing', async () => { + const db = new Database(databasePath) + db.exec(`CREATE TABLE worker_dispatches (dispatch_id TEXT PRIMARY KEY, state TEXT NOT NULL)`) + db.close() + seedWslMoveCutover(userDataPath, committedMove()) + + await runWslMoveDesktopRewritePass({ userDataPath }) + + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([ + WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID + ]) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestration-history-rewrite.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestration-history-rewrite.ts new file mode 100644 index 00000000000..f5fb0abe168 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestration-history-rewrite.ts @@ -0,0 +1,201 @@ +/** + * The desktop's orchestration history for a committed WSL move (owner decision 4: rewrite + * in place, the desktop keeps coordinating). A settled local worker dispatch in a moved project, + * and its worker-terminal row, take the host's worktree id. Everything else in orchestration.db + * stays as written; `SQLITE_TABLE_DISPOSITIONS` gives each table's reason. + */ +import { existsSync } from 'node:fs' +import { isStructuredWorkerHandle } from '../../../shared/structured-worker-handle' +import { hardenOrchestrationDatabaseFiles } from '../../runtime/orchestration/db/database-file-permissions' +import Database from '../../sqlite/sync-database' +import type { SqliteRow } from '../../sqlite/sqlite-statement' +import { registerWslMoveDesktopRewriteStore } from './wsl-move-desktop-rewrite-stores' +import { wslMoveRewriteScope, type WslMoveRewriteScope } from './wsl-move-rewrite-scope' + +export const WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID = 'orchestration-history' + +/** The recoverable worker states. The move's dispatch gate must refuse this same set. */ +export const WSL_MOVE_LIVE_WORKER_STATES = [ + 'starting', + 'ready', + 'start_unknown', + 'stopping', + 'stop_unknown' +] as const + +const LIVE_WORKER_STATES_SQL = `(${WSL_MOVE_LIVE_WORKER_STATES.map((state) => `'${state}'`).join(', ')})` + +// The lock wait blocks the main thread (synchronous SQLite), so keep it short; a busy pass retries. +const BUSY_TIMEOUT_MS = 2_000 + +type Rewrite = { key: string; from: string; to: string } + +function text(row: SqliteRow, column: string): string | null { + const value = row[column] + return typeof value === 'string' ? value : null +} + +function hasColumns(db: Database, table: string, columns: readonly string[]): boolean { + const names = new Set( + db + .prepare(`SELECT name FROM pragma_table_info(?)`) + .all(table) + .map((row) => text(row, 'name')) + ) + return columns.every((column) => names.has(column)) +} + +/** Excludes rows whose dispatch id appears in `table`, when that table exists. */ +function unlessListedIn(db: Database, table: string, dispatchIdColumn: string): string { + return hasColumns(db, table, ['dispatch_id']) + ? `AND NOT EXISTS (SELECT 1 FROM ${table} x WHERE x.dispatch_id = ${dispatchIdColumn})` + : '' +} + +/** + * Rows to rewrite; a structured (chat) worker stays local with its chat (owner decision C). A live + * row in scope throws, so the store gets no ledger entry and a later pass finds it settled. + */ +function plan( + rows: SqliteRow[], + scope: WslMoveRewriteScope, + keyColumn: string, + handleColumn: string +): Rewrite[] { + return rows.flatMap((row) => { + const key = text(row, keyColumn) + const from = text(row, 'worktree_id') + if (!key || !from || isStructuredWorkerHandle(text(row, handleColumn))) { + return [] + } + const to = scope.inScope(from) ? scope.worktreeId(from) : from + if (to === from) { + return [] + } + if (row.live === 1) { + throw new Error('wsl_move_orchestration_worker_live') + } + return [{ key, from, to }] + }) +} + +function dispatchRewrites(db: Database, scope: WslMoveRewriteScope): Rewrite[] { + if ( + !hasColumns(db, 'worker_dispatches', [ + 'dispatch_id', + 'worktree_id', + 'state', + 'agent_terminal_handle' + ]) + ) { + return [] + } + // A federated dispatch's row names the remote host's worktree. + const rows = db + .prepare( + `SELECT w.dispatch_id, w.worktree_id, w.agent_terminal_handle, + w.state IN ${LIVE_WORKER_STATES_SQL} AS live + FROM worker_dispatches w + WHERE w.worktree_id IS NOT NULL + ${unlessListedIn(db, 'federated_dispatches', 'w.dispatch_id')}` + ) + .all() + return plan(rows, scope, 'dispatch_id', 'agent_terminal_handle') +} + +function resourceRewrites(db: Database, scope: WslMoveRewriteScope): Rewrite[] { + if ( + !hasColumns(db, 'worker_terminal_resources', [ + 'id', + 'worktree_id', + 'owner_dispatch_id', + 'terminal_handle' + ]) || + !hasColumns(db, 'worker_dispatches', ['dispatch_id', 'state']) + ) { + return [] + } + // A federated worker's row belongs with its attachment, which a remote home reads verbatim. + const rows = db + .prepare( + `SELECT r.id, r.worktree_id, r.terminal_handle, + COALESCE(w.state IN ${LIVE_WORKER_STATES_SQL}, 0) AS live + FROM worker_terminal_resources r + LEFT JOIN worker_dispatches w ON w.dispatch_id = r.owner_dispatch_id + WHERE r.worktree_id IS NOT NULL + ${unlessListedIn(db, 'remote_dispatch_attachments', 'r.owner_dispatch_id')}` + ) + .all() + return plan(rows, scope, 'id', 'terminal_handle') +} + +// updated_at is left alone: the rewrite must not reorder history. +function applyRewrites(db: Database, sql: string, rewrites: readonly Rewrite[]): number { + if (rewrites.length === 0) { + return 0 + } + const update = db.prepare(sql) + return rewrites.reduce( + (changed, row) => changed + Number(update.run(row.to, row.key, row.from).changes), + 0 + ) +} + +/** + * Idempotent: a rewritten id is no longer a UNC path of the distro, so a second run finds nothing. + * One write transaction for both tables, planned before any write. Returns how many rows changed. + */ +export function rewriteWslMoveOrchestrationHistory( + db: Database, + scope: WslMoveRewriteScope +): number { + db.exec('BEGIN IMMEDIATE') + try { + const dispatches = dispatchRewrites(db, scope) + const resources = resourceRewrites(db, scope) + const changed = + applyRewrites( + db, + `UPDATE worker_dispatches SET worktree_id = ? WHERE dispatch_id = ? AND worktree_id = ?`, + dispatches + ) + + applyRewrites( + db, + `UPDATE worker_terminal_resources SET worktree_id = ? WHERE id = ? AND worktree_id = ?`, + resources + ) + db.exec('COMMIT') + return changed + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} + +/** + * Its own connection, so it runs before the runtime opens the database at startup. It never creates + * the file or migrates it: a profile with no database has no history, and an older schema is read + * as it stands. + */ +export function registerWslMoveOrchestrationHistoryStore(databasePath: string): void { + registerWslMoveDesktopRewriteStore({ + id: WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID, + side: 'main', + run: (context) => { + if (!existsSync(databasePath)) { + return + } + const db = new Database(databasePath, { fileMustExist: true, timeout: BUSY_TIMEOUT_MS }) + try { + // A FULL commit is on disk when it returns, which is what the ledger entry needs. + db.pragma('synchronous = FULL') + rewriteWslMoveOrchestrationHistory(db, wslMoveRewriteScope(context)) + } finally { + db.close() + hardenOrchestrationDatabaseFiles(databasePath) + } + }, + // `run` commits with synchronous FULL, so its write is durable before this is called. + flush: () => {} + }) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-driver.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-driver.test.ts new file mode 100644 index 00000000000..d18ba909352 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-driver.test.ts @@ -0,0 +1,210 @@ +/** + * The move orchestrator (D3a) over the real move driver and journal: only the WSL host and the + * terminals are faked (the driver tests' world), so the orchestrator's reading of every driver + * outcome is checked against what the driver actually returns. + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import { + WSL_MOVE_CUTOVER_PHASES, + wslMoveJournalKey +} from '../../../shared/wsl-move/wsl-move-cutover' +import type { WslMoveOrchestratorRecord } from '../../../shared/wsl-move/wsl-move-orchestrator-types' +import { + createWslMoveWorld, + WSL_MOVE_WORLD_PANE, + type WslMoveWorld +} from '../__fixtures__/wsl-mover/wsl-move-driver-world' +import { listWslMoveCutoverChain, listWslMoveCutovers } from './wsl-move-cutover-journal' +import { + registerWslMoveDesktopRewriteStore, + resetWslMoveDesktopRewriteStoresForTests, + setWslMoveDesktopRewriteRegistryCompleteForTests +} from './wsl-move-desktop-rewrite-stores' +import { moveProjectsToWslHost, previewWslMove } from './wsl-move-driver' +import { WslMoveOrchestrator } from './wsl-move-orchestrator' +import type { WslMoveOrchestratorPorts } from './wsl-move-orchestrator-ports' +import { + WSL_MOVE_ORCHESTRATOR_DEFERRED_RECHECK_MS, + WSL_MOVE_ORCHESTRATOR_UNCOUNTED_RETRY_MS +} from './wsl-move-orchestrator-transitions' +import { runWslMoveRetentionPass } from './wsl-move-retention' +import { bringWslMovedProjectBack } from './wsl-move-use-locally' +import { resetWslMoveWindowWriteHoldForTests } from './wsl-move-window-write-hold' + +const COMMITTED_FROM = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') + +let userDataPath: string +let world: WslMoveWorld + +beforeEach(() => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-orchestrator-driver-')) + world = createWslMoveWorld(userDataPath) + setWslMoveDesktopRewriteRegistryCompleteForTests(true) + registerWslMoveDesktopRewriteStore({ + id: 'ui-routing', + side: 'main', + run: () => {}, + flush: () => {} + }) +}) + +afterEach(() => { + vi.unstubAllEnvs() + resetWslMoveDesktopRewriteStoresForTests() + resetWslMoveWindowWriteHoldForTests() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +/** The orchestrator's production ports, with the driver and journal real and the host faked. */ +function orchestratorOverDriver(): { + orchestrator: WslMoveOrchestrator + advance: (ms: number) => Promise +} { + const clock = { now: 1_000 } + const timers: { at: number; run: () => void }[] = [] + let saved = new Map() + const ports: WslMoveOrchestratorPorts = { + now: () => clock.now, + appVersion: '1.0.0', + autoMoveEnabled: () => true, + prepareHost: async () => 'ready', + hostRemovalPending: () => false, + hasHost: () => true, + isHostReady: () => true, + preview: (distro) => previewWslMove(world.deps, { distro }), + start: async (distro, stillWanted) => + stillWanted() + ? moveProjectsToWslHost(world.deps, { distro }) + : { outcome: 'refused', code: 'wsl_move_cancelled' }, + // As the wiring reads it: the newest committed move of the distro's chain. + finishedMove: (distro) => { + const move = listWslMoveCutoverChain(userDataPath, wslMoveJournalKey(distro)).findLast( + (cutover) => WSL_MOVE_CUTOVER_PHASES.indexOf(cutover.phase) >= COMMITTED_FROM + ) + return move ? { migrationId: move.migrationId } : null + }, + checkMovedDistro: async () => 'present', + releaseFinishedMove: async () => ({ released: false, reason: 'not-moved' }), + load: () => saved, + save: (records) => { + saved = new Map(records) + }, + schedule: (delayMs, run) => { + const timer = { at: clock.now + delayMs, run } + timers.push(timer) + return () => { + timers.splice(timers.indexOf(timer), 1) + } + }, + track: () => {}, + publish: () => {} + } + const orchestrator = new WslMoveOrchestrator(ports) + return { + orchestrator, + advance: async (ms) => { + clock.now += ms + for (const timer of timers.filter((entry) => entry.at <= clock.now)) { + timers.splice(timers.indexOf(timer), 1) + timer.run() + } + await orchestrator.whenIdle() + } + } +} + +function stateOf(orchestrator: WslMoveOrchestrator) { + return orchestrator.list().find((record) => record.distro === 'Ubuntu')?.state +} + +describe('the move orchestrator over the real driver', () => { + it('moves a distro on a wake and records the journal migration it finished', async () => { + const { orchestrator } = orchestratorOverDriver() + await orchestrator.trigger('Ubuntu', 'project-opened') + const committed = listWslMoveCutovers(userDataPath) + expect(committed).toHaveLength(1) + expect(stateOf(orchestrator)).toEqual({ kind: 'done', migrationId: committed[0]?.migrationId }) + expect([...world.catalog.values()]).toEqual(['committed']) + }) + + it('defers on the driver blockers, then moves on the re-check once the terminals are free', async () => { + world.gate = { + panes: [ + { + paneKey: WSL_MOVE_WORLD_PANE, + foreground: 'process-running', + agent: { state: 'working', resumable: true, ownsForeground: true }, + scrollbackBytes: 0 + } + ], + runningAutomationRunIds: [], + activeDispatchIds: [] + } + const { orchestrator, advance } = orchestratorOverDriver() + await orchestrator.trigger('Ubuntu', 'connect') + expect(stateOf(orchestrator)).toMatchObject({ + kind: 'deferred', + blockers: [{ kind: 'agent-working', ids: [WSL_MOVE_WORLD_PANE] }] + }) + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + world.gate = { panes: [], runningAutomationRunIds: [], activeDispatchIds: [] } + await advance(WSL_MOVE_ORCHESTRATOR_DEFERRED_RECHECK_MS[0]) + expect(stateOf(orchestrator)).toMatchObject({ kind: 'done' }) + }) + + it('backs off a move the driver reset before the host committed it, leaving no journal', async () => { + world.failAt.add('prove-exited') + const { orchestrator, advance } = orchestratorOverDriver() + await orchestrator.trigger('Ubuntu', 'connect') + expect(stateOf(orchestrator)).toMatchObject({ kind: 'backoff' }) + expect(orchestrator.list()[0]?.attempts).toBe(1) + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + await advance(60_000) + expect(stateOf(orchestrator)).toMatchObject({ kind: 'done' }) + }) + + // The driver itself refuses as already in flight until retention marks the move retained. + it('waits out the real in-flight refusal while the move is retained, staying done', async () => { + const { orchestrator, advance } = orchestratorOverDriver() + await orchestrator.trigger('Ubuntu', 'connect') + const migrationId = listWslMoveCutovers(userDataPath)[0]?.migrationId + expect(listWslMoveCutovers(userDataPath)[0]?.phase).not.toBe('retained') + expect(await previewWslMove(world.deps, { distro: 'Ubuntu' })).toMatchObject({ + outcome: 'refused', + code: 'wsl_move_already_in_flight' + }) + await orchestrator.trigger('Ubuntu', 'project-opened') + expect(stateOf(orchestrator)).toMatchObject({ kind: 'done', migrationId, waits: 1 }) + expect(orchestrator.list()[0]?.attempts).toBe(0) + await runWslMoveRetentionPass({ + userDataPath, + store: { + getRepos: () => world.repos, + getFolderWorkspaces: () => [], + setWslMoveRetainedMarks: vi.fn(() => 1), + flushPendingOrThrowAsync: vi.fn(async () => {}) + } + }) + expect(listWslMoveCutovers(userDataPath)[0]?.phase).toBe('retained') + await advance(WSL_MOVE_ORCHESTRATOR_UNCOUNTED_RETRY_MS) + expect(stateOf(orchestrator)).toEqual({ kind: 'done', migrationId }) + expect(listWslMoveCutovers(userDataPath)).toHaveLength(1) + }) + + it('never moves again after the user brings the project back', async () => { + const { orchestrator } = orchestratorOverDriver() + await orchestrator.trigger('Ubuntu', 'connect') + expect(bringWslMovedProjectBack(userDataPath, { repoId: 'repo-1' }).released).toBe(true) + orchestrator.releasedByUser('Ubuntu') + const stagedBefore = world.points.filter((point) => point === 'stage:before').length + await orchestrator.trigger('Ubuntu', 'project-opened') + await orchestrator.trigger('Ubuntu', 'connect') + expect(stateOf(orchestrator)).toEqual({ kind: 'parked', reason: 'declined' }) + expect(world.points.filter((point) => point === 'stage:before').length).toBe(stagedBefore) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-external-transitions.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-external-transitions.ts new file mode 100644 index 00000000000..1e7d213c6f8 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-external-transitions.ts @@ -0,0 +1,117 @@ +/** + * The orchestrator's transitions for what happens outside a move step: the user's choices + * (Retry, Not now, a release, a host Add or Remove), R38 distro verdicts, and startup settling. + */ +import type { + WslMoveOrchestratorRecord, + WslMoveOrchestratorUserAction +} from '../../../shared/wsl-move/wsl-move-orchestrator-types' +import { + WSL_MOVE_ORCHESTRATOR_NOT_NOW_MS, + withWslMoveOrchestratorState +} from './wsl-move-orchestrator-transitions' + +/** Whether a finished move's distro is still the one it moved into (R38). */ +export type WslMovedDistroVerdict = + | 'present' + /** Neither its name nor its recorded GUID is registered. */ + | 'gone' + /** The host lists none of the moved projects (needs the host-catalog check). */ + | 'replaced' + /** The registry or host could not be read, or only the GUID differs; nothing is concluded. */ + | 'unknown' + +export function applyWslMovedDistroVerdict( + record: WslMoveOrchestratorRecord, + verdict: WslMovedDistroVerdict, + now: number +): WslMoveOrchestratorRecord { + switch (verdict) { + case 'gone': + return withWslMoveOrchestratorState(record, { kind: 'distro-gone' }, now) + case 'replaced': + return withWslMoveOrchestratorState(record, { kind: 'restore-offered' }, now) + case 'present': + // A distro registered again, or whose host still has the projects, is back. + return record.state.kind === 'distro-gone' || record.state.kind === 'restore-offered' + ? withWslMoveOrchestratorState(record, { kind: 'pending' }, now) + : record + case 'unknown': + return record + } +} + +/** The record after a user action; `restore` applies only once the release succeeded. */ +export function applyWslMoveUserAction( + record: WslMoveOrchestratorRecord, + action: WslMoveOrchestratorUserAction, + now: number, + appVersion: string +): WslMoveOrchestratorRecord { + if (record.state.kind === 'moving') { + return record + } + if (action === 'not-now') { + return withWslMoveOrchestratorState( + record, + { kind: 'parked', reason: 'not-now', until: now + WSL_MOVE_ORCHESTRATOR_NOT_NOW_MS }, + now + ) + } + return withWslMoveOrchestratorState(record, { kind: 'pending' }, now, { attempts: 0, appVersion }) +} + +/** "Use on this computer again" is the user saying no: nothing moves the distro until Retry. */ +export function applyWslMoveReleasedByUser( + record: WslMoveOrchestratorRecord, + now: number +): WslMoveOrchestratorRecord { + return withWslMoveOrchestratorState(record, { kind: 'parked', reason: 'declined' }, now) +} + +/** The user added the host themselves: a decline (theirs, by Remove or release) is taken back. */ +export function applyWslMoveHostAddedByUser( + record: WslMoveOrchestratorRecord, + now: number +): WslMoveOrchestratorRecord { + return record.state.kind === 'parked' && record.state.reason === 'declined' + ? withWslMoveOrchestratorState(record, { kind: 'pending' }, now) + : record +} + +/** A host whose Remove is unfinished is the user's to settle; park the distro until they act. */ +export function applyWslMoveHostRemovalPending( + record: WslMoveOrchestratorRecord, + now: number +): WslMoveOrchestratorRecord { + return withWslMoveOrchestratorState(record, { kind: 'parked', reason: 'removal-pending' }, now) +} + +/** The pending Remove settled: a kept host resumes, a removed one stays the user's decline. */ +export function applyWslMoveHostRemovalSettled( + record: WslMoveOrchestratorRecord, + hostKept: boolean, + now: number +): WslMoveOrchestratorRecord { + return withWslMoveOrchestratorState( + record, + hostKept ? { kind: 'pending' } : { kind: 'parked', reason: 'declined' }, + now + ) +} + +/** A record read at startup, made true to what survived the last process. */ +export function settleWslMoveOrchestratorRecord( + record: WslMoveOrchestratorRecord, + now: number, + hasFinishedMove: boolean +): WslMoveOrchestratorRecord { + // The driver's own startup recovery resumes or resets the journal; the next trigger previews. + if (record.state.kind === 'moving') { + return withWslMoveOrchestratorState(record, { kind: 'pending' }, now) + } + // A Restore that released the move before the app quit has nothing left to restore. + return record.state.kind === 'restore-offered' && !hasFinishedMove + ? withWslMoveOrchestratorState(record, { kind: 'pending' }, now) + : record +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-ports.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-ports.ts new file mode 100644 index 00000000000..69abf1b9be1 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-ports.ts @@ -0,0 +1,60 @@ +/** What the move orchestrator (D3a) reads and drives, so tests and production share one shape. */ +import type { WslMovePreview, WslMoveResult } from '../../../shared/wsl-move/wsl-move-driver-types' +import type { WslMoveUseLocallyResult } from '../../../shared/wsl-move/wsl-moved-project' +import type { + WslMoveOrchestratorRecord, + WslMoveOrchestratorTrigger +} from '../../../shared/wsl-move/wsl-move-orchestrator-types' +import type { WslMovedDistroVerdict } from './wsl-move-orchestrator-external-transitions' + +export type WslMoveHostReadiness = 'ready' | 'not-running' | 'unverifiable' | 'failed' + +/** The newest finished move of a distro, as R38's check needs it. */ +export type WslMoveFinishedMove = { migrationId: string; distroGuid?: string } + +export type WslMoveOrchestratorPorts = { + now: () => number + appVersion: string + /** Kill switches for automatic starts (env, rollout); explicit user actions still run. */ + autoMoveEnabled: () => boolean + /** + * Readies the distro's host for an automatic step, only if the distro is confirmed running: + * connects or adds its host then, and never boots it (A1). A failed running-distro read is + * `unverifiable`, never `not-running`: losing contact is no evidence either way. + */ + prepareHost: (distro: string, stillWanted: () => boolean) => Promise + /** E11: the user's Remove of the distro's host has not proven its server stopped. */ + hostRemovalPending: (distro: string) => boolean + /** Whether the distro still has a WSL host record; never connects. */ + hasHost: (distro: string) => boolean + /** Whether the distro's host connection is ready now; never connects. Gates timer steps. */ + isHostReady: (distro: string) => boolean + preview: (distro: string) => Promise + /** + * Runs the move. `stillWanted` turns false once the user released or snoozed the distro; the + * port checks it after its own waits (the window's panes) and refuses rather than start. + */ + start: (distro: string, stillWanted: () => boolean) => Promise + finishedMove: (distro: string) => WslMoveFinishedMove | null + /** R38: reads the registry and, when ready, the host's catalog; never boots the distro. */ + checkMovedDistro: (distro: string, move: WslMoveFinishedMove) => Promise + /** + * R38 restore, step one: drop every finished move of the distro, newest first, so its retained + * rows can move again. The window reloads after a release; the move waits for the next trigger. + */ + releaseFinishedMove: ( + distro: string, + unsavedWorkspaceIds: readonly string[] + ) => Promise + load: () => Map + save: (records: ReadonlyMap) => void + /** Returns the cancel. */ + schedule: (delayMs: number, run: () => void) => () => void + /** One row per state change; `cause` is the preview or result behind it. */ + track: ( + trigger: WslMoveOrchestratorTrigger, + record: WslMoveOrchestratorRecord, + cause?: WslMovePreview | WslMoveResult + ) => void + publish: (records: WslMoveOrchestratorRecord[]) => void +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-step.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-step.ts new file mode 100644 index 00000000000..5940165eeab --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-step.ts @@ -0,0 +1,147 @@ +/** + * One orchestrator step for a distro: the pending-Remove and R38 checks, the A1 gate, then the + * preview and, when it is clear, the move. Every wait re-reads the record, so a choice the user + * made meanwhile (a release, Not now) wins over the step's stale view. + */ +import type { WslMovePreview, WslMoveResult } from '../../../shared/wsl-move/wsl-move-driver-types' +import type { + WslMoveOrchestratorRecord, + WslMoveOrchestratorTrigger +} from '../../../shared/wsl-move/wsl-move-orchestrator-types' +import { + applyWslMovedDistroVerdict, + applyWslMoveHostRemovalPending, + applyWslMoveHostRemovalSettled, + type WslMovedDistroVerdict +} from './wsl-move-orchestrator-external-transitions' +import type { + WslMoveFinishedMove, + WslMoveHostReadiness, + WslMoveOrchestratorPorts +} from './wsl-move-orchestrator-ports' +import { + applyWslMoveOutcome, + classifyWslMoveOutcome, + mayStartWslMove +} from './wsl-move-orchestrator-transitions' + +export type WslMoveOrchestratorStepContext = { + ports: WslMoveOrchestratorPorts + /** The distro's record when the step began. */ + record: WslMoveOrchestratorRecord + /** True when the stored record is no longer `seen`. */ + changedUnder: (seen: WslMoveOrchestratorRecord) => boolean + /** Stores the record and returns the one now stored. */ + commit: ( + next: WslMoveOrchestratorRecord, + cause?: WslMovePreview | WslMoveResult + ) => WslMoveOrchestratorRecord +} + +/** The newest finished move; `unknown` when the journal cannot be read. */ +export function readWslMoveFinishedMove( + ports: Pick, + distro: string +): WslMoveFinishedMove | null | 'unknown' { + try { + return ports.finishedMove(distro) + } catch { + return 'unknown' + } +} + +/** A port that throws is a transient failure, never a lost record. */ +async function settle( + run: () => Promise +): Promise { + try { + return await run() + } catch (error) { + return { + outcome: 'reset', + migrationId: '', + detail: error instanceof Error ? error.message : 'orchestrator_error' + } + } +} + +export async function runWslMoveOrchestratorStep( + context: WslMoveOrchestratorStepContext, + distro: string, + trigger: WslMoveOrchestratorTrigger +): Promise { + const { ports, changedUnder, commit } = context + const now = (): number => ports.now() + const apply = ( + record: WslMoveOrchestratorRecord, + cause: WslMovePreview | WslMoveResult + ): WslMoveOrchestratorRecord => + applyWslMoveOutcome(record, classifyWslMoveOutcome(cause), { + now: now(), + appVersion: ports.appVersion, + trigger + }) + let record = context.record + // Before anything that could connect, add or move: even the user's Retry waits for the Remove. + let removalPending: boolean + try { + removalPending = ports.hostRemovalPending(distro) + } catch { + // An unreadable host record proves nothing either way; touch nothing this time. + return + } + if (removalPending) { + commit(applyWslMoveHostRemovalPending(record, now())) + return + } + // The Remove settled: a kept host (the user pressed Connect) resumes; a gone one was declined. + if (record.state.kind === 'parked' && record.state.reason === 'removal-pending') { + record = commit(applyWslMoveHostRemovalSettled(record, ports.hasHost(distro), now())) + } + // A timer re-check leaves R38 to the triggers, so a deferred move never polls the registry. + const finished = trigger === 'timer' ? null : readWslMoveFinishedMove(ports, distro) + if (finished === 'unknown') { + return + } + if (finished) { + const verdict = await ports + .checkMovedDistro(distro, finished) + .catch((): WslMovedDistroVerdict => 'unknown') + if (changedUnder(record)) { + return + } + record = commit(applyWslMovedDistroVerdict(record, verdict, now())) + } + if (!mayStartWslMove(record, trigger, now(), ports.appVersion)) { + return + } + // A1: only an explicit user action may wake a distro; every other step needs it running. + // A timer step never connects either: connecting can hold a running distro up. + if (trigger === 'timer') { + if (!ports.isHostReady(distro)) { + return + } + } else if (trigger !== 'user') { + const seen = record + const readiness = await ports + .prepareHost(distro, () => !changedUnder(seen)) + .catch((): WslMoveHostReadiness => 'unverifiable') + if (readiness !== 'ready' || changedUnder(record)) { + return + } + } + const preview = await settle(() => ports.preview(distro)) + if (changedUnder(record)) { + return + } + record = commit(apply(record, preview), preview) + if (record.state.kind !== 'moving') { + return + } + const started = record + // The port re-checks this once it has the window's panes, just before the driver claims the run. + const result = await settle(() => ports.start(distro, () => !changedUnder(started))) + if (!changedUnder(record)) { + commit(apply(record, result), result) + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-store.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-store.test.ts new file mode 100644 index 00000000000..6a223d4b17a --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-store.test.ts @@ -0,0 +1,82 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { wslMoveJournalKey } from '../../../shared/wsl-move/wsl-move-cutover' +import type { WslMoveOrchestratorRecord } from '../../../shared/wsl-move/wsl-move-orchestrator-types' +import { + readWslMoveOrchestratorRecords, + wslMoveOrchestratorStorePath, + writeWslMoveOrchestratorRecords +} from './wsl-move-orchestrator-store' + +const dirs: string[] = [] + +function userData(): string { + const dir = mkdtempSync(join(tmpdir(), 'wsl-move-orchestrator-')) + dirs.push(dir) + return dir +} + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +const deferred: WslMoveOrchestratorRecord = { + distro: 'Ubuntu', + state: { + kind: 'deferred', + blockers: [{ kind: 'process-running', ids: ['tab:leaf'], actions: ['end', 'skip-project'] }], + rechecks: 1 + }, + attempts: 2, + appVersion: '1.0.0', + updatedAt: 5 +} + +describe('wsl-move-orchestrator store', () => { + it('round-trips records keyed by distro', () => { + const dir = userData() + const records = new Map([[wslMoveJournalKey('Ubuntu'), deferred]]) + writeWslMoveOrchestratorRecords(dir, records) + expect(readWslMoveOrchestratorRecords(dir)).toEqual(records) + }) + + it('reads a missing or unparseable file as empty', () => { + const dir = userData() + expect(readWslMoveOrchestratorRecords(dir).size).toBe(0) + writeFileSync(wslMoveOrchestratorStorePath(dir), '{not json') + expect(readWslMoveOrchestratorRecords(dir).size).toBe(0) + }) + + it('drops a bad record alone and keeps the rest', () => { + const dir = userData() + writeFileSync( + wslMoveOrchestratorStorePath(dir), + JSON.stringify({ + version: 1, + distros: { + a: deferred, + b: { ...deferred, distro: 'Debian', state: { kind: 'from-a-newer-build' } } + } + }) + ) + expect([...readWslMoveOrchestratorRecords(dir).values()]).toEqual([deferred]) + }) + + it('reads a deferred record from before re-check counting as a fresh series', () => { + const dir = userData() + const { rechecks: _rechecks, ...older } = + deferred.state.kind === 'deferred' ? deferred.state : { rechecks: 0 } + writeFileSync( + wslMoveOrchestratorStorePath(dir), + JSON.stringify({ version: 1, distros: { a: { ...deferred, state: older } } }) + ) + expect([...readWslMoveOrchestratorRecords(dir).values()][0]?.state).toMatchObject({ + kind: 'deferred', + rechecks: 0 + }) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-store.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-store.ts new file mode 100644 index 00000000000..7ce61242662 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-store.ts @@ -0,0 +1,118 @@ +/** + * The orchestrator's per-distro records, in their own sidecar: older builds rewrite orca-data.json + * without fields they do not know, and an unreadable file only costs the orchestrator its memory + * (the move journal stays the source of truth for any move itself). + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { z } from 'zod' +import { writeDurableSecureJsonFile } from '../../../shared/secure-file' +import { wslMoveJournalKey } from '../../../shared/wsl-move/wsl-move-cutover' +import type { WslMoveOrchestratorRecord } from '../../../shared/wsl-move/wsl-move-orchestrator-types' + +const FILE_NAME = 'wsl-move-orchestrator.json' + +const blockerSchema = z.object({ + kind: z.enum([ + 'agent-working', + 'process-running', + 'agent-not-resumable', + 'unverifiable', + 'automation-running', + 'dispatch-running', + 'scrollback-too-large' + ]), + ids: z.array(z.string()), + actions: z.array(z.enum(['wait', 'end', 'skip-project', 'clear-scrollback'])) +}) + +const stateSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('idle') }), + z.object({ kind: z.literal('pending') }), + z.object({ + kind: z.literal('deferred'), + blockers: z.array(blockerSchema), + rechecks: z.number().int().nonnegative().default(0) + }), + z.object({ kind: z.literal('moving') }), + z.object({ kind: z.literal('waiting-for-host'), migrationId: z.string() }), + z.object({ + kind: z.literal('backoff'), + nextAttemptAt: z.number(), + reason: z.string(), + waits: z.number().int().nonnegative().optional() + }), + z.object({ + kind: z.literal('parked'), + reason: z.enum([ + 'export-incomplete', + 'manifest-too-large', + 'host-update-required', + 'attempts-exhausted', + 'not-now', + 'declined', + 'removal-pending', + 'stuck' + ]), + until: z.number().optional() + }), + z.object({ + kind: z.literal('done'), + migrationId: z.string(), + nextCheckAt: z.number().optional(), + waits: z.number().int().nonnegative().optional() + }), + z.object({ kind: z.literal('distro-gone') }), + z.object({ kind: z.literal('restore-offered') }) +]) + +const recordSchema = z.object({ + distro: z.string().min(1), + state: stateSchema, + attempts: z.number().int().nonnegative(), + appVersion: z.string(), + updatedAt: z.number() +}) + +const fileSchema = z.object({ + version: z.literal(1), + distros: z.record(z.string(), z.unknown()) +}) + +export function wslMoveOrchestratorStorePath(userDataPath: string): string { + return join(userDataPath, FILE_NAME) +} + +/** Every readable record, keyed by the distro's journal key; a bad record is dropped alone. */ +export function readWslMoveOrchestratorRecords( + userDataPath: string +): Map { + const records = new Map() + let raw: unknown + try { + raw = JSON.parse(readFileSync(wslMoveOrchestratorStorePath(userDataPath), 'utf8')) + } catch { + return records + } + const file = fileSchema.safeParse(raw) + if (!file.success) { + return records + } + for (const value of Object.values(file.data.distros)) { + const record = recordSchema.safeParse(value) + if (record.success) { + records.set(wslMoveJournalKey(record.data.distro), record.data) + } + } + return records +} + +export function writeWslMoveOrchestratorRecords( + userDataPath: string, + records: ReadonlyMap +): void { + writeDurableSecureJsonFile(wslMoveOrchestratorStorePath(userDataPath), { + version: 1, + distros: Object.fromEntries(records) + }) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-transitions.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-transitions.test.ts new file mode 100644 index 00000000000..962fa8f6974 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-transitions.test.ts @@ -0,0 +1,451 @@ +import { describe, expect, it } from 'vitest' +import type { + WslMoveBlocker, + WslMoveRefusalCode +} from '../../../shared/wsl-move/wsl-move-driver-types' +import type { WslMoveOrchestratorRecord } from '../../../shared/wsl-move/wsl-move-orchestrator-types' +import { + applyWslMoveOutcome, + classifyWslMoveOutcome, + mayStartWslMove, + newWslMoveOrchestratorRecord, + sameWslMoveOrchestratorRecord, + WSL_MOVE_ORCHESTRATOR_BACKOFF_MS, + WSL_MOVE_ORCHESTRATOR_DEFERRED_RECHECK_MS, + WSL_MOVE_ORCHESTRATOR_MAX_ATTEMPTS, + WSL_MOVE_ORCHESTRATOR_NOT_NOW_MS, + WSL_MOVE_ORCHESTRATOR_UNCOUNTED_RETRY_MS, + WSL_MOVE_ORCHESTRATOR_UNCOUNTED_WAIT_LIMIT, + wslMoveOrchestratorTimerDelay +} from './wsl-move-orchestrator-transitions' +import { + applyWslMovedDistroVerdict, + applyWslMoveHostAddedByUser, + applyWslMoveHostRemovalPending, + applyWslMoveReleasedByUser, + applyWslMoveUserAction, + settleWslMoveOrchestratorRecord +} from './wsl-move-orchestrator-external-transitions' + +const NOW = 1_000_000 +const context = { now: NOW, appVersion: '1.0.0', trigger: 'connect' as const } +const blocker: WslMoveBlocker = { + kind: 'agent-working', + ids: ['tab:leaf'], + actions: ['wait', 'end'] +} + +function record( + state: WslMoveOrchestratorRecord['state'], + attempts = 0 +): WslMoveOrchestratorRecord { + return { ...newWslMoveOrchestratorRecord('Ubuntu', '1.0.0', 0), state, attempts } +} + +function refused(code: WslMoveRefusalCode) { + return classifyWslMoveOutcome({ outcome: 'refused', code }) +} + +describe('classifyWslMoveOutcome', () => { + it('starts a ready preview and defers one with blockers', () => { + const ready = { + outcome: 'ready' as const, + distro: 'Ubuntu', + repoIds: ['r1'], + folderWorkspaceIds: [], + changed: [], + heldByHost: [], + refused: [] + } + expect(classifyWslMoveOutcome({ ...ready, blockers: [] })).toEqual({ next: 'start' }) + expect(classifyWslMoveOutcome({ ...ready, blockers: [blocker] })).toEqual({ + next: 'deferred', + blockers: [blocker] + }) + }) + + it('parks what needs the user and backs off what may pass on its own', () => { + expect(refused('wsl_move_export_incomplete')).toEqual({ + next: 'parked', + reason: 'export-incomplete' + }) + expect(refused('wsl_move_manifest_too_large')).toEqual({ + next: 'parked', + reason: 'manifest-too-large' + }) + expect(refused('wsl_move_cancelled')).toEqual({ next: 'parked', reason: 'not-now' }) + for (const code of [ + 'wsl_move_host_unavailable', + 'wsl_move_already_in_flight', + 'wsl_move_pause_failed', + 'wsl_move_scope_changed', + 'wsl_move_finished_earlier_move', + 'wsl_move_host_update_required' + ] as const) { + expect(refused(code)).toEqual({ next: 'transient', reason: code }) + } + expect(refused('wsl_move_nothing_new')).toEqual({ next: 'idle' }) + expect(refused('wsl_move_already_running')).toEqual({ next: 'stay' }) + expect( + classifyWslMoveOutcome({ + outcome: 'refused', + code: 'wsl_move_terminals_blocked', + blockers: [blocker] + }) + ).toEqual({ next: 'deferred', blockers: [blocker] }) + }) + + it('maps the move results', () => { + expect( + classifyWslMoveOutcome({ + outcome: 'moved', + migrationId: 'm1', + phase: 'retained', + reloadRequired: true + }) + ).toEqual({ next: 'done', migrationId: 'm1' }) + expect( + classifyWslMoveOutcome({ outcome: 'reset', migrationId: 'm1', detail: 'host_unreachable' }) + ).toEqual({ next: 'transient', reason: 'host_unreachable' }) + expect( + classifyWslMoveOutcome({ outcome: 'waiting-for-host', migrationId: 'm1', detail: 'x' }) + ).toEqual({ next: 'waiting', migrationId: 'm1' }) + }) +}) + +describe('mayStartWslMove', () => { + it('never starts a running move, even for the user', () => { + expect(mayStartWslMove(record({ kind: 'moving' }), 'user', NOW, '1.0.0')).toBe(false) + }) + + it('waits out a backoff unless the user asks', () => { + const waiting = record({ kind: 'backoff', nextAttemptAt: NOW + 1, reason: 'x' }) + expect(mayStartWslMove(waiting, 'timer', NOW, '1.0.0')).toBe(false) + expect(mayStartWslMove(waiting, 'timer', NOW + 1, '1.0.0')).toBe(true) + expect(mayStartWslMove(waiting, 'user', NOW, '1.0.0')).toBe(true) + }) + + it('keeps a parked move parked until the user, its snooze or a new build', () => { + const snoozed = record({ kind: 'parked', reason: 'not-now', until: NOW + 10 }) + expect(mayStartWslMove(snoozed, 'connect', NOW, '1.0.0')).toBe(false) + expect(mayStartWslMove(snoozed, 'connect', NOW + 10, '1.0.0')).toBe(true) + const exhausted = record({ kind: 'parked', reason: 'attempts-exhausted' }, 5) + expect(mayStartWslMove(exhausted, 'connect', NOW, '1.0.0')).toBe(false) + expect(mayStartWslMove(exhausted, 'connect', NOW, '1.1.0')).toBe(true) + const tooLarge = record({ kind: 'parked', reason: 'manifest-too-large' }) + expect(mayStartWslMove(tooLarge, 'connect', NOW, '1.1.0')).toBe(false) + expect(mayStartWslMove(tooLarge, 'user', NOW, '1.0.0')).toBe(true) + }) + + it('never starts on its own for a gone or replaced distro (R38)', () => { + expect(mayStartWslMove(record({ kind: 'distro-gone' }), 'connect', NOW, '1.0.0')).toBe(false) + expect(mayStartWslMove(record({ kind: 'distro-gone' }), 'user', NOW, '1.0.0')).toBe(false) + expect(mayStartWslMove(record({ kind: 'restore-offered' }), 'connect', NOW, '1.0.0')).toBe( + false + ) + }) +}) + +describe('applyWslMoveOutcome', () => { + it('backs off on the schedule, then parks once attempts run out', () => { + let current = record({ kind: 'pending' }) + for (let attempt = 1; attempt < WSL_MOVE_ORCHESTRATOR_MAX_ATTEMPTS; attempt++) { + current = applyWslMoveOutcome(current, { next: 'transient', reason: 'x' }, context) + expect(current.state).toEqual({ + kind: 'backoff', + nextAttemptAt: NOW + WSL_MOVE_ORCHESTRATOR_BACKOFF_MS[attempt - 1], + reason: 'x' + }) + expect(current.attempts).toBe(attempt) + } + current = applyWslMoveOutcome(current, { next: 'transient', reason: 'x' }, context) + expect(current.state).toEqual({ kind: 'parked', reason: 'attempts-exhausted' }) + }) + + it('starts a fresh count on a new build', () => { + const old = { ...record({ kind: 'pending' }, 4), appVersion: '0.9.0' } + const next = applyWslMoveOutcome(old, { next: 'transient', reason: 'x' }, context) + expect(next.attempts).toBe(1) + expect(next.appVersion).toBe('1.0.0') + }) + + it('parks a host that still needs an update after one retry', () => { + const reason = 'wsl_move_host_update_required' + const first = applyWslMoveOutcome( + record({ kind: 'pending' }), + { next: 'transient', reason }, + context + ) + expect(first.state.kind).toBe('backoff') + const second = applyWslMoveOutcome(first, { next: 'transient', reason }, context) + expect(second.state).toEqual({ kind: 'parked', reason: 'host-update-required' }) + }) + + it('snoozes a cancelled move for a week', () => { + const next = applyWslMoveOutcome( + record({ kind: 'moving' }), + { next: 'parked', reason: 'not-now' }, + context + ) + expect(next.state).toEqual({ + kind: 'parked', + reason: 'not-now', + until: NOW + WSL_MOVE_ORCHESTRATOR_NOT_NOW_MS + }) + }) + + it('keeps a finished distro done when there is nothing new to move', () => { + const done = record({ kind: 'done', migrationId: 'm1' }) + expect(applyWslMoveOutcome(done, { next: 'idle' }, context)).toBe(done) + expect( + applyWslMoveOutcome(record({ kind: 'pending' }), { next: 'idle' }, context).state + ).toEqual({ kind: 'idle' }) + }) + + it('clears the attempt count on success', () => { + const next = applyWslMoveOutcome( + record({ kind: 'moving' }, 3), + { next: 'done', migrationId: 'm1' }, + context + ) + expect(next).toMatchObject({ state: { kind: 'done', migrationId: 'm1' }, attempts: 0 }) + }) +}) + +describe('R38 verdicts and user actions', () => { + it('marks a gone or replaced distro and recovers one registered again', () => { + const done = record({ kind: 'done', migrationId: 'm1' }) + expect(applyWslMovedDistroVerdict(done, 'gone', NOW).state).toEqual({ kind: 'distro-gone' }) + expect(applyWslMovedDistroVerdict(done, 'replaced', NOW).state).toEqual({ + kind: 'restore-offered' + }) + expect(applyWslMovedDistroVerdict(done, 'unknown', NOW)).toBe(done) + expect(applyWslMovedDistroVerdict(done, 'present', NOW)).toBe(done) + expect( + applyWslMovedDistroVerdict(record({ kind: 'distro-gone' }), 'present', NOW).state + ).toEqual({ kind: 'pending' }) + }) + + it('applies Retry, Not now and Restore, and leaves a running move alone', () => { + const parked = record({ kind: 'parked', reason: 'attempts-exhausted' }, 5) + expect(applyWslMoveUserAction(parked, 'retry', NOW, '1.0.0')).toMatchObject({ + state: { kind: 'pending' }, + attempts: 0 + }) + expect(applyWslMoveUserAction(parked, 'not-now', NOW, '1.0.0').state).toEqual({ + kind: 'parked', + reason: 'not-now', + until: NOW + WSL_MOVE_ORCHESTRATOR_NOT_NOW_MS + }) + const moving = record({ kind: 'moving' }) + expect(applyWslMoveUserAction(moving, 'not-now', NOW, '1.0.0')).toBe(moving) + }) + + it('reads a move left running by an earlier process as pending', () => { + expect(settleWslMoveOrchestratorRecord(record({ kind: 'moving' }), NOW, true).state).toEqual({ + kind: 'pending' + }) + const deferred = record({ kind: 'deferred', blockers: [blocker], rechecks: 0 }) + expect(settleWslMoveOrchestratorRecord(deferred, NOW, false)).toBe(deferred) + }) +}) + +describe('review fixes (#33802)', () => { + // 1: a clean preview marks the run; a start another run already owns must not keep it marked. + it('puts a run refused as already running back to pending', () => { + const next = applyWslMoveOutcome(record({ kind: 'moving' }), { next: 'stay' }, context) + expect(next.state).toEqual({ kind: 'pending' }) + const idle = record({ kind: 'idle' }) + expect(applyWslMoveOutcome(idle, { next: 'stay' }, context)).toBe(idle) + }) + + // 2: "Use on this computer again" is a deliberate no, with no expiry. + it('parks a distro the user brought back until Retry', () => { + const declined = applyWslMoveReleasedByUser(record({ kind: 'done', migrationId: 'm1' }), NOW) + expect(declined.state).toEqual({ kind: 'parked', reason: 'declined' }) + expect(mayStartWslMove(declined, 'project-opened', NOW + 365 * 86_400_000, '9.0.0')).toBe(false) + expect(mayStartWslMove(declined, 'user', NOW, '1.0.0')).toBe(true) + }) + + // 4: deferred re-checks back off 1 m, 5 m, 30 m, then only a wake or the user re-checks. + it('spends the deferred re-check series on timers only, and a wake restarts it', () => { + let current = record({ kind: 'pending' }) + const delays: (number | null)[] = [] + current = applyWslMoveOutcome(current, { next: 'deferred', blockers: [blocker] }, context) + for (let check = 0; check < 4; check++) { + delays.push(wslMoveOrchestratorTimerDelay(current, NOW)) + current = applyWslMoveOutcome( + current, + { next: 'deferred', blockers: [blocker] }, + { ...context, trigger: 'timer' } + ) + } + expect(delays).toEqual([...WSL_MOVE_ORCHESTRATOR_DEFERRED_RECHECK_MS, null]) + const woken = applyWslMoveOutcome(current, { next: 'deferred', blockers: [blocker] }, context) + expect(wslMoveOrchestratorTimerDelay(woken, NOW)).toBe( + WSL_MOVE_ORCHESTRATOR_DEFERRED_RECHECK_MS[0] + ) + }) + + // 6: once the host-catalog check reports `present`, an offered Restore is withdrawn. + it('clears an offered Restore when the distro reads present', () => { + expect( + applyWslMovedDistroVerdict(record({ kind: 'restore-offered' }), 'present', NOW).state + ).toEqual({ kind: 'pending' }) + }) + + // 7: failing to ask the window says nothing about the move. + it('retries a window that could not be asked without spending an attempt', () => { + const next = applyWslMoveOutcome( + record({ kind: 'pending' }, 2), + { next: 'transient', reason: 'wsl_move_window_unavailable' }, + context + ) + expect(next.attempts).toBe(2) + expect(next.state).toMatchObject({ kind: 'backoff', reason: 'wsl_move_window_unavailable' }) + }) + + // 8: the 6 h step runs; the attempt after it parks. + it('waits 6 h after the fifth failure and parks on the sixth', () => { + const fifth = applyWslMoveOutcome( + record({ kind: 'backoff', nextAttemptAt: 0, reason: 'x' }, 4), + { next: 'transient', reason: 'x' }, + context + ) + expect(fifth.state).toEqual({ + kind: 'backoff', + nextAttemptAt: NOW + 6 * 60 * 60_000, + reason: 'x' + }) + const sixth = applyWslMoveOutcome(fifth, { next: 'transient', reason: 'x' }, context) + expect(sixth.state).toEqual({ kind: 'parked', reason: 'attempts-exhausted' }) + }) + + // 8: a host that needed an update gets one retry per new build, then waits again. + it('retries a host that needed an update once on a new build', () => { + const parked = record({ kind: 'parked', reason: 'host-update-required' }, 2) + expect(mayStartWslMove(parked, 'connect', NOW, '1.0.0')).toBe(false) + expect(mayStartWslMove(parked, 'connect', NOW, '1.1.0')).toBe(true) + const again = applyWslMoveOutcome( + parked, + { next: 'transient', reason: 'wsl_move_host_update_required' }, + { ...context, appVersion: '1.1.0' } + ) + expect(again.state).toEqual({ kind: 'parked', reason: 'host-update-required' }) + expect(mayStartWslMove(again, 'connect', NOW, '1.1.0')).toBe(false) + }) + + // 9: a Restore whose release finished before the app quit has nothing left to offer. + it('reads an offered Restore with no finished move left as pending', () => { + const offered = record({ kind: 'restore-offered' }) + expect(settleWslMoveOrchestratorRecord(offered, NOW, false).state).toEqual({ kind: 'pending' }) + expect(settleWslMoveOrchestratorRecord(offered, NOW, true)).toBe(offered) + }) + + it('treats records that differ only in their write time as the same', () => { + const a = record({ kind: 'deferred', blockers: [blocker], rechecks: 1 }) + expect(sameWslMoveOrchestratorRecord(a, { ...a, updatedAt: a.updatedAt + 1 })).toBe(true) + expect(sameWslMoveOrchestratorRecord(a, { ...a, attempts: 1 })).toBe(false) + }) + + it('parks a host whose Remove is pending for every automatic trigger and new build', () => { + const parked = applyWslMoveHostRemovalPending(record({ kind: 'done', migrationId: 'm1' }), NOW) + expect(parked.state).toEqual({ kind: 'parked', reason: 'removal-pending' }) + for (const trigger of ['connect', 'project-opened', 'startup', 'timer'] as const) { + expect(mayStartWslMove(parked, trigger, NOW + 365 * 86_400_000, '9.0.0')).toBe(false) + } + }) + + it('takes back only a decline when the user adds the host', () => { + const declined = record({ kind: 'parked', reason: 'declined' }) + expect(applyWslMoveHostAddedByUser(declined, NOW).state).toEqual({ kind: 'pending' }) + const snoozed = record({ kind: 'parked', reason: 'not-now', until: NOW }) + expect(applyWslMoveHostAddedByUser(snoozed, NOW)).toBe(snoozed) + }) + + // Found by the orchestrator-over-driver test: after a move, until retention marks it retained, + // the driver refuses new moves as already in flight. That is waiting, not a failed attempt. + it('keeps a finished distro done while its move is still being retained, and re-checks', () => { + const done = record({ kind: 'done', migrationId: 'm1' }) + const reason = 'wsl_move_already_in_flight' + const waiting = applyWslMoveOutcome(done, { next: 'transient', reason }, context) + expect(waiting.state).toEqual({ + kind: 'done', + migrationId: 'm1', + nextCheckAt: NOW + WSL_MOVE_ORCHESTRATOR_UNCOUNTED_RETRY_MS, + waits: 1 + }) + expect(wslMoveOrchestratorTimerDelay(waiting, NOW)).toBe( + WSL_MOVE_ORCHESTRATOR_UNCOUNTED_RETRY_MS + ) + // Retention done: nothing new to move ends the wait. + expect(applyWslMoveOutcome(waiting, { next: 'idle' }, context).state).toEqual({ + kind: 'done', + migrationId: 'm1' + }) + const pending = applyWslMoveOutcome( + record({ kind: 'pending' }, 2), + { next: 'transient', reason }, + context + ) + expect(pending).toMatchObject({ attempts: 2, state: { kind: 'backoff', reason } }) + }) + + it('keeps a finished distro done when the window cannot be asked', () => { + const done = record({ kind: 'done', migrationId: 'm1' }) + const next = applyWslMoveOutcome( + done, + { next: 'transient', reason: 'wsl_move_window_unavailable' }, + context + ) + expect(next).toMatchObject({ attempts: 0, state: { kind: 'done', migrationId: 'm1' } }) + }) + + // An in-flight refusal for an interrupted move whose host could not be asked is a real failure. + it('counts an in-flight refusal that carries the reset detail', () => { + const outcome = classifyWslMoveOutcome({ + outcome: 'refused', + code: 'wsl_move_already_in_flight', + detail: 'destination_unreachable' + }) + expect(outcome).toEqual({ + next: 'transient', + reason: 'wsl_move_already_in_flight_unverifiable' + }) + expect(applyWslMoveOutcome(record({ kind: 'pending' }), outcome, context).attempts).toBe(1) + }) + + it('parks as stuck after a day of uncounted checks, for a backoff and for a done distro', () => { + const reason = 'wsl_move_already_in_flight' + const last = WSL_MOVE_ORCHESTRATOR_UNCOUNTED_WAIT_LIMIT - 1 + const waitingBackoff = record({ kind: 'backoff', nextAttemptAt: NOW, reason, waits: last - 1 }) + expect( + applyWslMoveOutcome(waitingBackoff, { next: 'transient', reason }, context).state + ).toMatchObject({ kind: 'backoff', waits: last }) + const atCap = record({ kind: 'backoff', nextAttemptAt: NOW, reason, waits: last }) + expect(applyWslMoveOutcome(atCap, { next: 'transient', reason }, context).state).toEqual({ + kind: 'parked', + reason: 'stuck' + }) + const waitingDone = record({ kind: 'done', migrationId: 'm1', nextCheckAt: NOW, waits: last }) + const stuck = applyWslMoveOutcome(waitingDone, { next: 'transient', reason }, context) + expect(stuck.state).toEqual({ kind: 'parked', reason: 'stuck' }) + expect(mayStartWslMove(stuck, 'connect', NOW, '1.0.0')).toBe(false) + expect(mayStartWslMove(stuck, 'user', NOW, '1.0.0')).toBe(true) + }) + + // Review: a weekend with the app closed must not park a distro on its first check after launch. + it('does not park on the first check after days with the app closed', () => { + const reason = 'wsl_move_already_in_flight' + const waiting = record({ kind: 'done', migrationId: 'm1', nextCheckAt: NOW, waits: 3 }) + const afterWeekend = { ...context, now: NOW + 3 * 24 * 60 * 60_000 } + const next = applyWslMoveOutcome(waiting, { next: 'transient', reason }, afterWeekend) + expect(next.state).toMatchObject({ kind: 'done', waits: 4 }) + }) + + it('counts an in-flight refusal whose detail is an empty string', () => { + expect( + classifyWslMoveOutcome({ outcome: 'refused', code: 'wsl_move_already_in_flight', detail: '' }) + ).toEqual({ next: 'transient', reason: 'wsl_move_already_in_flight_unverifiable' }) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-transitions.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-transitions.ts new file mode 100644 index 00000000000..30f68cfacc0 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-transitions.ts @@ -0,0 +1,348 @@ +/** + * The orchestrator's pure state machine: which trigger may start a distro's move, and what each + * preview, move result, distro check (R38) or user action leaves the distro in. + */ +import type { + WslMovePreview, + WslMoveRefusalCode, + WslMoveResult +} from '../../../shared/wsl-move/wsl-move-driver-types' +import type { + WslMoveOrchestratorParkReason, + WslMoveOrchestratorRecord, + WslMoveOrchestratorState, + WslMoveOrchestratorTrigger +} from '../../../shared/wsl-move/wsl-move-orchestrator-types' + +/** Delay after failed automatic attempt n; the attempt after the last delay parks if it fails. */ +export const WSL_MOVE_ORCHESTRATOR_BACKOFF_MS = [ + 60_000, + 5 * 60_000, + 30 * 60_000, + 2 * 60 * 60_000, + 6 * 60 * 60_000 +] as const +export const WSL_MOVE_ORCHESTRATOR_MAX_ATTEMPTS = WSL_MOVE_ORCHESTRATOR_BACKOFF_MS.length + 1 +export const WSL_MOVE_ORCHESTRATOR_NOT_NOW_MS = 7 * 24 * 60 * 60_000 +/** Timer re-checks of a deferred move; after the last, it waits for a wake or the user. */ +export const WSL_MOVE_ORCHESTRATOR_DEFERRED_RECHECK_MS = [60_000, 5 * 60_000, 30 * 60_000] as const +/** Waiting that is not a failed attempt, retried without costing one. */ +export const WSL_MOVE_ORCHESTRATOR_UNCOUNTED_RETRY_MS = 5 * 60_000 +/** + * Uncounted checks before the distro parks as stuck: a day of the app running at 5-minute + * spacing. Counted, not timed, so days with the app closed never park a distro on launch. + */ +export const WSL_MOVE_ORCHESTRATOR_UNCOUNTED_WAIT_LIMIT = + (24 * 60 * 60_000) / WSL_MOVE_ORCHESTRATOR_UNCOUNTED_RETRY_MS +/** An in-flight refusal whose earlier move could not be checked is a failed attempt after all. */ +const IN_FLIGHT_UNVERIFIABLE = 'wsl_move_already_in_flight_unverifiable' + +/** + * Refusals that say nothing about this move: the window could not be asked for its panes, or an + * earlier move of the distro is still finishing (desktop rewrite, retention, recovery). + */ +const UNCOUNTED_FAILURES = new Set(['wsl_move_window_unavailable', 'wsl_move_already_in_flight']) + +type Blockers = Extract['blockers'] + +/** What one preview or move result asks of the orchestrator. */ +export type WslMoveOutcome = + | { next: 'start' } + | { next: 'idle' } + /** This process already runs the move; a run marked here goes back to waiting. */ + | { next: 'stay' } + | { next: 'deferred'; blockers: Blockers } + | { next: 'transient'; reason: string } + | { next: 'parked'; reason: WslMoveOrchestratorParkReason } + | { next: 'waiting'; migrationId: string } + | { next: 'done'; migrationId: string } + +function refusalOutcome( + code: WslMoveRefusalCode, + preview: WslMovePreview | WslMoveResult +): WslMoveOutcome { + switch (code) { + case 'wsl_move_nothing_new': + case 'wsl_move_unavailable': + return { next: 'idle' } + case 'wsl_move_already_running': + return { next: 'stay' } + case 'wsl_move_terminals_blocked': + return { next: 'deferred', blockers: 'blockers' in preview ? (preview.blockers ?? []) : [] } + case 'wsl_move_export_incomplete': + return { next: 'parked', reason: 'export-incomplete' } + case 'wsl_move_manifest_too_large': + return { next: 'parked', reason: 'manifest-too-large' } + case 'wsl_move_cancelled': + return { next: 'parked', reason: 'not-now' } + case 'wsl_move_already_in_flight': + // With a `detail`, the earlier move was interrupted and its host could not be asked. + return { + next: 'transient', + reason: 'detail' in preview && preview.detail !== undefined ? IN_FLIGHT_UNVERIFIABLE : code + } + case 'wsl_move_host_update_required': + case 'wsl_move_host_unavailable': + case 'wsl_move_pause_failed': + case 'wsl_move_scope_changed': + case 'wsl_move_finished_earlier_move': + return { next: 'transient', reason: code } + } +} + +export function classifyWslMoveOutcome(result: WslMovePreview | WslMoveResult): WslMoveOutcome { + switch (result.outcome) { + case 'ready': + return result.blockers.length > 0 + ? { next: 'deferred', blockers: result.blockers } + : { next: 'start' } + case 'refused': + return refusalOutcome(result.code, result) + case 'moved': + return { next: 'done', migrationId: result.migrationId } + case 'reset': + return { next: 'transient', reason: result.detail } + case 'waiting-for-host': + return { next: 'waiting', migrationId: result.migrationId } + } +} + +export function newWslMoveOrchestratorRecord( + distro: string, + appVersion: string, + now: number +): WslMoveOrchestratorRecord { + return { distro, state: { kind: 'pending' }, attempts: 0, appVersion, updatedAt: now } +} + +function parkedMayStart( + state: Extract, + newBuild: boolean, + now: number +): boolean { + switch (state.reason) { + case 'not-now': + return state.until !== undefined && now >= state.until + // A new build may carry the fix for whatever kept failing, or the host's update. + case 'attempts-exhausted': + case 'host-update-required': + case 'stuck': + return newBuild + case 'export-incomplete': + case 'manifest-too-large': + case 'declined': + case 'removal-pending': + return false + } +} + +/** Whether a trigger may preview the distro's move now. Never true while one runs. */ +export function mayStartWslMove( + record: WslMoveOrchestratorRecord, + trigger: WslMoveOrchestratorTrigger, + now: number, + appVersion: string +): boolean { + const { state } = record + if (state.kind === 'moving') { + return false + } + if (trigger === 'user') { + return state.kind !== 'distro-gone' + } + switch (state.kind) { + case 'idle': + case 'pending': + case 'deferred': + case 'waiting-for-host': + case 'done': + return true + case 'backoff': + return now >= state.nextAttemptAt + case 'parked': + return parkedMayStart(state, record.appVersion !== appVersion, now) + case 'distro-gone': + case 'restore-offered': + return false + } +} + +/** Ms until the timer re-checks this state; null when only a trigger may. */ +export function wslMoveOrchestratorTimerDelay( + record: WslMoveOrchestratorRecord, + now: number +): number | null { + const { state } = record + if (state.kind === 'backoff') { + return Math.max(0, state.nextAttemptAt - now) + } + if (state.kind === 'done' && state.nextCheckAt !== undefined) { + return Math.max(0, state.nextCheckAt - now) + } + return state.kind === 'deferred' + ? (WSL_MOVE_ORCHESTRATOR_DEFERRED_RECHECK_MS[state.rechecks] ?? null) + : null +} + +/** The record in a new state, stamped now; shared with the user- and host-event transitions. */ +export function withWslMoveOrchestratorState( + record: WslMoveOrchestratorRecord, + state: WslMoveOrchestratorState, + now: number, + changes: Partial> = {} +): WslMoveOrchestratorRecord { + return { ...record, ...changes, state, updatedAt: now } +} + +/** A done distro whose wait (if any) is over; unchanged when it was not waiting. */ +function settledDone(record: WslMoveOrchestratorRecord, now: number): WslMoveOrchestratorRecord { + const { state } = record + if (state.kind !== 'done' || (state.nextCheckAt === undefined && state.waits === undefined)) { + return record + } + return withWslMoveOrchestratorState(record, { kind: 'done', migrationId: state.migrationId }, now) +} + +/** Waiting costs no attempt, but it is re-checked and capped, so a stuck move cannot hide. */ +function uncountedWait( + record: WslMoveOrchestratorRecord, + reason: string, + now: number +): WslMoveOrchestratorRecord { + const { state } = record + const waits = + ((state.kind === 'backoff' || state.kind === 'done') && state.waits !== undefined + ? state.waits + : 0) + 1 + if (waits >= WSL_MOVE_ORCHESTRATOR_UNCOUNTED_WAIT_LIMIT) { + return withWslMoveOrchestratorState(record, { kind: 'parked', reason: 'stuck' }, now) + } + const nextAt = now + WSL_MOVE_ORCHESTRATOR_UNCOUNTED_RETRY_MS + // A finished distro stays done; new projects behind its settling move are re-checked. + return withWslMoveOrchestratorState( + record, + state.kind === 'done' + ? { kind: 'done', migrationId: state.migrationId, nextCheckAt: nextAt, waits } + : { kind: 'backoff', nextAttemptAt: nextAt, reason, waits }, + now + ) +} + +function failedAttempt( + record: WslMoveOrchestratorRecord, + reason: string, + now: number, + appVersion: string +): WslMoveOrchestratorRecord { + if (UNCOUNTED_FAILURES.has(reason)) { + return uncountedWait(record, reason, now) + } + // A new build starts a fresh count. + const attempts = (record.appVersion === appVersion ? record.attempts : 0) + 1 + const counted = { attempts, appVersion } + // One retry, then the host waits for a newer build: from a backoff, or after a new version. + const updateStillRequired = + reason === 'wsl_move_host_update_required' && + ((record.state.kind === 'backoff' && record.state.reason === reason) || + (record.state.kind === 'parked' && record.state.reason === 'host-update-required')) + if (updateStillRequired) { + return withWslMoveOrchestratorState( + record, + { kind: 'parked', reason: 'host-update-required' }, + now, + counted + ) + } + if (attempts >= WSL_MOVE_ORCHESTRATOR_MAX_ATTEMPTS) { + return withWslMoveOrchestratorState( + record, + { kind: 'parked', reason: 'attempts-exhausted' }, + now, + counted + ) + } + const delay = WSL_MOVE_ORCHESTRATOR_BACKOFF_MS[attempts - 1] + return withWslMoveOrchestratorState( + record, + { kind: 'backoff', nextAttemptAt: now + delay, reason }, + now, + counted + ) +} + +/** + * The record after a preview or move result. `start` marks the move running; the caller runs it + * and applies its result next. + */ +export function applyWslMoveOutcome( + record: WslMoveOrchestratorRecord, + outcome: WslMoveOutcome, + context: { now: number; appVersion: string; trigger: WslMoveOrchestratorTrigger } +): WslMoveOrchestratorRecord { + const { now, appVersion } = context + switch (outcome.next) { + case 'start': + return withWslMoveOrchestratorState(record, { kind: 'moving' }, now) + case 'stay': + // Another run of this process owns the move; this one must not stay marked running. + return record.state.kind === 'moving' + ? withWslMoveOrchestratorState(record, { kind: 'pending' }, now) + : record + case 'idle': + // A distro moved earlier stays done: nothing new to move is its normal answer. + return record.state.kind === 'done' + ? settledDone(record, now) + : withWslMoveOrchestratorState(record, { kind: 'idle' }, now) + case 'deferred': { + // Only timer re-checks spend the series; a wake or the user starts it again. + const rechecks = + context.trigger === 'timer' && record.state.kind === 'deferred' + ? record.state.rechecks + 1 + : 0 + return withWslMoveOrchestratorState( + record, + { kind: 'deferred', blockers: outcome.blockers, rechecks }, + now + ) + } + case 'transient': + return failedAttempt(record, outcome.reason, now, appVersion) + case 'parked': + return withWslMoveOrchestratorState( + record, + outcome.reason === 'not-now' + ? { kind: 'parked', reason: 'not-now', until: now + WSL_MOVE_ORCHESTRATOR_NOT_NOW_MS } + : { kind: 'parked', reason: outcome.reason }, + now + ) + case 'waiting': + return withWslMoveOrchestratorState( + record, + { kind: 'waiting-for-host', migrationId: outcome.migrationId }, + now + ) + case 'done': + return withWslMoveOrchestratorState( + record, + { kind: 'done', migrationId: outcome.migrationId }, + now, + { + attempts: 0, + appVersion + } + ) + } +} + +/** Records that differ only in when they were written are the same decision. */ +export function sameWslMoveOrchestratorRecord( + a: WslMoveOrchestratorRecord | undefined, + b: WslMoveOrchestratorRecord +): boolean { + return ( + a !== undefined && + a.attempts === b.attempts && + a.appVersion === b.appVersion && + JSON.stringify(a.state) === JSON.stringify(b.state) + ) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-user-choices.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-user-choices.test.ts new file mode 100644 index 00000000000..7f3db0abc2e --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-user-choices.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WslMoveUseLocallyResult } from '../../../shared/wsl-move/wsl-moved-project' +import type { WslMovePreview, WslMoveResult } from '../../../shared/wsl-move/wsl-move-driver-types' +import { wslMoveJournalKey } from '../../../shared/wsl-move/wsl-move-cutover' +import type { WslMoveOrchestratorRecord } from '../../../shared/wsl-move/wsl-move-orchestrator-types' +import { WslMoveOrchestrator } from './wsl-move-orchestrator' +import type { WslMoveOrchestratorPorts } from './wsl-move-orchestrator-ports' +import { blocker, fakePorts, moved, ready, stateOf } from './wsl-move-orchestrator.test-support' + +// The orchestrator under the user's own choices and the reviews' race and failure cases. +describe('WslMoveOrchestrator: user choices, races and failures', () => { + describe('a host whose Remove is pending', () => { + for (const trigger of ['connect', 'project-opened', 'startup', 'timer', 'user'] as const) { + it(`never readies, previews or moves it on ${trigger}`, async () => { + const ports = fakePorts({ hostRemovalPending: vi.fn(() => true) }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', trigger) + expect(ports.prepareHost).not.toHaveBeenCalled() + expect(ports.preview).not.toHaveBeenCalled() + expect(ports.start).not.toHaveBeenCalled() + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ + kind: 'parked', + reason: 'removal-pending' + }) + }) + } + + it('stays parked through Retry while the Remove is pending', async () => { + const ports = fakePorts({ hostRemovalPending: vi.fn(() => true) }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + await orchestrator.userAction('Ubuntu', 'retry') + await orchestrator.whenIdle() + expect(ports.start).not.toHaveBeenCalled() + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'parked', reason: 'removal-pending' }) + }) + + // Review 3 #2: the park clears once the Remove settles, by whether the host was kept. + it('resumes once the Remove settles with the host kept (the user pressed Connect)', async () => { + let pending = true + const ports = fakePorts({ hostRemovalPending: vi.fn(() => pending) }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + pending = false + await orchestrator.trigger('Ubuntu', 'connect') + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + + it('reads a Remove that settled with the host gone as declined', async () => { + let pending = true + const ports = fakePorts({ + hostRemovalPending: vi.fn(() => pending), + hasHost: vi.fn(() => !pending && false) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + pending = false + await orchestrator.trigger('Ubuntu', 'project-opened') + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'parked', reason: 'declined' }) + expect(ports.prepareHost).not.toHaveBeenCalled() + }) + }) + + describe('re-review fixes (#33802)', () => { + const offered = (): Map => + new Map([ + [ + wslMoveJournalKey('Ubuntu'), + { + distro: 'Ubuntu', + state: { kind: 'restore-offered' }, + attempts: 0, + appVersion: '1.0.0', + updatedAt: 0 + } + ] + ]) + const unreadable = (): never => { + throw new Error('wsl_move_journal_unreadable') + } + + // 1: an unreadable journal at startup must not escape the constructor. + it('starts with an unreadable journal and keeps an offered Restore', () => { + const orchestrator = new WslMoveOrchestrator( + fakePorts({ load: offered, finishedMove: unreadable }) + ) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'restore-offered' }) + }) + + it('touches nothing while the journal is unreadable', async () => { + const ports = fakePorts({ finishedMove: unreadable }) + const orchestrator = new WslMoveOrchestrator(ports) + await expect(orchestrator.trigger('Ubuntu', 'connect')).resolves.toBeUndefined() + expect(ports.prepareHost).not.toHaveBeenCalled() + expect(ports.preview).not.toHaveBeenCalled() + }) + + // 2: a release by the user during a step must win over the step's stale view. + it('does not move a distro the user released while its preview was running', async () => { + let release: () => void = () => {} + const ports = fakePorts({ + preview: vi.fn( + () => + new Promise((resolve) => { + release = () => resolve(ready('Ubuntu')) + }) + ) + }) + const orchestrator = new WslMoveOrchestrator(ports) + const step = orchestrator.trigger('Ubuntu', 'connect') + await vi.waitFor(() => expect(ports.preview).toHaveBeenCalled()) + orchestrator.releasedByUser('Ubuntu') + release() + await step + expect(ports.start).not.toHaveBeenCalled() + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'parked', reason: 'declined' }) + }) + + it('keeps a release made while the host was being readied', async () => { + let readied: () => void = () => {} + const ports = fakePorts({ + prepareHost: vi.fn( + () => + new Promise<'ready'>((resolve) => { + readied = () => resolve('ready') + }) + ) + }) + const orchestrator = new WslMoveOrchestrator(ports) + const step = orchestrator.trigger('Ubuntu', 'connect') + await vi.waitFor(() => expect(ports.prepareHost).toHaveBeenCalled()) + orchestrator.releasedByUser('Ubuntu') + readied() + await step + expect(ports.preview).not.toHaveBeenCalled() + }) + + // 3: a wake queued during Restore's own busy section runs after it. + it('replays a wake that arrived during Restore', async () => { + let finishRelease: () => void = () => {} + let finished: { migrationId: string } | null = { migrationId: 'm1' } + const ports = fakePorts({ + load: offered, + finishedMove: () => finished, + checkMovedDistro: vi.fn(async () => 'present' as const), + releaseFinishedMove: vi.fn( + () => + new Promise((resolve) => { + finishRelease = () => { + finished = null + resolve({ + released: true, + distro: 'Ubuntu', + repoIds: ['r1'], + folderWorkspaceIds: [] + }) + } + }) + ) + }) + const orchestrator = new WslMoveOrchestrator(ports) + const restore = orchestrator.userAction('Ubuntu', 'restore') + await vi.waitFor(() => expect(ports.releaseFinishedMove).toHaveBeenCalled()) + await orchestrator.trigger('Ubuntu', 'project-opened') + expect(ports.preview).not.toHaveBeenCalled() + finishRelease() + await restore + await orchestrator.whenIdle() + expect(ports.preview).toHaveBeenCalledTimes(1) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + + it('touches nothing when the host record cannot be read', async () => { + const ports = fakePorts({ hostRemovalPending: unreadable }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + expect(ports.prepareHost).not.toHaveBeenCalled() + expect(orchestrator.list()).toEqual([]) + }) + }) + + // A deliberate Remove of the host is the user declining it: nothing re-adds it on its own. + describe('a host the user removed', () => { + it('is never readied again by any automatic trigger, only by the user', async () => { + const ports = fakePorts() + const orchestrator = new WslMoveOrchestrator(ports) + orchestrator.releasedByUser('Ubuntu') + ports.clock.now += 365 * 86_400_000 + for (const trigger of ['connect', 'project-opened', 'startup', 'timer'] as const) { + await orchestrator.trigger('Ubuntu', trigger) + } + expect(ports.prepareHost).not.toHaveBeenCalled() + expect(ports.preview).not.toHaveBeenCalled() + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'parked', reason: 'declined' }) + }) + + it('resumes once the user adds the host again', async () => { + const ports = fakePorts() + const orchestrator = new WslMoveOrchestrator(ports) + orchestrator.releasedByUser('Ubuntu') + orchestrator.hostAddedByUser('Ubuntu') + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'pending' }) + await orchestrator.trigger('Ubuntu', 'connect') + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + + it('leaves other parks alone when the user adds a host', () => { + const ports = fakePorts() + const orchestrator = new WslMoveOrchestrator(ports) + void orchestrator.userAction('Ubuntu', 'not-now') + orchestrator.hostAddedByUser('Ubuntu') + expect(stateOf(orchestrator, 'Ubuntu')).toMatchObject({ kind: 'parked', reason: 'not-now' }) + }) + }) + + describe('review 3 fixes (#33802)', () => { + // #3: a release while the start waits on the window's panes must stop the start. + it('tells the start the distro is no longer wanted once the user released it', async () => { + const seen: { wanted?: () => boolean } = {} + let answer: () => void = () => {} + const ports = fakePorts({ + start: vi.fn( + (_distro: string, wanted: () => boolean) => + new Promise((resolve) => { + seen.wanted = wanted + answer = () => + resolve(wanted() ? moved : { outcome: 'refused', code: 'wsl_move_cancelled' }) + }) + ) + }) + const orchestrator = new WslMoveOrchestrator(ports) + const step = orchestrator.trigger('Ubuntu', 'connect') + await vi.waitFor(() => expect(seen.wanted).toBeDefined()) + expect(seen.wanted?.()).toBe(true) + orchestrator.releasedByUser('Ubuntu') + expect(seen.wanted?.()).toBe(false) + answer() + await step + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'parked', reason: 'declined' }) + }) + + // #4: the reply to Retry never waits for the move it starts. + it('answers Retry before the move finishes', async () => { + let finish: () => void = () => {} + const ports = fakePorts({ + start: vi.fn( + () => + new Promise((resolve) => { + finish = () => resolve(moved) + }) + ) + }) + const orchestrator = new WslMoveOrchestrator(ports) + const { record } = await orchestrator.userAction('Ubuntu', 'retry') + expect(record.state).toEqual({ kind: 'pending' }) + await vi.waitFor(() => expect(ports.start).toHaveBeenCalled()) + finish() + await orchestrator.whenIdle() + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + + // #4: a step that throws still runs the wake queued behind it. + it('replays a queued wake after a step that throws', async () => { + let answer: () => void = () => {} + const preview = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + answer = () => resolve(ready('Ubuntu', [blocker])) + }) + ) + .mockImplementation(async (distro) => ready(distro)) + const ports = fakePorts({ preview }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const orchestrator = new WslMoveOrchestrator(ports) + const step = orchestrator.trigger('Ubuntu', 'connect') + await vi.waitFor(() => expect(preview).toHaveBeenCalled()) + await orchestrator.trigger('Ubuntu', 'project-opened') + // Publishing the first step's state fails, so the step throws out of its commit. + vi.mocked(ports.publish).mockImplementationOnce(() => { + throw new Error('window gone') + }) + answer() + await expect(step).resolves.toBeUndefined() + await orchestrator.whenIdle() + expect(preview).toHaveBeenCalledTimes(2) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + + // Reviewer nit: a failed sidecar save must neither reject a fire-and-forget trigger nor lose + // the decision in memory. + it('keeps going in memory when the sidecar cannot be saved', async () => { + const ports = fakePorts() + vi.spyOn(ports, 'save').mockImplementation(() => { + throw new Error('disk full') + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const orchestrator = new WslMoveOrchestrator(ports) + await expect(orchestrator.trigger('Ubuntu', 'connect')).resolves.toBeUndefined() + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + }) + + // Re-review: the step hands prepareHost a check that turns false once the user declines. + it('tells prepareHost the distro is no longer wanted after a Remove mid-read', async () => { + const seen: { wanted?: () => boolean } = {} + let readied: () => void = () => {} + const ports = fakePorts({ + prepareHost: vi.fn( + (_distro: string, wanted: () => boolean) => + new Promise<'ready' | 'failed'>((resolve) => { + seen.wanted = wanted + readied = () => resolve(wanted() ? 'ready' : 'failed') + }) + ) + }) + const orchestrator = new WslMoveOrchestrator(ports) + const step = orchestrator.trigger('Ubuntu', 'project-opened') + await vi.waitFor(() => expect(seen.wanted).toBeDefined()) + expect(seen.wanted?.()).toBe(true) + orchestrator.releasedByUser('Ubuntu') + expect(seen.wanted?.()).toBe(false) + readied() + await step + expect(ports.preview).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test-support.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test-support.ts new file mode 100644 index 00000000000..e53f1d5a225 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test-support.ts @@ -0,0 +1,107 @@ +/** Fake ports and fixtures for the move orchestrator's tests. */ +import { vi } from 'vitest' +import type { + WslMoveBlocker, + WslMovePreview, + WslMoveResult +} from '../../../shared/wsl-move/wsl-move-driver-types' +import type { WslMoveOrchestratorRecord } from '../../../shared/wsl-move/wsl-move-orchestrator-types' +import type { WslMoveOrchestrator } from './wsl-move-orchestrator' +import type { WslMoveOrchestratorPorts } from './wsl-move-orchestrator-ports' + +export const blocker: WslMoveBlocker = { + kind: 'agent-working', + ids: ['tab:leaf'], + actions: ['wait'] +} + +export function ready(distro: string, blockers: WslMoveBlocker[] = []): WslMovePreview { + return { + outcome: 'ready', + distro, + repoIds: ['r1'], + folderWorkspaceIds: [], + changed: [], + heldByHost: [], + refused: [], + blockers + } +} + +export const moved: WslMoveResult = { + outcome: 'moved', + migrationId: 'm1', + phase: 'retained', + reloadRequired: true +} + +export type Fake = WslMoveOrchestratorPorts & { + clock: { now: number } + timers: { at: number; run: () => void; cancelled: boolean }[] + saved: Map + advance: (ms: number) => Promise +} + +export function fakePorts(overrides: Partial = {}): Fake { + const clock = { now: 1_000 } + const timers: Fake['timers'] = [] + const saved = new Map() + const ports: Fake = { + clock, + timers, + saved, + now: () => clock.now, + appVersion: '1.0.0', + autoMoveEnabled: () => true, + prepareHost: vi.fn(async () => 'ready' as const), + isHostReady: vi.fn(() => true), + hostRemovalPending: vi.fn(() => false), + hasHost: vi.fn(() => true), + preview: vi.fn(async (distro: string) => ready(distro)), + start: vi.fn(async () => moved), + finishedMove: () => null, + checkMovedDistro: vi.fn(async () => 'present' as const), + releaseFinishedMove: vi.fn(async () => ({ + released: true as const, + distro: 'Ubuntu', + repoIds: ['r1'], + folderWorkspaceIds: [] + })), + load: () => new Map(), + save: (records) => { + saved.clear() + for (const [key, value] of records) { + saved.set(key, value) + } + }, + schedule: (delayMs, run) => { + const timer = { at: clock.now + delayMs, run, cancelled: false } + timers.push(timer) + return () => { + timer.cancelled = true + } + }, + track: vi.fn(), + publish: vi.fn(), + advance: async (ms) => { + clock.now += ms + for (const timer of timers.splice(0)) { + if (!timer.cancelled && timer.at <= clock.now) { + timer.run() + } else if (!timer.cancelled) { + timers.push(timer) + } + } + // Let the triggered runs settle their awaited ports. + for (let turn = 0; turn < 10; turn++) { + await new Promise((resolve) => setImmediate(resolve)) + } + }, + ...overrides + } + return ports +} + +export function stateOf(orchestrator: WslMoveOrchestrator, distro: string) { + return orchestrator.list().find((record) => record.distro === distro)?.state +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test.ts new file mode 100644 index 00000000000..e221aa8f047 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test.ts @@ -0,0 +1,508 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WslMovePreview } from '../../../shared/wsl-move/wsl-move-driver-types' +import { wslMoveJournalKey } from '../../../shared/wsl-move/wsl-move-cutover' +import type { WslMoveOrchestratorRecord } from '../../../shared/wsl-move/wsl-move-orchestrator-types' +import { + WslMoveOrchestrator, + WSL_MOVE_ORCHESTRATOR_PROJECT_OPENED_THROTTLE_MS +} from './wsl-move-orchestrator' +import type { WslMoveOrchestratorPorts } from './wsl-move-orchestrator-ports' +import { + WSL_MOVE_ORCHESTRATOR_BACKOFF_MS, + WSL_MOVE_ORCHESTRATOR_DEFERRED_RECHECK_MS +} from './wsl-move-orchestrator-transitions' +import { blocker, fakePorts, moved, ready, stateOf } from './wsl-move-orchestrator.test-support' + +describe('WslMoveOrchestrator', () => { + it('moves two distros independently: one finishes, the busy one waits (fake transport)', async () => { + const ports = fakePorts({ + preview: vi.fn(async (distro: string) => + distro === 'Debian' ? ready(distro, [blocker]) : ready(distro) + ) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await Promise.all([ + orchestrator.trigger('Ubuntu', 'connect'), + orchestrator.trigger('Debian', 'connect') + ]) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + expect(stateOf(orchestrator, 'Debian')).toEqual({ + kind: 'deferred', + blockers: [blocker], + rechecks: 0 + }) + expect(ports.start).toHaveBeenCalledTimes(1) + expect(ports.start).toHaveBeenCalledWith('Ubuntu', expect.any(Function)) + expect(ports.saved.get(wslMoveJournalKey('Debian'))?.state.kind).toBe('deferred') + }) + + it('never wakes a distro on its own (A1): not running, no preview', async () => { + const ports = fakePorts({ prepareHost: vi.fn(async () => 'not-running' as const) }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + await orchestrator.trigger('Ubuntu', 'project-opened') + expect(ports.preview).not.toHaveBeenCalled() + // An explicit user action is the user's choice to wake it. + await orchestrator.trigger('Ubuntu', 'user') + expect(ports.prepareHost).toHaveBeenCalledTimes(2) + expect(ports.preview).toHaveBeenCalledTimes(1) + }) + + describe('T3 at startup', () => { + // Loss of contact is no evidence of state: an unreadable distro is never resumed. + for (const readiness of ['not-running', 'unverifiable', 'failed'] as const) { + it(`never resumes a distro that is ${readiness}`, async () => { + const ports = fakePorts({ prepareHost: vi.fn(async () => readiness) }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'startup') + expect(ports.preview).not.toHaveBeenCalled() + expect(ports.start).not.toHaveBeenCalled() + expect(stateOf(orchestrator, 'Ubuntu')).toBeUndefined() + }) + } + + it('never resumes when the running-distro read throws', async () => { + const ports = fakePorts({ + prepareHost: vi.fn(async () => { + throw new Error('wsl.exe timed out') + }) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'startup') + expect(ports.preview).not.toHaveBeenCalled() + }) + + it('keeps a finished move unchanged and unresumed when the registry read fails', async () => { + const loaded = new Map([ + [ + wslMoveJournalKey('Ubuntu'), + { + distro: 'Ubuntu', + state: { kind: 'done', migrationId: 'm1' }, + attempts: 0, + appVersion: '1.0.0', + updatedAt: 0 + } + ] + ]) + const ports = fakePorts({ + load: () => loaded, + finishedMove: () => ({ migrationId: 'm1', distroGuid: 'guid' }), + checkMovedDistro: vi.fn(async () => { + throw new Error('reg.exe failed') + }), + prepareHost: vi.fn(async () => 'unverifiable' as const) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'startup') + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + expect(ports.preview).not.toHaveBeenCalled() + expect(ports.track).not.toHaveBeenCalled() + }) + }) + + describe('T2: a bridge project opened', () => { + it('moves a distro that is running, readying its host first', async () => { + const ports = fakePorts() + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'project-opened') + expect(ports.prepareHost).toHaveBeenCalledWith('Ubuntu', expect.any(Function)) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + + it('reads the running distros at most once a minute per distro', async () => { + const ports = fakePorts({ prepareHost: vi.fn(async () => 'not-running' as const) }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'project-opened') + await orchestrator.trigger('ubuntu', 'project-opened') + await orchestrator.trigger('Debian', 'project-opened') + expect(ports.prepareHost).toHaveBeenCalledTimes(2) + ports.clock.now += WSL_MOVE_ORCHESTRATOR_PROJECT_OPENED_THROTTLE_MS + await orchestrator.trigger('Ubuntu', 'project-opened') + expect(ports.prepareHost).toHaveBeenCalledTimes(3) + }) + + it('readies no host for a distro parked for the user', async () => { + const ports = fakePorts() + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.userAction('Ubuntu', 'not-now') + await orchestrator.trigger('Ubuntu', 'project-opened') + expect(ports.prepareHost).not.toHaveBeenCalled() + }) + }) + + it('makes no automatic start with the kill switch off, but still runs Retry', async () => { + const ports = fakePorts({ autoMoveEnabled: () => false }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + await orchestrator.trigger('Ubuntu', 'project-opened') + expect(ports.preview).not.toHaveBeenCalled() + await orchestrator.userAction('Ubuntu', 'retry') + await orchestrator.whenIdle() + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + + it('re-checks a deferred move on its timer and starts once the terminals are free', async () => { + let busy = true + const ports = fakePorts({ + preview: vi.fn(async (distro: string) => (busy ? ready(distro, [blocker]) : ready(distro))) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + expect(stateOf(orchestrator, 'Ubuntu')?.kind).toBe('deferred') + busy = false + await ports.advance(WSL_MOVE_ORCHESTRATOR_DEFERRED_RECHECK_MS[0]) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + + it('backs off a reset move and retries on its timer while the host stays ready', async () => { + const start = vi + .fn() + .mockResolvedValueOnce({ outcome: 'reset', migrationId: 'm0', detail: 'ECONNRESET' }) + .mockResolvedValueOnce(moved) + const ports = fakePorts({ start }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + expect(stateOf(orchestrator, 'Ubuntu')).toMatchObject({ kind: 'backoff', reason: 'ECONNRESET' }) + await ports.advance(WSL_MOVE_ORCHESTRATOR_BACKOFF_MS[0] - 1) + expect(start).toHaveBeenCalledTimes(1) + await ports.advance(1) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + + it('treats a port that throws as a transient failure', async () => { + const ports = fakePorts({ + preview: vi.fn(async () => { + throw new Error('wsl_move_window_unavailable') + }) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + expect(stateOf(orchestrator, 'Ubuntu')).toMatchObject({ + kind: 'backoff', + reason: 'wsl_move_window_unavailable' + }) + }) + + it('absorbs a timer that fires while the distro is already being handled', async () => { + let release: () => void = () => {} + const ports = fakePorts({ + preview: vi.fn( + () => + new Promise((resolve) => { + release = () => resolve(ready('Ubuntu')) + }) + ) + }) + const orchestrator = new WslMoveOrchestrator(ports) + const first = orchestrator.trigger('Ubuntu', 'connect') + await vi.waitFor(() => expect(ports.preview).toHaveBeenCalled()) + await orchestrator.trigger('ubuntu', 'timer') + release() + await first + expect(ports.preview).toHaveBeenCalledTimes(1) + }) + + it('reports each state change once, not each unchanged re-check', async () => { + const ports = fakePorts({ + preview: vi.fn(async (distro: string) => ready(distro, [blocker])) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + await ports.advance(WSL_MOVE_ORCHESTRATOR_DEFERRED_RECHECK_MS[0]) + await ports.advance(WSL_MOVE_ORCHESTRATOR_DEFERRED_RECHECK_MS[0]) + expect(vi.mocked(ports.track).mock.calls.map(([, record]) => record.state.kind)).toEqual([ + 'deferred' + ]) + }) + + it('marks a finished move whose distro is gone and never previews it (R38)', async () => { + const ports = fakePorts({ + finishedMove: () => ({ migrationId: 'm1', distroGuid: 'guid' }), + checkMovedDistro: vi.fn(async () => 'gone' as const) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'distro-gone' }) + expect(ports.preview).not.toHaveBeenCalled() + }) + + it('offers Restore for a replaced distro, releases the move, then moves on the next trigger', async () => { + let verdict: 'replaced' | 'present' = 'replaced' + let finished: { migrationId: string } | null = { migrationId: 'm1' } + const ports = fakePorts({ + finishedMove: () => finished, + checkMovedDistro: vi.fn(async () => verdict), + releaseFinishedMove: vi.fn(async () => { + finished = null + verdict = 'present' + return { + released: true as const, + distro: 'Ubuntu', + repoIds: ['r1'], + folderWorkspaceIds: [] + } + }) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'restore-offered' }) + const { release } = await orchestrator.userAction('Ubuntu', 'restore', { + unsavedWorkspaceIds: ['wt'] + }) + expect(release).toMatchObject({ released: true }) + expect(ports.releaseFinishedMove).toHaveBeenCalledWith('Ubuntu', ['wt']) + // The window reloads after a release; the move waits for the next trigger. + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'pending' }) + expect(ports.preview).not.toHaveBeenCalled() + await orchestrator.trigger('Ubuntu', 'connect') + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + + it('keeps Restore offered when the release is refused', async () => { + const ports = fakePorts({ + finishedMove: () => ({ migrationId: 'm1' }), + checkMovedDistro: vi.fn(async () => 'replaced' as const), + releaseFinishedMove: vi.fn(async () => ({ + released: false as const, + reason: 'unsaved-files' as const, + unsavedWorkspaceIds: ['wt'] + })) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + const { record, release } = await orchestrator.userAction('Ubuntu', 'restore') + expect(release).toMatchObject({ released: false, reason: 'unsaved-files' }) + expect(record.state).toEqual({ kind: 'restore-offered' }) + }) + + it('snoozes on Not now and leaves the distro alone until it expires', async () => { + const ports = fakePorts() + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.userAction('Ubuntu', 'not-now') + await orchestrator.trigger('Ubuntu', 'connect') + expect(ports.preview).not.toHaveBeenCalled() + expect(stateOf(orchestrator, 'Ubuntu')).toMatchObject({ kind: 'parked', reason: 'not-now' }) + }) + + it('resumes from its sidecar: a move left running is pending, a backoff is rescheduled', async () => { + const loaded = new Map([ + [ + wslMoveJournalKey('Ubuntu'), + { + distro: 'Ubuntu', + state: { kind: 'moving' }, + attempts: 0, + appVersion: '1.0.0', + updatedAt: 0 + } + ], + [ + wslMoveJournalKey('Debian'), + { + distro: 'Debian', + state: { kind: 'backoff', nextAttemptAt: 1_500, reason: 'x' }, + attempts: 1, + appVersion: '1.0.0', + updatedAt: 0 + } + ] + ]) + const ports = fakePorts({ load: () => loaded }) + const orchestrator = new WslMoveOrchestrator(ports) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'pending' }) + await ports.advance(500) + expect(stateOf(orchestrator, 'Debian')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + + it('stops its timers on dispose', async () => { + const ports = fakePorts({ + preview: vi.fn(async (distro: string) => ready(distro, [blocker])) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + orchestrator.dispose() + await ports.advance(WSL_MOVE_ORCHESTRATOR_DEFERRED_RECHECK_MS[0]) + expect(ports.preview).toHaveBeenCalledTimes(1) + }) + + describe('review fixes (#33802)', () => { + // 1: a clean preview, then a start this process already runs, must not strand the record. + it('goes back to pending when the start is already running elsewhere', async () => { + const ports = fakePorts({ + start: vi.fn(async () => ({ + outcome: 'refused' as const, + code: 'wsl_move_already_running' as const + })) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'pending' }) + await orchestrator.trigger('Ubuntu', 'connect') + expect(ports.start).toHaveBeenCalledTimes(2) + }) + + // 2: after "Use on this computer again", opening the project must not move it again. + it('never moves a distro the user brought back until Retry', async () => { + const ports = fakePorts() + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + orchestrator.releasedByUser('Ubuntu') + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'parked', reason: 'declined' }) + ports.clock.now += 30 * 86_400_000 + await orchestrator.trigger('Ubuntu', 'project-opened') + await orchestrator.trigger('Ubuntu', 'connect') + expect(ports.start).toHaveBeenCalledTimes(1) + await orchestrator.userAction('Ubuntu', 'retry') + await orchestrator.whenIdle() + expect(ports.start).toHaveBeenCalledTimes(2) + }) + + // 3: a timer step never connects or adds a host, which could hold the distro up. + it('runs a timer step only on a host that is already ready, without readying one', async () => { + const start = vi + .fn() + .mockResolvedValueOnce({ outcome: 'reset', migrationId: 'm0', detail: 'ECONNRESET' }) + .mockResolvedValueOnce(moved) + let hostReady = false + const ports = fakePorts({ start, isHostReady: vi.fn(() => hostReady) }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + expect(ports.prepareHost).toHaveBeenCalledTimes(1) + await ports.advance(WSL_MOVE_ORCHESTRATOR_BACKOFF_MS[0]) + expect(ports.prepareHost).toHaveBeenCalledTimes(1) + expect(start).toHaveBeenCalledTimes(1) + hostReady = true + await orchestrator.trigger('Ubuntu', 'timer') + expect(ports.prepareHost).toHaveBeenCalledTimes(1) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + + // 4: deferred re-checks stop after 1 m, 5 m and 30 m; an unchanged state is not rewritten. + it('stops re-checking a deferred move after the series and writes nothing unchanged', async () => { + const ports = fakePorts({ + preview: vi.fn(async (distro: string) => ready(distro, [blocker])) + }) + const save = vi.spyOn(ports, 'save') + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + for (const delay of WSL_MOVE_ORCHESTRATOR_DEFERRED_RECHECK_MS) { + await ports.advance(delay) + } + expect(ports.preview).toHaveBeenCalledTimes(4) + await ports.advance(24 * 60 * 60_000) + expect(ports.preview).toHaveBeenCalledTimes(4) + const writes = save.mock.calls.length + // A wake previews again, and the same blockers at the same re-check count write nothing. + await orchestrator.trigger('Ubuntu', 'connect') + await orchestrator.trigger('Ubuntu', 'connect') + expect(ports.preview).toHaveBeenCalledTimes(6) + expect(save.mock.calls.length).toBe(writes + 1) + }) + + // 7: no window to ask is not a failed move. + it('spends no attempt when the window cannot be asked', async () => { + const ports = fakePorts({ + preview: vi.fn(async () => { + throw new Error('wsl_move_window_unavailable') + }) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + expect(orchestrator.list()[0]?.attempts).toBe(0) + }) + + // 8: each failed attempt of a backoff series is reported, not only the first. + it('reports every backoff transition, also backoff to backoff', async () => { + const ports = fakePorts({ + preview: vi.fn(async () => ({ + outcome: 'refused' as const, + code: 'wsl_move_host_unavailable' as const + })) + }) + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + await ports.advance(WSL_MOVE_ORCHESTRATOR_BACKOFF_MS[0]) + const backoffs = vi + .mocked(ports.track) + .mock.calls.filter(([, record]) => record.state.kind === 'backoff') + .map(([, record]) => record.attempts) + expect(backoffs).toEqual([1, 2]) + }) + + // 9: a Restore whose release finished before the app quit is not offered again. + it('reads an offered Restore with no finished move left as pending at start', () => { + const loaded = new Map([ + [ + wslMoveJournalKey('Ubuntu'), + { + distro: 'Ubuntu', + state: { kind: 'restore-offered' }, + attempts: 0, + appVersion: '1.0.0', + updatedAt: 0 + } + ] + ]) + const orchestrator = new WslMoveOrchestrator(fakePorts({ load: () => loaded })) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'pending' }) + }) + + // 10: a project opened while the distro is busy is run after, not dropped. + it('runs a project opened mid-step once the step ends', async () => { + let release: () => void = () => {} + const preview = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(ready('Ubuntu', [blocker])) + }) + ) + .mockImplementation(async (distro) => ready(distro)) + const ports = fakePorts({ preview }) + const orchestrator = new WslMoveOrchestrator(ports) + const first = orchestrator.trigger('Ubuntu', 'connect') + await vi.waitFor(() => expect(preview).toHaveBeenCalled()) + await orchestrator.trigger('Ubuntu', 'project-opened') + await orchestrator.trigger('Ubuntu', 'project-opened') + release() + await first + expect(preview).toHaveBeenCalledTimes(2) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'done', migrationId: 'm1' }) + }) + }) + + // E11 (#33843): a Remove that could not prove the server stopped leaves the host to the user. + + // Review of #34087: the wait cap counts checks, so a restart after days closed does not park. + it('does not park on the first in-flight refusal after a restart more than a day later', async () => { + const loaded = new Map([ + [ + wslMoveJournalKey('Ubuntu'), + { + distro: 'Ubuntu', + state: { kind: 'done', migrationId: 'm1', nextCheckAt: 1_000, waits: 2 }, + attempts: 0, + appVersion: '1.0.0', + updatedAt: 0 + } + ] + ]) + const ports = fakePorts({ + load: () => loaded, + preview: vi.fn(async () => ({ + outcome: 'refused' as const, + code: 'wsl_move_already_in_flight' as const + })) + }) + ports.clock.now += 3 * 24 * 60 * 60_000 + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'startup') + expect(stateOf(orchestrator, 'Ubuntu')).toMatchObject({ + kind: 'done', + migrationId: 'm1', + waits: 3 + }) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.ts new file mode 100644 index 00000000000..a5dc39b6468 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.ts @@ -0,0 +1,286 @@ +/** + * The general migration's orchestrator (D3a). Per distro it decides when the move driver runs: + * on a trigger that already woke the distro (never a background boot, A1), previewing first, + * deferring while terminals are busy, backing off on transient failures and parking what needs + * the user. A finished move's distro is checked for R38 (unregistered or replaced). It never moves + * anything itself; the driver and its journal own every move. + */ +import type { WslMovePreview, WslMoveResult } from '../../../shared/wsl-move/wsl-move-driver-types' +import type { WslMoveUseLocallyResult } from '../../../shared/wsl-move/wsl-moved-project' +import type { WslMoveOrchestratorPorts } from './wsl-move-orchestrator-ports' +import { readWslMoveFinishedMove, runWslMoveOrchestratorStep } from './wsl-move-orchestrator-step' +import { wslMoveJournalKey } from '../../../shared/wsl-move/wsl-move-cutover' +import type { + WslMoveOrchestratorRecord, + WslMoveOrchestratorTrigger, + WslMoveOrchestratorUserAction +} from '../../../shared/wsl-move/wsl-move-orchestrator-types' +import { + newWslMoveOrchestratorRecord, + sameWslMoveOrchestratorRecord, + wslMoveOrchestratorTimerDelay +} from './wsl-move-orchestrator-transitions' +import { + applyWslMoveHostAddedByUser, + applyWslMoveReleasedByUser, + applyWslMoveUserAction, + settleWslMoveOrchestratorRecord +} from './wsl-move-orchestrator-external-transitions' + +/** A project opened again within this window does not read the running distros again. */ +export const WSL_MOVE_ORCHESTRATOR_PROJECT_OPENED_THROTTLE_MS = 60_000 + +export class WslMoveOrchestrator { + private readonly records: Map + private readonly busy = new Set() + private readonly timers = new Map void>() + private readonly projectOpenedAt = new Map() + /** A wake that arrived while the distro was busy, run once the current step ends. */ + private readonly queued = new Map< + string, + { distro: string; trigger: WslMoveOrchestratorTrigger } + >() + private readonly inFlight = new Set>() + private disposed = false + + constructor(private readonly ports: WslMoveOrchestratorPorts) { + const now = ports.now() + this.records = new Map( + [...ports.load()].map(([key, record]) => [ + key, + // An unreadable journal says nothing about whether a move is finished: keep the record. + settleWslMoveOrchestratorRecord( + record, + now, + readWslMoveFinishedMove(ports, record.distro) !== null + ) + ]) + ) + for (const [key, record] of this.records) { + this.scheduleFor(key, record) + } + } + + list(): WslMoveOrchestratorRecord[] { + return [...this.records.values()] + } + + /** + * Runs the distro's next step for a trigger. A wake that arrives mid-step runs once after it; + * a timer or user trigger then is absorbed (the step it would repeat is under way). + */ + async trigger(distro: string, trigger: WslMoveOrchestratorTrigger): Promise { + const key = wslMoveJournalKey(distro) + if (this.disposed || (trigger !== 'user' && !this.ports.autoMoveEnabled())) { + return + } + if (this.busy.has(key)) { + if (trigger === 'project-opened' || trigger === 'connect') { + this.queued.set(key, { distro, trigger }) + } + return + } + if (trigger === 'project-opened') { + const now = this.ports.now() + const last = this.projectOpenedAt.get(key) + if (last !== undefined && now - last < WSL_MOVE_ORCHESTRATOR_PROJECT_OPENED_THROTTLE_MS) { + return + } + this.projectOpenedAt.set(key, now) + } + await this.run(key, distro, trigger) + } + + private async run( + key: string, + distro: string, + trigger: WslMoveOrchestratorTrigger + ): Promise { + this.busy.add(key) + const running = (async () => { + try { + await this.step(key, distro, trigger) + } catch (error) { + // Triggers are fire-and-forget (status events, timers): a throw must not go unhandled. + console.warn('[wsl-move] Orchestrator step failed:', error) + } finally { + this.busy.delete(key) + // In `finally`, so a step that throws never strands the wake queued behind it. + await this.replayQueued(key) + } + })() + this.inFlight.add(running) + try { + await running + } finally { + this.inFlight.delete(running) + } + } + + /** Resolves once no step is running; the IPC never waits on a move, so tests and quit do. */ + async whenIdle(): Promise { + while (this.inFlight.size > 0) { + await Promise.allSettled(this.inFlight) + } + } + + /** Runs the wake that arrived while the distro was busy, once it no longer is. */ + private async replayQueued(key: string): Promise { + const queued = this.queued.get(key) + if (queued && !this.disposed && !this.busy.has(key)) { + this.queued.delete(key) + await this.run(key, queued.distro, queued.trigger) + } + } + + /** The user added the distro's host themselves, which takes back a decline. */ + hostAddedByUser(distro: string): void { + const key = wslMoveJournalKey(distro) + const current = this.records.get(key) + if (current) { + this.commit(key, 'user', applyWslMoveHostAddedByUser(current, this.ports.now())) + } + } + + /** + * "Use on this computer again" ran for the distro, or the user removed its host: park it until + * the user adds the host or presses Retry. + */ + releasedByUser(distro: string): void { + const key = wslMoveJournalKey(distro) + this.commit( + key, + 'user', + applyWslMoveReleasedByUser(this.recordFor(key, distro), this.ports.now()) + ) + } + + async userAction( + distro: string, + action: WslMoveOrchestratorUserAction, + options: { unsavedWorkspaceIds?: readonly string[] } = {} + ): Promise<{ record: WslMoveOrchestratorRecord; release?: WslMoveUseLocallyResult }> { + const key = wslMoveJournalKey(distro) + const current = this.recordFor(key, distro) + if (this.busy.has(key)) { + return { record: current } + } + if (action !== 'restore') { + this.commit(key, 'user', this.userActionRecord(current, action)) + if (action !== 'not-now') { + // The reply carries the new state; the move itself reports through status events. + void this.trigger(distro, 'user') + } + return { record: this.recordFor(key, distro) } + } + if (current.state.kind !== 'restore-offered') { + return { record: current } + } + this.busy.add(key) + let release: WslMoveUseLocallyResult + try { + release = await this.ports.releaseFinishedMove(distro, options.unsavedWorkspaceIds ?? []) + } catch (error) { + release = { + released: false, + reason: 'failed', + detail: error instanceof Error ? error.message : 'restore_failed' + } + } finally { + this.busy.delete(key) + } + if (release.released) { + this.commit(key, 'user', this.userActionRecord(current, action)) + } + // A wake that came in during the release runs now, as after any step, without holding the reply. + void this.replayQueued(key) + return { record: this.recordFor(key, distro), release } + } + + private userActionRecord( + record: WslMoveOrchestratorRecord, + action: WslMoveOrchestratorUserAction + ): WslMoveOrchestratorRecord { + return applyWslMoveUserAction(record, action, this.ports.now(), this.ports.appVersion) + } + + dispose(): void { + this.disposed = true + for (const cancel of this.timers.values()) { + cancel() + } + this.timers.clear() + } + + private recordFor(key: string, distro: string): WslMoveOrchestratorRecord { + return ( + this.records.get(key) ?? + newWslMoveOrchestratorRecord(distro, this.ports.appVersion, this.ports.now()) + ) + } + + /** + * True when the record changed under an await (the user released the move, or chose Not now): + * the step's view is stale and must not overwrite the user's choice. + */ + private changedUnder(key: string, seen: WslMoveOrchestratorRecord): boolean { + const current = this.records.get(key) + return current !== undefined && current !== seen + } + + private step(key: string, distro: string, trigger: WslMoveOrchestratorTrigger): Promise { + return runWslMoveOrchestratorStep( + { + ports: this.ports, + record: this.recordFor(key, distro), + changedUnder: (seen) => this.changedUnder(key, seen), + commit: (next, cause) => this.commit(key, trigger, next, cause) + }, + distro, + trigger + ) + } + + /** Stores, reports and schedules a record; unchanged records cost nothing. */ + private commit( + key: string, + trigger: WslMoveOrchestratorTrigger, + next: WslMoveOrchestratorRecord, + cause?: WslMovePreview | WslMoveResult + ): WslMoveOrchestratorRecord { + const previous = this.records.get(key) + if (previous === next || sameWslMoveOrchestratorRecord(previous, next)) { + return previous ?? next + } + this.records.set(key, next) + try { + this.ports.save(this.records) + } catch (error) { + // The sidecar is the orchestrator's memory, not the move's record: keep going in memory. + console.warn('[wsl-move] Orchestrator state not saved:', error) + } + // Every new attempt is reported too, so a backoff series shows each failure. + if (previous?.state.kind !== next.state.kind || previous.attempts !== next.attempts) { + this.ports.track(trigger, next, cause) + } + this.ports.publish(this.list()) + this.scheduleFor(key, next) + return next + } + + private scheduleFor(key: string, record: WslMoveOrchestratorRecord): void { + this.timers.get(key)?.() + this.timers.delete(key) + const delay = wslMoveOrchestratorTimerDelay(record, this.ports.now()) + if (delay === null || this.disposed) { + return + } + this.timers.set( + key, + this.ports.schedule(delay, () => { + this.timers.delete(key) + void this.trigger(record.distro, 'timer') + }) + ) + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-owed-host-aborts.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-owed-host-aborts.test.ts new file mode 100644 index 00000000000..2c949f46a60 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-owed-host-aborts.test.ts @@ -0,0 +1,99 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { listWslMoveCutovers, writeWslMoveCutover } from './wsl-move-cutover-journal' +import { + listWslMoveOwedHostAborts, + recordWslMoveOwedHostAbort, + settleWslMoveOwedHostAborts +} from './wsl-move-owed-host-aborts' +import { recoverInterruptedWslMoves } from './wsl-move-reset' + +let userDataPath: string +beforeEach(() => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-owed-')) +}) +afterEach(() => { + vi.unstubAllEnvs() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +/** A record walked to `phase` through the journal's compare-and-swap. */ +function seed(phase: 'source-fenced' | 'quiesced' | 'destination-staged'): string { + const phases = ['prepared', 'source-fenced', 'quiesced', 'destination-staged'] as const + const base = wslMoveCutoverFixture('move-1', 'prepared') + for (const step of phases.slice(0, phases.indexOf(phase) + 1)) { + writeWslMoveCutover(userDataPath, { + ...base, + phase: step, + ...(step === 'quiesced' || step === 'destination-staged' ? { terminalRestoreHints: [] } : {}) + }) + } + return base.migrationId +} + +describe('owed host aborts', () => { + it.each(['quiesced', 'destination-staged'] as const)( + 'a reset at %s without the host owes the host an abort, written before the record goes', + async (phase) => { + seed(phase) + await recoverInterruptedWslMoves(userDataPath, () => {}) + + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + expect(listWslMoveOwedHostAborts(userDataPath).map((entry) => entry.migrationId)).toEqual([ + 'move-1' + ]) + } + ) + + it('a reset before the stage request owes nothing', async () => { + seed('source-fenced') + await recoverInterruptedWslMoves(userDataPath, () => {}) + + expect(listWslMoveOwedHostAborts(userDataPath)).toEqual([]) + }) + + it("settles only this distro's, keeps one the host could not answer, and drops answered ones", async () => { + const cutover = wslMoveCutoverFixture('move-1', 'prepared') + recordWslMoveOwedHostAbort(userDataPath, cutover) + recordWslMoveOwedHostAbort(userDataPath, { ...cutover, migrationId: 'move-2' }) + recordWslMoveOwedHostAbort(userDataPath, { + ...cutover, + migrationId: 'move-other', + journalKey: 'local-wsl:debian' + }) + const abortByReference = vi.fn(async (reference: { migrationId: string }) => { + if (reference.migrationId === 'move-2') { + throw new Error('socket closed') + } + return { + migrationId: reference.migrationId, + manifestSha256: cutover.manifestSha256, + state: 'absent' as const, + aborted: true, + durableAbsent: true as const + } + }) + + await settleWslMoveOwedHostAborts( + userDataPath, + cutover.journalKey, + { abortByReference }, + () => {} + ) + + expect(abortByReference.mock.calls.map(([reference]) => reference.migrationId).sort()).toEqual([ + 'move-1', + 'move-2' + ]) + expect( + listWslMoveOwedHostAborts(userDataPath) + .map((entry) => entry.migrationId) + .sort() + ).toEqual(['move-2', 'move-other']) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-owed-host-aborts.ts b/src/main/persistence/moving-wsl-projects/wsl-move-owed-host-aborts.ts new file mode 100644 index 00000000000..7b10d892a90 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-owed-host-aborts.ts @@ -0,0 +1,95 @@ +/** + * A stage a reset move may have left on the WSL host. A reset without the host's answer (a restart + * while the host is down) lifts the desktop's record but cannot abort the host's stage, which then + * keeps its claims on those projects for the stage's 7-day lifetime and refuses every retry. The + * reset leaves this record; the next move into the distro aborts that stage by identity first. + */ +import type { OrcadMigrationCatalogReferenceAbortResult } from '../../../shared/orcad-migration/orcad-migration-manifest' +import { isRecord } from '../../../shared/orcad-migration/orcad-migration-manifest-fields' +import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import { createMigrationJournalDirectory } from '../migrating-orcad-catalog/migration-journal-directory' +import type { OrcadMigrationDestinationCatalog } from '../../ssh/orcad-migration-cutover-coordinator' + +export type WslMoveOwedHostAbort = { + migrationId: string + manifestSha256: string + journalKey: string +} + +const owed = createMigrationJournalDirectory({ + // Its own directory: builds without it never read it, and their stage simply expires. + directoryName: 'orcad-migration-wsl-owed-aborts', + maxFileBytes: 4096, + parse: parseOwedAbort, + unreadable: (detail) => new Error(`wsl_move_owed_aborts_unreadable: ${detail}`) +}) + +function parseOwedAbort(value: unknown): WslMoveOwedHostAbort { + if ( + !isRecord(value) || + typeof value.migrationId !== 'string' || + typeof value.manifestSha256 !== 'string' || + !/^[a-f0-9]{64}$/.test(value.manifestSha256) || + typeof value.journalKey !== 'string' + ) { + throw new Error('wsl_move_owed_abort_invalid') + } + return { + migrationId: value.migrationId, + manifestSha256: value.manifestSha256, + journalKey: value.journalKey + } +} + +/** Durable before it returns, so it lands before the record it outlives is removed. */ +export function recordWslMoveOwedHostAbort( + userDataPath: string, + cutover: Pick +): void { + owed.write(userDataPath, { + migrationId: cutover.migrationId, + manifestSha256: cutover.manifestSha256, + journalKey: cutover.journalKey + }) +} + +export function listWslMoveOwedHostAborts(userDataPath: string): WslMoveOwedHostAbort[] { + return owed.list(userDataPath) +} + +/** + * Aborts each owed stage of this distro on its now-connected host. Only an answer settles one: + * a failure keeps it for the next move, whose own stage the leftover would refuse anyway. + */ +export async function settleWslMoveOwedHostAborts( + userDataPath: string, + journalKey: string, + destination: Pick, + warn: (message: string) => void = (message) => console.warn(message) +): Promise { + let pending: WslMoveOwedHostAbort[] + try { + pending = owed.list(userDataPath).filter((entry) => entry.journalKey === journalKey) + } catch (error) { + warn(`[wsl-move] Could not read the owed host aborts: ${describe(error)}`) + return + } + for (const entry of pending) { + let answer: OrcadMigrationCatalogReferenceAbortResult + try { + answer = await destination.abortByReference(entry) + } catch (error) { + warn(`[wsl-move] Could not abort a leftover stage on the host: ${describe(error)}`) + continue + } + if (answer.state === 'committed') { + // No commit was ever requested for it; leave the host's state alone and stop asking. + warn(`[wsl-move] A reset move ${entry.migrationId} reads as committed on the host`) + } + owed.remove(userDataPath, entry.migrationId) + } +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-path-rewriter.ts b/src/main/persistence/moving-wsl-projects/wsl-move-path-rewriter.ts index 85b780acd45..938c25646fd 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-path-rewriter.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-path-rewriter.ts @@ -1,13 +1,7 @@ // Convert only the moved distro's UNC paths; unrelated paths remain unchanged. -import { parseWslUncPath } from '../../../shared/wsl-paths' +import { wslUncPathToLinux } from '../../../shared/wsl-paths' import type { OrcadMigrationPathRewriter } from '../../ssh/orcad-migration-path-rewrite' -export function wslUncPathToLinux(value: string, distro: string): string | null { - const parsed = parseWslUncPath(value) - // Distro names compare case-insensitively in WSL UNC paths. - return parsed && parsed.distro.toLowerCase() === distro.toLowerCase() ? parsed.linuxPath : null -} - export function createUncToLinuxPathRewriter(distro: string): OrcadMigrationPathRewriter { const fileUrl = new RegExp(`^file://(?:wsl\\.localhost|wsl\\$)/([^/]+)(/.*)?$`, 'i') return { diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-preflight.ts b/src/main/persistence/moving-wsl-projects/wsl-move-preflight.ts index cf6f93df672..b0a1c108590 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-preflight.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-preflight.ts @@ -1,7 +1,6 @@ // No files move: prove rewritten paths identify the same objects before fencing anything. import { ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY } from '../../../shared/orcad-runtime-capabilities' -import { isDrvfsLinuxPath } from '../../../shared/wsl-paths' -import { wslUncPathToLinux } from './wsl-move-path-rewriter' +import { isDrvfsLinuxPath, wslUncPathToLinux } from '../../../shared/wsl-paths' /** Read once through desktop UNC paths and once on the host. */ export type WslMovePathFacts = { diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.test.ts index 8fc52072aaf..476bb675768 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.test.ts @@ -51,6 +51,7 @@ function harness(overrides: Partial = {}) { removed.push(migrationId) }, now: () => new Date('2026-10-14T00:00:00.000Z'), + wait: async () => {}, ...overrides } return { deps, journal, removed, steps } @@ -72,7 +73,11 @@ describe('quiesceWslMove', () => { expect(removed).toEqual([]) expect(result).toMatchObject({ outcome: 'quiesced', - cutover: { phase: 'quiesced', provenExitedSessionIds: ['session-1'] }, + cutover: { + phase: 'quiesced', + provenExitedSessionIds: ['session-1'], + terminalRestoreHints: hints.hints + }, restoreHints: hints }) }) @@ -173,17 +178,33 @@ describe('quiesceWslMove', () => { expect(removed).toEqual(['move-1']) }) - it('lifts the fence and names the workspaces whose Sleep failed', async () => { - const { deps, removed } = harness({ - sleepWorkspaces: async () => ({ failedWorktreeIds: [WORKTREE] }) + // A just-slept workspace's first stop failed once and the move refused. + it('sleeps a workspace again when its first Sleep left a pane awake', async () => { + const asked: string[][] = [] + const { deps, journal } = harness({ + sleepWorkspaces: async (ids) => { + asked.push([...ids]) + return { failedWorktreeIds: asked.length === 1 ? [WORKTREE] : [] } + } }) const result = await quiesceWslMove(cutover(), scope, deps) + expect(result).toMatchObject({ outcome: 'quiesced' }) + expect(asked).toEqual([[...scope.workspaceIds], [WORKTREE]]) + expect(journal.at(-1)).toBe('quiesced') + }) + + it('lifts the fence and names the workspaces whose Sleep keeps failing', async () => { + const sleepWorkspaces = vi.fn(async () => ({ failedWorktreeIds: [WORKTREE] })) + const { deps, removed } = harness({ sleepWorkspaces }) + const result = await quiesceWslMove(cutover(), scope, deps) + expect(result).toMatchObject({ outcome: 'refused', reason: 'sleep-failed', failedWorktreeIds: [WORKTREE] }) + expect(sleepWorkspaces).toHaveBeenCalledTimes(3) expect(removed).toEqual(['move-1']) }) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.ts b/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.ts index 0139f739951..755a883540a 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.ts @@ -29,6 +29,30 @@ export type WslMoveQuiesceDeps = { writeCutover: (cutover: WslMoveCutover) => void removeCutover: (migrationId: string) => void now: () => Date + /** Between Sleep attempts; tests skip the wait. */ + wait?: (ms: number) => Promise +} + +// A Sleep can fail once and succeed when tried again; only a repeat refuses. +const SLEEP_ATTEMPTS = 3 +const SLEEP_RETRY_MS = 1_000 + +/** Sleeps the workspaces, again for the ones that failed; returns those that kept failing. */ +export async function sleepUntilPaused( + deps: Pick, + workspaceIds: readonly string[] +): Promise { + let pending = workspaceIds + for (let attempt = 1; ; attempt += 1) { + const { failedWorktreeIds } = await deps.sleepWorkspaces(pending) + if (failedWorktreeIds.length === 0 || attempt >= SLEEP_ATTEMPTS) { + return failedWorktreeIds + } + pending = failedWorktreeIds + await (deps.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))( + SLEEP_RETRY_MS * attempt + ) + } } export type WslMoveQuiesceRefusalReason = @@ -46,8 +70,9 @@ export type WslMoveQuiesceRefusalReason = export type WslMoveQuiesceResult = | { outcome: 'quiesced' + /** Carries `terminalRestoreHints`, so a crash after quiesce keeps them. */ cutover: WslMoveCutover - // Hints stay with the caller because the journal schema cannot store them. + /** The whole capture, including the panes that had no folder to keep, for the preview. */ restoreHints: WslMoveRestoreHintCapture } | { @@ -135,7 +160,7 @@ async function quiesceUnderLock( } try { - const { failedWorktreeIds } = await deps.sleepWorkspaces(scope.workspaceIds) + const failedWorktreeIds = await sleepUntilPaused(deps, scope.workspaceIds) if (failedWorktreeIds.length > 0) { return refuse({ reason: 'sleep-failed', failedWorktreeIds }) } @@ -183,7 +208,8 @@ async function proveAndRecord( ...fenced, phase: 'quiesced', updatedAt: deps.now().toISOString(), - provenExitedSessionIds: [...sessionIds] + provenExitedSessionIds: [...sessionIds], + terminalRestoreHints: restoreHints.hints.map((hint) => ({ ...hint })) } try { deps.writeCutover(quiesced) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-real-pause.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-real-pause.test.ts new file mode 100644 index 00000000000..3c2f5d9f05b --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-real-pause.test.ts @@ -0,0 +1,306 @@ +/** + * The quiesce step against real terminals: real shells on node-pty, hosted by a real in-process + * daemon. Sleep ends them through the daemon, the daemon's own census proves they exited, its + * final checkpoint names each pane's folder, and a new shell started there prints it. + * + * On Windows it runs only in the real-WSL lane (ORCA_WSL_MOVE_REAL_PAUSE_DISTRO): the shells are + * `wsl.exe` in that distro, opened through its UNC path, exactly as a moving project's are. + */ +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DaemonPtyAdapter } from '../../daemon/daemon-pty-adapter' +import { DaemonServer } from '../../daemon/daemon-server' +import { getDaemonSocketPath } from '../../daemon/daemon-spawner' +import { HistoryReader } from '../../daemon/history-reader' +import { createPtySubprocess } from '../../daemon/pty-subprocess' +import { runProcessSync } from '@orca/process-host' +import { buildWslExecArgs } from '../../../shared/wsl-login-shell-command' +import { toWindowsWslUncPath } from '../../../shared/wsl-paths' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { + listWslMoveCutovers, + removeWslMoveCutover, + writeWslMoveCutover +} from './wsl-move-cutover-journal' +import { proveWslMoveTerminalsExited } from './wsl-move-exit-proof' +import { quiesceWslMove, type WslMoveQuiesceScope } from './wsl-move-quiesce' +import { captureWslMoveRestoreHints } from './wsl-move-restore-hints' + +const WSL_DISTRO = + process.platform === 'win32' ? process.env.ORCA_WSL_MOVE_REAL_PAUSE_DISTRO : undefined +const DISTRO = WSL_DISTRO ?? 'Ubuntu' +const SHELL = ['/bin/bash', '/bin/sh'].find(existsSync) ?? '/bin/sh' +const WORKTREE = `repo-1::${toWindowsWslUncPath('/home/dev/repo-1', DISTRO)}` +const PANE_A = 'tab-1:11111111-1111-4111-8111-111111111111' +const PANE_B = 'tab-2:22222222-2222-4222-8222-222222222222' +const SESSION_A = `${WORKTREE}@@aaaa1111` +const SESSION_B = `${WORKTREE}@@bbbb2222` +const TIMEOUT_MS = 20_000 + +type Daemon = { + server: DaemonServer + /** This app's client: the PTYs it holds are what `livePtyIds` reports. */ + adapter: DaemonPtyAdapter + /** Another client of the same daemon, e.g. a window this process never adopted. */ + otherAdapter: DaemonPtyAdapter +} + +let root: string +let home: string +let project: string +let userDataPath: string +let historyDir: string +let daemon: Daemon +let started: Daemon | undefined +const daemonDirs: string[] = [] +const output = new Map() +const exited = new Set() +const heldByThisApp = new Set() +const spawned: { adapter: DaemonPtyAdapter; id: string }[] = [] + +async function startDaemon(): Promise { + // Its own short dir keeps the socket path inside sun_path, so the endpoint never relocates to /tmp. + const daemonDir = mkdtempSync(join(tmpdir(), 'wmrp-')) + daemonDirs.push(daemonDir) + const socketPath = getDaemonSocketPath(daemonDir) + const tokenPath = join(daemonDir, 'test.token') + const server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: (opts) => createPtySubprocess(opts) + }) + await server.start() + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const otherAdapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + for (const client of [adapter, otherAdapter]) { + client.onData(({ id, data }) => output.set(id, `${output.get(id) ?? ''}${data}`)) + client.onExit(({ id }) => exited.add(id)) + } + return { server, adapter, otherAdapter } +} + +/** `cwd` is the shell's own (Linux) folder; a WSL shell is opened through its UNC path. */ +async function openShell(adapter: DaemonPtyAdapter, sessionId: string, cwd: string) { + await adapter.spawn({ + cols: 120, + rows: 30, + sessionId, + worktreeId: WORKTREE, + ...(WSL_DISTRO + ? { cwd: toWindowsWslUncPath(cwd, WSL_DISTRO), terminalWindowsWslDistro: WSL_DISTRO } + : { + cwd, + shellOverride: SHELL, + env: { HOME: home, SHELL, TERM: 'xterm-256color' } + }) + }) + spawned.push({ adapter, id: sessionId }) + if (adapter === daemon.adapter) { + heldByThisApp.add(sessionId) + } +} + +/** Types a command and waits for a marker the shell computes, so the echoed input never matches. */ +async function run(adapter: DaemonPtyAdapter, sessionId: string, command: string, tag: string) { + adapter.write(sessionId, `${command}; echo ${tag}_$((40+2))\n`) + await vi.waitFor(() => expect(output.get(sessionId) ?? '').toContain(`${tag}_42`), { + timeout: 10_000, + interval: 25 + }) +} + +// What a shell with cwd reporting prints after `cd`; the daemon's checkpoint tracks OSC 7. +const reportCwd = 'printf \'\\033]7;file://localhost%s\\033\\\\\' "$PWD"' + +/** Worktree Sleep's stopAndWait: end the PTY through the daemon, keep its history, await exit. */ +async function sleepPane(sessionId: string): Promise { + await daemon.adapter.shutdown(sessionId, { keepHistory: true }) + await vi.waitFor(() => expect(exited.has(sessionId)).toBe(true), { + timeout: 10_000, + interval: 25 + }) + heldByThisApp.delete(sessionId) +} + +function quiesceDeps(sleep: () => Promise) { + return { + gateInputs: async () => ({ panes: [], runningAutomationRunIds: [], activeDispatchIds: [] }), + sleepWorkspaces: async () => { + await sleep() + return { failedWorktreeIds: [] } + }, + livePtyIds: () => [...heldByThisApp], + captureRestoreHints: (panes: WslMoveQuiesceScope['panes'], distro: string) => + captureWslMoveRestoreHints(new HistoryReader(historyDir), panes, distro), + proveExited: (proofScope: Parameters[1]) => + proveWslMoveTerminalsExited( + { + listSessions: () => daemon.adapter.listSessions(), + countFallbackSessions: () => 0, + isScopeFenced: () => true + }, + proofScope + ), + writeCutover: (cutover: Parameters[1]) => + writeWslMoveCutover(userDataPath, cutover), + removeCutover: (migrationId: string) => removeWslMoveCutover(userDataPath, migrationId), + now: () => new Date('2026-10-20T00:00:00.000Z') + } +} + +function moveScope(): WslMoveQuiesceScope { + return { + workspaceIds: [WORKTREE], + workspaceKeys: new Set([WORKTREE]), + panes: [ + { paneKey: PANE_A, sessionId: SESSION_A, wslDistro: DISTRO }, + { paneKey: PANE_B, sessionId: SESSION_B, wslDistro: DISTRO } + ] + } +} + +function prepareMove() { + const prepared = wslMoveCutoverFixture('move-1', 'prepared', {}, {}, undefined, DISTRO) + writeWslMoveCutover(userDataPath, prepared) + return prepared +} + +/** A project folder inside the distro, made there, so no Windows path stands in for it. */ +function makeDistroProject(distro: string): string { + const dir = `/tmp/orca-wmrp-${process.pid}-${Date.now()}` + const made = runProcessSync({ + program: 'wsl.exe', + args: buildWslExecArgs(distro, ['mkdir', '-p', `${dir}/sub`]) + }) + if (made.code !== 0) { + throw new Error(`could not create ${dir} in ${distro}: ${made.stderr}`) + } + return dir +} + +describe.skipIf(process.platform === 'win32' && !WSL_DISTRO)( + 'WSL move quiesce on real terminals', + () => { + beforeEach(async () => { + // Real path: macOS tmp is a symlink through /private, and `pwd` prints the resolved one. + root = realpathSync(mkdtempSync(join(tmpdir(), 'wsl-move-real-pause-'))) + home = join(root, 'home') + project = WSL_DISTRO ? makeDistroProject(WSL_DISTRO) : join(root, 'project') + userDataPath = join(root, 'user-data') + historyDir = join(root, 'history') + for (const dir of [home, ...(WSL_DISTRO ? [] : [join(project, 'sub')]), userDataPath]) { + mkdirSync(dir, { recursive: true }) + } + // A quiet prompt and no user rc files, so the user's own shell setup cannot leak in. + for (const rc of ['.bash_profile', '.bashrc', '.profile']) { + writeFileSync(join(home, rc), "PS1='prompt> '\n") + } + vi.stubEnv('HOME', home) + vi.stubEnv('ENV', join(home, '.profile')) + daemon = await startDaemon() + started = daemon + }) + + afterEach(async () => { + for (const { adapter, id } of spawned.splice(0)) { + await adapter.shutdown(id, { immediate: true }).catch(() => {}) + } + heldByThisApp.clear() + output.clear() + exited.clear() + started?.adapter.dispose() + started?.otherAdapter.dispose() + await started?.server.shutdown() + started = undefined + vi.unstubAllEnvs() + if (WSL_DISTRO) { + runProcessSync({ + program: 'wsl.exe', + args: buildWslExecArgs(WSL_DISTRO, ['rm', '-rf', project]) + }) + } + for (const dir of [root, ...daemonDirs.splice(0)]) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it( + 'pauses two real shells, proves they exited, journals the folder, and reopens a shell there', + async () => { + const sub = `${project}/sub` + await openShell(daemon.adapter, SESSION_A, project) + await openShell(daemon.adapter, SESSION_B, project) + await run(daemon.adapter, SESSION_A, `cd sub && ${reportCwd}`, 'CD') + await run(daemon.adapter, SESSION_B, reportCwd, 'AT') + const census = await daemon.adapter.listSessions() + expect(census.filter((s) => s.isAlive).map((s) => s.sessionId)).toEqual( + expect.arrayContaining([SESSION_A, SESSION_B]) + ) + + const result = await quiesceWslMove( + prepareMove(), + moveScope(), + quiesceDeps(async () => { + await sleepPane(SESSION_A) + await sleepPane(SESSION_B) + }) + ) + + if (result.outcome !== 'quiesced') { + throw new Error(`quiesce refused: ${JSON.stringify(result)}`) + } + // Read from the shell's own reports, not the folder it was spawned in. + expect(result.restoreHints).toMatchObject({ staleCwdPaneKeys: [], noCwdPaneKeys: [] }) + // The daemon's census, not this process's bookkeeping, says both shells are gone. + expect((await daemon.adapter.listSessions()).filter((s) => s.isAlive)).toEqual([]) + const [journaled] = listWslMoveCutovers(userDataPath) + expect(journaled?.phase).toBe('quiesced') + expect(journaled?.provenExitedSessionIds).toEqual([SESSION_A, SESSION_B]) + expect(journaled?.terminalRestoreHints).toEqual([ + { paneKey: PANE_A, cwd: sub }, + { paneKey: PANE_B, cwd: project } + ]) + + const hint = journaled?.terminalRestoreHints?.find((h) => h.paneKey === PANE_A) + const restored = `${WORKTREE}@@cccc3333` + await openShell(daemon.adapter, restored, hint?.cwd ?? project) + await run(daemon.adapter, restored, 'pwd', 'PWD') + // The line `pwd` printed, right before the marker; the echoed command line holds neither. + expect(output.get(restored)).toContain(`${sub}\r\nPWD_42`) + }, + // Real daemon shells start, pause and exit; their startup alone can take seconds. + TIMEOUT_MS + ) + + it( + 'refuses and lifts the fence when the daemon still runs a pane this app does not hold', + async () => { + await openShell(daemon.adapter, SESSION_A, project) + // Started by another client, so this app's own PTY list cannot see it; only the census can. + await openShell(daemon.otherAdapter, SESSION_B, project) + await run(daemon.adapter, SESSION_A, reportCwd, 'A') + await run(daemon.otherAdapter, SESSION_B, reportCwd, 'B') + + const result = await quiesceWslMove( + prepareMove(), + moveScope(), + quiesceDeps(() => sleepPane(SESSION_A)) + ) + + expect(result).toMatchObject({ + outcome: 'refused', + reason: 'terminals-live', + liveSessionIds: [SESSION_B], + fenceLifted: true + }) + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + const alive = (await daemon.adapter.listSessions()).filter((s) => s.isAlive) + expect(alive.map((s) => s.sessionId)).toEqual([SESSION_B]) + }, + // Real daemon shells start, pause and exit; their startup alone can take seconds. + TIMEOUT_MS + ) + } +) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-registry-complete.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-registry-complete.test.ts new file mode 100644 index 00000000000..7ee5a506d7c --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-registry-complete.test.ts @@ -0,0 +1,322 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import { WSL_MOVE_RENDERER_REWRITE_STORE_IDS } from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { Store } from '../../persistence' +import { closeTestStores, createStore, testState, writeDataFile } from '../persistence-test-harness' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { wslMoverProfile } from '../__fixtures__/wsl-mover/wsl-mover-profiles' +import { tabGroup, terminalTab, unifiedTab } from '../__fixtures__/wsl-mover/wsl-mover-profile-rows' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + listWslMoveCutovers, + wslMoveJournalDirectory, + writeWslMoveCutover +} from './wsl-move-cutover-journal' +import { + recordWslMoveRendererStoreRewritten, + runGatedWslMoveDesktopRewritePass, + runWslMoveDesktopRewritePass +} from './wsl-move-desktop-rewrite' +import { registerWslMoveDesktopRewriteRegistry } from './wsl-move-desktop-rewrite-registry' +import { + isWslMoveDesktopRewriteRegistryComplete, + listWslMoveDesktopRewriteStores, + resetWslMoveDesktopRewriteStoresForTests +} from './wsl-move-desktop-rewrite-stores' +import { + resetWslMoveWindowWriteHoldForTests, + isWslMoveWindowWriteHeld, + setWslMoveReloadableWindow +} from './wsl-move-window-write-hold' +import type { WslMoveHostAutomations } from './wsl-move-host-automations-rewrite' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { runWslMoveRetentionPass } from './wsl-move-retention' +import { loadWslMoveFence, resetWslMoveFenceForTests } from './wsl-move-scope-guard' +import { runWslMoveStartupRewrite } from './wsl-move-startup-rewrite' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../browser/browser-partition-cookie-copy', () => ({ + copyBrowserPartitionCookies: vi.fn(), + flushBrowserPartitionCookies: vi.fn() +})) +vi.mock('../../browser/browser-session-registry', () => ({ browserSessionRegistry: {} })) +vi.mock('../../browser/browser-session-startup', () => ({ browserSessionsInitialized: vi.fn() })) +vi.mock('../../browser/browser-route-partition-binding-runtime', () => ({ + activeBrowserRoutePartitionOrcaProfileId: vi.fn(), + currentBrowserRoutePartitionBindingStore: vi.fn() +})) +vi.mock('../../browser/browser-route-partition-storage-dependencies', () => ({ + releaseEvictedBrowserRoutePartitionStorage: vi.fn() +})) + +const HOST = toRuntimeExecutionHostId('wsl:Ubuntu') +const source = wslMoverProfile('mixed-windows-wsl', 'source') +const MOVED = source.session.activeWorktreeId ?? '' +const MOVED_ON_HOST = 'repo-a::/home/dev/app' + +const committedMove = (migrationId = 'move-1', carried: WorkspaceSessionState = carriedSession()) => + wslMoveCutoverFixture( + migrationId, + 'destination-committed', + {}, + { repoIds: ['repo-a'] }, + { + version: 1, + worktreeMeta: [], + worktreeLineage: [], + workspaceLineage: [], + sparsePresets: [], + retiredWorktreeNames: [], + retiredWorktreeNamespaces: [], + workspaceSession: carried + } + ) + +/** What the manifest carries for repo-a: one terminal tab, unbound. */ +function carriedSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [MOVED_ON_HOST]: [terminalTab('tab-1', MOVED_ON_HOST, '/home/dev/app', null)] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + } + }, + unifiedTabs: { [MOVED_ON_HOST]: [unifiedTab('tab-1', MOVED_ON_HOST, 'group-1')] }, + tabGroups: { [MOVED_ON_HOST]: [tabGroup('group-1', MOVED_ON_HOST, ['tab-1'])] }, + activeGroupIdByWorktree: { [MOVED_ON_HOST]: 'group-1' } + } +} + +const hostTabIds = (): string[] => + store.getWorkspaceSession(HOST).tabsByWorktree[MOVED_ON_HOST]?.map((tab) => tab.id) ?? [] + +const journal = () => listWslMoveCutovers(testState.dir) + +let store: Store +let windowOpen = false +const reloads = vi.fn() +let pageLoaded: (() => void) | null = null +let enableAutomations: Mock + +/** Startup's registration, against this test's profile. */ +function registerRegistry(): void { + registerWslMoveDesktopRewriteRegistry({ + profile: store, + orchestrationDatabasePath: join(testState.dir, 'orchestration.db'), + hostAutomations: { enable: enableAutomations }, + browserCookies: { + userDataPath: testState.dir, + sessionsReady: () => true, + orcaProfileId: () => 'orca-profile-a', + // No environment: the cookie store records a fresh partition, the path 7f-1 tests in full. + listEnvironments: () => [], + listBrowserProfiles: () => [], + resolveHostStorage: vi.fn(), + bindHostPartition: vi.fn(), + copyCookies: vi.fn(), + flushCookies: vi.fn() + } + }) +} + +function recordRendererStores(migrationId = 'move-1'): void { + for (const storeId of WSL_MOVE_RENDERER_REWRITE_STORE_IDS) { + expect(recordWslMoveRendererStoreRewritten(testState.dir, { migrationId, storeId })).toEqual({ + recorded: true + }) + } +} + +async function retain(): Promise { + await runWslMoveRetentionPass({ userDataPath: testState.dir, store }) +} + +function expectFocusOnHost(): void { + expect(store.getUI().lastActiveWorktreeId).toBe(MOVED_ON_HOST) + const session = store.getWorkspaceSession() + expect(session.activeWorktreeId).toBe(MOVED_ON_HOST) + expect(session.activeWorkspaceKey).toBe(worktreeWorkspaceKey(MOVED_ON_HOST)) + expect(session.activeWorkspaceExecutionHostId).toBe(HOST) +} + +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'wsl-move-registry-complete-')) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + repos: source.state.repos, + ui: { + ...getDefaultPersistedState(testState.dir).ui, + ...source.ui, + workspaceHostScope: 'local' + }, + workspaceSession: { + ...getDefaultWorkspaceSession(), + ...source.session, + activeWorkspaceKey: worktreeWorkspaceKey(MOVED), + activeWorkspaceExecutionHostId: 'local' + } + }) + store = createStore() + windowOpen = false + reloads.mockReset() + pageLoaded = null + setWslMoveReloadableWindow(() => + windowOpen + ? { + isDestroyed: () => false, + reload: reloads, + allowUnload: () => () => {}, + onLoadedOrGone: (listener) => { + pageLoaded = listener + return () => { + pageLoaded = null + } + } + } + : null + ) + enableAutomations = vi.fn(async () => {}) + registerRegistry() +}) +afterEach(async () => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + resetWslMoveDesktopRewriteStoresForTests() + resetWslMoveWindowWriteHoldForTests() + pageLoaded?.() + setWslMoveReloadableWindow(() => null) + resetWslMoveFenceForTests() + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) +}) + +describe('WSL move with the complete desktop rewrite registry', () => { + it('takes a committed move through client-rewritten to retained', async () => { + expect(isWslMoveDesktopRewriteRegistryComplete()).toBe(true) + seedWslMoveCutover(testState.dir, committedMove()) + loadWslMoveFence(testState.dir) + + await runWslMoveDesktopRewritePass({ userDataPath: testState.dir }) + // Every main store is in; the renderer's are still owed. + expect(journal()[0]).toMatchObject({ + phase: 'destination-committed', + rewrittenStores: listWslMoveDesktopRewriteStores() + .filter((entry) => entry.side === 'main') + .map((entry) => entry.id), + browserCookieCopy: { outcome: 'fresh-partition' } + }) + + recordRendererStores() + const [rewritten] = journal() + expect(rewritten?.phase).toBe('client-rewritten') + // Each registered store exactly once. + expect([...(rewritten?.rewrittenStores ?? [])].sort()).toEqual( + listWslMoveDesktopRewriteStores() + .map((entry) => entry.id) + .sort() + ) + expectFocusOnHost() + + await retain() + expect(journal()[0]?.phase).toBe('retained') + expect(journal()[0]?.rewrittenStores).toEqual(rewritten?.rewrittenStores) + }) + + it('refuses a later move of the distro only until the earlier one is retained', async () => { + seedWslMoveCutover(testState.dir, committedMove()) + const next = { + ...wslMoveCutoverFixture('move-2', 'prepared', {}, { repoIds: ['repo-b'] }), + supersedesMigrationId: 'move-1' + } + expect(() => writeWslMoveCutover(testState.dir, next)).toThrow('wsl_move_already_in_flight') + + await runWslMoveDesktopRewritePass({ userDataPath: testState.dir }) + recordRendererStores() + await retain() + + writeWslMoveCutover(testState.dir, next) + expect(journal().map((entry) => [entry.migrationId, entry.phase])).toEqual([ + ['move-1', 'retained'], + ['move-2', 'prepared'] + ]) + }) + + it('changes nothing with the gate closed', async () => { + seedWslMoveCutover(testState.dir, committedMove()) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + const ui = store.getUI() + const session = store.getWorkspaceSession() + + await runGatedWslMoveDesktopRewritePass({ userDataPath: testState.dir }) + await runWslMoveStartupRewrite({ userDataPath: testState.dir }) + expect( + recordWslMoveRendererStoreRewritten(testState.dir, { + migrationId: 'move-1', + storeId: 'quick-open-history' + }) + ).toEqual({ recorded: false, reason: 'gated-off' }) + + expect(journal()[0]).toMatchObject({ phase: 'destination-committed', rewrittenStores: [] }) + expect(store.getUI()).toEqual(ui) + expect(store.getWorkspaceSession()).toEqual(session) + }) + + it('changes nothing without a journal', async () => { + const ui = store.getUI() + const session = store.getWorkspaceSession() + + await runWslMoveStartupRewrite({ userDataPath: testState.dir }) + + expect(existsSync(wslMoveJournalDirectory(testState.dir))).toBe(false) + expect(enableAutomations).not.toHaveBeenCalled() + expect(store.getUI()).toEqual(ui) + expect(store.getWorkspaceSession()).toEqual(session) + }) + + it("holds an open window's saves from the rewrite until its reloaded page has loaded", async () => { + windowOpen = true + seedWslMoveCutover(testState.dir, committedMove()) + loadWslMoveFence(testState.dir) + await runWslMoveDesktopRewritePass({ userDataPath: testState.dir }) + expect(isWslMoveWindowWriteHeld()).toBe(true) + expectFocusOnHost() + expect(hostTabIds()).toEqual(['tab-1']) + await new Promise((resolve) => setImmediate(resolve)) + expect(reloads).toHaveBeenCalledTimes(1) + + pageLoaded?.() + expect(isWslMoveWindowWriteHeld()).toBe(false) + // A later pass finds every window-owned store rewritten: no second hold, no second reload. + recordRendererStores() + await runWslMoveDesktopRewritePass({ userDataPath: testState.dir }) + await new Promise((resolve) => setImmediate(resolve)) + expect(isWslMoveWindowWriteHeld()).toBe(false) + expect(reloads).toHaveBeenCalledTimes(1) + }) + + it('holds and reloads nothing when no window is open', async () => { + seedWslMoveCutover(testState.dir, committedMove()) + loadWslMoveFence(testState.dir) + await runWslMoveDesktopRewritePass({ userDataPath: testState.dir }) + await new Promise((resolve) => setImmediate(resolve)) + expect(isWslMoveWindowWriteHeld()).toBe(false) + expect(reloads).not.toHaveBeenCalled() + expectFocusOnHost() + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-reset.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-reset.test.ts new file mode 100644 index 00000000000..e262ae4bef9 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-reset.test.ts @@ -0,0 +1,329 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogState, + OrcadMigrationManifest +} from '../../../shared/orcad-migration/orcad-migration-manifest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import type { WslMoveCutoverPhase } from '../../../shared/wsl-move/wsl-move-cutover' +import type { OrcadMigrationDestinationCatalog } from '../../ssh/orcad-migration-cutover-coordinator' +import { MAX_ORCAD_MIGRATION_MANIFEST_BYTES } from '../../../shared/orcad-migration/orcad-migration-manifest-validation' +import { + ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES, + orcadMigrationRequestFits +} from '../../ssh/orcad-migration-manifest-export' +import { computeOrcadMigrationManifestSha256 } from '../../orcad/orcad-migration-manifest-digest' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { listWslMoveCutovers, wslMoveJournalDirectory } from './wsl-move-cutover-journal' +import { resolveWslMoveFence } from './wsl-move-fence' +import { + assertWslMoveCommitRequestAllowed, + recoverInterruptedWslMoves, + resetWslMoveToZero +} from './wsl-move-reset' + +let userDataPath: string +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-reset-')) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') +}) +afterEach(() => { + vi.unstubAllEnvs() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +function seed(phase: WslMoveCutoverPhase, migrationId = 'move-1'): void { + seedWslMoveCutover(userDataPath, wslMoveCutoverFixture(migrationId, phase)) +} + +function state( + kind: 'absent' | 'staged' | 'committed', + manifest: OrcadMigrationManifest +): OrcadMigrationCatalogState { + const ids = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } + if (kind === 'absent') { + return { state: 'absent', ...ids } + } + if (kind === 'staged') { + return { state: 'staged', ...ids, stagedAt: '2026-10-16T00:00:00.000Z' } + } + return { + state: 'committed', + ...ids, + receipt: { + version: manifest.version, + ...ids, + source: manifest.source, + importedAt: '2026-10-16T00:00:00.000Z', + repositoryIds: [], + projectGroupIds: [], + folderWorkspaceIds: [] + } + } +} + +function host( + catalog: { current: 'absent' | 'staged' | 'committed' }, + options: { refuseInline?: boolean; durableCommitted?: boolean } = {} +): { + destination: OrcadMigrationDestinationCatalog + calls: string[] +} { + const calls: string[] = [] + return { + calls, + destination: { + readState: async (manifest) => { + assertCarried(manifest, options.refuseInline) + calls.push('read') + return state(catalog.current, manifest) + }, + stage: async () => { + throw new Error('not used') + }, + commit: async (manifest) => { + calls.push('commit') + return state(catalog.current, manifest) + }, + abort: async (manifest): Promise => { + assertCarried(manifest, options.refuseInline) + calls.push('abort') + const aborted = catalog.current === 'staged' + if (aborted) { + catalog.current = 'absent' + } + return { ...state(catalog.current, manifest), aborted } + }, + abortByReference: async (manifest) => { + calls.push('abort-by-reference') + const aborted = catalog.current === 'staged' + if (aborted) { + catalog.current = 'absent' + } + const ids = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } + if (catalog.current === 'committed') { + // An older host omits the flag: its committed answer may not be flushed yet. + return options.durableCommitted === false + ? { ...ids, state: 'committed', aborted: false } + : { ...ids, state: 'committed', aborted: false, durableCommitted: true } + } + return { ...ids, state: 'absent', aborted, durableAbsent: true } + }, + stageChunk: async () => { + throw new Error('not used') + } + } + } +} + +/** A real host closes the socket on a request this large; the fake refuses it the same way. */ +function assertCarried(manifest: OrcadMigrationManifest, refuse = false): void { + if (refuse || !orcadMigrationRequestFits({ manifest })) { + throw new Error('socket closed: request too large') + } +} + +/** + * The widest gap the validators allow: under the manifest cap, so the journal still reads it, yet + * over what one request can carry. New moves are refused this size; older records may hold it. + */ +function seedOversized(phase: WslMoveCutoverPhase): void { + const base = wslMoveCutoverFixture('move-1', phase) + const sized = (padding: number) => { + const { manifestSha256: _digest, ...unsigned } = base.manifest + const payload = { + ...unsigned.payload, + repositories: unsigned.payload.repositories.map((repo) => ({ + ...repo, + displayName: 'x'.repeat(padding) + })) + } + const signed = { ...unsigned, payload } + return { ...signed, manifestSha256: computeOrcadMigrationManifestSha256(signed) } + } + const overhead = JSON.stringify(sized(0)).length + const manifest = sized(MAX_ORCAD_MIGRATION_MANIFEST_BYTES - overhead - 16) + expect(JSON.stringify(manifest).length).toBeLessThanOrEqual(MAX_ORCAD_MIGRATION_MANIFEST_BYTES) + expect(JSON.stringify({ manifest }).length).toBeGreaterThan( + ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES + ) + // Written past the journal's size cap on purpose: a record from before the cap must still reset. + seedWslMoveCutover(userDataPath, base) + writeFileSync( + join(wslMoveJournalDirectory(userDataPath), `${base.migrationId}.json`), + JSON.stringify({ + ...base, + manifestSha256: manifest.manifestSha256, + manifest: { ...manifest, source: base.manifest.source } + }) + ) +} + +const fenced = (): boolean => resolveWslMoveFence(userDataPath).fenced.repoIds.has('repo-1') + +describe('resetWslMoveToZero', () => { + it('lifts a move that never left the source, with no host to ask', async () => { + seed('source-fenced') + expect(fenced()).toBe(true) + expect(await resetWslMoveToZero({ userDataPath, destination: null }, 'move-1')).toEqual({ + outcome: 'reset', + evidence: 'commit-never-requested' + }) + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + expect(fenced()).toBe(false) + }) + + it('resets a staged move without the host: no commit can have been requested', async () => { + seed('destination-staged') + expect(resolveWslMoveFence(userDataPath).sessionFrozen.repoIds.has('repo-1')).toBe(true) + expect(await resetWslMoveToZero({ userDataPath, destination: null }, 'move-1')).toMatchObject({ + outcome: 'reset' + }) + expect(resolveWslMoveFence(userDataPath).sessionFrozen.repoIds.size).toBe(0) + }) + + it('keeps a move that may have committed fenced until the host answers', async () => { + seed('blobs-transferred') + expect(await resetWslMoveToZero({ userDataPath, destination: null }, 'move-1')).toEqual({ + outcome: 'unverifiable', + reason: 'destination_unreachable' + }) + expect(fenced()).toBe(true) + }) + + it('aborts the host staging, then resets', async () => { + seed('blobs-transferred') + const { destination, calls } = host({ current: 'staged' }) + expect(await resetWslMoveToZero({ userDataPath, destination }, 'move-1')).toEqual({ + outcome: 'reset', + evidence: 'catalog-absent' + }) + expect(calls).toEqual(['read', 'abort']) + expect(fenced()).toBe(false) + }) + + it('moves forward when the host committed, recording only an acknowledged commit', async () => { + seed('blobs-transferred') + const { destination, calls } = host({ current: 'committed' }) + const result = await resetWslMoveToZero({ userDataPath, destination }, 'move-1') + expect(result.outcome).toBe('committed') + expect(calls).toEqual(['read', 'commit']) + expect(listWslMoveCutovers(userDataPath)[0]?.phase).toBe('destination-committed') + expect(resolveWslMoveFence(userDataPath).hidden.repoIds.has('repo-1')).toBe(true) + }) + + it('keeps the fence when the host reports a commit no request could have made', async () => { + seed('destination-staged') + const { destination } = host({ current: 'committed' }) + expect(await resetWslMoveToZero({ userDataPath, destination }, 'move-1')).toEqual({ + outcome: 'unverifiable', + reason: 'committed_without_commit_request' + }) + expect(fenced()).toBe(true) + }) + + it('aborts a manifest too large for any request by its identity, then resets', async () => { + seedOversized('blobs-transferred') + const { destination, calls } = host({ current: 'staged' }) + expect(await resetWslMoveToZero({ userDataPath, destination }, 'move-1')).toEqual({ + outcome: 'reset', + evidence: 'catalog-absent' + }) + expect(calls).toEqual(['abort-by-reference']) + expect(fenced()).toBe(false) + }) + + it('moves an oversized move forward on the host receipt when a commit was requested', async () => { + seedOversized('blobs-transferred') + const { destination, calls } = host({ current: 'committed' }) + expect((await resetWslMoveToZero({ userDataPath, destination }, 'move-1')).outcome).toBe( + 'committed' + ) + expect(calls).toEqual(['abort-by-reference']) + expect(resolveWslMoveFence(userDataPath).hidden.repoIds.has('repo-1')).toBe(true) + }) + + it('never moves forward on a committed answer the host did not flush first', async () => { + seedOversized('blobs-transferred') + const { destination } = host({ current: 'committed' }, { durableCommitted: false }) + expect(await resetWslMoveToZero({ userDataPath, destination }, 'move-1')).toEqual({ + outcome: 'unverifiable', + reason: 'commit_not_proven_durable' + }) + expect(fenced()).toBe(true) + expect(listWslMoveCutovers(userDataPath)[0]?.phase).toBe('blobs-transferred') + }) + + it('keeps an oversized move fenced on a commit no request could have made', async () => { + seedOversized('destination-staged') + const { destination } = host({ current: 'committed' }) + expect(await resetWslMoveToZero({ userDataPath, destination }, 'move-1')).toEqual({ + outcome: 'unverifiable', + reason: 'committed_without_commit_request' + }) + expect(fenced()).toBe(true) + }) + + it('falls back to abort by identity when the host refuses the manifest inline', async () => { + seed('blobs-transferred') + const { destination, calls } = host({ current: 'staged' }, { refuseInline: true }) + expect(await resetWslMoveToZero({ userDataPath, destination }, 'move-1')).toEqual({ + outcome: 'reset', + evidence: 'catalog-absent' + }) + expect(calls).toEqual(['abort-by-reference']) + }) + + it('leaves a committed move to the rewrite pass', async () => { + seed('destination-committed') + expect((await resetWslMoveToZero({ userDataPath, destination: null }, 'move-1')).outcome).toBe( + 'committed' + ) + expect(listWslMoveCutovers(userDataPath)).toHaveLength(1) + }) +}) + +describe('assertWslMoveCommitRequestAllowed', () => { + it('allows a commit request only from blobs-transferred', () => { + for (const phase of ['quiesced', 'destination-staged', 'destination-committed'] as const) { + expect(() => assertWslMoveCommitRequestAllowed({ phase })).toThrow( + 'wsl_move_commit_before_blobs_transferred' + ) + } + expect(() => assertWslMoveCommitRequestAllowed({ phase: 'blobs-transferred' })).not.toThrow() + }) +}) + +describe('recoverInterruptedWslMoves', () => { + it('resets a move interrupted before any commit request, at startup', async () => { + seed('quiesced') + expect(await recoverInterruptedWslMoves(userDataPath, () => {})).toEqual({ + reset: ['move-1'], + waitingForHost: [] + }) + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + }) + + it('keeps a move that may have committed waiting for its host, at startup', async () => { + seed('blobs-transferred') + expect(await recoverInterruptedWslMoves(userDataPath, () => {})).toEqual({ + reset: [], + waitingForHost: ['move-1'] + }) + expect(fenced()).toBe(true) + }) + + it('does nothing with the WSL hosts gate closed', async () => { + seed('quiesced') + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + expect(await recoverInterruptedWslMoves(userDataPath, () => {})).toEqual({ + reset: [], + waitingForHost: [] + }) + expect(listWslMoveCutovers(userDataPath)).toHaveLength(1) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-reset.ts b/src/main/persistence/moving-wsl-projects/wsl-move-reset.ts new file mode 100644 index 00000000000..71b5826c9b8 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-reset.ts @@ -0,0 +1,251 @@ +/** + * Recovery for an interrupted WSL move: back to zero, or forward when the destination committed. + * + * The commit point is the destination's durable `commitCatalog` acknowledgement. The desktop may + * ask for it only once the journal says `blobs-transferred`, so a record short of that phase cannot + * have a commit behind it: it resets without the host. Only `blobs-transferred` needs the host's + * catalog state to decide, and stays fenced while that is unknown. Source rows are never rewritten + * before commit, so lifting the record is the whole reset; a host's leftover staging expires. + */ +import type { + OrcadMigrationCatalogReferenceAbortResult, + OrcadMigrationCatalogState +} from '../../../shared/orcad-migration/orcad-migration-manifest' +import { + WSL_MOVE_CUTOVER_PHASES, + type WslMoveCutover, + type WslMoveCutoverPhase +} from '../../../shared/wsl-move/wsl-move-cutover' +import { + abortWithRecovery, + confirmDurableCommit, + type OrcadMigrationDestinationCatalog +} from '../../ssh/orcad-migration-cutover-coordinator' +import { orcadMigrationRequestFits } from '../../ssh/orcad-migration-manifest-export' +import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import { + listWslMoveCutovers, + removeUncommittedWslMoveCutover, + writeWslMoveCutover +} from './wsl-move-cutover-journal' +import { recordWslMoveOwedHostAbort } from './wsl-move-owed-host-aborts' + +/** The only phase from which the desktop may ask the destination to commit. */ +export const WSL_MOVE_COMMIT_REQUEST_PHASE: WslMoveCutoverPhase = 'blobs-transferred' + +/** A move driver calls this right before `commitCatalog`; recovery relies on it. */ +export function assertWslMoveCommitRequestAllowed(cutover: Pick): void { + if (cutover.phase !== WSL_MOVE_COMMIT_REQUEST_PHASE) { + throw new Error('wsl_move_commit_before_blobs_transferred') + } +} + +export type WslMoveResetResult = + | { + outcome: 'reset' + evidence: 'no-record' | 'commit-never-requested' | 'catalog-absent' | 'distro-unregistered' + } + | { outcome: 'committed'; cutover: WslMoveCutover } + | { outcome: 'unverifiable'; reason: string } + +export type WslMoveResetContext = { + userDataPath: string + /** The WSL host's catalog; null while it is unreachable. */ + destination: OrcadMigrationDestinationCatalog | null + /** + * The distro is proven unregistered (name and recorded GUID gone): its disk, and any commit on + * it, went too. Residual: an unregister then re-import under a new name between two startups. + */ + destinationUnregistered?: boolean + now?: () => Date +} + +const phaseIndex = (phase: WslMoveCutoverPhase): number => WSL_MOVE_CUTOVER_PHASES.indexOf(phase) +const COMMITTED_FROM = phaseIndex('destination-committed') +// The stage request goes out from `quiesced`, so from there the host may hold one. +const STAGE_MAY_EXIST_FROM = phaseIndex('quiesced') + +export async function resetWslMoveToZero( + context: WslMoveResetContext, + migrationId: string +): Promise { + const cutover = listWslMoveCutovers(context.userDataPath).find( + (entry) => entry.migrationId === migrationId + ) + if (!cutover) { + return { outcome: 'reset', evidence: 'no-record' } + } + if (phaseIndex(cutover.phase) >= COMMITTED_FROM) { + return { outcome: 'committed', cutover } + } + const commitMayExist = cutover.phase === WSL_MOVE_COMMIT_REQUEST_PHASE + const release = ( + evidence: 'commit-never-requested' | 'catalog-absent' | 'distro-unregistered', + hostConfirmedAbsent = false + ): WslMoveResetResult => { + if (!hostConfirmedAbsent && phaseIndex(cutover.phase) >= STAGE_MAY_EXIST_FROM) { + recordWslMoveOwedHostAbort(context.userDataPath, cutover) + } + removeUncommittedWslMoveCutover(context.userDataPath, migrationId) + return { outcome: 'reset', evidence } + } + if (!context.destination) { + if (context.destinationUnregistered) { + return release('distro-unregistered', true) + } + return commitMayExist + ? { outcome: 'unverifiable', reason: 'destination_unreachable' } + : release('commit-never-requested') + } + const destination = context.destination + const byReference = () => + resetByReference({ ...context, destination }, cutover, commitMayExist, release) + if (!orcadMigrationRequestFits({ manifest: cutover.manifest })) { + return byReference() + } + let state: OrcadMigrationCatalogState + try { + state = await destination.readState(cutover.manifest) + if (state.state !== 'committed') { + state = await abortWithRecovery(destination, cutover.manifest) + } + } catch { + // A host with a lower request limit refuses even a manifest this build sizes as fitting. + return commitMayExist ? byReference() : release('commit-never-requested') + } + if (state.state === 'absent') { + return release(commitMayExist ? 'catalog-absent' : 'commit-never-requested', true) + } + if (state.state !== 'committed') { + return { outcome: 'unverifiable', reason: `catalog_${state.state}` } + } + if (!commitMayExist) { + // A commit no request could have made: keep the fence rather than guess which side owns it. + return { outcome: 'unverifiable', reason: 'committed_without_commit_request' } + } + return { outcome: 'committed', cutover: await recordCommit(context, cutover) } +} + +/** + * A manifest too large for any request cannot be read or aborted inline, so the host is asked by + * its identity alone; without this a reset could never prove the host empty and would stay stuck. + */ +async function resetByReference( + context: WslMoveResetContext & { destination: OrcadMigrationDestinationCatalog }, + cutover: WslMoveCutover, + commitMayExist: boolean, + release: ( + evidence: 'commit-never-requested' | 'catalog-absent', + hostConfirmedAbsent?: boolean + ) => WslMoveResetResult +): Promise { + let aborted: OrcadMigrationCatalogReferenceAbortResult + try { + aborted = await context.destination.abortByReference(cutover.manifest) + } catch (error) { + return commitMayExist + ? { outcome: 'unverifiable', reason: describe(error) } + : release('commit-never-requested') + } + if (aborted.state === 'absent') { + return release(commitMayExist ? 'catalog-absent' : 'commit-never-requested', true) + } + if (!commitMayExist) { + return { outcome: 'unverifiable', reason: 'committed_without_commit_request' } + } + // commit() cannot carry this manifest; only a receipt the host flushed before answering counts. + if (aborted.durableCommitted !== true) { + return { outcome: 'unverifiable', reason: 'commit_not_proven_durable' } + } + return { outcome: 'committed', cutover: recordCommitted(context, cutover) } +} + +/** Only commit()'s acknowledgement, which flushes first, may move the journal to committed. */ +async function recordCommit( + context: WslMoveResetContext, + cutover: WslMoveCutover +): Promise { + if (!context.destination) { + throw new Error('wsl_move_destination_unreachable') + } + const state = await confirmDurableCommit(context.destination, cutover.manifest) + if ( + state.state !== 'committed' || + state.migrationId !== cutover.migrationId || + state.manifestSha256 !== cutover.manifestSha256 || + state.receipt.migrationId !== cutover.migrationId || + state.receipt.manifestSha256 !== cutover.manifestSha256 + ) { + throw new Error('wsl_move_destination_state_mismatch') + } + return recordCommitted(context, cutover) +} + +function recordCommitted(context: WslMoveResetContext, cutover: WslMoveCutover): WslMoveCutover { + const committed: WslMoveCutover = { + ...cutover, + phase: 'destination-committed', + updatedAt: (context.now ?? (() => new Date()))().toISOString() + } + writeWslMoveCutover(context.userDataPath, committed) + return committed +} + +export type WslMoveStartupRecovery = { + reset: string[] + /** `blobs-transferred` with no host to ask: still fenced until the host answers. */ + waitingForHost: string[] +} + +/** + * Startup, before anything can start a move: every interrupted move short of commit resets, except + * one that may have committed, which waits for its host. A no-op unless the WSL hosts gate is open. + */ +export async function recoverInterruptedWslMoves( + userDataPath: string, + warn: (message: string) => void = (message) => console.warn(message), + /** Asked only for a move that may have committed; one whose distro is gone resets too. */ + isDistroRegistered?: (distro: string, distroGuid?: string) => Promise +): Promise { + const result: WslMoveStartupRecovery = { reset: [], waitingForHost: [] } + if (!wslHostsGateOpen()) { + return result + } + let inFlight: WslMoveCutover[] + try { + inFlight = listWslMoveCutovers(userDataPath).filter( + (cutover) => phaseIndex(cutover.phase) < COMMITTED_FROM + ) + } catch (error) { + // Unreadable journal: every WSL project stays fenced (scope guard); nothing to reset safely. + warn(`[wsl-move] Could not read the move journal for recovery: ${describe(error)}`) + return result + } + for (const cutover of inFlight) { + try { + const destinationUnregistered = + cutover.phase === WSL_MOVE_COMMIT_REQUEST_PHASE && isDistroRegistered + ? (await isDistroRegistered( + cutover.manifest.source.distro, + cutover.manifest.source.distroGuid + ).catch(() => null)) === false + : false + const outcome = await resetWslMoveToZero( + { userDataPath, destination: null, destinationUnregistered }, + cutover.migrationId + ) + if (outcome.outcome === 'reset') { + result.reset.push(cutover.migrationId) + } else { + result.waitingForHost.push(cutover.migrationId) + } + } catch (error) { + warn(`[wsl-move] Could not reset interrupted move ${cutover.migrationId}: ${describe(error)}`) + } + } + return result +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints-blob.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints-blob.test.ts deleted file mode 100644 index a569c954d66..00000000000 --- a/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints-blob.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createHash } from 'node:crypto' -import { describe, expect, it } from 'vitest' -import { parseOrcadMigrationBlobDescriptor } from '../../../shared/orcad-migration/orcad-migration-blob-transfer' -import { parseOrcadMigrationTerminalRestoreHintsText } from '../../../shared/orcad-migration/orcad-migration-terminal-restore-hints' -import { buildWslMoveRestoreHintsBlob } from './wsl-move-restore-hints-blob' - -const hints = [ - { paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', cwd: '/home/me/src' }, - { paneKey: 'tab-2:22222222-2222-4222-8222-222222222222', cwd: '/srv/app' } -] - -describe('WSL move restore hints blob', () => { - it('sends no blob when quiesce found no hints', () => { - expect(buildWslMoveRestoreHintsBlob([])).toBeNull() - }) - - it('encodes a descriptor the channel accepts over bytes the destination parses back', async () => { - const blob = buildWslMoveRestoreHintsBlob(hints) - expect(blob).not.toBeNull() - if (!blob) { - return - } - expect(parseOrcadMigrationBlobDescriptor(blob.descriptor)).toEqual({ - blobKind: 'terminal-restore-hints', - key: 'migration', - sha256: createHash('sha256').update(blob.bytes).digest('hex'), - byteLength: blob.bytes.length - }) - expect(parseOrcadMigrationTerminalRestoreHintsText(blob.bytes.toString('utf8'))).toEqual({ - version: 1, - hints - }) - const head = await blob.source.readChunk(blob.descriptor, 0, 5) - const rest = await blob.source.readChunk(blob.descriptor, 5, blob.bytes.length) - expect(Buffer.concat([head, rest])).toEqual(blob.bytes) - await expect( - blob.source.readChunk({ ...blob.descriptor, byteLength: 1 }, 0, 1) - ).rejects.toThrow('orcad_migration_source_blob_changed:terminal-restore-hints') - }) - - it('refuses a hint the destination would refuse, and an oversize document', () => { - expect(() => buildWslMoveRestoreHintsBlob([{ paneKey: 'tab-1:1', cwd: '/home' }])).toThrow( - 'orcad_migration_terminal_restore_hint_pane_key_invalid' - ) - const many = Array.from({ length: 1100 }, (_, index) => ({ - paneKey: `tab-${index}:11111111-1111-4111-8111-111111111111`, - cwd: `/${'a'.repeat(4000)}` - })) - expect(() => buildWslMoveRestoreHintsBlob(many)).toThrow( - 'orcad_migration_terminal_restore_hints_too_large' - ) - }) -}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.test.ts index 40b34ed86b3..fb15ee1ffe7 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.test.ts @@ -120,4 +120,29 @@ describe('captureWslMoveRestoreHints', () => { expect(capture.hints).toEqual([{ paneKey: 't:a', cwd: '/home/dev/app' }]) expect(capture.staleCwdPaneKeys).toEqual(['t:a']) }) + + // Bash on the bridge reports no folder, so an open pane's checkpoint kept its + // spawn folder and `cd deep/er` reopened at the project root. + it('prefers the folder the shell was in before Sleep over the spawn folder', async () => { + const capture = await captureWslMoveRestoreHints( + { + detectColdRestoreState: async () => + restored('\\\\wsl.localhost\\Ubuntu\\home\\dev\\app', false, true) + }, + [{ paneKey: 't:a', sessionId: 'wt@@a', shellCwd: '/home/dev/app/deep/er' }], + 'Ubuntu' + ) + expect(capture.hints).toEqual([{ paneKey: 't:a', cwd: '/home/dev/app/deep/er' }]) + expect(capture.staleCwdPaneKeys).toEqual([]) + }) + + it('uses the shell folder when the checkpoint has none', async () => { + const capture = await captureWslMoveRestoreHints( + { detectColdRestoreState: async () => ({ status: 'none' }) }, + [{ paneKey: 't:a', sessionId: 'wt@@a', shellCwd: '/home/dev/app/deep' }], + 'Ubuntu' + ) + expect(capture.hints).toEqual([{ paneKey: 't:a', cwd: '/home/dev/app/deep' }]) + expect(capture.noCwdPaneKeys).toEqual([]) + }) }) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.ts b/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.ts index 5d5075ae418..dd0f17a092a 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.ts @@ -1,6 +1,6 @@ // Restore the last checkpointed cwd after Sleep instead of silently reopening at the worktree root. import type { ColdRestoreDetection } from '../../daemon/history-reader' -import { wslUncPathToLinux } from './wsl-move-path-rewriter' +import { wslUncPathToLinux } from '../../../shared/wsl-paths' export type WslMoveRestoreHint = { paneKey: string; cwd: string } @@ -9,6 +9,11 @@ export type WslMoveRestoreHintPane = { sessionId: string /** Daemon-reported distro; null means a native shell. */ wslDistro?: string | null + /** + * The folder the pane's shell was in, read inside the distro right before Sleep. Bash on the + * bridge reports none, so without it the checkpoint keeps only the spawn folder. + */ + shellCwd?: string } export type WslMoveRestoreHintCapture = { @@ -21,7 +26,7 @@ export type WslMoveRestoreHintCapture = { staleCwdPaneKeys: string[] } -type ColdRestoreReader = { +export type ColdRestoreReader = { detectColdRestoreState: ( sessionId: string, opts?: { ignoreCleanEnd?: boolean; wslDistro?: string } @@ -40,6 +45,11 @@ export async function captureWslMoveRestoreHints( staleCwdPaneKeys: [] } for (const pane of panes) { + const shellCwd = pane.shellCwd && /^\/(?!\/)/.test(pane.shellCwd) ? pane.shellCwd : null + if (shellCwd) { + capture.hints.push({ paneKey: pane.paneKey, cwd: shellCwd }) + continue + } let detection: ColdRestoreDetection try { // Clean Sleep exits still need their cold-restore checkpoints. @@ -59,7 +69,7 @@ export async function captureWslMoveRestoreHints( capture.noCwdPaneKeys.push(pane.paneKey) continue } - const linux = hostLinuxCwd(detection.restoreInfo.cwd, pane, distro) + const linux = wslMoveHostLinuxCwd(detection.restoreInfo.cwd, pane, distro) if (!linux) { capture.noCwdPaneKeys.push(pane.paneKey) continue @@ -74,7 +84,11 @@ export async function captureWslMoveRestoreHints( } // Accept Linux spelling only with evidence the session ran in this distro. -function hostLinuxCwd(cwd: string, pane: WslMoveRestoreHintPane, distro: string): string | null { +export function wslMoveHostLinuxCwd( + cwd: string, + pane: WslMoveRestoreHintPane, + distro: string +): string | null { const fromUnc = wslUncPathToLinux(cwd, distro) if (fromUnc) { return fromUnc diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-retained-marks.ts b/src/main/persistence/moving-wsl-projects/wsl-move-retained-marks.ts new file mode 100644 index 00000000000..fede8eafc1b --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-retained-marks.ts @@ -0,0 +1,43 @@ +/** + * Writes the `orcadRetained` mark on local project rows (mover design, section 4.5). Only this + * machine's rows: the WSL host's own row carries the same id and is never marked. + */ +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import { + sameOrcadRetainedMarker, + type OrcadRetainedMarker +} from '../../../shared/orcad-retained-marker' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Repo } from '../../../shared/repo-types' +import { isLocalWslMoveRow } from './wsl-move-scope-guard' + +/** `marker: null` removes the mark. */ +export type WslMoveRetainedMark = { + kind: 'repo' | 'folder' + id: string + marker: OrcadRetainedMarker | null +} + +/** Mutates the rows in place; returns how many changed so the caller saves only when needed. */ +export function applyWslMoveRetainedMarks( + state: Pick, + marks: readonly WslMoveRetainedMark[] +): number { + let changed = 0 + for (const mark of marks) { + const rows: readonly (Repo | FolderWorkspace)[] = + mark.kind === 'repo' ? state.repos : (state.folderWorkspaces ?? []) + const row = rows.find((entry) => entry.id === mark.id && isLocalWslMoveRow(entry)) + const next = mark.marker ?? undefined + if (!row || sameOrcadRetainedMarker(row.orcadRetained, next)) { + continue + } + if (next) { + row.orcadRetained = { ...next } + } else { + delete row.orcadRetained + } + changed++ + } + return changed +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-retained-source.ts b/src/main/persistence/moving-wsl-projects/wsl-move-retained-source.ts new file mode 100644 index 00000000000..d95e68015c4 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-retained-source.ts @@ -0,0 +1,141 @@ +/** + * A committed WSL move's source rows, as this build keeps them (mover design, section 4.5). The + * journal hides them; the `orcadRetained` mark records, on the row itself, which move took it and + * whether an older build changed it since. Only identity counts as a change, as for SSH: an older + * build's tabs and edits inside a moved project stay in the retained rows. + */ +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { OrcadRetainedMarker } from '../../../shared/orcad-retained-marker' +import type { Repo } from '../../../shared/repo-types' +import { + WSL_MOVE_CUTOVER_PHASES, + type WslMoveCutover +} from '../../../shared/wsl-move/wsl-move-cutover' +import { createUncToLinuxPathRewriter } from './wsl-move-path-rewriter' +import type { WslMoveRetainedMark } from './wsl-move-retained-marks' +import { isLocalWslMoveRow } from './wsl-move-scope-guard' + +export type WslMoveRowKind = 'repo' | 'folder' +export type WslMoveRowRef = { kind: WslMoveRowKind; id: string } + +type IdentityRow = Pick | FolderIdentityRow +type FolderIdentityRow = Pick + +function isFolderRow(row: IdentityRow): row is FolderIdentityRow { + return 'folderPath' in row +} + +/** Id, Linux path and name: what the host's copy of the row was created from. */ +function identity(row: IdentityRow, toLinux: (path: string) => string): string { + return isFolderRow(row) + ? JSON.stringify([row.id, toLinux(row.folderPath), row.name]) + : JSON.stringify([row.id, toLinux(row.path), row.displayName]) +} + +/** The local row as the move would export it today. */ +export function wslMoveLocalRowIdentity(row: IdentityRow, distro: string): string { + const rewriter = createUncToLinuxPathRewriter(distro) + return identity(row, (path) => rewriter.path(path, 'absolute')) +} + +/** The row as the move took it; null when its manifest does not carry the row. */ +export function wslMoveTakenRowIdentity( + cutover: Pick, + ref: WslMoveRowRef +): string | null { + const { payload } = cutover.manifest + const row = + ref.kind === 'repo' + ? payload.repositories.find((entry) => entry.id === ref.id) + : payload.folderWorkspaces.find((entry) => entry.id === ref.id) + // Manifest paths are already in Linux form. + return row ? identity(row, (path) => path) : null +} + +export function wslMoveScopeRefs(cutover: Pick): WslMoveRowRef[] { + const { scopeRepoIds, scopeFolderWorkspaceIds } = cutover.manifest.source + return [ + ...scopeRepoIds.map((id) => ({ kind: 'repo' as const, id })), + ...scopeFolderWorkspaceIds.map((id) => ({ kind: 'folder' as const, id })) + ] +} + +export const wslMoveRowKey = (ref: WslMoveRowRef): string => `${ref.kind}:${ref.id}` + +const COMMITTED_FROM = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') + +export type WslMoveRetainedMarkPlan = { + marks: WslMoveRetainedMark[] + /** Retained rows whose identity an older build changed: candidates for a later move. */ + changed: WslMoveRowRef[] +} + +/** + * The marks every local row should carry. `chains` are each distro's moves, oldest first; a move + * in `retaining` counts as retained, since its marks are what make it so. + */ +export function planWslMoveRetainedMarks(input: { + chains: readonly (readonly Readonly[])[] + retaining: ReadonlySet + repos: readonly Repo[] + folderWorkspaces: readonly FolderWorkspace[] + now: string +}): WslMoveRetainedMarkPlan { + const owners = new Map>() + // Committed moves by id, with the rows each took. + const committed = new Map>() + for (const chain of input.chains) { + for (const cutover of chain) { + if (WSL_MOVE_CUTOVER_PHASES.indexOf(cutover.phase) >= COMMITTED_FROM) { + committed.set(cutover.migrationId, new Set(wslMoveScopeRefs(cutover).map(wslMoveRowKey))) + } + if (cutover.phase !== 'retained' && !input.retaining.has(cutover.migrationId)) { + continue + } + // Oldest first, so a later move that took the row again wins. + for (const ref of wslMoveScopeRefs(cutover)) { + owners.set(wslMoveRowKey(ref), cutover) + } + } + } + const plan: WslMoveRetainedMarkPlan = { marks: [], changed: [] } + const visit = ( + ref: WslMoveRowRef, + row: IdentityRow, + current: OrcadRetainedMarker | undefined + ) => { + const owner = owners.get(wslMoveRowKey(ref)) + if (!owner) { + // A dropped move ("Use on this computer again"), or one that never took this row. + if (current && !committed.get(current.migrationId)?.has(wslMoveRowKey(ref))) { + plan.marks.push({ ...ref, marker: null }) + } + return + } + const changed = + wslMoveTakenRowIdentity(owner, ref) !== + wslMoveLocalRowIdentity(row, owner.manifest.source.distro) + if (changed) { + plan.changed.push(ref) + } + const keptChangedAt = + current?.migrationId === owner.migrationId ? current.sourceChangedAt : undefined + const marker: OrcadRetainedMarker = { + migrationId: owner.migrationId, + environmentId: owner.destinationEnvironmentId, + ...(changed ? { sourceChangedAt: keptChangedAt ?? input.now } : {}) + } + plan.marks.push({ ...ref, marker }) + } + for (const repo of input.repos) { + if (isLocalWslMoveRow(repo)) { + visit({ kind: 'repo', id: repo.id }, repo, repo.orcadRetained) + } + } + for (const folder of input.folderWorkspaces) { + if (isLocalWslMoveRow(folder)) { + visit({ kind: 'folder', id: folder.id }, folder, folder.orcadRetained) + } + } + return plan +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-retention.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-retention.test.ts new file mode 100644 index 00000000000..d6b73d205db --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-retention.test.ts @@ -0,0 +1,379 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { Repo } from '../../../shared/repo-types' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import type { Store } from '../../persistence' +import { + closeTestStores, + createStore, + makeRepo, + readPersistedStateJson, + testState, + writeDataFile +} from '../persistence-test-harness' +import { visibleFolderWorkspaces, visibleRepos } from '../../ssh/orcad-retained-source' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { + listWslMoveCutovers, + removeWslMoveCutover, + wslMoveJournalDirectory +} from './wsl-move-cutover-journal' +import { planWslMove } from './wsl-move-delta-plan' +import { installWslMoveRetention, runWslMoveRetentionPass } from './wsl-move-retention' +import { loadWslMoveFence, resetWslMoveFenceForTests } from './wsl-move-scope-guard' +import { bringWslMovedProjectBack } from './wsl-move-use-locally' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +const UNC = '\\\\wsl.localhost\\Ubuntu' +const NOW = new Date('2026-10-20T00:00:00.000Z') +const MARK = { migrationId: 'move-1', environmentId: 'wsl:Ubuntu' } + +const group: ProjectGroup = { + id: 'group', + name: 'Ubuntu folders', + parentPath: `${UNC}\\srv`, + connectionId: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 +} + +function folder(id: string, folderPath: string, connectionId: string | null = null) { + return { + id, + projectGroupId: 'group', + name: id, + folderPath, + connectionId, + linkedTask: null, + comment: 'kept', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } satisfies FolderWorkspace +} + +const movedRepo = makeRepo({ + id: 'repo-a', + displayName: 'repo-a', + path: `${UNC}\\home\\dev\\repo-a`, + worktreeBaseRef: 'origin/main' +}) +const stayingRepo = makeRepo({ id: 'repo-w', displayName: 'Windows', path: 'C:\\src\\w' }) +const sshRepo = makeRepo({ id: 'repo-a', displayName: 'remote', path: '/r', connectionId: 'ssh-1' }) + +function profile(overrides: Partial = {}): PersistedState { + return { + ...getDefaultPersistedState(testState.dir), + repos: [movedRepo, stayingRepo, sshRepo], + projectGroups: [group], + folderWorkspaces: [folder('notes', `${UNC}\\srv\\notes`), folder('remote', '/srv/r', 'ssh-1')], + ...overrides + } +} + +function persisted(): PersistedState { + return JSON.parse(readPersistedStateJson()) +} + +const retainedMove = (phase: 'client-rewritten' | 'retained' = 'client-rewritten') => + wslMoveCutoverFixture('move-1', phase, {}, { repoIds: ['repo-a'], folderWorkspaceIds: ['notes'] }) + +const localRow = (rows: T[]): T[] => + rows.filter((row) => !row.connectionId) + +let store: Store +function open(state: PersistedState): Store { + writeDataFile(state) + store = createStore() + loadWslMoveFence(testState.dir) + return store +} +const pass = () => + runWslMoveRetentionPass({ userDataPath: testState.dir, store, now: () => NOW, warn: () => {} }) + +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'wsl-move-retention-')) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') +}) +afterEach(async () => { + vi.unstubAllEnvs() + resetWslMoveFenceForTests() + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) +}) + +describe('WSL move retention', () => { + it('marks the source rows durably, then retains the move; nothing is deleted', async () => { + seedWslMoveCutover(testState.dir, retainedMove()) + open(profile()) + + const report = await pass() + + expect(report).toEqual({ retainedMigrationIds: ['move-1'], rewrittenMarks: 2, changed: [] }) + expect(listWslMoveCutovers(testState.dir)[0]?.phase).toBe('retained') + const saved = persisted() + expect(saved.repos).toHaveLength(3) + expect(localRow(saved.repos).find((repo) => repo.id === 'repo-a')).toEqual({ + ...movedRepo, + orcadRetained: MARK + }) + expect(saved.folderWorkspaces?.find((row) => row.id === 'notes')?.orcadRetained).toEqual(MARK) + // The SSH rows sharing an id, and the Windows project, are never marked. + expect(saved.repos.find((repo) => repo.connectionId === 'ssh-1')?.orcadRetained).toBeUndefined() + expect(saved.repos.find((repo) => repo.id === 'repo-w')?.orcadRetained).toBeUndefined() + expect( + saved.folderWorkspaces?.find((row) => row.id === 'remote')?.orcadRetained + ).toBeUndefined() + // Hidden by the same lists SSH uses. + const userData = () => testState.dir + expect( + visibleRepos(store, userData).map((repo) => [repo.id, repo.connectionId ?? null]) + ).toEqual([ + ['repo-w', null], + ['repo-a', 'ssh-1'] + ]) + expect(visibleFolderWorkspaces(store, userData).map((row) => row.id)).toEqual(['remote']) + }) + + it('downgrade then re-upgrade: re-hides, re-marks, flags the edit and loses no row', async () => { + seedWslMoveCutover(testState.dir, retainedMove()) + open(profile()) + await pass() + await closeTestStores() + resetWslMoveFenceForTests() + + // An older build: it never reads the WSL journal, strips the marks it does not know, and the + // user renames the moved project and adds a new one in the same distro. + const downgraded = persisted() + const strip = ({ + orcadRetained: _stripped, + ...row + }: T) => row + const added = makeRepo({ id: 'repo-b', displayName: 'repo-b', path: `${UNC}\\home\\dev\\b` }) + const rows = downgraded.repos + .map(strip) + .map((repo) => + repo.id === 'repo-a' && !repo.connectionId ? { ...repo, displayName: 'renamed' } : repo + ) + open({ + ...downgraded, + repos: [...rows, added], + folderWorkspaces: downgraded.folderWorkspaces?.map(strip) + }) + expect(store.getRepos().some((repo) => repo.orcadRetained)).toBe(false) + + const report = await pass() + + expect(report).toEqual({ + retainedMigrationIds: [], + rewrittenMarks: 2, + changed: [{ kind: 'repo', id: 'repo-a' }] + }) + const saved = persisted() + const local = localRow(saved.repos) + expect(local.find((repo) => repo.id === 'repo-a')).toEqual({ + ...movedRepo, + displayName: 'renamed', + orcadRetained: { ...MARK, sourceChangedAt: NOW.toISOString() } + }) + expect(saved.folderWorkspaces?.find((row) => row.id === 'notes')).toMatchObject({ + comment: 'kept', + orcadRetained: MARK + }) + expect(local.map((repo) => repo.id).sort()).toEqual(['repo-a', 'repo-b', 'repo-w']) + expect( + visibleRepos(store, () => testState.dir) + .filter((repo) => !repo.connectionId) + .map((repo) => repo.id) + .sort() + ).toEqual(['repo-b', 'repo-w']) + // Offered, never run: the edit is listed and only the new project would move. + const planned = planWslMove( + testState.dir, + { + repos: store.getRepos(), + folderWorkspaces: store.getFolderWorkspaces(), + projectGroups: store.getProjectGroups() + }, + 'Ubuntu', + { repoIds: ['repo-a'], folderWorkspaceIds: ['notes'], highestGeneration: 1 } + ) + expect(planned).toMatchObject({ + outcome: 'ready', + plan: { repoIds: ['repo-b'], changed: [{ kind: 'repo', id: 'repo-a' }] } + }) + expect(listWslMoveCutovers(testState.dir).map((entry) => entry.migrationId)).toEqual(['move-1']) + + // A later start keeps the first time it saw the change. + const again = await runWslMoveRetentionPass({ + userDataPath: testState.dir, + store, + now: () => new Date('2026-11-01T00:00:00.000Z') + }) + expect(again?.rewrittenMarks).toBe(0) + }) + + it('drops the marks once the move is dropped, so the rows list again', async () => { + seedWslMoveCutover(testState.dir, retainedMove()) + open(profile()) + await pass() + + expect(bringWslMovedProjectBack(testState.dir, { repoId: 'repo-a' }).released).toBe(true) + const report = await pass() + + expect(report?.rewrittenMarks).toBe(2) + expect(store.getRepos().some((repo) => repo.orcadRetained)).toBe(false) + expect(store.getFolderWorkspaces().some((row) => row.orcadRetained)).toBe(false) + expect(visibleRepos(store, () => testState.dir)).toHaveLength(3) + }) + + it('changes nothing with no journal: SSH and folder workspaces untouched', async () => { + open(profile()) + const before = persisted() + + expect(await pass()).toEqual({ retainedMigrationIds: [], rewrittenMarks: 0, changed: [] }) + + expect(persisted().repos).toEqual(before.repos) + expect(persisted().folderWorkspaces).toEqual(before.folderWorkspaces) + expect(visibleRepos(store, () => testState.dir)).toHaveLength(3) + expect(visibleFolderWorkspaces(store, () => testState.dir)).toHaveLength(2) + }) + + it('keeps every mark when the journal cannot be read', async () => { + seedWslMoveCutover(testState.dir, retainedMove()) + open(profile()) + await pass() + const dir = wslMoveJournalDirectory(testState.dir) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'broken.json'), '{') + + expect(await pass()).toBeNull() + expect(localRow(store.getRepos()).find((repo) => repo.id === 'repo-a')?.orcadRetained).toEqual( + MARK + ) + }) + + it('does nothing with the internal WSL hosts gate closed', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + seedWslMoveCutover(testState.dir, retainedMove()) + open(profile()) + + expect(await pass()).toBeNull() + expect(listWslMoveCutovers(testState.dir)[0]?.phase).toBe('client-rewritten') + expect(store.getRepos().some((repo: Repo) => repo.orcadRetained)).toBe(false) + }) + + it('marks a delta move with its own id and keeps the earlier move on its rows', async () => { + seedWslMoveCutover(testState.dir, retainedMove('retained')) + open(profile()) + await pass() + seedWslMoveCutover( + testState.dir, + wslMoveCutoverFixture( + 'move-2', + 'client-rewritten', + { supersedesMigrationId: 'move-1' }, + { repoIds: ['repo-z'] } + ) + ) + store.addRepo( + makeRepo({ id: 'repo-z', displayName: 'repo-z', path: `${UNC}\\home\\dev\\repo-z` }) + ) + + expect((await pass())?.retainedMigrationIds).toEqual(['move-2']) + + const marks = localRow(store.getRepos()).map((repo) => [ + repo.id, + repo.orcadRetained?.migrationId + ]) + expect(marks).toEqual( + expect.arrayContaining([ + ['repo-a', 'move-1'], + ['repo-z', 'move-2'] + ]) + ) + }) + + it('leaves the move unretained when its marks cannot be made durable', async () => { + seedWslMoveCutover(testState.dir, retainedMove()) + open(profile()) + const failing = { + getRepos: () => store.getRepos(), + getFolderWorkspaces: () => store.getFolderWorkspaces(), + setWslMoveRetainedMarks: (marks: Parameters[0]) => + store.setWslMoveRetainedMarks(marks), + flushPendingOrThrowAsync: () => Promise.reject(new Error('disk full')) + } + + expect( + await runWslMoveRetentionPass({ userDataPath: testState.dir, store: failing, warn: () => {} }) + ).toBeNull() + expect(listWslMoveCutovers(testState.dir)[0]?.phase).toBe('client-rewritten') + }) + + it('retains a move as soon as the journal says its rewrite finished', async () => { + open(profile()) + const stop = installWslMoveRetention({ userDataPath: testState.dir, store, warn: () => {} }) + try { + seedWslMoveCutover(testState.dir, retainedMove()) + await vi.waitFor(() => { + expect(listWslMoveCutovers(testState.dir)[0]?.phase).toBe('retained') + }) + expect( + localRow(store.getRepos()).find((repo) => repo.id === 'repo-a')?.orcadRetained + ).toEqual(MARK) + } finally { + stop() + } + }) + + it('keeps every mark when a release is refused because a later move builds on it', async () => { + seedWslMoveCutover(testState.dir, retainedMove('retained')) + seedWslMoveCutover( + testState.dir, + wslMoveCutoverFixture( + 'move-2', + 'retained', + { supersedesMigrationId: 'move-1' }, + { repoIds: ['repo-z'] } + ) + ) + open(profile()) + await pass() + + expect(() => removeWslMoveCutover(testState.dir, 'move-1')).toThrow('wsl_move_superseded') + expect((await pass())?.rewrittenMarks).toBe(0) + expect(localRow(store.getRepos()).find((repo) => repo.id === 'repo-a')?.orcadRetained).toEqual( + MARK + ) + }) + + it('drops a mark naming a move that never took the row', async () => { + seedWslMoveCutover(testState.dir, retainedMove('retained')) + open(profile({ repos: [movedRepo, { ...stayingRepo, orcadRetained: MARK }] })) + + await pass() + + expect(store.getRepos().find((repo) => repo.id === 'repo-w')?.orcadRetained).toBeUndefined() + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-retention.ts b/src/main/persistence/moving-wsl-projects/wsl-move-retention.ts new file mode 100644 index 00000000000..ac971bfe4c8 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-retention.ts @@ -0,0 +1,146 @@ +/** + * Retain and reconcile for WSL moves (mover design, section 4.5), extending what + * `reconcileManagedOrcadSshTargets` does for SSH hosts: + * - Retain: a move whose desktop rewrite finished (`client-rewritten`) marks its source rows, makes + * the marks durable, and only then becomes `retained`. Nothing is deleted. + * - Reconcile, on start and after every journal change: put back marks an older build stripped, + * flag rows whose identity it changed (a later move offers them; none runs on its own), and drop + * marks no committed move explains, such as after "Use on this computer again". + */ +import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import type { Store } from '../loading-store/store' +import { + listWslMoveCutoverChain, + onWslMoveJournalChange, + scanWslMoveCutovers, + writeWslMoveCutover +} from './wsl-move-cutover-journal' +import { planWslMoveRetainedMarks, type WslMoveRowRef } from './wsl-move-retained-source' +import { userDataKey } from './wsl-move-scope-guard' + +// Bounded like a desktop rewrite store: a busy profile must not hold every later pass. +export const WSL_MOVE_RETENTION_FLUSH_TIMEOUT_MS = 30_000 + +export type WslMoveRetentionStore = Pick< + Store, + 'getRepos' | 'getFolderWorkspaces' | 'setWslMoveRetainedMarks' | 'flushPendingOrThrowAsync' +> + +export type WslMoveRetentionOptions = { + userDataPath: string + store: WslMoveRetentionStore + flushTimeoutMs?: number + now?: () => Date + warn?: (message: string) => void +} + +export type WslMoveRetentionReport = { + /** Moves this pass advanced to `retained`. */ + retainedMigrationIds: string[] + /** Rows whose mark this pass wrote or removed. */ + rewrittenMarks: number + /** Retained rows an older build changed; a later move of the distro can take the changes. */ + changed: WslMoveRowRef[] +} + +async function runPass(options: WslMoveRetentionOptions): Promise { + const { userDataPath, store } = options + const warn = options.warn ?? ((message: string) => console.warn(message)) + const now = (options.now ?? (() => new Date()))().toISOString() + let chains: ReturnType[] + try { + const keys = new Set(scanWslMoveCutovers(userDataPath).map((cutover) => cutover.journalKey)) + chains = [...keys].map((key) => listWslMoveCutoverChain(userDataPath, key)) + } catch (error) { + // Unreadable: no mark is added or removed, since none can be explained. + warn( + `[wsl-move] Could not read the move journal to reconcile retained rows: ${describe(error)}` + ) + return null + } + const retaining = new Set( + chains + .flat() + .flatMap((cutover) => (cutover.phase === 'client-rewritten' ? [cutover.migrationId] : [])) + ) + const plan = planWslMoveRetainedMarks({ + chains, + retaining, + repos: store.getRepos(), + folderWorkspaces: store.getFolderWorkspaces(), + now + }) + const rewrittenMarks = store.setWslMoveRetainedMarks(plan.marks) + // Also when retaining with nothing new: an earlier pass's marks may be only in memory. + if (rewrittenMarks > 0 || retaining.size > 0) { + // The marks' own generation must land; later unrelated writes need not. + await store.flushPendingOrThrowAsync({ + drainToStableGeneration: false, + signal: AbortSignal.timeout(options.flushTimeoutMs ?? WSL_MOVE_RETENTION_FLUSH_TIMEOUT_MS) + }) + } + const retainedMigrationIds: string[] = [] + for (const migrationId of retaining) { + // Fresh from disk: "Use on this computer again" may have dropped it while the marks flushed. + const current = scanWslMoveCutovers(userDataPath).find( + (cutover) => cutover.migrationId === migrationId + ) + if (current?.phase !== 'client-rewritten') { + continue + } + writeWslMoveCutover(userDataPath, { + ...structuredClone(current), + phase: 'retained', + updatedAt: now + }) + retainedMigrationIds.push(migrationId) + } + return { retainedMigrationIds, rewrittenMarks, changed: plan.changed } +} + +let passQueue: Promise = Promise.resolve() + +/** Never throws; serialized, and a no-op with the internal WSL hosts gate closed. */ +export function runWslMoveRetentionPass( + options: WslMoveRetentionOptions +): Promise { + if (!wslHostsGateOpen()) { + return Promise.resolve(null) + } + const next = passQueue.then(() => + runPass(options).catch((error: unknown) => { + // Marks are idempotent and a move becomes `retained` only after its marks are durable. + ;(options.warn ?? console.warn)( + `[wsl-move] Retaining moved projects paused: ${describe(error)}` + ) + return null + }) + ) + passQueue = next + return next +} + +/** Startup: one pass now, then one after each journal change (coalesced). Returns the stop. */ +export function installWslMoveRetention(options: WslMoveRetentionOptions): () => void { + let queued = false + const schedule = (): void => { + if (queued) { + return + } + queued = true + void passQueue.then(() => { + queued = false + return runWslMoveRetentionPass(options) + }) + } + schedule() + return onWslMoveJournalChange((changedPath) => { + if (userDataKey(changedPath) === userDataKey(options.userDataPath)) { + schedule() + } + }) +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-ledger.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-ledger.test.ts index 7ad67a5a499..3c1f8dff716 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-ledger.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-ledger.test.ts @@ -24,6 +24,22 @@ const committed = (rewrittenStores: string[]) => cutover('move-1', 'destination-committed', { rewrittenStores }) describe('WSL move rewrite ledger', () => { + it('lets the seeded host workspaces only grow, and only while the desktop rewrites', () => { + const seeded = (ids: string[] | undefined) => + cutover('move-1', 'destination-committed', { seededHostWorkspaceIds: ids }) + seedWslMoveCutover(userDataPath, committed([])) + writeWslMoveCutover(userDataPath, seeded(['w1'])) + writeWslMoveCutover(userDataPath, seeded(['w1', 'w2'])) + + expect(() => writeWslMoveCutover(userDataPath, seeded(['w2']))).toThrow( + 'wsl_move_ledger_changed' + ) + expect(() => writeWslMoveCutover(userDataPath, seeded(undefined))).toThrow( + 'wsl_move_ledger_changed' + ) + expect(listWslMoveCutovers(userDataPath)[0]?.seededHostWorkspaceIds).toEqual(['w1', 'w2']) + }) + it('grows append-only while the desktop rewrites', () => { seedWslMoveCutover(userDataPath, committed([])) writeWslMoveCutover(userDataPath, committed(['a'])) @@ -75,6 +91,7 @@ describe('WSL move rewrite ledger', () => { }) it('refuses client-rewritten while the store registry is incomplete', () => { + setWslMoveDesktopRewriteRegistryCompleteForTests(false) seedWslMoveCutover(userDataPath, committed([])) expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'client-rewritten'))).toThrow( 'wsl_move_rewrite_incomplete' diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-retry.ts b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-retry.ts new file mode 100644 index 00000000000..a07bd39f208 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-retry.ts @@ -0,0 +1,71 @@ +/** + * Retries the desktop rewrite pass while a committed move still has steps left, such as automations + * the host could not turn back on yet. A pass runs at startup and after each move; this covers the + * host coming back later in the same run. Backs off, and stops once nothing is left. + */ +import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import { listWslMoveCutovers } from './wsl-move-cutover-journal' +import { runWslMoveDesktopRewritePass } from './wsl-move-desktop-rewrite' +import { pendingWslMoveDesktopRewriteStores } from './wsl-move-desktop-rewrite-stores' +import { + retryWslMoveHostAutomationsLeftOff, + type WslMoveHostAutomationRetryHost +} from './wsl-move-undo-host-automations' + +export const WSL_MOVE_REWRITE_RETRY_DELAYS_MS = [30_000, 2 * 60_000, 10 * 60_000] + +let timer: ReturnType | null = null +let restoreHostAutomations: WslMoveHostAutomationRetryHost | null = null + +/** How a retry turns back on host automations a refused undo left off (main wires the host). */ +export function setWslMoveHostAutomationRestorer(host: WslMoveHostAutomationRetryHost): void { + restoreHostAutomations = host +} + +function hasUnfinishedMove(userDataPath: string): boolean { + try { + // Main-side steps only: renderer stores run on the window's next load, not on a timer. + return listWslMoveCutovers(userDataPath).some( + (cutover) => + (cutover.phase === 'destination-committed' && + pendingWslMoveDesktopRewriteStores(cutover.rewrittenStores).some( + (store) => store.side === 'main' + )) || + (restoreHostAutomations !== null && (cutover.hostAutomationsLeftOff?.length ?? 0) > 0) + ) + } catch { + // Unreadable: the pass cannot advance anything either; startup tries again. + return false + } +} + +/** Idempotent: a retry already waiting keeps its place. No-op with the gate closed. */ +export function scheduleWslMoveRewriteRetry( + userDataPath: string, + delaysMs: readonly number[] = WSL_MOVE_REWRITE_RETRY_DELAYS_MS, + attempt = 0 +): void { + if (timer || !wslHostsGateOpen() || !hasUnfinishedMove(userDataPath)) { + return + } + const delay = delaysMs[Math.min(attempt, delaysMs.length - 1)] ?? 0 + timer = setTimeout(() => { + timer = null + void runWslMoveDesktopRewritePass({ userDataPath }) + .then(() => + restoreHostAutomations + ? retryWslMoveHostAutomationsLeftOff(userDataPath, restoreHostAutomations) + : false + ) + .catch(() => false) + .then(() => scheduleWslMoveRewriteRetry(userDataPath, delaysMs, attempt + 1)) + }, delay) + timer.unref?.() +} + +export function cancelWslMoveRewriteRetryForTests(): void { + if (timer) { + clearTimeout(timer) + timer = null + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-scope.ts b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-scope.ts new file mode 100644 index 00000000000..1347dfb0a3f --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-scope.ts @@ -0,0 +1,43 @@ +/** + * What a committed WSL move's desktop rewrite stores ask of a row: is it one of the moved + * projects, and what are its id and path on the host. Ids keep their repo; only a UNC path of the + * moved distro changes, through the same rewrite the manifest was built with. + */ +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { + normalizeWorkspaceSessionKeyToWorkspaceId, + parseWorkspaceKey +} from '../../../shared/workspace-scope' +import { rewriteOrcadMigrationWorktreeId } from '../../ssh/orcad-migration-path-rewrite' +import type { WslMoveDesktopRewriteContext } from './wsl-move-desktop-rewrite-stores' + +export type WslMoveRewriteScope = { + /** A worktree id, workspace key or host-qualified identity of a moved project. */ + inScope: (owner: string) => boolean + repoInScope: (repoId: string) => boolean + /** The host's id for a moved worktree id; anything else is returned unchanged. */ + worktreeId: (worktreeId: string) => string + /** The host's path for an absolute UNC path of the moved distro; anything else unchanged. */ + path: (value: string) => string + destinationHostId: WslMoveDesktopRewriteContext['destinationHostId'] +} + +export function wslMoveRewriteScope(context: WslMoveDesktopRewriteContext): WslMoveRewriteScope { + const { source } = context.cutover.manifest + const repoIds = new Set(source.scopeRepoIds) + const folderWorkspaceIds = new Set(source.scopeFolderWorkspaceIds) + return { + inScope: (owner) => { + const parsed = parseWorkspaceKey(owner) + if (parsed?.type === 'folder') { + return folderWorkspaceIds.has(parsed.folderWorkspaceId) + } + const id = normalizeWorkspaceSessionKeyToWorkspaceId(owner) + return folderWorkspaceIds.has(id) || repoIds.has(getRepoIdFromWorktreeId(id)) + }, + repoInScope: (repoId) => repoIds.has(repoId), + worktreeId: (worktreeId) => rewriteOrcadMigrationWorktreeId(worktreeId, context.rewritePath), + path: (value) => context.rewritePath.path(value, 'absolute'), + destinationHostId: context.destinationHostId + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-store-coverage.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-store-coverage.test.ts new file mode 100644 index 00000000000..f1d22a01d3b --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-store-coverage.test.ts @@ -0,0 +1,258 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { toPersistedAutomationHostFilter } from '../../../shared/automation-host-filter' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { WSL_MOVE_BROWSER_COOKIES_STORE_ID } from '../../../shared/wsl-move/wsl-move-browser-cookie-copy' +import { + PERSISTED_STATE_DISPOSITIONS, + WORKSPACE_SESSION_DISPOSITIONS, + type WslMoveFieldDisposition +} from '../../../shared/wsl-move/wsl-move-dispositions' +import { OUT_OF_STORE_DISPOSITIONS } from '../../../shared/wsl-move/wsl-move-out-of-store-dispositions' +import type { WslMoveRendererRewriteStoreId } from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types' +import { + LOCAL_STORAGE_MODULE_DISPOSITIONS, + SQLITE_TABLE_DISPOSITIONS +} from '../../../shared/wsl-move/wsl-move-storage-dispositions' +import { UI_STATE_DISPOSITIONS } from '../../../shared/wsl-move/wsl-move-ui-dispositions' +import { registerWslMoveDesktopRewriteRegistry } from './wsl-move-desktop-rewrite-registry' +import { + isWslMoveDesktopRewriteRegistryComplete, + listWslMoveDesktopRewriteStores, + resetWslMoveDesktopRewriteStoresForTests, + WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE, + type WslMoveDesktopRewriteContext +} from './wsl-move-desktop-rewrite-stores' +import { registerWslMoveMainRewriteStores } from './wsl-move-main-rewrite-stores' +import { createUncToLinuxPathRewriter } from './wsl-move-path-rewriter' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { wslMoverProfile } from '../__fixtures__/wsl-mover/wsl-mover-profiles' +import { WSL_MOVE_HOST_AUTOMATIONS_STORE_ID } from './wsl-move-host-automations-rewrite' +import { WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID } from './wsl-move-orchestration-history-rewrite' + +vi.mock('../../browser/browser-partition-cookie-copy', () => ({ + copyBrowserPartitionCookies: vi.fn(), + flushBrowserPartitionCookies: vi.fn() +})) +vi.mock('../../browser/browser-session-registry', () => ({ browserSessionRegistry: {} })) +vi.mock('../../browser/browser-session-startup', () => ({ browserSessionsInitialized: vi.fn() })) +vi.mock('../../browser/browser-route-partition-binding-runtime', () => ({ + activeBrowserRoutePartitionOrcaProfileId: vi.fn(), + currentBrowserRoutePartitionBindingStore: vi.fn() +})) +vi.mock('../../browser/browser-route-partition-storage-dependencies', () => ({ + releaseEvictedBrowserRoutePartitionStorage: vi.fn() +})) + +const UI_ROUTING = 'ui-routing' +const SESSION_GLOBALS = 'session-globals' +const HOST_SESSION_SEED = 'host-session-seed' +const renderer = (id: WslMoveRendererRewriteStoreId): string => id +// Rows whose rewrite shows in another field: the scalar scope stays, the host list it resolves to grows. +const WRITTEN_FIELD: Record = { 'ui.workspaceHostScope': 'visibleWorkspaceHostIds' } + +/** + * The store that carries out each disposition row it names. Keys are `.` after the + * disposition tables; every row here must exist, and every registered store must appear. + */ +const STORE_BY_ROW: Record = { + 'persisted.workspaceSessionsByHostId': HOST_SESSION_SEED, + // A move row, not a desktop rewrite: the host turns back on what the manifest carried disabled. + 'persisted.automations': WSL_MOVE_HOST_AUTOMATIONS_STORE_ID, + 'session.activeWorktreeId': SESSION_GLOBALS, + 'session.activeWorkspaceExecutionHostId': SESSION_GLOBALS, + 'session.activeWorkspaceKey': SESSION_GLOBALS, + // This desktop's copy, laid over the host partition the seed writes. + 'session.markdownFrontmatterVisible': HOST_SESSION_SEED, + 'session.closedTerminalTabTombstonesByTabId': HOST_SESSION_SEED, + 'ui.lastActiveWorktreeId': UI_ROUTING, + 'ui.manualRepoOrder': UI_ROUTING, + 'ui.explorerDisplayRootByWorktree': UI_ROUTING, + 'ui.showDotfilesByWorktree': UI_ROUTING, + 'ui.workspaceCleanup': UI_ROUTING, + 'ui.collapsedGroups': UI_ROUTING, + 'ui.workspaceHostScope': UI_ROUTING, + 'ui.workspaceHostOrder': UI_ROUTING, + 'ui.visibleWorkspaceHostIds': UI_ROUTING, + 'ui.agentsVisibleHostIds': UI_ROUTING, + 'ui.automationHostFilter': UI_ROUTING, + 'localStorage.src/renderer/src/lib/quick-open-file-history.ts': renderer('quick-open-history'), + 'localStorage.src/renderer/src/components/editor/ipynb-kernel-store.ts': + renderer('notebook-environments'), + 'localStorage.src/renderer/src/components/editor/file-view-preference-storage.ts': + renderer('file-view-preferences'), + 'sqlite.orchestration.worker_dispatches': WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID, + 'sqlite.orchestration.worker_terminal_resources': WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID, + 'outOfStore.orchestration.db': WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID, + 'outOfStore.browser partition (cookies, site storage)': WSL_MOVE_BROWSER_COOKIES_STORE_ID +} + +const prefixed = ( + prefix: string, + table: Record +): [string, WslMoveFieldDisposition][] => + Object.entries(table).map(([key, row]) => [`${prefix}.${key}`, row]) + +const ROWS = new Map([ + ...prefixed('persisted', PERSISTED_STATE_DISPOSITIONS), + ...prefixed('session', WORKSPACE_SESSION_DISPOSITIONS), + ...prefixed('ui', UI_STATE_DISPOSITIONS), + ...prefixed('localStorage', LOCAL_STORAGE_MODULE_DISPOSITIONS), + ...prefixed('sqlite', SQLITE_TABLE_DISPOSITIONS), + ...OUT_OF_STORE_DISPOSITIONS.map((row): [string, WslMoveFieldDisposition] => [ + `outOfStore.${row.store}`, + row + ]) +]) + +/** The stores startup registers, through the same entry point. */ +function registeredStoreIds(): string[] { + registerWslMoveDesktopRewriteRegistry({ + profile: { + getUI: vi.fn(), + updateUI: vi.fn(), + getWorkspaceSession: vi.fn(), + patchWorkspaceSession: vi.fn(), + setWorkspaceSession: vi.fn(), + flushPendingOrThrowAsync: vi.fn() + }, + orchestrationDatabasePath: '/nowhere/orchestration.db', + hostAutomations: { enable: vi.fn() }, + browserCookies: { + userDataPath: '/nowhere', + sessionsReady: () => false, + orcaProfileId: () => null, + listEnvironments: () => [], + listBrowserProfiles: () => [], + resolveHostStorage: vi.fn(), + bindHostPartition: vi.fn(), + copyCookies: vi.fn(), + flushCookies: vi.fn() + } + }) + return listWslMoveDesktopRewriteStores().map((store) => store.id) +} + +afterEach(() => { + resetWslMoveDesktopRewriteStoresForTests() +}) + +describe('WSL move desktop rewrite registry coverage', () => { + it('gives every desktop-rewrite disposition row a store', () => { + const rewriteRows = [...ROWS] + .filter(([, row]) => row.disposition === 'rewrite-desktop' || row.rewriteDesktopCopy) + .map(([key]) => key) + expect(rewriteRows.length).toBeGreaterThan(0) + expect(rewriteRows.filter((key) => !Object.hasOwn(STORE_BY_ROW, key))).toEqual([]) + }) + + it('names only rows the disposition tables have', () => { + expect(Object.keys(STORE_BY_ROW).filter((key) => !ROWS.has(key))).toEqual([]) + }) + + it('maps every registered store to a row, and registers every mapped store', () => { + const registered = registeredStoreIds() + const mapped = new Set(Object.values(STORE_BY_ROW)) + expect(registered.filter((id) => !mapped.has(id))).toEqual([]) + expect([...mapped].filter((id) => !registered.includes(id))).toEqual([]) + }) + + it('is marked complete', () => { + expect(WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE).toBe(true) + expect(isWslMoveDesktopRewriteRegistryComplete()).toBe(true) + }) + + it("has the profile's stores write every UI and session row they are mapped to", async () => { + const written = new Set() + const context: WslMoveDesktopRewriteContext = { + cutover: wslMoveCutoverFixture( + 'move-1', + 'destination-committed', + {}, + { repoIds: ['repo-a'] }, + { + version: 1, + worktreeMeta: [], + worktreeLineage: [], + workspaceLineage: [], + sparsePresets: [], + retiredWorktreeNames: [], + retiredWorktreeNamespaces: [], + automations: wslMoverProfile('folders-and-automations', 'destination').state.automations + } + ), + rewritePath: createUncToLinuxPathRewriter('Ubuntu'), + destinationHostId: HOST, + recordSeededHostWorkspaceIds: () => {} + } + registerWslMoveMainRewriteStores({ + getUI: () => ({ ...getDefaultPersistedState('/nowhere').ui, ...movedUi() }), + updateUI: (updates) => { + Object.keys(updates).forEach((key) => written.add(`ui.${key}`)) + }, + getWorkspaceSession: () => ({ ...getDefaultWorkspaceSession(), ...movedSession() }), + patchWorkspaceSession: (patch) => { + Object.keys(patch).forEach((key) => written.add(`session.${key}`)) + }, + setWorkspaceSession: vi.fn(), + flushPendingOrThrowAsync: vi.fn() + }) + for (const store of listWslMoveDesktopRewriteStores()) { + if (store.side === 'main' && (store.id === UI_ROUTING || store.id === SESSION_GLOBALS)) { + await store.run(context) + } + } + const missing = Object.entries(STORE_BY_ROW) + .filter(([, storeId]) => storeId === UI_ROUTING || storeId === SESSION_GLOBALS) + .map(([row]) => { + const field = WRITTEN_FIELD[row] + return field ? `${row.slice(0, row.indexOf('.'))}.${field}` : row + }) + .filter((row) => !written.has(row)) + expect(missing).toEqual([]) + }) +}) + +const HOST = toRuntimeExecutionHostId('wsl:Ubuntu') +const MOVED = 'repo-a::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app' + +/** Every UI row the move rewrites, naming the moved project on this computer. */ +function movedUi(): Partial { + return { + lastActiveWorktreeId: MOVED, + manualRepoOrder: [{ hostId: 'local', repoId: 'repo-a' }], + explorerDisplayRootByWorktree: { [MOVED]: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\app\\src' }, + showDotfilesByWorktree: { [MOVED]: true }, + workspaceCleanup: { + dismissals: { + [`local\0${MOVED}`]: { + worktreeId: MOVED, + executionHostId: 'local', + dismissedAt: 1, + fingerprint: 'f', + classifierVersion: 1 + } + } + }, + collapsedGroups: [`lineage:local|${MOVED}`], + workspaceHostScope: 'local', + visibleWorkspaceHostIds: null, + workspaceHostOrder: ['local'], + agentsVisibleHostIds: ['local'], + automationHostFilter: toPersistedAutomationHostFilter({ + kind: 'host', + host: { authority: { kind: 'desktop' }, selector: { kind: 'self' } } + }) + } +} + +function movedSession(): Partial { + return { + activeWorktreeId: MOVED, + activeWorkspaceKey: worktreeWorkspaceKey(MOVED), + activeWorkspaceExecutionHostId: 'local' + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-round-trip.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-round-trip.test.ts new file mode 100644 index 00000000000..4452b638c85 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-round-trip.test.ts @@ -0,0 +1,335 @@ +/** + * Round trip on every mover fixture, through the production code end to end: the real + * export from a desktop profile store, the real driver, the real import into a host profile store, + * the real retention marks. The host's state must project equal to the source's; then "Use on + * this computer again" (dropping the retained move, as #29833 does) must give back the desktop's + * catalog and session byte for byte, except the PTY bindings a move drops by definition. + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { SESSION_FOCUS_FIELDS } from '../../../shared/workspace-session-host-field-ownership' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import { closeTestStores, testState } from '../persistence-test-harness' +import type { Store } from '../loading-store/store' +import { createWslMoveWorld } from '../__fixtures__/wsl-mover/wsl-move-driver-world' +import { + WSL_MOVER_PROFILE_NAMES, + wslMoverProfile, + wslMoverProfileScope, + type WslMoverProfileName +} from '../__fixtures__/wsl-mover/wsl-mover-profiles' +import { + createWslMoveKillSwitch, + createWslMoveStoreHost +} from '../__fixtures__/wsl-mover/wsl-move-store-host' +import { + openWslMoveRoundTripStore, + readWslMoveRoundTripStoreJson +} from '../__fixtures__/wsl-mover/wsl-move-round-trip-stores' +import { + normalizeWslMoveRoundTripProjection, + WSL_MOVE_ROUND_TRIP_KNOWN_GAPS, + withoutParkedScrollbackRefs +} from '../__fixtures__/wsl-mover/wsl-move-round-trip-normalization' +import { isRecord } from './wsl-move-canonical-form' +import { listWslMoveCutovers, removeWslMoveCutover } from './wsl-move-cutover-journal' +import { runWslMoveDesktopRewritePass } from './wsl-move-desktop-rewrite' +import { + resetWslMoveDesktopRewriteStoresForTests, + setWslMoveDesktopRewriteRegistryCompleteForTests +} from './wsl-move-desktop-rewrite-stores' +import { moveProjectsToWslHost } from './wsl-move-driver' +import { exportWslMoveManifest } from './wsl-move-manifest-export' +import { registerWslMoveMainRewriteStores } from './wsl-move-main-rewrite-stores' +import { runWslMoveRetentionPass } from './wsl-move-retention' +import { loadWslMoveFence, resetWslMoveFenceForTests } from './wsl-move-scope-guard' +import { projectWslMoveState, WSL_MOVE_PROJECTED_STATE_FIELDS } from './wsl-move-state-projection' +import { WSL_MOVE_DROPPED_PTY_BINDING_KEYS } from '../../../shared/wsl-move/wsl-move-dispositions' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 1 })) +})) + +const dirs: string[] = [] +const tempDir = (prefix: string): string => { + const dir = mkdtempSync(join(tmpdir(), prefix)) + dirs.push(dir) + return dir +} + +beforeEach(() => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + setWslMoveDesktopRewriteRegistryCompleteForTests(true) +}) + +afterEach(async () => { + vi.unstubAllEnvs() + resetWslMoveDesktopRewriteStoresForTests() + resetWslMoveFenceForTests() + await closeTestStores() + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +const DROPPED: ReadonlySet = new Set(WSL_MOVE_DROPPED_PTY_BINDING_KEYS) + +// Focus follows the move to the host and stays there: the host keeps its copy (#29833). +const FOCUS: ReadonlySet = new Set(SESSION_FOCUS_FIELDS) + +/** + * The persisted catalog and local session, without the PTY bindings a move drops, and with keys + * sorted: the session is recomposed around the moved rows, which reorders a record's keys. + */ +function sourceBytes(state: PersistedState): string { + const kept: Record = {} + for (const field of [...WSL_MOVE_PROJECTED_STATE_FIELDS, 'workspaceSession'] as const) { + kept[field] = state[field] + } + const session = kept.workspaceSession + if (isRecord(session)) { + kept.workspaceSession = Object.fromEntries( + Object.entries(session) + .filter(([field]) => !FOCUS.has(field)) + // A set of worktree ids; the recomposed session lists the unmoved ones first. + .map(([field, value]) => + field === 'activeWorktreeIdsOnShutdown' && Array.isArray(value) + ? [field, [...value].sort()] + : [field, value] + ) + ) + } + return JSON.stringify( + kept, + (key, value: unknown) => { + if (DROPPED.has(key)) { + return undefined + } + return isRecord(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : 1))) + : value + }, + 1 + ) +} + +type RoundTrip = { + desktop: Store + host: Store + userDataPath: string + desktopDir: string + hostDir: string + before: PersistedState + sourceSession: WorkspaceSessionState +} + +function persistedState(dir: string): PersistedState { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a Store wrote this profile; the projection reads only PersistedState fields. + return JSON.parse(readWslMoveRoundTripStoreJson(dir)) as PersistedState +} + +async function moveFixture( + name: WslMoverProfileName, + options: { + /** What the host's own window focuses on before the move. */ + hostFocus?: Pick + /** Focus this desktop on the moving project, as when the user moves the one they have open. */ + focusMovedProject?: boolean + } = {} +): Promise { + const source = wslMoverProfile(name, 'source') + if (options.focusMovedProject) { + const movedRepoId = [...wslMoverProfileScope(name, 'source').repoIds][0] + const movedWorktree = Object.keys(source.session.tabsByWorktree ?? {}).find((id) => + id.startsWith(`${movedRepoId}::`) + ) + source.session = { + ...source.session, + activeRepoId: movedRepoId ?? null, + activeWorktreeId: movedWorktree ?? null + } + } + // Non-default repo settings, so a move that drops or changes them cannot pass as a default. + const [first, ...rest] = source.state.repos + source.state.repos = [ + { + ...first, + gitUsername: 'dev-user', + hookSettings: { + mode: 'auto', + scripts: { setup: 'make bootstrap', archive: '' }, + setupRunPolicy: 'run-by-default', + setupAgentStartupPolicy: 'start-immediately' + } + }, + ...rest + ] + const desktopDir = tempDir('wsl-round-trip-desktop-') + const desktop = openWslMoveRoundTripStore(desktopDir, { + ...getDefaultPersistedState(desktopDir), + ...source.state, + workspaceSession: { + ...getDefaultPersistedState(desktopDir).workspaceSession, + ...source.session + }, + ...(source.ui ? { ui: { ...getDefaultPersistedState(desktopDir).ui, ...source.ui } } : {}) + } satisfies PersistedState) + // A full write, so the source is the profile as the Store serializes it, like the host's. + desktop.setMobileClientTabSelections(desktop.getMobileClientTabSelections()) + await desktop.flushPendingOrThrowAsync() + const before = persistedState(desktopDir) + const sourceSession = structuredClone(desktop.getWorkspaceSession()) + const hostDir = tempDir('wsl-round-trip-host-') + const host = openWslMoveRoundTripStore( + hostDir, + options.hostFocus + ? { + ...getDefaultPersistedState(hostDir), + workspaceSession: { + ...getDefaultPersistedState(hostDir).workspaceSession, + ...options.hostFocus + } + } + : undefined + ) + const userDataPath = desktopDir + loadWslMoveFence(userDataPath) + registerWslMoveMainRewriteStores(desktop) + const world = createWslMoveWorld(userDataPath) + const kill = createWslMoveKillSwitch() + const hostPort = createWslMoveStoreHost({ store: () => host, kill }) + world.deps.connectHost = async () => hostPort + world.deps.readCatalog = () => ({ + repos: desktop.getRepos(), + folderWorkspaces: desktop.getFolderWorkspaces(), + projectGroups: desktop.getProjectGroups() + }) + world.deps.exportManifest = (request) => exportWslMoveManifest(desktop, request) + world.deps.snapshots = desktop + world.deps.finishOnDesktop = () => runWslMoveDesktopRewritePass({ userDataPath, warn: () => {} }) + const scope = wslMoverProfileScope(name, 'source') + const result = await moveProjectsToWslHost(world.deps, { + distro: scope.distro, + repoIds: [...scope.repoIds], + folderWorkspaceIds: [...scope.folderWorkspaceIds] + }) + expect(result, `${name}: ${JSON.stringify(result)}`).toMatchObject({ + outcome: 'moved', + phase: 'client-rewritten' + }) + await runWslMoveRetentionPass({ userDataPath, store: desktop }) + await desktop.flushPendingOrThrowAsync() + await host.flushPendingOrThrowAsync() + return { desktop, host, userDataPath, desktopDir, hostDir, before, sourceSession } +} + +const HOST = toRuntimeExecutionHostId('wsl:Ubuntu') + +/** What the user sees after the move: the host's catalog, and the session the desktop renders. */ +function destinationProjection(name: WslMoverProfileName, trip: RoundTrip): unknown { + const hostPartition = trip.desktop.getWorkspaceSession(HOST) + const local = trip.desktop.getWorkspaceSession() + // Global fields are read from the local partition only (HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS). + const session = { ...hostPartition, workspaceDocHistory: local.workspaceDocHistory } + return projectWslMoveState( + { state: persistedState(trip.hostDir), session }, + wslMoverProfileScope(name, 'destination') + ) +} + +describe('WSL move round trip on every fixture', () => { + it.each(WSL_MOVER_PROFILE_NAMES)( + '%s: the host after the move projects equal to the source before it', + async (name) => { + const trip = await moveFixture(name) + const source = projectWslMoveState( + { state: trip.before, session: trip.sourceSession }, + wslMoverProfileScope(name, 'source') + ) + const destination = destinationProjection(name, trip) + const pick = (value: unknown, key: string): unknown => + isRecord(value) ? value[key] : undefined + const sessionAfter = withoutParkedScrollbackRefs( + pick(source, 'session'), + pick(destination, 'session') + ) + expect(sessionAfter, 'local-only scrollback became host snapshots').not.toBeNull() + const expected = normalizeWslMoveRoundTripProjection(source) + const actual = normalizeWslMoveRoundTripProjection( + { ...(isRecord(destination) ? destination : {}), session: sessionAfter }, + source + ) + for (const key of Object.keys(isRecord(expected) ? expected : {})) { + expect.soft(pick(actual, key), key).toEqual(pick(expected, key)) + } + // Automations arrive off and come back on through the ledgered host step, never silently. + const enabled = (trip.before.automations ?? []).filter((automation) => automation.enabled) + const [cutover] = listWslMoveCutovers(trip.userDataPath) + expect([...(cutover?.automationsToReenable ?? [])].sort()).toEqual( + enabled + .map((automation) => automation.id) + .filter((id) => persistedState(trip.hostDir).automations?.some((row) => row.id === id)) + .sort() + ) + } + ) + + it.each(WSL_MOVER_PROFILE_NAMES)( + '%s: "Use on this computer again" gives the source back byte for byte, without the host', + async (name) => { + const trip = await moveFixture(name) + const [cutover] = listWslMoveCutovers(trip.userDataPath) + expect(cutover?.phase).toBe('retained') + // The escape hatch (#29833) drops the newest committed move; the host is never asked. + removeWslMoveCutover(trip.userDataPath, cutover.migrationId) + await runWslMoveRetentionPass({ userDataPath: trip.userDataPath, store: trip.desktop }) + await trip.desktop.flushPendingOrThrowAsync() + expect(sourceBytes(persistedState(trip.desktopDir))).toEqual(sourceBytes(trip.before)) + } + ) + + // The project the user had open could not move into a host with its own focus. + it('moves the focused project into a host that already has an active project, keeping its focus', async () => { + const hostFocus = { + activeRepoId: 'repo-already-on-host', + activeWorktreeId: 'repo-already-on-host::/home/dev/other', + activeTabId: 'host-tab' + } + const trip = await moveFixture('heavy-wsl', { hostFocus, focusMovedProject: true }) + expect(trip.sourceSession.activeRepoId).toBe( + [...wslMoverProfileScope('heavy-wsl', 'source').repoIds][0] + ) + // The host keeps its own focus (its unknown worktree id is dropped on load either way). + expect(persistedState(trip.hostDir).workspaceSession).toMatchObject({ + activeRepoId: hostFocus.activeRepoId, + activeTabId: hostFocus.activeTabId + }) + }) + + it('knows each listed gap still differs, so a fix must remove it from the list', async () => { + const trip = await moveFixture('heavy-wsl') + const source = projectWslMoveState( + { state: trip.before, session: trip.sourceSession }, + wslMoverProfileScope('heavy-wsl', 'source') + ) + const destination = destinationProjection('heavy-wsl', trip) + const pick = (value: unknown, key: string): unknown => + isRecord(value) ? value[key] : undefined + for (const field of Object.keys(WSL_MOVE_ROUND_TRIP_KNOWN_GAPS)) { + expect + .soft(pick(pick(destination, 'session'), field), field) + .not.toEqual(pick(pick(source, 'session'), field)) + } + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-running-moves.ts b/src/main/persistence/moving-wsl-projects/wsl-move-running-moves.ts new file mode 100644 index 00000000000..b5dd1b16cf2 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-running-moves.ts @@ -0,0 +1,42 @@ +/** + * The WSL moves this process is running, one per distro; the journal's compare-and-swap orders a + * run against other processes. + */ +import { wslMoveJournalKey } from '../../../shared/wsl-move/wsl-move-cutover' + +export type RunningWslMove = { controller: AbortController; commitRequested: boolean } + +const runningMoves = new Map() + +/** Null when a move of the distro already runs here. */ +export function claimWslMoveRun(distro: string): RunningWslMove | null { + const key = wslMoveJournalKey(distro) + if (runningMoves.has(key)) { + return null + } + const running: RunningWslMove = { controller: new AbortController(), commitRequested: false } + runningMoves.set(key, running) + return running +} + +export function releaseWslMoveRun(distro: string, running: RunningWslMove): void { + const key = wslMoveJournalKey(distro) + if (runningMoves.get(key) === running) { + runningMoves.delete(key) + } +} + +/** True while this process runs a move of the distro, through its desktop rewrite. */ +export function isWslMoveRunning(distro: string): boolean { + return runningMoves.has(wslMoveJournalKey(distro)) +} + +/** Cancels a run that has not asked the host to commit yet; true when one was running. */ +export function cancelWslMove(distro: string): boolean { + const running = runningMoves.get(wslMoveJournalKey(distro)) + if (!running || running.commitRequested) { + return false + } + running.controller.abort() + return true +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts index 17ae9f55ddd..9de59891dfa 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts @@ -6,6 +6,7 @@ import { isWslUncPath } from '../../../shared/wsl-paths' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { onWslMoveJournalChange } from './wsl-move-cutover-journal' import { resolveWslMoveFence, type WslMoveFenceState } from './wsl-move-fence' +import { comparableWslPath, isAtOrUnder } from './wsl-move-comparable-path' import { findProjectHostSetup, type ProjectHostSetupTarget @@ -35,7 +36,7 @@ type FenceView = { readable: true; state: WslMoveFenceState } | { readable: fals let loaded: { userDataKey: string; view: FenceView } | null = null // app.setName can change casing without changing the Windows directory. -function userDataKey(userDataPath: string): string { +export function userDataKey(userDataPath: string): string { const resolved = resolve(userDataPath) return process.platform === 'win32' ? resolved.toLowerCase() : resolved } @@ -240,6 +241,22 @@ export function isWslMoveWorkspaceBlocked( return !!workspaceId && isWslMoveProjectBlocked(store, wslMoveProjectRefForWorkspace(workspaceId)) } +/** + * Whether a new terminal's folder is one a move is taking, whatever workspace it names. A + * Linux-spelled folder names no distro, so it matches any moving distro. An unreadable journal lets + * no move reach commit, so it fences nothing here. + */ +export function isWslMoveCwdFenced(cwd: string): boolean { + const fence = loaded?.view + if (!fence?.readable) { + return false + } + return fence.state.fencedRoots.some(({ distro, roots }) => { + const path = comparableWslPath(cwd, distro) + return path !== null && roots.some((root) => isAtOrUnder(path, root)) + }) +} + export function isWslMoveProjectSessionFrozen(ref: WslMoveProjectRef | null): boolean { const fence = loaded?.view return !!ref && !!fence?.readable && inScope(fence.state.sessionFrozen, ref) @@ -293,6 +310,24 @@ function withoutHidden(rows: T[], toRef: (row: T) => WslMov return rows.filter((row) => !isLocalWslMoveRow(row) || !inScope(hidden, toRef(row))) } +/** The distro whose WSL host owns the project, once a move committed it; null otherwise. */ +export function wslMoveHostDistro(ref: WslMoveProjectRef | null): string | null { + const fence = loaded?.view + if (!ref || !fence?.readable) { + return null + } + const { hostDistro } = fence.state + return ( + ('repoId' in ref + ? hostDistro.repoIds.get(ref.repoId) + : hostDistro.folderWorkspaceIds.get(ref.folderWorkspaceId)) ?? null + ) +} + +export function isWslMoveWorkspaceHostOwned(workspaceId: string): boolean { + return wslMoveHostDistro(wslMoveProjectRefForWorkspace(workspaceId)) !== null +} + export function isLocalWslMoveRow(row: HostedRow): boolean { return !row.connectionId && isWslMoveSourceHost(row.executionHostId) } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-scope-roots.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-scope-roots.test.ts new file mode 100644 index 00000000000..12ca38c482e --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-scope-roots.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { OrcadMigrationDormantStatePayload } from '../../../shared/orcad-migration/orcad-migration-manifest' +import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { wslMoveScopeRootPaths } from './wsl-move-scope-roots' + +const dormant: OrcadMigrationDormantStatePayload = { + version: 1, + worktreeMeta: [], + worktreeLineage: [], + workspaceLineage: [], + sparsePresets: [], + retiredWorktreeNames: [], + retiredWorktreeNamespaces: [], + workspaceSession: { + ...getDefaultWorkspaceSession(), + unifiedTabs: { + 'worktree:repo-1::/home/dev/trees/feature': [], + 'repo-1::/home/dev/trees/fix::workspace:00000000-0000-0000-0000-000000000000': [], + 'folder:folder-1': [] + } + } +} + +describe('wslMoveScopeRootPaths', () => { + it('names each moved repo, folder and carried worktree directory on the host', () => { + const { manifest } = cutover( + 'move-1', + 'destination-committed', + {}, + { repoIds: ['repo-1'], folderWorkspaceIds: ['folder-1'] }, + dormant + ) + expect(wslMoveScopeRootPaths(manifest)).toEqual([ + '/home/dev/repo-1', + '/srv/folder-1', + '/home/dev/trees/feature', + '/home/dev/trees/fix' + ]) + }) + + it('lists only the repos and folders without dormant state', () => { + expect(wslMoveScopeRootPaths(cutover('move-1', 'destination-committed').manifest)).toEqual([ + '/home/dev/repo-1' + ]) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-scope-roots.ts b/src/main/persistence/moving-wsl-projects/wsl-move-scope-roots.ts new file mode 100644 index 00000000000..aecbb39979d --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-scope-roots.ts @@ -0,0 +1,27 @@ +/** + * The host (Linux) directories a committed WSL move took, as its manifest names them: each moved + * repo and folder, and every worktree the move carried meta, lineage or a session for. A store + * keyed by a bare file path uses them to tell a moved project's file from another in the distro. + */ +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest' +import { normalizeWorkspaceSessionKeyToWorkspaceId } from '../../../shared/workspace-scope' +import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' + +export function wslMoveScopeRootPaths(manifest: Pick): string[] { + const { payload } = manifest + const dormant = payload.dormantState + const worktreeIds = [ + ...(dormant?.worktreeMeta ?? []).map((entry) => entry.worktreeId), + ...(dormant?.worktreeLineage ?? []).map((entry) => entry.worktreeId), + ...Object.keys(dormant?.workspaceSession?.unifiedTabs ?? {}).map( + normalizeWorkspaceSessionKeyToWorkspaceId + ) + ] + const roots = new Set([ + ...payload.repositories.map((repo) => repo.path), + ...payload.folderWorkspaces.map((folder) => folder.folderPath), + ...worktreeIds.flatMap((id) => splitWorktreeIdForFilesystem(id)?.worktreePath ?? []) + ]) + // The payload is already in the host's form; anything else is not a moved project's root. + return [...roots].filter((root) => root.startsWith('/') && root !== '/') +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-scope.ts b/src/main/persistence/moving-wsl-projects/wsl-move-scope.ts index eb9f9d7cdab..5c49eff9970 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-scope.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-scope.ts @@ -5,8 +5,11 @@ import { resolveFolderWorkspaceHost } from '../../../shared/folder-workspace-exe import type { FolderWorkspace } from '../../../shared/folder-workspace-types' import type { ProjectGroup } from '../../../shared/project-group-types' import type { Repo } from '../../../shared/repo-types' -import { isDrvfsLinuxPath, resolveWslRepoWorktreeBasePath } from '../../../shared/wsl-paths' -import { wslUncPathToLinux } from './wsl-move-path-rewriter' +import { + isDrvfsLinuxPath, + resolveWslRepoWorktreeBasePath, + wslUncPathToLinux +} from '../../../shared/wsl-paths' export type WslMoveScopeRefusal = /** Distro access does not make Windows-drive projects Linux-owned. */ diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-seeded-view-state.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-seeded-view-state.test.ts new file mode 100644 index 00000000000..013117e24fe --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-seeded-view-state.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { + PersistedOpenFile, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { withWslMoveSeededViewState } from './wsl-move-seeded-view-state' + +const UNC_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\app' +const UNC = `repo-a::${UNC_ROOT}` +const HOST = 'repo-a::/home/dev/app' +const scope = { + destinationEnvironmentId: 'wsl:Ubuntu', + inScope: (owner: string) => owner.startsWith('repo-a::'), + worktreeId: (id: string) => (id === UNC ? HOST : id), + path: (value: string) => value.replace('\\\\wsl.localhost\\Ubuntu', '').replaceAll('\\', '/') +} + +const file = (filePath: string, worktreeId: string, draft?: string): PersistedOpenFile => ({ + filePath, + relativePath: 'README.md', + worktreeId, + language: 'markdown', + ...(draft === undefined ? {} : { dirtyDraftContent: draft, lastKnownDiskSignature: 'sig-1' }) +}) + +const seed = (): WorkspaceSessionState => ({ + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { [HOST]: [file('/home/dev/app/README.md', HOST)] } +}) +const local = { + openFilesByWorktree: { [UNC]: [file(`${UNC_ROOT}\\README.md`, UNC, '# unsaved')] } +} + +describe('withWslMoveSeededViewState', () => { + it("lays this desktop's draft and its disk baseline over the seeded file, owned by the host", () => { + const next = withWslMoveSeededViewState(seed(), local, scope, new Set([HOST])) + + // Without the host as owner the draft opens as this desktop's file, and the mirror drops it. + expect(next?.openFilesByWorktree?.[HOST]).toEqual([ + { ...file('/home/dev/app/README.md', HOST, '# unsaved'), runtimeEnvironmentId: 'wsl:Ubuntu' } + ]) + }) + + it('re-keys frontmatter visibility to the host file, and moves closed-tab tombstones', () => { + const localId = `editor:${encodeURIComponent(UNC)}:local:${encodeURIComponent(`${UNC_ROOT}\\README.md`)}` + const next = withWslMoveSeededViewState( + seed(), + { + markdownFrontmatterVisible: { [localId]: false, 'editor:other%3A%3Ax:ssh-1:%2Fx': true }, + closedTerminalTabTombstonesByTabId: { 'tab-old': { closedAt: 1, worktreeId: UNC } } + }, + scope, + new Set([HOST]) + ) + + expect(next?.markdownFrontmatterVisible).toEqual({ + [`editor:${encodeURIComponent(HOST)}:wsl%3AUbuntu:${encodeURIComponent('/home/dev/app/README.md')}`]: false + }) + expect(next?.closedTerminalTabTombstonesByTabId).toEqual({ + 'tab-old': { closedAt: 1, worktreeId: HOST } + }) + }) + + it('leaves a workspace this run did not seed alone, so a file closed on the host stays closed', () => { + expect(withWslMoveSeededViewState(seed(), local, scope, new Set())).toBeNull() + }) + + it('never replaces a draft the seeded record already has', () => { + const drafted = seed() + drafted.openFilesByWorktree = { [HOST]: [file('/home/dev/app/README.md', HOST, '# newer')] } + expect(withWslMoveSeededViewState(drafted, local, scope, new Set([HOST]))).toBeNull() + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-seeded-view-state.ts b/src/main/persistence/moving-wsl-projects/wsl-move-seeded-view-state.ts new file mode 100644 index 00000000000..e06fa2e59fa --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-seeded-view-state.ts @@ -0,0 +1,155 @@ +/** + * This desktop's view state of a moved project that the manifest never carries, laid over the + * host partition's seed: unsaved editor drafts (drafts stay on this desktop, as the SSH move's + * draft rename keeps them), markdown frontmatter visibility (keyed by a UNC file id the export + * cannot rewrite) and closed-terminal-tab tombstones. Only for workspaces seeded in this run, so + * anything the user since changed on the host stays as it is. + */ +import type { ClosedTerminalTabTombstone } from '../../../shared/closed-terminal-tab-tombstones' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { ownedEditorFileId } from '../../../shared/workspace-session-pane-ownership' +import { isHostBoundOpenFile } from '../migrating-orcad-catalog/orcad-source-draft-rename' +import type { WslMoveRewriteScope } from './wsl-move-rewrite-scope' + +type ViewStateScope = Pick & { + /** The environment the moved files open under on this desktop: their editor ids name it. */ + destinationEnvironmentId: string +} + +type LocalViewState = Pick< + WorkspaceSessionState, + 'openFilesByWorktree' | 'markdownFrontmatterVisible' | 'closedTerminalTabTombstonesByTabId' +> + +/** `seed` with this desktop's view state of `seededWorkspaceIds` laid over it; null if unchanged. */ +export function withWslMoveSeededViewState( + seed: WorkspaceSessionState, + local: LocalViewState, + scope: ViewStateScope, + seededWorkspaceIds: ReadonlySet +): WorkspaceSessionState | null { + const seeded = (owner: string): string | null => { + if (!scope.inScope(owner)) { + return null + } + const hostKey = scope.worktreeId(owner) + return seededWorkspaceIds.has(hostKey) ? hostKey : null + } + const drafts = withDrafts(seed, local, scope, seeded) + const frontmatter = withFrontmatter(seed, local, scope, seeded) + const tombstones = withClosedTabTombstones(seed, local, seeded) + if (!drafts && !frontmatter && !tombstones) { + return null + } + return { + ...seed, + ...(drafts ? { openFilesByWorktree: drafts } : {}), + ...(frontmatter ? { markdownFrontmatterVisible: frontmatter } : {}), + ...(tombstones ? { closedTerminalTabTombstonesByTabId: tombstones } : {}) + } +} + +type SeededKey = (owner: string) => string | null + +function withDrafts( + seed: WorkspaceSessionState, + local: LocalViewState, + scope: ViewStateScope, + seeded: SeededKey +): WorkspaceSessionState['openFilesByWorktree'] | null { + const openFilesByWorktree = { ...seed.openFilesByWorktree } + let changed = false + for (const [owner, files] of Object.entries(local.openFilesByWorktree ?? {})) { + const hostKey = seeded(owner) + if (!hostKey) { + continue + } + for (const file of files) { + if (file.dirtyDraftContent === undefined || isHostBoundOpenFile(file)) { + continue + } + const hostPath = scope.path(file.filePath) + const held = openFilesByWorktree[hostKey] ?? [] + const index = held.findIndex( + (entry) => entry.filePath === hostPath && entry.dirtyDraftContent === undefined + ) + if (index === -1) { + continue + } + const next = [...held] + // Owned by the host now, as the SSH move's draft rename makes it, so saves reach the host. + next[index] = { + ...held[index], + runtimeEnvironmentId: scope.destinationEnvironmentId, + dirtyDraftContent: file.dirtyDraftContent, + ...(file.lastKnownDiskSignature !== undefined + ? { lastKnownDiskSignature: file.lastKnownDiskSignature } + : {}) + } + openFilesByWorktree[hostKey] = next + changed = true + } + } + return changed ? openFilesByWorktree : null +} + +/** `editor:::`, each part URI-encoded (ownedEditorFileId). */ +function parseEditorFileId( + id: string +): { worktreeId: string; runtimeKey: string; filePath: string } | null { + const parts = id.split(':') + if (parts.length !== 4 || parts[0] !== 'editor') { + return null + } + try { + const [worktreeId, runtimeKey, filePath] = parts.slice(1).map(decodeURIComponent) + return { worktreeId, runtimeKey, filePath } + } catch { + return null + } +} + +function withFrontmatter( + seed: WorkspaceSessionState, + local: LocalViewState, + scope: ViewStateScope, + seeded: SeededKey +): Record | null { + const next = { ...seed.markdownFrontmatterVisible } + let changed = false + for (const [id, visible] of Object.entries(local.markdownFrontmatterVisible ?? {})) { + const parsed = parseEditorFileId(id) + // Another host's file keeps its own id; only this computer's files moved. + const hostKey = parsed?.runtimeKey === 'local' ? seeded(parsed.worktreeId) : null + if (!parsed || !hostKey) { + continue + } + const hostId = ownedEditorFileId( + scope.path(parsed.filePath), + hostKey, + scope.destinationEnvironmentId + ) + if (!Object.hasOwn(next, hostId)) { + next[hostId] = visible + changed = true + } + } + return changed ? next : null +} + +function withClosedTabTombstones( + seed: WorkspaceSessionState, + local: LocalViewState, + seeded: SeededKey +): Record | null { + const next = { ...seed.closedTerminalTabTombstonesByTabId } + let changed = false + for (const [tabId, tombstone] of Object.entries(local.closedTerminalTabTombstonesByTabId ?? {})) { + const hostKey = seeded(tombstone.worktreeId) + if (hostKey && !Object.hasOwn(next, tabId)) { + next[tabId] = { ...tombstone, worktreeId: hostKey } + changed = true + } + } + return changed ? next : null +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.test.ts index e86fc249c62..c70603b863f 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { Tab, TabGroup } from '../../../shared/tab-types' import type { Store } from '../../persistence' import { closeTestStores, createStore, testState } from '../persistence-test-harness' import { makeTerminalTab } from '../persistence-session-fixtures' @@ -107,6 +108,87 @@ describe('WSL move session freeze', () => { expect(titles(store)).toEqual({ moving: ['slept'], staying: ['edited'] }) }) + describe('once the host owns the project', () => { + const tab = (id: string, contentType: Tab['contentType']): Tab => ({ + id, + entityId: id, + groupId: 'group-1', + worktreeId: MOVING, + contentType, + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + }) + const group = (tabOrder: string[], activeTabId: string | null): TabGroup => ({ + id: 'group-1', + worktreeId: MOVING, + activeTabId, + tabOrder + }) + const withTabs = (base: WorkspaceSessionState, tabs: Tab[], active: string | null) => ({ + ...base, + unifiedTabs: { [MOVING]: tabs }, + tabGroups: { + [MOVING]: [ + group( + tabs.map((entry) => entry.id), + active + ) + ] + } + }) + const movingTabs = (store: Store) => ({ + tabs: store.getWorkspaceSession().unifiedTabs?.[MOVING]?.map((entry) => entry.id), + order: store.getWorkspaceSession().tabGroups?.[MOVING]?.[0]?.tabOrder, + active: store.getWorkspaceSession().tabGroups?.[MOVING]?.[0]?.activeTabId + }) + + it("takes the project's chat tabs as written and keeps its terminals as the quiesce left them", () => { + store.setWorkspaceSession( + withTabs( + session('slept', 'terminal'), + [tab('moving-tab', 'terminal'), tab('agent-session:old', 'agent-session')], + 'moving-tab' + ) + ) + seedWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed')) + // The renderer holds only the chats of a moved project: it dropped the terminal tab. + store.setWorkspaceSession( + withTabs( + session('edited', 'editor'), + [tab('agent-session:old', 'agent-session'), tab('agent-session:new', 'agent-session')], + 'agent-session:new' + ) + ) + expect(titles(store)).toEqual({ moving: ['slept'], staying: ['edited'] }) + expect(movingTabs(store)).toEqual({ + tabs: ['moving-tab', 'agent-session:old', 'agent-session:new'], + order: ['moving-tab', 'agent-session:old', 'agent-session:new'], + active: 'agent-session:new' + }) + + // A closed chat goes; the slept terminal stays. + store.setWorkspaceSession( + withTabs(session('edited', 'editor'), [tab('agent-session:new', 'agent-session')], null) + ) + expect(movingTabs(store)).toEqual({ + tabs: ['moving-tab', 'agent-session:new'], + order: ['moving-tab', 'agent-session:new'], + active: 'agent-session:new' + }) + }) + + it('still refuses chat tab writes while the move is only quiesced', () => { + seedWslMoveCutover(testState.dir, cutover('move-1', 'quiesced')) + store.setWorkspaceSession( + withTabs(session('edited', 'editor'), [tab('agent-session:new', 'agent-session')], null) + ) + expect(movingTabs(store).tabs).toBeUndefined() + }) + }) + it('freezes nothing, with one warning, when the journal cannot be read', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const directory = wslMoveJournalDirectory(testState.dir) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.ts b/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.ts index df647a2dba6..d605a3866bc 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.ts @@ -1,4 +1,9 @@ -// Preserve quiesced workspace rows against later renderer writes; accept unrelated rows and globals. +/** + * Once a move has paused a project, its workspaces' session stays exactly as the quiesce left it: + * that slept state is what the move carries to the host, so a later renderer write must neither + * change nor drop it. Every other workspace's rows, and the global fields, are taken as written. + * Once the host owns the project, its native chat tabs are taken as written too. + */ import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership' import { mergeWorkspaceSessionArrayField, @@ -10,12 +15,21 @@ import { workspaceIdsNamedByPartition } from '../../../shared/workspace-session-stranded-partition-adoption' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { hasWslMoveFrozenSessions, isWslMoveWorkspaceSessionFrozen } from './wsl-move-scope-guard' +import { + hasWslMoveFrozenSessions, + isWslMoveWorkspaceHostOwned, + isWslMoveWorkspaceSessionFrozen +} from './wsl-move-scope-guard' +import { hostOwnedChatTabRows } from './wsl-move-host-owned-chat-tabs' + +const CHAT_TAB_FIELDS = ['unifiedTabs', 'tabGroups', 'tabGroupLayouts'] as const export function keepWslMoveFrozenSessions( next: WorkspaceSessionState, prior: WorkspaceSessionState | undefined, - isFrozen: (workspaceId: string) => boolean + isFrozen: (workspaceId: string) => boolean, + // Committed moves only: the project's existing chats stay on this computer and keep working. + acceptsChatTabs: (workspaceId: string) => boolean = () => false ): WorkspaceSessionState { const frozen = new Set() for (const workspaceId of workspaceIdsNamedByPartition(next)) { @@ -32,9 +46,27 @@ export function keepWslMoveFrozenSessions( return next } const merged = withoutWorkspaces(next, frozen) - if (!prior) { - return merged + if (prior) { + keepPriorRows(merged, prior, frozen) } + for (const workspaceId of frozen) { + const chatRows = acceptsChatTabs(workspaceId) + ? hostOwnedChatTabRows(merged, next, workspaceId) + : null + if (chatRows) { + for (const field of CHAT_TAB_FIELDS) { + mergeWorkspaceSessionRecordField(merged, field, chatRows) + } + } + } + return merged +} + +function keepPriorRows( + merged: WorkspaceSessionState, + prior: WorkspaceSessionState, + frozen: ReadonlySet +): void { const kept = filterWorkspaceRows(prior, (workspaceId) => frozen.has(workspaceId)) for (const [field, ownership] of Object.entries(WORKSPACE_SESSION_FIELD_OWNERSHIP)) { if (!isSessionField(field) || ownership === 'global' || ownership === 'hostPrivate') { @@ -46,7 +78,6 @@ export function keepWslMoveFrozenSessions( mergeWorkspaceSessionRecordField(merged, field, kept) } } - return merged } function isSessionField(field: string): field is keyof WorkspaceSessionState { @@ -60,5 +91,10 @@ export function keepWslMoveFrozenSessionsForStore( if (!hasWslMoveFrozenSessions()) { return next } - return keepWslMoveFrozenSessions(next, prior, isWslMoveWorkspaceSessionFrozen) + return keepWslMoveFrozenSessions( + next, + prior, + isWslMoveWorkspaceSessionFrozen, + isWslMoveWorkspaceHostOwned + ) } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.test.ts new file mode 100644 index 00000000000..87426f0a780 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { Tab } from '../../../shared/tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { wslMoverProfile } from '../__fixtures__/wsl-mover/wsl-mover-profiles' +import { tabGroup, terminalTab } from '../__fixtures__/wsl-mover/wsl-mover-profile-rows' +import { createUncToLinuxPathRewriter } from './wsl-move-path-rewriter' +import { wslMoveRewriteScope } from './wsl-move-rewrite-scope' +import { + withoutDesktopFocus, + wslMoveHostSessionSeed, + wslMoveReleasedSessionFocusPatch, + wslMoveSessionGlobalsPatch +} from './wsl-move-session-rewrite' +import { prepareOrcadMigrationWorkspaceSession } from '../migrating-orcad-catalog/orcad-destination-workspace-session' + +const HOST = toRuntimeExecutionHostId('wsl:Ubuntu') +const scope = wslMoveRewriteScope({ + cutover: wslMoveCutoverFixture( + 'move-1', + 'destination-committed', + {}, + { repoIds: ['repo-a'], folderWorkspaceIds: ['folder-1'] } + ), + rewritePath: createUncToLinuxPathRewriter('Ubuntu'), + destinationHostId: HOST, + recordSeededHostWorkspaceIds: () => {} +}) + +const source = wslMoverProfile('mixed-windows-wsl', 'source') +const MOVED = source.session.activeWorktreeId ?? '' +const MOVED_ON_HOST = 'repo-a::/home/dev/app' +const FEATURE_ON_HOST = 'repo-a::/home/dev/app-feature' +const SAME_DISTRO_ON_HOST = 'repo-b::/home/dev/lib' + +const chatTab: Tab = { + id: 'chat-1', + entityId: 'agent-session-1', + groupId: 'group-1', + worktreeId: MOVED_ON_HOST, + contentType: 'agent-session', + label: 'Claude', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 0 +} + +/** The session the move carried, as the manifest holds it: host ids, a chat, live PTY ids. */ +function carriedSession(): WorkspaceSessionState { + const carried: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + ...wslMoverProfile('mixed-windows-wsl', 'destination').session + } + const group = carried.tabGroups?.[MOVED_ON_HOST]?.[0] + return { + ...carried, + unifiedTabs: { + ...carried.unifiedTabs, + [MOVED_ON_HOST]: [...(carried.unifiedTabs?.[MOVED_ON_HOST] ?? []), chatTab] + }, + tabGroups: { + [MOVED_ON_HOST]: group + ? [ + { + ...group, + tabOrder: [...group.tabOrder, chatTab.id], + activeTabId: chatTab.id, + recentTabIds: ['tab-1', chatTab.id] + } + ] + : [] + }, + activeTabIdByWorktree: { [MOVED_ON_HOST]: chatTab.id }, + activeTabTypeByWorktree: { [MOVED_ON_HOST]: 'agent-session' } + } +} + +describe('WSL move session globals', () => { + it("moves local focus on a moved workspace to the host's id and host", () => { + expect( + wslMoveSessionGlobalsPatch( + { + activeWorktreeId: MOVED, + activeWorkspaceKey: `worktree:${MOVED}`, + activeWorkspaceExecutionHostId: 'local' + }, + scope + ) + ).toEqual({ + activeWorktreeId: MOVED_ON_HOST, + activeWorkspaceKey: `worktree:${MOVED_ON_HOST}`, + activeWorkspaceExecutionHostId: HOST + }) + }) + + it("moves focus on a moved folder workspace to the host, keeping the folder's key", () => { + expect( + wslMoveSessionGlobalsPatch( + { + activeWorktreeId: null, + activeWorkspaceKey: 'folder:folder-1', + activeWorkspaceExecutionHostId: 'local' + }, + scope + ) + ).toEqual({ activeWorkspaceExecutionHostId: HOST }) + expect( + wslMoveSessionGlobalsPatch( + { activeWorktreeId: null, activeWorkspaceKey: 'folder:folder-2' }, + scope + ) + ).toBeNull() + }) + + it('leaves focus on another project, or already on a host, alone', () => { + const windows = 'repo-win::C:\\src\\win' + expect( + wslMoveSessionGlobalsPatch( + { activeWorktreeId: windows, activeWorkspaceExecutionHostId: 'local' }, + scope + ) + ).toBeNull() + expect( + wslMoveSessionGlobalsPatch( + { activeWorktreeId: MOVED, activeWorkspaceExecutionHostId: 'ssh:box' }, + scope + ) + ).toBeNull() + }) + + // verify-15b 6c: undo left focus on the host copy, which reopened and resumed its agent. + it("returns focus on a released project's host copy to its copy here", () => { + const tabsByWorktree = { [MOVED]: [], 'repo-win::C:\\src\\win': [] } + expect( + wslMoveReleasedSessionFocusPatch( + { + activeWorktreeId: MOVED_ON_HOST, + activeWorkspaceKey: `worktree:${MOVED_ON_HOST}`, + activeWorkspaceExecutionHostId: HOST, + tabsByWorktree + }, + scope + ) + ).toEqual({ + activeWorkspaceExecutionHostId: 'local', + activeWorktreeId: MOVED, + activeWorkspaceKey: `worktree:${MOVED}` + }) + expect( + wslMoveReleasedSessionFocusPatch( + { + activeWorktreeId: null, + activeWorkspaceKey: 'folder:folder-1', + activeWorkspaceExecutionHostId: HOST, + tabsByWorktree + }, + scope + ) + ).toEqual({ activeWorkspaceExecutionHostId: 'local' }) + // Nothing here to return to: focus leaves the host copy rather than reopen it. + expect( + wslMoveReleasedSessionFocusPatch( + { + activeWorktreeId: MOVED_ON_HOST, + activeWorkspaceExecutionHostId: HOST, + tabsByWorktree: {} + }, + scope + ) + ).toEqual({ + activeWorkspaceExecutionHostId: 'local', + activeWorktreeId: null, + activeWorkspaceKey: null + }) + }) + + it('leaves focus on another host project, another host or this computer alone', () => { + for (const focus of [ + { activeWorktreeId: SAME_DISTRO_ON_HOST, activeWorkspaceExecutionHostId: HOST }, + { activeWorktreeId: MOVED_ON_HOST, activeWorkspaceExecutionHostId: 'ssh:box' }, + { activeWorktreeId: MOVED, activeWorkspaceExecutionHostId: 'local' } + ] as const) { + expect(wslMoveReleasedSessionFocusPatch({ ...focus, tabsByWorktree: {} }, scope)).toBeNull() + } + }) + + it('changes nothing on a second run', () => { + const first = { activeWorktreeId: MOVED, activeWorkspaceExecutionHostId: null } + const patch = wslMoveSessionGlobalsPatch(first, scope) + + expect(patch).toEqual({ activeWorktreeId: MOVED_ON_HOST, activeWorkspaceExecutionHostId: HOST }) + expect(wslMoveSessionGlobalsPatch({ ...first, ...patch }, scope)).toBeNull() + }) +}) + +/** The seeded partition alone. */ +function seedSession( + carried: WorkspaceSessionState, + existing: WorkspaceSessionState, + seedScope: typeof scope +): WorkspaceSessionState | null { + return wslMoveHostSessionSeed(carried, existing, seedScope)?.session ?? null +} + +describe('WSL move host session seed', () => { + it("seeds only the moved project's rows, without chats or PTY bindings", () => { + const seed = seedSession(carriedSession(), getDefaultWorkspaceSession(), scope) + + expect(Object.keys(seed?.tabsByWorktree ?? {}).sort()).toEqual([MOVED_ON_HOST, FEATURE_ON_HOST]) + expect(seed?.tabsByWorktree[MOVED_ON_HOST]?.map((tab) => tab.ptyId)).toEqual([null]) + expect(seed?.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toBeUndefined() + expect(seed?.terminalLayoutsByTabId['tab-1']?.titlesByLeafId).toEqual({ + 'leaf-1': 'build', + 'leaf-2': 'claude' + }) + expect(seed?.terminalLayoutsByTabId['tab-3']).toBeUndefined() + expect(seed?.remoteSessionIdsByTabId ?? {}).toEqual({}) + expect(seed?.terminalPtyIncarnationsByPaneKey ?? {}).toEqual({}) + expect(seed?.activeWorktreeIdsOnShutdown ?? []).toEqual([]) + expect(seed?.unifiedTabs?.[MOVED_ON_HOST]?.map((tab) => tab.id)).not.toContain(chatTab.id) + expect(seed?.tabGroups?.[MOVED_ON_HOST]?.[0]).toMatchObject({ + tabOrder: ['tab-1', '/home/dev/app/README.md'], + activeTabId: 'tab-1', + recentTabIds: ['tab-1'] + }) + expect(seed?.activeTabIdByWorktree?.[MOVED_ON_HOST]).toBeUndefined() + expect(seed?.activeTabTypeByWorktree?.[MOVED_ON_HOST]).toBeUndefined() + expect(seed?.openFilesByWorktree?.[MOVED_ON_HOST]?.[0]?.dirtyDraftContent).toBe( + '# unsaved draft' + ) + // Focus belongs to whoever looks at the host, not to the seed. + expect(seed?.activeWorktreeId).toBeNull() + expect(seed?.tabsByWorktree[SAME_DISTRO_ON_HOST]).toBeUndefined() + }) + + it('never replaces what the host already published', () => { + const published: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeWorktreeId: MOVED_ON_HOST, + tabsByWorktree: { + [MOVED_ON_HOST]: [terminalTab('host-tab', MOVED_ON_HOST, null, 'host-pty')] + }, + terminalLayoutsByTabId: { + 'host-tab': { root: null, activeLeafId: null, expandedLeafId: null } + }, + tabGroups: { [MOVED_ON_HOST]: [tabGroup('host-group', MOVED_ON_HOST, ['host-tab'])] } + } + const seed = seedSession(carriedSession(), published, scope) + + expect(seed?.tabsByWorktree[MOVED_ON_HOST]).toEqual(published.tabsByWorktree[MOVED_ON_HOST]) + expect(seed?.tabGroups).toEqual(published.tabGroups) + expect(seed?.terminalLayoutsByTabId['tab-1']).toBeUndefined() + expect(seed?.tabsByWorktree[FEATURE_ON_HOST]?.map((tab) => tab.id)).toEqual(['tab-2']) + expect(seed?.activeWorktreeId).toBe(MOVED_ON_HOST) + }) + + it('changes nothing on a second run', () => { + const seed = seedSession(carriedSession(), getDefaultWorkspaceSession(), scope) + + if (!seed) { + throw new Error('expected a seed') + } + expect(wslMoveHostSessionSeed(carriedSession(), seed, scope)).toBeNull() + }) + + it('never seeds a workspace an earlier run recorded, even once its tabs are closed', () => { + const first = wslMoveHostSessionSeed(carriedSession(), getDefaultWorkspaceSession(), scope) + + expect(first?.workspaceIds.sort()).toEqual([MOVED_ON_HOST, FEATURE_ON_HOST]) + expect( + wslMoveHostSessionSeed( + carriedSession(), + getDefaultWorkspaceSession(), + scope, + new Set(first?.workspaceIds) + ) + ).toBeNull() + }) + + it('drops a group that held only chats, with the layout and focus that named it', () => { + const carried = carriedSession() + const workGroup = carried.tabGroups?.[MOVED_ON_HOST]?.[0] + const chatGroup = tabGroup('chat-group', MOVED_ON_HOST, [chatTab.id]) + const seed = seedSession( + { + ...carried, + unifiedTabs: { + ...carried.unifiedTabs, + [MOVED_ON_HOST]: [ + ...(carried.unifiedTabs?.[MOVED_ON_HOST] ?? []).filter((tab) => tab.id !== chatTab.id), + { ...chatTab, groupId: chatGroup.id } + ] + }, + tabGroups: { + [MOVED_ON_HOST]: [ + ...(workGroup + ? [{ ...workGroup, tabOrder: workGroup.tabOrder.filter((id) => id !== chatTab.id) }] + : []), + chatGroup + ] + }, + tabGroupLayouts: { + [MOVED_ON_HOST]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-1' }, + second: { type: 'leaf', groupId: chatGroup.id } + } + }, + activeGroupIdByWorktree: { [MOVED_ON_HOST]: chatGroup.id } + }, + getDefaultWorkspaceSession(), + scope + ) + + expect(seed?.tabGroups?.[MOVED_ON_HOST]?.map((group) => group.id)).toEqual(['group-1']) + expect(seed?.tabGroupLayouts?.[MOVED_ON_HOST]).toEqual({ type: 'leaf', groupId: 'group-1' }) + expect(seed?.activeGroupIdByWorktree?.[MOVED_ON_HOST]).toBe('group-1') + }) +}) + +describe('WSL move carried session focus', () => { + // The focused project could not move into a host that had its own focus. + it('carries no desktop focus, so a host with its own focus takes the moved project and keeps its focus', () => { + const desktop: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-moving', + activeWorktreeId: 'repo-moving::/home/dev/app', + activeWorkspaceKey: worktreeWorkspaceKey('repo-moving::/home/dev/app'), + activeWorkspaceExecutionHostId: 'local', + activeTabId: 'tab-1' + } + const carried = withoutDesktopFocus(desktop) + expect(carried).toMatchObject({ activeRepoId: null, activeWorktreeId: null, activeTabId: null }) + expect('activeWorkspaceKey' in carried).toBe(false) + expect('activeWorkspaceExecutionHostId' in carried).toBe(false) + + const host = { + ...getDefaultPersistedState('/host-data'), + workspaceSession: { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-on-host', + activeWorktreeId: 'repo-on-host::/home/dev/other', + activeTabId: 'host-tab' + } + } + const prepared = prepareOrcadMigrationWorkspaceSession(carried, host) + expect(prepared.merged).toMatchObject({ + activeRepoId: 'repo-on-host', + activeWorktreeId: 'repo-on-host::/home/dev/other', + activeTabId: 'host-tab' + }) + expect(() => prepareOrcadMigrationWorkspaceSession(desktop, host)).toThrow( + 'orcad_migration_dormant_focus_conflict' + ) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.ts b/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.ts new file mode 100644 index 00000000000..143d2f7b974 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.ts @@ -0,0 +1,206 @@ +/** + * The saved session after a WSL move commits: the desktop's focus follows a moved workspace to the + * host, and the host's partition starts from the session the move carried, so the first paint + * shows the moved tabs before the host publishes. Native chats stay on this computer (owner + * decision, option C), and terminals restart on the host, so neither is seeded. + */ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { Tab } from '../../../shared/tab-types' +import { filterWorkspaceSessionTabs } from '../../../shared/workspace-session-tab-filter' +import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership' +import { + mergeWorkspaceSessionArrayField, + mergeWorkspaceSessionRecordField +} from '../../../shared/workspace-session-host-records' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { + filterWorkspaceRows, + workspaceIdsNamedByPartition +} from '../../../shared/workspace-session-stranded-partition-adoption' +import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { WslMoveRewriteScope } from './wsl-move-rewrite-scope' + +type SessionGlobals = Pick< + WorkspaceSessionState, + 'activeWorktreeId' | 'activeWorkspaceKey' | 'activeWorkspaceExecutionHostId' +> + +/** The local partition's focus on the host when it points into the move; null when it does not. */ +export function wslMoveSessionGlobalsPatch( + session: SessionGlobals, + scope: WslMoveRewriteScope +): WorkspaceSessionPatch | null { + const host = session.activeWorkspaceExecutionHostId + // Focus already on a host (the WSL one included) names that host's ids, not the moved local ones. + if (host && host !== LOCAL_EXECUTION_HOST_ID) { + return null + } + const worktreeId = session.activeWorktreeId + const key = session.activeWorkspaceKey + const movedWorktree = worktreeId ? scope.inScope(worktreeId) : false + const movedKey = key ? scope.inScope(key) : false + if (!movedWorktree && !movedKey) { + return null + } + const parsedKey = key && movedKey ? parseWorkspaceKey(key) : null + return { + activeWorkspaceExecutionHostId: scope.destinationHostId, + ...(worktreeId && movedWorktree ? { activeWorktreeId: scope.worktreeId(worktreeId) } : {}), + ...(parsedKey?.type === 'worktree' + ? { + activeWorkspaceKey: worktreeWorkspaceKey(scope.worktreeId(parsedKey.worktreeId)) + } + : {}) + } +} + +/** + * Undo's inverse: focus left on a released project's host copy returns to its copy here, so the + * reloaded window does not open (and so restart) the host's panes; null when focus is elsewhere. + */ +export function wslMoveReleasedSessionFocusPatch( + session: SessionGlobals & Pick, + scope: WslMoveRewriteScope +): WorkspaceSessionPatch | null { + if (session.activeWorkspaceExecutionHostId !== scope.destinationHostId) { + return null + } + const hostWorktreeId = session.activeWorktreeId + const key = session.activeWorkspaceKey + const releasedWorktree = hostWorktreeId ? scope.inScope(hostWorktreeId) : false + if (!releasedWorktree && !(key && scope.inScope(key))) { + return null + } + if (!releasedWorktree) { + // A folder workspace keeps its id on the host; only the host changes back. + return { activeWorkspaceExecutionHostId: LOCAL_EXECUTION_HOST_ID } + } + const localWorktreeId = + Object.keys(session.tabsByWorktree).find( + (id) => id !== hostWorktreeId && scope.worktreeId(id) === hostWorktreeId + ) ?? null + return { + activeWorkspaceExecutionHostId: LOCAL_EXECUTION_HOST_ID, + activeWorktreeId: localWorktreeId, + activeWorkspaceKey: localWorktreeId ? worktreeWorkspaceKey(localWorktreeId) : null + } +} + +// Restart hints for local PTYs; the host decides what to start for its own terminals. +const UNSEEDED_FIELDS: ReadonlySet = new Set(['activeWorktreeIdsOnShutdown']) + +export type WslMoveHostSessionSeed = { + session: WorkspaceSessionState + workspaceIds: string[] +} + +/** + * `existing` plus the carried rows of every moved workspace it names nothing for and no earlier + * run seeded, or null when there is none: what the host published, and a seeded tab the user + * has since closed, are never replaced. + */ +export function wslMoveHostSessionSeed( + carried: WorkspaceSessionState, + existing: WorkspaceSessionState, + scope: WslMoveRewriteScope, + alreadySeeded: ReadonlySet = new Set() +): WslMoveHostSessionSeed | null { + const published = workspaceIdsNamedByPartition(existing) + const stripped = withoutChatTabs(withoutPtyBindings(carried)) + const seeded = new Set( + [...workspaceIdsNamedByPartition(stripped)].filter( + (workspaceId) => + scope.inScope(workspaceId) && !published.has(workspaceId) && !alreadySeeded.has(workspaceId) + ) + ) + if (seeded.size === 0) { + return null + } + const rows = filterWorkspaceRows(stripped, (workspaceId) => seeded.has(workspaceId)) + const next = structuredClone(existing) + for (const [field, ownership] of Object.entries(WORKSPACE_SESSION_FIELD_OWNERSHIP)) { + if ( + !isSessionField(field) || + ownership === 'global' || + ownership === 'hostPrivate' || + UNSEEDED_FIELDS.has(field) + ) { + continue + } + if (ownership === 'worktreeArray') { + mergeWorkspaceSessionArrayField(next, field, rows) + } else { + mergeWorkspaceSessionRecordField(next, field, rows) + } + } + return { session: next, workspaceIds: [...seeded] } +} + +function isSessionField(field: string): field is keyof WorkspaceSessionState { + return Object.hasOwn(WORKSPACE_SESSION_FIELD_OWNERSHIP, field) +} + +/** + * This desktop's focus, which names nothing on the host: the host keeps its own, and a host that + * already has one refuses a manifest naming another (`orcad_migration_dormant_focus_conflict`). + */ +export function withoutDesktopFocus(session: WorkspaceSessionState): WorkspaceSessionState { + const { activeWorkspaceKey: _key, activeWorkspaceExecutionHostId: _host, ...rest } = session + return { ...rest, activeRepoId: null, activeWorktreeId: null, activeTabId: null } +} + +/** Every local PTY binding (item 36): the host starts its own terminals. */ +export function withoutPtyBindings(session: WorkspaceSessionState): WorkspaceSessionState { + const { + remoteSessionIdsByTabId: _remoteSessions, + terminalPtyIncarnationsByPaneKey: _incarnations, + ...rest + } = session + return { + ...rest, + tabsByWorktree: Object.fromEntries( + Object.entries(session.tabsByWorktree).map(([owner, tabs]) => [ + owner, + tabs.map((tab) => ({ ...tab, ptyId: null })) + ]) + ), + terminalLayoutsByTabId: Object.fromEntries( + Object.entries(session.terminalLayoutsByTabId).map(([tabId, layout]) => { + const { ptyIdsByLeafId: _ptyIds, ...unbound } = layout + return [tabId, unbound] + }) + ) + } +} + +function isChatTab(tab: Pick): boolean { + return tab.contentType === 'agent-session' +} + +/** Chat tabs, and the groups, focus and sleeping records that name them; they stay local. */ +function withoutChatTabs(session: WorkspaceSessionState): WorkspaceSessionState { + const chatIds = new Set( + Object.values(session.unifiedTabs ?? {}).flatMap((tabs) => + tabs.filter(isChatTab).map((tab) => tab.id) + ) + ) + if (chatIds.size === 0) { + return session + } + const filtered = filterWorkspaceSessionTabs(session, (_owner, tab) => !isChatTab(tab)) + return { + ...filtered, + ...(session.sleepingAgentSessionsByPaneKey + ? { + sleepingAgentSessionsByPaneKey: Object.fromEntries( + Object.entries(session.sleepingAgentSessionsByPaneKey).filter( + ([, record]) => !record.tabId || !chatIds.has(record.tabId) + ) + ) + } + : {}) + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.test.ts new file mode 100644 index 00000000000..80059c48386 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.test.ts @@ -0,0 +1,363 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getHistorySessionDirName } from '../../daemon/history-paths' +import { HistoryReader } from '../../daemon/history-reader' +import type { TerminalModes } from '../../daemon/terminal-modes' +import { projectOrcadMigrationSessionScrollback } from '../migrating-orcad-catalog/orcad-source-scrollback-state' +import { parseOrcadMigrationTerminalHistoryText } from '../../../shared/orcad-migration/orcad-migration-terminal-history' +import { + installOrcadMigrationTerminalHistory, + movedPaneHistorySessionId, + orcadMigrationTerminalHistoryKey +} from '../migrating-orcad-catalog/orcad-terminal-history-installer' +import { getOrcadMigrationReceivedBlobPath } from '../migrating-orcad-catalog/orcad-migration-blob-staging' +import { + daemonHistoryLeafOf, + listWslMoveSleptSessionPanes, + readWslMoveSleptScrollbackFromHistory, + withSleptSessionPanes, + wslMoveTerminalHistoryBlobs +} from './wsl-move-slept-scrollback' + +const TAB = 'feature-shell' +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `${TAB}:${LEAF}` +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function spoolRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'wsl-move-slept-')) + roots.push(root) + return join(root, 'move-1') +} + +function sleptSession( + extra: Partial = {} +) { + // What Sleep leaves: the layout names the pane, with at most an older renderer buffer. + return { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: { + [TAB]: { + root: { type: 'leaf' as const, leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ...extra + } + } + } satisfies WorkspaceSessionState +} + +function restored(scrollbackAnsi: string, snapshotAnsi: string, hasUnreadableRecovery = false) { + return { + status: 'restored' as const, + sessionId: 'pty-1', + hasUnreadableRecovery, + restoreInfo: { + scrollbackAnsi, + snapshotAnsi, + rehydrateSequences: '', + cwd: '/home/me', + cols: 80, + rows: 24, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: unread here. + modes: {} as never + } + } +} + +function modes(alternateScreen: boolean): TerminalModes { + return { bracketedPaste: false, mouseTracking: false, applicationCursor: false, alternateScreen } +} + +function withModes>( + detection: T, + alternateScreen: boolean +): T { + return { + ...detection, + restoreInfo: { + ...detection.restoreInfo, + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen + } satisfies TerminalModes + } + } +} + +describe("a paused pane's daemon history", () => { + it('leaves the pane out of the snapshot channel: the host restores it, nothing else paints it', () => { + const projected = projectOrcadMigrationSessionScrollback( + sleptSession({ buffersByLeafId: { [LEAF]: 'older' } }), + undefined, + false, + () => true + ) + expect(projected.blockedItems).toEqual([]) + expect(projected.snapshots).toEqual([]) + expect(projected.session.terminalLayoutsByTabId[TAB]?.scrollbackRefsByLeafId).toBeUndefined() + }) + + // A normal-screen restore holds the whole buffer in both fields; carried twice, + // the per-pane cap then kept only the newest ~half of a 2,000-line pane. + it('writes a normal-screen buffer once, so a 2,000-line pane arrives whole', async () => { + const buffer = Array.from({ length: 2_000 }, (_, i) => `line ${i}`).join('\r\n') + const slept = await readWslMoveSleptScrollbackFromHistory( + { detectColdRestoreState: async () => withModes(restored(buffer, buffer), false) }, + [{ paneKey: PANE_KEY, sessionId: 'pty-1', wslDistro: 'Ubuntu' }], + 'Ubuntu', + spoolRoot() + ) + const document = documentOf(slept) + expect(document.checkpoint.snapshotAnsi).toBe(`${buffer}\r\n`) + expect(document.checkpoint.scrollbackAnsi).toBe('') + expect(daemonHistoryLeafOf(slept)?.(TAB, LEAF)).toBe(true) + slept.dispose() + }) + + it("keeps the normal buffer behind a TUI and the shell's last folder", async () => { + const slept = await readWslMoveSleptScrollbackFromHistory( + { + detectColdRestoreState: async () => + withModes(restored('$ make\r\nok\r\n', 'TUI FRAME'), true) + }, + [ + { paneKey: PANE_KEY, sessionId: 'pty-1', wslDistro: 'Ubuntu', shellCwd: '/home/me/deep/er' } + ], + 'Ubuntu', + spoolRoot() + ) + const document = documentOf(slept) + expect(document.cwd).toBe('/home/me/deep/er') + expect(document.checkpoint.cwd).toBe('/home/me/deep/er') + expect(document.checkpoint.scrollbackAnsi).toBe('$ make\r\nok\r\n') + slept.dispose() + }) + + it('reports a checkpoint it cannot fully read instead of carrying stale history', async () => { + const reader = { + detectColdRestoreState: vi.fn(async (sessionId: string) => + sessionId === 'pty-1' + ? restored('old\r\n', '$ ', true) + : { status: 'unreadable' as const, sessionId } + ) + } + const slept = await readWslMoveSleptScrollbackFromHistory( + reader, + [ + { paneKey: PANE_KEY, sessionId: 'pty-1' }, + { paneKey: 'other:22222222-1111-4111-8111-111111111111', sessionId: 'pty-2' } + ], + 'Ubuntu', + spoolRoot(), + (tabId) => (tabId === TAB ? 'build watcher' : null) + ) + expect(slept.buffers.size).toBe(0) + expect(slept.problems).toEqual([ + 'saved terminal history Orca could not fully read for the tab "build watcher"', + 'saved terminal history Orca could not fully read for the tab other' + ]) + slept.dispose() + }) + + // The redesign's contract: what the host's daemon cold-restores is what this desktop's would. + it('round-trips: the host restores the same history, in the shell folder, from its own daemon', async () => { + const source = historyRoot() + const lines = Array.from({ length: 2_000 }, (_, i) => `line ${i}`).join('\r\n') + writeSourceSession(source, 'pty-1', lines, '/home/me/deep') + const slept = await readWslMoveSleptScrollbackFromHistory( + new HistoryReader(source), + [{ paneKey: PANE_KEY, sessionId: 'pty-1', wslDistro: 'Ubuntu' }], + 'Ubuntu', + spoolRoot() + ) + const blobs = wslMoveTerminalHistoryBlobs(slept, { payload: manifestFor() }) + expect(blobs.descriptors).toEqual([ + expect.objectContaining({ + blobKind: 'terminal-history', + key: orcadMigrationTerminalHistoryKey(PANE_KEY) + }) + ]) + const migration = { migrationId: 'move-1', manifestSha256: 'a'.repeat(64) } + const received = historyRoot() + const descriptor = blobs.descriptors[0]! + const path = getOrcadMigrationReceivedBlobPath(received, migration, descriptor) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, await blobs.source.readChunk(descriptor, 0, descriptor.byteLength)) + slept.dispose() + + const host = historyRoot() + const worktreeId = 'repo::/home/me' + const install = { + migration, + root: received, + historyRoot: host, + paneWorktreeId: () => worktreeId, + workspacePath: () => '/home/me', + now: Date.parse('2026-10-23T00:00:00.000Z') + } + installOrcadMigrationTerminalHistory(install) + const sessionId = movedPaneHistorySessionId(worktreeId, PANE_KEY) + const reader = new HistoryReader(host) + expect(reader.probeRestorableHistory(sessionId)).toEqual({ status: 'restorable', sessionId }) + const restoredHere = await reader.detectColdRestoreState(sessionId) + expect(restoredHere.status).toBe('restored') + if (restoredHere.status !== 'restored') { + return + } + expect(restoredHere.restoreInfo.snapshotAnsi).toBe(`${lines}\r\n`) + expect(restoredHere.restoreInfo.cwd).toBe('/home/me/deep') + + // A retry after the pane started never rewinds it to the moved history. + writeFileSync( + join(host, getHistorySessionDirName(sessionId), 'meta.json'), + JSON.stringify({ ...readMeta(host, sessionId), endedAt: '2026-10-23T00:01:00.000Z' }) + ) + writeFileSync(path, 'stale retry') + installOrcadMigrationTerminalHistory(install) + expect(reader.probeRestorableHistory(sessionId)).toEqual({ status: 'none' }) + }) +}) + +function documentOf(slept: Awaited>) { + const entry = slept.buffers.get(PANE_KEY) + if (!entry) { + throw new Error('expected a history document') + } + const document = parseOrcadMigrationTerminalHistoryText(readFileSync(entry.path, 'utf8')) + return { + ...document, + checkpoint: { + snapshotAnsi: String(document.checkpoint.snapshotAnsi), + scrollbackAnsi: String(document.checkpoint.scrollbackAnsi), + cwd: document.checkpoint.cwd + } + } +} + +function manifestFor(): { dormantState: { workspaceSession: WorkspaceSessionState } } { + return { dormantState: { workspaceSession: sleptSession() } } +} + +function historyRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'wsl-move-history-')) + roots.push(root) + return root +} + +function writeSourceSession(root: string, sessionId: string, buffer: string, cwd: string): void { + const dir = join(root, getHistorySessionDirName(sessionId)) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, 'checkpoint.json'), + JSON.stringify({ + snapshotAnsi: buffer, + scrollbackAnsi: '', + rehydrateSequences: '', + cwd, + cols: 80, + rows: 24, + modes: modes(false), + scrollbackLines: 2_000, + checkpointedAt: '2026-10-22T00:00:00.000Z' + }) + ) + // What Sleep leaves: a cleanly ended session. + writeFileSync( + join(dir, 'meta.json'), + JSON.stringify({ + cwd: '/home/me', + cols: 80, + rows: 24, + startedAt: '2026-10-22T00:00:00.000Z', + endedAt: '2026-10-22T00:00:01.000Z', + exitCode: 0 + }) + ) +} + +function readMeta(root: string, sessionId: string): Record { + const value: unknown = JSON.parse( + readFileSync(join(root, getHistorySessionDirName(sessionId), 'meta.json'), 'utf8') + ) + return value && typeof value === 'object' ? { ...value } : {} +} + +describe('panes slept before the move', () => { + it("lists them from the scope's layouts, live panes first", () => { + // Panes slept earlier in the session had no live terminal, so 0 of 0 were read. + const session: WorkspaceSessionState = { + ...sleptSession({ ptyIdsByLeafId: { [LEAF]: 'pty-slept' } }), + tabsByWorktree: { + 'app::/w': [ + { + id: TAB, + ptyId: null, + worktreeId: 'app::/w', + title: 't', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + const slept = listWslMoveSleptSessionPanes(session, ['app::/w']) + expect(slept).toEqual([{ paneKey: PANE_KEY, sessionId: 'pty-slept' }]) + expect(listWslMoveSleptSessionPanes(session, ['other::/w'])).toEqual([]) + expect(withSleptSessionPanes([{ paneKey: PANE_KEY, sessionId: 'pty-live' }], slept)).toEqual([ + { paneKey: PANE_KEY, sessionId: 'pty-live' } + ]) + }) + + it("takes the window's binding of a pane main retired on Sleep, only for a moved workspace", () => { + // With no reload between Sleep and Move, main's layout had no binding left. + const tab = { + id: TAB, + ptyId: null, + worktreeId: 'app::/w', + title: 't', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + const session: WorkspaceSessionState = { + ...sleptSession(), + tabsByWorktree: { 'app::/w': [tab] } + } + const minted = 'app::/w@@0b4c' + expect(listWslMoveSleptSessionPanes(session, ['app::/w'])).toEqual([]) + expect( + listWslMoveSleptSessionPanes(session, ['app::/w'], [{ paneKey: PANE_KEY, sessionId: minted }]) + ).toEqual([{ paneKey: PANE_KEY, sessionId: minted }]) + for (const reported of [ + { paneKey: PANE_KEY, sessionId: 'other::/w@@0b4c' }, + { paneKey: PANE_KEY, sessionId: '../../etc' }, + { paneKey: `${TAB}:22222222-2222-4222-8222-222222222222`, sessionId: minted }, + { paneKey: `other-tab:${LEAF}`, sessionId: minted } + ]) { + expect(listWslMoveSleptSessionPanes(session, ['app::/w'], [reported])).toEqual([]) + } + expect( + listWslMoveSleptSessionPanes( + session, + ['other::/w'], + [{ paneKey: PANE_KEY, sessionId: minted }] + ) + ).toEqual([]) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.ts b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.ts new file mode 100644 index 00000000000..246ae52996e --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.ts @@ -0,0 +1,336 @@ +/** + * A paused pane's history lives in the daemon's checkpoint, so a WSL move reads it right after its + * Sleep and carries it as a `terminal-history` blob (checkpoint, cwd and size) for the host's own + * daemon to cold-restore once. Each document is spooled to a file; only hashes stay in memory. + */ +import { createHash } from 'node:crypto' +import { closeSync, mkdirSync, openSync, readSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { OrcadMigrationBlobDescriptor } from '../../../shared/orcad-migration/orcad-migration-blob-transfer' +import { ORCAD_MIGRATION_BLOB_KIND_CONTRACTS } from '../../../shared/orcad-migration/orcad-migration-blob-transfer' +import { + ORCAD_MIGRATION_TERMINAL_HISTORY_VERSION, + type OrcadMigrationTerminalHistoryDocument +} from '../../../shared/orcad-migration/orcad-migration-terminal-history' +import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { WslMoveRequest } from '../../../shared/wsl-move/wsl-move-driver-types' +import { parsePtySessionId } from '../../../shared/pty-session-id-format' +import { collectLeafIds } from '../../../shared/terminal-pane-layout-tree' +import type { ColdRestoreInfo } from '../../daemon/history-reader' +import type { TerminalCheckpointFile } from '../../daemon/types' +import type { OrcadMigrationBlobSource } from '../../ssh/orcad-migration-blob-coordinator' +import { orcadMigrationTerminalHistoryKey } from '../migrating-orcad-catalog/orcad-terminal-history-installer' +import type { WslMoveDriverDeps } from './wsl-move-driver-ports' +import { + wslMoveHostLinuxCwd, + type ColdRestoreReader, + type WslMoveRestoreHintPane +} from './wsl-move-restore-hints' + +export type WslMoveSleptBuffer = { path: string; sha256: string; byteLength: number } + +export type WslMoveSleptScrollback = { + /** Each pane's `terminal-history` document, keyed by pane key. */ + buffers: ReadonlyMap + /** User-facing: panes whose latest history can't be read. The move refuses rather than drop it. */ + problems: readonly string[] + /** Removes the spooled files; the move calls it whatever its outcome. */ + dispose: () => void +} + +export const WSL_MOVE_SLEPT_SPOOL_DIR = 'wsl-move-slept-scrollback' +const MAX_HISTORY_BYTES = ORCAD_MIGRATION_BLOB_KIND_CONTRACTS['terminal-history'].maxBytes + +export function emptyWslMoveSleptScrollback(): WslMoveSleptScrollback { + return { buffers: new Map(), problems: [], dispose: () => {} } +} + +/** Never throws: an unreadable reader is a problem the move reports, not a crash. */ +export async function readSleptScrollback( + deps: Pick, + panes: readonly WslMoveRestoreHintPane[], + distro: string, + migrationId: string, + workspaceIds: readonly string[], + reported: readonly WslMoveRestoreHintPane[] = [] +): Promise { + if (!deps.sleptScrollback) { + return emptyWslMoveSleptScrollback() + } + return deps + .sleptScrollback(panes, distro, migrationId, workspaceIds, reported) + .catch((error: unknown) => { + console.warn('[wsl-move] Slept terminal history unreadable:', error) + return { + ...emptyWslMoveSleptScrollback(), + problems: ['saved terminal history Orca could not read'] + } + }) +} + +/** For the preview: what a start would refuse for panes slept already, said before the user starts. */ +export async function unreadableSleptHistory( + deps: Pick, + request: Pick, + migrationId: string, + workspaceIds: readonly string[] +): Promise { + const slept = await readSleptScrollback( + deps, + [], + request.distro, + migrationId, + workspaceIds, + request.sleptPanes + ) + slept.dispose() + return [...slept.problems] +} + +/** For the export: the panes whose history travels as a blob, so no snapshot also paints it. */ +export function daemonHistoryLeafOf( + slept: WslMoveSleptScrollback +): ((tabId: string, leafId: string) => boolean) | undefined { + if (slept.buffers.size === 0) { + return undefined + } + // Why not makePaneKey: it throws on a non-UUID leaf, which a carried pane never has. + return (tabId, leafId) => isTerminalLeafId(leafId) && slept.buffers.has(`${tabId}:${leafId}`) +} + +/** + * Each pane's `terminal-history` blob, read from its spool file as the channel asks for it. Only a + * pane whose layout the manifest moves: the host refuses history for a pane it would not hold. + */ +export function wslMoveTerminalHistoryBlobs( + slept: WslMoveSleptScrollback, + manifest: { + payload: { + dormantState?: { + workspaceSession?: Pick | null + } + } + } +): { + descriptors: OrcadMigrationBlobDescriptor[] + source: OrcadMigrationBlobSource +} { + const byKey = new Map() + const descriptors: OrcadMigrationBlobDescriptor[] = [] + const layouts = manifest.payload.dormantState?.workspaceSession?.terminalLayoutsByTabId ?? {} + for (const [paneKey, entry] of slept.buffers) { + const pane = parsePaneKey(paneKey) + if (!pane || !collectLeafIds(layouts[pane.tabId]?.root).includes(pane.leafId)) { + continue + } + const key = orcadMigrationTerminalHistoryKey(paneKey) + byKey.set(key, entry) + descriptors.push({ + blobKind: 'terminal-history', + key, + sha256: entry.sha256, + byteLength: entry.byteLength + }) + } + return { + descriptors, + source: { + readChunk: async (requested, offset, maxBytes) => { + const entry = byKey.get(requested.key) + if (!entry || entry.sha256 !== requested.sha256) { + throw new Error(`orcad_migration_source_blob_changed:${requested.blobKind}`) + } + const length = Math.max(0, Math.min(maxBytes, entry.byteLength - offset)) + const chunk = Buffer.alloc(length) + const fd = openSync(entry.path, 'r') + try { + readSync(fd, chunk, 0, length, offset) + } finally { + closeSync(fd) + } + return chunk + } + } + } +} + +/** + * Production reader: each pane's checkpoint exactly as a cold restore here would replay it, written + * as the checkpoint the host's daemon restores once. Sleep ends a session cleanly, which is exactly + * why its clean end is not a reason to skip the checkpoint here. + */ +export async function readWslMoveSleptScrollbackFromHistory( + reader: ColdRestoreReader, + panes: readonly WslMoveRestoreHintPane[], + distro: string, + spoolRoot: string, + /** What the tab bar calls it; an id means nothing to the user. */ + tabTitle: (tabId: string) => string | null = () => null, + now: () => Date = () => new Date() +): Promise { + const buffers = new Map() + const problems: string[] = [] + const noCheckpoint: string[] = [] + const dispose = (): void => rmSync(spoolRoot, { recursive: true, force: true }) + const tabName = (tabId: string): string => { + const title = tabTitle(tabId) + return title ? `"${title}"` : tabId + } + try { + mkdirSync(spoolRoot, { recursive: true, mode: 0o700 }) + for (const [index, pane] of panes.entries()) { + const leaf = parsePaneKey(pane.paneKey) + if (!leaf) { + continue + } + const detection = await reader + .detectColdRestoreState(pane.sessionId, { ignoreCleanEnd: true, wslDistro: distro }) + .catch(() => ({ status: 'unreadable' as const, sessionId: pane.sessionId })) + if (detection.status === 'none') { + noCheckpoint.push(leaf.tabId) + continue + } + // A checkpoint behind an unreadable log is older than the pane: moving it would lose the rest. + if (detection.status === 'unreadable' || detection.hasUnreadableRecovery) { + problems.push( + `saved terminal history Orca could not fully read for the tab ${tabName(leaf.tabId)}` + ) + continue + } + const shellCwd = pane.shellCwd && /^\/(?!\/)/.test(pane.shellCwd) ? pane.shellCwd : null + const cwd = shellCwd ?? wslMoveHostLinuxCwd(detection.restoreInfo.cwd, pane, distro) + const document: OrcadMigrationTerminalHistoryDocument = { + version: ORCAD_MIGRATION_TERMINAL_HISTORY_VERSION, + paneKey: pane.paneKey, + cwd, + cols: detection.restoreInfo.cols, + rows: detection.restoreInfo.rows, + checkpoint: checkpointOf(detection.restoreInfo, cwd, now()) + } + const bytes = Buffer.from(JSON.stringify(document), 'utf8') + if (bytes.length > MAX_HISTORY_BYTES) { + problems.push( + `more saved terminal history than one move carries for the tab ${tabName(leaf.tabId)}` + ) + continue + } + const path = join(spoolRoot, `${index}.json`) + writeFileSync(path, bytes, { mode: 0o600 }) + buffers.set(pane.paneKey, { + path, + sha256: createHash('sha256').update(bytes).digest('hex'), + byteLength: bytes.length + }) + } + } catch (error) { + dispose() + throw error + } + console.info( + `[wsl-move] Slept terminal history: ${buffers.size} carried, ${problems.length} unreadable, ` + + `${noCheckpoint.length} with no checkpoint${noCheckpoint.length > 0 ? ` (${noCheckpoint.join(', ')})` : ''}, ` + + `of ${panes.length} panes` + ) + return { buffers, problems, dispose } +} + +export function wslMoveSleptSpoolRoot(userDataPath: string, migrationId: string): string { + return join(userDataPath, WSL_MOVE_SLEPT_SPOOL_DIR, migrationId) +} + +// What the daemon's own checkpoint writer would hold for this restore; no log is carried with it. +function checkpointOf( + info: ColdRestoreInfo, + cwd: string | null, + checkpointedAt: Date +): TerminalCheckpointFile { + // A normal screen's buffer is the snapshot; cold restore refills scrollback from it on read. + const scrollbackAnsi = + !info.modes.alternateScreen && info.scrollbackAnsi === info.snapshotAnsi + ? '' + : info.scrollbackAnsi + // Why the line break: the screen ends on the old shell's prompt, and the host's new shell would + // print its own beside it (`$ $`). Not inside an unfinished escape or a TUI's screen. + const endsOnNewLine = !info.modes.alternateScreen && !info.pendingEscapeTailAnsi + return { + snapshotAnsi: endsOnNewLine ? `${info.snapshotAnsi}\r\n` : info.snapshotAnsi, + scrollbackAnsi, + ...(info.oscLinks ? { oscLinks: info.oscLinks } : {}), + rehydrateSequences: info.rehydrateSequences, + ...(info.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: info.pendingEscapeTailAnsi } : {}), + cwd, + cols: info.cols, + rows: info.rows, + modes: info.modes, + scrollbackLines: info.scrollbackLines ?? 0, + ...(info.lastTitle ? { lastTitle: info.lastTitle } : {}), + ...(info.terminalOwner ? { terminalOwner: info.terminalOwner } : {}), + checkpointedAt: checkpointedAt.toISOString() + } +} + +/** + * Panes the user slept before the move: they have no live terminal for the move to list, but a + * layout still names the session whose checkpoint holds their history. Main retires a slept pane's + * binding while the window keeps it for wake, so the window's report counts too; it is trusted + * only for a leaf of a moved workspace's tab and a session id minted for that workspace. + */ +export function listWslMoveSleptSessionPanes( + session: WorkspaceSessionState, + workspaceIds: readonly string[], + reported: readonly WslMoveRestoreHintPane[] = [] +): WslMoveRestoreHintPane[] { + const panes: WslMoveRestoreHintPane[] = [] + const workspaceByTabId = new Map() + for (const workspaceId of workspaceIds) { + for (const tab of session.tabsByWorktree[workspaceId] ?? []) { + workspaceByTabId.set(tab.id, workspaceId) + const bindings = session.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {} + for (const [leafId, sessionId] of Object.entries(bindings)) { + if (isTerminalLeafId(leafId) && sessionId) { + panes.push({ paneKey: makePaneKey(tab.id, leafId), sessionId }) + } + } + } + } + for (const pane of reported) { + const key = parsePaneKey(pane.paneKey) + const workspaceId = key ? workspaceByTabId.get(key.tabId) : undefined + if ( + key && + workspaceId && + collectLeafIds(session.terminalLayoutsByTabId[key.tabId]?.root).includes(key.leafId) && + parsePtySessionId(pane.sessionId).worktreeId === workspaceId + ) { + panes.push({ paneKey: pane.paneKey, sessionId: pane.sessionId }) + } + } + return withSleptSessionPanes([], panes) +} + +/** One pane per key, the first listed kept: live panes first, their session just checkpointed. */ +export function withSleptSessionPanes( + live: readonly WslMoveRestoreHintPane[], + slept: readonly WslMoveRestoreHintPane[] +): WslMoveRestoreHintPane[] { + const known = new Set() + return [...live, ...slept].filter((pane) => { + if (known.has(pane.paneKey)) { + return false + } + known.add(pane.paneKey) + return true + }) +} + +/** The tab bar's name for a tab of the session, or null for a tab it does not hold. */ +export function wslMoveTabTitle(session: WorkspaceSessionState, tabId: string): string | null { + for (const tabs of Object.values(session.tabsByWorktree)) { + const tab = tabs.find((candidate) => candidate.id === tabId) + if (tab) { + return tab.customTitle?.trim() || tab.title.trim() || null + } + } + return null +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-slept-spool-sweep.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-slept-spool-sweep.test.ts new file mode 100644 index 00000000000..af968594166 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-slept-spool-sweep.test.ts @@ -0,0 +1,44 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { wslMoveSleptSpoolRoot } from './wsl-move-slept-scrollback' +import { sweepOrphanedWslMoveSleptSpools } from './wsl-move-slept-spool-sweep' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function spool(userDataPath: string, migrationId: string): string { + const dir = wslMoveSleptSpoolRoot(userDataPath, migrationId) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, '0.json'), '{}') + return dir +} + +describe('WSL move slept spool sweep', () => { + // A kill at `quiesced` left its 25 KB document behind after the rollback. + it("removes the spools of moves the journal no longer holds and keeps a live move's", () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-spool-sweep-')) + roots.push(userDataPath) + seedWslMoveCutover(userDataPath, wslMoveCutoverFixture('move-live', 'quiesced')) + const live = spool(userDataPath, 'move-live') + const orphan = spool(userDataPath, 'move-reset') + + sweepOrphanedWslMoveSleptSpools(userDataPath) + + expect(existsSync(live)).toBe(true) + expect(existsSync(orphan)).toBe(false) + }) + + it('does nothing without a spool folder', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-spool-sweep-')) + roots.push(userDataPath) + expect(() => sweepOrphanedWslMoveSleptSpools(userDataPath)).not.toThrow() + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-slept-spool-sweep.ts b/src/main/persistence/moving-wsl-projects/wsl-move-slept-spool-sweep.ts new file mode 100644 index 00000000000..4091a1563e7 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-slept-spool-sweep.ts @@ -0,0 +1,25 @@ +import { readdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { listWslMoveCutovers } from './wsl-move-cutover-journal' +import { WSL_MOVE_SLEPT_SPOOL_DIR } from './wsl-move-slept-scrollback' + +/** + * At startup, before any move runs: spools of moves the journal no longer holds. A run killed + * mid-pause is reset without its process, so it never disposed of its documents. + */ +export function sweepOrphanedWslMoveSleptSpools(userDataPath: string): void { + let live: Set + let names: string[] + try { + live = new Set(listWslMoveCutovers(userDataPath).map((cutover) => cutover.migrationId)) + names = readdirSync(join(userDataPath, WSL_MOVE_SLEPT_SPOOL_DIR)) + } catch { + // An unreadable journal could still name one; a missing folder holds nothing. + return + } + for (const name of names) { + if (!live.has(name)) { + rmSync(join(userDataPath, WSL_MOVE_SLEPT_SPOOL_DIR, name), { recursive: true, force: true }) + } + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.test.ts new file mode 100644 index 00000000000..e9e32a863ae --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' + +const pass = vi.hoisted(() => ({ run: vi.fn<() => Promise>() })) +vi.mock('./wsl-move-desktop-rewrite', () => ({ runWslMoveDesktopRewritePass: pass.run })) + +const { runWslMoveStartupRewrite, WSL_MOVE_STARTUP_REWRITE_WAIT_MS } = + await import('./wsl-move-startup-rewrite') + +beforeEach(() => { + pass.run.mockReset() +}) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + vi.useRealTimers() +}) + +describe('WSL move startup rewrite', () => { + it('neither runs the pass nor waits with the gate closed', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + const recover = vi.fn(async () => {}) + await runWslMoveStartupRewrite({ userDataPath: '/data' }, { recover }) + expect(recover).not.toHaveBeenCalled() + expect(pass.run).not.toHaveBeenCalled() + }) + + it('resets an interrupted move before the pass, and still runs the pass if that fails', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + const order: string[] = [] + pass.run.mockImplementation(async () => { + order.push('pass') + }) + await runWslMoveStartupRewrite( + { userDataPath: '/data' }, + { + recover: async () => { + order.push('recover') + } + } + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await runWslMoveStartupRewrite( + { userDataPath: '/data' }, + { + recover: async () => { + order.push('recover-failed') + throw new Error('journal unreadable') + } + } + ) + expect(order).toEqual(['recover', 'pass', 'recover-failed', 'pass']) + }) + + it('resolves once the pass finishes', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + let finish: () => void = () => {} + pass.run.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + let done = false + const waiting = runWslMoveStartupRewrite({ userDataPath: '/data' }).then(() => { + done = true + }) + await Promise.resolve() + expect(done).toBe(false) + finish() + await waiting + expect(pass.run).toHaveBeenCalledWith({ userDataPath: '/data' }) + }) + + it('stops waiting for a hung pass at the bound', async () => { + vi.useFakeTimers() + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + pass.run.mockReturnValue(new Promise(() => {})) + let done = false + void runWslMoveStartupRewrite({ userDataPath: '/data' }).then(() => { + done = true + }) + await vi.advanceTimersByTimeAsync(WSL_MOVE_STARTUP_REWRITE_WAIT_MS - 1) + expect(done).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(done).toBe(true) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.ts b/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.ts new file mode 100644 index 00000000000..1b8965d9170 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.ts @@ -0,0 +1,44 @@ +/** + * The startup run of the desktop rewrite stage. The window's first `session:get` and UI read must + * see a committed move's rewrite, or the renderer hydrates the old routing and later writes it + * back over the rewrite. So startup waits for the pass, but never long. + */ +import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import { + runWslMoveDesktopRewritePass, + type WslMoveDesktopRewritePassOptions +} from './wsl-move-desktop-rewrite' + +// A pass is a few profile writes and flushes; past this the window opens and the pass finishes +// behind it, holding the window's saves and reloading it. +export const WSL_MOVE_STARTUP_REWRITE_WAIT_MS = 5_000 + +export type WslMoveStartupRewriteSteps = { + /** Resets a move interrupted before commit; its failure never skips what follows. */ + recover?: () => Promise +} + +/** + * Resolves when the pass finishes or the wait runs out; at once with the gate closed. `recover` + * runs first inside the same wait. A pass that finishes behind the window reloads it. + */ +export function runWslMoveStartupRewrite( + options: WslMoveDesktopRewritePassOptions, + steps: WslMoveStartupRewriteSteps = {} +): Promise { + if (!wslHostsGateOpen()) { + return Promise.resolve() + } + let timer: ReturnType | undefined + const waited = new Promise((resolve) => { + timer = setTimeout(resolve, WSL_MOVE_STARTUP_REWRITE_WAIT_MS) + timer.unref?.() + }) + const pass = Promise.resolve() + .then(steps.recover) + .catch((error: unknown) => { + console.warn('[wsl-move] Startup recovery failed before the desktop rewrite:', error) + }) + .then(() => runWslMoveDesktopRewritePass(options)) + return Promise.race([pass, waited]).finally(() => clearTimeout(timer)) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-startup.ts b/src/main/persistence/moving-wsl-projects/wsl-move-startup.ts new file mode 100644 index 00000000000..594ddc8eebd --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-startup.ts @@ -0,0 +1,60 @@ +/** + * The WSL mover's part of main-process startup: registers the desktop rewrite stores, resets or + * finishes interrupted moves, then keeps retained rows marked and retries a failed host step. + */ +import type { Store } from '../loading-store/store' +import { recoverInterruptedWslMoves } from './wsl-move-reset' +import { isWslDistroRegistered } from '../../wsl/wsl-distro-registry-id' +import { runWslMoveStartupRewrite } from './wsl-move-startup-rewrite' +import { installWslMoveRetention } from './wsl-move-retention' +import { registerWslMoveDesktopRewriteRegistry } from './wsl-move-desktop-rewrite-registry' +import { sweepOrphanedWslMoveSleptSpools } from './wsl-move-slept-spool-sweep' +import { settleInterruptedWslMoveUndos } from './wsl-move-undo-host-automations' +import { enableWslHostAutomations } from '../../ipc/wsl-host-automation-enable' +import { + scheduleWslMoveRewriteRetry, + setWslMoveHostAutomationRestorer +} from './wsl-move-rewrite-retry' +import { + disableWslHostAutomations, + restoreWslHostAutomations +} from '../../ipc/wsl-host-automation-disable' +import { orchestrationDbFile } from '../../runtime/orchestration/db/orchestration-db-file' +import { productionWslMoveBrowserCookieCopyDependencies } from './wsl-move-browser-cookie-store' + +/** Resolves once interrupted moves are reset or finished; main-process-ready waits on it, bounded. */ +export function startWslMoveStartup(userDataPath: string, store: Store): Promise { + setWslMoveHostAutomationRestorer({ + restore: (environmentId, ids) => restoreWslHostAutomations(userDataPath, environmentId, ids), + disable: (environmentId, ids) => disableWslHostAutomations(userDataPath, environmentId, ids) + }) + registerWslMoveDesktopRewriteRegistry({ + profile: store, + orchestrationDatabasePath: orchestrationDbFile(userDataPath), + hostAutomations: { + enable: (environmentId, ids) => enableWslHostAutomations(userDataPath, environmentId, ids) + }, + browserCookies: productionWslMoveBrowserCookieCopyDependencies(userDataPath) + }) + // Resets a move interrupted before commit, then finishes one that committed. Gated off, + // neither runs. + const ready = runWslMoveStartupRewrite( + { userDataPath }, + { + recover: async () => { + await recoverInterruptedWslMoves(userDataPath, undefined, (distro, distroGuid) => + isWslDistroRegistered(distro, distroGuid) + ) + sweepOrphanedWslMoveSleptSpools(userDataPath) + settleInterruptedWslMoveUndos(userDataPath) + } + } + ) + // Re-marks retained rows a downgrade stripped; never awaited, since nothing it writes gates a read. + void ready.then(() => { + installWslMoveRetention({ userDataPath, store }) + // A committed move's host step (automations back on) that failed retries until it lands. + scheduleWslMoveRewriteRetry(userDataPath) + }) + return ready +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-state-projection.ts b/src/main/persistence/moving-wsl-projects/wsl-move-state-projection.ts index 1fdf730d19b..fffaa11e25d 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-state-projection.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-state-projection.ts @@ -187,7 +187,7 @@ export function projectWslMoveState( automationRuns: byId( state.automationRuns.filter((run) => automationIds.has(run.automationId)).map(withoutSetupId) ), - session: projectWslMoveSession(input.session, ownerInScope), + session: projectWslMoveSession(withoutOwnerFileBindings(input.session, aliases), ownerInScope), ui: projectWslMoveUi( input.ui ?? {}, input.session, @@ -207,6 +207,31 @@ export function projectWslMoveState( return canonical } +/** A file bound to its owning host is that host's file whether or not the binding is written. */ +function withoutOwnerFileBindings>( + session: T, + aliases: ReadonlySet +): T { + if (!session.openFilesByWorktree) { + return session + } + return { + ...session, + openFilesByWorktree: Object.fromEntries( + Object.entries(session.openFilesByWorktree).map(([owner, files]) => [ + owner, + files.map(({ runtimeEnvironmentId, ...file }) => + runtimeEnvironmentId === undefined || + runtimeEnvironmentId === null || + aliases.has(runtimeEnvironmentId) + ? file + : { ...file, runtimeEnvironmentId } + ) + ]) + ) + } +} + /** Catalog order is immaterial and may change on import. */ function byId(rows: T[]): T[] { return rows.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-two-desktops.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-two-desktops.test.ts new file mode 100644 index 00000000000..5715a895476 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-two-desktops.test.ts @@ -0,0 +1,213 @@ +/** + * R39: two Orca desktops (say a stable and a dev profile) moving into the one orcad of a distro. + * Each desktop is its own process with its own profile and journal, so desktop B runs on a second + * copy of the mover modules. The host's staging claims serialize overlapping moves: one wins, the + * other is refused and back at zero; neither corrupts the other. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import { testState } from '../persistence-test-harness' +import type { Store } from '../loading-store/store' +import { + createWslMoveKillRig, + removeWslMoveRigDirs, + WSL_MOVE_RIG_REQUEST as request, + wslMoveRigOutcome, + wslMoveRigRetentionStore, + wslMoveRigTempDir, + type WslMoveRig +} from '../__fixtures__/wsl-mover/wsl-move-kill-rig' +import { listWslMoveCutovers } from './wsl-move-cutover-journal' +import { resetWslMoveDesktopRewriteStoresForTests } from './wsl-move-desktop-rewrite-stores' +import { moveProjectsToWslHost } from './wsl-move-driver' +import type { WslMoveResult } from '../../../shared/wsl-move/wsl-move-driver-types' +import type { Repo } from '../../../shared/repo-types' +import { runWslMoveRetentionPass } from './wsl-move-retention' + +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: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 1 })) +})) + +type OtherDesktop = { + userDataPath: string + repos: Repo[] + move: () => Promise + retain: () => Promise + journalPhases: () => string[] +} + +/** Desktop B: its own module copies, profile and journal, on desktop A's host store. */ +async function otherDesktop(host: () => Store, repos: Repo[]): Promise { + vi.resetModules() + const driver = await import('./wsl-move-driver') + const stores = await import('./wsl-move-desktop-rewrite-stores') + const journal = await import('./wsl-move-cutover-journal') + const retention = await import('./wsl-move-retention') + const worldModule = await import('../__fixtures__/wsl-mover/wsl-move-driver-world') + const hostModule = await import('../__fixtures__/wsl-mover/wsl-move-store-host') + stores.setWslMoveDesktopRewriteRegistryCompleteForTests(true) + stores.registerWslMoveDesktopRewriteStore({ + id: 'ui-routing', + side: 'main', + run: () => {}, + flush: () => {} + }) + const userDataPath = wslMoveRigTempDir('wsl-move-desktop-b-') + const world = worldModule.createWslMoveWorld(userDataPath) + world.repos.splice(0, world.repos.length, ...repos) + // Its own ids: a second profile never shares this desktop's random move ids. + let moves = 0 + world.deps.newMigrationId = () => `desktop-b-move-${(moves += 1)}` + const port = hostModule.createWslMoveStoreHost({ + store: host, + kill: hostModule.createWslMoveKillSwitch(), + prefix: 'b:' + }) + world.deps.connectHost = async () => port + return { + userDataPath, + repos: world.repos, + move: () => driver.moveProjectsToWslHost(world.deps, request), + retain: async () => { + await retention.runWslMoveRetentionPass({ + userDataPath, + store: { + getRepos: () => world.repos, + getFolderWorkspaces: () => [], + setWslMoveRetainedMarks: () => 1, + flushPendingOrThrowAsync: async () => {} + } + }) + }, + journalPhases: () => journal.listWslMoveCutovers(userDataPath).map((entry) => entry.phase) + } +} + +let rig: WslMoveRig + +beforeEach(() => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + rig = createWslMoveKillRig() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + resetWslMoveDesktopRewriteStoresForTests() + removeWslMoveRigDirs() +}) + +/** Desktop A's move, held just before its commit until `release` runs. */ +function pausedBeforeCommit(): { + paused: Promise + release: () => void + done: Promise +} { + let release = (): void => {} + let onPaused = (): void => {} + const paused = new Promise((resolve) => (onPaused = resolve)) + const gate = new Promise((resolve) => (release = resolve)) + const { proveExited } = rig.world.deps.quiesce + let calls = 0 + rig.world.deps.quiesce.proveExited = async (scope) => { + calls += 1 + // The second proof runs after every blob and right before the commit request. + if (calls === 2) { + onPaused() + await gate + } + return proveExited(scope) + } + return { paused, release, done: moveProjectsToWslHost(rig.world.deps, request) } +} + +describe('R39: two desktops moving into one distro', () => { + it('the same project from both: the later stage is refused and resets; the first finishes', async () => { + const a = pausedBeforeCommit() + await a.paused + const b = await otherDesktop(() => rig.host, [...rig.world.repos]) + + const bResult = await b.move() + expect(bResult).toMatchObject({ outcome: 'reset' }) + expect(JSON.stringify(bResult)).toContain('orcad_migration_staged_claim_conflict') + expect(b.journalPhases()).toEqual([]) + + a.release() + expect(await a.done).toMatchObject({ outcome: 'moved' }) + await runWslMoveRetentionPass({ + userDataPath: rig.userDataPath, + store: wslMoveRigRetentionStore(rig) + }) + expect(wslMoveRigOutcome(rig)).toBe('fully-moved') + expect(rig.host.getRepos().filter((repo) => repo.id === 'repo-1')).toHaveLength(1) + }) + + it('the same project again after the first committed: refused, and the host copy is untouched', async () => { + expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({ outcome: 'moved' }) + const hostBefore = JSON.stringify(rig.host.getRepos()) + const b = await otherDesktop(() => rig.host, [...rig.world.repos]) + + expect(await b.move()).toMatchObject({ outcome: 'refused', code: 'wsl_move_nothing_new' }) + expect(b.journalPhases()).toEqual([]) + expect(JSON.stringify(rig.host.getRepos())).toBe(hostBefore) + expect(listWslMoveCutovers(rig.userDataPath).map((entry) => entry.phase)).toEqual([ + 'client-rewritten' + ]) + }) + + it('the same folder under another id (each profile added it): refused after the first committed', async () => { + expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({ outcome: 'moved' }) + const hostBefore = JSON.stringify(rig.host.getRepos()) + const b = await otherDesktop(() => rig.host, [{ ...rig.world.repos[0], id: 'repo-b-profile' }]) + + expect(await b.move()).toMatchObject({ + outcome: 'reset', + detail: 'orcad_migration_repository_path_conflict:/home/dev/app' + }) + expect(b.journalPhases()).toEqual([]) + expect(JSON.stringify(rig.host.getRepos())).toBe(hostBefore) + }) + + it('the same folder under another id, overlapping: the later stage is refused', async () => { + const a = pausedBeforeCommit() + await a.paused + const b = await otherDesktop(() => rig.host, [{ ...rig.world.repos[0], id: 'repo-b-profile' }]) + + const bResult = await b.move() + expect(bResult).toMatchObject({ outcome: 'reset' }) + expect(JSON.stringify(bResult)).toContain( + 'orcad_migration_staged_claim_conflict:repository-path' + ) + a.release() + expect(await a.done).toMatchObject({ outcome: 'moved' }) + }) + + it('different projects from each, overlapping: both move, and the host holds both', async () => { + const a = pausedBeforeCommit() + await a.paused + const b = await otherDesktop( + () => rig.host, + [{ ...rig.world.repos[0], id: 'repo-2', path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\other' }] + ) + + expect(await b.move()).toMatchObject({ outcome: 'moved' }) + a.release() + expect(await a.done).toMatchObject({ outcome: 'moved' }) + expect( + rig.host + .getRepos() + .map((repo) => repo.id) + .sort() + ).toEqual(['repo-1', 'repo-2']) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-ui-routing-rewrite.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-ui-routing-rewrite.test.ts new file mode 100644 index 00000000000..6e603ced902 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-ui-routing-rewrite.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest' +import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceCleanupDismissal } from '../../../shared/workspace-cleanup' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { wslMoverProfile } from '../__fixtures__/wsl-mover/wsl-mover-profiles' +import { createUncToLinuxPathRewriter } from './wsl-move-path-rewriter' +import { wslMoveRewriteScope } from './wsl-move-rewrite-scope' +import { rewriteWslMoveUiRouting, type WslMoveUiRoutingState } from './wsl-move-ui-routing-rewrite' + +const HOST = toRuntimeExecutionHostId('wsl:Ubuntu') +const scope = wslMoveRewriteScope({ + cutover: wslMoveCutoverFixture('move-1', 'destination-committed', {}, { repoIds: ['repo-a'] }), + rewritePath: createUncToLinuxPathRewriter('Ubuntu'), + destinationHostId: HOST, + recordSeededHostWorkspaceIds: () => {} +}) + +// The mixed profile: repo-a moves; repo-b (same distro), repo-win and repo-deb stay. +const source = wslMoverProfile('mixed-windows-wsl', 'source') +const destination = wslMoverProfile('mixed-windows-wsl', 'destination') +const sourceRepo = (id: string): string => + source.state.repos.find((repo) => repo.id === id)?.path ?? '' +const MOVED = `repo-a::${sourceRepo('repo-a')}` +const MOVED_ON_HOST = 'repo-a::/home/dev/app' +const SAME_DISTRO = `repo-b::${sourceRepo('repo-b')}` +const WINDOWS = `repo-win::${sourceRepo('repo-win')}` +const SSH = 'ssh:box' as const +const sourceUi = source.ui ?? {} +const destinationUi = destination.ui ?? {} +const EMPTY_UI: WslMoveUiRoutingState = { lastActiveWorktreeId: null, collapsedGroups: [] } + +function profileUi(): WslMoveUiRoutingState { + const ui = sourceUi + return { + lastActiveWorktreeId: ui.lastActiveWorktreeId ?? null, + showDotfilesByWorktree: { + ...ui.showDotfilesByWorktree, + [SAME_DISTRO]: false + }, + explorerDisplayRootByWorktree: { + ...ui.explorerDisplayRootByWorktree, + [WINDOWS]: 'C:\\src\\win\\lib' + }, + workspaceCleanup: { + dismissals: { + [`local\0${MOVED}`]: dismissal(MOVED, 'local'), + [`${SSH}\0${MOVED}`]: dismissal(MOVED, SSH), + [SAME_DISTRO]: dismissal(SAME_DISTRO) + } + }, + manualRepoOrder: [ + { hostId: 'local', repoId: 'repo-win' }, + ...(ui.manualRepoOrder ?? []), + { hostId: 'local', repoId: 'repo-b' }, + { hostId: SSH, repoId: 'repo-a' } + ], + workspaceHostScope: 'local', + visibleWorkspaceHostIds: null, + agentsVisibleHostIds: [SSH, 'local'], + workspaceHostOrder: ['local', SSH], + setupScriptPromptDismissedRepoIds: [ + 'generation-v1:local\0repo-a', + 'generation-v1:local\0repo-b', + `generation-v1:${SSH}\0repo-a` + ], + collapsedGroups: [ + `lineage:local|${MOVED}`, + `lineage:${SSH}|${MOVED}`, + `lineage:local|${SAME_DISTRO}`, + 'repo:repo-a' + ] + } +} + +function dismissal( + worktreeId: string, + executionHostId?: ExecutionHostId +): WorkspaceCleanupDismissal { + return { + worktreeId, + dismissedAt: 1, + fingerprint: 'f', + classifierVersion: 1, + ...(executionHostId ? { executionHostId } : {}) + } +} + +describe('WSL move UI routing rewrite', () => { + it("copies only the moved project's rows to the host's form, keeping the local ones", () => { + const ui = profileUi() + const updates = rewriteWslMoveUiRouting(ui, scope) + + expect(updates.lastActiveWorktreeId).toBe(destinationUi.lastActiveWorktreeId) + expect(updates.showDotfilesByWorktree).toEqual({ + ...ui.showDotfilesByWorktree, + ...destinationUi.showDotfilesByWorktree + }) + expect(updates.explorerDisplayRootByWorktree).toEqual({ + ...ui.explorerDisplayRootByWorktree, + ...destinationUi.explorerDisplayRootByWorktree + }) + expect(updates.workspaceCleanup?.dismissals).toEqual({ + ...ui.workspaceCleanup?.dismissals, + [`${HOST}\0${MOVED_ON_HOST}`]: dismissal(MOVED_ON_HOST, HOST) + }) + expect(updates.manualRepoOrder).toEqual([ + { hostId: 'local', repoId: 'repo-win' }, + { hostId: 'local', repoId: 'repo-a' }, + { hostId: HOST, repoId: 'repo-a' }, + { hostId: 'local', repoId: 'repo-b' }, + { hostId: SSH, repoId: 'repo-a' } + ]) + }) + + it('copies a legacy bare dismissal key, and one with no host, in their own form', () => { + const updates = rewriteWslMoveUiRouting( + { ...EMPTY_UI, workspaceCleanup: { dismissals: { [MOVED]: dismissal(MOVED) } } }, + scope + ) + + expect(updates.workspaceCleanup?.dismissals).toEqual({ + [MOVED]: dismissal(MOVED), + [MOVED_ON_HOST]: dismissal(MOVED_ON_HOST) + }) + }) + + it("maps an in-scope folder workspace's explorer root in place, its key unchanged", () => { + const folderScope = wslMoveRewriteScope({ + cutover: wslMoveCutoverFixture( + 'move-1', + 'destination-committed', + {}, + { repoIds: [], folderWorkspaceIds: ['folder-1'] } + ), + rewritePath: createUncToLinuxPathRewriter('Ubuntu'), + destinationHostId: HOST, + recordSeededHostWorkspaceIds: () => {} + }) + const other = '\\\\wsl.localhost\\Ubuntu\\srv\\other' + const updates = rewriteWslMoveUiRouting( + { + ...EMPTY_UI, + explorerDisplayRootByWorktree: { + 'folder:folder-1': '\\\\wsl.localhost\\Ubuntu\\srv\\notes\\src', + 'folder:folder-2': other + } + }, + folderScope + ) + + expect(updates.explorerDisplayRootByWorktree).toEqual({ + 'folder:folder-1': '/srv/notes/src', + 'folder:folder-2': other + }) + }) + + it('reads the older wsl$ spelling and a lowercase distro as the moved distro', () => { + const bothScope = wslMoveRewriteScope({ + cutover: wslMoveCutoverFixture( + 'move-1', + 'destination-committed', + {}, + { repoIds: ['repo-a', 'repo-b'] } + ), + rewritePath: createUncToLinuxPathRewriter('Ubuntu'), + destinationHostId: HOST, + recordSeededHostWorkspaceIds: () => {} + }) + const lowercase = 'repo-a::\\\\wsl.localhost\\ubuntu\\home\\dev\\app' + const updates = rewriteWslMoveUiRouting( + // SAME_DISTRO is spelled `\\wsl$\Ubuntu\...` by the fixture. + { ...EMPTY_UI, showDotfilesByWorktree: { [SAME_DISTRO]: true, [lowercase]: false } }, + bothScope + ) + + expect(SAME_DISTRO).toContain('wsl$') + expect(updates.showDotfilesByWorktree).toEqual({ + [SAME_DISTRO]: true, + [lowercase]: false, + 'repo-b::/home/dev/lib': true, + [MOVED_ON_HOST]: false + }) + }) + + it('adds the host wherever local is shown, and never removes local', () => { + const updates = rewriteWslMoveUiRouting(profileUi(), scope) + + // The legacy `local` scope resolves to a list, so the host joins it there. + expect(updates.visibleWorkspaceHostIds).toEqual(['local', HOST]) + expect(updates.workspaceHostScope).toBeUndefined() + expect(updates.agentsVisibleHostIds).toEqual([SSH, 'local', HOST]) + expect(updates.workspaceHostOrder).toEqual(['local', HOST, SSH]) + }) + + it('leaves host filters that do not show local, or show all hosts, alone', () => { + const updates = rewriteWslMoveUiRouting( + { + ...profileUi(), + workspaceHostScope: 'all', + visibleWorkspaceHostIds: null, + agentsVisibleHostIds: [SSH], + workspaceHostOrder: undefined + }, + scope + ) + + expect(updates.visibleWorkspaceHostIds).toBeUndefined() + expect(updates.agentsVisibleHostIds).toBeUndefined() + expect(updates.workspaceHostOrder).toBeUndefined() + }) + + it('keeps the local setup-prompt dismissal and lineage group beside the host ones', () => { + const ui = profileUi() + const updates = rewriteWslMoveUiRouting(ui, scope) + + expect(updates.setupScriptPromptDismissedRepoIds).toEqual([ + ...(ui.setupScriptPromptDismissedRepoIds ?? []), + `generation-v1:${HOST}\0repo-a` + ]) + expect(updates.collapsedGroups).toEqual([ + ...ui.collapsedGroups, + `lineage:${HOST}|${MOVED_ON_HOST}` + ]) + }) + + it('changes nothing on a second run', () => { + const first = { + ...profileUi(), + ...rewriteWslMoveUiRouting(profileUi(), scope) + } + + expect(rewriteWslMoveUiRouting(first, scope)).toEqual({}) + }) + + it('changes nothing for a UI that never named the moved project', () => { + const ui: WslMoveUiRoutingState = { + lastActiveWorktreeId: WINDOWS, + showDotfilesByWorktree: { [SAME_DISTRO]: true }, + collapsedGroups: [`lineage:local|${WINDOWS}`], + manualRepoOrder: [{ hostId: 'local', repoId: 'repo-win' }], + agentsVisibleHostIds: null + } + + expect(rewriteWslMoveUiRouting(ui, scope)).toEqual({}) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-ui-routing-rewrite.ts b/src/main/persistence/moving-wsl-projects/wsl-move-ui-routing-rewrite.ts new file mode 100644 index 00000000000..f147a16c42a --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-ui-routing-rewrite.ts @@ -0,0 +1,291 @@ +/** + * The persisted UI's routing to a moved project, extended to the WSL host after a move commits: + * each worktree-keyed preference, cleanup dismissal and manual-order row gains a host copy beside + * the local one, and every host filter that shows `local` also shows the host. The local forms + * stay for a lossless downgrade; only the last-active worktree, a single value, moves. A second + * run finds every copy present and changes nothing. + */ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' +import { getRepoHostIdentityForParts } from '../../../shared/repo-host-identity' +import type { + ManualRepoOrderEntry, + VisibleWorkspaceHostIds, + WorkspaceHostOrder +} from '../../../shared/ui-chrome-types' +import type { WorkspaceCleanupDismissal } from '../../../shared/workspace-cleanup' +import { getWorkspaceCleanupHostIdentity } from '../../../shared/workspace-cleanup-host-identity' +import { resolveVisibleWorkspaceHostIds } from '../../../shared/workspace-view/workspace-host-visibility' +import { + composeWorktreeHostIdentity, + getExecutionHostIdFromWorktreeHostIdentity, + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' +import type { WslMoveRewriteScope } from './wsl-move-rewrite-scope' + +export type WslMoveUiRoutingState = Pick< + PersistedUIState, + | 'lastActiveWorktreeId' + | 'showDotfilesByWorktree' + | 'explorerDisplayRootByWorktree' + | 'workspaceCleanup' + | 'manualRepoOrder' + | 'workspaceHostScope' + | 'visibleWorkspaceHostIds' + | 'agentsVisibleHostIds' + | 'workspaceHostOrder' + | 'setupScriptPromptDismissedRepoIds' + | 'collapsedGroups' +> + +// Mirrors the renderer's keys (setup-script-prompt.ts, group-keys.ts); main cannot import them. +const SETUP_SCRIPT_DISMISSAL_PREFIX = 'generation-v1:' +const LINEAGE_GROUP_PREFIX = 'lineage:' +const CLEANUP_IDENTITY_SEPARATOR = '\0' + +/** Only the fields that change; empty when the UI already routes to the host. */ +export function rewriteWslMoveUiRouting( + ui: WslMoveUiRoutingState, + scope: WslMoveRewriteScope +): Partial { + const updates: Partial = { ...wslMoveLastActiveWorktreeUpdate(ui, scope) } + const hostWorktreeKey = (key: string): string => + scope.inScope(key) ? scope.worktreeId(key) : key + const dotfiles = withHostKeys( + ui.showDotfilesByWorktree, + hostWorktreeKey, + (value) => value, + scope.inScope + ) + if (dotfiles) { + updates.showDotfilesByWorktree = dotfiles + } + const roots = withHostKeys( + ui.explorerDisplayRootByWorktree, + hostWorktreeKey, + scope.path, + scope.inScope + ) + if (roots) { + updates.explorerDisplayRootByWorktree = roots + } + const dismissals = withHostKeys( + ui.workspaceCleanup?.dismissals, + (key) => hostDismissalKey(key, scope), + (dismissal) => rewriteDismissal(dismissal, scope), + () => false + ) + if (ui.workspaceCleanup && dismissals) { + updates.workspaceCleanup = { ...ui.workspaceCleanup, dismissals } + } + const repoOrder = rewriteManualRepoOrder(ui.manualRepoOrder, scope) + if (repoOrder) { + updates.manualRepoOrder = repoOrder + } + // The legacy scalar scope stays: it is also the default host for new workspaces. + const visible = withHostBesideLocal( + resolveVisibleWorkspaceHostIds(ui.visibleWorkspaceHostIds, ui.workspaceHostScope), + scope + ) + if (visible) { + updates.visibleWorkspaceHostIds = visible + } + const agentsVisible = withHostBesideLocal(ui.agentsVisibleHostIds, scope) + if (agentsVisible) { + updates.agentsVisibleHostIds = agentsVisible + } + const hostOrder = withHostBesideLocal(ui.workspaceHostOrder, scope) + if (hostOrder) { + updates.workspaceHostOrder = hostOrder + } + const setupDismissals = addHostSetupDismissals(ui.setupScriptPromptDismissedRepoIds, scope) + if (setupDismissals) { + updates.setupScriptPromptDismissedRepoIds = setupDismissals + } + const collapsed = addHostLineageGroups(ui.collapsedGroups, scope) + if (collapsed) { + updates.collapsedGroups = collapsed + } + return updates +} + +/** + * The last-active worktree on the host when it names a moved local one. Corrective only, so it is + * also safe to re-apply after a stale window wrote the old id back. + */ +export function wslMoveLastActiveWorktreeUpdate( + ui: Pick, + scope: WslMoveRewriteScope +): Pick | null { + const lastActive = ui.lastActiveWorktreeId + return lastActive && scope.inScope(lastActive) && scope.worktreeId(lastActive) !== lastActive + ? { lastActiveWorktreeId: scope.worktreeId(lastActive) } + : null +} + +/** + * The host form of each moved key beside the local one, which a downgrade still reads. A moved + * key that keeps its form (a folder workspace's) has only its value mapped. + */ +function withHostKeys( + record: Record | undefined, + hostKeyOf: (key: string) => string, + mapValue: (value: T) => T, + inScope: (key: string) => boolean +): Record | null { + if (!record) { + return null + } + let changed = false + const next: Record = { ...record } + for (const [key, value] of Object.entries(record)) { + const hostKey = hostKeyOf(key) + if (hostKey !== key) { + if (!Object.hasOwn(next, hostKey)) { + next[hostKey] = mapValue(value) + changed = true + } + continue + } + const mapped = inScope(key) ? mapValue(value) : value + if (mapped !== value) { + next[key] = mapped + changed = true + } + } + return changed ? next : null +} + +/** `\0` (workspace-cleanup-host-identity.ts) or a legacy bare worktree id. */ +function hostDismissalKey(key: string, scope: WslMoveRewriteScope): string { + const separator = key.indexOf(CLEANUP_IDENTITY_SEPARATOR) + const worktreeId = separator === -1 ? key : key.slice(separator + 1) + const host = separator === -1 ? null : key.slice(0, separator) + if ((host !== null && host !== LOCAL_EXECUTION_HOST_ID) || !scope.inScope(worktreeId)) { + return key + } + const hostWorktreeId = scope.worktreeId(worktreeId) + if (hostWorktreeId === worktreeId) { + return key + } + return host === null + ? hostWorktreeId + : getWorkspaceCleanupHostIdentity(scope.destinationHostId, hostWorktreeId) +} + +function rewriteDismissal( + dismissal: WorkspaceCleanupDismissal, + scope: WslMoveRewriteScope +): WorkspaceCleanupDismissal { + const host = dismissal.executionHostId + return { + ...dismissal, + worktreeId: scope.worktreeId(dismissal.worktreeId), + ...(host === LOCAL_EXECUTION_HOST_ID ? { executionHostId: scope.destinationHostId } : {}) + } +} + +function rewriteManualRepoOrder( + order: ManualRepoOrderEntry[] | undefined, + scope: WslMoveRewriteScope +): ManualRepoOrderEntry[] | null { + if (!order?.some((entry) => isMovedLocalRepo(entry, scope))) { + return null + } + const onHost = new Set( + order.filter((entry) => entry.hostId === scope.destinationHostId).map((entry) => entry.repoId) + ) + let changed = false + const next = order.flatMap((entry) => { + // One row per repo and host: a host row already listed keeps its own position. + if (!isMovedLocalRepo(entry, scope) || onHost.has(entry.repoId)) { + return [entry] + } + onHost.add(entry.repoId) + changed = true + return [entry, { hostId: scope.destinationHostId, repoId: entry.repoId }] + }) + return changed ? next : null +} + +function isMovedLocalRepo(entry: ManualRepoOrderEntry, scope: WslMoveRewriteScope): boolean { + return entry.hostId === LOCAL_EXECUTION_HOST_ID && scope.repoInScope(entry.repoId) +} + +function withHostBesideLocal( + hostIds: T, + scope: WslMoveRewriteScope +): WorkspaceHostOrder | null { + if ( + !hostIds || + !hostIds.includes(LOCAL_EXECUTION_HOST_ID) || + hostIds.includes(scope.destinationHostId) + ) { + return null + } + return hostIds.flatMap((hostId) => + hostId === LOCAL_EXECUTION_HOST_ID ? [hostId, scope.destinationHostId] : [hostId] + ) +} + +/** The host's form beside the local one: either survives the renderer's prune by the catalog. */ +function addHostSetupDismissals( + entries: string[] | undefined, + scope: WslMoveRewriteScope +): string[] | null { + if (!entries) { + return null + } + const added: string[] = [] + for (const entry of entries) { + if (!entry.startsWith(SETUP_SCRIPT_DISMISSAL_PREFIX)) { + continue + } + const identity = entry.slice(SETUP_SCRIPT_DISMISSAL_PREFIX.length) + const separator = identity.indexOf('\0') + const host = separator === -1 ? LOCAL_EXECUTION_HOST_ID : identity.slice(0, separator) + const repoId = identity.slice(separator + 1) + if (host !== LOCAL_EXECUTION_HOST_ID || !scope.repoInScope(repoId)) { + continue + } + const hostEntry = `${SETUP_SCRIPT_DISMISSAL_PREFIX}${getRepoHostIdentityForParts(repoId, scope.destinationHostId)}` + if (!entries.includes(hostEntry) && !added.includes(hostEntry)) { + added.push(hostEntry) + } + } + return added.length > 0 ? [...entries, ...added] : null +} + +/** A collapsed lineage group of a moved local worktree stays collapsed under the host. */ +function addHostLineageGroups( + groups: string[] | undefined, + scope: WslMoveRewriteScope +): string[] | null { + if (!groups) { + return null + } + const added: string[] = [] + for (const key of groups) { + if (!key.startsWith(LINEAGE_GROUP_PREFIX)) { + continue + } + const identity = key.slice(LINEAGE_GROUP_PREFIX.length) + const qualified = isWorktreeHostIdentity(identity) + const host = qualified ? getExecutionHostIdFromWorktreeHostIdentity(identity) : undefined + // An unqualified key may name the local row; nothing else on this desktop has a UNC id. + if (qualified && host !== undefined && host !== LOCAL_EXECUTION_HOST_ID) { + continue + } + const worktreeId = qualified ? getWorktreeIdFromHostIdentity(identity) : identity + const hostWorktreeId = scope.worktreeId(worktreeId) + if (!scope.inScope(worktreeId) || hostWorktreeId === worktreeId) { + continue + } + const hostKey = `${LINEAGE_GROUP_PREFIX}${composeWorktreeHostIdentity(scope.destinationHostId, hostWorktreeId)}` + if (!groups.includes(hostKey) && !added.includes(hostKey)) { + added.push(hostKey) + } + } + return added.length > 0 ? [...groups, ...added] : null +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-undo-host-automations.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-undo-host-automations.test.ts new file mode 100644 index 00000000000..26aea7390ae --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-undo-host-automations.test.ts @@ -0,0 +1,348 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { + listWslMoveCutovers, + removeCommittedWslMoveCutover, + writeWslMoveCutover +} from './wsl-move-cutover-journal' +import { listUnfinishedWslMoves } from './wsl-move-host-automations-rewrite' +import { resetWslMoveDesktopRewriteStoresForTests } from './wsl-move-desktop-rewrite-stores' +import { + retryWslMoveHostAutomationsLeftOff, + runWslMoveHostAutomationsExclusive, + settleInterruptedWslMoveUndos, + stopWslMoveHostAutomations, + wslMoveLocalAutomationStateUpdates, + wslMoveMovedAutomationIds, + type WslMoveHostAutomationPorts +} from './wsl-move-undo-host-automations' + +const MOVE = 'move-1' + +/** A fake host and the copies here; `host` is each automation's enabled flag there. */ +function world(hostStates: Record, here: Record) { + const host = new Map(Object.entries(hostStates)) + const local = new Map(Object.entries(here)) + const fail: { disableAt: string | null; restore: boolean } = { disableAt: null, restore: false } + const ports: WslMoveHostAutomationPorts = { + read: async (ids) => Object.fromEntries(ids.map((id) => [id, host.get(id) ?? false])), + disable: async (ids) => { + const done: string[] = [] + for (const id of ids) { + if (id === fail.disableAt) { + // The host drops mid-loop: what was turned off stays off. + throw Object.assign(new Error('wsl_move_automation_disable_failed:gone'), { + leftOff: done + }) + } + host.set(id, false) + done.push(id) + } + return done + }, + restore: async (ids) => { + if (fail.restore) { + throw new Error('host gone') + } + for (const id of ids) { + host.set(id, true) + } + }, + listHere: () => [...local].map(([id, enabled]) => ({ id, enabled })), + setHere: (id, enabled) => { + local.set(id, enabled) + } + } + return { host, local, fail, ports } +} + +describe("undo's host automation step", () => { + it("reads every one of this move's automations, and only this move's", () => { + const moveOf = (...ids: string[]) => ({ + manifest: { payload: { dormantState: { automations: ids.map((id) => ({ id })) } } } + }) + expect(wslMoveMovedAutomationIds(moveOf('a', 'b'))).toEqual(['a', 'b']) + expect(wslMoveMovedAutomationIds({ manifest: { payload: {} } })).toEqual([]) + }) + + it('writes only the copies here that differ from the choice', () => { + expect( + wslMoveLocalAutomationStateUpdates( + [ + { id: 'a', enabled: true }, + { id: 'b', enabled: false }, + { id: 'c', enabled: true }, + { id: 'not-moved', enabled: true } + ], + { a: false, b: true, c: true } + ) + ).toEqual([ + { id: 'a', enabled: false }, + { id: 'b', enabled: true } + ]) + }) +}) + +describe('undo of a move with automations', () => { + let userDataPath: string + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-undo-automations-')) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + seedWslMoveCutover(userDataPath, wslMoveCutoverFixture(MOVE, 'retained')) + }) + afterEach(() => { + vi.restoreAllMocks() + resetWslMoveDesktopRewriteStoresForTests() + rmSync(userDataPath, { recursive: true, force: true }) + }) + const cutover = () => listWslMoveCutovers(userDataPath)[0] + + // d3a: undo kept the pre-move state and dropped what the user chose on the host since. + it("turns off every host copy that is on, and gives the copies here the host's state", async () => { + // off-on-host: turned off there after the move, still on here. + // on-on-host: off here before the move, turned on there after. + const w = world( + { 'off-on-host': false, 'on-on-host': true }, + { 'off-on-host': true, 'on-on-host': false } + ) + const stop = await stopWslMoveHostAutomations( + userDataPath, + MOVE, + ['off-on-host', 'on-on-host'], + w.ports + ) + if (stop.outcome !== 'stopped') { + throw new Error('expected stopped') + } + expect(Object.fromEntries(w.host)).toEqual({ 'off-on-host': false, 'on-on-host': false }) + // Nothing here changes before the release step. + expect(Object.fromEntries(w.local)).toEqual({ 'off-on-host': true, 'on-on-host': false }) + const revert = stop.applyHere() + expect(Object.fromEntries(w.local)).toEqual({ 'off-on-host': false, 'on-on-host': true }) + revert() + expect(Object.fromEntries(w.local)).toEqual({ 'off-on-host': true, 'on-on-host': false }) + }) + + it('a refusal turns back on what it turned off and forgets what it recorded', async () => { + const w = world({ a: true, b: false }, { a: true, b: true }) + const stop = await stopWslMoveHostAutomations(userDataPath, MOVE, ['a', 'b'], w.ports) + expect(cutover()?.undoHostAutomationIntent).toEqual({ a: true, b: false }) + if (stop.outcome !== 'stopped') { + throw new Error('expected stopped') + } + await stop.restore() + expect(Object.fromEntries(w.host)).toEqual({ a: true, b: false }) + expect(cutover()?.undoHostAutomationIntent).toBeUndefined() + expect(cutover()?.hostAutomationsLeftOff).toBeUndefined() + }) + + it('records what a refusal could not turn back on, and a later undo keeps it on here', async () => { + const w = world({ a: true }, { a: true }) + const first = await stopWslMoveHostAutomations(userDataPath, MOVE, ['a'], w.ports) + w.fail.restore = true + if (first.outcome !== 'stopped') { + throw new Error('expected stopped') + } + await first.restore() + expect(w.host.get('a')).toBe(false) + expect(listUnfinishedWslMoves(userDataPath)).toEqual([ + { migrationId: MOVE, distro: 'Ubuntu', automationsPending: ['a'] } + ]) + // d3a #1: off on the host only through the failed rollback; the user's choice is still on. + w.fail.restore = false + const second = await stopWslMoveHostAutomations(userDataPath, MOVE, ['a'], w.ports) + if (second.outcome !== 'stopped') { + throw new Error('expected stopped') + } + second.applyHere() + expect(w.local.get('a')).toBe(true) + }) + + it('records what the host dropped mid-loop left off, and shows it in the notice', async () => { + const w = world({ a: true, b: true }, { a: true, b: true }) + w.fail.disableAt = 'b' + const stop = await stopWslMoveHostAutomations(userDataPath, MOVE, ['a', 'b'], w.ports) + expect(stop).toEqual({ outcome: 'failed' }) + expect(cutover()?.hostAutomationsLeftOff).toEqual(['a']) + // Only the left-off one keeps its recorded choice for a later undo. + expect(cutover()?.undoHostAutomationIntent).toEqual({ a: true }) + expect(listUnfinishedWslMoves(userDataPath)[0]?.automationsPending).toEqual(['a']) + }) + + it('the retry turns left-off ones back on and clears them', async () => { + const w = world({ a: true }, { a: true }) + w.fail.restore = true + const stop = await stopWslMoveHostAutomations(userDataPath, MOVE, ['a'], w.ports) + if (stop.outcome === 'stopped') { + await stop.restore() + } + const disable = async () => {} + expect( + await retryWslMoveHostAutomationsLeftOff(userDataPath, { + restore: async () => { + throw new Error('unreachable') + }, + disable + }) + ).toBe(true) + const turnedOn = vi.fn(async () => {}) + expect( + await retryWslMoveHostAutomationsLeftOff(userDataPath, { restore: turnedOn, disable }) + ).toBe(false) + expect(turnedOn).toHaveBeenCalledWith('wsl:Ubuntu', ['a']) + expect(cutover()?.hostAutomationsLeftOff).toBeUndefined() + expect(cutover()?.undoHostAutomationIntent).toBeUndefined() + }) + + // d3-gate-audit / d3a #1: a retry mid-RPC while an undo releases the move. + it('takes turns with an undo, so a released move is neither resurrected nor left on the host', async () => { + const w = world({ a: true }, { a: true }) + w.fail.restore = true + const stop = await stopWslMoveHostAutomations(userDataPath, MOVE, ['a'], w.ports) + if (stop.outcome === 'stopped') { + await stop.restore() + } + w.fail.restore = false + const order: string[] = [] + let finishRestore = () => {} + const retry = retryWslMoveHostAutomationsLeftOff(userDataPath, { + restore: async (_env, ids) => { + order.push('retry restore') + await new Promise((resolve) => { + finishRestore = resolve + }) + for (const id of ids) { + w.host.set(id, true) + } + }, + disable: async () => {} + }) + // The undo waits for the retry's turn, then turns the host copy off and releases. + const undo = runWslMoveHostAutomationsExclusive(async () => { + order.push('undo') + const again = await stopWslMoveHostAutomations(userDataPath, MOVE, ['a'], w.ports) + if (again.outcome === 'stopped') { + again.applyHere() + } + removeCommittedWslMoveCutover(userDataPath, MOVE) + }) + await vi.waitFor(() => expect(order).toEqual(['retry restore'])) + finishRestore() + await Promise.all([retry, undo]) + expect(order).toEqual(['retry restore', 'undo']) + expect(w.host.get('a')).toBe(false) + expect(w.local.get('a')).toBe(true) + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + }) + + it('a retry finding the move released writes nothing back and turns its host copies off again', async () => { + const w = world({ a: true }, { a: true }) + w.fail.restore = true + const stop = await stopWslMoveHostAutomations(userDataPath, MOVE, ['a'], w.ports) + if (stop.outcome === 'stopped') { + await stop.restore() + } + expect(w.host.get('a')).toBe(false) + const disable = vi.fn(async (_env: string, ids: readonly string[]) => { + for (const id of ids) { + w.host.set(id, false) + } + }) + expect( + await retryWslMoveHostAutomationsLeftOff(userDataPath, { + restore: async (_env, ids) => { + for (const id of ids) { + w.host.set(id, true) + } + // The move is released by another route during the host round trip. + removeCommittedWslMoveCutover(userDataPath, MOVE) + }, + disable + }) + ).toBe(false) + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + // The project is local again: the host copy this retry turned on goes back off. + expect(disable).toHaveBeenCalledWith('wsl:Ubuntu', ['a']) + expect(w.host.get('a')).toBe(false) + }) + + it('at startup, an undo a crash interrupted sends what it read as on back on', async () => { + const w = world({ a: true, b: false }, { a: true, b: true }) + await stopWslMoveHostAutomations(userDataPath, MOVE, ['a', 'b'], w.ports) + // Crash here: the host copies are off, the record holds the choice. + settleInterruptedWslMoveUndos(userDataPath) + // The left-off one keeps its choice for a later undo; the rest clears. + expect(cutover()?.undoHostAutomationIntent).toEqual({ a: true }) + expect(cutover()?.hostAutomationsLeftOff).toEqual(['a']) + }) + + // d3a: a restart cleared the record, so a later undo read the left-off one as the user's "off". + it('a later undo after a restart and failed retries still keeps a left-off one on here', async () => { + const w = world({ a: true }, { a: true }) + w.fail.restore = true + const refused = await stopWslMoveHostAutomations(userDataPath, MOVE, ['a'], w.ports) + if (refused.outcome === 'stopped') { + await refused.restore() + } + settleInterruptedWslMoveUndos(userDataPath) + const unreachable = { + restore: async () => { + throw new Error('unreachable') + }, + disable: async () => {} + } + expect(await retryWslMoveHostAutomationsLeftOff(userDataPath, unreachable)).toBe(true) + expect(await retryWslMoveHostAutomationsLeftOff(userDataPath, unreachable)).toBe(true) + expect(w.host.get('a')).toBe(false) + w.fail.restore = false + const later = await stopWslMoveHostAutomations(userDataPath, MOVE, ['a'], w.ports) + if (later.outcome !== 'stopped') { + throw new Error('expected stopped') + } + later.applyHere() + expect(w.local.get('a')).toBe(true) + }) + + it('treats a left-off one as on even when only the left-off list survives', async () => { + const seeded = cutover() + if (!seeded) { + throw new Error('expected the move') + } + // Only the left-off list survives (an older build dropped the recorded choice). + writeWslMoveCutover(userDataPath, { ...seeded, hostAutomationsLeftOff: ['a'] }) + const w = world({ a: false }, { a: true }) + const stop = await stopWslMoveHostAutomations(userDataPath, MOVE, ['a'], w.ports) + if (stop.outcome !== 'stopped') { + throw new Error('expected stopped') + } + stop.applyHere() + expect(w.local.get('a')).toBe(true) + }) + + it('a revert that fails for one copy here still puts the others back', async () => { + const w = world({ a: false, b: false }, { a: true, b: true }) + const failRevertOf: { id: string | null } = { id: null } + const ports = { + ...w.ports, + setHere: (id: string, enabled: boolean) => { + if (id === failRevertOf.id) { + throw new Error('store busy') + } + w.local.set(id, enabled) + } + } + const stop = await stopWslMoveHostAutomations(userDataPath, MOVE, ['a', 'b'], ports) + if (stop.outcome !== 'stopped') { + throw new Error('expected stopped') + } + const revert = stop.applyHere() + expect(Object.fromEntries(w.local)).toEqual({ a: false, b: false }) + failRevertOf.id = 'a' + revert() + expect(Object.fromEntries(w.local)).toEqual({ a: false, b: true }) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-undo-host-automations.ts b/src/main/persistence/moving-wsl-projects/wsl-move-undo-host-automations.ts new file mode 100644 index 00000000000..f25a2e3563b --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-undo-host-automations.ts @@ -0,0 +1,254 @@ +/** + * Undo's automation step. A released move's automations run here again, so every one of the + * move's host copies that is on turns off first, and the copy here takes the host's state, the + * user's latest choice: each runs in exactly one place. That state is recorded on the move before + * anything turns off, so a crash or a refusal that left one off never passes for the choice. A + * refused undo turns back on what it turned off and changes nothing here; one it cannot turn back + * on is recorded, shown and retried. + */ +import type { Automation } from '../../../shared/automations-types' +import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import { listWslMoveCutovers, writeWslMoveCutover } from './wsl-move-cutover-journal' + +export type WslMoveHostAutomationStop = + | { + outcome: 'stopped' + /** A later undo step refused: turn back on what this attempt turned off on the host. */ + restore: () => Promise + /** Gives the copies here the recorded host state; returns how to undo that write. */ + applyHere: () => () => void + } + | { outcome: 'unreachable' | 'failed' | 'removal-pending' } + +export type WslMoveHostAutomationPorts = { + read: (automationIds: readonly string[]) => Promise> + /** Throws on a refusal; ids it could not turn back on ride the error as `leftOff`. */ + disable: (automationIds: readonly string[]) => Promise + restore: (automationIds: readonly string[]) => Promise + listHere: () => readonly Pick[] + setHere: (automationId: string, enabled: boolean) => void +} + +let exclusive: Promise = Promise.resolve() + +/** + * Undo (stop, release) and the left-off retry take turns: a retry turning a host copy back on + * while an undo releases the move would leave it on beside the copy here. + */ +export function runWslMoveHostAutomationsExclusive(run: () => Promise): Promise { + const next = exclusive.then(run, run) + exclusive = next.catch(() => {}) + return next +} + +/** Every automation the move carried, whatever its state here or on the host. */ +export function wslMoveMovedAutomationIds(move: { + manifest: { payload: { dormantState?: { automations?: readonly { id: string }[] } } } +}): string[] { + return (move.manifest.payload.dormantState?.automations ?? []).map(({ id }) => id) +} + +/** The copies here whose `enabled` differs from the user's choice. */ +export function wslMoveLocalAutomationStateUpdates( + localAutomations: readonly Pick[], + intent: Readonly> +): { id: string; enabled: boolean }[] { + return localAutomations.flatMap((automation) => { + const enabled = intent[automation.id] + return enabled === undefined || enabled === automation.enabled + ? [] + : [{ id: automation.id, enabled }] + }) +} + +function classify(error: unknown): 'failed' | 'unreachable' { + const message = error instanceof Error ? error.message : String(error) + return message.startsWith('wsl_move_automation_') ? 'failed' : 'unreachable' +} + +function leftOffOf(error: unknown): string[] { + return typeof error === 'object' && + error !== null && + 'leftOff' in error && + Array.isArray(error.leftOff) + ? error.leftOff.filter((id): id is string => typeof id === 'string') + : [] +} + +export async function stopWslMoveHostAutomations( + userDataPath: string, + migrationId: string, + automationIds: readonly string[], + ports: WslMoveHostAutomationPorts +): Promise { + if (automationIds.length === 0) { + return { outcome: 'stopped', restore: async () => {}, applyHere: () => () => {} } + } + let hostStates: Record + try { + hostStates = await ports.read(automationIds) + } catch (error) { + return { outcome: classify(error) } + } + const { intent, recorded } = recordUndoIntent(userDataPath, migrationId, hostStates) + let turnedOff: string[] + try { + turnedOff = await ports.disable(automationIds.filter((id) => hostStates[id])) + } catch (error) { + settleRefusedUndo(userDataPath, migrationId, recorded, leftOffOf(error)) + return { outcome: classify(error) } + } + return { + outcome: 'stopped', + restore: async () => { + let leftOff: string[] = [] + try { + await ports.restore(turnedOff) + } catch (error) { + console.warn('[wsl-move] Could not turn host automations back on:', error) + leftOff = leftOffOf(error).length > 0 ? leftOffOf(error) : turnedOff + } + settleRefusedUndo(userDataPath, migrationId, recorded, leftOff) + }, + applyHere: () => { + const applied: { id: string; enabled: boolean }[] = [] + for (const update of wslMoveLocalAutomationStateUpdates(ports.listHere(), intent)) { + try { + ports.setHere(update.id, update.enabled) + applied.push(update) + } catch (error) { + console.warn('[wsl-move] Could not give an automation here its host state:', error) + } + } + return () => { + for (const { id, enabled } of applied) { + try { + ports.setHere(id, !enabled) + } catch (error) { + console.warn('[wsl-move] Could not put an automation here back:', error) + } + } + } + } + } +} + +function readMove(userDataPath: string, migrationId: string): WslMoveCutover | undefined { + return listWslMoveCutovers(userDataPath).find((entry) => entry.migrationId === migrationId) +} + +/** Adds what this attempt read; an earlier attempt's entry wins, as the host shows its own off. */ +function recordUndoIntent( + userDataPath: string, + migrationId: string, + hostStates: Readonly> +): { intent: Record; recorded: string[] } { + const cutover = readMove(userDataPath, migrationId) + // Left off by an earlier undo: on is the choice, whatever the host shows now. + const earlier = { + ...Object.fromEntries((cutover?.hostAutomationsLeftOff ?? []).map((id) => [id, true])), + ...cutover?.undoHostAutomationIntent + } + const recorded = Object.keys(hostStates).filter((id) => !Object.hasOwn(earlier, id)) + const intent = { ...hostStates, ...earlier } + if (cutover && recorded.length > 0) { + writeWslMoveCutover(userDataPath, { ...cutover, undoHostAutomationIntent: intent }) + } + return { intent, recorded } +} + +/** A refused undo forgets what it recorded, except what it left off, which waits to be retried. */ +function settleRefusedUndo( + userDataPath: string, + migrationId: string, + recorded: readonly string[], + leftOff: readonly string[] +): void { + const cutover = readMove(userDataPath, migrationId) + if (!cutover) { + return + } + const kept = new Set(leftOff) + const forget = new Set(recorded.filter((id) => !kept.has(id))) + const intent = Object.fromEntries( + Object.entries(cutover.undoHostAutomationIntent ?? {}).filter(([id]) => !forget.has(id)) + ) + const left = [...new Set([...(cutover.hostAutomationsLeftOff ?? []), ...leftOff])].sort() + writeWslMoveCutover(userDataPath, withUndoAutomationFields(cutover, intent, left)) +} + +function withUndoAutomationFields( + cutover: WslMoveCutover, + intent: Record, + leftOff: string[] +): WslMoveCutover { + const { undoHostAutomationIntent: _intent, hostAutomationsLeftOff: _leftOff, ...rest } = cutover + return { + ...rest, + ...(Object.keys(intent).length > 0 ? { undoHostAutomationIntent: intent } : {}), + ...(leftOff.length > 0 ? { hostAutomationsLeftOff: leftOff } : {}) + } +} + +/** + * At startup no undo is running, so a recorded choice is an undo a crash interrupted: what it + * read as on goes back on through the retry and keeps its choice for a later undo; the rest clears. + */ +export function settleInterruptedWslMoveUndos(userDataPath: string): void { + for (const cutover of listWslMoveCutovers(userDataPath)) { + const intent = cutover.undoHostAutomationIntent + if (!intent) { + continue + } + const on = Object.entries(intent).flatMap(([id, enabled]) => (enabled ? [id] : [])) + const left = [...new Set([...(cutover.hostAutomationsLeftOff ?? []), ...on])].sort() + const kept = Object.fromEntries(on.map((id) => [id, true])) + writeWslMoveCutover(userDataPath, withUndoAutomationFields(cutover, kept, left)) + } +} + +export type WslMoveHostAutomationRetryHost = { + restore: (environmentId: string, automationIds: readonly string[]) => Promise + disable: (environmentId: string, automationIds: readonly string[]) => Promise +} + +/** Turns back on what refused undos left off; true while any is still left off. */ +export async function retryWslMoveHostAutomationsLeftOff( + userDataPath: string, + host: WslMoveHostAutomationRetryHost +): Promise { + let remaining = false + for (const { migrationId } of listWslMoveCutovers(userDataPath)) { + const left = await runWslMoveHostAutomationsExclusive(async () => { + // Fresh inside the turn: an undo may have released the move, or a pass advanced it. + const cutover = readMove(userDataPath, migrationId) + const ids = cutover?.hostAutomationsLeftOff ?? [] + if (!cutover || ids.length === 0) { + return 0 + } + let stillOff: string[] = [] + try { + await host.restore(cutover.destinationEnvironmentId, ids) + } catch (error) { + stillOff = leftOffOf(error).length > 0 ? leftOffOf(error) : ids + } + const turnedOn = new Set(ids.filter((id) => !stillOff.includes(id))) + const current = readMove(userDataPath, migrationId) + if (!current) { + // Released meanwhile: the project is local again, so what this turned on goes back off. + await host.disable(cutover.destinationEnvironmentId, [...turnedOn]).catch((error) => { + console.warn("[wsl-move] Could not turn a released move's host automations off:", error) + }) + return 0 + } + const leftOff = (current.hostAutomationsLeftOff ?? []).filter((id) => !turnedOn.has(id)) + const intent = Object.fromEntries( + Object.entries(current.undoHostAutomationIntent ?? {}).filter(([id]) => !turnedOn.has(id)) + ) + writeWslMoveCutover(userDataPath, withUndoAutomationFields(current, intent, leftOff)) + return leftOff.length + }) + remaining ||= left > 0 + } + return remaining +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-undo-spawn-fence.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-undo-spawn-fence.test.ts new file mode 100644 index 00000000000..ca82af75325 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-undo-spawn-fence.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + fenceHostSpawnsForWslMoveUndo, + isHostSpawnFencedForWslMoveUndo, + resetWslMoveUndoSpawnFencesForTests, + wslMoveUndoSpawnFencedResponse +} from './wsl-move-undo-spawn-fence' + +afterEach(() => { + vi.useRealTimers() + resetWslMoveUndoSpawnFencesForTests() +}) + +describe('WSL move undo spawn fence', () => { + it('holds while any undo of that host holds it', () => { + const first = fenceHostSpawnsForWslMoveUndo('wsl:Ubuntu') + const second = fenceHostSpawnsForWslMoveUndo('wsl:Ubuntu') + first() + first() + expect(isHostSpawnFencedForWslMoveUndo('wsl:Ubuntu', 'terminal.create')).toBe(true) + second() + expect(isHostSpawnFencedForWslMoveUndo('wsl:Ubuntu', 'terminal.create')).toBe(false) + }) + + it('lifts itself after the cap so a lost release cannot strand the host', () => { + vi.useFakeTimers() + fenceHostSpawnsForWslMoveUndo('wsl:Ubuntu') + vi.advanceTimersByTime(119_999) + expect(isHostSpawnFencedForWslMoveUndo('wsl:Ubuntu', 'agentSession.ensure')).toBe(true) + vi.advanceTimersByTime(1) + expect(isHostSpawnFencedForWslMoveUndo('wsl:Ubuntu', 'agentSession.ensure')).toBe(false) + }) + + it('answers a refused start as a failed call from the host', () => { + expect(wslMoveUndoSpawnFencedResponse({ runtimeId: 'rt-1' }, 'terminal.create')).toMatchObject({ + id: 'terminal.create', + ok: false, + error: { code: 'wsl_move_undo_in_progress' }, + _meta: { runtimeId: 'rt-1' } + }) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-undo-spawn-fence.ts b/src/main/persistence/moving-wsl-projects/wsl-move-undo-spawn-fence.ts new file mode 100644 index 00000000000..c07fd234bd0 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-undo-spawn-fence.ts @@ -0,0 +1,63 @@ +import type { RuntimeRpcFailure } from '../../../shared/runtime-rpc-envelope' + +/** + * While "Use on this computer again" stops a project's terminals on its WSL host, this window's + * host panes must not start them again. An open host pane whose terminal the stop killed resumes + * its agent on the host; the reloaded window then resumes it here too. + */ +const HOST_SPAWN_METHODS: ReadonlySet = new Set([ + 'terminal.create', + 'terminal.createAgentSession', + 'terminal.ensureAgentSession', + 'terminal.split', + 'agentSession.create', + 'agentSession.ensure', + 'agentSession.restartResume', + 'agentSession.restartContinue' +]) + +// Why a cap: a release lost to a crashed reload must not leave the host unusable from here. +const FENCE_MAX_MS = 120_000 + +const fences = new Map>() + +/** Refuses this window's starts on the host until the returned release runs, or the cap passes. */ +export function fenceHostSpawnsForWslMoveUndo(environmentId: string): () => void { + const token = Symbol(environmentId) + const tokens = fences.get(environmentId) ?? new Set() + tokens.add(token) + fences.set(environmentId, tokens) + const release = (): void => { + clearTimeout(timer) + tokens.delete(token) + if (tokens.size === 0 && fences.get(environmentId) === tokens) { + fences.delete(environmentId) + } + } + const timer = setTimeout(release, FENCE_MAX_MS) + timer.unref?.() + return release +} + +export function isHostSpawnFencedForWslMoveUndo(environmentId: string, method: string): boolean { + return HOST_SPAWN_METHODS.has(method) && (fences.get(environmentId)?.size ?? 0) > 0 +} + +export function wslMoveUndoSpawnFencedResponse( + environment: { runtimeId?: string | null }, + method: string +): RuntimeRpcFailure { + return { + id: method, + ok: false, + error: { + code: 'wsl_move_undo_in_progress', + message: 'This project is moving back to this computer; its terminals start here.' + }, + _meta: { runtimeId: environment.runtimeId ?? null } + } +} + +export function resetWslMoveUndoSpawnFencesForTests(): void { + fences.clear() +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-use-locally.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-use-locally.test.ts new file mode 100644 index 00000000000..daa87cc104d --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-use-locally.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { listWslMoveCutovers } from './wsl-move-cutover-journal' +import { resolveWslMoveFence } from './wsl-move-fence' +import { bringWslMovedProjectBack } from './wsl-move-use-locally' + +let userDataPath: string +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-use-locally-')) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') +}) +afterEach(() => { + vi.unstubAllEnvs() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +const hidden = (): ReadonlySet => resolveWslMoveFence(userDataPath).hidden.repoIds + +describe('bringWslMovedProjectBack', () => { + it('drops the finished move, so its retained rows list here again', () => { + seedWslMoveCutover( + userDataPath, + wslMoveCutoverFixture('move-1', 'retained', {}, { repoIds: ['repo-1', 'repo-2'] }) + ) + expect(hidden().has('repo-1')).toBe(true) + + expect(bringWslMovedProjectBack(userDataPath, { repoId: 'repo-1' })).toEqual({ + released: true, + distro: 'Ubuntu', + repoIds: ['repo-1', 'repo-2'], + folderWorkspaceIds: [] + }) + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + expect(hidden().size).toBe(0) + }) + + it('also releases a committed move whose desktop rewrite never finished', () => { + seedWslMoveCutover(userDataPath, wslMoveCutoverFixture('move-1', 'destination-committed')) + expect(bringWslMovedProjectBack(userDataPath, { repoId: 'repo-1' }).released).toBe(true) + expect(hidden().size).toBe(0) + }) + + it('refuses a project no committed move took', () => { + seedWslMoveCutover(userDataPath, wslMoveCutoverFixture('move-1', 'blobs-transferred')) + expect(bringWslMovedProjectBack(userDataPath, { repoId: 'repo-1' })).toEqual({ + released: false, + reason: 'not-moved' + }) + expect(bringWslMovedProjectBack(userDataPath, { folderWorkspaceId: 'other' })).toEqual({ + released: false, + reason: 'not-moved' + }) + expect(listWslMoveCutovers(userDataPath)).toHaveLength(1) + }) + + it('refuses a move a later move of the same distro builds on', () => { + seedWslMoveCutover(userDataPath, wslMoveCutoverFixture('move-1', 'retained')) + seedWslMoveCutover( + userDataPath, + wslMoveCutoverFixture( + 'move-2', + 'retained', + { supersedesMigrationId: 'move-1' }, + { repoIds: ['repo-9'] } + ) + ) + expect(bringWslMovedProjectBack(userDataPath, { repoId: 'repo-1' })).toEqual({ + released: false, + reason: 'superseded', + // The refusal named no move, so nothing said what to bring back first. + newerMove: { repoIds: ['repo-9'], folderWorkspaceIds: [] } + }) + expect(hidden().has('repo-1')).toBe(true) + // The newest one can go, and then the earlier one becomes the newest. + expect(bringWslMovedProjectBack(userDataPath, { repoId: 'repo-9' }).released).toBe(true) + expect(bringWslMovedProjectBack(userDataPath, { repoId: 'repo-1' }).released).toBe(true) + }) + + it('does nothing with the WSL hosts gate closed', () => { + seedWslMoveCutover(userDataPath, wslMoveCutoverFixture('move-1', 'retained')) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + expect(bringWslMovedProjectBack(userDataPath, { repoId: 'repo-1' })).toEqual({ + released: false, + reason: 'gated-off' + }) + expect(listWslMoveCutovers(userDataPath)).toHaveLength(1) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-use-locally.ts b/src/main/persistence/moving-wsl-projects/wsl-move-use-locally.ts new file mode 100644 index 00000000000..aaaee818f6e --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-use-locally.ts @@ -0,0 +1,149 @@ +/** + * The escape hatch for a moved project (no reverse mover, owner decision 2): drop the committed + * move so the retained source rows, untouched since the move, are listed here again. Nothing is + * copied back from the WSL host; the host keeps its own copy until the user removes it there. + * The repository files never moved, so code is never at stake, only Orca's own state. + */ +import { + WSL_MOVE_CUTOVER_PHASES, + type WslMoveCutover +} from '../../../shared/wsl-move/wsl-move-cutover' +import type { WslMoveUseLocallyResult } from '../../../shared/wsl-move/wsl-moved-project' +import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import { isWslMoveRunning } from './wsl-move-running-moves' +import { + listWslMoveCutoverChain, + listWslMoveCutovers, + removeCommittedWslMoveCutover +} from './wsl-move-cutover-journal' +import { type WslMoveProjectRef, wslMoveProjectRefForWorkspace } from './wsl-move-scope-guard' + +const COMMITTED_FROM = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') + +function inScope(cutover: WslMoveCutover, ref: WslMoveProjectRef): boolean { + const { scopeRepoIds, scopeFolderWorkspaceIds } = cutover.manifest.source + return 'repoId' in ref + ? scopeRepoIds.includes(ref.repoId) + : scopeFolderWorkspaceIds.includes(ref.folderWorkspaceId) +} + +/** The workspaces among `workspaceIds` whose projects the move brings back. */ +export function wslMoveWorkspacesInScope( + move: WslMoveCutover, + workspaceIds: readonly string[] +): string[] { + return workspaceIds.filter((workspaceId) => { + const ref = wslMoveProjectRefForWorkspace(workspaceId) + return ref !== null && inScope(move, ref) + }) +} + +/** The committed move that holds the project, newest in its distro's chain, or why there is none. */ +export function findWslMovedProjectMove( + userDataPath: string, + ref: WslMoveProjectRef +): { move: WslMoveCutover } | Extract { + if (!wslHostsGateOpen()) { + return { released: false, reason: 'gated-off' } + } + let move: WslMoveCutover | undefined + try { + const taken = listWslMoveCutovers(userDataPath).find( + (cutover) => + WSL_MOVE_CUTOVER_PHASES.indexOf(cutover.phase) >= COMMITTED_FROM && inScope(cutover, ref) + ) + // Newest in its distro's chain: a later delta move may carry the project on. + move = taken + ? listWslMoveCutoverChain(userDataPath, taken.journalKey).findLast((cutover) => + inScope(cutover, ref) + ) + : undefined + } catch (error) { + return { released: false, reason: 'failed', detail: describe(error) } + } + if (!move) { + return { released: false, reason: 'not-moved' } + } + // The running driver still writes this record; dropping it under the driver loses its outcome. + if (isWslMoveRunning(move.manifest.source.distro)) { + return { released: false, reason: 'move-running' } + } + return { move } +} + +/** + * Why `releaseWslMove` would refuse, read without changing anything, so undo can refuse before it + * stops the host's copies; null when the release would go ahead. + */ +export function wslMoveReleaseRefusal( + userDataPath: string, + move: WslMoveCutover +): Extract | null { + if (isWslMoveRunning(move.manifest.source.distro)) { + return { released: false, reason: 'move-running' } + } + try { + const newest = listWslMoveCutoverChain(userDataPath, move.journalKey).at(-1) + return newest && newest.migrationId !== move.migrationId + ? { released: false, reason: 'superseded', ...newerMoveOf(userDataPath, move) } + : null + } catch (error) { + return { released: false, reason: 'failed', detail: describe(error) } + } +} + +/** Drops the committed move so its retained source rows list here again. */ +export function releaseWslMove( + userDataPath: string, + move: WslMoveCutover +): WslMoveUseLocallyResult { + if (isWslMoveRunning(move.manifest.source.distro)) { + return { released: false, reason: 'move-running' } + } + try { + removeCommittedWslMoveCutover(userDataPath, move.migrationId) + } catch (error) { + const detail = describe(error) + return detail === 'wsl_move_superseded' + ? { released: false, reason: 'superseded', ...newerMoveOf(userDataPath, move) } + : { released: false, reason: 'failed', detail } + } + const { scopeRepoIds, scopeFolderWorkspaceIds } = move.manifest.source + return { + released: true, + distro: move.manifest.source.distro, + repoIds: [...scopeRepoIds], + folderWorkspaceIds: [...scopeFolderWorkspaceIds] + } +} + +export function bringWslMovedProjectBack( + userDataPath: string, + ref: WslMoveProjectRef +): WslMoveUseLocallyResult { + const found = findWslMovedProjectMove(userDataPath, ref) + return 'move' in found ? releaseWslMove(userDataPath, found.move) : found +} + +/** The move that has to come back first, so the refusal can name its projects. */ +function newerMoveOf( + userDataPath: string, + move: WslMoveCutover +): { newerMove?: { repoIds: string[]; folderWorkspaceIds: string[] } } { + try { + const newest = listWslMoveCutoverChain(userDataPath, move.journalKey).at(-1) + if (!newest || newest.migrationId === move.migrationId) { + return {} + } + const { scopeRepoIds, scopeFolderWorkspaceIds } = newest.manifest.source + return { + newerMove: { repoIds: [...scopeRepoIds], folderWorkspaceIds: [...scopeFolderWorkspaceIds] } + } + } catch { + return {} + } +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-window-write-hold.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-window-write-hold.test.ts new file mode 100644 index 00000000000..6c2c2a6b4a3 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-window-write-hold.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + holdWslMoveWindowWrites, + holdWslMoveWindowWritesForCommit, + resetWslMoveWindowWriteHoldForTests, + settleWslMoveWindowWriteHoldForCommit, + isWslMoveWindowWriteHeld, + reloadWslMoveWindowWithWritesHeld, + setWslMoveReloadableWindow +} from './wsl-move-window-write-hold' + +function fakeWindow() { + const page = { loaded: () => {}, unloadAllowed: false } + const window = { + isDestroyed: vi.fn(() => false), + reload: vi.fn(), + allowUnload: () => { + page.unloadAllowed = true + return () => { + page.unloadAllowed = false + } + }, + onLoadedOrGone: (listener: () => void) => { + page.loaded = listener + return () => {} + } + } + setWslMoveReloadableWindow(() => window) + return { window, page } +} + +const nextTurn = () => new Promise((resolve) => setImmediate(resolve)) + +afterEach(() => { + resetWslMoveWindowWriteHoldForTests() +}) + +describe('WSL move window write hold', () => { + it('holds nothing without a window', () => { + holdWslMoveWindowWrites() + expect(isWslMoveWindowWriteHeld()).toBe(false) + }) + + it("holds the window's saves until its reloaded page has loaded, past any unload veto", async () => { + const { window, page } = fakeWindow() + holdWslMoveWindowWrites() + holdWslMoveWindowWrites() + expect(isWslMoveWindowWriteHeld()).toBe(true) + + reloadWslMoveWindowWithWritesHeld() + reloadWslMoveWindowWithWritesHeld() + expect(page.unloadAllowed).toBe(true) + await nextTurn() + expect(window.reload).toHaveBeenCalledOnce() + expect(isWslMoveWindowWriteHeld()).toBe(true) + + page.loaded() + expect(isWslMoveWindowWriteHeld()).toBe(false) + // A later veto (a real quit with unsaved files) is the page's again. + expect(page.unloadAllowed).toBe(false) + }) + + it('reloads nothing when nothing was held', async () => { + const { window } = fakeWindow() + reloadWslMoveWindowWithWritesHeld() + await nextTurn() + expect(window.reload).not.toHaveBeenCalled() + }) + + it('lets the saves resume unreloaded when the commit request did not commit', async () => { + const { window } = fakeWindow() + holdWslMoveWindowWritesForCommit() + expect(isWslMoveWindowWriteHeld()).toBe(true) + settleWslMoveWindowWriteHoldForCommit(false) + expect(isWslMoveWindowWriteHeld()).toBe(false) + await nextTurn() + expect(window.reload).not.toHaveBeenCalled() + }) + + it('reloads once the commit settles, not when a rewrite pass ends before it', async () => { + const { window, page } = fakeWindow() + holdWslMoveWindowWritesForCommit() + holdWslMoveWindowWrites() + reloadWslMoveWindowWithWritesHeld() + await nextTurn() + expect(window.reload).not.toHaveBeenCalled() + + settleWslMoveWindowWriteHoldForCommit(true) + await nextTurn() + expect(window.reload).toHaveBeenCalledOnce() + expect(isWslMoveWindowWriteHeld()).toBe(true) + page.loaded() + expect(isWslMoveWindowWriteHeld()).toBe(false) + // Settled once; a second settle (the driver's own end) changes nothing. + settleWslMoveWindowWriteHoldForCommit(false) + expect(isWslMoveWindowWriteHeld()).toBe(false) + }) + + it('reloads after an uncommitted run when a rewrite pass held the window meanwhile', async () => { + const { window } = fakeWindow() + holdWslMoveWindowWritesForCommit() + holdWslMoveWindowWrites() + settleWslMoveWindowWriteHoldForCommit(false) + await nextTurn() + expect(window.reload).toHaveBeenCalledOnce() + expect(isWslMoveWindowWriteHeld()).toBe(true) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-window-write-hold.ts b/src/main/persistence/moving-wsl-projects/wsl-move-window-write-hold.ts new file mode 100644 index 00000000000..9642ff88032 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-window-write-hold.ts @@ -0,0 +1,106 @@ +/** + * A window hydrated before a move's desktop rewrite (or before "Use on this computer again") holds + * stale state, and every save it makes would write that back. So from the rewrite until the + * window's next page has loaded, its saves (session, UI, the unload checkpoint) are dropped, and + * main reloads it; the new page reads the rewritten state from disk. One path for move and undo. + */ + +export type WslMoveReloadableWindow = { + isDestroyed: () => boolean + reload: () => void + /** Lets the next unload past a beforeunload veto; returns its removal. */ + allowUnload: () => () => void + /** Calls back once the next page has loaded or the window is gone; returns its removal. */ + onLoadedOrGone: (listener: () => void) => () => void +} + +let held = false +let reloadOwed = false +// A commit request is out: its outcome, not a rewrite pass that happens to end, decides the reload. +let commitPending = false +let getWindow: () => WslMoveReloadableWindow | null = () => null + +/** The app window's contents, or null when none is open. */ +export function setWslMoveReloadableWindow(get: () => WslMoveReloadableWindow | null): void { + getWindow = get +} + +export function isWslMoveWindowWriteHeld(): boolean { + return held +} + +function hold(): boolean { + const window = getWindow() + if (!window || window.isDestroyed()) { + return false + } + held = true + return true +} + +/** Before a rewrite of window-owned state: drops the open window's saves until it reloads. */ +export function holdWslMoveWindowWrites(): void { + if (hold()) { + reloadOwed = true + } +} + +/** Before a commit request: the host may publish the moved projects to the window from here on. */ +export function holdWslMoveWindowWritesForCommit(): void { + if (hold()) { + commitPending = true + } +} + +/** The commit request settled: committed, the window reloads; otherwise its saves resume. */ +export function settleWslMoveWindowWriteHoldForCommit(committed: boolean): void { + if (!commitPending) { + return + } + commitPending = false + if (committed) { + reloadOwed = true + } + if (reloadOwed) { + reloadWslMoveWindowWithWritesHeld() + } else { + held = false + } +} + +/** After the rewrite is on disk: reloads the held window. A no-op when nothing was held. */ +export function reloadWslMoveWindowWithWritesHeld(onReloaded: () => void = () => {}): void { + if (!reloadOwed || commitPending) { + onReloaded() + return + } + reloadOwed = false + const window = getWindow() + if (!window || window.isDestroyed()) { + held = false + onReloaded() + return + } + // Why override: drafts reach disk with each save before the hold, and a vetoed reload would keep + // the stale page with its saves dropped. + const removeAllowUnload = window.allowUnload() + const removeLift = window.onLoadedOrGone(() => { + held = false + removeAllowUnload() + removeLift() + onReloaded() + }) + // After the caller's reply reaches the renderer, so its awaited call settles first. + setImmediate(() => { + if (!window.isDestroyed()) { + window.reload() + } + }) +} + +export function resetWslMoveWindowWriteHoldForTests(): void { + held = false + reloadOwed = false + commitPending = false + getWindow = () => null +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-windows-shell-choices.ts b/src/main/persistence/moving-wsl-projects/wsl-move-windows-shell-choices.ts new file mode 100644 index 00000000000..cf49b71f7a4 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-windows-shell-choices.ts @@ -0,0 +1,18 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +/** + * A tab's Windows launch choices, dropped on purpose: `shellOverride` names a Windows shell + * (`wsl.exe`, PowerShell, Git Bash) the Linux host can't run, and `forceHostRuntime` pins a tab to + * this desktop. The host starts its own shell. Unlisted, the session parser dropped them silently. + */ +export function withoutWindowsShellChoices(session: WorkspaceSessionState): WorkspaceSessionState { + return { + ...session, + tabsByWorktree: Object.fromEntries( + Object.entries(session.tabsByWorktree).map(([owner, tabs]) => [ + owner, + tabs.map(({ shellOverride: _shell, forceHostRuntime: _pinned, ...tab }) => tab) + ]) + ) + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-moved-projects.test.ts b/src/main/persistence/moving-wsl-projects/wsl-moved-projects.test.ts new file mode 100644 index 00000000000..64ae4c5b0b0 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-moved-projects.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { loadWslMoveFence, resetWslMoveFenceForTests } from './wsl-move-scope-guard' +import { listWslMovedProjects } from './wsl-moved-projects' + +const UNC = '\\\\wsl.localhost\\Ubuntu\\home\\dev' +const repos = [ + { id: 'repo-1', displayName: 'Repo one', path: `${UNC}\\repo-1` }, + // The host's own row after the move: same id, another host. + { + id: 'repo-1', + displayName: 'Repo one', + path: '/home/dev/repo-1', + executionHostId: 'runtime:wsl-ubuntu' as const + }, + { id: 'repo-2', displayName: 'Staying', path: '/src/repo-2' } +] +const folders = [{ id: 'notes', name: 'Notes', folderPath: `${UNC}\\notes` }] + +let dir: string +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'wsl-moved-projects-')) + loadWslMoveFence(dir) +}) +afterEach(() => { + resetWslMoveFenceForTests() + rmSync(dir, { recursive: true, force: true }) +}) + +const record = (phase: Parameters[1]) => + cutover('move-1', phase, {}, { repoIds: ['repo-1'], folderWorkspaceIds: ['notes'] }) + +describe('listWslMovedProjects', () => { + it('lists nothing while a move is only taking the projects', () => { + seedWslMoveCutover(dir, record('blobs-transferred')) + expect(listWslMovedProjects(repos, folders)).toEqual([]) + }) + + it("lists only this machine's rows the host owns, with the host's distro", () => { + seedWslMoveCutover(dir, record('destination-committed')) + expect(listWslMovedProjects(repos, folders)).toEqual([ + { + kind: 'repo', + id: 'repo-1', + displayName: 'Repo one', + path: repos[0].path, + distro: 'Ubuntu' + }, + { + kind: 'folder', + id: 'notes', + displayName: 'Notes', + path: folders[0].folderPath, + distro: 'Ubuntu' + } + ]) + }) + + it('never lists an SSH row that shares a moved id', () => { + seedWslMoveCutover(dir, record('retained')) + const ssh = [{ id: 'repo-1', displayName: 'Remote', path: '/r', connectionId: 'target-1' }] + expect(listWslMovedProjects(ssh, [])).toEqual([]) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-moved-projects.ts b/src/main/persistence/moving-wsl-projects/wsl-moved-projects.ts new file mode 100644 index 00000000000..f75267cb0ee --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-moved-projects.ts @@ -0,0 +1,39 @@ +/** + * The local projects a WSL host now owns, as the desktop lists them: read-only entries whose + * existing chats stay on this computer (owner decision, option C). Separate from the repo and + * folder lists so no caller can mistake one for a normal, editable project. + */ +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { Repo } from '../../../shared/repo-types' +import type { WslMovedProject } from '../../../shared/wsl-move/wsl-moved-project' +import { isLocalWslMoveRow, wslMoveHostDistro } from './wsl-move-scope-guard' + +type MovedRepoRow = Pick +type MovedFolderRow = Pick< + FolderWorkspace, + 'id' | 'name' | 'folderPath' | 'connectionId' | 'executionHostId' +> + +export function listWslMovedProjects( + repos: readonly MovedRepoRow[], + folderWorkspaces: readonly MovedFolderRow[] +): WslMovedProject[] { + const moved: WslMovedProject[] = [] + for (const repo of repos) { + const distro = isLocalWslMoveRow(repo) ? wslMoveHostDistro({ repoId: repo.id }) : null + if (distro) { + const { id, displayName, path } = repo + moved.push({ kind: 'repo', id, displayName, path, distro }) + } + } + for (const folder of folderWorkspaces) { + const distro = isLocalWslMoveRow(folder) + ? wslMoveHostDistro({ folderWorkspaceId: folder.id }) + : null + if (distro) { + const { id, name, folderPath } = folder + moved.push({ kind: 'folder', id, displayName: name, path: folderPath, distro }) + } + } + return moved +} diff --git a/src/main/persistence/restoring-sessions/cross-host-pane-tab-ids.ts b/src/main/persistence/restoring-sessions/cross-host-pane-tab-ids.ts index 5bcd36d60ae..d99087a6086 100644 --- a/src/main/persistence/restoring-sessions/cross-host-pane-tab-ids.ts +++ b/src/main/persistence/restoring-sessions/cross-host-pane-tab-ids.ts @@ -1,14 +1,33 @@ import type { PersistedState } from '../../../shared/persisted-state-types' +import { buildWorktreeIdByTabId } from '../../../shared/workspace-session-host-records' +import { isWslMoveWorkspaceHostOwned } from '../moving-wsl-projects/wsl-move-scope-guard' -/** Hostless legacy/acknowledgement keys cannot disambiguate shared tab IDs; rewrite neither host. */ -export function findCrossHostPaneTabIds(state: PersistedState): ReadonlySet { +/** + * Tab ids owned by more than one host partition. Legacy alias keys (`tabId:1`) and acknowledgement + * keys (`tabId:leafId`) carry no host segment, so such a tab id resolves to whichever partition was + * migrated last — routing one host's pane at another host's terminal. Rewrite neither instead. + * A WSL-moved workspace's local rows are a dormant downgrade copy of its host's, not a rival. + */ +export function findCrossHostPaneTabIds( + state: PersistedState, + isRetainedLocalWorkspace: (workspaceId: string) => boolean = isWslMoveWorkspaceHostOwned +): ReadonlySet { const seenTabIds = new Set() const collidingTabIds = new Set() - for (const session of [ - state.workspaceSession, - ...Object.values(state.workspaceSessionsByHostId ?? {}) - ]) { + const localWorkspaceByTabId = buildWorktreeIdByTabId(state.workspaceSession ?? {}) + const sessions = [ + { session: state.workspaceSession, local: true }, + ...Object.values(state.workspaceSessionsByHostId ?? {}).map((session) => ({ + session, + local: false + })) + ] + for (const { session, local } of sessions) { for (const tabId of Object.keys(session?.terminalLayoutsByTabId ?? {})) { + const workspaceId = local ? localWorkspaceByTabId.get(tabId) : undefined + if (workspaceId && isRetainedLocalWorkspace(workspaceId)) { + continue + } if (seenTabIds.has(tabId)) { collidingTabIds.add(tabId) continue diff --git a/src/main/persistence/tracking-repos/persistence-workspace-session-scrollback.test.ts b/src/main/persistence/tracking-repos/persistence-workspace-session-scrollback.test.ts index fead61cd029..c81c9ba4c56 100644 --- a/src/main/persistence/tracking-repos/persistence-workspace-session-scrollback.test.ts +++ b/src/main/persistence/tracking-repos/persistence-workspace-session-scrollback.test.ts @@ -403,6 +403,12 @@ describe('Store', () => { expect(buffer).toHaveLength(TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT) expect(buffer?.startsWith('stale-prefix')).toBe(false) expect(buffer?.endsWith('tail')).toBe(true) + expect(store.hasTerminalScrollbackSnapshot(ref)).toBe(true) + expect(store.hasTerminalScrollbackSnapshot('v1-11111111111111111111111111111111')).toBe(false) + await expect(store.readTerminalScrollbackSnapshotAsync(ref)).resolves.toBe(buffer) + await expect(store.statTerminalScrollbackSnapshotSize(ref)).resolves.toBe( + TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT + 'stale-prefix-tail'.length + ) }) it('strips legacy local terminal scrollback buffers when loading workspace session', async () => { diff --git a/src/main/persistence/tracking-repos/repo-hydration.ts b/src/main/persistence/tracking-repos/repo-hydration.ts index 14d09ee6162..ca4fc7d716e 100644 --- a/src/main/persistence/tracking-repos/repo-hydration.ts +++ b/src/main/persistence/tracking-repos/repo-hydration.ts @@ -6,6 +6,7 @@ import { sanitizeRepoIcon } from '../../../shared/repo-icon' import { normalizeGhAccountBinding } from '../../../shared/github/account-binding' import { normalizeJiraProjectReference } from '../../../shared/jira/jira-project-reference' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' +import { normalizeOrcadRetainedMarker } from '../../../shared/orcad-retained-marker' import { sanitizeForkSyncMode, sanitizeGitRemoteIdentity, @@ -37,6 +38,7 @@ export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap { - if (provider.hasPty?.(ptyId) === false) { + // The daemon's own inspect still refuses a session it no longer runs. + if (options?.includeUnattached !== true && provider.hasPty?.(ptyId) === false) { throw new Error('terminal_gone') } const inspectProcess = (provider as CompletionSensitivePtyProvider).inspectProcess diff --git a/src/main/runtime/host-terminal-scrollback-read.test.ts b/src/main/runtime/host-terminal-scrollback-read.test.ts new file mode 100644 index 00000000000..5cc7e3492c6 --- /dev/null +++ b/src/main/runtime/host-terminal-scrollback-read.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { TerminalTab } from '../../shared/terminal-tab-types' +import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../shared/terminal-scrollback-limits' +import { + readHostTerminalScrollback, + type HostTerminalScrollbackReadDeps +} from './host-terminal-scrollback-read' + +const HELD_REF = `v1-${'a'.repeat(32)}` +const OTHER_WORKTREE_REF = `v1-${'b'.repeat(32)}` + +function tab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function session(): WorkspaceSessionState { + return { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: { + 'repo::/wt-a': [tab('tab-a', 'repo::/wt-a')], + 'repo::/wt-b': [tab('tab-b', 'repo::/wt-b')] + }, + terminalLayoutsByTabId: { + 'tab-a': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'leaf-a', + expandedLeafId: null, + scrollbackRefsByLeafId: { 'leaf-a': HELD_REF } + }, + 'tab-b': { + root: { type: 'leaf', leafId: 'leaf-b' }, + activeLeafId: 'leaf-b', + expandedLeafId: null, + scrollbackRefsByLeafId: { 'leaf-b': OTHER_WORKTREE_REF } + } + } + } +} + +function deps(overrides: Partial = {}) { + const readSnapshot = vi.fn(async (ref: string) => (ref === HELD_REF ? 'stored history' : null)) + const statSnapshotSize = vi.fn(async (ref: string) => (ref === HELD_REF ? 14 : null)) + const resolved: HostTerminalScrollbackReadDeps = { + resolveWorktreeId: async (selector) => { + if (!selector.startsWith('id:')) { + throw new Error('selector_not_found') + } + return selector.slice(3) + }, + getWorkspaceSession: () => session(), + statSnapshotSize, + readSnapshot, + ...overrides + } + return { resolved, readSnapshot } +} + +describe('readHostTerminalScrollback', () => { + it('returns the stored bytes for a ref a tab of the caller worktree names', async () => { + const { resolved } = deps() + await expect( + readHostTerminalScrollback(resolved, { + worktree: 'id:repo::/wt-a', + ref: HELD_REF + }) + ).resolves.toEqual({ buffer: 'stored history' }) + }) + + it('refuses a ref named only by another worktree without touching the store', async () => { + const { resolved, readSnapshot } = deps() + await expect( + readHostTerminalScrollback(resolved, { + worktree: 'id:repo::/wt-a', + ref: OTHER_WORKTREE_REF + }) + ).rejects.toThrow('terminal_scrollback_not_found') + expect(readSnapshot).not.toHaveBeenCalled() + }) + + it('refuses a worktree the host cannot resolve', async () => { + const { resolved, readSnapshot } = deps() + await expect( + readHostTerminalScrollback(resolved, { + worktree: 'path:/elsewhere', + ref: HELD_REF + }) + ).rejects.toThrow('selector_not_found') + expect(readSnapshot).not.toHaveBeenCalled() + }) + + it('answers not-found when the named snapshot file is missing', async () => { + const { resolved } = deps({ statSnapshotSize: async () => null }) + await expect( + readHostTerminalScrollback(resolved, { + worktree: 'id:repo::/wt-a', + ref: HELD_REF + }) + ).rejects.toThrow('terminal_scrollback_not_found') + }) + + it('answers not-found when the host has no session for the worktree', async () => { + const { resolved } = deps({ getWorkspaceSession: () => null }) + await expect( + readHostTerminalScrollback(resolved, { + worktree: 'id:repo::/wt-a', + ref: HELD_REF + }) + ).rejects.toThrow('terminal_scrollback_not_found') + }) + + it('refuses a file over the store limit before reading any of it', async () => { + const { resolved, readSnapshot } = deps({ + statSnapshotSize: async () => TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1 + }) + await expect( + readHostTerminalScrollback(resolved, { + worktree: 'id:repo::/wt-a', + ref: HELD_REF + }) + ).rejects.toThrow('terminal_scrollback_too_large') + expect(readSnapshot).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/host-terminal-scrollback-read.ts b/src/main/runtime/host-terminal-scrollback-read.ts new file mode 100644 index 00000000000..0c6716a1f2c --- /dev/null +++ b/src/main/runtime/host-terminal-scrollback-read.ts @@ -0,0 +1,57 @@ +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { SessionReadTerminalScrollbackResult } from '../../shared/rpc-contract/session-terminal-scrollback-params' +import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../shared/terminal-scrollback-limits' + +export type HostTerminalScrollbackReadDeps = { + /** Resolves the caller's worktree selector the way every workspace method does; throws when unknown. */ + resolveWorktreeId: (selector: string) => Promise + getWorkspaceSession: (worktreeId: string) => WorkspaceSessionState | null + /** Byte size of the stored file, checked before any bytes are read; null when absent. */ + statSnapshotSize: (ref: string) => Promise + readSnapshot: (ref: string) => Promise +} + +export type HostTerminalScrollbackReadRequest = { + worktree: string + ref: string +} + +function worktreeSessionNamesScrollbackRef( + session: WorkspaceSessionState, + worktreeId: string, + ref: string +): boolean { + return (session.tabsByWorktree[worktreeId] ?? []).some((tab) => + Object.values(session.terminalLayoutsByTabId[tab.id]?.scrollbackRefsByLeafId ?? {}).includes( + ref + ) + ) +} + +/** + * The stored scrollback for a pane of a worktree this host serves. A ref no tab of that worktree + * names answers not-found, the same as a missing file, so a caller cannot probe other workspaces. + */ +export async function readHostTerminalScrollback( + deps: HostTerminalScrollbackReadDeps, + request: HostTerminalScrollbackReadRequest +): Promise { + const worktreeId = await deps.resolveWorktreeId(request.worktree) + const session = deps.getWorkspaceSession(worktreeId) + if (!session || !worktreeSessionNamesScrollbackRef(session, worktreeId, request.ref)) { + throw new Error('terminal_scrollback_not_found') + } + const size = await deps.statSnapshotSize(request.ref) + if (size === null || size <= 0) { + throw new Error('terminal_scrollback_not_found') + } + // Why: refuse a file no store write could have produced before reading any of it. + if (size > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) { + throw new Error('terminal_scrollback_too_large') + } + const buffer = await deps.readSnapshot(request.ref) + if (!buffer) { + throw new Error('terminal_scrollback_not_found') + } + return { buffer } +} diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index c57b7dd6694..d71c65c7200 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -8,7 +8,7 @@ import { import { isBrowserIdentityModeStoreInitialized } from '../browser/browser-identity-mode-store' import { servesLocalWslMigrationSource } from '../orcad/orcad-migration-wsl-destination' import { ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY } from '../../shared/orcad-runtime-capabilities' -import { advertisedOrcadMigrationBlobCapabilities } from '../../shared/orcad-migration/orcad-migration-blob-transfer' +import { advertisedOrcadMigrationDestinationCapabilities } from '../orcad/orcad-migration-destination-capabilities' import type { RuntimeCapability } from '../../shared/protocol-version' import { USAGE_CREDENTIALS_RUNTIME_CAPABILITY } from '../../shared/account-runtime-capabilities' import { @@ -125,7 +125,7 @@ export abstract class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntime capabilities.push(ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY) } // Advertise only installed blob kinds; senders refuse unsupported kinds instead of silently losing staged data. - capabilities.push(...advertisedOrcadMigrationBlobCapabilities()) + capabilities.push(...advertisedOrcadMigrationDestinationCapabilities()) // Certificate proceed belongs to whichever process hosts the page, renderer or offscreen. if (canBrowse) { capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY) diff --git a/src/main/runtime/orca-runtime-migration-catalog.ts b/src/main/runtime/orca-runtime-migration-catalog.ts index 35d00f4b47c..b077b8e0ca7 100644 --- a/src/main/runtime/orca-runtime-migration-catalog.ts +++ b/src/main/runtime/orca-runtime-migration-catalog.ts @@ -22,7 +22,13 @@ import { stageOrcadMigrationCatalogDurably } from './orcad-migration-catalog-import' import { OrcaRuntimeWithResolveWaiter } from './orca-runtime-resolve-waiter' +import type { WslMoveLiveTerminal } from '../persistence/moving-wsl-projects/wsl-move-gate-facts' import type { RuntimeStore } from './runtime-store-contract' +import type { SessionReadTerminalScrollbackResult } from '../../shared/rpc-contract/session-terminal-scrollback-params' +import { + readHostTerminalScrollback, + type HostTerminalScrollbackReadRequest +} from './host-terminal-scrollback-read' type MigrationStoreKey = | 'stageOrcadMigrationCatalog' @@ -49,7 +55,10 @@ export abstract class OrcaRuntimeWithMigrationCatalog extends OrcaRuntimeWithRes async commitStagedOrcadMigrationCatalog( manifest: OrcadMigrationManifest, - options: { signal?: AbortSignal; expectedBlobs?: readonly OrcadMigrationBlobDescriptor[] } = {} + options: { + signal?: AbortSignal + expectedBlobs?: readonly OrcadMigrationBlobDescriptor[] + } = {} ): Promise { return commitStagedOrcadMigrationCatalogDurably({ store: this.requireMigrationStore( @@ -166,6 +175,54 @@ export abstract class OrcaRuntimeWithMigrationCatalog extends OrcaRuntimeWithRes private readonly migrationWorktreeHasLiveShells = (worktreeId: string): boolean => this.getLivePtyIdsForWorktree(worktreeId, undefined, { resolvedConnectionId: null }).size > 0 + /** A migrated pane's stored scrollback, for a client whose own copy is gone or never existed. */ + async readTerminalScrollbackForClient( + request: HostTerminalScrollbackReadRequest + ): Promise { + const store = this.store + const readSnapshot = store?.readTerminalScrollbackSnapshotAsync + const statSnapshotSize = store?.statTerminalScrollbackSnapshotSize + if (!store || !readSnapshot || !statSnapshotSize) { + throw new Error('runtime_unavailable') + } + return readHostTerminalScrollback( + { + resolveWorktreeId: async (selector) => (await this.resolveWorktreeSelector(selector)).id, + getWorkspaceSession: (worktreeId) => this.workspaceSessions.get(worktreeId), + statSnapshotSize: (ref) => statSnapshotSize.call(store, ref), + readSnapshot: (ref) => readSnapshot.call(store, ref) + }, + request + ) + } + + /** The pane a live terminal shows, including one the window has not reopened since a relaunch. */ + wslMovePaneKeyForPty(ptyId: string): string | null { + const pty = this.ptysById.get(ptyId) + return pty ? this.paneOfPty(pty).paneKey : null + } + + /** This desktop's live terminals in the workspaces, for a WSL move's gate and pause. */ + listWslMoveLiveTerminals(workspaceIds: readonly string[]): WslMoveLiveTerminal[] { + const terminals = new Map() + for (const workspaceId of workspaceIds) { + // Local only: a same-id SSH twin's terminals are another host's. + for (const ptyId of this.getLivePtyIdsForWorktree(workspaceId, undefined, { + resolvedConnectionId: null + })) { + const pty = this.ptysById.get(ptyId) + terminals.set(ptyId, { + ptyId, + workspaceId, + paneKey: pty ? this.paneOfPty(pty).paneKey : null, + wslDistro: pty?.wslDistro ?? null, + foregroundAgent: pty?.foregroundAgent ?? null + }) + } + } + return [...terminals.values()] + } + // Check the captured Store, not a potentially different virtual return. private requireMigrationStore(...methods: MigrationStoreKey[]): RuntimeStore { const store = this.store diff --git a/src/main/runtime/orcad-migration-catalog-import.test.ts b/src/main/runtime/orcad-migration-catalog-import.test.ts index 231ee46e9c5..2fbd75acb81 100644 --- a/src/main/runtime/orcad-migration-catalog-import.test.ts +++ b/src/main/runtime/orcad-migration-catalog-import.test.ts @@ -123,4 +123,29 @@ describe('durable orcad migration catalog import', () => { }) expect(flush).toHaveBeenCalledTimes(2) }) + + it('flushes before answering committed by reference, and says so', async () => { + const reference = { migrationId: 'move-1', manifestSha256: 'a'.repeat(64) } + const committed = { ...reference, state: 'committed' as const, aborted: false } + const flush = vi + .fn() + .mockRejectedValueOnce(new Error('disk full')) + .mockResolvedValueOnce(undefined) + const args = { + store: { + abortStagedOrcadMigrationCatalogByReference: vi.fn().mockReturnValue(committed), + flushPendingOrThrowAsync: flush + }, + reference + } + + // A committed answer whose flush failed must not reach a caller that would act on it. + await expect(abortStagedOrcadMigrationCatalogByReferenceDurably(args)).rejects.toThrow( + 'disk full' + ) + await expect(abortStagedOrcadMigrationCatalogByReferenceDurably(args)).resolves.toEqual({ + ...committed, + durableCommitted: true + }) + }) }) diff --git a/src/main/runtime/orcad-migration-catalog-import.ts b/src/main/runtime/orcad-migration-catalog-import.ts index 88db0a7141a..f11a5b19b66 100644 --- a/src/main/runtime/orcad-migration-catalog-import.ts +++ b/src/main/runtime/orcad-migration-catalog-import.ts @@ -87,11 +87,11 @@ export async function abortStagedOrcadMigrationCatalogByReferenceDurably(args: { }): Promise { // SAFETY: Returned or mutable Store slots retain their original native missing-method failure. const result = args.store.abortStagedOrcadMigrationCatalogByReference!(args.reference) - if (result.state === 'absent') { - await flushMigrationState(args.store, args.signal) - return { ...result, durableAbsent: true } - } - return result + await flushMigrationState(args.store, args.signal) + // Either answer is flushed first, so a source that cannot re-send the manifest may act on it. + return result.state === 'absent' + ? { ...result, durableAbsent: true } + : { ...result, durableCommitted: true } } export function getOrcadMigrationCatalogState(args: { diff --git a/src/main/runtime/orchestration/db/orchestration-db-file.ts b/src/main/runtime/orchestration/db/orchestration-db-file.ts new file mode 100644 index 00000000000..9687b63bfeb --- /dev/null +++ b/src/main/runtime/orchestration/db/orchestration-db-file.ts @@ -0,0 +1,6 @@ +import { join } from 'node:path' + +/** The desktop's orchestration database inside a profile's userData directory. */ +export function orchestrationDbFile(userDataPath: string): string { + return join(userDataPath, 'orchestration.db') +} diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 471ef815341..218dad8a48f 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -12,6 +12,7 @@ import { import { successResponse } from './errors' import { ALL_RPC_METHODS } from './methods' +import { withoutMethodsThisHostDoesNotServe } from './methods/session-terminal-scrollback' import { emulatorProbe, emulatorProbeError } from '../../emulator/emulator-probe' import type { OrcaRuntimeService } from '../orca-runtime' import { isDurableMutation } from '../../../shared/orchestration-rpc-contract' @@ -57,7 +58,7 @@ export class RpcDispatcher { constructor({ runtime, methods = ALL_RPC_METHODS, callerScope }: DispatcherOptions) { this.runtime = runtime this.pinnedCallerScope = callerScope - this.registry = buildRegistry(methods) + this.registry = buildRegistry(withoutMethodsThisHostDoesNotServe(methods)) this.orchestrationMutations = getOrchestrationMutationExecutor(runtime) this.legacyOrchestration = new OrchestrationLegacyCompatibility(runtime) } diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index db5856c2a05..198dab0d092 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -2,6 +2,7 @@ import { STATUS_METHODS } from './status' import { SIDEBAR_INPUT_METHODS } from './sidebar-inputs' import { ORCAD_TERMINAL_CENSUS_METHODS } from './orcad-terminal-census' import { ORCAD_MIGRATION_METHODS } from './orcad-migration' +import { SESSION_TERMINAL_SCROLLBACK_METHODS } from './session-terminal-scrollback' import { AI_VAULT_METHODS } from './ai-vault' import { AUTOMATION_METHODS } from './automations' import { REPO_METHODS } from './repo' @@ -78,6 +79,7 @@ export const ALL_RPC_METHODS = [ ...STATUS_METHODS, ...ORCAD_TERMINAL_CENSUS_METHODS, ...ORCAD_MIGRATION_METHODS, + ...SESSION_TERMINAL_SCROLLBACK_METHODS, ...AGENT_HOOK_METHODS, ...AI_VAULT_METHODS, ...ARTIFACT_METHODS, diff --git a/src/main/runtime/rpc/methods/session-terminal-scrollback.test.ts b/src/main/runtime/rpc/methods/session-terminal-scrollback.test.ts new file mode 100644 index 00000000000..d319bf19254 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-terminal-scrollback.test.ts @@ -0,0 +1,79 @@ +import '../unused-default-rpc-methods.test-fixture' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version' +import { SESSION_TERMINAL_SCROLLBACK_READ_RUNTIME_CAPABILITY } from '../../../../shared/session-tabs-runtime-capabilities' +import { ORCA_E2E_WSL_MOVE_DESTINATION_ENV } from '../../../orcad/orcad-migration-wsl-destination' +import { SESSION_TERMINAL_SCROLLBACK_METHODS } from './session-terminal-scrollback' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import { SessionReadTerminalScrollbackParams } from '../../../../shared/rpc-contract/session-terminal-scrollback-params' +import { MOBILE_RPC_METHOD_ALLOWLIST } from '../../runtime-rpc/runtime-rpc-mobile-method-allowlist' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +const REF = `v1-${'0'.repeat(32)}` + +function dispatcherForThisHost() { + const readTerminalScrollbackForClient = vi.fn(async () => ({ buffer: 'moved history' })) + const dispatcher = new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The method's handler reads only readTerminalScrollbackForClient and getRuntimeId. + runtime: { + getRuntimeId: () => 'runtime-1', + readTerminalScrollbackForClient + } as unknown as OrcaRuntimeService, + methods: SESSION_TERMINAL_SCROLLBACK_METHODS + }) + const read = (params: unknown) => + dispatcher.dispatch({ + id: 'request-1', + authToken: 'token', + method: 'session.readTerminalScrollback', + params + }) + return { read, readTerminalScrollbackForClient } +} + +describe('session.readTerminalScrollback', () => { + it('is registered, validates its params and reads on a WSL move destination', async () => { + vi.stubEnv(ORCA_E2E_WSL_MOVE_DESTINATION_ENV, '1') + const { read, readTerminalScrollbackForClient } = dispatcherForThisHost() + expect(await read({ worktree: 'id:repo::/wt', ref: REF })).toMatchObject({ + ok: true, + result: { buffer: 'moved history' } + }) + expect(readTerminalScrollbackForClient).toHaveBeenCalledWith({ + worktree: 'id:repo::/wt', + ref: REF + }) + expect(await read({ worktree: 'id:repo::/wt', ref: '../secrets' })).toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + expect(readTerminalScrollbackForClient).toHaveBeenCalledTimes(1) + expect(SESSION_TERMINAL_SCROLLBACK_METHODS[0]?.permission).toBe('workspace') + expect(MOBILE_RPC_METHOD_ALLOWLIST.has('session.readTerminalScrollback')).toBe(true) + }) + + it('is absent, and not advertised by default, on every other host', async () => { + const { read, readTerminalScrollbackForClient } = dispatcherForThisHost() + expect(await read({ worktree: 'id:repo::/wt', ref: REF })).toMatchObject({ + ok: false, + error: { code: 'method_not_found' } + }) + expect(readTerminalScrollbackForClient).not.toHaveBeenCalled() + expect(RUNTIME_CAPABILITIES).not.toContain(SESSION_TERMINAL_SCROLLBACK_READ_RUNTIME_CAPABILITY) + }) + + it('accepts only snapshot-shaped refs and a worktree selector', () => { + const ok = { worktree: 'id:repo::/wt', ref: `v1-${'0'.repeat(32)}` } + expect(SessionReadTerminalScrollbackParams.safeParse(ok).success).toBe(true) + for (const ref of ['../secrets', `v1-${'0'.repeat(31)}`, 'v1-ZZ', '']) { + expect(SessionReadTerminalScrollbackParams.safeParse({ ...ok, ref }).success).toBe(false) + } + expect( + SessionReadTerminalScrollbackParams.safeParse({ ref: ok.ref, worktree: '' }).success + ).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-terminal-scrollback.ts b/src/main/runtime/rpc/methods/session-terminal-scrollback.ts new file mode 100644 index 00000000000..37166f9359d --- /dev/null +++ b/src/main/runtime/rpc/methods/session-terminal-scrollback.ts @@ -0,0 +1,28 @@ +import { SessionReadTerminalScrollbackParams } from '../../../../shared/rpc-contract/session-terminal-scrollback-params' +import { defineMethod } from '../core' +import { servesWslMovedPaneHistory } from '../../../orcad/orcad-migration-wsl-destination' + +// Defined statically so the params catalog types it; registration is gated per host below. +export const SESSION_TERMINAL_SCROLLBACK_METHODS = [ + // Gated by session.terminal-scrollback-read.v1: older hosts answer method-not-found. + defineMethod({ + name: 'session.readTerminalScrollback', + permission: 'workspace', + params: SessionReadTerminalScrollbackParams, + handler: async (params, { runtime }) => + runtime.readTerminalScrollbackForClient({ worktree: params.worktree, ref: params.ref }) + }) +] + +const WSL_HOST_ONLY_METHOD_NAMES: ReadonlySet = new Set( + SESSION_TERMINAL_SCROLLBACK_METHODS.map((method) => method.name) +) + +/** Only a WSL host serves moved panes' stored scrollback; every other host answers method-not-found. */ +export function withoutMethodsThisHostDoesNotServe( + methods: readonly Method[] +): readonly Method[] { + return servesWslMovedPaneHistory() + ? methods + : methods.filter((method) => !WSL_HOST_ONLY_METHOD_NAMES.has(method.name)) +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts index adcb88d4960..2c477cef5a9 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -4,6 +4,11 @@ import { serializeBudgetedMobileSnapshot } from './terminal-snapshot-publication' import { getOutputAfterSnapshotSeq } from './terminal-stream-replay' +import { isMovedPaneHistorySessionId } from '../../../../../shared/orcad-migration/orcad-migration-terminal-history' +import { + MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, + MOVED_PANE_SUBSCRIBE_SCROLLBACK_ROWS +} from '../../../scrollback-limits' import { getTerminalInputSequenceLedger } from './terminal-input-sequence-ledger' import { isMultiplexStreamAttached, @@ -29,8 +34,12 @@ export async function publishMultiplexInitialSnapshot( const isMobile = stream.isMobile const forcedInitialSnapshotTruncated = process.env.ORCA_E2E_FORCE_REMOTE_TERMINAL_INITIAL_SNAPSHOT_TRUNCATED === '1' + // A WSL-moved pane replays its whole carried history once; the first view must hold all of it. + const firstRows = isMovedPaneHistorySessionId(ptyId) + ? MOVED_PANE_SUBSCRIBE_SCROLLBACK_ROWS + : MOBILE_SUBSCRIBE_SCROLLBACK_ROWS let read = await runtime.readTerminal(request.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId) + let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, undefined, firstRows) if (!isMultiplexStreamAttached(state, stream)) { return null } @@ -40,7 +49,7 @@ export async function publishMultiplexInitialSnapshot( stream.pendingOutputBytes = 0 stream.pendingOutputOverflowed = false read = await runtime.readTerminal(request.terminal) - serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId) + serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, undefined, firstRows) if (!isMultiplexStreamAttached(state, stream)) { return null } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts index 8b6681924e2..d4ec8cffa84 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts @@ -195,13 +195,23 @@ function publishedCandidate { if (isTerminalSnapshotForcedUnavailable()) { return null } // Include history for desktop too; screen-only snapshots lost it on remount/reconnect (#20158). - const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] + const candidates = [ + ...(firstRows > MOBILE_SUBSCRIBE_SCROLLBACK_ROWS ? [firstRows, 2000] : []), + MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, + 500, + 250, + 100, + 25, + 0 + ] for (const rows of candidates) { const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows }) if (!serialized) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts index 7b3e74a249f..e6210b47555 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts @@ -3,6 +3,7 @@ import type { OrcaRuntimeService } from '../../../orca-runtime' import { MOBILE_SNAPSHOT_BYTE_BUDGET } from '../../../scrollback-limits' import { buildSnapshotFrameMeta } from './terminal-snapshot-payload' import { serializeBudgetedMobileSnapshot } from './terminal-snapshot-publication' +import { isMovedPaneHistorySessionId } from '../../../../../shared/orcad-migration/orcad-migration-terminal-history' function historyRuntime(bytesPerRow: number): Pick { return { @@ -36,6 +37,26 @@ describe('subscribe snapshot scrollback (#20158)', () => { }) }) +describe('subscribe snapshot of a WSL-moved pane', () => { + it('tries the larger first row count, then the usual ladder within the same byte budget', async () => { + const small = historyRuntime(10) + expect( + (await serializeBudgetedMobileSnapshot(small, 'pty-1', undefined, 5000))?.scrollbackRows + ).toBe(5000) + // 5000 and 2000 rows are over 512 KiB, 1000 is not. + const wide = historyRuntime(Math.floor(MOBILE_SNAPSHOT_BYTE_BUDGET / 1500)) + expect( + (await serializeBudgetedMobileSnapshot(wide, 'pty-1', undefined, 5000))?.scrollbackRows + ).toBe(1000) + }) + + it('names only moved panes by their session id', () => { + expect(isMovedPaneHistorySessionId('repo::/w@@moved-0123456789abcdef')).toBe(true) + expect(isMovedPaneHistorySessionId('repo::/w@@a1b2c3d4')).toBe(false) + expect(isMovedPaneHistorySessionId('moved-0123456789abcdef')).toBe(false) + }) +}) + describe('SnapshotStart scrollbackRows', () => { const base = { kind: 'scrollback' as const, cols: 80, rows: 24, data: '' } diff --git a/src/main/runtime/runtime-client-settings-wsl-host-hooks.test.ts b/src/main/runtime/runtime-client-settings-wsl-host-hooks.test.ts new file mode 100644 index 00000000000..74ea761a055 --- /dev/null +++ b/src/main/runtime/runtime-client-settings-wsl-host-hooks.test.ts @@ -0,0 +1,64 @@ +/** + * R42: a client turning status hooks off on a WSL host's orcad must not remove the hook entries + * the WSL bridge's panes in the same distro still run on; anywhere else it removes them as before. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { RuntimeClientSettingsController } from './runtime-client-settings' + +const mocks = vi.hoisted(() => ({ + apply: vi.fn(async () => []), + wslHost: vi.fn(() => false) +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ + applyAgentStatusHooksEnabled: mocks.apply +})) +vi.mock('../ipc/wsl-guest-windows-drive-path', () => ({ + isWslHostOrcadProcess: mocks.wslHost +})) + +function controller(): RuntimeClientSettingsController { + let settings = createGlobalSettingsFixture({ agentStatusHooksEnabled: true }) + return new RuntimeClientSettingsController({ + getSettings: () => settings, + updateSettings: (updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }, + runDurableMutation: async (mutate) => mutate().value + }) +} + +beforeEach(() => { + mocks.apply.mockClear() +}) + +describe('turning status hooks off through a paired client', () => { + it.each([ + [true, true], + [false, false] + ])('on a WSL host (%s) keeps the shared entries: %s', async (wslHost, keep) => { + mocks.wslHost.mockReturnValue(wslHost) + + await controller().update({ agentStatusHooksEnabled: false }) + + expect(mocks.apply).toHaveBeenCalledWith( + false, + expect.anything(), + expect.objectContaining({ keepSharedEntries: keep }) + ) + }) + + it('keeps them too when one agent is disabled on a WSL host', async () => { + mocks.wslHost.mockReturnValue(true) + + await controller().update({ disabledTuiAgents: ['claude'] }) + + expect(mocks.apply).toHaveBeenCalledWith( + true, + expect.anything(), + expect.objectContaining({ keepSharedEntries: true }) + ) + }) +}) diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 52429de09bb..0130a725522 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -20,6 +20,7 @@ import { getHostDisplayLabelOverrides } from '../../shared/host-setting-override import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types' import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { isWslHostOrcadProcess } from '../ipc/wsl-guest-windows-drive-path' import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' import type { RuntimeStore } from './runtime-store-contract' import { @@ -265,6 +266,8 @@ export class RuntimeClientSettingsController { return } await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, { + // R42, COMPAT(wsl-bridge): the bridge's panes in this distro share these entries; drop at D4. + keepSharedEntries: isWslHostOrcadProcess(), shouldHydrateShellPath: getAppEnvironment().isPackaged(), onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts index 63c0c3e687e..fd662c7ba6c 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts @@ -166,6 +166,7 @@ export const EXECUTION_HOST_METHODS = [ 'ssh.listTargets', // A targeted status is the server's own, so a server workspace gates on what that server supports. 'status.get', + 'session.readTerminalScrollback', 'session.tabs.activate', 'session.tabs.close', 'session.tabs.closeLifecycle', diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 99b729e2f1e..7692d9026d5 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -55,6 +55,8 @@ export type RuntimeStore = { onStateWritten?: Store['onStateWritten'] setWorkspaceSession?: Store['setWorkspaceSession'] getRemoteLayoutSnapshots?: Store['getRemoteLayoutSnapshots'] + readTerminalScrollbackSnapshotAsync?: Store['readTerminalScrollbackSnapshotAsync'] + statTerminalScrollbackSnapshotSize?: Store['statTerminalScrollbackSnapshotSize'] runDurableMutation?: Store['runDurableMutation'] flushOrThrow?: Store['flushOrThrow'] flushPendingOrThrowAsync?: Store['flushPendingOrThrowAsync'] diff --git a/src/main/runtime/runtime-visible-catalog.test.ts b/src/main/runtime/runtime-visible-catalog.test.ts index be32344635d..fe8b2123279 100644 --- a/src/main/runtime/runtime-visible-catalog.test.ts +++ b/src/main/runtime/runtime-visible-catalog.test.ts @@ -53,6 +53,18 @@ describe('runtime visible catalog', () => { expect(runtimeHiddenRepos(null).size).toBe(0) }) + it('hides a moved local row by host while the WSL host row with the same id stays listed', () => { + const local = repo('moved') + const onHost: Repo = { ...repo('moved'), executionHostId: 'runtime:wsl-ubuntu' } + visibleRepos.mockImplementation((store) => store.getRepos().filter((r) => r !== local)) + const hidden = runtimeHiddenRepos(storeWith([local, onHost], true)) + expect(hidden.size).toBe(1) + expect(hidden.hasRepo(local)).toBe(true) + expect(hidden.hasRepo(onHost)).toBe(false) + expect(hidden.hasWorktree({ repoId: 'moved', hostId: 'local' })).toBe(true) + expect(hidden.hasWorktree({ repoId: 'moved', hostId: 'runtime:wsl-ubuntu' })).toBe(false) + }) + it('names a host known only through hidden rows, but never this machine', () => { visibleRepos.mockImplementation((store) => store.getRepos().filter((r) => r.id !== 'retained' && r.id !== 'moved-local') diff --git a/src/main/runtime/scrollback-limits.ts b/src/main/runtime/scrollback-limits.ts index cdd7920c906..4f1b18ef587 100644 --- a/src/main/runtime/scrollback-limits.ts +++ b/src/main/runtime/scrollback-limits.ts @@ -1,3 +1,6 @@ // Bound phone snapshots: replaying the full 50k-row buffer across IPC/headless emulation is expensive (docs/mobile-prefer-renderer-scrollback.md). export const MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000 export const MOBILE_SNAPSHOT_BYTE_BUDGET = 512 * 1024 +// Why more for a WSL-moved pane: its first subscriber is the only one to see the history the host +// replayed from the move, and the 1000-row cut would drop the rest for good. Same byte budget. +export const MOVED_PANE_SUBSCRIBE_SCROLLBACK_ROWS = 5000 diff --git a/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts b/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts index 27d8413978c..292225c247b 100644 --- a/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts +++ b/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts @@ -27,7 +27,11 @@ import { isPublishedHeld, reopenHeldPanes } from '../../terminal/recovery/held-p import { reopenNewerDaemonTerminals } from '../../../daemon/daemon-newer-protocol-hold' import type { NewerDaemonReopenResult } from '../../../../shared/newer-daemon-hold' import type { WorkspaceLayoutStream } from '../../workspace-layout-stream' -import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { getDaemonHistoryDir } from '../../../daemon/daemon-launch-paths' +import { HistoryReader } from '../../../daemon/history-reader' +import { servesWslMovedPaneHistory } from '../../../orcad/orcad-migration-wsl-destination' +import { movedPaneHistorySessionId } from '../../../persistence/migrating-orcad-catalog/orcad-terminal-history-installer' +import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' import { worktreeIdsEqual } from '../../../../shared/worktree/id' @@ -143,6 +147,34 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu }) } + /** + * The session a WSL move installed for this pane (its carried checkpoint, interrupted), so its + * first start cold-restores that history once like after an app restart; undefined otherwise. + */ + private async claimMovedPaneHistorySession({ + worktreeId, + tabId, + leafId + }: RestoredPane): Promise { + // Only a WSL host installs moved panes' history; other hosts skip the scope and meta.json reads. + if (!servesWslMovedPaneHistory()) { + return undefined + } + try { + const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) + // Why never over SSH: only this host's own daemon holds the installed history. + if (workspace.connectionId !== null) { + return undefined + } + const sessionId = movedPaneHistorySessionId(worktreeId, makePaneKey(tabId, leafId)) + const probe = new HistoryReader(getDaemonHistoryDir()).probeRestorableHistory(sessionId) + return probe.status === 'restorable' ? sessionId : undefined + } catch { + // Why: history is best effort; the pane still starts, only without what it showed before. + return undefined + } + } + /** The pane's stored terminal; undefined while unbound, null once main no longer holds it. */ private storedPaneBinding({ worktreeId, @@ -197,6 +229,13 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu } let dispatched = false try { + const startSessionId = + sessionId ?? + (await this.claimMovedPaneHistorySession({ + worktreeId, + tabId: tab.parentTabId, + leafId: tab.leafId + })) const created = await this.createRuntimeOwnedMobileSessionTerminal( worktreeId, targetsHost, @@ -205,7 +244,7 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu identity: { tabId: tab.parentTabId, leafId: tab.leafId, - sessionId + sessionId: startSessionId }, cwd: tab.startupCwd, command: agentStartup.command, diff --git a/src/main/runtime/workspaces/persisted-surfaces/automation-operations.ts b/src/main/runtime/workspaces/persisted-surfaces/automation-operations.ts index 3be1a12b183..02d67ae9eac 100644 --- a/src/main/runtime/workspaces/persisted-surfaces/automation-operations.ts +++ b/src/main/runtime/workspaces/persisted-surfaces/automation-operations.ts @@ -16,11 +16,11 @@ import { type AutomationListResult } from '../../../../shared/automation-list-scope' import { OrchestrationDb } from '../../orchestration/db' +import { orchestrationDbFile } from '../../orchestration/db/orchestration-db-file' import { committedHandBackTypes, type MailHandBack } from '../../orchestration/db/messages/helper-mailbox-hand-back' -import { join } from 'node:path' import { existsSync } from 'node:fs' import { getAppEnvironment } from '../../../../shared/app-environment' import type { LegacyWorkerTerminalRecoveryPlan } from '../../orchestration/orchestration-legacy-worker-terminal-recovery' @@ -167,7 +167,7 @@ export abstract class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWit } private orchestrationDbPath(): string { - return join(getAppEnvironment().getPath('userData'), 'orchestration.db') + return orchestrationDbFile(getAppEnvironment().getPath('userData')) } setOrchestrationDb(db: OrchestrationDb): void { diff --git a/src/main/runtime/wsl-move-pane-key-for-pty.test.ts b/src/main/runtime/wsl-move-pane-key-for-pty.test.ts new file mode 100644 index 00000000000..71990baf157 --- /dev/null +++ b/src/main/runtime/wsl-move-pane-key-for-pty.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it, vi } from 'vitest' +import { createInventoryRuntime, PTY, processRow } from './pty-inventory-lifecycle-fixture' + +describe('wslMovePaneKeyForPty', () => { + // A pane the window never reopened after a relaunch had no spawn-time key. + it("answers a live terminal's pane from main's records, and null for an unknown one", async () => { + const { runtime } = createInventoryRuntime(vi.fn(async () => [processRow()])) + runtime.register() + expect(runtime.wslMovePaneKeyForPty(PTY)).toBe(runtime.capture().paneKey) + expect(runtime.wslMovePaneKeyForPty(PTY)).not.toBeNull() + expect(runtime.wslMovePaneKeyForPty('unknown@@pty')).toBeNull() + }) +}) diff --git a/src/main/runtime/wsl-moved-project-chat-runtime.test.ts b/src/main/runtime/wsl-moved-project-chat-runtime.test.ts new file mode 100644 index 00000000000..96c0ad068d5 --- /dev/null +++ b/src/main/runtime/wsl-moved-project-chat-runtime.test.ts @@ -0,0 +1,124 @@ +// A chat in a project a WSL host now owns stays on this computer (owner decision, option C): the +// runtime still resolves its UNC folder (the launch directory attach, ensure and a restart resume +// read through `resolveWorkspacePath`), and publishing its tab persists through the move's freeze. +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { Store } from '../persistence' +import { closeTestStores, createStore, testState } from '../persistence/persistence-test-harness' +import { makeTerminalTab } from '../persistence/persistence-session-fixtures' +import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { + loadWslMoveFence, + resetWslMoveFenceForTests +} from '../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { OrcaRuntimeService } from './orca-runtime' +import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import type { ResolvedWorktreeSnapshot } from './runtime-resolved-worktree-cache' +import { runtimeHiddenRepos } from './runtime-visible-catalog' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +const UNC = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-1' +const MOVED = `repo-1::${UNC}` +const ON_HOST = 'repo-1::/home/dev/repo-1' +const HOST_ID = 'runtime:wsl-ubuntu' as const +const repo = (path: string, executionHostId?: typeof HOST_ID): Repo => ({ + id: 'repo-1', + path, + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 0, + ...(executionHostId ? { executionHostId } : {}) +}) +const resolved = (id: string, path: string, hostId: Repo['executionHostId']): ResolvedWorktree => ({ + id, + repoId: 'repo-1', + hostId: hostId ?? 'local', + path, + head: 'abc', + branch: 'main', + isBare: false, + isMainWorktree: true, + displayName: 'repo-1', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true } +}) + +class MovedProjectRuntime extends OrcaRuntimeService { + protected override async listResolvedWorktreeSnapshot(): Promise { + return { + worktrees: [resolved(MOVED, UNC, undefined), resolved(ON_HOST, '/home/dev/repo-1', HOST_ID)], + platformByRepoId: new Map() + } + } +} + +let store: Store +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'wsl-moved-chat-runtime-')) + store = createStore() + loadWslMoveFence(testState.dir) + store.addRepo(repo(UNC)) + store.addRepo(repo('/home/dev/repo-1', HOST_ID)) + const slept: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [MOVED]: [makeTerminalTab({ id: 'slept', ptyId: null, worktreeId: MOVED })] } + } + store.setWorkspaceSession(slept) + seedWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed')) +}) +afterEach(async () => { + resetWslMoveFenceForTests() + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) +}) + +describe('a chat in a project the WSL host now owns', () => { + it('still resolves its UNC folder, the launch directory a resume uses', async () => { + const runtime = new MovedProjectRuntime(store) + const target = await runtime.resolveRuntimeFileTarget(`id:${MOVED}`) + expect(target.worktree.path).toBe(UNC) + expect(target.executionHostId).toBe('local') + }) + + it('hides only the local row from clients, by host, while the host row stays listed', () => { + const hidden = runtimeHiddenRepos(store) + expect(hidden.hasWorktree({ repoId: 'repo-1', hostId: 'local' })).toBe(true) + expect(hidden.hasWorktree({ repoId: 'repo-1', hostId: HOST_ID })).toBe(false) + }) + + it('persists its revealed tab through the freeze and keeps the slept terminal as it was', async () => { + const runtime = new MovedProjectRuntime(store) + await runtime.publishStructuredAgentSessionTab({ + workspaceId: MOVED, + sessionId: 'chat-moved-0001', + agent: 'codex', + activate: true + }) + const session = store.getWorkspaceSession() + expect( + session.unifiedTabs?.[MOVED]?.filter((tab) => tab.contentType === 'agent-session') + ).toHaveLength(1) + expect(session.tabsByWorktree[MOVED]?.map((tab) => tab.id)).toEqual(['slept']) + }) +}) diff --git a/src/main/ssh/__snapshots__/orcad-migration-manifest-collector-golden.test.ts.snap b/src/main/ssh/__snapshots__/orcad-migration-manifest-collector-golden.test.ts.snap new file mode 100644 index 00000000000..3a7c77da48e --- /dev/null +++ b/src/main/ssh/__snapshots__/orcad-migration-manifest-collector-golden.test.ts.snap @@ -0,0 +1,53 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SSH migration manifest through the real collectors > exercises every collector the rich profile feeds 1`] = ` +{ + "automationRuns": 1, + "automations": 1, + "savedPortForwards": 1, + "sessionTabs": 1, + "sparsePresets": 1, + "worktreeLineage": 1, + "worktreeMeta": 1, +} +`; + +exports[`SSH migration manifest through the real collectors > keeps the blocked counts of the rich profiles unchanged 1`] = ` +{ + "blockedCounts": { + "automation": 0, + "automation-run": 0, + "mobile-tab-selection": 0, + "retired-worktree-name": 0, + "saved-port-forward": 0, + "sparse-preset": 0, + "ui-routing": 0, + "workspace-lineage": 0, + "workspace-session": 0, + "worktree-lineage": 0, + "worktree-metadata": 0, + }, + "blockedSessionItems": [], + "leftBehindItems": [], +} +`; + +exports[`SSH migration manifest through the real collectors > keeps the blocked counts of the rich profiles unchanged 2`] = ` +{ + "blockedCounts": { + "automation": 0, + "automation-run": 0, + "mobile-tab-selection": 0, + "retired-worktree-name": 0, + "saved-port-forward": 0, + "sparse-preset": 0, + "ui-routing": 0, + "workspace-lineage": 0, + "workspace-session": 0, + "worktree-lineage": 0, + "worktree-metadata": 0, + }, + "blockedSessionItems": [], + "leftBehindItems": [], +} +`; diff --git a/src/main/ssh/orcad-migration-catalog-reference-client.ts b/src/main/ssh/orcad-migration-catalog-reference-client.ts index e377e5065c0..35c5c8742bb 100644 --- a/src/main/ssh/orcad-migration-catalog-reference-client.ts +++ b/src/main/ssh/orcad-migration-catalog-reference-client.ts @@ -1,7 +1,7 @@ import { parseOrcadMigrationCatalogReferenceAbortResult } from '../../shared/orcad-migration/orcad-migration-catalog-state' import type { - OrcadMigrationCatalogReferenceAbortResult, - OrcadMigrationManifest + OrcadMigrationCatalogReference, + OrcadMigrationCatalogReferenceAbortResult } from '../../shared/orcad-migration/orcad-migration-manifest' import { ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY } from '../../shared/orcad-runtime-capabilities' import { parsePairingCode } from '../../shared/pairing' @@ -19,7 +19,7 @@ export const ORCAD_MIGRATION_DESTINATION_ABORT_BY_REFERENCE_UNSUPPORTED = /** Abort by manifest identity when the full request cannot fit the sealed frame. */ export async function abortRemoteOrcadMigrationCatalogByReference( pairingCode: string, - manifest: OrcadMigrationManifest, + manifest: OrcadMigrationCatalogReference, options: CatalogRequestOptions = {} ): Promise { const pairing = parsePairingCode(pairingCode) diff --git a/src/main/ssh/orcad-migration-cutover-coordinator.ts b/src/main/ssh/orcad-migration-cutover-coordinator.ts index 4ed1335f231..4fa91490733 100644 --- a/src/main/ssh/orcad-migration-cutover-coordinator.ts +++ b/src/main/ssh/orcad-migration-cutover-coordinator.ts @@ -1,6 +1,7 @@ /** Keep source authority until durable destination commit. Verify the fenced export before stage/commit; resolve lost replies by catalog reads and persist journal transitions before returning. Never release a committed destination. */ import type { OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogReference, OrcadMigrationCatalogReferenceAbortResult, OrcadMigrationCatalogState, OrcadMigrationManifest @@ -36,7 +37,7 @@ export type OrcadMigrationDestinationCatalog = { abort: (manifest: OrcadMigrationManifest) => Promise /** Use identity-only abort when the journal's full manifest cannot fit a request. */ abortByReference: ( - manifest: OrcadMigrationManifest + manifest: OrcadMigrationCatalogReference ) => Promise stageChunk: ( request: OrcadMigrationSnapshotChunkRequest @@ -163,7 +164,7 @@ export async function abortOrcadMigrationCutover( } /** Committed reads may be unflushed memory; require the idempotent commit acknowledgement to prove durability. */ -async function confirmDurableCommit( +export async function confirmDurableCommit( destination: OrcadMigrationDestinationCatalog, manifest: OrcadMigrationManifest ): Promise { @@ -221,7 +222,8 @@ function recordObservedState( return next } -async function abortWithRecovery( +/** Aborts staging; a failed abort is decided by a re-read, never assumed. */ +export async function abortWithRecovery( destination: OrcadMigrationDestinationCatalog, manifest: OrcadMigrationManifest ): Promise { diff --git a/src/main/ssh/orcad-migration-destination-fake.ts b/src/main/ssh/orcad-migration-destination-fake.ts index 0669bc20093..097dd774dbd 100644 --- a/src/main/ssh/orcad-migration-destination-fake.ts +++ b/src/main/ssh/orcad-migration-destination-fake.ts @@ -14,7 +14,8 @@ export type FakeOrcadMigrationDestination = { commits: number } & { /** Model idempotent stage and receipt-keyed commit with separate catalog state for each migration, including later deltas. */ export function fakeOrcadMigrationDestination(): FakeOrcadMigrationDestination { const states = new Map() - const stateOf = (manifest: OrcadMigrationManifest) => states.get(manifest.migrationId) ?? 'absent' + const stateOf = (manifest: Pick) => + states.get(manifest.migrationId) ?? 'absent' const view = (manifest: OrcadMigrationManifest): OrcadMigrationCatalogState => { const state = stateOf(manifest) const base = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } diff --git a/src/main/ssh/orcad-migration-manifest-collector-golden.test.ts b/src/main/ssh/orcad-migration-manifest-collector-golden.test.ts new file mode 100644 index 00000000000..fc25393cb55 --- /dev/null +++ b/src/main/ssh/orcad-migration-manifest-collector-golden.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { PersistedState } from '../../shared/persisted-state-types' +import type { SshTarget } from '../../shared/ssh-types' +import type { TerminalTab } from '../../shared/terminal-tab-types' +import { worktreeWorkspaceKey } from '../../shared/workspace-scope' +import { collectOrcadMigrationSourceDormantState } from '../persistence/migrating-orcad-catalog/orcad-source-dormant-state' +import { + DORMANT_AUTOMATION, + DORMANT_AUTOMATION_RUN, + FOLDER_WORKSPACE, + PROJECT_GROUP, + REPOSITORY +} from '../persistence/migrating-orcad-catalog/persistence-orcad-migration-catalog-fixture' +import { createOrcadMigrationManifest } from './orcad-migration-manifest-export' + +// Pins the SSH export through the real dormant-state collectors, so generalizing their source scope +// cannot change one byte an SSH destination signs and verifies. +const TARGET: SshTarget = { + id: 'ssh-prod', + label: 'Production', + host: 'prod.example.com', + port: 22, + username: 'deploy', + generation: 3 +} +const HOST = 'ssh:ssh-prod' +const WORKTREE = 'repo-1::/srv/repo-1-feature' +const OTHER_WORKTREE = 'desktop-repo::/home/user/repo' + +function tab(id: string, worktreeId = WORKTREE): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + startupCwd: '/srv/repo-1-feature/src' + } +} + +function meta(hostId?: ExecutionHostId) { + return { + displayName: 'Feature', + comment: 'Keep', + isUnread: false, + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isPinned: true, + sortOrder: 2, + lastActivityAt: 3, + ...(hostId ? { hostId } : {}) + } +} + +function baseState(): PersistedState { + const state = getDefaultPersistedState('/home/user') + state.sshTargets = [ + { ...TARGET, portForwards: [{ localPort: 3000, remoteHost: 'localhost', remotePort: 3000 }] } + ] + state.projectGroups = [PROJECT_GROUP] + state.repos = [ + REPOSITORY, + { + id: 'desktop-repo', + path: '/home/user/repo', + displayName: 'Desktop', + badgeColor: '#737373', + addedAt: 1 + } + ] + state.folderWorkspaces = [FOLDER_WORKSPACE] + return state +} + +function richState(): PersistedState { + const state = baseState() + state.worktreeMeta[WORKTREE] = meta(HOST) + state.worktreeLineageById[WORKTREE] = { + worktreeId: WORKTREE, + worktreeInstanceId: 'child', + parentWorktreeId: 'repo-1::/srv/repo-1', + parentWorktreeInstanceId: 'parent', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + state.sparsePresetsByRepo['repo-1'] = [ + { + id: 'preset', + repoId: 'repo-1', + name: 'Src', + directories: ['src'], + createdAt: 1, + updatedAt: 2 + } + ] + state.automations = [ + { + ...DORMANT_AUTOMATION, + runContext: { ...DORMANT_AUTOMATION.runContext!, hostId: HOST }, + executionTargetType: 'ssh', + executionTargetId: TARGET.id, + executionTargetGeneration: TARGET.generation, + schedulerOwner: 'ssh_bridge' + } + ] + state.automationRuns = [ + { + ...DORMANT_AUTOMATION_RUN, + runContext: { ...DORMANT_AUTOMATION_RUN.runContext!, hostId: HOST } + } + ] + const host = getDefaultWorkspaceSession() + host.activeRepoId = 'repo-1' + host.activeWorktreeId = WORKTREE + host.tabsByWorktree = { [WORKTREE]: [tab('shell'), tab('build')] } + host.terminalLayoutsByTabId = { + shell: { + root: { type: 'leaf', leafId: 'leaf' }, + activeLeafId: 'leaf', + expandedLeafId: null, + buffersByLeafId: { leaf: 'saved output' } + } + } + host.openFilesByWorktree = { + [WORKTREE]: [ + { + filePath: '/srv/repo-1-feature/README.md', + relativePath: 'README.md', + worktreeId: WORKTREE, + language: 'markdown' + } + ] + } + state.workspaceSessionsByHostId = { [HOST]: host } + state.workspaceSession.tabsByWorktree = { [OTHER_WORKTREE]: [tab('desktop', OTHER_WORKTREE)] } + return state +} + +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.worktreeMeta['repo-1::/srv/repo-1-unqualified'] = meta() + const child = worktreeWorkspaceKey(WORKTREE) + state.workspaceLineageByChildKey[child] = { + childWorkspaceKey: child, + childInstanceId: 'child', + parentWorkspaceKey: 'folder:folder-1', + parentInstanceId: null, + origin: 'cli', + capture: { source: 'cwd-context', confidence: 'inferred' }, + createdAt: 1 + } + return state +} + +function exportState(state: PersistedState) { + return createOrcadMigrationManifest( + { + getRepos: () => state.repos, + getFolderWorkspaces: () => state.folderWorkspaces, + getProjectGroups: () => state.projectGroups, + collectOrcadMigrationSourceDormantState: (source, catalog, destination) => + collectOrcadMigrationSourceDormantState(state, source, catalog, undefined, destination) + .payload + }, + TARGET, + { + migrationId: 'migration-collector-golden', + now: () => new Date('2026-10-01T00:00:00.000Z'), + destinationEnvironmentId: 'env-golden' + } + ) +} + +// Pinned on main before the dormant collectors learned a local-WSL scope. +const GOLDEN = { + base: '895d1122c5447c38dce29c913f499836eaf6493746f71106437a15193de4dfdc', + rich: '432ba03f63495003e0b2d7ed12d5a7d767e4949b41d65f89908bfe94d67b935e', + shared: '690c62cb37b1aaab67184a9de2d65a548a9ed84d6f72b39bb758161a781ae165' +} + +describe('SSH migration manifest through the real collectors', () => { + it.each([ + ['base', baseState], + ['rich', richState], + ['shared', sharedRepoState] + ] as const)('keeps the %s profile digest byte-identical', (name, build) => { + expect(exportState(build()).manifestSha256).toBe(GOLDEN[name]) + }) + + it('exercises every collector the rich profile feeds', () => { + const dormant = exportState(richState()).payload.dormantState + expect({ + worktreeMeta: dormant?.worktreeMeta.length, + worktreeLineage: dormant?.worktreeLineage.length, + sparsePresets: dormant?.sparsePresets.length, + automations: dormant?.automations?.length, + automationRuns: dormant?.automationRuns?.length, + sessionTabs: Object.keys(dormant?.workspaceSession?.tabsByWorktree ?? {}).length, + savedPortForwards: dormant?.clientState?.savedPortForwards?.length + }).toMatchSnapshot() + }) + + it('keeps the blocked counts of the rich profiles unchanged', () => { + for (const build of [richState, sharedRepoState]) { + const state = build() + const manifest = exportState(state) + const inspection = collectOrcadMigrationSourceDormantState( + state, + manifest.source, + manifest.payload, + undefined, + 'env-golden' + ) + expect({ + blockedCounts: inspection.blockedCounts, + blockedSessionItems: inspection.blockedSessionItems, + leftBehindItems: inspection.leftBehindItems + }).toMatchSnapshot() + } + }) +}) diff --git a/src/main/ssh/orcad-migration-manifest-export.ts b/src/main/ssh/orcad-migration-manifest-export.ts index 55ca14f1da6..84cce609c58 100644 --- a/src/main/ssh/orcad-migration-manifest-export.ts +++ b/src/main/ssh/orcad-migration-manifest-export.ts @@ -105,7 +105,7 @@ const UNSIGNED_DIGEST = '0'.repeat(64) // Request id, device token, method name and JSON framing around the params. const REQUEST_ENVELOPE_RESERVE_BYTES = 1024 /** The largest catalog-request params that still fit the host's sealed inbound frame. */ -const ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES = +export const ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES = REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES - REQUEST_ENVELOPE_RESERVE_BYTES /** Reject oversized manifest requests before fencing: stage/state/full abort would all exceed the sealed frame and close its socket. */ @@ -200,7 +200,7 @@ function restrictCatalog( } } -function hasDormantState(state: OrcadMigrationDormantStatePayload): boolean { +export function hasDormantState(state: OrcadMigrationDormantStatePayload): boolean { return ( state.worktreeMeta.length > 0 || state.worktreeLineage.length > 0 || diff --git a/src/main/ssh/orcad-migration-path-rewrite.ts b/src/main/ssh/orcad-migration-path-rewrite.ts index 772736c469d..77582455acf 100644 --- a/src/main/ssh/orcad-migration-path-rewrite.ts +++ b/src/main/ssh/orcad-migration-path-rewrite.ts @@ -3,11 +3,7 @@ import type { OrcadMigrationCatalogPayload } from '../../shared/orcad-migration/ import { isRecord } from '../../shared/orcad-migration/orcad-migration-manifest-fields' import { PTY_SESSION_ID_SEPARATOR } from '../../shared/pty-session-id-format' import { parseWorkspaceKey } from '../../shared/workspace-scope' -import { - FOLDER_WORKSPACE_INSTANCE_SEPARATOR, - splitWorktreeId, - WORKTREE_ID_SEPARATOR -} from '../../shared/worktree/id' +import { rewriteWorktreeIdPath } from '../../shared/worktree/id' import { ORCAD_MIGRATION_PATH_FIELDS, type OrcadMigrationPathFieldKind, @@ -137,19 +133,7 @@ export function rewriteOrcadMigrationWorktreeId( value: string, rewriter: OrcadMigrationPathRewriter ): string { - const parsed = splitWorktreeId(value) - if (!parsed || !parsed.repoId || !parsed.worktreePath) { - return value - } - const suffixAt = parsed.worktreePath.lastIndexOf(FOLDER_WORKSPACE_INSTANCE_SEPARATOR) - const instanceId = parsed.worktreePath.slice( - suffixAt + FOLDER_WORKSPACE_INSTANCE_SEPARATOR.length - ) - const suffix = - suffixAt > 0 && /^[0-9a-f-]{36}$/.test(instanceId) ? parsed.worktreePath.slice(suffixAt) : '' - const path = parsed.worktreePath.slice(0, parsed.worktreePath.length - suffix.length) - const mapped = rewriter.path(path, 'absolute') - return mapped === path ? value : `${parsed.repoId}${WORKTREE_ID_SEPARATOR}${mapped}${suffix}` + return rewriteWorktreeIdPath(value, (path) => rewriter.path(path, 'absolute')) } function rewriteOwnerKey(value: string, rewriter: OrcadMigrationPathRewriter): string { diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 347c76b9427..3ac6efe45a3 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -13,7 +13,7 @@ import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' import { getCanonicalUserDataPath } from '../persistence' import { getAppEnvironment } from '../../shared/app-environment' import { loadWslMoveFence } from '../persistence/moving-wsl-projects/wsl-move-scope-guard' -import { runGatedWslMoveDesktopRewritePass } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite' +import { startWslMoveStartup } from '../persistence/moving-wsl-projects/wsl-move-startup' import { createProfileStateStoreForStartup } from '../persistence/profile-state/profile-state-startup-authority' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { initializeBrowserClientHostId } from '../browser/browser-client-host-id' @@ -141,6 +141,9 @@ export async function initializeReadyFoundation(): Promise { state.activeOrcaProfile = profile // Read durable browser hosting ID before window argv stamps it or renderer and lease ownership diverge. initializeBrowserClientHostId(profile.profileDirectory) + // Why before the store loads: its pane normalization tells a moved project's retained rows from + // a rival host's, and no handler or runtime may give a moving project a new terminal. + loadWslMoveFence(getAppEnvironment().getPath('userData')) const profileState = await createProfileStateStoreForStartup({ userDataPath: getProfileUserDataPath(), dataFile: profile.dataFile, @@ -160,9 +163,11 @@ export async function initializeReadyFoundation(): Promise { const store = profileState.store state.store = store setNodeDistMirrorSettingReader(() => store.getSettings().nodeDistMirrorUrl) - // Fence WSL moves before handlers/runtime can create terminals in moving projects. - loadWslMoveFence(getAppEnvironment().getPath('userData')) - void runGatedWslMoveDesktopRewritePass({ userDataPath: getAppEnvironment().getPath('userData') }) + // Main-process-ready waits on interrupted moves being reset or finished, bounded. + state.wslMoveStartupRewriteReady = startWslMoveStartup( + getAppEnvironment().getPath('userData'), + store + ) // Publish proxy readiness before guarding default-session requests; network awaits it, while window creation can proceed. state.initialProxyApplicationReady = applyElectronProxySettings(store.getSettings()).then( (result) => { diff --git a/src/main/startup/main-process-ready-phase-ordering.test.ts b/src/main/startup/main-process-ready-phase-ordering.test.ts index 9220bb54777..a66597549b7 100644 --- a/src/main/startup/main-process-ready-phase-ordering.test.ts +++ b/src/main/startup/main-process-ready-phase-ordering.test.ts @@ -34,6 +34,7 @@ vi.mock('./main-process-runtime-launch', () => ({ })) const { initializeMainProcessReady } = await import('./main-process-ready') +const { mainProcessState } = await import('./main-process-state') describe('ready-phase concurrency', () => { beforeEach(() => { @@ -86,4 +87,37 @@ describe('ready-phase concurrency', () => { await ready expect(settled).toBe(true) }) + + it('waits for the startup WSL move rewrite before runtime services and the window', async () => { + let finishRewrite: () => void = () => {} + mainProcessState.wslMoveStartupRewriteReady = new Promise((resolve) => { + finishRewrite = () => { + phaseEvents.push('wsl-move-rewrite-done') + resolve() + } + }) + const options = { + openMainWindow: vi.fn(), + handleMacAppActivation: vi.fn() + } as unknown as Parameters[0] + + const ready = initializeMainProcessReady(options) + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve() + } + expect(phaseEvents).toEqual(['foundation']) + + finishRewrite() + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve() + } + releaseI18n?.() + await ready + mainProcessState.wslMoveStartupRewriteReady = Promise.resolve() + expect(phaseEvents.slice(0, 3)).toEqual([ + 'foundation', + 'wsl-move-rewrite-done', + 'runtime-services' + ]) + }) }) diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index 005e0ff30de..fb39a5a38aa 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -33,6 +33,7 @@ import { runAfterFirstWindowShown } from './first-window-deferral' import { logStartupMilestone } from './startup-diagnostics' import { refreshInstalledOpenCodeStatusPlugins } from '../opencode/opencode-status-plugin-startup-refresh' import { pruneDesktopOrcadArtifactCache } from '../orcad/orcad-artifact-cache-retention' +import { runGatedWslMoveDesktopRewritePass } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' import { repairDuplicatePtyPanesOnStart } from '../runtime/moved-pane-owner-rekey' import { WslUsageReporter } from '../telemetry/wsl-usage-reporter' @@ -103,6 +104,10 @@ export async function initializeReadyRuntimeServices(): Promise { console.warn('[orcad-artifacts] Failed to bound the slot cache:', error) }) }, WORKTREE_TRASH_SWEEP_FALLBACK_MS) + // The startup pass ran before browser sessions and any window existed, so `browser-cookies` paused. + runAfterFirstWindowShown(() => { + void runGatedWslMoveDesktopRewritePass({ userDataPath: app.getPath('userData') }) + }, WORKTREE_TRASH_SWEEP_FALLBACK_MS) nativeTheme.themeSource = store.getSettings().theme ?? 'system' // Wait for shell PATH before probing Codex; start reconciliation early so first launch can share it. startCodexHooks({ diff --git a/src/main/startup/main-process-ready.ts b/src/main/startup/main-process-ready.ts index 5dae09ea3b5..780f3ad8670 100644 --- a/src/main/startup/main-process-ready.ts +++ b/src/main/startup/main-process-ready.ts @@ -20,6 +20,7 @@ export async function initializeMainProcessReady( } try { await initializeReadyFoundation() + await state.wslMoveStartupRewriteReady await initializeReadyRuntimeServices() // Window creation can proceed while translations and the native menu initialize. const i18nAndMenuReady = initializeMainProcessI18nAndMenu() diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 892ee75e8bf..614ff83433e 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -70,6 +70,15 @@ function settleDesktopActivation(): void { }) } +/** E2E only: a serve host with no browser of its own, as the WSL distro's server is. Never packaged. */ +function e2eHeadlessServeWithoutBrowser(): boolean { + return ( + !app.isPackaged && + process.env.ORCA_E2E_HEADLESS === '1' && + process.env.ORCA_E2E_HEADLESS_SERVE_WITHOUT_BROWSER === '1' + ) +} + function installRuntimeRpc( runtime: RuntimeService, serveOptions: ReturnType | null @@ -164,7 +173,7 @@ async function launchServeMode( await placeUnshownLiveTerminalsBestEffort(runtime) } // Headless hosts need display-backed offscreen WebContents; advertise browser capability only when usable. - if (state.headlessBrowserDisplayAvailable) { + if (state.headlessBrowserDisplayAvailable && !e2eHeadlessServeWithoutBrowser()) { runtime.setOffscreenBrowserBackend( new OffscreenBrowserBackend(browserManager, { getAgentBrowserBridge: () => state.agentBrowserBridge, diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index 4462d3fb05d..386a0665d14 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -128,6 +128,9 @@ export const mainProcessState = { firstWindowStartupServicesReady: Promise.resolve(), // Publish proxy readiness for fetchers; window creation can proceed behind the request guard. initialProxyApplicationReady: Promise.resolve(), + // Why awaited before runtime services: the first window's session and UI reads must see a + // committed WSL move's rewrite. Bounded, never rejects, and resolved at once with the gate off. + wslMoveStartupRewriteReady: Promise.resolve(), // Parallel i18n gates the translated failure dialog only; phase failures are handled by readiness composition. mainProcessI18nReady: Promise.resolve(), managedWslCliReconciliationReady: Promise.resolve(), diff --git a/src/main/telemetry/wsl-move-orchestrator-telemetry.test.ts b/src/main/telemetry/wsl-move-orchestrator-telemetry.test.ts new file mode 100644 index 00000000000..8f21ad598a5 --- /dev/null +++ b/src/main/telemetry/wsl-move-orchestrator-telemetry.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { wslMoveOrchestratorSchema } from '../../shared/telemetry-wsl-move-event-schemas' +import type { WslMoveOrchestratorRecord } from '../../shared/wsl-move/wsl-move-orchestrator-types' +import { wslMoveOrchestratorEventProps } from './wsl-move-orchestrator-telemetry' + +function record( + state: WslMoveOrchestratorRecord['state'], + attempts = 0 +): WslMoveOrchestratorRecord { + return { distro: 'Secret-Distro', state, attempts, appVersion: '1.0.0', updatedAt: 0 } +} + +describe('wslMoveOrchestratorEventProps', () => { + it('reports enums only, valid against the strict schema', () => { + const props = wslMoveOrchestratorEventProps( + 'project-opened', + record({ kind: 'parked', reason: 'manifest-too-large' }, 3), + { outcome: 'refused', code: 'wsl_move_manifest_too_large' } + ) + expect(props).toEqual({ + trigger: 'project_opened', + transition: 'parked', + refusal: 'manifest_too_large', + reset_reason: 'none', + park_reason: 'manifest_too_large', + attempt_bucket: '2_3' + }) + expect(wslMoveOrchestratorSchema.parse(props)).toEqual(props) + expect(JSON.stringify(props)).not.toContain('Secret') + }) + + it('names a reset by its code, never its detail', () => { + const props = wslMoveOrchestratorEventProps( + 'timer', + record({ kind: 'backoff', nextAttemptAt: 1, reason: 'x' }, 1), + { outcome: 'reset', migrationId: 'm1', detail: 'connect ECONNREFUSED 127.0.0.1:/home/u' } + ) + expect(props).toMatchObject({ + transition: 'backoff', + reset_reason: 'host_unreachable', + attempt_bucket: '1' + }) + expect(wslMoveOrchestratorSchema.safeParse(props).success).toBe(true) + }) + + it('maps every state to a transition the schema accepts', () => { + const states: WslMoveOrchestratorRecord['state'][] = [ + { kind: 'idle' }, + { kind: 'pending' }, + { kind: 'deferred', blockers: [], rechecks: 0 }, + { kind: 'moving' }, + { kind: 'waiting-for-host', migrationId: 'm' }, + { kind: 'backoff', nextAttemptAt: 0, reason: 'x' }, + { kind: 'parked', reason: 'not-now', until: 1 }, + { kind: 'parked', reason: 'declined' }, + { kind: 'parked', reason: 'removal-pending' }, + { kind: 'parked', reason: 'stuck' }, + { kind: 'done', migrationId: 'm' }, + { kind: 'distro-gone' }, + { kind: 'restore-offered' } + ] + for (const state of states) { + expect( + wslMoveOrchestratorSchema.safeParse(wslMoveOrchestratorEventProps('user', record(state))) + .success + ).toBe(true) + } + }) +}) diff --git a/src/main/telemetry/wsl-move-orchestrator-telemetry.ts b/src/main/telemetry/wsl-move-orchestrator-telemetry.ts new file mode 100644 index 00000000000..03f9b055104 --- /dev/null +++ b/src/main/telemetry/wsl-move-orchestrator-telemetry.ts @@ -0,0 +1,98 @@ +/** + * The move orchestrator's telemetry (D3a): one enum-only row each time a distro's state changes, + * so the rollout can see how many moves wait on busy terminals, back off or park, and why. + */ +import type { EventProps } from '../../shared/telemetry-events' +import { WSL_MOVE_REFUSAL_VALUES } from '../../shared/telemetry-wsl-move-event-schemas' +import type { WslMovePreview, WslMoveResult } from '../../shared/wsl-move/wsl-move-driver-types' +import type { + WslMoveOrchestratorParkReason, + WslMoveOrchestratorRecord, + WslMoveOrchestratorTrigger +} from '../../shared/wsl-move/wsl-move-orchestrator-types' +import { track } from './client' +import { wslMoveResetReason } from './wsl-move-telemetry' + +type Props = EventProps<'wsl_move_orchestrator'> + +function transition(record: WslMoveOrchestratorRecord): Props['transition'] { + switch (record.state.kind) { + case 'moving': + return 'started' + case 'waiting-for-host': + return 'waiting_for_host' + case 'distro-gone': + return 'distro_gone' + case 'restore-offered': + return 'restore_offered' + case 'idle': + case 'pending': + case 'deferred': + case 'backoff': + case 'parked': + case 'done': + return record.state.kind + } +} + +function parkReason(reason: WslMoveOrchestratorParkReason): Props['park_reason'] { + switch (reason) { + case 'export-incomplete': + return 'export_incomplete' + case 'manifest-too-large': + return 'manifest_too_large' + case 'host-update-required': + return 'host_update_required' + case 'attempts-exhausted': + return 'attempts_exhausted' + case 'not-now': + return 'not_now' + case 'declined': + return 'declined' + case 'removal-pending': + return 'removal_pending' + case 'stuck': + return 'stuck' + } +} + +function attemptBucket(attempts: number): Props['attempt_bucket'] { + if (attempts <= 0) { + return '0' + } + if (attempts === 1) { + return '1' + } + return attempts <= 3 ? '2_3' : 'gte_4' +} + +export function wslMoveOrchestratorEventProps( + trigger: WslMoveOrchestratorTrigger, + record: WslMoveOrchestratorRecord, + cause?: WslMovePreview | WslMoveResult +): Props { + const refusal = + cause?.outcome === 'refused' + ? (WSL_MOVE_REFUSAL_VALUES.find((value) => value === cause.code.replace(/^wsl_move_/u, '')) ?? + 'other') + : 'none' + const resetDetail = + cause?.outcome === 'reset' || cause?.outcome === 'waiting-for-host' ? cause.detail : null + return { + trigger: trigger === 'project-opened' ? 'project_opened' : trigger, + transition: transition(record), + refusal, + reset_reason: resetDetail === null ? 'none' : wslMoveResetReason(resetDetail), + park_reason: record.state.kind === 'parked' ? parkReason(record.state.reason) : 'none', + attempt_bucket: attemptBucket(record.attempts) + } +} + +export function trackWslMoveOrchestrator(props: Props): void { + try { + // track() applies the consent gate and validates against the strict schema. + track('wsl_move_orchestrator', props) + } catch { + // Telemetry never costs a move. + } +} diff --git a/src/main/telemetry/wsl-move-telemetry.test.ts b/src/main/telemetry/wsl-move-telemetry.test.ts new file mode 100644 index 00000000000..71951cc7f8a --- /dev/null +++ b/src/main/telemetry/wsl-move-telemetry.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { eventSchemas } from '../../shared/telemetry-events' +import type { + WslMoveProgressEvent, + WslMoveResult +} from '../../shared/wsl-move/wsl-move-driver-types' +import type { WslMoveDriverDeps } from '../persistence/moving-wsl-projects/wsl-move-driver-ports' +import { runTrackedWslMove, wslMoveResetReason } from './wsl-move-telemetry' + +const track = vi.hoisted(() => vi.fn()) +vi.mock('./client', () => ({ track })) + +beforeEach(() => { + track.mockReset() +}) + +type Fake = { clock: { ms: number }; deps: WslMoveDriverDeps } + +function fakeDeps(): Fake { + const clock = { ms: 0 } + const partial: Pick = { + now: () => new Date(clock.ms), + progress: () => {} + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: runTrackedWslMove reads only `now` and `progress`, both provided. + return { clock, deps: partial as WslMoveDriverDeps } +} + +async function tracked( + result: WslMoveResult, + progress: WslMoveProgressEvent[] = [], + durationMs = 1_000 +): Promise { + const fake = fakeDeps() + const returned = await runTrackedWslMove(fake.deps, async (wrapped) => { + progress.forEach((event) => wrapped.progress(event)) + fake.clock.ms += durationMs + return result + }) + expect(returned).toBe(result) + return track.mock.calls.map(([name, props]) => { + expect(name).toBe('wsl_move') + return eventSchemas.wsl_move.parse(props) + }) +} + +describe('WSL move telemetry', () => { + it('reports the start, then a finish with its size and duration buckets', async () => { + const events = await tracked( + { outcome: 'moved', migrationId: 'm-1', phase: 'client-rewritten', reloadRequired: true }, + [ + { + distro: 'Ubuntu', + step: 'copying', + phase: 'destination-staged', + bytesTotal: 4 * 1024 * 1024 + } + ], + 20_000 + ) + expect(events).toEqual([ + expect.objectContaining({ outcome: 'started' }), + { + outcome: 'finished', + refusal: 'none', + phase: 'destination_committed', + reset_reason: 'none', + duration_bucket: '15s_60s', + bytes_bucket: '1mb_10mb' + } + ]) + }) + + it('reports a reset with the phase it reached and a reason code, never the detail', async () => { + const events = await tracked( + { + outcome: 'reset', + migrationId: 'move-id-1', + detail: 'orcad_migration_repository_path_conflict:/home/secret-user/app' + }, + [{ distro: 'MyDistro', step: 'copying', phase: 'destination-staged' }] + ) + expect(events[1]).toMatchObject({ + outcome: 'reset', + phase: 'destination_staged', + reset_reason: 'repository_path_conflict' + }) + expect(JSON.stringify(track.mock.calls)).not.toMatch(/secret-user|MyDistro|move-id-1/) + }) + + it('reports a refusal by its code, and a lost distro as such', async () => { + const events = await tracked({ + outcome: 'refused', + code: 'wsl_move_host_unavailable', + detail: 'distro_unregistered' + }) + expect(events[1]).toMatchObject({ + outcome: 'refused', + refusal: 'host_unavailable', + reset_reason: 'distro_unregistered' + }) + }) + + it('reports why a pause refusal after a reset happened', async () => { + const events = await tracked({ + outcome: 'refused', + code: 'wsl_move_pause_failed', + detail: 'terminals-started-after-pause' + }) + expect(events[1]).toMatchObject({ + refusal: 'pause_failed', + reset_reason: 'terminals_started' + }) + }) + + it('reports an outcome even when the run throws', async () => { + await expect( + runTrackedWslMove(fakeDeps().deps, async () => { + throw new Error('boom') + }) + ).rejects.toThrow('boom') + expect(track.mock.calls.map(([, props]) => props.outcome)).toEqual(['started', 'reset']) + }) + + it('reports a move left waiting for its host', async () => { + const events = await tracked( + { outcome: 'waiting-for-host', migrationId: 'm-1', detail: 'x; destination_unreachable' }, + [{ distro: 'Ubuntu', step: 'switching', phase: 'blobs-transferred' }] + ) + expect(events[1]).toMatchObject({ + outcome: 'waiting_for_host', + phase: 'blobs_transferred', + reset_reason: 'host_unreachable' + }) + }) + + it('still moves when telemetry throws', async () => { + track.mockImplementation(() => { + throw new Error('telemetry down') + }) + const result: WslMoveResult = { outcome: 'refused', code: 'wsl_move_cancelled' } + expect(await runTrackedWslMove(fakeDeps().deps, async () => result)).toBe(result) + }) + + it.each([ + ['orcad_migration_staged_claim_conflict:repository:repo-1', 'staged_claim_conflict'], + ['terminals-started-after-pause', 'terminals_started'], + ['exit-unverifiable: census_unavailable', 'exit_unverifiable'], + ['wsl_move_commit_not_committed:absent', 'commit_rejected'], + ['database connection is closed', 'other'], + ['something new', 'other'] + ])('narrows %s to %s', (detail, reason) => { + expect(wslMoveResetReason(detail)).toBe(reason) + }) +}) diff --git a/src/main/telemetry/wsl-move-telemetry.ts b/src/main/telemetry/wsl-move-telemetry.ts new file mode 100644 index 00000000000..b8827b0d5e6 --- /dev/null +++ b/src/main/telemetry/wsl-move-telemetry.ts @@ -0,0 +1,184 @@ +/** + * Migration-outcome telemetry for a WSL move: started, then finished, reset, refused or + * waiting for its host, with the phase it reached, a reason code and duration and size buckets. + * Every raw code is narrowed onto the schema's enums here; nothing a user or host named leaves. + */ +import type { EventProps } from '../../shared/telemetry-events' +import { + WSL_MOVE_REFUSAL_VALUES, + WSL_MOVE_RESET_REASON_VALUES +} from '../../shared/telemetry-wsl-move-event-schemas' +import type { WslMoveRefusalCode, WslMoveResult } from '../../shared/wsl-move/wsl-move-driver-types' +import type { WslMoveCutoverPhase } from '../../shared/wsl-move/wsl-move-cutover' +import type { WslMoveDriverDeps } from '../persistence/moving-wsl-projects/wsl-move-driver-ports' +import { track } from './client' + +type Props = EventProps<'wsl_move'> + +// Why: a new refusal code must get its own telemetry value, not fall into `other` unnoticed. +type RefusalSuffix = WslMoveRefusalCode extends `wsl_move_${infer Suffix}` ? Suffix : never +type UnlistedRefusal = Exclude +const everyRefusalListed: [UnlistedRefusal] extends [never] ? true : never = true +void everyRefusalListed + +function pick(values: readonly T[], value: string, fallback: T): T { + return values.find((candidate) => candidate === value) ?? fallback +} + +function durationBucket(durationMs: number): Props['duration_bucket'] { + if (durationMs < 5_000) { + return 'lt_5s' + } + if (durationMs < 15_000) { + return '5s_15s' + } + return durationMs < 60_000 ? '15s_60s' : 'gte_60s' +} + +function bytesBucket(bytes: number): Props['bytes_bucket'] { + const mb = 1024 * 1024 + if (bytes <= 0) { + return '0' + } + if (bytes < mb) { + return 'lt_1mb' + } + if (bytes < 10 * mb) { + return '1mb_10mb' + } + return bytes < 100 * mb ? '10mb_100mb' : 'gte_100mb' +} + +function phaseValue(phase: WslMoveCutoverPhase | null): Props['phase'] { + switch (phase) { + case null: + return 'none' + case 'destination-staged': + return 'destination_staged' + case 'blobs-transferred': + return 'blobs_transferred' + case 'destination-committed': + case 'client-rewritten': + case 'retained': + return 'destination_committed' + case 'prepared': + case 'source-fenced': + case 'quiesced': + return 'before_stage' + } +} + +// Ordered: the first match names the reason, so specific codes come before generic ones. +const RESET_REASON_PATTERNS: readonly [RegExp, Props['reset_reason']][] = [ + [/orcad_migration_staged_claim_conflict/u, 'staged_claim_conflict'], + [/orcad_migration_repository_path_conflict/u, 'repository_path_conflict'], + [/distro_unregistered|wsl_distro_not_registered/u, 'distro_unregistered'], + [/terminals-started-after-pause/u, 'terminals_started'], + [/exit-unverifiable/u, 'exit_unverifiable'], + [/wsl_move_stage_not_staged/u, 'stage_rejected'], + [/orcad_migration_blob/u, 'blob_rejected'], + [/wsl_move_commit_not_committed|orcad_migration_catalog_not_staged/u, 'commit_rejected'], + [/destination_unreachable|ECONNREFUSED|ECONNRESET|EPIPE/u, 'host_unreachable'] +] + +/** Only the matched code leaves, never the detail text itself. */ +export function wslMoveResetReason(detail: string): Props['reset_reason'] { + const match = RESET_REASON_PATTERNS.find(([pattern]) => pattern.test(detail)) + return pick(WSL_MOVE_RESET_REASON_VALUES, match?.[1] ?? 'other', 'other') +} + +export function wslMoveOutcomeProps( + result: WslMoveResult, + observed: { phase: WslMoveCutoverPhase | null; bytesTotal: number; durationMs: number } +): Props { + const base = { + duration_bucket: durationBucket(observed.durationMs), + bytes_bucket: bytesBucket(observed.bytesTotal) + } + switch (result.outcome) { + case 'moved': + return { + outcome: 'finished', + refusal: 'none', + phase: phaseValue(result.phase), + reset_reason: 'none', + ...base + } + case 'refused': + return { + outcome: 'refused', + refusal: pick(WSL_MOVE_REFUSAL_VALUES, result.code.replace(/^wsl_move_/u, ''), 'other'), + phase: phaseValue(observed.phase), + // A pause refusal after a reset carries the reason in its detail. + reset_reason: result.detail ? wslMoveResetReason(result.detail) : 'none', + ...base + } + case 'reset': + case 'waiting-for-host': + return { + outcome: result.outcome === 'reset' ? 'reset' : 'waiting_for_host', + refusal: 'none', + phase: phaseValue(observed.phase), + reset_reason: wslMoveResetReason(result.detail), + ...base + } + } +} + +function safeTrack(props: Props): void { + try { + // track() applies the consent gate and validates against the strict schema. + track('wsl_move', props) + } catch { + // Telemetry never costs a move. + } +} + +/** Runs one move with its start and outcome reported; the move's own result is returned as is. */ +export async function runTrackedWslMove( + deps: WslMoveDriverDeps, + run: (deps: WslMoveDriverDeps) => Promise +): Promise { + const startedAt = deps.now().getTime() + const observed: { phase: WslMoveCutoverPhase | null; bytesTotal: number } = { + phase: null, + bytesTotal: 0 + } + const tracked: WslMoveDriverDeps = { + ...deps, + progress: (event) => { + observed.phase = event.phase ?? observed.phase + observed.bytesTotal = event.bytesTotal ?? observed.bytesTotal + deps.progress(event) + } + } + safeTrack({ + outcome: 'started', + refusal: 'none', + phase: 'none', + reset_reason: 'none', + duration_bucket: 'lt_5s', + bytes_bucket: '0' + }) + let result: WslMoveResult + try { + result = await run(tracked) + } catch (error) { + safeTrack({ + outcome: 'reset', + refusal: 'none', + phase: phaseValue(observed.phase), + reset_reason: 'other', + duration_bucket: durationBucket(Math.max(0, deps.now().getTime() - startedAt)), + bytes_bucket: bytesBucket(observed.bytesTotal) + }) + throw error + } + safeTrack( + wslMoveOutcomeProps(result, { + ...observed, + durationMs: Math.max(0, deps.now().getTime() - startedAt) + }) + ) + return result +} diff --git a/src/main/terminal-scrollback-snapshot-nonblocking-reads.ts b/src/main/terminal-scrollback-snapshot-nonblocking-reads.ts new file mode 100644 index 00000000000..ff8d06848eb --- /dev/null +++ b/src/main/terminal-scrollback-snapshot-nonblocking-reads.ts @@ -0,0 +1,71 @@ +import { statSync } from 'node:fs' +import { open, stat } from 'node:fs/promises' +import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT } from '../shared/terminal-scrollback-limits' +import { + decodeFromFirstUtf8Boundary, + snapshotReadPaths, + type TerminalScrollbackSnapshotStorage +} from './terminal-scrollback-snapshots' + +/** Whether a non-empty snapshot exists for `ref`; a stat only, so it never reads bytes. */ +export function hasTerminalScrollbackSnapshotSync( + ref: string, + storage?: TerminalScrollbackSnapshotStorage +): boolean { + for (const path of snapshotReadPaths(ref, storage)) { + try { + if (statSync(path).size > 0) { + return true + } + } catch { + // Try the legacy/global fallback when a profile-local snapshot is absent. + } + } + return false +} + +/** Byte size of the snapshot `readTerminalScrollbackSnapshot` would read; `null` when absent. */ +export async function statTerminalScrollbackSnapshotSize( + ref: string, + storage?: TerminalScrollbackSnapshotStorage +): Promise { + for (const path of snapshotReadPaths(ref, storage)) { + try { + return (await stat(path)).size + } catch { + // Try the legacy/global fallback when a profile-local snapshot is absent. + } + } + return null +} + +async function readTrailingUtf8Async(path: string, maxBytes: number): Promise { + const handle = await open(path, 'r') + try { + const size = (await handle.stat()).size + const length = Math.min(size, maxBytes) + if (length <= 0) { + return '' + } + const bytes = Buffer.allocUnsafe(length) + await handle.read(bytes, 0, length, size - length) + return decodeFromFirstUtf8Boundary(bytes) + } finally { + await handle.close() + } +} + +/** Async twin of `readTerminalScrollbackSnapshotSync` for request paths that must not block. */ +export async function readTerminalScrollbackSnapshot( + ref: string, + storage?: TerminalScrollbackSnapshotStorage +): Promise { + for (const path of snapshotReadPaths(ref, storage)) { + try { + return await readTrailingUtf8Async(path, TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT) + } catch { + // Try the legacy/global fallback when a profile-local snapshot is absent. + } + } + return null +} diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index a64d221d37b..231b5e9c5fe 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -54,7 +54,10 @@ function snapshotPath(ref: string, snapshotRoot: string): string | null { return join(snapshotRoot, `${ref}.bin`) } -function snapshotReadPaths(ref: string, storage?: TerminalScrollbackSnapshotStorage): string[] { +export function snapshotReadPaths( + ref: string, + storage?: TerminalScrollbackSnapshotStorage +): string[] { const primaryRoot = getTerminalScrollbackSnapshotRoot(storage) const primaryPath = snapshotPath(ref, primaryRoot) if (!primaryPath) { @@ -132,6 +135,11 @@ function readTrailingUtf8(path: string, maxBytes: number): string { } finally { closeSync(fd) } + return decodeFromFirstUtf8Boundary(bytes) +} + +/** Decodes a tail slice, skipping continuation bytes of a character the slice cut in half. */ +export function decodeFromFirstUtf8Boundary(bytes: Buffer): string { let start = 0 while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) { start++ @@ -269,7 +277,12 @@ export function migrateWorkspaceSessionTerminalScrollbackSnapshots( const remainingBuffers: Record = {} let layoutChanged = false for (const [leafId, buffer] of Object.entries(buffers)) { - const ref = writeTerminalScrollbackSnapshotSync({ tabId, leafId, buffer, storage }) + const ref = writeTerminalScrollbackSnapshotSync({ + tabId, + leafId, + buffer, + storage + }) if (ref) { refs[leafId] = ref layoutChanged = true diff --git a/src/main/wsl/wsl-distro-registry-id.test.ts b/src/main/wsl/wsl-distro-registry-id.test.ts index 15ac8a9e419..0ec5def3fc4 100644 --- a/src/main/wsl/wsl-distro-registry-id.test.ts +++ b/src/main/wsl/wsl-distro-registry-id.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { + isWslDistroRegistered, parseWslDistroRegistrations, readWslDistroRegistryId, readWslDistroRegistryIdShared, @@ -87,6 +88,32 @@ describe('readWslDistroRegistryIdShared', () => { }) }) +describe('isWslDistroRegistered', () => { + const query = async () => ({ code: 0, stdout: REG_OUTPUT }) + + it('finds the distro by name, case-insensitively', async () => { + await expect(isWslDistroRegistered('UBUNTU-24.04', undefined, query)).resolves.toBe(true) + }) + + it('still finds a renamed distro by its recorded GUID, braced or not', async () => { + await expect( + isWslDistroRegistered('Ubuntu', '{0283592D-BE56-40D4-B935-3DC18C3AA007}', query) + ).resolves.toBe(true) + }) + + it('reports a distro gone only when neither its name nor its GUID is registered', async () => { + await expect( + isWslDistroRegistered('Ubuntu', '99999999-0000-4000-8000-000000000000', query) + ).resolves.toBe(false) + }) + + it('never reports gone when the registrations cannot be read', async () => { + await expect( + isWslDistroRegistered('Ubuntu', undefined, async () => ({ code: 1, stdout: '' })) + ).resolves.toBeNull() + }) +}) + describe('resolveWslDistroName', () => { const query = async () => ({ code: 0, stdout: REG_OUTPUT }) diff --git a/src/main/wsl/wsl-distro-registry-id.ts b/src/main/wsl/wsl-distro-registry-id.ts index 4af37205bb9..03c6b00d107 100644 --- a/src/main/wsl/wsl-distro-registry-id.ts +++ b/src/main/wsl/wsl-distro-registry-id.ts @@ -105,6 +105,19 @@ export async function resolveWslDistroName( return match ? match.distro : false } +/** + * Whether the distro is registered, by name or (after a rename) by its recorded GUID; null when + * the registrations cannot be read. + */ +export async function isWslDistroRegistered( + distro: string, + distroGuid?: string, + query: RegQuery = defaultRegQuery +): Promise { + const name = await resolveWslDistroName(distro, distroGuid, query) + return name === null ? null : name !== false +} + const SHARED_READ_TTL_MS = 30_000 const sharedReads = new Map }>() diff --git a/src/main/wsl/wsl-guest-process-table.ts b/src/main/wsl/wsl-guest-process-table.ts new file mode 100644 index 00000000000..df837b26fa9 --- /dev/null +++ b/src/main/wsl/wsl-guest-process-table.ts @@ -0,0 +1,38 @@ +/** + * Every process in a distro, with the pane key and folder of Orca's pane processes, read with + * one `wsl.exe` launch for a WSL move's busy check. Callers asking at once share that launch. + */ +import { + parseWslGuestPaneProcesses, + WSL_GUEST_PANE_PROCESS_SCRIPT, + type WslGuestProcessRow +} from '../persistence/moving-wsl-projects/wsl-move-guest-foreground' +import { runWslProcess } from './wsl-runner' + +const readsInFlight = new Map>() + +/** Null when the distro could not be read in full. */ +export function readWslGuestProcessTable(distro: string): Promise { + // WSL resolves distro names case-insensitively. + const key = distro.toLowerCase() + const inFlight = readsInFlight.get(key) + if (inFlight) { + return inFlight + } + const read = runWslProcess({ + distro, + script: WSL_GUEST_PANE_PROCESS_SCRIPT, + loginPath: 'none', + timeoutMs: 5_000, + maxOutputBytes: 4_194_304 + }) + .then((result) => + result.code !== 0 || result.timedOut || result.outputTruncated + ? null + : parseWslGuestPaneProcesses(result.stdout) + ) + .catch(() => null) + .finally(() => readsInFlight.delete(key)) + readsInFlight.set(key, read) + return read +} diff --git a/src/main/wsl/wsl-host-add-admission.ts b/src/main/wsl/wsl-host-add-admission.ts index c02a74fc7d6..81420eb5379 100644 --- a/src/main/wsl/wsl-host-add-admission.ts +++ b/src/main/wsl/wsl-host-add-admission.ts @@ -70,7 +70,7 @@ export function admitWslHostDistro(args: { return { ok: false, kind: 'distro-stopped', - message: `${distro.name} is stopped. Start it to add it as a host.` + message: `${distro.name} stopped since the list was read. Choose Start and add to start it.` } } return { distro } diff --git a/src/main/wsl/wsl-host-removal.ts b/src/main/wsl/wsl-host-removal.ts index 871aa21395d..4c5061e0fd1 100644 --- a/src/main/wsl/wsl-host-removal.ts +++ b/src/main/wsl/wsl-host-removal.ts @@ -13,6 +13,7 @@ import type { WslHostAddResult, WslHostServerStop } from '../../shared/wsl-host- import { admitWslHostDistro, wslHostRemovalPendingMessage } from './wsl-host-add-admission' import type { WslHostDistroCandidate } from './wsl-host-distro-list' import type { WslHostConnector } from './wsl-host-service' +import { noteWslHostUserAction } from './wsl-host-user-actions' export type WslHostRemoval = { serverStop: WslHostServerStop; removed: boolean } @@ -44,6 +45,9 @@ export async function removeWslHostServer( } if (serverStop.stopped) { removeWslHostEnvironment(userDataPath, record.environmentId) + // Every Remove (sidebar or Settings) is the user's: the move orchestrator never removes a + // host. Only on the proven stop, and before the service's cleanup await can fail. + noteWslHostUserAction({ kind: 'removed', distro: record.distro }) return { serverStop, removed: true } } setWslHostPendingStop(userDataPath, record.environmentId, { diff --git a/src/main/wsl/wsl-host-service-user-actions.test.ts b/src/main/wsl/wsl-host-service-user-actions.test.ts new file mode 100644 index 00000000000..16d271ecacf --- /dev/null +++ b/src/main/wsl/wsl-host-service-user-actions.test.ts @@ -0,0 +1,74 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { listEnvironments } from '../../shared/runtime-environment-store' +import { removeThroughOwningService } from '../ipc/runtime-environments/runtime-environment-host-service-ops' +import { WslHostService, type WslHostConnector } from './wsl-host-service' +import { setWslHostService } from './wsl-host-service-instance' +import { UBUNTU, makeConnector } from './wsl-host-service-test-connector' +import { onWslHostUserAction, type WslHostUserAction } from './wsl-host-user-actions' + +describe('WslHostService Remove as a user action', () => { + let userDataPath: string + const removed = vi.fn() + + function makeService(connector: WslHostConnector): WslHostService { + const service = new WslHostService({ + userDataPath: () => userDataPath, + isEnabled: () => true, + hasServerFiles: () => true, + publish: () => {}, + pairingChanged: () => {}, + ready: () => {}, + removed + }) + service.attach(connector) + return service + } + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-wsl-host-user-actions-')) + removed.mockReset() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + vi.restoreAllMocks() + rmSync(userDataPath, { recursive: true, force: true }) + }) + + // D3a: Settings → Remote Orca Servers removes through the owning service, not wslHosts:remove; + // that Remove must decline the distro's move too, and the orchestrator's own add never counts. + it('reports a Remove made from Settings as the user declining the host', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + const heard: WslHostUserAction[] = [] + const stop = onWslHostUserAction((action) => heard.push(action)) + setWslHostService(service) + try { + await service.add({ distro: 'Ubuntu' }) + const [environment] = listEnvironments(userDataPath) + expect(environment).toBeDefined() + await removeThroughOwningService(environment!) + expect(heard).toEqual([{ kind: 'removed', distro: 'Ubuntu' }]) + } finally { + stop() + setWslHostService(null) + } + }) + + it('reports the Remove even when the cleanup after it fails', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + removed.mockRejectedValueOnce(new Error('transport cleanup failed')) + const heard: WslHostUserAction[] = [] + const stop = onWslHostUserAction((action) => heard.push(action)) + try { + await expect(service.remove('wsl:ubuntu')).rejects.toThrow('transport cleanup failed') + expect(heard).toEqual([{ kind: 'removed', distro: 'Ubuntu' }]) + } finally { + stop() + } + }) +}) diff --git a/src/main/wsl/wsl-host-user-actions.ts b/src/main/wsl/wsl-host-user-actions.ts new file mode 100644 index 00000000000..dffc74385e0 --- /dev/null +++ b/src/main/wsl/wsl-host-user-actions.ts @@ -0,0 +1,20 @@ +/** + * The user's own Add and Remove of a WSL host: a Remove is the user declining the host, and only + * their Add (or Retry) takes that back. Removes are reported by the host service itself, which + * only the user's sidebar and Settings paths reach; Adds by the user's IPC, since the move + * orchestrator adds hosts through the same service. + */ +export type WslHostUserAction = { kind: 'added' | 'removed'; distro: string } + +const listeners = new Set<(action: WslHostUserAction) => void>() + +export function onWslHostUserAction(listener: (action: WslHostUserAction) => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function noteWslHostUserAction(action: WslHostUserAction): void { + for (const listener of listeners) { + listener(action) + } +} diff --git a/src/preload/api/repos-bridge.ts b/src/preload/api/repos-bridge.ts index bd6a9f4d731..4b845f31742 100644 --- a/src/preload/api/repos-bridge.ts +++ b/src/preload/api/repos-bridge.ts @@ -6,6 +6,8 @@ import type { PreloadApi } from '../api-types' export const reposApi = { list: () => ipcRenderer.invoke('repos:list'), + listWslMoved: () => ipcRenderer.invoke('repos:listWslMoved'), + add: (args) => ipcRenderer.invoke('repos:add', args), create: (args) => ipcRenderer.invoke('repos:create', args), diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index 23534b9e266..c59462834bc 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -20,9 +20,12 @@ import type { ProjectUpdateArgs } from '../../shared/project-types' import type { BaseRefDefaultResult, BaseRefSearchResult, Repo } from '../../shared/repo-types' +import type { WslMovedProject } from '../../shared/wsl-move/wsl-moved-project' export type RepositoryApi = { list: () => Promise + /** Local projects a WSL host now owns: read-only, listed only for their existing chats. */ + listWslMoved: () => Promise add: (args: { path: string kind?: 'git' | 'folder' diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts index 2f33fa21263..23f6aff19ec 100644 --- a/src/preload/api/session-bridge.ts +++ b/src/preload/api/session-bridge.ts @@ -9,5 +9,7 @@ export const sessionApi = { closeTerminalSurface: (args) => ipcRenderer.invoke('session:close-terminal-surface', args), flush: () => ipcRenderer.invoke('session:flush'), readTerminalScrollback: (args) => - ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args) + ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), + hasTerminalScrollback: (args) => + ipcRenderer.sendSync('session:has-terminal-scrollback-sync', args) } satisfies PreloadApi['session'] diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index b816c2cde0e..65563c2905a 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -22,6 +22,8 @@ export type WorkspaceSessionApi = { }) => Promise flush: () => Promise readTerminalScrollback: (args: { ref: string }) => string | null + /** Stat-only: whether this machine's snapshot store holds `ref`. */ + hasTerminalScrollback: (args: { ref: string }) => boolean } cache: { getGitHub: () => Promise<{ diff --git a/src/preload/api/wsl-move-bridge.ts b/src/preload/api/wsl-move-bridge.ts index ff1009ec18e..c484a5174c2 100644 --- a/src/preload/api/wsl-move-bridge.ts +++ b/src/preload/api/wsl-move-bridge.ts @@ -1,19 +1,102 @@ import { ipcRenderer } from 'electron' +import { + WSL_MOVE_PROGRESS_CHANNEL, + type WslMovePreview, + type WslMoveProgressEvent, + type WslMoveRequest, + type WslMoveResult, + type WslMoveUnfinished +} from '../../shared/wsl-move/wsl-move-driver-types' import type { WslMovePendingRendererRewrite, WslMoveRecordStoreRewrittenRequest, WslMoveRecordStoreRewrittenResult } from '../../shared/wsl-move/wsl-move-renderer-rewrite-types' +import type { + WslMovedProjectKind, + WslMoveUseLocallyResult +} from '../../shared/wsl-move/wsl-moved-project' +import { + WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL, + WSL_MOVE_ORCHESTRATOR_STATUS_CHANNEL, + type WslMoveOrchestratorRecord, + type WslMoveOrchestratorUserAction +} from '../../shared/wsl-move/wsl-move-orchestrator-types' -/** The renderer half of a committed WSL move's desktop rewrite; main gates both calls. */ +/** A WSL move and the renderer half of its desktop rewrite; main gates every call. */ export type WslMoveApi = { + preview: (request: WslMoveRequest) => Promise + /** Resolves when the move ends; progress arrives through `onProgress` meanwhile. */ + start: (request: WslMoveRequest) => Promise + /** Committed moves with host steps still pending (automations not back on yet). */ + listUnfinished: () => Promise + /** True when a move of the distro was running and had not asked the host to commit yet. */ + cancel: (distro: string) => Promise + /** Returns the unsubscribe. */ + onProgress: (listener: (event: WslMoveProgressEvent) => void) => () => void listPendingRendererRewrites: () => Promise recordStoreRewritten: ( request: WslMoveRecordStoreRewrittenRequest ) => Promise + /** Drops the move that took this project, so its pre-move rows show here again. */ + useProjectHereAgain: (project: { + kind: WslMovedProjectKind + id: string + /** Workspaces with unsaved files; main refuses if the move brings any of them back. */ + unsavedWorkspaceIds?: string[] + }) => Promise + /** The general migration's per-distro state (D3a); empty while the gate is closed. */ + listOrchestrator: () => Promise + /** Null when refused (gate closed, not the main window, unknown action). */ + orchestratorUserAction: (request: { + distro: string + action: WslMoveOrchestratorUserAction + unsavedWorkspaceIds?: string[] + }) => Promise<{ record: WslMoveOrchestratorRecord; release?: WslMoveUseLocallyResult } | null> + /** T2: the window opened a bridge project of this distro; main never boots it. */ + orchestratorProjectOpened: (distro: string) => Promise + /** Returns the unsubscribe. */ + onOrchestratorStatus: (listener: (records: WslMoveOrchestratorRecord[]) => void) => () => void + /** Main asks for the window's slept-pane bindings before an automatic move. */ + onOrchestratorSleptPanesRequest: (listener: (requestId: string) => void) => () => void + answerOrchestratorSleptPanes: ( + requestId: string, + sleptPanes: NonNullable + ) => Promise } export const wslMoveApi: WslMoveApi = { + preview: (request) => ipcRenderer.invoke('wslMove:preview', request), + start: (request) => ipcRenderer.invoke('wslMove:start', request), + cancel: (distro) => ipcRenderer.invoke('wslMove:cancel', distro), + listUnfinished: () => ipcRenderer.invoke('wslMove:listUnfinished'), + onProgress: (listener) => { + const handler = (_event: Electron.IpcRendererEvent, progress: WslMoveProgressEvent): void => + listener(progress) + ipcRenderer.on(WSL_MOVE_PROGRESS_CHANNEL, handler) + return () => ipcRenderer.removeListener(WSL_MOVE_PROGRESS_CHANNEL, handler) + }, listPendingRendererRewrites: () => ipcRenderer.invoke('wslMove:listPendingRendererRewrites'), - recordStoreRewritten: (request) => ipcRenderer.invoke('wslMove:recordStoreRewritten', request) + recordStoreRewritten: (request) => ipcRenderer.invoke('wslMove:recordStoreRewritten', request), + useProjectHereAgain: (project) => ipcRenderer.invoke('wslMove:useProjectHereAgain', project), + listOrchestrator: () => ipcRenderer.invoke('wslMoveOrchestrator:list'), + orchestratorUserAction: (request) => + ipcRenderer.invoke('wslMoveOrchestrator:userAction', request), + orchestratorProjectOpened: (distro) => + ipcRenderer.invoke('wslMoveOrchestrator:projectOpened', distro), + onOrchestratorStatus: (listener) => { + const handler = (_event: Electron.IpcRendererEvent, records: WslMoveOrchestratorRecord[]) => + listener(records) + ipcRenderer.on(WSL_MOVE_ORCHESTRATOR_STATUS_CHANNEL, handler) + return () => ipcRenderer.removeListener(WSL_MOVE_ORCHESTRATOR_STATUS_CHANNEL, handler) + }, + onOrchestratorSleptPanesRequest: (listener) => { + const handler = (_event: Electron.IpcRendererEvent, request: { requestId: string }) => + listener(request.requestId) + ipcRenderer.on(WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL, handler) + return () => + ipcRenderer.removeListener(WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL, handler) + }, + answerOrchestratorSleptPanes: (requestId, sleptPanes) => + ipcRenderer.invoke('wslMoveOrchestrator:answerSleptPanes', { requestId, sleptPanes }) } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9a8583e3c9f..734bb1a5404 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -28,6 +28,7 @@ import { useAppSessionPersistence } from './app-shell/use-app-session-persistenc import { useAppShellServices } from './app-shell/use-app-shell-services' import { useAppStartupHydration } from './app-shell/use-app-startup-hydration' import { startNativeChatDraftLoad } from './app-shell/native-chat-draft-startup' +import { runWslMoveRendererStoreRewrites } from './lib/wsl-move-renderer-store-rewrites' import { useDocumentAppearance } from './app-shell/use-document-appearance' import { useFloatingWorkspacePanel } from './app-shell/use-floating-workspace-panel' import { useGlobalKeybindings } from './app-shell/use-global-keybindings' @@ -48,6 +49,8 @@ function App(): React.JSX.Element { useAppShellServices() // Load drafts before the startup chain so no failed step can skip them. useEffect(startNativeChatDraftLoad, []) + // Committed WSL moves only: the list is empty otherwise, and always with the internal gate closed. + useEffect(() => void runWslMoveRendererStoreRewrites(), []) useAppStartupHydration(onboardingGate.applyStartupOnboardingState) useAppSessionPersistence() useRuntimeGraphSync() diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 2f6d99b88e3..fb2472dca6c 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -18,6 +18,7 @@ import { StarNagAgentValueMomentObserver } from '../components/star-nag/StarNagA import { StarNagToastHost } from '../components/star-nag/StarNagToastHost' import { TelemetryFirstLaunchSurface } from '../components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from '../components/ZoomOverlay' +import { WslMoveDialogHost } from '../components/wsl-move/WslMoveDialog' import { shouldRenderPetOverlay } from '../components/pet/pet-overlay-visibility' import { useAppStore } from '../store' import type { UpdateStatus } from '../../../shared/update-status-types' @@ -209,6 +210,8 @@ export function AppRootSurfaces(props: { ) : null} + {/* Own store, not activeModal: another modal must never evict a running move. */} + {/* Opened by toast, SSH host row and terminal pane. */} {activeModal === 'node-dist-mirror' ? ( diff --git a/src/renderer/src/app-shell/startup-runtime-session-hosts.ts b/src/renderer/src/app-shell/startup-runtime-session-hosts.ts new file mode 100644 index 00000000000..dc071166e05 --- /dev/null +++ b/src/renderer/src/app-shell/startup-runtime-session-hosts.ts @@ -0,0 +1,12 @@ +import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' + +export async function listRuntimeSessionHostIdsForStartup(): Promise { + try { + return (await window.api.runtimeEnvironments.list()).map((environment) => + toRuntimeExecutionHostId(environment.id) + ) + } catch (err) { + console.warn('Failed to list runtime session hosts for startup:', err) + return [] + } +} diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 9a9f4c44257..8d8100ad60b 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -32,11 +32,12 @@ import { import { getRepoExecutionHostId, isOwnedSshTargetId, - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId + parseExecutionHostId } from '../../../shared/execution-host' +import { listRuntimeSessionHostIdsForStartup } from './startup-runtime-session-hosts' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' +import { collectWslMovedWorkspaceKeysFromSession } from '../store/repos/wsl-moved-projects' +import { wslMovedSessionReadApi } from '../store/repos/wsl-moved-local-session-rows' import type { OnboardingState } from '../../../shared/onboarding-state-types' import { ensureLocalRuntimeCapabilities } from '../runtime/local-runtime-capabilities' import { @@ -44,17 +45,6 @@ import { refreshStartupWorktrees } from '../startup/startup-worktree-refresh' -async function listRuntimeSessionHostIdsForStartup(): Promise { - try { - return (await window.api.runtimeEnvironments.list()).map((environment) => - toRuntimeExecutionHostId(environment.id) - ) - } catch (err) { - console.warn('Failed to list runtime session hosts for startup:', err) - return [] - } -} - /** Hydrate settings/UI/catalog/session, reconnect and restore terminals before unlocking saves. Failure boots in degraded no-save mode to protect disk state. */ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingState) => void): void { const actions = useStartupActions() @@ -114,6 +104,9 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta listRuntimeSessionHostIdsForStartup ) runtimeHostsPromise.catch(() => {}) + // Before the session read: a moved project's local rows must leave before the host's merge in. + // Started before the repo list too; this startup load does not prune, so it need not wait. + const wslMovedProjectsPromise = useAppStore.getState().fetchWslMovedProjects() // Skip remote catalogs for first paint; saved runtimes may wait a full connect timeout. await timeRendererStartupStep('fetch-repos-local', () => actions.fetchReposForAllHosts({ remoteHosts: 'skip' }) @@ -130,15 +123,17 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) ) })() - const sessionReadPromise = runtimeHostsPromise.then((startupRuntimeHostIds) => - // Restore cached runtime-host partitions without network waits; skip unreadable ones. - timeRendererStartupStep('session-get', () => - fetchWorkspaceSessionWithRuntimeHostOwners( - window.api.session, - useAppStore.getState().repos, - startupRuntimeHostIds + const sessionReadPromise = Promise.all([runtimeHostsPromise, wslMovedProjectsPromise]).then( + ([startupRuntimeHostIds]) => + // Restore cached runtime-host partitions without network waits; skip unreadable ones. + timeRendererStartupStep('session-get', () => + fetchWorkspaceSessionWithRuntimeHostOwners( + // A moved workspace's retained rows share tab ids with its host's live ones. + wslMovedSessionReadApi(useAppStore.getState().wslMovedProjects), + useAppStore.getState().repos, + startupRuntimeHostIds + ) ) - ) ) const hydrationSessionChain = sessionReadPromise.then(async (sessionRead) => { const hydrationRepoIds = collectWorktreeHydrationRepoIdsFromSession( @@ -182,7 +177,14 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta ) if (!cancelled) { const sessionHydrationOptions = { - additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session) + additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession( + sessionRead.session + ), + wslMovedChatWorkspaceKeys: collectWslMovedWorkspaceKeysFromSession( + sessionRead.session, + useAppStore.getState().wslMovedProjects, + sessionRead.runtimeHostIdByWorkspaceSessionKey + ) } timeRendererStartupSyncStep('hydrate-session-stores', () => { // The read's host-ownership fields are named as the hydration options it feeds. diff --git a/src/renderer/src/components/confirmation-dialog-context.ts b/src/renderer/src/components/confirmation-dialog-context.ts index 9af074dbb0b..b6bd3446157 100644 --- a/src/renderer/src/components/confirmation-dialog-context.ts +++ b/src/renderer/src/components/confirmation-dialog-context.ts @@ -20,6 +20,8 @@ export type ConfirmationDialogOptions = { surface?: 'security' icon?: LucideIcon cancelVariant?: 'outline' | 'ghost' + /** A notice, not a question: only the confirm button shows, and it settles true. */ + acknowledgeOnly?: boolean initialFocus?: 'confirm' /** The short close-prompt look: small title, text and buttons. */ compact?: boolean diff --git a/src/renderer/src/components/confirmation-dialog.test.tsx b/src/renderer/src/components/confirmation-dialog.test.tsx index d0b8080610f..09203168a02 100644 --- a/src/renderer/src/components/confirmation-dialog.test.tsx +++ b/src/renderer/src/components/confirmation-dialog.test.tsx @@ -118,6 +118,20 @@ describe('ConfirmationDialogProvider', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus()) }) + it('shows only the confirm button for a notice', async () => { + const { onSettled } = renderDialog({ + title: "Can't use p1 here yet", + confirmLabel: 'OK', + acknowledgeOnly: true, + initialFocus: 'confirm' + }) + await userEvent.click(screen.getByRole('button', { name: 'ask' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'OK' })).toHaveFocus()) + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument() + await userEvent.keyboard('{Enter}') + await waitFor(() => expect(onSettled).toHaveBeenCalledWith(true)) + }) + it('renders on the security surface when asked', async () => { renderDialog({ title: 'Remove extension?', surface: 'security' }) await userEvent.click(screen.getByRole('button', { name: 'ask' })) diff --git a/src/renderer/src/components/confirmation-dialog.tsx b/src/renderer/src/components/confirmation-dialog.tsx index 541f2bf6a7c..4a92e403588 100644 --- a/src/renderer/src/components/confirmation-dialog.tsx +++ b/src/renderer/src/components/confirmation-dialog.tsx @@ -219,22 +219,24 @@ export function ConfirmationDialogProvider({ Icon && '-mx-6 -mb-6 rounded-b-lg border-t border-border bg-muted/30 px-6 py-4' )} > - + {displayedRequest?.options.acknowledgeOnly ? null : ( + + )} + + + {translate('sidebar.wslMoved.useHere', 'Use on this computer again')} + + + +
+ {translate('sidebar.wslMoved.movedToHost', 'Moved to {{distro}} host', { + distro: project.distro + })} +
+
    + {workspaces.flatMap(({ key, chats }) => + chats.map((chat) => ( +
  • + +
  • + )) + )} +
+ + ))} + + ) +}) + +export default WslMovedProjectsSection diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index bed00b3d4a0..fe5b8da3f73 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -6,6 +6,7 @@ import SidebarHeader from './SidebarHeader' import SidebarNav from './SidebarNav' import SetupScriptPromptCard from './SetupScriptPromptCard' import WorktreeList from './WorktreeList' +import WslMovedProjectsSection from './WslMovedProjectsSection' import SidebarToolbar from './SidebarToolbar' import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' @@ -203,6 +204,7 @@ function Sidebar({ onWorkspaceBoardDragPreviewCommit={solidifyWorkspaceBoardFromDrag} onWorkspaceBoardDragPreviewCancel={cancelWorkspaceBoardDragPreview} /> + )} diff --git a/src/renderer/src/components/sidebar/project-move-pause-follower.test.ts b/src/renderer/src/components/sidebar/project-move-pause-follower.test.ts index 07010abf8b1..766bee0bfea 100644 --- a/src/renderer/src/components/sidebar/project-move-pause-follower.test.ts +++ b/src/renderer/src/components/sidebar/project-move-pause-follower.test.ts @@ -164,6 +164,46 @@ describe('project move renderer completion', () => { expect(mocks.persist).not.toHaveBeenCalled() }) + // One workspace of a two-project move failed in main, and its rollback's + // error failed the whole pause, so the move's retry never ran; a fresh pause re-sleeps it. + it('persists the slept workspaces of a partly failed pause, and a new pause re-sleeps the rest', async () => { + const f = fixture() + mocks.state.shutdownWorktreeTerminals.mockImplementation(async (_id, options) => { + if ((await options?.hostStop)?.failed) { + throw new Error('host_terminal_stop_failed') + } + }) + await f.prepare(['a', 'b']) + f.notice('started', 'a') + f.notice('started', 'b') + f.notice('ended', 'a') + f.follower.follow({ + phase: 'ended', + worktreeId: 'b', + projectMovePauseId: 'move-1', + slept: false, + stoppedPtyIds: [], + records: null + }) + await f.persist(['a']) + expect(f.responses.at(-1)).toMatchObject({ phase: 'persist' }) + await f.follower.request({ requestId: 'move-1', protocol: 1, phase: 'release' }) + + const retry = { requestId: 'move-2', protocol: 1 } as const + await f.follower.request({ ...retry, phase: 'prepare', worktreeIds: ['b'] }) + f.follower.follow({ phase: 'started', worktreeId: 'b', projectMovePauseId: 'move-2' }) + f.follower.follow({ + phase: 'ended', + worktreeId: 'b', + projectMovePauseId: 'move-2', + slept: true, + stoppedPtyIds: ['pty'], + records: [] + }) + await f.follower.request({ ...retry, phase: 'persist', successfulWorktreeIds: ['b'] }) + expect(f.responses.at(-1)).toMatchObject({ requestId: 'move-2', phase: 'persist' }) + }) + it('reports durable write failure after teardown', async () => { const f = fixture() mocks.persist.mockRejectedValue(new Error('disk full')) diff --git a/src/renderer/src/components/sidebar/project-move-pause-follower.ts b/src/renderer/src/components/sidebar/project-move-pause-follower.ts index f91c47a8a0f..0d98a304bb7 100644 --- a/src/renderer/src/components/sidebar/project-move-pause-follower.ts +++ b/src/renderer/src/components/sidebar/project-move-pause-follower.ts @@ -78,7 +78,12 @@ export function createProjectMovePauseFollower( if ([...pause.started].some((id) => !pause.ended.has(id))) { throw new Error('project_move_pause_incomplete_sleep') } - const errors = await Promise.all(pause.ended.values()) + const errors = await Promise.all( + [...pause.ended].map(async ([id, ended]) => + // Why: a workspace main failed to sleep stays awake, and main already reports it. + request.successfulWorktreeIds.includes(id) ? await ended : null + ) + ) const error = errors.find((failure) => failure !== null) if (error) { throw error diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx index c9c64eb9266..54c60961ed8 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx @@ -27,6 +27,7 @@ import { import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { getRepositoryIconSectionId } from '@/components/settings/repository/repository-settings-targets' +import { WslMoveProjectMenuItem } from '@/components/wsl-move/WslMoveProjectMenuItem' import type { ProjectGroup } from '../../../../../../shared/project-group-types' import type { Repo } from '../../../../../../shared/repo-types' import type { WorktreeVisibilityDefaults } from '../../../../../../shared/global-settings-types' @@ -174,6 +175,7 @@ export function RepoHeaderProjectActionsMenu({ {translate('auto.components.sidebar.WorktreeList.64e55f7f01', 'Remove from group')} ) : null} + actions.onRemoveProject(repo)}> diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index c52937c0cde..8d082da1672 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1,7 +1,18 @@ import { TerminalPaneSurface } from './TerminalPaneSurface' +import { TerminalRestoringPlaceholder } from './TerminalRestoringPlaceholder' import { useTerminalPaneController } from './use-terminal-pane-controller' +import { useHostTerminalScrollbackHold } from './use-host-terminal-scrollback-hold' import type { TerminalPaneProps } from './terminal-pane-types' -export default function TerminalPane(props: TerminalPaneProps): React.JSX.Element { +function MountedTerminalPane(props: TerminalPaneProps): React.JSX.Element { return } + +export default function TerminalPane(props: TerminalPaneProps): React.JSX.Element { + const holdForHostScrollback = useHostTerminalScrollbackHold(props.tabId, props.worktreeId) + return holdForHostScrollback ? ( + + ) : ( + + ) +} diff --git a/src/renderer/src/components/terminal-pane/host-scrollback-mount.test.ts b/src/renderer/src/components/terminal-pane/host-scrollback-mount.test.ts new file mode 100644 index 00000000000..84c6e090de4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/host-scrollback-mount.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom +import { cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import type { OrcadDeploymentLink } from '../../../../shared/runtime-environments' +import { resetHostTerminalScrollbackHydrationForTests } from '@/runtime/host-terminal-scrollback-hydration' +import { useHostTerminalScrollbackHold } from './use-host-terminal-scrollback-hold' +import { resolveLeafScrollbackBuffers } from './leaf-scrollback-resolution' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const REF = `v1-${'d'.repeat(32)}` + +type MountStore = { + runtimeEnvironments: { id: string; orcadDeployment?: OrcadDeploymentLink }[] + terminalLayoutsByTabId: Record + localOnlyScrollbackByTabId: Record | undefined> + setTabLocalOnlyScrollback: (tabId: string, buffers: Record | null) => void +} + +const WSL_HOST = { + id: 'env-wsl', + orcadDeployment: { kind: 'wsl', distro: 'Ubuntu', localPort: 6768, remotePort: 6768 } +} satisfies MountStore['runtimeEnvironments'][number] + +const store = vi.hoisted((): MountStore => ({ + runtimeEnvironments: [], + terminalLayoutsByTabId: {}, + localOnlyScrollbackByTabId: {}, + setTabLocalOnlyScrollback: () => {} +})) +const rpc = vi.hoisted((): { known: boolean | undefined; call: ReturnType } => ({ + known: undefined, + call: vi.fn() +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('@/lib/resolve-owner', () => ({ + runtimeEnvironmentIdForWorkspaceOwner: () => ({ kind: 'resolved', environmentId: 'env-wsl' }) +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: rpc.call, + peekRuntimeEnvironmentCapability: () => rpc.known, + runtimeEnvironmentSupportsCapability: async () => { + rpc.known = true + return true + } +})) + +function mountLayout(layout: TerminalLayoutSnapshot): void { + store.terminalLayoutsByTabId['tab-1'] = layout +} + +beforeEach(() => { + resetHostTerminalScrollbackHydrationForTests() + // Only a WSL host serves moved panes their history. + store.runtimeEnvironments = [WSL_HOST] + store.terminalLayoutsByTabId = {} + store.localOnlyScrollbackByTabId = {} + store.setTabLocalOnlyScrollback = (tabId, buffers) => { + store.localOnlyScrollbackByTabId[tabId] = buffers ?? undefined + } + rpc.known = undefined + rpc.call.mockReset() + rpc.call.mockResolvedValue({ buffer: 'moved history\r\n' }) + Reflect.set(window, 'api', { session: { hasTerminalScrollback: () => false } }) +}) + +afterEach(() => { + cleanup() + Reflect.deleteProperty(window, 'api') +}) + +describe('a moved pane mounting on its host', () => { + it('reads the host snapshot before mount and hands it to the replay', async () => { + // Moved panes opened with only a fresh prompt. + const layout: TerminalLayoutSnapshot = { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + scrollbackRefsByLeafId: { [LEAF]: REF } + } + mountLayout(layout) + const { result } = renderHook(() => useHostTerminalScrollbackHold('tab-1', 'wt-1')) + expect(result.current).toBe(true) + await waitFor(() => expect(result.current).toBe(false)) + expect(rpc.call).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-wsl' }, + 'session.readTerminalScrollback', + expect.objectContaining({ ref: REF }), + expect.anything() + ) + // What restoreTerminalPaneLayout replays into xterm on mount. + expect( + resolveLeafScrollbackBuffers({ + shared: layout, + localOnly: store.localOnlyScrollbackByTabId['tab-1'] + }) + ).toEqual({ [LEAF]: 'moved history\r\n' }) + }) + + it('never reads for a pane bound to a terminal, which is why a move carries no binding', () => { + mountLayout({ + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + scrollbackRefsByLeafId: { [LEAF]: REF }, + ptyIdsByLeafId: { [LEAF]: 'wt-1@@81c7100a' } + }) + const { result } = renderHook(() => useHostTerminalScrollbackHold('tab-1', 'wt-1')) + expect(result.current).toBe(false) + expect(rpc.call).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-host-terminal-scrollback-hold.test.ts b/src/renderer/src/components/terminal-pane/use-host-terminal-scrollback-hold.test.ts new file mode 100644 index 00000000000..83750c078e3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-host-terminal-scrollback-hold.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + HOST_TERMINAL_SCROLLBACK_HOLD_MS, + startHostTerminalScrollbackHydration +} from '@/runtime/host-terminal-scrollback-hydration' +import { useHostTerminalScrollbackHold } from './use-host-terminal-scrollback-hold' + +vi.mock('@/runtime/host-terminal-scrollback-hydration', () => ({ + HOST_TERMINAL_SCROLLBACK_HOLD_MS: 3_000, + startHostTerminalScrollbackHydration: vi.fn() +})) +const start = vi.mocked(startHostTerminalScrollbackHydration) + +afterEach(() => { + cleanup() + start.mockReset() + vi.useRealTimers() +}) + +describe('useHostTerminalScrollbackHold', () => { + it('mounts at once when there is nothing to read from a host', () => { + start.mockReturnValue(null) + const { result } = renderHook(() => useHostTerminalScrollbackHold('tab-1', 'wt-1')) + expect(result.current).toBe(false) + }) + + it('holds the mount until the host read settles', async () => { + let settle = (): void => {} + start.mockReturnValue( + new Promise((resolve) => { + settle = resolve + }) + ) + const { result } = renderHook(() => useHostTerminalScrollbackHold('tab-1', 'wt-1')) + expect(result.current).toBe(true) + await act(async () => settle()) + expect(result.current).toBe(false) + expect(start).toHaveBeenCalledWith('tab-1', 'wt-1') + }) + + it('stops holding after the bound even if the host never answers', () => { + vi.useFakeTimers() + start.mockReturnValue(new Promise(() => {})) + const { result } = renderHook(() => useHostTerminalScrollbackHold('tab-1', 'wt-1')) + expect(result.current).toBe(true) + act(() => { + vi.advanceTimersByTime(HOST_TERMINAL_SCROLLBACK_HOLD_MS) + }) + expect(result.current).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-host-terminal-scrollback-hold.ts b/src/renderer/src/components/terminal-pane/use-host-terminal-scrollback-hold.ts new file mode 100644 index 00000000000..b5a02f2386f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-host-terminal-scrollback-hold.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react' +import { + HOST_TERMINAL_SCROLLBACK_HOLD_MS, + startHostTerminalScrollbackHydration +} from '@/runtime/host-terminal-scrollback-hydration' + +/** + * True while this tab's scrollback is being read from its host. The pane mounts once, and mount + * is the only point that replays stored scrollback, so it waits for the bytes instead of racing them. + */ +export function useHostTerminalScrollbackHold(tabId: string, worktreeId: string): boolean { + // Why in the initializer: the hold must be known on the first render, before the pane mounts; + // the start is synchronous and deduplicated per tab, so a repeated render does not refetch. + const [pending] = useState(() => startHostTerminalScrollbackHydration(tabId, worktreeId)) + const [held, setHeld] = useState(pending !== null) + useEffect(() => { + if (!pending) { + return + } + let live = true + const release = (): void => { + if (live) { + setHeld(false) + } + } + // Why: the read aborts at the same bound; this only guards a dependency that ignores abort. + const timer = setTimeout(release, HOST_TERMINAL_SCROLLBACK_HOLD_MS) + void pending.then(release) + return () => { + live = false + clearTimeout(timer) + } + }, [pending]) + return held +} diff --git a/src/renderer/src/components/use-terminal-workspace-foundation.ts b/src/renderer/src/components/use-terminal-workspace-foundation.ts index 965a2f39b22..ec226c717e0 100644 --- a/src/renderer/src/components/use-terminal-workspace-foundation.ts +++ b/src/renderer/src/components/use-terminal-workspace-foundation.ts @@ -6,6 +6,10 @@ import { useWorktreeMap } from '../store/selectors' import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' import type { WorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' import { projectWorkspaceSurfaces } from './workspace-surface-projection' +import { + parseWslMovedSurfacesKey, + selectWslMovedSurfacesKey +} from '@/store/repos/wsl-moved-projects' import { useReusedArrayIdentity } from './sidebar/worktree-list/listing/use-reused-array-identity' import { usePairedRuntimeParkingEnvironmentIds } from '@/store/use-runtime-status-view' import { createTerminalWorktreeTopologyProjection } from './terminal-pane/terminal-hidden-worktree-retention' @@ -39,16 +43,24 @@ export function useTerminalWorkspaceFoundation() { : null // Without a resolved host, active workspace cannot affect projection; omit it to avoid rebuilding every surface on switches. const activeFolderSurfaceId = activeFolderSurfaceHostId ? renderedActiveWorktreeId : null - const workspaceSurfaces = useMemo( - () => - projectWorkspaceSurfaces({ - worktreesById, - folderWorkspaces, - activeWorkspaceId: activeFolderSurfaceId, - activeWorkspaceResolvedHostId: activeFolderSurfaceHostId - }), - [worktreesById, folderWorkspaces, activeFolderSurfaceId, activeFolderSurfaceHostId] - ) + const wslMovedSurfacesKey = useAppStore(selectWslMovedSurfacesKey) + const workspaceSurfaces = useMemo(() => { + const surfaces = projectWorkspaceSurfaces({ + worktreesById, + folderWorkspaces, + activeWorkspaceId: activeFolderSurfaceId, + activeWorkspaceResolvedHostId: activeFolderSurfaceHostId + }) + // A WSL-moved project's chats mount beside the catalog; it lists none of its workspaces. + const moved = parseWslMovedSurfacesKey(wslMovedSurfacesKey) + return moved.length > 0 ? [...surfaces, ...moved] : surfaces + }, [ + worktreesById, + folderWorkspaces, + activeFolderSurfaceId, + activeFolderSurfaceHostId, + wslMovedSurfacesKey + ]) // Reuse equal ID arrays across worktree row replacements to avoid restarting mount/park effects and rebuilding their Sets. const workspaceSurfaceIds = useReusedArrayIdentity( useMemo(() => workspaceSurfaces.map((workspace) => workspace.id), [workspaceSurfaces]) diff --git a/src/renderer/src/components/wsl-move/WslMoveBlockerList.tsx b/src/renderer/src/components/wsl-move/WslMoveBlockerList.tsx new file mode 100644 index 00000000000..50e51ad160e --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveBlockerList.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import { CircleAlert } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import type { + WslMoveBlocker, + WslMoveRowRef +} from '../../../../shared/wsl-move/wsl-move-driver-types' +import { wslMoveBlockerHint, wslMoveBlockerTitle } from './wsl-move-copy' + +/** One blocker kind, with the projects it names that the user can leave out of the move. */ +export type WslMoveBlockerView = { + blocker: WslMoveBlocker + /** The projects holding it, when known; "Skip" is offered only where main allows it. */ + projects: (WslMoveRowRef & { name: string })[] +} + +export function WslMoveBlockerList({ + blockers, + onSkip +}: { + blockers: WslMoveBlockerView[] + onSkip?: (refs: WslMoveRowRef[]) => void +}): React.JSX.Element { + return ( +
+
+ {translate('wslMove.blockers.title', 'Before these projects can move')} +
+
    + {blockers.map(({ blocker, projects }) => { + const canSkip = onSkip && blocker.actions.includes('skip-project') && projects.length > 0 + return ( +
  • + +
    +
    + {wslMoveBlockerTitle(blocker.kind, blocker.ids.length)} +
    + {projects.length > 0 ? ( +
    + {translate('wslMove.blockers.inProjects', 'In {{names}}', { + names: projects.map((project) => project.name).join(', ') + })} +
    + ) : null} +
    + {wslMoveBlockerHint(blocker.kind)} +
    + {canSkip ? ( + + ) : null} +
    +
  • + ) + })} +
+
+ ) +} diff --git a/src/renderer/src/components/wsl-move/WslMoveDialog.test.tsx b/src/renderer/src/components/wsl-move/WslMoveDialog.test.tsx new file mode 100644 index 00000000000..efb34293650 --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveDialog.test.tsx @@ -0,0 +1,275 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { + WslMoveRefusalCode, + WslMoveResult +} from '../../../../shared/wsl-move/wsl-move-driver-types' +import { wslMoveRefusalMessage } from './wsl-move-copy' +import { WslMovePreviewBody, type WslMovePreviewBodyProps } from './WslMovePreviewBody' +import { WslMoveProgressBody, wslMoveCancellable } from './WslMoveProgressBody' +import { WslMoveResultBody, wslMoveResultTitle } from './WslMoveResultBody' + +const noop = (): void => undefined +const READY = { + outcome: 'ready' as const, + distro: 'Ubuntu', + repoIds: ['api'], + folderWorkspaceIds: ['notes'], + changed: [], + heldByHost: [], + refused: [], + blockers: [] +} + +function preview(overrides: Partial = {}): string { + return renderToStaticMarkup( + + ) +} + +function result(value: WslMoveResult, reload: 'idle' | 'checkpoint-refused' = 'idle'): string { + return renderToStaticMarkup( + + ) +} + +describe('preview', () => { + it('says verbatim that chats stay, lists every project with a checkbox, and counts the selection', () => { + const markup = preview() + expect(markup).toContain( + 'Existing chats stay on this computer. New chats in this project start on the WSL host.' + ) + expect(markup.match(/role="checkbox"/g)).toHaveLength(2) + expect(markup).toContain('Folder') + expect(markup).toContain('Projects: 1 · Workspaces: 2 · Terminals: 3 · Chats: 4') + expect(markup).toContain('>Move 1 project') + expect(markup).not.toMatch(/disabled=""[^>]*>Move 1 project/) + }) + + it('cannot start while checking, with nothing selected, or with blockers', () => { + expect(preview({ preview: null })).toContain('Checking the selected projects') + expect(preview({ preview: null })).toMatch(/disabled=""[^>]*>Move 1 project/) + expect(preview({ selected: [] })).toMatch(/disabled=""[^>]*>Move 0 projects/) + const blocked = preview({ + blockers: [ + { + blocker: { + kind: 'agent-working', + ids: ['t:1'], + actions: ['wait', 'end', 'skip-project'] + }, + projects: [{ kind: 'repo', id: 'api', name: 'api' }] + } + ] + }) + expect(blocked).toMatch(/disabled=""[^>]*>Move 1 project/) + expect(blocked).toContain('An agent is working or waiting for an answer') + expect(blocked).toContain('Leave api out of this move') + expect(blocked).toContain('Check again') + }) + + it('offers no skip where main does not allow it', () => { + const markup = preview({ + blockers: [ + { + blocker: { + kind: 'scrollback-too-large', + ids: ['t:1', 't:2'], + actions: ['clear-scrollback'] + }, + projects: [{ kind: 'repo', id: 'api', name: 'api' }] + } + ] + }) + expect(markup).toContain('2 terminals have too much output history to move') + expect(markup).not.toContain('out of this move') + }) + + it('lets a start settle an earlier unfinished move', () => { + const markup = preview({ preview: { outcome: 'refused', code: 'wsl_move_already_in_flight' } }) + expect(markup).toContain('A move of Ubuntu is still finishing.') + expect(markup).not.toMatch(/disabled=""[^>]*>Move 1 project/) + }) + + it('explains what is not moving and shows a refusal with its reasons', () => { + const markup = preview({ + notMoving: [{ name: 'old', why: 'Already on the Ubuntu host.' }], + preview: { + outcome: 'refused', + code: 'wsl_move_export_incomplete', + reasons: ['1 draft can’t move with this project yet.'] + } + }) + expect(markup).toContain('Already on the Ubuntu host.') + expect(markup).toContain('can't move yet') + expect(markup).toContain('1 draft can’t move with this project yet.') + }) +}) + +describe('progress', () => { + it('shows bytes while copying and allows cancel until the switch', () => { + const markup = renderToStaticMarkup( + + ) + expect(markup).toContain('5.0 MB of 20 MB') + expect(markup).toContain('data-state="active"') + expect(markup).not.toMatch(/disabled=""[^>]*>Cancel move/) + }) + + it('disables cancel from the switch on, and says so', () => { + expect(wslMoveCancellable(null)).toBe(true) + expect(wslMoveCancellable({ distro: 'Ubuntu', step: 'copying' })).toBe(true) + expect(wslMoveCancellable({ distro: 'Ubuntu', step: 'switching' })).toBe(false) + expect(wslMoveCancellable({ distro: 'Ubuntu', step: 'finishing' })).toBe(false) + const markup = renderToStaticMarkup( + + ) + expect(markup).toMatch(/disabled=""[^>]*>Cancel move/) + expect(markup).toContain('Finishing') + expect(markup).toContain('cannot be cancelled') + }) +}) + +describe('result', () => { + it('asks for the reload, names automations still off, and points at the way back', () => { + const moved: WslMoveResult = { + outcome: 'moved', + migrationId: 'm1', + phase: 'retained', + reloadRequired: true, + automationsPending: ['a1', 'a2'], + browserLoginsNotCopiedReason: 'no-stable-partition' + } + const markup = result(moved) + expect(wslMoveResultTitle(moved, 'Ubuntu')).toBe('Moved to the Ubuntu host') + expect(markup).toContain('Reload now') + expect(markup).toContain('2 automations are off on the host for now') + expect(markup).toContain('could not identify the host’s browser profile') + expect(markup).not.toContain('no-stable-partition') + expect(markup).toContain('Use on this computer again') + expect(markup).not.toContain('Close') + }) + + it('shows a refused reload', () => { + const markup = result( + { outcome: 'moved', migrationId: 'm1', phase: 'retained', reloadRequired: true }, + 'checkpoint-refused' + ) + expect(markup).toContain('could not back up unsaved files') + }) + + it('says nothing was moved after a reset, and waits for the host when it may have committed', () => { + const reset = result({ outcome: 'reset', migrationId: 'm1', detail: 'stage failed' }) + expect(wslMoveResultTitle({ outcome: 'reset', migrationId: 'm', detail: '' }, 'U')).toBe( + 'Nothing was moved' + ) + expect(reset).toContain('everything was put back') + expect(reset).toContain('Back to projects') + const waiting = result({ outcome: 'waiting-for-host', migrationId: 'm1', detail: 'x' }) + expect(waiting).toContain('stay read-only here until it does') + expect(waiting).not.toContain('Reload now') + }) + + it('has a plain message for every refusal code', () => { + const codes: WslMoveRefusalCode[] = [ + 'wsl_move_unavailable', + 'wsl_move_already_running', + 'wsl_move_already_in_flight', + 'wsl_move_nothing_new', + 'wsl_move_host_unavailable', + 'wsl_move_host_update_required', + 'wsl_move_terminals_blocked', + 'wsl_move_export_incomplete', + 'wsl_move_manifest_too_large', + 'wsl_move_pause_failed', + 'wsl_move_scope_changed', + 'wsl_move_cancelled', + 'wsl_move_finished_earlier_move' + ] + const messages = codes.map((code) => + wslMoveRefusalMessage({ outcome: 'refused', code }, 'Ubuntu') + ) + expect(new Set(messages).size).toBe(codes.length) + for (const message of messages) { + expect(message).not.toMatch(/wsl_move|manifest|orcad|UNC/i) + } + expect( + wslMoveRefusalMessage( + { outcome: 'refused', code: 'wsl_move_manifest_too_large', message: 'Move fewer.' }, + 'Ubuntu' + ) + ).toBe('Move fewer.') + }) + + it('reloads, not closes, after an earlier move finished first', () => { + const markup = result({ outcome: 'refused', code: 'wsl_move_finished_earlier_move' }) + expect(markup).toContain('Reload now') + expect(markup).not.toContain('Back to projects') + }) +}) + +describe('entry points with the gate closed', () => { + it('render nothing', async () => { + vi.resetModules() + vi.doMock('../sidebar/use-wsl-hosts-enabled', () => ({ + useWslHostsEnabled: () => false, + useWslHostServerFilesMissing: () => false + })) + vi.doMock('@/store', () => ({ + useAppStore: (selector: (state: { runtimeEnvironments: unknown[] }) => unknown) => + selector({ + runtimeEnvironments: [ + { + id: 'wsl:ubuntu', + name: 'Ubuntu (WSL)', + orcadDeployment: { kind: 'wsl', distro: 'Ubuntu', localPort: 1, remotePort: 2 } + } + ] + }) + })) + const { WslMoveProjectMenuItem } = await import('./WslMoveProjectMenuItem') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the item reads only path and the host fields. + const repo = { id: 'api', path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\api' } as never + expect(renderToStaticMarkup()).toBe('') + vi.doUnmock('../sidebar/use-wsl-hosts-enabled') + vi.doUnmock('@/store') + }) +}) diff --git a/src/renderer/src/components/wsl-move/WslMoveDialog.tsx b/src/renderer/src/components/wsl-move/WslMoveDialog.tsx new file mode 100644 index 00000000000..89d2522eb6a --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveDialog.tsx @@ -0,0 +1,232 @@ +import React, { useEffect } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import type { + WslMoveBlocker, + WslMoveRowRef +} from '../../../../shared/wsl-move/wsl-move-driver-types' +import { + cancelWslMove, + wslMoveDialogLocked, + recheckWslMove, + reviewWslMoveAgain, + setWslMoveSelection, + skipWslMoveProjects, + startWslMove, + useWslMoveDialogStore +} from './wsl-move-dialog-store' +import { collectWslMoveSleptPaneBindings } from './wsl-move-slept-pane-bindings' +import { findWorktreeById } from '@/store/github/worktree-refresh' +import { getWslDistroFromPath } from '@/lib/local-preflight-context' +import { + wslMoveBlockerProjects, + wslMoveCounts, + wslMoveProjectRows, + type WslMoveCatalog +} from './wsl-move-scope-rows' +import type { WslMoveBlockerView } from './WslMoveBlockerList' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import { adoptWslMoveProgress } from './wsl-move-run-adoption' +import { closeWslMoveDialog, reloadAfterWslMove } from './wsl-move-dialog-close' +import { RecoverableRenderErrorBoundary } from '../error-boundaries/RecoverableRenderErrorBoundary' +import { useWslHostsEnabled } from '../sidebar/use-wsl-hosts-enabled' +import { WslMovePreviewBody, wslMoveNotMovingLines } from './WslMovePreviewBody' +import { WslMoveProgressBody } from './WslMoveProgressBody' +import { WslMoveResultBody, wslMoveResultTitle } from './WslMoveResultBody' + +function useMoveCatalog(): WslMoveCatalog { + return useAppStore( + useShallow((state) => ({ + repos: state.repos, + folders: selectFolderWorkspaces(state), + repoWorktrees: selectWorktreesByRepo(state), + unifiedTabsByWorktree: state.unifiedTabsByWorktree, + terminalLayoutsByTabId: state.terminalLayoutsByTabId + })) + ) +} + +function blockerViews( + catalog: WslMoveCatalog, + blockers: readonly WslMoveBlocker[], + selected: readonly WslMoveRowRef[] +): WslMoveBlockerView[] { + return blockers.map((blocker) => { + const refs = wslMoveBlockerProjects(catalog, blocker, selected) + return { + blocker, + projects: wslMoveProjectRows( + catalog, + refs.filter((ref) => ref.kind === 'repo').map((ref) => ref.id), + refs.filter((ref) => ref.kind === 'folder').map((ref) => ref.id) + ) + } + }) +} + +/** Root-hosted: entry points in Settings, the sidebar and project settings all open it. */ +export function WslMoveDialogHost(): React.JSX.Element | null { + const enabled = useWslHostsEnabled() + const open = useWslMoveDialogStore( + (s) => s.dialog.stage !== 'closed' && !('hidden' in s.dialog && s.dialog.hidden) + ) + // A run this window did not start (it reloaded mid-move) still gets its progress and reload. + useEffect( + () => (enabled ? window.api.wslMove.onProgress(adoptWslMoveProgress) : undefined), + [enabled] + ) + // Opening a bridge project is the trigger bridge users give; main checks it runs. + // Gated so flag-off windows skip the per-update worktree lookup. + const activeWslDistro = useAppStore((s) => { + const worktree = enabled && s.activeWorktreeId ? findWorktreeById(s, s.activeWorktreeId) : null + return worktree ? getWslDistroFromPath(worktree.path) : null + }) + useEffect(() => { + if (enabled && activeWslDistro) { + void window.api.wslMove.orchestratorProjectOpened(activeWslDistro) + } + }, [enabled, activeWslDistro]) + // An automatic move (D3a) needs this window's slept-pane bindings, as the dialog's start does. + useEffect( + () => + enabled + ? window.api.wslMove.onOrchestratorSleptPanesRequest((requestId) => { + void window.api.wslMove.answerOrchestratorSleptPanes( + requestId, + collectWslMoveSleptPaneBindings(useAppStore.getState()) + ) + }) + : undefined, + [enabled] + ) + return open ? ( + + + + ) : null +} + +function WslMoveDialog(): React.JSX.Element | null { + const dialog = useWslMoveDialogStore((s) => s.dialog) + const catalog = useMoveCatalog() + if (dialog.stage === 'closed') { + return null + } + const { distro } = dialog + // Locked until the moved window tries its reload; a running move only hides on close. + const locked = wslMoveDialogLocked(dialog) + const running = dialog.stage === 'running' + let title: string + let body: React.JSX.Element + switch (dialog.stage) { + case 'loading': + title = translate('wslMove.title', 'Move projects to the {{distro}} host', { distro }) + body = ( +
+ + {translate('wslMove.loading', 'Checking the projects in {{distro}}…', { distro })} +
+ ) + break + case 'preview': { + const { candidates, selected, preview } = dialog + const rows = wslMoveProjectRows(catalog, candidates.repoIds, candidates.folderWorkspaceIds) + const nameOf = (ref: WslMoveRowRef): string => + wslMoveProjectRows( + catalog, + ref.kind === 'repo' ? [ref.id] : [], + ref.kind === 'folder' ? [ref.id] : [] + )[0].name + title = translate('wslMove.title', 'Move projects to the {{distro}} host', { distro }) + body = ( + + setWslMoveSelection( + checked + ? [...selected, ref] + : selected.filter((s) => s.kind !== ref.kind || s.id !== ref.id) + ) + } + onSkip={skipWslMoveProjects} + onRecheck={() => void recheckWslMove()} + onStart={() => void startWslMove()} + onClose={closeWslMoveDialog} + /> + ) + break + } + case 'running': + title = translate('wslMove.runningTitle', 'Moving to the {{distro}} host', { distro }) + body = ( + void cancelWslMove()} + /> + ) + break + case 'result': { + const { result } = dialog + title = wslMoveResultTitle(result, distro) + body = ( + void reloadAfterWslMove()} + onReview={() => void reviewWslMoveAgain()} + onClose={closeWslMoveDialog} + /> + ) + break + } + } + return ( + { + if (!open) { + closeWslMoveDialog() + } + }} + > + locked && event.preventDefault()} + onInteractOutside={(event) => (locked || running) && event.preventDefault()} + > + + {title} + {title} + + {body} + + + ) +} diff --git a/src/renderer/src/components/wsl-move/WslMovePreviewBody.tsx b/src/renderer/src/components/wsl-move/WslMovePreviewBody.tsx new file mode 100644 index 00000000000..4bd610a79a9 --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMovePreviewBody.tsx @@ -0,0 +1,193 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { DialogFooter } from '@/components/ui/dialog' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import type { + WslMovePreview, + WslMoveRowRef +} from '../../../../shared/wsl-move/wsl-move-driver-types' +import { + wslMoveChatsStayCopy, + wslMoveRefusalMessage, + wslMoveScopeRefusalReason +} from './wsl-move-copy' +import type { WslMoveCounts, WslMoveProjectRow } from './wsl-move-scope-rows' +import { WslMoveBlockerList, type WslMoveBlockerView } from './WslMoveBlockerList' + +export type WslMovePreviewBodyProps = { + distro: string + rows: WslMoveProjectRow[] + selected: readonly WslMoveRowRef[] + counts: WslMoveCounts + /** The selection's preview; null while it is being checked. */ + preview: WslMovePreview | null + /** Listed by the whole-distro preview but not moved, each with why. */ + notMoving: { name: string; why: string }[] + blockers: WslMoveBlockerView[] + onToggle: (ref: WslMoveRowRef, checked: boolean) => void + onSkip: (refs: WslMoveRowRef[]) => void + onRecheck: () => void + onStart: () => void + onClose: () => void +} + +const isSelected = (selected: readonly WslMoveRowRef[], row: WslMoveRowRef): boolean => + selected.some((ref) => ref.kind === row.kind && ref.id === row.id) + +function countsLine(counts: WslMoveCounts): string { + return translate( + 'wslMove.preview.counts', + 'Projects: {{projects}} · Workspaces: {{workspaces}} · Terminals: {{terminals}} · Chats: {{chats}}', + { ...counts } + ) +} + +export function WslMovePreviewBody(props: WslMovePreviewBodyProps): React.JSX.Element { + const { distro, rows, selected, preview, blockers } = props + const ready = selected.length > 0 && wslMovePreviewStartable(preview, blockers.length) + return ( + <> +
+

+ {translate( + 'wslMove.preview.whatHappens', + 'Your files stay where they are. Terminals restart in the same folder; their output, shell history and agent conversations are kept.' + )} +

+

{wslMoveChatsStayCopy()}

+ {rows.length > 0 ? ( +
+
+ {translate('wslMove.preview.projects', 'Projects in {{distro}}', { distro })} +
+
    + {rows.map((row) => { + const id = `wsl-move-row-${row.kind}-${row.id}` + return ( +
  • + props.onToggle(row, checked === true)} + /> + + {row.kind === 'folder' ? ( + + {translate('wslMove.preview.folderBadge', 'Folder')} + + ) : null} +
  • + ) + })} +
+ {selected.length > 0 ? ( +
+ {countsLine(props.counts)} +
+ ) : null} +
+ ) : null} + {props.notMoving.length > 0 ? ( +
+
+ {translate('wslMove.preview.notMoving', 'Not moving')} +
+
    + {props.notMoving.map((entry) => ( +
  • + {entry.name} + {' — '} + {entry.why} +
  • + ))} +
+
+ ) : null} + {preview === null && selected.length > 0 ? ( +
+ + {translate('wslMove.preview.checking', 'Checking the selected projects…')} +
+ ) : null} + {preview?.outcome === 'refused' ? ( +
+

{wslMoveRefusalMessage(preview, distro, 'preview')}

+ {preview.reasons?.length ? ( +
    + {preview.reasons.map((reason) => ( +
  • {reason}
  • + ))} +
+ ) : null} +
+ ) : null} + {blockers.length > 0 ? ( + + ) : null} +
+ + {blockers.length > 0 || preview?.outcome === 'refused' ? ( + + ) : null} + + + + + ) +} + +/** A start settles an earlier unfinished move first, so that refusal does not block one. */ +export function wslMovePreviewStartable(preview: WslMovePreview | null, blockers: number): boolean { + return preview?.outcome === 'ready' + ? blockers === 0 + : preview?.code === 'wsl_move_already_in_flight' +} + +/** Plain-words lines for what the whole-distro preview lists but will not move. */ +export function wslMoveNotMovingLines( + preview: WslMovePreview, + nameOf: (ref: WslMoveRowRef) => string, + distro: string +): { name: string; why: string }[] { + if (preview.outcome !== 'ready') { + return [] + } + return [ + ...preview.heldByHost.map((ref) => ({ + name: nameOf(ref), + why: translate('wslMove.preview.heldByHost', 'Already on the {{distro}} host.', { distro }) + })), + ...preview.changed.map((ref) => ({ + name: nameOf(ref), + why: translate( + 'wslMove.preview.changed', + 'Moved earlier, then changed here by an older version of Orca. It is not moved again.' + ) + })), + ...preview.refused.map((entry) => ({ + name: nameOf({ kind: entry.kind === 'repo' ? 'repo' : 'folder', id: entry.id }), + why: wslMoveScopeRefusalReason(entry.reason) + })) + ] +} diff --git a/src/renderer/src/components/wsl-move/WslMoveProgressBody.tsx b/src/renderer/src/components/wsl-move/WslMoveProgressBody.tsx new file mode 100644 index 00000000000..daa4a3cf0c1 --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveProgressBody.tsx @@ -0,0 +1,123 @@ +import React from 'react' +import { Check, Circle } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { DialogFooter } from '@/components/ui/dialog' +import { Progress } from '@/components/ui/progress' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import type { + WslMoveProgressEvent, + WslMoveProgressStep +} from '../../../../shared/wsl-move/wsl-move-driver-types' +import { formatWslMoveBytes, wslMoveStepLabel } from './wsl-move-copy' + +// 'finishing' is the tail of the switch on this desktop; the stepper shows it under Switching. +const STEPS: WslMoveProgressStep[] = [ + 'preparing', + 'pausing-terminals', + 'copying', + 'switching', + 'done' +] + +function stepIndex(step: WslMoveProgressStep): number { + return step === 'finishing' ? STEPS.indexOf('switching') : STEPS.indexOf(step) +} + +/** Main honours a cancel only until it asks the host to commit, which follows copying. */ +export function wslMoveCancellable(progress: WslMoveProgressEvent | null): boolean { + return progress === null || stepIndex(progress.step) < STEPS.indexOf('switching') +} + +export function WslMoveProgressBody({ + progress, + cancel, + onCancel +}: { + progress: WslMoveProgressEvent | null + cancel: 'idle' | 'requested' | 'too-late' + onCancel: () => void +}): React.JSX.Element { + const current = progress ? stepIndex(progress.step) : 0 + const cancellable = wslMoveCancellable(progress) && cancel !== 'too-late' + const bytesTotal = progress?.bytesTotal + return ( + <> +
    + {STEPS.map((step, index) => { + const state = index < current ? 'done' : index === current ? 'active' : 'pending' + return ( +
  1. + {state === 'done' ? ( + + ) : state === 'active' && step !== 'done' ? ( + + ) : state === 'active' ? ( + + ) : ( + + )} + + {step === 'switching' && progress?.step === 'finishing' + ? wslMoveStepLabel('finishing') + : wslMoveStepLabel(step)} + +
  2. + ) + })} +
+ {progress?.step === 'copying' && bytesTotal ? ( +
+ +
+ {translate('wslMove.progress.bytes', '{{sent}} of {{total}}', { + sent: formatWslMoveBytes(progress.bytesSent ?? 0), + total: formatWslMoveBytes(bytesTotal) + })} +
+
+ ) : null} +

+ {cancel === 'too-late' + ? translate( + 'wslMove.progress.tooLate', + 'Too late to cancel: the host is already switching over. Keep Orca open until it finishes.' + ) + : !cancellable + ? translate( + 'wslMove.progress.switching', + 'The switch cannot be cancelled. Keep Orca open until it finishes.' + ) + : current > 0 + ? translate( + 'wslMove.progress.readOnly', + 'These projects are read-only while they move. You can cancel until the switch starts.' + ) + : translate( + 'wslMove.progress.cancelUntilSwitch', + 'You can cancel until the switch starts.' + )}{' '} + {translate( + 'wslMove.progress.closeKeepsGoing', + 'Closing this dialog does not stop the move; it opens again when the move ends.' + )} +

+ + + + + ) +} diff --git a/src/renderer/src/components/wsl-move/WslMoveProjectMenuItem.tsx b/src/renderer/src/components/wsl-move/WslMoveProjectMenuItem.tsx new file mode 100644 index 00000000000..f15b7272dfa --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveProjectMenuItem.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { FolderInput } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import type { Repo } from '../../../../shared/repo-types' +import { openWslMoveDialogFor, useWslMoveTargetForRepo } from './wsl-move-entry' + +/** "Move to host…" for a local WSL project; nothing where no move is offered. */ +export function WslMoveProjectMenuItem({ repo }: { repo: Repo }): React.JSX.Element | null { + const distro = useWslMoveTargetForRepo(repo) + if (!distro) { + return null + } + return ( + openWslMoveDialogFor(distro, { kind: 'repo', id: repo.id })}> + + {translate('wslMove.entry.projectMenu', 'Move to {{distro}} host…', { distro })} + + ) +} diff --git a/src/renderer/src/components/wsl-move/WslMoveResultBody.tsx b/src/renderer/src/components/wsl-move/WslMoveResultBody.tsx new file mode 100644 index 00000000000..068b795210e --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveResultBody.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { DialogFooter } from '@/components/ui/dialog' +import type { WslMoveResult } from '../../../../shared/wsl-move/wsl-move-driver-types' +import type { LazyChunkRecoveryReloadOutcome } from '@/lib/lazy-chunk-recovery-reload' +import { wslMoveBrowserLoginsNotCopied, wslMoveRefusalMessage } from './wsl-move-copy' +import { WslMoveBlockerList, type WslMoveBlockerView } from './WslMoveBlockerList' + +export type WslMoveReloadState = 'idle' | 'reloading' | LazyChunkRecoveryReloadOutcome + +export function wslMoveResultTitle(result: WslMoveResult, distro: string): string { + switch (result.outcome) { + case 'moved': + return translate('wslMove.result.movedTitle', 'Moved to the {{distro}} host', { distro }) + case 'reset': + return translate('wslMove.result.resetTitle', 'Nothing was moved') + case 'waiting-for-host': + return translate('wslMove.result.waitingTitle', 'Waiting for the {{distro}} host', { + distro + }) + case 'refused': + return result.code === 'wsl_move_finished_earlier_move' + ? translate('wslMove.result.earlierMoveTitle', 'An earlier move finished first') + : translate('wslMove.result.refusedTitle', 'Projects not moved') + } +} + +function reloadFailure(reload: WslMoveReloadState): string | null { + switch (reload) { + case 'idle': + case 'reloading': + return null + case 'checkpoint-refused': + return translate( + 'wslMove.result.reloadCheckpointRefused', + 'Orca could not back up unsaved files, so the window did not reload. Close this dialog, save or close those files, then use Reload now in the notice that stays open.' + ) + case 'unload-vetoed': + return translate( + 'wslMove.result.reloadVetoed', + 'Something kept the window from reloading. Close this dialog, save your work, then use Reload now in the notice that stays open.' + ) + case 'never-landed': + case 'request-failed': + return translate('wslMove.result.reloadFailed', 'The window did not reload. Try again.') + } +} + +export function WslMoveResultBody({ + distro, + result, + blockers, + reload, + onReload, + onReview, + onClose +}: { + distro: string + result: WslMoveResult + blockers: WslMoveBlockerView[] + reload: WslMoveReloadState + onReload: () => void + onReview: () => void + onClose: () => void +}): React.JSX.Element { + const needsReload = + result.outcome === 'moved' || + (result.outcome === 'refused' && result.code === 'wsl_move_finished_earlier_move') + const failure = reloadFailure(reload) + return ( + <> +
+ {result.outcome === 'moved' ? ( + <> +

+ {translate( + 'wslMove.result.moved', + 'Reload this window to see the projects under the {{distro}} host. Orca backs up unsaved files before it reloads.', + { distro } + )} +

+ {result.automationsPending?.length ? ( +

+ {result.automationsPending.length === 1 + ? translate( + 'wslMove.result.automationsPendingOne', + 'One automation is off on the host for now. Orca keeps asking the host to turn it back on.' + ) + : translate( + 'wslMove.result.automationsPendingMany', + '{{n}} automations are off on the host for now. Orca keeps asking the host to turn them back on.', + { n: result.automationsPending.length } + )} +

+ ) : null} + {result.browserLoginsNotCopiedReason ? ( +

{wslMoveBrowserLoginsNotCopied(result.browserLoginsNotCopiedReason)}

+ ) : null} +

+ {translate( + 'wslMove.result.undoHint', + 'Changed your mind? After the reload, the sidebar lists these projects as “Moved to {{distro}} host”. Choose “Use on this computer again” there to bring them back as they were before the move.', + { distro } + )} +

+ + ) : null} + {result.outcome === 'reset' ? ( + <> +

+ {translate( + 'wslMove.result.reset', + 'The move stopped and everything was put back. The projects work on this computer as before; paused terminals start again when you open them.' + )} +

+ {result.detail ? ( +

+ {translate('wslMove.result.resetReason', 'Why it stopped: {{detail}}', { + detail: result.detail + })} +

+ ) : null} + + ) : null} + {result.outcome === 'waiting-for-host' ? ( +

+ {translate( + 'wslMove.result.waiting', + 'The move may have reached the {{distro}} host, but the host is not answering. These projects stay read-only here until it does. Connect the host in Settings, then open this dialog again and start a move: Orca finishes or undoes this one first.', + { distro } + )} +

+ ) : null} + {result.outcome === 'refused' ? ( +
+

{wslMoveRefusalMessage(result, distro)}

+ {result.reasons?.length ? ( +
    + {result.reasons.map((reason) => ( +
  • {reason}
  • + ))} +
+ ) : null} +
+ ) : null} + {blockers.length > 0 ? : null} + {failure ? ( +

+ {failure} +

+ ) : null} +
+ + {needsReload ? ( + + ) : ( + <> + + {result.outcome === 'refused' || result.outcome === 'reset' ? ( + + ) : null} + + )} + + + ) +} diff --git a/src/renderer/src/components/wsl-move/WslMoveRuntimeRow.tsx b/src/renderer/src/components/wsl-move/WslMoveRuntimeRow.tsx new file mode 100644 index 00000000000..a3d17d1136a --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveRuntimeRow.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { SettingsRow } from '../settings/SettingsFormControls' +import { openWslMoveDialogFor } from './wsl-move-entry' + +/** The project runtime setting of a WSL project whose distro has a host: a move, not a toggle. */ +export function WslMoveRuntimeRow({ + distro, + repoId +}: { + distro: string + repoId: string +}): React.JSX.Element { + return ( + openWslMoveDialogFor(distro, { kind: 'repo', id: repoId })} + > + {translate('wslMove.runtime.move', 'Move project')} + + } + /> + ) +} diff --git a/src/renderer/src/components/wsl-move/WslMoveUnfinishedNotice.tsx b/src/renderer/src/components/wsl-move/WslMoveUnfinishedNotice.tsx new file mode 100644 index 00000000000..fbd87a27eee --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveUnfinishedNotice.tsx @@ -0,0 +1,38 @@ +import React, { useEffect } from 'react' +import { Info } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { refreshWslMoveUnfinished, useWslMoveDialogStore } from './wsl-move-dialog-store' + +/** Committed moves whose automations the host has not turned back on yet; never left unsaid. */ +export function WslMoveUnfinishedNotice(): React.JSX.Element | null { + const unfinished = useWslMoveDialogStore((s) => s.unfinished) + useEffect(() => { + void refreshWslMoveUnfinished() + }, []) + const lines = unfinished.filter((move) => move.automationsPending.length > 0) + if (lines.length === 0) { + return null + } + return ( +
    + {lines.map((move) => ( +
  • + + + {move.automationsPending.length === 1 + ? translate( + 'wslMove.unfinished.one', + 'One automation moved to the {{distro}} host is still off. Orca keeps asking the host to turn it back on.', + { distro: move.distro } + ) + : translate( + 'wslMove.unfinished.many', + '{{n}} automations moved to the {{distro}} host are still off. Orca keeps asking the host to turn them back on.', + { n: move.automationsPending.length, distro: move.distro } + )} + +
  • + ))} +
+ ) +} diff --git a/src/renderer/src/components/wsl-move/wsl-move-copy.ts b/src/renderer/src/components/wsl-move/wsl-move-copy.ts new file mode 100644 index 00000000000..21ff7be97e3 --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-copy.ts @@ -0,0 +1,297 @@ +/** Plain-language copy for the WSL move dialog: no "manifest", "orcad" or "UNC". */ +import { translate } from '@/i18n/i18n' +import type { + WslMoveBlockerKind, + WslMoveProgressStep, + WslMoveRefusal +} from '../../../../shared/wsl-move/wsl-move-driver-types' + +/** Decision C, verbatim from the design. */ +export function wslMoveChatsStayCopy(): string { + return translate( + 'wslMove.preview.chatsStay', + 'Existing chats stay on this computer. New chats in this project start on the WSL host.' + ) +} + +export function wslMoveRefusalMessage( + refusal: WslMoveRefusal, + distro: string, + /** Preview refusals describe what a start would do; results describe what happened. */ + context: 'preview' | 'result' = 'result' +): string { + switch (refusal.code) { + case 'wsl_move_unavailable': + if (refusal.detail === 'preview-failed') { + return translate( + 'wslMove.refusal.previewFailed', + 'Orca could not check these projects. Try again.' + ) + } + if (refusal.detail === 'start-failed') { + return translate( + 'wslMove.refusal.startFailed', + 'Orca lost contact with the move. Open this dialog again to see where it stands.' + ) + } + return translate( + 'wslMove.refusal.unavailable', + 'Moving projects to a WSL host is not available in this window.' + ) + case 'wsl_move_already_running': + return translate( + 'wslMove.refusal.alreadyRunning', + 'A move into {{distro}} is already running. Wait for it to finish, then try again.', + { distro } + ) + case 'wsl_move_already_in_flight': + return context === 'preview' + ? translate( + 'wslMove.refusal.alreadyInFlightPreview', + 'A move of {{distro}} is still finishing. Starting this move first finishes or undoes it where Orca can.', + { distro } + ) + : translate( + 'wslMove.refusal.alreadyInFlight', + 'A move of {{distro}} is still finishing, so this one did not start. Keep the {{distro}} host connected and try again later.', + { distro } + ) + case 'wsl_move_nothing_new': + return translate( + 'wslMove.refusal.nothingNew', + 'These projects are already on the {{distro}} host.', + { distro } + ) + case 'wsl_move_host_unavailable': + return refusal.detail === 'destination_changed' + ? translate( + 'wslMove.refusal.destinationChanged', + 'Earlier projects from {{distro}} moved to a different WSL host. Connect that host to move more.', + { distro } + ) + : translate( + 'wslMove.refusal.hostUnavailable', + 'The {{distro}} host is not connected. Connect it in Settings, then try again.', + { distro } + ) + case 'wsl_move_host_update_required': + return translate( + 'wslMove.refusal.hostUpdateRequired', + 'The {{distro}} host runs an older version of Orca. Update it in Settings, then try again.', + { distro } + ) + case 'wsl_move_terminals_blocked': + return translate( + 'wslMove.refusal.terminalsBlocked', + 'Some terminals, agents or automations in these projects are busy. Nothing was moved.' + ) + case 'wsl_move_export_incomplete': + if (context === 'preview') { + return translate( + 'wslMove.refusal.exportIncompletePreview', + "Some saved state in these projects can't move yet:" + ) + } + return translate( + 'wslMove.refusal.exportIncomplete', + 'Some saved state in these projects cannot move yet, so nothing was moved:' + ) + case 'wsl_move_manifest_too_large': + return ( + refusal.message ?? + translate( + 'wslMove.refusal.tooLarge', + 'These projects have too much saved state to move in one step. Move fewer projects at a time.' + ) + ) + case 'wsl_move_pause_failed': + return translate( + 'wslMove.refusal.pauseFailed', + 'Orca could not pause the terminals in these projects, so nothing was moved. Paused terminals start again when you open them. Try again.' + ) + case 'wsl_move_scope_changed': + return translate( + 'wslMove.refusal.scopeChanged', + 'A project changed while the move was getting ready, so nothing was moved. Check the projects and try again.' + ) + case 'wsl_move_cancelled': + return translate('wslMove.refusal.cancelled', 'Move cancelled. Nothing was moved.') + case 'wsl_move_finished_earlier_move': + return translate( + 'wslMove.refusal.finishedEarlierMove', + 'Orca first finished an earlier move into {{distro}}. Reload the window, then start this move again.', + { distro } + ) + } +} + +export function wslMoveStepLabel(step: WslMoveProgressStep): string { + switch (step) { + case 'preparing': + return translate('wslMove.step.preparing', 'Preparing') + case 'pausing-terminals': + return translate('wslMove.step.pausingTerminals', 'Pausing terminals') + case 'copying': + return translate('wslMove.step.copying', 'Copying') + case 'switching': + return translate('wslMove.step.switching', 'Switching') + case 'finishing': + return translate('wslMove.step.finishing', 'Finishing') + case 'done': + return translate('wslMove.step.done', 'Done') + } +} + +export function wslMoveBlockerTitle(kind: WslMoveBlockerKind, n: number): string { + const one = n === 1 + switch (kind) { + case 'agent-working': + return one + ? translate( + 'wslMove.blocker.agentWorkingOne', + 'An agent is working or waiting for an answer' + ) + : translate( + 'wslMove.blocker.agentWorkingMany', + '{{n}} agents are working or waiting for an answer', + { n } + ) + case 'process-running': + return one + ? translate('wslMove.blocker.processRunningOne', 'A terminal is running a program') + : translate('wslMove.blocker.processRunningMany', '{{n}} terminals are running a program', { + n + }) + case 'agent-not-resumable': + return one + ? translate( + 'wslMove.blocker.agentNotResumableOne', + 'An agent cannot be resumed after the move' + ) + : translate( + 'wslMove.blocker.agentNotResumableMany', + '{{n}} agents cannot be resumed after the move', + { n } + ) + case 'unverifiable': + return one + ? translate('wslMove.blocker.unverifiableOne', 'Orca cannot check a terminal right now') + : translate( + 'wslMove.blocker.unverifiableMany', + 'Orca cannot check {{n}} terminals right now', + { + n + } + ) + case 'automation-running': + return one + ? translate('wslMove.blocker.automationRunningOne', 'An automation run is active') + : translate('wslMove.blocker.automationRunningMany', '{{n}} automation runs are active', { + n + }) + case 'dispatch-running': + return one + ? translate('wslMove.blocker.dispatchRunningOne', 'An agent task is in progress') + : translate('wslMove.blocker.dispatchRunningMany', '{{n}} agent tasks are in progress', { + n + }) + case 'scrollback-too-large': + return one + ? translate( + 'wslMove.blocker.scrollbackTooLargeOne', + 'A terminal has too much output history to move' + ) + : translate( + 'wslMove.blocker.scrollbackTooLargeMany', + '{{n}} terminals have too much output history to move', + { n } + ) + } +} + +/** What the user can do about a blocker kind, given the actions main offers for it. */ +export function wslMoveBlockerHint(kind: WslMoveBlockerKind): string { + switch (kind) { + case 'agent-working': + return translate( + 'wslMove.blockerHint.agentWorking', + 'Let it finish, or stop it in its terminal, then check again.' + ) + case 'process-running': + return translate( + 'wslMove.blockerHint.processRunning', + 'Stop the program (for example a dev server or editor), then check again.' + ) + case 'agent-not-resumable': + return translate( + 'wslMove.blockerHint.agentNotResumable', + 'Exit the agent in its terminal, then check again.' + ) + case 'unverifiable': + return translate( + 'wslMove.blockerHint.unverifiable', + 'Wait a moment, then check again. Nothing is stopped to make the move happen.' + ) + case 'automation-running': + case 'dispatch-running': + return translate('wslMove.blockerHint.wait', 'Wait for it to finish, then check again.') + case 'scrollback-too-large': + return translate( + 'wslMove.blockerHint.scrollbackTooLarge', + 'Clear the terminal output in those terminals, then check again.' + ) + } +} + +export function wslMoveScopeRefusalReason(reason: string): string { + switch (reason) { + case 'windows-drive': + return translate( + 'wslMove.scopeRefused.windowsDrive', + 'Its folder is on a Windows drive, so it stays on this computer.' + ) + case 'mixed-locations': + return translate( + 'wslMove.scopeRefused.mixedLocations', + 'Some of its folders are outside this distro, so it stays on this computer.' + ) + case 'not-local': + return translate('wslMove.scopeRefused.notLocal', 'It already runs on another host.') + default: + return translate('wslMove.scopeRefused.other', 'It cannot move.') + } +} + +/** 7f's reasons the host's browser starts signed out; an unknown one reads generically. */ +export function wslMoveBrowserLoginsNotCopied(reason: string): string { + switch (reason) { + case 'no-stable-partition': + case 'destination-environment-missing': + return translate( + 'wslMove.result.browserLoginsHostUnknown', + 'Browser logins were not copied, because Orca could not identify the host’s browser profile. Sign in again in the host’s browser tabs.' + ) + case 'destination-partition-conflict': + return translate( + 'wslMove.result.browserLoginsConflict', + 'Browser logins were not copied, because the host’s browser profile is already in use. Sign in again in the host’s browser tabs.' + ) + default: + return translate( + 'wslMove.result.browserLoginsNotCopied', + 'Browser logins were not copied to the host. Sign in again in the host’s browser tabs.' + ) + } +} + +export function formatWslMoveBytes(bytes: number): string { + // A small move read "0.0 MB of 0.0 MB". + if (bytes < 1024) { + return `${Math.max(0, Math.round(bytes))} B` + } + if (bytes < 1024 * 1024) { + return `${Math.round(bytes / 1024)} KB` + } + const megabytes = bytes / (1024 * 1024) + return megabytes >= 10 ? `${Math.round(megabytes)} MB` : `${megabytes.toFixed(1)} MB` +} diff --git a/src/renderer/src/components/wsl-move/wsl-move-dialog-close.ts b/src/renderer/src/components/wsl-move/wsl-move-dialog-close.ts new file mode 100644 index 00000000000..c018bfa484f --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-dialog-close.ts @@ -0,0 +1,61 @@ +/** Leaving the dialog: closing, or hiding what must not be lost, and the reload after a move. */ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { requestLazyChunkRecoveryReload } from '@/lib/lazy-chunk-recovery-reload' +import { + getWslMoveDialog, + invalidateWslMovePreviews, + mustReload, + setWslMoveDialog, + wslMoveDialogLocked +} from './wsl-move-dialog-store' + +/** Reload through restart preparation, which backs up unsaved editors first. */ +export async function reloadAfterWslMove(): Promise { + const dialog = getWslMoveDialog() + if (dialog.stage !== 'result' || dialog.reload === 'reloading') { + return + } + setWslMoveDialog({ ...dialog, reload: 'reloading' }) + // Resolves only when the reload was refused; a landed reload replaces this document. + const outcome = await requestLazyChunkRecoveryReload(window) + const now = getWslMoveDialog() + if (now.stage === 'result') { + setWslMoveDialog({ ...now, reload: outcome }) + } +} + +/** + * Close, or hide what must not be lost: a running move shows again with its result, and a + * moved window whose reload was refused keeps a toast that offers the reload. + */ +export function closeWslMoveDialog(): void { + const dialog = getWslMoveDialog() + if (dialog.stage === 'running') { + setWslMoveDialog({ ...dialog, hidden: true }) + return + } + if (wslMoveDialogLocked(dialog)) { + return + } + if (dialog.stage === 'result' && mustReload(dialog.result)) { + setWslMoveDialog({ ...dialog, hidden: true }) + toast(translate('wslMove.reloadToast.title', 'Reload to finish the move'), { + id: 'wsl-move-reload', + duration: Infinity, + action: { + label: translate('wslMove.result.reload', 'Reload now'), + onClick: () => { + const now = getWslMoveDialog() + if (now.stage === 'result') { + setWslMoveDialog({ ...now, hidden: false, reload: 'idle' }) + void reloadAfterWslMove() + } + } + } + }) + return + } + invalidateWslMovePreviews() + setWslMoveDialog({ stage: 'closed' }) +} diff --git a/src/renderer/src/components/wsl-move/wsl-move-dialog-store.test.ts b/src/renderer/src/components/wsl-move/wsl-move-dialog-store.test.ts new file mode 100644 index 00000000000..d3981753aee --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-dialog-store.test.ts @@ -0,0 +1,336 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WslMovePreview, + WslMoveProgressEvent, + WslMoveResult +} from '../../../../shared/wsl-move/wsl-move-driver-types' + +const reload = vi.fn() +vi.mock('@/lib/lazy-chunk-recovery-reload', () => ({ + requestLazyChunkRecoveryReload: (...args: unknown[]) => reload(...args) +})) + +const toastMock = vi.fn() +vi.mock('sonner', () => ({ toast: (...args: unknown[]) => toastMock(...args) })) + +const store = await import('./wsl-move-dialog-store') +const { adoptWslMoveProgress } = await import('./wsl-move-run-adoption') +const { closeWslMoveDialog, reloadAfterWslMove } = await import('./wsl-move-dialog-close') +const { + cancelWslMove, + openWslMoveDialog, + reviewWslMoveAgain, + setWslMoveSelection, + skipWslMoveProjects, + startWslMove, + useWslMoveDialogStore +} = store + +const READY: WslMovePreview = { + outcome: 'ready', + distro: 'Ubuntu', + repoIds: ['api', 'web'], + folderWorkspaceIds: ['notes'], + changed: [], + heldByHost: [], + refused: [], + blockers: [] +} + +type Api = { + preview: ReturnType + start: ReturnType + cancel: ReturnType + listUnfinished: ReturnType + onProgress: ReturnType +} + +let api: Api +let emitProgress: (event: WslMoveProgressEvent) => void +const unsubscribe = vi.fn() + +const dialog = () => useWslMoveDialogStore.getState().dialog + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +beforeEach(() => { + useWslMoveDialogStore.setState({ dialog: { stage: 'closed' }, unfinished: [] }) + emitProgress = () => undefined + api = { + preview: vi.fn(async () => READY), + start: vi.fn(), + cancel: vi.fn(async () => true), + listUnfinished: vi.fn(async () => []), + onProgress: vi.fn((listener: (event: WslMoveProgressEvent) => void) => { + emitProgress = listener + return unsubscribe + }) + } + vi.stubGlobal('window', { api: { wslMove: api } }) + reload.mockReset() + unsubscribe.mockReset() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('preview', () => { + it('selects the whole distro by default and reuses its preview', async () => { + await openWslMoveDialog({ distro: 'Ubuntu' }) + const state = dialog() + expect(state.stage).toBe('preview') + expect(state.stage === 'preview' && state.selected).toHaveLength(3) + expect(state.stage === 'preview' && state.preview).toBe(READY) + expect(api.preview).toHaveBeenCalledTimes(1) + expect(api.preview).toHaveBeenCalledWith({ distro: 'Ubuntu', sleptPanes: [] }) + }) + + it('opened from one project, checks only it and previews that selection', async () => { + await openWslMoveDialog({ distro: 'Ubuntu', only: { kind: 'repo', id: 'web' } }) + expect(api.preview).toHaveBeenLastCalledWith({ + distro: 'Ubuntu', + repoIds: ['web'], + folderWorkspaceIds: [], + sleptPanes: [] + }) + const state = dialog() + expect(state.stage === 'preview' && state.selected).toEqual([{ kind: 'repo', id: 'web' }]) + }) + + it('drops a stale preview reply when the selection changed meanwhile', async () => { + await openWslMoveDialog({ distro: 'Ubuntu' }) + const slow = deferred() + api.preview.mockReturnValueOnce(slow.promise) + setWslMoveSelection([{ kind: 'repo', id: 'api' }]) + const fresh: WslMovePreview = { ...READY, repoIds: ['web'] } + api.preview.mockResolvedValueOnce(fresh) + setWslMoveSelection([{ kind: 'repo', id: 'web' }]) + await vi.waitFor(() => { + const state = dialog() + expect(state.stage === 'preview' && state.preview).toBe(fresh) + }) + slow.resolve(READY) + await Promise.resolve() + const state = dialog() + expect(state.stage === 'preview' && state.preview).toBe(fresh) + }) + + it('skips the projects a blocker names', async () => { + await openWslMoveDialog({ distro: 'Ubuntu' }) + skipWslMoveProjects([{ kind: 'repo', id: 'api' }]) + const state = dialog() + expect(state.stage === 'preview' && state.selected).toEqual([ + { kind: 'repo', id: 'web' }, + { kind: 'folder', id: 'notes' } + ]) + }) + + it('lists catalog projects when a smaller selection may avoid the refusal', async () => { + api.preview.mockResolvedValueOnce({ + outcome: 'refused', + code: 'wsl_move_export_incomplete', + reasons: ['1 draft can’t move with this project yet.'] + }) + await openWslMoveDialog({ + distro: 'Ubuntu', + catalogCandidates: () => ({ repoIds: ['api'], folderWorkspaceIds: [] }) + }) + const state = dialog() + expect(state.stage === 'preview' && state.candidates.repoIds).toEqual(['api']) + }) + + it('keeps no checkboxes for a refusal no selection can fix', async () => { + api.preview.mockResolvedValueOnce({ outcome: 'refused', code: 'wsl_move_host_unavailable' }) + await openWslMoveDialog({ + distro: 'Ubuntu', + catalogCandidates: () => ({ repoIds: ['api'], folderWorkspaceIds: [] }) + }) + const state = dialog() + expect(state.stage === 'preview' && state.selected).toEqual([]) + expect(state.stage === 'preview' && state.preview?.outcome).toBe('refused') + }) + + it('checks nothing when the project it was opened from cannot move', async () => { + await openWslMoveDialog({ distro: 'Ubuntu', only: { kind: 'repo', id: 'held' } }) + const state = dialog() + expect(state.stage === 'preview' && state.selected).toEqual([]) + }) + + it('checks the whole distro again when nothing was listed', async () => { + api.preview.mockResolvedValueOnce({ outcome: 'refused', code: 'wsl_move_host_unavailable' }) + await openWslMoveDialog({ distro: 'Ubuntu' }) + await store.recheckWslMove() + const state = dialog() + expect(state.stage === 'preview' && state.preview).toBe(READY) + expect(api.preview).toHaveBeenCalledTimes(2) + }) + + it('turns a failed preview call into a refusal, never a stuck spinner', async () => { + api.preview.mockRejectedValueOnce(new Error('ipc')) + await openWslMoveDialog({ distro: 'Ubuntu' }) + const state = dialog() + expect(state.stage === 'preview' && state.preview?.outcome).toBe('refused') + }) +}) + +describe('run', () => { + it('follows progress for its distro, then shows the result and refreshes unfinished moves', async () => { + await openWslMoveDialog({ distro: 'Ubuntu' }) + const run = deferred() + api.start.mockReturnValueOnce(run.promise) + const started = startWslMove() + expect(dialog().stage).toBe('running') + emitProgress({ distro: 'Debian', step: 'switching' }) + emitProgress({ distro: 'Ubuntu', step: 'copying', bytesSent: 5, bytesTotal: 10 }) + const running = dialog() + expect(running.stage === 'running' && running.progress?.step).toBe('copying') + run.resolve({ + outcome: 'moved', + migrationId: 'm1', + phase: 'retained', + reloadRequired: true, + automationsPending: ['a1'] + }) + await started + expect(unsubscribe).toHaveBeenCalled() + const result = dialog() + expect(result.stage === 'result' && result.result.outcome).toBe('moved') + expect(api.listUnfinished).toHaveBeenCalled() + }) + + it('only hides while running, shows the result again, and stays until the reload is tried', async () => { + await openWslMoveDialog({ distro: 'Ubuntu' }) + const run = deferred() + api.start.mockReturnValueOnce(run.promise) + const started = startWslMove() + closeWslMoveDialog() + const hidden = dialog() + expect(hidden.stage === 'running' && hidden.hidden).toBe(true) + await openWslMoveDialog({ distro: 'Debian' }) + const shown = dialog() + expect(shown.stage === 'running' && shown.hidden).toBe(false) + closeWslMoveDialog() + run.resolve({ outcome: 'moved', migrationId: 'm1', phase: 'retained', reloadRequired: true }) + await started + const result = dialog() + expect(result.stage === 'result' && !result.hidden).toBe(true) + closeWslMoveDialog() + expect(dialog().stage).toBe('result') + expect(toastMock).not.toHaveBeenCalled() + // After a refused reload the dialog may go, leaving a toast that offers the reload. + reload.mockResolvedValueOnce('checkpoint-refused') + await reloadAfterWslMove() + closeWslMoveDialog() + const afterRefusal = dialog() + expect(afterRefusal.stage === 'result' && afterRefusal.hidden).toBe(true) + expect(toastMock).toHaveBeenCalledTimes(1) + }) + + it('reports a cancel that came too late', async () => { + await openWslMoveDialog({ distro: 'Ubuntu' }) + const run = deferred() + api.start.mockReturnValueOnce(run.promise) + void startWslMove() + // Refused before the switch: the run was gone or the call failed, not "too late". + api.cancel.mockResolvedValueOnce(false) + await cancelWslMove() + const before = dialog() + expect(before.stage === 'running' && before.cancel).toBe('idle') + emitProgress({ distro: 'Ubuntu', step: 'switching' }) + api.cancel.mockResolvedValueOnce(false) + await cancelWslMove() + const state = dialog() + expect(state.stage === 'running' && state.cancel).toBe('too-late') + expect(api.cancel).toHaveBeenCalledWith('Ubuntu') + }) + + it('turns a failed start call into a refusal', async () => { + await openWslMoveDialog({ distro: 'Ubuntu' }) + api.start.mockRejectedValueOnce(new Error('ipc')) + await startWslMove() + const state = dialog() + expect(state.stage === 'result' && state.result.outcome).toBe('refused') + closeWslMoveDialog() + expect(dialog().stage).toBe('closed') + }) + + it('reloads through restart preparation and shows a refused reload', async () => { + await openWslMoveDialog({ distro: 'Ubuntu' }) + api.start.mockResolvedValueOnce({ + outcome: 'refused', + code: 'wsl_move_finished_earlier_move' + }) + await startWslMove() + reload.mockResolvedValueOnce('checkpoint-refused') + await reloadAfterWslMove() + expect(reload).toHaveBeenCalledTimes(1) + const state = dialog() + expect(state.stage === 'result' && state.reload).toBe('checkpoint-refused') + }) + + it('follows a run this window did not start, and asks for the reload when it is done', async () => { + adoptWslMoveProgress({ distro: 'Ubuntu', step: 'copying', migrationId: 'm9' }) + const running = dialog() + expect(running.stage === 'running' && running.adopted).toBe(true) + adoptWslMoveProgress({ distro: 'Ubuntu', step: 'done', migrationId: 'm9', phase: 'retained' }) + const state = dialog() + expect(state.stage === 'result' && state.result.outcome).toBe('moved') + expect(store.wslMoveDialogLocked(state)).toBe(true) + }) + + it('shows how a followed run ended when it did not commit, instead of its last step', () => { + // A refused start left the dialog on "Moving… Preparing" until a reload. + adoptWslMoveProgress({ distro: 'Ubuntu', step: 'preparing' }) + adoptWslMoveProgress({ + distro: 'Ubuntu', + step: 'preparing', + result: { outcome: 'refused', code: 'wsl_move_host_unavailable' } + }) + const state = dialog() + expect(state.stage === 'result' && state.result).toEqual({ + outcome: 'refused', + code: 'wsl_move_host_unavailable' + }) + }) + + it('opens nothing for the end of a run it never followed', () => { + adoptWslMoveProgress({ + distro: 'Ubuntu', + step: 'preparing', + result: { outcome: 'refused', code: 'wsl_move_cancelled' } + }) + expect(dialog().stage).toBe('closed') + }) + + it('leaves its own run to its own listener', async () => { + await openWslMoveDialog({ distro: 'Ubuntu' }) + api.start.mockReturnValueOnce(deferred().promise) + void startWslMove() + adoptWslMoveProgress({ distro: 'Ubuntu', step: 'done', migrationId: 'm1' }) + expect(dialog().stage).toBe('running') + }) + + it('goes back to the same projects after a refusal', async () => { + await openWslMoveDialog({ distro: 'Ubuntu' }) + setWslMoveSelection([ + { kind: 'repo', id: 'api' }, + { kind: 'folder', id: 'notes' } + ]) + await vi.waitFor(() => expect(dialog().stage === 'preview').toBe(true)) + api.start.mockResolvedValueOnce({ outcome: 'refused', code: 'wsl_move_scope_changed' }) + await startWslMove() + await reviewWslMoveAgain() + const state = dialog() + expect(state.stage === 'preview' && state.selected).toEqual([ + { kind: 'repo', id: 'api' }, + { kind: 'folder', id: 'notes' } + ]) + }) +}) diff --git a/src/renderer/src/components/wsl-move/wsl-move-dialog-store.ts b/src/renderer/src/components/wsl-move/wsl-move-dialog-store.ts new file mode 100644 index 00000000000..9776875959d --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-dialog-store.ts @@ -0,0 +1,333 @@ +/** + * The WSL move dialog's state: preview → running → result. Main runs the move and owns every + * decision; this only follows it. + * + * Why a standalone store, not `activeModal`: any `openModal` would evict a running move's dialog, + * and with it the reload prompt a committed move needs. + */ +import { create } from 'zustand' +import type { + WslMovePreview, + WslMoveProgressEvent, + WslMoveRequest, + WslMoveResult, + WslMoveRowRef, + WslMoveUnfinished +} from '../../../../shared/wsl-move/wsl-move-driver-types' +import type { LazyChunkRecoveryReloadOutcome } from '@/lib/lazy-chunk-recovery-reload' +import { useAppStore } from '@/store' +import { collectWslMoveSleptPaneBindings } from './wsl-move-slept-pane-bindings' + +/** Projects listed with a checkbox; from main's whole-distro preview, or the catalog fallback. */ +export type WslMoveCandidates = { repoIds: string[]; folderWorkspaceIds: string[] } + +export type WslMoveDialogState = + | { stage: 'closed' } + | { stage: 'loading'; distro: string } + | { + stage: 'preview' + distro: string + candidates: WslMoveCandidates + /** The whole-distro preview, for what it lists beside the candidates. */ + distroPreview: WslMovePreview + selected: WslMoveRowRef[] + /** The preview of `selected`; null while it is being checked. */ + preview: WslMovePreview | null + } + | { + stage: 'running' + distro: string + selected: WslMoveRowRef[] + progress: WslMoveProgressEvent | null + cancel: 'idle' | 'requested' | 'too-late' + /** Closed by the user; the move goes on and its result opens the dialog again. */ + hidden?: boolean + /** A run this window did not start (e.g. it reloaded mid-move): known only by its progress. */ + adopted?: boolean + } + | { + stage: 'result' + distro: string + selected: WslMoveRowRef[] + result: WslMoveResult + reload: 'idle' | 'reloading' | LazyChunkRecoveryReloadOutcome + hidden?: boolean + } + +export type WslMoveOpenRequest = { + distro: string + /** Opened from one project: only it starts checked. Otherwise every project does. */ + only?: WslMoveRowRef + /** Fallback list when main refuses the whole-distro preview. */ + catalogCandidates?: () => WslMoveCandidates +} + +type Store = { + dialog: WslMoveDialogState + unfinished: WslMoveUnfinished[] +} + +export type WslMoveReloadState = Extract['reload'] + +export const useWslMoveDialogStore = create()(() => ({ + dialog: { stage: 'closed' }, + unfinished: [] +})) + +export const setWslMoveDialog = (dialog: WslMoveDialogState): void => { + useWslMoveDialogStore.setState({ dialog }) +} +const setDialog = setWslMoveDialog +export const getWslMoveDialog = (): WslMoveDialogState => useWslMoveDialogStore.getState().dialog +const getDialog = getWslMoveDialog + +// Each preview request bumps this; a reply to an older one is dropped. +let previewToken = 0 +// Each open bumps this, so a slow "Back to projects" never edits a newer dialog. +let openGeneration = 0 + +// A refusal a smaller selection may avoid; the checkboxes stay so the user can narrow it. +const SCOPE_DEPENDENT_REFUSALS = new Set([ + // Only a start settles an earlier unfinished move, so the projects stay listed for one. + 'wsl_move_already_in_flight', + 'wsl_move_export_incomplete', + 'wsl_move_manifest_too_large', + 'wsl_move_scope_changed' +]) + +function requestOf(distro: string, selected: readonly WslMoveRowRef[]): WslMoveRequest { + return { + distro, + repoIds: selected.filter((ref) => ref.kind === 'repo').map((ref) => ref.id), + folderWorkspaceIds: selected.filter((ref) => ref.kind === 'folder').map((ref) => ref.id) + } +} + +function refsOf(candidates: WslMoveCandidates): WslMoveRowRef[] { + return [ + ...candidates.repoIds.map((id) => ({ kind: 'repo' as const, id })), + ...candidates.folderWorkspaceIds.map((id) => ({ kind: 'folder' as const, id })) + ] +} + +function refusedPreview(detail: string): WslMovePreview { + return { outcome: 'refused', code: 'wsl_move_unavailable', detail } +} + +async function previewOf(request: WslMoveRequest): Promise { + try { + return await window.api.wslMove.preview({ + ...request, + sleptPanes: collectWslMoveSleptPaneBindings(useAppStore.getState()) + }) + } catch (error) { + console.warn('[wsl-move] Preview failed:', error) + return refusedPreview('preview-failed') + } +} + +/** A moved window that has not tried its reload yet: it must, before anything else. */ +export function wslMoveDialogLocked(dialog: WslMoveDialogState): boolean { + return ( + dialog.stage === 'result' && + mustReload(dialog.result) && + (dialog.reload === 'idle' || dialog.reload === 'reloading') + ) +} + +/** A move in flight, or one this window must still reload for: never replaced by another. */ +function isBusy(dialog: WslMoveDialogState): boolean { + return dialog.stage === 'running' || (dialog.stage === 'result' && mustReload(dialog.result)) +} + +/** A result this window must reload after: its stores were rewritten under it. */ +export function mustReload(result: WslMoveResult): boolean { + return ( + result.outcome === 'moved' || + (result.outcome === 'refused' && result.code === 'wsl_move_finished_earlier_move') + ) +} + +export async function openWslMoveDialog(request: WslMoveOpenRequest): Promise { + const current = getDialog() + // A running move, or one waiting on its reload, keeps the dialog: opening shows it again. + if (isBusy(current)) { + if (current.stage !== 'closed' && 'hidden' in current && current.hidden) { + setDialog({ ...current, hidden: false }) + } + return + } + const generation = ++openGeneration + const token = ++previewToken + setDialog({ stage: 'loading', distro: request.distro }) + const distroPreview = await previewOf({ distro: request.distro }) + if (token !== previewToken || generation !== openGeneration || getDialog().stage !== 'loading') { + return + } + let candidates: WslMoveCandidates = { repoIds: [], folderWorkspaceIds: [] } + if (distroPreview.outcome === 'ready') { + candidates = { + repoIds: distroPreview.repoIds, + folderWorkspaceIds: distroPreview.folderWorkspaceIds + } + } else if (SCOPE_DEPENDENT_REFUSALS.has(distroPreview.code) && request.catalogCandidates) { + candidates = request.catalogCandidates() + } + const all = refsOf(candidates) + const only = request.only + // Opened from one project that cannot move: check nothing; "Not moving" says why. + const selected = only ? all.filter((ref) => ref.kind === only.kind && ref.id === only.id) : all + const wholeDistro = selected.length === all.length && distroPreview.outcome === 'ready' + setDialog({ + stage: 'preview', + distro: request.distro, + candidates, + distroPreview, + selected, + preview: wholeDistro || selected.length === 0 ? distroPreview : null + }) + if (!wholeDistro && selected.length > 0) { + await recheckWslMove() + } +} + +/** Previews the current selection again, e.g. after a blocker cleared or the selection changed. */ +export async function recheckWslMove(): Promise { + const dialog = getDialog() + if (dialog.stage !== 'preview') { + return + } + // Nothing listed (the distro preview was refused): check the whole distro again. + if (dialog.candidates.repoIds.length + dialog.candidates.folderWorkspaceIds.length === 0) { + await openWslMoveDialog({ distro: dialog.distro }) + return + } + const token = ++previewToken + setDialog({ ...dialog, preview: null }) + if (dialog.selected.length === 0) { + return + } + const preview = await previewOf(requestOf(dialog.distro, dialog.selected)) + const now = getDialog() + if (token === previewToken && now.stage === 'preview') { + setDialog({ ...now, preview }) + } +} + +export function setWslMoveSelection(selected: WslMoveRowRef[]): void { + const dialog = getDialog() + if (dialog.stage !== 'preview') { + return + } + setDialog({ ...dialog, selected }) + void recheckWslMove() +} + +export function skipWslMoveProjects(skipped: readonly WslMoveRowRef[]): void { + const dialog = getDialog() + if (dialog.stage !== 'preview') { + return + } + setWslMoveSelection( + dialog.selected.filter((ref) => !skipped.some((s) => s.kind === ref.kind && s.id === ref.id)) + ) +} + +export async function startWslMove(): Promise { + const dialog = getDialog() + if (dialog.stage !== 'preview' || dialog.selected.length === 0) { + return + } + const { distro, selected } = dialog + ++previewToken + setDialog({ stage: 'running', distro, selected, progress: null, cancel: 'idle' }) + const unsubscribe = window.api.wslMove.onProgress((progress) => { + const now = getDialog() + if (now.stage === 'running' && progress.distro === distro) { + setDialog({ ...now, progress }) + } + }) + let result: WslMoveResult + try { + result = await window.api.wslMove.start({ + ...requestOf(distro, selected), + sleptPanes: collectWslMoveSleptPaneBindings(useAppStore.getState()) + }) + } catch (error) { + // The IPC itself failed; main's own failures arrive as results. + console.warn('[wsl-move] Start failed:', error) + result = { outcome: 'refused', code: 'wsl_move_unavailable', detail: 'start-failed' } + } finally { + unsubscribe() + } + if (result.outcome === 'reset' || result.outcome === 'waiting-for-host') { + console.warn(`[wsl-move] ${result.outcome}: ${result.detail}`) + } + setDialog({ stage: 'result', distro, selected, result, reload: 'idle' }) + void refreshWslMoveUnfinished() +} + +/** Whether the switch has begun, after which main no longer honours a cancel. */ +export function wslMoveSwitchStarted(progress: WslMoveProgressEvent | null): boolean { + return ( + progress !== null && + (progress.step === 'switching' || progress.step === 'finishing' || progress.step === 'done') + ) +} + +/** Main honours a cancel only until it asks the host to commit. */ +export async function cancelWslMove(): Promise { + const dialog = getDialog() + if (dialog.stage !== 'running' || dialog.cancel !== 'idle') { + return + } + setDialog({ ...dialog, cancel: 'requested' }) + const stopped = await window.api.wslMove.cancel(dialog.distro).catch(() => false) + const now = getDialog() + if (!stopped && now.stage === 'running') { + // Before the switch a refused cancel means the run was gone or the call failed. + setDialog({ ...now, cancel: wslMoveSwitchStarted(now.progress) ? 'too-late' : 'idle' }) + } +} + +/** Back to the projects list with the same selection, e.g. after a refusal. */ +export async function reviewWslMoveAgain(): Promise { + const dialog = getDialog() + if (dialog.stage !== 'result' || mustReload(dialog.result)) { + return + } + const only = dialog.selected.length === 1 ? dialog.selected[0] : undefined + const ownGeneration = openGeneration + 1 + await openWslMoveDialog({ distro: dialog.distro, only }) + const reopened = getDialog() + // Keep a multi-project selection the user made, within what is still listed. + if ( + reopened.stage === 'preview' && + reopened.distro === dialog.distro && + ownGeneration === openGeneration && + !only + ) { + const listed = refsOf(reopened.candidates) + const kept = dialog.selected.filter((ref) => + listed.some((c) => c.kind === ref.kind && c.id === ref.id) + ) + if (kept.length !== reopened.selected.length) { + setWslMoveSelection(kept) + } + } +} + +/** Drops any preview reply still in flight, e.g. when the dialog closes. */ +export function invalidateWslMovePreviews(): void { + ++previewToken +} + +/** Committed moves whose automations the host has not turned back on yet. */ +export async function refreshWslMoveUnfinished(): Promise { + try { + const unfinished = await window.api.wslMove.listUnfinished() + useWslMoveDialogStore.setState({ unfinished }) + } catch (error) { + console.warn('[wsl-move] Could not list unfinished moves:', error) + } +} diff --git a/src/renderer/src/components/wsl-move/wsl-move-entry.ts b/src/renderer/src/components/wsl-move/wsl-move-entry.ts new file mode 100644 index 00000000000..e68b97365fa --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-entry.ts @@ -0,0 +1,36 @@ +/** + * The move dialog's entry points (host row, project menu, project runtime setting). Each is + * offered only where WSL hosts are: the internal gate open (the preload omits the API otherwise) + * and the Experimental switch on. Main refuses every call with the gate closed regardless. + */ +import { useAppStore } from '@/store' +import type { Repo } from '../../../../shared/repo-types' +import type { WslMoveRowRef } from '../../../../shared/wsl-move/wsl-move-driver-types' +import { useWslHostsEnabled, useWslHostServerFilesMissing } from '../sidebar/use-wsl-hosts-enabled' +import { openWslMoveDialog } from './wsl-move-dialog-store' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' +import { wslMoveCatalogCandidates, wslMoveTargetForRepo } from './wsl-move-scope-rows' + +export function openWslMoveDialogFor(distro: string, only?: WslMoveRowRef): void { + void openWslMoveDialog({ + distro, + only, + catalogCandidates: () => { + const state = useAppStore.getState() + return wslMoveCatalogCandidates( + { repos: state.repos, folders: selectFolderWorkspaces(state) }, + distro + ) + } + }) +} + +/** The distro host a local WSL repo can move to, or null where no move is offered. */ +export function useWslMoveTargetForRepo( + repo: Pick | null | undefined +): string | null { + const enabled = useWslHostsEnabled() + const serverFilesMissing = useWslHostServerFilesMissing() + const environments = useAppStore((s) => s.runtimeEnvironments) + return enabled && !serverFilesMissing && repo ? wslMoveTargetForRepo(repo, environments) : null +} diff --git a/src/renderer/src/components/wsl-move/wsl-move-result-reset-reason.test.tsx b/src/renderer/src/components/wsl-move/wsl-move-result-reset-reason.test.tsx new file mode 100644 index 00000000000..6e608bd65b0 --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-result-reset-reason.test.tsx @@ -0,0 +1,27 @@ +// @vitest-environment happy-dom + +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { WslMoveResultBody } from './WslMoveResultBody' + +describe('the reset result', () => { + // "Nothing was moved" with no reason left the user guessing. + it('says why the move stopped', () => { + const markup = renderToStaticMarkup( + {}} + onReview={() => {}} + onClose={() => {}} + /> + ) + expect(markup).toContain('Why it stopped: orcad_migration_stage_failed:') + }) +}) diff --git a/src/renderer/src/components/wsl-move/wsl-move-result-undo-path.test.tsx b/src/renderer/src/components/wsl-move/wsl-move-result-undo-path.test.tsx new file mode 100644 index 00000000000..9b7b372c0e9 --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-result-undo-path.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WslMovedProject } from '../../../../shared/wsl-move/wsl-moved-project' +import { WslMoveResultBody } from './WslMoveResultBody' + +const movedProject: WslMovedProject = { + kind: 'folder', + id: 'notes', + displayName: 'notes', + path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\notes', + distro: 'Ubuntu' +} +// No chats stayed here: the entry must still list, or the result's promise leads nowhere. +const mockState = { + wslMovedProjects: [movedProject], + worktreesByRepo: {}, + folderWorkspaces: [], + unifiedTabsByWorktree: {}, + activeWorktreeId: null, + activeGroupIdByWorktree: {}, + groupsByWorktree: {}, + // No unsaved file, so the release is not refused before it reaches main. + openFiles: [] +} +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof mockState) => unknown) => selector(mockState), + { getState: () => mockState } + ) +})) +vi.mock('@/components/confirmation-dialog-context', () => ({ + useConfirmationDialog: () => async () => true +})) + +const { default: WslMovedProjectsSection } = await import('../sidebar/WslMovedProjectsSection') +const { TooltipProvider } = await import('@/components/ui/tooltip') + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +/** The sidebar labels the moved result's undo hint quotes, in order. */ +function quotedInResult(): string[] { + const markup = renderToStaticMarkup( + {}} + onReview={() => {}} + onClose={() => {}} + /> + ) + return [...markup.matchAll(/“([^”]+)”/g)].map((match) => match[1] ?? '') +} + +describe('the moved result\'s "Use on this computer again"', () => { + it('names a sidebar entry and action that exist, and the action asks main to bring it back', async () => { + const useProjectHereAgain = vi.fn(async () => ({ released: false, reason: 'not-moved' })) + vi.stubGlobal('api', { wslMove: { useProjectHereAgain } }) + const [entryLabel, actionLabel] = quotedInResult() + expect([entryLabel, actionLabel]).toEqual([ + 'Moved to Ubuntu host', + 'Use on this computer again' + ]) + + render( + + + + ) + expect(screen.getByTestId('wsl-moved-project').textContent).toContain(entryLabel) + fireEvent.click(screen.getByRole('button', { name: actionLabel })) + + await waitFor(() => + expect(useProjectHereAgain).toHaveBeenCalledWith({ + kind: 'folder', + id: 'notes', + unsavedWorkspaceIds: [] + }) + ) + }) +}) diff --git a/src/renderer/src/components/wsl-move/wsl-move-run-adoption.ts b/src/renderer/src/components/wsl-move/wsl-move-run-adoption.ts new file mode 100644 index 00000000000..ffd1605468e --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-run-adoption.ts @@ -0,0 +1,68 @@ +/** + * A move this window did not start, e.g. after it reloaded mid-move: main keeps running it and only + * its progress reaches this window. The dialog follows it, and a run that reaches `done` committed, + * so it gets the same reload prompt as one started here. + */ +import type { WslMoveProgressEvent } from '../../../../shared/wsl-move/wsl-move-driver-types' +import { + getWslMoveDialog, + refreshWslMoveUnfinished, + setWslMoveDialog, + wslMoveDialogLocked +} from './wsl-move-dialog-store' + +export function adoptWslMoveProgress(progress: WslMoveProgressEvent): void { + const dialog = getWslMoveDialog() + if (dialog.stage === 'running') { + // A run started here follows its own events; only an adopted one is driven from here. + if (!dialog.adopted || dialog.distro !== progress.distro) { + return + } + if (progress.result) { + // It ended without committing; without this the dialog sat on its last step for good. + setWslMoveDialog({ + stage: 'result', + distro: dialog.distro, + selected: [], + result: progress.result, + reload: 'idle' + }) + void refreshWslMoveUnfinished() + return + } + if (progress.step === 'done' && progress.migrationId) { + setWslMoveDialog({ + stage: 'result', + distro: dialog.distro, + selected: [], + result: { + outcome: 'moved', + migrationId: progress.migrationId, + phase: progress.phase ?? 'destination-committed', + reloadRequired: true + }, + reload: 'idle' + }) + void refreshWslMoveUnfinished() + return + } + setWslMoveDialog({ ...dialog, progress }) + return + } + if ( + wslMoveDialogLocked(dialog) || + dialog.stage === 'result' || + progress.step === 'done' || + progress.result + ) { + return + } + setWslMoveDialog({ + stage: 'running', + distro: progress.distro, + selected: [], + progress, + cancel: 'idle', + adopted: true + }) +} diff --git a/src/renderer/src/components/wsl-move/wsl-move-scope-rows.test.ts b/src/renderer/src/components/wsl-move/wsl-move-scope-rows.test.ts new file mode 100644 index 00000000000..8bcda7dde68 --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-scope-rows.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { Repo } from '../../../../shared/repo-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { Worktree } from '../../../../shared/worktree/types' +import { + findWslHostForDistro, + wslMoveBlockerProjects, + wslMoveCatalogCandidates, + wslMoveCounts, + wslMoveDistroOfPath, + wslMoveProjectRows, + wslMoveTargetForRepo, + type WslMoveCatalog +} from './wsl-move-scope-rows' + +const UNC = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\api' +const LEAF = '11111111-2222-4333-8444-555555555555' + +function repo(id: string, path: string, extra: Partial = {}): Repo { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these tests read only id, path, displayName and the host fields. + return { id, path, displayName: `Repo ${id}`, ...extra } as Repo +} + +function folder( + id: string, + folderPath: string, + extra: Partial = {} +): FolderWorkspace { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these tests read only id, name, folderPath and the host fields. + return { id, name: `Folder ${id}`, folderPath, ...extra } as FolderWorkspace +} + +function worktree(id: string, hostId?: string): Worktree { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: counts read only id and hostId. + return { id, hostId } as Worktree +} + +function wslHost(distro: string): PublicKnownRuntimeEnvironment { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lookup reads only name and orcadDeployment. + return { + id: `wsl:${distro.toLowerCase()}`, + name: `${distro} (WSL)`, + orcadDeployment: { kind: 'wsl', distro, localPort: 1, remotePort: 2 } + } as PublicKnownRuntimeEnvironment +} + +describe('wslMoveDistroOfPath', () => { + it('names the distro of a WSL folder, in either share spelling', () => { + expect(wslMoveDistroOfPath(UNC)).toBe('Ubuntu') + expect(wslMoveDistroOfPath('//wsl$/Debian/home/x')).toBe('Debian') + }) + + it('keeps Windows-drive folders and plain Windows paths on this computer', () => { + expect(wslMoveDistroOfPath('\\\\wsl.localhost\\Ubuntu\\mnt\\c\\src\\api')).toBeNull() + expect(wslMoveDistroOfPath('C:\\src\\api')).toBeNull() + }) +}) + +describe('wslMoveTargetForRepo', () => { + it('offers the host, spelled as the host names its distro', () => { + expect( + wslMoveTargetForRepo(repo('r', UNC.replace('Ubuntu', 'ubuntu')), [wslHost('Ubuntu')]) + ).toBe('Ubuntu') + }) + + it('offers nothing without a host, for a Windows drive, or for a repo on another host', () => { + expect(wslMoveTargetForRepo(repo('r', UNC), [])).toBeNull() + expect(wslMoveTargetForRepo(repo('r', UNC), [wslHost('Debian')])).toBeNull() + expect( + wslMoveTargetForRepo(repo('r', '\\\\wsl.localhost\\Ubuntu\\mnt\\d\\x'), [wslHost('Ubuntu')]) + ).toBeNull() + expect( + wslMoveTargetForRepo(repo('r', UNC, { connectionId: 'ssh-1' }), [wslHost('Ubuntu')]) + ).toBeNull() + }) + + it('ignores non-WSL environments', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a runtime without a deployment link. + const plain = { id: 'r1', name: 'Ubuntu' } as PublicKnownRuntimeEnvironment + expect(findWslHostForDistro([plain], 'Ubuntu')).toBeNull() + }) +}) + +describe('rows, counts and blockers', () => { + const api = `api::${UNC}` + const catalog: WslMoveCatalog = { + repos: [ + repo('api', UNC), + // Same id on another host: never the name or the count of the local project. + repo('api', '/home/dev/api', { + displayName: 'Host copy', + executionHostId: 'runtime:wsl:Ubuntu' + }) + ], + folders: [folder('notes', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\notes')], + repoWorktrees: { + api: [ + worktree(api, 'local'), + worktree(`api::${UNC}\\feature`), + worktree('api::/x', 'runtime:wsl:Ubuntu') + ] + }, + unifiedTabsByWorktree: { + [api]: [ + { id: 't1', entityId: 'term-1', contentType: 'terminal' }, + { id: 'agent-session:s', entityId: 's', contentType: 'agent-session' }, + { id: '/a.ts', entityId: '/a.ts', contentType: 'editor' } + ], + 'folder:notes': [{ id: 't2', entityId: 'term-2', contentType: 'terminal' }] + } + } + + it('names local rows and falls back to the id', () => { + expect(wslMoveProjectRows(catalog, ['api', 'gone'], ['notes'])).toEqual([ + { kind: 'repo', id: 'api', name: 'Repo api' }, + { kind: 'repo', id: 'gone', name: 'gone' }, + { kind: 'folder', id: 'notes', name: 'Folder notes' } + ]) + }) + + it('counts local workspaces, terminals and chats of the selection', () => { + expect( + wslMoveCounts(catalog, [ + { kind: 'repo', id: 'api' }, + { kind: 'folder', id: 'notes' } + ]) + ).toEqual({ projects: 2, workspaces: 3, terminals: 2, chats: 1 }) + }) + + it('counts terminal panes, as the blockers do, not terminal tabs', () => { + // "Terminals: 5" sat next to "7 terminals are running a program". + const split: WslMoveCatalog = { + ...catalog, + terminalLayoutsByTabId: { + 'term-1': { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF }, + second: { type: 'leaf', leafId: 'other-leaf' } + } + } + } + } + expect( + wslMoveCounts(split, [ + { kind: 'repo', id: 'api' }, + { kind: 'folder', id: 'notes' } + ]).terminals + ).toBe(3) + }) + + it('maps a pane blocker to the selected project that holds it, and ignores run ids', () => { + const selected = [ + { kind: 'repo' as const, id: 'api' }, + { kind: 'folder' as const, id: 'notes' } + ] + expect( + wslMoveBlockerProjects( + catalog, + { + kind: 'process-running', + ids: [`term-1:${LEAF}`, `term-2:${LEAF}`, 'run-7'], + actions: ['end', 'skip-project'] + }, + selected + ) + ).toEqual(selected) + expect( + wslMoveBlockerProjects( + catalog, + { kind: 'automation-running', ids: ['run-7'], actions: ['wait', 'skip-project'] }, + selected + ) + ).toEqual([]) + }) + + it('lists the distro catalog as the fallback candidates', () => { + expect( + wslMoveCatalogCandidates( + { + repos: [...catalog.repos, repo('win', 'C:\\src\\win'), repo('deb', '//wsl$/Debian/x')], + folders: [ + ...catalog.folders, + folder('remote', '\\\\wsl.localhost\\Ubuntu\\r', { connectionId: 'ssh-1' }) + ] + }, + 'ubuntu' + ) + ).toEqual({ repoIds: ['api'], folderWorkspaceIds: ['notes'] }) + }) +}) diff --git a/src/renderer/src/components/wsl-move/wsl-move-scope-rows.ts b/src/renderer/src/components/wsl-move/wsl-move-scope-rows.ts new file mode 100644 index 00000000000..a3e383b11c6 --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-scope-rows.ts @@ -0,0 +1,205 @@ +/** + * What the move dialog shows about its projects, read from the renderer catalog. Main decides what + * moves; this only names rows, counts what they hold, and maps blockers back to their project. + */ +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import { + isWslHostRuntimeEnvironment, + type PublicKnownRuntimeEnvironment +} from '../../../../shared/runtime-environments' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { Tab } from '../../../../shared/tab-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { collectLeafIdsInOrder } from '../terminal-pane/terminal-layout-leaf-ids' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import type { Worktree } from '../../../../shared/worktree/types' +import { parseWslUncPath } from '../../../../shared/wsl-paths' +import type { + WslMoveBlocker, + WslMoveRowRef +} from '../../../../shared/wsl-move/wsl-move-driver-types' + +export type WslMoveCatalog = { + repos: readonly Repo[] + folders: readonly FolderWorkspace[] + repoWorktrees: Record + unifiedTabsByWorktree: Record[]> + /** Panes, not tabs, are what the terminal blockers count; absent: one pane per tab. */ + terminalLayoutsByTabId?: Record | undefined> +} + +export type WslMoveProjectRow = WslMoveRowRef & { name: string } + +export type WslMoveCounts = { + projects: number + workspaces: number + terminals: number + chats: number +} + +// Rule D7: a repo on a Windows drive reached through the distro stays a Windows project. +const WINDOWS_DRIVE_MOUNT = /^\/mnt\/[a-z](\/|$)/i + +/** The distro a local folder lives in when it could move to that distro's host; else null. */ +export function wslMoveDistroOfPath(path: string): string | null { + const parsed = parseWslUncPath(path) + return parsed && !WINDOWS_DRIVE_MOUNT.test(parsed.linuxPath) ? parsed.distro : null +} + +/** The added WSL host of a distro, matched as WSL matches names: case-insensitively. */ +export function findWslHostForDistro( + environments: readonly PublicKnownRuntimeEnvironment[], + distro: string +): { distro: string; name: string } | null { + const wanted = distro.toLowerCase() + for (const environment of environments) { + if ( + isWslHostRuntimeEnvironment(environment) && + environment.orcadDeployment.distro.toLowerCase() === wanted + ) { + return { distro: environment.orcadDeployment.distro, name: environment.name } + } + } + return null +} + +/** The host a local WSL repo could move to, spelled as the host names its distro. */ +export function wslMoveTargetForRepo( + repo: Pick, + environments: readonly PublicKnownRuntimeEnvironment[] +): string | null { + if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { + return null + } + const distro = wslMoveDistroOfPath(repo.path) + return distro ? (findWslHostForDistro(environments, distro)?.distro ?? null) : null +} + +function localRepo(catalog: WslMoveCatalog, id: string): Repo | undefined { + return catalog.repos.find( + (repo) => repo.id === id && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID + ) +} + +export function wslMoveProjectRows( + catalog: WslMoveCatalog, + repoIds: readonly string[], + folderWorkspaceIds: readonly string[] +): WslMoveProjectRow[] { + return [ + ...repoIds.map((id) => ({ + kind: 'repo' as const, + id, + name: localRepo(catalog, id)?.displayName ?? id + })), + ...folderWorkspaceIds.map((id) => ({ + kind: 'folder' as const, + id, + name: catalog.folders.find((folder) => folder.id === id)?.name ?? id + })) + ] +} + +function localWorkspaceKeys(catalog: WslMoveCatalog, ref: WslMoveRowRef): string[] { + if (ref.kind === 'folder') { + return [folderWorkspaceKey(ref.id)] + } + return (catalog.repoWorktrees[ref.id] ?? []) + .filter((worktree) => (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID) + .map((worktree) => worktree.id) +} + +/** Counts of what the selected projects hold here, for the preview's summary line. */ +export function wslMoveCounts( + catalog: WslMoveCatalog, + selected: readonly WslMoveRowRef[] +): WslMoveCounts { + const counts: WslMoveCounts = { projects: selected.length, workspaces: 0, terminals: 0, chats: 0 } + for (const ref of selected) { + for (const key of localWorkspaceKeys(catalog, ref)) { + counts.workspaces += 1 + for (const tab of catalog.unifiedTabsByWorktree[key] ?? []) { + if (tab.contentType === 'terminal') { + counts.terminals += terminalPaneCount(catalog, tab) + } else if (tab.contentType === 'agent-session') { + counts.chats += 1 + } + } + } + } + return counts +} + +function terminalPaneCount(catalog: WslMoveCatalog, tab: Pick): number { + const layout = + catalog.terminalLayoutsByTabId?.[tab.entityId] ?? catalog.terminalLayoutsByTabId?.[tab.id] + return Math.max(1, collectLeafIdsInOrder(layout?.root).length) +} + +function projectOfWorkspaceKey(key: string): WslMoveRowRef { + const scope = parseWorkspaceKey(key) + if (scope?.type === 'folder') { + return { kind: 'folder', id: scope.folderWorkspaceId } + } + return { + kind: 'repo', + id: getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : key) + } +} + +/** + * The projects a blocker names, for "Skip project". Pane keys resolve through the tab that holds + * them; automation runs and dispatches name no tab, so they resolve to nothing here. + */ +export function wslMoveBlockerProjects( + catalog: WslMoveCatalog, + blocker: WslMoveBlocker, + candidates: readonly WslMoveRowRef[] +): WslMoveRowRef[] { + const found = new Map() + for (const id of blocker.ids) { + const tabId = parsePaneKey(id)?.tabId + if (!tabId) { + continue + } + for (const [key, tabs] of Object.entries(catalog.unifiedTabsByWorktree)) { + if (tabs.some((tab) => tab.entityId === tabId || tab.id === tabId)) { + const ref = projectOfWorkspaceKey(key) + if (candidates.some((c) => c.kind === ref.kind && c.id === ref.id)) { + found.set(`${ref.kind}:${ref.id}`, ref) + } + } + } + } + return [...found.values()] +} + +/** + * Projects of the distro as this window's catalog lists them. Only the fallback for listing + * checkboxes when main refused the whole-distro preview for a reason a smaller selection may fix. + */ +export function wslMoveCatalogCandidates( + catalog: Pick, + distro: string +): { repoIds: string[]; folderWorkspaceIds: string[] } { + const inDistro = (path: string): boolean => + wslMoveDistroOfPath(path)?.toLowerCase() === distro.toLowerCase() + return { + repoIds: catalog.repos + .filter( + (repo) => getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID && inDistro(repo.path) + ) + .map((repo) => repo.id), + folderWorkspaceIds: catalog.folders + .filter( + (folder) => + (folder.executionHostId ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID && + !folder.connectionId && + inDistro(folder.folderPath) + ) + .map((folder) => folder.id) + } +} diff --git a/src/renderer/src/components/wsl-move/wsl-move-slept-pane-bindings.test.ts b/src/renderer/src/components/wsl-move/wsl-move-slept-pane-bindings.test.ts new file mode 100644 index 00000000000..9ad87742363 --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-slept-pane-bindings.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { collectWslMoveSleptPaneBindings } from './wsl-move-slept-pane-bindings' + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' + +function layout(ptyIdsByLeafId: Record) { + return { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: LEAF_A }, + second: { type: 'leaf' as const, leafId: LEAF_B } + }, + activeLeafId: LEAF_A, + expandedLeafId: null, + ptyIdsByLeafId + } +} + +describe('collectWslMoveSleptPaneBindings', () => { + it('reports bound panes with no live terminal, never a live one', () => { + const tab = { id: 'tab-1', ptyId: 'w@@slept', worktreeId: 'w' } + const state = { + tabsByWorktree: { w: [tab] }, + terminalLayoutsByTabId: { 'tab-1': layout({ [LEAF_A]: 'w@@slept', [LEAF_B]: 'w@@live' }) }, + ptyIdsByTabId: { 'tab-1': ['w@@live'] } + } + const bindings = collectWslMoveSleptPaneBindings(state) + expect(bindings).toEqual([{ paneKey: `tab-1:${LEAF_A}`, sessionId: 'w@@slept' }]) + }) +}) diff --git a/src/renderer/src/components/wsl-move/wsl-move-slept-pane-bindings.ts b/src/renderer/src/components/wsl-move/wsl-move-slept-pane-bindings.ts new file mode 100644 index 00000000000..bf94e6771ec --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-slept-pane-bindings.ts @@ -0,0 +1,27 @@ +import { makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id' + +type SleptPaneBinding = { paneKey: string; sessionId: string } + +/** + * Every pane this window still binds to a session but runs no terminal for: a Sleep keeps the + * binding here for wake while main retires it, so without this a move reads none of their history. + */ +export function collectWslMoveSleptPaneBindings(state: { + tabsByWorktree: Readonly> + terminalLayoutsByTabId: Readonly }>> + ptyIdsByTabId: Readonly> +}): SleptPaneBinding[] { + const bindings: SleptPaneBinding[] = [] + for (const tabs of Object.values(state.tabsByWorktree)) { + for (const tab of tabs) { + const live = new Set(state.ptyIdsByTabId[tab.id] ?? []) + const layout = state.terminalLayoutsByTabId[tab.id] + for (const [leafId, sessionId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) { + if (isTerminalLeafId(leafId) && sessionId && !live.has(sessionId)) { + bindings.push({ paneKey: makePaneKey(tab.id, leafId), sessionId }) + } + } + } + } + return bindings +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ecb65d4f847..d0c238827ce 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -49,6 +49,25 @@ "confirm": "Adjust filters and reveal", "cancel": "Keep filters" }, + "wslMoved": { + "movedToHost": "Moved to {{distro}} host", + "useHere": "Use on this computer again", + "useHereTitle": "Use {{name}} on this computer again?", + "useHereDescription": "Orca will open this project here as it was before the move. Your code is not affected: it never moved. Changes made on the {{distro}} host since the move, such as new tabs and chats, stay on the host and do not come back. Projects moved with it come back too. The project also stays on the {{distro}} host until you remove it there. This window reloads.", + "useHereConfirm": "Use on this computer", + "useHereSuperseded": "A later move to the {{distro}} host includes this project. Bring that one back first.", + "useHereRefusedTitle": "Can't use {{name}} here yet", + "useHereRefusedOk": "OK", + "useHereSupersededBy": "{{names}} moved to the {{distro}} host after this project. Use those on this computer again first, then this project.", + "useHereMoveRunning": "A move to the {{distro}} host is still running. Try again once it ends.", + "useHereHostUnreachable": "The {{distro}} host isn't answering, so Orca can't stop its copy of this project, and its agents may still be running there. Connect the host in Settings, or remove it, then try again.", + "useHereHostStillRunning": "The {{distro}} host's copy of this project is still running terminals, so its agents could run twice. Wait a moment, then try again.", + "useHereFailed": "Could not bring this project back to this computer.", + "useHereHostRemovalPending": "The {{distro}} host is still being removed, so Orca leaves it alone. Finish removing it in Settings, then try again.", + "useHereHostAutomations": "Orca couldn't turn off this project's automations on the {{distro}} host, so they could run twice. Connect the host in Settings, then try again.", + "useHereUnsavedTitle": "Save your files first", + "useHereUnsavedDescription": "Bringing {{name}} back reloads this window. Save or close these files first: {{files}}" + }, "startupWorktreeRefresh": { "loading": "Discovering workspaces…", "description": "More workspaces may appear while local and remote projects load." @@ -6514,7 +6533,7 @@ "running": "Running", "stopped": "Stopped", "title": "Add WSL distro", - "description": "Orca runs its server inside the distro, so git, files and terminals run natively in Linux. Use it for new projects; projects you opened through \\\\wsl.localhost keep working as they are.", + "description": "Orca runs its server inside the distro, so git, files and terminals run natively in Linux. Projects you opened through \\\\wsl.localhost keep working as they are until you move them to this host.", "distro": "Distro", "loading": "Looking for WSL distros…", "placeholder": "Choose a distro", @@ -20142,6 +20161,140 @@ }, "rowActions": "Actions for {{name}}" }, + "wslMove": { + "entry": { + "hostRow": "Move projects from this distro…", + "projectMenu": "Move to {{distro}} host…" + }, + "blockers": { + "title": "Before these projects can move", + "inProjects": "In {{names}}", + "skipOne": "Leave {{name}} out of this move", + "skipMany": "Leave these {{n}} projects out of this move" + }, + "title": "Move projects to the {{distro}} host", + "loading": "Checking the projects in {{distro}}…", + "runningTitle": "Moving to the {{distro}} host", + "preview": { + "counts": "Projects: {{projects}} · Workspaces: {{workspaces}} · Terminals: {{terminals}} · Chats: {{chats}}", + "whatHappens": "Your files stay where they are. Terminals restart in the same folder; their output, shell history and agent conversations are kept.", + "projects": "Projects in {{distro}}", + "folderBadge": "Folder", + "notMoving": "Not moving", + "checking": "Checking the selected projects…", + "checkAgain": "Check again", + "moveOne": "Move 1 project", + "moveMany": "Move {{n}} projects", + "heldByHost": "Already on the {{distro}} host.", + "changed": "Moved earlier, then changed here by an older version of Orca. It is not moved again.", + "chatsStay": "Existing chats stay on this computer. New chats in this project start on the WSL host." + }, + "cancel": "Cancel", + "progress": { + "bytes": "{{sent}} of {{total}}", + "tooLate": "Too late to cancel: the host is already switching over. Keep Orca open until it finishes.", + "switching": "The switch cannot be cancelled. Keep Orca open until it finishes.", + "readOnly": "These projects are read-only while they move. You can cancel until the switch starts.", + "cancelUntilSwitch": "You can cancel until the switch starts.", + "closeKeepsGoing": "Closing this dialog does not stop the move; it opens again when the move ends.", + "cancelling": "Cancelling…", + "cancel": "Cancel move" + }, + "result": { + "movedTitle": "Moved to the {{distro}} host", + "resetTitle": "Nothing was moved", + "waitingTitle": "Waiting for the {{distro}} host", + "earlierMoveTitle": "An earlier move finished first", + "refusedTitle": "Projects not moved", + "reloadCheckpointRefused": "Orca could not back up unsaved files, so the window did not reload. Close this dialog, save or close those files, then use Reload now in the notice that stays open.", + "reloadVetoed": "Something kept the window from reloading. Close this dialog, save your work, then use Reload now in the notice that stays open.", + "reloadFailed": "The window did not reload. Try again.", + "moved": "Reload this window to see the projects under the {{distro}} host. Orca backs up unsaved files before it reloads.", + "automationsPendingOne": "One automation is off on the host for now. Orca keeps asking the host to turn it back on.", + "automationsPendingMany": "{{n}} automations are off on the host for now. Orca keeps asking the host to turn them back on.", + "undoHint": "Changed your mind? After the reload, the sidebar lists these projects as “Moved to {{distro}} host”. Choose “Use on this computer again” there to bring them back as they were before the move.", + "reset": "The move stopped and everything was put back. The projects work on this computer as before; paused terminals start again when you open them.", + "resetReason": "Why it stopped: {{detail}}", + "waiting": "The move may have reached the {{distro}} host, but the host is not answering. These projects stay read-only here until it does. Connect the host in Settings, then open this dialog again and start a move: Orca finishes or undoes this one first.", + "reloading": "Reloading…", + "reload": "Reload now", + "close": "Close", + "review": "Back to projects", + "browserLoginsHostUnknown": "Browser logins were not copied, because Orca could not identify the host’s browser profile. Sign in again in the host’s browser tabs.", + "browserLoginsConflict": "Browser logins were not copied, because the host’s browser profile is already in use. Sign in again in the host’s browser tabs.", + "browserLoginsNotCopied": "Browser logins were not copied to the host. Sign in again in the host’s browser tabs." + }, + "runtime": { + "label": "Project runtime", + "description": "This project runs in {{distro}} through this computer. Move it to the {{distro}} host to run it there.", + "move": "Move project" + }, + "unfinished": { + "one": "One automation moved to the {{distro}} host is still off. Orca keeps asking the host to turn it back on.", + "many": "{{n}} automations moved to the {{distro}} host are still off. Orca keeps asking the host to turn them back on." + }, + "refusal": { + "previewFailed": "Orca could not check these projects. Try again.", + "startFailed": "Orca lost contact with the move. Open this dialog again to see where it stands.", + "unavailable": "Moving projects to a WSL host is not available in this window.", + "alreadyRunning": "A move into {{distro}} is already running. Wait for it to finish, then try again.", + "alreadyInFlightPreview": "A move of {{distro}} is still finishing. Starting this move first finishes or undoes it where Orca can.", + "alreadyInFlight": "A move of {{distro}} is still finishing, so this one did not start. Keep the {{distro}} host connected and try again later.", + "nothingNew": "These projects are already on the {{distro}} host.", + "destinationChanged": "Earlier projects from {{distro}} moved to a different WSL host. Connect that host to move more.", + "hostUnavailable": "The {{distro}} host is not connected. Connect it in Settings, then try again.", + "hostUpdateRequired": "The {{distro}} host runs an older version of Orca. Update it in Settings, then try again.", + "terminalsBlocked": "Some terminals, agents or automations in these projects are busy. Nothing was moved.", + "exportIncomplete": "Some saved state in these projects cannot move yet, so nothing was moved:", + "exportIncompletePreview": "Some saved state in these projects can't move yet:", + "tooLarge": "These projects have too much saved state to move in one step. Move fewer projects at a time.", + "pauseFailed": "Orca could not pause the terminals in these projects, so nothing was moved. Paused terminals start again when you open them. Try again.", + "scopeChanged": "A project changed while the move was getting ready, so nothing was moved. Check the projects and try again.", + "cancelled": "Move cancelled. Nothing was moved.", + "finishedEarlierMove": "Orca first finished an earlier move into {{distro}}. Reload the window, then start this move again." + }, + "step": { + "preparing": "Preparing", + "pausingTerminals": "Pausing terminals", + "copying": "Copying", + "switching": "Switching", + "finishing": "Finishing", + "done": "Done" + }, + "blocker": { + "agentWorkingOne": "An agent is working or waiting for an answer", + "agentWorkingMany": "{{n}} agents are working or waiting for an answer", + "processRunningOne": "A terminal is running a program", + "processRunningMany": "{{n}} terminals are running a program", + "agentNotResumableOne": "An agent cannot be resumed after the move", + "agentNotResumableMany": "{{n}} agents cannot be resumed after the move", + "unverifiableOne": "Orca cannot check a terminal right now", + "unverifiableMany": "Orca cannot check {{n}} terminals right now", + "automationRunningOne": "An automation run is active", + "automationRunningMany": "{{n}} automation runs are active", + "dispatchRunningOne": "An agent task is in progress", + "dispatchRunningMany": "{{n}} agent tasks are in progress", + "scrollbackTooLargeOne": "A terminal has too much output history to move", + "scrollbackTooLargeMany": "{{n}} terminals have too much output history to move" + }, + "blockerHint": { + "agentWorking": "Let it finish, or stop it in its terminal, then check again.", + "processRunning": "Stop the program (for example a dev server or editor), then check again.", + "agentNotResumable": "Exit the agent in its terminal, then check again.", + "unverifiable": "Wait a moment, then check again. Nothing is stopped to make the move happen.", + "wait": "Wait for it to finish, then check again.", + "scrollbackTooLarge": "Clear the terminal output in those terminals, then check again." + }, + "scopeRefused": { + "windowsDrive": "Its folder is on a Windows drive, so it stays on this computer.", + "mixedLocations": "Some of its folders are outside this distro, so it stays on this computer.", + "notLocal": "It already runs on another host.", + "other": "It cannot move." + }, + "reloadToast": { + "title": "Reload to finish the move" + } + }, "onboarding": { "language": { "back": "Languages", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f0af3a8e30a..dfac89a0a32 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -6209,7 +6209,7 @@ "running": "En ejecución", "stopped": "Detenida", "title": "Agregar distro WSL", - "description": "Orca ejecuta su servidor dentro de la distro, así que git, los archivos y las terminales se ejecutan de forma nativa en Linux. Úsala para proyectos nuevos; los proyectos que abriste a través de \\\\wsl.localhost siguen funcionando tal como están.", + "description": "Orca ejecuta su servidor dentro de la distro, así que git, los archivos y las terminales se ejecutan de forma nativa en Linux. Los proyectos que abriste a través de \\\\wsl.localhost siguen funcionando tal como están hasta que los muevas a este host.", "distro": "Distro", "loading": "Buscando distros WSL…", "placeholder": "Elige una distro", @@ -19420,6 +19420,20 @@ "confirm": "Ajustar filtros y mostrar", "cancel": "Mantener filtros" }, + "wslMoved": { + "movedToHost": "Movido al host de {{distro}}", + "useHere": "Usar de nuevo en este equipo", + "useHereTitle": "¿Usar {{name}} de nuevo en este equipo?", + "useHereDescription": "Orca abrirá este proyecto aquí tal como estaba antes de moverlo. Tu código no se ve afectado: nunca se movió. Los cambios hechos en el host {{distro}} desde el traslado, como pestañas y chats nuevos, se quedan en el host y no vuelven. Los proyectos que se movieron junto con este también vuelven. El proyecto también sigue en el host {{distro}} hasta que lo quites allí. Esta ventana se recargará.", + "useHereConfirm": "Usar en este equipo", + "useHereSuperseded": "Un traslado posterior al host {{distro}} incluye este proyecto. Trae ese de vuelta primero.", + "useHereMoveRunning": "Todavía hay un traslado al host {{distro}} en curso. Inténtalo de nuevo cuando termine.", + "useHereHostUnreachable": "El host {{distro}} no responde, así que Orca no puede detener su copia de este proyecto y sus agentes podrían seguir ejecutándose allí. Conecta el host en Configuración, o quítalo, e inténtalo de nuevo.", + "useHereHostStillRunning": "La copia de este proyecto en el host {{distro}} todavía tiene terminales en ejecución, así que sus agentes podrían ejecutarse dos veces. Espera un momento e inténtalo de nuevo.", + "useHereFailed": "No se pudo devolver este proyecto a este equipo.", + "useHereUnsavedTitle": "Guarda primero tus archivos", + "useHereUnsavedDescription": "Recuperar {{name}} recarga esta ventana. Guarda o cierra primero estos archivos: {{files}}" + }, "startupWorktreeRefresh": { "loading": "Buscando espacios de trabajo…", "description": "Pueden aparecer más espacios de trabajo mientras se cargan los proyectos locales y remotos." diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 78e3ca45a5f..95b95aa9617 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -6386,7 +6386,7 @@ "running": "En cours", "stopped": "Arrêtée", "title": "Ajouter une distro WSL", - "description": "Orca exécute son serveur dans la distro, de sorte que git, les fichiers et les terminaux s'exécutent nativement sous Linux. Utilisez-la pour les nouveaux projets ; les projets que vous avez ouverts via \\\\wsl.localhost continuent de fonctionner tels quels.", + "description": "Orca exécute son serveur dans la distro, de sorte que git, les fichiers et les terminaux s'exécutent nativement sous Linux. Les projets que vous avez ouverts via \\\\wsl.localhost continuent de fonctionner tels quels jusqu'à ce que vous les déplaciez vers cet hôte.", "distro": "Distro", "loading": "Recherche des distros WSL…", "placeholder": "Choisissez une distro", @@ -19191,6 +19191,20 @@ "confirm": "Ajustez les filtres et révélez", "cancel": "Conserver les filtres" }, + "wslMoved": { + "movedToHost": "Déplacé vers l'hôte {{distro}}", + "useHere": "Utiliser à nouveau sur cet ordinateur", + "useHereTitle": "Utiliser à nouveau {{name}} sur cet ordinateur ?", + "useHereDescription": "Orca ouvrira ce projet ici tel qu'il était avant le déplacement. Votre code n'est pas concerné : il n'a jamais été déplacé. Les modifications faites sur l'hôte {{distro}} depuis le déplacement, comme de nouveaux onglets et chats, restent sur l'hôte et ne reviennent pas. Les projets déplacés avec celui-ci reviennent aussi. Le projet reste également sur l'hôte {{distro}} jusqu'à ce que vous l'y supprimiez. Cette fenêtre va se recharger.", + "useHereConfirm": "Utiliser sur cet ordinateur", + "useHereSuperseded": "Un déplacement ultérieur vers l'hôte {{distro}} inclut ce projet. Ramenez d'abord celui-là.", + "useHereMoveRunning": "Un déplacement vers l'hôte {{distro}} est encore en cours. Réessayez une fois terminé.", + "useHereHostUnreachable": "L'hôte {{distro}} ne répond pas : Orca ne peut pas arrêter sa copie de ce projet, et ses agents y tournent peut-être encore. Connectez l'hôte dans les Réglages, ou supprimez-le, puis réessayez.", + "useHereHostStillRunning": "La copie de ce projet sur l'hôte {{distro}} a encore des terminaux en cours : ses agents pourraient tourner deux fois. Patientez un instant, puis réessayez.", + "useHereFailed": "Impossible de ramener ce projet sur cet ordinateur.", + "useHereUnsavedTitle": "Enregistrez d'abord vos fichiers", + "useHereUnsavedDescription": "Récupérer {{name}} recharge cette fenêtre. Enregistrez ou fermez d'abord ces fichiers : {{files}}" + }, "startupWorktreeRefresh": { "loading": "Recherche des espaces de travail…", "description": "D'autres espaces de travail peuvent apparaître pendant le chargement des projets locaux et distants." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e0b68ce71b5..548f8ee96e2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6207,7 +6207,7 @@ "running": "実行中", "stopped": "停止中", "title": "WSL ディストリビューションを追加", - "description": "Orca はディストリビューション内でサーバーを実行するため、git、ファイル、ターミナルは Linux 上でネイティブに動作します。新規プロジェクトに使用してください。\\\\wsl.localhost 経由で開いたプロジェクトはそのまま動作し続けます。", + "description": "Orca はディストリビューション内でサーバーを実行するため、git、ファイル、ターミナルは Linux 上でネイティブに動作します。\\\\wsl.localhost 経由で開いたプロジェクトは、このホストに移動するまでそのまま動作し続けます。", "distro": "ディストリビューション", "loading": "WSL ディストリビューションを探しています…", "placeholder": "ディストリビューションを選択", @@ -19120,6 +19120,20 @@ "confirm": "フィルターを調整して明らかにする", "cancel": "フィルタを保持する" }, + "wslMoved": { + "movedToHost": "{{distro}} ホストに移動済み", + "useHere": "このコンピューターで再び使用", + "useHereTitle": "{{name}} をこのコンピューターで再び使用しますか?", + "useHereDescription": "Orca はこのプロジェクトを移動前の状態でここに開きます。コードには影響しません(移動していません)。移動後に {{distro}} ホストで行った変更(新しいタブやチャットなど)はホストに残り、戻りません。一緒に移動したプロジェクトも戻ります。プロジェクトは、{{distro}} ホストで削除するまでそこにも残ります。このウィンドウは再読み込みされます。", + "useHereConfirm": "このコンピューターで使用", + "useHereSuperseded": "{{distro}} ホストへの後の移動にこのプロジェクトが含まれています。先にそちらを戻してください。", + "useHereMoveRunning": "{{distro}} ホストへの移動がまだ実行中です。終了してからもう一度お試しください。", + "useHereHostUnreachable": "{{distro}} ホストが応答しないため、Orca はこのプロジェクトのホスト側のコピーを停止できず、そのAgentがまだ実行中の可能性があります。設定でホストに接続するか削除してから、もう一度お試しください。", + "useHereHostStillRunning": "{{distro}} ホスト上のこのプロジェクトのコピーでまだターミナルが実行中のため、Agentが二重に動く可能性があります。少し待ってからもう一度お試しください。", + "useHereFailed": "このプロジェクトをこのコンピューターに戻せませんでした。", + "useHereUnsavedTitle": "先にファイルを保存してください", + "useHereUnsavedDescription": "{{name}} を戻すとこのウィンドウが再読み込みされます。先に次のファイルを保存するか閉じてください: {{files}}" + }, "startupWorktreeRefresh": { "loading": "ワークスペースを検出中…", "description": "ローカルおよびリモートのプロジェクトを読み込む間に、さらにワークスペースが表示されることがあります。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0a95e5dbc56..2119e9ca565 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6207,7 +6207,7 @@ "running": "실행 중", "stopped": "중지됨", "title": "WSL 배포판 추가", - "description": "Orca는 배포판 안에서 서버를 실행하므로 git, 파일, 터미널이 Linux에서 네이티브로 실행됩니다. 새 프로젝트에 사용하세요. \\\\wsl.localhost를 통해 연 프로젝트는 그대로 계속 작동합니다.", + "description": "Orca는 배포판 안에서 서버를 실행하므로 git, 파일, 터미널이 Linux에서 네이티브로 실행됩니다. \\\\wsl.localhost를 통해 연 프로젝트는 이 호스트로 옮기기 전까지 그대로 계속 작동합니다.", "distro": "배포판", "loading": "WSL 배포판 찾는 중…", "placeholder": "배포판 선택", @@ -19120,6 +19120,20 @@ "confirm": "필터 조정 및 공개", "cancel": "필터 유지" }, + "wslMoved": { + "movedToHost": "{{distro}} 호스트로 이동됨", + "useHere": "이 컴퓨터에서 다시 사용", + "useHereTitle": "{{name}}을(를) 이 컴퓨터에서 다시 사용할까요?", + "useHereDescription": "Orca가 이 프로젝트를 이동 전 상태 그대로 여기에서 엽니다. 코드에는 영향이 없습니다. 코드는 이동되지 않았습니다. 이동 후 {{distro}} 호스트에서 만든 새 탭이나 채팅 같은 변경 사항은 호스트에 남고 돌아오지 않습니다. 함께 이동한 프로젝트도 함께 돌아옵니다. 프로젝트는 {{distro}} 호스트에서 제거할 때까지 그곳에도 남아 있습니다. 이 창이 다시 로드됩니다.", + "useHereConfirm": "이 컴퓨터에서 사용", + "useHereSuperseded": "이후 {{distro}} 호스트로의 이동에 이 프로젝트가 포함되어 있습니다. 그 이동을 먼저 되돌리세요.", + "useHereMoveRunning": "{{distro}} 호스트로의 이동이 아직 진행 중입니다. 끝난 후 다시 시도하세요.", + "useHereHostUnreachable": "{{distro}} 호스트가 응답하지 않아 Orca가 이 프로젝트의 호스트 사본을 중지할 수 없으며, 그 에이전트가 아직 실행 중일 수 있습니다. 설정에서 호스트를 연결하거나 제거한 후 다시 시도하세요.", + "useHereHostStillRunning": "{{distro}} 호스트에 있는 이 프로젝트의 사본에서 아직 터미널이 실행 중이라 에이전트가 두 번 실행될 수 있습니다. 잠시 후 다시 시도하세요.", + "useHereFailed": "이 프로젝트를 이 컴퓨터로 되돌리지 못했습니다.", + "useHereUnsavedTitle": "먼저 파일을 저장하세요", + "useHereUnsavedDescription": "{{name}}을(를) 다시 가져오면 이 창이 새로고침됩니다. 먼저 다음 파일을 저장하거나 닫으세요: {{files}}" + }, "startupWorktreeRefresh": { "loading": "워크스페이스를 찾는 중…", "description": "로컬 및 원격 프로젝트를 불러오는 동안 워크스페이스가 더 표시될 수 있습니다." diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5e13769bbad..228657abf77 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6207,7 +6207,7 @@ "running": "运行中", "stopped": "已停止", "title": "添加 WSL 发行版", - "description": "Orca 在发行版内运行其服务器,因此 git、文件和终端都在 Linux 中原生运行。请将其用于新项目;通过 \\\\wsl.localhost 打开的项目会照常工作。", + "description": "Orca 在发行版内运行其服务器,因此 git、文件和终端都在 Linux 中原生运行。通过 \\\\wsl.localhost 打开的项目会照常工作,直到你将它们移到此主机。", "distro": "发行版", "loading": "正在查找 WSL 发行版…", "placeholder": "选择发行版", @@ -19121,6 +19121,20 @@ "confirm": "调整滤镜并显示", "cancel": "保留过滤器" }, + "wslMoved": { + "movedToHost": "已移至 {{distro}} 主机", + "useHere": "重新在此电脑上使用", + "useHereTitle": "要重新在此电脑上使用 {{name}} 吗?", + "useHereDescription": "Orca 会按移动前的状态在这里打开此项目。你的代码不受影响:它从未移动。移动后在 {{distro}} 主机上所做的更改(例如新标签页和聊天)会留在主机上,不会回来。与它一起移动的项目也会一起回来。在你于 {{distro}} 主机上将其移除之前,项目也会一直保留在那里。此窗口将重新加载。", + "useHereConfirm": "在此电脑上使用", + "useHereSuperseded": "之后一次移动到 {{distro}} 主机的操作包含此项目。请先将那次移动恢复。", + "useHereMoveRunning": "移动到 {{distro}} 主机的操作仍在进行。请在其结束后重试。", + "useHereHostUnreachable": "{{distro}} 主机没有响应,Orca 无法停止它上面的此项目副本,其代理可能仍在运行。请在设置中连接或移除该主机,然后重试。", + "useHereHostStillRunning": "{{distro}} 主机上的此项目副本仍有终端在运行,其代理可能会运行两次。请稍候再试。", + "useHereFailed": "无法将此项目恢复到此电脑。", + "useHereUnsavedTitle": "请先保存文件", + "useHereUnsavedDescription": "将 {{name}} 恢复到此电脑会重新加载此窗口。请先保存或关闭以下文件:{{files}}" + }, "startupWorktreeRefresh": { "loading": "正在发现工作区…", "description": "本地和远程项目加载期间,可能会显示更多工作区。" diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session.ts b/src/renderer/src/lib/activate-ai-vault-structured-session.ts index 9a76c516fe4..8234a37fee2 100644 --- a/src/renderer/src/lib/activate-ai-vault-structured-session.ts +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.ts @@ -2,6 +2,7 @@ import { toast } from 'sonner' import type { AiVaultSession } from '../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' import { activateAndRevealWorktree } from './worktree-activation' +import { activateWslMovedWorkspace } from './wsl-moved-chat-activation' import { activateStructuredAgentSessionById } from './agent-session/structured-agent-session-tab-activation' import { useAppStore } from '@/store' import { runtimeEnvironmentIdOrLocalUnlessTwin } from './worktree-runtime-owner' @@ -141,7 +142,11 @@ async function activateStructuredSession( } } } - if (useAppStore.getState().activeWorktreeId !== structured.workspaceId) { + // A moved project's chat opens under its retained entry; the catalog does not list it. + if ( + useAppStore.getState().activeWorktreeId !== structured.workspaceId && + !activateWslMovedWorkspace(structured.workspaceId) + ) { activateAndRevealWorktree(structured.workspaceId) } return true diff --git a/src/renderer/src/lib/quick-open-file-history.ts b/src/renderer/src/lib/quick-open-file-history.ts index e59e83238fd..921cd85b335 100644 --- a/src/renderer/src/lib/quick-open-file-history.ts +++ b/src/renderer/src/lib/quick-open-file-history.ts @@ -106,6 +106,42 @@ export function recordQuickOpenFileVisit(state: AppState, file: OpenFile | undef MAX_HISTORY_FILES ) ) + saveHistories(map) +} + +/** + * Adds each re-keyed history beside its source, at the same recency. A full store makes room by + * dropping copied sources, oldest first, never another scope. Throws when storage refuses the write. + */ +export function copyQuickOpenHistoryScopes(copyScopeOf: (scope: string) => string | null): boolean { + const map = historyMap() + const next = new Map() + const copiedSources: string[] = [] + for (const [scope, files] of map) { + next.set(scope, files) + const copy = copyScopeOf(scope) + if (copy !== null && !map.has(copy) && !next.has(copy)) { + next.set(copy, files) + copiedSources.push(scope) + } + } + if (copiedSources.length === 0) { + return false + } + for (const source of copiedSources) { + if (next.size <= MAX_HISTORY_SCOPES) { + break + } + next.delete(source) + } + histories = next + if (!saveHistories(next)) { + throw new Error('quick_open_history_not_saved') + } + return true +} + +function saveHistories(map: Map): boolean { while (map.size > MAX_HISTORY_SCOPES) { const oldest = map.keys().next().value if (oldest === undefined) { @@ -113,8 +149,12 @@ export function recordQuickOpenFileVisit(state: AppState, file: OpenFile | undef } map.delete(oldest) } + let saved = true try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...map])) - } catch {} + } catch { + saved = false + } listeners.forEach((listener) => listener()) + return saved } diff --git a/src/renderer/src/lib/setup-script-prompt.ts b/src/renderer/src/lib/setup-script-prompt.ts index c07c905bd74..c926e170535 100644 --- a/src/renderer/src/lib/setup-script-prompt.ts +++ b/src/renderer/src/lib/setup-script-prompt.ts @@ -82,6 +82,12 @@ export function getSetupScriptPromptDismissalKey(repoHostIdentity: string): stri return `${SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX}${repoHostIdentity}` } +/** The repo of a dismissal key, host-qualified (`\0`) or legacy bare. */ +export function getSetupScriptPromptDismissalRepoId(entry: string): string { + const identity = entry.slice(SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX.length) + return identity.slice(identity.indexOf('\0') + 1) +} + export function isSetupScriptPromptDismissed( repoHostIdentity: string, dismissedEntries: readonly string[] diff --git a/src/renderer/src/lib/workspace-session-hydration-keys.ts b/src/renderer/src/lib/workspace-session-hydration-keys.ts index cb972553d28..fff8a655fcc 100644 --- a/src/renderer/src/lib/workspace-session-hydration-keys.ts +++ b/src/renderer/src/lib/workspace-session-hydration-keys.ts @@ -9,6 +9,8 @@ import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' export type WorkspaceSessionHydrationOptions = { additionalValidWorkspaceKeys?: readonly WorkspaceKey[] replaceWorkspaceKeys?: readonly string[] + /** Workspaces of projects a WSL host now owns: only their native chat tabs hydrate. */ + wslMovedChatWorkspaceKeys?: readonly string[] } // Worktree-keyed fields carrying restorable chrome — a repo appears here only if it has live @@ -169,4 +171,9 @@ export function addAdditionalValidWorkspaceKeys( for (const key of options?.additionalValidWorkspaceKeys ?? []) { validWorkspaceIds.add(key) } + // A moved project's terminals, editors and browser tabs live on its host now; tabs hydration + // keeps only its chats. Its local-partition keys are never the host's own workspaces. + for (const key of options?.wslMovedChatWorkspaceKeys ?? []) { + validWorkspaceIds.delete(key) + } } diff --git a/src/renderer/src/lib/wsl-move-renderer-store-keys.test.ts b/src/renderer/src/lib/wsl-move-renderer-store-keys.test.ts new file mode 100644 index 00000000000..447bd4a1166 --- /dev/null +++ b/src/renderer/src/lib/wsl-move-renderer-store-keys.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' +import { wslMoveRendererRewriteScope } from '../../../shared/wsl-move/wsl-move-renderer-rewrite-scope' +import { buildFileViewPreferenceKey } from '../components/editor/file-view-preference-storage' +import { + hostCsvColumnWidthsKey, + hostFileViewPreferenceKey, + hostNotebookEnvironment, + hostQuickOpenHistoryScope +} from './wsl-move-renderer-store-keys' + +const scope = wslMoveRendererRewriteScope({ + distro: 'Ubuntu', + scopeRepoIds: ['repo-1'], + scopeFolderWorkspaceIds: ['folder-1'], + destinationHostId: 'runtime:wsl-env', + destinationEnvironmentId: 'wsl-env', + scopeRootPaths: ['/home/u/proj', '/home/u/notes'] +}) + +const UNC_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\u\\proj' +const LOCAL_ID = `repo-1::${UNC_ROOT}` +const HOST_ID = 'repo-1::/home/u/proj' +const localRoute = { executionHostId: 'local', runtimeEnvironmentId: null } +const hostRoute = { executionHostId: 'runtime:wsl-env', runtimeEnvironmentId: 'wsl-env' } + +describe('WSL move renderer rewrite scope', () => { + it('maps a moved worktree id, either UNC spelling and its workspace key', () => { + expect(scope.hostWorktreeId(LOCAL_ID)).toBe(HOST_ID) + expect(scope.hostWorktreeId('repo-1::\\\\wsl$\\ubuntu\\home\\u\\proj')).toBe(HOST_ID) + expect(scope.hostWorktreeId(`worktree:${LOCAL_ID}`)).toBe(`worktree:${HOST_ID}`) + expect(scope.hostWorktreeId('folder:folder-1')).toBe('folder:folder-1') + }) + + it('leaves unmoved repos, other distros and host ids alone', () => { + expect(scope.hostWorktreeId(`repo-2::${UNC_ROOT}`)).toBeNull() + expect(scope.hostWorktreeId('repo-1::\\\\wsl.localhost\\Debian\\home\\u\\proj')).toBeNull() + expect(scope.hostWorktreeId(HOST_ID)).toBeNull() + expect(scope.hostWorktreeId('folder:folder-2')).toBeNull() + }) +}) + +describe('WSL move renderer store keys', () => { + it('re-keys a moved quick-open history for the host, once', () => { + const local = JSON.stringify([ + localRoute, + LOCAL_ID, + normalizeRuntimePathForComparison(UNC_ROOT) + ]) + const host = hostQuickOpenHistoryScope(local, scope) + expect(host).toBe(JSON.stringify([hostRoute, HOST_ID, '/home/u/proj'])) + // The host's own key maps to nothing, so a re-run adds nothing. + expect(hostQuickOpenHistoryScope(host ?? '', scope)).toBeNull() + }) + + it('leaves quick-open histories of other hosts and unmoved repos alone', () => { + const root = normalizeRuntimePathForComparison(UNC_ROOT) + const ssh = { executionHostId: 'ssh:box', runtimeEnvironmentId: null } + expect(hostQuickOpenHistoryScope(JSON.stringify([ssh, LOCAL_ID, root]), scope)).toBeNull() + expect( + hostQuickOpenHistoryScope(JSON.stringify([localRoute, `repo-2::${UNC_ROOT}`, root]), scope) + ).toBeNull() + expect(hostQuickOpenHistoryScope('not json', scope)).toBeNull() + }) + + it('re-keys a moved file view preference and its CSV wrapper', () => { + const local = buildFileViewPreferenceKey({ + worktreeId: LOCAL_ID, + filePath: `${UNC_ROOT}\\data.csv` + }) + const host = buildFileViewPreferenceKey({ + worktreeId: HOST_ID, + runtimeEnvironmentId: 'wsl-env', + filePath: '/home/u/proj/data.csv' + }) + expect(hostFileViewPreferenceKey(local, scope)).toBe(host) + expect(hostFileViewPreferenceKey(host, scope)).toBeNull() + expect(hostCsvColumnWidthsKey(JSON.stringify([local, ',']), scope)).toBe( + JSON.stringify([host, ',']) + ) + }) + + it('maps a moved folder workspace preference by path only', () => { + const local = buildFileViewPreferenceKey({ + worktreeId: 'folder:folder-1', + filePath: '\\\\wsl.localhost\\Ubuntu\\home\\u\\notes\\a.pdf' + }) + expect(hostFileViewPreferenceKey(local, scope)).toBe( + buildFileViewPreferenceKey({ + worktreeId: 'folder:folder-1', + runtimeEnvironmentId: 'wsl-env', + filePath: '/home/u/notes/a.pdf' + }) + ) + }) + + it('leaves file view preferences of runtime, SSH and unmoved workspaces alone', () => { + const filePath = `${UNC_ROOT}\\a.pdf` + for (const key of [ + buildFileViewPreferenceKey({ worktreeId: LOCAL_ID, runtimeEnvironmentId: 'other', filePath }), + buildFileViewPreferenceKey({ worktreeId: LOCAL_ID, externalSshTargetId: 'box', filePath }), + buildFileViewPreferenceKey({ worktreeId: `repo-2::${UNC_ROOT}`, filePath }), + buildFileViewPreferenceKey({ worktreeId: 'folder:folder-2', filePath }) + ]) { + expect(hostFileViewPreferenceKey(key, scope)).toBeNull() + } + }) + + it('carries a notebook interpreter in the distro, inside a moved project only', () => { + const venv = { path: `${UNC_ROOT}\\.venv\\bin\\python`, name: '.venv' } + expect(hostNotebookEnvironment(`${UNC_ROOT}\\nb.ipynb`, venv, scope)).toEqual([ + '/home/u/proj/nb.ipynb', + { path: '/home/u/proj/.venv/bin/python', name: '.venv' } + ]) + // A Windows interpreter cannot run on the host. + expect( + hostNotebookEnvironment( + `${UNC_ROOT}\\nb.ipynb`, + { path: 'C:\\Python\\python.exe', name: 'python' }, + scope + ) + ).toBeNull() + // Same distro, but not a moved project's directory. + expect( + hostNotebookEnvironment('\\\\wsl.localhost\\Ubuntu\\home\\u\\other\\nb.ipynb', venv, scope) + ).toBeNull() + expect(hostNotebookEnvironment('/home/u/proj/nb.ipynb', venv, scope)).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/wsl-move-renderer-store-keys.ts b/src/renderer/src/lib/wsl-move-renderer-store-keys.ts new file mode 100644 index 00000000000..982ba802910 --- /dev/null +++ b/src/renderer/src/lib/wsl-move-renderer-store-keys.ts @@ -0,0 +1,99 @@ +/** + * The WSL host's key for a renderer-stored entry of a moved local workspace, or null when the entry + * is not one: another host's, another distro's, or a project the move did not take. + */ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' +import type { PythonEnvironment } from '../../../shared/notebook-kernel-types' +import type { WslMoveRendererRewriteScope } from '../../../shared/wsl-move/wsl-move-renderer-rewrite-scope' + +function parseArray(value: string): unknown[] | null { + try { + const parsed: unknown = JSON.parse(value) + return Array.isArray(parsed) ? parsed : null + } catch { + return null + } +} + +/** `[route, worktreeId, comparison-form root]`, as quick-open-file-history.ts keys a history. */ +export function hostQuickOpenHistoryScope( + scope: string, + move: WslMoveRendererRewriteScope +): string | null { + const parsed = parseArray(scope) + if (!parsed || parsed.length !== 3) { + return null + } + const [route, worktreeId, root] = parsed + if ( + typeof route !== 'object' || + route === null || + !('executionHostId' in route) || + route.executionHostId !== LOCAL_EXECUTION_HOST_ID || + typeof worktreeId !== 'string' || + typeof root !== 'string' + ) { + return null + } + const hostWorktreeId = move.hostWorktreeId(worktreeId) + const hostRoot = move.hostPath(root) + return hostWorktreeId !== null && hostRoot !== null + ? JSON.stringify([move.hostRoute, hostWorktreeId, normalizeRuntimePathForComparison(hostRoot)]) + : null +} + +/** `[worktreeId, environment, sshTarget, filePath]`, as buildFileViewPreferenceKey writes it. */ +export function hostFileViewPreferenceKey( + fileKey: string, + move: WslMoveRendererRewriteScope +): string | null { + const parsed = parseArray(fileKey) + if (!parsed || parsed.length !== 4) { + return null + } + const [worktreeId, environment, sshTarget, filePath] = parsed + if ( + typeof worktreeId !== 'string' || + environment !== 'local' || + sshTarget !== null || + typeof filePath !== 'string' + ) { + return null + } + const hostWorktreeId = move.hostWorktreeId(worktreeId) + const hostFilePath = move.hostPath(filePath) + return hostWorktreeId !== null && hostFilePath !== null + ? JSON.stringify([hostWorktreeId, move.hostRoute.runtimeEnvironmentId, null, hostFilePath]) + : null +} + +/** CSV widths are keyed by `[file key, delimiter]` around the file-view key. */ +export function hostCsvColumnWidthsKey( + storedKey: string, + move: WslMoveRendererRewriteScope +): string | null { + const parsed = parseArray(storedKey) + if (!parsed || parsed.length !== 2 || typeof parsed[0] !== 'string') { + return null + } + const hostFileKey = hostFileViewPreferenceKey(parsed[0], move) + return hostFileKey === null ? null : JSON.stringify([hostFileKey, parsed[1]]) +} + +/** + * A notebook's interpreter choice, keyed by its host path. A Windows interpreter cannot run on the + * host, so only an interpreter inside the distro carries over; the host picks again otherwise. + */ +export function hostNotebookEnvironment( + filePath: string, + environment: PythonEnvironment, + move: WslMoveRendererRewriteScope +): [string, PythonEnvironment] | null { + const hostFilePath = move.hostPath(filePath) + if (hostFilePath === null || !move.inMovedRoot(hostFilePath)) { + return null + } + const interpreter = move.hostPath(environment.path) + return interpreter === null ? null : [hostFilePath, { ...environment, path: interpreter }] +} diff --git a/src/renderer/src/lib/wsl-move-renderer-store-rewrites.test.ts b/src/renderer/src/lib/wsl-move-renderer-store-rewrites.test.ts new file mode 100644 index 00000000000..c4da57c490b --- /dev/null +++ b/src/renderer/src/lib/wsl-move-renderer-store-rewrites.test.ts @@ -0,0 +1,276 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WslMovePendingRendererRewrite } from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types' +import { WSL_MOVE_RENDERER_REWRITE_STORE_IDS } from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types' +import type * as RunnerModule from './wsl-move-renderer-store-rewrites' + +const client = vi.hoisted(() => ({ + list: vi.fn<() => Promise>(), + record: vi.fn() +})) +vi.mock('./wsl-move-renderer-rewrite-client', () => ({ + listPendingWslMoveRendererRewrites: client.list, + recordWslMoveRendererStoreRewritten: client.record +})) +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) + +const UNC_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\u\\proj' +const LOCAL_ID = `repo-1::${UNC_ROOT}` +const HOST_ID = 'repo-1::/home/u/proj' +const QUICK_OPEN = 'orca.quick-open-history.v1' +const PDF = 'orca.pdf.scale-preferences.v1' +const CSV = 'orca.csv.column-widths.v1' +const NOTEBOOKS = 'orca.notebookPythonEnvironments' +const APPLIED = 'orca.wsl-move.renderer-rewrites.v1' + +const localRoute = { executionHostId: 'local', runtimeEnvironmentId: null } +const hostRoute = { executionHostId: 'runtime:wsl-env', runtimeEnvironmentId: 'wsl-env' } +const localQuickOpen = JSON.stringify([localRoute, LOCAL_ID, '//wsl/ubuntu/home/u/proj']) +const hostQuickOpen = JSON.stringify([hostRoute, HOST_ID, '/home/u/proj']) +const sshQuickOpen = JSON.stringify([ + { executionHostId: 'ssh:box', runtimeEnvironmentId: null }, + 'repo-9::/srv/x', + '/srv/x' +]) +const localPdf = JSON.stringify([LOCAL_ID, 'local', null, `${UNC_ROOT}\\a.pdf`]) +const hostPdf = JSON.stringify([HOST_ID, 'wsl-env', null, '/home/u/proj/a.pdf']) +const localCsv = JSON.stringify([ + JSON.stringify([LOCAL_ID, 'local', null, `${UNC_ROOT}\\a.csv`]), + ',' +]) +const hostCsv = JSON.stringify([ + JSON.stringify([HOST_ID, 'wsl-env', null, '/home/u/proj/a.csv']), + ',' +]) + +function move( + overrides: Partial = {} +): WslMovePendingRendererRewrite { + return { + migrationId: 'move-1', + storeIds: [...WSL_MOVE_RENDERER_REWRITE_STORE_IDS], + destinationEnvironmentId: 'wsl-env', + destinationHostId: 'runtime:wsl-env', + distro: 'Ubuntu', + scopeRepoIds: ['repo-1'], + scopeFolderWorkspaceIds: [], + scopeRootPaths: ['/home/u/proj'], + ...overrides + } +} + +function seedStorage(): void { + localStorage.setItem( + QUICK_OPEN, + JSON.stringify([ + [sshQuickOpen, ['x.ts']], + [localQuickOpen, ['src/a.ts']] + ]) + ) + localStorage.setItem(PDF, JSON.stringify({ [localPdf]: { scale: 1.5 } })) + localStorage.setItem(CSV, JSON.stringify({ [localCsv]: { 0: 120 } })) + localStorage.setItem( + NOTEBOOKS, + JSON.stringify({ + [`${UNC_ROOT}\\nb.ipynb`]: { path: `${UNC_ROOT}\\.venv\\bin\\python`, name: '.venv' } + }) + ) +} + +const read = (key: string): unknown => JSON.parse(localStorage.getItem(key) ?? 'null') + +// A fresh module graph is a renderer reload: in-memory caches start over from storage. +async function loadRunner(): Promise { + vi.resetModules() + return import('./wsl-move-renderer-store-rewrites') +} + +beforeEach(() => { + localStorage.clear() + client.list.mockReset() + client.record.mockReset() + client.record.mockResolvedValue({ recorded: true }) +}) +afterEach(() => { + vi.doUnmock('../components/editor/ipynb-kernel-store') +}) + +describe('WSL move renderer store rewrites', () => { + it('changes nothing without a committed move', async () => { + seedStorage() + const before = { ...localStorage } + client.list.mockResolvedValue([]) + await (await loadRunner()).runWslMoveRendererStoreRewrites() + expect({ ...localStorage }).toEqual(before) + expect(localStorage.getItem(APPLIED)).toBeNull() + expect(client.record).not.toHaveBeenCalled() + }) + + it('never throws when the move list is unavailable, and asks again later', async () => { + vi.useFakeTimers() + try { + client.list.mockRejectedValueOnce(new Error('main not ready')).mockResolvedValue([]) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await expect((await loadRunner()).runWslMoveRendererStoreRewrites()).resolves.toBeUndefined() + expect(warn).toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(2_000) + expect(client.list).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(120_000) + expect(client.list).toHaveBeenCalledTimes(2) + warn.mockRestore() + } finally { + vi.useRealTimers() + } + }) + + it('does not retry a record another window was refused', async () => { + vi.useFakeTimers() + try { + client.list.mockResolvedValue([move({ storeIds: ['quick-open-history'] })]) + client.record.mockResolvedValue({ recorded: false, reason: 'not-main-window' }) + await (await loadRunner()).runWslMoveRendererStoreRewrites() + await vi.advanceTimersByTimeAsync(120_000) + expect(client.record).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('adds a host copy beside each moved entry, then records every store', async () => { + seedStorage() + client.list.mockResolvedValue([move()]) + await (await loadRunner()).runWslMoveRendererStoreRewrites() + + expect(read(QUICK_OPEN)).toEqual([ + [sshQuickOpen, ['x.ts']], + [localQuickOpen, ['src/a.ts']], + [hostQuickOpen, ['src/a.ts']] + ]) + expect(read(PDF)).toEqual({ [localPdf]: { scale: 1.5 }, [hostPdf]: { scale: 1.5 } }) + expect(read(CSV)).toEqual({ [localCsv]: { 0: 120 }, [hostCsv]: { 0: 120 } }) + expect(read(NOTEBOOKS)).toMatchObject({ + '/home/u/proj/nb.ipynb': { path: '/home/u/proj/.venv/bin/python', name: '.venv' } + }) + expect(client.record.mock.calls).toEqual( + WSL_MOVE_RENDERER_REWRITE_STORE_IDS.map((id) => ['move-1', id]) + ) + }) + + it('retries a store main refused to record instead of leaving the move stuck', async () => { + // On a real host the marker landed but the ledger entry never did, so every later move was refused. + vi.useFakeTimers() + try { + seedStorage() + client.list.mockResolvedValue([move({ storeIds: ['quick-open-history'] })]) + client.record.mockResolvedValueOnce({ recorded: false, reason: 'gated-off' }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await (await loadRunner()).runWslMoveRendererStoreRewrites() + expect(client.record).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(2_000) + expect(client.record).toHaveBeenCalledTimes(2) + expect(client.record).toHaveBeenLastCalledWith('move-1', 'quick-open-history') + // Recorded now: no further retry. + await vi.advanceTimersByTimeAsync(120_000) + expect(client.record).toHaveBeenCalledTimes(2) + warn.mockRestore() + } finally { + vi.useRealTimers() + } + }) + + it('does not re-apply a landed store after a reload, but still records it if pending', async () => { + seedStorage() + client.list.mockResolvedValue([move()]) + await (await loadRunner()).runWslMoveRendererStoreRewrites() + // The user drops the host copy afterwards; a reload must not bring it back. + const pdf = { [localPdf]: { scale: 1.5 } } + localStorage.setItem(PDF, JSON.stringify(pdf)) + client.record.mockClear() + + await (await loadRunner()).runWslMoveRendererStoreRewrites() + + expect(read(PDF)).toEqual(pdf) + expect(client.record).toHaveBeenCalledTimes(WSL_MOVE_RENDERER_REWRITE_STORE_IDS.length) + }) + + it('re-applies idempotently when the applied record was lost with a lazy flush', async () => { + seedStorage() + client.list.mockResolvedValue([move({ storeIds: [] })]) + await (await loadRunner()).runWslMoveRendererStoreRewrites() + const once = { ...localStorage } + localStorage.removeItem(APPLIED) + + await (await loadRunner()).runWslMoveRendererStoreRewrites() + + expect({ ...localStorage }).toEqual(once) + // A move past its rewrite phase is re-applied, never recorded. + expect(client.record).not.toHaveBeenCalled() + }) + + it('leaves entries of projects the move did not take', async () => { + seedStorage() + client.list.mockResolvedValue([move({ scopeRepoIds: ['repo-2'], scopeRootPaths: ['/srv'] })]) + const before = { ...localStorage } + await (await loadRunner()).runWslMoveRendererStoreRewrites() + for (const key of [QUICK_OPEN, PDF, CSV, NOTEBOOKS]) { + expect(localStorage.getItem(key)).toBe(before[key]) + } + }) + + it('joins a second call instead of running the stores twice at once', async () => { + seedStorage() + client.list.mockResolvedValue([move()]) + const runner = await loadRunner() + await Promise.all([ + runner.runWslMoveRendererStoreRewrites(), + runner.runWslMoveRendererStoreRewrites() + ]) + expect(client.list).toHaveBeenCalledOnce() + expect(read(QUICK_OPEN)).toHaveLength(3) + }) + + it('records neither marker nor ledger entry for a store that fails, and goes on', async () => { + seedStorage() + client.list.mockResolvedValue([move()]) + vi.doMock('../components/editor/ipynb-kernel-store', () => ({ + copyEnvironments: () => { + throw new Error('storage broke') + } + })) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await (await loadRunner()).runWslMoveRendererStoreRewrites() + warn.mockRestore() + + expect(client.record.mock.calls).toEqual([ + ['move-1', 'quick-open-history'], + ['move-1', 'file-view-preferences'] + ]) + expect(read(APPLIED)).toEqual(['move-1:quick-open-history', 'move-1:file-view-preferences']) + }) + + it('makes room in a full store by dropping the moved source, never another project', async () => { + const others = Array.from({ length: 63 }, (_, index) => [`other-${index}`, ['x.ts']]) + localStorage.setItem(QUICK_OPEN, JSON.stringify([[localQuickOpen, ['src/a.ts']], ...others])) + client.list.mockResolvedValue([move()]) + await (await loadRunner()).runWslMoveRendererStoreRewrites() + expect(read(QUICK_OPEN)).toEqual([[hostQuickOpen, ['src/a.ts']], ...others]) + }) + + it('neither marks nor records a store whose write storage refused', async () => { + seedStorage() + client.list.mockResolvedValue([move()]) + const setItem = localStorage.setItem.bind(localStorage) + const refuse = vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => { + if (key === QUICK_OPEN) { + throw new Error('quota') + } + setItem(key, value) + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await (await loadRunner()).runWslMoveRendererStoreRewrites() + refuse.mockRestore() + warn.mockRestore() + expect(client.record).not.toHaveBeenCalledWith('move-1', 'quick-open-history') + expect(read(APPLIED)).not.toContain('move-1:quick-open-history') + }) +}) diff --git a/src/renderer/src/lib/wsl-move-renderer-store-rewrites.ts b/src/renderer/src/lib/wsl-move-renderer-store-rewrites.ts new file mode 100644 index 00000000000..e474e5a809e --- /dev/null +++ b/src/renderer/src/lib/wsl-move-renderer-store-rewrites.ts @@ -0,0 +1,153 @@ +/** + * The renderer's stores of a committed WSL move: a moved workspace's quick-open history, + * CSV and PDF view preferences and notebook interpreter choices gain a copy keyed for the WSL host, + * beside the local entry a downgrade still reads. Native chats stay local (owner decision C). + * + * Storage here flushes lazily and main's flush before a ledger entry is only a request, so every + * startup re-applies each store for every committed move unless this window's applied-marker says + * it landed. The marker is written only after the store's own write succeeded, in the same storage + * area, so a lost flush loses both and the store runs again. + */ +import { + WSL_MOVE_RENDERER_REWRITE_STORE_IDS, + type WslMovePendingRendererRewrite, + type WslMoveRendererRewriteStoreId +} from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types' +import { + wslMoveRendererRewriteScope, + type WslMoveRendererRewriteScope +} from '../../../shared/wsl-move/wsl-move-renderer-rewrite-scope' +import { + listPendingWslMoveRendererRewrites, + recordWslMoveRendererStoreRewritten +} from './wsl-move-renderer-rewrite-client' +import { + hostCsvColumnWidthsKey, + hostFileViewPreferenceKey, + hostNotebookEnvironment, + hostQuickOpenHistoryScope +} from './wsl-move-renderer-store-keys' + +const APPLIED_STORAGE_KEY = 'orca.wsl-move.renderer-rewrites.v1' +const MAX_APPLIED_MARKERS = 2_048 + +// Loaded only when a move exists, so a profile without one never pays for these modules. +const STORE_REWRITES: Record< + WslMoveRendererRewriteStoreId, + (scope: WslMoveRendererRewriteScope) => Promise +> = { + 'quick-open-history': async (scope) => { + const { copyQuickOpenHistoryScopes } = await import('./quick-open-file-history') + copyQuickOpenHistoryScopes((key) => hostQuickOpenHistoryScope(key, scope)) + }, + 'file-view-preferences': async (scope) => { + const [{ copyFileViewPreferences }, { PDF_SCALE_PREFERENCES_STORAGE_KEY }, csv] = + await Promise.all([ + import('../components/editor/file-view-preference-storage'), + import('../components/editor/pdf-scale-preference-storage'), + import('../components/editor/csv/csv-column-width-preferences') + ]) + copyFileViewPreferences(PDF_SCALE_PREFERENCES_STORAGE_KEY, (key) => + hostFileViewPreferenceKey(key, scope) + ) + copyFileViewPreferences(csv.CSV_COLUMN_WIDTHS_STORAGE_KEY, (key) => + hostCsvColumnWidthsKey(key, scope) + ) + }, + 'notebook-environments': async (scope) => { + const { copyEnvironments } = await import('../components/editor/ipynb-kernel-store') + copyEnvironments((filePath, environment) => + hostNotebookEnvironment(filePath, environment, scope) + ) + } +} + +function readAppliedMarkers(): string[] { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(APPLIED_STORAGE_KEY) ?? '[]') + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === 'string') + : [] + } catch { + return [] + } +} + +function writeAppliedMarker(marker: string): void { + const markers = [...readAppliedMarkers().filter((entry) => entry !== marker), marker] + try { + localStorage.setItem(APPLIED_STORAGE_KEY, JSON.stringify(markers.slice(-MAX_APPLIED_MARKERS))) + } catch { + // Unavailable storage: the next startup re-applies, which only re-finds its copies. + } +} + +// Main's refusals a later call can clear. Another window (`not-main-window`) never becomes the main +// one, and the rest mean the store is no longer pending. +const RETRYABLE_RECORD_REASONS: ReadonlySet = new Set(['gated-off', 'failed']) + +/** True when a store's ledger entry still has to land: the move stays unfinished until it does. */ +async function rewriteMove(move: WslMovePendingRendererRewrite): Promise { + const scope = wslMoveRendererRewriteScope(move) + let unrecorded = false + for (const storeId of WSL_MOVE_RENDERER_REWRITE_STORE_IDS) { + const marker = `${move.migrationId}:${storeId}` + try { + if (!readAppliedMarkers().includes(marker)) { + await STORE_REWRITES[storeId](scope) + writeAppliedMarker(marker) + } + if (move.storeIds.includes(storeId)) { + const result = await recordWslMoveRendererStoreRewritten(move.migrationId, storeId) + if (!result.recorded && RETRYABLE_RECORD_REASONS.has(result.reason)) { + // A refused record left the move stuck until the next app start. + console.warn(`[wsl-move] Renderer rewrite ${storeId} not recorded: ${result.reason}`) + unrecorded = true + } + } + } catch (error) { + // No marker and no ledger entry: the next startup tries this store again. + console.warn(`[wsl-move] Renderer rewrite ${storeId} of ${move.migrationId} paused:`, error) + unrecorded = true + } + } + return unrecorded +} + +let inFlight: Promise | null = null +// Backoff for a pass that left a store unrecorded; past the last, the next startup tries again. +const RETRY_DELAYS_MS = [2_000, 5_000, 15_000, 30_000, 60_000] +let retryTimer: ReturnType | null = null + +/** Never throws; a second call while one runs joins it, so no store is applied twice at once. */ +export function runWslMoveRendererStoreRewrites(attempt = 0): Promise { + inFlight ??= rewritePendingMoves() + .then((unrecorded) => { + const delay = RETRY_DELAYS_MS[attempt] + if (unrecorded && delay !== undefined && retryTimer === null) { + retryTimer = setTimeout(() => { + retryTimer = null + void runWslMoveRendererStoreRewrites(attempt + 1) + }, delay) + } + }) + .finally(() => { + inFlight = null + }) + return inFlight +} + +async function rewritePendingMoves(): Promise { + let unrecorded = false + try { + const moves = await listPendingWslMoveRendererRewrites() + for (const move of moves) { + unrecorded = (await rewriteMove(move)) || unrecorded + } + } catch (error) { + // Transient (main not ready yet); the backoff bounds a bridge that never answers. + console.warn('[wsl-move] Could not list renderer rewrites:', error) + unrecorded = true + } + return unrecorded +} diff --git a/src/renderer/src/lib/wsl-moved-chat-activation.ts b/src/renderer/src/lib/wsl-moved-chat-activation.ts new file mode 100644 index 00000000000..e7ddf29cf05 --- /dev/null +++ b/src/renderer/src/lib/wsl-moved-chat-activation.ts @@ -0,0 +1,25 @@ +import { useAppStore } from '@/store' +import { findWslMovedProjectForWorkspace } from '@/store/repos/wsl-moved-projects' +import { activateStructuredAgentSessionTab } from './agent-session/structured-agent-session-tab-activation' + +/** + * Opens a workspace of a project a WSL host now owns, for its chats only. Unlike a normal + * activation it seeds no terminal and wakes no sleeping agent: those belong to the host now. + * False when the workspace is not a moved one, so callers fall through to the normal activation. + */ +export function activateWslMovedWorkspace(workspaceKey: string, chatTabId?: string): boolean { + const state = useAppStore.getState() + if (!findWslMovedProjectForWorkspace(state, workspaceKey)) { + return false + } + if (state.activeView !== 'terminal') { + state.setActiveView('terminal') + } + if (state.activeWorktreeId !== workspaceKey) { + state.setActiveWorktree(workspaceKey) + } + if (chatTabId) { + activateStructuredAgentSessionTab({ worktreeId: workspaceKey, tabId: chatTabId }) + } + return true +} diff --git a/src/renderer/src/lib/wsl-moved-project-use-here.test.ts b/src/renderer/src/lib/wsl-moved-project-use-here.test.ts new file mode 100644 index 00000000000..ee2c72bff89 --- /dev/null +++ b/src/renderer/src/lib/wsl-moved-project-use-here.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConfirmationDialogOptions } from '@/components/confirmation-dialog-context' +import type { + WslMovedProject, + WslMoveUseLocallyResult +} from '../../../shared/wsl-move/wsl-moved-project' + +const movedProjects = vi.hoisted((): { kind: string; id: string; displayName: string }[] => []) +const openFiles = vi.hoisted( + (): { id: string; relativePath: string; worktreeId: string; isDirty: boolean }[] => [] +) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ wslMovedProjects: movedProjects, openFiles }) } +})) + +const { bringWslMovedProjectBack } = await import('./wsl-moved-project-use-here') + +const project: WslMovedProject = { + kind: 'repo', + id: 'repo-1', + displayName: 'Repo 1', + path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-1', + distro: 'Ubuntu' +} + +function stubApi(result: WslMoveUseLocallyResult): ReturnType { + const useProjectHereAgain = vi.fn(async () => result) + vi.stubGlobal('window', { api: { wslMove: { useProjectHereAgain } } }) + return useProjectHereAgain +} + +/** Confirms, then returns the refusal notice: a dialog that only acknowledges. */ +async function refusalShown(): Promise { + const confirm = vi.fn(async (_options: ConfirmationDialogOptions) => true) + await bringWslMovedProjectBack(project, confirm) + // A toast for this sat behind the update notice, so nothing seemed to happen. + expect(confirm).toHaveBeenCalledTimes(2) + expect(confirm.mock.calls[1]?.[0]).toMatchObject({ acknowledgeOnly: true }) + return confirm.mock.calls[1]?.[0]?.description +} + +describe('bringWslMovedProjectBack', () => { + beforeEach(() => { + vi.clearAllMocks() + openFiles.length = 0 + }) + + // P6's unsaved README blocked bringing p9 back; only the move's own files count. + it("asks for the move's unsaved files to be saved first, naming only those", async () => { + openFiles.push( + { id: 'f1', relativePath: 'README.md', worktreeId: 'repo-1::/w', isDirty: true }, + { id: 'f2', relativePath: 'other.md', worktreeId: 'repo-2::/w', isDirty: true }, + { id: 'f3', relativePath: 'saved.md', worktreeId: 'repo-3::/w', isDirty: false } + ) + const call = stubApi({ + released: false, + reason: 'unsaved-files', + unsavedWorkspaceIds: ['repo-1::/w'] + }) + const confirm = vi.fn(async (_options: ConfirmationDialogOptions) => true) + await bringWslMovedProjectBack(project, confirm) + expect(call).toHaveBeenCalledWith({ + kind: 'repo', + id: 'repo-1', + unsavedWorkspaceIds: ['repo-1::/w', 'repo-2::/w'] + }) + expect(confirm).toHaveBeenCalledTimes(2) + expect(confirm.mock.calls[1]?.[0]).toMatchObject({ acknowledgeOnly: true }) + expect(confirm.mock.calls[1]?.[0]?.description).toContain('README.md') + expect(confirm.mock.calls[1]?.[0]?.description).not.toContain('other.md') + }) + + it("brings the project back when the unsaved files are another project's", async () => { + openFiles.push({ id: 'f2', relativePath: 'other.md', worktreeId: 'repo-2::/w', isDirty: true }) + const call = stubApi({ + released: true, + distro: 'Ubuntu', + repoIds: ['repo-1'], + folderWorkspaceIds: [] + }) + const confirm = vi.fn(async (_options: ConfirmationDialogOptions) => true) + await bringWslMovedProjectBack(project, confirm) + expect(call).toHaveBeenCalledOnce() + expect(confirm).toHaveBeenCalledTimes(1) + }) + + it('does nothing unless the user confirms', async () => { + const call = stubApi({ released: false, reason: 'not-moved' }) + await bringWslMovedProjectBack(project, async () => false) + expect(call).not.toHaveBeenCalled() + }) + + it('says plainly what comes back and what stays on the host', async () => { + stubApi({ released: false, reason: 'not-moved' }) + const confirm = vi.fn(async (_options: ConfirmationDialogOptions) => false) + await bringWslMovedProjectBack(project, confirm) + const description = confirm.mock.calls[0]?.[0]?.description ?? '' + expect(description).toContain('Your code is not affected') + expect(description).toContain('stay on the host and do not come back') + expect(description).toContain('stays on the Ubuntu host until you remove it there') + expect(description).toContain('This window reloads') + }) + + it('asks main to bring the project back, and says nothing more on success', async () => { + const call = stubApi({ + released: true, + distro: 'Ubuntu', + repoIds: ['repo-1'], + folderWorkspaceIds: [] + }) + const confirm = vi.fn(async (_options: ConfirmationDialogOptions) => true) + await bringWslMovedProjectBack(project, confirm) + expect(call).toHaveBeenCalledWith({ kind: 'repo', id: 'repo-1', unsavedWorkspaceIds: [] }) + expect(confirm).toHaveBeenCalledTimes(1) + }) + + it('explains a refusal', async () => { + stubApi({ released: false, reason: 'superseded' }) + expect(await refusalShown()).toEqual(expect.stringContaining('A later move')) + }) + + it('names the newer move to bring back first', async () => { + // Undoing an older move said nothing about which newer one blocked it. + movedProjects.splice(0, movedProjects.length, { + kind: 'repo', + id: 'repo-9', + displayName: 'Repo 9' + }) + stubApi({ + released: false, + reason: 'superseded', + newerMove: { repoIds: ['repo-9'], folderWorkspaceIds: [] } + }) + expect(await refusalShown()).toBe( + 'Repo 9 moved to the Ubuntu host after this project. Use those on this computer again first, then this project.' + ) + }) + + it('asks to wait while a move of the distro still runs', async () => { + stubApi({ released: false, reason: 'move-running' }) + expect(await refusalShown()).toBe( + 'A move to the Ubuntu host is still running. Try again once it ends.' + ) + }) + + it("says to connect the host when its automations couldn't be turned off", async () => { + for (const reason of ['host-unreachable', 'failed'] as const) { + stubApi({ released: false, reason, detail: 'host-automations' }) + expect(await refusalShown()).toBe( + "Orca couldn't turn off this project's automations on the Ubuntu host, so they could run twice. Connect the host in Settings, then try again." + ) + } + }) + + it('says a host still being removed is left alone', async () => { + stubApi({ released: false, reason: 'failed', detail: 'host-removal-pending' }) + expect(await refusalShown()).toBe( + 'The Ubuntu host is still being removed, so Orca leaves it alone. Finish removing it in Settings, then try again.' + ) + }) +}) diff --git a/src/renderer/src/lib/wsl-moved-project-use-here.ts b/src/renderer/src/lib/wsl-moved-project-use-here.ts new file mode 100644 index 00000000000..a8ed9a5c109 --- /dev/null +++ b/src/renderer/src/lib/wsl-moved-project-use-here.ts @@ -0,0 +1,146 @@ +import { translate } from '@/i18n/i18n' +import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context' +import { useAppStore } from '@/store' +import type { + WslMovedProject, + WslMoveUseLocallyResult +} from '../../../shared/wsl-move/wsl-moved-project' + +/** + * "Use on this computer again" for a project a WSL host owns: after a plain confirmation, the + * project's pre-move rows return here. Nothing comes back from the host, and its copy stays. + */ +export async function bringWslMovedProjectBack( + project: WslMovedProject, + confirm: ConfirmationDialogContextValue +): Promise { + const confirmed = await confirm({ + title: translate('sidebar.wslMoved.useHereTitle', 'Use {{name}} on this computer again?', { + name: project.displayName + }), + description: translate( + 'sidebar.wslMoved.useHereDescription', + 'Orca will open this project here as it was before the move. Your code is not affected: it never moved. Changes made on the {{distro}} host since the move, such as new tabs and chats, stay on the host and do not come back. Projects moved with it come back too. The project also stays on the {{distro}} host until you remove it there. This window reloads.', + { distro: project.distro } + ), + confirmLabel: translate('sidebar.wslMoved.useHereConfirm', 'Use on this computer') + }) + if (!confirmed) { + return + } + // The reload brings the move's workspaces back as they were before it, dropping their unsaved + // files; main knows which workspaces the move holds. + const unsaved = useAppStore.getState().openFiles.filter((file) => file.isDirty) + const result = await window.api.wslMove.useProjectHereAgain({ + kind: project.kind, + id: project.id, + unsavedWorkspaceIds: [...new Set(unsaved.map((file) => file.worktreeId))] + }) + if (!result.released && result.reason === 'unsaved-files') { + const inScope = new Set(result.unsavedWorkspaceIds) + const files = unsaved + .filter((file) => inScope.has(file.worktreeId)) + .map((file) => file.relativePath) + await confirm({ + title: translate('sidebar.wslMoved.useHereUnsavedTitle', 'Save your files first'), + description: translate( + 'sidebar.wslMoved.useHereUnsavedDescription', + 'Bringing {{name}} back reloads this window. Save or close these files first: {{files}}', + { name: project.displayName, files: files.join(', ') } + ), + confirmLabel: translate('sidebar.wslMoved.useHereRefusedOk', 'OK'), + acknowledgeOnly: true, + initialFocus: 'confirm' + }) + return + } + // On success main reloads the window, which hydrates the whole pre-move session from disk. + if (!result.released) { + // A dialog, not a toast: a toast sat behind the update notice and read as nothing. + await confirm({ + title: translate('sidebar.wslMoved.useHereRefusedTitle', "Can't use {{name}} here yet", { + name: project.displayName + }), + description: wslMoveUseHereRefusalMessage(result, project.distro), + confirmLabel: translate('sidebar.wslMoved.useHereRefusedOk', 'OK'), + acknowledgeOnly: true, + initialFocus: 'confirm' + }) + } +} + +/** The newer move's projects by name, as the sidebar lists them; ids alone mean nothing to the user. */ +function newerMoveNames( + newerMove: { repoIds: string[]; folderWorkspaceIds: string[] } | undefined +): string[] { + if (!newerMove) { + return [] + } + return useAppStore + .getState() + .wslMovedProjects.filter((moved) => + (moved.kind === 'repo' ? newerMove.repoIds : newerMove.folderWorkspaceIds).includes(moved.id) + ) + .map((moved) => moved.displayName) +} + +function wslMoveUseHereRefusalMessage( + result: Extract, + distro: string +): string { + const { reason } = result + const names = reason === 'superseded' ? newerMoveNames(result.newerMove) : [] + if (names.length > 0) { + return translate( + 'sidebar.wslMoved.useHereSupersededBy', + '{{names}} moved to the {{distro}} host after this project. Use those on this computer again first, then this project.', + { names: names.join(', '), distro } + ) + } + if (reason === 'superseded') { + return translate( + 'sidebar.wslMoved.useHereSuperseded', + 'A later move to the {{distro}} host includes this project. Bring that one back first.', + { distro } + ) + } + if (reason === 'move-running') { + return translate( + 'sidebar.wslMoved.useHereMoveRunning', + 'A move to the {{distro}} host is still running. Try again once it ends.', + { distro } + ) + } + if (result.detail === 'host-removal-pending') { + return translate( + 'sidebar.wslMoved.useHereHostRemovalPending', + 'The {{distro}} host is still being removed, so Orca leaves it alone. Finish removing it in Settings, then try again.', + { distro } + ) + } + if (result.detail === 'host-automations') { + return translate( + 'sidebar.wslMoved.useHereHostAutomations', + "Orca couldn't turn off this project's automations on the {{distro}} host, so they could run twice. Connect the host in Settings, then try again.", + { distro } + ) + } + if (reason === 'host-unreachable') { + return translate( + 'sidebar.wslMoved.useHereHostUnreachable', + "The {{distro}} host isn't answering, so Orca can't stop its copy of this project, and its agents may still be running there. Connect the host in Settings, or remove it, then try again.", + { distro } + ) + } + if (reason === 'host-still-running') { + return translate( + 'sidebar.wslMoved.useHereHostStillRunning', + "The {{distro}} host's copy of this project is still running terminals, so its agents could run twice. Wait a moment, then try again.", + { distro } + ) + } + return translate( + 'sidebar.wslMoved.useHereFailed', + 'Could not bring this project back to this computer.' + ) +} diff --git a/src/renderer/src/runtime/host-terminal-scrollback-environment.test.ts b/src/renderer/src/runtime/host-terminal-scrollback-environment.test.ts new file mode 100644 index 00000000000..b3b3a88c496 --- /dev/null +++ b/src/renderer/src/runtime/host-terminal-scrollback-environment.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import { + hostScrollbackEnvironmentId, + resetHostTerminalScrollbackHydrationForTests, + startHostTerminalScrollbackHydration, + type HostTerminalScrollbackHydrationDeps +} from './host-terminal-scrollback-hydration' +import type { OrcadDeploymentLink } from '../../../shared/runtime-environments' + +vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } })) +vi.mock('../web/preload-api/web-runtime-session', () => ({ + requireActiveEnvironmentOrNull: () => null +})) + +const PORTS = { localPort: 6768, remotePort: 6768 } +const ENVIRONMENTS: { id: string; orcadDeployment?: OrcadDeploymentLink }[] = [ + { id: 'env-wsl', orcadDeployment: { ...PORTS, kind: 'wsl', distro: 'Ubuntu' } }, + { + id: 'env-ssh-orcad', + orcadDeployment: { ...PORTS, kind: 'ssh', sshTargetId: 't', sshTargetGeneration: 1 } + }, + { id: 'env-paired-web' } +] +const owner = (environmentId: string | null) => ({ kind: 'resolved' as const, environmentId }) + +describe('which host serves a pane its stored scrollback', () => { + it('is only a WSL host', () => { + expect(hostScrollbackEnvironmentId(owner('env-wsl'), ENVIRONMENTS)).toBe('env-wsl') + expect(hostScrollbackEnvironmentId(owner('env-ssh-orcad'), ENVIRONMENTS)).toBeNull() + expect(hostScrollbackEnvironmentId(owner('env-paired-web'), ENVIRONMENTS)).toBeNull() + expect(hostScrollbackEnvironmentId(owner(null), ENVIRONMENTS)).toBeNull() + expect(hostScrollbackEnvironmentId({ kind: 'ambiguous' }, ENVIRONMENTS)).toBeNull() + // Before the environment list loads there is nothing to hold for. + expect(hostScrollbackEnvironmentId(owner('env-wsl'), undefined)).toBeNull() + expect(hostScrollbackEnvironmentId(owner('env-wsl'), [])).toBeNull() + }) + + it.each(['env-ssh-orcad', 'env-paired-web'])( + 'holds no %s tab for a placeholder and asks that host nothing', + (environmentId) => { + resetHostTerminalScrollbackHydrationForTests() + const deps: HostTerminalScrollbackHydrationDeps = { + getTabState: () => ({ + layout: { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + scrollbackRefsByLeafId: { 'leaf-1': `v1-${'c'.repeat(32)}` } + }, + localOnly: undefined + }), + resolveEnvironmentId: () => hostScrollbackEnvironmentId(owner(environmentId), ENVIRONMENTS), + hasLocalSnapshot: vi.fn(() => false), + peekHostSupportsRead: vi.fn(() => true), + warmHostSupportsRead: vi.fn(async () => true), + readHostSnapshot: vi.fn(async () => 'history'), + isTabMounted: () => false, + storeLocalOnly: vi.fn() + } + expect(startHostTerminalScrollbackHydration('tab-1', 'wt-1', deps)).toBeNull() + expect(deps.peekHostSupportsRead).not.toHaveBeenCalled() + expect(deps.warmHostSupportsRead).not.toHaveBeenCalled() + expect(deps.readHostSnapshot).not.toHaveBeenCalled() + expect(deps.hasLocalSnapshot).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/renderer/src/runtime/host-terminal-scrollback-hydration.test.ts b/src/renderer/src/runtime/host-terminal-scrollback-hydration.test.ts new file mode 100644 index 00000000000..9484dd5a250 --- /dev/null +++ b/src/renderer/src/runtime/host-terminal-scrollback-hydration.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import { + HOST_TERMINAL_SCROLLBACK_HOLD_MS, + resetHostTerminalScrollbackHydrationForTests, + startHostTerminalScrollbackHydration, + type HostTerminalScrollbackHydrationDeps, + type HostTerminalScrollbackTabState +} from './host-terminal-scrollback-hydration' +import { createWebWorkspaceSessionApi } from '../web/preload-api/web-workspace-session-api' + +vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } })) +vi.mock('../web/preload-api/web-runtime-session', () => ({ + requireActiveEnvironmentOrNull: () => null +})) + +const REF = `v1-${'c'.repeat(32)}` + +function layout(overrides: Partial = {}): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + scrollbackRefsByLeafId: { 'leaf-1': REF }, + ...overrides + } +} + +function harness(overrides: Partial = {}) { + const tabState: HostTerminalScrollbackTabState = { + layout: layout(), + localOnly: undefined + } + const deps = { + getTabState: vi.fn(() => tabState), + resolveEnvironmentId: vi.fn((_worktreeId: string): string | null => 'env-wsl'), + hasLocalSnapshot: vi.fn((_ref: string) => false), + peekHostSupportsRead: vi.fn((_environmentId: string): boolean | undefined => true), + warmHostSupportsRead: vi.fn(async (_environmentId: string): Promise => true), + readHostSnapshot: vi.fn( + async (_environmentId: string, _worktreeId: string, _ref: string, _signal: AbortSignal) => + 'host history' + ), + isTabMounted: vi.fn((_tabId: string) => false), + storeLocalOnly: vi.fn((tabId: string, buffers: Record) => { + void tabId + tabState.localOnly = buffers + }), + ...overrides + } + return { deps, tabState } +} + +beforeEach(() => { + resetHostTerminalScrollbackHydrationForTests() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +function rpcFailure(code: string): Error { + return Object.assign(new Error(code), { code }) +} + +describe('startHostTerminalScrollbackHydration', () => { + it('reads a host-run pane from its host when this client has no copy', async () => { + const { deps, tabState } = harness() + await startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps) + expect(deps.peekHostSupportsRead).toHaveBeenCalledWith('env-wsl') + expect(deps.warmHostSupportsRead).not.toHaveBeenCalled() + expect(deps.readHostSnapshot).toHaveBeenCalledExactlyOnceWith( + 'env-wsl', + 'repo::/wt', + REF, + expect.any(AbortSignal) + ) + expect(tabState.localOnly).toEqual({ 'leaf-1': 'host history' }) + }) + + it('mounts at once on a host cached as not advertising the read', () => { + const { deps } = harness({ peekHostSupportsRead: vi.fn(() => false) }) + expect(startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps)).toBeNull() + expect(deps.warmHostSupportsRead).not.toHaveBeenCalled() + expect(deps.readHostSnapshot).not.toHaveBeenCalled() + }) + + it('holds on an unknown host, warms its status once per environment, then reads', async () => { + let known: boolean | undefined + const { deps, tabState } = harness({ + peekHostSupportsRead: vi.fn(() => known), + warmHostSupportsRead: vi.fn(async () => { + known = true + return true + }) + }) + const first = startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps) + const second = startHostTerminalScrollbackHydration('tab-2', 'repo::/wt', deps) + expect(first).not.toBeNull() + expect(second).not.toBeNull() + await Promise.all([first, second]) + expect(deps.warmHostSupportsRead).toHaveBeenCalledExactlyOnceWith('env-wsl') + expect(deps.readHostSnapshot).toHaveBeenCalledTimes(2) + expect(tabState.localOnly).toEqual({ 'leaf-1': 'host history' }) + }) + + it('reads nothing when the warmed status says the host lacks the read', async () => { + let known: boolean | undefined + const { deps } = harness({ + peekHostSupportsRead: vi.fn(() => known), + warmHostSupportsRead: vi.fn(async () => { + known = false + return false + }) + }) + await startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps) + expect(deps.readHostSnapshot).not.toHaveBeenCalled() + expect(startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps)).toBeNull() + }) + + it('reads nothing when the status probe fails', async () => { + const { deps } = harness({ + peekHostSupportsRead: vi.fn(() => undefined), + warmHostSupportsRead: vi.fn(async () => { + throw new Error('offline') + }) + }) + await startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps) + expect(deps.readHostSnapshot).not.toHaveBeenCalled() + }) + + it('skips a leaf bound to a live PTY, whose screen replaces restored history', () => { + const { deps, tabState } = harness() + tabState.layout = layout({ ptyIdsByLeafId: { 'leaf-1': 'pty-1' } }) + expect(startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps)).toBeNull() + expect(deps.hasLocalSnapshot).not.toHaveBeenCalled() + expect(deps.peekHostSupportsRead).not.toHaveBeenCalled() + }) + + it('never asks a host for a pane this machine runs', () => { + const { deps } = harness({ resolveEnvironmentId: vi.fn(() => null) }) + expect(startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps)).toBeNull() + expect(deps.peekHostSupportsRead).not.toHaveBeenCalled() + expect(deps.hasLocalSnapshot).not.toHaveBeenCalled() + }) + + it('keeps using the local copy, stat-checked once per ref', () => { + const { deps } = harness({ hasLocalSnapshot: vi.fn(() => true) }) + expect(startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps)).toBeNull() + expect(startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps)).toBeNull() + expect(deps.hasLocalSnapshot).toHaveBeenCalledExactlyOnceWith(REF) + expect(deps.peekHostSupportsRead).not.toHaveBeenCalled() + }) + + it('skips leaves that already carry bytes', () => { + const { deps, tabState } = harness() + tabState.layout = layout({ buffersByLeafId: { 'leaf-1': 'shared' } }) + expect(startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps)).toBeNull() + tabState.layout = layout() + tabState.localOnly = { 'leaf-1': 'parked' } + expect(startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps)).toBeNull() + }) + + it('shares an in-flight read and retries after a transport failure', async () => { + const readHostSnapshot = vi.fn(async (): Promise => { + throw new Error('host unreachable') + }) + const { deps, tabState } = harness({ readHostSnapshot }) + const first = startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps) + expect(startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps)).toBe(first) + await first + expect(deps.storeLocalOnly).not.toHaveBeenCalled() + readHostSnapshot.mockResolvedValue('after reconnect') + await startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps) + expect(readHostSnapshot).toHaveBeenCalledTimes(2) + expect(tabState.localOnly).toEqual({ 'leaf-1': 'after reconnect' }) + }) + + it('never asks again after a not-found answer or stored bytes', async () => { + const { deps } = harness({ + readHostSnapshot: vi.fn(async (): Promise => { + throw rpcFailure('terminal_scrollback_not_found') + }) + }) + await startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps) + expect(startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps)).toBeNull() + + const stored = harness() + await startHostTerminalScrollbackHydration('tab-2', 'repo::/wt', stored.deps) + stored.tabState.localOnly = undefined + expect(startHostTerminalScrollbackHydration('tab-2', 'repo::/wt', stored.deps)).toBeNull() + }) + + it('aborts the read when the hold bound passes and drops late bytes', async () => { + vi.useFakeTimers() + let observed: AbortSignal | undefined + const { deps } = harness({ + readHostSnapshot: vi.fn( + (_environmentId: string, _worktreeId: string, _ref: string, signal: AbortSignal) => + new Promise((resolve, reject) => { + observed = signal + signal.addEventListener('abort', () => { + resolve('late history') + reject(new Error('aborted')) + }) + }) + ) + }) + const pending = startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps) + await vi.advanceTimersByTimeAsync(HOST_TERMINAL_SCROLLBACK_HOLD_MS) + await pending + expect(observed?.aborted).toBe(true) + expect(deps.storeLocalOnly).not.toHaveBeenCalled() + // An abort is not a definite answer, so a later mount may try again. + expect(startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps)).not.toBeNull() + }) + + it('does not write under a pane that mounted while the read was in flight', async () => { + const { deps } = harness({ isTabMounted: vi.fn(() => true) }) + await startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps) + expect(deps.storeLocalOnly).not.toHaveBeenCalled() + }) + + it('drops bytes for a leaf whose ref changed during the read', async () => { + const state: { current: HostTerminalScrollbackTabState | null } = { + current: null + } + const { deps, tabState } = harness({ + readHostSnapshot: vi.fn(async () => { + if (state.current) { + state.current.layout = layout({ + scrollbackRefsByLeafId: { 'leaf-1': `v1-${'d'.repeat(32)}` } + }) + } + return 'stale history' + }) + }) + state.current = tabState + await startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps) + expect(deps.storeLocalOnly).not.toHaveBeenCalled() + }) + + it('reads from the host on a web client, whose preload has no snapshot store', async () => { + const webSession = createWebWorkspaceSessionApi().session + expect(webSession).toBeDefined() + const { deps, tabState } = harness({ + hasLocalSnapshot: (ref) => webSession?.hasTerminalScrollback({ ref }) ?? false + }) + await startHostTerminalScrollbackHydration('tab-1', 'repo::/wt', deps) + expect(deps.readHostSnapshot).toHaveBeenCalledOnce() + expect(tabState.localOnly).toEqual({ 'leaf-1': 'host history' }) + }) +}) diff --git a/src/renderer/src/runtime/host-terminal-scrollback-hydration.ts b/src/renderer/src/runtime/host-terminal-scrollback-hydration.ts new file mode 100644 index 00000000000..10dec7f06f9 --- /dev/null +++ b/src/renderer/src/runtime/host-terminal-scrollback-hydration.ts @@ -0,0 +1,304 @@ +import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import type { SessionReadTerminalScrollbackResult } from '../../../shared/rpc-contract/session-terminal-scrollback-params' +import { SESSION_TERMINAL_SCROLLBACK_READ_RUNTIME_CAPABILITY } from '../../../shared/session-tabs-runtime-capabilities' +import { useAppStore } from '@/store' +import { + runtimeEnvironmentIdForWorkspaceOwner, + type RuntimeEnvironmentOwnerMatch +} from '@/lib/resolve-owner' +import type { OrcadDeploymentLink } from '../../../shared/runtime-environments' +import { + callRuntimeRpc, + peekRuntimeEnvironmentCapability, + runtimeEnvironmentSupportsCapability +} from './runtime-rpc-client' +import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' +import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' +import { hasRegisteredRuntimeTerminalTab } from './mounted-terminal-tab-registry' + +// Why bounded: a slow or unreachable host must never keep a terminal from mounting. +export const HOST_TERMINAL_SCROLLBACK_HOLD_MS = 3_000 + +// Answers that will not change on retry; anything else (transport, timeout, abort) may. +const DEFINITE_READ_FAILURE_CODES = [ + 'terminal_scrollback_not_found', + 'terminal_scrollback_too_large' +] + +export type HostTerminalScrollbackTabState = { + layout: TerminalLayoutSnapshot | undefined + localOnly: Record | undefined +} + +export type HostTerminalScrollbackHydrationDeps = { + getTabState: (tabId: string) => HostTerminalScrollbackTabState + /** Null for a pane this machine runs; such panes never ask a host. */ + resolveEnvironmentId: (worktreeId: string) => string | null + /** Stat-only check of this client's own snapshot store; web clients have none. */ + hasLocalSnapshot: (ref: string) => boolean + /** Cached verdict only; undefined when this client has no fresh status for the host. */ + peekHostSupportsRead: (environmentId: string) => boolean | undefined + /** Fetches the host's status so a later peek answers. */ + warmHostSupportsRead: (environmentId: string) => Promise + readHostSnapshot: ( + environmentId: string, + worktreeId: string, + ref: string, + signal: AbortSignal + ) => Promise + isTabMounted: (tabId: string) => boolean + storeLocalOnly: (tabId: string, buffersByLeafId: Record) => void +} + +type HostScrollbackLeaf = { leafId: string; ref: string } +type HostScrollbackPlan = { + environmentId: string + leaves: HostScrollbackLeaf[] + /** Undefined until this client has a fresh status for the host; the read waits for one. */ + supported: boolean | undefined +} +type FetchedHostScrollbackLeaf = HostScrollbackLeaf & { buffer: string | null } + +const defaultDeps: HostTerminalScrollbackHydrationDeps = { + getTabState: (tabId) => { + const state = useAppStore.getState() + return { + layout: state.terminalLayoutsByTabId[tabId], + localOnly: state.localOnlyScrollbackByTabId[tabId] + } + }, + resolveEnvironmentId: (worktreeId) => { + const state = useAppStore.getState() + return hostScrollbackEnvironmentId( + runtimeEnvironmentIdForWorkspaceOwner(state, { workspaceId: worktreeId }), + state.runtimeEnvironments + ) + }, + hasLocalSnapshot: (ref) => { + try { + return window.api.session.hasTerminalScrollback({ ref }) + } catch { + return false + } + }, + peekHostSupportsRead: (environmentId) => + peekRuntimeEnvironmentCapability( + environmentId, + SESSION_TERMINAL_SCROLLBACK_READ_RUNTIME_CAPABILITY + ), + warmHostSupportsRead: (environmentId) => + runtimeEnvironmentSupportsCapability( + environmentId, + SESSION_TERMINAL_SCROLLBACK_READ_RUNTIME_CAPABILITY + ), + readHostSnapshot: async (environmentId, worktreeId, ref, signal) => + ( + await callRuntimeRpc( + { kind: 'environment', environmentId }, + 'session.readTerminalScrollback', + { worktree: toRuntimeWorktreeSelector(worktreeId), ref }, + { timeoutMs: HOST_TERMINAL_SCROLLBACK_HOLD_MS, signal } + ) + ).buffer, + isTabMounted: (tabId) => hasRegisteredRuntimeTerminalTab(tabId), + storeLocalOnly: (tabId, buffersByLeafId) => + useAppStore.getState().setTabLocalOnlyScrollback(tabId, buffersByLeafId) +} + +// Why: a pane snapshot with a definite answer is never asked for again, so it never holds a mount twice. +const settledKeys = new Set() +// Why: a known local copy needs no further stat IPC on remount. +const localSnapshotRefs = new Set() +const inFlightByTabId = new Map>() +const warmingByEnvironmentId = new Map>() + +function attemptKey(environmentId: string, tabId: string, ref: string): string { + return `${environmentId}\0${tabId}\0${ref}` +} + +function hasLocalSnapshotCached(ref: string, deps: HostTerminalScrollbackHydrationDeps): boolean { + if (localSnapshotRefs.has(ref)) { + return true + } + if (!deps.hasLocalSnapshot(ref)) { + return false + } + localSnapshotRefs.add(ref) + return true +} + +function warmCapability( + environmentId: string, + deps: HostTerminalScrollbackHydrationDeps +): Promise { + const warming = warmingByEnvironmentId.get(environmentId) + if (warming) { + return warming + } + const pending = Promise.resolve() + .then(() => deps.warmHostSupportsRead(environmentId)) + .then( + () => {}, + () => {} + ) + .finally(() => warmingByEnvironmentId.delete(environmentId)) + warmingByEnvironmentId.set(environmentId, pending) + return pending +} + +function planHostScrollbackHydration( + tabId: string, + worktreeId: string, + deps: HostTerminalScrollbackHydrationDeps +): HostScrollbackPlan | null { + const { layout, localOnly } = deps.getTabState(tabId) + const refs = Object.entries(layout?.scrollbackRefsByLeafId ?? {}).filter( + ([leafId]) => + // Why: a live PTY repaints its own screen, which replaces restored history anyway. + layout?.ptyIdsByLeafId?.[leafId] === undefined && + layout?.buffersByLeafId?.[leafId] === undefined && + localOnly?.[leafId] === undefined + ) + if (refs.length === 0) { + return null + } + const environmentId = deps.resolveEnvironmentId(worktreeId) + if (!environmentId) { + return null + } + const leaves = refs + .filter( + ([, ref]) => + !settledKeys.has(attemptKey(environmentId, tabId, ref)) && + !hasLocalSnapshotCached(ref, deps) + ) + .map(([leafId, ref]) => ({ leafId, ref })) + if (leaves.length === 0) { + return null + } + // Why hold on an unknown host too: mount is the only replay, so a pane that mounts before the + // status answers (a moved tab opened a minute after the last probe) opens empty for good. + // Only a pane with a ref and no local copy gets here, and a definite answer settles it. + const supported = deps.peekHostSupportsRead(environmentId) + return supported === false ? null : { environmentId, leaves, supported } +} + +async function readLeaf( + tabId: string, + worktreeId: string, + environmentId: string, + leaf: HostScrollbackLeaf, + signal: AbortSignal, + deps: HostTerminalScrollbackHydrationDeps +): Promise { + try { + const buffer = await deps.readHostSnapshot(environmentId, worktreeId, leaf.ref, signal) + return { ...leaf, buffer } + } catch (error) { + if (DEFINITE_READ_FAILURE_CODES.some((code) => hasRuntimeRpcErrorCode(error, code))) { + settledKeys.add(attemptKey(environmentId, tabId, leaf.ref)) + } + return { ...leaf, buffer: null } + } +} + +async function fetchHostScrollback( + tabId: string, + worktreeId: string, + plan: HostScrollbackPlan, + deps: HostTerminalScrollbackHydrationDeps +): Promise { + const controller = new AbortController() + // Why: the hold releases at this bound; bytes after it would land behind a mounted pane. + const deadline = setTimeout(() => controller.abort(), HOST_TERMINAL_SCROLLBACK_HOLD_MS) + let fetched: FetchedHostScrollbackLeaf[] + try { + if (plan.supported === undefined) { + await warmCapability(plan.environmentId, deps) + if (controller.signal.aborted || deps.peekHostSupportsRead(plan.environmentId) !== true) { + return + } + } + fetched = await Promise.all( + plan.leaves.map((leaf) => + readLeaf(tabId, worktreeId, plan.environmentId, leaf, controller.signal, deps) + ) + ) + } finally { + clearTimeout(deadline) + } + // Why: a mounted pane's xterm already owns its bytes; its next park captures them instead. + if (controller.signal.aborted || deps.isTabMounted(tabId)) { + return + } + const { layout, localOnly } = deps.getTabState(tabId) + const next = { ...localOnly } + const stored: HostScrollbackLeaf[] = [] + for (const { leafId, ref, buffer } of fetched) { + if ( + buffer && + layout?.scrollbackRefsByLeafId?.[leafId] === ref && + layout.buffersByLeafId?.[leafId] === undefined && + next[leafId] === undefined + ) { + next[leafId] = buffer + stored.push({ leafId, ref }) + } + } + if (stored.length > 0) { + deps.storeLocalOnly(tabId, next) + for (const { ref } of stored) { + settledKeys.add(attemptKey(plan.environmentId, tabId, ref)) + } + } +} + +/** + * The host to read a pane's stored scrollback from: only a WSL host, whose moved panes keep their + * history as host snapshots. SSH and paired-server panes paint as before, with no hold or read. + * Null for a twin too: no single host owns the stored scrollback, so the pane paints locally. + */ +export function hostScrollbackEnvironmentId( + owner: RuntimeEnvironmentOwnerMatch, + environments: readonly { id: string; orcadDeployment?: OrcadDeploymentLink | null }[] | undefined +): string | null { + if (owner.kind !== 'resolved' || owner.environmentId === null) { + return null + } + const environmentId = owner.environmentId + const environment = environments?.find((candidate) => candidate.id === environmentId) + return environment?.orcadDeployment?.kind === 'wsl' ? environmentId : null +} + +/** + * Starts reading a host-run tab's stored scrollback from its host when this client has no copy, + * so the pane paints its history after a move deleted (or a web client never had) the local one. + * Decides synchronously and holds unless the host is known not to serve the read; returns null + * when there is nothing to fetch. The promise never rejects and settles by the hold bound. + */ +export function startHostTerminalScrollbackHydration( + tabId: string, + worktreeId: string, + deps: HostTerminalScrollbackHydrationDeps = defaultDeps +): Promise | null { + const inFlight = inFlightByTabId.get(tabId) + if (inFlight) { + return inFlight + } + const plan = planHostScrollbackHydration(tabId, worktreeId, deps) + if (!plan) { + return null + } + const pending = fetchHostScrollback(tabId, worktreeId, plan, deps) + .catch(() => {}) + .finally(() => inFlightByTabId.delete(tabId)) + inFlightByTabId.set(tabId, pending) + return pending +} + +export function resetHostTerminalScrollbackHydrationForTests(): void { + settledKeys.clear() + localSnapshotRefs.clear() + inFlightByTabId.clear() + warmingByEnvironmentId.clear() +} diff --git a/src/renderer/src/runtime/runtime-compatibility-cache.ts b/src/renderer/src/runtime/runtime-compatibility-cache.ts new file mode 100644 index 00000000000..dd87effa00b --- /dev/null +++ b/src/renderer/src/runtime/runtime-compatibility-cache.ts @@ -0,0 +1,146 @@ +import type { RuntimeStatus } from '../../../shared/runtime-types' +import type { RuntimeCapability } from '../../../shared/protocol-version' +import { isRuntimeStatusCompatible } from './runtime-protocol-compat' + +const RUNTIME_COMPATIBILITY_CACHE_MAX = 32 +const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000 +// Why: capability verdicts must eventually follow a saved environment's version changes. +export const RUNTIME_CAPABILITY_STATUS_TTL_MS = 60_000 + +export type RuntimeCompatibilityCacheEntry = { + check: Promise + failedAt: number | null + // False while probing so recovery can drop a doomed pending compatibility check. + provenCompatible: boolean + status: RuntimeStatus | null + statusCheckedAt: number | null + // Why: statusCheckedAt is cleared after a negative verdict to force async re-probes; the sync + // peek keeps answering from this so many mounts on an old host do not each fire status.get. + statusObservedAt: number | null +} + +export const runtimeCompatibilityChecks = new Map() + +/** A pending probe by default; an observed status is proof of itself. */ +export function compatibilityEntry( + status: RuntimeStatus | null = null, + provenCompatible = status !== null +): RuntimeCompatibilityCacheEntry { + const statusCheckedAt = status ? Date.now() : null + return { + check: Promise.resolve(), + failedAt: null, + provenCompatible, + status, + statusCheckedAt, + statusObservedAt: statusCheckedAt + } +} + +export function getCachedRuntimeCompatibilityCheck( + environmentId: string, + options: { reuseRecentCompatibilityFailure?: boolean } +): RuntimeCompatibilityCacheEntry | null { + const cached = runtimeCompatibilityChecks.get(environmentId) + if (!cached) { + return null + } + if ( + cached.failedAt !== null && + Date.now() - cached.failedAt >= RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS + ) { + runtimeCompatibilityChecks.delete(environmentId) + return null + } + if (cached.failedAt !== null && options.reuseRecentCompatibilityFailure !== true) { + return null + } + runtimeCompatibilityChecks.delete(environmentId) + runtimeCompatibilityChecks.set(environmentId, cached) + return cached +} + +export function rememberRuntimeEnvironmentCompatibility( + environmentId: string, + entry: RuntimeCompatibilityCacheEntry +): void { + // Why: saved/removed remote runtimes can churn through unique ids in long + // renderer sessions; compatibility cache entries should not grow forever. + runtimeCompatibilityChecks.delete(environmentId) + runtimeCompatibilityChecks.set(environmentId, entry) + while (runtimeCompatibilityChecks.size > RUNTIME_COMPATIBILITY_CACHE_MAX) { + const oldest = runtimeCompatibilityChecks.keys().next().value + if (oldest === undefined) { + break + } + runtimeCompatibilityChecks.delete(oldest) + } +} + +// Why: a live status answer invalidates failures and pending probes from the +// dropped connection; only proven-compatible successes remain reusable. +export function clearRecentRuntimeCompatibilityFailure( + environmentId: string, + observedStatus?: RuntimeStatus +): void { + const trimmed = environmentId.trim() + if (!trimmed) { + return + } + const cached = runtimeCompatibilityChecks.get(trimmed) + if ( + cached && + (!cached.provenCompatible || + (observedStatus && + cached.status !== null && + cached.status.runtimeId !== observedStatus.runtimeId)) + ) { + // Why: a saved endpoint can reconnect to a different runtime version; its predecessor's + // positive capability verdict must not route a structured request to the replacement. + runtimeCompatibilityChecks.delete(trimmed) + // Why (STA-11921): the host publishes a probe's answer before settling it, so the detached + // probe never records it; the published answer is the proof synchronous readers need. + const answersPendingProbe = cached.failedAt === null && !cached.provenCompatible + if (observedStatus && answersPendingProbe && isRuntimeStatusCompatible(observedStatus)) { + rememberRuntimeEnvironmentCompatibility(trimmed, compatibilityEntry(observedStatus)) + } + } +} + +export function clearRuntimeCompatibilityCache(environmentId?: string | null): void { + const trimmed = environmentId?.trim() + if (trimmed) { + runtimeCompatibilityChecks.delete(trimmed) + return + } + runtimeCompatibilityChecks.clear() +} + +export function markRuntimeEnvironmentCompatible(environmentId: string): void { + const trimmed = environmentId.trim() + if (!trimmed) { + return + } + rememberRuntimeEnvironmentCompatibility(trimmed, compatibilityEntry(null, true)) +} + +/** + * Synchronous capability verdict from an already-cached status, positive or negative, within the + * capability TTL; `undefined` when no fresh status is cached. Never issues a request. + */ +export function peekRuntimeEnvironmentCapability( + environmentId: string, + capability: RuntimeCapability +): boolean | undefined { + const cached = runtimeCompatibilityChecks.get(environmentId.trim()) + if ( + !cached || + cached.failedAt !== null || + !cached.status || + cached.statusObservedAt === null || + Date.now() - cached.statusObservedAt >= RUNTIME_CAPABILITY_STATUS_TTL_MS + ) { + return undefined + } + return cached.status.capabilities?.includes(capability) === true +} diff --git a/src/renderer/src/runtime/runtime-rpc-client-capability-peek.test.ts b/src/renderer/src/runtime/runtime-rpc-client-capability-peek.test.ts new file mode 100644 index 00000000000..1f3b4fd185a --- /dev/null +++ b/src/renderer/src/runtime/runtime-rpc-client-capability-peek.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearRuntimeCompatibilityCacheForTests, + peekRuntimeEnvironmentCapability, + runtimeEnvironmentSupportsCapability +} from './runtime-rpc-client' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' + +const CAPABILITY = 'session.terminal-scrollback-read.v1' +const runtimeEnvironmentCall = vi.fn() + +function statusReply(capabilities: string[]) { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities + }, + _meta: { runtimeId: 'remote-runtime' } + } +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeEnvironmentCall, subscribe: vi.fn() } } + }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('peekRuntimeEnvironmentCapability', () => { + it('answers undefined without issuing a request when no status is cached', () => { + expect(peekRuntimeEnvironmentCapability('env-cold', CAPABILITY)).toBeUndefined() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('answers a cached positive verdict synchronously', async () => { + runtimeEnvironmentCall.mockResolvedValue(statusReply([CAPABILITY])) + await runtimeEnvironmentSupportsCapability('env-new', CAPABILITY) + expect(peekRuntimeEnvironmentCapability('env-new', CAPABILITY)).toBe(true) + }) + + it('caches an old host answer for the TTL while async callers still re-probe it', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(0)) + runtimeEnvironmentCall.mockResolvedValue(statusReply([])) + await expect(runtimeEnvironmentSupportsCapability('env-old', CAPABILITY)).resolves.toBe(false) + expect(peekRuntimeEnvironmentCapability('env-old', CAPABILITY)).toBe(false) + vi.setSystemTime(new Date(59_999)) + expect(peekRuntimeEnvironmentCapability('env-old', CAPABILITY)).toBe(false) + expect(runtimeEnvironmentCall).toHaveBeenCalledOnce() + // The async verdict is unchanged: a negative answer still re-probes so an upgrade is seen. + await runtimeEnvironmentSupportsCapability('env-old', CAPABILITY) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(2) + vi.setSystemTime(new Date(59_999 + 59_999)) + expect(peekRuntimeEnvironmentCapability('env-old', CAPABILITY)).toBe(false) + vi.setSystemTime(new Date(59_999 + 60_000)) + expect(peekRuntimeEnvironmentCapability('env-old', CAPABILITY)).toBeUndefined() + }) +}) diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index 84547921988..9f73ca72dd0 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -3,37 +3,27 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../../shared/runtime-types' import type { RuntimeCapability } from '../../../shared/protocol-version' import { withBrowserPaneUiRuntimeRpcSource } from '../../../shared/runtime-rpc-feature-interaction-source' -import { assertRuntimeStatusCompatible, isRuntimeStatusCompatible } from './runtime-protocol-compat' +import { assertRuntimeStatusCompatible } from './runtime-protocol-compat' import { createRuntimeRpcAbortError } from './abortable-runtime-environment-call' import { callRuntimeEnvironmentWithRevision } from './runtime-rpc-environment-call' import { unwrapRuntimeRpcResult } from './runtime-rpc-result' import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment-revision' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { + clearRuntimeCompatibilityCache, + compatibilityEntry, + getCachedRuntimeCompatibilityCheck, + rememberRuntimeEnvironmentCompatibility, + runtimeCompatibilityChecks, + RUNTIME_CAPABILITY_STATUS_TTL_MS +} from './runtime-compatibility-cache' -const RUNTIME_COMPATIBILITY_CACHE_MAX = 32 -const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000 -// Why: capability verdicts must eventually follow a saved environment's version changes. -const RUNTIME_CAPABILITY_STATUS_TTL_MS = 60_000 - -type RuntimeCompatibilityCacheEntry = { - check: Promise - failedAt: number | null - // False while probing so recovery can drop a doomed pending compatibility check. - provenCompatible: boolean - status: RuntimeStatus | null - statusCheckedAt: number | null -} - -const runtimeCompatibilityChecks = new Map() - -/** A pending probe by default; an observed status is proof of itself. */ -function compatibilityEntry( - status: RuntimeStatus | null = null, - provenCompatible = status !== null -): RuntimeCompatibilityCacheEntry { - const statusCheckedAt = status ? Date.now() : null - return { check: Promise.resolve(), failedAt: null, provenCompatible, status, statusCheckedAt } -} +export { + clearRecentRuntimeCompatibilityFailure, + clearRuntimeCompatibilityCache, + markRuntimeEnvironmentCompatible, + peekRuntimeEnvironmentCapability +} from './runtime-compatibility-cache' export async function callRuntimeRpc( target: RuntimeClientTarget, @@ -122,6 +112,7 @@ export async function ensureRuntimeEnvironmentCompatible( assertRuntimeStatusCompatible(status) entry.status = status entry.statusCheckedAt = Date.now() + entry.statusObservedAt = entry.statusCheckedAt })() entry.check = check rememberRuntimeEnvironmentCompatibility(environmentId, entry) @@ -139,90 +130,6 @@ export async function ensureRuntimeEnvironmentCompatible( } } -function getCachedRuntimeCompatibilityCheck( - environmentId: string, - options: { reuseRecentCompatibilityFailure?: boolean } -): RuntimeCompatibilityCacheEntry | null { - const cached = runtimeCompatibilityChecks.get(environmentId) - if (!cached) { - return null - } - if ( - cached.failedAt !== null && - Date.now() - cached.failedAt >= RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS - ) { - runtimeCompatibilityChecks.delete(environmentId) - return null - } - if (cached.failedAt !== null && options.reuseRecentCompatibilityFailure !== true) { - return null - } - runtimeCompatibilityChecks.delete(environmentId) - runtimeCompatibilityChecks.set(environmentId, cached) - return cached -} - -function rememberRuntimeEnvironmentCompatibility( - environmentId: string, - entry: RuntimeCompatibilityCacheEntry -): void { - // Bound compatibility cache across saved/removed environment ID churn in long sessions. - runtimeCompatibilityChecks.delete(environmentId) - runtimeCompatibilityChecks.set(environmentId, entry) - while (runtimeCompatibilityChecks.size > RUNTIME_COMPATIBILITY_CACHE_MAX) { - const oldest = runtimeCompatibilityChecks.keys().next().value - if (oldest === undefined) { - break - } - runtimeCompatibilityChecks.delete(oldest) - } -} - -// Live status invalidates dead-connection failures/pending probes; only proven-compatible success remains reusable. -export function clearRecentRuntimeCompatibilityFailure( - environmentId: string, - observedStatus?: RuntimeStatus -): void { - const trimmed = environmentId.trim() - if (!trimmed) { - return - } - const cached = runtimeCompatibilityChecks.get(trimmed) - if ( - cached && - (!cached.provenCompatible || - (observedStatus && - cached.status !== null && - cached.status.runtimeId !== observedStatus.runtimeId)) - ) { - // Replacement runtimes can be older; predecessor capability proof must not authorize structured calls. - runtimeCompatibilityChecks.delete(trimmed) - // Why (STA-11921): the host publishes a probe's answer before settling it, so the detached - // probe never records it; the published answer is the proof synchronous readers need. - const answersPendingProbe = cached.failedAt === null && !cached.provenCompatible - if (observedStatus && answersPendingProbe && isRuntimeStatusCompatible(observedStatus)) { - rememberRuntimeEnvironmentCompatibility(trimmed, compatibilityEntry(observedStatus)) - } - } -} - -export function clearRuntimeCompatibilityCache(environmentId?: string | null): void { - const trimmed = environmentId?.trim() - if (trimmed) { - runtimeCompatibilityChecks.delete(trimmed) - return - } - runtimeCompatibilityChecks.clear() -} - -export function markRuntimeEnvironmentCompatible(environmentId: string): void { - const trimmed = environmentId.trim() - if (!trimmed) { - return - } - rememberRuntimeEnvironmentCompatibility(trimmed, compatibilityEntry(null, true)) -} - export async function getRuntimeEnvironmentStatus( environmentId: string, timeoutMs?: number @@ -242,6 +149,7 @@ export async function getRuntimeEnvironmentStatus( assertRuntimeStatusCompatible(status) entry.status = status entry.statusCheckedAt = Date.now() + entry.statusObservedAt = entry.statusCheckedAt entry.provenCompatible = true })() entry.check = check diff --git a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts index a13785267e8..9f0581b9db9 100644 --- a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts @@ -33,6 +33,10 @@ import { import { getRuntimeTargetHostId } from '../../runtime/runtime-client-target' import { mergeFetchedHostProjectCatalog } from '../../../../shared/project-compatibility-host-merge' import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' +import { + withWslMovedRepoIds, + withWslMovedSetupScriptDismissals +} from './wsl-moved-repo-ui-retention' export function createAllHostRepoCatalogActions( set: Parameters>[0], @@ -117,15 +121,24 @@ export function createAllHostRepoCatalogActions( set((s) => { const validRepoIds = new Set(s.repos.map((repo) => repo.id)) const validRepoHostIdentities = new Set(s.repos.map(getRepoHostIdentity)) + // A moved repo's host may be the catalog that didn't load (e.g. its WSL environment). + const retainedRepoIds = withWslMovedRepoIds(validRepoIds, s.wslMovedProjects) return { activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), - setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( + filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, retainedRepoIds), + setupScriptPromptDismissedRepoIds: withWslMovedSetupScriptDismissals( s.setupScriptPromptDismissedRepoIds, - validRepoHostIdentities + filterSetupScriptPromptDismissalsToValidRepos( + s.setupScriptPromptDismissedRepoIds, + validRepoHostIdentities + ), + s.wslMovedProjects ), - trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(s.trustedOrcaHooks, validRepoIds) + trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos( + s.trustedOrcaHooks, + retainedRepoIds + ) } }) } diff --git a/src/renderer/src/store/repos/repo-catalog-actions.ts b/src/renderer/src/store/repos/repo-catalog-actions.ts index be3d86963bb..fbfbf4e1950 100644 --- a/src/renderer/src/store/repos/repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/repo-catalog-actions.ts @@ -36,6 +36,10 @@ import { } from '../../../../shared/project-compatibility-merge' import { mergeFetchedHostProjectCatalog } from '../../../../shared/project-compatibility-host-merge' import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' +import { + withWslMovedRepoIds, + withWslMovedSetupScriptDismissals +} from './wsl-moved-repo-ui-retention' export function createRepoCatalogActions( set: Parameters>[0], @@ -135,10 +139,17 @@ export function createRepoCatalogActions( : { folderWorkspacePathStatuses: {} }), activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), - setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( + filterRepoIds: retainValidFilterRepoIds( + s.filterRepoIds, + withWslMovedRepoIds(validRepoIds, s.wslMovedProjects) + ), + setupScriptPromptDismissedRepoIds: withWslMovedSetupScriptDismissals( s.setupScriptPromptDismissedRepoIds, - validRepoHostIdentities + filterSetupScriptPromptDismissalsToValidRepos( + s.setupScriptPromptDismissedRepoIds, + validRepoHostIdentities + ), + s.wslMovedProjects ) } }) diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 7d4b4a9f8d5..6f2c62a1822 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -29,6 +29,7 @@ import type { FolderWorkspacePathStatusRequest } from '../../../../shared/folder-workspace-path-status' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { WslMovedProject } from '../../../../shared/wsl-move/wsl-moved-project' import type { ConfirmedUnsavedDiscards } from '../slices/worktrees/teardown/removed-workspace-unsaved-files' export const ERROR_TOAST_DURATION = 60_000 @@ -168,6 +169,9 @@ export type RepoSlice = { projectHostSetups: readonly ProjectHostSetup[] projectGroups: readonly ProjectGroup[] folderWorkspaces: readonly FolderWorkspace[] + /** Local projects a WSL host now owns: never in `repos`/`folderWorkspaces`, read-only, chats only. */ + wslMovedProjects: readonly WslMovedProject[] + fetchWslMovedProjects: () => Promise folderWorkspacePathStatuses: Record /** Host-resolved floating workspace directory; null until the host has answered. */ floatingWorkspacePath: string | null diff --git a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts index 0b1a8c6f8d5..f9887693ec8 100644 --- a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts @@ -21,6 +21,10 @@ import { import { getRuntimeTargetHostId } from '../../runtime/runtime-client-target' import { mergeFetchedHostProjectCatalog } from '../../../../shared/project-compatibility-host-merge' import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' +import { + withWslMovedRepoIds, + withWslMovedSetupScriptDismissals +} from './wsl-moved-repo-ui-retention' export function createRuntimeRepoCatalogActions( set: Parameters>[0], @@ -109,10 +113,17 @@ export function createRuntimeRepoCatalogActions( ...mergedProjectCompatibility, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), - setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( + filterRepoIds: retainValidFilterRepoIds( + s.filterRepoIds, + withWslMovedRepoIds(validRepoIds, s.wslMovedProjects) + ), + setupScriptPromptDismissedRepoIds: withWslMovedSetupScriptDismissals( s.setupScriptPromptDismissedRepoIds, - validRepoHostIdentities + filterSetupScriptPromptDismissalsToValidRepos( + s.setupScriptPromptDismissedRepoIds, + validRepoHostIdentities + ), + s.wslMovedProjects ) } }) diff --git a/src/renderer/src/store/repos/wsl-moved-local-session-rows.test.ts b/src/renderer/src/store/repos/wsl-moved-local-session-rows.test.ts new file mode 100644 index 00000000000..316fd962692 --- /dev/null +++ b/src/renderer/src/store/repos/wsl-moved-local-session-rows.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import type { WslMovedProject } from '../../../../shared/wsl-move/wsl-moved-project' +import { fetchWorkspaceSessionWithRuntimeHostOwners } from '@/lib/workspace-session-host-hydration' +import { withoutWslMovedLocalRows, wslMovedSessionReadApi } from './wsl-moved-local-session-rows' + +const HOST: ExecutionHostId = 'runtime:wsl%3AUbuntu' +const LOCAL_ID = 'repo-a::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app' +const HOST_ID = 'repo-a::/home/dev/app' +const STAYING_ID = 'repo-b::C:\\src\\b' +const MOVED: WslMovedProject[] = [ + { + kind: 'repo', + id: 'repo-a', + displayName: 'app', + path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\app', + distro: 'Ubuntu' + } +] + +function terminal(id: string, worktreeId: string, title: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function tab(id: string, worktreeId: string, contentType: Tab['contentType']): Tab { + return { + id, + entityId: id, + groupId: 'group-1', + worktreeId, + contentType, + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function group(worktreeId: string, tabOrder: string[]): TabGroup { + return { id: 'group-1', worktreeId, activeTabId: tabOrder[0] ?? null, tabOrder } +} + +/** One workspace's terminal `tab-1` (titled by partition) plus any extra tab-bar tabs. */ +function workspaceSession( + worktreeId: string, + title: string, + extra: Tab[] = [] +): WorkspaceSessionState { + const tabs = [tab('tab-1', worktreeId, 'terminal'), ...extra] + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [terminal('tab-1', worktreeId, title)] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: `${title}-leaf` }, + activeLeafId: `${title}-leaf`, + expandedLeafId: null + } + }, + unifiedTabs: { [worktreeId]: tabs }, + tabGroups: { + [worktreeId]: [ + group( + worktreeId, + tabs.map((entry) => entry.id) + ) + ] + }, + activeTabIdByWorktree: { [worktreeId]: 'tab-1' } + } +} + +function localPartition(): WorkspaceSessionState { + const moved = workspaceSession(LOCAL_ID, 'local', [tab('chat-1', LOCAL_ID, 'agent-session')]) + const staying = workspaceSession(STAYING_ID, 'staying') + return { + ...moved, + tabsByWorktree: { + ...moved.tabsByWorktree, + [STAYING_ID]: [terminal('tab-9', STAYING_ID, 'staying')] + }, + terminalLayoutsByTabId: { + ...moved.terminalLayoutsByTabId, + 'tab-9': staying.terminalLayoutsByTabId['tab-1'] + }, + unifiedTabs: { ...moved.unifiedTabs, [STAYING_ID]: [tab('tab-9', STAYING_ID, 'terminal')] }, + tabGroups: { ...moved.tabGroups, [STAYING_ID]: [group(STAYING_ID, ['tab-9'])] } + } +} + +describe('WSL moved local session rows', () => { + it("keeps only a moved workspace's chats, and every other workspace whole", () => { + const local = withoutWslMovedLocalRows(localPartition(), MOVED) + + expect(local.tabsByWorktree).toEqual({ + [STAYING_ID]: [terminal('tab-9', STAYING_ID, 'staying')] + }) + expect(Object.keys(local.terminalLayoutsByTabId)).toEqual(['tab-9']) + expect(local.unifiedTabs?.[LOCAL_ID]?.map((entry) => entry.id)).toEqual(['chat-1']) + expect(local.tabGroups?.[LOCAL_ID]?.[0]).toMatchObject({ + tabOrder: ['chat-1'], + activeTabId: 'chat-1' + }) + expect(local.activeTabIdByWorktree?.[LOCAL_ID]).toBeUndefined() + expect(local.unifiedTabs?.[STAYING_ID]?.map((entry) => entry.id)).toEqual(['tab-9']) + }) + + it('leaves the partition alone when nothing moved', () => { + const local = localPartition() + + expect(withoutWslMovedLocalRows(local, [])).toBe(local) + }) + + it("hydrates one tab-1, the host's, when both partitions hold it; the local chat stays", async () => { + const read = await fetchWorkspaceSessionWithRuntimeHostOwners( + wslMovedSessionReadApi(MOVED, { + get: vi.fn(async (hostId?: ExecutionHostId) => + hostId === HOST ? workspaceSession(HOST_ID, 'host') : localPartition() + ) + }), + [], + [HOST] + ) + const { session } = read + + expect(session.terminalLayoutsByTabId['tab-1']?.activeLeafId).toBe('host-leaf') + expect(session.tabsByWorktree[HOST_ID]?.map((entry) => entry.title)).toEqual(['host']) + expect(session.tabsByWorktree[LOCAL_ID]).toBeUndefined() + const tabOneOwners = Object.entries(session.unifiedTabs ?? {}).flatMap(([owner, tabs]) => + tabs.some((entry) => entry.id === 'tab-1') ? [owner] : [] + ) + expect(tabOneOwners).toEqual([HOST_ID]) + expect(session.unifiedTabs?.[LOCAL_ID]?.map((entry) => entry.id)).toEqual(['chat-1']) + expect(read.runtimeHostIdByWorkspaceSessionKey[HOST_ID]).toBe(HOST) + }) +}) diff --git a/src/renderer/src/store/repos/wsl-moved-local-session-rows.ts b/src/renderer/src/store/repos/wsl-moved-local-session-rows.ts new file mode 100644 index 00000000000..fe9ae78c4f2 --- /dev/null +++ b/src/renderer/src/store/repos/wsl-moved-local-session-rows.ts @@ -0,0 +1,71 @@ +/** + * The local partition as startup reads it once WSL hosts own some of its projects: a moved + * workspace keeps only its native chat tabs (owner decision, option C). Its terminals, editors and + * browser tabs are retained on disk for a downgrade, but the host's partition now holds the live + * copies under the same tab ids, so they must leave before the host partitions merge in. + */ +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { normalizeWorkspaceSessionKeyToWorkspaceId } from '../../../../shared/workspace-scope' +import { + filterWorkspaceRows, + workspaceIdsNamedByPartition +} from '../../../../shared/workspace-session-stranded-partition-adoption' +import { filterWorkspaceSessionTabs } from '../../../../shared/workspace-session-tab-filter' +import type { WslMovedProject } from '../../../../shared/wsl-move/wsl-moved-project' +import { mergeWorkspaceSessionRecordField } from '../../../../shared/workspace-session-host-records' +import { isWslMovedChatTab, movedProjectForKey } from './wsl-moved-projects' + +// The tab bar alone: hydration picks a chat-only workspace's focus from its groups. +const CHAT_TAB_FIELDS = [ + 'unifiedTabs', + 'tabGroups', + 'tabGroupLayouts' +] as const satisfies readonly (keyof WorkspaceSessionState)[] + +export function withoutWslMovedLocalRows( + local: WorkspaceSessionState, + projects: readonly WslMovedProject[] +): WorkspaceSessionState { + if (projects.length === 0) { + return local + } + const moved = new Set( + [...workspaceIdsNamedByPartition(local)].filter( + (workspaceId) => movedProjectForKey(projects, workspaceId) !== null + ) + ) + if (moved.size === 0) { + return local + } + const isMoved = (owner: string): boolean => + moved.has(normalizeWorkspaceSessionKeyToWorkspaceId(owner)) + const chats = filterWorkspaceSessionTabs( + local, + (owner, tab) => !isMoved(owner) || isWslMovedChatTab(tab) + ) + const rest = structuredClone(filterWorkspaceRows(chats, (workspaceId) => !moved.has(workspaceId))) + const withChats = new Set( + Object.entries(chats.unifiedTabs ?? {}).flatMap(([owner, tabs]) => + isMoved(owner) && tabs.length > 0 ? [normalizeWorkspaceSessionKeyToWorkspaceId(owner)] : [] + ) + ) + const chatRows = filterWorkspaceRows(chats, (workspaceId) => withChats.has(workspaceId)) + for (const field of CHAT_TAB_FIELDS) { + mergeWorkspaceSessionRecordField(rest, field, chatRows) + } + return rest +} + +type SessionRead = { get: (hostId?: ExecutionHostId) => Promise } + +/** The session read startup hydrates from, with moved workspaces' retained local rows left out. */ +export function wslMovedSessionReadApi( + projects: readonly WslMovedProject[], + api: SessionRead = window.api.session +): SessionRead { + return { + get: async (hostId) => + hostId === undefined ? withoutWslMovedLocalRows(await api.get(), projects) : api.get(hostId) + } +} diff --git a/src/renderer/src/store/repos/wsl-moved-projects.ts b/src/renderer/src/store/repos/wsl-moved-projects.ts new file mode 100644 index 00000000000..5a4c89b4dd5 --- /dev/null +++ b/src/renderer/src/store/repos/wsl-moved-projects.ts @@ -0,0 +1,179 @@ +/** + * Local projects a WSL host now owns (owner decision, option C): their existing native chats stay + * on this computer, listed under a read-only entry. Their terminals, editors and browser tabs live + * on the host now, so only `agent-session` tabs of these workspaces survive here. + * + * The host's own rows carry the same project ids, so a workspace counts as moved only when it is + * not one the catalog lists: a listed worktree or folder of that id is the host's. + */ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { Tab } from '../../../../shared/tab-types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import type { WslMovedProject } from '../../../../shared/wsl-move/wsl-moved-project' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { + getRepoIdFromWorktreeId, + splitWorktreeIdForFilesystem +} from '../../../../shared/worktree/id' +import { findWorktreeById } from '../slices/worktree-helpers' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '../workspace-record-selectors' + +type MovedCatalog = Pick + +export async function fetchWslMovedProjects( + set: Parameters>[0] +): Promise { + const list = window.api?.repos?.listWslMoved + if (typeof list !== 'function') { + return + } + try { + const projects = await list() + set((state) => + sameMovedProjects(state.wslMovedProjects, projects) ? state : { wslMovedProjects: projects } + ) + } catch (error) { + // Without the list the moved chats stay hidden, as before this entry existed; nothing breaks. + console.warn('[wsl-move] Could not list moved projects:', error) + } +} + +function sameMovedProjects( + left: readonly WslMovedProject[], + right: readonly WslMovedProject[] +): boolean { + return ( + left.length === right.length && + left.every((project, index) => { + const other = right[index] + return ( + project.kind === other.kind && + project.id === other.id && + project.displayName === other.displayName && + project.path === other.path && + project.distro === other.distro + ) + }) + ) +} + +/** The moved project a session key names by id alone, before the catalog says whose it is. */ +export function movedProjectForKey( + projects: readonly WslMovedProject[], + workspaceKey: string +): WslMovedProject | null { + if (projects.length === 0) { + return null + } + const scope = parseWorkspaceKey(workspaceKey) + if (scope?.type === 'folder') { + const { folderWorkspaceId } = scope + return projects.find((p) => p.kind === 'folder' && p.id === folderWorkspaceId) ?? null + } + const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceKey + const repoId = getRepoIdFromWorktreeId(worktreeId) + return projects.find((p) => p.kind === 'repo' && p.id === repoId) ?? null +} + +/** The moved project a workspace belongs to; null for every listed workspace, the host's included. */ +export function findWslMovedProjectForWorkspace( + state: MovedCatalog, + workspaceKey: string +): WslMovedProject | null { + const project = movedProjectForKey(state.wslMovedProjects, workspaceKey) + if (!project) { + return null + } + if (project.kind === 'folder') { + return selectFolderWorkspaces(state).some((folder) => folder.id === project.id) ? null : project + } + const scope = parseWorkspaceKey(workspaceKey) + const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceKey + return findWorktreeById(selectWorktreesByRepo(state), worktreeId) ? null : project +} + +export function isWslMovedChatTab(tab: Pick): boolean { + return tab.contentType === 'agent-session' +} + +/** + * This computer's session keys of moved projects, read before the catalog decides anything: a + * paired host's partition is that host's own workspace, never the retained local one. + */ +export function collectWslMovedWorkspaceKeysFromSession( + session: Pick, + projects: readonly WslMovedProject[], + runtimeHostIdByWorkspaceSessionKey?: Readonly> +): string[] { + return Object.keys(session.unifiedTabs ?? {}).filter( + (key) => + movedProjectForKey(projects, key) !== null && + parseExecutionHostId(runtimeHostIdByWorkspaceSessionKey?.[key])?.kind !== 'runtime' + ) +} + +/** + * Every moved project, in list order, with its workspaces holding chats. A project without chats + * still lists: its entry is the only way to "Use on this computer again". + */ +export function selectWslMovedChatWorkspaces( + state: MovedCatalog & Pick +): { project: WslMovedProject; workspaces: { key: string; chats: Tab[] }[] }[] { + if (state.wslMovedProjects.length === 0) { + return [] + } + const byProject = new Map() + for (const [key, tabs] of Object.entries(state.unifiedTabsByWorktree)) { + const chats = tabs.filter(isWslMovedChatTab) + const project = chats.length > 0 ? findWslMovedProjectForWorkspace(state, key) : null + if (project) { + byProject.set(project, [...(byProject.get(project) ?? []), { key, chats }]) + } + } + return state.wslMovedProjects.map((project) => ({ + project, + workspaces: byProject.get(project) ?? [] + })) +} + +export type WslMovedWorkspaceSurface = { id: string; path: string } + +/** + * The workbench mounts only catalog workspaces, so a moved one needs its own surface to show its + * chats. Serialized so tab churn elsewhere returns an equal string and re-projects nothing. + */ +export function selectWslMovedSurfacesKey( + state: MovedCatalog & Pick +): string { + if (state.wslMovedProjects.length === 0) { + return '' + } + const surfaces: WslMovedWorkspaceSurface[] = [] + for (const key of Object.keys(state.unifiedTabsByWorktree)) { + const project = findWslMovedProjectForWorkspace(state, key) + if (project) { + const path = + project.kind === 'folder' + ? project.path + : (splitWorktreeIdForFilesystem(key)?.worktreePath ?? project.path) + surfaces.push({ id: key, path }) + } + } + return surfaces.length > 0 ? JSON.stringify(surfaces) : '' +} + +export function parseWslMovedSurfacesKey(surfacesKey: string): WslMovedWorkspaceSurface[] { + if (!surfacesKey) { + return [] + } + const parsed: unknown = JSON.parse(surfacesKey) + return Array.isArray(parsed) + ? parsed.filter( + (entry): entry is WslMovedWorkspaceSurface => + typeof entry?.id === 'string' && typeof entry?.path === 'string' + ) + : [] +} diff --git a/src/renderer/src/store/repos/wsl-moved-repo-ui-retention.test.ts b/src/renderer/src/store/repos/wsl-moved-repo-ui-retention.test.ts new file mode 100644 index 00000000000..ea598563dc3 --- /dev/null +++ b/src/renderer/src/store/repos/wsl-moved-repo-ui-retention.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import type { Repo } from '../../../../shared/repo-types' +import type { WslMovedProject } from '../../../../shared/wsl-move/wsl-moved-project' +import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt' +import { createTestStore } from '../slices/store-test-helpers' +import { createUIStore, makePersistedUI } from '../slices/ui-slice-test-harness' +import { + withWslMovedRepoIds, + withWslMovedSetupScriptDismissals +} from './wsl-moved-repo-ui-retention' + +const moved: WslMovedProject = { + kind: 'repo', + id: 'moved-repo', + displayName: 'Moved', + path: '\\\\wsl.localhost\\Ubuntu\\home\\u\\moved', + distro: 'Ubuntu' +} +const movedFolder: WslMovedProject = { ...moved, kind: 'folder', id: 'moved-folder' } +const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 +} + +const hostDismissal = getSetupScriptPromptDismissalKey('runtime:wsl-env\0moved-repo') +const localDismissal = getSetupScriptPromptDismissalKey('local\0moved-repo') +const staleDismissal = getSetupScriptPromptDismissalKey('local\0stale-repo') +const trust = (approvedAt: number) => ({ all: { approvedAt } }) + +describe('WSL moved repo UI retention', () => { + it('returns its input unchanged without a moved repo', () => { + const valid = new Set(['local-repo']) + expect(withWslMovedRepoIds(valid, [])).toBe(valid) + expect(withWslMovedRepoIds(valid, undefined)).toBe(valid) + expect(withWslMovedRepoIds(valid, [movedFolder])).toBe(valid) + const retained = ['a'] + expect(withWslMovedSetupScriptDismissals([staleDismissal], retained, [])).toBe(retained) + }) + + it('adds the moved repos, also when no other repo is listed', () => { + expect([...withWslMovedRepoIds(new Set(['local-repo']), [moved])]).toEqual([ + 'local-repo', + 'moved-repo' + ]) + expect([...withWslMovedRepoIds(new Set(), [moved])]).toEqual(['moved-repo']) + }) + + it('hands back the persisted dismissals when nothing was pruned', () => { + const persisted = [hostDismissal, localDismissal] + expect(withWslMovedSetupScriptDismissals(persisted, [], [moved])).toBe(persisted) + }) + + it('keeps every dismissal of a moved repo, whichever host it names', () => { + expect( + withWslMovedSetupScriptDismissals( + [hostDismissal, localDismissal, staleDismissal], + [], + [moved] + ) + ).toEqual([hostDismissal, localDismissal]) + }) +}) + +describe('catalog prune with a moved project', () => { + beforeEach(() => { + vi.stubGlobal('window', { + api: { + repos: { list: vi.fn().mockResolvedValue([localRepo]) }, + ui: { set: vi.fn().mockResolvedValue(undefined) }, + projects: { + list: vi.fn().mockResolvedValue([]), + listHostSetups: vi.fn().mockResolvedValue([]) + }, + // The WSL environment is missing from this load, so the moved repo's host row is too. + runtimeEnvironments: { list: vi.fn().mockResolvedValue([]), call: vi.fn() } + }, + dispatchEvent: vi.fn() + }) + }) + + const seeded = { + filterRepoIds: ['moved-repo', 'stale-repo'], + setupScriptPromptDismissedRepoIds: [hostDismissal, staleDismissal], + trustedOrcaHooks: { 'moved-repo': trust(1), 'stale-repo': trust(2) } + } + + it('keeps the moved repo filter, trust and dismissals when its host is absent', async () => { + const store = createTestStore() + store.setState({ ...seeded, wslMovedProjects: [moved] }) + await store.getState().fetchReposForAllHosts() + expect(store.getState().filterRepoIds).toEqual(['moved-repo']) + expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([hostDismissal]) + expect(store.getState().trustedOrcaHooks).toEqual({ 'moved-repo': trust(1) }) + }) + + it('keeps them when the moved repo was the only one listed', async () => { + window.api.repos.list = vi.fn().mockResolvedValue([]) + const store = createTestStore() + store.setState({ ...seeded, wslMovedProjects: [moved] }) + await store.getState().fetchReposForAllHosts() + expect(store.getState().filterRepoIds).toEqual(['moved-repo']) + expect(store.getState().trustedOrcaHooks).toEqual({ 'moved-repo': trust(1) }) + }) + + it('keeps the moved repo filter and dismissals through a local-only refresh', async () => { + const store = createTestStore() + store.setState({ ...seeded, wslMovedProjects: [moved], settings: getDefaultSettings('/home') }) + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) + expect(store.getState().filterRepoIds).toEqual(['moved-repo']) + expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([hostDismissal]) + }) + + it('prunes exactly as before without a moved project', async () => { + const store = createTestStore() + store.setState(seeded) + await store.getState().fetchReposForAllHosts() + expect(store.getState().filterRepoIds).toEqual([]) + expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([]) + expect(store.getState().trustedOrcaHooks).toEqual({}) + }) + + it('keeps them through a UI re-hydration too, and only for the moved repo', () => { + const ui = makePersistedUI({ + ...seeded, + agentsFilterRepoIds: ['moved-repo', 'stale-repo'] + }) + const withMove = createUIStore() + withMove.setState({ repos: [localRepo], wslMovedProjects: [moved] }) + withMove.getState().hydratePersistedUI(ui) + expect(withMove.getState().filterRepoIds).toEqual(['moved-repo']) + expect(withMove.getState().agentsFilterRepoIds).toEqual(['moved-repo']) + expect(withMove.getState().trustedOrcaHooks).toEqual({ 'moved-repo': trust(1) }) + expect(withMove.getState().setupScriptPromptDismissedRepoIds).toEqual([hostDismissal]) + + const without = createUIStore() + without.setState({ repos: [localRepo] }) + without.getState().hydratePersistedUI(ui) + expect(without.getState().filterRepoIds).toEqual([]) + expect(without.getState().agentsFilterRepoIds).toEqual([]) + expect(without.getState().trustedOrcaHooks).toEqual({}) + expect(without.getState().setupScriptPromptDismissedRepoIds).toEqual([]) + }) +}) diff --git a/src/renderer/src/store/repos/wsl-moved-repo-ui-retention.ts b/src/renderer/src/store/repos/wsl-moved-repo-ui-retention.ts new file mode 100644 index 00000000000..d8a60056629 --- /dev/null +++ b/src/renderer/src/store/repos/wsl-moved-repo-ui-retention.ts @@ -0,0 +1,53 @@ +/** + * A moved project's repo-scoped UI (filters, hook trust, setup-prompt dismissals) survives a + * catalog prune: its local row is hidden once the WSL host owns it, and the host's row is missing + * whenever that host's catalog has not loaded. Without a moved project every result is the input. + */ +import type { WslMovedProject } from '../../../../shared/wsl-move/wsl-moved-project' +import { + getSetupScriptPromptDismissalRepoId, + sanitizeSetupScriptPromptDismissals +} from '@/lib/setup-script-prompt' + +// Undefined in partial test stores and older state shapes; treated as no moved project. +type MovedProjects = readonly WslMovedProject[] | undefined + +function movedRepoIds(projects: MovedProjects): Set { + return new Set( + (projects ?? []).flatMap((project) => (project.kind === 'repo' ? [project.id] : [])) + ) +} + +/** `validRepoIds` plus the moved repos. */ +export function withWslMovedRepoIds( + validRepoIds: Set, + projects: MovedProjects +): Set { + const missing = [...movedRepoIds(projects)].filter((id) => !validRepoIds.has(id)) + return missing.length === 0 ? validRepoIds : new Set([...validRepoIds, ...missing]) +} + +/** `retained` plus every persisted dismissal of a moved repo, whichever host it names. */ +export function withWslMovedSetupScriptDismissals( + persisted: unknown, + retained: readonly string[], + projects: MovedProjects +): readonly string[] { + const moved = movedRepoIds(projects) + if (moved.size === 0) { + return retained + } + const kept = sanitizeSetupScriptPromptDismissals(persisted).filter( + (entry) => !retained.includes(entry) && moved.has(getSetupScriptPromptDismissalRepoId(entry)) + ) + if (kept.length === 0) { + return retained + } + const next = [...retained, ...kept] + // Nothing pruned: hand back the persisted array, so subscribers see no change. + return Array.isArray(persisted) && + persisted.length === next.length && + next.every((entry) => persisted.includes(entry)) + ? persisted + : next +} diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 543607cd7a3..aacac43f136 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -16,6 +16,7 @@ import { createRepoRemovalActions } from '../repos/repo-removal' import { createProjectUpdateActions } from '../projects/project-update' import { createRepoUpdateActions } from '../repos/repo-update' import { createRepoOrderingActions } from '../repos/repo-ordering' +import { fetchWslMovedProjects } from '../repos/wsl-moved-projects' export const createRepoSlice: StateCreator = (set, get) => { const repoCatalogActions = createRepoCatalogActions(set, get) @@ -39,6 +40,8 @@ export const createRepoSlice: StateCreator = (set, projectHostSetups: [], projectGroups: [], folderWorkspaces: [], + wslMovedProjects: [], + fetchWslMovedProjects: () => fetchWslMovedProjects(set), folderWorkspacePathStatuses: {}, floatingWorkspacePath: null, setFloatingWorkspacePath: (path) => diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index d0466edd337..59ea5ca5380 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -61,12 +61,16 @@ function pruneLayoutNodeForGroups( return { ...root, first, second } } -/** Tabs come from the tab bar alone; terminal rows it lacks are adopted later by reconciliation. */ +/** + * Tabs come from the tab bar alone; terminal rows it lacks are adopted later by reconciliation. + * A chat-only workspace (a project a WSL host now owns) keeps just its native chat tabs. + */ export function buildHydratedTabState( session: WorkspaceSessionState, validWorktreeIds: Set, /** A diff, conflict or check tab a frame build keeps: its frame carries the tab's view. */ - keepsTransientTab: (worktreeId: string, entityId: string) => boolean = () => false + keepsTransientTab: (worktreeId: string, entityId: string) => boolean = () => false, + chatOnlyWorktreeIds: ReadonlySet = new Set() ): HydratedTabState { const tabsByWorktree: Record = {} const groupsByWorktree: Record = {} @@ -76,7 +80,8 @@ export function buildHydratedTabState( const persistedEditFileIdsByWorktree = getPersistedEditFileIdsByWorktree(session) for (const [worktreeId, tabs] of Object.entries(session.unifiedTabs ?? {})) { - if (!validWorktreeIds.has(worktreeId)) { + const chatOnly = chatOnlyWorktreeIds.has(worktreeId) + if (!validWorktreeIds.has(worktreeId) && !chatOnly) { continue } if (tabs.length === 0) { @@ -120,6 +125,9 @@ export function buildHydratedTabState( } }) .filter((tab) => { + if (chatOnly && tab.contentType !== 'agent-session') { + return false + } if (tab.contentType === 'terminal') { // Why: old web-client sessions could persist host surface ids // containing "::"; those are invalid pane-key tab ids. @@ -138,7 +146,7 @@ export function buildHydratedTabState( } for (const [worktreeId, groups] of Object.entries(session.tabGroups ?? {})) { - if (!validWorktreeIds.has(worktreeId)) { + if (!validWorktreeIds.has(worktreeId) && !chatOnlyWorktreeIds.has(worktreeId)) { continue } if (groups.length === 0) { diff --git a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts index 124a0787970..0241b9d81ac 100644 --- a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts @@ -87,7 +87,12 @@ export function createTabsSessionActions( validWorktreeIds.add(folderWorkspaceKey(workspace.id)) } addAdditionalValidWorkspaceKeys(validWorktreeIds, options) - const hydrated = buildHydratedTabState(session, validWorktreeIds) + const hydrated = buildHydratedTabState( + session, + validWorktreeIds, + undefined, + new Set(options?.wslMovedChatWorkspaceKeys) + ) if (!options?.replaceWorkspaceKeys) { set(hydrated) return diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index 6dbd471001f..f4fd19fab0d 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -44,6 +44,10 @@ import { } from '../../../lib/setup-script-prompt' import { isBundledPetId, DEFAULT_PET_ID } from '../../../components/pet/pet-models' import { getRepoHostIdentity } from '../repo-host-identity' +import { + withWslMovedRepoIds, + withWslMovedSetupScriptDismissals +} from '../../repos/wsl-moved-repo-ui-retention' import type { PersistedUIWriteBaseline } from '../persisted-ui-write-baseline' import { capturePersistedUIWriteBaseline, @@ -75,7 +79,12 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par set((s) => { const manualRepoOrder = normalizeManualRepoOrder(ui.manualRepoOrder) const orderedRepos = applyManualRepoOrder(s.repos, manualRepoOrder) - const validRepoIds = new Set(s.repos.map((repo) => repo.id)) + const listedRepoIds = new Set(s.repos.map((repo) => repo.id)) + // An empty catalog keeps everything; otherwise a moved repo whose host has not loaded stays. + const validRepoIds = + listedRepoIds.size === 0 + ? listedRepoIds + : withWslMovedRepoIds(listedRepoIds, s.wslMovedProjects) const validRepoHostIdentities = new Set(s.repos.map(getRepoHostIdentity)) const persistedFilterRepoIds = sanitizePersistedRepoIds(ui.filterRepoIds) const persistedAgentsFilterRepoIds = sanitizePersistedRepoIds(ui.agentsFilterRepoIds) @@ -224,9 +233,13 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par setupScriptPromptDismissedRepoIds: validRepoHostIdentities.size === 0 ? sanitizeSetupScriptPromptDismissals(ui.setupScriptPromptDismissedRepoIds) - : filterSetupScriptPromptDismissalsToValidRepos( + : withWslMovedSetupScriptDismissals( ui.setupScriptPromptDismissedRepoIds, - validRepoHostIdentities + filterSetupScriptPromptDismissalsToValidRepos( + ui.setupScriptPromptDismissedRepoIds, + validRepoHostIdentities + ), + s.wslMovedProjects ), setupGuideSidebarDismissed: ui.setupGuideSidebarDismissed === true, setupGuideBrowserMilestoneMigrated: ui.setupGuideBrowserMilestoneMigrated === true, diff --git a/src/renderer/src/store/slices/worktrees/listing/hydrated-worktree-purge.ts b/src/renderer/src/store/slices/worktrees/listing/hydrated-worktree-purge.ts index 1837851a464..cb877c7dd95 100644 --- a/src/renderer/src/store/slices/worktrees/listing/hydrated-worktree-purge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/hydrated-worktree-purge.ts @@ -3,6 +3,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constant import { getRepoExecutionHostId } from '../../../../../../shared/execution-host' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../../../../../shared/worktree/id' +import { findWslMovedProjectForWorkspace } from '../../../repos/wsl-moved-projects' import { selectDetectedWorktreesByRepo, selectFolderWorkspaces @@ -69,6 +70,14 @@ export function purgeHydratedWorktrees( validIds.add(key) } } + // Why: hydration kept only a moved project's chats, which stay on this computer. + if ((state.wslMovedProjects ?? []).length > 0) { + for (const key of Object.keys(state.unifiedTabsByWorktree)) { + if (findWslMovedProjectForWorkspace(state, key)) { + validIds.add(key) + } + } + } for (const result of Object.values(selectDetectedWorktreesByRepo(state))) { if (!result.authoritative) { continue diff --git a/src/renderer/src/store/slices/wsl-moved-project-chat-hydration.test.ts b/src/renderer/src/store/slices/wsl-moved-project-chat-hydration.test.ts new file mode 100644 index 00000000000..b6087e07939 --- /dev/null +++ b/src/renderer/src/store/slices/wsl-moved-project-chat-hydration.test.ts @@ -0,0 +1,146 @@ +import { expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type { Tab } from '../../../../shared/tab-types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import type { WslMovedProject } from '../../../../shared/wsl-move/wsl-moved-project' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +// @ts-expect-error -- mocked browser preload API +globalThis.window = { api: {} } + +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { + collectWslMovedWorkspaceKeysFromSession, + findWslMovedProjectForWorkspace, + selectWslMovedChatWorkspaces +} from '../repos/wsl-moved-projects' +import { createTestStore, makeTab, makeWorktree } from './store-test-helpers' + +// The local row's checkout, as a UNC path; the host lists the same repo id at its Linux path. +const MOVED = 'repo1::\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo1' +const ON_HOST = 'repo1::/home/dev/repo1' +const GROUP = 'group-1' +const TERMINAL = 'terminal-1' +const EDITOR = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo1\\README.md' +const CHAT = 'agent-session:chat-1' +const projects: WslMovedProject[] = [ + { kind: 'repo', id: 'repo1', displayName: 'Repo 1', path: MOVED, distro: 'Ubuntu' } +] + +function tab(id: string, contentType: Tab['contentType'], sortOrder: number): Tab { + return { + id, + entityId: contentType === 'agent-session' ? 'chat-1' : id, + groupId: GROUP, + worktreeId: MOVED, + contentType, + label: id, + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + } +} + +function movedSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo1', + activeWorktreeId: MOVED, + tabsByWorktree: { [MOVED]: [makeTab({ id: TERMINAL, worktreeId: MOVED })] }, + openFilesByWorktree: { + [MOVED]: [{ filePath: EDITOR, relativePath: 'README.md', worktreeId: MOVED, language: 'md' }] + }, + unifiedTabs: { + [MOVED]: [ + tab(TERMINAL, 'terminal', 0), + tab(EDITOR, 'editor', 1), + tab(CHAT, 'agent-session', 2) + ] + }, + tabGroups: { + [MOVED]: [ + { id: GROUP, worktreeId: MOVED, activeTabId: TERMINAL, tabOrder: [TERMINAL, EDITOR, CHAT] } + ] + }, + activeGroupIdByWorktree: { [MOVED]: GROUP } + } +} + +function hydrate(store: ReturnType, session: WorkspaceSessionState) { + // The host's same-id repo is known and listed; without the moved keys the degraded rule would + // keep the unlisted local worktree whole. + store.setState({ + repos: [ + { + id: 'repo1', + path: '/home/dev/repo1', + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 0 + } + ], + worktreesByRepo: { repo1: [] }, + wslMovedProjects: projects + }) + const options = { + wslMovedChatWorkspaceKeys: collectWslMovedWorkspaceKeysFromSession(session, projects) + } + store.getState().hydrateWorkspaceSession(session, options) + store.getState().hydrateTabsSession(session, options) + store.getState().hydrateEditorSession(session, options) + store.getState().hydrateBrowserSession(session, options) +} + +it("keeps only a moved project's chat tab through hydration and persistence", () => { + const store = createTestStore() + hydrate(store, movedSession()) + + const state = store.getState() + expect(state.unifiedTabsByWorktree[MOVED]?.map((entry) => entry.id)).toEqual([CHAT]) + expect(state.groupsByWorktree[MOVED]?.[0]?.tabOrder).toEqual([CHAT]) + expect(state.tabsByWorktree[MOVED]).toBeUndefined() + expect(state.openFiles).toEqual([]) + + store.setState({ workspaceSessionReady: true }) + store.getState().setHydrationSucceeded(true) + const persisted = buildWorkspaceSessionPayload(store.getState()) + expect(persisted.unifiedTabs?.[MOVED]?.map((entry) => entry.id)).toEqual([CHAT]) +}) + +it("never treats a paired host's partition as the moved local workspace", () => { + const session = movedSession() + expect( + collectWslMovedWorkspaceKeysFromSession(session, projects, { [MOVED]: 'runtime:wsl-ubuntu' }) + ).toEqual([]) + expect(collectWslMovedWorkspaceKeysFromSession(session, [])).toEqual([]) +}) + +it("lists the moved chats under their project, and never the host's listed worktree", () => { + const store = createTestStore() + hydrate(store, movedSession()) + store.setState({ + worktreesByRepo: { + repo1: [makeWorktree({ id: ON_HOST, repoId: 'repo1', path: '/home/dev/repo1' })] + }, + unifiedTabsByWorktree: { + ...store.getState().unifiedTabsByWorktree, + [ON_HOST]: [{ ...tab('agent-session:host', 'agent-session', 0), worktreeId: ON_HOST }] + } + }) + const state = store.getState() + expect(findWslMovedProjectForWorkspace(state, MOVED)?.id).toBe('repo1') + expect(findWslMovedProjectForWorkspace(state, ON_HOST)).toBeNull() + expect( + selectWslMovedChatWorkspaces(state).map(({ project, workspaces }) => ({ + project: project.id, + workspaces: workspaces.map(({ key, chats }) => ({ key, chats: chats.map((c) => c.id) })) + })) + ).toEqual([{ project: 'repo1', workspaces: [{ key: MOVED, chats: [CHAT] }] }]) +}) diff --git a/src/renderer/src/store/slices/wsl-moved-project-chat-purge.test.ts b/src/renderer/src/store/slices/wsl-moved-project-chat-purge.test.ts new file mode 100644 index 00000000000..766670ec0f2 --- /dev/null +++ b/src/renderer/src/store/slices/wsl-moved-project-chat-purge.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { makeTab, makeUnifiedTab } from './store-session-test-harness' +import { makeWorktree } from './worktrees-slice-test-fixtures' +import { makeDetectedResult, qualifyDetectedResult } from './worktrees-detected-listing-fixtures' +import { + createTestStore, + mockApi, + resetRemoteRuntimeMocks, + resetWorktreeSliceModuleMemory +} from './worktrees-slice-test-harness' + +vi.mock('sonner', () => ({ + toast: { warning: vi.fn(), info: vi.fn(), success: vi.fn(), error: vi.fn(), dismiss: vi.fn() } +})) + +beforeEach(() => { + resetWorktreeSliceModuleMemory() + vi.clearAllMocks() + resetRemoteRuntimeMocks() +}) + +const MOVED = 'moved::\\\\wsl.localhost\\Ubuntu\\home\\dev\\moved' +const repoA = { id: 'repoA', path: '/repos/a', displayName: 'a', badgeColor: '#000', addedAt: 0 } + +it("keeps a moved project's chat workspace through the startup purge, and purges a stale one", async () => { + const store = createTestStore() + const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) + mockApi.worktrees.listDetected.mockImplementation(async (args) => + qualifyDetectedResult(args, makeDetectedResult('repoA', [wtA])) + ) + const chat = makeUnifiedTab({ + id: 'structured-agent-session-chat-1', + worktreeId: MOVED, + groupId: 'group-1', + contentType: 'agent-session' + }) + store.setState({ + repos: [repoA], + wslMovedProjects: [ + { kind: 'repo', id: 'moved', displayName: 'Moved', path: '/moved', distro: 'Ubuntu' } + ], + // A reconciliation can leave an empty terminal entry beside the chat-only workspace. + tabsByWorktree: { + [wtA.id]: [makeTab({ id: 'tab-A', worktreeId: wtA.id })], + [MOVED]: [], + 'repoA::/a/zombie': [makeTab({ id: 'tab-zombie', worktreeId: 'repoA::/a/zombie' })] + }, + unifiedTabsByWorktree: { [MOVED]: [chat] } + }) + + await store.getState().fetchAllWorktrees() + + expect(store.getState().hasHydratedWorktreePurge).toBe(true) + expect(Object.keys(store.getState().tabsByWorktree).sort()).toEqual([MOVED, wtA.id].sort()) + expect(store.getState().unifiedTabsByWorktree[MOVED]).toEqual([chat]) +}) diff --git a/src/renderer/src/web/preload-api/web-repositories-api.ts b/src/renderer/src/web/preload-api/web-repositories-api.ts index 6c2dff10905..91bd76b8396 100644 --- a/src/renderer/src/web/preload-api/web-repositories-api.ts +++ b/src/renderer/src/web/preload-api/web-repositories-api.ts @@ -17,6 +17,8 @@ export function createReposApi(): NonNullable['repos']> { const owned = await callRuntimeResultWithOwner<{ repos: Repo[] }>('repo.list') return owned.result.repos.map((repo) => withRuntimeRepoOwner(repo, owned.hostId)) }, + // A WSL move is a desktop's own; a paired web client never holds the retained local rows. + listWslMoved: async () => [], add: async ({ path, kind, displayName }) => { invalidateRuntimeWorktreeCaches() const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( diff --git a/src/renderer/src/web/preload-api/web-workspace-session-api.ts b/src/renderer/src/web/preload-api/web-workspace-session-api.ts index 353b85e7232..411ae817a75 100644 --- a/src/renderer/src/web/preload-api/web-workspace-session-api.ts +++ b/src/renderer/src/web/preload-api/web-workspace-session-api.ts @@ -83,7 +83,9 @@ export function createWebWorkspaceSessionApi(): Partial { closeTerminalSurface: async () => {}, // localStorage writes synchronously, so there is no deferred web flush. flush: async () => {}, - readTerminalScrollback: () => null + // No local snapshot store: host-run panes read theirs from the host (host-terminal-scrollback-hydration). + readTerminalScrollback: () => null, + hasTerminalScrollback: () => false } } } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 2e5978322d2..007487574ca 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -140,7 +140,19 @@ function createWebPreloadApi(): Partial { // Desktop-only: a web client never owns a WSL move's desktop stores. wslMove: { listPendingRendererRewrites: () => Promise.resolve([]), - recordStoreRewritten: () => Promise.resolve({ recorded: false, reason: 'gated-off' }) + recordStoreRewritten: () => Promise.resolve({ recorded: false, reason: 'gated-off' }), + preview: () => Promise.resolve({ outcome: 'refused', code: 'wsl_move_unavailable' }), + start: () => Promise.resolve({ outcome: 'refused', code: 'wsl_move_unavailable' }), + cancel: () => Promise.resolve(false), + listUnfinished: () => Promise.resolve([]), + useProjectHereAgain: () => Promise.resolve({ released: false, reason: 'gated-off' }), + onProgress: () => () => {}, + listOrchestrator: () => Promise.resolve([]), + orchestratorUserAction: () => Promise.resolve(null), + orchestratorProjectOpened: () => Promise.resolve(), + onOrchestratorStatus: () => () => {}, + onOrchestratorSleptPanesRequest: () => () => {}, + answerOrchestratorSleptPanes: () => Promise.resolve() }, pwsh: { isAvailable: () => callRuntimeResult('host.pwsh.isAvailable').catch(() => false) diff --git a/src/shared/folder-workspace-types.ts b/src/shared/folder-workspace-types.ts index b3651117073..cd1beca7df9 100644 --- a/src/shared/folder-workspace-types.ts +++ b/src/shared/folder-workspace-types.ts @@ -3,6 +3,7 @@ import type { TaskSourceContext } from './task-source-context' import type { TuiAgent } from './tui-agent' import type { DiffComment } from './diff-comment-types' import type { SmartTier } from './sidebar-list/sort-stamps' +import type { OrcadRetainedMarker } from './orcad-retained-marker' import type { ExternalConversationLink } from './external-conversation-link' import type { WorkspaceCreatorProvenance, @@ -49,6 +50,8 @@ export type FolderWorkspace = { createdAt: number updatedAt: number diffComments?: DiffComment[] + /** Local row a WSL host now owns, kept for a downgrade; set only by the mover's retention pass. */ + orcadRetained?: OrcadRetainedMarker /** See {@link WorktreeMeta.externalConversations}. */ externalConversations?: ExternalConversationLink[] } diff --git a/src/shared/folder-workspaces.ts b/src/shared/folder-workspaces.ts index 0fa50fc37d0..154c8eab08b 100644 --- a/src/shared/folder-workspaces.ts +++ b/src/shared/folder-workspaces.ts @@ -7,6 +7,7 @@ import { isWorkspaceLinkedItemSourceContextMatch } from './workspace-linked-item import { normalizeWorkspaceCreatorProvenance } from './workspace-creator-provenance' import { getWorkspaceAttachments } from './workspace-attachments' import { isSmartTier } from './sidebar-list/sort-stamps' +import { normalizeOrcadRetainedMarker } from './orcad-retained-marker' export function normalizeFolderWorkspaceName( name: string | null | undefined, @@ -58,6 +59,7 @@ export function normalizeFolderWorkspaces( const linkedTask = normalizeWorkspaceLinkedItem(raw.linkedTask) const linkedTaskSourceContext = normalizeStoredTaskSourceContext(raw.linkedTaskSourceContext) const creatorProvenance = normalizeWorkspaceCreatorProvenance(raw.creatorProvenance) + const orcadRetained = normalizeOrcadRetainedMarker(raw.orcadRetained) seen.add(raw.id) // Do not persist fetch-time executionHostId: it may name an unowned runtime authority and lacks a generation fence. // connectionId is the durable pin main projects from. @@ -131,7 +133,8 @@ export function normalizeFolderWorkspaces( // Entries are validated where they are read (external-conversation-links.ts). ...(Array.isArray(raw.externalConversations) ? { externalConversations: raw.externalConversations } - : {}) + : {}), + ...(orcadRetained ? { orcadRetained } : {}) }) } return workspaces.sort( diff --git a/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts b/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts index 5a84d027354..8480450d7eb 100644 --- a/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts +++ b/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { MAX_ORCAD_MIGRATION_BLOB_TOTAL_BYTES, ORCAD_MIGRATION_BLOB_KIND_CONTRACTS, + advertisedOrcadMigrationBlobCapabilities, OrcadMigrationBlobChunkRequestSchema, OrcadMigrationBlobDescriptorSchema, assertOrcadMigrationBlobSet, @@ -194,3 +195,19 @@ describe('orcad migration catalog state with blob uploads', () => { ).toThrow('orcad_migration_blob_key_invalid') }) }) + +describe('advertised migration blob kinds', () => { + it('advertises only the kinds this host installs, terminal history only on a WSL host', () => { + expect(advertisedOrcadMigrationBlobCapabilities({ wslHost: false })).toEqual([ + ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, + 'orcad.migration-blob.terminal-restore-hints.v1', + 'orcad.migration-blob.shell-history.v1' + ]) + expect(advertisedOrcadMigrationBlobCapabilities({ wslHost: true })).toEqual([ + ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, + 'orcad.migration-blob.terminal-restore-hints.v1', + 'orcad.migration-blob.shell-history.v1', + 'orcad.migration-blob.terminal-history.v1' + ]) + }) +}) diff --git a/src/shared/orcad-migration/orcad-migration-blob-transfer.ts b/src/shared/orcad-migration/orcad-migration-blob-transfer.ts index 4e105687dc0..df91e89f02e 100644 --- a/src/shared/orcad-migration/orcad-migration-blob-transfer.ts +++ b/src/shared/orcad-migration/orcad-migration-blob-transfer.ts @@ -15,7 +15,8 @@ import { ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY } from '../orcad-runtim export const ORCAD_MIGRATION_BLOB_KINDS = [ 'terminal-restore-hints', 'shell-history', - 'agent-session-records' + 'agent-session-records', + 'terminal-history' ] as const export type OrcadMigrationBlobKind = (typeof ORCAD_MIGRATION_BLOB_KINDS)[number] @@ -50,6 +51,12 @@ export const ORCAD_MIGRATION_BLOB_KIND_CONTRACTS: Record< key: /^[a-z0-9][a-z0-9_-]{0,127}$/, maxBytes: 64 * MIB, capability: 'orcad.migration-blob.agent-session-records.v1' + }, + // 16 hex of the pane key's sha256; the session id it restores as travels inside. + 'terminal-history': { + key: /^[a-f0-9]{16}$/, + maxBytes: 16 * MIB, + capability: 'orcad.migration-blob.terminal-history.v1' } } @@ -254,14 +261,16 @@ export function parseOrcadMigrationBlobChunkResult( /** Kinds this build's destination installs after commit; each is added with its installer. */ export const ORCAD_MIGRATION_INSTALLABLE_BLOB_KINDS: readonly OrcadMigrationBlobKind[] = [ 'terminal-restore-hints', - 'shell-history' + 'shell-history', + 'terminal-history' ] -export function advertisedOrcadMigrationBlobCapabilities(): string[] { +/** Moved panes' terminal history installs only on a WSL host, the one destination a WSL move has. */ +export function advertisedOrcadMigrationBlobCapabilities(host: { wslHost: boolean }): string[] { return [ ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, - ...ORCAD_MIGRATION_INSTALLABLE_BLOB_KINDS.map( - (kind) => ORCAD_MIGRATION_BLOB_KIND_CONTRACTS[kind].capability - ) + ...ORCAD_MIGRATION_INSTALLABLE_BLOB_KINDS.filter( + (kind) => kind !== 'terminal-history' || host.wslHost + ).map((kind) => ORCAD_MIGRATION_BLOB_KIND_CONTRACTS[kind].capability) ] } diff --git a/src/shared/orcad-migration/orcad-migration-catalog-reference-abort.test.ts b/src/shared/orcad-migration/orcad-migration-catalog-reference-abort.test.ts new file mode 100644 index 00000000000..edb10b0bc6f --- /dev/null +++ b/src/shared/orcad-migration/orcad-migration-catalog-reference-abort.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import type { OrcadMigrationManifest } from './orcad-migration-manifest' +import { parseOrcadMigrationCatalogReferenceAbortResult } from './orcad-migration-catalog-state' + +const identity = { migrationId: 'move-1', manifestSha256: 'a'.repeat(64) } +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the parser reads only the identity fields. +const manifest = identity as OrcadMigrationManifest + +describe('parseOrcadMigrationCatalogReferenceAbortResult', () => { + it('keeps a host’s proof that its committed answer was flushed', () => { + expect( + parseOrcadMigrationCatalogReferenceAbortResult( + { ...identity, state: 'committed', aborted: false, durableCommitted: true }, + manifest + ) + ).toEqual({ ...identity, state: 'committed', aborted: false, durableCommitted: true }) + }) + + it('reads an older host’s committed answer as not proven durable', () => { + expect( + parseOrcadMigrationCatalogReferenceAbortResult( + { ...identity, state: 'committed', aborted: false, durableCommitted: 'yes' }, + manifest + ) + ).toEqual({ ...identity, state: 'committed', aborted: false }) + }) +}) diff --git a/src/shared/orcad-migration/orcad-migration-catalog-state.ts b/src/shared/orcad-migration/orcad-migration-catalog-state.ts index 3580f0fe613..a7ef8ab843d 100644 --- a/src/shared/orcad-migration/orcad-migration-catalog-state.ts +++ b/src/shared/orcad-migration/orcad-migration-catalog-state.ts @@ -1,6 +1,7 @@ import { normalizeOrcadMigrationImportReceipts } from './orcad-migration-manifest-validation' import type { OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogReference, OrcadMigrationCatalogReferenceAbortResult, OrcadMigrationCatalogState, OrcadMigrationManifest @@ -81,7 +82,7 @@ export function parseOrcadMigrationCatalogAbortResult( /** Absent only with the host's flush behind it: an unflushed abort could come back on restart. */ export function parseOrcadMigrationCatalogReferenceAbortResult( value: unknown, - manifest: OrcadMigrationManifest + manifest: OrcadMigrationCatalogReference ): OrcadMigrationCatalogReferenceAbortResult { const record = requireRecord(value) if ( @@ -94,7 +95,12 @@ export function parseOrcadMigrationCatalogReferenceAbortResult( throw new Error('orcad_migration_catalog_abort_result_invalid') } if (record.state === 'committed') { - return { state: 'committed', ...migrationIdentity(manifest), aborted: false } + return { + state: 'committed', + ...migrationIdentity(manifest), + aborted: false, + ...(record.durableCommitted === true ? { durableCommitted: true as const } : {}) + } } if (record.state === 'absent' && record.durableAbsent === true) { return { @@ -107,7 +113,7 @@ export function parseOrcadMigrationCatalogReferenceAbortResult( throw new Error('orcad_migration_catalog_abort_durability_invalid') } -function migrationIdentity(manifest: OrcadMigrationManifest) { +function migrationIdentity(manifest: OrcadMigrationCatalogReference) { return { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } } diff --git a/src/shared/orcad-migration/orcad-migration-manifest.ts b/src/shared/orcad-migration/orcad-migration-manifest.ts index 351f47f3211..bc7fbf2061b 100644 --- a/src/shared/orcad-migration/orcad-migration-manifest.ts +++ b/src/shared/orcad-migration/orcad-migration-manifest.ts @@ -156,6 +156,8 @@ export type OrcadMigrationCatalogReferenceAbortResult = OrcadMigrationCatalogRef state: 'absent' | 'committed' aborted: boolean durableAbsent?: true + /** The receipt was flushed before this answer; older hosts never send it. */ + durableCommitted?: true } export function orcadMigrationManifestHashInput( diff --git a/src/shared/orcad-migration/orcad-migration-terminal-history.ts b/src/shared/orcad-migration/orcad-migration-terminal-history.ts new file mode 100644 index 00000000000..271f6185146 --- /dev/null +++ b/src/shared/orcad-migration/orcad-migration-terminal-history.ts @@ -0,0 +1,70 @@ +/** + * The `terminal-history` blob: one moved pane's daemon history (its checkpoint, cwd and size) for + * the destination's own daemon to cold-restore once, exactly as after an app restart. The host + * installs it under the pane's derived session id, which the pane's first start then claims. + */ +import { z } from 'zod' +import { MAX_PANE_KEY_LEN } from '../agent-hook-listener/listener-limits' +import { PTY_SESSION_ID_SEPARATOR } from '../pty-session-id-format' +import { parsePaneKey } from '../stable-pane-id' +import { isOrcadMigrationTerminalRestoreHintCwd } from './orcad-migration-terminal-restore-hints' + +export const ORCAD_MIGRATION_TERMINAL_HISTORY_VERSION = 1 + +/** Begins the session part of a moved pane's id (`@@moved-`). */ +export const MOVED_PANE_HISTORY_SESSION_TAG = 'moved-' + +export function isMovedPaneHistorySessionId(sessionId: string): boolean { + const separator = sessionId.lastIndexOf(PTY_SESSION_ID_SEPARATOR) + return ( + separator > 0 && + sessionId.startsWith( + MOVED_PANE_HISTORY_SESSION_TAG, + separator + PTY_SESSION_ID_SEPARATOR.length + ) + ) +} + +const MAX_TERMINAL_DIMENSION = 10_000 + +export type OrcadMigrationTerminalHistoryDocument = { + version: typeof ORCAD_MIGRATION_TERMINAL_HISTORY_VERSION + paneKey: string + /** The shell's last folder on the host; null when the source never knew it. */ + cwd: string | null + cols: number + rows: number + /** The daemon's own checkpoint.json; the destination's history reader validates it on restore. */ + checkpoint: Record +} + +const DimensionSchema = z.number().int().min(1).max(MAX_TERMINAL_DIMENSION) + +const DocumentSchema = z + .object({ + version: z.literal(ORCAD_MIGRATION_TERMINAL_HISTORY_VERSION), + paneKey: z + .string() + .refine((value) => value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null), + cwd: z.string().refine(isOrcadMigrationTerminalRestoreHintCwd).nullable(), + cols: DimensionSchema, + rows: DimensionSchema, + checkpoint: z.record(z.string(), z.unknown()) + }) + .strict() + +export function parseOrcadMigrationTerminalHistoryText( + text: string +): OrcadMigrationTerminalHistoryDocument { + let value: unknown + try { + value = JSON.parse(text) + } catch { + throw new Error('orcad_migration_terminal_history_not_json') + } + const parsed = DocumentSchema.safeParse(value) + if (!parsed.success) { + throw new Error('orcad_migration_terminal_history_invalid') + } + return parsed.data +} diff --git a/src/shared/orcad-migration/orcad-migration-wsl-source-validation.ts b/src/shared/orcad-migration/orcad-migration-wsl-source-validation.ts index 96cb49a6184..140888d257b 100644 --- a/src/shared/orcad-migration/orcad-migration-wsl-source-validation.ts +++ b/src/shared/orcad-migration/orcad-migration-wsl-source-validation.ts @@ -49,7 +49,12 @@ export function assertLocalWslScopeMatchesPayload(manifest: OrcadMigrationManife } const sameIds = (declared: readonly string[], rows: readonly { id: string }[]): boolean => { const ids = new Set(rows.map((row) => row.id)) - return declared.length === ids.size && declared.every((id) => ids.has(id)) + // A doubled declared id would let one payload row through unscoped. + return ( + new Set(declared).size === declared.length && + declared.length === ids.size && + declared.every((id) => ids.has(id)) + ) } if ( !sameIds(source.scopeRepoIds, payload.repositories) || diff --git a/src/shared/orcad-retained-marker.test.ts b/src/shared/orcad-retained-marker.test.ts new file mode 100644 index 00000000000..2d31621e3ec --- /dev/null +++ b/src/shared/orcad-retained-marker.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { normalizeOrcadRetainedMarker, withoutOrcadRetainedMarker } from './orcad-retained-marker' + +describe('normalizeOrcadRetainedMarker', () => { + it('keeps a well-formed mark, with or without its change time', () => { + const mark = { migrationId: 'move-1', environmentId: 'wsl:Ubuntu' } + expect(normalizeOrcadRetainedMarker(mark)).toEqual(mark) + expect( + normalizeOrcadRetainedMarker({ ...mark, sourceChangedAt: '2026-10-20T00:00:00.000Z' }) + ).toEqual({ ...mark, sourceChangedAt: '2026-10-20T00:00:00.000Z' }) + }) + + it.each([null, 'move-1', { migrationId: 'move-1' }, { migrationId: '', environmentId: 'e' }])( + 'reads %j as no mark', + (value) => { + expect(normalizeOrcadRetainedMarker(value)).toBeUndefined() + } + ) +}) + +describe('withoutOrcadRetainedMarker', () => { + it('copies the row without the mark and leaves the stored row as it was', () => { + const row = { id: 'repo-a', orcadRetained: { migrationId: 'move-1', environmentId: 'wsl:U' } } + expect(withoutOrcadRetainedMarker(row)).toEqual({ id: 'repo-a' }) + expect(row.orcadRetained).toEqual({ migrationId: 'move-1', environmentId: 'wsl:U' }) + }) +}) diff --git a/src/shared/orcad-retained-marker.ts b/src/shared/orcad-retained-marker.ts new file mode 100644 index 00000000000..3e14f421e11 --- /dev/null +++ b/src/shared/orcad-retained-marker.ts @@ -0,0 +1,56 @@ +import { isRecord } from './orcad-migration/orcad-migration-manifest-fields' + +/** + * The mark a local project row carries once a committed WSL move retains it (mover design, section + * 4.5). The journal, not this mark, decides what is hidden: an older build may strip it, and the + * next start puts it back. It stays on this desktop and is never part of a move's manifest. + */ +export type OrcadRetainedMarker = { + /** The newest retained move that took the row. */ + migrationId: string + /** The host that owns the project now. */ + environmentId: string + /** Set when the row's identity no longer matches what that move took (an older build edited it). */ + sourceChangedAt?: string +} + +const MAX_ID_LENGTH = 1_024 + +function boundedString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= MAX_ID_LENGTH +} + +/** Undefined for anything malformed, so a bad mark reads as no mark and the next start rewrites it. */ +export function normalizeOrcadRetainedMarker(value: unknown): OrcadRetainedMarker | undefined { + if (!isRecord(value)) { + return undefined + } + const { migrationId, environmentId, sourceChangedAt } = value + if (!boundedString(migrationId) || !boundedString(environmentId)) { + return undefined + } + return { + migrationId, + environmentId, + ...(boundedString(sourceChangedAt) ? { sourceChangedAt } : {}) + } +} + +export function sameOrcadRetainedMarker( + left: OrcadRetainedMarker | undefined, + right: OrcadRetainedMarker | undefined +): boolean { + return ( + left?.migrationId === right?.migrationId && + left?.environmentId === right?.environmentId && + left?.sourceChangedAt === right?.sourceChangedAt + ) +} + +/** A copy for a manifest: the mark is this desktop's record and must never reach a host. */ +export function withoutOrcadRetainedMarker( + row: T +): Omit { + const { orcadRetained: _desktopOnly, ...copy } = structuredClone(row) + return copy +} diff --git a/src/shared/repo-types.ts b/src/shared/repo-types.ts index 3762d0ee4b0..19bf707eb46 100644 --- a/src/shared/repo-types.ts +++ b/src/shared/repo-types.ts @@ -7,6 +7,7 @@ import type { GitRemoteIdentity } from './git-remote-identity' import type { RepoSourceControlAiOverrides } from './source-control-ai-types' import type { RepoProjectHostSetupMethod } from './project-types' import type { JiraProjectReference } from './jira/jira-project-reference' +import type { OrcadRetainedMarker } from './orcad-retained-marker' export type RepoKind = 'git' | 'folder' @@ -95,6 +96,8 @@ export type Repo = { sourceControlAi?: RepoSourceControlAiOverrides /** Transitional source for ProjectHostSetup.setupMethod while Repo remains compatibility storage. */ projectHostSetupMethod?: RepoProjectHostSetupMethod + /** Local row a WSL host now owns, kept for a downgrade; set only by the mover's retention pass. */ + orcadRetained?: OrcadRetainedMarker } /** Shared IPC shape for repos:getBaseRefDefault across main/preload/renderer; remoteCount avoids a second hint lookup. */ diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 0b6cefbafde..6f40a5363cc 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -531,6 +531,7 @@ import { UpdatePaneLayout, WorktreeTabSelector } from './session-tabs-schemas-params' +import { SessionReadTerminalScrollbackParams } from './session-terminal-scrollback-params' import { SidebarSessionBeginParams } from './sidebar-session-params' import { SkillsCancelInstallParams, @@ -1312,6 +1313,7 @@ export const RPC_PARAMS_BY_METHOD = { 'runtime.clientCapabilities.update': ClientCapabilitiesUpdate, 'runtime.clientEvents.subscribe': null, 'runtime.clientEvents.unsubscribe': ClientEventsUnsubscribeParams, + 'session.readTerminalScrollback': SessionReadTerminalScrollbackParams, 'session.tabs.activate': ActivateTab, 'session.tabs.close': CloseTab, 'session.tabs.closeLifecycle': CloseLifecycleTab, diff --git a/src/shared/rpc-contract/session-terminal-scrollback-params.ts b/src/shared/rpc-contract/session-terminal-scrollback-params.ts new file mode 100644 index 00000000000..018b807bacd --- /dev/null +++ b/src/shared/rpc-contract/session-terminal-scrollback-params.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import { WorktreeTabSelector } from './session-tabs-schemas-params' + +// Why pinned here: the ref is a file name on the host, so no other shape may reach the read. +export const TERMINAL_SCROLLBACK_SNAPSHOT_REF_PATTERN = /^v1-[0-9a-f]{32}$/ + +export const SessionReadTerminalScrollbackParams = WorktreeTabSelector.extend({ + ref: z.string().regex(TERMINAL_SCROLLBACK_SNAPSHOT_REF_PATTERN, 'Invalid scrollback ref') +}) + +export type SessionReadTerminalScrollbackResult = { buffer: string } diff --git a/src/shared/runtime-environment-wsl-host-store.test.ts b/src/shared/runtime-environment-wsl-host-store.test.ts index 658788f845e..25f95441985 100644 --- a/src/shared/runtime-environment-wsl-host-store.test.ts +++ b/src/shared/runtime-environment-wsl-host-store.test.ts @@ -183,4 +183,29 @@ describe('WSL host environment store', () => { expect(listEnvironments(userDataPath)).toEqual([]) expect(findWslHostDeployment(userDataPath, 'wsl:ubuntu')).toBeNull() }) + + // A golden image with a baked-in orcad key: each distro is its own host, with its own grant. + it('keeps two distros that carry the same orcad key as two hosts', () => { + addWslHostEnvironment(userDataPath, { + id: 'wsl:ubuntu', + name: 'Ubuntu (WSL)', + pairingCode: code(42000, 'token-ubuntu'), + record + }) + addWslHostEnvironment(userDataPath, { + id: 'wsl:ubuntu-2', + name: 'Ubuntu-2 (WSL)', + pairingCode: code(42001, 'token-ubuntu-2'), + record: { ...record, distro: 'Ubuntu-2', localPort: 42001 } + }) + const environments = listEnvironments(userDataPath) + expect(environments.map((environment) => environment.id)).toEqual([ + 'wsl:ubuntu', + 'wsl:ubuntu-2' + ]) + expect(environments.map((environment) => getPreferredPairingOffer(environment))).toEqual([ + expect.objectContaining({ publicKeyB64, deviceToken: 'token-ubuntu' }), + expect.objectContaining({ publicKeyB64, deviceToken: 'token-ubuntu-2' }) + ]) + }) }) diff --git a/src/shared/session-tabs-runtime-capabilities.ts b/src/shared/session-tabs-runtime-capabilities.ts index 10d359c8ec3..676024e5e4e 100644 --- a/src/shared/session-tabs-runtime-capabilities.ts +++ b/src/shared/session-tabs-runtime-capabilities.ts @@ -35,6 +35,11 @@ export const SESSION_TABS_TAB_DELTA_RUNTIME_CAPABILITY = 'session-tabs.tab-delta export const AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY = 'agent-status.restored-unconfirmed.v1' as const +// Why: only a WSL host advertises this, with session.readTerminalScrollback; a client asks for a +// pane's stored scrollback only when it is advertised and otherwise paints from its own store. +export const SESSION_TERMINAL_SCROLLBACK_READ_RUNTIME_CAPABILITY = + 'session.terminal-scrollback-read.v1' as const + export const SESSION_TABS_RUNTIME_CAPABILITIES = [ QODER_OWNED_TERMINAL_CREATE_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, diff --git a/src/shared/telemetry-event-registry.ts b/src/shared/telemetry-event-registry.ts index 68d6061ae2e..3edc0b3109c 100644 --- a/src/shared/telemetry-event-registry.ts +++ b/src/shared/telemetry-event-registry.ts @@ -112,6 +112,7 @@ import { sshHostServerMoveSchema, sshHostServerLegacyRelayBridgeSchema } from './telemetry-ssh-host-server-event-schemas' +import { wslMoveOrchestratorSchema, wslMoveSchema } from './telemetry-wsl-move-event-schemas' // Breaking payload changes require a new event name to keep historical rows distinguishable; optional additions are safe. export const eventSchemas = { @@ -225,5 +226,8 @@ export const eventSchemas = { ssh_host_server_move: sshHostServerMoveSchema, ssh_host_server_legacy_relay_bridge: sshHostServerLegacyRelayBridgeSchema, + wsl_move: wslMoveSchema, + wsl_move_orchestrator: wslMoveOrchestratorSchema, + wsl_usage_snapshot: wslUsageSnapshotSchema } as const diff --git a/src/shared/telemetry-wsl-move-event-schemas.ts b/src/shared/telemetry-wsl-move-event-schemas.ts new file mode 100644 index 00000000000..3744890c801 --- /dev/null +++ b/src/shared/telemetry-wsl-move-event-schemas.ts @@ -0,0 +1,118 @@ +import { z } from 'zod' +import { SSH_RUNTIME_DURATION_BUCKETS } from './telemetry-ssh-runtime-event-schemas' + +// Why enum-only: these rows size how WSL moves end. A distro, repo or path name, a migration +// id or a raw error would identify a machine, so none may appear; unknown codes become `other`. + +/** A refusal's code without its `wsl_move_` prefix. */ +export const WSL_MOVE_REFUSAL_VALUES = [ + 'none', + 'unavailable', + 'already_running', + 'already_in_flight', + 'nothing_new', + 'host_unavailable', + 'host_update_required', + 'terminals_blocked', + 'export_incomplete', + 'manifest_too_large', + 'pause_failed', + 'scope_changed', + 'cancelled', + 'finished_earlier_move', + 'other' +] as const + +/** The last move phase the desktop journaled before it was reset or left waiting. */ +export const WSL_MOVE_PHASE_VALUES = [ + 'none', + 'before_stage', + 'destination_staged', + 'blobs_transferred', + 'destination_committed' +] as const + +/** Why a move was put back to zero, or is waiting for its host. */ +export const WSL_MOVE_RESET_REASON_VALUES = [ + 'none', + 'staged_claim_conflict', + 'repository_path_conflict', + 'host_unreachable', + 'distro_unregistered', + 'stage_rejected', + 'blob_rejected', + 'commit_rejected', + 'terminals_started', + 'exit_unverifiable', + 'other' +] as const + +export const WSL_MOVE_BYTES_BUCKETS = [ + '0', + 'lt_1mb', + '1mb_10mb', + '10mb_100mb', + 'gte_100mb' +] as const + +// `started` counts attempts (it precedes refusals too) and carries placeholder buckets. +export const wslMoveSchema = z + .object({ + outcome: z.enum(['started', 'finished', 'reset', 'refused', 'waiting_for_host']), + refusal: z.enum(WSL_MOVE_REFUSAL_VALUES), + phase: z.enum(WSL_MOVE_PHASE_VALUES), + reset_reason: z.enum(WSL_MOVE_RESET_REASON_VALUES), + duration_bucket: z.enum(SSH_RUNTIME_DURATION_BUCKETS), + // Scrollback, shell history and restore hints sent: what the move had to carry. + bytes_bucket: z.enum(WSL_MOVE_BYTES_BUCKETS) + }) + .strict() + +/** What woke the move orchestrator (D3a) for a distro; `timer` is a retry or deferred re-check. */ +export const WSL_MOVE_ORCHESTRATOR_TRIGGER_VALUES = [ + 'connect', + 'project_opened', + 'startup', + 'user', + 'timer' +] as const + +/** The state the orchestrator left the distro in; reported only when it changes. */ +export const WSL_MOVE_ORCHESTRATOR_TRANSITION_VALUES = [ + 'idle', + 'pending', + 'deferred', + 'started', + 'waiting_for_host', + 'backoff', + 'parked', + 'done', + 'distro_gone', + 'restore_offered' +] as const + +export const WSL_MOVE_ORCHESTRATOR_PARK_REASON_VALUES = [ + 'none', + 'export_incomplete', + 'manifest_too_large', + 'host_update_required', + 'attempts_exhausted', + 'not_now', + 'declined', + 'removal_pending', + 'stuck' +] as const + +export const WSL_MOVE_ORCHESTRATOR_ATTEMPT_BUCKETS = ['0', '1', '2_3', 'gte_4'] as const + +// Same enum-only rule as `wsl_move`: no distro, project or migration id leaves. +export const wslMoveOrchestratorSchema = z + .object({ + trigger: z.enum(WSL_MOVE_ORCHESTRATOR_TRIGGER_VALUES), + transition: z.enum(WSL_MOVE_ORCHESTRATOR_TRANSITION_VALUES), + refusal: z.enum(WSL_MOVE_REFUSAL_VALUES), + reset_reason: z.enum(WSL_MOVE_RESET_REASON_VALUES), + park_reason: z.enum(WSL_MOVE_ORCHESTRATOR_PARK_REASON_VALUES), + attempt_bucket: z.enum(WSL_MOVE_ORCHESTRATOR_ATTEMPT_BUCKETS) + }) + .strict() diff --git a/src/shared/workspace-session-host-records.ts b/src/shared/workspace-session-host-records.ts index 13eca52c05a..f9b0a586aa1 100644 --- a/src/shared/workspace-session-host-records.ts +++ b/src/shared/workspace-session-host-records.ts @@ -78,7 +78,7 @@ export function buildWorktreeIdByFileId( export function mergeWorkspaceSessionRecordField( out: WorkspaceSessionRecord, field: keyof WorkspaceSessionState, - slice: WorkspaceSessionState + slice: Partial ): void { const value = slice[field] if (!isWorkspaceSessionRecord(value)) { diff --git a/src/shared/workspace-session-tab-filter.ts b/src/shared/workspace-session-tab-filter.ts new file mode 100644 index 00000000000..41a0e0374d0 --- /dev/null +++ b/src/shared/workspace-session-tab-filter.ts @@ -0,0 +1,127 @@ +/** + * A saved session with some tab-bar tabs removed, and everything that named them made consistent: + * groups lose them (a group they emptied goes, with its layout leaf), and per-workspace focus on a + * removed tab or group moves to what is left. Terminal rows and layouts are left to the caller. + */ +import type { Tab, TabGroup } from './tab-types' +import { toVisibleTabType } from './tab-types' +import type { WorkspaceSessionState } from './workspace-session-state-types' +import { pruneGroupLayout } from './workspace-session-terminal-tab-close' + +export function filterWorkspaceSessionTabs( + session: WorkspaceSessionState, + keepTab: (owner: string, tab: Tab) => boolean +): WorkspaceSessionState { + const removedByOwner = new Map>() + const keptTypesByOwner = new Map>() + const unifiedTabs: Record = {} + for (const [owner, tabs] of Object.entries(session.unifiedTabs ?? {})) { + const kept = tabs.filter((tab) => keepTab(owner, tab)) + unifiedTabs[owner] = kept.length === tabs.length ? tabs : kept + if (kept.length !== tabs.length) { + const keptIds = new Set(kept.map((tab) => tab.id)) + removedByOwner.set( + owner, + new Set(tabs.filter((tab) => !keptIds.has(tab.id)).map((tab) => tab.id)) + ) + keptTypesByOwner.set(owner, new Set(kept.map((tab) => toVisibleTabType(tab.contentType)))) + } + } + if (removedByOwner.size === 0) { + return session + } + const tabGroups = mapOwners(session.tabGroups, removedByOwner, (groups, removed) => + groups.flatMap((group) => { + const kept = withoutTabs(group, removed) + return kept.tabOrder.length === 0 && group.tabOrder.length > 0 ? [] : [kept] + }) + ) + const groupIds = (owner: string): Set => + new Set((tabGroups?.[owner] ?? []).map((group) => group.id)) + return { + ...session, + unifiedTabs, + ...(tabGroups ? { tabGroups } : {}), + ...(session.tabGroupLayouts + ? { + tabGroupLayouts: filterOwners(session.tabGroupLayouts, removedByOwner, (layout, owner) => + pruneGroupLayout(layout, groupIds(owner)) + ) + } + : {}), + ...(session.activeGroupIdByWorktree + ? { + activeGroupIdByWorktree: filterOwners( + session.activeGroupIdByWorktree, + removedByOwner, + (groupId, owner) => { + const ids = groupIds(owner) + return ids.has(groupId) ? groupId : ids.values().next().value + } + ) + } + : {}), + ...(session.activeTabIdByWorktree + ? { + activeTabIdByWorktree: filterOwners( + session.activeTabIdByWorktree, + removedByOwner, + (tabId, owner) => (tabId && removedByOwner.get(owner)?.has(tabId) ? undefined : tabId) + ) + } + : {}), + ...(session.activeTabTypeByWorktree + ? { + activeTabTypeByWorktree: filterOwners( + session.activeTabTypeByWorktree, + removedByOwner, + (type, owner) => (keptTypesByOwner.get(owner)?.has(type) ? type : undefined) + ) + } + : {}) + } +} + +function withoutTabs(group: TabGroup, tabIds: ReadonlySet): TabGroup { + const tabOrder = group.tabOrder.filter((id) => !tabIds.has(id)) + const recentTabIds = group.recentTabIds?.filter((id) => !tabIds.has(id)) + const activeTabId = + group.activeTabId && !tabIds.has(group.activeTabId) + ? group.activeTabId + : (recentTabIds?.at(-1) ?? tabOrder.at(-1) ?? null) + return { ...group, tabOrder, activeTabId, ...(recentTabIds ? { recentTabIds } : {}) } +} + +/** Each owner that lost tabs mapped; the rest as they were. */ +function mapOwners( + record: Record | undefined, + removedByOwner: ReadonlyMap>, + map: (value: T, removed: ReadonlySet) => T +): Record | undefined { + if (!record) { + return undefined + } + return Object.fromEntries( + Object.entries(record).map(([owner, value]) => { + const removed = removedByOwner.get(owner) + return [owner, removed ? map(value, removed) : value] + }) + ) +} + +/** Like `mapOwners`, but an owner mapped to nothing is dropped. */ +function filterOwners( + record: Record, + removedByOwner: ReadonlyMap>, + map: (value: T, owner: string) => T | null | undefined +): Record { + return Object.fromEntries( + Object.entries(record).flatMap(([owner, value]) => { + if (!removedByOwner.has(owner) || value === null) { + return [[owner, value]] + } + const mapped = map(value, owner) + return mapped === undefined || mapped === null ? [] : [[owner, mapped]] + }) + ) +} diff --git a/src/shared/worktree/id.ts b/src/shared/worktree/id.ts index 520a0f1e24c..882902402a0 100644 --- a/src/shared/worktree/id.ts +++ b/src/shared/worktree/id.ts @@ -56,6 +56,26 @@ export function splitWorktreeId(worktreeId: string): ParsedWorktreeId | null { } } +/** `worktreeId` with its path mapped; the repo id and a folder workspace's instance suffix stay. */ +export function rewriteWorktreeIdPath( + worktreeId: string, + mapPath: (path: string) => string +): string { + const parsed = splitWorktreeId(worktreeId) + if (!parsed || !parsed.repoId || !parsed.worktreePath) { + return worktreeId + } + const suffixAt = parsed.worktreePath.lastIndexOf(FOLDER_WORKSPACE_INSTANCE_SEPARATOR) + const instanceId = parsed.worktreePath.slice( + suffixAt + FOLDER_WORKSPACE_INSTANCE_SEPARATOR.length + ) + const suffix = + suffixAt > 0 && /^[0-9a-f-]{36}$/.test(instanceId) ? parsed.worktreePath.slice(suffixAt) : '' + const path = parsed.worktreePath.slice(0, parsed.worktreePath.length - suffix.length) + const mapped = mapPath(path) + return mapped === path ? worktreeId : `${parsed.repoId}${WORKTREE_ID_SEPARATOR}${mapped}${suffix}` +} + export function splitWorktreeIdForFilesystem(worktreeId: string): ParsedWorktreeId | null { const parsed = splitWorktreeId(worktreeId) if (!parsed) { diff --git a/src/shared/wsl-move/wsl-move-browser-cookie-copy.ts b/src/shared/wsl-move/wsl-move-browser-cookie-copy.ts new file mode 100644 index 00000000000..0ae811742f9 --- /dev/null +++ b/src/shared/wsl-move/wsl-move-browser-cookie-copy.ts @@ -0,0 +1,78 @@ +/** + * What a WSL move did with the desktop's in-app browser logins (owner decision 3): copied once into + * the WSL host's route partition, or left the host on a fresh partition and says why. + */ +import { z } from 'zod' + +export const WSL_MOVE_BROWSER_COOKIES_STORE_ID = 'browser-cookies' + +export const WSL_MOVE_BROWSER_COOKIE_FRESH_PARTITION_REASONS = [ + // The host has no deployment link, so its partition names the pairing and the next orcad + // relaunch would move it, stranding the copy. + 'no-stable-partition', + 'destination-not-wsl-host', + 'destination-environment-missing', + // Another identity already owns the partition name this host derives. + 'destination-partition-conflict' +] as const + +export type WslMoveBrowserCookieFreshPartitionReason = + (typeof WSL_MOVE_BROWSER_COOKIE_FRESH_PARTITION_REASONS)[number] + +const count = z.number().int().nonnegative().max(1_000_000) + +const WslMoveBrowserCookieCopyCountsSchema = z.object({ + copied: count, + /** Subsets of `copied`, by the kinds the design asked about. */ + copiedHttpOnly: count, + copiedPartitioned: count, + copiedSessionOnly: count, + /** The host jar already held the cookie; its value wins. */ + skippedPresent: count, + /** Sites that bind a login to the jar that minted it; a copy would sign out both jars. */ + skippedSourceBound: count, + /** Partitioned under an opaque top-level site, which no write can address. */ + skippedOpaquePartition: count, + /** Unreadable or refused by the cookie store (e.g. an invalid value or prefix). */ + rejected: count +}) + +export type WslMoveBrowserCookieCopyCounts = z.infer + +export function emptyWslMoveBrowserCookieCopyCounts(): WslMoveBrowserCookieCopyCounts { + return { + copied: 0, + copiedHttpOnly: 0, + copiedPartitioned: 0, + copiedSessionOnly: 0, + skippedPresent: 0, + skippedSourceBound: 0, + skippedOpaquePartition: 0, + rejected: 0 + } +} + +export const WSL_MOVE_BROWSER_COOKIE_COPY_MAX_PROFILES = 256 + +export const WslMoveBrowserCookieCopySchema = z.discriminatedUnion('outcome', [ + z.object({ + outcome: z.literal('copied'), + profiles: z + .array( + z.object({ + browserProfileId: z.string().min(1).max(256), + destinationPartition: z.string().min(1).max(256), + /** An earlier move to this host already copied into this partition. */ + alreadyCopied: z.boolean(), + counts: WslMoveBrowserCookieCopyCountsSchema + }) + ) + .max(WSL_MOVE_BROWSER_COOKIE_COPY_MAX_PROFILES) + }), + z.object({ + outcome: z.literal('fresh-partition'), + reason: z.enum(WSL_MOVE_BROWSER_COOKIE_FRESH_PARTITION_REASONS) + }) +]) + +export type WslMoveBrowserCookieCopy = z.infer diff --git a/src/shared/wsl-move/wsl-move-cutover.ts b/src/shared/wsl-move/wsl-move-cutover.ts index 248682eed55..946355b4c1e 100644 --- a/src/shared/wsl-move/wsl-move-cutover.ts +++ b/src/shared/wsl-move/wsl-move-cutover.ts @@ -6,6 +6,12 @@ import type { OrcadMigrationManifest } from '../orcad-migration/orcad-migration-manifest' import { isOrcadMigrationLocalWslSource } from '../orcad-migration/orcad-migration-source-kind' +import { WslMoveBrowserCookieCopySchema } from './wsl-move-browser-cookie-copy' +import { + MAX_ORCAD_MIGRATION_TERMINAL_RESTORE_HINTS, + isOrcadMigrationTerminalRestoreHintCwd, + isOrcadMigrationTerminalRestoreHintPaneKey +} from '../orcad-migration/orcad-migration-terminal-restore-hints' export const WSL_MOVE_CUTOVER_VERSION = 1 @@ -38,7 +44,41 @@ const WslMoveCutoverSchema = z.object({ provenExitedSessionIds: z.array(z.string().min(1).max(1_024)).max(10_000), // Desktop stores already rewritten after commit, so a crash redoes only the rest. rewrittenStores: z.array(z.string().min(1).max(128)).max(256), + // Workspaces the host partition was seeded with, so a re-run never revives a closed tab. + seededHostWorkspaceIds: z.array(z.string().min(1).max(4_096)).max(10_000).optional(), + // What the move did with the desktop's browser logins. Lenient: a newer build's reason must not + // make the whole journal unreadable here. + browserCookieCopy: WslMoveBrowserCookieCopySchema.optional().catch(undefined), supersedesMigrationId: z.string().min(1).max(128).optional(), + // Each paused pane's last folder, captured at `quiesced`; the restore-hints blob is built from it. + // Lenient: a malformed list reads as absent, so it never makes the journal unreadable. + terminalRestoreHints: z + .array( + z.object({ + paneKey: z.string().refine(isOrcadMigrationTerminalRestoreHintPaneKey), + cwd: z.string().refine(isOrcadMigrationTerminalRestoreHintCwd) + }) + ) + .max(MAX_ORCAD_MIGRATION_TERMINAL_RESTORE_HINTS) + .optional() + .catch(undefined), + // Automations the manifest carries disabled (the wire only takes disabled ones) that were on + // here; the host turns them back on after commit. Set with the record, never changed after. + automationsToReenable: z.array(z.string().min(1).max(1_024)).max(10_000).optional(), + // A refused undo turned these off on the host and could not turn them back on yet; retried. + // Lenient: a malformed list reads as absent. + hostAutomationsLeftOff: z + .array(z.string().min(1).max(1_024)) + .max(10_000) + .optional() + .catch(undefined), + // Each moved automation's host state as an undo first read it, before turning any off: the + // user's choice, which the copy here takes on release. Kept so a crash or a refusal that left + // one off never reads that off as the choice. Lenient, like the list above. + undoHostAutomationIntent: z + .record(z.string().min(1).max(1_024), z.boolean()) + .optional() + .catch(undefined), manifest: z.unknown() }) diff --git a/src/shared/wsl-move/wsl-move-dispositions.ts b/src/shared/wsl-move/wsl-move-dispositions.ts index 3c94966eef6..7d7968cdef4 100644 --- a/src/shared/wsl-move/wsl-move-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-dispositions.ts @@ -70,9 +70,13 @@ export const PERSISTED_STATE_DISPOSITIONS = { workspaceSessionsByHostId: { disposition: 'rewrite-desktop', item: 22, - note: "The desktop seeds the WSL host's session from the moved 'local' rows (D1b-7c)." + note: "The desktop seeds the WSL host's session from the moved 'local' rows." + }, + remoteLayoutSnapshots: { + disposition: 'rederive', + item: 47, + note: "Rebuilt from the host's first tab stream; the seeded workspaceSessionsByHostId gives first paint." }, - remoteLayoutSnapshots: { disposition: 'rewrite-desktop', item: 47 }, sshTargets: notApplicableDisposition(62), sshTargetGenerationCounter: notApplicableDisposition(62), deletedSshConfigAliases: notApplicableDisposition(62), @@ -81,9 +85,11 @@ export const PERSISTED_STATE_DISPOSITIONS = { sshPtyConsumerRecoveries: notApplicableDisposition(60), migrationUnsupportedPtyEntries: { disposition: 'gate', item: 20 }, legacyPaneKeyAliasEntries: { disposition: 'gate', item: 20 }, - automations: moveDisposition(18), + automations: moveDisposition(18, { + note: "The host runs the moved copy; the desktop's stays unchanged, and its scheduler only advances it." + }), automationRuns: moveDisposition(19, { - note: 'An in-flight run in scope blocks the move.' + note: "An in-flight run in scope blocks the move. The desktop's history stays as written, beside its automation." }), orcadMigrationImportReceipts: notApplicableDisposition(67, 'Mover-owned.'), orcadMigrationEvictedReceipts: notApplicableDisposition(67, 'Mover-owned.'), @@ -131,7 +137,10 @@ export const WORKSPACE_SESSION_DISPOSITIONS = { localOnlyScrollbackByTabId: moveDisposition(25, { newManifestSection: true }), remoteSessionIdsByTabId: { disposition: 'drop-pty-binding', item: 36 }, browserPagesByWorkspace: moveDisposition(29), - markdownFrontmatterVisible: moveDisposition(28), + markdownFrontmatterVisible: moveDisposition(28, { + rewriteDesktopCopy: true, + note: 'Keyed by a UNC file id; host-session-seed re-keys this desktop copy to the host file.' + }), sleepingAgentSessionsByPaneKey: moveDisposition(33), terminalPtyIncarnationsByPaneKey: { disposition: 'drop-pty-binding', @@ -139,7 +148,10 @@ export const WORKSPACE_SESSION_DISPOSITIONS = { }, terminalTopologyRevisionByRepoId: { disposition: 'rederive', item: 35 }, terminalSurfaceTombstonesByPaneKey: moveDisposition(34), - closedTerminalTabTombstonesByTabId: moveDisposition(34), + closedTerminalTabTombstonesByTabId: moveDisposition(34, { + rewriteDesktopCopy: true, + note: 'Laid over the host partition by host-session-seed, with the host worktree id.' + }), // Names terminals of the host the project leaves, as the incarnation map does. closedTerminalPanesByPaneKey: { disposition: 'drop-pty-binding', item: 36 } } as const satisfies Record diff --git a/src/shared/wsl-move/wsl-move-driver-types.ts b/src/shared/wsl-move/wsl-move-driver-types.ts new file mode 100644 index 00000000000..4ea10179f89 --- /dev/null +++ b/src/shared/wsl-move/wsl-move-driver-types.ts @@ -0,0 +1,159 @@ +/** + * The IPC contract of a "move projects to the WSL host" run, for the move dialog. Main runs + * the move; the renderer previews it, starts it, may cancel it before the switch, and follows its + * progress. Every call refuses with `wsl_move_unavailable` while the internal WSL hosts gate is off. + */ +import type { WslMoveCutoverPhase } from './wsl-move-cutover' + +/** Which projects of a distro to move; omitted ids mean every project in the distro. */ +export type WslMoveRequest = { + distro: string + repoIds?: string[] + folderWorkspaceIds?: string[] + /** The window's bindings of panes with no live terminal: main retires a slept pane's on Sleep. */ + sleptPanes?: { paneKey: string; sessionId: string }[] +} + +export type WslMoveRowRef = { kind: 'repo' | 'folder'; id: string } + +export type WslMoveBlockerKind = + | 'agent-working' + | 'process-running' + | 'agent-not-resumable' + | 'unverifiable' + | 'automation-running' + | 'dispatch-running' + | 'scrollback-too-large' + +export type WslMoveBlockerAction = 'wait' | 'end' | 'skip-project' | 'clear-scrollback' + +/** One reason the move cannot start now, with the ids it names (pane keys, run or dispatch ids). */ +export type WslMoveBlocker = { + kind: WslMoveBlockerKind + ids: string[] + actions: WslMoveBlockerAction[] +} + +export type WslMoveRefusalCode = + /** The internal WSL hosts gate is off. */ + | 'wsl_move_unavailable' + /** This app is already moving projects into the distro. */ + | 'wsl_move_already_running' + /** An earlier move of the distro has not finished; it resumes or resets first. */ + | 'wsl_move_already_in_flight' + /** Every selected project is already on the host. */ + | 'wsl_move_nothing_new' + /** The distro has no connected WSL host. */ + | 'wsl_move_host_unavailable' + /** The host runs an older Orca that cannot take this move; it must update first. */ + | 'wsl_move_host_update_required' + /** Terminals, automations or dispatches in scope must finish first: see `blockers`. */ + | 'wsl_move_terminals_blocked' + /** Saved state the move cannot carry yet: see `reasons`. Nothing is dropped to make progress. */ + | 'wsl_move_export_incomplete' + /** Too much saved state for one move; `message` says what to do. */ + | 'wsl_move_manifest_too_large' + /** Pausing the terminals failed; `detail` names the step. */ + | 'wsl_move_pause_failed' + /** A project in scope changed while it was paused; nothing moved. */ + | 'wsl_move_scope_changed' + | 'wsl_move_cancelled' + /** + * An earlier move of the distro, interrupted after the host committed it, was finished first. + * Reload the window as for `reloadRequired`, then start again. + */ + | 'wsl_move_finished_earlier_move' + +export type WslMovePreview = + | { + outcome: 'ready' + distro: string + repoIds: string[] + folderWorkspaceIds: string[] + /** Moved earlier and changed since by an older build: listed, not moved again. */ + changed: WslMoveRowRef[] + /** On the host already with no move to show for it. */ + heldByHost: WslMoveRowRef[] + /** Projects that touch the distro but cannot move, with the reason. */ + refused: { kind: 'repo' | 'folder-workspace'; id: string; reason: string }[] + /** Empty when the move can start now. */ + blockers: WslMoveBlocker[] + } + | WslMoveRefusal + +export type WslMoveRefusal = { + outcome: 'refused' + code: WslMoveRefusalCode + /** User-facing, for `wsl_move_manifest_too_large`. */ + message?: string + blockers?: WslMoveBlocker[] + /** User-facing, for `wsl_move_export_incomplete`. */ + reasons?: string[] + detail?: string +} + +export type WslMoveResult = + | { + outcome: 'moved' + migrationId: string + phase: WslMoveCutoverPhase + /** + * This desktop's stores were rewritten under the open window: reload it through the + * restart-preparation path (`requestLazyChunkRecoveryReload`), never a bare reload, so + * unsaved editors are backed up and the session checkpoint lands first. + */ + reloadRequired: true + /** + * Automations the host has not turned back on yet; Orca retries until it confirms. The + * dialog must say they are off on the host for now, never hide it. + */ + automationsPending?: string[] + /** Set by the browser-login copy when the host's browser starts signed out. */ + browserLoginsNotCopiedReason?: string + } + | WslMoveRefusal + | { + /** The move stopped and was put back: nothing moved, the projects are usable here again. */ + outcome: 'reset' + migrationId: string + detail: string + } + | { + /** It may have reached the host; the projects stay read-only until the host answers. */ + outcome: 'waiting-for-host' + migrationId: string + detail: string + } + +/** The dialog's stepper; `phase` is the journal's, once the move has one. */ +export type WslMoveProgressStep = + | 'preparing' + | 'pausing-terminals' + | 'copying' + | 'switching' + | 'finishing' + | 'done' + +export type WslMoveProgressEvent = { + distro: string + step: WslMoveProgressStep + migrationId?: string + phase?: WslMoveCutoverPhase + /** While copying: bytes sent of the total. */ + bytesSent?: number + bytesTotal?: number + /** + * The run ended without committing (refused, reset, waiting): how, so a window that only followed + * its progress (it reloaded mid-move, or the move started elsewhere) can show the result too. + */ + result?: WslMoveResult +} + +/** A committed move whose host steps are not all confirmed yet, for the sidebar/settings notice. */ +export type WslMoveUnfinished = { + migrationId: string + distro: string + automationsPending: string[] +} + +export const WSL_MOVE_PROGRESS_CHANNEL = 'wslMove:progress' diff --git a/src/shared/wsl-move/wsl-move-orchestrator-types.ts b/src/shared/wsl-move/wsl-move-orchestrator-types.ts new file mode 100644 index 00000000000..f9137ba85f9 --- /dev/null +++ b/src/shared/wsl-move/wsl-move-orchestrator-types.ts @@ -0,0 +1,79 @@ +/** + * The general migration's orchestrator (D3a): per distro, when to start the move the driver runs, + * and what the user is told while it cannot finish. It never boots a distro (A1): only a user + * action that already woke it, or an explicit request, starts anything. + */ +import type { WslMoveBlocker } from './wsl-move-driver-types' + +/** What woke the orchestrator for a distro. */ +export type WslMoveOrchestratorTrigger = + /** The distro's host connection reached ready (the user's Connect, or visible demand, C7). */ + | 'connect' + /** The user opened a project of a distro that is already running. */ + | 'project-opened' + /** App start, for a distro whose host is already ready. */ + | 'startup' + /** Retry or Restore from the distro's status line. */ + | 'user' + /** A backoff retry or deferred re-check; runs only while the host stays ready. */ + | 'timer' + +/** Why the orchestrator stopped trying on its own; each needs the user. */ +export type WslMoveOrchestratorParkReason = + | 'export-incomplete' + | 'manifest-too-large' + | 'host-update-required' + | 'attempts-exhausted' + /** The user cancelled a move or chose "Not now"; it lapses after a week. */ + | 'not-now' + /** The user brought the projects back with "Use on this computer again"; only Retry undoes it. */ + | 'declined' + /** The user's Remove of the host could not prove its server stopped (E11); nothing touches it. */ + | 'removal-pending' + /** An earlier move never finished settling for a day; the waits stopped and need the user. */ + | 'stuck' + +export type WslMoveOrchestratorState = + /** Nothing of the distro left to move. */ + | { kind: 'idle' } + /** Waiting for a trigger. */ + | { kind: 'pending' } + /** "Finishing your move": the move waits on busy terminals, automations or dispatches. */ + /** `rechecks` counts timer re-checks; after the last one, only a user action or wake re-checks. */ + | { kind: 'deferred'; blockers: WslMoveBlocker[]; rechecks: number } + | { kind: 'moving' } + /** The move may have reached the host; it resumes when the host answers. */ + | { kind: 'waiting-for-host'; migrationId: string } + /** `waits`: uncounted checks so far (an earlier move still settling), for their cap. */ + | { kind: 'backoff'; nextAttemptAt: number; reason: string; waits?: number } + | { kind: 'parked'; reason: WslMoveOrchestratorParkReason; until?: number } + /** + * `nextCheckAt` / `waits` while a newer preview waits on this move to finish settling: + * new projects may be queued behind it, so the wait is re-checked and capped like a backoff. + */ + | { kind: 'done'; migrationId: string; nextCheckAt?: number; waits?: number } + /** R38: a finished move's distro is registered under neither its name nor its recorded GUID. */ + | { kind: 'distro-gone' } + /** R38: the distro was replaced (new GUID) or its host lost the moved projects. */ + | { kind: 'restore-offered' } + +export type WslMoveOrchestratorStateKind = WslMoveOrchestratorState['kind'] + +/** One distro's persisted record. */ +export type WslMoveOrchestratorRecord = { + distro: string + state: WslMoveOrchestratorState + /** Failed automatic attempts since the last success or user Retry. */ + attempts: number + /** The build that counted `attempts`; a new build may try again. */ + appVersion: string + updatedAt: number +} + +/** What the user asked of a distro's move from its status line. */ +export type WslMoveOrchestratorUserAction = 'retry' | 'not-now' | 'restore' + +export const WSL_MOVE_ORCHESTRATOR_STATUS_CHANNEL = 'wslMoveOrchestrator:status' +/** Main asks the window for its slept-pane bindings before an automatic start. */ +export const WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL = + 'wslMoveOrchestrator:requestSleptPanes' diff --git a/src/shared/wsl-move/wsl-move-out-of-store-dispositions.ts b/src/shared/wsl-move/wsl-move-out-of-store-dispositions.ts index 6f307a567ce..f6ece2559bf 100644 --- a/src/shared/wsl-move/wsl-move-out-of-store-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-out-of-store-dispositions.ts @@ -31,7 +31,12 @@ export const OUT_OF_STORE_DISPOSITIONS: readonly (WslMoveFieldDisposition & { item: 52, note: 'Chats stay local on the desktop (owner decision C); the journal is not moved.' }, - { store: 'orchestration.db', disposition: 'rewrite-desktop', item: 53 }, + { + store: 'orchestration.db', + disposition: 'rewrite-desktop', + item: 53, + note: 'Per table in SQLITE_TABLE_DISPOSITIONS.' + }, { store: 'codex-pane-accounts.json', disposition: 'not-applicable', @@ -53,7 +58,7 @@ export const OUT_OF_STORE_DISPOSITIONS: readonly (WslMoveFieldDisposition & { store: 'browser partition (cookies, site storage)', disposition: 'rewrite-desktop', item: 58, - note: "The WSL host's route partition binds to the local profile partition (decision 3)." + note: "Cookies are copied once into the WSL host's route partition, else the move records why the host starts fresh (decision 3)." }, { store: 'notifications, push, dismissals', diff --git a/src/shared/wsl-move/wsl-move-renderer-rewrite-scope.ts b/src/shared/wsl-move/wsl-move-renderer-rewrite-scope.ts new file mode 100644 index 00000000000..a977b76ac90 --- /dev/null +++ b/src/shared/wsl-move/wsl-move-renderer-rewrite-scope.ts @@ -0,0 +1,78 @@ +/** + * What a committed WSL move's renderer stores ask of a key: does it name a moved local workspace, + * and what are that workspace's id and paths on the host. Ids keep their repo; only a UNC path of + * the moved distro changes, as the main-side rewrite (wsl-move-rewrite-scope.ts) maps it. + */ +import { parseWorkspaceKey } from '../workspace-scope' +import { wslUncPathToLinux } from '../wsl-paths' +import { getRepoIdFromWorktreeId, rewriteWorktreeIdPath } from '../worktree/id' +import type { WslMovePendingRendererRewrite } from './wsl-move-renderer-rewrite-types' + +export type WslMoveRendererRewriteScope = { + /** The host's form of a moved local worktree id or workspace key; null for anything else. */ + hostWorktreeId: (worktreeId: string) => string | null + /** The host's path for a UNC path of the moved distro; null for anything else. */ + hostPath: (path: string) => string | null + /** Whether a host path lies in one of the moved projects' directories. */ + inMovedRoot: (hostPath: string) => boolean + /** The host's route, in the shape the renderer resolves for a runtime-owned worktree. */ + hostRoute: { executionHostId: `runtime:${string}`; runtimeEnvironmentId: string } +} + +// `normalizeRuntimePathForComparison` folds both UNC spellings of a WSL path into this form. +const COMPARISON_FORM = /^\/\/wsl\/([^/]+)(\/[\s\S]*)?$/ + +export function wslMoveRendererRewriteScope( + move: Pick< + WslMovePendingRendererRewrite, + | 'distro' + | 'scopeRepoIds' + | 'scopeFolderWorkspaceIds' + | 'destinationHostId' + | 'destinationEnvironmentId' + | 'scopeRootPaths' + > +): WslMoveRendererRewriteScope { + const repoIds = new Set(move.scopeRepoIds) + const folderIds = new Set(move.scopeFolderWorkspaceIds) + const distro = move.distro.toLowerCase() + const hostPath = (path: string): string | null => { + const linux = wslUncPathToLinux(path, distro) + if (linux !== null) { + return linux + } + const folded = COMPARISON_FORM.exec(path) + return folded && folded[1].toLowerCase() === distro ? (folded[2] ?? '/') : null + } + const hostWorktreeId = (worktreeId: string): string | null => { + const key = parseWorkspaceKey(worktreeId) + if (key?.type === 'folder') { + return folderIds.has(key.folderWorkspaceId) ? worktreeId : null + } + if (key?.type === 'worktree') { + const mapped = hostWorktreeId(key.worktreeId) + return mapped === null ? null : `worktree:${mapped}` + } + // A folder workspace keeps its id; only its paths move. + if (folderIds.has(worktreeId)) { + return worktreeId + } + if (!repoIds.has(getRepoIdFromWorktreeId(worktreeId))) { + return null + } + const mapped = rewriteWorktreeIdPath(worktreeId, (path) => hostPath(path) ?? path) + return mapped === worktreeId ? null : mapped + } + return { + hostWorktreeId, + hostPath, + inMovedRoot: (path) => + move.scopeRootPaths.some( + (root) => path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`) + ), + hostRoute: { + executionHostId: move.destinationHostId, + runtimeEnvironmentId: move.destinationEnvironmentId + } + } +} diff --git a/src/shared/wsl-move/wsl-move-renderer-rewrite-types.ts b/src/shared/wsl-move/wsl-move-renderer-rewrite-types.ts index 7ae75621d82..c063f528242 100644 --- a/src/shared/wsl-move/wsl-move-renderer-rewrite-types.ts +++ b/src/shared/wsl-move/wsl-move-renderer-rewrite-types.ts @@ -1,14 +1,29 @@ /** The renderer's half of a committed WSL move's desktop rewrite, as carried over IPC. */ +/** The renderer-side stores, in run order; main registers each so the ledger waits on it. */ +export const WSL_MOVE_RENDERER_REWRITE_STORE_IDS = [ + 'quick-open-history', + 'file-view-preferences', + 'notebook-environments' +] as const + +export type WslMoveRendererRewriteStoreId = (typeof WSL_MOVE_RENDERER_REWRITE_STORE_IDS)[number] + +/** + * A committed move, from `destination-committed` on. Renderer storage flushes lazily, so the + * renderer re-applies its stores for every listed move; only `storeIds` still need a ledger entry. + */ export type WslMovePendingRendererRewrite = { migrationId: string - /** Renderer-side stores still to rewrite, in order. */ + /** Renderer-side stores still to record, in order; empty once the move left its rewrite phase. */ storeIds: string[] destinationEnvironmentId: string destinationHostId: `runtime:${string}` distro: string scopeRepoIds: string[] scopeFolderWorkspaceIds: string[] + /** The moved projects' directories on the host, for stores keyed by a bare file path. */ + scopeRootPaths: string[] } export type WslMoveRecordStoreRewrittenRequest = { @@ -20,5 +35,11 @@ export type WslMoveRecordStoreRewrittenResult = | { recorded: true } | { recorded: false - reason: 'gated-off' | 'unknown-store' | 'not-renderer-store' | 'not-pending' | 'failed' + reason: + | 'gated-off' + | 'unknown-store' + | 'not-renderer-store' + | 'not-pending' + | 'not-main-window' + | 'failed' } diff --git a/src/shared/wsl-move/wsl-move-request.ts b/src/shared/wsl-move/wsl-move-request.ts new file mode 100644 index 00000000000..5e859bd81d7 --- /dev/null +++ b/src/shared/wsl-move/wsl-move-request.ts @@ -0,0 +1,27 @@ +/** The move dialog's request as main accepts it from the renderer: anything else is refused. */ +import { z } from 'zod' +import type { WslMoveRequest } from './wsl-move-driver-types' + +const Ids = z.array(z.string().min(1).max(1_024)).max(10_000) + +const WslMoveRequestSchema = z.object({ + // A WSL distro name: what `wsl.exe -l` lists, never a path. + distro: z + .string() + .min(1) + .max(255) + .regex(/^[^\\/:*?"<>|\0]+$/), + repoIds: Ids.optional(), + folderWorkspaceIds: Ids.optional(), + sleptPanes: z + .array( + z.object({ paneKey: z.string().min(1).max(1_024), sessionId: z.string().min(1).max(1_024) }) + ) + .max(10_000) + .optional() +}) + +export function parseWslMoveRequest(value: unknown): WslMoveRequest | null { + const parsed = WslMoveRequestSchema.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/src/shared/wsl-move/wsl-move-storage-dispositions.ts b/src/shared/wsl-move/wsl-move-storage-dispositions.ts index ba843d55fd5..8f580b21b2b 100644 --- a/src/shared/wsl-move/wsl-move-storage-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-storage-dispositions.ts @@ -30,6 +30,10 @@ export const LOCAL_STORAGE_MODULE_DISPOSITIONS: Record 'Native chat journal: chats stay local on the desktop (owner decision C), never moved.' ) ), + ...tables('orchestration', ['worker_dispatches', 'worker_terminal_resources'], { + disposition: 'rewrite-desktop', + item: 53, + note: "worktree_id of a settled local dispatch becomes the host's id; a live one holds the store until it settles. Chat and federated workers and JSON receipts stay as written." + }), + ...tables( + 'orchestration', + ['remote_dispatch_attachments'], + notApplicableDisposition( + 53, + 'What this computer told a remote home as its worker; that home reaches this runtime, which does not serve the host ids.' + ) + ), + ...tables( + 'orchestration', + ['federated_dispatches', 'federation_relay_items'], + notApplicableDisposition(53, "Another host's worktree ids and relayed messages.") + ), + ...tables( + 'orchestration', + ['dispatch_contexts', 'runs', 'tasks', 'run_coordinator_handles', 'coordinator_runs'], + notApplicableDisposition( + 53, + 'Handles, pane keys (tab and leaf ids are kept) and the host scope a process ran in, which stays true of it.' + ) + ), ...tables( 'orchestration', [ - 'worker_dispatches', - 'worker_terminal_resources', 'worker_terminal_archives', 'worker_terminal_archives_v39', - 'remote_dispatch_attachments', - 'federated_dispatches', - 'federation_relay_items', - 'dispatch_contexts', - 'coordinator_runs', - 'run_coordinator_handles', - 'runs', - 'tasks', 'decision_gates', 'deliveries', 'deliveries_new', @@ -185,6 +205,14 @@ export const SQLITE_TABLE_DISPOSITIONS: Record 'messages_new', 'question_threads', 'remote_questions', + 'attempt_observation_facts', + 'structured_pointer_operations' + ], + notApplicableDisposition(53, 'Keyed by run, task, dispatch, message or handle ids.') + ), + ...tables( + 'orchestration', + [ 'mutation_receipts', 'mutation_receipt_ledger', 'mutation_caller_identities', @@ -192,17 +220,18 @@ export const SQLITE_TABLE_DISPOSITIONS: Record 'legacy_compatibility_principals', 'legacy_mail_receipts', 'legacy_operation_receipts', - 'attempt_observation_facts', - 'structured_pointer_operations', 'removed_orchestration_work', - 'removed_orchestration_work_coverage', - 'won' + 'removed_orchestration_work_coverage' ], - { - disposition: 'rewrite-desktop', - item: 53, - note: 'History is rewritten in place; an active run or dispatch in scope blocks the move.' - } + notApplicableDisposition( + 53, + 'Idempotency receipts: a replay returns the bytes first answered.' + ) + ), + ...tables( + 'orchestration', + ['won'], + notApplicableDisposition(53, "Not a table: the census reads it from migrate.ts's comment.") ), ...tables('profile-state', ['profile_state_documents'], { disposition: 'per-field', diff --git a/src/shared/wsl-move/wsl-move-ui-dispositions.ts b/src/shared/wsl-move/wsl-move-ui-dispositions.ts index b399af856dd..eb5831c0113 100644 --- a/src/shared/wsl-move/wsl-move-ui-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-ui-dispositions.ts @@ -29,11 +29,10 @@ export const UI_STATE_DISPOSITIONS = { acknowledgedAgentsByPaneKey: moveDisposition(38, { note: 'Pane keys are kept.' }), activityClearedAtByPaneKey: moveDisposition(38, { note: 'Pane keys are kept.' }), manuallyUnreadTurnsByPaneKey: moveDisposition(38, { note: 'Pane keys are kept.' }), - trustedOrcaHooks: { - disposition: 'rewrite-desktop', - item: 40, - note: 'Kept by repoId; the trust prune must not drop it while the repo is between hosts.' - }, + trustedOrcaHooks: notApplicableDisposition( + 40, + 'Kept by repoId, which the move keeps; wsl-moved-repo-ui-retention.ts stops the trust prune dropping it.' + ), collapsedGroups: { disposition: 'rewrite-desktop', item: 38, @@ -66,7 +65,7 @@ export const UI_STATE_DISPOSITIONS = { automationHostFilter: { disposition: 'rewrite-desktop', item: 38, - note: "A 'local' filter also covers the WSL host." + note: 'Single-select: a filter on this desktop widens to All hosts once, if the move carried automations.' }, jiraQuickFilterState: notApplicableDisposition(38, 'Filters belong to the Jira host.'), jiraSavedFilterState: notApplicableDisposition( diff --git a/src/shared/wsl-move/wsl-moved-project.ts b/src/shared/wsl-move/wsl-moved-project.ts new file mode 100644 index 00000000000..cc41759c581 --- /dev/null +++ b/src/shared/wsl-move/wsl-moved-project.ts @@ -0,0 +1,39 @@ +/** + * A local project the WSL host now owns. Its existing chats stay on this computer, so the desktop + * lists it read-only under its own entry; new work, terminals and edits belong to the host. + */ +export type WslMovedProjectKind = 'repo' | 'folder' + +export type WslMovedProject = { + kind: WslMovedProjectKind + /** The local row's id; the host's row carries the same id on another host. */ + id: string + displayName: string + /** The local (UNC) path the project ran at before the move. */ + path: string + distro: string +} + +/** "Use on this computer again": the whole move it undid comes back, never part of one. */ +export type WslMoveUseLocallyResult = + | { released: true; distro: string; repoIds: string[]; folderWorkspaceIds: string[] } + | { + released: false + reason: + | 'gated-off' + | 'not-moved' + | 'superseded' + | 'move-running' + | 'failed' + /** The host's copies of the project's terminals did not stop; its agents may still run. */ + | 'host-still-running' + /** The host is not answering, so its copies of the project's agents may still run. */ + | 'host-unreachable' + /** The reload would drop unsaved files in the move's own workspaces. */ + | 'unsaved-files' + detail?: string + /** `unsaved-files`: the asked-about workspaces that the move brings back. */ + unsavedWorkspaceIds?: string[] + /** `superseded`: the newest move of the distro, which has to come back first. */ + newerMove?: { repoIds: string[]; folderWorkspaceIds: string[] } + } diff --git a/src/shared/wsl-paths.ts b/src/shared/wsl-paths.ts index 40e648913bb..4385d231233 100644 --- a/src/shared/wsl-paths.ts +++ b/src/shared/wsl-paths.ts @@ -30,6 +30,13 @@ export function parseWslUncPath(path: string): WslUncPathInfo | null { } } +/** Linux form of `value` when it is a UNC path of `distro` (either spelling, either slash). */ +export function wslUncPathToLinux(value: string, distro: string): string | null { + const parsed = parseWslUncPath(value) + // WSL resolves distro names case-insensitively, and so does every UNC path to one. + return parsed && parsed.distro.toLowerCase() === distro.toLowerCase() ? parsed.linuxPath : null +} + export function isWslUncPath(path: string): boolean { return parseWslUncPath(path) !== null } diff --git a/tests/e2e/helpers/headless-paired-runtime-host.ts b/tests/e2e/helpers/headless-paired-runtime-host.ts index 6298ad04942..e5054c5b00d 100644 --- a/tests/e2e/helpers/headless-paired-runtime-host.ts +++ b/tests/e2e/helpers/headless-paired-runtime-host.ts @@ -95,6 +95,8 @@ export async function launchHeadlessPairedRuntimeHost( /** Bind a stable loopback port so `restartServeProcess` can reclaim it. */ pinnedServePort?: boolean userDataParent?: string + /** No browser of its own, as the WSL distro's server: its browser tabs live only on clients. */ + withoutBrowser?: boolean } = {} ): Promise { const userDataDir = mkdtempSync( @@ -118,7 +120,8 @@ export async function launchHeadlessPairedRuntimeHost( launchEnv: { NODE_ENV: 'development', ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1', - ORCA_E2E_HEADLESS: '1' + ORCA_E2E_HEADLESS: '1', + ...(options.withoutBrowser ? { ORCA_E2E_HEADLESS_SERVE_WITHOUT_BROWSER: '1' } : {}) }, extraEnv: options.extraEnv ?? {}, userDataDir diff --git a/tests/e2e/helpers/wsl-moved-host-profile.ts b/tests/e2e/helpers/wsl-moved-host-profile.ts new file mode 100644 index 00000000000..5cb9bd18950 --- /dev/null +++ b/tests/e2e/helpers/wsl-moved-host-profile.ts @@ -0,0 +1,239 @@ +/** + * Profile fixtures for a project a WSL move carried onto a host: the host's state right after it + * imported the project, and the copy of those rows the moving desktop keeps for that host. + */ +import { execFileSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { makeTerminalScrollbackSnapshotRef } from '../../../src/main/terminal-scrollback-snapshots' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../../src/shared/orca-profiles' +import { ORCAD_MIGRATION_MANIFEST_VERSION } from '../../../src/shared/orcad-migration/orcad-migration-manifest-validation' +import { mutateStoppedProfileState } from './persisted-profile-state' + +export const MOVED_TAB = 'moved-tab-0001' +const MOVED_LEAF = 'moved-leaf-0001' +const MOVED_GROUP = 'moved-group-0001' +const MOVED_BROWSER = 'moved-browser-0001' +const MOVED_BROWSER_PAGE = 'moved-browser-page-0001' +export const MOVED_BROWSER_URL = 'https://example.invalid/moved-docs' + +type MovedOpenFile = { filePath: string; relativePath: string; dirtyDraftContent?: string } + +/** The moved workspace's session rows, keyed as a session partition holds them. */ +export function movedWorkspaceRows( + worktreeId: string, + options: { + openFiles?: MovedOpenFile[] + runtimeEnvironmentId?: string + browserTab?: boolean + /** A UUID leaf whose history the host's daemon holds, so no stored snapshot names it. */ + daemonHistoryLeafId?: string + } = {} +): Record> { + const files = options.openFiles ?? [] + const leafId = options.daemonHistoryLeafId ?? MOVED_LEAF + const browser = options.browserTab !== false + const page = { + id: MOVED_BROWSER_PAGE, + workspaceId: MOVED_BROWSER, + worktreeId, + url: MOVED_BROWSER_URL, + title: 'Moved docs', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + return { + tabsByWorktree: { + [worktreeId]: [ + { + id: MOVED_TAB, + ptyId: null, + worktreeId, + title: 'Shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + unifiedTabs: { + [worktreeId]: [ + { + id: MOVED_TAB, + entityId: MOVED_TAB, + groupId: MOVED_GROUP, + worktreeId, + contentType: 'terminal', + label: 'Shell', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + }, + ...(browser + ? [ + { + id: MOVED_BROWSER, + entityId: MOVED_BROWSER, + groupId: MOVED_GROUP, + worktreeId, + contentType: 'browser', + label: 'Browser 1', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } + ] + : []) + ] + }, + browserTabsByWorktree: browser + ? { + [worktreeId]: [ + { + id: MOVED_BROWSER, + worktreeId, + label: 'Browser 1', + activePageId: MOVED_BROWSER_PAGE, + pageIds: [MOVED_BROWSER_PAGE], + url: MOVED_BROWSER_URL, + title: 'Moved docs', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + } + : {}, + browserPagesByWorkspace: browser ? { [MOVED_BROWSER]: [page] } : {}, + openFilesByWorktree: + files.length === 0 + ? {} + : { + [worktreeId]: files.map((file) => ({ + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId, + language: 'plaintext', + ...(options.runtimeEnvironmentId + ? { runtimeEnvironmentId: options.runtimeEnvironmentId } + : {}), + ...(file.dirtyDraftContent !== undefined + ? { dirtyDraftContent: file.dirtyDraftContent } + : {}) + })) + }, + tabGroups: { + [worktreeId]: [ + { + id: MOVED_GROUP, + worktreeId, + activeTabId: MOVED_TAB, + tabOrder: browser ? [MOVED_TAB, MOVED_BROWSER] : [MOVED_TAB] + } + ] + }, + activeGroupIdByWorktree: { [worktreeId]: MOVED_GROUP }, + terminalLayoutsByTabId: { + [MOVED_TAB]: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ...(options.daemonHistoryLeafId + ? {} + : { + scrollbackRefsByLeafId: { + [MOVED_LEAF]: makeTerminalScrollbackSnapshotRef(MOVED_TAB, MOVED_LEAF) + } + }) + } + } + } +} + +/** Lays `rows` over `session`, one keyed map at a time. */ +export function withMovedRows( + session: Record, + rows: Record> +): void { + for (const [field, entries] of Object.entries(rows)) { + const held = session[field] + session[field] = { ...(held && typeof held === 'object' ? held : {}), ...entries } + } +} + +/** + * The host's state right after it imported a moved project: a terminal tab whose only history is + * a stored scrollback snapshot, with no terminal session behind it (the move strips the desktop's). + * Drafts never reach the host: the export strips them, so `openFiles` here carry none. + */ +export function seedImportedMovedTab( + userDataDir: string, + worktreeId: string, + history: string, + options: { + importedByWslMove?: boolean + openFiles?: MovedOpenFile[] + /** False as the distro's server holds it after import: no browser, so no browser tab. */ + browserTab?: boolean + } = {} +): void { + const ref = makeTerminalScrollbackSnapshotRef(MOVED_TAB, MOVED_LEAF) + const roots = [userDataDir, path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID)] + for (const root of roots) { + mkdirSync(path.join(root, 'terminal-scrollback'), { recursive: true }) + writeFileSync(path.join(root, 'terminal-scrollback', `${ref}.bin`), history) + } + mutateStoppedProfileState(userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test seeded the profile and reads only these session maps. + const session = state.workspaceSession as Record + // Why: a headless host has no window that would restore the pane itself; keep the fixture's off it. + Reflect.set(session, 'activeWorktreeId', null) + if (options.importedByWslMove !== false) { + // The receipt a committed WSL move leaves: it is what marks these panes as moved here. + state.orcadMigrationImportReceipts = [ + { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: 'moved-history-move', + manifestSha256: '0'.repeat(64), + source: { + kind: 'local-wsl', + distro: 'Ubuntu', + scopeRepoIds: [worktreeId.split('::')[0]], + scopeFolderWorkspaceIds: [], + generation: 1 + }, + importedAt: '2026-10-23T00:00:00.000Z', + repositoryIds: [worktreeId.split('::')[0]], + projectGroupIds: [], + folderWorkspaceIds: [] + } + ] + } + const openFiles = (options.openFiles ?? []).map( + ({ dirtyDraftContent: _draft, ...file }) => file + ) + const rows = movedWorkspaceRows(worktreeId, { openFiles, browserTab: options.browserTab }) + withMovedRows(session, rows) + }) +} + +export function createMovedRepo(root: string): string { + const repoPath = path.join(root, 'moved-repo') + mkdirSync(repoPath) + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repoPath }) + const identity = ['-c', 'user.name=e2e', '-c', 'user.email=e2e@example.invalid'] + execFileSync('git', [...identity, 'commit', '-q', '--allow-empty', '-m', 'init'], { + cwd: repoPath + }) + return repoPath +} diff --git a/tests/e2e/wsl-move-dialog.spec.ts b/tests/e2e/wsl-move-dialog.spec.ts new file mode 100644 index 00000000000..a7ce0697710 --- /dev/null +++ b/tests/e2e/wsl-move-dialog.spec.ts @@ -0,0 +1,438 @@ +/** + * The WSL move dialog against a fake move transport: no wsl.exe or distro in CI. Main's + * `wslMove:*` handlers are swapped for a scripted host, so the real dialog runs open preview → + * blockers → skip → start → progress → refusal → start again → moved → reload. A second test + * proves that with the internal gate closed none of it shows and main refuses. + */ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { + _electron as electron, + type ElectronApplication, + type Page +} from '@stablyai/playwright-test' +import { getE2ECompletedOnboardingProfile } from './helpers/e2e-completed-onboarding-profile' +import { getOrcaElectronLaunchArgs } from './helpers/electron-launch-args' +import { cleanupE2EDaemons, closeElectronAppForE2E } from './helpers/electron-process-shutdown' +import { createElectronHomeIsolation } from './helpers/electron-home-isolation' +import { expect, forwardElectronProcessLogs, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { encodePairingOffer } from '../../src/shared/pairing' +import { addWslHostEnvironment } from '../../src/shared/runtime-environment-wsl-host-store' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../src/shared/wsl-host-internal-gate' +import type { + WslMovePreview, + WslMoveProgressEvent, + WslMoveResult +} from '../../src/shared/wsl-move/wsl-move-driver-types' + +test.use({ seedTestRepo: false }) + +const DISTRO = 'Ubuntu' +const LEAF = '6f1c1d2e-3a4b-4c5d-8e9f-0a1b2c3d4e5f' +// Visual proof for the PR; outside the repo on purpose. +const PROOF_DIR = process.env.ORCA_D1B9_PROOF_DIR + +type Desktop = { app: ElectronApplication; page: Page; dispose: () => Promise } + +function createGitProject(name: string): string { + const dir = path.join(mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-wsl-move-')), name) + execFileSync('git', ['init', '-q', '-b', 'main', dir]) + writeFileSync(path.join(dir, 'README.md'), `# ${name}\n`) + execFileSync('git', ['-C', dir, 'add', '.']) + execFileSync( + 'git', + ['-C', dir, '-c', 'user.name=e2e', '-c', 'user.email=e2e@example.com', 'commit', '-qm', 'init'], + { stdio: 'ignore' } + ) + return dir +} + +async function launch( + testInfo: Parameters[1], + internalGate: boolean +): Promise { + const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-wsl-move-ui-')) + writeFileSync( + path.join(userDataDir, 'orca-data.json'), + `${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n` + ) + // A saved host for the distro; it never connects here, which the dialog does not need. + addWslHostEnvironment(userDataDir, { + id: 'wsl:ubuntu', + name: `${DISTRO} (WSL)`, + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:9', + deviceToken: 'move-ui-token', + publicKeyB64: Buffer.alloc(32, 7).toString('base64') + }), + record: { + kind: 'wsl', + distro: DISTRO, + distroId: '0e3a7f2c-1b4d-4c5e-9f60-7a8b9c0d1e2f', + localPort: 9, + remotePort: 6768 + } + }) + const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env + void _unused + const isolation = createElectronHomeIsolation({ + inheritedEnv: cleanEnv, + launchEnv: {}, + extraEnv: { + ...(internalGate ? { [WSL_HOSTS_INTERNAL_GATE_ENV]: '1' } : {}), + ORCA_E2E_FAKE_WSL_HOSTS: JSON.stringify({ + distros: [{ name: DISTRO, state: 'stopped', version: 2 }], + pairingCode: 'unused' + }) + }, + userDataDir + }) + const app = await electron.launch({ + args: getOrcaElectronLaunchArgs(path.join(process.cwd(), 'out', 'main', 'index.js'), false), + env: { ...isolation.env, NODE_ENV: 'development', ORCA_E2E_HEADLESS: '1' } + }) + forwardElectronProcessLogs(app, testInfo) + const dispose = async (): Promise => { + await closeElectronAppForE2E(app) + await cleanupE2EDaemons(userDataDir) + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }) + } + try { + const page = await app.firstWindow({ timeout: 120_000 }) + await page.waitForLoadState('domcontentloaded') + await waitForSessionReady(page) + await page.evaluate(async () => { + const settings = await window.api.settings.set({ experimentalWslHosts: true }) + window.__store?.setState({ settings }) + }) + return { app, page, dispose } + } catch (error) { + await dispose() + throw error + } +} + +async function addProject(page: Page, projectPath: string): Promise { + const repoId = await page.evaluate(async (repoPath) => { + const result = await window.api.repos.add({ path: repoPath }) + if ('error' in result) { + throw new Error(result.error) + } + return result.repo.id + }, projectPath) + await expect + .poll(() => + page.evaluate(async (id) => { + const store = window.__store + await store?.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) + return Boolean(store?.getState().repos.some((repo) => repo.id === id)) + }, repoId) + ) + .toBe(true) + return repoId +} + +/** The terminal tab of a project's main workspace, for a blocker that names one of its panes. */ +async function terminalTabOf(page: Page, repoId: string): Promise { + await expect + .poll(() => + page.evaluate(async (id) => { + const state = window.__store?.getState() + await state?.fetchWorktrees(id) + const worktree = window.__store?.getState().worktreesByRepo[id]?.[0] + if (worktree) { + window.__store?.getState().setActiveWorktree(worktree.id) + } + return Boolean(worktree) + }, repoId) + ) + .toBe(true) + await ensureTerminalVisible(page, 30_000) + return page.evaluate(() => { + const state = window.__store!.getState() + const tab = state.tabsByWorktree[state.activeWorktreeId!]?.[0] + if (!tab) { + throw new Error('Expected a terminal tab') + } + return tab.id + }) +} + +type FakeScript = { + distroPreview: WslMovePreview + selectionPreview: WslMovePreview + results: WslMoveResult[] +} + +/** The scripted host's state in Electron main, shared by the evaluate calls below. */ +type FakeMoveState = { + starts: unknown[] + pending: ((result: unknown) => void)[] + results: unknown[] + unfinished: unknown[] + cancelAllowed: boolean +} + +declare global { + // Set only in Electron main, by installFakeMove. + var __wslMoveFake: FakeMoveState | undefined +} + +/** Swaps main's move handlers for a scripted host; the renderer and its bridge stay real. */ +async function installFakeMove(app: ElectronApplication, script: FakeScript): Promise { + await app.evaluate(({ ipcMain }, fake) => { + const state: FakeMoveState = { + starts: [], + pending: [], + results: [...fake.results], + unfinished: [], + cancelAllowed: true + } + globalThis.__wslMoveFake = state + for (const channel of [ + 'wslMove:preview', + 'wslMove:start', + 'wslMove:cancel', + 'wslMove:listUnfinished' + ]) { + ipcMain.removeHandler(channel) + } + ipcMain.handle('wslMove:preview', (_event, request: { repoIds?: string[] }) => + request.repoIds ? fake.selectionPreview : fake.distroPreview + ) + ipcMain.handle('wslMove:start', (_event, request: unknown) => { + state.starts.push(request) + return new Promise((resolve) => state.pending.push(resolve)) + }) + ipcMain.handle('wslMove:cancel', () => state.cancelAllowed) + ipcMain.handle('wslMove:listUnfinished', () => state.unfinished) + }, script) +} + +async function sendProgress(app: ElectronApplication, event: WslMoveProgressEvent): Promise { + await app.evaluate(({ BrowserWindow }, progress) => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('wslMove:progress', progress) + } + }, event) +} + +/** Ends the running start with the next scripted result. */ +async function finishStart(app: ElectronApplication, unfinished: unknown[] = []): Promise { + await app.evaluate((_electron, nextUnfinished) => { + const state = globalThis.__wslMoveFake + if (!state) { + throw new Error('installFakeMove did not run') + } + state.unfinished = nextUnfinished + state.pending.shift()?.(state.results.shift()) + }, unfinished) +} + +async function settle(page: Page): Promise { + // Finite animations only: spinners run forever; dialog fades do not. + await page.waitForFunction(() => + document + .getAnimations() + .every( + (animation) => + animation.playState !== 'running' || + animation.effect?.getComputedTiming().iterations === Infinity + ) + ) +} + +async function proof( + page: Page, + testInfo: Parameters[1], + name: string +): Promise { + await settle(page) + const file = `wsl-move-${name}.png` + await page.screenshot({ path: testInfo.outputPath(file) }) + if (PROOF_DIR) { + await page.screenshot({ path: path.join(PROOF_DIR, file) }) + } +} + +async function openServersPane(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsPage() + state?.openSettingsTarget({ pane: 'servers', repoId: null }) + }) + await expect(page.locator('[data-settings-section="wsl-hosts"]')).toBeVisible({ timeout: 30_000 }) +} + +// oxlint-disable-next-line no-empty-pattern -- Playwright passes fixtures before testInfo. +test('moves projects through preview, blockers, progress and result', async ({}, testInfo) => { + // Two git projects are created, previewed and moved through every dialog step. + test.setTimeout(240_000) + const apiPath = createGitProject('payments-api') + const webPath = createGitProject('web-app') + const desktop = await launch(testInfo, true) + const { app, page } = desktop + try { + const apiId = await addProject(page, apiPath) + const webId = await addProject(page, webPath) + const webTab = await terminalTabOf(page, webId) + const ready = { + outcome: 'ready' as const, + distro: DISTRO, + changed: [], + heldByHost: [], + refused: [{ kind: 'repo' as const, id: 'drive-repo', reason: 'windows-drive' }] + } + await installFakeMove(app, { + distroPreview: { + ...ready, + repoIds: [apiId, webId], + folderWorkspaceIds: [], + blockers: [ + { kind: 'process-running', ids: [`${webTab}:${LEAF}`], actions: ['end', 'skip-project'] } + ] + }, + selectionPreview: { ...ready, repoIds: [apiId], folderWorkspaceIds: [], blockers: [] }, + results: [ + { outcome: 'refused', code: 'wsl_move_pause_failed', detail: 'sleep' }, + { + outcome: 'moved', + migrationId: 'move-1', + phase: 'retained', + reloadRequired: true, + automationsPending: ['nightly-triage'] + } + ] + }) + + // Entry: the WSL host row's menu in Settings → Servers. + await openServersPane(page) + const hosts = page.locator('[data-settings-section="wsl-hosts"]') + await hosts.getByRole('button', { name: `More actions for ${DISTRO} (WSL)` }).click() + const entry = page.getByRole('menuitem', { name: 'Move projects from this distro…' }) + await expect(entry).toBeVisible() + await proof(page, testInfo, 'host-row-menu') + await entry.click() + const dialog = page.getByTestId('wsl-move-dialog') + await expect( + dialog.getByRole('heading', { name: `Move projects to the ${DISTRO} host` }) + ).toBeVisible() + await expect(dialog.getByTestId('wsl-move-chats-stay')).toHaveText( + 'Existing chats stay on this computer. New chats in this project start on the WSL host.' + ) + await expect(dialog.getByRole('checkbox')).toHaveCount(2) + await expect( + dialog.getByText('Its folder is on a Windows drive, so it stays on this computer.') + ).toBeVisible() + const blockers = dialog.getByTestId('wsl-move-blockers') + await expect(blockers.getByText('A terminal is running a program')).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Move 2 projects' })).toBeDisabled() + await proof(page, testInfo, 'blockers') + + // Skip the blocked project: the selection narrows and its preview is clear. + await blockers.getByRole('button', { name: 'Leave web-app out of this move' }).click() + await expect(blockers).toHaveCount(0) + const move = dialog.getByRole('button', { name: 'Move 1 project' }) + await expect(move).toBeEnabled() + await expect(dialog.getByTestId('wsl-move-counts')).toContainText('Projects: 1') + await proof(page, testInfo, 'preview') + + // First run: refused, with a plain message and a way back. + await move.click() + await expect(dialog.getByTestId('wsl-move-progress')).toBeVisible() + await finishStart(app) + await expect(dialog.getByTestId('wsl-move-result')).toHaveAttribute('data-outcome', 'refused') + await expect(dialog.getByText('could not pause the terminals')).toBeVisible() + await proof(page, testInfo, 'refusal') + await dialog.getByRole('button', { name: 'Back to projects' }).click() + await expect(dialog.getByRole('button', { name: 'Move 1 project' })).toBeEnabled() + + // Second run: progress steps and bytes, cancel only before the switch. + await dialog.getByRole('button', { name: 'Move 1 project' }).click() + await sendProgress(app, { distro: DISTRO, step: 'pausing-terminals', migrationId: 'move-1' }) + await sendProgress(app, { + distro: DISTRO, + step: 'copying', + migrationId: 'move-1', + bytesSent: 14 * 1024 * 1024, + bytesTotal: 34 * 1024 * 1024 + }) + const progress = dialog.getByTestId('wsl-move-progress') + await expect(progress.locator('[data-state="active"]')).toHaveText('Copying') + await expect(dialog.getByText('14 MB of 34 MB')).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Cancel move' })).toBeEnabled() + await proof(page, testInfo, 'progress') + // Closing only hides a running move; the entry point shows it again. + await page.keyboard.press('Escape') + await expect(dialog).toHaveCount(0) + await hosts.getByRole('button', { name: `More actions for ${DISTRO} (WSL)` }).click() + await page.getByRole('menuitem', { name: 'Move projects from this distro…' }).click() + await expect(dialog.getByTestId('wsl-move-progress')).toBeVisible() + await sendProgress(app, { distro: DISTRO, step: 'switching', migrationId: 'move-1' }) + await expect(dialog.getByRole('button', { name: 'Cancel move' })).toBeDisabled() + + await finishStart(app, [ + { migrationId: 'move-1', distro: DISTRO, automationsPending: ['nightly-triage'] } + ]) + await expect(dialog.getByTestId('wsl-move-result')).toHaveAttribute('data-outcome', 'moved') + await expect(dialog.getByTestId('wsl-move-automations-pending')).toBeVisible() + await page.keyboard.press('Escape') + await expect(dialog).toBeVisible() + await proof(page, testInfo, 'success') + expect(await app.evaluate(() => globalThis.__wslMoveFake?.starts)).toEqual([ + { distro: DISTRO, repoIds: [apiId], folderWorkspaceIds: [], sleptPanes: [] }, + { distro: DISTRO, repoIds: [apiId], folderWorkspaceIds: [], sleptPanes: [] } + ]) + + // Reload goes through restart preparation and lands a fresh page. + const navigated = page.waitForEvent('framenavigated', { timeout: 60_000 }) + await dialog.getByRole('button', { name: 'Reload now' }).click() + await navigated + await page.waitForLoadState('domcontentloaded') + await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 60_000 }) + await waitForSessionReady(page) + await expect(page.getByTestId('wsl-move-dialog')).toHaveCount(0) + + // The automations still off show under the host until it confirms. + await openServersPane(page) + await expect(page.getByTestId('wsl-move-unfinished')).toContainText( + `One automation moved to the ${DISTRO} host is still off.` + ) + await proof(page, testInfo, 'unfinished-notice') + } finally { + await desktop.dispose() + rmSync(path.dirname(apiPath), { recursive: true, force: true }) + rmSync(path.dirname(webPath), { recursive: true, force: true }) + } +}) + +// oxlint-disable-next-line no-empty-pattern -- Playwright passes fixtures before testInfo. +test('with the internal gate closed, no move entry shows and main refuses', async ({}, testInfo) => { + // The gated-off launch still boots a full app before main is asked to refuse. + test.setTimeout(180_000) + const desktop = await launch(testInfo, false) + const { page } = desktop + try { + expect( + await page.evaluate((distro) => window.api.wslMove.preview({ distro }), DISTRO) + ).toMatchObject({ outcome: 'refused', code: 'wsl_move_unavailable' }) + expect(await page.evaluate(() => window.api.wslMove.listUnfinished())).toEqual([]) + await page.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsPage() + state?.openSettingsTarget({ pane: 'servers', repoId: null }) + }) + await expect(page.getByText('No saved servers', { exact: true })).toBeVisible({ + timeout: 30_000 + }) + await expect(page.locator('[data-settings-section="wsl-hosts"]')).toHaveCount(0) + await expect(page.getByText('Move projects from this distro…')).toHaveCount(0) + await expect(page.getByTestId('wsl-move-dialog')).toHaveCount(0) + } finally { + await desktop.dispose() + } +}) diff --git a/tests/e2e/wsl-move-host-browser-drafts.spec.ts b/tests/e2e/wsl-move-host-browser-drafts.spec.ts new file mode 100644 index 00000000000..2c90224895b --- /dev/null +++ b/tests/e2e/wsl-move-host-browser-drafts.spec.ts @@ -0,0 +1,149 @@ +/** + * A moved project's browser tabs and unsaved drafts, as the moving desktop sees them once the WSL + * host owns the project. The distro's server has no browser of its own and the move never sends it + * drafts, so both come back only from the copy of the host's session this desktop kept. + * + * Run: + * pnpm exec playwright test tests/e2e/wsl-move-host-browser-drafts.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { launchPairedElectronClient } from './helpers/paired-electron-client' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { mutateStoppedProfileState } from './helpers/persisted-profile-state' +import { + createMovedRepo, + MOVED_BROWSER_URL, + movedWorkspaceRows, + seedImportedMovedTab, + withMovedRows +} from './helpers/wsl-moved-host-profile' +import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' + +const DRAFT = 'unsaved words typed before the move' + +test("a moved project's browser tab and draft come back on the desktop from a browserless host", async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring to request no fixtures. +{}, testInfo) => { + test.skip(process.platform === 'win32', 'POSIX paths') + // A host and two client launches, with a profile edit between them. + test.setTimeout(600_000) + const root = mkdtempSync(path.join(os.tmpdir(), 'orca-moved-browser-drafts-')) + const repoPath = realpathSync(createMovedRepo(root)) + const notesPath = path.join(repoPath, 'notes.md') + writeFileSync(notesPath, 'saved words\n') + const openFile = { filePath: notesPath, relativePath: 'notes.md' } + const host = await launchHeadlessPairedRuntimeHost({ + pinnedServePort: true, + withoutBrowser: true, + // Stands in for the distro's orcad, the only host that serves moved panes' history. + extraEnv: { ORCA_E2E_WSL_MOVE_DESTINATION: '1' } + }) + try { + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: repoPath, + kind: 'git' + }) + const worktreeId = `${added.result.repo.id}::${repoPath}` + await host.restartServeProcess({ + betweenProcesses: () => + seedImportedMovedTab(host.userDataDir, worktreeId, 'moved history\r\n', { + openFiles: [openFile], + browserTab: false + }) + }) + const first = await launchPairedElectronClient(host.offer, testInfo, 'moved-browser-drafts') + const { environmentId, userDataDir } = first + let client = first + try { + await expect + .poll( + () => + client.page.evaluate( + (id) => + Boolean( + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) + ), + worktreeId + ), + { timeout: 60_000 } + ) + .toBe(true) + // This desktop mirrors the host's tabs before it quits, as one open before the move did. + await expect + .poll( + () => + client.page.evaluate( + (id) => (window.__store?.getState().unifiedTabsByWorktree[id] ?? []).length, + worktreeId + ), + { timeout: 60_000, message: "the desktop never mirrored the host's tabs" } + ) + .toBeGreaterThan(0) + await client.quitPreservingProfile() + // The move's seed of this desktop's copy of the host's session: browser tab and draft included. + mutateStoppedProfileState(userDataDir, (state) => { + const held = state.workspaceSessionsByHostId + const partitions: Record = + held && typeof held === 'object' ? { ...held } : {} + const hostId = toRuntimeExecutionHostId(environmentId) + const current = partitions[hostId] + const partition: Record = + current && typeof current === 'object' ? { ...current } : {} + withMovedRows( + partition, + movedWorkspaceRows(worktreeId, { + openFiles: [{ ...openFile, dirtyDraftContent: DRAFT }], + runtimeEnvironmentId: environmentId + }) + ) + state.workspaceSessionsByHostId = { ...partitions, [hostId]: partition } + }) + client = await launchPairedElectronClient(host.offer, testInfo, 'moved-browser-drafts', { + reuseUserDataDir: userDataDir + }) + await client.page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(id) + }, worktreeId) + const readMoved = () => + client.page.evaluate( + ({ id, path: filePath }) => { + const store = window.__store?.getState() + const browserUrls = (store?.browserTabsByWorktree[id] ?? []).flatMap((workspace) => + (store?.browserPagesByWorkspace[workspace.id] ?? []).map((page) => page.url) + ) + const file = store?.openFiles.find( + (entry) => entry.worktreeId === id && entry.filePath === filePath + ) + return { + browserUrls, + tabs: (store?.unifiedTabsByWorktree[id] ?? []).map((tab) => tab.contentType), + draft: file ? (store?.editorDrafts[file.id] ?? null) : null + } + }, + { id: worktreeId, path: notesPath } + ) + await expect + .poll(async () => (await readMoved()).tabs, { timeout: 60_000 }) + .toEqual(expect.arrayContaining(['browser', 'editor'])) + const moved = await readMoved() + console.log(`[moved-browser-drafts] ${JSON.stringify(moved)}`) + // What the user sees: a tab for each, not only rows in the store. + expect.soft(moved.tabs).toEqual(expect.arrayContaining(['browser', 'editor'])) + expect.soft(moved.browserUrls).toContain(MOVED_BROWSER_URL) + expect.soft(moved.draft).toBe(DRAFT) + } finally { + await client.dispose() + } + } finally { + await host.dispose() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/tests/e2e/wsl-move-host-pane-history.spec.ts b/tests/e2e/wsl-move-host-pane-history.spec.ts new file mode 100644 index 00000000000..8ed846abccd --- /dev/null +++ b/tests/e2e/wsl-move-host-pane-history.spec.ts @@ -0,0 +1,242 @@ +/** + * A moved project's terminals, as the desktop sees them once the WSL host owns them: the host + * serves the workspace, its panes were put to sleep (so the host holds only a stored scrollback + * snapshot and no live terminal), and this desktop has never painted them. Opening the tab must + * show the history the pane had before it slept. + * + * Topology: the headed fixture app stands in for the distro's orcad; a paired desktop client + * stands in for the moving desktop. The host's own sleep writes the snapshot the move would. + * + * Run: + * pnpm exec playwright test tests/e2e/wsl-move-host-pane-history.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { randomUUID } from 'node:crypto' +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient +} from './helpers/paired-electron-client' +import { + callEnvironment, + createPairedHostTerminal, + openPairedClientTab, + readPairedPaneContent +} from './helpers/paired-host-terminal' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { createMovedRepo, MOVED_TAB, seedImportedMovedTab } from './helpers/wsl-moved-host-profile' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' +import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' + +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-moved-history-')) +const fixturePath = path.join(scratch, 'history-terminal.mjs') +writeFileSync( + fixturePath, + [ + 'const marker = process.argv[2]', + 'for (let i = 0; i < 40; i += 1) process.stdout.write(`${marker} line ${i}\\r\\n`)', + 'process.stdin.resume()' + ].join('\n') +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function fixtureCommand(marker: string): string { + return [process.execPath, fixturePath, marker].map(quotePosixShell).join(' ') +} + +type ClientTabState = { + refs: Record + ptyIds: Record + buffers: number + localOnly: number +} + +async function readClientTabState(page: Page, tabId: string): Promise { + return page.evaluate((id) => { + const state = window.__store?.getState() + const layout = state?.terminalLayoutsByTabId?.[id] + return { + refs: { ...layout?.scrollbackRefsByLeafId }, + ptyIds: { ...layout?.ptyIdsByLeafId }, + buffers: Object.values(layout?.buffersByLeafId ?? {}).join('').length, + localOnly: Object.values(state?.localOnlyScrollbackByTabId?.[id] ?? {}).join('').length + } + }, tabId) +} + +test('a slept host pane this desktop never painted shows its history when opened', async ({ + orcaPage +}, testInfo) => { + test.skip(process.platform === 'win32', 'POSIX fixture command') + // A paired host and client each boot a full app, and the host terminal runs before the check. + test.setTimeout(600_000) + const worktreeId = await orcaPage.evaluate(() => { + const id = window.__store?.getState().activeWorktreeId + if (!id) { + throw new Error('host has no active worktree') + } + return id + }) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedElectronClient(offer, testInfo, 'moved-history') + try { + await expect + .poll( + () => + client.page.evaluate( + (id) => + Boolean( + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) + ), + worktreeId + ), + { timeout: 60_000 } + ) + .toBe(true) + const marker = `moved-${randomUUID()}` + const terminal = await createPairedHostTerminal( + client.page, + client.environmentId, + worktreeId, + fixtureCommand(marker) + ) + // The host's terminal prints before it sleeps; the client never mounts the tab. + await expect + .poll( + async () => { + const read = await callEnvironment(client.page, client.environmentId, 'terminal.read', { + terminal: terminal.terminal, + screen: true + }) + return JSON.stringify(read).includes(`${marker} line 39`) + }, + { timeout: 30_000, message: 'the host terminal never printed its last line' } + ) + .toBe(true) + await callEnvironment(client.page, client.environmentId, 'worktree.sleep', { + worktree: `id:${worktreeId}` + }) + await client.page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(id) + }, worktreeId) + console.log( + `[moved-history] before-open ${JSON.stringify(await readClientTabState(client.page, terminal.webTabId))}` + ) + await openPairedClientTab(client.page, worktreeId, terminal.webTabId) + await expect + .poll(() => readPairedPaneContent(client.page, terminal.webTabId), { timeout: 30_000 }) + .toContain(`${marker} line 39`) + console.log( + `[moved-history] after-open ${JSON.stringify(await readClientTabState(client.page, terminal.webTabId))}` + ) + const content = await readPairedPaneContent(client.page, terminal.webTabId) + console.log(`[moved-history] content-tail ${JSON.stringify(content.slice(-400))}`) + expect(content).toContain(`${marker} line 39`) + } finally { + await client.dispose() + } +}) + +type ImportedOutcome = { marker: string; content: string; browserUrls: string[] } + +async function openImportedPane( + testInfo: TestInfo, + importedByWslMove: boolean +): Promise { + const outcome: ImportedOutcome = { marker: '', content: '', browserUrls: [] } + const root = mkdtempSync(path.join(os.tmpdir(), 'orca-moved-import-')) + const repoPath = createMovedRepo(root) + // A windowless host, as the distro's orcad is: nothing there restores a pane or a browser tab itself. + const host = await launchHeadlessPairedRuntimeHost({ + pinnedServePort: true, + // Stands in for the distro's orcad, the only host that serves moved panes' history. + extraEnv: { ORCA_E2E_WSL_MOVE_DESTINATION: '1' } + }) + try { + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: repoPath, + kind: 'git' + }) + const worktreeId = `${added.result.repo.id}::${realpathSync(repoPath)}` + outcome.marker = `imported-${randomUUID()} before the move` + await host.restartServeProcess({ + betweenProcesses: () => + seedImportedMovedTab(host.userDataDir, worktreeId, `${outcome.marker}\r\n`, { + importedByWslMove + }) + }) + const client = await launchPairedElectronClient(host.offer, testInfo, 'moved-import') + try { + const webTabId = toWebTerminalSurfaceTabId(MOVED_TAB) + await expect + .poll( + () => + client.page.evaluate( + (id) => + Boolean( + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) + ), + worktreeId + ), + { timeout: 60_000 } + ) + .toBe(true) + await client.page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(id) + }, worktreeId) + await openPairedClientTab(client.page, worktreeId, webTabId) + // The pane has started once it shows anything: its replayed history or a fresh prompt. + await expect + .poll(async () => (await readPairedPaneContent(client.page, webTabId)).trim().length, { + timeout: 30_000 + }) + .toBeGreaterThan(0) + const state = await readClientTabState(client.page, webTabId) + console.log(`[moved-history] import after-open ${JSON.stringify(state)}`) + outcome.content = await readPairedPaneContent(client.page, webTabId) + console.log( + `[moved-history] import content-tail ${JSON.stringify(outcome.content.slice(-300))}` + ) + outcome.browserUrls = await client.page.evaluate((id) => { + const store = window.__store?.getState() + return (store?.browserTabsByWorktree[id] ?? []).flatMap((workspace) => + (store?.browserPagesByWorkspace[workspace.id] ?? []).map((page) => page.url) + ) + }, worktreeId) + console.log(`[moved-history] import browser-urls ${JSON.stringify(outcome.browserUrls)}`) + return outcome + } finally { + await client.dispose() + } + } finally { + await host.dispose() + rmSync(root, { recursive: true, force: true }) + } +} + +test("an ordinary pane's saved snapshot is not replayed by a host start", async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring to request no fixtures. +{}, testInfo) => { + test.skip(process.platform === 'win32', 'POSIX paths') + // A paired host and client each boot a full app, and the host terminal runs before the check. + test.setTimeout(600_000) + // No move receipt: the same saved state on a host that was never a move's destination. + const outcome = await openImportedPane(testInfo, false) + expect(outcome.content).not.toContain(outcome.marker) +}) diff --git a/tests/e2e/wsl-move-moved-project-chats.spec.ts b/tests/e2e/wsl-move-moved-project-chats.spec.ts new file mode 100644 index 00000000000..08d25a2d35f --- /dev/null +++ b/tests/e2e/wsl-move-moved-project-chats.spec.ts @@ -0,0 +1,296 @@ +/** + * After a WSL move commits, the moved project's existing chats stay on this computer (owner + * decision, option C): the sidebar lists them under a read-only "Moved to host" entry, + * while the project's terminals stay with the host and are no longer restored here. + */ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { createRestartSession } from './helpers/orca-restart' +import { + mutateStoppedProfileState, + readPersistedProfileState +} from './helpers/persisted-profile-state' +import { wslMoveCutoverFixture } from '../../src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../../src/main/persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { seedTestAgentSessionRecordStore } from '../../src/main/runtime/agent-session-record-store-test-harness' +import { structuredAgentSessionTabId } from '../../src/shared/structured-agent-session-projection' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../src/shared/wsl-host-internal-gate' +import { closeTestJournalHostDatabases } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../src/shared/agent-session-record.test-fixture' + +test.use({ seedTestRepo: false }) + +const GROUP = 'moved-group' +const TERMINAL = 'moved-terminal' +const CHAT_SESSION = 'moved-chat-0001' +const CHAT = structuredAgentSessionTabId(CHAT_SESSION) +const CHAT_LABEL = 'Plan the parser rewrite' +// Visual proof for the PR; outside the repo on purpose. +const PROOF_DIR = process.env.ORCA_D1B7A_PROOF_DIR ?? process.env.ORCA_WSL_USE_HERE_PROOF_DIR + +type SessionTabs = { + tabsByWorktree?: Record + unifiedTabs?: Record + tabGroups?: Record + activeGroupIdByWorktree?: Record +} + +function sessionOf(state: Record): SessionTabs { + const session = state.workspaceSession + if (!session || typeof session !== 'object') { + throw new Error('Expected a persisted workspace session') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test seeded the profile and checks each optional field at use. + return session as SessionTabs +} + +/** The chat's durable record on this computer, at rest, in the moved project's worktree. */ +async function seedChatRecord(userDataDir: string, worktreeId: string): Promise { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: CHAT_SESSION, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released' + }) + ) + await seedTestAgentSessionRecordStore(userDataDir, { + records: [{ ...record, location: { ...record.location, workspaceId: worktreeId } }], + sessionTabs: [{ tabId: CHAT, sessionId: CHAT_SESSION }] + }) + closeTestJournalHostDatabases() +} + +/** A terminal and a native chat in the moved project, as a session saved before the move. */ +function seedMovedProjectTabs(userDataDir: string, worktreeId: string): void { + mutateStoppedProfileState(userDataDir, (state) => { + const session = sessionOf(state) + const tab = (id: string, contentType: string, label: string, sortOrder: number) => ({ + id, + entityId: contentType === 'agent-session' ? CHAT_SESSION : id, + groupId: GROUP, + worktreeId, + contentType, + label, + customLabel: contentType === 'agent-session' ? label : null, + color: null, + sortOrder, + createdAt: sortOrder, + ...(contentType === 'agent-session' ? { agentSessionAgent: 'claude' } : {}) + }) + session.tabsByWorktree = { + ...session.tabsByWorktree, + [worktreeId]: [ + { + id: TERMINAL, + ptyId: null, + worktreeId, + title: 'Shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + } + session.unifiedTabs = { + ...session.unifiedTabs, + [worktreeId]: [ + tab(TERMINAL, 'terminal', 'Shell', 0), + tab(CHAT, 'agent-session', CHAT_LABEL, 1) + ] + } + session.tabGroups = { + ...session.tabGroups, + [worktreeId]: [{ id: GROUP, worktreeId, activeTabId: CHAT, tabOrder: [TERMINAL, CHAT] }] + } + session.activeGroupIdByWorktree = { ...session.activeGroupIdByWorktree, [worktreeId]: GROUP } + }) +} + +async function addRepo(page: Page, repoPath: string): Promise { + return page.evaluate(async (path) => { + const result = await window.api.repos.add({ path }) + if ('error' in result) { + throw new Error(result.error) + } + return result.repo.id + }, repoPath) +} + +async function movedTabs(page: Page, worktreeId: string) { + return page.evaluate((id) => { + const state = window.__store!.getState() + return { + unified: (state.unifiedTabsByWorktree[id] ?? []).map((tab) => tab.id), + terminals: (state.tabsByWorktree[id] ?? []).map((tab) => tab.id), + activeWorktreeId: state.activeWorktreeId + } + }, worktreeId) +} + +test("a moved project's existing chats stay listed and openable on this computer", async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring to request no fixtures. +{}, testInfo) => { + const root = mkdtempSync(join(tmpdir(), 'orca-wsl-moved-chats-')) + const repoPath = join(root, 'moved-repo') + mkdirSync(repoPath) + const gitIdentity = ['-c', 'user.name=e2e', '-c', 'user.email=e2e@example.invalid'] + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repoPath }) + execFileSync('git', [...gitIdentity, 'commit', '-q', '--allow-empty', '-m', 'init'], { + cwd: repoPath + }) + const session = createRestartSession(testInfo) + let launched: Awaited> | undefined + try { + launched = await session.launch() + await waitForSessionReady(launched.page) + const repoId = await addRepo(launched.page, repoPath) + const worktreeId = `${repoId}::${realpathSync(repoPath)}` + await session.close(launched.app) + launched = undefined + + seedMovedProjectTabs(session.userDataDir, worktreeId) + await seedChatRecord(session.userDataDir, worktreeId) + seedWslMoveCutover( + session.userDataDir, + wslMoveCutoverFixture('move-1', 'destination-committed', {}, { repoIds: [repoId] }) + ) + + launched = await session.launch() + const { page } = launched + await waitForSessionReady(page) + // Session History's reveal: the host reads the chat's record and republishes its tab into the + // moved project's worktree, which only the retained entry lists. + const revealed = await page.evaluate( + (sessionId) => + window.api.runtime.call({ method: 'agentSession.reveal', params: { sessionId } }), + CHAT_SESSION + ) + expect(revealed).toMatchObject({ ok: true, result: { ok: true, workspaceId: worktreeId } }) + const entry = page.getByTestId('wsl-moved-project') + await expect(entry).toBeVisible({ timeout: 30_000 }) + await expect(entry).toContainText('moved-repo') + await expect(entry).toContainText('Moved to Ubuntu host') + const chat = entry.getByTestId('wsl-moved-chat') + await expect(chat).toHaveCount(1) + // Read-only: the chat and "use on this computer again" are the entry's only controls. + await expect(entry.getByRole('button')).toHaveCount(2) + // The project's terminal lives on the host now; only its chat is here. + expect(await movedTabs(page, worktreeId)).toMatchObject({ unified: [CHAT], terminals: [] }) + if (PROOF_DIR) { + mkdirSync(PROOF_DIR, { recursive: true }) + await page.screenshot({ path: join(PROOF_DIR, 'moved-project-sidebar.png') }) + } + + await chat.click() + await expect + .poll(async () => (await movedTabs(page, worktreeId)).activeWorktreeId) + .toBe(worktreeId) + await expect(chat).toHaveAttribute('data-current', 'true') + if (PROOF_DIR) { + await page.screenshot({ path: join(PROOF_DIR, 'moved-project-chat-open.png') }) + } + await session.close(launched.app) + launched = undefined + + // The freeze kept the slept terminal for a downgrade, and the chat tab beside it. + const persisted = sessionOf(readPersistedProfileState(session.userDataDir)) + expect(persisted.tabsByWorktree?.[worktreeId]).toHaveLength(1) + expect(persisted.unifiedTabs?.[worktreeId]?.map((tab) => tab.id)).toEqual([TERMINAL, CHAT]) + } finally { + if (launched) { + await session.close(launched.app) + } + await session.dispose() + rmSync(root, { recursive: true, force: true }) + } +}) + +test('"Use on this computer again" brings a moved project back as it was before the move', async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring to request no fixtures. +{}, testInfo) => { + const root = mkdtempSync(join(tmpdir(), 'orca-wsl-use-here-')) + const repoPath = join(root, 'moved-repo') + mkdirSync(repoPath) + const gitIdentity = ['-c', 'user.name=e2e', '-c', 'user.email=e2e@example.invalid'] + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repoPath }) + execFileSync('git', [...gitIdentity, 'commit', '-q', '--allow-empty', '-m', 'init'], { + cwd: repoPath + }) + // The escape hatch, like every WSL move action, answers only behind the internal gate. + const session = createRestartSession(testInfo, { [WSL_HOSTS_INTERNAL_GATE_ENV]: '1' }) + let launched: Awaited> | undefined + try { + launched = await session.launch() + await waitForSessionReady(launched.page) + const repoId = await addRepo(launched.page, repoPath) + const worktreeId = `${repoId}::${realpathSync(repoPath)}` + await session.close(launched.app) + launched = undefined + + seedMovedProjectTabs(session.userDataDir, worktreeId) + await seedChatRecord(session.userDataDir, worktreeId) + seedWslMoveCutover( + session.userDataDir, + wslMoveCutoverFixture('move-1', 'retained', {}, { repoIds: [repoId] }) + ) + + launched = await session.launch() + const { page } = launched + await waitForSessionReady(page) + await page.evaluate( + (sessionId) => + window.api.runtime.call({ method: 'agentSession.reveal', params: { sessionId } }), + CHAT_SESSION + ) + const entry = page.getByTestId('wsl-moved-project') + await expect(entry).toBeVisible({ timeout: 30_000 }) + const listed = () => + page.evaluate((id) => window.__store!.getState().repos.some((repo) => repo.id === id), repoId) + expect(await listed()).toBe(false) + + await entry.getByTestId('wsl-moved-use-here').click() + const dialog = page.getByRole('dialog') + await expect(dialog).toContainText('Your code is not affected') + await expect(dialog).toContainText('stays on the Ubuntu host until you remove it there') + if (PROOF_DIR) { + mkdirSync(PROOF_DIR, { recursive: true }) + // Settled, so the proof shows the dialog rather than its fade-in. + await dialog.evaluate((element) => + Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished)) + ) + await page.screenshot({ path: join(PROOF_DIR, 'use-here-confirm.png') }) + } + // Main reloads the window so the project's whole pre-move session hydrates from disk. + const reloaded = page.waitForEvent('load') + await dialog.getByRole('button', { name: 'Use on this computer' }).click() + await reloaded + await waitForSessionReady(page) + + // The retained row lists again, and the read-only entry goes away. + await expect.poll(listed, { timeout: 30_000 }).toBe(true) + await expect(page.getByTestId('wsl-moved-project')).toHaveCount(0) + if (PROOF_DIR) { + await page.screenshot({ path: join(PROOF_DIR, 'use-here-done.png') }) + } + await session.close(launched.app) + launched = undefined + + // The pre-move session came back with it: the slept terminal and the chat beside it. + const persisted = sessionOf(readPersistedProfileState(session.userDataDir)) + expect(persisted.unifiedTabs?.[worktreeId]?.map((tab) => tab.id)).toEqual([TERMINAL, CHAT]) + } finally { + if (launched) { + await session.close(launched.app) + } + await session.dispose() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/tests/e2e/wsl-move-restore-lifecycle.spec.ts b/tests/e2e/wsl-move-restore-lifecycle.spec.ts new file mode 100644 index 00000000000..33172a060b7 --- /dev/null +++ b/tests/e2e/wsl-move-restore-lifecycle.spec.ts @@ -0,0 +1,669 @@ +/** + * The restart-style restore a WSL move ends in, from the pane's own daemon checkpoint to what the + * desktop shows across reloads. The distro's orcad is a windowless paired host; the desktop is a + * paired client. The move's export reads the host pane's real checkpoint, the host's installer + * lays it into its daemon's history, and the host's first start of that pane replays it once. + * + * Run: + * pnpm exec playwright test tests/e2e/wsl-move-restore-lifecycle.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { randomUUID } from 'node:crypto' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { z } from 'zod' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { launchPairedElectronClient } from './helpers/paired-electron-client' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { openPairedClientTab, readPairedPaneContent } from './helpers/paired-host-terminal' +import { mutateStoppedProfileState } from './helpers/persisted-profile-state' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + splitActiveTerminalPane, + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { + createMovedRepo, + MOVED_BROWSER_URL, + MOVED_TAB, + movedWorkspaceRows, + withMovedRows +} from './helpers/wsl-moved-host-profile' +import { HistoryReader } from '../../src/main/daemon/history-reader' +import { getOrcadMigrationReceivedBlobPath } from '../../src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging' +import { + installOrcadMigrationTerminalHistory, + movedPaneHistorySessionId, + orcadMigrationTerminalHistoryKey +} from '../../src/main/persistence/migrating-orcad-catalog/orcad-terminal-history-installer' +import { getHistorySessionDirName } from '../../src/main/daemon/history-paths' +import { readWslMoveSleptScrollbackFromHistory } from '../../src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback' +import { wslMoveCutoverFixture } from '../../src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../../src/main/persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../src/shared/wsl-host-internal-gate' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' +import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' + +const LINES = 2_000 +const LEAF = '6f1b0c3e-8d2a-4c5b-9e7f-1a2b3c4d5e6f' +const DRAFT = 'unsaved words typed before the move' +// What the undo e2e reads of a saved session: its agent records, everything else kept as is. +const SessionWithAgentRecords = z + .object({ + sleepingAgentSessionsByPaneKey: z + .record( + z.string(), + z + .object({ providerSession: z.object({ id: z.unknown() }).passthrough().optional() }) + .passthrough() + ) + .optional() + }) + .passthrough() +const MOVED_AGENT_SESSION = 'e2e-wsl-moved-resume-session' +const UNDO_AGENT_SESSION = 'e2e-wsl-undo-resume-session' +const MIGRATION = { migrationId: 'restore-lifecycle-move', manifestSha256: 'a'.repeat(64) } + +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-restore-lifecycle-')) +const printerPath = path.join(scratch, 'print-history.mjs') +writeFileSync( + printerPath, + [ + 'const marker = process.argv[2]', + `for (let i = 1; i <= ${LINES}; i += 1) process.stdout.write(\`\${marker} \${String(i).padStart(4, '0')}\\r\\n\`)`, + 'process.stdin.resume()' + ].join('\n') +) + +const stubAgentDir = path.join(scratch, 'agents') +mkdirSync(stubAgentDir) +writeFileSync( + path.join(stubAgentDir, 'codex'), + '#!/bin/sh\necho "STUB_CODEX_ARGV $*"\necho "STUB_CODEX_CWD $PWD"\necho "STUB_CODEX_OLDPWD $OLDPWD"\nexec cat\n', + { + mode: 0o755 + } +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +/** The daemon history folders under a profile, whichever profile root the host wrote them in. */ +function historyRoots(userDataDir: string): string[] { + const found: string[] = [] + const walk = (dir: string, depth: number): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue + } + const full = path.join(dir, entry.name) + if (entry.name === 'terminal-history') { + found.push(full) + } else if (depth < 3) { + walk(full, depth + 1) + } + } + } + walk(userDataDir, 0) + return found +} + +function sessionFolders(root: string): string[] { + return readdirSync(root).filter((name) => existsSync(path.join(root, name, 'checkpoint.json'))) +} + +function count(text: string, needle: string): number { + return text.split(needle).length - 1 +} + +type ShownPane = { first: number; last: number; content: string } + +async function readShownPane(page: Page, webTabId: string, marker: string): Promise { + let content = '' + await expect + .poll( + async () => { + content = await readPairedPaneContent(page, webTabId) + return content.includes(`${marker} ${String(LINES).padStart(4, '0')}`) + }, + { timeout: 60_000, message: 'the moved pane never showed its history' } + ) + .toBe(true) + return { + first: count(content, `${marker} 0001`), + last: count(content, `${marker} ${String(LINES).padStart(4, '0')}`), + content + } +} + +test('a moved pane shows its own history once, in its folder, across three reloads, with its browser tab and draft', async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring to request no fixtures. +{}, testInfo) => { + test.skip(process.platform === 'win32', 'POSIX fixture command') + // A move, three reloads and a restart, each booting a full app against a real daemon. + test.setTimeout(900_000) + const root = mkdtempSync(path.join(os.tmpdir(), 'orca-restore-lifecycle-repo-')) + const repoPath = realpathSync(createMovedRepo(root)) + const deep = path.join(repoPath, 'deep') + mkdirSync(deep) + const notesPath = path.join(repoPath, 'notes.md') + writeFileSync(notesPath, 'saved words\n') + const openFile = { filePath: notesPath, relativePath: 'notes.md' } + const marker = `MOVED-${randomUUID().slice(0, 8)}` + const host = await launchHeadlessPairedRuntimeHost({ + pinnedServePort: true, + withoutBrowser: true, + // Stands in for the distro's orcad, the only host that serves moved panes' history. + extraEnv: { ORCA_E2E_WSL_MOVE_DESTINATION: '1' } + }) + try { + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: repoPath, + kind: 'git' + }) + const worktreeId = `${added.result.repo.id}::${repoPath}` + + // The pane before the move: a real daemon session that printed its history, then slept, so + // its final checkpoint is on disk as the move's export reads it. + const before = new Set(historyRoots(host.userDataDir).flatMap(sessionFolders)) + const created = await host.client.call<{ tab: { terminal: string } }>( + 'session.tabs.createTerminal', + { + worktree: `id:${worktreeId}`, + command: [process.execPath, printerPath, marker].map(quotePosixShell).join(' '), + activate: false, + select: false, + navigation: 'caller' + } + ) + const lastLine = `${marker} ${String(LINES).padStart(4, '0')}` + // The printer finishes its history before the pane sleeps. + await expect + .poll( + async () => { + const read = await host.client.call('terminal.read', { + terminal: created.result.tab.terminal, + screen: true + }) + return JSON.stringify(read.result).includes(lastLine) + }, + { timeout: 30_000, message: 'the printer never printed its last line' } + ) + .toBe(true) + await host.client.call('worktree.sleep', { worktree: `id:${worktreeId}` }) + let historyRoot = '' + let sessionFolder = '' + await expect + .poll( + () => { + for (const candidate of historyRoots(host.userDataDir)) { + const fresh = sessionFolders(candidate).find((name) => !before.has(name)) + if (fresh) { + historyRoot = candidate + sessionFolder = fresh + return true + } + } + return false + }, + { timeout: 60_000, message: 'the slept pane left no checkpoint' } + ) + .toBe(true) + + // The move's export: the desktop reads that checkpoint as its terminal-history blob, with the + // folder the shell was in (the distro's /proc read stands in as `shellCwd`). + const paneKey = `${MOVED_TAB}:${LEAF}` + const spool = path.join(scratch, 'spool') + const exported = await readWslMoveSleptScrollbackFromHistory( + new HistoryReader(historyRoot), + [{ paneKey, sessionId: decodeURIComponent(sessionFolder), shellCwd: deep }], + 'Ubuntu', + spool + ) + expect(exported.problems).toEqual([]) + const blob = exported.buffers.get(paneKey) + if (!blob) { + throw new Error('the export carried no history for the moved pane') + } + + // The host's import: the moved tab in its session (no binding, no snapshot), and its history + // laid into the daemon's folder by the real installer. + await host.restartServeProcess({ + betweenProcesses: () => { + mutateStoppedProfileState(host.userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test seeded the profile and writes only these session maps. + const session = state.workspaceSession as Record + Reflect.set(session, 'activeWorktreeId', null) + // The agent's own session file on the host, as the distro keeps it: a resume whose owning + // account cannot be verified starts the agent fresh instead. + const codexHome = host.env.CODEX_HOME ?? path.join(host.env.HOME ?? '', '.codex') + const sessionsDir = path.join(codexHome, 'sessions', '2026', '07', '28') + mkdirSync(sessionsDir, { recursive: true }) + const transcriptPath = path.join( + sessionsDir, + `rollout-2026-07-28T00-00-00-${MOVED_AGENT_SESSION}.jsonl` + ) + writeFileSync( + transcriptPath, + `${JSON.stringify({ + timestamp: '2026-07-28T00:00:00.000Z', + type: 'session_meta', + payload: { id: MOVED_AGENT_SESSION, cwd: deep } + })}\n` + ) + // The pane's idle agent, as the move carries it: resumed in the pane when it opens. + Reflect.set(session, 'sleepingAgentSessionsByPaneKey', { + [paneKey]: { + paneKey, + tabId: MOVED_TAB, + worktreeId, + agent: 'codex', + providerSession: { key: 'session_id', id: MOVED_AGENT_SESSION, transcriptPath }, + prompt: 'finish the task', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep', + restoreOnTabOpenOnly: true, + // The command a launch recorded; absolute, since a login shell may reset PATH. + launchConfig: { + agentCommand: path.join(stubAgentDir, 'codex'), + agentArgs: '', + agentEnv: {} + } + } + }) + withMovedRows( + session, + movedWorkspaceRows(worktreeId, { + openFiles: [openFile], + browserTab: false, + daemonHistoryLeafId: LEAF + }) + ) + }) + const received = getOrcadMigrationReceivedBlobPath(scratch, MIGRATION, { + blobKind: 'terminal-history', + key: orcadMigrationTerminalHistoryKey(paneKey) + }) + mkdirSync(path.dirname(received), { recursive: true }) + writeFileSync(received, readFileSync(blob.path)) + installOrcadMigrationTerminalHistory({ + migration: MIGRATION, + root: scratch, + historyRoot, + paneWorktreeId: () => worktreeId, + workspacePath: () => repoPath, + now: Date.now() + }) + } + }) + + const first = await launchPairedElectronClient(host.offer, testInfo, 'restore-lifecycle') + const { environmentId, userDataDir } = first + let client = first + const webTabId = toWebTerminalSurfaceTabId(MOVED_TAB) + try { + await expect + .poll( + () => + client.page.evaluate( + (id) => + Boolean( + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) + ), + worktreeId + ), + { timeout: 60_000 } + ) + .toBe(true) + await client.quitPreservingProfile() + // What the move's seed leaves in this desktop's copy of the host's session. + mutateStoppedProfileState(userDataDir, (state) => { + const held = state.workspaceSessionsByHostId + const partitions: Record = + held && typeof held === 'object' ? { ...held } : {} + const hostId = toRuntimeExecutionHostId(environmentId) + const current = partitions[hostId] + const partition: Record = + current && typeof current === 'object' ? { ...current } : {} + withMovedRows( + partition, + movedWorkspaceRows(worktreeId, { + openFiles: [{ ...openFile, dirtyDraftContent: DRAFT }], + runtimeEnvironmentId: environmentId, + daemonHistoryLeafId: LEAF + }) + ) + state.workspaceSessionsByHostId = { ...partitions, [hostId]: partition } + }) + + const shown: ShownPane[] = [] + for (let launch = 0; launch < 4; launch += 1) { + client = await launchPairedElectronClient(host.offer, testInfo, 'restore-lifecycle', { + reuseUserDataDir: userDataDir + }) + await expect + .poll( + () => + client.page.evaluate( + (id) => + Boolean( + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) + ), + worktreeId + ), + { timeout: 60_000 } + ) + .toBe(true) + await openPairedClientTab(client.page, worktreeId, webTabId) + try { + await expect + .poll( + async () => + (await readPairedPaneContent(client.page, webTabId)).includes('STUB_CODEX_CWD'), + { + timeout: 60_000, + message: 'the carried agent never started in the moved pane' + } + ) + .toBe(true) + } catch (error) { + // What the pane shows instead: a refused resume names itself, a plain shell shows its prompt. + const tail = (await readPairedPaneContent(client.page, webTabId)).slice(-800) + console.log(`[restore-lifecycle] launch ${launch} pane tail: ${JSON.stringify(tail)}`) + throw error + } + const pane = await readShownPane(client.page, webTabId, marker) + const resumes = count(pane.content, 'STUB_CODEX_CWD') + console.log( + `[restore-lifecycle] launch ${launch}: first=${pane.first} last=${pane.last} agent=${resumes} chars=${pane.content.length}` + ) + shown.push(pane) + if (launch === 0) { + // The agent resumed in the folder the pane was in (the stub prints where it started). + if ( + ![`STUB_CODEX_CWD ${deep}\r`, `STUB_CODEX_OLDPWD ${deep}\r`].some((line) => + pane.content.includes(line) + ) + ) { + // Diagnostics for a pane that resumed outside its folder: which terminal, from which history. + const listed = await host.client.call('terminal.list', { worktree: `id:${worktreeId}` }) + const movedSession = movedPaneHistorySessionId(worktreeId, paneKey) + const folder = path.join(historyRoot, getHistorySessionDirName(movedSession)) + const read = (name: string): string => + existsSync(path.join(folder, name)) + ? readFileSync(path.join(folder, name), 'utf8').slice(0, 300) + : 'missing' + // A login profile that changes folder or HOME moves every login shell (Blacksmith's does). + const profileDir = '/etc/profile.d' + const profiles = existsSync(profileDir) + ? readdirSync(profileDir) + .filter((name) => name.endsWith('.sh')) + .map((name) => { + const text = readFileSync(path.join(profileDir, name), 'utf8') + return `${name}: ${text + .split('\n') + .filter((line) => /\bcd\b|HOME|PWD/.test(line)) + .join(' | ')}` + }) + : [] + console.log(`[restore-lifecycle] login profiles: ${JSON.stringify(profiles)}`) + console.log( + `[restore-lifecycle] wrong folder: terminals=${JSON.stringify(listed.result)} meta=${read('meta.json')} checkpointCwd=${JSON.stringify(/"cwd":"[^"]*"/.exec(read('checkpoint.json'))?.[0] ?? null)} tail=${JSON.stringify(pane.content.slice(-900))}` + ) + } + // Where Orca started the pane's shell. A login profile that cds (CI runners' does, into + // GITHUB_WORKSPACE) moves every login shell after it starts; the folder it left is OLDPWD. + expect + .soft( + [`STUB_CODEX_CWD ${deep}\r`, `STUB_CODEX_OLDPWD ${deep}\r`].some((line) => + pane.content.includes(line) + ) + ) + .toBe(true) + const moved = await client.page.evaluate( + ({ id, path: filePath }) => { + const store = window.__store?.getState() + const browserUrls = (store?.browserTabsByWorktree[id] ?? []).flatMap((workspace) => + (store?.browserPagesByWorkspace[workspace.id] ?? []).map((page) => page.url) + ) + const file = store?.openFiles.find( + (entry) => entry.worktreeId === id && entry.filePath === filePath + ) + return { browserUrls, draft: file ? (store?.editorDrafts[file.id] ?? null) : null } + }, + { id: worktreeId, path: notesPath } + ) + expect.soft(moved.browserUrls).toContain(MOVED_BROWSER_URL) + expect.soft(moved.draft).toBe(DRAFT) + } + if (launch < 3) { + await client.quitPreservingProfile() + } + } + for (const pane of shown) { + // Every line of the pane's history, exactly once: no second replay after any reload. + expect.soft(pane.last).toBe(1) + expect.soft(pane.first).toBe(1) + } + // The carried agent resumed on the first open only: a reload never resumes it again. + expect.soft(shown[0]?.content).toContain(`resume ${MOVED_AGENT_SESSION}`) + expect.soft(shown.map((pane) => count(pane.content, 'STUB_CODEX_CWD'))).toEqual([1, 1, 1, 1]) + } finally { + await client.dispose() + } + } finally { + await host.dispose() + rmSync(root, { recursive: true, force: true }) + } +}) + +test('"Use on this computer again" after reloads and a restart brings back slept panes live, each with its history once', async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring to request no fixtures. +{}, testInfo) => { + test.skip(process.platform === 'win32', 'POSIX shell commands') + // A move, reloads, a restart and the undo's reload, each booting a full app. + test.setTimeout(900_000) + const root = mkdtempSync(path.join(os.tmpdir(), 'orca-restore-lifecycle-undo-')) + const repoPath = realpathSync(createMovedRepo(root)) + const session = createRestartSession(testInfo, { [WSL_HOSTS_INTERNAL_GATE_ENV]: '1' }) + let launched: Awaited> | undefined + try { + launched = await session.launch() + await waitForSessionReady(launched.page) + const worktreeId = await attachRepoAndOpenTerminal(launched.page, repoPath) + await ensureTerminalVisible(launched.page) + const firstPty = await waitForActivePanePtyId(launched.page) + // The variable keeps the printed lines out of the echoed command. + await execInTerminal(launched.page, firstPty, `M=UNDO; seq -f "\${M}A-%04g" 1 ${LINES}`) + await waitForTerminalOutput(launched.page, `UNDOA-${LINES}`, 30_000) + // Pane A also holds an idle, resumable agent, as a hook-reporting Codex leaves it. + const agentPane = await waitForActivePaneHookDescriptor(launched.page) + const transcriptPath = session.seedCodexResumeRollout(UNDO_AGENT_SESSION, repoPath) + await launched.page.evaluate( + ({ paneKey, worktreeId: wtId, providerSessionId, transcriptPath }) => { + window.__store + ?.getState() + .setAgentStatus( + paneKey, + { state: 'done', prompt: 'finish the task', agentType: 'codex' }, + 'Codex', + undefined, + { worktreeId: wtId }, + { providerSession: { key: 'session_id', id: providerSessionId, transcriptPath } } + ) + }, + { + paneKey: agentPane.paneKey, + worktreeId: agentPane.worktreeId, + providerSessionId: UNDO_AGENT_SESSION, + transcriptPath + } + ) + await splitActiveTerminalPane(launched.page, 'vertical') + await waitForPaneCount(launched.page, 2, 30_000) + const secondPty = await waitForActivePanePtyId(launched.page) + expect(secondPty).not.toBe(firstPty) + await execInTerminal(launched.page, secondPty, `M=UNDO; seq -f "\${M}B-%04g" 1 ${LINES}`) + await waitForTerminalOutput(launched.page, `UNDOB-${LINES}`, 30_000) + // The move's pause is main's own Sleep: it keeps each pane's identity and final checkpoint. + const slept = await launched.page.evaluate( + (id) => + window.api.runtime.call({ method: 'worktree.sleep', params: { worktree: `id:${id}` } }), + worktreeId + ) + expect(slept.ok).toBe(true) + await expect + .poll( + () => + launched!.page.evaluate((id) => { + const state = window.__store!.getState() + return (state.tabsByWorktree[id] ?? []).reduce( + (n, tab) => n + (state.ptyIdsByTabId[tab.id]?.length ?? 0), + 0 + ) + }, worktreeId), + { timeout: 30_000 } + ) + .toBe(0) + const repoId = worktreeId.split('::')[0] + await session.close(launched.app) + launched = undefined + // Hermetic resume: the agent command echoes its arguments instead of running Codex. + mutateStoppedProfileState(session.userDataDir, (state) => { + const session = SessionWithAgentRecords.parse(state.workspaceSession) + const records = session.sleepingAgentSessionsByPaneKey ?? {} + const paneKey = Object.keys(records).find( + (key) => records[key]?.providerSession?.id === UNDO_AGENT_SESSION + ) + const record = paneKey ? records[paneKey] : undefined + if (!paneKey || !record) { + throw new Error('the pause kept no resumable agent for pane A') + } + state.workspaceSession = { + ...session, + sleepingAgentSessionsByPaneKey: { + ...records, + [paneKey]: { + ...record, + launchConfig: { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + } + } + } + }) + + // The move committed: startup runs its desktop rewrite and retention, as after a real move. + seedWslMoveCutover( + session.userDataDir, + wslMoveCutoverFixture('move-1', 'destination-committed', {}, { repoIds: [repoId] }) + ) + // Moved: the window reloads three times and the app restarts once before the undo. + launched = await session.launch() + await waitForSessionReady(launched.page) + for (let reload = 0; reload < 3; reload += 1) { + await launched.page.reload() + await waitForSessionReady(launched.page) + } + await session.close(launched.app) + launched = await session.launch() + const { page } = launched + await waitForSessionReady(page) + const entry = page.getByTestId('wsl-moved-project') + await expect(entry).toBeVisible({ timeout: 30_000 }) + const reloaded = page.waitForEvent('load') + await entry.getByTestId('wsl-moved-use-here').click() + await page.getByRole('dialog').getByRole('button', { name: 'Use on this computer' }).click() + await reloaded + await waitForSessionReady(page) + + // Back here as a slept workspace: nothing runs until it is opened... + const livePtys = (): Promise => + page.evaluate((id) => { + const state = window.__store!.getState() + return (state.tabsByWorktree[id] ?? []).reduce( + (n, tab) => n + (state.ptyIdsByTabId[tab.id]?.length ?? 0), + 0 + ) + }, worktreeId) + expect(await livePtys()).toBe(0) + // ...and opening it wakes both panes from their checkpoints. + await page.evaluate((id) => window.__store!.getState().setActiveWorktree(id), worktreeId) + await ensureTerminalVisible(page) + await waitForPaneCount(page, 2, 60_000) + const readPanes = (): Promise => + page.evaluate(() => { + const state = window.__store!.getState() + const manager = state.activeTabId ? window.__paneManagers?.get(state.activeTabId) : null + return (manager?.getPanes?.() ?? []).map((pane) => pane.serializeAddon?.serialize?.() ?? '') + }) + await expect + .poll( + async () => + (await readPanes()).map((text) => + ['UNDOA', 'UNDOB'].filter((marker) => text.includes(`${marker}-${LINES}`)).join() + ), + { timeout: 60_000, message: 'a woken pane never showed its history' } + ) + .toEqual(['UNDOA', 'UNDOB']) + for (const [index, text] of (await readPanes()).entries()) { + const marker = ['UNDOA', 'UNDOB'][index] + console.log( + `[restore-lifecycle] undo ${marker}: first=${count(text, `${marker}-0001`)} last=${count(text, `${marker}-${LINES}`)}` + ) + expect.soft(count(text, `${marker}-${LINES}`)).toBe(1) + } + // Live panes: what is typed in each now runs there. + const ptyIds = await page.evaluate(() => { + const state = window.__store!.getState() + return state.activeTabId ? (state.ptyIdsByTabId[state.activeTabId] ?? []) : [] + }) + expect(ptyIds).toHaveLength(2) + for (const [index, ptyId] of ptyIds.entries()) { + await execInTerminal(page, ptyId, `echo "ALIVE-${index}-$((40 + 2))"`) + } + await expect + .poll(async () => (await readPanes()).filter((text) => /ALIVE-\d-42/.test(text)).length, { + timeout: 30_000, + message: 'a woken pane did not run what was typed' + }) + .toBe(2) + // Pane A's agent resumed there, once; pane B started no agent. + const panes = await readPanes() + expect.soft(count(panes[0] ?? '', UNDO_AGENT_SESSION)).toBeGreaterThan(0) + expect.soft(count(panes[1] ?? '', UNDO_AGENT_SESSION)).toBe(0) + const resumeTabs = await page.evaluate( + (id) => (window.__store!.getState().tabsByWorktree[id] ?? []).length, + worktreeId + ) + expect.soft(resumeTabs).toBe(1) + } finally { + if (launched) { + await session.close(launched.app) + } + await session.dispose() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/tests/e2e/wsl-move-undo-host-copy.spec.ts b/tests/e2e/wsl-move-undo-host-copy.spec.ts new file mode 100644 index 00000000000..fab4bbcda12 --- /dev/null +++ b/tests/e2e/wsl-move-undo-host-copy.spec.ts @@ -0,0 +1,137 @@ +/** + * "Use on this computer again" while the host still runs its copy of the project: undo must stop the host's terminals for the project before the panes wake here, so the + * project's terminal runs in exactly one place. The host is a windowless paired host holding the + * project under the ids the move gave it; the desktop is a paired client the move journaled. + * + * Run: + * pnpm exec playwright test tests/e2e/wsl-move-undo-host-copy.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { mutateStoppedProfileState } from './helpers/persisted-profile-state' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { execInTerminal, waitForActivePanePtyId, waitForTerminalOutput } from './helpers/terminal' +import { createMovedRepo } from './helpers/wsl-moved-host-profile' +import { wslMoveCutoverFixture } from '../../src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../../src/main/persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../src/shared/wsl-host-internal-gate' +import { getEnvironmentStorePath } from '../../src/shared/runtime-environment-store-file' +import type { RuntimeTerminalListResult } from '../../src/shared/runtime-terminal-contracts' + +const GATE = { [WSL_HOSTS_INTERNAL_GATE_ENV]: '1' } + +test("undo stops the host's copy first, so the project's terminal runs in one place", async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring to request no fixtures. +{}, testInfo) => { + test.skip(process.platform === 'win32', 'POSIX shell commands') + // A move, a host terminal start and the undo's reload, each booting a full app. + test.setTimeout(900_000) + const root = mkdtempSync(path.join(os.tmpdir(), 'orca-undo-host-copy-')) + const repoPath = realpathSync(createMovedRepo(root)) + const host = await launchHeadlessPairedRuntimeHost({ + pinnedServePort: true, + withoutBrowser: true, + // Stands in for the distro's orcad, the only host that serves moved panes' history. + extraEnv: { ORCA_E2E_WSL_MOVE_DESTINATION: '1' } + }) + const session = createRestartSession(testInfo, GATE) + let launched: Awaited> | undefined + try { + launched = await session.launch() + await waitForSessionReady(launched.page) + // This desktop's project before the move: a terminal with history, paused by the move. + const worktreeId = await attachRepoAndOpenTerminal(launched.page, repoPath) + await ensureTerminalVisible(launched.page) + const ptyId = await waitForActivePanePtyId(launched.page) + await execInTerminal(launched.page, ptyId, 'M=UNDO; seq -f "${M}H-%04g" 1 50') + await waitForTerminalOutput(launched.page, 'UNDOH-0050', 30_000) + const slept = await launched.page.evaluate( + (id) => + window.api.runtime.call({ method: 'worktree.sleep', params: { worktree: `id:${id}` } }), + worktreeId + ) + expect(slept.ok).toBe(true) + const repoId = worktreeId.split('::')[0] + // The WSL host this desktop moved the project to, paired as the move pairs it. + const environmentId = await launched.page.evaluate( + async (pairingCode) => + (await window.api.runtimeEnvironments.addFromPairingCode({ name: 'Ubuntu', pairingCode })) + .environment.id, + host.offer.pairingUrl + ) + await session.close(launched.app) + launched = undefined + + // The host holds the project under the ids the move gave it, its copy of the pane running. + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: repoPath, + kind: 'git' + }) + await host.restartServeProcess({ + betweenProcesses: () => + mutateStoppedProfileState(host.userDataDir, (state) => { + Object.assign( + state, + JSON.parse(JSON.stringify(state).replaceAll(added.result.repo.id, repoId)) + ) + }) + }) + await host.client.call('session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + command: 'sleep 600', + activate: false, + select: false, + navigation: 'caller' + }) + const hostRunning = async (): Promise => + ( + await host.client.call('terminal.list', { + worktree: `id:${worktreeId}` + }) + ).result.terminals.filter((terminal) => terminal.connected).length + await expect.poll(hostRunning, { timeout: 30_000 }).toBe(1) + + // The move names its host by the WSL environment id; the paired host stands in under it. + const move = wslMoveCutoverFixture('move-1', 'destination-committed', {}, { repoIds: [repoId] }) + const storePath = getEnvironmentStorePath(session.userDataDir) + writeFileSync( + storePath, + readFileSync(storePath, 'utf8').replaceAll(environmentId, move.destinationEnvironmentId) + ) + seedWslMoveCutover(session.userDataDir, move) + launched = await session.launch() + const { page } = launched + await waitForSessionReady(page) + const entry = page.getByTestId('wsl-moved-project') + await expect(entry).toBeVisible({ timeout: 30_000 }) + const reloaded = page.waitForEvent('load', { timeout: 120_000 }) + await entry.getByTestId('wsl-moved-use-here').click() + await page.getByRole('dialog').getByRole('button', { name: 'Use on this computer' }).click() + await reloaded + await waitForSessionReady(page) + + // The host's copy stopped before anything woke here, and the project is back on this computer + // with its pane slept: opening it (the lifecycle e2e) starts the only copy that runs. + expect(await hostRunning()).toBe(0) + await expect + .poll(() => + page.evaluate( + (id) => window.__store!.getState().repos.some((repo) => repo.id === id), + repoId + ) + ) + .toBe(true) + await expect(page.getByTestId('wsl-moved-project')).toHaveCount(0) + } finally { + if (launched) { + await session.close(launched.app) + } + await session.dispose() + await host.dispose() + rmSync(root, { recursive: true, force: true }) + } +})