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/CHANGELOG.md +292 -0
- package/LICENSE +15 -0
- package/README.md +418 -2
- package/js/src/automategraph.d.ts +588 -0
- package/js/src/automategraph.d.ts.map +1 -0
- package/js/src/automategraph.js +1682 -0
- package/js/src/automategraph.js.map +1 -0
- package/js/src/graph-json.d.ts +85 -0
- package/js/src/graph-json.d.ts.map +1 -0
- package/js/src/graph-json.js +572 -0
- package/js/src/graph-json.js.map +1 -0
- package/js/src/node/graph-scripts.d.ts +77 -0
- package/js/src/node/graph-scripts.d.ts.map +1 -0
- package/js/src/node/graph-scripts.js +490 -0
- package/js/src/node/graph-scripts.js.map +1 -0
- package/js/src/storage-adapter.d.ts +70 -0
- package/js/src/storage-adapter.d.ts.map +1 -0
- package/js/src/storage-adapter.js +245 -0
- package/js/src/storage-adapter.js.map +1 -0
- package/js/src/storage-conformance.d.ts +31 -0
- package/js/src/storage-conformance.d.ts.map +1 -0
- package/js/src/storage-conformance.js +286 -0
- package/js/src/storage-conformance.js.map +1 -0
- package/package.json +89 -4
- package/src/automategraph.ts +2761 -0
- package/src/graph-json.ts +954 -0
- package/src/node/graph-scripts.ts +829 -0
- package/src/storage-adapter.ts +363 -0
- package/src/storage-conformance.ts +423 -0
package/README.md
CHANGED
|
@@ -1,3 +1,419 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AutomateGraph
|
|
2
2
|
|
|
3
|
-
|
|
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.
|