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.js CHANGED
@@ -18,6 +18,7 @@ function runWithWriter(writer, fn) {
18
18
  function getActiveWriter() {
19
19
  return writerStorage.getStore();
20
20
  }
21
+ var SKIP = /* @__PURE__ */ Symbol("pipeai.foreach.skip");
21
22
  function resolveValue(value, ctx, input) {
22
23
  if (typeof value === "function") {
23
24
  return value(ctx, input);
@@ -257,7 +258,15 @@ var Agent = class {
257
258
  const resolved = await this.resolveConfig(ctx, input);
258
259
  const options = this.buildCallOptions(resolved, ctx, input);
259
260
  try {
260
- const onErrorOption = this.config.onError ? { onError: ({ error }) => this.invokeOnError(error, ctx, input) } : {};
261
+ const onErrorOption = this.config.onError ? {
262
+ onError: async ({ error }) => {
263
+ try {
264
+ await this.invokeOnError(error, ctx, input);
265
+ } catch (handlerError) {
266
+ console.error(`Agent "${this.id}": onError handler threw on the stream path:`, handlerError);
267
+ }
268
+ }
269
+ } : {};
261
270
  return streamText({
262
271
  ...options,
263
272
  ...extra,
@@ -358,12 +367,19 @@ import {
358
367
 
359
368
  // src/steps/step.ts
360
369
  var Step = class {
361
- // Note: `type: "step"` subclasses (agent / transform / nested / branch /
362
- // foreach / repeat / parallel) also carry a `readonly category` that the run
363
- // loop reads (`getObservabilityType`) to type observability events. It is not
364
- // declared on this base the `StepNode` union in `workflow.ts` redeclares the
365
- // node shape, so `category` is part of that structural contract rather than
366
- // this class. Subclasses add it directly.
370
+ /**
371
+ * Observability event subtype for `type: "step"` nodes (agent / transform =
372
+ * `"step"`; nested / branch / foreach / repeat / parallel override).
373
+ * `undefined` on gate / catch / finally nodes, whose `type` IS the event type.
374
+ */
375
+ category;
376
+ /**
377
+ * The sealed sub-workflow attached to this node, when it has one (`nested`,
378
+ * and workflow-target `foreach` / `repeat`). Consumed by the recursive
379
+ * `stepShapeHash` walk and the resume path-walk in `loadState`.
380
+ */
381
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
382
+ nestedWorkflow;
367
383
  /**
368
384
  * Precedence source tag a kind writes to `state.pendingError` when it
369
385
  * captures a thrown body error. Defaults to `"step"`; kinds with a distinct
@@ -371,19 +387,20 @@ var Step = class {
371
387
  */
372
388
  errorSource = "step";
373
389
  /**
374
- * The step's body the only method the run loop invokes. Each kind overrides
375
- * it to do its work, run its own skip checks, and capture errors onto state.
376
- * `state.output` is the input on entry and becomes the output on exit;
377
- * `state.writer` is present in stream mode. The base implementation is a
378
- * no-op so kinds that carry no body of their own need not override it.
390
+ * The step's body, invoked by the run loop only after {@link shouldSkip}
391
+ * returned `false`. Each kind overrides it to do its work and capture errors
392
+ * onto state. `state.output` is the input on entry and becomes the output on
393
+ * exit; `state.writer` is present in stream mode. The base implementation is
394
+ * a no-op so kinds that carry no body of their own need not override it.
379
395
  */
380
396
  async execute(_state) {
381
397
  }
382
398
  /**
383
- * Run-policy gate: return `true` when this step should be skipped silently
384
- * (no output change). The default is the "normal" policy skip while the
385
- * flow is suspended or already in error. Overridden by kinds with inverted
386
- * policies: `catch` runs only when there's an error, `finally` always runs.
399
+ * Run-policy gate, called by the run loop before {@link execute}: return
400
+ * `true` when this step should be skipped silently (no hooks, no output
401
+ * change). The default is the "normal" policy skip while the flow is
402
+ * suspended or already in error. Overridden by kinds with inverted policies:
403
+ * `catch` runs only when there's an error, `finally` always runs.
387
404
  */
388
405
  shouldSkip(state) {
389
406
  return !!state.suspension || !!state.pendingError;
@@ -419,7 +436,6 @@ var TransformStep = class extends Step {
419
436
  this.options = options;
420
437
  }
421
438
  async execute(state) {
422
- if (this.shouldSkip(state)) return;
423
439
  try {
424
440
  if (await this.applyConditionalSkip(state, this.options)) return;
425
441
  state.output = await this.fn({
@@ -450,7 +466,6 @@ var AgentStep = class _AgentStep extends Step {
450
466
  this.options = options;
451
467
  }
452
468
  async execute(state) {
453
- if (this.shouldSkip(state)) return;
454
469
  try {
455
470
  if (await this.applyConditionalSkip(state, this.options)) return;
456
471
  await _AgentStep.runAgent(state, this.agent, state.ctx, this.options);
@@ -521,6 +536,26 @@ var AgentStep = class _AgentStep extends Step {
521
536
  }
522
537
  };
523
538
 
539
+ // src/errors.ts
540
+ var WorkflowBranchError = class extends Error {
541
+ constructor(branchType, message) {
542
+ super(message);
543
+ this.branchType = branchType;
544
+ this.name = "WorkflowBranchError";
545
+ }
546
+ };
547
+ var WorkflowLoopError = class extends Error {
548
+ constructor(iterations, maxIterations) {
549
+ super(`Loop exceeded maximum iterations (${maxIterations})`);
550
+ this.iterations = iterations;
551
+ this.maxIterations = maxIterations;
552
+ this.name = "WorkflowLoopError";
553
+ }
554
+ };
555
+ var CHECKPOINT_STEP_ID = "::pipeai::onCheckpoint";
556
+ var ABORT_STEP_ID = "::pipeai::abort";
557
+ var GATE_RESUME_STEP_ID = "::pipeai::gate:resume";
558
+
524
559
  // src/steps/branch-step.ts
525
560
  var PredicateBranchStep = class extends Step {
526
561
  type = "step";
@@ -535,7 +570,6 @@ var PredicateBranchStep = class extends Step {
535
570
  this.cases = cases;
536
571
  }
537
572
  async execute(state) {
538
- if (this.shouldSkip(state)) return;
539
573
  try {
540
574
  const ctx = state.ctx;
541
575
  const input = state.output;
@@ -574,7 +608,6 @@ var SelectBranchStep = class extends Step {
574
608
  this.config = config;
575
609
  }
576
610
  async execute(state) {
577
- if (this.shouldSkip(state)) return;
578
611
  try {
579
612
  const ctx = state.ctx;
580
613
  const input = state.output;
@@ -609,6 +642,95 @@ var SelectBranchStep = class extends Step {
609
642
  }
610
643
  };
611
644
 
645
+ // src/runtime.ts
646
+ function makeAbortError(signal) {
647
+ return {
648
+ error: signal.reason ?? new Error("Workflow aborted"),
649
+ stepId: ABORT_STEP_ID,
650
+ source: "step"
651
+ };
652
+ }
653
+ function prependNestedPath(snapshot, index, state) {
654
+ const next = { ...snapshot, nestedPath: [index, ...snapshot.nestedPath ?? []] };
655
+ if (resolveFreezeSnapshots(state)) deepFreeze(next);
656
+ return next;
657
+ }
658
+ function resolveFreezeSnapshots(state) {
659
+ return state.runOptions?.freezeSnapshots ? true : false;
660
+ }
661
+ function pendingErrorSourceToStepType(source) {
662
+ switch (source) {
663
+ case "step":
664
+ return "step";
665
+ case "gate":
666
+ return "gate";
667
+ case "finally":
668
+ return "finally";
669
+ case "catch":
670
+ return "catch";
671
+ case "onCheckpoint":
672
+ return "step";
673
+ }
674
+ }
675
+ async function emitCheckpoint(state, opts, resumeFromIndex, stepShapeHash) {
676
+ if (!opts.onCheckpoint) return;
677
+ const willFreeze = resolveFreezeSnapshots(state);
678
+ const snap = {
679
+ version: 2,
680
+ kind: "checkpoint",
681
+ resumeFromIndex,
682
+ output: willFreeze ? structuredClone(state.output) : state.output,
683
+ stepShapeHash
684
+ };
685
+ if (willFreeze) deepFreeze(snap);
686
+ await opts.onCheckpoint(snap, { signal: state.abortSignal });
687
+ }
688
+ var warnedStreamOnErrorOnSuspend = false;
689
+ function pushWarning(state, source, stepId, error) {
690
+ (state.warnings ??= []).push({ source, stepId, error });
691
+ }
692
+ function fireHook(observability, state, name, event) {
693
+ const hook = observability?.[name];
694
+ if (!hook) return void 0;
695
+ return fireHookSlow(state, name, event, hook);
696
+ }
697
+ async function fireHookSlow(state, name, event, hook) {
698
+ try {
699
+ await hook(event);
700
+ return void 0;
701
+ } catch (e) {
702
+ if (name !== "onStepError") {
703
+ const stepId = event.stepId;
704
+ pushWarning(state, name, stepId, e);
705
+ console.error(`pipeai: ${name} hook threw for stepId "${stepId}":`, e);
706
+ }
707
+ return e;
708
+ }
709
+ }
710
+ function hasItemHooks(observability) {
711
+ return !!observability && !!(observability.onItemStart || observability.onItemFinish || observability.onItemError);
712
+ }
713
+ function demotePendingError(state, pe) {
714
+ pushWarning(state, pe.source, pe.stepId, pe.error);
715
+ }
716
+ function maybeWarnStreamOnErrorOnSuspend(result, options) {
717
+ if (result.status !== "suspended" || !options?.onError || warnedStreamOnErrorOnSuspend) return;
718
+ warnedStreamOnErrorOnSuspend = true;
719
+ console.warn(
720
+ "pipeai: stream() with options.onError suspended at a gate \u2014 onError will NOT be invoked for suspension. Discriminate via the resolved output Promise."
721
+ );
722
+ }
723
+ function makeRuntimeState(ctx, output, mode, opts, writer) {
724
+ return {
725
+ ctx,
726
+ output,
727
+ mode,
728
+ ...writer ? { writer } : {},
729
+ runOptions: opts,
730
+ abortSignal: opts?.abortSignal
731
+ };
732
+ }
733
+
612
734
  // src/steps/semaphore.ts
613
735
  var Semaphore = class {
614
736
  available;
@@ -631,17 +753,103 @@ var Semaphore = class {
631
753
  this.available++;
632
754
  }
633
755
  }
634
- async run(fn) {
635
- await this.acquire();
636
- try {
637
- return await fn();
638
- } finally {
639
- this.release();
640
- }
641
- }
642
756
  };
643
757
 
644
758
  // src/steps/concurrent.ts
759
+ function validateConcurrency(kind, value) {
760
+ if (value !== void 0 && !(Number.isInteger(value) && value >= 1 || value === Infinity)) {
761
+ throw new Error(`${kind}: concurrency must be a positive integer or Infinity, got ${value}`);
762
+ }
763
+ return value ?? Infinity;
764
+ }
765
+ async function dispatchUnits(params) {
766
+ const { state, stepId, kind, units, observability, handleStream, onUnitSuccess } = params;
767
+ const unitStates = new Array(units.length);
768
+ const wantItemHooks = hasItemHooks(observability);
769
+ const executeUnit = async (unit, index) => {
770
+ const inheritStreaming = unit.isWorkflow || handleStream !== void 0;
771
+ const unitState = {
772
+ ctx: state.ctx,
773
+ output: unit.input,
774
+ mode: inheritStreaming ? state.mode : "generate",
775
+ writer: inheritStreaming ? state.writer : void 0,
776
+ abortSignal: state.abortSignal
777
+ };
778
+ unitStates[index] = unitState;
779
+ const unitStart = wantItemHooks ? performance.now() : 0;
780
+ if (wantItemHooks) {
781
+ await fireHook(observability, state, "onItemStart", {
782
+ stepId,
783
+ type: kind,
784
+ itemIndex: unit.key,
785
+ ctx: state.ctx,
786
+ input: unit.input
787
+ });
788
+ }
789
+ try {
790
+ if (unit.isWorkflow) {
791
+ await unit.target.executeAsNested(unitState);
792
+ } else {
793
+ await AgentStep.runAgent(
794
+ unitState,
795
+ unit.target,
796
+ state.ctx,
797
+ handleStream ? { handleStream } : void 0,
798
+ unit.key
799
+ );
800
+ }
801
+ onUnitSuccess(index, unitState.output);
802
+ if (wantItemHooks) {
803
+ await fireHook(observability, state, "onItemFinish", {
804
+ stepId,
805
+ type: kind,
806
+ itemIndex: unit.key,
807
+ ctx: state.ctx,
808
+ output: unitState.output,
809
+ durationMs: performance.now() - unitStart
810
+ });
811
+ }
812
+ } catch (error) {
813
+ if (wantItemHooks) {
814
+ await fireHook(observability, state, "onItemError", {
815
+ stepId,
816
+ type: kind,
817
+ itemIndex: unit.key,
818
+ ctx: state.ctx,
819
+ error,
820
+ durationMs: performance.now() - unitStart
821
+ });
822
+ }
823
+ throw error;
824
+ }
825
+ };
826
+ const sem = new Semaphore(params.concurrency);
827
+ const failures = [];
828
+ const inflight = /* @__PURE__ */ new Set();
829
+ for (let i = 0; i < units.length; i++) {
830
+ if (state.abortSignal?.aborted) break;
831
+ await sem.acquire();
832
+ if (state.abortSignal?.aborted) {
833
+ sem.release();
834
+ break;
835
+ }
836
+ const index = i;
837
+ const unit = (async () => {
838
+ try {
839
+ await executeUnit(units[index], index);
840
+ } catch (error) {
841
+ failures.push({ key: units[index].key, index, error });
842
+ } finally {
843
+ sem.release();
844
+ }
845
+ })();
846
+ inflight.add(unit);
847
+ void unit.finally(() => inflight.delete(unit));
848
+ }
849
+ await Promise.all(inflight);
850
+ failures.sort((a, b) => a.index - b.index);
851
+ return reconcileUnits(state, stepId, failures, units.length, (i) => units[i].key, unitStates, state.abortSignal);
852
+ }
645
853
  function reconcileUnits(state, id, failures, count, keyAt, unitStates, signal) {
646
854
  for (let i = 0; i < count; i++) {
647
855
  const us = unitStates[i];
@@ -669,128 +877,50 @@ function reconcileUnits(state, id, failures, count, keyAt, unitStates, signal) {
669
877
  var ForeachStep = class extends Step {
670
878
  type = "step";
671
879
  category = "foreach";
672
- id;
673
880
  nestedWorkflow;
881
+ id;
674
882
  target;
675
883
  concurrency;
676
884
  onError;
677
885
  handleStream;
678
886
  isWorkflow;
679
- inheritStreaming;
680
887
  observability;
681
888
  constructor(target, options, observability) {
682
889
  super();
683
- if (options?.concurrency !== void 0 && !(Number.isInteger(options.concurrency) && options.concurrency >= 1 || options.concurrency === Infinity)) {
684
- throw new Error(`foreach: concurrency must be a positive integer or Infinity, got ${options.concurrency}`);
685
- }
686
890
  this.target = target;
687
- this.concurrency = options?.concurrency ?? Infinity;
891
+ this.concurrency = validateConcurrency("foreach", options?.concurrency);
688
892
  this.onError = options?.onError;
689
893
  this.handleStream = options?.handleStream;
690
894
  this.observability = observability;
691
895
  this.isWorkflow = target instanceof SealedWorkflow;
692
- this.inheritStreaming = this.isWorkflow || this.handleStream !== void 0;
693
896
  const defaultId = this.isWorkflow ? target.id ?? "foreach" : `foreach:${target.id}`;
694
897
  this.id = options?.id ?? defaultId;
695
898
  this.nestedWorkflow = this.isWorkflow ? target : void 0;
696
899
  }
697
900
  async execute(state) {
698
- if (this.shouldSkip(state)) return;
699
901
  try {
700
902
  const items = state.output;
701
903
  if (!Array.isArray(items)) {
702
904
  throw new Error(`foreach "${this.id}": expected array input, got ${typeof items}`);
703
905
  }
704
906
  const results = new Array(items.length);
705
- const skipped = /* @__PURE__ */ new Set();
706
- const itemStates = new Array(items.length);
707
- const wantItemHooks = hasItemHooks(this.observability);
708
- const executeItem = async (item, index) => {
709
- const itemState = {
710
- ctx: state.ctx,
711
- output: item,
712
- mode: this.inheritStreaming ? state.mode : "generate",
713
- writer: this.inheritStreaming ? state.writer : void 0,
714
- abortSignal: state.abortSignal
715
- };
716
- itemStates[index] = itemState;
717
- const itemStart = wantItemHooks ? performance.now() : 0;
718
- if (wantItemHooks) {
719
- await fireHook(this.observability, state, "onItemStart", {
720
- stepId: this.id,
721
- type: "foreach",
722
- itemIndex: index,
723
- ctx: state.ctx,
724
- input: item
725
- });
726
- }
727
- try {
728
- if (this.isWorkflow) {
729
- await this.target.executeAsNested(itemState);
730
- } else {
731
- await AgentStep.runAgent(
732
- itemState,
733
- this.target,
734
- state.ctx,
735
- this.handleStream ? { handleStream: this.handleStream } : void 0,
736
- index
737
- );
738
- }
739
- results[index] = itemState.output;
740
- if (wantItemHooks) {
741
- await fireHook(this.observability, state, "onItemFinish", {
742
- stepId: this.id,
743
- type: "foreach",
744
- itemIndex: index,
745
- ctx: state.ctx,
746
- output: itemState.output,
747
- durationMs: performance.now() - itemStart
748
- });
749
- }
750
- } catch (error) {
751
- if (wantItemHooks) {
752
- await fireHook(this.observability, state, "onItemError", {
753
- stepId: this.id,
754
- type: "foreach",
755
- itemIndex: index,
756
- ctx: state.ctx,
757
- error,
758
- durationMs: performance.now() - itemStart
759
- });
760
- }
761
- throw error;
762
- }
763
- };
764
- const sem = new Semaphore(this.concurrency);
765
- const failures = [];
766
- const inflight = /* @__PURE__ */ new Set();
767
- for (let i = 0; i < items.length; i++) {
768
- if (state.abortSignal?.aborted) break;
769
- await sem.acquire();
770
- if (state.abortSignal?.aborted) {
771
- sem.release();
772
- break;
907
+ const failures = await dispatchUnits({
908
+ state,
909
+ stepId: this.id,
910
+ kind: "foreach",
911
+ units: items.map((item, i) => ({ key: i, input: item, target: this.target, isWorkflow: this.isWorkflow })),
912
+ concurrency: this.concurrency,
913
+ observability: this.observability,
914
+ handleStream: this.handleStream,
915
+ onUnitSuccess: (index, output) => {
916
+ results[index] = output;
773
917
  }
774
- const index = i;
775
- const unit = (async () => {
776
- try {
777
- await executeItem(items[index], index);
778
- } catch (error) {
779
- failures.push({ key: index, index, error });
780
- } finally {
781
- sem.release();
782
- }
783
- })();
784
- inflight.add(unit);
785
- void unit.finally(() => inflight.delete(unit));
786
- }
787
- await Promise.all(inflight);
788
- failures.sort((a, b) => a.index - b.index);
789
- const nonGateFailures = reconcileUnits(state, this.id, failures, items.length, (i) => i, itemStates, state.abortSignal);
790
- for (const { index, error } of nonGateFailures) {
918
+ });
919
+ const skipped = /* @__PURE__ */ new Set();
920
+ for (const { index, error } of failures) {
791
921
  if (!this.onError) throw error;
792
922
  const recovered = await this.onError({ error, item: items[index], index, ctx: state.ctx });
793
- if (recovered === Workflow.SKIP) {
923
+ if (recovered === SKIP) {
794
924
  skipped.add(index);
795
925
  } else {
796
926
  results[index] = recovered;
@@ -810,7 +940,6 @@ var ParallelStep = class extends Step {
810
940
  id;
811
941
  entries;
812
942
  isTuple;
813
- branchCount;
814
943
  concurrency;
815
944
  onError;
816
945
  handleStream;
@@ -818,112 +947,30 @@ var ParallelStep = class extends Step {
818
947
  constructor(branches, options, observability) {
819
948
  super();
820
949
  this.isTuple = Array.isArray(branches);
821
- 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 }));
822
- this.branchCount = this.entries.length;
823
- const requestedConcurrency = options?.concurrency;
824
- if (requestedConcurrency !== void 0 && !(Number.isInteger(requestedConcurrency) && requestedConcurrency >= 1 || requestedConcurrency === Infinity)) {
825
- throw new Error(`parallel: concurrency must be a positive integer or Infinity, got ${requestedConcurrency}`);
826
- }
827
- this.concurrency = requestedConcurrency ?? Infinity;
950
+ 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 }));
951
+ this.concurrency = validateConcurrency("parallel", options?.concurrency);
828
952
  this.onError = options?.onError;
829
953
  this.handleStream = options?.handleStream;
830
954
  this.observability = observability;
831
955
  this.id = options?.id ?? (this.isTuple ? "parallel:tuple" : "parallel:record");
832
956
  }
833
957
  async execute(state) {
834
- if (this.shouldSkip(state)) return;
835
958
  try {
836
959
  const input = state.output;
837
- const results = this.isTuple ? new Array(this.branchCount) : {};
838
- const branchStates = new Array(this.branchCount);
839
- const wantItemHooks = hasItemHooks(this.observability);
840
- const executeBranch = async ({ key, index, target }) => {
841
- const isWorkflowBranch = target instanceof SealedWorkflow;
842
- const inheritStreaming = isWorkflowBranch || this.handleStream !== void 0;
843
- const branchState = {
844
- ctx: state.ctx,
845
- output: input,
846
- mode: inheritStreaming ? state.mode : "generate",
847
- writer: inheritStreaming ? state.writer : void 0,
848
- abortSignal: state.abortSignal
849
- };
850
- branchStates[index] = branchState;
851
- const branchStart = wantItemHooks ? performance.now() : 0;
852
- const itemIndex = this.isTuple ? index : key;
853
- if (wantItemHooks) {
854
- await fireHook(this.observability, state, "onItemStart", {
855
- stepId: this.id,
856
- type: "parallel",
857
- itemIndex,
858
- ctx: state.ctx,
859
- input
860
- });
861
- }
862
- try {
863
- if (isWorkflowBranch) {
864
- await target.executeAsNested(branchState);
865
- } else {
866
- await AgentStep.runAgent(
867
- branchState,
868
- target,
869
- state.ctx,
870
- this.handleStream ? { handleStream: this.handleStream } : void 0,
871
- itemIndex
872
- );
873
- }
874
- results[key] = branchState.output;
875
- if (wantItemHooks) {
876
- await fireHook(this.observability, state, "onItemFinish", {
877
- stepId: this.id,
878
- type: "parallel",
879
- itemIndex,
880
- ctx: state.ctx,
881
- output: branchState.output,
882
- durationMs: performance.now() - branchStart
883
- });
884
- }
885
- } catch (error) {
886
- if (wantItemHooks) {
887
- await fireHook(this.observability, state, "onItemError", {
888
- stepId: this.id,
889
- type: "parallel",
890
- itemIndex,
891
- ctx: state.ctx,
892
- error,
893
- durationMs: performance.now() - branchStart
894
- });
895
- }
896
- throw error;
897
- }
898
- };
899
- const keyAt = (i) => this.entries[i].key;
900
- const sem = new Semaphore(this.concurrency);
901
- const failures = [];
902
- const inflight = /* @__PURE__ */ new Set();
903
- for (let i = 0; i < this.branchCount; i++) {
904
- if (state.abortSignal?.aborted) break;
905
- await sem.acquire();
906
- if (state.abortSignal?.aborted) {
907
- sem.release();
908
- break;
960
+ const results = this.isTuple ? new Array(this.entries.length) : {};
961
+ const failures = await dispatchUnits({
962
+ state,
963
+ stepId: this.id,
964
+ kind: "parallel",
965
+ units: this.entries.map((e) => ({ key: e.key, input, target: e.target, isWorkflow: e.isWorkflow })),
966
+ concurrency: this.concurrency,
967
+ observability: this.observability,
968
+ handleStream: this.handleStream,
969
+ onUnitSuccess: (index, output) => {
970
+ results[this.entries[index].key] = output;
909
971
  }
910
- const index = i;
911
- const unit = (async () => {
912
- try {
913
- await executeBranch(this.entries[index]);
914
- } catch (error) {
915
- failures.push({ key: keyAt(index), index, error });
916
- } finally {
917
- sem.release();
918
- }
919
- })();
920
- inflight.add(unit);
921
- void unit.finally(() => inflight.delete(unit));
922
- }
923
- await Promise.all(inflight);
924
- failures.sort((a, b) => a.index - b.index);
925
- const nonGateFailures = reconcileUnits(state, this.id, failures, this.branchCount, keyAt, branchStates, state.abortSignal);
926
- for (const { key, index, error } of nonGateFailures) {
972
+ });
973
+ for (const { key, index, error } of failures) {
927
974
  if (!this.onError) throw error;
928
975
  const recovered = await this.onError({
929
976
  error,
@@ -931,11 +978,7 @@ var ParallelStep = class extends Step {
931
978
  index: this.isTuple ? index : void 0,
932
979
  ctx: state.ctx
933
980
  });
934
- if (recovered === Workflow.SKIP) {
935
- results[key] = void 0;
936
- } else {
937
- results[key] = recovered;
938
- }
981
+ results[key] = recovered === SKIP ? void 0 : recovered;
939
982
  }
940
983
  state.output = results;
941
984
  } catch (error) {
@@ -955,25 +998,25 @@ var GateStep = class extends Step {
955
998
  merge;
956
999
  payload;
957
1000
  condition;
958
- constructor(id, payload, schema, condition, merge) {
1001
+ constructor(id, options) {
959
1002
  super();
960
1003
  this.id = id;
961
- this.payload = payload;
962
- this.schema = schema;
963
- this.condition = condition;
964
- this.merge = merge;
1004
+ this.payload = options?.payload;
1005
+ this.schema = options?.schema;
1006
+ this.condition = options?.condition;
1007
+ this.merge = options?.merge;
965
1008
  }
966
1009
  async execute(state) {
967
- if (this.shouldSkip(state)) return;
968
1010
  try {
969
- if (this.condition && !await this.condition(state)) return;
1011
+ const params = { ctx: state.ctx, input: state.output };
1012
+ if (this.condition && !await this.condition(params)) return;
970
1013
  const snapshot = {
971
1014
  version: 2,
972
1015
  kind: "gate",
973
1016
  resumeFromIndex: state.stepIndex ?? -1,
974
1017
  output: state.output,
975
1018
  gateId: this.id,
976
- gatePayload: await this.payload(state)
1019
+ gatePayload: this.payload ? await this.payload(params) : state.output
977
1020
  };
978
1021
  state.suspension = snapshot;
979
1022
  if (resolveFreezeSnapshots(state)) deepFreeze(snapshot);
@@ -999,7 +1042,6 @@ var CatchStep = class extends Step {
999
1042
  return !!state.suspension || !state.pendingError || !!state.checkpointFailed;
1000
1043
  }
1001
1044
  async execute(state) {
1002
- if (this.shouldSkip(state)) return;
1003
1045
  const handled = state.pendingError;
1004
1046
  state.output = await this.catchFn({
1005
1047
  error: handled.error,
@@ -1027,7 +1069,6 @@ var FinallyStep = class extends Step {
1027
1069
  return false;
1028
1070
  }
1029
1071
  async execute(state) {
1030
- if (this.shouldSkip(state)) return;
1031
1072
  await this.fn({ ctx: state.ctx });
1032
1073
  }
1033
1074
  };
@@ -1036,8 +1077,8 @@ var FinallyStep = class extends Step {
1036
1077
  var NestedWorkflowStep = class extends Step {
1037
1078
  type = "step";
1038
1079
  category = "nested";
1039
- id;
1040
1080
  nestedWorkflow;
1081
+ id;
1041
1082
  options;
1042
1083
  constructor(id, workflow, options) {
1043
1084
  super();
@@ -1064,7 +1105,6 @@ var NestedWorkflowStep = class extends Step {
1064
1105
  }
1065
1106
  return;
1066
1107
  }
1067
- if (this.shouldSkip(state)) return;
1068
1108
  const myIndex = state.stepIndex ?? -1;
1069
1109
  try {
1070
1110
  if (await this.applyConditionalSkip(state, this.options)) return;
@@ -1080,8 +1120,8 @@ var NestedWorkflowStep = class extends Step {
1080
1120
  var RepeatStep = class extends Step {
1081
1121
  type = "step";
1082
1122
  category = "repeat";
1083
- id;
1084
1123
  nestedWorkflow;
1124
+ id;
1085
1125
  target;
1086
1126
  predicate;
1087
1127
  maxIterations;
@@ -1096,7 +1136,6 @@ var RepeatStep = class extends Step {
1096
1136
  this.nestedWorkflow = isWorkflow ? target : void 0;
1097
1137
  }
1098
1138
  async execute(state) {
1099
- if (this.shouldSkip(state)) return;
1100
1139
  try {
1101
1140
  const ctx = state.ctx;
1102
1141
  for (let i = 1; i <= this.maxIterations; i++) {
@@ -1118,107 +1157,7 @@ var RepeatStep = class extends Step {
1118
1157
  }
1119
1158
  };
1120
1159
 
1121
- // src/runtime.ts
1122
- function resolveFreezeSnapshots(state) {
1123
- return state.runOptions?.freezeSnapshots ? true : false;
1124
- }
1125
- function pendingErrorSourceToStepType(source) {
1126
- switch (source) {
1127
- case "step":
1128
- return "step";
1129
- case "gate":
1130
- return "gate";
1131
- case "finally":
1132
- return "finally";
1133
- case "catch":
1134
- return "catch";
1135
- case "onCheckpoint":
1136
- return "step";
1137
- }
1138
- }
1139
- async function emitCheckpoint(state, opts, resumeFromIndex, stepShapeHash) {
1140
- if (!opts.onCheckpoint) return;
1141
- const willFreeze = resolveFreezeSnapshots(state);
1142
- const snap = {
1143
- version: 2,
1144
- kind: "checkpoint",
1145
- resumeFromIndex,
1146
- output: willFreeze ? structuredClone(state.output) : state.output,
1147
- stepShapeHash
1148
- };
1149
- if (willFreeze) deepFreeze(snap);
1150
- await opts.onCheckpoint(snap, { signal: state.abortSignal });
1151
- }
1152
- var warnedStreamOnErrorOnSuspend = false;
1153
- function pushWarning(state, source, stepId, error) {
1154
- (state.warnings ??= []).push({ source, stepId, error });
1155
- }
1156
- function fireHook(observability, state, name, event) {
1157
- const hook = observability?.[name];
1158
- if (!hook) return void 0;
1159
- return fireHookSlow(state, name, event, hook);
1160
- }
1161
- async function fireHookSlow(state, name, event, hook) {
1162
- try {
1163
- await hook(event);
1164
- return void 0;
1165
- } catch (e) {
1166
- if (name !== "onStepError") {
1167
- const stepId = event.stepId;
1168
- pushWarning(state, name, stepId, e);
1169
- console.error(`pipeai: ${name} hook threw for stepId "${stepId}":`, e);
1170
- }
1171
- return e;
1172
- }
1173
- }
1174
- function hasItemHooks(observability) {
1175
- return !!observability && !!(observability.onItemStart || observability.onItemFinish || observability.onItemError);
1176
- }
1177
- function demotePendingError(state, pe) {
1178
- pushWarning(state, pe.source, pe.stepId, pe.error);
1179
- }
1180
- function maybeWarnStreamOnErrorOnSuspend(result, options) {
1181
- if (result.status !== "suspended" || !options?.onError || warnedStreamOnErrorOnSuspend) return;
1182
- warnedStreamOnErrorOnSuspend = true;
1183
- console.warn(
1184
- "pipeai: stream() with options.onError suspended at a gate \u2014 onError will NOT be invoked for suspension. Discriminate via the resolved output Promise."
1185
- );
1186
- }
1187
- function makeRuntimeState(ctx, output, mode, opts, writer) {
1188
- return {
1189
- ctx,
1190
- output,
1191
- mode,
1192
- ...writer ? { writer } : {},
1193
- runOptions: opts,
1194
- abortSignal: opts?.abortSignal
1195
- };
1196
- }
1197
-
1198
1160
  // src/workflow.ts
1199
- var WorkflowBranchError = class extends Error {
1200
- constructor(branchType, message) {
1201
- super(message);
1202
- this.branchType = branchType;
1203
- this.name = "WorkflowBranchError";
1204
- }
1205
- };
1206
- var WorkflowLoopError = class extends Error {
1207
- constructor(iterations, maxIterations) {
1208
- super(`Loop exceeded maximum iterations (${maxIterations})`);
1209
- this.iterations = iterations;
1210
- this.maxIterations = maxIterations;
1211
- this.name = "WorkflowLoopError";
1212
- }
1213
- };
1214
- var CHECKPOINT_STEP_ID = "::pipeai::onCheckpoint";
1215
- var ABORT_STEP_ID = "::pipeai::abort";
1216
- var GATE_RESUME_STEP_ID = "::pipeai::gate:resume";
1217
- function prependNestedPath(snapshot, index, state) {
1218
- const next = { ...snapshot, nestedPath: [index, ...snapshot.nestedPath ?? []] };
1219
- if (resolveFreezeSnapshots(state)) deepFreeze(next);
1220
- return next;
1221
- }
1222
1161
  function migrateSnapshot(legacy) {
1223
1162
  if (legacy.version !== 1) {
1224
1163
  throw new Error(`migrateSnapshot: expected v1 snapshot, got version ${legacy.version}`);
@@ -1232,30 +1171,15 @@ function migrateSnapshot(legacy) {
1232
1171
  gatePayload: legacy.gatePayload
1233
1172
  };
1234
1173
  }
1235
- function getObservabilityType(node) {
1236
- if (node.type !== "step") return node.type;
1237
- return node.category ?? "step";
1238
- }
1239
- function getNestedWorkflows(node) {
1240
- switch (node.type) {
1241
- case "step":
1242
- return node.nestedWorkflow ? [node.nestedWorkflow] : [];
1243
- case "gate":
1244
- case "catch":
1245
- case "finally":
1246
- return [];
1247
- }
1248
- }
1249
1174
  var SealedWorkflow = class _SealedWorkflow {
1250
1175
  id;
1251
1176
  steps;
1252
1177
  observability;
1253
1178
  // Memoized — see ensureDuplicateCheck().
1254
1179
  duplicateCheckPassed = false;
1255
- // Memoized lazily per terminal instance build pipelines once at module
1256
- // load and re-run via generate() to amortize.
1257
- _cachedExecutableStepCount;
1258
- _cachedCheckpointableStepCount;
1180
+ // Memoized lazily per terminal instance: the executable / checkpointable step
1181
+ // counts (one walk) and the recursive shape hash (separate it's expensive).
1182
+ _stepCounts;
1259
1183
  _cachedStepShapeHash;
1260
1184
  constructor(steps, id, observability) {
1261
1185
  this.steps = steps;
@@ -1290,39 +1214,26 @@ var SealedWorkflow = class _SealedWorkflow {
1290
1214
  }
1291
1215
  this.duplicateCheckPassed = true;
1292
1216
  }
1293
- // ── shape-hash + RunOptions validation ────────────────────────
1217
+ // ── step counts + shape-hash (memoized) ────────────────────────────
1294
1218
  /**
1295
- * Count of executable nodes i.e. NOT `catch` or `finally`. Drives
1296
- * checkpoint auto-cadence so adding cleanup steps doesn't surprise users
1297
- * with extra fires. `branch`/`foreach`/`repeat`/`parallel`/`nested` are all
1298
- * `type: "step"` internally and count as executable.
1219
+ * Two cadence inputs from a single walk:
1220
+ * - `executable` nodes that aren't `catch` / `finally`. A graph-size
1221
+ * proxy for the catastrophe threshold in {@link validateRunOptions}.
1222
+ * - `checkpointable` — `type === "step"` nodes only (this includes
1223
+ * branch / foreach / repeat / parallel / nested). Drives the checkpoint
1224
+ * auto-cadence denominator: gates suspend/skip and never reach the
1225
+ * checkpoint block, so counting them would dilute the "~4 checkpoints
1226
+ * across the run" target.
1299
1227
  */
1300
- get cachedExecutableStepCount() {
1301
- if (this._cachedExecutableStepCount !== void 0) return this._cachedExecutableStepCount;
1302
- let n = 0;
1228
+ get stepCounts() {
1229
+ if (this._stepCounts) return this._stepCounts;
1230
+ let executable = 0;
1231
+ let checkpointable = 0;
1303
1232
  for (const s of this.steps) {
1304
- if (s.type !== "catch" && s.type !== "finally") n++;
1233
+ if (s.type !== "catch" && s.type !== "finally") executable++;
1234
+ if (s.type === "step") checkpointable++;
1305
1235
  }
1306
- this._cachedExecutableStepCount = n;
1307
- return n;
1308
- }
1309
- /**
1310
- * Count of *checkpointable* nodes — `type === "step"` only (this includes
1311
- * `branch`/`foreach`/`repeat`/`parallel`/`nested`, all internally `step`).
1312
- * Drives the checkpoint auto-cadence denominator. Distinct from
1313
- * {@link cachedExecutableStepCount}, which also counts `gate` nodes: gates
1314
- * suspend/skip and never reach the checkpoint block, so the runtime
1315
- * `executableStepsSeen` counter never advances on them. Counting gates in
1316
- * the denominator would dilute the "~4 checkpoints across the run" target.
1317
- */
1318
- get cachedCheckpointableStepCount() {
1319
- if (this._cachedCheckpointableStepCount !== void 0) return this._cachedCheckpointableStepCount;
1320
- let n = 0;
1321
- for (const s of this.steps) {
1322
- if (s.type === "step") n++;
1323
- }
1324
- this._cachedCheckpointableStepCount = n;
1325
- return n;
1236
+ return this._stepCounts = { executable, checkpointable };
1326
1237
  }
1327
1238
  /** @internal — used by `computeStepShapeHash` to descend nested workflows. */
1328
1239
  getStepsForShapeHash() {
@@ -1330,7 +1241,7 @@ var SealedWorkflow = class _SealedWorkflow {
1330
1241
  }
1331
1242
  get cachedStepShapeHash() {
1332
1243
  if (this._cachedStepShapeHash !== void 0) return this._cachedStepShapeHash;
1333
- const getNested = (node) => getNestedWorkflows(node);
1244
+ const getNested = (node) => node.nestedWorkflow ? [node.nestedWorkflow] : [];
1334
1245
  this._cachedStepShapeHash = computeStepShapeHash(
1335
1246
  this.steps,
1336
1247
  getNested
@@ -1341,21 +1252,29 @@ var SealedWorkflow = class _SealedWorkflow {
1341
1252
  * Validate user-provided RunOptions before a run begins. Throws on
1342
1253
  * outright errors and on the loud-disaster combo (`freezeSnapshots: true
1343
1254
  * + checkpointEvery: 1` on a workflow of 8+ steps). Warns once on the
1344
- * merely-suspicious combo (`freezeSnapshots: true + cadence <= 2`).
1345
- * Plan-of-record: catastrophic combo escape via the
1346
- * `"iAcceptThePerformanceCost"` literal.
1255
+ * merely-suspicious combo (`freezeSnapshots: true + cadence <= 2`), and on
1256
+ * checkpoint-cadence options set without an `onCheckpoint` sink (a no-op
1257
+ * that usually signals a forgotten sink).
1347
1258
  */
1348
1259
  validateRunOptions(opts) {
1349
1260
  if (!opts) return;
1350
- if (!opts.onCheckpoint) return;
1351
1261
  if (opts.checkpointEvery !== void 0 && opts.checkpointWhen !== void 0) {
1352
1262
  throw new Error("RunOptions: checkpointEvery and checkpointWhen are mutually exclusive");
1353
1263
  }
1354
1264
  if (opts.checkpointEvery !== void 0 && (!Number.isInteger(opts.checkpointEvery) || opts.checkpointEvery < 1)) {
1355
1265
  throw new Error(`RunOptions: checkpointEvery must be a positive integer, got ${opts.checkpointEvery}`);
1356
1266
  }
1357
- const length = this.cachedExecutableStepCount;
1358
- const cadence = opts.checkpointEvery ?? Math.max(1, Math.ceil(this.cachedCheckpointableStepCount / 4));
1267
+ if (!opts.onCheckpoint) {
1268
+ if (opts.checkpointEvery !== void 0 || opts.checkpointWhen !== void 0) {
1269
+ warnOnce(
1270
+ "pipeai:checkpoint-without-sink",
1271
+ "pipeai: checkpointEvery/checkpointWhen set without onCheckpoint \u2014 no checkpoints will fire. Did you forget the onCheckpoint sink?"
1272
+ );
1273
+ }
1274
+ return;
1275
+ }
1276
+ const length = this.stepCounts.executable;
1277
+ const cadence = opts.checkpointEvery ?? Math.max(1, Math.ceil(this.stepCounts.checkpointable / 4));
1359
1278
  if (opts.freezeSnapshots && opts.freezeSnapshots !== "iAcceptThePerformanceCost" && cadence === 1 && length >= 8) {
1360
1279
  throw new Error(
1361
1280
  `freezeSnapshots+checkpointEvery:1 on a ${length}-step workflow is reliably catastrophic. Set checkpointEvery >= 5, freezeSnapshots: false, or pass "iAcceptThePerformanceCost".`
@@ -1369,6 +1288,13 @@ var SealedWorkflow = class _SealedWorkflow {
1369
1288
  }
1370
1289
  }
1371
1290
  // ── Observability helpers ─────────────────────────────────────
1291
+ /** Observability event `type` for a node: a `type: "step"` node reports its
1292
+ * `category` (agent / transform default to `"step"`); every other node's
1293
+ * `type` IS the event type. */
1294
+ obsEventType(node) {
1295
+ if (node.type !== "step") return node.type;
1296
+ return node.category ?? "step";
1297
+ }
1372
1298
  /**
1373
1299
  * Fire an observability hook safely. Returns `undefined` synchronously when
1374
1300
  * no hook is registered — avoiding the promise wrapper + microtask that an
@@ -1382,17 +1308,14 @@ var SealedWorkflow = class _SealedWorkflow {
1382
1308
  * Returns the hook's thrown error if any; undefined otherwise. Callers
1383
1309
  * `await` the result — `await undefined` is sync, so the no-hook path
1384
1310
  * stays allocation-free.
1311
+ *
1312
+ * Thin delegate to the free `fireHook` (which takes an explicit
1313
+ * observability), kept as a method so the loop's many `this.fireHook` call
1314
+ * sites stay unchanged.
1385
1315
  */
1386
- // Thin delegates to the free `fireHook` / `hasItemHooks` (which take an
1387
- // explicit observability). Kept as methods so the loop's many `this.fireHook`
1388
- // call sites stay unchanged; bare `fireHook` / `hasItemHooks` below resolve to
1389
- // the module-level functions, not these members.
1390
1316
  fireHook(state, name, event) {
1391
1317
  return fireHook(this.observability, state, name, event);
1392
1318
  }
1393
- hasItemHooks() {
1394
- return hasItemHooks(this.observability);
1395
- }
1396
1319
  /**
1397
1320
  * Fire `onStepError` for a step-body failure and honor the documented
1398
1321
  * cause-attachment contract uniformly across every firing path (step, gate,
@@ -1497,30 +1420,15 @@ var SealedWorkflow = class _SealedWorkflow {
1497
1420
  if (opts !== void 0 && state.runOptions === void 0) {
1498
1421
  state.runOptions = opts;
1499
1422
  }
1500
- const ckptCadence = opts?.onCheckpoint && opts.checkpointWhen === void 0 ? opts.checkpointEvery ?? Math.max(1, Math.ceil(this.cachedCheckpointableStepCount / 4)) : 0;
1501
- let executableStepsSeen = 0;
1423
+ const ckptCadence = opts?.onCheckpoint && opts.checkpointWhen === void 0 ? opts.checkpointEvery ?? Math.max(1, Math.ceil(this.stepCounts.checkpointable / 4)) : 0;
1424
+ const ckptCounter = { seen: 0 };
1502
1425
  state.pendingError = initialError ?? void 0;
1503
- let abortPromoted = false;
1504
- const makeAbortError = (signal) => ({
1505
- error: signal.reason ?? new Error("Workflow aborted"),
1506
- stepId: ABORT_STEP_ID,
1507
- source: "step"
1508
- });
1426
+ const abortState = { promoted: false };
1509
1427
  for (let i = startIndex; i < this.steps.length; i++) {
1510
- if (state.abortSignal?.aborted) {
1511
- if (!abortPromoted) {
1512
- abortPromoted = true;
1513
- state.suspension = void 0;
1514
- if (state.pendingError) demotePendingError(state, state.pendingError);
1515
- state.pendingError = makeAbortError(state.abortSignal);
1516
- } else if (!state.pendingError) {
1517
- state.pendingError = makeAbortError(state.abortSignal);
1518
- }
1519
- }
1428
+ this.promoteAbort(state, abortState);
1520
1429
  const node = this.steps[i];
1521
- const skip = node.type === "finally" ? false : node.type === "catch" ? !!state.suspension || !state.pendingError || !!state.checkpointFailed : !!state.suspension || !!state.pendingError;
1522
- if (skip) continue;
1523
- const obsType = getObservabilityType(node);
1430
+ if (node.shouldSkip(state)) continue;
1431
+ const obsType = this.obsEventType(node);
1524
1432
  const stepId = node.id;
1525
1433
  const sStart = performance.now();
1526
1434
  const errBefore = state.pendingError;
@@ -1540,7 +1448,9 @@ var SealedWorkflow = class _SealedWorkflow {
1540
1448
  throw e;
1541
1449
  }
1542
1450
  const newError = state.pendingError && state.pendingError !== errBefore ? state.pendingError : null;
1543
- if (newError) {
1451
+ const isAbort = !!newError && state.abortSignal?.aborted === true && newError.error === state.abortSignal.reason;
1452
+ if (isAbort) {
1453
+ } else if (newError) {
1544
1454
  await this.fireStepErrorAndAttachCause(state, {
1545
1455
  stepId,
1546
1456
  type: obsType,
@@ -1563,32 +1473,73 @@ var SealedWorkflow = class _SealedWorkflow {
1563
1473
  state.suspension = void 0;
1564
1474
  throw new Error(`internal: suspension bubbled from non-gate step "${node.id}" (gate "${leaked.gateId}").`);
1565
1475
  }
1566
- if (node.type === "step" && !state.pendingError && !state.suspension && opts?.onCheckpoint) {
1567
- executableStepsSeen++;
1568
- const shouldCheckpoint = opts.checkpointWhen ? opts.checkpointWhen({ stepIndex: i, stepId: node.id, ctx: state.ctx }) : executableStepsSeen % ckptCadence === 0;
1569
- if (shouldCheckpoint) {
1570
- const ckptStart = performance.now();
1571
- try {
1572
- await emitCheckpoint(
1573
- state,
1574
- opts,
1575
- i + 1,
1576
- this.cachedStepShapeHash
1577
- );
1578
- } catch (e) {
1579
- state.pendingError = { error: e, stepId: CHECKPOINT_STEP_ID, source: "onCheckpoint" };
1580
- state.checkpointFailed = true;
1581
- await this.fireStepErrorAndAttachCause(state, {
1582
- stepId: CHECKPOINT_STEP_ID,
1583
- type: "step",
1584
- ctx: state.ctx,
1585
- error: e,
1586
- durationMs: performance.now() - ckptStart
1587
- });
1588
- }
1589
- }
1590
- }
1476
+ await this.maybeCheckpoint(state, opts, node, i, ckptCadence, ckptCounter);
1591
1477
  }
1478
+ await this.settleRun(state, abortState.promoted);
1479
+ }
1480
+ /**
1481
+ * Promote a fired abort signal into `state.pendingError` at an iteration
1482
+ * boundary. First observation discards any in-progress suspension (the caller
1483
+ * asked to stop) and preserves a genuinely-different prior step error as a
1484
+ * warning — but NOT one that is itself the abort reason (a nested workflow /
1485
+ * concurrent unit that already rethrew it), which would surface a phantom
1486
+ * step-failure warning. Subsequent iterations only re-promote if a downstream
1487
+ * catch cleared pendingError — `AbortSignal.aborted` is sticky, so the
1488
+ * workflow must not resume mid-pipeline just because a catch swallowed one
1489
+ * observation.
1490
+ */
1491
+ promoteAbort(state, abortState) {
1492
+ const signal = state.abortSignal;
1493
+ if (!signal?.aborted) return;
1494
+ if (!abortState.promoted) {
1495
+ abortState.promoted = true;
1496
+ state.suspension = void 0;
1497
+ const prior = state.pendingError;
1498
+ if (prior && prior.error !== signal.reason) demotePendingError(state, prior);
1499
+ state.pendingError = makeAbortError(signal);
1500
+ } else if (!state.pendingError) {
1501
+ state.pendingError = makeAbortError(signal);
1502
+ }
1503
+ }
1504
+ /**
1505
+ * Emit a checkpoint after a successful `type:"step"` body. Skipped on
1506
+ * pendingError (no clean state to snapshot), on suspension (gate already
1507
+ * won), and for catch/finally/gate nodes (not checkpointable). Numeric
1508
+ * `checkpointEvery` (default: `max(1, ceil(count/4))`) uses the loop-hoisted
1509
+ * `ckptCadence`; the predicate form runs per step. A `when:false`-skipped
1510
+ * `type:"step"` node returns normally (its body never ran) and still reaches
1511
+ * here — it advances the counter and can itself be a checkpoint boundary,
1512
+ * keeping the cadence denominator (`stepCounts.checkpointable`) consistent
1513
+ * with the runtime counter.
1514
+ */
1515
+ async maybeCheckpoint(state, opts, node, index, ckptCadence, counter) {
1516
+ if (node.type !== "step" || state.pendingError || state.suspension || !opts?.onCheckpoint) return;
1517
+ counter.seen++;
1518
+ const shouldCheckpoint = opts.checkpointWhen ? opts.checkpointWhen({ stepIndex: index, stepId: node.id, ctx: state.ctx }) : counter.seen % ckptCadence === 0;
1519
+ if (!shouldCheckpoint) return;
1520
+ const ckptStart = performance.now();
1521
+ try {
1522
+ await emitCheckpoint(state, opts, index + 1, this.cachedStepShapeHash);
1523
+ } catch (e) {
1524
+ state.pendingError = { error: e, stepId: CHECKPOINT_STEP_ID, source: "onCheckpoint" };
1525
+ state.checkpointFailed = true;
1526
+ await this.fireStepErrorAndAttachCause(state, {
1527
+ stepId: CHECKPOINT_STEP_ID,
1528
+ type: "step",
1529
+ ctx: state.ctx,
1530
+ error: e,
1531
+ durationMs: performance.now() - ckptStart
1532
+ });
1533
+ }
1534
+ }
1535
+ /**
1536
+ * Terminal reconciliation after the loop. Re-promotes a swallowed abort
1537
+ * (recoverability must not depend on catch position), then resolves the
1538
+ * mutually-exclusive precedence tail: checkpointFailed > original-step error
1539
+ * > suspension. (A throwing catch/finally never reaches here — it bubbles
1540
+ * straight out of the loop, so there is no finally-aggregation branch.)
1541
+ */
1542
+ async settleRun(state, abortPromoted) {
1592
1543
  if (abortPromoted && !state.pendingError && !state.suspension && state.abortSignal?.aborted) {
1593
1544
  state.pendingError = makeAbortError(state.abortSignal);
1594
1545
  }
@@ -1664,15 +1615,14 @@ var SealedWorkflow = class _SealedWorkflow {
1664
1615
  if (nestedPath && nestedPath.length > 0) {
1665
1616
  let steps = this.steps;
1666
1617
  for (const idx of nestedPath) {
1667
- const node = steps[idx];
1668
- const child = node?.type === "step" ? node.nestedWorkflow : void 0;
1618
+ const child = steps[idx]?.nestedWorkflow;
1669
1619
  if (!child) {
1670
1620
  throw new Error(`loadState: nested gate "${gateId}" path is stale \u2014 step ${idx} is not a nested workflow.`);
1671
1621
  }
1672
1622
  steps = child.getStepsForShapeHash();
1673
1623
  }
1674
1624
  const innerGate = steps[gateLike.resumeFromIndex];
1675
- if (innerGate?.type !== "gate" || innerGate.id !== gateId) {
1625
+ if (!(innerGate instanceof GateStep) || innerGate.id !== gateId) {
1676
1626
  throw new Error(`loadState: nested gate "${gateId}" not found at the recorded path.`);
1677
1627
  }
1678
1628
  const remaining = [...nestedPath.slice(1), gateLike.resumeFromIndex + 1];
@@ -1736,9 +1686,7 @@ var SealedWorkflow = class _SealedWorkflow {
1736
1686
  this.ensureDuplicateCheck();
1737
1687
  }
1738
1688
  return new CheckpointResumedWorkflow(this.steps, idx, {
1739
- mode: "checkpoint",
1740
1689
  priorOutput: ckpt.output,
1741
- snapshot: ckpt,
1742
1690
  observability: this.observability
1743
1691
  });
1744
1692
  }
@@ -1854,11 +1802,12 @@ var CheckpointResumedWorkflow = class extends SealedWorkflow {
1854
1802
  };
1855
1803
  var Workflow = class _Workflow extends SealedWorkflow {
1856
1804
  /**
1857
- * Sentinel value for `foreach`'s `onError` handler. Returning `Workflow.SKIP`
1858
- * from `onError` omits the failed item's index from the output array,
1859
- * shortening it relative to the input array.
1805
+ * Sentinel value for `foreach`/`parallel`'s `onError` handler. Returning
1806
+ * `Workflow.SKIP` omits the failed item (foreach: shortens the output array;
1807
+ * parallel: leaves the slot `undefined`). Aliases the leaf-module `SKIP` so
1808
+ * the step subclasses can compare against it without importing this class.
1860
1809
  */
1861
- static SKIP = /* @__PURE__ */ Symbol("pipeai.foreach.skip");
1810
+ static SKIP = SKIP;
1862
1811
  constructor(steps = [], id, observability) {
1863
1812
  super(steps, id, observability);
1864
1813
  }
@@ -1869,8 +1818,6 @@ var Workflow = class _Workflow extends SealedWorkflow {
1869
1818
  return new _Workflow([]).step(agent, options);
1870
1819
  }
1871
1820
  // Builder helper — append a step and return a re-typed Workflow.
1872
- // Centralizes the `[...steps, node] as any` + new Workflow + observability/id
1873
- // forwarding pattern used by every combinator method.
1874
1821
  appendStep(node) {
1875
1822
  return new _Workflow([...this.steps, node], this.id, this.observability);
1876
1823
  }
@@ -1915,67 +1862,18 @@ var Workflow = class _Workflow extends SealedWorkflow {
1915
1862
  if (this.steps.some((s) => s.type === "gate" && s.id === id)) {
1916
1863
  throw new Error(`Workflow: duplicate gate ID "${id}". Each gate must have a unique identifier.`);
1917
1864
  }
1918
- const node = new GateStep(
1919
- id,
1920
- async (state) => {
1921
- if (options?.payload) {
1922
- return options.payload({
1923
- ctx: state.ctx,
1924
- input: state.output
1925
- });
1926
- }
1927
- return state.output;
1928
- },
1929
- options?.schema,
1930
- options?.condition ? async (state) => options.condition({
1931
- ctx: state.ctx,
1932
- input: state.output
1933
- }) : void 0,
1934
- options?.merge ? (params) => options.merge(params) : void 0
1935
- );
1865
+ const node = new GateStep(id, options);
1936
1866
  return this.appendStep(node);
1937
1867
  }
1938
1868
  // ── branch: implementation ────────────────────────────────────
1939
1869
  branch(casesOrConfig, options) {
1940
- if (Array.isArray(casesOrConfig)) {
1941
- return this.branchPredicate(casesOrConfig, options?.id);
1942
- }
1943
- return this.branchSelect(casesOrConfig, options?.id);
1944
- }
1945
- branchPredicate(cases, explicitId) {
1946
- const node = new PredicateBranchStep(explicitId ?? "branch:predicate", cases);
1947
- return this.appendStep(node);
1948
- }
1949
- branchSelect(config, explicitId) {
1950
- const node = new SelectBranchStep(explicitId ?? "branch:select", config);
1870
+ const node = Array.isArray(casesOrConfig) ? new PredicateBranchStep(options?.id ?? "branch:predicate", casesOrConfig) : new SelectBranchStep(options?.id ?? "branch:select", casesOrConfig);
1951
1871
  return this.appendStep(node);
1952
1872
  }
1953
- // ── foreach: array iteration ─────────────────────────────────
1954
- /**
1955
- * Map each item of an array through an agent or sub-workflow.
1956
- *
1957
- * @param target Agent or `SealedWorkflow` invoked once per item.
1958
- * @param options.id Override the default step id (`foreach:<agentId>` or
1959
- * the workflow's id). Required when chaining multiple foreach over the same
1960
- * target — the construction-time `(type, id)` walk rejects duplicates.
1961
- * @param options.concurrency Max items in flight at any moment. **Default:
1962
- * unbounded** (`Infinity` — every item runs concurrently, clamped only by
1963
- * item count). Pass an integer to throttle against provider rate limits.
1964
- * Backed by a worker pool: as soon as one item completes, the next launches —
1965
- * no lockstep batching.
1966
- * @param options.onError Per-iteration error handler. **Bypassed entirely on
1967
- * the suspension path** (when any item hits a nested gate) **and on the
1968
- * cancellation path** (the run was aborted — pre-abort failures become
1969
- * `foreach-sibling` warnings and the abort reason rethrows) — see the
1970
- * foreach concurrency hazards in the README. Otherwise: return a
1971
- * `TNextOutput` value to substitute, return `Workflow.SKIP` to omit, throw
1972
- * to abort. Invoked sequentially in index order after all items settle.
1973
- * A throw (or rethrow) from `onError` aborts the foreach immediately:
1974
- * failures at indices AFTER the throwing one are neither recovered nor
1975
- * surfaced as warnings.
1976
- */
1873
+ // Implementation
1977
1874
  foreach(target, options) {
1978
- const node = new ForeachStep(target, options, this.observability);
1875
+ const body = typeof target === "function" ? target(_Workflow.create({ observability: this.observability })) : target;
1876
+ const node = new ForeachStep(body, options, this.observability);
1979
1877
  return this.appendStep(node);
1980
1878
  }
1981
1879
  // Implementation
@@ -2022,13 +1920,13 @@ var Workflow = class _Workflow extends SealedWorkflow {
2022
1920
  };
2023
1921
 
2024
1922
  // src/index.ts
2025
- var SKIP = Workflow.SKIP;
1923
+ var SKIP2 = Workflow.SKIP;
2026
1924
  export {
2027
1925
  ABORT_STEP_ID,
2028
1926
  Agent,
2029
1927
  CHECKPOINT_STEP_ID,
2030
1928
  GATE_RESUME_STEP_ID,
2031
- SKIP,
1929
+ SKIP2 as SKIP,
2032
1930
  TOOL_PROVIDER_BRAND,
2033
1931
  ToolProvider,
2034
1932
  Workflow,