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,1134 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* FUNCTION REGISTRY PACK (v1.4)
|
|
3
|
+
* ------------------------------------------------------------
|
|
4
|
+
* <name, function> tables and lazy resolution:
|
|
5
|
+
*
|
|
6
|
+
* engine registry { functions } + engine.registerFunction()
|
|
7
|
+
* graph registry GraphDefinition.functions (wins on collision)
|
|
8
|
+
*
|
|
9
|
+
* NodeDefinition.run = function | "registeredName"
|
|
10
|
+
* NextEdge.test = function | "registeredName"
|
|
11
|
+
*
|
|
12
|
+
* Semantics under test:
|
|
13
|
+
* • resolution happens at EVAL time — a name missing at construction
|
|
14
|
+
* may still be registered before the node runs
|
|
15
|
+
* • a missing name is UNKNOWN_FUNCTION: handled = false, PERMANENT
|
|
16
|
+
* (never retried, never routed through onThrow/fallback)
|
|
17
|
+
* • an unresolvable edge test records the error ONCE per node+name,
|
|
18
|
+
* never matches, and lets the rest of the chain evaluate
|
|
19
|
+
* • observability: `fn` on results and events, { fingerprint, names }
|
|
20
|
+
* on graph.ready, the report and every checkpoint
|
|
21
|
+
* ============================================================ */
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
FunctionRegistry,
|
|
25
|
+
GraphExecutionEngine,
|
|
26
|
+
GraphValidationError,
|
|
27
|
+
Next,
|
|
28
|
+
NodeContext,
|
|
29
|
+
RegisteredFunction,
|
|
30
|
+
createFunctionRegistry,
|
|
31
|
+
edgeTests,
|
|
32
|
+
nodeFunctions
|
|
33
|
+
} from "../src/automategraph";
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
BIG,
|
|
37
|
+
SpyStorage,
|
|
38
|
+
capture,
|
|
39
|
+
constNode,
|
|
40
|
+
g,
|
|
41
|
+
logCollector,
|
|
42
|
+
node,
|
|
43
|
+
sleep
|
|
44
|
+
} from "./helpers";
|
|
45
|
+
import { suite } from "./harness";
|
|
46
|
+
|
|
47
|
+
const registry = suite("registry", "Function registry");
|
|
48
|
+
|
|
49
|
+
/* ------------------------------ assertion helpers ------------------------------ */
|
|
50
|
+
|
|
51
|
+
function show(value: unknown): string {
|
|
52
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
53
|
+
if (typeof value === "function") return "function";
|
|
54
|
+
try {
|
|
55
|
+
return JSON.stringify(value) ?? String(value);
|
|
56
|
+
} catch {
|
|
57
|
+
return String(value);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ok(condition: unknown, label: string): void {
|
|
62
|
+
if (!condition) throw new Error(label);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function eq(actual: unknown, expected: unknown, label: string): void {
|
|
66
|
+
if (actual !== expected) {
|
|
67
|
+
throw new Error(`${label}: expected ${show(expected)}, got ${show(actual)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function neq(actual: unknown, expected: unknown, label: string): void {
|
|
72
|
+
if (actual === expected) {
|
|
73
|
+
throw new Error(`${label}: expected a value different from ${show(expected)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stable(value: unknown): string {
|
|
78
|
+
return JSON.stringify(sortKeys(value)) ?? "undefined";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sortKeys(value: unknown): unknown {
|
|
82
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
83
|
+
if (value && typeof value === "object") {
|
|
84
|
+
return Object.fromEntries(
|
|
85
|
+
Object.keys(value as Record<string, unknown>)
|
|
86
|
+
.sort()
|
|
87
|
+
.map(key => [key, sortKeys((value as Record<string, unknown>)[key])])
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function deepEq(actual: unknown, expected: unknown, label: string): void {
|
|
94
|
+
const a = stable(actual);
|
|
95
|
+
const b = stable(expected);
|
|
96
|
+
|
|
97
|
+
if (a !== b) throw new Error(`${label}: expected ${b}, got ${a}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ------------------------------ 1. resolution ------------------------------ */
|
|
101
|
+
|
|
102
|
+
registry.t(
|
|
103
|
+
"a node whose run is a registered NAME executes that function",
|
|
104
|
+
async t => {
|
|
105
|
+
const calls: string[] = [];
|
|
106
|
+
|
|
107
|
+
const engine = new GraphExecutionEngine(
|
|
108
|
+
g({
|
|
109
|
+
nodes: [
|
|
110
|
+
node("a", "greet", { next: ["end"] }),
|
|
111
|
+
constNode("end", "done")
|
|
112
|
+
],
|
|
113
|
+
entryNodes: ["a"],
|
|
114
|
+
terminalNodes: ["end"]
|
|
115
|
+
}),
|
|
116
|
+
{
|
|
117
|
+
functions: {
|
|
118
|
+
greet: (context: NodeContext) => {
|
|
119
|
+
calls.push(`${context.nodeId}@${context.attempt}`);
|
|
120
|
+
return "hello";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const report = await engine.run();
|
|
127
|
+
|
|
128
|
+
eq(report.status, "COMPLETED", "status");
|
|
129
|
+
eq(calls.join(","), "a@1", "the registered function ran once");
|
|
130
|
+
eq(report.outputs.a, "hello", "output");
|
|
131
|
+
eq(report.results[0].fn, "greet", "NodeResult.fn");
|
|
132
|
+
|
|
133
|
+
t.log("finding: report.results[i].fn — not report.results[i].output — is the audit link to the registered source");
|
|
134
|
+
},
|
|
135
|
+
BIG
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
registry.t(
|
|
139
|
+
"a graph-local function overrides the engine's shared entry",
|
|
140
|
+
async t => {
|
|
141
|
+
const engine = new GraphExecutionEngine(
|
|
142
|
+
g({
|
|
143
|
+
nodes: [node("a", "shared", { next: ["end"] }), constNode("end", "done")],
|
|
144
|
+
entryNodes: ["a"],
|
|
145
|
+
terminalNodes: ["end"],
|
|
146
|
+
functions: { shared: () => "graph-local" }
|
|
147
|
+
}),
|
|
148
|
+
{ functions: { shared: () => "engine-level" } }
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const report = await engine.run();
|
|
152
|
+
|
|
153
|
+
eq(report.outputs.a, "graph-local", "graph entries win on collision");
|
|
154
|
+
eq(report.status, "COMPLETED", "status");
|
|
155
|
+
deepEq(report.registry?.names, ["shared"], "one merged table");
|
|
156
|
+
},
|
|
157
|
+
BIG
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
registry.t(
|
|
161
|
+
"graph-local functions work with no engine registry at all",
|
|
162
|
+
async t => {
|
|
163
|
+
const engine = new GraphExecutionEngine(
|
|
164
|
+
g({
|
|
165
|
+
nodes: [node("a", "local", { next: ["end"] }), constNode("end", "done")],
|
|
166
|
+
entryNodes: ["a"],
|
|
167
|
+
terminalNodes: ["end"],
|
|
168
|
+
functions: { local: () => 41 + 1 }
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const report = await engine.run();
|
|
173
|
+
|
|
174
|
+
eq(report.outputs.a, 42, "output");
|
|
175
|
+
deepEq(report.registry?.names, ["local"], "the graph table is the registry");
|
|
176
|
+
},
|
|
177
|
+
BIG
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
registry.t(
|
|
181
|
+
"registerFunction() after construction is honoured (resolution is lazy)",
|
|
182
|
+
async t => {
|
|
183
|
+
const engine = new GraphExecutionEngine(
|
|
184
|
+
g({
|
|
185
|
+
nodes: [node("a", "late", { next: ["end"] }), constNode("end", "done")],
|
|
186
|
+
entryNodes: ["a"],
|
|
187
|
+
terminalNodes: ["end"]
|
|
188
|
+
})
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
eq(engine.registry.names.length, 0, "empty before registering");
|
|
192
|
+
|
|
193
|
+
const chained = engine.registerFunction("late", () => "registered later");
|
|
194
|
+
|
|
195
|
+
ok(chained === engine, "registerFunction returns the engine");
|
|
196
|
+
eq(engine.registry.names.join(","), "late", "the registry view is invalidated");
|
|
197
|
+
|
|
198
|
+
const report = await engine.run();
|
|
199
|
+
|
|
200
|
+
eq(report.outputs.a, "registered later", "late registration is used");
|
|
201
|
+
ok(
|
|
202
|
+
(report.registry?.fingerprint ?? "").startsWith("fnr1:1:"),
|
|
203
|
+
`fingerprint format: ${report.registry?.fingerprint}`
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
t.log("finding: an engine with an empty registry reports no `registry` field, but registerFunction() makes it appear");
|
|
207
|
+
},
|
|
208
|
+
BIG
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
registry.t(
|
|
212
|
+
"inline functions and registered names coexist in one graph",
|
|
213
|
+
async t => {
|
|
214
|
+
const engine = new GraphExecutionEngine(
|
|
215
|
+
g({
|
|
216
|
+
nodes: [
|
|
217
|
+
node("a", () => "inline", { next: ["b"] }),
|
|
218
|
+
node("b", "named", { next: ["c"] }),
|
|
219
|
+
node("c", () => "inline again")
|
|
220
|
+
],
|
|
221
|
+
entryNodes: ["a"],
|
|
222
|
+
terminalNodes: ["c"]
|
|
223
|
+
}),
|
|
224
|
+
{ functions: { named: () => "from registry" } }
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const report = await engine.run();
|
|
228
|
+
|
|
229
|
+
eq(report.outputs.a, "inline", "a");
|
|
230
|
+
eq(report.outputs.b, "from registry", "b");
|
|
231
|
+
eq(report.outputs.c, "inline again", "c");
|
|
232
|
+
eq(report.results[0].fn, undefined, "inline activation has no fn");
|
|
233
|
+
eq(report.results[1].fn, "named", "named activation has fn");
|
|
234
|
+
eq(report.results[2].fn, undefined, "inline activation has no fn");
|
|
235
|
+
},
|
|
236
|
+
BIG
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
registry.t(
|
|
240
|
+
"a registered node function receives a full NodeContext",
|
|
241
|
+
async t => {
|
|
242
|
+
const seen: string[] = [];
|
|
243
|
+
|
|
244
|
+
const engine = new GraphExecutionEngine(
|
|
245
|
+
g({
|
|
246
|
+
nodes: [
|
|
247
|
+
constNode("a", "A", { next: ["b"] }),
|
|
248
|
+
node("b", "inspect", { next: ["end"] }),
|
|
249
|
+
constNode("end", "done")
|
|
250
|
+
],
|
|
251
|
+
entryNodes: ["a"],
|
|
252
|
+
terminalNodes: ["end"]
|
|
253
|
+
}),
|
|
254
|
+
{
|
|
255
|
+
functions: {
|
|
256
|
+
inspect: (context: NodeContext) => {
|
|
257
|
+
seen.push(
|
|
258
|
+
[
|
|
259
|
+
context.nodeId,
|
|
260
|
+
context.graphId,
|
|
261
|
+
context.attempt,
|
|
262
|
+
context.outputs.a,
|
|
263
|
+
context.signal.aborted
|
|
264
|
+
].join("|")
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
return context.outputs.a;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
await engine.run();
|
|
274
|
+
|
|
275
|
+
eq(seen.join(","), "b|test-graph|1|A|false", "context fields");
|
|
276
|
+
},
|
|
277
|
+
BIG
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
registry.t(
|
|
281
|
+
"a registered async function is awaited",
|
|
282
|
+
async t => {
|
|
283
|
+
const engine = new GraphExecutionEngine(
|
|
284
|
+
g({
|
|
285
|
+
nodes: [node("a", "slow", { next: ["end"] }), constNode("end", "done")],
|
|
286
|
+
entryNodes: ["a"],
|
|
287
|
+
terminalNodes: ["end"]
|
|
288
|
+
}),
|
|
289
|
+
{
|
|
290
|
+
functions: {
|
|
291
|
+
slow: async () => {
|
|
292
|
+
await sleep(5);
|
|
293
|
+
return "awaited";
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const report = await engine.run();
|
|
300
|
+
|
|
301
|
+
eq(report.outputs.a, "awaited", "output");
|
|
302
|
+
eq(report.status, "COMPLETED", "status");
|
|
303
|
+
},
|
|
304
|
+
BIG
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
registry.t(
|
|
308
|
+
"a registered node can route explicitly with Next and with context.goto",
|
|
309
|
+
async t => {
|
|
310
|
+
const build = () =>
|
|
311
|
+
g({
|
|
312
|
+
nodes: [
|
|
313
|
+
node("a", "router", { next: ["b"] }),
|
|
314
|
+
node("b", "voidJump", { next: ["c"] }),
|
|
315
|
+
constNode("c", "C"),
|
|
316
|
+
constNode("end", "E")
|
|
317
|
+
],
|
|
318
|
+
entryNodes: ["a"],
|
|
319
|
+
terminalNodes: ["end"]
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const engine = new GraphExecutionEngine(build(), {
|
|
323
|
+
functions: {
|
|
324
|
+
/* a registered function is real code: it can build a Next */
|
|
325
|
+
router: () => new Next("end", "routed-by-name"),
|
|
326
|
+
voidJump: (context: NodeContext) => {
|
|
327
|
+
context.goto("c");
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const captured = capture(engine, ["node.succeeded"]);
|
|
334
|
+
const report = await engine.run();
|
|
335
|
+
|
|
336
|
+
eq(report.status, "COMPLETED", "status");
|
|
337
|
+
deepEq(
|
|
338
|
+
report.results.map(result => `${result.nodeId}:${result.fn ?? "-"}`),
|
|
339
|
+
["a:router", "end:-"],
|
|
340
|
+
"names recorded per activation"
|
|
341
|
+
);
|
|
342
|
+
eq(
|
|
343
|
+
report.outputs.a,
|
|
344
|
+
"routed-by-name",
|
|
345
|
+
"the Next value is recorded as the ROUTER's output (matchers see it)"
|
|
346
|
+
);
|
|
347
|
+
eq(report.outputs.end, "E", "the target node still runs its own function");
|
|
348
|
+
eq(
|
|
349
|
+
(captured[0].payload as { route?: { via: string } }).route?.via,
|
|
350
|
+
"next-class",
|
|
351
|
+
"the route is reported for a named function too"
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
/* the goto variant, in its own engine */
|
|
355
|
+
const gotoEngine = new GraphExecutionEngine(build(), {
|
|
356
|
+
functions: {
|
|
357
|
+
router: () => "goes-to-b",
|
|
358
|
+
voidJump: (context: NodeContext) => {
|
|
359
|
+
context.goto("end");
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const gotoReport = await gotoEngine.run();
|
|
366
|
+
|
|
367
|
+
deepEq(
|
|
368
|
+
gotoReport.results.map(result => result.nodeId),
|
|
369
|
+
["a", "b", "end"],
|
|
370
|
+
"a registered void node can jump with goto"
|
|
371
|
+
);
|
|
372
|
+
eq(gotoReport.outputs.end, "E", "the jump reached the terminal");
|
|
373
|
+
|
|
374
|
+
t.log("finding: `new Next(target, value)` sets the ROUTER's output to `value` (that is what edge matchers and context.outputs see); the target node still executes its own function");
|
|
375
|
+
},
|
|
376
|
+
BIG
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
registry.t(
|
|
380
|
+
"timeouts and retries apply to registered functions exactly as to inline ones",
|
|
381
|
+
async t => {
|
|
382
|
+
const engine = new GraphExecutionEngine(
|
|
383
|
+
g({
|
|
384
|
+
nodes: [
|
|
385
|
+
node("a", "slow", { next: ["end"], timeoutMs: 20 }),
|
|
386
|
+
constNode("end", "done")
|
|
387
|
+
],
|
|
388
|
+
entryNodes: ["a"],
|
|
389
|
+
terminalNodes: ["end"]
|
|
390
|
+
}),
|
|
391
|
+
{
|
|
392
|
+
functions: {
|
|
393
|
+
slow: async () => {
|
|
394
|
+
await sleep(150);
|
|
395
|
+
return "too late";
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
const report = await engine.run();
|
|
402
|
+
|
|
403
|
+
eq(report.results[0].status, "TIMED_OUT", "status");
|
|
404
|
+
eq(report.results[0].fn, "slow", "fn is recorded on a failed result too");
|
|
405
|
+
eq(report.errors[0].code, "NODE_TIMEOUT", "error code");
|
|
406
|
+
eq(report.status, "FAILED", "no terminal reached");
|
|
407
|
+
},
|
|
408
|
+
BIG
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
/* ------------------------------ 2. missing names ------------------------------ */
|
|
412
|
+
|
|
413
|
+
registry.t(
|
|
414
|
+
"a missing run name records UNKNOWN_FUNCTION (unhandled) and schedules nothing",
|
|
415
|
+
async t => {
|
|
416
|
+
const log = logCollector();
|
|
417
|
+
|
|
418
|
+
const engine = new GraphExecutionEngine(
|
|
419
|
+
g({
|
|
420
|
+
nodes: [node("a", "ghost", { next: ["end"] }), constNode("end", "done")],
|
|
421
|
+
entryNodes: ["a"],
|
|
422
|
+
terminalNodes: ["end"]
|
|
423
|
+
}),
|
|
424
|
+
{ onLog: log.onLog }
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const captured = capture(engine, [
|
|
428
|
+
"node.started",
|
|
429
|
+
"node.succeeded",
|
|
430
|
+
"node.failed"
|
|
431
|
+
]);
|
|
432
|
+
|
|
433
|
+
const report = await engine.run();
|
|
434
|
+
|
|
435
|
+
eq(report.status, "FAILED", "status");
|
|
436
|
+
eq(report.results.length, 1, "only the failing activation exists");
|
|
437
|
+
|
|
438
|
+
const failure = report.results[0];
|
|
439
|
+
|
|
440
|
+
eq(failure.nodeId, "a", "nodeId");
|
|
441
|
+
eq(failure.status, "FAILED", "status");
|
|
442
|
+
eq(failure.attempt, 1, "attempt");
|
|
443
|
+
eq(failure.fn, "ghost", "fn");
|
|
444
|
+
|
|
445
|
+
const error = report.errors[0];
|
|
446
|
+
|
|
447
|
+
eq(error.code, "UNKNOWN_FUNCTION", "code");
|
|
448
|
+
eq(error.handled, false, "handled");
|
|
449
|
+
eq(error.nodeId, "a", "nodeId");
|
|
450
|
+
deepEq(
|
|
451
|
+
(error.details as { available: string[] }).available,
|
|
452
|
+
[],
|
|
453
|
+
"the error carries the available names"
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
deepEq(
|
|
457
|
+
captured.map(entry => entry.event),
|
|
458
|
+
["node.started", "node.failed"],
|
|
459
|
+
"the activation starts and fails — never succeeds"
|
|
460
|
+
);
|
|
461
|
+
eq(
|
|
462
|
+
(captured[0].payload as { fn?: string }).fn,
|
|
463
|
+
"ghost",
|
|
464
|
+
"node.started names the function"
|
|
465
|
+
);
|
|
466
|
+
eq((captured[1].payload as { fn?: string }).fn, "ghost", "node.failed too");
|
|
467
|
+
ok(
|
|
468
|
+
log.lines.some(line => line.message.includes("unregistered function")),
|
|
469
|
+
"the defect is logged"
|
|
470
|
+
);
|
|
471
|
+
},
|
|
472
|
+
BIG
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
registry.t(
|
|
476
|
+
"a missing run name is PERMANENT: no retry, no onThrow, no fallback",
|
|
477
|
+
async t => {
|
|
478
|
+
const ran: string[] = [];
|
|
479
|
+
|
|
480
|
+
const engine = new GraphExecutionEngine(
|
|
481
|
+
g({
|
|
482
|
+
nodes: [
|
|
483
|
+
node("a", "ghost", {
|
|
484
|
+
onThrow: "rescue",
|
|
485
|
+
fallback: "rescue",
|
|
486
|
+
retry: { maxAttempts: 3, delayMs: 1 },
|
|
487
|
+
errorPolicy: "RETRY"
|
|
488
|
+
}),
|
|
489
|
+
node("rescue", () => {
|
|
490
|
+
ran.push("rescue");
|
|
491
|
+
return "rescued";
|
|
492
|
+
})
|
|
493
|
+
],
|
|
494
|
+
entryNodes: ["a"],
|
|
495
|
+
terminalNodes: ["rescue"]
|
|
496
|
+
})
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const report = await engine.run();
|
|
500
|
+
|
|
501
|
+
eq(report.results[0].attempt, 1, "UNKNOWN_FUNCTION is not retried");
|
|
502
|
+
eq(
|
|
503
|
+
report.errors.filter(error => error.code === "UNKNOWN_FUNCTION").length,
|
|
504
|
+
1,
|
|
505
|
+
"exactly one error record"
|
|
506
|
+
);
|
|
507
|
+
eq(ran.length, 0, "neither onThrow nor fallback ran");
|
|
508
|
+
eq(report.results.length, 1, "nothing was scheduled");
|
|
509
|
+
eq(report.status, "FAILED", "status");
|
|
510
|
+
|
|
511
|
+
t.log("finding: retry/onThrow/fallback are error RECOVERY; a name that was never registered is a wiring defect, so the engine refuses to recover from it");
|
|
512
|
+
},
|
|
513
|
+
BIG
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
registry.t(
|
|
517
|
+
"a missing run name ignores FALLBACK policy as well",
|
|
518
|
+
async t => {
|
|
519
|
+
const engine = new GraphExecutionEngine(
|
|
520
|
+
g({
|
|
521
|
+
nodes: [node("a", "ghost", { fallback: "rescue" }), constNode("rescue", "rescued")],
|
|
522
|
+
entryNodes: ["a"],
|
|
523
|
+
terminalNodes: ["rescue"],
|
|
524
|
+
errorPolicy: "FALLBACK"
|
|
525
|
+
})
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
const report = await engine.run();
|
|
529
|
+
|
|
530
|
+
eq(report.errors[0].code, "UNKNOWN_FUNCTION", "code");
|
|
531
|
+
eq(report.errors[0].handled, false, "handled");
|
|
532
|
+
eq(report.results.length, 1, "the fallback target was not activated");
|
|
533
|
+
eq(report.status, "FAILED", "status");
|
|
534
|
+
},
|
|
535
|
+
BIG
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
registry.t(
|
|
539
|
+
"registering the missing name mid-run lets the next activation succeed",
|
|
540
|
+
async t => {
|
|
541
|
+
const engine: GraphExecutionEngine = new GraphExecutionEngine(
|
|
542
|
+
g({
|
|
543
|
+
nodes: [
|
|
544
|
+
node("a", "first", { next: ["b"] }),
|
|
545
|
+
node("b", "second", { next: ["end"] }),
|
|
546
|
+
constNode("end", "done")
|
|
547
|
+
],
|
|
548
|
+
entryNodes: ["a"],
|
|
549
|
+
terminalNodes: ["end"]
|
|
550
|
+
}),
|
|
551
|
+
{
|
|
552
|
+
functions: {
|
|
553
|
+
first: () => {
|
|
554
|
+
/* `second` has not been resolved yet — resolution is per eval */
|
|
555
|
+
engine.registerFunction("second", () => "registered in flight");
|
|
556
|
+
return "A";
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
const report = await engine.run();
|
|
563
|
+
|
|
564
|
+
eq(report.outputs.b, "registered in flight", "output");
|
|
565
|
+
eq(report.registry?.names.join(","), "first,second", "names");
|
|
566
|
+
eq(report.status, "COMPLETED", "status");
|
|
567
|
+
},
|
|
568
|
+
BIG
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
/* ------------------------------ 3. edge tests by name ------------------------------ */
|
|
572
|
+
|
|
573
|
+
registry.t(
|
|
574
|
+
"a named edge test decides the branch, and result + test still AND",
|
|
575
|
+
async t => {
|
|
576
|
+
const parts = (test: string) => [
|
|
577
|
+
node("a", () => "high", {
|
|
578
|
+
next: [
|
|
579
|
+
{ test, nextNode: "yes" },
|
|
580
|
+
{ nextNode: "no" }
|
|
581
|
+
]
|
|
582
|
+
}),
|
|
583
|
+
constNode("yes", "YES"),
|
|
584
|
+
constNode("no", "NO")
|
|
585
|
+
];
|
|
586
|
+
|
|
587
|
+
const yes = await new GraphExecutionEngine(
|
|
588
|
+
g({
|
|
589
|
+
nodes: parts("isHigh"),
|
|
590
|
+
entryNodes: ["a"],
|
|
591
|
+
terminalNodes: ["yes", "no"]
|
|
592
|
+
}),
|
|
593
|
+
{ functions: { isHigh: (value: unknown) => value === "high" } }
|
|
594
|
+
).run();
|
|
595
|
+
|
|
596
|
+
eq(yes.outputs.yes, "YES", "the named test matched");
|
|
597
|
+
eq(yes.results.length, 2, "one branch only");
|
|
598
|
+
|
|
599
|
+
const and = await new GraphExecutionEngine(
|
|
600
|
+
g({
|
|
601
|
+
nodes: [
|
|
602
|
+
node("a", () => "low", {
|
|
603
|
+
next: [
|
|
604
|
+
{ result: "high", test: "isLow", nextNode: "yes" },
|
|
605
|
+
{ nextNode: "no" }
|
|
606
|
+
]
|
|
607
|
+
}),
|
|
608
|
+
constNode("yes", "YES"),
|
|
609
|
+
constNode("no", "NO")
|
|
610
|
+
],
|
|
611
|
+
entryNodes: ["a"],
|
|
612
|
+
terminalNodes: ["yes", "no"]
|
|
613
|
+
}),
|
|
614
|
+
{ functions: { isLow: (value: unknown) => value === "low" } }
|
|
615
|
+
).run();
|
|
616
|
+
|
|
617
|
+
eq(and.outputs.no, "NO", "result must AND with the named test");
|
|
618
|
+
eq(and.results.length, 2, "one branch only");
|
|
619
|
+
},
|
|
620
|
+
BIG
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
registry.t(
|
|
624
|
+
"inline and named tests mix in one chain, in declaration order",
|
|
625
|
+
async t => {
|
|
626
|
+
const engine = new GraphExecutionEngine(
|
|
627
|
+
g({
|
|
628
|
+
nodes: [
|
|
629
|
+
node("a", () => 7, {
|
|
630
|
+
next: [
|
|
631
|
+
{ test: "isSeven", nextNode: "named" },
|
|
632
|
+
{ test: (value: unknown) => value === 7, nextNode: "inline" },
|
|
633
|
+
{ nextNode: "else" }
|
|
634
|
+
]
|
|
635
|
+
}),
|
|
636
|
+
constNode("named", "NAMED"),
|
|
637
|
+
constNode("inline", "INLINE"),
|
|
638
|
+
constNode("else", "ELSE")
|
|
639
|
+
],
|
|
640
|
+
entryNodes: ["a"],
|
|
641
|
+
terminalNodes: ["named", "inline", "else"]
|
|
642
|
+
}),
|
|
643
|
+
{ functions: { isSeven: (value: unknown) => value === 7 } }
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
const report = await engine.run();
|
|
647
|
+
|
|
648
|
+
eq(report.outputs.named, "NAMED", "first match wins");
|
|
649
|
+
eq(report.results.length, 2, "one branch only");
|
|
650
|
+
},
|
|
651
|
+
BIG
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
registry.t(
|
|
655
|
+
"an unresolvable edge test never matches, is recorded once, and lets the else edge run",
|
|
656
|
+
async t => {
|
|
657
|
+
const engine = new GraphExecutionEngine(
|
|
658
|
+
g({
|
|
659
|
+
nodes: [
|
|
660
|
+
node("a", () => "value", {
|
|
661
|
+
next: [
|
|
662
|
+
{ test: "missing", nextNode: "never" },
|
|
663
|
+
{ nextNode: "else" }
|
|
664
|
+
]
|
|
665
|
+
}),
|
|
666
|
+
constNode("never", "NEVER"),
|
|
667
|
+
constNode("else", "ELSE")
|
|
668
|
+
],
|
|
669
|
+
entryNodes: ["a"],
|
|
670
|
+
terminalNodes: ["never", "else"]
|
|
671
|
+
})
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
const report = await engine.run();
|
|
675
|
+
|
|
676
|
+
const unknown = report.errors.filter(
|
|
677
|
+
error => error.code === "UNKNOWN_FUNCTION"
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
eq(unknown.length, 1, "one record for the missing test");
|
|
681
|
+
eq(unknown[0].handled, false, "handled");
|
|
682
|
+
eq(
|
|
683
|
+
(unknown[0].details as { kind: string }).kind,
|
|
684
|
+
"test",
|
|
685
|
+
"the record identifies the position"
|
|
686
|
+
);
|
|
687
|
+
eq(unknown[0].nodeId, "a", "the defect is attributed to the source node");
|
|
688
|
+
|
|
689
|
+
eq(report.outputs.else, "ELSE", "the else edge still fires");
|
|
690
|
+
eq(report.results.length, 2, "the unresolvable edge scheduled nothing");
|
|
691
|
+
eq(report.status, "FAILED", "the unhandled defect fails the run");
|
|
692
|
+
|
|
693
|
+
t.log("finding: an unresolvable test is loud but not fatal to ROUTING — the rest of the chain still evaluates; the run itself settles FAILED because the error stays unhandled (terminal-reached + unhandled error = FAILED, as in v1.2)");
|
|
694
|
+
},
|
|
695
|
+
BIG
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
registry.t(
|
|
699
|
+
"a repeated evaluation reports a missing test only once (retry path)",
|
|
700
|
+
async t => {
|
|
701
|
+
const engine = new GraphExecutionEngine(
|
|
702
|
+
g({
|
|
703
|
+
id: "retry-with-missing-test",
|
|
704
|
+
nodes: [
|
|
705
|
+
node("a", "looper", {
|
|
706
|
+
next: [{ test: "missing", nextNode: "b" }, { nextNode: "b" }],
|
|
707
|
+
retry: { maxAttempts: 3 },
|
|
708
|
+
errorPolicy: "RETRY"
|
|
709
|
+
}),
|
|
710
|
+
constNode("b", "B")
|
|
711
|
+
],
|
|
712
|
+
entryNodes: ["a"],
|
|
713
|
+
terminalNodes: ["b"]
|
|
714
|
+
}),
|
|
715
|
+
{
|
|
716
|
+
functions: {
|
|
717
|
+
looper: (context: NodeContext) => {
|
|
718
|
+
if (context.attempt === 1) throw new Error("transient");
|
|
719
|
+
return "ok";
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
const report = await engine.run();
|
|
726
|
+
|
|
727
|
+
eq(report.results[0].attempt, 2, "the node really retried");
|
|
728
|
+
eq(
|
|
729
|
+
report.errors.filter(error => error.code === "UNKNOWN_FUNCTION").length,
|
|
730
|
+
1,
|
|
731
|
+
"still exactly one record for node a + missing"
|
|
732
|
+
);
|
|
733
|
+
eq(
|
|
734
|
+
report.status,
|
|
735
|
+
"FAILED",
|
|
736
|
+
"the retry recovered, but the unhandled defect still fails the run"
|
|
737
|
+
);
|
|
738
|
+
},
|
|
739
|
+
BIG
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
registry.t(
|
|
743
|
+
"lint treats a NAMED test as conditional — no false UNREACHABLE_EDGE",
|
|
744
|
+
async t => {
|
|
745
|
+
const engine = new GraphExecutionEngine(
|
|
746
|
+
g({
|
|
747
|
+
nodes: [
|
|
748
|
+
node("a", () => "x", {
|
|
749
|
+
next: [
|
|
750
|
+
{ test: "isHigh", nextNode: "b" },
|
|
751
|
+
{ nextNode: "c" }
|
|
752
|
+
]
|
|
753
|
+
}),
|
|
754
|
+
constNode("b", "B"),
|
|
755
|
+
constNode("c", "C")
|
|
756
|
+
],
|
|
757
|
+
entryNodes: ["a"],
|
|
758
|
+
terminalNodes: ["b", "c"]
|
|
759
|
+
}),
|
|
760
|
+
{ functions: { isHigh: () => false }, lint: "error" }
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
const report = await engine.run();
|
|
764
|
+
|
|
765
|
+
deepEq(report.lintIssues, [], "no lint issue for a named matcher");
|
|
766
|
+
eq(report.status, "COMPLETED", "status");
|
|
767
|
+
eq(report.outputs.c, "C", "the else edge is reachable");
|
|
768
|
+
|
|
769
|
+
t.log("finding: regression guard — before edgeHasMatcher() was shared, a named test looked like an else edge to the lint and every later edge was reported UNREACHABLE_EDGE");
|
|
770
|
+
},
|
|
771
|
+
BIG
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
registry.t(
|
|
775
|
+
"a PARALLEL edge with a named test still warns PARALLEL_EDGE_MATCHER_IGNORED",
|
|
776
|
+
async t => {
|
|
777
|
+
const engine = new GraphExecutionEngine(
|
|
778
|
+
g({
|
|
779
|
+
nodes: [
|
|
780
|
+
node("a", () => "x", {
|
|
781
|
+
next: [
|
|
782
|
+
{ test: "isHigh", nextNode: "b", parallel: true },
|
|
783
|
+
{ nextNode: "c" }
|
|
784
|
+
]
|
|
785
|
+
}),
|
|
786
|
+
constNode("b", "B"),
|
|
787
|
+
constNode("c", "C")
|
|
788
|
+
],
|
|
789
|
+
entryNodes: ["a"],
|
|
790
|
+
terminalNodes: ["b", "c"],
|
|
791
|
+
completionPolicy: "DRAIN"
|
|
792
|
+
}),
|
|
793
|
+
{ functions: { isHigh: () => false }, lint: "warn" }
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
const report = await engine.run();
|
|
797
|
+
const codes = report.lintIssues.map(issue => issue.code);
|
|
798
|
+
|
|
799
|
+
ok(codes.includes("PARALLEL_EDGE_MATCHER_IGNORED"), codes.join(","));
|
|
800
|
+
eq(report.outputs.b, "B", "the parallel edge fires despite the false test");
|
|
801
|
+
eq(report.outputs.c, "C", "the exclusive chain still ran");
|
|
802
|
+
},
|
|
803
|
+
BIG
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
/* ------------------------------ 4. observability ------------------------------ */
|
|
807
|
+
|
|
808
|
+
registry.t(
|
|
809
|
+
"graph.ready and the report carry { fingerprint, names }; both are absent without a registry",
|
|
810
|
+
async t => {
|
|
811
|
+
const withRegistry = new GraphExecutionEngine(
|
|
812
|
+
g({
|
|
813
|
+
nodes: [node("a", "fn2", { next: ["b"] }), constNode("b", "B")],
|
|
814
|
+
entryNodes: ["a"],
|
|
815
|
+
terminalNodes: ["b"]
|
|
816
|
+
}),
|
|
817
|
+
{ functions: { fn1: () => 1, fn2: () => 2 } }
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
const captured = capture(withRegistry, ["graph.ready"]);
|
|
821
|
+
const report = await withRegistry.run();
|
|
822
|
+
|
|
823
|
+
const readyRegistry = (
|
|
824
|
+
captured[0].payload as { registry?: { names: string[] } }
|
|
825
|
+
).registry;
|
|
826
|
+
|
|
827
|
+
deepEq(readyRegistry?.names, ["fn1", "fn2"], "names are sorted");
|
|
828
|
+
deepEq(report.registry?.names, ["fn1", "fn2"], "report names");
|
|
829
|
+
eq(
|
|
830
|
+
report.registry?.fingerprint,
|
|
831
|
+
withRegistry.registry.fingerprint,
|
|
832
|
+
"one merged identity everywhere"
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
const bare = new GraphExecutionEngine(
|
|
836
|
+
g({
|
|
837
|
+
nodes: [constNode("a", "A")],
|
|
838
|
+
entryNodes: ["a"],
|
|
839
|
+
terminalNodes: ["a"]
|
|
840
|
+
})
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
const bareReport = await bare.run();
|
|
844
|
+
|
|
845
|
+
eq(bareReport.registry, undefined, "no registry configured ⇒ no field");
|
|
846
|
+
eq(bare.registry.names.length, 0, "empty registry");
|
|
847
|
+
eq(bare.registry.fingerprint.startsWith("fnr1:0:"), true, "empty fingerprint");
|
|
848
|
+
},
|
|
849
|
+
BIG
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
registry.t(
|
|
853
|
+
"every checkpoint carries the registry identity",
|
|
854
|
+
async t => {
|
|
855
|
+
const storage = new SpyStorage();
|
|
856
|
+
|
|
857
|
+
const engine = new GraphExecutionEngine(
|
|
858
|
+
g({
|
|
859
|
+
nodes: [node("a", "one", { next: ["b"] }), node("b", "two")],
|
|
860
|
+
entryNodes: ["a"],
|
|
861
|
+
terminalNodes: ["b"]
|
|
862
|
+
}),
|
|
863
|
+
{ functions: { one: () => 1, two: () => 2 }, storage }
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
const report = await engine.run();
|
|
867
|
+
const snapshot = await storage.load(report.executionId);
|
|
868
|
+
|
|
869
|
+
deepEq(snapshot?.registry?.names, ["one", "two"], "checkpoint names");
|
|
870
|
+
eq(
|
|
871
|
+
snapshot?.registry?.fingerprint,
|
|
872
|
+
report.registry?.fingerprint,
|
|
873
|
+
"checkpoint and report agree"
|
|
874
|
+
);
|
|
875
|
+
deepEq(
|
|
876
|
+
snapshot?.results.map(result => result.fn),
|
|
877
|
+
["one", "two"],
|
|
878
|
+
"checkpoint results carry fn"
|
|
879
|
+
);
|
|
880
|
+
},
|
|
881
|
+
BIG
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
/* ------------------------------ 5. registry object + validation ------------------------------ */
|
|
885
|
+
|
|
886
|
+
registry.t(
|
|
887
|
+
"FunctionRegistry: register/extend/has/get/names/snapshot stay consistent",
|
|
888
|
+
async t => {
|
|
889
|
+
const table = createFunctionRegistry({ beta: () => 1 });
|
|
890
|
+
|
|
891
|
+
eq(table.size, 1, "size");
|
|
892
|
+
eq(table.has("beta"), true, "has");
|
|
893
|
+
eq(table.has("gamma"), false, "has (absent)");
|
|
894
|
+
|
|
895
|
+
table.register("alpha", () => 2).extend({ gamma: () => 3 });
|
|
896
|
+
|
|
897
|
+
deepEq(table.names(), ["alpha", "beta", "gamma"], "names sorted");
|
|
898
|
+
eq(table.size, 3, "size after extend");
|
|
899
|
+
deepEq(
|
|
900
|
+
Object.keys(table.snapshot()).sort(),
|
|
901
|
+
["alpha", "beta", "gamma"],
|
|
902
|
+
"snapshot keys"
|
|
903
|
+
);
|
|
904
|
+
eq(typeof table.get("alpha"), "function", "get");
|
|
905
|
+
eq(table.get("nope"), undefined, "get (absent)");
|
|
906
|
+
ok(FunctionRegistry.from(table) === table, "from() passes instances through");
|
|
907
|
+
eq(FunctionRegistry.from({ z: () => 1 }).names().join(","), "z", "from() builds");
|
|
908
|
+
|
|
909
|
+
table.extend({ beta: () => 99 });
|
|
910
|
+
eq((table.get("beta") as () => number)(), 99, "later registration wins");
|
|
911
|
+
eq(table.size, 3, "extend does not duplicate keys");
|
|
912
|
+
},
|
|
913
|
+
BIG
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
registry.t(
|
|
917
|
+
"the fingerprint is stable, order-independent, and source-sensitive",
|
|
918
|
+
async t => {
|
|
919
|
+
const source = () => "same";
|
|
920
|
+
const sameTextElsewhere = () => "same";
|
|
921
|
+
const differentBody = () => "same!";
|
|
922
|
+
|
|
923
|
+
const first = createFunctionRegistry({ a: source, b: () => 2 });
|
|
924
|
+
const reordered = createFunctionRegistry({ b: () => 2, a: source });
|
|
925
|
+
const otherLiteral = createFunctionRegistry({
|
|
926
|
+
a: sameTextElsewhere,
|
|
927
|
+
b: () => 2
|
|
928
|
+
});
|
|
929
|
+
const changedBody = createFunctionRegistry({ a: differentBody, b: () => 2 });
|
|
930
|
+
const changedValue = createFunctionRegistry({ a: source, b: () => 3 });
|
|
931
|
+
|
|
932
|
+
eq(
|
|
933
|
+
first.fingerprint(),
|
|
934
|
+
reordered.fingerprint(),
|
|
935
|
+
"input key order does not matter"
|
|
936
|
+
);
|
|
937
|
+
eq(first.fingerprint(), first.fingerprint(), "stable in-process");
|
|
938
|
+
eq(
|
|
939
|
+
first.fingerprint(),
|
|
940
|
+
otherLiteral.fingerprint(),
|
|
941
|
+
"identical SOURCE TEXT fingerprints identically (closures are not inspected)"
|
|
942
|
+
);
|
|
943
|
+
neq(
|
|
944
|
+
first.fingerprint(),
|
|
945
|
+
changedBody.fingerprint(),
|
|
946
|
+
"a different source changes the identity"
|
|
947
|
+
);
|
|
948
|
+
neq(
|
|
949
|
+
first.fingerprint(),
|
|
950
|
+
changedValue.fingerprint(),
|
|
951
|
+
"a different implementation changes the identity"
|
|
952
|
+
);
|
|
953
|
+
eq(first.info().names.join(","), "a,b", "info names");
|
|
954
|
+
|
|
955
|
+
t.log("finding: the fingerprint covers names + function SOURCE TEXT — two closures of the same source share it, while a captured value that changes at runtime does not show up");
|
|
956
|
+
},
|
|
957
|
+
BIG
|
|
958
|
+
);
|
|
959
|
+
|
|
960
|
+
registry.t(
|
|
961
|
+
"invalid registrations fail immediately",
|
|
962
|
+
async t => {
|
|
963
|
+
const table = createFunctionRegistry();
|
|
964
|
+
|
|
965
|
+
const cases: Array<[string, unknown, RegExp]> = [
|
|
966
|
+
["", () => 1, /identifier-like string/],
|
|
967
|
+
["1bad", () => 1, /identifier-like string/],
|
|
968
|
+
["has space", () => 1, /identifier-like string/],
|
|
969
|
+
["ok", 42, /must be a function/]
|
|
970
|
+
];
|
|
971
|
+
|
|
972
|
+
for (const [name, value, pattern] of cases) {
|
|
973
|
+
try {
|
|
974
|
+
table.register(name, value as RegisteredFunction);
|
|
975
|
+
throw new Error(`expected a throw for ${JSON.stringify(name)}`);
|
|
976
|
+
} catch (error) {
|
|
977
|
+
if (!(error instanceof GraphValidationError)) throw error;
|
|
978
|
+
if (!pattern.test(error.message)) {
|
|
979
|
+
throw new Error(`got "${error.message}", expected ${pattern}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
table.register("vendor:retry", () => 1);
|
|
985
|
+
table.register("order.chargeCard", () => 2);
|
|
986
|
+
|
|
987
|
+
deepEq(table.names(), ["order.chargeCard", "vendor:retry"], "namespaced keys");
|
|
988
|
+
},
|
|
989
|
+
BIG
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
registry.t(
|
|
993
|
+
"malformed run values and graph tables are rejected at construction",
|
|
994
|
+
async t => {
|
|
995
|
+
const cases: Array<[unknown, RegExp]> = [
|
|
996
|
+
[42, /requires a run function/],
|
|
997
|
+
["not a name!", /invalid registered function name/]
|
|
998
|
+
];
|
|
999
|
+
|
|
1000
|
+
for (const [run, pattern] of cases) {
|
|
1001
|
+
try {
|
|
1002
|
+
new GraphExecutionEngine(
|
|
1003
|
+
g({
|
|
1004
|
+
nodes: [node("a", run as never), constNode("end", "E")],
|
|
1005
|
+
entryNodes: ["a"],
|
|
1006
|
+
terminalNodes: ["end"]
|
|
1007
|
+
})
|
|
1008
|
+
);
|
|
1009
|
+
throw new Error(`expected a throw for run=${JSON.stringify(run)}`);
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
if (!(error instanceof GraphValidationError)) throw error;
|
|
1012
|
+
if (!pattern.test(error.message)) {
|
|
1013
|
+
throw new Error(`got "${error.message}", expected ${pattern}`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
try {
|
|
1019
|
+
new GraphExecutionEngine(
|
|
1020
|
+
g({
|
|
1021
|
+
nodes: [constNode("a", "A")],
|
|
1022
|
+
entryNodes: ["a"],
|
|
1023
|
+
terminalNodes: ["a"],
|
|
1024
|
+
functions: { bad: "not a function" as never }
|
|
1025
|
+
})
|
|
1026
|
+
);
|
|
1027
|
+
throw new Error("expected a throw for a non-function graph entry");
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
if (!(error instanceof GraphValidationError)) throw error;
|
|
1030
|
+
ok(/must be a function/.test(error.message), error.message);
|
|
1031
|
+
}
|
|
1032
|
+
},
|
|
1033
|
+
BIG
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
registry.t(
|
|
1037
|
+
"nodeFunctions()/edgeTests() restore per-signature inference",
|
|
1038
|
+
async t => {
|
|
1039
|
+
const functions = {
|
|
1040
|
+
...nodeFunctions({
|
|
1041
|
+
/* `context` infers as NodeContext — no annotation needed */
|
|
1042
|
+
reportAttempt: context => `attempt ${context.attempt}`
|
|
1043
|
+
}),
|
|
1044
|
+
...edgeTests({
|
|
1045
|
+
/* `value` infers as unknown */
|
|
1046
|
+
isHigh: value => value === "high"
|
|
1047
|
+
})
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
const engine = new GraphExecutionEngine(
|
|
1051
|
+
g({
|
|
1052
|
+
nodes: [
|
|
1053
|
+
node("a", "reportAttempt", {
|
|
1054
|
+
next: [
|
|
1055
|
+
{ test: "isHigh", nextNode: "yes" },
|
|
1056
|
+
{ nextNode: "no" }
|
|
1057
|
+
]
|
|
1058
|
+
}),
|
|
1059
|
+
constNode("yes", "YES"),
|
|
1060
|
+
constNode("no", "NO")
|
|
1061
|
+
],
|
|
1062
|
+
entryNodes: ["a"],
|
|
1063
|
+
terminalNodes: ["yes", "no"]
|
|
1064
|
+
}),
|
|
1065
|
+
{ functions }
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
const report = await engine.run();
|
|
1069
|
+
|
|
1070
|
+
eq(report.outputs.a, "attempt 1", "the node function ran");
|
|
1071
|
+
eq(report.outputs.no, "NO", "the named test matched nothing");
|
|
1072
|
+
deepEq(report.registry?.names, ["isHigh", "reportAttempt"], "names merged");
|
|
1073
|
+
|
|
1074
|
+
t.log("finding: one table holds both signatures; a bare literal cannot infer a parameter type from the union, hence nodeFunctions()/edgeTests()");
|
|
1075
|
+
},
|
|
1076
|
+
BIG
|
|
1077
|
+
);
|
|
1078
|
+
|
|
1079
|
+
registry.t(
|
|
1080
|
+
"the report stays JSON-serializable with a registry configured",
|
|
1081
|
+
async t => {
|
|
1082
|
+
const engine = new GraphExecutionEngine(
|
|
1083
|
+
g({
|
|
1084
|
+
nodes: [node("a", "boom", { next: ["b"] }), constNode("b", "B")],
|
|
1085
|
+
entryNodes: ["a"],
|
|
1086
|
+
terminalNodes: ["b"]
|
|
1087
|
+
}),
|
|
1088
|
+
{ functions: { boom: () => ({ ok: true }) } }
|
|
1089
|
+
);
|
|
1090
|
+
|
|
1091
|
+
const report = await engine.run();
|
|
1092
|
+
const roundTrip = JSON.parse(JSON.stringify(report)) as typeof report;
|
|
1093
|
+
|
|
1094
|
+
deepEq(roundTrip.registry, report.registry, "registry survives JSON");
|
|
1095
|
+
deepEq(
|
|
1096
|
+
roundTrip.results.map(result => result.fn),
|
|
1097
|
+
["boom", null],
|
|
1098
|
+
"fn survives JSON (an inline activation serializes as null)"
|
|
1099
|
+
);
|
|
1100
|
+
eq(roundTrip.status, "COMPLETED", "status");
|
|
1101
|
+
},
|
|
1102
|
+
BIG
|
|
1103
|
+
);
|
|
1104
|
+
|
|
1105
|
+
registry.t(
|
|
1106
|
+
"the UNKNOWN_FUNCTION record is self-describing",
|
|
1107
|
+
async t => {
|
|
1108
|
+
const engine = new GraphExecutionEngine(
|
|
1109
|
+
g({
|
|
1110
|
+
nodes: [node("a", "ghost"), constNode("b", "B")],
|
|
1111
|
+
entryNodes: ["a"],
|
|
1112
|
+
terminalNodes: ["b"]
|
|
1113
|
+
}),
|
|
1114
|
+
{ functions: { present: () => 1 } }
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
const report = await engine.run();
|
|
1118
|
+
const error = report.errors[0];
|
|
1119
|
+
const details = error.details as {
|
|
1120
|
+
kind: string;
|
|
1121
|
+
name: string;
|
|
1122
|
+
nodeId: string;
|
|
1123
|
+
available: string[];
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
eq(error.code, "UNKNOWN_FUNCTION", "code");
|
|
1127
|
+
eq(details.kind, "run", "kind");
|
|
1128
|
+
eq(details.name, "ghost", "name");
|
|
1129
|
+
eq(details.nodeId, "a", "nodeId");
|
|
1130
|
+
deepEq(details.available, ["present"], "available names");
|
|
1131
|
+
ok(error.message.includes("ghost"), error.message);
|
|
1132
|
+
},
|
|
1133
|
+
BIG
|
|
1134
|
+
);
|