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 dace75e6f83..547bb0125f0 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 @@ -321,6 +321,10 @@ vi.mock('../wsl-host-handlers', () => ({ registerWslHostHandlers: vi.fn() })) +vi.mock('../wsl-move-handlers', () => ({ + registerWslMoveHandlers: vi.fn() +})) + vi.mock('../ephemeral-vm', () => ({ registerEphemeralVmHandlers: registerEphemeralVmHandlersMock })) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index cc256168d74..a39b797fdf4 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -28,6 +28,7 @@ import { registerRuntimeEnvironmentHandlers } from '../runtime-environments' import { registerWslHostHandlers } from '../wsl-host-handlers' +import { registerWslMoveHandlers } from '../wsl-move-handlers' import { registerEphemeralVmHandlers } from '../ephemeral-vm' import { registerAiVaultHandlers } from '../ai-vault' import { registerAiVaultSearchHandlers } from '../ai-vault-search' @@ -254,6 +255,7 @@ export function registerCoreHandlers( getUserDataPath: () => app.getPath('userData'), invalidateTransport: invalidateRuntimeEnvironmentTransport }) + registerWslMoveHandlers(() => app.getPath('userData')) registerEphemeralVmHandlers(store, pluginService) // Session history and terminal resume are not chats; a refused host leaves nothing to check. const ensureStructuredSessionOwnership = () => diff --git a/src/main/ipc/wsl-move-handlers.test.ts b/src/main/ipc/wsl-move-handlers.test.ts new file mode 100644 index 00000000000..335ab7ee45b --- /dev/null +++ b/src/main/ipc/wsl-move-handlers.test.ts @@ -0,0 +1,79 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate' +import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { listWslMoveCutovers } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal' +import { + registerWslMoveDesktopRewriteStore, + resetWslMoveDesktopRewriteStoresForTests +} from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores' + +type Handler = (event: unknown, request?: unknown) => unknown + +const handlers = vi.hoisted(() => new Map()) +vi.mock('electron', () => ({ + ipcMain: { + removeHandler: (channel: string) => handlers.delete(channel), + handle: (channel: string, handler: Handler) => handlers.set(channel, handler) + } +})) + +import { registerWslMoveHandlers } from './wsl-move-handlers' + +let userDataPath: string +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-ipc-')) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + registerWslMoveDesktopRewriteStore({ id: 'renderer-a', side: 'renderer' }) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + registerWslMoveHandlers(() => userDataPath) +}) +afterEach(() => { + vi.unstubAllEnvs() + resetWslMoveDesktopRewriteStoresForTests() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +const invoke = (channel: string, request?: unknown) => handlers.get(channel)?.({}, request) + +describe('WSL move IPC', () => { + it('lists pending renderer rewrites and records one', () => { + expect(invoke('wslMove:listPendingRendererRewrites')).toMatchObject([ + { migrationId: 'move-1', storeIds: ['renderer-a'] } + ]) + expect( + invoke('wslMove:recordStoreRewritten', { migrationId: 'move-1', storeId: 'renderer-a' }) + ).toEqual({ recorded: true }) + // The registry is incomplete until D1b-7f, so the move stays in its rewrite phase. + expect(listWslMoveCutovers(userDataPath)[0]).toMatchObject({ + phase: 'destination-committed', + rewrittenStores: ['renderer-a'] + }) + }) + + it.each([ + undefined, + null, + 'renderer-a', + { migrationId: 'move-1' }, + { migrationId: 1, storeId: 2 } + ])('refuses a malformed request %j', (request) => { + expect(invoke('wslMove:recordStoreRewritten', request)).toEqual({ + recorded: false, + reason: 'unknown-store' + }) + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([]) + }) + + it('answers empty and refuses with the gate closed', () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + expect(invoke('wslMove:listPendingRendererRewrites')).toEqual([]) + expect( + invoke('wslMove:recordStoreRewritten', { migrationId: 'move-1', storeId: 'renderer-a' }) + ).toEqual({ recorded: false, reason: 'gated-off' }) + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([]) + }) +}) diff --git a/src/main/ipc/wsl-move-handlers.ts b/src/main/ipc/wsl-move-handlers.ts new file mode 100644 index 00000000000..6f157820821 --- /dev/null +++ b/src/main/ipc/wsl-move-handlers.ts @@ -0,0 +1,47 @@ +/** + * IPC for the renderer half of a committed WSL move's desktop rewrite. Both channels answer + * empty / refuse while the internal WSL hosts gate is closed. + */ +import { ipcMain } from 'electron' +import type { + WslMovePendingRendererRewrite, + WslMoveRecordStoreRewrittenResult +} from '../../shared/wsl-move/wsl-move-renderer-rewrite-types' +import { + listPendingWslMoveRendererRewrites, + recordWslMoveRendererStoreRewritten +} from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite' + +export const WSL_MOVE_HANDLER_CHANNELS = [ + 'wslMove:listPendingRendererRewrites', + 'wslMove:recordStoreRewritten' +] as const + +export function registerWslMoveHandlers(getUserDataPath: () => string): void { + for (const channel of WSL_MOVE_HANDLER_CHANNELS) { + ipcMain.removeHandler(channel) + } + ipcMain.handle('wslMove:listPendingRendererRewrites', (): WslMovePendingRendererRewrite[] => + listPendingWslMoveRendererRewrites(getUserDataPath()) + ) + ipcMain.handle( + 'wslMove:recordStoreRewritten', + (_event, request: unknown): WslMoveRecordStoreRewrittenResult => { + // Renderer input: anything but two strings is an unknown store. + if ( + typeof request !== 'object' || + request === null || + !('migrationId' in request) || + !('storeId' in request) || + typeof request.migrationId !== 'string' || + typeof request.storeId !== 'string' + ) { + return { recorded: false, reason: 'unknown-store' } + } + return recordWslMoveRendererStoreRewritten(getUserDataPath(), { + migrationId: request.migrationId, + storeId: request.storeId + }) + } + ) +} diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding.ts index 8e45dcdb77f..6d86edff60e 100644 --- a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding.ts +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding.ts @@ -10,6 +10,7 @@ import { listWslMoveCutovers, writeWslMoveCutover } from '../../moving-wsl-projects/wsl-move-cutover-journal' +import { setWslMoveDesktopRewriteRegistryCompleteForTests } from '../../moving-wsl-projects/wsl-move-desktop-rewrite-stores' export function seedWslMoveCutover(userDataPath: string, cutover: WslMoveCutover): void { const existing = listWslMoveCutovers(userDataPath).find( @@ -17,7 +18,26 @@ export function seedWslMoveCutover(userDataPath: string, cutover: WslMoveCutover ) const from = existing ? WSL_MOVE_CUTOVER_PHASES.indexOf(existing.phase) + 1 : 0 const to = WSL_MOVE_CUTOVER_PHASES.indexOf(cutover.phase) - for (const phase of WSL_MOVE_CUTOVER_PHASES.slice(from, to + 1)) { - writeWslMoveCutover(userDataPath, { ...cutover, phase }) + const rewriteFrom = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') + // The ledger never shrinks: an unstated one keeps what the record already holds. + const ledger = + cutover.rewrittenStores.length > 0 ? cutover.rewrittenStores : (existing?.rewrittenStores ?? []) + // Seeding stands in for a finished rewrite, whatever stores exist yet. + const previous = setWslMoveDesktopRewriteRegistryCompleteForTests(true) + try { + for (const phase of WSL_MOVE_CUTOVER_PHASES.slice(from, to + 1)) { + const index = WSL_MOVE_CUTOVER_PHASES.indexOf(phase) + // The ledger only grows inside the rewrite phase, so earlier phases carry none of it. + writeWslMoveCutover(userDataPath, { + ...cutover, + phase, + rewrittenStores: index <= rewriteFrom ? [] : ledger + }) + if (index === rewriteFrom) { + writeWslMoveCutover(userDataPath, { ...cutover, phase, rewrittenStores: ledger }) + } + } + } finally { + setWslMoveDesktopRewriteRegistryCompleteForTests(previous) } } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts index 3944b9907a7..84473750600 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts @@ -16,12 +16,19 @@ import { writeWslMoveCutover, wslMoveJournalDirectory } from './wsl-move-cutover-journal' +import { + resetWslMoveDesktopRewriteStoresForTests, + setWslMoveDesktopRewriteRegistryCompleteForTests +} from './wsl-move-desktop-rewrite-stores' let userDataPath: string beforeEach(() => { userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-journal-')) }) -afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) +afterEach(() => { + resetWslMoveDesktopRewriteStoresForTests() + rmSync(userDataPath, { recursive: true, force: true }) +}) const journalPath = (id: string) => join(wslMoveJournalDirectory(userDataPath), `${id}.json`) @@ -81,6 +88,7 @@ describe('WSL move journal', () => { }) it('never moves a record backwards, rebinds it, or advances a superseded one', () => { + setWslMoveDesktopRewriteRegistryCompleteForTests(true) seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared'))).toThrow( 'wsl_move_phase_regressed' 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 2cb6b835c86..15e10cf5f85 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 @@ -14,6 +14,10 @@ import { createMigrationJournalDirectory, orderMigrationJournalChain } from '../migrating-orcad-catalog/migration-journal-directory' +import { + isWslMoveDesktopRewriteRegistryComplete, + pendingWslMoveDesktopRewriteStores +} from './wsl-move-desktop-rewrite-stores' export class WslMoveJournalUnreadableError extends Error { constructor(detail: string) { @@ -102,6 +106,9 @@ export function writeWslMoveCutover(userDataPath: string, cutover: WslMoveCutove if (!existing && parsed.phase !== 'prepared') { throw new Error('wsl_move_record_missing') } + if (!existing && parsed.rewrittenStores.length > 0) { + throw new Error('wsl_move_ledger_changed') + } if (existing) { assertWslMoveUpdate(existing, parsed, head) } else if (!head) { @@ -141,6 +148,35 @@ function assertWslMoveUpdate( if (head && head.migrationId !== existing.migrationId && next.phase !== existing.phase) { throw new Error('wsl_move_superseded') } + assertWslMoveLedgerUpdate(existing, next) +} + +/** + * The rewrite ledger only grows, and only while the desktop rewrites (`destination-committed`): + * append-only and without duplicates, so a stale writer cannot drop another's entry. Leaving that + * phase needs a complete registry and every registered store recorded. + */ +function assertWslMoveLedgerUpdate(existing: WslMoveCutover, next: WslMoveCutover): void { + const before = existing.rewrittenStores + const after = next.rewrittenStores + const kept = before.every((id, index) => after[index] === id) + if (!kept || new Set(after).size !== after.length) { + throw new Error('wsl_move_ledger_changed') + } + if ( + after.length > before.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' && + (!isWslMoveDesktopRewriteRegistryComplete() || + pendingWslMoveDesktopRewriteStores(after).length > 0) + ) { + throw new Error('wsl_move_rewrite_incomplete') + } } // Before quiesce nothing has reached the destination, so lifting the fence loses nothing. 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 new file mode 100644 index 00000000000..52af4c02f67 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores.ts @@ -0,0 +1,89 @@ +/** + * The desktop stores a committed WSL move rewrites before it reaches `client-rewritten` + * (design, section 4.4). Each id is recorded in the journal's `rewrittenStores` ledger once its + * store is rewritten and durable, so a crash redoes only the rest. + */ +import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import type { OrcadMigrationPathRewriter } from '../../ssh/orcad-migration-path-rewrite' + +export type WslMoveDesktopRewriteContext = { + cutover: Readonly + /** UNC paths of the moved distro become the Linux paths the host sees. */ + rewritePath: OrcadMigrationPathRewriter + /** The execution host the moved projects now belong to. */ + destinationHostId: `runtime:${string}` +} + +export type WslMoveMainRewriteStore = { + id: string + side: 'main' + /** Must be idempotent: a crash before its ledger entry runs it again. */ + run: (context: WslMoveDesktopRewriteContext) => Promise | void + /** Makes what `run` wrote durable; its ledger entry is written only after this resolves. */ + flush: () => Promise | void +} + +/** Rewritten by the renderer, which records it over IPC. */ +export type WslMoveRendererRewriteStore = { + id: string + side: 'renderer' +} + +export type WslMoveDesktopRewriteStore = WslMoveMainRewriteStore | WslMoveRendererRewriteStore + +// Within the journal schema's ledger bounds, so a registered id always fits the ledger. +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`. +export const WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE: boolean = false + +let registryComplete = WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE + +/** The journal and the driver both refuse `client-rewritten` while this is false. */ +export function isWslMoveDesktopRewriteRegistryComplete(): boolean { + return registryComplete +} + +/** Returns the previous value, so a fixture can restore it. */ +export function setWslMoveDesktopRewriteRegistryCompleteForTests(complete: boolean): boolean { + const previous = registryComplete + registryComplete = complete + return previous +} + +const stores: WslMoveDesktopRewriteStore[] = [] + +/** + * Registration order is run order among main-side stores. Renderer-side stores are independent of + * it: the renderer records them whenever it finishes, before or after any main store. + */ +export function registerWslMoveDesktopRewriteStore(store: WslMoveDesktopRewriteStore): void { + if (!STORE_ID_PATTERN.test(store.id)) { + throw new Error('wsl_move_rewrite_store_id_invalid') + } + if (stores.some((entry) => entry.id === store.id)) { + throw new Error('wsl_move_rewrite_store_duplicate') + } + if (stores.length >= MAX_STORES) { + throw new Error('wsl_move_rewrite_store_limit') + } + stores.push(store) +} + +export function listWslMoveDesktopRewriteStores(): readonly WslMoveDesktopRewriteStore[] { + return stores +} + +/** Registered stores the ledger does not record yet, in run order. */ +export function pendingWslMoveDesktopRewriteStores( + ledger: readonly string[] +): WslMoveDesktopRewriteStore[] { + const recorded = new Set(ledger) + return stores.filter((entry) => !recorded.has(entry.id)) +} + +export function resetWslMoveDesktopRewriteStoresForTests(): void { + stores.length = 0 + registryComplete = WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.test.ts new file mode 100644 index 00000000000..a66223e71a2 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.test.ts @@ -0,0 +1,277 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { listWslMoveCutovers, wslMoveJournalDirectory } from './wsl-move-cutover-journal' +import { + listPendingWslMoveRendererRewrites, + recordWslMoveRendererStoreRewritten, + runGatedWslMoveDesktopRewritePass, + runWslMoveDesktopRewritePass +} from './wsl-move-desktop-rewrite' +import { + registerWslMoveDesktopRewriteStore, + resetWslMoveDesktopRewriteStoresForTests, + setWslMoveDesktopRewriteRegistryCompleteForTests, + type WslMoveDesktopRewriteContext +} from './wsl-move-desktop-rewrite-stores' + +let userDataPath: string +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-rewrite-')) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + setWslMoveDesktopRewriteRegistryCompleteForTests(true) +}) +afterEach(() => { + vi.unstubAllEnvs() + resetWslMoveDesktopRewriteStoresForTests() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +const record = () => listWslMoveCutovers(userDataPath)[0] + +type Trace = { events: string[]; failAt: Set } + +function mainStore(id: string, trace: Trace): void { + registerWslMoveDesktopRewriteStore({ + id, + side: 'main', + run: () => { + trace.events.push(`run:${id}`) + if (trace.failAt.has(`run:${id}`)) { + throw new Error(`run ${id} failed`) + } + }, + flush: () => { + trace.events.push(`flush:${id}`) + if (trace.failAt.has(`flush:${id}`)) { + throw new Error(`flush ${id} failed`) + } + } + }) +} + +describe('WSL move desktop rewrite', () => { + it('runs each store then flushes it before its ledger entry, and finishes the move', async () => { + const trace: Trace = { events: [], failAt: new Set() } + const contexts: WslMoveDesktopRewriteContext[] = [] + registerWslMoveDesktopRewriteStore({ + id: 'first', + side: 'main', + run: (context) => { + contexts.push(context) + trace.events.push(`ledger-before-run:${record()?.rewrittenStores.join(',')}`) + }, + flush: () => { + trace.events.push(`ledger-at-flush:${record()?.rewrittenStores.join(',')}`) + } + }) + mainStore('second', trace) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + + await runWslMoveDesktopRewritePass({ userDataPath }) + + expect(trace.events).toEqual([ + 'ledger-before-run:', + 'ledger-at-flush:', + 'run:second', + 'flush:second' + ]) + expect(record()?.rewrittenStores).toEqual(['first', 'second']) + expect(record()?.phase).toBe('client-rewritten') + expect(contexts[0]?.destinationHostId).toBe('runtime:wsl%3AUbuntu') + expect( + contexts[0]?.rewritePath.path('\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-1', 'absolute') + ).toBe('/home/dev/repo-1') + }) + + it.each(['run:b', 'flush:b'])( + 'after a failure at %s, the next pass redoes only unrecorded stores', + async (failure) => { + const trace: Trace = { events: [], failAt: new Set([failure]) } + for (const id of ['a', 'b', 'c']) { + mainStore(id, trace) + } + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + const warn = vi.fn() + + await runWslMoveDesktopRewritePass({ userDataPath, warn }) + expect(record()?.rewrittenStores).toEqual(['a']) + expect(record()?.phase).toBe('destination-committed') + expect(warn).toHaveBeenCalledTimes(1) + expect(trace.events).not.toContain('run:c') + + trace.events.length = 0 + trace.failAt.clear() + await runWslMoveDesktopRewritePass({ userDataPath, warn }) + expect(trace.events).toEqual(['run:b', 'flush:b', 'run:c', 'flush:c']) + expect(record()?.rewrittenStores).toEqual(['a', 'b', 'c']) + expect(record()?.phase).toBe('client-rewritten') + } + ) + + it('stays in the rewrite phase while the store registry is incomplete', async () => { + setWslMoveDesktopRewriteRegistryCompleteForTests(false) + const trace: Trace = { events: [], failAt: new Set() } + mainStore('a', trace) + registerWslMoveDesktopRewriteStore({ id: 'r', side: 'renderer' }) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + await runWslMoveDesktopRewritePass({ userDataPath }) + recordWslMoveRendererStoreRewritten(userDataPath, { migrationId: 'move-1', storeId: 'r' }) + expect(record()?.rewrittenStores).toEqual(['a', 'r']) + expect(record()?.phase).toBe('destination-committed') + + setWslMoveDesktopRewriteRegistryCompleteForTests(true) + await runWslMoveDesktopRewritePass({ userDataPath }) + expect(record()?.phase).toBe('client-rewritten') + }) + + it('times out a hung store like a failure, without blocking the next pass', async () => { + let hang = true + registerWslMoveDesktopRewriteStore({ + id: 'slow', + side: 'main', + run: () => (hang ? new Promise(() => {}) : undefined), + flush: () => {} + }) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + const warn = vi.fn() + await runWslMoveDesktopRewritePass({ userDataPath, warn, storeTimeoutMs: 10 }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('slow run timed out')) + expect(record()?.rewrittenStores).toEqual([]) + + hang = false + await runWslMoveDesktopRewritePass({ userDataPath, warn, storeTimeoutMs: 10 }) + expect(record()?.rewrittenStores).toEqual(['slow']) + expect(record()?.phase).toBe('client-rewritten') + }) + + it('is idempotent: a finished move is not rewritten again', async () => { + const trace: Trace = { events: [], failAt: new Set() } + mainStore('a', trace) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + await runWslMoveDesktopRewritePass({ userDataPath }) + trace.events.length = 0 + await runWslMoveDesktopRewritePass({ userDataPath }) + expect(trace.events).toEqual([]) + expect(record()?.phase).toBe('client-rewritten') + expect(record()?.rewrittenStores).toEqual(['a']) + }) + + it('leaves moves before commit, and waits on renderer stores before finishing', async () => { + const trace: Trace = { events: [], failAt: new Set() } + mainStore('a', trace) + registerWslMoveDesktopRewriteStore({ id: 'r', side: 'renderer' }) + seedWslMoveCutover(userDataPath, cutover('move-1', 'blobs-transferred')) + await runWslMoveDesktopRewritePass({ userDataPath }) + expect(trace.events).toEqual([]) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + await runWslMoveDesktopRewritePass({ userDataPath }) + expect(record()?.rewrittenStores).toEqual(['a']) + expect(record()?.phase).toBe('destination-committed') + }) + + it('never throws when the journal cannot be read', async () => { + const warn = vi.fn() + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + writeFileSync(join(wslMoveJournalDirectory(userDataPath), 'move-2.json'), '{not json') + await expect(runWslMoveDesktopRewritePass({ userDataPath, warn })).resolves.toBeUndefined() + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('does nothing at startup with the gate closed', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + const trace: Trace = { events: [], failAt: new Set() } + mainStore('a', trace) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + await runGatedWslMoveDesktopRewritePass({ userDataPath }) + expect(trace.events).toEqual([]) + expect(record()?.phase).toBe('destination-committed') + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + await runGatedWslMoveDesktopRewritePass({ userDataPath }) + expect(record()?.phase).toBe('client-rewritten') + }) +}) + +describe('WSL move renderer rewrites', () => { + beforeEach(() => { + registerWslMoveDesktopRewriteStore({ + id: 'main-a', + side: 'main', + run: () => {}, + flush: () => {} + }) + registerWslMoveDesktopRewriteStore({ id: 'renderer-a', side: 'renderer' }) + registerWslMoveDesktopRewriteStore({ id: 'renderer-b', side: 'renderer' }) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + }) + + it('lists the pending renderer stores of a committed move', () => { + expect(listPendingWslMoveRendererRewrites(userDataPath)).toEqual([ + { + migrationId: 'move-1', + storeIds: ['renderer-a', 'renderer-b'], + destinationEnvironmentId: 'wsl:Ubuntu', + destinationHostId: 'runtime:wsl%3AUbuntu', + distro: 'Ubuntu', + scopeRepoIds: ['repo-1'], + scopeFolderWorkspaceIds: [] + } + ]) + }) + + it('records a pending renderer store, and finishes the move with the last one', async () => { + await runWslMoveDesktopRewritePass({ userDataPath }) + expect( + recordWslMoveRendererStoreRewritten(userDataPath, { + migrationId: 'move-1', + storeId: 'renderer-a' + }) + ).toEqual({ recorded: true }) + expect(record()?.phase).toBe('destination-committed') + recordWslMoveRendererStoreRewritten(userDataPath, { + migrationId: 'move-1', + storeId: 'renderer-b' + }) + expect(record()?.rewrittenStores).toEqual(['main-a', 'renderer-a', 'renderer-b']) + expect(record()?.phase).toBe('client-rewritten') + expect(listPendingWslMoveRendererRewrites(userDataPath)).toEqual([]) + }) + + it.each([ + ['an unknown store', { migrationId: 'move-1', storeId: 'nope' }, 'unknown-store'], + ['a main-side store', { migrationId: 'move-1', storeId: 'main-a' }, 'not-renderer-store'], + ['an unknown move', { migrationId: 'move-9', storeId: 'renderer-a' }, 'not-pending'] + ])('refuses %s', (_label, request, reason) => { + expect(recordWslMoveRendererStoreRewritten(userDataPath, request)).toEqual({ + recorded: false, + reason + }) + expect(record()?.rewrittenStores).toEqual([]) + }) + + it('refuses a store already recorded', () => { + const request = { migrationId: 'move-1', storeId: 'renderer-a' } + recordWslMoveRendererStoreRewritten(userDataPath, request) + expect(recordWslMoveRendererStoreRewritten(userDataPath, request)).toEqual({ + recorded: false, + reason: 'not-pending' + }) + expect(record()?.rewrittenStores).toEqual(['renderer-a']) + }) + + it('lists nothing and records nothing with the gate closed', () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + expect(listPendingWslMoveRendererRewrites(userDataPath)).toEqual([]) + expect( + recordWslMoveRendererStoreRewritten(userDataPath, { + migrationId: 'move-1', + storeId: 'renderer-a' + }) + ).toEqual({ recorded: false, reason: 'gated-off' }) + expect(record()?.rewrittenStores).toEqual([]) + }) +}) 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 new file mode 100644 index 00000000000..3cd01b99421 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.ts @@ -0,0 +1,237 @@ +/** + * The desktop rewrite stage of a committed WSL move: `destination-committed` → `client-rewritten`. + * Each registered store is rewritten, made durable, then recorded in the journal's ledger; a + * crash or failure redoes only the stores not recorded yet, on the next pass. + */ +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import type { + WslMovePendingRendererRewrite, + WslMoveRecordStoreRewrittenRequest, + WslMoveRecordStoreRewrittenResult +} from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types' +import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import { listWslMoveCutovers, writeWslMoveCutover } from './wsl-move-cutover-journal' +import { + isWslMoveDesktopRewriteRegistryComplete, + listWslMoveDesktopRewriteStores, + pendingWslMoveDesktopRewriteStores, + type WslMoveDesktopRewriteContext +} from './wsl-move-desktop-rewrite-stores' +import { createUncToLinuxPathRewriter } from './wsl-move-path-rewriter' + +// Passes are serialized, so one hung store would otherwise stall every later pass in the process. +export const WSL_MOVE_REWRITE_STORE_TIMEOUT_MS = 30_000 + +export type WslMoveDesktopRewritePassOptions = { + userDataPath: string + /** Per `run` and per `flush`; defaults to WSL_MOVE_REWRITE_STORE_TIMEOUT_MS. */ + storeTimeoutMs?: number + warn?: (message: string) => void +} + +function rewriteContext(cutover: Readonly): WslMoveDesktopRewriteContext { + return { + cutover, + rewritePath: createUncToLinuxPathRewriter(cutover.manifest.source.distro), + destinationHostId: toRuntimeExecutionHostId(cutover.destinationEnvironmentId) + } +} + +/** 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( + (entry) => entry.migrationId === migrationId + ) + return cutover?.phase === 'destination-committed' ? cutover : null +} + +function appendLedgerEntry(userDataPath: string, cutover: WslMoveCutover, storeId: string): void { + writeWslMoveCutover(userDataPath, { + ...cutover, + rewrittenStores: [...cutover.rewrittenStores, storeId], + updatedAt: new Date().toISOString() + }) +} + +/** Advances once every registered store, main and renderer, is in the ledger. */ +function advanceWhenRewritten(userDataPath: string, migrationId: string): void { + // Stores not registered yet would never run on a move that already left the rewrite phase. + if (!isWslMoveDesktopRewriteRegistryComplete()) { + return + } + const cutover = readCommittedCutover(userDataPath, migrationId) + if (!cutover || pendingWslMoveDesktopRewriteStores(cutover.rewrittenStores).length > 0) { + return + } + writeWslMoveCutover(userDataPath, { + ...cutover, + phase: 'client-rewritten', + updatedAt: new Date().toISOString() + }) +} + +/** A timed-out step fails like a thrown one; its late result is ignored, never recorded. */ +async function withStoreTimeout( + step: () => Promise | void, + timeoutMs: number, + label: string +): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs) + }) + try { + await Promise.race([Promise.resolve().then(step), timeout]) + } finally { + clearTimeout(timer) + } +} + +async function rewriteMainStores( + options: WslMoveDesktopRewritePassOptions, + migrationId: string +): Promise { + const { userDataPath } = options + const initial = readCommittedCutover(userDataPath, migrationId) + if (!initial) { + return + } + for (const store of pendingWslMoveDesktopRewriteStores(initial.rewrittenStores)) { + if (store.side !== 'main') { + continue + } + const before = readCommittedCutover(userDataPath, migrationId) + if (!before || before.rewrittenStores.includes(store.id)) { + continue + } + const timeoutMs = options.storeTimeoutMs ?? WSL_MOVE_REWRITE_STORE_TIMEOUT_MS + await withStoreTimeout(() => store.run(rewriteContext(before)), timeoutMs, `${store.id} run`) + await withStoreTimeout(() => store.flush(), timeoutMs, `${store.id} flush`) + const current = readCommittedCutover(userDataPath, migrationId) + if (!current) { + throw new Error('wsl_move_record_missing') + } + if (!current.rewrittenStores.includes(store.id)) { + appendLedgerEntry(userDataPath, current, store.id) + } + } + advanceWhenRewritten(userDataPath, migrationId) +} + +async function runPass(options: WslMoveDesktopRewritePassOptions): Promise { + const warn = options.warn ?? ((message: string) => console.warn(message)) + let committed: string[] + try { + committed = listWslMoveCutovers(options.userDataPath) + .filter((cutover) => cutover.phase === 'destination-committed') + .map((cutover) => cutover.migrationId) + } catch (error) { + warn(`[wsl-move] Could not read the move journal for the desktop rewrite: ${describe(error)}`) + return + } + for (const migrationId of committed) { + try { + await rewriteMainStores(options, migrationId) + } catch (error) { + // No ledger entry for the failed store: the next pass retries it, and nothing else changes. + warn(`[wsl-move] Desktop rewrite of move ${migrationId} paused: ${describe(error)}`) + } + } +} + +let passQueue: Promise = Promise.resolve() + +/** Never throws; passes are serialized so two cannot rewrite the same store at once. */ +export function runWslMoveDesktopRewritePass( + options: WslMoveDesktopRewritePassOptions +): Promise { + const next = passQueue.then(() => runPass(options)) + passQueue = next + return next +} + +/** Startup, and after a move commits: a no-op unless the internal WSL hosts gate is open. */ +export function runGatedWslMoveDesktopRewritePass( + options: WslMoveDesktopRewritePassOptions +): Promise { + return wslHostsGateOpen() ? runWslMoveDesktopRewritePass(options) : Promise.resolve() +} + +/** Committed moves still waiting on renderer-side stores; empty with the gate closed. */ +export function listPendingWslMoveRendererRewrites( + userDataPath: string +): WslMovePendingRendererRewrite[] { + if (!wslHostsGateOpen()) { + return [] + } + try { + return listWslMoveCutovers(userDataPath).flatMap((cutover) => { + if (cutover.phase !== 'destination-committed') { + return [] + } + const storeIds = pendingWslMoveDesktopRewriteStores(cutover.rewrittenStores) + .filter((store) => store.side === 'renderer') + .map((store) => store.id) + if (storeIds.length === 0) { + return [] + } + const { source } = cutover.manifest + return [ + { + migrationId: cutover.migrationId, + storeIds, + destinationEnvironmentId: cutover.destinationEnvironmentId, + destinationHostId: toRuntimeExecutionHostId(cutover.destinationEnvironmentId), + distro: source.distro, + scopeRepoIds: [...source.scopeRepoIds], + scopeFolderWorkspaceIds: [...source.scopeFolderWorkspaceIds] + } + ] + }) + } catch (error) { + console.warn(`[wsl-move] Could not list pending renderer rewrites: ${describe(error)}`) + return [] + } +} + +/** + * The renderer finished (and persisted) one of its stores for a committed move. Independent of + * main-store order: it may land before, between or after the main pass's entries. + */ +export function recordWslMoveRendererStoreRewritten( + userDataPath: string, + request: WslMoveRecordStoreRewrittenRequest +): WslMoveRecordStoreRewrittenResult { + if (!wslHostsGateOpen()) { + return { recorded: false, reason: 'gated-off' } + } + const store = listWslMoveDesktopRewriteStores().find((entry) => entry.id === request.storeId) + if (!store) { + return { recorded: false, reason: 'unknown-store' } + } + if (store.side !== 'renderer') { + return { recorded: false, reason: 'not-renderer-store' } + } + try { + const cutover = readCommittedCutover(userDataPath, request.migrationId) + if (!cutover || cutover.rewrittenStores.includes(store.id)) { + return { recorded: false, reason: 'not-pending' } + } + appendLedgerEntry(userDataPath, cutover, store.id) + } catch (error) { + console.warn(`[wsl-move] Could not record a renderer rewrite: ${describe(error)}`) + return { recorded: false, reason: 'failed' } + } + try { + advanceWhenRewritten(userDataPath, request.migrationId) + } catch (error) { + // The entry is durable; the next pass retries the advance. + console.warn(`[wsl-move] Could not finish the desktop rewrite: ${describe(error)}`) + } + return { recorded: true } +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-ledger.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-ledger.test.ts new file mode 100644 index 00000000000..e09a4d7645c --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-ledger.test.ts @@ -0,0 +1,96 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { listWslMoveCutovers, writeWslMoveCutover } from './wsl-move-cutover-journal' +import { + registerWslMoveDesktopRewriteStore, + resetWslMoveDesktopRewriteStoresForTests, + setWslMoveDesktopRewriteRegistryCompleteForTests +} from './wsl-move-desktop-rewrite-stores' + +let userDataPath: string +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-ledger-')) +}) +afterEach(() => { + resetWslMoveDesktopRewriteStoresForTests() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +const committed = (rewrittenStores: string[]) => + cutover('move-1', 'destination-committed', { rewrittenStores }) + +describe('WSL move rewrite ledger', () => { + it('grows append-only while the desktop rewrites', () => { + seedWslMoveCutover(userDataPath, committed([])) + writeWslMoveCutover(userDataPath, committed(['a'])) + writeWslMoveCutover(userDataPath, committed(['a', 'b'])) + // Rewriting the same ledger is a no-op, not a change. + writeWslMoveCutover(userDataPath, committed(['a', 'b'])) + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual(['a', 'b']) + }) + + it.each([ + ['removes an entry', ['a']], + ['reorders entries', ['b', 'a']], + ['replaces an entry', ['a', 'c']], + ['duplicates an entry', ['a', 'b', 'a']] + ])('refuses a write that %s', (_label, rewrittenStores) => { + seedWslMoveCutover(userDataPath, committed(['a', 'b'])) + expect(() => writeWslMoveCutover(userDataPath, committed(rewrittenStores))).toThrow( + 'wsl_move_ledger_changed' + ) + expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual(['a', 'b']) + }) + + it('refuses ledger entries outside the rewrite phase', () => { + expect(() => + writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared', { rewrittenStores: ['a'] })) + ).toThrow('wsl_move_ledger_changed') + seedWslMoveCutover(userDataPath, cutover('move-1', 'blobs-transferred')) + expect(() => writeWslMoveCutover(userDataPath, committed(['a']))).toThrow( + 'wsl_move_ledger_changed' + ) + }) + + it('advances to client-rewritten only once every registered store is recorded', () => { + setWslMoveDesktopRewriteRegistryCompleteForTests(true) + registerWslMoveDesktopRewriteStore({ id: 'a', side: 'main', run: () => {}, flush: () => {} }) + registerWslMoveDesktopRewriteStore({ id: 'b', side: 'renderer' }) + seedWslMoveCutover(userDataPath, committed(['a'])) + expect(() => + writeWslMoveCutover( + userDataPath, + cutover('move-1', 'client-rewritten', { rewrittenStores: ['a'] }) + ) + ).toThrow('wsl_move_rewrite_incomplete') + writeWslMoveCutover(userDataPath, committed(['a', 'b'])) + writeWslMoveCutover( + userDataPath, + cutover('move-1', 'client-rewritten', { rewrittenStores: ['a', 'b'] }) + ) + expect(listWslMoveCutovers(userDataPath)[0]?.phase).toBe('client-rewritten') + }) + + it('refuses client-rewritten while the store registry is incomplete', () => { + seedWslMoveCutover(userDataPath, committed([])) + expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'client-rewritten'))).toThrow( + 'wsl_move_rewrite_incomplete' + ) + setWslMoveDesktopRewriteRegistryCompleteForTests(true) + writeWslMoveCutover(userDataPath, cutover('move-1', 'client-rewritten')) + expect(listWslMoveCutovers(userDataPath)[0]?.phase).toBe('client-rewritten') + }) + + it('re-seeds a committed record to a later phase without dropping its ledger', () => { + seedWslMoveCutover(userDataPath, committed(['a', 'b'])) + seedWslMoveCutover(userDataPath, cutover('move-1', 'retained')) + expect(listWslMoveCutovers(userDataPath)[0]).toMatchObject({ + phase: 'retained', + rewrittenStores: ['a', 'b'] + }) + }) +}) diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 10391497512..d2fdcae302c 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -11,6 +11,7 @@ import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' import { getCanonicalUserDataPath } from '../persistence' import { getAppEnvironment } from '../../shared/app-environment' import { loadWslMoveFence } from '../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { runGatedWslMoveDesktopRewritePass } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite' import { 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' @@ -148,6 +149,8 @@ export async function initializeReadyFoundation(): Promise { state.store = store // Why before any handler or runtime: a project a WSL move is taking must never get a new terminal. loadWslMoveFence(getAppEnvironment().getPath('userData')) + // Finishes a move that committed before a crash; gated off it does nothing, and it never throws. + void runGatedWslMoveDesktopRewritePass({ userDataPath: getAppEnvironment().getPath('userData') }) // 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 // proxy env vars, so the persisted proxy must land before any app-owned network fetcher runs — diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index fc54b0a8b60..31cfafbdde6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -53,6 +53,7 @@ import type { PreflightApi } from './api/preflight-api' import type { PtyApi } from './api/pty-api' import type { ProjectGroupsApi, ProjectsApi, RepositoryApi } from './api/repository-api' import type { RuntimeApi } from './api/runtime-api' +import type { WslMoveApi } from './api/wsl-move-bridge' import type { KeybindingsApi, SettingsApi } from './api/settings-api' import type { ShellApi } from './api/shell-api' import type { SpeechApi } from './api/speech-api' @@ -156,6 +157,7 @@ export type PreloadApi = { legacyRelayBridge?: LegacyRelayBridgeApi automations: AutomationsApi wsl: RuntimeApi['wsl'] + wslMove: WslMoveApi pwsh: RuntimeApi['pwsh'] gitBash: RuntimeApi['gitBash'] plugins: PluginsApi diff --git a/src/preload/api/wsl-move-bridge.ts b/src/preload/api/wsl-move-bridge.ts new file mode 100644 index 00000000000..ff1009ec18e --- /dev/null +++ b/src/preload/api/wsl-move-bridge.ts @@ -0,0 +1,19 @@ +import { ipcRenderer } from 'electron' +import type { + WslMovePendingRendererRewrite, + WslMoveRecordStoreRewrittenRequest, + WslMoveRecordStoreRewrittenResult +} from '../../shared/wsl-move/wsl-move-renderer-rewrite-types' + +/** The renderer half of a committed WSL move's desktop rewrite; main gates both calls. */ +export type WslMoveApi = { + listPendingRendererRewrites: () => Promise + recordStoreRewritten: ( + request: WslMoveRecordStoreRewrittenRequest + ) => Promise +} + +export const wslMoveApi: WslMoveApi = { + listPendingRendererRewrites: () => ipcRenderer.invoke('wslMove:listPendingRendererRewrites'), + recordStoreRewritten: (request) => ipcRenderer.invoke('wslMove:recordStoreRewritten', request) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 402c9e00eb1..c395e6ab123 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,7 @@ import { appApi } from './api/app-bridge' import { orcaProfilesApi } from './api/orca-profiles-bridge' import { platformApi } from './api/platform-bridge' import { wslApi } from './api/wsl-bridge' +import { wslMoveApi } from './api/wsl-move-bridge' import { pwshApi } from './api/pwsh-bridge' import { gitBashApi } from './api/git-bash-bridge' import { pluginsApi } from './api/plugins-bridge' @@ -101,6 +102,7 @@ const api = { orcaProfiles: orcaProfilesApi, platform: platformApi, wsl: wslApi, + wslMove: wslMoveApi, pwsh: pwshApi, gitBash: gitBashApi, plugins: pluginsApi, diff --git a/src/renderer/src/lib/wsl-move-renderer-rewrite-client.ts b/src/renderer/src/lib/wsl-move-renderer-rewrite-client.ts new file mode 100644 index 00000000000..24fd85ba0c4 --- /dev/null +++ b/src/renderer/src/lib/wsl-move-renderer-rewrite-client.ts @@ -0,0 +1,20 @@ +/** + * The renderer's side of a committed WSL move's desktop rewrite: which renderer stores a move still + * needs, and recording one once it is rewritten and persisted. Main refuses both with the gate off. + */ +import type { + WslMovePendingRendererRewrite, + WslMoveRecordStoreRewrittenResult +} from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types' + +export function listPendingWslMoveRendererRewrites(): Promise { + return window.api.wslMove.listPendingRendererRewrites() +} + +/** Only after the store's rewrite is durable: the ledger entry means it never runs again. */ +export function recordWslMoveRendererStoreRewritten( + migrationId: string, + storeId: string +): Promise { + return window.api.wslMove.recordStoreRewritten({ migrationId, storeId }) +} diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts index cedd68e21bb..a33dd0413dd 100644 --- a/src/renderer/src/web/web-preload-api-composition.test.ts +++ b/src/renderer/src/web/web-preload-api-composition.test.ts @@ -72,6 +72,7 @@ describe('web preload API composition', () => { 'pty', 'ssh', 'wsl', + 'wslMove', 'pwsh', 'gitBash', 'agentStatus', diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index aca82b57501..fc7c055d672 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -134,6 +134,11 @@ function createWebPreloadApi(): Partial { isAvailable: () => callRuntimeResult('host.wsl.isAvailable').catch(() => false), listDistros: () => callRuntimeResult('host.wsl.listDistros').catch(() => []) }, + // Desktop-only: a web client never owns a WSL move's desktop stores. + wslMove: { + listPendingRendererRewrites: () => Promise.resolve([]), + recordStoreRewritten: () => Promise.resolve({ recorded: false, reason: 'gated-off' }) + }, pwsh: { isAvailable: () => callRuntimeResult('host.pwsh.isAvailable').catch(() => false) }, diff --git a/src/shared/wsl-move/wsl-move-dispositions.test.ts b/src/shared/wsl-move/wsl-move-dispositions.test.ts index 9884522562d..3be7b52f8d8 100644 --- a/src/shared/wsl-move/wsl-move-dispositions.test.ts +++ b/src/shared/wsl-move/wsl-move-dispositions.test.ts @@ -79,3 +79,27 @@ describe('WSL mover dispositions', () => { expect([...new Set(pathLike)].sort()).toEqual(Object.keys(SETTINGS_KEYS_NAMING_A_PATH).sort()) }) }) + +// Containers whose fields are decided one by one; any other item gets one verdict. +const PER_FIELD_ITEMS = new Set([19, 32, 38]) + +describe('WSL mover disposition consistency', () => { + it('never both moves and leaves an inventory item', () => { + const byItem = new Map>() + for (const row of ALL_ROWS) { + byItem.set(row.item, (byItem.get(row.item) ?? new Set()).add(row.disposition)) + } + const conflicting = [...byItem] + .filter(([item, dispositions]) => { + return ( + !PER_FIELD_ITEMS.has(item) && + dispositions.has('move') && + dispositions.has('not-applicable') + ) + }) + .map(([item]) => item) + expect(conflicting).toEqual([]) + // Decision C: the native chat journal and its tables stay local. + expect(byItem.get(52)).toEqual(new Set(['not-applicable'])) + }) +}) diff --git a/src/shared/wsl-move/wsl-move-dispositions.ts b/src/shared/wsl-move/wsl-move-dispositions.ts index 9aebf62df4c..09a9ef64533 100644 --- a/src/shared/wsl-move/wsl-move-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-dispositions.ts @@ -73,10 +73,11 @@ export const PERSISTED_STATE_DISPOSITIONS = { ui: { disposition: 'per-field', item: 38 }, githubCache: notApplicableDisposition(21, 'Cache keyed by repoId, which the move keeps.'), workspaceSession: { disposition: 'per-field', item: 22 }, - workspaceSessionsByHostId: notApplicableDisposition( - 22, - "WSL projects live in 'local', i.e. workspaceSession." - ), + workspaceSessionsByHostId: { + disposition: 'rewrite-desktop', + item: 22, + note: "The desktop seeds the WSL host's session from the moved 'local' rows (D1b-7c)." + }, remoteLayoutSnapshots: { disposition: 'rewrite-desktop', item: 47 }, sshTargets: notApplicableDisposition(62), sshTargetGenerationCounter: notApplicableDisposition(62), diff --git a/src/shared/wsl-move/wsl-move-out-of-store-dispositions.ts b/src/shared/wsl-move/wsl-move-out-of-store-dispositions.ts index be41e07d4fa..6f307a567ce 100644 --- a/src/shared/wsl-move/wsl-move-out-of-store-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-out-of-store-dispositions.ts @@ -27,9 +27,9 @@ export const OUT_OF_STORE_DISPOSITIONS: readonly (WslMoveFieldDisposition & { { store: 'agent-hooks/last-status.json', disposition: 'rederive', item: 51 }, { store: 'agent-session-journal.db', - disposition: 'move', + disposition: 'not-applicable', item: 52, - newManifestSection: true + note: 'Chats stay local on the desktop (owner decision C); the journal is not moved.' }, { store: 'orchestration.db', disposition: 'rewrite-desktop', item: 53 }, { diff --git a/src/shared/wsl-move/wsl-move-renderer-rewrite-types.ts b/src/shared/wsl-move/wsl-move-renderer-rewrite-types.ts new file mode 100644 index 00000000000..7ae75621d82 --- /dev/null +++ b/src/shared/wsl-move/wsl-move-renderer-rewrite-types.ts @@ -0,0 +1,24 @@ +/** The renderer's half of a committed WSL move's desktop rewrite, as carried over IPC. */ + +export type WslMovePendingRendererRewrite = { + migrationId: string + /** Renderer-side stores still to rewrite, in order. */ + storeIds: string[] + destinationEnvironmentId: string + destinationHostId: `runtime:${string}` + distro: string + scopeRepoIds: string[] + scopeFolderWorkspaceIds: string[] +} + +export type WslMoveRecordStoreRewrittenRequest = { + migrationId: string + storeId: string +} + +export type WslMoveRecordStoreRewrittenResult = + | { recorded: true } + | { + recorded: false + reason: 'gated-off' | 'unknown-store' | 'not-renderer-store' | 'not-pending' | 'failed' + } diff --git a/src/shared/wsl-move/wsl-move-storage-dispositions.ts b/src/shared/wsl-move/wsl-move-storage-dispositions.ts index 72d84d1d8c4..a28c79001fe 100644 --- a/src/shared/wsl-move/wsl-move-storage-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-storage-dispositions.ts @@ -17,11 +17,10 @@ export const LOCAL_STORAGE_MODULE_DISPOSITIONS: Record 'journal_set_aside', 'queued_messages' ], - moveDisposition(52, { - newManifestSection: true, - note: 'Native chat journal: scoped records move to the host, location rewritten.' - }) + notApplicableDisposition( + 52, + 'Native chat journal: chats stay local on the desktop (owner decision C), never moved.' + ) ), ...tables( 'orchestration',