format.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. /**
  2. * On-disk format helpers for the JSONL session-persistence backend: path
  3. * sanitization (a {@link SessionId} is an unvalidated branded string, so it
  4. * MUST be encoded before use in a path — no traversal, no collision), the
  5. * per-project/session directory layout, header-line (de)serialization, and the
  6. * truncation-repair offset computation.
  7. *
  8. * @module dsh-session-persistence-jsonl/format
  9. */
  10. import { isAbsolute, join } from 'node:path'
  11. import {
  12. decodeSeqRanges, decodeStorageRecord, encodeSeqRanges, packChunkRuns, SESSION_FORMAT_VERSION,
  13. SessionLogOffset,
  14. } from '@deepseek-ai/dsh-session'
  15. import type {
  16. SessionEvent,
  17. SessionHeader,
  18. SessionId,
  19. SessionLogOffset as SessionLogOffsetType,
  20. StorageRecord,
  21. } from '@deepseek-ai/dsh-session'
  22. import { parseSessionFormatLogFilename, sessionFormatLogFilename } from '@deepseek-ai/dsh-session-format'
  23. import {
  24. SessionFormatUnsupportedError,
  25. sessionFormatVersionRefusal,
  26. type SessionStorageMetadata,
  27. } from '@deepseek-ai/dsh-session-persistence'
  28. /** Physical encoding selected for JSONL session artifacts. */
  29. export type JsonlCompression = 'zstd' | 'none'
  30. /**
  31. * Return the artifact suffix for one physical encoding.
  32. * @param compression - configured JSONL artifact encoding.
  33. * @returns `.jsonl.zstd` for Zstandard or `.jsonl` for plaintext.
  34. */
  35. export function logSuffix(compression: JsonlCompression): '.jsonl.zstd' | '.jsonl' {
  36. return `.jsonl${compressionSuffix(compression)}`
  37. }
  38. function compressionSuffix(compression: JsonlCompression): '.zstd' | '' {
  39. return compression === 'zstd' ? '.zstd' : ''
  40. }
  41. /**
  42. * Return the canonical filename for one immutable Session format generation.
  43. * Version zero retains the original suffix-only name; every later generation
  44. * carries a lowercase numeric `vN` component.
  45. * @param version - non-negative safe Session format version.
  46. * @param compression - configured JSONL artifact encoding.
  47. * @returns the generation filename inside one Session directory.
  48. */
  49. export function generationLogFilename(version: number, compression: JsonlCompression): string {
  50. return `${sessionFormatLogFilename(version)}${compressionSuffix(compression)}`
  51. }
  52. /**
  53. * Parse one canonical generation filename for the selected physical encoding.
  54. * Noncanonical, temporary, uppercase, leading-zero, and version-zero-tagged names do
  55. * not identify committed generations.
  56. * @param filename - one entry from a Session directory.
  57. * @param compression - configured JSONL artifact encoding.
  58. * @returns its format version, or `undefined` when the name is not canonical.
  59. */
  60. export function parseGenerationLogFilename(
  61. filename: string,
  62. compression: JsonlCompression,
  63. ): number | undefined {
  64. const suffix = compressionSuffix(compression)
  65. if (!filename.endsWith(suffix)) return undefined
  66. return parseSessionFormatLogFilename(filename.slice(0, filename.length - suffix.length))
  67. }
  68. /**
  69. * The current shared-layout physical header stored as the first JSONL record.
  70. * Its optional numeric `seedLength` translates to logical lineage metadata
  71. * plus a separately carried exact inherited cut.
  72. */
  73. interface HeaderLine {
  74. type: 'session'
  75. version: number
  76. id: SessionId
  77. createdAt: number
  78. cwd?: string
  79. parentSession?: SessionId
  80. seedLength?: number
  81. origin?: 'subagent'
  82. delegationDepth: number
  83. agentPreset?: string
  84. }
  85. const HEADER_REQUIRED_KEYS = ['type', 'version', 'id', 'createdAt', 'delegationDepth'] as const
  86. const HEADER_OPTIONAL_KEYS = ['cwd', 'parentSession', 'seedLength', 'origin', 'agentPreset'] as const
  87. const HEADER_KEYS = new Set<string>([...HEADER_REQUIRED_KEYS, ...HEADER_OPTIONAL_KEYS])
  88. /**
  89. * Refuse policy fields that never belong to a released Session header.
  90. * @param value - parsed physical header candidate.
  91. * @returns nothing after successful validation.
  92. */
  93. export function assertNoRetiredHeaderFields(value: unknown): void {
  94. if (typeof value !== 'object' || value === null) return
  95. if (Object.hasOwn(value, 'sandboxMode') || Object.hasOwn(value, 'approvalPolicy')) {
  96. throw new Error('session header uses retired policy baseline fields')
  97. }
  98. }
  99. /**
  100. * Build the header line object from a {@link SessionHeader}.
  101. * @param header - the immutable session metadata to serialize.
  102. * @param inheritedEventCount - exact inherited prefix length; required for a
  103. * seeded header and omitted only for an unseeded header.
  104. * @returns the `type: 'session'`-tagged line object, absent optional fields omitted (never null).
  105. */
  106. export function toHeaderLine(
  107. header: SessionHeader,
  108. inheritedEventCount?: SessionLogOffsetType,
  109. ): HeaderLine {
  110. if (header.isSeeded && inheritedEventCount === undefined) {
  111. throw new Error('seeded session header requires an inherited event count')
  112. }
  113. const cut = SessionLogOffset(inheritedEventCount ?? 0)
  114. if (!header.isSeeded && cut !== 0) {
  115. throw new Error('unseeded session header inherited event count must be 0')
  116. }
  117. return {
  118. type: 'session',
  119. version: header.version,
  120. id: header.id,
  121. createdAt: header.createdAt,
  122. ...header.cwd !== undefined ? { cwd: header.cwd } : {},
  123. ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {},
  124. ...header.isSeeded ? { seedLength: cut } : {},
  125. ...header.origin !== undefined ? { origin: header.origin } : {},
  126. delegationDepth: header.delegationDepth ?? 0,
  127. ...header.agentPreset !== undefined ? { agentPreset: header.agentPreset } : {},
  128. }
  129. }
  130. /**
  131. * Translate one current physical header into logical metadata and its cut.
  132. * @param line - the shape-checked first line of a log (see the `isHeaderLine` guard).
  133. * @returns logical Session metadata paired with the exact inherited prefix length.
  134. */
  135. function fromHeaderLine(line: HeaderLine): SessionStorageMetadata {
  136. return {
  137. meta: {
  138. version: SESSION_FORMAT_VERSION,
  139. id: line.id,
  140. createdAt: line.createdAt,
  141. ...line.cwd !== undefined ? { cwd: line.cwd } : {},
  142. ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {},
  143. isSeeded: line.seedLength !== undefined,
  144. ...line.origin !== undefined ? { origin: line.origin } : {},
  145. delegationDepth: line.delegationDepth,
  146. ...line.agentPreset !== undefined ? { agentPreset: line.agentPreset } : {},
  147. },
  148. inheritedEventCount: SessionLogOffset(line.seedLength ?? 0),
  149. }
  150. }
  151. /** Type guard: a parsed first line is a well-formed session header. */
  152. function isHeaderLine(value: unknown): value is HeaderLine {
  153. return (
  154. typeof value === 'object' && value !== null && !Array.isArray(value)
  155. && HEADER_REQUIRED_KEYS.every(key => Object.hasOwn(value, key))
  156. && Object.keys(value).every(key => HEADER_KEYS.has(key))
  157. && (value as { type?: unknown }).type === 'session'
  158. && typeof (value as { version?: unknown }).version === 'number'
  159. && typeof (value as { id?: unknown }).id === 'string'
  160. && typeof (value as { createdAt?: unknown }).createdAt === 'number'
  161. && Number.isSafeInteger((value as { createdAt: number }).createdAt)
  162. && (value as { createdAt: number }).createdAt >= 0
  163. && !Object.is((value as { createdAt: number }).createdAt, -0)
  164. && typeof (value as { delegationDepth?: unknown }).delegationDepth === 'number'
  165. && Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth)
  166. && (value as { delegationDepth: number }).delegationDepth >= 0
  167. && !Object.is((value as { delegationDepth: number }).delegationDepth, -0)
  168. && ((value as { cwd?: unknown }).cwd === undefined
  169. || (typeof (value as { cwd?: unknown }).cwd === 'string'
  170. && isAbsolute((value as { cwd: string }).cwd)))
  171. && ((value as { parentSession?: unknown }).parentSession === undefined
  172. || typeof (value as { parentSession?: unknown }).parentSession === 'string')
  173. && ((value as { seedLength?: unknown }).seedLength === undefined
  174. || (typeof (value as { seedLength?: unknown }).seedLength === 'number'
  175. && Number.isSafeInteger((value as { seedLength: number }).seedLength)
  176. && (value as { seedLength: number }).seedLength >= 0
  177. && !Object.is((value as { seedLength: number }).seedLength, -0)))
  178. && ((value as { origin?: unknown }).origin === undefined
  179. || (value as { origin?: unknown }).origin === 'subagent')
  180. && ((value as { agentPreset?: unknown }).agentPreset === undefined
  181. || typeof (value as { agentPreset?: unknown }).agentPreset === 'string')
  182. )
  183. }
  184. /**
  185. * Encode an arbitrary string as a single safe path segment, injectively over ALL JS (UTF-16)
  186. * strings — including lone surrogates. A {@link SessionId} is an unvalidated branded string,
  187. * so this neutralizes `../`, absolute paths, NUL, and separators before any filesystem use.
  188. * Safe code units remain literal; every other unit, including `~`, becomes
  189. * `~XXXX`. Operating on code units preserves lone surrogates, while special-
  190. * casing `.` and `..` prevents traversal by an otherwise safe whole segment.
  191. *
  192. * @param raw - the string to encode; must be non-empty (throws on `''`).
  193. * @returns the escaped single path segment, decodable back to `raw`.
  194. */
  195. export function encodeSegment(raw: string): string {
  196. if (raw.length === 0) throw new Error('cannot encode an empty path segment')
  197. if (raw === '.') return '~002E'
  198. if (raw === '..') return '~002E~002E'
  199. let out = ''
  200. for (let i = 0; i < raw.length; i++) {
  201. const code = raw.charCodeAt(i)
  202. const ch = String.fromCharCode(code)
  203. if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
  204. out += ch
  205. } else {
  206. out += '~' + code.toString(16).toUpperCase().padStart(4, '0')
  207. }
  208. }
  209. return out
  210. }
  211. /**
  212. * Build the readable directory key for a project path.
  213. * Filesystem separators and drive separators become `-`; unsafe code units use
  214. * the same `~XXXX` escape as session ids. The key is bounded for filesystem
  215. * component limits. Separator replacement and truncation are intentionally
  216. * lossy, following the common human-navigable project-directory convention.
  217. * @param cwd - the session's project directory.
  218. * @returns a single filesystem-safe project directory name.
  219. */
  220. export function projectKey(cwd: string): string {
  221. if (cwd.length === 0) throw new Error('cannot encode an empty project path')
  222. let readable = ''
  223. let separatorRun = false
  224. for (let i = 0; i < cwd.length; i++) {
  225. const code = cwd.charCodeAt(i)
  226. const ch = String.fromCharCode(code)
  227. if (ch === '/' || ch === '\\' || ch === ':') {
  228. if (!separatorRun) readable += '-'
  229. separatorRun = true
  230. } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
  231. readable += ch
  232. separatorRun = false
  233. } else {
  234. readable += '~' + code.toString(16).toUpperCase().padStart(4, '0')
  235. separatorRun = false
  236. }
  237. }
  238. const slug = readable.replace(/^-+/, '') || 'root'
  239. return `--${slug.slice(0, 251)}--`
  240. }
  241. /**
  242. * The configured root's human-navigable project directory. A configured root
  243. * may be local or shared; this grouping does not prescribe its deployment.
  244. * @param root - the backend's session root directory.
  245. * @param cwd - the session's project directory; `undefined` selects `_no-cwd`.
  246. * @returns the project directory path under `root`.
  247. */
  248. export function projectDir(root: string, cwd: string | undefined): string {
  249. if (cwd === undefined) return join(root, '_no-cwd')
  250. return join(root, projectKey(cwd))
  251. }
  252. /**
  253. * The directory owned by one session and available for future session-local
  254. * artifacts.
  255. * @param root - the backend's session root directory.
  256. * @param cwd - the session's project directory.
  257. * @param id - the session id, encoded to one safe path segment.
  258. * @returns the session directory beneath its project directory.
  259. */
  260. export function sessionDir(root: string, cwd: string | undefined, id: SessionId): string {
  261. return join(projectDir(root, cwd), encodeSegment(id))
  262. }
  263. /**
  264. * Build one immutable Session format generation path.
  265. * @param root - the backend's session root directory.
  266. * @param cwd - the session's project directory (`undefined` → `_no-cwd`).
  267. * @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
  268. * @param version - physical Session format generation.
  269. * @param compression - physical artifact encoding and filename suffix.
  270. * @returns the selected generation's configured JSONL artifact path.
  271. */
  272. export function generationLogPath(
  273. root: string,
  274. cwd: string | undefined,
  275. id: SessionId,
  276. version: number,
  277. compression: JsonlCompression,
  278. ): string {
  279. return join(sessionDir(root, cwd, id), generationLogFilename(version, compression))
  280. }
  281. /**
  282. * Build the current generation's append target path for a Session.
  283. * @param root - the backend's session root directory.
  284. * @param cwd - the session's project directory (`undefined` → `_no-cwd`).
  285. * @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
  286. * @param compression - physical artifact encoding and filename suffix.
  287. * @returns the current Session format generation path.
  288. */
  289. export function logPath(
  290. root: string,
  291. cwd: string | undefined,
  292. id: SessionId,
  293. compression: JsonlCompression,
  294. ): string {
  295. return generationLogPath(root, cwd, id, SESSION_FORMAT_VERSION, compression)
  296. }
  297. /**
  298. * Serialize an event batch as JSONL lines (no trailing newline). With
  299. * `packChunks` on, delta-chunk runs pack into `text-chunks` /
  300. * `reasoning-chunks` / `tool-call-chunks` storage rows; off writes one event
  301. * per line. Both modes range-encode provenance at the storage boundary.
  302. * Reading is layout-blind either way ({@link scanLog} always decodes rows),
  303. * so the switch changes only newly written bytes.
  304. * @param events - the batch to serialize, in log order.
  305. * @param packChunks - whether to pack delta runs into storage rows.
  306. * @returns the batch's JSONL text; the writer adds the final newline.
  307. */
  308. export function eventLines(events: readonly SessionEvent[], packChunks: boolean): string {
  309. const records: readonly StorageRecord[] = packChunks ? packChunkRuns(events) : events
  310. return records.map(record => JSON.stringify(encodeProvenanceForStorage(record))).join('\n')
  311. }
  312. /**
  313. * Losslessly shrink a record's `sourceEventSeqs` for the log: consecutive
  314. * runs of at least three seqs become `[start, end]` pairs, and any other list
  315. * stays verbatim.
  316. * @param record - one stored record (event or packed row).
  317. * @returns the record with its provenance in storage form (widened from the
  318. * in-memory `SessionSeq[]`; {@link expandProvenanceFromStorage} restores it).
  319. */
  320. function encodeProvenanceForStorage(record: StorageRecord): unknown {
  321. if (!('sourceEventSeqs' in record)) return record
  322. return { ...record, sourceEventSeqs: encodeSeqRanges(record.sourceEventSeqs) }
  323. }
  324. /**
  325. * Expand a parsed line's storage-form provenance back to `SessionSeq[]`.
  326. * @param parsed - the JSON-parsed value of one stored line.
  327. * @returns the value with provenance expanded.
  328. * @throws when the record or its storage-form provenance is malformed.
  329. */
  330. function expandProvenanceFromStorage(parsed: unknown): unknown {
  331. if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
  332. throw new TypeError('stored session records must be objects')
  333. }
  334. const record = parsed as { seq?: unknown; sourceEventSeqs?: unknown }
  335. if (record.sourceEventSeqs === undefined) return parsed
  336. if (!Number.isSafeInteger(record.seq) || (record.seq as number) < 0) {
  337. throw new TypeError('stored session event seq must be a non-negative safe integer')
  338. }
  339. return { ...record, sourceEventSeqs: decodeSeqRanges(record.sourceEventSeqs, record.seq as number) }
  340. }
  341. interface SessionLogScan {
  342. meta: SessionHeader
  343. inheritedEventCount: SessionLogOffsetType
  344. events: SessionEvent[]
  345. committedBytes: number
  346. }
  347. /**
  348. * Refuse a header carrying a format version this build does not read BEFORE
  349. * validating the current header shape or decoding any event row: a future
  350. * format need not satisfy this build's structural checks at all, and its user
  351. * must see "upgrade the harness", never "corrupt session log".
  352. * @param parsed - the JSON-parsed first line of a session artifact.
  353. */
  354. function refuseForeignFormatVersion(parsed: unknown): void {
  355. if (typeof parsed !== 'object' || parsed === null) return
  356. const { version, id } = parsed as { version?: unknown; id?: unknown }
  357. if (typeof version !== 'number' || version === SESSION_FORMAT_VERSION) return
  358. throw new SessionFormatUnsupportedError(
  359. sessionFormatVersionRefusal(typeof id === 'string' ? id : String(id), version),
  360. )
  361. }
  362. /** Parse one complete header record supplied independently from event rows. */
  363. function parseHeaderRecord(record: Buffer): ReturnType<typeof fromHeaderLine> {
  364. if (record.length === 0 || record.at(-1) !== 0x0A || record.indexOf(0x0A) !== record.length - 1) {
  365. throw new Error('empty or header-less session log')
  366. }
  367. let parsed: unknown
  368. try {
  369. parsed = JSON.parse(record.subarray(0, -1).toString('utf8'))
  370. } catch {
  371. throw new Error('corrupt session log: header line is not valid JSON')
  372. }
  373. refuseForeignFormatVersion(parsed)
  374. assertNoRetiredHeaderFields(parsed)
  375. if (!isHeaderLine(parsed)) {
  376. throw new Error('corrupt session log: first line is not a session header')
  377. }
  378. return fromHeaderLine(parsed)
  379. }
  380. /**
  381. * Incrementally scan complete JSONL event records after an independently
  382. * supplied header record. Newline search and byte offsets stay on raw buffers;
  383. * only complete records are decoded to UTF-8. A fragment crossing writes is
  384. * copied because a decoder may reuse its output buffer after `write()` returns.
  385. */
  386. export class SessionLogScanner {
  387. private readonly meta: SessionHeader
  388. private readonly inheritedEventCount: SessionLogOffsetType
  389. private readonly events: SessionEvent[] = []
  390. private fragments: Buffer[] = []
  391. private fragmentBytes = 0
  392. private inputBytes: number
  393. private committedBytes: number
  394. private eventLine = 0
  395. private issue: Error | undefined
  396. private finished = false
  397. /**
  398. * Create an event scanner from exactly one newline-terminated header record.
  399. * @param headerRecord - the complete first JSONL record, including its newline.
  400. */
  401. constructor(headerRecord: Buffer) {
  402. const parsed = parseHeaderRecord(headerRecord)
  403. this.meta = parsed.meta
  404. this.inheritedEventCount = parsed.inheritedEventCount
  405. this.inputBytes = headerRecord.length
  406. this.committedBytes = headerRecord.length
  407. }
  408. /**
  409. * Consume the next raw plaintext chunk, retaining only an incomplete final record.
  410. * @param chunk - bytes immediately following all previously supplied bytes.
  411. */
  412. write(chunk: Buffer): void {
  413. if (this.finished) throw new Error('cannot write to a finished session log scanner')
  414. const chunkStart = this.inputBytes
  415. this.inputBytes += chunk.length
  416. let lineStart = 0
  417. for (
  418. let newline = chunk.indexOf(0x0A);
  419. newline !== -1;
  420. newline = chunk.indexOf(0x0A, lineStart)
  421. ) {
  422. const fragment = chunk.subarray(lineStart, newline)
  423. let line = fragment
  424. if (this.fragments.length > 0) {
  425. if (fragment.length > 0) this.fragments.push(fragment)
  426. line = Buffer.concat(this.fragments, this.fragmentBytes + fragment.length)
  427. this.fragments = []
  428. this.fragmentBytes = 0
  429. }
  430. this.consumeEventLine(line, chunkStart + newline + 1)
  431. lineStart = newline + 1
  432. }
  433. if (lineStart < chunk.length) {
  434. const fragment = Buffer.from(chunk.subarray(lineStart))
  435. this.fragments.push(fragment)
  436. this.fragmentBytes += fragment.length
  437. }
  438. }
  439. /**
  440. * Snapshot progress before appending a recoverable torn-frame prefix.
  441. * @returns byte, committed-prefix, and expanded-event cursors.
  442. */
  443. checkpoint(): {
  444. inputBytes: number
  445. committedBytes: number
  446. eventCount: SessionLogOffsetType
  447. } {
  448. return {
  449. inputBytes: this.inputBytes,
  450. committedBytes: this.committedBytes,
  451. eventCount: SessionLogOffset(this.events.length),
  452. }
  453. }
  454. /**
  455. * Finish scanning, ignoring a final record without a newline as a torn tail.
  456. * @returns the header, contiguous event prefix, and safe truncation offset.
  457. */
  458. finish(): SessionLogScan {
  459. this.finished = true
  460. return {
  461. meta: this.meta,
  462. inheritedEventCount: this.inheritedEventCount,
  463. events: this.events,
  464. committedBytes: this.committedBytes,
  465. }
  466. }
  467. /** Decode one complete event row and update the contiguous prefix. */
  468. private consumeEventLine(line: Buffer, endByte: number): void {
  469. this.eventLine += 1
  470. let decoded: SessionEvent[]
  471. try {
  472. decoded = decodeStorageRecord(expandProvenanceFromStorage(JSON.parse(line.toString('utf8'))))
  473. } catch {
  474. this.issue ??= new Error(`corrupt session log: unparsable committed event at line ${this.eventLine}`)
  475. return
  476. }
  477. if (this.issue !== undefined) {
  478. if (decoded.some(event => event.type === 'turn/end')) throw this.issue
  479. return
  480. }
  481. const rowStart = this.events.length
  482. for (const event of decoded) {
  483. if (event.seq !== this.events.length) {
  484. const expected = this.events.length
  485. this.events.length = rowStart
  486. this.issue = new Error(
  487. `corrupt session log: seq gap in committed region at line ${this.eventLine} `
  488. + `(expected ${expected}, got ${event.seq})`,
  489. )
  490. if (decoded.some(candidate => candidate.type === 'turn/end')) throw this.issue
  491. return
  492. }
  493. this.events.push(event)
  494. }
  495. this.committedBytes = endByte
  496. }
  497. }
  498. /**
  499. * Parse a complete or torn JSONL buffer into its preserved event prefix. This
  500. * compatibility wrapper supplies the first record separately, then delegates
  501. * event rows to {@link SessionLogScanner}.
  502. *
  503. * @param buffer - the raw bytes of the log file (header line first).
  504. * @returns the header, preserved event prefix, and byte offset safe to append at.
  505. */
  506. export function scanLog(buffer: Buffer): SessionLogScan {
  507. const headerEnd = buffer.indexOf(0x0A)
  508. if (headerEnd === -1) throw new Error('empty or header-less session log')
  509. const scanner = new SessionLogScanner(buffer.subarray(0, headerEnd + 1))
  510. scanner.write(buffer.subarray(headerEnd + 1))
  511. return scanner.finish()
  512. }