diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index b5f7afc92ac..38e58e5654e 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -41,6 +41,8 @@ import type { ReconnectFactsFor } from '../persistence/window-save-view' type RegisterAppHandlersOptions = { quitResumeRecordsFor?: QuitResumeRecordsFor reconnectFactsFor?: ReconnectFactsFor + /** Runs after the unload checkpoint stages the window's state, before it flushes. */ + afterUnloadStage?: () => void onBeforeRelaunch?: () => void | Promise /** Only for app:relaunch, whose app.exit(0) is committed and skips will-quit. */ onBeforeRelaunchExit?: () => void | Promise @@ -121,7 +123,8 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp registerRendererShutdownCheckpointHandler( store, options.quitResumeRecordsFor, - options.reconnectFactsFor + options.reconnectFactsFor, + options.afterUnloadStage ) registerMacKeyboardLayoutChangeNotifications() ipcMain.handle('app:isProfileStateSaveDelayed', isProfileStateSaveDelayed) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 3bc9009221c..dc1af02774a 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -572,6 +572,7 @@ describe('registerCoreHandlers', () => { }) expect(registerAppHandlersMock).toHaveBeenCalledWith(store, { quitResumeRecordsFor: expect.any(Function), + afterUnloadStage: expect.any(Function), onBeforeRelaunch, onBeforeRelaunchExit: expect.any(Function) }) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 8e5d3ad45a8..d74a028a9b7 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -31,6 +31,7 @@ import { } from '../runtime-environments/runtime-environments' import { registerWslHostHandlers } from '../wsl-host-handlers' import { registerWslMoveHandlers } from '../wsl-move-handlers' +import { reapplyWslMoveFocusRewrites } from '../../persistence/moving-wsl-projects/wsl-move-focus-reapply' import { registerEphemeralVmHandlers } from '../ephemeral-vm' import { registerAiVaultHandlers } from '../ai-vault' import { registerAiVaultSearchHandlers } from '../ai-vault-search' @@ -182,6 +183,10 @@ export function registerCoreHandlers( runtime.shutdownReconnectFactsFor(session, partition) } : {}), + // The checkpoint writes the window's focus, which is stale for a project moved under it. + afterUnloadStage: () => { + reapplyWslMoveFocusRewrites(app.getPath('userData'), store) + }, onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch, // Why here, not onBeforeRelaunch: restart and profile switches quit through will-quit, which closes it. onBeforeRelaunchExit: () => closeNotificationDismissalsBeforeRelaunch(runtime) diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index 4c7125060b4..66950692b95 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -102,7 +102,9 @@ function stagedSessionForMain( export function registerRendererShutdownCheckpointHandler( store: Store, quitResumeRecordsFor: QuitResumeRecordsFor = () => [], - reconnectFactsFor?: ReconnectFactsFor + reconnectFactsFor?: ReconnectFactsFor, + /** Corrects what the window staged before the flush; must not throw. */ + afterStage: () => void = () => {} ): void { // Why: beforeunload cannot await, so the sync reply only reports staging. // Durability is joined out-of-band by paths that are about to navigate. @@ -122,6 +124,7 @@ export function registerRendererShutdownCheckpointHandler( } } store.updateUI(args.ui) + afterStage() } catch (error) { console.error('[app] Failed to stage renderer state before unload:', error) ok = false diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-automation-filter-rewrite.ts b/src/main/persistence/moving-wsl-projects/wsl-move-automation-filter-rewrite.ts new file mode 100644 index 00000000000..04c7e076ac6 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-automation-filter-rewrite.ts @@ -0,0 +1,25 @@ +/** + * The Automations page host filter after a WSL move. It selects one host or all of them, so it + * cannot also cover the WSL host; a filter on this desktop would hide every automation the move + * carried. Only then does it widen to All hosts, once: the ledgered run never repeats, so a + * later choice of this desktop stays. + */ +import type { PersistedAutomationHostFilter } from '../../../shared/automation-host-filter' +import { hostStableKey } from '../../../shared/automation-owner-key' + +const DESKTOP_SELF_HOST_KEY = hostStableKey({ + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +}) + +/** The filter to persist, or null when it stays. */ +export function wslMoveAutomationHostFilterUpdate( + filter: PersistedAutomationHostFilter | undefined, + movedAutomationCount: number +): PersistedAutomationHostFilter | null { + return movedAutomationCount > 0 && + filter?.kind === 'host' && + filter.hostKey === DESKTOP_SELF_HOST_KEY + ? { kind: 'all' } + : null +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite-registry.ts b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite-registry.ts new file mode 100644 index 00000000000..d562548a068 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite-registry.ts @@ -0,0 +1,34 @@ +/** + * Every desktop rewrite store a committed WSL move runs, registered in run order. Startup calls it + * once, before the first rewrite pass; the coverage test checks it against the disposition tables. + */ +import type { WslMoveBrowserCookieCopyDependencies } from './wsl-move-browser-cookie-copy' +import { registerWslMoveBrowserCookieStore } from './wsl-move-browser-cookie-store' +import { registerWslMoveRendererRewriteStores } from './wsl-move-desktop-rewrite-stores' +import { + registerWslMoveHostAutomationsStore, + type WslMoveHostAutomations +} from './wsl-move-host-automations-rewrite' +import { + registerWslMoveMainRewriteStores, + type WslMoveMainRewriteProfile +} from './wsl-move-main-rewrite-stores' +import { registerWslMoveOrchestrationHistoryStore } from './wsl-move-orchestration-history-rewrite' + +export type WslMoveDesktopRewriteRegistryDependencies = { + profile: WslMoveMainRewriteProfile + orchestrationDatabasePath: string + hostAutomations: WslMoveHostAutomations + browserCookies: WslMoveBrowserCookieCopyDependencies +} + +export function registerWslMoveDesktopRewriteRegistry( + dependencies: WslMoveDesktopRewriteRegistryDependencies +): void { + registerWslMoveMainRewriteStores(dependencies.profile) + registerWslMoveOrchestrationHistoryStore(dependencies.orchestrationDatabasePath) + registerWslMoveRendererRewriteStores() + registerWslMoveHostAutomationsStore(dependencies.hostAutomations) + // Last among main-side stores: it throws until browser sessions exist, pausing any store after it. + registerWslMoveBrowserCookieStore(dependencies.browserCookies) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores.ts b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores.ts index afcbef6b4b0..b766c7711aa 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 @@ -38,9 +38,8 @@ export type WslMoveDesktopRewriteStore = WslMoveMainRewriteStore | WslMoveRender const STORE_ID_PATTERN = /^[A-Za-z0-9:._-]{1,128}$/ const MAX_STORES = 256 -// 7f (the last store PR) flips this; until then no move may leave `destination-committed`. -// 7f-1 registers `browser-cookies` (wsl-move-browser-cookie-store.ts); the flip waits for 7c-7e. -export const WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE: boolean = false +// Every desktop-rewrite disposition has its store (wsl-move-rewrite-store-coverage.test.ts proves it). +export const WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE: boolean = true let registryComplete = WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-focus-reapply.ts b/src/main/persistence/moving-wsl-projects/wsl-move-focus-reapply.ts new file mode 100644 index 00000000000..3d760969e72 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-focus-reapply.ts @@ -0,0 +1,71 @@ +/** + * A window hydrated before a move's rewrite can still write the old focus (the session's active + * workspace, the UI's last-active worktree) for a moved project, and its unload checkpoint does. + * So at startup and after every unload checkpoint the corrective part of `ui-routing` and + * `session-globals` runs again for each committed move. Nothing else is re-applied: the additive + * rewrites (host copies, visible hosts, the automation filter) would undo a later user choice. + * No ledger entry is written; the ledgered pass alone owns the journal. + */ +import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { WSL_MOVE_CUTOVER_PHASES } from '../../../shared/wsl-move/wsl-move-cutover' +import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import type { Store } from '../loading-store/store' +import { listWslMoveCutovers } from './wsl-move-cutover-journal' +import { createUncToLinuxPathRewriter } from './wsl-move-path-rewriter' +import { wslMoveRewriteScope } from './wsl-move-rewrite-scope' +import { wslMoveSessionGlobalsPatch } from './wsl-move-session-rewrite' +import { wslMoveLastActiveWorktreeUpdate } from './wsl-move-ui-routing-rewrite' + +export type WslMoveFocusProfile = Pick< + Store, + 'getUI' | 'updateUI' | 'getWorkspaceSession' | 'patchWorkspaceSession' +> + +const COMMITTED_PHASE_INDEX = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') + +/** Never throws; a no-op with the gate closed or no committed move. Returns how many writes it made. */ +export function reapplyWslMoveFocusRewrites( + userDataPath: string, + profile: WslMoveFocusProfile, + warn: (message: string) => void = (message) => console.warn(message) +): number { + if (!wslHostsGateOpen()) { + return 0 + } + let writes = 0 + try { + for (const cutover of listWslMoveCutovers(userDataPath)) { + if (WSL_MOVE_CUTOVER_PHASES.indexOf(cutover.phase) < COMMITTED_PHASE_INDEX) { + continue + } + const scope = wslMoveRewriteScope({ + cutover, + rewritePath: createUncToLinuxPathRewriter(cutover.manifest.source.distro), + destinationHostId: toRuntimeExecutionHostId(cutover.destinationEnvironmentId), + recordSeededHostWorkspaceIds: () => { + throw new Error('wsl_move_focus_reapply_seeds_nothing') + } + }) + const lastActive = wslMoveLastActiveWorktreeUpdate(profile.getUI(), scope) + if (lastActive) { + profile.updateUI(lastActive) + writes += 1 + } + const focus = wslMoveSessionGlobalsPatch( + profile.getWorkspaceSession(LOCAL_EXECUTION_HOST_ID), + scope + ) + if (focus) { + profile.patchWorkspaceSession(focus, LOCAL_EXECUTION_HOST_ID) + writes += 1 + } + } + } catch (error) { + warn(`[wsl-move] Could not re-apply a moved project's focus: ${describe(error)}`) + } + return writes +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} 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 a77cc167602..c782664a882 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 @@ -5,6 +5,7 @@ */ import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { Store } from '../loading-store/store' +import { wslMoveAutomationHostFilterUpdate } from './wsl-move-automation-filter-rewrite' import { registerWslMoveDesktopRewriteStore } from './wsl-move-desktop-rewrite-stores' import { wslMoveRewriteScope } from './wsl-move-rewrite-scope' import { wslMoveHostSessionSeed, wslMoveSessionGlobalsPatch } from './wsl-move-session-rewrite' @@ -27,7 +28,15 @@ export function registerWslMoveMainRewriteStores(profile: WslMoveMainRewriteProf id: 'ui-routing', side: 'main', run: (context) => { - const updates = rewriteWslMoveUiRouting(profile.getUI(), wslMoveRewriteScope(context)) + const ui = profile.getUI() + const automationHostFilter = wslMoveAutomationHostFilterUpdate( + ui.automationHostFilter, + context.cutover.manifest.payload.dormantState?.automations?.length ?? 0 + ) + const updates = { + ...rewriteWslMoveUiRouting(ui, wslMoveRewriteScope(context)), + ...(automationHostFilter ? { automationHostFilter } : {}) + } if (Object.keys(updates).length > 0) { profile.updateUI(updates) } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-store-coverage.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-store-coverage.test.ts new file mode 100644 index 00000000000..9b7b66c691d --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-store-coverage.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WSL_MOVE_BROWSER_COOKIES_STORE_ID } from '../../../shared/wsl-move/wsl-move-browser-cookie-copy' +import { + PERSISTED_STATE_DISPOSITIONS, + WORKSPACE_SESSION_DISPOSITIONS, + type WslMoveFieldDisposition +} from '../../../shared/wsl-move/wsl-move-dispositions' +import { OUT_OF_STORE_DISPOSITIONS } from '../../../shared/wsl-move/wsl-move-out-of-store-dispositions' +import type { WslMoveRendererRewriteStoreId } from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types' +import { + LOCAL_STORAGE_MODULE_DISPOSITIONS, + SQLITE_TABLE_DISPOSITIONS +} from '../../../shared/wsl-move/wsl-move-storage-dispositions' +import { UI_STATE_DISPOSITIONS } from '../../../shared/wsl-move/wsl-move-ui-dispositions' +import { registerWslMoveDesktopRewriteRegistry } from './wsl-move-desktop-rewrite-registry' +import { + isWslMoveDesktopRewriteRegistryComplete, + listWslMoveDesktopRewriteStores, + resetWslMoveDesktopRewriteStoresForTests, + WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE +} from './wsl-move-desktop-rewrite-stores' +import { WSL_MOVE_HOST_AUTOMATIONS_STORE_ID } from './wsl-move-host-automations-rewrite' +import { WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID } from './wsl-move-orchestration-history-rewrite' + +vi.mock('../../browser/browser-partition-cookie-copy', () => ({ + copyBrowserPartitionCookies: vi.fn(), + flushBrowserPartitionCookies: vi.fn() +})) +vi.mock('../../browser/browser-session-registry', () => ({ browserSessionRegistry: {} })) +vi.mock('../../browser/browser-session-startup', () => ({ browserSessionsInitialized: vi.fn() })) +vi.mock('../../browser/browser-route-partition-binding-runtime', () => ({ + activeBrowserRoutePartitionOrcaProfileId: vi.fn(), + currentBrowserRoutePartitionBindingStore: vi.fn() +})) +vi.mock('../../browser/browser-route-partition-storage-dependencies', () => ({ + releaseEvictedBrowserRoutePartitionStorage: vi.fn() +})) + +const UI_ROUTING = 'ui-routing' +const SESSION_GLOBALS = 'session-globals' +const HOST_SESSION_SEED = 'host-session-seed' +const renderer = (id: WslMoveRendererRewriteStoreId): string => id + +/** + * The store that carries out each disposition row it names. Keys are `.` after the + * disposition tables; every row here must exist, and every registered store must appear. + */ +const STORE_BY_ROW: Record = { + 'persisted.workspaceSessionsByHostId': HOST_SESSION_SEED, + // A move row, not a desktop rewrite: the host turns back on what the manifest carried disabled. + 'persisted.automations': WSL_MOVE_HOST_AUTOMATIONS_STORE_ID, + 'session.activeWorktreeId': SESSION_GLOBALS, + 'session.activeWorkspaceExecutionHostId': SESSION_GLOBALS, + 'session.activeWorkspaceKey': SESSION_GLOBALS, + 'ui.lastActiveWorktreeId': UI_ROUTING, + 'ui.manualRepoOrder': UI_ROUTING, + 'ui.explorerDisplayRootByWorktree': UI_ROUTING, + 'ui.showDotfilesByWorktree': UI_ROUTING, + 'ui.workspaceCleanup': UI_ROUTING, + 'ui.collapsedGroups': UI_ROUTING, + // The scalar stays; the visible host list it resolves to gains the WSL host. + 'ui.workspaceHostScope': UI_ROUTING, + 'ui.workspaceHostOrder': UI_ROUTING, + 'ui.visibleWorkspaceHostIds': UI_ROUTING, + 'ui.agentsVisibleHostIds': UI_ROUTING, + 'ui.automationHostFilter': UI_ROUTING, + 'localStorage.src/renderer/src/lib/quick-open-file-history.ts': renderer('quick-open-history'), + 'localStorage.src/renderer/src/components/editor/ipynb-kernel-store.ts': + renderer('notebook-environments'), + 'localStorage.src/renderer/src/components/editor/file-view-preference-storage.ts': + renderer('file-view-preferences'), + 'sqlite.orchestration.worker_dispatches': WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID, + 'sqlite.orchestration.worker_terminal_resources': WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID, + 'outOfStore.orchestration.db': WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID, + 'outOfStore.browser partition (cookies, site storage)': WSL_MOVE_BROWSER_COOKIES_STORE_ID +} + +const prefixed = ( + prefix: string, + table: Record +): [string, WslMoveFieldDisposition][] => + Object.entries(table).map(([key, row]) => [`${prefix}.${key}`, row]) + +const ROWS = new Map([ + ...prefixed('persisted', PERSISTED_STATE_DISPOSITIONS), + ...prefixed('session', WORKSPACE_SESSION_DISPOSITIONS), + ...prefixed('ui', UI_STATE_DISPOSITIONS), + ...prefixed('localStorage', LOCAL_STORAGE_MODULE_DISPOSITIONS), + ...prefixed('sqlite', SQLITE_TABLE_DISPOSITIONS), + ...OUT_OF_STORE_DISPOSITIONS.map((row): [string, WslMoveFieldDisposition] => [ + `outOfStore.${row.store}`, + row + ]) +]) + +/** The stores startup registers, through the same entry point. */ +function registeredStoreIds(): string[] { + registerWslMoveDesktopRewriteRegistry({ + profile: { + getUI: vi.fn(), + updateUI: vi.fn(), + getWorkspaceSession: vi.fn(), + patchWorkspaceSession: vi.fn(), + setWorkspaceSession: vi.fn(), + flushPendingOrThrowAsync: vi.fn() + }, + orchestrationDatabasePath: '/nowhere/orchestration.db', + hostAutomations: { enable: vi.fn() }, + browserCookies: { + userDataPath: '/nowhere', + sessionsReady: () => false, + orcaProfileId: () => null, + listEnvironments: () => [], + listBrowserProfiles: () => [], + resolveHostStorage: vi.fn(), + bindHostPartition: vi.fn(), + copyCookies: vi.fn(), + flushCookies: vi.fn() + } + }) + return listWslMoveDesktopRewriteStores().map((store) => store.id) +} + +afterEach(() => { + resetWslMoveDesktopRewriteStoresForTests() +}) + +describe('WSL move desktop rewrite registry coverage', () => { + it('gives every desktop-rewrite disposition row a store', () => { + const rewriteRows = [...ROWS] + .filter(([, row]) => row.disposition === 'rewrite-desktop' || row.rewriteDesktopCopy) + .map(([key]) => key) + expect(rewriteRows.length).toBeGreaterThan(0) + expect(rewriteRows.filter((key) => !Object.hasOwn(STORE_BY_ROW, key))).toEqual([]) + }) + + it('names only rows the disposition tables have', () => { + expect(Object.keys(STORE_BY_ROW).filter((key) => !ROWS.has(key))).toEqual([]) + }) + + it('maps every registered store to a row, and registers every mapped store', () => { + const registered = registeredStoreIds() + const mapped = new Set(Object.values(STORE_BY_ROW)) + expect(registered.filter((id) => !mapped.has(id))).toEqual([]) + expect([...mapped].filter((id) => !registered.includes(id))).toEqual([]) + }) + + it('is marked complete', () => { + expect(WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE).toBe(true) + expect(isWslMoveDesktopRewriteRegistryComplete()).toBe(true) + }) +}) 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 index 1b08ac7a5e3..130985e4437 100644 --- 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 @@ -20,26 +20,52 @@ 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) + const reapply = vi.fn() + await runWslMoveStartupRewrite({ userDataPath: '/data' }, { recover, reapply }) expect(recover).not.toHaveBeenCalled() + expect(reapply).not.toHaveBeenCalled() expect(pass.run).not.toHaveBeenCalled() }) - it('resets an interrupted move before the pass, and still runs the pass if that fails', async () => { + it('resets an interrupted move, then re-applies finished ones, before the pass, and still runs the pass if either 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') - }) + await runWslMoveStartupRewrite( + { userDataPath: '/data' }, + { + recover: async () => { + order.push('recover') + }, + reapply: () => { + order.push('reapply') + } + } + ) 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']) + await runWslMoveStartupRewrite( + { userDataPath: '/data' }, + { + recover: async () => { + order.push('recover-failed') + throw new Error('journal unreadable') + }, + reapply: () => { + order.push('reapply-failed') + throw new Error('profile unreadable') + } + } + ) + expect(order).toEqual([ + 'recover', + 'reapply', + 'pass', + 'recover-failed', + 'reapply-failed', + 'pass' + ]) }) it('resolves once the pass finishes', async () => { 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 index 0d67d58b85c..6213c9af4d5 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.ts @@ -13,29 +13,42 @@ import { // behind it, with the stale-hydration risk this wait otherwise closes. export const WSL_MOVE_STARTUP_REWRITE_WAIT_MS = 5_000 +export type WslMoveStartupRewriteSteps = { + /** Resets a move interrupted before commit; its failure never skips what follows. */ + recover?: () => Promise + /** Corrects what a stale window wrote over a finished rewrite; must not throw. */ + reapply?: () => void +} + /** - * 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. + * Resolves when the pass finishes or the wait runs out; at once with the gate closed. `recover`, + * then `reapply`, run first inside the same wait, so a finished move is corrected even when the + * pass is slow. */ export function runWslMoveStartupRewrite( options: WslMoveDesktopRewritePassOptions, - before: () => Promise = () => Promise.resolve(), - waitMs: number = WSL_MOVE_STARTUP_REWRITE_WAIT_MS + steps: WslMoveStartupRewriteSteps = {} ): Promise { if (!wslHostsGateOpen()) { return Promise.resolve() } let timer: ReturnType | undefined const waited = new Promise((resolve) => { - timer = setTimeout(resolve, waitMs) + timer = setTimeout(resolve, WSL_MOVE_STARTUP_REWRITE_WAIT_MS) timer.unref?.() }) const pass = Promise.resolve() - .then(before) + .then(steps.recover) .catch((error: unknown) => { console.warn('[wsl-move] Startup recovery failed before the desktop rewrite:', error) }) + .then(steps.reapply) + .catch((error: unknown) => { + console.warn( + '[wsl-move] Re-applying a finished move 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.ts b/src/main/persistence/moving-wsl-projects/wsl-move-ui-routing-rewrite.ts index 7559443f28f..f147a16c42a 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 @@ -49,11 +49,7 @@ export function rewriteWslMoveUiRouting( ui: WslMoveUiRoutingState, scope: WslMoveRewriteScope ): Partial { - const updates: Partial = {} - const lastActive = ui.lastActiveWorktreeId - if (lastActive && scope.inScope(lastActive) && scope.worktreeId(lastActive) !== lastActive) { - updates.lastActiveWorktreeId = scope.worktreeId(lastActive) - } + const updates: Partial = { ...wslMoveLastActiveWorktreeUpdate(ui, scope) } const hostWorktreeKey = (key: string): string => scope.inScope(key) ? scope.worktreeId(key) : key const dotfiles = withHostKeys( @@ -114,6 +110,20 @@ export function rewriteWslMoveUiRouting( return updates } +/** + * The last-active worktree on the host when it names a moved local one. Corrective only, so it is + * also safe to re-apply after a stale window wrote the old id back. + */ +export function wslMoveLastActiveWorktreeUpdate( + ui: Pick, + scope: WslMoveRewriteScope +): Pick | null { + const lastActive = ui.lastActiveWorktreeId + return lastActive && scope.inScope(lastActive) && scope.worktreeId(lastActive) !== lastActive + ? { lastActiveWorktreeId: scope.worktreeId(lastActive) } + : null +} + /** * The host form of each moved key beside the local one, which a downgrade still reads. A moved * key that keeps its form (a folder workspace's) has only its value mapped. diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index d7d50c601de..72edce718f5 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -16,17 +16,12 @@ import { loadWslMoveFence } from '../persistence/moving-wsl-projects/wsl-move-sc import { recoverInterruptedWslMoves } from '../persistence/moving-wsl-projects/wsl-move-reset' import { runWslMoveStartupRewrite } from '../persistence/moving-wsl-projects/wsl-move-startup-rewrite' import { installWslMoveRetention } from '../persistence/moving-wsl-projects/wsl-move-retention' -import { registerWslMoveMainRewriteStores } from '../persistence/moving-wsl-projects/wsl-move-main-rewrite-stores' -import { registerWslMoveRendererRewriteStores } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores' -import { registerWslMoveOrchestrationHistoryStore } from '../persistence/moving-wsl-projects/wsl-move-orchestration-history-rewrite' -import { registerWslMoveHostAutomationsStore } from '../persistence/moving-wsl-projects/wsl-move-host-automations-rewrite' +import { registerWslMoveDesktopRewriteRegistry } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite-registry' +import { reapplyWslMoveFocusRewrites } from '../persistence/moving-wsl-projects/wsl-move-focus-reapply' import { enableWslHostAutomations } from '../ipc/wsl-host-automation-enable' import { scheduleWslMoveRewriteRetry } from '../persistence/moving-wsl-projects/wsl-move-rewrite-retry' import { orchestrationDbFile } from '../runtime/orchestration/db/orchestration-db-file' -import { - productionWslMoveBrowserCookieCopyDependencies, - registerWslMoveBrowserCookieStore -} from '../persistence/moving-wsl-projects/wsl-move-browser-cookie-store' +import { productionWslMoveBrowserCookieCopyDependencies } from '../persistence/moving-wsl-projects/wsl-move-browser-cookie-store' import { createProfileStateStoreForStartup } from '../persistence/profile-state/profile-state-startup-authority' import { initializeBrowserClientHostId } from '../browser/browser-client-host-id' import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report' @@ -168,20 +163,24 @@ export async function initializeReadyFoundation(): Promise { state.store = store setNodeDistMirrorSettingReader(() => store.getSettings().nodeDistMirrorUrl) const userDataPath = getAppEnvironment().getPath('userData') - registerWslMoveMainRewriteStores(store) - registerWslMoveOrchestrationHistoryStore( - orchestrationDbFile(getAppEnvironment().getPath('userData')) - ) - registerWslMoveRendererRewriteStores() - registerWslMoveHostAutomationsStore({ - enable: (environmentId, ids) => enableWslHostAutomations(userDataPath, environmentId, ids) + registerWslMoveDesktopRewriteRegistry({ + profile: store, + orchestrationDatabasePath: orchestrationDbFile(userDataPath), + hostAutomations: { + enable: (environmentId, ids) => enableWslHostAutomations(userDataPath, environmentId, ids) + }, + browserCookies: productionWslMoveBrowserCookieCopyDependencies(userDataPath) }) - // Last among main-side stores: it throws until browser sessions exist, pausing any store after it. - registerWslMoveBrowserCookieStore(productionWslMoveBrowserCookieCopyDependencies(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) + // Resets a move interrupted before commit, corrects stale focus on finished ones, then finishes + // one that committed; main-process-ready waits on all three, bounded. Gated off, none runs. + state.wslMoveStartupRewriteReady = runWslMoveStartupRewrite( + { userDataPath }, + { + recover: () => recoverInterruptedWslMoves(userDataPath), + reapply: () => { + reapplyWslMoveFocusRewrites(userDataPath, store) + } + } ) // Re-marks retained rows a downgrade stripped; never awaited, since nothing it writes gates a read. void state.wslMoveStartupRewriteReady.then(() => { diff --git a/src/shared/wsl-move/wsl-move-dispositions.ts b/src/shared/wsl-move/wsl-move-dispositions.ts index f316a12b090..35dd749e360 100644 --- a/src/shared/wsl-move/wsl-move-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-dispositions.ts @@ -78,7 +78,11 @@ export const PERSISTED_STATE_DISPOSITIONS = { item: 22, note: "The desktop seeds the WSL host's session from the moved 'local' rows (D1b-7c)." }, - remoteLayoutSnapshots: { disposition: 'rewrite-desktop', item: 47 }, + remoteLayoutSnapshots: { + disposition: 'rederive', + item: 47, + note: "Rebuilt from the host's first tab stream; the seeded workspaceSessionsByHostId gives first paint." + }, sshTargets: notApplicableDisposition(62), sshTargetGenerationCounter: notApplicableDisposition(62), deletedSshConfigAliases: notApplicableDisposition(62), diff --git a/src/shared/wsl-move/wsl-move-ui-dispositions.ts b/src/shared/wsl-move/wsl-move-ui-dispositions.ts index 6017a4ed05d..c35907f1388 100644 --- a/src/shared/wsl-move/wsl-move-ui-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-ui-dispositions.ts @@ -25,11 +25,10 @@ export const UI_STATE_DISPOSITIONS = { acknowledgedAgentsByPaneKey: moveDisposition(38, { note: 'Pane keys are kept.' }), activityClearedAtByPaneKey: moveDisposition(38, { note: 'Pane keys are kept.' }), manuallyUnreadTurnsByPaneKey: moveDisposition(38, { note: 'Pane keys are kept.' }), - trustedOrcaHooks: { - disposition: 'rewrite-desktop', - item: 40, - note: 'Kept by repoId; the trust prune must not drop it while the repo is between hosts.' - }, + trustedOrcaHooks: notApplicableDisposition( + 40, + 'Kept by repoId, which the move keeps; wsl-moved-repo-ui-retention.ts stops the trust prune dropping it.' + ), collapsedGroups: { disposition: 'rewrite-desktop', item: 38, @@ -62,7 +61,7 @@ export const UI_STATE_DISPOSITIONS = { automationHostFilter: { disposition: 'rewrite-desktop', item: 38, - note: "A 'local' filter also covers the WSL host." + note: 'Single-select: a filter on this desktop widens to All hosts once, if the move carried automations.' }, jiraQuickFilterState: notApplicableDisposition(38, 'Filters belong to the Jira host.'), jiraSavedFilterState: notApplicableDisposition(