generation.spec.ts 67 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { createHash } from 'node:crypto'
  3. import {
  4. link,
  5. mkdir,
  6. mkdtemp,
  7. open,
  8. readFile,
  9. readdir,
  10. rename,
  11. rm,
  12. stat,
  13. symlink,
  14. writeFile,
  15. type FileHandle,
  16. } from 'node:fs/promises'
  17. import { tmpdir } from 'node:os'
  18. import { basename, join } from 'node:path'
  19. import { performance } from 'node:perf_hooks'
  20. import {
  21. JsonlGenerationSourceChangedError,
  22. JsonlGenerationTargetConflictError,
  23. JsonlGenerationUnsupportedMigrationError,
  24. prepareJsonlMigration,
  25. verifyJsonlCurrentGeneration,
  26. type JsonlGenerationFormatAdapter,
  27. type PrepareJsonlMigrationOptions,
  28. } from '../src/generation.ts'
  29. import { createJsonlGenerationTestRuntime } from '../src/testing/generation.ts'
  30. import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
  31. import type { JsonlCompression } from '../src/format.ts'
  32. import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
  33. import type {
  34. SessionFormatArtifact,
  35. SessionFormatEvent,
  36. SessionFormatJsonValue,
  37. SessionFormatRestore,
  38. } from '@deepseek-ai/dsh-session-format'
  39. const roots: string[] = []
  40. async function tempRoot(): Promise<string> {
  41. const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-generation-'))
  42. roots.push(root)
  43. return root
  44. }
  45. afterEach(async () => {
  46. vi.restoreAllMocks()
  47. for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
  48. })
  49. function line(value: unknown): string {
  50. return `${JSON.stringify(value)}\n`
  51. }
  52. function fsError(code: string, message = code): NodeJS.ErrnoException {
  53. const error = new Error(message) as NodeJS.ErrnoException
  54. error.code = code
  55. return error
  56. }
  57. /** Complete a POSIX-branch simulation on Windows, whose NTFS directory handles reject fsync. */
  58. async function openWithPosixDirectorySync(path: string, flags: string, mode?: number) {
  59. const handle = await open(path, flags, mode)
  60. if (flags === 'r' && (await stat(path)).isDirectory()) {
  61. vi.spyOn(handle, 'sync').mockResolvedValue(undefined)
  62. }
  63. return handle
  64. }
  65. function posixSimulationFs<T extends Record<string, unknown>>(
  66. overrides: T,
  67. ): T & { readonly open: typeof openWithPosixDirectorySync } {
  68. return { open: openWithPosixDirectorySync, ...overrides }
  69. }
  70. function header(version: number, id = 'generation-test'): Record<string, unknown> {
  71. return {
  72. type: 'session', version, id, createdAt: 1, delegationDepth: 0,
  73. ...(version >= 2 ? { isSeeded: false } : {}),
  74. }
  75. }
  76. const event0 = { type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } }
  77. const event1 = { type: 'turn/end', seq: 1, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }
  78. const assistantUsage = { inputTokens: 3, outputTokens: 2 }
  79. const assistantReplayState = { response: { id: 'response' } }
  80. function assistantData(
  81. overrides: {
  82. readonly content?: readonly SessionFormatJsonValue[]
  83. readonly stream?: SessionFormatJsonValue
  84. readonly usage?: SessionFormatJsonValue
  85. readonly replayState?: SessionFormatJsonValue
  86. readonly interrupted?: true
  87. } = {},
  88. ): SessionFormatJsonValue {
  89. const replayState = overrides.replayState === undefined
  90. ? assistantReplayState
  91. : overrides.replayState
  92. return {
  93. turn: 1,
  94. step: 1,
  95. message: {
  96. id: 'assistant',
  97. role: 'assistant',
  98. content: overrides.content ?? [{ type: 'text', text: 'hello' }],
  99. source: {
  100. kind: 'model', provider: 'mock', model: 'mock',
  101. ...(replayState === null ? {} : { replayState }),
  102. },
  103. },
  104. stream: overrides.stream ?? [
  105. { type: 'text-chunks', time0: 3, index: 0, dt: [], texts: ['hello'] },
  106. { type: 'chunk', time: 4, chunk: { type: 'usage', usage: assistantUsage } },
  107. { type: 'chunk', time: 5, chunk: { type: 'finish', reason: { kind: 'stop' }, replayState: assistantReplayState } },
  108. ],
  109. ...(overrides.usage === null ? {} : { usage: overrides.usage ?? assistantUsage }),
  110. ...(overrides.interrupted === undefined ? {} : { interrupted: overrides.interrupted }),
  111. }
  112. }
  113. function assistantLifecycle(
  114. type: 'assistant/message' | 'assistant/attempt',
  115. data: SessionFormatJsonValue,
  116. ): SessionFormatEvent[] {
  117. return [
  118. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  119. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  120. {
  121. type,
  122. seq: 2,
  123. time: 5,
  124. data,
  125. ...(type === 'assistant/message' ? { surfaceOp: 'append' as const } : {}),
  126. },
  127. { type: 'step/end', seq: 3, time: 6, data: { turn: 1, step: 1 } },
  128. { type: 'turn/end', seq: 4, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
  129. ]
  130. }
  131. interface TestGenerationFormatAdapter extends JsonlGenerationFormatAdapter {
  132. createRestore(header: Record<string, unknown>): SessionFormatRestore
  133. }
  134. function adapter(overrides: Partial<TestGenerationFormatAdapter> = {}): TestGenerationFormatAdapter {
  135. const currentVersion = overrides.currentVersion ?? 3
  136. return {
  137. currentVersion,
  138. createRestore(headerValue) {
  139. const events: SessionFormatArtifact['events'][number][] = []
  140. const header = {
  141. ...headerValue,
  142. version: currentVersion,
  143. isSeeded: false,
  144. } as SessionFormatArtifact['header']
  145. return {
  146. header,
  147. decodeRow(row) { events.push(row as SessionFormatArtifact['events'][number]) },
  148. finish: () => ({ header, inheritedEventCount: 0, events }),
  149. }
  150. },
  151. encodeHeader(value) {
  152. const { isSeeded: _isSeeded, ...header } = value
  153. return currentVersion === 0 ? header : {
  154. ...header,
  155. type: 'session',
  156. version: currentVersion,
  157. ...(currentVersion >= 2 ? { isSeeded: value.isSeeded } : {}),
  158. }
  159. },
  160. encodeEvent: event => event,
  161. ...overrides,
  162. }
  163. }
  164. function catalogAdapter(): JsonlGenerationFormatAdapter {
  165. return {
  166. currentVersion: sessionFormatCatalog.currentVersion,
  167. createRestore: header => sessionFormatCatalog.createRestore(header, {
  168. recovery: 'recoverable', validation: 'transformed',
  169. }),
  170. encodeHeader: (header, inheritedEventCount) =>
  171. sessionFormatCatalog.encodeCurrentHeader(header, inheritedEventCount),
  172. encodeEvent: event => sessionFormatCatalog.encodeCurrentEvent(event),
  173. }
  174. }
  175. function streamingAdapter(): JsonlGenerationFormatAdapter & {
  176. createRestore(header: Record<string, unknown>): SessionFormatRestore
  177. } {
  178. return adapter()
  179. }
  180. function verifier(): PrepareJsonlMigrationOptions['verifyCurrentFile'] {
  181. return (path, compression, expectedId, expectedEventCount, expectedPrefix) =>
  182. verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount, expectedPrefix)
  183. }
  184. const byteVerifier: PrepareJsonlMigrationOptions['verifyCurrentFile'] = async (path) => {
  185. const [bytes, identity] = await Promise.all([readFile(path), stat(path, { bigint: true })])
  186. return {
  187. identity,
  188. bytes: bytes.length,
  189. digest: createHash('sha256').update(bytes).digest('hex'),
  190. }
  191. }
  192. function generationPath(root: string, version: number, compression: JsonlCompression): string {
  193. const suffix = compression === 'zstd' ? '.jsonl.zstd' : '.jsonl'
  194. return join(root, version === 0 ? `session${suffix}` : `session.v${version}${suffix}`)
  195. }
  196. function options(
  197. root: string,
  198. compression: JsonlCompression = 'none',
  199. format: JsonlGenerationFormatAdapter = adapter(),
  200. sourceVersion = 0,
  201. ): Omit<PrepareJsonlMigrationOptions, 'verifyCurrentFile'> {
  202. return {
  203. sourcePath: generationPath(root, sourceVersion, compression),
  204. sourceVersion,
  205. currentPath: generationPath(root, format.currentVersion, compression),
  206. compression,
  207. format,
  208. }
  209. }
  210. type TestMigrationOptions = ReturnType<typeof options> & {
  211. readonly signal?: AbortSignal
  212. readonly verifyCurrentFile?: PrepareJsonlMigrationOptions['verifyCurrentFile']
  213. }
  214. type TestGenerationOverrides = Parameters<typeof createJsonlGenerationTestRuntime>[0]
  215. async function ensureWithOverrides(
  216. request: TestMigrationOptions,
  217. overrides: TestGenerationOverrides,
  218. ) {
  219. const runtime = createJsonlGenerationTestRuntime(overrides)
  220. const verifyCurrentFile = request.verifyCurrentFile ?? (
  221. (path: string, compression: JsonlCompression, expectedId: string, expectedEventCount: number, expectedPrefix) =>
  222. runtime.verify(
  223. path,
  224. compression,
  225. expectedId,
  226. expectedEventCount,
  227. expectedPrefix,
  228. )
  229. )
  230. const prepared = await runtime.prepare({
  231. ...request,
  232. verifyCurrentFile,
  233. })
  234. const identity = await prepared.publish()
  235. const bytes = await readFile(request.currentPath)
  236. return {
  237. status: 'migrated' as const,
  238. fromVersion: request.sourceVersion,
  239. toVersion: request.format.currentVersion,
  240. path: request.currentPath,
  241. sourcePath: request.sourcePath,
  242. snapshot: { identity, bytes },
  243. }
  244. }
  245. function ensureJsonlGenerationCurrent(request: TestMigrationOptions) {
  246. return ensureWithOverrides(request, {})
  247. }
  248. async function encodeZstd(version: number, rows: readonly unknown[]): Promise<Buffer> {
  249. return Buffer.concat([
  250. await compressZstdFrame(line(header(version))),
  251. ...rows.length === 0
  252. ? []
  253. : [await compressZstdFrame(rows.map(row => line(row)).join(''))],
  254. ])
  255. }
  256. async function decodeZstdJsonl(path: string): Promise<string> {
  257. const bytes = await readFile(path)
  258. const { frames, tornStart } = scanZstdFrames(bytes)
  259. expect(tornStart).toBeUndefined()
  260. const plaintext: Buffer[] = []
  261. for (const frame of frames) plaintext.push(await decompressZstdFrame(bytes.subarray(frame.start, frame.end)))
  262. return Buffer.concat(plaintext).toString('utf8')
  263. }
  264. describe('JSONL immutable generation publication', () => {
  265. it('refuses V2 messages without surface markers before writing a V3 successor', async () => {
  266. const root = await tempRoot()
  267. const request = options(root, 'none', catalogAdapter(), 2)
  268. const events = assistantLifecycle('assistant/message', assistantData())
  269. .map(({ surfaceOp: _surfaceOp, ...event }) => event)
  270. const source = Buffer.from(line(header(2)) + events.map(line).join(''))
  271. await writeFile(request.sourcePath, source)
  272. await expect(ensureJsonlGenerationCurrent(request))
  273. .rejects.toThrow('assistant/message requires surfaceOp')
  274. expect(await readFile(request.sourcePath)).toEqual(source)
  275. await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  276. expect(await readdir(root)).toEqual(['session.v2.jsonl'])
  277. })
  278. it('refuses contradictory V2 tool error metadata without publishing or modifying the source', async () => {
  279. const root = await tempRoot()
  280. const request = options(root, 'none', catalogAdapter(), 2)
  281. const events: SessionFormatEvent[] = [
  282. ...assistantLifecycle('assistant/message', assistantData({
  283. content: [{ type: 'tool-call', id: 'call', name: 'test', arguments: '{}' }],
  284. stream: [], usage: null, replayState: null,
  285. })).slice(0, 3),
  286. { type: 'tool/call', seq: 3, time: 6,
  287. data: { turn: 1, step: 1, callId: 'call', name: 'test', arguments: '{}' } },
  288. { type: 'tool/result', seq: 4, time: 7, surfaceOp: 'append', data: {
  289. turn: 1, step: 1,
  290. message: { id: 'result', role: 'user', source: { kind: 'tool', callId: 'call' },
  291. content: [{ type: 'tool-result', toolCallId: 'call', isError: false,
  292. content: [{ type: 'text', text: 'success' }] }] },
  293. error: { name: 'ToolError', code: 'FAILED' },
  294. } },
  295. { type: 'step/end', seq: 5, time: 8, data: { turn: 1, step: 1 } },
  296. { type: 'turn/end', seq: 6, time: 9, data: { turn: 1, reason: { kind: 'completed' } } },
  297. ]
  298. const source = Buffer.from(line(header(2)) + events.map(line).join(''))
  299. await writeFile(request.sourcePath, source)
  300. await expect(ensureJsonlGenerationCurrent(request))
  301. .rejects.toThrow('tool/result at seq 5 carries error metadata for a non-error tool result')
  302. expect(await readFile(request.sourcePath)).toEqual(source)
  303. await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  304. expect(await readdir(root)).toEqual(['session.v2.jsonl'])
  305. })
  306. it('publishes canonical V3 replacements and headers while retaining exact V2 bytes', async () => {
  307. const root = await tempRoot()
  308. const request = options(root, 'none', catalogAdapter(), 2)
  309. const config = { provider: 'mock', model: 'mock' }
  310. const events: SessionFormatEvent[] = [
  311. event0,
  312. { type: 'step/start', seq: 1, time: 3, data: { turn: 1, step: 1 } },
  313. { type: 'user/message', seq: 2, time: 4, surfaceOp: 'append', data: {
  314. id: 'input', role: 'user', content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
  315. } },
  316. { type: 'user/message', seq: 3, time: 5,
  317. surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2], data: {
  318. id: 'summary', role: 'user', content: [{ type: 'text', text: 'summary' }],
  319. source: { kind: 'plugin', plugin: 'summary-fixture' },
  320. } },
  321. { type: 'request/header', seq: 4, time: 6, data: {
  322. header: { config, system: '', tools: [], adapterDefaults: {} }, reason: 'initial',
  323. } },
  324. { type: 'step/end', seq: 5, time: 7, data: { turn: 1, step: 1 } },
  325. { type: 'turn/end', seq: 6, time: 8, data: { turn: 1, reason: { kind: 'completed' } } },
  326. ]
  327. const source = Buffer.from(line(header(2)) + events.map(line).join(''))
  328. await writeFile(request.sourcePath, source)
  329. const prepared = await prepareJsonlMigration({ ...request, verifyCurrentFile: verifier() })
  330. const canonical: unknown[] = [
  331. events[0], events[1],
  332. expect.objectContaining({ type: 'system/message', seq: 2, surfaceOp: 'append', data: expect.objectContaining({ message: expect.objectContaining({ role: 'system', content: [] }) as unknown }) as unknown }) as unknown,
  333. ...events.slice(2).map(event => event.seq === 3
  334. ? { ...event, seq: 4, surfaceOp: { op: 'replace', startSeq: 3, endSeq: 3 }, sourceEventSeqs: [3] }
  335. : event.seq === 4 ? { ...event, seq: 5, data: { header: { config }, reason: 'initial' } } : { ...event, seq: event.seq + 1 }),
  336. ]
  337. expect(prepared.artifact.events).toEqual(canonical)
  338. await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  339. await prepared.publish()
  340. expect(await readFile(request.sourcePath)).toEqual(source)
  341. const written = (await readFile(request.currentPath, 'utf8')).trimEnd().split('\n')
  342. .map(row => JSON.parse(row) as unknown)
  343. expect(written).toEqual([header(3), ...canonical])
  344. expect((await readdir(root)).sort()).toEqual(['session.v2.jsonl', 'session.v3.jsonl'])
  345. })
  346. it('returns migrated events while publication is still waiting for verification', async () => {
  347. const root = await tempRoot()
  348. const request = options(root, 'none', streamingAdapter())
  349. const boundaryBase = { ...event0, data: { turn: 1, text: '' } }
  350. const boundaryEvent = {
  351. ...boundaryBase,
  352. data: { ...boundaryBase.data, text: 'x'.repeat(1024 * 1024 - JSON.stringify(boundaryBase).length) },
  353. }
  354. const largeEvent = { ...event0, seq: 1, data: { turn: 1, text: 'y'.repeat(1024 * 1024) } }
  355. const finalEvent = { ...event1, seq: 2 }
  356. await writeFile(request.sourcePath, line(header(0)) + line(boundaryEvent) + line(largeEvent) + line(finalEvent))
  357. let now = 0
  358. vi.spyOn(performance, 'now').mockImplementation(() => now += 600)
  359. const entered = Promise.withResolvers<undefined>()
  360. const release = Promise.withResolvers<undefined>()
  361. const prepared = await prepareJsonlMigration({
  362. ...request,
  363. verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => {
  364. entered.resolve(undefined)
  365. await release.promise
  366. return verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount)
  367. },
  368. })
  369. expect(prepared.artifact.events).toEqual([boundaryEvent, largeEvent, finalEvent])
  370. const publication = prepared.publish()
  371. expect(prepared.publish()).toBe(publication)
  372. await entered.promise
  373. await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  374. release.resolve(undefined)
  375. await publication
  376. const [writtenHeader, ...writtenEvents] = (await readFile(request.currentPath, 'utf8')).trimEnd().split('\n')
  377. expect(JSON.parse(writtenHeader as string)).toEqual({ ...header(3), isSeeded: false })
  378. expect(writtenEvents.map(row => JSON.parse(row) as unknown)).toEqual([boundaryEvent, largeEvent, finalEvent])
  379. })
  380. it('batches exact-threshold encoded rows without retaining a final partial write', async () => {
  381. const root = await tempRoot()
  382. const mib = 1024 * 1024
  383. const widths = [4 * mib - 3, mib - 3, mib - 3, mib - 3, mib - 3]
  384. const format = adapter({
  385. encodeEvent: event => 'x'.repeat(widths[event.seq] as number),
  386. })
  387. const request = options(root, 'none', format)
  388. const events = widths.map((_, seq) => ({ ...event0, seq }))
  389. await writeFile(request.sourcePath, line(header(0)) + events.map(line).join(''))
  390. const prepared = await prepareJsonlMigration({
  391. ...request,
  392. verifyCurrentFile: byteVerifier,
  393. })
  394. await prepared.publish()
  395. expect((await stat(request.currentPath)).size).toBeGreaterThan(8 * mib)
  396. })
  397. it('fails publication without rerunning migration when the source changes', async () => {
  398. const root = await tempRoot()
  399. const base = streamingAdapter()
  400. const sourceStreams = vi.fn()
  401. const request = options(root, 'none', {
  402. ...base,
  403. createRestore: (value) => {
  404. if (value.version === 0) sourceStreams()
  405. return base.createRestore(value)
  406. },
  407. })
  408. const source = line(header(0)) + line(event0)
  409. await writeFile(request.sourcePath, source)
  410. const prepared = await prepareJsonlMigration({
  411. ...request,
  412. verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => {
  413. const verified = await verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount)
  414. await writeFile(request.sourcePath, source + line(event1))
  415. return verified
  416. },
  417. })
  418. await expect(prepared.publish()).rejects.toBeInstanceOf(JsonlGenerationSourceChangedError)
  419. expect(sourceStreams).toHaveBeenCalledOnce()
  420. await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  421. })
  422. it('refuses malformed streaming inputs before publication', async () => {
  423. const root = await tempRoot()
  424. const request = options(root, 'none', streamingAdapter())
  425. await writeFile(request.sourcePath, '')
  426. await expect(prepareJsonlMigration({ ...request, verifyCurrentFile: vi.fn() }))
  427. .rejects.toThrow('empty or header-less')
  428. await writeFile(request.sourcePath, line(header(1)))
  429. await expect(prepareJsonlMigration({ ...request, verifyCurrentFile: vi.fn() }))
  430. .rejects.toThrow(/filename identifies v0.*header identifies v1/)
  431. await writeFile(request.sourcePath, line(header(0)) + '{bad json}\n' + line(event1))
  432. await expect(prepareJsonlMigration({ ...request, verifyCurrentFile: vi.fn() }))
  433. .rejects.toThrow('row 1 is not valid JSON')
  434. await writeFile(request.sourcePath, line(header(0)) + '{bad json}\n' + line(event0))
  435. const dropped = await prepareJsonlMigration({
  436. ...request,
  437. verifyCurrentFile: verifier(),
  438. })
  439. expect(dropped.artifact.events).toEqual([])
  440. await dropped.publish()
  441. })
  442. it('verifies exact current identity, completeness, and event count', async () => {
  443. const root = await tempRoot()
  444. const path = generationPath(root, 3, 'none')
  445. await writeFile(path, line({ ...header(3), isSeeded: false }) + line(event0))
  446. await expect(verifyJsonlCurrentGeneration(path, 'none', 'other', 1))
  447. .rejects.toThrow('expected "other"')
  448. await expect(verifyJsonlCurrentGeneration(path, 'none', 'generation-test', 2))
  449. .rejects.toThrow('contains 1 events')
  450. await writeFile(path, line({ ...header(3), isSeeded: false }) + JSON.stringify(event0))
  451. await expect(verifyJsonlCurrentGeneration(path, 'none', 'generation-test', 1))
  452. .rejects.toThrow('torn physical tail')
  453. await writeFile(path, Buffer.alloc(0))
  454. await expect(verifyJsonlCurrentGeneration(path, 'none', 'generation-test', 0))
  455. .rejects.toThrow('empty or header-less')
  456. await expect(verifyJsonlCurrentGeneration(path, 'zstd', 'generation-test', 0))
  457. .rejects.toThrow('empty or header-less Zstandard')
  458. const headerFrame = await compressZstdFrame(line({ ...header(3), isSeeded: false }))
  459. await writeFile(path, Buffer.concat([headerFrame, await compressZstdFrame(JSON.stringify(event0))]))
  460. await expect(verifyJsonlCurrentGeneration(path, 'zstd', 'generation-test', 1))
  461. .rejects.toThrow('torn physical tail')
  462. await writeFile(path, Buffer.concat([
  463. headerFrame,
  464. (await compressZstdFrame(line(event0))).subarray(0, -3),
  465. ]))
  466. await expect(verifyJsonlCurrentGeneration(path, 'zstd', 'generation-test', 1))
  467. .rejects.toThrow('torn physical tail')
  468. })
  469. it('keeps complete Assistant stream checks in current-generation verification', async () => {
  470. const root = await tempRoot()
  471. const path = generationPath(root, 3, 'none')
  472. const verify = async (events: readonly SessionFormatEvent[]) => {
  473. await writeFile(path, line(header(3)) + events.map(line).join(''))
  474. return verifyJsonlCurrentGeneration(path, 'none', 'generation-test', events.length)
  475. }
  476. const valid = [
  477. assistantLifecycle('assistant/message', assistantData()),
  478. assistantLifecycle('assistant/message', assistantData({
  479. interrupted: true,
  480. stream: [{ type: 'text-chunks', time0: 3, index: 0, dt: [], texts: ['hello'] }],
  481. usage: null,
  482. replayState: null,
  483. })),
  484. assistantLifecycle('assistant/message', assistantData({
  485. content: [], stream: [], usage: null, replayState: null,
  486. })),
  487. assistantLifecycle('assistant/attempt', {
  488. turn: 1,
  489. step: 1,
  490. stream: [{ type: 'text-chunks', time0: 3, index: 0, dt: [1], texts: ['a', 'b'] }],
  491. }),
  492. ]
  493. for (const events of valid) expect((await verify(events)).bytes).toBeGreaterThan(0)
  494. await expect(verify(assistantLifecycle('assistant/attempt', {
  495. turn: 1, step: 1, stream: [{ type: 'future' }],
  496. }))).rejects.toThrow(/invalid embedded stream/)
  497. await expect(verify(assistantLifecycle('assistant/message', assistantData({
  498. content: [{ type: 'text', text: 'different' }],
  499. })))).rejects.toThrow(/content disagrees/)
  500. await expect(verify(assistantLifecycle('assistant/message', assistantData({
  501. usage: { inputTokens: 9, outputTokens: 2 },
  502. })))).rejects.toThrow(/usage disagrees/)
  503. await expect(verify(assistantLifecycle('assistant/message', assistantData({
  504. replayState: { response: { id: 'different' } },
  505. })))).rejects.toThrow(/replay state disagrees/)
  506. })
  507. it('accepts an identical publication winner and rejects different bytes', async () => {
  508. const identicalRoot = await tempRoot()
  509. const identical = options(identicalRoot, 'none', streamingAdapter())
  510. await writeFile(identical.sourcePath, line(header(0)) + line(event0))
  511. const prepared = await prepareJsonlMigration({
  512. ...identical,
  513. verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => {
  514. const verified = await verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount)
  515. if (path !== identical.currentPath) await link(path, identical.currentPath)
  516. return verified
  517. },
  518. })
  519. expect((await prepared.publish()).size).toBeGreaterThan(0n)
  520. const differentRoot = await tempRoot()
  521. const different = options(differentRoot, 'none', streamingAdapter())
  522. await writeFile(different.sourcePath, line(header(0)) + line(event0))
  523. await writeFile(different.currentPath, line({ ...header(3), isSeeded: false }) + line({ ...event0, time: 99 }))
  524. const conflicted = await prepareJsonlMigration({
  525. ...different,
  526. verifyCurrentFile: verifier(),
  527. })
  528. await expect(conflicted.publish()).rejects.toBeInstanceOf(JsonlGenerationTargetConflictError)
  529. const uncheckedRoot = await tempRoot()
  530. const unchecked = options(uncheckedRoot, 'none', streamingAdapter())
  531. await writeFile(unchecked.sourcePath, line(header(0)) + line(event0))
  532. await writeFile(
  533. unchecked.currentPath,
  534. line({ ...header(3), isSeeded: false }) + line({ ...event0, time: 99 }),
  535. )
  536. const uncheckedPublication = await prepareJsonlMigration({
  537. ...unchecked,
  538. verifyCurrentFile: byteVerifier,
  539. })
  540. await expect(uncheckedPublication.publish())
  541. .rejects.toThrow(/target bytes differ from the migrated generation/)
  542. })
  543. it('handles empty, incomplete-record, and torn Zstandard migration sources', async () => {
  544. const emptyRoot = await tempRoot()
  545. const empty = options(emptyRoot, 'zstd', streamingAdapter())
  546. await writeFile(empty.sourcePath, Buffer.alloc(0))
  547. await expect(prepareJsonlMigration({ ...empty, verifyCurrentFile: vi.fn() }))
  548. .rejects.toThrow('empty or header-less Zstandard')
  549. const incompleteRoot = await tempRoot()
  550. const incomplete = options(incompleteRoot, 'zstd', streamingAdapter())
  551. await writeFile(incomplete.sourcePath, Buffer.concat([
  552. await compressZstdFrame(line(header(0))),
  553. await compressZstdFrame(JSON.stringify(event0)),
  554. ]))
  555. await expect(prepareJsonlMigration({ ...incomplete, verifyCurrentFile: vi.fn() }))
  556. .rejects.toThrow('complete frame contains a torn JSONL record')
  557. const tornRoot = await tempRoot()
  558. const torn = options(tornRoot, 'zstd', streamingAdapter())
  559. const tornBody = await compressZstdFrame(line(event0) + line(event1))
  560. await writeFile(torn.sourcePath, Buffer.concat([
  561. await compressZstdFrame(line(header(0))),
  562. tornBody.subarray(0, -3),
  563. ]))
  564. const recovered = await prepareJsonlMigration({
  565. ...torn,
  566. verifyCurrentFile: verifier(),
  567. })
  568. expect(recovered.artifact.events).toEqual([event0, event1])
  569. await recovered.publish()
  570. const emptyTailRoot = await tempRoot()
  571. const emptyTail = options(emptyTailRoot, 'zstd', streamingAdapter())
  572. await writeFile(emptyTail.sourcePath, Buffer.concat([
  573. await compressZstdFrame(line(header(0))),
  574. tornBody.subarray(0, 8),
  575. ]))
  576. const withoutTail = await prepareJsonlMigration({
  577. ...emptyTail,
  578. verifyCurrentFile: verifier(),
  579. })
  580. expect(withoutTail.artifact.events).toEqual([])
  581. await withoutTail.publish()
  582. })
  583. it('checks migration and verification identities exactly', async () => {
  584. const verifyRoot = await tempRoot()
  585. const currentPath = generationPath(verifyRoot, 3, 'none')
  586. await writeFile(currentPath, line(header(3)))
  587. let statCount = 0
  588. await expect(createJsonlGenerationTestRuntime({
  589. fs: { stat: async path => ({ ...await stat(path, { bigint: true }), ctimeNs: BigInt(++statCount) }) },
  590. }).verify(
  591. currentPath,
  592. 'none',
  593. 'generation-test',
  594. 0,
  595. )).rejects.toThrow('changed during verification')
  596. const mismatchRoot = await tempRoot()
  597. const mismatch = options(mismatchRoot, 'none', streamingAdapter())
  598. await writeFile(mismatch.sourcePath, line(header(0)))
  599. const mismatched = await prepareJsonlMigration({
  600. ...mismatch,
  601. verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => ({
  602. ...await verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount),
  603. digest: 'different',
  604. }),
  605. })
  606. await expect(mismatched.publish()).rejects.toThrow('changed during verification')
  607. const current = options(await tempRoot(), 'none', streamingAdapter(), 3)
  608. await expect(prepareJsonlMigration({ ...current, verifyCurrentFile: vi.fn() }))
  609. .rejects.toThrow('requires a historical source')
  610. const wrongRoot = await tempRoot()
  611. const wrongFormat = streamingAdapter()
  612. const wrong = options(wrongRoot, 'none', {
  613. ...wrongFormat,
  614. createRestore: headerValue => ({
  615. header: { ...headerValue, version: 0, isSeeded: false } as SessionFormatArtifact['header'],
  616. decodeRow: () => {},
  617. finish: () => ({
  618. header: { ...headerValue, version: 0, isSeeded: false } as SessionFormatArtifact['header'],
  619. inheritedEventCount: 0,
  620. events: [],
  621. }),
  622. }),
  623. })
  624. await writeFile(wrong.sourcePath, line(header(0)))
  625. await expect(prepareJsonlMigration({ ...wrong, verifyCurrentFile: vi.fn() }))
  626. .rejects.toThrow('migration returned v0')
  627. })
  628. it('propagates a streamed compressor write failure through stage cleanup', async () => {
  629. const failedRoot = await tempRoot()
  630. const failed = options(failedRoot, 'zstd', streamingAdapter())
  631. await writeFile(failed.sourcePath, await encodeZstd(0, [event0]))
  632. let writes = 0
  633. const failedHandle = {
  634. writeFile: async () => { if (++writes > 1) throw new Error('write failed') },
  635. sync: async () => {},
  636. close: async () => { throw new Error('close failed') },
  637. } as unknown as FileHandle
  638. const failedPreparation = await createJsonlGenerationTestRuntime({
  639. fs: { open: async () => failedHandle },
  640. }).prepare({
  641. ...failed,
  642. verifyCurrentFile: vi.fn(),
  643. })
  644. await expect(failedPreparation.publish()).rejects.toBeInstanceOf(AggregateError)
  645. })
  646. it('propagates a streamed encoder failure through the Zstandard pipeline', async () => {
  647. const root = await tempRoot()
  648. const failure = new Error('event encoder failed')
  649. const request = options(root, 'zstd', adapter({
  650. encodeEvent: () => { throw failure },
  651. }))
  652. await writeFile(request.sourcePath, await encodeZstd(0, [event0]))
  653. const prepared = await prepareJsonlMigration({
  654. ...request,
  655. verifyCurrentFile: vi.fn(),
  656. })
  657. await expect(prepared.publish()).rejects.toBe(failure)
  658. expect(await readdir(root)).toEqual(['session.jsonl.zstd'])
  659. })
  660. it('publishes a prepared stage through the Windows no-overwrite path', async () => {
  661. const winRoot = await tempRoot()
  662. const win = options(winRoot, 'none', streamingAdapter())
  663. await writeFile(win.sourcePath, line(header(0)))
  664. const winPrepared = await createJsonlGenerationTestRuntime({
  665. platform: 'win32',
  666. publishNewWin32: rename,
  667. }).prepare({
  668. ...win,
  669. verifyCurrentFile: verifier(),
  670. })
  671. await winPrepared.publish()
  672. expect(await readFile(win.currentPath, 'utf8')).toContain('"version":3')
  673. })
  674. it('publishes v3 beside an immutable suffixless v0 source', async () => {
  675. const root = await tempRoot()
  676. const request = { ...options(root), signal: new AbortController().signal }
  677. const source = Buffer.from(line(header(0)) + line(event0))
  678. await writeFile(request.sourcePath, source)
  679. const result = await ensureJsonlGenerationCurrent(request)
  680. expect(result).toMatchObject({
  681. status: 'migrated',
  682. fromVersion: 0,
  683. toVersion: 3,
  684. path: request.currentPath,
  685. sourcePath: request.sourcePath,
  686. })
  687. expect(await readFile(request.sourcePath)).toEqual(source)
  688. expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
  689. expect((await readdir(root)).sort()).toEqual(['session.jsonl', 'session.v3.jsonl'])
  690. })
  691. it.each(['none', 'zstd'] as const)(
  692. 'validates the selected %s historical header before invoking migration',
  693. async (compression) => {
  694. const root = await tempRoot()
  695. const request = options(root, compression)
  696. const source = compression === 'zstd'
  697. ? await encodeZstd(0, [event0])
  698. : Buffer.from(line(header(0)) + line(event0))
  699. await writeFile(request.sourcePath, source)
  700. const failure = new Error('selected path does not match source header identity')
  701. const base = adapter()
  702. const createRestore = vi.fn((value: Record<string, unknown>) => base.createRestore(value))
  703. const validateHistoricalHeader = vi.fn(() => { throw failure })
  704. await expect(ensureJsonlGenerationCurrent({
  705. ...request,
  706. format: { ...base, createRestore },
  707. validateHistoricalHeader,
  708. })).rejects.toBe(failure)
  709. expect(validateHistoricalHeader).toHaveBeenCalledWith(expect.objectContaining({ id: 'generation-test' }))
  710. expect(createRestore).not.toHaveBeenCalled()
  711. expect(await readFile(request.sourcePath)).toEqual(source)
  712. expect(await readdir(root)).toEqual([basename(request.sourcePath)])
  713. },
  714. )
  715. it('awaits asynchronous historical-header validation before migration', async () => {
  716. const root = await tempRoot()
  717. const request = options(root)
  718. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  719. const order: string[] = []
  720. const base = adapter()
  721. await ensureJsonlGenerationCurrent({
  722. ...request,
  723. format: adapter({
  724. createRestore: (value) => {
  725. order.push('restore')
  726. return base.createRestore(value)
  727. },
  728. }),
  729. validateHistoricalHeader: async () => {
  730. await Promise.resolve()
  731. order.push('validate')
  732. },
  733. })
  734. expect(order).toEqual(['validate', 'restore'])
  735. })
  736. it('rejects a resolver/header version disagreement before migration', async () => {
  737. const root = await tempRoot()
  738. const request = options(root)
  739. await writeFile(request.sourcePath, line(header(1)) + line(event0))
  740. await expect(ensureJsonlGenerationCurrent(request)).rejects.toThrow(
  741. 'source filename identifies v0, but its header identifies v1',
  742. )
  743. expect(await readdir(root)).toEqual(['session.jsonl'])
  744. })
  745. it('rejects a malformed version discriminator before migration', async () => {
  746. const root = await tempRoot()
  747. const malformed = options(join(root, 'malformed'))
  748. await mkdir(join(root, 'malformed'))
  749. await writeFile(malformed.sourcePath, line(header(-1)))
  750. await expect(ensureJsonlGenerationCurrent(malformed)).rejects.toThrow(
  751. 'header version is not a non-negative safe integer',
  752. )
  753. })
  754. it.each([
  755. [null, 'first line is not a JSON object'],
  756. [[], 'first line is not a JSON object'],
  757. [{ ...header(0), version: Number.MAX_SAFE_INTEGER + 1 }, 'header version is not a non-negative safe integer'],
  758. [{ ...header(0), version: '0' }, 'header version is not a non-negative safe integer'],
  759. ] as const)('rejects malformed physical header %#', async (value, message) => {
  760. const root = await tempRoot()
  761. const request = options(root)
  762. await writeFile(request.sourcePath, line(value))
  763. await expect(ensureJsonlGenerationCurrent(request)).rejects.toThrow(message)
  764. })
  765. it('rejects a negative-zero physical version', async () => {
  766. const root = await tempRoot()
  767. const request = options(root)
  768. await writeFile(request.sourcePath, '{"type":"session","version":-0,"id":"generation-test"}\n')
  769. await expect(ensureJsonlGenerationCurrent(request)).rejects.toThrow(
  770. 'header version is not a non-negative safe integer',
  771. )
  772. })
  773. it('distinguishes policy refusal from ordinary and invalid-output migration failures', async () => {
  774. const root = await tempRoot()
  775. const blockedRoot = join(root, 'blocked')
  776. const ordinaryRoot = join(root, 'ordinary')
  777. const wrongRoot = join(root, 'wrong')
  778. await mkdir(blockedRoot)
  779. await mkdir(ordinaryRoot)
  780. await mkdir(wrongRoot)
  781. const blocked = new Error('blocked by edge policy')
  782. const ordinary = new Error('malformed source')
  783. for (const dir of [blockedRoot, ordinaryRoot, wrongRoot]) {
  784. await writeFile(generationPath(dir, 0, 'none'), line(header(0)) + line(event0))
  785. }
  786. await expect(ensureJsonlGenerationCurrent(options(blockedRoot, 'none', adapter({
  787. createRestore: () => { throw blocked },
  788. isUnsupportedMigrationError: (error): error is Error => error === blocked,
  789. })))).rejects.toMatchObject({
  790. name: 'JsonlGenerationUnsupportedMigrationError',
  791. fromVersion: 0,
  792. reason: blocked,
  793. } satisfies Partial<JsonlGenerationUnsupportedMigrationError>)
  794. await expect(ensureJsonlGenerationCurrent(options(ordinaryRoot, 'none', adapter({
  795. createRestore: () => { throw ordinary },
  796. })))).rejects.toBe(ordinary)
  797. const wrongBase = adapter()
  798. await expect(ensureJsonlGenerationCurrent(options(wrongRoot, 'none', adapter({
  799. createRestore: (value) => {
  800. const restore = wrongBase.createRestore(value)
  801. return {
  802. header: { ...restore.header, version: 4 },
  803. decodeRow: (row) => { restore.decodeRow(row) },
  804. finish: () => {
  805. const artifact = restore.finish()
  806. return { ...artifact, header: { ...artifact.header, version: 4 } }
  807. },
  808. }
  809. },
  810. })))).rejects.toThrow('format migration returned v4, expected v3')
  811. expect(await readdir(blockedRoot)).toEqual(['session.jsonl'])
  812. expect(await readdir(ordinaryRoot)).toEqual(['session.jsonl'])
  813. expect(await readdir(wrongRoot)).toEqual(['session.jsonl'])
  814. })
  815. it('refuses migration output that JSON cannot encode losslessly', async () => {
  816. const circular: Record<string, unknown> = {}
  817. circular['self'] = circular
  818. for (const [name, value] of [
  819. ['bigint', 1n],
  820. ['circular', circular],
  821. ['undefined', undefined],
  822. ] as const) {
  823. const root = await tempRoot()
  824. const request = options(root)
  825. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  826. await expect(ensureJsonlGenerationCurrent({
  827. ...request,
  828. format: adapter({
  829. encodeEvent: () => value as never,
  830. }),
  831. })).rejects.toThrow('migrated Session event 0 is not lossless JSON')
  832. expect(await readdir(root), name).toEqual(['session.jsonl'])
  833. }
  834. })
  835. it('publishes only the adapter target generation beside the source', async () => {
  836. const root = await tempRoot()
  837. const format = adapter()
  838. const request = options(root, 'none', format)
  839. const source = Buffer.from(line(header(0)) + line(event0))
  840. await writeFile(request.sourcePath, source)
  841. const sourceBefore = await stat(request.sourcePath, { bigint: true })
  842. await ensureJsonlGenerationCurrent(request)
  843. expect(await readFile(request.sourcePath)).toEqual(source)
  844. const sourceAfter = await stat(request.sourcePath, { bigint: true })
  845. expect([sourceAfter.dev, sourceAfter.ino]).toEqual([sourceBefore.dev, sourceBefore.ino])
  846. expect(await readFile(request.currentPath, 'utf8')).toBe(line({ ...header(3), isSeeded: false }) + line(event0))
  847. expect((await readdir(root)).sort()).toEqual(['session.jsonl', 'session.v3.jsonl'])
  848. })
  849. it.each(['none', 'zstd'] as const)(
  850. 'uses one immutable publication algorithm for %s',
  851. async (compression) => {
  852. const root = await tempRoot()
  853. const request = options(root, compression)
  854. const source = compression === 'zstd'
  855. ? await encodeZstd(0, [event0])
  856. : Buffer.from(line(header(0)) + line(event0))
  857. await writeFile(request.sourcePath, source)
  858. const sourceBefore = await stat(request.sourcePath, { bigint: true })
  859. await ensureJsonlGenerationCurrent(request)
  860. expect(await readFile(request.sourcePath)).toEqual(source)
  861. const sourceAfter = await stat(request.sourcePath, { bigint: true })
  862. const current = await stat(request.currentPath, { bigint: true })
  863. expect([sourceAfter.dev, sourceAfter.ino]).toEqual([sourceBefore.dev, sourceBefore.ino])
  864. expect([current.dev, current.ino]).not.toEqual([sourceBefore.dev, sourceBefore.ino])
  865. const currentText = compression === 'zstd'
  866. ? await decodeZstdJsonl(request.currentPath)
  867. : await readFile(request.currentPath, 'utf8')
  868. expect(currentText).toBe(line(header(3)) + line(event0))
  869. },
  870. )
  871. it('handles header-only and torn-tail historical Zstandard generations', async () => {
  872. const root = await tempRoot()
  873. const headerRoot = join(root, 'header')
  874. const emptyTailRoot = join(root, 'empty-tail')
  875. const tornRoot = join(root, 'torn')
  876. await mkdir(headerRoot)
  877. await mkdir(emptyTailRoot)
  878. await mkdir(tornRoot)
  879. const headerRequest = options(headerRoot, 'zstd')
  880. const emptyTailRequest = options(emptyTailRoot, 'zstd')
  881. const tornRequest = options(tornRoot, 'zstd')
  882. const headerFrame = await compressZstdFrame(line(header(0)))
  883. const eventFrame = await compressZstdFrame(line(event0))
  884. const recoveredFrame = await compressZstdFrame(line(event1))
  885. await writeFile(headerRequest.sourcePath, headerFrame)
  886. await writeFile(emptyTailRequest.sourcePath, Buffer.concat([headerFrame, eventFrame.subarray(0, 8)]))
  887. await writeFile(tornRequest.sourcePath, Buffer.concat([headerFrame, eventFrame, recoveredFrame.subarray(0, -3)]))
  888. await ensureJsonlGenerationCurrent(headerRequest)
  889. await ensureJsonlGenerationCurrent(emptyTailRequest)
  890. await ensureJsonlGenerationCurrent(tornRequest)
  891. expect(await decodeZstdJsonl(headerRequest.currentPath)).toBe(line(header(3)))
  892. expect(await decodeZstdJsonl(emptyTailRequest.currentPath)).toBe(line(header(3)))
  893. expect(await decodeZstdJsonl(tornRequest.currentPath)).toBe(line(header(3)) + line(event0) + line(event1))
  894. })
  895. it('rejects header-less raw and Zstandard sources and a non-independent Zstandard header frame', async () => {
  896. const root = await tempRoot()
  897. const rawRoot = join(root, 'raw')
  898. const emptyZstdRoot = join(root, 'empty-zstd')
  899. const joinedZstdRoot = join(root, 'joined-zstd')
  900. await mkdir(rawRoot)
  901. await mkdir(emptyZstdRoot)
  902. await mkdir(joinedZstdRoot)
  903. const raw = options(rawRoot)
  904. const emptyZstd = options(emptyZstdRoot, 'zstd')
  905. const joinedZstd = options(joinedZstdRoot, 'zstd')
  906. await writeFile(raw.sourcePath, JSON.stringify(header(0)))
  907. await writeFile(emptyZstd.sourcePath, Buffer.alloc(0))
  908. await writeFile(joinedZstd.sourcePath, await compressZstdFrame(line(header(0)) + line(event0)))
  909. await expect(ensureJsonlGenerationCurrent(raw)).rejects.toThrow('empty or header-less session log')
  910. await expect(ensureJsonlGenerationCurrent(emptyZstd)).rejects.toThrow(
  911. 'empty or header-less Zstandard session log',
  912. )
  913. await expect(ensureJsonlGenerationCurrent(joinedZstd)).rejects.toThrow(
  914. 'first frame is not exactly one header line',
  915. )
  916. })
  917. it('rejects a complete Zstandard frame whose final JSONL record is torn', async () => {
  918. const root = await tempRoot()
  919. const request = options(root, 'zstd')
  920. await writeFile(request.sourcePath, Buffer.concat([
  921. await compressZstdFrame(line(header(0))),
  922. await compressZstdFrame(JSON.stringify(event0)),
  923. ]))
  924. await expect(ensureJsonlGenerationCurrent(request)).rejects.toThrow(
  925. 'complete frame contains a torn JSONL record',
  926. )
  927. expect(await readdir(root)).toEqual(['session.jsonl.zstd'])
  928. })
  929. it('drops an uncommitted corrupt raw suffix but refuses corruption before a committed turn end', async () => {
  930. const root = await tempRoot()
  931. const droppedRoot = join(root, 'dropped')
  932. const refusedRoot = join(root, 'refused')
  933. await mkdir(droppedRoot)
  934. await mkdir(refusedRoot)
  935. const dropped = options(droppedRoot)
  936. const refused = options(refusedRoot)
  937. const incomplete = line(header(0)) + line(event0) + '{not-json}\n' + line({ type: 'step/start', seq: 1 })
  938. const committed = line(header(0)) + line(event0) + '{not-json}\n' + line(event1)
  939. await writeFile(dropped.sourcePath, incomplete)
  940. await writeFile(refused.sourcePath, committed)
  941. await ensureJsonlGenerationCurrent(dropped)
  942. await expect(ensureJsonlGenerationCurrent(refused)).rejects.toThrow('row 2 is not valid JSON')
  943. expect(await readFile(dropped.sourcePath, 'utf8')).toBe(incomplete)
  944. expect(await readFile(dropped.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
  945. expect(await readFile(refused.sourcePath, 'utf8')).toBe(committed)
  946. expect(await readdir(refusedRoot)).toEqual(['session.jsonl'])
  947. })
  948. it('drops a byte-torn raw suffix without altering the source', async () => {
  949. const root = await tempRoot()
  950. const request = options(root)
  951. const source = Buffer.from(line(header(0)) + line(event0) + '{"type":"turn/end"')
  952. await writeFile(request.sourcePath, source)
  953. await ensureJsonlGenerationCurrent(request)
  954. expect(await readFile(request.sourcePath)).toEqual(source)
  955. expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
  956. })
  957. it('validates canonical lowercase generation filenames and one shared directory', async () => {
  958. const root = await tempRoot()
  959. const other = join(root, 'other')
  960. await mkdir(other)
  961. const source = line(header(0))
  962. const cases = [
  963. {
  964. request: { ...options(root), sourcePath: join(root, 'session.v0.jsonl') },
  965. message: 'source path must end with "session.jsonl"',
  966. },
  967. {
  968. request: { ...options(root), currentPath: join(root, 'session.V3.jsonl') },
  969. message: 'current JSONL generation path must end with "session.v3.jsonl"',
  970. },
  971. {
  972. request: { ...options(root), currentPath: generationPath(other, 3, 'none') },
  973. message: 'must share one Session directory',
  974. },
  975. ]
  976. await writeFile(generationPath(root, 0, 'none'), source)
  977. for (const { request, message } of cases) {
  978. await expect(ensureJsonlGenerationCurrent(request)).rejects.toThrow(message)
  979. }
  980. })
  981. it('bounds a bracketed physical read and does not rerun migration after a publication race', async () => {
  982. const root = await tempRoot()
  983. const request = options(root)
  984. const first = Buffer.from(line(header(0)) + line(event0))
  985. const second = Buffer.from(line(header(0)) + line(event0) + line(event1))
  986. await writeFile(request.sourcePath, first)
  987. let stats = 0
  988. const statFile = async (path: string) => {
  989. const value = await stat(path, { bigint: true })
  990. if (path !== request.sourcePath) return value
  991. stats += 1
  992. return stats === 2 ? { ...value, mtimeNs: value.mtimeNs + 1n } : value
  993. }
  994. const base = adapter()
  995. const createRestore = vi.fn((value: Record<string, unknown>) => base.createRestore(value))
  996. const barrier = vi.fn(async (phase: string, attempt: number) => {
  997. if (phase === 'before-source-check' && attempt === 1) await writeFile(request.sourcePath, second)
  998. })
  999. await expect(ensureWithOverrides(
  1000. { ...request, format: { ...base, createRestore } },
  1001. { fs: { stat: statFile }, barrier },
  1002. )).rejects.toBeInstanceOf(JsonlGenerationSourceChangedError)
  1003. expect(stats).toBeGreaterThan(2)
  1004. expect(createRestore).toHaveBeenCalledOnce()
  1005. expect(await readFile(request.sourcePath)).toEqual(second)
  1006. await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  1007. expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
  1008. })
  1009. it('surfaces stage cleanup failure when a changed source discards an attempt', async () => {
  1010. const root = await tempRoot()
  1011. const request = options(root)
  1012. const first = Buffer.from(line(header(0)) + line(event0))
  1013. const second = Buffer.from(line(header(0)) + line(event0) + line(event1))
  1014. await writeFile(request.sourcePath, first)
  1015. const cleanup = new Error('discarded stage cleanup failed')
  1016. const barrier = async (phase: string, attempt: number) => {
  1017. if (phase === 'before-source-check' && attempt === 1) await writeFile(request.sourcePath, second)
  1018. }
  1019. const failure = await ensureWithOverrides(request, {
  1020. barrier,
  1021. fs: {
  1022. rm: async (path: string) => {
  1023. if (path.includes('.tmp')) throw cleanup
  1024. await rm(path, { force: true })
  1025. },
  1026. },
  1027. }).then(() => undefined, (error: unknown) => error)
  1028. if (!(failure instanceof AggregateError)) throw new Error('expected source and cleanup failures')
  1029. expect(failure.errors[0]).toBeInstanceOf(JsonlGenerationSourceChangedError)
  1030. expect(failure.errors[1]).toBe(cleanup)
  1031. expect(await readFile(request.sourcePath)).toEqual(second)
  1032. await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  1033. })
  1034. it('never overwrites a colliding exclusive stage name', async () => {
  1035. const root = await tempRoot()
  1036. const request = options(root)
  1037. const collision = join(root, 'session.migration.collision.jsonl.tmp')
  1038. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1039. await writeFile(collision, 'owned-by-another-attempt\n')
  1040. const randomToken = vi.fn().mockReturnValueOnce('collision').mockReturnValue('stage')
  1041. await ensureWithOverrides(request, { randomToken })
  1042. expect(randomToken).toHaveBeenCalledTimes(2)
  1043. expect(await readFile(collision, 'utf8')).toBe('owned-by-another-attempt\n')
  1044. expect((await readdir(root)).sort()).toEqual([
  1045. 'session.jsonl',
  1046. 'session.migration.collision.jsonl.tmp',
  1047. 'session.v3.jsonl',
  1048. ])
  1049. })
  1050. it('accepts an identical regular target created by another migration', async () => {
  1051. const root = await tempRoot()
  1052. const request = options(root)
  1053. const source = Buffer.from(line(header(0)) + line(event0))
  1054. const current = Buffer.from(line(header(3)) + line(event0))
  1055. await writeFile(request.sourcePath, source)
  1056. await writeFile(request.currentPath, current)
  1057. const result = await ensureJsonlGenerationCurrent(request)
  1058. expect(result).toMatchObject({ status: 'migrated', path: request.currentPath })
  1059. expect(await readFile(request.sourcePath)).toEqual(source)
  1060. expect(await readFile(request.currentPath)).toEqual(current)
  1061. expect((await readdir(root)).sort()).toEqual(['session.jsonl', 'session.v3.jsonl'])
  1062. })
  1063. it.each(['none', 'zstd'] as const)(
  1064. 'accepts a valid append on a %s target created by another migration',
  1065. async (compression) => {
  1066. const root = await tempRoot()
  1067. const request = options(root, compression)
  1068. const source = compression === 'zstd'
  1069. ? await encodeZstd(0, [event0])
  1070. : Buffer.from(line(header(0)) + line(event0))
  1071. const expected = compression === 'zstd'
  1072. ? await encodeZstd(3, [event0])
  1073. : Buffer.from(line(header(3)) + line(event0))
  1074. const appended = compression === 'zstd'
  1075. ? await compressZstdFrame(line(event1))
  1076. : Buffer.from(line(event1))
  1077. const winner = Buffer.concat([expected, appended])
  1078. await writeFile(request.sourcePath, source)
  1079. await writeFile(request.currentPath, winner)
  1080. const result = await ensureJsonlGenerationCurrent(request)
  1081. expect(result).toMatchObject({ status: 'migrated', path: request.currentPath })
  1082. expect(result.snapshot.bytes).toEqual(winner)
  1083. expect(await readFile(request.currentPath)).toEqual(winner)
  1084. },
  1085. )
  1086. it('accepts an identical regular hardlink target', async () => {
  1087. const root = await tempRoot()
  1088. const request = options(root)
  1089. const expected = join(root, 'expected.jsonl')
  1090. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1091. await writeFile(expected, line(header(3)) + line(event0))
  1092. await link(expected, request.currentPath)
  1093. await expect(ensureJsonlGenerationCurrent(request)).resolves.toMatchObject({ path: request.currentPath })
  1094. expect(await readFile(expected, 'utf8')).toBe(line(header(3)) + line(event0))
  1095. })
  1096. it.each(['different', 'malformed', 'symlink', 'directory'] as const)(
  1097. 'fails loud without altering a colliding %s target',
  1098. async (kind) => {
  1099. const root = await tempRoot()
  1100. const request = options(root)
  1101. const source = Buffer.from(line(header(0)) + line(event0))
  1102. await writeFile(request.sourcePath, source)
  1103. if (kind === 'different') await writeFile(request.currentPath, line(header(3)) + line(event1))
  1104. if (kind === 'malformed') await writeFile(request.currentPath, '{not-json}\n')
  1105. if (kind === 'symlink') await symlink(request.sourcePath, request.currentPath)
  1106. if (kind === 'directory') await mkdir(request.currentPath)
  1107. await expect(ensureJsonlGenerationCurrent(request)).rejects.toBeInstanceOf(
  1108. JsonlGenerationTargetConflictError,
  1109. )
  1110. expect(await readFile(request.sourcePath)).toEqual(source)
  1111. expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
  1112. },
  1113. )
  1114. it('normalizes a non-Error rejection while reopening an existing target', async () => {
  1115. const root = await tempRoot()
  1116. let validations = 0
  1117. const format = adapter()
  1118. const request = {
  1119. ...options(root, 'none', format),
  1120. verifyCurrentFile: async (...args: Parameters<PrepareJsonlMigrationOptions['verifyCurrentFile']>) => {
  1121. validations += 1
  1122. if (validations === 2) throw 'non-error rejection'
  1123. return verifier()(...args)
  1124. },
  1125. }
  1126. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1127. await writeFile(request.currentPath, line(header(3)) + line(event0))
  1128. const failure = await ensureJsonlGenerationCurrent(request).then(
  1129. () => undefined,
  1130. (error: unknown) => error,
  1131. )
  1132. if (!(failure instanceof JsonlGenerationTargetConflictError)) throw new Error('expected target conflict')
  1133. expect(failure.reason.message).toBe('current-generation validation failed with a non-Error rejection')
  1134. })
  1135. it('retains a POSIX publication after the directory sync fails', async () => {
  1136. const root = await tempRoot()
  1137. const request = options(root)
  1138. const directorySyncFailure = new Error('published directory sync failed')
  1139. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1140. let directorySyncs = 0
  1141. const openFile = async (path: string, flags: string, mode?: number) => {
  1142. const handle = await openWithPosixDirectorySync(path, flags, mode)
  1143. if (path === root && flags === 'r') {
  1144. directorySyncs += 1
  1145. if (directorySyncs === 1) vi.spyOn(handle, 'sync').mockRejectedValueOnce(directorySyncFailure)
  1146. }
  1147. return handle
  1148. }
  1149. await expect(ensureWithOverrides(
  1150. request,
  1151. { platform: 'darwin', fs: { open: openFile } },
  1152. )).rejects.toBe(directorySyncFailure)
  1153. expect((await readdir(root)).sort()).toEqual(['session.jsonl', 'session.v3.jsonl'])
  1154. await expect(ensureJsonlGenerationCurrent(request)).resolves.toMatchObject({ path: request.currentPath })
  1155. expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
  1156. })
  1157. it('retains a committed generation when its post-publication stat fails', async () => {
  1158. const root = await tempRoot()
  1159. const request = options(root)
  1160. const statFailure = new Error('published target stat failed')
  1161. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1162. let targetStats = 0
  1163. await expect(ensureWithOverrides(request, {
  1164. fs: {
  1165. stat: async (path) => {
  1166. if (path === request.currentPath && ++targetStats === 1) throw statFailure
  1167. return stat(path, { bigint: true })
  1168. },
  1169. },
  1170. })).rejects.toBe(statFailure)
  1171. expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
  1172. expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
  1173. })
  1174. it('finishes a committed publication despite later caller cancellation', async () => {
  1175. const root = await tempRoot()
  1176. const controller = new AbortController()
  1177. const reason = new Error('stop after publication')
  1178. const request = { ...options(root), signal: controller.signal }
  1179. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1180. await expect(ensureWithOverrides(request, {
  1181. barrier: (phase) => {
  1182. if (phase === 'after-publication') controller.abort(reason)
  1183. },
  1184. })).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
  1185. expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
  1186. })
  1187. it('rejects a noncanonical case-insensitive collision instead of accepting its bytes', async () => {
  1188. const root = await tempRoot()
  1189. const request = options(root)
  1190. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1191. const failure = await ensureWithOverrides(request, {
  1192. platform: 'darwin',
  1193. fs: posixSimulationFs({
  1194. link: async () => { throw fsError('EEXIST') },
  1195. readdir: async () => ['session.V3.jsonl'],
  1196. }),
  1197. }).then(() => undefined, (error: unknown) => error)
  1198. if (!(failure instanceof JsonlGenerationTargetConflictError)) throw new Error('expected target conflict')
  1199. expect(failure.reason.message).toContain('noncanonical directory entry "session.V3.jsonl"')
  1200. expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
  1201. })
  1202. it('preserves ENOENT when an exclusive-publication winner disappears', async () => {
  1203. const root = await tempRoot()
  1204. const request = options(root)
  1205. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1206. await expect(ensureWithOverrides(request, {
  1207. platform: 'darwin',
  1208. fs: posixSimulationFs({ link: async () => { throw fsError('EEXIST') } }),
  1209. })).rejects.toMatchObject({ code: 'ENOENT', path: request.currentPath })
  1210. })
  1211. it('does not reopen a target after exclusive publication', async () => {
  1212. const root = await tempRoot()
  1213. const request = options(root)
  1214. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1215. const reads: string[] = []
  1216. await expect(ensureWithOverrides(request, {
  1217. fs: {
  1218. readFile: async (path, signal) => {
  1219. reads.push(path)
  1220. return readFile(path, signal === undefined ? undefined : { signal })
  1221. },
  1222. },
  1223. })).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
  1224. expect(reads).not.toContain(request.currentPath)
  1225. expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
  1226. })
  1227. it('leaves a crash-style staging file inert', async () => {
  1228. const root = await tempRoot()
  1229. const request = options(root)
  1230. const crashStage = join(root, 'session.migration.crash.jsonl.tmp')
  1231. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1232. await writeFile(crashStage, line(header(99)))
  1233. await ensureJsonlGenerationCurrent(request)
  1234. expect(await readFile(crashStage, 'utf8')).toBe(line(header(99)))
  1235. expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
  1236. })
  1237. it('removes an exclusively created stage when writing or syncing it fails', async () => {
  1238. const root = await tempRoot()
  1239. const request = options(root)
  1240. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1241. let injected = false
  1242. const openFile = async (path: string, flags: string, mode?: number) => {
  1243. const handle = await open(path, flags, mode)
  1244. if (!injected && path.includes('.tmp')) {
  1245. injected = true
  1246. vi.spyOn(handle, 'sync').mockRejectedValueOnce(new Error('simulated stage fsync failure'))
  1247. }
  1248. return handle
  1249. }
  1250. await expect(ensureWithOverrides(request, { fs: { open: openFile } })).rejects.toThrow(
  1251. 'simulated stage fsync failure',
  1252. )
  1253. expect(await readdir(root)).toEqual(['session.jsonl'])
  1254. })
  1255. it.each(['open', 'close', 'write-close'] as const)(
  1256. 'surfaces %s stage failures without leaving a stage',
  1257. async (mode) => {
  1258. const root = await tempRoot()
  1259. const request = options(root)
  1260. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1261. const openFile = async (path: string, flags: string, fileMode?: number) => {
  1262. if (mode === 'open' && flags === 'wx') throw fsError('EACCES', 'stage open denied')
  1263. const handle = await open(path, flags, fileMode)
  1264. if (mode === 'write-close' && path.includes('.tmp')) {
  1265. vi.spyOn(handle, 'sync').mockRejectedValueOnce(new Error('stage write failed'))
  1266. }
  1267. if (mode !== 'open' && path.includes('.tmp')) {
  1268. const close = handle.close.bind(handle)
  1269. vi.spyOn(handle, 'close').mockImplementationOnce(async () => {
  1270. await close()
  1271. throw new Error('stage close failed')
  1272. })
  1273. }
  1274. return handle
  1275. }
  1276. await expect(ensureWithOverrides(request, { fs: { open: openFile } })).rejects.toThrow(
  1277. mode === 'open'
  1278. ? 'stage open denied'
  1279. : mode === 'close'
  1280. ? 'stage close failed'
  1281. : 'failed to write and close migration stage',
  1282. )
  1283. expect(await readdir(root)).toEqual(['session.jsonl'])
  1284. },
  1285. )
  1286. it.each([false, true])('normalizes a non-Error stage failure (cleanup fails: %s)', async (cleanupFails) => {
  1287. const root = await tempRoot()
  1288. const request = options(root)
  1289. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1290. const openFile = async (path: string, flags: string, mode?: number) => {
  1291. const handle = await open(path, flags, mode)
  1292. if (path.includes('.tmp')) vi.spyOn(handle, 'sync').mockRejectedValueOnce('non-error failure')
  1293. return handle
  1294. }
  1295. const removeFile = async (path: string) => {
  1296. if (cleanupFails && path.includes('.tmp')) throw new Error('stage cleanup failed')
  1297. await rm(path, { force: true })
  1298. }
  1299. await expect(ensureWithOverrides(
  1300. request,
  1301. { fs: { open: openFile, rm: removeFile } },
  1302. )).rejects.toThrow(cleanupFails
  1303. ? 'failed to clean migration temporary'
  1304. : 'migration stage write failed with a non-Error rejection')
  1305. })
  1306. it('preserves a publication failure when temporary cleanup also fails', async () => {
  1307. const root = await tempRoot()
  1308. const request = options(root)
  1309. const publication = new Error('exclusive publication failed')
  1310. const cleanup = new Error('stage cleanup failed')
  1311. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1312. const removeFile = async (path: string) => {
  1313. if (path.includes('.tmp')) throw cleanup
  1314. await rm(path, { force: true })
  1315. }
  1316. const failure = await ensureWithOverrides(
  1317. request,
  1318. {
  1319. platform: 'darwin',
  1320. fs: posixSimulationFs({
  1321. link: async () => { throw publication },
  1322. rm: removeFile,
  1323. }),
  1324. },
  1325. ).then(() => undefined, (error: unknown) => error)
  1326. if (!(failure instanceof AggregateError)) throw new Error('expected an aggregate cleanup failure')
  1327. expect(failure.errors).toEqual([publication, cleanup])
  1328. expect(await readFile(request.sourcePath, 'utf8')).toBe(line(header(0)) + line(event0))
  1329. })
  1330. it('reports success after exclusive publication when redundant stage cleanup fails', async () => {
  1331. const root = await tempRoot()
  1332. const request = options(root)
  1333. const cleanup = new Error('published stage cleanup failed')
  1334. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1335. await expect(ensureWithOverrides(request, {
  1336. platform: 'darwin',
  1337. fs: posixSimulationFs({
  1338. rm: async (path: string) => {
  1339. if (path.includes('.tmp')) throw cleanup
  1340. await rm(path, { force: true })
  1341. },
  1342. }),
  1343. })).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
  1344. expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
  1345. })
  1346. it('surfaces candidate validation errors and cleanup errors without publishing', async () => {
  1347. const root = await tempRoot()
  1348. const request = {
  1349. ...options(root),
  1350. verifyCurrentFile: async () => { throw new Error('candidate validation failed') },
  1351. }
  1352. const cleanup = new Error('cleanup failed')
  1353. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1354. const failure = await ensureWithOverrides(request, {
  1355. fs: {
  1356. rm: async () => { throw cleanup },
  1357. },
  1358. }).then(() => undefined, (error: unknown) => error)
  1359. if (!(failure instanceof AggregateError)) throw new Error('expected aggregate validation cleanup failure')
  1360. expect(failure.errors[0]).toMatchObject({ message: 'candidate validation failed' })
  1361. expect(failure.errors[1]).toBe(cleanup)
  1362. await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  1363. })
  1364. it.each(['torn', 'old', 'invalid-json'] as const)(
  1365. 'rejects a %s staged candidate before publication',
  1366. async (mode) => {
  1367. const root = await tempRoot()
  1368. const request = options(root)
  1369. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1370. const readFileForStage = async (path: string, signal?: AbortSignal) => {
  1371. const bytes = await readFile(path, signal === undefined ? undefined : { signal })
  1372. if (!path.includes('.tmp')) return bytes
  1373. if (mode === 'torn') return bytes.subarray(0, -1)
  1374. if (mode === 'old') return Buffer.from(line(header(0)) + line(event0))
  1375. return Buffer.from(line(header(3)) + '{not-json}\n')
  1376. }
  1377. await expect(ensureWithOverrides(
  1378. request,
  1379. { fs: { readFile: readFileForStage } },
  1380. )).rejects.toThrow(
  1381. mode === 'torn'
  1382. ? 'current session generation has a torn physical tail'
  1383. : mode === 'old'
  1384. ? 'uses log format v0, older than the supported v3'
  1385. : 'unparsable committed event at line 1',
  1386. )
  1387. expect(await readdir(root)).toEqual(['session.jsonl'])
  1388. },
  1389. )
  1390. it('uses Windows write-through exclusive publication without replacing the source', async () => {
  1391. const root = await tempRoot()
  1392. const request = options(root)
  1393. const source = Buffer.from(line(header(0)) + line(event0))
  1394. await writeFile(request.sourcePath, source)
  1395. const publishNewWin32 = vi.fn(async (from: string, to: string) => { await rename(from, to) })
  1396. await ensureWithOverrides(request, { platform: 'win32', publishNewWin32 })
  1397. expect(publishNewWin32).toHaveBeenCalledOnce()
  1398. expect(publishNewWin32.mock.calls[0]?.[1]).toBe(request.currentPath)
  1399. expect(await readFile(request.sourcePath)).toEqual(source)
  1400. expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
  1401. })
  1402. it('accepts an identical target that wins Windows publication', async () => {
  1403. const root = await tempRoot()
  1404. const request = options(root)
  1405. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1406. const publishNewWin32 = vi.fn(async (_from: string, to: string) => {
  1407. await writeFile(to, line(header(3)) + line(event0))
  1408. throw fsError('EEXIST')
  1409. })
  1410. await expect(ensureWithOverrides(
  1411. request,
  1412. { platform: 'win32', publishNewWin32 },
  1413. )).resolves.toMatchObject({ path: request.currentPath })
  1414. expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
  1415. })
  1416. it('propagates non-collision Windows and POSIX publication failures', async () => {
  1417. for (const platform of ['win32', 'darwin'] as const) {
  1418. const root = await tempRoot()
  1419. const request = options(root)
  1420. const failure = new Error(`${platform} publication failed`)
  1421. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1422. await expect(ensureWithOverrides(request, platform === 'win32'
  1423. ? { platform, publishNewWin32: async () => { throw failure } }
  1424. : { platform, fs: posixSimulationFs({ link: async () => { throw failure } }) }))
  1425. .rejects.toBe(failure)
  1426. expect(await readdir(root)).toEqual(['session.jsonl'])
  1427. }
  1428. })
  1429. it('accepts an identical target that wins POSIX publication', async () => {
  1430. const root = await tempRoot()
  1431. const request = options(root)
  1432. await writeFile(request.sourcePath, line(header(0)) + line(event0))
  1433. let raced = false
  1434. const linkFile = async (existingPath: string, newPath: string) => {
  1435. await link(existingPath, newPath)
  1436. raced = true
  1437. throw fsError('EEXIST')
  1438. }
  1439. await ensureWithOverrides(
  1440. request,
  1441. { platform: 'darwin', fs: posixSimulationFs({ link: linkFile }) },
  1442. )
  1443. expect(raced).toBe(true)
  1444. expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
  1445. })
  1446. it('honors cancellation before reading a generation', async () => {
  1447. const root = await tempRoot()
  1448. const controller = new AbortController()
  1449. const request = { ...options(root), signal: controller.signal }
  1450. await writeFile(request.sourcePath, line(header(0)))
  1451. controller.abort(new Error('cancelled migration'))
  1452. await expect(ensureJsonlGenerationCurrent(request)).rejects.toThrow('cancelled migration')
  1453. expect(await readdir(root)).toEqual(['session.jsonl'])
  1454. })
  1455. })