Convert an unknown legacy plugin wrapper such as
{kind: 'plugin', plugin: 'acme'} into {kind: 'plugin:acme'}, retaining the
complete plugin name after one prefix. Keep the existing explicit
first-party producer renames and preserve all non-identity metadata.
A direct source already records its producer in kind. Reclassifying
unknown direct kinds into plugin:source:<name> needlessly changes that
attribution and forces plugin consumers to adopt new identifiers even
though V4 accepts their existing representation. Return every valid
direct source unchanged, including already-prefixed names. Remove the
obsolete direct-kind table and two-category identity helper rather than
adding collision classification or another mapping layer.
Update the source conversion examples, codec/restore regressions, and
native fixture producers together. Refresh only the owning Python
advanced scenario's unshipped V4 outputs; preserve historical V0-V3
recordings and their generation selection. Event names, content tags,
and ToolSchema migration remain separate concerns.
Refs #2692
1653 lines
69 KiB
TypeScript
1653 lines
69 KiB
TypeScript
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { createHash } from 'node:crypto'
|
|
import {
|
|
link,
|
|
mkdir,
|
|
mkdtemp,
|
|
open,
|
|
readFile,
|
|
readdir,
|
|
rename,
|
|
rm,
|
|
stat,
|
|
symlink,
|
|
writeFile,
|
|
type FileHandle,
|
|
} from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { basename, join } from 'node:path'
|
|
import { performance } from 'node:perf_hooks'
|
|
import {
|
|
JsonlGenerationSourceChangedError,
|
|
JsonlGenerationTargetConflictError,
|
|
JsonlGenerationUnsupportedMigrationError,
|
|
prepareJsonlMigration,
|
|
readDecodedJsonlSource,
|
|
verifyJsonlCurrentGeneration,
|
|
type JsonlGenerationFormatAdapter,
|
|
type PrepareJsonlMigrationOptions,
|
|
} from '../src/generation.ts'
|
|
import { createJsonlGenerationTestRuntime } from '../src/testing/generation.ts'
|
|
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
|
|
import type { JsonlCompression } from '../src/format.ts'
|
|
import { createSessionFormatCatalogWithChildren, sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
|
|
import type {
|
|
SessionFormatArtifact,
|
|
SessionFormatEvent,
|
|
SessionFormatJsonValue,
|
|
SessionFormatRestore,
|
|
} from '@deepseek-ai/dsh-session-format'
|
|
|
|
const roots: string[] = []
|
|
|
|
async function tempRoot(): Promise<string> {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-generation-'))
|
|
roots.push(root)
|
|
return root
|
|
}
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks()
|
|
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
|
|
})
|
|
|
|
function line(value: unknown): string {
|
|
return `${JSON.stringify(value)}\n`
|
|
}
|
|
|
|
function fsError(code: string, message = code): NodeJS.ErrnoException {
|
|
const error = new Error(message) as NodeJS.ErrnoException
|
|
error.code = code
|
|
return error
|
|
}
|
|
|
|
/** Complete a POSIX-branch simulation on Windows, whose NTFS directory handles reject fsync. */
|
|
async function openWithPosixDirectorySync(path: string, flags: string, mode?: number) {
|
|
const handle = await open(path, flags, mode)
|
|
if (flags === 'r' && (await stat(path)).isDirectory()) {
|
|
vi.spyOn(handle, 'sync').mockResolvedValue(undefined)
|
|
}
|
|
return handle
|
|
}
|
|
|
|
function posixSimulationFs<T extends Record<string, unknown>>(
|
|
overrides: T,
|
|
): T & { readonly open: typeof openWithPosixDirectorySync } {
|
|
return { open: openWithPosixDirectorySync, ...overrides }
|
|
}
|
|
|
|
function header(version: number, id = 'generation-test'): Record<string, unknown> {
|
|
return {
|
|
type: 'session', version, id, createdAt: 1, delegationDepth: 0,
|
|
...(version >= 2 ? { isSeeded: false } : {}),
|
|
}
|
|
}
|
|
|
|
const event0 = { type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } }
|
|
const event1 = { type: 'turn/end', seq: 1, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }
|
|
|
|
it('preserves cancellation raised while creating a child source decoder', async () => {
|
|
const path = join(await tempRoot(), 'session.v3.jsonl')
|
|
await writeFile(path, line(header(3)))
|
|
const controller = new AbortController()
|
|
const reason = new Error('child decoding cancelled')
|
|
await expect(readDecodedJsonlSource(path, 3, 'none', {
|
|
createRestore() {
|
|
controller.abort(reason)
|
|
throw reason
|
|
},
|
|
}, controller.signal)).rejects.toBe(reason)
|
|
})
|
|
|
|
const assistantUsage = { inputTokens: 3, outputTokens: 2 }
|
|
const assistantReplayState = { response: { id: 'response' } }
|
|
|
|
function assistantData(
|
|
overrides: {
|
|
readonly content?: readonly SessionFormatJsonValue[]
|
|
readonly stream?: SessionFormatJsonValue
|
|
readonly usage?: SessionFormatJsonValue
|
|
readonly replayState?: SessionFormatJsonValue
|
|
readonly interrupted?: true
|
|
} = {},
|
|
): SessionFormatJsonValue {
|
|
const replayState = overrides.replayState === undefined
|
|
? assistantReplayState
|
|
: overrides.replayState
|
|
return {
|
|
turn: 1,
|
|
step: 1,
|
|
message: {
|
|
id: 'assistant',
|
|
role: 'assistant',
|
|
content: overrides.content ?? [{ type: 'text', text: 'hello' }],
|
|
source: {
|
|
kind: 'model', provider: 'mock', model: 'mock',
|
|
...(replayState === null ? {} : { replayState }),
|
|
},
|
|
},
|
|
stream: overrides.stream ?? [
|
|
{ type: 'text-chunks', time0: 3, index: 0, dt: [], texts: ['hello'] },
|
|
{ type: 'chunk', time: 4, chunk: { type: 'usage', usage: assistantUsage } },
|
|
{ type: 'chunk', time: 5, chunk: { type: 'finish', reason: { kind: 'stop' }, replayState: assistantReplayState } },
|
|
],
|
|
...(overrides.usage === null ? {} : { usage: overrides.usage ?? assistantUsage }),
|
|
...(overrides.interrupted === undefined ? {} : { interrupted: overrides.interrupted }),
|
|
}
|
|
}
|
|
|
|
function assistantLifecycle(
|
|
type: 'assistant/message' | 'assistant/attempt',
|
|
data: SessionFormatJsonValue,
|
|
): SessionFormatEvent[] {
|
|
return [
|
|
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
|
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
|
{
|
|
type,
|
|
seq: 2,
|
|
time: 5,
|
|
data,
|
|
...(type === 'assistant/message' ? { surfaceOp: 'append' as const } : {}),
|
|
},
|
|
{ type: 'step/end', seq: 3, time: 6, data: { turn: 1, step: 1 } },
|
|
{ type: 'turn/end', seq: 4, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
|
|
]
|
|
}
|
|
|
|
interface TestGenerationFormatAdapter extends JsonlGenerationFormatAdapter {
|
|
createRestore(header: Record<string, unknown>): SessionFormatRestore
|
|
}
|
|
|
|
function adapter(overrides: Partial<TestGenerationFormatAdapter> = {}): TestGenerationFormatAdapter {
|
|
const currentVersion = overrides.currentVersion ?? SESSION_FORMAT_VERSION
|
|
return {
|
|
currentVersion,
|
|
createRestore(headerValue) {
|
|
const events: SessionFormatArtifact['events'][number][] = []
|
|
const header = {
|
|
...headerValue,
|
|
version: currentVersion,
|
|
isSeeded: false,
|
|
} as SessionFormatArtifact['header']
|
|
return {
|
|
header,
|
|
decodeRow(row) { events.push(row as SessionFormatArtifact['events'][number]) },
|
|
finish: () => ({ header, inheritedEventCount: 0, events }),
|
|
}
|
|
},
|
|
encodeHeader(value) {
|
|
const { isSeeded: _isSeeded, ...header } = value
|
|
return currentVersion === 0 ? header : {
|
|
...header,
|
|
type: 'session',
|
|
version: currentVersion,
|
|
...(currentVersion >= 2 ? { isSeeded: value.isSeeded } : {}),
|
|
}
|
|
},
|
|
encodeEvent: event => event,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function catalogAdapter(): JsonlGenerationFormatAdapter {
|
|
return {
|
|
currentVersion: sessionFormatCatalog.currentVersion,
|
|
createRestore: header => createSessionFormatCatalogWithChildren([]).createRestore(header, {
|
|
recovery: 'recoverable', validation: 'transformed',
|
|
}),
|
|
encodeHeader: (header, inheritedEventCount) =>
|
|
sessionFormatCatalog.encodeCurrentHeader(header, inheritedEventCount),
|
|
encodeEvent: event => sessionFormatCatalog.encodeCurrentEvent(event),
|
|
}
|
|
}
|
|
|
|
function streamingAdapter(): JsonlGenerationFormatAdapter & {
|
|
createRestore(header: Record<string, unknown>): SessionFormatRestore
|
|
} {
|
|
return adapter()
|
|
}
|
|
|
|
function verifier(): PrepareJsonlMigrationOptions['verifyCurrentFile'] {
|
|
return (path, compression, expectedId, expectedEventCount, expectedPrefix) =>
|
|
verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount, expectedPrefix)
|
|
}
|
|
|
|
const byteVerifier: PrepareJsonlMigrationOptions['verifyCurrentFile'] = async (path) => {
|
|
const [bytes, identity] = await Promise.all([readFile(path), stat(path, { bigint: true })])
|
|
return {
|
|
identity,
|
|
bytes: bytes.length,
|
|
digest: createHash('sha256').update(bytes).digest('hex'),
|
|
}
|
|
}
|
|
|
|
function generationPath(root: string, version: number, compression: JsonlCompression): string {
|
|
const suffix = compression === 'zstd' ? '.jsonl.zstd' : '.jsonl'
|
|
return join(root, version === 0 ? `session${suffix}` : `session.v${version}${suffix}`)
|
|
}
|
|
|
|
function options(
|
|
root: string,
|
|
compression: JsonlCompression = 'none',
|
|
format: JsonlGenerationFormatAdapter = adapter(),
|
|
sourceVersion = 0,
|
|
): Omit<PrepareJsonlMigrationOptions, 'verifyCurrentFile'> {
|
|
return {
|
|
sourcePath: generationPath(root, sourceVersion, compression),
|
|
sourceVersion,
|
|
currentPath: generationPath(root, format.currentVersion, compression),
|
|
compression,
|
|
format,
|
|
}
|
|
}
|
|
|
|
type TestMigrationOptions = ReturnType<typeof options> & {
|
|
readonly signal?: AbortSignal
|
|
readonly verifyCurrentFile?: PrepareJsonlMigrationOptions['verifyCurrentFile']
|
|
}
|
|
type TestGenerationOverrides = Parameters<typeof createJsonlGenerationTestRuntime>[0]
|
|
|
|
async function ensureWithOverrides(
|
|
request: TestMigrationOptions,
|
|
overrides: TestGenerationOverrides,
|
|
) {
|
|
const runtime = createJsonlGenerationTestRuntime(overrides)
|
|
const verifyCurrentFile = request.verifyCurrentFile ?? (
|
|
(path: string, compression: JsonlCompression, expectedId: string, expectedEventCount: number, expectedPrefix) =>
|
|
runtime.verify(
|
|
path,
|
|
compression,
|
|
expectedId,
|
|
expectedEventCount,
|
|
expectedPrefix,
|
|
)
|
|
)
|
|
const prepared = await runtime.prepare({
|
|
...request,
|
|
verifyCurrentFile,
|
|
})
|
|
const identity = await prepared.publish()
|
|
const bytes = await readFile(request.currentPath)
|
|
return {
|
|
status: 'migrated' as const,
|
|
fromVersion: request.sourceVersion,
|
|
toVersion: request.format.currentVersion,
|
|
path: request.currentPath,
|
|
sourcePath: request.sourcePath,
|
|
snapshot: { identity, bytes },
|
|
}
|
|
}
|
|
|
|
function ensureJsonlGenerationCurrent(request: TestMigrationOptions) {
|
|
return ensureWithOverrides(request, {})
|
|
}
|
|
|
|
async function encodeZstd(version: number, rows: readonly unknown[]): Promise<Buffer> {
|
|
return Buffer.concat([
|
|
await compressZstdFrame(line(header(version))),
|
|
...rows.length === 0
|
|
? []
|
|
: [await compressZstdFrame(rows.map(row => line(row)).join(''))],
|
|
])
|
|
}
|
|
|
|
async function decodeZstdJsonl(path: string): Promise<string> {
|
|
const bytes = await readFile(path)
|
|
const { frames, tornStart } = scanZstdFrames(bytes)
|
|
expect(tornStart).toBeUndefined()
|
|
const plaintext: Buffer[] = []
|
|
for (const frame of frames) plaintext.push(await decompressZstdFrame(bytes.subarray(frame.start, frame.end)))
|
|
return Buffer.concat(plaintext).toString('utf8')
|
|
}
|
|
|
|
describe('JSONL immutable generation publication', () => {
|
|
it('refuses V2 messages without surface markers before writing a current successor', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root, 'none', catalogAdapter(), 2)
|
|
const events = assistantLifecycle('assistant/message', assistantData())
|
|
.map(({ surfaceOp: _surfaceOp, ...event }) => event)
|
|
const source = Buffer.from(line(header(2)) + events.map(line).join(''))
|
|
await writeFile(request.sourcePath, source)
|
|
|
|
await expect(ensureJsonlGenerationCurrent(request))
|
|
.rejects.toThrow('assistant/message requires surfaceOp')
|
|
expect(await readFile(request.sourcePath)).toEqual(source)
|
|
await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
expect(await readdir(root)).toEqual(['session.v2.jsonl'])
|
|
})
|
|
|
|
it('refuses contradictory V2 tool error metadata without publishing or modifying the source', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root, 'none', catalogAdapter(), 2)
|
|
const events: SessionFormatEvent[] = [
|
|
...assistantLifecycle('assistant/message', assistantData({
|
|
content: [{ type: 'tool-call', id: 'call', name: 'test', arguments: '{}' }],
|
|
stream: [], usage: null, replayState: null,
|
|
})).slice(0, 3),
|
|
{ type: 'tool/call', seq: 3, time: 6,
|
|
data: { turn: 1, step: 1, callId: 'call', name: 'test', arguments: '{}' } },
|
|
{ type: 'tool/result', seq: 4, time: 7, surfaceOp: 'append', data: {
|
|
turn: 1, step: 1,
|
|
message: { id: 'result', role: 'user', source: { kind: 'tool', callId: 'call' },
|
|
content: [{ type: 'tool-result', toolCallId: 'call', isError: false,
|
|
content: [{ type: 'text', text: 'success' }] }] },
|
|
error: { name: 'ToolError', code: 'FAILED' },
|
|
} },
|
|
{ type: 'step/end', seq: 5, time: 8, data: { turn: 1, step: 1 } },
|
|
{ type: 'turn/end', seq: 6, time: 9, data: { turn: 1, reason: { kind: 'completed' } } },
|
|
]
|
|
const source = Buffer.from(line(header(2)) + events.map(line).join(''))
|
|
await writeFile(request.sourcePath, source)
|
|
|
|
await expect(ensureJsonlGenerationCurrent(request))
|
|
.rejects.toThrow('tool/result at seq 5 carries error metadata for a non-error tool result')
|
|
expect(await readFile(request.sourcePath)).toEqual(source)
|
|
await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
expect(await readdir(root)).toEqual(['session.v2.jsonl'])
|
|
})
|
|
|
|
it('publishes canonical replacements and current headers while retaining exact V2 bytes', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root, 'none', catalogAdapter(), 2)
|
|
const config = { provider: 'mock', model: 'mock' }
|
|
const events: SessionFormatEvent[] = [
|
|
event0,
|
|
{ type: 'step/start', seq: 1, time: 3, data: { turn: 1, step: 1 } },
|
|
{ type: 'user/message', seq: 2, time: 4, surfaceOp: 'append', data: {
|
|
id: 'input', role: 'user', content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
|
|
} },
|
|
{ type: 'user/message', seq: 3, time: 5,
|
|
surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2], data: {
|
|
id: 'summary', role: 'user', content: [{ type: 'text', text: 'summary' }],
|
|
source: { kind: 'plugin', plugin: 'summary-fixture', form: 'notice', summary: 'summary-fixture' },
|
|
} },
|
|
{ type: 'request/header', seq: 4, time: 6, data: {
|
|
header: { config, system: '', tools: [], adapterDefaults: {} }, reason: 'initial',
|
|
} },
|
|
{ type: 'step/end', seq: 5, time: 7, data: { turn: 1, step: 1 } },
|
|
{ type: 'turn/end', seq: 6, time: 8, data: { turn: 1, reason: { kind: 'completed' } } },
|
|
]
|
|
const source = Buffer.from(line(header(2)) + events.map(line).join(''))
|
|
await writeFile(request.sourcePath, source)
|
|
const prepared = await prepareJsonlMigration({ ...request, verifyCurrentFile: verifier() })
|
|
const canonical: unknown[] = [
|
|
events[0], events[1],
|
|
expect.objectContaining({ type: 'system/message', seq: 2, surfaceOp: 'append', data: expect.objectContaining({ message: expect.objectContaining({ role: 'system', content: [] }) as unknown }) as unknown }) as unknown,
|
|
...events.slice(2).map(event => event.seq === 3
|
|
? { ...event, seq: 4, surfaceOp: { op: 'replace', startSeq: 3, endSeq: 3 }, sourceEventSeqs: [3],
|
|
data: { ...event.data as Record<string, unknown>, source: {
|
|
kind: 'plugin:summary-fixture', form: 'notice', summary: 'summary-fixture',
|
|
} } }
|
|
: event.seq === 4 ? { ...event, seq: 5, data: { header: { config }, reason: 'initial' } } : { ...event, seq: event.seq + 1 }),
|
|
]
|
|
|
|
expect(prepared.artifact.events).toEqual(canonical)
|
|
await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
await prepared.publish()
|
|
expect(await readFile(request.sourcePath)).toEqual(source)
|
|
const written = (await readFile(request.currentPath, 'utf8')).trimEnd().split('\n')
|
|
.map(row => JSON.parse(row) as unknown)
|
|
expect(written).toEqual([header(SESSION_FORMAT_VERSION), ...canonical])
|
|
expect((await readdir(root)).sort()).toEqual(['session.v2.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`])
|
|
})
|
|
|
|
it('returns migrated events while publication is still waiting for verification', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root, 'none', streamingAdapter())
|
|
const boundaryBase = { ...event0, data: { turn: 1, text: '' } }
|
|
const boundaryEvent = {
|
|
...boundaryBase,
|
|
data: { ...boundaryBase.data, text: 'x'.repeat(1024 * 1024 - JSON.stringify(boundaryBase).length) },
|
|
}
|
|
const largeEvent = { type: 'user/message', seq: 1, time: 3, surfaceOp: 'append', data: {
|
|
id: 'large-message', role: 'user', source: { kind: 'user' },
|
|
content: [{ type: 'text', text: 'y'.repeat(1024 * 1024) }],
|
|
} }
|
|
const finalEvent = { ...event1, seq: 2 }
|
|
await writeFile(request.sourcePath, line(header(0)) + line(boundaryEvent) + line(largeEvent) + line(finalEvent))
|
|
let now = 0
|
|
vi.spyOn(performance, 'now').mockImplementation(() => now += 600)
|
|
const entered = Promise.withResolvers<undefined>()
|
|
const release = Promise.withResolvers<undefined>()
|
|
|
|
const prepared = await prepareJsonlMigration({
|
|
...request,
|
|
verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => {
|
|
entered.resolve(undefined)
|
|
await release.promise
|
|
return verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount)
|
|
},
|
|
})
|
|
expect(prepared.artifact.events).toEqual([boundaryEvent, largeEvent, finalEvent])
|
|
const publication = prepared.publish()
|
|
expect(prepared.publish()).toBe(publication)
|
|
await entered.promise
|
|
await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
|
|
release.resolve(undefined)
|
|
await publication
|
|
const [writtenHeader, ...writtenEvents] = (await readFile(request.currentPath, 'utf8')).trimEnd().split('\n')
|
|
expect(JSON.parse(writtenHeader as string)).toEqual({ ...header(SESSION_FORMAT_VERSION), isSeeded: false })
|
|
expect(writtenEvents.map(row => JSON.parse(row) as unknown)).toEqual([boundaryEvent, largeEvent, finalEvent])
|
|
})
|
|
|
|
it('batches exact-threshold encoded rows without retaining a final partial write', async () => {
|
|
const root = await tempRoot()
|
|
const mib = 1024 * 1024
|
|
const widths = [4 * mib - 3, mib - 3, mib - 3, mib - 3, mib - 3]
|
|
const format = adapter({
|
|
encodeEvent: event => 'x'.repeat(widths[event.seq] as number),
|
|
})
|
|
const request = options(root, 'none', format)
|
|
const events = widths.map((_, seq) => ({ ...event0, seq }))
|
|
await writeFile(request.sourcePath, line(header(0)) + events.map(line).join(''))
|
|
|
|
const prepared = await prepareJsonlMigration({
|
|
...request,
|
|
verifyCurrentFile: byteVerifier,
|
|
})
|
|
await prepared.publish()
|
|
|
|
expect((await stat(request.currentPath)).size).toBeGreaterThan(8 * mib)
|
|
})
|
|
|
|
it('fails publication without rerunning migration when the source changes', async () => {
|
|
const root = await tempRoot()
|
|
const base = streamingAdapter()
|
|
const sourceStreams = vi.fn()
|
|
const request = options(root, 'none', {
|
|
...base,
|
|
createRestore: (value) => {
|
|
if (value.version === 0) sourceStreams()
|
|
return base.createRestore(value)
|
|
},
|
|
})
|
|
const source = line(header(0)) + line(event0)
|
|
await writeFile(request.sourcePath, source)
|
|
|
|
const prepared = await prepareJsonlMigration({
|
|
...request,
|
|
verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => {
|
|
const verified = await verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount)
|
|
await writeFile(request.sourcePath, source + line(event1))
|
|
return verified
|
|
},
|
|
})
|
|
|
|
await expect(prepared.publish()).rejects.toBeInstanceOf(JsonlGenerationSourceChangedError)
|
|
expect(sourceStreams).toHaveBeenCalledOnce()
|
|
await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
})
|
|
|
|
it('refuses malformed streaming inputs before publication', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root, 'none', streamingAdapter())
|
|
|
|
await writeFile(request.sourcePath, '')
|
|
await expect(prepareJsonlMigration({ ...request, verifyCurrentFile: vi.fn() }))
|
|
.rejects.toThrow('empty or header-less')
|
|
|
|
await writeFile(request.sourcePath, line(header(1)))
|
|
await expect(prepareJsonlMigration({ ...request, verifyCurrentFile: vi.fn() }))
|
|
.rejects.toThrow(/filename identifies v0.*header identifies v1/)
|
|
|
|
await writeFile(request.sourcePath, line(header(0)) + '{bad json}\n' + line(event1))
|
|
await expect(prepareJsonlMigration({ ...request, verifyCurrentFile: vi.fn() }))
|
|
.rejects.toThrow('row 1 is not valid JSON')
|
|
|
|
await writeFile(request.sourcePath, line(header(0)) + '{bad json}\n' + line(event0))
|
|
const dropped = await prepareJsonlMigration({
|
|
...request,
|
|
verifyCurrentFile: verifier(),
|
|
})
|
|
expect(dropped.artifact.events).toEqual([])
|
|
await dropped.publish()
|
|
|
|
})
|
|
|
|
it('verifies exact current identity, completeness, and event count', async () => {
|
|
const root = await tempRoot()
|
|
const path = generationPath(root, SESSION_FORMAT_VERSION, 'none')
|
|
await writeFile(path, line({ ...header(SESSION_FORMAT_VERSION), isSeeded: false }) + line(event0))
|
|
|
|
await expect(verifyJsonlCurrentGeneration(path, 'none', 'other', 1))
|
|
.rejects.toThrow('expected "other"')
|
|
await expect(verifyJsonlCurrentGeneration(path, 'none', 'generation-test', 2))
|
|
.rejects.toThrow('contains 1 events')
|
|
await writeFile(path, line({ ...header(SESSION_FORMAT_VERSION), isSeeded: false }) + JSON.stringify(event0))
|
|
await expect(verifyJsonlCurrentGeneration(path, 'none', 'generation-test', 1))
|
|
.rejects.toThrow('torn physical tail')
|
|
|
|
await writeFile(path, Buffer.alloc(0))
|
|
await expect(verifyJsonlCurrentGeneration(path, 'none', 'generation-test', 0))
|
|
.rejects.toThrow('empty or header-less')
|
|
await expect(verifyJsonlCurrentGeneration(path, 'zstd', 'generation-test', 0))
|
|
.rejects.toThrow('empty or header-less Zstandard')
|
|
const headerFrame = await compressZstdFrame(line({ ...header(SESSION_FORMAT_VERSION), isSeeded: false }))
|
|
await writeFile(path, Buffer.concat([headerFrame, await compressZstdFrame(JSON.stringify(event0))]))
|
|
await expect(verifyJsonlCurrentGeneration(path, 'zstd', 'generation-test', 1))
|
|
.rejects.toThrow('torn physical tail')
|
|
await writeFile(path, Buffer.concat([
|
|
headerFrame,
|
|
(await compressZstdFrame(line(event0))).subarray(0, -3),
|
|
]))
|
|
await expect(verifyJsonlCurrentGeneration(path, 'zstd', 'generation-test', 1))
|
|
.rejects.toThrow('torn physical tail')
|
|
})
|
|
|
|
it('keeps complete Assistant stream checks in current-generation verification', async () => {
|
|
const root = await tempRoot()
|
|
const path = generationPath(root, SESSION_FORMAT_VERSION, 'none')
|
|
const verify = async (events: readonly SessionFormatEvent[]) => {
|
|
await writeFile(path, line(header(SESSION_FORMAT_VERSION)) + events.map(line).join(''))
|
|
return verifyJsonlCurrentGeneration(path, 'none', 'generation-test', events.length)
|
|
}
|
|
|
|
const valid = [
|
|
assistantLifecycle('assistant/message', assistantData()),
|
|
assistantLifecycle('assistant/message', assistantData({
|
|
interrupted: true,
|
|
stream: [{ type: 'text-chunks', time0: 3, index: 0, dt: [], texts: ['hello'] }],
|
|
usage: null,
|
|
replayState: null,
|
|
})),
|
|
assistantLifecycle('assistant/message', assistantData({
|
|
content: [], stream: [], usage: null, replayState: null,
|
|
})),
|
|
assistantLifecycle('assistant/attempt', {
|
|
turn: 1,
|
|
step: 1,
|
|
stream: [{ type: 'text-chunks', time0: 3, index: 0, dt: [1], texts: ['a', 'b'] }],
|
|
}),
|
|
]
|
|
for (const events of valid) expect((await verify(events)).bytes).toBeGreaterThan(0)
|
|
|
|
await expect(verify(assistantLifecycle('assistant/attempt', {
|
|
turn: 1, step: 1, stream: [{ type: 'future' }],
|
|
}))).rejects.toThrow(/invalid embedded stream/)
|
|
await expect(verify(assistantLifecycle('assistant/message', assistantData({
|
|
content: [{ type: 'text', text: 'different' }],
|
|
})))).rejects.toThrow(/content disagrees/)
|
|
await expect(verify(assistantLifecycle('assistant/message', assistantData({
|
|
usage: { inputTokens: 9, outputTokens: 2 },
|
|
})))).rejects.toThrow(/usage disagrees/)
|
|
await expect(verify(assistantLifecycle('assistant/message', assistantData({
|
|
replayState: { response: { id: 'different' } },
|
|
})))).rejects.toThrow(/replay state disagrees/)
|
|
})
|
|
|
|
it('accepts an identical publication winner and rejects different bytes', async () => {
|
|
const identicalRoot = await tempRoot()
|
|
const identical = options(identicalRoot, 'none', streamingAdapter())
|
|
await writeFile(identical.sourcePath, line(header(0)) + line(event0))
|
|
const prepared = await prepareJsonlMigration({
|
|
...identical,
|
|
verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => {
|
|
const verified = await verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount)
|
|
if (path !== identical.currentPath) await link(path, identical.currentPath)
|
|
return verified
|
|
},
|
|
})
|
|
expect((await prepared.publish()).size).toBeGreaterThan(0n)
|
|
|
|
const differentRoot = await tempRoot()
|
|
const different = options(differentRoot, 'none', streamingAdapter())
|
|
await writeFile(different.sourcePath, line(header(0)) + line(event0))
|
|
await writeFile(different.currentPath, line({ ...header(SESSION_FORMAT_VERSION), isSeeded: false }) + line({ ...event0, time: 99 }))
|
|
const conflicted = await prepareJsonlMigration({
|
|
...different,
|
|
verifyCurrentFile: verifier(),
|
|
})
|
|
await expect(conflicted.publish()).rejects.toBeInstanceOf(JsonlGenerationTargetConflictError)
|
|
|
|
const uncheckedRoot = await tempRoot()
|
|
const unchecked = options(uncheckedRoot, 'none', streamingAdapter())
|
|
await writeFile(unchecked.sourcePath, line(header(0)) + line(event0))
|
|
await writeFile(
|
|
unchecked.currentPath,
|
|
line({ ...header(SESSION_FORMAT_VERSION), isSeeded: false }) + line({ ...event0, time: 99 }),
|
|
)
|
|
const uncheckedPublication = await prepareJsonlMigration({
|
|
...unchecked,
|
|
verifyCurrentFile: byteVerifier,
|
|
})
|
|
await expect(uncheckedPublication.publish())
|
|
.rejects.toThrow(/target bytes differ from the migrated generation/)
|
|
})
|
|
|
|
it('handles empty, incomplete-record, and torn Zstandard migration sources', async () => {
|
|
const emptyRoot = await tempRoot()
|
|
const empty = options(emptyRoot, 'zstd', streamingAdapter())
|
|
await writeFile(empty.sourcePath, Buffer.alloc(0))
|
|
await expect(prepareJsonlMigration({ ...empty, verifyCurrentFile: vi.fn() }))
|
|
.rejects.toThrow('empty or header-less Zstandard')
|
|
|
|
const incompleteRoot = await tempRoot()
|
|
const incomplete = options(incompleteRoot, 'zstd', streamingAdapter())
|
|
await writeFile(incomplete.sourcePath, Buffer.concat([
|
|
await compressZstdFrame(line(header(0))),
|
|
await compressZstdFrame(JSON.stringify(event0)),
|
|
]))
|
|
await expect(prepareJsonlMigration({ ...incomplete, verifyCurrentFile: vi.fn() }))
|
|
.rejects.toThrow('complete frame contains a torn JSONL record')
|
|
|
|
const tornRoot = await tempRoot()
|
|
const torn = options(tornRoot, 'zstd', streamingAdapter())
|
|
const tornBody = await compressZstdFrame(line(event0) + line(event1))
|
|
await writeFile(torn.sourcePath, Buffer.concat([
|
|
await compressZstdFrame(line(header(0))),
|
|
tornBody.subarray(0, -3),
|
|
]))
|
|
const recovered = await prepareJsonlMigration({
|
|
...torn,
|
|
verifyCurrentFile: verifier(),
|
|
})
|
|
expect(recovered.artifact.events).toEqual([event0, event1])
|
|
await recovered.publish()
|
|
|
|
const emptyTailRoot = await tempRoot()
|
|
const emptyTail = options(emptyTailRoot, 'zstd', streamingAdapter())
|
|
await writeFile(emptyTail.sourcePath, Buffer.concat([
|
|
await compressZstdFrame(line(header(0))),
|
|
tornBody.subarray(0, 8),
|
|
]))
|
|
const withoutTail = await prepareJsonlMigration({
|
|
...emptyTail,
|
|
verifyCurrentFile: verifier(),
|
|
})
|
|
expect(withoutTail.artifact.events).toEqual([])
|
|
await withoutTail.publish()
|
|
})
|
|
|
|
it('checks migration and verification identities exactly', async () => {
|
|
const verifyRoot = await tempRoot()
|
|
const currentPath = generationPath(verifyRoot, SESSION_FORMAT_VERSION, 'none')
|
|
await writeFile(currentPath, line(header(SESSION_FORMAT_VERSION)))
|
|
let statCount = 0
|
|
await expect(createJsonlGenerationTestRuntime({
|
|
fs: { stat: async path => ({ ...await stat(path, { bigint: true }), ctimeNs: BigInt(++statCount) }) },
|
|
}).verify(
|
|
currentPath,
|
|
'none',
|
|
'generation-test',
|
|
0,
|
|
)).rejects.toThrow('changed during verification')
|
|
|
|
const mismatchRoot = await tempRoot()
|
|
const mismatch = options(mismatchRoot, 'none', streamingAdapter())
|
|
await writeFile(mismatch.sourcePath, line(header(0)))
|
|
const mismatched = await prepareJsonlMigration({
|
|
...mismatch,
|
|
verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => ({
|
|
...await verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount),
|
|
digest: 'different',
|
|
}),
|
|
})
|
|
await expect(mismatched.publish()).rejects.toThrow('changed during verification')
|
|
|
|
const current = options(await tempRoot(), 'none', streamingAdapter(), SESSION_FORMAT_VERSION)
|
|
await expect(prepareJsonlMigration({ ...current, verifyCurrentFile: vi.fn() }))
|
|
.rejects.toThrow('requires a historical source')
|
|
|
|
const wrongRoot = await tempRoot()
|
|
const wrongFormat = streamingAdapter()
|
|
const wrong = options(wrongRoot, 'none', {
|
|
...wrongFormat,
|
|
createRestore: headerValue => ({
|
|
header: { ...headerValue, version: 0, isSeeded: false } as SessionFormatArtifact['header'],
|
|
decodeRow: () => {},
|
|
finish: () => ({
|
|
header: { ...headerValue, version: 0, isSeeded: false } as SessionFormatArtifact['header'],
|
|
inheritedEventCount: 0,
|
|
events: [],
|
|
}),
|
|
}),
|
|
})
|
|
await writeFile(wrong.sourcePath, line(header(0)))
|
|
await expect(prepareJsonlMigration({ ...wrong, verifyCurrentFile: vi.fn() }))
|
|
.rejects.toThrow('migration returned v0')
|
|
})
|
|
|
|
it('propagates a streamed compressor write failure through stage cleanup', async () => {
|
|
const failedRoot = await tempRoot()
|
|
const failed = options(failedRoot, 'zstd', streamingAdapter())
|
|
await writeFile(failed.sourcePath, await encodeZstd(0, [event0]))
|
|
let writes = 0
|
|
const failedHandle = {
|
|
writeFile: async () => { if (++writes > 1) throw new Error('write failed') },
|
|
sync: async () => {},
|
|
close: async () => { throw new Error('close failed') },
|
|
} as unknown as FileHandle
|
|
const failedPreparation = await createJsonlGenerationTestRuntime({
|
|
fs: { open: async () => failedHandle },
|
|
}).prepare({
|
|
...failed,
|
|
verifyCurrentFile: vi.fn(),
|
|
})
|
|
await expect(failedPreparation.publish()).rejects.toBeInstanceOf(AggregateError)
|
|
})
|
|
|
|
it('propagates a streamed encoder failure through the Zstandard pipeline', async () => {
|
|
const root = await tempRoot()
|
|
const failure = new Error('event encoder failed')
|
|
const request = options(root, 'zstd', adapter({
|
|
encodeEvent: () => { throw failure },
|
|
}))
|
|
await writeFile(request.sourcePath, await encodeZstd(0, [event0]))
|
|
|
|
const prepared = await prepareJsonlMigration({
|
|
...request,
|
|
verifyCurrentFile: vi.fn(),
|
|
})
|
|
await expect(prepared.publish()).rejects.toBe(failure)
|
|
expect(await readdir(root)).toEqual(['session.jsonl.zstd'])
|
|
})
|
|
|
|
it('publishes a prepared stage through the Windows no-overwrite path', async () => {
|
|
const winRoot = await tempRoot()
|
|
const win = options(winRoot, 'none', streamingAdapter())
|
|
await writeFile(win.sourcePath, line(header(0)))
|
|
const winPrepared = await createJsonlGenerationTestRuntime({
|
|
platform: 'win32',
|
|
publishNewWin32: rename,
|
|
}).prepare({
|
|
...win,
|
|
verifyCurrentFile: verifier(),
|
|
})
|
|
await winPrepared.publish()
|
|
expect(await readFile(win.currentPath, 'utf8')).toContain(`"version":${SESSION_FORMAT_VERSION}`)
|
|
})
|
|
|
|
it('publishes the current generation beside an immutable suffixless v0 source', async () => {
|
|
const root = await tempRoot()
|
|
const request = { ...options(root), signal: new AbortController().signal }
|
|
const source = Buffer.from(line(header(0)) + line(event0))
|
|
await writeFile(request.sourcePath, source)
|
|
|
|
const result = await ensureJsonlGenerationCurrent(request)
|
|
|
|
expect(result).toMatchObject({
|
|
status: 'migrated',
|
|
fromVersion: 0,
|
|
toVersion: SESSION_FORMAT_VERSION,
|
|
path: request.currentPath,
|
|
sourcePath: request.sourcePath,
|
|
})
|
|
expect(await readFile(request.sourcePath)).toEqual(source)
|
|
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
expect((await readdir(root)).sort()).toEqual(['session.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`])
|
|
})
|
|
|
|
it.each(['none', 'zstd'] as const)(
|
|
'validates the selected %s historical header before invoking migration',
|
|
async (compression) => {
|
|
const root = await tempRoot()
|
|
const request = options(root, compression)
|
|
const source = compression === 'zstd'
|
|
? await encodeZstd(0, [event0])
|
|
: Buffer.from(line(header(0)) + line(event0))
|
|
await writeFile(request.sourcePath, source)
|
|
const failure = new Error('selected path does not match source header identity')
|
|
const base = adapter()
|
|
const createRestore = vi.fn((value: Record<string, unknown>) => base.createRestore(value))
|
|
const validateHistoricalHeader = vi.fn(() => { throw failure })
|
|
|
|
await expect(ensureJsonlGenerationCurrent({
|
|
...request,
|
|
format: { ...base, createRestore },
|
|
validateHistoricalHeader,
|
|
})).rejects.toBe(failure)
|
|
|
|
expect(validateHistoricalHeader).toHaveBeenCalledWith(expect.objectContaining({ id: 'generation-test' }))
|
|
expect(createRestore).not.toHaveBeenCalled()
|
|
expect(await readFile(request.sourcePath)).toEqual(source)
|
|
expect(await readdir(root)).toEqual([basename(request.sourcePath)])
|
|
},
|
|
)
|
|
|
|
it('awaits asynchronous historical-header validation before migration', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
const order: string[] = []
|
|
const base = adapter()
|
|
|
|
await ensureJsonlGenerationCurrent({
|
|
...request,
|
|
format: adapter({
|
|
createRestore: (value) => {
|
|
order.push('restore')
|
|
return base.createRestore(value)
|
|
},
|
|
}),
|
|
validateHistoricalHeader: async () => {
|
|
await Promise.resolve()
|
|
order.push('validate')
|
|
},
|
|
})
|
|
|
|
expect(order).toEqual(['validate', 'restore'])
|
|
})
|
|
|
|
it('rejects a resolver/header version disagreement before migration', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, line(header(1)) + line(event0))
|
|
|
|
await expect(ensureJsonlGenerationCurrent(request)).rejects.toThrow(
|
|
'source filename identifies v0, but its header identifies v1',
|
|
)
|
|
expect(await readdir(root)).toEqual(['session.jsonl'])
|
|
})
|
|
|
|
it('rejects a malformed version discriminator before migration', async () => {
|
|
const root = await tempRoot()
|
|
const malformed = options(join(root, 'malformed'))
|
|
await mkdir(join(root, 'malformed'))
|
|
await writeFile(malformed.sourcePath, line(header(-1)))
|
|
|
|
await expect(ensureJsonlGenerationCurrent(malformed)).rejects.toThrow(
|
|
'header version is not a non-negative safe integer',
|
|
)
|
|
})
|
|
|
|
it.each([
|
|
[null, 'first line is not a JSON object'],
|
|
[[], 'first line is not a JSON object'],
|
|
[{ ...header(0), version: Number.MAX_SAFE_INTEGER + 1 }, 'header version is not a non-negative safe integer'],
|
|
[{ ...header(0), version: '0' }, 'header version is not a non-negative safe integer'],
|
|
] as const)('rejects malformed physical header %#', async (value, message) => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, line(value))
|
|
|
|
await expect(ensureJsonlGenerationCurrent(request)).rejects.toThrow(message)
|
|
})
|
|
|
|
it('rejects a negative-zero physical version', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, '{"type":"session","version":-0,"id":"generation-test"}\n')
|
|
|
|
await expect(ensureJsonlGenerationCurrent(request)).rejects.toThrow(
|
|
'header version is not a non-negative safe integer',
|
|
)
|
|
})
|
|
|
|
it('distinguishes policy refusal from ordinary and invalid-output migration failures', async () => {
|
|
const root = await tempRoot()
|
|
const blockedRoot = join(root, 'blocked')
|
|
const ordinaryRoot = join(root, 'ordinary')
|
|
const wrongRoot = join(root, 'wrong')
|
|
await mkdir(blockedRoot)
|
|
await mkdir(ordinaryRoot)
|
|
await mkdir(wrongRoot)
|
|
const blocked = new Error('blocked by edge policy')
|
|
const ordinary = new Error('malformed source')
|
|
for (const dir of [blockedRoot, ordinaryRoot, wrongRoot]) {
|
|
await writeFile(generationPath(dir, 0, 'none'), line(header(0)) + line(event0))
|
|
}
|
|
|
|
await expect(ensureJsonlGenerationCurrent(options(blockedRoot, 'none', adapter({
|
|
createRestore: () => { throw blocked },
|
|
isUnsupportedMigrationError: (error): error is Error => error === blocked,
|
|
})))).rejects.toMatchObject({
|
|
name: 'JsonlGenerationUnsupportedMigrationError',
|
|
fromVersion: 0,
|
|
reason: blocked,
|
|
} satisfies Partial<JsonlGenerationUnsupportedMigrationError>)
|
|
await expect(ensureJsonlGenerationCurrent(options(ordinaryRoot, 'none', adapter({
|
|
createRestore: () => { throw ordinary },
|
|
})))).rejects.toBe(ordinary)
|
|
const wrongBase = adapter()
|
|
await expect(ensureJsonlGenerationCurrent(options(wrongRoot, 'none', adapter({
|
|
createRestore: (value) => {
|
|
const restore = wrongBase.createRestore(value)
|
|
return {
|
|
header: { ...restore.header, version: SESSION_FORMAT_VERSION + 1 },
|
|
decodeRow: (row) => { restore.decodeRow(row) },
|
|
finish: () => {
|
|
const artifact = restore.finish()
|
|
return { ...artifact, header: { ...artifact.header, version: SESSION_FORMAT_VERSION + 1 } }
|
|
},
|
|
}
|
|
},
|
|
})))).rejects.toThrow(`format migration returned v${SESSION_FORMAT_VERSION + 1}, expected v${SESSION_FORMAT_VERSION}`)
|
|
expect(await readdir(blockedRoot)).toEqual(['session.jsonl'])
|
|
expect(await readdir(ordinaryRoot)).toEqual(['session.jsonl'])
|
|
expect(await readdir(wrongRoot)).toEqual(['session.jsonl'])
|
|
})
|
|
|
|
it('refuses migration output that JSON cannot encode losslessly', async () => {
|
|
const circular: Record<string, unknown> = {}
|
|
circular['self'] = circular
|
|
for (const [name, value] of [
|
|
['bigint', 1n],
|
|
['circular', circular],
|
|
['undefined', undefined],
|
|
] as const) {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
|
|
await expect(ensureJsonlGenerationCurrent({
|
|
...request,
|
|
format: adapter({
|
|
encodeEvent: () => value as never,
|
|
}),
|
|
})).rejects.toThrow('migrated Session event 0 is not lossless JSON')
|
|
expect(await readdir(root), name).toEqual(['session.jsonl'])
|
|
}
|
|
})
|
|
|
|
it('publishes only the adapter target generation beside the source', async () => {
|
|
const root = await tempRoot()
|
|
const format = adapter()
|
|
const request = options(root, 'none', format)
|
|
const source = Buffer.from(line(header(0)) + line(event0))
|
|
await writeFile(request.sourcePath, source)
|
|
const sourceBefore = await stat(request.sourcePath, { bigint: true })
|
|
|
|
await ensureJsonlGenerationCurrent(request)
|
|
|
|
expect(await readFile(request.sourcePath)).toEqual(source)
|
|
const sourceAfter = await stat(request.sourcePath, { bigint: true })
|
|
expect([sourceAfter.dev, sourceAfter.ino]).toEqual([sourceBefore.dev, sourceBefore.ino])
|
|
expect(await readFile(request.currentPath, 'utf8')).toBe(line({ ...header(SESSION_FORMAT_VERSION), isSeeded: false }) + line(event0))
|
|
expect((await readdir(root)).sort()).toEqual(['session.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`])
|
|
})
|
|
|
|
it.each(['none', 'zstd'] as const)(
|
|
'uses one immutable publication algorithm for %s',
|
|
async (compression) => {
|
|
const root = await tempRoot()
|
|
const request = options(root, compression)
|
|
const source = compression === 'zstd'
|
|
? await encodeZstd(0, [event0])
|
|
: Buffer.from(line(header(0)) + line(event0))
|
|
await writeFile(request.sourcePath, source)
|
|
const sourceBefore = await stat(request.sourcePath, { bigint: true })
|
|
|
|
await ensureJsonlGenerationCurrent(request)
|
|
|
|
expect(await readFile(request.sourcePath)).toEqual(source)
|
|
const sourceAfter = await stat(request.sourcePath, { bigint: true })
|
|
const current = await stat(request.currentPath, { bigint: true })
|
|
expect([sourceAfter.dev, sourceAfter.ino]).toEqual([sourceBefore.dev, sourceBefore.ino])
|
|
expect([current.dev, current.ino]).not.toEqual([sourceBefore.dev, sourceBefore.ino])
|
|
const currentText = compression === 'zstd'
|
|
? await decodeZstdJsonl(request.currentPath)
|
|
: await readFile(request.currentPath, 'utf8')
|
|
expect(currentText).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
},
|
|
)
|
|
|
|
it('handles header-only and torn-tail historical Zstandard generations', async () => {
|
|
const root = await tempRoot()
|
|
const headerRoot = join(root, 'header')
|
|
const emptyTailRoot = join(root, 'empty-tail')
|
|
const tornRoot = join(root, 'torn')
|
|
await mkdir(headerRoot)
|
|
await mkdir(emptyTailRoot)
|
|
await mkdir(tornRoot)
|
|
const headerRequest = options(headerRoot, 'zstd')
|
|
const emptyTailRequest = options(emptyTailRoot, 'zstd')
|
|
const tornRequest = options(tornRoot, 'zstd')
|
|
const headerFrame = await compressZstdFrame(line(header(0)))
|
|
const eventFrame = await compressZstdFrame(line(event0))
|
|
const recoveredFrame = await compressZstdFrame(line(event1))
|
|
await writeFile(headerRequest.sourcePath, headerFrame)
|
|
await writeFile(emptyTailRequest.sourcePath, Buffer.concat([headerFrame, eventFrame.subarray(0, 8)]))
|
|
await writeFile(tornRequest.sourcePath, Buffer.concat([headerFrame, eventFrame, recoveredFrame.subarray(0, -3)]))
|
|
|
|
await ensureJsonlGenerationCurrent(headerRequest)
|
|
await ensureJsonlGenerationCurrent(emptyTailRequest)
|
|
await ensureJsonlGenerationCurrent(tornRequest)
|
|
|
|
expect(await decodeZstdJsonl(headerRequest.currentPath)).toBe(line(header(SESSION_FORMAT_VERSION)))
|
|
expect(await decodeZstdJsonl(emptyTailRequest.currentPath)).toBe(line(header(SESSION_FORMAT_VERSION)))
|
|
expect(await decodeZstdJsonl(tornRequest.currentPath)).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0) + line(event1))
|
|
})
|
|
|
|
it('rejects header-less raw and Zstandard sources and a non-independent Zstandard header frame', async () => {
|
|
const root = await tempRoot()
|
|
const rawRoot = join(root, 'raw')
|
|
const emptyZstdRoot = join(root, 'empty-zstd')
|
|
const joinedZstdRoot = join(root, 'joined-zstd')
|
|
await mkdir(rawRoot)
|
|
await mkdir(emptyZstdRoot)
|
|
await mkdir(joinedZstdRoot)
|
|
const raw = options(rawRoot)
|
|
const emptyZstd = options(emptyZstdRoot, 'zstd')
|
|
const joinedZstd = options(joinedZstdRoot, 'zstd')
|
|
await writeFile(raw.sourcePath, JSON.stringify(header(0)))
|
|
await writeFile(emptyZstd.sourcePath, Buffer.alloc(0))
|
|
await writeFile(joinedZstd.sourcePath, await compressZstdFrame(line(header(0)) + line(event0)))
|
|
|
|
await expect(ensureJsonlGenerationCurrent(raw)).rejects.toThrow('empty or header-less session log')
|
|
await expect(ensureJsonlGenerationCurrent(emptyZstd)).rejects.toThrow(
|
|
'empty or header-less Zstandard session log',
|
|
)
|
|
await expect(ensureJsonlGenerationCurrent(joinedZstd)).rejects.toThrow(
|
|
'first frame is not exactly one header line',
|
|
)
|
|
})
|
|
|
|
it('rejects a complete Zstandard frame whose final JSONL record is torn', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root, 'zstd')
|
|
await writeFile(request.sourcePath, Buffer.concat([
|
|
await compressZstdFrame(line(header(0))),
|
|
await compressZstdFrame(JSON.stringify(event0)),
|
|
]))
|
|
|
|
await expect(ensureJsonlGenerationCurrent(request)).rejects.toThrow(
|
|
'complete frame contains a torn JSONL record',
|
|
)
|
|
expect(await readdir(root)).toEqual(['session.jsonl.zstd'])
|
|
})
|
|
|
|
it('drops an uncommitted corrupt raw suffix but refuses corruption before a committed turn end', async () => {
|
|
const root = await tempRoot()
|
|
const droppedRoot = join(root, 'dropped')
|
|
const refusedRoot = join(root, 'refused')
|
|
await mkdir(droppedRoot)
|
|
await mkdir(refusedRoot)
|
|
const dropped = options(droppedRoot)
|
|
const refused = options(refusedRoot)
|
|
const incomplete = line(header(0)) + line(event0) + '{not-json}\n' + line({ type: 'step/start', seq: 1 })
|
|
const committed = line(header(0)) + line(event0) + '{not-json}\n' + line(event1)
|
|
await writeFile(dropped.sourcePath, incomplete)
|
|
await writeFile(refused.sourcePath, committed)
|
|
|
|
await ensureJsonlGenerationCurrent(dropped)
|
|
await expect(ensureJsonlGenerationCurrent(refused)).rejects.toThrow('row 2 is not valid JSON')
|
|
|
|
expect(await readFile(dropped.sourcePath, 'utf8')).toBe(incomplete)
|
|
expect(await readFile(dropped.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
expect(await readFile(refused.sourcePath, 'utf8')).toBe(committed)
|
|
expect(await readdir(refusedRoot)).toEqual(['session.jsonl'])
|
|
})
|
|
|
|
it('drops a byte-torn raw suffix without altering the source', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const source = Buffer.from(line(header(0)) + line(event0) + '{"type":"turn/end"')
|
|
await writeFile(request.sourcePath, source)
|
|
|
|
await ensureJsonlGenerationCurrent(request)
|
|
|
|
expect(await readFile(request.sourcePath)).toEqual(source)
|
|
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
})
|
|
|
|
it('validates canonical lowercase generation filenames and one shared directory', async () => {
|
|
const root = await tempRoot()
|
|
const other = join(root, 'other')
|
|
await mkdir(other)
|
|
const source = line(header(0))
|
|
const cases = [
|
|
{
|
|
request: { ...options(root), sourcePath: join(root, 'session.v0.jsonl') },
|
|
message: 'source path must end with "session.jsonl"',
|
|
},
|
|
{
|
|
request: { ...options(root), currentPath: join(root, `session.V${SESSION_FORMAT_VERSION}.jsonl`) },
|
|
message: `current JSONL generation path must end with "session.v${SESSION_FORMAT_VERSION}.jsonl"`,
|
|
},
|
|
{
|
|
request: { ...options(root), currentPath: generationPath(other, SESSION_FORMAT_VERSION, 'none') },
|
|
message: 'must share one Session directory',
|
|
},
|
|
]
|
|
await writeFile(generationPath(root, 0, 'none'), source)
|
|
|
|
for (const { request, message } of cases) {
|
|
await expect(ensureJsonlGenerationCurrent(request)).rejects.toThrow(message)
|
|
}
|
|
})
|
|
|
|
it('bounds a bracketed physical read and does not rerun migration after a publication race', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const first = Buffer.from(line(header(0)) + line(event0))
|
|
const second = Buffer.from(line(header(0)) + line(event0) + line(event1))
|
|
await writeFile(request.sourcePath, first)
|
|
let stats = 0
|
|
const statFile = async (path: string) => {
|
|
const value = await stat(path, { bigint: true })
|
|
if (path !== request.sourcePath) return value
|
|
stats += 1
|
|
return stats === 2 ? { ...value, mtimeNs: value.mtimeNs + 1n } : value
|
|
}
|
|
const base = adapter()
|
|
const createRestore = vi.fn((value: Record<string, unknown>) => base.createRestore(value))
|
|
const barrier = vi.fn(async (phase: string, attempt: number) => {
|
|
if (phase === 'before-source-check' && attempt === 1) await writeFile(request.sourcePath, second)
|
|
})
|
|
|
|
await expect(ensureWithOverrides(
|
|
{ ...request, format: { ...base, createRestore } },
|
|
{ fs: { stat: statFile }, barrier },
|
|
)).rejects.toBeInstanceOf(JsonlGenerationSourceChangedError)
|
|
|
|
expect(stats).toBeGreaterThan(2)
|
|
expect(createRestore).toHaveBeenCalledOnce()
|
|
expect(await readFile(request.sourcePath)).toEqual(second)
|
|
await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
|
|
})
|
|
|
|
it('surfaces stage cleanup failure when a changed source discards an attempt', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const first = Buffer.from(line(header(0)) + line(event0))
|
|
const second = Buffer.from(line(header(0)) + line(event0) + line(event1))
|
|
await writeFile(request.sourcePath, first)
|
|
const cleanup = new Error('discarded stage cleanup failed')
|
|
const barrier = async (phase: string, attempt: number) => {
|
|
if (phase === 'before-source-check' && attempt === 1) await writeFile(request.sourcePath, second)
|
|
}
|
|
|
|
const failure = await ensureWithOverrides(request, {
|
|
barrier,
|
|
fs: {
|
|
rm: async (path: string) => {
|
|
if (path.includes('.tmp')) throw cleanup
|
|
await rm(path, { force: true })
|
|
},
|
|
},
|
|
}).then(() => undefined, (error: unknown) => error)
|
|
if (!(failure instanceof AggregateError)) throw new Error('expected source and cleanup failures')
|
|
expect(failure.errors[0]).toBeInstanceOf(JsonlGenerationSourceChangedError)
|
|
expect(failure.errors[1]).toBe(cleanup)
|
|
expect(await readFile(request.sourcePath)).toEqual(second)
|
|
await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
})
|
|
|
|
it('never overwrites a colliding exclusive stage name', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const collision = join(root, 'session.migration.collision.jsonl.tmp')
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
await writeFile(collision, 'owned-by-another-attempt\n')
|
|
const randomToken = vi.fn().mockReturnValueOnce('collision').mockReturnValue('stage')
|
|
|
|
await ensureWithOverrides(request, { randomToken })
|
|
|
|
expect(randomToken).toHaveBeenCalledTimes(2)
|
|
expect(await readFile(collision, 'utf8')).toBe('owned-by-another-attempt\n')
|
|
expect((await readdir(root)).sort()).toEqual([
|
|
'session.jsonl',
|
|
'session.migration.collision.jsonl.tmp',
|
|
`session.v${SESSION_FORMAT_VERSION}.jsonl`,
|
|
])
|
|
})
|
|
|
|
it('accepts an identical regular target created by another migration', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const source = Buffer.from(line(header(0)) + line(event0))
|
|
const current = Buffer.from(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
await writeFile(request.sourcePath, source)
|
|
await writeFile(request.currentPath, current)
|
|
|
|
const result = await ensureJsonlGenerationCurrent(request)
|
|
|
|
expect(result).toMatchObject({ status: 'migrated', path: request.currentPath })
|
|
expect(await readFile(request.sourcePath)).toEqual(source)
|
|
expect(await readFile(request.currentPath)).toEqual(current)
|
|
expect((await readdir(root)).sort()).toEqual(['session.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`])
|
|
})
|
|
|
|
it.each(['none', 'zstd'] as const)(
|
|
'accepts a valid append on a %s target created by another migration',
|
|
async (compression) => {
|
|
const root = await tempRoot()
|
|
const request = options(root, compression)
|
|
const source = compression === 'zstd'
|
|
? await encodeZstd(0, [event0])
|
|
: Buffer.from(line(header(0)) + line(event0))
|
|
const expected = compression === 'zstd'
|
|
? await encodeZstd(SESSION_FORMAT_VERSION, [event0])
|
|
: Buffer.from(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
const appended = compression === 'zstd'
|
|
? await compressZstdFrame(line(event1))
|
|
: Buffer.from(line(event1))
|
|
const winner = Buffer.concat([expected, appended])
|
|
await writeFile(request.sourcePath, source)
|
|
await writeFile(request.currentPath, winner)
|
|
|
|
const result = await ensureJsonlGenerationCurrent(request)
|
|
|
|
expect(result).toMatchObject({ status: 'migrated', path: request.currentPath })
|
|
expect(result.snapshot.bytes).toEqual(winner)
|
|
expect(await readFile(request.currentPath)).toEqual(winner)
|
|
},
|
|
)
|
|
|
|
it('accepts an identical regular hardlink target', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const expected = join(root, 'expected.jsonl')
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
await writeFile(expected, line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
await link(expected, request.currentPath)
|
|
|
|
await expect(ensureJsonlGenerationCurrent(request)).resolves.toMatchObject({ path: request.currentPath })
|
|
expect(await readFile(expected, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
})
|
|
|
|
it.each(['different', 'malformed', 'symlink', 'directory'] as const)(
|
|
'fails loud without altering a colliding %s target',
|
|
async (kind) => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const source = Buffer.from(line(header(0)) + line(event0))
|
|
await writeFile(request.sourcePath, source)
|
|
if (kind === 'different') await writeFile(request.currentPath, line(header(SESSION_FORMAT_VERSION)) + line(event1))
|
|
if (kind === 'malformed') await writeFile(request.currentPath, '{not-json}\n')
|
|
if (kind === 'symlink') await symlink(request.sourcePath, request.currentPath)
|
|
if (kind === 'directory') await mkdir(request.currentPath)
|
|
|
|
await expect(ensureJsonlGenerationCurrent(request)).rejects.toBeInstanceOf(
|
|
JsonlGenerationTargetConflictError,
|
|
)
|
|
|
|
expect(await readFile(request.sourcePath)).toEqual(source)
|
|
expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
|
|
},
|
|
)
|
|
|
|
it('normalizes a non-Error rejection while reopening an existing target', async () => {
|
|
const root = await tempRoot()
|
|
let validations = 0
|
|
const format = adapter()
|
|
const request = {
|
|
...options(root, 'none', format),
|
|
verifyCurrentFile: async (...args: Parameters<PrepareJsonlMigrationOptions['verifyCurrentFile']>) => {
|
|
validations += 1
|
|
if (validations === 2) throw 'non-error rejection'
|
|
return verifier()(...args)
|
|
},
|
|
}
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
await writeFile(request.currentPath, line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
|
|
const failure = await ensureJsonlGenerationCurrent(request).then(
|
|
() => undefined,
|
|
(error: unknown) => error,
|
|
)
|
|
if (!(failure instanceof JsonlGenerationTargetConflictError)) throw new Error('expected target conflict')
|
|
expect(failure.reason.message).toBe('current-generation validation failed with a non-Error rejection')
|
|
})
|
|
|
|
it('retains a POSIX publication after the directory sync fails', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const directorySyncFailure = new Error('published directory sync failed')
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
let directorySyncs = 0
|
|
const openFile = async (path: string, flags: string, mode?: number) => {
|
|
const handle = await openWithPosixDirectorySync(path, flags, mode)
|
|
if (path === root && flags === 'r') {
|
|
directorySyncs += 1
|
|
if (directorySyncs === 1) vi.spyOn(handle, 'sync').mockRejectedValueOnce(directorySyncFailure)
|
|
}
|
|
return handle
|
|
}
|
|
|
|
await expect(ensureWithOverrides(
|
|
request,
|
|
{ platform: 'darwin', fs: { open: openFile } },
|
|
)).rejects.toBe(directorySyncFailure)
|
|
expect((await readdir(root)).sort()).toEqual(['session.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`])
|
|
|
|
await expect(ensureJsonlGenerationCurrent(request)).resolves.toMatchObject({ path: request.currentPath })
|
|
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
})
|
|
|
|
it('retains a committed generation when its post-publication stat fails', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const statFailure = new Error('published target stat failed')
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
let targetStats = 0
|
|
|
|
await expect(ensureWithOverrides(request, {
|
|
fs: {
|
|
stat: async (path) => {
|
|
if (path === request.currentPath && ++targetStats === 1) throw statFailure
|
|
return stat(path, { bigint: true })
|
|
},
|
|
},
|
|
})).rejects.toBe(statFailure)
|
|
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
|
|
})
|
|
|
|
it('finishes a committed publication despite later caller cancellation', async () => {
|
|
const root = await tempRoot()
|
|
const controller = new AbortController()
|
|
const reason = new Error('stop after publication')
|
|
const request = { ...options(root), signal: controller.signal }
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
|
|
await expect(ensureWithOverrides(request, {
|
|
barrier: (phase) => {
|
|
if (phase === 'after-publication') controller.abort(reason)
|
|
},
|
|
})).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
|
|
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
})
|
|
|
|
it('rejects a noncanonical case-insensitive collision instead of accepting its bytes', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
|
|
const failure = await ensureWithOverrides(request, {
|
|
platform: 'darwin',
|
|
fs: posixSimulationFs({
|
|
link: async () => { throw fsError('EEXIST') },
|
|
readdir: async () => [`session.V${SESSION_FORMAT_VERSION}.jsonl`],
|
|
}),
|
|
}).then(() => undefined, (error: unknown) => error)
|
|
|
|
if (!(failure instanceof JsonlGenerationTargetConflictError)) throw new Error('expected target conflict')
|
|
expect(failure.reason.message).toContain(`noncanonical directory entry "session.V${SESSION_FORMAT_VERSION}.jsonl"`)
|
|
expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
|
|
})
|
|
|
|
it('preserves ENOENT when an exclusive-publication winner disappears', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
|
|
await expect(ensureWithOverrides(request, {
|
|
platform: 'darwin',
|
|
fs: posixSimulationFs({ link: async () => { throw fsError('EEXIST') } }),
|
|
})).rejects.toMatchObject({ code: 'ENOENT', path: request.currentPath })
|
|
})
|
|
|
|
it('does not reopen a target after exclusive publication', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
const reads: string[] = []
|
|
|
|
await expect(ensureWithOverrides(request, {
|
|
fs: {
|
|
readFile: async (path, signal) => {
|
|
reads.push(path)
|
|
return readFile(path, signal === undefined ? undefined : { signal })
|
|
},
|
|
},
|
|
})).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
|
|
expect(reads).not.toContain(request.currentPath)
|
|
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
})
|
|
|
|
it('leaves a crash-style staging file inert', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const crashStage = join(root, 'session.migration.crash.jsonl.tmp')
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
await writeFile(crashStage, line(header(99)))
|
|
|
|
await ensureJsonlGenerationCurrent(request)
|
|
|
|
expect(await readFile(crashStage, 'utf8')).toBe(line(header(99)))
|
|
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
})
|
|
|
|
it('removes an exclusively created stage when writing or syncing it fails', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
let injected = false
|
|
const openFile = async (path: string, flags: string, mode?: number) => {
|
|
const handle = await open(path, flags, mode)
|
|
if (!injected && path.includes('.tmp')) {
|
|
injected = true
|
|
vi.spyOn(handle, 'sync').mockRejectedValueOnce(new Error('simulated stage fsync failure'))
|
|
}
|
|
return handle
|
|
}
|
|
|
|
await expect(ensureWithOverrides(request, { fs: { open: openFile } })).rejects.toThrow(
|
|
'simulated stage fsync failure',
|
|
)
|
|
expect(await readdir(root)).toEqual(['session.jsonl'])
|
|
})
|
|
|
|
it.each(['open', 'close', 'write-close'] as const)(
|
|
'surfaces %s stage failures without leaving a stage',
|
|
async (mode) => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
const openFile = async (path: string, flags: string, fileMode?: number) => {
|
|
if (mode === 'open' && flags === 'wx') throw fsError('EACCES', 'stage open denied')
|
|
const handle = await open(path, flags, fileMode)
|
|
if (mode === 'write-close' && path.includes('.tmp')) {
|
|
vi.spyOn(handle, 'sync').mockRejectedValueOnce(new Error('stage write failed'))
|
|
}
|
|
if (mode !== 'open' && path.includes('.tmp')) {
|
|
const close = handle.close.bind(handle)
|
|
vi.spyOn(handle, 'close').mockImplementationOnce(async () => {
|
|
await close()
|
|
throw new Error('stage close failed')
|
|
})
|
|
}
|
|
return handle
|
|
}
|
|
|
|
await expect(ensureWithOverrides(request, { fs: { open: openFile } })).rejects.toThrow(
|
|
mode === 'open'
|
|
? 'stage open denied'
|
|
: mode === 'close'
|
|
? 'stage close failed'
|
|
: 'failed to write and close migration stage',
|
|
)
|
|
expect(await readdir(root)).toEqual(['session.jsonl'])
|
|
},
|
|
)
|
|
|
|
it.each([false, true])('normalizes a non-Error stage failure (cleanup fails: %s)', async (cleanupFails) => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
const openFile = async (path: string, flags: string, mode?: number) => {
|
|
const handle = await open(path, flags, mode)
|
|
if (path.includes('.tmp')) vi.spyOn(handle, 'sync').mockRejectedValueOnce('non-error failure')
|
|
return handle
|
|
}
|
|
const removeFile = async (path: string) => {
|
|
if (cleanupFails && path.includes('.tmp')) throw new Error('stage cleanup failed')
|
|
await rm(path, { force: true })
|
|
}
|
|
|
|
await expect(ensureWithOverrides(
|
|
request,
|
|
{ fs: { open: openFile, rm: removeFile } },
|
|
)).rejects.toThrow(cleanupFails
|
|
? 'failed to clean migration temporary'
|
|
: 'migration stage write failed with a non-Error rejection')
|
|
})
|
|
|
|
it('preserves a publication failure when temporary cleanup also fails', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const publication = new Error('exclusive publication failed')
|
|
const cleanup = new Error('stage cleanup failed')
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
const removeFile = async (path: string) => {
|
|
if (path.includes('.tmp')) throw cleanup
|
|
await rm(path, { force: true })
|
|
}
|
|
|
|
const failure = await ensureWithOverrides(
|
|
request,
|
|
{
|
|
platform: 'darwin',
|
|
fs: posixSimulationFs({
|
|
link: async () => { throw publication },
|
|
rm: removeFile,
|
|
}),
|
|
},
|
|
).then(() => undefined, (error: unknown) => error)
|
|
|
|
if (!(failure instanceof AggregateError)) throw new Error('expected an aggregate cleanup failure')
|
|
expect(failure.errors).toEqual([publication, cleanup])
|
|
expect(await readFile(request.sourcePath, 'utf8')).toBe(line(header(0)) + line(event0))
|
|
})
|
|
|
|
it('reports success after exclusive publication when redundant stage cleanup fails', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const cleanup = new Error('published stage cleanup failed')
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
|
|
await expect(ensureWithOverrides(request, {
|
|
platform: 'darwin',
|
|
fs: posixSimulationFs({
|
|
rm: async (path: string) => {
|
|
if (path.includes('.tmp')) throw cleanup
|
|
await rm(path, { force: true })
|
|
},
|
|
}),
|
|
})).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
|
|
|
|
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
})
|
|
|
|
it('surfaces candidate validation errors and cleanup errors without publishing', async () => {
|
|
const root = await tempRoot()
|
|
const request = {
|
|
...options(root),
|
|
verifyCurrentFile: async () => { throw new Error('candidate validation failed') },
|
|
}
|
|
const cleanup = new Error('cleanup failed')
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
|
|
const failure = await ensureWithOverrides(request, {
|
|
fs: {
|
|
rm: async () => { throw cleanup },
|
|
},
|
|
}).then(() => undefined, (error: unknown) => error)
|
|
|
|
if (!(failure instanceof AggregateError)) throw new Error('expected aggregate validation cleanup failure')
|
|
expect(failure.errors[0]).toMatchObject({ message: 'candidate validation failed' })
|
|
expect(failure.errors[1]).toBe(cleanup)
|
|
await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
})
|
|
|
|
it.each(['torn', 'old', 'invalid-json'] as const)(
|
|
'rejects a %s staged candidate before publication',
|
|
async (mode) => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
const readFileForStage = async (path: string, signal?: AbortSignal) => {
|
|
const bytes = await readFile(path, signal === undefined ? undefined : { signal })
|
|
if (!path.includes('.tmp')) return bytes
|
|
if (mode === 'torn') return bytes.subarray(0, -1)
|
|
if (mode === 'old') return Buffer.from(line(header(0)) + line(event0))
|
|
return Buffer.from(line(header(SESSION_FORMAT_VERSION)) + '{not-json}\n')
|
|
}
|
|
|
|
await expect(ensureWithOverrides(
|
|
request,
|
|
{ fs: { readFile: readFileForStage } },
|
|
)).rejects.toThrow(
|
|
mode === 'torn'
|
|
? 'current session generation has a torn physical tail'
|
|
: mode === 'old'
|
|
? `uses log format v0, older than the supported v${SESSION_FORMAT_VERSION}`
|
|
: 'unparsable committed event at line 1',
|
|
)
|
|
expect(await readdir(root)).toEqual(['session.jsonl'])
|
|
},
|
|
)
|
|
|
|
it('uses Windows write-through exclusive publication without replacing the source', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const source = Buffer.from(line(header(0)) + line(event0))
|
|
await writeFile(request.sourcePath, source)
|
|
const publishNewWin32 = vi.fn(async (from: string, to: string) => { await rename(from, to) })
|
|
|
|
await ensureWithOverrides(request, { platform: 'win32', publishNewWin32 })
|
|
|
|
expect(publishNewWin32).toHaveBeenCalledOnce()
|
|
expect(publishNewWin32.mock.calls[0]?.[1]).toBe(request.currentPath)
|
|
expect(await readFile(request.sourcePath)).toEqual(source)
|
|
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
})
|
|
|
|
it('accepts an identical target that wins Windows publication', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
const publishNewWin32 = vi.fn(async (_from: string, to: string) => {
|
|
await writeFile(to, line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
throw fsError('EEXIST')
|
|
})
|
|
|
|
await expect(ensureWithOverrides(
|
|
request,
|
|
{ platform: 'win32', publishNewWin32 },
|
|
)).resolves.toMatchObject({ path: request.currentPath })
|
|
expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
|
|
})
|
|
|
|
it('propagates non-collision Windows and POSIX publication failures', async () => {
|
|
for (const platform of ['win32', 'darwin'] as const) {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
const failure = new Error(`${platform} publication failed`)
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
|
|
await expect(ensureWithOverrides(request, platform === 'win32'
|
|
? { platform, publishNewWin32: async () => { throw failure } }
|
|
: { platform, fs: posixSimulationFs({ link: async () => { throw failure } }) }))
|
|
.rejects.toBe(failure)
|
|
expect(await readdir(root)).toEqual(['session.jsonl'])
|
|
}
|
|
})
|
|
|
|
it('accepts an identical target that wins POSIX publication', async () => {
|
|
const root = await tempRoot()
|
|
const request = options(root)
|
|
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
|
let raced = false
|
|
const linkFile = async (existingPath: string, newPath: string) => {
|
|
await link(existingPath, newPath)
|
|
raced = true
|
|
throw fsError('EEXIST')
|
|
}
|
|
|
|
await ensureWithOverrides(
|
|
request,
|
|
{ platform: 'darwin', fs: posixSimulationFs({ link: linkFile }) },
|
|
)
|
|
|
|
expect(raced).toBe(true)
|
|
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
|
})
|
|
|
|
it('honors cancellation before reading a generation', async () => {
|
|
const root = await tempRoot()
|
|
const controller = new AbortController()
|
|
const request = { ...options(root), signal: controller.signal }
|
|
await writeFile(request.sourcePath, line(header(0)))
|
|
controller.abort(new Error('cancelled migration'))
|
|
|
|
await expect(ensureJsonlGenerationCurrent(request)).rejects.toThrow('cancelled migration')
|
|
expect(await readdir(root)).toEqual(['session.jsonl'])
|
|
})
|
|
})
|