footprintjs 4.17.1 → 4.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +567 -82
- package/README.md +1 -0
- package/dist/advanced.js +4 -2
- package/dist/esm/advanced.js +2 -1
- package/dist/esm/lib/recorder/BoundaryStateTracker.js +263 -0
- package/dist/esm/lib/recorder/index.js +2 -1
- package/dist/esm/trace.js +4 -1
- package/dist/lib/recorder/BoundaryStateTracker.js +267 -0
- package/dist/lib/recorder/index.js +4 -2
- package/dist/trace.js +6 -2
- package/dist/types/advanced.d.ts +1 -0
- package/dist/types/lib/recorder/BoundaryStateTracker.d.ts +215 -0
- package/dist/types/lib/recorder/index.d.ts +1 -0
- package/dist/types/trace.d.ts +1 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -1,134 +1,619 @@
|
|
|
1
|
-
# footprint.js —
|
|
1
|
+
# footprint.js — AI Coding Instructions
|
|
2
2
|
|
|
3
3
|
This is the footprint.js library — the flowchart pattern for backend code. Self-explainable systems that AI can reason about.
|
|
4
4
|
|
|
5
5
|
## Core Principle
|
|
6
6
|
|
|
7
|
-
**Collect during traversal, never post-process.** All data collection happens as side effects of the single DFS traversal. Never walk the tree after execution.
|
|
7
|
+
**Collect during traversal, never post-process.** All data collection (narrative, metrics, manifest, identity) happens as side effects of the single DFS traversal pass. Never walk the tree again after execution.
|
|
8
8
|
|
|
9
|
-
## Architecture
|
|
9
|
+
## Architecture — Library of Libraries
|
|
10
10
|
|
|
11
11
|
```
|
|
12
12
|
src/lib/
|
|
13
|
-
├── memory/ → Transactional state (SharedMemory, StageContext, TransactionBuffer)
|
|
14
|
-
├── schema/ → Validation (Zod optional, duck-typed)
|
|
15
|
-
├── builder/ → Fluent DSL (FlowChartBuilder, flowChart())
|
|
13
|
+
├── memory/ → Transactional state (SharedMemory, StageContext, TransactionBuffer, EventLog)
|
|
14
|
+
├── schema/ → Validation abstraction (Zod optional, duck-typed detection)
|
|
15
|
+
├── builder/ → Fluent DSL (FlowChartBuilder, flowChart(), DeciderList, SelectorFnList)
|
|
16
16
|
├── scope/ → Per-stage facades + recorders + providers
|
|
17
|
-
├── reactive/ → TypedScope<T> deep Proxy (typed property access, $-methods)
|
|
18
|
-
├── decide/ → decide()/select() decision evidence capture
|
|
19
|
-
├── recorder/ → CompositeRecorder, KeyedRecorder<T
|
|
20
|
-
├──
|
|
21
|
-
├──
|
|
22
|
-
|
|
17
|
+
├── reactive/ → TypedScope<T> deep Proxy (typed property access, $-methods, cycle-safe)
|
|
18
|
+
├── decide/ → decide()/select() decision evidence capture (filter + function)
|
|
19
|
+
├── recorder/ → CompositeRecorder, KeyedRecorder<T>, SequenceRecorder<T>, BoundaryStateTracker<TState>, composition primitives
|
|
20
|
+
├── pause/ → Pause/Resume (PauseSignal, FlowchartCheckpoint, PausableHandler)
|
|
21
|
+
├── engine/ → DFS traversal + narrative + 13 handlers
|
|
22
|
+
├── runner/ → High-level executor (FlowChartExecutor)
|
|
23
|
+
└── contract/ → I/O schema + OpenAPI generation
|
|
23
24
|
```
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
Dependency DAG: `memory <- scope <- reactive <- engine <- runner`, `schema <- engine`, `builder (standalone) -> engine`, `contract <- schema`, `decide -> scope`
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
Three entry points:
|
|
29
|
+
- `import { ... } from 'footprintjs'` — public API
|
|
30
|
+
- `import { ... } from 'footprintjs/trace'` — execution tracing: runtimeStageId, commitLog queries, KeyedRecorder, SequenceRecorder, BoundaryStateTracker
|
|
31
|
+
- `import { ... } from 'footprintjs/advanced'` — engine internals (also re-exports trace)
|
|
32
|
+
|
|
33
|
+
## Key API
|
|
34
|
+
|
|
35
|
+
### TypedScope (Recommended)
|
|
28
36
|
|
|
29
37
|
```typescript
|
|
30
|
-
import { flowChart, FlowChartExecutor
|
|
38
|
+
import { flowChart, FlowChartExecutor } from 'footprintjs';
|
|
31
39
|
|
|
32
|
-
interface
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
interface LoanState {
|
|
41
|
+
creditTier: string;
|
|
42
|
+
amount: number;
|
|
43
|
+
customer: { name: string; address: { zip: string } };
|
|
44
|
+
tags: string[];
|
|
45
|
+
approved?: boolean;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
const chart = flowChart<
|
|
39
|
-
scope.
|
|
40
|
-
scope.
|
|
48
|
+
const chart = flowChart<LoanState>('Intake', async (scope) => {
|
|
49
|
+
scope.creditTier = 'A'; // typed write
|
|
50
|
+
scope.amount = 50000; // typed write
|
|
51
|
+
scope.customer.address.zip = '90210'; // deep write (updateValue)
|
|
52
|
+
scope.tags.push('vip'); // array copy-on-write (single push)
|
|
53
|
+
scope.$batchArray('tags', (arr) => { // O(1) batch: 1 clone + 1 commit
|
|
54
|
+
arr.push('vip', 'premium', 'verified');
|
|
55
|
+
});
|
|
56
|
+
scope.approved = true; // optional field
|
|
57
|
+
|
|
58
|
+
// $-prefixed escape hatches
|
|
59
|
+
scope.$debug('checkpoint', { step: 1 });
|
|
60
|
+
scope.$metric('latency', 42);
|
|
61
|
+
const args = scope.$getArgs<{ requestId: string }>();
|
|
62
|
+
const env = scope.$getEnv();
|
|
63
|
+
scope.$break(); // stop pipeline
|
|
41
64
|
}, 'intake')
|
|
42
|
-
.addDeciderFunction('Route', (scope) => {
|
|
43
|
-
return decide(scope, [
|
|
44
|
-
{ when: { riskTier: { eq: 'low' } }, then: 'approved', label: 'Low risk' },
|
|
45
|
-
], 'rejected');
|
|
46
|
-
}, 'route', 'Route based on risk')
|
|
47
|
-
.addFunctionBranch('approved', 'Approve', async (scope) => {
|
|
48
|
-
scope.decision = 'Approved';
|
|
49
|
-
})
|
|
50
|
-
.addFunctionBranch('rejected', 'Reject', async (scope) => {
|
|
51
|
-
scope.decision = 'Rejected';
|
|
52
|
-
})
|
|
53
|
-
.setDefault('rejected')
|
|
54
|
-
.end()
|
|
55
65
|
.build();
|
|
56
66
|
|
|
57
67
|
const executor = new FlowChartExecutor(chart);
|
|
58
|
-
await executor.run();
|
|
59
|
-
executor.getNarrative(); // causal trace with decision evidence
|
|
68
|
+
await executor.run({ input: { requestId: 'req-123' } });
|
|
60
69
|
```
|
|
61
70
|
|
|
62
|
-
###
|
|
71
|
+
### decide() / select() — Decision Evidence Capture
|
|
63
72
|
|
|
64
73
|
```typescript
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
scope
|
|
74
|
+
import { decide, select } from 'footprintjs';
|
|
75
|
+
|
|
76
|
+
// Inside a decider function — auto-captures which values led to the decision
|
|
77
|
+
.addDeciderFunction('ClassifyRisk', (scope) => {
|
|
78
|
+
return decide(scope, [
|
|
79
|
+
{ when: { creditScore: { gt: 700 }, dti: { lt: 0.43 } }, then: 'approved', label: 'Good credit' },
|
|
80
|
+
{ when: (s) => s.creditScore > 600, then: 'manual-review', label: 'Marginal' },
|
|
81
|
+
], 'rejected');
|
|
82
|
+
}, 'classify-risk')
|
|
83
|
+
|
|
84
|
+
// Narrative: "It evaluated Rule 0 'Good credit': creditScore 750 gt 700, and chose approved."
|
|
70
85
|
```
|
|
71
86
|
|
|
72
|
-
###
|
|
87
|
+
### Builder
|
|
73
88
|
|
|
74
89
|
```typescript
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
90
|
+
import { flowChart, FlowChartBuilder } from 'footprintjs';
|
|
91
|
+
|
|
92
|
+
const chart = flowChart('Stage1', fn1, 'stage-1', undefined, 'Description')
|
|
93
|
+
.addFunction('Stage2', fn2, 'stage-2', 'Description')
|
|
94
|
+
.addDeciderFunction('Decide', deciderFn, 'decide', 'Route based on risk')
|
|
95
|
+
.addFunctionBranch('high', 'Reject', rejectFn)
|
|
96
|
+
.addFunctionBranch('low', 'Approve', approveFn)
|
|
97
|
+
.setDefault('high')
|
|
98
|
+
.end()
|
|
99
|
+
.build();
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Methods: `start()`, `addFunction()`, `addStreamingFunction()`, `addDeciderFunction()`, `addSelectorFunction()`, `addListOfFunction()`, `addPausableFunction()`, `addSubFlowChart()`, `addSubFlowChartNext()`, `loopTo()`, `contract()`, `build()`, `toSpec()`, `toMermaid()`
|
|
79
103
|
|
|
80
|
-
|
|
81
|
-
decide(scope, [
|
|
82
|
-
{ when: (s) => s.creditScore > 700, then: 'approved' },
|
|
83
|
-
], 'rejected');
|
|
104
|
+
### ScopeFacade (Internal — use TypedScope for new code)
|
|
84
105
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
106
|
+
```typescript
|
|
107
|
+
scope.getValue('key') // tracked read
|
|
108
|
+
scope.setValue('key', value) // tracked write
|
|
109
|
+
scope.getArgs<T>() // frozen readonly input (NOT tracked)
|
|
110
|
+
scope.getEnv() // frozen execution environment (NOT tracked)
|
|
90
111
|
```
|
|
91
112
|
|
|
113
|
+
**Three access tiers:**
|
|
114
|
+
- `getValue`/`setValue` — mutable shared state, tracked in narrative
|
|
115
|
+
- `getArgs()` — frozen business input from `run({ input })`, NOT tracked
|
|
116
|
+
- `getEnv()` — frozen infrastructure context from `run({ env })`, NOT tracked. Returns `ExecutionEnv { signal?, timeoutMs?, traceId? }`. Auto-inherited by subflows. Closed type.
|
|
117
|
+
|
|
92
118
|
### Executor
|
|
93
119
|
|
|
94
120
|
```typescript
|
|
95
121
|
const executor = new FlowChartExecutor(chart);
|
|
96
|
-
|
|
97
|
-
executor
|
|
98
|
-
executor.
|
|
99
|
-
|
|
100
|
-
executor.attachRecorder(recorder)
|
|
101
|
-
executor.
|
|
102
|
-
executor.
|
|
122
|
+
// With options (preferred over positional params):
|
|
123
|
+
const executor = new FlowChartExecutor(chart, { scopeFactory: myFactory, enrichSnapshots: true });
|
|
124
|
+
await executor.run({ input: data, env: { traceId: 'req-123' } });
|
|
125
|
+
|
|
126
|
+
executor.attachRecorder(recorder) // plug scope observer
|
|
127
|
+
executor.getNarrative() // combined flow + data narrative
|
|
128
|
+
executor.getNarrativeEntries() // structured entries with type/depth/stageName/stageId
|
|
129
|
+
executor.getFlowNarrative() // flow-only (no data ops)
|
|
130
|
+
executor.getSnapshot() // full memory state (includes recorder snapshots)
|
|
131
|
+
executor.attachFlowRecorder(r) // plug flow observer
|
|
132
|
+
executor.setRedactionPolicy({}) // PII protection
|
|
133
|
+
|
|
134
|
+
// Pause/Resume — human-in-the-loop
|
|
135
|
+
executor.isPaused() // true if last run paused
|
|
136
|
+
executor.getCheckpoint() // JSON-safe checkpoint (store in Redis/Postgres/etc.)
|
|
137
|
+
executor.resume(checkpoint, input) // continue from checkpoint with human's answer
|
|
103
138
|
```
|
|
104
139
|
|
|
105
|
-
|
|
140
|
+
### Pause/Resume (Human-in-the-Loop)
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import { flowChart, FlowChartExecutor } from 'footprintjs';
|
|
144
|
+
import type { PausableHandler } from 'footprintjs';
|
|
145
|
+
|
|
146
|
+
const handler: PausableHandler<MyState> = {
|
|
147
|
+
execute: async (scope) => {
|
|
148
|
+
// Return data = pause. Return nothing = continue.
|
|
149
|
+
return { question: `Approve $${scope.amount}?` };
|
|
150
|
+
},
|
|
151
|
+
resume: async (scope, input) => {
|
|
152
|
+
scope.approved = input.approved;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Pausable root stage (single-stage subflows):
|
|
157
|
+
const chart = flowChart<MyState>('Approve', handler, 'approve').build();
|
|
158
|
+
|
|
159
|
+
// Or chained after other stages:
|
|
160
|
+
const chart2 = flowChart<MyState>('Seed', seedFn, 'seed')
|
|
161
|
+
.addPausableFunction('Approve', handler, 'approve')
|
|
162
|
+
.addFunction('Process', processFn, 'process')
|
|
163
|
+
.build();
|
|
164
|
+
|
|
165
|
+
const executor = new FlowChartExecutor(chart);
|
|
166
|
+
await executor.run();
|
|
167
|
+
|
|
168
|
+
if (executor.isPaused()) {
|
|
169
|
+
const checkpoint = executor.getCheckpoint(); // JSON-safe, store anywhere
|
|
170
|
+
// Later (hours, different server):
|
|
171
|
+
await executor.resume(checkpoint, { approved: true });
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
- `execute` returns data → pauses. Returns void → continues normally (conditional pause).
|
|
176
|
+
- Checkpoint is JSON-serializable — no functions, no class instances.
|
|
177
|
+
- `resume()` reuses the execution runtime — narrative, metrics, execution tree all accumulate.
|
|
178
|
+
- `FlowRecorder.onPause`/`onResume` and `Recorder.onPause`/`onResume` fire on both observer systems.
|
|
179
|
+
|
|
180
|
+
### ComposableRunner & Snapshot Navigation
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import type { ComposableRunner } from 'footprintjs';
|
|
184
|
+
import { getSubtreeSnapshot, listSubflowPaths } from 'footprintjs';
|
|
185
|
+
|
|
186
|
+
const subtree = getSubtreeSnapshot(snapshot, 'sf-payment');
|
|
187
|
+
listSubflowPaths(snapshot); // ['sf-payment', 'sf-outer/sf-inner']
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Two Observer Systems
|
|
191
|
+
|
|
192
|
+
Both use `{ id, hooks } -> dispatcher -> error isolation -> attach/detach`. Intentionally NOT unified.
|
|
193
|
+
|
|
194
|
+
**Recorder ID contract:**
|
|
195
|
+
- `attachRecorder` is **idempotent by ID** — same ID replaces, different IDs coexist. Prevents accidental double-counting.
|
|
196
|
+
- Built-in recorders use auto-increment default IDs (`metrics-1`, `debug-1`, ...) so multiple instances with different configs coexist naturally.
|
|
197
|
+
- Frameworks that auto-attach recorders should use a well-known ID (e.g., `new MetricRecorder('metrics')`) so the consumer can override it by passing the same ID, or add a second instance with `new MetricRecorder()` (gets unique ID).
|
|
198
|
+
|
|
199
|
+
**Scope Recorder** (data ops — fires DURING stage execution):
|
|
200
|
+
- `onRead`, `onWrite`, `onCommit`, `onError`, `onStageStart`, `onStageEnd`
|
|
201
|
+
- Built-in: `MetricRecorder`, `DebugRecorder`
|
|
202
|
+
|
|
203
|
+
**FlowRecorder** (control flow — fires AFTER stage execution):
|
|
204
|
+
- `onStageExecuted`, `onNext`, `onDecision`, `onFork`, `onSelected`, `onSubflowEntry/Exit`, `onLoop`, `onBreak`, `onError`
|
|
205
|
+
- All events carry `traversalContext: TraversalContext`
|
|
206
|
+
- `onDecision`/`onSelected` carry optional `evidence` from decide()/select()
|
|
207
|
+
- Built-in: 8 strategies (Narrative, Adaptive, Windowed, RLE, Milestone, Progressive, Separate, Manifest, Silent)
|
|
106
208
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
209
|
+
**CombinedNarrativeRecorder** implements BOTH interfaces. Attached via `executor.recorder(narrative())` at runtime.
|
|
210
|
+
|
|
211
|
+
## Event Ordering
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
1. Recorder.onStageStart — stage begins
|
|
215
|
+
2. Recorder.onRead/onWrite — DURING execution (buffered per-stage)
|
|
216
|
+
3. Recorder.onCommit — transaction flush
|
|
217
|
+
4. Recorder.onStageEnd — stage completes
|
|
218
|
+
5. FlowRecorder.onStageExecuted — CombinedNarrativeRecorder flushes buffered ops
|
|
219
|
+
6. FlowRecorder.onNext/onDecision/onFork — control flow continues
|
|
220
|
+
```
|
|
111
221
|
|
|
112
222
|
## Execution Tracing (`footprintjs/trace`)
|
|
113
223
|
|
|
114
|
-
Every stage gets a unique `runtimeStageId
|
|
224
|
+
Every stage execution gets a unique `runtimeStageId` — the universal key that links recorder events, commit log entries, and execution tree nodes.
|
|
225
|
+
|
|
226
|
+
**When to use:** Debugging (which stage set a value to something unexpected?), audit trails (trace every write to its source stage), custom recorders (correlate events with specific execution steps), quality trace backtracking (walk backwards to find where data quality dropped).
|
|
115
227
|
|
|
116
|
-
|
|
228
|
+
**Format:** `[subflowPath/]stageId#executionIndex`
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
seed#0 — root stage
|
|
232
|
+
call-llm#5 — 5th execution step
|
|
233
|
+
sf-tools/execute-tool-calls#8 — subflow stage
|
|
234
|
+
call-llm#9 — same stageId, different execution (loop)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**The commitLog:** An ordered array of `CommitBundle` — one per stage commit, recording what each stage wrote to shared state. Get it from `executor.getSnapshot().commitLog`.
|
|
117
238
|
|
|
118
239
|
```typescript
|
|
119
|
-
import { parseRuntimeStageId, findLastWriter, findCommit
|
|
240
|
+
import { parseRuntimeStageId, findLastWriter, findCommit } from 'footprintjs/trace';
|
|
241
|
+
|
|
242
|
+
// Parse a runtimeStageId into components
|
|
243
|
+
parseRuntimeStageId('sf-tools/execute-tool-calls#8');
|
|
244
|
+
// → { stageId: 'execute-tool-calls', executionIndex: 8, subflowPath: 'sf-tools' }
|
|
120
245
|
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
246
|
+
// Get the commit log after execution
|
|
247
|
+
const snapshot = executor.getSnapshot();
|
|
248
|
+
const commitLog = snapshot.commitLog; // CommitBundle[]
|
|
249
|
+
|
|
250
|
+
// Backtrack: who last wrote 'systemPrompt' before commitLog array index 8?
|
|
251
|
+
// beforeIdx is the CommitBundle.idx (array position), NOT the executionIndex from runtimeStageId.
|
|
252
|
+
const writer = findLastWriter(commitLog, 'systemPrompt', 8);
|
|
253
|
+
// → CommitBundle | undefined (has .stage, .stageId, .runtimeStageId, .trace, .overwrite, .updates)
|
|
254
|
+
|
|
255
|
+
// Find by stageId: use findCommit when you know the stage.
|
|
256
|
+
// Use findLastWriter when you know the key but not which stage wrote it.
|
|
257
|
+
const llmCommit = findCommit(commitLog, 'call-llm', 'adapterRawResponse');
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Exports from `footprintjs/trace`:**
|
|
261
|
+
|
|
262
|
+
| Export | Returns | Use |
|
|
263
|
+
|--------|---------|-----|
|
|
264
|
+
| `buildRuntimeStageId(stageId, idx, subflowPath?)` | `string` | Construct an ID from components |
|
|
265
|
+
| `parseRuntimeStageId(id)` | `{ stageId, executionIndex, subflowPath }` | Decompose an ID |
|
|
266
|
+
| `findCommit(commitLog, stageId, key?)` | `CommitBundle \| undefined` | Find first commit by stageId |
|
|
267
|
+
| `findCommits(commitLog, stageId)` | `CommitBundle[]` | Find all commits by stageId |
|
|
268
|
+
| `findLastWriter(commitLog, key, beforeIdx?)` | `CommitBundle \| undefined` | Search backwards for who wrote a key |
|
|
269
|
+
| `KeyedRecorder<T>` | abstract class | Base for 1:1 Map-based recorders (durable) |
|
|
270
|
+
| `SequenceRecorder<T>` | abstract class | Base for 1:N ordered sequence recorders (durable; has `getEntryRanges()` for O(1) time-travel) |
|
|
271
|
+
| `BoundaryStateTracker<TState>` | abstract class | Base for transient bracket-scoped state — live state DURING a `[start, stop]` interval; clears on stop. O(1) reads via `getActive` / `hasActive` / `activeCount`. Subclass calls `startBoundary` / `updateBoundary` / `stopBoundary` from observer hooks. |
|
|
272
|
+
| `topologyRecorder()` / `TopologyRecorder` | factory / class | Live composition graph for streaming consumers (subflow nodes + control-flow edges) |
|
|
273
|
+
| `inOutRecorder()` / `InOutRecorder` | factory / class | Chart in/out stream — `entry`/`exit` pairs at every chart boundary (top-level run + every subflow) |
|
|
274
|
+
|
|
275
|
+
### TopologyRecorder — Composition Graph for Streaming Consumers
|
|
276
|
+
|
|
277
|
+
**One-liner:** reconstructs a live, queryable mini-flowchart of what your run actually traced, built from the 3 primitive recorder channels during traversal.
|
|
278
|
+
|
|
279
|
+
**Mental model:**
|
|
280
|
+
|
|
281
|
+
```
|
|
282
|
+
flowChart() builder → STATIC flowchart (design-time definition)
|
|
283
|
+
│
|
|
284
|
+
▼ executor runs it
|
|
285
|
+
Traversal emits events on 3 channels:
|
|
286
|
+
Recorder · FlowRecorder · EmitRecorder
|
|
287
|
+
│
|
|
288
|
+
▼ TopologyRecorder listens
|
|
289
|
+
DYNAMIC flowchart (runtime shape):
|
|
290
|
+
Nodes = composition points
|
|
291
|
+
(subflow / fork-branch / decision-branch)
|
|
292
|
+
Edges = transitions
|
|
293
|
+
(next / fork / decision / loop)
|
|
294
|
+
Queryable any moment — during or after run
|
|
125
295
|
```
|
|
126
296
|
|
|
127
|
-
|
|
297
|
+
**What it IS:**
|
|
298
|
+
- Live composition graph derived from 3 primitive channels
|
|
299
|
+
- Each node = one composition-significant moment (subflow entered, fork child, decision chosen)
|
|
300
|
+
- Each edge = a control-flow transition, timestamped with `runtimeStageId`
|
|
301
|
+
- Works identically during or after a run
|
|
302
|
+
|
|
303
|
+
**What it ISN'T:**
|
|
304
|
+
- Not a full execution tree — that's `StageContext` / `executor.getSnapshot()`
|
|
305
|
+
- Not per-stage data — that's `MetricRecorder` / custom `KeyedRecorder<T>`
|
|
306
|
+
- Not agent-specific — agentfootprint composes it; footprintjs owns it
|
|
307
|
+
|
|
308
|
+
**Why live consumers need it:** The executor already has the topology internally (execution tree in `StageContext`). But streaming consumers can't access that tree mid-run — they only see events. `TopologyRecorder` = "the tree, reconstructed from events, live-queryable."
|
|
309
|
+
|
|
310
|
+
Fills the gap between "post-run snapshot (full tree available)" and "live event stream (only point observations)." Attach once; query `getTopology()` anytime during or after a run.
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
import { topologyRecorder } from 'footprintjs/trace';
|
|
314
|
+
|
|
315
|
+
const topo = topologyRecorder();
|
|
316
|
+
executor.attachCombinedRecorder(topo); // auto-routes to FlowRecorder channel
|
|
317
|
+
|
|
318
|
+
await executor.run({ input });
|
|
319
|
+
|
|
320
|
+
const { nodes, edges, activeNodeId, rootId } = topo.getTopology();
|
|
321
|
+
topo.getSubflowNodes(); // agent-centric view
|
|
322
|
+
topo.getByKind('fork-branch'); // all parallel branches
|
|
323
|
+
topo.getParallelSiblings(id); // siblings of a parallel branch
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**Three node kinds — complete composition coverage:**
|
|
327
|
+
|
|
328
|
+
| Kind | Fires on | Represents |
|
|
329
|
+
|---|---|---|
|
|
330
|
+
| `subflow` | `onSubflowEntry` | Mounted subflow boundary (with stable `subflowId`) |
|
|
331
|
+
| `fork-branch` | `onFork` (synthesized one per child) | One branch of a parallel split — works for plain stages AND subflows |
|
|
332
|
+
| `decision-branch` | `onDecision` (synthesized for chosen) | The chosen branch of a conditional |
|
|
333
|
+
|
|
334
|
+
When a fork-branch or decision-branch target is also a subflow, the subsequent `onSubflowEntry` creates a subflow CHILD of the synthetic node. Layered shape preserves both "who branched" and "what the branch ran."
|
|
335
|
+
|
|
336
|
+
**Edges:** one per control-flow transition. `edge.kind ∈ 'next' | 'fork-branch' | 'decision-branch' | 'loop-iteration'`. Each carries `at: runtimeStageId` for time correlation.
|
|
337
|
+
|
|
338
|
+
**Correlation rules:**
|
|
339
|
+
- `onFork({ parent, children })` → N `fork-branch` nodes synthesized up-front; subsequent matching `onSubflowEntry` nests under the right fork-branch
|
|
340
|
+
- `onDecision({ chosen })` → `decision-branch` node synthesized up-front; matching `onSubflowEntry` nests under it
|
|
341
|
+
- Pending correlation clears on `onSubflowExit` so state doesn't leak across scopes
|
|
342
|
+
- `onLoop` → self-edge on the currently-active subflow (synthetic nodes don't participate)
|
|
343
|
+
- Re-entry of same `subflowId` (loop body) disambiguates via `id#n` suffix
|
|
344
|
+
|
|
345
|
+
**What it does NOT track:** plain sequential stages. Use `MetricRecorder` / `StageContext` for per-stage data. Topology is a graph of control-flow branching, not a full execution tree.
|
|
346
|
+
|
|
347
|
+
**For downstream libraries:** compose, don't duplicate. An agent-shaped recorder should wrap a `topologyRecorder()` internally and translate topology nodes into agent semantics — not re-implement subflow-stack + fork + decision tracking.
|
|
348
|
+
|
|
349
|
+
Example: [examples/runtime-features/flow-recorder/06-topology.ts](examples/runtime-features/flow-recorder/06-topology.ts)
|
|
350
|
+
|
|
351
|
+
### InOutRecorder — Chart In/Out Stream (every chart boundary, root + subflows)
|
|
352
|
+
|
|
353
|
+
**One-liner:** captures every chart execution (top-level run AND every subflow) as an `entry`/`exit` boundary pair, with the `inputMapper`/`outputMapper` payloads attached. Combined with `TopologyRecorder` (composition shape) this gives downstream layers the universal "step" primitive — `runtimeStageId` binds them.
|
|
354
|
+
|
|
355
|
+
**Mental model:**
|
|
356
|
+
|
|
357
|
+
```
|
|
358
|
+
user input ─►┌───────────────── run ─────────────────┐ ◄─ user output
|
|
359
|
+
│ __root__#0 onRunStart / onRunEnd │
|
|
360
|
+
│ │
|
|
361
|
+
│ inputMapper outputMapper │
|
|
362
|
+
│ │ │ │
|
|
363
|
+
│ parent ──►┤ subflow ├──► parent │
|
|
364
|
+
│ │ │ │
|
|
365
|
+
│ └── runtimeStageId ───┘ │
|
|
366
|
+
│ │
|
|
367
|
+
└────────────────────────────────────────┘
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Each chart execution → 2 boundaries:
|
|
371
|
+
- **Root** — `onRunStart` / `onRunEnd` fire ONCE per `executor.run()`. `subflowId: '__root__'`, `depth: 0`, `isRoot: true`.
|
|
372
|
+
- **Subflow** — `onSubflowEntry` / `onSubflowExit` fire once per mounted subflow. Nested under root in the path tree (`['__root__', 'sf-x']`, depth 1+).
|
|
373
|
+
|
|
374
|
+
Loop re-entry produces distinct pairs because the parent stage's executionIndex increments.
|
|
375
|
+
|
|
376
|
+
**What it IS:**
|
|
377
|
+
- `SequenceRecorder<InOutEntry>` — flat ordered list + per-`runtimeStageId` index
|
|
378
|
+
- Captures the **payloads** at every chart boundary (what flowed IN and OUT)
|
|
379
|
+
- Path-aware: `subflowPath` is decomposed from the engine's path-prefixed `subflowId` and rooted under `__root__`
|
|
380
|
+
- Domain-agnostic — knows nothing about LLMs, tools, agents
|
|
381
|
+
|
|
382
|
+
**What it ISN'T:**
|
|
383
|
+
- Not a composition graph — that's `TopologyRecorder` (shape) vs this (data crossing each boundary)
|
|
384
|
+
- Not a full execution tree — that's `StageContext`
|
|
385
|
+
- Not agent-specific — domain libraries (e.g. agentfootprint) compose it; footprintjs owns it
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
import { inOutRecorder, ROOT_SUBFLOW_ID } from 'footprintjs/trace';
|
|
389
|
+
|
|
390
|
+
const inOut = inOutRecorder();
|
|
391
|
+
executor.attachCombinedRecorder(inOut);
|
|
392
|
+
|
|
393
|
+
await executor.run({ input });
|
|
394
|
+
|
|
395
|
+
inOut.getSteps(); // entry boundaries (timeline; root is first step)
|
|
396
|
+
inOut.getBoundary(runtimeStageId); // { entry, exit } pair for one execution
|
|
397
|
+
inOut.getRootBoundary(); // { entry, exit } for the top-level run
|
|
398
|
+
inOut.getBoundaries(); // flat list (entry+exit interleaved)
|
|
399
|
+
inOut.getEntryRanges(); // O(1) per-step range index for time-travel
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
**`InOutEntry` shape:**
|
|
403
|
+
|
|
404
|
+
| Field | Description |
|
|
405
|
+
|---|---|
|
|
406
|
+
| `runtimeStageId` | Same value for the entry/exit pair of one execution. Top-level run uses `'__root__#0'`. |
|
|
407
|
+
| `subflowId` | Path-prefixed engine id. Top-level → `'__root__'`. Subflow → `'sf-outer'` or `'sf-outer/sf-inner'`. |
|
|
408
|
+
| `localSubflowId` | Last segment of `subflowId` |
|
|
409
|
+
| `subflowName` | Human-readable display name (`'Run'` for the top-level run) |
|
|
410
|
+
| `description` | Build-time description (carries taxonomy markers like `'Agent: ReAct loop'`). Undefined for root. |
|
|
411
|
+
| `subflowPath` | Decomposition of `subflowId` rooted under `__root__`: `['__root__']` for root, `['__root__', 'sf-x']` for top-level subflow |
|
|
412
|
+
| `depth` | Root → 0. First-level subflow → 1. |
|
|
413
|
+
| `phase` | `'entry'` or `'exit'` |
|
|
414
|
+
| `payload` | `entry`: `inputMapper` result (subflow) or `run({input})` (root); `exit`: shared state at exit (subflow) or chart return value (root) |
|
|
415
|
+
| `isRoot` | True only for the synthetic root pair from `onRunStart` / `onRunEnd` |
|
|
416
|
+
|
|
417
|
+
**Pause semantics:** when a stage pauses inside a subflow, the engine re-throws without firing `onSubflowExit` (or `onRunEnd`). The chart has an `entry` with no matching `exit` until resume completes. `getBoundary()` returns `{ entry, exit: undefined }` in that case.
|
|
418
|
+
|
|
419
|
+
**Engine events:** `FlowRecorder.onRunStart(event)` and `onRunEnd(event)` carry `event.payload` (the run's input or output). Fire ONCE per top-level `executor.run()` — not for subflow traversers (those fire `onSubflowEntry`/`onSubflowExit` instead). Available on the `IControlFlowNarrative` interface and the `FlowRecorderDispatcher`.
|
|
420
|
+
|
|
421
|
+
**For downstream libraries:** compose, don't duplicate. A domain-flavored step graph (e.g., agentfootprint's `StepGraph`) should consume `InOutRecorder` output and label each entry by inspecting the payload through domain semantics — not re-walk subflow events.
|
|
422
|
+
|
|
423
|
+
Example: [examples/runtime-features/flow-recorder/07-inout.ts](examples/runtime-features/flow-recorder/07-inout.ts)
|
|
424
|
+
|
|
425
|
+
**Three recorder storage primitives** — choose based on data shape and durability:
|
|
426
|
+
|
|
427
|
+
| Base Class | Relationship | Time scope | Use When |
|
|
428
|
+
|------------|-------------|------------|----------|
|
|
429
|
+
| `KeyedRecorder<T>` | 1:1 Map | durable | Each step produces one record (MetricRecorder, TokenRecorder) |
|
|
430
|
+
| `SequenceRecorder<T>` | 1:N sequence + Map | durable | Multiple records per step, ordering matters (CombinedNarrativeRecorder) |
|
|
431
|
+
| `BoundaryStateTracker<TState>` | Map\<key, TState\> active stack | transient — clears on stop | Live state DURING a `[start, stop]` bracket (LLM stream partial, tool args streaming) |
|
|
432
|
+
|
|
433
|
+
A real recorder picks ONE observer interface AND ONE storage shelf, combining via `extends + implements`. Existing example: `BoundaryRecorder extends SequenceRecorder<DomainEvent> implements CombinedRecorder`.
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
import { KeyedRecorder, SequenceRecorder } from 'footprintjs/trace';
|
|
437
|
+
|
|
438
|
+
// KeyedRecorder: one entry per step
|
|
439
|
+
class TokenRecorder extends KeyedRecorder<TokenEntry> {
|
|
440
|
+
readonly id = 'tokens';
|
|
441
|
+
onLLMCall(event) { this.store(event.runtimeStageId, { tokens: event.usage }); }
|
|
442
|
+
}
|
|
443
|
+
recorder.getByKey('call-llm#5'); // Translate: per-step value
|
|
444
|
+
recorder.aggregate((sum, e) => sum + e.tokens, 0); // Aggregate: grand total
|
|
445
|
+
recorder.accumulate((sum, e) => sum + e.tokens, 0, visibleKeys); // Accumulate: up to slider
|
|
446
|
+
|
|
447
|
+
// SequenceRecorder: multiple entries per step, ordered
|
|
448
|
+
class AuditRecorder extends SequenceRecorder<AuditEntry> {
|
|
449
|
+
readonly id = 'audit';
|
|
450
|
+
onRead(event) { this.emit({ runtimeStageId: event.runtimeStageId, type: 'read', key: event.key }); }
|
|
451
|
+
onDecision(event) { this.emit({ runtimeStageId: event.traversalContext?.runtimeStageId, ... }); }
|
|
452
|
+
}
|
|
453
|
+
recorder.getEntriesForStep('call-llm#5'); // Translate: per-step entries
|
|
454
|
+
recorder.aggregate((count, _) => count + 1, 0); // Aggregate: grand total
|
|
455
|
+
recorder.getEntriesUpTo(visibleKeys); // Progressive: up to slider
|
|
456
|
+
recorder.getEntryRanges(); // Range index: O(1) slider sync
|
|
457
|
+
|
|
458
|
+
// BoundaryStateTracker: transient state DURING a bracket; clears on stop
|
|
459
|
+
class LiveLLMTracker extends BoundaryStateTracker<{ partial: string; tokens: number }>
|
|
460
|
+
implements EmitRecorder
|
|
461
|
+
{
|
|
462
|
+
readonly id = 'live-llm';
|
|
463
|
+
onEmit(e) {
|
|
464
|
+
if (e.name === 'llm.start') this.startBoundary(e.runtimeStageId, { partial: '', tokens: 0 });
|
|
465
|
+
if (e.name === 'llm.token') this.updateBoundary(e.runtimeStageId, s => ({ partial: s.partial + e.payload.content, tokens: s.tokens + 1 }));
|
|
466
|
+
if (e.name === 'llm.end') this.stopBoundary(e.runtimeStageId);
|
|
467
|
+
}
|
|
468
|
+
isInFlight(): boolean { return this.hasActive; }
|
|
469
|
+
getPartial(rid: string): string { return this.getActive(rid)?.partial ?? ''; }
|
|
470
|
+
}
|
|
471
|
+
tracker.isInFlight(); // O(1) — live read
|
|
472
|
+
tracker.getActive(rid); // O(1) — current state of one boundary
|
|
473
|
+
tracker.activeCount; // O(1) — how many concurrent boundaries
|
|
474
|
+
// Lifecycle: clear() between runs; dev-mode warns on leaked-stop bugs.
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
**`getEntryRanges()`** returns a precomputed `Map<runtimeStageId, {firstIdx, endIdx}>` maintained during `emit()`. Use for O(1) per-step range lookups during time-travel scrubbing. Same shape as `buildEntryRangeIndex()` in `footprint-explainable-ui`.
|
|
478
|
+
|
|
479
|
+
**`CombinedNarrativeEntry.direction`** — subflow entries carry `direction: 'entry' | 'exit'`. Use for programmatic subflow boundary detection instead of text scanning (which breaks with custom `NarrativeRenderer`).
|
|
480
|
+
|
|
481
|
+
**`footprint-explainable-ui` narrative utilities** — for consumers building custom shells without `ExplainableShell`:
|
|
482
|
+
- `buildEntryRangeIndex(entries)` — build range index from flat array (when no recorder access)
|
|
483
|
+
- `computeRevealedEntryCount(entries, snapshots, idx, rangeIndex?)` — slider position → entry count
|
|
484
|
+
- `extractSubflowNarrative(entries, subflowId)` — three-tier subflow entry extraction
|
|
485
|
+
|
|
486
|
+
**How runtimeStageId is generated:** A counter starts at 0 and increments by 1 for each stage execution across the entire run, including subflow stages. Subflow child traversers share the parent counter so indices are globally unique. Stages inside subflows have stageIds already prefixed by the builder (e.g., `sf-tools/execute-tool-calls`), so `buildRuntimeStageId` just appends `#index`.
|
|
487
|
+
|
|
488
|
+
## Dev Mode
|
|
489
|
+
|
|
490
|
+
One global flag (`enableDevMode()` / `disableDevMode()` / `isDevMode()`) controls every developer-only diagnostic across the library. OFF by default — production pays zero overhead.
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
import { enableDevMode } from 'footprintjs';
|
|
494
|
+
if (process.env.NODE_ENV !== 'production') enableDevMode();
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Gated diagnostics:
|
|
498
|
+
- **Circular-ref detection** in `ScopeFacade.setValue()` — O(n) WeakSet traversal per write
|
|
499
|
+
- **Empty-recorder warning** in `attachCombinedRecorder(r)` — catches `r` with no `on*` handler
|
|
500
|
+
- **Suspicious predicates** in `decide()` / `select()`
|
|
501
|
+
- **Snapshot integrity** in `getSubtreeSnapshot()`
|
|
502
|
+
|
|
503
|
+
Convention: when adding a new dev-only check, gate on `isDevMode()` (from `scope/detectCircular.ts`). Do NOT use `process.env.NODE_ENV` inline — consumers control dev tooling centrally via `enableDevMode()`/`disableDevMode()`, and inline env checks break that contract.
|
|
504
|
+
|
|
505
|
+
## Break + Propagation
|
|
506
|
+
|
|
507
|
+
`scope.$break(reason?)` takes an optional free-form reason string that surfaces on `FlowBreakEvent.reason`. Recorders and narrative consumers see it.
|
|
508
|
+
|
|
509
|
+
By default, an inner subflow's `$break` stops ONLY the subflow; the parent continues. Opt into propagation via `SubflowMountOptions.propagateBreak: true`:
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
builder.addSubFlowChartNext('sf-escalate', escalateChart, 'Escalate', {
|
|
513
|
+
inputMapper: ..., outputMapper: ...,
|
|
514
|
+
propagateBreak: true, // ← inner $break → parent $break, with reason
|
|
515
|
+
});
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Semantics:
|
|
519
|
+
- **Linear chain:** inner `$break(reason)` → parent's `breakFlag` flips → next parent stage does NOT run → `FlowBreakEvent` fires at parent-mount level with `propagatedFromSubflow` + reason.
|
|
520
|
+
- **Nested chain:** propagates through every hop that opted in. Reason survives.
|
|
521
|
+
- **outputMapper still runs** before propagation — subflow's partial state lands in parent before the break. Escape hatch: early-return `{}` from outputMapper when the break state is set.
|
|
522
|
+
- **Parallel/fan-out:** existing ChildrenExecutor rule applies — parent breaks only when ALL fork children broke. `propagateBreak: true` on a single child contributes to that count; doesn't terminate the fork alone.
|
|
523
|
+
|
|
524
|
+
Example: [examples/runtime-features/break/04-subflow-propagate.ts](examples/runtime-features/break/04-subflow-propagate.ts).
|
|
525
|
+
|
|
526
|
+
## Emit Channel (Phase 3)
|
|
527
|
+
|
|
528
|
+
Third observer channel alongside `Recorder` (data-flow) and `FlowRecorder` (control-flow). Consumer stage code emits structured events; `EmitRecorder.onEmit(event)` fires synchronously with auto-enriched context.
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
import type { EmitRecorder, EmitEvent } from 'footprintjs';
|
|
532
|
+
|
|
533
|
+
// Inside a stage:
|
|
534
|
+
scope.$emit('myapp.llm.tokens', { input: 100, output: 50 });
|
|
535
|
+
|
|
536
|
+
// Recorder observes:
|
|
537
|
+
const rec: EmitRecorder = {
|
|
538
|
+
id: 'token-meter',
|
|
539
|
+
onEmit: (e) => { if (e.name === 'myapp.llm.tokens') tally(e.payload); },
|
|
540
|
+
};
|
|
541
|
+
executor.attachEmitRecorder(rec);
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
### Semantics
|
|
545
|
+
- **Pass-through.** Delivered synchronously, in call order. Zero allocation when no recorder attached (fast-path in `ScopeFacade.emitEvent`).
|
|
546
|
+
- **Auto-enriched.** Events carry `stageName`, `runtimeStageId`, `subflowPath`, `pipelineId`, `timestamp` — parsed from `runtimeStageId` for subflow context.
|
|
547
|
+
- **Error-isolated.** A throwing `onEmit` doesn't propagate; errors route to `onError` on other recorders.
|
|
548
|
+
- **Redactable.** `RedactionPolicy.emitPatterns: RegExp[]` matches `event.name`; matched payloads become `'[REDACTED]'` before dispatch.
|
|
549
|
+
- **Buffered in narrative.** `CombinedNarrativeRecorder.onEmit` buffers alongside reads/writes; flushed in `flushOps` so emit entries appear AFTER the stage header in ordered narrative.
|
|
550
|
+
|
|
551
|
+
### Naming convention
|
|
552
|
+
Hierarchical dotted names — `<namespace>.<category>.<event>`. Examples:
|
|
553
|
+
- `'agentfootprint.llm.tokens'`, `'agentfootprint.llm.request'`
|
|
554
|
+
- `'myapp.billing.spend'`, `'myapp.auth.check'`
|
|
555
|
+
|
|
556
|
+
### Legacy primitives route through this channel
|
|
557
|
+
`$debug`, `$metric`, `$error`, `$eval`, `$log` also dispatch on the emit channel (in addition to their existing `DiagnosticCollector` side-bag writes for snapshot inclusion):
|
|
558
|
+
|
|
559
|
+
```
|
|
560
|
+
$debug(key, value) → emits 'log.debug.${key}'
|
|
561
|
+
$error(key, value) → emits 'log.error.${key}'
|
|
562
|
+
$metric(name, value) → emits 'metric.${name}'
|
|
563
|
+
$eval (name, value) → emits 'eval.${name}'
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
This closes the long-standing gap where `$metric` / `$debug` went to side bags no recorder observed. Backward-compat: the side bags still populate for consumers that inspect snapshots directly.
|
|
567
|
+
|
|
568
|
+
### Customizing narrative rendering
|
|
569
|
+
`NarrativeFormatter.renderEmit?(ctx)` hook renders an emit event into a narrative line. Return `string` to use, `null` to exclude, `undefined` to fall back to the default `[emit] name: payloadSummary`.
|
|
570
|
+
|
|
571
|
+
Example: [examples/runtime-features/emit/01-custom-events.ts](examples/runtime-features/emit/01-custom-events.ts).
|
|
572
|
+
|
|
573
|
+
## Combined Recorder
|
|
574
|
+
|
|
575
|
+
A `CombinedRecorder` is an observer that hooks into multiple event streams (scope data-flow, control-flow, AND emit — all three channels). One object, one `id`, one `attachCombinedRecorder()` call — the library routes to the right channels via runtime method-shape detection.
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
import type { CombinedRecorder } from 'footprintjs';
|
|
579
|
+
import { isFlowEvent } from 'footprintjs';
|
|
580
|
+
|
|
581
|
+
const audit: CombinedRecorder = {
|
|
582
|
+
id: 'audit',
|
|
583
|
+
onWrite: (e) => log('scope write', e.key), // Recorder stream
|
|
584
|
+
onDecision: (e) => log('routed to', e.chosen), // FlowRecorder stream
|
|
585
|
+
onError: (e) => {
|
|
586
|
+
// Shared method — union payload. Discriminate with isFlowEvent():
|
|
587
|
+
if (isFlowEvent(e)) log('flow error in', e.stageName);
|
|
588
|
+
else log('scope error during', e.operation);
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
executor.attachCombinedRecorder(audit);
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
Built on `CombinedRecorder`: `CombinedNarrativeRecorder` (the `executor.enableNarrative()` default). Consumers implement ONLY the events they care about — `Partial<Recorder> & Partial<FlowRecorder>` under the hood.
|
|
596
|
+
|
|
597
|
+
**Detection rule:** only OWN event-method properties count (prototype methods are ignored for security — prevents accidental `Object.prototype` pollution from attaching handlers).
|
|
598
|
+
|
|
599
|
+
## Anti-Patterns
|
|
128
600
|
|
|
129
|
-
- Use `flowChart<T>()` — scopeFactory is auto-embedded
|
|
130
|
-
- Use `decide()` / `select()` in decider/selector functions
|
|
131
|
-
- Use typed property access (not getValue/setValue)
|
|
132
|
-
- Use `$getArgs()` for input, `$getEnv()` for environment
|
|
133
601
|
- Never post-process the tree — use recorders
|
|
134
|
-
-
|
|
602
|
+
- Don't use `getValue()`/`setValue()` in TypedScope stages — use typed property access
|
|
603
|
+
- Don't use `$`-prefixed state keys (e.g., `$break`) — they collide with ScopeMethods
|
|
604
|
+
- Don't use deprecated `CombinedNarrativeBuilder` — use `CombinedNarrativeRecorder`
|
|
605
|
+
- Don't extract shared base for Recorder/FlowRecorder — two instances = coincidence
|
|
606
|
+
- Don't use `getArgs()` for tracked data — use typed scope properties
|
|
607
|
+
- Don't put infrastructure data in `getArgs()` — use `getEnv()` via `run({ env })`
|
|
608
|
+
- Don't manually create `CombinedNarrativeRecorder` — `executor.recorder(narrative())` handles it
|
|
609
|
+
- Don't return full arrays from `outputMapper` without `arrayMerge: ArrayMergeMode.Replace` — default `applyOutputMapping` **concatenates** arrays (`[...parent, ...subflow]`). Either return only the **delta** (new items), or set `arrayMerge: ArrayMergeMode.Replace` on `SubflowMountOptions` to overwrite instead of concatenate. Scalars are always replaced regardless.
|
|
610
|
+
|
|
611
|
+
## Build & Test
|
|
612
|
+
|
|
613
|
+
```bash
|
|
614
|
+
npm run build # tsc (CJS) + tsc -p tsconfig.esm.json (ESM)
|
|
615
|
+
npm test # full suite
|
|
616
|
+
npm run test:unit
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Dual output: CommonJS (`dist/`) + ESM (`dist/esm/`) + types (`dist/types/`)
|