@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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -0
  3. package/dist/extension/executor.d.ts +58 -0
  4. package/dist/extension/executor.js +201 -0
  5. package/dist/extension/executor.js.map +1 -0
  6. package/dist/extension/index.d.ts +17 -0
  7. package/dist/extension/index.js +504 -0
  8. package/dist/extension/index.js.map +1 -0
  9. package/dist/extension/widget.d.ts +21 -0
  10. package/dist/extension/widget.js +142 -0
  11. package/dist/extension/widget.js.map +1 -0
  12. package/dist/render/ansi.d.ts +16 -0
  13. package/dist/render/ansi.js +42 -0
  14. package/dist/render/ansi.js.map +1 -0
  15. package/dist/render/canvas.d.ts +40 -0
  16. package/dist/render/canvas.js +177 -0
  17. package/dist/render/canvas.js.map +1 -0
  18. package/dist/render/format.d.ts +3 -0
  19. package/dist/render/format.js +17 -0
  20. package/dist/render/format.js.map +1 -0
  21. package/dist/render/graph-render.d.ts +22 -0
  22. package/dist/render/graph-render.js +520 -0
  23. package/dist/render/graph-render.js.map +1 -0
  24. package/dist/render/graph.d.ts +46 -0
  25. package/dist/render/graph.js +272 -0
  26. package/dist/render/graph.js.map +1 -0
  27. package/dist/viewer/cli.d.ts +10 -0
  28. package/dist/viewer/cli.js +132 -0
  29. package/dist/viewer/cli.js.map +1 -0
  30. package/dist/viewer/render.d.ts +19 -0
  31. package/dist/viewer/render.js +162 -0
  32. package/dist/viewer/render.js.map +1 -0
  33. package/dist/viewer/tui.d.ts +11 -0
  34. package/dist/viewer/tui.js +140 -0
  35. package/dist/viewer/tui.js.map +1 -0
  36. package/dist/viewer/watch.d.ts +9 -0
  37. package/dist/viewer/watch.js +46 -0
  38. package/dist/viewer/watch.js.map +1 -0
  39. package/dist/workflows/decision.d.ts +25 -0
  40. package/dist/workflows/decision.js +96 -0
  41. package/dist/workflows/decision.js.map +1 -0
  42. package/dist/workflows/definition.d.ts +9 -0
  43. package/dist/workflows/definition.js +61 -0
  44. package/dist/workflows/definition.js.map +1 -0
  45. package/dist/workflows/engine.d.ts +65 -0
  46. package/dist/workflows/engine.js +574 -0
  47. package/dist/workflows/engine.js.map +1 -0
  48. package/dist/workflows/errors.d.ts +9 -0
  49. package/dist/workflows/errors.js +24 -0
  50. package/dist/workflows/errors.js.map +1 -0
  51. package/dist/workflows/graph.d.ts +17 -0
  52. package/dist/workflows/graph.js +127 -0
  53. package/dist/workflows/graph.js.map +1 -0
  54. package/dist/workflows/index.d.ts +11 -0
  55. package/dist/workflows/index.js +11 -0
  56. package/dist/workflows/index.js.map +1 -0
  57. package/dist/workflows/json.d.ts +14 -0
  58. package/dist/workflows/json.js +134 -0
  59. package/dist/workflows/json.js.map +1 -0
  60. package/dist/workflows/loader.d.ts +28 -0
  61. package/dist/workflows/loader.js +94 -0
  62. package/dist/workflows/loader.js.map +1 -0
  63. package/dist/workflows/schema.d.ts +7 -0
  64. package/dist/workflows/schema.js +176 -0
  65. package/dist/workflows/schema.js.map +1 -0
  66. package/dist/workflows/shell.d.ts +9 -0
  67. package/dist/workflows/shell.js +177 -0
  68. package/dist/workflows/shell.js.map +1 -0
  69. package/dist/workflows/store.d.ts +35 -0
  70. package/dist/workflows/store.js +181 -0
  71. package/dist/workflows/store.js.map +1 -0
  72. package/dist/workflows/text.d.ts +10 -0
  73. package/dist/workflows/text.js +32 -0
  74. package/dist/workflows/text.js.map +1 -0
  75. package/dist/workflows/types.d.ts +280 -0
  76. package/dist/workflows/types.js +2 -0
  77. package/dist/workflows/types.js.map +1 -0
  78. package/docs/development.md +130 -0
  79. package/docs/run-bundles.md +114 -0
  80. package/docs/workflows.md +311 -0
  81. package/examples/workflows/autoimplement.workflow.ts +92 -0
  82. package/examples/workflows/autoresearch.workflow.ts +139 -0
  83. package/examples/workflows/branch.workflow.ts +63 -0
  84. package/examples/workflows/echo.workflow.ts +23 -0
  85. package/examples/workflows/elegant-solution.workflow.ts +95 -0
  86. package/examples/workflows/shell.workflow.ts +31 -0
  87. package/examples/workflows/two-turn.workflow.ts +64 -0
  88. package/package.json +80 -0
  89. package/src/extension/executor.ts +251 -0
  90. package/src/extension/index.ts +627 -0
  91. package/src/extension/widget.ts +183 -0
  92. package/src/render/ansi.ts +47 -0
  93. package/src/render/canvas.ts +196 -0
  94. package/src/render/format.ts +19 -0
  95. package/src/render/graph-render.ts +738 -0
  96. package/src/render/graph.ts +341 -0
  97. package/src/viewer/cli.ts +150 -0
  98. package/src/viewer/render.ts +236 -0
  99. package/src/viewer/tui.ts +159 -0
  100. package/src/viewer/watch.ts +55 -0
  101. package/src/workflows/decision.ts +127 -0
  102. package/src/workflows/definition.ts +104 -0
  103. package/src/workflows/engine.ts +793 -0
  104. package/src/workflows/errors.ts +27 -0
  105. package/src/workflows/graph.ts +161 -0
  106. package/src/workflows/index.ts +76 -0
  107. package/src/workflows/json.ts +155 -0
  108. package/src/workflows/loader.ts +123 -0
  109. package/src/workflows/schema.ts +218 -0
  110. package/src/workflows/shell.ts +199 -0
  111. package/src/workflows/store.ts +234 -0
  112. package/src/workflows/text.ts +34 -0
  113. 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
+ }