chore(wsl-mover): split the journal step out of the driver, and format

The review fixes took wsl-move-driver.ts past the line limit; the export-and-journal step moves to
its own module unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-23 04:19:12 -07:00
co-authored by Claude
parent 19f14237ff
commit 1f91fa2a41
6 changed files with 123 additions and 58 deletions
@@ -0,0 +1,67 @@
/**
* The driver's first durable step: export the slept scope and journal it as `prepared`. Nothing has
* reached the host yet, so every failure here is a refusal with its reason, never a put-back.
*/
import type { WslMoveRefusal } from '../../../shared/wsl-move/wsl-move-driver-types'
import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover'
import { journalWslMove } from './wsl-move-delta-plan'
import type { WslMoveDriverDeps } from './wsl-move-driver-ports'
import {
exportRequestOf,
isWslMoveScopeChanged,
manifestTooLargeRefusal,
refusal,
type WslMovePrepared
} from './wsl-move-driver-plan'
import { resetWslMoveToZero } from './wsl-move-reset'
import { sleptScrollbackReader, type WslMoveSleptScrollback } from './wsl-move-slept-scrollback'
export async function journalSleptWslMove(
deps: Pick<WslMoveDriverDeps, 'exportManifest' | 'userDataPath'>,
prepared: WslMovePrepared,
migrationId: string,
startedAt: Date,
slept: WslMoveSleptScrollback
): Promise<WslMoveCutover | WslMoveRefusal> {
if (slept.problems.length > 0) {
return refusal('wsl_move_export_incomplete', { reasons: [...slept.problems] })
}
try {
// The slept state, which the session freeze then holds for the rest of the move.
const exported = deps.exportManifest(
exportRequestOf(prepared, migrationId, startedAt, sleptScrollbackReader(slept))
)
if (exported.blockers.length > 0) {
return refusal('wsl_move_export_incomplete', { reasons: [...exported.blockers] })
}
return journalWslMove({
userDataPath: deps.userDataPath,
plan: prepared.plan,
manifest: exported.manifest,
destinationName: prepared.host.name,
automationsToReenable: exported.automationsToReenable,
now: () => startedAt
})
} catch (error) {
// A write that landed before it threw would leave a record no host ever saw: drop it.
await resetWslMoveToZero(
{ userDataPath: deps.userDataPath, destination: null },
migrationId
).catch(() => {})
const tooLarge = manifestTooLargeRefusal(error)
if (tooLarge) {
return tooLarge
}
const message = error instanceof Error ? error.message : String(error)
if (message === 'wsl_move_already_in_flight') {
return refusal('wsl_move_already_in_flight')
}
if (isWslMoveScopeChanged(error)) {
return refusal('wsl_move_scope_changed')
}
console.warn('[wsl-move] Could not prepare the move:', error)
return refusal('wsl_move_export_incomplete', {
reasons: [`Orca could not prepare these projects' saved state (${message}).`]
})
}
}
@@ -21,7 +21,7 @@ import {
removeWslMoveCutover,
writeWslMoveCutover
} from './wsl-move-cutover-journal'
import { journalWslMove } from './wsl-move-delta-plan'
import { journalSleptWslMove } from './wsl-move-driver-journal'
import type { WslMoveDriverDeps, WslMoveHost } from './wsl-move-driver-ports'
import {
readSleptScrollback,
@@ -32,7 +32,6 @@ import {
import {
exportRequestOf,
isWslMoveScopeChanged,
manifestTooLargeRefusal,
prepareWslMove,
refusal,
type WslMovePrepared
@@ -128,47 +127,9 @@ async function runMove(
const { panes } = paused
const slept = await readSleptScrollback(deps, panes, distro, migrationId)
try {
if (slept.problems.length > 0) {
return refusal('wsl_move_export_incomplete', { reasons: [...slept.problems] })
}
let journaled: WslMoveCutover
try {
// The slept state, which the session freeze then holds for the rest of the move.
const exported = deps.exportManifest(
exportRequestOf(prepared, migrationId, startedAt, sleptScrollbackReader(slept))
)
if (exported.blockers.length > 0) {
return refusal('wsl_move_export_incomplete', { reasons: [...exported.blockers] })
}
journaled = journalWslMove({
userDataPath: deps.userDataPath,
plan: prepared.plan,
manifest: exported.manifest,
destinationName: host.name,
automationsToReenable: exported.automationsToReenable,
now: () => startedAt
})
} catch (error) {
// A write that landed before it threw would leave a record no host ever saw: drop it.
await resetWslMoveToZero(
{ userDataPath: deps.userDataPath, destination: null },
migrationId
).catch(() => {})
const tooLarge = manifestTooLargeRefusal(error)
if (tooLarge) {
return tooLarge
}
if (describe(error) === 'wsl_move_already_in_flight') {
return refusal('wsl_move_already_in_flight')
}
if (isWslMoveScopeChanged(error)) {
return refusal('wsl_move_scope_changed')
}
// Nothing is journaled yet, so nothing needs putting back; say why instead of a bare reset.
console.warn('[wsl-move] Could not prepare the move:', error)
return refusal('wsl_move_export_incomplete', {
reasons: [`Orca could not prepare these projects' saved state (${describe(error)}).`]
})
const journaled = await journalSleptWslMove(deps, prepared, migrationId, startedAt, slept)
if ('outcome' in journaled) {
return journaled
}
const quiesced = await quiesceWslMove(
journaled,
@@ -192,7 +153,9 @@ async function runMove(
}
return quiesced.reason === 'terminals-blocked'
? refusal('wsl_move_terminals_blocked', { blockers: quiesced.blockers ?? [] })
: refusal('wsl_move_pause_failed', { detail: pauseDetail(quiesced.reason, quiesced.detail) })
: refusal('wsl_move_pause_failed', {
detail: pauseDetail(quiesced.reason, quiesced.detail)
})
}
try {
return await moveQuiesced(deps, host, quiesced.cutover, prepared, running, slept)
@@ -16,7 +16,14 @@ const scope = { workspaceIds: [WT], workspaceKeys: new Set([WT]) }
const RELAY = 10
// The WSL relay (`init`) starts the pane's login shell as a session leader.
const relay: WslGuestProcessRow = { pid: RELAY, ppid: 1, sid: 1, argc: 1, comm: 'init', paneKey: null }
const relay: WslGuestProcessRow = {
pid: RELAY,
ppid: 1,
sid: 1,
argc: 1,
comm: 'init',
paneKey: null
}
const shellA: WslGuestProcessRow = {
pid: 100,
ppid: RELAY,
@@ -49,9 +56,7 @@ describe('classifyWslGuestPaneForeground', () => {
['a script waiting at `read`', [child(150, 'bash', { argc: 2 })]],
['`sh -c` running a command', [child(150, 'sh', { argc: 3 })]]
])('blocks %s', (_label, extra) => {
expect(classifyWslGuestPaneForeground([relay, shellA, ...extra], KEY_A)).toBe(
'process-running'
)
expect(classifyWslGuestPaneForeground([relay, shellA, ...extra], KEY_A)).toBe('process-running')
})
it("ignores another pane's work and the relay above the shell", () => {
@@ -97,7 +102,13 @@ describe('collectWslMoveGateInputs with a guest foreground reader', () => {
})
const base: WslMoveGateFactSources = {
listTerminals: () => [
{ ptyId: 'pty-1', workspaceId: WT, paneKey: KEY_A, wslDistro: 'Ubuntu', foregroundAgent: null }
{
ptyId: 'pty-1',
workspaceId: WT,
paneKey: KEY_A,
wslDistro: 'Ubuntu',
foregroundAgent: null
}
],
inspect: windowsSaysLive,
agentRows: () => [],
@@ -265,7 +265,7 @@ describe('exportWslMoveManifest', () => {
expect(exportWslMoveManifest(storeFor(state), REQUEST).blockers).toEqual([])
})
it("is not refused by a project an earlier move retained, whose agent stays here", () => {
it('is not refused by a project an earlier move retained, whose agent stays here', () => {
const state = sourceState()
const retainedWorktree = 'old::\\\\wsl.localhost\\Ubuntu\\home\\me\\old'
state.repos.push(repo('old', '\\\\wsl.localhost\\Ubuntu\\home\\me\\old'))
@@ -292,7 +292,9 @@ describe('exportWslMoveManifest', () => {
// a project reset with a digest mismatch until a restart (D1b-10 run on awin).
const state = sourceState()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the in-memory shape the load normalizes.
;(state.repos[0] as unknown as Record<string, unknown>).lastSeenAt = new Date('2026-10-20T00:00:00.000Z')
;(state.repos[0] as unknown as Record<string, unknown>).lastSeenAt = new Date(
'2026-10-20T00:00:00.000Z'
)
const { manifest } = exportWslMoveManifest(storeFor(state), REQUEST)
expect(() =>
assertOrcadMigrationManifestDigest(JSON.parse(JSON.stringify(manifest)))
@@ -314,7 +316,11 @@ describe('exportWslMoveManifest', () => {
})
describe('only the selected projects can refuse it', () => {
const agentIn = (worktreeId: string, tabId: string, leafId: string): SleepingAgentSessionRecord => ({
const agentIn = (
worktreeId: string,
tabId: string,
leafId: string
): SleepingAgentSessionRecord => ({
paneKey: `${tabId}:${leafId}`,
tabId,
worktreeId,
@@ -395,7 +401,9 @@ describe('exportWslMoveManifest', () => {
// The awin run: a move right after sleeping terminals reset with a digest mismatch, because the
// signature covered session fields the manifest parser does not keep.
const state = sourceState()
const sleptTab: Record<string, unknown> = { ...state.workspaceSession.tabsByWorktree[WORKTREE]![0] }
const sleptTab: Record<string, unknown> = {
...state.workspaceSession.tabsByWorktree[WORKTREE]![0]
}
sleptTab.fieldNoParserKeeps = 1
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an extra field on a real tab.
state.workspaceSession.tabsByWorktree[WORKTREE] = [sleptTab as unknown as TerminalTab]
@@ -31,7 +31,9 @@ function spoolRoot(): string {
return join(root, 'move-1')
}
function sleptSession(extra: Partial<WorkspaceSessionState['terminalLayoutsByTabId'][string]> = {}) {
function sleptSession(
extra: Partial<WorkspaceSessionState['terminalLayoutsByTabId'][string]> = {}
) {
// What Sleep leaves: the layout names the pane, with at most an older renderer buffer.
return {
...getDefaultWorkspaceSession(),
@@ -69,8 +71,11 @@ describe('a slept pane on the snapshot channel', () => {
const history = Buffer.from('$ make\r\nok\r\n$ ', 'utf8')
// Before the fix the moved terminal opened empty: nothing was carried.
expect(projectOrcadMigrationSessionScrollback(sleptSession()).snapshots).toEqual([])
const projected = projectOrcadMigrationSessionScrollback(sleptSession(), undefined, false, () =>
history
const projected = projectOrcadMigrationSessionScrollback(
sleptSession(),
undefined,
false,
() => history
)
const ref = makeTerminalScrollbackSnapshotRef(TAB, LEAF)
expect(projected.blockedItems).toEqual([])
@@ -4,7 +4,15 @@
* own ref. Each buffer is spooled to a file, so only hashes and sizes stay in memory.
*/
import { createHash } from 'node:crypto'
import { closeSync, mkdirSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from 'node:fs'
import {
closeSync,
mkdirSync,
openSync,
readFileSync,
readSync,
rmSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES } from '../../../shared/orcad-migration/orcad-migration-scrollback'
import { parsePaneKey } from '../../../shared/stable-pane-id'
@@ -154,7 +162,10 @@ export function withSleptSnapshots(
if (!descriptor || !entry || entry.sha256 !== descriptor.sha256) {
return source.readOrcadMigrationSourceSnapshotChunk(manifest, ref, offset)
}
const length = Math.max(0, Math.min(ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES, entry.byteLength - offset))
const length = Math.max(
0,
Math.min(ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES, entry.byteLength - offset)
)
const chunk = Buffer.alloc(length)
const fd = openSync(entry.path, 'r')
try {