pipeai 0.8.2 → 0.9.0

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