@osolmaz/pi-workflows 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 +182 -0
- package/dist/extension/executor.d.ts +58 -0
- package/dist/extension/executor.js +201 -0
- package/dist/extension/executor.js.map +1 -0
- package/dist/extension/index.d.ts +17 -0
- package/dist/extension/index.js +504 -0
- package/dist/extension/index.js.map +1 -0
- package/dist/extension/widget.d.ts +21 -0
- package/dist/extension/widget.js +142 -0
- package/dist/extension/widget.js.map +1 -0
- package/dist/render/ansi.d.ts +16 -0
- package/dist/render/ansi.js +42 -0
- package/dist/render/ansi.js.map +1 -0
- package/dist/render/canvas.d.ts +40 -0
- package/dist/render/canvas.js +177 -0
- package/dist/render/canvas.js.map +1 -0
- package/dist/render/format.d.ts +3 -0
- package/dist/render/format.js +17 -0
- package/dist/render/format.js.map +1 -0
- package/dist/render/graph-render.d.ts +22 -0
- package/dist/render/graph-render.js +520 -0
- package/dist/render/graph-render.js.map +1 -0
- package/dist/render/graph.d.ts +46 -0
- package/dist/render/graph.js +272 -0
- package/dist/render/graph.js.map +1 -0
- package/dist/viewer/cli.d.ts +10 -0
- package/dist/viewer/cli.js +132 -0
- package/dist/viewer/cli.js.map +1 -0
- package/dist/viewer/render.d.ts +19 -0
- package/dist/viewer/render.js +162 -0
- package/dist/viewer/render.js.map +1 -0
- package/dist/viewer/tui.d.ts +11 -0
- package/dist/viewer/tui.js +140 -0
- package/dist/viewer/tui.js.map +1 -0
- package/dist/viewer/watch.d.ts +9 -0
- package/dist/viewer/watch.js +46 -0
- package/dist/viewer/watch.js.map +1 -0
- package/dist/workflows/decision.d.ts +25 -0
- package/dist/workflows/decision.js +96 -0
- package/dist/workflows/decision.js.map +1 -0
- package/dist/workflows/definition.d.ts +9 -0
- package/dist/workflows/definition.js +61 -0
- package/dist/workflows/definition.js.map +1 -0
- package/dist/workflows/engine.d.ts +65 -0
- package/dist/workflows/engine.js +574 -0
- package/dist/workflows/engine.js.map +1 -0
- package/dist/workflows/errors.d.ts +9 -0
- package/dist/workflows/errors.js +24 -0
- package/dist/workflows/errors.js.map +1 -0
- package/dist/workflows/graph.d.ts +17 -0
- package/dist/workflows/graph.js +127 -0
- package/dist/workflows/graph.js.map +1 -0
- package/dist/workflows/index.d.ts +11 -0
- package/dist/workflows/index.js +11 -0
- package/dist/workflows/index.js.map +1 -0
- package/dist/workflows/json.d.ts +14 -0
- package/dist/workflows/json.js +134 -0
- package/dist/workflows/json.js.map +1 -0
- package/dist/workflows/loader.d.ts +28 -0
- package/dist/workflows/loader.js +94 -0
- package/dist/workflows/loader.js.map +1 -0
- package/dist/workflows/schema.d.ts +7 -0
- package/dist/workflows/schema.js +176 -0
- package/dist/workflows/schema.js.map +1 -0
- package/dist/workflows/shell.d.ts +9 -0
- package/dist/workflows/shell.js +177 -0
- package/dist/workflows/shell.js.map +1 -0
- package/dist/workflows/store.d.ts +35 -0
- package/dist/workflows/store.js +181 -0
- package/dist/workflows/store.js.map +1 -0
- package/dist/workflows/text.d.ts +10 -0
- package/dist/workflows/text.js +32 -0
- package/dist/workflows/text.js.map +1 -0
- package/dist/workflows/types.d.ts +280 -0
- package/dist/workflows/types.js +2 -0
- package/dist/workflows/types.js.map +1 -0
- package/docs/development.md +130 -0
- package/docs/run-bundles.md +114 -0
- package/docs/workflows.md +311 -0
- package/examples/workflows/autoimplement.workflow.ts +92 -0
- package/examples/workflows/autoresearch.workflow.ts +139 -0
- package/examples/workflows/branch.workflow.ts +63 -0
- package/examples/workflows/echo.workflow.ts +23 -0
- package/examples/workflows/elegant-solution.workflow.ts +95 -0
- package/examples/workflows/shell.workflow.ts +31 -0
- package/examples/workflows/two-turn.workflow.ts +64 -0
- package/package.json +80 -0
- package/src/extension/executor.ts +251 -0
- package/src/extension/index.ts +627 -0
- package/src/extension/widget.ts +183 -0
- package/src/render/ansi.ts +47 -0
- package/src/render/canvas.ts +196 -0
- package/src/render/format.ts +19 -0
- package/src/render/graph-render.ts +738 -0
- package/src/render/graph.ts +341 -0
- package/src/viewer/cli.ts +150 -0
- package/src/viewer/render.ts +236 -0
- package/src/viewer/tui.ts +159 -0
- package/src/viewer/watch.ts +55 -0
- package/src/workflows/decision.ts +127 -0
- package/src/workflows/definition.ts +104 -0
- package/src/workflows/engine.ts +793 -0
- package/src/workflows/errors.ts +27 -0
- package/src/workflows/graph.ts +161 -0
- package/src/workflows/index.ts +76 -0
- package/src/workflows/json.ts +155 -0
- package/src/workflows/loader.ts +123 -0
- package/src/workflows/schema.ts +218 -0
- package/src/workflows/shell.ts +199 -0
- package/src/workflows/store.ts +234 -0
- package/src/workflows/text.ts +34 -0
- package/src/workflows/types.ts +318 -0
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { CancelledError, errorMessage, isAbortLikeError, TimeoutError } from "./errors.js";
|
|
4
|
+
import { resolveNext, resolveNextForOutcome, validateWorkflowDefinition } from "./graph.js";
|
|
5
|
+
import { extractJsonValue } from "./json.js";
|
|
6
|
+
import { runShellAction, shellResultFromError } from "./shell.js";
|
|
7
|
+
import { WorkflowRunStore, createRunId } from "./store.js";
|
|
8
|
+
import type {
|
|
9
|
+
AgentNodeDefinition,
|
|
10
|
+
AgentStepExecutor,
|
|
11
|
+
ActionNodeDefinition,
|
|
12
|
+
CheckpointNodeDefinition,
|
|
13
|
+
ShellActionNodeDefinition,
|
|
14
|
+
ShellActionResult,
|
|
15
|
+
WorkflowActionReceipt,
|
|
16
|
+
WorkflowDefinition,
|
|
17
|
+
WorkflowEngineOptions,
|
|
18
|
+
WorkflowNodeContext,
|
|
19
|
+
WorkflowNodeDefinition,
|
|
20
|
+
WorkflowNodeOutcome,
|
|
21
|
+
WorkflowNodeResult,
|
|
22
|
+
WorkflowRunResult,
|
|
23
|
+
WorkflowRunState,
|
|
24
|
+
WorkflowStepRecord,
|
|
25
|
+
WorkflowTraceEventDraft,
|
|
26
|
+
} from "./types.js";
|
|
27
|
+
|
|
28
|
+
const DEFAULT_NODE_TIMEOUT_MS = 15 * 60_000;
|
|
29
|
+
const DEFAULT_MAX_STEPS = 100;
|
|
30
|
+
const TITLE_TIMEOUT_MS = 30_000;
|
|
31
|
+
// Covers the shell SIGTERM → SIGKILL escalation (1s) plus stdio close.
|
|
32
|
+
const ABORT_CLEANUP_GRACE_MS = 2_000;
|
|
33
|
+
|
|
34
|
+
type NodeExecution = {
|
|
35
|
+
output: unknown;
|
|
36
|
+
promptText: string | null;
|
|
37
|
+
action?: WorkflowActionReceipt;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Metadata collected while a node runs, so a failing node still persists the
|
|
42
|
+
* agent prompt it sent and the shell action it executed.
|
|
43
|
+
*/
|
|
44
|
+
type NodeExecutionMeta = {
|
|
45
|
+
promptText: string | null;
|
|
46
|
+
action?: WorkflowActionReceipt;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type NodeAttempt = {
|
|
50
|
+
result: WorkflowNodeResult;
|
|
51
|
+
execution: NodeExecution | null;
|
|
52
|
+
error?: unknown;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Executes a workflow graph step by step. Agent steps are delegated to the
|
|
57
|
+
* configured executor; compute/action/checkpoint nodes run inline. Every
|
|
58
|
+
* state transition is persisted to the run bundle before the engine moves on,
|
|
59
|
+
* so a live viewer can follow along by watching the bundle directory.
|
|
60
|
+
*/
|
|
61
|
+
export class WorkflowEngine {
|
|
62
|
+
private readonly executor: AgentStepExecutor;
|
|
63
|
+
private readonly store: WorkflowRunStore;
|
|
64
|
+
private readonly defaultNodeTimeoutMs: number;
|
|
65
|
+
private readonly maxSteps: number;
|
|
66
|
+
private readonly onEvent?: WorkflowEngineOptions["onEvent"];
|
|
67
|
+
private activeAbort: AbortController | null = null;
|
|
68
|
+
private cancelled = false;
|
|
69
|
+
private paused = false;
|
|
70
|
+
private wakePause: (() => void) | null = null;
|
|
71
|
+
|
|
72
|
+
constructor(options: WorkflowEngineOptions) {
|
|
73
|
+
this.executor = options.executor;
|
|
74
|
+
this.store = new WorkflowRunStore(options.outputRoot);
|
|
75
|
+
this.defaultNodeTimeoutMs = options.defaultNodeTimeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
76
|
+
this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
77
|
+
this.onEvent = options.onEvent;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get outputRoot(): string {
|
|
81
|
+
return this.store.outputRoot;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Abort the currently running node and mark the run cancelled. */
|
|
85
|
+
cancel(): void {
|
|
86
|
+
this.cancelled = true;
|
|
87
|
+
this.activeAbort?.abort(new CancelledError());
|
|
88
|
+
// A run held at a pause boundary has no active node to abort; wake it so
|
|
89
|
+
// it can observe the cancellation.
|
|
90
|
+
this.wakePause?.();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Request a pause. The current step finishes normally; the engine then
|
|
95
|
+
* holds before dispatching the next node until `resume` (or `cancel`).
|
|
96
|
+
*/
|
|
97
|
+
pause(): void {
|
|
98
|
+
this.paused = true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Release a pause requested with `pause`. */
|
|
102
|
+
resume(): void {
|
|
103
|
+
this.paused = false;
|
|
104
|
+
this.wakePause?.();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** True when a pause has been requested or the run is already held. */
|
|
108
|
+
get pauseRequested(): boolean {
|
|
109
|
+
return this.paused;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async run(
|
|
113
|
+
workflow: WorkflowDefinition,
|
|
114
|
+
input: unknown,
|
|
115
|
+
options: { workflowPath?: string } = {},
|
|
116
|
+
): Promise<WorkflowRunResult> {
|
|
117
|
+
validateWorkflowDefinition(workflow);
|
|
118
|
+
// Fail before any bundle exists so bad input cannot leave a partial run
|
|
119
|
+
// on disk or silently change shape when state.json round-trips.
|
|
120
|
+
const normalizedInput = input === undefined ? null : input;
|
|
121
|
+
assertJsonSerializable(normalizedInput, "Workflow run input");
|
|
122
|
+
this.cancelled = false;
|
|
123
|
+
this.paused = false;
|
|
124
|
+
|
|
125
|
+
const state = await this.createRunState(workflow, normalizedInput, options.workflowPath);
|
|
126
|
+
const runDir = await this.store.initializeRunBundle(workflow, state);
|
|
127
|
+
await this.persist(runDir, state, {
|
|
128
|
+
scope: "run",
|
|
129
|
+
type: "run_started",
|
|
130
|
+
payload: {
|
|
131
|
+
workflowName: workflow.name,
|
|
132
|
+
...(state.runTitle ? { runTitle: state.runTitle } : {}),
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
await this.executeGraph(workflow, state, runDir);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const cancelled = this.cancelled || isAbortLikeError(error);
|
|
140
|
+
await this.finishRun(runDir, state, cancelled ? "cancelled" : "failed", {
|
|
141
|
+
error: errorMessage(error),
|
|
142
|
+
});
|
|
143
|
+
return { runDir, state };
|
|
144
|
+
}
|
|
145
|
+
return { runDir, state };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the run title inside a cancellation and timeout boundary. This
|
|
150
|
+
* runs before any node abort controller exists, so without it a hung async
|
|
151
|
+
* `title` callback would leave the session permanently occupied.
|
|
152
|
+
*/
|
|
153
|
+
private async resolveTitleBounded(
|
|
154
|
+
workflow: WorkflowDefinition,
|
|
155
|
+
input: unknown,
|
|
156
|
+
): Promise<{ runTitle?: string }> {
|
|
157
|
+
if (typeof workflow.title !== "function") {
|
|
158
|
+
return resolveRunTitle(workflow, input);
|
|
159
|
+
}
|
|
160
|
+
const abort = new AbortController();
|
|
161
|
+
this.activeAbort = abort;
|
|
162
|
+
const timer = setTimeout(
|
|
163
|
+
() => abort.abort(new TimeoutError(TITLE_TIMEOUT_MS)),
|
|
164
|
+
TITLE_TIMEOUT_MS,
|
|
165
|
+
);
|
|
166
|
+
try {
|
|
167
|
+
return await Promise.race([resolveRunTitle(workflow, input), abortRejection(abort.signal)]);
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
this.activeAbort = null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async createRunState(
|
|
175
|
+
workflow: WorkflowDefinition,
|
|
176
|
+
input: unknown,
|
|
177
|
+
workflowPath: string | undefined,
|
|
178
|
+
): Promise<WorkflowRunState> {
|
|
179
|
+
const now = new Date().toISOString();
|
|
180
|
+
return {
|
|
181
|
+
runId: createRunId(workflow.name),
|
|
182
|
+
workflowName: workflow.name,
|
|
183
|
+
...(await this.resolveTitleBounded(workflow, input)),
|
|
184
|
+
...(workflowPath !== undefined ? { workflowPath } : {}),
|
|
185
|
+
startedAt: now,
|
|
186
|
+
updatedAt: now,
|
|
187
|
+
status: "running",
|
|
188
|
+
input,
|
|
189
|
+
outputs: {},
|
|
190
|
+
results: {},
|
|
191
|
+
steps: [],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async executeGraph(
|
|
196
|
+
workflow: WorkflowDefinition,
|
|
197
|
+
state: WorkflowRunState,
|
|
198
|
+
runDir: string,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
const maxSteps = workflow.maxSteps ?? this.maxSteps;
|
|
201
|
+
let currentNodeId: string | null = workflow.startAt;
|
|
202
|
+
let executedSteps = 0;
|
|
203
|
+
let lastOutput: unknown;
|
|
204
|
+
|
|
205
|
+
while (currentNodeId !== null) {
|
|
206
|
+
await this.holdWhilePaused(state, runDir);
|
|
207
|
+
executedSteps += 1;
|
|
208
|
+
if (executedSteps > maxSteps) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`Workflow exceeded maxSteps=${maxSteps}; aborting to avoid an unbounded loop`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const node = workflow.nodes[currentNodeId];
|
|
215
|
+
if (!node) {
|
|
216
|
+
throw new Error(`Workflow node is missing: ${currentNodeId}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const attempt = await this.executeNode(workflow, state, runDir, currentNodeId, node);
|
|
220
|
+
this.recordAttempt(state, attempt);
|
|
221
|
+
await this.persist(runDir, state, {
|
|
222
|
+
scope: "node",
|
|
223
|
+
type: attempt.result.outcome === "ok" ? "node_finished" : "node_failed",
|
|
224
|
+
nodeId: attempt.result.nodeId,
|
|
225
|
+
attemptId: attempt.result.attemptId,
|
|
226
|
+
payload: {
|
|
227
|
+
outcome: attempt.result.outcome,
|
|
228
|
+
durationMs: attempt.result.durationMs,
|
|
229
|
+
...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (attempt.result.outcome !== "ok") {
|
|
234
|
+
currentNodeId = this.routeAfterFailure(workflow, state, attempt);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
lastOutput = attempt.result.output;
|
|
239
|
+
if (node.nodeType === "checkpoint") {
|
|
240
|
+
await this.finishRun(runDir, state, "waiting", {
|
|
241
|
+
waitingOn: attempt.result.nodeId,
|
|
242
|
+
finalOutput: lastOutput,
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
currentNodeId = resolveNext(
|
|
247
|
+
workflow.edges,
|
|
248
|
+
attempt.result.nodeId,
|
|
249
|
+
attempt.result.output,
|
|
250
|
+
attempt.result,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await this.finishRun(runDir, state, "completed", { finalOutput: lastOutput });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Hold the run at the step boundary while a pause is in effect. Pausing
|
|
259
|
+
* never interrupts a node mid-flight; it only delays the next dispatch.
|
|
260
|
+
*/
|
|
261
|
+
private async holdWhilePaused(state: WorkflowRunState, runDir: string): Promise<void> {
|
|
262
|
+
if (this.cancelled) {
|
|
263
|
+
throw new CancelledError();
|
|
264
|
+
}
|
|
265
|
+
if (!this.paused) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
state.paused = true;
|
|
269
|
+
await this.persist(runDir, state, { scope: "run", type: "run_paused", payload: {} });
|
|
270
|
+
while (this.paused && !this.cancelled) {
|
|
271
|
+
await new Promise<void>((resolve) => {
|
|
272
|
+
this.wakePause = resolve;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
this.wakePause = null;
|
|
276
|
+
delete state.paused;
|
|
277
|
+
if (this.cancelled) {
|
|
278
|
+
throw new CancelledError();
|
|
279
|
+
}
|
|
280
|
+
await this.persist(runDir, state, { scope: "run", type: "run_resumed", payload: {} });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private routeAfterFailure(
|
|
284
|
+
workflow: WorkflowDefinition,
|
|
285
|
+
state: WorkflowRunState,
|
|
286
|
+
attempt: NodeAttempt,
|
|
287
|
+
): string | null {
|
|
288
|
+
const next = resolveNextForOutcome(workflow.edges, attempt.result.nodeId, attempt.result);
|
|
289
|
+
if (next !== null) {
|
|
290
|
+
return next;
|
|
291
|
+
}
|
|
292
|
+
if (attempt.result.outcome === "cancelled" || this.cancelled) {
|
|
293
|
+
throw new CancelledError();
|
|
294
|
+
}
|
|
295
|
+
if (attempt.result.outcome === "timed_out") {
|
|
296
|
+
state.status = "timed_out";
|
|
297
|
+
}
|
|
298
|
+
throw attempt.error instanceof Error
|
|
299
|
+
? attempt.error
|
|
300
|
+
: new Error(attempt.result.error ?? `Workflow node failed: ${attempt.result.nodeId}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private recordAttempt(state: WorkflowRunState, attempt: NodeAttempt): void {
|
|
304
|
+
state.results[attempt.result.nodeId] = attempt.result;
|
|
305
|
+
if (attempt.result.outcome === "ok") {
|
|
306
|
+
state.outputs[attempt.result.nodeId] = attempt.result.output;
|
|
307
|
+
} else {
|
|
308
|
+
// A failed repeat attempt supersedes an earlier success; stale output
|
|
309
|
+
// must not survive next to a non-ok latest result.
|
|
310
|
+
delete state.outputs[attempt.result.nodeId];
|
|
311
|
+
}
|
|
312
|
+
const step: WorkflowStepRecord = {
|
|
313
|
+
attemptId: attempt.result.attemptId,
|
|
314
|
+
nodeId: attempt.result.nodeId,
|
|
315
|
+
nodeType: attempt.result.nodeType,
|
|
316
|
+
outcome: attempt.result.outcome,
|
|
317
|
+
startedAt: attempt.result.startedAt,
|
|
318
|
+
finishedAt: attempt.result.finishedAt,
|
|
319
|
+
promptText: attempt.execution?.promptText ?? null,
|
|
320
|
+
// `undefined` would drop the required field during JSON serialization.
|
|
321
|
+
output: attempt.result.output ?? null,
|
|
322
|
+
...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
|
|
323
|
+
...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
|
|
324
|
+
};
|
|
325
|
+
state.steps.push(step);
|
|
326
|
+
delete state.currentNode;
|
|
327
|
+
delete state.currentAttemptId;
|
|
328
|
+
delete state.currentNodeType;
|
|
329
|
+
delete state.currentNodeStartedAt;
|
|
330
|
+
delete state.statusDetail;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private async executeNode(
|
|
334
|
+
workflow: WorkflowDefinition,
|
|
335
|
+
state: WorkflowRunState,
|
|
336
|
+
runDir: string,
|
|
337
|
+
nodeId: string,
|
|
338
|
+
node: WorkflowNodeDefinition,
|
|
339
|
+
): Promise<NodeAttempt> {
|
|
340
|
+
const attemptId = randomUUID();
|
|
341
|
+
const startedAt = new Date().toISOString();
|
|
342
|
+
state.currentNode = nodeId;
|
|
343
|
+
state.currentAttemptId = attemptId;
|
|
344
|
+
state.currentNodeType = node.nodeType;
|
|
345
|
+
state.currentNodeStartedAt = startedAt;
|
|
346
|
+
if (node.statusDetail !== undefined) {
|
|
347
|
+
state.statusDetail = node.statusDetail;
|
|
348
|
+
}
|
|
349
|
+
await this.persist(runDir, state, {
|
|
350
|
+
scope: "node",
|
|
351
|
+
type: "node_started",
|
|
352
|
+
nodeId,
|
|
353
|
+
attemptId,
|
|
354
|
+
payload: { nodeType: node.nodeType },
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const meta: NodeExecutionMeta = { promptText: null };
|
|
358
|
+
try {
|
|
359
|
+
const execution = await this.runNodeWithTimeout(
|
|
360
|
+
workflow,
|
|
361
|
+
state,
|
|
362
|
+
runDir,
|
|
363
|
+
nodeId,
|
|
364
|
+
attemptId,
|
|
365
|
+
node,
|
|
366
|
+
meta,
|
|
367
|
+
);
|
|
368
|
+
return {
|
|
369
|
+
result: this.createNodeResult(nodeId, node, attemptId, startedAt, "ok", execution.output),
|
|
370
|
+
execution,
|
|
371
|
+
};
|
|
372
|
+
} catch (error) {
|
|
373
|
+
const outcome = this.outcomeForError(error);
|
|
374
|
+
return {
|
|
375
|
+
result: {
|
|
376
|
+
...this.createNodeResult(nodeId, node, attemptId, startedAt, outcome, undefined),
|
|
377
|
+
error: errorMessage(error),
|
|
378
|
+
},
|
|
379
|
+
// Keep whatever metadata the node produced before failing so the
|
|
380
|
+
// audit history retains the agent prompt and action receipt.
|
|
381
|
+
execution: {
|
|
382
|
+
output: null,
|
|
383
|
+
promptText: meta.promptText,
|
|
384
|
+
...(meta.action !== undefined ? { action: meta.action } : {}),
|
|
385
|
+
},
|
|
386
|
+
error,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private outcomeForError(error: unknown): WorkflowNodeOutcome {
|
|
392
|
+
if (error instanceof TimeoutError) {
|
|
393
|
+
return "timed_out";
|
|
394
|
+
}
|
|
395
|
+
if (this.cancelled || isAbortLikeError(error)) {
|
|
396
|
+
return "cancelled";
|
|
397
|
+
}
|
|
398
|
+
return "failed";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private createNodeResult(
|
|
402
|
+
nodeId: string,
|
|
403
|
+
node: WorkflowNodeDefinition,
|
|
404
|
+
attemptId: string,
|
|
405
|
+
startedAt: string,
|
|
406
|
+
outcome: WorkflowNodeOutcome,
|
|
407
|
+
output: unknown,
|
|
408
|
+
): WorkflowNodeResult {
|
|
409
|
+
const finishedAt = new Date().toISOString();
|
|
410
|
+
return {
|
|
411
|
+
attemptId,
|
|
412
|
+
nodeId,
|
|
413
|
+
nodeType: node.nodeType,
|
|
414
|
+
outcome,
|
|
415
|
+
startedAt,
|
|
416
|
+
finishedAt,
|
|
417
|
+
durationMs: Date.parse(finishedAt) - Date.parse(startedAt),
|
|
418
|
+
...(output !== undefined ? { output } : {}),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private async runNodeWithTimeout(
|
|
423
|
+
workflow: WorkflowDefinition,
|
|
424
|
+
state: WorkflowRunState,
|
|
425
|
+
runDir: string,
|
|
426
|
+
nodeId: string,
|
|
427
|
+
attemptId: string,
|
|
428
|
+
node: WorkflowNodeDefinition,
|
|
429
|
+
meta: NodeExecutionMeta,
|
|
430
|
+
): Promise<NodeExecution> {
|
|
431
|
+
const timeoutMs = node.timeoutMs ?? this.defaultNodeTimeoutMs;
|
|
432
|
+
const abort = new AbortController();
|
|
433
|
+
this.activeAbort = abort;
|
|
434
|
+
if (this.cancelled) {
|
|
435
|
+
throw new CancelledError();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const timer = setTimeout(() => {
|
|
439
|
+
abort.abort(new TimeoutError(timeoutMs));
|
|
440
|
+
}, timeoutMs);
|
|
441
|
+
const dispatched = this.dispatchNode(
|
|
442
|
+
workflow,
|
|
443
|
+
state,
|
|
444
|
+
runDir,
|
|
445
|
+
nodeId,
|
|
446
|
+
attemptId,
|
|
447
|
+
node,
|
|
448
|
+
abort.signal,
|
|
449
|
+
meta,
|
|
450
|
+
);
|
|
451
|
+
const dispatchSettled = dispatched.then(
|
|
452
|
+
() => undefined,
|
|
453
|
+
() => undefined,
|
|
454
|
+
);
|
|
455
|
+
try {
|
|
456
|
+
// Race the dispatch against the abort signal so timeouts and cancel
|
|
457
|
+
// take effect even for node callbacks that never observe the signal.
|
|
458
|
+
const execution = await Promise.race([dispatched, abortRejection(abort.signal)]);
|
|
459
|
+
if (execution.output === undefined) {
|
|
460
|
+
// JSON cannot represent undefined; normalize so the in-memory state
|
|
461
|
+
// matches what the persisted bundle round-trips to.
|
|
462
|
+
execution.output = null;
|
|
463
|
+
}
|
|
464
|
+
assertJsonSerializable(execution.output, `Node ${nodeId} output`);
|
|
465
|
+
return execution;
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (node.nodeType === "action" && "exec" in node) {
|
|
468
|
+
// Give the killed shell command a short grace period to close so its
|
|
469
|
+
// action receipt lands in `meta` before the failed attempt persists.
|
|
470
|
+
await Promise.race([
|
|
471
|
+
dispatchSettled,
|
|
472
|
+
new Promise((resolve) => setTimeout(resolve, ABORT_CLEANUP_GRACE_MS)),
|
|
473
|
+
]);
|
|
474
|
+
}
|
|
475
|
+
const reason: unknown = abort.signal.aborted ? abort.signal.reason : undefined;
|
|
476
|
+
throw reason instanceof TimeoutError || reason instanceof CancelledError ? reason : error;
|
|
477
|
+
} finally {
|
|
478
|
+
clearTimeout(timer);
|
|
479
|
+
this.activeAbort = null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private async dispatchNode(
|
|
484
|
+
workflow: WorkflowDefinition,
|
|
485
|
+
state: WorkflowRunState,
|
|
486
|
+
runDir: string,
|
|
487
|
+
nodeId: string,
|
|
488
|
+
attemptId: string,
|
|
489
|
+
node: WorkflowNodeDefinition,
|
|
490
|
+
signal: AbortSignal,
|
|
491
|
+
meta: NodeExecutionMeta,
|
|
492
|
+
): Promise<NodeExecution> {
|
|
493
|
+
const context = this.createNodeContext(state, signal);
|
|
494
|
+
switch (node.nodeType) {
|
|
495
|
+
case "agent":
|
|
496
|
+
return await this.runAgentNode(
|
|
497
|
+
workflow,
|
|
498
|
+
state,
|
|
499
|
+
runDir,
|
|
500
|
+
nodeId,
|
|
501
|
+
attemptId,
|
|
502
|
+
node,
|
|
503
|
+
context,
|
|
504
|
+
signal,
|
|
505
|
+
meta,
|
|
506
|
+
);
|
|
507
|
+
case "compute":
|
|
508
|
+
return { output: await node.run(context), promptText: null };
|
|
509
|
+
case "action":
|
|
510
|
+
return await this.runActionNode(node, context, signal, meta);
|
|
511
|
+
case "checkpoint":
|
|
512
|
+
return await runCheckpointNode(node, context);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private createNodeContext(state: WorkflowRunState, signal: AbortSignal): WorkflowNodeContext {
|
|
517
|
+
return {
|
|
518
|
+
input: state.input,
|
|
519
|
+
outputs: state.outputs,
|
|
520
|
+
results: state.results,
|
|
521
|
+
state,
|
|
522
|
+
signal,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private async runAgentNode(
|
|
527
|
+
workflow: WorkflowDefinition,
|
|
528
|
+
state: WorkflowRunState,
|
|
529
|
+
runDir: string,
|
|
530
|
+
nodeId: string,
|
|
531
|
+
attemptId: string,
|
|
532
|
+
node: AgentNodeDefinition,
|
|
533
|
+
context: WorkflowNodeContext,
|
|
534
|
+
signal: AbortSignal,
|
|
535
|
+
meta: NodeExecutionMeta,
|
|
536
|
+
): Promise<NodeExecution> {
|
|
537
|
+
const basePrompt = await node.prompt(context);
|
|
538
|
+
if (signal.aborted) {
|
|
539
|
+
// The node timed out or the run was cancelled while the async prompt
|
|
540
|
+
// builder ran; a late continuation must not write into a bundle that
|
|
541
|
+
// may already be terminal.
|
|
542
|
+
throw abortError(signal);
|
|
543
|
+
}
|
|
544
|
+
const prompt = appendStepContract(
|
|
545
|
+
basePrompt,
|
|
546
|
+
workflow.name,
|
|
547
|
+
nodeId,
|
|
548
|
+
attemptId,
|
|
549
|
+
node.expectedOutput,
|
|
550
|
+
);
|
|
551
|
+
meta.promptText = prompt;
|
|
552
|
+
await this.persist(runDir, state, {
|
|
553
|
+
scope: "agent",
|
|
554
|
+
type: "agent_prompt_sent",
|
|
555
|
+
nodeId,
|
|
556
|
+
attemptId,
|
|
557
|
+
payload: { prompt },
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const submission = await this.executor.runAgentStep(
|
|
561
|
+
{
|
|
562
|
+
contract: {
|
|
563
|
+
runId: state.runId,
|
|
564
|
+
workflowName: workflow.name,
|
|
565
|
+
nodeId,
|
|
566
|
+
attemptId,
|
|
567
|
+
...(node.expectedOutput !== undefined ? { expectedOutput: node.expectedOutput } : {}),
|
|
568
|
+
},
|
|
569
|
+
prompt,
|
|
570
|
+
accept: async (output) => await this.acceptSubmission(node, context, output),
|
|
571
|
+
},
|
|
572
|
+
signal,
|
|
573
|
+
);
|
|
574
|
+
return { output: submission.output, promptText: prompt };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private async acceptSubmission(
|
|
578
|
+
node: AgentNodeDefinition,
|
|
579
|
+
context: WorkflowNodeContext,
|
|
580
|
+
output: unknown,
|
|
581
|
+
): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> {
|
|
582
|
+
try {
|
|
583
|
+
const normalized = normalizeAgentOutput(output);
|
|
584
|
+
const validated = node.validate ? await node.validate(normalized, context) : normalized;
|
|
585
|
+
const value = validated === undefined ? null : validated;
|
|
586
|
+
// Check here rather than after acceptance so a non-JSON validator
|
|
587
|
+
// result comes back as a validation error the model can retry.
|
|
588
|
+
assertJsonSerializable(value, "Step output");
|
|
589
|
+
return { ok: true, value };
|
|
590
|
+
} catch (error) {
|
|
591
|
+
return { ok: false, error: errorMessage(error) };
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private async runActionNode(
|
|
596
|
+
node: ActionNodeDefinition,
|
|
597
|
+
context: WorkflowNodeContext,
|
|
598
|
+
signal: AbortSignal,
|
|
599
|
+
meta: NodeExecutionMeta,
|
|
600
|
+
): Promise<NodeExecution> {
|
|
601
|
+
if ("exec" in node) {
|
|
602
|
+
return await runShellActionNode(node, context, signal, meta);
|
|
603
|
+
}
|
|
604
|
+
meta.action = { actionType: "function" };
|
|
605
|
+
const output = await node.run(context);
|
|
606
|
+
return { output, promptText: null, action: { actionType: "function" } };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private async persist(
|
|
610
|
+
runDir: string,
|
|
611
|
+
state: WorkflowRunState,
|
|
612
|
+
event: WorkflowTraceEventDraft,
|
|
613
|
+
): Promise<void> {
|
|
614
|
+
const traceEvent = await this.store.writeSnapshot(runDir, state, event);
|
|
615
|
+
try {
|
|
616
|
+
this.onEvent?.(traceEvent, state);
|
|
617
|
+
} catch {
|
|
618
|
+
// Observers (UI updates, loggers) must never determine workflow
|
|
619
|
+
// correctness; a throwing observer would otherwise fail the run.
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private async finishRun(
|
|
624
|
+
runDir: string,
|
|
625
|
+
state: WorkflowRunState,
|
|
626
|
+
status: WorkflowRunState["status"],
|
|
627
|
+
fields: { error?: string; waitingOn?: string; finalOutput?: unknown },
|
|
628
|
+
): Promise<void> {
|
|
629
|
+
if (status === "failed" && state.status === "timed_out") {
|
|
630
|
+
status = "timed_out";
|
|
631
|
+
}
|
|
632
|
+
state.status = status;
|
|
633
|
+
state.finishedAt = new Date().toISOString();
|
|
634
|
+
if (fields.error !== undefined) {
|
|
635
|
+
state.error = fields.error;
|
|
636
|
+
}
|
|
637
|
+
if (fields.waitingOn !== undefined) {
|
|
638
|
+
state.waitingOn = fields.waitingOn;
|
|
639
|
+
}
|
|
640
|
+
if (fields.finalOutput !== undefined) {
|
|
641
|
+
state.finalOutput = fields.finalOutput;
|
|
642
|
+
}
|
|
643
|
+
delete state.currentNode;
|
|
644
|
+
delete state.currentAttemptId;
|
|
645
|
+
delete state.currentNodeType;
|
|
646
|
+
delete state.currentNodeStartedAt;
|
|
647
|
+
await this.persist(runDir, state, {
|
|
648
|
+
scope: "run",
|
|
649
|
+
type: `run_${status}`,
|
|
650
|
+
payload: {
|
|
651
|
+
status,
|
|
652
|
+
...(fields.error !== undefined ? { error: fields.error } : {}),
|
|
653
|
+
...(fields.waitingOn !== undefined ? { waitingOn: fields.waitingOn } : {}),
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function runCheckpointNode(
|
|
660
|
+
node: CheckpointNodeDefinition,
|
|
661
|
+
context: WorkflowNodeContext,
|
|
662
|
+
): Promise<NodeExecution> {
|
|
663
|
+
const output = node.run ? await node.run(context) : { summary: node.summary ?? "checkpoint" };
|
|
664
|
+
return { output, promptText: null };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function shellReceipt(result: ShellActionResult): WorkflowActionReceipt {
|
|
668
|
+
return {
|
|
669
|
+
actionType: "shell",
|
|
670
|
+
command: result.command,
|
|
671
|
+
args: result.args,
|
|
672
|
+
cwd: result.cwd,
|
|
673
|
+
exitCode: result.exitCode,
|
|
674
|
+
signal: result.signal,
|
|
675
|
+
durationMs: result.durationMs,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function runShellActionNode(
|
|
680
|
+
node: ShellActionNodeDefinition,
|
|
681
|
+
context: WorkflowNodeContext,
|
|
682
|
+
signal: AbortSignal,
|
|
683
|
+
meta: NodeExecutionMeta,
|
|
684
|
+
): Promise<NodeExecution> {
|
|
685
|
+
const spec = await node.exec(context);
|
|
686
|
+
let result: ShellActionResult;
|
|
687
|
+
try {
|
|
688
|
+
result = await runShellAction(spec, signal);
|
|
689
|
+
} catch (error) {
|
|
690
|
+
const failed = shellResultFromError(error);
|
|
691
|
+
if (failed) {
|
|
692
|
+
meta.action = shellReceipt(failed);
|
|
693
|
+
}
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
meta.action = shellReceipt(result);
|
|
697
|
+
const output = node.parse ? await node.parse(result, context) : result;
|
|
698
|
+
return { output, promptText: null, action: shellReceipt(result) };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Rejects with the abort reason once the signal fires; never resolves. */
|
|
702
|
+
/** The error carried by an aborted signal, normalized to an Error. */
|
|
703
|
+
function abortError(signal: AbortSignal): Error {
|
|
704
|
+
const reason: unknown = signal.reason ?? new CancelledError();
|
|
705
|
+
return reason instanceof Error ? reason : new CancelledError(String(reason));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function abortRejection(signal: AbortSignal): Promise<never> {
|
|
709
|
+
return new Promise<never>((_resolve, reject) => {
|
|
710
|
+
const onAbort = () => {
|
|
711
|
+
reject(abortError(signal));
|
|
712
|
+
};
|
|
713
|
+
if (signal.aborted) {
|
|
714
|
+
onAbort();
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Outputs are persisted to the run bundle, so they must be JSON-serializable.
|
|
723
|
+
* Failing here turns a bad callback return value into a normal node failure
|
|
724
|
+
* instead of corrupting the run state.
|
|
725
|
+
*/
|
|
726
|
+
function assertJsonSerializable(value: unknown, what: string): void {
|
|
727
|
+
let encoded: string | undefined;
|
|
728
|
+
try {
|
|
729
|
+
encoded = JSON.stringify(value);
|
|
730
|
+
} catch (error) {
|
|
731
|
+
throw new Error(`${what} is non-JSON-serializable: ${errorMessage(error)}`);
|
|
732
|
+
}
|
|
733
|
+
if (encoded === undefined || !isDeepStrictEqual(JSON.parse(encoded), value)) {
|
|
734
|
+
throw new Error(
|
|
735
|
+
`${what} does not survive a JSON round-trip. ` +
|
|
736
|
+
`Use plain JSON values (no functions, dates, NaN, or undefined properties).`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Models occasionally submit the step output as a JSON-encoded string. Accept
|
|
743
|
+
* that by parsing tolerantly, falling back to the raw string.
|
|
744
|
+
*/
|
|
745
|
+
function normalizeAgentOutput(output: unknown): unknown {
|
|
746
|
+
if (typeof output !== "string") {
|
|
747
|
+
return output;
|
|
748
|
+
}
|
|
749
|
+
try {
|
|
750
|
+
return extractJsonValue(output);
|
|
751
|
+
} catch {
|
|
752
|
+
return output;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* The step contract appended to every agent-node prompt. This is the
|
|
758
|
+
* documented standard for how the model completes a workflow step.
|
|
759
|
+
*/
|
|
760
|
+
export function appendStepContract(
|
|
761
|
+
prompt: string,
|
|
762
|
+
workflowName: string,
|
|
763
|
+
nodeId: string,
|
|
764
|
+
attemptId: string,
|
|
765
|
+
expectedOutput: string | undefined,
|
|
766
|
+
): string {
|
|
767
|
+
return [
|
|
768
|
+
prompt.trimEnd(),
|
|
769
|
+
"",
|
|
770
|
+
"---",
|
|
771
|
+
`Workflow step contract (workflow: ${workflowName}, step: ${nodeId}, attempt: ${attemptId})`,
|
|
772
|
+
"",
|
|
773
|
+
"Complete this step by calling the `workflow` tool exactly once with:",
|
|
774
|
+
`{"step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "output": <your result>}`,
|
|
775
|
+
`Expected output: ${expectedOutput ?? "a JSON object with your result"}`,
|
|
776
|
+
"The step is complete only after the workflow tool accepts the output.",
|
|
777
|
+
"If the tool reports a validation error, correct the output and call it again.",
|
|
778
|
+
].join("\n");
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function resolveRunTitle(
|
|
782
|
+
workflow: WorkflowDefinition,
|
|
783
|
+
input: unknown,
|
|
784
|
+
): Promise<{ runTitle?: string }> {
|
|
785
|
+
if (typeof workflow.title === "string") {
|
|
786
|
+
return { runTitle: workflow.title };
|
|
787
|
+
}
|
|
788
|
+
if (typeof workflow.title === "function") {
|
|
789
|
+
const title = await workflow.title({ input, workflowName: workflow.name });
|
|
790
|
+
return title !== undefined ? { runTitle: title } : {};
|
|
791
|
+
}
|
|
792
|
+
return {};
|
|
793
|
+
}
|