/** * Crash-safe JSONL primitives for the session event stream. * * The 06:32 disk-full incident showed what an unguarded `appendFileSync` does * under ENOSPC: the kernel short-writes, the already-written prefix stays on * disk, and the next successful append lands directly on it — fusing a partial * record with a valid later one into a single unparseable line. These helpers * make an append all-or-nothing, keep a damaged byte range from ever swallowing * a later record, and describe damage rather than quietly dropping it. * * Single-writer assumption: rollback truncates back to the size observed at the * start of the append, so exactly one process may append to a given path. */ /** The syscall surface an append needs, injectable so faults can be forced deterministically. */ export interface LogIo { openSync(path: string, flags: string, mode?: number): number; fstatSync(fd: number): { size: number; }; readSync(fd: number, buffer: Buffer, offset: number, length: number, position: number): number; writeSync(fd: number, buffer: Buffer, offset: number, length: number): number; ftruncateSync(fd: number, length: number): void; closeSync(fd: number): void; } export declare const nodeLogIo: LogIo; export declare class AtomicAppendError extends Error { readonly rollbackFailed: boolean; readonly cause?: unknown | undefined; constructor(message: string, rollbackFailed: boolean, cause?: unknown | undefined); } export interface AppendResult { /** True when a dangling partial record was closed off before this line was written. */ repairedBoundary: boolean; } export interface AppendOptions { io?: LogIo; /** Total attempts, including the first. Bounded so a full disk cannot spin. */ maxAttempts?: number; mode?: number; } /** * Append one line, all or nothing. A failed write is rolled back to the byte * length observed before it started, so a partial record never persists; if the * file already ends mid-record (damage from before this fix), the line is put on * a fresh line so it cannot fuse with the damaged bytes. */ export declare function appendLineAtomic(path: string, line: string, options?: AppendOptions): AppendResult; export type DamageReason = 'truncated_tail' | 'interior_corruption'; export interface DamagedLine { /** 1-based line number in the file as read. */ lineNumber: number; /** True byte length of the damaged line, even when `raw` is capped. */ bytes: number; reason: DamageReason; /** The damaged bytes, capped for storage; `truncatedEvidence` says when. */ raw: string; truncatedEvidence: boolean; } /** A run of sequence numbers that is simply gone — never recoverable, only reportable. */ export interface SequenceGap { afterSeq: number; beforeSeq: number; missing: number; } export interface LogRecord { version: 1; seq: number; [key: string]: unknown; } export interface ReadLogResult { /** Records this version understands and can replay. */ records: LogRecord[]; /** * Sequence numbers claimed by every readable entry in file order, whatever * its version. A record written by a newer version is not replayable here, * but it did occupy its number: counting it is what keeps a forward-compatible * file from looking like it has a hole, and keeps continuation past it. */ sequence: number[]; /** Highest sequence still readable anywhere in the file, for monotonic continuation. */ maxSeq: number; damaged: DamagedLine[]; gaps: SequenceGap[]; } /** * Read the log line by line. One bad line costs exactly that line: everything * before and after it is kept, and the damage is described rather than dropped. */ export declare function readLog(path: string): ReadLogResult; /** * Missing sequence numbers between consecutive records. Records that parse * perfectly still leave a hole when the writes between them never landed, so * this is computed over whatever ordered run the caller cares about — including * a rotated stream followed by its live successor, where the hole falls on the * boundary and neither file can see it alone. */ export declare function findSequenceGaps(sequence: number[]): SequenceGap[]; export interface QuarantineResult { quarantined: boolean; /** Present when the sidecar could not be written; the source file is untouched either way. */ error?: string; sidecar: string; } /** * Copy damaged bytes to a sidecar for later forensics. The damaged file itself * is never read-modify-written, and a sidecar that cannot be created is reported * rather than retried into the event writer. */ export declare function quarantineDamage(path: string, damaged: DamagedLine[], source?: string): QuarantineResult;