jsonl.spec.ts 120 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665
  1. import { MessageId, 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, join, relative, resolve } from 'node:path'
  7. import { scheduler } from 'node:timers/promises'
  8. import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq, SessionId } from '@deepseek-ai/dsh-session'
  9. import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
  10. import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  11. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  12. import {
  13. assertNoRetiredHeaderFields, encodeSegment, eventLines, generationLogFilename, generationLogPath,
  14. logPath, parseGenerationLogFilename, projectDir, projectKey, scanLog, sessionDir, SessionLogScanner,
  15. toHeaderLine,
  16. } from '../src/format.ts'
  17. import {
  18. runPersistenceContract, meta, oneTurnLog, releasedV1OneTurnLog,
  19. } from '../../session-persistence/tests/contract.ts'
  20. import { runLiveWritePathContract } from '../../session-persistence/tests/live-write-contract.ts'
  21. import { LIVE_WRITE_BATCH_MAX_DELAY_MS, type JsonlSessionHandle } from '../src/storage.ts'
  22. import { JsonlGenerationSourceChangedError } from '../src/generation.ts'
  23. import SessionStore from '@deepseek-ai/dsh-session'
  24. const statRace = vi.hoisted(() => ({
  25. path: undefined as string | undefined,
  26. reads: 0,
  27. /** 'settle': the revision changes once and then holds; 'churn': every stat differs. */
  28. mode: 'settle' as 'settle' | 'churn',
  29. }))
  30. const statFailure = vi.hoisted(() => ({
  31. path: undefined as string | undefined,
  32. error: undefined as Error | undefined,
  33. }))
  34. const readdirFailure = vi.hoisted(() => ({
  35. path: undefined as string | undefined,
  36. error: undefined as Error | undefined,
  37. }))
  38. const readTally = vi.hoisted(() => ({
  39. /** Physical whole-file reads per path suffix; keyed by session id segment. */
  40. bySuffix: new Map<string, number>(),
  41. enabled: false,
  42. }))
  43. const readFailure = vi.hoisted(() => ({
  44. path: undefined as string | undefined,
  45. error: undefined as Error | undefined,
  46. }))
  47. const pausedRead = vi.hoisted(() => ({
  48. path: undefined as string | undefined,
  49. active: false,
  50. entered: undefined as (() => void) | undefined,
  51. resume: undefined as Promise<void> | undefined,
  52. release: undefined as (() => void) | undefined,
  53. done: undefined as Promise<void> | undefined,
  54. finished: undefined as (() => void) | undefined,
  55. }))
  56. vi.mock('node:fs/promises', async (importOriginal) => {
  57. const actual = await importOriginal<typeof import('node:fs/promises')>()
  58. return {
  59. ...actual,
  60. stat: (async (...args: Parameters<typeof actual.stat>) => {
  61. if (String(args[0]) === statFailure.path && statFailure.error !== undefined) throw statFailure.error
  62. const identity = await actual.stat(...args)
  63. if (String(args[0]) !== statRace.path || !('mtimeNs' in identity)) return identity
  64. statRace.reads += 1
  65. if (statRace.mode === 'churn') return { ...identity, mtimeNs: identity.mtimeNs + BigInt(statRace.reads) }
  66. if (statRace.reads < 3) return identity
  67. return { ...identity, mtimeNs: identity.mtimeNs + 1n }
  68. }) as typeof actual.stat,
  69. readFile: (async (...args: Parameters<typeof actual.readFile>) => {
  70. const path = typeof args[0] === 'string' ? args[0] : undefined
  71. if (path === readFailure.path && readFailure.error !== undefined) throw readFailure.error
  72. if (readTally.enabled && path !== undefined) {
  73. readTally.bySuffix.set(path, (readTally.bySuffix.get(path) ?? 0) + 1)
  74. }
  75. if (path !== pausedRead.path || pausedRead.resume === undefined) {
  76. return actual.readFile(...args)
  77. }
  78. const resume = pausedRead.resume
  79. const finished = pausedRead.finished
  80. pausedRead.active = true
  81. pausedRead.entered?.()
  82. await resume
  83. try {
  84. return await actual.readFile(...args)
  85. } finally {
  86. pausedRead.active = false
  87. finished?.()
  88. }
  89. }) as typeof actual.readFile,
  90. readdir: (async (...args: Parameters<typeof actual.readdir>) => {
  91. if (String(args[0]) === readdirFailure.path && readdirFailure.error !== undefined) {
  92. throw readdirFailure.error
  93. }
  94. return actual.readdir(...args)
  95. }) as typeof actual.readdir,
  96. }
  97. })
  98. let root: string
  99. const dirs: string[] = []
  100. const liveContexts: Context[] = []
  101. type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
  102. /** Test-only mutable view used to verify that backends detach caller metadata. */
  103. function mutableHeader(header: SessionHeader): MutableSessionHeader {
  104. return header
  105. }
  106. /** Rewrite only a stored header while preserving every event byte below it. */
  107. async function rewriteHeader(path: string, update: (header: Record<string, unknown>) => void): Promise<void> {
  108. const lines = (await readFile(path, 'utf8')).split('\n')
  109. const header = JSON.parse(lines[0] as string) as Record<string, unknown>
  110. update(header)
  111. lines[0] = JSON.stringify(header)
  112. await writeFile(path, lines.join('\n'))
  113. }
  114. async function expectCode(promise: Promise<unknown>, codes: readonly string[]): Promise<void> {
  115. try {
  116. await promise
  117. } catch (error) {
  118. expect(error).toBeInstanceOf(Error)
  119. expect(codes).toContain((error as NodeJS.ErrnoException).code)
  120. return
  121. }
  122. throw new Error('expected the operation to reject')
  123. }
  124. async function freshRoot(): Promise<string> {
  125. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
  126. dirs.push(dir)
  127. return dir
  128. }
  129. function pausePhysicalRead(path: string): {
  130. readonly entered: Promise<void>
  131. readonly finished: Promise<void>
  132. release(): void
  133. } {
  134. const entered = Promise.withResolvers<undefined>()
  135. const resume = Promise.withResolvers<undefined>()
  136. const finished = Promise.withResolvers<undefined>()
  137. pausedRead.path = path
  138. pausedRead.entered = () => { entered.resolve(undefined) }
  139. pausedRead.resume = resume.promise
  140. pausedRead.release = () => { resume.resolve(undefined) }
  141. pausedRead.done = finished.promise
  142. pausedRead.finished = () => { finished.resolve(undefined) }
  143. return {
  144. entered: entered.promise,
  145. finished: finished.promise,
  146. release: () => { resume.resolve(undefined) },
  147. }
  148. }
  149. function rawLogPath(root: string, cwd: string | undefined, id: SessionId): string {
  150. return logPath(root, cwd, id, 'none')
  151. }
  152. function historicalLogPath(root: string, cwd: string | undefined, id: SessionId): string {
  153. return generationLogPath(root, cwd, id, 0, 'none')
  154. }
  155. function releasedV0Header(header: SessionHeader): Record<string, unknown> {
  156. return {
  157. type: 'session',
  158. version: 0,
  159. id: header.id,
  160. createdAt: header.createdAt,
  161. ...(header.cwd === undefined ? {} : { cwd: header.cwd }),
  162. ...(header.parentSession === undefined ? {} : { parentSession: header.parentSession }),
  163. ...(header.isSeeded ? { seedLength: 0 } : {}),
  164. ...(header.origin === undefined ? {} : { origin: header.origin }),
  165. delegationDepth: header.delegationDepth ?? 0,
  166. ...(header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset }),
  167. }
  168. }
  169. /** Construct a supported historical turn with its first surface inside the step. */
  170. function migrationOneTurnLog(): SessionEvent[] {
  171. const [turn, user, step, ...tail] = releasedV1OneTurnLog()
  172. return [turn!, { ...step!, time: 2 }, { ...user!, time: 3 }, ...tail]
  173. .map((event, seq) => ({ ...event, seq: SessionSeq(seq) }))
  174. }
  175. /** Expected V3 insertion for an append-only log beginning with turn/start and step/start. */
  176. function withMigratedEmptyHead(log: readonly SessionEvent[]): readonly unknown[] {
  177. return [
  178. ...log.slice(0, 2),
  179. {
  180. type: 'system/message', seq: 2, time: log[1]!.time, surfaceOp: 'append',
  181. data: { turn: 1, step: 1, message: {
  182. id: expect.stringMatching(/^v2-to-v3-system-[0-9a-f]{64}$/) as unknown,
  183. role: 'system', content: [], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
  184. } },
  185. },
  186. ...log.slice(2).map(event => ({ ...event, seq: SessionSeq(event.seq + 1) })),
  187. ]
  188. }
  189. function migratedOneTurnLog(): readonly unknown[] {
  190. const [turn, user, step, ...tail] = oneTurnLog()
  191. const log = [turn!, { ...step!, time: 2 }, { ...user!, time: 3 }, ...tail]
  192. .map((event, seq) => ({ ...event, seq: SessionSeq(seq) }))
  193. return withMigratedEmptyHead(log)
  194. }
  195. function releasedV1PackedPhysicalLog(header: SessionHeader): string {
  196. const source = migrationOneTurnLog()
  197. const extraChunks: SessionEvent[] = [
  198. {
  199. type: 'assistant/chunk', seq: SessionSeq(5), time: 3,
  200. data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' } },
  201. } as unknown as SessionEvent,
  202. {
  203. type: 'assistant/chunk', seq: SessionSeq(6), time: 3,
  204. data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' } },
  205. } as unknown as SessionEvent,
  206. ]
  207. const events = [
  208. ...source.slice(0, 5),
  209. ...extraChunks,
  210. ...source.slice(5).map((event): SessionEvent => ({
  211. ...event,
  212. seq: SessionSeq(event.seq + 2),
  213. ...(event.type === 'assistant/message'
  214. ? { sourceEventSeqs: [3, 4, 5, 6, 7, 8].map(SessionSeq) }
  215. : {}),
  216. } as unknown as SessionEvent)),
  217. ]
  218. const packed = {
  219. type: 'text-chunks',
  220. seq0: 4,
  221. time0: 3,
  222. data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['hello', '', ''] },
  223. }
  224. const rows = [...events.slice(0, 4), packed, ...events.slice(7)]
  225. return [{ ...releasedV0Header(header), version: 1 }, ...rows]
  226. .map(row => JSON.stringify(row)).join('\n') + '\n'
  227. }
  228. /** Create + append + close: persist one whole log through the write handle. */
  229. async function writeLog(persistence: SessionPersistence, m: SessionHeader, events: readonly SessionEvent[]): Promise<void> {
  230. const handle = await persistence.create(m)
  231. try {
  232. await handle.append(events)
  233. } finally {
  234. await handle.close()
  235. }
  236. }
  237. /** Open a read handle, read the whole log, and close. */
  238. async function readAll(persistence: SessionPersistence, id: SessionId): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> {
  239. const handle = await persistence.open(id, 'read')
  240. try {
  241. return { meta: handle.header, events: (await handle.read()).events }
  242. } finally {
  243. await handle.close()
  244. }
  245. }
  246. /** Append one contiguous batch through a temporary write handle. */
  247. async function appendBatch(persistence: SessionPersistence, id: SessionId, events: readonly SessionEvent[]): Promise<void> {
  248. const handle = await persistence.open(id, 'write')
  249. try {
  250. await handle.append(events)
  251. } finally {
  252. await handle.close()
  253. }
  254. }
  255. afterEach(async () => {
  256. const contexts = liveContexts.splice(0)
  257. const directories = dirs.splice(0)
  258. vi.useRealTimers()
  259. statRace.path = undefined
  260. statRace.reads = 0
  261. statRace.mode = 'settle'
  262. readTally.bySuffix.clear()
  263. readTally.enabled = false
  264. const pausedReadDone = pausedRead.active ? pausedRead.done : undefined
  265. readFailure.path = undefined
  266. readFailure.error = undefined
  267. pausedRead.release?.()
  268. await pausedReadDone
  269. pausedRead.path = undefined
  270. pausedRead.active = false
  271. pausedRead.entered = undefined
  272. pausedRead.resume = undefined
  273. pausedRead.release = undefined
  274. pausedRead.done = undefined
  275. pausedRead.finished = undefined
  276. statFailure.path = undefined
  277. statFailure.error = undefined
  278. readdirFailure.path = undefined
  279. readdirFailure.error = undefined
  280. vi.restoreAllMocks()
  281. const results = await Promise.allSettled(contexts.map(ctx => ctx.fiber.dispose()))
  282. for (const d of directories) await rm(d, { recursive: true, force: true })
  283. const failures: unknown[] = results.flatMap((result): unknown[] => result.status === 'rejected' ? [result.reason] : [])
  284. if (failures.length > 0) throw new AggregateError(failures, 'live-write fixture cleanup failed')
  285. })
  286. runPersistenceContract('jsonl-none', async () => {
  287. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
  288. const instance = async (): Promise<{ persistence: SessionPersistence; dispose: () => Promise<void> }> => {
  289. const ctx = new Context()
  290. const fiber = await ctx.plugin(JsonlSessionPersistence, { root: dir, compression: 'none' })
  291. return {
  292. persistence: ctx.sessionPersistence,
  293. dispose: async () => { await fiber.dispose() },
  294. }
  295. }
  296. const primary = await instance()
  297. return {
  298. persistence: primary.persistence,
  299. dispose: async () => {
  300. await primary.dispose()
  301. await rm(dir, { recursive: true, force: true })
  302. },
  303. reopen: instance,
  304. // A half-written record with no trailing newline: scanLog treats it as an
  305. // uncommitted crash fragment, so the write path sees a torn tail to truncate.
  306. corruptTail: async (id, cwd) => {
  307. await appendFile(rawLogPath(dir, cwd, id), '{"type":"assistant/chunk","seq":8,"ti')
  308. },
  309. }
  310. })
  311. runLiveWritePathContract('jsonl', LIVE_WRITE_BATCH_MAX_DELAY_MS, async () => {
  312. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-live-'))
  313. dirs.push(dir)
  314. const mount = async (): Promise<Context> => {
  315. const ctx = new Context()
  316. liveContexts.push(ctx)
  317. await ctx.plugin(SessionStore)
  318. await ctx.plugin(JsonlSessionPersistence, { root: dir, compression: 'none' })
  319. return ctx
  320. }
  321. return { ctx: await mount(), remount: mount }
  322. })
  323. describe('JsonlSessionPersistence: format helpers', () => {
  324. it('names and parses only canonical immutable generations', () => {
  325. expect(generationLogFilename(0, 'none')).toBe('session.jsonl')
  326. expect(generationLogFilename(1, 'none')).toBe('session.v1.jsonl')
  327. expect(generationLogFilename(27, 'zstd')).toBe('session.v27.jsonl.zstd')
  328. for (const invalid of [-1, -0, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
  329. expect(() => generationLogFilename(invalid, 'none')).toThrow(/non-negative safe integer/)
  330. }
  331. expect(parseGenerationLogFilename('session.jsonl', 'none')).toBe(0)
  332. expect(parseGenerationLogFilename('session.v1.jsonl', 'none')).toBe(1)
  333. expect(parseGenerationLogFilename('session.v9007199254740992.jsonl', 'none')).toBeUndefined()
  334. for (const name of [
  335. 'session.v0.jsonl',
  336. 'session.v01.jsonl',
  337. 'session.V1.jsonl',
  338. 'session.v1.backup.jsonl',
  339. 'session.migration.deadbeef.tmp.jsonl',
  340. 'session.v1.jsonl.zstd',
  341. ]) {
  342. expect(parseGenerationLogFilename(name, 'none')).toBeUndefined()
  343. }
  344. })
  345. it('ignores retired-header checks for non-object values', () => {
  346. expect(() => { assertNoRetiredHeaderFields(null) }).not.toThrow()
  347. expect(() => { assertNoRetiredHeaderFields('header') }).not.toThrow()
  348. expect(() => scanLog(Buffer.from('42\n'))).toThrow(/first line is not a JSON object/)
  349. expect(() => scanLog(Buffer.from('null\n'))).toThrow(/first line is not a JSON object/)
  350. expect(() => scanLog(Buffer.from(
  351. `${JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: 123 })}\n`,
  352. ))).toThrow(/first line is not a session header/)
  353. expect(() => scanLog(Buffer.from(
  354. `${JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION + 1, id: 123 })}\n`,
  355. ))).toThrow(`session "123" uses log format v${SESSION_FORMAT_VERSION + 1}`)
  356. })
  357. it('encodeSegment neutralizes traversal, separators, and absolute paths', () => {
  358. expect(encodeSegment('..')).toBe('~002E~002E')
  359. expect(encodeSegment('.')).toBe('~002E')
  360. expect(encodeSegment('a/b')).toBe('a~002Fb')
  361. expect(encodeSegment('/etc/passwd')).toBe('~002Fetc~002Fpasswd')
  362. expect(encodeSegment('a\u0000b')).toBe('a~0000b')
  363. expect(encodeSegment('plain-ID_1.2')).toBe('plain-ID_1.2') // safe chars pass through
  364. expect(encodeSegment('a~b')).toBe('a~007Eb') // ~ itself is escaped
  365. })
  366. it('encodeSegment is injective over UTF-16, incl. lone surrogates', () => {
  367. // Distinct lone surrogates must NOT collide (Buffer.from would normalize
  368. // both to U+FFFD; code-unit escaping keeps them distinct).
  369. const hi = encodeSegment(String.fromCharCode(0xD800))
  370. const lo = encodeSegment(String.fromCharCode(0xDC00))
  371. expect(hi).toBe('~D800')
  372. expect(lo).toBe('~DC00')
  373. expect(hi).not.toBe(lo)
  374. // A literal "~002F" input cannot collide with the encoding of "/".
  375. expect(encodeSegment('~002F')).not.toBe(encodeSegment('/'))
  376. })
  377. it('encodeSegment rejects an empty id', () => {
  378. expect(() => encodeSegment('')).toThrow(/empty/)
  379. })
  380. it('round-trips every optional header field through the header line', () => {
  381. const full: SessionHeader = {
  382. version: SESSION_FORMAT_VERSION,
  383. id: SessionId('full-header'),
  384. createdAt: 5,
  385. cwd: '/w',
  386. parentSession: SessionId('parent'),
  387. isSeeded: true,
  388. origin: 'subagent',
  389. delegationDepth: 2,
  390. agentPreset: 'minimal',
  391. }
  392. const events = [
  393. ...Array.from({ length: 3 }, (_, seq) => ({
  394. type: 'feedback/record', seq, time: seq + 1, data: { text: `prefix-${String(seq)}` },
  395. })),
  396. { type: 'session/end-seed', seq: 3, time: 4, data: { inherited: true } },
  397. ]
  398. const scan = scanLog(Buffer.from(
  399. `${[toHeaderLine(full, SessionLogOffset(3)), ...events].map(value => JSON.stringify(value)).join('\n')}\n`,
  400. ))
  401. expect(scan.meta).toEqual(full)
  402. expect(scan.inheritedEventCount).toBe(3)
  403. })
  404. it.each([
  405. ['unseeded', false, 0],
  406. ['empty-seed', true, 0],
  407. ['nonempty-seed', true, 3],
  408. ] as const)('round-trips v3 lineage markers for %s', (_case, isSeeded, inheritedEventCount) => {
  409. const events = isSeeded
  410. ? [
  411. ...Array.from({ length: inheritedEventCount }, (_, seq) => ({
  412. type: 'feedback/record', seq, time: seq + 1, data: { text: `prefix-${String(seq)}` },
  413. })),
  414. {
  415. type: 'session/end-seed',
  416. seq: inheritedEventCount,
  417. time: inheritedEventCount + 1,
  418. data: { inherited: true },
  419. },
  420. ]
  421. : []
  422. const line = {
  423. type: 'session',
  424. version: SESSION_FORMAT_VERSION,
  425. id: SessionId(`physical-seed-${_case}`),
  426. createdAt: 1000,
  427. isSeeded,
  428. delegationDepth: 0,
  429. }
  430. const bytes = `${[line, ...events].map(value => JSON.stringify(value)).join('\n')}\n`
  431. const scanned = scanLog(Buffer.from(bytes))
  432. expect(scanned.meta.isSeeded).toBe(isSeeded)
  433. expect(scanned.inheritedEventCount).toBe(SessionLogOffset(inheritedEventCount))
  434. expect(toHeaderLine(scanned.meta, scanned.inheritedEventCount)).toStrictEqual(line)
  435. })
  436. it('rejects a v3 physical header without explicit isSeeded', () => {
  437. const { isSeeded: _isSeeded, ...line } = toHeaderLine(meta('missing-is-seeded'))
  438. expect(() => scanLog(Buffer.from(`${JSON.stringify(line)}\n`))).toThrow(/session header/)
  439. })
  440. it('requires logical lineage and the physical inherited cut to agree', () => {
  441. const unseeded = meta('lineage-cut')
  442. expect(() => toHeaderLine({ ...unseeded, isSeeded: true }))
  443. .toThrow('seeded session header requires an inherited event count')
  444. expect(() => toHeaderLine(unseeded, SessionLogOffset(1)))
  445. .toThrow('unseeded session header inherited event count must be 0')
  446. })
  447. it('projectKey normalizes project paths into bounded readable names', () => {
  448. expect(projectKey('/Users/qyj/work/deepseek-harness')).toBe('--Users-qyj-work-deepseek-harness--')
  449. expect(projectKey('/a/b-c')).toBe(projectKey('/a-b/c'))
  450. expect(projectKey('C:\\work\\agent')).toBe('--C-work-agent--')
  451. expect(projectKey('/开发/~agent')).toBe('--~5F00~53D1-~007Eagent--')
  452. expect(projectKey('/')).toBe('--root--')
  453. expect(projectKey('/' + 'x'.repeat(1_000))).toHaveLength(255)
  454. expect(() => projectKey('')).toThrow(/empty project path/)
  455. })
  456. it('resolves a relative custom root before storing a session', async () => {
  457. const absoluteRoot = await freshRoot()
  458. const ctx = new Context()
  459. const fiber = await ctx.plugin(JsonlSessionPersistence, {
  460. root: relative(process.cwd(), absoluteRoot),
  461. compression: 'none',
  462. })
  463. const m = meta('relative-location', '/work')
  464. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  465. expect((await stat(rawLogPath(resolve(absoluteRoot), '/work', m.id))).isFile()).toBe(true)
  466. await fiber.dispose()
  467. })
  468. })
  469. describe('JsonlSessionPersistence: stored-format refusals', () => {
  470. let ctx: Context
  471. beforeEach(async () => {
  472. root = await freshRoot()
  473. ctx = new Context()
  474. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  475. })
  476. afterEach(async () => { await ctx.fiber.dispose() })
  477. it('propagates a non-format header failure from stat and list unchanged', async () => {
  478. // Only foreign-version refusals are enriched (stat) or skipped (list);
  479. // any other header failure stays fail-loud on both paths.
  480. const id = SessionId('retired-policy-header')
  481. const path = rawLogPath(root, '/work', id)
  482. await mkdir(dirname(path), { recursive: true })
  483. const line = {
  484. type: 'session',
  485. version: SESSION_FORMAT_VERSION,
  486. id,
  487. createdAt: 1,
  488. delegationDepth: 0,
  489. sandboxMode: 'strict',
  490. }
  491. await writeFile(path, `${JSON.stringify(line)}\n`)
  492. await expect(ctx.sessionPersistence.stat(id)).rejects.toThrow('retired policy baseline fields')
  493. await expect(ctx.sessionPersistence.list()).rejects.toThrow('retired policy baseline fields')
  494. })
  495. it('refuses a structurally foreign future header as unsupported, not corrupt or absent', async () => {
  496. // A future format need not satisfy this build's header shape at all (no
  497. // createdAt, unknown fields): the version must be refused before shape
  498. // validation, so the user sees the upgrade direction — never "not found".
  499. const id = SessionId('future-shape')
  500. const path = generationLogPath(root, '/work', id, 42, 'none')
  501. await mkdir(dirname(path), { recursive: true })
  502. await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id, futureOnly: true })}\n{"future":"row"}\n`)
  503. for (const access of ['read', 'write'] as const) {
  504. const failure = await ctx.sessionPersistence.open(id, access).then(() => undefined, (error: unknown) => error as Error)
  505. expect(failure?.name).toBe('SessionFormatUnsupportedError')
  506. expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/)
  507. expect(failure?.message).toContain(`(raw log: ${path})`)
  508. }
  509. // Listing skips the unreadable header instead of failing the whole root.
  510. expect(await ctx.sessionPersistence.list()).toEqual([])
  511. })
  512. it('classifies an unparsable future generation header as corruption', async () => {
  513. const id = SessionId('future-malformed')
  514. const path = generationLogPath(root, '/work', id, 42, 'none')
  515. await mkdir(dirname(path), { recursive: true })
  516. await writeFile(path, '{not-json}\n')
  517. await expect(ctx.sessionPersistence.open(id, 'read')).rejects.toMatchObject({
  518. name: 'SessionPersistenceCorruptionError',
  519. })
  520. })
  521. it('refuses a well-shaped newer-version header at read open with the upgrade direction', async () => {
  522. // A header that satisfies the current shape but carries a future version:
  523. // stat can parse it, and the open still refuses before handing out a
  524. // handle whose every read would fail.
  525. const id = SessionId('future-version')
  526. const path = generationLogPath(root, '/work', id, 42, 'none')
  527. await mkdir(dirname(path), { recursive: true })
  528. await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id, createdAt: 1, cwd: '/work', delegationDepth: 0 })}\n`)
  529. const failure = await ctx.sessionPersistence.open(id, 'read').then(() => undefined, (error: unknown) => error as Error)
  530. expect(failure?.name).toBe('SessionFormatUnsupportedError')
  531. expect(failure?.message).toMatch(/upgrade the harness/)
  532. expect(failure?.message).toContain(`(raw log: ${path})`)
  533. })
  534. it('keeps a non-object header line a corruption, not a format refusal', async () => {
  535. // Valid JSON that is no object carries no version to compare, so the
  536. // version guard must pass it through to the corruption diagnostics.
  537. const id = SessionId('scalar-header')
  538. const path = rawLogPath(root, '/work', id)
  539. await mkdir(dirname(path), { recursive: true })
  540. await writeFile(path, '42\n')
  541. const failure = await ctx.sessionPersistence.open(id, 'read').then(() => undefined, (error: unknown) => error as Error)
  542. expect(failure?.name).toBe('SessionPersistenceCorruptionError')
  543. expect(failure?.message).toContain('first line is not a JSON object')
  544. expect(failure?.message).toContain(`(raw log: ${path})`)
  545. })
  546. it.each([
  547. ['invalid JSON', 'not json\n', /not valid JSON/],
  548. ['missing header newline', '{"type":"session"}', /header-less session log/],
  549. ])('classifies a %s current log as corruption with its raw path', async (name, content, reason) => {
  550. const id = SessionId(`corrupt-${name.replaceAll(' ', '-')}`)
  551. const path = rawLogPath(root, '/work', id)
  552. await mkdir(dirname(path), { recursive: true })
  553. await writeFile(path, content)
  554. const failure = await ctx.sessionPersistence.open(id, 'read').then(() => undefined, (error: unknown) => error as Error)
  555. expect(failure?.name).toBe('SessionPersistenceCorruptionError')
  556. expect(failure?.message).toMatch(reason)
  557. expect(failure?.message).toContain(`(raw log: ${path})`)
  558. })
  559. it('names a foreign-version header by its stringified non-string id', async () => {
  560. // A future header's id field is as untrusted as the rest of its shape:
  561. // the refusal must still name the session it read, not crash on the type.
  562. const id = SessionId('numeric-id')
  563. const path = generationLogPath(root, '/work', id, 42, 'none')
  564. await mkdir(dirname(path), { recursive: true })
  565. await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id: 123 })}\n`)
  566. const failure = await ctx.sessionPersistence.open(id, 'read').then(() => undefined, (error: unknown) => error as Error)
  567. expect(failure?.name).toBe('SessionFormatUnsupportedError')
  568. expect(failure?.message).toContain('session "123" uses log format v42')
  569. })
  570. it('points a future-generation refusal at the selected raw log path', async () => {
  571. const m = meta('newer-format', '/work')
  572. const path = generationLogPath(root, m.cwd, m.id, 7, 'none')
  573. await mkdir(dirname(path), { recursive: true })
  574. await writeFile(path, `${JSON.stringify({ ...toHeaderLine(m), version: 7 })}\n`)
  575. const failure = await ctx.sessionPersistence.open(m.id, 'read').then(() => undefined, (error: unknown) => error as Error)
  576. expect(failure?.name).toBe('SessionFormatUnsupportedError')
  577. expect(failure?.message).toContain(`(raw log: ${path})`)
  578. })
  579. it('serves a read open through the full log read when the header-only read races a writer', async () => {
  580. const m = meta('stat-race-open', '/work')
  581. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  582. // Simulate the race: the header-only stat sees nothing although the full
  583. // log is present and readable.
  584. vi.spyOn(ctx.sessionPersistence, 'stat').mockResolvedValue(undefined)
  585. const handle = await ctx.sessionPersistence.open(m.id, 'read', { signal: new AbortController().signal })
  586. try {
  587. expect(handle.header).toMatchObject({ id: m.id, cwd: '/work' })
  588. const read = await handle.read()
  589. expect(read.eventState).toBe('shared-frozen')
  590. expect(read.events).toEqual(oneTurnLog())
  591. expect(read.events.every(event => Object.isFrozen(event) && Object.isFrozen(event.data))).toBe(true)
  592. const reread = await handle.read()
  593. expect(reread.events).not.toBe(read.events)
  594. expect(reread.events[0]).toBe(read.events[0])
  595. expect((await handle.read(read.events.length)).eventState).toBe('shared-frozen')
  596. } finally {
  597. await handle.close()
  598. }
  599. })
  600. it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
  601. const m = meta('legacy-header-delta', '/legacy')
  602. const path = rawLogPath(root, m.cwd, m.id)
  603. await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true })
  604. await writeFile(path, [
  605. JSON.stringify(toHeaderLine(m)),
  606. JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }),
  607. JSON.stringify({ type: 'request/header-delta', seq: SessionSeq(1), time: 2, data: { config: { model: 'legacy' } } }),
  608. JSON.stringify({ type: 'turn/end', seq: SessionSeq(2), time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
  609. '',
  610. ].join('\n'))
  611. await expect(readAll(ctx.sessionPersistence, m.id))
  612. .rejects.toThrow(/contains event type "request\/header-delta" \(seq 1\) unknown to this harness/)
  613. })
  614. it('rejects a stored v0 full header carrying the legacy fallback reason', async () => {
  615. const m = meta('legacy-header-fallback', '/legacy')
  616. const path = rawLogPath(root, m.cwd, m.id)
  617. await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true })
  618. await writeFile(path, [
  619. JSON.stringify(toHeaderLine(m)),
  620. JSON.stringify({
  621. type: 'request/header',
  622. seq: SessionSeq(0),
  623. time: 1,
  624. data: { header: { config: { provider: 'mock', model: 'legacy' } }, reason: 'fallback' },
  625. }),
  626. '',
  627. ].join('\n'))
  628. await expect(readAll(ctx.sessionPersistence, m.id))
  629. .rejects.toThrow(/unsupported legacy reason "fallback"/)
  630. })
  631. })
  632. describe('JsonlSessionPersistence: immutable format generations', () => {
  633. let ctx: Context
  634. beforeEach(async () => {
  635. root = await freshRoot()
  636. ctx = new Context()
  637. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  638. })
  639. afterEach(async () => { await ctx.fiber.dispose() })
  640. it('projects a released v0 header through stat and list without reading or mutating its body', async () => {
  641. expect(SESSION_FORMAT_VERSION).toBe(3)
  642. const header = meta('released-v0-metadata', '/work')
  643. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  644. const currentPath = rawLogPath(root, header.cwd, header.id)
  645. const source = Buffer.from(
  646. `${JSON.stringify(releasedV0Header(header))}\n${releasedV1OneTurnLog().map(event => JSON.stringify(event)).join('\n')}\n`,
  647. )
  648. await mkdir(dirname(sourcePath), { recursive: true })
  649. await writeFile(sourcePath, source)
  650. expect(await ctx.sessionPersistence.stat(header.id)).toMatchObject({
  651. header: { id: header.id, version: SESSION_FORMAT_VERSION },
  652. })
  653. const [listed] = await ctx.sessionPersistence.list()
  654. expect(listed?.header).toMatchObject({ id: header.id, version: SESSION_FORMAT_VERSION })
  655. expect(await readFile(sourcePath)).toEqual(source)
  656. await expect(stat(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  657. })
  658. it('serves a migrated v0 read without publishing at the service durability barrier', async () => {
  659. const header = meta('released-v0-read', '/work')
  660. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  661. const currentPath = rawLogPath(root, header.cwd, header.id)
  662. const source = Buffer.from(
  663. `${JSON.stringify(releasedV0Header(header))}\n${migrationOneTurnLog().map(event => JSON.stringify(event)).join('\n')}\n`,
  664. )
  665. await mkdir(dirname(sourcePath), { recursive: true })
  666. await writeFile(sourcePath, source)
  667. const restored = await readAll(ctx.sessionPersistence, header.id)
  668. expect(restored).toEqual({
  669. meta: { ...header, delegationDepth: 0 },
  670. events: migratedOneTurnLog(),
  671. })
  672. const userMessage = restored.events.find(event => event.type === 'user/message')
  673. expect(userMessage).toBeDefined()
  674. expect(Object.isFrozen(userMessage?.data)).toBe(true)
  675. expect(await readFile(sourcePath)).toEqual(source)
  676. await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  677. expect((await readdir(dirname(sourcePath))).filter(name => name.startsWith('session')).sort())
  678. .toEqual(['session.jsonl'])
  679. })
  680. it('resolves absent, current, and historical current-generation paths', async () => {
  681. const persistence = ctx.sessionPersistence as JsonlSessionPersistence
  682. expect(await persistence.resolveCurrentLog(SessionId('missing-generation'))).toBeUndefined()
  683. const current = meta('resolved-current', '/work')
  684. const currentHandle = await ctx.sessionPersistence.create(current)
  685. await currentHandle.flush()
  686. await currentHandle.close()
  687. await expect(persistence.resolveCurrentLog(current.id)).resolves.toBe(rawLogPath(root, current.cwd, current.id))
  688. const historical = meta('resolved-historical', '/work')
  689. const sourcePath = historicalLogPath(root, historical.cwd, historical.id)
  690. await mkdir(dirname(sourcePath), { recursive: true })
  691. await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(historical))}\n`)
  692. await expect(persistence.resolveCurrentLog(historical.id)).resolves.toBeUndefined()
  693. const future = meta('resolved-future', '/work')
  694. const futurePath = generationLogPath(root, future.cwd, future.id, SESSION_FORMAT_VERSION + 1, 'none')
  695. await mkdir(dirname(futurePath), { recursive: true })
  696. await writeFile(futurePath, `${JSON.stringify({ ...toHeaderLine(future), version: SESSION_FORMAT_VERSION + 1 })}\n`)
  697. await expect(persistence.resolveCurrentLog(future.id)).rejects.toMatchObject({
  698. name: 'SessionFormatUnsupportedError',
  699. })
  700. })
  701. it('singleflights concurrent historical reads and keeps service flush read-only', async () => {
  702. const header = meta('released-v0-source-drift', '/work')
  703. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  704. await mkdir(dirname(sourcePath), { recursive: true })
  705. await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
  706. readTally.enabled = true
  707. const [first, second] = await Promise.all([
  708. ctx.sessionPersistence.open(header.id, 'read'),
  709. ctx.sessionPersistence.open(header.id, 'read'),
  710. ])
  711. expect((await first.read()).events).toEqual([])
  712. expect((await second.read()).events).toEqual([])
  713. expect(readTally.bySuffix.get(sourcePath)).toBe(1)
  714. await appendFile(sourcePath, '\n')
  715. await expect(ctx.sessionPersistence.flush()).resolves.toBeUndefined()
  716. await expect(stat(rawLogPath(root, header.cwd, header.id))).rejects.toMatchObject({ code: 'ENOENT' })
  717. await Promise.all([first.close(), second.close()])
  718. await ctx.fiber.dispose()
  719. ctx = new Context()
  720. })
  721. it('does not join an in-flight historical preparation for an older source revision', async () => {
  722. const header = meta('released-v0-revision-singleflight', '/work')
  723. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  724. await mkdir(dirname(sourcePath), { recursive: true })
  725. await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
  726. const pause = pausePhysicalRead(sourcePath)
  727. readTally.enabled = true
  728. const firstOpening = ctx.sessionPersistence.open(header.id, 'read')
  729. await pause.entered
  730. await appendFile(sourcePath, `${migrationOneTurnLog().map(event => JSON.stringify(event)).join('\n')}\n`)
  731. const secondOpening = ctx.sessionPersistence.open(header.id, 'read')
  732. let tallyFailure: unknown
  733. try {
  734. await vi.waitFor(() => { expect(readTally.bySuffix.get(sourcePath)).toBe(2) })
  735. } catch (error: unknown) {
  736. tallyFailure = error
  737. } finally {
  738. pause.release()
  739. }
  740. const [first, second] = await Promise.all([firstOpening, secondOpening])
  741. try {
  742. if (tallyFailure !== undefined) throw tallyFailure
  743. expect((await first.read()).events).toEqual(migratedOneTurnLog())
  744. expect((await second.read()).events).toEqual(migratedOneTurnLog())
  745. } finally {
  746. await Promise.all([first.close(), second.close()])
  747. }
  748. })
  749. it('lets one historical-open caller abort without cancelling another waiter', async () => {
  750. const header = meta('released-v0-shared-cancellation', '/work')
  751. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  752. await mkdir(dirname(sourcePath), { recursive: true })
  753. await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
  754. const pause = pausePhysicalRead(sourcePath)
  755. readTally.enabled = true
  756. const controller = new AbortController()
  757. const reason = new Error('first historical waiter cancelled')
  758. const internals = ctx.sessionPersistence as unknown as {
  759. migrationPreparations: Map<SessionId, { waiters: number }>
  760. }
  761. const first = ctx.sessionPersistence.open(header.id, 'read', { signal: controller.signal })
  762. const second = ctx.sessionPersistence.open(header.id, 'read')
  763. const settled = Promise.allSettled([first, second])
  764. try {
  765. await pause.entered
  766. // Both callers must join the preparation before either caller leaves it.
  767. await expect.poll(() => internals.migrationPreparations.get(header.id)?.waiters).toBe(2)
  768. controller.abort(reason)
  769. await expect(first).rejects.toBe(reason)
  770. pause.release()
  771. const handle = await second
  772. expect((await handle.read()).events).toEqual([])
  773. expect(readTally.bySuffix.get(sourcePath)).toBe(1)
  774. } finally {
  775. controller.abort(reason)
  776. pause.release()
  777. for (const result of await settled) {
  778. if (result.status === 'fulfilled') await result.value.close()
  779. }
  780. }
  781. })
  782. it('cancels shared historical preparation after its last waiter leaves', async () => {
  783. const header = meta('released-v0-last-waiter-cancellation', '/work')
  784. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  785. await mkdir(dirname(sourcePath), { recursive: true })
  786. await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
  787. const pause = pausePhysicalRead(sourcePath)
  788. readTally.enabled = true
  789. const controller = new AbortController()
  790. const reason = 'last historical waiter cancelled'
  791. const opening = ctx.sessionPersistence.open(header.id, 'read', { signal: controller.signal })
  792. await pause.entered
  793. controller.abort(reason)
  794. await expect(opening).rejects.toMatchObject({
  795. message: 'session migration preparation aborted',
  796. cause: reason,
  797. })
  798. pause.release()
  799. await pause.finished
  800. await scheduler.yield()
  801. const retried = await ctx.sessionPersistence.open(header.id, 'read')
  802. expect((await retried.read()).events).toEqual([])
  803. expect(readTally.bySuffix.get(sourcePath)).toBe(2)
  804. await retried.close()
  805. })
  806. it.each(['read', 'write'] as const)('refuses the frozen pre-step V0 fixture on %s open without publishing a successor', async (access) => {
  807. const id = SessionId('released-v0-real-shapes')
  808. const sourcePath = historicalLogPath(root, '/work', id)
  809. const currentPath = rawLogPath(root, '/work', id)
  810. const source = await readFile(resolve(
  811. 'packages/session/session-persistence-jsonl/tests/fixtures/released-v0-real-shapes.jsonl',
  812. ))
  813. await mkdir(dirname(sourcePath), { recursive: true })
  814. await writeFile(sourcePath, source)
  815. const before = await stat(sourcePath, { bigint: true })
  816. await expect(ctx.sessionPersistence.open(id, access)).rejects.toMatchObject({
  817. name: 'SessionFormatUnsupportedError',
  818. message: expect.stringContaining('surface before first step') as unknown,
  819. })
  820. await ctx.sessionPersistence.flush()
  821. const after = await stat(sourcePath, { bigint: true })
  822. expect({ dev: after.dev, ino: after.ino, size: after.size, mtimeNs: after.mtimeNs, ctimeNs: after.ctimeNs })
  823. .toEqual({ dev: before.dev, ino: before.ino, size: before.size, mtimeNs: before.mtimeNs, ctimeNs: before.ctimeNs })
  824. expect(await readFile(sourcePath)).toEqual(source)
  825. await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  826. expect((await readdir(dirname(sourcePath))).filter(name => name !== 'session.lock'))
  827. .toEqual(['session.jsonl'])
  828. })
  829. it.each(['read', 'write'] as const)('restores canonical replacement envelopes from valid V2 chronology on %s open', async (access) => {
  830. const header = meta('released-v2-replacement', '/work')
  831. const sourcePath = generationLogPath(root, header.cwd, header.id, 2, 'none')
  832. const currentPath = rawLogPath(root, header.cwd, header.id)
  833. const message = { id: 'original', role: 'user', content: [{ type: 'text', text: 'question' }], source: { kind: 'user' } }
  834. const source = Buffer.from([
  835. JSON.stringify({ ...toHeaderLine(header), version: 2 }),
  836. ...[
  837. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  838. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  839. { type: 'user/message', seq: 2, time: 3, data: message, surfaceOp: 'append' },
  840. { type: 'user/message', seq: 3, time: 4, data: { ...message, id: 'summary' }, surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] },
  841. { type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
  842. { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
  843. ].map(event => JSON.stringify(event)),
  844. '',
  845. ].join('\n'))
  846. await mkdir(dirname(sourcePath), { recursive: true })
  847. await writeFile(sourcePath, source)
  848. const handle = await ctx.sessionPersistence.open(header.id, access)
  849. try {
  850. const restored = await handle.read()
  851. expect(restored.events[2]).toMatchObject({ type: 'system/message', surfaceOp: 'append' })
  852. expect(restored.events[4]).toMatchObject({
  853. type: 'user/message', seq: 4,
  854. surfaceOp: { op: 'replace', startSeq: 3, endSeq: 3 }, sourceEventSeqs: [3],
  855. })
  856. expect(restored.events[4]?.surfaceOp).not.toHaveProperty('start')
  857. expect(restored.events[4]?.surfaceOp).not.toHaveProperty('end')
  858. await ctx.sessionPersistence.flush()
  859. if (access === 'write') {
  860. const published = scanLog(await readFile(currentPath))
  861. expect(published.events).toEqual(restored.events)
  862. } else {
  863. await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  864. }
  865. expect(await readFile(sourcePath)).toEqual(source)
  866. } finally {
  867. await handle.close()
  868. }
  869. })
  870. it('reads v1 packed chunk rows without publishing or changing the source', async () => {
  871. const header = meta('released-v1-read', '/work')
  872. const sourcePath = generationLogPath(root, header.cwd, header.id, 1, 'none')
  873. const currentPath = rawLogPath(root, header.cwd, header.id)
  874. const source = Buffer.from(releasedV1PackedPhysicalLog(header))
  875. await mkdir(dirname(sourcePath), { recursive: true })
  876. await writeFile(sourcePath, source)
  877. const restored = await readAll(ctx.sessionPersistence, header.id)
  878. expect(restored.meta).toEqual({ ...header, delegationDepth: 0 })
  879. expect(restored.events.map(event => event.type)).toEqual([
  880. 'turn/start', 'step/start', 'system/message', 'user/message', 'assistant/message', 'step/end', 'turn/end',
  881. ])
  882. expect(restored.events.find(event => event.type === 'assistant/message'))
  883. .toMatchObject({ data: { message: { content: [{ type: 'text', text: 'hello' }] } } })
  884. expect(await readFile(sourcePath)).toEqual(source)
  885. await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  886. expect((await readdir(dirname(sourcePath))).filter(name => name.startsWith('session')).sort())
  887. .toEqual(['session.v1.jsonl'])
  888. })
  889. it('selects v1 from a v0/v1 directory, then v3 from the retained three-generation set', async () => {
  890. const header = meta('mixed-generation-read', '/work')
  891. const directory = sessionDir(root, header.cwd, header.id)
  892. const v0Path = historicalLogPath(root, header.cwd, header.id)
  893. const v1Path = generationLogPath(root, header.cwd, header.id, 1, 'none')
  894. const v3Path = rawLogPath(root, header.cwd, header.id)
  895. await mkdir(directory, { recursive: true })
  896. await writeFile(v0Path, `${JSON.stringify(releasedV0Header(header))}\n`)
  897. await writeFile(v1Path, releasedV1PackedPhysicalLog(header))
  898. const migrated = await readAll(ctx.sessionPersistence, header.id)
  899. expect(migrated.events.map(event => event.type)).toContain('assistant/message')
  900. const writer = await ctx.sessionPersistence.open(header.id, 'write')
  901. await writer.close()
  902. expect((await readdir(directory)).filter(name => name.startsWith('session')).sort())
  903. .toEqual(process.platform === 'win32'
  904. ? ['session.jsonl', 'session.v1.jsonl', 'session.v3.jsonl']
  905. : ['session.jsonl', 'session.lock', 'session.v1.jsonl', 'session.v3.jsonl'])
  906. await writeFile(v0Path, 'corrupt lower v0\n')
  907. await writeFile(v1Path, 'corrupt lower v1\n')
  908. await expect(readAll(ctx.sessionPersistence, header.id)).resolves.toEqual(migrated)
  909. expect(await readFile(v3Path, 'utf8')).toContain('"version":3')
  910. })
  911. it('does not publish a historical generation through handle storage resolution', async () => {
  912. const header = meta('released-v0-handle-read', '/work')
  913. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  914. const currentPath = rawLogPath(root, header.cwd, header.id)
  915. await mkdir(dirname(sourcePath), { recursive: true })
  916. await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
  917. const persistence = ctx.sessionPersistence as JsonlSessionPersistence
  918. await expect(persistence.resolveCurrentLog(header.id, new AbortController().signal)).resolves.toBeUndefined()
  919. expect(await readFile(sourcePath, 'utf8')).toBe(`${JSON.stringify(releasedV0Header(header))}\n`)
  920. await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  921. })
  922. it('opens the migrated successor for append while retaining the historical source', async () => {
  923. const header = meta('released-v0-write', '/work')
  924. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  925. const source = Buffer.from(
  926. `${JSON.stringify(releasedV0Header(header))}\n${migrationOneTurnLog().map(event => JSON.stringify(event)).join('\n')}\n`,
  927. )
  928. await mkdir(dirname(sourcePath), { recursive: true })
  929. await writeFile(sourcePath, source)
  930. const suffix: SessionEvent[] = [
  931. { type: 'turn/start', seq: SessionSeq(7), time: 9, data: { turn: 2 } },
  932. { type: 'turn/end', seq: SessionSeq(8), time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  933. ]
  934. await appendBatch(ctx.sessionPersistence, header.id, suffix)
  935. expect(await readFile(sourcePath)).toEqual(source)
  936. expect((await readAll(ctx.sessionPersistence, header.id)).events).toEqual([
  937. ...migratedOneTurnLog(),
  938. ...suffix,
  939. ])
  940. })
  941. it('switches an existing prepared read handle to the published append tail', async () => {
  942. const header = meta('released-v0-read-handoff', '/work')
  943. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  944. await mkdir(dirname(sourcePath), { recursive: true })
  945. await writeFile(
  946. sourcePath,
  947. `${JSON.stringify(releasedV0Header(header))}\n${migrationOneTurnLog().map(event => JSON.stringify(event)).join('\n')}\n`,
  948. )
  949. const reader = await ctx.sessionPersistence.open(header.id, 'read')
  950. const suffix: SessionEvent[] = [
  951. { type: 'turn/start', seq: SessionSeq(7), time: 9, data: { turn: 2 } },
  952. { type: 'turn/end', seq: SessionSeq(8), time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  953. ]
  954. try {
  955. expect((await reader.read()).events).toEqual(migratedOneTurnLog())
  956. await appendBatch(ctx.sessionPersistence, header.id, suffix)
  957. expect((await reader.read()).events).toEqual([...migratedOneTurnLog(), ...suffix])
  958. } finally {
  959. await reader.close()
  960. }
  961. })
  962. it('fails a stale prepared publication once and re-prepares on the next write open', async () => {
  963. const header = meta('released-v0-write-source-drift', '/work')
  964. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  965. const currentPath = rawLogPath(root, header.cwd, header.id)
  966. const source = `${JSON.stringify(releasedV0Header(header))}\n`
  967. await mkdir(dirname(sourcePath), { recursive: true })
  968. await writeFile(sourcePath, source)
  969. await readAll(ctx.sessionPersistence, header.id)
  970. vi.spyOn(scheduler, 'yield').mockImplementationOnce(async () => {
  971. await appendFile(sourcePath, '\n')
  972. })
  973. await expect(ctx.sessionPersistence.open(header.id, 'write'))
  974. .rejects.toBeInstanceOf(JsonlGenerationSourceChangedError)
  975. await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  976. const writer = await ctx.sessionPersistence.open(header.id, 'write')
  977. await writer.close()
  978. expect(await readFile(sourcePath, 'utf8')).toBe(`${source}\n`)
  979. expect(await readFile(currentPath, 'utf8')).toContain('"version":3')
  980. })
  981. it('finishes publication before rejecting a write open cancelled during publication', async () => {
  982. const header = meta('released-v0-publication-cancellation', '/work')
  983. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  984. const currentPath = rawLogPath(root, header.cwd, header.id)
  985. await mkdir(dirname(sourcePath), { recursive: true })
  986. await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
  987. await readAll(ctx.sessionPersistence, header.id)
  988. const controller = new AbortController()
  989. const reason = new Error('write open cancelled during publication')
  990. vi.spyOn(scheduler, 'yield').mockImplementationOnce(async () => { controller.abort(reason) })
  991. await expect(ctx.sessionPersistence.open(header.id, 'write', { signal: controller.signal }))
  992. .rejects.toBe(reason)
  993. expect(await readFile(currentPath, 'utf8')).toContain('"version":3')
  994. const writer = await ctx.sessionPersistence.open(header.id, 'write')
  995. await writer.close()
  996. })
  997. it('treats a historical generation as an existing id at create', async () => {
  998. const header = meta('released-v0-collision', '/work')
  999. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  1000. await mkdir(dirname(sourcePath), { recursive: true })
  1001. await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
  1002. await expect(ctx.sessionPersistence.create(header)).rejects.toMatchObject({
  1003. name: 'SessionAlreadyExistsError',
  1004. })
  1005. })
  1006. it('materializes a seeded current header at its exact inherited cut', async () => {
  1007. const header: SessionHeader = {
  1008. ...meta('seeded-current', '/work'),
  1009. parentSession: SessionId('seed-parent'),
  1010. isSeeded: true,
  1011. origin: 'subagent',
  1012. }
  1013. const handle = await ctx.sessionPersistence.create(header, {
  1014. inheritedEventCount: SessionLogOffset(0),
  1015. })
  1016. expect(handle.inheritedEventCount).toBe(SessionLogOffset(0))
  1017. await handle.append([{
  1018. type: 'session/end-seed', seq: SessionSeq(0), time: 1, data: { inherited: true },
  1019. }])
  1020. await handle.flush()
  1021. await handle.close()
  1022. const rows = (await readFile(rawLogPath(root, header.cwd, header.id), 'utf8')).trim().split('\n')
  1023. .map(line => JSON.parse(line) as Record<string, unknown>)
  1024. expect(rows[0]).toMatchObject({ version: SESSION_FORMAT_VERSION, isSeeded: true })
  1025. expect(rows[1]).toMatchObject({ type: 'session/end-seed', seq: 0, data: { inherited: true } })
  1026. expect(await ctx.sessionPersistence.stat(header.id)).toMatchObject({
  1027. header: { parentSession: header.parentSession, origin: 'subagent' },
  1028. })
  1029. })
  1030. it('selects a retained future generation above readable historical bytes', async () => {
  1031. const header = meta('future-wins', '/work')
  1032. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  1033. const futurePath = generationLogPath(root, header.cwd, header.id, SESSION_FORMAT_VERSION + 1, 'none')
  1034. await mkdir(dirname(sourcePath), { recursive: true })
  1035. await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
  1036. await writeFile(futurePath, `${JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION + 1, id: header.id })}\n`)
  1037. await expect(ctx.sessionPersistence.open(header.id, 'read')).rejects.toMatchObject({
  1038. name: 'SessionFormatUnsupportedError',
  1039. })
  1040. expect(await readFile(sourcePath, 'utf8')).toBe(`${JSON.stringify(releasedV0Header(header))}\n`)
  1041. await expect(stat(rawLogPath(root, header.cwd, header.id))).rejects.toMatchObject({ code: 'ENOENT' })
  1042. })
  1043. it('refuses a selected generation whose filename and header versions disagree', async () => {
  1044. const header = meta('generation-header-mismatch', '/work')
  1045. const sourcePath = generationLogPath(root, header.cwd, header.id, 3, 'none')
  1046. await mkdir(dirname(sourcePath), { recursive: true })
  1047. await writeFile(sourcePath, `${JSON.stringify({ ...releasedV0Header(header), version: 2 })}\n`)
  1048. await expect(ctx.sessionPersistence.stat(header.id))
  1049. .rejects.toThrow(/filename identifies v3.*header identifies v2/)
  1050. })
  1051. it('refuses a malformed historical header before migration reads its rows', async () => {
  1052. const header = meta('malformed-v0-header', '/work')
  1053. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  1054. await mkdir(dirname(sourcePath), { recursive: true })
  1055. await writeFile(sourcePath, `${JSON.stringify({ version: 0, id: header.id })}\n`)
  1056. await expect(ctx.sessionPersistence.open(header.id, 'read'))
  1057. .rejects.toThrow(/released v0 physical header lacks required member "type"/)
  1058. })
  1059. it('surfaces source-read storage faults and aborts unwrapped during migration', async () => {
  1060. const header = meta('source-read-fault', '/work')
  1061. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  1062. await mkdir(dirname(sourcePath), { recursive: true })
  1063. await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
  1064. statFailure.path = sourcePath
  1065. statFailure.error = Object.assign(new Error('EACCES: denied'), { code: 'EACCES' })
  1066. await expect(ctx.sessionPersistence.open(header.id, 'read')).rejects.toMatchObject({ code: 'EACCES' })
  1067. statFailure.error = new DOMException('source read aborted', 'AbortError')
  1068. await expect(ctx.sessionPersistence.open(header.id, 'read')).rejects.toMatchObject({ name: 'AbortError' })
  1069. statFailure.error = undefined
  1070. readFailure.path = sourcePath
  1071. readFailure.error = new DOMException('source read failed', 'InvalidStateError')
  1072. await expect(ctx.sessionPersistence.open(header.id, 'read')).rejects.toMatchObject({
  1073. name: 'SessionPersistenceCorruptionError',
  1074. })
  1075. })
  1076. it('selects the highest opposite-encoding generation for its refusal', async () => {
  1077. const header = meta('opposite-generations', '/work')
  1078. const dir = sessionDir(root, header.cwd, header.id)
  1079. await mkdir(dir, { recursive: true })
  1080. await writeFile(generationLogPath(root, header.cwd, header.id, 2, 'zstd'), 'older')
  1081. const highest = generationLogPath(root, header.cwd, header.id, 4, 'zstd')
  1082. await writeFile(highest, 'newer')
  1083. await expect(ctx.sessionPersistence.list()).rejects.toThrow(JSON.stringify(highest))
  1084. })
  1085. it('propagates a non-ENOENT opposite-generation scan failure during materialization', async () => {
  1086. const header = meta('opposite-generation-scan-failure', '/work')
  1087. const handle = await ctx.sessionPersistence.create(header)
  1088. const failure = Object.assign(new Error('opposite-generation scan denied'), { code: 'EACCES' })
  1089. readdirFailure.path = sessionDir(root, header.cwd, header.id)
  1090. readdirFailure.error = failure
  1091. try {
  1092. await expect(handle.flush()).rejects.toBe(failure)
  1093. } finally {
  1094. await handle.close()
  1095. }
  1096. })
  1097. it('surfaces a file occupying the targeted Session-directory path', async () => {
  1098. const header = meta('blocked-generation-directory', '/work')
  1099. const dir = sessionDir(root, header.cwd, header.id)
  1100. await mkdir(dirname(dir), { recursive: true })
  1101. await writeFile(dir, 'not a directory')
  1102. await expect(ctx.sessionPersistence.open(header.id, 'read')).rejects.toMatchObject({
  1103. code: 'ENOTDIR',
  1104. })
  1105. })
  1106. it('leaves v0 unchanged when migration policy refuses an unknown event', async () => {
  1107. const header = meta('released-v0-refusal', '/work')
  1108. const sourcePath = historicalLogPath(root, header.cwd, header.id)
  1109. const source = Buffer.from([
  1110. JSON.stringify(releasedV0Header(header)),
  1111. JSON.stringify({ type: 'external/info', seq: 0, time: 1, data: {}, ignorable: true }),
  1112. '',
  1113. ].join('\n'))
  1114. await mkdir(dirname(sourcePath), { recursive: true })
  1115. await writeFile(sourcePath, source)
  1116. const failure = await ctx.sessionPersistence.open(header.id, 'read')
  1117. .then(() => undefined, (error: unknown) => error)
  1118. expect(failure).toBeInstanceOf(Error)
  1119. expect((failure as Error).name).toBe('SessionFormatUnsupportedError')
  1120. expect((failure as Error).message).toContain('unknown historical event type "external/info" at seq 0')
  1121. expect(await readFile(sourcePath)).toEqual(source)
  1122. await expect(stat(rawLogPath(root, header.cwd, header.id))).rejects.toMatchObject({ code: 'ENOENT' })
  1123. })
  1124. it('attaches the current path when a direct handle read finds a foreign header', async () => {
  1125. const header = meta('foreign-direct-read', '/work')
  1126. const path = rawLogPath(root, header.cwd, header.id)
  1127. await mkdir(dirname(path), { recursive: true })
  1128. await writeFile(path, `${JSON.stringify({
  1129. ...toHeaderLine(header), version: SESSION_FORMAT_VERSION + 1,
  1130. })}\n`)
  1131. const storage = ctx.sessionPersistence as unknown as {
  1132. readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<unknown>
  1133. }
  1134. const failure = await storage.readStoredLog(path, header.id)
  1135. .then(() => undefined, (error: unknown) => error)
  1136. expect(failure).toBeInstanceOf(Error)
  1137. expect((failure as Error).name).toBe('SessionFormatUnsupportedError')
  1138. expect((failure as Error).message).toContain(`(raw log: ${path})`)
  1139. })
  1140. it('refuses a current Zstandard path without a complete header frame', async () => {
  1141. const compressedRoot = await freshRoot()
  1142. const compressed = new Context()
  1143. await compressed.plugin(JsonlSessionPersistence, { root: compressedRoot, compression: 'zstd' })
  1144. const header = meta('empty-zstd-generation', '/work')
  1145. const path = logPath(compressedRoot, header.cwd, header.id, 'zstd')
  1146. await mkdir(dirname(path), { recursive: true })
  1147. await writeFile(path, '')
  1148. const storage = compressed.sessionPersistence as unknown as {
  1149. readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<unknown>
  1150. }
  1151. try {
  1152. await expect(storage.readStoredLog(path, header.id))
  1153. .rejects.toThrow(/empty or header-less Zstandard session log/)
  1154. } finally {
  1155. await compressed.fiber.dispose()
  1156. }
  1157. })
  1158. })
  1159. describe('JsonlSessionPersistence: durability and crash semantics', () => {
  1160. let ctx: Context
  1161. beforeEach(async () => {
  1162. root = await freshRoot()
  1163. ctx = new Context()
  1164. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  1165. })
  1166. afterEach(async () => { await ctx.fiber.dispose() })
  1167. it('lazy materialization: create() writes no file until the first append', async () => {
  1168. const m = meta('lazy', '/work')
  1169. const handle = await ctx.sessionPersistence.create(m)
  1170. expect((await handle.read()).eventState).toBe('detached')
  1171. // create() materializes no file before the first append — while the
  1172. // created session is already visible to this process.
  1173. const dir = sessionDir(root, '/work', m.id)
  1174. await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow()
  1175. expect((await ctx.sessionPersistence.list()).map(s => s.header.id)).toContain(m.id)
  1176. expect((await ctx.sessionPersistence.stat(m.id))?.sizeBytes).toBeUndefined()
  1177. await handle.append(oneTurnLog())
  1178. expect((await stat(dir)).isDirectory()).toBe(true)
  1179. expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true)
  1180. expect((await ctx.sessionPersistence.list()).map(s => s.header.id)).toContain(m.id)
  1181. await handle.close()
  1182. })
  1183. it('flush materializes an explicitly durable empty session without an event row', async () => {
  1184. const m = meta('durable-empty', '/work')
  1185. const handle = await ctx.sessionPersistence.create(m)
  1186. await handle.flush()
  1187. await handle.close()
  1188. expect(await readFile(rawLogPath(root, '/work', m.id), 'utf8')).toBe(`${JSON.stringify(toHeaderLine(m))}\n`)
  1189. const reader = await ctx.sessionPersistence.open(m.id, 'read')
  1190. const read = await reader.read()
  1191. expect(read).toEqual({ eventState: 'shared-frozen', events: [] })
  1192. await reader.close()
  1193. })
  1194. it('close drains a routed event that arrives while it waits for an in-flight append', async () => {
  1195. const m = meta('late-closer', '/work')
  1196. const handle = await ctx.sessionPersistence.create(m) as JsonlSessionHandle
  1197. const service = ctx.sessionPersistence as unknown as {
  1198. persistBatch: (...args: [SessionHeader, readonly SessionEvent[], boolean]) => Promise<void>
  1199. }
  1200. const original = service.persistBatch.bind(service)
  1201. const gate = Promise.withResolvers<undefined>()
  1202. const entered = Promise.withResolvers<undefined>()
  1203. vi.spyOn(service, 'persistBatch').mockImplementationOnce(async (...args) => {
  1204. entered.resolve(undefined)
  1205. await gate.promise
  1206. return original(...args)
  1207. })
  1208. const [start, ...rest] = oneTurnLog()
  1209. const inflight = handle.append([start!])
  1210. // The append is in flight (inside the gated storage write) before close
  1211. // starts, so close waits on the chain rather than refusing the append.
  1212. await entered.promise
  1213. const closing = handle.close()
  1214. // A concurrently unwinding producer routes more events while close waits
  1215. // on the blocked chain; the close loop must still drain them.
  1216. for (const event of rest) handle.enqueueLive(event, () => {})
  1217. gate.resolve(undefined)
  1218. await inflight
  1219. await closing
  1220. const reopened = new Context()
  1221. await reopened.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  1222. await expect(readAll(reopened.sessionPersistence, m.id))
  1223. .resolves.toMatchObject({ events: oneTurnLog() })
  1224. await reopened.fiber.dispose()
  1225. })
  1226. it('service flush skips a write claim whose handle is still opening', async () => {
  1227. const m = meta('opening-claim', '/work')
  1228. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1229. const service = ctx.sessionPersistence as unknown as {
  1230. requireStoredLog: (
  1231. id: SessionId,
  1232. signal?: AbortSignal,
  1233. resolved?: unknown,
  1234. ) => Promise<unknown>
  1235. }
  1236. const original = service.requireStoredLog.bind(service)
  1237. const gate = Promise.withResolvers<undefined>()
  1238. const entered = Promise.withResolvers<undefined>()
  1239. vi.spyOn(service, 'requireStoredLog').mockImplementationOnce(async (...args) => {
  1240. entered.resolve(undefined)
  1241. await gate.promise
  1242. return original(...args)
  1243. })
  1244. const opening = ctx.sessionPersistence.open(m.id, 'write')
  1245. await entered.promise
  1246. // The claim exists but its handle is still constructing: nothing routes
  1247. // to it yet, so the barrier has nothing to flush there.
  1248. await ctx.sessionPersistence.flush()
  1249. gate.resolve(undefined)
  1250. await (await opening).close()
  1251. })
  1252. it('stat and list carry the physical artifact size once materialized', async () => {
  1253. const m = meta('sized', '/work')
  1254. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1255. const size = (await stat(rawLogPath(root, '/work', m.id))).size
  1256. expect((await ctx.sessionPersistence.stat(m.id))?.sizeBytes).toBe(size)
  1257. expect((await ctx.sessionPersistence.list()).find(s => s.header.id === m.id)?.sizeBytes).toBe(size)
  1258. })
  1259. it('binds revisions to the physical artifact identity', async () => {
  1260. const m = meta('revision-source')
  1261. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1262. const revision = (await ctx.sessionPersistence.stat(m.id))?.revision
  1263. // A fresh backend over the SAME root reports the same revision…
  1264. const reopenedCtx = new Context()
  1265. await reopenedCtx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  1266. expect((await reopenedCtx.sessionPersistence.stat(m.id))?.revision).toBe(revision)
  1267. // …while an identical log in a DIFFERENT root is a different source.
  1268. const otherRoot = await freshRoot()
  1269. const otherCtx = new Context()
  1270. await otherCtx.plugin(JsonlSessionPersistence, { root: otherRoot, compression: 'none' })
  1271. await writeLog(otherCtx.sessionPersistence, m, oneTurnLog())
  1272. expect((await otherCtx.sessionPersistence.stat(m.id))?.revision).not.toBe(revision)
  1273. await reopenedCtx.fiber.dispose()
  1274. await otherCtx.fiber.dispose()
  1275. })
  1276. it('an unchanged cold log parses once across an observe-then-resume handoff', async () => {
  1277. const m = meta('memo-handoff', '/work')
  1278. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1279. const path = rawLogPath(root, '/work', m.id)
  1280. readTally.enabled = true
  1281. // The observation's read parses the artifact...
  1282. expect((await readAll(ctx.sessionPersistence, m.id)).events).toEqual(oneTurnLog())
  1283. expect(readTally.bySuffix.get(path)).toBe(1)
  1284. // ...and the immediate write-open (resume) reuses the parsed log through
  1285. // the revision guard instead of re-reading the file.
  1286. const writer = await ctx.sessionPersistence.open(m.id, 'write')
  1287. expect((await writer.read()).events.length).toBe(oneTurnLog().length)
  1288. expect(readTally.bySuffix.get(path)).toBe(1)
  1289. // A local append invalidates the memo: the next cold read re-parses and
  1290. // observes the appended suffix.
  1291. await writer.append([
  1292. { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } },
  1293. { type: 'turn/end', seq: SessionSeq(7), time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  1294. ] as SessionEvent[])
  1295. await writer.close()
  1296. expect((await readAll(ctx.sessionPersistence, m.id)).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  1297. expect(readTally.bySuffix.get(path)).toBe(2)
  1298. })
  1299. it('a foreign write misses the memo through the revision guard', async () => {
  1300. const m = meta('memo-foreign', '/work')
  1301. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1302. const path = rawLogPath(root, '/work', m.id)
  1303. await readAll(ctx.sessionPersistence, m.id)
  1304. // Another backend instance over the same root appends behind this one's memo.
  1305. const foreign = new Context()
  1306. await foreign.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  1307. const writer = await foreign.sessionPersistence.open(m.id, 'write')
  1308. await writer.append([
  1309. { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } },
  1310. { type: 'turn/end', seq: SessionSeq(7), time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  1311. ] as SessionEvent[])
  1312. await writer.close()
  1313. await foreign.fiber.dispose()
  1314. readTally.enabled = true
  1315. expect((await readAll(ctx.sessionPersistence, m.id)).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  1316. expect(readTally.bySuffix.get(path)).toBe(1)
  1317. })
  1318. it('the cold-log memo keeps only the handoff window and evicts the oldest id', async () => {
  1319. const first = meta('memo-evict-a', '/work')
  1320. const second = meta('memo-evict-b', '/work')
  1321. const third = meta('memo-evict-c', '/work')
  1322. for (const m of [first, second, third]) await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1323. readTally.enabled = true
  1324. await readAll(ctx.sessionPersistence, first.id)
  1325. await readAll(ctx.sessionPersistence, second.id)
  1326. await readAll(ctx.sessionPersistence, third.id) // evicts the first id
  1327. await readAll(ctx.sessionPersistence, third.id) // still memoized
  1328. await readAll(ctx.sessionPersistence, first.id) // re-parses after eviction
  1329. expect(readTally.bySuffix.get(rawLogPath(root, '/work', first.id))).toBe(2)
  1330. expect(readTally.bySuffix.get(rawLogPath(root, '/work', third.id))).toBe(1)
  1331. })
  1332. it('a handle read retries once when the file revision changes during the read', async () => {
  1333. const m = meta('read-revision-race', '/work')
  1334. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1335. const handle = await ctx.sessionPersistence.open(m.id, 'read')
  1336. try {
  1337. const internals = ctx.sessionPersistence as unknown as { coldLogMemo: Map<SessionId, unknown> }
  1338. internals.coldLogMemo.clear()
  1339. statRace.path = rawLogPath(root, '/work', m.id)
  1340. expect((await handle.read()).events).toEqual(oneTurnLog())
  1341. // The memo probe, the initial identity, the mismatching post-read stat
  1342. // (reused as the retry's pre-read identity), and the retry's matching
  1343. // post-read stat.
  1344. expect(statRace.reads).toBe(4)
  1345. } finally {
  1346. statRace.path = undefined
  1347. await handle.close()
  1348. }
  1349. })
  1350. it('a continuously churning revision yields the committed prefix instead of looping', async () => {
  1351. const m = meta('read-revision-churn', '/work')
  1352. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1353. const handle = await ctx.sessionPersistence.open(m.id, 'read')
  1354. try {
  1355. const internals = ctx.sessionPersistence as unknown as { coldLogMemo: Map<SessionId, unknown> }
  1356. internals.coldLogMemo.clear()
  1357. statRace.mode = 'churn'
  1358. statRace.path = rawLogPath(root, '/work', m.id)
  1359. // Every stat disagrees, so the bounded read stops after one retry and
  1360. // serves the retry's pre-read committed prefix — here the whole log.
  1361. // Four stats: the memo probe, the initial identity, and one mismatching
  1362. // post-read stat per bounded attempt.
  1363. expect((await handle.read()).events).toEqual(oneTurnLog())
  1364. expect(statRace.reads).toBe(4)
  1365. } finally {
  1366. statRace.path = undefined
  1367. await handle.close()
  1368. }
  1369. })
  1370. it('appends on reopen extend the same artifact and a fork materializes its own', async () => {
  1371. const parent = meta('location-parent', '/work')
  1372. await writeLog(ctx.sessionPersistence, parent, oneTurnLog())
  1373. const parentPath = rawLogPath(root, '/work', parent.id)
  1374. const sizeBefore = (await stat(parentPath)).size
  1375. const loaded = await readAll(ctx.sessionPersistence, parent.id)
  1376. const writer = await ctx.sessionPersistence.open(parent.id, 'write')
  1377. await writer.append([
  1378. { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } },
  1379. { type: 'turn/end', seq: SessionSeq(7), time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  1380. ] as SessionEvent[])
  1381. await writer.close()
  1382. // The resume-side append extended the same physical artifact.
  1383. expect((await stat(parentPath)).size).toBeGreaterThan(sizeBefore)
  1384. const child: SessionHeader = {
  1385. ...loaded.meta,
  1386. id: SessionId('location-child'),
  1387. parentSession: parent.id,
  1388. isSeeded: true,
  1389. }
  1390. const childHandle = await ctx.sessionPersistence.create(child, {
  1391. inheritedEventCount: SessionLogOffset(loaded.events.length),
  1392. })
  1393. await childHandle.append([
  1394. ...loaded.events,
  1395. {
  1396. type: 'session/end-seed',
  1397. seq: SessionSeq(loaded.events.length),
  1398. time: loaded.events.at(-1)?.time ?? 1,
  1399. data: { inherited: true },
  1400. },
  1401. ])
  1402. await childHandle.close()
  1403. const childPath = rawLogPath(root, '/work', child.id)
  1404. expect(childPath).not.toBe(parentPath)
  1405. expect((await stat(childPath)).isFile()).toBe(true)
  1406. })
  1407. it('round-trip is byte-identical including nested Assistant stream records', async () => {
  1408. const m = meta('chunks')
  1409. const log: SessionEvent[] = [
  1410. { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
  1411. { type: 'step/start', seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1 } },
  1412. { type: 'assistant/message', seq: SessionSeq(2), time: 5, data: {
  1413. turn: 1, step: 1,
  1414. message: createMessage({
  1415. role: 'assistant',
  1416. content: [{ type: 'text', text: 'hello' }],
  1417. source: {
  1418. kind: 'model',
  1419. ...{ provider: 'mock', model: 'mock' },
  1420. },
  1421. }),
  1422. stream: [
  1423. { type: 'text-chunks', time0: 3, index: 0, dt: [1], texts: ['he', 'llo'] },
  1424. { type: 'chunk', time: 5, chunk: { type: 'finish', reason: { kind: 'stop' } } },
  1425. ],
  1426. }, surfaceOp: 'append' },
  1427. { type: 'step/end', seq: SessionSeq(3), time: 6, data: { turn: 1, step: 1 } },
  1428. { type: 'turn/end', seq: SessionSeq(4), time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
  1429. ]
  1430. await writeLog(ctx.sessionPersistence, m, log)
  1431. const loaded = await readAll(ctx.sessionPersistence, m.id)
  1432. expect(loaded.events).toEqual(log)
  1433. })
  1434. it('a torn crash tail is served as the committed prefix and repaired only by the write path', async () => {
  1435. const m = meta('crash', '/proj')
  1436. await writeLog(ctx.sessionPersistence, m, oneTurnLog()) // seqs 0..5
  1437. const path = rawLogPath(root, '/proj', m.id)
  1438. const committed = await readFile(path)
  1439. // A crash mid-second-turn: two complete uncommitted lines plus a torn
  1440. // fragment with no newline.
  1441. const tail = [
  1442. JSON.stringify({ type: 'turn/start', seq: SessionSeq(6), time: 8, data: { turn: 2 } }),
  1443. JSON.stringify({ type: 'step/start', seq: SessionSeq(7), time: 9, data: { turn: 2, step: 1 } }),
  1444. '{"type":"assistant/chunk","seq":8,"ti', // truncated partial line (no newline)
  1445. ].join('\n')
  1446. await writeFile(path, tail, { flag: 'a' })
  1447. // A reader serves the valid contiguous prefix — the complete tail lines
  1448. // ARE committed reads, the torn fragment never is — without repairing.
  1449. const loaded = await readAll(ctx.sessionPersistence, m.id)
  1450. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  1451. expect(await readFile(path)).toEqual(Buffer.concat([committed, Buffer.from(tail)]))
  1452. // The write path truncates the torn fragment durably before its first
  1453. // append, preserving every committed byte before it.
  1454. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  1455. await appendBatch(ctx.sessionPersistence, m.id, [
  1456. { type: 'step/end', seq: SessionSeq(8), time: 10, data: { turn: 2, step: 1 } },
  1457. { type: 'turn/end', seq: SessionSeq(9), time: 11, data: { turn: 2, reason: { kind: 'interrupted' } } },
  1458. ])
  1459. expect(warn).toHaveBeenCalledWith(expect.stringContaining('recovered from a torn tail'))
  1460. const repaired = await readFile(path, 'utf8')
  1461. expect(repaired.startsWith(committed.toString('utf8'))).toBe(true)
  1462. expect(repaired).not.toContain('assistant/chunk')
  1463. const reloaded = await readAll(ctx.sessionPersistence, m.id)
  1464. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
  1465. })
  1466. it('a stored open turn is served as stored, with no synthetic closers', async () => {
  1467. // Logical repair (closing an interrupted turn) is a resume concern; the
  1468. // storage seam returns exactly the committed events.
  1469. const m = meta('open-turn', '/h')
  1470. await writeLog(ctx.sessionPersistence, m, [
  1471. { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
  1472. ])
  1473. const { events } = await readAll(ctx.sessionPersistence, m.id)
  1474. expect(events.map(e => e.type)).toEqual(['turn/start'])
  1475. })
  1476. it('a failed appendLines truncates partial bytes so a retry has no seq gap', async () => {
  1477. const m = meta('truncate-retry')
  1478. await writeLog(ctx.sessionPersistence, m, oneTurnLog()) // materialized, seqs 0..5
  1479. const sizeBefore = (await stat(rawLogPath(root, undefined, m.id))).size
  1480. const handle = await ctx.sessionPersistence.open(m.id, 'write')
  1481. try {
  1482. // Force the NEXT fsync (inside appendLines) to fail once, AFTER writeFile
  1483. // has already put bytes on disk — simulating an ENOSPC/fsync error
  1484. // mid-append. The recovery truncate() also fsyncs, so allow that one.
  1485. const probe = await (await import('node:fs/promises')).open(rawLogPath(root, undefined, m.id), 'r')
  1486. const proto = Object.getPrototypeOf(probe) as { sync: () => Promise<void> }
  1487. await probe.close()
  1488. const realSync = proto.sync
  1489. let failed = false
  1490. const spy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
  1491. if (!failed) { failed = true; throw new Error('simulated fsync ENOSPC') }
  1492. return realSync.call(this)
  1493. })
  1494. const turn2: SessionEvent[] = [
  1495. { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } },
  1496. { type: 'turn/end', seq: SessionSeq(7), time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  1497. ]
  1498. // The append rejects, but the partial bytes are truncated back: the file
  1499. // is its pre-append size and the handle cursor is unchanged.
  1500. await expect(handle.append(turn2)).rejects.toThrow(/ENOSPC/)
  1501. expect((await stat(rawLogPath(root, undefined, m.id))).size).toBe(sizeBefore)
  1502. spy.mockRestore()
  1503. // The retry now succeeds with NO seq gap — the log is contiguous 0..7.
  1504. await handle.append(turn2)
  1505. expect((await handle.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  1506. } finally {
  1507. await handle.close()
  1508. }
  1509. })
  1510. it('reports both the append failure and a failed rollback', async () => {
  1511. const m = meta('rollback-failure')
  1512. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1513. const path = rawLogPath(root, undefined, m.id)
  1514. const probe = await (await import('node:fs/promises')).open(path, 'r')
  1515. const proto = Object.getPrototypeOf(probe) as { sync: () => Promise<void> }
  1516. await probe.close()
  1517. const realSync = proto.sync
  1518. let failed = false
  1519. const syncSpy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
  1520. if (!failed) { failed = true; throw new Error('simulated append fsync failure') }
  1521. return realSync.call(this)
  1522. })
  1523. const backend = ctx.sessionPersistence as unknown as {
  1524. rollbackAppend: (path: string, size: number) => Promise<void>
  1525. }
  1526. const realRollback = backend.rollbackAppend.bind(backend)
  1527. backend.rollbackAppend = () => Promise.reject(new Error('simulated rollback failure'))
  1528. try {
  1529. await appendBatch(ctx.sessionPersistence, m.id, [
  1530. { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } },
  1531. ])
  1532. throw new Error('expected append to reject')
  1533. } catch (error) {
  1534. expect(error).toBeInstanceOf(AggregateError)
  1535. const aggregate = error as AggregateError
  1536. expect(aggregate.message).toContain(`failed to roll back append to "${path}"`)
  1537. expect(aggregate.errors).toHaveLength(2)
  1538. expect(aggregate.errors[0]).toMatchObject({ message: 'simulated append fsync failure' })
  1539. expect(aggregate.errors[1]).toMatchObject({ message: 'simulated rollback failure' })
  1540. } finally {
  1541. backend.rollbackAppend = realRollback
  1542. syncSpy.mockRestore()
  1543. }
  1544. })
  1545. it('rejects a mismatched header id before serving either session log', async () => {
  1546. const a = meta('identity-a', '/same')
  1547. const b = meta('identity-b', '/same')
  1548. await writeLog(ctx.sessionPersistence, a, [{
  1549. type: 'turn/start',
  1550. seq: SessionSeq(0),
  1551. time: 1,
  1552. data: { turn: 1 },
  1553. }])
  1554. await writeLog(ctx.sessionPersistence, b, oneTurnLog())
  1555. const aPath = rawLogPath(root, a.cwd, a.id)
  1556. const bPath = rawLogPath(root, b.cwd, b.id)
  1557. await rewriteHeader(aPath, (header) => { header.id = b.id })
  1558. const beforeA = await readFile(aPath)
  1559. const beforeB = await readFile(bPath)
  1560. await expect(readAll(ctx.sessionPersistence, a.id))
  1561. .rejects.toThrow(/requested id "identity-a" does not match header id "identity-b"/)
  1562. expect(await readFile(aPath)).toEqual(beforeA)
  1563. expect(await readFile(bPath)).toEqual(beforeB)
  1564. })
  1565. it('path-traversal session ids are neutralized (no escape from root)', async () => {
  1566. const evil = SessionId('../../etc/pwn')
  1567. const m: SessionHeader = {
  1568. version: SESSION_FORMAT_VERSION,
  1569. id: evil,
  1570. createdAt: 1,
  1571. isSeeded: false,
  1572. }
  1573. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1574. // The file lives UNDER root, not at ../../etc.
  1575. const all: string[] = []
  1576. async function walk(dir: string): Promise<void> {
  1577. for (const e of await readdir(dir, { withFileTypes: true })) {
  1578. const p = join(dir, e.name)
  1579. if (e.isDirectory()) await walk(p)
  1580. else all.push(p)
  1581. }
  1582. }
  1583. await walk(root)
  1584. expect(all.length).toBeGreaterThan(0)
  1585. expect(all.every(p => p.startsWith(root))).toBe(true)
  1586. })
  1587. it('rejects pre-aborted operations with the exact cancellation reason', async () => {
  1588. const m = meta('pre-aborted')
  1589. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1590. const reason = new Error('persistence operation cancelled')
  1591. const controller = new AbortController()
  1592. controller.abort(reason)
  1593. const signal = controller.signal
  1594. await expect(ctx.sessionPersistence.create(meta('aborted-create'), { signal })).rejects.toBe(reason)
  1595. await expect(ctx.sessionPersistence.open(m.id, 'read', { signal })).rejects.toBe(reason)
  1596. await expect(ctx.sessionPersistence.open(m.id, 'write', { signal })).rejects.toBe(reason)
  1597. await expect(ctx.sessionPersistence.stat(m.id, { signal })).rejects.toBe(reason)
  1598. await expect(ctx.sessionPersistence.list({ signal })).rejects.toBe(reason)
  1599. const handle = await ctx.sessionPersistence.open(m.id, 'write')
  1600. try {
  1601. await expect(handle.read(0, undefined, { signal })).rejects.toBe(reason)
  1602. await expect(handle.append([
  1603. { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } },
  1604. ], { signal })).rejects.toBe(reason)
  1605. await expect(handle.flush({ signal })).rejects.toBe(reason)
  1606. // The aborted mutations left the log untouched.
  1607. expect((await handle.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
  1608. } finally {
  1609. await handle.close()
  1610. }
  1611. })
  1612. it('stat handles artifact removal and non-ENOENT failures after log discovery', async () => {
  1613. const m = meta('stored-revision-race')
  1614. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1615. const persistence = ctx.sessionPersistence as JsonlSessionPersistence
  1616. const internals = persistence as unknown as {
  1617. findLog(id: SessionId, signal?: AbortSignal): Promise<{
  1618. sourcePath: string
  1619. sourceVersion: number
  1620. currentPath: string
  1621. } | undefined>
  1622. }
  1623. const path = rawLogPath(root, m.cwd, m.id)
  1624. const selected = {
  1625. sourcePath: path,
  1626. sourceVersion: SESSION_FORMAT_VERSION,
  1627. currentPath: path,
  1628. }
  1629. const findLog = vi.spyOn(internals, 'findLog').mockResolvedValue(selected)
  1630. await rm(path)
  1631. expect(await persistence.stat(m.id)).toBeUndefined()
  1632. const invalidPath = `${path}\0`
  1633. findLog.mockResolvedValue({ ...selected, sourcePath: invalidPath, currentPath: invalidPath })
  1634. await expect(persistence.stat(m.id)).rejects.toMatchObject({
  1635. code: 'ERR_INVALID_ARG_VALUE',
  1636. })
  1637. const reason = new Error('stat cancelled after discovery')
  1638. const controller = new AbortController()
  1639. findLog.mockImplementation(async () => {
  1640. controller.abort(reason)
  1641. return { ...selected, sourcePath: invalidPath, currentPath: invalidPath }
  1642. })
  1643. await expect(persistence.stat(m.id, { signal: controller.signal })).rejects.toBe(reason)
  1644. })
  1645. it('stat reports absence for an artifact vanishing before its identity stat and surfaces other faults', async () => {
  1646. const m = meta('stat-fault', '/work')
  1647. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1648. // The header read succeeds; the identity stat then loses the file (a
  1649. // concurrent removal) or hits a storage fault.
  1650. statFailure.path = rawLogPath(root, '/work', m.id)
  1651. statFailure.error = Object.assign(new Error('ENOENT: vanished'), { code: 'ENOENT' })
  1652. expect(await ctx.sessionPersistence.stat(m.id)).toBeUndefined()
  1653. statFailure.error = Object.assign(new Error('EACCES: denied'), { code: 'EACCES' })
  1654. await expect(ctx.sessionPersistence.stat(m.id)).rejects.toThrow(/EACCES/)
  1655. })
  1656. it('an empty append batch is a no-op that does not materialize', async () => {
  1657. const m = meta('empty-batch', '/work')
  1658. const handle = await ctx.sessionPersistence.create(m)
  1659. await handle.append([])
  1660. await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow()
  1661. await handle.append(oneTurnLog())
  1662. expect((await handle.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
  1663. await handle.close()
  1664. })
  1665. it('lists a pending id once when an artifact for the same id appears beneath it', async () => {
  1666. const m = meta('shadowed', '/w')
  1667. const creator = await ctx.sessionPersistence.create(m)
  1668. // An artifact materialized outside this instance's create/append path.
  1669. await mkdir(sessionDir(root, '/w', m.id), { recursive: true })
  1670. await writeFile(rawLogPath(root, '/w', m.id), [
  1671. JSON.stringify(toHeaderLine(m)),
  1672. ...oneTurnLog().map(e => JSON.stringify(e)),
  1673. ].join('\n') + '\n')
  1674. const entries = (await ctx.sessionPersistence.list()).filter(s => s.header.id === m.id)
  1675. expect(entries).toHaveLength(1)
  1676. // The artifact entry wins over the pending one.
  1677. expect(entries[0]!.sizeBytes).toBeDefined()
  1678. await creator.close()
  1679. })
  1680. it('a read handle over an erased pending session fails loudly', async () => {
  1681. const m = meta('erased-pending')
  1682. const creator = await ctx.sessionPersistence.create(m)
  1683. const reader = await ctx.sessionPersistence.open(m.id, 'read')
  1684. expect((await reader.read()).events).toEqual([])
  1685. // The creator closes without ever appending: the session never existed.
  1686. await creator.close()
  1687. await expect(reader.read()).rejects.toThrow(/not found/)
  1688. await reader.close()
  1689. })
  1690. it('a read handle rejects a stored log that shrank below an observed prefix', async () => {
  1691. const m = meta('shrunk', '/work')
  1692. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1693. const reader = await ctx.sessionPersistence.open(m.id, 'read')
  1694. try {
  1695. expect((await reader.read()).events).toHaveLength(6)
  1696. // Committed events are never rewritten; a shorter file is damage, not a
  1697. // legal state, and a handle must not silently backtrack.
  1698. await writeFile(rawLogPath(root, '/work', m.id), [
  1699. JSON.stringify(toHeaderLine(m)),
  1700. JSON.stringify(oneTurnLog()[0]),
  1701. ].join('\n') + '\n')
  1702. await expect(reader.read()).rejects.toThrow(/shrank below a previously observed prefix/)
  1703. } finally {
  1704. await reader.close()
  1705. }
  1706. })
  1707. it('backend dispose aggregates open-handle close failures into one reported error', async () => {
  1708. const ctx2 = new Context()
  1709. const fiber = await ctx2.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  1710. const reported = vi.spyOn(ctx2.logger, 'error').mockImplementation(() => undefined)
  1711. const handle = await ctx2.sessionPersistence.create(meta('dispose-fail'))
  1712. const failure = new Error('close exploded')
  1713. vi.spyOn(handle, 'close').mockRejectedValue(failure)
  1714. await fiber.dispose()
  1715. // Cordis contains effect-disposal failures and reports them; the backend's
  1716. // teardown aggregate names every handle that refused to close.
  1717. const aggregate = reported.mock.calls
  1718. .map((call): unknown => call[0])
  1719. .find((value): value is AggregateError => value instanceof AggregateError)
  1720. expect(aggregate?.message).toContain('session-persistence-jsonl dispose failed')
  1721. expect(aggregate?.errors).toEqual([failure])
  1722. })
  1723. it('omits a listed artifact removed after discovery', async () => {
  1724. const m = meta('vanishing-snapshot')
  1725. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1726. const persistence = ctx.sessionPersistence as unknown as {
  1727. listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>>
  1728. }
  1729. const listArtifacts = persistence.listArtifacts.bind(persistence)
  1730. const discovery = vi.spyOn(persistence, 'listArtifacts').mockImplementation(async () => {
  1731. const artifacts = await listArtifacts()
  1732. await rm(artifacts[0]!.path)
  1733. return artifacts
  1734. })
  1735. await expect(ctx.sessionPersistence.list()).resolves.toEqual([])
  1736. discovery.mockRestore()
  1737. })
  1738. it('surfaces non-ENOENT stat failures during listing', async () => {
  1739. const persistence = ctx.sessionPersistence as unknown as {
  1740. listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>>
  1741. }
  1742. const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{
  1743. header: meta('snapshot-stat-failure'),
  1744. path: `${root}\0snapshot-stat-failure`,
  1745. }])
  1746. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/null bytes/)
  1747. discovery.mockRestore()
  1748. })
  1749. it('forwards list cancellation and awaits in-flight discovery cleanup', async () => {
  1750. const persistence = ctx.sessionPersistence as unknown as {
  1751. listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>>
  1752. }
  1753. const started = Promise.withResolvers<AbortSignal>()
  1754. const cleanup = Promise.withResolvers<undefined>()
  1755. vi.spyOn(persistence, 'listArtifacts').mockImplementation(async (signal) => {
  1756. if (signal === undefined) throw new Error('expected list signal')
  1757. started.resolve(signal)
  1758. await cleanup.promise
  1759. return []
  1760. })
  1761. const reason = new Error('JSONL list discovery cancelled')
  1762. const controller = new AbortController()
  1763. const pending = ctx.sessionPersistence.list({ signal: controller.signal })
  1764. expect(await started.promise).toBe(controller.signal)
  1765. let settled = false
  1766. void pending.then(
  1767. () => { settled = true },
  1768. () => { settled = true },
  1769. )
  1770. controller.abort(reason)
  1771. await Promise.resolve()
  1772. expect(settled).toBe(false)
  1773. cleanup.resolve(undefined)
  1774. await expect(pending).rejects.toBe(reason)
  1775. })
  1776. it('checks cancellation after an uncancellable list stat settles', async () => {
  1777. const m = meta('snapshot-stat-cancellation')
  1778. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  1779. const persistence = ctx.sessionPersistence as unknown as {
  1780. listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>>
  1781. }
  1782. const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{
  1783. header: m,
  1784. path: rawLogPath(root, m.cwd, m.id),
  1785. }])
  1786. const reason = new Error('JSONL list stat cancelled')
  1787. const controller = new AbortController()
  1788. const pending = ctx.sessionPersistence.list({ signal: controller.signal })
  1789. queueMicrotask(() => { controller.abort(reason) })
  1790. await expect(pending).rejects.toBe(reason)
  1791. expect(discovery).toHaveBeenCalledWith(controller.signal)
  1792. })
  1793. })
  1794. describe('JsonlSessionPersistence: scanLog unit', () => {
  1795. it('requires exactly one newline-terminated header record', () => {
  1796. const header = JSON.stringify(toHeaderLine(meta('scanner-header')))
  1797. expect(() => new SessionLogScanner(Buffer.alloc(0))).toThrow(/header-less/)
  1798. expect(() => new SessionLogScanner(Buffer.from(header))).toThrow(/header-less/)
  1799. expect(() => new SessionLogScanner(Buffer.from(`${header}\n${header}\n`))).toThrow(/header-less/)
  1800. })
  1801. it('fails immediately on an invalid row in strict scanner mode', () => {
  1802. const header = Buffer.from(`${JSON.stringify(toHeaderLine(meta('scanner-strict')))}\n`)
  1803. const scanner = new SessionLogScanner(header, 'strict')
  1804. expect(() => { scanner.write(Buffer.from('null\n')) }).toThrow(/invalid committed event/)
  1805. })
  1806. it('expands valid stored provenance ranges', () => {
  1807. const log = [
  1808. JSON.stringify(toHeaderLine(meta('scanner-provenance'))),
  1809. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
  1810. JSON.stringify(oneTurnLog()[1]),
  1811. JSON.stringify({
  1812. ...oneTurnLog()[1], seq: 2, time: 3, sourceEventSeqs: [[0, 1]],
  1813. }),
  1814. '',
  1815. ].join('\n')
  1816. const restored = scanLog(Buffer.from(log)).events[2]
  1817. expect(restored).toMatchObject({ type: 'user/message', surfaceOp: 'append', sourceEventSeqs: [0, 1] })
  1818. })
  1819. it('requires the tagged inherited cut to agree with the v3 header lineage', () => {
  1820. const seeded = { ...meta('scanner-seeded-cut'), isSeeded: true }
  1821. const seededHeader = JSON.stringify(toHeaderLine(seeded, SessionLogOffset(0)))
  1822. expect(() => scanLog(Buffer.from(`${seededHeader}\n`)))
  1823. .toThrow(/seeded Session lacks an inherited end-seed marker/)
  1824. const unseededHeader = JSON.stringify(toHeaderLine(meta('scanner-unseeded-cut')))
  1825. const inheritedMarker = JSON.stringify({
  1826. type: 'session/end-seed', seq: 0, time: 1, data: { inherited: true },
  1827. })
  1828. expect(() => scanLog(Buffer.from(`${unseededHeader}\n${inheritedMarker}\n`)))
  1829. .toThrow(/unseeded Session contains an inherited end-seed marker/)
  1830. })
  1831. it('handles empty writes, boundary newlines, torn fragments, and scanner completion', () => {
  1832. const header = Buffer.from(`${JSON.stringify(toHeaderLine(meta('scanner-lifecycle')))}\n`)
  1833. const event = Buffer.from(JSON.stringify(oneTurnLog()[0]))
  1834. const scanner = new SessionLogScanner(header)
  1835. scanner.write(Buffer.alloc(0))
  1836. scanner.write(event)
  1837. scanner.write(Buffer.from('\nignored torn tail'))
  1838. const result = scanner.finish()
  1839. expect(result.events).toEqual([oneTurnLog()[0]])
  1840. expect(result.committedBytes).toBe(header.length + event.length + 1)
  1841. expect(() => { scanner.write(Buffer.from('\n')) }).toThrow(/finished/)
  1842. })
  1843. it('keeps scanning after a tolerable corrupt suffix until a committed turn end appears', () => {
  1844. const header = Buffer.from(`${JSON.stringify(toHeaderLine(meta('scanner-corrupt-suffix')))}\n`)
  1845. const scanner = new SessionLogScanner(header)
  1846. scanner.write(Buffer.from([
  1847. JSON.stringify(oneTurnLog()[0]),
  1848. '{not json',
  1849. JSON.stringify({ type: 'step/start', seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1 } }),
  1850. '',
  1851. ].join('\n')))
  1852. expect(scanner.finish().events).toEqual([oneTurnLog()[0]])
  1853. const committed = new SessionLogScanner(header)
  1854. expect(() => { committed.write(Buffer.from([
  1855. JSON.stringify({ type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
  1856. '',
  1857. ].join('\n'))) }).toThrow(/seq gap/)
  1858. })
  1859. it('incrementally scans records split across reusable decoder chunks', () => {
  1860. const header = Buffer.from(`${JSON.stringify(toHeaderLine(meta('incremental')))}\n`)
  1861. const body = Buffer.from(`${oneTurnLog().map(event => JSON.stringify(event)).join('\n').replace('"hi"', '"你好"')}\n`)
  1862. const split = body.indexOf(Buffer.from('你')) + 1
  1863. const firstChunk = Buffer.from(body.subarray(0, split))
  1864. const scanner = new SessionLogScanner(header)
  1865. scanner.write(firstChunk)
  1866. const checkpoint = scanner.checkpoint()
  1867. firstChunk.fill(0)
  1868. scanner.write(body.subarray(split))
  1869. expect(checkpoint).toMatchObject({
  1870. inputBytes: header.length + split,
  1871. eventCount: 1,
  1872. })
  1873. expect(scanner.finish()).toEqual(scanLog(Buffer.concat([header, body])))
  1874. })
  1875. it('rejects a header-less / empty log', () => {
  1876. expect(() => scanLog(Buffer.from(''))).toThrow()
  1877. })
  1878. it('rejects a corrupt header line', () => {
  1879. expect(() => scanLog(Buffer.from('not json\n'))).toThrow(/header/)
  1880. })
  1881. it('rejects a non-session first line', () => {
  1882. expect(() => scanLog(Buffer.from('{"type":"event"}\n'))).toThrow(/session header/)
  1883. })
  1884. it.each([
  1885. ['fractional', 1.5],
  1886. ['negative', -1],
  1887. ['unsafe', Number.MAX_SAFE_INTEGER + 1],
  1888. ])('rejects a session header with a %s createdAt', (_label, createdAt) => {
  1889. const log = JSON.stringify({
  1890. type: 'session',
  1891. version: SESSION_FORMAT_VERSION,
  1892. id: 'invalid-created-at',
  1893. createdAt,
  1894. delegationDepth: 0,
  1895. }) + '\n'
  1896. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  1897. })
  1898. it('rejects a session header with negative-zero createdAt', () => {
  1899. const log = `{"type":"session","version":${SESSION_FORMAT_VERSION},"id":"invalid-created-at","createdAt":-0,"delegationDepth":0}\n`
  1900. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  1901. })
  1902. it.each([
  1903. ['missing', undefined],
  1904. ['a string', '1'],
  1905. ['fractional', 1.5],
  1906. ['negative', -1],
  1907. ])('rejects a session header with %s delegationDepth', (_label, delegationDepth) => {
  1908. const log = JSON.stringify({
  1909. type: 'session',
  1910. version: SESSION_FORMAT_VERSION,
  1911. id: 'invalid-depth',
  1912. createdAt: 1,
  1913. ...delegationDepth === undefined ? {} : { delegationDepth },
  1914. }) + '\n'
  1915. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  1916. })
  1917. it('rejects a session header with negative-zero delegationDepth', () => {
  1918. const log = `{"type":"session","version":${SESSION_FORMAT_VERSION},"id":"invalid-depth","createdAt":1,"delegationDepth":-0}\n`
  1919. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  1920. })
  1921. it('round-trips the agent preset a session was composed from', () => {
  1922. const line = toHeaderLine({
  1923. version: SESSION_FORMAT_VERSION,
  1924. id: SessionId('composed'),
  1925. isSeeded: false,
  1926. createdAt: 1,
  1927. delegationDepth: 0,
  1928. agentPreset: 'minimal',
  1929. })
  1930. const log = `${JSON.stringify(line)}\n`
  1931. // The preset decides the resumed session's tools and prompt; dropping it
  1932. // on disk would restore a composition the logged history contradicts.
  1933. expect(scanLog(Buffer.from(log)).meta.agentPreset).toBe('minimal')
  1934. })
  1935. it('rejects a session header whose agentPreset is not a string', () => {
  1936. const log = JSON.stringify({
  1937. type: 'session',
  1938. version: SESSION_FORMAT_VERSION,
  1939. id: 'bad-preset',
  1940. createdAt: 1,
  1941. delegationDepth: 0,
  1942. agentPreset: 7,
  1943. }) + '\n'
  1944. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  1945. })
  1946. it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => {
  1947. const log = [
  1948. JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: 'g', createdAt: 1, isSeeded: false, delegationDepth: 0 }),
  1949. JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }),
  1950. JSON.stringify({ type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
  1951. ].join('\n') + '\n'
  1952. // No committed turn/end, so the gap is a tolerated crash boundary: scanLog
  1953. // PRESERVES the contiguous prefix (turn/start seq 0) — real interrupted-turn
  1954. // work, not discarded — and stops at the gap.
  1955. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
  1956. })
  1957. it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => {
  1958. const log = [
  1959. JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: 'g2', createdAt: 1, isSeeded: false, delegationDepth: 0 }),
  1960. JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }),
  1961. JSON.stringify({ type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
  1962. JSON.stringify({ type: 'turn/end', seq: SessionSeq(3), time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
  1963. ].join('\n') + '\n'
  1964. // A turn/end exists, so the prefix up to it is committed — but it has a hole.
  1965. // Truncating it would silently drop committed data → unloadable.
  1966. expect(() => scanLog(Buffer.from(log))).toThrow(/seq gap/)
  1967. })
  1968. it('rejects malformed records before a later committed turn/end', () => {
  1969. const corruptRecords = [
  1970. ['{not json', /unparsable committed event/],
  1971. ['null', /invalid committed event/],
  1972. [JSON.stringify({ type: 'assistant/message', sourceEventSeqs: [0], data: {} }), /invalid committed event/],
  1973. ] as const
  1974. for (const [record, message] of corruptRecords) {
  1975. const log = [
  1976. JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: 'c', createdAt: 1, isSeeded: false, delegationDepth: 0 }),
  1977. record,
  1978. JSON.stringify({ type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
  1979. ].join('\n') + '\n'
  1980. expect(() => scanLog(Buffer.from(log))).toThrow(message)
  1981. }
  1982. })
  1983. it('a header-only log (no event lines at all) preserves nothing — committedBytes is the header', () => {
  1984. const log = JSON.stringify({
  1985. type: 'session', version: SESSION_FORMAT_VERSION, id: 'h0', createdAt: 1,
  1986. isSeeded: false, delegationDepth: 0,
  1987. }) + '\n'
  1988. const scanned = scanLog(Buffer.from(log))
  1989. expect(scanned.events).toEqual([])
  1990. // committedBytes falls back to the header line's end (no preserved events).
  1991. expect(scanned.committedBytes).toBe(Buffer.byteLength(log, 'utf8'))
  1992. })
  1993. it('a corrupt line after the last turn/end bounds the preserved tail', () => {
  1994. const log = [
  1995. JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: 'c2', createdAt: 1, isSeeded: false, delegationDepth: 0 }),
  1996. JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }),
  1997. '{not json', // corrupt crash fragment, no turn/end committed
  1998. ].join('\n') + '\n'
  1999. // The contiguous prefix (turn/start seq 0) is preserved; the corrupt
  2000. // fragment after it is the tolerated crash boundary.
  2001. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
  2002. })
  2003. it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => {
  2004. const log = [
  2005. JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: 't', createdAt: 1, isSeeded: false, delegationDepth: 0 }),
  2006. JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }),
  2007. JSON.stringify({ type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
  2008. JSON.stringify({ type: 'step/start', seq: SessionSeq(9), time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail
  2009. ].join('\n') + '\n'
  2010. const { events } = scanLog(Buffer.from(log))
  2011. expect(events.map(e => e.seq)).toEqual([0, 1]) // tail dropped
  2012. })
  2013. })
  2014. describe('JsonlSessionPersistence: nested v3 Assistant streams', () => {
  2015. let ctx: Context
  2016. beforeEach(async () => {
  2017. root = await freshRoot()
  2018. ctx = new Context()
  2019. // compression: 'none' — these tests assert the textual current row layout.
  2020. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  2021. })
  2022. afterEach(async () => { await ctx.fiber.dispose() })
  2023. /** A one-turn log whose message embeds a five-member text-delta run. */
  2024. function chunkRunLog(): SessionEvent[] {
  2025. return [
  2026. { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
  2027. { type: 'step/start', seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1 } },
  2028. { type: 'assistant/message', seq: SessionSeq(2), time: 8, data: {
  2029. turn: 1, step: 1,
  2030. message: createMessage({
  2031. role: 'assistant',
  2032. content: [{ type: 'text', text: 't0t1t2t3t4' }],
  2033. source: {
  2034. kind: 'model',
  2035. ...{ provider: 'mock', model: 'mock' },
  2036. },
  2037. }),
  2038. stream: [
  2039. {
  2040. type: 'text-chunks', time0: 3, index: 0,
  2041. dt: [1, 1, 1, 1], texts: ['t0', 't1', 't2', 't3', 't4'],
  2042. },
  2043. { type: 'chunk', time: 8, chunk: { type: 'finish', reason: { kind: 'stop' } } },
  2044. ],
  2045. }, surfaceOp: 'append' },
  2046. { type: 'step/end', seq: SessionSeq(3), time: 9, data: { turn: 1, step: 1 } },
  2047. { type: 'turn/end', seq: SessionSeq(4), time: 10, data: { turn: 1, reason: { kind: 'completed' } } },
  2048. ]
  2049. }
  2050. it('writes one row per event with compact stream records nested in the message', async () => {
  2051. const m = meta('packed', '/work')
  2052. const log = chunkRunLog()
  2053. await writeLog(ctx.sessionPersistence, m, log)
  2054. const raw = (await readFile(rawLogPath(root, '/work', m.id), 'utf8')).split('\n').filter(Boolean)
  2055. const tags = raw.slice(1).map(line => (JSON.parse(line) as { type: string }).type)
  2056. expect(tags).toEqual(['turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end'])
  2057. const message = JSON.parse(raw[3] as string) as { data: { stream: Array<{ type: string }> } }
  2058. expect(message.data.stream.map(record => record.type)).toEqual(['text-chunks', 'chunk'])
  2059. const loaded = await readAll(ctx.sessionPersistence, m.id)
  2060. expect(loaded.events).toEqual(log)
  2061. })
  2062. it.each([2, 3])('reads v%s rows and appends a v3 turn without changing predecessor bytes', async (version) => {
  2063. const m = meta('mixed', '/work')
  2064. const log = chunkRunLog()
  2065. const sourcePath = generationLogPath(root, '/work', m.id, version, 'none')
  2066. const currentPath = rawLogPath(root, '/work', m.id)
  2067. const source = Buffer.from([
  2068. JSON.stringify({
  2069. type: 'session', version, id: 'mixed', createdAt: 1000,
  2070. cwd: '/work', isSeeded: false, delegationDepth: 0,
  2071. }),
  2072. ...log.map(e => JSON.stringify(e)),
  2073. ].join('\n') + '\n')
  2074. await mkdir(dirname(sourcePath), { recursive: true })
  2075. await writeFile(sourcePath, source)
  2076. const expected = version === 2 ? withMigratedEmptyHead(log) : log
  2077. const restored = await readAll(ctx.sessionPersistence, m.id)
  2078. expect(restored.meta.version).toBe(3)
  2079. expect(restored.events).toEqual(expected)
  2080. expect(await readFile(sourcePath)).toEqual(source)
  2081. if (version === 2) await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
  2082. const secondTurn: SessionEvent[] = JSON.parse(JSON.stringify(log)) as SessionEvent[]
  2083. for (const [k, e] of secondTurn.entries()) {
  2084. ;(e as { seq: number }).seq = expected.length + k
  2085. ;(e.data as { turn: number }).turn = 2
  2086. }
  2087. await appendBatch(ctx.sessionPersistence, m.id, secondTurn)
  2088. const loaded = await readAll(ctx.sessionPersistence, m.id)
  2089. expect(loaded.events).toEqual([...expected, ...secondTurn])
  2090. expect(currentPath).toBe(join(dirname(sourcePath), 'session.v3.jsonl'))
  2091. const successor = (await readFile(currentPath, 'utf8')).trimEnd().split('\n')
  2092. expect(JSON.parse(successor[0] as string)).toMatchObject({ version: 3 })
  2093. expect(successor.slice(1).map(row => JSON.parse(row) as unknown)).toEqual([...expected, ...secondTurn])
  2094. if (version === 2) expect(await readFile(sourcePath)).toEqual(source)
  2095. // Compact tags stay nested; physical rows contain only current event tags.
  2096. const tags = (await readFile(rawLogPath(root, '/work', m.id), 'utf8')).split('\n').filter(Boolean)
  2097. .map(line => (JSON.parse(line) as { type: string }).type)
  2098. expect(tags.filter(t => t === 'text-chunks')).toHaveLength(0)
  2099. expect(tags.filter(t => t === 'assistant/chunk')).toHaveLength(0)
  2100. expect(tags.filter(t => t === 'assistant/message')).toHaveLength(2)
  2101. })
  2102. it('scanLog rejects a removed top-level packed row before a committed boundary', () => {
  2103. const logText = [
  2104. JSON.stringify({
  2105. type: 'session', version: SESSION_FORMAT_VERSION, id: 'rows', createdAt: 1,
  2106. isSeeded: false, delegationDepth: 0,
  2107. }),
  2108. JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }),
  2109. JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
  2110. JSON.stringify({ type: 'turn/end', seq: SessionSeq(4), time: 5, data: { turn: 1, reason: { kind: 'completed' } } }),
  2111. ].join('\n') + '\n'
  2112. expect(() => scanLog(Buffer.from(logText))).toThrow(/lacks .*seq/)
  2113. })
  2114. it('scanLog treats a malformed removed packed row as a committed seq hole', () => {
  2115. const logText = [
  2116. JSON.stringify({
  2117. type: 'session', version: SESSION_FORMAT_VERSION, id: 'bad-row', createdAt: 1,
  2118. isSeeded: false, delegationDepth: 0,
  2119. }),
  2120. // dt arity mismatch — row validation throws, so the line is a committed hole.
  2121. JSON.stringify({ type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] } }),
  2122. JSON.stringify({ type: 'turn/end', seq: SessionSeq(2), time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
  2123. ].join('\n') + '\n'
  2124. expect(() => scanLog(Buffer.from(logText))).toThrow(/lacks .*seq/)
  2125. })
  2126. it('scanLog: a packed row with a mid-run seq gap after the last turn/end drops the whole row', () => {
  2127. const logText = [
  2128. JSON.stringify({
  2129. type: 'session', version: SESSION_FORMAT_VERSION, id: 'row-gap', createdAt: 1,
  2130. isSeeded: false, delegationDepth: 0,
  2131. }),
  2132. JSON.stringify({ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }),
  2133. // seq0 skips 1 — the run's first member is already a gap; no turn/end follows.
  2134. JSON.stringify({ type: 'text-chunks', seq0: 2, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
  2135. ].join('\n') + '\n'
  2136. const scanned = scanLog(Buffer.from(logText))
  2137. expect(scanned.events.map(e => e.seq)).toEqual([0])
  2138. // committedBytes stays on the line boundary BEFORE the dropped row.
  2139. const headerAndTurn = logText.split('\n').slice(0, 2).join('\n') + '\n'
  2140. expect(scanned.committedBytes).toBe(Buffer.byteLength(headerAndTurn, 'utf8'))
  2141. })
  2142. it('eventLines keeps one event per line with nested compact stream records', () => {
  2143. const log = chunkRunLog()
  2144. const text = eventLines(log)
  2145. const lines = text.split('\n')
  2146. expect(lines).toHaveLength(log.length)
  2147. for (const line of lines) {
  2148. expect((JSON.parse(line) as { type: string }).type).not.toMatch(/-chunks$/)
  2149. }
  2150. const header = JSON.stringify(toHeaderLine(meta('packed', '/work'))) + '\n'
  2151. expect(scanLog(Buffer.from(header + text + '\n')).events).toEqual(log)
  2152. })
  2153. })
  2154. describe('JsonlSessionPersistence: edge cases', () => {
  2155. let ctx: Context
  2156. beforeEach(async () => {
  2157. root = await freshRoot()
  2158. ctx = new Context()
  2159. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  2160. })
  2161. afterEach(async () => { await ctx.fiber.dispose() })
  2162. it('create snapshots its meta: mutating the caller object after the call is ignored', async () => {
  2163. const m = meta('create-snap', '/orig')
  2164. const p = ctx.sessionPersistence.create(m)
  2165. // Mutate the caller's meta object immediately after calling create.
  2166. mutableHeader(m).cwd = '/mutated'
  2167. const handle = await p
  2168. await handle.append(oneTurnLog())
  2169. await handle.close()
  2170. // The log materialized under the ORIGINAL cwd, not the mutated one.
  2171. expect((await stat(rawLogPath(root, '/orig', SessionId('create-snap')))).isFile()).toBe(true)
  2172. await expect(stat(rawLogPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow()
  2173. })
  2174. it('create rejects non-JSON metadata and a fractional creation timestamp without reserving the id', async () => {
  2175. await expect(ctx.sessionPersistence.create({ ...meta('bad-meta'), extra: 1n } as unknown as SessionHeader))
  2176. .rejects.toThrow('session metadata must be losslessly JSON-serializable')
  2177. await expect(ctx.sessionPersistence.create({ ...meta('fractional-created-at'), createdAt: 1.5 }))
  2178. .rejects.toThrow('session metadata createdAt must be a non-negative safe integer')
  2179. // The rejected create reserved nothing: the id is free.
  2180. const valid = meta('fractional-created-at')
  2181. await writeLog(ctx.sessionPersistence, valid, oneTurnLog())
  2182. expect((await readAll(ctx.sessionPersistence, valid.id)).meta.createdAt).toBe(valid.createdAt)
  2183. })
  2184. it('list discovers sessions across multiple project directories', async () => {
  2185. await writeLog(ctx.sessionPersistence, meta('p1', '/projA'), oneTurnLog())
  2186. await writeLog(ctx.sessionPersistence, meta('p2', '/projB'), oneTurnLog())
  2187. await writeLog(ctx.sessionPersistence, meta('p3'), oneTurnLog()) // no cwd → _no-cwd project directory
  2188. const ids = (await ctx.sessionPersistence.list()).map(s => s.header.id).sort()
  2189. expect(ids).toEqual(['p1', 'p2', 'p3'])
  2190. })
  2191. it('groups sessions whose cwd paths normalize to the same project directory', async () => {
  2192. const first = meta('normalized-first', '/a/b-c')
  2193. const second = meta('normalized-second', '/a-b/c')
  2194. await writeLog(ctx.sessionPersistence, first, oneTurnLog())
  2195. await writeLog(ctx.sessionPersistence, second, oneTurnLog())
  2196. expect(projectDir(root, first.cwd)).toBe(projectDir(root, second.cwd))
  2197. expect(await readdir(projectDir(root, first.cwd))).toEqual(expect.arrayContaining([
  2198. encodeSegment(first.id),
  2199. encodeSegment(second.id),
  2200. ]))
  2201. expect((await ctx.sessionPersistence.list()).map(s => s.header.id).sort())
  2202. .toEqual([first.id, second.id].sort())
  2203. })
  2204. it('list on an empty root returns nothing', async () => {
  2205. expect(await ctx.sessionPersistence.list()).toEqual([])
  2206. })
  2207. it('keeps the transcript in an extensible session-owned directory', async () => {
  2208. const m = meta('owned-directory', '/project')
  2209. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  2210. const dir = sessionDir(root, m.cwd, m.id)
  2211. await writeFile(join(dir, 'metadata.json'), '{}\n')
  2212. await writeFile(join(projectDir(root, m.cwd), 'README'), 'project metadata\n')
  2213. await mkdir(join(projectDir(root, m.cwd), 'reserved-session'), { recursive: true })
  2214. expect(await readdir(dir)).toEqual(expect.arrayContaining([
  2215. 'metadata.json', generationLogFilename(SESSION_FORMAT_VERSION, 'none'),
  2216. ]))
  2217. expect((await ctx.sessionPersistence.list()).map(s => s.header.id)).toContain(m.id)
  2218. expect((await readAll(ctx.sessionPersistence, m.id)).events).toEqual(oneTurnLog())
  2219. })
  2220. it('rejects the obsolete flat-file layout instead of ignoring stored sessions', async () => {
  2221. const m = meta('legacy-flat', '/legacy')
  2222. const project = projectDir(root, m.cwd)
  2223. const path = join(project, `${encodeSegment(m.id)}.jsonl`)
  2224. await mkdir(project, { recursive: true })
  2225. await writeFile(path, [
  2226. JSON.stringify(toHeaderLine(m)),
  2227. ...oneTurnLog().map(event => JSON.stringify(event)),
  2228. '',
  2229. ].join('\n'))
  2230. await expect(ctx.sessionPersistence.open(m.id, 'read')).rejects.toThrow(/unsupported flat-file layout/)
  2231. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/unsupported flat-file layout/)
  2232. })
  2233. it('rejects a compressed obsolete flat-file artifact during targeted lookup', async () => {
  2234. const m = meta('legacy-compressed-flat', '/legacy')
  2235. const project = projectDir(root, m.cwd)
  2236. expect(await ctx.sessionPersistence.list()).toEqual([])
  2237. await mkdir(project, { recursive: true })
  2238. await writeFile(join(project, `${encodeSegment(m.id)}.jsonl.zstd`), 'legacy')
  2239. await expect(ctx.sessionPersistence.open(m.id, 'read')).rejects.toThrow(/unsupported flat-file layout/)
  2240. })
  2241. it('list skips empty and non-header session logs (metadata-only read)', async () => {
  2242. // A real session…
  2243. await writeLog(ctx.sessionPersistence, meta('real', '/p'), oneTurnLog())
  2244. // …alongside junk session directories whose fixed transcript is empty or
  2245. // lacks a header. Both remain unlisted.
  2246. for (const [id, content] of [
  2247. ['empty', ''],
  2248. ['notheader', '{"type":"turn/start"}\n'],
  2249. ['badjson', 'not json at all\n'],
  2250. ] as const) {
  2251. const path = rawLogPath(root, undefined, SessionId(id))
  2252. await mkdir(sessionDir(root, undefined, SessionId(id)), { recursive: true })
  2253. await writeFile(path, content)
  2254. }
  2255. const ids = (await ctx.sessionPersistence.list()).map(s => s.header.id).sort()
  2256. expect(ids).toEqual(['real'])
  2257. })
  2258. it('list reads a header line longer than the 8KB read chunk', async () => {
  2259. // A tolerated extra field makes this valid header exceed the 8192-byte read buffer, proving
  2260. // `readFirstLine` accumulates chunks before `list()` parses it.
  2261. const id = SessionId('big')
  2262. await mkdir(sessionDir(root, undefined, id), { recursive: true })
  2263. const bigHeader = JSON.stringify({
  2264. type: 'session',
  2265. version: SESSION_FORMAT_VERSION,
  2266. id: 'big',
  2267. createdAt: 1,
  2268. isSeeded: false,
  2269. delegationDepth: 0,
  2270. agentPreset: 'x'.repeat(9000),
  2271. })
  2272. await writeFile(rawLogPath(root, undefined, id), bigHeader + '\n')
  2273. const ids = (await ctx.sessionPersistence.list()).map(s => s.header.id)
  2274. expect(ids).toContain('big')
  2275. })
  2276. it.each(['sandboxMode', 'approvalPolicy'] as const)('rejects the retired %s header field', (field) => {
  2277. const line = { ...toHeaderLine(meta('retired-policy-header')), [field]: 'read-only' }
  2278. expect(() => scanLog(Buffer.from(`${JSON.stringify(line)}\n`)))
  2279. .toThrow(/retired policy baseline fields/)
  2280. })
  2281. it('list rejects a header whose cwd does not identify its physical log', async () => {
  2282. const m = meta('misplaced', '/stored')
  2283. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  2284. await rewriteHeader(rawLogPath(root, m.cwd, m.id), (header) => { header.cwd = '/elsewhere' })
  2285. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd identify/)
  2286. })
  2287. it('accepts an alternate project path only when it identifies the same physical log', async () => {
  2288. const m = meta('physical-alias', '/stored')
  2289. await writeLog(ctx.sessionPersistence, m, oneTurnLog())
  2290. const path = rawLogPath(root, m.cwd, m.id)
  2291. const aliasCwd = '/alias'
  2292. await symlink(
  2293. projectDir(root, m.cwd),
  2294. projectDir(root, aliasCwd),
  2295. process.platform === 'win32' ? 'junction' : 'dir',
  2296. )
  2297. await rewriteHeader(path, (header) => { header.cwd = aliasCwd })
  2298. expect((await readAll(ctx.sessionPersistence, m.id)).meta.cwd).toBe(aliasCwd)
  2299. expect((await ctx.sessionPersistence.list()).map(s => s.header.id)).toContain(m.id)
  2300. })
  2301. it('list rejects a session header whose id cannot name a storage path', async () => {
  2302. const dir = join(projectDir(root, undefined), 'invalid-id')
  2303. await mkdir(dir, { recursive: true })
  2304. await writeFile(join(dir, generationLogFilename(SESSION_FORMAT_VERSION, 'none')), JSON.stringify({
  2305. type: 'session', version: SESSION_FORMAT_VERSION, id: '', createdAt: 1,
  2306. isSeeded: false, delegationDepth: 0,
  2307. }) + '\n')
  2308. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header id cannot name a storage path/)
  2309. })
  2310. it('open and list reject one id materialized in multiple project directories', async () => {
  2311. const id = SessionId('duplicate')
  2312. for (const cwd of ['/a', '/b']) {
  2313. const m = meta(id, cwd)
  2314. await mkdir(sessionDir(root, cwd, id), { recursive: true })
  2315. const content = [JSON.stringify(toHeaderLine(m)), ...oneTurnLog().map(event => JSON.stringify(event))].join('\n') + '\n'
  2316. await writeFile(rawLogPath(root, cwd, id), content)
  2317. }
  2318. await expect(ctx.sessionPersistence.open(id, 'read')).rejects.toThrow(/appears in multiple project directories/)
  2319. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/appears in multiple project directories/)
  2320. })
  2321. it('create rejects an id already on disk under a different project directory', async () => {
  2322. // Persist the id under cwd A.
  2323. const a = meta('dup-id', '/projA')
  2324. await writeLog(ctx.sessionPersistence, a, oneTurnLog())
  2325. // A fresh backend creating the SAME id under cwd B must still refuse:
  2326. // opens identify by id across all projects, so a second log would make
  2327. // resume nondeterministic. create scans every project, not just meta.cwd's.
  2328. const ctx2 = new Context()
  2329. await ctx2.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  2330. await expect(ctx2.sessionPersistence.create(meta('dup-id', '/projB')))
  2331. .rejects.toThrow(/already exists/)
  2332. await ctx2.fiber.dispose()
  2333. })
  2334. it('list returns nothing when the root directory does not exist', async () => {
  2335. const ctx2 = new Context()
  2336. await ctx2.plugin(JsonlSessionPersistence, {
  2337. root: join(root, 'does-not-exist-yet'),
  2338. compression: 'none',
  2339. })
  2340. expect(await ctx2.sessionPersistence.list()).toEqual([])
  2341. await ctx2.fiber.dispose()
  2342. })
  2343. it('plugin load rejects an existing root that is not a directory', async () => {
  2344. const filePath = join(root, 'not-a-dir')
  2345. await writeFile(filePath, 'x')
  2346. const ctx2 = new Context()
  2347. await expect(ctx2.plugin(JsonlSessionPersistence, { root: filePath, compression: 'none' })).rejects.toThrow(/ENOTDIR/)
  2348. await ctx2.fiber.dispose()
  2349. })
  2350. it('list surfaces a root that becomes unusable after plugin load', async () => {
  2351. await rm(root, { recursive: true })
  2352. await writeFile(root, 'not a directory')
  2353. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/)
  2354. })
  2355. it('per-id lookup surfaces non-ENOENT storage errors', async () => {
  2356. const blocker = join(root, 'not-a-directory')
  2357. await writeFile(blocker, 'x')
  2358. const backend = ctx.sessionPersistence as unknown as { exists(path: string): Promise<boolean> }
  2359. await expect(backend.exists(join(blocker, 'child.jsonl'))).rejects.toThrow(/ENOTDIR/)
  2360. })
  2361. it('a project-directory storage fault surfaces at the first materializing write', async () => {
  2362. const cwd = '/x'
  2363. await writeFile(projectDir(root, cwd), 'x') // project path is now a file
  2364. // Create touches no storage; the lock acquisition ahead of the first
  2365. // materializing append walks into the fault.
  2366. const handle = await ctx.sessionPersistence.create(meta('exists-fault', cwd))
  2367. await expectCode(handle.append([{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }]), ['EEXIST', 'ENOTDIR'])
  2368. await handle.close()
  2369. })
  2370. it('backend teardown closes handles left open and fails later operations loudly', async () => {
  2371. const m = meta('teardown')
  2372. const handle = await ctx.sessionPersistence.create(m)
  2373. await handle.append(oneTurnLog())
  2374. await ctx.fiber.dispose()
  2375. await expect(handle.append([
  2376. { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } },
  2377. ])).rejects.toThrow(/on a closed handle/)
  2378. // Reload the backend so the shared afterEach dispose stays valid.
  2379. ctx = new Context()
  2380. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  2381. })
  2382. it('accepts well-formed JSON values (null, booleans, nested arrays/objects)', async () => {
  2383. const m = meta('json-ok')
  2384. const events = [{ type: 'user/message', seq: SessionSeq(0), time: 1, data: {
  2385. id: MessageId('json-ok'),
  2386. role: 'user',
  2387. content: [{ type: 'text', text: 'x' }],
  2388. source: { kind: 'user' },
  2389. extra: { a: null, b: true, c: [1, 2, { d: 'nested' }] },
  2390. }, surfaceOp: 'append' }] as unknown as SessionEvent[]
  2391. await writeLog(ctx.sessionPersistence, m, events)
  2392. expect((await readAll(ctx.sessionPersistence, m.id)).events).toEqual(events)
  2393. })
  2394. })