jev-devtools 0.2.0 → 0.4.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 +50 -6
- package/lib/flow/index.d.ts +143 -0
- package/lib/flow/index.js +1029 -0
- package/lib/flow/runtime.d.ts +5 -0
- package/lib/flow/runtime.js +81 -0
- package/lib/server/flows.d.ts +28 -0
- package/lib/server/flows.js +187 -0
- package/lib/server/index.d.ts +14 -1
- package/lib/server/index.js +100 -1
- package/lib/server/recorder.d.ts +4 -1
- package/lib/server/recorder.js +19 -3
- package/lib/src/ChoiceProbabilities.js +1 -1
- package/lib/src/ConnectedInspector.js +13 -4
- package/lib/src/DiagramContent.d.ts +6 -0
- package/lib/src/DiagramContent.js +32 -0
- package/lib/src/FlowDetailsPanel.d.ts +8 -0
- package/lib/src/FlowDetailsPanel.js +79 -0
- package/lib/src/FlowOverview.d.ts +8 -0
- package/lib/src/FlowOverview.js +440 -0
- package/lib/src/JevDevtools.d.ts +6 -1
- package/lib/src/JevDevtools.js +18 -4
- package/lib/src/TraceDiagram.d.ts +4 -1
- package/lib/src/TraceDiagram.js +30 -12
- package/lib/src/condition-inputs.d.ts +9 -0
- package/lib/src/condition-inputs.js +109 -0
- package/lib/src/flow-condition.d.ts +4 -0
- package/lib/src/flow-condition.js +48 -0
- package/lib/src/flow-model.d.ts +7 -2
- package/lib/src/flow-model.js +2 -11
- package/lib/src/flow-protocol.d.ts +93 -0
- package/lib/src/flow-protocol.js +1 -0
- package/lib/src/flow-stream.d.ts +8 -0
- package/lib/src/flow-stream.js +80 -0
- package/lib/src/inspector-styles.js +1 -1
- package/lib/src/inspector.css +746 -8
- package/lib/src/protocol.d.ts +5 -0
- package/lib/src/useDiagramViewport.d.ts +5 -1
- package/lib/src/useDiagramViewport.js +31 -9
- package/package.json +11 -4
- package/skills/setup-jev-devtools/SKILL.md +8 -4
- package/skills/setup-jev-devtools/references/flows.md +227 -0
- package/skills/setup-jev-devtools/references/nextjs.md +3 -1
- package/skills/setup-jev-devtools/references/react-node.md +2 -2
- package/skills/setup-jev-devtools/references/verify.md +1 -1
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { jsonSnapshot, runCallback } from "./runtime.js";
|
|
3
|
+
const owners = new WeakMap();
|
|
4
|
+
const builderIds = new WeakMap();
|
|
5
|
+
function freezeAst(ast, depth = 0) {
|
|
6
|
+
if (depth > 80)
|
|
7
|
+
throw new Error("Expression nesting exceeds 80");
|
|
8
|
+
if (Object.isFrozen(ast))
|
|
9
|
+
return;
|
|
10
|
+
if (ast.op === "get")
|
|
11
|
+
freezeAst(ast.from, depth + 1);
|
|
12
|
+
if ("args" in ast) {
|
|
13
|
+
if (ast.args.length > 256)
|
|
14
|
+
throw new Error("Too many condition operands");
|
|
15
|
+
for (const child of ast.args)
|
|
16
|
+
freezeAst(child, depth + 1);
|
|
17
|
+
Object.freeze(ast.args);
|
|
18
|
+
}
|
|
19
|
+
Object.freeze(ast);
|
|
20
|
+
}
|
|
21
|
+
/** Inspectable expression, not an application callback. Use get() for input fields. */
|
|
22
|
+
export class FlowExpression {
|
|
23
|
+
ast;
|
|
24
|
+
constructor(ast, owner) {
|
|
25
|
+
this.ast = ast;
|
|
26
|
+
if (owner)
|
|
27
|
+
owners.set(this, owner);
|
|
28
|
+
freezeAst(ast);
|
|
29
|
+
Object.freeze(this);
|
|
30
|
+
}
|
|
31
|
+
get(key) {
|
|
32
|
+
return expression({ op: "get", from: this.ast, key: String(key) }, [this]);
|
|
33
|
+
}
|
|
34
|
+
eq(value) {
|
|
35
|
+
return operation("eq", [this, value]);
|
|
36
|
+
}
|
|
37
|
+
lt(value) {
|
|
38
|
+
return operation("lt", [this, value]);
|
|
39
|
+
}
|
|
40
|
+
gt(value) {
|
|
41
|
+
return operation("gt", [this, value]);
|
|
42
|
+
}
|
|
43
|
+
gte(value) {
|
|
44
|
+
return operation("gte", [this, value]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function expression(ast, values) {
|
|
48
|
+
const tokens = new Set(values
|
|
49
|
+
.filter((v) => v instanceof FlowExpression)
|
|
50
|
+
.map((v) => owners.get(v))
|
|
51
|
+
.filter(Boolean));
|
|
52
|
+
if (tokens.size > 1)
|
|
53
|
+
throw new Error("References belong to different flows");
|
|
54
|
+
return new FlowExpression(ast, tokens.values().next().value);
|
|
55
|
+
}
|
|
56
|
+
function operation(op, args) {
|
|
57
|
+
return expression({
|
|
58
|
+
op,
|
|
59
|
+
args: args.map((v) => v instanceof FlowExpression ? v.ast : { op: "literal", value: v }),
|
|
60
|
+
}, args);
|
|
61
|
+
}
|
|
62
|
+
export const all = (...conditions) => operation("all", conditions);
|
|
63
|
+
export const any = (...conditions) => operation("any", conditions);
|
|
64
|
+
export const not = (condition) => operation("not", [condition]);
|
|
65
|
+
// Nested programs share a reservation budget without exposing execution internals in the API.
|
|
66
|
+
const executionBudgets = new WeakMap();
|
|
67
|
+
function withBudget(options, budget) {
|
|
68
|
+
executionBudgets.set(options, budget);
|
|
69
|
+
return options;
|
|
70
|
+
}
|
|
71
|
+
function definitionSize(definition) {
|
|
72
|
+
return definition.nodes.reduce((count, node) => count +
|
|
73
|
+
1 +
|
|
74
|
+
(node.itemDefinition ? definitionSize(node.itemDefinition) : 0), 0);
|
|
75
|
+
}
|
|
76
|
+
export class FlowLoopLimitError extends Error {
|
|
77
|
+
name = "FlowLoopLimitError";
|
|
78
|
+
constructor() {
|
|
79
|
+
super("Loop condition is still true at maxIterations");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const plain = (v) => !!v &&
|
|
83
|
+
typeof v === "object" &&
|
|
84
|
+
(Object.getPrototypeOf(v) === Object.prototype ||
|
|
85
|
+
Object.getPrototypeOf(v) === null);
|
|
86
|
+
function mapValue(value, ref, seen = new Set(), depth = 0) {
|
|
87
|
+
if (depth > 40)
|
|
88
|
+
throw new Error("Flow value nesting exceeds 40");
|
|
89
|
+
if (value instanceof FlowExpression)
|
|
90
|
+
return ref(value);
|
|
91
|
+
if (typeof value === "function")
|
|
92
|
+
throw new Error("Flow values cannot contain callbacks");
|
|
93
|
+
if (!Array.isArray(value) && !plain(value))
|
|
94
|
+
return value; // Preserve SDK assets.
|
|
95
|
+
if (seen.has(value))
|
|
96
|
+
throw new Error("Cyclic flow value");
|
|
97
|
+
seen.add(value);
|
|
98
|
+
const result = Array.isArray(value)
|
|
99
|
+
? value.map((v) => mapValue(v, ref, seen, depth + 1))
|
|
100
|
+
: Object.fromEntries(Object.entries(value).map(([k, v]) => [
|
|
101
|
+
k,
|
|
102
|
+
mapValue(v, ref, seen, depth + 1),
|
|
103
|
+
]));
|
|
104
|
+
seen.delete(value);
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
const inspectAst = (ast, ref) => {
|
|
108
|
+
if (ast.op === "ref")
|
|
109
|
+
ref(ast.id);
|
|
110
|
+
else if (ast.op === "get")
|
|
111
|
+
inspectAst(ast.from, ref);
|
|
112
|
+
else if ("args" in ast)
|
|
113
|
+
ast.args.forEach((a) => inspectAst(a, ref));
|
|
114
|
+
};
|
|
115
|
+
function describe(ast) {
|
|
116
|
+
if (ast.op === "input")
|
|
117
|
+
return "input";
|
|
118
|
+
if (ast.op === "ref")
|
|
119
|
+
return ast.id;
|
|
120
|
+
if (ast.op === "get")
|
|
121
|
+
return `${describe(ast.from)}.${ast.key}`;
|
|
122
|
+
if (ast.op === "literal")
|
|
123
|
+
return "[literal]";
|
|
124
|
+
return `${ast.op}(${ast.args.map(describe).join(", ")})`;
|
|
125
|
+
}
|
|
126
|
+
function conditionRule(ast, requests, tasks) {
|
|
127
|
+
let remaining = 128;
|
|
128
|
+
const visit = (node) => {
|
|
129
|
+
if (--remaining < 0)
|
|
130
|
+
return { op: "literal", value: "[rule truncated]" };
|
|
131
|
+
if (node.op === "input" || node.op === "ref" || node.op === "get") {
|
|
132
|
+
const path = [];
|
|
133
|
+
let base = node;
|
|
134
|
+
while (base.op === "get") {
|
|
135
|
+
path.unshift(base.key);
|
|
136
|
+
base = base.from;
|
|
137
|
+
}
|
|
138
|
+
const answer = base.op === "ref" && requests.has(base.id) && path[0] === "answers";
|
|
139
|
+
const probability = answer && path.length === 3 && path[2] === "noul";
|
|
140
|
+
const root = base.op === "input"
|
|
141
|
+
? "input"
|
|
142
|
+
: base.op === "ref"
|
|
143
|
+
? base.id
|
|
144
|
+
: describe(base);
|
|
145
|
+
const fields = probability
|
|
146
|
+
? path.slice(1, -1)
|
|
147
|
+
: answer
|
|
148
|
+
? path.slice(1)
|
|
149
|
+
: path;
|
|
150
|
+
const label = root +
|
|
151
|
+
fields
|
|
152
|
+
.map((key) => /^[a-zA-Z_$][\w$]*$/.test(key)
|
|
153
|
+
? `.${key}`
|
|
154
|
+
: `[${JSON.stringify(key)}]`)
|
|
155
|
+
.join("");
|
|
156
|
+
return {
|
|
157
|
+
op: "reference",
|
|
158
|
+
label,
|
|
159
|
+
probability,
|
|
160
|
+
name: answer
|
|
161
|
+
? path.length === 3 && ["noul", "choice", "score"].includes(path[2])
|
|
162
|
+
? path[1]
|
|
163
|
+
: path.slice(1).join(" ")
|
|
164
|
+
: path.at(-1) ?? "Input",
|
|
165
|
+
...(base.op === "ref" && requests.has(base.id)
|
|
166
|
+
? { stepId: base.id, path }
|
|
167
|
+
: base.op === "ref" && tasks.has(base.id)
|
|
168
|
+
? { stepId: base.id, path, source: "task" }
|
|
169
|
+
: {}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (node.op === "literal")
|
|
173
|
+
return {
|
|
174
|
+
op: "literal",
|
|
175
|
+
value: node.value === null ||
|
|
176
|
+
typeof node.value === "string" ||
|
|
177
|
+
typeof node.value === "boolean" ||
|
|
178
|
+
(typeof node.value === "number" && Number.isFinite(node.value))
|
|
179
|
+
? node.value
|
|
180
|
+
: "[value unavailable]",
|
|
181
|
+
};
|
|
182
|
+
const args = [];
|
|
183
|
+
for (const arg of node.args) {
|
|
184
|
+
args.push(visit(arg));
|
|
185
|
+
if (remaining < 0)
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
return { op: node.op, args };
|
|
189
|
+
};
|
|
190
|
+
return visit(ast);
|
|
191
|
+
}
|
|
192
|
+
function evaluate(ast, input, values) {
|
|
193
|
+
if (ast.op === "input")
|
|
194
|
+
return input;
|
|
195
|
+
if (ast.op === "literal")
|
|
196
|
+
return ast.value;
|
|
197
|
+
if (ast.op === "ref") {
|
|
198
|
+
if (!values.has(ast.id))
|
|
199
|
+
throw new Error(`Unavailable result: ${ast.id}`);
|
|
200
|
+
return values.get(ast.id);
|
|
201
|
+
}
|
|
202
|
+
if (ast.op === "get") {
|
|
203
|
+
const obj = evaluate(ast.from, input, values);
|
|
204
|
+
if (obj === null ||
|
|
205
|
+
typeof obj !== "object" ||
|
|
206
|
+
!Object.hasOwn(obj, ast.key) ||
|
|
207
|
+
obj[ast.key] === undefined)
|
|
208
|
+
throw new Error(`Missing value: ${describe(ast)}`);
|
|
209
|
+
return obj[ast.key];
|
|
210
|
+
}
|
|
211
|
+
// Evaluate every operand: a missing value must never hide behind short-circuiting.
|
|
212
|
+
const args = ast.args.map((a) => evaluate(a, input, values));
|
|
213
|
+
if (ast.op === "all" || ast.op === "any" || ast.op === "not") {
|
|
214
|
+
if (args.some((v) => typeof v !== "boolean"))
|
|
215
|
+
throw new Error("Boolean condition required");
|
|
216
|
+
return ast.op === "not"
|
|
217
|
+
? !args[0]
|
|
218
|
+
: ast.op === "all"
|
|
219
|
+
? args.every(Boolean)
|
|
220
|
+
: args.some(Boolean);
|
|
221
|
+
}
|
|
222
|
+
const [a, b] = args;
|
|
223
|
+
if (ast.op === "eq") {
|
|
224
|
+
if (a === undefined ||
|
|
225
|
+
b === undefined ||
|
|
226
|
+
(a !== null && !["string", "number", "boolean"].includes(typeof a)) ||
|
|
227
|
+
(b !== null && !["string", "number", "boolean"].includes(typeof b)) ||
|
|
228
|
+
typeof a !== typeof b ||
|
|
229
|
+
(typeof a === "number" && (!Number.isFinite(a) || !Number.isFinite(b))))
|
|
230
|
+
throw new Error("eq requires matching scalar values");
|
|
231
|
+
return a === b;
|
|
232
|
+
}
|
|
233
|
+
if (typeof a !== "number" ||
|
|
234
|
+
typeof b !== "number" ||
|
|
235
|
+
!Number.isFinite(a) ||
|
|
236
|
+
!Number.isFinite(b))
|
|
237
|
+
throw new Error("Numeric comparison requires finite numbers");
|
|
238
|
+
return ast.op === "lt" ? a < b : ast.op === "gt" ? a > b : a >= b;
|
|
239
|
+
}
|
|
240
|
+
export function defineJevFlow(name, build) {
|
|
241
|
+
const owner = {}, ids = new Set(), nodes = [];
|
|
242
|
+
const input = new FlowExpression({ op: "input" }, owner);
|
|
243
|
+
let building = true;
|
|
244
|
+
let templateSteps = 0;
|
|
245
|
+
let activeBlock;
|
|
246
|
+
function block(callback, available, path, previous = [], depth = 0, requireOutcome = false, requireValue = false) {
|
|
247
|
+
if (depth > 16)
|
|
248
|
+
throw new Error("Flow nesting exceeds 16");
|
|
249
|
+
const token = {};
|
|
250
|
+
const result = { nodes: [] };
|
|
251
|
+
let terminal = false;
|
|
252
|
+
const validate = (value) => mapValue(value, (expr) => {
|
|
253
|
+
if (owners.get(expr) !== owner)
|
|
254
|
+
throw new Error("Foreign or unowned flow reference");
|
|
255
|
+
inspectAst(expr.ast, (id) => {
|
|
256
|
+
if (!available.has(id))
|
|
257
|
+
throw new Error(`Unavailable reference: ${id}`);
|
|
258
|
+
});
|
|
259
|
+
return expr;
|
|
260
|
+
});
|
|
261
|
+
function add(id, kind, value, display) {
|
|
262
|
+
if (!building)
|
|
263
|
+
throw new Error("Flow definition is closed");
|
|
264
|
+
if (activeBlock !== token)
|
|
265
|
+
throw new Error("Use the current branch or lane builder for steps");
|
|
266
|
+
if (terminal)
|
|
267
|
+
throw new Error("No steps allowed after a terminal output or branch");
|
|
268
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(id) || ids.has(id))
|
|
269
|
+
throw new Error(`Invalid or duplicate step ID: ${id}`);
|
|
270
|
+
if (ids.size + templateSteps >= 256)
|
|
271
|
+
throw new Error("Flows support at most 256 steps");
|
|
272
|
+
const copy = {};
|
|
273
|
+
if (display !== undefined) {
|
|
274
|
+
if (!display || typeof display !== "object" || Array.isArray(display))
|
|
275
|
+
throw new Error("Step display metadata must be an object");
|
|
276
|
+
for (const [key, max] of [
|
|
277
|
+
["title", 120],
|
|
278
|
+
["description", 600],
|
|
279
|
+
]) {
|
|
280
|
+
const text = display[key];
|
|
281
|
+
if (text === undefined)
|
|
282
|
+
continue;
|
|
283
|
+
if (typeof text !== "string" || !text.trim() || text.length > max)
|
|
284
|
+
throw new Error(`${key} must be a non-empty string of at most ${max} characters`);
|
|
285
|
+
copy[key] = text.trim();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
ids.add(id);
|
|
289
|
+
validate(value);
|
|
290
|
+
const dependencies = new Set(previous);
|
|
291
|
+
mapValue(value, (expr) => {
|
|
292
|
+
inspectAst(expr.ast, (r) => dependencies.add(r));
|
|
293
|
+
return expr;
|
|
294
|
+
});
|
|
295
|
+
nodes.push({
|
|
296
|
+
id,
|
|
297
|
+
kind,
|
|
298
|
+
dependencies: [...dependencies],
|
|
299
|
+
path: [...path],
|
|
300
|
+
...copy,
|
|
301
|
+
});
|
|
302
|
+
previous = [id];
|
|
303
|
+
available.add(id);
|
|
304
|
+
const step = { id, kind };
|
|
305
|
+
result.nodes.push(step);
|
|
306
|
+
return step;
|
|
307
|
+
}
|
|
308
|
+
const builder = {
|
|
309
|
+
input,
|
|
310
|
+
jev(id, request, display) {
|
|
311
|
+
const dynamic = "request" in request;
|
|
312
|
+
if (dynamic && typeof request.request !== "function")
|
|
313
|
+
throw new Error("Request factory must be a function");
|
|
314
|
+
const frozen = mapValue(dynamic ? request.input : request, (expr) => expr);
|
|
315
|
+
const step = add(id, "jev", frozen, display);
|
|
316
|
+
if (dynamic) {
|
|
317
|
+
step.input = frozen;
|
|
318
|
+
step.factory = request.request;
|
|
319
|
+
step.answerNames = new Set();
|
|
320
|
+
}
|
|
321
|
+
else
|
|
322
|
+
step.request = frozen;
|
|
323
|
+
return {
|
|
324
|
+
answers: new FlowExpression({ op: "get", from: { op: "ref", id }, key: "answers" }, owner),
|
|
325
|
+
answer(question) {
|
|
326
|
+
if (!dynamic && !Object.hasOwn(step.request.questions, question))
|
|
327
|
+
throw new Error(`Unknown question: ${question}`);
|
|
328
|
+
step.answerNames?.add(question);
|
|
329
|
+
const type = dynamic
|
|
330
|
+
? undefined
|
|
331
|
+
: step.request.questions[question].type;
|
|
332
|
+
const fields = dynamic
|
|
333
|
+
? [
|
|
334
|
+
"type",
|
|
335
|
+
"noul",
|
|
336
|
+
"choice",
|
|
337
|
+
"score",
|
|
338
|
+
"confidence",
|
|
339
|
+
"probabilities",
|
|
340
|
+
"legend",
|
|
341
|
+
"box",
|
|
342
|
+
]
|
|
343
|
+
: type === "noul"
|
|
344
|
+
? ["type", "noul"]
|
|
345
|
+
: type === "choice"
|
|
346
|
+
? ["type", "choice", "confidence", "probabilities"]
|
|
347
|
+
: type === "score"
|
|
348
|
+
? ["type", "score", "confidence", "probabilities", "legend"]
|
|
349
|
+
: ["type", "box", "confidence"];
|
|
350
|
+
return Object.fromEntries(fields.map((field) => [
|
|
351
|
+
field,
|
|
352
|
+
new FlowExpression({
|
|
353
|
+
op: "get",
|
|
354
|
+
from: {
|
|
355
|
+
op: "get",
|
|
356
|
+
from: {
|
|
357
|
+
op: "get",
|
|
358
|
+
from: { op: "ref", id },
|
|
359
|
+
key: "answers",
|
|
360
|
+
},
|
|
361
|
+
key: question,
|
|
362
|
+
},
|
|
363
|
+
key: field,
|
|
364
|
+
}, owner),
|
|
365
|
+
]));
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
},
|
|
369
|
+
task(id, task, display) {
|
|
370
|
+
if (typeof task.run !== "function")
|
|
371
|
+
throw new Error("Task run must be a function");
|
|
372
|
+
if (task.timeoutMs !== undefined &&
|
|
373
|
+
(!Number.isInteger(task.timeoutMs) ||
|
|
374
|
+
task.timeoutMs < 1 ||
|
|
375
|
+
task.timeoutMs > 2_147_483_647))
|
|
376
|
+
throw new Error("Task timeoutMs must be a positive 32-bit integer");
|
|
377
|
+
const frozen = mapValue(task.input, (expr) => expr);
|
|
378
|
+
const step = add(id, "task", frozen, display);
|
|
379
|
+
step.input = frozen;
|
|
380
|
+
step.task = task.run;
|
|
381
|
+
step.timeoutMs = task.timeoutMs;
|
|
382
|
+
return new FlowExpression({ op: "ref", id }, owner);
|
|
383
|
+
},
|
|
384
|
+
map(id, config, buildItem, display) {
|
|
385
|
+
const concurrency = config.concurrency ?? 4;
|
|
386
|
+
if (!Number.isInteger(concurrency) ||
|
|
387
|
+
concurrency < 1 ||
|
|
388
|
+
concurrency > 16)
|
|
389
|
+
throw new Error("Map concurrency must be an integer from 1 to 16");
|
|
390
|
+
if (!Number.isInteger(config.maxItems) ||
|
|
391
|
+
config.maxItems < 1 ||
|
|
392
|
+
config.maxItems > 64)
|
|
393
|
+
throw new Error("Map maxItems must be an integer from 1 to 64");
|
|
394
|
+
const frozen = mapValue({ items: config.items, context: config.input }, (e) => e);
|
|
395
|
+
const step = add(id, "map", frozen, display);
|
|
396
|
+
const parentScope = activeBlock;
|
|
397
|
+
activeBlock = undefined;
|
|
398
|
+
let program;
|
|
399
|
+
try {
|
|
400
|
+
program = defineJevFlow(`Item · ${display?.title ?? id}`.slice(0, 100), (item) => {
|
|
401
|
+
const result = buildItem(item);
|
|
402
|
+
let outputId = "item-result";
|
|
403
|
+
for (let suffix = 1; builderIds.get(item).has(outputId); suffix++)
|
|
404
|
+
outputId = `item-result-${suffix}`;
|
|
405
|
+
return item.output(outputId, result, {
|
|
406
|
+
title: "Item result",
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
activeBlock = parentScope;
|
|
412
|
+
}
|
|
413
|
+
if (program.definition.nodes.some((n) => n.kind === "map"))
|
|
414
|
+
throw new Error("Nested maps are not supported");
|
|
415
|
+
if (program.definition.nodes.some((n) => n.kind === "loop"))
|
|
416
|
+
throw new Error("Loops inside maps are not supported");
|
|
417
|
+
templateSteps += definitionSize(program.definition);
|
|
418
|
+
if (ids.size + templateSteps > 256)
|
|
419
|
+
throw new Error("Flows support at most 256 steps including map templates");
|
|
420
|
+
step.collection = {
|
|
421
|
+
...frozen,
|
|
422
|
+
concurrency,
|
|
423
|
+
maxItems: config.maxItems,
|
|
424
|
+
program: program,
|
|
425
|
+
};
|
|
426
|
+
nodes.find((node) => node.id === id).itemDefinition =
|
|
427
|
+
program.definition;
|
|
428
|
+
return new FlowExpression({ op: "ref", id }, owner);
|
|
429
|
+
},
|
|
430
|
+
loop(id, config, buildIteration, display) {
|
|
431
|
+
if (!Number.isInteger(config.maxIterations) ||
|
|
432
|
+
config.maxIterations < 1 ||
|
|
433
|
+
config.maxIterations > 64)
|
|
434
|
+
throw new Error("Loop maxIterations must be an integer from 1 to 64");
|
|
435
|
+
const gateOwner = {};
|
|
436
|
+
const when = config.while(new FlowExpression({ op: "input" }, gateOwner));
|
|
437
|
+
if (!(when instanceof FlowExpression) || owners.get(when) !== gateOwner)
|
|
438
|
+
throw new Error("Loop while must be an expression of its state");
|
|
439
|
+
inspectAst(when.ast, () => {
|
|
440
|
+
throw new Error("Loop while cannot reference outer steps");
|
|
441
|
+
});
|
|
442
|
+
const frozen = mapValue({ initial: config.initial, context: config.input }, (e) => e);
|
|
443
|
+
const step = add(id, "loop", frozen, display);
|
|
444
|
+
const parentScope = activeBlock;
|
|
445
|
+
activeBlock = undefined;
|
|
446
|
+
let program;
|
|
447
|
+
try {
|
|
448
|
+
program = defineJevFlow(`Iteration · ${display?.title ?? id}`.slice(0, 100), (iteration) => {
|
|
449
|
+
const result = buildIteration(iteration);
|
|
450
|
+
let outputId = "iteration-result";
|
|
451
|
+
for (let suffix = 1; builderIds.get(iteration).has(outputId); suffix++)
|
|
452
|
+
outputId = `iteration-result-${suffix}`;
|
|
453
|
+
return iteration.output(outputId, result, {
|
|
454
|
+
title: "Next iteration state",
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
activeBlock = parentScope;
|
|
460
|
+
}
|
|
461
|
+
if (program.definition.nodes.some((n) => n.kind === "loop"))
|
|
462
|
+
throw new Error("Nested loops are not supported");
|
|
463
|
+
templateSteps += definitionSize(program.definition);
|
|
464
|
+
if (ids.size + templateSteps > 256)
|
|
465
|
+
throw new Error("Flows support at most 256 steps including iteration templates");
|
|
466
|
+
step.loop = {
|
|
467
|
+
...frozen,
|
|
468
|
+
when,
|
|
469
|
+
maxIterations: config.maxIterations,
|
|
470
|
+
program,
|
|
471
|
+
};
|
|
472
|
+
const node = nodes.find((n) => n.id === id);
|
|
473
|
+
node.itemDefinition = program.definition;
|
|
474
|
+
// Point displayed gate inputs at the loop's captured latest state.
|
|
475
|
+
// Execution still evaluates the original isolated state expression.
|
|
476
|
+
const referenceState = (ast) => {
|
|
477
|
+
if (ast.op === "input")
|
|
478
|
+
return { op: "ref", id };
|
|
479
|
+
if (ast.op === "get")
|
|
480
|
+
return { ...ast, from: referenceState(ast.from) };
|
|
481
|
+
if ("args" in ast)
|
|
482
|
+
return { ...ast, args: ast.args.map(referenceState) };
|
|
483
|
+
return ast;
|
|
484
|
+
};
|
|
485
|
+
node.conditionRule = conditionRule(referenceState(when.ast), new Set(), new Set([id]));
|
|
486
|
+
return new FlowExpression({ op: "ref", id }, owner);
|
|
487
|
+
},
|
|
488
|
+
if(id, paths, display) {
|
|
489
|
+
if (!(paths.when instanceof FlowExpression))
|
|
490
|
+
throw new Error("if requires a condition expression");
|
|
491
|
+
const step = add(id, "if", paths.when, display);
|
|
492
|
+
step.when = paths.when;
|
|
493
|
+
const node = nodes.find((n) => n.id === id);
|
|
494
|
+
node.condition = describe(paths.when.ast);
|
|
495
|
+
node.conditionRule = conditionRule(paths.when.ast, new Set(nodes.filter((n) => n.kind === "jev").map((n) => n.id)), new Set(nodes
|
|
496
|
+
.filter((n) => n.kind === "task" || n.kind === "merge" || n.kind === "loop")
|
|
497
|
+
.map((n) => n.id)));
|
|
498
|
+
step.then = block(paths.then, new Set(available), [...path, `${id}:then`], [id], depth + 1, false, true);
|
|
499
|
+
step.else = block(paths.else, new Set(available), [...path, `${id}:else`], [id], depth + 1, false, true);
|
|
500
|
+
// A separate merge keeps the diagram acyclic and gives downstream refs a
|
|
501
|
+
// scope-safe identity, including when an arm returns an existing value.
|
|
502
|
+
const base = `${id.slice(0, 65)}-result`;
|
|
503
|
+
let mergeId = base;
|
|
504
|
+
for (let suffix = 2; ids.has(mergeId); suffix++)
|
|
505
|
+
mergeId = `${base}-${suffix}`;
|
|
506
|
+
const merge = add(mergeId, "merge", undefined, {
|
|
507
|
+
title: "Selected result",
|
|
508
|
+
description: "Continue with the value returned by the selected path.",
|
|
509
|
+
});
|
|
510
|
+
merge.mergeOf = id;
|
|
511
|
+
const mergeNode = nodes.find((n) => n.id === mergeId);
|
|
512
|
+
const ends = {
|
|
513
|
+
then: step.then.nodes.at(-1)?.id ?? id,
|
|
514
|
+
else: step.else.nodes.at(-1)?.id ?? id,
|
|
515
|
+
};
|
|
516
|
+
mergeNode.dependencies = [...new Set(Object.values(ends))];
|
|
517
|
+
mergeNode.merge = { condition: id, ...ends };
|
|
518
|
+
return new FlowExpression({ op: "ref", id: mergeId }, owner);
|
|
519
|
+
},
|
|
520
|
+
branch(id, paths, display) {
|
|
521
|
+
const step = add(id, "branch", paths.when, display);
|
|
522
|
+
step.when = paths.when;
|
|
523
|
+
if (!(paths.when instanceof FlowExpression))
|
|
524
|
+
throw new Error("branch requires a condition expression");
|
|
525
|
+
const node = nodes.find((n) => n.id === id);
|
|
526
|
+
node.condition = describe(paths.when.ast);
|
|
527
|
+
node.conditionRule = conditionRule(paths.when.ast, new Set(nodes.filter((n) => n.kind === "jev").map((n) => n.id)), new Set(nodes
|
|
528
|
+
.filter((n) => n.kind === "task" || n.kind === "merge" || n.kind === "loop")
|
|
529
|
+
.map((n) => n.id)));
|
|
530
|
+
step.then = block(paths.then, new Set(available), [...path, `${id}:then`], [id], depth + 1, true);
|
|
531
|
+
step.else = block(paths.else, new Set(available), [...path, `${id}:else`], [id], depth + 1, true);
|
|
532
|
+
terminal = true;
|
|
533
|
+
return new FlowExpression({ op: "ref", id }, owner);
|
|
534
|
+
},
|
|
535
|
+
parallel(id, lanes, display) {
|
|
536
|
+
const prior = [...previous], base = new Set(available);
|
|
537
|
+
const step = add(id, "parallel", undefined, display);
|
|
538
|
+
step.lanes = {};
|
|
539
|
+
if (!Object.keys(lanes).length)
|
|
540
|
+
throw new Error("parallel requires at least one lane");
|
|
541
|
+
const refs = {};
|
|
542
|
+
const tails = [];
|
|
543
|
+
for (const [key, callback] of Object.entries(lanes)) {
|
|
544
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(key))
|
|
545
|
+
throw new Error("Invalid lane name");
|
|
546
|
+
const lane = block(callback, new Set(base), [...path, `${id}:${key}`], prior, depth + 1);
|
|
547
|
+
step.lanes[key] = lane;
|
|
548
|
+
tails.push(lane.nodes.at(-1)?.id ?? prior.at(-1) ?? "");
|
|
549
|
+
refs[key] = new FlowExpression({ op: "get", from: { op: "ref", id }, key }, owner);
|
|
550
|
+
}
|
|
551
|
+
nodes.find((n) => n.id === id).dependencies = tails.filter(Boolean);
|
|
552
|
+
nodes.find((n) => n.id === id).lanes = Object.keys(lanes);
|
|
553
|
+
return refs;
|
|
554
|
+
},
|
|
555
|
+
output(outcome, value, display) {
|
|
556
|
+
const step = add(outcome, "output", value, display);
|
|
557
|
+
step.outcome = outcome;
|
|
558
|
+
step.value = mapValue(value, (e) => e);
|
|
559
|
+
terminal = true;
|
|
560
|
+
return new FlowExpression({ op: "ref", id: outcome }, owner);
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
builderIds.set(builder, ids);
|
|
564
|
+
const parentBlock = activeBlock;
|
|
565
|
+
activeBlock = token;
|
|
566
|
+
let returned;
|
|
567
|
+
try {
|
|
568
|
+
returned = callback(builder);
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
activeBlock = parentBlock;
|
|
572
|
+
}
|
|
573
|
+
if (!(returned instanceof FlowExpression))
|
|
574
|
+
throw new Error("Flow callbacks must return an expression, output, or branch");
|
|
575
|
+
if (requireValue && terminal)
|
|
576
|
+
throw new Error("if paths must return a value, not a terminal output or branch");
|
|
577
|
+
if (requireOutcome && !terminal)
|
|
578
|
+
throw new Error("Branch paths must end with output or branch");
|
|
579
|
+
if (terminal &&
|
|
580
|
+
(returned.ast.op !== "ref" || returned.ast.id !== result.nodes.at(-1).id))
|
|
581
|
+
throw new Error("Return the terminal output or branch");
|
|
582
|
+
validate(returned);
|
|
583
|
+
result.result = returned;
|
|
584
|
+
return result;
|
|
585
|
+
}
|
|
586
|
+
if (!name.trim() || name.length > 100)
|
|
587
|
+
throw new Error("Invalid flow name");
|
|
588
|
+
let program;
|
|
589
|
+
try {
|
|
590
|
+
program = block(build, new Set(), [], [], 0, true);
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
building = false;
|
|
594
|
+
}
|
|
595
|
+
if (!["branch", "output"].includes(program.nodes.at(-1)?.kind ?? ""))
|
|
596
|
+
throw new Error("Flow must end with output or branch");
|
|
597
|
+
const definition = {
|
|
598
|
+
version: 1,
|
|
599
|
+
id: randomUUID(),
|
|
600
|
+
name,
|
|
601
|
+
nodes,
|
|
602
|
+
};
|
|
603
|
+
const definitionView = structuredClone(definition);
|
|
604
|
+
const freezeRule = (rule) => {
|
|
605
|
+
if (!rule || typeof rule !== "object")
|
|
606
|
+
return;
|
|
607
|
+
for (const value of Object.values(rule))
|
|
608
|
+
freezeRule(value);
|
|
609
|
+
Object.freeze(rule);
|
|
610
|
+
};
|
|
611
|
+
for (const node of definitionView.nodes) {
|
|
612
|
+
Object.freeze(node.dependencies);
|
|
613
|
+
Object.freeze(node.path);
|
|
614
|
+
if (node.lanes)
|
|
615
|
+
Object.freeze(node.lanes);
|
|
616
|
+
freezeRule(node.conditionRule);
|
|
617
|
+
freezeRule(node.itemDefinition);
|
|
618
|
+
Object.freeze(node);
|
|
619
|
+
}
|
|
620
|
+
Object.freeze(definitionView.nodes);
|
|
621
|
+
Object.freeze(definitionView);
|
|
622
|
+
return {
|
|
623
|
+
definition: definitionView,
|
|
624
|
+
async run(data, options) {
|
|
625
|
+
const controller = new AbortController();
|
|
626
|
+
// Preserve the initiating failure before aborting siblings: their cancellation
|
|
627
|
+
// can reach a surrounding parallel join before the failing map settles.
|
|
628
|
+
let siblingFailure;
|
|
629
|
+
const stopSiblings = (error) => {
|
|
630
|
+
if (!controller.signal.aborted)
|
|
631
|
+
siblingFailure = { error };
|
|
632
|
+
controller.abort(error);
|
|
633
|
+
};
|
|
634
|
+
const cancel = () => controller.abort(options.signal?.reason);
|
|
635
|
+
if (options.signal?.aborted)
|
|
636
|
+
cancel();
|
|
637
|
+
else
|
|
638
|
+
options.signal?.addEventListener("abort", cancel, { once: true });
|
|
639
|
+
const run = {
|
|
640
|
+
version: 1,
|
|
641
|
+
id: randomUUID(),
|
|
642
|
+
definitionId: definition.id,
|
|
643
|
+
name,
|
|
644
|
+
startedAt: Date.now(),
|
|
645
|
+
status: "pending",
|
|
646
|
+
steps: Object.fromEntries(nodes.map((n) => [
|
|
647
|
+
n.id,
|
|
648
|
+
{ id: n.id, executionId: randomUUID(), status: "waiting" },
|
|
649
|
+
])),
|
|
650
|
+
};
|
|
651
|
+
const started = performance.now(), values = new Map();
|
|
652
|
+
const budget = executionBudgets.get(options) ?? { used: nodes.length };
|
|
653
|
+
const reserve = (count) => {
|
|
654
|
+
if (budget.used + count > 1024)
|
|
655
|
+
throw new RangeError("Flow exceeds 1024 expanded steps");
|
|
656
|
+
budget.used += count;
|
|
657
|
+
};
|
|
658
|
+
let observer;
|
|
659
|
+
try {
|
|
660
|
+
observer = options.devtools?.startFlow(structuredClone(definition), structuredClone(run));
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
/* Recording cannot stop execution. */
|
|
664
|
+
}
|
|
665
|
+
const emit = () => {
|
|
666
|
+
try {
|
|
667
|
+
if (observer) {
|
|
668
|
+
const { value, steps, ...metadata } = run;
|
|
669
|
+
// Stable data snapshots are captured once by the recorder, not
|
|
670
|
+
// cloned/redacted again on every unrelated step update.
|
|
671
|
+
observer.update({
|
|
672
|
+
...structuredClone(metadata),
|
|
673
|
+
steps: Object.fromEntries(Object.entries(steps).map(([id, step]) => [id, { ...step }])),
|
|
674
|
+
...(value !== undefined ? { value } : {}),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
/* Best effort capture. */
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
const abortCheck = () => {
|
|
683
|
+
if (controller.signal.aborted)
|
|
684
|
+
throw new DOMException("Flow cancelled", "AbortError");
|
|
685
|
+
};
|
|
686
|
+
const mark = (b, status) => {
|
|
687
|
+
for (const step of b.nodes) {
|
|
688
|
+
if (run.steps[step.id].status === "waiting")
|
|
689
|
+
run.steps[step.id].status = status;
|
|
690
|
+
if (step.then)
|
|
691
|
+
mark(step.then, status);
|
|
692
|
+
if (step.else)
|
|
693
|
+
mark(step.else, status);
|
|
694
|
+
for (const lane of Object.values(step.lanes ?? {}))
|
|
695
|
+
mark(lane, status);
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
async function execute(b) {
|
|
699
|
+
for (const step of b.nodes) {
|
|
700
|
+
abortCheck();
|
|
701
|
+
const state = run.steps[step.id], time = performance.now();
|
|
702
|
+
state.status = "running";
|
|
703
|
+
state.startedAt = Date.now();
|
|
704
|
+
emit();
|
|
705
|
+
try {
|
|
706
|
+
let value;
|
|
707
|
+
if (step.kind === "jev") {
|
|
708
|
+
const generated = step.factory
|
|
709
|
+
? step.factory(jsonSnapshot(mapValue(step.input, (e) => evaluate(e.ast, data, values))))
|
|
710
|
+
: step.request;
|
|
711
|
+
if (step.factory) {
|
|
712
|
+
if (generated &&
|
|
713
|
+
typeof generated.then ===
|
|
714
|
+
"function") {
|
|
715
|
+
// Invalid async factories must not leave an unhandled rejection.
|
|
716
|
+
void Promise.resolve(generated).catch(() => { });
|
|
717
|
+
throw new TypeError("Request factory must be synchronous");
|
|
718
|
+
}
|
|
719
|
+
if (!plain(generated) || !plain(generated.questions))
|
|
720
|
+
throw new Error("Request factory must return a synchronous SDK request");
|
|
721
|
+
for (const name of step.answerNames)
|
|
722
|
+
if (!Object.hasOwn(generated.questions, name))
|
|
723
|
+
throw new Error(`Unknown generated question: ${name}`);
|
|
724
|
+
}
|
|
725
|
+
const request = mapValue(generated, (e) => {
|
|
726
|
+
if (step.factory)
|
|
727
|
+
throw new Error("Request factories must return resolved values");
|
|
728
|
+
return evaluate(e.ast, data, values);
|
|
729
|
+
});
|
|
730
|
+
abortCheck();
|
|
731
|
+
const call = () => options.client.systemOne(request, {
|
|
732
|
+
signal: controller.signal,
|
|
733
|
+
});
|
|
734
|
+
const link = {
|
|
735
|
+
runId: run.id,
|
|
736
|
+
stepId: step.id,
|
|
737
|
+
executionId: state.executionId,
|
|
738
|
+
};
|
|
739
|
+
value = await runCallback(() => observer
|
|
740
|
+
? observer.request(link, (id) => {
|
|
741
|
+
if (controller.signal.aborted ||
|
|
742
|
+
state.status !== "running")
|
|
743
|
+
return;
|
|
744
|
+
state.traceId = id;
|
|
745
|
+
emit();
|
|
746
|
+
}, call)
|
|
747
|
+
: call(), controller.signal);
|
|
748
|
+
abortCheck();
|
|
749
|
+
}
|
|
750
|
+
else if (step.kind === "task") {
|
|
751
|
+
const input = jsonSnapshot(mapValue(step.input, (e) => evaluate(e.ast, data, values)));
|
|
752
|
+
state.data = { input };
|
|
753
|
+
emit();
|
|
754
|
+
value = jsonSnapshot(await runCallback((signal) => step.task(jsonSnapshot(input), {
|
|
755
|
+
signal,
|
|
756
|
+
runId: run.id,
|
|
757
|
+
stepId: step.id,
|
|
758
|
+
executionId: state.executionId,
|
|
759
|
+
}), controller.signal, step.timeoutMs));
|
|
760
|
+
abortCheck();
|
|
761
|
+
state.data = { input, output: jsonSnapshot(value) };
|
|
762
|
+
}
|
|
763
|
+
else if (step.kind === "loop") {
|
|
764
|
+
const config = step.loop;
|
|
765
|
+
const initial = jsonSnapshot(mapValue(config.initial, (e) => evaluate(e.ast, data, values)));
|
|
766
|
+
const context = jsonSnapshot(mapValue(config.context, (e) => evaluate(e.ast, data, values)));
|
|
767
|
+
let current = jsonSnapshot(initial);
|
|
768
|
+
state.collection = { total: 0, concurrency: 1, items: [] };
|
|
769
|
+
state.loop = { maxIterations: config.maxIterations };
|
|
770
|
+
state.data = { input: initial, output: current };
|
|
771
|
+
for (let index = 0;; index++) {
|
|
772
|
+
abortCheck();
|
|
773
|
+
const decision = evaluate(config.when.ast, current, new Map());
|
|
774
|
+
if (typeof decision !== "boolean")
|
|
775
|
+
throw new TypeError("Loop condition did not return a boolean");
|
|
776
|
+
state.decision = decision;
|
|
777
|
+
if (!decision) {
|
|
778
|
+
state.loop = { ...state.loop, stopReason: "condition" };
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
if (index === config.maxIterations) {
|
|
782
|
+
state.loop = { ...state.loop, stopReason: "limit" };
|
|
783
|
+
throw new FlowLoopLimitError();
|
|
784
|
+
}
|
|
785
|
+
reserve(config.program.definition.nodes.length);
|
|
786
|
+
state.collection = {
|
|
787
|
+
...state.collection,
|
|
788
|
+
total: index + 1,
|
|
789
|
+
items: [
|
|
790
|
+
...state.collection.items,
|
|
791
|
+
{ index, status: "waiting" },
|
|
792
|
+
],
|
|
793
|
+
};
|
|
794
|
+
const updateIteration = (child) => {
|
|
795
|
+
state.collection = {
|
|
796
|
+
...state.collection,
|
|
797
|
+
items: state.collection.items.map((item) => item.index === index
|
|
798
|
+
? {
|
|
799
|
+
index,
|
|
800
|
+
status: child.status === "pending"
|
|
801
|
+
? "running"
|
|
802
|
+
: child.status === "success"
|
|
803
|
+
? "completed"
|
|
804
|
+
: child.status,
|
|
805
|
+
run: child,
|
|
806
|
+
}
|
|
807
|
+
: item),
|
|
808
|
+
};
|
|
809
|
+
emit();
|
|
810
|
+
};
|
|
811
|
+
const result = await config.program.run({ state: current, index, context }, withBudget({
|
|
812
|
+
client: options.client,
|
|
813
|
+
signal: controller.signal,
|
|
814
|
+
devtools: {
|
|
815
|
+
startFlow(_definition, child) {
|
|
816
|
+
updateIteration(child);
|
|
817
|
+
return {
|
|
818
|
+
update: updateIteration,
|
|
819
|
+
request(link, onTrace, callback) {
|
|
820
|
+
return observer
|
|
821
|
+
? observer.request({
|
|
822
|
+
...link,
|
|
823
|
+
runId: run.id,
|
|
824
|
+
stepId: `${step.id}/${index}/${link.stepId}`,
|
|
825
|
+
}, onTrace, callback)
|
|
826
|
+
: callback();
|
|
827
|
+
},
|
|
828
|
+
};
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
}, budget));
|
|
832
|
+
abortCheck();
|
|
833
|
+
current = jsonSnapshot(result.value);
|
|
834
|
+
state.data = { input: initial, output: current };
|
|
835
|
+
}
|
|
836
|
+
value = current;
|
|
837
|
+
}
|
|
838
|
+
else if (step.kind === "map") {
|
|
839
|
+
const config = step.collection;
|
|
840
|
+
const items = jsonSnapshot(mapValue(config.items, (e) => evaluate(e.ast, data, values)));
|
|
841
|
+
const context = jsonSnapshot(mapValue(config.context, (e) => evaluate(e.ast, data, values)));
|
|
842
|
+
if (!Array.isArray(items))
|
|
843
|
+
throw new TypeError("Map items must be an array");
|
|
844
|
+
if (items.length > config.maxItems)
|
|
845
|
+
throw new RangeError("Map exceeds maxItems");
|
|
846
|
+
reserve(items.length * config.program.definition.nodes.length);
|
|
847
|
+
state.collection = {
|
|
848
|
+
total: items.length,
|
|
849
|
+
concurrency: config.concurrency,
|
|
850
|
+
items: items.map((_, index) => ({ index, status: "waiting" })),
|
|
851
|
+
};
|
|
852
|
+
emit();
|
|
853
|
+
const results = new Array(items.length);
|
|
854
|
+
let next = 0, failed = false, firstError;
|
|
855
|
+
const updateItem = (index, child) => {
|
|
856
|
+
state.collection = {
|
|
857
|
+
...state.collection,
|
|
858
|
+
items: state.collection.items.map((item) => item.index === index
|
|
859
|
+
? {
|
|
860
|
+
index,
|
|
861
|
+
status: child.status === "pending"
|
|
862
|
+
? "running"
|
|
863
|
+
: child.status === "success"
|
|
864
|
+
? "completed"
|
|
865
|
+
: child.status,
|
|
866
|
+
run: child,
|
|
867
|
+
}
|
|
868
|
+
: item),
|
|
869
|
+
};
|
|
870
|
+
emit();
|
|
871
|
+
};
|
|
872
|
+
const worker = async () => {
|
|
873
|
+
while (!controller.signal.aborted &&
|
|
874
|
+
!failed &&
|
|
875
|
+
next < items.length) {
|
|
876
|
+
const index = next++;
|
|
877
|
+
try {
|
|
878
|
+
const result = await config.program.run({ item: items[index], index, context }, withBudget({
|
|
879
|
+
client: options.client,
|
|
880
|
+
signal: controller.signal,
|
|
881
|
+
devtools: {
|
|
882
|
+
startFlow(_definition, child) {
|
|
883
|
+
updateItem(index, child);
|
|
884
|
+
return {
|
|
885
|
+
update(child) {
|
|
886
|
+
updateItem(index, child);
|
|
887
|
+
},
|
|
888
|
+
request(link, onTrace, callback) {
|
|
889
|
+
return observer
|
|
890
|
+
? observer.request({
|
|
891
|
+
...link,
|
|
892
|
+
runId: run.id,
|
|
893
|
+
stepId: `${step.id}/${index}/${link.stepId}`,
|
|
894
|
+
}, onTrace, callback)
|
|
895
|
+
: callback();
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
}, budget));
|
|
901
|
+
results[index] = result.value;
|
|
902
|
+
}
|
|
903
|
+
catch (error) {
|
|
904
|
+
if (!failed) {
|
|
905
|
+
failed = true;
|
|
906
|
+
firstError = error;
|
|
907
|
+
}
|
|
908
|
+
stopSiblings(error);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
await Promise.all(Array.from({ length: Math.min(config.concurrency, items.length) }, worker));
|
|
913
|
+
if (failed || controller.signal.aborted) {
|
|
914
|
+
state.collection = {
|
|
915
|
+
...state.collection,
|
|
916
|
+
items: state.collection.items.map((item) => item.status === "waiting"
|
|
917
|
+
? {
|
|
918
|
+
...item,
|
|
919
|
+
status: options.signal?.aborted
|
|
920
|
+
? "cancelled"
|
|
921
|
+
: "blocked",
|
|
922
|
+
}
|
|
923
|
+
: item),
|
|
924
|
+
};
|
|
925
|
+
if (failed)
|
|
926
|
+
throw firstError;
|
|
927
|
+
abortCheck();
|
|
928
|
+
}
|
|
929
|
+
value = results;
|
|
930
|
+
}
|
|
931
|
+
else if (step.kind === "merge") {
|
|
932
|
+
value = values.get(step.mergeOf);
|
|
933
|
+
state.decision = run.steps[step.mergeOf].decision;
|
|
934
|
+
// Capture is observational; a direct input/reference can contain
|
|
935
|
+
// a non-JSON value even though task boundaries require JSON.
|
|
936
|
+
try {
|
|
937
|
+
state.data = { output: jsonSnapshot(value) };
|
|
938
|
+
}
|
|
939
|
+
catch {
|
|
940
|
+
state.data = "Selected value could not be captured as JSON.";
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
else if (step.kind === "branch" || step.kind === "if") {
|
|
944
|
+
const decision = evaluate(step.when.ast, data, values);
|
|
945
|
+
if (typeof decision !== "boolean")
|
|
946
|
+
throw new Error("Branch condition did not return a boolean");
|
|
947
|
+
state.decision = decision;
|
|
948
|
+
mark(decision ? step.else : step.then, "untaken");
|
|
949
|
+
emit();
|
|
950
|
+
value = await execute(decision ? step.then : step.else);
|
|
951
|
+
}
|
|
952
|
+
else if (step.kind === "parallel") {
|
|
953
|
+
const entries = Object.entries(step.lanes);
|
|
954
|
+
let firstError;
|
|
955
|
+
let failed = false;
|
|
956
|
+
const results = await Promise.allSettled(entries.map(async ([, lane]) => {
|
|
957
|
+
try {
|
|
958
|
+
return await execute(lane);
|
|
959
|
+
}
|
|
960
|
+
catch (e) {
|
|
961
|
+
if (!failed) {
|
|
962
|
+
firstError = siblingFailure ? siblingFailure.error : e;
|
|
963
|
+
failed = true;
|
|
964
|
+
}
|
|
965
|
+
stopSiblings(e);
|
|
966
|
+
throw e;
|
|
967
|
+
}
|
|
968
|
+
}));
|
|
969
|
+
if (failed)
|
|
970
|
+
throw firstError;
|
|
971
|
+
value = Object.fromEntries(results.map((r, i) => [
|
|
972
|
+
entries[i][0],
|
|
973
|
+
r.value,
|
|
974
|
+
]));
|
|
975
|
+
}
|
|
976
|
+
else
|
|
977
|
+
value = {
|
|
978
|
+
outcome: step.outcome,
|
|
979
|
+
value: mapValue(step.value, (e) => evaluate(e.ast, data, values)),
|
|
980
|
+
};
|
|
981
|
+
values.set(step.id, value);
|
|
982
|
+
state.status = "completed";
|
|
983
|
+
}
|
|
984
|
+
catch (error) {
|
|
985
|
+
state.status =
|
|
986
|
+
error instanceof Error &&
|
|
987
|
+
["AbortError", "APIUserAbortError"].includes(error.name)
|
|
988
|
+
? "cancelled"
|
|
989
|
+
: "error";
|
|
990
|
+
state.error = error instanceof Error ? error.name : "FlowError";
|
|
991
|
+
throw error;
|
|
992
|
+
}
|
|
993
|
+
finally {
|
|
994
|
+
state.durationMs = performance.now() - time;
|
|
995
|
+
emit();
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return evaluate(b.result.ast, data, values);
|
|
999
|
+
}
|
|
1000
|
+
try {
|
|
1001
|
+
const result = (await execute(program));
|
|
1002
|
+
abortCheck();
|
|
1003
|
+
if (!result || typeof result.outcome !== "string")
|
|
1004
|
+
throw new Error("Flow must return a named output");
|
|
1005
|
+
run.status = "success";
|
|
1006
|
+
run.outcome = result.outcome;
|
|
1007
|
+
// Only the recorder snapshots output data; runtime keeps the actual typed value.
|
|
1008
|
+
if (result.value !== undefined)
|
|
1009
|
+
run.value = result.value;
|
|
1010
|
+
return { runId: run.id, ...result };
|
|
1011
|
+
}
|
|
1012
|
+
catch (error) {
|
|
1013
|
+
run.status =
|
|
1014
|
+
error instanceof Error &&
|
|
1015
|
+
["AbortError", "APIUserAbortError"].includes(error.name)
|
|
1016
|
+
? "cancelled"
|
|
1017
|
+
: "error";
|
|
1018
|
+
run.error = error instanceof Error ? error.name : "FlowError";
|
|
1019
|
+
mark(program, run.status === "cancelled" ? "cancelled" : "blocked");
|
|
1020
|
+
throw error;
|
|
1021
|
+
}
|
|
1022
|
+
finally {
|
|
1023
|
+
run.durationMs = performance.now() - started;
|
|
1024
|
+
emit();
|
|
1025
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
1026
|
+
}
|
|
1027
|
+
},
|
|
1028
|
+
};
|
|
1029
|
+
}
|