pipeai 0.8.2 → 0.9.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/dist/index.cjs CHANGED
@@ -20,18 +20,17 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ ABORT_STEP_ID: () => ABORT_STEP_ID,
23
24
  Agent: () => Agent,
24
25
  CHECKPOINT_STEP_ID: () => CHECKPOINT_STEP_ID,
25
- CheckpointTimeoutError: () => CheckpointTimeoutError,
26
- NestedGateUnsupportedError: () => NestedGateUnsupportedError,
27
- SKIP: () => SKIP,
26
+ GATE_RESUME_STEP_ID: () => GATE_RESUME_STEP_ID,
27
+ SKIP: () => SKIP2,
28
28
  TOOL_PROVIDER_BRAND: () => TOOL_PROVIDER_BRAND,
29
29
  ToolProvider: () => ToolProvider,
30
30
  Workflow: () => Workflow,
31
31
  WorkflowBranchError: () => WorkflowBranchError,
32
32
  WorkflowLoopError: () => WorkflowLoopError,
33
33
  defineTool: () => defineTool,
34
- getActiveWriter: () => getActiveWriter,
35
34
  isToolProvider: () => isToolProvider,
36
35
  migrateSnapshot: () => migrateSnapshot
37
36
  });
@@ -53,6 +52,7 @@ function runWithWriter(writer, fn) {
53
52
  function getActiveWriter() {
54
53
  return writerStorage.getStore();
55
54
  }
55
+ var SKIP = /* @__PURE__ */ Symbol("pipeai.foreach.skip");
56
56
  function resolveValue(value, ctx, input) {
57
57
  if (typeof value === "function") {
58
58
  return value(ctx, input);
@@ -292,7 +292,15 @@ var Agent = class {
292
292
  const resolved = await this.resolveConfig(ctx, input);
293
293
  const options = this.buildCallOptions(resolved, ctx, input);
294
294
  try {
295
- const onErrorOption = this.config.onError ? { onError: ({ error }) => this.invokeOnError(error, ctx, input) } : {};
295
+ const onErrorOption = this.config.onError ? {
296
+ onError: async ({ error }) => {
297
+ try {
298
+ await this.invokeOnError(error, ctx, input);
299
+ } catch (handlerError) {
300
+ console.error(`Agent "${this.id}": onError handler threw on the stream path:`, handlerError);
301
+ }
302
+ }
303
+ } : {};
296
304
  return (0, import_ai2.streamText)({
297
305
  ...options,
298
306
  ...extra,
@@ -388,6 +396,179 @@ var Agent = class {
388
396
 
389
397
  // src/workflow.ts
390
398
  var import_ai3 = require("ai");
399
+
400
+ // src/steps/step.ts
401
+ var Step = class {
402
+ /**
403
+ * Observability event subtype for `type: "step"` nodes (agent / transform =
404
+ * `"step"`; nested / branch / foreach / repeat / parallel override).
405
+ * `undefined` on gate / catch / finally nodes, whose `type` IS the event type.
406
+ */
407
+ category;
408
+ /**
409
+ * The sealed sub-workflow attached to this node, when it has one (`nested`,
410
+ * and workflow-target `foreach` / `repeat`). Consumed by the recursive
411
+ * `stepShapeHash` walk and the resume path-walk in `loadState`.
412
+ */
413
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
414
+ nestedWorkflow;
415
+ /**
416
+ * Precedence source tag a kind writes to `state.pendingError` when it
417
+ * captures a thrown body error. Defaults to `"step"`; kinds with a distinct
418
+ * error-precedence bucket (e.g. `finally`, `catch`, `gate`) override it.
419
+ */
420
+ errorSource = "step";
421
+ /**
422
+ * The step's body, invoked by the run loop only after {@link shouldSkip}
423
+ * returned `false`. Each kind overrides it to do its work and capture errors
424
+ * onto state. `state.output` is the input on entry and becomes the output on
425
+ * exit; `state.writer` is present in stream mode. The base implementation is
426
+ * a no-op so kinds that carry no body of their own need not override it.
427
+ */
428
+ async execute(_state) {
429
+ }
430
+ /**
431
+ * Run-policy gate, called by the run loop before {@link execute}: return
432
+ * `true` when this step should be skipped silently (no hooks, no output
433
+ * change). The default is the "normal" policy — skip while the flow is
434
+ * suspended or already in error. Overridden by kinds with inverted policies:
435
+ * `catch` runs only when there's an error, `finally` always runs.
436
+ */
437
+ shouldSkip(state) {
438
+ return !!state.suspension || !!state.pendingError;
439
+ }
440
+ /**
441
+ * Apply `when` / `otherwise` conditional-skip options. Returns `true` when
442
+ * the body should be skipped — i.e. `when` returned false. On skip,
443
+ * `otherwise` (if present) produces the output; without it the input passes
444
+ * through unchanged. Distinct from {@link shouldSkip}: this is the body-level
445
+ * `when` / `otherwise` decision a kind applies after the policy gate passes.
446
+ */
447
+ async applyConditionalSkip(state, options) {
448
+ if (!options?.when) return false;
449
+ const params = { ctx: state.ctx, input: state.output };
450
+ if (await options.when(params)) return false;
451
+ if (options.otherwise) {
452
+ state.output = await options.otherwise(params);
453
+ }
454
+ return true;
455
+ }
456
+ };
457
+
458
+ // src/steps/transform-step.ts
459
+ var TransformStep = class extends Step {
460
+ type = "step";
461
+ id;
462
+ fn;
463
+ options;
464
+ constructor(id, fn, options) {
465
+ super();
466
+ this.id = id;
467
+ this.fn = fn;
468
+ this.options = options;
469
+ }
470
+ async execute(state) {
471
+ try {
472
+ if (await this.applyConditionalSkip(state, this.options)) return;
473
+ state.output = await this.fn({
474
+ ctx: state.ctx,
475
+ input: state.output,
476
+ // Present in stream mode (undefined in generate mode), letting the
477
+ // inline step emit UIMessageChunk parts onto the workflow's stream.
478
+ writer: state.writer
479
+ });
480
+ } catch (error) {
481
+ state.pendingError = { error, stepId: this.id, source: this.errorSource };
482
+ }
483
+ }
484
+ };
485
+
486
+ // src/steps/agent-step.ts
487
+ var AgentStep = class _AgentStep extends Step {
488
+ type = "step";
489
+ id;
490
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
491
+ agent;
492
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
493
+ options;
494
+ constructor(id, agent, options) {
495
+ super();
496
+ this.id = id;
497
+ this.agent = agent;
498
+ this.options = options;
499
+ }
500
+ async execute(state) {
501
+ try {
502
+ if (await this.applyConditionalSkip(state, this.options)) return;
503
+ await _AgentStep.runAgent(state, this.agent, state.ctx, this.options);
504
+ } catch (error) {
505
+ state.pendingError = { error, stepId: this.id, source: this.errorSource };
506
+ }
507
+ }
508
+ /**
509
+ * Run an agent against the current state, writing its result to
510
+ * `state.output`. In stream mode, output extraction awaits the full stream
511
+ * before returning — streaming benefits the client (incremental output), not
512
+ * pipeline throughput, since each step still runs sequentially.
513
+ *
514
+ * Static (does not touch instance state) so the still-literal foreach /
515
+ * parallel / branch combinators can share it. `itemIndex` identifies the
516
+ * execution to `handleStream` inside a multi-execution combinator (numeric
517
+ * index, record key, or matched case); `undefined` for a plain single
518
+ * `.step(agent)`.
519
+ */
520
+ static async runAgent(state, agent, ctx, options, itemIndex) {
521
+ const input = state.output;
522
+ const hasStructuredOutput = agent.hasOutput;
523
+ const abortSignal = state.abortSignal;
524
+ const agentCallOpts = abortSignal ? { abortSignal } : void 0;
525
+ if (state.mode === "stream" && state.writer) {
526
+ const writer = state.writer;
527
+ await runWithWriter(writer, async () => {
528
+ const result = await agent.stream(ctx, state.output, agentCallOpts);
529
+ if (options?.handleStream) {
530
+ await options.handleStream({ result, writer, ctx, input, itemIndex });
531
+ } else {
532
+ writer.merge(result.toUIMessageStream());
533
+ }
534
+ const hookParams = {
535
+ mode: "stream",
536
+ result,
537
+ ctx,
538
+ input
539
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
540
+ };
541
+ if (options?.onResult) {
542
+ await options.onResult(hookParams);
543
+ }
544
+ if (options?.mapResult) {
545
+ state.output = await options.mapResult(hookParams);
546
+ } else {
547
+ state.output = await extractOutput(result, hasStructuredOutput, agent.validateOutput);
548
+ }
549
+ });
550
+ } else {
551
+ const result = await agent.generate(ctx, state.output, agentCallOpts);
552
+ const hookParams = {
553
+ mode: "generate",
554
+ result,
555
+ ctx,
556
+ input
557
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
558
+ };
559
+ if (options?.onResult) {
560
+ await options.onResult(hookParams);
561
+ }
562
+ if (options?.mapResult) {
563
+ state.output = await options.mapResult(hookParams);
564
+ } else {
565
+ state.output = await extractOutput(result, hasStructuredOutput, agent.validateOutput);
566
+ }
567
+ }
568
+ }
569
+ };
570
+
571
+ // src/errors.ts
391
572
  var WorkflowBranchError = class extends Error {
392
573
  constructor(branchType, message) {
393
574
  super(message);
@@ -403,65 +584,118 @@ var WorkflowLoopError = class extends Error {
403
584
  this.name = "WorkflowLoopError";
404
585
  }
405
586
  };
406
- var NestedGateUnsupportedError = class extends Error {
407
- gateId;
408
- workflowId;
409
- // Always present; non-gate rejections from concurrent foreach.
410
- siblingErrors;
411
- // Always present; OTHER suspending items in concurrent foreach.
412
- siblingSuspensions;
413
- constructor(gateId, workflowId, siblingErrors = [], siblingSuspensions = []) {
414
- super(`Gate "${gateId}" hit inside nested workflow "${workflowId ?? "(anonymous)"}". Nested gates are not yet supported.`);
415
- this.name = "NestedGateUnsupportedError";
416
- this.gateId = gateId;
417
- this.workflowId = workflowId;
418
- this.siblingErrors = siblingErrors;
419
- this.siblingSuspensions = siblingSuspensions;
420
- }
421
- };
422
587
  var CHECKPOINT_STEP_ID = "::pipeai::onCheckpoint";
423
- var CheckpointTimeoutError = class extends Error {
424
- timeoutMs;
425
- constructor(timeoutMs) {
426
- super(`onCheckpoint exceeded ${timeoutMs}ms timeout`);
427
- this.name = "CheckpointTimeoutError";
428
- this.timeoutMs = timeoutMs;
588
+ var ABORT_STEP_ID = "::pipeai::abort";
589
+ var GATE_RESUME_STEP_ID = "::pipeai::gate:resume";
590
+
591
+ // src/steps/branch-step.ts
592
+ var PredicateBranchStep = class extends Step {
593
+ type = "step";
594
+ category = "branch";
595
+ id;
596
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
597
+ cases;
598
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
599
+ constructor(id, cases) {
600
+ super();
601
+ this.id = id;
602
+ this.cases = cases;
603
+ }
604
+ async execute(state) {
605
+ try {
606
+ const ctx = state.ctx;
607
+ const input = state.output;
608
+ for (let caseIndex = 0; caseIndex < this.cases.length; caseIndex++) {
609
+ const branchCase = this.cases[caseIndex];
610
+ if (branchCase.when) {
611
+ const match = await branchCase.when({ ctx, input });
612
+ if (!match) continue;
613
+ }
614
+ await AgentStep.runAgent(state, branchCase.agent, ctx, branchCase, caseIndex);
615
+ return;
616
+ }
617
+ let inputRepr;
618
+ try {
619
+ inputRepr = JSON.stringify(input);
620
+ if (inputRepr === void 0) inputRepr = String(input);
621
+ } catch {
622
+ inputRepr = `[unserializable ${typeof input}]`;
623
+ }
624
+ throw new WorkflowBranchError("predicate", `No branch matched and no default branch (a case without \`when\`) was provided. Input: ${inputRepr}`);
625
+ } catch (error) {
626
+ state.pendingError = { error, stepId: this.id, source: this.errorSource };
627
+ }
429
628
  }
430
629
  };
431
- function resolveFreezeSnapshots(state) {
432
- return state.runOptions?.freezeSnapshots ? true : false;
433
- }
434
- function migrateSnapshot(legacy) {
435
- if (legacy.version !== 1) {
436
- throw new Error(`migrateSnapshot: expected v1 snapshot, got version ${legacy.version}`);
630
+ var SelectBranchStep = class extends Step {
631
+ type = "step";
632
+ category = "branch";
633
+ id;
634
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
635
+ config;
636
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
637
+ constructor(id, config) {
638
+ super();
639
+ this.id = id;
640
+ this.config = config;
437
641
  }
642
+ async execute(state) {
643
+ try {
644
+ const ctx = state.ctx;
645
+ const input = state.output;
646
+ const config = this.config;
647
+ const key = await config.select({ ctx, input });
648
+ const keyDeclared = Object.prototype.hasOwnProperty.call(config.agents, key);
649
+ if (keyDeclared && config.agents[key] === void 0) {
650
+ throw new WorkflowBranchError(
651
+ "select",
652
+ `Agent for key "${key}" was declared but the value is undefined. This usually means a conditional spread set the value to undefined. Available keys: ${Object.keys(config.agents).join(", ")}`
653
+ );
654
+ }
655
+ let agent = keyDeclared ? config.agents[key] : void 0;
656
+ if (!agent) {
657
+ if (config.onUnknownKey) {
658
+ config.onUnknownKey({
659
+ key,
660
+ availableKeys: Object.keys(config.agents),
661
+ ctx
662
+ });
663
+ }
664
+ if (config.fallback) {
665
+ agent = config.fallback;
666
+ } else {
667
+ throw new WorkflowBranchError("select", `No agent found for key "${key}" and no fallback provided. Available keys: ${Object.keys(config.agents).join(", ")}`);
668
+ }
669
+ }
670
+ await AgentStep.runAgent(state, agent, ctx, config, key);
671
+ } catch (error) {
672
+ state.pendingError = { error, stepId: this.id, source: this.errorSource };
673
+ }
674
+ }
675
+ };
676
+
677
+ // src/runtime.ts
678
+ function makeAbortError(signal) {
438
679
  return {
439
- version: 2,
440
- kind: "gate",
441
- resumeFromIndex: legacy.resumeFromIndex,
442
- output: legacy.output,
443
- gateId: legacy.gateId,
444
- gatePayload: legacy.gatePayload
680
+ error: signal.reason ?? new Error("Workflow aborted"),
681
+ stepId: ABORT_STEP_ID,
682
+ source: "step"
445
683
  };
446
684
  }
447
- function getObservabilityType(node) {
448
- if (node.type !== "step") return node.type;
449
- return node.category ?? "step";
685
+ function prependNestedPath(snapshot, index, state) {
686
+ const next = { ...snapshot, nestedPath: [index, ...snapshot.nestedPath ?? []] };
687
+ if (resolveFreezeSnapshots(state)) deepFreeze(next);
688
+ return next;
450
689
  }
451
- function getNestedWorkflows(node) {
452
- switch (node.type) {
453
- case "step":
454
- return node.nestedWorkflow ? [node.nestedWorkflow] : [];
455
- case "gate":
456
- case "catch":
457
- case "finally":
458
- return [];
459
- }
690
+ function resolveFreezeSnapshots(state) {
691
+ return state.runOptions?.freezeSnapshots ? true : false;
460
692
  }
461
693
  function pendingErrorSourceToStepType(source) {
462
694
  switch (source) {
463
695
  case "step":
464
696
  return "step";
697
+ case "gate":
698
+ return "gate";
465
699
  case "finally":
466
700
  return "finally";
467
701
  case "catch":
@@ -472,43 +706,42 @@ function pendingErrorSourceToStepType(source) {
472
706
  }
473
707
  async function emitCheckpoint(state, opts, resumeFromIndex, stepShapeHash) {
474
708
  if (!opts.onCheckpoint) return;
709
+ const willFreeze = resolveFreezeSnapshots(state);
475
710
  const snap = {
476
711
  version: 2,
477
712
  kind: "checkpoint",
478
713
  resumeFromIndex,
479
- output: state.output,
714
+ output: willFreeze ? structuredClone(state.output) : state.output,
480
715
  stepShapeHash
481
716
  };
482
- if (resolveFreezeSnapshots(state)) deepFreeze(snap);
483
- const controller = new AbortController();
484
- if (opts.checkpointTimeout !== void 0) {
485
- const timeoutMs = opts.checkpointTimeout;
486
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
487
- try {
488
- const callPromise = Promise.resolve(opts.onCheckpoint(snap, { signal: controller.signal }));
489
- const timeoutPromise = new Promise((_, reject) => {
490
- controller.signal.addEventListener(
491
- "abort",
492
- () => reject(new CheckpointTimeoutError(timeoutMs)),
493
- { once: true }
494
- );
495
- });
496
- callPromise.catch(() => {
497
- });
498
- timeoutPromise.catch(() => {
499
- });
500
- await Promise.race([callPromise, timeoutPromise]);
501
- } finally {
502
- clearTimeout(timeoutId);
503
- }
504
- } else {
505
- await opts.onCheckpoint(snap, { signal: controller.signal });
506
- }
717
+ if (willFreeze) deepFreeze(snap);
718
+ await opts.onCheckpoint(snap, { signal: state.abortSignal });
507
719
  }
508
720
  var warnedStreamOnErrorOnSuspend = false;
509
721
  function pushWarning(state, source, stepId, error) {
510
722
  (state.warnings ??= []).push({ source, stepId, error });
511
723
  }
724
+ function fireHook(observability, state, name, event) {
725
+ const hook = observability?.[name];
726
+ if (!hook) return void 0;
727
+ return fireHookSlow(state, name, event, hook);
728
+ }
729
+ async function fireHookSlow(state, name, event, hook) {
730
+ try {
731
+ await hook(event);
732
+ return void 0;
733
+ } catch (e) {
734
+ if (name !== "onStepError") {
735
+ const stepId = event.stepId;
736
+ pushWarning(state, name, stepId, e);
737
+ console.error(`pipeai: ${name} hook threw for stepId "${stepId}":`, e);
738
+ }
739
+ return e;
740
+ }
741
+ }
742
+ function hasItemHooks(observability) {
743
+ return !!observability && !!(observability.onItemStart || observability.onItemFinish || observability.onItemError);
744
+ }
512
745
  function demotePendingError(state, pe) {
513
746
  pushWarning(state, pe.source, pe.stepId, pe.error);
514
747
  }
@@ -519,15 +752,466 @@ function maybeWarnStreamOnErrorOnSuspend(result, options) {
519
752
  "pipeai: stream() with options.onError suspended at a gate \u2014 onError will NOT be invoked for suspension. Discriminate via the resolved output Promise."
520
753
  );
521
754
  }
755
+ function makeRuntimeState(ctx, output, mode, opts, writer) {
756
+ return {
757
+ ctx,
758
+ output,
759
+ mode,
760
+ ...writer ? { writer } : {},
761
+ runOptions: opts,
762
+ abortSignal: opts?.abortSignal
763
+ };
764
+ }
765
+
766
+ // src/steps/semaphore.ts
767
+ var Semaphore = class {
768
+ available;
769
+ waiters = [];
770
+ constructor(permits) {
771
+ this.available = permits;
772
+ }
773
+ acquire() {
774
+ if (this.available > 0) {
775
+ this.available--;
776
+ return Promise.resolve();
777
+ }
778
+ return new Promise((resolve) => this.waiters.push(resolve));
779
+ }
780
+ release() {
781
+ const next = this.waiters.shift();
782
+ if (next) {
783
+ next();
784
+ } else {
785
+ this.available++;
786
+ }
787
+ }
788
+ };
789
+
790
+ // src/steps/concurrent.ts
791
+ function validateConcurrency(kind, value) {
792
+ if (value !== void 0 && !(Number.isInteger(value) && value >= 1 || value === Infinity)) {
793
+ throw new Error(`${kind}: concurrency must be a positive integer or Infinity, got ${value}`);
794
+ }
795
+ return value ?? Infinity;
796
+ }
797
+ async function dispatchUnits(params) {
798
+ const { state, stepId, kind, units, observability, handleStream, onUnitSuccess } = params;
799
+ const unitStates = new Array(units.length);
800
+ const wantItemHooks = hasItemHooks(observability);
801
+ const executeUnit = async (unit, index) => {
802
+ const inheritStreaming = unit.isWorkflow || handleStream !== void 0;
803
+ const unitState = {
804
+ ctx: state.ctx,
805
+ output: unit.input,
806
+ mode: inheritStreaming ? state.mode : "generate",
807
+ writer: inheritStreaming ? state.writer : void 0,
808
+ abortSignal: state.abortSignal
809
+ };
810
+ unitStates[index] = unitState;
811
+ const unitStart = wantItemHooks ? performance.now() : 0;
812
+ if (wantItemHooks) {
813
+ await fireHook(observability, state, "onItemStart", {
814
+ stepId,
815
+ type: kind,
816
+ itemIndex: unit.key,
817
+ ctx: state.ctx,
818
+ input: unit.input
819
+ });
820
+ }
821
+ try {
822
+ if (unit.isWorkflow) {
823
+ await unit.target.executeAsNested(unitState);
824
+ } else {
825
+ await AgentStep.runAgent(
826
+ unitState,
827
+ unit.target,
828
+ state.ctx,
829
+ handleStream ? { handleStream } : void 0,
830
+ unit.key
831
+ );
832
+ }
833
+ onUnitSuccess(index, unitState.output);
834
+ if (wantItemHooks) {
835
+ await fireHook(observability, state, "onItemFinish", {
836
+ stepId,
837
+ type: kind,
838
+ itemIndex: unit.key,
839
+ ctx: state.ctx,
840
+ output: unitState.output,
841
+ durationMs: performance.now() - unitStart
842
+ });
843
+ }
844
+ } catch (error) {
845
+ if (wantItemHooks) {
846
+ await fireHook(observability, state, "onItemError", {
847
+ stepId,
848
+ type: kind,
849
+ itemIndex: unit.key,
850
+ ctx: state.ctx,
851
+ error,
852
+ durationMs: performance.now() - unitStart
853
+ });
854
+ }
855
+ throw error;
856
+ }
857
+ };
858
+ const sem = new Semaphore(params.concurrency);
859
+ const failures = [];
860
+ const inflight = /* @__PURE__ */ new Set();
861
+ for (let i = 0; i < units.length; i++) {
862
+ if (state.abortSignal?.aborted) break;
863
+ await sem.acquire();
864
+ if (state.abortSignal?.aborted) {
865
+ sem.release();
866
+ break;
867
+ }
868
+ const index = i;
869
+ const unit = (async () => {
870
+ try {
871
+ await executeUnit(units[index], index);
872
+ } catch (error) {
873
+ failures.push({ key: units[index].key, index, error });
874
+ } finally {
875
+ sem.release();
876
+ }
877
+ })();
878
+ inflight.add(unit);
879
+ void unit.finally(() => inflight.delete(unit));
880
+ }
881
+ await Promise.all(inflight);
882
+ failures.sort((a, b) => a.index - b.index);
883
+ return reconcileUnits(state, stepId, failures, units.length, (i) => units[i].key, unitStates, state.abortSignal);
884
+ }
885
+ function reconcileUnits(state, id, failures, count, keyAt, unitStates, signal) {
886
+ for (let i = 0; i < count; i++) {
887
+ const us = unitStates[i];
888
+ if (!us) continue;
889
+ if (us.suspension) {
890
+ throw new Error(
891
+ `internal: gate "${us.suspension.gateId}" suspended inside concurrent unit ${id}[${keyAt(i)}]. Gates are forbidden in foreach / parallel targets \u2014 a cast must have bypassed the build-time guard.`
892
+ );
893
+ }
894
+ if (!us.warnings) continue;
895
+ for (const w of us.warnings) {
896
+ pushWarning(state, w.source, `${id}[${keyAt(i)}]:${w.stepId}`, w.error);
897
+ }
898
+ }
899
+ if (signal?.aborted) {
900
+ for (const f of failures) {
901
+ pushWarning(state, "foreach-sibling", `${id}[${f.key}]`, f.error);
902
+ }
903
+ throw signal.reason ?? new Error("Workflow aborted");
904
+ }
905
+ return failures;
906
+ }
907
+
908
+ // src/steps/foreach-step.ts
909
+ var ForeachStep = class extends Step {
910
+ type = "step";
911
+ category = "foreach";
912
+ nestedWorkflow;
913
+ id;
914
+ target;
915
+ concurrency;
916
+ onError;
917
+ handleStream;
918
+ isWorkflow;
919
+ observability;
920
+ constructor(target, options, observability) {
921
+ super();
922
+ this.target = target;
923
+ this.concurrency = validateConcurrency("foreach", options?.concurrency);
924
+ this.onError = options?.onError;
925
+ this.handleStream = options?.handleStream;
926
+ this.observability = observability;
927
+ this.isWorkflow = target instanceof SealedWorkflow;
928
+ const defaultId = this.isWorkflow ? target.id ?? "foreach" : `foreach:${target.id}`;
929
+ this.id = options?.id ?? defaultId;
930
+ this.nestedWorkflow = this.isWorkflow ? target : void 0;
931
+ }
932
+ async execute(state) {
933
+ try {
934
+ const items = state.output;
935
+ if (!Array.isArray(items)) {
936
+ throw new Error(`foreach "${this.id}": expected array input, got ${typeof items}`);
937
+ }
938
+ const results = new Array(items.length);
939
+ const failures = await dispatchUnits({
940
+ state,
941
+ stepId: this.id,
942
+ kind: "foreach",
943
+ units: items.map((item, i) => ({ key: i, input: item, target: this.target, isWorkflow: this.isWorkflow })),
944
+ concurrency: this.concurrency,
945
+ observability: this.observability,
946
+ handleStream: this.handleStream,
947
+ onUnitSuccess: (index, output) => {
948
+ results[index] = output;
949
+ }
950
+ });
951
+ const skipped = /* @__PURE__ */ new Set();
952
+ for (const { index, error } of failures) {
953
+ if (!this.onError) throw error;
954
+ const recovered = await this.onError({ error, item: items[index], index, ctx: state.ctx });
955
+ if (recovered === SKIP) {
956
+ skipped.add(index);
957
+ } else {
958
+ results[index] = recovered;
959
+ }
960
+ }
961
+ state.output = skipped.size === 0 ? results : results.filter((_, i) => !skipped.has(i));
962
+ } catch (error) {
963
+ state.pendingError = { error, stepId: this.id, source: this.errorSource };
964
+ }
965
+ }
966
+ };
967
+
968
+ // src/steps/parallel-step.ts
969
+ var ParallelStep = class extends Step {
970
+ type = "step";
971
+ category = "parallel";
972
+ id;
973
+ entries;
974
+ isTuple;
975
+ concurrency;
976
+ onError;
977
+ handleStream;
978
+ observability;
979
+ constructor(branches, options, observability) {
980
+ super();
981
+ this.isTuple = Array.isArray(branches);
982
+ this.entries = this.isTuple ? branches.map((target, i) => ({ key: i, index: i, target, isWorkflow: target instanceof SealedWorkflow })) : Object.entries(branches).map(([k, t], i) => ({ key: k, index: i, target: t, isWorkflow: t instanceof SealedWorkflow }));
983
+ this.concurrency = validateConcurrency("parallel", options?.concurrency);
984
+ this.onError = options?.onError;
985
+ this.handleStream = options?.handleStream;
986
+ this.observability = observability;
987
+ this.id = options?.id ?? (this.isTuple ? "parallel:tuple" : "parallel:record");
988
+ }
989
+ async execute(state) {
990
+ try {
991
+ const input = state.output;
992
+ const results = this.isTuple ? new Array(this.entries.length) : {};
993
+ const failures = await dispatchUnits({
994
+ state,
995
+ stepId: this.id,
996
+ kind: "parallel",
997
+ units: this.entries.map((e) => ({ key: e.key, input, target: e.target, isWorkflow: e.isWorkflow })),
998
+ concurrency: this.concurrency,
999
+ observability: this.observability,
1000
+ handleStream: this.handleStream,
1001
+ onUnitSuccess: (index, output) => {
1002
+ results[this.entries[index].key] = output;
1003
+ }
1004
+ });
1005
+ for (const { key, index, error } of failures) {
1006
+ if (!this.onError) throw error;
1007
+ const recovered = await this.onError({
1008
+ error,
1009
+ key: this.isTuple ? void 0 : key,
1010
+ index: this.isTuple ? index : void 0,
1011
+ ctx: state.ctx
1012
+ });
1013
+ results[key] = recovered === SKIP ? void 0 : recovered;
1014
+ }
1015
+ state.output = results;
1016
+ } catch (error) {
1017
+ state.pendingError = { error, stepId: this.id, source: this.errorSource };
1018
+ }
1019
+ }
1020
+ };
1021
+
1022
+ // src/steps/gate-step.ts
1023
+ var GateStep = class extends Step {
1024
+ type = "gate";
1025
+ id;
1026
+ errorSource = "gate";
1027
+ /** Read by `loadState` to validate the resumed gate response. */
1028
+ schema;
1029
+ /** Read by `loadState` to merge the response with the suspended output. */
1030
+ merge;
1031
+ payload;
1032
+ condition;
1033
+ constructor(id, options) {
1034
+ super();
1035
+ this.id = id;
1036
+ this.payload = options?.payload;
1037
+ this.schema = options?.schema;
1038
+ this.condition = options?.condition;
1039
+ this.merge = options?.merge;
1040
+ }
1041
+ async execute(state) {
1042
+ try {
1043
+ const params = { ctx: state.ctx, input: state.output };
1044
+ if (this.condition && !await this.condition(params)) return;
1045
+ const snapshot = {
1046
+ version: 2,
1047
+ kind: "gate",
1048
+ resumeFromIndex: state.stepIndex ?? -1,
1049
+ output: state.output,
1050
+ gateId: this.id,
1051
+ gatePayload: this.payload ? await this.payload(params) : state.output
1052
+ };
1053
+ state.suspension = snapshot;
1054
+ if (resolveFreezeSnapshots(state)) deepFreeze(snapshot);
1055
+ } catch (error) {
1056
+ state.pendingError = { error, stepId: this.id, source: this.errorSource };
1057
+ }
1058
+ }
1059
+ };
1060
+
1061
+ // src/steps/catch-step.ts
1062
+ var CatchStep = class extends Step {
1063
+ type = "catch";
1064
+ id;
1065
+ errorSource = "catch";
1066
+ catchFn;
1067
+ constructor(id, catchFn) {
1068
+ super();
1069
+ this.id = id;
1070
+ this.catchFn = catchFn;
1071
+ }
1072
+ // Runs only on a pending error; skipped on suspension and checkpoint failure.
1073
+ shouldSkip(state) {
1074
+ return !!state.suspension || !state.pendingError || !!state.checkpointFailed;
1075
+ }
1076
+ async execute(state) {
1077
+ const handled = state.pendingError;
1078
+ state.output = await this.catchFn({
1079
+ error: handled.error,
1080
+ ctx: state.ctx,
1081
+ lastOutput: state.output,
1082
+ stepId: handled.stepId
1083
+ });
1084
+ state.pendingError = void 0;
1085
+ }
1086
+ };
1087
+
1088
+ // src/steps/finally-step.ts
1089
+ var FinallyStep = class extends Step {
1090
+ type = "finally";
1091
+ id;
1092
+ errorSource = "finally";
1093
+ fn;
1094
+ constructor(id, fn) {
1095
+ super();
1096
+ this.id = id;
1097
+ this.fn = fn;
1098
+ }
1099
+ // Always runs — cleanup must fire regardless of suspension / error state.
1100
+ shouldSkip(_state) {
1101
+ return false;
1102
+ }
1103
+ async execute(state) {
1104
+ await this.fn({ ctx: state.ctx });
1105
+ }
1106
+ };
1107
+
1108
+ // src/steps/nested-workflow-step.ts
1109
+ var NestedWorkflowStep = class extends Step {
1110
+ type = "step";
1111
+ category = "nested";
1112
+ nestedWorkflow;
1113
+ id;
1114
+ options;
1115
+ constructor(id, workflow, options) {
1116
+ super();
1117
+ this.id = id;
1118
+ this.nestedWorkflow = workflow;
1119
+ this.options = options;
1120
+ }
1121
+ async execute(state) {
1122
+ const descent = state.resumeDescent;
1123
+ if (descent) {
1124
+ state.resumeDescent = void 0;
1125
+ const [childStart, ...rest] = descent.remaining;
1126
+ const myIndex2 = state.stepIndex ?? -1;
1127
+ try {
1128
+ if (rest.length === 0) {
1129
+ state.output = descent.seedOutput;
1130
+ } else {
1131
+ state.resumeDescent = { remaining: rest, seedOutput: descent.seedOutput };
1132
+ }
1133
+ await this.nestedWorkflow.executeAsNested(state, childStart);
1134
+ if (state.suspension) state.suspension = prependNestedPath(state.suspension, myIndex2, state);
1135
+ } catch (error) {
1136
+ state.pendingError = { error, stepId: this.id, source: this.errorSource };
1137
+ }
1138
+ return;
1139
+ }
1140
+ const myIndex = state.stepIndex ?? -1;
1141
+ try {
1142
+ if (await this.applyConditionalSkip(state, this.options)) return;
1143
+ await this.nestedWorkflow.executeAsNested(state);
1144
+ if (state.suspension) state.suspension = prependNestedPath(state.suspension, myIndex, state);
1145
+ } catch (error) {
1146
+ state.pendingError = { error, stepId: this.id, source: this.errorSource };
1147
+ }
1148
+ }
1149
+ };
1150
+
1151
+ // src/steps/repeat-step.ts
1152
+ var RepeatStep = class extends Step {
1153
+ type = "step";
1154
+ category = "repeat";
1155
+ nestedWorkflow;
1156
+ id;
1157
+ target;
1158
+ predicate;
1159
+ maxIterations;
1160
+ isWorkflow;
1161
+ constructor(id, target, predicate, maxIterations, isWorkflow) {
1162
+ super();
1163
+ this.id = id;
1164
+ this.target = target;
1165
+ this.predicate = predicate;
1166
+ this.maxIterations = maxIterations;
1167
+ this.isWorkflow = isWorkflow;
1168
+ this.nestedWorkflow = isWorkflow ? target : void 0;
1169
+ }
1170
+ async execute(state) {
1171
+ try {
1172
+ const ctx = state.ctx;
1173
+ for (let i = 1; i <= this.maxIterations; i++) {
1174
+ if (state.abortSignal?.aborted) {
1175
+ throw state.abortSignal.reason ?? new Error("Workflow aborted");
1176
+ }
1177
+ if (this.isWorkflow) {
1178
+ await this.target.executeAsNested(state);
1179
+ } else {
1180
+ await AgentStep.runAgent(state, this.target, ctx);
1181
+ }
1182
+ const done = await this.predicate({ output: state.output, ctx, iterations: i });
1183
+ if (done) return;
1184
+ }
1185
+ throw new WorkflowLoopError(this.maxIterations, this.maxIterations);
1186
+ } catch (error) {
1187
+ state.pendingError = { error, stepId: this.id, source: this.errorSource };
1188
+ }
1189
+ }
1190
+ };
1191
+
1192
+ // src/workflow.ts
1193
+ function migrateSnapshot(legacy) {
1194
+ if (legacy.version !== 1) {
1195
+ throw new Error(`migrateSnapshot: expected v1 snapshot, got version ${legacy.version}`);
1196
+ }
1197
+ return {
1198
+ version: 2,
1199
+ kind: "gate",
1200
+ resumeFromIndex: legacy.resumeFromIndex,
1201
+ output: legacy.output,
1202
+ gateId: legacy.gateId,
1203
+ gatePayload: legacy.gatePayload
1204
+ };
1205
+ }
522
1206
  var SealedWorkflow = class _SealedWorkflow {
523
1207
  id;
524
1208
  steps;
525
1209
  observability;
526
1210
  // Memoized — see ensureDuplicateCheck().
527
1211
  duplicateCheckPassed = false;
528
- // Memoized lazily per terminal instance build pipelines once at module
529
- // load and re-run via generate() to amortize.
530
- _cachedExecutableStepCount;
1212
+ // Memoized lazily per terminal instance: the executable / checkpointable step
1213
+ // counts (one walk) and the recursive shape hash (separate it's expensive).
1214
+ _stepCounts;
531
1215
  _cachedStepShapeHash;
532
1216
  constructor(steps, id, observability) {
533
1217
  this.steps = steps;
@@ -562,21 +1246,26 @@ var SealedWorkflow = class _SealedWorkflow {
562
1246
  }
563
1247
  this.duplicateCheckPassed = true;
564
1248
  }
565
- // ── shape-hash + RunOptions validation ────────────────────────
1249
+ // ── step counts + shape-hash (memoized) ────────────────────────────
566
1250
  /**
567
- * Count of executable nodes i.e. NOT `catch` or `finally`. Drives
568
- * checkpoint auto-cadence so adding cleanup steps doesn't surprise users
569
- * with extra fires. `branch`/`foreach`/`repeat`/`parallel`/`nested` are all
570
- * `type: "step"` internally and count as executable.
1251
+ * Two cadence inputs from a single walk:
1252
+ * - `executable` nodes that aren't `catch` / `finally`. A graph-size
1253
+ * proxy for the catastrophe threshold in {@link validateRunOptions}.
1254
+ * - `checkpointable` — `type === "step"` nodes only (this includes
1255
+ * branch / foreach / repeat / parallel / nested). Drives the checkpoint
1256
+ * auto-cadence denominator: gates suspend/skip and never reach the
1257
+ * checkpoint block, so counting them would dilute the "~4 checkpoints
1258
+ * across the run" target.
571
1259
  */
572
- get cachedExecutableStepCount() {
573
- if (this._cachedExecutableStepCount !== void 0) return this._cachedExecutableStepCount;
574
- let n = 0;
1260
+ get stepCounts() {
1261
+ if (this._stepCounts) return this._stepCounts;
1262
+ let executable = 0;
1263
+ let checkpointable = 0;
575
1264
  for (const s of this.steps) {
576
- if (s.type !== "catch" && s.type !== "finally") n++;
1265
+ if (s.type !== "catch" && s.type !== "finally") executable++;
1266
+ if (s.type === "step") checkpointable++;
577
1267
  }
578
- this._cachedExecutableStepCount = n;
579
- return n;
1268
+ return this._stepCounts = { executable, checkpointable };
580
1269
  }
581
1270
  /** @internal — used by `computeStepShapeHash` to descend nested workflows. */
582
1271
  getStepsForShapeHash() {
@@ -584,7 +1273,7 @@ var SealedWorkflow = class _SealedWorkflow {
584
1273
  }
585
1274
  get cachedStepShapeHash() {
586
1275
  if (this._cachedStepShapeHash !== void 0) return this._cachedStepShapeHash;
587
- const getNested = (node) => getNestedWorkflows(node);
1276
+ const getNested = (node) => node.nestedWorkflow ? [node.nestedWorkflow] : [];
588
1277
  this._cachedStepShapeHash = computeStepShapeHash(
589
1278
  this.steps,
590
1279
  getNested
@@ -595,24 +1284,29 @@ var SealedWorkflow = class _SealedWorkflow {
595
1284
  * Validate user-provided RunOptions before a run begins. Throws on
596
1285
  * outright errors and on the loud-disaster combo (`freezeSnapshots: true
597
1286
  * + checkpointEvery: 1` on a workflow of 8+ steps). Warns once on the
598
- * merely-suspicious combo (`freezeSnapshots: true + cadence <= 2`).
599
- * Plan-of-record: catastrophic combo escape via the
600
- * `"iAcceptThePerformanceCost"` literal.
1287
+ * merely-suspicious combo (`freezeSnapshots: true + cadence <= 2`), and on
1288
+ * checkpoint-cadence options set without an `onCheckpoint` sink (a no-op
1289
+ * that usually signals a forgotten sink).
601
1290
  */
602
1291
  validateRunOptions(opts) {
603
1292
  if (!opts) return;
604
- if (!opts.onCheckpoint) return;
605
1293
  if (opts.checkpointEvery !== void 0 && opts.checkpointWhen !== void 0) {
606
1294
  throw new Error("RunOptions: checkpointEvery and checkpointWhen are mutually exclusive");
607
1295
  }
608
1296
  if (opts.checkpointEvery !== void 0 && (!Number.isInteger(opts.checkpointEvery) || opts.checkpointEvery < 1)) {
609
1297
  throw new Error(`RunOptions: checkpointEvery must be a positive integer, got ${opts.checkpointEvery}`);
610
1298
  }
611
- if (opts.checkpointTimeout !== void 0 && (!Number.isFinite(opts.checkpointTimeout) || opts.checkpointTimeout < 1)) {
612
- throw new Error(`RunOptions: checkpointTimeout must be a finite positive number (ms), got ${opts.checkpointTimeout}`);
1299
+ if (!opts.onCheckpoint) {
1300
+ if (opts.checkpointEvery !== void 0 || opts.checkpointWhen !== void 0) {
1301
+ warnOnce(
1302
+ "pipeai:checkpoint-without-sink",
1303
+ "pipeai: checkpointEvery/checkpointWhen set without onCheckpoint \u2014 no checkpoints will fire. Did you forget the onCheckpoint sink?"
1304
+ );
1305
+ }
1306
+ return;
613
1307
  }
614
- const length = this.cachedExecutableStepCount;
615
- const cadence = opts.checkpointEvery ?? Math.max(1, Math.ceil(length / 4));
1308
+ const length = this.stepCounts.executable;
1309
+ const cadence = opts.checkpointEvery ?? Math.max(1, Math.ceil(this.stepCounts.checkpointable / 4));
616
1310
  if (opts.freezeSnapshots && opts.freezeSnapshots !== "iAcceptThePerformanceCost" && cadence === 1 && length >= 8) {
617
1311
  throw new Error(
618
1312
  `freezeSnapshots+checkpointEvery:1 on a ${length}-step workflow is reliably catastrophic. Set checkpointEvery >= 5, freezeSnapshots: false, or pass "iAcceptThePerformanceCost".`
@@ -626,6 +1320,13 @@ var SealedWorkflow = class _SealedWorkflow {
626
1320
  }
627
1321
  }
628
1322
  // ── Observability helpers ─────────────────────────────────────
1323
+ /** Observability event `type` for a node: a `type: "step"` node reports its
1324
+ * `category` (agent / transform default to `"step"`); every other node's
1325
+ * `type` IS the event type. */
1326
+ obsEventType(node) {
1327
+ if (node.type !== "step") return node.type;
1328
+ return node.category ?? "step";
1329
+ }
629
1330
  /**
630
1331
  * Fire an observability hook safely. Returns `undefined` synchronously when
631
1332
  * no hook is registered — avoiding the promise wrapper + microtask that an
@@ -639,48 +1340,80 @@ var SealedWorkflow = class _SealedWorkflow {
639
1340
  * Returns the hook's thrown error if any; undefined otherwise. Callers
640
1341
  * `await` the result — `await undefined` is sync, so the no-hook path
641
1342
  * stays allocation-free.
1343
+ *
1344
+ * Thin delegate to the free `fireHook` (which takes an explicit
1345
+ * observability), kept as a method so the loop's many `this.fireHook` call
1346
+ * sites stay unchanged.
642
1347
  */
643
1348
  fireHook(state, name, event) {
644
- const hook = this.observability?.[name];
645
- if (!hook) return void 0;
646
- return this.fireHookSlow(state, name, event, hook);
1349
+ return fireHook(this.observability, state, name, event);
647
1350
  }
648
- async fireHookSlow(state, name, event, hook) {
649
- try {
650
- await hook(event);
651
- return void 0;
652
- } catch (e) {
653
- if (name !== "onStepError") {
654
- const stepId = event.stepId;
655
- pushWarning(state, name, stepId, e);
656
- console.error(`pipeai: ${name} hook threw for stepId "${stepId}":`, e);
1351
+ /**
1352
+ * Fire `onStepError` for a step-body failure and honor the documented
1353
+ * cause-attachment contract uniformly across every firing path (step, gate,
1354
+ * catch, finally, checkpoint). When the hook itself throws, its error is
1355
+ * attached as `cause` on the ORIGINAL error so the original still reaches the
1356
+ * caller with the failure trail attached. If the original error is frozen /
1357
+ * non-extensible (cause assignment throws) or is not an object, the hook
1358
+ * error is recorded as a warning instead — so an `onStepError` throw is never
1359
+ * silently lost. (The suspension-wins tail fires `onStepError` separately, on
1360
+ * its own demotion path.)
1361
+ */
1362
+ async fireStepErrorAndAttachCause(state, event) {
1363
+ const obsError = await this.fireHook(state, "onStepError", event);
1364
+ if (obsError === void 0) return;
1365
+ const e = event.error;
1366
+ if (typeof e === "object" && e !== null) {
1367
+ try {
1368
+ e.cause = obsError;
1369
+ return;
1370
+ } catch {
657
1371
  }
658
- return e;
659
1372
  }
1373
+ pushWarning(state, "onStepError", event.stepId, obsError);
660
1374
  }
661
1375
  // ── Execution ─────────────────────────────────────────────────
662
1376
  async generate(ctx, ...args) {
663
1377
  this.ensureDuplicateCheck();
664
1378
  const input = args[0];
665
1379
  const opts = args[1];
666
- this.validateRunOptions(opts);
667
- const state = {
668
- ctx,
669
- output: input,
670
- mode: "generate",
671
- runOptions: opts,
672
- abortSignal: opts?.abortSignal
673
- };
674
- await this.execute(state, 0, opts);
675
- return this.buildResult(state);
1380
+ return this.runGenerate(ctx, 0, opts, () => ({ output: input, initialError: null }));
676
1381
  }
677
1382
  stream(ctx, ...args) {
678
1383
  this.ensureDuplicateCheck();
679
1384
  const input = args[0];
680
1385
  const options = args[1];
681
1386
  const opts = args[2];
1387
+ return this.runStream(ctx, 0, opts, options, () => ({ output: input, initialError: null }));
1388
+ }
1389
+ // Helper — converts terminal RuntimeState into a WorkflowResult; freezes
1390
+ // snapshot + warnings if requested via runOptions.
1391
+ buildResult(state) {
1392
+ const warnings = state.warnings ?? [];
1393
+ if (resolveFreezeSnapshots(state)) {
1394
+ deepFreeze(warnings);
1395
+ }
1396
+ if (state.suspension) {
1397
+ return { status: "suspended", snapshot: state.suspension, warnings };
1398
+ }
1399
+ return { status: "complete", output: state.output, warnings };
1400
+ }
1401
+ // ── Shared run drivers (generate / stream) ────────────────────
1402
+ // Every public entry point — base generate/stream plus gate- and
1403
+ // checkpoint-resume — differs only in (a) how it seeds the initial output /
1404
+ // pre-execute error and (b) the start index. Both drivers take a `seed`
1405
+ // thunk for (a) and a `startIndex` for (b); the rest (validation, state
1406
+ // construction, execute, result building, stream plumbing) is identical.
1407
+ async runGenerate(ctx, startIndex, opts, seed) {
1408
+ this.validateRunOptions(opts);
1409
+ const seeded = await seed();
1410
+ const state = makeRuntimeState(ctx, seeded.output, "generate", opts);
1411
+ if (seeded.resumeDescent) state.resumeDescent = seeded.resumeDescent;
1412
+ await this.execute(state, startIndex, opts, seeded.initialError);
1413
+ return this.buildResult(state);
1414
+ }
1415
+ runStream(ctx, startIndex, opts, options, seed) {
682
1416
  this.validateRunOptions(opts);
683
- const abortSignal = opts?.abortSignal;
684
1417
  let resolveOutput;
685
1418
  let rejectOutput;
686
1419
  const outputPromise = new Promise((res, rej) => {
@@ -691,16 +1424,11 @@ var SealedWorkflow = class _SealedWorkflow {
691
1424
  });
692
1425
  const stream = (0, import_ai3.createUIMessageStream)({
693
1426
  execute: async ({ writer }) => {
694
- const state = {
695
- ctx,
696
- output: input,
697
- mode: "stream",
698
- writer,
699
- runOptions: opts,
700
- abortSignal
701
- };
702
1427
  try {
703
- await this.execute(state, 0, opts);
1428
+ const seeded = await seed();
1429
+ const state = makeRuntimeState(ctx, seeded.output, "stream", opts, writer);
1430
+ if (seeded.resumeDescent) state.resumeDescent = seeded.resumeDescent;
1431
+ await this.execute(state, startIndex, opts, seeded.initialError);
704
1432
  const result = this.buildResult(state);
705
1433
  maybeWarnStreamOnErrorOnSuspend(result, options);
706
1434
  resolveOutput(result);
@@ -714,22 +1442,7 @@ var SealedWorkflow = class _SealedWorkflow {
714
1442
  ...options?.originalMessages ? { originalMessages: options.originalMessages } : {},
715
1443
  ...options?.generateId ? { generateId: options.generateId } : {}
716
1444
  });
717
- return {
718
- stream,
719
- output: outputPromise
720
- };
721
- }
722
- // Helper — converts terminal RuntimeState into a WorkflowResult; freezes
723
- // snapshot + warnings if requested via runOptions.
724
- buildResult(state) {
725
- const warnings = state.warnings ?? [];
726
- if (state.suspension && resolveFreezeSnapshots(state)) {
727
- deepFreeze(warnings);
728
- }
729
- if (state.suspension) {
730
- return { status: "suspended", snapshot: state.suspension, warnings };
731
- }
732
- return { status: "complete", output: state.output, warnings };
1445
+ return { stream, output: outputPromise };
733
1446
  }
734
1447
  // ── Internal: execute pipeline ────────────────────────────────
735
1448
  async execute(state, startIndex = 0, opts, initialError = null) {
@@ -739,297 +1452,184 @@ var SealedWorkflow = class _SealedWorkflow {
739
1452
  if (opts !== void 0 && state.runOptions === void 0) {
740
1453
  state.runOptions = opts;
741
1454
  }
742
- const ckptCadence = opts?.onCheckpoint && opts.checkpointWhen === void 0 ? opts.checkpointEvery ?? Math.max(1, Math.ceil(this.cachedExecutableStepCount / 4)) : 0;
743
- let pendingError = initialError;
744
- let abortPromoted = false;
745
- const makeAbortError = (signal) => ({
746
- error: signal.reason ?? new Error("Workflow aborted"),
747
- stepId: "abort",
748
- source: "step"
749
- });
1455
+ const ckptCadence = opts?.onCheckpoint && opts.checkpointWhen === void 0 ? opts.checkpointEvery ?? Math.max(1, Math.ceil(this.stepCounts.checkpointable / 4)) : 0;
1456
+ const ckptCounter = { seen: 0 };
1457
+ state.pendingError = initialError ?? void 0;
1458
+ const abortState = { promoted: false };
750
1459
  for (let i = startIndex; i < this.steps.length; i++) {
751
- if (state.abortSignal?.aborted) {
752
- if (!abortPromoted) {
753
- abortPromoted = true;
754
- state.suspension = void 0;
755
- if (pendingError) demotePendingError(state, pendingError);
756
- pendingError = makeAbortError(state.abortSignal);
757
- } else if (!pendingError) {
758
- pendingError = makeAbortError(state.abortSignal);
759
- }
760
- }
1460
+ this.promoteAbort(state, abortState);
761
1461
  const node = this.steps[i];
762
- if (node.type === "finally") {
763
- const stepId2 = node.id;
764
- const finStart = performance.now();
765
- await this.fireHook(state, "onStepStart", { stepId: stepId2, type: "finally", ctx: state.ctx, input: state.output });
766
- try {
767
- await node.execute(state);
768
- await this.fireHook(state, "onStepFinish", {
769
- stepId: stepId2,
770
- type: "finally",
771
- ctx: state.ctx,
772
- output: state.output,
773
- durationMs: performance.now() - finStart,
774
- suspended: false
775
- });
776
- } catch (e) {
777
- await this.fireHook(state, "onStepError", {
778
- stepId: stepId2,
779
- type: "finally",
780
- ctx: state.ctx,
781
- error: e,
782
- durationMs: performance.now() - finStart
783
- });
784
- if (pendingError) demotePendingError(state, pendingError);
785
- pendingError = { error: e, stepId: stepId2, source: "finally" };
786
- }
787
- continue;
788
- }
789
- if (node.type === "catch") {
790
- if (state.suspension || !pendingError || state.checkpointFailed) continue;
791
- const stepId2 = node.id;
792
- const cStart = performance.now();
793
- await this.fireHook(state, "onStepStart", { stepId: stepId2, type: "catch", ctx: state.ctx, input: state.output });
794
- try {
795
- state.output = await node.catchFn({
796
- error: pendingError.error,
797
- ctx: state.ctx,
798
- lastOutput: state.output,
799
- stepId: pendingError.stepId
800
- });
801
- pendingError = null;
802
- await this.fireHook(state, "onStepFinish", {
803
- stepId: stepId2,
804
- type: "catch",
805
- ctx: state.ctx,
806
- output: state.output,
807
- durationMs: performance.now() - cStart,
808
- suspended: false
809
- });
810
- } catch (e) {
811
- await this.fireHook(state, "onStepError", {
812
- stepId: stepId2,
813
- type: "catch",
814
- ctx: state.ctx,
815
- error: e,
816
- durationMs: performance.now() - cStart
817
- });
818
- if (pendingError) demotePendingError(state, pendingError);
819
- pendingError = { error: e, stepId: stepId2, source: "catch" };
820
- }
821
- continue;
822
- }
823
- if (state.suspension || pendingError) continue;
824
- if (node.type === "gate") {
825
- const stepId2 = node.id;
826
- const gStart = performance.now();
827
- await this.fireHook(state, "onStepStart", { stepId: stepId2, type: "gate", ctx: state.ctx, input: state.output });
828
- try {
829
- if (node.condition && !await node.condition(state)) {
830
- await this.fireHook(state, "onStepFinish", {
831
- stepId: stepId2,
832
- type: "gate",
833
- ctx: state.ctx,
834
- output: state.output,
835
- durationMs: performance.now() - gStart,
836
- suspended: false
837
- });
838
- continue;
839
- }
840
- const snapshot = {
841
- version: 2,
842
- kind: "gate",
843
- resumeFromIndex: i,
844
- output: state.output,
845
- gateId: node.id,
846
- gatePayload: await node.payload(state)
847
- };
848
- state.suspension = snapshot;
849
- if (resolveFreezeSnapshots(state)) deepFreeze(snapshot);
850
- await this.fireHook(state, "onStepFinish", {
851
- stepId: stepId2,
852
- type: "gate",
853
- ctx: state.ctx,
854
- output: state.output,
855
- durationMs: performance.now() - gStart,
856
- suspended: true
857
- });
858
- } catch (e) {
859
- pendingError = { error: e, stepId: node.id, source: "step" };
860
- }
861
- continue;
862
- }
863
- const obsType = getObservabilityType(node);
1462
+ if (node.shouldSkip(state)) continue;
1463
+ const obsType = this.obsEventType(node);
864
1464
  const stepId = node.id;
865
1465
  const sStart = performance.now();
866
- const stepInput = state.output;
867
- await this.fireHook(state, "onStepStart", { stepId, type: obsType, ctx: state.ctx, input: stepInput });
1466
+ const errBefore = state.pendingError;
1467
+ const suspendedBefore = !!state.suspension;
1468
+ state.stepIndex = i;
1469
+ await this.fireHook(state, "onStepStart", { stepId, type: obsType, ctx: state.ctx, input: state.output });
868
1470
  try {
869
1471
  await node.execute(state);
870
- await this.fireHook(state, "onStepFinish", {
1472
+ } catch (e) {
1473
+ await this.fireStepErrorAndAttachCause(state, {
871
1474
  stepId,
872
1475
  type: obsType,
873
1476
  ctx: state.ctx,
874
- output: state.output,
875
- durationMs: performance.now() - sStart,
876
- suspended: false
1477
+ error: e,
1478
+ durationMs: performance.now() - sStart
877
1479
  });
878
- } catch (e) {
879
- pendingError = { error: e, stepId: node.id, source: "step" };
880
- const obsError = await this.fireHook(state, "onStepError", {
1480
+ throw e;
1481
+ }
1482
+ const newError = state.pendingError && state.pendingError !== errBefore ? state.pendingError : null;
1483
+ const isAbort = !!newError && state.abortSignal?.aborted === true && newError.error === state.abortSignal.reason;
1484
+ if (isAbort) {
1485
+ } else if (newError) {
1486
+ await this.fireStepErrorAndAttachCause(state, {
881
1487
  stepId,
882
1488
  type: obsType,
883
1489
  ctx: state.ctx,
884
- error: e,
1490
+ error: newError.error,
885
1491
  durationMs: performance.now() - sStart
886
1492
  });
887
- if (obsError !== void 0 && typeof e === "object" && e !== null) {
888
- try {
889
- e.cause = obsError;
890
- } catch {
891
- }
892
- }
1493
+ } else {
1494
+ await this.fireHook(state, "onStepFinish", {
1495
+ stepId,
1496
+ type: obsType,
1497
+ ctx: state.ctx,
1498
+ output: state.output,
1499
+ durationMs: performance.now() - sStart,
1500
+ suspended: !suspendedBefore && !!state.suspension
1501
+ });
893
1502
  }
894
- const leaked = state.suspension;
895
- if (leaked) {
1503
+ if (node.type === "step" && node.category !== "nested" && state.suspension) {
1504
+ const leaked = state.suspension;
896
1505
  state.suspension = void 0;
897
1506
  throw new Error(`internal: suspension bubbled from non-gate step "${node.id}" (gate "${leaked.gateId}").`);
898
1507
  }
899
- if (!pendingError && !state.suspension && opts?.onCheckpoint) {
900
- const shouldCheckpoint = opts.checkpointWhen ? opts.checkpointWhen({ stepIndex: i, stepId: node.id, ctx: state.ctx }) : (i + 1) % ckptCadence === 0;
901
- if (shouldCheckpoint) {
902
- const ckptStart = performance.now();
903
- try {
904
- await emitCheckpoint(
905
- state,
906
- opts,
907
- i + 1,
908
- this.cachedStepShapeHash
909
- );
910
- } catch (e) {
911
- pendingError = { error: e, stepId: CHECKPOINT_STEP_ID, source: "onCheckpoint" };
912
- state.checkpointFailed = true;
913
- await this.fireHook(state, "onStepError", {
914
- stepId: CHECKPOINT_STEP_ID,
915
- type: "step",
916
- ctx: state.ctx,
917
- error: e,
918
- durationMs: performance.now() - ckptStart
919
- });
920
- }
921
- }
922
- }
1508
+ await this.maybeCheckpoint(state, opts, node, i, ckptCadence, ckptCounter);
1509
+ }
1510
+ await this.settleRun(state, abortState.promoted);
1511
+ }
1512
+ /**
1513
+ * Promote a fired abort signal into `state.pendingError` at an iteration
1514
+ * boundary. First observation discards any in-progress suspension (the caller
1515
+ * asked to stop) and preserves a genuinely-different prior step error as a
1516
+ * warning — but NOT one that is itself the abort reason (a nested workflow /
1517
+ * concurrent unit that already rethrew it), which would surface a phantom
1518
+ * step-failure warning. Subsequent iterations only re-promote if a downstream
1519
+ * catch cleared pendingError — `AbortSignal.aborted` is sticky, so the
1520
+ * workflow must not resume mid-pipeline just because a catch swallowed one
1521
+ * observation.
1522
+ */
1523
+ promoteAbort(state, abortState) {
1524
+ const signal = state.abortSignal;
1525
+ if (!signal?.aborted) return;
1526
+ if (!abortState.promoted) {
1527
+ abortState.promoted = true;
1528
+ state.suspension = void 0;
1529
+ const prior = state.pendingError;
1530
+ if (prior && prior.error !== signal.reason) demotePendingError(state, prior);
1531
+ state.pendingError = makeAbortError(signal);
1532
+ } else if (!state.pendingError) {
1533
+ state.pendingError = makeAbortError(signal);
1534
+ }
1535
+ }
1536
+ /**
1537
+ * Emit a checkpoint after a successful `type:"step"` body. Skipped on
1538
+ * pendingError (no clean state to snapshot), on suspension (gate already
1539
+ * won), and for catch/finally/gate nodes (not checkpointable). Numeric
1540
+ * `checkpointEvery` (default: `max(1, ceil(count/4))`) uses the loop-hoisted
1541
+ * `ckptCadence`; the predicate form runs per step. A `when:false`-skipped
1542
+ * `type:"step"` node returns normally (its body never ran) and still reaches
1543
+ * here — it advances the counter and can itself be a checkpoint boundary,
1544
+ * keeping the cadence denominator (`stepCounts.checkpointable`) consistent
1545
+ * with the runtime counter.
1546
+ */
1547
+ async maybeCheckpoint(state, opts, node, index, ckptCadence, counter) {
1548
+ if (node.type !== "step" || state.pendingError || state.suspension || !opts?.onCheckpoint) return;
1549
+ counter.seen++;
1550
+ const shouldCheckpoint = opts.checkpointWhen ? opts.checkpointWhen({ stepIndex: index, stepId: node.id, ctx: state.ctx }) : counter.seen % ckptCadence === 0;
1551
+ if (!shouldCheckpoint) return;
1552
+ const ckptStart = performance.now();
1553
+ try {
1554
+ await emitCheckpoint(state, opts, index + 1, this.cachedStepShapeHash);
1555
+ } catch (e) {
1556
+ state.pendingError = { error: e, stepId: CHECKPOINT_STEP_ID, source: "onCheckpoint" };
1557
+ state.checkpointFailed = true;
1558
+ await this.fireStepErrorAndAttachCause(state, {
1559
+ stepId: CHECKPOINT_STEP_ID,
1560
+ type: "step",
1561
+ ctx: state.ctx,
1562
+ error: e,
1563
+ durationMs: performance.now() - ckptStart
1564
+ });
1565
+ }
1566
+ }
1567
+ /**
1568
+ * Terminal reconciliation after the loop. Re-promotes a swallowed abort
1569
+ * (recoverability must not depend on catch position), then resolves the
1570
+ * mutually-exclusive precedence tail: checkpointFailed > original-step error
1571
+ * > suspension. (A throwing catch/finally never reaches here — it bubbles
1572
+ * straight out of the loop, so there is no finally-aggregation branch.)
1573
+ */
1574
+ async settleRun(state, abortPromoted) {
1575
+ if (abortPromoted && !state.pendingError && !state.suspension && state.abortSignal?.aborted) {
1576
+ state.pendingError = makeAbortError(state.abortSignal);
923
1577
  }
924
- if (pendingError && !state.suspension) {
1578
+ if (state.pendingError && !state.suspension) {
1579
+ const pe = state.pendingError;
925
1580
  if (state.checkpointFailed) {
926
1581
  const warningsArr = state.warnings ?? [];
927
- const checkpointError = pendingError.source === "onCheckpoint" ? pendingError.error : warningsArr.find((w) => w.source === "onCheckpoint")?.error;
928
- const finallyErrors = warningsArr.filter((w) => w.source === "finally").map((w) => w.error);
929
- const all = pendingError.source === "finally" ? [...finallyErrors, pendingError.error] : finallyErrors;
930
- if (all.length > 0) {
1582
+ const checkpointError = pe.source === "onCheckpoint" ? pe.error : warningsArr.find((w) => w.source === "onCheckpoint")?.error;
1583
+ const suppressed = warningsArr.filter((w) => w.error !== checkpointError).map((w) => w.error);
1584
+ if (pe.source !== "onCheckpoint" && pe.error !== checkpointError) {
1585
+ suppressed.push(pe.error);
1586
+ }
1587
+ if (suppressed.length > 0) {
931
1588
  console.warn(
932
- `pipeai: ${all.length} .finally() error(s) suppressed by checkpoint-failure precedence:`,
933
- all
1589
+ `pipeai: ${suppressed.length} error(s) suppressed by checkpoint-failure precedence:`,
1590
+ suppressed
934
1591
  );
935
1592
  }
936
- throw checkpointError ?? pendingError.error;
1593
+ throw checkpointError ?? pe.error;
937
1594
  }
938
- const isFinallyPath = pendingError.source === "finally" || (state.warnings?.some((w) => w.source === "finally") ?? false);
939
- if (isFinallyPath) {
940
- const all = [...(state.warnings ?? []).map((w) => w.error), pendingError.error];
941
- throw new AggregateError(all, `Workflow failed with ${all.length} error(s) from .finally() bodies`);
942
- }
943
- throw pendingError.error;
944
- } else if (pendingError && state.suspension) {
945
- demotePendingError(state, pendingError);
1595
+ throw pe.error;
1596
+ } else if (state.pendingError && state.suspension) {
1597
+ const pe = state.pendingError;
1598
+ demotePendingError(state, pe);
946
1599
  try {
947
1600
  await this.observability?.onStepError?.({
948
- stepId: pendingError.stepId,
949
- type: pendingErrorSourceToStepType(pendingError.source),
1601
+ stepId: pe.stepId,
1602
+ type: pendingErrorSourceToStepType(pe.source),
950
1603
  ctx: state.ctx,
951
- error: pendingError.error,
1604
+ error: pe.error,
952
1605
  durationMs: 0
953
1606
  });
954
1607
  } catch (obsError) {
955
- pushWarning(state, "onStepError", pendingError.stepId, obsError);
1608
+ pushWarning(state, "onStepError", pe.stepId, obsError);
956
1609
  }
957
- pendingError = null;
1610
+ state.pendingError = void 0;
958
1611
  }
959
1612
  }
960
- // ── Internal: execute a nested workflow within a step/loop ─────
961
- // Defined on SealedWorkflow (not Workflow) because TypeScript's protected
962
- // access rules only allow calling workflow.execute() from the same class.
963
- //
964
- // Contract: clears any inner suspension before re-throwing as
965
- // NestedGateUnsupportedError. The outer execute() therefore never observes
966
- // a leaked `state.suspension` from non-gate nodes (defensive invariant).
967
- async executeNestedWorkflow(state, workflow) {
1613
+ /**
1614
+ * Run THIS sealed workflow as a nested step on the caller's run `state`.
1615
+ * Public (internal; not re-exported from index) so `Step` subclasses
1616
+ * `nested` / `repeat` / `foreach` / `parallel` with `SealedWorkflow` targets
1617
+ * can run a sub-workflow without reaching the protected `execute`.
1618
+ *
1619
+ * Contract: RunOptions is run-scoped, so the child never inherits the
1620
+ * parent's (`state.warnings` IS propagated — telemetry > config). A gate
1621
+ * inside the child leaves `state.suspension` set so it propagates up (only a
1622
+ * `.step(workflow)` ever does this — concurrent/looped combinators forbid
1623
+ * gated targets at build time).
1624
+ */
1625
+ async executeAsNested(state, startIndex = 0) {
968
1626
  const savedRunOptions = state.runOptions;
969
1627
  state.runOptions = void 0;
970
1628
  try {
971
- await workflow.execute(state);
1629
+ await this.execute(state, startIndex);
972
1630
  } finally {
973
1631
  state.runOptions = savedRunOptions;
974
1632
  }
975
- if (state.suspension) {
976
- const gateId = state.suspension.gateId;
977
- state.suspension = void 0;
978
- throw new NestedGateUnsupportedError(gateId, workflow.id);
979
- }
980
- }
981
- // ── Internal: execute an agent within a step/branch ───────────
982
- // In stream mode, output extraction awaits the full stream before returning.
983
- // Streaming benefits the client (incremental output), not pipeline throughput —
984
- // each step still runs sequentially.
985
- async executeAgent(state, agent, ctx, options) {
986
- const input = state.output;
987
- const hasStructuredOutput = agent.hasOutput;
988
- const abortSignal = state.abortSignal;
989
- const agentCallOpts = abortSignal ? { abortSignal } : void 0;
990
- if (state.mode === "stream" && state.writer) {
991
- const writer = state.writer;
992
- await runWithWriter(writer, async () => {
993
- const result = await agent.stream(ctx, state.output, agentCallOpts);
994
- if (options?.handleStream) {
995
- await options.handleStream({ result, writer, ctx, input });
996
- } else {
997
- writer.merge(result.toUIMessageStream());
998
- }
999
- const hookParams = {
1000
- mode: "stream",
1001
- result,
1002
- ctx,
1003
- input
1004
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1005
- };
1006
- if (options?.onResult) {
1007
- await options.onResult(hookParams);
1008
- }
1009
- if (options?.mapResult) {
1010
- state.output = await options.mapResult(hookParams);
1011
- } else {
1012
- state.output = await extractOutput(result, hasStructuredOutput, agent.validateOutput);
1013
- }
1014
- });
1015
- } else {
1016
- const result = await agent.generate(ctx, state.output, agentCallOpts);
1017
- const hookParams = {
1018
- mode: "generate",
1019
- result,
1020
- ctx,
1021
- input
1022
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1023
- };
1024
- if (options?.onResult) {
1025
- await options.onResult(hookParams);
1026
- }
1027
- if (options?.mapResult) {
1028
- state.output = await options.mapResult(hookParams);
1029
- } else {
1030
- state.output = await extractOutput(result, hasStructuredOutput, agent.validateOutput);
1031
- }
1032
- }
1033
1633
  }
1034
1634
  // ── Gate: load persisted state for resumption ──────────────────
1035
1635
  loadState(gateId, snapshot) {
@@ -1043,6 +1643,31 @@ var SealedWorkflow = class _SealedWorkflow {
1043
1643
  );
1044
1644
  }
1045
1645
  this.ensureDuplicateCheck();
1646
+ const nestedPath = gateLike.nestedPath;
1647
+ if (nestedPath && nestedPath.length > 0) {
1648
+ let steps = this.steps;
1649
+ for (const idx of nestedPath) {
1650
+ const child = steps[idx]?.nestedWorkflow;
1651
+ if (!child) {
1652
+ throw new Error(`loadState: nested gate "${gateId}" path is stale \u2014 step ${idx} is not a nested workflow.`);
1653
+ }
1654
+ steps = child.getStepsForShapeHash();
1655
+ }
1656
+ const innerGate = steps[gateLike.resumeFromIndex];
1657
+ if (!(innerGate instanceof GateStep) || innerGate.id !== gateId) {
1658
+ throw new Error(`loadState: nested gate "${gateId}" not found at the recorded path.`);
1659
+ }
1660
+ const remaining = [...nestedPath.slice(1), gateLike.resumeFromIndex + 1];
1661
+ return new ResumedWorkflow(this.steps, nestedPath[0], {
1662
+ mode: "gate",
1663
+ schema: innerGate.schema,
1664
+ mergeFn: innerGate.merge,
1665
+ priorOutput: gateLike.output,
1666
+ snapshot: gateLike,
1667
+ observability: this.observability,
1668
+ nestedRemaining: remaining
1669
+ });
1670
+ }
1046
1671
  const gateIndex = this.findGateIndex(gateLike);
1047
1672
  const gateNode = this.steps[gateIndex];
1048
1673
  return new ResumedWorkflow(this.steps, gateIndex + 1, {
@@ -1093,25 +1718,19 @@ var SealedWorkflow = class _SealedWorkflow {
1093
1718
  this.ensureDuplicateCheck();
1094
1719
  }
1095
1720
  return new CheckpointResumedWorkflow(this.steps, idx, {
1096
- mode: "checkpoint",
1097
1721
  priorOutput: ckpt.output,
1098
- snapshot: ckpt,
1099
1722
  observability: this.observability
1100
1723
  });
1101
1724
  }
1102
1725
  /**
1103
1726
  * Append a `.finally()` body to a sealed workflow, returning another sealed
1104
1727
  * workflow. Allows multi-finally chains (`.finally().finally()`). A throwing
1105
- * `.finally` body does NOT abort subsequent ones they all run.
1728
+ * `.finally` body bubbles straight out of the run: it is non-recoverable, does
1729
+ * NOT aggregate with a prior error, and subsequent `.finally()` bodies do not
1730
+ * run. (See {@link FinallyStep} for the full contract.)
1106
1731
  */
1107
1732
  finally(id, fn) {
1108
- const node = {
1109
- type: "finally",
1110
- id,
1111
- execute: async (state) => {
1112
- await fn({ ctx: state.ctx });
1113
- }
1114
- };
1733
+ const node = new FinallyStep(id, fn);
1115
1734
  return new _SealedWorkflow([...this.steps, node], this.id, this.observability);
1116
1735
  }
1117
1736
  findGateIndex(snapshot) {
@@ -1141,6 +1760,7 @@ var ResumedWorkflow = class extends SealedWorkflow {
1141
1760
  schema;
1142
1761
  mergeFn;
1143
1762
  priorOutput;
1763
+ nestedRemaining;
1144
1764
  /** @internal */
1145
1765
  constructor(steps, startIndex, config) {
1146
1766
  super(steps, void 0, config.observability);
@@ -1148,6 +1768,7 @@ var ResumedWorkflow = class extends SealedWorkflow {
1148
1768
  this.schema = config.schema;
1149
1769
  this.mergeFn = config.mergeFn;
1150
1770
  this.priorOutput = config.priorOutput;
1771
+ this.nestedRemaining = config.nestedRemaining;
1151
1772
  }
1152
1773
  validateResponse(response) {
1153
1774
  if (this.schema) {
@@ -1155,77 +1776,39 @@ var ResumedWorkflow = class extends SealedWorkflow {
1155
1776
  }
1156
1777
  return response;
1157
1778
  }
1158
- async generate(ctx, ...args) {
1159
- const rawResponse = args[0];
1160
- const opts = args[1];
1161
- let output = this.priorOutput;
1162
- let initialError = null;
1779
+ /**
1780
+ * Seed the run by validating the gate response and merging it with the
1781
+ * suspended output. Runs schema.parse + mergeFn inside a try so a failure
1782
+ * becomes a pre-execute `initialError` (routed through `.catch()`) rather
1783
+ * than escaping the run synchronously. On error the output falls back to the
1784
+ * prior (pre-gate) output.
1785
+ */
1786
+ async seedFromResponse(rawResponse) {
1163
1787
  try {
1164
1788
  const response = this.validateResponse(rawResponse);
1165
- output = this.mergeFn ? await this.mergeFn({ priorOutput: this.priorOutput, response }) : response;
1789
+ const merged = this.mergeFn ? await this.mergeFn({ priorOutput: this.priorOutput, response }) : response;
1790
+ if (this.nestedRemaining) {
1791
+ return {
1792
+ output: this.priorOutput,
1793
+ initialError: null,
1794
+ resumeDescent: { remaining: this.nestedRemaining, seedOutput: merged }
1795
+ };
1796
+ }
1797
+ return { output: merged, initialError: null };
1166
1798
  } catch (error) {
1167
- initialError = { error, stepId: "gate:resume", source: "step" };
1168
- }
1169
- const state = {
1170
- ctx,
1171
- output,
1172
- mode: "generate",
1173
- runOptions: opts,
1174
- abortSignal: opts?.abortSignal
1175
- };
1176
- await this.execute(state, this.startIndex, opts, initialError);
1177
- return this.buildResult(state);
1799
+ return { output: this.priorOutput, initialError: { error, stepId: GATE_RESUME_STEP_ID, source: "step" } };
1800
+ }
1801
+ }
1802
+ async generate(ctx, ...args) {
1803
+ const rawResponse = args[0];
1804
+ const opts = args[1];
1805
+ return this.runGenerate(ctx, this.startIndex, opts, () => this.seedFromResponse(rawResponse));
1178
1806
  }
1179
1807
  stream(ctx, ...args) {
1180
1808
  const rawResponse = args[0];
1181
1809
  const options = args[1];
1182
1810
  const opts = args[2];
1183
- const abortSignal = opts?.abortSignal;
1184
- let resolveOutput;
1185
- let rejectOutput;
1186
- const outputPromise = new Promise((res, rej) => {
1187
- resolveOutput = res;
1188
- rejectOutput = rej;
1189
- });
1190
- outputPromise.catch(() => {
1191
- });
1192
- const mergeFn = this.mergeFn;
1193
- const priorOutput = this.priorOutput;
1194
- const startIndex = this.startIndex;
1195
- const stream = (0, import_ai3.createUIMessageStream)({
1196
- execute: async ({ writer }) => {
1197
- let output = priorOutput;
1198
- let initialError = null;
1199
- try {
1200
- const response = this.validateResponse(rawResponse);
1201
- output = mergeFn ? await mergeFn({ priorOutput, response }) : response;
1202
- } catch (error) {
1203
- initialError = { error, stepId: "gate:resume", source: "step" };
1204
- }
1205
- const state = {
1206
- ctx,
1207
- output,
1208
- mode: "stream",
1209
- writer,
1210
- runOptions: opts,
1211
- abortSignal
1212
- };
1213
- try {
1214
- await this.execute(state, startIndex, opts, initialError);
1215
- const result = this.buildResult(state);
1216
- maybeWarnStreamOnErrorOnSuspend(result, options);
1217
- resolveOutput(result);
1218
- } catch (error) {
1219
- rejectOutput(error);
1220
- throw error;
1221
- }
1222
- },
1223
- ...options?.onError ? { onError: options.onError } : {},
1224
- ...options?.onFinish ? { onFinish: options.onFinish } : {},
1225
- ...options?.originalMessages ? { originalMessages: options.originalMessages } : {},
1226
- ...options?.generateId ? { generateId: options.generateId } : {}
1227
- });
1228
- return { stream, output: outputPromise };
1811
+ return this.runStream(ctx, this.startIndex, opts, options, () => this.seedFromResponse(rawResponse));
1229
1812
  }
1230
1813
  };
1231
1814
  var CheckpointResumedWorkflow = class extends SealedWorkflow {
@@ -1241,64 +1824,22 @@ var CheckpointResumedWorkflow = class extends SealedWorkflow {
1241
1824
  // Inputs are ignored — state is seeded from the snapshot's `output` field.
1242
1825
  async generate(ctx, ...args) {
1243
1826
  const opts = args[1];
1244
- this.validateRunOptions(opts);
1245
- const state = {
1246
- ctx,
1247
- output: this.priorOutput,
1248
- mode: "generate",
1249
- runOptions: opts
1250
- };
1251
- await this.execute(state, this.startIndex, opts);
1252
- return this.buildResult(state);
1827
+ return this.runGenerate(ctx, this.startIndex, opts, () => ({ output: this.priorOutput, initialError: null }));
1253
1828
  }
1254
1829
  stream(ctx, ...args) {
1255
1830
  const options = args[1];
1256
1831
  const opts = args[2];
1257
- this.validateRunOptions(opts);
1258
- let resolveOutput;
1259
- let rejectOutput;
1260
- const outputPromise = new Promise((res, rej) => {
1261
- resolveOutput = res;
1262
- rejectOutput = rej;
1263
- });
1264
- outputPromise.catch(() => {
1265
- });
1266
- const priorOutput = this.priorOutput;
1267
- const startIndex = this.startIndex;
1268
- const stream = (0, import_ai3.createUIMessageStream)({
1269
- execute: async ({ writer }) => {
1270
- const state = {
1271
- ctx,
1272
- output: priorOutput,
1273
- mode: "stream",
1274
- writer,
1275
- runOptions: opts
1276
- };
1277
- try {
1278
- await this.execute(state, startIndex, opts);
1279
- const result = this.buildResult(state);
1280
- maybeWarnStreamOnErrorOnSuspend(result, options);
1281
- resolveOutput(result);
1282
- } catch (error) {
1283
- rejectOutput(error);
1284
- throw error;
1285
- }
1286
- },
1287
- ...options?.onError ? { onError: options.onError } : {},
1288
- ...options?.onFinish ? { onFinish: options.onFinish } : {},
1289
- ...options?.originalMessages ? { originalMessages: options.originalMessages } : {},
1290
- ...options?.generateId ? { generateId: options.generateId } : {}
1291
- });
1292
- return { stream, output: outputPromise };
1832
+ return this.runStream(ctx, this.startIndex, opts, options, () => ({ output: this.priorOutput, initialError: null }));
1293
1833
  }
1294
1834
  };
1295
1835
  var Workflow = class _Workflow extends SealedWorkflow {
1296
1836
  /**
1297
- * Sentinel value for `foreach`'s `onError` handler. Returning `Workflow.SKIP`
1298
- * from `onError` omits the failed item's index from the output array,
1299
- * shortening it relative to the input array.
1837
+ * Sentinel value for `foreach`/`parallel`'s `onError` handler. Returning
1838
+ * `Workflow.SKIP` omits the failed item (foreach: shortens the output array;
1839
+ * parallel: leaves the slot `undefined`). Aliases the leaf-module `SKIP` so
1840
+ * the step subclasses can compare against it without importing this class.
1300
1841
  */
1301
- static SKIP = /* @__PURE__ */ Symbol("pipeai.foreach.skip");
1842
+ static SKIP = SKIP;
1302
1843
  constructor(steps = [], id, observability) {
1303
1844
  super(steps, id, observability);
1304
1845
  }
@@ -1309,26 +1850,20 @@ var Workflow = class _Workflow extends SealedWorkflow {
1309
1850
  return new _Workflow([]).step(agent, options);
1310
1851
  }
1311
1852
  // Builder helper — append a step and return a re-typed Workflow.
1312
- // Centralizes the `[...steps, node] as any` + new Workflow + observability/id
1313
- // forwarding pattern used by every combinator method.
1314
1853
  appendStep(node) {
1315
1854
  return new _Workflow([...this.steps, node], this.id, this.observability);
1316
1855
  }
1317
1856
  // ── step: implementation ──────────────────────────────────────
1318
- step(target, optionsOrFn) {
1857
+ step(target, optionsOrFn, inlineOptions) {
1319
1858
  if (target instanceof SealedWorkflow) {
1320
1859
  const workflow = target;
1321
- const node2 = {
1322
- type: "step",
1323
- id: workflow.id ?? "nested-workflow",
1324
- nestedWorkflow: workflow,
1325
- // Feeds the recursive stepShapeHash walk.
1326
- category: "nested",
1327
- // Observability event type.
1328
- execute: async (state) => {
1329
- await this.executeNestedWorkflow(state, workflow);
1330
- }
1331
- };
1860
+ const options2 = optionsOrFn;
1861
+ const node2 = new NestedWorkflowStep(
1862
+ options2?.id ?? workflow.id ?? "nested-workflow",
1863
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1864
+ workflow,
1865
+ options2
1866
+ );
1332
1867
  return this.appendStep(node2);
1333
1868
  }
1334
1869
  if (typeof target === "string") {
@@ -1336,31 +1871,22 @@ var Workflow = class _Workflow extends SealedWorkflow {
1336
1871
  throw new Error(`Workflow step("${target}"): second argument must be a function`);
1337
1872
  }
1338
1873
  const fn = optionsOrFn;
1339
- const node2 = {
1340
- type: "step",
1341
- id: target,
1342
- execute: async (state) => {
1343
- state.output = await fn({
1344
- ctx: state.ctx,
1345
- input: state.output,
1346
- // Present in stream mode (undefined in generate mode), letting the
1347
- // inline step emit UIMessageChunk parts onto the workflow's stream.
1348
- writer: state.writer
1349
- });
1350
- }
1351
- };
1874
+ const node2 = new TransformStep(
1875
+ target,
1876
+ fn,
1877
+ inlineOptions
1878
+ );
1352
1879
  return this.appendStep(node2);
1353
1880
  }
1354
1881
  const agent = target;
1355
1882
  const options = optionsOrFn;
1356
- const node = {
1357
- type: "step",
1358
- id: options?.id ?? agent.id,
1359
- execute: async (state) => {
1360
- const ctx = state.ctx;
1361
- await this.executeAgent(state, agent, ctx, options);
1362
- }
1363
- };
1883
+ const node = new AgentStep(
1884
+ options?.id ?? agent.id,
1885
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1886
+ agent,
1887
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1888
+ options
1889
+ );
1364
1890
  return this.appendStep(node);
1365
1891
  }
1366
1892
  // ── gate: human-in-the-loop suspension point ────────────────
@@ -1368,467 +1894,57 @@ var Workflow = class _Workflow extends SealedWorkflow {
1368
1894
  if (this.steps.some((s) => s.type === "gate" && s.id === id)) {
1369
1895
  throw new Error(`Workflow: duplicate gate ID "${id}". Each gate must have a unique identifier.`);
1370
1896
  }
1371
- const node = {
1372
- type: "gate",
1373
- id,
1374
- schema: options?.schema,
1375
- condition: options?.condition ? async (state) => options.condition({
1376
- ctx: state.ctx,
1377
- input: state.output
1378
- }) : void 0,
1379
- merge: options?.merge ? (params) => options.merge(params) : void 0,
1380
- payload: async (state) => {
1381
- if (options?.payload) {
1382
- return options.payload({
1383
- ctx: state.ctx,
1384
- input: state.output
1385
- });
1386
- }
1387
- return state.output;
1388
- }
1389
- };
1897
+ const node = new GateStep(id, options);
1390
1898
  return this.appendStep(node);
1391
1899
  }
1392
1900
  // ── branch: implementation ────────────────────────────────────
1393
1901
  branch(casesOrConfig, options) {
1394
- if (Array.isArray(casesOrConfig)) {
1395
- return this.branchPredicate(casesOrConfig, options?.id);
1396
- }
1397
- return this.branchSelect(casesOrConfig, options?.id);
1398
- }
1399
- branchPredicate(cases, explicitId) {
1400
- const node = {
1401
- type: "step",
1402
- id: explicitId ?? "branch:predicate",
1403
- category: "branch",
1404
- execute: async (state) => {
1405
- const ctx = state.ctx;
1406
- const input = state.output;
1407
- for (const branchCase of cases) {
1408
- if (branchCase.when) {
1409
- const match = await branchCase.when({ ctx, input });
1410
- if (!match) continue;
1411
- }
1412
- await this.executeAgent(state, branchCase.agent, ctx, branchCase);
1413
- return;
1414
- }
1415
- let inputRepr;
1416
- try {
1417
- inputRepr = JSON.stringify(input);
1418
- if (inputRepr === void 0) inputRepr = String(input);
1419
- } catch {
1420
- inputRepr = `[unserializable ${typeof input}]`;
1421
- }
1422
- throw new WorkflowBranchError("predicate", `No branch matched and no default branch (a case without \`when\`) was provided. Input: ${inputRepr}`);
1423
- }
1424
- };
1425
- return this.appendStep(node);
1426
- }
1427
- branchSelect(config, explicitId) {
1428
- const node = {
1429
- type: "step",
1430
- id: explicitId ?? "branch:select",
1431
- category: "branch",
1432
- execute: async (state) => {
1433
- const ctx = state.ctx;
1434
- const input = state.output;
1435
- const key = await config.select({ ctx, input });
1436
- const keyDeclared = Object.prototype.hasOwnProperty.call(config.agents, key);
1437
- if (keyDeclared && config.agents[key] === void 0) {
1438
- throw new WorkflowBranchError(
1439
- "select",
1440
- `Agent for key "${key}" was declared but the value is undefined. This usually means a conditional spread set the value to undefined. Available keys: ${Object.keys(config.agents).join(", ")}`
1441
- );
1442
- }
1443
- let agent = keyDeclared ? config.agents[key] : void 0;
1444
- if (!agent) {
1445
- if (config.onUnknownKey) {
1446
- config.onUnknownKey({
1447
- key,
1448
- availableKeys: Object.keys(config.agents),
1449
- ctx
1450
- });
1451
- }
1452
- if (config.fallback) {
1453
- agent = config.fallback;
1454
- } else {
1455
- throw new WorkflowBranchError("select", `No agent found for key "${key}" and no fallback provided. Available keys: ${Object.keys(config.agents).join(", ")}`);
1456
- }
1457
- }
1458
- await this.executeAgent(state, agent, ctx, config);
1459
- }
1460
- };
1902
+ const node = Array.isArray(casesOrConfig) ? new PredicateBranchStep(options?.id ?? "branch:predicate", casesOrConfig) : new SelectBranchStep(options?.id ?? "branch:select", casesOrConfig);
1461
1903
  return this.appendStep(node);
1462
1904
  }
1463
- // ── foreach: array iteration ─────────────────────────────────
1464
- /**
1465
- * Map each item of an array through an agent or sub-workflow.
1466
- *
1467
- * @param target Agent or `SealedWorkflow` invoked once per item.
1468
- * @param options.id Override the default step id (`foreach:<agentId>` or
1469
- * the workflow's id). Required when chaining multiple foreach over the same
1470
- * target — the construction-time `(type, id)` walk rejects duplicates.
1471
- * @param options.concurrency Max items in flight at any moment (default 1).
1472
- * Backed by a semaphore: as soon as one item completes, the next launches —
1473
- * no lockstep batching.
1474
- * @param options.onError Per-iteration error handler. **Bypassed entirely on
1475
- * the suspension path** (when any item hits a nested gate) — see the
1476
- * foreach concurrency hazards in the README. Otherwise: return a
1477
- * `TNextOutput` value to substitute, return `Workflow.SKIP` to omit, throw
1478
- * to abort. Invoked sequentially in index order after all items settle.
1479
- */
1905
+ // Implementation
1480
1906
  foreach(target, options) {
1481
- const concurrency = options?.concurrency ?? 1;
1482
- const onError = options?.onError;
1483
- const isWorkflow = target instanceof SealedWorkflow;
1484
- const defaultId = isWorkflow ? target.id ?? "foreach" : `foreach:${target.id}`;
1485
- const id = options?.id ?? defaultId;
1486
- const node = {
1487
- type: "step",
1488
- id,
1489
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1490
- nestedWorkflow: isWorkflow ? target : void 0,
1491
- category: "foreach",
1492
- execute: async (state) => {
1493
- const items = state.output;
1494
- if (!Array.isArray(items)) {
1495
- throw new Error(`foreach "${id}": expected array input, got ${typeof items}`);
1496
- }
1497
- const ctx = state.ctx;
1498
- const results = new Array(items.length);
1499
- const skipped = /* @__PURE__ */ new Set();
1500
- const itemStates = new Array(items.length);
1501
- const executeItem = async (item, index) => {
1502
- const itemState = {
1503
- ctx: state.ctx,
1504
- output: item,
1505
- mode: "generate",
1506
- abortSignal: state.abortSignal
1507
- };
1508
- itemStates[index] = itemState;
1509
- const itemStart = performance.now();
1510
- await this.fireHook(state, "onItemStart", {
1511
- stepId: id,
1512
- type: "foreach",
1513
- itemIndex: index,
1514
- ctx: state.ctx,
1515
- input: item
1516
- });
1517
- try {
1518
- if (isWorkflow) {
1519
- await this.executeNestedWorkflow(itemState, target);
1520
- } else {
1521
- await this.executeAgent(itemState, target, ctx);
1522
- }
1523
- results[index] = itemState.output;
1524
- await this.fireHook(state, "onItemFinish", {
1525
- stepId: id,
1526
- type: "foreach",
1527
- itemIndex: index,
1528
- ctx: state.ctx,
1529
- output: itemState.output,
1530
- durationMs: performance.now() - itemStart
1531
- });
1532
- } catch (error) {
1533
- await this.fireHook(state, "onItemError", {
1534
- stepId: id,
1535
- type: "foreach",
1536
- itemIndex: index,
1537
- ctx: state.ctx,
1538
- error,
1539
- durationMs: performance.now() - itemStart
1540
- });
1541
- throw error;
1542
- }
1543
- };
1544
- const mergeItemWarnings = () => {
1545
- for (let idx = 0; idx < items.length; idx++) {
1546
- const its = itemStates[idx];
1547
- if (!its?.warnings) continue;
1548
- for (const w of its.warnings) {
1549
- pushWarning(state, w.source, `${id}[${idx}]:${w.stepId}`, w.error);
1550
- }
1551
- }
1552
- };
1553
- const handleRejection = async (error, item, index) => {
1554
- if (!onError) throw error;
1555
- const recovered = await onError({
1556
- error,
1557
- item,
1558
- index,
1559
- ctx: state.ctx
1560
- });
1561
- if (recovered === _Workflow.SKIP) {
1562
- skipped.add(index);
1563
- } else {
1564
- results[index] = recovered;
1565
- }
1566
- };
1567
- const failures = [];
1568
- const signal = state.abortSignal;
1569
- if (concurrency <= 1) {
1570
- for (let i = 0; i < items.length; i++) {
1571
- if (signal?.aborted) {
1572
- failures.push({ index: i, error: signal.reason ?? new Error("Workflow aborted") });
1573
- continue;
1574
- }
1575
- try {
1576
- await executeItem(items[i], i);
1577
- } catch (error) {
1578
- failures.push({ index: i, error });
1579
- }
1580
- }
1581
- } else {
1582
- let nextIndex = 0;
1583
- const worker = async () => {
1584
- while (true) {
1585
- const i = nextIndex++;
1586
- if (i >= items.length) return;
1587
- if (signal?.aborted) {
1588
- failures.push({ index: i, error: signal.reason ?? new Error("Workflow aborted") });
1589
- continue;
1590
- }
1591
- try {
1592
- await executeItem(items[i], i);
1593
- } catch (error) {
1594
- failures.push({ index: i, error });
1595
- }
1596
- }
1597
- };
1598
- const workers = Array.from(
1599
- { length: Math.min(concurrency, items.length) },
1600
- () => worker()
1601
- );
1602
- await Promise.all(workers);
1603
- }
1604
- failures.sort((a, b) => a.index - b.index);
1605
- const gateFailures = [];
1606
- const nonGateFailures = [];
1607
- for (const f of failures) {
1608
- if (f.error instanceof NestedGateUnsupportedError) {
1609
- gateFailures.push({ index: f.index, error: f.error });
1610
- } else {
1611
- nonGateFailures.push(f);
1612
- }
1613
- }
1614
- mergeItemWarnings();
1615
- if (gateFailures.length > 0) {
1616
- for (const nr of nonGateFailures) {
1617
- pushWarning(state, "foreach-sibling", `${id}[${nr.index}]`, nr.error);
1618
- }
1619
- const lowest = gateFailures[0];
1620
- const otherSuspensions = gateFailures.slice(1).map((g) => ({
1621
- index: g.index,
1622
- gateId: g.error.gateId
1623
- }));
1624
- const siblingErrors = nonGateFailures.map((nr) => nr.error);
1625
- throw new NestedGateUnsupportedError(
1626
- lowest.error.gateId,
1627
- lowest.error.workflowId,
1628
- siblingErrors,
1629
- otherSuspensions
1630
- );
1631
- }
1632
- for (const { index, error } of nonGateFailures) {
1633
- await handleRejection(error, items[index], index);
1634
- }
1635
- state.output = skipped.size === 0 ? results : results.filter((_, i) => !skipped.has(i));
1636
- }
1637
- };
1907
+ const body = typeof target === "function" ? target(_Workflow.create({ observability: this.observability })) : target;
1908
+ const node = new ForeachStep(body, options, this.observability);
1638
1909
  return this.appendStep(node);
1639
1910
  }
1640
1911
  // Implementation
1641
1912
  parallel(branches, options) {
1642
- const isTuple = Array.isArray(branches);
1643
- const entries = isTuple ? branches.map((target, i) => ({ key: i, index: i, target })) : Object.entries(branches).map(([k, t], i) => ({ key: k, index: i, target: t }));
1644
- const branchCount = entries.length;
1645
- const requestedConcurrency = options?.concurrency;
1646
- let effectiveConcurrency;
1647
- if (requestedConcurrency === void 0) {
1648
- effectiveConcurrency = Math.min(branchCount, 5);
1649
- } else {
1650
- effectiveConcurrency = requestedConcurrency;
1651
- }
1652
- if (requestedConcurrency === void 0 && branchCount > 5) {
1653
- warnOnce(
1654
- "pipeai:parallel-cap",
1655
- `pipeai: parallel() with ${branchCount} branches capped at concurrency 5 by default. Pass { concurrency: ${branchCount} } (or Infinity) to opt in, or set { concurrency: N } if you want fewer.`
1656
- );
1657
- }
1658
- const onError = options?.onError;
1659
- const id = options?.id ?? (isTuple ? "parallel:tuple" : "parallel:record");
1660
- const node = {
1661
- type: "step",
1662
- id,
1663
- category: "parallel",
1664
- execute: async (state) => {
1665
- const ctx = state.ctx;
1666
- const input = state.output;
1667
- const results = isTuple ? new Array(branchCount) : {};
1668
- const branchStates = new Array(branchCount);
1669
- const executeBranch = async ({ key, index, target }) => {
1670
- const branchState = { ctx: state.ctx, output: input, mode: "generate" };
1671
- branchStates[index] = branchState;
1672
- const branchStart = performance.now();
1673
- const itemIndex = isTuple ? index : key;
1674
- await this.fireHook(state, "onItemStart", {
1675
- stepId: id,
1676
- type: "parallel",
1677
- itemIndex,
1678
- ctx: state.ctx,
1679
- input
1680
- });
1681
- try {
1682
- if (target instanceof SealedWorkflow) {
1683
- await this.executeNestedWorkflow(branchState, target);
1684
- } else {
1685
- await this.executeAgent(branchState, target, ctx);
1686
- }
1687
- results[key] = branchState.output;
1688
- await this.fireHook(state, "onItemFinish", {
1689
- stepId: id,
1690
- type: "parallel",
1691
- itemIndex,
1692
- ctx: state.ctx,
1693
- output: branchState.output,
1694
- durationMs: performance.now() - branchStart
1695
- });
1696
- } catch (error) {
1697
- await this.fireHook(state, "onItemError", {
1698
- stepId: id,
1699
- type: "parallel",
1700
- itemIndex,
1701
- ctx: state.ctx,
1702
- error,
1703
- durationMs: performance.now() - branchStart
1704
- });
1705
- throw error;
1706
- }
1707
- };
1708
- const failures = [];
1709
- const eff = Number.isFinite(effectiveConcurrency) ? Math.max(1, effectiveConcurrency) : branchCount;
1710
- if (eff <= 1) {
1711
- for (const e of entries) {
1712
- try {
1713
- await executeBranch(e);
1714
- } catch (error) {
1715
- failures.push({ key: e.key, index: e.index, error });
1716
- }
1717
- }
1718
- } else {
1719
- let nextIndex = 0;
1720
- const worker = async () => {
1721
- while (true) {
1722
- const i = nextIndex++;
1723
- if (i >= branchCount) return;
1724
- const e = entries[i];
1725
- try {
1726
- await executeBranch(e);
1727
- } catch (error) {
1728
- failures.push({ key: e.key, index: e.index, error });
1729
- }
1730
- }
1731
- };
1732
- const workers = Array.from(
1733
- { length: Math.min(eff, branchCount) },
1734
- () => worker()
1735
- );
1736
- await Promise.all(workers);
1737
- }
1738
- for (let idx = 0; idx < branchCount; idx++) {
1739
- const bs = branchStates[idx];
1740
- if (!bs?.warnings) continue;
1741
- for (const w of bs.warnings) {
1742
- pushWarning(state, w.source, `${id}[${entries[idx].key}]:${w.stepId}`, w.error);
1743
- }
1744
- }
1745
- const gateFailures = [];
1746
- const nonGateFailures = [];
1747
- for (const f of failures) {
1748
- if (f.error instanceof NestedGateUnsupportedError) gateFailures.push({ key: f.key, index: f.index, error: f.error });
1749
- else nonGateFailures.push(f);
1750
- }
1751
- gateFailures.sort((a, b) => a.index - b.index);
1752
- nonGateFailures.sort((a, b) => a.index - b.index);
1753
- if (gateFailures.length > 0) {
1754
- for (const nr of nonGateFailures) {
1755
- pushWarning(state, "foreach-sibling", `${id}[${nr.key}]`, nr.error);
1756
- }
1757
- const lowest = gateFailures[0];
1758
- const otherSuspensions = gateFailures.slice(1).map((g) => ({ index: g.index, gateId: g.error.gateId }));
1759
- const siblingErrors = nonGateFailures.map((nr) => nr.error);
1760
- throw new NestedGateUnsupportedError(
1761
- lowest.error.gateId,
1762
- lowest.error.workflowId,
1763
- siblingErrors,
1764
- otherSuspensions
1765
- );
1766
- }
1767
- for (const { key, index, error } of nonGateFailures) {
1768
- if (!onError) throw error;
1769
- const recovered = await onError({
1770
- error,
1771
- key: isTuple ? void 0 : key,
1772
- index: isTuple ? index : void 0,
1773
- ctx: state.ctx
1774
- });
1775
- if (recovered === _Workflow.SKIP) {
1776
- results[key] = void 0;
1777
- } else {
1778
- results[key] = recovered;
1779
- }
1780
- }
1781
- state.output = results;
1782
- }
1783
- };
1913
+ const node = new ParallelStep(branches, options, this.observability);
1784
1914
  return this.appendStep(node);
1785
1915
  }
1786
1916
  // ── repeat: conditional loop ─────────────────────────────────
1787
1917
  repeat(target, options) {
1918
+ if (options.maxIterations !== void 0 && (!Number.isInteger(options.maxIterations) || options.maxIterations < 1)) {
1919
+ throw new Error(`repeat: maxIterations must be a positive integer, got ${options.maxIterations}`);
1920
+ }
1921
+ if (options.until === void 0 === (options.while === void 0)) {
1922
+ throw new Error("repeat: requires exactly one of `until` or `while`");
1923
+ }
1788
1924
  const maxIterations = options.maxIterations ?? 10;
1789
1925
  const isWorkflow = target instanceof SealedWorkflow;
1790
1926
  const defaultId = isWorkflow ? target.id ?? "repeat" : `repeat:${target.id}`;
1791
1927
  const id = options.id ?? defaultId;
1792
1928
  const predicate = options.until ?? (async (p) => !await options.while(p));
1793
- const node = {
1794
- type: "step",
1929
+ const node = new RepeatStep(
1795
1930
  id,
1796
1931
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1797
- nestedWorkflow: isWorkflow ? target : void 0,
1798
- category: "repeat",
1799
- execute: async (state) => {
1800
- const ctx = state.ctx;
1801
- for (let i = 1; i <= maxIterations; i++) {
1802
- if (state.abortSignal?.aborted) {
1803
- throw state.abortSignal.reason ?? new Error("Workflow aborted");
1804
- }
1805
- if (isWorkflow) {
1806
- await this.executeNestedWorkflow(state, target);
1807
- } else {
1808
- await this.executeAgent(state, target, ctx);
1809
- }
1810
- const done = await predicate({
1811
- output: state.output,
1812
- ctx,
1813
- iterations: i
1814
- });
1815
- if (done) return;
1816
- }
1817
- throw new WorkflowLoopError(maxIterations, maxIterations);
1818
- }
1819
- };
1932
+ target,
1933
+ predicate,
1934
+ maxIterations,
1935
+ isWorkflow
1936
+ );
1820
1937
  return this.appendStep(node);
1821
1938
  }
1822
1939
  // ── catch ─────────────────────────────────────────────────────
1823
1940
  catch(id, fn) {
1824
- if (!this.steps.some((s) => s.type === "step")) {
1825
- throw new Error(`Workflow: catch("${id}") requires at least one preceding step.`);
1941
+ if (!this.steps.some((s) => s.type === "step" || s.type === "gate")) {
1942
+ throw new Error(`Workflow: catch("${id}") requires at least one preceding step or gate.`);
1826
1943
  }
1827
- const node = {
1828
- type: "catch",
1944
+ const node = new CatchStep(
1829
1945
  id,
1830
- catchFn: fn
1831
- };
1946
+ fn
1947
+ );
1832
1948
  return this.appendStep(node);
1833
1949
  }
1834
1950
  // `.finally()` is inherited from SealedWorkflow now (it lives there so
@@ -1836,13 +1952,13 @@ var Workflow = class _Workflow extends SealedWorkflow {
1836
1952
  };
1837
1953
 
1838
1954
  // src/index.ts
1839
- var SKIP = Workflow.SKIP;
1955
+ var SKIP2 = Workflow.SKIP;
1840
1956
  // Annotate the CommonJS export names for ESM import in node:
1841
1957
  0 && (module.exports = {
1958
+ ABORT_STEP_ID,
1842
1959
  Agent,
1843
1960
  CHECKPOINT_STEP_ID,
1844
- CheckpointTimeoutError,
1845
- NestedGateUnsupportedError,
1961
+ GATE_RESUME_STEP_ID,
1846
1962
  SKIP,
1847
1963
  TOOL_PROVIDER_BRAND,
1848
1964
  ToolProvider,
@@ -1850,7 +1966,6 @@ var SKIP = Workflow.SKIP;
1850
1966
  WorkflowBranchError,
1851
1967
  WorkflowLoopError,
1852
1968
  defineTool,
1853
- getActiveWriter,
1854
1969
  isToolProvider,
1855
1970
  migrateSnapshot
1856
1971
  });