automategraph 1.5.0 → 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.
Files changed (88) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/UPGRADE-PROPOSAL.md +141 -0
  3. package/docs/diagrams/c19-resilience-scenario.md +61 -0
  4. package/docs/diagrams/c19-resilience-scenario.mmd +28 -0
  5. package/docs/diagrams/c19-resilience-scenario.svg +181 -0
  6. package/examples/README.md +206 -0
  7. package/examples/branching-edges.ts +192 -0
  8. package/examples/branching-graph.ts +333 -0
  9. package/examples/branching-router.ts +475 -0
  10. package/examples/json-graph.ts +231 -0
  11. package/examples/json-scripts.ts +152 -0
  12. package/examples/order-flow.functions.cjs +32 -0
  13. package/examples/order-flow.graph.json +27 -0
  14. package/examples/order-flow.scripted.graph.json +68 -0
  15. package/js/examples/branching-edges.js +133 -0
  16. package/js/examples/branching-edges.js.map +1 -0
  17. package/js/examples/branching-graph.js +232 -0
  18. package/js/examples/branching-graph.js.map +1 -0
  19. package/js/examples/branching-router.js +305 -0
  20. package/js/examples/branching-router.js.map +1 -0
  21. package/js/examples/json-graph.js +197 -0
  22. package/js/examples/json-graph.js.map +1 -0
  23. package/js/examples/json-scripts.js +131 -0
  24. package/js/examples/json-scripts.js.map +1 -0
  25. package/js/tests/async.test.js +592 -0
  26. package/js/tests/async.test.js.map +1 -0
  27. package/js/tests/complex.test.js +972 -0
  28. package/js/tests/complex.test.js.map +1 -0
  29. package/js/tests/conformance.test.js +365 -0
  30. package/js/tests/conformance.test.js.map +1 -0
  31. package/js/tests/edges.test.js +702 -0
  32. package/js/tests/edges.test.js.map +1 -0
  33. package/js/tests/graphjson.test.js +504 -0
  34. package/js/tests/graphjson.test.js.map +1 -0
  35. package/js/tests/harness.js +192 -0
  36. package/js/tests/harness.js.map +1 -0
  37. package/js/tests/helpers.js +136 -0
  38. package/js/tests/helpers.js.map +1 -0
  39. package/js/tests/lint.test.js +317 -0
  40. package/js/tests/lint.test.js.map +1 -0
  41. package/js/tests/registry.test.js +705 -0
  42. package/js/tests/registry.test.js.map +1 -0
  43. package/js/tests/report.js +422 -0
  44. package/js/tests/report.js.map +1 -0
  45. package/js/tests/runner.js +67 -0
  46. package/js/tests/runner.js.map +1 -0
  47. package/js/tests/scripts.test.js +552 -0
  48. package/js/tests/scripts.test.js.map +1 -0
  49. package/js/tests/unit.test.js +1239 -0
  50. package/js/tests/unit.test.js.map +1 -0
  51. package/js/tests/upgrade.test.js +618 -0
  52. package/js/tests/upgrade.test.js.map +1 -0
  53. package/package.json +2 -9
  54. package/report/index.html +3727 -0
  55. package/report/report.json +3053 -0
  56. package/tests/async.test.ts +668 -0
  57. package/tests/complex.test.ts +1123 -0
  58. package/tests/conformance.test.ts +442 -0
  59. package/tests/edges.test.ts +848 -0
  60. package/tests/fixtures/scripts/bad-registration.cjs +4 -0
  61. package/tests/fixtures/scripts/broken.cjs +2 -0
  62. package/tests/fixtures/scripts/cjs-default.js +4 -0
  63. package/tests/fixtures/scripts/collide-a.cjs +4 -0
  64. package/tests/fixtures/scripts/collide-b.cjs +4 -0
  65. package/tests/fixtures/scripts/empty.cjs +2 -0
  66. package/tests/fixtures/scripts/esm-functions.mjs +10 -0
  67. package/tests/fixtures/scripts/esm-syntax.js +3 -0
  68. package/tests/fixtures/scripts/global-channel.cjs +6 -0
  69. package/tests/fixtures/scripts/global-channel.mjs +7 -0
  70. package/tests/fixtures/scripts/mismatch.cjs +4 -0
  71. package/tests/fixtures/scripts/needs-sibling.cjs +7 -0
  72. package/tests/fixtures/scripts/order.cjs +14 -0
  73. package/tests/fixtures/scripts/sibling-helper.cjs +3 -0
  74. package/tests/fixtures/scripts/throwing-global.mjs +6 -0
  75. package/tests/graphjson.test.ts +737 -0
  76. package/tests/harness.ts +291 -0
  77. package/tests/helpers.ts +216 -0
  78. package/tests/lint.test.ts +381 -0
  79. package/tests/registry.test.ts +1134 -0
  80. package/tests/report.ts +452 -0
  81. package/tests/runner.ts +58 -0
  82. package/tests/scripts.test.ts +970 -0
  83. package/tests/unit.test.ts +1192 -0
  84. package/tests/upgrade.test.ts +725 -0
  85. package/tools/server.mjs +152 -0
  86. package/tsconfig.browser.json +19 -0
  87. package/tsconfig.json +18 -0
  88. 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
+ }