/** * 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 { decodeSeqRanges, encodeSeqRanges, 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 } from '@deepseek-ai/dsh-session-format' 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 v2 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([...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 { type: 'session', version: header.version, id: header.id, createdAt: header.createdAt, ...header.cwd !== undefined ? { cwd: header.cwd } : {}, ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {}, isSeeded: header.isSeeded, ...header.origin !== undefined ? { origin: header.origin } : {}, delegationDepth: header.delegationDepth ?? 0, ...header.agentPreset !== undefined ? { agentPreset: header.agentPreset } : {}, } } /** * 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 v2 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(record => JSON.stringify(encodeProvenanceForStorage(record))).join('\n') } /** * Losslessly shrink a record's `sourceEventSeqs` for the log: consecutive * runs of at least three seqs become `[start, end]` pairs, and any other list * stays verbatim. * @param record - one stored record (event or packed row). * @returns the record with its provenance in storage form (widened from the * in-memory `SessionSeq[]`; {@link expandProvenanceFromStorage} restores it). */ function encodeProvenanceForStorage(record: SessionEvent): unknown { if (!('sourceEventSeqs' in record)) return record return { ...record, sourceEventSeqs: encodeSeqRanges(record.sourceEventSeqs) } } /** * Expand a parsed line's storage-form provenance back to `SessionSeq[]`. * @param parsed - the JSON-parsed value of one stored line. * @returns the value with provenance expanded. * @throws when the record or its storage-form provenance is malformed. */ function expandProvenanceFromStorage(parsed: unknown): unknown { if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { throw new TypeError('stored session records must be objects') } const record = parsed as { seq?: unknown; sourceEventSeqs?: unknown } if (record.sourceEventSeqs === undefined) return parsed if (!Number.isSafeInteger(record.seq) || (record.seq as number) < 0) { throw new TypeError('stored session event seq must be a non-negative safe integer') } return { ...record, sourceEventSeqs: decodeSeqRanges(record.sourceEventSeqs, record.seq as number) } } interface SessionLogScan { meta: SessionHeader inheritedEventCount: SessionLogOffsetType events: SessionEvent[] committedBytes: number } /** Derive the v2 fork cut from the last lineage-tagged seed marker. */ function inheritedCut(meta: SessionHeader, events: readonly SessionEvent[]): SessionLogOffsetType { let cut: SessionLogOffsetType | undefined for (const event of events) { if (event.type === 'session/end-seed' && event.data.inherited === true) cut = SessionLogOffset(event.seq) } if (meta.isSeeded && cut === undefined) { throw new Error('corrupt session log: seeded v2 header lacks an inherited end-seed marker') } if (!meta.isSeeded && cut !== undefined) { throw new Error('corrupt session log: unseeded v2 header contains an inherited end-seed marker') } return cut ?? SessionLogOffset(0) } /** * 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: unknown): void { if (typeof parsed !== 'object' || parsed === null) return 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): ReturnType { 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') } refuseForeignFormatVersion(parsed) assertNoRetiredHeaderFields(parsed) if (!isHeaderLine(parsed)) { throw new Error('corrupt session log: first line is not a session header') } return fromHeaderLine(parsed) } /** * 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 events: SessionEvent[] = [] 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) { const parsed = parseHeaderRecord(headerRecord) this.meta = parsed.meta 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.events.length), } } /** * 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 return { meta: this.meta, inheritedEventCount: inheritedCut(this.meta, this.events), events: this.events, 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: SessionEvent[] try { decoded = [expandProvenanceFromStorage(JSON.parse(line.toString('utf8'))) as SessionEvent] } catch { this.issue ??= new Error(`corrupt session log: unparsable committed event at line ${this.eventLine}`) return } if (this.issue !== undefined) { if (decoded.some(event => event.type === 'turn/end')) throw this.issue return } const rowStart = this.events.length for (const event of decoded) { if (event.seq !== this.events.length) { const expected = this.events.length this.events.length = rowStart this.issue = new Error( `corrupt session log: seq gap in committed region at line ${this.eventLine} ` + `(expected ${expected}, got ${event.seq})`, ) if (decoded.some(candidate => candidate.type === 'turn/end')) throw this.issue return } this.events.push(event) } 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() }