@glubean/sdk 0.5.1 → 0.8.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-artifacts.d.ts +39 -21
- package/dist/contract-artifacts.d.ts.map +1 -1
- package/dist/contract-artifacts.js +80 -24
- package/dist/contract-artifacts.js.map +1 -1
- package/dist/contract-core.d.ts +10 -21
- package/dist/contract-core.d.ts.map +1 -1
- package/dist/contract-core.js +26 -812
- package/dist/contract-core.js.map +1 -1
- package/dist/contract-http/adapter.d.ts +13 -0
- package/dist/contract-http/adapter.d.ts.map +1 -1
- package/dist/contract-http/adapter.js +191 -23
- package/dist/contract-http/adapter.js.map +1 -1
- package/dist/contract-http/factory.d.ts +2 -2
- package/dist/contract-http/factory.d.ts.map +1 -1
- package/dist/contract-http/factory.js +5 -0
- package/dist/contract-http/factory.js.map +1 -1
- package/dist/contract-http/inbound-match.d.ts +47 -0
- package/dist/contract-http/inbound-match.d.ts.map +1 -0
- package/dist/contract-http/inbound-match.js +136 -0
- package/dist/contract-http/inbound-match.js.map +1 -0
- package/dist/contract-http/inbound-verify.d.ts +46 -0
- package/dist/contract-http/inbound-verify.d.ts.map +1 -0
- package/dist/contract-http/inbound-verify.js +101 -0
- package/dist/contract-http/inbound-verify.js.map +1 -0
- package/dist/contract-http/inbound.d.ts +45 -0
- package/dist/contract-http/inbound.d.ts.map +1 -0
- package/dist/contract-http/inbound.js +89 -0
- package/dist/contract-http/inbound.js.map +1 -0
- package/dist/contract-http/index.d.ts +5 -0
- package/dist/contract-http/index.d.ts.map +1 -1
- package/dist/contract-http/index.js +3 -0
- package/dist/contract-http/index.js.map +1 -1
- package/dist/contract-http/openapi.d.ts +0 -7
- package/dist/contract-http/openapi.d.ts.map +1 -1
- package/dist/contract-http/openapi.js +20 -14
- package/dist/contract-http/openapi.js.map +1 -1
- package/dist/contract-http/types.d.ts +106 -2
- package/dist/contract-http/types.d.ts.map +1 -1
- package/dist/contract-http/types.js +33 -0
- package/dist/contract-http/types.js.map +1 -1
- package/dist/contract-types.d.ts +148 -446
- package/dist/contract-types.d.ts.map +1 -1
- package/dist/data-path.d.ts.map +1 -1
- package/dist/data-path.js +10 -2
- package/dist/data-path.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +7 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +14 -0
- package/dist/internal.js.map +1 -1
- package/dist/load/artifact.d.ts +376 -0
- package/dist/load/artifact.d.ts.map +1 -0
- package/dist/load/artifact.js +14 -0
- package/dist/load/artifact.js.map +1 -0
- package/dist/load/builder.d.ts +80 -0
- package/dist/load/builder.d.ts.map +1 -0
- package/dist/load/builder.js +262 -0
- package/dist/load/builder.js.map +1 -0
- package/dist/load/context.d.ts +81 -0
- package/dist/load/context.d.ts.map +1 -0
- package/dist/load/context.js +2 -0
- package/dist/load/context.js.map +1 -0
- package/dist/load/duration.d.ts +9 -0
- package/dist/load/duration.d.ts.map +1 -0
- package/dist/load/duration.js +22 -0
- package/dist/load/duration.js.map +1 -0
- package/dist/load/events.d.ts +132 -0
- package/dist/load/events.d.ts.map +1 -0
- package/dist/load/events.js +2 -0
- package/dist/load/events.js.map +1 -0
- package/dist/load/feeder.d.ts +118 -0
- package/dist/load/feeder.d.ts.map +1 -0
- package/dist/load/feeder.js +170 -0
- package/dist/load/feeder.js.map +1 -0
- package/dist/load/index.d.ts +32 -0
- package/dist/load/index.d.ts.map +1 -0
- package/dist/load/index.js +7 -0
- package/dist/load/index.js.map +1 -0
- package/dist/load/progress.d.ts +56 -0
- package/dist/load/progress.d.ts.map +1 -0
- package/dist/load/progress.js +2 -0
- package/dist/load/progress.js.map +1 -0
- package/dist/load/projection.d.ts +36 -0
- package/dist/load/projection.d.ts.map +1 -0
- package/dist/load/projection.js +47 -0
- package/dist/load/projection.js.map +1 -0
- package/dist/load/runner.d.ts +201 -0
- package/dist/load/runner.d.ts.map +1 -0
- package/dist/load/runner.js +78 -0
- package/dist/load/runner.js.map +1 -0
- package/dist/load/scenario.d.ts +130 -0
- package/dist/load/scenario.d.ts.map +1 -0
- package/dist/load/scenario.js +9 -0
- package/dist/load/scenario.js.map +1 -0
- package/dist/load/step.d.ts +42 -0
- package/dist/load/step.d.ts.map +1 -0
- package/dist/load/step.js +2 -0
- package/dist/load/step.js.map +1 -0
- package/dist/{contract-flow-poll.d.ts → poll-primitives.d.ts} +8 -81
- package/dist/poll-primitives.d.ts.map +1 -0
- package/dist/{contract-flow-poll.js → poll-primitives.js} +10 -64
- package/dist/poll-primitives.js.map +1 -0
- package/dist/{contract-flow-condition.d.ts → predicates.d.ts} +34 -71
- package/dist/predicates.d.ts.map +1 -0
- package/dist/{contract-flow-condition.js → predicates.js} +86 -80
- package/dist/predicates.js.map +1 -0
- package/dist/test/builder.js +2 -2
- package/dist/test/builder.js.map +1 -1
- package/dist/test/utils.d.ts +7 -0
- package/dist/test/utils.d.ts.map +1 -1
- package/dist/test/utils.js +22 -17
- package/dist/test/utils.js.map +1 -1
- package/dist/types.d.ts +42 -14
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/workflow/builder.d.ts +386 -0
- package/dist/workflow/builder.d.ts.map +1 -0
- package/dist/workflow/builder.js +1150 -0
- package/dist/workflow/builder.js.map +1 -0
- package/dist/workflow/execute.d.ts +277 -0
- package/dist/workflow/execute.d.ts.map +1 -0
- package/dist/workflow/execute.js +1489 -0
- package/dist/workflow/execute.js.map +1 -0
- package/dist/workflow/index.d.ts +11 -0
- package/dist/workflow/index.d.ts.map +1 -0
- package/dist/workflow/index.js +15 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflow/project.d.ts +18 -0
- package/dist/workflow/project.d.ts.map +1 -0
- package/dist/workflow/project.js +321 -0
- package/dist/workflow/project.js.map +1 -0
- package/dist/workflow/retry.d.ts +13 -0
- package/dist/workflow/retry.d.ts.map +1 -0
- package/dist/workflow/retry.js +15 -0
- package/dist/workflow/retry.js.map +1 -0
- package/dist/workflow/types.d.ts +512 -0
- package/dist/workflow/types.d.ts.map +1 -0
- package/dist/workflow/types.js +2 -0
- package/dist/workflow/types.js.map +1 -0
- package/package.json +9 -2
- package/dist/contract-flow-condition.d.ts.map +0 -1
- package/dist/contract-flow-condition.js.map +0 -1
- package/dist/contract-flow-poll.d.ts.map +0 -1
- package/dist/contract-flow-poll.js.map +0 -1
- package/dist/contract-http/flow-helpers.d.ts +0 -12
- package/dist/contract-http/flow-helpers.d.ts.map +0 -1
- package/dist/contract-http/flow-helpers.js +0 -34
- package/dist/contract-http/flow-helpers.js.map +0 -1
package/dist/contract-core.js
CHANGED
|
@@ -6,9 +6,6 @@
|
|
|
6
6
|
* - `contract.register(protocol, adapter)` — plugin extension point
|
|
7
7
|
* - `contract[protocol](id, spec)` dispatcher — validates 1:1 case keys,
|
|
8
8
|
* invokes adapter.execute per case, registers tests + ContractRegistryMeta
|
|
9
|
-
* - `contract.flow(id)` — protocol-agnostic FlowBuilder
|
|
10
|
-
* - `runFlow(flow, ctx)` — core flow execution helper (Rule 1/2 teardown)
|
|
11
|
-
* - `normalizeFlow(runtime)` — Runtime → ExtractedFlowProjection
|
|
12
9
|
* - Permissive Proxy tracer for `compute` nodes (best-effort reads/writes)
|
|
13
10
|
*
|
|
14
11
|
* HTTP is NOT handled here — it registers itself in `./contract-http/`.
|
|
@@ -19,8 +16,6 @@
|
|
|
19
16
|
* - `internal/40-discovery/proposals/contract-flow.md` v9
|
|
20
17
|
*/
|
|
21
18
|
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";
|
|
24
19
|
import { getBootstrap, registerBootstrap } from "./bootstrap-registry.js";
|
|
25
20
|
import { getExplicitInput, getBootstrapInput, isForceStandalone, } from "./runner-input-channel.js";
|
|
26
21
|
/**
|
|
@@ -58,7 +53,7 @@ async function runCleanupsLifo(cleanups, testId) {
|
|
|
58
53
|
}
|
|
59
54
|
}
|
|
60
55
|
}
|
|
61
|
-
function validateNeedsOutput(needsSchema, value, ctx) {
|
|
56
|
+
export function validateNeedsOutput(needsSchema, value, ctx) {
|
|
62
57
|
const sp = needsSchema.safeParse;
|
|
63
58
|
if (typeof sp === "function") {
|
|
64
59
|
const result = sp(value);
|
|
@@ -68,7 +63,8 @@ function validateNeedsOutput(needsSchema, value, ctx) {
|
|
|
68
63
|
const sourceLabel = ctx.source === "bootstrap" ? "Bootstrap output"
|
|
69
64
|
: ctx.source === "explicit" ? "Explicit input"
|
|
70
65
|
: ctx.source === "bootstrap-params" ? "Bootstrap params"
|
|
71
|
-
: "
|
|
66
|
+
: ctx.source === "workflow" ? "Workflow `in` output"
|
|
67
|
+
: "Flow `in` output";
|
|
72
68
|
const schemaLabel = ctx.source === "bootstrap-params" ? "params schema" : "needs schema";
|
|
73
69
|
throw new Error(`${sourceLabel} for case "${ctx.testId}" does not satisfy ${schemaLabel}:\n${lines.join("\n")}`);
|
|
74
70
|
}
|
|
@@ -191,10 +187,15 @@ function dispatchContract(protocol, adapter, id, spec) {
|
|
|
191
187
|
? [contractTagsRaw]
|
|
192
188
|
: [];
|
|
193
189
|
const projCaseMap = new Map(projection.cases.map((c) => [c.key, c]));
|
|
194
|
-
const tests = Object.entries(cases).
|
|
190
|
+
const tests = Object.entries(cases).flatMap(([caseKey, caseSpec]) => {
|
|
195
191
|
const testId = `${id}.${caseKey}`;
|
|
196
192
|
const testName = `${id} — ${caseKey}`;
|
|
197
193
|
const projCase = projCaseMap.get(caseKey);
|
|
194
|
+
// Non-runnable cases (adapter sets `runnable: false`, e.g. inbound
|
|
195
|
+
// cases — inbound-contract-design §9.5): no Test, no runnable-inventory
|
|
196
|
+
// entry. The case exists in projection/_extracted only.
|
|
197
|
+
if (projCase.runnable === false)
|
|
198
|
+
return [];
|
|
198
199
|
const caseTags = caseSpec.tags ?? [];
|
|
199
200
|
const allTags = [...contractTags, ...caseTags];
|
|
200
201
|
// Projection lifecycle/severity are authoritative
|
|
@@ -476,7 +477,13 @@ function dispatchContract(protocol, adapter, id, spec) {
|
|
|
476
477
|
defaultRun,
|
|
477
478
|
contract: registryMeta,
|
|
478
479
|
});
|
|
479
|
-
|
|
480
|
+
// Keep contract cases on the LEGACY harness under the runner-on-engine cutover
|
|
481
|
+
// (engineSupports excludes marked tests). Outbound cases are common HTTP the
|
|
482
|
+
// engine could drive, but inbound (node:http webhook + node:crypto signing) is
|
|
483
|
+
// node-only, and the runner golden suite can't validate the engine path for
|
|
484
|
+
// contracts — so conservatively all contract cases stay legacy (plan 0005 §scope).
|
|
485
|
+
testDef.__glubean_kind = "contract";
|
|
486
|
+
return [testDef];
|
|
480
487
|
});
|
|
481
488
|
// Core injects id into both _projection and _spec carrier
|
|
482
489
|
const enrichedProjection = { ...projection, id };
|
|
@@ -498,7 +505,7 @@ function dispatchContract(protocol, adapter, id, spec) {
|
|
|
498
505
|
// ref used via contract.bootstrap(overlay) legitimately uses
|
|
499
506
|
// function-valued fields with `resolvedInput` — v9's
|
|
500
507
|
// validateCaseForFlow incorrectly rejected that at .case() time.
|
|
501
|
-
return makeContractCaseRef(protocol, id, projection.target, key, contractObj, spec);
|
|
508
|
+
return makeContractCaseRef(protocol, id, projection.target, key, contractObj, spec, projCaseMap.get(key)?.direction);
|
|
502
509
|
},
|
|
503
510
|
});
|
|
504
511
|
return contractObj;
|
|
@@ -536,7 +543,7 @@ function validateCaseKeys(protocol, specCases, projectionCases) {
|
|
|
536
543
|
* should enforce it in their own `.case()` wrapper if needed. For
|
|
537
544
|
* built-in HTTP adapter, see `./contract-http/adapter.ts`.
|
|
538
545
|
*/
|
|
539
|
-
function makeContractCaseRef(protocol, contractId, target, caseKey, contract, spec) {
|
|
546
|
+
function makeContractCaseRef(protocol, contractId, target, caseKey, contract, spec, direction) {
|
|
540
547
|
const caseSpec = spec.cases?.[caseKey];
|
|
541
548
|
return {
|
|
542
549
|
__glubean_type: "contract-case-ref",
|
|
@@ -546,6 +553,7 @@ function makeContractCaseRef(protocol, contractId, target, caseKey, contract, sp
|
|
|
546
553
|
target,
|
|
547
554
|
contract,
|
|
548
555
|
...(caseSpec?.runnability ? { runnability: caseSpec.runnability } : {}),
|
|
556
|
+
...(direction ? { direction } : {}),
|
|
549
557
|
};
|
|
550
558
|
}
|
|
551
559
|
/**
|
|
@@ -559,818 +567,24 @@ function makeContractCaseRef(protocol, contractId, target, caseKey, contract, sp
|
|
|
559
567
|
* compatible Needs, masking real type errors (Spike 0 Finding 1).
|
|
560
568
|
*/
|
|
561
569
|
export function bootstrap(ref, spec) {
|
|
570
|
+
if (ref.direction === "inbound") {
|
|
571
|
+
throw new Error(`contract.bootstrap: case "${ref.contractId}.${ref.caseKey}" is inbound — ` +
|
|
572
|
+
`it is awaited (workflow inbound poll), never executed, so a bootstrap ` +
|
|
573
|
+
`overlay has no meaning for it.`);
|
|
574
|
+
}
|
|
562
575
|
return registerBootstrap(ref, spec);
|
|
563
576
|
}
|
|
564
577
|
export const contract = {
|
|
565
578
|
register,
|
|
566
|
-
flow,
|
|
567
579
|
bootstrap,
|
|
568
580
|
};
|
|
569
|
-
// =============================================================================
|
|
570
|
-
// Flow: FlowBuilder + runFlow + normalizeFlow + tracePureFn
|
|
571
|
-
// =============================================================================
|
|
572
|
-
/**
|
|
573
|
-
* Protocol-agnostic flow builder. See contract-flow.md v9 §4.1.
|
|
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
|
-
}
|
|
805
|
-
export function flow(idOrMeta) {
|
|
806
|
-
const meta = typeof idOrMeta === "string"
|
|
807
|
-
? { id: idOrMeta }
|
|
808
|
-
: idOrMeta;
|
|
809
|
-
const steps = [];
|
|
810
|
-
let setupFn;
|
|
811
|
-
let teardownFn;
|
|
812
|
-
let built = false;
|
|
813
|
-
let extraMeta = {};
|
|
814
|
-
const builder = {
|
|
815
|
-
__glubean_type: "flow-builder",
|
|
816
|
-
meta(m) {
|
|
817
|
-
extraMeta = { ...extraMeta, ...m };
|
|
818
|
-
return builder;
|
|
819
|
-
},
|
|
820
|
-
setup(fn) {
|
|
821
|
-
setupFn = fn;
|
|
822
|
-
return builder;
|
|
823
|
-
},
|
|
824
|
-
teardown(fn) {
|
|
825
|
-
teardownFn = fn;
|
|
826
|
-
return builder;
|
|
827
|
-
},
|
|
828
|
-
step(ref, bindings) {
|
|
829
|
-
steps.push(buildContractCallStep(meta.id, ref, bindings));
|
|
830
|
-
return builder;
|
|
831
|
-
},
|
|
832
|
-
compute(fn) {
|
|
833
|
-
const step = {
|
|
834
|
-
kind: "compute",
|
|
835
|
-
fn: fn,
|
|
836
|
-
};
|
|
837
|
-
steps.push(step);
|
|
838
|
-
return builder;
|
|
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
|
-
},
|
|
874
|
-
build() {
|
|
875
|
-
return finalize();
|
|
876
|
-
},
|
|
877
|
-
};
|
|
878
|
-
// Auto-finalize via microtask (mirrors TestBuilder pattern)
|
|
879
|
-
queueMicrotask(() => {
|
|
880
|
-
if (!built)
|
|
881
|
-
finalize();
|
|
882
|
-
});
|
|
883
|
-
function finalize() {
|
|
884
|
-
if (built)
|
|
885
|
-
return builtResult;
|
|
886
|
-
built = true;
|
|
887
|
-
const runtime = {
|
|
888
|
-
protocol: "flow",
|
|
889
|
-
description: extraMeta.description,
|
|
890
|
-
tags: extraMeta.tags,
|
|
891
|
-
extensions: extraMeta.extensions,
|
|
892
|
-
...(extraMeta.skip !== undefined ? { skip: extraMeta.skip } : {}),
|
|
893
|
-
...(extraMeta.only !== undefined ? { only: extraMeta.only } : {}),
|
|
894
|
-
setup: setupFn,
|
|
895
|
-
teardown: teardownFn,
|
|
896
|
-
steps,
|
|
897
|
-
};
|
|
898
|
-
// Build the single Test that runFlow orchestrates.
|
|
899
|
-
const flowTest = {
|
|
900
|
-
meta: {
|
|
901
|
-
id: meta.id,
|
|
902
|
-
name: extraMeta.name ?? meta.id,
|
|
903
|
-
tags: extraMeta.tags,
|
|
904
|
-
description: extraMeta.description,
|
|
905
|
-
// `FlowMeta.skip: string` (the reason) → `TestMeta.deferred: string`
|
|
906
|
-
// mirrors the ContractCase.deferred convention so downstream
|
|
907
|
-
// reporters render the skip reason consistently.
|
|
908
|
-
...(extraMeta.skip !== undefined ? { deferred: extraMeta.skip } : {}),
|
|
909
|
-
...(extraMeta.only !== undefined ? { only: extraMeta.only } : {}),
|
|
910
|
-
},
|
|
911
|
-
type: "simple",
|
|
912
|
-
fn: async (ctx) => {
|
|
913
|
-
// Belt-and-suspenders: runtime ctx.skip in case the runner didn't
|
|
914
|
-
// filter on meta.deferred (e.g. the user ran with an explicit
|
|
915
|
-
// target that bypasses skip filters).
|
|
916
|
-
if (extraMeta.skip)
|
|
917
|
-
ctx.skip(extraMeta.skip);
|
|
918
|
-
await runFlow(resultHandle, ctx);
|
|
919
|
-
},
|
|
920
|
-
};
|
|
921
|
-
const arr = [flowTest];
|
|
922
|
-
const runtimeWithId = { ...runtime, id: meta.id };
|
|
923
|
-
// Pre-compute extracted projection so downstream consumers (scanner,
|
|
924
|
-
// CLI, MCP, Cloud) get full field mappings + compute reads/writes
|
|
925
|
-
// without having to import the SDK to call normalizeFlow themselves.
|
|
926
|
-
const extracted = normalizeFlow(runtimeWithId);
|
|
927
|
-
const resultHandle = Object.assign(arr, {
|
|
928
|
-
_flow: runtimeWithId,
|
|
929
|
-
_extracted: extracted,
|
|
930
|
-
});
|
|
931
|
-
// Register flow in the registry
|
|
932
|
-
const flowRegistryMeta = {
|
|
933
|
-
id: extracted.id,
|
|
934
|
-
description: extracted.description,
|
|
935
|
-
tags: extracted.tags,
|
|
936
|
-
steps: extracted.steps.map(stepProjectionToRegistry),
|
|
937
|
-
setupDynamic: extracted.setupDynamic,
|
|
938
|
-
};
|
|
939
|
-
registerTest({
|
|
940
|
-
id: meta.id,
|
|
941
|
-
name: extraMeta.name ?? meta.id,
|
|
942
|
-
type: "simple",
|
|
943
|
-
tags: extraMeta.tags,
|
|
944
|
-
description: extraMeta.description,
|
|
945
|
-
flow: flowRegistryMeta,
|
|
946
|
-
});
|
|
947
|
-
builtResult = resultHandle;
|
|
948
|
-
return resultHandle;
|
|
949
|
-
}
|
|
950
|
-
let builtResult;
|
|
951
|
-
return builder;
|
|
952
|
-
}
|
|
953
|
-
function stepProjectionToRegistry(step) {
|
|
954
|
-
if (step.kind === "compute") {
|
|
955
|
-
return {
|
|
956
|
-
kind: "compute",
|
|
957
|
-
name: step.name,
|
|
958
|
-
reads: step.reads,
|
|
959
|
-
writes: step.writes,
|
|
960
|
-
};
|
|
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
|
-
}
|
|
1008
|
-
return {
|
|
1009
|
-
kind: "contract-call",
|
|
1010
|
-
name: step.name,
|
|
1011
|
-
contractId: step.contractId,
|
|
1012
|
-
caseKey: step.caseKey,
|
|
1013
|
-
protocol: step.protocol,
|
|
1014
|
-
target: step.target,
|
|
1015
|
-
inputs: step.inputs,
|
|
1016
|
-
outputs: step.outputs,
|
|
1017
|
-
...(step.accept ? { accept: step.accept } : {}),
|
|
1018
|
-
};
|
|
1019
|
-
}
|
|
1020
|
-
/**
|
|
1021
|
-
* Core flow execution helper. Implements the Rule 1 / Rule 2 teardown
|
|
1022
|
-
* semantics from contract-flow.md §7.
|
|
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
|
-
}
|
|
1183
|
-
export async function runFlow(flowContract, ctx) {
|
|
1184
|
-
const runtime = flowContract._flow;
|
|
1185
|
-
// If flow.setup throws → flow.teardown does NOT run (Rule 2).
|
|
1186
|
-
let state = runtime.setup
|
|
1187
|
-
? await runtime.setup(ctx)
|
|
1188
|
-
: undefined;
|
|
1189
|
-
try {
|
|
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;
|
|
1202
|
-
}
|
|
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);
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
};
|
|
1233
|
-
await runSteps(runtime.steps);
|
|
1234
|
-
}
|
|
1235
|
-
finally {
|
|
1236
|
-
if (runtime.teardown) {
|
|
1237
|
-
// flow.teardown runs in Rule 2 outer-finally. Its errors are logged
|
|
1238
|
-
// but must not mask the primary exception.
|
|
1239
|
-
try {
|
|
1240
|
-
await runtime.teardown(ctx, state);
|
|
1241
|
-
}
|
|
1242
|
-
catch (teardownErr) {
|
|
1243
|
-
ctx.log?.(`flow.teardown failed: ${String(teardownErr)}`);
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
// =============================================================================
|
|
1249
|
-
// normalizeFlow + permissive Proxy tracer
|
|
1250
|
-
// =============================================================================
|
|
1251
|
-
/**
|
|
1252
|
-
* Normalize a RuntimeFlowProjection to JSON-safe ExtractedFlowProjection.
|
|
1253
|
-
* Runs Proxy dry-run of lens functions to extract FieldMappings.
|
|
1254
|
-
*
|
|
1255
|
-
* Lens purity is enforced here: if a `step.bindings.in` or `.out` lens
|
|
1256
|
-
* violates purity (method call, `new`, etc.), `LensPurityError` is thrown
|
|
1257
|
-
* with step context so the author sees which step needs fixing.
|
|
1258
|
-
*/
|
|
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
|
-
};
|
|
1355
|
-
return {
|
|
1356
|
-
id: runtime.id,
|
|
1357
|
-
protocol: "flow",
|
|
1358
|
-
description: runtime.description,
|
|
1359
|
-
tags: runtime.tags,
|
|
1360
|
-
extensions: runtime.extensions,
|
|
1361
|
-
...(runtime.skip !== undefined ? { skip: runtime.skip } : {}),
|
|
1362
|
-
...(runtime.only !== undefined ? { only: runtime.only } : {}),
|
|
1363
|
-
setupDynamic: runtime.setup ? true : undefined,
|
|
1364
|
-
steps: runtime.steps.map(normalizeStep),
|
|
1365
|
-
};
|
|
1366
|
-
}
|
|
1367
581
|
/**
|
|
1368
582
|
* Run a pure lens `fn: (state) => output` with a tracing Proxy, extracting
|
|
1369
583
|
* FieldMappings from state paths to output paths.
|
|
1370
584
|
*
|
|
1371
585
|
* Pure-lens enforcement: method calls, `new`, and coercion on the state
|
|
1372
586
|
* proxy raise `LensPurityError`. Errors are **not** swallowed — the caller
|
|
1373
|
-
* (
|
|
587
|
+
* (legacy flow's normalizer used to) wraps them with step context and re-throws,
|
|
1374
588
|
* so authors see the failure at flow build time rather than silently
|
|
1375
589
|
* losing projection data.
|
|
1376
590
|
*/
|
|
@@ -1466,7 +680,7 @@ function isTracedValue(v) {
|
|
|
1466
680
|
/**
|
|
1467
681
|
* Raised by the strict lens Proxy when a user lens fn attempts an operation
|
|
1468
682
|
* that breaks the "pure field access + repack" contract. Caught and re-
|
|
1469
|
-
* thrown with step context by
|
|
683
|
+
* thrown with step context by callers.
|
|
1470
684
|
*/
|
|
1471
685
|
export class LensPurityError extends Error {
|
|
1472
686
|
path;
|