@osolmaz/pi-workflows 0.2.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.
Files changed (133) hide show
  1. package/README.md +71 -5
  2. package/dist/builtins/monitor.workflow.d.ts +71 -0
  3. package/dist/builtins/monitor.workflow.js +234 -0
  4. package/dist/builtins/monitor.workflow.js.map +1 -0
  5. package/dist/controllers/conditions.d.ts +6 -0
  6. package/dist/controllers/conditions.js +68 -0
  7. package/dist/controllers/conditions.js.map +1 -0
  8. package/dist/controllers/definition.d.ts +6 -0
  9. package/dist/controllers/definition.js +45 -0
  10. package/dist/controllers/definition.js.map +1 -0
  11. package/dist/controllers/effects.d.ts +14 -0
  12. package/dist/controllers/effects.js +104 -0
  13. package/dist/controllers/effects.js.map +1 -0
  14. package/dist/controllers/errors.d.ts +12 -0
  15. package/dist/controllers/errors.js +25 -0
  16. package/dist/controllers/errors.js.map +1 -0
  17. package/dist/controllers/index.d.ts +13 -0
  18. package/dist/controllers/index.js +13 -0
  19. package/dist/controllers/index.js.map +1 -0
  20. package/dist/controllers/json.d.ts +5 -0
  21. package/dist/controllers/json.js +57 -0
  22. package/dist/controllers/json.js.map +1 -0
  23. package/dist/controllers/loader.d.ts +23 -0
  24. package/dist/controllers/loader.js +74 -0
  25. package/dist/controllers/loader.js.map +1 -0
  26. package/dist/controllers/manager.d.ts +58 -0
  27. package/dist/controllers/manager.js +399 -0
  28. package/dist/controllers/manager.js.map +1 -0
  29. package/dist/controllers/results.d.ts +5 -0
  30. package/dist/controllers/results.js +32 -0
  31. package/dist/controllers/results.js.map +1 -0
  32. package/dist/controllers/sqlite.d.ts +212 -0
  33. package/dist/controllers/sqlite.js +1009 -0
  34. package/dist/controllers/sqlite.js.map +1 -0
  35. package/dist/controllers/store.d.ts +112 -0
  36. package/dist/controllers/store.js +32 -0
  37. package/dist/controllers/store.js.map +1 -0
  38. package/dist/controllers/types.d.ts +159 -0
  39. package/dist/controllers/types.js +2 -0
  40. package/dist/controllers/types.js.map +1 -0
  41. package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
  42. package/dist/controllers/workflow-engine-scheduler.js +93 -0
  43. package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
  44. package/dist/controllers/workflows.d.ts +27 -0
  45. package/dist/controllers/workflows.js +109 -0
  46. package/dist/controllers/workflows.js.map +1 -0
  47. package/dist/extension/controller-host.d.ts +47 -0
  48. package/dist/extension/controller-host.js +110 -0
  49. package/dist/extension/controller-host.js.map +1 -0
  50. package/dist/extension/executor.js +1 -1
  51. package/dist/extension/executor.js.map +1 -1
  52. package/dist/extension/index.d.ts +7 -0
  53. package/dist/extension/index.js +1053 -96
  54. package/dist/extension/index.js.map +1 -1
  55. package/dist/extension/recorder.d.ts +6 -0
  56. package/dist/extension/recorder.js +20 -8
  57. package/dist/extension/recorder.js.map +1 -1
  58. package/dist/extension/workflow-tool.d.ts +28 -0
  59. package/dist/extension/workflow-tool.js +33 -0
  60. package/dist/extension/workflow-tool.js.map +1 -0
  61. package/dist/host/processes.d.ts +24 -0
  62. package/dist/host/processes.js +114 -0
  63. package/dist/host/processes.js.map +1 -0
  64. package/dist/host/rpc-bridge.d.ts +9 -0
  65. package/dist/host/rpc-bridge.js +39 -0
  66. package/dist/host/rpc-bridge.js.map +1 -0
  67. package/dist/host/rpc-executor.d.ts +38 -0
  68. package/dist/host/rpc-executor.js +254 -0
  69. package/dist/host/rpc-executor.js.map +1 -0
  70. package/dist/host/runner.d.ts +49 -0
  71. package/dist/host/runner.js +350 -0
  72. package/dist/host/runner.js.map +1 -0
  73. package/dist/viewer/cli.d.ts +7 -3
  74. package/dist/viewer/cli.js +150 -19
  75. package/dist/viewer/cli.js.map +1 -1
  76. package/dist/workflows/engine.d.ts +36 -0
  77. package/dist/workflows/engine.js +244 -14
  78. package/dist/workflows/engine.js.map +1 -1
  79. package/dist/workflows/errors.d.ts +23 -0
  80. package/dist/workflows/errors.js +38 -0
  81. package/dist/workflows/errors.js.map +1 -1
  82. package/dist/workflows/graph.js +0 -5
  83. package/dist/workflows/graph.js.map +1 -1
  84. package/dist/workflows/loader.d.ts +5 -3
  85. package/dist/workflows/loader.js +10 -1
  86. package/dist/workflows/loader.js.map +1 -1
  87. package/dist/workflows/schema.js +1 -1
  88. package/dist/workflows/schema.js.map +1 -1
  89. package/dist/workflows/store.d.ts +50 -6
  90. package/dist/workflows/store.js +446 -51
  91. package/dist/workflows/store.js.map +1 -1
  92. package/dist/workflows/types.d.ts +10 -0
  93. package/docs/CONTROLLERS.md +215 -0
  94. package/docs/development.md +12 -9
  95. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  96. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  97. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  98. package/docs/run-bundles.md +66 -27
  99. package/docs/workflows.md +131 -11
  100. package/examples/controllers/pull-request.controller.ts +215 -0
  101. package/package.json +10 -2
  102. package/src/builtins/monitor.workflow.ts +278 -0
  103. package/src/controllers/conditions.ts +110 -0
  104. package/src/controllers/definition.ts +65 -0
  105. package/src/controllers/effects.ts +123 -0
  106. package/src/controllers/errors.ts +27 -0
  107. package/src/controllers/index.ts +90 -0
  108. package/src/controllers/json.ts +62 -0
  109. package/src/controllers/loader.ts +104 -0
  110. package/src/controllers/manager.ts +533 -0
  111. package/src/controllers/results.ts +46 -0
  112. package/src/controllers/sqlite.ts +1427 -0
  113. package/src/controllers/store.ts +160 -0
  114. package/src/controllers/types.ts +183 -0
  115. package/src/controllers/workflow-engine-scheduler.ts +145 -0
  116. package/src/controllers/workflows.ts +152 -0
  117. package/src/extension/controller-host.ts +163 -0
  118. package/src/extension/executor.ts +1 -1
  119. package/src/extension/index.ts +1243 -117
  120. package/src/extension/recorder.ts +65 -36
  121. package/src/extension/workflow-tool.ts +59 -0
  122. package/src/host/processes.ts +119 -0
  123. package/src/host/rpc-bridge.ts +44 -0
  124. package/src/host/rpc-executor.ts +299 -0
  125. package/src/host/runner.ts +406 -0
  126. package/src/viewer/cli.ts +167 -21
  127. package/src/workflows/engine.ts +327 -13
  128. package/src/workflows/errors.ts +45 -0
  129. package/src/workflows/graph.ts +0 -5
  130. package/src/workflows/loader.ts +13 -3
  131. package/src/workflows/schema.ts +1 -1
  132. package/src/workflows/store.ts +555 -46
  133. package/src/workflows/types.ts +10 -0
