@jarenjs/flow 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +234 -0
- package/dist/types/app.d.ts +65 -0
- package/dist/types/dag.d.ts +89 -0
- package/dist/types/errors.d.ts +155 -0
- package/dist/types/fsm.d.ts +150 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/persist.d.ts +61 -0
- package/docs/APP-INTEGRATION.md +241 -0
- package/docs/FLOW-FORMAT.md +441 -0
- package/package.json +55 -0
- package/schemas/jaren-dag.draft-07.schema.json +200 -0
- package/schemas/jaren-dag.schema.json +200 -0
- package/schemas/jaren-fsm.draft-07.schema.json +127 -0
- package/schemas/jaren-fsm.schema.json +94 -0
- package/src/app.js +206 -0
- package/src/dag.js +560 -0
- package/src/errors.js +169 -0
- package/src/fsm.js +396 -0
- package/src/index.js +13 -0
- package/src/persist.js +65 -0
package/src/dag.js
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The jaren-dag engine: compile an acyclic dataflow document
|
|
4
|
+
* (docs/FLOW-FORMAT.md §6) once — every embedded query, stylesheet,
|
|
5
|
+
* `with` and `select` becomes a closure, the task registry is resolved,
|
|
6
|
+
* the wiring rules are proven — and run it many times. A run resolves
|
|
7
|
+
* nodes as their inputs arrive (independent branches concurrently),
|
|
8
|
+
* delivers values by reference, and fails closed: the first failing
|
|
9
|
+
* node aborts the shared signal and rejects the whole run (§7.3) — no
|
|
10
|
+
* retries, no partial results.
|
|
11
|
+
*
|
|
12
|
+
* Determinism is same input → same output VALUES, never same timing:
|
|
13
|
+
* results are keyed per node and port objects assemble in edge
|
|
14
|
+
* document order, so completion order cannot change a value (§7.2).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
18
|
+
import { compileJsltStylesheet } from '@jarenjs/json/jslt';
|
|
19
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
20
|
+
import { encodeJSONPointerSegment } from '@jarenjs/json/pointer';
|
|
21
|
+
import { isJsonObject } from '@jarenjs/core/object';
|
|
22
|
+
import { asError, FlowCompileError, FlowRuntimeError } from './errors.js';
|
|
23
|
+
|
|
24
|
+
const KINDS = ['input', 'output', 'const', 'query', 'jslt', 'task'];
|
|
25
|
+
|
|
26
|
+
/** Kinds that must not receive an inbound edge / must have one. */
|
|
27
|
+
const NO_INBOUND = new Set(['input', 'const']);
|
|
28
|
+
const NEEDS_INBOUND = new Set(['query', 'jslt', 'task', 'output']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compile one embedded document, wrapping the engine error as JF0014.
|
|
32
|
+
* @param {(d: any) => any} compile
|
|
33
|
+
* @param {any} embedded
|
|
34
|
+
* @param {string} docPath
|
|
35
|
+
*/
|
|
36
|
+
function compileEmbedded(compile, embedded, docPath) {
|
|
37
|
+
try {
|
|
38
|
+
return compile(embedded);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const cause = asError(err);
|
|
42
|
+
throw new FlowCompileError('JF0014',
|
|
43
|
+
`the embedded document failed to compile: ${cause.message}`, docPath, cause);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A settlement record handed to `onNode` (§7.4). `restored` fires at
|
|
49
|
+
* the start of a RESUMED run for every node whose checkpointed value
|
|
50
|
+
* was seeded instead of evaluated (§7.6).
|
|
51
|
+
* @typedef {{ id: string, status: 'ok'|'error'|'aborted'|'restored', ms: number }} DagNodeRecord
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The opt-in checkpoint store (§7.6): `load` answers a prior run's
|
|
56
|
+
* recorded values (or null), `save` records one declared node's
|
|
57
|
+
* value, `complete` records the run's result. Any member may return a
|
|
58
|
+
* promise; a throwing store fails the run (JF2009), never silently.
|
|
59
|
+
* @typedef {Object} DagCheckpointStore
|
|
60
|
+
* @property {(runId: string) => any} load
|
|
61
|
+
* @property {(runId: string, nodeId: string, value: any) => any} save
|
|
62
|
+
* @property {(runId: string, result: any) => any} complete
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A compiled jaren-dag graph.
|
|
67
|
+
* @typedef {Object} CompiledDag
|
|
68
|
+
* @property {readonly string[]} nodes - Declared node ids, document order.
|
|
69
|
+
* @property {string} output - The output node's id.
|
|
70
|
+
* @property {(input?: any, opts?: { signal?: AbortSignal, onNode?: (record: DagNodeRecord) => void, runId?: string }) => Promise<any>} run -
|
|
71
|
+
* Execute the graph for one input (`undefined` reads as `null`).
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Compile a jaren-dag document (docs/FLOW-FORMAT.md §6–§7) against a
|
|
76
|
+
* task registry. Everything is decided here: structural validation,
|
|
77
|
+
* the wiring rules, acyclicity, embedded-document compilation and
|
|
78
|
+
* registry resolution — `run` only executes closures.
|
|
79
|
+
*
|
|
80
|
+
* @param {any} doc - the jaren-dag document
|
|
81
|
+
* @param {{ tasks?: Record<string, (props: { with: any, input: any }, signal: AbortSignal) => any>,
|
|
82
|
+
* checkpoint?: DagCheckpointStore }} [options]
|
|
83
|
+
* @returns {CompiledDag}
|
|
84
|
+
* @throws {FlowCompileError} when the document violates the format (JF0xxx)
|
|
85
|
+
* @throws {TypeError} when the options are malformed (a registry that is
|
|
86
|
+
* not an object, a registered handler that is not a function, or a
|
|
87
|
+
* checkpoint store missing one of load/save/complete)
|
|
88
|
+
*/
|
|
89
|
+
export function compileDag(doc, options) {
|
|
90
|
+
const tasks = options?.tasks ?? {};
|
|
91
|
+
if (!isJsonObject(tasks)) {
|
|
92
|
+
throw new TypeError('compileDag: "tasks" must be an object of handler functions');
|
|
93
|
+
}
|
|
94
|
+
const checkpoint = options?.checkpoint;
|
|
95
|
+
if (checkpoint !== undefined && (typeof checkpoint?.load !== 'function'
|
|
96
|
+
|| typeof checkpoint.save !== 'function'
|
|
97
|
+
|| typeof checkpoint.complete !== 'function')) {
|
|
98
|
+
throw new TypeError(
|
|
99
|
+
'compileDag: "checkpoint" must provide load, save and complete functions');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!isJsonObject(doc)) {
|
|
103
|
+
throw new FlowCompileError('JF0010', 'the dag document must be an object', '');
|
|
104
|
+
}
|
|
105
|
+
if (doc.$dag !== '0.1') {
|
|
106
|
+
throw new FlowCompileError('JF0010',
|
|
107
|
+
`the "$dag" member is required and must be '0.1' (got ${JSON.stringify(doc.$dag)})`,
|
|
108
|
+
'/$dag');
|
|
109
|
+
}
|
|
110
|
+
if (!isJsonObject(doc.nodes)) {
|
|
111
|
+
throw new FlowCompileError('JF0011',
|
|
112
|
+
'the "nodes" member must be an object of node declarations', '/nodes');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @type {Map<string, any>} */
|
|
116
|
+
const nodes = new Map();
|
|
117
|
+
const order = Object.keys(doc.nodes);
|
|
118
|
+
for (const id of order) {
|
|
119
|
+
const decl = doc.nodes[id];
|
|
120
|
+
const base = `/nodes/${encodeJSONPointerSegment(id)}`;
|
|
121
|
+
if (!isJsonObject(decl) || !KINDS.includes(decl.kind)) {
|
|
122
|
+
throw new FlowCompileError('JF0011',
|
|
123
|
+
`node '${id}' must be an object with a kind from ${KINDS.join('|')}`,
|
|
124
|
+
isJsonObject(decl) ? `${base}/kind` : base);
|
|
125
|
+
}
|
|
126
|
+
if (decl.checkpoint !== undefined && typeof decl.checkpoint !== 'boolean') {
|
|
127
|
+
throw new FlowCompileError('JF0011',
|
|
128
|
+
`node '${id}' has a "checkpoint" member that is not a boolean`,
|
|
129
|
+
`${base}/checkpoint`);
|
|
130
|
+
}
|
|
131
|
+
/** @type {any} */
|
|
132
|
+
const node = {
|
|
133
|
+
id, kind: decl.kind, docPath: base, inbound: [],
|
|
134
|
+
checkpoint: decl.checkpoint === true,
|
|
135
|
+
};
|
|
136
|
+
switch (decl.kind) {
|
|
137
|
+
case 'const':
|
|
138
|
+
if (!Object.hasOwn(decl, 'value')) {
|
|
139
|
+
throw new FlowCompileError('JF0011',
|
|
140
|
+
`const node '${id}' must carry a "value" member`, base);
|
|
141
|
+
}
|
|
142
|
+
node.value = decl.value;
|
|
143
|
+
break;
|
|
144
|
+
case 'query':
|
|
145
|
+
if (decl.query === undefined) {
|
|
146
|
+
throw new FlowCompileError('JF0011',
|
|
147
|
+
`query node '${id}' must carry a "query" member`, base);
|
|
148
|
+
}
|
|
149
|
+
node.query = compileEmbedded(compileJsonQuery, decl.query, `${base}/query`);
|
|
150
|
+
break;
|
|
151
|
+
case 'jslt':
|
|
152
|
+
if (decl.stylesheet === undefined) {
|
|
153
|
+
throw new FlowCompileError('JF0011',
|
|
154
|
+
`jslt node '${id}' must carry a "stylesheet" member`, base);
|
|
155
|
+
}
|
|
156
|
+
node.transform = compileEmbedded(compileJsltStylesheet, decl.stylesheet, `${base}/stylesheet`);
|
|
157
|
+
break;
|
|
158
|
+
case 'task': {
|
|
159
|
+
if (typeof decl.run !== 'string' || decl.run === '') {
|
|
160
|
+
throw new FlowCompileError('JF0011',
|
|
161
|
+
`task node '${id}' must carry a non-empty string "run"`, `${base}/run`);
|
|
162
|
+
}
|
|
163
|
+
if (!Object.hasOwn(tasks, decl.run)) {
|
|
164
|
+
throw new FlowCompileError('JF0018',
|
|
165
|
+
`task node '${id}' names the handler '${decl.run}', which the registry does not provide`,
|
|
166
|
+
`${base}/run`);
|
|
167
|
+
}
|
|
168
|
+
if (typeof tasks[decl.run] !== 'function') {
|
|
169
|
+
throw new TypeError(
|
|
170
|
+
`compileDag: the registered handler '${decl.run}' is not a function`);
|
|
171
|
+
}
|
|
172
|
+
node.handler = tasks[decl.run];
|
|
173
|
+
node.with = decl.with === undefined
|
|
174
|
+
? null
|
|
175
|
+
: compileEmbedded(compileJsonQuery, decl.with, `${base}/with`);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
default:
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
nodes.set(id, node);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const outputs = order.filter((id) => nodes.get(id).kind === 'output');
|
|
185
|
+
if (outputs.length !== 1) {
|
|
186
|
+
throw new FlowCompileError('JF0017',
|
|
187
|
+
`a dag declares exactly one output node (found ${outputs.length})`, '/nodes');
|
|
188
|
+
}
|
|
189
|
+
const outputId = outputs[0];
|
|
190
|
+
|
|
191
|
+
if (!Array.isArray(doc.edges)) {
|
|
192
|
+
throw new FlowCompileError('JF0012',
|
|
193
|
+
'the "edges" member must be an array of edge entries', '/edges');
|
|
194
|
+
}
|
|
195
|
+
for (let i = 0; i < doc.edges.length; i++) {
|
|
196
|
+
const e = doc.edges[i];
|
|
197
|
+
const base = `/edges/${i}`;
|
|
198
|
+
if (!isJsonObject(e)) {
|
|
199
|
+
throw new FlowCompileError('JF0012', `edge ${i} must be an object`, base);
|
|
200
|
+
}
|
|
201
|
+
if (typeof e.from !== 'string') {
|
|
202
|
+
throw new FlowCompileError('JF0012', `edge ${i} must carry a string "from"`, `${base}/from`);
|
|
203
|
+
}
|
|
204
|
+
if (typeof e.to !== 'string') {
|
|
205
|
+
throw new FlowCompileError('JF0012', `edge ${i} must carry a string "to"`, `${base}/to`);
|
|
206
|
+
}
|
|
207
|
+
if (e.port !== undefined && (typeof e.port !== 'string' || e.port === '')) {
|
|
208
|
+
throw new FlowCompileError('JF0012',
|
|
209
|
+
`edge ${i} has a "port" that is not a non-empty string`, `${base}/port`);
|
|
210
|
+
}
|
|
211
|
+
if (!nodes.has(e.from)) {
|
|
212
|
+
throw new FlowCompileError('JF0013',
|
|
213
|
+
`edge ${i} leaves the undeclared node '${e.from}'`, `${base}/from`);
|
|
214
|
+
}
|
|
215
|
+
if (!nodes.has(e.to)) {
|
|
216
|
+
throw new FlowCompileError('JF0013',
|
|
217
|
+
`edge ${i} enters the undeclared node '${e.to}'`, `${base}/to`);
|
|
218
|
+
}
|
|
219
|
+
if (NO_INBOUND.has(nodes.get(e.to).kind)) {
|
|
220
|
+
throw new FlowCompileError('JF0015',
|
|
221
|
+
`edge ${i} enters '${e.to}', but ${nodes.get(e.to).kind} nodes accept no inbound edge`,
|
|
222
|
+
`${base}/to`);
|
|
223
|
+
}
|
|
224
|
+
if (nodes.get(e.from).kind === 'output') {
|
|
225
|
+
throw new FlowCompileError('JF0015',
|
|
226
|
+
`edge ${i} leaves the output node '${e.from}'`, `${base}/from`);
|
|
227
|
+
}
|
|
228
|
+
nodes.get(e.to).inbound.push({
|
|
229
|
+
from: e.from,
|
|
230
|
+
port: e.port ?? null,
|
|
231
|
+
select: e.select === undefined
|
|
232
|
+
? null
|
|
233
|
+
: compileEmbedded(compileJsonQuery, e.select, `${base}/select`),
|
|
234
|
+
edgeIndex: i,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// port completeness/uniqueness and inbound-required rules (§6.1)
|
|
239
|
+
for (const id of order) {
|
|
240
|
+
const node = nodes.get(id);
|
|
241
|
+
if (NEEDS_INBOUND.has(node.kind) && node.inbound.length === 0) {
|
|
242
|
+
throw new FlowCompileError('JF0015',
|
|
243
|
+
`${node.kind} node '${id}' has no inbound edge`, node.docPath);
|
|
244
|
+
}
|
|
245
|
+
const ported = node.inbound.some((e) => e.port !== null);
|
|
246
|
+
if (node.inbound.length > 1 || ported) {
|
|
247
|
+
const seen = new Set();
|
|
248
|
+
for (const e of node.inbound) {
|
|
249
|
+
if (e.port === null) {
|
|
250
|
+
throw new FlowCompileError('JF0015',
|
|
251
|
+
`edge ${e.edgeIndex} into '${id}' needs a "port": ported fan-in must be all-ported`,
|
|
252
|
+
`/edges/${e.edgeIndex}`);
|
|
253
|
+
}
|
|
254
|
+
if (seen.has(e.port)) {
|
|
255
|
+
throw new FlowCompileError('JF0015',
|
|
256
|
+
`edge ${e.edgeIndex} duplicates port '${e.port}' into '${id}'`,
|
|
257
|
+
`/edges/${e.edgeIndex}/port`);
|
|
258
|
+
}
|
|
259
|
+
seen.add(e.port);
|
|
260
|
+
}
|
|
261
|
+
node.ports = true;
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
node.ports = false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// acyclicity via Kahn (insertion-order tie-break). A forward pass's
|
|
269
|
+
// leftover holds cycles PLUS their downstream; a backward pass's
|
|
270
|
+
// leftover holds cycles PLUS their upstream — the intersection names
|
|
271
|
+
// exactly the cyclic core, so the message never accuses an innocent
|
|
272
|
+
// downstream node.
|
|
273
|
+
{
|
|
274
|
+
/** @param {(id: string) => string[]} depsOf */
|
|
275
|
+
const kahnLeftover = (depsOf) => {
|
|
276
|
+
const degree = new Map(order.map((id) => [id, depsOf(id).length]));
|
|
277
|
+
const consumers = new Map(order.map((id) => [id, /** @type {string[]} */ ([])]));
|
|
278
|
+
for (const id of order) {
|
|
279
|
+
for (const dep of depsOf(id)) /** @type {string[]} */ (consumers.get(dep)).push(id);
|
|
280
|
+
}
|
|
281
|
+
const ready = order.filter((id) => degree.get(id) === 0);
|
|
282
|
+
while (ready.length > 0) {
|
|
283
|
+
const id = /** @type {string} */ (ready.shift());
|
|
284
|
+
degree.set(id, -1);
|
|
285
|
+
for (const next of /** @type {string[]} */ (consumers.get(id))) {
|
|
286
|
+
const left = /** @type {number} */ (degree.get(next)) - 1;
|
|
287
|
+
degree.set(next, left);
|
|
288
|
+
if (left === 0) ready.push(next);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return new Set(order.filter((id) => /** @type {number} */ (degree.get(id)) > 0));
|
|
292
|
+
};
|
|
293
|
+
const forward = kahnLeftover((id) => nodes.get(id).inbound.map((e) => e.from));
|
|
294
|
+
if (forward.size > 0) {
|
|
295
|
+
const backward = kahnLeftover((id) => {
|
|
296
|
+
const out = [];
|
|
297
|
+
for (const other of order) {
|
|
298
|
+
for (const e of nodes.get(other).inbound) {
|
|
299
|
+
if (e.from === id) out.push(other);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
});
|
|
304
|
+
const cyclic = new Set([...forward].filter((id) => backward.has(id)));
|
|
305
|
+
const offender = doc.edges.findIndex(
|
|
306
|
+
(e) => cyclic.has(e.from) && cyclic.has(e.to));
|
|
307
|
+
throw new FlowCompileError('JF0016',
|
|
308
|
+
`the graph has a cycle among: ${[...cyclic].join(', ')}`, `/edges/${offender}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** @type {CompiledDag['run']} */
|
|
313
|
+
function run(input, opts) {
|
|
314
|
+
// option misuse throws synchronously, like every compile surface;
|
|
315
|
+
// only document-level outcomes travel through the promise
|
|
316
|
+
const signal = opts?.signal;
|
|
317
|
+
if (signal !== undefined && typeof signal?.addEventListener !== 'function') {
|
|
318
|
+
throw new TypeError('run: "signal" must be an AbortSignal');
|
|
319
|
+
}
|
|
320
|
+
const onNode = opts?.onNode;
|
|
321
|
+
if (onNode !== undefined && typeof onNode !== 'function') {
|
|
322
|
+
throw new TypeError('run: "onNode" must be a function');
|
|
323
|
+
}
|
|
324
|
+
const runId = opts?.runId;
|
|
325
|
+
if (runId !== undefined && checkpoint === undefined) {
|
|
326
|
+
throw new TypeError('run: "runId" needs a checkpoint store on compileDag');
|
|
327
|
+
}
|
|
328
|
+
if (checkpoint !== undefined
|
|
329
|
+
&& (typeof runId !== 'string' || runId === '')) {
|
|
330
|
+
throw new TypeError(
|
|
331
|
+
'run: a checkpointed dag needs a non-empty string "runId" to persist under');
|
|
332
|
+
}
|
|
333
|
+
return execute(input === undefined ? null : input, signal, onNode, runId);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* @param {any} runInput
|
|
338
|
+
* @param {AbortSignal|undefined} signal
|
|
339
|
+
* @param {((record: DagNodeRecord) => void)|undefined} onNode
|
|
340
|
+
* @param {string|undefined} runId
|
|
341
|
+
*/
|
|
342
|
+
async function execute(runInput, signal, onNode, runId) {
|
|
343
|
+
const controller = new AbortController();
|
|
344
|
+
/** @type {FlowRuntimeError|null} */
|
|
345
|
+
let failure = null;
|
|
346
|
+
|
|
347
|
+
/** @param {DagNodeRecord} rec */
|
|
348
|
+
const record = (rec) => {
|
|
349
|
+
if (onNode === undefined) return;
|
|
350
|
+
try {
|
|
351
|
+
onNode(rec);
|
|
352
|
+
}
|
|
353
|
+
catch { /* observation must not change a run (§7.4) */ }
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Register the canonical run failure exactly once and abort the
|
|
358
|
+
* shared signal (§7.3).
|
|
359
|
+
* @param {FlowRuntimeError} err
|
|
360
|
+
*/
|
|
361
|
+
const fail = (err) => {
|
|
362
|
+
if (failure === null) {
|
|
363
|
+
failure = err;
|
|
364
|
+
controller.abort();
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/** @type {(() => void) | null} */
|
|
369
|
+
let onAbort = null;
|
|
370
|
+
if (signal !== undefined) {
|
|
371
|
+
if (signal.aborted) {
|
|
372
|
+
throw new FlowRuntimeError('JF2007', 'the run was aborted before it started', '',
|
|
373
|
+
signal.reason instanceof Error ? signal.reason : undefined);
|
|
374
|
+
}
|
|
375
|
+
onAbort = () => {
|
|
376
|
+
fail(new FlowRuntimeError('JF2007', 'the run was aborted', '',
|
|
377
|
+
signal.reason instanceof Error ? signal.reason : undefined));
|
|
378
|
+
};
|
|
379
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** @type {Map<string, Promise<any>>} */
|
|
383
|
+
const promises = new Map();
|
|
384
|
+
|
|
385
|
+
// a resumed run SEEDS the memo from the store (§7.6): recorded
|
|
386
|
+
// values for declared-checkpoint nodes skip evaluation entirely —
|
|
387
|
+
// the execution model is untouched, only where the memo comes from
|
|
388
|
+
if (checkpoint !== undefined && runId !== undefined) {
|
|
389
|
+
let loaded;
|
|
390
|
+
try {
|
|
391
|
+
loaded = await checkpoint.load(runId);
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
const cause = asError(err);
|
|
395
|
+
throw new FlowRuntimeError('JF2009',
|
|
396
|
+
`the checkpoint store failed to load run '${runId}': ${cause.message}`,
|
|
397
|
+
'', cause);
|
|
398
|
+
}
|
|
399
|
+
if (loaded !== null && loaded !== undefined && isJsonObject(loaded.values)) {
|
|
400
|
+
for (const id of Object.keys(loaded.values)) {
|
|
401
|
+
const node = nodes.get(id);
|
|
402
|
+
if (node === undefined || node.checkpoint !== true) continue;
|
|
403
|
+
promises.set(id, Promise.resolve(loaded.values[id]));
|
|
404
|
+
record({ id, status: 'restored', ms: 0 });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** @param {string} id @returns {Promise<any>} */
|
|
410
|
+
const valueOf = (id) => {
|
|
411
|
+
let p = promises.get(id);
|
|
412
|
+
if (p === undefined) {
|
|
413
|
+
p = evaluate(/** @type {any} */ (nodes.get(id)));
|
|
414
|
+
promises.set(id, p);
|
|
415
|
+
}
|
|
416
|
+
return p;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/** @param {any} node @returns {Promise<any>} */
|
|
420
|
+
async function evaluate(node) {
|
|
421
|
+
// upstream failures propagate without a record: this node never
|
|
422
|
+
// started (§7.4)
|
|
423
|
+
const raw = await Promise.all(node.inbound.map((e) => valueOf(e.from)));
|
|
424
|
+
// a failed run launches no new work — inputs may have arrived,
|
|
425
|
+
// but the canonical failure propagates instead (§7.3)
|
|
426
|
+
if (failure !== null) throw failure;
|
|
427
|
+
|
|
428
|
+
const started = globalThis.performance.now();
|
|
429
|
+
/** @param {'ok'|'error'|'aborted'} status */
|
|
430
|
+
const settle = (status) =>
|
|
431
|
+
record({ id: node.id, status, ms: globalThis.performance.now() - started });
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
// deliveries: per-edge select, empty → null (§6.1)
|
|
435
|
+
const delivered = node.inbound.map((e, i) => {
|
|
436
|
+
if (e.select === null) return raw[i];
|
|
437
|
+
try {
|
|
438
|
+
const v = e.select(raw[i]);
|
|
439
|
+
return v === undefined ? null : v;
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
const cause = asError(err);
|
|
443
|
+
throw new FlowRuntimeError('JF2006',
|
|
444
|
+
`the select on edge ${e.edgeIndex} into '${node.id}' failed: ${cause.message}`,
|
|
445
|
+
`/edges/${e.edgeIndex}/select`, cause);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
let scope = null;
|
|
449
|
+
if (node.ports) {
|
|
450
|
+
scope = {};
|
|
451
|
+
for (let i = 0; i < node.inbound.length; i++) {
|
|
452
|
+
scope[node.inbound[i].port] = delivered[i];
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
else if (node.inbound.length === 1) {
|
|
456
|
+
scope = delivered[0];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let value;
|
|
460
|
+
switch (node.kind) {
|
|
461
|
+
case 'input': value = runInput; break;
|
|
462
|
+
case 'const': value = node.value; break;
|
|
463
|
+
case 'output': value = scope; break;
|
|
464
|
+
case 'query': value = node.query(scope) ?? null; break;
|
|
465
|
+
case 'jslt': value = node.transform(scope) ?? null; break;
|
|
466
|
+
case 'task': {
|
|
467
|
+
const props = {
|
|
468
|
+
with: node.with === null ? null : node.with(scope) ?? null,
|
|
469
|
+
input: scope,
|
|
470
|
+
};
|
|
471
|
+
value = (await node.handler(props, controller.signal)) ?? null;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
default: value = null; break;
|
|
475
|
+
}
|
|
476
|
+
if (node.checkpoint && checkpoint !== undefined && runId !== undefined) {
|
|
477
|
+
// the explicit serialization contract (§7.6): the node
|
|
478
|
+
// DECLARED its output JSON; a value that is not fails the
|
|
479
|
+
// run at save time, never a silent skip
|
|
480
|
+
try {
|
|
481
|
+
canonicalizeJson(value);
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
const cause = asError(err);
|
|
485
|
+
throw new FlowRuntimeError('JF2008',
|
|
486
|
+
`node '${node.id}' declared checkpoint but produced a value that is `
|
|
487
|
+
+ `not JSON-serializable: ${cause.message}`, node.docPath, cause);
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
await checkpoint.save(runId, node.id, value);
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
const cause = asError(err);
|
|
494
|
+
throw new FlowRuntimeError('JF2009',
|
|
495
|
+
`the checkpoint store failed to save node '${node.id}': ${cause.message}`,
|
|
496
|
+
node.docPath, cause);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
settle('ok');
|
|
500
|
+
return value;
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
const mapped = err instanceof FlowRuntimeError && /** @type {any} */ (err).nodeId !== undefined
|
|
504
|
+
? /** @type {FlowRuntimeError} */ (err)
|
|
505
|
+
: (() => {
|
|
506
|
+
const cause = err instanceof FlowRuntimeError ? err.cause : asError(err);
|
|
507
|
+
const wrapped = err instanceof FlowRuntimeError
|
|
508
|
+
? err
|
|
509
|
+
: new FlowRuntimeError('JF2006',
|
|
510
|
+
`node '${node.id}' failed: ${asError(err).message}`,
|
|
511
|
+
node.docPath, /** @type {Error|undefined} */ (cause instanceof Error ? cause : undefined));
|
|
512
|
+
return wrapped;
|
|
513
|
+
})();
|
|
514
|
+
/** @type {any} */ (mapped).nodeId ??= node.id;
|
|
515
|
+
const status = failure !== null || controller.signal.aborted ? 'aborted' : 'error';
|
|
516
|
+
if (status === 'error') fail(mapped);
|
|
517
|
+
settle(status);
|
|
518
|
+
throw mapped;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const all = order.map((id) => {
|
|
523
|
+
const p = valueOf(id);
|
|
524
|
+
p.catch(() => { /* guarded: the run rethrows the canonical failure */ });
|
|
525
|
+
return p;
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
await Promise.all(all);
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
throw failure ?? err;
|
|
533
|
+
}
|
|
534
|
+
finally {
|
|
535
|
+
if (signal !== undefined && onAbort !== null) {
|
|
536
|
+
signal.removeEventListener('abort', onAbort);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (failure !== null) throw failure;
|
|
540
|
+
const result = await promises.get(outputId);
|
|
541
|
+
if (checkpoint !== undefined && runId !== undefined) {
|
|
542
|
+
try {
|
|
543
|
+
await checkpoint.complete(runId, result);
|
|
544
|
+
}
|
|
545
|
+
catch (err) {
|
|
546
|
+
const cause = asError(err);
|
|
547
|
+
throw new FlowRuntimeError('JF2009',
|
|
548
|
+
`the checkpoint store failed to complete run '${runId}': ${cause.message}`,
|
|
549
|
+
'', cause);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return result;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return Object.freeze({
|
|
556
|
+
nodes: Object.freeze(order.slice()),
|
|
557
|
+
output: outputId,
|
|
558
|
+
run,
|
|
559
|
+
});
|
|
560
|
+
}
|