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,475 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* Branching router example — the a → b/c/d/e/f picture
|
|
3
|
+
* ------------------------------------------------------------
|
|
4
|
+
* The graph under discussion:
|
|
5
|
+
*
|
|
6
|
+
* a ── result === 1 ────────────────▶ b ──(forces)──▶ f
|
|
7
|
+
* a ── test(result) === true ───────▶ c
|
|
8
|
+
* a ── otherwise ───────────────────▶ d
|
|
9
|
+
* a ── on throw ────────────────────▶ e
|
|
10
|
+
* a ── on retry ────────────────────▶ f
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS IS NOT A SINGLE GraphDefinition
|
|
13
|
+
* GraphDefinition.next is an UNCONDITIONAL fan-out: every target is
|
|
14
|
+
* scheduled, every time. The engine also has no "goto" and no
|
|
15
|
+
* per-attempt transition. So the arrows above cannot be encoded in
|
|
16
|
+
* the definition — they are routing *decisions*, and decisions need
|
|
17
|
+
* a place to run. This file keeps src/automategraph.ts untouched and
|
|
18
|
+
* puts the decisions in a small host-side router: one engine
|
|
19
|
+
* execution per step, the router inspects the finished execution
|
|
20
|
+
* (output / attempts / errors / control events) and chooses the next
|
|
21
|
+
* hop.
|
|
22
|
+
*
|
|
23
|
+
* Arrow → mechanism used here (all engine-native)
|
|
24
|
+
* ------------------------------------------------------------------
|
|
25
|
+
* result === 1 → returned value + ordered `routes` predicate
|
|
26
|
+
* test(...) === true→ returned value + ordered `routes` predicate
|
|
27
|
+
* otherwise → `otherwise` fallback target
|
|
28
|
+
* on throw → report.status / unhandled error → `onThrow`
|
|
29
|
+
* on retry → context.attempt > 1 (seen via node.started) → `onRetry`
|
|
30
|
+
* b forces f → b returns void and emits "router.goto" on the
|
|
31
|
+
* public event bus; the router honours the request
|
|
32
|
+
*
|
|
33
|
+
* Decision precedence (first match wins): goto → throw → retry →
|
|
34
|
+
* routes → otherwise → stop. Flip `onRetry` before `onThrow` in
|
|
35
|
+
* decide() if you prefer "any retry goes to f, even a failed one".
|
|
36
|
+
*
|
|
37
|
+
* Run it: npm run example:branching
|
|
38
|
+
* Exits non-zero if any scenario deviates from its expected path, so
|
|
39
|
+
* it doubles as a smoke test.
|
|
40
|
+
* ============================================================ */
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
AutomateGraphError,
|
|
44
|
+
ErrorPolicy,
|
|
45
|
+
ExecutionReport,
|
|
46
|
+
GraphDefinition,
|
|
47
|
+
GraphStatus,
|
|
48
|
+
InMemoryStorage,
|
|
49
|
+
NodeContext,
|
|
50
|
+
NodeDefinition,
|
|
51
|
+
RetryPolicy,
|
|
52
|
+
GraphExecutionEngine
|
|
53
|
+
} from "../src/automategraph";
|
|
54
|
+
|
|
55
|
+
/* =========================
|
|
56
|
+
* 1. The graph as data
|
|
57
|
+
* ========================= */
|
|
58
|
+
|
|
59
|
+
/** What node `a` may return: 1, 2 or 3 — or it may fail at runtime. */
|
|
60
|
+
export type ScenarioSeed = 1 | 2 | 3 | "throw" | "retry";
|
|
61
|
+
|
|
62
|
+
/** The spec's `test(result)`: decides between c and d. */
|
|
63
|
+
export const test = (result: number): boolean => result === 2;
|
|
64
|
+
|
|
65
|
+
/** Mutable context threaded through the whole walk. */
|
|
66
|
+
export interface WalkState {
|
|
67
|
+
seed: ScenarioSeed;
|
|
68
|
+
visited: string[];
|
|
69
|
+
lastError?: { nodeId: string; code: string; message: string };
|
|
70
|
+
notes: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RouteTable {
|
|
74
|
+
/** Ordered conditional arrows, evaluated against the returned value. */
|
|
75
|
+
readonly routes?: ReadonlyArray<{
|
|
76
|
+
readonly when: (value: unknown) => boolean;
|
|
77
|
+
readonly to: string;
|
|
78
|
+
}>;
|
|
79
|
+
/** The `else` arrow. */
|
|
80
|
+
readonly otherwise?: string;
|
|
81
|
+
/** Arrow taken when the step failed with no attempts left. */
|
|
82
|
+
readonly onThrow?: string;
|
|
83
|
+
/** Arrow taken when the step needed more than one attempt. */
|
|
84
|
+
readonly onRetry?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One node in the picture: a callback plus its outgoing arrows. */
|
|
88
|
+
export interface StepDefinition {
|
|
89
|
+
readonly run: (state: WalkState, context: NodeContext) => unknown | Promise<unknown>;
|
|
90
|
+
readonly retry?: RetryPolicy;
|
|
91
|
+
readonly errorPolicy?: ErrorPolicy;
|
|
92
|
+
readonly routes?: RouteTable;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Event a node emits when it wants to force the next hop from inside. */
|
|
96
|
+
export const GOTO_EVENT = "router.goto";
|
|
97
|
+
|
|
98
|
+
export interface GotoRequest {
|
|
99
|
+
to: string;
|
|
100
|
+
reason?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* =========================
|
|
104
|
+
* 2. The nodes
|
|
105
|
+
* ========================= */
|
|
106
|
+
|
|
107
|
+
export function buildSteps(state: WalkState): Record<string, StepDefinition> {
|
|
108
|
+
return {
|
|
109
|
+
/**
|
|
110
|
+
* a — produces 1 | 2 | 3, or throws.
|
|
111
|
+
* It carries the whole arrow table from the picture.
|
|
112
|
+
*/
|
|
113
|
+
a: {
|
|
114
|
+
run: (_state, context) => {
|
|
115
|
+
if (state.seed === "throw") {
|
|
116
|
+
throw new Error("a: upstream exploded");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (state.seed === "retry" && context.attempt === 1) {
|
|
120
|
+
throw new Error(`a: transient failure on attempt ${context.attempt}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// On the retry path, attempt 2 succeeds with value 2.
|
|
124
|
+
return state.seed === "retry" ? 2 : state.seed;
|
|
125
|
+
},
|
|
126
|
+
retry: { maxAttempts: 2, delayMs: 10 },
|
|
127
|
+
errorPolicy: "RETRY",
|
|
128
|
+
routes: {
|
|
129
|
+
routes: [
|
|
130
|
+
{ when: value => value === 1, to: "b" },
|
|
131
|
+
{ when: value => typeof value === "number" && test(value), to: "c" }
|
|
132
|
+
],
|
|
133
|
+
otherwise: "d",
|
|
134
|
+
onThrow: "e",
|
|
135
|
+
onRetry: "f"
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* b — returns void, but forces the walk to f *from inside its own
|
|
141
|
+
* execution* by emitting a control message. The engine never
|
|
142
|
+
* schedules the jump; the router (host code) performs it.
|
|
143
|
+
*/
|
|
144
|
+
b: {
|
|
145
|
+
run: async (_state, context) => {
|
|
146
|
+
await context.emit(GOTO_EVENT, {
|
|
147
|
+
to: "f",
|
|
148
|
+
reason: "b short-circuits to f"
|
|
149
|
+
} satisfies GotoRequest);
|
|
150
|
+
|
|
151
|
+
return undefined; // void, as specified
|
|
152
|
+
},
|
|
153
|
+
// Belt and braces: also true if a goto is ever ignored.
|
|
154
|
+
routes: { otherwise: "f" }
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
c: { run: () => "c: even result handled" },
|
|
158
|
+
d: { run: () => "d: fallback branch handled" },
|
|
159
|
+
|
|
160
|
+
/** e — the throw route; can read the failure the router recorded. */
|
|
161
|
+
e: {
|
|
162
|
+
run: () => `e: recovered from ${state.lastError?.nodeId ?? "?"} (${state.lastError?.code ?? "?"})`
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
f: { run: () => "f: retry/forced path handled" }
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* =========================
|
|
170
|
+
* 3. One step = one execution
|
|
171
|
+
* ========================= */
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Build the single-node graph for a hop. The node is BOTH the entry
|
|
175
|
+
* and the terminal, which keeps the graph lint-clean (the v1.2
|
|
176
|
+
* SUCCESS_DEAD_END rule needs each non-terminal node to have a
|
|
177
|
+
* `next`) and guarantees the engine schedules nothing on its own.
|
|
178
|
+
*
|
|
179
|
+
* `onThrow` is deliberately NOT declared on the engine node: its
|
|
180
|
+
* target would have to exist inside this one-node graph, and error
|
|
181
|
+
* routing is the router's job here (see decide()).
|
|
182
|
+
*/
|
|
183
|
+
function stepGraph(id: string, step: StepDefinition, state: WalkState): GraphDefinition {
|
|
184
|
+
const definition: NodeDefinition = {
|
|
185
|
+
id,
|
|
186
|
+
run: (context: NodeContext) => step.run(state, context),
|
|
187
|
+
retry: step.retry,
|
|
188
|
+
errorPolicy: step.errorPolicy
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
id: `branching-step:${id}`,
|
|
193
|
+
version: "1.0.0",
|
|
194
|
+
entryNodes: [id],
|
|
195
|
+
terminalNodes: [id],
|
|
196
|
+
nodes: { [id]: definition }
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export type DecisionBy = "goto" | "throw" | "retry" | "return" | "otherwise" | "none";
|
|
201
|
+
|
|
202
|
+
export interface HopRecord {
|
|
203
|
+
step: number;
|
|
204
|
+
nodeId: string;
|
|
205
|
+
executionId: string;
|
|
206
|
+
status: GraphStatus;
|
|
207
|
+
attempts: number;
|
|
208
|
+
output: unknown;
|
|
209
|
+
errorCode?: string;
|
|
210
|
+
decision: { by: DecisionBy; to?: string; reason: string };
|
|
211
|
+
durationMs: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface RawRun {
|
|
215
|
+
report: ExecutionReport;
|
|
216
|
+
attempts: number;
|
|
217
|
+
gotoRequests: GotoRequest[];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isGotoRequest(value: unknown): value is GotoRequest {
|
|
221
|
+
return (
|
|
222
|
+
typeof value === "object" &&
|
|
223
|
+
value !== null &&
|
|
224
|
+
typeof (value as GotoRequest).to === "string"
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function runOne(
|
|
229
|
+
nodeId: string,
|
|
230
|
+
step: StepDefinition,
|
|
231
|
+
state: WalkState,
|
|
232
|
+
storage: InMemoryStorage
|
|
233
|
+
): Promise<RawRun> {
|
|
234
|
+
const engine = new GraphExecutionEngine(stepGraph(nodeId, step, state), {
|
|
235
|
+
storage,
|
|
236
|
+
maxActivations: 1 // a hop starts exactly one activation
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
let attempts = 0;
|
|
240
|
+
const gotoRequests: GotoRequest[] = [];
|
|
241
|
+
|
|
242
|
+
// Required listeners are awaited by the bus, so by the time run()
|
|
243
|
+
// resolves we have the true attempt count and every goto request.
|
|
244
|
+
engine.events.on(
|
|
245
|
+
"node.started",
|
|
246
|
+
payload => {
|
|
247
|
+
const { attempt } = payload as { attempt: number };
|
|
248
|
+
attempts = Math.max(attempts, attempt);
|
|
249
|
+
},
|
|
250
|
+
{ required: true }
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
engine.events.on(
|
|
254
|
+
GOTO_EVENT,
|
|
255
|
+
payload => {
|
|
256
|
+
if (isGotoRequest(payload)) gotoRequests.push(payload);
|
|
257
|
+
},
|
|
258
|
+
{ required: true }
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const report = await engine.run();
|
|
262
|
+
return { report, attempts, gotoRequests };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* =========================
|
|
266
|
+
* 4. The router
|
|
267
|
+
* ========================= */
|
|
268
|
+
|
|
269
|
+
export interface WalkOptions {
|
|
270
|
+
readonly maxSteps?: number;
|
|
271
|
+
readonly storage?: InMemoryStorage;
|
|
272
|
+
readonly onHop?: (hop: HopRecord) => void;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface WalkResult {
|
|
276
|
+
hops: HopRecord[];
|
|
277
|
+
path: string[];
|
|
278
|
+
executionIds: string[];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Decision precedence: goto → throw → retry → returned value → stop.
|
|
283
|
+
* A *failed* step goes to `onThrow` even when it consumed retries;
|
|
284
|
+
* `onRetry` is for a step that recovered on a later attempt.
|
|
285
|
+
*/
|
|
286
|
+
export function decide(
|
|
287
|
+
routes: RouteTable | undefined,
|
|
288
|
+
run: RawRun
|
|
289
|
+
): { by: DecisionBy; to?: string; reason: string } {
|
|
290
|
+
const { report, attempts, gotoRequests } = run;
|
|
291
|
+
|
|
292
|
+
if (gotoRequests.length > 0) {
|
|
293
|
+
const request = gotoRequests[0];
|
|
294
|
+
return {
|
|
295
|
+
by: "goto",
|
|
296
|
+
to: request.to,
|
|
297
|
+
reason: `forced from inside the node (${request.reason ?? "no reason given"})`
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const failed = report.status !== "COMPLETED";
|
|
302
|
+
|
|
303
|
+
if (failed) {
|
|
304
|
+
if (routes?.onThrow) {
|
|
305
|
+
return { by: "throw", to: routes.onThrow, reason: `step ended ${report.status}` };
|
|
306
|
+
}
|
|
307
|
+
return { by: "none", reason: `step ended ${report.status} and no onThrow is declared` };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (attempts > 1 && routes?.onRetry) {
|
|
311
|
+
return { by: "retry", to: routes.onRetry, reason: `recovered on attempt ${attempts}` };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const output = report.results[0]?.output;
|
|
315
|
+
|
|
316
|
+
for (const route of routes?.routes ?? []) {
|
|
317
|
+
if (route.when(output)) {
|
|
318
|
+
return { by: "return", to: route.to, reason: "value route matched" };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (routes?.otherwise) {
|
|
323
|
+
return { by: "otherwise", to: routes.otherwise, reason: "no value route matched" };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { by: "none", reason: "no transition declared; walk complete" };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* =========================
|
|
330
|
+
* 5. The walk factory (public entry point)
|
|
331
|
+
* ========================= */
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Seeds the walk, builds the steps against the shared state, and runs
|
|
335
|
+
* the router loop. This is the whole "interpreter": run one node, read
|
|
336
|
+
* its outcome, follow the matching arrow.
|
|
337
|
+
*/
|
|
338
|
+
export async function runWalk(
|
|
339
|
+
seed: ScenarioSeed,
|
|
340
|
+
options: WalkOptions = {}
|
|
341
|
+
): Promise<{ result: WalkResult; state: WalkState }> {
|
|
342
|
+
const storage = options.storage ?? new InMemoryStorage();
|
|
343
|
+
const maxSteps = options.maxSteps ?? 64;
|
|
344
|
+
|
|
345
|
+
const state: WalkState = { seed, visited: [], notes: [] };
|
|
346
|
+
const steps = buildSteps(state);
|
|
347
|
+
const hops: HopRecord[] = [];
|
|
348
|
+
const executionIds: string[] = [];
|
|
349
|
+
|
|
350
|
+
let current: string | undefined = "a";
|
|
351
|
+
|
|
352
|
+
while (current !== undefined) {
|
|
353
|
+
if (hops.length >= maxSteps) {
|
|
354
|
+
throw new AutomateGraphError(
|
|
355
|
+
`Walk exceeded maxSteps=${maxSteps} (possible cycle)`,
|
|
356
|
+
"WALK_STEP_LIMIT",
|
|
357
|
+
{ visited: [...state.visited] }
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const step: StepDefinition | undefined = steps[current];
|
|
362
|
+
|
|
363
|
+
if (!step) {
|
|
364
|
+
throw new AutomateGraphError(
|
|
365
|
+
`Route target "${current}" has no node definition`,
|
|
366
|
+
"ROUTE_TARGET_UNKNOWN",
|
|
367
|
+
{ visited: [...state.visited] }
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const startedAt = Date.now();
|
|
372
|
+
const run = await runOne(current, step, state, storage);
|
|
373
|
+
const { report, attempts, gotoRequests } = run;
|
|
374
|
+
|
|
375
|
+
// Record the failure so the next hop (e) can read it.
|
|
376
|
+
const unhandled = report.errors.find(error => !error.handled);
|
|
377
|
+
if (unhandled) {
|
|
378
|
+
state.lastError = {
|
|
379
|
+
nodeId: unhandled.nodeId ?? current,
|
|
380
|
+
code: unhandled.code,
|
|
381
|
+
message: unhandled.message
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const decision = decide(step.routes, run);
|
|
386
|
+
|
|
387
|
+
const hop: HopRecord = {
|
|
388
|
+
step: hops.length + 1,
|
|
389
|
+
nodeId: current,
|
|
390
|
+
executionId: report.executionId,
|
|
391
|
+
status: report.status,
|
|
392
|
+
attempts,
|
|
393
|
+
output: report.results[0]?.output,
|
|
394
|
+
errorCode: report.errors[0]?.code,
|
|
395
|
+
decision,
|
|
396
|
+
durationMs: Date.now() - startedAt
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
hops.push(hop);
|
|
400
|
+
executionIds.push(report.executionId);
|
|
401
|
+
state.visited.push(current);
|
|
402
|
+
options.onHop?.(hop);
|
|
403
|
+
|
|
404
|
+
if (gotoRequests.length > 1) {
|
|
405
|
+
state.notes.push(`${current} requested ${gotoRequests.length} jumps; using the first`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
current = decision.to;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return { result: { hops, path: state.visited, executionIds }, state };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/* =========================
|
|
415
|
+
* 6. Demo — proves the picture
|
|
416
|
+
* ========================= */
|
|
417
|
+
|
|
418
|
+
const SCENARIOS: ReadonlyArray<{ label: string; seed: ScenarioSeed; expected: string[] }> = [
|
|
419
|
+
{ label: "result === 1 → b, then b forces f", seed: 1, expected: ["a", "b", "f"] },
|
|
420
|
+
{ label: "test(result) === true → c", seed: 2, expected: ["a", "c"] },
|
|
421
|
+
{ label: "otherwise → d", seed: 3, expected: ["a", "d"] },
|
|
422
|
+
{ label: "on throw → e", seed: "throw", expected: ["a", "e"] },
|
|
423
|
+
{ label: "on retry (recovered) → f", seed: "retry", expected: ["a", "f"] }
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
function show(value: unknown): string {
|
|
427
|
+
return value === undefined ? "undefined" : JSON.stringify(value);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function main(): Promise<void> {
|
|
431
|
+
const storage = new InMemoryStorage();
|
|
432
|
+
let failures = 0;
|
|
433
|
+
|
|
434
|
+
console.log("=".repeat(96));
|
|
435
|
+
console.log("Branching router — engine untouched, arrows resolved by the host-side router");
|
|
436
|
+
console.log("=".repeat(96));
|
|
437
|
+
|
|
438
|
+
for (const scenario of SCENARIOS) {
|
|
439
|
+
const { result } = await runWalk(scenario.seed, { storage });
|
|
440
|
+
const path = result.path.join(" → ");
|
|
441
|
+
const expected = scenario.expected.join(" → ");
|
|
442
|
+
const ok = path === expected;
|
|
443
|
+
if (!ok) failures += 1;
|
|
444
|
+
|
|
445
|
+
console.log(`\n▶ seed = ${JSON.stringify(scenario.seed)} ${scenario.label}`);
|
|
446
|
+
|
|
447
|
+
for (const hop of result.hops) {
|
|
448
|
+
const target = hop.decision.to ? `→ ${hop.decision.to}` : "stop";
|
|
449
|
+
console.log(
|
|
450
|
+
` hop ${hop.step} ${hop.nodeId} ${hop.status.padEnd(9)}` +
|
|
451
|
+
` attempts=${hop.attempts} output=${show(hop.output).padEnd(42)}` +
|
|
452
|
+
` ${hop.decision.by.padEnd(9)} ${target} (${hop.decision.reason})`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
console.log(` path: ${path} ${ok ? "✓ as expected" : `✗ expected ${expected}`}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const snapshotIds = await storage.listExecutionIds();
|
|
460
|
+
|
|
461
|
+
console.log(`\n${"-".repeat(96)}`);
|
|
462
|
+
console.log(
|
|
463
|
+
`scenarios: ${SCENARIOS.length - failures}/${SCENARIOS.length} matched · ` +
|
|
464
|
+
`engine executions (one per hop): ${snapshotIds.length} · ` +
|
|
465
|
+
`checkpoints retrievable via storage.load(executionId)`
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
if (failures > 0) {
|
|
469
|
+
process.exitCode = 1;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (require.main === module) {
|
|
474
|
+
void main();
|
|
475
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* Graph as a DATA FILE (v1.4)
|
|
3
|
+
* ------------------------------------------------------------
|
|
4
|
+
* examples/order-flow.graph.json declares nodes and edges, but no
|
|
5
|
+
* JavaScript. Every function is referenced by name and supplied to the
|
|
6
|
+
* engine at construction, so the SAME document runs against different
|
|
7
|
+
* implementations:
|
|
8
|
+
*
|
|
9
|
+
* classify ── isPriority ──▶ charge ──▶ ship
|
|
10
|
+
* ── "manual" ────▶ manual
|
|
11
|
+
* ── else ────────▶ reject
|
|
12
|
+
*
|
|
13
|
+
* Scenario D runs the document with a registry that is missing
|
|
14
|
+
* `shipOrder` — nothing throws at build time; the activation fails with
|
|
15
|
+
* UNKNOWN_FUNCTION and the run settles FAILED.
|
|
16
|
+
*
|
|
17
|
+
* Run: npm run example:graphjson (after npm run build)
|
|
18
|
+
* ============================================================ */
|
|
19
|
+
|
|
20
|
+
import * as fs from "fs";
|
|
21
|
+
import * as path from "path";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
FunctionRegistryInput,
|
|
25
|
+
NodeContext
|
|
26
|
+
} from "../src/automategraph";
|
|
27
|
+
import {
|
|
28
|
+
createEngineFromJSON,
|
|
29
|
+
parseGraphJSON
|
|
30
|
+
} from "../src/graph-json";
|
|
31
|
+
|
|
32
|
+
const DOCUMENT_PATH = path.join(
|
|
33
|
+
__dirname,
|
|
34
|
+
"..",
|
|
35
|
+
"..",
|
|
36
|
+
"examples",
|
|
37
|
+
"order-flow.graph.json"
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/* ---------------------------------------------------------------
|
|
41
|
+
* The implementations, keyed by the names the document uses.
|
|
42
|
+
* ------------------------------------------------------------- */
|
|
43
|
+
|
|
44
|
+
function isPriority(value: unknown): boolean {
|
|
45
|
+
return value === "priority";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function chargeCard(context: NodeContext): string {
|
|
49
|
+
if (context.attempt === 1) {
|
|
50
|
+
throw new Error("chargeCard: gateway timeout (transient)");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return "charged";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function shipOrder(): string {
|
|
57
|
+
return "shipped";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function manualReview(): string {
|
|
61
|
+
return "queued for a human";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function rejectOrder(): string {
|
|
65
|
+
return "rejected";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/*
|
|
69
|
+
* Three classifier IMPLEMENTATIONS — the document itself never changes.
|
|
70
|
+
* Each has its own source, so the registry fingerprint moves with it.
|
|
71
|
+
*/
|
|
72
|
+
function classifyAsPriority(): string {
|
|
73
|
+
return "priority";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function classifyAsManual(): string {
|
|
77
|
+
return "manual";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function classifyAsStandard(): string {
|
|
81
|
+
return "standard";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const SCENARIOS: ReadonlyArray<{
|
|
85
|
+
label: string;
|
|
86
|
+
registry: FunctionRegistryInput;
|
|
87
|
+
expect: string;
|
|
88
|
+
expectStatus: string;
|
|
89
|
+
}> = [
|
|
90
|
+
{
|
|
91
|
+
label: "priority order → charge → ship",
|
|
92
|
+
registry: {
|
|
93
|
+
classifyOrder: classifyAsPriority,
|
|
94
|
+
isPriority,
|
|
95
|
+
chargeCard,
|
|
96
|
+
shipOrder,
|
|
97
|
+
manualReview,
|
|
98
|
+
rejectOrder
|
|
99
|
+
},
|
|
100
|
+
expect: "classify → charge → ship",
|
|
101
|
+
expectStatus: "COMPLETED"
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
label: 'returned "manual" → manual review',
|
|
105
|
+
registry: {
|
|
106
|
+
classifyOrder: classifyAsManual,
|
|
107
|
+
isPriority,
|
|
108
|
+
chargeCard,
|
|
109
|
+
shipOrder,
|
|
110
|
+
manualReview,
|
|
111
|
+
rejectOrder
|
|
112
|
+
},
|
|
113
|
+
expect: "classify → manual",
|
|
114
|
+
expectStatus: "COMPLETED"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
label: "anything else → else edge → reject",
|
|
118
|
+
registry: {
|
|
119
|
+
classifyOrder: classifyAsStandard,
|
|
120
|
+
isPriority,
|
|
121
|
+
chargeCard,
|
|
122
|
+
shipOrder,
|
|
123
|
+
manualReview,
|
|
124
|
+
rejectOrder
|
|
125
|
+
},
|
|
126
|
+
expect: "classify → reject",
|
|
127
|
+
expectStatus: "COMPLETED"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
label: "registry missing shipOrder → UNKNOWN_FUNCTION",
|
|
131
|
+
registry: {
|
|
132
|
+
classifyOrder: classifyAsPriority,
|
|
133
|
+
isPriority,
|
|
134
|
+
chargeCard,
|
|
135
|
+
manualReview,
|
|
136
|
+
rejectOrder
|
|
137
|
+
},
|
|
138
|
+
expect: "classify → charge",
|
|
139
|
+
expectStatus: "FAILED"
|
|
140
|
+
}
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
async function main(): Promise<void> {
|
|
144
|
+
const document = fs.readFileSync(DOCUMENT_PATH, "utf8");
|
|
145
|
+
|
|
146
|
+
console.log("=".repeat(96));
|
|
147
|
+
console.log(`graph as a data file — ${path.basename(DOCUMENT_PATH)}`);
|
|
148
|
+
console.log("=".repeat(96));
|
|
149
|
+
|
|
150
|
+
/* Parsing needs no registry at all: names resolve lazily. */
|
|
151
|
+
const definition = parseGraphJSON(document);
|
|
152
|
+
console.log(
|
|
153
|
+
`parsed: ${definition.id} v${definition.version}, ` +
|
|
154
|
+
`${Object.keys(definition.nodes).length} nodes, ` +
|
|
155
|
+
`${definition.entryNodes.join(",")} → ${definition.terminalNodes.join(",")}\n`
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
let failures = 0;
|
|
159
|
+
let previousFingerprint: string | undefined;
|
|
160
|
+
|
|
161
|
+
for (const scenario of SCENARIOS) {
|
|
162
|
+
const engine = createEngineFromJSON(document, {
|
|
163
|
+
functions: scenario.registry,
|
|
164
|
+
lint: "error",
|
|
165
|
+
onLog: (level, message) => {
|
|
166
|
+
if (level === "warn" || level === "error") {
|
|
167
|
+
console.log(` log[${level}] ${message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const report = await engine.run();
|
|
173
|
+
|
|
174
|
+
const path2 = report.results
|
|
175
|
+
.filter(result => result.status === "SUCCEEDED")
|
|
176
|
+
.map(result => result.nodeId)
|
|
177
|
+
.join(" → ");
|
|
178
|
+
|
|
179
|
+
const ok =
|
|
180
|
+
report.status === scenario.expectStatus &&
|
|
181
|
+
path2 === scenario.expect;
|
|
182
|
+
|
|
183
|
+
if (!ok) failures += 1;
|
|
184
|
+
|
|
185
|
+
const fingerprint = report.registry?.fingerprint ?? "(none)";
|
|
186
|
+
const drift =
|
|
187
|
+
previousFingerprint !== undefined &&
|
|
188
|
+
previousFingerprint !== fingerprint
|
|
189
|
+
? " ← registry changed (fingerprint moved)"
|
|
190
|
+
: "";
|
|
191
|
+
|
|
192
|
+
previousFingerprint = fingerprint;
|
|
193
|
+
|
|
194
|
+
console.log(`▶ ${scenario.label}`);
|
|
195
|
+
console.log(
|
|
196
|
+
` ${report.status} path: ${path2 || "(none)"} ` +
|
|
197
|
+
`${ok ? "✓" : `✗ expected ${scenario.expectStatus} / ${scenario.expect}`}`
|
|
198
|
+
);
|
|
199
|
+
console.log(` registry ${fingerprint}${drift}`);
|
|
200
|
+
console.log(` names: ${(report.registry?.names ?? []).join(", ")}`);
|
|
201
|
+
|
|
202
|
+
for (const result of report.results) {
|
|
203
|
+
if (result.fn) {
|
|
204
|
+
console.log(
|
|
205
|
+
` ${result.nodeId.padEnd(9)} fn=${result.fn} ` +
|
|
206
|
+
`attempt=${result.attempt} ${result.status}`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const error of report.errors) {
|
|
212
|
+
console.log(
|
|
213
|
+
` error ${error.code} @${error.nodeId ?? "?"} ` +
|
|
214
|
+
`handled=${error.handled} ${error.message}`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log("");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log("-".repeat(96));
|
|
222
|
+
console.log(
|
|
223
|
+
`scenarios: ${SCENARIOS.length - failures}/${SCENARIOS.length} matched`
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (failures > 0) process.exitCode = 1;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (require.main === module) {
|
|
230
|
+
void main();
|
|
231
|
+
}
|