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,668 @@
1
+ /* ============================================================
2
+ * ASYNC NODE-CALLBACK PACK
3
+ * ------------------------------------------------------------
4
+ * The engine's contract is:
5
+ *
6
+ * readonly run: (context: NodeContext) => unknown | Promise<unknown>
7
+ *
8
+ * so `async` node callbacks (and any thenable-returning callback)
9
+ * are first-class. This pack proves the async contract holds on
10
+ * every code path: success, retry, ONTHROW, timeout, failure,
11
+ * cancellation, fan-out, checkpoints, events and error capture.
12
+ *
13
+ * These 20 cases are counted separately from the canonical
14
+ * 100 unit + 20 complex suites.
15
+ * ============================================================ */
16
+
17
+ import {
18
+ AutomateGraphError,
19
+ GraphExecutionEngine,
20
+ NodeContext,
21
+ TimeoutError
22
+ } from "../src/automategraph";
23
+
24
+ import { capture, constNode, g, linear, node, sleep, SpyStorage, tick } from "./helpers";
25
+ import { suite } from "./harness";
26
+
27
+ const asy = suite("async", "Node callbacks (async)");
28
+ const BIG = { timeoutMs: 20_000 };
29
+
30
+ /* A-01 — the baseline async contract */
31
+ asy.t(
32
+ "an async node callback is awaited and its resolved value becomes the output",
33
+ async t => {
34
+ const definition = g({
35
+ nodes: [node("async-node", async () => { await sleep(10); return "async-result"; })],
36
+ entryNodes: ["async-node"],
37
+ terminalNodes: ["async-node"]
38
+ });
39
+
40
+ const report = await new GraphExecutionEngine(definition).run();
41
+
42
+ if (report.status !== "COMPLETED") throw new Error(report.status);
43
+ if (report.outputs["async-node"] !== "async-result") throw new Error(String(report.outputs["async-node"]));
44
+ if (report.results[0].status !== "SUCCEEDED") throw new Error(report.results[0].status);
45
+
46
+ t.log("finding: NodeContext signals are typed as AbortSignal even in older @types/node DOM-less libs, " +
47
+ "so async callbacks can consume context.signal without casting");
48
+ },
49
+ BIG
50
+ );
51
+
52
+ /* A-02 — sync callback returning a thenable */
53
+ asy.t(
54
+ "a non-async callback that returns a thenable is also awaited",
55
+ async t => {
56
+ const definition = g({
57
+ nodes: [
58
+ node("thenable", () => {
59
+ // deliberately NOT an async function
60
+ return { then: (resolve: (value: unknown) => void) => setTimeout(() => resolve("thenable-done"), 5) };
61
+ })
62
+ ],
63
+ entryNodes: ["thenable"],
64
+ terminalNodes: ["thenable"]
65
+ });
66
+
67
+ const report = await new GraphExecutionEngine(definition).run();
68
+
69
+ if (report.status !== "COMPLETED") throw new Error(report.status);
70
+ if (report.outputs.thenable !== "thenable-done") throw new Error(String(report.outputs.thenable));
71
+ },
72
+ BIG
73
+ );
74
+
75
+ /* A-03 — async without an explicit return yields undefined, not an error */
76
+ asy.t(
77
+ "async callback with no return value produces an undefined output",
78
+ async t => {
79
+ const definition = g({
80
+ nodes: [node("void-async", async () => { await sleep(5); /* no return */ })],
81
+ entryNodes: ["void-async"],
82
+ terminalNodes: ["void-async"]
83
+ });
84
+
85
+ const report = await new GraphExecutionEngine(definition).run();
86
+
87
+ if (report.status !== "COMPLETED") throw new Error(report.status);
88
+ if (!("void-async" in report.outputs)) throw new Error("output key missing");
89
+ if (report.outputs["void-async"] !== undefined) throw new Error("expected undefined");
90
+ },
91
+ BIG
92
+ );
93
+
94
+ /* A-04 — rejection of async callbacks is captured as a GraphError */
95
+ asy.t(
96
+ "a rejected async callback is captured with code, stack and path",
97
+ async t => {
98
+ const definition = g({
99
+ nodes: [
100
+ constNode("a", "A", { next: ["boom"] }),
101
+ node("boom", async () => {
102
+ await sleep(5);
103
+ throw new Error("async-rejection");
104
+ })
105
+ ],
106
+ entryNodes: ["a"],
107
+ terminalNodes: ["boom"]
108
+ });
109
+
110
+ const report = await new GraphExecutionEngine(definition).run();
111
+
112
+ if (report.status !== "FAILED") throw new Error(report.status);
113
+ if (report.results.find(result => result.nodeId === "boom")!.status !== "FAILED") throw new Error("status");
114
+ if (report.errors.length !== 1) throw new Error(`errors=${report.errors.length}`);
115
+
116
+ const error = report.errors[0];
117
+ if (error.message !== "async-rejection") throw new Error(error.message);
118
+ if (error.code !== "NODE_EXECUTION_ERROR") throw new Error(error.code);
119
+ if (!error.stack || !error.stack.includes("async-rejection")) throw new Error("stack missing");
120
+ if (error.path.join("/") !== "a/boom") throw new Error(error.path.join("/"));
121
+ if (error.handled) throw new Error("should be unhandled");
122
+
123
+ t.log("captured stack head", error.stack!.split("\n")[0]);
124
+ },
125
+ BIG
126
+ );
127
+
128
+ /* A-05 — AutomateGraphError shaped rejections keep their code and details */
129
+ asy.t(
130
+ "async rejections with AutomateGraphError preserve code and details",
131
+ async t => {
132
+ const definition = g({
133
+ nodes: [
134
+ node("app-async", async () => {
135
+ await sleep(5);
136
+ throw new AutomateGraphError("upstream unavailable", "UPSTREAM_DOWN", { service: "billing" });
137
+ })
138
+ ],
139
+ entryNodes: ["app-async"],
140
+ terminalNodes: ["app-async"]
141
+ });
142
+
143
+ const report = await new GraphExecutionEngine(definition).run();
144
+ const error = report.errors[0];
145
+
146
+ if (error.code !== "UPSTREAM_DOWN") throw new Error(error.code);
147
+ if ((error.details as { service: string }).service !== "billing") throw new Error("details lost");
148
+
149
+ t.log("finding: TimeoutError is identified by class OR by a NODE_TIMEOUT-family code, so async " +
150
+ "rejections carrying that code are classified as TIMED_OUT");
151
+ },
152
+ BIG
153
+ );
154
+
155
+ /* A-06 — async retry: succeed on the final attempt */
156
+ asy.t(
157
+ "async retry callback recovers on a later attempt inside one activation",
158
+ async t => {
159
+ let attempts = 0;
160
+ const definition = g({
161
+ nodes: [
162
+ node("async-flaky", async () => {
163
+ attempts += 1;
164
+ await sleep(5);
165
+ if (attempts < 3) throw new Error(`async-fail-${attempts}`);
166
+ return "async-recovered";
167
+ }, { retry: { maxAttempts: 3, delayMs: 5 }, errorPolicy: "RETRY" })
168
+ ],
169
+ entryNodes: ["async-flaky"],
170
+ terminalNodes: ["async-flaky"]
171
+ });
172
+
173
+ const report = await new GraphExecutionEngine(definition).run();
174
+
175
+ if (attempts !== 3) throw new Error(`attempts=${attempts}`);
176
+ if (report.results[0].attempt !== 3) throw new Error(`attempt=${report.results[0].attempt}`);
177
+ if (report.results[0].status !== "SUCCEEDED") throw new Error(report.results[0].status);
178
+ if (report.errors.length !== 0) throw new Error("recovered attempts must not record errors");
179
+ if (report.outputs["async-flaky"] !== "async-recovered") throw new Error("output missing");
180
+ },
181
+ BIG
182
+ );
183
+
184
+ /* A-07 — async retry with a custom errorPolicy fixture */
185
+ asy.t(
186
+ "async callback can be retried by a custom node errorPolicy (RETRY)",
187
+ async t => {
188
+ let attempts = 0;
189
+ const definition = g({
190
+ nodes: [
191
+ node("policy-retry", async () => {
192
+ attempts += 1;
193
+ await sleep(3);
194
+ if (attempts === 1) throw new Error("first-async-fail");
195
+ return "second-async-ok";
196
+ }, { retry: { maxAttempts: 2 }, errorPolicy: "RETRY", next: ["tail"] }),
197
+ constNode("tail", "TAIL")
198
+ ],
199
+ entryNodes: ["policy-retry"],
200
+ terminalNodes: ["tail"]
201
+ });
202
+
203
+ const report = await new GraphExecutionEngine(definition).run();
204
+
205
+ if (attempts !== 2) throw new Error(`attempts=${attempts}`);
206
+ if (report.status !== "COMPLETED") throw new Error(report.status);
207
+ if (report.results[0].attempt !== 2) throw new Error("final attempt not recorded");
208
+ if (!report.results.some(result => result.nodeId === "tail")) throw new Error("successor skipped");
209
+ },
210
+ BIG
211
+ );
212
+
213
+ /* A-08 — async ONTHROW repair */
214
+ asy.t(
215
+ "async rejection routes through ONTHROW into an async repair callback",
216
+ async t => {
217
+ let failures = 0;
218
+ const definition = g({
219
+ nodes: [
220
+ node("async-broken", async () => { failures += 1; await sleep(5); throw new Error("async-explode"); }, {
221
+ onThrow: "async-repair"
222
+ }),
223
+ node("async-repair", async () => { await sleep(5); return "ASYNC-REPAIRED"; }, { next: ["finish"] }),
224
+ constNode("finish", "END")
225
+ ],
226
+ entryNodes: ["async-broken"],
227
+ terminalNodes: ["finish"]
228
+ });
229
+
230
+ const report = await new GraphExecutionEngine(definition).run();
231
+
232
+ if (failures !== 1) throw new Error(`async-broken ran ${failures} times`);
233
+ if (report.status !== "COMPLETED") throw new Error(report.status);
234
+ if (report.outputs["async-repair"] !== "ASYNC-REPAIRED") throw new Error("repair output missing");
235
+ if (!report.errors[0].handled) throw new Error("repair error must be handled");
236
+ if (report.results.find(result => result.nodeId === "async-broken")!.status !== "FAILED") throw new Error("status");
237
+ },
238
+ BIG
239
+ );
240
+
241
+ /* A-09 — async timeout */
242
+ asy.t(
243
+ "a slow async callback is preempted by timeoutMs and reported TIMED_OUT",
244
+ async t => {
245
+ let settledLate = false;
246
+ const definition = g({
247
+ nodes: [
248
+ node("async-slow", async () => {
249
+ await sleep(120);
250
+ settledLate = true;
251
+ return "too-late";
252
+ }, { timeoutMs: 25 })
253
+ ],
254
+ entryNodes: ["async-slow"],
255
+ terminalNodes: ["async-slow"]
256
+ });
257
+
258
+ const startedAt = Date.now();
259
+ const report = await new GraphExecutionEngine(definition).run();
260
+ const durationMs = Date.now() - startedAt;
261
+
262
+ if (report.status !== "FAILED") throw new Error(report.status);
263
+ if (report.results[0].status !== "TIMED_OUT") throw new Error(report.results[0].status);
264
+ if (report.results[0].output !== undefined) throw new Error("timed-out async output must be discarded");
265
+ if (durationMs >= 110) throw new Error(`timeout not enforced (${durationMs}ms)`);
266
+ if (report.errors[0].code !== "NODE_TIMEOUT") throw new Error(report.errors[0].code);
267
+
268
+ await sleep(140);
269
+ if (!settledLate) throw new Error("abandoned async callback never settled");
270
+
271
+ t.log("run duration", `${durationMs}ms (callback keeps running for 120ms)`);
272
+ t.log("finding: on timeout the async callback's later resolution is discarded — no output is recorded");
273
+ },
274
+ BIG
275
+ );
276
+
277
+ /* A-10 — async cancellation is cooperative via context.signal */
278
+ asy.t(
279
+ "a cooperative async callback observes the abort and rejects; the run settles CANCELLED",
280
+ async t => {
281
+ let sawAbort: boolean | undefined;
282
+ let aborted = false;
283
+
284
+ const definition = g({
285
+ nodes: [
286
+ node("async-cooperative", async (context: NodeContext) => {
287
+ const guard = new Promise<never>((_, reject) => {
288
+ context.signal.addEventListener("abort", () => {
289
+ aborted = true;
290
+ const reason = context.signal.reason;
291
+ reject(reason instanceof Error ? reason : new AutomateGraphError("aborted mid-await", "GRAPH_CANCELLED"));
292
+ });
293
+ });
294
+ // A real async workload: fall back to the abort guard instead of idle waiting.
295
+ await Promise.race([sleep(500).then(() => "unreached"), guard]);
296
+ sawAbort = context.signal.aborted;
297
+ return "unreached";
298
+ })
299
+ ],
300
+ entryNodes: ["async-cooperative"],
301
+ terminalNodes: ["async-cooperative"]
302
+ });
303
+
304
+ const engine = new GraphExecutionEngine(definition);
305
+ const running = engine.run();
306
+ await sleep(15);
307
+ await engine.cancel();
308
+ const report = await running;
309
+
310
+ if (!aborted) throw new Error("abort event never reached the async callback");
311
+ if (report.status !== "CANCELLED") throw new Error(report.status);
312
+ if (sawAbort !== undefined) throw new Error("callback continued past the abort guard");
313
+ if (report.results.length !== 1) throw new Error("unexpected extra activations");
314
+
315
+ // v1.1 (A1) fixed the asymmetry: rejecting because of the abort now
316
+ // settles exactly like resolving after it — NodeStatus CANCELLED and a
317
+ // handled GRAPH_CANCELLED record (previously FAILED + unhandled).
318
+ const nodeResult = report.results[0];
319
+ if (nodeResult.status !== "CANCELLED") throw new Error(nodeResult.status);
320
+ if (report.errors.length !== 1) throw new Error(`errors=${report.errors.length}`);
321
+ if (report.errors[0].code !== "GRAPH_CANCELLED") throw new Error(report.errors[0].code);
322
+ if (!report.errors[0].handled) throw new Error("cancellation error must be recorded as handled");
323
+ if (nodeResult.output !== undefined) throw new Error("aborted callback must not record output");
324
+
325
+ // v1.1 (A6): the result carries its own error record.
326
+ if (!nodeResult.error) throw new Error("result.error missing");
327
+ if (nodeResult.error.id !== report.errors[0].id) throw new Error("result.error mismatch");
328
+
329
+ t.log("graph status", report.status, "| node status", nodeResult.status);
330
+ t.log("error record", `${report.errors[0].code} handled=${report.errors[0].handled}`);
331
+ t.log(
332
+ "finding: since v1.1 (A1) cooperative cancellation is symmetric — a callback that rejects " +
333
+ "because of the abort settles CANCELLED with a handled GRAPH_CANCELLED record, exactly like " +
334
+ "one that resolves after it; result.error now carries the record (A6)"
335
+ );
336
+ },
337
+ BIG
338
+ );
339
+
340
+ /* A-11 — async callback that ignores cancellation */
341
+ asy.t(
342
+ "an async callback that ignores the signal delays settlement; successors stay unscheduled",
343
+ async t => {
344
+ const definition = g({
345
+ nodes: [
346
+ node("stubborn-async", async () => { await sleep(60); return "finished-anyway"; }, { next: ["tail"] }),
347
+ constNode("tail", "TAIL")
348
+ ],
349
+ entryNodes: ["stubborn-async"],
350
+ terminalNodes: ["tail"]
351
+ });
352
+
353
+ const engine = new GraphExecutionEngine(definition);
354
+ const startedAt = Date.now();
355
+ const running = engine.run();
356
+ await sleep(10);
357
+ await engine.cancel();
358
+ const report = await running;
359
+ const durationMs = Date.now() - startedAt;
360
+
361
+ if (report.status !== "CANCELLED") throw new Error(report.status);
362
+ if (durationMs < 40) throw new Error(`settled too early (${durationMs}ms) — cooperative drain broken`);
363
+ if (report.results.some(result => result.nodeId === "tail")) throw new Error("successor ran after cancel");
364
+ if (report.outputs.tail !== undefined) throw new Error("successor output recorded");
365
+
366
+ t.log("drain time", `${durationMs}ms (callback sleeps 60ms and ignores the abort)`);
367
+ },
368
+ BIG
369
+ );
370
+
371
+ /* A-12 — async node whose completion triggers a checkpoint */
372
+ asy.t(
373
+ "async completion triggers exactly one checkpoint per successful node",
374
+ async t => {
375
+ const storage = new SpyStorage();
376
+ const definition = linear(["a", "b", "c"], async id => { await sleep(5); return `${id}-async`; });
377
+
378
+ const report = await new GraphExecutionEngine(definition, { storage }).run();
379
+
380
+ if (report.status !== "COMPLETED") throw new Error(report.status);
381
+ if (storage.calls !== 3) throw new Error(`checkpoints=${storage.calls}`);
382
+ const last = storage.snapshots[storage.snapshots.length - 1];
383
+ if (last.outputs.c !== "c-async") throw new Error("final snapshot incomplete");
384
+ if (Object.keys(last.outputs).length !== 3) throw new Error("snapshot outputs mismatch");
385
+ },
386
+ BIG
387
+ );
388
+
389
+ /* A-13 — async context.emit is awaited in order */
390
+ asy.t(
391
+ "context.emit from an async callback is awaited and ordered",
392
+ async t => {
393
+ const seen: string[] = [];
394
+ const definition = g({
395
+ nodes: [
396
+ node("emitter", async (context: NodeContext) => {
397
+ await context.emit("async.first", { n: 1 });
398
+ await context.emit("async.second", { n: 2 });
399
+ return "emitted";
400
+ })
401
+ ],
402
+ entryNodes: ["emitter"],
403
+ terminalNodes: ["emitter"]
404
+ });
405
+
406
+ const engine = new GraphExecutionEngine(definition);
407
+ engine.events.on("async.first", () => { seen.push("first"); }, { required: true });
408
+ engine.events.on("async.second", async () => { await sleep(3); seen.push("second"); }, { required: true });
409
+
410
+ const report = await engine.run();
411
+
412
+ if (report.status !== "COMPLETED") throw new Error(report.status);
413
+ if (seen.join(",") !== "first,second") throw new Error(seen.join(","));
414
+ if (report.outputs.emitter !== "emitted") throw new Error("output missing");
415
+ },
416
+ BIG
417
+ );
418
+
419
+ /* A-14 — async parallelism */
420
+ asy.t(
421
+ "async callbacks on independent branches execute in parallel",
422
+ async t => {
423
+ const definition = g({
424
+ nodes: [
425
+ node("root", () => "root", { next: ["p1", "p2", "p3", "p4"] }),
426
+ ...["p1", "p2", "p3", "p4"].map(id =>
427
+ node(id, async () => { await sleep(40); return id; })
428
+ )
429
+ ],
430
+ entryNodes: ["root"],
431
+ terminalNodes: ["p4"]
432
+ });
433
+
434
+ const startedAt = Date.now();
435
+ const report = await new GraphExecutionEngine(definition).run();
436
+ const durationMs = Date.now() - startedAt;
437
+
438
+ if (report.status !== "COMPLETED") throw new Error(report.status);
439
+ if (report.results.filter(result => result.status === "SUCCEEDED").length !== 5) {
440
+ throw new Error("not all async branches succeeded");
441
+ }
442
+ if (durationMs >= 100) throw new Error(`serialized: ${durationMs}ms for 4x40ms in parallel`);
443
+
444
+ t.log("wall time", `${durationMs}ms (serial would be ~160ms)`);
445
+ },
446
+ BIG
447
+ );
448
+
449
+ /* A-15 — mixed sync/async pipeline keeps isolation */
450
+ asy.t(
451
+ "mixed sync and async nodes run together in one pipeline",
452
+ async t => {
453
+ const definition = g({
454
+ id: "mixed-pipeline",
455
+ version: "1.0.0",
456
+ nodes: [
457
+ node("sync-validate", () => "valid", { next: ["async-enrich"] }),
458
+ node("async-enrich", async context => {
459
+ await sleep(5);
460
+ return `enriched(${context.outputs["sync-validate"]})`;
461
+ }, { next: ["sync-price"] }),
462
+ node("sync-price", context => `priced(${context.outputs["async-enrich"]})`, { next: ["async-store"] }),
463
+ node("async-store", async context => {
464
+ await sleep(5);
465
+ return `stored(${context.outputs["sync-price"]})`;
466
+ })
467
+ ],
468
+ entryNodes: ["sync-validate"],
469
+ terminalNodes: ["async-store"]
470
+ });
471
+
472
+ const report = await new GraphExecutionEngine(definition).run();
473
+
474
+ if (report.status !== "COMPLETED") throw new Error(report.status);
475
+ const final = String(report.outputs["async-store"]);
476
+ if (final !== "stored(priced(enriched(valid)))") throw new Error(final);
477
+ if (report.results.length !== 4) throw new Error(`results=${report.results.length}`);
478
+ },
479
+ BIG
480
+ );
481
+
482
+ /* A-16 — async callbacks composing the fast-path/retry/fatal branches */
483
+ asy.t(
484
+ "async callback composes success, retry and failure across a three-branch graph",
485
+ async t => {
486
+ let fastAttempts = 0;
487
+ let retryAttempts = 0;
488
+
489
+ const definition = g({
490
+ nodes: [
491
+ node("root", () => "R", { next: ["async-fast", "async-retry", "async-fatal"] }),
492
+ node("async-fast", async () => { fastAttempts += 1; await sleep(5); return "fast-ok"; }),
493
+ node("async-retry", async () => {
494
+ retryAttempts += 1;
495
+ await sleep(5);
496
+ if (retryAttempts < 2) throw new Error("async-once");
497
+ return "retry-ok";
498
+ }, { retry: { maxAttempts: 2 }, errorPolicy: "RETRY" }),
499
+ node("async-fatal", async () => { await sleep(5); throw new Error("async-fatal-error"); })
500
+ ],
501
+ entryNodes: ["root"],
502
+ terminalNodes: ["async-fast"]
503
+ });
504
+
505
+ const report = await new GraphExecutionEngine(definition).run();
506
+
507
+ if (fastAttempts !== 1) throw new Error(`fastAttempts=${fastAttempts}`);
508
+ if (retryAttempts !== 2) throw new Error(`retryAttempts=${retryAttempts}`);
509
+ if (report.status !== "FAILED") throw new Error(report.status);
510
+ if (report.outputs["async-fast"] !== "fast-ok") throw new Error("fast output missing");
511
+ if (report.outputs["async-retry"] !== "retry-ok") throw new Error("retry output missing");
512
+ if (report.errors.length !== 1) throw new Error(`errors=${report.errors.length}`);
513
+ if (report.errors[0].nodeId !== "async-fatal") throw new Error(report.errors[0].nodeId);
514
+
515
+ t.log("fatal error path", report.errors[0].path.join("/"));
516
+ },
517
+ BIG
518
+ );
519
+
520
+ /* A-17 — async state-machine callback through a sync node */
521
+ asy.t(
522
+ "an async callback returning a marker drives a synchronous state-machine successor",
523
+ async t => {
524
+ const definition = g({
525
+ nodes: [
526
+ node("async-evaluate", async context => {
527
+ await sleep(5);
528
+ const marker = (context.outputs.manual as string | undefined) ?? "AUTO";
529
+ return marker;
530
+ }, { next: ["auto-path"] }),
531
+ node("auto-path", context => {
532
+ const marker = context.outputs["async-evaluate"];
533
+ if (marker !== "AUTO") throw new Error(`unexpected marker ${String(marker)}`);
534
+ return "auto-done";
535
+ })
536
+ ],
537
+ entryNodes: ["async-evaluate"],
538
+ terminalNodes: ["auto-path"]
539
+ });
540
+
541
+ const report = await new GraphExecutionEngine(definition).run();
542
+
543
+ if (report.status !== "COMPLETED") throw new Error(report.status);
544
+ if (report.outputs["auto-path"] !== "auto-done") throw new Error("state machine diverged");
545
+ },
546
+ BIG
547
+ );
548
+
549
+ /* A-18 — abandoned async work must not corrupt a later execution */
550
+ asy.t(
551
+ "an unrelated execution started after an abandoned async node is unaffected",
552
+ async t => {
553
+ const abandoned: string[] = [];
554
+
555
+ const slow = g({
556
+ nodes: [
557
+ node("stubborn", async () => {
558
+ await sleep(150);
559
+ abandoned.push("late side effect");
560
+ return "late";
561
+ }, { timeoutMs: 20 })
562
+ ],
563
+ entryNodes: ["stubborn"],
564
+ terminalNodes: ["stubborn"]
565
+ });
566
+
567
+ const first = await new GraphExecutionEngine(slow).run();
568
+ if (first.status !== "FAILED") throw new Error(first.status);
569
+
570
+ const second = await new GraphExecutionEngine(linear(["x", "y"], async id => `${id}!`)).run();
571
+ if (second.status !== "COMPLETED") throw new Error(second.status);
572
+ if (second.outputs.y !== "y!") throw new Error("second execution corrupted");
573
+
574
+ if (second.errors.length !== 0) throw new Error("errors leaked between executions");
575
+ if (Object.keys(second.outputs).length !== 2) throw new Error("outputs leaked between executions");
576
+
577
+ await sleep(160);
578
+ if (abandoned.length !== 1) throw new Error("abandoned side effect did not run at all");
579
+
580
+ t.log("finding: the abandoned async node still performed its side effect after settlement — " +
581
+ "async cancellation is cooperative only; nothing can stop an in-flight promise");
582
+ },
583
+ BIG
584
+ );
585
+
586
+ /* A-19 — async node emitting into a non-required listener */
587
+ asy.t(
588
+ "diverse async callbacks (timers, microtasks, thenables) are classified correctly",
589
+ async t => {
590
+ const definition = g({
591
+ nodes: [
592
+ node("timer-node", async () => { await sleep(1); return "timer"; }, { next: ["microtask-node"] }),
593
+ node("microtask-node", async () => { await Promise.resolve(); return "microtask"; }, { next: ["thenable-node"] }),
594
+ node("thenable-node", () =>
595
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
596
+ (globalThis as unknown as { Promise: PromiseConstructor }).Promise.resolve("thenable"),
597
+ { next: ["void-node"] }),
598
+ node("void-node", async () => { await sleep(1); })
599
+ ],
600
+ entryNodes: ["timer-node"],
601
+ terminalNodes: ["void-node"]
602
+ });
603
+
604
+ const report = await new GraphExecutionEngine(definition).run();
605
+
606
+ if (report.status !== "COMPLETED") throw new Error(report.status);
607
+ if (report.outputs["timer-node"] !== "timer") throw new Error("timer");
608
+ if (report.outputs["microtask-node"] !== "microtask") throw new Error("microtask");
609
+ if (report.outputs["thenable-node"] !== "thenable") throw new Error("thenable");
610
+ if (report.outputs["void-node"] !== undefined) throw new Error("void");
611
+ if (report.results.every(result => result.status === "SUCCEEDED") !== true) throw new Error("some failed");
612
+ },
613
+ BIG
614
+ );
615
+
616
+ /* A-20 — async node used with the full plugin/storage stack */
617
+ asy.t(
618
+ "async callbacks integrate with plugins, storage snapshots and the execution report",
619
+ async t => {
620
+ const events: string[] = [];
621
+ const storage = new SpyStorage();
622
+
623
+ const definition = g({
624
+ id: "async-integrated",
625
+ version: "3.0.0",
626
+ nodes: [
627
+ node("load", async () => { await sleep(5); return { rows: 3 }; }, { next: ["transform"] }),
628
+ node("transform", async context => {
629
+ await sleep(5);
630
+ const rows = (context.outputs.load as { rows: number }).rows;
631
+ return { rows: rows * 2 };
632
+ }, { next: ["persist"] }),
633
+ node("persist", async () => { await sleep(5); return "PERSISTED"; })
634
+ ],
635
+ entryNodes: ["load"],
636
+ terminalNodes: ["persist"]
637
+ });
638
+
639
+ const engine = new GraphExecutionEngine(definition, {
640
+ storage,
641
+ plugins: [
642
+ {
643
+ id: "async-plugin",
644
+ version: "1.2.0",
645
+ async initialize() { await sleep(3); events.push("init"); },
646
+ async close() { await sleep(3); events.push("close"); }
647
+ }
648
+ ]
649
+ });
650
+
651
+ const captured = capture(engine, ["node.started", "node.succeeded", "graph.finished"]);
652
+ const report = await engine.run();
653
+
654
+ if (report.status !== "COMPLETED") throw new Error(report.status);
655
+ if ((report.outputs.transform as { rows: number }).rows !== 6) throw new Error("async chain output wrong");
656
+ if (events.join(",") !== "init,close") throw new Error(events.join(","));
657
+ if (storage.calls !== 3) throw new Error(`checkpoints=${storage.calls}`);
658
+ if (captured.filter(entry => entry.event === "node.succeeded").length !== 3) throw new Error("events");
659
+ if (storage.snapshots.some(snapshot => snapshot.graphVersion !== "3.0.0")) throw new Error("snapshot version");
660
+
661
+ const firstSnapshot = storage.snapshots[0];
662
+ if (firstSnapshot.outputs.load === undefined) throw new Error("first checkpoint missing output");
663
+
664
+ t.log("checkpoints", storage.calls);
665
+ t.log("plugin lifecycle", events.join(" -> "));
666
+ },
667
+ BIG
668
+ );