Files
deepseek-harness/packages/session/session-persistence-jsonl/src/format.ts
T
Yixiang TuandTianyi Cui e0bd7e1960 feat(session): add developer changes using historical tool schemas
Session V4 reserves developer-role messages for incremental model-visible
session changes. The role, tool addition/removal blocks, and deferred-loading
marker need a durable representation before independent provider and UI
integrations can emit or consume them. Known developer events must match an
open turn and step; unsupported providers and UI consumers fail explicitly.

Request headers already capture full tool definitions. Persisting another
ToolSchema in each addition would duplicate Session information and create
two definitions that could drift. Additions therefore store toolName, and
the containing developer event uses headerSeq to select one earlier known
request/header for all its additions. A same-name replacement keeps its
historical definition identity through restart, fork and surface changes.
Removals require no definition copy, and ordinary developer messages omit
headerSeq. Generic sourceEventSeqs remains independent derivation metadata.

The rejected inline tool field is optional never with @persistenceReserved.
The schema extractor retains that uninhabited slot, so permitting a future
value requires a format bump instead of being mistaken for compatible
optional metadata. Unrelated optional fields remain additive and readable.

Native and Session admission reject missing, forward, non-header, unknown,
ambiguous and incomplete referenced definitions, and reject the retired
inline tool field. Validate complete schema fields only for referenced
definitions, preserving unrelated header behavior and extra JSON metadata.
Unknown ignorable records remain opaque. Empty developer nodes retain their
surface position without producing a message or shadowing the system head.
No automatic emitter, provider activation, UI implementation, or speculative
public resolver is added.

Keep the core-owned developer source binding beside the introduced role,
including the request-only type rejection and attribution round trips. Update
consumer guards, SDK/snapshot handling, bilingual owner documentation, and
the same unreleased V4 acknowledgement. Keep earlier tool/source migration
work and its documentation in the parent. Preserve the existing recorded
Cordis inspection generation from the original developer change.

Integration keeps the final source layer as the sole parent and preserves
master's Messages input filtering, empty-user omission, empty tool results,
and system-update placement. Developer/deferred guards still run before
conversion. Native V4 admission, header references, reservation declarations,
and generic tooling match the validated marker candidate. Parent-owned
fixture and generator fixes remain in the parent.

Earlier feature validation passed full build, full lint, 41 doc-sync gates,
and 16 hygiene gates. The unchanged native implementation retains that evidence.
The focused Session/V4/request-input run passes 884 tests across 38 files with
100% statement, branch, function and line coverage for the V3-to-V4 package
and Session surface. Consumer tests pass 482 with one existing skip; tool,
message and optional-field tests pass 146. Cordis inspection and compaction
replays pass in both source and built modes (two cases per mode). Ten invalid
referenced-definition/unknown-header controls failed before their scoped
guards and pass after them.

Reservation validation: the actual generated developer root records tool as
optional primitive never. Widening it requires a bump, while traceId remains
additive and preserved; all 85 focused reader/classifier tests pass.

Final-parent validation: 205 tests across eight provider, type, catalog,
Session and native-format files pass. The preserved parent fixture verifies
Messages input-history replay; runtime/declaration and parent-owned-file
comparisons show no loss during the final rebase.

Co-authored-by: Tianyi Cui <[email protected]>
2026-09-18 19:08:11 +08:00

539 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,
KNOWN_SESSION_EVENT_TYPES,
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 { assertV4RowAdmission, assertReleasedV4Relationships } from '@deepseek-ai/dsh-session-format-v3-to-v4'
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()
assertReleasedV4Relationships(artifact, KNOWN_SESSION_EVENT_TYPES)
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 {
assertV4RowAdmission(decoded, KNOWN_SESSION_EVENT_TYPES)
} 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 current-format 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()
}