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,725 @@
1
+ /* ============================================================
2
+ * WAVE 1 — upgrade feature pack (v1.1)
3
+ * ------------------------------------------------------------
4
+ * One suite per finding that Wave 1 addressed, so each former
5
+ * "surprise" now has an executable guarantee:
6
+ *
7
+ * A1 cancellation symmetry A2 drain deadline / ABANDONED
8
+ * A3 completionPolicy + drops A4 sticky FAIL_FAST
9
+ * A5 FALLBACK routing A6 NodeResult.error
10
+ * A8 maxConcurrency / step budget A9 lintGraph + lint modes
11
+ * A10 lifecycle hygiene IP isomorphic build proof
12
+ * ============================================================ */
13
+
14
+ import { spawnSync } from "node:child_process";
15
+ import * as path from "node:path";
16
+
17
+ import {
18
+ AutomateGraphError,
19
+ GraphExecutionEngine,
20
+ GraphValidationError,
21
+ lintGraph
22
+ } from "../src/automategraph";
23
+
24
+ import {
25
+ capture,
26
+ constNode,
27
+ g,
28
+ linear,
29
+ logCollector,
30
+ node,
31
+ sleep,
32
+ tick
33
+ } from "./helpers";
34
+
35
+ import { suite } from "./harness";
36
+
37
+ const a1 = suite("upgrade", "A1 · cancellation symmetry");
38
+ const a2 = suite("upgrade", "A2 · drain deadline");
39
+ const a3 = suite("upgrade", "A3 · completion policy");
40
+ const a45 = suite("upgrade", "A4/A5 · FAIL_FAST & FALLBACK");
41
+ const a6 = suite("upgrade", "A6/A10 · results & hygiene");
42
+ const a8 = suite("upgrade", "A8 · concurrency & budget");
43
+ const a9 = suite("upgrade", "A9 · graph linting");
44
+ const ip = suite("upgrade", "Isomorphic build");
45
+
46
+ const BIG = { timeoutMs: 20_000 };
47
+
48
+ /* ========================= A2 — drain deadline ========================= */
49
+
50
+ a2.t(
51
+ "cancellationTimeoutMs bounds the drain and abandons in-flight work",
52
+ async t => {
53
+ const definition = g({
54
+ nodes: [
55
+ node("stubborn", async () => { await sleep(400); return "late"; })
56
+ ],
57
+ entryNodes: ["stubborn"],
58
+ terminalNodes: ["stubborn"]
59
+ });
60
+
61
+ const engine = new GraphExecutionEngine(definition, {
62
+ cancellationTimeoutMs: 40
63
+ });
64
+
65
+ const startedAt = Date.now();
66
+ const running = engine.run();
67
+ await sleep(10);
68
+ await engine.cancel();
69
+ const report = await running;
70
+ const durationMs = Date.now() - startedAt;
71
+
72
+ if (report.status !== "CANCELLED") throw new Error(report.status);
73
+ if (!report.drainTimedOut) throw new Error("drainTimedOut must be true");
74
+ if (durationMs > 300) throw new Error(`drain was not bounded (${durationMs}ms)`);
75
+ if (durationMs < 35) throw new Error(`settled before the drain deadline (${durationMs}ms)`);
76
+
77
+ const result = report.results[0];
78
+ if (result.status !== "ABANDONED") throw new Error(result.status);
79
+ if (result.output !== undefined) throw new Error("abandoned work must not adopt output");
80
+
81
+ if (report.abandonedActivations.length !== 1) {
82
+ throw new Error(`abandonedActivations=${report.abandonedActivations.length}`);
83
+ }
84
+ if (report.abandonedActivations[0] !== result.activationId) {
85
+ throw new Error("abandoned activation id mismatch");
86
+ }
87
+
88
+ const drainError = report.errors.find(error => error.code === "DRAIN_TIMEOUT");
89
+ if (!drainError) throw new Error("DRAIN_TIMEOUT not recorded");
90
+ if (!drainError.handled) throw new Error("DRAIN_TIMEOUT is informational, not a failure");
91
+ if (!(drainError.details as { abandoned: string[] }).abandoned.includes(result.activationId)) {
92
+ throw new Error("DRAIN_TIMEOUT details missing the abandoned activation");
93
+ }
94
+
95
+ // the abandoned node keeps running; its late output must never appear
96
+ const outputs = { ...report.outputs };
97
+ await sleep(430);
98
+ if (outputs.stubborn !== undefined) throw new Error("late output leaked into the report");
99
+
100
+ t.log("run duration", `${durationMs}ms (node sleeps 400ms)`);
101
+ t.log("finding: without cancellationTimeoutMs the engine still waits forever (v1.0 behaviour); " +
102
+ "setting it converts an unbounded drain into a bounded, reported one");
103
+ },
104
+ BIG
105
+ );
106
+
107
+ a2.t(
108
+ "without cancellationTimeoutMs the run waits for cooperative drain",
109
+ async t => {
110
+ const definition = g({
111
+ nodes: [node("slow", async () => { await sleep(60); return "done"; })],
112
+ entryNodes: ["slow"],
113
+ terminalNodes: ["slow"]
114
+ });
115
+
116
+ const engine = new GraphExecutionEngine(definition);
117
+ const startedAt = Date.now();
118
+ const running = engine.run();
119
+ await sleep(10);
120
+ await engine.cancel();
121
+ const report = await running;
122
+ const durationMs = Date.now() - startedAt;
123
+
124
+ if (report.drainTimedOut) throw new Error("drainTimedOut must be false when unbounded");
125
+ if (durationMs < 45) throw new Error(`settled too early (${durationMs}ms)`);
126
+ if (report.results[0].status !== "CANCELLED") throw new Error(report.results[0].status);
127
+ },
128
+ BIG
129
+ );
130
+
131
+ /* ========================= A3 — completion policies ========================= */
132
+
133
+ function twoTerminalGraph(slowMs: number) {
134
+ return g({
135
+ nodes: [
136
+ node("entry", () => "E", { next: ["fast-branch", "slow-branch"] }),
137
+ node("fast-branch", () => "FAST", { next: ["t1"] }),
138
+ node("slow-branch", async () => { await sleep(slowMs); return "SLOW"; }, { next: ["t2"] }),
139
+ constNode("t1", "T1"),
140
+ constNode("t2", "T2")
141
+ ],
142
+ entryNodes: ["entry"],
143
+ terminalNodes: ["t1", "t2"]
144
+ });
145
+ }
146
+
147
+ a3.t(
148
+ "ALL_TERMINALS waits for every declared terminal before latching completion",
149
+ async t => {
150
+ const definition = { ...twoTerminalGraph(25), completionPolicy: "ALL_TERMINALS" as const };
151
+ const engine = new GraphExecutionEngine(definition);
152
+ const captured = capture(engine, ["graph.logicallyCompleted"]);
153
+ const report = await engine.run();
154
+
155
+ if (report.status !== "COMPLETED") throw new Error(report.status);
156
+ if (!report.logicallyCompleted) throw new Error("not logically completed");
157
+ if (!report.results.some(result => result.nodeId === "t2")) throw new Error("t2 never ran");
158
+
159
+ if (captured.length !== 1) throw new Error(`logicallyCompleted events=${captured.length}`);
160
+ const payload = captured[0].payload as { terminalNodeId: string };
161
+ if (payload.terminalNodeId !== "t2") {
162
+ throw new Error(`latching terminal=${payload.terminalNodeId} (expected the last one, t2)`);
163
+ }
164
+
165
+ t.log("finding: ALL_TERMINALS gives fan-in style completion semantics without extra plumbing");
166
+ },
167
+ BIG
168
+ );
169
+
170
+ a3.t(
171
+ "DRAIN keeps scheduling after a terminal, so ONTHROW recovery is no longer dropped",
172
+ async t => {
173
+ const definition = {
174
+ ...g({
175
+ nodes: [
176
+ node("entry", () => "E", { next: ["fast-branch", "slow-branch"] }),
177
+ node("fast-branch", () => "FAST", { next: ["t1"] }),
178
+ node("slow-branch", async () => { await sleep(25); throw new Error("late-failure"); }, {
179
+ onThrow: "recovery"
180
+ }),
181
+ node("recovery", () => "RECOVERED", { next: ["t2"] }),
182
+ constNode("t1", "T1"),
183
+ constNode("t2", "T2")
184
+ ],
185
+ entryNodes: ["entry"],
186
+ terminalNodes: ["t1", "t2"]
187
+ }),
188
+ completionPolicy: "DRAIN" as const
189
+ };
190
+
191
+ const report = await new GraphExecutionEngine(definition).run();
192
+
193
+ if (report.status !== "COMPLETED") throw new Error(report.status);
194
+ if (report.outputs.recovery !== "RECOVERED") {
195
+ throw new Error("recovery was dropped in DRAIN mode");
196
+ }
197
+ if (!report.results.some(result => result.nodeId === "t2")) throw new Error("t2 never ran");
198
+ if (!report.errors[0].handled) throw new Error("recovery error should be handled");
199
+
200
+ t.log(
201
+ "finding: DRAIN is the opt-in fix for the v1.0 silent-drop behaviour (finding C-15/C-19) — " +
202
+ "the run executes every reachable activation before settling"
203
+ );
204
+ },
205
+ BIG
206
+ );
207
+
208
+ a3.t(
209
+ "FIRST_TERMINAL (default) drops pending work but now reports it via activation.dropped",
210
+ async t => {
211
+ const definition = g({
212
+ nodes: [
213
+ node("entry", () => "E", { next: ["fast-branch", "slow-branch"] }),
214
+ node("fast-branch", () => "FAST", { next: ["t1"] }),
215
+ node("slow-branch", async () => { await sleep(25); throw new Error("late-failure"); }, {
216
+ onThrow: "recovery"
217
+ }),
218
+ node("recovery", () => "RECOVERED", { next: ["t2"] }),
219
+ constNode("t1", "T1"),
220
+ constNode("t2", "T2")
221
+ ],
222
+ entryNodes: ["entry"],
223
+ terminalNodes: ["t1", "t2"]
224
+ });
225
+
226
+ const engine = new GraphExecutionEngine(definition);
227
+ const captured = capture(engine, ["activation.dropped"]);
228
+ const report = await engine.run();
229
+
230
+ if (report.status !== "COMPLETED") throw new Error(report.status);
231
+ if (report.outputs.recovery !== undefined) throw new Error("recovery unexpectedly ran");
232
+
233
+ if (captured.length !== 1) throw new Error(`activation.dropped events=${captured.length}`);
234
+ const payload = captured[0].payload as {
235
+ reason: string;
236
+ dropped: Array<{ nodeId: string }>;
237
+ };
238
+
239
+ if (payload.reason !== "logical-completion") throw new Error(payload.reason);
240
+ if (!payload.dropped.some(entry => entry.nodeId === "recovery")) {
241
+ throw new Error(`dropped=${payload.dropped.map(entry => entry.nodeId).join(",")}`);
242
+ }
243
+
244
+ t.log("finding: dropped work is now observable, so silent data loss in the default mode is detectable");
245
+ },
246
+ BIG
247
+ );
248
+
249
+ a3.t(
250
+ "an unknown completionPolicy is rejected at construction time",
251
+ () => {
252
+ try {
253
+ new GraphExecutionEngine({
254
+ ...linear(["a"]),
255
+ completionPolicy: "WHENEVER" as never
256
+ });
257
+ throw new Error("expected throw");
258
+ } catch (error) {
259
+ if (!(error instanceof GraphValidationError)) throw error;
260
+ if (!/Unknown completionPolicy/.test(error.message)) throw new Error(error.message);
261
+ }
262
+ }
263
+ );
264
+
265
+ /* ========================= A4 / A5 — policies ========================= */
266
+
267
+ a45.t(
268
+ "node-level FAIL_FAST is sticky and drops successors via activation.dropped",
269
+ async t => {
270
+ const definition = g({
271
+ nodes: [
272
+ node("root", () => "R", { next: ["worker", "breaker"] }),
273
+ node("worker", async () => { await sleep(30); return "WORKED"; }, { next: ["tail"] }),
274
+ node("breaker", () => { throw new Error("policy-violation"); }, { errorPolicy: "FAIL_FAST" }),
275
+ constNode("tail", "TAIL")
276
+ ],
277
+ entryNodes: ["root"],
278
+ terminalNodes: ["tail"]
279
+ });
280
+
281
+ const engine = new GraphExecutionEngine(definition);
282
+ const captured = capture(engine, ["activation.dropped"]);
283
+ const report = await engine.run();
284
+
285
+ if (report.status !== "FAILED") throw new Error(report.status);
286
+ if (report.results.some(result => result.nodeId === "tail")) {
287
+ throw new Error("sticky FAIL_FAST failed to stop the successor");
288
+ }
289
+ if (!report.results.some(result => result.nodeId === "worker" && result.status === "SUCCEEDED")) {
290
+ throw new Error("in-flight sibling must still drain");
291
+ }
292
+
293
+ const drop = captured.find(entry => (entry.payload as { reason: string }).reason === "fail-fast");
294
+ if (!drop) throw new Error("no fail-fast drop event");
295
+ const dropped = (drop.payload as { dropped: Array<{ nodeId: string }> }).dropped;
296
+ if (!dropped.some(entry => entry.nodeId === "tail")) {
297
+ throw new Error(`dropped=${dropped.map(entry => entry.nodeId).join(",")}`);
298
+ }
299
+ },
300
+ BIG
301
+ );
302
+
303
+ a45.t(
304
+ "FALLBACK routes to the fallback node and marks the error handled",
305
+ async t => {
306
+ const definition = g({
307
+ nodes: [
308
+ node("primary", () => { throw new Error("vendor-down"); }, {
309
+ errorPolicy: "FALLBACK",
310
+ fallback: "cached"
311
+ }),
312
+ node("cached", () => "CACHED-RESULT", { next: ["finish"] }),
313
+ constNode("finish", "DONE")
314
+ ],
315
+ entryNodes: ["primary"],
316
+ terminalNodes: ["finish"]
317
+ });
318
+
319
+ const report = await new GraphExecutionEngine(definition).run();
320
+
321
+ if (report.status !== "COMPLETED") throw new Error(report.status);
322
+ if (report.outputs.cached !== "CACHED-RESULT") throw new Error("fallback node did not run");
323
+ if (report.errors.length !== 1) throw new Error(`errors=${report.errors.length}`);
324
+ if (!report.errors[0].handled) throw new Error("fallback error must be handled");
325
+ if (report.errors[0].message !== "vendor-down") throw new Error(report.errors[0].message);
326
+ },
327
+ BIG
328
+ );
329
+
330
+ a45.t(
331
+ "a fallback target is ignored unless the effective policy is FALLBACK",
332
+ async t => {
333
+ const definition = g({
334
+ nodes: [
335
+ node("primary", () => { throw new Error("boom"); }, {
336
+ errorPolicy: "CONTINUE",
337
+ fallback: "cached"
338
+ }),
339
+ constNode("cached", "CACHED")
340
+ ],
341
+ entryNodes: ["primary"],
342
+ terminalNodes: ["cached"]
343
+ });
344
+
345
+ const report = await new GraphExecutionEngine(definition).run();
346
+
347
+ if (report.status !== "FAILED") throw new Error(report.status);
348
+ if (report.outputs.cached !== undefined) throw new Error("fallback ran under CONTINUE");
349
+ if (report.errors[0].handled) throw new Error("error must stay unhandled under CONTINUE");
350
+ },
351
+ BIG
352
+ );
353
+
354
+ a45.t(
355
+ "an unknown fallback target is rejected at construction time",
356
+ () => {
357
+ try {
358
+ new GraphExecutionEngine(g({
359
+ nodes: [node("a", () => 1, { fallback: "nowhere" })],
360
+ entryNodes: ["a"],
361
+ terminalNodes: ["a"]
362
+ }));
363
+ throw new Error("expected throw");
364
+ } catch (error) {
365
+ if (!(error instanceof GraphValidationError)) throw error;
366
+ if (!/unknown FALLBACK target "nowhere"/.test(error.message)) throw new Error(error.message);
367
+ }
368
+ }
369
+ );
370
+
371
+ /* ========================= A6 / A10 — results & hygiene ========================= */
372
+
373
+ a6.t(
374
+ "NodeResult.error carries the failure record and matches the report errors",
375
+ async t => {
376
+ const definition = g({
377
+ nodes: [node("broken", () => { throw new Error("attached-error"); })],
378
+ entryNodes: ["broken"],
379
+ terminalNodes: ["broken"]
380
+ });
381
+
382
+ const report = await new GraphExecutionEngine(definition).run();
383
+ const result = report.results[0];
384
+
385
+ if (!result.error) throw new Error("result.error missing");
386
+ if (result.error.id !== report.errors[0].id) throw new Error("record mismatch");
387
+ if (result.error.message !== "attached-error") throw new Error(result.error.message);
388
+ if (result.error.code !== "NODE_EXECUTION_ERROR") throw new Error(result.error.code);
389
+ },
390
+ BIG
391
+ );
392
+
393
+ a6.t(
394
+ "a timed-out node exposes NODE_TIMEOUT on its own result",
395
+ async t => {
396
+ const definition = g({
397
+ nodes: [node("slow", async () => { await sleep(80); return "late"; }, { timeoutMs: 15 })],
398
+ entryNodes: ["slow"],
399
+ terminalNodes: ["slow"]
400
+ });
401
+
402
+ const report = await new GraphExecutionEngine(definition).run();
403
+ const result = report.results[0];
404
+
405
+ if (result.status !== "TIMED_OUT") throw new Error(result.status);
406
+ if (!result.error) throw new Error("result.error missing for TIMED_OUT");
407
+ if (result.error.code !== "NODE_TIMEOUT") throw new Error(result.error.code);
408
+
409
+ await sleep(100);
410
+ },
411
+ BIG
412
+ );
413
+
414
+ a6.t(
415
+ "in-flight activation tracking is empty before and after a run",
416
+ async t => {
417
+ const definition = g({
418
+ nodes: [node("slow", async () => { await sleep(30); return "ok"; })],
419
+ entryNodes: ["slow"],
420
+ terminalNodes: ["slow"]
421
+ });
422
+
423
+ const engine = new GraphExecutionEngine(definition);
424
+ if (engine.getInFlightActivationIds().length !== 0) throw new Error("dirty before run");
425
+
426
+ const running = engine.run();
427
+ await sleep(10);
428
+ if (engine.getInFlightActivationIds().length !== 1) {
429
+ throw new Error(`in-flight=${engine.getInFlightActivationIds().length}`);
430
+ }
431
+
432
+ const report = await running;
433
+ if (report.status !== "COMPLETED") throw new Error(report.status);
434
+ if (engine.getInFlightActivationIds().length !== 0) throw new Error("leaked after settle");
435
+ },
436
+ BIG
437
+ );
438
+
439
+ /* ========================= A8 — concurrency & budget ========================= */
440
+
441
+ function fanOutGraph(width: number, sleepMs: number, tracker: { active: number; max: number }) {
442
+ // A dedicated terminal node keeps FIRST_TERMINAL completion from
443
+ // dropping leaves that were not scheduled yet.
444
+
445
+ const leaves = Array.from({ length: width }, (_, index) =>
446
+ node(`leaf-${index}`, async () => {
447
+ tracker.active += 1;
448
+ tracker.max = Math.max(tracker.max, tracker.active);
449
+ try {
450
+ await sleep(sleepMs);
451
+ return index;
452
+ } finally {
453
+ tracker.active -= 1;
454
+ }
455
+ }, { next: ["sink"] })
456
+ );
457
+
458
+ return g({
459
+ nodes: [
460
+ node("root", () => "R", { next: leaves.map(leaf => leaf.id) }),
461
+ ...leaves,
462
+ constNode("sink", "SINK")
463
+ ],
464
+ entryNodes: ["root"],
465
+ terminalNodes: ["sink"]
466
+ });
467
+ }
468
+
469
+ a8.t(
470
+ "maxConcurrency bounds parallelism and preserves completion",
471
+ async t => {
472
+ const tracker = { active: 0, max: 0 };
473
+ const report = await new GraphExecutionEngine(
474
+ fanOutGraph(8, 5, tracker),
475
+ { maxConcurrency: 2 }
476
+ ).run();
477
+
478
+ if (report.status !== "COMPLETED") throw new Error(report.status);
479
+ if (tracker.max !== 2) throw new Error(`observed concurrency ${tracker.max}, expected 2`);
480
+ const leaves = report.results.filter(result => result.nodeId.startsWith("leaf-"));
481
+ if (leaves.length !== 8) throw new Error(`leaf results=${leaves.length}`);
482
+ if (leaves.some(result => result.status !== "SUCCEEDED")) throw new Error("a leaf failed");
483
+
484
+ const unlimited = { active: 0, max: 0 };
485
+ await new GraphExecutionEngine(fanOutGraph(8, 5, unlimited)).run();
486
+ if (unlimited.max !== 8) throw new Error(`unbounded default regressed: ${unlimited.max}`);
487
+
488
+ t.log("bounded", tracker.max, "| unbounded default", unlimited.max);
489
+ },
490
+ BIG
491
+ );
492
+
493
+ a8.t(
494
+ "maxActivations stops a runaway cycle with STEP_BUDGET_EXCEEDED",
495
+ async t => {
496
+ const definition = g({
497
+ // "exit" is declared but unreachable: the cycle can never terminate
498
+ // on its own, so only the budget can stop it.
499
+ nodes: [
500
+ node("spinner", () => "again", { next: ["spinner2"] }),
501
+ node("spinner2", () => "again", { next: ["spinner"] }),
502
+ constNode("exit", "EXIT")
503
+ ],
504
+ entryNodes: ["spinner"],
505
+ terminalNodes: ["exit"]
506
+ });
507
+
508
+ const startedAt = Date.now();
509
+ const report = await new GraphExecutionEngine(definition, { maxActivations: 6 }).run();
510
+ const durationMs = Date.now() - startedAt;
511
+
512
+ if (durationMs > 5000) throw new Error("budget did not stop the cycle");
513
+ if (report.status !== "FAILED") throw new Error(report.status);
514
+
515
+ const budgetError = report.errors.find(error => error.code === "STEP_BUDGET_EXCEEDED");
516
+ if (!budgetError) throw new Error("STEP_BUDGET_EXCEEDED not recorded");
517
+ if (budgetError.handled) throw new Error("budget exhaustion must be unhandled");
518
+
519
+ if (report.results.length > 6) {
520
+ throw new Error(`ran ${report.results.length} activations despite a budget of 6`);
521
+ }
522
+ if (report.results.length === 0) throw new Error("no activation ran");
523
+
524
+ t.log("activations executed", report.results.length, "| duration", `${durationMs}ms`);
525
+ },
526
+ BIG
527
+ );
528
+
529
+ a8.t(
530
+ "invalid concurrency and budget options are rejected",
531
+ () => {
532
+ const definition = linear(["a"]);
533
+ for (const options of [
534
+ { maxConcurrency: 0 },
535
+ { maxConcurrency: 1.5 },
536
+ { maxActivations: 0 },
537
+ { maxActivations: -3 },
538
+ { lint: "sometimes" as never }
539
+ ]) {
540
+ try {
541
+ new GraphExecutionEngine(definition, options as never);
542
+ throw new Error(`expected throw for ${JSON.stringify(options)}`);
543
+ } catch (error) {
544
+ if (!(error instanceof GraphValidationError)) throw error;
545
+ }
546
+ }
547
+ }
548
+ );
549
+
550
+ /* ========================= A9 — linting ========================= */
551
+
552
+ a9.t(
553
+ "lintGraph reports unreachable nodes, unreachable terminals, dead ends and duplicates",
554
+ () => {
555
+ const definition = g({
556
+ nodes: [
557
+ constNode("entry", "E", { next: ["finish"] }),
558
+ constNode("finish", "F"),
559
+ constNode("orphan", "O"),
560
+ constNode("dead", "D")
561
+ ],
562
+ entryNodes: ["entry", "entry"],
563
+ terminalNodes: ["finish", "ghost-terminal"]
564
+ });
565
+
566
+ // "ghost-terminal" is not part of nodes: lint is standalone, the
567
+ // engine's validateGraph would reject it before linting.
568
+ const issues = lintGraph({
569
+ ...definition,
570
+ nodes: { ...definition.nodes, "ghost-terminal": constNode("ghost-terminal", "G") }
571
+ });
572
+
573
+ const codes = new Set(issues.map(issue => issue.code));
574
+ for (const expected of [
575
+ "DUPLICATE_ENTRY",
576
+ "UNREACHABLE_NODE",
577
+ "DEAD_END_NODE"
578
+ ]) {
579
+ if (!codes.has(expected as never)) throw new Error(`missing ${expected}: ${[...codes].join(",")}`);
580
+ }
581
+
582
+ const orphan = issues.find(issue => issue.nodeId === "orphan");
583
+ if (!orphan || orphan.code !== "UNREACHABLE_NODE") throw new Error("orphan not flagged");
584
+ if (orphan.level !== "warn") throw new Error("unreachable nodes are warnings");
585
+
586
+ const dead = issues.find(
587
+ issue => issue.code === "DEAD_END_NODE" && issue.nodeId === "dead"
588
+ );
589
+ if (!dead || dead.level !== "error") {
590
+ throw new Error(`dead end must be an error; got ${issues.map(i => `${i.code}@${i.nodeId}`).join(",")}`);
591
+ }
592
+ }
593
+ );
594
+
595
+ a9.t(
596
+ "lint: \"warn\" logs issues, lint: \"error\" rejects only on error-level issues",
597
+ async t => {
598
+ const { lines, onLog } = logCollector();
599
+
600
+ const withWarning = g({
601
+ nodes: [
602
+ constNode("entry", "E", { next: ["finish"] }),
603
+ constNode("finish", "F"),
604
+ // reachable-if-wired but nothing points at it: a pure warning
605
+ constNode("orphan", "O", { next: ["finish"] })
606
+ ],
607
+ entryNodes: ["entry"],
608
+ terminalNodes: ["finish"]
609
+ });
610
+
611
+ const warned = await new GraphExecutionEngine(withWarning, {
612
+ lint: "warn",
613
+ onLog
614
+ }).run();
615
+
616
+ if (warned.status !== "COMPLETED") throw new Error(warned.status);
617
+ if (warned.lintIssues.length !== 1) throw new Error(`lintIssues=${warned.lintIssues.length}`);
618
+ if (!lines.some(line => line.message.includes("Graph lint [UNREACHABLE_NODE]"))) {
619
+ throw new Error("lint warning not logged");
620
+ }
621
+
622
+ // warnings alone never reject, even in "error" mode
623
+ const tolerant = await new GraphExecutionEngine(withWarning, { lint: "error" }).run();
624
+ if (tolerant.status !== "COMPLETED") throw new Error(`(warn-only) ${tolerant.status}`);
625
+
626
+ // a dead-end node is an error-level issue
627
+ const withError = g({
628
+ nodes: [
629
+ constNode("entry", "E", { next: ["dead", "finish"] }),
630
+ constNode("dead", "D"),
631
+ constNode("finish", "F")
632
+ ],
633
+ entryNodes: ["entry"],
634
+ terminalNodes: ["finish"]
635
+ });
636
+
637
+ const rejected = await new GraphExecutionEngine(withError, { lint: "error" }).run();
638
+
639
+ if (rejected.status !== "REJECTED") throw new Error(rejected.status);
640
+ if (rejected.results.length !== 0) throw new Error("work ran despite lint failure");
641
+ if (!rejected.lintIssues.some(issue => issue.code === "DEAD_END_NODE")) {
642
+ throw new Error("lint issues missing from the report");
643
+ }
644
+ if (!rejected.errors[0].message.includes("Graph lint reported")) {
645
+ throw new Error(rejected.errors[0].message);
646
+ }
647
+
648
+ t.log("lint issues in the rejected report", rejected.lintIssues.map(issue => issue.code).join(","));
649
+ },
650
+ BIG
651
+ );
652
+
653
+ /* ========================= Isomorphic build proof ========================= */
654
+
655
+ ip.t(
656
+ "the engine core compiles with DOM-only libs and no Node types (browser-safe)",
657
+ async t => {
658
+ const root = path.resolve(__dirname, "..", "..");
659
+ const fs = await import("node:fs");
660
+ const tsc = path.join(
661
+ root,
662
+ "node_modules",
663
+ ".bin",
664
+ process.platform === "win32" ? "tsc.cmd" : "tsc"
665
+ );
666
+
667
+ /* The guard config must keep its teeth: DOM only, no Node types, and
668
+ * the Node-only loader outside the isomorphic surface. */
669
+ const config = fs.readFileSync(path.join(root, "tsconfig.browser.json"), "utf8");
670
+ if (!config.includes('"types": []')) throw new Error("browser config lost its Node-free guard");
671
+ if (!config.includes('"DOM"')) throw new Error("browser config lost the DOM lib");
672
+ if (!config.includes("src/node")) throw new Error("browser config should exclude the Node-only loader");
673
+
674
+ if (fs.existsSync(tsc)) {
675
+ const result = spawnSync(tsc, ["-p", "tsconfig.browser.json"], {
676
+ cwd: root,
677
+ encoding: "utf8",
678
+ timeout: 60_000
679
+ });
680
+
681
+ if (result.error) throw result.error;
682
+ if (result.status !== 0) {
683
+ throw new Error(`browser build failed:\n${result.stdout}\n${result.stderr}`);
684
+ }
685
+ } else {
686
+ /*
687
+ * An installed copy of the published package has no devDependencies,
688
+ * so the compiler proof cannot run. The property is still checked —
689
+ * statically, over the isomorphic core (src/node/** is Node-only by
690
+ * definition and excluded).
691
+ */
692
+ const FORBIDDEN =
693
+ /(?:^|[^\w.$])(require\s*\(|process\.|Buffer\.|__dirname|__filename|setImmediate\s*\()|from\s*["']node:/m;
694
+
695
+ const core = [
696
+ "src/automategraph.ts",
697
+ "src/graph-json.ts",
698
+ "src/storage-adapter.ts",
699
+ "src/storage-conformance.ts"
700
+ ];
701
+
702
+ for (const file of core) {
703
+ const source = fs
704
+ .readFileSync(path.join(root, file), "utf8")
705
+ .replace(/\/\*[\s\S]*?\*\//g, "")
706
+ .replace(/(^|[^:])\/\/.*$/gm, "$1");
707
+
708
+ const match = FORBIDDEN.exec(source);
709
+
710
+ if (match) {
711
+ throw new Error(
712
+ `${file} references Node-only ${JSON.stringify(match[0])} — the isomorphic core must stay free of Node APIs`
713
+ );
714
+ }
715
+ }
716
+ }
717
+
718
+ t.log(
719
+ fs.existsSync(tsc)
720
+ ? "finding: the engine core is isomorphic — no Node APIs, no Buffers, timers via setTimeout only"
721
+ : "note: typescript is not installed in this copy, so the DOM-only tsc proof was replaced by a static scan of the four isomorphic core files (all clean)"
722
+ );
723
+ },
724
+ BIG
725
+ );