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)

This commit is contained in:
Neil
2026-10-20 22:00:43 -07:00
parent 01fc68626f
commit 0a93bdd293
24 changed files with 903 additions and 134 deletions
@@ -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' &&
@@ -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 = {
@@ -30,14 +30,41 @@ export type WslMoveDesktopRewritePassOptions = {
warn?: (message: string) => void
}
function rewriteContext(cutover: Readonly<WslMoveCutover>): WslMoveDesktopRewriteContext {
function rewriteContext(
userDataPath: string,
cutover: Readonly<WslMoveCutover>
): 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<void> {
@@ -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<void>(() => {}))
// 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(),
@@ -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
@@ -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']))
@@ -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')
})
})
@@ -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<string> = 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<string> = 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<Tab, 'contentType'>): 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<string>): 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<T>(
record: Record<string, T> | undefined,
keep: (value: T) => boolean
): Record<string, T> | undefined {
return record && Object.fromEntries(Object.entries(record).filter(([, value]) => keep(value)))
}
@@ -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<void>>() }))
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<void>((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<void>(() => {}))
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)
})
})
@@ -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<unknown> = () => Promise.resolve(),
waitMs: number = WSL_MOVE_STARTUP_REWRITE_WAIT_MS
): Promise<void> {
if (!wslHostsGateOpen()) {
return Promise.resolve()
}
let timer: ReturnType<typeof setTimeout> | undefined
const waited = new Promise<void>((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))
}
@@ -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)
@@ -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<T>(
/**
* 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<T>(
record: Record<string, T> | undefined,
hostKeyOf: (key: string) => string,
mapValue: (value: T) => T
mapValue: (value: T) => T,
inScope: (key: string) => boolean
): Record<string, T> | null {
if (!record) {
return null
}
let changed = false
const next: Record<string, T> = {}
const next: Record<string, T> = { ...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 {
@@ -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<string> {
export function findCrossHostPaneTabIds(
state: PersistedState,
isRetainedLocalWorkspace: (workspaceId: string) => boolean = isWslMoveWorkspaceHostOwned
): ReadonlySet<string> {
const seenTabIds = new Set<string>()
const collidingTabIds = new Set<string>()
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
@@ -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<void> {
// 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<void> {
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
@@ -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<void>((resolve) => {
finishRewrite = () => {
phaseEvents.push('wsl-move-rewrite-done')
resolve()
}
})
const options = {
openMainWindow: vi.fn(),
handleMacAppActivation: vi.fn()
} as unknown as Parameters<typeof initializeMainProcessReady>[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'
])
})
})
+1
View File
@@ -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()
+3
View File
@@ -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.
@@ -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(
@@ -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<WorkspaceSessionState>
@@ -161,7 +163,8 @@ export async function fetchWorkspaceSessionFromHosts(
export async function fetchWorkspaceSessionWithRuntimeHostOwners(
api: SessionReadApi,
repos: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[],
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
additionalRuntimeHostIds: readonly ExecutionHostId[] = [],
wslMovedProjects: readonly WslMovedProject[] = []
): Promise<WorkspaceSessionHostRead> {
const readPartition = async (hostId: ExecutionHostId): Promise<WorkspaceSessionState | null> => {
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.
@@ -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)
})
})
@@ -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
}
@@ -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 {
+127
View File
@@ -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<string, Set<string>>()
const keptTypesByOwner = new Map<string, Set<string>>()
const unifiedTabs: Record<string, Tab[]> = {}
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<string> =>
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<string>): 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<T>(
record: Record<string, T> | undefined,
removedByOwner: ReadonlyMap<string, ReadonlySet<string>>,
map: (value: T, removed: ReadonlySet<string>) => T
): Record<string, T> | 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<T>(
record: Record<string, T>,
removedByOwner: ReadonlyMap<string, ReadonlySet<string>>,
map: (value: T, owner: string) => T | null | undefined
): Record<string, T> {
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]]
})
)
}
+2
View File
@@ -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()
})