jsonl.spec.ts 75 KB

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