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,442 @@
1
+ /* ============================================================
2
+ * STORAGE ADAPTER CONFORMANCE PACK
3
+ * ------------------------------------------------------------
4
+ * The adapter contract (src/storage-adapter.ts) is only worth
5
+ * something if it is enforced. These tests do three things:
6
+ *
7
+ * 1. prove the reference adapter (InMemoryStorage) passes
8
+ * 2. prove the checker itself has teeth — a deliberately
9
+ * broken adapter must produce specific violations
10
+ * 3. cover the wire format helpers (serialize/deserialize,
11
+ * stable output, corruption detection, comparison)
12
+ * ============================================================ */
13
+
14
+ import { InMemoryStorage, ExecutionSnapshot } from "../src/automategraph";
15
+
16
+ import {
17
+ StorageAdapter,
18
+ compareSnapshots,
19
+ defineStorageAdapter,
20
+ deserializeSnapshot,
21
+ serializeSnapshot,
22
+ snapshotShapeViolations,
23
+ storageAdapterViolations
24
+ } from "../src/storage-adapter";
25
+
26
+ import {
27
+ ConformanceReport,
28
+ StorageConformanceFixture,
29
+ conformanceSnapshot,
30
+ runStorageConformance
31
+ } from "../src/storage-conformance";
32
+
33
+ import { suite } from "./harness";
34
+
35
+ const conf = suite("conformance", "Storage adapter contract");
36
+ const wire = suite("conformance", "Snapshot wire format");
37
+
38
+ const BIG = { timeoutMs: 20_000 };
39
+
40
+ /* ------------------------- fixtures ------------------------- */
41
+
42
+ /** Reference adapter: the in-memory implementation shipped with the engine. */
43
+ const memoryFixture: StorageConformanceFixture = {
44
+ name: "InMemoryStorage",
45
+ create: () => new InMemoryStorage()
46
+ };
47
+
48
+ /** Same adapter, but able to inject a failing save (atomicity checks). */
49
+ class FaultInjectingAdapter implements StorageAdapter {
50
+ readonly id = "test.fault-injecting";
51
+ readonly version = "1.0.0";
52
+ readonly capabilities = {
53
+ atomic: true,
54
+ durable: false,
55
+ list: true,
56
+ delete: true,
57
+ concurrentExecutions: true
58
+ };
59
+
60
+ private readonly inner = new InMemoryStorage();
61
+ private failNext = false;
62
+
63
+ failNextSave(): void {
64
+ this.failNext = true;
65
+ }
66
+
67
+ async saveAtomic(snapshot: ExecutionSnapshot): Promise<void> {
68
+ if (this.failNext) {
69
+ this.failNext = false;
70
+ // Correct behaviour: reject WITHOUT touching the stored snapshot.
71
+ throw new Error("injected write failure");
72
+ }
73
+ return this.inner.saveAtomic(snapshot);
74
+ }
75
+
76
+ load(executionId: string) {
77
+ return this.inner.load(executionId);
78
+ }
79
+
80
+ listExecutionIds(): Promise<string[]> {
81
+ return this.inner.listExecutionIds();
82
+ }
83
+
84
+ delete(executionId: string): Promise<boolean> {
85
+ return this.inner.delete(executionId);
86
+ }
87
+ }
88
+
89
+ const faultFixture: StorageConformanceFixture = {
90
+ name: "FaultInjectingAdapter",
91
+ create: () => new FaultInjectingAdapter(),
92
+ failNextSave: adapter => (adapter as FaultInjectingAdapter).failNextSave()
93
+ };
94
+
95
+ /**
96
+ * Deliberately broken adapter: aliases snapshots on save AND load,
97
+ * ignores deletes and returns an empty list. The checker must catch all
98
+ * of it — otherwise "conformance" means nothing.
99
+ */
100
+ class BrokenAdapter implements StorageAdapter {
101
+ readonly id = "test.broken";
102
+ readonly version = "0.0.1";
103
+ readonly capabilities = { list: true, delete: true };
104
+
105
+ private readonly store = new Map<string, ExecutionSnapshot>();
106
+
107
+ async saveAtomic(snapshot: ExecutionSnapshot): Promise<void> {
108
+ this.store.set(snapshot.executionId, snapshot); // no copy: aliasing bug
109
+ }
110
+
111
+ async load(executionId: string): Promise<ExecutionSnapshot | null> {
112
+ return this.store.get(executionId) ?? null; // no copy: aliasing bug
113
+ }
114
+
115
+ async listExecutionIds(): Promise<string[]> {
116
+ return []; // broken
117
+ }
118
+
119
+ async delete(): Promise<boolean> {
120
+ return false; // broken
121
+ }
122
+ }
123
+
124
+ /** Commits first, then fails — a torn write. */
125
+ class TornSaveAdapter implements StorageAdapter {
126
+ readonly id = "test.torn-save";
127
+ readonly version = "0.0.1";
128
+ readonly capabilities = { atomic: false, list: true, delete: true };
129
+
130
+ private readonly inner = new InMemoryStorage();
131
+ private failNext = false;
132
+
133
+ failNextSave(): void {
134
+ this.failNext = true;
135
+ }
136
+
137
+ async saveAtomic(snapshot: ExecutionSnapshot): Promise<void> {
138
+ await this.inner.saveAtomic(snapshot); // committed too early
139
+ if (this.failNext) {
140
+ this.failNext = false;
141
+ throw new Error("failure after commit");
142
+ }
143
+ }
144
+
145
+ load(executionId: string) {
146
+ return this.inner.load(executionId);
147
+ }
148
+
149
+ listExecutionIds() {
150
+ return this.inner.listExecutionIds();
151
+ }
152
+
153
+ delete(executionId: string) {
154
+ return this.inner.delete(executionId);
155
+ }
156
+ }
157
+
158
+ const tornFixture: StorageConformanceFixture = {
159
+ name: "TornSaveAdapter",
160
+ create: () => new TornSaveAdapter(),
161
+ failNextSave: adapter => (adapter as TornSaveAdapter).failNextSave()
162
+ };
163
+
164
+ const checks = (report: ConformanceReport, code: string): boolean =>
165
+ report.violations.some(violation => violation.code === code);
166
+
167
+ /* ------------------------- tests ------------------------- */
168
+
169
+ conf.t(
170
+ "InMemoryStorage passes the full conformance suite with zero violations",
171
+ async t => {
172
+ const report = await runStorageConformance(memoryFixture);
173
+
174
+ if (!report.passed) {
175
+ throw new Error(
176
+ `violations: ${report.violations.map(v => `${v.code}: ${v.message}`).join(" | ")}`
177
+ );
178
+ }
179
+ if (report.adapterId !== "automategraph.memory-storage") throw new Error(report.adapterId);
180
+ if (report.adapterVersion !== "1.1.0") throw new Error(report.adapterVersion);
181
+ if (report.checks < 10) throw new Error(`checks=${report.checks}`);
182
+ if (report.violations.length !== 0) throw new Error("expected zero violations");
183
+
184
+ // the memory adapter cannot inject faults, so atomicity is reported as
185
+ // skipped rather than silently assumed to hold
186
+ if (!report.skipped.some(skip => skip.startsWith("FAILED_SAVE_PRESERVES_PREVIOUS"))) {
187
+ throw new Error("atomicity should be skipped when no fault injection exists");
188
+ }
189
+
190
+ t.log("checks executed", report.checks);
191
+ t.log("skipped", report.skipped.length, report.skipped);
192
+ },
193
+ BIG
194
+ );
195
+
196
+ conf.t(
197
+ "an adapter with fault injection proves atomicity instead of skipping it",
198
+ async t => {
199
+ const report = await runStorageConformance(faultFixture);
200
+
201
+ if (!report.passed) {
202
+ throw new Error(report.violations.map(v => v.message).join(" | "));
203
+ }
204
+ if (report.skipped.some(skip => skip.startsWith("FAILED_SAVE_PRESERVES_PREVIOUS"))) {
205
+ throw new Error("atomicity check was skipped despite failNextSave()");
206
+ }
207
+
208
+ t.log("checks executed", report.checks);
209
+ t.log("skipped", report.skipped);
210
+ },
211
+ BIG
212
+ );
213
+
214
+ conf.t(
215
+ "the checker catches aliasing, broken listing and broken deletion",
216
+ async t => {
217
+ const report = await runStorageConformance({
218
+ name: "BrokenAdapter",
219
+ create: () => new BrokenAdapter()
220
+ });
221
+
222
+ if (report.passed) throw new Error("the broken adapter was wrongly accepted");
223
+
224
+ for (const code of [
225
+ "NO_ALIASING_ON_SAVE",
226
+ "NO_ALIASING_ON_LOAD",
227
+ "LIST",
228
+ "DELETE_IDEMPOTENT"
229
+ ]) {
230
+ if (!checks(report, code)) {
231
+ throw new Error(
232
+ `missing ${code}; got ${report.violations.map(v => v.code).join(",")}`
233
+ );
234
+ }
235
+ }
236
+
237
+ t.log("violations detected", report.violations.map(v => v.code).join(", "));
238
+ },
239
+ BIG
240
+ );
241
+
242
+ conf.t(
243
+ "the checker catches a torn write that commits before failing",
244
+ async t => {
245
+ const report = await runStorageConformance(tornFixture);
246
+
247
+ if (report.passed) throw new Error("torn writes were wrongly accepted");
248
+ if (!checks(report, "FAILED_SAVE_PRESERVES_PREVIOUS")) {
249
+ throw new Error(`violations=${report.violations.map(v => v.code).join(",")}`);
250
+ }
251
+
252
+ t.log("finding: the conformance suite distinguishes 'rejects' from 'rejects without " +
253
+ "corrupting state' — adapters must checkpoint before commit, not after");
254
+ },
255
+ BIG
256
+ );
257
+
258
+ conf.t(
259
+ "missing required members are reported as SHAPE violations",
260
+ async t => {
261
+ const incomplete = { id: "x", version: "1" } as unknown as StorageAdapter;
262
+ const violations = storageAdapterViolations(incomplete);
263
+
264
+ if (!violations.some(violation => violation.includes("saveAtomic"))) throw new Error("saveAtomic");
265
+ if (!violations.some(violation => violation.includes("load"))) throw new Error("load");
266
+
267
+ const report = await runStorageConformance({
268
+ name: "Incomplete",
269
+ create: () => incomplete
270
+ });
271
+
272
+ if (report.passed) throw new Error("incomplete adapter accepted");
273
+ if (!checks(report, "SHAPE")) throw new Error("SHAPE not reported");
274
+ },
275
+ BIG
276
+ );
277
+
278
+ conf.t(
279
+ "an adapter that claims a capability it does not implement is rejected (defineStorageAdapter)",
280
+ () => {
281
+ const liar = {
282
+ id: "test.liar",
283
+ version: "1.0.0",
284
+ capabilities: { list: true },
285
+ saveAtomic: async () => undefined,
286
+ load: async () => null
287
+ };
288
+
289
+ try {
290
+ defineStorageAdapter(liar as unknown as StorageAdapter);
291
+ throw new Error("expected throw");
292
+ } catch (error) {
293
+ if (!(error as { code?: string }).code) throw error;
294
+ if ((error as { code: string }).code !== "STORAGE_ADAPTER_INVALID") {
295
+ throw new Error((error as { code: string }).code);
296
+ }
297
+ }
298
+
299
+ // and a consistent adapter is accepted, identity-preserved
300
+ const storage = new InMemoryStorage();
301
+ if (defineStorageAdapter(storage) !== storage) throw new Error("identity broken");
302
+ }
303
+ );
304
+
305
+ conf.t(
306
+ "the engine accepts any conformant adapter as its storage plugin",
307
+ async t => {
308
+ const { GraphExecutionEngine } = await import("../src/automategraph");
309
+ const { g, node: build } = await import("./helpers");
310
+
311
+ const adapter = new FaultInjectingAdapter();
312
+ const definition = g({
313
+ nodes: [build("a", () => "A", { next: ["b"] }), build("b", () => "B")],
314
+ entryNodes: ["a"],
315
+ terminalNodes: ["b"]
316
+ });
317
+
318
+ const report = await new GraphExecutionEngine(definition, { storage: adapter }).run();
319
+
320
+ if (report.status !== "COMPLETED") throw new Error(report.status);
321
+
322
+ const stored = await adapter.load(report.executionId);
323
+ if (!stored) throw new Error("execution was not persisted");
324
+ if (stored.graphId !== definition.id) throw new Error("stored snapshot mismatch");
325
+ if (stored.pluginVersions && Object.keys(stored.pluginVersions).length !== 0) {
326
+ throw new Error("unexpected plugin versions");
327
+ }
328
+
329
+ t.log("snapshots stored", (await adapter.listExecutionIds()).length);
330
+ },
331
+ BIG
332
+ );
333
+
334
+ /* ------------------------- wire format ------------------------- */
335
+
336
+ wire.t(
337
+ "serializeSnapshot is deterministic and round-trips through JSON",
338
+ () => {
339
+ const snapshot = conformanceSnapshot("wire-1");
340
+ const first = serializeSnapshot(snapshot);
341
+ const second = serializeSnapshot(conformanceSnapshot("wire-1"));
342
+
343
+ if (first !== second) throw new Error("serialization is not deterministic");
344
+
345
+ const revived = deserializeSnapshot(first);
346
+ const differences = compareSnapshots(snapshot, revived);
347
+ if (differences.length > 0) throw new Error(differences.join(","));
348
+
349
+ // key order of the source object must not matter
350
+ const shuffled: ExecutionSnapshot = {
351
+ timestamp: snapshot.timestamp,
352
+ pluginVersions: snapshot.pluginVersions,
353
+ errors: snapshot.errors,
354
+ results: snapshot.results,
355
+ outputs: snapshot.outputs,
356
+ status: snapshot.status,
357
+ graphVersion: snapshot.graphVersion,
358
+ graphId: snapshot.graphId,
359
+ executionId: snapshot.executionId
360
+ };
361
+ if (serializeSnapshot(shuffled) !== first) throw new Error("key order changed the bytes");
362
+ }
363
+ );
364
+
365
+ wire.t(
366
+ "corrupt or unsupported snapshots are rejected with typed errors",
367
+ () => {
368
+ for (const [payload, expectedCode] of [
369
+ ["{not json", "STORAGE_CORRUPT_SNAPSHOT"],
370
+ [JSON.stringify({ hello: "world" }), "STORAGE_CORRUPT_SNAPSHOT"],
371
+ [
372
+ JSON.stringify({ format: "automategraph.snapshot", formatVersion: 99, snapshot: {} }),
373
+ "STORAGE_FORMAT_UNSUPPORTED"
374
+ ],
375
+ [
376
+ JSON.stringify({
377
+ format: "automategraph.snapshot",
378
+ formatVersion: 1,
379
+ snapshot: { executionId: "e" }
380
+ }),
381
+ "STORAGE_CORRUPT_SNAPSHOT"
382
+ ]
383
+ ] as Array<[string, string]>) {
384
+ try {
385
+ deserializeSnapshot(payload);
386
+ throw new Error(`expected throw for ${payload.slice(0, 40)}`);
387
+ } catch (error) {
388
+ const code = (error as { code?: string }).code;
389
+ if (code !== expectedCode) throw new Error(`got ${String(code)}, expected ${expectedCode}`);
390
+ }
391
+ }
392
+ }
393
+ );
394
+
395
+ wire.t(
396
+ "snapshotShapeViolations pinpoints every malformed field",
397
+ () => {
398
+ const violations = snapshotShapeViolations({
399
+ executionId: "e",
400
+ graphId: "",
401
+ graphVersion: "1",
402
+ status: "NOT_A_STATUS",
403
+ outputs: null,
404
+ results: [{ nodeId: "a" }],
405
+ errors: [{}],
406
+ pluginVersions: {},
407
+ timestamp: "2026-01-01T00:00:00.000Z"
408
+ });
409
+
410
+ const joined = violations.join(" | ");
411
+ for (const expected of [
412
+ "graphId",
413
+ "status must be a GraphStatus",
414
+ "outputs must be an object",
415
+ "results[0].activationId",
416
+ "results[0].attempt",
417
+ "errors[0]"
418
+ ]) {
419
+ if (!joined.includes(expected)) throw new Error(`missing "${expected}" in: ${joined}`);
420
+ }
421
+ }
422
+ );
423
+
424
+ wire.t(
425
+ "compareSnapshots reports structural differences precisely",
426
+ () => {
427
+ const base = conformanceSnapshot("wire-compare");
428
+ if (compareSnapshots(base, conformanceSnapshot("wire-compare")).length !== 0) {
429
+ throw new Error("identical snapshots reported as different");
430
+ }
431
+
432
+ const changed: ExecutionSnapshot = {
433
+ ...base,
434
+ status: "COMPLETED",
435
+ outputs: { a: { n: 2 } }
436
+ };
437
+ const differences = compareSnapshots(base, changed);
438
+
439
+ if (!differences.includes("status differs")) throw new Error(differences.join(","));
440
+ if (!differences.includes("outputs differs")) throw new Error(differences.join(","));
441
+ }
442
+ );