@glubean/runner 0.4.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.
@@ -0,0 +1,1475 @@
1
+ /**
2
+ * @module workflow/execute
3
+ *
4
+ * vNext `workflow` EXECUTOR — the per-node runtime harness.
5
+ *
6
+ * GOVERNING PRINCIPLE (2026-06-09): no backward compatibility. Where `runFlow`
7
+ * (`contract-core.ts`) and the `test()` harness (`packages/runner`) diverge, this
8
+ * executor picks the ONE rule decided in the proposal §17 self-consistency corpus
9
+ * and every node obeys it. See `internal/40-discovery/proposals/contract-workflow-vnext.md`.
10
+ *
11
+ * This slice (S2.0) builds the foundation every later node type stands on:
12
+ * - a per-node derived child ctx (`makeNodeScope`) that attributes evidence to
13
+ * the node, tracks its pass/fail + structured-evidence emission, and — after
14
+ * the node settles — QUARANTINES any late evidence the body still emits (§17 #12);
15
+ * - `runNode`: brackets the node with `workflow:node_start` / `:node_end`
16
+ * events, runs the body under a per-node `AbortController`, and enforces a
17
+ * TERMINAL per-node timeout (no retry; §17 #4) that aborts the node (§17 #12).
18
+ *
19
+ * On top of it: `runWorkflow` walks setup → nodes → teardown (S2.1); `runBranch`
20
+ * runs only the taken side (S2.4a, §17 #6); `runPollNode` is the bounded
21
+ * poll-until with per-attempt quarantine (S2.4b, §17 #3). Control-flow nodes
22
+ * (branch/poll) own their bracket/bounds and are dispatched by `runNodeList`,
23
+ * not `runNode`; remaining reserved kinds throw "not implemented yet".
24
+ */
25
+ // The workflow executor lives in @glubean/runner (node-only) as of plan 0007. It is
26
+ // execution semantics, not authoring DSL, and it legitimately uses node:async_hooks
27
+ // (the runner is node-only). Its SDK dependencies are split: authoring/projection
28
+ // types + GlubeanSkipError + Expectation come from the public surface; the shared
29
+ // runtime primitives (contract adapters, predicate eval, poll primitives, static
30
+ // grading, retry validation) come through the @glubean/sdk/internal bridge.
31
+ import { AsyncLocalStorage } from "node:async_hooks";
32
+ import { GlubeanSkipError } from "@glubean/sdk";
33
+ import { Expectation } from "@glubean/sdk/expect";
34
+ import { getAdapter, validateNeedsOutput, evalPredicate, extractPredicate, resolvePath, quarantinedCtx, raceBudget, validatePollBounds, PollExhaustedError, BACKOFF_CAP_MS, DEFAULT_EVERY_MS, staticGradeOf, validateRetryMeta, } from "@glubean/sdk/internal";
35
+ /**
36
+ * Human-readable label for a declarative expect item (phase4 §7.2) — derived
37
+ * from the extracted predicate, never authored, so it cannot drift from what
38
+ * actually evaluates. Used for assertion-event messages.
39
+ */
40
+ export function predicateLabel(p) {
41
+ switch (p.kind) {
42
+ case "compare": {
43
+ const OPS = { eq: "==", ne: "!=", gt: ">", gte: ">=", lt: "<", lte: "<=" };
44
+ const lhs = p.path.join(".") || "<state>";
45
+ const rhs = p.rhsPath !== undefined ? p.rhsPath.join(".") : JSON.stringify(p.value);
46
+ return `${lhs} ${OPS[p.op] ?? p.op} ${rhs}`;
47
+ }
48
+ case "in":
49
+ return `${p.path.join(".")} in ${JSON.stringify(p.values)}`;
50
+ case "presence":
51
+ return `${p.path.join(".")} ${p.op}`;
52
+ case "matches":
53
+ return `${p.path.join(".")} matches /${p.pattern}/${p.flags ?? ""}`;
54
+ case "and":
55
+ return p.clauses.map(predicateLabel).join(" && ");
56
+ case "or":
57
+ return `(${p.clauses.map(predicateLabel).join(" || ")})`;
58
+ case "not":
59
+ return `not(${predicateLabel(p.clause)})`;
60
+ case "opaque":
61
+ return "<opaque predicate>";
62
+ }
63
+ }
64
+ // =============================================================================
65
+ // Active-node context channel (§17 #9/#12 — the ctx.http rebind, S2.10)
66
+ // =============================================================================
67
+ /**
68
+ * The per-node child ctx of the CURRENTLY EXECUTING workflow node body.
69
+ *
70
+ * The host harness pre-binds `ctx.http`'s auto-trace/metric hooks to the
71
+ * test-level ctx at construction, so inline HTTP inside a workflow node used
72
+ * to bypass the node scope entirely — no attribution (an opaque HTTP action
73
+ * never promoted to `trace`, §17 #10) and no late-evidence quarantine
74
+ * (§17 #12). The executor now runs every node body inside this
75
+ * AsyncLocalStorage scope; the harness's hooks ask for the active node ctx
76
+ * FIRST and fall back to their own closure ctx (non-workflow tests see no
77
+ * behavior change).
78
+ */
79
+ const activeNodeCtx = new AsyncLocalStorage();
80
+ /**
81
+ * Host integration point (used by @glubean/runner's http hooks): the ctx that
82
+ * observability emitted DURING a workflow node body should be attributed to,
83
+ * or undefined outside any workflow node.
84
+ */
85
+ export function __activeWorkflowNodeCtx() {
86
+ return activeNodeCtx.getStore();
87
+ }
88
+ /** Run a node body (or attempt/decision) attributed to `ctx`. */
89
+ function withActiveNodeCtx(ctx, fn) {
90
+ return activeNodeCtx.run(ctx, fn);
91
+ }
92
+ // =============================================================================
93
+ // Per-node evidence events (§17 #9)
94
+ // =============================================================================
95
+ /**
96
+ * Per-node boundary events ride the generic `ctx.event` channel for this slice
97
+ * (zero runner-package change — they reach the timeline as namespaced
98
+ * `GlubeanEvent`s). S2.5 decides whether to promote them to first-class
99
+ * `node_start` / `node_end` event kinds for richer Cloud rendering.
100
+ */
101
+ export const NODE_START_EVENT = "workflow:node_start";
102
+ export const NODE_END_EVENT = "workflow:node_end";
103
+ // =============================================================================
104
+ // Timeout (§17 #4 — terminal, never retried) + abort (§17 #12)
105
+ // =============================================================================
106
+ /** A node exceeded its `timeout`. TERMINAL — the node is not retried (§17 #4). */
107
+ export class NodeTimeoutError extends Error {
108
+ nodeId;
109
+ timeoutMs;
110
+ constructor(nodeId, timeoutMs) {
111
+ super(`workflow node "${nodeId}" timed out after ${timeoutMs}ms`);
112
+ this.name = "NodeTimeoutError";
113
+ this.nodeId = nodeId;
114
+ this.timeoutMs = timeoutMs;
115
+ }
116
+ }
117
+ /**
118
+ * A workflow phase (setup, or a node) FAILED via soft assertions/validation —
119
+ * nothing was thrown, so `runNode` has no `error` to report. The executor
120
+ * synthesizes this as the run's `cause` so teardown + the result carry a
121
+ * non-empty failure even for the pure soft-failure path (codex S2.1 P2).
122
+ */
123
+ export class WorkflowPhaseFailedError extends Error {
124
+ workflowId;
125
+ phase;
126
+ constructor(workflowId, phase) {
127
+ super(`workflow "${workflowId}" ${phase} failed (soft assertion/validation)`);
128
+ this.name = "WorkflowPhaseFailedError";
129
+ this.workflowId = workflowId;
130
+ this.phase = phase;
131
+ }
132
+ }
133
+ /**
134
+ * A `compute` node returned a thenable — an async fn, or (the case the builder
135
+ * can't catch) a sync fn that returns a Promise. compute MUST be synchronous and
136
+ * I/O-free; the executor FAILS the node rather than committing the Promise as
137
+ * state, so async/I/O can't masquerade as a `full` pure transform (§17 #11).
138
+ */
139
+ export class ComputeAsyncError extends Error {
140
+ nodeId;
141
+ constructor(nodeId) {
142
+ super(`workflow compute "${nodeId}" returned a thenable — compute must be ` +
143
+ `synchronous and I/O-free; use .action() for async work`);
144
+ this.name = "ComputeAsyncError";
145
+ this.nodeId = nodeId;
146
+ }
147
+ }
148
+ export function makeNodeScope(base, signal) {
149
+ let live = true;
150
+ let failed = false;
151
+ let structured = false;
152
+ // Single gate for every emit: while live, record flags + forward to the real
153
+ // ctx; once sealed, do NOTHING (drop the late emission, leave flags untouched —
154
+ // the node's verdict is already fixed). `run` returns its value so accessors
155
+ // that must yield a sound result to the (now-abandoned) body still can.
156
+ const onEvidence = (flags, forward) => {
157
+ if (!live)
158
+ return;
159
+ if (flags.failed)
160
+ failed = true;
161
+ if (flags.structured)
162
+ structured = true;
163
+ forward();
164
+ };
165
+ // `Object.create(base)` (not a spread) so prototype-inherited APIs survive — a
166
+ // fixture-augmented ctx (test.extend) carries vars/http/log/etc. on its
167
+ // prototype chain. Only the APIs below are overridden; everything else resolves
168
+ // through `base`.
169
+ //
170
+ // `ctx.http` rebind (closes the S2.0 known limitation): the host's ky hooks
171
+ // are pre-bound to the test-level ctx, so the executor runs every node body
172
+ // inside the activeNodeCtx ALS scope and the harness's hooks route their
173
+ // auto-trace/metric through `__activeWorkflowNodeCtx()` first — inline HTTP
174
+ // inside a node now attributes to THIS scope (opaque HTTP actions promote to
175
+ // `trace`, §17 #10) and is quarantined after seal (§17 #12).
176
+ const ctx = Object.assign(Object.create(base), {
177
+ signal,
178
+ assert: (a, message, details) => {
179
+ const passed = typeof a === "boolean" ? a : a.passed;
180
+ onEvidence({ failed: !passed, structured: true }, () => base.assert(a, message, details));
181
+ },
182
+ expect: (actual) => new Expectation(actual, (result) => {
183
+ onEvidence({ failed: !result.passed, structured: true }, () => base.assert({ passed: result.passed, actual: result.actual, expected: result.expected }, result.message));
184
+ }),
185
+ validate: (data, schema, label, options) => {
186
+ // Run the schema NOW regardless of liveness — the body consumes the return
187
+ // value (mirrors `quarantinedCtx.validate`). Supports both safeParse and
188
+ // parse-only SchemaLikes. Only the EMIT + failure flag + fatal-throw are
189
+ // gated on liveness.
190
+ const s = schema;
191
+ let ok = true;
192
+ let value;
193
+ if (typeof s.safeParse === "function") {
194
+ const r = s.safeParse(data);
195
+ ok = r.success;
196
+ value = r.success ? r.data : undefined;
197
+ }
198
+ else if (typeof s.parse === "function") {
199
+ try {
200
+ value = s.parse(data);
201
+ }
202
+ catch {
203
+ ok = false;
204
+ }
205
+ }
206
+ else {
207
+ // Neither safeParse nor parse — an unusable schema. The delegated
208
+ // base.validate still records a failed validation, so this scope must
209
+ // count it as a failure too, else runNode would report `passed` despite
210
+ // failed validation evidence (codex S2.0 R2 P2).
211
+ ok = false;
212
+ }
213
+ const severity = options?.severity ?? "error";
214
+ onEvidence({ failed: !ok && severity !== "warn", structured: true }, () => base.validate(data, schema, label, options));
215
+ // `severity: "fatal"` aborts the body at the validation point — preserve
216
+ // that control flow, but only while live (a sealed node is already done).
217
+ if (live && !ok && severity === "fatal") {
218
+ throw new Error(`fatal validation failed: ${label ?? "data"}`);
219
+ }
220
+ return value;
221
+ },
222
+ // `fail` emits a failed assertion AND aborts the body. It must NOT delegate to
223
+ // `base.fail` (that would mutate the real ctx even from a sealed/abandoned
224
+ // node). Emit (gated) + throw (always — the throw is control flow, not leaked
225
+ // evidence).
226
+ fail: (message) => {
227
+ onEvidence({ failed: true, structured: true }, () => base.assert({ passed: false }, message));
228
+ throw new Error(message);
229
+ },
230
+ // `skip` is pure control flow (emits nothing). OVERRIDE it (don't inherit
231
+ // base.skip) so a node skip throws the SDK `GlubeanSkipError` REGARDLESS of
232
+ // what the host ctx's skip throws — the real runner's `ctx.skip` throws its
233
+ // own private `SkipError` (harness.ts:692), which `runNode`'s instanceof check
234
+ // would miss. Overriding makes the skip shape the executor's own, not the
235
+ // host's (codex S2.0 R2 P1; no-compat — workflow owns its skip semantics).
236
+ skip: (reason) => {
237
+ throw new GlubeanSkipError(reason);
238
+ },
239
+ warn: (condition, message) => onEvidence({}, () => base.warn(condition, message)),
240
+ // Structured observability that DOES count toward opaque→trace (§17 #10).
241
+ trace: (request) => onEvidence({ structured: true }, () => base.trace(request)),
242
+ metric: (name, value, options) => onEvidence({ structured: true }, () => base.metric(name, value, options)),
243
+ // Non-structured channels: forwarded while live, never promote the grade.
244
+ action: (a) => onEvidence({}, () => base.action(a)),
245
+ event: (ev) => onEvidence({}, () => base.event(ev)),
246
+ log: (message, data) => onEvidence({}, () => base.log(message, data)),
247
+ });
248
+ return {
249
+ ctx,
250
+ hasFailure: () => failed,
251
+ emittedStructuredEvidence: () => structured,
252
+ seal: () => {
253
+ live = false;
254
+ },
255
+ };
256
+ }
257
+ // =============================================================================
258
+ // Grade promotion (§17 #10)
259
+ // =============================================================================
260
+ /**
261
+ * Runtime grade = the static floor, promoted from `opaque` → `trace` iff the node
262
+ * emitted structured evidence at run time (§17 #10). `full` / `partial` are never
263
+ * promoted (they are already as good as or better than `trace`).
264
+ */
265
+ export function promoteGrade(staticGrade, scope) {
266
+ return staticGrade === "opaque" && scope.emittedStructuredEvidence()
267
+ ? "trace"
268
+ : staticGrade;
269
+ }
270
+ function emitNodeStart(base, node, attempt) {
271
+ const data = {
272
+ nodeId: node.meta.id,
273
+ kind: node.kind,
274
+ name: node.meta.name ?? node.meta.id,
275
+ };
276
+ if (attempt) {
277
+ data.attempt = attempt.n;
278
+ data.attempts = attempt.of;
279
+ }
280
+ base.event({ type: NODE_START_EVENT, data });
281
+ }
282
+ function emitNodeEnd(base, node, status, grade, durationMs, error, attempt) {
283
+ const data = {
284
+ nodeId: node.meta.id,
285
+ kind: node.kind,
286
+ name: node.meta.name ?? node.meta.id,
287
+ status,
288
+ grade,
289
+ durationMs,
290
+ };
291
+ if (error !== undefined)
292
+ data.error = error instanceof Error ? error.message : String(error);
293
+ if (attempt) {
294
+ data.attempt = attempt.n;
295
+ data.attempts = attempt.of;
296
+ }
297
+ base.event({ type: NODE_END_EVENT, data });
298
+ }
299
+ // `validateRetryMeta` (§17 #7 retry validation) moved to the SDK (`workflow/retry.ts`)
300
+ // when the executor relocated here (plan 0007): it is shared with the authoring-side
301
+ // builder. Imported from @glubean/sdk/internal above; still called by the executor
302
+ // before looping (an `as any`/JS caller could smuggle an invalid retry past the types).
303
+ /** True for a Promise or any Promise-like (`.then` is a function). */
304
+ function isThenable(v) {
305
+ return v != null && typeof v.then === "function";
306
+ }
307
+ /**
308
+ * Execute ONE in-graph contract-case invocation (§17 #8). Independent of the flow
309
+ * engine (which gets deleted — plan fork a): dispatch through the PUBLIC adapter
310
+ * registry and call the protocol-neutral `executeCaseInFlow`. Covers the adapter
311
+ * lookup, the third-party `validateCaseForFlow` veto (mirrors contract-core.ts:818),
312
+ * the `needs` validation at the call boundary (contract-core.ts:1402-1427 parity;
313
+ * codex S2.1 R4 P2), and the `in` lens (PURE SYNCHRONOUS — thenables rejected,
314
+ * codex S2.1 R7). Shared by `.call` (one shot, node signal) and `.poll` (one
315
+ * invocation per attempt, per-attempt budget signal + quarantined ctx).
316
+ */
317
+ async function executeCallAttempt(kind, node, ctx, state, signal) {
318
+ const label = `workflow ${kind} "${node.meta.id}"`;
319
+ const adapter = getAdapter(node.ref.protocol);
320
+ if (!adapter?.executeCaseInFlow) {
321
+ throw new Error(`${label}: protocol "${node.ref.protocol}" ` +
322
+ (adapter
323
+ ? "does not implement executeCaseInFlow — it cannot be called in a workflow"
324
+ : "has no registered adapter (did you import its contract plugin package?)"));
325
+ }
326
+ // Third-party adapter veto: a protocol can reject specific cases from in-graph use
327
+ // (§17 #8, the validateCaseForFlow half — a third-party slot; built-in adapters
328
+ // don't implement it).
329
+ adapter.validateCaseForFlow?.(node.ref.contract._spec, node.ref.caseKey, node.ref.contractId);
330
+ const contractSpec = node.ref.contract._spec;
331
+ const needsSchema = contractSpec?.cases?.[node.ref.caseKey]?.needs;
332
+ const caseId = `${node.ref.contractId}.${node.ref.caseKey}`;
333
+ if (needsSchema && typeof node.in !== "function") {
334
+ throw new Error(`${label}: case "${caseId}" declares \`needs\` but the ` +
335
+ `${kind} has no \`in\` binding. Provide \`in: (state) => <logical input>\`.`);
336
+ }
337
+ let resolvedInputs = node.in ? node.in(state) : undefined;
338
+ if (isThenable(resolvedInputs)) {
339
+ throw new Error(`${label}: \`in\` returned a thenable — in must be a ` +
340
+ `pure synchronous lens (state) => input; do async work in .action()`);
341
+ }
342
+ if (needsSchema) {
343
+ resolvedInputs = validateNeedsOutput(needsSchema, resolvedInputs, { testId: caseId, source: "workflow" });
344
+ }
345
+ return adapter.executeCaseInFlow({
346
+ ctx,
347
+ // The adapter owns the contract's concrete spec type; the ref carries the live
348
+ // instance. Cast at this protocol-neutral seam (mirrors contract-core.ts:1431).
349
+ contract: node.ref.contract,
350
+ caseKey: node.ref.caseKey,
351
+ resolvedInputs,
352
+ ...(node.accept ? { accept: node.accept } : {}),
353
+ ...(signal ? { signal } : {}),
354
+ });
355
+ }
356
+ /**
357
+ * Invoke a contract case IN-GRAPH for a `.call` node: one `executeCallAttempt`
358
+ * under the node's ctx + signal, then the `out` lens. `in` maps state → the
359
+ * case's logical input; `out` folds the raw response back into state (no `out`
360
+ * → response discarded, state preserved, like runFlow contract-core.ts:1625-1628).
361
+ */
362
+ async function runContractCall(node, ctx, state, scope) {
363
+ const res = await executeCallAttempt("call", node, ctx, state, ctx.signal);
364
+ // If the adapter already recorded a soft failure (scoped expect/validate on a
365
+ // failing case), STOP before `out`: an out lens that assumes the validated success
366
+ // shape would throw and become the cause, masking the real validation failure.
367
+ // runNode then fails the node on hasFailure() with a synthesized cause (codex S2.1 R7).
368
+ if (scope.hasFailure()) {
369
+ return state; // preserve; the node fails on the recorded failure, not via `out`
370
+ }
371
+ // `out` REPLACES state; absent `out` returns undefined → §17 #2 preserves it.
372
+ // `out` is a PURE SYNCHRONOUS lens — reject a thenable result so async/I/O can't
373
+ // hide inside a `full`-graded call lens (mirrors compute, §17 #11; codex S2.1 R6).
374
+ const next = node.out ? node.out(state, res) : undefined;
375
+ if (isThenable(next)) {
376
+ throw new Error(`workflow call "${node.meta.id}": \`out\` returned a thenable — out must be a ` +
377
+ `pure synchronous lens (state, res) => state; do async work in .action()`);
378
+ }
379
+ return next;
380
+ }
381
+ /** Dispatch a node's body to its kind-specific executor. */
382
+ function runBody(node, ctx, state, scope) {
383
+ switch (node.kind) {
384
+ case "action":
385
+ return Promise.resolve(node.fn(ctx, state));
386
+ case "check": {
387
+ const check = node;
388
+ // DECLARATIVE form (phase4 §7): pure evaluation, one assertion event
389
+ // per item, soft semantics (§17 #5) — the node fails iff any item
390
+ // failed, via the scope's existing hasFailure aggregation. No user
391
+ // code runs.
392
+ if (check.expects !== undefined) {
393
+ for (const item of check.expects) {
394
+ const extracted = extractPredicate(item);
395
+ ctx.assert(evalPredicate(item, state), predicateLabel(extracted));
396
+ }
397
+ return Promise.resolve(undefined);
398
+ }
399
+ // `as any`/JS construction can hand a check with NEITHER form — the
400
+ // type is an XOR union, but fail fast for bypassers (codex S2.17 R2).
401
+ if (typeof check.fn !== "function") {
402
+ throw new Error(`workflow check "${node.meta.id}": needs an inline fn or { expect: [...] }`);
403
+ }
404
+ // A check returns void → it never changes state (resolve to `undefined` so
405
+ // the caller's §17 #2 "void preserves" rule keeps the prior state).
406
+ return Promise.resolve(check.fn(ctx, state)).then(() => undefined);
407
+ }
408
+ case "compute": {
409
+ // Pure synchronous transform; the return REPLACES state (§17 #2/#13). The
410
+ // builder rejects `async` compute syntactically; here we also catch a sync fn
411
+ // that RETURNS a thenable (JS / cast bypass) — fail the node, never commit the
412
+ // Promise as state (§17 #11).
413
+ const result = node.fn(state);
414
+ if (isThenable(result)) {
415
+ throw new ComputeAsyncError(node.meta.id);
416
+ }
417
+ return Promise.resolve(result);
418
+ }
419
+ case "contract-call":
420
+ return runContractCall(node, ctx, state, scope);
421
+ case "branch":
422
+ case "poll":
423
+ // Control-flow nodes own their bracket/bounds — runNodeList dispatches them
424
+ // to runBranch / runPollNode directly, never through runNode.
425
+ throw new Error(`runNode: "${node.kind}" nodes run via the node-list dispatcher, not runNode`);
426
+ default:
427
+ throw new Error(`runNode: node kind "${node.kind}" is not implemented yet (lands in a later slice)`);
428
+ }
429
+ }
430
+ /**
431
+ * Run ONE node: bracket it with node_start/node_end, execute its body under a
432
+ * per-node child ctx + `AbortController`, enforce a TERMINAL timeout (§17 #4),
433
+ * and quarantine any late evidence after the node settles (§17 #12). Returns the
434
+ * verdict + the state to carry forward (commit-on-success, §17 #13).
435
+ */
436
+ export async function runNode(base, node, state, opts) {
437
+ const ac = new AbortController();
438
+ const scope = makeNodeScope(base, ac.signal);
439
+ const started = Date.now();
440
+ // One grade per settle path, shared by the node_end event AND the result so
441
+ // they always agree; `adjustGrade` lets the retry wrapper tie promotion to
442
+ // host-visible evidence (codex S2.5 R5 P2). Called only after settle().
443
+ const finalGrade = (status, error) => {
444
+ const computed = promoteGrade(opts.staticGrade, scope);
445
+ return opts.adjustGrade
446
+ ? opts.adjustGrade(computed, {
447
+ structured: scope.emittedStructuredEvidence(),
448
+ status,
449
+ error,
450
+ })
451
+ : computed;
452
+ };
453
+ emitNodeStart(base, node, opts.attempt);
454
+ let timer;
455
+ const hasTimeout = opts.timeoutMs != null && Number.isFinite(opts.timeoutMs);
456
+ const settle = () => {
457
+ // Order matters: seal BEFORE clearing the timer so a body that races the
458
+ // timer can't slip an emission through the gap.
459
+ scope.seal();
460
+ if (timer !== undefined)
461
+ clearTimeout(timer);
462
+ };
463
+ try {
464
+ const body = withActiveNodeCtx(scope.ctx, () => runBody(node, scope.ctx, state, scope));
465
+ const raced = hasTimeout
466
+ ? Promise.race([
467
+ body,
468
+ new Promise((_, reject) => {
469
+ timer = setTimeout(() => {
470
+ // Seal BEFORE aborting: `ac.abort()` runs abort listeners
471
+ // synchronously, and a listener that emits evidence must be
472
+ // quarantined too — sealing first stops it leaking past the timeout
473
+ // boundary or promoting the grade (codex S2.0 R3 P2).
474
+ scope.seal();
475
+ ac.abort();
476
+ reject(new NodeTimeoutError(node.meta.id, opts.timeoutMs));
477
+ }, opts.timeoutMs);
478
+ }),
479
+ ])
480
+ : body;
481
+ const returned = await raced;
482
+ settle();
483
+ if (scope.hasFailure()) {
484
+ // Body resolved but a soft assertion failed → node fails, no commit (§17 #13).
485
+ // Abort the signal too (as timeout/throw do) so any cooperative work the node
486
+ // started observes the failure and stops — the WorkflowContext signal contract.
487
+ if (!ac.signal.aborted)
488
+ ac.abort();
489
+ const grade = finalGrade("failed");
490
+ opts.beforeNodeEnd?.("failed", undefined);
491
+ emitNodeEnd(base, node, "failed", grade, Date.now() - started, undefined, opts.attempt);
492
+ return { status: "failed", state, grade };
493
+ }
494
+ // Success: return REPLACES state; void/undefined PRESERVES it (§17 #2 / #13).
495
+ const nextState = returned === undefined ? state : returned;
496
+ const grade = finalGrade("passed");
497
+ opts.beforeNodeEnd?.("passed", undefined);
498
+ emitNodeEnd(base, node, "passed", grade, Date.now() - started, undefined, opts.attempt);
499
+ return { status: "passed", state: nextState, grade };
500
+ }
501
+ catch (err) {
502
+ settle();
503
+ // Ensure the body's signal fires even on a thrown (non-timeout) failure, so a
504
+ // dangling async leg observes the abort.
505
+ if (!ac.signal.aborted)
506
+ ac.abort();
507
+ // `ctx.skip()` is control flow, not a failure: a node that deliberately skips
508
+ // (and had no earlier failed assertion) settles `skipped`, NOT `failed` — a
509
+ // failed assertion before the skip still wins (mirrors harness.ts:2297). The
510
+ // skip is preserved on `err` so the caller (runWorkflow, S2.1) can decide its
511
+ // graph-level meaning (skip the rest vs. continue); runNode only classifies.
512
+ if (err instanceof GlubeanSkipError && !scope.hasFailure()) {
513
+ const grade = finalGrade("skipped", err);
514
+ opts.beforeNodeEnd?.("skipped", err);
515
+ emitNodeEnd(base, node, "skipped", grade, Date.now() - started, undefined, opts.attempt);
516
+ return { status: "skipped", state, grade, error: err };
517
+ }
518
+ // A skip thrown AFTER a soft failure is NOT the cause — the prior failure is.
519
+ // Drop the skip so runWorkflow synthesizes a proper phase-failure cause; a real
520
+ // thrown error / timeout stays as the cause (codex S2.1 R4 P3).
521
+ const failError = err instanceof GlubeanSkipError ? undefined : err;
522
+ const grade = finalGrade("failed", failError);
523
+ opts.beforeNodeEnd?.("failed", failError);
524
+ emitNodeEnd(base, node, "failed", grade, Date.now() - started, failError, opts.attempt);
525
+ return { status: "failed", state, grade, error: failError };
526
+ }
527
+ }
528
+ /**
529
+ * Derive a lifecycle (setup/teardown) child ctx + its `AbortController` — same
530
+ * per-node attribution + late-evidence quarantine as a graph node, AND the same
531
+ * failure-abort guarantee: the caller aborts the signal when the phase fails, so
532
+ * signal-aware async work the body started gets cancelled (codex S2.1 R2). No
533
+ * per-node timeout for this slice (§17 #4 timeout on setup/teardown lands later).
534
+ */
535
+ function deriveLifecycleScope(base) {
536
+ const ac = new AbortController();
537
+ return { scope: makeNodeScope(base, ac.signal), ac };
538
+ }
539
+ /**
540
+ * Record nodes in `list` from `startIndex` onward as `skipped` (emit end-only — they
541
+ * never started). Indexed by POSITION, not `meta.id`, so two nodes that accidentally
542
+ * share an id both still get an outcome (the builder doesn't reject dup ids — codex
543
+ * S2.1 R3 P3). A skipped branch node is recorded as one `skipped` node (its sides are
544
+ * not expanded — they never ran).
545
+ */
546
+ function emitNodesSkipped(baseCtx, list, outcomes, startIndex) {
547
+ for (let i = startIndex; i < list.length; i++) {
548
+ const node = list[i];
549
+ const grade = staticGradeOf(node);
550
+ emitNodeEnd(baseCtx, node, "skipped", grade, 0);
551
+ outcomes.push({ id: node.meta.id, status: "skipped", grade });
552
+ // RECURSIVE (phase4 §2.4 / design-R2): summary and Cloud count nodes from
553
+ // per-child node_end events. A skipped GROUP's members are a definite
554
+ // execution list — they must each report. A skipped BRANCH's case
555
+ // sub-graphs get the same treatment for consistency with the executed
556
+ // branch path, which emits skipped node_ends for every non-taken case.
557
+ if (node.kind === "group") {
558
+ emitNodesSkipped(baseCtx, node.nodes, outcomes, 0);
559
+ }
560
+ else if (node.kind === "branch") {
561
+ const b = node;
562
+ for (const c of b.cases)
563
+ emitNodesSkipped(baseCtx, c.nodes, outcomes, 0);
564
+ if (b.default)
565
+ emitNodesSkipped(baseCtx, b.default, outcomes, 0);
566
+ }
567
+ }
568
+ }
569
+ /** Is this `when` an L1/L0 opaque predicate (vs an L2 declarative `BranchPredicate`)? */
570
+ function isOpaquePredicate(when) {
571
+ return when.kind === "opaque";
572
+ }
573
+ /**
574
+ * Decide the taken case of a branch-family node (addendum §9).
575
+ *
576
+ * - value mode: run the `on` lens (pure + synchronous — thenables rejected,
577
+ * mirrors call's `in`), === match against the literal case table.
578
+ * - predicate mode: ordered first-match. L2 declarative → pure synchronous
579
+ * `evalPredicate`; an opaque fn (branch's whenRuntime) runs under the node's
580
+ * child ctx (may read ctx + emit evidence; async awaited). A non-boolean
581
+ * opaque result is rejected, not coerced (codex S2.4a P2).
582
+ *
583
+ * No match → "default" (which the caller maps to the `default` nodes, or — for
584
+ * switch with no default — the identity pass-through).
585
+ */
586
+ async function selectBranchCase(node, state, ctx) {
587
+ if (node.mode === "value") {
588
+ // S2.18 lens-purity: the runtime dispatches via SAFE PATH RESOLUTION over
589
+ // the extracted onPath — the projection and the execution share one truth
590
+ // (the live lens is not kept on the node). A missing path yields
591
+ // `undefined`, which `===`-matches no JSON-scalar case → default/identity
592
+ // (the missing-operand rule). `as any`/JS callers without an onPath get
593
+ // the same no-match behavior rather than a crash.
594
+ const discriminant = node.onPath !== undefined ? resolvePath(state, node.onPath) : undefined;
595
+ // A thenable at the path (an unawaited request stored into state by an
596
+ // earlier action) must FAIL, not silently fall through to default —
597
+ // taking the wrong branch on a pending value is misrouting, not a
598
+ // no-match (codex S2.18 R1 P2; preserves the pre-onPath guard).
599
+ if (isThenable(discriminant)) {
600
+ throw new Error(`workflow ${node.meta.id}: the value at \`on\` path ` +
601
+ `"${(node.onPath ?? []).join(".")}" is a thenable — state must hold ` +
602
+ `settled values; await async work in .action() before switching on it`);
603
+ }
604
+ const idx = node.cases.findIndex((c) => c.value === discriminant);
605
+ return idx >= 0
606
+ ? { takenIndex: idx, takenLabel: node.cases[idx].label }
607
+ : { takenIndex: "default" };
608
+ }
609
+ for (let i = 0; i < node.cases.length; i++) {
610
+ const when = node.cases[i].when;
611
+ if (when === undefined)
612
+ continue; // defensive: a case without a predicate never matches
613
+ let hit;
614
+ if (isOpaquePredicate(when)) {
615
+ const result = await when.fn(ctx, state);
616
+ if (typeof result !== "boolean") {
617
+ // The public type promises boolean; reject non-booleans rather than coercing
618
+ // (a `'false'` string or a forgotten return would silently take the case).
619
+ throw new Error(`workflow branch "${node.meta.id}": whenRuntime must return a boolean, got ${typeof result}`);
620
+ }
621
+ hit = result;
622
+ }
623
+ else {
624
+ hit = evalPredicate(when, state);
625
+ }
626
+ if (hit)
627
+ return { takenIndex: i, takenLabel: node.cases[i].label };
628
+ }
629
+ return { takenIndex: "default" };
630
+ }
631
+ /**
632
+ * Run a single leaf (non-control-flow) node: runNode + push outcome + cause
633
+ * synthesis. Explicit-intent retry (§17 #7) lives HERE: each attempt is a FULL
634
+ * runNode bracket (fresh scope + abort + attempt-stamped node_start/node_end).
635
+ *
636
+ * Attempt-evidence policy (§17 #7, codex S2.4c R1 P2): the run's pass/fail
637
+ * counters belong to the FINAL attempt only — a retried-and-passed node must
638
+ * not leave failed assertion/validation counts on the host ctx (the shipped
639
+ * step retry resets per-attempt counters the same way; otherwise a host
640
+ * summary computed from assertion events contradicts the node verdict). So a
641
+ * retrying node buffers each attempt's pass/fail evidence (`quarantinedCtx`)
642
+ * and flushes ONLY the attempt that decides the verdict: the passing/skipping
643
+ * attempt, the terminal-timeout attempt, or the last exhausted attempt. A
644
+ * non-final failed attempt stays VISIBLE — its attempt-stamped node_end
645
+ * (failed, with the error), its traces/logs/metrics, and a retry log line all
646
+ * pass through; only its buffered assert/validate COUNTS are dropped.
647
+ *
648
+ * Each attempt re-reads the same last-committed state (a failed attempt
649
+ * commits nothing, §17 #13). Never retried: a passed/skipped attempt (skip is
650
+ * control flow), and a timeout (TERMINAL, §17 #4). check/compute cannot carry
651
+ * retry — a check failure must never replay a prior action.
652
+ */
653
+ async function runLeafNode(baseCtx, workflowId, node, state, outcomes) {
654
+ const grade = staticGradeOf(node);
655
+ const retry = node.kind === "contract-call" || node.kind === "action"
656
+ ? node.retry
657
+ : undefined;
658
+ // Re-validate at run time: an `as any`/JS caller could smuggle e.g.
659
+ // `attempts: Infinity` past the builder — fail the node fast, don't loop.
660
+ let r;
661
+ if (retry) {
662
+ try {
663
+ validateRetryMeta(retry, node.meta.id);
664
+ }
665
+ catch (err) {
666
+ outcomes.push({ id: node.meta.id, status: "failed", grade });
667
+ emitNodeEnd(baseCtx, node, "failed", grade, 0, err);
668
+ return { state, status: "failed", cause: err };
669
+ }
670
+ }
671
+ const maxAttempts = retry?.attempts ?? 1;
672
+ const timeoutOpt = node.meta.timeout !== undefined ? { timeoutMs: node.meta.timeout } : {};
673
+ if (!retry) {
674
+ r = await runNode(baseCtx, node, state, { staticGrade: grade, ...timeoutOpt });
675
+ }
676
+ else {
677
+ // A failed attempt is terminal when it cannot/must not be replayed: a
678
+ // passed/skipped verdict (skip is control flow), a timeout (TERMINAL,
679
+ // §17 #4), or attempt exhaustion.
680
+ const isTerminal = (attempt, status, error) => status !== "failed" || error instanceof NodeTimeoutError || attempt >= maxAttempts;
681
+ // Replaying a buffered FATAL validation onto the real host ctx throws again
682
+ // (the host's validate aborts on fatal). That control flow is already spent —
683
+ // it failed the attempt at the original throw — so the replay's throw must
684
+ // not escape runNode/runWorkflow (codex S2.4c R3 P2). Evidence before the
685
+ // fatal entry has already replayed; nothing follows it (the attempt body
686
+ // stopped at the throw).
687
+ const safeFlush = (attemptCtx) => {
688
+ try {
689
+ attemptCtx.flushTo(baseCtx);
690
+ }
691
+ catch {
692
+ /* replayed fatal validation — already accounted in the attempt verdict */
693
+ }
694
+ };
695
+ // Promotion aggregates across attempts, but ONLY from evidence that actually
696
+ // reached the host (codex S2.4c R3+R4 P2): pass-through trace/metric on ANY
697
+ // attempt counts (it is on the host timeline even when the attempt's buffered
698
+ // counts are dropped); buffered assert/validate counts only when FLUSHED —
699
+ // i.e. on the terminal attempt. The adjustment is applied INSIDE runNode (via
700
+ // adjustGrade) so each attempt's node_end event and the final outcome carry
701
+ // the same host-visible grade — a discarded attempt's assert-only "trace"
702
+ // must not show anywhere, and an earlier attempt's pass-through promotion
703
+ // must survive into the terminal bracket (codex S2.5 R5 P2).
704
+ let promoted = false;
705
+ for (let attempt = 1;; attempt++) {
706
+ // Buffer this attempt's pass/fail evidence; observability (traces, logs,
707
+ // node_start/node_end events) passes straight through to the host — via a
708
+ // probe that records whether any pass-through STRUCTURED evidence landed.
709
+ let passthroughStructured = false;
710
+ const probe = Object.assign(Object.create(baseCtx), {
711
+ trace: (t) => {
712
+ passthroughStructured = true;
713
+ baseCtx.trace(t);
714
+ },
715
+ metric: (name, value, options) => {
716
+ passthroughStructured = true;
717
+ baseCtx.metric(name, value, options);
718
+ },
719
+ });
720
+ const attemptCtx = quarantinedCtx(probe);
721
+ let flushed = false;
722
+ r = await runNode(attemptCtx, node, state, {
723
+ staticGrade: grade,
724
+ ...timeoutOpt, // §17 #4: a timed-out attempt is TERMINAL — never retried
725
+ attempt: { n: attempt, of: maxAttempts },
726
+ // Flush the verdict-deciding attempt's evidence BEFORE its node_end is
727
+ // emitted, so the assertions land INSIDE the attempt bracket on the
728
+ // host timeline (codex S2.4c R2 P2).
729
+ beforeNodeEnd: (status, error) => {
730
+ if (isTerminal(attempt, status, error)) {
731
+ flushed = true; // set first — a throwing replay still counts as flushed
732
+ safeFlush(attemptCtx);
733
+ }
734
+ },
735
+ adjustGrade: (computed, info) => {
736
+ if (grade !== "opaque")
737
+ return computed; // only opaque→trace exists (§17 #10)
738
+ const terminal = isTerminal(attempt, info.status, info.error);
739
+ // Host-visible structured evidence: a pass-through trace/metric (this
740
+ // or any earlier attempt), or — only when this attempt's buffers flush
741
+ // (terminal) — its scope-recorded assert/validate evidence.
742
+ const hostVisible = promoted || passthroughStructured || (terminal && info.structured);
743
+ return hostVisible ? "trace" : "opaque";
744
+ },
745
+ });
746
+ if (passthroughStructured)
747
+ promoted = true;
748
+ if (isTerminal(attempt, r.status, r.error)) {
749
+ // beforeNodeEnd already flushed on every runNode settle path; this is a
750
+ // safety net for a future runNode path that misses the hook (flushTo
751
+ // replays the buffer, so it must run at most once).
752
+ if (!flushed)
753
+ safeFlush(attemptCtx);
754
+ break;
755
+ }
756
+ // Non-final failed attempt: drop its buffered counts, log the retry
757
+ // (mirrors the shipped step-retry log line), wait, go again.
758
+ baseCtx.log?.(`retrying workflow node "${node.meta.id}" (${attempt + 1}/${maxAttempts}) ` +
759
+ `after failure — ${retry.reason}`);
760
+ if (retry.delay)
761
+ await sleep(retry.delay);
762
+ }
763
+ // r.grade already carries the cross-attempt host-visible promotion (adjustGrade).
764
+ }
765
+ outcomes.push({ id: node.meta.id, status: r.status, grade: r.grade });
766
+ if (r.status === "passed")
767
+ return { state: r.state, status: "passed" };
768
+ if (r.status === "failed") {
769
+ return {
770
+ state,
771
+ status: "failed",
772
+ cause: r.error ?? new WorkflowPhaseFailedError(workflowId, `node "${node.meta.id}"`),
773
+ };
774
+ }
775
+ // node ctx.skip() — preserve the authored reason (codex S2.5 R6).
776
+ return {
777
+ state,
778
+ status: "skipped",
779
+ skipReason: r.error instanceof GlubeanSkipError ? r.error.reason : undefined,
780
+ };
781
+ }
782
+ /** Branch-family decision event (§17 #9 "branch decision") — rides the generic
783
+ * ctx.event channel like node_start/node_end; the runner unwraps it into a
784
+ * first-class `branch_decision` timeline event. */
785
+ export const BRANCH_DECISION_EVENT = "workflow:branch_decision";
786
+ /**
787
+ * Run a branch-family node (§17 #6, addendum §9): decide the taken case (under
788
+ * a child ctx so an opaque predicate's evidence is attributed + quarantined),
789
+ * then run ONLY the taken case's nodes via runNodeList; every non-taken case
790
+ * (and the un-taken default) is emitted `skipped`. A decision that throws or
791
+ * soft-fails fails the node (all cases skipped). switch with no default and no
792
+ * match = identity pass-through. A terminal route's outcome carries
793
+ * `terminal: true` so runNodeList stops the trunk after it.
794
+ */
795
+ async function runBranch(baseCtx, workflowId, node, state, outcomes) {
796
+ const grade = staticGradeOf(node);
797
+ const started = Date.now();
798
+ emitNodeStart(baseCtx, node);
799
+ // The family node's own outcome precedes its children (tree order); the final
800
+ // status is set once the taken case settles.
801
+ const outcomeIndex = outcomes.length;
802
+ outcomes.push({ id: node.meta.id, status: "passed", grade });
803
+ const ac = new AbortController();
804
+ const scope = makeNodeScope(baseCtx, ac.signal);
805
+ // Emit ALL children (every case in authored order, then default) as skipped.
806
+ // `exceptIndex` skips the taken case's slot (its children get real outcomes).
807
+ const emitChildrenSkipped = (exceptIndex) => {
808
+ node.cases.forEach((c, i) => {
809
+ if (exceptIndex !== i)
810
+ emitNodesSkipped(baseCtx, c.nodes, outcomes, 0);
811
+ });
812
+ if (node.default && exceptIndex !== "default") {
813
+ emitNodesSkipped(baseCtx, node.default, outcomes, 0);
814
+ }
815
+ };
816
+ const failAll = (cause, error) => {
817
+ ac.abort();
818
+ emitChildrenSkipped();
819
+ const g = promoteGrade(grade, scope); // a soft-failed predicate may have emitted evidence (§17 #10)
820
+ outcomes[outcomeIndex] = { id: node.meta.id, status: "failed", grade: g };
821
+ emitNodeEnd(baseCtx, node, "failed", g, Date.now() - started, error);
822
+ return { state, status: "failed", cause };
823
+ };
824
+ let decision;
825
+ try {
826
+ decision = await withActiveNodeCtx(scope.ctx, () => selectBranchCase(node, state, scope.ctx));
827
+ }
828
+ catch (err) {
829
+ scope.seal();
830
+ if (err instanceof GlubeanSkipError && !scope.hasFailure()) {
831
+ // decision ctx.skip() with no prior failure → skip the node (→ whole
832
+ // workflow, like a node skip), NOT a failure (codex S2.4a R5).
833
+ ac.abort();
834
+ emitChildrenSkipped();
835
+ const g = promoteGrade(grade, scope);
836
+ outcomes[outcomeIndex] = { id: node.meta.id, status: "skipped", grade: g };
837
+ emitNodeEnd(baseCtx, node, "skipped", g, Date.now() - started);
838
+ return { state, status: "skipped", skipReason: err.reason };
839
+ }
840
+ // a real throw, OR a soft-failure-then-skip → fail. A skip is never the cause;
841
+ // synthesize a phase failure (mirrors runNode/setup, codex S2.4a R6).
842
+ if (err instanceof GlubeanSkipError) {
843
+ return failAll(new WorkflowPhaseFailedError(workflowId, `branch "${node.meta.id}" decision`));
844
+ }
845
+ return failAll(err, err);
846
+ }
847
+ if (scope.hasFailure()) {
848
+ // opaque predicate soft-failed (scoped assert/validate) — fail the node.
849
+ scope.seal();
850
+ return failAll(new WorkflowPhaseFailedError(workflowId, `branch "${node.meta.id}" decision`));
851
+ }
852
+ // The decision is observability (§17 #9) — emit through the scope while live.
853
+ const decisionData = {
854
+ nodeId: node.meta.id,
855
+ mode: node.mode,
856
+ takenIndex: decision.takenIndex,
857
+ ...(decision.takenLabel !== undefined ? { takenLabel: decision.takenLabel } : {}),
858
+ };
859
+ scope.ctx.event({ type: BRANCH_DECISION_EVENT, data: decisionData });
860
+ scope.seal();
861
+ // Run the taken case; emit every other case's children skipped — in AUTHORED
862
+ // order, so outcomes/events align with the projected tree (codex S2.4a P2):
863
+ // cases before the taken one first, then the taken sub-run, then the rest.
864
+ let r;
865
+ if (decision.takenIndex === "default") {
866
+ emitChildrenSkipped("default");
867
+ r = node.default
868
+ ? await runNodeList(baseCtx, workflowId, node.default, state, outcomes)
869
+ : { state, status: "passed" }; // switch with no default: identity pass-through (§9 #4)
870
+ }
871
+ else {
872
+ const takenIdx = decision.takenIndex;
873
+ node.cases.forEach((c, i) => {
874
+ if (i < takenIdx)
875
+ emitNodesSkipped(baseCtx, c.nodes, outcomes, 0);
876
+ });
877
+ r = await runNodeList(baseCtx, workflowId, node.cases[takenIdx].nodes, state, outcomes);
878
+ node.cases.forEach((c, i) => {
879
+ if (i > takenIdx)
880
+ emitNodesSkipped(baseCtx, c.nodes, outcomes, 0);
881
+ });
882
+ if (node.default)
883
+ emitNodesSkipped(baseCtx, node.default, outcomes, 0);
884
+ }
885
+ // promote opaque→trace if the decision emitted structured evidence, same as
886
+ // runNode (§17 #10; codex S2.4a P2).
887
+ const runtimeGrade = promoteGrade(grade, scope);
888
+ outcomes[outcomeIndex] = { id: node.meta.id, status: r.status, grade: runtimeGrade };
889
+ emitNodeEnd(baseCtx, node, r.status, runtimeGrade, Date.now() - started);
890
+ // A terminal route that completed (passed OR skipped) ends the trunk; a
891
+ // failed one already fail-stops the list the normal way.
892
+ if (node.terminal && r.status === "passed") {
893
+ return { ...r, terminal: true };
894
+ }
895
+ return r;
896
+ }
897
+ // =============================================================================
898
+ // runPollNode — bounded poll-until with per-attempt quarantine (§17 #3, §6.7)
899
+ // =============================================================================
900
+ /** Per-attempt timeline event (§17 #9 "poll attempt timeline"). Rides the generic
901
+ * `ctx.event` channel like node_start/node_end; emitted through the NODE scope, so
902
+ * it is observability (never promotes the grade) and is quarantined after seal. */
903
+ export const POLL_ATTEMPT_EVENT = "workflow:poll_attempt";
904
+ function emitPollAttempt(ctx, node, attempt, outcome, durationMs, inboundDetail) {
905
+ const data = {
906
+ nodeId: node.meta.id,
907
+ attempt,
908
+ outcome,
909
+ durationMs: Math.round(durationMs),
910
+ ...(inboundDetail ? inboundDetail : {}),
911
+ };
912
+ ctx.event({ type: POLL_ATTEMPT_EVENT, data });
913
+ }
914
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
915
+ /**
916
+ * A fail-class inbound classification (inbound-contract-design §9.4 rows 1–3):
917
+ * a property of the delivery CHANNEL (forgery / replay / non-JSON), never
918
+ * noise — it fails the poll node even when a later delivery in the same
919
+ * snapshot would have matched (§9.4a #4, first terminal wins).
920
+ */
921
+ export class InboundDeliveryFailError extends Error {
922
+ classification;
923
+ constructor(classification, nodeId, detail) {
924
+ super(`workflow poll "${nodeId}": inbound delivery classified ${classification}` +
925
+ `${detail ? ` (${detail})` : ""} — authentication/channel failures are ` +
926
+ `never noise (inbound-contract-design §9.4).`);
927
+ this.classification = classification;
928
+ this.name = "InboundDeliveryFailError";
929
+ }
930
+ }
931
+ /**
932
+ * ONE inbound attempt (design §9.3): walk the receiver's unclaimed snapshot
933
+ * in receiver order (oldest first) through the adapter matcher; the FIRST
934
+ * terminal classification wins — matched claims and exits, a fail class
935
+ * throws (§9.4a #4). Pre-existing deliveries are allowed evidence
936
+ * (§9.4a #3) — eligibility is "unclaimed", not "received after poll start".
937
+ */
938
+ function runInboundAttempt(node, state, ctx) {
939
+ const inbound = node.inbound;
940
+ const ref = node.ref;
941
+ const label = node.meta.id;
942
+ const adapter = getAdapter(ref.protocol);
943
+ if (!adapter?.matchInboundCase) {
944
+ throw new Error(`workflow poll "${label}": adapter for protocol "${ref.protocol}" does ` +
945
+ `not implement matchInboundCase — it cannot await inbound deliveries.`);
946
+ }
947
+ const handle = inbound.via(state);
948
+ if (!handle ||
949
+ typeof handle.deliveries !== "function" ||
950
+ typeof handle.claim !== "function") {
951
+ throw new Error(`workflow poll "${label}": \`via\` did not resolve to a ReceiverHandle ` +
952
+ `(needs deliveries() and claim()) — put the live handle in state ` +
953
+ `during setup and select it with a pure lens.`);
954
+ }
955
+ const caseSpec = ref.contract._spec
956
+ ?.cases?.[ref.caseKey];
957
+ if (!caseSpec) {
958
+ throw new Error(`workflow poll "${label}": case "${ref.caseKey}" not found in contract "${ref.contractId}"`);
959
+ }
960
+ // Delivery-less preflight (§9.4 row P): an EMPTY inbox must not let an
961
+ // unknown scheme / missing secret exhaust as a timeout — the matcher's own
962
+ // preflight only runs when a delivery exists (codex I3 R3 P2).
963
+ adapter.preflightInboundCase?.({ caseSpec, secrets: ctx.secrets });
964
+ // Both correlate sides resolve via EXTRACTED paths (safe traversal) — a
965
+ // lens call (`s => s.a.b`) throws on a missing intermediate. Event side:
966
+ // missing path = type-mismatch probe (the matcher walks it). State side:
967
+ // state is OURS and deterministic, so a missing/undefined correlation
968
+ // value is an authoring bug — fail fast with a named error instead of
969
+ // letting `undefined === undefined` match someone else's event or probing
970
+ // silently to exhaustion (the missing-operand rule, same as eqPath).
971
+ let correlate;
972
+ if (inbound.correlate) {
973
+ const stateValue = resolvePath(state, inbound.correlate.statePath);
974
+ if (stateValue === undefined) {
975
+ throw new Error(`workflow poll "${label}": correlate.state resolved \`undefined\` at ` +
976
+ `path "${inbound.correlate.statePath.join(".")}" — the value to ` +
977
+ `correlate on must be in state before the poll starts.`);
978
+ }
979
+ correlate = { eventPath: inbound.correlate.eventPath, stateValue };
980
+ }
981
+ let last = { matched: false, classification: "no-delivery" };
982
+ for (const delivery of handle.deliveries()) {
983
+ const result = adapter.matchInboundCase({
984
+ caseSpec,
985
+ delivery: delivery,
986
+ secrets: ctx.secrets,
987
+ correlate,
988
+ });
989
+ if (result.kind === "matched") {
990
+ handle.claim(delivery.id);
991
+ return {
992
+ matched: true,
993
+ parsed: result.parsed,
994
+ classification: "matched",
995
+ receivedAt: delivery.receivedAt,
996
+ };
997
+ }
998
+ if (result.kind === "signature-invalid" ||
999
+ result.kind === "stale" ||
1000
+ result.kind === "unparseable") {
1001
+ throw new InboundDeliveryFailError(result.kind, label, result.detail);
1002
+ }
1003
+ last = { matched: false, classification: result.kind };
1004
+ }
1005
+ return last;
1006
+ }
1007
+ /**
1008
+ * Evaluate the poll exit predicate. L2 declarative reads the RESPONSE (shared
1009
+ * `evalPredicate`, subject = response — the single predicate engine, same as
1010
+ * branch). An opaque `untilRuntime` gets `(ctx, res, state)` and is awaited; a
1011
+ * non-boolean result is rejected (same rule + wording as branch `whenRuntime`).
1012
+ * Deliberately NOT the flow's `evalPollExit`: that helper's error text names the
1013
+ * legacy `pollFn`/`pollAsync` API, which is deleted before release (no-compat).
1014
+ */
1015
+ async function evalUntil(node, response, ctx, state) {
1016
+ const until = node.until;
1017
+ if (until.kind === "opaque") {
1018
+ // The quarantined predicate ctx prototype-chains to the node scope's ctx, so
1019
+ // `signal` and observability resolve through it — the WorkflowContext cast is
1020
+ // sound at runtime (same seam as the branch predicate).
1021
+ const result = await until.fn(ctx, response, state);
1022
+ if (typeof result !== "boolean") {
1023
+ throw new Error(`workflow poll "${node.meta.id}": untilRuntime must return a boolean, got ${typeof result}`);
1024
+ }
1025
+ return result;
1026
+ }
1027
+ return evalPredicate(until, response);
1028
+ }
1029
+ /**
1030
+ * The bounded attempt loop — a port of the flow's `runPollStep`
1031
+ * (contract-core.ts:1449-1569) onto the workflow node scope. Each attempt runs
1032
+ * request + exit predicate in their own quarantined ctxs over `scope.ctx`
1033
+ * (§17 #3):
1034
+ * - satisfy (until=true) → flush reqCtx, return the response (caller applies `out`);
1035
+ * - probe (until=false) → DISCARD reqCtx (pending validation noise);
1036
+ * - in-budget fail/throw → flush the throwing ctx (the deliberate failure lands), rethrow;
1037
+ * - orphan (budget timeout) → DISCARD that ctx (a timed-out attempt cannot corrupt the run);
1038
+ * - exhaust (any bound) → throw PollExhaustedError (the node fails).
1039
+ * The predicate ctx is flushed after EVERY in-budget evaluation — its traces and
1040
+ * deliberate asserts are observability/intent, not probe noise (§17 #3). Both the
1041
+ * request and the predicate are budget-raced, so the loop is bounded even when
1042
+ * the adapter ignores `signal`.
1043
+ */
1044
+ async function pollLoop(node, state, scope) {
1045
+ const label = node.meta.id;
1046
+ // `as any`/JS callers can hand a poll node with neither attempt source —
1047
+ // executeCallAttempt would crash on an undefined ref; fail fast instead.
1048
+ if (!node.ref && !node.attemptFn) {
1049
+ throw new Error(`workflow poll "${label}": needs a contract ref (.poll) or an attempt fn (.pollAction)`);
1050
+ }
1051
+ // Inbound polls have no exit predicate — matched IS the exit (§9.4a); every
1052
+ // other poll requires one (an exit-predicate-less poll loops for nothing).
1053
+ if (!node.inbound && !node.until) {
1054
+ throw new Error(`workflow poll "${label}": needs an exit predicate — \`until\` (declarative) or \`untilRuntime\``);
1055
+ }
1056
+ // Epoch anchor for `within` evidence (receivedAt is epoch ms; §9.4a #3 —
1057
+ // the delta may be negative for deliveries that beat the poll start).
1058
+ const pollStartEpochMs = Date.now();
1059
+ // Runtime bound guard — the builder validates at construction, but `as any` /
1060
+ // JS callers can hand the executor an unbounded poll node. Fail fast instead of
1061
+ // looping forever (mirrors runPollStep's guard).
1062
+ validatePollBounds({
1063
+ timeout: node.timeoutMs,
1064
+ maxAttempts: node.maxAttempts,
1065
+ perAttemptTimeout: node.perAttemptTimeoutMs,
1066
+ every: node.every,
1067
+ backoff: node.backoff,
1068
+ }, label);
1069
+ // Defaults for as-any/JS callers — the builder already applies them (a missing
1070
+ // `every` would hot-loop; `delay * undefined` is NaN).
1071
+ const everyMs = node.every ?? DEFAULT_EVERY_MS;
1072
+ const backoffFactor = node.backoff ?? 1;
1073
+ const now = () => performance.now();
1074
+ const start = now();
1075
+ const deadline = node.timeoutMs !== undefined ? start + node.timeoutMs : Infinity;
1076
+ const perAttempt = node.perAttemptTimeoutMs ?? Infinity;
1077
+ let attempt = 0;
1078
+ let delay = everyMs;
1079
+ let lastRes;
1080
+ for (;;) {
1081
+ attempt += 1;
1082
+ const remainingTotal = deadline - now();
1083
+ if (remainingTotal <= 0) {
1084
+ throw new PollExhaustedError(label, attempt - 1, "total timeout reached before next attempt");
1085
+ }
1086
+ const attemptBudget = Math.min(perAttempt, remainingTotal); // build-time validation ⇒ finite
1087
+ const attemptStart = now();
1088
+ // Request: quarantined ctx + per-attempt abort signal; budget-raced so a
1089
+ // non-honoring adapter can't block past the budget.
1090
+ const attemptAc = new AbortController();
1091
+ let budgetAborted = false;
1092
+ const budgetTimer = Number.isFinite(attemptBudget)
1093
+ ? setTimeout(() => {
1094
+ budgetAborted = true;
1095
+ attemptAc.abort();
1096
+ }, Math.max(0, attemptBudget))
1097
+ : undefined;
1098
+ // The attempt ctx: quarantined over the node scope, with `signal` =
1099
+ // THIS attempt's budget abort (an attemptFn reads ctx.signal; a contract
1100
+ // attempt gets the same signal explicitly).
1101
+ const reqCtx = Object.assign(quarantinedCtx(scope.ctx), { signal: attemptAc.signal });
1102
+ const exhausted = () => new PollExhaustedError(label, attempt, `attempt budget ${Math.round(attemptBudget)}ms exceeded`);
1103
+ try {
1104
+ // Attempt source: inbound await (design §9.3 — scan the receiver
1105
+ // snapshot through the adapter matcher), pollAction probe (addendum
1106
+ // §4), or a contract-call attempt. Same quarantine, same budget race.
1107
+ const attemptRun = node.inbound
1108
+ ? Promise.resolve(runInboundAttempt(node, state, reqCtx))
1109
+ : node.attemptFn
1110
+ ? Promise.resolve(node.attemptFn(reqCtx, state))
1111
+ : executeCallAttempt("poll", node, reqCtx, state, attemptAc.signal);
1112
+ lastRes = await raceBudget(attemptRun, attemptBudget, exhausted);
1113
+ }
1114
+ catch (err) {
1115
+ // Once OUR budget timer fired, the attempt is over budget no matter HOW
1116
+ // the cancelled body rejects — an AbortError from a signal-honoring
1117
+ // adapter, or (pollAction takes arbitrary fns — codex S2.11 R1 P2) a
1118
+ // plain Error thrown from an abort handler. Either way the rejection is
1119
+ // the cancellation's echo, not an in-budget verdict: normalize to poll
1120
+ // exhaustion and DISCARD the orphan's buffered evidence.
1121
+ const isBudgetAbort = budgetAborted && !(err instanceof PollExhaustedError);
1122
+ // A genuine IN-BUDGET request error (incl. fatal validation / ctx.fail)
1123
+ // flushes its buffered effects (the failure assertion lands) then fails
1124
+ // the poll; budget overruns discard the orphan ctx.
1125
+ if (!isBudgetAbort && !(err instanceof PollExhaustedError))
1126
+ reqCtx.flushTo(scope.ctx);
1127
+ emitPollAttempt(scope.ctx, node, attempt, "failed", now() - attemptStart, err instanceof InboundDeliveryFailError
1128
+ ? { classification: err.classification }
1129
+ : undefined);
1130
+ throw isBudgetAbort ? exhausted() : err;
1131
+ }
1132
+ finally {
1133
+ if (budgetTimer)
1134
+ clearTimeout(budgetTimer);
1135
+ }
1136
+ // A sync-heavy request the timer couldn't preempt may have overrun — re-check.
1137
+ if (now() > deadline)
1138
+ throw new PollExhaustedError(label, attempt, "total timeout reached");
1139
+ if (now() - attemptStart > perAttempt)
1140
+ throw new PollExhaustedError(label, attempt, "attempt budget exceeded");
1141
+ let done;
1142
+ let inboundDetail;
1143
+ if (node.inbound) {
1144
+ // Inbound: matched IS the exit (§9.4a) — no predicate, no predCtx.
1145
+ const res = lastRes;
1146
+ done = res.matched;
1147
+ inboundDetail = {
1148
+ classification: res.classification,
1149
+ ...(res.matched && res.receivedAt !== undefined
1150
+ ? { withinDeltaMs: res.receivedAt - pollStartEpochMs }
1151
+ : {}),
1152
+ };
1153
+ }
1154
+ else {
1155
+ // Exit predicate — quarantined ctx, budget-raced, flushed only on in-budget completion.
1156
+ const predBudget = Math.min(deadline - now(), perAttempt - (now() - attemptStart));
1157
+ const predCtx = quarantinedCtx(scope.ctx);
1158
+ try {
1159
+ done = await raceBudget(evalUntil(node, lastRes, predCtx, state), Math.max(0, predBudget), () => new PollExhaustedError(label, attempt, "exit-predicate budget exceeded"));
1160
+ }
1161
+ catch (err) {
1162
+ // Timed-out predicate orphan → discard predCtx. An in-budget predicate that
1163
+ // threw (ctx.fail / ctx.skip / a thrown error) → flush its buffered effects
1164
+ // (the failure assertion lands) then propagate (the poll fails / skips).
1165
+ if (!(err instanceof PollExhaustedError))
1166
+ predCtx.flushTo(scope.ctx);
1167
+ emitPollAttempt(scope.ctx, node, attempt, "failed", now() - attemptStart);
1168
+ throw err;
1169
+ }
1170
+ if (now() > deadline)
1171
+ throw new PollExhaustedError(label, attempt, "total timeout reached");
1172
+ if (now() - attemptStart > perAttempt)
1173
+ throw new PollExhaustedError(label, attempt, "attempt budget exceeded");
1174
+ predCtx.flushTo(scope.ctx); // in-budget: the user's deliberate skip/fail/assert count (§17 #3)
1175
+ }
1176
+ if (done) {
1177
+ reqCtx.flushTo(scope.ctx); // satisfying attempt: final-response validation failures surface
1178
+ emitPollAttempt(scope.ctx, node, attempt, "satisfied", now() - attemptStart, inboundDetail);
1179
+ // The satisfying inbound result hands `out` the PARSED, schema-validated
1180
+ // body (§9.4a) — the wrapper is loop machinery, not user data.
1181
+ return node.inbound ? lastRes.parsed : lastRes;
1182
+ }
1183
+ // probe (not satisfied): reqCtx discarded (pending validation noise).
1184
+ emitPollAttempt(scope.ctx, node, attempt, "probe", now() - attemptStart, inboundDetail);
1185
+ if (node.maxAttempts && attempt >= node.maxAttempts) {
1186
+ throw new PollExhaustedError(label, attempt, "maxAttempts reached");
1187
+ }
1188
+ // A wait that would cross the deadline is pointless — fail now, don't sleep.
1189
+ if (Number.isFinite(deadline) && now() + delay >= deadline) {
1190
+ throw new PollExhaustedError(label, attempt, "next wait would exceed timeout");
1191
+ }
1192
+ await sleep(delay);
1193
+ delay = Math.min(delay * backoffFactor, BACKOFF_CAP_MS);
1194
+ }
1195
+ }
1196
+ /**
1197
+ * Run a poll node (§17 #3): a leaf with its OWN bounds (it does not take the
1198
+ * generic per-node timeout, §17 #4) — so like branch it owns its bracket instead
1199
+ * of going through runNode. The loop runs under the node scope; quarantine policy
1200
+ * is layered: per-attempt buffered ctxs (flush/discard, §17 #3) over the node
1201
+ * scope (late-evidence seal, §17 #12). On satisfy the `out` lens folds the
1202
+ * response into state (commit-on-success, §17 #13); on exhaustion / a deliberate
1203
+ * in-budget failure the node fails; a predicate/adapter `ctx.skip()` with no
1204
+ * prior failure skips the node (→ whole workflow, same as every other node).
1205
+ */
1206
+ async function runPollNode(baseCtx, workflowId, node, state, outcomes) {
1207
+ const staticGrade = staticGradeOf(node);
1208
+ const started = Date.now();
1209
+ emitNodeStart(baseCtx, node);
1210
+ const ac = new AbortController();
1211
+ const scope = makeNodeScope(baseCtx, ac.signal);
1212
+ let status;
1213
+ let nextState = state;
1214
+ let cause;
1215
+ let errForEvent;
1216
+ let skipReason;
1217
+ try {
1218
+ const res = await withActiveNodeCtx(scope.ctx, () => pollLoop(node, state, scope));
1219
+ if (scope.hasFailure()) {
1220
+ // The satisfying attempt's flushed evidence (or an earlier in-budget
1221
+ // predicate assert) recorded a soft failure → the node fails and `out` is
1222
+ // NOT applied (an out lens assumes the validated success shape — same rule
1223
+ // as runContractCall, codex S2.1 R7).
1224
+ status = "failed";
1225
+ cause = new WorkflowPhaseFailedError(workflowId, `poll "${node.meta.id}"`);
1226
+ }
1227
+ else {
1228
+ // `out` REPLACES state; absent/void `out` preserves it (§17 #2). Reject a
1229
+ // thenable so async/I/O can't hide inside the lens (§17 #11; mirrors call).
1230
+ const next = node.out ? node.out(state, res) : undefined;
1231
+ if (isThenable(next)) {
1232
+ throw new Error(`workflow poll "${node.meta.id}": \`out\` returned a thenable — out must be a ` +
1233
+ `pure synchronous lens (state, res) => state; do async work in .action()`);
1234
+ }
1235
+ nextState = next === undefined ? state : next;
1236
+ status = "passed";
1237
+ }
1238
+ }
1239
+ catch (err) {
1240
+ if (err instanceof GlubeanSkipError && !scope.hasFailure()) {
1241
+ // deliberate ctx.skip() (predicate or adapter, in-budget, no prior failure)
1242
+ // → skip the poll (→ whole workflow), NOT a failure.
1243
+ status = "skipped";
1244
+ skipReason = err.reason;
1245
+ }
1246
+ else if (err instanceof GlubeanSkipError) {
1247
+ // skip AFTER a soft failure → the failure wins; never surface the skip.
1248
+ status = "failed";
1249
+ cause = new WorkflowPhaseFailedError(workflowId, `poll "${node.meta.id}"`);
1250
+ }
1251
+ else {
1252
+ status = "failed";
1253
+ cause = err;
1254
+ errForEvent = err;
1255
+ }
1256
+ }
1257
+ // Seal BEFORE aborting (abort listeners that emit must be quarantined too —
1258
+ // same ordering as runNode's timeout path). Abort on any non-success so
1259
+ // signal-aware work the node started observes it; a passed poll's attempts are
1260
+ // already settled (per-attempt signals own in-flight aborts).
1261
+ scope.seal();
1262
+ if (status !== "passed" && !ac.signal.aborted)
1263
+ ac.abort();
1264
+ const grade = promoteGrade(staticGrade, scope);
1265
+ outcomes.push({ id: node.meta.id, status, grade });
1266
+ emitNodeEnd(baseCtx, node, status, grade, Date.now() - started, errForEvent);
1267
+ if (status === "passed")
1268
+ return { state: nextState, status };
1269
+ if (status === "failed")
1270
+ return { state, status, cause };
1271
+ return { state, status, skipReason };
1272
+ }
1273
+ /**
1274
+ * Run a node LIST with fail-stop / skip-stop + commit-on-success state threading
1275
+ * (§17 #5/#13). Shared by the top-level graph and each branch side (recursion). A
1276
+ * non-passed node stops the list; the rest are emitted `skipped`.
1277
+ */
1278
+ async function runNodeList(baseCtx, workflowId, list, state, outcomes) {
1279
+ for (let i = 0; i < list.length; i++) {
1280
+ const node = list[i];
1281
+ const r = node.kind === "branch"
1282
+ ? await runBranch(baseCtx, workflowId, node, state, outcomes)
1283
+ : node.kind === "poll"
1284
+ ? await runPollNode(baseCtx, workflowId, node, state, outcomes)
1285
+ : node.kind === "group"
1286
+ ? await runGroup(baseCtx, workflowId, node, state, outcomes)
1287
+ : await runLeafNode(baseCtx, workflowId, node, state, outcomes);
1288
+ state = r.state;
1289
+ if (r.status === "passed") {
1290
+ // A terminal route completed: the trunk ends here (§9 #5). Anything
1291
+ // smuggled after it (the builder forbids it; `as any` can't) is reported
1292
+ // skipped, never executed.
1293
+ if (r.terminal) {
1294
+ emitNodesSkipped(baseCtx, list, outcomes, i + 1);
1295
+ return r;
1296
+ }
1297
+ continue;
1298
+ }
1299
+ emitNodesSkipped(baseCtx, list, outcomes, i + 1); // rest of THIS list → skipped
1300
+ return r;
1301
+ }
1302
+ return { state, status: "passed" };
1303
+ }
1304
+ /**
1305
+ * Run a `.group()` node — a DISPLAY-ONLY container (phase4 §2, owner-decided):
1306
+ * members execute sequentially on the trunk's state exactly as if the group
1307
+ * wrapper were absent; deleting the group changes nothing about execution.
1308
+ * The group adds ONLY a node_start/node_end bracket so CLI/Cloud can render a
1309
+ * collapsible section. Aggregate status precedence: failed > skipped > passed
1310
+ * (design-R2 — a member ctx.skip() must not render a "passed" bracket).
1311
+ * Grade = worst member RUNTIME grade (promotions included). Summary counts
1312
+ * MEMBERS, not the bracket (kind:"group" on node_end is the discriminator).
1313
+ * No own lifecycle, no timeout, no retry — those were rejected at build time.
1314
+ */
1315
+ async function runGroup(baseCtx, workflowId, node, state, outcomes) {
1316
+ const started = Date.now();
1317
+ emitNodeStart(baseCtx, node);
1318
+ const before = outcomes.length;
1319
+ const r = await runNodeList(baseCtx, workflowId, node.nodes, state, outcomes);
1320
+ // Worst DIRECT-member grade — same scope as staticGradeOf(group), which
1321
+ // folds direct children only (a branch member contributes its DECISION
1322
+ // grade; its case sub-graphs are tallied independently, S2.4a R4). Folding
1323
+ // descendants here would let a skipped opaque side DOWNGRADE the bracket
1324
+ // below its static floor (codex S2.14 R1 P2) — runtime may only promote.
1325
+ const directIds = new Set(node.nodes.map((n) => n.meta.id));
1326
+ const RANK = { full: 0, partial: 1, trace: 2, opaque: 3 };
1327
+ let grade = "full";
1328
+ for (let i = before; i < outcomes.length; i++) {
1329
+ const o = outcomes[i];
1330
+ if (directIds.has(o.id) && RANK[o.grade] > RANK[grade])
1331
+ grade = o.grade;
1332
+ }
1333
+ if (node.nodes.length === 0)
1334
+ grade = staticGradeOf(node);
1335
+ outcomes.push({ id: node.meta.id, status: r.status, grade });
1336
+ emitNodeEnd(baseCtx, node, r.status, grade, Date.now() - started);
1337
+ return r;
1338
+ }
1339
+ /**
1340
+ * Execute a built `Workflow` against a host `TestContext`. setup runs INSIDE the
1341
+ * protected region so teardown ALWAYS runs — even when setup throws (§17 #1, the
1342
+ * opposite of runFlow). Nodes run in authored order with fail-stop: a non-passed
1343
+ * node stops the rest (emitted `skipped`). A node `ctx.skip()` skips the whole
1344
+ * workflow (mirrors the runner's whole-test skip); `meta.skip` opts the workflow
1345
+ * out entirely (discoverable, never executed). teardown sees the last committed
1346
+ * state + the failing cause; a teardown that throws is logged and never masks it.
1347
+ */
1348
+ export async function runWorkflow(wf, baseCtx) {
1349
+ const nodes = [];
1350
+ // --- workflow-level skip: discoverable but NEVER executed — no setup, no nodes,
1351
+ // --- no teardown; every node reported skipped (codex S2.1 R2). ---
1352
+ if (wf.meta.skip !== undefined) {
1353
+ emitNodesSkipped(baseCtx, wf.nodes, nodes, 0);
1354
+ return {
1355
+ status: "skipped",
1356
+ state: undefined,
1357
+ nodes,
1358
+ error: undefined,
1359
+ skipReason: wf.meta.skip,
1360
+ };
1361
+ }
1362
+ let state;
1363
+ let cause;
1364
+ let failed = false;
1365
+ let skipped = false; // setup or a node called ctx.skip() → the whole workflow is skipped
1366
+ let skipReason; // the authored ctx.skip(reason) (codex S2.5 R6)
1367
+ try {
1368
+ // --- setup: an async lifecycle node, SELF-CONTAINED — its throw / ctx.skip() /
1369
+ // --- soft-failure is classified here (no outer catch); on any non-success the
1370
+ // --- graph is skipped and we stop before the node loop. ---
1371
+ let proceed = true;
1372
+ if (wf.setup) {
1373
+ const { scope, ac } = deriveLifecycleScope(baseCtx);
1374
+ let setupResult;
1375
+ let setupError;
1376
+ let threw = false;
1377
+ try {
1378
+ // setup is an async lifecycle node — its terminal timeout (§17 #4)
1379
+ // budget-races the body; on expiry the run fails with the timeout as
1380
+ // the cause and the seal (below) quarantines any late evidence.
1381
+ const setupRun = Promise.resolve(withActiveNodeCtx(scope.ctx, () => wf.setup(scope.ctx)));
1382
+ setupResult =
1383
+ wf.setupTimeoutMs !== undefined
1384
+ ? await raceBudget(setupRun, wf.setupTimeoutMs, () => new NodeTimeoutError("setup", wf.setupTimeoutMs))
1385
+ : await setupRun;
1386
+ }
1387
+ catch (e) {
1388
+ threw = true;
1389
+ setupError = e;
1390
+ }
1391
+ finally {
1392
+ scope.seal();
1393
+ }
1394
+ const softFailed = scope.hasFailure();
1395
+ if (threw || softFailed) {
1396
+ ac.abort(); // failure/skip → cancel signal-aware work setup started (codex S2.1 R2)
1397
+ proceed = false;
1398
+ emitNodesSkipped(baseCtx, wf.nodes, nodes, 0);
1399
+ if (threw && setupError instanceof GlubeanSkipError && !softFailed) {
1400
+ // deliberate setup ctx.skip() with no prior failure → whole workflow
1401
+ // skipped, like a node skip (codex S2.1 R3 P2).
1402
+ skipped = true;
1403
+ skipReason = setupError.reason;
1404
+ }
1405
+ else {
1406
+ // a thrown error, or a soft failure (possibly then a skip) → run fails.
1407
+ // A skip after a soft failure is NOT the cause (failure wins, mirroring the
1408
+ // node path); use the real thrown error, else synthesize a phase failure —
1409
+ // never surface the skip as the cause (codex S2.1 R5 P2).
1410
+ failed = true;
1411
+ cause =
1412
+ setupError && !(setupError instanceof GlubeanSkipError)
1413
+ ? setupError
1414
+ : new WorkflowPhaseFailedError(wf.meta.id, "setup");
1415
+ }
1416
+ }
1417
+ else {
1418
+ state = setupResult; // commit-on-success (§17 #13)
1419
+ }
1420
+ }
1421
+ // --- walk nodes in authored order; fail-stop / skip-stop (§17 #5 basic). The
1422
+ // --- graph + each branch side share runNodeList (commit-on-success threading,
1423
+ // --- §17 #13; node ctx.skip() → whole-workflow skip, mirrors harness.ts:1944). ---
1424
+ if (proceed) {
1425
+ const r = await runNodeList(baseCtx, wf.meta.id, wf.nodes, state, nodes);
1426
+ state = r.state;
1427
+ if (r.status === "failed") {
1428
+ failed = true;
1429
+ cause = r.cause;
1430
+ }
1431
+ else if (r.status === "skipped") {
1432
+ skipped = true;
1433
+ skipReason = r.skipReason;
1434
+ }
1435
+ }
1436
+ }
1437
+ finally {
1438
+ // --- teardown: ALWAYS runs (§17 #1), sees the last committed state + cause ---
1439
+ if (wf.teardown) {
1440
+ const { scope, ac } = deriveLifecycleScope(baseCtx);
1441
+ let teardownThrew = false;
1442
+ try {
1443
+ const teardownRun = Promise.resolve(withActiveNodeCtx(scope.ctx, () => wf.teardown(scope.ctx, state, cause)));
1444
+ // teardown's terminal timeout (§17 #4): logged like any teardown
1445
+ // failure — it NEVER masks the primary cause (§17 #1).
1446
+ if (wf.teardownTimeoutMs !== undefined) {
1447
+ await raceBudget(teardownRun, wf.teardownTimeoutMs, () => new NodeTimeoutError("teardown", wf.teardownTimeoutMs));
1448
+ }
1449
+ else {
1450
+ await teardownRun;
1451
+ }
1452
+ }
1453
+ catch (teardownErr) {
1454
+ teardownThrew = true;
1455
+ // Logged, NEVER masks the primary cause (§17 #1).
1456
+ baseCtx.log?.(`workflow "${wf.meta.id}" teardown failed: ${String(teardownErr)}`);
1457
+ }
1458
+ finally {
1459
+ scope.seal();
1460
+ // Abort on failure so signal-aware cleanup work is cancelled, mirroring the
1461
+ // node/setup failure path (codex S2.1 R2).
1462
+ if (teardownThrew || scope.hasFailure())
1463
+ ac.abort();
1464
+ }
1465
+ }
1466
+ }
1467
+ return {
1468
+ status: failed ? "failed" : skipped ? "skipped" : "passed",
1469
+ state,
1470
+ nodes,
1471
+ error: cause,
1472
+ ...(skipped && skipReason !== undefined ? { skipReason } : {}),
1473
+ };
1474
+ }
1475
+ //# sourceMappingURL=execute.js.map