mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
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:
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user