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,333 @@
1
+ /* ============================================================
2
+ * Branching graph — a → b / c / d / e / f
3
+ * ------------------------------------------------------------
4
+ * ONE GraphDefinition. No router, no walk loop, no engine change.
5
+ * Built with the same tiny builders the fixtures use:
6
+ *
7
+ * node(id, run, extra) → { id, run, ...extra }
8
+ * constNode(id, output, x) → node(id, () => output, x)
9
+ * g(spec) → GraphDefinition
10
+ *
11
+ * The picture:
12
+ *
13
+ * a ── result === 1 ────────────▶ b ──(forces)──▶ f
14
+ * a ── test(result) === true ───▶ c
15
+ * a ── else ────────────────────▶ d
16
+ * a ── on throw ────────────────▶ e
17
+ * a ── on retry ────────────────▶ f
18
+ *
19
+ * HOW EACH ARROW IS EXPRESSED (engine-native only)
20
+ * ---------------------------------------------------------------
21
+ * fan-out + guards `next` is an UNCONDITIONAL fan-out, so a lists
22
+ * every candidate and each one starts with a guard
23
+ * that reads a's output: the acting branch does its
24
+ * work, the others return a SKIP marker.
25
+ * result === 1 → b guard: a.route === "b"
26
+ * test(result) → c guard: a.route === "c" (test(r) = r === 2)
27
+ * else → d guard: a.route === "d" (a computed "else")
28
+ * on throw → e real transition: a.onThrow = "e"
29
+ * on retry → f a returns route "f" when context.attempt > 1
30
+ * (the retry loop is internal, so the attempt
31
+ * number is the only per-attempt signal a node has)
32
+ * b forces f b returns void, then releases a gate that f is
33
+ * waiting on — f cannot act before b finished.
34
+ *
35
+ * WHY THE GATE FOR "b FORCES f"
36
+ * ---------------------------------------------------------------
37
+ * A node cannot schedule work. The only in-node state transition the
38
+ * engine offers is `throw` + `onThrow`, and here it cannot be used:
39
+ * • f must stay in a's fan-out, otherwise the retry arrow has no way
40
+ * to reach it (a recovered attempt succeeds, it cannot also throw);
41
+ * • if b threw to f, f would be activated a SECOND time, and f
42
+ * cannot tell that arrival apart from the fan-out one — both see
43
+ * the same outputs (b threw, so it has no output to check);
44
+ * • and b would be recorded FAILED, which is wrong for a jump.
45
+ * So b forces f by ORDER, not by scheduling: f is already activated by
46
+ * the fan-out, but it blocks on a gate that only b releases. The
47
+ * observable result is exactly "b runs, then f runs — because of b".
48
+ *
49
+ * COMPLETION
50
+ * ---------------------------------------------------------------
51
+ * b, c, d, e and f are declared terminal and the graph uses
52
+ * completionPolicy: "DRAIN", because skipping nodes also succeed and
53
+ * FIRST_TERMINAL could otherwise latch on a SKIP and drop real work.
54
+ *
55
+ * Run: node js/examples/branching-graph.js (after npm run build)
56
+ * ============================================================ */
57
+
58
+ import {
59
+ ExecutionReport,
60
+ GraphDefinition,
61
+ GraphExecutionEngine,
62
+ NodeContext,
63
+ NodeDefinition
64
+ } from "../src/automategraph";
65
+
66
+ /* =========================
67
+ * 1. Simple builders (same shape as tests/helpers.ts)
68
+ * ========================= */
69
+
70
+ type Extra = Omit<Partial<NodeDefinition>, "id" | "run">;
71
+
72
+ const node = (
73
+ id: string,
74
+ run: NodeDefinition["run"],
75
+ extra: Extra = {}
76
+ ): NodeDefinition => ({ id, run, ...extra });
77
+
78
+ const constNode = (
79
+ id: string,
80
+ output: unknown,
81
+ extra: Extra = {}
82
+ ): NodeDefinition => node(id, () => output, extra);
83
+
84
+ interface GraphSpec {
85
+ id?: string;
86
+ version?: string;
87
+ nodes: NodeDefinition[];
88
+ entryNodes: string[];
89
+ terminalNodes: string[];
90
+ errorPolicy?: GraphDefinition["errorPolicy"];
91
+ completionPolicy?: GraphDefinition["completionPolicy"];
92
+ }
93
+
94
+ const g = (spec: GraphSpec): GraphDefinition => ({
95
+ id: spec.id ?? "branching-graph",
96
+ version: spec.version ?? "1.0.0",
97
+ entryNodes: spec.entryNodes,
98
+ terminalNodes: spec.terminalNodes,
99
+ errorPolicy: spec.errorPolicy,
100
+ completionPolicy: spec.completionPolicy,
101
+ nodes: Object.fromEntries(spec.nodes.map(definition => [definition.id, definition]))
102
+ });
103
+
104
+ /* =========================
105
+ * 2. The spec: a returns 1, 2 or 3; test() splits c from d
106
+ * ========================= */
107
+
108
+ export type ScenarioSeed = 1 | 2 | 3 | "throw" | "retry";
109
+
110
+ /** `test(result)` from the picture. */
111
+ export const test = (result: number): boolean => result === 2;
112
+
113
+ /** Marker returned by every branch that was activated but is not the chosen one. */
114
+ export interface Skipped {
115
+ readonly skipped: true;
116
+ readonly nodeId: string;
117
+ }
118
+
119
+ const SKIPPED = (nodeId: string): Skipped => ({ skipped: true, nodeId });
120
+
121
+ /** What node `a` publishes for its successors. */
122
+ interface ARoute {
123
+ readonly route: "b" | "c" | "d" | "f";
124
+ readonly result: number;
125
+ readonly attempts: number;
126
+ }
127
+
128
+ const routeOf = (context: NodeContext): ARoute | undefined =>
129
+ context.outputs.a as ARoute | undefined;
130
+
131
+ /** Observation only — nobody acts on this event; it just records the intent. */
132
+ export const FORCE_EVENT = "graph.force";
133
+
134
+ /* =========================
135
+ * 3. The graph
136
+ * ========================= */
137
+
138
+ /**
139
+ * Builds the graph for one run. A fresh gate per run is what makes
140
+ * "b forces f" deterministic without a router.
141
+ */
142
+ export function buildGraph(seed: ScenarioSeed): GraphDefinition {
143
+ /* --- the b → f gate (closure state, not engine state) --- */
144
+ let released = false;
145
+ let release: () => void = () => {};
146
+ const gate = new Promise<void>(resolve => { release = () => { released = true; resolve(); }; });
147
+
148
+ return g({
149
+ id: "branching-graph",
150
+ version: "1.0.0",
151
+
152
+ nodes: [
153
+ /* ---------------- a: produces the value, owns every arrow ---------------- */
154
+ node("a", (context: NodeContext): ARoute => {
155
+ // on throw → e (engine transition, declared as a.onThrow below)
156
+ if (seed === "throw") {
157
+ throw new Error("a: upstream exploded");
158
+ }
159
+
160
+ // on retry → f: the retry loop is internal, so a sees its own attempt
161
+ if (seed === "retry" && context.attempt === 1) {
162
+ throw new Error(`a: transient failure on attempt ${context.attempt}`);
163
+ }
164
+
165
+ const result = seed === "retry" ? 2 : seed;
166
+
167
+ if (context.attempt > 1) {
168
+ // recovered on a later attempt → the "on retry" arrow points at f
169
+ return { route: "f", result, attempts: context.attempt };
170
+ }
171
+
172
+ if (result === 1) return { route: "b", result, attempts: context.attempt };
173
+ if (test(result)) return { route: "c", result, attempts: context.attempt };
174
+ return { route: "d", result, attempts: context.attempt }; // else
175
+ }, {
176
+ // unconditional fan-out: every candidate is activated, guards pick one
177
+ next: ["b", "c", "d", "f"],
178
+ onThrow: "e",
179
+ retry: { maxAttempts: 2, delayMs: 5 },
180
+ errorPolicy: "RETRY"
181
+ }),
182
+
183
+ /* ---------------- b: returns void, then forces f ---------------- */
184
+ node("b", async (context: NodeContext) => {
185
+ if (routeOf(context)?.route !== "b") return SKIPPED("b");
186
+
187
+ // b's work ... whatever it needs to do, it returns nothing
188
+ await context.emit(FORCE_EVENT, { from: "b", to: "f", reason: "b short-circuits to f" });
189
+
190
+ release(); // ← this is the force: f is unblocked by b, after b
191
+ return undefined; // void
192
+ }),
193
+
194
+ /* ---------------- c: test(result) === true ---------------- */
195
+ node("c", (context: NodeContext) =>
196
+ routeOf(context)?.route === "c"
197
+ ? `c: handled result ${routeOf(context)!.result}`
198
+ : SKIPPED("c")
199
+ ),
200
+
201
+ /* ---------------- d: else ---------------- */
202
+ node("d", (context: NodeContext) =>
203
+ routeOf(context)?.route === "d"
204
+ ? `d: handled result ${routeOf(context)!.result}`
205
+ : SKIPPED("d")
206
+ ),
207
+
208
+ /* ---------------- e: on throw ---------------- */
209
+ constNode("e", "e: failure route"),
210
+
211
+ /* ---------------- f: on retry, or forced by b ---------------- */
212
+ node("f", async (context: NodeContext) => {
213
+ const route = routeOf(context)?.route;
214
+
215
+ if (route === "f") return "f: reached after a retry (on retry arrow)";
216
+ if (route !== "b") return SKIPPED("f");
217
+
218
+ // route === "b": f only acts once b has forced it
219
+ if (!released) await gate;
220
+ return "f: forced by b (ran after b completed)";
221
+ })
222
+ ],
223
+
224
+ entryNodes: ["a"],
225
+ terminalNodes: ["b", "c", "d", "e", "f"],
226
+ // skipping nodes succeed too, so let the graph drain instead of
227
+ // latching on the first success (FIRST_TERMINAL would drop work)
228
+ completionPolicy: "DRAIN"
229
+ });
230
+ }
231
+
232
+ /* =========================
233
+ * 4. Demo — one engine run per seed, no router
234
+ * ========================= */
235
+
236
+ const SCENARIOS: ReadonlyArray<{
237
+ label: string;
238
+ seed: ScenarioSeed;
239
+ /** branch nodes that must do real work, in order */
240
+ expectedActed: string[];
241
+ }> = [
242
+ { label: "result === 1 → b, then b forces f", seed: 1, expectedActed: ["b", "f"] },
243
+ { label: "test(result) → c", seed: 2, expectedActed: ["c"] },
244
+ { label: "else → d", seed: 3, expectedActed: ["d"] },
245
+ { label: "on throw → e", seed: "throw", expectedActed: ["e"] },
246
+ { label: "on retry → f", seed: "retry", expectedActed: ["f"] }
247
+ ];
248
+
249
+ const isSkipped = (output: unknown): boolean =>
250
+ typeof output === "object" && output !== null && (output as Skipped).skipped === true;
251
+
252
+ const show = (output: unknown): string =>
253
+ output === undefined ? "undefined (void)" : JSON.stringify(output);
254
+
255
+ const BRANCHES = ["b", "c", "d", "e", "f"] as const;
256
+
257
+ /** `acted` = the branch nodes that did real work (a is the dispatcher, not a branch). */
258
+ export function summarise(report: ExecutionReport): {
259
+ acted: string[];
260
+ status: string;
261
+ attempts: number;
262
+ } {
263
+ const dispatcher = report.results.find(result => result.nodeId === "a");
264
+ return {
265
+ acted: report.results
266
+ .filter(
267
+ result =>
268
+ BRANCHES.includes(result.nodeId as (typeof BRANCHES)[number]) &&
269
+ result.status === "SUCCEEDED" &&
270
+ !isSkipped(result.output)
271
+ )
272
+ .map(result => result.nodeId),
273
+ status: report.status,
274
+ attempts: dispatcher?.attempt ?? 0
275
+ };
276
+ }
277
+
278
+ async function main(): Promise<void> {
279
+ console.log("=".repeat(104));
280
+ console.log("branching graph — one GraphDefinition, no router, engine untouched");
281
+ console.log("=".repeat(104));
282
+
283
+ let failures = 0;
284
+
285
+ for (const scenario of SCENARIOS) {
286
+ const engine = new GraphExecutionEngine(buildGraph(scenario.seed), { lint: "error" });
287
+ const forces: unknown[] = [];
288
+ engine.events.on(FORCE_EVENT, payload => { forces.push(payload); });
289
+
290
+ const report = await engine.run();
291
+ const summary = summarise(report);
292
+
293
+ const actedOk = summary.acted.join(",") === scenario.expectedActed.join(",");
294
+ const statusOk = summary.status === "COMPLETED";
295
+ if (!actedOk || !statusOk) failures += 1;
296
+
297
+ console.log(`\n▶ seed = ${JSON.stringify(scenario.seed)} ${scenario.label}`);
298
+ console.log(
299
+ ` status=${summary.status} a.attempt=${summary.attempts} ` +
300
+ `acted: ${summary.acted.join(" → ")} ${actedOk && statusOk ? "✓" : "✗"}`
301
+ );
302
+
303
+ for (const result of report.results) {
304
+ const tag = result.status === "SUCCEEDED" && !isSkipped(result.output)
305
+ ? "ACTS "
306
+ : result.status === "SUCCEEDED"
307
+ ? "skip "
308
+ : result.status.padEnd(5);
309
+ console.log(
310
+ ` ${tag} ${result.nodeId} attempt=${result.attempt} output=${show(result.output)}`
311
+ );
312
+ }
313
+
314
+ if (report.errors.length > 0) {
315
+ console.log(
316
+ ` errors: ${report.errors
317
+ .map(error => `${error.code}@${error.nodeId ?? "-"}(handled=${error.handled})`)
318
+ .join(", ")}`
319
+ );
320
+ }
321
+ if (forces.length > 0) {
322
+ console.log(` ${FORCE_EVENT}: ${JSON.stringify(forces[0])}`);
323
+ }
324
+ }
325
+
326
+ console.log(`\n${"-".repeat(104)}`);
327
+ console.log(`scenarios: ${SCENARIOS.length - failures}/${SCENARIOS.length} matched`);
328
+ if (failures > 0) process.exitCode = 1;
329
+ }
330
+
331
+ if (require.main === module) {
332
+ void main();
333
+ }