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.
Files changed (88) hide show
  1. package/CHANGELOG.md +18 -5
  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,848 @@
1
+ /* ============================================================
2
+ * TYPED EDGES & EXPLICIT ROUTING PACK (v1.3)
3
+ * ------------------------------------------------------------
4
+ * Pins the new `next` contract:
5
+ *
6
+ * string → parallel edge (v1.0 behaviour)
7
+ * { nextNode, result } → exclusive, deep-equality matcher
8
+ * { nextNode, test } → exclusive, predicate matcher
9
+ * { nextNode } → exclusive else edge
10
+ * { nextNode, parallel: true } → unconditional, out of the chain
11
+ * return new Next(target, value)→ explicit route for the whole activation
12
+ * context.goto(target) + return value → same, for void/imperative nodes
13
+ *
14
+ * Plus the three new lint codes and the validation rules.
15
+ * ============================================================ */
16
+
17
+ import {
18
+ AutomateGraphError,
19
+ GraphExecutionEngine,
20
+ GraphValidationError,
21
+ Next,
22
+ deepEquals,
23
+ lintGraph,
24
+ normalizeNextEdges
25
+ } from "../src/automategraph";
26
+
27
+ import { capture, constNode, g, logCollector, node, sleep } from "./helpers";
28
+ import { suite } from "./harness";
29
+
30
+ const edges = suite("edges", "Typed edges & routing");
31
+
32
+ const BIG = { timeoutMs: 20_000 };
33
+
34
+ /* ------------------------------ 1. compatibility ------------------------------ */
35
+
36
+ edges.t(
37
+ "string edges keep the v1.0 meaning: unconditional fan-out",
38
+ async t => {
39
+ const definition = g({
40
+ nodes: [
41
+ node("a", () => 1, { next: ["b", "c"] }),
42
+ constNode("b", "B"),
43
+ constNode("c", "C")
44
+ ],
45
+ entryNodes: ["a"],
46
+ terminalNodes: ["b", "c"]
47
+ });
48
+
49
+ const report = await new GraphExecutionEngine(definition).run();
50
+ const ran = report.results.map(result => result.nodeId);
51
+
52
+ if (!ran.includes("b") || !ran.includes("c")) throw new Error(ran.join(","));
53
+ if (report.status !== "COMPLETED") throw new Error(report.status);
54
+
55
+ const normalized = normalizeNextEdges(definition.nodes.a.next);
56
+ if (normalized.length !== 2) throw new Error(`normalized=${normalized.length}`);
57
+ if (!normalized.every(edge => edge.parallel)) throw new Error("strings must normalize to parallel");
58
+
59
+ t.log("finding: `next: [\"b\",\"c\"]` normalizes to two parallel edges — every existing graph keeps its behaviour");
60
+ },
61
+ BIG
62
+ );
63
+
64
+ /* ------------------------------ 2. exclusive conditionals ------------------------------ */
65
+
66
+ edges.t(
67
+ "result matches by deep equality and only the first matching edge fires",
68
+ async t => {
69
+ const definition = g({
70
+ nodes: [
71
+ node("a", () => 1, {
72
+ next: [
73
+ { result: 1, nextNode: "b" },
74
+ { result: 2, nextNode: "c" },
75
+ { nextNode: "d" }
76
+ ]
77
+ }),
78
+ constNode("b", "B", { next: ["end"] }),
79
+ constNode("c", "C", { next: ["end"] }),
80
+ constNode("d", "D", { next: ["end"] }),
81
+ constNode("end", "END")
82
+ ],
83
+ entryNodes: ["a"],
84
+ terminalNodes: ["end"]
85
+ });
86
+
87
+ const report = await new GraphExecutionEngine(definition).run();
88
+ const ran = report.results.map(result => result.nodeId);
89
+
90
+ if (!ran.includes("b")) throw new Error("result: 1 did not route to b");
91
+ if (ran.includes("c") || ran.includes("d")) throw new Error(`extra branches ran: ${ran.join(",")}`);
92
+ if (report.outputs.end !== "END") throw new Error("end never ran");
93
+ },
94
+ BIG
95
+ );
96
+
97
+ edges.t(
98
+ "deep equality matches nested objects regardless of key order",
99
+ async t => {
100
+ const definition = g({
101
+ nodes: [
102
+ node("a", () => ({ tags: ["x", "y"], id: 7 }), {
103
+ next: [
104
+ { result: { id: 7, tags: ["x", "y"] }, nextNode: "b" },
105
+ { nextNode: "d" }
106
+ ]
107
+ }),
108
+ constNode("b", "B"),
109
+ constNode("d", "D")
110
+ ],
111
+ entryNodes: ["a"],
112
+ terminalNodes: ["b", "d"]
113
+ });
114
+
115
+ const report = await new GraphExecutionEngine(definition).run();
116
+ const ran = report.results.map(result => result.nodeId);
117
+
118
+ if (!ran.includes("b")) throw new Error(`deep-equality match failed: ${ran.join(",")}`);
119
+ if (ran.includes("d")) throw new Error("else edge fired although a result matched");
120
+
121
+ t.log("finding: result uses structural equality (key order, nesting and array order aware)");
122
+ },
123
+ BIG
124
+ );
125
+
126
+ edges.t(
127
+ "result: NaN matches a NaN output (Object.is semantics)",
128
+ async t => {
129
+ const definition = g({
130
+ nodes: [
131
+ node("a", () => Number.NaN, {
132
+ next: [
133
+ { result: Number.NaN, nextNode: "b" },
134
+ { nextNode: "d" }
135
+ ]
136
+ }),
137
+ constNode("b", "B"),
138
+ constNode("d", "D")
139
+ ],
140
+ entryNodes: ["a"],
141
+ terminalNodes: ["b", "d"]
142
+ });
143
+
144
+ const report = await new GraphExecutionEngine(definition).run();
145
+ const ran = report.results.map(result => result.nodeId);
146
+
147
+ if (!ran.includes("b")) throw new Error(`NaN did not match: ${ran.join(",")}`);
148
+ if (ran.includes("d")) throw new Error("else fired too");
149
+
150
+ if (!deepEquals(Number.NaN, Number.NaN)) throw new Error("deepEquals(NaN, NaN) is false");
151
+ if (deepEquals(0, -0)) throw new Error("deepEquals(0, -0) should be false (Object.is)");
152
+ },
153
+ BIG
154
+ );
155
+
156
+ edges.t(
157
+ "result: undefined matches an undefined output (presence, not truthiness)",
158
+ async t => {
159
+ const definition = g({
160
+ nodes: [
161
+ node("a", () => undefined, {
162
+ next: [
163
+ { result: undefined, nextNode: "b" },
164
+ { nextNode: "d" }
165
+ ]
166
+ }),
167
+ constNode("b", "B"),
168
+ constNode("d", "D")
169
+ ],
170
+ entryNodes: ["a"],
171
+ terminalNodes: ["b", "d"]
172
+ });
173
+
174
+ const report = await new GraphExecutionEngine(definition).run();
175
+ const ran = report.results.map(result => result.nodeId);
176
+
177
+ if (!ran.includes("b")) throw new Error(`undefined did not match: ${ran.join(",")}`);
178
+ if (ran.includes("d")) throw new Error("else fired too");
179
+
180
+ t.log("finding: hasResult tracks key presence, so an undefined output is matchable");
181
+ },
182
+ BIG
183
+ );
184
+
185
+ edges.t(
186
+ "test predicate routes the activation",
187
+ async t => {
188
+ const definition = g({
189
+ nodes: [
190
+ node("a", () => 20, {
191
+ next: [
192
+ { test: value => typeof value === "number" && value > 10, nextNode: "b" },
193
+ { nextNode: "d" }
194
+ ]
195
+ }),
196
+ constNode("b", "B"),
197
+ constNode("d", "D")
198
+ ],
199
+ entryNodes: ["a"],
200
+ terminalNodes: ["b", "d"]
201
+ });
202
+
203
+ const report = await new GraphExecutionEngine(definition).run();
204
+ const ran = report.results.map(result => result.nodeId);
205
+
206
+ if (!ran.includes("b") || ran.includes("d")) throw new Error(ran.join(","));
207
+ },
208
+ BIG
209
+ );
210
+
211
+ edges.t(
212
+ "two matching conditionals: declaration order wins",
213
+ async t => {
214
+ const definition = g({
215
+ nodes: [
216
+ node("a", () => 1, {
217
+ next: [
218
+ { test: () => true, nextNode: "b" },
219
+ { test: () => true, nextNode: "c" },
220
+ { nextNode: "d" }
221
+ ]
222
+ }),
223
+ constNode("b", "B"),
224
+ constNode("c", "C"),
225
+ constNode("d", "D")
226
+ ],
227
+ entryNodes: ["a"],
228
+ terminalNodes: ["b", "c", "d"]
229
+ });
230
+
231
+ const report = await new GraphExecutionEngine(definition).run();
232
+ const ran = report.results.map(result => result.nodeId);
233
+
234
+ if (!ran.includes("b")) throw new Error("first matching edge did not win");
235
+ if (ran.includes("c") || ran.includes("d")) throw new Error(`later edges fired: ${ran.join(",")}`);
236
+ },
237
+ BIG
238
+ );
239
+
240
+ edges.t(
241
+ "result + test on one edge must both pass (AND)",
242
+ async t => {
243
+ const definition = g({
244
+ nodes: [
245
+ node("a", () => 5, {
246
+ next: [
247
+ { result: 5, test: value => typeof value === "number" && value > 10, nextNode: "b" },
248
+ { nextNode: "d" }
249
+ ]
250
+ }),
251
+ constNode("b", "B"),
252
+ constNode("d", "D")
253
+ ],
254
+ entryNodes: ["a"],
255
+ terminalNodes: ["b", "d"]
256
+ });
257
+
258
+ const report = await new GraphExecutionEngine(definition).run();
259
+ const ran = report.results.map(result => result.nodeId);
260
+
261
+ if (ran.includes("b")) throw new Error("AND failed: b ran although test() was false");
262
+ if (!ran.includes("d")) throw new Error("else edge should have fired");
263
+ },
264
+ BIG
265
+ );
266
+
267
+ edges.t(
268
+ "the else edge (no matcher) fires when nothing earlier matched",
269
+ async t => {
270
+ const definition = g({
271
+ nodes: [
272
+ node("a", () => 3, {
273
+ next: [
274
+ { result: 1, nextNode: "b" },
275
+ { test: value => value === 2, nextNode: "c" },
276
+ { nextNode: "d" }
277
+ ]
278
+ }),
279
+ constNode("b", "B"),
280
+ constNode("c", "C"),
281
+ constNode("d", "D")
282
+ ],
283
+ entryNodes: ["a"],
284
+ terminalNodes: ["b", "c", "d"]
285
+ });
286
+
287
+ const report = await new GraphExecutionEngine(definition).run();
288
+ const ran = report.results.map(result => result.nodeId);
289
+
290
+ if (!ran.includes("d") || ran.includes("b") || ran.includes("c")) {
291
+ throw new Error(ran.join(","));
292
+ }
293
+ },
294
+ BIG
295
+ );
296
+
297
+ edges.t(
298
+ "no match is silent: nothing is scheduled, no routing error is recorded",
299
+ async t => {
300
+ const definition = g({
301
+ nodes: [
302
+ node("a", () => 42, {
303
+ next: [
304
+ { result: 1, nextNode: "b" },
305
+ { test: value => value === 2, nextNode: "c" }
306
+ ]
307
+ }),
308
+ constNode("b", "B"),
309
+ constNode("c", "C")
310
+ ],
311
+ entryNodes: ["a"],
312
+ terminalNodes: ["b"]
313
+ });
314
+
315
+ const report = await new GraphExecutionEngine(definition).run();
316
+
317
+ if (report.results.length !== 1) throw new Error(`results=${report.results.length}`);
318
+ if (report.status !== "FAILED") throw new Error(report.status);
319
+ if (report.errors.length !== 1) throw new Error(`errors=${report.errors.length}`);
320
+ if (report.errors[0].code !== "GRAPH_TERMINAL_NOT_REACHED") throw new Error(report.errors[0].code);
321
+
322
+ t.log("finding: an unmatched conditional chain is silent by design (no NO_EDGE_MATCHED error)");
323
+ },
324
+ BIG
325
+ );
326
+
327
+ /* ------------------------------ 3. parallel edges ------------------------------ */
328
+
329
+ edges.t(
330
+ "parallel edges fire alongside the winning conditional",
331
+ async t => {
332
+ const definition = g({
333
+ nodes: [
334
+ node("a", () => 1, {
335
+ next: [
336
+ { result: 1, nextNode: "b" },
337
+ "audit"
338
+ ]
339
+ }),
340
+ constNode("b", "B"),
341
+ constNode("audit", "AUDITED")
342
+ ],
343
+ entryNodes: ["a"],
344
+ terminalNodes: ["b", "audit"]
345
+ });
346
+
347
+ const report = await new GraphExecutionEngine(definition).run();
348
+ const ran = report.results.map(result => result.nodeId);
349
+
350
+ if (!ran.includes("b")) throw new Error("conditional branch missing");
351
+ if (!ran.includes("audit")) throw new Error("parallel edge was suppressed by the conditional");
352
+ },
353
+ BIG
354
+ );
355
+
356
+ edges.t(
357
+ "a parallel edge is never blocked, even after a conditional match",
358
+ async t => {
359
+ const definition = g({
360
+ nodes: [
361
+ node("a", () => 7, {
362
+ next: [
363
+ { test: () => true, nextNode: "b" },
364
+ "audit",
365
+ { test: () => true, nextNode: "c" }
366
+ ]
367
+ }),
368
+ constNode("b", "B"),
369
+ constNode("c", "C"),
370
+ constNode("audit", "AUDITED")
371
+ ],
372
+ entryNodes: ["a"],
373
+ terminalNodes: ["b", "c", "audit"]
374
+ });
375
+
376
+ const report = await new GraphExecutionEngine(definition).run();
377
+ const ran = report.results.map(result => result.nodeId);
378
+
379
+ if (!ran.includes("b")) throw new Error("first conditional must win");
380
+ if (!ran.includes("audit")) throw new Error("parallel edge blocked by exclusivity");
381
+ if (ran.includes("c")) throw new Error("second conditional should be skipped");
382
+ },
383
+ BIG
384
+ );
385
+
386
+ edges.t(
387
+ "a parallel edge with a matcher is still unconditional (matcher ignored, lint warns)",
388
+ async t => {
389
+ const definition = g({
390
+ nodes: [
391
+ node("a", () => 1, {
392
+ next: [
393
+ "audit",
394
+ { result: 999, parallel: true, nextNode: "b" }
395
+ ]
396
+ }),
397
+ constNode("b", "B"),
398
+ constNode("audit", "AUDITED")
399
+ ],
400
+ entryNodes: ["a"],
401
+ terminalNodes: ["b", "audit"]
402
+ });
403
+
404
+ const report = await new GraphExecutionEngine(definition).run();
405
+ const ran = report.results.map(result => result.nodeId);
406
+
407
+ if (!ran.includes("b") || !ran.includes("audit")) throw new Error(ran.join(","));
408
+
409
+ const issues = lintGraph(definition);
410
+ const warning = issues.find(issue => issue.code === "PARALLEL_EDGE_MATCHER_IGNORED");
411
+ if (!warning) throw new Error(`lint codes: ${issues.map(i => i.code).join(",")}`);
412
+ if (warning.level !== "warn") throw new Error(warning.level);
413
+ if (warning.nodeId !== "a") throw new Error(String(warning.nodeId));
414
+ },
415
+ BIG
416
+ );
417
+
418
+ /* ------------------------------ 4. explicit routing ------------------------------ */
419
+
420
+ edges.t(
421
+ "returning new Next(target, value) routes and successors see the unwrapped value",
422
+ async t => {
423
+ let seen: unknown;
424
+ const definition = g({
425
+ nodes: [
426
+ node("a", () => new Next("b", { k: 2 })),
427
+ node("b", context => { seen = context.outputs.a; return "B"; })
428
+ ],
429
+ entryNodes: ["a"],
430
+ terminalNodes: ["b"]
431
+ });
432
+
433
+ const engine = new GraphExecutionEngine(definition);
434
+ const succeeded = capture(engine, ["node.succeeded"]);
435
+ const report = await engine.run();
436
+
437
+ if (report.outputs.a === undefined) throw new Error("a produced no output");
438
+ if (!deepEquals(seen, { k: 2 })) throw new Error(`successor saw ${JSON.stringify(seen)}`);
439
+ if (report.outputs.b !== "B") throw new Error("b did not run");
440
+
441
+ const payload = succeeded.find(entry => (entry.payload as { nodeId: string }).nodeId === "a")!
442
+ .payload as { output: unknown; route?: { nextNode: string; via: string } };
443
+
444
+ if (!deepEquals(payload.output, { k: 2 })) throw new Error("event output was not unwrapped");
445
+ if (payload.route?.nextNode !== "b" || payload.route.via !== "next-class") {
446
+ throw new Error(`route payload wrong: ${JSON.stringify(payload.route)}`);
447
+ }
448
+
449
+ t.log("finding: Next carries both destination and payload — successors and matchers see the payload");
450
+ },
451
+ BIG
452
+ );
453
+
454
+ edges.t(
455
+ "context.goto(target) + return value routes for void/imperative nodes",
456
+ async t => {
457
+ const definition = g({
458
+ nodes: [
459
+ node("a", context => {
460
+ context.goto("f");
461
+ return "payload"; // stays a's output
462
+ }),
463
+ constNode("f", "F")
464
+ ],
465
+ entryNodes: ["a"],
466
+ terminalNodes: ["f"]
467
+ });
468
+
469
+ const report = await new GraphExecutionEngine(definition).run();
470
+
471
+ if (report.outputs.a !== "payload") throw new Error(String(report.outputs.a));
472
+ if (report.outputs.f !== "F") throw new Error("goto target did not run");
473
+ if (report.status !== "COMPLETED") throw new Error(report.status);
474
+ },
475
+ BIG
476
+ );
477
+
478
+ edges.t(
479
+ "a void node can route with goto alone",
480
+ async t => {
481
+ const definition = g({
482
+ nodes: [
483
+ node("b", context => { context.goto("f"); return undefined; }),
484
+ constNode("f", "F")
485
+ ],
486
+ entryNodes: ["b"],
487
+ terminalNodes: ["f"]
488
+ });
489
+
490
+ const report = await new GraphExecutionEngine(definition).run();
491
+ if (report.outputs.f !== "F") throw new Error("f never ran");
492
+ if (report.outputs.b !== undefined) throw new Error("void node recorded an output");
493
+ },
494
+ BIG
495
+ );
496
+
497
+ edges.t(
498
+ "an explicit route replaces the whole edge list, parallel edges included",
499
+ async t => {
500
+ const definition = g({
501
+ nodes: [
502
+ node("a", () => new Next("c", 1), {
503
+ next: ["audit", { result: 1, nextNode: "b" }]
504
+ }),
505
+ constNode("b", "B"),
506
+ constNode("c", "C"),
507
+ constNode("audit", "AUDITED")
508
+ ],
509
+ entryNodes: ["a"],
510
+ terminalNodes: ["b", "c", "audit"]
511
+ });
512
+
513
+ const report = await new GraphExecutionEngine(definition).run();
514
+ const ran = report.results.map(result => result.nodeId);
515
+
516
+ if (!ran.includes("c")) throw new Error("explicit target did not run");
517
+ if (ran.includes("b") || ran.includes("audit")) {
518
+ throw new Error(`edge list still fired: ${ran.join(",")}`);
519
+ }
520
+
521
+ t.log("finding: an explicit route is authoritative — declared edges (and parallel ones) are ignored");
522
+ },
523
+ BIG
524
+ );
525
+
526
+ edges.t(
527
+ "return new Next wins over context.goto when both are used",
528
+ async t => {
529
+ const definition = g({
530
+ nodes: [
531
+ node("a", context => {
532
+ context.goto("c");
533
+ return new Next("b", 1);
534
+ }),
535
+ constNode("b", "B"),
536
+ constNode("c", "C")
537
+ ],
538
+ entryNodes: ["a"],
539
+ terminalNodes: ["b", "c"]
540
+ });
541
+
542
+ const report = await new GraphExecutionEngine(definition).run();
543
+ const ran = report.results.map(result => result.nodeId);
544
+
545
+ if (!ran.includes("b")) throw new Error("Next should take precedence");
546
+ if (ran.includes("c")) throw new Error(`goto also fired: ${ran.join(",")}`);
547
+ },
548
+ BIG
549
+ );
550
+
551
+ edges.t(
552
+ "goto works per attempt: the route is taken by the successful attempt",
553
+ async t => {
554
+ const definition = g({
555
+ nodes: [
556
+ node("a", context => {
557
+ if (context.attempt === 1) throw new Error("transient");
558
+ context.goto("f");
559
+ return `ok-after-${context.attempt}`;
560
+ }, { retry: { maxAttempts: 2, delayMs: 5 }, errorPolicy: "RETRY", next: [] }),
561
+ constNode("f", "F")
562
+ ],
563
+ entryNodes: ["a"],
564
+ terminalNodes: ["f"]
565
+ });
566
+
567
+ const report = await new GraphExecutionEngine(definition).run();
568
+ const result = report.results.find(entry => entry.nodeId === "a")!;
569
+
570
+ if (result.attempt !== 2) throw new Error(`attempt=${result.attempt}`);
571
+ if (report.outputs.a !== "ok-after-2") throw new Error(String(report.outputs.a));
572
+ if (report.outputs.f !== "F") throw new Error("f never ran");
573
+ },
574
+ BIG
575
+ );
576
+
577
+ edges.t(
578
+ "routing to an unknown node records INVALID_GOTO_TARGET and schedules nothing",
579
+ async t => {
580
+ for (const [label, definition] of [
581
+ ["goto", g({
582
+ nodes: [
583
+ node("a", context => { context.goto("nowhere"); return 1; }),
584
+ constNode("end", "END")
585
+ ],
586
+ entryNodes: ["a"],
587
+ terminalNodes: ["end"]
588
+ })],
589
+ ["Next", g({
590
+ nodes: [
591
+ node("a", () => new Next("nowhere", 1)),
592
+ constNode("end", "END")
593
+ ],
594
+ entryNodes: ["a"],
595
+ terminalNodes: ["end"]
596
+ })]
597
+ ] as const) {
598
+ const report = await new GraphExecutionEngine(definition).run();
599
+ const error = report.errors.find(entry => entry.code === "INVALID_GOTO_TARGET");
600
+
601
+ if (!error) throw new Error(`[${label}] missing INVALID_GOTO_TARGET`);
602
+ if (error.handled) throw new Error(`[${label}] routing bug must stay unhandled`);
603
+ if (report.results.length !== 1) throw new Error(`[${label}] successors were scheduled`);
604
+ if (report.status !== "FAILED") throw new Error(`[${label}] ${report.status}`);
605
+ if ((error.details as { to: string }).to !== "nowhere") throw new Error(`[${label}] details`);
606
+ }
607
+ },
608
+ BIG
609
+ );
610
+
611
+ edges.t(
612
+ "new Next with an empty target throws INVALID_NEXT_TARGET immediately",
613
+ () => {
614
+ try {
615
+ new Next(" ", 1);
616
+ throw new Error("expected throw");
617
+ } catch (error) {
618
+ if (!(error instanceof AutomateGraphError)) throw error;
619
+ if (error.code !== "INVALID_NEXT_TARGET") throw new Error(error.code);
620
+ }
621
+ }
622
+ );
623
+
624
+ /* ------------------------------ 5. terminals & lint ------------------------------ */
625
+
626
+ edges.t(
627
+ "a terminal node ignores its edges and its routing, and lint warns about it",
628
+ async t => {
629
+ const definition = g({
630
+ nodes: [
631
+ node("a", () => new Next("b", 1), { next: ["b"] }),
632
+ constNode("b", "B")
633
+ ],
634
+ entryNodes: ["a"],
635
+ terminalNodes: ["a", "b"] // "a" is terminal on purpose
636
+ });
637
+
638
+ const report = await new GraphExecutionEngine(definition).run();
639
+ const ran = report.results.map(result => result.nodeId);
640
+
641
+ if (ran.includes("b")) throw new Error("terminal node routed anyway");
642
+ if (!report.logicallyCompleted) throw new Error("terminal did not latch completion");
643
+
644
+ const issues = lintGraph(definition);
645
+ const warning = issues.find(issue => issue.code === "TERMINAL_EDGE_IGNORED");
646
+ if (!warning) throw new Error(`lint codes: ${issues.map(i => i.code).join(",")}`);
647
+ if (warning.level !== "warn" || warning.nodeId !== "a") throw new Error(JSON.stringify(warning));
648
+
649
+ t.log("finding: terminal detection happens before routing — a terminal node never schedules successors");
650
+ },
651
+ BIG
652
+ );
653
+
654
+ edges.t(
655
+ "lint flags an exclusive edge declared after an else edge as unreachable",
656
+ async t => {
657
+ const definition = g({
658
+ nodes: [
659
+ node("a", () => 1, {
660
+ next: [
661
+ { nextNode: "d" }, // else: always matches
662
+ { result: 1, nextNode: "b" } // dead
663
+ ]
664
+ }),
665
+ constNode("b", "B", { next: ["end"] }),
666
+ constNode("d", "D", { next: ["end"] }),
667
+ constNode("end", "END")
668
+ ],
669
+ entryNodes: ["a"],
670
+ terminalNodes: ["end"]
671
+ });
672
+
673
+ const issues = lintGraph(definition);
674
+ const dead = issues.find(issue => issue.code === "UNREACHABLE_EDGE");
675
+
676
+ if (!dead) throw new Error(`lint codes: ${issues.map(i => i.code).join(",")}`);
677
+ if (dead.level !== "error") throw new Error(dead.level);
678
+ if (dead.nodeId !== "a" || !dead.message.includes('"b"')) throw new Error(dead.message);
679
+
680
+ const report = await new GraphExecutionEngine(definition).run();
681
+ const ran = report.results.map(result => result.nodeId);
682
+ if (!ran.includes("d") || ran.includes("b")) throw new Error(ran.join(","));
683
+
684
+ t.log("finding: an else edge shadows every exclusive edge declared after it — runtime agrees with lint");
685
+ },
686
+ BIG
687
+ );
688
+
689
+ /* ------------------------------ 6. validation ------------------------------ */
690
+
691
+ edges.t(
692
+ "malformed edge entries are rejected at construction time",
693
+ () => {
694
+ const cases: Array<[unknown, RegExp]> = [
695
+ [42, /malformed next edge/],
696
+ [{ nextNode: "" }, /non-empty string/],
697
+ // v1.3 shape rule, extended in v1.4: a STRING test is a registry
698
+ // name, so only a non-callable, non-string value is malformed here.
699
+ [{ nextNode: "b", test: 42 }, /test must be a function/],
700
+ [{ nextNode: "b", test: "not a name!" }, /invalid test function name/],
701
+ [{ nextNode: "b", parallel: "yes" }, /parallel flag must be a boolean/],
702
+ [{ nextNode: "ghost" }, /unknown next Node "ghost"/],
703
+ ["", /empty next Node reference/]
704
+ ];
705
+
706
+ for (const [entry, pattern] of cases) {
707
+ try {
708
+ new GraphExecutionEngine(g({
709
+ nodes: [
710
+ node("a", () => 1, { next: [entry as never] }),
711
+ constNode("b", "B")
712
+ ],
713
+ entryNodes: ["a"],
714
+ terminalNodes: ["b"]
715
+ }));
716
+ throw new Error(`expected throw for ${JSON.stringify(entry)}`);
717
+ } catch (error) {
718
+ if (!(error instanceof GraphValidationError)) throw error;
719
+ if (!pattern.test(error.message)) {
720
+ throw new Error(`got "${error.message}", expected ${pattern}`);
721
+ }
722
+ }
723
+ }
724
+ }
725
+ );
726
+
727
+ /* ------------------------------ 7. the payoff: one execution ------------------------------ */
728
+
729
+ edges.t(
730
+ "the a → b/c/d/e/f picture runs as ONE execution — the router is no longer needed",
731
+ async t => {
732
+ type Seed = 1 | 2 | 3 | "throw" | "retry";
733
+
734
+ const build = (seed: Seed) => g({
735
+ id: "branching-edges",
736
+ version: "1.3.0",
737
+ nodes: [
738
+ node("a", context => {
739
+ if (seed === "throw") throw new Error("a: upstream exploded");
740
+ if (seed === "retry" && context.attempt === 1) throw new Error("a: transient");
741
+
742
+ const result = seed === "retry" ? 2 : seed;
743
+
744
+ // "on retry → f": only the node knows the attempt number
745
+ if (context.attempt > 1) return new Next("f", result);
746
+
747
+ return result;
748
+ }, {
749
+ next: [
750
+ { result: 1, nextNode: "b" },
751
+ { test: value => typeof value === "number" && value === 2, nextNode: "c" },
752
+ { nextNode: "d" }
753
+ ],
754
+ onThrow: "e",
755
+ retry: { maxAttempts: 2, delayMs: 5 },
756
+ errorPolicy: "RETRY"
757
+ }),
758
+
759
+ // b returns void and forces f; the declared edge keeps f reachable
760
+ // for static linting (runtime routing wins, the edge stays a fallback)
761
+ node("b", context => {
762
+ context.goto("f");
763
+ return undefined;
764
+ }, { next: ["f"] }),
765
+
766
+ constNode("c", "C"),
767
+ constNode("d", "D"),
768
+ constNode("e", "E"),
769
+ constNode("f", "F")
770
+ ],
771
+ entryNodes: ["a"],
772
+ terminalNodes: ["c", "d", "e", "f"],
773
+ completionPolicy: "FIRST_TERMINAL"
774
+ });
775
+
776
+ const expectations: Array<[Seed, string[], number]> = [
777
+ [1, ["a", "b", "f"], 1],
778
+ [2, ["a", "c"], 1],
779
+ [3, ["a", "d"], 1],
780
+ ["throw", ["a", "e"], 2],
781
+ ["retry", ["a", "f"], 2]
782
+ ];
783
+
784
+ for (const [seed, path, attempts] of expectations) {
785
+ const engine = new GraphExecutionEngine(build(seed), { lint: "error" });
786
+ const captured = capture(engine, ["activation.dropped"]);
787
+
788
+ let flaky = false;
789
+ engine.events.on("node.failed", () => { flaky = true; });
790
+
791
+ const report = await engine.run();
792
+ const ran = report.results.map(result => result.nodeId).join(",");
793
+ const a = report.results.find(result => result.nodeId === "a")!;
794
+
795
+ if (ran !== path.join(",")) throw new Error(`seed ${JSON.stringify(seed)}: ${ran} ≠ ${path.join(",")}`);
796
+ if (report.status !== "COMPLETED") throw new Error(`seed ${seed}: ${report.status}`);
797
+ if (a.attempt !== attempts) throw new Error(`seed ${seed}: attempt=${a.attempt}`);
798
+ if (captured.length !== 0) throw new Error(`seed ${seed}: work was dropped`);
799
+
800
+ t.log(`seed ${JSON.stringify(seed)}`, `${ran} (attempts ${a.attempt})`);
801
+ }
802
+
803
+ t.log(
804
+ "finding: with typed edges the whole picture is ONE execution and lint-clean — " +
805
+ "no router, no gate, no DRAIN policy needed"
806
+ );
807
+ },
808
+ BIG
809
+ );
810
+
811
+ edges.t(
812
+ "lint is clean for the payoff graph (routes declared, no dead edges)",
813
+ async t => {
814
+ const definition = g({
815
+ nodes: [
816
+ node("a", () => 1, {
817
+ next: [
818
+ { result: 1, nextNode: "b" },
819
+ { test: () => false, nextNode: "c" },
820
+ { nextNode: "d" }
821
+ ],
822
+ onThrow: "e"
823
+ }),
824
+ node("b", context => { context.goto("f"); return undefined; }, { next: ["f"] }),
825
+ constNode("c", "C", { next: ["f"] }),
826
+ constNode("d", "D", { next: ["f"] }),
827
+ constNode("e", "E", { next: ["f"] }),
828
+ constNode("f", "F")
829
+ ],
830
+ entryNodes: ["a"],
831
+ terminalNodes: ["f"]
832
+ });
833
+
834
+ const issues = lintGraph(definition);
835
+ if (issues.length !== 0) {
836
+ throw new Error(`lint issues: ${issues.map(issue => `${issue.code}@${issue.nodeId}`).join(",")}`);
837
+ }
838
+
839
+ const engine = new GraphExecutionEngine(definition, { lint: "error" });
840
+ const report = await engine.run();
841
+
842
+ if (report.status !== "COMPLETED") throw new Error(report.status);
843
+ if (!report.results.some(result => result.nodeId === "f")) throw new Error("f never ran");
844
+
845
+ t.log("graph linted clean and ran under lint: \"error\"");
846
+ },
847
+ BIG
848
+ );