@acpus/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/artifacts.d.ts +30 -0
- package/dist/artifacts.d.ts.map +1 -0
- package/dist/artifacts.js +97 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/client.d.ts +54 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +159 -0
- package/dist/client.js.map +1 -0
- package/dist/evaluator.d.ts +37 -0
- package/dist/evaluator.d.ts.map +1 -0
- package/dist/evaluator.js +101 -0
- package/dist/evaluator.js.map +1 -0
- package/dist/executors/agent.d.ts +74 -0
- package/dist/executors/agent.d.ts.map +1 -0
- package/dist/executors/agent.js +401 -0
- package/dist/executors/agent.js.map +1 -0
- package/dist/executors/mock-program.d.ts +24 -0
- package/dist/executors/mock-program.d.ts.map +1 -0
- package/dist/executors/mock-program.js +79 -0
- package/dist/executors/mock-program.js.map +1 -0
- package/dist/executors/program.d.ts +21 -0
- package/dist/executors/program.d.ts.map +1 -0
- package/dist/executors/program.js +178 -0
- package/dist/executors/program.js.map +1 -0
- package/dist/executors/types.d.ts +26 -0
- package/dist/executors/types.d.ts.map +1 -0
- package/dist/executors/types.js +2 -0
- package/dist/executors/types.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/interpreter.d.ts +186 -0
- package/dist/interpreter.d.ts.map +1 -0
- package/dist/interpreter.js +1500 -0
- package/dist/interpreter.js.map +1 -0
- package/dist/keys.d.ts +36 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +59 -0
- package/dist/keys.js.map +1 -0
- package/dist/state-machine.d.ts +27 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +87 -0
- package/dist/state-machine.js.map +1 -0
- package/dist/store.d.ts +46 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +260 -0
- package/dist/store.js.map +1 -0
- package/dist/supervisor-app.d.ts +27 -0
- package/dist/supervisor-app.d.ts.map +1 -0
- package/dist/supervisor-app.js +548 -0
- package/dist/supervisor-app.js.map +1 -0
- package/dist/supervisor-discovery.d.ts +17 -0
- package/dist/supervisor-discovery.d.ts.map +1 -0
- package/dist/supervisor-discovery.js +186 -0
- package/dist/supervisor-discovery.js.map +1 -0
- package/dist/supervisor-entry.d.ts +11 -0
- package/dist/supervisor-entry.d.ts.map +1 -0
- package/dist/supervisor-entry.js +50 -0
- package/dist/supervisor-entry.js.map +1 -0
- package/dist/supervisor-lock.d.ts +26 -0
- package/dist/supervisor-lock.d.ts.map +1 -0
- package/dist/supervisor-lock.js +49 -0
- package/dist/supervisor-lock.js.map +1 -0
- package/dist/supervisor-runner.d.ts +11 -0
- package/dist/supervisor-runner.d.ts.map +1 -0
- package/dist/supervisor-runner.js +128 -0
- package/dist/supervisor-runner.js.map +1 -0
- package/dist/types.d.ts +230 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validate-input.d.ts +27 -0
- package/dist/validate-input.d.ts.map +1 -0
- package/dist/validate-input.js +56 -0
- package/dist/validate-input.js.map +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,1500 @@
|
|
|
1
|
+
import { parseDurationMs, compileWorkflow } from "@acpus/core";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { ExpressionEvaluator } from "./evaluator.js";
|
|
5
|
+
import { resolveNodeKey, sanitizeValue } from "./keys.js";
|
|
6
|
+
import { canTransition, transition, createInitialNodeState, resetFailedForRetry, resetRunningForCrashRecovery, resetAwaitingForCrashRecovery, resetPausedForRunResume } from "./state-machine.js";
|
|
7
|
+
import { ArtifactStore } from "./artifacts.js";
|
|
8
|
+
import { renderAgentRequestPrompt } from "./executors/agent.js";
|
|
9
|
+
import { validateInput } from "./validate-input.js";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import pLimit from "p-limit";
|
|
12
|
+
const PAUSED_ABORT_ERROR = "Aborted: paused";
|
|
13
|
+
function abortedNodeError(state) {
|
|
14
|
+
return state === "paused" ? PAUSED_ABORT_ERROR : "Aborted: cancelled";
|
|
15
|
+
}
|
|
16
|
+
function isPausedContinuationState(state) {
|
|
17
|
+
return state?.state === "pending" && state.attempt > 0 && state.error === PAUSED_ABORT_ERROR;
|
|
18
|
+
}
|
|
19
|
+
function pushUnique(target, refs) {
|
|
20
|
+
if (!refs)
|
|
21
|
+
return;
|
|
22
|
+
const seen = new Set(target);
|
|
23
|
+
for (const ref of refs) {
|
|
24
|
+
if (!seen.has(ref)) {
|
|
25
|
+
target.push(ref);
|
|
26
|
+
seen.add(ref);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
target.sort(compareAttemptArtifactRefs);
|
|
30
|
+
}
|
|
31
|
+
function mergeStderrDiagnostics(stderr, diagnostics) {
|
|
32
|
+
if (diagnostics.length === 0)
|
|
33
|
+
return stderr;
|
|
34
|
+
const diagnosticText = diagnostics.map((line) => `[acpus] ${line}`).join("\n");
|
|
35
|
+
return stderr && stderr.length > 0 ? `${stderr}\n${diagnosticText}` : diagnosticText;
|
|
36
|
+
}
|
|
37
|
+
function compareAttemptArtifactRefs(a, b) {
|
|
38
|
+
const left = attemptArtifactSortKey(a);
|
|
39
|
+
const right = attemptArtifactSortKey(b);
|
|
40
|
+
if (!left || !right)
|
|
41
|
+
return 0;
|
|
42
|
+
return left.attempt - right.attempt || left.kind - right.kind;
|
|
43
|
+
}
|
|
44
|
+
function attemptArtifactSortKey(ref) {
|
|
45
|
+
const match = /attempt-(\d+)\.(prompt\.md|transcript\.jsonl|response\.md|stderr\.log)$/.exec(ref);
|
|
46
|
+
if (!match)
|
|
47
|
+
return undefined;
|
|
48
|
+
const kindOrder = {
|
|
49
|
+
"prompt.md": 0,
|
|
50
|
+
"transcript.jsonl": 1,
|
|
51
|
+
"response.md": 2,
|
|
52
|
+
"stderr.log": 3
|
|
53
|
+
};
|
|
54
|
+
return { attempt: Number(match[1]), kind: kindOrder[match[2]] ?? 99 };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The core IR interpreter that drives state transitions, orchestrates
|
|
58
|
+
* execution, and persists state.
|
|
59
|
+
*/
|
|
60
|
+
export class WorkflowInterpreter {
|
|
61
|
+
store;
|
|
62
|
+
evaluator;
|
|
63
|
+
agentExecutor;
|
|
64
|
+
programExecutor;
|
|
65
|
+
artifactStore;
|
|
66
|
+
maxConcurrency;
|
|
67
|
+
allowedSourceRoots;
|
|
68
|
+
sleep;
|
|
69
|
+
/** Active abort controllers keyed by "runId:nodeKey" for pause/cancel support */
|
|
70
|
+
abortControllers = new Map();
|
|
71
|
+
/** Intent of an in-flight abort keyed by "runId:nodeKey" (pause vs cancel). */
|
|
72
|
+
abortIntents = new Map();
|
|
73
|
+
/** Pending human-decision resolvers for Approval Gates awaiting a signal,
|
|
74
|
+
* keyed by "runId:nodeKey". An entry exists only while a node is `awaiting`. */
|
|
75
|
+
approvalResolvers = new Map();
|
|
76
|
+
/** Absolute paths of subworkflow specs currently on the execution stack (cycle guard). */
|
|
77
|
+
subworkflowStack = new Set();
|
|
78
|
+
/** Scheduling guards for Run-level pause/cancel, keyed by runId.
|
|
79
|
+
* Per-runId to prevent leakage across runs sharing the same interpreter. */
|
|
80
|
+
schedulingPaused = new Map();
|
|
81
|
+
schedulingCancelled = new Map();
|
|
82
|
+
constructor(store, agentExecutor, programExecutor, options) {
|
|
83
|
+
this.store = store;
|
|
84
|
+
this.evaluator = new ExpressionEvaluator({ nowTimestamp: options?.nowTimestamp });
|
|
85
|
+
this.agentExecutor = agentExecutor;
|
|
86
|
+
this.programExecutor = programExecutor;
|
|
87
|
+
this.artifactStore = new ArtifactStore(store.getBaseDir());
|
|
88
|
+
this.maxConcurrency = options?.maxConcurrency ?? 10;
|
|
89
|
+
this.allowedSourceRoots = options?.allowedSourceRoots?.map((root) => resolve(root)) ?? [];
|
|
90
|
+
this.sleep = options?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Start a new workflow run to completion (init + execute). Convenience for
|
|
94
|
+
* callers that want to await the terminal state.
|
|
95
|
+
*/
|
|
96
|
+
async start(ir, opts) {
|
|
97
|
+
const meta = this.initRun(ir, opts);
|
|
98
|
+
return this.runToCompletion(ir, opts, meta.runId);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Initialize a run (freeze IR + input, write running meta) and return the
|
|
102
|
+
* initial running state synchronously, without executing nodes.
|
|
103
|
+
*/
|
|
104
|
+
initRun(ir, opts) {
|
|
105
|
+
const validatedInput = validateInput(ir.input, opts.input);
|
|
106
|
+
const runId = opts.runId ?? generateRunId();
|
|
107
|
+
return this.store.initRun(runId, ir, validatedInput, {
|
|
108
|
+
workflowRef: opts.workflowRef,
|
|
109
|
+
workflowSourcePath: opts.workflowSourcePath
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Execute a previously-initialized run to its terminal state.
|
|
114
|
+
*/
|
|
115
|
+
async runToCompletion(ir, opts, runId) {
|
|
116
|
+
const meta = this.store.readRunMeta(runId);
|
|
117
|
+
try {
|
|
118
|
+
await this.executeNode(ir.root, this.buildContext(opts.input, runId), runId, {});
|
|
119
|
+
meta.status = "completed";
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const rootState = this.store.readNodeState(runId, resolveNodeKey(ir.root.keyTemplate));
|
|
123
|
+
if (rootState?.state === "paused") {
|
|
124
|
+
meta.status = "paused";
|
|
125
|
+
}
|
|
126
|
+
else if (rootState?.state === "cancelled") {
|
|
127
|
+
meta.status = "cancelled";
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
meta.status = "failed";
|
|
131
|
+
void error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
// Clean up per-runId scheduling guards when the run settles,
|
|
136
|
+
// so they don't leak memory across runs.
|
|
137
|
+
this.schedulingPaused.delete(runId);
|
|
138
|
+
this.schedulingCancelled.delete(runId);
|
|
139
|
+
}
|
|
140
|
+
meta.updatedAt = new Date().toISOString();
|
|
141
|
+
this.store.writeRunMeta(runId, meta);
|
|
142
|
+
return meta;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Reset any nodes persisted as `running` (or `awaiting`) back to `pending`
|
|
146
|
+
* (crash recovery). Safe to call when adopting a Run after a supervisor
|
|
147
|
+
* restart: in-memory abort controllers and approval resolvers are gone, so a
|
|
148
|
+
* node marked `running`/`awaiting` on disk has no live execution and must be
|
|
149
|
+
* re-runnable. An `awaiting` Approval Gate re-registers its resolver and waits
|
|
150
|
+
* for a fresh human decision on re-execution.
|
|
151
|
+
*/
|
|
152
|
+
recoverStaleNodes(runId) {
|
|
153
|
+
for (const nodeState of this.store.listNodeStates(runId)) {
|
|
154
|
+
if (nodeState.state === "running") {
|
|
155
|
+
nodeState.state = resetRunningForCrashRecovery(nodeState.state);
|
|
156
|
+
this.store.writeNodeState(runId, nodeState);
|
|
157
|
+
}
|
|
158
|
+
else if (nodeState.state === "awaiting") {
|
|
159
|
+
nodeState.state = resetAwaitingForCrashRecovery(nodeState.state);
|
|
160
|
+
this.store.writeNodeState(runId, nodeState);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Deterministically replay a persisted Run and verify its reconstructed
|
|
166
|
+
* Node topology.
|
|
167
|
+
*
|
|
168
|
+
* Re-walks the frozen IR snapshot (never the mutable YAML), feeding recorded
|
|
169
|
+
* per-node outputs back into the expression context so control-flow decisions
|
|
170
|
+
* (switch branches, loop rounds, fanout lanes) are re-derived. No agents or
|
|
171
|
+
* programs are executed, no disk writes occur, and the walk is pinned to the
|
|
172
|
+
* recorded runId + a frozen clock for self-determinism. The set of node keys
|
|
173
|
+
* the re-walk reaches is compared against what was persisted; per-node
|
|
174
|
+
* terminal-state and output equivalence are out of scope for this milestone.
|
|
175
|
+
*/
|
|
176
|
+
replay(runId) {
|
|
177
|
+
const ir = this.store.readIr(runId);
|
|
178
|
+
const input = this.store.readInput(runId);
|
|
179
|
+
if (!ir || !input) {
|
|
180
|
+
throw new Error(`Run ${runId} not found`);
|
|
181
|
+
}
|
|
182
|
+
// Pin determinism to the run's frozen creation clock. `createdAt` is written
|
|
183
|
+
// once at run init and never mutated (unlike `updatedAt`), so it is a stable
|
|
184
|
+
// deterministic-clock source for re-deriving any now()-dependent values.
|
|
185
|
+
// NOTE: aligning the execution-time clock to this same value (so now()-driven
|
|
186
|
+
// control flow replays identically) is a follow-up; R3 replay verifies node
|
|
187
|
+
// topology (reached key set), which does not depend on wall-clock time.
|
|
188
|
+
const meta = this.store.readRunMeta(runId);
|
|
189
|
+
const evaluator = new ExpressionEvaluator({ nowTimestamp: meta?.createdAt });
|
|
190
|
+
// Persisted (recorded) node states, keyed by node key.
|
|
191
|
+
const recorded = new Map();
|
|
192
|
+
for (const s of this.store.listNodeStates(runId))
|
|
193
|
+
recorded.set(s.nodeKey, s);
|
|
194
|
+
// States reached by the deterministic re-walk.
|
|
195
|
+
const reached = new Map();
|
|
196
|
+
const ctx = this.buildContext(input, runId);
|
|
197
|
+
this.replayNode(ir.root, ctx, runId, {}, undefined, recorded, reached, evaluator);
|
|
198
|
+
// Compare the set of node keys reached by the re-walk against what was
|
|
199
|
+
// persisted. `reached` carries each node's recorded state for reporting; the
|
|
200
|
+
// effective check is topological (which keys the interpretation reaches),
|
|
201
|
+
// since recorded outputs are replayed rather than recomputed.
|
|
202
|
+
const mismatches = [];
|
|
203
|
+
for (const [key, recordedState] of recorded) {
|
|
204
|
+
const actual = reached.get(key);
|
|
205
|
+
if (actual === undefined) {
|
|
206
|
+
mismatches.push({ nodeKey: key, kind: "missing-in-replay", expected: recordedState.state });
|
|
207
|
+
}
|
|
208
|
+
else if (actual !== recordedState.state) {
|
|
209
|
+
mismatches.push({ nodeKey: key, kind: "state", expected: recordedState.state, actual });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const [key, actual] of reached) {
|
|
213
|
+
if (!recorded.has(key)) {
|
|
214
|
+
mismatches.push({ nodeKey: key, kind: "unexpected-in-replay", actual });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { runId, ok: mismatches.length === 0, mismatches };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Read-only re-walk used by {@link replay}. Mirrors the control-flow dispatch
|
|
221
|
+
* of executeNode but never executes leaves: it replays recorded outputs into
|
|
222
|
+
* the context and records the reconstructed node key → state. Concurrent
|
|
223
|
+
* containers (parallel/fanout) descend only into the lanes/branches that were
|
|
224
|
+
* actually recorded (racy joins do not run every branch).
|
|
225
|
+
*/
|
|
226
|
+
replayNode(node, ctx, runId, dynamic, keyPrefix, recorded, reached, evaluator) {
|
|
227
|
+
const resolved = resolveNodeKey(node.keyTemplate, dynamic);
|
|
228
|
+
const nodeKey = keyPrefix ? `${keyPrefix}/${resolved}` : resolved;
|
|
229
|
+
const rec = recorded.get(nodeKey);
|
|
230
|
+
// A node absent from the recording was never reached on the original walk
|
|
231
|
+
// (e.g. an untaken switch branch); skip it so we don't fabricate topology.
|
|
232
|
+
if (!rec)
|
|
233
|
+
return;
|
|
234
|
+
reached.set(nodeKey, rec.state);
|
|
235
|
+
// Feed the recorded output into the step context so downstream decisions
|
|
236
|
+
// re-derive identically. Leaves contribute their output; containers below
|
|
237
|
+
// populate ctx.steps for their own children as they descend.
|
|
238
|
+
if (rec.output !== undefined)
|
|
239
|
+
ctx.steps[node.id] = rec.output;
|
|
240
|
+
switch (node.kind) {
|
|
241
|
+
case "pipeline":
|
|
242
|
+
for (const child of node.children ?? []) {
|
|
243
|
+
this.replayNode(child, ctx, runId, dynamic, keyPrefix, recorded, reached, evaluator);
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
case "parallel":
|
|
247
|
+
(node.children ?? []).forEach((child, index) => {
|
|
248
|
+
const branchDynamic = { ...dynamic, parallelBranchId: String(index) };
|
|
249
|
+
this.replayNode(child, ctx, runId, branchDynamic, keyPrefix, recorded, reached, evaluator);
|
|
250
|
+
});
|
|
251
|
+
break;
|
|
252
|
+
case "fanout": {
|
|
253
|
+
const overExpr = node.metadata.over;
|
|
254
|
+
const items = overExpr ? evaluator.evaluateOverExpression(overExpr, ctx) : [];
|
|
255
|
+
items.forEach((item, index) => {
|
|
256
|
+
const keyCtx = { ...ctx, item, item_index: index };
|
|
257
|
+
const itemId = this.extractItemId(item, node.metadata.key, index, keyCtx, evaluator);
|
|
258
|
+
const itemDynamic = { ...dynamic, fanoutItemId: itemId, laneId: String(index) };
|
|
259
|
+
const itemCtx = { ...keyCtx, steps: { ...ctx.steps }, item_id: itemId };
|
|
260
|
+
for (const child of node.children ?? []) {
|
|
261
|
+
this.replayNode(child, itemCtx, runId, itemDynamic, keyPrefix, recorded, reached, evaluator);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
case "switch":
|
|
267
|
+
for (const branch of node.branches ?? []) {
|
|
268
|
+
const taken = !branch.when || Boolean(evaluator.evaluateExpression(branch.when, ctx));
|
|
269
|
+
if (taken) {
|
|
270
|
+
for (const child of branch.children) {
|
|
271
|
+
this.replayNode(child, ctx, runId, dynamic, keyPrefix, recorded, reached, evaluator);
|
|
272
|
+
}
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
break;
|
|
277
|
+
case "loop": {
|
|
278
|
+
const untilExpr = node.metadata.until;
|
|
279
|
+
const maxIterations = node.metadata.max_iterations ?? 100;
|
|
280
|
+
let lastOutput;
|
|
281
|
+
for (let iter = 0; iter < maxIterations; iter++) {
|
|
282
|
+
const loopCtx = { ...ctx, loop: { iter, last: lastOutput } };
|
|
283
|
+
const loopDynamic = { ...dynamic, loopRound: iter };
|
|
284
|
+
if (untilExpr && iter > 0 && evaluator.evaluateExpression(untilExpr, loopCtx))
|
|
285
|
+
break;
|
|
286
|
+
// Only descend while this round's children were actually recorded.
|
|
287
|
+
let anyChildReached = false;
|
|
288
|
+
for (const child of node.children ?? []) {
|
|
289
|
+
const before = reached.size;
|
|
290
|
+
this.replayNode(child, loopCtx, runId, loopDynamic, keyPrefix, recorded, reached, evaluator);
|
|
291
|
+
if (reached.size > before)
|
|
292
|
+
anyChildReached = true;
|
|
293
|
+
const childKey = `${keyPrefix ? `${keyPrefix}/` : ""}${resolveNodeKey(child.keyTemplate, loopDynamic)}`;
|
|
294
|
+
lastOutput = recorded.get(childKey)?.output ?? lastOutput;
|
|
295
|
+
}
|
|
296
|
+
if (!anyChildReached)
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case "subworkflow":
|
|
302
|
+
// The child root was executed under this node's key as prefix; descend
|
|
303
|
+
// using recorded child node states (frozen child IR is not re-read).
|
|
304
|
+
for (const [key, state] of recorded) {
|
|
305
|
+
if (key.startsWith(`${nodeKey}/`) && !reached.has(key)) {
|
|
306
|
+
reached.set(key, state.state);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
break;
|
|
310
|
+
// run.agent / run.program / approval are leaves: their recorded state was
|
|
311
|
+
// already captured above; nothing further to descend.
|
|
312
|
+
default:
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** Pause one running node as part of a Run-level pause operation. */
|
|
317
|
+
pauseRunningNode(runId, nodeKey) {
|
|
318
|
+
const state = this.store.readNodeState(runId, nodeKey);
|
|
319
|
+
if (!state || !canTransition(state.state, "paused")) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
this.abortIntents.set(`${runId}:${nodeKey}`, "paused");
|
|
323
|
+
const controller = this.abortControllers.get(`${runId}:${nodeKey}`);
|
|
324
|
+
if (controller) {
|
|
325
|
+
controller.abort();
|
|
326
|
+
}
|
|
327
|
+
state.state = transition(state.state, "paused");
|
|
328
|
+
this.store.writeNodeState(runId, state);
|
|
329
|
+
}
|
|
330
|
+
/** Cancel one materialized node as part of Run-level cancel or fail-fast. */
|
|
331
|
+
cancelMaterializedNode(runId, nodeKey) {
|
|
332
|
+
this.abortIntents.set(`${runId}:${nodeKey}`, "cancelled");
|
|
333
|
+
const controller = this.abortControllers.get(`${runId}:${nodeKey}`);
|
|
334
|
+
if (controller) {
|
|
335
|
+
controller.abort();
|
|
336
|
+
}
|
|
337
|
+
const state = this.store.readNodeState(runId, nodeKey);
|
|
338
|
+
if (state && canTransition(state.state, "cancelled")) {
|
|
339
|
+
state.state = transition(state.state, "cancelled");
|
|
340
|
+
this.store.writeNodeState(runId, state);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Resolve the operator intent for an in-flight abort; defaults to paused. */
|
|
344
|
+
abortIntent(runId, nodeKey) {
|
|
345
|
+
return this.abortIntents.get(`${runId}:${nodeKey}`) ?? "paused";
|
|
346
|
+
}
|
|
347
|
+
/** True if the node has been persisted as terminal `cancelled` (e.g. by
|
|
348
|
+
* fail-fast). Used to avoid clobbering it when a still-running leaf later
|
|
349
|
+
* resolves/rejects. */
|
|
350
|
+
readAbortedStateOnDisk(runId, nodeKey) {
|
|
351
|
+
const state = this.store.readNodeState(runId, nodeKey)?.state;
|
|
352
|
+
return state === "paused" || state === "cancelled" ? state : undefined;
|
|
353
|
+
}
|
|
354
|
+
isStaleAttemptOnDisk(runId, nodeKey, attempt, startedAt) {
|
|
355
|
+
const current = this.store.readNodeState(runId, nodeKey);
|
|
356
|
+
return current !== undefined && current.attempt > attempt && current.startedAt !== startedAt;
|
|
357
|
+
}
|
|
358
|
+
syncInFrameAttemptFromDisk(runId, nodeKey, state) {
|
|
359
|
+
const current = this.store.readNodeState(runId, nodeKey);
|
|
360
|
+
if (current !== undefined && current.startedAt === state.startedAt && current.attempt > state.attempt) {
|
|
361
|
+
state.attempt = current.attempt;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Retry a failed executable Node. This is a local repair operation for the
|
|
366
|
+
* target executable only; Run-level retry remains the operation that restores
|
|
367
|
+
* Workflow progress from failed composite ancestors.
|
|
368
|
+
*/
|
|
369
|
+
async retryNode(runId, nodeKey) {
|
|
370
|
+
const state = this.store.readNodeState(runId, nodeKey);
|
|
371
|
+
if (!state) {
|
|
372
|
+
throw new Error(`Node ${nodeKey} not found in run ${runId}`);
|
|
373
|
+
}
|
|
374
|
+
if (state.state !== "failed") {
|
|
375
|
+
throw new Error(`Cannot retry node ${nodeKey} in state '${state.state}': only failed executable nodes are retryable`);
|
|
376
|
+
}
|
|
377
|
+
if (state.kind !== "run.agent" && state.kind !== "run.program") {
|
|
378
|
+
throw new Error(`Cannot retry node ${nodeKey}: only failed executable nodes are retryable`);
|
|
379
|
+
}
|
|
380
|
+
const meta = this.store.readRunMeta(runId);
|
|
381
|
+
if (!meta)
|
|
382
|
+
throw new Error(`Run ${runId} not found`);
|
|
383
|
+
if (meta.status !== "failed") {
|
|
384
|
+
throw new Error(`Cannot retry node ${nodeKey}: node retry is accepted only when the Run is failed`);
|
|
385
|
+
}
|
|
386
|
+
const ir = this.store.readIr(runId);
|
|
387
|
+
const input = this.store.readInput(runId);
|
|
388
|
+
if (!ir || !input) {
|
|
389
|
+
throw new Error(`Cannot retry node ${nodeKey}: run ${runId} has no persisted IR or input`);
|
|
390
|
+
}
|
|
391
|
+
// Resolve the node IR *before* mutating state. Subworkflow child IR is
|
|
392
|
+
// compiled on demand and never persisted, so a child key like
|
|
393
|
+
// `workflow/sub/child` is unresolvable here; reject explicitly rather than
|
|
394
|
+
// silently no-op and leave the node stuck in a pending state.
|
|
395
|
+
const node = this.findNodeByKey(ir.root, nodeKey);
|
|
396
|
+
if (!node) {
|
|
397
|
+
throw new Error(`Cannot retry node ${nodeKey}: its definition was not found in the run's IR (subworkflow child nodes are not individually retryable)`);
|
|
398
|
+
}
|
|
399
|
+
// Control-plane reset (failed → pending), not a business-lifecycle
|
|
400
|
+
// transition. `attempt` is incremented by executeNode.
|
|
401
|
+
state.state = resetFailedForRetry(state.state);
|
|
402
|
+
state.error = undefined;
|
|
403
|
+
this.store.writeNodeState(runId, state);
|
|
404
|
+
const ctx = this.buildContext(input, runId);
|
|
405
|
+
this.populateStepOutputs(runId, ctx);
|
|
406
|
+
// Restore the parent dynamic value-context captured at first execution.
|
|
407
|
+
this.restoreDynamicContext(ctx, state.dynamicContext);
|
|
408
|
+
// Retry re-runs the executable as a continuation: agents keep the same acpx
|
|
409
|
+
// session name and receive the fixed continuation prompt. The original full
|
|
410
|
+
// node key is preserved for stable identity.
|
|
411
|
+
await this.executeNode(node, ctx, runId, {}, undefined, true, nodeKey);
|
|
412
|
+
}
|
|
413
|
+
// ─── Run-level controls ─────────────────────────────────────────
|
|
414
|
+
/**
|
|
415
|
+
* Pause an entire Run. Validates Run is `running`, sets scheduling guard,
|
|
416
|
+
* pauses all running nodes, and updates Run metadata to `paused`.
|
|
417
|
+
*/
|
|
418
|
+
pauseRun(runId) {
|
|
419
|
+
const meta = this.store.readRunMeta(runId);
|
|
420
|
+
if (!meta)
|
|
421
|
+
throw new Error(`Run ${runId} not found`);
|
|
422
|
+
if (meta.status !== "running") {
|
|
423
|
+
throw new Error(`Cannot pause a run in state '${meta.status}'`);
|
|
424
|
+
}
|
|
425
|
+
this.schedulingPaused.set(runId, true);
|
|
426
|
+
// Pause all currently running nodes
|
|
427
|
+
for (const nodeState of this.store.listNodeStates(runId)) {
|
|
428
|
+
if (nodeState.state === "running") {
|
|
429
|
+
this.pauseRunningNode(runId, nodeState.nodeKey);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
meta.status = "paused";
|
|
433
|
+
meta.updatedAt = new Date().toISOString();
|
|
434
|
+
this.store.writeRunMeta(runId, meta);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Cancel an entire Run. Validates Run is `running` or `paused`, sets
|
|
438
|
+
* scheduling guard, cancels running nodes, marks pending nodes as cancelled,
|
|
439
|
+
* and updates Run metadata to `cancelled`.
|
|
440
|
+
*/
|
|
441
|
+
cancelRun(runId) {
|
|
442
|
+
const meta = this.store.readRunMeta(runId);
|
|
443
|
+
if (!meta)
|
|
444
|
+
throw new Error(`Run ${runId} not found`);
|
|
445
|
+
if (meta.status !== "running" && meta.status !== "paused") {
|
|
446
|
+
throw new Error(`Cannot cancel a run in state '${meta.status}'`);
|
|
447
|
+
}
|
|
448
|
+
this.schedulingCancelled.set(runId, true);
|
|
449
|
+
// Override any prior pause abort intents to "cancelled" so that
|
|
450
|
+
// runToCompletion's catch block writes "cancelled" (not "paused").
|
|
451
|
+
for (const [key, intent] of this.abortIntents) {
|
|
452
|
+
if (key.startsWith(`${runId}:`) && intent === "paused") {
|
|
453
|
+
this.abortIntents.set(key, "cancelled");
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// Cancel all currently running or awaiting nodes (abort the in-flight leaf
|
|
457
|
+
// or the pending approval wait, then transition to cancelled).
|
|
458
|
+
for (const nodeState of this.store.listNodeStates(runId)) {
|
|
459
|
+
if (nodeState.state === "running" || nodeState.state === "awaiting") {
|
|
460
|
+
this.cancelMaterializedNode(runId, nodeState.nodeKey);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// Also transition paused nodes to cancelled (a prior pauseRun may have
|
|
464
|
+
// left nodes in "paused" state; cancel supersedes pause).
|
|
465
|
+
for (const nodeState of this.store.listNodeStates(runId)) {
|
|
466
|
+
if (nodeState.state === "paused") {
|
|
467
|
+
if (canTransition(nodeState.state, "cancelled")) {
|
|
468
|
+
nodeState.state = transition(nodeState.state, "cancelled");
|
|
469
|
+
this.store.writeNodeState(runId, nodeState);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// Mark all pending nodes as cancelled (do NOT materialize unvisited nodes)
|
|
474
|
+
for (const nodeState of this.store.listNodeStates(runId)) {
|
|
475
|
+
if (nodeState.state === "pending") {
|
|
476
|
+
if (canTransition(nodeState.state, "cancelled")) {
|
|
477
|
+
nodeState.state = transition(nodeState.state, "cancelled");
|
|
478
|
+
this.store.writeNodeState(runId, nodeState);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
meta.status = "cancelled";
|
|
483
|
+
meta.updatedAt = new Date().toISOString();
|
|
484
|
+
this.store.writeRunMeta(runId, meta);
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Resume an entire paused Run. Validates Run is `paused`, clears scheduling
|
|
488
|
+
* guards, recovers stale nodes, and re-executes from root.
|
|
489
|
+
*/
|
|
490
|
+
async resumeRun(runId) {
|
|
491
|
+
const meta = this.store.readRunMeta(runId);
|
|
492
|
+
if (!meta)
|
|
493
|
+
throw new Error(`Run ${runId} not found`);
|
|
494
|
+
if (meta.status !== "paused") {
|
|
495
|
+
throw new Error(`Cannot resume a run in state '${meta.status}'`);
|
|
496
|
+
}
|
|
497
|
+
// Clear scheduling guards for this run
|
|
498
|
+
this.schedulingPaused.delete(runId);
|
|
499
|
+
this.schedulingCancelled.delete(runId);
|
|
500
|
+
// Recover stale running nodes back to pending
|
|
501
|
+
this.recoverStaleNodes(runId);
|
|
502
|
+
// Also reset paused nodes back to pending so runToCompletion can re-execute them.
|
|
503
|
+
// Without this, executeNode sees the 'paused' state and throws NodeAbortedError,
|
|
504
|
+
// making the run permanently stuck.
|
|
505
|
+
for (const nodeState of this.store.listNodeStates(runId)) {
|
|
506
|
+
if (nodeState.state === "paused") {
|
|
507
|
+
nodeState.state = resetPausedForRunResume(nodeState.state);
|
|
508
|
+
this.store.writeNodeState(runId, nodeState);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
meta.status = "running";
|
|
512
|
+
meta.updatedAt = new Date().toISOString();
|
|
513
|
+
this.store.writeRunMeta(runId, meta);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Retry a failed Run. Validates Run is `failed`, resets failed materialized
|
|
517
|
+
* nodes to pending (preserving completed), clears scheduling guards, and
|
|
518
|
+
* re-executes from root. Same Run ID, no new Run.
|
|
519
|
+
*/
|
|
520
|
+
retryRun(runId) {
|
|
521
|
+
const meta = this.store.readRunMeta(runId);
|
|
522
|
+
if (!meta)
|
|
523
|
+
throw new Error(`Run ${runId} not found`);
|
|
524
|
+
if (meta.status !== "failed") {
|
|
525
|
+
throw new Error(`Cannot retry a run in state '${meta.status}'`);
|
|
526
|
+
}
|
|
527
|
+
// Clear scheduling guards for this run
|
|
528
|
+
this.schedulingPaused.delete(runId);
|
|
529
|
+
this.schedulingCancelled.delete(runId);
|
|
530
|
+
// Reset failed and paused materialized nodes to pending (preserve completed).
|
|
531
|
+
// Paused nodes can exist in a "failed" run if it was paused before a sibling
|
|
532
|
+
// failed (e.g. parallel lane failure while another lane was paused).
|
|
533
|
+
for (const nodeState of this.store.listNodeStates(runId)) {
|
|
534
|
+
if (nodeState.state === "failed") {
|
|
535
|
+
nodeState.state = resetFailedForRetry(nodeState.state);
|
|
536
|
+
nodeState.error = undefined;
|
|
537
|
+
this.store.writeNodeState(runId, nodeState);
|
|
538
|
+
}
|
|
539
|
+
else if (nodeState.state === "paused") {
|
|
540
|
+
nodeState.state = resetPausedForRunResume(nodeState.state);
|
|
541
|
+
this.store.writeNodeState(runId, nodeState);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
meta.status = "running";
|
|
545
|
+
meta.runAttempt++;
|
|
546
|
+
meta.updatedAt = new Date().toISOString();
|
|
547
|
+
this.store.writeRunMeta(runId, meta);
|
|
548
|
+
}
|
|
549
|
+
// ─── Node execution dispatch ──────────────────────────────────
|
|
550
|
+
async executeNode(node, ctx, runId, dynamic, keyPrefix, isContinuation, overrideNodeKey) {
|
|
551
|
+
// On continuation/retry the full resolved node key is supplied directly so the
|
|
552
|
+
// node's stable identity (and thus the agent's acpx session name) survives
|
|
553
|
+
// across loop/fanout/lane/subworkflow dynamics that are not re-derived here.
|
|
554
|
+
const resolved = resolveNodeKey(node.keyTemplate, dynamic);
|
|
555
|
+
const nodeKey = overrideNodeKey ?? (keyPrefix ? `${keyPrefix}/${resolved}` : resolved);
|
|
556
|
+
// Check if already completed (from prior run)
|
|
557
|
+
const existing = this.store.readNodeState(runId, nodeKey);
|
|
558
|
+
if (existing?.state === "completed") {
|
|
559
|
+
ctx.steps[node.id] = existing.output;
|
|
560
|
+
return existing.output;
|
|
561
|
+
}
|
|
562
|
+
if (existing?.state === "cancelled" || existing?.state === "paused") {
|
|
563
|
+
throw new NodeAbortedError(nodeKey, existing.state);
|
|
564
|
+
}
|
|
565
|
+
const continuation = Boolean(isContinuation || isPausedContinuationState(existing));
|
|
566
|
+
// Initialize state
|
|
567
|
+
const state = existing ?? createInitialNodeState(nodeKey, node.id, node.kind);
|
|
568
|
+
if (state.state === "failed") {
|
|
569
|
+
throw new Error(`Node ${nodeKey} is in failed state`);
|
|
570
|
+
}
|
|
571
|
+
// Run-level scheduling guards: if this Run is paused or cancelled,
|
|
572
|
+
// don't schedule new work.
|
|
573
|
+
if (this.schedulingPaused.get(runId) && state.state === "pending") {
|
|
574
|
+
throw new NodeAbortedError(nodeKey, "paused");
|
|
575
|
+
}
|
|
576
|
+
if (this.schedulingCancelled.get(runId) && state.state === "pending") {
|
|
577
|
+
if (canTransition(state.state, "cancelled")) {
|
|
578
|
+
state.state = transition(state.state, "cancelled");
|
|
579
|
+
this.store.writeNodeState(runId, state);
|
|
580
|
+
}
|
|
581
|
+
throw new NodeAbortedError(nodeKey, "cancelled");
|
|
582
|
+
}
|
|
583
|
+
// Set up abort controller
|
|
584
|
+
const controller = new AbortController();
|
|
585
|
+
this.abortControllers.set(`${runId}:${nodeKey}`, controller);
|
|
586
|
+
// Transition to running
|
|
587
|
+
if (canTransition(state.state, "running")) {
|
|
588
|
+
state.state = transition(state.state, "running");
|
|
589
|
+
}
|
|
590
|
+
state.attempt++;
|
|
591
|
+
state.startedAt = new Date().toISOString();
|
|
592
|
+
// Snapshot the parent dynamic value-context (fanout item / loop round) for
|
|
593
|
+
// executable leaves so retry/continuation can re-render their command/prompt
|
|
594
|
+
// without the parent re-deriving item/loop. Only captured on fresh entry
|
|
595
|
+
// (retry/continuation restore it from disk into ctx).
|
|
596
|
+
if (!continuation && (node.kind === "run.agent" || node.kind === "run.program")) {
|
|
597
|
+
const snapshot = this.captureDynamicContext(ctx);
|
|
598
|
+
if (snapshot)
|
|
599
|
+
state.dynamicContext = snapshot;
|
|
600
|
+
}
|
|
601
|
+
this.store.writeNodeState(runId, state);
|
|
602
|
+
try {
|
|
603
|
+
let output;
|
|
604
|
+
let completeScope = false;
|
|
605
|
+
// Artifact refs produced by a leaf execution in this call frame. Kept as a
|
|
606
|
+
// local (not a shared field) so concurrent parallel/fanout siblings can't
|
|
607
|
+
// clobber each other's refs.
|
|
608
|
+
let artifactRefs;
|
|
609
|
+
switch (node.kind) {
|
|
610
|
+
case "pipeline":
|
|
611
|
+
output = await this.executePipeline(node, ctx, runId, dynamic, keyPrefix);
|
|
612
|
+
break;
|
|
613
|
+
case "run.agent": {
|
|
614
|
+
const leaf = await this.executeAgent(node, ctx, runId, controller.signal, nodeKey, continuation);
|
|
615
|
+
output = leaf.output;
|
|
616
|
+
artifactRefs = leaf.artifactRefs;
|
|
617
|
+
if (leaf.renderedPrompt)
|
|
618
|
+
state.renderedPrompt = leaf.renderedPrompt;
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
case "run.program": {
|
|
622
|
+
const leaf = await this.executeProgram(node, ctx, runId, controller.signal, nodeKey);
|
|
623
|
+
output = leaf.output;
|
|
624
|
+
artifactRefs = leaf.artifactRefs;
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
case "parallel":
|
|
628
|
+
output = await this.executeParallel(node, ctx, runId, dynamic, keyPrefix);
|
|
629
|
+
break;
|
|
630
|
+
case "fanout":
|
|
631
|
+
output = await this.executeFanout(node, ctx, runId, dynamic, keyPrefix);
|
|
632
|
+
break;
|
|
633
|
+
case "switch":
|
|
634
|
+
output = await this.executeSwitch(node, ctx, runId, dynamic, keyPrefix);
|
|
635
|
+
break;
|
|
636
|
+
case "loop":
|
|
637
|
+
output = await this.executeLoop(node, ctx, runId, dynamic, keyPrefix);
|
|
638
|
+
break;
|
|
639
|
+
case "guard": {
|
|
640
|
+
const guard = this.executeGuard(node, ctx);
|
|
641
|
+
output = guard.output;
|
|
642
|
+
completeScope = guard.completeScope;
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
case "approval":
|
|
646
|
+
output = await this.executeApproval(node, ctx, runId, controller.signal, nodeKey);
|
|
647
|
+
break;
|
|
648
|
+
case "subworkflow":
|
|
649
|
+
output = await this.executeSubworkflow(node, ctx, runId, dynamic, nodeKey);
|
|
650
|
+
break;
|
|
651
|
+
default:
|
|
652
|
+
throw new Error(`Unknown node kind: ${node.kind}`);
|
|
653
|
+
}
|
|
654
|
+
// If a Run-level resume already re-entered this node, a late result from
|
|
655
|
+
// the old attempt must not overwrite the newer attempt's state.
|
|
656
|
+
if (this.isStaleAttemptOnDisk(runId, nodeKey, state.attempt, state.startedAt)) {
|
|
657
|
+
return output;
|
|
658
|
+
}
|
|
659
|
+
this.syncInFrameAttemptFromDisk(runId, nodeKey, state);
|
|
660
|
+
// Transition to completed unless an external pause/cancel already changed
|
|
661
|
+
// this node's state on disk while we were awaiting the leaf.
|
|
662
|
+
const abortedState = this.readAbortedStateOnDisk(runId, nodeKey);
|
|
663
|
+
if (abortedState) {
|
|
664
|
+
throw new NodeAbortedError(nodeKey, abortedState, artifactRefs, output, state.renderedPrompt);
|
|
665
|
+
}
|
|
666
|
+
state.state = "completed";
|
|
667
|
+
state.error = undefined;
|
|
668
|
+
state.output = output;
|
|
669
|
+
if (artifactRefs)
|
|
670
|
+
state.artifactRefs = artifactRefs;
|
|
671
|
+
state.completedAt = new Date().toISOString();
|
|
672
|
+
this.store.writeNodeState(runId, state);
|
|
673
|
+
// Add output to step context
|
|
674
|
+
ctx.steps[node.id] = output;
|
|
675
|
+
if (completeScope) {
|
|
676
|
+
throw new ScopeCompleted(output);
|
|
677
|
+
}
|
|
678
|
+
return output;
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
if (error instanceof ScopeCompleted) {
|
|
682
|
+
throw error;
|
|
683
|
+
}
|
|
684
|
+
if (error instanceof NodeAbortedError) {
|
|
685
|
+
if (this.isStaleAttemptOnDisk(runId, nodeKey, state.attempt, state.startedAt)) {
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
this.syncInFrameAttemptFromDisk(runId, nodeKey, state);
|
|
689
|
+
// Transition this node to the same state as the child that was aborted.
|
|
690
|
+
state.state = error.state === "paused" ? "paused" : "cancelled";
|
|
691
|
+
state.error = abortedNodeError(error.state);
|
|
692
|
+
// Preserve any output + partial transcript artifacts from the aborted leaf.
|
|
693
|
+
if (error.output !== undefined)
|
|
694
|
+
state.output = error.output;
|
|
695
|
+
if (error.artifactRefs)
|
|
696
|
+
state.artifactRefs = error.artifactRefs;
|
|
697
|
+
state.renderedPrompt = error.renderedPrompt ?? state.renderedPrompt ?? this.store.readNodeState(runId, nodeKey)?.renderedPrompt;
|
|
698
|
+
this.store.writeNodeState(runId, state);
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
// Don't clobber an external pause/cancel that landed while this node's
|
|
702
|
+
// leaf was failing; keep the control-plane abort as the observed outcome.
|
|
703
|
+
if (this.isStaleAttemptOnDisk(runId, nodeKey, state.attempt, state.startedAt)) {
|
|
704
|
+
throw error;
|
|
705
|
+
}
|
|
706
|
+
this.syncInFrameAttemptFromDisk(runId, nodeKey, state);
|
|
707
|
+
const abortedState = this.readAbortedStateOnDisk(runId, nodeKey);
|
|
708
|
+
if (abortedState) {
|
|
709
|
+
throw new NodeAbortedError(nodeKey, abortedState);
|
|
710
|
+
}
|
|
711
|
+
state.state = "failed";
|
|
712
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
713
|
+
// Preserve artifacts and invalid output a leaf produced before failing.
|
|
714
|
+
if (error instanceof LeafExecutionError) {
|
|
715
|
+
if (error.artifactRefs)
|
|
716
|
+
state.artifactRefs = error.artifactRefs;
|
|
717
|
+
if (error.output !== undefined)
|
|
718
|
+
state.output = error.output;
|
|
719
|
+
}
|
|
720
|
+
if (error instanceof GuardFailureError) {
|
|
721
|
+
state.output = error.output;
|
|
722
|
+
}
|
|
723
|
+
state.completedAt = new Date().toISOString();
|
|
724
|
+
this.store.writeNodeState(runId, state);
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
727
|
+
finally {
|
|
728
|
+
this.abortControllers.delete(`${runId}:${nodeKey}`);
|
|
729
|
+
this.abortIntents.delete(`${runId}:${nodeKey}`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// ─── Kind-specific execution ───────────────────────────────────
|
|
733
|
+
async executePipeline(node, ctx, runId, dynamic, keyPrefix) {
|
|
734
|
+
const children = node.children ?? [];
|
|
735
|
+
for (const child of children) {
|
|
736
|
+
try {
|
|
737
|
+
await this.executeNode(child, ctx, runId, dynamic, keyPrefix);
|
|
738
|
+
}
|
|
739
|
+
catch (error) {
|
|
740
|
+
if (error instanceof ScopeCompleted)
|
|
741
|
+
return error.output;
|
|
742
|
+
throw error;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
// Pipeline output: map of step outputs
|
|
746
|
+
return { ...ctx.steps };
|
|
747
|
+
}
|
|
748
|
+
async executeAgent(node, ctx, runId, signal, nodeKey, continuation) {
|
|
749
|
+
const retry = node.metadata.retry;
|
|
750
|
+
const hasOutputSchema = node.metadata.output !== undefined;
|
|
751
|
+
const maxRetries = typeof retry?.max === "number" ? retry.max : hasOutputSchema ? 2 : 0;
|
|
752
|
+
const backoffMs = retry?.backoff ? parseDurationMs(retry.backoff) : 0;
|
|
753
|
+
const allArtifactRefs = [...(this.store.readNodeState(runId, nodeKey)?.artifactRefs ?? [])];
|
|
754
|
+
// `attempt` is local to this executor call and controls parse/schema
|
|
755
|
+
// auto-retry. The persisted `state.attempt` is the durable attempt sequence
|
|
756
|
+
// used for node state and artifact filenames across pause/resume/manual retry.
|
|
757
|
+
for (let attempt = 0;; attempt++) {
|
|
758
|
+
const attemptNo = this.store.readNodeState(runId, nodeKey)?.attempt ?? attempt + 1;
|
|
759
|
+
let preparedPrompt;
|
|
760
|
+
try {
|
|
761
|
+
preparedPrompt = renderAgentRequestPrompt(node, ctx, this.evaluator, Boolean(continuation), attempt > 0);
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
const use = node.metadata.agent?.use ?? "?";
|
|
765
|
+
throw new LeafExecutionError(`Agent step '${node.id}' (use: ${use}) failed (config): Failed to evaluate agent configuration template: ${error instanceof Error ? error.message : String(error)}`);
|
|
766
|
+
}
|
|
767
|
+
const liveRefs = this.startAgentAttemptArtifacts(runId, nodeKey, attemptNo, preparedPrompt);
|
|
768
|
+
pushUnique(allArtifactRefs, liveRefs);
|
|
769
|
+
this.publishRunningAgentAttempt(runId, nodeKey, preparedPrompt, allArtifactRefs);
|
|
770
|
+
const streamDiagnostics = [];
|
|
771
|
+
const result = await this.agentExecutor.execute({
|
|
772
|
+
node,
|
|
773
|
+
context: ctx,
|
|
774
|
+
signal,
|
|
775
|
+
nodeKey,
|
|
776
|
+
prompt: preparedPrompt,
|
|
777
|
+
continuation,
|
|
778
|
+
retry: attempt > 0,
|
|
779
|
+
onStream: (stream, chunk) => {
|
|
780
|
+
if (stream === "stdout" && chunk.length > 0) {
|
|
781
|
+
try {
|
|
782
|
+
this.appendAgentTranscript(runId, nodeKey, attemptNo, chunk);
|
|
783
|
+
}
|
|
784
|
+
catch (error) {
|
|
785
|
+
streamDiagnostics.push(`failed to append live agent transcript: ${error instanceof Error ? error.message : String(error)}`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
// Persist per-attempt human-readable agent IO plus raw protocol artifacts
|
|
791
|
+
// when the executor exposed them. Refs flow back to executeNode via the
|
|
792
|
+
// return value / thrown error (no shared mutable field).
|
|
793
|
+
const artifactRefs = result.responseText !== undefined || result.stdout !== undefined || result.stderr !== undefined
|
|
794
|
+
? this.writeAgentArtifacts(runId, nodeKey, attemptNo, {
|
|
795
|
+
responseText: result.responseText,
|
|
796
|
+
transcript: result.stdout,
|
|
797
|
+
stderr: mergeStderrDiagnostics(result.stderr, streamDiagnostics)
|
|
798
|
+
})
|
|
799
|
+
: result.artifactRefs;
|
|
800
|
+
if (artifactRefs)
|
|
801
|
+
pushUnique(allArtifactRefs, artifactRefs);
|
|
802
|
+
if (result.partial) {
|
|
803
|
+
// Operator abort → carry output + transcript refs on the abort error;
|
|
804
|
+
// executeNode persists the paused/cancelled state.
|
|
805
|
+
throw new NodeAbortedError(nodeKey, this.abortIntent(runId, nodeKey), allArtifactRefs.length > 0 ? allArtifactRefs : undefined, result.output, preparedPrompt);
|
|
806
|
+
}
|
|
807
|
+
// parse/schema failures are retryable while attempts remain.
|
|
808
|
+
const retryable = result.failureKind === "parse" || result.failureKind === "schema";
|
|
809
|
+
if (retryable && attempt < maxRetries) {
|
|
810
|
+
const state = this.store.readNodeState(runId, nodeKey);
|
|
811
|
+
if (state) {
|
|
812
|
+
state.attempt++;
|
|
813
|
+
this.store.writeNodeState(runId, state);
|
|
814
|
+
}
|
|
815
|
+
if (backoffMs > 0)
|
|
816
|
+
await this.sleep(backoffMs);
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
if (result.failureKind || (result.error && !result.partial)) {
|
|
820
|
+
const use = node.metadata.agent?.use ?? "?";
|
|
821
|
+
throw new LeafExecutionError(`Agent step '${node.id}' (use: ${use}) failed${result.failureKind ? ` (${result.failureKind})` : ""}: ${result.error ?? "unknown"}`, allArtifactRefs.length > 0 ? allArtifactRefs : artifactRefs, result.output);
|
|
822
|
+
}
|
|
823
|
+
// Agent output is wrapped in an envelope for parity with program steps.
|
|
824
|
+
return {
|
|
825
|
+
output: { output: result.output },
|
|
826
|
+
artifactRefs: allArtifactRefs.length > 0 ? allArtifactRefs : artifactRefs,
|
|
827
|
+
renderedPrompt: result.prompt ?? result.renderedPrompt
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
async executeProgram(node, ctx, runId, signal, nodeKey) {
|
|
832
|
+
const result = await this.programExecutor.execute({ node, context: ctx, signal, nodeKey });
|
|
833
|
+
// Operator abort → paused/cancelled (carry output on the abort error).
|
|
834
|
+
if (result.partial) {
|
|
835
|
+
throw new NodeAbortedError(nodeKey, this.abortIntent(runId, nodeKey), undefined, result.output);
|
|
836
|
+
}
|
|
837
|
+
// Always persist stdout/stderr as artifacts (even when empty). An artifact
|
|
838
|
+
// write failure is itself non-recoverable.
|
|
839
|
+
const artifactRefs = this.writeProgramArtifacts(runId, nodeKey, result.stdout ?? "", result.stderr ?? "");
|
|
840
|
+
// Non-recoverable failures fail the node.
|
|
841
|
+
if (result.failureKind) {
|
|
842
|
+
throw new LeafExecutionError(`Program step '${node.id}' failed (${result.failureKind}): ${result.error ?? "unknown"}`, artifactRefs);
|
|
843
|
+
}
|
|
844
|
+
// A non-zero exit code is step data. Expose output + exit_code envelope.
|
|
845
|
+
return { output: { output: result.output, exit_code: result.exitCode ?? 0 }, artifactRefs };
|
|
846
|
+
}
|
|
847
|
+
/** Write stdout.log/stderr.log artifacts; returns their URIs. */
|
|
848
|
+
writeProgramArtifacts(runId, nodeKey, stdout, stderr) {
|
|
849
|
+
const out = this.artifactStore.write(runId, nodeKey, "stdout.log", stdout);
|
|
850
|
+
const err = this.artifactStore.write(runId, nodeKey, "stderr.log", stderr);
|
|
851
|
+
return [out.uri, err.uri];
|
|
852
|
+
}
|
|
853
|
+
/** Write per-attempt agent response/protocol artifacts; returns their URIs. */
|
|
854
|
+
writeAgentArtifacts(runId, nodeKey, attemptNo, content) {
|
|
855
|
+
const prefix = this.agentAttemptPrefix(attemptNo);
|
|
856
|
+
const refs = [];
|
|
857
|
+
if (content.prompt !== undefined) {
|
|
858
|
+
refs.push(this.artifactStore.write(runId, nodeKey, `${prefix}.prompt.md`, content.prompt).uri);
|
|
859
|
+
}
|
|
860
|
+
if (content.responseText !== undefined) {
|
|
861
|
+
refs.push(this.artifactStore.write(runId, nodeKey, `${prefix}.response.md`, content.responseText).uri);
|
|
862
|
+
}
|
|
863
|
+
if (content.transcript !== undefined) {
|
|
864
|
+
// Transcript is pre-created and append-built while acpx runs. Finalization
|
|
865
|
+
// returns the live artifact ref without overwriting accumulated NDJSON.
|
|
866
|
+
refs.push(this.artifactStore.append(runId, nodeKey, `${prefix}.transcript.jsonl`, "").uri);
|
|
867
|
+
}
|
|
868
|
+
if (content.stderr !== undefined) {
|
|
869
|
+
refs.push(this.artifactStore.write(runId, nodeKey, `${prefix}.stderr.log`, content.stderr).uri);
|
|
870
|
+
}
|
|
871
|
+
return refs;
|
|
872
|
+
}
|
|
873
|
+
/** Pre-create live Agent attempt artifacts and return their refs immediately. */
|
|
874
|
+
startAgentAttemptArtifacts(runId, nodeKey, attemptNo, prompt) {
|
|
875
|
+
const prefix = this.agentAttemptPrefix(attemptNo);
|
|
876
|
+
const promptRef = this.artifactStore.write(runId, nodeKey, `${prefix}.prompt.md`, prompt);
|
|
877
|
+
const transcriptRef = this.artifactStore.create(runId, nodeKey, `${prefix}.transcript.jsonl`);
|
|
878
|
+
return [promptRef.uri, transcriptRef.uri];
|
|
879
|
+
}
|
|
880
|
+
appendAgentTranscript(runId, nodeKey, attemptNo, chunk) {
|
|
881
|
+
this.artifactStore.append(runId, nodeKey, `${this.agentAttemptPrefix(attemptNo)}.transcript.jsonl`, chunk);
|
|
882
|
+
}
|
|
883
|
+
publishRunningAgentAttempt(runId, nodeKey, prompt, artifactRefs) {
|
|
884
|
+
const state = this.store.readNodeState(runId, nodeKey);
|
|
885
|
+
if (!state)
|
|
886
|
+
return;
|
|
887
|
+
state.renderedPrompt = prompt;
|
|
888
|
+
state.artifactRefs = [...artifactRefs];
|
|
889
|
+
this.store.writeNodeState(runId, state);
|
|
890
|
+
}
|
|
891
|
+
agentAttemptPrefix(attemptNo) {
|
|
892
|
+
return `attempt-${String(Math.max(0, attemptNo)).padStart(3, "0")}`;
|
|
893
|
+
}
|
|
894
|
+
async executeParallel(node, ctx, runId, dynamic, keyPrefix) {
|
|
895
|
+
const children = node.children ?? [];
|
|
896
|
+
const maxConcurrency = node.metadata.max_concurrency ?? this.maxConcurrency;
|
|
897
|
+
const join = node.metadata.join ?? "all";
|
|
898
|
+
const limit = pLimit(maxConcurrency);
|
|
899
|
+
// Each branch resolves to { child, output } so race can identify the winner.
|
|
900
|
+
const branchPromises = children.map((child, index) => limit(async () => {
|
|
901
|
+
const branchDynamic = { ...dynamic, parallelBranchId: String(index) };
|
|
902
|
+
let output;
|
|
903
|
+
try {
|
|
904
|
+
output = await this.executeNode(child, { ...ctx, steps: { ...ctx.steps } }, runId, branchDynamic, keyPrefix);
|
|
905
|
+
}
|
|
906
|
+
catch (error) {
|
|
907
|
+
if (error instanceof ScopeCompleted) {
|
|
908
|
+
output = error.output;
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
throw error;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return { child, output };
|
|
915
|
+
}));
|
|
916
|
+
if (join === "race") {
|
|
917
|
+
try {
|
|
918
|
+
// First branch to settle wins; losers are not cancelled but silently
|
|
919
|
+
// consumed so their later rejection doesn't surface as unhandled.
|
|
920
|
+
const winner = await Promise.race(branchPromises);
|
|
921
|
+
branchPromises.forEach((p) => void p.catch(() => undefined));
|
|
922
|
+
const mapOutput = { [winner.child.id]: winner.output };
|
|
923
|
+
ctx.steps[winner.child.id] = winner.output;
|
|
924
|
+
return mapOutput;
|
|
925
|
+
}
|
|
926
|
+
catch (error) {
|
|
927
|
+
if (!(error instanceof NodeAbortedError)) {
|
|
928
|
+
this.cancelDescendantsInScope(runId, node, dynamic);
|
|
929
|
+
}
|
|
930
|
+
branchPromises.forEach((p) => void p.catch(() => undefined));
|
|
931
|
+
throw error;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// join: all — collect every branch output keyed by step id.
|
|
935
|
+
// fail-fast: Promise.all rejects on the first branch failure. Before
|
|
936
|
+
// rethrowing, actively cancel the still-running sibling branches so they
|
|
937
|
+
// don't linger in "running" state (they belong to a parallel that has
|
|
938
|
+
// already failed). Siblings transition to "cancelled".
|
|
939
|
+
let results;
|
|
940
|
+
try {
|
|
941
|
+
results = await Promise.all(branchPromises);
|
|
942
|
+
}
|
|
943
|
+
catch (error) {
|
|
944
|
+
if (!(error instanceof NodeAbortedError)) {
|
|
945
|
+
// Genuine failure or cancel — fast-stop: cancel still-running siblings
|
|
946
|
+
this.cancelDescendantsInScope(runId, node, dynamic);
|
|
947
|
+
}
|
|
948
|
+
branchPromises.forEach((p) => void p.catch(() => undefined));
|
|
949
|
+
throw error;
|
|
950
|
+
}
|
|
951
|
+
const mapOutput = {};
|
|
952
|
+
for (const { child, output } of results) {
|
|
953
|
+
mapOutput[child.id] = output;
|
|
954
|
+
ctx.steps[child.id] = output;
|
|
955
|
+
}
|
|
956
|
+
return mapOutput;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Cancel still-running/pending/awaiting descendant nodes of a failed
|
|
960
|
+
* composite (parallel or fanout), scoped to the given dynamic context. A
|
|
961
|
+
* descendant must (a) be one of the composite's descendant IR node ids and
|
|
962
|
+
* (b) share every dynamic dimension (item/lane/round) the composite already
|
|
963
|
+
* carries — so concurrent sibling fanout lanes outside this scope are not
|
|
964
|
+
* affected.
|
|
965
|
+
*
|
|
966
|
+
* NOTE: pass the composite's OWN dynamic. For a parallel this scopes to the
|
|
967
|
+
* current lane (item/lane present). For a fanout, the dynamic does NOT yet
|
|
968
|
+
* carry this fanout's lane (lanes are introduced for its children), so the
|
|
969
|
+
* scope spans ALL of the fanout's lanes — exactly the fail-fast intent.
|
|
970
|
+
*/
|
|
971
|
+
descendantsInScope(runId, node, dynamic, action) {
|
|
972
|
+
const descendantIds = new Set();
|
|
973
|
+
const collect = (n) => {
|
|
974
|
+
for (const c of n.children ?? []) {
|
|
975
|
+
descendantIds.add(c.id);
|
|
976
|
+
collect(c);
|
|
977
|
+
}
|
|
978
|
+
for (const b of n.branches ?? []) {
|
|
979
|
+
for (const c of b.children) {
|
|
980
|
+
descendantIds.add(c.id);
|
|
981
|
+
collect(c);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
collect(node);
|
|
986
|
+
// Key segments that must be present for an instance to share this scope.
|
|
987
|
+
// Values are sanitized to match resolveNodeKey's on-disk encoding (e.g. an
|
|
988
|
+
// item id "file:alpha" is stored as "item:file_alpha").
|
|
989
|
+
const scopeSegments = [];
|
|
990
|
+
if (dynamic.fanoutItemId !== undefined)
|
|
991
|
+
scopeSegments.push(`item:${sanitizeValue(String(dynamic.fanoutItemId))}`);
|
|
992
|
+
if (dynamic.laneId !== undefined)
|
|
993
|
+
scopeSegments.push(`lane:${sanitizeValue(String(dynamic.laneId))}`);
|
|
994
|
+
if (dynamic.loopRound !== undefined)
|
|
995
|
+
scopeSegments.push(`round:${dynamic.loopRound}`);
|
|
996
|
+
for (const ns of this.store.listNodeStates(runId)) {
|
|
997
|
+
if (ns.state !== "running" && ns.state !== "awaiting" && ns.state !== "pending")
|
|
998
|
+
continue;
|
|
999
|
+
if (!descendantIds.has(ns.nodeId))
|
|
1000
|
+
continue;
|
|
1001
|
+
const segs = ns.nodeKey.split("/");
|
|
1002
|
+
if (!scopeSegments.every((s) => segs.includes(s)))
|
|
1003
|
+
continue;
|
|
1004
|
+
action(runId, ns.nodeKey);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Cancel still-running/pending descendant nodes of a failed composite
|
|
1009
|
+
* (parallel `join: all`, or fanout `join: all` fail-fast).
|
|
1010
|
+
*/
|
|
1011
|
+
cancelDescendantsInScope(runId, node, dynamic) {
|
|
1012
|
+
this.descendantsInScope(runId, node, dynamic, (rid, key) => this.cancelMaterializedNode(rid, key));
|
|
1013
|
+
}
|
|
1014
|
+
async executeFanout(node, ctx, runId, dynamic, keyPrefix) {
|
|
1015
|
+
const overExpr = node.metadata.over;
|
|
1016
|
+
if (!overExpr) {
|
|
1017
|
+
throw new Error(`fanout node ${node.id} missing 'over' expression`);
|
|
1018
|
+
}
|
|
1019
|
+
const items = this.evaluator.evaluateOverExpression(overExpr, ctx);
|
|
1020
|
+
const maxConcurrency = node.metadata.max_concurrency ?? this.maxConcurrency;
|
|
1021
|
+
const join = node.metadata.join ?? "all";
|
|
1022
|
+
const quorum = node.metadata.quorum;
|
|
1023
|
+
const successCriteria = node.metadata.success_criteria;
|
|
1024
|
+
const children = node.children ?? [];
|
|
1025
|
+
const limit = pLimit(maxConcurrency);
|
|
1026
|
+
// Success target (how many lanes must succeed). Default follows join.
|
|
1027
|
+
const defaultMinSuccess = join === "race" ? 1 : join === "quorum" ? (quorum ?? 1) : items.length;
|
|
1028
|
+
const minSuccess = successCriteria?.min_success ?? defaultMinSuccess;
|
|
1029
|
+
// Fail-fast: once enough lanes have failed that the success target is
|
|
1030
|
+
// unreachable (failures > total - minSuccess), abort the whole fanout —
|
|
1031
|
+
// cancel every still-running/pending lane subtree and reject to short
|
|
1032
|
+
// circuit the wait, instead of waiting for doomed lanes to finish.
|
|
1033
|
+
// race/quorum tolerate per-lane failure until their target is impossible.
|
|
1034
|
+
const maxFailures = items.length - minSuccess;
|
|
1035
|
+
let failures = 0;
|
|
1036
|
+
let failFastTriggered = false;
|
|
1037
|
+
// Each lane resolves to a LaneResult. A tolerable failure is captured (does
|
|
1038
|
+
// not reject) so the join/min_success logic can run. A NodeAbortedError
|
|
1039
|
+
// (operator pause/cancel) re-throws so it propagates to the parent.
|
|
1040
|
+
const lanePromises = items.map((item, index) => limit(async () => {
|
|
1041
|
+
const keyCtx = { ...ctx, item, item_index: index };
|
|
1042
|
+
const itemId = this.extractItemId(item, node.metadata.key, index, keyCtx, this.evaluator);
|
|
1043
|
+
const itemDynamic = { ...dynamic, fanoutItemId: itemId, laneId: String(index) };
|
|
1044
|
+
const itemCtx = {
|
|
1045
|
+
...keyCtx,
|
|
1046
|
+
steps: { ...ctx.steps },
|
|
1047
|
+
item_id: itemId
|
|
1048
|
+
};
|
|
1049
|
+
try {
|
|
1050
|
+
let laneOutput;
|
|
1051
|
+
for (const child of children) {
|
|
1052
|
+
try {
|
|
1053
|
+
laneOutput = await this.executeNode(child, itemCtx, runId, itemDynamic, keyPrefix);
|
|
1054
|
+
}
|
|
1055
|
+
catch (error) {
|
|
1056
|
+
if (error instanceof ScopeCompleted)
|
|
1057
|
+
return { ok: true, output: error.output };
|
|
1058
|
+
throw error;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return { ok: true, output: laneOutput };
|
|
1062
|
+
}
|
|
1063
|
+
catch (error) {
|
|
1064
|
+
if (error instanceof NodeAbortedError) {
|
|
1065
|
+
throw error;
|
|
1066
|
+
}
|
|
1067
|
+
failures++;
|
|
1068
|
+
if (failures > maxFailures) {
|
|
1069
|
+
// Success target is now unreachable → fail fast: cancel all
|
|
1070
|
+
// still-running lanes of THIS fanout (scoped to the parent dynamic,
|
|
1071
|
+
// so it spans every lane), then reject to short-circuit the wait.
|
|
1072
|
+
if (!failFastTriggered) {
|
|
1073
|
+
failFastTriggered = true;
|
|
1074
|
+
this.cancelDescendantsInScope(runId, node, dynamic);
|
|
1075
|
+
}
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
1078
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1079
|
+
}
|
|
1080
|
+
}));
|
|
1081
|
+
// Wait strategy.
|
|
1082
|
+
const settled = await this.waitForFanout(lanePromises, join, quorum);
|
|
1083
|
+
const successes = settled.filter((r) => r.ok);
|
|
1084
|
+
if (successes.length < minSuccess) {
|
|
1085
|
+
throw new Error(`fanout ${node.id}: ${successes.length} successful lanes, requires ${minSuccess}`);
|
|
1086
|
+
}
|
|
1087
|
+
// outputMerge: "array" of successful lane outputs.
|
|
1088
|
+
return successes.map((r) => r.output);
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Resolve fanout lanes per the wait strategy. Lanes never reject on normal
|
|
1092
|
+
* failure (captured as LaneResult); only NodeAbortedError rejects, which we
|
|
1093
|
+
* let propagate. Losing/excess lanes are silently consumed.
|
|
1094
|
+
*/
|
|
1095
|
+
async waitForFanout(lanePromises, join, quorum) {
|
|
1096
|
+
if (join === "race") {
|
|
1097
|
+
const first = await Promise.race(lanePromises);
|
|
1098
|
+
lanePromises.forEach((p) => void p.catch(() => undefined));
|
|
1099
|
+
return [first];
|
|
1100
|
+
}
|
|
1101
|
+
if (join === "quorum") {
|
|
1102
|
+
const target = Math.min(quorum ?? lanePromises.length, lanePromises.length);
|
|
1103
|
+
return new Promise((resolve, reject) => {
|
|
1104
|
+
const collected = [];
|
|
1105
|
+
for (const p of lanePromises) {
|
|
1106
|
+
p.then((r) => {
|
|
1107
|
+
collected.push(r);
|
|
1108
|
+
if (collected.length >= target)
|
|
1109
|
+
resolve(collected.slice());
|
|
1110
|
+
}, (err) => reject(err));
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
// join: all — wait for every lane. A lane only rejects under fail-fast
|
|
1115
|
+
// (first failure) or NodeAbortedError; either way Promise.all short-circuits
|
|
1116
|
+
// and the rejection propagates to fail the fanout immediately.
|
|
1117
|
+
return Promise.all(lanePromises);
|
|
1118
|
+
}
|
|
1119
|
+
async executeSwitch(node, ctx, runId, dynamic, keyPrefix) {
|
|
1120
|
+
const branches = node.branches ?? [];
|
|
1121
|
+
for (const branch of branches) {
|
|
1122
|
+
if (branch.when) {
|
|
1123
|
+
const matches = this.evaluator.evaluateExpression(branch.when, ctx);
|
|
1124
|
+
if (matches) {
|
|
1125
|
+
let lastOutput;
|
|
1126
|
+
for (const child of branch.children) {
|
|
1127
|
+
try {
|
|
1128
|
+
lastOutput = await this.executeNode(child, ctx, runId, dynamic, keyPrefix);
|
|
1129
|
+
}
|
|
1130
|
+
catch (error) {
|
|
1131
|
+
if (error instanceof ScopeCompleted)
|
|
1132
|
+
return error.output;
|
|
1133
|
+
throw error;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return lastOutput;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
else {
|
|
1140
|
+
// Default branch (no when condition)
|
|
1141
|
+
let lastOutput;
|
|
1142
|
+
for (const child of branch.children) {
|
|
1143
|
+
try {
|
|
1144
|
+
lastOutput = await this.executeNode(child, ctx, runId, dynamic, keyPrefix);
|
|
1145
|
+
}
|
|
1146
|
+
catch (error) {
|
|
1147
|
+
if (error instanceof ScopeCompleted)
|
|
1148
|
+
return error.output;
|
|
1149
|
+
throw error;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return lastOutput;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
throw new Error(`Switch node ${node.id}: no branch matched and no default`);
|
|
1156
|
+
}
|
|
1157
|
+
async executeLoop(node, ctx, runId, dynamic, keyPrefix) {
|
|
1158
|
+
const untilExpr = node.metadata.until;
|
|
1159
|
+
const maxIterations = node.metadata.max_iterations ?? 100;
|
|
1160
|
+
const children = node.children ?? [];
|
|
1161
|
+
let lastOutput;
|
|
1162
|
+
for (let iter = 0; iter < maxIterations; iter++) {
|
|
1163
|
+
const loopCtx = {
|
|
1164
|
+
...ctx,
|
|
1165
|
+
loop: { iter, last: lastOutput }
|
|
1166
|
+
};
|
|
1167
|
+
const loopDynamic = { ...dynamic, loopRound: iter };
|
|
1168
|
+
// Check until condition (skip on first iteration)
|
|
1169
|
+
if (untilExpr && iter > 0) {
|
|
1170
|
+
const done = this.evaluator.evaluateExpression(untilExpr, loopCtx);
|
|
1171
|
+
if (done)
|
|
1172
|
+
break;
|
|
1173
|
+
}
|
|
1174
|
+
// Execute loop body
|
|
1175
|
+
for (const child of children) {
|
|
1176
|
+
try {
|
|
1177
|
+
lastOutput = await this.executeNode(child, loopCtx, runId, loopDynamic, keyPrefix);
|
|
1178
|
+
}
|
|
1179
|
+
catch (error) {
|
|
1180
|
+
if (error instanceof ScopeCompleted)
|
|
1181
|
+
return error.output;
|
|
1182
|
+
throw error;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
// outputMerge: "last"
|
|
1187
|
+
return lastOutput;
|
|
1188
|
+
}
|
|
1189
|
+
executeGuard(node, ctx) {
|
|
1190
|
+
const when = node.metadata.when;
|
|
1191
|
+
if (!when) {
|
|
1192
|
+
throw new Error(`guard node ${node.id} missing 'when' expression`);
|
|
1193
|
+
}
|
|
1194
|
+
const matched = Boolean(this.evaluator.evaluateExpression(when, ctx));
|
|
1195
|
+
const action = (matched ? node.metadata.then : node.metadata.else);
|
|
1196
|
+
if (action !== "continue" && action !== "fail" && action !== "complete") {
|
|
1197
|
+
throw new Error(`guard node ${node.id}: action must be continue, fail, or complete`);
|
|
1198
|
+
}
|
|
1199
|
+
const messageTemplate = node.metadata.message;
|
|
1200
|
+
const message = typeof messageTemplate === "string" ? this.evaluator.evaluateTemplate(messageTemplate, ctx) : undefined;
|
|
1201
|
+
const output = { matched, action };
|
|
1202
|
+
if (message !== undefined)
|
|
1203
|
+
output.message = message;
|
|
1204
|
+
if (action === "fail") {
|
|
1205
|
+
throw new GuardFailureError(message ?? `Guard '${node.id}' failed`, output);
|
|
1206
|
+
}
|
|
1207
|
+
return { output, completeScope: action === "complete" };
|
|
1208
|
+
}
|
|
1209
|
+
async executeApproval(node, ctx, runId, signal, nodeKey) {
|
|
1210
|
+
const timeout = node.metadata.timeout;
|
|
1211
|
+
const onTimeout = node.metadata.on_timeout;
|
|
1212
|
+
const at = this.evaluator.getNow();
|
|
1213
|
+
const timeoutMs = timeout ? parseDurationMs(timeout) : undefined;
|
|
1214
|
+
const resolverKey = `${runId}:${nodeKey}`;
|
|
1215
|
+
// Enter the `awaiting` state: the gate is now blocked on a human decision.
|
|
1216
|
+
// This is deliberately distinct from operator `paused` (see state machine);
|
|
1217
|
+
// a human signal resolves it, a cancel aborts it.
|
|
1218
|
+
const enterState = this.store.readNodeState(runId, nodeKey);
|
|
1219
|
+
if (enterState && canTransition(enterState.state, "awaiting")) {
|
|
1220
|
+
enterState.state = transition(enterState.state, "awaiting");
|
|
1221
|
+
this.store.writeNodeState(runId, enterState);
|
|
1222
|
+
}
|
|
1223
|
+
return new Promise((resolve, reject) => {
|
|
1224
|
+
const onAbort = () => {
|
|
1225
|
+
cleanup();
|
|
1226
|
+
// Honor the operator intent recorded by Run-level pause/cancel. An
|
|
1227
|
+
// awaiting gate is normally cancelled (awaiting → cancelled).
|
|
1228
|
+
reject(new NodeAbortedError(nodeKey, this.abortIntent(runId, nodeKey)));
|
|
1229
|
+
};
|
|
1230
|
+
const timer = timeoutMs
|
|
1231
|
+
? setTimeout(() => {
|
|
1232
|
+
cleanup();
|
|
1233
|
+
// On timeout, the resolved decision follows the configured policy.
|
|
1234
|
+
// `approve`/`reject` resolve; `fail`/`escalate` fail the node
|
|
1235
|
+
// (escalate has no runtime channel yet — see roadmap).
|
|
1236
|
+
if (onTimeout === "approve") {
|
|
1237
|
+
resolve({ approved: true, decision: "timeout", at });
|
|
1238
|
+
}
|
|
1239
|
+
else if (onTimeout === "reject") {
|
|
1240
|
+
resolve({ approved: false, decision: "timeout", at });
|
|
1241
|
+
}
|
|
1242
|
+
else {
|
|
1243
|
+
reject(new Error(`Approval timed out after ${timeout} (on_timeout: ${onTimeout ?? "fail"})`));
|
|
1244
|
+
}
|
|
1245
|
+
}, timeoutMs)
|
|
1246
|
+
: undefined;
|
|
1247
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1248
|
+
const cleanup = () => {
|
|
1249
|
+
if (timer)
|
|
1250
|
+
clearTimeout(timer);
|
|
1251
|
+
signal.removeEventListener("abort", onAbort);
|
|
1252
|
+
this.approvalResolvers.delete(resolverKey);
|
|
1253
|
+
};
|
|
1254
|
+
// Human-in-the-loop decision channel: an operator `signal` resolves the
|
|
1255
|
+
// gate. With no `timeout` configured, the gate waits indefinitely for this
|
|
1256
|
+
// (or a cancel). Whoever arrives first wins; the rest are torn down by
|
|
1257
|
+
// cleanup().
|
|
1258
|
+
this.approvalResolvers.set(resolverKey, (approved) => {
|
|
1259
|
+
cleanup();
|
|
1260
|
+
resolve({ approved, decision: approved ? "approved" : "rejected", at });
|
|
1261
|
+
});
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Submit a human decision to an Approval Gate that is currently `awaiting`.
|
|
1266
|
+
* Resolves the in-memory promise registered by executeApproval, which lets
|
|
1267
|
+
* executeNode transition the node `awaiting → completed` with the decision
|
|
1268
|
+
* output. Throws if the node is not awaiting a decision (no live resolver).
|
|
1269
|
+
*/
|
|
1270
|
+
submitApproval(runId, nodeKey, approved) {
|
|
1271
|
+
const resolver = this.approvalResolvers.get(`${runId}:${nodeKey}`);
|
|
1272
|
+
if (!resolver) {
|
|
1273
|
+
throw new Error(`Node ${nodeKey} is not awaiting an approval decision`);
|
|
1274
|
+
}
|
|
1275
|
+
resolver(approved);
|
|
1276
|
+
}
|
|
1277
|
+
async executeSubworkflow(node, ctx, runId, dynamic, nodeKey) {
|
|
1278
|
+
const specPath = node.metadata.subworkflow;
|
|
1279
|
+
const inputSpec = node.metadata.input;
|
|
1280
|
+
// Resolve the child spec path relative to the parent spec, falling back to cwd.
|
|
1281
|
+
const parentIr = this.store.readIr(runId);
|
|
1282
|
+
const baseDir = parentIr?.source.path ? dirname(parentIr.source.path) : process.cwd();
|
|
1283
|
+
const childAbs = resolve(baseDir, specPath);
|
|
1284
|
+
this.assertAllowedSourcePath(childAbs, `Subworkflow path '${specPath}' resolves outside allowed Workflow Spec roots`);
|
|
1285
|
+
// Cycle guard across nested subworkflows.
|
|
1286
|
+
if (this.subworkflowStack.has(childAbs)) {
|
|
1287
|
+
throw new Error(`Subworkflow cycle detected for '${childAbs}'`);
|
|
1288
|
+
}
|
|
1289
|
+
// Compile the child spec at runtime (file I/O lives in the runtime layer).
|
|
1290
|
+
const source = readFileSync(childAbs, "utf8");
|
|
1291
|
+
const compiled = compileWorkflow(source, {
|
|
1292
|
+
sourcePath: childAbs,
|
|
1293
|
+
includeResolver: (includePath, fromPath) => {
|
|
1294
|
+
const dir = fromPath ? dirname(resolve(fromPath)) : process.cwd();
|
|
1295
|
+
const includeAbs = resolve(dir, includePath);
|
|
1296
|
+
this.assertAllowedSourcePath(includeAbs, `Include path '${includePath}' resolves outside allowed Workflow Spec roots`);
|
|
1297
|
+
return readFileSync(includeAbs, "utf8");
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
if (!compiled.ok || !compiled.ir) {
|
|
1301
|
+
throw new Error(`Subworkflow '${specPath}' failed to compile: ${compiled.diagnostics.map((d) => d.message).join(", ")}`);
|
|
1302
|
+
}
|
|
1303
|
+
// Evaluate the declared input map against the current context. A field that
|
|
1304
|
+
// is a single ${{ }} expression keeps its native type; otherwise it is a
|
|
1305
|
+
// template string.
|
|
1306
|
+
const childInput = {};
|
|
1307
|
+
for (const [key, value] of Object.entries(inputSpec ?? {})) {
|
|
1308
|
+
childInput[key] = this.evaluateInputValue(value, ctx);
|
|
1309
|
+
}
|
|
1310
|
+
// Validate subworkflow input against the child IR's compiled input schema.
|
|
1311
|
+
const validatedChildInput = validateInput(compiled.ir.input, childInput);
|
|
1312
|
+
// Execute the child root as a nested pipeline. Child node keys are nested
|
|
1313
|
+
// under this subworkflow's node key to stay unique within the run.
|
|
1314
|
+
this.subworkflowStack.add(childAbs);
|
|
1315
|
+
try {
|
|
1316
|
+
const childCtx = this.buildContext(validatedChildInput, runId);
|
|
1317
|
+
await this.executeNode(compiled.ir.root, childCtx, runId, dynamic, nodeKey);
|
|
1318
|
+
return { ...childCtx.steps };
|
|
1319
|
+
}
|
|
1320
|
+
finally {
|
|
1321
|
+
this.subworkflowStack.delete(childAbs);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
assertAllowedSourcePath(path, message) {
|
|
1325
|
+
if (this.allowedSourceRoots.length === 0)
|
|
1326
|
+
return;
|
|
1327
|
+
if (this.allowedSourceRoots.some((root) => path === root || path.startsWith(root + "/")))
|
|
1328
|
+
return;
|
|
1329
|
+
throw new Error(message);
|
|
1330
|
+
}
|
|
1331
|
+
/** Evaluate a subworkflow input value, preserving native type for single expressions. */
|
|
1332
|
+
evaluateInputValue(value, ctx) {
|
|
1333
|
+
if (typeof value !== "string")
|
|
1334
|
+
return value;
|
|
1335
|
+
const single = value.match(/^\s*\$\{\{(.+)\}\}\s*$/s);
|
|
1336
|
+
if (single) {
|
|
1337
|
+
return this.evaluator.evaluateExpression(single[1].trim(), ctx);
|
|
1338
|
+
}
|
|
1339
|
+
return this.evaluator.evaluateTemplate(value, ctx);
|
|
1340
|
+
}
|
|
1341
|
+
// ─── Helpers ────────────────────────────────────────────────────
|
|
1342
|
+
buildContext(input, runId) {
|
|
1343
|
+
return { input, steps: {}, run_id: runId };
|
|
1344
|
+
}
|
|
1345
|
+
populateStepOutputs(runId, ctx) {
|
|
1346
|
+
for (const nodeState of this.store.listNodeStates(runId)) {
|
|
1347
|
+
if (nodeState.state === "completed" && nodeState.output !== undefined) {
|
|
1348
|
+
ctx.steps[nodeState.nodeId] = nodeState.output;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
/** Extract the parent dynamic value-context (fanout item / loop round) from a context, if any. */
|
|
1353
|
+
captureDynamicContext(ctx) {
|
|
1354
|
+
const snapshot = {};
|
|
1355
|
+
let has = false;
|
|
1356
|
+
if (ctx.item !== undefined) {
|
|
1357
|
+
snapshot.item = ctx.item;
|
|
1358
|
+
has = true;
|
|
1359
|
+
}
|
|
1360
|
+
if (ctx.item_id !== undefined) {
|
|
1361
|
+
snapshot.item_id = ctx.item_id;
|
|
1362
|
+
has = true;
|
|
1363
|
+
}
|
|
1364
|
+
if (ctx.item_index !== undefined) {
|
|
1365
|
+
snapshot.item_index = ctx.item_index;
|
|
1366
|
+
has = true;
|
|
1367
|
+
}
|
|
1368
|
+
if (ctx.loop !== undefined) {
|
|
1369
|
+
snapshot.loop = ctx.loop;
|
|
1370
|
+
has = true;
|
|
1371
|
+
}
|
|
1372
|
+
return has ? snapshot : undefined;
|
|
1373
|
+
}
|
|
1374
|
+
/** Merge a persisted dynamic value-context back into a rebuilt context (retry/continuation). */
|
|
1375
|
+
restoreDynamicContext(ctx, snapshot) {
|
|
1376
|
+
if (!snapshot)
|
|
1377
|
+
return;
|
|
1378
|
+
if (snapshot.item !== undefined)
|
|
1379
|
+
ctx.item = snapshot.item;
|
|
1380
|
+
if (snapshot.item_id !== undefined)
|
|
1381
|
+
ctx.item_id = snapshot.item_id;
|
|
1382
|
+
if (snapshot.item_index !== undefined)
|
|
1383
|
+
ctx.item_index = snapshot.item_index;
|
|
1384
|
+
if (snapshot.loop !== undefined)
|
|
1385
|
+
ctx.loop = snapshot.loop;
|
|
1386
|
+
}
|
|
1387
|
+
findNodeByKey(root, nodeKey) {
|
|
1388
|
+
// Node keys contain dynamic dimensions (e.g. "workflow/mapped/item:0/lane:0")
|
|
1389
|
+
// but IR nodes have template keys without dynamics. Match by extracting
|
|
1390
|
+
// the nodePath from the resolved key and comparing to the template.
|
|
1391
|
+
// The nodePath is always the prefix before any dynamic segments.
|
|
1392
|
+
// We match by the IR node's id since that's unique and stable.
|
|
1393
|
+
const nodeId = this.extractNodeIdFromKey(nodeKey);
|
|
1394
|
+
return this.findNodeById(root, nodeId);
|
|
1395
|
+
}
|
|
1396
|
+
/** Extract the step ID from a resolved node key. The ID is the last path segment before dynamic dims. */
|
|
1397
|
+
extractNodeIdFromKey(nodeKey) {
|
|
1398
|
+
// Key format: "workflow/step-a" or "workflow/mapped/item:0/lane:0"
|
|
1399
|
+
// The node id is the second segment (after "workflow/") for top-level steps
|
|
1400
|
+
// For nested, it's the segment before any "type:value" segments
|
|
1401
|
+
const segments = nodeKey.split("/");
|
|
1402
|
+
// Find the last segment that is NOT a "type:value" dynamic segment
|
|
1403
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1404
|
+
if (!segments[i].includes(":")) {
|
|
1405
|
+
return segments[i];
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
return segments[segments.length - 1];
|
|
1409
|
+
}
|
|
1410
|
+
findNodeById(root, nodeId) {
|
|
1411
|
+
if (root.id === nodeId)
|
|
1412
|
+
return root;
|
|
1413
|
+
for (const child of root.children ?? []) {
|
|
1414
|
+
const found = this.findNodeById(child, nodeId);
|
|
1415
|
+
if (found)
|
|
1416
|
+
return found;
|
|
1417
|
+
}
|
|
1418
|
+
for (const branch of root.branches ?? []) {
|
|
1419
|
+
for (const child of branch.children) {
|
|
1420
|
+
const found = this.findNodeById(child, nodeId);
|
|
1421
|
+
if (found)
|
|
1422
|
+
return found;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
return undefined;
|
|
1426
|
+
}
|
|
1427
|
+
extractItemId(item, keyExpr, index, ctx, evaluator) {
|
|
1428
|
+
if (keyExpr) {
|
|
1429
|
+
const result = evaluator.evaluateTemplate(keyExpr, ctx);
|
|
1430
|
+
if (result)
|
|
1431
|
+
return result;
|
|
1432
|
+
}
|
|
1433
|
+
return String(index ?? 0);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
/** Generate a locally sortable run ID: yyyyMMddHHmmss + 20 uppercase hex chars. */
|
|
1437
|
+
export function generateRunId(now = new Date()) {
|
|
1438
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1439
|
+
const timestamp = [
|
|
1440
|
+
now.getFullYear(),
|
|
1441
|
+
pad(now.getMonth() + 1),
|
|
1442
|
+
pad(now.getDate()),
|
|
1443
|
+
pad(now.getHours()),
|
|
1444
|
+
pad(now.getMinutes()),
|
|
1445
|
+
pad(now.getSeconds())
|
|
1446
|
+
].join("");
|
|
1447
|
+
return `${timestamp}${randomBytes(10).toString("hex").toUpperCase()}`;
|
|
1448
|
+
}
|
|
1449
|
+
class NodeAbortedError extends Error {
|
|
1450
|
+
nodeKey;
|
|
1451
|
+
state;
|
|
1452
|
+
artifactRefs;
|
|
1453
|
+
output;
|
|
1454
|
+
renderedPrompt;
|
|
1455
|
+
constructor(nodeKey, state,
|
|
1456
|
+
/** Artifact refs (e.g. partial transcript) to persist on the aborted node. */
|
|
1457
|
+
artifactRefs,
|
|
1458
|
+
/** Partial output captured before the abort. */
|
|
1459
|
+
output,
|
|
1460
|
+
/** Rendered Agent prompt to preserve when abort overwrites node state. */
|
|
1461
|
+
renderedPrompt) {
|
|
1462
|
+
super(`Node ${nodeKey} aborted: ${state}`);
|
|
1463
|
+
this.nodeKey = nodeKey;
|
|
1464
|
+
this.state = state;
|
|
1465
|
+
this.artifactRefs = artifactRefs;
|
|
1466
|
+
this.output = output;
|
|
1467
|
+
this.renderedPrompt = renderedPrompt;
|
|
1468
|
+
this.name = "NodeAbortedError";
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
class ScopeCompleted extends Error {
|
|
1472
|
+
output;
|
|
1473
|
+
constructor(output) {
|
|
1474
|
+
super("Scope completed by guard");
|
|
1475
|
+
this.output = output;
|
|
1476
|
+
this.name = "ScopeCompleted";
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
class GuardFailureError extends Error {
|
|
1480
|
+
output;
|
|
1481
|
+
constructor(message, output) {
|
|
1482
|
+
super(message);
|
|
1483
|
+
this.output = output;
|
|
1484
|
+
this.name = "GuardFailureError";
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
/** A non-recoverable leaf failure that still carries artifacts written before failing. */
|
|
1488
|
+
class LeafExecutionError extends Error {
|
|
1489
|
+
artifactRefs;
|
|
1490
|
+
output;
|
|
1491
|
+
constructor(message, artifactRefs,
|
|
1492
|
+
/** Invalid output captured before the leaf failed validation/execution. */
|
|
1493
|
+
output) {
|
|
1494
|
+
super(message);
|
|
1495
|
+
this.artifactRefs = artifactRefs;
|
|
1496
|
+
this.output = output;
|
|
1497
|
+
this.name = "LeafExecutionError";
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
//# sourceMappingURL=interpreter.js.map
|