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
package/CHANGELOG.md CHANGED
@@ -3,12 +3,25 @@
3
3
  All notable changes to the AutomateGraph Execution Engine.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/); the project uses semver.
5
5
 
6
- ## [1.5.1] — First registry publish
6
+ ## [1.5.3] — The complete project on npm
7
7
 
8
- No code changes. The 1.5.0 upload was accepted by the registry but held by the account's
9
- publish policy (granular tokens with 2FA bypass are being restricted for direct publishing,
10
- which routes to staged approval), so the version never became installable. 1.5.1 carries the
11
- identical tarball through the same policy.
8
+ No code changes. The tarball now ships the WHOLE project instead of the library subset: the
9
+ 282-test suite with its fixtures, the examples, the report tooling, the docs and the build
10
+ configuration, alongside the engine sources and the compiled output. `files` is gone in favour
11
+ of a short `.npmignore` deny-list, so anything added to the repository travels to npm by
12
+ default; `npm test` and every example run from an installed copy.
13
+
14
+ Lineage of this release: 1.5.0 was consumed by the registry without ever becoming installable
15
+ (held by the account's publish policy), 1.5.1 went live with the library-only tarball, 1.5.2
16
+ was staged but could not be approved and never shipped. 1.5.3 is the complete project.
17
+
18
+ ## [1.5.2] — First registry publish
19
+
20
+ No code changes. The 1.5.0 and 1.5.1 uploads were accepted by the registry but held by the
21
+ account's publish policy — bypass-2FA granular tokens are being restricted for direct
22
+ publishing (see https://gh.io/npm-gat-bypass2fa-deprecation), and a directly submitted version
23
+ can no longer be staged either, so those two numbers are consumed. 1.5.2 carries the identical
24
+ tarball through the supported path: `npm stage publish` + approval.
12
25
 
13
26
  ## [1.5.0] — Script loading
14
27
 
@@ -0,0 +1,141 @@
1
+ # AutomateGraph — Upgrade Proposal
2
+
3
+ **From "a well-tested single-file engine" to "a durable, observable workflow engine you could actually run in production — with proof."**
4
+
5
+ Current state: 140/140 tests passing, 18 documented semantics/findings, a self-contained web
6
+ dashboard, zero dependencies. Everything below builds on that baseline without throwing it away.
7
+
8
+ ---
9
+
10
+ ## 0. North star
11
+
12
+ Three properties separate a tested engine from a production-grade one:
13
+
14
+ | Property | Today | Target |
15
+ |---|---|---|
16
+ | **Durability** — survives process death, resumes mid-graph, supports long waits | Checkpoints are *written* but never *read*; `StoragePlugin.load()` is dead code in the engine | `resume(executionId)` reconstructs the scheduler frontier and continues; durable timers & external signals |
17
+ | **Predictability** — policies are explicit, never "deferred" | 18 findings: silent dropped work, asymmetric FAIL_FAST, inert FALLBACK, unbounded concurrency, ambiguous cancellation | Every policy is a documented, configurable, tested contract — findings become specs, not surprises |
18
+ | **Provability** — confidence scales beyond hand-written cases | 140 curated tests | Property-based fuzzing over random graphs + mutation testing (proves the suite can *catch* bugs) + deterministic clock |
19
+
20
+ The single highest-leverage move: **checkpointing already exists but is write-only.** Turning it
21
+ into real resume/replay upgrades the product category, not just the code.
22
+
23
+ ---
24
+
25
+ ## Track A — Engine correctness (turn the 18 findings into specs)
26
+
27
+ | # | Upgrade | Why (evidence from the suite) | Scope |
28
+ |---|---|---|---|
29
+ | A1 | **Cancellation symmetry** — in `executeActivation`'s catch, re-check `cancellationRequested` before recording: settle `CANCELLED`, mark the `GRAPH_CANCELLED` record as handled | A-10 proved a callback that *rejects* on abort is recorded `FAILED` + unhandled error while one that *resolves* is `CANCELLED` | S |
30
+ | A2 | **Use `cancellationTimeoutMs` for the drain** — it is documented as "Cancellation timeout" but is only applied to plugin shutdown (line 644). After the deadline, remaining in-flight work is recorded `ABANDONED` and the report says `drainTimedOut: true` | The `cancel()` contract is explicitly "deferred" today; work that ignores the signal can hang `run()` forever | M |
31
+ | A3 | **Explicit completion policy** — `completionPolicy: "FIRST_TERMINAL" \| "ALL_TERMINALS" \| "DRAIN"`. FIRST_TERMINAL must emit `activation.dropped` for cleared work instead of silently discarding ONTHROW recovery paths whose errors are already `handled: true` | C-15/C-19: recovery nodes never run yet the run reports COMPLETED | M |
32
+ | A4 | **Persistent FAIL_FAST** — node-level `FAIL_FAST` currently empties the queue once (no-op under unbounded concurrency); make any FAIL_FAST set a sticky stop. Node-level `CONTINUE` can't shield branches from a graph-level stop — document or drop the override | C-16: three-way asymmetry | S |
33
+ | A5 | **Real FALLBACK or delete it** — `errorPolicy: "FALLBACK"` is accepted and treated as CONTINUE. Either add `fallback: { node, when }` routing or remove the enum value so the API stops lying | C-12 | S |
34
+ | A6 | **Fix `NodeResult.error`** — the field exists but is never assigned; attach the `GraphError` record (and the `TimeoutError` for `TIMED_OUT`) to the result | Dead field; consumers can't correlate result→error without array scanning | S |
35
+ | A7 | **Join semantics primitive** — `join: { mode: "ALL" \| "ANY", of: [...] }` on a node so convergence is deterministic and runs once; keep current per-edge behavior as `join: "NONE"`. Fix last-writer-wins `outputs[nodeId]` with per-activation results (`outputsByActivation`) | C-05: diamond fan-in executes the join 2× by design-that-isn't-documented | M |
36
+ | A8 | **Bounded concurrency + backpressure** — replace `maxConcurrency = Number.MAX_SAFE_INTEGER // Provisional` with `maxConcurrency`, `queuePolicy: "FIFO" \| "PRIORITY"`, per-node `concurrency`, and a `maxActivations` step budget (kills runaway cycles with a typed `STEP_BUDGET_EXCEEDED` error instead of hanging) | C-07 proved unbounded; cycles are legal and unbounded (C-06) | M |
37
+ | A9 | **Static graph lint** — `lintGraph()` returns warnings: unreachable nodes, terminals that no edge can reach, duplicate entries, non-terminating cycles, nodes never referenced by `next`/`onThrow` | C-15 scenario 1 only fails at *runtime* after burning work | S |
38
+ | A10 | **Kill dead weight** — `resolveRun` (declared, never used), `NodeStatus: "PENDING"` (never assigned), `activeControllers` leaking after settle (assert empty at drain) | Hygiene; every dead symbol weakens the type contract | S |
39
+
40
+ **Payoff:** the finding count drops from 18 "surprises" to ~6 documented-by-design behaviors, each
41
+ with regression tests named after its finding (A1–A10 → ~25 new assertions).
42
+
43
+ ---
44
+
45
+ ## Track B — Durability & workflow capabilities (the big unlock)
46
+
47
+ | # | Upgrade | What it enables | Scope |
48
+ |---|---|---|---|
49
+ | B1 | **Snapshot v2 + real resume** — snapshot gains the scheduler frontier (`pending: Activation[]`), `phase`, `graphHash`, `input`, `activationSeq`, `idempotencyKeys`; `GraphExecutionEngine.resume(id)` loads, validates graph-version compatibility, re-marks in-flight work per policy (`RETRY` \| `FAIL` \| `ABANDON`), continues | Crash-safe executions: kill the process mid-graph, restart, it finishes. `StoragePlugin.load()` finally means something | L |
50
+ | B2 | **Durable timers & external signals** — a node returns `suspend({ until })` or `awaitSignal("approval")`; the activation is parked in the snapshot; `engine.signal(executionId, name, payload)` wakes it. Timers survive restarts | Human-in-the-loop approvals, webhooks, SLA deadlines — the features that make people choose a workflow engine | L |
51
+ | B3 | **Deterministic replay** — activation ids derived from `hash(executionId, path, seq)`; identical inputs + seeded clock ⇒ identical event stream, so a run can be replayed step-by-step for debugging | Time-travel debugging; also makes property tests reproducible | M |
52
+ | B4 | **Saga / compensation** — `compensate: nodeId` per node; on failure the engine walks completed activations in reverse and runs their compensations, recording a compensation journal | Multi-step transactions (charge → reserve → ship) with rollback, the #1 enterprise workflow pattern | M |
53
+ | B5 | **Map / fan-out primitive** — `map: { over: (ctx) => iterable, concurrency }` with per-item child executions, results aggregated at the join | Replaces 250 hand-wired nodes with one declarative node | M |
54
+ | B6 | **Idempotency & exactly-once** — `idempotencyKey` per activation handed to node code, checkpoint-before-effect ordering option (outbox), dedupe of replayed activations | Prevents the classic "retry charged the customer twice" bug | M |
55
+ | B7 | **Middleware / interceptors** — `engine.use(async (activation, next) => …)` around node execution: tracing, metrics, caching, auth context, dry-run mode | Extensibility without forking the engine | S |
56
+ | B8 | **Retry upgrades** — exponential backoff + jitter + maxDelay, `retryOn: (error) => boolean`, per-attempt timeout, budget-aware retries | Production retry semantics instead of fixed `delayMs` | S |
57
+
58
+ ---
59
+
60
+ ## Track C — Observability & developer experience
61
+
62
+ | # | Upgrade | What it looks like | Scope |
63
+ |---|---|---|---|
64
+ | C1 | **Live execution stream** — the existing `tools/server.mjs` gains `GET /events/:executionId` (SSE) fed by an engine event hook; the dashboard gains a *live* mode showing nodes lighting up as they run | Watch a graph execute in the browser instead of reading JSON after the fact | M |
65
+ | C2 | **Trace viewer / waterfall** — per-activation timeline (start, attempts, retries, timeout marks, path tree), driven by the recorded event stream; scrub through checkpoints | Debugging a 250-node fan-out becomes visual | M |
66
+ | C3 | **Graph visualization** — `toMermaid(graph)` / `toDot(graph)` + an SVG renderer in the report; node states colored by status (pending/running/failed/skipped/abandoned) | Onboarding: see the graph, not just the list | S |
67
+ | C4 | **Structured logs + trace context** — replace free-form `onLog` calls with `{ level, event, executionId, nodeId, activationId, path, attempt, durationMs }`; OpenTelemetry-compatible span events (zero-dep core, adapter optional) | Plugs into existing observability stacks | M |
68
+ | C5 | **CLI** — `automategraph validate graph.ts`, `run graph.ts --input in.json --watch`, `resume <executionId>`, `export --mermaid`, `bench` | One command from file to running graph | M |
69
+ | C6 | **Interactive playground** — define nodes in a browser sandbox (engine already has zero deps → runs in a Web Worker), wire them visually, run and inspect | The demo that sells the library | L |
70
+
71
+ ---
72
+
73
+ ## Track D — Testing to the next level (proof, not just coverage)
74
+
75
+ | # | Upgrade | Why it's the real step up | Scope |
76
+ |---|---|---|---|
77
+ | D1 | **Property-based fuzzing** — a random graph generator (DAG + cycles, random failures/timeouts/retries/policies/cancellation) plus invariants asserted on every run: `COMPLETED ⇒ logicallyCompleted`; no output for non-SUCCEEDED results; `finishedAt ≥ startedAt`; unique activation ids; no unhandled rejections; no work scheduled after cancel; `activeControllers`/`activeTasks` empty at settle. Thousands of cases per run, counterexample-minimizing | Curated tests check what *you thought of*; invariants check everything. A-10-class asymmetries get found automatically | L |
78
+ | D2 | **Mutation testing** — a zero-dep mutation runner that patches the engine (`>=`→`>`, drop `handled = true`, swap settle precedence, remove `pending.length = 0`) and re-runs the suite; **kill rate ≥ 95%** gated in CI, score shown in the dashboard | Answers the only question that matters: *can the suite catch bugs?* 140 green tests prove nothing without this | M |
79
+ | D3 | **Deterministic clock + seeded scheduler** — inject `now()`, `setTimeout`, `delay`, and id generation; timeout tests stop using wall-clock sleeps | Removes CI flakiness; makes D1 replayable | M |
80
+ | D4 | **Burn-in & leak checks** — `--repeat 50` stress mode; heap-delta assertions; `activeTasks.size === 0` after every run; 10k-node soak | Catches the O(n²) snapshot growth (every checkpoint copies all results/errors) before users do | S |
81
+ | D5 | **Chaos storage/listener faults** — extend `SpyStorage` into a fault matrix: latency, partial write, duplicate save, out-of-order resolve, close-timeout; assert the sticky-checkpoint-failure invariants still hold | Our C-09/C-14 already probe this; make it systematic | S |
82
+ | D6 | **Coverage gate + badge** in the dashboard (c8, statements/branches thresholds); **CI matrix** (Node 18/20/22, Linux/macOS), published report to GitHub Pages, `npm pack` smoke test | Turns the suite into infrastructure | M |
83
+ | D7 | **Differential testing** — a trivially-correct sequential reference interpreter for small graphs; random graphs run on both; any divergence is a scheduler bug | Finds scheduler semantics bugs no hand-written test anticipates | M |
84
+
85
+ ---
86
+
87
+ ## Track E — Productization
88
+
89
+ | # | Upgrade | Notes | Scope |
90
+ |---|---|---|---|
91
+ | E1 | **Package** — `@automategraph/core` with dual ESM/CJS builds, `.d.ts`, browser + worker builds (already dependency-free), semver major for the breaking policy changes in Track A | 30 minutes of tooling, changes who can use it | S |
92
+ | E2 | **Storage adapters** — SQLite (single file, `BEGIN IMMEDIATE` for atomicity), Postgres (JSONB + `FOR UPDATE`), Redis (Lua CAS); conformance test suite that every adapter must pass | `InMemoryStorage` already defines the contract; adapters are how durability becomes real | M–L |
93
+ | E3 | **Server mode** — `POST /executions`, `GET /executions/:id`, `GET /events/:id` (SSE), `POST /executions/:id/signals`, `POST /executions/:id/cancel`; execution registry, retention, auth hook | The engine becomes a service; the dashboard becomes an ops console | L |
94
+ | E4 | **Docs** — typedoc API reference, 5 runnable guides (ETL, order pipeline, approval flow, saga, map/reduce), migration guide for v2 semantics, published benchmark numbers (nodes/sec, snapshot overhead per checkpoint) | Adoption is proportional to documentation | M |
95
+
96
+ ---
97
+
98
+ ## Sequencing — five waves
99
+
100
+ Each wave ends with a numeric acceptance criterion, so progress is provable, not vibes.
101
+
102
+ | Wave | Content | Acceptance criterion |
103
+ |---|---|---|
104
+ | **1. Correctness** (1–2 days) | A1–A10 + regression tests named per finding; README "findings" section rewritten as "guaranteed semantics" | Findings 18 → ≤6; suite 140 → ~170; all green ×5 runs |
105
+ | **2. Durability** (3–5 days) | B1 snapshot v2 + `resume()`, B2 suspends/signals, B8 retry upgrades; SQLite adapter; kill-and-resume integration test that SIGKILLs a child process mid-graph | Kill a run at 50%, restart, execution completes with identical outputs; new tests cover resume matrix |
106
+ | **3. Trust** (2–3 days) | D1 property fuzzing, D2 mutation ≥95%, D3 deterministic clock, D4 burn-in; dashboard cards for mutation score + fuzz cases + coverage | 10k fuzz cases clean; mutation score ≥95% displayed in the report |
107
+ | **4. Capabilities** (3–5 days) | B3 replay, B4 saga, B5 map/fan-out, B7 middleware, A7 joins in anger; C1 live SSE stream + C2 waterfall in the dashboard | A saga demo (charge→reserve→ship→rollback) and a live-updating trace view |
108
+ | **5. Product** (3–5 days) | E1 package, E3 server, E2 Postgres adapter, E4 docs, C5 CLI | `npx automategraph run examples/approval-flow.ts` works; docs site live |
109
+
110
+ Deliberate omissions (still worth doing *later*): distributed workers with leases (needs E2/E3
111
+ first), conditional/edge-level policies as first-class objects, subgraph composition, JSON/YAML
112
+ declarative graph specs, secrets plugin, multi-tenant isolation.
113
+
114
+ ---
115
+
116
+ ## My recommendation — start here
117
+
118
+ If you only do three things, do these:
119
+
120
+ 1. **Wave 1 (A1–A10)** — cheap, and it converts the audit into guarantees. The cancellation
121
+ asymmetry (A1) and the silently-dropped recovery work (A3) are the two behaviors most likely to
122
+ bite a real user, and both are ~1 hour fixes plus tests.
123
+ 2. **B1 — snapshot v2 + `resume()`.** This is the category change: "automation library" → "durable
124
+ workflow engine". Everything else in Track B is optional; this one is not.
125
+ 3. **D1 + D2 — property fuzzing and mutation testing.** These change how you *feel* about the code:
126
+ "140 tests pass" becomes "the suite kills 97% of injected bugs and 10,000 random graphs violate
127
+ no invariants". That is the difference between a test suite and proof.
128
+
129
+ Nice-to-have that pays for itself immediately: **C3** (Mermaid/SVG export — one afternoon, makes
130
+ every future conversation about the engine visual) and **C1/C2** (live trace view — makes the
131
+ dashboard a selling point instead of a report).
132
+
133
+ ### Decisions I need from you before Wave 1
134
+
135
+ 1. **Breaking-change budget** — Track A changes observable semantics (completion policy default,
136
+ FAIL_FAST stickiness, cancellation status). Do we ship v2.0 with clean semantics, or v1.1 with
137
+ opt-in flags defaulting to today's behavior?
138
+ 2. **Runtime target** — Node-only (child-process workers for hard isolation), or keep it
139
+ isomorphic for the browser playground?
140
+ 3. **Where durability lands** — in-repo SQLite via `node:sqlite` (zero deps, ships with Node 22+)
141
+ or a pluggable adapter interface you implement later?
@@ -0,0 +1,61 @@
1
+ ```mermaid
2
+ %% C-19 · Resilience scenario — timeout routed to a fallback (AutomateGraph v1.1)
3
+ %% Solid = next (normal transition) · dashed red = onThrow (error transition, NOT a retry)
4
+ %% Dotted violet = fixture-only synchronisation gate
5
+ flowchart TD
6
+ start["<b>start</b><br/><i>sync · run: () =&gt; 1</i><br/>next: [slow-vendor, fast-vendor]"]
7
+ slow["<b>slow-vendor</b><br/><i>async · await sleep(80)</i><br/>timeoutMs: 25 · onThrow ↓"]
8
+ fast["<b>fast-vendor</b><br/><i>async · await race(fallbackDone, 2000ms)</i><br/>next: [decide]"]
9
+ fb["<b>vendor-fallback</b><br/><i>sync · releaseFast()</i><br/>next: [decide]"]
10
+ decide["<b>decide</b><br/><i>async · emit(pricing.decided)</i><br/>reads outputs[vendor-fallback]<br/>next: [finish]"]
11
+ finish(["<b>finish ★</b><br/><i>const · 'DONE'</i><br/>terminal node"])
12
+
13
+ start -->|next| slow
14
+ start -->|next · fan-out| fast
15
+ slow -.->|onThrow · NODE_TIMEOUT · handled| fb
16
+ fb -->|next| decide
17
+ fast -->|next| decide
18
+ decide -->|next| finish
19
+ fb -.->|test gate: releaseFast resolves fallbackDone| fast
20
+
21
+ classDef done fill:#0f3627,stroke:#31c48d,color:#e7ecf7
22
+ classDef failed fill:#3a1620,stroke:#f4645f,color:#e7ecf7
23
+ classDef terminalNode fill:#1b2a55,stroke:#6ea8fe,color:#e7ecf7
24
+ classDef hot fill:#3a2b0d,stroke:#f0b429,color:#e7ecf7
25
+
26
+ class start,fb,fast done
27
+ class slow failed
28
+ class decide hot
29
+ class finish terminalNode
30
+ ```
31
+
32
+ ### Observed execution (verified, not illustrative)
33
+
34
+ | t | what happens |
35
+ |---|---|
36
+ | 8 ms | `start` succeeds → fan-out seeds **both** vendors |
37
+ | 9 ms | `slow-vendor` begins (25 ms budget, 80 ms of work); `fast-vendor` begins awaiting the gate |
38
+ | 25 ms | `timeoutMs: 25` deadline passes |
39
+ | 36 ms | `slow-vendor` → **TIMED_OUT**, `NODE_TIMEOUT` recorded (handled) → `onThrow` → `vendor-fallback` |
40
+ | 37 ms | `vendor-fallback` runs `releaseFast()` → `"cached-price"`; `fast-vendor` released → `"fresh-price"` |
41
+ | 38 ms | `decide` starts **twice** (two activations, both read `outputs["vendor-fallback"] = "cached-price"`) |
42
+ | 39 ms | `pricing.decided` emitted ×2 · `decide` ×2 SUCCEEDED · `finish` ×2 start + SUCCEEDED |
43
+ | 40 ms | `graph.logicallyCompleted` (`terminalNodeId: finish`) → **COMPLETED** |
44
+ | 89 ms | `slow-vendor`'s abandoned work resolves — **discarded**, never appears in `outputs` |
45
+
46
+ ### What the diagram shows that the code doesn't
47
+
48
+ 1. **`onThrow` is an error *transition*, not a retry** — `slow-vendor` has `maxAttempts = 1`, so there is
49
+ exactly one attempt; the dashed edge is taken instead of a second run.
50
+ 2. **The timeout is cooperative** — `run()` settles at 40 ms while the 80 ms callback keeps running and
51
+ finishes at 89 ms. The `⚠` in the legend; its output is never adopted.
52
+ 3. **Fan-in has no join** — the two `next` edges into `decide` produce two activations, so `decide`
53
+ *and* `finish` each run twice (last-write-wins on `outputs[nodeId]`).
54
+ 4. **The gate is test-only** — `releaseFast()` / `fallbackDone` don't belong to the engine; they exist so
55
+ the *fast* branch waits for the *fallback* branch, keeping the C-19 assertions deterministic. Without
56
+ it, `fast-vendor` would have waited the full 2000 ms race timeout.
57
+ 5. **The dead edge** — `slow-vendor`'s success path is unreachable because 25 ms < 80 ms: the graph's
58
+ "happy path" for that node can never fire in this fixture.
59
+ 6. **Since v1.2 this graph is a lint error** — `lintGraph()` reports
60
+ `SUCCESS_DEAD_END` for `slow-vendor` (non-terminal node, no `next` target, only `onThrow`).
61
+ Adding `next: ["decide"]` clears it; see `tests/lint.test.ts`.
@@ -0,0 +1,28 @@
1
+ %% C-19 · Resilience scenario — timeout routed to a fallback (AutomateGraph v1.1)
2
+ %% Solid = next (normal transition) · dashed red = onThrow (error transition, NOT a retry)
3
+ %% Dotted violet = fixture-only synchronisation gate
4
+ flowchart TD
5
+ start["<b>start</b><br/><i>sync · run: () =&gt; 1</i><br/>next: [slow-vendor, fast-vendor]"]
6
+ slow["<b>slow-vendor</b><br/><i>async · await sleep(80)</i><br/>timeoutMs: 25 · onThrow ↓"]
7
+ fast["<b>fast-vendor</b><br/><i>async · await race(fallbackDone, 2000ms)</i><br/>next: [decide]"]
8
+ fb["<b>vendor-fallback</b><br/><i>sync · releaseFast()</i><br/>next: [decide]"]
9
+ decide["<b>decide</b><br/><i>async · emit(pricing.decided)</i><br/>reads outputs[vendor-fallback]<br/>next: [finish]"]
10
+ finish(["<b>finish ★</b><br/><i>const · 'DONE'</i><br/>terminal node"])
11
+
12
+ start -->|next| slow
13
+ start -->|next · fan-out| fast
14
+ slow -.->|onThrow · NODE_TIMEOUT · handled| fb
15
+ fb -->|next| decide
16
+ fast -->|next| decide
17
+ decide -->|next| finish
18
+ fb -.->|test gate: releaseFast resolves fallbackDone| fast
19
+
20
+ classDef done fill:#0f3627,stroke:#31c48d,color:#e7ecf7
21
+ classDef failed fill:#3a1620,stroke:#f4645f,color:#e7ecf7
22
+ classDef terminalNode fill:#1b2a55,stroke:#6ea8fe,color:#e7ecf7
23
+ classDef hot fill:#3a2b0d,stroke:#f0b429,color:#e7ecf7
24
+
25
+ class start,fb,fast done
26
+ class slow failed
27
+ class decide hot
28
+ class finish terminalNode
@@ -0,0 +1,181 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1120 900" width="1120" height="900"
2
+ font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
3
+ <defs>
4
+ <radialGradient id="bg" cx="15%" cy="-10%" r="120%">
5
+ <stop offset="0%" stop-color="#16224a"/>
6
+ <stop offset="55%" stop-color="#0b1020"/>
7
+ <stop offset="100%" stop-color="#0b1020"/>
8
+ </radialGradient>
9
+ <marker id="aNext" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
10
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#31c48d"/>
11
+ </marker>
12
+ <marker id="aErr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
13
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#f4645f"/>
14
+ </marker>
15
+ <marker id="aGate" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
16
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#c084fc"/>
17
+ </marker>
18
+ <marker id="aDead" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
19
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#4a5670"/>
20
+ </marker>
21
+ </defs>
22
+
23
+ <rect width="1120" height="900" fill="url(#bg)"/>
24
+
25
+ <!-- ============ header ============ -->
26
+ <text x="40" y="52" font-size="26" font-weight="700" fill="#e7ecf7" letter-spacing="-0.4">C-19 · Resilience scenario — node timeout routed to a fallback</text>
27
+ <text x="40" y="78" font-size="13" fill="#94a3c4">GraphExecutionEngine v1.1 · observed run: COMPLETED, logicallyCompleted = true, 40 ms, 1 handled NODE_TIMEOUT · no retries, no joins</text>
28
+
29
+ <!-- ============ edges (drawn first, under the boxes) ============ -->
30
+
31
+ <!-- start -> slow-vendor -->
32
+ <path d="M 490 196 C 490 240, 260 216, 260 254" fill="none" stroke="#31c48d" stroke-width="2" marker-end="url(#aNext)"/>
33
+ <text x="306" y="238" font-size="11.5" fill="#31c48d">next</text>
34
+
35
+ <!-- start -> fast-vendor -->
36
+ <path d="M 630 196 C 630 240, 860 216, 860 254" fill="none" stroke="#31c48d" stroke-width="2" marker-end="url(#aNext)"/>
37
+ <text x="776" y="238" font-size="11.5" fill="#31c48d">next</text>
38
+
39
+ <!-- slow-vendor -> vendor-fallback : ONTHROW (error transition) -->
40
+ <path d="M 260 346 L 260 416" fill="none" stroke="#f4645f" stroke-width="2" stroke-dasharray="7 5" marker-end="url(#aErr)"/>
41
+ <text x="274" y="374" font-size="12" font-weight="700" fill="#f4645f">onThrow</text>
42
+ <text x="274" y="391" font-size="11" fill="#f4645f">NODE_TIMEOUT · recorded handled</text>
43
+
44
+ <!-- slow-vendor success path : never taken -->
45
+ <path d="M 380 303 L 452 303" fill="none" stroke="#4a5670" stroke-width="1.6" stroke-dasharray="4 4" marker-end="url(#aDead)"/>
46
+ <text x="462" y="307" font-size="11" fill="#8b96ae">success path never reached (25 ms &lt; 80 ms work)</text>
47
+
48
+ <!-- test gate : vendor-fallback resolves the promise fast-vendor awaits -->
49
+ <path d="M 384 430 C 480 398, 620 368, 744 338" fill="none" stroke="#c084fc" stroke-width="1.8" stroke-dasharray="2 5" marker-end="url(#aGate)"/>
50
+ <text x="500" y="428" font-size="11" fill="#c084fc">test gate: releaseFast() resolves fallbackDone</text>
51
+
52
+ <!-- vendor-fallback -> decide -->
53
+ <path d="M 340 506 C 400 556, 468 552, 500 570" fill="none" stroke="#31c48d" stroke-width="2" marker-end="url(#aNext)"/>
54
+ <text x="350" y="540" font-size="11.5" fill="#31c48d">next</text>
55
+
56
+ <!-- fast-vendor -> decide -->
57
+ <path d="M 900 346 C 900 480, 690 500, 640 570" fill="none" stroke="#31c48d" stroke-width="2" marker-end="url(#aNext)"/>
58
+ <text x="828" y="470" font-size="11.5" fill="#31c48d">next</text>
59
+
60
+ <!-- decide -> finish -->
61
+ <path d="M 560 656 L 560 694" fill="none" stroke="#31c48d" stroke-width="2" marker-end="url(#aNext)"/>
62
+ <text x="572" y="680" font-size="11.5" fill="#31c48d">next</text>
63
+
64
+ <!-- ============ nodes ============ -->
65
+
66
+ <!-- start -->
67
+ <g>
68
+ <rect x="440" y="110" width="240" height="86" rx="12" fill="#121a33" stroke="#31c48d" stroke-width="1.6"/>
69
+ <text x="456" y="140" font-size="15" font-weight="700" fill="#e7ecf7">start</text>
70
+ <text x="664" y="140" font-size="10.5" font-weight="700" fill="#31c48d" text-anchor="end">SUCCEEDED</text>
71
+ <text x="456" y="166" font-size="11.5" fill="#c8d3ec">run: () =&gt; 1</text>
72
+ <text x="456" y="186" font-size="11.5" fill="#94a3c4">next: [slow-vendor, fast-vendor]</text>
73
+ </g>
74
+
75
+ <!-- slow-vendor -->
76
+ <g>
77
+ <rect x="140" y="260" width="240" height="86" rx="12" fill="#121a33" stroke="#f4645f" stroke-width="1.8"/>
78
+ <text x="156" y="290" font-size="15" font-weight="700" fill="#e7ecf7">slow-vendor</text>
79
+ <text x="364" y="290" font-size="10.5" font-weight="700" fill="#f4645f" text-anchor="end">TIMED_OUT</text>
80
+ <text x="156" y="316" font-size="11.5" fill="#c8d3ec">await sleep(80) → "vendor-result"</text>
81
+ <text x="156" y="336" font-size="11.5" fill="#f0b429">timeoutMs: 25 · onThrow ↓</text>
82
+ </g>
83
+
84
+ <!-- vendor-fallback -->
85
+ <g>
86
+ <rect x="140" y="430" width="240" height="86" rx="12" fill="#121a33" stroke="#31c48d" stroke-width="1.6"/>
87
+ <text x="156" y="460" font-size="15" font-weight="700" fill="#e7ecf7">vendor-fallback</text>
88
+ <text x="364" y="460" font-size="10.5" font-weight="700" fill="#31c48d" text-anchor="end">SUCCEEDED</text>
89
+ <text x="156" y="486" font-size="11.5" fill="#c8d3ec">releaseFast() → "cached-price"</text>
90
+ <text x="156" y="506" font-size="11.5" fill="#94a3c4">next: [decide]</text>
91
+ </g>
92
+
93
+ <!-- fast-vendor -->
94
+ <g>
95
+ <rect x="740" y="260" width="240" height="86" rx="12" fill="#121a33" stroke="#31c48d" stroke-width="1.6"/>
96
+ <text x="756" y="290" font-size="15" font-weight="700" fill="#e7ecf7">fast-vendor</text>
97
+ <text x="964" y="290" font-size="10.5" font-weight="700" fill="#31c48d" text-anchor="end">SUCCEEDED</text>
98
+ <text x="756" y="316" font-size="11.5" fill="#c8d3ec">await race(fallbackDone, 2000 ms)</text>
99
+ <text x="756" y="336" font-size="11.5" fill="#94a3c4">"fresh-price" · next: [decide]</text>
100
+ </g>
101
+
102
+ <!-- decide -->
103
+ <g>
104
+ <rect x="440" y="570" width="240" height="86" rx="12" fill="#121a33" stroke="#f0b429" stroke-width="1.8"/>
105
+ <text x="456" y="600" font-size="15" font-weight="700" fill="#e7ecf7">decide</text>
106
+ <text x="664" y="600" font-size="10.5" font-weight="700" fill="#f0b429" text-anchor="end">SUCCEEDED ×2</text>
107
+ <text x="456" y="626" font-size="11.5" fill="#c8d3ec">emit("pricing.decided", …)</text>
108
+ <text x="456" y="646" font-size="11.5" fill="#94a3c4">"decided" · next: [finish]</text>
109
+ </g>
110
+
111
+ <!-- finish -->
112
+ <g>
113
+ <rect x="440" y="700" width="240" height="86" rx="12" fill="#121a33" stroke="#6ea8fe" stroke-width="1.8"/>
114
+ <text x="456" y="730" font-size="15" font-weight="700" fill="#e7ecf7">finish ★</text>
115
+ <text x="664" y="730" font-size="10.5" font-weight="700" fill="#f0b429" text-anchor="end">SUCCEEDED ×2</text>
116
+ <text x="456" y="756" font-size="11.5" fill="#c8d3ec">"DONE"</text>
117
+ <text x="456" y="776" font-size="11.5" fill="#6ea8fe">terminal node → latches completion</text>
118
+ </g>
119
+
120
+ <!-- ============ legend ============ -->
121
+ <g>
122
+ <rect x="740" y="100" width="340" height="140" rx="12" fill="#101a38" stroke="#24304f"/>
123
+ <text x="756" y="122" font-size="10.5" font-weight="700" fill="#94a3c4" letter-spacing="0.08em">LEGEND</text>
124
+
125
+ <line x1="756" y1="142" x2="778" y2="142" stroke="#31c48d" stroke-width="2" marker-end="url(#aNext)"/>
126
+ <text x="788" y="146" font-size="11.5" fill="#c8d3ec">next — normal transition</text>
127
+
128
+ <line x1="756" y1="162" x2="778" y2="162" stroke="#f4645f" stroke-width="2" stroke-dasharray="7 5" marker-end="url(#aErr)"/>
129
+ <text x="788" y="166" font-size="11.5" fill="#c8d3ec">onThrow — error transition (no retry)</text>
130
+
131
+ <line x1="756" y1="182" x2="778" y2="182" stroke="#c084fc" stroke-width="1.8" stroke-dasharray="2 5" marker-end="url(#aGate)"/>
132
+ <text x="788" y="186" font-size="11.5" fill="#c8d3ec">test gate — fixture-only synchronisation</text>
133
+
134
+ <text x="756" y="206" font-size="11.5" fill="#c8d3ec">★ terminal · ⚠ abandoned work keeps running</text>
135
+ <text x="756" y="226" font-size="11.5" fill="#94a3c4">even after the run has already settled</text>
136
+ </g>
137
+
138
+ <!-- ============ fan-in note ============ -->
139
+ <g>
140
+ <rect x="700" y="546" width="396" height="132" rx="12" fill="#101a38" stroke="#f0b429" stroke-width="1.2"/>
141
+ <text x="716" y="572" font-size="11" font-weight="700" fill="#f0b429" letter-spacing="0.06em">FAN-IN · NO JOIN</text>
142
+ <text x="716" y="596" font-size="11.5" fill="#c8d3ec">decide is activated once per incoming edge</text>
143
+ <text x="716" y="614" font-size="11.5" fill="#c8d3ec">→ 2 activations · it ran twice · emit fired twice</text>
144
+ <text x="716" y="636" font-size="11.5" fill="#c8d3ec">each activation also schedules finish</text>
145
+ <text x="716" y="654" font-size="11.5" fill="#c8d3ec">→ finish ran twice · outputs[nodeId] last-write-wins</text>
146
+ <text x="716" y="672" font-size="11" fill="#94a3c4">(finding C-05 / C-19 · workaround: DRAIN or a join primitive)</text>
147
+ </g>
148
+
149
+ <!-- ============ timeline ============ -->
150
+ <g>
151
+ <text x="140" y="804" font-size="10.5" font-weight="700" fill="#94a3c4" letter-spacing="0.08em">OBSERVED TIMELINE (ms)</text>
152
+
153
+ <line x1="140" y1="838" x2="1010" y2="838" stroke="#24304f" stroke-width="2"/>
154
+ <rect x="449" y="830" width="46" height="16" rx="4" fill="#f0b429" opacity="0.18"/>
155
+
156
+ <!-- t = 8 -->
157
+ <line x1="210" y1="830" x2="210" y2="846" stroke="#4a5670" stroke-width="1.5"/>
158
+ <circle cx="210" cy="838" r="3.5" fill="#31c48d"/>
159
+ <text x="220" y="824" font-size="11" fill="#31c48d">8 · fan-out: both vendors start</text>
160
+
161
+ <!-- t = 25 -->
162
+ <line x1="358" y1="830" x2="358" y2="846" stroke="#4a5670" stroke-width="1.5"/>
163
+ <circle cx="358" cy="838" r="3.5" fill="#f0b429"/>
164
+ <text x="358" y="864" font-size="11" fill="#f0b429" text-anchor="middle">25 · timeoutMs deadline</text>
165
+
166
+ <!-- burst 36..40 -->
167
+ <line x1="453" y1="830" x2="453" y2="846" stroke="#4a5670" stroke-width="1.5"/>
168
+ <line x1="488" y1="830" x2="488" y2="846" stroke="#4a5670" stroke-width="1.5"/>
169
+ <circle cx="453" cy="838" r="3.5" fill="#f4645f"/>
170
+ <circle cx="462" cy="838" r="3" fill="#c084fc"/>
171
+ <circle cx="479" cy="838" r="3" fill="#31c48d"/>
172
+ <circle cx="488" cy="838" r="3.5" fill="#6ea8fe"/>
173
+
174
+ <!-- t = 89 -->
175
+ <line x1="914" y1="830" x2="914" y2="846" stroke="#4a5670" stroke-width="1.5"/>
176
+ <circle cx="914" cy="838" r="3.5" fill="#f4645f"/>
177
+ <text x="904" y="824" font-size="11" fill="#f4645f" text-anchor="end">89 · abandoned work ends — result discarded</text>
178
+
179
+ <text x="140" y="886" font-size="11.5" fill="#c8d3ec">t = 36–40 ms: NODE_TIMEOUT → onThrow → vendor-fallback → releaseFast → fast-vendor released → decide ×2 → finish ×2 → COMPLETED</text>
180
+ </g>
181
+ </svg>