From 0a93bdd2937de2c862d2d38593c0bdbb51fe0c5f Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 16 Oct 2026 04:49:37 -0700 Subject: [PATCH] fix(wsl-mover): finish the main rewrite before the window loads, keep one copy of each moved tab, and copy instead of move (D1b-7c) --- .../wsl-move-cutover-journal.ts | 12 +- .../wsl-move-desktop-rewrite-stores.ts | 2 + .../wsl-move-desktop-rewrite.ts | 42 ++++- .../wsl-move-main-rewrite-stores.test.ts | 46 +++++- .../wsl-move-main-rewrite-stores.ts | 7 +- .../wsl-move-rewrite-ledger.test.ts | 16 ++ .../wsl-move-session-rewrite.test.ts | 99 +++++++++++- .../wsl-move-session-rewrite.ts | 82 ++++------ .../wsl-move-startup-rewrite.test.ts | 77 +++++++++ .../wsl-move-startup-rewrite.ts | 41 +++++ .../wsl-move-ui-routing-rewrite.test.ts | 96 +++++++++-- .../wsl-move-ui-routing-rewrite.ts | 68 +++++--- .../cross-host-pane-tab-ids.ts | 25 ++- .../startup/main-process-ready-foundation.ts | 14 +- .../main-process-ready-phase-ordering.test.ts | 34 ++++ src/main/startup/main-process-ready.ts | 1 + src/main/startup/main-process-state.ts | 3 + .../app-shell/use-app-startup-hydration.ts | 24 +-- .../lib/workspace-session-host-hydration.ts | 8 +- .../wsl-moved-local-session-rows.test.ts | 151 ++++++++++++++++++ .../repos/wsl-moved-local-session-rows.ts | 57 +++++++ .../src/store/repos/wsl-moved-projects.ts | 3 +- src/shared/workspace-session-tab-filter.ts | 127 +++++++++++++++ src/shared/wsl-move/wsl-move-cutover.ts | 2 + 24 files changed, 903 insertions(+), 134 deletions(-) create mode 100644 src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.test.ts create mode 100644 src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.ts create mode 100644 src/renderer/src/store/repos/wsl-moved-local-session-rows.test.ts create mode 100644 src/renderer/src/store/repos/wsl-moved-local-session-rows.ts create mode 100644 src/shared/workspace-session-tab-filter.ts 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 78d426d2064..fdce984fec4 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 @@ -157,7 +157,8 @@ function assertWslMoveUpdate( } /** - * The rewrite ledger only grows, and only while the desktop rewrites (`destination-committed`): + * 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. */ @@ -174,6 +175,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' && 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 52af4c02f67..4361e7daf3f 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 @@ -12,6 +12,8 @@ export type WslMoveDesktopRewriteContext = { rewritePath: OrcadMigrationPathRewriter /** The execution host the moved projects now belong to. */ destinationHostId: `runtime:${string}` + /** Durable before it returns; `cutover.seededHostWorkspaceIds` holds what earlier runs recorded. */ + recordSeededHostWorkspaceIds: (workspaceIds: readonly string[]) => void } export type WslMoveMainRewriteStore = { 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 3cd01b99421..62b2e954ba9 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 @@ -30,14 +30,41 @@ export type WslMoveDesktopRewritePassOptions = { warn?: (message: string) => void } -function rewriteContext(cutover: Readonly): WslMoveDesktopRewriteContext { +function rewriteContext( + 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() + }) +} + /** Fresh from disk: the ledger write must extend what is there now, not what a pass started with. */ function readCommittedCutover(userDataPath: string, migrationId: string): WslMoveCutover | null { const cutover = listWslMoveCutovers(userDataPath).find( @@ -106,7 +133,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(rewriteContext(userDataPath, before)), + timeoutMs, + `${store.id} run` + ) await withStoreTimeout(() => store.flush(), timeoutMs, `${store.id} flush`) const current = readCommittedCutover(userDataPath, migrationId) if (!current) { @@ -151,7 +182,10 @@ export function runWslMoveDesktopRewritePass( return next } -/** Startup, and after a move commits: a no-op unless the internal WSL hosts gate is open. */ +/** + * After a move commits: a no-op unless the internal WSL hosts gate is open. Its caller must run it + * with no window open, or after the renderer stores (7d) stop writing the old routing back. + */ export function runGatedWslMoveDesktopRewritePass( options: WslMoveDesktopRewritePassOptions ): Promise { 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 index 8041ba15233..3f741457d09 100644 --- 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 @@ -27,6 +27,7 @@ import { 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', () => ({ @@ -137,13 +138,19 @@ describe('WSL move main rewrite stores', () => { const [record] = listWslMoveCutovers(testState.dir) expect(record?.rewrittenStores).toEqual(STORE_IDS) expect(record?.phase).toBe('destination-committed') + 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) - expect(saved.ui.explorerDisplayRootByWorktree).toEqual( - destination.ui?.explorerDisplayRootByWorktree - ) - expect(saved.ui.manualRepoOrder).toEqual([{ hostId: HOST, repoId: 'repo-a' }]) + // 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) @@ -157,6 +164,10 @@ describe('WSL move main rewrite stores', () => { // 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 () => { @@ -184,6 +195,33 @@ describe('WSL move main rewrite stores', () => { 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(), 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 index b81e863b07d..a77cc167602 100644 --- 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 @@ -61,11 +61,14 @@ export function registerWslMoveMainRewriteStores(profile: WslMoveMainRewriteProf const seed = wslMoveHostSessionSeed( carried, profile.getWorkspaceSession(hostId), - wslMoveRewriteScope(context) + wslMoveRewriteScope(context), + new Set(context.cutover.seededHostWorkspaceIds) ) if (seed) { // One-shot, ledgered move rewrite; layout commands refuse runtime hosts' partitions. - profile.setWorkspaceSession(seed, hostId) + profile.setWorkspaceSession(seed.session, hostId) + // Recorded before the flush: a crash may then lose the seed, but never revives a closed tab. + context.recordSeededHostWorkspaceIds(seed.workspaceIds) } }, flush 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 e09a4d7645c..95791002ffc 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'])) 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 index ce4eecc80fc..4f08dbc9dd1 100644 --- 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 @@ -12,9 +12,15 @@ import { wslMoveHostSessionSeed, wslMoveSessionGlobalsPatch } from './wsl-move-s const HOST = toRuntimeExecutionHostId('wsl:Ubuntu') const scope = wslMoveRewriteScope({ - cutover: wslMoveCutoverFixture('move-1', 'destination-committed', {}, { repoIds: ['repo-a'] }), + cutover: wslMoveCutoverFixture( + 'move-1', + 'destination-committed', + {}, + { repoIds: ['repo-a'], folderWorkspaceIds: ['folder-1'] } + ), rewritePath: createUncToLinuxPathRewriter('Ubuntu'), - destinationHostId: HOST + destinationHostId: HOST, + recordSeededHostWorkspaceIds: () => {} }) const source = wslMoverProfile('mixed-windows-wsl', 'source') @@ -84,6 +90,25 @@ describe('WSL move session globals', () => { }) }) + 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( @@ -109,9 +134,18 @@ describe('WSL move session globals', () => { }) }) +/** 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 = wslMoveHostSessionSeed(carriedSession(), getDefaultWorkspaceSession(), scope) + 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]) @@ -152,7 +186,7 @@ describe('WSL move host session seed', () => { }, tabGroups: { [MOVED_ON_HOST]: [tabGroup('host-group', MOVED_ON_HOST, ['host-tab'])] } } - const seed = wslMoveHostSessionSeed(carriedSession(), published, scope) + const seed = seedSession(carriedSession(), published, scope) expect(seed?.tabsByWorktree[MOVED_ON_HOST]).toEqual(published.tabsByWorktree[MOVED_ON_HOST]) expect(seed?.tabGroups).toEqual(published.tabGroups) @@ -162,11 +196,66 @@ describe('WSL move host session seed', () => { }) it('changes nothing on a second run', () => { - const seed = wslMoveHostSessionSeed(carriedSession(), getDefaultWorkspaceSession(), scope) + 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') + }) }) 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 index 3288585fc7a..9880c55267d 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.ts @@ -5,7 +5,8 @@ * 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, TabGroup } from '../../../shared/tab-types' +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, @@ -59,20 +60,28 @@ export function wslMoveSessionGlobalsPatch( // 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, or null when - * there is none: what the host already published is never replaced. + * `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 -): WorkspaceSessionState | null { + 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) + (workspaceId) => + scope.inScope(workspaceId) && !published.has(workspaceId) && !alreadySeeded.has(workspaceId) ) ) if (seeded.size === 0) { @@ -95,7 +104,7 @@ export function wslMoveHostSessionSeed( mergeWorkspaceSessionRecordField(next, field, rows) } } - return next + return { session: next, workspaceIds: [...seeded] } } function isSessionField(field: string): field is keyof WorkspaceSessionState { @@ -130,7 +139,7 @@ function isChatTab(tab: Pick): boolean { return tab.contentType === 'agent-session' } -/** Chat tabs, and the focus and groups that name them; they stay listed on the local entry. */ +/** 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) => @@ -140,52 +149,17 @@ function withoutChatTabs(session: WorkspaceSessionState): WorkspaceSessionState if (chatIds.size === 0) { return session } - const unifiedTabs = Object.fromEntries( - Object.entries(session.unifiedTabs ?? {}).map(([owner, tabs]) => [ - owner, - tabs.filter((tab) => !isChatTab(tab)) - ]) - ) - const tabGroups = Object.fromEntries( - Object.entries(session.tabGroups ?? {}).map(([owner, groups]) => [ - owner, - groups.map((group) => withoutTabs(group, chatIds)) - ]) - ) - const notChat = (value: string | null | undefined): boolean => !value || !chatIds.has(value) + const filtered = filterWorkspaceSessionTabs(session, (_owner, tab) => !isChatTab(tab)) return { - ...session, - unifiedTabs, - tabGroups, - activeTabIdByWorktree: pickValues(session.activeTabIdByWorktree, notChat), - activeTabTypeByWorktree: pickValues( - session.activeTabTypeByWorktree, - (type) => type !== 'agent-session' - ), - sleepingAgentSessionsByPaneKey: pickValues(session.sleepingAgentSessionsByPaneKey, (record) => - notChat(record.tabId) - ) + ...filtered, + ...(session.sleepingAgentSessionsByPaneKey + ? { + sleepingAgentSessionsByPaneKey: Object.fromEntries( + Object.entries(session.sleepingAgentSessionsByPaneKey).filter( + ([, record]) => !record.tabId || !chatIds.has(record.tabId) + ) + ) + } + : {}) } } - -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 } : {}) - } -} - -function pickValues( - record: Record | undefined, - keep: (value: T) => boolean -): Record | undefined { - return record && Object.fromEntries(Object.entries(record).filter(([, value]) => keep(value))) -} 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..1b08ac7a5e3 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.test.ts @@ -0,0 +1,77 @@ +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' }, async () => { + order.push('recover') + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await runWslMoveStartupRewrite({ userDataPath: '/data' }, 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..0d67d58b85c --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.ts @@ -0,0 +1,41 @@ +/** + * 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, with the stale-hydration risk this wait otherwise closes. +export const WSL_MOVE_STARTUP_REWRITE_WAIT_MS = 5_000 + +/** + * Resolves when the pass finishes or the wait runs out; at once with the gate closed. `before` + * (the reset of an interrupted move) runs first, inside the same wait, and its failure never + * skips the pass. + */ +export function runWslMoveStartupRewrite( + options: WslMoveDesktopRewritePassOptions, + before: () => Promise = () => Promise.resolve(), + waitMs: number = WSL_MOVE_STARTUP_REWRITE_WAIT_MS +): Promise { + if (!wslHostsGateOpen()) { + return Promise.resolve() + } + let timer: ReturnType | undefined + const waited = new Promise((resolve) => { + timer = setTimeout(resolve, waitMs) + timer.unref?.() + }) + const pass = Promise.resolve() + .then(before) + .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-ui-routing-rewrite.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-ui-routing-rewrite.test.ts index 60c315c4947..6e603ced902 100644 --- 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 @@ -11,7 +11,8 @@ const HOST = toRuntimeExecutionHostId('wsl:Ubuntu') const scope = wslMoveRewriteScope({ cutover: wslMoveCutoverFixture('move-1', 'destination-committed', {}, { repoIds: ['repo-a'] }), rewritePath: createUncToLinuxPathRewriter('Ubuntu'), - destinationHostId: HOST + destinationHostId: HOST, + recordSeededHostWorkspaceIds: () => {} }) // The mixed profile: repo-a moves; repo-b (same distro), repo-win and repo-deb stay. @@ -26,6 +27,7 @@ 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 @@ -84,34 +86,102 @@ function dismissal( } describe('WSL move UI routing rewrite', () => { - it("routes only the moved project's rows to the host, as the host holds them", () => { - const updates = rewriteWslMoveUiRouting(profileUi(), scope) + 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({ - ...destinationUi.showDotfilesByWorktree, - [SAME_DISTRO]: false + ...ui.showDotfilesByWorktree, + ...destinationUi.showDotfilesByWorktree }) expect(updates.explorerDisplayRootByWorktree).toEqual({ - ...destinationUi.explorerDisplayRootByWorktree, - [WINDOWS]: 'C:\\src\\win\\lib' + ...ui.explorerDisplayRootByWorktree, + ...destinationUi.explorerDisplayRootByWorktree }) expect(updates.workspaceCleanup?.dismissals).toEqual({ - [`${HOST}\0${MOVED_ON_HOST}`]: { - ...dismissal(MOVED_ON_HOST), - executionHostId: HOST - }, - [`${SSH}\0${MOVED}`]: dismissal(MOVED, SSH), - [SAME_DISTRO]: dismissal(SAME_DISTRO) + ...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) 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 index 425b8670540..7559443f28f 100644 --- 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 @@ -1,8 +1,9 @@ /** - * The persisted UI's routing to a moved project, rewritten to the WSL host after a move commits: - * worktree-keyed preferences take the host's ids, and every host filter that shows `local` also - * shows the host. Nothing is removed, so the retained local entry keeps its own preferences, and a - * second run finds nothing left to change. + * 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' @@ -55,18 +56,29 @@ export function rewriteWslMoveUiRouting( } const hostWorktreeKey = (key: string): string => scope.inScope(key) ? scope.worktreeId(key) : key - const dotfiles = rekey(ui.showDotfilesByWorktree, hostWorktreeKey, (value) => value) + const dotfiles = withHostKeys( + ui.showDotfilesByWorktree, + hostWorktreeKey, + (value) => value, + scope.inScope + ) if (dotfiles) { updates.showDotfilesByWorktree = dotfiles } - const roots = rekey(ui.explorerDisplayRootByWorktree, hostWorktreeKey, scope.path) + const roots = withHostKeys( + ui.explorerDisplayRootByWorktree, + hostWorktreeKey, + scope.path, + scope.inScope + ) if (roots) { updates.explorerDisplayRootByWorktree = roots } - const dismissals = rekey( + const dismissals = withHostKeys( ui.workspaceCleanup?.dismissals, (key) => hostDismissalKey(key, scope), - (dismissal) => rewriteDismissal(dismissal, scope) + (dismissal) => rewriteDismissal(dismissal, scope), + () => false ) if (ui.workspaceCleanup && dismissals) { updates.workspaceCleanup = { ...ui.workspaceCleanup, dismissals } @@ -102,28 +114,34 @@ export function rewriteWslMoveUiRouting( return updates } -/** Moved keys take the host's form; a key the host form already holds keeps that row. */ -function rekey( +/** + * 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 + mapValue: (value: T) => T, + inScope: (key: string) => boolean ): Record | null { if (!record) { return null } let changed = false - const next: Record = {} + const next: Record = { ...record } for (const [key, value] of Object.entries(record)) { const hostKey = hostKeyOf(key) - if (hostKey === key) { - if (!Object.hasOwn(next, key)) { - next[key] = value + if (hostKey !== key) { + if (!Object.hasOwn(next, hostKey)) { + next[hostKey] = mapValue(value) + changed = true } continue } - changed = true - if (!Object.hasOwn(record, hostKey)) { - next[hostKey] = mapValue(value) + const mapped = inScope(key) ? mapValue(value) : value + if (mapped !== value) { + next[key] = mapped + changed = true } } return changed ? next : null @@ -168,17 +186,17 @@ function rewriteManualRepoOrder( const onHost = new Set( order.filter((entry) => entry.hostId === scope.destinationHostId).map((entry) => entry.repoId) ) - return order.flatMap((entry) => { - if (!isMovedLocalRepo(entry, scope)) { + 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] } - // The host already lists it: one row per repo and host, at the earlier position. - if (onHost.has(entry.repoId)) { - return [] - } onHost.add(entry.repoId) - return [{ hostId: scope.destinationHostId, repoId: entry.repoId }] + changed = true + return [entry, { hostId: scope.destinationHostId, repoId: entry.repoId }] }) + return changed ? next : null } function isMovedLocalRepo(entry: ManualRepoOrderEntry, scope: WslMoveRewriteScope): boolean { 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 dfbf2a1cbcb..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,18 +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' /** * 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): ReadonlySet { +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/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 77bc7936211..a371f909d14 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -13,8 +13,8 @@ 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 { recoverInterruptedWslMoves } from '../persistence/moving-wsl-projects/wsl-move-reset' +import { runWslMoveStartupRewrite } from '../persistence/moving-wsl-projects/wsl-move-startup-rewrite' import { registerWslMoveMainRewriteStores } from '../persistence/moving-wsl-projects/wsl-move-main-rewrite-stores' import { createProfileStateStoreForStartup } from '../persistence/profile-state/profile-state-startup-authority' import { initializeBrowserClientHostId } from '../browser/browser-client-host-id' @@ -135,6 +135,9 @@ export async function initializeReadyFoundation(): Promise { // Why this early: the first window stamps the hosting id into its renderer's argv, so the durable // read has to have happened by then or the renderer and the browser-host lease disagree. 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({ dataFile: profile.dataFile, databaseFile: profile.stateDatabaseFile, @@ -154,12 +157,11 @@ export async function initializeReadyFoundation(): Promise { state.store = store setNodeDistMirrorSettingReader(() => store.getSettings().nodeDistMirrorUrl) const userDataPath = getAppEnvironment().getPath('userData') - // Why before any handler or runtime: a project a WSL move is taking must never get a new terminal. - loadWslMoveFence(userDataPath) registerWslMoveMainRewriteStores(store) - // Resets a move interrupted before commit, then finishes one that committed; gated off, no-ops. - void recoverInterruptedWslMoves(userDataPath).then(() => - runGatedWslMoveDesktopRewritePass({ userDataPath }) + // Resets a move interrupted before commit, then finishes one that committed; main-process-ready + // waits on both, bounded. Gated off, neither runs. + state.wslMoveStartupRewriteReady = runWslMoveStartupRewrite({ userDataPath }, () => + recoverInterruptedWslMoves(userDataPath) ) // Why: create pending readiness before the guard can observe the default session. // Why parked on state instead of awaited here: Dock/Launchpad launches don't inherit shell 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.ts b/src/main/startup/main-process-ready.ts index 7c27f73a465..28dc4a12c51 100644 --- a/src/main/startup/main-process-ready.ts +++ b/src/main/startup/main-process-ready.ts @@ -21,6 +21,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-state.ts b/src/main/startup/main-process-state.ts index 94f2b1bb0e2..e80ebed3342 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -127,6 +127,9 @@ export const mainProcessState = { // Why published: the default-session proxy must be applied before the first app-owned fetcher, // but window creation has no reason to queue behind it (the request guard already fences it). 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(), // Why published: i18n/menu init no longer precedes the launch phase, so the one launch-phase // path that reads a translated string (the runtime-RPC startup failure dialog) waits on this. // Never rejects: the phase's own failure is surfaced by initializeMainProcessReady. 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 216c1df9360..94ba048aa76 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -120,6 +120,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta await timeRendererStartupStep('repo-catalog-settlement', () => actions.awaitLocalRepoCatalogSettlement() ) + // Before the session read: a moved project's local rows must leave before the host's merge in. + const wslMovedProjectsPromise = useAppStore.getState().fetchWslMovedProjects() // Why: folder workspaces merge against projectGroups (repos.ts fetchFolderWorkspacesForAllHosts), // so keep this chain ordered while overlapping it with session-scoped hydration. const localCatalogChain = (async () => { @@ -129,20 +131,18 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta await timeRendererStartupStep('fetch-folder-workspaces-local', () => actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) ) - // Before hydration: a moved project's chats hydrate only once it is known to be moved. - await timeRendererStartupStep('fetch-wsl-moved-projects', () => - useAppStore.getState().fetchWslMovedProjects() - ) })() - const sessionReadPromise = runtimeHostsPromise.then((startupRuntimeHostIds) => - // Why: include saved runtime host ids so per-host worktree session slices restore from local settings without waiting on network reachability; unreadable partitions skip. - timeRendererStartupStep('session-get', () => - fetchWorkspaceSessionWithRuntimeHostOwners( - window.api.session, - useAppStore.getState().repos, - startupRuntimeHostIds + const sessionReadPromise = Promise.all([runtimeHostsPromise, wslMovedProjectsPromise]).then( + ([startupRuntimeHostIds]) => + // Why: include saved runtime host ids so per-host worktree session slices restore from local settings without waiting on network reachability; unreadable partitions skip. + timeRendererStartupStep('session-get', () => + fetchWorkspaceSessionWithRuntimeHostOwners( + window.api.session, + useAppStore.getState().repos, + startupRuntimeHostIds, + useAppStore.getState().wslMovedProjects + ) ) - ) ) const hydrationSessionChain = sessionReadPromise.then(async (sessionRead) => { const hydrationRepoIds = collectWorktreeHydrationRepoIdsFromSession( diff --git a/src/renderer/src/lib/workspace-session-host-hydration.ts b/src/renderer/src/lib/workspace-session-host-hydration.ts index 9852dd56385..f2cb905b92b 100644 --- a/src/renderer/src/lib/workspace-session-host-hydration.ts +++ b/src/renderer/src/lib/workspace-session-host-hydration.ts @@ -22,6 +22,8 @@ import { normalizeWorkspaceSessionKeyToWorktreeId } from './workspace-session-host-contention' import { nonLocalHostSessionEntries, type HostSessionSlices } from './workspace-session-host-split' +import type { WslMovedProject } from '../../../shared/wsl-move/wsl-moved-project' +import { withoutWslMovedLocalRows } from '../store/repos/wsl-moved-local-session-rows' type SessionReadApi = { get: (hostId?: ExecutionHostId) => Promise @@ -161,7 +163,8 @@ export async function fetchWorkspaceSessionFromHosts( export async function fetchWorkspaceSessionWithRuntimeHostOwners( api: SessionReadApi, repos: readonly Pick[], - additionalRuntimeHostIds: readonly ExecutionHostId[] = [] + additionalRuntimeHostIds: readonly ExecutionHostId[] = [], + wslMovedProjects: readonly WslMovedProject[] = [] ): Promise { const readPartition = async (hostId: ExecutionHostId): Promise => { try { @@ -172,7 +175,8 @@ export async function fetchWorkspaceSessionWithRuntimeHostOwners( } } const slices: HostSessionSlices = { - [LOCAL_EXECUTION_HOST_ID]: await api.get() + // Before the merge: a moved workspace's retained rows share tab ids with its host's live ones. + [LOCAL_EXECUTION_HOST_ID]: withoutWslMovedLocalRows(await api.get(), wslMovedProjects) } // Why: startup can know saved runtime session hosts before their repo // catalogs hydrate, so include those partitions in the first read. 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..4fa43d00181 --- /dev/null +++ b/src/renderer/src/store/repos/wsl-moved-local-session-rows.test.ts @@ -0,0 +1,151 @@ +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 } 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( + { + get: vi.fn(async (hostId?: ExecutionHostId) => + hostId === HOST ? workspaceSession(HOST_ID, 'host') : localPartition() + ) + }, + [], + [HOST], + MOVED + ) + 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..239027906b2 --- /dev/null +++ b/src/renderer/src/store/repos/wsl-moved-local-session-rows.ts @@ -0,0 +1,57 @@ +/** + * 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 { 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 +} diff --git a/src/renderer/src/store/repos/wsl-moved-projects.ts b/src/renderer/src/store/repos/wsl-moved-projects.ts index 497446f92fa..cd07d2bacd0 100644 --- a/src/renderer/src/store/repos/wsl-moved-projects.ts +++ b/src/renderer/src/store/repos/wsl-moved-projects.ts @@ -59,7 +59,8 @@ function sameMovedProjects( ) } -function movedProjectForKey( +/** 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 { 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/wsl-move/wsl-move-cutover.ts b/src/shared/wsl-move/wsl-move-cutover.ts index 0b51115fcd1..067c299fadc 100644 --- a/src/shared/wsl-move/wsl-move-cutover.ts +++ b/src/shared/wsl-move/wsl-move-cutover.ts @@ -41,6 +41,8 @@ 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(), supersedesMigrationId: z.string().min(1).max(128).optional(), manifest: z.unknown() })