@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.
- package/dist/contract-core.d.ts +15 -7
- package/dist/contract-core.d.ts.map +1 -1
- package/dist/contract-core.js +632 -150
- package/dist/contract-core.js.map +1 -1
- package/dist/contract-flow-condition.d.ts +229 -0
- package/dist/contract-flow-condition.d.ts.map +1 -0
- package/dist/contract-flow-condition.js +457 -0
- package/dist/contract-flow-condition.js.map +1 -0
- package/dist/contract-flow-poll.d.ts +157 -0
- package/dist/contract-flow-poll.d.ts.map +1 -0
- package/dist/contract-flow-poll.js +223 -0
- package/dist/contract-flow-poll.js.map +1 -0
- package/dist/contract-http/adapter.d.ts.map +1 -1
- package/dist/contract-http/adapter.js +23 -0
- package/dist/contract-http/adapter.js.map +1 -1
- package/dist/contract-http/types.d.ts +9 -0
- package/dist/contract-http/types.d.ts.map +1 -1
- package/dist/contract-types.d.ts +300 -21
- package/dist/contract-types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/test/builder.d.ts +28 -1
- package/dist/test/builder.d.ts.map +1 -1
- package/dist/test/builder.js +194 -4
- package/dist/test/builder.js.map +1 -1
- package/dist/types.d.ts +123 -14
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/contract-core.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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(
|
|
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`.
|