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]>
539 lines
21 KiB
TypeScript
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()
|
|
}
|