@osolmaz/pi-workflows 0.1.0 → 0.3.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/README.md +107 -26
- package/dist/builtins/monitor.workflow.d.ts +71 -0
- package/dist/builtins/monitor.workflow.js +234 -0
- package/dist/builtins/monitor.workflow.js.map +1 -0
- package/dist/controllers/conditions.d.ts +6 -0
- package/dist/controllers/conditions.js +68 -0
- package/dist/controllers/conditions.js.map +1 -0
- package/dist/controllers/definition.d.ts +6 -0
- package/dist/controllers/definition.js +45 -0
- package/dist/controllers/definition.js.map +1 -0
- package/dist/controllers/effects.d.ts +14 -0
- package/dist/controllers/effects.js +104 -0
- package/dist/controllers/effects.js.map +1 -0
- package/dist/controllers/errors.d.ts +12 -0
- package/dist/controllers/errors.js +25 -0
- package/dist/controllers/errors.js.map +1 -0
- package/dist/controllers/index.d.ts +13 -0
- package/dist/controllers/index.js +13 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/controllers/json.d.ts +5 -0
- package/dist/controllers/json.js +57 -0
- package/dist/controllers/json.js.map +1 -0
- package/dist/controllers/loader.d.ts +23 -0
- package/dist/controllers/loader.js +74 -0
- package/dist/controllers/loader.js.map +1 -0
- package/dist/controllers/manager.d.ts +58 -0
- package/dist/controllers/manager.js +399 -0
- package/dist/controllers/manager.js.map +1 -0
- package/dist/controllers/results.d.ts +5 -0
- package/dist/controllers/results.js +32 -0
- package/dist/controllers/results.js.map +1 -0
- package/dist/controllers/sqlite.d.ts +212 -0
- package/dist/controllers/sqlite.js +1009 -0
- package/dist/controllers/sqlite.js.map +1 -0
- package/dist/controllers/store.d.ts +112 -0
- package/dist/controllers/store.js +32 -0
- package/dist/controllers/store.js.map +1 -0
- package/dist/controllers/types.d.ts +159 -0
- package/dist/controllers/types.js +2 -0
- package/dist/controllers/types.js.map +1 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
- package/dist/controllers/workflow-engine-scheduler.js +93 -0
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
- package/dist/controllers/workflows.d.ts +27 -0
- package/dist/controllers/workflows.js +109 -0
- package/dist/controllers/workflows.js.map +1 -0
- package/dist/extension/controller-host.d.ts +47 -0
- package/dist/extension/controller-host.js +110 -0
- package/dist/extension/controller-host.js.map +1 -0
- package/dist/extension/executor.d.ts +14 -1
- package/dist/extension/executor.js +12 -2
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.js +1118 -89
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +91 -0
- package/dist/extension/recorder.js +537 -0
- package/dist/extension/recorder.js.map +1 -0
- package/dist/extension/session-events.d.ts +134 -0
- package/dist/extension/session-events.js +60 -0
- package/dist/extension/session-events.js.map +1 -0
- package/dist/extension/widget.js +25 -24
- package/dist/extension/widget.js.map +1 -1
- package/dist/extension/workflow-tool.d.ts +28 -0
- package/dist/extension/workflow-tool.js +33 -0
- package/dist/extension/workflow-tool.js.map +1 -0
- package/dist/host/processes.d.ts +24 -0
- package/dist/host/processes.js +114 -0
- package/dist/host/processes.js.map +1 -0
- package/dist/host/rpc-bridge.d.ts +9 -0
- package/dist/host/rpc-bridge.js +39 -0
- package/dist/host/rpc-bridge.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +38 -0
- package/dist/host/rpc-executor.js +254 -0
- package/dist/host/rpc-executor.js.map +1 -0
- package/dist/host/runner.d.ts +49 -0
- package/dist/host/runner.js +350 -0
- package/dist/host/runner.js.map +1 -0
- package/dist/render/canvas.d.ts +1 -1
- package/dist/render/canvas.js +5 -0
- package/dist/render/canvas.js.map +1 -1
- package/dist/render/graph-render.d.ts +5 -0
- package/dist/render/graph-render.js +211 -48
- package/dist/render/graph-render.js.map +1 -1
- package/dist/viewer/cli.d.ts +7 -3
- package/dist/viewer/cli.js +150 -19
- package/dist/viewer/cli.js.map +1 -1
- package/dist/viewer/render.js +19 -3
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/session-reducer.d.ts +45 -0
- package/dist/viewer/session-reducer.js +266 -0
- package/dist/viewer/session-reducer.js.map +1 -0
- package/dist/workflows/artifacts.d.ts +40 -0
- package/dist/workflows/artifacts.js +155 -0
- package/dist/workflows/artifacts.js.map +1 -0
- package/dist/workflows/engine.d.ts +38 -0
- package/dist/workflows/engine.js +281 -20
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +23 -0
- package/dist/workflows/errors.js +38 -0
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/graph.js +0 -5
- package/dist/workflows/graph.js.map +1 -1
- package/dist/workflows/index.d.ts +3 -2
- package/dist/workflows/index.js +2 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/loader.d.ts +5 -3
- package/dist/workflows/loader.js +10 -1
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/schema.js +1 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +98 -10
- package/dist/workflows/store.js +921 -46
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +136 -3
- package/docs/CONTROLLERS.md +215 -0
- package/docs/development.md +51 -24
- package/docs/live-replay-protocol.md +155 -0
- package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
- package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
- package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
- package/docs/plans/piw-viewer-experience-implementation-plan.md +674 -0
- package/docs/plans/replayable-run-bundles-implementation-plan.md +65 -0
- package/docs/plans/session-event-replay-implementation-plan.md +494 -0
- package/docs/plans/tui-viewer-implementation-plan.md +64 -0
- package/docs/run-bundles.md +359 -55
- package/docs/session-event-journal.md +470 -0
- package/docs/tui-viewer.md +218 -0
- package/docs/workflows.md +131 -11
- package/examples/controllers/pull-request.controller.ts +215 -0
- package/package.json +11 -2
- package/src/builtins/monitor.workflow.ts +278 -0
- package/src/controllers/conditions.ts +110 -0
- package/src/controllers/definition.ts +65 -0
- package/src/controllers/effects.ts +123 -0
- package/src/controllers/errors.ts +27 -0
- package/src/controllers/index.ts +90 -0
- package/src/controllers/json.ts +62 -0
- package/src/controllers/loader.ts +104 -0
- package/src/controllers/manager.ts +533 -0
- package/src/controllers/results.ts +46 -0
- package/src/controllers/sqlite.ts +1427 -0
- package/src/controllers/store.ts +160 -0
- package/src/controllers/types.ts +183 -0
- package/src/controllers/workflow-engine-scheduler.ts +145 -0
- package/src/controllers/workflows.ts +152 -0
- package/src/extension/controller-host.ts +163 -0
- package/src/extension/executor.ts +29 -2
- package/src/extension/index.ts +1315 -109
- package/src/extension/recorder.ts +662 -0
- package/src/extension/session-events.ts +119 -0
- package/src/extension/widget.ts +26 -24
- package/src/extension/workflow-tool.ts +59 -0
- package/src/host/processes.ts +119 -0
- package/src/host/rpc-bridge.ts +44 -0
- package/src/host/rpc-executor.ts +299 -0
- package/src/host/runner.ts +406 -0
- package/src/render/canvas.ts +19 -1
- package/src/render/graph-render.ts +277 -44
- package/src/viewer/cli.ts +167 -21
- package/src/viewer/render.ts +21 -3
- package/src/viewer/session-reducer.ts +347 -0
- package/src/workflows/artifacts.ts +188 -0
- package/src/workflows/engine.ts +365 -19
- package/src/workflows/errors.ts +45 -0
- package/src/workflows/graph.ts +0 -5
- package/src/workflows/index.ts +15 -0
- package/src/workflows/loader.ts +13 -3
- package/src/workflows/schema.ts +1 -1
- package/src/workflows/store.ts +1157 -48
- package/src/workflows/types.ts +151 -3
package/src/workflows/engine.ts
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveArtifacts } from "./artifacts.js";
|
|
4
|
+
import {
|
|
5
|
+
CancelledError,
|
|
6
|
+
errorMessage,
|
|
7
|
+
isAbortLikeError,
|
|
8
|
+
isClaimLostError,
|
|
9
|
+
isRunParkedError,
|
|
10
|
+
RunParkedError,
|
|
11
|
+
TimeoutError,
|
|
12
|
+
WorkflowSourceChangedError,
|
|
13
|
+
} from "./errors.js";
|
|
4
14
|
import { resolveNext, resolveNextForOutcome, validateWorkflowDefinition } from "./graph.js";
|
|
5
15
|
import { extractJsonValue } from "./json.js";
|
|
6
16
|
import { runShellAction, shellResultFromError } from "./shell.js";
|
|
7
|
-
import { WorkflowRunStore, createRunId } from "./store.js";
|
|
17
|
+
import { RUN_STATE_SCHEMA, WorkflowRunStore, createRunId, readRunBundle } from "./store.js";
|
|
8
18
|
import type {
|
|
9
19
|
AgentNodeDefinition,
|
|
10
20
|
AgentStepExecutor,
|
|
11
21
|
ActionNodeDefinition,
|
|
12
22
|
CheckpointNodeDefinition,
|
|
23
|
+
ConversationRange,
|
|
13
24
|
ShellActionNodeDefinition,
|
|
14
25
|
ShellActionResult,
|
|
15
26
|
WorkflowActionReceipt,
|
|
@@ -35,6 +46,7 @@ type NodeExecution = {
|
|
|
35
46
|
output: unknown;
|
|
36
47
|
promptText: string | null;
|
|
37
48
|
action?: WorkflowActionReceipt;
|
|
49
|
+
conversation?: ConversationRange;
|
|
38
50
|
};
|
|
39
51
|
|
|
40
52
|
/**
|
|
@@ -64,17 +76,22 @@ export class WorkflowEngine {
|
|
|
64
76
|
private readonly defaultNodeTimeoutMs: number;
|
|
65
77
|
private readonly maxSteps: number;
|
|
66
78
|
private readonly onEvent?: WorkflowEngineOptions["onEvent"];
|
|
79
|
+
private readonly onRunStarted?: WorkflowEngineOptions["onRunStarted"];
|
|
80
|
+
private readonly onRunFinishing?: WorkflowEngineOptions["onRunFinishing"];
|
|
67
81
|
private activeAbort: AbortController | null = null;
|
|
68
82
|
private cancelled = false;
|
|
83
|
+
private parked = false;
|
|
69
84
|
private paused = false;
|
|
70
85
|
private wakePause: (() => void) | null = null;
|
|
71
86
|
|
|
72
87
|
constructor(options: WorkflowEngineOptions) {
|
|
73
88
|
this.executor = options.executor;
|
|
74
|
-
this.store = new WorkflowRunStore(options.outputRoot);
|
|
89
|
+
this.store = options.store ?? new WorkflowRunStore(options.outputRoot);
|
|
75
90
|
this.defaultNodeTimeoutMs = options.defaultNodeTimeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
76
91
|
this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
77
92
|
this.onEvent = options.onEvent;
|
|
93
|
+
this.onRunStarted = options.onRunStarted;
|
|
94
|
+
this.onRunFinishing = options.onRunFinishing;
|
|
78
95
|
}
|
|
79
96
|
|
|
80
97
|
get outputRoot(): string {
|
|
@@ -90,6 +107,17 @@ export class WorkflowEngine {
|
|
|
90
107
|
this.wakePause?.();
|
|
91
108
|
}
|
|
92
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Stop without a terminal event so another runner can claim and resume
|
|
112
|
+
* the run. The active node aborts; its partial attempt is never recorded,
|
|
113
|
+
* so resume reruns that node from its last persisted boundary.
|
|
114
|
+
*/
|
|
115
|
+
park(): void {
|
|
116
|
+
this.parked = true;
|
|
117
|
+
this.activeAbort?.abort(new CancelledError());
|
|
118
|
+
this.wakePause?.();
|
|
119
|
+
}
|
|
120
|
+
|
|
93
121
|
/**
|
|
94
122
|
* Request a pause. The current step finishes normally; the engine then
|
|
95
123
|
* holds before dispatching the next node until `resume` (or `cancel`).
|
|
@@ -112,17 +140,27 @@ export class WorkflowEngine {
|
|
|
112
140
|
async run(
|
|
113
141
|
workflow: WorkflowDefinition,
|
|
114
142
|
input: unknown,
|
|
115
|
-
options: { workflowPath?: string } = {},
|
|
143
|
+
options: { workflowPath?: string; workflowHash?: string; runId?: string } = {},
|
|
116
144
|
): Promise<WorkflowRunResult> {
|
|
117
145
|
validateWorkflowDefinition(workflow);
|
|
118
146
|
// Fail before any bundle exists so bad input cannot leave a partial run
|
|
119
147
|
// on disk or silently change shape when state.json round-trips.
|
|
120
148
|
const normalizedInput = input === undefined ? null : input;
|
|
121
149
|
assertJsonSerializable(normalizedInput, "Workflow run input");
|
|
150
|
+
if (options.runId !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(options.runId)) {
|
|
151
|
+
throw new Error(`Invalid workflow run id: ${JSON.stringify(options.runId)}`);
|
|
152
|
+
}
|
|
122
153
|
this.cancelled = false;
|
|
123
154
|
this.paused = false;
|
|
155
|
+
this.parked = false;
|
|
124
156
|
|
|
125
|
-
const state = await this.createRunState(
|
|
157
|
+
const state = await this.createRunState(
|
|
158
|
+
workflow,
|
|
159
|
+
normalizedInput,
|
|
160
|
+
options.workflowPath,
|
|
161
|
+
options.workflowHash,
|
|
162
|
+
options.runId,
|
|
163
|
+
);
|
|
126
164
|
const runDir = await this.store.initializeRunBundle(workflow, state);
|
|
127
165
|
await this.persist(runDir, state, {
|
|
128
166
|
scope: "run",
|
|
@@ -130,19 +168,284 @@ export class WorkflowEngine {
|
|
|
130
168
|
payload: {
|
|
131
169
|
workflowName: workflow.name,
|
|
132
170
|
...(state.runTitle ? { runTitle: state.runTitle } : {}),
|
|
171
|
+
input: state.input,
|
|
133
172
|
},
|
|
134
173
|
});
|
|
174
|
+
// Awaited so anything the hook writes (e.g. a session binding and its
|
|
175
|
+
// `session_bound` event) lands before node events and can never trail
|
|
176
|
+
// the terminal event of a fast run.
|
|
177
|
+
await this.onRunStarted?.(runDir, state);
|
|
135
178
|
|
|
136
179
|
try {
|
|
137
180
|
await this.executeGraph(workflow, state, runDir);
|
|
138
181
|
} catch (error) {
|
|
139
|
-
|
|
182
|
+
if (isRunParkedError(error) || this.parked) {
|
|
183
|
+
return { runDir, state };
|
|
184
|
+
}
|
|
185
|
+
await this.finishAfterError(runDir, state, error);
|
|
186
|
+
return { runDir, state };
|
|
187
|
+
}
|
|
188
|
+
return { runDir, state };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resume an interrupted run at the node it stopped on. The caller must
|
|
193
|
+
* hold the run's queue claim. Completed nodes replay from the recorded
|
|
194
|
+
* state; only the interrupted node and everything downstream rerun.
|
|
195
|
+
*/
|
|
196
|
+
async resumeRun(
|
|
197
|
+
workflow: WorkflowDefinition,
|
|
198
|
+
runId: string,
|
|
199
|
+
options: { workflowHash?: string; force?: boolean } = {},
|
|
200
|
+
): Promise<WorkflowRunResult> {
|
|
201
|
+
validateWorkflowDefinition(workflow);
|
|
202
|
+
// Reset before any await: a park or cancel landing during preparation
|
|
203
|
+
// must survive, or a host drain would hang while the run executes.
|
|
204
|
+
this.cancelled = false;
|
|
205
|
+
this.paused = false;
|
|
206
|
+
this.parked = false;
|
|
207
|
+
const bundle = await this.store.prepareRunResume(runId);
|
|
208
|
+
const { runDir } = bundle;
|
|
209
|
+
const state = bundle.state;
|
|
210
|
+
const hashMismatch =
|
|
211
|
+
state.workflowHash !== undefined &&
|
|
212
|
+
options.workflowHash !== undefined &&
|
|
213
|
+
state.workflowHash !== options.workflowHash;
|
|
214
|
+
if (hashMismatch && options.force !== true) {
|
|
215
|
+
throw new WorkflowSourceChangedError(runId);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const point = this.resumePointFor(workflow, state, "wait");
|
|
219
|
+
// A resumed run starts unpaused; the operator can pause again. The
|
|
220
|
+
// interrupted node's stale in-flight markers go away before the resume
|
|
221
|
+
// event so the projection matches what the engine is about to do.
|
|
222
|
+
delete state.paused;
|
|
223
|
+
delete state.currentNode;
|
|
224
|
+
delete state.currentAttemptId;
|
|
225
|
+
delete state.currentNodeStartedAt;
|
|
226
|
+
delete state.statusDetail;
|
|
227
|
+
await this.persist(runDir, state, {
|
|
228
|
+
scope: "run",
|
|
229
|
+
type: "run_resumed",
|
|
230
|
+
payload: {
|
|
231
|
+
...(point.nodeId !== null ? { resumeAt: point.nodeId } : {}),
|
|
232
|
+
replayedSteps: state.steps.length,
|
|
233
|
+
...(hashMismatch ? { workflowHashMismatch: true, forced: true } : {}),
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
await this.onRunStarted?.(runDir, state);
|
|
237
|
+
|
|
238
|
+
if (point.nodeId === null) {
|
|
239
|
+
// The last recorded transition already finished the graph; the crash
|
|
240
|
+
// happened before the terminal event was written. A finished
|
|
241
|
+
// checkpoint restores its waiting gate rather than completing.
|
|
242
|
+
if (point.waitingOn !== undefined) {
|
|
243
|
+
await this.finishRun(runDir, state, "waiting", {
|
|
244
|
+
waitingOn: point.waitingOn,
|
|
245
|
+
finalOutput: point.lastOutput,
|
|
246
|
+
});
|
|
247
|
+
} else if (point.failedResult === undefined) {
|
|
248
|
+
await this.finishRun(runDir, state, "completed", { finalOutput: point.lastOutput });
|
|
249
|
+
} else {
|
|
250
|
+
const timedOut = point.failedResult.outcome === "timed_out";
|
|
251
|
+
await this.finishRun(runDir, state, timedOut ? "timed_out" : "failed", {
|
|
252
|
+
error: point.failedResult.error ?? `Workflow node failed: ${point.failedResult.nodeId}`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return { runDir, state };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await this.executeGraph(
|
|
260
|
+
workflow,
|
|
261
|
+
state,
|
|
262
|
+
runDir,
|
|
263
|
+
point.nodeId,
|
|
264
|
+
state.steps.length,
|
|
265
|
+
point.lastOutput,
|
|
266
|
+
);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (isRunParkedError(error) || this.parked) {
|
|
269
|
+
return { runDir, state };
|
|
270
|
+
}
|
|
271
|
+
await this.finishAfterError(runDir, state, error);
|
|
272
|
+
return { runDir, state };
|
|
273
|
+
}
|
|
274
|
+
return { runDir, state };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Start a continuation run from a checkpointed parent. The new run gets a
|
|
279
|
+
* fresh bundle and trace, carries forward the parent's outputs, results,
|
|
280
|
+
* and step accounting, and continues routing after the checkpoint.
|
|
281
|
+
*/
|
|
282
|
+
async continueRun(
|
|
283
|
+
workflow: WorkflowDefinition,
|
|
284
|
+
parentRunId: string,
|
|
285
|
+
input: unknown,
|
|
286
|
+
options: { workflowPath?: string; workflowHash?: string; runId?: string; force?: boolean } = {},
|
|
287
|
+
): Promise<WorkflowRunResult> {
|
|
288
|
+
validateWorkflowDefinition(workflow);
|
|
289
|
+
this.cancelled = false;
|
|
290
|
+
this.paused = false;
|
|
291
|
+
this.parked = false;
|
|
292
|
+
const parent = await readRunBundle(this.store.runDirFor(parentRunId));
|
|
293
|
+
if (parent === null) {
|
|
294
|
+
throw new Error(`Cannot continue from unreadable workflow run: ${parentRunId}`);
|
|
295
|
+
}
|
|
296
|
+
if (parent.state.status !== "waiting" || parent.state.waitingOn === undefined) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`Cannot continue workflow run ${parentRunId} with status ${parent.state.status}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
const hashMismatch =
|
|
302
|
+
parent.state.workflowHash !== undefined &&
|
|
303
|
+
options.workflowHash !== undefined &&
|
|
304
|
+
parent.state.workflowHash !== options.workflowHash;
|
|
305
|
+
if (hashMismatch && options.force !== true) {
|
|
306
|
+
throw new WorkflowSourceChangedError(parentRunId);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const normalizedInput = input === undefined ? null : input;
|
|
310
|
+
assertJsonSerializable(normalizedInput, "Workflow run input");
|
|
311
|
+
if (options.runId !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(options.runId)) {
|
|
312
|
+
throw new Error(`Invalid workflow run id: ${JSON.stringify(options.runId)}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const state = await this.createRunState(
|
|
316
|
+
workflow,
|
|
317
|
+
normalizedInput,
|
|
318
|
+
options.workflowPath,
|
|
319
|
+
options.workflowHash,
|
|
320
|
+
options.runId,
|
|
321
|
+
);
|
|
322
|
+
state.parentRunId = parentRunId;
|
|
323
|
+
// Artifact references point into the parent's bundle, so carried values
|
|
324
|
+
// are fully resolved here and re-externalized into the new bundle.
|
|
325
|
+
state.outputs = (await resolveArtifacts(
|
|
326
|
+
parent.state.outputs,
|
|
327
|
+
parent.runDir,
|
|
328
|
+
)) as WorkflowRunState["outputs"];
|
|
329
|
+
state.results = (await resolveArtifacts(
|
|
330
|
+
parent.state.results,
|
|
331
|
+
parent.runDir,
|
|
332
|
+
)) as WorkflowRunState["results"];
|
|
333
|
+
state.steps = (await resolveArtifacts(
|
|
334
|
+
parent.state.steps,
|
|
335
|
+
parent.runDir,
|
|
336
|
+
)) as WorkflowRunState["steps"];
|
|
337
|
+
state.carriedStepCount = state.steps.length;
|
|
338
|
+
|
|
339
|
+
const runDir = await this.store.initializeRunBundle(workflow, state);
|
|
340
|
+
await this.persist(runDir, state, {
|
|
341
|
+
scope: "run",
|
|
342
|
+
type: "run_started",
|
|
343
|
+
payload: {
|
|
344
|
+
workflowName: workflow.name,
|
|
345
|
+
...(state.runTitle ? { runTitle: state.runTitle } : {}),
|
|
346
|
+
input: state.input,
|
|
347
|
+
continuedFrom: parentRunId,
|
|
348
|
+
checkpoint: parent.state.waitingOn,
|
|
349
|
+
carriedSteps: state.steps.length,
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
await this.onRunStarted?.(runDir, state);
|
|
353
|
+
|
|
354
|
+
const point = this.resumePointFor(workflow, state, "continue");
|
|
355
|
+
if (point.nodeId === null) {
|
|
356
|
+
// The checkpoint was the final node; the answer completes the chain.
|
|
357
|
+
await this.finishRun(runDir, state, "completed", { finalOutput: point.lastOutput });
|
|
358
|
+
return { runDir, state };
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
await this.executeGraph(
|
|
362
|
+
workflow,
|
|
363
|
+
state,
|
|
364
|
+
runDir,
|
|
365
|
+
point.nodeId,
|
|
366
|
+
state.steps.length,
|
|
367
|
+
point.lastOutput,
|
|
368
|
+
);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (isRunParkedError(error) || this.parked) {
|
|
371
|
+
return { runDir, state };
|
|
372
|
+
}
|
|
373
|
+
await this.finishAfterError(runDir, state, error);
|
|
374
|
+
return { runDir, state };
|
|
375
|
+
}
|
|
376
|
+
return { runDir, state };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Find where a resumed run continues. An in-flight node reruns; otherwise
|
|
381
|
+
* routing continues from the last recorded step. A null nodeId means the
|
|
382
|
+
* graph was already done when the crash hit.
|
|
383
|
+
*/
|
|
384
|
+
private resumePointFor(
|
|
385
|
+
workflow: WorkflowDefinition,
|
|
386
|
+
state: WorkflowRunState,
|
|
387
|
+
checkpointBehavior: "wait" | "continue",
|
|
388
|
+
): {
|
|
389
|
+
nodeId: string | null;
|
|
390
|
+
lastOutput?: unknown;
|
|
391
|
+
failedResult?: WorkflowNodeResult;
|
|
392
|
+
waitingOn?: string;
|
|
393
|
+
} {
|
|
394
|
+
if (state.currentNode !== undefined) {
|
|
395
|
+
if (workflow.nodes[state.currentNode] === undefined) {
|
|
396
|
+
throw new Error(`Resume node is missing from the workflow: ${state.currentNode}`);
|
|
397
|
+
}
|
|
398
|
+
return { nodeId: state.currentNode };
|
|
399
|
+
}
|
|
400
|
+
const lastStep = state.steps.at(-1);
|
|
401
|
+
if (lastStep === undefined) {
|
|
402
|
+
return { nodeId: workflow.startAt };
|
|
403
|
+
}
|
|
404
|
+
const result = state.results[lastStep.nodeId];
|
|
405
|
+
if (result === undefined) {
|
|
406
|
+
return { nodeId: lastStep.nodeId };
|
|
407
|
+
}
|
|
408
|
+
if (result.outcome === "ok") {
|
|
409
|
+
// A recorded checkpoint means the run should be waiting; a crash
|
|
410
|
+
// before the run_waiting persist restores the gate instead of
|
|
411
|
+
// routing past it. The gate applies to this run's own checkpoint
|
|
412
|
+
// only: a continuation's carried steps end with the parent's
|
|
413
|
+
// already-answered checkpoint, and routing must continue from it.
|
|
414
|
+
const isCarriedStep = state.steps.length <= (state.carriedStepCount ?? 0);
|
|
415
|
+
if (
|
|
416
|
+
checkpointBehavior === "wait" &&
|
|
417
|
+
!isCarriedStep &&
|
|
418
|
+
workflow.nodes[lastStep.nodeId]?.nodeType === "checkpoint"
|
|
419
|
+
) {
|
|
420
|
+
return { nodeId: null, waitingOn: lastStep.nodeId, lastOutput: result.output };
|
|
421
|
+
}
|
|
422
|
+
const next = resolveNext(workflow.edges, lastStep.nodeId, result.output, result);
|
|
423
|
+
return next === null
|
|
424
|
+
? { nodeId: null, lastOutput: result.output }
|
|
425
|
+
: { nodeId: next, lastOutput: result.output };
|
|
426
|
+
}
|
|
427
|
+
const next = resolveNextForOutcome(workflow.edges, lastStep.nodeId, result);
|
|
428
|
+
return next === null ? { nodeId: null, failedResult: result } : { nodeId: next };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private async finishAfterError(
|
|
432
|
+
runDir: string,
|
|
433
|
+
state: WorkflowRunState,
|
|
434
|
+
error: unknown,
|
|
435
|
+
): Promise<void> {
|
|
436
|
+
const cancelled = this.cancelled || isAbortLikeError(error);
|
|
437
|
+
try {
|
|
140
438
|
await this.finishRun(runDir, state, cancelled ? "cancelled" : "failed", {
|
|
141
439
|
error: errorMessage(error),
|
|
142
440
|
});
|
|
143
|
-
|
|
441
|
+
} catch (finishError) {
|
|
442
|
+
// A fenced-out runner must not touch the bundle, including terminal
|
|
443
|
+
// projections. Propagate the claim loss instead of the node error.
|
|
444
|
+
if (isClaimLostError(finishError)) {
|
|
445
|
+
throw finishError;
|
|
446
|
+
}
|
|
447
|
+
throw error;
|
|
144
448
|
}
|
|
145
|
-
return { runDir, state };
|
|
146
449
|
}
|
|
147
450
|
|
|
148
451
|
/**
|
|
@@ -175,13 +478,18 @@ export class WorkflowEngine {
|
|
|
175
478
|
workflow: WorkflowDefinition,
|
|
176
479
|
input: unknown,
|
|
177
480
|
workflowPath: string | undefined,
|
|
481
|
+
workflowHash: string | undefined,
|
|
482
|
+
runId: string | undefined,
|
|
178
483
|
): Promise<WorkflowRunState> {
|
|
179
484
|
const now = new Date().toISOString();
|
|
180
485
|
return {
|
|
181
|
-
|
|
486
|
+
schema: RUN_STATE_SCHEMA,
|
|
487
|
+
traceSeq: 0,
|
|
488
|
+
runId: runId ?? createRunId(workflow.name),
|
|
182
489
|
workflowName: workflow.name,
|
|
183
490
|
...(await this.resolveTitleBounded(workflow, input)),
|
|
184
491
|
...(workflowPath !== undefined ? { workflowPath } : {}),
|
|
492
|
+
...(workflowHash !== undefined ? { workflowHash } : {}),
|
|
185
493
|
startedAt: now,
|
|
186
494
|
updatedAt: now,
|
|
187
495
|
status: "running",
|
|
@@ -196,11 +504,14 @@ export class WorkflowEngine {
|
|
|
196
504
|
workflow: WorkflowDefinition,
|
|
197
505
|
state: WorkflowRunState,
|
|
198
506
|
runDir: string,
|
|
507
|
+
startNodeId: string | null = workflow.startAt,
|
|
508
|
+
executedStepsBase = 0,
|
|
509
|
+
initialLastOutput?: unknown,
|
|
199
510
|
): Promise<void> {
|
|
200
511
|
const maxSteps = workflow.maxSteps ?? this.maxSteps;
|
|
201
|
-
let currentNodeId: string | null =
|
|
202
|
-
let executedSteps =
|
|
203
|
-
let lastOutput: unknown;
|
|
512
|
+
let currentNodeId: string | null = startNodeId;
|
|
513
|
+
let executedSteps = executedStepsBase;
|
|
514
|
+
let lastOutput: unknown = initialLastOutput;
|
|
204
515
|
|
|
205
516
|
while (currentNodeId !== null) {
|
|
206
517
|
await this.holdWhilePaused(state, runDir);
|
|
@@ -217,7 +528,14 @@ export class WorkflowEngine {
|
|
|
217
528
|
}
|
|
218
529
|
|
|
219
530
|
const attempt = await this.executeNode(workflow, state, runDir, currentNodeId, node);
|
|
531
|
+
if (this.parked) {
|
|
532
|
+
// Do not record the aborted attempt: the projection keeps the node
|
|
533
|
+
// as in-flight, and resume reruns it with a fresh attempt.
|
|
534
|
+
throw new RunParkedError();
|
|
535
|
+
}
|
|
220
536
|
this.recordAttempt(state, attempt);
|
|
537
|
+
// The terminal node event carries the output, receipt, and conversation
|
|
538
|
+
// linkage so the trace alone is sufficient to reconstruct the run.
|
|
221
539
|
await this.persist(runDir, state, {
|
|
222
540
|
scope: "node",
|
|
223
541
|
type: attempt.result.outcome === "ok" ? "node_finished" : "node_failed",
|
|
@@ -226,7 +544,12 @@ export class WorkflowEngine {
|
|
|
226
544
|
payload: {
|
|
227
545
|
outcome: attempt.result.outcome,
|
|
228
546
|
durationMs: attempt.result.durationMs,
|
|
547
|
+
...(attempt.result.outcome === "ok" ? { output: attempt.result.output ?? null } : {}),
|
|
229
548
|
...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
|
|
549
|
+
...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
|
|
550
|
+
...(attempt.execution?.conversation !== undefined
|
|
551
|
+
? { conversation: attempt.execution.conversation }
|
|
552
|
+
: {}),
|
|
230
553
|
},
|
|
231
554
|
});
|
|
232
555
|
|
|
@@ -259,6 +582,9 @@ export class WorkflowEngine {
|
|
|
259
582
|
* never interrupts a node mid-flight; it only delays the next dispatch.
|
|
260
583
|
*/
|
|
261
584
|
private async holdWhilePaused(state: WorkflowRunState, runDir: string): Promise<void> {
|
|
585
|
+
if (this.parked) {
|
|
586
|
+
throw new RunParkedError();
|
|
587
|
+
}
|
|
262
588
|
if (this.cancelled) {
|
|
263
589
|
throw new CancelledError();
|
|
264
590
|
}
|
|
@@ -267,12 +593,15 @@ export class WorkflowEngine {
|
|
|
267
593
|
}
|
|
268
594
|
state.paused = true;
|
|
269
595
|
await this.persist(runDir, state, { scope: "run", type: "run_paused", payload: {} });
|
|
270
|
-
while (this.paused && !this.cancelled) {
|
|
596
|
+
while (this.paused && !this.cancelled && !this.parked) {
|
|
271
597
|
await new Promise<void>((resolve) => {
|
|
272
598
|
this.wakePause = resolve;
|
|
273
599
|
});
|
|
274
600
|
}
|
|
275
601
|
this.wakePause = null;
|
|
602
|
+
if (this.parked) {
|
|
603
|
+
throw new RunParkedError();
|
|
604
|
+
}
|
|
276
605
|
delete state.paused;
|
|
277
606
|
if (this.cancelled) {
|
|
278
607
|
throw new CancelledError();
|
|
@@ -316,16 +645,18 @@ export class WorkflowEngine {
|
|
|
316
645
|
outcome: attempt.result.outcome,
|
|
317
646
|
startedAt: attempt.result.startedAt,
|
|
318
647
|
finishedAt: attempt.result.finishedAt,
|
|
319
|
-
|
|
648
|
+
prompt: attempt.execution?.promptText ?? null,
|
|
320
649
|
// `undefined` would drop the required field during JSON serialization.
|
|
321
650
|
output: attempt.result.output ?? null,
|
|
322
651
|
...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
|
|
323
652
|
...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
|
|
653
|
+
...(attempt.execution?.conversation !== undefined
|
|
654
|
+
? { conversation: attempt.execution.conversation }
|
|
655
|
+
: {}),
|
|
324
656
|
};
|
|
325
657
|
state.steps.push(step);
|
|
326
658
|
delete state.currentNode;
|
|
327
659
|
delete state.currentAttemptId;
|
|
328
|
-
delete state.currentNodeType;
|
|
329
660
|
delete state.currentNodeStartedAt;
|
|
330
661
|
delete state.statusDetail;
|
|
331
662
|
}
|
|
@@ -341,7 +672,6 @@ export class WorkflowEngine {
|
|
|
341
672
|
const startedAt = new Date().toISOString();
|
|
342
673
|
state.currentNode = nodeId;
|
|
343
674
|
state.currentAttemptId = attemptId;
|
|
344
|
-
state.currentNodeType = node.nodeType;
|
|
345
675
|
state.currentNodeStartedAt = startedAt;
|
|
346
676
|
if (node.statusDetail !== undefined) {
|
|
347
677
|
state.statusDetail = node.statusDetail;
|
|
@@ -431,6 +761,11 @@ export class WorkflowEngine {
|
|
|
431
761
|
const timeoutMs = node.timeoutMs ?? this.defaultNodeTimeoutMs;
|
|
432
762
|
const abort = new AbortController();
|
|
433
763
|
this.activeAbort = abort;
|
|
764
|
+
if (this.parked) {
|
|
765
|
+
// A park that landed during the node_started persist must not let the
|
|
766
|
+
// node dispatch: its discarded side effects would rerun on resume.
|
|
767
|
+
throw new RunParkedError();
|
|
768
|
+
}
|
|
434
769
|
if (this.cancelled) {
|
|
435
770
|
throw new CancelledError();
|
|
436
771
|
}
|
|
@@ -571,7 +906,11 @@ export class WorkflowEngine {
|
|
|
571
906
|
},
|
|
572
907
|
signal,
|
|
573
908
|
);
|
|
574
|
-
return {
|
|
909
|
+
return {
|
|
910
|
+
output: submission.output,
|
|
911
|
+
promptText: prompt,
|
|
912
|
+
...(submission.conversation !== undefined ? { conversation: submission.conversation } : {}),
|
|
913
|
+
};
|
|
575
914
|
}
|
|
576
915
|
|
|
577
916
|
private async acceptSubmission(
|
|
@@ -629,6 +968,13 @@ export class WorkflowEngine {
|
|
|
629
968
|
if (status === "failed" && state.status === "timed_out") {
|
|
630
969
|
status = "timed_out";
|
|
631
970
|
}
|
|
971
|
+
// Let observers (e.g. the session recorder) stop and drain before the
|
|
972
|
+
// terminal event exists, so the bundle is immutable from that point on.
|
|
973
|
+
try {
|
|
974
|
+
await this.onRunFinishing?.(runDir, state);
|
|
975
|
+
} catch {
|
|
976
|
+
// Finishing the run wins over observer failures.
|
|
977
|
+
}
|
|
632
978
|
state.status = status;
|
|
633
979
|
state.finishedAt = new Date().toISOString();
|
|
634
980
|
if (fields.error !== undefined) {
|
|
@@ -642,7 +988,6 @@ export class WorkflowEngine {
|
|
|
642
988
|
}
|
|
643
989
|
delete state.currentNode;
|
|
644
990
|
delete state.currentAttemptId;
|
|
645
|
-
delete state.currentNodeType;
|
|
646
991
|
delete state.currentNodeStartedAt;
|
|
647
992
|
await this.persist(runDir, state, {
|
|
648
993
|
scope: "run",
|
|
@@ -651,6 +996,7 @@ export class WorkflowEngine {
|
|
|
651
996
|
status,
|
|
652
997
|
...(fields.error !== undefined ? { error: fields.error } : {}),
|
|
653
998
|
...(fields.waitingOn !== undefined ? { waitingOn: fields.waitingOn } : {}),
|
|
999
|
+
...(fields.finalOutput !== undefined ? { finalOutput: fields.finalOutput } : {}),
|
|
654
1000
|
},
|
|
655
1001
|
});
|
|
656
1002
|
}
|
|
@@ -771,7 +1117,7 @@ export function appendStepContract(
|
|
|
771
1117
|
`Workflow step contract (workflow: ${workflowName}, step: ${nodeId}, attempt: ${attemptId})`,
|
|
772
1118
|
"",
|
|
773
1119
|
"Complete this step by calling the `workflow` tool exactly once with:",
|
|
774
|
-
`{"step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "output": <your result>}`,
|
|
1120
|
+
`{"action": "submit", "step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "output": <your result>}`,
|
|
775
1121
|
`Expected output: ${expectedOutput ?? "a JSON object with your result"}`,
|
|
776
1122
|
"The step is complete only after the workflow tool accepts the output.",
|
|
777
1123
|
"If the tool reports a validation error, correct the output and call it again.",
|
package/src/workflows/errors.ts
CHANGED
|
@@ -15,6 +15,51 @@ export class CancelledError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Thrown when a runner writes to a run bundle after losing its queue claim.
|
|
20
|
+
* The current claim holder owns the run from that point on, so the fenced
|
|
21
|
+
* writer must stop touching the bundle immediately.
|
|
22
|
+
*/
|
|
23
|
+
export class ClaimLostError extends Error {
|
|
24
|
+
readonly runId: string;
|
|
25
|
+
|
|
26
|
+
constructor(runId: string) {
|
|
27
|
+
super(`Workflow run claim lost: ${runId}`);
|
|
28
|
+
this.name = "ClaimLostError";
|
|
29
|
+
this.runId = runId;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Internal stop signal for close-to-park: the engine halts without writing
|
|
35
|
+
* a terminal event, leaving a resumable bundle for the next claim holder.
|
|
36
|
+
*/
|
|
37
|
+
export class RunParkedError extends Error {
|
|
38
|
+
constructor() {
|
|
39
|
+
super("Workflow run was parked");
|
|
40
|
+
this.name = "RunParkedError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isRunParkedError(error: unknown): error is RunParkedError {
|
|
45
|
+
return error instanceof RunParkedError;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The workflow source changed after the run started; resume needs force. */
|
|
49
|
+
export class WorkflowSourceChangedError extends Error {
|
|
50
|
+
readonly runId: string;
|
|
51
|
+
|
|
52
|
+
constructor(runId: string) {
|
|
53
|
+
super(`Workflow source changed since run ${runId} started; pass force to resume anyway`);
|
|
54
|
+
this.name = "WorkflowSourceChangedError";
|
|
55
|
+
this.runId = runId;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isClaimLostError(error: unknown): error is ClaimLostError {
|
|
60
|
+
return error instanceof ClaimLostError;
|
|
61
|
+
}
|
|
62
|
+
|
|
18
63
|
export function isAbortLikeError(error: unknown): boolean {
|
|
19
64
|
return error instanceof CancelledError || (error instanceof Error && error.name === "AbortError");
|
|
20
65
|
}
|
package/src/workflows/graph.ts
CHANGED
|
@@ -58,11 +58,6 @@ function validateWorkflowEdge(
|
|
|
58
58
|
outgoingEdges: Set<string>,
|
|
59
59
|
): void {
|
|
60
60
|
assertKnownNode(workflow, edge.from, "Workflow edge references unknown from-node");
|
|
61
|
-
if (workflow.nodes[edge.from]?.nodeType === "checkpoint") {
|
|
62
|
-
// A checkpoint terminates the run as `waiting` and nothing resumes it,
|
|
63
|
-
// so an outgoing edge would make its targets silently unreachable.
|
|
64
|
-
throw new Error(`Workflow checkpoint node must not declare an outgoing edge: ${edge.from}`);
|
|
65
|
-
}
|
|
66
61
|
if (outgoingEdges.has(edge.from)) {
|
|
67
62
|
throw new Error(`Workflow node must not declare multiple outgoing edges: ${edge.from}`);
|
|
68
63
|
}
|
package/src/workflows/index.ts
CHANGED
|
@@ -28,9 +28,19 @@ export {
|
|
|
28
28
|
} from "./loader.js";
|
|
29
29
|
export { renderShellCommand, runShellAction } from "./shell.js";
|
|
30
30
|
export { sanitizeText, stripAnsi } from "./text.js";
|
|
31
|
+
export {
|
|
32
|
+
ARTIFACT_THRESHOLD_BYTES,
|
|
33
|
+
ArtifactWriter,
|
|
34
|
+
decodeValueWith,
|
|
35
|
+
encodeValue,
|
|
36
|
+
isArtifactValue,
|
|
37
|
+
resolveArtifacts,
|
|
38
|
+
} from "./artifacts.js";
|
|
31
39
|
export {
|
|
32
40
|
DEFINITION_SNAPSHOT_SCHEMA,
|
|
33
41
|
RUN_BUNDLE_SCHEMA,
|
|
42
|
+
RUN_STATE_SCHEMA,
|
|
43
|
+
SESSION_BINDING_SCHEMA,
|
|
34
44
|
TRACE_EVENT_SCHEMA,
|
|
35
45
|
WorkflowRunStore,
|
|
36
46
|
createDefinitionSnapshot,
|
|
@@ -47,7 +57,10 @@ export type {
|
|
|
47
57
|
AgentStepRequest,
|
|
48
58
|
AgentStepSubmission,
|
|
49
59
|
ActionNodeDefinition,
|
|
60
|
+
ArtifactRef,
|
|
61
|
+
ArtifactValue,
|
|
50
62
|
CheckpointNodeDefinition,
|
|
63
|
+
ConversationRange,
|
|
51
64
|
ComputeNodeDefinition,
|
|
52
65
|
FunctionActionNodeDefinition,
|
|
53
66
|
MaybePromise,
|
|
@@ -70,6 +83,8 @@ export type {
|
|
|
70
83
|
WorkflowRunResult,
|
|
71
84
|
WorkflowRunState,
|
|
72
85
|
WorkflowRunStatus,
|
|
86
|
+
WorkflowSessionBinding,
|
|
87
|
+
WorkflowSessionEntryRecord,
|
|
73
88
|
WorkflowStepRecord,
|
|
74
89
|
WorkflowTraceEvent,
|
|
75
90
|
WorkflowTraceEventDraft,
|