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