format.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  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-cwd directory layout, header-line (de)serialization, and the
  6. * truncation-repair offset computation.
  7. *
  8. * @module dsh-session-persistence-jsonl/format
  9. */
  10. import { createHash } from 'node:crypto'
  11. import { join } from 'node:path'
  12. import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
  13. /**
  14. * The first line of a session's `.jsonl` file: the immutable
  15. * {@link SessionHeader} tagged as a `session` record so a reader can tell it
  16. * apart from an event line.
  17. */
  18. export interface HeaderLine {
  19. type: 'session'
  20. version: number
  21. id: SessionId
  22. createdAt: number
  23. cwd?: string
  24. parentSession?: SessionId
  25. }
  26. /** Build the header line object from a {@link SessionHeader}. */
  27. export function toHeaderLine(header: SessionHeader): HeaderLine {
  28. return {
  29. type: 'session',
  30. version: header.version,
  31. id: header.id,
  32. createdAt: header.createdAt,
  33. ...header.cwd !== undefined ? { cwd: header.cwd } : {},
  34. ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {},
  35. }
  36. }
  37. /** Parse a header line back into a {@link SessionHeader}. */
  38. export function fromHeaderLine(line: HeaderLine): SessionHeader {
  39. return {
  40. version: line.version,
  41. id: line.id,
  42. createdAt: line.createdAt,
  43. ...line.cwd !== undefined ? { cwd: line.cwd } : {},
  44. ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {},
  45. }
  46. }
  47. /** Type guard: a parsed first line is a well-formed session header. */
  48. function isHeaderLine(value: unknown): value is HeaderLine {
  49. return (
  50. typeof value === 'object' && value !== null
  51. && (value as { type?: unknown }).type === 'session'
  52. && typeof (value as { version?: unknown }).version === 'number'
  53. && typeof (value as { id?: unknown }).id === 'string'
  54. && typeof (value as { createdAt?: unknown }).createdAt === 'number'
  55. )
  56. }
  57. /**
  58. * Encode an arbitrary string as a single safe path segment, injectively over
  59. * ALL JS (UTF-16) strings — including lone surrogates. A {@link SessionId} is
  60. * an unvalidated branded string, so this neutralizes `../`, absolute paths,
  61. * NUL, and separators before any filesystem use.
  62. *
  63. * Each UTF-16 code unit is either kept literal (the safe set `[A-Za-z0-9_-]`)
  64. * or escaped as `~XXXX` (its 4-hex-digit code unit). `~` is itself escaped, so
  65. * the mapping is injective and reversible: distinct inputs never collide. We
  66. * iterate code UNITS (`charCodeAt`), not code points, so a lone surrogate
  67. * escapes to a distinct `~XXXX` instead of being normalized to U+FFFD (which
  68. * `Buffer.from(…, 'utf8')` would do, breaking injectivity). `.` is in the safe
  69. * set for readability but the whole-segment tokens `.`/`..` are escaped so they
  70. * can never traverse.
  71. */
  72. export function encodeSegment(raw: string): string {
  73. if (raw.length === 0) throw new Error('cannot encode an empty path segment')
  74. if (raw === '.') return '~002E'
  75. if (raw === '..') return '~002E~002E'
  76. let out = ''
  77. for (let i = 0; i < raw.length; i++) {
  78. const code = raw.charCodeAt(i)
  79. const ch = String.fromCharCode(code)
  80. if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
  81. out += ch
  82. } else {
  83. out += '~' + code.toString(16).toUpperCase().padStart(4, '0')
  84. }
  85. }
  86. return out
  87. }
  88. /**
  89. * The directory a session's files live in: the configured root, then a per-cwd
  90. * subdirectory so sessions group by project. The cwd subdir is a stable hash
  91. * (short, collision-resistant, filesystem-safe) plus an encoded suffix for
  92. * readability; sessions without a cwd go in a shared `_no-cwd` bucket.
  93. */
  94. export function sessionDir(root: string, cwd: string | undefined): string {
  95. if (cwd === undefined) return join(root, '_no-cwd')
  96. const hash = createHash('sha256').update(cwd).digest('hex').slice(0, 12)
  97. return join(root, `cwd-${hash}`)
  98. }
  99. /** The append-only event-log file path for a session. */
  100. export function logPath(root: string, cwd: string | undefined, id: SessionId): string {
  101. return join(sessionDir(root, cwd), `${encodeSegment(id)}.jsonl`)
  102. }
  103. /** Serialize one event as a JSONL line (no trailing newline). */
  104. export function eventLine(event: SessionEvent): string {
  105. return JSON.stringify(event)
  106. }
  107. /**
  108. * Parse a JSONL log buffer into its preserved event prefix (the header is line
  109. * 0). Returns the longest prefix of complete, seq-contiguous events plus the
  110. * byte offset of the end of the last preserved line (`committedBytes`).
  111. *
  112. * A crash can leave a durable log whose final turn never closed: real,
  113. * fully-written events sit after the last `turn/end`. Those are PRESERVED (a
  114. * single turn can be huge in a long-horizon task — truncating it would destroy
  115. * real work); the backend closes the orphaned open turn with a synthetic
  116. * `turn/end {kind:'interrupted'}` on reload (the session-persistence RFC). Only a TORN trailing
  117. * fragment — a final line never fully flushed (no newline, unparseable, or a
  118. * seq gap) — is excluded; it bounds the preserved region. A parse error or seq
  119. * gap AT OR BEFORE the last committed `turn/end` is committed-data corruption
  120. * and makes the session unloadable (throws).
  121. *
  122. * This relies on the session-log invariant that every event lives inside a turn
  123. * (`Session.append` enforces it): only the final turn can be open, so the
  124. * preserved tail is at most one unclosed turn.
  125. */
  126. export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionEvent[]; committedBytes: number } {
  127. const text = buffer.toString('utf8')
  128. // Split into complete (newline-terminated) lines, tracking the byte offset of
  129. // each line's end so the truncation point is exact (multi-byte chars make the
  130. // char offset differ from the byte offset). A trailing line with no newline is
  131. // an uncommitted crash fragment and is ignored — it is below the last
  132. // turn/end by construction (the loop only flushes whole lines).
  133. //
  134. // Track the byte offset with a RUNNING accumulator (`endByte`), adding each
  135. // line's byte length as we go. Recomputing `Buffer.byteLength(text.slice(0, i))`
  136. // per newline would rescan the whole prefix every time — O(n²) over a long
  137. // log (one assistant/chunk line per token makes that pathological).
  138. const lines: { text: string; endByte: number }[] = []
  139. let start = 0
  140. let byteOffset = 0
  141. for (let i = 0; i < text.length; i++) {
  142. if (text[i] === '\n') {
  143. const lineText = text.slice(start, i)
  144. byteOffset += Buffer.byteLength(lineText, 'utf8') + 1 // +1 for the '\n' (a 1-byte char)
  145. lines.push({ text: lineText, endByte: byteOffset })
  146. start = i + 1
  147. }
  148. }
  149. const [headerEntry, ...eventEntries] = lines
  150. if (headerEntry === undefined) throw new Error('empty or header-less session log')
  151. // Line 0 is the header.
  152. let parsedHeader: unknown
  153. try {
  154. parsedHeader = JSON.parse(headerEntry.text)
  155. } catch {
  156. throw new Error('corrupt session log: header line is not valid JSON')
  157. }
  158. if (!isHeaderLine(parsedHeader)) {
  159. throw new Error('corrupt session log: first line is not a session header')
  160. }
  161. const headerLine = parsedHeader
  162. // Find the committed region: the prefix up to and including the LAST complete
  163. // `turn/end` in the WHOLE log. Two passes so a crash tail after the last
  164. // turn/end is tolerated, but corruption/gaps AT OR BEFORE the last committed
  165. // turn/end make the log unloadable (committed data must never be silently
  166. // dropped).
  167. //
  168. // Pass 1: parse every line that parses, recording (parsedOk, seq, isTurnEnd,
  169. // endByte) per line index. Lines that fail to parse are holes.
  170. interface Parsed { ok: boolean; event?: SessionEvent; endByte: number }
  171. const parsed: Parsed[] = eventEntries.map((entry) => {
  172. try {
  173. return { ok: true, event: JSON.parse(entry.text) as SessionEvent, endByte: entry.endByte }
  174. } catch {
  175. return { ok: false, endByte: entry.endByte }
  176. }
  177. })
  178. // The last index (into eventEntries) that is a valid `turn/end` — the last
  179. // fully-committed boundary (the loop flushes only at turn/end).
  180. let lastTurnEnd = -1
  181. for (let i = parsed.length - 1; i >= 0; i--) {
  182. const p = parsed[i]
  183. if (p?.ok && p.event?.type === 'turn/end') { lastTurnEnd = i; break }
  184. }
  185. // Walk the longest PREFIX of complete, seq-contiguous, parseable event lines
  186. // (line i is a parsed event with seq === i). This is the preservable region:
  187. // it includes any fully-written events of an interrupted final turn AFTER the
  188. // last turn/end — those are real, durably-written work and must NOT be
  189. // truncated (a single turn can be huge in a long-horizon task; the orphaned
  190. // open turn is closed with a synthetic turn/end on reload, not discarded —
  191. // the session-persistence RFC). The walk stops at the first hole (unparseable line or seq gap):
  192. // - if that hole is AT OR BEFORE the last committed turn/end, committed data
  193. // was damaged → the session is unloadable (throw);
  194. // - if it is AFTER (or there is no committed turn/end yet), it is the
  195. // tolerated crash boundary — a torn final line never fully flushed — and
  196. // it simply bounds the preserved tail.
  197. const preserved: SessionEvent[] = []
  198. for (let i = 0; i < parsed.length; i++) {
  199. const p = parsed[i]
  200. if (!p?.ok || p.event === undefined) {
  201. if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at line ${i + 1}`)
  202. break // torn tail fragment after the last turn/end — stop, tolerate
  203. }
  204. if (p.event.seq !== i) {
  205. if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region at line ${i + 1} (expected ${i}, got ${p.event.seq})`)
  206. break // gap after the last turn/end — torn tail, stop
  207. }
  208. preserved.push(p.event)
  209. }
  210. // committedBytes = end of the last PRESERVED line (header if none): the next
  211. // append truncates any torn bytes past this point before writing the
  212. // synthetic closers + new events.
  213. const lastPreserved = parsed[preserved.length - 1]
  214. const committedBytes = preserved.length > 0 && lastPreserved ? lastPreserved.endByte : headerEntry.endByte
  215. return { meta: fromHeaderLine(headerLine), events: preserved, committedBytes }
  216. }
  217. /**
  218. * Parse just the header line of a log into a {@link SessionHeader}, or
  219. * `undefined` if it is missing/not a header. Used by `list()` to read session
  220. * metadata WITHOUT parsing the whole log: a session picker scales with the
  221. * number of sessions, not the total size of every conversation.
  222. */
  223. export function parseHeaderMeta(firstLine: string): SessionHeader | undefined {
  224. let parsed: unknown
  225. try {
  226. parsed = JSON.parse(firstLine)
  227. } catch {
  228. return undefined
  229. }
  230. if (!isHeaderLine(parsed)) return undefined
  231. return fromHeaderLine(parsed)
  232. }