automategraph 1.5.1 → 1.5.3
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/CHANGELOG.md +18 -5
- package/UPGRADE-PROPOSAL.md +141 -0
- package/docs/diagrams/c19-resilience-scenario.md +61 -0
- package/docs/diagrams/c19-resilience-scenario.mmd +28 -0
- package/docs/diagrams/c19-resilience-scenario.svg +181 -0
- package/examples/README.md +206 -0
- package/examples/branching-edges.ts +192 -0
- package/examples/branching-graph.ts +333 -0
- package/examples/branching-router.ts +475 -0
- package/examples/json-graph.ts +231 -0
- package/examples/json-scripts.ts +152 -0
- package/examples/order-flow.functions.cjs +32 -0
- package/examples/order-flow.graph.json +27 -0
- package/examples/order-flow.scripted.graph.json +68 -0
- package/js/examples/branching-edges.js +133 -0
- package/js/examples/branching-edges.js.map +1 -0
- package/js/examples/branching-graph.js +232 -0
- package/js/examples/branching-graph.js.map +1 -0
- package/js/examples/branching-router.js +305 -0
- package/js/examples/branching-router.js.map +1 -0
- package/js/examples/json-graph.js +197 -0
- package/js/examples/json-graph.js.map +1 -0
- package/js/examples/json-scripts.js +131 -0
- package/js/examples/json-scripts.js.map +1 -0
- package/js/tests/async.test.js +592 -0
- package/js/tests/async.test.js.map +1 -0
- package/js/tests/complex.test.js +972 -0
- package/js/tests/complex.test.js.map +1 -0
- package/js/tests/conformance.test.js +365 -0
- package/js/tests/conformance.test.js.map +1 -0
- package/js/tests/edges.test.js +702 -0
- package/js/tests/edges.test.js.map +1 -0
- package/js/tests/graphjson.test.js +504 -0
- package/js/tests/graphjson.test.js.map +1 -0
- package/js/tests/harness.js +192 -0
- package/js/tests/harness.js.map +1 -0
- package/js/tests/helpers.js +136 -0
- package/js/tests/helpers.js.map +1 -0
- package/js/tests/lint.test.js +317 -0
- package/js/tests/lint.test.js.map +1 -0
- package/js/tests/registry.test.js +705 -0
- package/js/tests/registry.test.js.map +1 -0
- package/js/tests/report.js +422 -0
- package/js/tests/report.js.map +1 -0
- package/js/tests/runner.js +67 -0
- package/js/tests/runner.js.map +1 -0
- package/js/tests/scripts.test.js +552 -0
- package/js/tests/scripts.test.js.map +1 -0
- package/js/tests/unit.test.js +1239 -0
- package/js/tests/unit.test.js.map +1 -0
- package/js/tests/upgrade.test.js +618 -0
- package/js/tests/upgrade.test.js.map +1 -0
- package/package.json +2 -9
- package/report/index.html +3727 -0
- package/report/report.json +3053 -0
- package/tests/async.test.ts +668 -0
- package/tests/complex.test.ts +1123 -0
- package/tests/conformance.test.ts +442 -0
- package/tests/edges.test.ts +848 -0
- package/tests/fixtures/scripts/bad-registration.cjs +4 -0
- package/tests/fixtures/scripts/broken.cjs +2 -0
- package/tests/fixtures/scripts/cjs-default.js +4 -0
- package/tests/fixtures/scripts/collide-a.cjs +4 -0
- package/tests/fixtures/scripts/collide-b.cjs +4 -0
- package/tests/fixtures/scripts/empty.cjs +2 -0
- package/tests/fixtures/scripts/esm-functions.mjs +10 -0
- package/tests/fixtures/scripts/esm-syntax.js +3 -0
- package/tests/fixtures/scripts/global-channel.cjs +6 -0
- package/tests/fixtures/scripts/global-channel.mjs +7 -0
- package/tests/fixtures/scripts/mismatch.cjs +4 -0
- package/tests/fixtures/scripts/needs-sibling.cjs +7 -0
- package/tests/fixtures/scripts/order.cjs +14 -0
- package/tests/fixtures/scripts/sibling-helper.cjs +3 -0
- package/tests/fixtures/scripts/throwing-global.mjs +6 -0
- package/tests/graphjson.test.ts +737 -0
- package/tests/harness.ts +291 -0
- package/tests/helpers.ts +216 -0
- package/tests/lint.test.ts +381 -0
- package/tests/registry.test.ts +1134 -0
- package/tests/report.ts +452 -0
- package/tests/runner.ts +58 -0
- package/tests/scripts.test.ts +970 -0
- package/tests/unit.test.ts +1192 -0
- package/tests/upgrade.test.ts +725 -0
- package/tools/server.mjs +152 -0
- package/tsconfig.browser.json +19 -0
- package/tsconfig.json +18 -0
- package/tsconfig.publish.json +13 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
| Path | What | Run |
|
|
4
|
+
|---|---|---|
|
|
5
|
+
| `json-scripts.ts` + `order-flow.scripted.graph.json` + `order-flow.functions.cjs` | **v1.5** — the document brings its own code: a path script + an inline self-registering script, then the namespace freeze | `npm run example:scripts` |
|
|
6
|
+
| `json-graph.ts` + `order-flow.graph.json` | **v1.4** — the same document (no JS) against four registries, incl. a missing name | `npm run example:graphjson` |
|
|
7
|
+
| `branching-edges.ts` | v1.3 — typed edges, one execution | `npm run example:edges` |
|
|
8
|
+
| `branching-graph.ts` | v1.2 — guarded fan-out, gate + DRAIN, engine untouched | `npm run build && node js/examples/branching-graph.js` |
|
|
9
|
+
| `branching-router.ts` | v1.2 — host-side router, engine untouched | `npm run example:branching` |
|
|
10
|
+
|
|
11
|
+
## The `a → b / c / d / e / f` picture, three ways
|
|
12
|
+
|
|
13
|
+
The same branching graph, solved with three different levels of engine support. All three are
|
|
14
|
+
runnable and self-verifying; all three end with `5/5 matched`.
|
|
15
|
+
|
|
16
|
+
| # | Example | Engine version | Cost |
|
|
17
|
+
|---|---|---|---|
|
|
18
|
+
| 1 | `branching-router.ts` — host-side router | v1.2, **untouched** | 10 executions (one per hop), control plane in host code |
|
|
19
|
+
| 2 | `branching-graph.ts` — pure graph, guards + gate | v1.2, **untouched** | 1 execution, but 4 branch activations + one closure gate + `DRAIN` |
|
|
20
|
+
| 3 | **`branching-edges.ts` — typed edges** | **v1.3** | 1 execution, 1 branch activation, no gate, `FIRST_TERMINAL` |
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm run example:edges # 3 — typed edges, the recommended version
|
|
24
|
+
node js/examples/branching-graph.js # 2 — guards + gate
|
|
25
|
+
npm run example:branching # 1 — host-side router
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 3. Typed edges (v1.3) — the arrows, declared
|
|
29
|
+
|
|
30
|
+
v1.3 lifts routing into the engine, so every arrow is one `next` entry and the rest of the diagram
|
|
31
|
+
is plain node code:
|
|
32
|
+
|
|
33
|
+
| Arrow in the picture | Typed-edge declaration |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `result === 1` → b | `{ result: 1, nextNode: "b" }` |
|
|
36
|
+
| `test(result)` → c | `{ test: r => r === 2, nextNode: "c" }` |
|
|
37
|
+
| `else` → d | `{ nextNode: "d" }` (no matcher — evaluated last, in order) |
|
|
38
|
+
| `on throw` → e | `onThrow: "e"` + `errorPolicy: "RETRY"` (unchanged) |
|
|
39
|
+
| `on retry` → f | `if (context.attempt > 1) return new Next("f", result)` |
|
|
40
|
+
| b **forces** f | `context.goto("f"); return undefined;` inside `b` |
|
|
41
|
+
|
|
42
|
+
Exclusive edges are ordered and first-match-wins, `result` matches by deep equality, and no match
|
|
43
|
+
schedules nothing. One execution per seed, one activation per branch, `lint: "error"` clean under
|
|
44
|
+
`FIRST_TERMINAL`. `b` also declares `next: ["f"]` — the explicit `goto` wins at runtime, but the
|
|
45
|
+
declared edge is what makes `f` reachable to static lint, and it is the fallback if `b` ever
|
|
46
|
+
returns a value (case 24 of `tests/edges.test.ts` records this as a finding).
|
|
47
|
+
|
|
48
|
+
The remaining sections document the two v1.2 approaches and why typed edges made them obsolete.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
# Branching router — `a → b / c / d / e / f`
|
|
53
|
+
|
|
54
|
+
Runnable translation of this graph, **without touching `src/automategraph.ts`** (v1.2):
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
a ── result === 1 ────────────────▶ b ──(forces)──▶ f
|
|
58
|
+
a ── test(result) === true ───────▶ c
|
|
59
|
+
a ── otherwise ───────────────────▶ d
|
|
60
|
+
a ── on throw ────────────────────▶ e
|
|
61
|
+
a ── on retry ────────────────────▶ f
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm run example:branching # 5 scenarios, exits non-zero on any deviation
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Why this is not a single `GraphDefinition`
|
|
69
|
+
|
|
70
|
+
Two engine facts make the picture inexpressible as one definition:
|
|
71
|
+
|
|
72
|
+
1. **`next` is an unconditional fan-out.** Every target listed is scheduled, every time — there
|
|
73
|
+
is no conditional edge, so `if result === 1 → b` cannot be declared.
|
|
74
|
+
2. **There is no `goto` and no per-attempt transition.** A node cannot schedule work from inside
|
|
75
|
+
its own execution, and retries are internal (`errorPolicy: "RETRY"` loops *inside* one
|
|
76
|
+
activation), so "on retry → f" is not a state transition the engine knows about.
|
|
77
|
+
|
|
78
|
+
Routing is therefore a **decision**, and a decision needs somewhere to run. This example puts it
|
|
79
|
+
in a host-side router that drives the engine one step at a time and reads each finished execution.
|
|
80
|
+
|
|
81
|
+
```mermaid
|
|
82
|
+
flowchart LR
|
|
83
|
+
subgraph R["Router (host code — examples/branching-router.ts)"]
|
|
84
|
+
D{"decide()"}
|
|
85
|
+
S["WalkState<br/>seed · visited · lastError"]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
subgraph E["GraphExecutionEngine (untouched)"]
|
|
89
|
+
H1["execution #1<br/>a"]
|
|
90
|
+
H2["execution #2<br/>b"]
|
|
91
|
+
H3["execution #3<br/>f"]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
D -->|"step graph: one node,<br/>entry = terminal"| H1
|
|
95
|
+
H1 -->|"report: output / attempts / errors<br/>+ router.goto event"| D
|
|
96
|
+
D --> H2 --> D
|
|
97
|
+
D --> H3 --> D
|
|
98
|
+
S -.->|"state shared by all step callbacks"| H1 & H2 & H3
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Each hop is a one-node graph (`entryNodes: [id]`, `terminalNodes: [id]`, `maxActivations: 1`), so
|
|
102
|
+
the engine schedules nothing on its own and the walk stays lint-clean (the v1.2 `SUCCESS_DEAD_END`
|
|
103
|
+
rule is satisfied because the step *is* the terminal).
|
|
104
|
+
|
|
105
|
+
## Arrow → mechanism
|
|
106
|
+
|
|
107
|
+
| Arrow in the picture | Mechanism | Engine feature used |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `if result === 1` → b | ordered `routes` predicate on the returned value | `NodeResult.output` |
|
|
110
|
+
| `if test(result) === true` → c | second predicate, evaluated in order | `NodeResult.output` |
|
|
111
|
+
| `else` → d | `otherwise` fallback target | — |
|
|
112
|
+
| `on throw` → e | failed hop ⇒ `onThrow` target | `ExecutionReport.status` + `errors[]` |
|
|
113
|
+
| `on retry` → f | hop needed > 1 attempt ⇒ `onRetry` target | `node.started` event → `payload.attempt` |
|
|
114
|
+
| b **forces** f | b returns `undefined` and emits `router.goto` | `context.emit` on the public `EventBus` |
|
|
115
|
+
| retries on `a` | `retry: { maxAttempts: 2 }` + `errorPolicy: "RETRY"` | engine-native retry loop |
|
|
116
|
+
|
|
117
|
+
Decision precedence, first match wins:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
goto → throw → retry → value routes → otherwise → stop
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A **failed** step goes to `onThrow` even after consuming retries; `onRetry` fires for a step that
|
|
124
|
+
*recovered* on a later attempt. Flip those two in `decide()` if you want "any retry goes to f".
|
|
125
|
+
|
|
126
|
+
## The forced jump, honestly
|
|
127
|
+
|
|
128
|
+
`b` cannot schedule `f` — no node can schedule anything in this engine. What `b` does is **ask**:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
run: async (_state, context) => {
|
|
132
|
+
await context.emit(GOTO_EVENT, { to: "f", reason: "b short-circuits to f" });
|
|
133
|
+
return undefined; // void, as specified
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The router registers a *required* listener (so the bus awaits it and the request is guaranteed to
|
|
138
|
+
be visible before `run()` resolves), then performs the jump itself with a fresh execution starting
|
|
139
|
+
at `f`. The engine stays a pure executor; the control plane lives in host code.
|
|
140
|
+
|
|
141
|
+
Alternatives that also work without engine changes, and why they were not used:
|
|
142
|
+
|
|
143
|
+
- **Return a marker** (`return { goto: "f" }`) — works, but `b` must then return an object instead
|
|
144
|
+
of `void`, contradicting the spec.
|
|
145
|
+
- **Throw and use `onThrow`** — a real engine transition, but it records `b` as `FAILED` with an
|
|
146
|
+
error, which is the wrong semantics for a deliberate jump.
|
|
147
|
+
- **`fallback` + `errorPolicy: "FALLBACK"`** — same objection, plus the fallback target must exist
|
|
148
|
+
inside the same graph.
|
|
149
|
+
|
|
150
|
+
## The single-execution alternative (and its catch)
|
|
151
|
+
|
|
152
|
+
If you need **one** execution spanning the whole graph, express the branch as **guarded fan-out**:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
node("a", runA, {
|
|
156
|
+
next: ["b", "c", "d", "f"], // unconditional: all four are activated
|
|
157
|
+
onThrow: "e", // engine-native error arrow
|
|
158
|
+
errorPolicy: "RETRY", retry: { maxAttempts: 2 }
|
|
159
|
+
})
|
|
160
|
+
// each of b/c/d/f starts with a guard:
|
|
161
|
+
node("b", ctx => ctx.outputs.a === 1 ? runB(ctx) : SKIPPED)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Caveats, all demonstrated by the `complex` pack:
|
|
165
|
+
|
|
166
|
+
- every candidate is **activated** (results, events, checkpoints) even when it is a no-op;
|
|
167
|
+
- `FIRST_TERMINAL` completion can drop a not-yet-scheduled branch — you want
|
|
168
|
+
`completionPolicy: "DRAIN"`;
|
|
169
|
+
- the **forced jump from `b` to `f` becomes racy**, because fan-out activates siblings
|
|
170
|
+
concurrently: `f`'s guard may run before `b` decided. Making it deterministic needs an explicit
|
|
171
|
+
gate — exactly the `releaseFast()` / `fallbackDone` trick used in the C-19 fixture
|
|
172
|
+
(`docs/diagrams/c19-resilience-scenario.md`).
|
|
173
|
+
|
|
174
|
+
Trade-off summary: **router = true routing, N executions**; **guards = one execution, all branches
|
|
175
|
+
activated, needs gates for cross-branch jumps**.
|
|
176
|
+
|
|
177
|
+
## Files
|
|
178
|
+
|
|
179
|
+
| Path | What |
|
|
180
|
+
|---|---|
|
|
181
|
+
| `branching-edges.ts` | **v1.3**: the picture as one `GraphDefinition` — `npm run example:edges` |
|
|
182
|
+
| `branching-graph.ts` | v1.2, engine untouched: guarded fan-out + `DRAIN` + closure gate |
|
|
183
|
+
| `branching-router.ts` | v1.2, engine untouched: steps as data + `runWalk()` + self-verifying demo |
|
|
184
|
+
| `npm run example:branching` | builds and runs the router's five scenarios |
|
|
185
|
+
|
|
186
|
+
Verified output (each hop is a real engine execution with its own `executionId` and checkpoint):
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
▶ seed = 1 result === 1 → b, then b forces f
|
|
190
|
+
hop 1 a COMPLETED attempts=1 output=1 return → b
|
|
191
|
+
hop 2 b COMPLETED attempts=1 output=undefined goto → f
|
|
192
|
+
hop 3 f COMPLETED attempts=1 output="f: …" none stop
|
|
193
|
+
path: a → b → f ✓ as expected
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## 3 (typed edges) — verified output
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
▶ seed = 1 result === 1 → b, and b forces f
|
|
200
|
+
COMPLETED path: a → b → f ✓
|
|
201
|
+
a output=1
|
|
202
|
+
b output=undefined (void) ⤳ routed to f (via goto)
|
|
203
|
+
f output="f: reached by retry or by b's forced jump"
|
|
204
|
+
...
|
|
205
|
+
scenarios: 5/5 matched
|
|
206
|
+
```
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* Branching graph with TYPED EDGES (v1.3) — one execution
|
|
3
|
+
* ------------------------------------------------------------
|
|
4
|
+
* The same picture as examples/branching-graph.ts, but the arrows
|
|
5
|
+
* are now declared where they belong — on the node — and the whole
|
|
6
|
+
* thing runs as ONE GraphExecutionEngine execution:
|
|
7
|
+
*
|
|
8
|
+
* a ── result === 1 ────────────▶ b ──(forces)──▶ f
|
|
9
|
+
* a ── test(result) === true ───▶ c
|
|
10
|
+
* a ── else ────────────────────▶ d
|
|
11
|
+
* a ── on throw ────────────────▶ e
|
|
12
|
+
* a ── on retry ────────────────▶ f
|
|
13
|
+
*
|
|
14
|
+
* What replaced what (see CHANGELOG 1.3.0):
|
|
15
|
+
* guards + skip markers → { result } / { test } / { nextNode } edges
|
|
16
|
+
* the b → f gate → context.goto("f") inside b
|
|
17
|
+
* "on retry → f" route → return new Next("f", result) when attempt > 1
|
|
18
|
+
* completionPolicy DRAIN → not needed: exclusive edges schedule one branch
|
|
19
|
+
*
|
|
20
|
+
* Run: node js/examples/branching-edges.js (after npm run build)
|
|
21
|
+
* ============================================================ */
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
GraphDefinition,
|
|
25
|
+
GraphExecutionEngine,
|
|
26
|
+
Next,
|
|
27
|
+
NodeContext,
|
|
28
|
+
NodeDefinition
|
|
29
|
+
} from "../src/automategraph";
|
|
30
|
+
|
|
31
|
+
/* ---------------------------------------------------------------
|
|
32
|
+
* Edge semantics used here
|
|
33
|
+
* ---------------------------------------------------------------
|
|
34
|
+
* next: [
|
|
35
|
+
* { result: 1, nextNode: "b" } exclusive, deep equality
|
|
36
|
+
* { test: r => r === 2, nextNode: "c" } exclusive, predicate
|
|
37
|
+
* { nextNode: "d" } else edge (no matcher)
|
|
38
|
+
* ]
|
|
39
|
+
* First match wins, in declaration order. No match ⇒ nothing is
|
|
40
|
+
* scheduled (silent). A string entry would be a parallel edge that
|
|
41
|
+
* always fires, alongside the winning conditional.
|
|
42
|
+
* ------------------------------------------------------------- */
|
|
43
|
+
|
|
44
|
+
export type ScenarioSeed = 1 | 2 | 3 | "throw" | "retry";
|
|
45
|
+
|
|
46
|
+
/** `test(result)` from the picture. */
|
|
47
|
+
export const test = (result: number): boolean => result === 2;
|
|
48
|
+
|
|
49
|
+
const node = (
|
|
50
|
+
id: string,
|
|
51
|
+
run: NodeDefinition["run"],
|
|
52
|
+
extra: Partial<NodeDefinition> = {}
|
|
53
|
+
): NodeDefinition => ({ id, run, ...extra });
|
|
54
|
+
|
|
55
|
+
export function buildGraph(seed: ScenarioSeed): GraphDefinition {
|
|
56
|
+
return {
|
|
57
|
+
id: "branching-edges",
|
|
58
|
+
version: "1.3.0",
|
|
59
|
+
|
|
60
|
+
nodes: {
|
|
61
|
+
/* ---------------------------- a ---------------------------- */
|
|
62
|
+
a: node("a", (context: NodeContext) => {
|
|
63
|
+
// on throw → e (a real engine transition, declared below)
|
|
64
|
+
if (seed === "throw") throw new Error("a: upstream exploded");
|
|
65
|
+
|
|
66
|
+
// retry: attempt 1 fails, attempt 2 succeeds
|
|
67
|
+
if (seed === "retry" && context.attempt === 1) {
|
|
68
|
+
throw new Error(`a: transient failure on attempt ${context.attempt}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const result = seed === "retry" ? 2 : seed;
|
|
72
|
+
|
|
73
|
+
// on retry → f (only the node knows the attempt number)
|
|
74
|
+
if (context.attempt > 1) return new Next("f", result);
|
|
75
|
+
|
|
76
|
+
return result;
|
|
77
|
+
}, {
|
|
78
|
+
next: [
|
|
79
|
+
{ result: 1, nextNode: "b" },
|
|
80
|
+
{ test: value => typeof value === "number" && test(value), nextNode: "c" },
|
|
81
|
+
{ nextNode: "d" } // else
|
|
82
|
+
],
|
|
83
|
+
onThrow: "e",
|
|
84
|
+
retry: { maxAttempts: 2, delayMs: 5 },
|
|
85
|
+
errorPolicy: "RETRY"
|
|
86
|
+
}),
|
|
87
|
+
|
|
88
|
+
/* ---------------------------- b ---------------------------- */
|
|
89
|
+
// returns void, then forces f from inside the execution
|
|
90
|
+
b: node("b", (context: NodeContext) => {
|
|
91
|
+
context.goto("f");
|
|
92
|
+
return undefined; // void
|
|
93
|
+
}, {
|
|
94
|
+
// declared so static reachability sees f; at runtime the explicit
|
|
95
|
+
// route wins and this edge stays a fallback if b ever returns a value
|
|
96
|
+
next: ["f"]
|
|
97
|
+
}),
|
|
98
|
+
|
|
99
|
+
/* ---------------------------- c / d / e / f ---------------------------- */
|
|
100
|
+
c: node("c", () => "c: handled result 2"),
|
|
101
|
+
d: node("d", () => "d: handled anything else"),
|
|
102
|
+
e: node("e", () => "e: failure route"),
|
|
103
|
+
f: node("f", () => "f: reached by retry or by b's forced jump")
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
entryNodes: ["a"],
|
|
107
|
+
terminalNodes: ["c", "d", "e", "f"],
|
|
108
|
+
completionPolicy: "FIRST_TERMINAL" // exclusive edges ⇒ one branch only
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ============================= demo ============================= */
|
|
113
|
+
|
|
114
|
+
const SCENARIOS: ReadonlyArray<{
|
|
115
|
+
label: string;
|
|
116
|
+
seed: ScenarioSeed;
|
|
117
|
+
path: string[];
|
|
118
|
+
}> = [
|
|
119
|
+
{ label: "result === 1 → b, and b forces f", seed: 1, path: ["a", "b", "f"] },
|
|
120
|
+
{ label: "test(result) → c", seed: 2, path: ["a", "c"] },
|
|
121
|
+
{ label: "else → d", seed: 3, path: ["a", "d"] },
|
|
122
|
+
{ label: "on throw → e", seed: "throw", path: ["a", "e"] },
|
|
123
|
+
{ label: "on retry → f", seed: "retry", path: ["a", "f"] }
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
function show(value: unknown): string {
|
|
127
|
+
return value === undefined ? "undefined (void)" : JSON.stringify(value);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface Visited {
|
|
131
|
+
nodeId: string;
|
|
132
|
+
attempt: number;
|
|
133
|
+
output: unknown;
|
|
134
|
+
route?: { nextNode: string; via: string };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Collects what each node produced / routed to, in execution order. */
|
|
138
|
+
function instrument(engine: GraphExecutionEngine): Visited[] {
|
|
139
|
+
const visited: Visited[] = [];
|
|
140
|
+
|
|
141
|
+
engine.events.on("node.succeeded", payload => {
|
|
142
|
+
const info = payload as {
|
|
143
|
+
nodeId: string;
|
|
144
|
+
output: unknown;
|
|
145
|
+
route?: { nextNode: string; via: string };
|
|
146
|
+
};
|
|
147
|
+
visited.push({ nodeId: info.nodeId, attempt: 1, output: info.output, route: info.route });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return visited;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function main(): Promise<void> {
|
|
154
|
+
console.log("=".repeat(100));
|
|
155
|
+
console.log("typed edges (v1.3) — the whole picture as ONE execution, lint: \"error\"");
|
|
156
|
+
console.log("=".repeat(100));
|
|
157
|
+
|
|
158
|
+
let failures = 0;
|
|
159
|
+
|
|
160
|
+
for (const scenario of SCENARIOS) {
|
|
161
|
+
const engine = new GraphExecutionEngine(buildGraph(scenario.seed), { lint: "error" });
|
|
162
|
+
const visited = instrument(engine);
|
|
163
|
+
|
|
164
|
+
const report = await engine.run();
|
|
165
|
+
const path = report.results.map(result => result.nodeId);
|
|
166
|
+
const ok = path.join(",") === scenario.path.join(",") && report.status === "COMPLETED";
|
|
167
|
+
if (!ok) failures += 1;
|
|
168
|
+
|
|
169
|
+
console.log(`\n▶ seed = ${JSON.stringify(scenario.seed)} ${scenario.label}`);
|
|
170
|
+
console.log(
|
|
171
|
+
` ${report.status} path: ${path.join(" → ")} ` +
|
|
172
|
+
`${ok ? "✓" : `✗ expected ${scenario.path.join(" → ")}`}`
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
for (const step of visited) {
|
|
176
|
+
const route = step.route ? ` ⤳ routed to ${step.route.nextNode} (via ${step.route.via})` : "";
|
|
177
|
+
console.log(` ${step.nodeId.padEnd(2)} output=${show(step.output).padEnd(40)}${route}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const error of report.errors) {
|
|
181
|
+
console.log(` error: ${error.code}@${error.nodeId} handled=${error.handled}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
console.log(`\n${"-".repeat(100)}`);
|
|
186
|
+
console.log(`scenarios: ${SCENARIOS.length - failures}/${SCENARIOS.length} matched`);
|
|
187
|
+
if (failures > 0) process.exitCode = 1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (require.main === module) {
|
|
191
|
+
void main();
|
|
192
|
+
}
|