From 80f038ed80ea8ea70ad88f942270e84919e092df Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:39:34 -0700 Subject: [PATCH] feat(wsl-mover): a journal and fence for local WSL moves, on one shared journal store (D1b-5a) (#28536) * refactor(migration): one durable journal-directory store for every source kind * feat(wsl-mover): a WSL move journal in its own directory, one move in flight per distro (D1b-5a) * feat(wsl-mover): the WSL move journal fences and hides the rows it names * fix(wsl-mover): journal records only move forward; a new journal directory is durable * test(migration): pin the SSH journal's path and bytes across the shared store * fix(wsl-mover): journal records are detached, unique per id, rooted, kept while in flight, and digest-checked --- .../wsl-mover/wsl-move-cutover-fixture.ts | 72 ++++++++ .../migration-journal-directory.ts | 155 ++++++++++++++++++ .../wsl-move-cutover-journal.test.ts | 148 +++++++++++++++++ .../wsl-move-cutover-journal.ts | 132 +++++++++++++++ .../wsl-move-fence.test.ts | 34 ++++ .../moving-wsl-projects/wsl-move-fence.ts | 53 ++++++ .../orcad-migration-cutover-journal.test.ts | 21 ++- .../ssh/orcad-migration-cutover-journal.ts | 138 +++------------- src/shared/wsl-move/wsl-move-cutover.ts | 76 +++++++++ 9 files changed, 711 insertions(+), 118 deletions(-) create mode 100644 src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/migration-journal-directory.ts create mode 100644 src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts create mode 100644 src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.ts create mode 100644 src/main/persistence/moving-wsl-projects/wsl-move-fence.test.ts create mode 100644 src/main/persistence/moving-wsl-projects/wsl-move-fence.ts create mode 100644 src/shared/wsl-move/wsl-move-cutover.ts diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture.ts new file mode 100644 index 00000000000..e6a2385de79 --- /dev/null +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture.ts @@ -0,0 +1,72 @@ +/** A valid WSL move journal record whose manifest is bound to it. */ +import { ORCAD_MIGRATION_MANIFEST_VERSION } from '../../../../shared/orcad-migration-manifest' +import { + wslMoveJournalKey, + type WslMoveCutover, + type WslMoveCutoverPhase +} from '../../../../shared/wsl-move/wsl-move-cutover' +import { computeOrcadMigrationManifestSha256 } from '../../../orcad/orcad-migration-manifest-digest' + +export function wslMoveCutoverFixture( + migrationId = 'move-1', + phase: WslMoveCutoverPhase = 'prepared', + extra: Partial = {}, + scope: { repoIds?: string[]; folderWorkspaceIds?: string[] } = {} +): WslMoveCutover { + const repoIds = scope.repoIds ?? ['repo-1'] + const folderWorkspaceIds = scope.folderWorkspaceIds ?? [] + const unsigned = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId, + createdAt: '2026-10-13T00:00:00.000Z', + source: { + kind: 'local-wsl' as const, + distro: 'Ubuntu', + scopeRepoIds: repoIds, + scopeFolderWorkspaceIds: folderWorkspaceIds, + generation: 1 + }, + payload: { + repositories: repoIds.map((id) => ({ + id, + path: `/home/dev/${id}`, + displayName: id, + badgeColor: '#000', + addedAt: 0 + })), + projectGroups: [], + folderWorkspaces: folderWorkspaceIds.map((id) => ({ + id, + projectGroupId: 'group', + name: id, + folderPath: `/srv/${id}`, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + })) + }, + destinationEnvironmentId: 'wsl:Ubuntu' + } + const manifest = { ...unsigned, manifestSha256: computeOrcadMigrationManifestSha256(unsigned) } + return { + version: 1, + migrationId, + phase, + startedAt: '2026-10-13T00:00:00.000Z', + updatedAt: '2026-10-13T00:00:00.000Z', + destinationEnvironmentId: 'wsl:Ubuntu', + destinationName: 'Ubuntu', + journalKey: wslMoveJournalKey('Ubuntu'), + manifestSha256: manifest.manifestSha256, + provenExitedSessionIds: [], + rewrittenStores: [], + manifest, + ...extra + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/migration-journal-directory.ts b/src/main/persistence/migrating-orcad-catalog/migration-journal-directory.ts new file mode 100644 index 00000000000..3f997ec197e --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/migration-journal-directory.ts @@ -0,0 +1,155 @@ +/** + * One durable, owner-only JSON file per migration in a directory beside the profile. Each source + * kind keeps its own directory, so a build that knows only one kind never reads another's records. + * Reads fail closed: an entry that cannot be trusted throws instead of being skipped. + */ +import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { writeSecureJsonFileWithinLimit } from '../../../shared/bounded-secure-json-file' +import { errorMessage } from '../../../shared/error-message' +import { readNodeFileSyncWithinLimit } from '../../../shared/node-bounded-file-reader' +import { syncDirectoryDurablySync } from '../../durable-file-write' + +const JOURNAL_FILE = /^[A-Za-z0-9_-]{1,128}\.json$/ + +export type MigrationJournalDirectory = { + directory: (userDataPath: string) => string + /** Every record; throws (the configured unreadable error) rather than skipping a file. */ + list: (userDataPath: string) => T[] + /** Durable before it returns. */ + write: (userDataPath: string, record: T) => void + remove: (userDataPath: string, migrationId: string) => void +} + +export function createMigrationJournalDirectory(options: { + directoryName: string + maxFileBytes: number + parse: (value: unknown) => T + unreadable: (detail: string) => Error + onChange?: (userDataPath: string) => void +}): MigrationJournalDirectory { + // Why: hidden-row checks run on every list call, and each record may embed a full manifest. + const parsed = new Map() + const directory = (userDataPath: string): string => join(userDataPath, options.directoryName) + const fileVersion = (path: string): string => { + try { + // Durable writes replace the file, so size, mtime and inode change with every rewrite. + const stat = statSync(path, { bigint: true }) + return `${stat.ino}:${stat.size}:${stat.mtimeNs}` + } catch (error) { + throw options.unreadable(errorMessage(error)) + } + } + const readFile = (path: string, migrationId: string): T => { + try { + const raw = readNodeFileSyncWithinLimit(path, options.maxFileBytes).buffer.toString('utf8') + const record = options.parse(JSON.parse(raw)) + if (record.migrationId !== migrationId) { + throw new Error('file name does not match its migration id') + } + return record + } catch (error) { + throw options.unreadable(errorMessage(error)) + } + } + const changed = (userDataPath: string): void => { + try { + options.onChange?.(userDataPath) + } catch (error) { + // The write is already durable; a follower failing must not undo or fail it. + console.warn('[orcad-migration] Journal change follower failed:', error) + } + } + return { + directory, + list: (userDataPath) => { + const dir = directory(userDataPath) + if (!existsSync(dir)) { + return [] + } + let names: string[] + try { + names = readdirSync(dir) + } catch (error) { + throw options.unreadable(errorMessage(error)) + } + const files: { name: string; path: string }[] = [] + for (const name of names) { + if (!name.endsWith('.json')) { + continue // Durable-write temporaries and foreign files carry no journal state. + } + if (!JOURNAL_FILE.test(name)) { + throw options.unreadable(`unexpected entry ${name}`) + } + files.push({ name, path: join(dir, name) }) + } + const key = files.map((file) => `${file.name}:${fileVersion(file.path)}`).join('|') + const cached = parsed.get(dir) + if (cached?.key === key) { + return [...cached.records] + } + const records = files.map((file) => readFile(file.path, file.name.slice(0, -'.json'.length))) + parsed.set(dir, { key, records }) + return [...records] + }, + write: (userDataPath, record) => { + const valid = options.parse(record) + if (!JOURNAL_FILE.test(`${valid.migrationId}.json`)) { + throw new Error('orcad_migration_cutover_id_invalid') + } + const dir = directory(userDataPath) + const created = !existsSync(dir) + mkdirSync(dir, { recursive: true, mode: 0o700 }) + writeSecureJsonFileWithinLimit( + join(dir, `${valid.migrationId}.json`), + valid, + options.maxFileBytes, + { durable: true } + ) + syncDirectoryDurablySync(dir) + // A new directory's own entry lives in its parent; without this a crash can lose the journal. + if (created) { + syncDirectoryDurablySync(userDataPath) + } + parsed.delete(dir) + changed(userDataPath) + }, + remove: (userDataPath, migrationId) => { + if (!JOURNAL_FILE.test(`${migrationId}.json`)) { + throw new Error('orcad_migration_cutover_id_invalid') + } + const dir = directory(userDataPath) + rmSync(join(dir, `${migrationId}.json`), { force: true }) + parsed.delete(dir) + if (existsSync(dir)) { + syncDirectoryDurablySync(dir) + } + changed(userDataPath) + } + } +} + +/** + * Oldest first: the records of one source as a single chain of delta moves, each naming the + * migration it extends. Throws `unreadable` when they fork or leave a gap. + */ +export function orderMigrationJournalChain< + T extends { migrationId: string; supersedesMigrationId?: string } +>(matches: readonly T[], unreadable: (detail: string) => Error, label: string): T[] { + const superseded = new Set(matches.map((entry) => entry.supersedesMigrationId)) + const heads = matches.filter((entry) => !superseded.has(entry.migrationId)) + if (heads.length > 1) { + throw unreadable(`${heads.length} journals name ${label}`) + } + const byId = new Map(matches.map((entry) => [entry.migrationId, entry])) + const chain: T[] = [] + let entry: T | undefined = heads[0] + while (entry && chain.length <= matches.length) { + chain.unshift(entry) + entry = entry.supersedesMigrationId ? byId.get(entry.supersedesMigrationId) : undefined + } + if (chain.length !== matches.length) { + throw unreadable(`the journals naming ${label} do not form one chain`) + } + return chain +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts new file mode 100644 index 00000000000..028da35c276 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts @@ -0,0 +1,148 @@ +import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { wslMoveJournalKey } from '../../../shared/wsl-move/wsl-move-cutover' +import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { orcadMigrationCutoverJournalDirectory } from '../../ssh/orcad-migration-cutover-journal' +import { + listWslMoveCutoverChain, + listWslMoveCutovers, + removeWslMoveCutover, + writeWslMoveCutover, + wslMoveJournalDirectory +} from './wsl-move-cutover-journal' + +let userDataPath: string +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-journal-')) +}) +afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) + +const journalPath = (id: string) => join(wslMoveJournalDirectory(userDataPath), `${id}.json`) + +describe('WSL move journal', () => { + it('round-trips a move in an owner-only file of its own directory', () => { + writeWslMoveCutover(userDataPath, cutover()) + expect(listWslMoveCutovers(userDataPath)).toEqual([cutover()]) + expect(wslMoveJournalDirectory(userDataPath)).not.toBe( + orcadMigrationCutoverJournalDirectory(userDataPath) + ) + if (process.platform !== 'win32') { + expect(statSync(journalPath('move-1')).mode & 0o077).toBe(0) + } + removeWslMoveCutover(userDataPath, 'move-1') + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + }) + + it.each([ + ['corrupt JSON', '{not json'], + ['another distro key', JSON.stringify({ ...cutover(), journalKey: 'local-wsl:debian' })], + ['a tampered manifest', JSON.stringify({ ...cutover(), manifestSha256: 'f'.repeat(64) })], + ['an unknown phase', JSON.stringify({ ...cutover(), phase: 'teleported' })] + ])('fails closed on %s', (_label, contents) => { + mkdirSync(wslMoveJournalDirectory(userDataPath), { recursive: true }) + writeFileSync(journalPath('move-1'), contents) + expect(() => listWslMoveCutovers(userDataPath)).toThrow('stay fenced') + }) + + it('refuses a second move into a distro while the first is in flight', () => { + writeWslMoveCutover(userDataPath, cutover('move-1', 'quiesced')) + expect(() => + writeWslMoveCutover( + userDataPath, + cutover('move-2', 'prepared', { supersedesMigrationId: 'move-1' }) + ) + ).toThrow('wsl_move_already_in_flight') + }) + + it('chains a delta move after a finished one, and only onto the head', () => { + writeWslMoveCutover(userDataPath, cutover('move-1', 'retained')) + expect(() => writeWslMoveCutover(userDataPath, cutover('move-2'))).toThrow( + 'wsl_move_must_extend_its_chain' + ) + writeWslMoveCutover( + userDataPath, + cutover('move-2', 'prepared', { supersedesMigrationId: 'move-1' }) + ) + expect( + listWslMoveCutoverChain(userDataPath, wslMoveJournalKey('ubuntu')).map((c) => c.migrationId) + ).toEqual(['move-1', 'move-2']) + }) + + it('advances its own record through the phases', () => { + writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared')) + writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) + expect(listWslMoveCutovers(userDataPath)[0]?.phase).toBe('source-fenced') + }) + + it('never moves a record backwards, rebinds it, or advances a superseded one', () => { + writeWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared'))).toThrow( + 'wsl_move_phase_regressed' + ) + expect(() => + writeWslMoveCutover( + userDataPath, + cutover('move-1', 'retained', {}, { repoIds: ['repo-1', 'repo-2'] }) + ) + ).toThrow('wsl_move_binding_changed') + writeWslMoveCutover(userDataPath, cutover('move-1', 'retained')) + writeWslMoveCutover( + userDataPath, + cutover('move-2', 'prepared', { supersedesMigrationId: 'move-1' }) + ) + // Rewriting the finished link unchanged is fine; nothing may restart it. + writeWslMoveCutover(userDataPath, cutover('move-1', 'retained')) + }) + + it('hands out records a caller cannot edit into the next comparison', () => { + writeWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + const [listed] = listWslMoveCutovers(userDataPath) + if (listed) { + listed.phase = 'prepared' + } + expect(listWslMoveCutovers(userDataPath)[0]?.phase).toBe('destination-committed') + expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared'))).toThrow( + 'wsl_move_phase_regressed' + ) + }) + + it('refuses one id for two distros, and a first move that names a predecessor', () => { + writeWslMoveCutover(userDataPath, cutover('move-1')) + const debian = cutover('move-1') + const other = { ...debian, journalKey: wslMoveJournalKey('Debian') } + other.manifest = { ...debian.manifest, source: { ...debian.manifest.source, distro: 'Debian' } } + expect(() => writeWslMoveCutover(userDataPath, other)).toThrow() + expect(() => + writeWslMoveCutover( + userDataPath, + cutover('move-9', 'prepared', { supersedesMigrationId: 'gone' }) + ) + ).toThrow() + }) + + it('keeps the fence: an in-flight record cannot be removed', () => { + writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) + expect(() => removeWslMoveCutover(userDataPath, 'move-1')).toThrow('wsl_move_in_flight') + expect(listWslMoveCutovers(userDataPath)).toHaveLength(1) + }) + + it('fails closed on a manifest edited without re-signing it', () => { + const record = cutover() + const edited = { + ...record, + manifest: { + ...record.manifest, + source: { ...record.manifest.source, scopeRepoIds: ['repo-2'] }, + payload: { + ...record.manifest.payload, + repositories: [{ ...record.manifest.payload.repositories[0], id: 'repo-2' }] + } + } + } + mkdirSync(wslMoveJournalDirectory(userDataPath), { recursive: true }) + writeFileSync(journalPath('move-1'), JSON.stringify(edited)) + expect(() => listWslMoveCutovers(userDataPath)).toThrow('stay fenced') + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.ts b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.ts new file mode 100644 index 00000000000..cfdcd1fd603 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.ts @@ -0,0 +1,132 @@ +/** + * The WSL move journal: `orcad-migration-cutovers-wsl/.json` beside the profile. + * A separate directory from the SSH journal, so an older build never reads a WSL record as SSH. + */ +import { + WSL_MOVE_CUTOVER_PHASES, + isWslMoveCutoverFinished, + parseWslMoveCutover, + type WslMoveCutover +} from '../../../shared/wsl-move/wsl-move-cutover' +import { isRecord } from '../../../shared/orcad-migration-manifest-fields' +import { assertOrcadMigrationManifestDigest } from '../../orcad/orcad-migration-manifest-digest' +import { + createMigrationJournalDirectory, + orderMigrationJournalChain +} from '../migrating-orcad-catalog/migration-journal-directory' + +export class WslMoveJournalUnreadableError extends Error { + constructor(detail: string) { + super(`The WSL move journal cannot be read, so the moving projects stay fenced: ${detail}`) + this.name = 'WslMoveJournalUnreadableError' + } +} + +const journals = createMigrationJournalDirectory({ + directoryName: 'orcad-migration-cutovers-wsl', + // A manifest carries catalog rows and dormant session state; blobs travel on their own channel. + maxFileBytes: 16 * 1024 * 1024, + parse: parseVerifiedWslMoveCutover, + unreadable: (detail) => new WslMoveJournalUnreadableError(detail) +}) + +export function wslMoveJournalDirectory(userDataPath: string): string { + return journals.directory(userDataPath) +} + +/** Detached copies: a caller editing one must not change what the next write compares against. */ +export function listWslMoveCutovers(userDataPath: string): WslMoveCutover[] { + return journals.list(userDataPath).map((cutover) => structuredClone(cutover)) +} + +/** The record's own fields, plus the manifest's digest recomputed: a scope edit cannot pass. */ +function parseVerifiedWslMoveCutover(value: unknown): WslMoveCutover { + const cutover = parseWslMoveCutover(value) + // Raw, not parsed: parsing drops fields a newer build signed. + assertOrcadMigrationManifestDigest(isRecord(value) ? value.manifest : undefined) + return cutover +} + +/** Oldest first; throws unless the distro's journals form one chain of delta moves. */ +export function listWslMoveCutoverChain( + userDataPath: string, + journalKey: string +): WslMoveCutover[] { + const matches = listWslMoveCutovers(userDataPath).filter( + (cutover) => cutover.journalKey === journalKey + ) + return orderMigrationJournalChain( + matches, + (detail) => new WslMoveJournalUnreadableError(detail), + journalKey + ) +} + +/** + * Durable before it returns. A new move into a distro must extend the head of its chain, and only + * once that head finished: one in-flight move per distro (design, section 4.3). + */ +export function writeWslMoveCutover(userDataPath: string, cutover: WslMoveCutover): void { + const parsed = parseVerifiedWslMoveCutover(cutover) + // Files are named by migration id alone: one id must never name moves of two distros. + if ( + listWslMoveCutovers(userDataPath).some( + (entry) => entry.migrationId === parsed.migrationId && entry.journalKey !== parsed.journalKey + ) + ) { + throw new Error('wsl_move_id_in_use') + } + const chain = listWslMoveCutoverChain(userDataPath, parsed.journalKey) + const existing = chain.find((entry) => entry.migrationId === parsed.migrationId) + const head = chain.at(-1) + if (existing) { + assertWslMoveUpdate(existing, parsed, head) + } else if (!head) { + if (parsed.supersedesMigrationId !== undefined) { + throw new Error('wsl_move_must_extend_its_chain') + } + } else { + if (!isWslMoveCutoverFinished(head)) { + throw new Error('wsl_move_already_in_flight') + } + if (parsed.supersedesMigrationId !== head.migrationId) { + throw new Error('wsl_move_must_extend_its_chain') + } + } + journals.write(userDataPath, parsed) +} + +/** A record only moves forward, keeps its binding, and only the head of its chain still moves. */ +function assertWslMoveUpdate( + existing: WslMoveCutover, + next: WslMoveCutover, + head: WslMoveCutover | undefined +): void { + if ( + next.manifestSha256 !== existing.manifestSha256 || + next.supersedesMigrationId !== existing.supersedesMigrationId + ) { + throw new Error('wsl_move_binding_changed') + } + const order = (phase: WslMoveCutover['phase']): number => WSL_MOVE_CUTOVER_PHASES.indexOf(phase) + if (order(next.phase) < order(existing.phase)) { + throw new Error('wsl_move_phase_regressed') + } + if (head && head.migrationId !== existing.migrationId && next.phase !== existing.phase) { + throw new Error('wsl_move_superseded') + } +} + +/** + * Only a record that fences nothing goes: one never fenced (`prepared`) or one finished. A move in + * between keeps its fence until it resumes, or until a cancel proves the destination holds nothing. + */ +export function removeWslMoveCutover(userDataPath: string, migrationId: string): void { + const record = listWslMoveCutovers(userDataPath).find( + (entry) => entry.migrationId === migrationId + ) + if (record && record.phase !== 'prepared' && !isWslMoveCutoverFinished(record)) { + throw new Error('wsl_move_in_flight') + } + journals.remove(userDataPath, migrationId) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-fence.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-fence.test.ts new file mode 100644 index 00000000000..12a8fe06eba --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-fence.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { wslMoveFenceFromCutovers } from './wsl-move-fence' + +const ids = (state: ReturnType) => ({ + fenced: [...state.fenced.repoIds, ...state.fenced.folderWorkspaceIds], + hidden: [...state.hidden.repoIds, ...state.hidden.folderWorkspaceIds] +}) + +describe('WSL move fence', () => { + it.each([ + ['prepared', { fenced: [], hidden: [] }], + ['source-fenced', { fenced: ['repo-1', 'notes'], hidden: [] }], + ['blobs-transferred', { fenced: ['repo-1', 'notes'], hidden: [] }], + ['destination-committed', { fenced: [], hidden: ['repo-1', 'notes'] }], + ['retained', { fenced: [], hidden: ['repo-1', 'notes'] }] + ] as const)('%s', (phase, expected) => { + const record = cutover( + 'move-1', + phase, + {}, + { repoIds: ['repo-1'], folderWorkspaceIds: ['notes'] } + ) + expect(ids(wslMoveFenceFromCutovers([record]))).toEqual(expected) + }) + + it('fences only what each move names', () => { + const state = wslMoveFenceFromCutovers([ + cutover('move-1', 'retained', {}, { repoIds: ['repo-1'] }), + cutover('move-2', 'quiesced', { supersedesMigrationId: 'move-1' }, { repoIds: ['repo-2'] }) + ]) + expect(ids(state)).toEqual({ fenced: ['repo-2'], hidden: ['repo-1'] }) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-fence.ts b/src/main/persistence/moving-wsl-projects/wsl-move-fence.ts new file mode 100644 index 00000000000..35fd541889a --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-fence.ts @@ -0,0 +1,53 @@ +/** + * What the WSL move journal fences on this desktop. The journal itself is the fence: it already + * lives outside the profile store, so an older build rewriting the profile cannot drop it, and the + * scope it names is the manifest's own (design, section 4.3). + * + * - Fenced (read-only): from `source-fenced` until the host owns the projects. Edits, new + * terminals and session writes for these rows are refused. + * - Hidden: once the destination committed. The source rows stay (retained) but are not listed. + */ +import type { WslMoveCutover, WslMoveCutoverPhase } from '../../../shared/wsl-move/wsl-move-cutover' +import { WSL_MOVE_CUTOVER_PHASES } from '../../../shared/wsl-move/wsl-move-cutover' +import { listWslMoveCutovers } from './wsl-move-cutover-journal' + +export type WslMoveScopeIds = { + repoIds: ReadonlySet + folderWorkspaceIds: ReadonlySet +} + +export type WslMoveFenceState = { + /** Rows a move is taking right now: shown as "Moving…", and read-only. */ + fenced: WslMoveScopeIds + /** Rows the WSL host now owns: kept for a downgrade, never listed. */ + hidden: WslMoveScopeIds +} + +const phaseIndex = (phase: WslMoveCutoverPhase): number => WSL_MOVE_CUTOVER_PHASES.indexOf(phase) +const FENCED_FROM = phaseIndex('source-fenced') +const HIDDEN_FROM = phaseIndex('destination-committed') + +/** Throws when the journal cannot be read: callers keep every WSL project fenced then. */ +export function resolveWslMoveFence(userDataPath: string): WslMoveFenceState { + return wslMoveFenceFromCutovers(listWslMoveCutovers(userDataPath)) +} + +export function wslMoveFenceFromCutovers(cutovers: readonly WslMoveCutover[]): WslMoveFenceState { + const fenced = { repoIds: new Set(), folderWorkspaceIds: new Set() } + const hidden = { repoIds: new Set(), folderWorkspaceIds: new Set() } + for (const cutover of cutovers) { + const index = phaseIndex(cutover.phase) + const target = index >= HIDDEN_FROM ? hidden : index >= FENCED_FROM ? fenced : null + if (!target) { + continue // `prepared` touches nothing on the source. + } + const { scopeRepoIds, scopeFolderWorkspaceIds } = cutover.manifest.source + for (const id of scopeRepoIds) { + target.repoIds.add(id) + } + for (const id of scopeFolderWorkspaceIds) { + target.folderWorkspaceIds.add(id) + } + } + return { fenced, hidden } +} diff --git a/src/main/ssh/orcad-migration-cutover-journal.test.ts b/src/main/ssh/orcad-migration-cutover-journal.test.ts index b18741d6ac0..166592061e6 100644 --- a/src/main/ssh/orcad-migration-cutover-journal.test.ts +++ b/src/main/ssh/orcad-migration-cutover-journal.test.ts @@ -1,7 +1,9 @@ -import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { writeSecureJsonFileWithinLimit } from '../../shared/bounded-secure-json-file' +import { parseOrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover' import { orcadMigrationCutoverFixture as cutover } from './orcad-migration-cutover-fixture' import { findOrcadMigrationSourceCutoverForTarget, @@ -90,4 +92,21 @@ describe('migration cutover journal sidecar', () => { 'destination-staged' ) }) + + // Older desktops read these files: the shared store must keep the path and bytes the SSH + // journal has always written (the exact call it made before the store existed). + it('writes the same path and bytes the SSH journal always wrote', () => { + writeOrcadMigrationSourceCutover(userDataPath, cutover()) + expect(orcadMigrationCutoverJournalDirectory(userDataPath)).toBe( + join(userDataPath, 'orcad-migration-cutovers') + ) + const reference = join(userDataPath, 'reference.json') + writeSecureJsonFileWithinLimit( + reference, + parseOrcadMigrationSourceCutover(cutover()), + 16 * 1024 * 1024, + { durable: true } + ) + expect(readFileSync(journalPath('migration-1'))).toEqual(readFileSync(reference)) + }) }) diff --git a/src/main/ssh/orcad-migration-cutover-journal.ts b/src/main/ssh/orcad-migration-cutover-journal.ts index bdf498cc1dd..46097ae9194 100644 --- a/src/main/ssh/orcad-migration-cutover-journal.ts +++ b/src/main/ssh/orcad-migration-cutover-journal.ts @@ -6,23 +6,16 @@ * the target's `orcadFence`, which shipped builds keep, survived it. A missing journal must * never look like "no migration"; an unreadable one fails closed. */ -import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs' -import { join } from 'node:path' -import { writeSecureJsonFileWithinLimit } from '../../shared/bounded-secure-json-file' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' import { MAX_ORCAD_MIGRATION_SOURCE_CUTOVERS, isRetainedOrcadMigrationSourceCutover, parseOrcadMigrationSourceCutover, type OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover' -import { syncDirectoryDurablySync } from '../durable-file-write' -import { errorMessage } from '../../shared/error-message' - -const JOURNAL_DIRECTORY = 'orcad-migration-cutovers' -const JOURNAL_FILE = /^[A-Za-z0-9_-]{1,128}\.json$/ -// A manifest carries catalog rows and dormant session state, never scrollback bytes. -const MAX_JOURNAL_FILE_BYTES = 16 * 1024 * 1024 +import { + createMigrationJournalDirectory, + orderMigrationJournalChain +} from '../persistence/migrating-orcad-catalog/migration-journal-directory' export class OrcadMigrationCutoverJournalUnreadableError extends Error { constructor(detail: string) { @@ -41,56 +34,24 @@ export function setOrcadMigrationJournalChangeListener( journalChangeListener = listener } -function notifyJournalChanged(userDataPath: string): void { - try { - journalChangeListener?.(userDataPath) - } catch (error) { - // The journal write is already durable; a follower failing must not undo or fail it. - console.warn('[orcad-migration] Journal change follower failed:', error) - } -} - -// Why: hidden-row checks run on every list call, and each journal embeds a full manifest. -const parsedJournals = new Map() +const journals = createMigrationJournalDirectory({ + directoryName: 'orcad-migration-cutovers', + // A manifest carries catalog rows and dormant session state, never scrollback bytes. + maxFileBytes: 16 * 1024 * 1024, + parse: parseOrcadMigrationSourceCutover, + unreadable: (detail) => new OrcadMigrationCutoverJournalUnreadableError(detail), + onChange: (userDataPath) => journalChangeListener?.(userDataPath) +}) export function orcadMigrationCutoverJournalDirectory(userDataPath: string): string { - return join(userDataPath, JOURNAL_DIRECTORY) + return journals.directory(userDataPath) } /** Every journaled cutover; throws rather than skipping a file it cannot trust. */ export function listOrcadMigrationSourceCutovers( userDataPath: string ): OrcadMigrationSourceCutover[] { - const directory = orcadMigrationCutoverJournalDirectory(userDataPath) - if (!existsSync(directory)) { - return [] - } - let names: string[] - try { - names = readdirSync(directory) - } catch (error) { - throw new OrcadMigrationCutoverJournalUnreadableError(errorMessage(error)) - } - const files: { name: string; path: string }[] = [] - for (const name of names) { - if (!name.endsWith('.json')) { - continue // Durable-write temporaries and foreign files carry no journal state. - } - if (!JOURNAL_FILE.test(name)) { - throw new OrcadMigrationCutoverJournalUnreadableError(`unexpected entry ${name}`) - } - files.push({ name, path: join(directory, name) }) - } - const key = files.map((file) => `${file.name}:${fileVersion(file.path)}`).join('|') - const cached = parsedJournals.get(directory) - if (cached?.key === key) { - return [...cached.cutovers] - } - const cutovers = files.map((file) => - readJournalFile(file.path, file.name.slice(0, -'.json'.length)) - ) - parsedJournals.set(directory, { key, cutovers }) - return [...cutovers] + return journals.list(userDataPath) } /** The target's current cutover: the head of its chain, which no later delta move supersedes. */ @@ -110,26 +71,11 @@ export function listOrcadMigrationCutoverChainForTarget( const matches = listOrcadMigrationSourceCutovers(userDataPath).filter( (cutover) => cutover.sshTargetId === sshTargetId ) - const superseded = new Set(matches.map((cutover) => cutover.supersedesMigrationId)) - const heads = matches.filter((cutover) => !superseded.has(cutover.migrationId)) - if (heads.length > 1) { - throw new OrcadMigrationCutoverJournalUnreadableError( - `${heads.length} journals name SSH target ${sshTargetId}` - ) - } - const byId = new Map(matches.map((cutover) => [cutover.migrationId, cutover])) - const chain: OrcadMigrationSourceCutover[] = [] - let entry: OrcadMigrationSourceCutover | undefined = heads[0] - while (entry && chain.length <= matches.length) { - chain.unshift(entry) - entry = entry.supersedesMigrationId ? byId.get(entry.supersedesMigrationId) : undefined - } - if (chain.length !== matches.length) { - throw new OrcadMigrationCutoverJournalUnreadableError( - `the journals naming SSH target ${sshTargetId} do not form one chain` - ) - } - return chain + return orderMigrationJournalChain( + matches, + (detail) => new OrcadMigrationCutoverJournalUnreadableError(detail), + `SSH target ${sshTargetId}` + ) } /** Durable before it returns: a fence written after this always has its journal on disk. */ @@ -149,30 +95,11 @@ export function writeOrcadMigrationSourceCutover( ) { throw new Error('orcad_migration_cutover_capacity_exceeded') } - const directory = orcadMigrationCutoverJournalDirectory(userDataPath) - mkdirSync(directory, { recursive: true, mode: 0o700 }) - writeSecureJsonFileWithinLimit( - join(directory, `${parsed.migrationId}.json`), - parsed, - MAX_JOURNAL_FILE_BYTES, - { durable: true } - ) - syncDirectoryDurablySync(directory) - parsedJournals.delete(directory) - notifyJournalChanged(userDataPath) + journals.write(userDataPath, parsed) } export function removeOrcadMigrationSourceCutover(userDataPath: string, migrationId: string): void { - if (!JOURNAL_FILE.test(`${migrationId}.json`)) { - throw new Error('orcad_migration_cutover_id_invalid') - } - const directory = orcadMigrationCutoverJournalDirectory(userDataPath) - rmSync(join(directory, `${migrationId}.json`), { force: true }) - parsedJournals.delete(directory) - if (existsSync(directory)) { - syncDirectoryDurablySync(directory) - } - notifyJournalChanged(userDataPath) + journals.remove(userDataPath, migrationId) } /** Every journal a host's stopped or never-registered server leaves behind; they grant nothing. */ @@ -187,26 +114,3 @@ export function removeOrcadMigrationJournalsForDestination( } } } - -/** Durable writes replace the file, so size, mtime and inode change with every rewrite. */ -function fileVersion(path: string): string { - try { - const stat = statSync(path, { bigint: true }) - return `${stat.ino}:${stat.size}:${stat.mtimeNs}` - } catch (error) { - throw new OrcadMigrationCutoverJournalUnreadableError(errorMessage(error)) - } -} - -function readJournalFile(path: string, migrationId: string): OrcadMigrationSourceCutover { - try { - const raw = readNodeFileSyncWithinLimit(path, MAX_JOURNAL_FILE_BYTES).buffer.toString('utf8') - const cutover = parseOrcadMigrationSourceCutover(JSON.parse(raw)) - if (cutover.migrationId !== migrationId) { - throw new Error('file name does not match its migration id') - } - return cutover - } catch (error) { - throw new OrcadMigrationCutoverJournalUnreadableError(errorMessage(error)) - } -} diff --git a/src/shared/wsl-move/wsl-move-cutover.ts b/src/shared/wsl-move/wsl-move-cutover.ts new file mode 100644 index 00000000000..eac84ff2c44 --- /dev/null +++ b/src/shared/wsl-move/wsl-move-cutover.ts @@ -0,0 +1,76 @@ +/** + * The desktop's journal of one local WSL project move (mover design, section 4.4). Kept outside the + * profile store, in its own directory, so a build that knows only SSH journals never reads one. + */ +import { z } from 'zod' +import { + parseOrcadMigrationManifest, + type OrcadMigrationLocalWslManifestSource, + type OrcadMigrationManifest +} from '../orcad-migration-manifest' +import { isOrcadMigrationLocalWslSource } from '../orcad-migration-source-kind' + +export const WSL_MOVE_CUTOVER_VERSION = 1 + +/** In order; each crash point resumes from the last one written (design, section 4.4). */ +export const WSL_MOVE_CUTOVER_PHASES = [ + 'prepared', + 'source-fenced', + 'quiesced', + 'destination-staged', + 'blobs-transferred', + 'destination-committed', + 'client-rewritten', + 'retained' +] as const + +export type WslMoveCutoverPhase = (typeof WSL_MOVE_CUTOVER_PHASES)[number] + +// Why not strict: a newer build's optional field must not make every journal unreadable here. +const WslMoveCutoverSchema = z.object({ + version: z.literal(WSL_MOVE_CUTOVER_VERSION), + migrationId: z.string().regex(/^[A-Za-z0-9_-]{1,128}$/), + phase: z.enum(WSL_MOVE_CUTOVER_PHASES), + startedAt: z.string().datetime(), + updatedAt: z.string().datetime(), + destinationEnvironmentId: z.string().min(1).max(256), + destinationName: z.string().min(1).max(1_024), + journalKey: z.string().min(1).max(1_024), + manifestSha256: z.string().regex(/^[a-f0-9]{64}$/), + // Local daemon sessions the quiesce proved exited; any other live session in scope is new work. + provenExitedSessionIds: z.array(z.string().min(1).max(1_024)).max(10_000), + // Desktop stores already rewritten after commit, so a crash redoes only the rest. + rewrittenStores: z.array(z.string().min(1).max(128)).max(256), + supersedesMigrationId: z.string().min(1).max(128).optional(), + manifest: z.unknown() +}) + +export type WslMoveCutover = Omit, 'manifest'> & { + manifest: OrcadMigrationManifest & { source: OrcadMigrationLocalWslManifestSource } +} + +/** One in-flight move per distro; WSL resolves distro names case-insensitively. */ +export function wslMoveJournalKey(distro: string): string { + return `local-wsl:${distro.toLowerCase()}` +} + +/** Throws on anything that is not exactly a WSL move whose manifest matches its binding. */ +export function parseWslMoveCutover(value: unknown): WslMoveCutover { + const record = WslMoveCutoverSchema.parse(value) + const manifest = parseOrcadMigrationManifest(record.manifest) + const { source } = manifest + if ( + !isOrcadMigrationLocalWslSource(source) || + manifest.migrationId !== record.migrationId || + manifest.manifestSha256 !== record.manifestSha256 || + manifest.destinationEnvironmentId !== record.destinationEnvironmentId || + wslMoveJournalKey(source.distro) !== record.journalKey + ) { + throw new Error('wsl_move_cutover_binding_mismatch') + } + return { ...record, manifest: { ...manifest, source } } +} + +export function isWslMoveCutoverFinished(cutover: Pick): boolean { + return cutover.phase === 'retained' +}