@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
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
import { predicateScope, assertL2Predicate, selectorPath } from "../predicates.js";
|
|
2
|
+
import { validatePollBounds, DEFAULT_EVERY_MS } from "../poll-primitives.js";
|
|
3
|
+
import { registerTest } from "../internal.js";
|
|
4
|
+
import { traceComputeFn } from "../contract-core.js";
|
|
5
|
+
import { interpolateTemplate, normalizeEachTable } from "../test/utils.js";
|
|
6
|
+
import { validateRetryMeta } from "./retry.js";
|
|
7
|
+
import { projectWorkflow } from "./project.js";
|
|
8
|
+
/**
|
|
9
|
+
* Validate a per-node timeout (§17 #4) at build time. Only the ASYNC node
|
|
10
|
+
* kinds accept one; `compute` is synchronous by contract and `poll`/the branch
|
|
11
|
+
* family own their own bounds/decision semantics — a timeout there would be
|
|
12
|
+
* dead config pretending to protect something.
|
|
13
|
+
*/
|
|
14
|
+
function validateNodeTimeout(meta, kind) {
|
|
15
|
+
if (meta.timeout === undefined)
|
|
16
|
+
return;
|
|
17
|
+
if (kind !== "call" && kind !== "action" && kind !== "check") {
|
|
18
|
+
throw new Error(`workflow.${kind}() "${meta.id}": \`timeout\` is not supported on ${kind} nodes — ` +
|
|
19
|
+
(kind === "poll"
|
|
20
|
+
? "a poll owns its own bounds (timeout/perAttemptTimeout/maxAttempts)"
|
|
21
|
+
: kind === "compute"
|
|
22
|
+
? "compute is synchronous by contract"
|
|
23
|
+
: kind === "group"
|
|
24
|
+
? "a group is a display-only bracket; members carry their own timeouts"
|
|
25
|
+
: "the branch family's decision has no timeout semantics"));
|
|
26
|
+
}
|
|
27
|
+
if (typeof meta.timeout !== "number" || !Number.isFinite(meta.timeout) || meta.timeout <= 0) {
|
|
28
|
+
throw new Error(`workflow.${kind}() "${meta.id}": \`timeout\` must be a finite number > 0 (ms); got ${String(meta.timeout)}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Normalize a step's first argument into a `NodeMeta`. A string is shorthand for
|
|
33
|
+
* the node `id` (mirrors `workflow(id)`); an object is spread as-is. `id` falls
|
|
34
|
+
* back to `name` then to a positional `${idPrefix}node-${index}`; `name` defaults
|
|
35
|
+
* to `id`. `idPrefix` keeps anonymous-fallback ids unique inside branch sides
|
|
36
|
+
* (each side collects with its own positional counter — codex S2.4a R7).
|
|
37
|
+
*/
|
|
38
|
+
function normalizeNodeMeta(input, index, idPrefix = "") {
|
|
39
|
+
const m = typeof input === "string" ? { id: input } : { ...input };
|
|
40
|
+
const id = m.id ?? m.name ?? `${idPrefix}node-${index}`;
|
|
41
|
+
return { ...m, id, name: m.name ?? id };
|
|
42
|
+
}
|
|
43
|
+
class WorkflowBuilderImpl {
|
|
44
|
+
_idPrefix;
|
|
45
|
+
_isChild;
|
|
46
|
+
/** Discovery marker — the runner's resolver auto-builds exported builders. */
|
|
47
|
+
__glubean_type = "workflow-builder";
|
|
48
|
+
_meta;
|
|
49
|
+
_setup;
|
|
50
|
+
_setupTimeoutMs;
|
|
51
|
+
_setupNote;
|
|
52
|
+
_teardown;
|
|
53
|
+
_teardownTimeoutMs;
|
|
54
|
+
_teardownNote;
|
|
55
|
+
_nodes = [];
|
|
56
|
+
_built;
|
|
57
|
+
/** The first authoring error, if any — a poisoned builder never finalizes. */
|
|
58
|
+
_poisoned;
|
|
59
|
+
/** Set by `.route()` — the tree is terminal; only teardown/build may follow. */
|
|
60
|
+
_terminated;
|
|
61
|
+
/** workflow.each: registration is deferred until EVERY row passes the
|
|
62
|
+
* one-structure validation — set by the engine, never by users. */
|
|
63
|
+
_suppressRegistration = false;
|
|
64
|
+
constructor(meta, _idPrefix = "",
|
|
65
|
+
/** Child builders (branch/poll sub-graphs) never finalize/register. */
|
|
66
|
+
_isChild = false) {
|
|
67
|
+
this._idPrefix = _idPrefix;
|
|
68
|
+
this._isChild = _isChild;
|
|
69
|
+
this._meta = meta;
|
|
70
|
+
// Auto-finalize via microtask (mirrors contract.flow()/TestBuilder): an
|
|
71
|
+
// exported, never-built workflow still registers for discovery. A poisoned
|
|
72
|
+
// builder (a chained call threw) must NOT auto-build — registering the
|
|
73
|
+
// partial graph would leak a phantom workflow into discovery when the
|
|
74
|
+
// author caught the error (codex S2.5 R3 P2).
|
|
75
|
+
if (!_isChild) {
|
|
76
|
+
queueMicrotask(() => {
|
|
77
|
+
if (this._built || this._poisoned !== undefined)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
this.build();
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
// Auto-build is fire-and-forget on a microtask — a build-time
|
|
84
|
+
// validation error (e.g. duplicate node ids) must not become an
|
|
85
|
+
// unhandled exception (codex S2.13 R1 P1). The builder is poisoned
|
|
86
|
+
// (build() did that) so it never registers; surface the cause
|
|
87
|
+
// loudly enough to find.
|
|
88
|
+
console.error(`[glubean] workflow "${this._meta.id}" failed to auto-build and will not be registered: ` +
|
|
89
|
+
(e instanceof Error ? e.message : String(e)));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// The CHAINED brand is purely type-level — at runtime every chain call
|
|
95
|
+
// returns this same instance; the cast just re-labels it.
|
|
96
|
+
chained() {
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
// Wraps every authoring mutator: a throw from validation (invalid poll
|
|
100
|
+
// bounds, sub-builder build(), …) leaves the graph half-authored, so it
|
|
101
|
+
// poisons the builder — build()/auto-build then refuse instead of
|
|
102
|
+
// registering the partial graph (codex S2.5 R3 P2). A post-build mutation
|
|
103
|
+
// throw does NOT poison: the registered graph is complete and stays valid.
|
|
104
|
+
authoring(fn) {
|
|
105
|
+
try {
|
|
106
|
+
return fn();
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
if (!this._built)
|
|
110
|
+
this._poisoned ??= e;
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
meta(meta) {
|
|
115
|
+
return this.authoring(() => {
|
|
116
|
+
this.assertNotBuilt("meta");
|
|
117
|
+
// ENGINE-OWNED fields are locked (codex S2.12 R21 P2): a factory
|
|
118
|
+
// overwriting them would desync discovery from execution. The Omit
|
|
119
|
+
// above blocks the type level; this reapply blocks `as any`.
|
|
120
|
+
this._meta = {
|
|
121
|
+
...this._meta,
|
|
122
|
+
...meta,
|
|
123
|
+
id: this._meta.id,
|
|
124
|
+
templateId: this._meta.templateId,
|
|
125
|
+
groupId: this._meta.groupId,
|
|
126
|
+
parallel: this._meta.parallel,
|
|
127
|
+
};
|
|
128
|
+
return this.chained();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/** A blank hint is noise pretending to be signal (phase4 §6.1). */
|
|
132
|
+
static validateLifecycleNote(phase, note) {
|
|
133
|
+
if (note === undefined)
|
|
134
|
+
return;
|
|
135
|
+
if (typeof note !== "string" || note.trim().length === 0) {
|
|
136
|
+
throw new Error(`workflow.${phase}(): \`note\` must be a non-empty string — omit it instead of leaving it blank`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
static validateLifecycleTimeout(phase, timeout) {
|
|
140
|
+
if (timeout === undefined)
|
|
141
|
+
return;
|
|
142
|
+
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
|
|
143
|
+
throw new Error(`workflow.${phase}(): \`timeout\` must be a finite number > 0 (ms); got ${String(timeout)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
setup(fn, opts) {
|
|
147
|
+
return this.authoring(() => {
|
|
148
|
+
this.assertNotBuilt("setup");
|
|
149
|
+
// setup runs first at execution, so authoring it after a node OR after
|
|
150
|
+
// teardown would make the advertised state type dishonest (codex slice-1 P2).
|
|
151
|
+
if (this._nodes.length > 0 || this._teardown) {
|
|
152
|
+
throw new Error("workflow.setup() must be called before any step (call/action/check/compute) or teardown");
|
|
153
|
+
}
|
|
154
|
+
// Lifecycle belongs to the WORKFLOW AUTHOR: a fragment silently
|
|
155
|
+
// replacing the host's setup (and reshaping runtime state invisibly to
|
|
156
|
+
// the tip guard, which only tracks nodes) is exactly the stale-handle
|
|
157
|
+
// hazard again (codex S2.13 R5 P2). meta() stays allowed — display
|
|
158
|
+
// annotation, not behavior.
|
|
159
|
+
if (this._fragmentDepth > 0) {
|
|
160
|
+
throw new Error(`workflow "${this._meta.id}": a .use() fragment cannot declare setup — ` +
|
|
161
|
+
`lifecycle belongs to the workflow author`);
|
|
162
|
+
}
|
|
163
|
+
WorkflowBuilderImpl.validateLifecycleTimeout("setup", opts?.timeout);
|
|
164
|
+
WorkflowBuilderImpl.validateLifecycleNote("setup", opts?.note);
|
|
165
|
+
this._setup = fn;
|
|
166
|
+
this._setupTimeoutMs = opts?.timeout;
|
|
167
|
+
this._setupNote = opts?.note;
|
|
168
|
+
return this;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
teardown(fn, opts) {
|
|
172
|
+
return this.authoring(() => {
|
|
173
|
+
this.assertNotBuilt("teardown");
|
|
174
|
+
if (this._fragmentDepth > 0) {
|
|
175
|
+
throw new Error(`workflow "${this._meta.id}": a .use() fragment cannot declare teardown — ` +
|
|
176
|
+
`lifecycle belongs to the workflow author`);
|
|
177
|
+
}
|
|
178
|
+
WorkflowBuilderImpl.validateLifecycleTimeout("teardown", opts?.timeout);
|
|
179
|
+
WorkflowBuilderImpl.validateLifecycleNote("teardown", opts?.note);
|
|
180
|
+
this._teardown = fn;
|
|
181
|
+
this._teardownTimeoutMs = opts?.timeout;
|
|
182
|
+
this._teardownNote = opts?.note;
|
|
183
|
+
return this.chained();
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// A finalized workflow is immutable — its Test/registry entry already
|
|
187
|
+
// captured the graph; a later mutation would be silently dropped.
|
|
188
|
+
assertNotBuilt(method) {
|
|
189
|
+
if (this._built) {
|
|
190
|
+
throw new Error(`workflow.${method}() called after build() — the workflow is already finalized`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// teardown receives the FINAL state, so no step may follow it (else its
|
|
194
|
+
// callback is authored against a stale state type) (codex slice-1 P2).
|
|
195
|
+
// A `.route()` terminal tree likewise allows no further graph nodes (§9 #5).
|
|
196
|
+
assertNoTeardown(method) {
|
|
197
|
+
this.assertNotBuilt(method);
|
|
198
|
+
if (this._terminated) {
|
|
199
|
+
throw new Error(`workflow.${method}() cannot follow ${this._terminated} — a route is terminal; ` +
|
|
200
|
+
`only .teardown()/.build() may come after`);
|
|
201
|
+
}
|
|
202
|
+
if (this._teardown) {
|
|
203
|
+
throw new Error(`workflow.${method}() cannot be added after .teardown() — teardown must be the last step`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Impl uses a broad `...rest` + `any` return so it satisfies the interface's
|
|
207
|
+
// generic conditional-tuple signature; call-site type-checking uses the precise
|
|
208
|
+
// interface signature, not this one.
|
|
209
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
210
|
+
call(idOrMeta, ref, ...rest) {
|
|
211
|
+
return this.authoring(() => {
|
|
212
|
+
this.assertNoTeardown("call");
|
|
213
|
+
const meta = normalizeNodeMeta(idOrMeta, this._nodes.length, this._idPrefix);
|
|
214
|
+
validateNodeTimeout(meta, "call");
|
|
215
|
+
if (ref?.direction === "inbound") {
|
|
216
|
+
throw new Error(`workflow.call() "${meta.id}": case "${ref.contractId}.${ref.caseKey}" is ` +
|
|
217
|
+
`inbound — the counterparty calls us, so there is nothing to call. ` +
|
|
218
|
+
`Await it with an inbound poll instead.`);
|
|
219
|
+
}
|
|
220
|
+
const bindings = rest[0];
|
|
221
|
+
if (bindings?.retry)
|
|
222
|
+
validateRetryMeta(bindings.retry, meta.id);
|
|
223
|
+
const node = {
|
|
224
|
+
kind: "contract-call",
|
|
225
|
+
meta,
|
|
226
|
+
ref,
|
|
227
|
+
in: bindings?.in,
|
|
228
|
+
out: bindings?.out,
|
|
229
|
+
accept: bindings?.accept,
|
|
230
|
+
retry: bindings?.retry,
|
|
231
|
+
};
|
|
232
|
+
this._nodes.push(node);
|
|
233
|
+
return this;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
// Broad impl satisfies both action overloads; call sites use the interface.
|
|
237
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
238
|
+
action(idOrMeta, fn, opts) {
|
|
239
|
+
return this.authoring(() => {
|
|
240
|
+
this.assertNoTeardown("action");
|
|
241
|
+
const meta = normalizeNodeMeta(idOrMeta, this._nodes.length, this._idPrefix);
|
|
242
|
+
validateNodeTimeout(meta, "action");
|
|
243
|
+
if (opts?.retry)
|
|
244
|
+
validateRetryMeta(opts.retry, meta.id);
|
|
245
|
+
const node = {
|
|
246
|
+
kind: "action",
|
|
247
|
+
meta,
|
|
248
|
+
fn: fn,
|
|
249
|
+
project: opts?.project,
|
|
250
|
+
retry: opts?.retry,
|
|
251
|
+
};
|
|
252
|
+
this._nodes.push(node);
|
|
253
|
+
return this;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
// Broad impl satisfies both check overloads; call sites use the interface.
|
|
257
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
258
|
+
check(idOrMeta, fnOrOpts, opts) {
|
|
259
|
+
return this.authoring(() => {
|
|
260
|
+
this.assertNoTeardown("check");
|
|
261
|
+
const checkMeta = normalizeNodeMeta(idOrMeta, this._nodes.length, this._idPrefix);
|
|
262
|
+
validateNodeTimeout(checkMeta, "check");
|
|
263
|
+
// Declarative form: { expect: (w) => [...] } (phase4 §7)
|
|
264
|
+
if (fnOrOpts !== null &&
|
|
265
|
+
typeof fnOrOpts === "object" &&
|
|
266
|
+
typeof fnOrOpts.expect === "function") {
|
|
267
|
+
const expectFn = fnOrOpts.expect;
|
|
268
|
+
const items = expectFn(predicateScope());
|
|
269
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
270
|
+
throw new Error(`workflow.check() "${checkMeta.id}": \`expect\` must return a non-empty array of predicates`);
|
|
271
|
+
}
|
|
272
|
+
for (const item of items) {
|
|
273
|
+
// every item must be L2 declarative — an opaque predicate here is a
|
|
274
|
+
// contradiction in terms; use the inline form for opaque logic.
|
|
275
|
+
assertL2Predicate(item, `check "${checkMeta.id}" expect`);
|
|
276
|
+
}
|
|
277
|
+
const node = {
|
|
278
|
+
kind: "check",
|
|
279
|
+
meta: checkMeta,
|
|
280
|
+
expects: Object.freeze([...items]),
|
|
281
|
+
};
|
|
282
|
+
this._nodes.push(node);
|
|
283
|
+
return this.chained();
|
|
284
|
+
}
|
|
285
|
+
if (typeof fnOrOpts !== "function") {
|
|
286
|
+
throw new Error(`workflow.check() "${checkMeta.id}" requires a check function or { expect: (w) => [...] }`);
|
|
287
|
+
}
|
|
288
|
+
const node = {
|
|
289
|
+
kind: "check",
|
|
290
|
+
meta: checkMeta,
|
|
291
|
+
fn: fnOrOpts,
|
|
292
|
+
project: opts?.project,
|
|
293
|
+
};
|
|
294
|
+
this._nodes.push(node);
|
|
295
|
+
return this.chained();
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
// Broad impl returns `any` to satisfy the interface's conditional return type.
|
|
299
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
300
|
+
compute(idOrMeta, fn) {
|
|
301
|
+
return this.authoring(() => {
|
|
302
|
+
this.assertNoTeardown("compute");
|
|
303
|
+
// Catch the common async case here (compute is modeled/projected as a
|
|
304
|
+
// synchronous `full` transform; an async fn would store a Promise-returning
|
|
305
|
+
// fn — codex slice-1 P2). A sync fn that still RETURNS a promise
|
|
306
|
+
// (`() => Promise.resolve(x)`) can't be caught at build time (the fn isn't
|
|
307
|
+
// invoked here); TODO(executor): thenable-check the compute result at run
|
|
308
|
+
// time and fail the node.
|
|
309
|
+
if (fn?.constructor?.name === "AsyncFunction") {
|
|
310
|
+
throw new Error("workflow.compute() must be synchronous — use .action() for async work");
|
|
311
|
+
}
|
|
312
|
+
const computeMeta = normalizeNodeMeta(idOrMeta, this._nodes.length, this._idPrefix);
|
|
313
|
+
validateNodeTimeout(computeMeta, "compute");
|
|
314
|
+
// S2.18 lens-purity: trace the dataflow at BUILD time (proxy dry-run —
|
|
315
|
+
// the same tracer flow's normalizer runs). Transform LOGIC stays
|
|
316
|
+
// undeclarable, which is why compute grades `partial` at best.
|
|
317
|
+
//
|
|
318
|
+
// DESIGN STANCE (codex S2.18 R3, defended): yes, this executes the fn
|
|
319
|
+
// once at authoring/scan time. That is the established vNext mechanism
|
|
320
|
+
// for EVERY declared function — predicate lenses, selector lenses,
|
|
321
|
+
// via/correlate lenses all dry-run through a proxy at build — and the
|
|
322
|
+
// public contract of compute is "pure synchronous transform": a body
|
|
323
|
+
// with closure mutation/side effects (`++n`) violates that contract,
|
|
324
|
+
// and the dry run makes the violation observable instead of latent.
|
|
325
|
+
// No published users exist (pre-0.6 window); a static-analysis
|
|
326
|
+
// alternative is the regex bottomless pit S2.15 already disproved.
|
|
327
|
+
//
|
|
328
|
+
// A throw during the dry run is NOT an authoring error: the proxy
|
|
329
|
+
// feeds dummy values ("", 0) and a value-sensitive helper
|
|
330
|
+
// (`new URL(state.url)`, schema validators) may legitimately reject
|
|
331
|
+
// them — degrade to untraced (grade `opaque`) instead of failing a
|
|
332
|
+
// build the runtime would have passed (codex S2.18 R1 P2).
|
|
333
|
+
let traced;
|
|
334
|
+
try {
|
|
335
|
+
traced = traceComputeFn(fn);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
traced = undefined;
|
|
339
|
+
}
|
|
340
|
+
const node = {
|
|
341
|
+
kind: "compute",
|
|
342
|
+
meta: computeMeta,
|
|
343
|
+
fn: fn,
|
|
344
|
+
...(traced ? { reads: traced.reads, writes: traced.writes } : {}),
|
|
345
|
+
};
|
|
346
|
+
this._nodes.push(node);
|
|
347
|
+
return this;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/** DEPTH of currently-executing `.use()` fragments (nested fragments
|
|
351
|
+
* compose — a boolean would be cleared by the inner finally, reopening the
|
|
352
|
+
* premature-build hole; codex S2.13 R3 P2). A fragment finalizing the
|
|
353
|
+
* workflow (`b.build()` via JS/as-any) would register it BEFORE use() can
|
|
354
|
+
* reject the handle, and the set `_built` would defeat poisoning. */
|
|
355
|
+
_fragmentDepth = 0;
|
|
356
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
357
|
+
use(fragment) {
|
|
358
|
+
return this.authoring(() => {
|
|
359
|
+
this.assertNotBuilt("use");
|
|
360
|
+
if (typeof fragment !== "function") {
|
|
361
|
+
throw new Error(`workflow.use() on "${this._meta.id}": a fragment function is required`);
|
|
362
|
+
}
|
|
363
|
+
// The fragment gets a TIP-GUARDED FACADE, not the raw builder — the
|
|
364
|
+
// same stale-handle hazard branch sides have exists on the trunk: a
|
|
365
|
+
// block-body fragment could chain off a pre-reshape handle and run a
|
|
366
|
+
// check against state that no longer exists (codex S2.13 R3 P2).
|
|
367
|
+
const { facade, tipOf } = WorkflowBuilderImpl.makeTipGuardedFacade(this, `fragment in "${this._meta.id}"`);
|
|
368
|
+
let result;
|
|
369
|
+
this._fragmentDepth += 1;
|
|
370
|
+
try {
|
|
371
|
+
result = fragment(facade);
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
this._fragmentDepth -= 1;
|
|
375
|
+
}
|
|
376
|
+
// An async fragment would add nodes after the chain moved on — same
|
|
377
|
+
// hazard as an async branch side (codex S2.4a R5). Consume the promise
|
|
378
|
+
// first: its later rejection would otherwise surface as unhandled even
|
|
379
|
+
// when the caller catches this throw (codex S2.13 R2 P2).
|
|
380
|
+
if (result && typeof result.then === "function") {
|
|
381
|
+
result.then(() => { }, () => { });
|
|
382
|
+
throw new Error(`workflow.use() on "${this._meta.id}": the fragment must be synchronous — an ` +
|
|
383
|
+
`async fragment loses any steps added after an await`);
|
|
384
|
+
}
|
|
385
|
+
// The CHAINED brand is type-only — a fragment returning a chain built
|
|
386
|
+
// from a DIFFERENT workflow() satisfies the type while redirecting
|
|
387
|
+
// every subsequent trunk call to that other builder and leaving this
|
|
388
|
+
// one half-authored (codex phase4 design-R1). The returned handle must
|
|
389
|
+
// be THIS use()'s facade chain AND its tip (a stale prefix means steps
|
|
390
|
+
// were appended after it — same belt-and-suspenders as branch sides).
|
|
391
|
+
const returnedTip = tipOf(result);
|
|
392
|
+
if (returnedTip === undefined) {
|
|
393
|
+
throw new Error(`workflow.use() on "${this._meta.id}": the fragment must return the chain built ` +
|
|
394
|
+
`from the builder it was given — it returned ${result === undefined ? "undefined" : "a different builder/value"}`);
|
|
395
|
+
}
|
|
396
|
+
if (returnedTip !== this._nodes.length) {
|
|
397
|
+
throw new Error(`workflow.use() on "${this._meta.id}": the fragment returned a stale chain handle — ` +
|
|
398
|
+
`return the value of the LAST chain call`);
|
|
399
|
+
}
|
|
400
|
+
return this.chained();
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
404
|
+
group(idOrMeta, body) {
|
|
405
|
+
return this.authoring(() => {
|
|
406
|
+
this.assertNoTeardown("group");
|
|
407
|
+
const meta = normalizeNodeMeta(idOrMeta, this._nodes.length, this._idPrefix);
|
|
408
|
+
validateNodeTimeout(meta, "group");
|
|
409
|
+
if (typeof body !== "function") {
|
|
410
|
+
throw new Error(`workflow.group() "${meta.id}" requires a body function`);
|
|
411
|
+
}
|
|
412
|
+
const node = {
|
|
413
|
+
kind: "group",
|
|
414
|
+
meta,
|
|
415
|
+
nodes: this.collectSubNodes(body, `${meta.id}.`),
|
|
416
|
+
};
|
|
417
|
+
this._nodes.push(node);
|
|
418
|
+
return this.chained();
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
branch(idOrMeta, opts) {
|
|
422
|
+
return this.authoring(() => {
|
|
423
|
+
this.assertNoTeardown("branch");
|
|
424
|
+
const meta = normalizeNodeMeta(idOrMeta, this._nodes.length, this._idPrefix);
|
|
425
|
+
validateNodeTimeout(meta, "branch");
|
|
426
|
+
// 2-way sugar over the branch-family IR (addendum §9): one predicate case
|
|
427
|
+
// (then) + default (else). Prefix each side's anonymous-fallback ids with
|
|
428
|
+
// the branch id + side so flat outcome/projection ids stay unique (S2.4a R7).
|
|
429
|
+
const node = {
|
|
430
|
+
kind: "branch",
|
|
431
|
+
meta,
|
|
432
|
+
mode: "predicate",
|
|
433
|
+
message: opts.message,
|
|
434
|
+
cases: [
|
|
435
|
+
{
|
|
436
|
+
when: this.buildBranchPredicate(opts),
|
|
437
|
+
label: "then",
|
|
438
|
+
nodes: this.collectSubNodes(opts.then, `${meta.id}.then.`),
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
default: opts.else ? this.collectSubNodes(opts.else, `${meta.id}.else.`) : undefined,
|
|
442
|
+
};
|
|
443
|
+
this._nodes.push(node);
|
|
444
|
+
return this.chained();
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
// Broad impls satisfy the interface signatures; call sites type-check against
|
|
448
|
+
// the precise interface, not these.
|
|
449
|
+
switch(idOrMeta, opts) {
|
|
450
|
+
return this.authoring(() => {
|
|
451
|
+
this.assertNoTeardown("switch");
|
|
452
|
+
const meta = normalizeNodeMeta(idOrMeta, this._nodes.length, this._idPrefix);
|
|
453
|
+
validateNodeTimeout(meta, "switch");
|
|
454
|
+
const node = {
|
|
455
|
+
kind: "branch",
|
|
456
|
+
meta,
|
|
457
|
+
...this.buildFamilyDecision("switch", meta.id, opts),
|
|
458
|
+
default: opts.default
|
|
459
|
+
? this.collectSubNodes(opts.default, `${meta.id}.default.`)
|
|
460
|
+
: undefined, // optional = identity pass-through (addendum §9 #4)
|
|
461
|
+
};
|
|
462
|
+
this._nodes.push(node);
|
|
463
|
+
return this.chained();
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
route(idOrMeta, opts) {
|
|
467
|
+
return this.authoring(() => {
|
|
468
|
+
this.assertNoTeardown("route");
|
|
469
|
+
const meta = normalizeNodeMeta(idOrMeta, this._nodes.length, this._idPrefix);
|
|
470
|
+
validateNodeTimeout(meta, "route");
|
|
471
|
+
// route's default is REQUIRED (a no-match must be defined — §9 #5); the
|
|
472
|
+
// types enforce it, but `as any`/JS callers can omit it.
|
|
473
|
+
if (typeof opts.default !== "function") {
|
|
474
|
+
throw new Error(`workflow.route() "${meta.id}" requires a \`default\` case — a no-match path must be defined`);
|
|
475
|
+
}
|
|
476
|
+
const node = {
|
|
477
|
+
kind: "branch",
|
|
478
|
+
meta,
|
|
479
|
+
...this.buildFamilyDecision("route", meta.id, opts),
|
|
480
|
+
default: this.collectSubNodes(opts.default, `${meta.id}.default.`),
|
|
481
|
+
terminal: true,
|
|
482
|
+
};
|
|
483
|
+
this._nodes.push(node);
|
|
484
|
+
// The tree is terminal: only teardown/build may follow (§9 #5).
|
|
485
|
+
this._terminated = `route "${meta.id}"`;
|
|
486
|
+
return this;
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
// Shared switch/route decision lowering: value mode (on lens + literal table,
|
|
490
|
+
// === match) XOR predicate mode (ordered L2, first-match). Case sub-graphs
|
|
491
|
+
// collect under `<id>.<label>.` prefixes; labels derive from explicit label →
|
|
492
|
+
// String(value) → positional case-N.
|
|
493
|
+
buildFamilyDecision(method, nodeId, opts) {
|
|
494
|
+
if (!Array.isArray(opts.cases) || opts.cases.length === 0) {
|
|
495
|
+
throw new Error(`workflow.${method}() "${nodeId}" requires at least one case`);
|
|
496
|
+
}
|
|
497
|
+
const valueMode = typeof opts.on === "function";
|
|
498
|
+
const cases = opts.cases.map((c, i) => {
|
|
499
|
+
const raw = c;
|
|
500
|
+
if (valueMode) {
|
|
501
|
+
const v = raw.value;
|
|
502
|
+
if (v !== null && typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") {
|
|
503
|
+
throw new Error(`workflow.${method}() "${nodeId}" case ${i}: value mode requires a JSON-scalar \`value\` ` +
|
|
504
|
+
`(string | number | boolean | null); got ${typeof v}`);
|
|
505
|
+
}
|
|
506
|
+
// NaN never matches under === and non-finite numbers serialize as null
|
|
507
|
+
// in JSON metadata — reject before they poison the case table (codex
|
|
508
|
+
// S2.8 R1 P2).
|
|
509
|
+
if (typeof v === "number" && !Number.isFinite(v)) {
|
|
510
|
+
throw new Error(`workflow.${method}() "${nodeId}" case ${i}: \`value\` must be a finite number — ` +
|
|
511
|
+
`${String(v)} never matches (===) and is not JSON-safe`);
|
|
512
|
+
}
|
|
513
|
+
const label = raw.label ?? String(v);
|
|
514
|
+
return { value: v, label, nodes: this.collectSubNodes(raw.then, `${nodeId}.${label}.`) };
|
|
515
|
+
}
|
|
516
|
+
if (typeof raw.when !== "function") {
|
|
517
|
+
throw new Error(`workflow.${method}() "${nodeId}" case ${i}: predicate mode requires a declarative \`when\``);
|
|
518
|
+
}
|
|
519
|
+
const pred = raw.when(predicateScope());
|
|
520
|
+
// switch/route case predicates are L2-only — a clean decision table
|
|
521
|
+
// (addendum §9 #4); an opaque N-way gate is a nested branch+whenRuntime.
|
|
522
|
+
assertL2Predicate(pred, method);
|
|
523
|
+
const label = raw.label ?? `case-${i}`;
|
|
524
|
+
return { when: pred, label, nodes: this.collectSubNodes(raw.then, `${nodeId}.${label}.`) };
|
|
525
|
+
});
|
|
526
|
+
// Duplicate value-mode literals would make later cases unreachable — fail
|
|
527
|
+
// fast (mirrors the unreachable-case spirit of a real switch).
|
|
528
|
+
if (valueMode) {
|
|
529
|
+
const seen = new Set();
|
|
530
|
+
for (const c of cases) {
|
|
531
|
+
const key = `${typeof c.value}:${String(c.value)}`;
|
|
532
|
+
if (seen.has(key)) {
|
|
533
|
+
throw new Error(`workflow.${method}() "${nodeId}": duplicate case value ${JSON.stringify(c.value)} — later case is unreachable`);
|
|
534
|
+
}
|
|
535
|
+
seen.add(key);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// Labels must be unique: they prefix anonymous child ids and identify the
|
|
539
|
+
// taken case in branch_decision events — `value: 1` vs `value: "1"` both
|
|
540
|
+
// stringify to "1", and explicit labels can collide too (codex S2.8 R1 P2).
|
|
541
|
+
const seenLabels = new Set();
|
|
542
|
+
for (const c of cases) {
|
|
543
|
+
// "default" is reserved: the fallback side collects under the same
|
|
544
|
+
// `${nodeId}.default.` prefix, so a case labeled "default" (e.g.
|
|
545
|
+
// `value: "default"`) would collide with it (codex S2.8 R2 P2).
|
|
546
|
+
if (c.label === "default") {
|
|
547
|
+
throw new Error(`workflow.${method}() "${nodeId}": case label "default" is reserved for the ` +
|
|
548
|
+
`fallback side — give the case an explicit different \`label\``);
|
|
549
|
+
}
|
|
550
|
+
if (seenLabels.has(c.label)) {
|
|
551
|
+
throw new Error(`workflow.${method}() "${nodeId}": duplicate case label "${c.label}" — ` +
|
|
552
|
+
`labels prefix child node ids and identify the taken case; give the ` +
|
|
553
|
+
`colliding case an explicit distinct \`label\``);
|
|
554
|
+
}
|
|
555
|
+
seenLabels.add(c.label);
|
|
556
|
+
}
|
|
557
|
+
if (valueMode) {
|
|
558
|
+
// S2.18 lens-purity: `on` passes the same P0 selector gate as every
|
|
559
|
+
// predicate lens, and only its EXTRACTED path is kept — the runtime
|
|
560
|
+
// dispatches via safe path resolution over onPath, so projection and
|
|
561
|
+
// execution share one truth. A classification expression (ternary,
|
|
562
|
+
// comparison, call) is rejected here: make it explicit — `.compute()`
|
|
563
|
+
// a discriminant field first, or use predicate mode.
|
|
564
|
+
const onPath = selectorPath(opts.on);
|
|
565
|
+
return { mode: "value", onPath, cases };
|
|
566
|
+
}
|
|
567
|
+
return { mode: "predicate", cases };
|
|
568
|
+
}
|
|
569
|
+
// Broad impl satisfies the interface's conditional-tuple signature; call sites
|
|
570
|
+
// type-check against the precise interface signature, not this one.
|
|
571
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
572
|
+
poll(idOrMeta, ref, ...rest) {
|
|
573
|
+
return this.authoring(() => {
|
|
574
|
+
this.assertNoTeardown("poll");
|
|
575
|
+
const meta = normalizeNodeMeta(idOrMeta, this._nodes.length, this._idPrefix);
|
|
576
|
+
validateNodeTimeout(meta, "poll");
|
|
577
|
+
if (ref?.direction === "inbound") {
|
|
578
|
+
return this.inboundPoll(meta, ref, rest[0]);
|
|
579
|
+
}
|
|
580
|
+
const opts = rest[0];
|
|
581
|
+
// Inbound-only vocabulary on an outbound poll = a direction mixup —
|
|
582
|
+
// reject loudly instead of silently ignoring the receiver lens.
|
|
583
|
+
const stray = opts;
|
|
584
|
+
if (stray && (stray.via !== undefined || stray.correlate !== undefined)) {
|
|
585
|
+
throw new Error(`workflow.poll() "${meta.id}": \`via\`/\`correlate\` are inbound-poll options, ` +
|
|
586
|
+
`but case "${ref?.contractId}.${ref?.caseKey}" is outbound — did you mean an ` +
|
|
587
|
+
`inboundCase (direction: "inbound")?`);
|
|
588
|
+
}
|
|
589
|
+
// The type system requires opts (until XOR untilRuntime), but JS / `as any`
|
|
590
|
+
// callers can omit it — an exit-predicate-less poll would loop to exhaustion
|
|
591
|
+
// for nothing, so fail fast here.
|
|
592
|
+
if (!opts || (typeof opts.until !== "function" && typeof opts.untilRuntime !== "function")) {
|
|
593
|
+
throw new Error(`workflow.poll() "${meta.id}" requires an exit predicate — \`until\` (declarative) or \`untilRuntime\``);
|
|
594
|
+
}
|
|
595
|
+
validatePollBounds({
|
|
596
|
+
timeout: opts.timeout,
|
|
597
|
+
maxAttempts: opts.maxAttempts,
|
|
598
|
+
perAttemptTimeout: opts.perAttemptTimeout,
|
|
599
|
+
every: opts.every,
|
|
600
|
+
backoff: opts.backoff,
|
|
601
|
+
}, meta.id);
|
|
602
|
+
const node = {
|
|
603
|
+
kind: "poll",
|
|
604
|
+
meta,
|
|
605
|
+
ref,
|
|
606
|
+
in: opts.in,
|
|
607
|
+
out: opts.out,
|
|
608
|
+
accept: opts.accept,
|
|
609
|
+
until: this.buildPollUntil(opts),
|
|
610
|
+
message: opts.message,
|
|
611
|
+
every: opts.every ?? DEFAULT_EVERY_MS,
|
|
612
|
+
backoff: opts.backoff ?? 1,
|
|
613
|
+
timeoutMs: opts.timeout,
|
|
614
|
+
perAttemptTimeoutMs: opts.perAttemptTimeout,
|
|
615
|
+
maxAttempts: opts.maxAttempts,
|
|
616
|
+
};
|
|
617
|
+
this._nodes.push(node);
|
|
618
|
+
return this;
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Inbound await (inbound-contract-design §9.3, slice I3): the counterparty
|
|
623
|
+
* calls US — one attempt scans `via(state).deliveries()` through the
|
|
624
|
+
* adapter matcher; matched IS the exit (no `until`), nothing is sent (no
|
|
625
|
+
* `in`/`accept`). `timeout` defaults from the case's `expect.within`
|
|
626
|
+
* (evidence + default bound, not an independent failure condition —
|
|
627
|
+
* §9.4a #5). All lenses pass the selector-source gate at build time.
|
|
628
|
+
*/
|
|
629
|
+
inboundPoll(meta, ref, rawOpts) {
|
|
630
|
+
const opts = (rawOpts ?? {});
|
|
631
|
+
const where = `workflow.poll() "${meta.id}" (inbound "${ref.contractId}.${ref.caseKey}")`;
|
|
632
|
+
// Outbound-only vocabulary is rejected loudly — silent acceptance would
|
|
633
|
+
// read as "this knob works" when nothing is ever sent or predicated.
|
|
634
|
+
const rejected = [
|
|
635
|
+
["until", "matched IS the exit — an inbound poll has no response to predicate over"],
|
|
636
|
+
["untilRuntime", "matched IS the exit"],
|
|
637
|
+
["in", "nothing is sent — an inbound case has no logical input"],
|
|
638
|
+
["accept", "alternate outcome keys are outbound response vocabulary"],
|
|
639
|
+
["message", "no opaque predicate exists to label"],
|
|
640
|
+
];
|
|
641
|
+
for (const [key, why] of rejected) {
|
|
642
|
+
if (opts[key] !== undefined) {
|
|
643
|
+
throw new Error(`${where}: \`${key}\` is not allowed on an inbound poll — ${why}.`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (typeof opts.via !== "function") {
|
|
647
|
+
throw new Error(`${where}: \`via\` is required — a pure lens selecting the ReceiverHandle ` +
|
|
648
|
+
`from workflow state, e.g. \`via: (s) => s.inbox\` (design §9.3).`);
|
|
649
|
+
}
|
|
650
|
+
const viaPath = selectorPath(opts.via);
|
|
651
|
+
let correlate;
|
|
652
|
+
if (opts.correlate !== undefined) {
|
|
653
|
+
const { event, state: stateLens } = opts.correlate;
|
|
654
|
+
// §9.3: correlation is an === compare — BOTH lenses must be defined or
|
|
655
|
+
// the comparison degenerates to undefined === x.
|
|
656
|
+
if (typeof event !== "function" || typeof stateLens !== "function") {
|
|
657
|
+
throw new Error(`${where}: \`correlate\` needs BOTH lenses — { event: (e) => ..., state: (s) => ... } (design §9.3).`);
|
|
658
|
+
}
|
|
659
|
+
correlate = {
|
|
660
|
+
event,
|
|
661
|
+
eventPath: selectorPath(event),
|
|
662
|
+
state: stateLens,
|
|
663
|
+
statePath: selectorPath(stateLens),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
// `expect.within` (duck-read; the adapter owns the case shape) is the
|
|
667
|
+
// poll's default total timeout.
|
|
668
|
+
const within = (ref.contract._spec
|
|
669
|
+
?.cases?.[ref.caseKey]?.expect?.within);
|
|
670
|
+
const timeout = opts.timeout ?? within;
|
|
671
|
+
validatePollBounds({
|
|
672
|
+
timeout,
|
|
673
|
+
maxAttempts: opts.maxAttempts,
|
|
674
|
+
perAttemptTimeout: opts.perAttemptTimeout,
|
|
675
|
+
every: opts.every,
|
|
676
|
+
backoff: opts.backoff,
|
|
677
|
+
}, meta.id);
|
|
678
|
+
const node = {
|
|
679
|
+
kind: "poll",
|
|
680
|
+
meta,
|
|
681
|
+
ref,
|
|
682
|
+
inbound: {
|
|
683
|
+
via: opts.via,
|
|
684
|
+
viaPath,
|
|
685
|
+
...(correlate ? { correlate } : {}),
|
|
686
|
+
...(within !== undefined ? { withinMs: within } : {}),
|
|
687
|
+
},
|
|
688
|
+
out: opts.out,
|
|
689
|
+
every: opts.every ?? DEFAULT_EVERY_MS,
|
|
690
|
+
backoff: opts.backoff ?? 1,
|
|
691
|
+
timeoutMs: timeout,
|
|
692
|
+
perAttemptTimeoutMs: opts.perAttemptTimeout,
|
|
693
|
+
maxAttempts: opts.maxAttempts,
|
|
694
|
+
};
|
|
695
|
+
this._nodes.push(node);
|
|
696
|
+
return this;
|
|
697
|
+
}
|
|
698
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
699
|
+
pollAction(idOrMeta, fn, ...rest) {
|
|
700
|
+
return this.authoring(() => {
|
|
701
|
+
this.assertNoTeardown("pollAction");
|
|
702
|
+
const meta = normalizeNodeMeta(idOrMeta, this._nodes.length, this._idPrefix);
|
|
703
|
+
validateNodeTimeout(meta, "poll");
|
|
704
|
+
const opts = rest[0];
|
|
705
|
+
if (typeof fn !== "function") {
|
|
706
|
+
throw new Error(`workflow.pollAction() "${meta.id}" requires an attempt function`);
|
|
707
|
+
}
|
|
708
|
+
if (!opts || (typeof opts.until !== "function" && typeof opts.untilRuntime !== "function")) {
|
|
709
|
+
throw new Error(`workflow.pollAction() "${meta.id}" requires an exit predicate — \`until\` (declarative) or \`untilRuntime\``);
|
|
710
|
+
}
|
|
711
|
+
validatePollBounds({
|
|
712
|
+
timeout: opts.timeout,
|
|
713
|
+
maxAttempts: opts.maxAttempts,
|
|
714
|
+
perAttemptTimeout: opts.perAttemptTimeout,
|
|
715
|
+
every: opts.every,
|
|
716
|
+
backoff: opts.backoff,
|
|
717
|
+
}, meta.id);
|
|
718
|
+
const node = {
|
|
719
|
+
kind: "poll",
|
|
720
|
+
meta,
|
|
721
|
+
attemptFn: fn,
|
|
722
|
+
project: opts.project,
|
|
723
|
+
out: opts.out,
|
|
724
|
+
until: this.buildPollUntil(opts),
|
|
725
|
+
message: opts.message,
|
|
726
|
+
every: opts.every ?? DEFAULT_EVERY_MS,
|
|
727
|
+
backoff: opts.backoff ?? 1,
|
|
728
|
+
timeoutMs: opts.timeout,
|
|
729
|
+
perAttemptTimeoutMs: opts.perAttemptTimeout,
|
|
730
|
+
maxAttempts: opts.maxAttempts,
|
|
731
|
+
};
|
|
732
|
+
this._nodes.push(node);
|
|
733
|
+
return this;
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
// Build the poll exit predicate: an L2 declarative tree over the RESPONSE
|
|
737
|
+
// (validated + projectable) or an opaque wrapper. Mirrors buildBranchPredicate,
|
|
738
|
+
// including the conservative `sync: false` (we cannot statically prove an
|
|
739
|
+
// untilRuntime fn is synchronous — codex S2.4a P2 applies identically here).
|
|
740
|
+
buildPollUntil(opts) {
|
|
741
|
+
if (opts.until) {
|
|
742
|
+
const pred = opts.until(predicateScope());
|
|
743
|
+
assertL2Predicate(pred, "poll");
|
|
744
|
+
return pred;
|
|
745
|
+
}
|
|
746
|
+
if (typeof opts.message !== "string" || opts.message.length === 0) {
|
|
747
|
+
throw new Error("workflow.poll() with untilRuntime requires a non-empty `message`");
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
kind: "opaque",
|
|
751
|
+
sync: false,
|
|
752
|
+
fn: opts.untilRuntime,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
// Build the branch predicate: an L2 declarative tree (validated + projectable) or
|
|
756
|
+
// an L1/L0 opaque wrapper. Reuses the flow condition model (no second predicate
|
|
757
|
+
// engine). `whenRuntime` requires a `message` (projection/diagnostics).
|
|
758
|
+
buildBranchPredicate(opts) {
|
|
759
|
+
if (opts.when) {
|
|
760
|
+
const pred = opts.when(predicateScope());
|
|
761
|
+
assertL2Predicate(pred, "branch");
|
|
762
|
+
return pred;
|
|
763
|
+
}
|
|
764
|
+
const wr = opts.whenRuntime;
|
|
765
|
+
if (typeof opts.message !== "string" || opts.message.length === 0) {
|
|
766
|
+
throw new Error("workflow.branch() with whenRuntime requires a non-empty `message`");
|
|
767
|
+
}
|
|
768
|
+
return {
|
|
769
|
+
kind: "opaque",
|
|
770
|
+
// whenRuntime's return type allows `Promise<boolean>`, so we CANNOT statically
|
|
771
|
+
// prove it's synchronous — inferring sync from the constructor name is dishonest
|
|
772
|
+
// for `() => Promise.resolve(x)`. Project conservatively as L0 / may-do-async-IO
|
|
773
|
+
// (codex S2.4a P2). A future split (whenRuntime L1 vs whenRuntimeAsync L0) could
|
|
774
|
+
// recover the L1 distinction; the runtime awaits either way.
|
|
775
|
+
sync: false,
|
|
776
|
+
fn: wr,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
// Collect a then/else sub-graph by running its callback on a fresh child builder.
|
|
780
|
+
// A branch side may NOT declare setup/teardown (those are workflow-scoped).
|
|
781
|
+
/**
|
|
782
|
+
* Tip-guarded facade over a builder (codex S2.8 R3 / S2.13 R3): the CHAINED
|
|
783
|
+
* brand catches `return b`, but a FORKED chain needs a runtime guard — e.g.
|
|
784
|
+
* `b.compute(reshape); return b.check(noop)` compiles (the second chain is
|
|
785
|
+
* S→S) while the discarded reshaping chain still lands in the node list.
|
|
786
|
+
* Every facade remembers the chain length at its creation; a node call from
|
|
787
|
+
* a handle whose tip is no longer the chain's end IS the fork — throw at
|
|
788
|
+
* authoring time. Sequential chains (incl. `const c = b.x(); return c`)
|
|
789
|
+
* pass untouched. Shared by branch-family sides (collectSubNodes) AND
|
|
790
|
+
* `.use()` fragments — the same stale-handle hazard exists on the trunk.
|
|
791
|
+
*/
|
|
792
|
+
static makeTipGuardedFacade(target, label) {
|
|
793
|
+
const tips = new WeakMap();
|
|
794
|
+
const make = (tip) => {
|
|
795
|
+
const proxy = new Proxy(target, {
|
|
796
|
+
get: (t, prop) => {
|
|
797
|
+
const v = Reflect.get(t, prop, t);
|
|
798
|
+
if (typeof v !== "function")
|
|
799
|
+
return v;
|
|
800
|
+
return (...args) => {
|
|
801
|
+
if (target._nodes.length !== tip) {
|
|
802
|
+
throw new Error(`workflow ${label} forked its chain — continue from the ` +
|
|
803
|
+
`latest chain return value; don't call off an earlier handle`);
|
|
804
|
+
}
|
|
805
|
+
const out = v.apply(t, args);
|
|
806
|
+
// A node call advanced the chain → hand back a fresh tip handle.
|
|
807
|
+
if (target._nodes.length !== tip)
|
|
808
|
+
return make(target._nodes.length);
|
|
809
|
+
// A NON-advancing chain call (meta/setup/teardown) returns the
|
|
810
|
+
// raw builder — re-wrap at the same tip so the chain never
|
|
811
|
+
// escapes the facade, else the final return-value validation
|
|
812
|
+
// would reject a perfectly valid fragment that starts with
|
|
813
|
+
// metadata (codex S2.13 R4 P2). Non-builder results (e.g. a
|
|
814
|
+
// guard's return) pass through untouched.
|
|
815
|
+
return out === t ? make(tip) : out;
|
|
816
|
+
};
|
|
817
|
+
},
|
|
818
|
+
});
|
|
819
|
+
tips.set(proxy, tip);
|
|
820
|
+
return proxy;
|
|
821
|
+
};
|
|
822
|
+
// The facade starts at the CURRENT chain end — a trunk facade (.use) is
|
|
823
|
+
// created mid-chain, so tip 0 would instantly false-positive the fork
|
|
824
|
+
// guard on the first call; a child builder starts empty so this is 0.
|
|
825
|
+
return {
|
|
826
|
+
facade: make(target._nodes.length),
|
|
827
|
+
tipOf: (v) => (typeof v === "object" && v !== null ? tips.get(v) : undefined),
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
collectSubNodes(fn, idPrefix) {
|
|
831
|
+
const child = new WorkflowBuilderImpl({ id: this._meta.id }, idPrefix, true);
|
|
832
|
+
const { facade, tipOf } = WorkflowBuilderImpl.makeTipGuardedFacade(child, `side under "${idPrefix}"`);
|
|
833
|
+
const result = fn(facade);
|
|
834
|
+
// An async then/else callback would snapshot child._nodes BEFORE its post-await
|
|
835
|
+
// steps are added — silently dropping them. Reject thenables (codex S2.4a R5).
|
|
836
|
+
if (result && typeof result.then === "function") {
|
|
837
|
+
throw new Error("workflow.branch() then/else callback must be synchronous — an async callback " +
|
|
838
|
+
"loses any steps added after an await");
|
|
839
|
+
}
|
|
840
|
+
// The callback MUST return its own facade chain (codex S2.14 R2 P2): a
|
|
841
|
+
// body closing over a DIFFERENT builder (`group("g", () => other.compute(
|
|
842
|
+
// ...))`) type-checks when shapes match, but the sub-graph would be EMPTY
|
|
843
|
+
// while the other builder mutates — silently dropping every member. And a
|
|
844
|
+
// returned handle that is not the chain's tip means steps were added
|
|
845
|
+
// after it (stale prefix — codex S2.8 R3).
|
|
846
|
+
const returnedTip = tipOf(result);
|
|
847
|
+
if (returnedTip === undefined) {
|
|
848
|
+
throw new Error(`workflow sub-graph under "${idPrefix}": the callback must return the chain built ` +
|
|
849
|
+
`from the builder it was given — it returned ` +
|
|
850
|
+
`${result === undefined ? "undefined" : "a different builder/value"}`);
|
|
851
|
+
}
|
|
852
|
+
if (returnedTip !== child._nodes.length) {
|
|
853
|
+
throw new Error(`workflow side under "${idPrefix}" returned a stale chain handle — ` +
|
|
854
|
+
`return the value of the LAST chain call`);
|
|
855
|
+
}
|
|
856
|
+
if (child._setup || child._teardown) {
|
|
857
|
+
throw new Error("a workflow sub-graph callback (branch side / group body) cannot declare setup/teardown");
|
|
858
|
+
}
|
|
859
|
+
// A poisoned child holds a half-authored side (the callback caught an
|
|
860
|
+
// authoring error on it) — accepting its nodes would smuggle the partial
|
|
861
|
+
// graph past the parent's poison protection (codex S2.5 R4 P2). This throw
|
|
862
|
+
// runs inside the parent's authoring() wrapper, so the parent poisons too.
|
|
863
|
+
if (child._poisoned !== undefined) {
|
|
864
|
+
const causeMsg = child._poisoned instanceof Error ? child._poisoned.message : String(child._poisoned);
|
|
865
|
+
throw new Error(`workflow sub-graph (branch side / group body) is half-authored — a sub-builder call failed: ${causeMsg}`);
|
|
866
|
+
}
|
|
867
|
+
return child._nodes.slice();
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Node ids must be UNIQUE across the whole graph (S2.13 / phase4 §1.3):
|
|
871
|
+
* evidence addressing (§17 #9), summary folding (per-nodeId last-wins) and
|
|
872
|
+
* Cloud rendering all assume it, and a fragment used twice (.use) would
|
|
873
|
+
* silently collide. Walks the trunk plus branch-family cases/default and
|
|
874
|
+
* group children; throws naming both occurrences.
|
|
875
|
+
*/
|
|
876
|
+
static assertUniqueNodeIds(nodes, wfId) {
|
|
877
|
+
const seen = new Map();
|
|
878
|
+
const visit = (node, path) => {
|
|
879
|
+
const where = path ? `${path} > ${node.meta.id}` : node.meta.id;
|
|
880
|
+
const prior = seen.get(node.meta.id);
|
|
881
|
+
if (prior !== undefined) {
|
|
882
|
+
throw new Error(`workflow "${wfId}": duplicate node id "${node.meta.id}" (first at ${prior}, ` +
|
|
883
|
+
`again at ${where}) — node ids address evidence and must be unique; a reused ` +
|
|
884
|
+
`fragment should parameterize its ids (e.g. makeLogin(prefix))`);
|
|
885
|
+
}
|
|
886
|
+
seen.set(node.meta.id, where);
|
|
887
|
+
if (node.kind === "branch") {
|
|
888
|
+
for (const c of node.cases)
|
|
889
|
+
for (const n of c.nodes)
|
|
890
|
+
visit(n, where);
|
|
891
|
+
for (const n of node.default ?? [])
|
|
892
|
+
visit(n, where);
|
|
893
|
+
}
|
|
894
|
+
else if (node.kind === "group") {
|
|
895
|
+
for (const n of node.nodes)
|
|
896
|
+
visit(n, where);
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
for (const n of nodes)
|
|
900
|
+
visit(n, "");
|
|
901
|
+
}
|
|
902
|
+
build() {
|
|
903
|
+
// A branch/poll sub-builder is not an independent workflow: building one
|
|
904
|
+
// would register a bogus top-level entry under the parent id carrying only
|
|
905
|
+
// the side's nodes (codex S2.5 R2 P2). The sub-graph belongs to its parent.
|
|
906
|
+
if (this._isChild) {
|
|
907
|
+
throw new Error("workflow.build() cannot be called on a branch/poll sub-builder — " +
|
|
908
|
+
"return the builder chain from the callback instead");
|
|
909
|
+
}
|
|
910
|
+
// A fragment finalizing its host would register the workflow before
|
|
911
|
+
// use() can validate the return — and `_built` would then defeat the
|
|
912
|
+
// poison path (codex S2.13 R2 P2).
|
|
913
|
+
if (this._fragmentDepth > 0) {
|
|
914
|
+
throw new Error(`workflow "${this._meta.id}": build() cannot be called inside a .use() fragment — ` +
|
|
915
|
+
`return the chain; the workflow's author builds it`);
|
|
916
|
+
}
|
|
917
|
+
if (this._built)
|
|
918
|
+
return this._built; // idempotent — one Test, one registration
|
|
919
|
+
// A poisoned builder holds a half-authored graph (a chained call threw and
|
|
920
|
+
// the author caught it) — registering/executing it would be a phantom
|
|
921
|
+
// workflow (codex S2.5 R3 P2).
|
|
922
|
+
if (this._poisoned !== undefined) {
|
|
923
|
+
const causeMsg = this._poisoned instanceof Error ? this._poisoned.message : String(this._poisoned);
|
|
924
|
+
throw new Error(`workflow "${this._meta.id}" cannot build — an earlier authoring call failed: ${causeMsg}`);
|
|
925
|
+
}
|
|
926
|
+
try {
|
|
927
|
+
WorkflowBuilderImpl.assertUniqueNodeIds(this._nodes, this._meta.id);
|
|
928
|
+
}
|
|
929
|
+
catch (e) {
|
|
930
|
+
// Build-time validation failure must POISON the builder: the queued
|
|
931
|
+
// auto-build would otherwise call build() again on the next microtask
|
|
932
|
+
// and throw UNHANDLED even though the caller caught this one
|
|
933
|
+
// (codex S2.13 R1 P1).
|
|
934
|
+
this._poisoned ??= e;
|
|
935
|
+
throw e;
|
|
936
|
+
}
|
|
937
|
+
const meta = this._meta;
|
|
938
|
+
// The single simple-shaped Test that REPRESENTS the graph (mirrors the flow's
|
|
939
|
+
// wrapping, contract-core.ts:1236). Invocation inversion (plan 0007): the wrapper
|
|
940
|
+
// is a first-class workflow DEF — it carries the Workflow IR but does NOT execute
|
|
941
|
+
// itself. The host run-loop detects `__glubean_kind === "workflow"`, reads the IR
|
|
942
|
+
// off `__glubean_workflow`, and drives it through the (host-owned) workflow
|
|
943
|
+
// executor — exactly like RunnerCore.run() dispatches simple vs steps. The SDK
|
|
944
|
+
// stays authoring-only: it defines the workflow's SHAPE; the host runs it. The
|
|
945
|
+
// verdict (skipped → ctx.skip(reason); failed → rethrow the cause) is applied by
|
|
946
|
+
// the host, not here.
|
|
947
|
+
const wfTest = {
|
|
948
|
+
meta: {
|
|
949
|
+
id: meta.id,
|
|
950
|
+
name: meta.name ?? meta.id,
|
|
951
|
+
...(meta.tags ? { tags: meta.tags } : {}),
|
|
952
|
+
...(meta.description ? { description: meta.description } : {}),
|
|
953
|
+
// `WorkflowMeta.skip: string` (the reason) → `TestMeta.deferred: string`
|
|
954
|
+
// mirrors the flow convention so reporters render the reason consistently.
|
|
955
|
+
...(meta.skip !== undefined ? { deferred: meta.skip } : {}),
|
|
956
|
+
...(meta.only !== undefined ? { only: meta.only } : {}),
|
|
957
|
+
},
|
|
958
|
+
type: "simple",
|
|
959
|
+
// No `fn`: a built workflow is a DEF the host run-loop owns, not a self-
|
|
960
|
+
// executing test (plan 0007 invocation inversion). The IR is attached below.
|
|
961
|
+
};
|
|
962
|
+
// Mark the wrapper so the host routes it to the workflow executor (and the
|
|
963
|
+
// runner-on-engine gate `engineSupports` keeps it OFF the browser-safe engine:
|
|
964
|
+
// workflow stays node-only / cloud — plan 0005 §scope; codex Phase-3 P2).
|
|
965
|
+
wfTest.__glubean_kind = "workflow";
|
|
966
|
+
// The handle IS both the Workflow IR (runWorkflow/projectWorkflow consume it
|
|
967
|
+
// directly) and a one-element Test[] (the runner's array resolution discovers
|
|
968
|
+
// it) — the same dual shape as contract.flow()'s FlowContract.
|
|
969
|
+
const handle = Object.assign([wfTest], {
|
|
970
|
+
__glubean_type: "workflow",
|
|
971
|
+
meta,
|
|
972
|
+
setup: this._setup,
|
|
973
|
+
...(this._setupTimeoutMs !== undefined ? { setupTimeoutMs: this._setupTimeoutMs } : {}),
|
|
974
|
+
...(this._setupNote !== undefined ? { setupNote: this._setupNote } : {}),
|
|
975
|
+
teardown: this._teardown,
|
|
976
|
+
...(this._teardownTimeoutMs !== undefined
|
|
977
|
+
? { teardownTimeoutMs: this._teardownTimeoutMs }
|
|
978
|
+
: {}),
|
|
979
|
+
...(this._teardownNote !== undefined ? { teardownNote: this._teardownNote } : {}),
|
|
980
|
+
nodes: this._nodes.slice(),
|
|
981
|
+
});
|
|
982
|
+
// Invocation inversion (plan 0007): attach the Workflow IR to the wrapper so the
|
|
983
|
+
// host run-loop can drive `runWorkflow(handle, ctx)` itself (the handle IS a
|
|
984
|
+
// `Workflow`). Non-enumerable so the wrapper still serializes/spreads as a plain
|
|
985
|
+
// simple Test — and so the wfTest↔handle cycle (handle[0] === wfTest) never leaks
|
|
986
|
+
// into JSON. Slice 1 of moving the executor out of the SDK (plan 0007).
|
|
987
|
+
Object.defineProperty(wfTest, "__glubean_workflow", {
|
|
988
|
+
value: handle,
|
|
989
|
+
enumerable: false,
|
|
990
|
+
configurable: true,
|
|
991
|
+
});
|
|
992
|
+
// Pre-compute the graded projection ONCE; the registry entry and the
|
|
993
|
+
// handle's `_projection` (the scanner's dep-free read, mirroring
|
|
994
|
+
// FlowContract._extracted — S2.6) share the same object. A data-driven
|
|
995
|
+
// member carries its template id (addendum §3: rows share ONE structure).
|
|
996
|
+
// templateId/groupId/parallel ride projectWorkflow itself (from meta) so
|
|
997
|
+
// every projection path agrees, not just this cached one (codex R5 P2).
|
|
998
|
+
const projection = projectWorkflow(handle);
|
|
999
|
+
Object.assign(handle, { _projection: projection });
|
|
1000
|
+
// Register for scanner discovery with the full graded projection (§7) —
|
|
1001
|
+
// scanner/Cloud/agents read grades + call identity without executing.
|
|
1002
|
+
// workflow.each defers this until every row validates (codex S2.12 R3 P2:
|
|
1003
|
+
// a rejected matrix must not leave half its members in the registry).
|
|
1004
|
+
const register = () => registerTest({
|
|
1005
|
+
id: meta.id,
|
|
1006
|
+
name: meta.name ?? meta.id,
|
|
1007
|
+
type: "simple",
|
|
1008
|
+
...(meta.tags ? { tags: meta.tags } : {}),
|
|
1009
|
+
...(meta.description ? { description: meta.description } : {}),
|
|
1010
|
+
...(meta.groupId ? { groupId: meta.groupId } : {}),
|
|
1011
|
+
...(meta.parallel ? { parallel: true } : {}),
|
|
1012
|
+
workflow: projection,
|
|
1013
|
+
});
|
|
1014
|
+
if (this._suppressRegistration) {
|
|
1015
|
+
Object.assign(handle, { _pendingRegistration: register });
|
|
1016
|
+
}
|
|
1017
|
+
else {
|
|
1018
|
+
register();
|
|
1019
|
+
}
|
|
1020
|
+
this._built = handle;
|
|
1021
|
+
return handle;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
function workflowFn(idOrMeta) {
|
|
1025
|
+
const meta = typeof idOrMeta === "string" ? { id: idOrMeta } : { ...idOrMeta };
|
|
1026
|
+
if (typeof meta.id !== "string" || meta.id.length === 0) {
|
|
1027
|
+
throw new Error("workflow(): a non-empty string id is required");
|
|
1028
|
+
}
|
|
1029
|
+
// Engine-owned data-driven fields can only be minted by workflow.each/pick.
|
|
1030
|
+
delete meta.templateId;
|
|
1031
|
+
delete meta.groupId;
|
|
1032
|
+
delete meta.parallel;
|
|
1033
|
+
return new WorkflowBuilderImpl(meta);
|
|
1034
|
+
}
|
|
1035
|
+
function buildEachMembers(rows, meta, factory) {
|
|
1036
|
+
{
|
|
1037
|
+
if (typeof meta?.id !== "string" || meta.id.length === 0) {
|
|
1038
|
+
throw new Error("workflow.each(): a non-empty template id is required");
|
|
1039
|
+
}
|
|
1040
|
+
if (typeof factory !== "function") {
|
|
1041
|
+
throw new Error(`workflow.each() "${meta.id}": a (wf, row) factory is required`);
|
|
1042
|
+
}
|
|
1043
|
+
const filtered = meta.filter
|
|
1044
|
+
? rows.filter((row, index) => meta.filter(row, index))
|
|
1045
|
+
: rows;
|
|
1046
|
+
const tagFieldNames = typeof meta.tagFields === "string" ? [meta.tagFields] : [...(meta.tagFields ?? [])];
|
|
1047
|
+
const staticTags = meta.tags ?? [];
|
|
1048
|
+
// Grouping is parallel-only: an object-table each has _pick on its rows
|
|
1049
|
+
// (the map key) but stays deterministic and ungrouped (codex S2.12 R11).
|
|
1050
|
+
const hasGroup = meta.parallel === true;
|
|
1051
|
+
// Addendum §3 binding rule 2 made executable: row data enters state ONLY
|
|
1052
|
+
// via setup (or closure literals), so every row must project to ONE
|
|
1053
|
+
// structure — identical canonicalHash, one version × N runs. A factory
|
|
1054
|
+
// that shapes the GRAPH from the row (conditional nodes) breaks that
|
|
1055
|
+
// contract and is rejected here, not silently shipped as N blobs.
|
|
1056
|
+
let canonicalStructure;
|
|
1057
|
+
let canonicalRowId;
|
|
1058
|
+
// Row-INVARIANT top-level meta participates too: a factory varying e.g.
|
|
1059
|
+
// `skip` per row via wf.meta() would let pick's collapsed template entry
|
|
1060
|
+
// inherit the first member's deferral and skip runnable examples
|
|
1061
|
+
// (codex S2.12 R14 P2). name/tags are engine-owned (templated per row)
|
|
1062
|
+
// and excluded.
|
|
1063
|
+
// Lifecycle PRESENCE and timeouts are structure too: one row declaring
|
|
1064
|
+
// setup while another doesn't is a different workflow shape even though
|
|
1065
|
+
// projected nodes match (codex S2.12 R15 P2). The setup fn itself may
|
|
1066
|
+
// freely close over row data — only presence/bounds are compared.
|
|
1067
|
+
const structureOf = (handle) => JSON.stringify({
|
|
1068
|
+
nodes: handle._projection.nodes,
|
|
1069
|
+
gradeSummary: handle._projection.gradeSummary,
|
|
1070
|
+
description: handle.meta.description ?? null,
|
|
1071
|
+
skip: handle.meta.skip ?? null,
|
|
1072
|
+
only: handle.meta.only ?? null,
|
|
1073
|
+
extensions: handle.meta.extensions ?? null,
|
|
1074
|
+
setup: handle.setup
|
|
1075
|
+
? { timeoutMs: handle.setupTimeoutMs ?? null, note: handle.setupNote ?? null }
|
|
1076
|
+
: null,
|
|
1077
|
+
teardown: handle.teardown
|
|
1078
|
+
? { timeoutMs: handle.teardownTimeoutMs ?? null, note: handle.teardownNote ?? null }
|
|
1079
|
+
: null,
|
|
1080
|
+
});
|
|
1081
|
+
return filtered.map((row, index) => {
|
|
1082
|
+
const id = interpolateTemplate(meta.id, row, index);
|
|
1083
|
+
const name = meta.name ? interpolateTemplate(meta.name, row, index) : id;
|
|
1084
|
+
const dynamicTags = tagFieldNames
|
|
1085
|
+
.map((field) => {
|
|
1086
|
+
const value = row[field];
|
|
1087
|
+
return value != null ? `${field}:${String(value)}` : null;
|
|
1088
|
+
})
|
|
1089
|
+
.filter((t) => t !== null);
|
|
1090
|
+
const allTags = [...staticTags, ...dynamicTags];
|
|
1091
|
+
const builder = new WorkflowBuilderImpl({
|
|
1092
|
+
...meta,
|
|
1093
|
+
id,
|
|
1094
|
+
name,
|
|
1095
|
+
tags: allTags.length > 0 ? allTags : undefined,
|
|
1096
|
+
templateId: meta.id, // every member carries the shared-structure marker
|
|
1097
|
+
groupId: hasGroup ? meta.id : undefined,
|
|
1098
|
+
parallel: meta.parallel === true ? true : undefined,
|
|
1099
|
+
});
|
|
1100
|
+
builder._suppressRegistration = true; // registered only after ALL rows validate
|
|
1101
|
+
const result = factory(builder, row);
|
|
1102
|
+
// An async factory would add nodes after we build — same hazard as an
|
|
1103
|
+
// async branch side (codex S2.4a R5): reject thenables. Consume the
|
|
1104
|
+
// promise first: its continuation will reject later (the builder is
|
|
1105
|
+
// finalized by then), and an unhandled rejection would bury this
|
|
1106
|
+
// actionable diagnostic (codex S2.12 R4 P3).
|
|
1107
|
+
if (result && typeof result.then === "function") {
|
|
1108
|
+
result.then(() => { }, () => { });
|
|
1109
|
+
throw new Error(`workflow.each() "${meta.id}": the factory must be synchronous — an async ` +
|
|
1110
|
+
`factory loses any steps added after an await`);
|
|
1111
|
+
}
|
|
1112
|
+
const handle = builder.build();
|
|
1113
|
+
const structure = structureOf(handle);
|
|
1114
|
+
if (canonicalStructure === undefined) {
|
|
1115
|
+
canonicalStructure = structure;
|
|
1116
|
+
canonicalRowId = id;
|
|
1117
|
+
}
|
|
1118
|
+
else if (structure !== canonicalStructure) {
|
|
1119
|
+
throw new Error(`workflow.each() "${meta.id}": row ${index} ("${id}") projects a DIFFERENT ` +
|
|
1120
|
+
`structure than "${canonicalRowId}" — all rows must share one projected shape ` +
|
|
1121
|
+
`(addendum §3: row data enters state via setup only; don't shape the graph ` +
|
|
1122
|
+
`from the row — use filter, or split into separate workflows)`);
|
|
1123
|
+
}
|
|
1124
|
+
return handle;
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
function workflowEach(table) {
|
|
1129
|
+
const rows = normalizeEachTable(table);
|
|
1130
|
+
return (meta, factory) => {
|
|
1131
|
+
const members = buildEachMembers(rows, meta, factory);
|
|
1132
|
+
// Every row validated against the canonical structure — register now
|
|
1133
|
+
// (a rejected matrix never reaches the registry; codex S2.12 R3 P2).
|
|
1134
|
+
for (const m of members) {
|
|
1135
|
+
m._pendingRegistration?.();
|
|
1136
|
+
}
|
|
1137
|
+
return members;
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
/** Create a workflow builder. `id` is required (string or `WorkflowMeta`).
|
|
1141
|
+
* `workflow.each(table)(metaTemplate, factory)` authors data-driven matrices
|
|
1142
|
+
* (addendum §3). There is deliberately NO `workflow.pick`: random selection
|
|
1143
|
+
* is a test-layer idea — a workflow's declaration inventory must be
|
|
1144
|
+
* deterministic (canonicalHash, one version × N runs), and "hand-run one
|
|
1145
|
+
* member" is already covered because every each member is an addressable
|
|
1146
|
+
* test (`glubean run -t checkout-us`). Owner decision 2026-06-12. */
|
|
1147
|
+
export const workflow = Object.assign(workflowFn, {
|
|
1148
|
+
each: workflowEach,
|
|
1149
|
+
});
|
|
1150
|
+
//# sourceMappingURL=builder.js.map
|