/** * BoundaryRecorder — unified domain event log for an agentfootprint run. * * The single source of truth Lens (and any other consumer) reads to * render a run. Every observable moment in a run is captured as one * `DomainEvent` in a single ordered stream: * * - `run.entry` / `run.exit` — top-level executor.run() * - `subflow.entry` / `subflow.exit` — every subflow boundary * - `fork.branch` — one per parallel child * - `decision.branch` — chosen branch of a Conditional * - `loop.iteration` — one per back-edge traversal * - `llm.start` / `llm.end` — LLM provider call lifecycle * - `tool.start` / `tool.end` — tool execution lifecycle * - `context.injected` — anything injected into a slot * * All events carry `runtimeStageId` (binds with footprintjs Trace view + * with each other), `subflowPath`, `depth`, and `ts` (wall-clock ms). * Subflow events are domain-tagged (`slotKind` / `primitiveKind` / * `isAgentInternal`) so consumers dispatch on tag without re-parsing. * * Architecture: * * ┌──── footprintjs (domain-agnostic) ────┐ * │ FlowRecorder events (run/subflow/ │ ──┐ * │ fork/decision/loop) │ │ * └───────────────────────────────────────┘ │ * │ * ┌──── agentfootprint dispatcher ─────────┐ │ consumed by * │ Typed events (llm/tool/context) │ ──┤ * └────────────────────────────────────────┘ │ * ▼ * ┌─── BoundaryRecorder ───┐ * │ one tagged stream of │ * │ DomainEvent │ * └────────────────────────┘ * │ * ▼ consumed by * ┌────── Lens (UI) ──────┐ * │ Slider / RunFlow / │ * │ NodeDetail / etc. │ * └───────────────────────┘ * * Why ONE recorder: Lens scrub axis, run-flow graph, slot rows inside * the LLM card, right-pane detail panel, commentary panel — every UI * surface reads from the SAME stream. Adding a new domain event = one * tagged emit + one render shape. No state machines spread across * renderers, no merging of multiple sources, no name-based filter lists. * * Naming: `runtimeStageId` is footprintjs's primitive (path-prefixed + * `#executionIndex`). `subflowPath` is rooted under the synthetic * `'__root__'`. `slotKind` / `primitiveKind` are agent-domain. The * design follows the React Fiber + OpenTelemetry pattern: * **producers self-describe; consumers dispatch on type**. * * WIRING — THREE PARTS, ALL AT RECORD TIME * ──────────────────────────────────────── * This recorder hears half a run per connection, and the half it misses * cannot be reconstructed afterwards. All three go on BEFORE `run()`: * * 1. `runner.attach(boundary)` — the footprintjs control-flow channel: * run / subflow entry and exit, forks, decisions, loops. Without it * there are no boundaries at all. * 2. `boundary.subscribe(runner)` — the agentfootprint typed stream: * `llm.*`, `tool.*`, `context.injected`. Without it the boundaries * have no content: no model calls, no tool calls, no injections. * 3. `{ getCommitCount }` — where each boundary sits on the commit * axis. Without it every event is stamped `commitIdxBefore: 0` and * the step strip cannot be rebuilt from the recording later. This * one fails SILENTLY — the events all look fine. * * There is no after-the-fact fix for any of the three: a completed run * leaves a snapshot and a commit log, and neither says where a boundary * opened relative to the commits. `recordRun(runner)` does all three and * hands back `{ snapshot, events, structure }` — reach for it first, and * wire by hand only when you need a shape it doesn't produce. * * @example * ```typescript * import { boundaryRecorder, EventDispatcher } from 'agentfootprint'; * * const boundary = boundaryRecorder({ * getCommitCount: () => executor.getCommitCount(), // the commit axis * }); * const dispatcher = new EventDispatcher(); * executor.attachCombinedRecorder(boundary); // wires FlowRecorder side * boundary.subscribe(dispatcher); // wires typed-event side * * await executor.run({ input }); * * for (const e of boundary.getEvents()) { * switch (e.type) { * case 'run.entry': renderRoot(e); break; * case 'subflow.entry': if (e.slotKind) renderSlotRow(e); * else if (e.primitiveKind) renderPrimitive(e); * break; * case 'llm.start': renderLLMCall(e); break; * // ... * } * } * ``` */ import { CommitRangeIndex } from 'footprintjs/trace'; import type { CombinedRecorder, FlowDecisionEvent, FlowForkEvent, FlowLoopEvent, FlowSubflowEvent, TraversalContext } from 'footprintjs'; interface FlowRunEvent { readonly payload?: unknown; readonly traversalContext?: TraversalContext; } interface FlowRunFailedEvent { readonly structuredError: { readonly message: string; }; readonly traversalContext?: TraversalContext; } import type { AgentfootprintEvent } from '../../events/registry.js'; import type { Unsubscribe } from '../../events/dispatcher.js'; import type { ContextSlot } from '../../events/types.js'; /** Fields every domain event carries. */ interface DomainEventBase { /** Stable per-execution key (footprintjs primitive). For run events it * is `'__root__#0'`; subflow events use the parent stage's runtimeStageId * at mount; typed events use the firing stage's runtimeStageId. */ readonly runtimeStageId: string; /** Decomposition of `subflowId` into segments, rooted under `'__root__'`. */ readonly subflowPath: readonly string[]; /** Depth in the run tree — root = 0, top-level subflow = 1, etc. */ readonly depth: number; /** Wall-clock ms at capture time. */ readonly ts: number; /** Commit count when this event fired. 0 if the recorder was * constructed without `getCommitCount` (legacy mode). The boundary * RANGE for an (entry, exit) pair is `[entry.commitIdxBefore, * exit.commitIdxBefore]`. Phase 5 Layer 2 — see * `docs/design/boundary-commit-ranges.md`. */ readonly commitIdxBefore: number; /** RESERVED for future event types that trigger engine writes. * CURRENT BEHAVIOR: always equals `commitIdxBefore` for every event * emitted by today's BoundaryRecorder. Observer events don't write * to scope, so the executor's commit count doesn't change between * the moment the event is sampled and the moment it's recorded. * Consumers should currently treat this as identical to * `commitIdxBefore`; do NOT rely on it being strictly greater. * The field exists for forward compatibility — if a future * observer pattern triggers commits during its handler, this is * where the post-effect count will land. */ readonly commitIdxAfter: number; } export interface DomainRunEvent extends DomainEventBase { readonly type: 'run.entry' | 'run.exit'; readonly payload?: unknown; /** Always `true` for run events — convenience flag for filter callers. */ readonly isRoot: true; } export interface DomainSubflowEvent extends DomainEventBase { readonly type: 'subflow.entry' | 'subflow.exit'; /** Path-prefixed engine id (matches `FlowSubflowEvent.subflowId`). */ readonly subflowId: string; /** Last segment of `subflowId` — convenience for leaf-name grouping. */ readonly localSubflowId: string; readonly subflowName: string; /** Build-time description from the subflow root (`': '`). */ readonly description?: string; /** Parsed `':'` prefix — `'Agent'`, `'LLMCall'`, `'Sequence'`, etc. */ readonly primitiveKind?: string; /** Set ONLY for the 3 input-slot subflows (sf-system-prompt / sf-messages / sf-tools). */ readonly slotKind?: ContextSlot; /** True for Agent state-machine routing/wrapper subflows (route, tool-calls, final, merge). */ readonly isAgentInternal: boolean; /** `inputMapper` result on entry; subflow shared state on exit. */ readonly payload?: unknown; } export interface DomainForkBranchEvent extends DomainEventBase { readonly type: 'fork.branch'; readonly parentSubflowId: string; readonly childName: string; } export interface DomainDecisionBranchEvent extends DomainEventBase { readonly type: 'decision.branch'; readonly decider: string; readonly chosen: string; readonly rationale?: string; /** * `true` when this decision comes from one of the Agent's internal * routing stages (e.g., the ReAct `Route` decider that picks * `tool-calls` vs `final`). Filtered out of the timeline by * `buildStepGraph` — the actor arrows that follow already encode * the routing observably (`llm→tool` vs `llm→user`). * * `false` when the decision comes from a consumer-defined * `Conditional` primitive — those ARE meaningful timeline steps. */ readonly isAgentInternal: boolean; } export interface DomainLoopIterationEvent extends DomainEventBase { readonly type: 'loop.iteration'; readonly target: string; readonly iteration: number; } /** * Composition boundary event — fired for every composition primitive * (Parallel / Sequence / Loop / Conditional). Mirrors `subflow.entry/exit` * but for the COMPOSITION wrapper itself (the box that contains the * branches / steps / iterations / chosen-branch). * * This pair OPENS and CLOSES a boundary range in `boundaryIndex`. Child * subflows that fire between the pair nest naturally inside the * composition's range. * * The `runtimeStageId` is the composition's own per-execution id — * SAME format as any other runtimeStageId, with `#executionIndex`. The * `kind` discriminates which composition primitive this is. * * For the Lens compound time axis, this group is what collapses * parallel branches into ONE slider position at the parent's drill * level. Drill into the composition to see its children as positions. */ export interface DomainCompositionEvent extends DomainEventBase { readonly type: 'composition.start' | 'composition.end'; readonly kind: 'Parallel' | 'Sequence' | 'Loop' | 'Conditional'; readonly compositionId: string; readonly name: string; /** On `composition.end`, the exit status reported by the composition. */ readonly status?: 'ok' | 'err' | 'break' | 'budget_exhausted'; readonly durationMs?: number; } /** * The 4 actor arrows of a ReAct cycle. Tagged on `llm.start` / `llm.end` * at capture time so consumers (slider, run-flow renderer) dispatch by * `event.actorArrow` instead of running their own state machine. * * - `'user→llm'` — first LLM call, or any LLM call NOT preceded by a * tool result (assembled-context delivery to the model). * - `'tool→llm'` — LLM call that follows a tool's result (the next * iteration of a ReAct loop). * - `'llm→tool'` — `llm.end` whose `toolCallCount > 0` (the LLM is * requesting tool execution). * - `'llm→user'` — `llm.end` with `toolCallCount === 0` (terminal * response delivered to the user). */ export type ActorArrow = 'user→llm' | 'tool→llm' | 'llm→tool' | 'llm→user'; export interface DomainLLMStartEvent extends DomainEventBase { readonly type: 'llm.start'; readonly model: string; readonly provider: string; readonly systemPromptChars?: number; readonly messagesCount?: number; readonly toolsCount?: number; /** Capture-time classification: `'user→llm'` for the first call or any * call not preceded by a tool result; `'tool→llm'` after a tool result. */ readonly actorArrow: 'user→llm' | 'tool→llm'; } export interface DomainLLMEndEvent extends DomainEventBase { readonly type: 'llm.end'; readonly content: string; readonly toolCallCount: number; readonly usage: { readonly input: number; readonly output: number; }; readonly stopReason?: string; /** Capture-time classification: `'llm→tool'` when the LLM requested * tools (`toolCallCount > 0`); `'llm→user'` for terminal delivery. */ readonly actorArrow: 'llm→tool' | 'llm→user'; } export interface DomainToolStartEvent extends DomainEventBase { readonly type: 'tool.start'; readonly toolName: string; readonly toolCallId: string; readonly args?: unknown; } export interface DomainToolEndEvent extends DomainEventBase { readonly type: 'tool.end'; readonly toolCallId: string; readonly result?: unknown; readonly durationMs?: number; readonly error?: boolean; } export interface DomainContextInjectedEvent extends DomainEventBase { readonly type: 'context.injected'; readonly slot: ContextSlot; readonly source: string; readonly sourceId?: string; readonly asRole?: 'system' | 'user' | 'assistant' | 'tool'; readonly contentSummary?: string; readonly reason?: string; readonly sectionTag?: string; readonly upstreamRef?: string; readonly retrievalScore?: number; readonly rankPosition?: number; /** Tokens consumed by this injection (from `budgetSpent.tokens`). */ readonly budgetTokens?: number; /** Fraction of slot cap consumed (from `budgetSpent.fractionOfCap`). */ readonly budgetFraction?: number; } /** Discriminated union covering every observable moment in a run. */ export type DomainEvent = DomainRunEvent | DomainSubflowEvent | DomainCompositionEvent | DomainForkBranchEvent | DomainDecisionBranchEvent | DomainLoopIterationEvent | DomainLLMStartEvent | DomainLLMEndEvent | DomainToolStartEvent | DomainToolEndEvent | DomainContextInjectedEvent; /** * The `DomainEvent` fields that carry captured run CONTENT — what the * agent read, produced, and handed across a boundary — as opposed to * run STRUCTURE (where a boundary was, when, of what kind). These are * the fields `snapshot: 'lean'` drops. * * They are also exactly the fields `buildStepGraphFromEvents` turns * into StepNode content (`entryPayload` / `exitPayload` / * `contentSummary` / `assistantText` / `toolArgs` / `toolResult`) — * which is why dropping them is opt-in rather than the default. * * When you add a field to a `Domain*Event`, decide: * - Does it hold data the agent handled (unbounded, redaction- * relevant)? → add it HERE * - Does it say where/when/what-kind the boundary was? → leave it out * * Forgetting to add one means a lean snapshot still ships that content * — silently, since nothing else references the field. * * Two free-text fields deliberately stay OUT of this set: `rationale` * on `decision.branch` and `reason` on `context.injected`. They are * short capture-time annotations saying WHY a boundary happened, and a * lean bundle loses most of its explanatory value without them — but a * decider that interpolates run values into its rationale does put that * text in a lean artifact. Lean means "no captured payloads", not "no * free text at all"; say so wherever lean is offered as a redaction * story. */ declare const CONTENT_FIELDS: readonly ["payload", "args", "result", "content", "contentSummary"]; type ContentField = (typeof CONTENT_FIELDS)[number]; /** * A `DomainEvent` with its content fields removed — the shape * `toSnapshot()` emits under `snapshot: 'lean'`. * * Distributive by construction (`T extends unknown ? … : never`) so the * union survives the `Omit` and `type` still narrows for consumers. */ type StripContent = T extends unknown ? Omit : never; export type LeanDomainEvent = StripContent; /** * Per-boundary rollup returned by * `BoundaryRecorder.aggregateForBoundary` and * `BoundaryRecorder.aggregateAllBoundaries`. Same shape regardless of * primitive kind — UIs render the same chip set for every Agent / * LLMCall / Sequence / Parallel / Conditional / Loop. * * Events count toward this rollup when their `subflowPath` is a * prefix-match of the boundary's `subflowPath`. Nested boundaries * (e.g., LLMCall inside an Agent) contribute to BOTH rollups. * * In-flight boundaries (no `subflow.exit` yet) get partial values; * `endedAtMs` and `durationMs` are undefined until close. */ export interface BoundaryAggregate { readonly runtimeStageId: string; readonly subflowId: string; readonly subflowPath: readonly string[]; /** `'Agent'` / `'LLMCall'` / `'Sequence'` / `'Parallel'` / * `'Conditional'` / `'Loop'`. Always set on rollups returned by * `aggregateAllBoundaries` (which filters to primitive boundaries). * Optional on `aggregateForBoundary` results because the caller may * request rollup for a non-primitive subflow (rare). */ readonly primitiveKind?: string; /** Subflow display name (e.g., 'Triage', 'Billing'). */ readonly label: string; /** Token usage summed across every `llm.end` inside this boundary. */ readonly tokens: { readonly input: number; readonly output: number; }; /** Count of `llm.start` events inside this boundary. */ readonly llmCalls: number; /** Count of `tool.start` events inside this boundary. */ readonly toolCalls: number; /** Count of `agent.iteration_start` events scoped to this boundary — * ReAct-loop iterations. Always `0` for non-Agent primitives. */ readonly iterations: number; /** Wall-clock ms of `subflow.entry`. */ readonly startedAtMs: number; /** Wall-clock ms of `subflow.exit`. Undefined while in flight. */ readonly endedAtMs?: number; /** `endedAtMs - startedAtMs`. Undefined while in flight. */ readonly durationMs?: number; } /** * Anything that can hand out every typed event as it fires — the one * capability `BoundaryRecorder.subscribe()` needs. * * A `Runner` satisfies this (`runner.on('*', …)`) and so does the * internal `EventDispatcher`, which is the point: wiring the recorder's * typed half never requires reaching past the public runner for a * dispatcher it doesn't hand out. */ export interface TypedEventSource { on(type: '*', listener: (event: AgentfootprintEvent) => void): Unsubscribe; } export interface BoundaryRecorderOptions { readonly id?: string; /** * Live commit-count accessor — `() => runner.getCommitCount()` on an * agentfootprint runner, or `() => executor.getCommitCount()` on a raw * footprintjs executor (5.1+). When provided: * - Every DomainEvent gains `commitIdxBefore` / `commitIdxAfter`. * - `recorder.boundaryIndex` is populated with open/close ranges * keyed on each subflow's entry event. * * **Omitting it produces a recording whose step strip cannot be * rebuilt.** Every event is stamped `commitIdxBefore: 0`, so every * boundary claims to have opened at the same instant; `boundaryIndex` * is deliberately left EMPTY rather than filled with zero-width * `[0, 0]` ranges that would read as real. Offline, a consumer * replaying this bundle sees no positions to place, so the strip stays * quiet — the honest outcome, and an unrecoverable one: the commit log * records what each stage WROTE, never when a boundary was crossed * relative to it, so nothing downstream can derive the axis after the * fact. There is no error and the events otherwise look complete, * which is exactly why this is called out here. * * Sample it live — a closure over the runner, not a captured number. * The count is meaningless before the run starts and must be read at * the moment each boundary fires. Phase 5 Layer 2. */ readonly getCommitCount?: () => number; /** * How much of each event `toSnapshot()` carries into * `runtimeSnapshot.recorders`. * * - `'full'` (default) — every field, content included. Required by * `buildStepGraphFromEvents()`: an offline `` restores its * entry/exit payloads, assistant text, tool args and tool results * from this bundle and nowhere else. * - `'lean'` — boundary STRUCTURE only. Drops the five content * fields (`payload`, `args`, `result`, `content`, * `contentSummary`); keeps every field that says WHERE, WHEN and * OF WHAT KIND each boundary was, so the commit-range index * rebuilds range for range offline. Sized on one demo turn (4 * ReAct iterations, 4 LLM calls, 3 tool calls, run end to end on * a mock provider): the bundle this method returned was 69.7 KB * raw / 4.6 KB gzipped, its lean projection 21.7 KB / 1.9 KB — * content was 69% of the raw bytes, and a consumer rebuilding * only the index reads none of it. Same run measured twice; that * turn's content was the entry/exit payloads, since only the * boundary side of this recorder was attached — a run that also * `subscribe()`s the typed-event side carries tool args, tool * results and assistant text in there too. (The replay fixture * the suite measures reports a much larger full bundle — it * stands in for each subflow's missing entry seed with that * subflow's final state, so it serializes the state twice per * boundary. Don't quote it.) * * Same reasoning as `BoundaryRangeLabel`: a stored artifact shouldn't * become a second, redaction-bypassing copy of everything the agent * read and wrote. Consumers that DO want content join back on * `runtimeStageId` against the run's own snapshot. * * `'full'` stays the default because this bundle has carried content * since it shipped (v2.x, 2026-04-27) and offline consumers depend on * that silently — a lean default would quietly empty their replays. */ readonly snapshot?: 'full' | 'lean'; } /** * Stripped projection used as the LABEL for the commit-range index. * Intentionally OMITS `payload` (security panel review YELLOW #1): * `boundaryIndex.enclosing()` queries should not bypass redaction by * exposing raw scope payloads through the range index. Consumers * needing payload can join on `runtimeStageId` with the full event * stream via `getEvents()` (which IS subject to redaction policy). */ export interface BoundaryRangeLabel { readonly type: 'subflow.entry' | 'run.entry' | 'composition.start'; readonly runtimeStageId: string; readonly subflowPath: readonly string[]; readonly depth: number; readonly ts: number; /** Set on subflow entries; undefined on the synthetic run-root entry. */ readonly subflowId?: string; readonly localSubflowId?: string; readonly subflowName?: string; readonly description?: string; readonly primitiveKind?: string; readonly slotKind?: ContextSlot; readonly isAgentInternal?: boolean; /** Composition primitive (Parallel/Sequence/Loop/Conditional) when the * range was opened by a `composition.start` event. */ readonly compositionKind?: 'Parallel' | 'Sequence' | 'Loop' | 'Conditional'; readonly compositionName?: string; } /** Factory — matches the `inOutRecorder()` / `topologyRecorder()` style. */ export declare function boundaryRecorder(options?: BoundaryRecorderOptions): BoundaryRecorder; /** * Unified domain event recorder. Implements `CombinedRecorder` so it can * attach to the executor's FlowRecorder channel; exposes `subscribe()` * to wire to the agentfootprint typed-event dispatcher. * * v5: composes a `SequenceStore` (storage) instead of * extending the deprecated `SequenceStore` base. Time-travel * utilities (`getEntryRanges`, `accumulate`) are accessed through the * store via the public read API on this class. */ export declare class BoundaryRecorder implements CombinedRecorder { readonly id: string; /** Composition: storage shelf. */ private readonly store; /** * Phase 5 Layer 2 — interval index over commit indices, populated * live as boundary entry/exit pairs fire. Consumers (Lens) read * `enclosing(commitIdx)` for breadcrumbs and `overlapping(slice)` * for time-range queries. Empty when `getCommitCount` is not * injected. See `docs/design/boundary-commit-ranges.md`. */ readonly boundaryIndex: CommitRangeIndex; /** Open-range tokens keyed by `runtimeStageId` so the matching exit * can close the correct range. Pure side-table; cleared on runId * reset. Not exposed externally. */ private readonly openTokens; /** Live commit-count accessor injected by the runner. Sanitized * (NaN/Infinity/negative → 0) before use. */ private readonly getCommitCount; /** True when `getCommitCount` was explicitly injected. In LEGACY * MODE (false), `boundaryIndex` is intentionally NOT populated — * zero-width [0,0] ranges would mislead consumers querying the * index. Multi-panel review flagged this footgun. */ private readonly hasCommitTracking; /** Snapshot verbosity — see `BoundaryRecorderOptions.snapshot`. Only * `toSnapshot()` reads it; the live event stream from `getEvents()` * is always full, so in-process consumers are never affected. */ private readonly snapshotMode; /** * Tracks whether the most recent `llm.end` had toolCalls. Used to * classify the NEXT `llm.start` as `'tool→llm'` (vs `'user→llm'` if * there's no pending tool result). Reset on `clear()` and on every * `llm.start` event after the classification is applied. */ private prevLLMEndHadTools; /** * Run-boundary observer — fires resetForNewRun() when * traversalContext.runId changes between events AND no boundary is * currently open. The "no open boundary" gate distinguishes: * * - **Legitimate new run** — consumer reuses one recorder across * sequential `executor.run()` calls. All prior boundaries closed * before the second run began; openTokens is empty when the new * runId arrives → safe to wipe state so the second run doesn't * alias with the first. * - **Composition sub-run** — primitives like `LLMCall`, `Sequence`, * and `Parallel` internally spawn their own `FlowChartExecutor` * instances. Each sub-executor mints a NEW runId. When that * sub-executor fires events on the SHARED recorder, the recorder * is still inside the parent run — `openTokens` is non-empty. * Resetting here would wipe the parent's boundary index mid-run * (the bug Layer 4 surfaced in agentfootprint-lens fanout). * * The `openTokens.size === 0` check is the cleanest semantic signal: * if nothing is in-flight, a runId change means "the consumer started * fresh"; if something is open, the new runId is from a sub-executor * nested inside the still-ongoing parent. */ private readonly runIdGuard; constructor(options?: BoundaryRecorderOptions); /** * Reset all transient state. * * **Composition-safe gate (Phase 5 Layer 4):** if `openTokens.size > 0` * the call is a no-op. Rationale: `FlowChartExecutor.run()` calls * `r.clear?.()` on every attached recorder during its pre-run loop. * When agentfootprint composition primitives (LLMCall, Sequence, * Parallel, etc.) propagate the parent's recorders to nested * sub-executors, EACH sub-executor's pre-run clear loop calls * `clear()` on the SHARED parent recorder mid-run — wiping live * parent state. The `openTokens.size > 0` check distinguishes: * * - **Legitimate reset** — consumer or executor calls `clear()` * when no boundary is in-flight (`openTokens` empty). Safe to * wipe; the recorder is idle. * - **Composition wipe** — sub-executor's pre-run clear fires * while the parent has open boundaries (`openTokens` non-empty). * Skip the wipe; the parent's state must be preserved. * * If a consumer needs to forcibly wipe state even with open tokens * (e.g., manual recovery after a crashed run), pair `clear()` with * an explicit `forceClear()` (TODO — add when the use case shows up; * today the recorder lifecycle pattern is "one recorder per logical * run" so leaked tokens shouldn't occur). */ clear(): void; private observeRunId; onRunStart(event: FlowRunEvent): void; onRunEnd(event: FlowRunEvent): void; onRunFailed(event: FlowRunFailedEvent): void; onSubflowEntry(event: FlowSubflowEvent): void; onSubflowExit(event: FlowSubflowEvent): void; onFork(event: FlowForkEvent): void; onDecision(event: FlowDecisionEvent): void; onLoop(event: FlowLoopEvent): void; /** * Subscribe to the run's typed events and emit a domain event for each * `llm.*` / `tool.*` / `context.injected` one. This is the recorder's * SECOND half — `runner.attach(...)` supplies the boundaries, this * supplies what happened inside them. Wire both, before the run. * * Takes anything that offers a wildcard subscription, which a public * `Runner` does as readily as the internal `EventDispatcher` — so a * consumer never has to reach past `runner.on` for a dispatcher it * isn't given. * * Returns an unsubscribe function; safe to call multiple times (each * call adds a new subscription). Most consumers call this once at * recorder construction and dispose with the returned function. */ subscribe(source: TypedEventSource): Unsubscribe; private ingestTypedEvent; /** All events in capture order (the canonical projection). */ getEvents(): DomainEvent[]; /** Type-narrowed lookup: all events of one kind. */ getEventsByType(type: T): Extract[]; /** All boundary events (run + subflow, entry + exit interleaved). */ getBoundaries(): (DomainRunEvent | DomainSubflowEvent)[]; /** Just the entry-phase boundary events — the "step list" timeline. */ getSteps(): (DomainRunEvent | DomainSubflowEvent)[]; /** Subset of `getSteps()` excluding agent-internal routing subflows. */ getVisibleSteps(): (DomainRunEvent | DomainSubflowEvent)[]; /** Entry/exit pair for one chart execution by `runtimeStageId`. */ getBoundary(runtimeStageId: string): { entry?: DomainRunEvent | DomainSubflowEvent; exit?: DomainRunEvent | DomainSubflowEvent; }; /** Convenience for the outermost `__root__` pair. */ getRootBoundary(): { entry?: DomainRunEvent; exit?: DomainRunEvent; }; /** Subflow events grouped by the 3 input slots — for slot-row rendering. */ getSlotBoundaries(): { systemPrompt: DomainSubflowEvent[]; messages: DomainSubflowEvent[]; tools: DomainSubflowEvent[]; }; /** * Roll up the event stream for ONE primitive boundary (Agent / * LLMCall / Sequence / Parallel / Conditional / Loop) into per- * boundary totals — tokens, llm calls, tool calls, iterations, * cache hits, duration. * * Pure projection over `getEvents()`. Events are attributed to a * boundary when their `subflowPath` is a **prefix-match** of the * boundary's path — so a nested `LLMCall` inside an `Agent` rolls * up into BOTH (LLMCall total + Agent total). * * Works mid-run (the boundary's `subflow.exit` may not have fired * yet — `endedAtMs` / `durationMs` are undefined in that case). * Works post-run. * * Multi-consumer story: this is the single source of rollup truth * for Lens, CLI live monitors, Sentry breadcrumbs, OTel exporters, * dashboards. Domain math (what counts as an "iteration"? does * cache hit count separately from llmCalls?) lives HERE — every * consumer hooks up; nobody re-implements. * * @param runtimeStageId The boundary's runtimeStageId (the same id * carried by `StepNode.runtimeStageId` for primitive subflows). * @returns The rollup, or `undefined` if no `subflow.entry` event * matches `runtimeStageId`. */ aggregateForBoundary(runtimeStageId: string): BoundaryAggregate | undefined; /** * Roll up every primitive boundary in the run into one rollup each, * in the order their `subflow.entry` events fired. Top-level multi- * agent UIs call this once per render to populate per-agent chips. * * Filters to `primitiveKind`-tagged subflows ONLY (Agent / LLMCall / * Sequence / Parallel / Conditional / Loop). Slot subflows * (`sf-system-prompt` / `sf-messages` / `sf-tools`) are NOT * boundaries in this sense — they're context-engineering machinery, * not user-facing rollup units. */ aggregateAllBoundaries(): readonly BoundaryAggregate[]; /** Snapshot bundle — included in `executor.getSnapshot()` if the * executor implements the snapshot extension protocol. * * Under `snapshot: 'lean'` the bundle carries structure only, and * `meta.mode` says so. That field is the whole point: an offline * consumer holding a lean bundle can then SAY "this recording carries * structure only" instead of drawing empty detail panels, because * `buildStepGraphFromEvents()` restores step content from this bundle * and nowhere else. `description` still carries the same fact in * prose for a human reading the JSON — but prose is not something * code branches on, and the readers that had to string-match it * didn't. * * `meta` reaches `getSnapshot().recorders[i].meta` on footprintjs * 9.12+, which copies it through; older engines rebuild the row * without it and the field is dropped in transit. It is always on the * value THIS method returns, so a producer holding the recorder * (`recordRun`) can read it either way. * * `data` is annotated rather than inferred: a lean event is a * structural SUPERtype of a full one, so inference collapses the * union to `LeanDomainEvent[]` and full-mode callers silently lose * `payload` / `content` / … from the type. Narrow on the mode you * configured. */ toSnapshot(): { name: string; description: string; preferredOperation: 'translate'; data: DomainEvent[] | LeanDomainEvent[]; meta: { readonly mode: 'full' | 'lean'; }; }; } export {};