pipeai 0.8.4 → 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
@@ -24,7 +24,7 @@ __export(index_exports, {
24
24
  Agent: () => Agent,
25
25
  CHECKPOINT_STEP_ID: () => CHECKPOINT_STEP_ID,
26
26
  GATE_RESUME_STEP_ID: () => GATE_RESUME_STEP_ID,
27
- SKIP: () => SKIP,
27
+ SKIP: () => SKIP2,
28
28
  TOOL_PROVIDER_BRAND: () => TOOL_PROVIDER_BRAND,
29
29
  ToolProvider: () => ToolProvider,
30
30
  Workflow: () => Workflow,
@@ -52,6 +52,7 @@ function runWithWriter(writer, fn) {
52
52
  function getActiveWriter() {
53
53
  return writerStorage.getStore();
54
54
  }
55
+ var SKIP = /* @__PURE__ */ Symbol("pipeai.foreach.skip");
55
56
  function resolveValue(value, ctx, input) {
56
57
  if (typeof value === "function") {
57
58
  return value(ctx, input);
@@ -291,7 +292,15 @@ var Agent = class {
291
292
  const resolved = await this.resolveConfig(ctx, input);
292
293
  const options = this.buildCallOptions(resolved, ctx, input);
293
294
  try {
294
- 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
+ } : {};
295
304
  return (0, import_ai2.streamText)({
296
305
  ...options,
297
306
  ...extra,
@@ -390,12 +399,19 @@ var import_ai3 = require("ai");
390
399
 
391
400
  // src/steps/step.ts
392
401
  var Step = class {
393
- // Note: `type: "step"` subclasses (agent / transform / nested / branch /
394
- // foreach / repeat / parallel) also carry a `readonly category` that the run
395
- // loop reads (`getObservabilityType`) to type observability events. It is not
396
- // declared on this base the `StepNode` union in `workflow.ts` redeclares the
397
- // node shape, so `category` is part of that structural contract rather than
398
- // this class. Subclasses add it directly.
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;
399
415
  /**
400
416
  * Precedence source tag a kind writes to `state.pendingError` when it
401
417
  * captures a thrown body error. Defaults to `"step"`; kinds with a distinct
@@ -403,19 +419,20 @@ var Step = class {
403
419
  */
404
420
  errorSource = "step";
405
421
  /**
406
- * The step's body the only method the run loop invokes. Each kind overrides
407
- * it to do its work, run its own skip checks, and capture errors onto state.
408
- * `state.output` is the input on entry and becomes the output on exit;
409
- * `state.writer` is present in stream mode. The base implementation is a
410
- * no-op so kinds that carry no body of their own need not override it.
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.
411
427
  */
412
428
  async execute(_state) {
413
429
  }
414
430
  /**
415
- * Run-policy gate: return `true` when this step should be skipped silently
416
- * (no output change). The default is the "normal" policy skip while the
417
- * flow is suspended or already in error. Overridden by kinds with inverted
418
- * policies: `catch` runs only when there's an error, `finally` always runs.
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.
419
436
  */
420
437
  shouldSkip(state) {
421
438
  return !!state.suspension || !!state.pendingError;
@@ -451,7 +468,6 @@ var TransformStep = class extends Step {
451
468
  this.options = options;
452
469
  }
453
470
  async execute(state) {
454
- if (this.shouldSkip(state)) return;
455
471
  try {
456
472
  if (await this.applyConditionalSkip(state, this.options)) return;
457
473
  state.output = await this.fn({
@@ -482,7 +498,6 @@ var AgentStep = class _AgentStep extends Step {
482
498
  this.options = options;
483
499
  }
484
500
  async execute(state) {
485
- if (this.shouldSkip(state)) return;
486
501
  try {
487
502
  if (await this.applyConditionalSkip(state, this.options)) return;
488
503
  await _AgentStep.runAgent(state, this.agent, state.ctx, this.options);
@@ -553,6 +568,26 @@ var AgentStep = class _AgentStep extends Step {
553
568
  }
554
569
  };
555
570
 
571
+ // src/errors.ts
572
+ var WorkflowBranchError = class extends Error {
573
+ constructor(branchType, message) {
574
+ super(message);
575
+ this.branchType = branchType;
576
+ this.name = "WorkflowBranchError";
577
+ }
578
+ };
579
+ var WorkflowLoopError = class extends Error {
580
+ constructor(iterations, maxIterations) {
581
+ super(`Loop exceeded maximum iterations (${maxIterations})`);
582
+ this.iterations = iterations;
583
+ this.maxIterations = maxIterations;
584
+ this.name = "WorkflowLoopError";
585
+ }
586
+ };
587
+ var CHECKPOINT_STEP_ID = "::pipeai::onCheckpoint";
588
+ var ABORT_STEP_ID = "::pipeai::abort";
589
+ var GATE_RESUME_STEP_ID = "::pipeai::gate:resume";
590
+
556
591
  // src/steps/branch-step.ts
557
592
  var PredicateBranchStep = class extends Step {
558
593
  type = "step";
@@ -567,7 +602,6 @@ var PredicateBranchStep = class extends Step {
567
602
  this.cases = cases;
568
603
  }
569
604
  async execute(state) {
570
- if (this.shouldSkip(state)) return;
571
605
  try {
572
606
  const ctx = state.ctx;
573
607
  const input = state.output;
@@ -606,7 +640,6 @@ var SelectBranchStep = class extends Step {
606
640
  this.config = config;
607
641
  }
608
642
  async execute(state) {
609
- if (this.shouldSkip(state)) return;
610
643
  try {
611
644
  const ctx = state.ctx;
612
645
  const input = state.output;
@@ -641,6 +674,95 @@ var SelectBranchStep = class extends Step {
641
674
  }
642
675
  };
643
676
 
677
+ // src/runtime.ts
678
+ function makeAbortError(signal) {
679
+ return {
680
+ error: signal.reason ?? new Error("Workflow aborted"),
681
+ stepId: ABORT_STEP_ID,
682
+ source: "step"
683
+ };
684
+ }
685
+ function prependNestedPath(snapshot, index, state) {
686
+ const next = { ...snapshot, nestedPath: [index, ...snapshot.nestedPath ?? []] };
687
+ if (resolveFreezeSnapshots(state)) deepFreeze(next);
688
+ return next;
689
+ }
690
+ function resolveFreezeSnapshots(state) {
691
+ return state.runOptions?.freezeSnapshots ? true : false;
692
+ }
693
+ function pendingErrorSourceToStepType(source) {
694
+ switch (source) {
695
+ case "step":
696
+ return "step";
697
+ case "gate":
698
+ return "gate";
699
+ case "finally":
700
+ return "finally";
701
+ case "catch":
702
+ return "catch";
703
+ case "onCheckpoint":
704
+ return "step";
705
+ }
706
+ }
707
+ async function emitCheckpoint(state, opts, resumeFromIndex, stepShapeHash) {
708
+ if (!opts.onCheckpoint) return;
709
+ const willFreeze = resolveFreezeSnapshots(state);
710
+ const snap = {
711
+ version: 2,
712
+ kind: "checkpoint",
713
+ resumeFromIndex,
714
+ output: willFreeze ? structuredClone(state.output) : state.output,
715
+ stepShapeHash
716
+ };
717
+ if (willFreeze) deepFreeze(snap);
718
+ await opts.onCheckpoint(snap, { signal: state.abortSignal });
719
+ }
720
+ var warnedStreamOnErrorOnSuspend = false;
721
+ function pushWarning(state, source, stepId, error) {
722
+ (state.warnings ??= []).push({ source, stepId, error });
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
+ }
745
+ function demotePendingError(state, pe) {
746
+ pushWarning(state, pe.source, pe.stepId, pe.error);
747
+ }
748
+ function maybeWarnStreamOnErrorOnSuspend(result, options) {
749
+ if (result.status !== "suspended" || !options?.onError || warnedStreamOnErrorOnSuspend) return;
750
+ warnedStreamOnErrorOnSuspend = true;
751
+ console.warn(
752
+ "pipeai: stream() with options.onError suspended at a gate \u2014 onError will NOT be invoked for suspension. Discriminate via the resolved output Promise."
753
+ );
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
+
644
766
  // src/steps/semaphore.ts
645
767
  var Semaphore = class {
646
768
  available;
@@ -663,17 +785,103 @@ var Semaphore = class {
663
785
  this.available++;
664
786
  }
665
787
  }
666
- async run(fn) {
667
- await this.acquire();
668
- try {
669
- return await fn();
670
- } finally {
671
- this.release();
672
- }
673
- }
674
788
  };
675
789
 
676
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
+ }
677
885
  function reconcileUnits(state, id, failures, count, keyAt, unitStates, signal) {
678
886
  for (let i = 0; i < count; i++) {
679
887
  const us = unitStates[i];
@@ -701,128 +909,50 @@ function reconcileUnits(state, id, failures, count, keyAt, unitStates, signal) {
701
909
  var ForeachStep = class extends Step {
702
910
  type = "step";
703
911
  category = "foreach";
704
- id;
705
912
  nestedWorkflow;
913
+ id;
706
914
  target;
707
915
  concurrency;
708
916
  onError;
709
917
  handleStream;
710
918
  isWorkflow;
711
- inheritStreaming;
712
919
  observability;
713
920
  constructor(target, options, observability) {
714
921
  super();
715
- if (options?.concurrency !== void 0 && !(Number.isInteger(options.concurrency) && options.concurrency >= 1 || options.concurrency === Infinity)) {
716
- throw new Error(`foreach: concurrency must be a positive integer or Infinity, got ${options.concurrency}`);
717
- }
718
922
  this.target = target;
719
- this.concurrency = options?.concurrency ?? Infinity;
923
+ this.concurrency = validateConcurrency("foreach", options?.concurrency);
720
924
  this.onError = options?.onError;
721
925
  this.handleStream = options?.handleStream;
722
926
  this.observability = observability;
723
927
  this.isWorkflow = target instanceof SealedWorkflow;
724
- this.inheritStreaming = this.isWorkflow || this.handleStream !== void 0;
725
928
  const defaultId = this.isWorkflow ? target.id ?? "foreach" : `foreach:${target.id}`;
726
929
  this.id = options?.id ?? defaultId;
727
930
  this.nestedWorkflow = this.isWorkflow ? target : void 0;
728
931
  }
729
932
  async execute(state) {
730
- if (this.shouldSkip(state)) return;
731
933
  try {
732
934
  const items = state.output;
733
935
  if (!Array.isArray(items)) {
734
936
  throw new Error(`foreach "${this.id}": expected array input, got ${typeof items}`);
735
937
  }
736
938
  const results = new Array(items.length);
737
- const skipped = /* @__PURE__ */ new Set();
738
- const itemStates = new Array(items.length);
739
- const wantItemHooks = hasItemHooks(this.observability);
740
- const executeItem = async (item, index) => {
741
- const itemState = {
742
- ctx: state.ctx,
743
- output: item,
744
- mode: this.inheritStreaming ? state.mode : "generate",
745
- writer: this.inheritStreaming ? state.writer : void 0,
746
- abortSignal: state.abortSignal
747
- };
748
- itemStates[index] = itemState;
749
- const itemStart = wantItemHooks ? performance.now() : 0;
750
- if (wantItemHooks) {
751
- await fireHook(this.observability, state, "onItemStart", {
752
- stepId: this.id,
753
- type: "foreach",
754
- itemIndex: index,
755
- ctx: state.ctx,
756
- input: item
757
- });
758
- }
759
- try {
760
- if (this.isWorkflow) {
761
- await this.target.executeAsNested(itemState);
762
- } else {
763
- await AgentStep.runAgent(
764
- itemState,
765
- this.target,
766
- state.ctx,
767
- this.handleStream ? { handleStream: this.handleStream } : void 0,
768
- index
769
- );
770
- }
771
- results[index] = itemState.output;
772
- if (wantItemHooks) {
773
- await fireHook(this.observability, state, "onItemFinish", {
774
- stepId: this.id,
775
- type: "foreach",
776
- itemIndex: index,
777
- ctx: state.ctx,
778
- output: itemState.output,
779
- durationMs: performance.now() - itemStart
780
- });
781
- }
782
- } catch (error) {
783
- if (wantItemHooks) {
784
- await fireHook(this.observability, state, "onItemError", {
785
- stepId: this.id,
786
- type: "foreach",
787
- itemIndex: index,
788
- ctx: state.ctx,
789
- error,
790
- durationMs: performance.now() - itemStart
791
- });
792
- }
793
- throw error;
794
- }
795
- };
796
- const sem = new Semaphore(this.concurrency);
797
- const failures = [];
798
- const inflight = /* @__PURE__ */ new Set();
799
- for (let i = 0; i < items.length; i++) {
800
- if (state.abortSignal?.aborted) break;
801
- await sem.acquire();
802
- if (state.abortSignal?.aborted) {
803
- sem.release();
804
- break;
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;
805
949
  }
806
- const index = i;
807
- const unit = (async () => {
808
- try {
809
- await executeItem(items[index], index);
810
- } catch (error) {
811
- failures.push({ key: index, index, error });
812
- } finally {
813
- sem.release();
814
- }
815
- })();
816
- inflight.add(unit);
817
- void unit.finally(() => inflight.delete(unit));
818
- }
819
- await Promise.all(inflight);
820
- failures.sort((a, b) => a.index - b.index);
821
- const nonGateFailures = reconcileUnits(state, this.id, failures, items.length, (i) => i, itemStates, state.abortSignal);
822
- for (const { index, error } of nonGateFailures) {
950
+ });
951
+ const skipped = /* @__PURE__ */ new Set();
952
+ for (const { index, error } of failures) {
823
953
  if (!this.onError) throw error;
824
954
  const recovered = await this.onError({ error, item: items[index], index, ctx: state.ctx });
825
- if (recovered === Workflow.SKIP) {
955
+ if (recovered === SKIP) {
826
956
  skipped.add(index);
827
957
  } else {
828
958
  results[index] = recovered;
@@ -842,7 +972,6 @@ var ParallelStep = class extends Step {
842
972
  id;
843
973
  entries;
844
974
  isTuple;
845
- branchCount;
846
975
  concurrency;
847
976
  onError;
848
977
  handleStream;
@@ -850,112 +979,30 @@ var ParallelStep = class extends Step {
850
979
  constructor(branches, options, observability) {
851
980
  super();
852
981
  this.isTuple = Array.isArray(branches);
853
- this.entries = this.isTuple ? branches.map((target, i) => ({ key: i, index: i, target })) : Object.entries(branches).map(([k, t], i) => ({ key: k, index: i, target: t }));
854
- this.branchCount = this.entries.length;
855
- const requestedConcurrency = options?.concurrency;
856
- if (requestedConcurrency !== void 0 && !(Number.isInteger(requestedConcurrency) && requestedConcurrency >= 1 || requestedConcurrency === Infinity)) {
857
- throw new Error(`parallel: concurrency must be a positive integer or Infinity, got ${requestedConcurrency}`);
858
- }
859
- this.concurrency = requestedConcurrency ?? Infinity;
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);
860
984
  this.onError = options?.onError;
861
985
  this.handleStream = options?.handleStream;
862
986
  this.observability = observability;
863
987
  this.id = options?.id ?? (this.isTuple ? "parallel:tuple" : "parallel:record");
864
988
  }
865
989
  async execute(state) {
866
- if (this.shouldSkip(state)) return;
867
990
  try {
868
991
  const input = state.output;
869
- const results = this.isTuple ? new Array(this.branchCount) : {};
870
- const branchStates = new Array(this.branchCount);
871
- const wantItemHooks = hasItemHooks(this.observability);
872
- const executeBranch = async ({ key, index, target }) => {
873
- const isWorkflowBranch = target instanceof SealedWorkflow;
874
- const inheritStreaming = isWorkflowBranch || this.handleStream !== void 0;
875
- const branchState = {
876
- ctx: state.ctx,
877
- output: input,
878
- mode: inheritStreaming ? state.mode : "generate",
879
- writer: inheritStreaming ? state.writer : void 0,
880
- abortSignal: state.abortSignal
881
- };
882
- branchStates[index] = branchState;
883
- const branchStart = wantItemHooks ? performance.now() : 0;
884
- const itemIndex = this.isTuple ? index : key;
885
- if (wantItemHooks) {
886
- await fireHook(this.observability, state, "onItemStart", {
887
- stepId: this.id,
888
- type: "parallel",
889
- itemIndex,
890
- ctx: state.ctx,
891
- input
892
- });
893
- }
894
- try {
895
- if (isWorkflowBranch) {
896
- await target.executeAsNested(branchState);
897
- } else {
898
- await AgentStep.runAgent(
899
- branchState,
900
- target,
901
- state.ctx,
902
- this.handleStream ? { handleStream: this.handleStream } : void 0,
903
- itemIndex
904
- );
905
- }
906
- results[key] = branchState.output;
907
- if (wantItemHooks) {
908
- await fireHook(this.observability, state, "onItemFinish", {
909
- stepId: this.id,
910
- type: "parallel",
911
- itemIndex,
912
- ctx: state.ctx,
913
- output: branchState.output,
914
- durationMs: performance.now() - branchStart
915
- });
916
- }
917
- } catch (error) {
918
- if (wantItemHooks) {
919
- await fireHook(this.observability, state, "onItemError", {
920
- stepId: this.id,
921
- type: "parallel",
922
- itemIndex,
923
- ctx: state.ctx,
924
- error,
925
- durationMs: performance.now() - branchStart
926
- });
927
- }
928
- throw error;
929
- }
930
- };
931
- const keyAt = (i) => this.entries[i].key;
932
- const sem = new Semaphore(this.concurrency);
933
- const failures = [];
934
- const inflight = /* @__PURE__ */ new Set();
935
- for (let i = 0; i < this.branchCount; i++) {
936
- if (state.abortSignal?.aborted) break;
937
- await sem.acquire();
938
- if (state.abortSignal?.aborted) {
939
- sem.release();
940
- break;
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;
941
1003
  }
942
- const index = i;
943
- const unit = (async () => {
944
- try {
945
- await executeBranch(this.entries[index]);
946
- } catch (error) {
947
- failures.push({ key: keyAt(index), index, error });
948
- } finally {
949
- sem.release();
950
- }
951
- })();
952
- inflight.add(unit);
953
- void unit.finally(() => inflight.delete(unit));
954
- }
955
- await Promise.all(inflight);
956
- failures.sort((a, b) => a.index - b.index);
957
- const nonGateFailures = reconcileUnits(state, this.id, failures, this.branchCount, keyAt, branchStates, state.abortSignal);
958
- for (const { key, index, error } of nonGateFailures) {
1004
+ });
1005
+ for (const { key, index, error } of failures) {
959
1006
  if (!this.onError) throw error;
960
1007
  const recovered = await this.onError({
961
1008
  error,
@@ -963,11 +1010,7 @@ var ParallelStep = class extends Step {
963
1010
  index: this.isTuple ? index : void 0,
964
1011
  ctx: state.ctx
965
1012
  });
966
- if (recovered === Workflow.SKIP) {
967
- results[key] = void 0;
968
- } else {
969
- results[key] = recovered;
970
- }
1013
+ results[key] = recovered === SKIP ? void 0 : recovered;
971
1014
  }
972
1015
  state.output = results;
973
1016
  } catch (error) {
@@ -987,25 +1030,25 @@ var GateStep = class extends Step {
987
1030
  merge;
988
1031
  payload;
989
1032
  condition;
990
- constructor(id, payload, schema, condition, merge) {
1033
+ constructor(id, options) {
991
1034
  super();
992
1035
  this.id = id;
993
- this.payload = payload;
994
- this.schema = schema;
995
- this.condition = condition;
996
- this.merge = merge;
1036
+ this.payload = options?.payload;
1037
+ this.schema = options?.schema;
1038
+ this.condition = options?.condition;
1039
+ this.merge = options?.merge;
997
1040
  }
998
1041
  async execute(state) {
999
- if (this.shouldSkip(state)) return;
1000
1042
  try {
1001
- if (this.condition && !await this.condition(state)) return;
1043
+ const params = { ctx: state.ctx, input: state.output };
1044
+ if (this.condition && !await this.condition(params)) return;
1002
1045
  const snapshot = {
1003
1046
  version: 2,
1004
1047
  kind: "gate",
1005
1048
  resumeFromIndex: state.stepIndex ?? -1,
1006
1049
  output: state.output,
1007
1050
  gateId: this.id,
1008
- gatePayload: await this.payload(state)
1051
+ gatePayload: this.payload ? await this.payload(params) : state.output
1009
1052
  };
1010
1053
  state.suspension = snapshot;
1011
1054
  if (resolveFreezeSnapshots(state)) deepFreeze(snapshot);
@@ -1031,7 +1074,6 @@ var CatchStep = class extends Step {
1031
1074
  return !!state.suspension || !state.pendingError || !!state.checkpointFailed;
1032
1075
  }
1033
1076
  async execute(state) {
1034
- if (this.shouldSkip(state)) return;
1035
1077
  const handled = state.pendingError;
1036
1078
  state.output = await this.catchFn({
1037
1079
  error: handled.error,
@@ -1059,7 +1101,6 @@ var FinallyStep = class extends Step {
1059
1101
  return false;
1060
1102
  }
1061
1103
  async execute(state) {
1062
- if (this.shouldSkip(state)) return;
1063
1104
  await this.fn({ ctx: state.ctx });
1064
1105
  }
1065
1106
  };
@@ -1068,8 +1109,8 @@ var FinallyStep = class extends Step {
1068
1109
  var NestedWorkflowStep = class extends Step {
1069
1110
  type = "step";
1070
1111
  category = "nested";
1071
- id;
1072
1112
  nestedWorkflow;
1113
+ id;
1073
1114
  options;
1074
1115
  constructor(id, workflow, options) {
1075
1116
  super();
@@ -1096,7 +1137,6 @@ var NestedWorkflowStep = class extends Step {
1096
1137
  }
1097
1138
  return;
1098
1139
  }
1099
- if (this.shouldSkip(state)) return;
1100
1140
  const myIndex = state.stepIndex ?? -1;
1101
1141
  try {
1102
1142
  if (await this.applyConditionalSkip(state, this.options)) return;
@@ -1112,8 +1152,8 @@ var NestedWorkflowStep = class extends Step {
1112
1152
  var RepeatStep = class extends Step {
1113
1153
  type = "step";
1114
1154
  category = "repeat";
1115
- id;
1116
1155
  nestedWorkflow;
1156
+ id;
1117
1157
  target;
1118
1158
  predicate;
1119
1159
  maxIterations;
@@ -1128,7 +1168,6 @@ var RepeatStep = class extends Step {
1128
1168
  this.nestedWorkflow = isWorkflow ? target : void 0;
1129
1169
  }
1130
1170
  async execute(state) {
1131
- if (this.shouldSkip(state)) return;
1132
1171
  try {
1133
1172
  const ctx = state.ctx;
1134
1173
  for (let i = 1; i <= this.maxIterations; i++) {
@@ -1150,107 +1189,7 @@ var RepeatStep = class extends Step {
1150
1189
  }
1151
1190
  };
1152
1191
 
1153
- // src/runtime.ts
1154
- function resolveFreezeSnapshots(state) {
1155
- return state.runOptions?.freezeSnapshots ? true : false;
1156
- }
1157
- function pendingErrorSourceToStepType(source) {
1158
- switch (source) {
1159
- case "step":
1160
- return "step";
1161
- case "gate":
1162
- return "gate";
1163
- case "finally":
1164
- return "finally";
1165
- case "catch":
1166
- return "catch";
1167
- case "onCheckpoint":
1168
- return "step";
1169
- }
1170
- }
1171
- async function emitCheckpoint(state, opts, resumeFromIndex, stepShapeHash) {
1172
- if (!opts.onCheckpoint) return;
1173
- const willFreeze = resolveFreezeSnapshots(state);
1174
- const snap = {
1175
- version: 2,
1176
- kind: "checkpoint",
1177
- resumeFromIndex,
1178
- output: willFreeze ? structuredClone(state.output) : state.output,
1179
- stepShapeHash
1180
- };
1181
- if (willFreeze) deepFreeze(snap);
1182
- await opts.onCheckpoint(snap, { signal: state.abortSignal });
1183
- }
1184
- var warnedStreamOnErrorOnSuspend = false;
1185
- function pushWarning(state, source, stepId, error) {
1186
- (state.warnings ??= []).push({ source, stepId, error });
1187
- }
1188
- function fireHook(observability, state, name, event) {
1189
- const hook = observability?.[name];
1190
- if (!hook) return void 0;
1191
- return fireHookSlow(state, name, event, hook);
1192
- }
1193
- async function fireHookSlow(state, name, event, hook) {
1194
- try {
1195
- await hook(event);
1196
- return void 0;
1197
- } catch (e) {
1198
- if (name !== "onStepError") {
1199
- const stepId = event.stepId;
1200
- pushWarning(state, name, stepId, e);
1201
- console.error(`pipeai: ${name} hook threw for stepId "${stepId}":`, e);
1202
- }
1203
- return e;
1204
- }
1205
- }
1206
- function hasItemHooks(observability) {
1207
- return !!observability && !!(observability.onItemStart || observability.onItemFinish || observability.onItemError);
1208
- }
1209
- function demotePendingError(state, pe) {
1210
- pushWarning(state, pe.source, pe.stepId, pe.error);
1211
- }
1212
- function maybeWarnStreamOnErrorOnSuspend(result, options) {
1213
- if (result.status !== "suspended" || !options?.onError || warnedStreamOnErrorOnSuspend) return;
1214
- warnedStreamOnErrorOnSuspend = true;
1215
- console.warn(
1216
- "pipeai: stream() with options.onError suspended at a gate \u2014 onError will NOT be invoked for suspension. Discriminate via the resolved output Promise."
1217
- );
1218
- }
1219
- function makeRuntimeState(ctx, output, mode, opts, writer) {
1220
- return {
1221
- ctx,
1222
- output,
1223
- mode,
1224
- ...writer ? { writer } : {},
1225
- runOptions: opts,
1226
- abortSignal: opts?.abortSignal
1227
- };
1228
- }
1229
-
1230
1192
  // src/workflow.ts
1231
- var WorkflowBranchError = class extends Error {
1232
- constructor(branchType, message) {
1233
- super(message);
1234
- this.branchType = branchType;
1235
- this.name = "WorkflowBranchError";
1236
- }
1237
- };
1238
- var WorkflowLoopError = class extends Error {
1239
- constructor(iterations, maxIterations) {
1240
- super(`Loop exceeded maximum iterations (${maxIterations})`);
1241
- this.iterations = iterations;
1242
- this.maxIterations = maxIterations;
1243
- this.name = "WorkflowLoopError";
1244
- }
1245
- };
1246
- var CHECKPOINT_STEP_ID = "::pipeai::onCheckpoint";
1247
- var ABORT_STEP_ID = "::pipeai::abort";
1248
- var GATE_RESUME_STEP_ID = "::pipeai::gate:resume";
1249
- function prependNestedPath(snapshot, index, state) {
1250
- const next = { ...snapshot, nestedPath: [index, ...snapshot.nestedPath ?? []] };
1251
- if (resolveFreezeSnapshots(state)) deepFreeze(next);
1252
- return next;
1253
- }
1254
1193
  function migrateSnapshot(legacy) {
1255
1194
  if (legacy.version !== 1) {
1256
1195
  throw new Error(`migrateSnapshot: expected v1 snapshot, got version ${legacy.version}`);
@@ -1264,30 +1203,15 @@ function migrateSnapshot(legacy) {
1264
1203
  gatePayload: legacy.gatePayload
1265
1204
  };
1266
1205
  }
1267
- function getObservabilityType(node) {
1268
- if (node.type !== "step") return node.type;
1269
- return node.category ?? "step";
1270
- }
1271
- function getNestedWorkflows(node) {
1272
- switch (node.type) {
1273
- case "step":
1274
- return node.nestedWorkflow ? [node.nestedWorkflow] : [];
1275
- case "gate":
1276
- case "catch":
1277
- case "finally":
1278
- return [];
1279
- }
1280
- }
1281
1206
  var SealedWorkflow = class _SealedWorkflow {
1282
1207
  id;
1283
1208
  steps;
1284
1209
  observability;
1285
1210
  // Memoized — see ensureDuplicateCheck().
1286
1211
  duplicateCheckPassed = false;
1287
- // Memoized lazily per terminal instance build pipelines once at module
1288
- // load and re-run via generate() to amortize.
1289
- _cachedExecutableStepCount;
1290
- _cachedCheckpointableStepCount;
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;
1291
1215
  _cachedStepShapeHash;
1292
1216
  constructor(steps, id, observability) {
1293
1217
  this.steps = steps;
@@ -1322,39 +1246,26 @@ var SealedWorkflow = class _SealedWorkflow {
1322
1246
  }
1323
1247
  this.duplicateCheckPassed = true;
1324
1248
  }
1325
- // ── shape-hash + RunOptions validation ────────────────────────
1249
+ // ── step counts + shape-hash (memoized) ────────────────────────────
1326
1250
  /**
1327
- * Count of executable nodes i.e. NOT `catch` or `finally`. Drives
1328
- * checkpoint auto-cadence so adding cleanup steps doesn't surprise users
1329
- * with extra fires. `branch`/`foreach`/`repeat`/`parallel`/`nested` are all
1330
- * `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.
1331
1259
  */
1332
- get cachedExecutableStepCount() {
1333
- if (this._cachedExecutableStepCount !== void 0) return this._cachedExecutableStepCount;
1334
- let n = 0;
1260
+ get stepCounts() {
1261
+ if (this._stepCounts) return this._stepCounts;
1262
+ let executable = 0;
1263
+ let checkpointable = 0;
1335
1264
  for (const s of this.steps) {
1336
- if (s.type !== "catch" && s.type !== "finally") n++;
1265
+ if (s.type !== "catch" && s.type !== "finally") executable++;
1266
+ if (s.type === "step") checkpointable++;
1337
1267
  }
1338
- this._cachedExecutableStepCount = n;
1339
- return n;
1340
- }
1341
- /**
1342
- * Count of *checkpointable* nodes — `type === "step"` only (this includes
1343
- * `branch`/`foreach`/`repeat`/`parallel`/`nested`, all internally `step`).
1344
- * Drives the checkpoint auto-cadence denominator. Distinct from
1345
- * {@link cachedExecutableStepCount}, which also counts `gate` nodes: gates
1346
- * suspend/skip and never reach the checkpoint block, so the runtime
1347
- * `executableStepsSeen` counter never advances on them. Counting gates in
1348
- * the denominator would dilute the "~4 checkpoints across the run" target.
1349
- */
1350
- get cachedCheckpointableStepCount() {
1351
- if (this._cachedCheckpointableStepCount !== void 0) return this._cachedCheckpointableStepCount;
1352
- let n = 0;
1353
- for (const s of this.steps) {
1354
- if (s.type === "step") n++;
1355
- }
1356
- this._cachedCheckpointableStepCount = n;
1357
- return n;
1268
+ return this._stepCounts = { executable, checkpointable };
1358
1269
  }
1359
1270
  /** @internal — used by `computeStepShapeHash` to descend nested workflows. */
1360
1271
  getStepsForShapeHash() {
@@ -1362,7 +1273,7 @@ var SealedWorkflow = class _SealedWorkflow {
1362
1273
  }
1363
1274
  get cachedStepShapeHash() {
1364
1275
  if (this._cachedStepShapeHash !== void 0) return this._cachedStepShapeHash;
1365
- const getNested = (node) => getNestedWorkflows(node);
1276
+ const getNested = (node) => node.nestedWorkflow ? [node.nestedWorkflow] : [];
1366
1277
  this._cachedStepShapeHash = computeStepShapeHash(
1367
1278
  this.steps,
1368
1279
  getNested
@@ -1373,21 +1284,29 @@ var SealedWorkflow = class _SealedWorkflow {
1373
1284
  * Validate user-provided RunOptions before a run begins. Throws on
1374
1285
  * outright errors and on the loud-disaster combo (`freezeSnapshots: true
1375
1286
  * + checkpointEvery: 1` on a workflow of 8+ steps). Warns once on the
1376
- * merely-suspicious combo (`freezeSnapshots: true + cadence <= 2`).
1377
- * Plan-of-record: catastrophic combo escape via the
1378
- * `"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).
1379
1290
  */
1380
1291
  validateRunOptions(opts) {
1381
1292
  if (!opts) return;
1382
- if (!opts.onCheckpoint) return;
1383
1293
  if (opts.checkpointEvery !== void 0 && opts.checkpointWhen !== void 0) {
1384
1294
  throw new Error("RunOptions: checkpointEvery and checkpointWhen are mutually exclusive");
1385
1295
  }
1386
1296
  if (opts.checkpointEvery !== void 0 && (!Number.isInteger(opts.checkpointEvery) || opts.checkpointEvery < 1)) {
1387
1297
  throw new Error(`RunOptions: checkpointEvery must be a positive integer, got ${opts.checkpointEvery}`);
1388
1298
  }
1389
- const length = this.cachedExecutableStepCount;
1390
- const cadence = opts.checkpointEvery ?? Math.max(1, Math.ceil(this.cachedCheckpointableStepCount / 4));
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;
1307
+ }
1308
+ const length = this.stepCounts.executable;
1309
+ const cadence = opts.checkpointEvery ?? Math.max(1, Math.ceil(this.stepCounts.checkpointable / 4));
1391
1310
  if (opts.freezeSnapshots && opts.freezeSnapshots !== "iAcceptThePerformanceCost" && cadence === 1 && length >= 8) {
1392
1311
  throw new Error(
1393
1312
  `freezeSnapshots+checkpointEvery:1 on a ${length}-step workflow is reliably catastrophic. Set checkpointEvery >= 5, freezeSnapshots: false, or pass "iAcceptThePerformanceCost".`
@@ -1401,6 +1320,13 @@ var SealedWorkflow = class _SealedWorkflow {
1401
1320
  }
1402
1321
  }
1403
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
+ }
1404
1330
  /**
1405
1331
  * Fire an observability hook safely. Returns `undefined` synchronously when
1406
1332
  * no hook is registered — avoiding the promise wrapper + microtask that an
@@ -1414,17 +1340,14 @@ var SealedWorkflow = class _SealedWorkflow {
1414
1340
  * Returns the hook's thrown error if any; undefined otherwise. Callers
1415
1341
  * `await` the result — `await undefined` is sync, so the no-hook path
1416
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.
1417
1347
  */
1418
- // Thin delegates to the free `fireHook` / `hasItemHooks` (which take an
1419
- // explicit observability). Kept as methods so the loop's many `this.fireHook`
1420
- // call sites stay unchanged; bare `fireHook` / `hasItemHooks` below resolve to
1421
- // the module-level functions, not these members.
1422
1348
  fireHook(state, name, event) {
1423
1349
  return fireHook(this.observability, state, name, event);
1424
1350
  }
1425
- hasItemHooks() {
1426
- return hasItemHooks(this.observability);
1427
- }
1428
1351
  /**
1429
1352
  * Fire `onStepError` for a step-body failure and honor the documented
1430
1353
  * cause-attachment contract uniformly across every firing path (step, gate,
@@ -1529,30 +1452,15 @@ var SealedWorkflow = class _SealedWorkflow {
1529
1452
  if (opts !== void 0 && state.runOptions === void 0) {
1530
1453
  state.runOptions = opts;
1531
1454
  }
1532
- const ckptCadence = opts?.onCheckpoint && opts.checkpointWhen === void 0 ? opts.checkpointEvery ?? Math.max(1, Math.ceil(this.cachedCheckpointableStepCount / 4)) : 0;
1533
- let executableStepsSeen = 0;
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 };
1534
1457
  state.pendingError = initialError ?? void 0;
1535
- let abortPromoted = false;
1536
- const makeAbortError = (signal) => ({
1537
- error: signal.reason ?? new Error("Workflow aborted"),
1538
- stepId: ABORT_STEP_ID,
1539
- source: "step"
1540
- });
1458
+ const abortState = { promoted: false };
1541
1459
  for (let i = startIndex; i < this.steps.length; i++) {
1542
- if (state.abortSignal?.aborted) {
1543
- if (!abortPromoted) {
1544
- abortPromoted = true;
1545
- state.suspension = void 0;
1546
- if (state.pendingError) demotePendingError(state, state.pendingError);
1547
- state.pendingError = makeAbortError(state.abortSignal);
1548
- } else if (!state.pendingError) {
1549
- state.pendingError = makeAbortError(state.abortSignal);
1550
- }
1551
- }
1460
+ this.promoteAbort(state, abortState);
1552
1461
  const node = this.steps[i];
1553
- const skip = node.type === "finally" ? false : node.type === "catch" ? !!state.suspension || !state.pendingError || !!state.checkpointFailed : !!state.suspension || !!state.pendingError;
1554
- if (skip) continue;
1555
- const obsType = getObservabilityType(node);
1462
+ if (node.shouldSkip(state)) continue;
1463
+ const obsType = this.obsEventType(node);
1556
1464
  const stepId = node.id;
1557
1465
  const sStart = performance.now();
1558
1466
  const errBefore = state.pendingError;
@@ -1572,7 +1480,9 @@ var SealedWorkflow = class _SealedWorkflow {
1572
1480
  throw e;
1573
1481
  }
1574
1482
  const newError = state.pendingError && state.pendingError !== errBefore ? state.pendingError : null;
1575
- if (newError) {
1483
+ const isAbort = !!newError && state.abortSignal?.aborted === true && newError.error === state.abortSignal.reason;
1484
+ if (isAbort) {
1485
+ } else if (newError) {
1576
1486
  await this.fireStepErrorAndAttachCause(state, {
1577
1487
  stepId,
1578
1488
  type: obsType,
@@ -1595,32 +1505,73 @@ var SealedWorkflow = class _SealedWorkflow {
1595
1505
  state.suspension = void 0;
1596
1506
  throw new Error(`internal: suspension bubbled from non-gate step "${node.id}" (gate "${leaked.gateId}").`);
1597
1507
  }
1598
- if (node.type === "step" && !state.pendingError && !state.suspension && opts?.onCheckpoint) {
1599
- executableStepsSeen++;
1600
- const shouldCheckpoint = opts.checkpointWhen ? opts.checkpointWhen({ stepIndex: i, stepId: node.id, ctx: state.ctx }) : executableStepsSeen % ckptCadence === 0;
1601
- if (shouldCheckpoint) {
1602
- const ckptStart = performance.now();
1603
- try {
1604
- await emitCheckpoint(
1605
- state,
1606
- opts,
1607
- i + 1,
1608
- this.cachedStepShapeHash
1609
- );
1610
- } catch (e) {
1611
- state.pendingError = { error: e, stepId: CHECKPOINT_STEP_ID, source: "onCheckpoint" };
1612
- state.checkpointFailed = true;
1613
- await this.fireStepErrorAndAttachCause(state, {
1614
- stepId: CHECKPOINT_STEP_ID,
1615
- type: "step",
1616
- ctx: state.ctx,
1617
- error: e,
1618
- durationMs: performance.now() - ckptStart
1619
- });
1620
- }
1621
- }
1622
- }
1508
+ await this.maybeCheckpoint(state, opts, node, i, ckptCadence, ckptCounter);
1623
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) {
1624
1575
  if (abortPromoted && !state.pendingError && !state.suspension && state.abortSignal?.aborted) {
1625
1576
  state.pendingError = makeAbortError(state.abortSignal);
1626
1577
  }
@@ -1696,15 +1647,14 @@ var SealedWorkflow = class _SealedWorkflow {
1696
1647
  if (nestedPath && nestedPath.length > 0) {
1697
1648
  let steps = this.steps;
1698
1649
  for (const idx of nestedPath) {
1699
- const node = steps[idx];
1700
- const child = node?.type === "step" ? node.nestedWorkflow : void 0;
1650
+ const child = steps[idx]?.nestedWorkflow;
1701
1651
  if (!child) {
1702
1652
  throw new Error(`loadState: nested gate "${gateId}" path is stale \u2014 step ${idx} is not a nested workflow.`);
1703
1653
  }
1704
1654
  steps = child.getStepsForShapeHash();
1705
1655
  }
1706
1656
  const innerGate = steps[gateLike.resumeFromIndex];
1707
- if (innerGate?.type !== "gate" || innerGate.id !== gateId) {
1657
+ if (!(innerGate instanceof GateStep) || innerGate.id !== gateId) {
1708
1658
  throw new Error(`loadState: nested gate "${gateId}" not found at the recorded path.`);
1709
1659
  }
1710
1660
  const remaining = [...nestedPath.slice(1), gateLike.resumeFromIndex + 1];
@@ -1768,9 +1718,7 @@ var SealedWorkflow = class _SealedWorkflow {
1768
1718
  this.ensureDuplicateCheck();
1769
1719
  }
1770
1720
  return new CheckpointResumedWorkflow(this.steps, idx, {
1771
- mode: "checkpoint",
1772
1721
  priorOutput: ckpt.output,
1773
- snapshot: ckpt,
1774
1722
  observability: this.observability
1775
1723
  });
1776
1724
  }
@@ -1886,11 +1834,12 @@ var CheckpointResumedWorkflow = class extends SealedWorkflow {
1886
1834
  };
1887
1835
  var Workflow = class _Workflow extends SealedWorkflow {
1888
1836
  /**
1889
- * Sentinel value for `foreach`'s `onError` handler. Returning `Workflow.SKIP`
1890
- * from `onError` omits the failed item's index from the output array,
1891
- * 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.
1892
1841
  */
1893
- static SKIP = /* @__PURE__ */ Symbol("pipeai.foreach.skip");
1842
+ static SKIP = SKIP;
1894
1843
  constructor(steps = [], id, observability) {
1895
1844
  super(steps, id, observability);
1896
1845
  }
@@ -1901,8 +1850,6 @@ var Workflow = class _Workflow extends SealedWorkflow {
1901
1850
  return new _Workflow([]).step(agent, options);
1902
1851
  }
1903
1852
  // Builder helper — append a step and return a re-typed Workflow.
1904
- // Centralizes the `[...steps, node] as any` + new Workflow + observability/id
1905
- // forwarding pattern used by every combinator method.
1906
1853
  appendStep(node) {
1907
1854
  return new _Workflow([...this.steps, node], this.id, this.observability);
1908
1855
  }
@@ -1947,67 +1894,18 @@ var Workflow = class _Workflow extends SealedWorkflow {
1947
1894
  if (this.steps.some((s) => s.type === "gate" && s.id === id)) {
1948
1895
  throw new Error(`Workflow: duplicate gate ID "${id}". Each gate must have a unique identifier.`);
1949
1896
  }
1950
- const node = new GateStep(
1951
- id,
1952
- async (state) => {
1953
- if (options?.payload) {
1954
- return options.payload({
1955
- ctx: state.ctx,
1956
- input: state.output
1957
- });
1958
- }
1959
- return state.output;
1960
- },
1961
- options?.schema,
1962
- options?.condition ? async (state) => options.condition({
1963
- ctx: state.ctx,
1964
- input: state.output
1965
- }) : void 0,
1966
- options?.merge ? (params) => options.merge(params) : void 0
1967
- );
1897
+ const node = new GateStep(id, options);
1968
1898
  return this.appendStep(node);
1969
1899
  }
1970
1900
  // ── branch: implementation ────────────────────────────────────
1971
1901
  branch(casesOrConfig, options) {
1972
- if (Array.isArray(casesOrConfig)) {
1973
- return this.branchPredicate(casesOrConfig, options?.id);
1974
- }
1975
- return this.branchSelect(casesOrConfig, options?.id);
1976
- }
1977
- branchPredicate(cases, explicitId) {
1978
- const node = new PredicateBranchStep(explicitId ?? "branch:predicate", cases);
1979
- return this.appendStep(node);
1980
- }
1981
- branchSelect(config, explicitId) {
1982
- const node = new SelectBranchStep(explicitId ?? "branch:select", config);
1902
+ const node = Array.isArray(casesOrConfig) ? new PredicateBranchStep(options?.id ?? "branch:predicate", casesOrConfig) : new SelectBranchStep(options?.id ?? "branch:select", casesOrConfig);
1983
1903
  return this.appendStep(node);
1984
1904
  }
1985
- // ── foreach: array iteration ─────────────────────────────────
1986
- /**
1987
- * Map each item of an array through an agent or sub-workflow.
1988
- *
1989
- * @param target Agent or `SealedWorkflow` invoked once per item.
1990
- * @param options.id Override the default step id (`foreach:<agentId>` or
1991
- * the workflow's id). Required when chaining multiple foreach over the same
1992
- * target — the construction-time `(type, id)` walk rejects duplicates.
1993
- * @param options.concurrency Max items in flight at any moment. **Default:
1994
- * unbounded** (`Infinity` — every item runs concurrently, clamped only by
1995
- * item count). Pass an integer to throttle against provider rate limits.
1996
- * Backed by a worker pool: as soon as one item completes, the next launches —
1997
- * no lockstep batching.
1998
- * @param options.onError Per-iteration error handler. **Bypassed entirely on
1999
- * the suspension path** (when any item hits a nested gate) **and on the
2000
- * cancellation path** (the run was aborted — pre-abort failures become
2001
- * `foreach-sibling` warnings and the abort reason rethrows) — see the
2002
- * foreach concurrency hazards in the README. Otherwise: return a
2003
- * `TNextOutput` value to substitute, return `Workflow.SKIP` to omit, throw
2004
- * to abort. Invoked sequentially in index order after all items settle.
2005
- * A throw (or rethrow) from `onError` aborts the foreach immediately:
2006
- * failures at indices AFTER the throwing one are neither recovered nor
2007
- * surfaced as warnings.
2008
- */
1905
+ // Implementation
2009
1906
  foreach(target, options) {
2010
- const node = new ForeachStep(target, options, this.observability);
1907
+ const body = typeof target === "function" ? target(_Workflow.create({ observability: this.observability })) : target;
1908
+ const node = new ForeachStep(body, options, this.observability);
2011
1909
  return this.appendStep(node);
2012
1910
  }
2013
1911
  // Implementation
@@ -2054,7 +1952,7 @@ var Workflow = class _Workflow extends SealedWorkflow {
2054
1952
  };
2055
1953
 
2056
1954
  // src/index.ts
2057
- var SKIP = Workflow.SKIP;
1955
+ var SKIP2 = Workflow.SKIP;
2058
1956
  // Annotate the CommonJS export names for ESM import in node:
2059
1957
  0 && (module.exports = {
2060
1958
  ABORT_STEP_ID,