automategraph 0.0.0-stage → 1.5.0

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.
package/README.md CHANGED
@@ -1,3 +1,419 @@
1
- # Temporary Holding Version
1
+ # AutomateGraph
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An isomorphic execution engine for graph definitions — typed conditional edges, deep-equality
4
+ routing, explicit `Next`/`goto` routing, a function registry, and graph documents that load
5
+ their own scripts. **Zero runtime dependencies**, one file for the core, identical API in
6
+ Node.js and the browser.
7
+
8
+ ```bash
9
+ npm install automategraph
10
+ ```
11
+
12
+ ```ts
13
+ import { GraphExecutionEngine } from "automategraph";
14
+
15
+ const engine = new GraphExecutionEngine({
16
+ id: "greeting",
17
+ version: "1.0.0",
18
+ entryNodes: ["classify"],
19
+ terminalNodes: ["respond"],
20
+ nodes: {
21
+ classify: {
22
+ id: "classify",
23
+ run: () => "priority",
24
+ next: [
25
+ { test: value => value === "priority", nextNode: "respond" },
26
+ { nextNode: "respond" } // else edge
27
+ ]
28
+ },
29
+ respond: { id: "respond", run: scope => "hello" }
30
+ }
31
+ });
32
+
33
+ const report = await engine.run();
34
+
35
+ report.status; // "COMPLETED"
36
+ report.results.map(r => r.nodeId); // ["classify", "respond"]
37
+ ```
38
+
39
+ Three entry points:
40
+
41
+ | Import | What |
42
+ |---|---|
43
+ | `automategraph` | the engine: `GraphExecutionEngine`, `Next`, `deepEquals`, `FunctionRegistry`, `lintGraph`, storage contract |
44
+ | `automategraph/graph-json` | `parseGraphJSON()` / `createEngineFromJSON()` — graphs as data, with total path-carrying validation |
45
+ | `automategraph/node/graph-scripts` | **Node-only** `loadGraphScripts()` — executes the scripts a document declares |
46
+
47
+ `npm run example:edges`, `example:graphjson` and `example:scripts` in the repository show the
48
+ three levels: typed edges, a JSON document with an injected registry, and a document that
49
+ carries its own code.
50
+
51
+ ---
52
+
53
+ ## The test suite behind it
54
+
55
+ This repository is also the engine's proof: **100 unit + 20 complex integration + 20
56
+ async-callback + 19 upgrade + 11 conformance + 13 lint + 26 edges + 28 registry + 19 graphjson
57
+ + 26 scripts = 282 tests, all passing**, with a self-contained web dashboard that shows every
58
+ result, observation and finding.
59
+
60
+ The engine is **isomorphic** (browser + Node, no Node APIs in the core — proven by a
61
+ DOM-only TypeScript build) and ships a **storage adapter contract** with a conformance
62
+ suite that any backend must pass.
63
+
64
+ ## Run it
65
+
66
+ ```bash
67
+ npm install # typescript + @types/node only
68
+ npm test # compile + run all 282 tests + regenerate the report
69
+ npm run build:browser # prove the core is Node-free (DOM libs, no Node types)
70
+ npm run serve # serve report/index.html on http://0.0.0.0:4173
71
+ npm run report # test + serve in one shot
72
+ ```
73
+
74
+ The runner prints a summary and writes:
75
+
76
+ | Artifact | Purpose |
77
+ |---|---|
78
+ | `report/index.html` | self-contained dashboard (no external assets) |
79
+ | `report/report.json` | machine-readable full results, logs and findings |
80
+
81
+ While the server is running, `GET /run` recompiles and re-executes the whole suite;
82
+ open pages auto-refresh when the results change.
83
+
84
+ ## Layout
85
+
86
+ ```
87
+ src/automategraph.ts the engine, kept verbatim
88
+ tests/harness.ts test registry, per-test timeout, log capture, runner
89
+ tests/helpers.ts fixtures: node/g/linear builders, SpyStorage, plugin factory, capture
90
+ tests/unit.test.ts 100 unit tests (9 categories)
91
+ tests/complex.test.ts 20 complex integration scenarios
92
+ tests/async.test.ts 20 async node-callback cases
93
+ tests/upgrade.test.ts 19 Wave-1 (v1.1) feature cases: policies, budgets, linting, isomorphism
94
+ tests/conformance.test.ts 11 storage-adapter contract cases (checker with teeth + wire format)
95
+ tests/lint.test.ts 13 cases for the v1.2 SUCCESS_DEAD_END rule
96
+ tests/edges.test.ts 26 typed-edge cases: conditionals, else/parallel, Next/goto routing
97
+ tests/registry.test.ts 28 function-registry cases: named run/test, lazy resolution, fingerprints
98
+ tests/graphjson.test.ts 19 graph-JSON cases: parsing, total validation, engine from a data file
99
+ tests/scripts.test.ts 26 script-loading cases: exports, self-registration, formats, the freeze
100
+ tests/fixtures/scripts/ the script fixtures those cases load (cjs/mjs/js, global channel, collisions)
101
+ src/graph-json.ts the JSON loader: parseGraphJSON() + createEngineFromJSON()
102
+ src/node/graph-scripts.ts *** Node-only *** loadGraphScripts(): executes a document's scripts
103
+ examples/ the a→b/c/d/e/f picture three ways (guards, router, typed edges)
104
+ docs/diagrams/ C-19 resilience scenario: SVG, Mermaid (.mmd) and annotated notes
105
+ src/storage-adapter.ts the StorageAdapter contract + canonical JSON wire format
106
+ src/storage-conformance.ts runStorageConformance(): ship it in your adapter's CI
107
+ CHANGELOG.md version history: v1.1 policies, v1.2 lint, v1.3 edges, v1.4 registry, v1.5 scripts
108
+ UPGRADE-PROPOSAL.md the roadmap this wave came from
109
+ tests/report.ts HTML/JSON report generator
110
+ tests/runner.ts entry point (npm test)
111
+ tools/server.mjs static report server with auto-refresh
112
+ ```
113
+
114
+ ## Coverage
115
+
116
+ | Unit category | Tests |
117
+ |---|---|
118
+ | Graph validation (ids, versions, entries/terminals, refs, timeouts, retry bounds) | 12 |
119
+ | Error classes & utilities (hierarchy, codes, ids, timestamps, throw coercion) | 19 |
120
+ | EventBus (required vs non-required, FIFO, isolation, teardown) | 12 |
121
+ | InMemoryStorage (atomicity, copy isolation, concurrency) | 8 |
122
+ | Graph freezing (deep immutability of definitions) | 8 |
123
+ | Engine lifecycle (state machine, single run, plugin init/shutdown) | 10 |
124
+ | Execution & scheduler (paths, fan-out, contexts, events, checkpoints, settlement) | 20 |
125
+ | Retry (RETRY looping, delayMs, attempt accounting) | 6 |
126
+ | Cancellation (cooperative abort, events, terminal no-ops) | 5 |
127
+
128
+ The 20 complex scenarios cover multi-branch pipelines, chaos graphs, FAIL_FAST,
129
+ ONTHROW repair, diamond fan-in, cycles, 250-node concurrency barriers, 40-node
130
+ checkpoint chains, plugin shutdown, mid-run checkpoint failure, timeouts,
131
+ cancellation, event ordering and listener isolation. See the report for the
132
+ full list with per-test findings.
133
+
134
+ ### Async node callbacks (`tests/async.test.ts`, 20 cases)
135
+
136
+ `NodeDefinition.run` is typed `(context) => unknown | Promise<unknown>`, so `async`
137
+ callbacks are first-class. This pack proves the async contract on every path:
138
+ awaited resolution, thenable-returning non-async callbacks, `undefined` results,
139
+ rejections (plain, `AutomateGraphError`-shaped), async retry + `onThrow` repair,
140
+ async timeout preemption, cooperative abort (both the resolve-after-abort and
141
+ reject-because-of-abort paths), checkpoints after async completion, awaited
142
+ `context.emit` ordering, parallel async branches, mixed sync/async pipelines,
143
+ abandoned-work isolation and a full plugin/storage integration run.
144
+
145
+ These 20 cases are counted separately so your canonical **100 unit + 20 complex**
146
+ suites stay intact.
147
+
148
+ ## Storage adapter contract (v1.1)
149
+
150
+ Anything that can hold a JSON string can be a backend. Implement `StorageAdapter`
151
+ (with required `saveAtomic`/`load` and optional `listExecutionIds`/`delete`/`close`,
152
+ plus a `capabilities` self-description), then prove it:
153
+
154
+ ```ts
155
+ import { runStorageConformance } from "../src/storage-conformance";
156
+
157
+ const report = await runStorageConformance({
158
+ name: "my-sqlite-adapter",
159
+ create: () => createSqliteAdapter(path),
160
+ failNextSave: adapter => adapter.failNextSave() // optional: enables atomicity checks
161
+ });
162
+
163
+ if (!report.passed) {
164
+ console.error(report.violations); // ROUNDTRIP_FAILED, NO_ALIASING_ON_LOAD, …
165
+ process.exit(1);
166
+ }
167
+ ```
168
+
169
+ The checker is part of this repo and backs its own test suite, so the contract cannot
170
+ drift from the tests that enforce it. It catches the classic adapter bugs: aliasing
171
+ (storing live references), torn writes (committing before the failure is raised — it
172
+ distinguishes *"rejects"* from *"rejects without corrupting state"*), broken listing,
173
+ non-idempotent deletes and non-deterministic serialization. `serializeSnapshot()` /
174
+ `deserializeSnapshot()` provide a versioned, byte-stable envelope for storage.
175
+
176
+ ## Guaranteed semantics (v1.1 → v1.5)
177
+
178
+ Verified by named tests; the dashboard lists all 24 findings with the case that produced each.
179
+
180
+ 1. **Terminal completion is policy-driven** — `FIRST_TERMINAL` (default) latches on the first
181
+ terminal and drops the rest *visibly* via `activation.dropped`; `ALL_TERMINALS` waits for every
182
+ declared terminal; `DRAIN` executes all reachable work before settling (the opt-in fix for the
183
+ v1.0 silent-drop of ONTHROW recovery paths).
184
+ 2. **Cancellation is symmetric** — a callback that rejects because of an abort settles
185
+ `CANCELLED` with a handled `GRAPH_CANCELLED` record, exactly like one that resolves after it.
186
+ 3. **The drain has a deadline** — set `cancellationTimeoutMs` and ignored aborts cannot hang
187
+ `run()`: in-flight activations become `ABANDONED`, `drainTimedOut: true`, late outputs are
188
+ never adopted. Unset ⇒ v1.0 wait-forever.
189
+ 4. **`FAIL_FAST` is sticky at both levels** and is reported through `activation.dropped`.
190
+ 5. **`FALLBACK` actually routes** when `NodeDefinition.fallback` is declared (error handled);
191
+ without a target it degrades to `CONTINUE`.
192
+ 6. **Budgets hold** — `maxConcurrency` caps parallelism, `maxActivations` terminates runaway
193
+ cycles with `STEP_BUDGET_EXCEEDED` instead of spinning forever.
194
+ 7. **`NodeResult.error` carries the failure record** (including `NODE_TIMEOUT`).
195
+ 8. **Linting catches structural mistakes before they burn work** — unreachable nodes/terminals,
196
+ dead ends, duplicates, ignored fallback targets, and (v1.2) **success dead ends**
197
+ (`lint: "error"` rejects the run at pre-flight, before any node callback runs).
198
+ 9. **The scheduler is honest about drops** — every cleared activation is reported with a reason.
199
+ 10. **Fan-in has no join semantics** — a node with N incoming edges activates once per edge;
200
+ last writer wins (unchanged from v1.0; use `ALL_TERMINALS`/`DRAIN` or a map primitive to
201
+ model joins until Wave 4's join primitive lands).
202
+ 11. **Checkpoint failure is sticky**, keeps the last valid snapshot, and surfaces as
203
+ `CHECKPOINT_SAVE_FAILED` plus node-level `NODE_EXECUTION_ERROR`.
204
+ 12. **Work is notified properly** — `graph.finished` is the only emit whose rejection is caught;
205
+ a throwing required listener elsewhere escapes into the node error path (documented, test C-17).
206
+ 13. **The core is isomorphic** — no Node APIs, no Buffers; `setTimeout`/`AbortController` only.
207
+ 14. **A non-terminal node whose success path has nowhere to go is a lint error (v1.2)** —
208
+ `SUCCESS_DEAD_END` fires when a node has no `next` target but does declare error transitions
209
+ (`onThrow` / a live `fallback`). Its successful output is silently unreachable; the graph can
210
+ only "continue" when the node fails. `DEAD_END_NODE` remains for nodes with no transitions at
211
+ all. The two are mutually exclusive, and the engine never invents a successor.
212
+
213
+ 15. **`next` is an ordered edge list, not just a fan-out (v1.3)** — string entries stay
214
+ unconditional (parallel) as in v1.0, object entries `{ nextNode, result, test, parallel }`
215
+ are exclusive and evaluated in declaration order, first match wins. `result` matches by
216
+ deep equality, `result` + `test` together must both pass, and an entry with no matcher is
217
+ the `else` edge. No match ⇒ nothing is scheduled (silent).
218
+ 16. **A node can route itself (v1.3)** — `return new Next(target, value)` or
219
+ `context.goto(target)` followed by the normal `return value`. An explicit route replaces the
220
+ whole edge list for that activation, may target any node in the graph, and is validated at
221
+ runtime (`INVALID_GOTO_TARGET` when the target does not exist). `Next` wins when both are
222
+ used; each retry attempt starts with no route set.
223
+ 17. **Terminal detection happens before routing** — a node listed in `terminalNodes` latches
224
+ logical completion and never schedules successors, so its edges are decorative
225
+ (`TERMINAL_EDGE_IGNORED` warns about it).
226
+
227
+ 18. **A function may live in a registry (v1.4)** — `NodeDefinition.run` and `NextEdge.test`
228
+ accept the NAME of a registered function. The engine merges its own `{ functions }`
229
+ table with `GraphDefinition.functions` (graph entries win) and resolves names **lazily,
230
+ at evaluation time**, so `engine.registerFunction()` after construction — or mid-run —
231
+ still takes effect. `FnRegistry.register` validation and `FUNCTION_NAME_PATTERN` apply
232
+ to keys; the pattern allows namespacing (`vendor:retry`, `order.chargeCard`).
233
+ 19. **An unresolved name is `UNKNOWN_FUNCTION`, permanently (v1.4)** — the activation is
234
+ recorded FAILED with an unhandled error and NOTHING is scheduled: no retry, no
235
+ `onThrow`, no `fallback`. An unresolvable edge `test` records the error once per
236
+ node+name and simply never matches, so the rest of the chain still evaluates — the run
237
+ settles FAILED either way.
238
+ 20. **Identity is observable (v1.4)** — activations carry `fn` (results, `node.started`,
239
+ `node.succeeded`, `node.failed`), and the merged registry is reported as
240
+ `{ fingerprint, names }` on `graph.ready`, `ExecutionReport.registry` and every
241
+ checkpoint. The fingerprint is `fnr1:<count>:<hash>` over sorted names + function
242
+ source text, so it moves when an implementation changes and stays put when it does not.
243
+
244
+ 21. **A document may declare where its functions come from (v1.5)** — `scripts` entries carry
245
+ `{ namespace, path | source | url, format? }`. The isomorphic parser validates the
246
+ declarations (shape, exactly one source, unique namespaces, JSON paths) and the Node
247
+ loader (`src/node/graph-scripts.ts`) executes them. **Validation runs before any code
248
+ executes**: a document with a broken graph never loads a script. `createEngineFromJSON`
249
+ does not load scripts — the pipeline stays two-step.
250
+ 22. **A script provides functions in two ways (v1.5)** — it EXPORTS them (`module.exports = {…}`,
251
+ `export const …`, `export default {…}` — its functions are merged; non-function exports are
252
+ ignored and reported under `ignoredExports`), or it REGISTERS itself from inside its own
253
+ body via `globalThis.GraphEngine.registerEngineNamespace(namespace, functions)`. The loader
254
+ installs that global for the duration of the load only and restores the realm afterwards.
255
+ Names keep their exported spelling — **the namespace is a claim slot, not a name prefix**.
256
+ 23. **Namespaces are frozen, once per process (v1.5)** — a second registration of the same
257
+ namespace throws `NAMESPACE_ALREADY_REGISTERED`, from inside the script when it
258
+ self-registers. Claims are made at registration time: a script that registers and then
259
+ throws leaves its namespace claimed. `resetGraphScriptNamespaces()` clears the claims for
260
+ tests/dev; production should not need it. Two scripts providing the same NAME — in
261
+ different namespaces — is a separate gate: `FUNCTION_NAME_COLLISION` unless the load opts
262
+ into `{ collisions: "override" }`.
263
+ 24. **Formats are auto-detected with a safe fallback (v1.5)** — `.cjs` → `require`, `.mjs` →
264
+ dynamic `import`, `.js`/URL → source hint, then CJS, falling back to ESM (and the reverse on
265
+ a format error). An explicit `format` is never retried. Because the fallback REPLAYS the
266
+ script body, it is reserved for module-format failures (`ERR_REQUIRE_ESM`, ESM syntax under
267
+ `require`); a script that throws while running is executed exactly once.
268
+
269
+ ### Script loading (v1.5)
270
+
271
+ ```json
272
+ {
273
+ "id": "order-flow", "version": "1.5.0",
274
+ "entryNodes": ["classify"], "terminalNodes": ["ship", "manual"],
275
+ "scripts": [
276
+ { "namespace": "order", "path": "./order.functions.cjs" },
277
+ { "namespace": "audit", "source": "globalThis.GraphEngine.registerEngineNamespace('audit', { auditDecision: () => 'ok' });" }
278
+ ],
279
+ "nodes": {
280
+ "classify": { "run": "classifyOrder", "next": [{ "test": "isPriority", "nextNode": "charge" }, { "nextNode": "manual" }] },
281
+ "charge": { "run": "chargeCard", "next": ["ship"] },
282
+ "ship": { "run": "shipOrder" },
283
+ "manual": { "run": "manualReview" }
284
+ }
285
+ }
286
+ ```
287
+
288
+ ```ts
289
+ import { loadGraphScripts } from "./src/node/graph-scripts"; // Node-only
290
+
291
+ const { functions, namespaces, scripts, registry } = await loadGraphScripts(document);
292
+ const engine = createEngineFromJSON(document, { functions, lint: "error" });
293
+
294
+ registry.fingerprint; // fnr1:7:… — the engine reports the same value
295
+ ```
296
+
297
+ A script may equally not export anything and register itself:
298
+
299
+ ```js
300
+ /* order.functions.cjs — either strategy, or both, in one file */
301
+ module.exports = { classifyOrder: () => "priority", isPriority: v => v === "priority" };
302
+ ```
303
+
304
+ **SECURITY.** Executing a script named by a document is arbitrary code execution with the full
305
+ privileges of the process — there is no sandbox, deliberately (the same-realm strategy). Treat
306
+ "who may supply a graph document" as a security boundary: load documents you trust, from
307
+ directories you control. The loader is Node-only precisely so this capability never ships into
308
+ a browser bundle by accident.
309
+
310
+ ### Function registry (v1.4)
311
+
312
+ ```ts
313
+ const engine = new GraphExecutionEngine(graph, {
314
+ functions: {
315
+ ...nodeFunctions({ chargeCard: context => context.attempt }), // (context: NodeContext)
316
+ ...edgeTests({ isPriority: value => value === "high" }) // (value: unknown)
317
+ }
318
+ });
319
+
320
+ engine.registerFunction("vendor:retry", context => "retried"); // any time
321
+
322
+ // graph data may name them instead of inlining them:
323
+ node("charge", "chargeCard", {
324
+ next: [{ test: "isPriority", nextNode: "ship" }, { nextNode: "manual" }]
325
+ })
326
+ ```
327
+
328
+ `nodeFunctions()` / `edgeTests()` exist because one table holds both signatures — a bare
329
+ object literal cannot infer a parameter type from that union, these wrappers restore it.
330
+
331
+ ### Graph JSON loader (v1.4)
332
+
333
+ `src/graph-json.ts` turns a document into a `GraphDefinition` — functions included, by name:
334
+
335
+ ```json
336
+ {
337
+ "id": "order-flow", "version": "1.0.0",
338
+ "entryNodes": ["classify"], "terminalNodes": ["ship", "manual"],
339
+ "completionPolicy": "FIRST_TERMINAL",
340
+ "nodes": {
341
+ "classify": { "run": "classifyOrder",
342
+ "next": [{ "test": "isPriority", "nextNode": "charge" },
343
+ { "nextNode": "manual" }] },
344
+ "charge": { "run": "chargeCard", "next": ["ship"], "onThrow": "manual",
345
+ "errorPolicy": "RETRY", "retry": { "maxAttempts": 2 } },
346
+ "ship": { "run": "shipOrder" },
347
+ "manual": { "run": "manualReview" }
348
+ }
349
+ }
350
+ ```
351
+
352
+ ```ts
353
+ const engine = createEngineFromJSON(document, { functions, lint: "error" });
354
+ ```
355
+
356
+ Parsing needs no registry (names resolve lazily), validation is total — every problem is
357
+ collected with its JSON path (`nodes.a.next[0].test`) and thrown as one `GraphJSONError`
358
+ carrying `issues` — node `id` defaults to its key, unknown properties are rejected at every
359
+ level, and `functions` inside the document is an error that points at the engine options.
360
+ Run `npm run example:graphjson` for the same document against four registries.
361
+
362
+ ### Typed edges and explicit routing (v1.3)
363
+
364
+ ```ts
365
+ node("a", () => result, {
366
+ next: [
367
+ { result: 1, nextNode: "b" }, // deep equality
368
+ { test: r => r === 2, nextNode: "c" }, // predicate
369
+ { nextNode: "d" }, // else edge
370
+ "audit" // parallel: always fires
371
+ ],
372
+ onThrow: "e", // error transition (unchanged)
373
+ retry: { maxAttempts: 2 }, errorPolicy: "RETRY"
374
+ })
375
+
376
+ node("b", context => { // void node forcing a jump
377
+ context.goto("f");
378
+ return undefined;
379
+ }, { next: ["f"] }) // declared so lint sees f
380
+ ```
381
+
382
+ Evaluation, after a node succeeds and its checkpoint is written:
383
+
384
+ ```
385
+ if explicit route (Next | goto) → schedule exactly that node, ignore every edge
386
+ else for each edge in order:
387
+ parallel → schedule (never blocked)
388
+ exclusive, taken? → skip
389
+ exclusive, matches? → schedule, mark taken // first match wins
390
+ ```
391
+
392
+ New lint codes: `UNREACHABLE_EDGE` (an exclusive edge declared after an else edge),
393
+ `PARALLEL_EDGE_MATCHER_IGNORED` (parallel edges are unconditional), `TERMINAL_EDGE_IGNORED`.
394
+
395
+ See `examples/branching-edges.ts` for the `a → b/c/d/e/f` picture running as **one execution**
396
+ — no router, no gate, no `DRAIN` policy — compared against the two earlier approaches in
397
+ `examples/README.md`, and `npm run example:scripts` for a document whose functions arrive from
398
+ a script file plus an inline self-registering script (including the freeze and the reset).
399
+
400
+ ### The success dead-end rule (v1.2)
401
+
402
+ Discovered while explaining the C-19 resilience fixture, where `slow-vendor` declared
403
+ `timeoutMs` + `onThrow` but **no `next`** — so it passed linting even though its success path
404
+ (the 25% case where the vendor answers in time) led nowhere:
405
+
406
+ ```text
407
+ SUCCESS_DEAD_END (error) · Node "slow-vendor" is not a terminal node and cannot continue
408
+ after success (no next target); only its error transitions are declared.
409
+ Add next targets or declare it as a terminal node
410
+ ```
411
+
412
+ Fixes, in order of preference:
413
+
414
+ 1. **Handle the failure inside the node** so it always succeeds and has a single live edge:
415
+ `try { … } catch { return cachedPrice; }` then `next: ["decide"]`.
416
+ 2. **Declare `next`** for the success path — remember fan-in then produces one activation per
417
+ incoming edge (see `completionPolicy`).
418
+ 3. **Declare it a terminal node** if success really is the end — and pair it with
419
+ `completionPolicy: "ALL_TERMINALS"` / `"DRAIN"` so `FIRST_TERMINAL` cannot drop other branches.