@glubean/sdk 0.5.1 → 0.7.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.
Files changed (100) hide show
  1. package/dist/contract-artifacts.d.ts +39 -21
  2. package/dist/contract-artifacts.d.ts.map +1 -1
  3. package/dist/contract-artifacts.js +80 -24
  4. package/dist/contract-artifacts.js.map +1 -1
  5. package/dist/contract-core.d.ts +10 -21
  6. package/dist/contract-core.d.ts.map +1 -1
  7. package/dist/contract-core.js +26 -812
  8. package/dist/contract-core.js.map +1 -1
  9. package/dist/contract-http/adapter.d.ts +13 -0
  10. package/dist/contract-http/adapter.d.ts.map +1 -1
  11. package/dist/contract-http/adapter.js +182 -23
  12. package/dist/contract-http/adapter.js.map +1 -1
  13. package/dist/contract-http/factory.d.ts +2 -2
  14. package/dist/contract-http/factory.d.ts.map +1 -1
  15. package/dist/contract-http/factory.js +5 -0
  16. package/dist/contract-http/factory.js.map +1 -1
  17. package/dist/contract-http/inbound-match.d.ts +47 -0
  18. package/dist/contract-http/inbound-match.d.ts.map +1 -0
  19. package/dist/contract-http/inbound-match.js +136 -0
  20. package/dist/contract-http/inbound-match.js.map +1 -0
  21. package/dist/contract-http/inbound-verify.d.ts +46 -0
  22. package/dist/contract-http/inbound-verify.d.ts.map +1 -0
  23. package/dist/contract-http/inbound-verify.js +101 -0
  24. package/dist/contract-http/inbound-verify.js.map +1 -0
  25. package/dist/contract-http/inbound.d.ts +45 -0
  26. package/dist/contract-http/inbound.d.ts.map +1 -0
  27. package/dist/contract-http/inbound.js +89 -0
  28. package/dist/contract-http/inbound.js.map +1 -0
  29. package/dist/contract-http/index.d.ts +5 -0
  30. package/dist/contract-http/index.d.ts.map +1 -1
  31. package/dist/contract-http/index.js +3 -0
  32. package/dist/contract-http/index.js.map +1 -1
  33. package/dist/contract-http/openapi.d.ts +0 -7
  34. package/dist/contract-http/openapi.d.ts.map +1 -1
  35. package/dist/contract-http/openapi.js +20 -14
  36. package/dist/contract-http/openapi.js.map +1 -1
  37. package/dist/contract-http/types.d.ts +106 -2
  38. package/dist/contract-http/types.d.ts.map +1 -1
  39. package/dist/contract-http/types.js +33 -0
  40. package/dist/contract-http/types.js.map +1 -1
  41. package/dist/contract-types.d.ts +148 -446
  42. package/dist/contract-types.d.ts.map +1 -1
  43. package/dist/index.d.ts +10 -6
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +10 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/internal.d.ts +7 -0
  48. package/dist/internal.d.ts.map +1 -1
  49. package/dist/internal.js +14 -0
  50. package/dist/internal.js.map +1 -1
  51. package/dist/{contract-flow-poll.d.ts → poll-primitives.d.ts} +8 -81
  52. package/dist/poll-primitives.d.ts.map +1 -0
  53. package/dist/{contract-flow-poll.js → poll-primitives.js} +10 -64
  54. package/dist/poll-primitives.js.map +1 -0
  55. package/dist/{contract-flow-condition.d.ts → predicates.d.ts} +34 -71
  56. package/dist/predicates.d.ts.map +1 -0
  57. package/dist/{contract-flow-condition.js → predicates.js} +86 -80
  58. package/dist/predicates.js.map +1 -0
  59. package/dist/test/builder.js +2 -2
  60. package/dist/test/builder.js.map +1 -1
  61. package/dist/test/utils.d.ts +7 -0
  62. package/dist/test/utils.d.ts.map +1 -1
  63. package/dist/test/utils.js +22 -17
  64. package/dist/test/utils.js.map +1 -1
  65. package/dist/types.d.ts +26 -14
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js.map +1 -1
  68. package/dist/workflow/builder.d.ts +386 -0
  69. package/dist/workflow/builder.d.ts.map +1 -0
  70. package/dist/workflow/builder.js +1150 -0
  71. package/dist/workflow/builder.js.map +1 -0
  72. package/dist/workflow/execute.d.ts +277 -0
  73. package/dist/workflow/execute.d.ts.map +1 -0
  74. package/dist/workflow/execute.js +1489 -0
  75. package/dist/workflow/execute.js.map +1 -0
  76. package/dist/workflow/index.d.ts +11 -0
  77. package/dist/workflow/index.d.ts.map +1 -0
  78. package/dist/workflow/index.js +15 -0
  79. package/dist/workflow/index.js.map +1 -0
  80. package/dist/workflow/project.d.ts +18 -0
  81. package/dist/workflow/project.d.ts.map +1 -0
  82. package/dist/workflow/project.js +321 -0
  83. package/dist/workflow/project.js.map +1 -0
  84. package/dist/workflow/retry.d.ts +13 -0
  85. package/dist/workflow/retry.d.ts.map +1 -0
  86. package/dist/workflow/retry.js +15 -0
  87. package/dist/workflow/retry.js.map +1 -0
  88. package/dist/workflow/types.d.ts +512 -0
  89. package/dist/workflow/types.d.ts.map +1 -0
  90. package/dist/workflow/types.js +2 -0
  91. package/dist/workflow/types.js.map +1 -0
  92. package/package.json +5 -2
  93. package/dist/contract-flow-condition.d.ts.map +0 -1
  94. package/dist/contract-flow-condition.js.map +0 -1
  95. package/dist/contract-flow-poll.d.ts.map +0 -1
  96. package/dist/contract-flow-poll.js.map +0 -1
  97. package/dist/contract-http/flow-helpers.d.ts +0 -12
  98. package/dist/contract-http/flow-helpers.d.ts.map +0 -1
  99. package/dist/contract-http/flow-helpers.js +0 -34
  100. 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