footprintjs 9.31.0 → 9.33.0
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 +670 -194
- package/dist/esm/lib/capture/freeze.d.ts +47 -0
- package/dist/esm/lib/capture/freeze.js +60 -0
- package/dist/esm/lib/capture/index.d.ts +1 -0
- package/dist/esm/lib/capture/index.js +2 -1
- package/dist/esm/lib/decide/evaluator.js +3 -2
- package/dist/esm/lib/decide/evidence.js +3 -2
- package/dist/esm/lib/detach/drivers/immediate.d.ts +25 -24
- package/dist/esm/lib/detach/drivers/immediate.js +26 -25
- package/dist/esm/lib/detach/drivers/microtaskBatch.d.ts +5 -4
- package/dist/esm/lib/detach/drivers/microtaskBatch.js +6 -5
- package/dist/esm/lib/engine/narrative/CombinedNarrativeRecorder.d.ts +1 -1
- package/dist/esm/lib/memory/EventLog.d.ts +28 -3
- package/dist/esm/lib/memory/EventLog.js +31 -4
- package/dist/esm/lib/memory/StageContext.js +6 -6
- package/dist/esm/lib/memory/TransactionBuffer.d.ts +2 -2
- package/dist/esm/lib/memory/TransactionBuffer.js +6 -4
- package/dist/esm/lib/memory/backtrack.d.ts +33 -0
- package/dist/esm/lib/memory/backtrack.js +52 -41
- package/dist/esm/lib/memory/commitLogUtils.d.ts +129 -38
- package/dist/esm/lib/memory/commitLogUtils.js +252 -59
- package/dist/esm/lib/memory/deltaEncoding.d.ts +1 -1
- package/dist/esm/lib/memory/deltaEncoding.js +2 -2
- package/dist/esm/lib/memory/honesty.d.ts +92 -0
- package/dist/esm/lib/memory/honesty.js +91 -0
- package/dist/esm/lib/memory/index.d.ts +5 -2
- package/dist/esm/lib/memory/index.js +6 -3
- package/dist/esm/lib/memory/keyPaths.d.ts +125 -0
- package/dist/esm/lib/memory/keyPaths.js +227 -0
- package/dist/esm/lib/memory/logModel.d.ts +76 -0
- package/dist/esm/lib/memory/logModel.js +357 -0
- package/dist/esm/lib/memory/placeholders.d.ts +44 -0
- package/dist/esm/lib/memory/placeholders.js +45 -0
- package/dist/esm/lib/memory/redaction.d.ts +38 -5
- package/dist/esm/lib/memory/redaction.js +64 -12
- package/dist/esm/lib/memory/utils.d.ts +2 -6
- package/dist/esm/lib/memory/utils.js +4 -20
- package/dist/esm/lib/memory/verbs.d.ts +65 -22
- package/dist/esm/lib/memory/verbs.js +64 -23
- package/dist/esm/lib/runner/ExecutionRuntime.d.ts +1 -0
- package/dist/esm/lib/runner/ExecutionRuntime.js +6 -6
- package/dist/esm/lib/runner/FlowChartExecutor.js +5 -5
- package/dist/esm/lib/scope/ScopeFacade.js +3 -2
- package/dist/esm/lib/scope/protection/readonlyInput.d.ts +2 -6
- package/dist/esm/lib/scope/protection/readonlyInput.js +4 -18
- package/dist/esm/lib/slice/elementProvenance.d.ts +4 -1
- package/dist/esm/lib/slice/elementProvenance.js +34 -16
- package/dist/esm/lib/slice/forwardSliceForKey.d.ts +4 -0
- package/dist/esm/lib/slice/forwardSliceForKey.js +32 -9
- package/dist/esm/lib/slice/keyIndex.d.ts +50 -8
- package/dist/esm/lib/slice/keyIndex.js +97 -16
- package/dist/esm/lib/slice/keyTimeline.js +11 -6
- package/dist/esm/lib/slice/serialize.js +8 -2
- package/dist/esm/lib/slice/sliceForKey.js +22 -6
- package/dist/esm/lib/slice/types.d.ts +92 -33
- package/dist/esm/lib/slice/types.js +1 -1
- package/dist/esm/lib/time-travel/stateAt.js +4 -14
- package/dist/esm/lib/time-travel/types.d.ts +18 -7
- package/dist/esm/lib/time-travel/types.js +1 -1
- package/dist/esm/trace.d.ts +4 -1
- package/dist/esm/trace.js +3 -6
- package/dist/lib/capture/freeze.js +64 -0
- package/dist/lib/capture/index.js +4 -2
- package/dist/lib/decide/evaluator.js +3 -2
- package/dist/lib/decide/evidence.js +3 -2
- package/dist/lib/detach/drivers/immediate.js +26 -25
- package/dist/lib/detach/drivers/microtaskBatch.js +6 -5
- package/dist/lib/memory/EventLog.js +31 -4
- package/dist/lib/memory/StageContext.js +5 -5
- package/dist/lib/memory/TransactionBuffer.js +6 -4
- package/dist/lib/memory/backtrack.js +52 -41
- package/dist/lib/memory/commitLogUtils.js +257 -61
- package/dist/lib/memory/deltaEncoding.js +2 -2
- package/dist/lib/memory/honesty.js +94 -0
- package/dist/lib/memory/index.js +9 -4
- package/dist/lib/memory/keyPaths.js +243 -0
- package/dist/lib/memory/logModel.js +365 -0
- package/dist/lib/memory/placeholders.js +48 -0
- package/dist/lib/memory/redaction.js +66 -12
- package/dist/lib/memory/utils.js +8 -25
- package/dist/lib/memory/verbs.js +64 -23
- package/dist/lib/runner/ExecutionRuntime.js +8 -8
- package/dist/lib/runner/FlowChartExecutor.js +6 -6
- package/dist/lib/scope/ScopeFacade.js +3 -2
- package/dist/lib/scope/protection/readonlyInput.js +6 -21
- package/dist/lib/slice/elementProvenance.js +33 -15
- package/dist/lib/slice/forwardSliceForKey.js +31 -8
- package/dist/lib/slice/keyIndex.js +103 -17
- package/dist/lib/slice/keyTimeline.js +10 -5
- package/dist/lib/slice/serialize.js +8 -2
- package/dist/lib/slice/sliceForKey.js +21 -5
- package/dist/lib/slice/types.js +1 -1
- package/dist/lib/time-travel/stateAt.js +4 -14
- package/dist/lib/time-travel/types.js +1 -1
- package/dist/trace.js +7 -7
- package/dist/types/lib/capture/freeze.d.ts +47 -0
- package/dist/types/lib/capture/index.d.ts +1 -0
- package/dist/types/lib/detach/drivers/immediate.d.ts +25 -24
- package/dist/types/lib/detach/drivers/microtaskBatch.d.ts +5 -4
- package/dist/types/lib/engine/narrative/CombinedNarrativeRecorder.d.ts +1 -1
- package/dist/types/lib/memory/EventLog.d.ts +28 -3
- package/dist/types/lib/memory/TransactionBuffer.d.ts +2 -2
- package/dist/types/lib/memory/backtrack.d.ts +33 -0
- package/dist/types/lib/memory/commitLogUtils.d.ts +129 -38
- package/dist/types/lib/memory/deltaEncoding.d.ts +1 -1
- package/dist/types/lib/memory/honesty.d.ts +92 -0
- package/dist/types/lib/memory/index.d.ts +5 -2
- package/dist/types/lib/memory/keyPaths.d.ts +125 -0
- package/dist/types/lib/memory/logModel.d.ts +76 -0
- package/dist/types/lib/memory/placeholders.d.ts +44 -0
- package/dist/types/lib/memory/redaction.d.ts +38 -5
- package/dist/types/lib/memory/utils.d.ts +2 -6
- package/dist/types/lib/memory/verbs.d.ts +65 -22
- package/dist/types/lib/runner/ExecutionRuntime.d.ts +1 -0
- package/dist/types/lib/scope/protection/readonlyInput.d.ts +2 -6
- package/dist/types/lib/slice/elementProvenance.d.ts +4 -1
- package/dist/types/lib/slice/forwardSliceForKey.d.ts +4 -0
- package/dist/types/lib/slice/keyIndex.d.ts +50 -8
- package/dist/types/lib/slice/types.d.ts +92 -33
- package/dist/types/lib/time-travel/types.d.ts +18 -7
- package/dist/types/trace.d.ts +4 -1
- package/package.json +2 -1
package/AGENTS.md
CHANGED
|
@@ -2,34 +2,26 @@
|
|
|
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
|
+
> Every TypeScript block in this file is type-checked (strict) and run against the built package (footprintjs 9.32.0); the `// …` lines under a `console.log` are that run's real output.
|
|
6
|
+
|
|
5
7
|
## Core Principle
|
|
6
8
|
|
|
7
|
-
**Collect during traversal, never post-process.** All data collection (narrative, metrics, manifest,
|
|
9
|
+
**Collect during traversal, never post-process.** All data collection (narrative, metrics, manifest, topology, in/out boundaries) happens as side effects of the single DFS traversal pass. Never walk the tree again after execution.
|
|
8
10
|
|
|
9
11
|
## Architecture — Library of Libraries
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
src/lib/
|
|
13
|
-
├── capture/ → Value-capture/retention primitives (RetentionPolicy 'full'|'summary'|'off', read/write summary markers) — shared by the readTracking (#14) + writeTracking (#13c-A) dials; RFC-001 builds on it; also the shared leaves `invokeHook`, `circular`, `summarizeValue` (`lib/devMode.ts`, the dev-mode flag, sits beside it)
|
|
14
|
-
├── memory/ → Transactional state (SharedMemory, StageContext, TransactionBuffer, EventLog)
|
|
15
|
-
├── schema/ → Validation abstraction (Zod optional, duck-typed detection)
|
|
16
|
-
├── builder/ → Fluent DSL (FlowChartBuilder, flowChart(), DeciderList, SelectorFnList)
|
|
17
|
-
├── scope/ → Per-stage facades + recorders + providers
|
|
18
|
-
├── reactive/ → TypedScope<T> deep Proxy (typed property access, $-methods, cycle-safe)
|
|
19
|
-
├── decide/ → decide()/select() decision evidence capture (filter + function)
|
|
20
|
-
├── recorder/ → CompositeRecorder, KeyedStore<T>, SequenceStore<T>, BoundaryStateStore<TState>, composition primitives
|
|
21
|
-
├── pause/ → Pause/Resume (PauseSignal, FlowchartCheckpoint, PausableHandler)
|
|
22
|
-
├── engine/ → DFS traversal + narrative + 13 handlers
|
|
23
|
-
├── runner/ → High-level executor (FlowChartExecutor)
|
|
24
|
-
└── contract/ → I/O schema + OpenAPI generation
|
|
25
|
-
```
|
|
13
|
+
`package.json` `exports` has six doors — import from the one that owns the symbol:
|
|
26
14
|
|
|
27
|
-
|
|
15
|
+
| Import | What it is for |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `footprintjs` | The main door: `flowChart`, `FlowChartExecutor`, `decide` / `select`, `narrative`, the built-in recorder classes, `interrupt`, and the public types |
|
|
18
|
+
| `footprintjs/recorders` | Recorder factories — `narrative()`, `metrics()`, `debug()`, `manifest()`, `adaptive()`, `milestone()`, `windowed()` — and `CompositeRecorder` |
|
|
19
|
+
| `footprintjs/trace` | Execution tracing: runtimeStageId helpers, commit-log queries (`findCommit`, `findLastWriter`, `causalChain`, `sliceForKey`, `stateAt`, `timeTravel`), the storage primitives (`KeyedStore`, `SequenceStore`, `BoundaryStateStore`), `topologyRecorder()` / `inOutRecorder()`, `HONESTY_CODES` |
|
|
20
|
+
| `footprintjs/advanced` | Engine internals (`SharedMemory`, `StageContext`, `FlowchartTraverser`, `ScopeFacade`, scope providers, `SCOPE_METHOD_NAMES`, `ArrayMergeMode`) plus a small hand-picked slice of the trace helpers (`findCommit`, `findCommits`, `findLastWriter`, `parseRuntimeStageId`, `buildRuntimeStageId`) — not all of `trace` |
|
|
21
|
+
| `footprintjs/detach` | Fire-and-forget child charts and their drivers |
|
|
22
|
+
| `footprintjs/zod` | Opt-in zod bridge (`defineScopeFromZod`, …) — the core never imports zod |
|
|
28
23
|
|
|
29
|
-
|
|
30
|
-
- `import { ... } from 'footprintjs'` — public API
|
|
31
|
-
- `import { ... } from 'footprintjs/trace'` — execution tracing: runtimeStageId, commitLog queries, storage primitives (KeyedStore, SequenceStore, BoundaryStateStore)
|
|
32
|
-
- `import { ... } from 'footprintjs/advanced'` — engine internals (also re-exports trace)
|
|
24
|
+
The module map (one line per `src/lib/` directory) and the layering rule — a file imports only its own layer or below — live in one place each, so this file keeps no copy that can drift: `CLAUDE.md` ("Module map" and "The fence", shipped with the package) and the layer table in `scripts/layering.config.cjs`, enforced by lint (`import/no-restricted-paths` zones + `import/no-cycle`) and `npm run check:layering` (value-level cycles + upward runtime edges).
|
|
33
25
|
|
|
34
26
|
## Key API
|
|
35
27
|
|
|
@@ -49,10 +41,12 @@ interface LoanState {
|
|
|
49
41
|
const chart = flowChart<LoanState>('Intake', async (scope) => {
|
|
50
42
|
scope.creditTier = 'A'; // typed write
|
|
51
43
|
scope.amount = 50000; // typed write
|
|
52
|
-
scope.customer
|
|
44
|
+
scope.customer = { name: 'Ana', address: { zip: '10001' } };
|
|
45
|
+
scope.tags = [];
|
|
46
|
+
scope.customer.address.zip = '90210'; // deep write (recorded as an update of `customer`)
|
|
53
47
|
scope.tags.push('vip'); // array copy-on-write (single push)
|
|
54
|
-
scope.$batchArray('tags', (arr) => { //
|
|
55
|
-
arr.push('
|
|
48
|
+
scope.$batchArray('tags', (arr) => { // batch: one clone + one commit however many mutations
|
|
49
|
+
arr.push('premium', 'verified');
|
|
56
50
|
});
|
|
57
51
|
scope.approved = true; // optional field
|
|
58
52
|
|
|
@@ -61,28 +55,82 @@ const chart = flowChart<LoanState>('Intake', async (scope) => {
|
|
|
61
55
|
scope.$metric('latency', 42);
|
|
62
56
|
const args = scope.$getArgs<{ requestId: string }>();
|
|
63
57
|
const env = scope.$getEnv();
|
|
64
|
-
|
|
58
|
+
console.log(args.requestId, env.traceId);
|
|
59
|
+
// req-123 trace-1
|
|
60
|
+
scope.$break(); // stop the run
|
|
65
61
|
}, 'intake')
|
|
66
62
|
.build();
|
|
67
63
|
|
|
68
64
|
const executor = new FlowChartExecutor(chart);
|
|
69
|
-
|
|
65
|
+
executor.enableNarrative();
|
|
66
|
+
await executor.run({ input: { requestId: 'req-123' }, env: { traceId: 'trace-1' } });
|
|
67
|
+
|
|
68
|
+
console.log(JSON.stringify(executor.getSnapshot().sharedState));
|
|
69
|
+
// {"creditTier":"A","amount":50000,"customer":{"name":"Ana","address":{"zip":"90210"}},"tags":["vip","premium","verified"],"approved":true}
|
|
70
|
+
// the batch is ONE write; the single push before it is another
|
|
71
|
+
console.log(JSON.stringify(executor.getNarrativeEntries().filter((e) => e.text.includes('Write tags')).map((e) => e.text)));
|
|
72
|
+
// ["Step 4: Write tags = []","Step 8: Write tags = (1 item)","Step 10: Write tags = (3 items)"]
|
|
70
73
|
```
|
|
71
74
|
|
|
72
75
|
### decide() / select() — Decision Evidence Capture
|
|
73
76
|
|
|
74
77
|
```typescript
|
|
75
|
-
import {
|
|
78
|
+
import { flowChart, FlowChartExecutor, decide } from 'footprintjs';
|
|
79
|
+
|
|
80
|
+
interface RiskState {
|
|
81
|
+
creditScore: number;
|
|
82
|
+
dti: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const chart = flowChart<RiskState>('Intake', (scope) => {
|
|
86
|
+
scope.creditScore = 750;
|
|
87
|
+
scope.dti = 0.35;
|
|
88
|
+
}, 'intake')
|
|
89
|
+
// Inside a decider function — auto-captures which values led to the decision
|
|
90
|
+
.addDeciderFunction('ClassifyRisk', (scope) => {
|
|
91
|
+
return decide(scope, [
|
|
92
|
+
{ when: { creditScore: { gt: 700 }, dti: { lt: 0.43 } }, then: 'approved', label: 'Good credit' },
|
|
93
|
+
{ when: (s) => s.creditScore > 600, then: 'manual-review', label: 'Marginal' },
|
|
94
|
+
], 'rejected');
|
|
95
|
+
}, 'classify-risk')
|
|
96
|
+
.addFunctionBranch('approved', 'Approve', () => {})
|
|
97
|
+
.addFunctionBranch('manual-review', 'Review', () => {})
|
|
98
|
+
.addFunctionBranch('rejected', 'Reject', () => {})
|
|
99
|
+
.end()
|
|
100
|
+
.build();
|
|
101
|
+
|
|
102
|
+
const executor = new FlowChartExecutor(chart);
|
|
103
|
+
executor.enableNarrative();
|
|
104
|
+
await executor.run();
|
|
105
|
+
console.log(executor.getNarrativeEntries().find((e) => e.type === 'condition')?.text);
|
|
106
|
+
// [Condition]: It evaluated Rule 0 "Good credit": creditScore 750 gt 700 ✓, dti 0.35 lt 0.43 ✓, and chose Approve.
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Filter operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn`. `decide()` returns `{ branch, evidence }`; the narrative names the chosen branch by its **name** (`Approve`), the evidence by its **id** (`approved`).
|
|
76
110
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
111
|
+
`select()` is the multi-pick twin — every matching rule picks its branch (not first-match), and the picked branches run in parallel:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
import { flowChart, FlowChartExecutor, select } from 'footprintjs';
|
|
115
|
+
|
|
116
|
+
const chart = flowChart<{ glucose: number; bmi: number }>('Intake', (scope) => {
|
|
117
|
+
scope.glucose = 120;
|
|
118
|
+
scope.bmi = 31;
|
|
119
|
+
}, 'intake')
|
|
120
|
+
.addSelectorFunction('Screen', (scope) => select(scope, [
|
|
121
|
+
{ when: { glucose: { gt: 100 } }, then: 'diabetes', label: 'High glucose' },
|
|
122
|
+
{ when: { bmi: { gt: 30 } }, then: 'obesity', label: 'High BMI' },
|
|
123
|
+
]), 'screen')
|
|
124
|
+
.addFunctionBranch('diabetes', 'DiabetesPath', () => {})
|
|
125
|
+
.addFunctionBranch('obesity', 'ObesityPath', () => {})
|
|
126
|
+
.end()
|
|
127
|
+
.build();
|
|
84
128
|
|
|
85
|
-
|
|
129
|
+
const executor = new FlowChartExecutor(chart);
|
|
130
|
+
executor.enableNarrative();
|
|
131
|
+
await executor.run();
|
|
132
|
+
console.log(executor.getNarrativeEntries().find((e) => e.type === 'fork')?.text);
|
|
133
|
+
// [Parallel]: Forking into 2 parallel paths: DiabetesPath, ObesityPath.
|
|
86
134
|
```
|
|
87
135
|
|
|
88
136
|
**Naming the default branch.** The default is chosen by no rule, so no
|
|
@@ -92,132 +140,264 @@ string; the label lands on `DecisionEvidence.defaultLabel` and is recorded on
|
|
|
92
140
|
every decision, not just the runs that fell through.
|
|
93
141
|
|
|
94
142
|
```typescript
|
|
95
|
-
|
|
96
|
-
|
|
143
|
+
import { flowChart, FlowChartExecutor, decide } from 'footprintjs';
|
|
144
|
+
|
|
145
|
+
for (const creditScore of [750, 100]) {
|
|
146
|
+
const chart = flowChart<{ creditScore: number }>('Intake', (scope) => { scope.creditScore = creditScore; }, 'intake')
|
|
147
|
+
.addDeciderFunction('ClassifyRisk', (scope) => decide(scope, [
|
|
148
|
+
{ when: { creditScore: { gt: 700 } }, then: 'approved', label: 'Good credit' },
|
|
149
|
+
], { branch: 'rejected', label: 'No rule fired — application rejected' }), 'classify-risk')
|
|
150
|
+
.addFunctionBranch('approved', 'Approve', () => {})
|
|
151
|
+
.addFunctionBranch('rejected', 'Reject', () => {})
|
|
152
|
+
.end()
|
|
153
|
+
.build();
|
|
154
|
+
const executor = new FlowChartExecutor(chart);
|
|
155
|
+
executor.attachFlowRecorder({
|
|
156
|
+
id: 'evidence',
|
|
157
|
+
onDecision: (e) => console.log(e.evidence?.chosen, '|', e.evidence?.defaultLabel),
|
|
158
|
+
});
|
|
159
|
+
await executor.run();
|
|
160
|
+
}
|
|
161
|
+
// approved | No rule fired — application rejected
|
|
162
|
+
// rejected | No rule fired — application rejected
|
|
163
|
+
// a bare string default still works: decide(scope, rules, 'rejected') — the evidence then has no `defaultLabel` key
|
|
97
164
|
```
|
|
98
165
|
|
|
99
166
|
### Builder
|
|
100
167
|
|
|
101
168
|
```typescript
|
|
102
|
-
import { flowChart
|
|
169
|
+
import { flowChart } from 'footprintjs';
|
|
103
170
|
|
|
104
|
-
const chart = flowChart('Stage1',
|
|
105
|
-
.addFunction('Stage2',
|
|
106
|
-
.addDeciderFunction('Decide',
|
|
107
|
-
.addFunctionBranch('high', 'Reject',
|
|
108
|
-
.addFunctionBranch('low', 'Approve',
|
|
171
|
+
const chart = flowChart('Stage1', () => {}, 'stage-1', { description: 'Description' })
|
|
172
|
+
.addFunction('Stage2', () => {}, 'stage-2', 'Description')
|
|
173
|
+
.addDeciderFunction('Decide', () => 'high', 'decide', 'Route based on risk')
|
|
174
|
+
.addFunctionBranch('high', 'Reject', () => {})
|
|
175
|
+
.addFunctionBranch('low', 'Approve', () => {})
|
|
109
176
|
.setDefault('high')
|
|
110
177
|
.end()
|
|
111
178
|
.build();
|
|
112
179
|
```
|
|
113
180
|
|
|
114
|
-
|
|
181
|
+
The first stage is `flowChart(name, fn, id, { description?, structureRecorders? })`; every later stage is `.addFunction(name, fn, id, description?)`.
|
|
182
|
+
|
|
183
|
+
Methods (not exhaustive): `start()`, `addFunction()`, `addStreamingFunction()`, `addDeciderFunction()`, `addSelectorFunction()`, `addListOfFunction()`, `addParallelForEach()`, `addPausableFunction()`, `addSubFlowChart()`, `addSubFlowChartNext()`, `addLazySubFlowChart()`, `addDetachAndForget()`, `addDetachAndJoinLater()`, `retry()`, `tag()`, `loopTo()`, `contract()`, `attachStructureRecorder()`, `build()`, `toSpec()`, `toMermaid()`. `toOpenAPI()` and `toMCPTool()` are on the built chart.
|
|
115
184
|
|
|
116
185
|
### ScopeFacade (Internal — use TypedScope for new code)
|
|
117
186
|
|
|
187
|
+
`ScopeFacade` (from `footprintjs/advanced`) is the engine's own scope; `TypedScope` wraps it. A stage only receives a `ScopeFacade` when you supply a custom `scopeFactory`. In a TypedScope stage the same operations are `scope.$getValue(key)` / `scope.$setValue(key, value)` / `scope.$getArgs()` / `scope.$getEnv()` — plain `scope.getValue` does not exist there.
|
|
188
|
+
|
|
118
189
|
```typescript
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
190
|
+
import { flowChart, FlowChartExecutor } from 'footprintjs';
|
|
191
|
+
import { ScopeFacade } from 'footprintjs/advanced';
|
|
192
|
+
|
|
193
|
+
// TypedScope stage: dynamic keys go through the $ methods
|
|
194
|
+
const typed = flowChart<Record<string, unknown>>('Typed', (scope) => {
|
|
195
|
+
scope.$setValue('dyn', 41);
|
|
196
|
+
scope.$setValue('dyn', (scope.$getValue('dyn') as number) + 1);
|
|
197
|
+
}, 'typed').build();
|
|
198
|
+
const typedRun = new FlowChartExecutor(typed);
|
|
199
|
+
await typedRun.run();
|
|
200
|
+
console.log(JSON.stringify(typedRun.getSnapshot().sharedState));
|
|
201
|
+
// {"dyn":42}
|
|
202
|
+
|
|
203
|
+
// ScopeFacade stage: getValue / setValue / getArgs / getEnv
|
|
204
|
+
const facade = flowChart<any>('Facade', (scope: any) => {
|
|
205
|
+
const s = scope as ScopeFacade;
|
|
206
|
+
s.setValue('key', { v: 1 });
|
|
207
|
+
console.log(JSON.stringify([s.getValue('key'), s.getArgs(), s.getEnv()]));
|
|
208
|
+
}, 'facade').build();
|
|
209
|
+
const facadeRun = new FlowChartExecutor(facade, {
|
|
210
|
+
scopeFactory: (ctx, stageName, readOnly, env) => new ScopeFacade(ctx, stageName, readOnly, env),
|
|
211
|
+
});
|
|
212
|
+
await facadeRun.run({ input: { requestId: 'req-9' }, env: { traceId: 'trace-9' } });
|
|
213
|
+
// [{"v":1},{"requestId":"req-9"},{"traceId":"trace-9"}]
|
|
123
214
|
```
|
|
124
215
|
|
|
125
216
|
**Three access tiers:**
|
|
126
|
-
- `getValue`/`setValue` — mutable shared state, tracked in narrative
|
|
127
|
-
- `getArgs()` — frozen business input from `run({ input })`, NOT tracked
|
|
128
|
-
- `getEnv()` — frozen infrastructure context from `run({ env })`, NOT tracked. Returns `ExecutionEnv { signal?, timeoutMs?, traceId? }`. Auto-inherited by subflows. Closed type
|
|
217
|
+
- `scope.amount = 50000` (ScopeFacade: `getValue`/`setValue`) — mutable shared state, tracked in the narrative
|
|
218
|
+
- `$getArgs()` (ScopeFacade: `getArgs()`) — frozen business input from `run({ input })`, NOT tracked. Its keys are read-only for the run: a stage that writes a state key with the same name throws `Cannot write to readonly input key "requestId"`. A subflow's `inputMapper` result is its input and follows the same rule
|
|
219
|
+
- `$getEnv()` (ScopeFacade: `getEnv()`) — frozen infrastructure context from `run({ env })`, NOT tracked. Returns `ExecutionEnv { signal?, timeoutMs?, traceId? }`. Auto-inherited by subflows. Closed type
|
|
129
220
|
|
|
130
221
|
### Executor
|
|
131
222
|
|
|
132
223
|
```typescript
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
executor
|
|
141
|
-
executor.
|
|
142
|
-
executor.
|
|
143
|
-
executor.attachFlowRecorder(
|
|
144
|
-
executor.setRedactionPolicy({})
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
executor.
|
|
224
|
+
import { flowChart, FlowChartExecutor, MetricRecorder, NarrativeFlowRecorder } from 'footprintjs';
|
|
225
|
+
|
|
226
|
+
const chart = flowChart<{ n: number }>('One', (scope) => { scope.n = scope.$getArgs<{ x: number }>().x; }, 'one')
|
|
227
|
+
.addFunction('Two', (scope) => { scope.n = scope.n + 1; }, 'two')
|
|
228
|
+
.build();
|
|
229
|
+
|
|
230
|
+
// With options (preferred over positional params): scopeFactory, readTracking, writeTracking, commitValues, …
|
|
231
|
+
const executor = new FlowChartExecutor(chart, { readTracking: 'full' });
|
|
232
|
+
executor.enableNarrative(); // before run() — the narrative is off by default
|
|
233
|
+
executor.attachScopeRecorder(new MetricRecorder()); // plug scope observer
|
|
234
|
+
executor.attachFlowRecorder(new NarrativeFlowRecorder()); // plug flow observer
|
|
235
|
+
executor.setRedactionPolicy({ keys: ['secret'] }); // PII protection
|
|
236
|
+
|
|
237
|
+
await executor.run({ input: { x: 1 }, env: { traceId: 'req-123' } });
|
|
238
|
+
|
|
239
|
+
// CombinedNarrativeEntry[] — combined flow + data narrative
|
|
240
|
+
console.log(executor.getNarrativeEntries().length);
|
|
241
|
+
// 5
|
|
242
|
+
// flow-only (no data ops): drop the read/write steps
|
|
243
|
+
console.log(JSON.stringify(executor.getNarrativeEntries().filter((e) => e.type !== 'step').map((e) => e.text)));
|
|
244
|
+
// ["Stage 1: The process began with One.","Stage 2: Next, it moved on to Two."]
|
|
245
|
+
// full memory state (includes recorder snapshots)
|
|
246
|
+
console.log(JSON.stringify(Object.keys(executor.getSnapshot())));
|
|
247
|
+
// ["sharedState","executionTree","initialState","commitLog","commitValues","writeProvenance","runId","recorders"]
|
|
248
|
+
|
|
249
|
+
// Pause/Resume — human-in-the-loop (next section)
|
|
250
|
+
// executor.isPaused() // true if last run paused
|
|
251
|
+
// executor.getCheckpoint() // JSON-safe checkpoint (store in Redis/Postgres/etc.)
|
|
252
|
+
// executor.resume(checkpoint, input) // continue from checkpoint with human's answer
|
|
150
253
|
```
|
|
151
254
|
|
|
255
|
+
`getNarrativeEntries()` returns entries (`type`, `text`, `depth`, `stageName`, `stageId`, …), not strings: `.map((e) => e.text)` for plain lines. One executor runs one execution at a time — create one per concurrent run.
|
|
256
|
+
|
|
152
257
|
### Pause/Resume (Human-in-the-Loop)
|
|
153
258
|
|
|
154
259
|
```typescript
|
|
155
260
|
import { flowChart, FlowChartExecutor } from 'footprintjs';
|
|
156
261
|
import type { PausableHandler } from 'footprintjs';
|
|
157
262
|
|
|
263
|
+
interface MyState {
|
|
264
|
+
amount: number;
|
|
265
|
+
approved?: boolean;
|
|
266
|
+
done?: boolean;
|
|
267
|
+
}
|
|
268
|
+
|
|
158
269
|
const handler: PausableHandler<MyState> = {
|
|
159
270
|
execute: async (scope) => {
|
|
160
271
|
// Return data = pause. Return nothing = continue.
|
|
161
272
|
return { question: `Approve $${scope.amount}?` };
|
|
162
273
|
},
|
|
163
274
|
resume: async (scope, input) => {
|
|
164
|
-
scope.approved = input.approved;
|
|
275
|
+
scope.approved = (input as { approved: boolean }).approved;
|
|
165
276
|
},
|
|
166
277
|
};
|
|
167
278
|
|
|
168
279
|
// Pausable root stage (single-stage subflows):
|
|
169
|
-
const
|
|
280
|
+
const single = flowChart<MyState>('Approve', handler, 'approve').build();
|
|
170
281
|
|
|
171
282
|
// Or chained after other stages:
|
|
172
|
-
const
|
|
283
|
+
const chart = flowChart<MyState>('Seed', (scope) => { scope.amount = 500; }, 'seed')
|
|
173
284
|
.addPausableFunction('Approve', handler, 'approve')
|
|
174
|
-
.addFunction('Process',
|
|
285
|
+
.addFunction('Process', (scope) => { scope.done = scope.approved === true; }, 'process')
|
|
175
286
|
.build();
|
|
176
287
|
|
|
177
288
|
const executor = new FlowChartExecutor(chart);
|
|
289
|
+
executor.enableNarrative();
|
|
178
290
|
await executor.run();
|
|
291
|
+
console.log(executor.isPaused());
|
|
292
|
+
// true
|
|
179
293
|
|
|
180
294
|
if (executor.isPaused()) {
|
|
181
|
-
const checkpoint = executor.getCheckpoint()
|
|
295
|
+
const checkpoint = executor.getCheckpoint()!; // JSON-safe, store anywhere
|
|
296
|
+
console.log(checkpoint.pausedStageId, JSON.stringify(checkpoint.pauseData));
|
|
297
|
+
// approve {"question":"Approve $500?"}
|
|
298
|
+
const stored = JSON.stringify(checkpoint);
|
|
182
299
|
// Later (hours, different server):
|
|
183
|
-
await executor.resume(
|
|
300
|
+
await executor.resume(JSON.parse(stored), { approved: true });
|
|
184
301
|
}
|
|
302
|
+
console.log(executor.isPaused(), JSON.stringify(executor.getSnapshot().sharedState));
|
|
303
|
+
// false {"amount":500,"approved":true,"done":true}
|
|
304
|
+
console.log(executor.getNarrativeEntries().filter((e) => e.type === 'pause' || e.type === 'resume').map((e) => e.text));
|
|
305
|
+
// [
|
|
306
|
+
// 'Execution paused at Approve.',
|
|
307
|
+
// 'Execution resumed at Approve with input.'
|
|
308
|
+
// ]
|
|
309
|
+
void single;
|
|
185
310
|
```
|
|
186
311
|
|
|
187
312
|
- `execute` returns data → pauses. Returns void → continues normally (conditional pause).
|
|
188
|
-
- Checkpoint is JSON-serializable — no functions, no class instances.
|
|
189
|
-
- `resume()`
|
|
190
|
-
- A resume re-enters ONCE, then the run is the chart as built: a `loopTo` back to the paused stage runs that stage again (it pauses again — a re-ask),
|
|
313
|
+
- Checkpoint is JSON-serializable — no functions, no class instances. A fresh executor resumes it too (`new FlowChartExecutor(chart).resume(JSON.parse(stored), input)`).
|
|
314
|
+
- `resume()` on the SAME executor reuses its runtime — narrative, metrics, execution tree all accumulate; a fresh executor seeds a new runtime from `checkpoint.sharedState`.
|
|
315
|
+
- A resume re-enters ONCE, then the run is the chart as built: a `loopTo` back to the paused stage runs that stage again (it pauses again — a re-ask), and the next pass through a subflow mount is a fresh entry (its inputMapper runs). A pause several subflows deep does not re-run the outer subflows' earlier stages, and a subflow mounted as a decider/selector branch or a fork child hands back to its parent's continuation (the decider's `next`, the fork's join). (Since 9.28.0.)
|
|
191
316
|
- Two parallel siblings that pause in one fan-out are asked IN TURN: the checkpoint asks the first and carries the others in `checkpoint.pendingPauses`; each resume answers one; the join runs after the last. (Since 9.28.0.)
|
|
192
|
-
- Not resumable yet: a pause inside a LAZY subflow or an `addParallelForEach` branch — `resume()`
|
|
193
|
-
- `FlowRecorder.onPause`/`onResume` and `
|
|
317
|
+
- Not resumable yet: a pause inside a LAZY subflow or an `addParallelForEach` branch — `resume()` rejects (`Cannot resume: stage 'lz/sf-ask' not found in flowchart`). A subflow id mounted twice in one chart — `resume()` refuses a pause inside it (give each mount its own id).
|
|
318
|
+
- `FlowRecorder.onPause`/`onResume` and `ScopeRecorder.onPause`/`onResume` fire on both observer systems.
|
|
194
319
|
|
|
195
320
|
### ComposableRunner & Snapshot Navigation
|
|
196
321
|
|
|
322
|
+
`ComposableRunner<TIn, TOut>` is the interface for a runner that exposes its internal chart so a parent can mount it as a subflow: `toFlowChart()` and `run(input, options?)`.
|
|
323
|
+
|
|
197
324
|
```typescript
|
|
325
|
+
import { flowChart, FlowChartExecutor, getSubtreeSnapshot, listSubflowPaths } from 'footprintjs';
|
|
198
326
|
import type { ComposableRunner } from 'footprintjs';
|
|
199
|
-
import { getSubtreeSnapshot, listSubflowPaths } from 'footprintjs';
|
|
200
327
|
|
|
201
|
-
|
|
202
|
-
|
|
328
|
+
class Doubler implements ComposableRunner<number, number> {
|
|
329
|
+
toFlowChart() {
|
|
330
|
+
return flowChart<{ out?: number }>('Double', (scope) => { scope.out = scope.$getArgs<{ n: number }>().n * 2; }, 'double').build();
|
|
331
|
+
}
|
|
332
|
+
async run(input: number): Promise<number> {
|
|
333
|
+
const executor = new FlowChartExecutor(this.toFlowChart());
|
|
334
|
+
await executor.run({ input: { n: input } });
|
|
335
|
+
return (executor.getSnapshot().sharedState as { out: number }).out;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const outer = flowChart<{ out?: number }>('Outer', () => {}, 'outer')
|
|
340
|
+
.addSubFlowChartNext('sf-doubler', new Doubler().toFlowChart(), 'Doubler', {
|
|
341
|
+
inputMapper: () => ({ n: 21 }),
|
|
342
|
+
outputMapper: (sub: { out?: number }) => ({ out: sub.out }),
|
|
343
|
+
})
|
|
344
|
+
.build();
|
|
345
|
+
const root = flowChart<{ out?: number }>('Root', () => {}, 'root')
|
|
346
|
+
.addSubFlowChartNext('sf-outer', outer, 'OuterMount', { outputMapper: (sub: { out?: number }) => ({ out: sub.out }) })
|
|
347
|
+
.build();
|
|
348
|
+
|
|
349
|
+
const executor = new FlowChartExecutor(root);
|
|
350
|
+
await executor.run();
|
|
351
|
+
const snapshot = executor.getSnapshot();
|
|
352
|
+
|
|
353
|
+
console.log(await new Doubler().run(4));
|
|
354
|
+
// 8
|
|
355
|
+
console.log(listSubflowPaths(snapshot));
|
|
356
|
+
// [ 'sf-outer/sf-doubler', 'sf-outer' ]
|
|
357
|
+
const subtree = getSubtreeSnapshot(snapshot, 'sf-outer/sf-doubler');
|
|
358
|
+
console.log(Object.keys(subtree ?? {}), JSON.stringify(subtree?.sharedState));
|
|
359
|
+
// [
|
|
360
|
+
// 'subflowId',
|
|
361
|
+
// 'executionTree',
|
|
362
|
+
// 'sharedState',
|
|
363
|
+
// 'history',
|
|
364
|
+
// 'initialState',
|
|
365
|
+
// 'narrativeEntries'
|
|
366
|
+
// ] {"n":21,"out":42}
|
|
203
367
|
```
|
|
204
368
|
|
|
205
369
|
## Observer Channels
|
|
206
370
|
|
|
207
|
-
Four pluggable observer channels — three fire at runtime (Scope, Flow, Emit) and one fires at build time (Structure).
|
|
371
|
+
Four pluggable observer channels — three fire at runtime (Scope, Flow, Emit) and one fires at build time (Structure). The runtime channels share `{ id, hooks } -> dispatcher -> error isolation -> attach/detach` (the Structure channel has `attachStructureRecorder` only). `attachCombinedRecorder(r)` routes a recorder to channels by runtime duck-typing of its `on*` methods. Intentionally NOT unified into one interface — each channel has a distinct invariant set.
|
|
208
372
|
|
|
209
373
|
**Recorder ID contract:**
|
|
210
|
-
- `
|
|
374
|
+
- `attachScopeRecorder` / `attachFlowRecorder` / `attachEmitRecorder` / `attachCombinedRecorder` are **idempotent by ID** — same ID replaces, different IDs coexist. Prevents accidental double-counting.
|
|
211
375
|
- Built-in recorders use auto-increment default IDs (`metrics-1`, `debug-1`, ...) so multiple instances with different configs coexist naturally.
|
|
212
376
|
- 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).
|
|
213
377
|
|
|
214
|
-
|
|
215
|
-
|
|
378
|
+
```typescript
|
|
379
|
+
import { flowChart, FlowChartExecutor, MetricRecorder, DebugRecorder } from 'footprintjs';
|
|
380
|
+
|
|
381
|
+
console.log(new MetricRecorder().id, new MetricRecorder().id, new MetricRecorder('metrics').id, new DebugRecorder({ id: 'dbg' }).id);
|
|
382
|
+
// metrics-1 metrics-2 metrics dbg
|
|
383
|
+
|
|
384
|
+
const executor = new FlowChartExecutor(flowChart<{ a: number }>('One', (scope) => { scope.a = 1; }, 'one').build());
|
|
385
|
+
let first = 0;
|
|
386
|
+
let second = 0;
|
|
387
|
+
executor.attachScopeRecorder({ id: 'same', onWrite: () => { first++; } });
|
|
388
|
+
executor.attachScopeRecorder({ id: 'same', onWrite: () => { second++; } }); // same id: replaces the first
|
|
389
|
+
await executor.run();
|
|
390
|
+
console.log(first, second);
|
|
391
|
+
// 0 1
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
**Scope Recorder** (data ops — `onStageStart` → `onRead`/`onWrite` DURING the stage function → `onStageEnd` → `onCommit`):
|
|
395
|
+
- `onStageStart`, `onRead`, `onWrite`, `onStageEnd`, `onCommit`, `onError`, `onPause`, `onResume`
|
|
216
396
|
- Built-in: `MetricRecorder`, `DebugRecorder`
|
|
217
397
|
|
|
218
|
-
**FlowRecorder** (control flow — fires AFTER stage
|
|
219
|
-
- `onStageExecuted` (universal "did this stage run", carries `stageType: 'linear' | 'decider' | 'fork' | 'selector' | 'subflow-mount'`), `onNext`, `onDecision`, `onFork`, `onSelected`, `onSubflowEntry/Exit`, `onSubflowRegistered`, `onLoop`, `onBreak`, `onError`, `onPause`/`onResume`, `onRunStart`/`onRunEnd`, `onRunFailed`
|
|
220
|
-
-
|
|
398
|
+
**FlowRecorder** (control flow — fires AFTER the stage's commit):
|
|
399
|
+
- `onStageExecuted` (universal "did this stage run", carries `stageType: 'linear' | 'decider' | 'fork' | 'selector' | 'subflow-mount'`), `onNext`, `onDecision`, `onFork`, `onSelected`, `onSubflowEntry/Exit`, `onSubflowRegistered`, `onLoop`, `onBreak`, `onStageRetry`, `onError`, `onPause`/`onResume`, `onRunStart`/`onRunEnd`, `onRunFailed`
|
|
400
|
+
- Events carry an optional `traversalContext: TraversalContext` (includes per-run `runId`)
|
|
221
401
|
- `onDecision`/`onSelected` carry optional `evidence` from decide()/select()
|
|
222
402
|
- Built-in: 9 strategies (Narrative, Adaptive, Windowed, RLE, Milestone, Progressive, Separate, Manifest, Silent)
|
|
223
403
|
|
|
@@ -225,76 +405,154 @@ Four pluggable observer channels — three fire at runtime (Scope, Flow, Emit) a
|
|
|
225
405
|
- `onEmit(EmitEvent)` — see the "Emit Channel" section below.
|
|
226
406
|
|
|
227
407
|
**Structure Recorder** (build-time chart shape — fires SYNCHRONOUSLY during builder operations, NOT runtime):
|
|
228
|
-
- `onStageAdded`, `onEdgeAdded`, `onLoopEdgeAdded`, `onDeciderComplete`, `onSubflowMounted`
|
|
229
|
-
- Attach via options bag — `flowChart('seed', fn, 'seed', { structureRecorders: [rec] })` — or fluent `.attachStructureRecorder(rec)`. MOUNT-ONLY: a builder's recorder sees only that builder's events; subflow internals arrive via the mount event's `subflowSpec` (walk with `walkSubflowSpec` from `footprintjs/trace`).
|
|
408
|
+
- `onStageAdded`, `onStageTagged`, `onEdgeAdded`, `onLoopEdgeAdded`, `onDeciderComplete`, `onSubflowMounted`
|
|
409
|
+
- Attach via options bag — `flowChart('seed', fn, 'seed', { structureRecorders: [rec] })` — or fluent `.attachStructureRecorder(rec)`. MOUNT-ONLY: a builder's recorder sees only that builder's events; subflow internals arrive via the mount event's `subflowSpec` (walk with `walkSubflowSpec` from `footprintjs/trace`). The types are exported from the main `footprintjs` barrel.
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
import { flowChart } from 'footprintjs';
|
|
413
|
+
import type { StructureRecorder, StructureSubflowMountedEvent } from 'footprintjs';
|
|
414
|
+
import { walkSubflowSpec } from 'footprintjs/trace';
|
|
415
|
+
|
|
416
|
+
const events: string[] = [];
|
|
417
|
+
let mounted: StructureSubflowMountedEvent | undefined;
|
|
418
|
+
const recorder: StructureRecorder = {
|
|
419
|
+
id: 'structure',
|
|
420
|
+
onStageAdded: (e) => { events.push(`stage ${e.spec.id}`); },
|
|
421
|
+
onEdgeAdded: (e) => { events.push(`edge ${e.from} -> ${e.to} (${e.kind})`); },
|
|
422
|
+
onSubflowMounted: (e) => { events.push(`mounted ${e.subflowId}`); mounted = e; },
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const sub = flowChart<object>('SubA', () => {}, 'sub-a').addFunction('SubB', () => {}, 'sub-b').build();
|
|
426
|
+
flowChart<object>('Seed', () => {}, 'seed', { structureRecorders: [recorder] })
|
|
427
|
+
.addSubFlowChartNext('sf', sub, 'Sub')
|
|
428
|
+
.build();
|
|
429
|
+
|
|
430
|
+
console.log(events); // the subflow's own stages (sub-a, sub-b) are NOT reported here
|
|
431
|
+
// [ 'stage seed', 'stage sf', 'edge seed -> sf (next)', 'mounted sf' ]
|
|
432
|
+
for (const item of walkSubflowSpec(mounted!.subflowSpec!, mounted!.subflowPath ?? mounted!.subflowId)) {
|
|
433
|
+
console.log(item.kind);
|
|
434
|
+
}
|
|
435
|
+
// subflow-start
|
|
436
|
+
// stage
|
|
437
|
+
// edge
|
|
438
|
+
// stage
|
|
439
|
+
```
|
|
230
440
|
|
|
231
|
-
**CombinedNarrativeRecorder** implements the Scope + Flow + Emit interfaces.
|
|
441
|
+
**CombinedNarrativeRecorder** implements the Scope + Flow + Emit interfaces. `executor.enableNarrative()` installs it (read it with `executor.getNarrativeEntries()`); `chart.recorder(narrative())` attaches a `narrative()` instance for a one-shot run, and `executor.attachCombinedRecorder(narrative())` works too — read either back from the instance (`recorder.getEntries()`); the executor's own `getNarrativeEntries()` belongs to `enableNarrative()`. It is not exported as a class.
|
|
232
442
|
|
|
233
443
|
## Event Ordering
|
|
234
444
|
|
|
235
445
|
```
|
|
236
|
-
0. FlowRecorder.onRunStart
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
446
|
+
0. FlowRecorder.onRunStart — once per executor.run(), before any stage; event.payload = the run input
|
|
447
|
+
Per stage, ScopeRecorder events:
|
|
448
|
+
1. Recorder.onStageStart — stage begins
|
|
449
|
+
2. Recorder.onRead/onWrite — DURING execution, before the commit
|
|
450
|
+
3. Recorder.onStageEnd — the stage function returned
|
|
451
|
+
4. Recorder.onCommit — transaction flush
|
|
452
|
+
Then FlowRecorder events, by stage kind:
|
|
453
|
+
5. linear stage onStageExecuted → onNext
|
|
454
|
+
decider onDecision → onStageExecuted
|
|
455
|
+
selector onSelected → onStageExecuted → onFork
|
|
456
|
+
fork parent onStageExecuted → onFork → onStageExecuted (stageType 'fork')
|
|
457
|
+
subflow mount onNext → onSubflowEntry → onStageExecuted (stageType 'subflow-mount')
|
|
458
|
+
→ the subflow's own stages → onSubflowExit
|
|
459
|
+
6. FlowRecorder.onRunEnd (clean; event.payload = the chart's return value) or onRunFailed (error; event.structuredError) — once per run, closes the boundary symmetrically
|
|
244
460
|
```
|
|
245
461
|
|
|
462
|
+
`CombinedNarrativeRecorder` flushes a stage's buffered reads and writes when that stage's flow event arrives: `onStageExecuted` for LINEAR stages, `onDecision` / `onSelected` / `onFork` / `onSubflowEntry` for the others.
|
|
463
|
+
|
|
246
464
|
## Execution Tracing (`footprintjs/trace`)
|
|
247
465
|
|
|
248
466
|
Every stage execution gets a unique `runtimeStageId` — the universal key that links recorder events, commit log entries, and execution tree nodes.
|
|
249
467
|
|
|
250
468
|
**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).
|
|
251
469
|
|
|
252
|
-
**Format:** `[subflowPath/]stageId#executionIndex`
|
|
470
|
+
**Format:** `[subflowPath/]stageId#executionIndex` — the index starts at 0 and counts every stage execution across the run (a loop revisits the same stageId with a higher index):
|
|
253
471
|
|
|
254
472
|
```
|
|
255
|
-
seed#0 — root stage
|
|
256
|
-
|
|
257
|
-
sf-
|
|
258
|
-
|
|
473
|
+
seed#0 — root stage, the first execution
|
|
474
|
+
tick#6 — the seventh execution
|
|
475
|
+
sf-outer/sf-inner/inner-end#5 — a stage inside a nested subflow
|
|
476
|
+
tick#9 — same stageId, different execution (loop)
|
|
259
477
|
```
|
|
260
478
|
|
|
261
|
-
**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`.
|
|
479
|
+
**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`. A subflow mount appears twice in the root log under one runtimeStageId; the subflow's own stage commits are in its subtree history (`getSubtreeSnapshot(snapshot, path).history`).
|
|
262
480
|
|
|
263
481
|
```typescript
|
|
264
|
-
import {
|
|
482
|
+
import { flowChart, FlowChartExecutor, decide, getSubtreeSnapshot } from 'footprintjs';
|
|
483
|
+
import { parseRuntimeStageId, buildRuntimeStageId, splitStageId, findLastWriter, findCommit, findCommits, isCommitBundle } from 'footprintjs/trace';
|
|
265
484
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
485
|
+
const inner = flowChart<{ v: number; t?: number; out?: number }>('InnerStart', (scope) => { scope.t = scope.v + 1; }, 'inner-start')
|
|
486
|
+
.addFunction('InnerEnd', (scope) => { scope.out = (scope.t ?? 0) * 2; }, 'inner-end')
|
|
487
|
+
.build();
|
|
488
|
+
const outer = flowChart<{ n: number; w?: number; out?: number }>('OuterStart', (scope) => { scope.w = scope.n; }, 'outer-start')
|
|
489
|
+
.addSubFlowChartNext('sf-inner', inner, 'Inner', {
|
|
490
|
+
inputMapper: (parent: { w?: number }) => ({ v: parent.w }),
|
|
491
|
+
outputMapper: (sub: { out?: number }) => ({ out: sub.out }),
|
|
492
|
+
})
|
|
493
|
+
.build();
|
|
494
|
+
const chart = flowChart<{ n: number; count: number }>('Seed', (scope) => { scope.n = 1; scope.count = 0; }, 'seed')
|
|
495
|
+
.addSubFlowChartNext('sf-outer', outer, 'Outer', {
|
|
496
|
+
inputMapper: (parent: { n: number }) => ({ n: parent.n }),
|
|
497
|
+
outputMapper: (sub: { out?: number }) => ({ out: sub.out }),
|
|
498
|
+
})
|
|
499
|
+
.addFunction('Tick', (scope) => { scope.count = scope.count + 1; }, 'tick')
|
|
500
|
+
.addDeciderFunction('Route', (scope) => decide(scope, [
|
|
501
|
+
{ when: { count: { lt: 2 } }, then: 'again', label: 'under 2' },
|
|
502
|
+
], 'done'), 'route')
|
|
503
|
+
.addFunctionBranch('again', 'Again', () => {})
|
|
504
|
+
.loopTo('tick')
|
|
505
|
+
.addFunctionBranch('done', 'Done', () => {})
|
|
506
|
+
.end()
|
|
507
|
+
.build();
|
|
508
|
+
|
|
509
|
+
const executor = new FlowChartExecutor(chart);
|
|
510
|
+
await executor.run();
|
|
269
511
|
|
|
270
512
|
// Get the commit log after execution
|
|
271
513
|
const snapshot = executor.getSnapshot();
|
|
272
514
|
const commitLog = snapshot.commitLog; // CommitBundle[]
|
|
515
|
+
console.log(commitLog.map((bundle) => bundle.runtimeStageId).join(' '));
|
|
516
|
+
// seed#0 sf-outer#1 sf-outer#1 tick#6 route#7 again#8 tick#9 route#10 done#11
|
|
517
|
+
|
|
518
|
+
// A subflow's own stage commits live in its subtree history, not in the root log
|
|
519
|
+
const innerHistory = (getSubtreeSnapshot(snapshot, 'sf-outer/sf-inner')?.history ?? []).filter(isCommitBundle);
|
|
520
|
+
console.log(innerHistory.map((bundle) => bundle.runtimeStageId).join(' '));
|
|
521
|
+
// sf-outer/sf-inner/inner-start#4 sf-outer/sf-inner/inner-end#5
|
|
522
|
+
|
|
523
|
+
// Parse a runtimeStageId into components
|
|
524
|
+
console.log(JSON.stringify(parseRuntimeStageId(innerHistory[1].runtimeStageId)));
|
|
525
|
+
// {"stageId":"inner-start","executionIndex":4,"subflowPath":"sf-outer/sf-inner"}
|
|
526
|
+
console.log(buildRuntimeStageId('inner-end', 5, 'sf-outer/sf-inner'), JSON.stringify(splitStageId('sf-outer/sf-inner/inner-end')));
|
|
527
|
+
// sf-outer/sf-inner/inner-end#5 {"localStageId":"inner-end","subflowPath":"sf-outer/sf-inner"}
|
|
273
528
|
|
|
274
|
-
// Backtrack: who last wrote '
|
|
529
|
+
// Backtrack: who last wrote 'count' before commitLog array index 6?
|
|
275
530
|
// beforeIdx is the CommitBundle.idx (array position), NOT the executionIndex from runtimeStageId.
|
|
276
|
-
const writer = findLastWriter(commitLog, '
|
|
277
|
-
|
|
531
|
+
const writer = findLastWriter(commitLog, 'count', 6);
|
|
532
|
+
console.log(writer?.runtimeStageId, writer?.idx, JSON.stringify(writer?.trace));
|
|
533
|
+
// tick#6 3 [{"path":"count","verb":"set"}]
|
|
534
|
+
// → CommitBundle | undefined (has .idx, .stage, .stageId, .runtimeStageId, .trace, .overwrite, .updates, .redactedPaths)
|
|
278
535
|
|
|
279
536
|
// Find by stageId: use findCommit when you know the stage.
|
|
280
537
|
// Use findLastWriter when you know the key but not which stage wrote it.
|
|
281
|
-
|
|
538
|
+
console.log(findCommit(commitLog, 'tick', 'count')?.runtimeStageId, findCommits(commitLog, 'tick').map((bundle) => bundle.runtimeStageId));
|
|
539
|
+
// tick#6 [ 'tick#6', 'tick#9' ]
|
|
282
540
|
```
|
|
283
541
|
|
|
284
|
-
**Exports from `footprintjs/trace
|
|
542
|
+
**Exports from `footprintjs/trace`** (the door has many more — causal chains, slices, time travel, honesty codes, `ControlDepRecorder`, `QualityRecorder`; see `src/lib/slice/README.md` and `src/lib/time-travel/README.md`):
|
|
285
543
|
|
|
286
544
|
| Export | Returns | Use |
|
|
287
545
|
|--------|---------|-----|
|
|
288
546
|
| `buildRuntimeStageId(stageId, idx, subflowPath?)` | `string` | Construct an ID from components |
|
|
289
|
-
| `parseRuntimeStageId(id)` | `{ stageId, executionIndex, subflowPath }` | Decompose an ID |
|
|
290
|
-
| `findCommit(commitLog, stageId, key?)` | `CommitBundle \| undefined` | Find first commit by stageId |
|
|
547
|
+
| `parseRuntimeStageId(id)` | `{ stageId, executionIndex, subflowPath }` (`subflowPath` is `undefined` at top level) | Decompose an ID |
|
|
548
|
+
| `findCommit(commitLog, stageId, key?)` | `CommitBundle \| undefined` | Find the first commit by stageId (that wrote `key`, if given) |
|
|
291
549
|
| `findCommits(commitLog, stageId)` | `CommitBundle[]` | Find all commits by stageId |
|
|
292
550
|
| `findLastWriter(commitLog, key, beforeIdx?)` | `CommitBundle \| undefined` | Search backwards for who wrote a key |
|
|
293
551
|
| `splitStageId(prefixedId)` | `{ localStageId, subflowPath }` | Decompose a bare prefixed id (`spec.id`, `CommitBundle.stageId`) |
|
|
294
552
|
| `walkSubflowSpec(spec, subflowPath, opts?)` | `Generator<WalkerItem>` | Walk a subflow spec from `StructureSubflowMountedEvent.subflowSpec` |
|
|
295
|
-
| `KeyedStore<T>` | class (
|
|
296
|
-
| `SequenceStore<T>` | class (
|
|
297
|
-
| `BoundaryStateStore<TState>` | class (
|
|
553
|
+
| `KeyedStore<T>` | class (since 5.0.0) | Storage shelf for 1:1 Map keyed by runtimeStageId (`set`/`get`/`aggregate`/`accumulate`/`filterByKeys`) |
|
|
554
|
+
| `SequenceStore<T>` | class (since 5.0.0) | Storage shelf for 1:N ordered entries (`push`/`getByKey`/`getEntryRanges()` for O(1) time-travel/`getEntriesUpTo`) |
|
|
555
|
+
| `BoundaryStateStore<TState>` | class (since 5.0.0) | Storage shelf for transient bracket-scoped state — live state DURING a `[start, stop]` interval; clears on stop. O(1) reads via `get` / `hasActive` / `activeCount`; lifecycle via `start` / `update` / `stop`. |
|
|
298
556
|
| `KeyedRecorder<T>` / `SequenceRecorder<T>` / `BoundaryStateTracker<TState>` | abstract bases — **REMOVED in 7.0.0** | Gone — no inheritance path remains. Superseded by the `*Store` classes above: own a store as a field and implement the channel interface yourself. |
|
|
299
557
|
| `CommitRangeIndex<TLabel>` | class | Interval index over commit indices (`open`/`close`/`enclosing`/`overlapping`) |
|
|
300
558
|
| `topologyRecorder()` / `TopologyRecorder` | factory / class | Live composition graph for streaming consumers (subflow nodes + control-flow edges) |
|
|
@@ -302,7 +560,7 @@ const llmCommit = findCommit(commitLog, 'call-llm', 'adapterRawResponse');
|
|
|
302
560
|
|
|
303
561
|
### TopologyRecorder — Composition Graph for Streaming Consumers
|
|
304
562
|
|
|
305
|
-
**One-liner:** reconstructs a live, queryable mini-flowchart of what your run actually traced, built from
|
|
563
|
+
**One-liner:** reconstructs a live, queryable mini-flowchart of what your run actually traced, built from FlowRecorder events (`onSubflowEntry`/`onSubflowExit`, `onFork`, `onDecision`, `onLoop`) during traversal.
|
|
306
564
|
|
|
307
565
|
**Mental model:**
|
|
308
566
|
|
|
@@ -310,22 +568,21 @@ const llmCommit = findCommit(commitLog, 'call-llm', 'adapterRawResponse');
|
|
|
310
568
|
flowChart() builder → STATIC flowchart (design-time definition)
|
|
311
569
|
│
|
|
312
570
|
▼ executor runs it
|
|
313
|
-
Traversal emits events
|
|
314
|
-
Recorder · FlowRecorder · EmitRecorder
|
|
571
|
+
Traversal emits FlowRecorder events
|
|
315
572
|
│
|
|
316
573
|
▼ TopologyRecorder listens
|
|
317
574
|
DYNAMIC flowchart (runtime shape):
|
|
318
575
|
Nodes = composition points
|
|
319
576
|
(subflow / fork-branch / decision-branch)
|
|
320
577
|
Edges = transitions
|
|
321
|
-
(next / fork / decision / loop)
|
|
578
|
+
(next / fork-branch / decision-branch / loop-iteration)
|
|
322
579
|
Queryable any moment — during or after run
|
|
323
580
|
```
|
|
324
581
|
|
|
325
582
|
**What it IS:**
|
|
326
|
-
- Live composition graph derived from
|
|
583
|
+
- Live composition graph derived from flow events
|
|
327
584
|
- Each node = one composition-significant moment (subflow entered, fork child, decision chosen)
|
|
328
|
-
- Each edge = a control-flow transition,
|
|
585
|
+
- Each edge = a control-flow transition, stamped with the `runtimeStageId` it happened at (`edge.at`)
|
|
329
586
|
- Works identically during or after a run
|
|
330
587
|
|
|
331
588
|
**What it ISN'T:**
|
|
@@ -338,17 +595,62 @@ flowChart() builder → STATIC flowchart (design-time definition)
|
|
|
338
595
|
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.
|
|
339
596
|
|
|
340
597
|
```typescript
|
|
598
|
+
import { flowChart, FlowChartExecutor, decide } from 'footprintjs';
|
|
341
599
|
import { topologyRecorder } from 'footprintjs/trace';
|
|
342
600
|
|
|
601
|
+
const agent = flowChart<{ count: number }>('Start', (scope) => { scope.count = 0; }, 'start')
|
|
602
|
+
.addFunction('Tick', (scope) => { scope.count = scope.count + 1; }, 'tick')
|
|
603
|
+
.addDeciderFunction('Check', (scope) => decide(scope, [
|
|
604
|
+
{ when: { count: { lt: 2 } }, then: 'again', label: 'under 2' },
|
|
605
|
+
], 'done'), 'check')
|
|
606
|
+
.addFunctionBranch('again', 'Again', () => {})
|
|
607
|
+
.loopTo('tick')
|
|
608
|
+
.addFunctionBranch('done', 'Done', () => {})
|
|
609
|
+
.end()
|
|
610
|
+
.build();
|
|
611
|
+
const fanOut = flowChart<object>('Host', () => {}, 'host')
|
|
612
|
+
.addListOfFunction([
|
|
613
|
+
{ id: 'x', name: 'X', fn: () => {} },
|
|
614
|
+
{ id: 'y', name: 'Y', fn: () => {} },
|
|
615
|
+
])
|
|
616
|
+
.build();
|
|
617
|
+
const chart = flowChart<object>('Seed', () => {}, 'seed')
|
|
618
|
+
.addSubFlowChartNext('sf-agent', agent, 'Agent')
|
|
619
|
+
.addSubFlowChartNext('sf-fanout', fanOut, 'FanOut')
|
|
620
|
+
.build();
|
|
621
|
+
|
|
622
|
+
const executor = new FlowChartExecutor(chart);
|
|
343
623
|
const topo = topologyRecorder();
|
|
344
624
|
executor.attachCombinedRecorder(topo); // auto-routes to FlowRecorder channel
|
|
345
625
|
|
|
346
|
-
await executor.run(
|
|
626
|
+
await executor.run();
|
|
347
627
|
|
|
348
628
|
const { nodes, edges, activeNodeId, rootId } = topo.getTopology();
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
629
|
+
console.log(nodes.map((node) => `${node.kind} ${node.id}`));
|
|
630
|
+
// [
|
|
631
|
+
// 'subflow sf-agent',
|
|
632
|
+
// 'decision-branch decision-sf-agent/check#4-sf-agent/Again',
|
|
633
|
+
// 'decision-branch decision-sf-agent/check#7-sf-agent/Done',
|
|
634
|
+
// 'subflow sf-fanout',
|
|
635
|
+
// 'fork-branch fork-sf-fanout/host#10-0-sf-fanout/X',
|
|
636
|
+
// 'fork-branch fork-sf-fanout/host#10-1-sf-fanout/Y'
|
|
637
|
+
// ]
|
|
638
|
+
console.log(edges.map((edge) => `${edge.kind}: ${edge.from} -> ${edge.to}`));
|
|
639
|
+
// [
|
|
640
|
+
// 'decision-branch: sf-agent -> decision-sf-agent/check#4-sf-agent/Again',
|
|
641
|
+
// 'loop-iteration: sf-agent -> sf-agent',
|
|
642
|
+
// 'decision-branch: sf-agent -> decision-sf-agent/check#7-sf-agent/Done',
|
|
643
|
+
// 'next: sf-agent -> sf-fanout',
|
|
644
|
+
// 'fork-branch: sf-fanout -> fork-sf-fanout/host#10-0-sf-fanout/X',
|
|
645
|
+
// 'fork-branch: sf-fanout -> fork-sf-fanout/host#10-1-sf-fanout/Y'
|
|
646
|
+
// ]
|
|
647
|
+
console.log(activeNodeId, rootId);
|
|
648
|
+
// null sf-agent
|
|
649
|
+
console.log(topo.getSubflowNodes().map((node) => node.id)); // agent-centric view
|
|
650
|
+
// [ 'sf-agent', 'sf-fanout' ]
|
|
651
|
+
console.log(topo.getByKind('fork-branch').map((node) => node.name)); // all parallel branches
|
|
652
|
+
// [ 'sf-fanout/X', 'sf-fanout/Y' ]
|
|
653
|
+
// topo.getParallelSiblings(id) — siblings of a parallel branch; topo.getChildren(id) — direct children
|
|
352
654
|
```
|
|
353
655
|
|
|
354
656
|
**Three node kinds — complete composition coverage:**
|
|
@@ -359,16 +661,16 @@ topo.getParallelSiblings(id); // siblings of a parallel branch
|
|
|
359
661
|
| `fork-branch` | `onFork` (synthesized one per child) | One branch of a parallel split — works for plain stages AND subflows |
|
|
360
662
|
| `decision-branch` | `onDecision` (synthesized for chosen) | The chosen branch of a conditional |
|
|
361
663
|
|
|
362
|
-
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."
|
|
664
|
+
When a fork-branch or decision-branch target is also a subflow, the subsequent `onSubflowEntry` creates a subflow CHILD of the synthetic node (verified for a fork/decider at the top level of a run; inside another subflow the branch names carry the subflow path prefix and the subflow node attaches to the enclosing subflow instead). Layered shape preserves both "who branched" and "what the branch ran."
|
|
363
665
|
|
|
364
|
-
**Edges:**
|
|
666
|
+
**Edges:** `edge.kind ∈ 'next' | 'fork-branch' | 'decision-branch' | 'loop-iteration'`. Each carries `at: runtimeStageId` for time correlation. Fork, decision and loop edges are recorded only while a subflow is active — a fork or decision at the top level of the run still creates its nodes, but no edge — and a `loopTo` outside any subflow adds nothing.
|
|
365
667
|
|
|
366
668
|
**Correlation rules:**
|
|
367
669
|
- `onFork({ parent, children })` → N `fork-branch` nodes synthesized up-front; subsequent matching `onSubflowEntry` nests under the right fork-branch
|
|
368
670
|
- `onDecision({ chosen })` → `decision-branch` node synthesized up-front; matching `onSubflowEntry` nests under it
|
|
369
|
-
-
|
|
671
|
+
- A pending decision clears on `onSubflowExit` so it can't match an unrelated subflow later; pending fork-sibling entries survive scope exits (a sibling's inner subflow may exit before the next sibling enters) and clear on the next `onFork` or on a match
|
|
370
672
|
- `onLoop` → self-edge on the currently-active subflow (synthetic nodes don't participate)
|
|
371
|
-
- Re-entry of same `subflowId` (loop body) disambiguates via `id#n` suffix
|
|
673
|
+
- Re-entry of same `subflowId` (loop body) disambiguates via `id#n` suffix (`sf-body`, `sf-body#1`)
|
|
372
674
|
|
|
373
675
|
**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.
|
|
374
676
|
|
|
@@ -399,7 +701,7 @@ Each chart execution → 2 boundaries:
|
|
|
399
701
|
- **Root** — `onRunStart` / `onRunEnd` fire ONCE per `executor.run()`. `subflowId: '__root__'`, `depth: 0`, `isRoot: true`.
|
|
400
702
|
- **Subflow** — `onSubflowEntry` / `onSubflowExit` fire once per mounted subflow. Nested under root in the path tree (`['__root__', 'sf-x']`, depth 1+).
|
|
401
703
|
|
|
402
|
-
Loop re-entry produces distinct pairs because the parent stage's executionIndex increments.
|
|
704
|
+
Loop re-entry produces distinct pairs because the parent stage's executionIndex increments (`sf-body#2`, `sf-body#7`).
|
|
403
705
|
|
|
404
706
|
**What it IS:**
|
|
405
707
|
- composes `SequenceStore<InOutEntry>` — flat ordered list + per-`runtimeStageId` index
|
|
@@ -413,18 +715,33 @@ Loop re-entry produces distinct pairs because the parent stage's executionIndex
|
|
|
413
715
|
- Not agent-specific — domain libraries (e.g. agentfootprint) compose it; footprintjs owns it
|
|
414
716
|
|
|
415
717
|
```typescript
|
|
718
|
+
import { flowChart, FlowChartExecutor } from 'footprintjs';
|
|
416
719
|
import { inOutRecorder, ROOT_SUBFLOW_ID } from 'footprintjs/trace';
|
|
417
720
|
|
|
721
|
+
const sub = flowChart<{ q: number; r?: number }>('Double', (scope) => { scope.r = scope.q * 2; }, 'double').build();
|
|
722
|
+
const chart = flowChart<{ n: number; out?: number }>('Seed', (scope) => { scope.n = 21; }, 'seed')
|
|
723
|
+
.addSubFlowChartNext('sf-double', sub, 'Doubler', {
|
|
724
|
+
inputMapper: (parent: { n: number }) => ({ q: parent.n }),
|
|
725
|
+
outputMapper: (out: { r?: number }) => ({ out: out.r }),
|
|
726
|
+
})
|
|
727
|
+
.build();
|
|
728
|
+
|
|
729
|
+
const executor = new FlowChartExecutor(chart);
|
|
418
730
|
const inOut = inOutRecorder();
|
|
419
731
|
executor.attachCombinedRecorder(inOut);
|
|
420
732
|
|
|
421
|
-
await executor.run({ input });
|
|
422
|
-
|
|
423
|
-
inOut.getSteps();
|
|
424
|
-
|
|
425
|
-
inOut.
|
|
426
|
-
|
|
427
|
-
|
|
733
|
+
await executor.run({ input: { start: true } });
|
|
734
|
+
|
|
735
|
+
console.log(inOut.getSteps().map((step) => step.runtimeStageId), ROOT_SUBFLOW_ID); // entry boundaries (timeline; root is first step)
|
|
736
|
+
// [ '__root__#0', 'sf-double#1' ] __root__
|
|
737
|
+
const { entry, exit } = inOut.getBoundary('sf-double#1')!; // { entry, exit } pair for one execution
|
|
738
|
+
console.log(JSON.stringify(entry?.payload), JSON.stringify(exit?.payload));
|
|
739
|
+
// {"q":21} {"q":21,"r":42}
|
|
740
|
+
const rootBoundary = inOut.getRootBoundary(); // { entry, exit } for the top-level run
|
|
741
|
+
console.log(JSON.stringify(rootBoundary?.entry?.payload), rootBoundary?.exit?.payload);
|
|
742
|
+
// {"start":true} undefined
|
|
743
|
+
// inOut.getBoundaries() — flat list (entry+exit interleaved)
|
|
744
|
+
// inOut.getEntryRanges() — O(1) per-step range index for time-travel
|
|
428
745
|
```
|
|
429
746
|
|
|
430
747
|
**`InOutEntry` shape:**
|
|
@@ -442,7 +759,7 @@ inOut.getEntryRanges(); // O(1) per-step range index for time-trave
|
|
|
442
759
|
| `payload` | `entry`: `inputMapper` result (subflow) or `run({input})` (root); `exit`: shared state at exit (subflow) or chart return value (root) |
|
|
443
760
|
| `isRoot` | True only for the synthetic root pair from `onRunStart` / `onRunEnd` |
|
|
444
761
|
|
|
445
|
-
**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
|
|
762
|
+
**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`: `getBoundary()` returns `{ entry, exit: undefined }`. Resuming on the same executor appends a second root pair and a fresh pair for the re-entered subflow (new runtimeStageId) — the first leg's dangling `entry` stays as recorded.
|
|
446
763
|
|
|
447
764
|
**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`.
|
|
448
765
|
|
|
@@ -450,7 +767,7 @@ inOut.getEntryRanges(); // O(1) per-step range index for time-trave
|
|
|
450
767
|
|
|
451
768
|
Example: [examples/runtime-features/flow-recorder/07-inout.ts](examples/runtime-features/flow-recorder/07-inout.ts)
|
|
452
769
|
|
|
453
|
-
**Three recorder STORAGE PRIMITIVES (
|
|
770
|
+
**Three recorder STORAGE PRIMITIVES (since 5.0.0)** — "one purpose per recorder": a store is storage ONLY. You own one as a field and implement the channel interface (`ScopeRecorder` / `FlowRecorder` / `EmitRecorder` / `CombinedRecorder`) yourself, delegating storage to the store. The abstract base classes (`KeyedRecorder` / `SequenceRecorder` / `BoundaryStateTracker`) were REMOVED in 7.0.0 — composition is the only model. Choose a store by data shape and durability:
|
|
454
771
|
|
|
455
772
|
| Store | Relationship | Time scope | Use When |
|
|
456
773
|
|------------|-------------|------------|----------|
|
|
@@ -459,14 +776,18 @@ Example: [examples/runtime-features/flow-recorder/07-inout.ts](examples/runtime-
|
|
|
459
776
|
| `BoundaryStateStore<TState>` | Map\<key, TState\> active bracket | transient — clears on stop | Live state DURING a `[start, stop]` bracket (LLM stream partial, tool args streaming) |
|
|
460
777
|
|
|
461
778
|
```typescript
|
|
779
|
+
import { flowChart, FlowChartExecutor } from 'footprintjs';
|
|
780
|
+
import type { FlowRecorder, EmitRecorder, EmitEvent, FlowStageEvent, FlowDecisionEvent } from 'footprintjs';
|
|
462
781
|
import { KeyedStore, SequenceStore, BoundaryStateStore } from 'footprintjs/trace';
|
|
463
|
-
import type { FlowRecorder, EmitRecorder } from 'footprintjs';
|
|
464
782
|
|
|
465
783
|
// KeyedStore: one entry per step. Own the store; implement the channel.
|
|
466
784
|
class TokenRecorder implements FlowRecorder {
|
|
467
785
|
readonly id = 'tokens';
|
|
468
786
|
private store = new KeyedStore<{ tokens: number }>();
|
|
469
|
-
onStageExecuted(e
|
|
787
|
+
onStageExecuted(e: FlowStageEvent) {
|
|
788
|
+
const rid = e.traversalContext?.runtimeStageId;
|
|
789
|
+
if (rid) this.store.set(rid, { tokens: 10 });
|
|
790
|
+
}
|
|
470
791
|
byStep(rid: string) { return this.store.get(rid); } // Translate: per-step value
|
|
471
792
|
total() { return this.store.aggregate((sum, e) => sum + e.tokens, 0); } // Aggregate: grand total
|
|
472
793
|
upTo(keys: ReadonlySet<string>) { // Accumulate: up to slider
|
|
@@ -478,7 +799,9 @@ class TokenRecorder implements FlowRecorder {
|
|
|
478
799
|
class AuditRecorder implements FlowRecorder {
|
|
479
800
|
readonly id = 'audit';
|
|
480
801
|
private store = new SequenceStore<{ runtimeStageId: string; type: string }>();
|
|
481
|
-
onDecision(e
|
|
802
|
+
onDecision(e: FlowDecisionEvent) {
|
|
803
|
+
this.store.push({ runtimeStageId: e.traversalContext?.runtimeStageId ?? '', type: 'decision' });
|
|
804
|
+
}
|
|
482
805
|
forStep(rid: string) { return this.store.getByKey(rid); } // Translate: per-step entries
|
|
483
806
|
upTo(keys: ReadonlySet<string>) { return this.store.getEntriesUpTo(keys); } // Progressive: up to slider
|
|
484
807
|
ranges() { return this.store.getEntryRanges(); } // Range index: O(1) slider sync
|
|
@@ -488,9 +811,10 @@ class AuditRecorder implements FlowRecorder {
|
|
|
488
811
|
class LiveLLMTracker implements EmitRecorder {
|
|
489
812
|
readonly id = 'live-llm';
|
|
490
813
|
private store = new BoundaryStateStore<{ partial: string; tokens: number }>();
|
|
491
|
-
onEmit(e) {
|
|
814
|
+
onEmit(e: EmitEvent) {
|
|
815
|
+
const payload = e.payload as { content?: string };
|
|
492
816
|
if (e.name === 'llm.start') this.store.start(e.runtimeStageId, { partial: '', tokens: 0 });
|
|
493
|
-
if (e.name === 'llm.token') this.store.update(e.runtimeStageId, s => ({ partial: s.partial +
|
|
817
|
+
if (e.name === 'llm.token') this.store.update(e.runtimeStageId, (s) => ({ partial: s.partial + (payload.content ?? ''), tokens: s.tokens + 1 }));
|
|
494
818
|
if (e.name === 'llm.end') this.store.stop(e.runtimeStageId);
|
|
495
819
|
}
|
|
496
820
|
isInFlight() { return this.store.hasActive; } // O(1) — live read
|
|
@@ -498,33 +822,81 @@ class LiveLLMTracker implements EmitRecorder {
|
|
|
498
822
|
concurrent() { return this.store.activeCount; } // O(1) — how many concurrent boundaries
|
|
499
823
|
}
|
|
500
824
|
// Lifecycle: call store.clear() between runs; dev-mode warns on leaked-stop bugs.
|
|
825
|
+
|
|
826
|
+
const tokens = new TokenRecorder();
|
|
827
|
+
const audit = new AuditRecorder();
|
|
828
|
+
const live = new LiveLLMTracker();
|
|
829
|
+
|
|
830
|
+
const chart = flowChart<object>('Ask', (scope) => {
|
|
831
|
+
scope.$emit('llm.start', {});
|
|
832
|
+
scope.$emit('llm.token', { content: 'Hel' });
|
|
833
|
+
scope.$emit('llm.token', { content: 'lo' });
|
|
834
|
+
console.log(live.isInFlight(), live.concurrent(), live.getPartial('ask#0')); // mid-bracket
|
|
835
|
+
scope.$emit('llm.end', {});
|
|
836
|
+
}, 'ask')
|
|
837
|
+
.addDeciderFunction('Check', () => 'ok', 'check')
|
|
838
|
+
.addFunctionBranch('ok', 'Ok', () => {})
|
|
839
|
+
.end()
|
|
840
|
+
.build();
|
|
841
|
+
|
|
842
|
+
const executor = new FlowChartExecutor(chart);
|
|
843
|
+
executor.attachFlowRecorder(tokens);
|
|
844
|
+
executor.attachFlowRecorder(audit);
|
|
845
|
+
executor.attachEmitRecorder(live);
|
|
846
|
+
await executor.run();
|
|
847
|
+
|
|
848
|
+
console.log(tokens.total(), tokens.byStep('ask#0'), tokens.upTo(new Set(['ask#0'])));
|
|
849
|
+
console.log(live.isInFlight(), live.concurrent(), JSON.stringify(audit.forStep('check#1')), audit.ranges().size);
|
|
850
|
+
// true 1 Hello
|
|
851
|
+
// 30 { tokens: 10 } 10
|
|
852
|
+
// false 0 [{"runtimeStageId":"check#1","type":"decision"}] 1
|
|
501
853
|
```
|
|
502
854
|
|
|
503
855
|
**`getEntryRanges()`** returns a precomputed `Map<runtimeStageId, {firstIdx, endIdx}>` maintained during `push()`. Use for O(1) per-step range lookups during time-travel scrubbing. Same shape as `buildEntryRangeIndex()` in `footprint-explainable-ui`.
|
|
504
856
|
|
|
505
|
-
**`CombinedNarrativeEntry.direction`** — subflow entries carry `direction: 'entry' | 'exit'
|
|
857
|
+
**`CombinedNarrativeEntry.direction`** — subflow entries carry `direction: 'entry' | 'exit'` (and `subflowId`). Use for programmatic subflow boundary detection instead of text scanning (which breaks with a custom `NarrativeFormatter`; `NarrativeRenderer` is its deprecated alias).
|
|
506
858
|
|
|
507
|
-
**`footprint-explainable-ui` narrative utilities** — for consumers building custom shells without `ExplainableShell`:
|
|
859
|
+
**`footprint-explainable-ui` narrative utilities** (read from that package's source, 0.38.0) — for consumers building custom shells without `ExplainableShell`:
|
|
508
860
|
- `buildEntryRangeIndex(entries)` — build range index from flat array (when no recorder access)
|
|
509
861
|
- `computeRevealedEntryCount(entries, snapshots, idx, rangeIndex?)` — slider position → entry count
|
|
510
862
|
- `extractSubflowNarrative(entries, subflowId)` — three-tier subflow entry extraction
|
|
511
863
|
|
|
512
|
-
**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`.
|
|
864
|
+
**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 the engine's `buildRuntimeStageId(prefixedId, idx)` just appends `#index`.
|
|
513
865
|
|
|
514
866
|
## Dev Mode
|
|
515
867
|
|
|
516
|
-
One global flag (`enableDevMode()` / `disableDevMode()` / `isDevMode()`) controls every developer-only diagnostic across the library. OFF by default —
|
|
868
|
+
One global flag (`enableDevMode()` / `disableDevMode()` / `isDevMode()`) controls every developer-only diagnostic across the library. OFF by default — the diagnostics below run only when it is on.
|
|
869
|
+
|
|
870
|
+
```typescript
|
|
871
|
+
import { flowChart, FlowChartExecutor, enableDevMode, disableDevMode, isDevMode } from 'footprintjs';
|
|
872
|
+
|
|
873
|
+
console.log(isDevMode());
|
|
874
|
+
// false
|
|
875
|
+
enableDevMode(); // in a real app: if (process.env.NODE_ENV !== 'production') enableDevMode();
|
|
876
|
+
|
|
877
|
+
const warnings: string[] = [];
|
|
878
|
+
console.warn = (message: string) => { warnings.push(message.split(' — ')[0]); };
|
|
517
879
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
880
|
+
const executor = new FlowChartExecutor(flowChart<{ n: number }>('Seed', (scope) => { scope.n = 1; }, 'seed').build());
|
|
881
|
+
executor.attachCombinedRecorder({ id: 'empty' }); // no on* handler: warns
|
|
882
|
+
await executor.run();
|
|
883
|
+
console.log(warnings);
|
|
884
|
+
// [
|
|
885
|
+
// "[footprintjs] attachCombinedRecorder: recorder 'empty' has no observer event methods"
|
|
886
|
+
// ]
|
|
887
|
+
console.log(Object.isFrozen(executor.getSnapshot().sharedState)); // dev mode: a deep-frozen clone, not the live state
|
|
888
|
+
// true
|
|
889
|
+
disableDevMode();
|
|
521
890
|
```
|
|
522
891
|
|
|
523
|
-
Gated diagnostics:
|
|
524
|
-
- **Circular-ref detection** in `ScopeFacade.setValue()` — O(n) WeakSet traversal per write
|
|
892
|
+
Gated diagnostics (not exhaustive; each is gated on `isDevMode()` in the source):
|
|
893
|
+
- **Circular-ref detection** in scope writes (`ScopeFacade.setValue()`) — O(n) WeakSet traversal per write
|
|
525
894
|
- **Empty-recorder warning** in `attachCombinedRecorder(r)` — catches `r` with no `on*` handler
|
|
526
|
-
-
|
|
895
|
+
- **`decide()` / `select()` rules** that throw while evaluating, or filter ops that are not one of `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn` (the rule counts as not matched)
|
|
527
896
|
- **Snapshot integrity** in `getSubtreeSnapshot()`
|
|
897
|
+
- **Recorder hook errors** — a recorder hook that throws is isolated and, in dev mode, warned about; it does not abort the run — except `onResume`, which the executor calls unguarded (a throw rejects `resume()`), and, in dev mode, a hook that throws a value that cannot be stringified (a null-prototype object rejects `run()`).
|
|
898
|
+
- **`getSnapshot().sharedState`** becomes a deep-frozen clone, so a mutation throws instead of corrupting engine state
|
|
899
|
+
- **A subflow id mounted twice** — warned at build time (a pause inside it could not be resumed)
|
|
528
900
|
|
|
529
901
|
Convention: when adding a new dev-only check, gate on `isDevMode()` (from `lib/devMode.ts`). Do NOT use `process.env.NODE_ENV` inline — consumers control dev tooling centrally via `enableDevMode()`/`disableDevMode()`, and inline env checks break that contract.
|
|
530
902
|
|
|
@@ -534,48 +906,119 @@ Convention: when adding a new dev-only check, gate on `isDevMode()` (from `lib/d
|
|
|
534
906
|
|
|
535
907
|
By default, an inner subflow's `$break` stops ONLY the subflow; the parent continues. Opt into propagation via `SubflowMountOptions.propagateBreak: true`:
|
|
536
908
|
|
|
537
|
-
```
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
909
|
+
```typescript
|
|
910
|
+
import { flowChart, FlowChartExecutor } from 'footprintjs';
|
|
911
|
+
|
|
912
|
+
const escalateChart = flowChart<{ partial?: string; later?: boolean }>('Escalate', (scope) => {
|
|
913
|
+
scope.partial = 'from-subflow';
|
|
914
|
+
scope.$break('policy violation');
|
|
915
|
+
}, 'escalate')
|
|
916
|
+
.addFunction('NeverRuns', (scope) => { scope.later = true; }, 'never-runs')
|
|
917
|
+
.build();
|
|
918
|
+
|
|
919
|
+
for (const propagateBreak of [false, true]) {
|
|
920
|
+
const chart = flowChart<{ seed?: boolean; got?: string; after?: boolean }>('Seed', (scope) => { scope.seed = true; }, 'seed')
|
|
921
|
+
.addSubFlowChartNext('sf-escalate', escalateChart, 'Escalate', {
|
|
922
|
+
outputMapper: (sub: { partial?: string }) => ({ got: sub.partial }),
|
|
923
|
+
propagateBreak, // ← inner $break → parent $break, with reason
|
|
924
|
+
})
|
|
925
|
+
.addFunction('After', (scope) => { scope.after = true; }, 'after')
|
|
926
|
+
.build();
|
|
927
|
+
const executor = new FlowChartExecutor(chart);
|
|
928
|
+
const breaks: string[] = [];
|
|
929
|
+
executor.attachFlowRecorder({
|
|
930
|
+
id: 'breaks',
|
|
931
|
+
onBreak: (e) => { breaks.push(`${e.stageName}: ${e.reason}${e.propagatedFromSubflow ? ` (from ${e.propagatedFromSubflow})` : ''}`); },
|
|
932
|
+
});
|
|
933
|
+
await executor.run();
|
|
934
|
+
console.log(propagateBreak, JSON.stringify(executor.getSnapshot().sharedState), breaks);
|
|
935
|
+
}
|
|
936
|
+
// false {"seed":true,"got":"from-subflow","after":true} [ 'sf-escalate/Escalate: policy violation' ]
|
|
937
|
+
// true {"seed":true,"got":"from-subflow"} [
|
|
938
|
+
// 'sf-escalate/Escalate: policy violation',
|
|
939
|
+
// 'Escalate: policy violation (from sf-escalate)'
|
|
940
|
+
// ]
|
|
542
941
|
```
|
|
543
942
|
|
|
544
943
|
Semantics:
|
|
545
944
|
- **Linear chain:** inner `$break(reason)` → parent's `breakFlag` flips → next parent stage does NOT run → `FlowBreakEvent` fires at parent-mount level with `propagatedFromSubflow` + reason.
|
|
546
|
-
- **Nested chain:** propagates through every hop that opted in. Reason survives.
|
|
547
|
-
- **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.
|
|
548
|
-
- **Parallel/fan-out:**
|
|
945
|
+
- **Nested chain:** propagates through every hop that opted in (a hop that did not opt in stops the propagation there). Reason survives.
|
|
946
|
+
- **outputMapper still runs** before propagation — subflow's partial state lands in parent before the break (`got` above). Escape hatch: early-return `{}` from outputMapper when the break state is set.
|
|
947
|
+
- **Parallel/fan-out:** a `$break` in a fork child stops that child; the parent continues after the fork even when EVERY child broke (the "all children broke" rule in `ChildrenExecutor` is not wired in 9.32.0). `propagateBreak: true` does not change that.
|
|
549
948
|
|
|
550
949
|
Example: [examples/runtime-features/break/04-subflow-propagate.ts](examples/runtime-features/break/04-subflow-propagate.ts).
|
|
551
950
|
|
|
552
951
|
## Emit Channel (Phase 3)
|
|
553
952
|
|
|
554
|
-
Third observer channel alongside `
|
|
953
|
+
Third observer channel alongside `ScopeRecorder` (data-flow) and `FlowRecorder` (control-flow). Consumer stage code emits structured events; `EmitRecorder.onEmit(event)` fires synchronously with auto-enriched context.
|
|
555
954
|
|
|
556
|
-
```
|
|
955
|
+
```typescript
|
|
956
|
+
import { flowChart, FlowChartExecutor } from 'footprintjs';
|
|
557
957
|
import type { EmitRecorder, EmitEvent } from 'footprintjs';
|
|
558
958
|
|
|
559
|
-
|
|
560
|
-
scope.$emit('myapp.llm.tokens', { input: 100, output: 50 });
|
|
561
|
-
|
|
562
|
-
// Recorder observes:
|
|
959
|
+
const tallies: unknown[] = [];
|
|
563
960
|
const rec: EmitRecorder = {
|
|
564
961
|
id: 'token-meter',
|
|
565
|
-
onEmit: (e) => { if (e.name === 'myapp.llm.tokens')
|
|
962
|
+
onEmit: (e) => { if (e.name === 'myapp.llm.tokens') tallies.push(e.payload); },
|
|
566
963
|
};
|
|
567
|
-
|
|
964
|
+
|
|
965
|
+
const sub = flowChart<object>('SubStage', (scope) => { scope.$emit('myapp.sub.event', { in: 'sub' }); }, 'sub-stage').build();
|
|
966
|
+
const chart = flowChart<object>('Meter', (scope) => {
|
|
967
|
+
// Inside a stage:
|
|
968
|
+
scope.$emit('myapp.llm.tokens', { input: 100, output: 50 });
|
|
969
|
+
scope.$emit('myapp.auth.check', { secret: 'x' }); // matched by emitPatterns below
|
|
970
|
+
scope.$debug('k', 1); scope.$error('e', 2); scope.$metric('m', 3); scope.$eval('ev', 4); scope.$log('hello');
|
|
971
|
+
}, 'meter')
|
|
972
|
+
.addSubFlowChartNext('sf', sub, 'Sub')
|
|
973
|
+
.build();
|
|
974
|
+
|
|
975
|
+
const executor = new FlowChartExecutor(chart);
|
|
976
|
+
executor.enableNarrative({
|
|
977
|
+
renderer: {
|
|
978
|
+
renderEmit: (ctx) => (ctx.name === 'myapp.llm.tokens' ? `Tokens: ${JSON.stringify(ctx.payload)}` : undefined),
|
|
979
|
+
},
|
|
980
|
+
});
|
|
981
|
+
executor.setRedactionPolicy({ emitPatterns: [/\.auth\./] });
|
|
982
|
+
executor.attachEmitRecorder(rec); // Recorder observes
|
|
983
|
+
const seen: EmitEvent[] = [];
|
|
984
|
+
executor.attachEmitRecorder({ id: 'all', onEmit: (e) => { seen.push(e); } });
|
|
985
|
+
await executor.run();
|
|
986
|
+
|
|
987
|
+
console.log(JSON.stringify(tallies));
|
|
988
|
+
// [{"input":100,"output":50}]
|
|
989
|
+
console.log(seen.map((e) => e.name));
|
|
990
|
+
// [
|
|
991
|
+
// 'myapp.llm.tokens',
|
|
992
|
+
// 'myapp.auth.check',
|
|
993
|
+
// 'log.debug.k',
|
|
994
|
+
// 'log.error.e',
|
|
995
|
+
// 'metric.m',
|
|
996
|
+
// 'eval.ev',
|
|
997
|
+
// 'log.debug.messages',
|
|
998
|
+
// 'myapp.sub.event'
|
|
999
|
+
// ]
|
|
1000
|
+
const { timestamp, ...enriched } = seen[seen.length - 1];
|
|
1001
|
+
console.log(JSON.stringify(enriched), typeof timestamp);
|
|
1002
|
+
// {"name":"myapp.sub.event","payload":{"in":"sub"},"stageName":"sf/SubStage","runtimeStageId":"sf/sub-stage#2","subflowPath":["sf"],"pipelineId":""} number
|
|
1003
|
+
console.log(JSON.stringify(seen[1].payload));
|
|
1004
|
+
// "[REDACTED]"
|
|
1005
|
+
console.log(executor.getNarrativeEntries().filter((e) => e.type === 'emit').map((e) => e.text).slice(0, 3));
|
|
1006
|
+
// [
|
|
1007
|
+
// 'Tokens: {"input":100,"output":50}',
|
|
1008
|
+
// '[emit] myapp.auth.check: "[REDACTED]"',
|
|
1009
|
+
// '[emit] log.debug.k: {key, value, level}'
|
|
1010
|
+
// ]
|
|
568
1011
|
```
|
|
569
1012
|
|
|
570
1013
|
### Semantics
|
|
571
|
-
- **Pass-through.** Delivered synchronously, in call order.
|
|
572
|
-
- **Auto-enriched.** Events carry `stageName`, `runtimeStageId`, `subflowPath`, `pipelineId`, `timestamp` —
|
|
1014
|
+
- **Pass-through.** Delivered synchronously, in call order. `ScopeFacade.emitEvent` returns immediately when no recorder is attached.
|
|
1015
|
+
- **Auto-enriched.** Events carry `stageName`, `runtimeStageId`, `subflowPath`, `pipelineId`, `timestamp` — in a subflow, the stage name and runtimeStageId carry the path prefix and `subflowPath` lists it.
|
|
573
1016
|
- **Error-isolated.** A throwing `onEmit` doesn't propagate; errors route to `onError` on other recorders.
|
|
574
1017
|
- **Redactable.** `RedactionPolicy.emitPatterns: RegExp[]` matches `event.name`; matched payloads become `'[REDACTED]'` before dispatch.
|
|
575
1018
|
- **Buffered in narrative.** `CombinedNarrativeRecorder.onEmit` buffers alongside reads/writes; flushed in `flushOps` so emit entries appear AFTER the stage header in ordered narrative.
|
|
576
1019
|
|
|
577
1020
|
### Naming convention
|
|
578
|
-
Hierarchical dotted names — `<namespace>.<category>.<event
|
|
1021
|
+
Hierarchical dotted names — `<namespace>.<category>.<event>` (a convention: any name is accepted). Examples:
|
|
579
1022
|
- `'agentfootprint.llm.tokens'`, `'agentfootprint.llm.request'`
|
|
580
1023
|
- `'myapp.billing.spend'`, `'myapp.auth.check'`
|
|
581
1024
|
|
|
@@ -587,12 +1030,13 @@ $debug(key, value) → emits 'log.debug.${key}'
|
|
|
587
1030
|
$error(key, value) → emits 'log.error.${key}'
|
|
588
1031
|
$metric(name, value) → emits 'metric.${name}'
|
|
589
1032
|
$eval (name, value) → emits 'eval.${name}'
|
|
1033
|
+
$log(value) → emits 'log.debug.messages'
|
|
590
1034
|
```
|
|
591
1035
|
|
|
592
|
-
|
|
1036
|
+
So `$metric` / `$debug` are observable by recorders in real time; the side bags still populate for consumers that inspect snapshots directly.
|
|
593
1037
|
|
|
594
1038
|
### Customizing narrative rendering
|
|
595
|
-
`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
|
|
1039
|
+
`NarrativeFormatter.renderEmit?(ctx)` hook (passed as `enableNarrative({ renderer })`) 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` (above: the first line is the custom one, the rest are defaults).
|
|
596
1040
|
|
|
597
1041
|
Example: [examples/runtime-features/emit/01-custom-events.ts](examples/runtime-features/emit/01-custom-events.ts).
|
|
598
1042
|
|
|
@@ -600,9 +1044,11 @@ Example: [examples/runtime-features/emit/01-custom-events.ts](examples/runtime-f
|
|
|
600
1044
|
|
|
601
1045
|
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.
|
|
602
1046
|
|
|
603
|
-
```
|
|
1047
|
+
```typescript
|
|
1048
|
+
import { flowChart, FlowChartExecutor, isFlowEvent } from 'footprintjs';
|
|
604
1049
|
import type { CombinedRecorder } from 'footprintjs';
|
|
605
|
-
|
|
1050
|
+
|
|
1051
|
+
const log = (...parts: unknown[]) => console.log(...parts);
|
|
606
1052
|
|
|
607
1053
|
const audit: CombinedRecorder = {
|
|
608
1054
|
id: 'audit',
|
|
@@ -615,31 +1061,61 @@ const audit: CombinedRecorder = {
|
|
|
615
1061
|
},
|
|
616
1062
|
};
|
|
617
1063
|
|
|
1064
|
+
const chart = flowChart<{ n: number }>('Seed', (scope) => { scope.n = 1; }, 'seed')
|
|
1065
|
+
.addFunction('Boom', () => { throw new Error('kaput'); }, 'boom')
|
|
1066
|
+
.build();
|
|
1067
|
+
|
|
1068
|
+
const executor = new FlowChartExecutor(chart);
|
|
618
1069
|
executor.attachCombinedRecorder(audit);
|
|
1070
|
+
await executor.run().catch(() => undefined);
|
|
1071
|
+
// scope write n
|
|
1072
|
+
// flow error in Boom
|
|
619
1073
|
```
|
|
620
1074
|
|
|
621
|
-
Built on `CombinedRecorder`: `CombinedNarrativeRecorder` (the `executor.enableNarrative()` default). Consumers implement ONLY the events they care about — `Partial<
|
|
1075
|
+
Built on `CombinedRecorder`: `CombinedNarrativeRecorder` (the `executor.enableNarrative()` default). Consumers implement ONLY the events they care about — `Partial<ScopeRecorder> & Partial<FlowRecorder> & Partial<EmitRecorder>` under the hood.
|
|
622
1076
|
|
|
623
|
-
**Detection rule:**
|
|
1077
|
+
**Detection rule:** a handler counts when it is an OWN property OR a method on the recorder's class prototype chain — class instances work. Only handlers inherited from `Object.prototype` are ignored (prevents accidental `Object.prototype` pollution from attaching handlers).
|
|
624
1078
|
|
|
625
1079
|
## Anti-Patterns
|
|
626
1080
|
|
|
627
|
-
- Never post-process the tree — use recorders
|
|
628
|
-
- Don't use `getValue()`/`setValue()` in TypedScope stages — use typed property access
|
|
629
|
-
- Don't
|
|
630
|
-
- Don't
|
|
631
|
-
-
|
|
1081
|
+
- Never post-process the tree — use recorders (or the `footprintjs/trace` queries over the recorded log)
|
|
1082
|
+
- Don't use `getValue()`/`setValue()` for keys you know in TypedScope stages — use typed property access (`$getValue`/`$setValue` are for dynamic keys; plain `scope.getValue` does not exist there)
|
|
1083
|
+
- Don't give a state key the name of a `$` method (`scope.$break = 1` throws "conflicts with a reserved TypedScope method") — the reserved names are `SCOPE_METHOD_NAMES` in `footprintjs/advanced`; avoid `$`-prefixed state keys altogether
|
|
1084
|
+
- Don't write a state key that is also a `run({ input })` key — input keys are read-only for the run
|
|
1085
|
+
- `CombinedNarrativeBuilder` is gone (removed in v1.0) — the narrative comes from `CombinedNarrativeRecorder` via `executor.enableNarrative()` or the `narrative()` factory
|
|
1086
|
+
- Don't extract a shared base for scope and flow recorders — built-ins compose a store (`KeyedStore` / `SequenceStore`) as a field
|
|
632
1087
|
- Don't use `getArgs()` for tracked data — use typed scope properties
|
|
633
1088
|
- Don't put infrastructure data in `getArgs()` — use `getEnv()` via `run({ env })`
|
|
634
|
-
- Don't
|
|
635
|
-
- Don't
|
|
1089
|
+
- Don't hand-roll a recorder to get the narrative — `executor.enableNarrative()` (or `chart.recorder(narrative())`) is the whole setup, and it is off until called
|
|
1090
|
+
- Don't end a loop with `return` — `.loopTo(id)` is unconditional; exit through a decider branch or `scope.$break()`
|
|
1091
|
+
- 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. `ArrayMergeMode` is exported from `footprintjs/advanced` (not the main door).
|
|
1092
|
+
|
|
1093
|
+
```typescript
|
|
1094
|
+
import { flowChart, FlowChartExecutor } from 'footprintjs';
|
|
1095
|
+
import { ArrayMergeMode } from 'footprintjs/advanced';
|
|
1096
|
+
|
|
1097
|
+
const sub = flowChart<{ items?: string[] }>('Sub', (scope) => { scope.items = ['a', 'b']; }, 'sub').build();
|
|
1098
|
+
for (const arrayMerge of [ArrayMergeMode.Concat, ArrayMergeMode.Replace]) {
|
|
1099
|
+
const chart = flowChart<{ items: string[] }>('Seed', (scope) => { scope.items = ['a']; }, 'seed')
|
|
1100
|
+
.addSubFlowChartNext('sf', sub, 'Sub', { outputMapper: (out: { items?: string[] }) => ({ items: out.items }), arrayMerge })
|
|
1101
|
+
.build();
|
|
1102
|
+
const executor = new FlowChartExecutor(chart);
|
|
1103
|
+
await executor.run();
|
|
1104
|
+
console.log(arrayMerge, JSON.stringify(executor.getSnapshot().sharedState.items));
|
|
1105
|
+
}
|
|
1106
|
+
// concat ["a","a","b"]
|
|
1107
|
+
// replace ["a","b"]
|
|
1108
|
+
```
|
|
636
1109
|
|
|
637
1110
|
## Build & Test
|
|
638
1111
|
|
|
639
1112
|
```bash
|
|
640
|
-
npm run build
|
|
641
|
-
npm test
|
|
642
|
-
npm run test:
|
|
1113
|
+
npm run build # tsc (CJS) + tsc -p tsconfig.esm.json (ESM) + scripts/postbuild-esm.mjs
|
|
1114
|
+
npm test # full suite (vitest)
|
|
1115
|
+
npm run test:examples # type-check examples/
|
|
1116
|
+
npm run lint
|
|
1117
|
+
npm run check:layering # the layering rule, from scripts/layering.config.cjs
|
|
1118
|
+
npm run check:doc-snippets
|
|
643
1119
|
```
|
|
644
1120
|
|
|
645
1121
|
Dual output: CommonJS (`dist/`) + ESM (`dist/esm/`) + types (`dist/types/`)
|