jsonl.spec.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  7. import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
  8. import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
  9. import { encodeSegment, logPath, scanLog, sessionDir } from '../src/format.ts'
  10. import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
  11. import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
  12. let root: string
  13. const dirs: string[] = []
  14. async function freshRoot(): Promise<string> {
  15. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
  16. dirs.push(dir)
  17. return dir
  18. }
  19. afterEach(async () => {
  20. for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
  21. })
  22. // Run the shared backend contract against the real JSONL backend.
  23. runPersistenceContract('jsonl', async () => {
  24. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
  25. const ctx = new Context()
  26. await ctx.plugin(SessionStore)
  27. const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir })
  28. return {
  29. persistence: ctx.sessionPersistence,
  30. dispose: async () => {
  31. await fiber.dispose()
  32. await rm(dir, { recursive: true, force: true })
  33. },
  34. }
  35. })
  36. // Run the shared coordinator orchestration suite against the real JSONL backend.
  37. // One temp root is the shared storage scope (two mounted instances over the same
  38. // root = HMR/reload). `corruptTail` appends a partial, newline-less fragment to
  39. // the session's .jsonl past the committed region — a never-committed torn tail
  40. // that drives the coordinator's commitRepair-with-tornMarker branch over real
  41. // file bytes.
  42. runCoordinatorContract('jsonl', async (): Promise<CoordinatorFixture> => {
  43. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-'))
  44. return {
  45. mount: async (ctx) => {
  46. const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir })
  47. return fiber
  48. },
  49. corruptTail: async (id, cwd) => {
  50. // A half-written record with no trailing newline: scanLog treats it as an
  51. // uncommitted crash fragment and reports committedBytes < byteLength, so
  52. // the coordinator sees a tornMarker to truncate.
  53. await appendFile(logPath(dir, cwd, id), '{"type":"assistant/chunk","seq":8,"ti')
  54. },
  55. cleanup: async () => { await rm(dir, { recursive: true, force: true }) },
  56. }
  57. })
  58. describe('SessionPersistenceJsonl: format helpers', () => {
  59. it('encodeSegment neutralizes traversal, separators, and absolute paths', () => {
  60. expect(encodeSegment('..')).toBe('~002E~002E')
  61. expect(encodeSegment('.')).toBe('~002E')
  62. expect(encodeSegment('a/b')).toBe('a~002Fb')
  63. expect(encodeSegment('/etc/passwd')).toBe('~002Fetc~002Fpasswd')
  64. expect(encodeSegment('a\u0000b')).toBe('a~0000b')
  65. expect(encodeSegment('plain-ID_1.2')).toBe('plain-ID_1.2') // safe chars pass through
  66. expect(encodeSegment('a~b')).toBe('a~007Eb') // ~ itself is escaped
  67. })
  68. it('encodeSegment is injective over UTF-16, incl. lone surrogates', () => {
  69. // Distinct lone surrogates must NOT collide (Buffer.from would normalize
  70. // both to U+FFFD; code-unit escaping keeps them distinct).
  71. const hi = encodeSegment(String.fromCharCode(0xD800))
  72. const lo = encodeSegment(String.fromCharCode(0xDC00))
  73. expect(hi).toBe('~D800')
  74. expect(lo).toBe('~DC00')
  75. expect(hi).not.toBe(lo)
  76. // A literal "~002F" input cannot collide with the encoding of "/".
  77. expect(encodeSegment('~002F')).not.toBe(encodeSegment('/'))
  78. })
  79. it('encodeSegment rejects an empty id', () => {
  80. expect(() => encodeSegment('')).toThrow(/empty/)
  81. })
  82. })
  83. describe('SessionPersistenceJsonl: durability and crash semantics', () => {
  84. let ctx: Context
  85. beforeEach(async () => {
  86. root = await freshRoot()
  87. ctx = new Context()
  88. await ctx.plugin(SessionStore)
  89. await ctx.plugin(SessionPersistenceJsonl, { root })
  90. })
  91. afterEach(async () => { await ctx.fiber.dispose() })
  92. it('lazy materialization: create() writes no file until the first append', async () => {
  93. const m = meta('lazy', '/work')
  94. await ctx.sessionPersistence.create(m)
  95. // nothing on disk yet
  96. const dir = sessionDir(root, '/work')
  97. await expect(stat(logPath(root, '/work', m.id))).rejects.toThrow()
  98. expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
  99. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  100. // now materialized
  101. expect((await stat(logPath(root, '/work', m.id))).isFile()).toBe(true)
  102. expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id)
  103. void dir
  104. })
  105. it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => {
  106. const m = meta('chunks')
  107. const log: SessionEvent[] = [
  108. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  109. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  110. { type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'he' } } },
  111. { type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'llo' } } },
  112. { type: 'assistant/message', seq: 4, time: 5, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'hello' }] }, surfaceOp: 'append', sourceEventSeqs: [2, 3] },
  113. { type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
  114. { type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
  115. ]
  116. await ctx.sessionPersistence.create(m)
  117. await ctx.sessionPersistence.append(m.id, log)
  118. const loaded = await ctx.sessionPersistence.load(m.id)
  119. expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs
  120. })
  121. it('crash recovery: load preserves the interrupted turn and closes it with a synthetic turn/end {interrupted}', async () => {
  122. const m = meta('crash', '/proj')
  123. await ctx.sessionPersistence.create(m)
  124. await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5, turn/end at 5
  125. // Simulate a crash mid-second-turn: append raw lines that are NOT closed by
  126. // a turn/end (turn/start + step/start are fully written), plus a final
  127. // partial line with no newline (a torn fragment never fully flushed).
  128. const path = logPath(root, '/proj', m.id)
  129. await writeFile(path, [
  130. JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  131. JSON.stringify({ type: 'step/start', seq: 7, time: 9, data: { turn: 2, step: 1 } }),
  132. '{"type":"assistant/chunk","seq":8,"ti', // truncated partial line (no newline)
  133. ].join('\n'), { flag: 'a' })
  134. // load PRESERVES the interrupted turn's real events (turn/start 6, step/start
  135. // 7) — a turn can be huge, so they must not be truncated — and durably closes
  136. // the orphaned turn with synthetic step/end (8) + turn/end {interrupted} (9).
  137. const loaded = await ctx.sessionPersistence.load(m.id)
  138. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
  139. const last = loaded.events.at(-1)!
  140. expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
  141. const stepEnd = loaded.events[8]!
  142. expect(stepEnd.type).toBe('step/end')
  143. // the torn seq-8 chunk fragment did not survive
  144. expect(loaded.events.some(e => e.type === 'assistant/chunk' && e.seq === 8)).toBe(false)
  145. // The next append continues at seq 10 (the balanced length).
  146. const turn3 = [
  147. { type: 'turn/start', seq: 10, time: 11, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } },
  148. { type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'completed' } } },
  149. ] as SessionEvent[]
  150. await ctx.sessionPersistence.append(m.id, turn3)
  151. const reloaded = await ctx.sessionPersistence.load(m.id)
  152. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
  153. })
  154. it('committed events are never rewritten: only the crash tail is repaired', async () => {
  155. const m = meta('append-only')
  156. await ctx.sessionPersistence.create(m)
  157. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  158. const before = await readFile(logPath(root, undefined, m.id), 'utf8')
  159. const committedPrefix = before // the whole committed log
  160. // A crash tail then a repair-append.
  161. await writeFile(logPath(root, undefined, m.id), '\n{"partial', { flag: 'a' })
  162. await ctx.sessionPersistence.load(m.id)
  163. await ctx.sessionPersistence.append(m.id, [
  164. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  165. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  166. ] as SessionEvent[])
  167. const after = await readFile(logPath(root, undefined, m.id), 'utf8')
  168. // the committed prefix is byte-for-byte intact at the head of the file
  169. expect(after.startsWith(committedPrefix)).toBe(true)
  170. })
  171. it('a failed appendLines truncates partial bytes so a retry has no seq gap', async () => {
  172. const m = meta('truncate-retry')
  173. await ctx.sessionPersistence.create(m)
  174. await ctx.sessionPersistence.append(m.id, oneTurnLog()) // materialized, seqs 0..5
  175. const sizeBefore = (await stat(logPath(root, undefined, m.id))).size
  176. // Force the NEXT fsync (inside appendLines) to fail once, AFTER writeFile
  177. // has already put bytes on disk — simulating an ENOSPC/fsync error
  178. // mid-append. The recovery truncate() also fsyncs, so allow that one.
  179. const handle = await (await import('node:fs/promises')).open(logPath(root, undefined, m.id), 'r')
  180. const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
  181. await handle.close()
  182. const realSync = proto.sync
  183. let failed = false
  184. const spy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
  185. if (!failed) { failed = true; throw new Error('simulated fsync ENOSPC') }
  186. return realSync.call(this)
  187. })
  188. const turn2 = [
  189. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  190. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  191. ] as SessionEvent[]
  192. // The append rejects, but the partial bytes are truncated back: the file is
  193. // its pre-append size and the cursor is unchanged.
  194. await expect(ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/ENOSPC/)
  195. expect((await stat(logPath(root, undefined, m.id))).size).toBe(sizeBefore)
  196. spy.mockRestore()
  197. // The retry now succeeds with NO seq gap — the log is contiguous 0..7.
  198. await ctx.sessionPersistence.append(m.id, turn2)
  199. const loaded = await ctx.sessionPersistence.load(m.id)
  200. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  201. })
  202. it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => {
  203. const m = meta('meta-copy', '/proj')
  204. await ctx.sessionPersistence.create(m)
  205. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  206. const loaded = await ctx.sessionPersistence.load(m.id)
  207. // A consumer mutates the returned meta's cwd. The backend's stored pathing
  208. // metadata must be unaffected, so a later append still finds the right log.
  209. loaded.meta.cwd = '/evil'
  210. await ctx.sessionPersistence.append(m.id, [
  211. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  212. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  213. ] as SessionEvent[])
  214. // The append landed in the ORIGINAL /proj log, not beside an /evil path.
  215. const reloaded = await ctx.sessionPersistence.load(m.id)
  216. expect(reloaded.meta.cwd).toBe('/proj')
  217. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  218. })
  219. it('rejects a re-append of an already-stored seq', async () => {
  220. const m = meta('reappend')
  221. await ctx.sessionPersistence.create(m)
  222. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  223. await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toThrow(/seq mismatch/)
  224. })
  225. it('path-traversal session ids are neutralized (no escape from root)', async () => {
  226. const evil = SessionId('../../etc/pwn')
  227. const m = { version: 0, id: evil, createdAt: 1 }
  228. await ctx.sessionPersistence.create(m)
  229. await ctx.sessionPersistence.append(evil, oneTurnLog())
  230. // The file lives UNDER root, not at ../../etc.
  231. const all: string[] = []
  232. async function walk(dir: string): Promise<void> {
  233. for (const e of await readdir(dir, { withFileTypes: true })) {
  234. const p = join(dir, e.name)
  235. if (e.isDirectory()) await walk(p)
  236. else all.push(p)
  237. }
  238. }
  239. await walk(root)
  240. expect(all.length).toBeGreaterThan(0)
  241. expect(all.every(p => p.startsWith(root))).toBe(true)
  242. })
  243. })
  244. describe('SessionPersistenceJsonl: write path (session/event → flush)', () => {
  245. it('concurrent sessions do not cross buffers', async () => {
  246. root = await freshRoot()
  247. const ctx = new Context()
  248. await ctx.plugin(SessionStore)
  249. await ctx.plugin(SessionPersistenceJsonl, { root })
  250. const a = ctx.sessions.create(SessionId('sa'))
  251. const b = ctx.sessions.create(SessionId('sb'))
  252. a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  253. b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  254. a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  255. b.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  256. await ctx.parallel('session/flush', a)
  257. await ctx.parallel('session/flush', b)
  258. const la = await ctx.sessionPersistence.load(SessionId('sa'))
  259. const lb = await ctx.sessionPersistence.load(SessionId('sb'))
  260. expect(JSON.stringify(la.events)).toContain('"A"')
  261. expect(JSON.stringify(la.events)).not.toContain('"B"')
  262. expect(JSON.stringify(lb.events)).toContain('"B"')
  263. expect(JSON.stringify(lb.events)).not.toContain('"A"')
  264. await ctx.fiber.dispose()
  265. })
  266. })
  267. describe('SessionPersistenceJsonl: scanLog unit', () => {
  268. it('rejects a header-less / empty log', () => {
  269. expect(() => scanLog(Buffer.from(''))).toThrow()
  270. })
  271. it('rejects a corrupt header line', () => {
  272. expect(() => scanLog(Buffer.from('not json\n'))).toThrow(/header/)
  273. })
  274. it('rejects a non-session first line', () => {
  275. expect(() => scanLog(Buffer.from('{"type":"event"}\n'))).toThrow(/session header/)
  276. })
  277. it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => {
  278. const log = [
  279. JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1 }),
  280. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  281. JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
  282. ].join('\n') + '\n'
  283. // No committed turn/end, so the gap is a tolerated crash boundary: scanLog
  284. // PRESERVES the contiguous prefix (turn/start seq 0) — real interrupted-turn
  285. // work, not discarded — and stops at the gap. The orphaned open turn is
  286. // closed by loadCore's synthetic turn/end, not here.
  287. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
  288. })
  289. it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => {
  290. const log = [
  291. JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1 }),
  292. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  293. JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
  294. JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
  295. ].join('\n') + '\n'
  296. // A turn/end exists, so the prefix up to it is committed — but it has a hole.
  297. // Truncating it would silently drop committed data → unloadable.
  298. expect(() => scanLog(Buffer.from(log))).toThrow(/seq gap in committed region/)
  299. })
  300. it('rejects a corrupt line BEFORE a later committed turn/end (committed data damaged)', () => {
  301. const log = [
  302. JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1 }),
  303. '{not json', // corrupt, sits in the committed region (a turn/end follows)
  304. JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
  305. ].join('\n') + '\n'
  306. expect(() => scanLog(Buffer.from(log))).toThrow(/unparsable committed event/)
  307. })
  308. it('a header-only log (no event lines at all) preserves nothing — committedBytes is the header', () => {
  309. const log = JSON.stringify({ type: 'session', version: 0, id: 'h0', createdAt: 1 }) + '\n'
  310. const scanned = scanLog(Buffer.from(log))
  311. expect(scanned.events).toEqual([])
  312. // committedBytes falls back to the header line's end (no preserved events).
  313. expect(scanned.committedBytes).toBe(Buffer.byteLength(log, 'utf8'))
  314. })
  315. it('a corrupt line after the last turn/end bounds the preserved tail', () => {
  316. const log = [
  317. JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1 }),
  318. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  319. '{not json', // corrupt crash fragment, no turn/end committed
  320. ].join('\n') + '\n'
  321. // The contiguous prefix (turn/start seq 0) is preserved; the corrupt
  322. // fragment after it is the tolerated crash boundary.
  323. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
  324. })
  325. it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => {
  326. const log = [
  327. JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1 }),
  328. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  329. JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
  330. JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail
  331. ].join('\n') + '\n'
  332. const { events } = scanLog(Buffer.from(log))
  333. expect(events.map(e => e.seq)).toEqual([0, 1]) // tail dropped
  334. })
  335. })
  336. describe('SessionPersistenceJsonl: edge cases', () => {
  337. let ctx: Context
  338. beforeEach(async () => {
  339. root = await freshRoot()
  340. ctx = new Context()
  341. await ctx.plugin(SessionStore)
  342. await ctx.plugin(SessionPersistenceJsonl, { root })
  343. })
  344. afterEach(async () => { await ctx.fiber.dispose() })
  345. it('append rejects non-JSON-serializable undefined-producing data', async () => {
  346. const m = meta('undef')
  347. await ctx.sessionPersistence.create(m)
  348. // A value whose JSON.stringify yields undefined (a bare function as data).
  349. const bad = [{ type: 'user/message', seq: 0, time: 1, data: (() => 0) as unknown }] as unknown as SessionEvent[]
  350. await expect(ctx.sessionPersistence.append(m.id, bad)).rejects.toThrow(/non-JSON-serializable/)
  351. })
  352. it('create snapshots its meta: mutating the caller object after the call is ignored', async () => {
  353. const m = meta('create-snap', '/orig')
  354. const p = ctx.sessionPersistence.create(m)
  355. // Mutate the caller's meta object immediately after calling create.
  356. m.cwd = '/mutated'
  357. await p
  358. await ctx.sessionPersistence.append(SessionId('create-snap'), oneTurnLog())
  359. // The log materialized under the ORIGINAL cwd, not the mutated one.
  360. expect((await stat(logPath(root, '/orig', SessionId('create-snap')))).isFile()).toBe(true)
  361. await expect(stat(logPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow()
  362. })
  363. it('list discovers sessions across multiple cwd buckets', async () => {
  364. await ctx.sessionPersistence.create(meta('p1', '/projA'))
  365. await ctx.sessionPersistence.append(SessionId('p1'), oneTurnLog())
  366. await ctx.sessionPersistence.create(meta('p2', '/projB'))
  367. await ctx.sessionPersistence.append(SessionId('p2'), oneTurnLog())
  368. await ctx.sessionPersistence.create(meta('p3')) // no cwd → _no-cwd bucket
  369. await ctx.sessionPersistence.append(SessionId('p3'), oneTurnLog())
  370. const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort()
  371. expect(ids).toEqual(['p1', 'p2', 'p3'])
  372. })
  373. it('list on an empty root returns nothing', async () => {
  374. expect(await ctx.sessionPersistence.list()).toEqual([])
  375. })
  376. it('list skips empty and non-header .jsonl files (metadata-only read)', async () => {
  377. // A real session…
  378. await ctx.sessionPersistence.create(meta('real', '/p'))
  379. await ctx.sessionPersistence.append(SessionId('real'), oneTurnLog())
  380. // …alongside two junk files in the _no-cwd bucket: an EMPTY file (readFirstLine
  381. // returns undefined) and a file whose first line is not a session header
  382. // (parseHeaderMeta returns undefined). Both are skipped, not listed.
  383. const bucket = join(root, '_no-cwd')
  384. await mkdir(bucket, { recursive: true })
  385. await writeFile(join(bucket, 'empty.jsonl'), '')
  386. await writeFile(join(bucket, 'notheader.jsonl'), '{"type":"turn/start"}\n')
  387. await writeFile(join(bucket, 'badjson.jsonl'), 'not json at all\n')
  388. const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort()
  389. expect(ids).toEqual(['real'])
  390. })
  391. it('list reads a header line longer than the 8KB read chunk', async () => {
  392. // readFirstLine accumulates across reads when the first line exceeds its
  393. // buffer. Plant a valid header whose line is > 8192 bytes (a long extra
  394. // field is tolerated by the header type guard) and confirm list() reads it.
  395. const bucket = join(root, '_no-cwd')
  396. await mkdir(bucket, { recursive: true })
  397. const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, pad: 'x'.repeat(9000) })
  398. await writeFile(join(bucket, 'big.jsonl'), bigHeader + '\n')
  399. const ids = (await ctx.sessionPersistence.list()).map(x => x.id)
  400. expect(ids).toContain('big')
  401. })
  402. it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => {
  403. // Session A materializes a log under id "reuse".
  404. const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => {
  405. const a = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
  406. appendLog(a, oneTurnLog())
  407. }, { inject: ['sessions'] }))
  408. // Drain A, then dispose ITS fiber (the live session A is gone) while the
  409. // backend stays loaded.
  410. for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s)
  411. await sessFiberA.dispose()
  412. // A NEW live Session object reuses id "reuse". The init cache is keyed by
  413. // the Session OBJECT, so this gets its OWN onCreated (not A's stale promise)
  414. // — which detects the on-disk collision and rejects, rather than silently
  415. // appending the new session's events onto A's log under a stale cursor.
  416. const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
  417. let b!: Session
  418. await ctx.plugin(Object.assign((inner: Context) => {
  419. b = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
  420. }, { inject: ['sessions'] }))
  421. await expect(backend.inits.get(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/)
  422. })
  423. it('a NO-CWD live session does NOT cross-cwd-adopt a same-id log from a real cwd bucket (loadLive is scope-exact)', async () => {
  424. // Backend 1: materialize a log under id "x" in the cwd "/w" bucket, then
  425. // dispose the WHOLE backend (so backend 2 mounts with an EMPTY states map —
  426. // the HMR/reload path where onCreated goes through loadLive, not a tracked
  427. // collision).
  428. await ctx.sessionPersistence.create(meta('x', '/w'))
  429. await ctx.sessionPersistence.append(SessionId('x'), oneTurnLog())
  430. await ctx.fiber.dispose()
  431. // Backend 2 over the SAME root. A live no-cwd session reuses id "x". Because
  432. // loadLive(id, undefined) is the DEFINITE no-cwd bucket (NOT an all-buckets
  433. // scan), case-2 adoption does NOT match the "/w" log — so it would NOT
  434. // silently graft the no-cwd events onto the "/w" log with a mismatched cwd
  435. // (the bug a non-scope-exact loadLive caused). It falls through to the
  436. // new-session path, where createCore's any-cwd collision probe (loadStored)
  437. // catches the duplicate id and REJECTS — the id is taken in another bucket.
  438. const ctx2 = new Context()
  439. await ctx2.plugin(SessionStore)
  440. await ctx2.plugin(SessionPersistenceJsonl, { root })
  441. const backend = ctx2.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
  442. let b!: Session
  443. await ctx2.plugin(Object.assign((inner: Context) => {
  444. b = inner.sessions.create(SessionId('x')) // no cwd
  445. }, { inject: ['sessions'] }))
  446. await expect(backend.inits.get(b)).rejects.toThrow(/already has a persisted log on disk/)
  447. // The "/w" log is untouched — no no-cwd events were grafted onto it, and no
  448. // `_no-cwd` log for "x" was created.
  449. const inW = scanLog(await readFile(logPath(root, '/w', SessionId('x'))))
  450. expect(inW.meta.cwd).toBe('/w')
  451. expect(inW.events).toHaveLength(6)
  452. await expect(stat(logPath(root, undefined, SessionId('x')))).rejects.toThrow()
  453. await ctx2.fiber.dispose()
  454. })
  455. it('a seed with matching seq/type/time but DIFFERENT data is rejected (deep prefix compare)', async () => {
  456. // Materialize and load (ownerless, cursor = 6).
  457. await ctx.sessionPersistence.create(meta('divergent', '/a'))
  458. await ctx.sessionPersistence.append(SessionId('divergent'), oneTurnLog())
  459. await ctx.sessionPersistence.load(SessionId('divergent'))
  460. const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
  461. // A seed that keeps every seq/type/time but mutates a payload must NOT be
  462. // accepted as "the same session" — otherwise drain filters those seqs as
  463. // already persisted and the divergent payload is silently lost.
  464. const tampered = oneTurnLog()
  465. const userMsg = tampered[1]
  466. if (userMsg?.type === 'user/message') userMsg.data.content = [{ type: 'text', text: 'DIFFERENT' }]
  467. let bad!: Session
  468. await ctx.plugin(Object.assign((inner: Context) => {
  469. bad = inner.sessions.create(SessionId('divergent'), { seed: tampered, meta: { cwd: '/a' } })
  470. }, { inject: ['sessions'] }))
  471. await expect(backend.inits.get(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/)
  472. })
  473. it('a second live session reusing a bound id is rejected', async () => {
  474. // A live session materializes and owns the id.
  475. const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
  476. const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
  477. a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  478. a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  479. }, { inject: ['sessions'] }))
  480. for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s)
  481. await firstFiber.dispose()
  482. const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
  483. let second!: Session
  484. await ctx.plugin(Object.assign((inner: Context) => {
  485. second = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
  486. }, { inject: ['sessions'] }))
  487. await expect(backend.inits.get(second))
  488. .rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/)
  489. })
  490. it('list returns nothing when the root directory does not exist', async () => {
  491. const ctx2 = new Context()
  492. await ctx2.plugin(SessionStore)
  493. await ctx2.plugin(SessionPersistenceJsonl, { root: join(root, 'does-not-exist-yet') })
  494. expect(await ctx2.sessionPersistence.list()).toEqual([])
  495. await ctx2.fiber.dispose()
  496. })
  497. it('list surfaces a non-ENOENT root error (ENOTDIR) instead of reporting no sessions', async () => {
  498. // A durable backend must NOT collapse a storage fault to "no sessions". Point
  499. // the root at a regular FILE: readdir then fails with ENOTDIR, which must
  500. // propagate rather than be swallowed as an empty listing.
  501. const filePath = join(root, 'not-a-dir')
  502. await writeFile(filePath, 'x')
  503. const ctx2 = new Context()
  504. await ctx2.plugin(SessionStore)
  505. await ctx2.plugin(SessionPersistenceJsonl, { root: filePath })
  506. await expect(ctx2.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/)
  507. await ctx2.fiber.dispose()
  508. })
  509. it('loadLive surfaces a non-ENOENT lookup error (ENOTDIR) instead of reporting absent', async () => {
  510. // A non-ENOENT error from the per-id open() must surface, not be collapsed to
  511. // "not found" (which would let live-adoption proceed under a false absence
  512. // assumption). A live session's onCreated reaches loadLive(id, cwd) →
  513. // exists(logPath). Make that cwd's bucket DIRECTORY a regular file: open()ing
  514. // `bucket/<id>.jsonl` under it then fails ENOTDIR.
  515. const cwd = '/x'
  516. const ctx2 = new Context()
  517. await ctx2.plugin(SessionStore)
  518. await ctx2.plugin(SessionPersistenceJsonl, { root })
  519. await writeFile(sessionDir(root, cwd), 'x') // bucket path is now a FILE
  520. const backend = ctx2.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
  521. let s!: Session
  522. await ctx2.plugin(Object.assign((inner: Context) => {
  523. s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } })
  524. }, { inject: ['sessions'] }))
  525. await expect(backend.inits.get(s)).rejects.toThrow(/ENOTDIR/)
  526. await ctx2.fiber.dispose()
  527. })
  528. it('append() to a disk-only session adopts it and repairs a crash tail', async () => {
  529. // Persist a session, then corrupt its tail, all through ONE backend.
  530. const m = meta('disk-append', '/d')
  531. await ctx.sessionPersistence.create(m)
  532. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  533. await writeFile(logPath(root, '/d', m.id), '\n{"partial crash', { flag: 'a' })
  534. // A FRESH backend with no in-memory state: append directly (no prior load)
  535. // → append must adopt from disk, and the adopt's load schedules a repair
  536. // that the same append then performs before writing.
  537. const ctx2 = new Context()
  538. await ctx2.plugin(SessionStore)
  539. await ctx2.plugin(SessionPersistenceJsonl, { root })
  540. await ctx2.sessionPersistence.append(m.id, [
  541. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  542. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  543. ] as SessionEvent[])
  544. const loaded = await ctx2.sessionPersistence.load(m.id)
  545. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  546. await ctx2.fiber.dispose()
  547. })
  548. it('a header-only log (open turn, no turn/end) preserves the open turn on load and closes it', async () => {
  549. // A session whose only durable content is an unclosed first turn. scanLog
  550. // preserves the turn/start; loadCore closes it with a synthetic
  551. // turn/end {interrupted} so the returned log is balanced.
  552. const m = meta('open-turn', '/h')
  553. await ctx.sessionPersistence.create(m)
  554. await ctx.sessionPersistence.append(m.id, [
  555. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  556. ] as SessionEvent[])
  557. const { events } = await ctx.sessionPersistence.load(m.id)
  558. expect(events.map(e => e.type)).toEqual(['turn/start', 'turn/end'])
  559. const end = events[1]!
  560. expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' })
  561. })
  562. it('createCore rejects an id already on disk under a DIFFERENT cwd bucket', async () => {
  563. // Persist the id under cwd A.
  564. const a = meta('dup-id', '/projA')
  565. await ctx.sessionPersistence.create(a)
  566. await ctx.sessionPersistence.append(a.id, oneTurnLog())
  567. // A fresh backend creating the SAME id under cwd B must still refuse: load
  568. // identifies by id across all buckets, so a second log would make resume
  569. // nondeterministic. create scans every bucket, not just meta.cwd's.
  570. const ctx2 = new Context()
  571. await ctx2.plugin(SessionStore)
  572. await ctx2.plugin(SessionPersistenceJsonl, { root })
  573. await expect(ctx2.sessionPersistence.create(meta('dup-id', '/projB')))
  574. .rejects.toThrow(/already has a persisted log on disk/)
  575. await ctx2.fiber.dispose()
  576. })
  577. it('flush keeps buffered events when the append fails (no silent loss)', async () => {
  578. root = await freshRoot()
  579. const ctx2 = new Context()
  580. await ctx2.plugin(SessionStore)
  581. await ctx2.plugin(SessionPersistenceJsonl, { root })
  582. const session = ctx2.sessions.create(SessionId('flush-fail'))
  583. // A full turn lands in the write-behind buffer.
  584. session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  585. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  586. // Make the durable materialize fail on the next flush.
  587. const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise<void> }
  588. const origMat = backend.materialize.bind(backend)
  589. backend.materialize = () => Promise.reject(new Error('disk full'))
  590. await expect(ctx2.parallel('session/flush', session)).rejects.toThrow(/disk full/)
  591. // The events are STILL buffered (not silently dropped): a retry persists them.
  592. backend.materialize = origMat
  593. await ctx2.parallel('session/flush', session)
  594. const loaded = await ctx2.sessionPersistence.load(SessionId('flush-fail'))
  595. expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
  596. await ctx2.fiber.dispose()
  597. })
  598. it('rejects non-JSON event data: BigInt, function, circular, Map, undefined property', async () => {
  599. const m = meta('serial')
  600. await ctx.sessionPersistence.create(m)
  601. const bad = (extra: unknown) => [{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } }] as unknown as SessionEvent[]
  602. await expect(ctx.sessionPersistence.append(m.id, bad(1n))).rejects.toThrow(/non-JSON-serializable/)
  603. await expect(ctx.sessionPersistence.append(m.id, bad(() => 0))).rejects.toThrow(/non-JSON-serializable/)
  604. await expect(ctx.sessionPersistence.append(m.id, bad(Symbol('s')))).rejects.toThrow(/non-JSON-serializable/)
  605. await expect(ctx.sessionPersistence.append(m.id, bad(new Map()))).rejects.toThrow(/non-JSON-serializable/)
  606. await expect(ctx.sessionPersistence.append(m.id, bad(undefined))).rejects.toThrow(/non-JSON-serializable/)
  607. await expect(ctx.sessionPersistence.append(m.id, bad(Infinity))).rejects.toThrow(/non-JSON-serializable/)
  608. // a circular structure
  609. const circ: Record<string, unknown> = {}
  610. circ.self = circ
  611. await expect(ctx.sessionPersistence.append(m.id, bad(circ))).rejects.toThrow(/non-JSON-serializable/)
  612. // The session was never materialized by any of the rejected appends.
  613. expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
  614. })
  615. it('accepts well-formed JSON values (null, booleans, nested arrays/objects)', async () => {
  616. const m = meta('json-ok')
  617. await ctx.sessionPersistence.create(m)
  618. const ev = [{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: { a: null, b: true, c: [1, 2, { d: 'nested' }] } } }] as unknown as SessionEvent[]
  619. await ctx.sessionPersistence.append(m.id, ev)
  620. expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id)
  621. })
  622. it('Session.append rejects a non-serializable event at the source (never enters the log)', () => {
  623. const session = ctx.sessions.create(SessionId('reject-bad'))
  624. // Serializability is enforced at the source: Session.append throws on a
  625. // BigInt-bearing event BEFORE it enters session.events, so the durable log
  626. // can never diverge from the live log. The throw surfaces at the caller's
  627. // append site, not asynchronously in a backend flush.
  628. expect(() => {
  629. session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' })
  630. }).toThrow(/non-JSON-serializable/)
  631. // The bad event was rejected, so the log stayed empty.
  632. expect(session.events.length).toBe(0)
  633. })
  634. })