@@ -1,10 +1,20 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
- import { CancelledError, errorMessage, isAbortLikeError, TimeoutError } from "./errors.js";
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 { RUN_STATE_SCHEMA, 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,
@@ -70,6 +80,7 @@ export class WorkflowEngine {
70
80
  private readonly onRunFinishing?: WorkflowEngineOptions["onRunFinishing"];
71
81
  private activeAbort: AbortController | null = null;
72
82
  private cancelled = false;
83
+ private parked = false;
73
84
  private paused = false;
74
85
  private wakePause: (() => void) | null = null;
75
86
 
@@ -96,6 +107,17 @@ export class WorkflowEngine {
96
107
  this.wakePause?.();
97
108
  }
98
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
+
99
121
  /**
100
122
  * Request a pause. The current step finishes normally; the engine then
101
123
  * holds before dispatching the next node until `resume` (or `cancel`).
@@ -118,17 +140,27 @@ export class WorkflowEngine {
118
140
  async run(
119
141
  workflow: WorkflowDefinition,
120
142
  input: unknown,
121
- options: { workflowPath?: string } = {},
143
+ options: { workflowPath?: string; workflowHash?: string; runId?: string } = {},
122
144
  ): Promise<WorkflowRunResult> {
123
145
  validateWorkflowDefinition(workflow);
124
146
  // Fail before any bundle exists so bad input cannot leave a partial run
125
147
  // on disk or silently change shape when state.json round-trips.
126
148
  const normalizedInput = input === undefined ? null : input;
127
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
+ }
128
153
  this.cancelled = false;
129
154
  this.paused = false;
155
+ this.parked = false;
130
156
 
131
- const state = await this.createRunState(workflow, normalizedInput, options.workflowPath);
157
+ const state = await this.createRunState(
158
+ workflow,
159
+ normalizedInput,
160
+ options.workflowPath,
161
+ options.workflowHash,
162
+ options.runId,
163
+ );
132
164
  const runDir = await this.store.initializeRunBundle(workflow, state);
133
165
  await this.persist(runDir, state, {
134
166
  scope: "run",
@@ -147,13 +179,273 @@ export class WorkflowEngine {
147
179
  try {
148
180
  await this.executeGraph(workflow, state, runDir);
149
181
  } catch (error) {
150
- const cancelled = this.cancelled || isAbortLikeError(error);
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 {
151
438
  await this.finishRun(runDir, state, cancelled ? "cancelled" : "failed", {
152
439
  error: errorMessage(error),
153
440
  });
154
- return { runDir, state };
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;
155
448
  }
156
- return { runDir, state };
157
449
  }
158
450
 
159
451
  /**
@@ -186,15 +478,18 @@ export class WorkflowEngine {
186
478
  workflow: WorkflowDefinition,
187
479
  input: unknown,
188
480
  workflowPath: string | undefined,
481
+ workflowHash: string | undefined,
482
+ runId: string | undefined,
189
483
  ): Promise<WorkflowRunState> {
190
484
  const now = new Date().toISOString();
191
485
  return {
192
486
  schema: RUN_STATE_SCHEMA,
193
487
  traceSeq: 0,
194
- runId: createRunId(workflow.name),
488
+ runId: runId ?? createRunId(workflow.name),
195
489
  workflowName: workflow.name,
196
490
  ...(await this.resolveTitleBounded(workflow, input)),
197
491
  ...(workflowPath !== undefined ? { workflowPath } : {}),
492
+ ...(workflowHash !== undefined ? { workflowHash } : {}),
198
493
  startedAt: now,
199
494
  updatedAt: now,
200
495
  status: "running",
@@ -209,11 +504,14 @@ export class WorkflowEngine {
209
504
  workflow: WorkflowDefinition,
210
505
  state: WorkflowRunState,
211
506
  runDir: string,
507
+ startNodeId: string | null = workflow.startAt,
508
+ executedStepsBase = 0,
509
+ initialLastOutput?: unknown,
212
510
  ): Promise<void> {
213
511
  const maxSteps = workflow.maxSteps ?? this.maxSteps;
214
- let currentNodeId: string | null = workflow.startAt;
215
- let executedSteps = 0;
216
- let lastOutput: unknown;
512
+ let currentNodeId: string | null = startNodeId;
513
+ let executedSteps = executedStepsBase;
514
+ let lastOutput: unknown = initialLastOutput;
217
515
 
218
516
  while (currentNodeId !== null) {
219
517
  await this.holdWhilePaused(state, runDir);
@@ -230,6 +528,11 @@ export class WorkflowEngine {
230
528
  }
231
529
 
232
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
+ }
233
536
  this.recordAttempt(state, attempt);
234
537
  // The terminal node event carries the output, receipt, and conversation
235
538
  // linkage so the trace alone is sufficient to reconstruct the run.
@@ -279,6 +582,9 @@ export class WorkflowEngine {
279
582
  * never interrupts a node mid-flight; it only delays the next dispatch.
280
583
  */
281
584
  private async holdWhilePaused(state: WorkflowRunState, runDir: string): Promise<void> {
585
+ if (this.parked) {
586
+ throw new RunParkedError();
587
+ }
282
588
  if (this.cancelled) {
283
589
  throw new CancelledError();
284
590
  }
@@ -287,12 +593,15 @@ export class WorkflowEngine {
287
593
  }
288
594
  state.paused = true;
289
595
  await this.persist(runDir, state, { scope: "run", type: "run_paused", payload: {} });
290
- while (this.paused && !this.cancelled) {
596
+ while (this.paused && !this.cancelled && !this.parked) {
291
597
  await new Promise<void>((resolve) => {
292
598
  this.wakePause = resolve;
293
599
  });
294
600
  }
295
601
  this.wakePause = null;
602
+ if (this.parked) {
603
+ throw new RunParkedError();
604
+ }
296
605
  delete state.paused;
297
606
  if (this.cancelled) {
298
607
  throw new CancelledError();
@@ -452,6 +761,11 @@ export class WorkflowEngine {
452
761
  const timeoutMs = node.timeoutMs ?? this.defaultNodeTimeoutMs;
453
762
  const abort = new AbortController();
454
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
+ }
455
769
  if (this.cancelled) {
456
770
  throw new CancelledError();
457
771
  }
@@ -803,7 +1117,7 @@ export function appendStepContract(
803
1117
  `Workflow step contract (workflow: ${workflowName}, step: ${nodeId}, attempt: ${attemptId})`,
804
1118
  "",
805
1119
  "Complete this step by calling the `workflow` tool exactly once with:",
806
- `{"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>}`,
807
1121
  `Expected output: ${expectedOutput ?? "a JSON object with your result"}`,
808
1122
  "The step is complete only after the workflow tool accepts the output.",
809
1123
  "If the tool reports a validation error, correct the output and call it again.",
@@ -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
  }
@@ -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
  }
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -11,7 +12,7 @@ const WORKFLOW_FILE_SUFFIXES = [".workflow.ts", ".workflow.js", ".workflow.mts",
11
12
  export type DiscoveredWorkflow = {
12
13
  name: string;
13
14
  path: string;
14
- source: "project" | "global" | "path";
15
+ source: "project" | "global" | "builtin" | "path";
15
16
  };
16
17
 
17
18
  export type WorkflowSearchPaths = {
@@ -19,17 +20,26 @@ export type WorkflowSearchPaths = {
19
20
  homeDir?: string;
20
21
  };
21
22
 
22
- /** Directories scanned for `*.workflow.ts` files, in precedence order. */
23
+ /** Directories scanned for workflow files, in precedence order. */
23
24
  export function workflowSearchDirs(
24
25
  options: WorkflowSearchPaths,
25
- ): { dir: string; source: "project" | "global" }[] {
26
+ ): { dir: string; source: "project" | "global" | "builtin" }[] {
26
27
  const homeDir = options.homeDir ?? os.homedir();
28
+ const builtinDir = fileURLToPath(new URL("../builtins/", import.meta.url));
27
29
  return [
28
30
  { dir: path.join(options.cwd, ".pi", "workflows"), source: "project" },
29
31
  { dir: path.join(homeDir, ".pi", "agent", "workflows"), source: "global" },
32
+ { dir: builtinDir, source: "builtin" },
30
33
  ];
31
34
  }
32
35
 
36
+ /** SHA-256 of a workflow source file, recorded in run state for resume pinning. */
37
+ export async function hashWorkflowSource(filePath: string): Promise<string> {
38
+ return createHash("sha256")
39
+ .update(await fs.readFile(filePath))
40
+ .digest("hex");
41
+ }
42
+
33
43
  function isWorkflowFile(fileName: string): boolean {
34
44
  return WORKFLOW_FILE_SUFFIXES.some((suffix) => fileName.endsWith(suffix));
35
45
  }
@@ -160,7 +160,7 @@ function assertValidEdgeShape(edge: WorkflowEdge, index: number): void {
160
160
  * Names claimed by `/workflow` subcommands; a workflow with one of these
161
161
  * names could never be started because the keyword wins the argument slot.
162
162
  */
163
- const RESERVED_WORKFLOW_NAMES = new Set(["cancel", "list", "pause", "resume"]);
163
+ const RESERVED_WORKFLOW_NAMES = new Set(["answer", "cancel", "list", "pause", "resume", "status"]);
164
164
 
165
165
  export function assertValidWorkflowDefinitionShape(definition: WorkflowDefinition): void {
166
166
  assertRecord(definition, "workflow");