/** * Process-local activity ledger. * * The event path is deliberately dumb: an arriving event touches ONE cell * of a per-session row (the freshest timestamp seen for that clock), and * nothing else. No threshold math, no timer churn, no bucket arithmetic — * events are O(1) table writes. All deriving happens in the **bucket**: the * view's fixed-cadence ticker, which reads the ledger + the host's durable * store, computes the current state, and notices edge transitions. A * transition may therefore wait at most one cadence to be seen — accepted * by design; the bucket stays running and at rest either way. * * Three clocks per session: * * - `promptAtMs` — the last **user prompt** (`session.inbox.enqueued`; * durably seeded by the store's `time.updated`); * - `partAtMs` — the last **LLM part of any kind** (new thought, new tool, * streamed text, …; durably seeded by the store's `time.idle`, the last * turn's final part); * - `settledAtMs` + `outcome` — the last turn **settle point** (the * settled-clock terminal, kept for markers and the single-clock * fallback). * * Plain mutable state (no Effect, no Solid): views read it inside their own * tick signals, so reactivity stays at the view boundary. Every cell is * monotone — stale, replayed, or out-of-order events never wind a clock * back. */ /** How the last turn of a session ended (mirrors the host's `outcome`). */ export type Outcome = "succeeded" | "failed" | "interrupted"; /** Which clock an activity touch advances. */ export type Clock = "prompt" | "part"; /** A session's ledger row: the freshest timestamp seen per clock. */ export interface SessionActivity { readonly promptAtMs?: number; readonly partAtMs?: number; readonly settledAtMs?: number; readonly outcome?: Outcome; } /** One observed turn-settling: a session, a settle point, and how it ended. */ export interface SettleRecord { readonly sessionID: string; readonly atMs: number; readonly outcome: Outcome; } export class ActivityTracker { private readonly bySession = new Map(); /** * Touch a clock. Monotone: a stale or replayed event never winds the * clock back; equal timestamps are no-ops. */ touch(sessionID: string, clock: Clock, atMs: number): void { if (!sessionID || !Number.isFinite(atMs) || atMs <= 0) return; const row = this.bySession.get(sessionID) ?? {}; const known = clock === "prompt" ? row.promptAtMs : row.partAtMs; if (known !== undefined && atMs <= known) return; const next: SessionActivity = clock === "prompt" ? { ...row, promptAtMs: atMs } : { ...row, partAtMs: atMs }; this.bySession.set(sessionID, next); } /** * Record a turn settling. The settle point and its outcome are monotone * (a stale terminal never rewinds them); the terminal is also the turn's * last LLM evidence, so it advances the part clock too. */ settle(record: SettleRecord): void { const { sessionID, atMs, outcome } = record; if (!sessionID || !Number.isFinite(atMs) || atMs <= 0) return; const row = this.bySession.get(sessionID) ?? {}; const settles = row.settledAtMs === undefined || atMs >= row.settledAtMs; this.bySession.set(sessionID, { ...row, settledAtMs: settles ? atMs : row.settledAtMs, outcome: settles ? outcome : row.outcome, partAtMs: row.partAtMs === undefined || atMs > row.partAtMs ? atMs : row.partAtMs, }); } /** A session's ledger row, if anything has been recorded. */ activityFor(sessionID: string | undefined): SessionActivity | undefined { return sessionID ? this.bySession.get(sessionID) : undefined; } }