/** * transcript-journal.ts, WAL-style append-only transcript journal. * * Purpose * ─────── * Between full snapshots (written by persistConversation / writeRecoveryFile), * a SIGKILL loses every conversation turn since the last snapshot. This module * provides an append-only journal that records each durable conversation event * (user message submitted, assistant turn finalised, tool results appended, * compaction performed) so that a kill at any moment loses at most the * in-flight append, never a full turn. * * File format (NDJSON) * ──────────────────── * Line 0 , header: { version: 1, sessionId: "...", createdAt: } * Line 1+, records: { type, seq, ts, messages: ConversationMessageSnapshot[] } * * The header carries the schemaVersion so that a reader from a future process * can gate on it (readVersioned convention: unknown version → quarantine). * * Durability / performance tradeoff * ────────────────────────────────── * appendRecord() performs one appendFileSync + one fsyncSync per call. * This means one fsync per durable conversation event (user message, * assistant turn, tool result batch, compaction). It does NOT fsync per * streaming token, the streaming path never calls appendRecord(). * * At typical usage (a few events per user turn), this is 2–6 fsyncs/min, * well within the durability/throughput envelope of any modern filesystem. * The tradeoff is explicit: we accept per-event write amplification in * exchange for at-most-one-record loss on SIGKILL. * * Recovery semantics * ────────────────── * 1. Read the header line. Gate on version, quarantine if unrecognised. * 2. Read subsequent lines until EOF. Stop at the first line that is not * valid JSON or lacks the expected shape. Quarantine the remainder of * the file from that point onward (rename to .unrecognized). Never crash. * 3. Return only records whose `ts` is strictly greater than the provided * `snapshotTimestamp` (i.e. events that occurred after the last snapshot). * 4. Caller replays the returned records in `seq` order atop the snapshot * to reconstruct the conversation, then writes a fresh snapshot and * calls `journal.rotate()` to truncate the journal. * * Rotation * ──────── * After a fresh snapshot is written, call journal.rotate() which deletes the * journal file. The next append will recreate it with a fresh header. * * Journal path convention * ─────────────────────── * /.goodvibes//transcript-.journal * This mirrors the recovery-file location (homeDirectory-scoped, not * workingDir-scoped) so all per-session durability artefacts live together. */ import type { ConversationMessageSnapshot } from '../core/conversation.js'; import type { SessionSurface } from './session-surface.js'; export declare const JOURNAL_SCHEMA_VERSION = 1; export interface JournalHeader { readonly version: typeof JOURNAL_SCHEMA_VERSION; readonly sessionId: string; readonly createdAt: number; } export type JournalEventType = 'user_message' | 'assistant_turn' | 'tool_results' | 'compaction'; export interface JournalRecord { /** Discriminator for the kind of durable event. */ readonly type: JournalEventType; /** Monotonically increasing sequence number (0-based, per journal file). */ readonly seq: number; /** Wall-clock timestamp (Date.now()) when the record was appended. */ readonly ts: number; /** Full conversation message snapshot at the time of the event. */ readonly messages: ConversationMessageSnapshot[]; } export interface ReplayResult { /** Records whose ts is strictly after snapshotTimestamp, in seq order. */ readonly records: JournalRecord[]; /** * True if the journal tail was corrupt (a partial write from a kill). * The corrupt tail has been quarantined; replay stopped at the last * good record. */ readonly hadCorruptTail: boolean; } export interface TranscriptJournal { /** * Append one durable event record and fsync it to disk. * * Best-effort: if the write fails (e.g. disk full), the error is swallowed *, the journal is durability-enhancing, never a hard requirement. */ appendRecord(type: JournalEventType, messages: ConversationMessageSnapshot[]): void; /** * Delete the journal file (called after a fresh snapshot is written). * The next appendRecord() will recreate the file with a fresh header. * Best-effort, silently swallows errors. */ rotate(): void; /** Absolute path to the journal file. */ readonly path: string; /** * Point this journal at a different session's file, resetting the sequence * counter and initialization flag. Used when the session a journal was * opened for is switched out from under it (e.g. `/session resume` or * `/session fork` reassigning `runtime.sessionId`), without this, the * journal keeps appending the NEW session's records into the OLD session's * file. Does not touch the old file on disk; it is simply no longer * written to. */ rebind(journalPath: string, sessionId: string): void; } /** * Create a TranscriptJournal for the given session. * * The journal file is created lazily on the first appendRecord() call. * Calling openTranscriptJournal() does not perform any I/O. */ export declare function openTranscriptJournal(journalPath: string, sessionId: string): TranscriptJournal; /** * Build the canonical journal path for a session. * * Both the home directory and the scope segment come off the caller's * SessionSurface, so the journal lands beside the sessions and recovery * snapshots it fills gaps for rather than under a separately-spelled scope. * * @param surface The app's declare-once session-storage handle. * @param sessionId The session identifier. */ export declare function journalPathFor(surface: SessionSurface, sessionId: string): string; /** * Replay journal records that post-date `snapshotTimestamp`. * * Returns an empty result if the journal file does not exist. * Corrupt tail lines (partial write from a kill) are quarantined; replay * stops at the first unparseable line. * * @param journalPath Absolute path to the journal file. * @param snapshotTimestamp The `writtenAt` / `timestamp` of the last known * good snapshot. Only records with ts > this value * are returned. */ export declare function replayJournal(journalPath: string, snapshotTimestamp: number): ReplayResult; /** How long an untouched journal for a non-live session is kept: 7 days. */ export declare const JOURNAL_ORPHAN_MAX_AGE_MS: number; /** * Hard ceiling on reapable journals kept after the age rule, newest kept. * A burst of crashes inside the age window is still bounded. */ export declare const JOURNAL_ORPHAN_MAX_FILES = 50; export interface JournalReapResult { /** Journal files examined this sweep. */ readonly scanned: number; /** Journal files deleted this sweep. */ readonly reaped: number; } export interface JournalReapOptions { /** * Is this session open in a still-running process? Injected rather than * imported so this module keeps no dependency on the liveness marker, the * composition point (runtime/durability-housekeeping.ts) supplies the real * check, and tests supply their own. */ readonly isSessionLive: (sessionId: string) => boolean; /** The session this process is writing right now; never reaped. */ readonly currentSessionId?: string | null; readonly now?: () => number; /** Override the age window (tests). */ readonly maxAgeMs?: number; /** Override the count cap (tests). */ readonly maxFiles?: number; } /** * Delete transcript journals belonging to sessions that crashed and were never * resumed. * * A journal is reapable only when it is neither the current session's nor * apparently open in another running process. Of those, one is deleted when it * is empty (a zero-byte file holds no records, so nothing can be lost) or * untouched for longer than the age window; whatever survives both rules is * then capped by count, newest kept. * * The rules are deliberately mtime- and liveness-based, never parse-based: an * unparseable TAIL is the normal, expected shape of a journal killed * mid-append and is exactly the data replay is there to salvage, so a parse * failure must never make a journal reapable. * * Idempotent and concurrency-safe: a journal another sweeper unlinked between * the listing and this unlink (ENOENT) counts as reaped, not as an error. */ export declare function reapOrphanedJournals(surface: SessionSurface, options: JournalReapOptions): JournalReapResult; //# sourceMappingURL=transcript-journal.d.ts.map