feat(wsl-mover): the ledgered desktop rewrite stage that finishes a committed move (D1b-7b) (#29597)

* feat(wsl-mover): the ledgered desktop rewrite stage that finishes a committed move (D1b-7b)

* fix(wsl-mover): a move finishes its rewrite only once every store exists (D1b-7b)

* fix(wsl-mover): chats never move per decision C, the journal enforces registry completeness, stores time out (D1b-7b)
This commit is contained in:
Neil
2026-10-16 04:03:09 -07:00
committed by GitHub
parent 98064b2608
commit 8eaaab88bd
23 changed files with 1013 additions and 18 deletions
@@ -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
}))
@@ -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 = () =>
+79
View File
@@ -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<string, Handler>())
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([])
})
})
+47
View File
@@ -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
})
}
)
}
@@ -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)
}
}
@@ -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'
@@ -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.
@@ -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<WslMoveCutover>
/** 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> | void
/** Makes what `run` wrote durable; its ledger entry is written only after this resolves. */
flush: () => Promise<void> | 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
}
@@ -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<string> }
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<void>(() => {}) : 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([])
})
})
@@ -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<WslMoveCutover>): 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> | void,
timeoutMs: number,
label: string
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_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<void> {
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<void> {
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<void> = Promise.resolve()
/** Never throws; passes are serialized so two cannot rewrite the same store at once. */
export function runWslMoveDesktopRewritePass(
options: WslMoveDesktopRewritePassOptions
): Promise<void> {
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<void> {
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)
}
@@ -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']
})
})
})
@@ -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<void> {
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 —
+2
View File
@@ -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
+19
View File
@@ -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<WslMovePendingRendererRewrite[]>
recordStoreRewritten: (
request: WslMoveRecordStoreRewrittenRequest
) => Promise<WslMoveRecordStoreRewrittenResult>
}
export const wslMoveApi: WslMoveApi = {
listPendingRendererRewrites: () => ipcRenderer.invoke('wslMove:listPendingRendererRewrites'),
recordStoreRewritten: (request) => ipcRenderer.invoke('wslMove:recordStoreRewritten', request)
}
+2
View File
@@ -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,
@@ -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<WslMovePendingRendererRewrite[]> {
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<WslMoveRecordStoreRewrittenResult> {
return window.api.wslMove.recordStoreRewritten({ migrationId, storeId })
}
@@ -72,6 +72,7 @@ describe('web preload API composition', () => {
'pty',
'ssh',
'wsl',
'wslMove',
'pwsh',
'gitBash',
'agentStatus',
+5
View File
@@ -134,6 +134,11 @@ function createWebPreloadApi(): Partial<PreloadApi> {
isAvailable: () => callRuntimeResult<boolean>('host.wsl.isAvailable').catch(() => false),
listDistros: () => callRuntimeResult<string[]>('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<boolean>('host.pwsh.isAvailable').catch(() => false)
},
@@ -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<number, Set<string>>()
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']))
})
})
+5 -4
View File
@@ -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),
@@ -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 },
{
@@ -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'
}
@@ -17,11 +17,10 @@ export const LOCAL_STORAGE_MODULE_DISPOSITIONS: Record<string, WslMoveFieldDispo
disposition: 'rewrite-desktop',
item: 43
},
'src/renderer/src/lib/structured-agent-session-launch-persistence.ts': {
disposition: 'rewrite-desktop',
item: 44,
note: 'The payload fingerprint hashes the worktree, so a rewritten record re-derives it.'
},
'src/renderer/src/lib/structured-agent-session-launch-persistence.ts': notApplicableDisposition(
44,
'Chats stay local on the desktop (owner decision C), so their launch records stay as written.'
),
'src/renderer/src/components/editor/file-view-preference-storage.ts': {
disposition: 'rewrite-desktop',
item: 72,
@@ -151,10 +150,10 @@ export const SQLITE_TABLE_DISPOSITIONS: Record<string, WslMoveFieldDisposition>
'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',