/** * IControlFlowNarrative — Interface for control flow narrative generation. * * Captures FLOW events during traversal: decisions, forks, loops, subflows. * Complementary to scope/recorders/NarrativeRecorder which captures DATA events. * * @module */ import type { DecisionEvidence, SelectionEvidence } from '../../decide/types.js'; import type { StructuredErrorInfo } from '../errors/errorInfo.js'; /** * * Uses Null Object pattern: NullControlFlowNarrativeGenerator satisfies this * interface with empty methods for zero-cost disabled path. */ /** * The kind of stage that completed. Lets consumers route uniform * "did this stage execute" handling without a side-table lookup into * the chart spec. Required on every `onStageExecuted` event since * proposal #003 unified the event to fire for ALL stage kinds. */ export type StageType = 'linear' | 'decider' | 'fork' | 'selector' | 'subflow-mount'; export interface IControlFlowNarrative { /** * Called when a stage completes its main work. Fires for ALL stage * kinds (`'linear'`, `'decider'`, `'fork'`, `'selector'`, `'subflow-mount'`) * AFTER the corresponding specialized event (`onDecision`, `onFork`, * `onSelected`, `onSubflowEntry`). For linear stages, fires after * the stage function returns. * * Consumers tracking "did this stage run?" use this event uniformly * and switch on `stageType` for kind-specific work. */ onStageExecuted(stageName: string, description: string | undefined, traversalContext: TraversalContext | undefined, stageType: StageType): void; /** Called on linear continuation from one stage to the next. */ onNext(fromStage: string, toStage: string, description?: string, traversalContext?: TraversalContext): void; /** Called when a decider selects a branch. Most valuable for LLM context. */ onDecision(deciderName: string, chosenBranch: string, rationale?: string, deciderDescription?: string, traversalContext?: TraversalContext, evidence?: DecisionEvidence): void; /** Called when a fork executes all children in parallel. */ onFork(parentStage: string, childNames: string[], traversalContext?: TraversalContext): void; /** Called when a selector picks a subset of children. */ onSelected(parentStage: string, selectedNames: string[], totalCount: number, traversalContext?: TraversalContext, evidence?: SelectionEvidence): void; /** Called when entering a subflow (nested context boundary). */ onSubflowEntry(subflowName: string, subflowId?: string, description?: string, traversalContext?: TraversalContext, mappedInput?: Record): void; /** Called when exiting a subflow. */ onSubflowExit(subflowName: string, subflowId?: string, traversalContext?: TraversalContext, outputState?: Record): void; /** Called when a dynamic subflow is registered during traversal. */ onSubflowRegistered(subflowId: string, name: string, description?: string, specStructure?: unknown): void; /** Called on loop iteration (back-edge traversal). */ onLoop(targetStage: string, iteration: number, description?: string, traversalContext?: TraversalContext): void; /** * Called when a stage triggers break (early termination). * * @param reason - Optional string passed to `scope.$break(reason)`. * @param propagatedFromSubflow - When set, this break was raised on the * parent because an inner subflow (this id) broke with `propagateBreak` * enabled. Used by recorders to distinguish originating vs propagated * breaks and render them accordingly. */ onBreak(stageName: string, traversalContext?: TraversalContext, reason?: string, propagatedFromSubflow?: string): void; /** Called when a stage throws an error. Raw error is extracted into structured details. */ onError(stageName: string, errorMessage: string, error: unknown, traversalContext?: TraversalContext): void; /** Called when a pausable stage pauses execution. */ onPause(stageName: string, stageId: string, pauseData: unknown, subflowPath: readonly string[], traversalContext?: TraversalContext): void; /** Called when a paused stage is resumed. */ onResume(stageName: string, stageId: string, hasInput: boolean, traversalContext?: TraversalContext): void; /** * Called once per top-level `executor.run()`, BEFORE the first stage executes. * `input` is the value passed via `run({input})` (after schema validation). * Subflow-traversers do NOT fire this event — they fire `onSubflowEntry`. */ onRunStart(input: unknown, traversalContext?: TraversalContext): void; /** * Called once per top-level `executor.run()`, AFTER the last stage commits. * `output` is the value the chart returned. NOT fired on pause (the run * didn't end; it suspended) or on uncaught error. Subflow-traversers do * NOT fire this event — they fire `onSubflowExit`. */ onRunEnd(output: unknown, traversalContext?: TraversalContext): void; /** * Called once per top-level `executor.run()` when traversal throws a * NON-pause error, BEFORE the exception propagates to `run()`'s caller. * * This is the TERMINAL counterpart to `onRunEnd` — it closes the run * boundary symmetrically so every `onRunStart` is followed by exactly * one of `onRunEnd` (clean) or `onRunFailed` (error). Without it, a * live monitor sees `onRunStart` then silence on a failed run and can't * tell "still running" from "crashed." * * Errors STILL throw — this is the observable terminal signal, not a * recovery mechanism (no routing, no error-subflow). Pause is NOT an * error, so it does not fire this. Subflow-traversers do NOT fire it; * their errors propagate to the parent and surface at the top level. */ onRunFailed(error: StructuredErrorInfo, traversalContext?: TraversalContext): void; /** Returns accumulated narrative sentences in execution order. */ getSentences(): string[]; } /** * Traversal context attached to every FlowRecorder event. * Created by the traverser during DFS, passed to recorders as read-only data. * Enables recorders to build trees, group by subflow, and correlate events * without maintaining their own stacks or post-processing. * * Like OpenTelemetry's span context: stageId + parentStageId form a tree. */ export interface TraversalContext { /** * Per-`executor.run()` identifier. Generated once at the start of every * `run()` (and again on `resume()`); shared by every event of that run; * differs across consecutive runs of the same executor. * * Format: `${Date.now()}-${counter}` — sortable lexicographically (== * chronologically for runs > 1ms apart). Process-local — for cross- * process correlation use `getEnv().traceId` (consumer-supplied). * * Recorders that accumulate state across runs (fork bookkeeping, * sibling-handoff state, etc.) detect "new run" via * `event.traversalContext.runId !== this.lastRunId` and reset * transient bookkeeping. Recorders that don't care about scoping * ignore the field. */ readonly runId: string; /** Stable stage identifier from the builder (matches spec node id). */ readonly stageId: string; /** Unique per-execution-step identifier. Format: [subflowPath/]stageId#executionIndex. * Counter resets per executor — combine with `runId` for globally unique step keys. */ readonly runtimeStageId: string; /** Human-readable stage name. */ readonly stageName: string; /** Parent stage ID — walk up to reconstruct the tree. Undefined at root. */ readonly parentStageId?: string; /** * The parent EXECUTION step's runtimeStageId — the runtime twin of * `parentStageId` (RFC-003 D1). Walk up to reconstruct the runtime * ancestor chain; loop re-entries stay unambiguous because runtime ids * (`stageId#executionIndex`) differ per iteration even when stage ids * repeat. Crosses subflow boundaries: the first stage inside a subflow * points at the MOUNT stage's runtimeStageId in the parent traverser. * Undefined only at the first stage of the top-level chart. */ readonly parentRuntimeStageId?: string; /** Subflow ID when inside a subflow. Undefined at root level. */ readonly subflowId?: string; /** Full subflow path for nested subflows (e.g., "sf-outer/sf-inner"). */ readonly subflowPath?: string; /** Nesting depth (0 = root, 1 = inside first subflow, etc.). */ readonly depth: number; /** * How many times this stage has executed BEFORE in this run — the loop * iteration count when a node is revisited (e.g. via `loopTo`). Absent on the * first execution; `1` on the first loop-back, `2` on the next, … (i.e. * `visitCount - 1`). Run-scoped (resets each `run()`/`resume()`) and monotonic * across subflow re-mounts. Populated for every stage kind. Mirrors the * narrative recorder's "pass N" count. */ readonly loopIteration?: number; /** Fork branch ID when inside a parallel or decider branch. */ readonly forkBranch?: string; } /** Event passed to FlowRecorder.onStageExecuted. */ export interface FlowStageEvent { stageName: string; description?: string; /** Traversal context from the engine — read-only, set by traverser. */ traversalContext?: TraversalContext; /** * Which kind of stage completed. The engine fires `onStageExecuted` * uniformly for every stage kind (proposal #003); consumers route by * `stageType` without a chart-spec lookup. */ stageType: StageType; } /** Event passed to FlowRecorder.onNext. */ export interface FlowNextEvent { from: string; to: string; description?: string; traversalContext?: TraversalContext; } /** Event passed to FlowRecorder.onDecision. */ export interface FlowDecisionEvent { decider: string; chosen: string; rationale?: string; description?: string; traversalContext?: TraversalContext; /** Structured decision evidence from decide() helper. */ evidence?: DecisionEvidence; } /** Event passed to FlowRecorder.onFork. */ export interface FlowForkEvent { parent: string; children: string[]; traversalContext?: TraversalContext; } /** Event passed to FlowRecorder.onSelected. */ export interface FlowSelectedEvent { parent: string; selected: string[]; total: number; traversalContext?: TraversalContext; /** Structured selection evidence from select() helper. */ evidence?: SelectionEvidence; } /** Event passed to FlowRecorder.onSubflow. */ export interface FlowSubflowEvent { name: string; /** Subflow identifier — use this to look up the full spec via the manifest. */ subflowId?: string; /** Build-time description of what this subflow does. */ description?: string; traversalContext?: TraversalContext; /** Mapped input values sent INTO the subflow (from inputMapper/inputKeys). Present on entry events. */ mappedInput?: Record; /** Subflow shared state at exit. Present on exit events. */ outputState?: Record; } /** Event passed to FlowRecorder.onSubflowRegistered (dynamic subflow attachment). */ export interface FlowSubflowRegisteredEvent { /** Subflow identifier. */ subflowId: string; /** Human-readable name. */ name: string; /** Build-time description. */ description?: string; /** Full spec structure (when available from buildTimeStructure). */ specStructure?: unknown; traversalContext?: TraversalContext; } /** Event passed to FlowRecorder.onLoop. */ export interface FlowLoopEvent { target: string; iteration: number; description?: string; traversalContext?: TraversalContext; } /** Event passed to FlowRecorder.onBreak. */ export interface FlowBreakEvent { stageName: string; traversalContext?: TraversalContext; /** * Optional free-form reason supplied by `scope.$break(reason)`. Absent * when the stage invoked `$break()` without an argument. Propagates when * a subflow is mounted with `propagateBreak: true` — the outer break * event carries the inner break's reason too. */ reason?: string; /** * When true, this break event was raised on the PARENT because an inner * subflow's break propagated up (via `SubflowMountOptions.propagateBreak`). * The originating inner break fires its own `onBreak` event separately * — this flag lets recorders distinguish the two. */ propagatedFromSubflow?: string; } /** Event passed to FlowRecorder.onError. */ export interface FlowErrorEvent { stageName: string; message: string; /** Structured error details — preserves field-level issues, error codes, etc. */ structuredError: StructuredErrorInfo; traversalContext?: TraversalContext; /** * Explicit channel discriminant — `'flow'` on every engine-dispatched * event. `isFlowEvent()` checks it first (backlog B3); optional so * consumer-fabricated events (tests, replays) remain type-valid and fall * back to the legacy pipelineId-absence heuristic. */ channel?: 'flow'; } /** Event passed to FlowRecorder.onPause. */ export interface FlowPauseEvent { stageName: string; stageId: string; /** Data from the pause signal (question, reason, metadata). */ pauseData?: unknown; /** Path through subflows to the paused stage. Empty at root level. */ subflowPath: readonly string[]; traversalContext?: TraversalContext; /** Explicit channel discriminant — see {@link FlowErrorEvent.channel}. */ channel?: 'flow'; } /** Event passed to FlowRecorder.onResume. */ export interface FlowResumeEvent { stageName: string; stageId: string; /** Whether resume input was provided. */ hasInput: boolean; traversalContext?: TraversalContext; /** Explicit channel discriminant — see {@link FlowErrorEvent.channel}. */ channel?: 'flow'; } /** * Event passed to FlowRecorder.onRunStart / onRunEnd. * * Brackets the top-level `executor.run()` call. Subflows have their own * pair (`onSubflowEntry` / `onSubflowExit`); the run pair is the OUTERMOST * boundary, so consumers building "every step has an in/out" views can * close the chain at the top level. * * - `onRunStart` payload → the input passed to `run({input})` * - `onRunEnd` payload → the chart's return value */ export interface FlowRunEvent { /** On `onRunStart`: the input from `run({input})` after schema validation. * On `onRunEnd`: the value returned by the chart. Undefined if neither. */ payload?: unknown; traversalContext?: TraversalContext; } /** * Event passed to FlowRecorder.onRunFailed — the TERMINAL failure boundary * for a top-level run. Mirror of `onRunEnd` for the error path: it fires * once, during traversal, when the run throws a non-pause error, before * the exception propagates. Carries the structured error (field-level * issues, codes) rather than a flattened string. */ export interface FlowRunFailedEvent { /** Structured details of the error that terminated the run. */ structuredError: StructuredErrorInfo; traversalContext?: TraversalContext; } /** * FlowRecorder — Pluggable observer for control flow events. * * Mirrors the scope-level ScopeRecorder pattern for the engine layer. * All methods are optional — implement only the hooks you need. * Recorders are invoked synchronously in attachment order. * If a recorder throws, the error is caught and swallowed; execution continues. * * @example * ```typescript * const metricsRecorder: FlowRecorder = { * id: 'metrics', * onLoop: (event) => recordMetric('loop.iteration', event.iteration), * onDecision: (event) => recordMetric('decision', event.chosen), * }; * executor.attachFlowRecorder(metricsRecorder); * ``` */ export interface FlowRecorder { readonly id: string; onStageExecuted?(event: FlowStageEvent): void; onNext?(event: FlowNextEvent): void; onDecision?(event: FlowDecisionEvent): void; onFork?(event: FlowForkEvent): void; onSelected?(event: FlowSelectedEvent): void; onSubflowEntry?(event: FlowSubflowEvent): void; onSubflowExit?(event: FlowSubflowEvent): void; /** Called when a dynamic subflow is registered during traversal. */ onSubflowRegistered?(event: FlowSubflowRegisteredEvent): void; onLoop?(event: FlowLoopEvent): void; onBreak?(event: FlowBreakEvent): void; onError?(event: FlowErrorEvent): void; onPause?(event: FlowPauseEvent): void; onResume?(event: FlowResumeEvent): void; /** * Called once per top-level `executor.run()` BEFORE traversal begins. * Carries `event.payload = run({input})`. Subflow-traversers don't fire it. */ onRunStart?(event: FlowRunEvent): void; /** * Called once per top-level `executor.run()` AFTER traversal completes * cleanly. Carries `event.payload = chart's return value`. NOT fired on * pause (the run didn't end) or uncaught error. */ onRunEnd?(event: FlowRunEvent): void; /** * Called once per top-level `executor.run()` when the run throws a * non-pause error, BEFORE the exception propagates. The TERMINAL * counterpart to `onRunEnd` — lets a monitor close the run boundary on * failure instead of waiting forever. NOT fired on pause. */ onRunFailed?(event: FlowRunFailedEvent): void; /** Called before each run to reset per-run state. Implement for stateful recorders. */ clear?(): void; /** Optional: expose collected data for inclusion in snapshots. */ toSnapshot?(): { name: string; description?: string; preferredOperation?: 'translate' | 'accumulate' | 'aggregate'; data: unknown; }; }