@glubean/sdk 0.3.2 → 0.5.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.
@@ -19,6 +19,8 @@
19
19
  * - `internal/40-discovery/proposals/contract-flow.md` v9
20
20
  */
21
21
  import { registerTest } from "./internal.js";
22
+ import { selectBranchSteps, extractBranchStep, predicateScope, assertSelectorSource, assertSwitchCaseValues, assertL2Predicate, } from "./contract-flow-condition.js";
23
+ import { quarantinedCtx, raceBudget, evalPollExit, extractPollStep, validatePollBounds, PollExhaustedError, BACKOFF_CAP_MS, DEFAULT_EVERY_MS, } from "./contract-flow-poll.js";
22
24
  import { getBootstrap, registerBootstrap } from "./bootstrap-registry.js";
23
25
  import { getExplicitInput, getBootstrapInput, isForceStandalone, } from "./runner-input-channel.js";
24
26
  /**
@@ -570,6 +572,236 @@ export const contract = {
570
572
  /**
571
573
  * Protocol-agnostic flow builder. See contract-flow.md v9 §4.1.
572
574
  */
575
+ // =============================================================================
576
+ // Branch desugar helpers (condition / switchOn / switchCond → one "branch" step)
577
+ //
578
+ // Shared by both FlowBuilder (top-level steps) and FlowFragmentBuilder (branch
579
+ // sub-steps). Each `then`/`else`/`default`/case callback is run against a fresh
580
+ // step sink whose accumulated RuntimeFlowStep[] becomes that branch's steps.
581
+ // =============================================================================
582
+ /** Build a contract-call runtime step (adapter lookup + flow-safety validation). */
583
+ function buildContractCallStep(flowId, ref, bindings) {
584
+ const adapter = _adapters.get(ref.protocol);
585
+ if (!adapter) {
586
+ throw new Error(`contract.flow(${JSON.stringify(flowId)}).step: unknown protocol "${ref.protocol}". ` +
587
+ `Did you forget to import a contract plugin package (e.g. "@glubean/grpc")?`);
588
+ }
589
+ if (!adapter.executeCaseInFlow) {
590
+ throw new Error(`contract.flow(${JSON.stringify(flowId)}).step: adapter for "${ref.protocol}" ` +
591
+ `does not implement executeCaseInFlow — this protocol cannot appear in a flow.`);
592
+ }
593
+ // v10: flow-safety validation fires at step-declaration time (not .case()).
594
+ const contractRef = ref.contract;
595
+ adapter.validateCaseForFlow?.(contractRef._spec, ref.caseKey, ref.contractId);
596
+ return {
597
+ kind: "contract-call",
598
+ name: bindings?.name,
599
+ ref,
600
+ caseKey: ref.caseKey,
601
+ contract: ref.contract,
602
+ bindings: bindings
603
+ ? {
604
+ in: bindings.in,
605
+ out: bindings.out,
606
+ ...(bindings.accept ? { accept: bindings.accept } : {}),
607
+ }
608
+ : undefined,
609
+ };
610
+ }
611
+ /** Module-private key holding a fragment sink's accumulated step list. */
612
+ const FRAGMENT_STEPS = Symbol("glubean.fragment-steps");
613
+ /**
614
+ * Run a branch-body callback and return the steps of the fragment it RETURNS
615
+ * (not a closure-shared array). Because the sink is persistent/immutable (each
616
+ * method returns a NEW sink carrying `[...prev, step]`), the executed steps are
617
+ * exactly the chain that produced the returned, type-checked fragment. A block
618
+ * body that mutates then returns an earlier sink — e.g.
619
+ * `(b) => { b.compute(reshape); return b; }` — yields that earlier sink's
620
+ * (shorter) step list, so runtime matches what the type system saw. This keeps
621
+ * the `NoExtraKeys` / invariant convergence guarantees honest.
622
+ */
623
+ function collectFragmentSteps(flowId, fn) {
624
+ const result = fn(makeStepSink(flowId, []));
625
+ const out = result?.[FRAGMENT_STEPS];
626
+ if (!Array.isArray(out)) {
627
+ throw new Error(`flow "${flowId}": a branch body must return the fragment builder ` +
628
+ `(e.g. \`(b) => b.compute(...)\` or \`(b) => b\`); it returned a non-builder value`);
629
+ }
630
+ return out;
631
+ }
632
+ /**
633
+ * A persistent (immutable) FlowFragmentBuilder over a steps array. Every method
634
+ * returns a NEW sink carrying `[...steps, newStep]` — it never mutates `steps`.
635
+ * The accumulated list is read back (by `collectFragmentSteps`) via the
636
+ * module-private `FRAGMENT_STEPS` key, so only the RETURNED fragment's steps
637
+ * run. The phantom invariant brand exists only in the type.
638
+ */
639
+ function makeStepSink(flowId, steps) {
640
+ const extend = (step) => makeStepSink(flowId, [...steps, step]);
641
+ // Typed `any` so methods can return sinks; the public type is the cast below.
642
+ const sink = {
643
+ step(ref, bindings) {
644
+ return extend(buildContractCallStep(flowId, ref, bindings));
645
+ },
646
+ compute(fn) {
647
+ return extend({ kind: "compute", fn });
648
+ },
649
+ condition(spec, thenB, elseB) {
650
+ return extend(buildConditionStep(flowId, "L2", spec, thenB, elseB));
651
+ },
652
+ conditionFn(spec, thenB, elseB) {
653
+ return extend(buildConditionStep(flowId, "L1", spec, thenB, elseB));
654
+ },
655
+ conditionAsync(spec, thenB, elseB) {
656
+ return extend(buildConditionStep(flowId, "L0", spec, thenB, elseB));
657
+ },
658
+ switchOn(lens) {
659
+ return (cases, deflt) => extend(buildSwitchOnStep(flowId, lens, cases, deflt));
660
+ },
661
+ switchCond(cases, deflt) {
662
+ return extend(buildSwitchCondStep(flowId, cases, deflt));
663
+ },
664
+ poll(ref, opts) {
665
+ return extend(buildPollStep(flowId, "L2", ref, opts));
666
+ },
667
+ pollFn(ref, opts) {
668
+ return extend(buildPollStep(flowId, "L1", ref, opts));
669
+ },
670
+ pollAsync(ref, opts) {
671
+ return extend(buildPollStep(flowId, "L0", ref, opts));
672
+ },
673
+ };
674
+ // Non-enumerable, frozen step list snapshot for this sink.
675
+ Object.defineProperty(sink, FRAGMENT_STEPS, {
676
+ value: Object.freeze(steps),
677
+ enumerable: false,
678
+ });
679
+ return sink;
680
+ }
681
+ /**
682
+ * condition / conditionFn / conditionAsync → predicate-mode branch (1 case +
683
+ * default). L2 builds a declarative `BranchPredicate` via `predicateScope`;
684
+ * L1/L0 wrap the opaque fn and REQUIRE a message (type-level + this runtime
685
+ * guard, so `as any` / JS callers can't produce an unlabeled opaque gate).
686
+ */
687
+ function buildConditionStep(flowId, tier, spec, thenB, elseB) {
688
+ const thenSteps = collectFragmentSteps(flowId, thenB);
689
+ const elseSteps = elseB ? collectFragmentSteps(flowId, elseB) : [];
690
+ let predicate;
691
+ let message;
692
+ if (tier === "L2") {
693
+ predicate = spec.predicate(predicateScope());
694
+ assertL2Predicate(predicate, "condition"); // runtime brand: reject forged/opaque/non-JSON trees
695
+ message = spec.message;
696
+ }
697
+ else {
698
+ if (typeof spec.message !== "string" || spec.message.length === 0) {
699
+ throw new LensPurityError(tier === "L1" ? "conditionFn" : "conditionAsync", `opaque (${tier}) predicate requires a non-empty \`message\` — the projection marks ` +
700
+ `this branch as an opaque/dynamic gate and the message is its only human-facing label`);
701
+ }
702
+ message = spec.message;
703
+ predicate = { kind: "opaque", sync: tier === "L1", fn: spec.predicate };
704
+ }
705
+ return {
706
+ kind: "branch",
707
+ mode: "predicate",
708
+ cases: [{ ...(message ? { message } : {}), predicate, steps: thenSteps }],
709
+ default: elseSteps,
710
+ };
711
+ }
712
+ /** switchCond → predicate-mode branch (N cases + default), each case a declarative predicate. */
713
+ function buildSwitchCondStep(flowId, cases, deflt) {
714
+ return {
715
+ kind: "branch",
716
+ mode: "predicate",
717
+ cases: cases.map((c) => {
718
+ const predicate = c.when(predicateScope());
719
+ assertL2Predicate(predicate, "switchCond"); // switch is always L2 — reject forged/opaque trees
720
+ return { predicate, steps: collectFragmentSteps(flowId, c.then) };
721
+ }),
722
+ default: collectFragmentSteps(flowId, deflt),
723
+ };
724
+ }
725
+ /**
726
+ * switchOn → value-mode branch. The subject lens runs the same P0 source gate +
727
+ * strict-Proxy path extraction as `w.when` (so the decision-table projection
728
+ * can't lie), values must be unique + finite, and the lens is normalized to
729
+ * `(ctx, s)` for uniform runtime evaluation (it is evaluated exactly once).
730
+ */
731
+ function buildSwitchOnStep(flowId, lens, cases, deflt) {
732
+ void flowId;
733
+ assertSelectorSource(lens);
734
+ const path = extractSelectorPath(lens);
735
+ assertSwitchCaseValues(cases.map((c) => c.value));
736
+ return {
737
+ kind: "branch",
738
+ mode: "value",
739
+ subject: { lens: (_ctx, s) => lens(s), path },
740
+ cases: cases.map((c) => ({ value: c.value, steps: collectFragmentSteps(flowId, c.then) })),
741
+ default: collectFragmentSteps(flowId, deflt),
742
+ };
743
+ }
744
+ /**
745
+ * poll / pollFn / pollAsync → a bounded poll-until step. Validates flow-safety
746
+ * (like a contract-call step) + bounds (validatePollBounds), builds the exit
747
+ * predicate (L2 declarative over the response via predicateScope + brand gate;
748
+ * L1/L0 opaque, message REQUIRED), and applies the every/backoff defaults.
749
+ */
750
+ function buildPollStep(flowId, tier, ref, opts) {
751
+ const adapter = _adapters.get(ref.protocol);
752
+ if (!adapter) {
753
+ throw new Error(`contract.flow(${JSON.stringify(flowId)}).poll: unknown protocol "${ref.protocol}". ` +
754
+ `Did you forget to import a contract plugin package?`);
755
+ }
756
+ if (!adapter.executeCaseInFlow) {
757
+ throw new Error(`contract.flow(${JSON.stringify(flowId)}).poll: adapter for "${ref.protocol}" ` +
758
+ `does not implement executeCaseInFlow — this protocol cannot appear in a flow.`);
759
+ }
760
+ const contractRef = ref.contract;
761
+ adapter.validateCaseForFlow?.(contractRef._spec, ref.caseKey, ref.contractId);
762
+ const stepLabel = opts.name ?? `${ref.contractId}#${ref.caseKey}`;
763
+ validatePollBounds({
764
+ timeout: opts.timeout,
765
+ maxAttempts: opts.maxAttempts,
766
+ perAttemptTimeout: opts.perAttemptTimeout,
767
+ every: opts.every,
768
+ backoff: opts.backoff,
769
+ }, stepLabel);
770
+ let until;
771
+ let message;
772
+ if (tier === "L2") {
773
+ until = opts.until(predicateScope());
774
+ assertL2Predicate(until, "poll"); // runtime brand: reject forged/opaque/non-JSON exit predicates
775
+ message = opts.message;
776
+ }
777
+ else {
778
+ if (typeof opts.message !== "string" || opts.message.length === 0) {
779
+ throw new LensPurityError(tier === "L1" ? "pollFn" : "pollAsync", `opaque (${tier}) poll exit predicate requires a non-empty \`message\` — the projection marks ` +
780
+ `this poll as an opaque/dynamic gate and the message is its only human-facing label`);
781
+ }
782
+ message = opts.message;
783
+ until = { kind: "opaque", sync: tier === "L1", fn: opts.until };
784
+ }
785
+ return {
786
+ kind: "poll",
787
+ name: opts.name,
788
+ ref,
789
+ caseKey: ref.caseKey,
790
+ contract: ref.contract,
791
+ bindings: {
792
+ in: opts.in,
793
+ out: opts.out,
794
+ ...(opts.accept ? { accept: opts.accept } : {}),
795
+ },
796
+ until,
797
+ ...(message ? { message } : {}),
798
+ every: opts.every ?? DEFAULT_EVERY_MS,
799
+ backoff: opts.backoff ?? 1,
800
+ ...(opts.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
801
+ ...(opts.perAttemptTimeout !== undefined ? { perAttemptTimeoutMs: opts.perAttemptTimeout } : {}),
802
+ ...(opts.maxAttempts !== undefined ? { maxAttempts: opts.maxAttempts } : {}),
803
+ };
804
+ }
573
805
  export function flow(idOrMeta) {
574
806
  const meta = typeof idOrMeta === "string"
575
807
  ? { id: idOrMeta }
@@ -594,34 +826,7 @@ export function flow(idOrMeta) {
594
826
  return builder;
595
827
  },
596
828
  step(ref, bindings) {
597
- const adapter = _adapters.get(ref.protocol);
598
- if (!adapter) {
599
- throw new Error(`contract.flow(${JSON.stringify(meta.id)}).step: unknown protocol "${ref.protocol}". ` +
600
- `Did you forget to import a contract plugin package (e.g. "@glubean/grpc")?`);
601
- }
602
- if (!adapter.executeCaseInFlow) {
603
- throw new Error(`contract.flow(${JSON.stringify(meta.id)}).step: adapter for "${ref.protocol}" ` +
604
- `does not implement executeCaseInFlow — this protocol cannot appear in a flow.`);
605
- }
606
- // v10: flow-safety validation fires here (at step-declaration time),
607
- // not at .case() time. The ref itself is pure; only when it enters a
608
- // flow do we enforce adapter-specific rules like HTTP's "function-
609
- // valued body/params/headers can't resolve in flow mode". This lets
610
- // contract.bootstrap(ref) attach to the same ref (where function-
611
- // valued fields legitimately receive resolvedInput).
612
- const contractRef = ref.contract;
613
- adapter.validateCaseForFlow?.(contractRef._spec, ref.caseKey, ref.contractId);
614
- const step = {
615
- kind: "contract-call",
616
- name: bindings?.name,
617
- ref,
618
- caseKey: ref.caseKey,
619
- contract: ref.contract,
620
- bindings: bindings
621
- ? { in: bindings.in, out: bindings.out }
622
- : undefined,
623
- };
624
- steps.push(step);
829
+ steps.push(buildContractCallStep(meta.id, ref, bindings));
625
830
  return builder;
626
831
  },
627
832
  compute(fn) {
@@ -632,6 +837,40 @@ export function flow(idOrMeta) {
632
837
  steps.push(step);
633
838
  return builder;
634
839
  },
840
+ condition(spec, thenB, elseB) {
841
+ steps.push(buildConditionStep(meta.id, "L2", spec, thenB, elseB));
842
+ return builder;
843
+ },
844
+ conditionFn(spec, thenB, elseB) {
845
+ steps.push(buildConditionStep(meta.id, "L1", spec, thenB, elseB));
846
+ return builder;
847
+ },
848
+ conditionAsync(spec, thenB, elseB) {
849
+ steps.push(buildConditionStep(meta.id, "L0", spec, thenB, elseB));
850
+ return builder;
851
+ },
852
+ switchOn(lens) {
853
+ return (cases, deflt) => {
854
+ steps.push(buildSwitchOnStep(meta.id, lens, cases, deflt));
855
+ return builder;
856
+ };
857
+ },
858
+ switchCond(cases, deflt) {
859
+ steps.push(buildSwitchCondStep(meta.id, cases, deflt));
860
+ return builder;
861
+ },
862
+ poll(ref, opts) {
863
+ steps.push(buildPollStep(meta.id, "L2", ref, opts));
864
+ return builder;
865
+ },
866
+ pollFn(ref, opts) {
867
+ steps.push(buildPollStep(meta.id, "L1", ref, opts));
868
+ return builder;
869
+ },
870
+ pollAsync(ref, opts) {
871
+ steps.push(buildPollStep(meta.id, "L0", ref, opts));
872
+ return builder;
873
+ },
635
874
  build() {
636
875
  return finalize();
637
876
  },
@@ -720,6 +959,52 @@ function stepProjectionToRegistry(step) {
720
959
  writes: step.writes,
721
960
  };
722
961
  }
962
+ if (step.kind === "branch") {
963
+ if (step.mode === "value") {
964
+ return {
965
+ kind: "branch",
966
+ mode: "value",
967
+ name: step.name,
968
+ subjectPath: step.subjectPath,
969
+ cases: step.cases.map((c) => ({
970
+ value: c.value,
971
+ steps: c.steps.map(stepProjectionToRegistry),
972
+ })),
973
+ default: step.default.map(stepProjectionToRegistry),
974
+ };
975
+ }
976
+ return {
977
+ kind: "branch",
978
+ mode: "predicate",
979
+ name: step.name,
980
+ cases: step.cases.map((c) => ({
981
+ message: c.message,
982
+ predicate: c.predicate,
983
+ steps: c.steps.map(stepProjectionToRegistry),
984
+ })),
985
+ default: step.default.map(stepProjectionToRegistry),
986
+ };
987
+ }
988
+ if (step.kind === "poll") {
989
+ return {
990
+ kind: "poll",
991
+ name: step.name,
992
+ contractId: step.contractId,
993
+ caseKey: step.caseKey,
994
+ protocol: step.protocol,
995
+ target: step.target,
996
+ inputs: step.inputs,
997
+ outputs: step.outputs,
998
+ ...(step.accept ? { accept: step.accept } : {}),
999
+ ...(step.until !== undefined ? { until: step.until } : {}),
1000
+ ...(step.message !== undefined ? { message: step.message } : {}),
1001
+ every: step.every,
1002
+ backoff: step.backoff,
1003
+ ...(step.timeoutMs !== undefined ? { timeoutMs: step.timeoutMs } : {}),
1004
+ ...(step.perAttemptTimeoutMs !== undefined ? { perAttemptTimeoutMs: step.perAttemptTimeoutMs } : {}),
1005
+ ...(step.maxAttempts !== undefined ? { maxAttempts: step.maxAttempts } : {}),
1006
+ };
1007
+ }
723
1008
  return {
724
1009
  kind: "contract-call",
725
1010
  name: step.name,
@@ -729,12 +1014,172 @@ function stepProjectionToRegistry(step) {
729
1014
  target: step.target,
730
1015
  inputs: step.inputs,
731
1016
  outputs: step.outputs,
1017
+ ...(step.accept ? { accept: step.accept } : {}),
732
1018
  };
733
1019
  }
734
1020
  /**
735
1021
  * Core flow execution helper. Implements the Rule 1 / Rule 2 teardown
736
1022
  * semantics from contract-flow.md §7.
737
1023
  */
1024
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1025
+ /**
1026
+ * Execute ONE contract-call against its adapter (shared by the contract-call
1027
+ * step and each poll attempt). Resolves `in`, enforces `needs` at the flow
1028
+ * boundary, and calls `adapter.executeCaseInFlow`. Returns the raw response;
1029
+ * the caller applies `out`. `opts.signal` is threaded to honoring adapters.
1030
+ */
1031
+ async function executeContractCaseInFlow(step, state, ctx, runtimeId, opts) {
1032
+ const adapter = _adapters.get(step.ref.protocol);
1033
+ if (!adapter) {
1034
+ throw new Error(`flow "${runtimeId}" step "${step.name ?? step.caseKey}": ` +
1035
+ `no registered adapter for protocol "${step.ref.protocol}". ` +
1036
+ `Did you forget to import a contract plugin package?`);
1037
+ }
1038
+ if (!adapter.executeCaseInFlow) {
1039
+ throw new Error(`flow "${runtimeId}" step "${step.name ?? step.caseKey}": ` +
1040
+ `adapter for "${step.ref.protocol}" does not implement executeCaseInFlow`);
1041
+ }
1042
+ // Enforce `needs` at the flow boundary (the conditional-tuple step() signature
1043
+ // catches authoring shape, this catches `as any` / JS bypass + Zod transforms).
1044
+ const contractSpec = step.contract._spec;
1045
+ const caseSpec = contractSpec?.cases?.[step.caseKey];
1046
+ const needsSchema = caseSpec?.needs;
1047
+ const hasIn = typeof step.bindings?.in === "function";
1048
+ if (needsSchema && !hasIn) {
1049
+ throw new Error(`flow "${runtimeId}" step "${step.name ?? step.caseKey}": ` +
1050
+ `case "${step.ref.contractId}.${step.caseKey}" declares \`needs\` ` +
1051
+ `but the step has no \`bindings.in\`. Provide an ` +
1052
+ `\`in: (state) => <logical input>\` binding or remove the \`needs\` schema.`);
1053
+ }
1054
+ let resolvedInputs = step.bindings?.in?.(state);
1055
+ if (needsSchema) {
1056
+ resolvedInputs = validateNeedsOutput(needsSchema, resolvedInputs, { testId: `${step.ref.contractId}.${step.caseKey}`, source: "flow" });
1057
+ }
1058
+ return adapter.executeCaseInFlow({
1059
+ ctx,
1060
+ contract: step.contract,
1061
+ caseKey: step.caseKey,
1062
+ resolvedInputs,
1063
+ ...(step.bindings?.accept ? { accept: step.bindings.accept } : {}),
1064
+ ...(opts?.signal ? { signal: opts.signal } : {}),
1065
+ });
1066
+ }
1067
+ /**
1068
+ * Bounded-retry execution of a poll step. Repeats the case until `until` holds
1069
+ * (→ apply `out`, return next state) or a bound is exhausted (→ throw
1070
+ * PollExhaustedError, failing the flow). Each attempt's request + exit predicate
1071
+ * are budget-raced (so the loop is bounded regardless of whether the adapter
1072
+ * honors `signal`) and run in quarantined contexts (probe noise / timed-out
1073
+ * orphans discarded; the satisfying attempt's validation + in-budget predicate
1074
+ * effects flushed). See contract-flow-poll.md §4.2.
1075
+ */
1076
+ async function runPollStep(step, state, ctx, runtimeId) {
1077
+ const label = step.name ?? `${step.contract._projection.id}#${step.caseKey}`;
1078
+ // Runtime bound guard — the builder validates at construction, but `as any` /
1079
+ // JS callers can reach runFlow with an unbounded poll step. Re-validate so a
1080
+ // missing stop condition / per-attempt budget fails fast instead of looping
1081
+ // forever (mirrors the runtime `needs` guard for contract-call steps).
1082
+ validatePollBounds({
1083
+ timeout: step.timeoutMs,
1084
+ maxAttempts: step.maxAttempts,
1085
+ perAttemptTimeout: step.perAttemptTimeoutMs,
1086
+ every: step.every,
1087
+ backoff: step.backoff,
1088
+ }, label);
1089
+ // Default interval/backoff — validatePollBounds permits them to be omitted, but
1090
+ // the loop must not read `undefined` (sleep(undefined) hot-loops; delay*undefined
1091
+ // is NaN). The builder applies the same defaults; this covers as-any/JS callers.
1092
+ const everyMs = step.every ?? DEFAULT_EVERY_MS;
1093
+ const backoff = step.backoff ?? 1;
1094
+ const now = () => performance.now();
1095
+ const start = now();
1096
+ const deadline = step.timeoutMs !== undefined ? start + step.timeoutMs : Infinity;
1097
+ const perAttempt = step.perAttemptTimeoutMs ?? Infinity;
1098
+ let attempt = 0;
1099
+ let delay = everyMs;
1100
+ let lastRes;
1101
+ for (;;) {
1102
+ attempt += 1;
1103
+ const remainingTotal = deadline - now();
1104
+ if (remainingTotal <= 0) {
1105
+ throw new PollExhaustedError(label, attempt - 1, "total timeout reached before next attempt");
1106
+ }
1107
+ const attemptBudget = Math.min(perAttempt, remainingTotal); // build-time validation ⇒ finite
1108
+ const attemptStart = now();
1109
+ // Request: quarantined ctx + abort signal; budget-raced so a non-honoring
1110
+ // adapter can't block past the budget.
1111
+ const ac = new AbortController();
1112
+ let budgetAborted = false;
1113
+ const budgetTimer = Number.isFinite(attemptBudget)
1114
+ ? setTimeout(() => {
1115
+ budgetAborted = true;
1116
+ ac.abort();
1117
+ }, Math.max(0, attemptBudget))
1118
+ : undefined;
1119
+ const reqCtx = quarantinedCtx(ctx);
1120
+ const exhausted = () => new PollExhaustedError(label, attempt, `attempt budget ${Math.round(attemptBudget)}ms exceeded`);
1121
+ try {
1122
+ lastRes = await raceBudget(executeContractCaseInFlow(step, state, reqCtx, runtimeId, { signal: ac.signal }), attemptBudget, exhausted);
1123
+ }
1124
+ catch (err) {
1125
+ // A signal-honoring adapter (HTTP) rejects with AbortError when OUR budget
1126
+ // timer fired — convert it to the poll exhaustion error (with the attempt
1127
+ // count) rather than leaking a raw AbortError.
1128
+ if (budgetAborted && err instanceof Error && err.name === "AbortError") {
1129
+ throw exhausted();
1130
+ }
1131
+ // A budget timeout discards this attempt's ctx (orphan); a genuine request
1132
+ // error (incl. fatal validation / ctx.fail in the adapter) is in-budget —
1133
+ // flush its buffered effects (the failure assertion lands) then fail the poll.
1134
+ if (!(err instanceof PollExhaustedError))
1135
+ reqCtx.flushTo(ctx);
1136
+ throw err;
1137
+ }
1138
+ finally {
1139
+ if (budgetTimer)
1140
+ clearTimeout(budgetTimer);
1141
+ }
1142
+ // A sync-heavy request the timer couldn't preempt may have overrun — re-check.
1143
+ if (now() > deadline)
1144
+ throw new PollExhaustedError(label, attempt, "total timeout reached");
1145
+ if (now() - attemptStart > perAttempt)
1146
+ throw new PollExhaustedError(label, attempt, "attempt budget exceeded");
1147
+ // Exit predicate — quarantined ctx, budget-raced, flushed only on in-budget completion.
1148
+ const predBudget = Math.min(deadline - now(), perAttempt - (now() - attemptStart));
1149
+ const predCtx = quarantinedCtx(ctx);
1150
+ let done;
1151
+ try {
1152
+ done = await raceBudget(evalPollExit(step.until, lastRes, predCtx, state), Math.max(0, predBudget), () => new PollExhaustedError(label, attempt, "exit-predicate budget exceeded"));
1153
+ }
1154
+ catch (err) {
1155
+ // Timed-out predicate orphan → discard predCtx. An in-budget predicate that
1156
+ // threw (ctx.fail / ctx.skip / a thrown error) → flush its buffered effects
1157
+ // (the failure assertion lands) then propagate (the poll fails / skips).
1158
+ if (!(err instanceof PollExhaustedError))
1159
+ predCtx.flushTo(ctx);
1160
+ throw err;
1161
+ }
1162
+ if (now() > deadline)
1163
+ throw new PollExhaustedError(label, attempt, "total timeout reached");
1164
+ if (now() - attemptStart > perAttempt)
1165
+ throw new PollExhaustedError(label, attempt, "attempt budget exceeded");
1166
+ predCtx.flushTo(ctx); // in-budget: user's deliberate skip/fail/assert count
1167
+ if (done) {
1168
+ reqCtx.flushTo(ctx); // satisfying attempt: final-response validation failures surface (P1)
1169
+ return step.bindings?.out ? step.bindings.out(state, lastRes) : state;
1170
+ }
1171
+ // probe (not satisfied): reqCtx discarded (pending validation noise).
1172
+ if (step.maxAttempts && attempt >= step.maxAttempts) {
1173
+ throw new PollExhaustedError(label, attempt, "maxAttempts reached");
1174
+ }
1175
+ // A wait that would cross the deadline is pointless — fail now, don't sleep.
1176
+ if (Number.isFinite(deadline) && now() + delay >= deadline) {
1177
+ throw new PollExhaustedError(label, attempt, "next wait would exceed timeout");
1178
+ }
1179
+ await sleep(delay);
1180
+ delay = Math.min(delay * backoff, BACKOFF_CAP_MS);
1181
+ }
1182
+ }
738
1183
  export async function runFlow(flowContract, ctx) {
739
1184
  const runtime = flowContract._flow;
740
1185
  // If flow.setup throws → flow.teardown does NOT run (Rule 2).
@@ -742,81 +1187,50 @@ export async function runFlow(flowContract, ctx) {
742
1187
  ? await runtime.setup(ctx)
743
1188
  : undefined;
744
1189
  try {
745
- for (const step of runtime.steps) {
746
- if (step.kind === "compute") {
747
- // Compute: synchronous pure function. Enforce both syntactic and
748
- // value-level async rejection.
749
- if (step.fn.constructor?.name === "AsyncFunction") {
750
- throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
751
- `async functions are not allowed — compute must be synchronous and I/O-free`);
1190
+ // Recursive step executor. `state` is the closure-level committed cell —
1191
+ // branch sub-steps read and write the same `state`, so a taken branch can
1192
+ // observe and mutate flow state exactly like a top-level step. Non-taken
1193
+ // branches never run (and so never touch state). Branches nest via recursion.
1194
+ const runSteps = async (steps) => {
1195
+ for (const step of steps) {
1196
+ if (step.kind === "branch") {
1197
+ // Evaluate selector/predicates against the current committed state,
1198
+ // then execute only the matched case's sub-steps (or default).
1199
+ const taken = await selectBranchSteps(step, state, ctx);
1200
+ await runSteps(taken);
1201
+ continue;
752
1202
  }
753
- const result = step.fn(state);
754
- if (result && typeof result.then === "function") {
755
- throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
756
- `returned a thenable (Promise or Promise-like) — compute must not return async values. ` +
757
- `If you need async initialization, use flow.setup() instead.`);
1203
+ if (step.kind === "compute") {
1204
+ // Compute: synchronous pure function. Enforce both syntactic and
1205
+ // value-level async rejection.
1206
+ if (step.fn.constructor?.name === "AsyncFunction") {
1207
+ throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
1208
+ `async functions are not allowed — compute must be synchronous and I/O-free`);
1209
+ }
1210
+ const result = step.fn(state);
1211
+ if (result && typeof result.then === "function") {
1212
+ throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
1213
+ `returned a thenable (Promise or Promise-like) — compute must not return async values. ` +
1214
+ `If you need async initialization, use flow.setup() instead.`);
1215
+ }
1216
+ state = result;
1217
+ continue;
1218
+ }
1219
+ // poll branch — bounded retry of ONE case until the exit predicate holds
1220
+ // (or a bound is exhausted → the poll step fails). Shares the committed
1221
+ // `state` cell like every other step.
1222
+ if (step.kind === "poll") {
1223
+ state = (await runPollStep(step, state, ctx, runtime.id));
1224
+ continue;
1225
+ }
1226
+ // contract-call branch
1227
+ const response = await executeContractCaseInFlow(step, state, ctx, runtime.id);
1228
+ if (step.bindings?.out) {
1229
+ state = step.bindings.out(state, response);
758
1230
  }
759
- state = result;
760
- continue;
761
- }
762
- // contract-call branch
763
- const adapter = _adapters.get(step.ref.protocol);
764
- if (!adapter) {
765
- throw new Error(`flow "${runtime.id}" step "${step.name ?? step.caseKey}": ` +
766
- `no registered adapter for protocol "${step.ref.protocol}". ` +
767
- `Did you forget to import a contract plugin package?`);
768
- }
769
- if (!adapter.executeCaseInFlow) {
770
- throw new Error(`flow "${runtime.id}" step "${step.name ?? step.caseKey}": ` +
771
- `adapter for "${step.ref.protocol}" does not implement executeCaseInFlow`);
772
- }
773
- // v10 Phase 2d Step 2+3 follow-up (RFR v2 / v2.1 P1): enforce `needs`
774
- // schema at flow boundary, not just standalone. The conditional-tuple
775
- // `step()` signature catches TS authoring shape mismatches, but runtime
776
- // validation is the only line of defense against:
777
- // - `as any` / JS callers that bypass the TS check
778
- // - Zod parse / coerce / default semantics (schema can transform
779
- // the value, not just validate it)
780
- // - State drift producing invalid values at runtime despite valid
781
- // authoring types
782
- // Standalone overlay path already validates via the same helper in
783
- // `dispatchContract`'s test.fn closure; flow mirrors that to keep
784
- // `needs` a true contract semantic rather than a TS-only boundary.
785
- //
786
- // Two-branch guard:
787
- // needs present + bindings.in missing → throw (contract requires input)
788
- // needs present + bindings.in present → validate unconditionally
789
- // needs absent → no guard
790
- const contractSpec = step.contract._spec;
791
- const caseSpec = contractSpec?.cases?.[step.caseKey];
792
- const needsSchema = caseSpec?.needs;
793
- const hasIn = typeof step.bindings?.in === "function";
794
- if (needsSchema && !hasIn) {
795
- throw new Error(`flow "${runtime.id}" step "${step.name ?? step.caseKey}": ` +
796
- `case "${step.ref.contractId}.${step.caseKey}" declares \`needs\` ` +
797
- `but the step has no \`bindings.in\`. The TypeScript conditional ` +
798
- `tuple on FlowBuilder.step() usually prevents this at compile time; ` +
799
- `this runtime check catches \`as any\` / JS bypass. Provide an ` +
800
- `\`in: (state) => <logical input>\` binding or remove the \`needs\` ` +
801
- `schema from the case.`);
802
- }
803
- let resolvedInputs = step.bindings?.in?.(state);
804
- if (needsSchema) {
805
- resolvedInputs = validateNeedsOutput(needsSchema, resolvedInputs, {
806
- testId: `${step.ref.contractId}.${step.caseKey}`,
807
- source: "flow",
808
- });
809
- }
810
- const response = await adapter.executeCaseInFlow({
811
- ctx,
812
- contract: step.contract,
813
- caseKey: step.caseKey,
814
- resolvedInputs,
815
- });
816
- if (step.bindings?.out) {
817
- state = step.bindings.out(state, response);
818
1231
  }
819
- }
1232
+ };
1233
+ await runSteps(runtime.steps);
820
1234
  }
821
1235
  finally {
822
1236
  if (runtime.teardown) {
@@ -843,6 +1257,101 @@ export async function runFlow(flowContract, ctx) {
843
1257
  * with step context so the author sees which step needs fixing.
844
1258
  */
845
1259
  export function normalizeFlow(runtime) {
1260
+ // Recursive single-step normalizer. Branch sub-steps (cases + default) are
1261
+ // normalized through the same helper, so nested branches and any lens-purity
1262
+ // diagnostics inside them are handled identically at every depth.
1263
+ const normalizeStep = (s, idx) => {
1264
+ const stepLabel = s.name ??
1265
+ (s.kind === "contract-call" || s.kind === "poll"
1266
+ ? `${s.contract._projection.id}#${s.caseKey}`
1267
+ : `step-${idx + 1}`);
1268
+ if (s.kind === "branch") {
1269
+ // Recurse over each case/default sub-step list. We re-index from 0 within
1270
+ // the branch for labeling; the branch name (if any) carries outer context.
1271
+ return extractBranchStep(s, (steps) => steps.map((sub, i) => normalizeStep(sub, i)));
1272
+ }
1273
+ if (s.kind === "poll") {
1274
+ // Same Proxy dry-run as a contract-call step → the poll node carries
1275
+ // input/output data-flow edges; the bounded-retry + exit predicate are
1276
+ // carried by extractPollStep.
1277
+ return extractPollStep(s, (ps) => {
1278
+ let pInputs;
1279
+ if (ps.bindings?.in) {
1280
+ try {
1281
+ pInputs = extractMappings(ps.bindings.in);
1282
+ }
1283
+ catch (err) {
1284
+ if (err instanceof LensPurityError) {
1285
+ throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (poll in lens): ${err.message}`);
1286
+ }
1287
+ throw err;
1288
+ }
1289
+ }
1290
+ let pOutputs;
1291
+ if (ps.bindings?.out) {
1292
+ try {
1293
+ pOutputs = extractMappingsOut(ps.bindings.out);
1294
+ }
1295
+ catch (err) {
1296
+ if (err instanceof LensPurityError) {
1297
+ throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (poll out lens): ${err.message}`);
1298
+ }
1299
+ throw err;
1300
+ }
1301
+ }
1302
+ return { inputs: pInputs, outputs: pOutputs };
1303
+ });
1304
+ }
1305
+ if (s.kind === "compute") {
1306
+ try {
1307
+ const { reads, writes } = traceComputeFn(s.fn);
1308
+ return { kind: "compute", name: s.name, reads, writes };
1309
+ }
1310
+ catch (err) {
1311
+ // Wrap compute-tracer failures with the same step-context format
1312
+ // used for lens errors, so authoring mistakes are equally easy
1313
+ // to localize regardless of which tracer caught them.
1314
+ throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (compute): ${err instanceof Error ? err.message : String(err)}`);
1315
+ }
1316
+ }
1317
+ let inputs;
1318
+ if (s.bindings?.in) {
1319
+ try {
1320
+ inputs = extractMappings(s.bindings.in);
1321
+ }
1322
+ catch (err) {
1323
+ if (err instanceof LensPurityError) {
1324
+ throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (in lens): ${err.message}`);
1325
+ }
1326
+ throw err;
1327
+ }
1328
+ }
1329
+ let outputs;
1330
+ if (s.bindings?.out) {
1331
+ try {
1332
+ outputs = extractMappingsOut(s.bindings.out);
1333
+ }
1334
+ catch (err) {
1335
+ if (err instanceof LensPurityError) {
1336
+ throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (out lens): ${err.message}`);
1337
+ }
1338
+ throw err;
1339
+ }
1340
+ }
1341
+ return {
1342
+ kind: "contract-call",
1343
+ name: s.name,
1344
+ contractId: s.contract._projection.id,
1345
+ caseKey: s.caseKey,
1346
+ protocol: s.ref.protocol,
1347
+ target: s.ref.target,
1348
+ inputs,
1349
+ outputs,
1350
+ ...(s.bindings?.accept
1351
+ ? { accept: [...s.bindings.accept] }
1352
+ : {}),
1353
+ };
1354
+ };
846
1355
  return {
847
1356
  id: runtime.id,
848
1357
  protocol: "flow",
@@ -852,56 +1361,7 @@ export function normalizeFlow(runtime) {
852
1361
  ...(runtime.skip !== undefined ? { skip: runtime.skip } : {}),
853
1362
  ...(runtime.only !== undefined ? { only: runtime.only } : {}),
854
1363
  setupDynamic: runtime.setup ? true : undefined,
855
- steps: runtime.steps.map((s, idx) => {
856
- const stepLabel = s.name ??
857
- (s.kind === "contract-call" ? `${s.contract._projection.id}#${s.caseKey}` : `step-${idx + 1}`);
858
- if (s.kind === "compute") {
859
- try {
860
- const { reads, writes } = traceComputeFn(s.fn);
861
- return { kind: "compute", name: s.name, reads, writes };
862
- }
863
- catch (err) {
864
- // Wrap compute-tracer failures with the same step-context format
865
- // used for lens errors, so authoring mistakes are equally easy
866
- // to localize regardless of which tracer caught them.
867
- throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (compute): ${err instanceof Error ? err.message : String(err)}`);
868
- }
869
- }
870
- let inputs;
871
- if (s.bindings?.in) {
872
- try {
873
- inputs = extractMappings(s.bindings.in);
874
- }
875
- catch (err) {
876
- if (err instanceof LensPurityError) {
877
- throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (in lens): ${err.message}`);
878
- }
879
- throw err;
880
- }
881
- }
882
- let outputs;
883
- if (s.bindings?.out) {
884
- try {
885
- outputs = extractMappingsOut(s.bindings.out);
886
- }
887
- catch (err) {
888
- if (err instanceof LensPurityError) {
889
- throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (out lens): ${err.message}`);
890
- }
891
- throw err;
892
- }
893
- }
894
- return {
895
- kind: "contract-call",
896
- name: s.name,
897
- contractId: s.contract._projection.id,
898
- caseKey: s.caseKey,
899
- protocol: s.ref.protocol,
900
- target: s.ref.target,
901
- inputs,
902
- outputs,
903
- };
904
- }),
1364
+ steps: runtime.steps.map(normalizeStep),
905
1365
  };
906
1366
  }
907
1367
  /**
@@ -919,6 +1379,28 @@ export function extractMappings(fn) {
919
1379
  const result = fn(proxy);
920
1380
  return collectMappings(result, []);
921
1381
  }
1382
+ /**
1383
+ * Run a single-selector lens `fn: (state) => state.a.b.c` through the strict
1384
+ * tracing Proxy and return the read path (root segment dropped), e.g.
1385
+ * `s => s.lookup.status` → `["lookup", "status"]`.
1386
+ *
1387
+ * Used by the condition/switch predicate builders (contract-flow-condition.ts)
1388
+ * to capture a predicate's read path at construction time. The strict Proxy
1389
+ * enforces purity (method calls / `new` / arithmetic-via-coercion throw
1390
+ * `LensPurityError`); callers add a source-level single-selector gate on top
1391
+ * to also reject ternaries / free-variable reads the Proxy can't see.
1392
+ *
1393
+ * Throws `LensPurityError` if the lens does not resolve to a single traced
1394
+ * value (e.g. returns an object literal, a constant, or `undefined`).
1395
+ */
1396
+ export function extractSelectorPath(fn) {
1397
+ const result = fn(makeLensProxy("state"));
1398
+ if (!isTracedValue(result)) {
1399
+ throw new LensPurityError("state", "selector must read a single state path (e.g. s => s.a.b), not compute or repack a value");
1400
+ }
1401
+ const full = result[TRACE_MARKER]; // "state.lookup.status"
1402
+ return full.split(".").slice(1); // drop "state" root
1403
+ }
922
1404
  /**
923
1405
  * Extract FieldMappings from `out: (state, response) => newState`.
924
1406
  * Same purity contract as `extractMappings`.