| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536 |
- /**
- * On-disk format helpers for the JSONL session-persistence backend: path
- * sanitization (a {@link SessionId} is an unvalidated branded string, so it
- * MUST be encoded before use in a path — no traversal, no collision), the
- * per-project/session directory layout, header-line (de)serialization, and the
- * truncation-repair offset computation.
- *
- * @module dsh-session-persistence-jsonl/format
- */
- import { isAbsolute, join } from 'node:path'
- import {
- SESSION_FORMAT_VERSION,
- SessionLogOffset,
- } from '@deepseek-ai/dsh-session'
- import type {
- SessionEvent,
- SessionHeader,
- SessionId,
- SessionLogOffset as SessionLogOffsetType,
- } from '@deepseek-ai/dsh-session'
- import { parseSessionFormatLogFilename, sessionFormatLogFilename, SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format'
- import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
- import type { SessionFormatRecovery, SessionFormatRestore } from '@deepseek-ai/dsh-session-format'
- import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
- import { assertV3RowAdmission } from '@deepseek-ai/dsh-session-format-v2-to-v3'
- import {
- SessionFormatUnsupportedError,
- sessionFormatVersionRefusal,
- type SessionStorageMetadata,
- } from '@deepseek-ai/dsh-session-persistence'
- /** Physical encoding selected for JSONL session artifacts. */
- export type JsonlCompression = 'zstd' | 'none'
- /**
- * Return the artifact suffix for one physical encoding.
- * @param compression - configured JSONL artifact encoding.
- * @returns `.jsonl.zstd` for Zstandard or `.jsonl` for plaintext.
- */
- export function logSuffix(compression: JsonlCompression): '.jsonl.zstd' | '.jsonl' {
- return `.jsonl${compressionSuffix(compression)}`
- }
- function compressionSuffix(compression: JsonlCompression): '.zstd' | '' {
- return compression === 'zstd' ? '.zstd' : ''
- }
- /**
- * Return the canonical filename for one immutable Session format generation.
- * Version zero retains the original suffix-only name; every later generation
- * carries a lowercase numeric `vN` component.
- * @param version - non-negative safe Session format version.
- * @param compression - configured JSONL artifact encoding.
- * @returns the generation filename inside one Session directory.
- */
- export function generationLogFilename(version: number, compression: JsonlCompression): string {
- return `${sessionFormatLogFilename(version)}${compressionSuffix(compression)}`
- }
- /**
- * Parse one canonical generation filename for the selected physical encoding.
- * Noncanonical, temporary, uppercase, leading-zero, and version-zero-tagged names do
- * not identify committed generations.
- * @param filename - one entry from a Session directory.
- * @param compression - configured JSONL artifact encoding.
- * @returns its format version, or `undefined` when the name is not canonical.
- */
- export function parseGenerationLogFilename(
- filename: string,
- compression: JsonlCompression,
- ): number | undefined {
- const suffix = compressionSuffix(compression)
- if (!filename.endsWith(suffix)) return undefined
- return parseSessionFormatLogFilename(filename.slice(0, filename.length - suffix.length))
- }
- /**
- * The current physical header stored as the first JSONL record. The exact
- * inherited cut lives on the last tagged `session/end-seed` event.
- */
- interface HeaderLine {
- type: 'session'
- version: number
- id: SessionId
- createdAt: number
- cwd?: string
- parentSession?: SessionId
- isSeeded: boolean
- origin?: 'subagent'
- delegationDepth: number
- agentPreset?: string
- }
- const HEADER_REQUIRED_KEYS = ['type', 'version', 'id', 'createdAt', 'isSeeded', 'delegationDepth'] as const
- const HEADER_OPTIONAL_KEYS = ['cwd', 'parentSession', 'origin', 'agentPreset'] as const
- const HEADER_KEYS = new Set<string>([...HEADER_REQUIRED_KEYS, ...HEADER_OPTIONAL_KEYS])
- /**
- * Refuse policy fields that never belong to a released Session header.
- * @param value - parsed physical header candidate.
- * @returns nothing after successful validation.
- */
- export function assertNoRetiredHeaderFields(value: unknown): void {
- if (typeof value !== 'object' || value === null) return
- if (Object.hasOwn(value, 'sandboxMode') || Object.hasOwn(value, 'approvalPolicy')) {
- throw new Error('session header uses retired policy baseline fields')
- }
- }
- /**
- * Build the header line object from a {@link SessionHeader}.
- * @param header - the immutable session metadata to serialize.
- * @param inheritedEventCount - exact inherited prefix length; required for a
- * seeded header and omitted only for an unseeded header.
- * @returns the `type: 'session'`-tagged line object, absent optional fields omitted (never null).
- */
- export function toHeaderLine(
- header: SessionHeader,
- inheritedEventCount?: SessionLogOffsetType,
- ): HeaderLine {
- if (header.isSeeded && inheritedEventCount === undefined) {
- throw new Error('seeded session header requires an inherited event count')
- }
- const cut = SessionLogOffset(inheritedEventCount ?? 0)
- if (!header.isSeeded && cut !== 0) {
- throw new Error('unseeded session header inherited event count must be 0')
- }
- return sessionFormatCatalog.encodeCurrentHeader({
- ...header,
- delegationDepth: header.delegationDepth ?? 0,
- }, cut) as unknown as HeaderLine
- }
- /**
- * Translate one current physical header into logical metadata and its cut.
- * @param line - the shape-checked first line of a log (see the `isHeaderLine` guard).
- * @returns logical Session metadata paired with the exact inherited prefix length.
- */
- function fromHeaderLine(line: HeaderLine): SessionStorageMetadata {
- return {
- meta: {
- version: SESSION_FORMAT_VERSION,
- id: line.id,
- createdAt: line.createdAt,
- ...line.cwd !== undefined ? { cwd: line.cwd } : {},
- ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {},
- isSeeded: line.isSeeded,
- ...line.origin !== undefined ? { origin: line.origin } : {},
- delegationDepth: line.delegationDepth,
- ...line.agentPreset !== undefined ? { agentPreset: line.agentPreset } : {},
- },
- inheritedEventCount: SessionLogOffset(0),
- }
- }
- /** Type guard: a parsed first line is a well-formed session header. */
- function isHeaderLine(value: unknown): value is HeaderLine {
- return (
- typeof value === 'object' && value !== null && !Array.isArray(value)
- && HEADER_REQUIRED_KEYS.every(key => Object.hasOwn(value, key))
- && Object.keys(value).every(key => HEADER_KEYS.has(key))
- && (value as { type?: unknown }).type === 'session'
- && typeof (value as { version?: unknown }).version === 'number'
- && typeof (value as { id?: unknown }).id === 'string'
- && typeof (value as { createdAt?: unknown }).createdAt === 'number'
- && Number.isSafeInteger((value as { createdAt: number }).createdAt)
- && (value as { createdAt: number }).createdAt >= 0
- && !Object.is((value as { createdAt: number }).createdAt, -0)
- && typeof (value as { delegationDepth?: unknown }).delegationDepth === 'number'
- && Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth)
- && (value as { delegationDepth: number }).delegationDepth >= 0
- && !Object.is((value as { delegationDepth: number }).delegationDepth, -0)
- && ((value as { cwd?: unknown }).cwd === undefined
- || (typeof (value as { cwd?: unknown }).cwd === 'string'
- && isAbsolute((value as { cwd: string }).cwd)))
- && ((value as { parentSession?: unknown }).parentSession === undefined
- || typeof (value as { parentSession?: unknown }).parentSession === 'string')
- && typeof (value as { isSeeded?: unknown }).isSeeded === 'boolean'
- && ((value as { origin?: unknown }).origin === undefined
- || (value as { origin?: unknown }).origin === 'subagent')
- && ((value as { agentPreset?: unknown }).agentPreset === undefined
- || typeof (value as { agentPreset?: unknown }).agentPreset === 'string')
- )
- }
- /**
- * Encode an arbitrary string as a single safe path segment, injectively over ALL JS (UTF-16)
- * strings — including lone surrogates. A {@link SessionId} is an unvalidated branded string,
- * so this neutralizes `../`, absolute paths, NUL, and separators before any filesystem use.
- * Safe code units remain literal; every other unit, including `~`, becomes
- * `~XXXX`. Operating on code units preserves lone surrogates, while special-
- * casing `.` and `..` prevents traversal by an otherwise safe whole segment.
- *
- * @param raw - the string to encode; must be non-empty (throws on `''`).
- * @returns the escaped single path segment, decodable back to `raw`.
- */
- export function encodeSegment(raw: string): string {
- if (raw.length === 0) throw new Error('cannot encode an empty path segment')
- if (raw === '.') return '~002E'
- if (raw === '..') return '~002E~002E'
- let out = ''
- for (let i = 0; i < raw.length; i++) {
- const code = raw.charCodeAt(i)
- const ch = String.fromCharCode(code)
- if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
- out += ch
- } else {
- out += '~' + code.toString(16).toUpperCase().padStart(4, '0')
- }
- }
- return out
- }
- /**
- * Build the readable directory key for a project path.
- * Filesystem separators and drive separators become `-`; unsafe code units use
- * the same `~XXXX` escape as session ids. The key is bounded for filesystem
- * component limits. Separator replacement and truncation are intentionally
- * lossy, following the common human-navigable project-directory convention.
- * @param cwd - the session's project directory.
- * @returns a single filesystem-safe project directory name.
- */
- export function projectKey(cwd: string): string {
- if (cwd.length === 0) throw new Error('cannot encode an empty project path')
- let readable = ''
- let separatorRun = false
- for (let i = 0; i < cwd.length; i++) {
- const code = cwd.charCodeAt(i)
- const ch = String.fromCharCode(code)
- if (ch === '/' || ch === '\\' || ch === ':') {
- if (!separatorRun) readable += '-'
- separatorRun = true
- } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
- readable += ch
- separatorRun = false
- } else {
- readable += '~' + code.toString(16).toUpperCase().padStart(4, '0')
- separatorRun = false
- }
- }
- const slug = readable.replace(/^-+/, '') || 'root'
- return `--${slug.slice(0, 251)}--`
- }
- /**
- * The configured root's human-navigable project directory. A configured root
- * may be local or shared; this grouping does not prescribe its deployment.
- * @param root - the backend's session root directory.
- * @param cwd - the session's project directory; `undefined` selects `_no-cwd`.
- * @returns the project directory path under `root`.
- */
- export function projectDir(root: string, cwd: string | undefined): string {
- if (cwd === undefined) return join(root, '_no-cwd')
- return join(root, projectKey(cwd))
- }
- /**
- * The directory owned by one session and available for future session-local
- * artifacts.
- * @param root - the backend's session root directory.
- * @param cwd - the session's project directory.
- * @param id - the session id, encoded to one safe path segment.
- * @returns the session directory beneath its project directory.
- */
- export function sessionDir(root: string, cwd: string | undefined, id: SessionId): string {
- return join(projectDir(root, cwd), encodeSegment(id))
- }
- /**
- * Build one immutable Session format generation path.
- * @param root - the backend's session root directory.
- * @param cwd - the session's project directory (`undefined` → `_no-cwd`).
- * @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
- * @param version - physical Session format generation.
- * @param compression - physical artifact encoding and filename suffix.
- * @returns the selected generation's configured JSONL artifact path.
- */
- export function generationLogPath(
- root: string,
- cwd: string | undefined,
- id: SessionId,
- version: number,
- compression: JsonlCompression,
- ): string {
- return join(sessionDir(root, cwd, id), generationLogFilename(version, compression))
- }
- /**
- * Build the current generation's append target path for a Session.
- * @param root - the backend's session root directory.
- * @param cwd - the session's project directory (`undefined` → `_no-cwd`).
- * @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
- * @param compression - physical artifact encoding and filename suffix.
- * @returns the current Session format generation path.
- */
- export function logPath(
- root: string,
- cwd: string | undefined,
- id: SessionId,
- compression: JsonlCompression,
- ): string {
- return generationLogPath(root, cwd, id, SESSION_FORMAT_VERSION, compression)
- }
- /**
- * Serialize a current event batch as JSONL lines (no trailing newline). Compact
- * Assistant streams are nested event data; every event occupies one row.
- * @param events - the batch to serialize, in log order.
- * @returns the batch's JSONL text; the writer adds the final newline.
- */
- export function eventLines(events: readonly SessionEvent[]): string {
- return events.map(eventLine).join('\n')
- }
- /**
- * Serialize one current event as one JSONL record without its trailing newline.
- * @param event - current event to encode.
- * @returns one physical JSON record.
- */
- export function eventLine(event: SessionEvent): string {
- return JSON.stringify(sessionFormatCatalog.encodeCurrentEvent(event as unknown as SessionFormatEvent))
- }
- interface SessionLogScan {
- meta: SessionHeader
- inheritedEventCount: SessionLogOffsetType
- events: SessionEvent[]
- committedBytes: number
- }
- /**
- * Refuse a header carrying a format version this build does not read BEFORE
- * validating the current header shape or decoding any event row: a future
- * format need not satisfy this build's structural checks at all, and its user
- * must see "upgrade the harness", never "corrupt session log".
- * @param parsed - the JSON-parsed first line of a session artifact.
- */
- function refuseForeignFormatVersion(parsed: object): void {
- const { version, id } = parsed as { version?: unknown; id?: unknown }
- if (typeof version !== 'number' || version === SESSION_FORMAT_VERSION) return
- throw new SessionFormatUnsupportedError(
- sessionFormatVersionRefusal(typeof id === 'string' ? id : String(id), version),
- )
- }
- /** Parse one complete header record supplied independently from event rows. */
- function parseHeaderRecord(record: Buffer): { readonly meta: SessionHeader; readonly restore: SessionFormatRestore } {
- if (record.length === 0 || record.at(-1) !== 0x0A || record.indexOf(0x0A) !== record.length - 1) {
- throw new Error('empty or header-less session log')
- }
- let parsed: unknown
- try {
- parsed = JSON.parse(record.subarray(0, -1).toString('utf8'))
- } catch {
- throw new Error('corrupt session log: header line is not valid JSON')
- }
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
- throw new Error('corrupt session log: first line is not a JSON object')
- }
- refuseForeignFormatVersion(parsed)
- assertNoRetiredHeaderFields(parsed)
- if (!isHeaderLine(parsed)) {
- throw new Error('corrupt session log: first line is not a session header')
- }
- let restore: SessionFormatRestore
- try {
- restore = sessionFormatCatalog.createRestore(parsed, {
- recovery: 'strict',
- validation: 'transformed',
- })
- } catch {
- /* v8 ignore next -- isHeaderLine matches the current codec; this preserves classification if it tightens. */
- throw new Error('corrupt session log: first line is not a session header')
- }
- return { meta: fromHeaderLine(parsed).meta, restore }
- }
- /**
- * Incrementally scan complete JSONL event records after an independently
- * supplied header record. Newline search and byte offsets stay on raw buffers;
- * only complete records are decoded to UTF-8. A fragment crossing writes is
- * copied because a decoder may reuse its output buffer after `write()` returns.
- */
- export class SessionLogScanner {
- private readonly meta: SessionHeader
- private readonly restore: SessionFormatRestore
- private eventCount = 0
- private fragments: Buffer[] = []
- private fragmentBytes = 0
- private inputBytes: number
- private committedBytes: number
- private eventLine = 0
- private issue: Error | undefined
- private finished = false
- /**
- * Create an event scanner from exactly one newline-terminated header record.
- * @param headerRecord - the complete first JSONL record, including its newline.
- */
- constructor(
- headerRecord: Buffer,
- private readonly recovery: SessionFormatRecovery = 'recoverable',
- ) {
- const parsed = parseHeaderRecord(headerRecord)
- this.meta = parsed.meta
- this.restore = parsed.restore
- this.inputBytes = headerRecord.length
- this.committedBytes = headerRecord.length
- }
- /**
- * Consume the next raw plaintext chunk, retaining only an incomplete final record.
- * @param chunk - bytes immediately following all previously supplied bytes.
- */
- write(chunk: Buffer): void {
- if (this.finished) throw new Error('cannot write to a finished session log scanner')
- const chunkStart = this.inputBytes
- this.inputBytes += chunk.length
- let lineStart = 0
- for (
- let newline = chunk.indexOf(0x0A);
- newline !== -1;
- newline = chunk.indexOf(0x0A, lineStart)
- ) {
- const fragment = chunk.subarray(lineStart, newline)
- let line = fragment
- if (this.fragments.length > 0) {
- if (fragment.length > 0) this.fragments.push(fragment)
- line = Buffer.concat(this.fragments, this.fragmentBytes + fragment.length)
- this.fragments = []
- this.fragmentBytes = 0
- }
- this.consumeEventLine(line, chunkStart + newline + 1)
- lineStart = newline + 1
- }
- if (lineStart < chunk.length) {
- const fragment = Buffer.from(chunk.subarray(lineStart))
- this.fragments.push(fragment)
- this.fragmentBytes += fragment.length
- }
- }
- /**
- * Snapshot progress before appending a recoverable torn-frame prefix.
- * @returns byte, committed-prefix, and expanded-event cursors.
- */
- checkpoint(): {
- inputBytes: number
- committedBytes: number
- eventCount: SessionLogOffsetType
- } {
- return {
- inputBytes: this.inputBytes,
- committedBytes: this.committedBytes,
- eventCount: SessionLogOffset(this.eventCount),
- }
- }
- /**
- * Finish scanning, ignoring a final record without a newline as a torn tail.
- * @returns the header, contiguous event prefix, and safe truncation offset.
- */
- finish(): SessionLogScan {
- this.finished = true
- const artifact = this.restore.finish()
- return {
- meta: this.meta,
- inheritedEventCount: SessionLogOffset(artifact.inheritedEventCount),
- events: artifact.events as unknown as SessionEvent[],
- committedBytes: this.committedBytes,
- }
- }
- /** Decode one complete event row and update the contiguous prefix. */
- private consumeEventLine(line: Buffer, endByte: number): void {
- this.eventLine += 1
- let decoded: unknown
- try {
- decoded = JSON.parse(line.toString('utf8')) as unknown
- } catch {
- const issue = new Error(`corrupt session log: unparsable committed event at line ${this.eventLine}`)
- if (this.recovery === 'strict') throw issue
- this.issue ??= issue
- return
- }
- // This scanner accepts only current-generation files. Owned structural refusal must
- // precede its recoverable-tail suppression, independently of the strict decoder state.
- try {
- assertV3RowAdmission(decoded)
- } catch (error: unknown) {
- if (error instanceof SessionFormatUnsupportedMigrationError) throw new SessionFormatUnsupportedError(error.message)
- throw error
- }
- if (this.issue !== undefined) {
- if (typeof decoded === 'object' && decoded !== null
- && (decoded as { type?: unknown }).type === 'turn/end') throw this.issue
- return
- }
- try {
- this.restore.decodeRow(decoded)
- } catch (error: unknown) {
- // Unsupported V3 rows have already been refused before recovery.
- /* v8 ignore next -- every production Session format decoder rejects with Error. */
- const detail = error instanceof Error ? error.message : String(error)
- const issue = new Error(`corrupt session log: invalid committed event at line ${this.eventLine}: ${detail}`, {
- cause: error,
- })
- if (this.recovery === 'strict') throw issue
- this.issue = issue
- if (typeof decoded === 'object' && decoded !== null
- && (decoded as { type?: unknown }).type === 'turn/end') throw issue
- return
- }
- this.eventCount += 1
- this.committedBytes = endByte
- }
- }
- /**
- * Parse a complete or torn JSONL buffer into its preserved event prefix. This
- * compatibility wrapper supplies the first record separately, then delegates
- * event rows to {@link SessionLogScanner}.
- *
- * @param buffer - the raw bytes of the log file (header line first).
- * @returns the header, preserved event prefix, and byte offset safe to append at.
- */
- export function scanLog(buffer: Buffer): SessionLogScan {
- const headerEnd = buffer.indexOf(0x0A)
- if (headerEnd === -1) throw new Error('empty or header-less session log')
- const scanner = new SessionLogScanner(buffer.subarray(0, headerEnd + 1))
- scanner.write(buffer.subarray(headerEnd + 1))
- return scanner.finish()
- }
|