/** * KHAELOR * File: src/session/store.ts * Description: Append-only JSONL session log — serialized atomic appends, replay, torn-last-line recovery. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { appendFile, mkdir, readFile, truncate, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { KhaelorError, ulid } from "../shared/index.js"; import { EVENT_SCHEMA_VERSION, isDurableEventType } from "./events.js"; import type { DurableEvent, DurableEventInput } from "./events.js"; import type { DurableAppender } from "./bus.js"; /** Default sessions root (ARCHITECTURE.md §5.1). */ export function defaultSessionsDir(): string { return join(homedir(), ".khaelor", "sessions"); } export interface SessionLogCreateOptions { projectHash: string; sessionsDir?: string; sessionId?: string; // defaults to a fresh ULID } export interface SessionLogOpenOptions { projectHash: string; sessionId: string; sessionsDir?: string; } export interface TornLineRecovery { /** Byte offset the file was truncated to. */ truncatedTo: number; /** Diagnostics file containing the torn bytes. */ tornFile: string; } /** * Canonicalize a JSON-compatible value: object keys sorted recursively. * Applied to `ToolRequested.input` so replayed LLM history reproduces * identical bytes on every rebuild (EVENT_MODEL.md §5.1). */ export function canonicalizeJsonValue(value: unknown): unknown { if (Array.isArray(value)) return value.map(canonicalizeJsonValue); if (value !== null && typeof value === "object") { const source = value as Record; const out: Record = {}; for (const key of Object.keys(source).sort()) { out[key] = canonicalizeJsonValue(source[key]); } return out; } return value; } interface ParsedEnvelope { v: number; id: string; sessionId: string; seq: number; ts: number; type: string; payload: unknown; } function validateEnvelope(value: unknown): ParsedEnvelope | null { if (value === null || typeof value !== "object" || Array.isArray(value)) return null; const o = value as Record; if (typeof o["v"] !== "number") return null; if (typeof o["id"] !== "string" || o["id"].length === 0) return null; if (typeof o["sessionId"] !== "string" || o["sessionId"].length === 0) return null; if (typeof o["seq"] !== "number" || !Number.isInteger(o["seq"]) || (o["seq"] as number) < 1) return null; if (typeof o["ts"] !== "number") return null; if (typeof o["type"] !== "string" || o["type"].length === 0) return null; if (!("payload" in o)) return null; return o as unknown as ParsedEnvelope; } interface PairingEntry { requestedSeq: number; closedSeq: number | null; } /** * One append-only JSONL file per session — THE source of truth (ADR-3). * - `append` assigns the envelope synchronously (seq gapless, at enqueue) * and serializes the write through a per-session queue: one complete * line per write call, never interleaved (EVENT_MODEL.md §5.2). * - `open` replays with torn-last-line recovery (EVENT_MODEL.md §5.3). */ export class SessionLog implements DurableAppender { readonly sessionId: string; readonly filePath: string; /** Events replayed by `open()`; empty for a freshly created log. */ readonly replayedEvents: readonly DurableEvent[]; /** Set when `open()` recovered from a torn final line. */ readonly recovery: TornLineRecovery | null; private nextSeq: number; private queue: Promise = Promise.resolve(); private writeError: unknown = null; private closed = false; /** toolUseId → pairing bookkeeping for compaction-cut validation (EVENT_MODEL.md §6.5). */ private readonly pairing = new Map(); private constructor(args: { sessionId: string; filePath: string; replayedEvents: DurableEvent[]; recovery: TornLineRecovery | null; }) { this.sessionId = args.sessionId; this.filePath = args.filePath; this.replayedEvents = args.replayedEvents; this.recovery = args.recovery; const last = args.replayedEvents[args.replayedEvents.length - 1]; this.nextSeq = last ? last.seq + 1 : 1; for (const event of args.replayedEvents) this.track(event); } /** Next seq that will be assigned (last seq + 1). */ get seqCursor(): number { return this.nextSeq; } static async create(options: SessionLogCreateOptions): Promise { const sessionsDir = options.sessionsDir ?? defaultSessionsDir(); const sessionId = options.sessionId ?? ulid(); const dir = join(sessionsDir, options.projectHash); await mkdir(dir, { recursive: true }); const filePath = join(dir, `${sessionId}.jsonl`); // "wx": refuse to clobber an existing session log. await writeFile(filePath, "", { flag: "wx", encoding: "utf8" }); return new SessionLog({ sessionId, filePath, replayedEvents: [], recovery: null }); } static async open(options: SessionLogOpenOptions): Promise { const sessionsDir = options.sessionsDir ?? defaultSessionsDir(); const filePath = join(sessionsDir, options.projectHash, `${options.sessionId}.jsonl`); let buf: Buffer; try { buf = await readFile(filePath); } catch (error) { throw new KhaelorError("session-log-io", `Cannot open session log: ${filePath}`, { cause: String(error), }); } const events: DurableEvent[] = []; let offset = 0; let lastValidEnd = 0; let tornStart = -1; while (offset < buf.length) { const nl = buf.indexOf(0x0a, offset); if (nl === -1) { // Final bytes lack a trailing newline — torn write. tornStart = offset; break; } const line = buf.subarray(offset, nl).toString("utf8"); const parsed = SessionLog.parseLine(line); if (parsed === null) { if (nl === buf.length - 1) { // Invalid final complete line — treated as torn (EVENT_MODEL.md §5.3.1). tornStart = offset; break; } // Corruption anywhere other than the final line: refuse (no silent repair). throw new KhaelorError( "session-log-corrupted", `Session log corrupted before the final line (byte offset ${offset}): ${filePath}. ` + "Refusing to resume; inspect the file manually.", { filePath, offset }, ); } const expectedSeq = events.length + 1; if (parsed.seq !== expectedSeq) { throw new KhaelorError( "session-log-corrupted", `Session log seq gap at line ${events.length + 1}: expected seq ${expectedSeq}, found ${parsed.seq}: ${filePath}`, { filePath, expectedSeq, foundSeq: parsed.seq }, ); } events.push(parsed as unknown as DurableEvent); lastValidEnd = nl + 1; offset = nl + 1; } let recovery: TornLineRecovery | null = null; if (tornStart >= 0) { const tornFile = `${filePath}.torn`; await writeFile(tornFile, buf.subarray(tornStart)); await truncate(filePath, lastValidEnd); recovery = { truncatedTo: lastValidEnd, tornFile }; } return new SessionLog({ sessionId: options.sessionId, filePath, replayedEvents: events, recovery }); } private static parseLine(line: string): ParsedEnvelope | null { if (line.length === 0) return null; let value: unknown; try { value = JSON.parse(line); } catch { return null; } return validateEnvelope(value); } /** * Assign the envelope and enqueue the append. Synchronous by design: * seq is assigned at enqueue and is gapless; the write itself is * serialized behind all prior writes (write-ahead of bus publication). */ append(input: DurableEventInput): DurableEvent { if (this.closed) { throw new KhaelorError("store-write-failed", "Session log is closed"); } if (!isDurableEventType(input.type)) { throw new KhaelorError("invalid-event", `Not a durable event type: ${String(input.type)}`); } const payload = input.type === "tool.requested" ? { ...input.payload, input: canonicalizeJsonValue(input.payload.input) } : input.payload; if (input.type === "context.compacted") { this.validateCompactionCut(input.payload.cut); } // Envelope key order is fixed: v,id,sessionId,seq,ts,type,payload (EVENT_MODEL.md §5.1). const event = { v: EVENT_SCHEMA_VERSION, id: ulid(), sessionId: this.sessionId, seq: this.nextSeq++, ts: Date.now(), type: input.type, payload, } as DurableEvent; this.track(event); const line = JSON.stringify(event) + "\n"; this.queue = this.queue .then(() => appendFile(this.filePath, line, "utf8")) .catch((error: unknown) => { if (this.writeError === null) this.writeError = error; }); return event; } /** Await all enqueued appends; throws if any write failed. */ async flush(): Promise { await this.queue; if (this.writeError !== null) { throw new KhaelorError("store-write-failed", `Failed writing session log: ${this.filePath}`, { cause: String(this.writeError), }); } } async close(): Promise { this.closed = true; await this.flush(); } /** * Pairing bookkeeping: track open/closed tool_use ids so a compaction * cut can be validated before it is appended (EVENT_MODEL.md §6.5.3). */ private track(event: DurableEvent): void { switch (event.type) { case "tool.requested": this.pairing.set(event.payload.toolUseId, { requestedSeq: event.seq, closedSeq: null }); return; case "tool.completed": case "tool.failed": case "tool.cancelled": { const entry = this.pairing.get(event.payload.toolUseId); if (entry) entry.closedSeq = event.seq; return; } default: return; } } private validateCompactionCut(cut: { fromSeq: number; toSeq: number }): void { if (!Number.isInteger(cut.fromSeq) || !Number.isInteger(cut.toSeq) || cut.fromSeq > cut.toSeq) { throw new KhaelorError("pairing-violation", "context.compacted: invalid cut range", { cut }); } for (const [toolUseId, entry] of this.pairing) { const requestedInOrBefore = entry.requestedSeq <= cut.toSeq; const closedInOrBefore = entry.closedSeq !== null && entry.closedSeq <= cut.toSeq; // A tool_use at seq ≤ toSeq must have its tool_result at seq ≤ toSeq. if (requestedInOrBefore && !closedInOrBefore) { throw new KhaelorError( "pairing-violation", `context.compacted: cut would orphan tool_use ${toolUseId} (requested at seq ${entry.requestedSeq}, not closed within cut)`, { toolUseId, cut }, ); } // A tool_use before the cut start must not have its result consumed by the cut. if ( entry.requestedSeq < cut.fromSeq && entry.closedSeq !== null && entry.closedSeq >= cut.fromSeq && entry.closedSeq <= cut.toSeq ) { throw new KhaelorError( "pairing-violation", `context.compacted: cut would consume the tool_result of ${toolUseId} while keeping its tool_use`, { toolUseId, cut }, ); } } } }