generation.ts 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024
  1. /**
  2. * Durable whole-generation publication for JSONL Session artifacts.
  3. *
  4. * Format packages transform parsed JSON values. This module owns the physical
  5. * encoding, exact source identity, immutable generation files, and exclusive
  6. * current-generation publication for both configured JSONL suffixes.
  7. * @module @deepseek-ai/dsh-session-persistence-jsonl/generation
  8. */
  9. import { createHash, randomBytes } from 'node:crypto'
  10. import {
  11. link as fsLink,
  12. lstat as fsLstat,
  13. open as fsOpen,
  14. readFile as fsReadFile,
  15. readdir as fsReaddir,
  16. rm as fsRm,
  17. stat as fsStat,
  18. type FileHandle,
  19. } from 'node:fs/promises'
  20. import { basename, dirname, join } from 'node:path'
  21. import { performance } from 'node:perf_hooks'
  22. import { pipeline, Readable } from 'node:stream'
  23. import { scheduler } from 'node:timers/promises'
  24. import { isDeepStrictEqual } from 'node:util'
  25. import { constants, createZstdCompress } from 'node:zlib'
  26. import { Session } from '@deepseek-ai/dsh-session'
  27. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  28. import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm'
  29. import type {
  30. SessionFormatArtifact,
  31. SessionFormatJsonValue,
  32. SessionFormatRestore,
  33. } from '@deepseek-ai/dsh-session-format'
  34. import { validateStoredEvents } from '@deepseek-ai/dsh-session-persistence'
  35. import type { JsonlCompression } from './format.ts'
  36. import { generationLogFilename, logSuffix, SessionLogScanner } from './format.ts'
  37. import { publishNewFileWin32 } from './win32.ts'
  38. import {
  39. compressZstdFrame,
  40. createZstdFrameDecoder,
  41. decompressZstdPrefix,
  42. scanZstdFrames,
  43. } from './zstd.ts'
  44. /** Internal scheduling bounds: preserve old decode cadence and cap each synchronous encode slice. */
  45. const MIGRATION_DECODE_YIELD_INTERVAL_MS = 500
  46. const MIGRATION_WORK_CHUNK_BYTES = 1024 * 1024
  47. const MIGRATION_WRITE_CHUNK_BYTES = 4 * 1024 * 1024
  48. const ZSTD_CHECKSUM_OPTIONS = {
  49. chunkSize: MIGRATION_WORK_CHUNK_BYTES,
  50. params: { [constants.ZSTD_c_checksumFlag]: 1 },
  51. }
  52. /** Pure adapter between backend-owned JSONL framing and the format catalog. */
  53. export interface JsonlGenerationFormatAdapter {
  54. readonly currentVersion: number
  55. /** Create the single-pass codec and migration state for a historical header. */
  56. createRestore(header: Record<string, unknown>): SessionFormatRestore
  57. /** Encode one current header record without materializing body rows. */
  58. encodeHeader(header: SessionFormatArtifact['header'], inheritedEventCount: number): SessionFormatJsonValue
  59. /** Encode one current event record. */
  60. encodeEvent(event: SessionFormatArtifact['events'][number]): SessionFormatJsonValue
  61. /** Classify a supported-version artifact that policy refuses to migrate. */
  62. isUnsupportedMigrationError?(error: unknown): error is Error
  63. }
  64. /** Inputs for preparing one historical generation and publishing its current successor later. */
  65. export interface PrepareJsonlMigrationOptions {
  66. /** Immutable generation selected by the backend resolver. */
  67. readonly sourcePath: string
  68. /** Version selected from the source filename and independently checked against its header. */
  69. readonly sourceVersion: number
  70. /** Canonical filename for `format.currentVersion` in the same Session directory. */
  71. readonly currentPath: string
  72. readonly compression: JsonlCompression
  73. readonly format: JsonlGenerationFormatAdapter
  74. /** Validate one selected historical header's identity before any migration write. */
  75. readonly validateHistoricalHeader?: (
  76. header: Readonly<Record<string, unknown>>,
  77. ) => void | Promise<void>
  78. /** Validate the staged file in an isolated worker before publication. */
  79. readonly verifyCurrentFile: (
  80. path: string,
  81. compression: JsonlCompression,
  82. expectedId: string,
  83. expectedEventCount: number,
  84. expectedPrefix?: JsonlExpectedPrefix,
  85. signal?: AbortSignal,
  86. ) => Promise<JsonlVerifiedGeneration>
  87. readonly signal?: AbortSignal
  88. }
  89. /** Small physical identity returned by an isolated generation verifier. */
  90. export interface JsonlVerifiedGeneration {
  91. readonly identity: JsonlPhysicalIdentity
  92. readonly bytes: number
  93. readonly digest: string
  94. }
  95. /** Physical byte prefix already proven to be a valid complete generation. */
  96. export interface JsonlExpectedPrefix {
  97. readonly bytes: number
  98. readonly digest: string
  99. }
  100. /** A historical source changed after its single decode and migration pass. */
  101. export class JsonlGenerationSourceChangedError extends Error {
  102. override readonly name = 'JsonlGenerationSourceChangedError'
  103. /** @param path - historical generation whose revision changed. */
  104. constructor(readonly path: string) {
  105. super(`historical session generation changed during migration: "${path}"`)
  106. }
  107. }
  108. /** Current logical state prepared independently from durable publication. */
  109. export interface PreparedJsonlMigration {
  110. readonly sourceIdentity: JsonlPhysicalIdentity
  111. readonly artifact: SessionFormatArtifact
  112. /** Encode, verify, and exclusively publish once; every call shares the same success or failure. */
  113. publish(): Promise<JsonlPhysicalIdentity>
  114. }
  115. /** A historical artifact is intact, but the format edge refuses its contents. */
  116. export class JsonlGenerationUnsupportedMigrationError extends Error {
  117. override readonly name = 'JsonlGenerationUnsupportedMigrationError'
  118. /**
  119. * @param fromVersion - unchanged source generation version.
  120. * @param reason - format-edge refusal.
  121. */
  122. constructor(
  123. readonly fromVersion: number,
  124. readonly reason: Error,
  125. ) {
  126. super(reason.message, { cause: reason })
  127. }
  128. }
  129. /** A current-generation filename already names different or invalid bytes. */
  130. export class JsonlGenerationTargetConflictError extends Error {
  131. override readonly name = 'JsonlGenerationTargetConflictError'
  132. /**
  133. * @param path - immutable target that prevented exclusive publication.
  134. * @param reason - why the existing target cannot be accepted.
  135. */
  136. constructor(
  137. readonly path: string,
  138. readonly reason: Error,
  139. ) {
  140. super(`current session generation already exists at "${path}": ${reason.message}`, { cause: reason })
  141. }
  142. }
  143. /** Stat identity captured together with exact generation bytes. */
  144. export interface JsonlPhysicalIdentity {
  145. readonly dev: bigint
  146. readonly ino: bigint
  147. readonly size: bigint
  148. readonly mtimeNs: bigint
  149. readonly ctimeNs: bigint
  150. }
  151. /** Exact bytes of one stable file revision together with the stat identity that proved it stable. */
  152. export interface StablePhysicalFile {
  153. readonly bytes: Buffer
  154. readonly identity: JsonlPhysicalIdentity
  155. }
  156. interface GenerationFileSystem {
  157. open(path: string, flags: string, mode?: number): Promise<FileHandle>
  158. readFile(path: string, signal?: AbortSignal): Promise<Buffer>
  159. readdir(path: string): Promise<string[]>
  160. stat(path: string): Promise<JsonlPhysicalIdentity>
  161. lstat(path: string): Promise<{ isFile(): boolean; isSymbolicLink(): boolean }>
  162. link(existingPath: string, newPath: string): Promise<void>
  163. rm(path: string): Promise<void>
  164. }
  165. type GenerationBarrierPhase =
  166. | 'before-source-check'
  167. | 'after-publication'
  168. interface JsonlGenerationInternals {
  169. readonly fs: GenerationFileSystem
  170. readonly randomToken: () => string
  171. readonly platform: NodeJS.Platform
  172. readonly publishNewWin32: typeof publishNewFileWin32
  173. readonly barrier: (phase: GenerationBarrierPhase, attempt: number) => void | Promise<void>
  174. }
  175. /** Dependency overrides for an isolated generation runtime. */
  176. export type JsonlGenerationRuntimeOverrides = Partial<Omit<JsonlGenerationInternals, 'fs'>> & {
  177. readonly fs?: Partial<GenerationFileSystem>
  178. }
  179. /** Bound generation operations used by production defaults and deterministic tests. */
  180. export interface JsonlGenerationRuntime {
  181. readStable(path: string, signal?: AbortSignal): Promise<StablePhysicalFile>
  182. prepare(options: PrepareJsonlMigrationOptions): Promise<PreparedJsonlMigration>
  183. verify(
  184. path: string,
  185. compression: JsonlCompression,
  186. expectedId: string,
  187. expectedEventCount: number,
  188. expectedPrefix?: JsonlExpectedPrefix,
  189. ): Promise<JsonlVerifiedGeneration>
  190. }
  191. const defaultFileSystem: GenerationFileSystem = {
  192. open: (path, flags, mode) => fsOpen(path, flags, mode),
  193. readFile: (path, signal) => fsReadFile(path, signal === undefined ? undefined : { signal }),
  194. readdir: path => fsReaddir(path),
  195. stat: path => fsStat(path, { bigint: true }),
  196. lstat: path => fsLstat(path),
  197. link: fsLink,
  198. rm: path => fsRm(path, { force: true }),
  199. }
  200. const defaultInternals: JsonlGenerationInternals = {
  201. fs: defaultFileSystem,
  202. randomToken: () => randomBytes(8).toString('hex'),
  203. platform: process.platform,
  204. publishNewWin32: publishNewFileWin32,
  205. barrier: () => {},
  206. }
  207. function isEEXIST(error: unknown): boolean {
  208. return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
  209. }
  210. /** Whether a filesystem-owned failure should retain its original errno and path. */
  211. function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
  212. return typeof (error as NodeJS.ErrnoException | null)?.code === 'string'
  213. }
  214. function identity(value: JsonlPhysicalIdentity): string {
  215. return [value.dev, value.ino, value.size, value.mtimeNs, value.ctimeNs].join(':')
  216. }
  217. /**
  218. * Read one stable revision of a JSONL file with a single retry. If an append
  219. * overlaps both reads, return the second read's committed pre-read prefix
  220. * instead of starving behind a continuous writer.
  221. * @param path - the generation file to read.
  222. * @param signal - optional cancellation for the stat/read work.
  223. * @returns the stable bytes (or the committed prefix) and their stat identity.
  224. */
  225. export async function readStableJsonlFile(
  226. path: string,
  227. signal?: AbortSignal,
  228. ): Promise<StablePhysicalFile> {
  229. return defaultGenerationRuntime.readStable(path, signal)
  230. }
  231. async function readStableSnapshot(
  232. path: string,
  233. signal: AbortSignal | undefined,
  234. fs: GenerationFileSystem,
  235. ): Promise<StablePhysicalFile> {
  236. signal?.throwIfAborted()
  237. let before = await fs.stat(path)
  238. for (let attempt = 0; ; attempt += 1) {
  239. const bytes = await fs.readFile(path, signal)
  240. signal?.throwIfAborted()
  241. const after = await fs.stat(path)
  242. if (identity(before) === identity(after)) {
  243. signal?.throwIfAborted()
  244. return { bytes, identity: after }
  245. }
  246. if (attempt === 1) {
  247. return { bytes: bytes.subarray(0, Number(before.size)), identity: before }
  248. }
  249. before = after
  250. }
  251. }
  252. /** Parse the version discriminator without validating any version-specific field. */
  253. function storedVersion(header: unknown): number {
  254. if (typeof header !== 'object' || header === null || Array.isArray(header)) {
  255. throw new Error('corrupt session log: first line is not a JSON object')
  256. }
  257. const version = (header as { version?: unknown }).version
  258. if (!Number.isSafeInteger(version) || (version as number) < 0 || Object.is(version, -0)) {
  259. throw new Error('corrupt session log: header version is not a non-negative safe integer')
  260. }
  261. return version as number
  262. }
  263. function parseJson(text: string, subject: string): unknown {
  264. try {
  265. return JSON.parse(text)
  266. } catch (error) {
  267. throw new Error(`corrupt session log: ${subject} is not valid JSON`, { cause: error })
  268. }
  269. }
  270. /** Incremental JSONL parser that retains only one cross-frame record fragment. */
  271. class MigratingJsonlRows {
  272. private fragments: Buffer[] = []
  273. private fragmentBytes = 0
  274. private rowIndex = 0
  275. private issue: Error | undefined
  276. constructor(private readonly restore: SessionFormatRestore) {}
  277. /** Consume plaintext bytes following the independently decoded header. */
  278. write(chunk: Buffer): void {
  279. /* jscpd:ignore-start -- migration parsing and readable-log scanning own different recovery and byte-accounting state. */
  280. let lineStart = 0
  281. for (
  282. let newline = chunk.indexOf(0x0A);
  283. newline !== -1;
  284. newline = chunk.indexOf(0x0A, lineStart)
  285. ) {
  286. const fragment = chunk.subarray(lineStart, newline)
  287. let line = fragment
  288. if (this.fragments.length > 0) {
  289. if (fragment.length > 0) this.fragments.push(fragment)
  290. line = Buffer.concat(this.fragments, this.fragmentBytes + fragment.length)
  291. this.fragments = []
  292. this.fragmentBytes = 0
  293. }
  294. this.consume(line)
  295. lineStart = newline + 1
  296. }
  297. if (lineStart < chunk.length) {
  298. const fragment = Buffer.from(chunk.subarray(lineStart))
  299. this.fragments.push(fragment)
  300. this.fragmentBytes += fragment.length
  301. }
  302. /* jscpd:ignore-end */
  303. }
  304. /** Refuse a record fragment left by structurally complete Zstandard frames. */
  305. assertCompleteFramesEndOnRecord(): void {
  306. if (this.fragments.length > 0) {
  307. throw new Error('corrupt Zstandard session log: complete frame contains a torn JSONL record')
  308. }
  309. }
  310. finish(): SessionFormatArtifact {
  311. return this.restore.finish()
  312. }
  313. private consume(line: Buffer): void {
  314. const index = this.rowIndex
  315. this.rowIndex += 1
  316. let row: unknown
  317. try {
  318. row = parseJson(line.toString('utf8'), `row ${index + 1}`)
  319. } catch (error: unknown) {
  320. this.issue ??= asError(error)
  321. return
  322. }
  323. if (this.issue !== undefined) {
  324. if (typeof row === 'object' && row !== null
  325. && (row as { type?: unknown }).type === 'turn/end') throw this.issue
  326. return
  327. }
  328. this.restore.decodeRow(row)
  329. }
  330. }
  331. interface StartedMigrationStream {
  332. readonly parser: MigratingJsonlRows
  333. }
  334. async function startMigrationStream(
  335. headerRecord: Buffer,
  336. sourceVersion: number,
  337. format: JsonlGenerationFormatAdapter,
  338. validateHistoricalHeader?: PrepareJsonlMigrationOptions['validateHistoricalHeader'],
  339. ): Promise<StartedMigrationStream> {
  340. const value = parseJson(headerRecord.subarray(0, -1).toString('utf8'), 'header line')
  341. const version = storedVersion(value)
  342. if (version !== sourceVersion) {
  343. throw new Error(`resolved JSONL source filename identifies v${sourceVersion}, but its header identifies v${version}`)
  344. }
  345. const header = value as Record<string, unknown>
  346. const validation = validateHistoricalHeader?.(header)
  347. if (validation !== undefined) await validation
  348. const stream = format.createRestore(header)
  349. return { parser: new MigratingJsonlRows(stream) }
  350. }
  351. async function consumeMigrationBytes(
  352. rows: MigratingJsonlRows,
  353. chunks: Iterable<Buffer>,
  354. signal?: AbortSignal,
  355. ): Promise<void> {
  356. signal?.throwIfAborted()
  357. let yieldDeadline = performance.now() + MIGRATION_DECODE_YIELD_INTERVAL_MS
  358. for (const bytes of chunks) {
  359. for (let offset = 0; offset < bytes.length; offset += MIGRATION_WORK_CHUNK_BYTES) {
  360. rows.write(bytes.subarray(offset, offset + MIGRATION_WORK_CHUNK_BYTES))
  361. if (performance.now() < yieldDeadline) continue
  362. await scheduler.yield()
  363. signal?.throwIfAborted()
  364. yieldDeadline = performance.now() + MIGRATION_DECODE_YIELD_INTERVAL_MS
  365. }
  366. }
  367. }
  368. async function decodeStreamingMigration(
  369. bytes: Buffer,
  370. compression: JsonlCompression,
  371. sourceVersion: number,
  372. format: JsonlGenerationFormatAdapter,
  373. validateHistoricalHeader: PrepareJsonlMigrationOptions['validateHistoricalHeader'],
  374. signal?: AbortSignal,
  375. ): Promise<SessionFormatArtifact> {
  376. signal?.throwIfAborted()
  377. if (compression === 'none') {
  378. const headerEnd = bytes.indexOf(0x0A)
  379. if (headerEnd === -1) throw new Error('empty or header-less session log')
  380. const stream = await startMigrationStream(
  381. bytes.subarray(0, headerEnd + 1),
  382. sourceVersion,
  383. format,
  384. validateHistoricalHeader,
  385. )
  386. signal?.throwIfAborted()
  387. const bodyEnd = bytes.lastIndexOf(0x0A)
  388. if (bodyEnd > headerEnd) {
  389. await consumeMigrationBytes(
  390. stream.parser,
  391. [bytes.subarray(headerEnd + 1, bodyEnd + 1)],
  392. signal,
  393. )
  394. }
  395. return stream.parser.finish()
  396. }
  397. const { frames, tornStart } = scanZstdFrames(bytes)
  398. if (frames.length === 0) throw new Error('empty or header-less Zstandard session log')
  399. const decoder = createZstdFrameDecoder()
  400. try {
  401. const decoded = decoder.decode(bytes, frames)
  402. const first = decoded.next()
  403. /* v8 ignore next -- a non-empty structural frame list yields once or throws. */
  404. if (first.done) throw new Error('empty or header-less Zstandard session log')
  405. assertIndependentHeaderFrame(first.value)
  406. const stream = await startMigrationStream(
  407. first.value,
  408. sourceVersion,
  409. format,
  410. validateHistoricalHeader,
  411. )
  412. signal?.throwIfAborted()
  413. await consumeMigrationBytes(stream.parser, decoded, signal)
  414. stream.parser.assertCompleteFramesEndOnRecord()
  415. if (tornStart !== undefined) {
  416. let recovered: Buffer = Buffer.alloc(0)
  417. try {
  418. recovered = await decompressZstdPrefix(bytes.subarray(tornStart))
  419. } catch {
  420. /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent. */
  421. if (signal?.aborted) signal.throwIfAborted()
  422. }
  423. signal?.throwIfAborted()
  424. const newline = recovered.lastIndexOf(0x0A)
  425. if (newline !== -1) {
  426. await consumeMigrationBytes(
  427. stream.parser,
  428. [recovered.subarray(0, newline + 1)],
  429. signal,
  430. )
  431. }
  432. }
  433. return stream.parser.finish()
  434. } finally {
  435. decoder.close()
  436. }
  437. }
  438. /**
  439. * Read and validate one complete current generation for an isolated verifier.
  440. * @param path - staged or competing current-generation path.
  441. * @param compression - configured physical encoding.
  442. * @param expectedId - Session identity expected in the header.
  443. * @param expectedEventCount - exact logical event count expected after decoding.
  444. * @param expectedPrefix - verified migration prefix; an append tail may be present and is not validated.
  445. * @returns stable physical identity and digest for publication comparison.
  446. */
  447. export async function verifyJsonlCurrentGeneration(
  448. path: string,
  449. compression: JsonlCompression,
  450. expectedId: string,
  451. expectedEventCount: number,
  452. expectedPrefix?: JsonlExpectedPrefix,
  453. ): Promise<JsonlVerifiedGeneration> {
  454. return defaultGenerationRuntime.verify(path, compression, expectedId, expectedEventCount, expectedPrefix)
  455. }
  456. async function verifyCurrentGeneration(
  457. path: string,
  458. compression: JsonlCompression,
  459. expectedId: string,
  460. expectedEventCount: number,
  461. fs: GenerationFileSystem,
  462. expectedPrefix?: JsonlExpectedPrefix,
  463. ): Promise<JsonlVerifiedGeneration> {
  464. const before = await fs.stat(path)
  465. const bytes = await fs.readFile(path)
  466. const after = await fs.stat(path)
  467. if (expectedPrefix !== undefined) {
  468. if (bytes.length < expectedPrefix.bytes) {
  469. throw new Error('target bytes are shorter than the migrated generation')
  470. }
  471. const digest = createHash('sha256').update(bytes.subarray(0, expectedPrefix.bytes)).digest('hex')
  472. if (digest !== expectedPrefix.digest) {
  473. throw new Error('target bytes do not begin with the migrated generation')
  474. }
  475. return { identity: after, bytes: expectedPrefix.bytes, digest }
  476. }
  477. if (identity(before) !== identity(after)) {
  478. throw new Error('current session generation changed during verification')
  479. }
  480. const snapshot = { bytes, identity: after }
  481. const generation = decodeCurrentGeneration(snapshot.bytes, compression)
  482. validateStoredEvents(generation.meta, generation.events, { kind: 'jsonl', path })
  483. if (generation.meta.id !== expectedId) {
  484. throw new Error(`current session generation contains id "${generation.meta.id}", expected "${expectedId}"`)
  485. }
  486. if (generation.events.length !== expectedEventCount) {
  487. throw new Error(
  488. `current session generation contains ${generation.events.length} events, expected ${expectedEventCount}`,
  489. )
  490. }
  491. Session.fromRestore(
  492. generation.meta.id,
  493. generation.events,
  494. generation.meta,
  495. generation.inheritedEventCount,
  496. 'detached',
  497. )
  498. assertCurrentAssistantStreams(generation.events)
  499. return {
  500. identity: snapshot.identity,
  501. bytes: snapshot.bytes.length,
  502. digest: createHash('sha256').update(snapshot.bytes).digest('hex'),
  503. }
  504. }
  505. /** Fully replay embedded streams only inside isolated current-generation verification. */
  506. function assertCurrentAssistantStreams(events: readonly SessionEvent[]): void {
  507. for (const [index, event] of events.entries()) {
  508. if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') continue
  509. const assembler = new BlockAssembler()
  510. let timed: ReturnType<typeof expandAssistantStream>
  511. try {
  512. timed = expandAssistantStream(event.data.stream)
  513. for (const member of timed) assembler.push(member.chunk)
  514. } catch (error: unknown) {
  515. throw new Error(`seed ${event.type} at index ${index} has an invalid embedded stream`, { cause: error })
  516. }
  517. if (event.type === 'assistant/attempt' || timed.length === 0) continue
  518. const content = event.data.interrupted === true ? assembler.interruptedBlocks() : assembler.blocks()
  519. if (!isDeepStrictEqual(event.data.message.content, content)) {
  520. throw new Error(`seed assistant/message at index ${index} content disagrees with its embedded stream`)
  521. }
  522. if (!isDeepStrictEqual(event.data.usage, assembler.usage)) {
  523. throw new Error(`seed assistant/message at index ${index} usage disagrees with its embedded stream`)
  524. }
  525. if (!isDeepStrictEqual(event.data.message.source.replayState, assembler.replayState)) {
  526. throw new Error(`seed assistant/message at index ${index} replay state disagrees with its embedded stream`)
  527. }
  528. }
  529. }
  530. function decodeCurrentGeneration(
  531. bytes: Buffer,
  532. compression: JsonlCompression,
  533. ): ReturnType<SessionLogScanner['finish']> {
  534. if (compression === 'none') {
  535. const headerEnd = bytes.indexOf(0x0A)
  536. if (headerEnd === -1) throw new Error('empty or header-less session log')
  537. const scanner = new SessionLogScanner(bytes.subarray(0, headerEnd + 1), 'strict')
  538. scanner.write(bytes.subarray(headerEnd + 1))
  539. return finishCurrentGenerationScan(scanner)
  540. }
  541. const { frames, tornStart } = scanZstdFrames(bytes)
  542. if (frames.length === 0) throw new Error('empty or header-less Zstandard session log')
  543. if (tornStart !== undefined) throw new Error('current session generation has a torn physical tail')
  544. const decoder = createZstdFrameDecoder()
  545. try {
  546. const plaintext = decoder.decode(bytes, frames)
  547. const header = plaintext.next()
  548. /* v8 ignore next -- a non-empty structural frame list yields once or throws. */
  549. if (header.done) throw new Error('empty or header-less Zstandard session log')
  550. assertIndependentHeaderFrame(header.value)
  551. const scanner = new SessionLogScanner(header.value, 'strict')
  552. for (const chunk of plaintext) scanner.write(chunk)
  553. return finishCurrentGenerationScan(scanner)
  554. } finally {
  555. decoder.close()
  556. }
  557. }
  558. function finishCurrentGenerationScan(
  559. scanner: SessionLogScanner,
  560. ): ReturnType<SessionLogScanner['finish']> {
  561. const inputBytes = scanner.checkpoint().inputBytes
  562. const decoded = scanner.finish()
  563. if (decoded.committedBytes !== inputBytes) throw new Error('current session generation has a torn physical tail')
  564. return decoded
  565. }
  566. function stringifyJson(value: unknown, subject: string): string {
  567. let text: unknown
  568. try {
  569. text = JSON.stringify(value)
  570. } catch (error) {
  571. throw new Error(`${subject} is not lossless JSON`, { cause: error })
  572. }
  573. if (typeof text !== 'string') throw new Error(`${subject} is not lossless JSON`)
  574. return text
  575. }
  576. function assertIndependentHeaderFrame(plaintext: Buffer): void {
  577. if (plaintext.length === 0 || plaintext.indexOf(0x0A) !== plaintext.length - 1) {
  578. throw new Error('corrupt Zstandard session log: first frame is not exactly one header line')
  579. }
  580. }
  581. function assertGenerationPaths(
  582. sourcePath: string,
  583. sourceVersion: number,
  584. currentPath: string,
  585. currentVersion: number,
  586. compression: JsonlCompression,
  587. ): string {
  588. const expectedSource = generationLogFilename(sourceVersion, compression)
  589. const expectedCurrent = generationLogFilename(currentVersion, compression)
  590. if (basename(sourcePath) !== expectedSource) {
  591. throw new Error(`resolved JSONL source path must end with "${expectedSource}": ${sourcePath}`)
  592. }
  593. if (basename(currentPath) !== expectedCurrent) {
  594. throw new Error(`current JSONL generation path must end with "${expectedCurrent}": ${currentPath}`)
  595. }
  596. if (dirname(sourcePath) !== dirname(currentPath)) {
  597. throw new Error('source and current JSONL generations must share one Session directory')
  598. }
  599. return logSuffix(compression)
  600. }
  601. async function syncDirectory(path: string, internals: JsonlGenerationInternals): Promise<void> {
  602. /* v8 ignore next -- Windows namespace operations request write-through directly. */
  603. if (internals.platform === 'win32') return
  604. const handle = await internals.fs.open(path, 'r')
  605. try {
  606. await handle.sync()
  607. } finally {
  608. await handle.close()
  609. }
  610. }
  611. interface StreamedMigrationStage {
  612. readonly path: string
  613. readonly bytes: number
  614. readonly digest: string
  615. }
  616. /** Produce bounded JSONL chunks while yielding between main-thread encoding slices. */
  617. async function* encodeMigrationRows(
  618. artifact: SessionFormatArtifact,
  619. format: JsonlGenerationFormatAdapter,
  620. signal?: AbortSignal,
  621. ): AsyncGenerator<Buffer, void, void> {
  622. signal?.throwIfAborted()
  623. let lines: string[] = []
  624. let bytes = 0
  625. for (const value of artifact.events) {
  626. const line = `${stringifyJson(format.encodeEvent(value), `migrated Session event ${value.seq}`)}\n`
  627. const lineBytes = Buffer.byteLength(line)
  628. if (bytes > 0 && bytes + lineBytes > MIGRATION_WORK_CHUNK_BYTES) {
  629. yield Buffer.from(lines.join(''))
  630. await scheduler.yield()
  631. signal?.throwIfAborted()
  632. lines = []
  633. bytes = 0
  634. }
  635. lines.push(line)
  636. bytes += lineBytes
  637. }
  638. yield Buffer.from(lines.join(''))
  639. }
  640. async function writeMigrationChunks(
  641. chunks: AsyncIterable<Buffer>,
  642. write: (chunk: Buffer) => Promise<void>,
  643. ): Promise<void> {
  644. let pending: Buffer[] = []
  645. let bytes = 0
  646. for await (const chunk of chunks) {
  647. pending.push(chunk)
  648. bytes += chunk.length
  649. if (bytes < MIGRATION_WRITE_CHUNK_BYTES) continue
  650. await write(pending.length === 1 ? pending[0] as Buffer : Buffer.concat(pending, bytes))
  651. pending = []
  652. bytes = 0
  653. }
  654. if (bytes > 0) await write(pending.length === 1 ? pending[0] as Buffer : Buffer.concat(pending, bytes))
  655. }
  656. /** Encode directly into one synced stage without a whole-artifact row or byte buffer. */
  657. async function writeSyncedTemp(
  658. currentPath: string,
  659. suffix: string,
  660. compression: JsonlCompression,
  661. artifact: SessionFormatArtifact,
  662. format: JsonlGenerationFormatAdapter,
  663. signal: AbortSignal | undefined,
  664. internals: JsonlGenerationInternals,
  665. ): Promise<StreamedMigrationStage> {
  666. signal?.throwIfAborted()
  667. let path: string
  668. let handle: FileHandle
  669. for (;;) {
  670. path = join(dirname(currentPath), `session.migration.${internals.randomToken()}${suffix}.tmp`)
  671. try {
  672. handle = await internals.fs.open(path, 'wx', 0o600)
  673. break
  674. } catch (error) {
  675. if (isEEXIST(error)) continue
  676. throw error
  677. }
  678. }
  679. const hash = createHash('sha256')
  680. let bytes = 0
  681. const write = async (chunk: Buffer): Promise<void> => {
  682. await handle.writeFile(chunk)
  683. hash.update(chunk)
  684. bytes += chunk.length
  685. }
  686. let failure: unknown
  687. try {
  688. const headerValue = format.encodeHeader(artifact.header, artifact.inheritedEventCount)
  689. const header = Buffer.from(`${stringifyJson(headerValue, 'migrated session header')}\n`)
  690. await write(compression === 'zstd' ? await compressZstdFrame(header) : header)
  691. if (artifact.events.length > 0) {
  692. const rows = encodeMigrationRows(artifact, format, signal)
  693. if (compression === 'none') {
  694. await writeMigrationChunks(rows, write)
  695. } else {
  696. await new Promise<void>((resolve, reject) => {
  697. pipeline(
  698. Readable.from(rows, { objectMode: false, highWaterMark: MIGRATION_WORK_CHUNK_BYTES }),
  699. createZstdCompress(ZSTD_CHECKSUM_OPTIONS),
  700. async (source) => { await writeMigrationChunks(source as AsyncIterable<Buffer>, write) },
  701. (error: Error | null | undefined) => {
  702. if (error instanceof Error) reject(error)
  703. else resolve()
  704. },
  705. )
  706. })
  707. }
  708. }
  709. signal?.throwIfAborted()
  710. await handle.sync()
  711. } catch (error: unknown) {
  712. failure = error
  713. }
  714. try {
  715. await handle.close()
  716. } catch (error: unknown) {
  717. failure = failure === undefined
  718. ? error
  719. : new AggregateError([failure, error], `failed to write and close migration stage "${path}"`)
  720. }
  721. if (failure !== undefined) {
  722. const writeError = failure instanceof Error
  723. ? failure
  724. : new Error('migration stage write failed with a non-Error rejection', { cause: failure })
  725. await removeTemporary(path, writeError, internals)
  726. throw writeError
  727. }
  728. return { path, bytes, digest: hash.digest('hex') }
  729. }
  730. /** Remove one temporary file without hiding the operation failure that made it disposable. */
  731. async function removeTemporary(
  732. path: string,
  733. primaryFailure: unknown,
  734. internals: JsonlGenerationInternals,
  735. ): Promise<void> {
  736. try {
  737. await internals.fs.rm(path)
  738. } catch (cleanupFailure: unknown) {
  739. throw new AggregateError(
  740. [primaryFailure, cleanupFailure],
  741. `failed to clean migration temporary "${path}" after an earlier failure`,
  742. )
  743. }
  744. }
  745. /** Remove a redundant stage after the target has been validated as committed. */
  746. async function removeCommittedTemporary(
  747. path: string,
  748. internals: JsonlGenerationInternals,
  749. ): Promise<void> {
  750. try {
  751. await internals.fs.rm(path)
  752. } catch {
  753. // The validated target owns the committed bytes; a redundant link cannot turn success into failure.
  754. }
  755. }
  756. async function publishCurrentExclusive(
  757. staged: string,
  758. currentPath: string,
  759. internals: JsonlGenerationInternals,
  760. ): Promise<boolean> {
  761. if (internals.platform === 'win32') {
  762. try {
  763. await internals.publishNewWin32(staged, currentPath)
  764. return true
  765. } catch (error) {
  766. /* v8 ignore else -- native helper tests own non-collision Win32 failures. */
  767. if (isEEXIST(error)) return false
  768. /* v8 ignore next -- the filesystem error is already complete. */
  769. throw error
  770. }
  771. }
  772. try {
  773. await internals.fs.link(staged, currentPath)
  774. } catch (error) {
  775. /* v8 ignore else -- a non-collision filesystem error propagates unchanged. */
  776. if (isEEXIST(error)) return false
  777. /* v8 ignore next -- the filesystem error is already complete. */
  778. throw error
  779. }
  780. await syncDirectory(dirname(currentPath), internals)
  781. return true
  782. }
  783. function asError(error: unknown): Error {
  784. return error instanceof Error ? error : new Error('current-generation validation failed with a non-Error rejection', {
  785. cause: error,
  786. })
  787. }
  788. async function inspectExpectedCurrent<T>(
  789. currentPath: string,
  790. internals: JsonlGenerationInternals,
  791. inspect: () => Promise<T>,
  792. ): Promise<T> {
  793. try {
  794. const expectedName = basename(currentPath)
  795. const names = await internals.fs.readdir(dirname(currentPath))
  796. if (!names.includes(expectedName)) {
  797. const noncanonical = names.find(name => name.toLowerCase() === expectedName.toLowerCase())
  798. if (noncanonical !== undefined) {
  799. throw new Error(`target resolves to noncanonical directory entry "${noncanonical}"`)
  800. }
  801. }
  802. const info = await internals.fs.lstat(currentPath)
  803. if (info.isSymbolicLink() || !info.isFile()) {
  804. throw new Error(`target is a ${info.isSymbolicLink() ? 'symbolic link' : 'non-regular file'}`)
  805. }
  806. return await inspect()
  807. } catch (error: unknown) {
  808. if (isErrnoException(error)) throw error
  809. throw new JsonlGenerationTargetConflictError(currentPath, asError(error))
  810. }
  811. }
  812. function withOverrides(overrides: JsonlGenerationRuntimeOverrides): JsonlGenerationInternals {
  813. return {
  814. ...defaultInternals,
  815. ...overrides,
  816. fs: { ...defaultFileSystem, ...overrides.fs },
  817. }
  818. }
  819. async function publishPreparedMigration(
  820. options: PrepareJsonlMigrationOptions,
  821. suffix: string,
  822. artifact: SessionFormatArtifact,
  823. sourceIdentity: JsonlPhysicalIdentity,
  824. internals: JsonlGenerationInternals,
  825. ): Promise<JsonlPhysicalIdentity> {
  826. await scheduler.yield()
  827. const { sourcePath, currentPath, compression, verifyCurrentFile } = options
  828. const eventCount = artifact.events.length
  829. let staged = await writeSyncedTemp(currentPath, suffix, compression, artifact, options.format, undefined, internals)
  830. try {
  831. const verifiedStage = await verifyCurrentFile(
  832. staged.path,
  833. compression,
  834. artifact.header.id,
  835. eventCount,
  836. )
  837. if (verifiedStage.bytes !== staged.bytes || verifiedStage.digest !== staged.digest) {
  838. throw new Error('staged session generation changed during verification')
  839. }
  840. await internals.barrier('before-source-check', 1)
  841. const beforePublish = await internals.fs.stat(sourcePath)
  842. if (identity(beforePublish) !== identity(sourceIdentity)) {
  843. throw new JsonlGenerationSourceChangedError(sourcePath)
  844. }
  845. const published = await publishCurrentExclusive(staged.path, currentPath, internals)
  846. if (published && internals.platform === 'win32') staged = { ...staged, path: '' }
  847. await internals.barrier('after-publication', 1)
  848. let currentIdentity: JsonlPhysicalIdentity
  849. if (published) {
  850. if (staged.path !== '') {
  851. await removeCommittedTemporary(staged.path, internals)
  852. staged = { ...staged, path: '' }
  853. }
  854. currentIdentity = await internals.fs.stat(currentPath)
  855. } else {
  856. const winner = await inspectExpectedCurrent(currentPath, internals, async () => {
  857. const candidate = await verifyCurrentFile(
  858. currentPath,
  859. compression,
  860. artifact.header.id,
  861. eventCount,
  862. staged,
  863. )
  864. if (candidate.bytes !== staged.bytes || candidate.digest !== staged.digest) {
  865. throw new Error('target bytes differ from the migrated generation')
  866. }
  867. return candidate
  868. })
  869. currentIdentity = winner.identity
  870. await removeCommittedTemporary(staged.path, internals)
  871. staged = { ...staged, path: '' }
  872. }
  873. return currentIdentity
  874. } catch (error: unknown) {
  875. if (staged.path !== '') await removeTemporary(staged.path, error, internals)
  876. throw error
  877. }
  878. }
  879. async function prepareMigration(
  880. options: PrepareJsonlMigrationOptions,
  881. internals: JsonlGenerationInternals,
  882. ): Promise<PreparedJsonlMigration> {
  883. const { sourcePath, sourceVersion, currentPath, compression, format, signal } = options
  884. const suffix = assertGenerationPaths(
  885. sourcePath,
  886. sourceVersion,
  887. currentPath,
  888. format.currentVersion,
  889. compression,
  890. )
  891. if (sourceVersion >= format.currentVersion) {
  892. throw new Error(`migration preparation requires a historical source, got v${sourceVersion}`)
  893. }
  894. const source = await readStableSnapshot(sourcePath, signal, internals.fs)
  895. let artifact: SessionFormatArtifact
  896. try {
  897. artifact = await decodeStreamingMigration(
  898. source.bytes,
  899. compression,
  900. sourceVersion,
  901. format,
  902. options.validateHistoricalHeader,
  903. signal,
  904. )
  905. } catch (error: unknown) {
  906. if (format.isUnsupportedMigrationError?.(error) === true) {
  907. throw new JsonlGenerationUnsupportedMigrationError(sourceVersion, error)
  908. }
  909. throw error
  910. }
  911. if (artifact.header.version !== format.currentVersion) {
  912. throw new Error(`format migration returned v${artifact.header.version}, expected v${format.currentVersion}`)
  913. }
  914. const sourceIdentity = source.identity
  915. let publication: Promise<JsonlPhysicalIdentity> | undefined
  916. return {
  917. sourceIdentity,
  918. artifact,
  919. publish() {
  920. if (publication === undefined) {
  921. publication = publishPreparedMigration(
  922. options,
  923. suffix,
  924. artifact,
  925. sourceIdentity,
  926. internals,
  927. )
  928. }
  929. return publication
  930. },
  931. }
  932. }
  933. /**
  934. * Decode and migrate one historical generation without writing its successor.
  935. * @param options - resolved source, current target, format adapter, and load cancellation.
  936. * @returns the current artifact and an idempotent explicit publication operation.
  937. */
  938. export function prepareJsonlMigration(
  939. options: PrepareJsonlMigrationOptions,
  940. ): Promise<PreparedJsonlMigration> {
  941. return defaultGenerationRuntime.prepare(options)
  942. }
  943. /**
  944. * Create one generation runtime with fixed filesystem and publication dependencies.
  945. * @param overrides - deterministic filesystem, platform, and race dependencies.
  946. * @returns bound generation operations.
  947. */
  948. export function createJsonlGenerationRuntime(
  949. overrides: JsonlGenerationRuntimeOverrides = {},
  950. ): JsonlGenerationRuntime {
  951. const internals = withOverrides(overrides)
  952. return {
  953. readStable: (path, signal) => readStableSnapshot(path, signal, internals.fs),
  954. prepare: options => prepareMigration(options, internals),
  955. verify: (path, compression, expectedId, expectedEventCount, expectedPrefix) => verifyCurrentGeneration(
  956. path, compression, expectedId, expectedEventCount, internals.fs, expectedPrefix,
  957. ),
  958. }
  959. }
  960. const defaultGenerationRuntime = createJsonlGenerationRuntime()