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
This commit is contained in:
Neil
2026-10-14 00:39:34 -07:00
committed by GitHub
parent b428def8f1
commit 80f038ed80
9 changed files with 711 additions and 118 deletions
@@ -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<WslMoveCutover> = {},
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
}
}
@@ -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<T extends { migrationId: string }> = {
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<T extends { migrationId: string }>(options: {
directoryName: string
maxFileBytes: number
parse: (value: unknown) => T
unreadable: (detail: string) => Error
onChange?: (userDataPath: string) => void
}): MigrationJournalDirectory<T> {
// Why: hidden-row checks run on every list call, and each record may embed a full manifest.
const parsed = new Map<string, { key: string; records: T[] }>()
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
}
@@ -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')
})
})
@@ -0,0 +1,132 @@
/**
* The WSL move journal: `orcad-migration-cutovers-wsl/<migrationId>.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)
}
@@ -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<typeof wslMoveFenceFromCutovers>) => ({
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'] })
})
})
@@ -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<string>
folderWorkspaceIds: ReadonlySet<string>
}
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<string>(), folderWorkspaceIds: new Set<string>() }
const hidden = { repoIds: new Set<string>(), folderWorkspaceIds: new Set<string>() }
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 }
}
@@ -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))
})
})
+21 -117
View File
@@ -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<string, { key: string; cutovers: OrcadMigrationSourceCutover[] }>()
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))
}
}
+76
View File
@@ -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<z.infer<typeof WslMoveCutoverSchema>, '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<WslMoveCutover, 'phase'>): boolean {
return cutover.phase === 'retained'
}