jsonl.spec.ts 68 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479
  1. import { MessageId, createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { Context } from 'cordis'
  4. import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises'
  5. import { tmpdir } from 'node:os'
  6. import { isAbsolute, join, relative, resolve } from 'node:path'
  7. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  8. import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
  9. import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
  10. import {
  11. encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, SessionLogScanner, toHeaderLine,
  12. } from '../src/format.ts'
  13. import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
  14. import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
  15. const statRace = vi.hoisted(() => ({
  16. path: undefined as string | undefined,
  17. reads: 0,
  18. }))
  19. vi.mock('node:fs/promises', async (importOriginal) => {
  20. const actual = await importOriginal<typeof import('node:fs/promises')>()
  21. return {
  22. ...actual,
  23. stat: (async (...args: Parameters<typeof actual.stat>) => {
  24. const identity = await actual.stat(...args)
  25. if (String(args[0]) !== statRace.path || !('mtimeNs' in identity)) return identity
  26. statRace.reads += 1
  27. if (statRace.reads !== 2) return identity
  28. return { ...identity, mtimeNs: identity.mtimeNs + 1n }
  29. }) as typeof actual.stat,
  30. }
  31. })
  32. let root: string
  33. const dirs: string[] = []
  34. type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
  35. /** Test-only mutable view used to verify that backends detach returned/caller metadata. */
  36. function mutableHeader(header: SessionHeader): MutableSessionHeader {
  37. return header
  38. }
  39. /** Rewrite only a stored header while preserving every event byte below it. */
  40. async function rewriteHeader(path: string, update: (header: Record<string, unknown>) => void): Promise<void> {
  41. const lines = (await readFile(path, 'utf8')).split('\n')
  42. const header = JSON.parse(lines[0] as string) as Record<string, unknown>
  43. update(header)
  44. lines[0] = JSON.stringify(header)
  45. await writeFile(path, lines.join('\n'))
  46. }
  47. async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
  48. try {
  49. await promise
  50. } catch (error) {
  51. expect(error).toBeInstanceOf(Error)
  52. expect((error as Error).message).toMatch(message)
  53. return
  54. }
  55. throw new Error('expected flush to reject')
  56. }
  57. async function freshRoot(): Promise<string> {
  58. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
  59. dirs.push(dir)
  60. return dir
  61. }
  62. function rawLogPath(root: string, cwd: string | undefined, id: SessionId): string {
  63. return logPath(root, cwd, id, 'none')
  64. }
  65. afterEach(async () => {
  66. statRace.path = undefined
  67. statRace.reads = 0
  68. vi.restoreAllMocks()
  69. for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
  70. })
  71. function appendClosedTurn(session: Session): void {
  72. session.append('turn/start', { turn: 1 })
  73. session.append('user/message', createUserMessage({
  74. content: [{ type: 'text', text: 'hello' }],
  75. source: { kind: 'user' },
  76. }), { surfaceOp: 'append' })
  77. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  78. }
  79. // Run the shared backend contract against the real JSONL backend.
  80. runPersistenceContract('jsonl-none', async () => {
  81. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
  82. const ctx = new Context()
  83. await ctx.plugin(SessionStore)
  84. const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
  85. return {
  86. persistence: ctx.sessionPersistence,
  87. dispose: async () => {
  88. await fiber.dispose()
  89. await rm(dir, { recursive: true, force: true })
  90. },
  91. }
  92. })
  93. // Two mounts share this temp root to exercise reload. `corruptTail` appends a partial,
  94. // newline-less fragment past the committed region so coordinator repair runs on real file bytes.
  95. runCoordinatorContract('jsonl-none', async (): Promise<CoordinatorFixture> => {
  96. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-'))
  97. return {
  98. mount: async (ctx) => {
  99. const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
  100. return fiber
  101. },
  102. corruptTail: async (id, cwd) => {
  103. // A half-written record with no trailing newline: scanLog treats it as an
  104. // uncommitted crash fragment and reports committedBytes < byteLength, so
  105. // the coordinator sees a tornMarker to truncate.
  106. await appendFile(rawLogPath(dir, cwd, id), '{"type":"assistant/chunk","seq":8,"ti')
  107. },
  108. cleanup: async () => { await rm(dir, { recursive: true, force: true }) },
  109. }
  110. })
  111. describe('SessionPersistenceJsonl: format helpers', () => {
  112. it('encodeSegment neutralizes traversal, separators, and absolute paths', () => {
  113. expect(encodeSegment('..')).toBe('~002E~002E')
  114. expect(encodeSegment('.')).toBe('~002E')
  115. expect(encodeSegment('a/b')).toBe('a~002Fb')
  116. expect(encodeSegment('/etc/passwd')).toBe('~002Fetc~002Fpasswd')
  117. expect(encodeSegment('a\u0000b')).toBe('a~0000b')
  118. expect(encodeSegment('plain-ID_1.2')).toBe('plain-ID_1.2') // safe chars pass through
  119. expect(encodeSegment('a~b')).toBe('a~007Eb') // ~ itself is escaped
  120. })
  121. it('encodeSegment is injective over UTF-16, incl. lone surrogates', () => {
  122. // Distinct lone surrogates must NOT collide (Buffer.from would normalize
  123. // both to U+FFFD; code-unit escaping keeps them distinct).
  124. const hi = encodeSegment(String.fromCharCode(0xD800))
  125. const lo = encodeSegment(String.fromCharCode(0xDC00))
  126. expect(hi).toBe('~D800')
  127. expect(lo).toBe('~DC00')
  128. expect(hi).not.toBe(lo)
  129. // A literal "~002F" input cannot collide with the encoding of "/".
  130. expect(encodeSegment('~002F')).not.toBe(encodeSegment('/'))
  131. })
  132. it('encodeSegment rejects an empty id', () => {
  133. expect(() => encodeSegment('')).toThrow(/empty/)
  134. })
  135. it('projectKey normalizes project paths into bounded readable names', () => {
  136. expect(projectKey('/Users/qyj/work/deepseek-harness')).toBe('--Users-qyj-work-deepseek-harness--')
  137. expect(projectKey('/a/b-c')).toBe(projectKey('/a-b/c'))
  138. expect(projectKey('C:\\work\\agent')).toBe('--C-work-agent--')
  139. expect(projectKey('/开发/~agent')).toBe('--~5F00~53D1-~007Eagent--')
  140. expect(projectKey('/')).toBe('--root--')
  141. expect(projectKey('/' + 'x'.repeat(1_000))).toHaveLength(255)
  142. expect(() => projectKey('')).toThrow(/empty project path/)
  143. })
  144. it('resolves a relative custom root before locating a session', async () => {
  145. const absoluteRoot = await freshRoot()
  146. const ctx = new Context()
  147. await ctx.plugin(SessionStore)
  148. const fiber = await ctx.plugin(SessionPersistenceJsonl, {
  149. root: relative(process.cwd(), absoluteRoot),
  150. compression: 'none',
  151. })
  152. const m = meta('relative-location', '/work')
  153. expect(ctx.sessionPersistence.locate(m)).toEqual({
  154. kind: 'jsonl',
  155. path: rawLogPath(resolve(absoluteRoot), '/work', m.id),
  156. })
  157. await fiber.dispose()
  158. })
  159. })
  160. describe('SessionPersistenceJsonl: durability and crash semantics', () => {
  161. let ctx: Context
  162. beforeEach(async () => {
  163. root = await freshRoot()
  164. ctx = new Context()
  165. await ctx.plugin(SessionStore)
  166. await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  167. })
  168. afterEach(async () => { await ctx.fiber.dispose() })
  169. it('lazy materialization: create() writes no file until the first append', async () => {
  170. const m = meta('lazy', '/work')
  171. const location = ctx.sessionPersistence.locate(m)
  172. expect(location).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', m.id) })
  173. expect(isAbsolute(location!.path)).toBe(true)
  174. await ctx.sessionPersistence.create(m)
  175. // locate() is a pure target-path calculation: neither it nor create()
  176. // materializes a file before the first append.
  177. const dir = sessionDir(root, '/work', m.id)
  178. await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow()
  179. expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
  180. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  181. // now materialized
  182. expect((await stat(dir)).isDirectory()).toBe(true)
  183. expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true)
  184. expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id)
  185. })
  186. it('keeps the same location on resume and gives a fork its own location', async () => {
  187. const parent = meta('location-parent', '/work')
  188. const parentLocation = ctx.sessionPersistence.locate(parent)
  189. await ctx.sessionPersistence.create(parent)
  190. await ctx.sessionPersistence.append(parent.id, oneTurnLog())
  191. const loaded = await ctx.sessionPersistence.load(parent.id)
  192. expect(ctx.sessionPersistence.locate(loaded.meta)).toEqual(parentLocation)
  193. const child = {
  194. ...loaded.meta,
  195. id: SessionId('location-child'),
  196. parentSession: parent.id,
  197. seedLength: loaded.events.length,
  198. }
  199. const childLocation = ctx.sessionPersistence.locate(child)
  200. expect(childLocation?.path).not.toBe(parentLocation?.path)
  201. expect(childLocation).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', child.id) })
  202. })
  203. it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => {
  204. const m = meta('chunks')
  205. const log: SessionEvent[] = [
  206. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  207. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  208. { type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'he' } } },
  209. { type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'llo' } } },
  210. { type: 'assistant/message', seq: 4, time: 5, data: {
  211. turn: 1, step: 1,
  212. message: createMessage({
  213. role: 'assistant',
  214. content: [{ type: 'text', text: 'hello' }],
  215. source: {
  216. kind: 'model',
  217. ...{ provider: 'mock', model: 'mock' },
  218. },
  219. }),
  220. }, surfaceOp: 'append', sourceEventSeqs: [2, 3] },
  221. { type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
  222. { type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
  223. ]
  224. await ctx.sessionPersistence.create(m)
  225. await ctx.sessionPersistence.append(m.id, log)
  226. const loaded = await ctx.sessionPersistence.load(m.id)
  227. expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs
  228. })
  229. it('source-qualifies revisions across roots while preserving same-log reopen identity', async () => {
  230. const m = meta('revision-source')
  231. await ctx.sessionPersistence.create(m)
  232. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  233. const revision = (await ctx.sessionPersistence.listSnapshots())[0]?.revision
  234. const reopenedCtx = new Context()
  235. await reopenedCtx.plugin(SessionStore)
  236. await reopenedCtx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  237. expect((await reopenedCtx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revision)
  238. const otherRoot = await freshRoot()
  239. const otherCtx = new Context()
  240. await otherCtx.plugin(SessionStore)
  241. await otherCtx.plugin(SessionPersistenceJsonl, { root: otherRoot, compression: 'none' })
  242. await otherCtx.sessionPersistence.create(m)
  243. await otherCtx.sessionPersistence.append(m.id, oneTurnLog())
  244. expect((await otherCtx.sessionPersistence.listSnapshots())[0]?.revision).not.toBe(revision)
  245. await reopenedCtx.fiber.dispose()
  246. await otherCtx.fiber.dispose()
  247. })
  248. it('binds a full stored prefix to the same revision as a lightweight read', async () => {
  249. const m = meta('stored-prefix-revision')
  250. await ctx.sessionPersistence.create(m)
  251. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  252. const persistence = ctx.sessionPersistence as SessionPersistenceJsonl
  253. const stored = await persistence.loadStored(m.id)
  254. expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id))
  255. expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined()
  256. })
  257. it('retries a full-prefix read when the file revision changes during the read', async () => {
  258. const m = meta('stored-prefix-revision-race')
  259. await ctx.sessionPersistence.create(m)
  260. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  261. const persistence = ctx.sessionPersistence as SessionPersistenceJsonl
  262. statRace.path = rawLogPath(root, m.cwd, m.id)
  263. await expect(persistence.loadStored(m.id)).resolves.toMatchObject({ events: oneTurnLog() })
  264. expect(statRace.reads).toBe(4)
  265. })
  266. it('handles revision-stat races and errors after log discovery', async () => {
  267. const m = meta('stored-revision-race')
  268. await ctx.sessionPersistence.create(m)
  269. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  270. const persistence = ctx.sessionPersistence as SessionPersistenceJsonl
  271. const internals = persistence as unknown as {
  272. findLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined>
  273. }
  274. const path = rawLogPath(root, m.cwd, m.id)
  275. const findLog = vi.spyOn(internals, 'findLog').mockResolvedValue(path)
  276. await rm(path)
  277. expect(await persistence.readStoredRevision(m.id)).toBeUndefined()
  278. const invalidPath = `${path}\0`
  279. findLog.mockResolvedValue(invalidPath)
  280. await expect(persistence.readStoredRevision(m.id)).rejects.toMatchObject({
  281. code: 'ERR_INVALID_ARG_VALUE',
  282. })
  283. const reason = new Error('revision read cancelled after discovery')
  284. const controller = new AbortController()
  285. findLog.mockImplementation(async () => {
  286. controller.abort(reason)
  287. return invalidPath
  288. })
  289. await expect(persistence.readStoredRevision(m.id, controller.signal)).rejects.toBe(reason)
  290. })
  291. it('omits a snapshot artifact removed after discovery', async () => {
  292. const m = meta('vanishing-snapshot')
  293. await ctx.sessionPersistence.create(m)
  294. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  295. const persistence = ctx.sessionPersistence as unknown as {
  296. listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>>
  297. }
  298. const listArtifacts = persistence.listArtifacts.bind(persistence)
  299. const discovery = vi.spyOn(persistence, 'listArtifacts').mockImplementation(async () => {
  300. const artifacts = await listArtifacts()
  301. await rm(artifacts[0]!.path)
  302. return artifacts
  303. })
  304. await expect(ctx.sessionPersistence.listSnapshots()).resolves.toEqual([])
  305. discovery.mockRestore()
  306. })
  307. it('surfaces non-ENOENT snapshot stat failures after discovery', async () => {
  308. const persistence = ctx.sessionPersistence as unknown as {
  309. listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>>
  310. }
  311. const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{
  312. header: meta('snapshot-stat-failure'),
  313. path: `${root}\0snapshot-stat-failure`,
  314. }])
  315. await expect(ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/null bytes/)
  316. discovery.mockRestore()
  317. })
  318. it('forwards snapshot-list cancellation and awaits in-flight discovery cleanup', async () => {
  319. const persistence = ctx.sessionPersistence as unknown as {
  320. listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>>
  321. }
  322. const started = Promise.withResolvers<AbortSignal>()
  323. const cleanup = Promise.withResolvers<undefined>()
  324. vi.spyOn(persistence, 'listArtifacts').mockImplementation(async (signal) => {
  325. if (signal === undefined) throw new Error('expected snapshot-list signal')
  326. started.resolve(signal)
  327. await cleanup.promise
  328. return []
  329. })
  330. const reason = new Error('JSONL snapshot discovery cancelled')
  331. const controller = new AbortController()
  332. const pending = ctx.sessionPersistence.listSnapshots(controller.signal)
  333. expect(await started.promise).toBe(controller.signal)
  334. let settled = false
  335. void pending.then(
  336. () => { settled = true },
  337. () => { settled = true },
  338. )
  339. controller.abort(reason)
  340. await Promise.resolve()
  341. expect(settled).toBe(false)
  342. cleanup.resolve(undefined)
  343. await expect(pending).rejects.toBe(reason)
  344. })
  345. it('checks cancellation after an uncancellable snapshot stat settles', async () => {
  346. const m = meta('snapshot-stat-cancellation')
  347. await ctx.sessionPersistence.create(m)
  348. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  349. const persistence = ctx.sessionPersistence as unknown as {
  350. listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>>
  351. }
  352. const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{
  353. header: m,
  354. path: rawLogPath(root, m.cwd, m.id),
  355. }])
  356. const reason = new Error('JSONL snapshot stat cancelled')
  357. const controller = new AbortController()
  358. const pending = ctx.sessionPersistence.listSnapshots(controller.signal)
  359. queueMicrotask(() => { controller.abort(reason) })
  360. await expect(pending).rejects.toBe(reason)
  361. expect(discovery).toHaveBeenCalledWith(controller.signal)
  362. })
  363. it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
  364. const m = meta('legacy-header-delta', '/legacy')
  365. const path = rawLogPath(root, m.cwd, m.id)
  366. await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true })
  367. await writeFile(path, [
  368. JSON.stringify(toHeaderLine(m)),
  369. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
  370. JSON.stringify({ type: 'request/header-delta', seq: 1, time: 2, data: { config: { model: 'legacy' } } }),
  371. JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
  372. '',
  373. ].join('\n'))
  374. await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/)
  375. })
  376. it('rejects a stored v0 full header carrying the legacy fallback reason', async () => {
  377. const m = meta('legacy-header-fallback', '/legacy')
  378. const path = rawLogPath(root, m.cwd, m.id)
  379. await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true })
  380. await writeFile(path, [
  381. JSON.stringify(toHeaderLine(m)),
  382. JSON.stringify({
  383. type: 'request/header',
  384. seq: 0,
  385. time: 1,
  386. data: { header: { config: { model: 'legacy' } }, reason: 'fallback' },
  387. }),
  388. '',
  389. ].join('\n'))
  390. await expect(ctx.sessionPersistence.load(m.id))
  391. .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/)
  392. })
  393. it('persists a forked child seed through the existing session write path', async () => {
  394. const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } })
  395. appendClosedTurn(source)
  396. const child = ctx.sessions.fork(source, undefined, SessionId('persist-child'))
  397. await ctx.sessions.flush(child)
  398. const loaded = await ctx.sessionPersistence.load(child.id)
  399. // The constructor seed reaches disk verbatim, then the child's end-seed.
  400. expect(loaded.events.slice(0, source.events.length)).toEqual(source.events)
  401. expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: source.events.length })
  402. expect(loaded.meta).toMatchObject({
  403. id: SessionId('persist-child'),
  404. cwd: '/workspace',
  405. parentSession: SessionId('persist-parent'),
  406. seedLength: source.events.length,
  407. })
  408. })
  409. it('crash recovery: load preserves the interrupted turn and closes it with a synthetic turn/end {interrupted}', async () => {
  410. const m = meta('crash', '/proj')
  411. await ctx.sessionPersistence.create(m)
  412. await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5, turn/end at 5
  413. // Simulate a crash mid-second-turn: append raw lines that are NOT closed by
  414. // a turn/end (turn/start + step/start are fully written), plus a final
  415. // partial line with no newline (a torn fragment never fully flushed).
  416. const path = rawLogPath(root, '/proj', m.id)
  417. await writeFile(path, [
  418. JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } }),
  419. JSON.stringify({ type: 'step/start', seq: 7, time: 9, data: { turn: 2, step: 1 } }),
  420. '{"type":"assistant/chunk","seq":8,"ti', // truncated partial line (no newline)
  421. ].join('\n'), { flag: 'a' })
  422. // load PRESERVES the interrupted turn's real events (turn/start 6, step/start
  423. // 7) — a turn can be huge, so they must not be truncated — and durably closes
  424. // the orphaned turn with synthetic step/end (8) + turn/end {interrupted} (9).
  425. const loaded = await ctx.sessionPersistence.load(m.id)
  426. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
  427. const last = loaded.events.at(-1)!
  428. expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
  429. const stepEnd = loaded.events[8]!
  430. expect(stepEnd.type).toBe('step/end')
  431. // the torn seq-8 chunk fragment did not survive
  432. expect(loaded.events.some(e => e.type === 'assistant/chunk' && e.seq === 8)).toBe(false)
  433. // The next append continues at seq 10 (the balanced length).
  434. const turn3 = [
  435. { type: 'turn/start', seq: 10, time: 11, data: { turn: 3 } },
  436. { type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'completed' } } },
  437. ] as SessionEvent[]
  438. await ctx.sessionPersistence.append(m.id, turn3)
  439. const reloaded = await ctx.sessionPersistence.load(m.id)
  440. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
  441. })
  442. it('committed events are never rewritten: only the crash tail is repaired', async () => {
  443. const m = meta('append-only')
  444. await ctx.sessionPersistence.create(m)
  445. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  446. const before = await readFile(rawLogPath(root, undefined, m.id), 'utf8')
  447. const committedPrefix = before // the whole committed log
  448. // A crash tail then a repair-append.
  449. await writeFile(rawLogPath(root, undefined, m.id), '\n{"partial', { flag: 'a' })
  450. await ctx.sessionPersistence.load(m.id)
  451. await ctx.sessionPersistence.append(m.id, [
  452. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
  453. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  454. ] as SessionEvent[])
  455. const after = await readFile(rawLogPath(root, undefined, m.id), 'utf8')
  456. // the committed prefix is byte-for-byte intact at the head of the file
  457. expect(after.startsWith(committedPrefix)).toBe(true)
  458. })
  459. it('a failed appendLines truncates partial bytes so a retry has no seq gap', async () => {
  460. const m = meta('truncate-retry')
  461. await ctx.sessionPersistence.create(m)
  462. await ctx.sessionPersistence.append(m.id, oneTurnLog()) // materialized, seqs 0..5
  463. const sizeBefore = (await stat(rawLogPath(root, undefined, m.id))).size
  464. // Force the NEXT fsync (inside appendLines) to fail once, AFTER writeFile
  465. // has already put bytes on disk — simulating an ENOSPC/fsync error
  466. // mid-append. The recovery truncate() also fsyncs, so allow that one.
  467. const handle = await (await import('node:fs/promises')).open(rawLogPath(root, undefined, m.id), 'r')
  468. const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
  469. await handle.close()
  470. const realSync = proto.sync
  471. let failed = false
  472. const spy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
  473. if (!failed) { failed = true; throw new Error('simulated fsync ENOSPC') }
  474. return realSync.call(this)
  475. })
  476. const turn2 = [
  477. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
  478. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  479. ] as SessionEvent[]
  480. // The append rejects, but the partial bytes are truncated back: the file is
  481. // its pre-append size and the cursor is unchanged.
  482. await expect(ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/ENOSPC/)
  483. expect((await stat(rawLogPath(root, undefined, m.id))).size).toBe(sizeBefore)
  484. spy.mockRestore()
  485. // The retry now succeeds with NO seq gap — the log is contiguous 0..7.
  486. await ctx.sessionPersistence.append(m.id, turn2)
  487. const loaded = await ctx.sessionPersistence.load(m.id)
  488. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  489. })
  490. it('reports both the append failure and a failed rollback', async () => {
  491. const m = meta('rollback-failure')
  492. await ctx.sessionPersistence.create(m)
  493. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  494. const path = rawLogPath(root, undefined, m.id)
  495. const handle = await (await import('node:fs/promises')).open(path, 'r')
  496. const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
  497. await handle.close()
  498. const realSync = proto.sync
  499. let failed = false
  500. const syncSpy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
  501. if (!failed) { failed = true; throw new Error('simulated append fsync failure') }
  502. return realSync.call(this)
  503. })
  504. const backend = ctx.sessionPersistence as unknown as {
  505. rollbackAppend: (path: string, size: number) => Promise<void>
  506. }
  507. const realRollback = backend.rollbackAppend.bind(backend)
  508. backend.rollbackAppend = () => Promise.reject(new Error('simulated rollback failure'))
  509. try {
  510. await ctx.sessionPersistence.append(m.id, [
  511. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
  512. ] as SessionEvent[])
  513. throw new Error('expected append to reject')
  514. } catch (error) {
  515. expect(error).toBeInstanceOf(AggregateError)
  516. const aggregate = error as AggregateError
  517. expect(aggregate.message).toContain(`failed to roll back append to "${path}"`)
  518. expect(aggregate.errors).toHaveLength(2)
  519. expect(aggregate.errors[0]).toMatchObject({ message: 'simulated append fsync failure' })
  520. expect(aggregate.errors[1]).toMatchObject({ message: 'simulated rollback failure' })
  521. } finally {
  522. backend.rollbackAppend = realRollback
  523. syncSpy.mockRestore()
  524. }
  525. })
  526. it('load returns immutable meta without exposing backend pathing', async () => {
  527. const m = meta('meta-copy', '/proj')
  528. await ctx.sessionPersistence.create(m)
  529. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  530. const loaded = await ctx.sessionPersistence.load(m.id)
  531. expect(() => { mutableHeader(loaded.meta).cwd = '/evil' }).toThrow()
  532. await ctx.sessionPersistence.append(m.id, [
  533. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
  534. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  535. ] as SessionEvent[])
  536. // The append landed in the ORIGINAL /proj log, not beside an /evil path.
  537. const reloaded = await ctx.sessionPersistence.load(m.id)
  538. expect(reloaded.meta.cwd).toBe('/proj')
  539. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  540. })
  541. it('rejects a mismatched header before repairing either session log', async () => {
  542. const a = meta('identity-a', '/same')
  543. const b = meta('identity-b', '/same')
  544. await ctx.sessionPersistence.create(a)
  545. await ctx.sessionPersistence.append(a.id, [{
  546. type: 'turn/start',
  547. seq: 0,
  548. time: 1,
  549. data: { turn: 1 },
  550. }])
  551. await ctx.sessionPersistence.create(b)
  552. await ctx.sessionPersistence.append(b.id, oneTurnLog())
  553. const aPath = rawLogPath(root, a.cwd, a.id)
  554. const bPath = rawLogPath(root, b.cwd, b.id)
  555. await rewriteHeader(aPath, (header) => { header.id = b.id })
  556. const beforeA = await readFile(aPath)
  557. const beforeB = await readFile(bPath)
  558. await expect(ctx.sessionPersistence.load(a.id))
  559. .rejects.toThrow(/requested id "identity-a" does not match header id "identity-b"/)
  560. expect(await readFile(aPath)).toEqual(beforeA)
  561. expect(await readFile(bPath)).toEqual(beforeB)
  562. })
  563. it('rejects a re-append of an already-stored seq', async () => {
  564. const m = meta('reappend')
  565. await ctx.sessionPersistence.create(m)
  566. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  567. await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toThrow(/seq mismatch/)
  568. })
  569. it('path-traversal session ids are neutralized (no escape from root)', async () => {
  570. const evil = SessionId('../../etc/pwn')
  571. const m = { version: 0, id: evil, createdAt: 1 }
  572. await ctx.sessionPersistence.create(m)
  573. await ctx.sessionPersistence.append(evil, oneTurnLog())
  574. // The file lives UNDER root, not at ../../etc.
  575. const all: string[] = []
  576. async function walk(dir: string): Promise<void> {
  577. for (const e of await readdir(dir, { withFileTypes: true })) {
  578. const p = join(dir, e.name)
  579. if (e.isDirectory()) await walk(p)
  580. else all.push(p)
  581. }
  582. }
  583. await walk(root)
  584. expect(all.length).toBeGreaterThan(0)
  585. expect(all.every(p => p.startsWith(root))).toBe(true)
  586. })
  587. })
  588. describe('SessionPersistenceJsonl: write path (session/event → flush)', () => {
  589. it('concurrent sessions do not cross buffers', async () => {
  590. root = await freshRoot()
  591. const ctx = new Context()
  592. await ctx.plugin(SessionStore)
  593. await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  594. const a = ctx.sessions.create(SessionId('sa'))
  595. const b = ctx.sessions.create(SessionId('sb'))
  596. a.append('turn/start', { turn: 1 })
  597. b.append('turn/start', { turn: 1 })
  598. a.append('user/message', createUserMessage({
  599. content: [{ type: 'text', text: 'A' }], source: { kind: 'user' },
  600. }), { surfaceOp: 'append' })
  601. b.append('user/message', createUserMessage({
  602. content: [{ type: 'text', text: 'B' }], source: { kind: 'user' },
  603. }), { surfaceOp: 'append' })
  604. a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  605. b.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  606. await ctx.sessions.flush(a)
  607. await ctx.sessions.flush(b)
  608. const la = await ctx.sessionPersistence.load(SessionId('sa'))
  609. const lb = await ctx.sessionPersistence.load(SessionId('sb'))
  610. expect(JSON.stringify(la.events)).toContain('"A"')
  611. expect(JSON.stringify(la.events)).not.toContain('"B"')
  612. expect(JSON.stringify(lb.events)).toContain('"B"')
  613. expect(JSON.stringify(lb.events)).not.toContain('"A"')
  614. await ctx.fiber.dispose()
  615. })
  616. })
  617. describe('SessionPersistenceJsonl: scanLog unit', () => {
  618. it('requires exactly one newline-terminated header record', () => {
  619. const header = JSON.stringify(toHeaderLine(meta('scanner-header')))
  620. expect(() => new SessionLogScanner(Buffer.alloc(0))).toThrow(/header-less/)
  621. expect(() => new SessionLogScanner(Buffer.from(header))).toThrow(/header-less/)
  622. expect(() => new SessionLogScanner(Buffer.from(`${header}\n${header}\n`))).toThrow(/header-less/)
  623. })
  624. it('handles empty writes, boundary newlines, torn fragments, and scanner completion', () => {
  625. const header = Buffer.from(`${JSON.stringify(toHeaderLine(meta('scanner-lifecycle')))}\n`)
  626. const event = Buffer.from(JSON.stringify(oneTurnLog()[0]))
  627. const scanner = new SessionLogScanner(header)
  628. scanner.write(Buffer.alloc(0))
  629. scanner.write(event)
  630. scanner.write(Buffer.from('\nignored torn tail'))
  631. const result = scanner.finish()
  632. expect(result.events).toEqual([oneTurnLog()[0]])
  633. expect(result.committedBytes).toBe(header.length + event.length + 1)
  634. expect(() => { scanner.write(Buffer.from('\n')) }).toThrow(/finished/)
  635. })
  636. it('keeps scanning after a tolerable corrupt suffix until a committed turn end appears', () => {
  637. const header = Buffer.from(`${JSON.stringify(toHeaderLine(meta('scanner-corrupt-suffix')))}\n`)
  638. const scanner = new SessionLogScanner(header)
  639. scanner.write(Buffer.from([
  640. JSON.stringify(oneTurnLog()[0]),
  641. '{not json',
  642. JSON.stringify({ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }),
  643. '',
  644. ].join('\n')))
  645. expect(scanner.finish().events).toEqual([oneTurnLog()[0]])
  646. const committed = new SessionLogScanner(header)
  647. expect(() => { committed.write(Buffer.from([
  648. JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
  649. '',
  650. ].join('\n'))) }).toThrow(/seq gap in committed region/)
  651. })
  652. it('incrementally scans records split across reusable decoder chunks', () => {
  653. const header = Buffer.from(`${JSON.stringify(toHeaderLine(meta('incremental')))}\n`)
  654. const body = Buffer.from(`${oneTurnLog().map(event => JSON.stringify(event)).join('\n').replace('"hi"', '"你好"')}\n`)
  655. const split = body.indexOf(Buffer.from('你')) + 1
  656. const firstChunk = Buffer.from(body.subarray(0, split))
  657. const scanner = new SessionLogScanner(header)
  658. scanner.write(firstChunk)
  659. const checkpoint = scanner.checkpoint()
  660. firstChunk.fill(0)
  661. scanner.write(body.subarray(split))
  662. expect(checkpoint).toMatchObject({
  663. inputBytes: header.length + split,
  664. eventCount: 1,
  665. })
  666. expect(scanner.finish()).toEqual(scanLog(Buffer.concat([header, body])))
  667. })
  668. it('rejects a header-less / empty log', () => {
  669. expect(() => scanLog(Buffer.from(''))).toThrow()
  670. })
  671. it('rejects a corrupt header line', () => {
  672. expect(() => scanLog(Buffer.from('not json\n'))).toThrow(/header/)
  673. })
  674. it('rejects a non-session first line', () => {
  675. expect(() => scanLog(Buffer.from('{"type":"event"}\n'))).toThrow(/session header/)
  676. })
  677. it.each([
  678. ['fractional', 1.5],
  679. ['negative', -1],
  680. ['unsafe', Number.MAX_SAFE_INTEGER + 1],
  681. ])('rejects a session header with a %s createdAt', (_label, createdAt) => {
  682. const log = JSON.stringify({
  683. type: 'session',
  684. version: 0,
  685. id: 'invalid-created-at',
  686. createdAt,
  687. delegationDepth: 0,
  688. }) + '\n'
  689. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  690. })
  691. it('rejects a session header with negative-zero createdAt', () => {
  692. const log = '{"type":"session","version":0,"id":"invalid-created-at","createdAt":-0,"delegationDepth":0}\n'
  693. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  694. })
  695. it.each([
  696. ['missing', undefined],
  697. ['a string', '1'],
  698. ['fractional', 1.5],
  699. ['negative', -1],
  700. ])('rejects a session header with %s delegationDepth', (_label, delegationDepth) => {
  701. const log = JSON.stringify({
  702. type: 'session',
  703. version: 0,
  704. id: 'invalid-depth',
  705. createdAt: 1,
  706. ...delegationDepth === undefined ? {} : { delegationDepth },
  707. }) + '\n'
  708. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  709. })
  710. it('rejects a session header with negative-zero delegationDepth', () => {
  711. const log = '{"type":"session","version":0,"id":"invalid-depth","createdAt":1,"delegationDepth":-0}\n'
  712. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  713. })
  714. it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => {
  715. const log = [
  716. JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1, delegationDepth: 0 }),
  717. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
  718. JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
  719. ].join('\n') + '\n'
  720. // No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the
  721. // contiguous prefix (turn/start seq 0) — real interrupted-turn work, not discarded — and
  722. // stops at the gap. `loadCore`, not this scanner, later closes the orphaned turn.
  723. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
  724. })
  725. it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => {
  726. const log = [
  727. JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1, delegationDepth: 0 }),
  728. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
  729. JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
  730. JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
  731. ].join('\n') + '\n'
  732. // A turn/end exists, so the prefix up to it is committed — but it has a hole.
  733. // Truncating it would silently drop committed data → unloadable.
  734. expect(() => scanLog(Buffer.from(log))).toThrow(/seq gap in committed region/)
  735. })
  736. it('rejects a corrupt line BEFORE a later committed turn/end (committed data damaged)', () => {
  737. const log = [
  738. JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1, delegationDepth: 0 }),
  739. '{not json', // corrupt, sits in the committed region (a turn/end follows)
  740. JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
  741. ].join('\n') + '\n'
  742. expect(() => scanLog(Buffer.from(log))).toThrow(/unparsable committed event/)
  743. })
  744. it('a header-only log (no event lines at all) preserves nothing — committedBytes is the header', () => {
  745. const log = JSON.stringify({ type: 'session', version: 0, id: 'h0', createdAt: 1, delegationDepth: 0 }) + '\n'
  746. const scanned = scanLog(Buffer.from(log))
  747. expect(scanned.events).toEqual([])
  748. // committedBytes falls back to the header line's end (no preserved events).
  749. expect(scanned.committedBytes).toBe(Buffer.byteLength(log, 'utf8'))
  750. })
  751. it('a corrupt line after the last turn/end bounds the preserved tail', () => {
  752. const log = [
  753. JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1, delegationDepth: 0 }),
  754. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
  755. '{not json', // corrupt crash fragment, no turn/end committed
  756. ].join('\n') + '\n'
  757. // The contiguous prefix (turn/start seq 0) is preserved; the corrupt
  758. // fragment after it is the tolerated crash boundary.
  759. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
  760. })
  761. it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => {
  762. const log = [
  763. JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1, delegationDepth: 0 }),
  764. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
  765. JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
  766. JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail
  767. ].join('\n') + '\n'
  768. const { events } = scanLog(Buffer.from(log))
  769. expect(events.map(e => e.seq)).toEqual([0, 1]) // tail dropped
  770. })
  771. })
  772. describe('SessionPersistenceJsonl: default packed chunk rows', () => {
  773. let ctx: Context
  774. beforeEach(async () => {
  775. root = await freshRoot()
  776. ctx = new Context()
  777. await ctx.plugin(SessionStore)
  778. // compression: 'none' — these tests assert the textual storage-record layout
  779. // (row tags per line); packing is orthogonal to the physical encoding.
  780. await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  781. })
  782. afterEach(async () => { await ctx.fiber.dispose() })
  783. /** A one-turn log whose step streams a five-member text-delta run. */
  784. function chunkRunLog(): SessionEvent[] {
  785. const deltas: SessionEvent[] = Array.from({ length: 5 }, (_, k) => ({
  786. type: 'assistant/chunk',
  787. seq: 2 + k,
  788. time: 3 + k,
  789. data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: `t${k}` } },
  790. }))
  791. return [
  792. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  793. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  794. ...deltas,
  795. { type: 'assistant/message', seq: 7, time: 8, data: {
  796. turn: 1, step: 1,
  797. message: createMessage({
  798. role: 'assistant',
  799. content: [{ type: 'text', text: 't0t1t2t3t4' }],
  800. source: {
  801. kind: 'model',
  802. ...{ provider: 'mock', model: 'mock' },
  803. },
  804. }),
  805. }, surfaceOp: 'append', sourceEventSeqs: [2, 3, 4, 5, 6] },
  806. { type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } },
  807. { type: 'turn/end', seq: 9, time: 10, data: { turn: 1, reason: { kind: 'completed' } } },
  808. ]
  809. }
  810. it('writes a delta run as one text-chunks row by default and loads back identical events', async () => {
  811. const m = meta('packed', '/work')
  812. const log = chunkRunLog()
  813. await ctx.sessionPersistence.create(m)
  814. await ctx.sessionPersistence.append(m.id, log)
  815. const raw = (await readFile(rawLogPath(root, '/work', m.id), 'utf8')).split('\n').filter(Boolean)
  816. const tags = raw.slice(1).map(line => (JSON.parse(line) as { type: string }).type)
  817. expect(tags).toEqual(['turn/start', 'step/start', 'text-chunks', 'assistant/message', 'step/end', 'turn/end'])
  818. const loaded = await ctx.sessionPersistence.load(m.id)
  819. expect(loaded.events).toEqual(log)
  820. })
  821. it('packChunks: false writes one event per line and still loads identical events', async () => {
  822. const unpackedRoot = await freshRoot()
  823. const unpacked = new Context()
  824. await unpacked.plugin(SessionStore)
  825. await unpacked.plugin(SessionPersistenceJsonl, {
  826. root: unpackedRoot,
  827. packChunks: false,
  828. compression: 'none',
  829. })
  830. try {
  831. const m = meta('unpacked', '/work')
  832. const log = chunkRunLog()
  833. await unpacked.sessionPersistence.create(m)
  834. await unpacked.sessionPersistence.append(m.id, log)
  835. const records = (await readFile(rawLogPath(unpackedRoot, '/work', m.id), 'utf8'))
  836. .split('\n').filter(Boolean).slice(1)
  837. .map(line => JSON.parse(line) as { type: string })
  838. expect(records.filter(record => record.type === 'assistant/chunk')).toHaveLength(5)
  839. expect(records.some(record => record.type === 'text-chunks')).toBe(false)
  840. expect((await unpacked.sessionPersistence.load(m.id)).events).toEqual(log)
  841. } finally {
  842. await unpacked.fiber.dispose()
  843. }
  844. })
  845. it('loads a mixed file: verbatim lines from an unpacked writer, then packed appends', async () => {
  846. const m = meta('mixed', '/work')
  847. const log = chunkRunLog()
  848. // First turn written line-per-event by an unpacked-config writer (an old
  849. // file, hand-planted so this packed-config backend adopts it on load).
  850. await mkdir(sessionDir(root, '/work', m.id), { recursive: true })
  851. await writeFile(rawLogPath(root, '/work', m.id), [
  852. JSON.stringify({ type: 'session', version: 0, id: 'mixed', createdAt: 1000, cwd: '/work', delegationDepth: 0 }),
  853. ...log.map(e => JSON.stringify(e)),
  854. ].join('\n') + '\n')
  855. // Adopt the stored log (cursor = stored length), then append a second turn
  856. // through THIS packed-config backend.
  857. expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(log)
  858. const secondTurn: SessionEvent[] = JSON.parse(JSON.stringify(log)) as SessionEvent[]
  859. for (const [k, e] of secondTurn.entries()) {
  860. ;(e as { seq: number }).seq = 10 + k
  861. ;(e.data as { turn: number }).turn = 2
  862. }
  863. await ctx.sessionPersistence.append(m.id, secondTurn)
  864. const loaded = await ctx.sessionPersistence.load(m.id)
  865. expect(loaded.events).toEqual([...log, ...secondTurn])
  866. // The packed append really packed: the file's tail carries a text-chunks row.
  867. const tags = (await readFile(rawLogPath(root, '/work', m.id), 'utf8')).split('\n').filter(Boolean)
  868. .map(line => (JSON.parse(line) as { type: string }).type)
  869. expect(tags.filter(t => t === 'text-chunks')).toHaveLength(1)
  870. expect(tags.filter(t => t === 'assistant/chunk')).toHaveLength(5)
  871. })
  872. it('scanLog: a packed row advances the seq cursor by its whole run', () => {
  873. const logText = [
  874. JSON.stringify({ type: 'session', version: 0, id: 'rows', createdAt: 1, delegationDepth: 0 }),
  875. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
  876. JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
  877. JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }),
  878. ].join('\n') + '\n'
  879. const { events } = scanLog(Buffer.from(logText))
  880. expect(events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4])
  881. expect(events[2]).toEqual({ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } } })
  882. })
  883. it('scanLog: a malformed packed row in the committed region rejects like corrupt JSON', () => {
  884. const logText = [
  885. JSON.stringify({ type: 'session', version: 0, id: 'bad-row', createdAt: 1, delegationDepth: 0 }),
  886. // dt arity mismatch — row validation throws, so the line is a committed hole.
  887. JSON.stringify({ type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] } }),
  888. JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
  889. ].join('\n') + '\n'
  890. expect(() => scanLog(Buffer.from(logText))).toThrow(/unparsable committed event/)
  891. })
  892. it('scanLog: a packed row with a mid-run seq gap after the last turn/end drops the whole row', () => {
  893. const logText = [
  894. JSON.stringify({ type: 'session', version: 0, id: 'row-gap', createdAt: 1, delegationDepth: 0 }),
  895. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
  896. // seq0 skips 1 — the run's first member is already a gap; no turn/end follows.
  897. JSON.stringify({ type: 'text-chunks', seq0: 2, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
  898. ].join('\n') + '\n'
  899. const scanned = scanLog(Buffer.from(logText))
  900. expect(scanned.events.map(e => e.seq)).toEqual([0])
  901. // committedBytes stays on the line boundary BEFORE the dropped row.
  902. const headerAndTurn = logText.split('\n').slice(0, 2).join('\n') + '\n'
  903. expect(scanned.committedBytes).toBe(Buffer.byteLength(headerAndTurn, 'utf8'))
  904. })
  905. it('eventLines(packChunks: false) is byte-identical to the pre-packing layout', () => {
  906. const log = chunkRunLog()
  907. expect(eventLines(log, false)).toBe(log.map(e => JSON.stringify(e)).join('\n'))
  908. })
  909. })
  910. describe('SessionPersistenceJsonl: edge cases', () => {
  911. let ctx: Context
  912. beforeEach(async () => {
  913. root = await freshRoot()
  914. ctx = new Context()
  915. await ctx.plugin(SessionStore)
  916. await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  917. })
  918. afterEach(async () => { await ctx.fiber.dispose() })
  919. it('append rejects non-JSON-serializable undefined-producing data', async () => {
  920. const m = meta('undef')
  921. await ctx.sessionPersistence.create(m)
  922. // A value whose JSON.stringify yields undefined (a bare function as data).
  923. const bad = [{ type: 'user/message', seq: 0, time: 1, data: (() => 0) as unknown }] as unknown as SessionEvent[]
  924. await expect(ctx.sessionPersistence.append(m.id, bad)).rejects.toThrow(/non-JSON-serializable/)
  925. })
  926. it('create snapshots its meta: mutating the caller object after the call is ignored', async () => {
  927. const m = meta('create-snap', '/orig')
  928. const p = ctx.sessionPersistence.create(m)
  929. // Mutate the caller's meta object immediately after calling create.
  930. mutableHeader(m).cwd = '/mutated'
  931. await p
  932. await ctx.sessionPersistence.append(SessionId('create-snap'), oneTurnLog())
  933. // The log materialized under the ORIGINAL cwd, not the mutated one.
  934. expect((await stat(rawLogPath(root, '/orig', SessionId('create-snap')))).isFile()).toBe(true)
  935. await expect(stat(rawLogPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow()
  936. })
  937. it('list discovers sessions across multiple project directories', async () => {
  938. await ctx.sessionPersistence.create(meta('p1', '/projA'))
  939. await ctx.sessionPersistence.append(SessionId('p1'), oneTurnLog())
  940. await ctx.sessionPersistence.create(meta('p2', '/projB'))
  941. await ctx.sessionPersistence.append(SessionId('p2'), oneTurnLog())
  942. await ctx.sessionPersistence.create(meta('p3')) // no cwd → _no-cwd project directory
  943. await ctx.sessionPersistence.append(SessionId('p3'), oneTurnLog())
  944. const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort()
  945. expect(ids).toEqual(['p1', 'p2', 'p3'])
  946. })
  947. it('groups sessions whose cwd paths normalize to the same project directory', async () => {
  948. const first = meta('normalized-first', '/a/b-c')
  949. const second = meta('normalized-second', '/a-b/c')
  950. await ctx.sessionPersistence.create(first)
  951. await ctx.sessionPersistence.append(first.id, oneTurnLog())
  952. await ctx.sessionPersistence.create(second)
  953. await ctx.sessionPersistence.append(second.id, oneTurnLog())
  954. expect(projectDir(root, first.cwd)).toBe(projectDir(root, second.cwd))
  955. expect(await readdir(projectDir(root, first.cwd))).toEqual(expect.arrayContaining([
  956. encodeSegment(first.id),
  957. encodeSegment(second.id),
  958. ]))
  959. expect((await ctx.sessionPersistence.list()).map(header => header.id).sort())
  960. .toEqual([first.id, second.id].sort())
  961. })
  962. it('list on an empty root returns nothing', async () => {
  963. expect(await ctx.sessionPersistence.list()).toEqual([])
  964. })
  965. it('keeps the transcript in an extensible session-owned directory', async () => {
  966. const m = meta('owned-directory', '/project')
  967. await ctx.sessionPersistence.create(m)
  968. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  969. const dir = sessionDir(root, m.cwd, m.id)
  970. await writeFile(join(dir, 'metadata.json'), '{}\n')
  971. await writeFile(join(projectDir(root, m.cwd), 'README'), 'project metadata\n')
  972. await mkdir(join(projectDir(root, m.cwd), 'reserved-session'), { recursive: true })
  973. expect(await readdir(dir)).toEqual(expect.arrayContaining(['metadata.json', 'session.jsonl']))
  974. expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id)
  975. expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog())
  976. })
  977. it('rejects the obsolete flat-file layout instead of ignoring stored sessions', async () => {
  978. const m = meta('legacy-flat', '/legacy')
  979. const project = projectDir(root, m.cwd)
  980. const path = join(project, `${encodeSegment(m.id)}.jsonl`)
  981. await mkdir(project, { recursive: true })
  982. await writeFile(path, [
  983. JSON.stringify(toHeaderLine(m)),
  984. ...oneTurnLog().map(event => JSON.stringify(event)),
  985. '',
  986. ].join('\n'))
  987. await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/)
  988. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/unsupported flat-file layout/)
  989. })
  990. it('rejects a compressed obsolete flat-file artifact during targeted lookup', async () => {
  991. const m = meta('legacy-compressed-flat', '/legacy')
  992. const project = projectDir(root, m.cwd)
  993. expect(await ctx.sessionPersistence.list()).toEqual([])
  994. await mkdir(project, { recursive: true })
  995. await writeFile(join(project, `${encodeSegment(m.id)}.jsonl.zstd`), 'legacy')
  996. await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/)
  997. })
  998. it('list skips empty and non-header session logs (metadata-only read)', async () => {
  999. // A real session…
  1000. await ctx.sessionPersistence.create(meta('real', '/p'))
  1001. await ctx.sessionPersistence.append(SessionId('real'), oneTurnLog())
  1002. // …alongside junk session directories whose fixed transcript is empty or
  1003. // lacks a header. Both remain unmaterialized and are skipped.
  1004. for (const [id, content] of [
  1005. ['empty', ''],
  1006. ['notheader', '{"type":"turn/start"}\n'],
  1007. ['badjson', 'not json at all\n'],
  1008. ] as const) {
  1009. const path = rawLogPath(root, undefined, SessionId(id))
  1010. await mkdir(sessionDir(root, undefined, SessionId(id)), { recursive: true })
  1011. await writeFile(path, content)
  1012. }
  1013. const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort()
  1014. expect(ids).toEqual(['real'])
  1015. })
  1016. it('list reads a header line longer than the 8KB read chunk', async () => {
  1017. // A tolerated extra field makes this valid header exceed the 8192-byte read buffer, proving
  1018. // `readFirstLine` accumulates chunks before `list()` parses it.
  1019. const id = SessionId('big')
  1020. await mkdir(sessionDir(root, undefined, id), { recursive: true })
  1021. const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, delegationDepth: 0, pad: 'x'.repeat(9000) })
  1022. await writeFile(rawLogPath(root, undefined, id), bigHeader + '\n')
  1023. const ids = (await ctx.sessionPersistence.list()).map(x => x.id)
  1024. expect(ids).toContain('big')
  1025. })
  1026. it.each(['sandboxMode', 'approvalPolicy'] as const)('rejects the retired %s header field', (field) => {
  1027. const line = { ...toHeaderLine(meta('retired-policy-header')), [field]: 'read-only' }
  1028. expect(() => scanLog(Buffer.from(`${JSON.stringify(line)}\n`)))
  1029. .toThrow(/retired policy baseline fields/)
  1030. })
  1031. it('list rejects a header whose cwd does not identify its physical log', async () => {
  1032. const m = meta('misplaced', '/stored')
  1033. await ctx.sessionPersistence.create(m)
  1034. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  1035. await rewriteHeader(rawLogPath(root, m.cwd, m.id), (header) => { header.cwd = '/elsewhere' })
  1036. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd identify/)
  1037. })
  1038. it('accepts an alternate project path only when it identifies the same physical log', async () => {
  1039. const m = meta('physical-alias', '/stored')
  1040. await ctx.sessionPersistence.create(m)
  1041. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  1042. const path = rawLogPath(root, m.cwd, m.id)
  1043. const aliasCwd = '/alias'
  1044. await symlink(
  1045. projectDir(root, m.cwd),
  1046. projectDir(root, aliasCwd),
  1047. process.platform === 'win32' ? 'junction' : 'dir',
  1048. )
  1049. await rewriteHeader(path, (header) => { header.cwd = aliasCwd })
  1050. expect((await ctx.sessionPersistence.load(m.id)).meta.cwd).toBe(aliasCwd)
  1051. expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id)
  1052. })
  1053. it('list rejects a session header whose id cannot name a storage path', async () => {
  1054. const dir = join(projectDir(root, undefined), 'invalid-id')
  1055. await mkdir(dir, { recursive: true })
  1056. await writeFile(join(dir, 'session.jsonl'), JSON.stringify({
  1057. type: 'session', version: 0, id: '', createdAt: 1, delegationDepth: 0,
  1058. }) + '\n')
  1059. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header id cannot name a storage path/)
  1060. })
  1061. it('load and list reject one id materialized in multiple project directories', async () => {
  1062. const id = SessionId('duplicate')
  1063. for (const cwd of ['/a', '/b']) {
  1064. const m = meta(id, cwd)
  1065. await mkdir(sessionDir(root, cwd, id), { recursive: true })
  1066. const content = [JSON.stringify(toHeaderLine(m)), ...oneTurnLog().map(event => JSON.stringify(event))].join('\n') + '\n'
  1067. await writeFile(rawLogPath(root, cwd, id), content)
  1068. }
  1069. await expect(ctx.sessionPersistence.load(id)).rejects.toThrow(/appears in multiple project directories/)
  1070. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/appears in multiple project directories/)
  1071. })
  1072. it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => {
  1073. // Session A materializes a log under id "reuse".
  1074. const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => {
  1075. const a = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
  1076. appendLog(a, oneTurnLog())
  1077. }, { inject: ['sessions'] }))
  1078. // Drain A, then dispose ITS fiber (the live session A is gone) while the
  1079. // backend stays loaded.
  1080. for (const s of ctx.sessions.list()) await ctx.sessions.flush(s)
  1081. await sessFiberA.dispose()
  1082. // A new Session object reuses the id. Object-keyed initialization must run independently,
  1083. // detect the disk collision, and reject instead of appending through session A's stale cursor.
  1084. let b!: Session
  1085. await ctx.plugin(Object.assign((inner: Context) => {
  1086. b = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
  1087. }, { inject: ['sessions'] }))
  1088. await expect(ctx.sessions.flush(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/)
  1089. })
  1090. it('a no-cwd live session cannot adopt a same-id log from another cwd', async () => {
  1091. // Backend 1: materialize a log under id "x" in the cwd "/w" bucket, then
  1092. // dispose the WHOLE backend (so backend 2 mounts with an EMPTY states map —
  1093. // the HMR/reload path with no tracked collision state).
  1094. await ctx.sessionPersistence.create(meta('x', '/w'))
  1095. await ctx.sessionPersistence.append(SessionId('x'), oneTurnLog())
  1096. await ctx.fiber.dispose()
  1097. // Backend 2 creates a no-cwd session whose id exists only in `/w`. The
  1098. // stored cwd check rejects instead of grafting no-cwd events onto that log.
  1099. const ctx2 = new Context()
  1100. await ctx2.plugin(SessionStore)
  1101. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  1102. let b!: Session
  1103. await ctx2.plugin(Object.assign((inner: Context) => {
  1104. b = inner.sessions.create(SessionId('x')) // no cwd
  1105. }, { inject: ['sessions'] }))
  1106. await expect(ctx2.sessions.flush(b)).rejects.toThrow(/different cwd|id collision/)
  1107. // The "/w" log is untouched — no no-cwd events were grafted onto it, and no
  1108. // `_no-cwd` log for "x" was created.
  1109. const inW = scanLog(await readFile(rawLogPath(root, '/w', SessionId('x'))))
  1110. expect(inW.meta.cwd).toBe('/w')
  1111. expect(inW.events).toHaveLength(6)
  1112. await expect(stat(rawLogPath(root, undefined, SessionId('x')))).rejects.toThrow()
  1113. await ctx2.fiber.dispose()
  1114. })
  1115. it('a seed with matching seq/type/time but DIFFERENT data is rejected (deep prefix compare)', async () => {
  1116. // Materialize and load (ownerless, cursor = 6).
  1117. await ctx.sessionPersistence.create(meta('divergent', '/a'))
  1118. await ctx.sessionPersistence.append(SessionId('divergent'), oneTurnLog())
  1119. await ctx.sessionPersistence.load(SessionId('divergent'))
  1120. // A seed that keeps every seq/type/time but mutates a payload must NOT be
  1121. // accepted as "the same session" — otherwise drain filters those seqs as
  1122. // already persisted and the divergent payload is silently lost.
  1123. const tampered = structuredClone(oneTurnLog())
  1124. const userMsg = tampered[1]
  1125. if (userMsg?.type === 'user/message') {
  1126. (userMsg.data as { content: unknown[] }).content = [{ type: 'text', text: 'DIFFERENT' }]
  1127. }
  1128. let bad!: Session
  1129. await ctx.plugin(Object.assign((inner: Context) => {
  1130. bad = inner.sessions.create(SessionId('divergent'), { seed: tampered, meta: { cwd: '/a' } })
  1131. }, { inject: ['sessions'] }))
  1132. await expect(ctx.sessions.flush(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/)
  1133. })
  1134. it('a second live session reusing a bound id is rejected', async () => {
  1135. // A live session materializes and owns the id.
  1136. const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
  1137. const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
  1138. a.append('turn/start', { turn: 1 })
  1139. a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  1140. }, { inject: ['sessions'] }))
  1141. for (const s of ctx.sessions.list()) await ctx.sessions.flush(s)
  1142. await firstFiber.dispose()
  1143. let second!: Session
  1144. await ctx.plugin(Object.assign((inner: Context) => {
  1145. second = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
  1146. }, { inject: ['sessions'] }))
  1147. await expect(ctx.sessions.flush(second))
  1148. .rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/)
  1149. })
  1150. it('list returns nothing when the root directory does not exist', async () => {
  1151. const ctx2 = new Context()
  1152. await ctx2.plugin(SessionStore)
  1153. await ctx2.plugin(SessionPersistenceJsonl, {
  1154. root: join(root, 'does-not-exist-yet'),
  1155. compression: 'none',
  1156. })
  1157. expect(await ctx2.sessionPersistence.list()).toEqual([])
  1158. await ctx2.fiber.dispose()
  1159. })
  1160. it('plugin load rejects an existing root that is not a directory', async () => {
  1161. const filePath = join(root, 'not-a-dir')
  1162. await writeFile(filePath, 'x')
  1163. const ctx2 = new Context()
  1164. await ctx2.plugin(SessionStore)
  1165. await expect(ctx2.plugin(SessionPersistenceJsonl, { root: filePath, compression: 'none' })).rejects.toThrow(/ENOTDIR/)
  1166. await ctx2.fiber.dispose()
  1167. })
  1168. it('list surfaces a root that becomes unusable after plugin load', async () => {
  1169. await rm(root, { recursive: true })
  1170. await writeFile(root, 'not a directory')
  1171. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/)
  1172. })
  1173. it('per-id lookup surfaces non-ENOENT storage errors', async () => {
  1174. const blocker = join(root, 'not-a-directory')
  1175. await writeFile(blocker, 'x')
  1176. const backend = ctx.sessionPersistence as unknown as { exists(path: string): Promise<boolean> }
  1177. await expect(backend.exists(join(blocker, 'child.jsonl'))).rejects.toThrow(/ENOTDIR/)
  1178. })
  1179. it('materialization surfaces a project-directory storage fault', async () => {
  1180. const cwd = '/x'
  1181. const ctx2 = new Context()
  1182. await ctx2.plugin(SessionStore)
  1183. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  1184. await writeFile(projectDir(root, cwd), 'x') // project path is now a file
  1185. let s!: Session
  1186. await ctx2.plugin(Object.assign((inner: Context) => {
  1187. s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } })
  1188. appendClosedTurn(s)
  1189. }, { inject: ['sessions'] }))
  1190. await expect(ctx2.sessions.flush(s)).rejects.toThrow(/EEXIST|ENOTDIR/)
  1191. await ctx2.fiber.dispose()
  1192. })
  1193. it('append() to a disk-only session adopts it and repairs a crash tail', async () => {
  1194. // Persist a session, then corrupt its tail, all through ONE backend.
  1195. const m = meta('disk-append', '/d')
  1196. await ctx.sessionPersistence.create(m)
  1197. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  1198. await writeFile(rawLogPath(root, '/d', m.id), '\n{"partial crash', { flag: 'a' })
  1199. // A FRESH backend with no in-memory state: append directly (no prior load)
  1200. // → append must adopt from disk, and the adopt's load schedules a repair
  1201. // that the same append then performs before writing.
  1202. const ctx2 = new Context()
  1203. await ctx2.plugin(SessionStore)
  1204. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  1205. await ctx2.sessionPersistence.append(m.id, [
  1206. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
  1207. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  1208. ] as SessionEvent[])
  1209. const loaded = await ctx2.sessionPersistence.load(m.id)
  1210. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  1211. await ctx2.fiber.dispose()
  1212. })
  1213. it('a header-only log (open turn, no turn/end) preserves the open turn on load and closes it', async () => {
  1214. // A session whose only durable content is an unclosed first turn. scanLog
  1215. // preserves the turn/start; loadCore closes it with a synthetic
  1216. // turn/end {interrupted} so the returned log is balanced.
  1217. const m = meta('open-turn', '/h')
  1218. await ctx.sessionPersistence.create(m)
  1219. await ctx.sessionPersistence.append(m.id, [
  1220. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  1221. ] as SessionEvent[])
  1222. const { events } = await ctx.sessionPersistence.load(m.id)
  1223. expect(events.map(e => e.type)).toEqual(['turn/start', 'turn/end'])
  1224. const end = events[1]!
  1225. expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' })
  1226. })
  1227. it('createCore rejects an id already on disk under a different project directory', async () => {
  1228. // Persist the id under cwd A.
  1229. const a = meta('dup-id', '/projA')
  1230. await ctx.sessionPersistence.create(a)
  1231. await ctx.sessionPersistence.append(a.id, oneTurnLog())
  1232. // A fresh backend creating the SAME id under cwd B must still refuse: load
  1233. // identifies by id across all projects, so a second log would make resume
  1234. // nondeterministic. create scans every project, not just meta.cwd's.
  1235. const ctx2 = new Context()
  1236. await ctx2.plugin(SessionStore)
  1237. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  1238. await expect(ctx2.sessionPersistence.create(meta('dup-id', '/projB')))
  1239. .rejects.toThrow(/already has a persisted log on disk/)
  1240. await ctx2.fiber.dispose()
  1241. })
  1242. it('flush keeps buffered events when the append fails (no silent loss)', async () => {
  1243. root = await freshRoot()
  1244. const ctx2 = new Context()
  1245. await ctx2.plugin(SessionStore)
  1246. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  1247. const session = ctx2.sessions.create(SessionId('flush-fail'))
  1248. // A full turn lands in the write-behind buffer.
  1249. session.append('turn/start', { turn: 1 })
  1250. session.append('user/message', createUserMessage({
  1251. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  1252. }), { surfaceOp: 'append' })
  1253. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  1254. // Make the durable materialize fail on the next flush.
  1255. const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise<void> }
  1256. const origMat = backend.materialize.bind(backend)
  1257. backend.materialize = () => Promise.reject(new Error('disk full'))
  1258. await expectFlushError(ctx2.sessions.flush(session), /disk full/)
  1259. // The events are STILL buffered (not silently dropped): a retry persists them.
  1260. backend.materialize = origMat
  1261. await ctx2.sessions.flush(session)
  1262. const loaded = await ctx2.sessionPersistence.load(SessionId('flush-fail'))
  1263. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2])
  1264. await ctx2.fiber.dispose()
  1265. })
  1266. it('rejects non-JSON event data: BigInt, function, circular, Map, undefined property', async () => {
  1267. const m = meta('serial')
  1268. await ctx.sessionPersistence.create(m)
  1269. const bad = (extra: unknown) => [{
  1270. type: 'user/message',
  1271. seq: 0,
  1272. time: 1,
  1273. data: {
  1274. id: MessageId('invalid-json'),
  1275. role: 'user',
  1276. content: [{ type: 'text', text: 'x' }],
  1277. source: { kind: 'user' },
  1278. extra,
  1279. },
  1280. }] as unknown as SessionEvent[]
  1281. await expect(ctx.sessionPersistence.append(m.id, bad(1n))).rejects.toThrow(/non-JSON-serializable/)
  1282. await expect(ctx.sessionPersistence.append(m.id, bad(() => 0))).rejects.toThrow(/non-JSON-serializable/)
  1283. await expect(ctx.sessionPersistence.append(m.id, bad(Symbol('s')))).rejects.toThrow(/non-JSON-serializable/)
  1284. await expect(ctx.sessionPersistence.append(m.id, bad(new Map()))).rejects.toThrow(/non-JSON-serializable/)
  1285. await expect(ctx.sessionPersistence.append(m.id, bad(undefined))).rejects.toThrow(/non-JSON-serializable/)
  1286. await expect(ctx.sessionPersistence.append(m.id, bad(Infinity))).rejects.toThrow(/non-JSON-serializable/)
  1287. // a circular structure
  1288. const circ: Record<string, unknown> = {}
  1289. circ.self = circ
  1290. await expect(ctx.sessionPersistence.append(m.id, bad(circ))).rejects.toThrow(/non-JSON-serializable/)
  1291. // The session was never materialized by any of the rejected appends.
  1292. expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
  1293. })
  1294. it('accepts well-formed JSON values (null, booleans, nested arrays/objects)', async () => {
  1295. const m = meta('json-ok')
  1296. await ctx.sessionPersistence.create(m)
  1297. const ev = [{ type: 'user/message', seq: 0, time: 1, data: createUserMessage({
  1298. content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: { a: null, b: true, c: [1, 2, { d: 'nested' }] },
  1299. }) }] as unknown as SessionEvent[]
  1300. await ctx.sessionPersistence.append(m.id, ev)
  1301. expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id)
  1302. })
  1303. it('Session.append rejects a non-serializable event at the source (never enters the log)', () => {
  1304. const session = ctx.sessions.create(SessionId('reject-bad'))
  1305. // Serializability is enforced at the source: Session.append throws on a BigInt-bearing
  1306. // event before it enters session.events, so the durable log can never diverge from the live
  1307. // log. The error therefore surfaces synchronously at append, not later during backend flush.
  1308. expect(() => {
  1309. session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' })
  1310. }).toThrow(/non-JSON-serializable/)
  1311. // The bad event was rejected, so the log stayed empty.
  1312. expect(session.events.length).toBe(0)
  1313. })
  1314. })