From 1f91fa2a41acf89127f66141f4f0d296d76e9c58 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 23 Oct 2026 04:19:12 -0700 Subject: [PATCH] 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 --- .../wsl-move-driver-journal.ts | 67 +++++++++++++++++++ .../moving-wsl-projects/wsl-move-driver.ts | 51 ++------------ .../wsl-move-guest-foreground.test.ts | 21 ++++-- .../wsl-move-manifest-export.test.ts | 16 +++-- .../wsl-move-slept-scrollback.test.ts | 11 ++- .../wsl-move-slept-scrollback.ts | 15 ++++- 6 files changed, 123 insertions(+), 58 deletions(-) create mode 100644 src/main/persistence/moving-wsl-projects/wsl-move-driver-journal.ts diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-journal.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-journal.ts new file mode 100644 index 00000000000..a22ee0d09a9 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-journal.ts @@ -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, + prepared: WslMovePrepared, + migrationId: string, + startedAt: Date, + slept: WslMoveSleptScrollback +): Promise { + 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}).`] + }) + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver.ts index 8093ffade9d..c7ca0fa7505 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-driver.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver.ts @@ -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) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.test.ts index 2f2b3a2440e..df622858a27 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.test.ts @@ -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: () => [], diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts index 4db314a5272..cb68ca9f9f3 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts @@ -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).lastSeenAt = new Date('2026-10-20T00:00:00.000Z') + ;(state.repos[0] as unknown as Record).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 = { ...state.workspaceSession.tabsByWorktree[WORKTREE]![0] } + const sleptTab: Record = { + ...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] diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.test.ts index 39bc8099f50..5eb28c025af 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.test.ts @@ -31,7 +31,9 @@ function spoolRoot(): string { return join(root, 'move-1') } -function sleptSession(extra: Partial = {}) { +function sleptSession( + extra: Partial = {} +) { // 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([]) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.ts b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.ts index 1e269bf64d3..0ffb3d7098c 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.ts @@ -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 {