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