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.
- package/CHANGELOG.md +18 -5
- package/UPGRADE-PROPOSAL.md +141 -0
- package/docs/diagrams/c19-resilience-scenario.md +61 -0
- package/docs/diagrams/c19-resilience-scenario.mmd +28 -0
- package/docs/diagrams/c19-resilience-scenario.svg +181 -0
- package/examples/README.md +206 -0
- package/examples/branching-edges.ts +192 -0
- package/examples/branching-graph.ts +333 -0
- package/examples/branching-router.ts +475 -0
- package/examples/json-graph.ts +231 -0
- package/examples/json-scripts.ts +152 -0
- package/examples/order-flow.functions.cjs +32 -0
- package/examples/order-flow.graph.json +27 -0
- package/examples/order-flow.scripted.graph.json +68 -0
- package/js/examples/branching-edges.js +133 -0
- package/js/examples/branching-edges.js.map +1 -0
- package/js/examples/branching-graph.js +232 -0
- package/js/examples/branching-graph.js.map +1 -0
- package/js/examples/branching-router.js +305 -0
- package/js/examples/branching-router.js.map +1 -0
- package/js/examples/json-graph.js +197 -0
- package/js/examples/json-graph.js.map +1 -0
- package/js/examples/json-scripts.js +131 -0
- package/js/examples/json-scripts.js.map +1 -0
- package/js/tests/async.test.js +592 -0
- package/js/tests/async.test.js.map +1 -0
- package/js/tests/complex.test.js +972 -0
- package/js/tests/complex.test.js.map +1 -0
- package/js/tests/conformance.test.js +365 -0
- package/js/tests/conformance.test.js.map +1 -0
- package/js/tests/edges.test.js +702 -0
- package/js/tests/edges.test.js.map +1 -0
- package/js/tests/graphjson.test.js +504 -0
- package/js/tests/graphjson.test.js.map +1 -0
- package/js/tests/harness.js +192 -0
- package/js/tests/harness.js.map +1 -0
- package/js/tests/helpers.js +136 -0
- package/js/tests/helpers.js.map +1 -0
- package/js/tests/lint.test.js +317 -0
- package/js/tests/lint.test.js.map +1 -0
- package/js/tests/registry.test.js +705 -0
- package/js/tests/registry.test.js.map +1 -0
- package/js/tests/report.js +422 -0
- package/js/tests/report.js.map +1 -0
- package/js/tests/runner.js +67 -0
- package/js/tests/runner.js.map +1 -0
- package/js/tests/scripts.test.js +552 -0
- package/js/tests/scripts.test.js.map +1 -0
- package/js/tests/unit.test.js +1239 -0
- package/js/tests/unit.test.js.map +1 -0
- package/js/tests/upgrade.test.js +618 -0
- package/js/tests/upgrade.test.js.map +1 -0
- package/package.json +2 -9
- package/report/index.html +3727 -0
- package/report/report.json +3053 -0
- package/tests/async.test.ts +668 -0
- package/tests/complex.test.ts +1123 -0
- package/tests/conformance.test.ts +442 -0
- package/tests/edges.test.ts +848 -0
- package/tests/fixtures/scripts/bad-registration.cjs +4 -0
- package/tests/fixtures/scripts/broken.cjs +2 -0
- package/tests/fixtures/scripts/cjs-default.js +4 -0
- package/tests/fixtures/scripts/collide-a.cjs +4 -0
- package/tests/fixtures/scripts/collide-b.cjs +4 -0
- package/tests/fixtures/scripts/empty.cjs +2 -0
- package/tests/fixtures/scripts/esm-functions.mjs +10 -0
- package/tests/fixtures/scripts/esm-syntax.js +3 -0
- package/tests/fixtures/scripts/global-channel.cjs +6 -0
- package/tests/fixtures/scripts/global-channel.mjs +7 -0
- package/tests/fixtures/scripts/mismatch.cjs +4 -0
- package/tests/fixtures/scripts/needs-sibling.cjs +7 -0
- package/tests/fixtures/scripts/order.cjs +14 -0
- package/tests/fixtures/scripts/sibling-helper.cjs +3 -0
- package/tests/fixtures/scripts/throwing-global.mjs +6 -0
- package/tests/graphjson.test.ts +737 -0
- package/tests/harness.ts +291 -0
- package/tests/helpers.ts +216 -0
- package/tests/lint.test.ts +381 -0
- package/tests/registry.test.ts +1134 -0
- package/tests/report.ts +452 -0
- package/tests/runner.ts +58 -0
- package/tests/scripts.test.ts +970 -0
- package/tests/unit.test.ts +1192 -0
- package/tests/upgrade.test.ts +725 -0
- package/tools/server.mjs +152 -0
- package/tsconfig.browser.json +19 -0
- package/tsconfig.json +18 -0
- package/tsconfig.publish.json +13 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* Branching graph — a → b / c / d / e / f
|
|
3
|
+
* ------------------------------------------------------------
|
|
4
|
+
* ONE GraphDefinition. No router, no walk loop, no engine change.
|
|
5
|
+
* Built with the same tiny builders the fixtures use:
|
|
6
|
+
*
|
|
7
|
+
* node(id, run, extra) → { id, run, ...extra }
|
|
8
|
+
* constNode(id, output, x) → node(id, () => output, x)
|
|
9
|
+
* g(spec) → GraphDefinition
|
|
10
|
+
*
|
|
11
|
+
* The picture:
|
|
12
|
+
*
|
|
13
|
+
* a ── result === 1 ────────────▶ b ──(forces)──▶ f
|
|
14
|
+
* a ── test(result) === true ───▶ c
|
|
15
|
+
* a ── else ────────────────────▶ d
|
|
16
|
+
* a ── on throw ────────────────▶ e
|
|
17
|
+
* a ── on retry ────────────────▶ f
|
|
18
|
+
*
|
|
19
|
+
* HOW EACH ARROW IS EXPRESSED (engine-native only)
|
|
20
|
+
* ---------------------------------------------------------------
|
|
21
|
+
* fan-out + guards `next` is an UNCONDITIONAL fan-out, so a lists
|
|
22
|
+
* every candidate and each one starts with a guard
|
|
23
|
+
* that reads a's output: the acting branch does its
|
|
24
|
+
* work, the others return a SKIP marker.
|
|
25
|
+
* result === 1 → b guard: a.route === "b"
|
|
26
|
+
* test(result) → c guard: a.route === "c" (test(r) = r === 2)
|
|
27
|
+
* else → d guard: a.route === "d" (a computed "else")
|
|
28
|
+
* on throw → e real transition: a.onThrow = "e"
|
|
29
|
+
* on retry → f a returns route "f" when context.attempt > 1
|
|
30
|
+
* (the retry loop is internal, so the attempt
|
|
31
|
+
* number is the only per-attempt signal a node has)
|
|
32
|
+
* b forces f b returns void, then releases a gate that f is
|
|
33
|
+
* waiting on — f cannot act before b finished.
|
|
34
|
+
*
|
|
35
|
+
* WHY THE GATE FOR "b FORCES f"
|
|
36
|
+
* ---------------------------------------------------------------
|
|
37
|
+
* A node cannot schedule work. The only in-node state transition the
|
|
38
|
+
* engine offers is `throw` + `onThrow`, and here it cannot be used:
|
|
39
|
+
* • f must stay in a's fan-out, otherwise the retry arrow has no way
|
|
40
|
+
* to reach it (a recovered attempt succeeds, it cannot also throw);
|
|
41
|
+
* • if b threw to f, f would be activated a SECOND time, and f
|
|
42
|
+
* cannot tell that arrival apart from the fan-out one — both see
|
|
43
|
+
* the same outputs (b threw, so it has no output to check);
|
|
44
|
+
* • and b would be recorded FAILED, which is wrong for a jump.
|
|
45
|
+
* So b forces f by ORDER, not by scheduling: f is already activated by
|
|
46
|
+
* the fan-out, but it blocks on a gate that only b releases. The
|
|
47
|
+
* observable result is exactly "b runs, then f runs — because of b".
|
|
48
|
+
*
|
|
49
|
+
* COMPLETION
|
|
50
|
+
* ---------------------------------------------------------------
|
|
51
|
+
* b, c, d, e and f are declared terminal and the graph uses
|
|
52
|
+
* completionPolicy: "DRAIN", because skipping nodes also succeed and
|
|
53
|
+
* FIRST_TERMINAL could otherwise latch on a SKIP and drop real work.
|
|
54
|
+
*
|
|
55
|
+
* Run: node js/examples/branching-graph.js (after npm run build)
|
|
56
|
+
* ============================================================ */
|
|
57
|
+
|
|
58
|
+
import {
|
|
59
|
+
ExecutionReport,
|
|
60
|
+
GraphDefinition,
|
|
61
|
+
GraphExecutionEngine,
|
|
62
|
+
NodeContext,
|
|
63
|
+
NodeDefinition
|
|
64
|
+
} from "../src/automategraph";
|
|
65
|
+
|
|
66
|
+
/* =========================
|
|
67
|
+
* 1. Simple builders (same shape as tests/helpers.ts)
|
|
68
|
+
* ========================= */
|
|
69
|
+
|
|
70
|
+
type Extra = Omit<Partial<NodeDefinition>, "id" | "run">;
|
|
71
|
+
|
|
72
|
+
const node = (
|
|
73
|
+
id: string,
|
|
74
|
+
run: NodeDefinition["run"],
|
|
75
|
+
extra: Extra = {}
|
|
76
|
+
): NodeDefinition => ({ id, run, ...extra });
|
|
77
|
+
|
|
78
|
+
const constNode = (
|
|
79
|
+
id: string,
|
|
80
|
+
output: unknown,
|
|
81
|
+
extra: Extra = {}
|
|
82
|
+
): NodeDefinition => node(id, () => output, extra);
|
|
83
|
+
|
|
84
|
+
interface GraphSpec {
|
|
85
|
+
id?: string;
|
|
86
|
+
version?: string;
|
|
87
|
+
nodes: NodeDefinition[];
|
|
88
|
+
entryNodes: string[];
|
|
89
|
+
terminalNodes: string[];
|
|
90
|
+
errorPolicy?: GraphDefinition["errorPolicy"];
|
|
91
|
+
completionPolicy?: GraphDefinition["completionPolicy"];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const g = (spec: GraphSpec): GraphDefinition => ({
|
|
95
|
+
id: spec.id ?? "branching-graph",
|
|
96
|
+
version: spec.version ?? "1.0.0",
|
|
97
|
+
entryNodes: spec.entryNodes,
|
|
98
|
+
terminalNodes: spec.terminalNodes,
|
|
99
|
+
errorPolicy: spec.errorPolicy,
|
|
100
|
+
completionPolicy: spec.completionPolicy,
|
|
101
|
+
nodes: Object.fromEntries(spec.nodes.map(definition => [definition.id, definition]))
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/* =========================
|
|
105
|
+
* 2. The spec: a returns 1, 2 or 3; test() splits c from d
|
|
106
|
+
* ========================= */
|
|
107
|
+
|
|
108
|
+
export type ScenarioSeed = 1 | 2 | 3 | "throw" | "retry";
|
|
109
|
+
|
|
110
|
+
/** `test(result)` from the picture. */
|
|
111
|
+
export const test = (result: number): boolean => result === 2;
|
|
112
|
+
|
|
113
|
+
/** Marker returned by every branch that was activated but is not the chosen one. */
|
|
114
|
+
export interface Skipped {
|
|
115
|
+
readonly skipped: true;
|
|
116
|
+
readonly nodeId: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const SKIPPED = (nodeId: string): Skipped => ({ skipped: true, nodeId });
|
|
120
|
+
|
|
121
|
+
/** What node `a` publishes for its successors. */
|
|
122
|
+
interface ARoute {
|
|
123
|
+
readonly route: "b" | "c" | "d" | "f";
|
|
124
|
+
readonly result: number;
|
|
125
|
+
readonly attempts: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const routeOf = (context: NodeContext): ARoute | undefined =>
|
|
129
|
+
context.outputs.a as ARoute | undefined;
|
|
130
|
+
|
|
131
|
+
/** Observation only — nobody acts on this event; it just records the intent. */
|
|
132
|
+
export const FORCE_EVENT = "graph.force";
|
|
133
|
+
|
|
134
|
+
/* =========================
|
|
135
|
+
* 3. The graph
|
|
136
|
+
* ========================= */
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Builds the graph for one run. A fresh gate per run is what makes
|
|
140
|
+
* "b forces f" deterministic without a router.
|
|
141
|
+
*/
|
|
142
|
+
export function buildGraph(seed: ScenarioSeed): GraphDefinition {
|
|
143
|
+
/* --- the b → f gate (closure state, not engine state) --- */
|
|
144
|
+
let released = false;
|
|
145
|
+
let release: () => void = () => {};
|
|
146
|
+
const gate = new Promise<void>(resolve => { release = () => { released = true; resolve(); }; });
|
|
147
|
+
|
|
148
|
+
return g({
|
|
149
|
+
id: "branching-graph",
|
|
150
|
+
version: "1.0.0",
|
|
151
|
+
|
|
152
|
+
nodes: [
|
|
153
|
+
/* ---------------- a: produces the value, owns every arrow ---------------- */
|
|
154
|
+
node("a", (context: NodeContext): ARoute => {
|
|
155
|
+
// on throw → e (engine transition, declared as a.onThrow below)
|
|
156
|
+
if (seed === "throw") {
|
|
157
|
+
throw new Error("a: upstream exploded");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// on retry → f: the retry loop is internal, so a sees its own attempt
|
|
161
|
+
if (seed === "retry" && context.attempt === 1) {
|
|
162
|
+
throw new Error(`a: transient failure on attempt ${context.attempt}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const result = seed === "retry" ? 2 : seed;
|
|
166
|
+
|
|
167
|
+
if (context.attempt > 1) {
|
|
168
|
+
// recovered on a later attempt → the "on retry" arrow points at f
|
|
169
|
+
return { route: "f", result, attempts: context.attempt };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (result === 1) return { route: "b", result, attempts: context.attempt };
|
|
173
|
+
if (test(result)) return { route: "c", result, attempts: context.attempt };
|
|
174
|
+
return { route: "d", result, attempts: context.attempt }; // else
|
|
175
|
+
}, {
|
|
176
|
+
// unconditional fan-out: every candidate is activated, guards pick one
|
|
177
|
+
next: ["b", "c", "d", "f"],
|
|
178
|
+
onThrow: "e",
|
|
179
|
+
retry: { maxAttempts: 2, delayMs: 5 },
|
|
180
|
+
errorPolicy: "RETRY"
|
|
181
|
+
}),
|
|
182
|
+
|
|
183
|
+
/* ---------------- b: returns void, then forces f ---------------- */
|
|
184
|
+
node("b", async (context: NodeContext) => {
|
|
185
|
+
if (routeOf(context)?.route !== "b") return SKIPPED("b");
|
|
186
|
+
|
|
187
|
+
// b's work ... whatever it needs to do, it returns nothing
|
|
188
|
+
await context.emit(FORCE_EVENT, { from: "b", to: "f", reason: "b short-circuits to f" });
|
|
189
|
+
|
|
190
|
+
release(); // ← this is the force: f is unblocked by b, after b
|
|
191
|
+
return undefined; // void
|
|
192
|
+
}),
|
|
193
|
+
|
|
194
|
+
/* ---------------- c: test(result) === true ---------------- */
|
|
195
|
+
node("c", (context: NodeContext) =>
|
|
196
|
+
routeOf(context)?.route === "c"
|
|
197
|
+
? `c: handled result ${routeOf(context)!.result}`
|
|
198
|
+
: SKIPPED("c")
|
|
199
|
+
),
|
|
200
|
+
|
|
201
|
+
/* ---------------- d: else ---------------- */
|
|
202
|
+
node("d", (context: NodeContext) =>
|
|
203
|
+
routeOf(context)?.route === "d"
|
|
204
|
+
? `d: handled result ${routeOf(context)!.result}`
|
|
205
|
+
: SKIPPED("d")
|
|
206
|
+
),
|
|
207
|
+
|
|
208
|
+
/* ---------------- e: on throw ---------------- */
|
|
209
|
+
constNode("e", "e: failure route"),
|
|
210
|
+
|
|
211
|
+
/* ---------------- f: on retry, or forced by b ---------------- */
|
|
212
|
+
node("f", async (context: NodeContext) => {
|
|
213
|
+
const route = routeOf(context)?.route;
|
|
214
|
+
|
|
215
|
+
if (route === "f") return "f: reached after a retry (on retry arrow)";
|
|
216
|
+
if (route !== "b") return SKIPPED("f");
|
|
217
|
+
|
|
218
|
+
// route === "b": f only acts once b has forced it
|
|
219
|
+
if (!released) await gate;
|
|
220
|
+
return "f: forced by b (ran after b completed)";
|
|
221
|
+
})
|
|
222
|
+
],
|
|
223
|
+
|
|
224
|
+
entryNodes: ["a"],
|
|
225
|
+
terminalNodes: ["b", "c", "d", "e", "f"],
|
|
226
|
+
// skipping nodes succeed too, so let the graph drain instead of
|
|
227
|
+
// latching on the first success (FIRST_TERMINAL would drop work)
|
|
228
|
+
completionPolicy: "DRAIN"
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* =========================
|
|
233
|
+
* 4. Demo — one engine run per seed, no router
|
|
234
|
+
* ========================= */
|
|
235
|
+
|
|
236
|
+
const SCENARIOS: ReadonlyArray<{
|
|
237
|
+
label: string;
|
|
238
|
+
seed: ScenarioSeed;
|
|
239
|
+
/** branch nodes that must do real work, in order */
|
|
240
|
+
expectedActed: string[];
|
|
241
|
+
}> = [
|
|
242
|
+
{ label: "result === 1 → b, then b forces f", seed: 1, expectedActed: ["b", "f"] },
|
|
243
|
+
{ label: "test(result) → c", seed: 2, expectedActed: ["c"] },
|
|
244
|
+
{ label: "else → d", seed: 3, expectedActed: ["d"] },
|
|
245
|
+
{ label: "on throw → e", seed: "throw", expectedActed: ["e"] },
|
|
246
|
+
{ label: "on retry → f", seed: "retry", expectedActed: ["f"] }
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
const isSkipped = (output: unknown): boolean =>
|
|
250
|
+
typeof output === "object" && output !== null && (output as Skipped).skipped === true;
|
|
251
|
+
|
|
252
|
+
const show = (output: unknown): string =>
|
|
253
|
+
output === undefined ? "undefined (void)" : JSON.stringify(output);
|
|
254
|
+
|
|
255
|
+
const BRANCHES = ["b", "c", "d", "e", "f"] as const;
|
|
256
|
+
|
|
257
|
+
/** `acted` = the branch nodes that did real work (a is the dispatcher, not a branch). */
|
|
258
|
+
export function summarise(report: ExecutionReport): {
|
|
259
|
+
acted: string[];
|
|
260
|
+
status: string;
|
|
261
|
+
attempts: number;
|
|
262
|
+
} {
|
|
263
|
+
const dispatcher = report.results.find(result => result.nodeId === "a");
|
|
264
|
+
return {
|
|
265
|
+
acted: report.results
|
|
266
|
+
.filter(
|
|
267
|
+
result =>
|
|
268
|
+
BRANCHES.includes(result.nodeId as (typeof BRANCHES)[number]) &&
|
|
269
|
+
result.status === "SUCCEEDED" &&
|
|
270
|
+
!isSkipped(result.output)
|
|
271
|
+
)
|
|
272
|
+
.map(result => result.nodeId),
|
|
273
|
+
status: report.status,
|
|
274
|
+
attempts: dispatcher?.attempt ?? 0
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function main(): Promise<void> {
|
|
279
|
+
console.log("=".repeat(104));
|
|
280
|
+
console.log("branching graph — one GraphDefinition, no router, engine untouched");
|
|
281
|
+
console.log("=".repeat(104));
|
|
282
|
+
|
|
283
|
+
let failures = 0;
|
|
284
|
+
|
|
285
|
+
for (const scenario of SCENARIOS) {
|
|
286
|
+
const engine = new GraphExecutionEngine(buildGraph(scenario.seed), { lint: "error" });
|
|
287
|
+
const forces: unknown[] = [];
|
|
288
|
+
engine.events.on(FORCE_EVENT, payload => { forces.push(payload); });
|
|
289
|
+
|
|
290
|
+
const report = await engine.run();
|
|
291
|
+
const summary = summarise(report);
|
|
292
|
+
|
|
293
|
+
const actedOk = summary.acted.join(",") === scenario.expectedActed.join(",");
|
|
294
|
+
const statusOk = summary.status === "COMPLETED";
|
|
295
|
+
if (!actedOk || !statusOk) failures += 1;
|
|
296
|
+
|
|
297
|
+
console.log(`\n▶ seed = ${JSON.stringify(scenario.seed)} ${scenario.label}`);
|
|
298
|
+
console.log(
|
|
299
|
+
` status=${summary.status} a.attempt=${summary.attempts} ` +
|
|
300
|
+
`acted: ${summary.acted.join(" → ")} ${actedOk && statusOk ? "✓" : "✗"}`
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
for (const result of report.results) {
|
|
304
|
+
const tag = result.status === "SUCCEEDED" && !isSkipped(result.output)
|
|
305
|
+
? "ACTS "
|
|
306
|
+
: result.status === "SUCCEEDED"
|
|
307
|
+
? "skip "
|
|
308
|
+
: result.status.padEnd(5);
|
|
309
|
+
console.log(
|
|
310
|
+
` ${tag} ${result.nodeId} attempt=${result.attempt} output=${show(result.output)}`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (report.errors.length > 0) {
|
|
315
|
+
console.log(
|
|
316
|
+
` errors: ${report.errors
|
|
317
|
+
.map(error => `${error.code}@${error.nodeId ?? "-"}(handled=${error.handled})`)
|
|
318
|
+
.join(", ")}`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
if (forces.length > 0) {
|
|
322
|
+
console.log(` ${FORCE_EVENT}: ${JSON.stringify(forces[0])}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
console.log(`\n${"-".repeat(104)}`);
|
|
327
|
+
console.log(`scenarios: ${SCENARIOS.length - failures}/${SCENARIOS.length} matched`);
|
|
328
|
+
if (failures > 0) process.exitCode = 1;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (require.main === module) {
|
|
332
|
+
void main();
|
|
333
|
+
}
|