format.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  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 { join } from 'node:path'
  11. import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session'
  12. import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session'
  13. /** Physical encoding selected for JSONL session artifacts. */
  14. export type JsonlCompression = 'zstd' | 'none'
  15. /**
  16. * Return the artifact suffix for one physical encoding.
  17. * @param compression - configured JSONL artifact encoding.
  18. * @returns `.jsonl.zstd` for Zstandard or `.jsonl` for plaintext.
  19. */
  20. export function logSuffix(compression: JsonlCompression): '.jsonl.zstd' | '.jsonl' {
  21. return compression === 'zstd' ? '.jsonl.zstd' : '.jsonl'
  22. }
  23. /**
  24. * The first JSONL record of a session artifact: the immutable
  25. * {@link SessionHeader} tagged as a `session` record so a reader can tell it
  26. * apart from an event line.
  27. */
  28. export interface HeaderLine {
  29. type: 'session'
  30. version: number
  31. id: SessionId
  32. createdAt: number
  33. cwd?: string
  34. parentSession?: SessionId
  35. seedLength?: number
  36. delegationDepth: number
  37. }
  38. /**
  39. * Build the header line object from a {@link SessionHeader}.
  40. * @param header - the immutable session metadata to serialize.
  41. * @returns the `type: 'session'`-tagged line object, absent optional fields omitted (never null).
  42. */
  43. export function toHeaderLine(header: SessionHeader): HeaderLine {
  44. return {
  45. type: 'session',
  46. version: header.version,
  47. id: header.id,
  48. createdAt: header.createdAt,
  49. ...header.cwd !== undefined ? { cwd: header.cwd } : {},
  50. ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {},
  51. ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {},
  52. delegationDepth: header.delegationDepth ?? 0,
  53. }
  54. }
  55. /**
  56. * Parse a header line back into a {@link SessionHeader}.
  57. * @param line - the shape-checked first line of a log (see the `isHeaderLine` guard).
  58. * @returns the header, absent optional fields omitted.
  59. */
  60. export function fromHeaderLine(line: HeaderLine): SessionHeader {
  61. if (Object.hasOwn(line, 'sandboxMode') || Object.hasOwn(line, 'approvalPolicy')) {
  62. throw new Error('session header uses retired policy baseline fields')
  63. }
  64. return {
  65. version: line.version,
  66. id: line.id,
  67. createdAt: line.createdAt,
  68. ...line.cwd !== undefined ? { cwd: line.cwd } : {},
  69. ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {},
  70. ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {},
  71. delegationDepth: line.delegationDepth,
  72. }
  73. }
  74. /** Type guard: a parsed first line is a well-formed session header. */
  75. function isHeaderLine(value: unknown): value is HeaderLine {
  76. return (
  77. typeof value === 'object' && value !== null
  78. && (value as { type?: unknown }).type === 'session'
  79. && typeof (value as { version?: unknown }).version === 'number'
  80. && typeof (value as { id?: unknown }).id === 'string'
  81. && typeof (value as { createdAt?: unknown }).createdAt === 'number'
  82. && Number.isSafeInteger((value as { createdAt: number }).createdAt)
  83. && (value as { createdAt: number }).createdAt >= 0
  84. && !Object.is((value as { createdAt: number }).createdAt, -0)
  85. && typeof (value as { delegationDepth?: unknown }).delegationDepth === 'number'
  86. && Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth)
  87. && (value as { delegationDepth: number }).delegationDepth >= 0
  88. && !Object.is((value as { delegationDepth: number }).delegationDepth, -0)
  89. )
  90. }
  91. /**
  92. * Encode an arbitrary string as a single safe path segment, injectively over ALL JS (UTF-16)
  93. * strings — including lone surrogates. A {@link SessionId} is an unvalidated branded string,
  94. * so this neutralizes `../`, absolute paths, NUL, and separators before any filesystem use.
  95. * Safe code units remain literal; every other unit, including `~`, becomes
  96. * `~XXXX`. Operating on code units preserves lone surrogates, while special-
  97. * casing `.` and `..` prevents traversal by an otherwise safe whole segment.
  98. *
  99. * @param raw - the string to encode; must be non-empty (throws on `''`).
  100. * @returns the escaped single path segment, decodable back to `raw`.
  101. */
  102. export function encodeSegment(raw: string): string {
  103. if (raw.length === 0) throw new Error('cannot encode an empty path segment')
  104. if (raw === '.') return '~002E'
  105. if (raw === '..') return '~002E~002E'
  106. let out = ''
  107. for (let i = 0; i < raw.length; i++) {
  108. const code = raw.charCodeAt(i)
  109. const ch = String.fromCharCode(code)
  110. if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
  111. out += ch
  112. } else {
  113. out += '~' + code.toString(16).toUpperCase().padStart(4, '0')
  114. }
  115. }
  116. return out
  117. }
  118. /**
  119. * Build the readable directory key for a project path.
  120. * Filesystem separators and drive separators become `-`; unsafe code units use
  121. * the same `~XXXX` escape as session ids. The key is bounded for filesystem
  122. * component limits. Separator replacement and truncation are intentionally
  123. * lossy, following the common human-navigable project-directory convention.
  124. * @param cwd - the session's project directory.
  125. * @returns a single filesystem-safe project directory name.
  126. */
  127. export function projectKey(cwd: string): string {
  128. if (cwd.length === 0) throw new Error('cannot encode an empty project path')
  129. let readable = ''
  130. let separatorRun = false
  131. for (let i = 0; i < cwd.length; i++) {
  132. const code = cwd.charCodeAt(i)
  133. const ch = String.fromCharCode(code)
  134. if (ch === '/' || ch === '\\' || ch === ':') {
  135. if (!separatorRun) readable += '-'
  136. separatorRun = true
  137. } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
  138. readable += ch
  139. separatorRun = false
  140. } else {
  141. readable += '~' + code.toString(16).toUpperCase().padStart(4, '0')
  142. separatorRun = false
  143. }
  144. }
  145. const slug = readable.replace(/^-+/, '') || 'root'
  146. return `--${slug.slice(0, 251)}--`
  147. }
  148. /**
  149. * The configured root's human-navigable project directory. A configured root
  150. * may be local or shared; this grouping does not prescribe its deployment.
  151. * @param root - the backend's session root directory.
  152. * @param cwd - the session's project directory; `undefined` selects `_no-cwd`.
  153. * @returns the project directory path under `root`.
  154. */
  155. export function projectDir(root: string, cwd: string | undefined): string {
  156. if (cwd === undefined) return join(root, '_no-cwd')
  157. return join(root, projectKey(cwd))
  158. }
  159. /**
  160. * The directory owned by one session and available for future session-local
  161. * artifacts.
  162. * @param root - the backend's session root directory.
  163. * @param cwd - the session's project directory.
  164. * @param id - the session id, encoded to one safe path segment.
  165. * @returns the session directory beneath its project directory.
  166. */
  167. export function sessionDir(root: string, cwd: string | undefined, id: SessionId): string {
  168. return join(projectDir(root, cwd), encodeSegment(id))
  169. }
  170. /**
  171. * The append-only event-log file path for a session.
  172. * @param root - the backend's session root directory.
  173. * @param cwd - the session's project directory (`undefined` → `_no-cwd`).
  174. * @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
  175. * @param compression - physical artifact encoding and filename suffix.
  176. * @returns the session's configured JSONL artifact path.
  177. */
  178. export function logPath(
  179. root: string,
  180. cwd: string | undefined,
  181. id: SessionId,
  182. compression: JsonlCompression,
  183. ): string {
  184. return join(sessionDir(root, cwd, id), `session${logSuffix(compression)}`)
  185. }
  186. /**
  187. * Serialize an event batch as JSONL lines (no trailing newline). With
  188. * `packChunks` on, delta-chunk runs pack into `text-chunks` /
  189. * `reasoning-chunks` / `tool-call-chunks` storage rows; off writes one event
  190. * per line, byte-identical to the pre-packing layout. Reading is layout-blind
  191. * either way ({@link scanLog} always decodes rows), so the switch only shapes
  192. * NEW bytes.
  193. * @param events - the batch to serialize, in log order.
  194. * @param packChunks - whether to pack delta runs into storage rows.
  195. * @returns the batch's JSONL text; the writer adds the final newline.
  196. */
  197. export function eventLines(events: readonly SessionEvent[], packChunks: boolean): string {
  198. const records: readonly StorageRecord[] = packChunks ? packChunkRuns(events) : events
  199. return records.map(record => JSON.stringify(record)).join('\n')
  200. }
  201. /**
  202. * Parse a JSONL log buffer into its preserved event prefix (the header is line
  203. * 0). Event lines pass through verbatim; packed chunk rows expand back into
  204. * their events, so callers see one contiguous event list regardless of layout.
  205. * Fully written events in an interrupted final turn remain part of the
  206. * prefix. The first unparsable record or seq gap after the last `turn/end`
  207. * marks a tolerated torn tail; the same hole in the committed region rejects.
  208. *
  209. * @param buffer - the raw bytes of the log file (header line first).
  210. * @returns the header, the preserved event prefix, and `committedBytes` — the
  211. * byte offset the next append truncates any torn tail to.
  212. */
  213. export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionEvent[]; committedBytes: number } {
  214. const text = buffer.toString('utf8')
  215. // Track complete lines by byte offset: a non-newline tail is torn and ignored,
  216. // and a running counter avoids rescanning a long multi-byte log.
  217. const lines: { text: string; endByte: number }[] = []
  218. let start = 0
  219. let byteOffset = 0
  220. for (let i = 0; i < text.length; i++) {
  221. if (text[i] === '\n') {
  222. const lineText = text.slice(start, i)
  223. byteOffset += Buffer.byteLength(lineText, 'utf8') + 1 // +1 for the '\n' (a 1-byte char)
  224. lines.push({ text: lineText, endByte: byteOffset })
  225. start = i + 1
  226. }
  227. }
  228. const [headerEntry, ...eventEntries] = lines
  229. if (headerEntry === undefined) throw new Error('empty or header-less session log')
  230. // Line 0 is the header.
  231. let parsedHeader: unknown
  232. try {
  233. parsedHeader = JSON.parse(headerEntry.text)
  234. } catch {
  235. throw new Error('corrupt session log: header line is not valid JSON')
  236. }
  237. if (!isHeaderLine(parsedHeader)) {
  238. throw new Error('corrupt session log: first line is not a session header')
  239. }
  240. const headerLine = parsedHeader
  241. // Parse and decode every complete line first so the last valid `turn/end`
  242. // determines whether an earlier hole interrupts an otherwise closed
  243. // execution or belongs to a tolerable final suffix. One line yields one
  244. // event, or a whole run for a packed chunk row; a row-tagged line that fails
  245. // row validation is a hole, exactly like unparsable JSON.
  246. interface Parsed { ok: boolean; events?: SessionEvent[]; endByte: number }
  247. const parsed: Parsed[] = eventEntries.map((entry) => {
  248. try {
  249. return { ok: true, events: decodeStorageRecord(JSON.parse(entry.text)), endByte: entry.endByte }
  250. } catch {
  251. return { ok: false, endByte: entry.endByte }
  252. }
  253. })
  254. // The last index (into eventEntries) that ends in a valid `turn/end`. A hole
  255. // before this boundary cannot be a torn final suffix because later execution
  256. // already closed. Standalone events after it remain part of the preserved
  257. // contiguous prefix. A packed row never stores a turn/end, so only
  258. // single-event lines can match.
  259. let lastTurnEnd = -1
  260. for (let i = parsed.length - 1; i >= 0; i--) {
  261. const p = parsed[i]
  262. if (p?.ok && p.events?.some(e => e.type === 'turn/end')) { lastTurnEnd = i; break }
  263. }
  264. // Preserve the contiguous prefix, including a complete interrupted turn;
  265. // holes through the last committed boundary throw, while later holes stop.
  266. // Contiguity is a cursor over seqs (not the line index): a packed row
  267. // advances the cursor by its whole run.
  268. const preserved: SessionEvent[] = []
  269. let lastPreservedLine = -1
  270. scan: for (let i = 0; i < parsed.length; i++) {
  271. const p = parsed[i]
  272. if (!p?.ok || p.events === undefined) {
  273. if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at line ${i + 1}`)
  274. break // torn tail fragment after the last turn/end — stop, tolerate
  275. }
  276. for (const event of p.events) {
  277. if (event.seq !== preserved.length) {
  278. if (i <= lastTurnEnd) {
  279. throw new Error(`corrupt session log: seq gap in committed region at line ${i + 1} (expected ${preserved.length}, got ${event.seq})`)
  280. }
  281. break scan // gap after the last turn/end — torn tail, stop
  282. }
  283. preserved.push(event)
  284. }
  285. lastPreservedLine = i
  286. }
  287. // committedBytes = end of the last FULLY preserved line (header if none): the
  288. // next append truncates any torn bytes past this point before writing the
  289. // synthetic closers + new events. A line is preserved whole or not at all —
  290. // a mid-row seq gap discards the whole row, keeping the truncation offset on
  291. // a line boundary.
  292. const lastPreserved = parsed[lastPreservedLine]
  293. const committedBytes = lastPreserved !== undefined ? lastPreserved.endByte : headerEntry.endByte
  294. return { meta: fromHeaderLine(headerLine), events: preserved, committedBytes }
  295. }
  296. /**
  297. * Parse just the header line of a log into a {@link SessionHeader}, or
  298. * `undefined` if it is missing/not a header. Used by `list()` to read session
  299. * metadata WITHOUT parsing the whole log: a session picker scales with the
  300. * number of sessions, not the total size of every conversation.
  301. * @param firstLine - the first line of a log file (without its trailing newline).
  302. * @returns the parsed header, or `undefined` when the line is not a well-formed session header.
  303. */
  304. export function parseHeaderMeta(firstLine: string): SessionHeader | undefined {
  305. let parsed: unknown
  306. try {
  307. parsed = JSON.parse(firstLine)
  308. } catch {
  309. return undefined
  310. }
  311. if (!isHeaderLine(parsed)) return undefined
  312. return fromHeaderLine(parsed)
  313. }