Files
deepseek-harness/packages/session/session-persistence-jsonl/src/format.ts
T
Tianyi Cui 78f03ffef9 fix(session): cover V3 admission and keep compressed fixture migratable
Keep the synthetic compressed historical fixture step-first without changing frozen generation data or shared fixtures. Pin its migrated empty system head and unchanged source bytes. Exercise malformed native system rows before and after recoverable damage through scanner and read/write handles. Remove the unreachable duplicate unsupported-format catch after current-row admission; preserve its owned rejection and generic error path.

Validation: original zstd regression red; admission-bypass negative control 3/3 red; five focused specs 314/314 pass with format.ts 100% statements/branches/functions/lines. Final admission cleanup 15/15 pass. Full build-backed lint found one matcher typing error; exact identifier oracle replaces it and focused type-aware lint passes.
2026-09-07 13:14:18 +08:00

537 lines
21 KiB
TypeScript

/**
* 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 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<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 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(eventLine).join('\n')
}
/**
* Serialize one v2 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()
}