/** * Run trace store: append-only `/traces/.jsonl` files * plus a small `index.json` for listing and ref lookups. * * - Bookkeeping (index, refs, size cap) is synchronous and in memory; file * writes are chained on one promise and never awaited by callers. * - A trace takes at most MAX_EVENTS_PER_TRACE events / MAX_BYTES_PER_TRACE * bytes; the event that would cross a limit becomes one `trace.truncated` * marker and later events are dropped. * - Traces idle for RETENTION_MS are deleted by a sweep that runs on the first * write after boot and then at most once per SWEEP_INTERVAL_MS; the same * sweep (and the disk janitor) prunes the oldest traces when the folder is * over MAX_TOTAL_BYTES. * - Nothing here throws to a caller: a failed write is logged and counted. * * specs/2026-10-03-run-traces.md * * @module services/trace/trace-store */ import { type TraceEvent, type TraceIndexEntry, type TraceRefKind, type TraceRoot, type TraceRootKind, type TraceTags } from './trace.types.js'; /** File operations the store uses (injectable to simulate failures). */ export interface TraceFsOps { mkdir(dir: string, opts: { recursive: true; }): Promise; appendFile(file: string, data: string): Promise; writeFile(file: string, data: string): Promise; rename(from: string, to: string): Promise; readFile(file: string): Promise; unlink(file: string): Promise; readdir(dir: string): Promise; stat(file: string): Promise<{ mtimeMs: number; size?: number; }>; /** Temp file + fsync + rename. Absent on a fake: writeFile + rename is used instead. */ writeFileAtomic?(file: string, data: string): Promise; } /** Options (all optional; tests override limits and the clock). */ export interface TraceStoreOptions { /** Traces folder (default `/traces`, resolved on first use) */ dir?: string; now?: () => Date; fs?: TraceFsOps; maxEvents?: number; maxBytes?: number; retentionMs?: number; sweepIntervalMs?: number; indexFlushDelayMs?: number; /** Most bytes the whole folder may take (default {@link defaultMaxTotalBytes}) */ maxTotalBytes?: number; } /** What {@link TraceStore.enforceTotalCap} found and did. */ export interface TraceDiskResult { /** Bytes in the traces folder before pruning */ bytes: number; /** Trace files in it */ files: number; /** The cap applied */ capBytes: number; /** Traces deleted (or that would be, on a dry run) to get under the cap */ prunedTraces: number; /** Bytes those took */ freedBytes: number; } /** * Cap on the traces folder: `CREWLY_TRACES_MAX_TOTAL_MB`, else * {@link TRACE_CONSTANTS.MAX_TOTAL_BYTES}. * * @returns Bytes */ export declare function defaultMaxTotalBytes(): number; /** Filters of {@link TraceStore.list}. */ export interface TraceListFilter { /** Only traces with an event at or after this time */ since?: Date; /** Only this root kind */ rootKind?: TraceRootKind; /** Only traces tagged with this autopilot project (specs/2026-10-03-autopilot-experiments.md) */ autopilotProjectId?: string; /** Only traces whose autopilot tag has this day (YYYY-MM-DD) */ day?: string; /** Only traces tagged with this ticket label */ label?: string; limit?: number; } /** One page of a trace. */ export interface TracePage { root: TraceRoot; events: TraceEvent[]; /** Events in the file */ total: number; offset: number; limit: number; truncated: boolean; } /** Result of a retention sweep. */ export interface TraceSweepResult { removedTraces: number; removedFiles: number; } /** * The trace store. One per process (see {@link getTraceStore}). */ export declare class TraceStore { private readonly logger; private readonly fs; private readonly now; private readonly maxEvents; private readonly maxBytes; private readonly retentionMs; private readonly sweepIntervalMs; private readonly indexFlushDelayMs; private readonly maxTotalBytes; private readonly explicitDir?; private resolvedDir; private index; private chain; private indexTimer; private dirReady; private sweepScheduled; private sweeping; private failures; /** * @param options - Folder, clock, limits, file ops */ constructor(options?: TraceStoreOptions); /** * The traces folder. * * @returns Absolute path */ get dir(): string; /** Writes that failed since the store was created. */ get writeFailures(): number; /** * Register a new trace and write its `trace.root` event. * * @param root - The root (its traceId must be new and well-formed) * @returns True when registered */ createRoot(root: TraceRoot): boolean; /** * Append one event. Unknown traces and events past the cap are dropped. * * @param event - The event * @returns True when it was queued for writing */ append(event: TraceEvent): boolean; /** * Remember which trace an entity belongs to. The first link wins: an * entity is never moved to another trace. * * @param kind - Entity kind * @param id - Entity id * @param traceId - Trace */ linkRef(kind: TraceRefKind, id: string, traceId: string): void; /** * Tag a trace (index only). The autopilot tag is set once (the first tag * wins: a ticket's trace keeps the day its work started); labels are * merged, case-insensitively, up to MAX_TAG_LABELS. * * @param traceId - Trace * @param tags - Tags to add * @returns True when the entry changed */ tag(traceId: string, tags: TraceTags): boolean; /** * The trace an entity belongs to. * * @param kind - Entity kind * @param id - Entity id * @returns Trace id, or null */ traceByRef(kind: TraceRefKind, id: string | null | undefined): string | null; /** * Whether a trace exists. * * @param traceId - Trace id * @returns True when indexed */ has(traceId: string): boolean; /** * The index entry of a trace. * * @param traceId - Trace id * @returns Copy of the entry, or null */ getEntry(traceId: string): TraceIndexEntry | null; /** * Traces, most recently active first. * * @param filter - Since / root kind / limit * @returns Index entries (copies) */ list(filter?: TraceListFilter): TraceIndexEntry[]; /** * Every trace tagged with an autopilot project (no row limit: the stats * read a whole window). Most recently active first. * * @param filter - `autopilotProjectId` (required), since / day / label * @returns Index entries (copies); empty without a project * @throws When the index cannot be read */ listTagged(filter: TraceListFilter & { autopilotProjectId: string; }): TraceIndexEntry[]; /** * One page of a trace's events, oldest first. Waits for queued writes. * * @param traceId - Trace id * @param offset - Events to skip * @param limit - Page size * @returns The page, or null when the trace is unknown */ read(traceId: string, offset?: number, limit?: number): Promise; /** * Every event of a trace in one file read and one parse (metrics, timeline, * summary). A torn last line (crash mid-append) is skipped. * * @param traceId - Trace id (validated by the caller) * @returns Root, all events and the truncated flag, or null when unknown */ readAll(traceId: string): Promise<{ root: TraceRoot; events: TraceEvent[]; truncated: boolean; } | null>; /** * Delete traces whose last event is older than the retention, their refs, * and orphan `.jsonl` files older than the retention. * * @returns What was removed */ sweep(): Promise; /** * Size of the traces folder, and — unless `dryRun` — delete the least * recently active traces until it is under the cap. Run by the daily sweep * and the disk janitor. Never throws. * * @param opts.capBytes - Cap (default: the store's) * @param opts.dryRun - Only report what would be pruned * @returns Size and what was pruned */ enforceTotalCap(opts?: { capBytes?: number; dryRun?: boolean; }): Promise; /** * Wait for queued writes and write the index now. */ flush(): Promise; /** * Wait for queued writes and any automatic sweep in progress (tests / shutdown). */ idle(): Promise; /** * Stop the index timer (tests / shutdown). Pending writes still finish. */ dispose(): void; /** * Path of a trace's event file. * * @param traceId - A well-formed id * @returns Absolute path */ private fileOf; /** * The in-memory index, read from disk on first use. * * @returns The index */ private loadIndex; /** Create the folder once. */ private ensureDir; /** Write the index atomically (temp file + fsync + rename). */ private writeIndex; /** Write the index after a short delay, once per burst of changes. */ private scheduleIndexFlush; /** Run the retention sweep when it is due (first write after boot, then daily). */ private maybeSweep; /** * Chain a write after the previous one; a failure is logged and counted. * * @param op - The write * @param what - Label for the log */ private enqueue; /** * Count and log (debug) a failure. * * @param what - Operation * @param err - Error */ private noteFailure; } /** * The process-wide trace store. * * @returns The store (created on first use) */ export declare function getTraceStore(): TraceStore; /** * Replace the process-wide store (tests). * * @param store - Store, or null to rebuild the default lazily */ export declare function setTraceStoreForTesting(store: TraceStore | null): void; //# sourceMappingURL=trace-store.d.ts.map