format.ts 21 KB

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