jev-devtools 0.3.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/lib/flow/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { jsonSnapshot, runCallback } from "./runtime.js";
2
3
  const owners = new WeakMap();
4
+ const builderIds = new WeakMap();
3
5
  function freezeAst(ast, depth = 0) {
4
6
  if (depth > 80)
5
7
  throw new Error("Expression nesting exceeds 80");
@@ -35,6 +37,9 @@ export class FlowExpression {
35
37
  lt(value) {
36
38
  return operation("lt", [this, value]);
37
39
  }
40
+ gt(value) {
41
+ return operation("gt", [this, value]);
42
+ }
38
43
  gte(value) {
39
44
  return operation("gte", [this, value]);
40
45
  }
@@ -57,6 +62,23 @@ function operation(op, args) {
57
62
  export const all = (...conditions) => operation("all", conditions);
58
63
  export const any = (...conditions) => operation("any", conditions);
59
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
+ }
60
82
  const plain = (v) => !!v &&
61
83
  typeof v === "object" &&
62
84
  (Object.getPrototypeOf(v) === Object.prototype ||
@@ -101,7 +123,7 @@ function describe(ast) {
101
123
  return "[literal]";
102
124
  return `${ast.op}(${ast.args.map(describe).join(", ")})`;
103
125
  }
104
- function conditionRule(ast, requests) {
126
+ function conditionRule(ast, requests, tasks) {
105
127
  let remaining = 128;
106
128
  const visit = (node) => {
107
129
  if (--remaining < 0)
@@ -139,10 +161,12 @@ function conditionRule(ast, requests) {
139
161
  ? path.length === 3 && ["noul", "choice", "score"].includes(path[2])
140
162
  ? path[1]
141
163
  : path.slice(1).join(" ")
142
- : (path.at(-1) ?? "Input"),
164
+ : path.at(-1) ?? "Input",
143
165
  ...(base.op === "ref" && requests.has(base.id)
144
166
  ? { stepId: base.id, path }
145
- : {}),
167
+ : base.op === "ref" && tasks.has(base.id)
168
+ ? { stepId: base.id, path, source: "task" }
169
+ : {}),
146
170
  };
147
171
  }
148
172
  if (node.op === "literal")
@@ -211,14 +235,15 @@ function evaluate(ast, input, values) {
211
235
  !Number.isFinite(a) ||
212
236
  !Number.isFinite(b))
213
237
  throw new Error("Numeric comparison requires finite numbers");
214
- return ast.op === "lt" ? a < b : a >= b;
238
+ return ast.op === "lt" ? a < b : ast.op === "gt" ? a > b : a >= b;
215
239
  }
216
240
  export function defineJevFlow(name, build) {
217
241
  const owner = {}, ids = new Set(), nodes = [];
218
242
  const input = new FlowExpression({ op: "input" }, owner);
219
243
  let building = true;
244
+ let templateSteps = 0;
220
245
  let activeBlock;
221
- function block(callback, available, path, previous = [], depth = 0, requireOutcome = false) {
246
+ function block(callback, available, path, previous = [], depth = 0, requireOutcome = false, requireValue = false) {
222
247
  if (depth > 16)
223
248
  throw new Error("Flow nesting exceeds 16");
224
249
  const token = {};
@@ -242,7 +267,7 @@ export function defineJevFlow(name, build) {
242
267
  throw new Error("No steps allowed after a terminal output or branch");
243
268
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(id) || ids.has(id))
244
269
  throw new Error(`Invalid or duplicate step ID: ${id}`);
245
- if (ids.size >= 256)
270
+ if (ids.size + templateSteps >= 256)
246
271
  throw new Error("Flows support at most 256 steps");
247
272
  const copy = {};
248
273
  if (display !== undefined) {
@@ -283,21 +308,45 @@ export function defineJevFlow(name, build) {
283
308
  const builder = {
284
309
  input,
285
310
  jev(id, request, display) {
286
- const frozen = mapValue(request, (expr) => expr);
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);
287
315
  const step = add(id, "jev", frozen, display);
288
- step.request = frozen;
316
+ if (dynamic) {
317
+ step.input = frozen;
318
+ step.factory = request.request;
319
+ step.answerNames = new Set();
320
+ }
321
+ else
322
+ step.request = frozen;
289
323
  return {
324
+ answers: new FlowExpression({ op: "get", from: { op: "ref", id }, key: "answers" }, owner),
290
325
  answer(question) {
291
- if (!Object.hasOwn(frozen.questions, question))
326
+ if (!dynamic && !Object.hasOwn(step.request.questions, question))
292
327
  throw new Error(`Unknown question: ${question}`);
293
- const type = frozen.questions[question].type;
294
- const fields = type === "noul"
295
- ? ["type", "noul"]
296
- : type === "choice"
297
- ? ["type", "choice", "confidence", "probabilities"]
298
- : type === "score"
299
- ? ["type", "score", "confidence", "probabilities", "legend"]
300
- : ["type", "box", "confidence"];
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"];
301
350
  return Object.fromEntries(fields.map((field) => [
302
351
  field,
303
352
  new FlowExpression({
@@ -317,6 +366,157 @@ export function defineJevFlow(name, build) {
317
366
  },
318
367
  };
319
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
+ },
320
520
  branch(id, paths, display) {
321
521
  const step = add(id, "branch", paths.when, display);
322
522
  step.when = paths.when;
@@ -324,7 +524,9 @@ export function defineJevFlow(name, build) {
324
524
  throw new Error("branch requires a condition expression");
325
525
  const node = nodes.find((n) => n.id === id);
326
526
  node.condition = describe(paths.when.ast);
327
- node.conditionRule = conditionRule(paths.when.ast, new Set(nodes.filter((n) => n.kind === "jev").map((n) => n.id)));
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)));
328
530
  step.then = block(paths.then, new Set(available), [...path, `${id}:then`], [id], depth + 1, true);
329
531
  step.else = block(paths.else, new Set(available), [...path, `${id}:else`], [id], depth + 1, true);
330
532
  terminal = true;
@@ -358,6 +560,7 @@ export function defineJevFlow(name, build) {
358
560
  return new FlowExpression({ op: "ref", id: outcome }, owner);
359
561
  },
360
562
  };
563
+ builderIds.set(builder, ids);
361
564
  const parentBlock = activeBlock;
362
565
  activeBlock = token;
363
566
  let returned;
@@ -369,6 +572,8 @@ export function defineJevFlow(name, build) {
369
572
  }
370
573
  if (!(returned instanceof FlowExpression))
371
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");
372
577
  if (requireOutcome && !terminal)
373
578
  throw new Error("Branch paths must end with output or branch");
374
579
  if (terminal &&
@@ -409,6 +614,7 @@ export function defineJevFlow(name, build) {
409
614
  if (node.lanes)
410
615
  Object.freeze(node.lanes);
411
616
  freezeRule(node.conditionRule);
617
+ freezeRule(node.itemDefinition);
412
618
  Object.freeze(node);
413
619
  }
414
620
  Object.freeze(definitionView.nodes);
@@ -417,6 +623,14 @@ export function defineJevFlow(name, build) {
417
623
  definition: definitionView,
418
624
  async run(data, options) {
419
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
+ };
420
634
  const cancel = () => controller.abort(options.signal?.reason);
421
635
  if (options.signal?.aborted)
422
636
  cancel();
@@ -435,6 +649,12 @@ export function defineJevFlow(name, build) {
435
649
  ])),
436
650
  };
437
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
+ };
438
658
  let observer;
439
659
  try {
440
660
  observer = options.devtools?.startFlow(structuredClone(definition), structuredClone(run));
@@ -445,9 +665,12 @@ export function defineJevFlow(name, build) {
445
665
  const emit = () => {
446
666
  try {
447
667
  if (observer) {
448
- const { value, ...metadata } = run;
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.
449
671
  observer.update({
450
672
  ...structuredClone(metadata),
673
+ steps: Object.fromEntries(Object.entries(steps).map(([id, step]) => [id, { ...step }])),
451
674
  ...(value !== undefined ? { value } : {}),
452
675
  });
453
676
  }
@@ -482,7 +705,29 @@ export function defineJevFlow(name, build) {
482
705
  try {
483
706
  let value;
484
707
  if (step.kind === "jev") {
485
- const request = mapValue(step.request, (e) => evaluate(e.ast, data, values));
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();
486
731
  const call = () => options.client.systemOne(request, {
487
732
  signal: controller.signal,
488
733
  });
@@ -491,15 +736,211 @@ export function defineJevFlow(name, build) {
491
736
  stepId: step.id,
492
737
  executionId: state.executionId,
493
738
  };
494
- value = await (observer
739
+ value = await runCallback(() => observer
495
740
  ? observer.request(link, (id) => {
741
+ if (controller.signal.aborted ||
742
+ state.status !== "running")
743
+ return;
496
744
  state.traceId = id;
497
745
  emit();
498
746
  }, call)
499
- : call());
747
+ : call(), controller.signal);
500
748
  abortCheck();
501
749
  }
502
- else if (step.kind === "branch") {
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") {
503
944
  const decision = evaluate(step.when.ast, data, values);
504
945
  if (typeof decision !== "boolean")
505
946
  throw new Error("Branch condition did not return a boolean");
@@ -518,10 +959,10 @@ export function defineJevFlow(name, build) {
518
959
  }
519
960
  catch (e) {
520
961
  if (!failed) {
521
- firstError = e;
962
+ firstError = siblingFailure ? siblingFailure.error : e;
522
963
  failed = true;
523
964
  }
524
- controller.abort(e);
965
+ stopSiblings(e);
525
966
  throw e;
526
967
  }
527
968
  }));
@@ -0,0 +1,5 @@
1
+ import type { Json } from "../src/protocol.js";
2
+ /** Detached JSON snapshot, without capture truncation or lossy JSON.stringify. */
3
+ export declare function jsonSnapshot(value: unknown, seen?: Set<object>, depth?: number): Json;
4
+ /** Cancellation ends awaiting, even when user code ignores the cooperative signal. */
5
+ export declare function runCallback(callback: (signal: AbortSignal) => unknown, parent: AbortSignal, timeoutMs?: number): Promise<unknown>;