mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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 = () =>
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user