/** * Append-only local sink for analytics events. Always written, regardless * of telemetry opt-in — this is the "demo inspects this" local debug file, * independent of whether anything gets batched to a remote collector. * * Concurrency: every append is serialized through a promise queue (the same * pattern as workflow-registry.ts and session-manager.ts). `runExclusive(fn)` * chains `fn` onto the same queue so retention sweeps (read→filter→rename) * never overlap with an in-flight append and no appended line can be lost * in a sweep's read window. Overhead is negligible — appends are low-frequency * (one per hook event), and the queue never holds more than O(sessions) entries. * * Reads (`read`, `index`) run deliberately OUTSIDE that queue: an append is a * single `appendFile` of one line, so a reader can never be left waiting out a * half-written record it would otherwise have to skip — the worst case is a * torn final line from a crash, which the parser drops. Blocking writers * behind a 50 MB scan to buy nothing would be the wrong trade. * * Index: reading one session's events must not rescan the whole file (capped * at 50 MB — see store-retention.ts). The index maps each harnessSessionId to * the byte spans its lines occupy. It is built lazily on the first read and * maintained incrementally after that: each subsequent read scans only the * bytes appended since the last one — by this process or by a second harness * boot writing to the same file, which is why the trigger is the file's size * rather than our own append path. A shrink or an inode change means the file * was rewritten underneath us (a retention sweep), which invalidates every * offset, so the index is rebuilt from scratch. */ import { type AnalyticsEvent, type AnalyticsEventType, type HarnessKind } from "../../shared/types.js"; /** A half-open byte range `[start, end)` of the ndjson file. */ export interface ByteSpan { start: number; end: number; } /** What the index knows about one harnessSessionId. */ export interface EventIndexEntry { harnessSessionId: string; /** Byte spans holding this session's lines, ascending, adjacent ones merged. */ spans: ByteSpan[]; /** Total events recorded for the session. */ eventCount: number; /** `prompt.submitted` events — the exact human-turn count, at any file size. */ turnCount: number; /** Agent session ids seen on this session's events, in first-seen order. */ agentSessionIds: string[]; /** Harness kind from the session's first event carrying one. */ harness: HarnessKind | null; /** ISO-8601 `ts` of the first / last event seen for the session. */ firstTs: string | null; lastTs: string | null; } export interface EventIndex { /** Keyed by harnessSessionId. */ bySession: ReadonlyMap; /** agentSessionId → harnessSessionIds that reported it, in first-seen order. */ byAgentSession: ReadonlyMap; } export interface EventReadFilter { /** One or more harnessSessionIds. Index-accelerated: only those byte spans * are read, never the whole file. */ harnessSessionId?: string | readonly string[]; /** Keep only these event types. Applied after parsing (the type isn't part * of the index key, so it saves parsing work for the caller, not I/O). */ types?: readonly AnalyticsEventType[]; } export interface EventStore { /** Append one event as a single ndjson line, serialized through the queue. */ append(event: AnalyticsEvent): Promise; /** * Run `fn` exclusively — after all pending appends complete and blocking * any new appends until `fn` resolves. Use this to run a retention sweep * without racing concurrent writes. * * A failed `fn` never poisons the queue (later appends proceed normally). * The return value of `fn` is forwarded to the caller. * * The byte-offset index is dropped on entry: a sweep rewrites the file, so * every offset it holds is presumed stale. */ runExclusive(fn: () => Promise): Promise; /** * Stream matching events in file order (= append order, which is only * roughly `ts` order; callers needing a strict order sort by `(ts, seq)` — * see core/session-record.ts). * * Runs outside the append queue. Unparseable lines — including a torn final * line from a crash mid-append — are skipped, never thrown on. Yields * nothing when the file doesn't exist yet. */ read(filter?: EventReadFilter): AsyncIterable; /** * The byte-offset index, built on first call and extended on later ones by * scanning only the bytes appended since. Reconciles against the file on * every call, so it is always as fresh as the moment it was asked for. Empty * when the file doesn't exist. */ index(): Promise; } /** Minimal read surface — what session-record.ts actually needs from a store. */ export type EventReader = Pick; /** * @param filePath Defaults to `HARNESS_PATHS.events` * (`~/.sapiom/harness/events.ndjson`). Override in tests. */ export declare function createEventStore(filePath?: string): EventStore; //# sourceMappingURL=store.d.ts.map