@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.
Files changed (171) hide show
  1. package/README.md +107 -26
  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.d.ts +14 -1
  51. package/dist/extension/executor.js +12 -2
  52. package/dist/extension/executor.js.map +1 -1
  53. package/dist/extension/index.d.ts +7 -0
  54. package/dist/extension/index.js +1118 -89
  55. package/dist/extension/index.js.map +1 -1
  56. package/dist/extension/recorder.d.ts +91 -0
  57. package/dist/extension/recorder.js +537 -0
  58. package/dist/extension/recorder.js.map +1 -0
  59. package/dist/extension/session-events.d.ts +134 -0
  60. package/dist/extension/session-events.js +60 -0
  61. package/dist/extension/session-events.js.map +1 -0
  62. package/dist/extension/widget.js +25 -24
  63. package/dist/extension/widget.js.map +1 -1
  64. package/dist/extension/workflow-tool.d.ts +28 -0
  65. package/dist/extension/workflow-tool.js +33 -0
  66. package/dist/extension/workflow-tool.js.map +1 -0
  67. package/dist/host/processes.d.ts +24 -0
  68. package/dist/host/processes.js +114 -0
  69. package/dist/host/processes.js.map +1 -0
  70. package/dist/host/rpc-bridge.d.ts +9 -0
  71. package/dist/host/rpc-bridge.js +39 -0
  72. package/dist/host/rpc-bridge.js.map +1 -0
  73. package/dist/host/rpc-executor.d.ts +38 -0
  74. package/dist/host/rpc-executor.js +254 -0
  75. package/dist/host/rpc-executor.js.map +1 -0
  76. package/dist/host/runner.d.ts +49 -0
  77. package/dist/host/runner.js +350 -0
  78. package/dist/host/runner.js.map +1 -0
  79. package/dist/render/canvas.d.ts +1 -1
  80. package/dist/render/canvas.js +5 -0
  81. package/dist/render/canvas.js.map +1 -1
  82. package/dist/render/graph-render.d.ts +5 -0
  83. package/dist/render/graph-render.js +211 -48
  84. package/dist/render/graph-render.js.map +1 -1
  85. package/dist/viewer/cli.d.ts +7 -3
  86. package/dist/viewer/cli.js +150 -19
  87. package/dist/viewer/cli.js.map +1 -1
  88. package/dist/viewer/render.js +19 -3
  89. package/dist/viewer/render.js.map +1 -1
  90. package/dist/viewer/session-reducer.d.ts +45 -0
  91. package/dist/viewer/session-reducer.js +266 -0
  92. package/dist/viewer/session-reducer.js.map +1 -0
  93. package/dist/workflows/artifacts.d.ts +40 -0
  94. package/dist/workflows/artifacts.js +155 -0
  95. package/dist/workflows/artifacts.js.map +1 -0
  96. package/dist/workflows/engine.d.ts +38 -0
  97. package/dist/workflows/engine.js +281 -20
  98. package/dist/workflows/engine.js.map +1 -1
  99. package/dist/workflows/errors.d.ts +23 -0
  100. package/dist/workflows/errors.js +38 -0
  101. package/dist/workflows/errors.js.map +1 -1
  102. package/dist/workflows/graph.js +0 -5
  103. package/dist/workflows/graph.js.map +1 -1
  104. package/dist/workflows/index.d.ts +3 -2
  105. package/dist/workflows/index.js +2 -1
  106. package/dist/workflows/index.js.map +1 -1
  107. package/dist/workflows/loader.d.ts +5 -3
  108. package/dist/workflows/loader.js +10 -1
  109. package/dist/workflows/loader.js.map +1 -1
  110. package/dist/workflows/schema.js +1 -1
  111. package/dist/workflows/schema.js.map +1 -1
  112. package/dist/workflows/store.d.ts +98 -10
  113. package/dist/workflows/store.js +921 -46
  114. package/dist/workflows/store.js.map +1 -1
  115. package/dist/workflows/types.d.ts +136 -3
  116. package/docs/CONTROLLERS.md +215 -0
  117. package/docs/development.md +51 -24
  118. package/docs/live-replay-protocol.md +155 -0
  119. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  120. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  121. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  122. package/docs/plans/piw-viewer-experience-implementation-plan.md +674 -0
  123. package/docs/plans/replayable-run-bundles-implementation-plan.md +65 -0
  124. package/docs/plans/session-event-replay-implementation-plan.md +494 -0
  125. package/docs/plans/tui-viewer-implementation-plan.md +64 -0
  126. package/docs/run-bundles.md +359 -55
  127. package/docs/session-event-journal.md +470 -0
  128. package/docs/tui-viewer.md +218 -0
  129. package/docs/workflows.md +131 -11
  130. package/examples/controllers/pull-request.controller.ts +215 -0
  131. package/package.json +11 -2
  132. package/src/builtins/monitor.workflow.ts +278 -0
  133. package/src/controllers/conditions.ts +110 -0
  134. package/src/controllers/definition.ts +65 -0
  135. package/src/controllers/effects.ts +123 -0
  136. package/src/controllers/errors.ts +27 -0
  137. package/src/controllers/index.ts +90 -0
  138. package/src/controllers/json.ts +62 -0
  139. package/src/controllers/loader.ts +104 -0
  140. package/src/controllers/manager.ts +533 -0
  141. package/src/controllers/results.ts +46 -0
  142. package/src/controllers/sqlite.ts +1427 -0
  143. package/src/controllers/store.ts +160 -0
  144. package/src/controllers/types.ts +183 -0
  145. package/src/controllers/workflow-engine-scheduler.ts +145 -0
  146. package/src/controllers/workflows.ts +152 -0
  147. package/src/extension/controller-host.ts +163 -0
  148. package/src/extension/executor.ts +29 -2
  149. package/src/extension/index.ts +1315 -109
  150. package/src/extension/recorder.ts +662 -0
  151. package/src/extension/session-events.ts +119 -0
  152. package/src/extension/widget.ts +26 -24
  153. package/src/extension/workflow-tool.ts +59 -0
  154. package/src/host/processes.ts +119 -0
  155. package/src/host/rpc-bridge.ts +44 -0
  156. package/src/host/rpc-executor.ts +299 -0
  157. package/src/host/runner.ts +406 -0
  158. package/src/render/canvas.ts +19 -1
  159. package/src/render/graph-render.ts +277 -44
  160. package/src/viewer/cli.ts +167 -21
  161. package/src/viewer/render.ts +21 -3
  162. package/src/viewer/session-reducer.ts +347 -0
  163. package/src/workflows/artifacts.ts +188 -0
  164. package/src/workflows/engine.ts +365 -19
  165. package/src/workflows/errors.ts +45 -0
  166. package/src/workflows/graph.ts +0 -5
  167. package/src/workflows/index.ts +15 -0
  168. package/src/workflows/loader.ts +13 -3
  169. package/src/workflows/schema.ts +1 -1
  170. package/src/workflows/store.ts +1157 -48
  171. package/src/workflows/types.ts +151 -3
@@ -1,15 +1,25 @@
1
- import { Type } from "typebox";
1
+ import { randomUUID } from "node:crypto";
2
+ import { projectControllerStorePath, SqliteControllerStore, } from "../controllers/index.js";
2
3
  import { WorkflowEngine } from "../workflows/engine.js";
3
- import { errorMessage } from "../workflows/errors.js";
4
- import { discoverWorkflows, loadWorkflowFile, resolveWorkflowRef } from "../workflows/loader.js";
5
- import { createDefinitionSnapshot } from "../workflows/store.js";
4
+ import { ClaimLostError, errorMessage, isClaimLostError } from "../workflows/errors.js";
5
+ import { discoverWorkflows, hashWorkflowSource, loadWorkflowFile, resolveWorkflowRef, } from "../workflows/loader.js";
6
+ import { createRunId, listRunBundles, readLastTraceEvent, readRunBundle, WorkflowRunStore, createDefinitionSnapshot, } from "../workflows/store.js";
7
+ import { PiControllerHost, parseControllerArgs, } from "./controller-host.js";
6
8
  import { ConversationStepExecutor } from "./executor.js";
9
+ import { SessionRecorder } from "./recorder.js";
7
10
  import { buildWidgetView } from "./widget.js";
11
+ import { WorkflowToolParameters } from "./workflow-tool.js";
12
+ const RUN_CLAIM_LEASE_MS = 30_000;
13
+ const RUN_CLAIM_RENEW_MS = 10_000;
14
+ const RUN_SYNC_POLL_MS = 3_000;
8
15
  const WIDGET_KEY = "pi-workflows";
9
16
  const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
10
17
  const FINAL_WIDGET_TTL_MS = 60_000;
11
18
  const WIDGET_SCROLL_STEP = 3;
12
19
  const MAX_PRESENTATION_RESULT_CHARS = 50_000;
20
+ const MAX_STATUS_ERROR_CHARS = 4_000;
21
+ const MAX_WORKFLOW_LIST_ITEMS = 50;
22
+ const MAX_WORKFLOW_LIST_NAME_CHARS = 3_500;
13
23
  const PRESENTATION_TIMEOUT_MS = 30_000;
14
24
  class PresentationSupersededError extends Error {
15
25
  }
@@ -24,6 +34,49 @@ export function parseWorkflowArgs(args) {
24
34
  if (trimmed === "cancel" || trimmed === "pause" || trimmed === "resume") {
25
35
  return { kind: trimmed };
26
36
  }
37
+ if (trimmed === "status") {
38
+ return { kind: "status" };
39
+ }
40
+ if (trimmed.startsWith("status ")) {
41
+ const runId = trimmed.slice("status".length).trim();
42
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
43
+ throw new Error("status requires one valid run id");
44
+ }
45
+ return { kind: "status", runId };
46
+ }
47
+ if (trimmed === "answer" || trimmed.startsWith("answer ")) {
48
+ let rest = trimmed === "answer" ? "" : trimmed.slice("answer".length).trim();
49
+ if (rest.length === 0) {
50
+ throw new Error('answer requires a JSON value or text, e.g. /workflow answer {"approved":true}');
51
+ }
52
+ // `/workflow answer <run-id> <json>` targets a specific waiting run,
53
+ // for example after a restart or for host-driven runs.
54
+ let runId;
55
+ const firstSpace = rest.search(/\s/);
56
+ if (firstSpace > 0) {
57
+ const candidate = rest.slice(0, firstSpace);
58
+ const remainder = rest.slice(firstSpace).trim();
59
+ if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(candidate) &&
60
+ remainder.length > 0 &&
61
+ (remainder.startsWith("{") || remainder.startsWith("["))) {
62
+ runId = candidate;
63
+ rest = remainder;
64
+ }
65
+ }
66
+ try {
67
+ return {
68
+ kind: "answer",
69
+ input: JSON.parse(rest),
70
+ ...(runId !== undefined ? { runId } : {}),
71
+ };
72
+ }
73
+ catch {
74
+ if (runId !== undefined) {
75
+ throw new Error("answer with a run id requires a JSON value");
76
+ }
77
+ return { kind: "answer", input: { answer: rest } };
78
+ }
79
+ }
27
80
  const spaceIndex = trimmed.search(/\s/);
28
81
  const ref = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex);
29
82
  const rest = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex).trim();
@@ -39,8 +92,146 @@ export function parseWorkflowArgs(args) {
39
92
  }
40
93
  return { kind: "run", ref, input: rest.length > 0 ? { task: rest } : {} };
41
94
  }
95
+ function workflowStateSummary(state) {
96
+ const error = state.error === undefined
97
+ ? undefined
98
+ : state.error.length <= MAX_STATUS_ERROR_CHARS
99
+ ? state.error
100
+ : `${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}\n… [error truncated]`;
101
+ return {
102
+ active: state.status === "running",
103
+ runId: state.runId,
104
+ workflowName: state.workflowName,
105
+ status: state.status,
106
+ steps: state.steps.length,
107
+ ...(state.currentNode !== undefined ? { currentNode: state.currentNode } : {}),
108
+ ...(state.waitingOn !== undefined ? { waitingOn: state.waitingOn } : {}),
109
+ ...(state.startedAt !== undefined ? { startedAt: state.startedAt } : {}),
110
+ ...(state.finishedAt !== undefined ? { finishedAt: state.finishedAt } : {}),
111
+ ...(error !== undefined ? { error } : {}),
112
+ };
113
+ }
42
114
  export default function piWorkflows(pi) {
115
+ // One runner identity per session; it names this session in run claims.
116
+ const runnerId = randomUUID();
117
+ let runQueueStore = null;
118
+ const ensureRunQueueStore = (cwd) => {
119
+ runQueueStore ??= new SqliteControllerStore(projectControllerStorePath(cwd));
120
+ return runQueueStore;
121
+ };
122
+ // Session sync: a per-session watermark over the run event feed keeps this
123
+ // session's context current with runs other runners drove.
124
+ // The sync watermark is project-scoped: a reopened session catches up
125
+ // where the last one stopped, and two open sessions share one pointer.
126
+ const SYNC_WATERMARK_KEY = "project";
127
+ let syncArmed = false;
128
+ let runSyncTimer = null;
129
+ const recordRunEvent = (event) => {
130
+ try {
131
+ runQueueStore?.recordRunEvent({ ...event, runnerId });
132
+ }
133
+ catch {
134
+ // The event feed is best-effort; never fail a run for it.
135
+ }
136
+ };
137
+ const describeRunEvent = (event) => {
138
+ const label = `${event.workflowRef} run ${event.runId}`;
139
+ switch (event.type) {
140
+ case "waiting": {
141
+ const waitingOn = typeof event.payload.waitingOn === "string" ? event.payload.waitingOn : "a checkpoint";
142
+ return `${label} waits at checkpoint ${waitingOn} — answer with /workflow answer`;
143
+ }
144
+ case "parked":
145
+ return `${label} was parked and will resume when a runner is available`;
146
+ case "failed": {
147
+ const detail = typeof event.payload.error === "string" ? `: ${event.payload.error}` : "";
148
+ return `${label} failed${detail}`;
149
+ }
150
+ default:
151
+ return `${label} ${event.type}`;
152
+ }
153
+ };
154
+ const runSyncPass = async (ctx) => {
155
+ if (runQueueStore === null || !syncArmed) {
156
+ return;
157
+ }
158
+ try {
159
+ const watermark = runQueueStore.getSessionWatermark(SYNC_WATERMARK_KEY);
160
+ if (watermark === 0) {
161
+ // First sync ever for this project: never replay the feed (stale
162
+ // "waits at checkpoint" lines included). Fast-forward, then catch
163
+ // up from current state instead — what is parked, resuming, or
164
+ // waiting for an answer right now.
165
+ const latest = runQueueStore.latestRunEventSeq();
166
+ if (latest > 0) {
167
+ runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, latest);
168
+ }
169
+ await sendStateSnapshot(ctx);
170
+ return;
171
+ }
172
+ const events = runQueueStore.listRunEventsAfter(watermark, { limit: 20 });
173
+ if (events.length === 0) {
174
+ return;
175
+ }
176
+ // Persist the watermark first. A crash after this point skips the
177
+ // message, but snapshots recompute from the store, so no information
178
+ // stays lost; a duplicated state line is the worst outcome.
179
+ runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, events[events.length - 1]?.seq ?? 0);
180
+ const noteworthy = events.filter((event) => event.runnerId !== runnerId &&
181
+ ["completed", "failed", "timed_out", "cancelled", "waiting", "parked"].includes(event.type));
182
+ if (noteworthy.length === 0) {
183
+ return;
184
+ }
185
+ const content = `Workflow run update:\n${noteworthy.map(describeRunEvent).join("\n")}`;
186
+ pi.sendMessage({ customType: "pi-workflows-run-sync", content, display: false }, { deliverAs: "steer", triggerTurn: false });
187
+ notify(ctx, noteworthy.map(describeRunEvent).join("; "));
188
+ }
189
+ catch {
190
+ // Sync is observational.
191
+ }
192
+ };
193
+ // The first-use catch-up: a snapshot of runs that need attention now.
194
+ const sendStateSnapshot = async (ctx) => {
195
+ if (runQueueStore === null) {
196
+ return;
197
+ }
198
+ const lines = [];
199
+ const rows = runQueueStore.listWorkflowRuns();
200
+ for (const row of rows) {
201
+ if (row.status === "parked") {
202
+ lines.push(`${row.workflowRef} run ${row.runId} is parked and will resume`);
203
+ }
204
+ }
205
+ const known = new Set(rows.map((row) => row.runId));
206
+ const continued = new Set(rows.map((row) => row.parentRunId).filter((parent) => parent !== null));
207
+ const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
208
+ for (const bundle of bundles) {
209
+ if (bundle.state.status === "waiting" &&
210
+ known.has(bundle.state.runId) &&
211
+ !continued.has(bundle.state.runId)) {
212
+ lines.push(`${bundle.state.workflowName} run ${bundle.state.runId} waits at checkpoint ${bundle.state.waitingOn ?? "?"} — answer with /workflow answer`);
213
+ }
214
+ }
215
+ if (lines.length === 0) {
216
+ return;
217
+ }
218
+ const content = `Workflow runs needing attention:\n${lines.join("\n")}`;
219
+ pi.sendMessage({ customType: "pi-workflows-run-sync", content, display: false }, { deliverAs: "steer", triggerTurn: false });
220
+ notify(ctx, lines.join("; "));
221
+ };
222
+ const startRunSync = (ctx) => {
223
+ if (runSyncTimer !== null) {
224
+ return;
225
+ }
226
+ ensureRunQueueStore(ctx.cwd);
227
+ void runSyncPass(ctx);
228
+ runSyncTimer = setInterval(() => void runSyncPass(ctx), RUN_SYNC_POLL_MS);
229
+ runSyncTimer.unref?.();
230
+ };
43
231
  let activeRun = null;
232
+ let pendingToolLaunch = null;
233
+ // The interactive run currently parked at a checkpoint, if any.
234
+ let lastWaitingRunId = null;
44
235
  let widgetTimer = null;
45
236
  let widgetTicker = null;
46
237
  // Manual widget scroll: null follows the active node; a number is the
@@ -54,6 +245,8 @@ export default function piWorkflows(pi) {
54
245
  let runGeneration = 0;
55
246
  let presentationAbort = null;
56
247
  let presentationPending = null;
248
+ let controllerHost;
249
+ let controllerContext = null;
57
250
  // UI updates are best-effort: a captured ctx becomes stale after session
58
251
  // replacement or shutdown, and pi throws on any access (even `ctx.hasUI`).
59
252
  // A workflow finishing right as the session goes away must not crash pi.
@@ -210,10 +403,61 @@ export default function piWorkflows(pi) {
210
403
  }
211
404
  }
212
405
  };
213
- const finishRun = (ctx, run, result) => {
406
+ // Release the queue claim exactly once. "done" marks the queue row
407
+ // terminal, "park" leaves it claimable for another runner, and "lost"
408
+ // leaves it for the new claim holder.
409
+ const releaseClaim = (run, outcome) => {
410
+ if (run.renewTimer !== undefined) {
411
+ clearInterval(run.renewTimer);
412
+ run.renewTimer = undefined;
413
+ }
414
+ if (run.claimToken === undefined || runQueueStore === null || outcome === "lost") {
415
+ return;
416
+ }
417
+ try {
418
+ if (outcome === "park") {
419
+ runQueueStore.parkWorkflowRun({ runId: run.runId, claimToken: run.claimToken });
420
+ }
421
+ else {
422
+ runQueueStore.completeWorkflowRun({ runId: run.runId, claimToken: run.claimToken });
423
+ }
424
+ }
425
+ catch {
426
+ // Queue bookkeeping is best-effort next to the durable bundle.
427
+ }
428
+ finally {
429
+ run.claimToken = undefined;
430
+ }
431
+ };
432
+ const finishRun = async (ctx, run, result) => {
214
433
  if (activeRun === run) {
215
434
  activeRun = null;
216
435
  }
436
+ if (result.state.status === "running") {
437
+ // The run was parked for another runner; its bundle stays resumable
438
+ // and its queue row becomes claimable. The recorder drains first: its
439
+ // finalization writes share the fenced store, so the claim must stay
440
+ // valid until capture is complete. Nothing else to present.
441
+ await run.recorder?.stop().catch(() => undefined);
442
+ releaseClaim(run, "park");
443
+ recordRunEvent({ runId: run.runId, workflowRef: run.workflowName, type: "parked" });
444
+ stopWidgetTicker();
445
+ clearWidget(ctx);
446
+ return;
447
+ }
448
+ releaseClaim(run, "done");
449
+ recordRunEvent({
450
+ runId: run.runId,
451
+ workflowRef: run.workflowName,
452
+ type: result.state.status,
453
+ payload: {
454
+ ...(result.state.error !== undefined ? { error: result.state.error } : {}),
455
+ ...(result.state.waitingOn !== undefined ? { waitingOn: result.state.waitingOn } : {}),
456
+ },
457
+ });
458
+ // Normally already stopped via onRunFinishing; this covers observers of
459
+ // runs that ended without reaching that hook.
460
+ void run.recorder?.stop();
217
461
  stopWidgetTicker();
218
462
  const { state } = result;
219
463
  updateWidget(ctx, state, run.snapshot);
@@ -224,85 +468,669 @@ export default function piWorkflows(pi) {
224
468
  widgetTimer = setTimeout(() => clearWidget(ctx), FINAL_WIDGET_TTL_MS);
225
469
  widgetTimer.unref?.();
226
470
  }
471
+ if (state.status === "waiting" && run.childKey === undefined) {
472
+ lastWaitingRunId = run.runId;
473
+ }
227
474
  const summary = state.status === "waiting" && state.waitingOn
228
- ? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — run ended, awaiting your decision (run ${state.runId})`
475
+ ? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
229
476
  : `Workflow ${state.workflowName} ${state.status} (run ${state.runId})`;
230
477
  notify(ctx, summary, state.status === "completed" ? "info" : "warning");
478
+ try {
479
+ const childResult = run.childKey !== undefined &&
480
+ (sessionClosed || run.interruptionRequested === true) &&
481
+ state.status === "cancelled"
482
+ ? {
483
+ state: "interrupted",
484
+ runId: state.runId,
485
+ ...(state.error !== undefined ? { error: state.error } : {}),
486
+ }
487
+ : workflowSchedulerResult(state);
488
+ run.onFinish?.(childResult);
489
+ }
490
+ catch (error) {
491
+ notify(ctx, `Could not record child workflow completion: ${errorMessage(error)}`, "warning");
492
+ }
231
493
  void presentRun(ctx, run, state);
232
494
  };
233
- const startRun = async (ctx, ref, input) => {
495
+ const startRun = async (ctx, ref, input, options = {}) => {
496
+ if (options.signal?.aborted) {
497
+ throw options.signal.reason ?? new Error("Workflow startup aborted");
498
+ }
234
499
  if (activeRun) {
235
- notify(ctx, `A workflow is already running: ${activeRun.workflowName}. Use /workflow cancel first.`, "error");
236
- return;
500
+ if (!options.quiet) {
501
+ notify(ctx, `A workflow is already running: ${activeRun.workflowName}. Use /workflow cancel first.`, "error");
502
+ }
503
+ return undefined;
237
504
  }
238
505
  if (presentationPending !== null) {
239
- notify(ctx, "The previous workflow result is still being presented. Wait for it to finish.");
240
- return;
506
+ if (!options.quiet) {
507
+ notify(ctx, "The previous workflow result is still being presented. Wait for it to finish.");
508
+ }
509
+ return undefined;
241
510
  }
242
511
  supersedePresentation();
243
512
  const generation = runGeneration;
244
513
  const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
245
514
  const workflow = await loadWorkflowFile(resolved.path);
515
+ if (options.signal?.aborted) {
516
+ throw options.signal.reason ?? new Error("Workflow startup aborted");
517
+ }
246
518
  const snapshot = createDefinitionSnapshot(workflow);
519
+ const workflowHash = await hashWorkflowSource(resolved.path);
520
+ const runId = options.runId ?? createRunId(workflow.name);
521
+ // Continuations validate the parent before touching the queue: a
522
+ // refused continuation (edited source, missing parent) must not consume
523
+ // the parent's one-continuation slot.
524
+ if (options.parentRunId !== undefined) {
525
+ const parent = await readRunBundle(new WorkflowRunStore().runDirFor(options.parentRunId));
526
+ if (parent === null || parent.state.status !== "waiting") {
527
+ throw new Error(`Workflow run ${options.parentRunId} is not waiting at a checkpoint`);
528
+ }
529
+ if (parent.state.workflowHash !== undefined && parent.state.workflowHash !== workflowHash) {
530
+ throw new Error(`Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`);
531
+ }
532
+ }
533
+ // Interactive runs are queued and claimed atomically, so this session
534
+ // owns the run from birth (origin affinity). Controller child runs keep
535
+ // their own scheduling and stay out of the run queue. A resume caller
536
+ // arrives with its claim already taken.
537
+ let claimToken = options.claimToken;
538
+ let queueStore = null;
539
+ if (options.childKey === undefined) {
540
+ queueStore = ensureRunQueueStore(ctx.cwd);
541
+ if (claimToken === undefined) {
542
+ const token = randomUUID();
543
+ queueStore.enqueueWorkflowRun({
544
+ runId,
545
+ workflowRef: ref,
546
+ workflowPath: resolved.path,
547
+ input,
548
+ runnerId,
549
+ claimToken: token,
550
+ leaseMs: RUN_CLAIM_LEASE_MS,
551
+ ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
552
+ });
553
+ claimToken = token;
554
+ recordRunEvent({
555
+ runId,
556
+ workflowRef: ref,
557
+ type: "queued",
558
+ payload: options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {},
559
+ });
560
+ }
561
+ }
562
+ let fence;
563
+ if (queueStore !== null && claimToken !== undefined) {
564
+ const claimedStore = queueStore;
565
+ const token = claimToken;
566
+ fence = () => {
567
+ if (!claimedStore.verifyWorkflowRunClaim({ runId, claimToken: token })) {
568
+ throw new ClaimLostError(runId);
569
+ }
570
+ };
571
+ }
247
572
  const executor = new ConversationStepExecutor({
248
573
  sendPrompt: ({ prompt, streaming }) => {
249
574
  pi.sendUserMessage(prompt, streaming ? { deliverAs: "steer" } : undefined);
250
575
  },
576
+ conversation: {
577
+ beginAttempt: (contract) => run.recorder?.beginAttempt(contract),
578
+ mark: () => run.recorder?.mark() ?? 0,
579
+ rangeSince: (mark) => run.recorder?.rangeSince(mark),
580
+ },
251
581
  });
582
+ // The store is shared between the engine and the session recorder so the
583
+ // trace sequence stays single-writer (see docs/run-bundles.md). Queued
584
+ // runs are fenced: every write proves the claim is still ours first.
585
+ const store = new WorkflowRunStore(undefined, fence === undefined ? {} : { fenceProvider: () => fence });
252
586
  const engine = new WorkflowEngine({
253
587
  executor,
254
- onEvent: (_event, state) => {
255
- if (run.runId === null) {
256
- run.runId = state.runId;
588
+ store,
589
+ // Awaited by the engine after run_started is persisted, so the session
590
+ // binding and its trace event always precede node and terminal events.
591
+ onRunStarted: async (runDir, state) => {
592
+ const recorder = new SessionRecorder(store, runDir, state.runId);
593
+ try {
594
+ await recorder.bind(ctx);
595
+ run.recorder = recorder;
596
+ }
597
+ catch {
598
+ // Binding is best-effort: a session without UI access or an
599
+ // ephemeral context must not fail the run.
257
600
  }
601
+ },
602
+ // Awaited by the engine before the terminal snapshot. If completion was
603
+ // submitted from the workflow tool, capture stays open through Pi's
604
+ // final tool, message, and turn hooks before it drains.
605
+ onRunFinishing: async () => {
606
+ await run.recorder?.finish();
607
+ },
608
+ onEvent: (_event, state) => {
258
609
  run.lastState = state;
259
610
  updateWidget(ctx, state, snapshot);
260
611
  },
261
612
  });
262
613
  const run = {
263
- runId: null,
614
+ runId,
264
615
  workflowName: workflow.name,
265
616
  engine,
266
617
  executor,
618
+ recorder: null,
267
619
  snapshot,
268
- presentationPrompt: workflow.presentationPrompt,
620
+ presentationPrompt: options.presentation === false ? undefined : workflow.presentationPrompt,
269
621
  generation,
270
622
  lastState: null,
623
+ ...(options.childKey !== undefined ? { childKey: options.childKey } : {}),
624
+ ...(options.onFinish !== undefined ? { onFinish: options.onFinish } : {}),
625
+ ...(claimToken !== undefined ? { claimToken } : {}),
626
+ ...(options.resume === true ? { resume: true } : {}),
627
+ ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
271
628
  };
272
629
  activeRun = run;
630
+ if (queueStore !== null && claimToken !== undefined) {
631
+ const store = queueStore;
632
+ const token = claimToken;
633
+ // Renew the claim while the run executes. If renewal says the claim is
634
+ // gone, cancel so the fence trips on the next write instead of letting
635
+ // this runner interleave with the new claim holder.
636
+ run.renewTimer = setInterval(() => {
637
+ try {
638
+ if (!store.renewWorkflowRunClaim({ runId, claimToken: token, leaseMs: RUN_CLAIM_LEASE_MS })) {
639
+ run.engine.cancel();
640
+ }
641
+ }
642
+ catch {
643
+ // Transient store errors leave fencing to decide ownership.
644
+ }
645
+ }, RUN_CLAIM_RENEW_MS);
646
+ run.renewTimer.unref?.();
647
+ }
273
648
  clearWidgetTimer();
274
649
  startWidgetTicker(ctx, run);
275
- notify(ctx, `Workflow ${workflow.name} started. Follow it live with: pi-workflows view`);
276
- engine
277
- .run(workflow, input, { workflowPath: resolved.path })
650
+ if (!options.quiet) {
651
+ notify(ctx, `Workflow ${workflow.name} started. Follow it live with: pi-workflows view`);
652
+ }
653
+ run.completion = (options.resume === true
654
+ ? engine.resumeRun(workflow, runId, { workflowHash })
655
+ : options.parentRunId === undefined
656
+ ? engine.run(workflow, input, { workflowPath: resolved.path, workflowHash, runId })
657
+ : engine.continueRun(workflow, options.parentRunId, input, {
658
+ workflowPath: resolved.path,
659
+ workflowHash,
660
+ runId,
661
+ }))
278
662
  .then((result) => finishRun(ctx, run, result))
279
- .catch((error) => {
663
+ .catch(async (error) => {
280
664
  if (activeRun === run) {
281
665
  activeRun = null;
282
666
  }
667
+ const message = errorMessage(error);
668
+ // A resume that fails before executing (for example edited workflow
669
+ // source) must not strand the run: park it so it stays claimable
670
+ // once the cause is fixed, and say so in the feed.
671
+ let resumeFailed = run.resume === true && !isClaimLostError(error);
672
+ let terminalStatus;
673
+ // Re-parking only makes sense while the bundle is still resumable.
674
+ // A terminal or waiting bundle closes the queue row instead, or
675
+ // every session start would retry it forever — and the feed reports
676
+ // the bundle's real state, not a bogus failure.
677
+ if (resumeFailed) {
678
+ try {
679
+ const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
680
+ if (bundle === null || bundle.state.status !== "running") {
681
+ resumeFailed = false;
682
+ terminalStatus = bundle?.state.status;
683
+ }
684
+ }
685
+ catch {
686
+ // Unreadable bundles close the row too.
687
+ resumeFailed = false;
688
+ }
689
+ }
690
+ // A continuation that failed before its bundle exists frees the
691
+ // parent's continuation slot instead of consuming it forever.
692
+ let continuationSlotFreed = false;
693
+ if (run.parentRunId !== undefined &&
694
+ run.claimToken !== undefined &&
695
+ !isClaimLostError(error)) {
696
+ try {
697
+ const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
698
+ if (bundle === null) {
699
+ continuationSlotFreed =
700
+ runQueueStore?.deleteWorkflowRun({ runId, claimToken: run.claimToken }) === true;
701
+ if (continuationSlotFreed) {
702
+ run.claimToken = undefined;
703
+ }
704
+ }
705
+ }
706
+ catch {
707
+ // The row may remain; a later cleanup pass can remove it.
708
+ }
709
+ }
710
+ releaseClaim(run, isClaimLostError(error) || continuationSlotFreed
711
+ ? "lost"
712
+ : resumeFailed
713
+ ? "park"
714
+ : "done");
715
+ void run.recorder?.stop();
283
716
  stopWidgetTicker();
284
717
  clearWidget(ctx);
285
- notify(ctx, `Workflow ${workflow.name} crashed: ${errorMessage(error)}`, "error");
718
+ if (isClaimLostError(error)) {
719
+ notify(ctx, `Workflow ${workflow.name} continues under another runner (run ${runId}).`);
720
+ return;
721
+ }
722
+ if (resumeFailed) {
723
+ recordRunEvent({ runId, workflowRef: workflow.name, type: "parked", payload: {} });
724
+ notify(ctx, `Could not resume workflow ${workflow.name}: ${message}. The run is parked again.`, "warning");
725
+ return;
726
+ }
727
+ if (terminalStatus !== undefined) {
728
+ recordRunEvent({ runId, workflowRef: workflow.name, type: terminalStatus, payload: {} });
729
+ notify(ctx, `Workflow ${workflow.name} ${terminalStatus} (run ${runId}).`);
730
+ return;
731
+ }
732
+ recordRunEvent({
733
+ runId,
734
+ workflowRef: workflow.name,
735
+ type: "failed",
736
+ payload: { error: message },
737
+ });
738
+ try {
739
+ run.onFinish?.({ state: "failed", runId, error: message });
740
+ }
741
+ catch {
742
+ // The original workflow failure remains the primary error.
743
+ }
744
+ notify(ctx, `Workflow ${workflow.name} crashed: ${message}`, "error");
286
745
  });
746
+ return runId;
287
747
  };
288
- const listWorkflows = async (ctx) => {
748
+ // Reclaim and resume a parked run when this session opens without an
749
+ // active run. The claim comes first; the engine resumes at the stopped
750
+ // node only after the queue proves ownership.
751
+ const resumeParkedRun = async (ctx) => {
752
+ if (activeRun !== null) {
753
+ return;
754
+ }
755
+ const queueStore = ensureRunQueueStore(ctx.cwd);
756
+ const claimToken = randomUUID();
757
+ const claimed = queueStore.claimNextWorkflowRun({
758
+ runnerId,
759
+ claimToken,
760
+ leaseMs: RUN_CLAIM_LEASE_MS,
761
+ });
762
+ if (claimed === undefined) {
763
+ return;
764
+ }
765
+ let started;
766
+ try {
767
+ started = await startRun(ctx, claimed.workflowPath, claimed.input, {
768
+ resume: true,
769
+ runId: claimed.runId,
770
+ claimToken,
771
+ });
772
+ }
773
+ catch (error) {
774
+ queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
775
+ throw error;
776
+ }
777
+ if (started !== undefined) {
778
+ notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.workflowRef}).`);
779
+ }
780
+ else {
781
+ queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
782
+ }
783
+ };
784
+ const startChild = async (request, signal, onComplete) => {
785
+ if (signal.aborted) {
786
+ throw signal.reason ?? new Error("Child workflow scheduling aborted");
787
+ }
788
+ const childKey = `${request.requestId}:${request.attempt}`;
789
+ if (activeRun !== null) {
790
+ if (activeRun.childKey !== childKey) {
791
+ return { state: "pending" };
792
+ }
793
+ return activeRun.lastState === null
794
+ ? { state: "running", runId: activeRun.runId }
795
+ : workflowSchedulerResult(activeRun.lastState);
796
+ }
797
+ const store = new WorkflowRunStore();
798
+ const bundle = await readRunBundle(store.runDirFor(request.runId));
799
+ if (bundle !== null) {
800
+ const recovered = bundle.state.status === "running" ? await store.markRunInterrupted(request.runId) : bundle;
801
+ if (recovered !== null) {
802
+ const lastTraceEvent = await readLastTraceEvent(recovered.runDir, recovered.manifest.paths.trace);
803
+ return workflowSchedulerResult(recovered.state, lastTraceEvent?.type === "run_interrupted");
804
+ }
805
+ return { state: "pending" };
806
+ }
807
+ if (controllerContext === null) {
808
+ return { state: "pending" };
809
+ }
810
+ await store.quarantineIncompleteRun(request.runId);
811
+ const runId = await startRun(controllerContext, request.workflow, request.input, {
812
+ runId: request.runId,
813
+ childKey,
814
+ onFinish: onComplete,
815
+ presentation: false,
816
+ quiet: true,
817
+ signal,
818
+ });
819
+ return runId === undefined ? { state: "pending" } : { state: "running", runId };
820
+ };
821
+ const updateControllerStatus = (ctx) => {
822
+ if (controllerHost === undefined) {
823
+ ctx.ui.setStatus("pi-controllers", undefined);
824
+ return;
825
+ }
826
+ const count = controllerHost.store.listResources().length;
827
+ ctx.ui.setStatus("pi-controllers", `${count} controller resource${count === 1 ? "" : "s"}`);
828
+ };
829
+ const ensureControllerHost = async (ctx) => {
830
+ controllerContext = ctx;
831
+ if (controllerHost !== undefined) {
832
+ return controllerHost;
833
+ }
834
+ controllerHost = await PiControllerHost.create({ cwd: ctx.cwd, startChild });
835
+ controllerHost?.start();
836
+ updateControllerStatus(ctx);
837
+ return controllerHost;
838
+ };
839
+ const listWorkflowControl = async (ctx, offset = 0) => {
289
840
  const discovered = await discoverWorkflows({ cwd: ctx.cwd });
290
841
  if (discovered.length === 0) {
291
- notify(ctx, "No workflows found. Put *.workflow.ts files in .pi/workflows/ or ~/.pi/agent/workflows/, or pass a path.", "warning");
292
- return;
842
+ return {
843
+ message: "No workflows found. Put *.workflow.ts files in .pi/workflows/ or ~/.pi/agent/workflows/, or pass a path.",
844
+ details: { workflows: [], total: 0, offset: 0 },
845
+ level: "warning",
846
+ };
847
+ }
848
+ if (!Number.isInteger(offset) || offset < 0 || offset > discovered.length) {
849
+ throw new Error(`Workflow list offset must be an integer from 0 through ${discovered.length}.`);
850
+ }
851
+ const page = [];
852
+ let nameChars = 0;
853
+ for (const workflow of discovered.slice(offset)) {
854
+ const renderedName = `${workflow.name} (${workflow.source})`;
855
+ if (page.length >= MAX_WORKFLOW_LIST_ITEMS ||
856
+ nameChars + renderedName.length > MAX_WORKFLOW_LIST_NAME_CHARS) {
857
+ break;
858
+ }
859
+ page.push(workflow);
860
+ nameChars += renderedName.length;
861
+ }
862
+ const names = page.map((workflow) => `${workflow.name} (${workflow.source})`).join(", ");
863
+ const nextOffset = offset + page.length;
864
+ const omitted = discovered.length - nextOffset;
865
+ return {
866
+ message: [
867
+ `Workflows: ${names}.`,
868
+ omitted > 0 ? `${omitted} more omitted; list again with offset ${nextOffset}.` : "",
869
+ "Run one with /workflow <name> [task].",
870
+ ]
871
+ .filter(Boolean)
872
+ .join(" "),
873
+ details: {
874
+ workflows: page.map((workflow) => ({
875
+ name: workflow.name,
876
+ source: workflow.source,
877
+ ...(workflow.name === "monitor"
878
+ ? {
879
+ description: "Repeatedly check a target, report requested changes, and stop on a condition.",
880
+ }
881
+ : {}),
882
+ })),
883
+ total: discovered.length,
884
+ offset,
885
+ omitted,
886
+ ...(omitted > 0 ? { nextOffset } : {}),
887
+ },
888
+ };
889
+ };
890
+ const listWorkflows = async (ctx) => {
891
+ const result = await listWorkflowControl(ctx);
892
+ notify(ctx, result.message, result.level);
893
+ };
894
+ const cancelWorkflowControl = (ctx) => {
895
+ if (activeRun) {
896
+ const workflowName = activeRun.workflowName;
897
+ const runId = activeRun.runId;
898
+ activeRun.engine.cancel();
899
+ return {
900
+ message: `Cancelling workflow ${workflowName}…`,
901
+ details: { action: "cancel", workflowName, runId },
902
+ };
903
+ }
904
+ if (pendingToolLaunch !== null) {
905
+ const ref = pendingToolLaunch.ref;
906
+ pendingToolLaunch = null;
907
+ return {
908
+ message: `Cancelled the queued workflow launch for ${ref}.`,
909
+ details: { action: "cancel", workflow: ref, queued: false },
910
+ };
911
+ }
912
+ if (widgetSource) {
913
+ const { state } = widgetSource;
914
+ clearWidgetTimer();
915
+ clearWidget(ctx);
916
+ const detail = state.status === "waiting" && state.waitingOn
917
+ ? `already ended at checkpoint ${state.waitingOn}`
918
+ : `already ${state.status}`;
919
+ return {
920
+ message: `Workflow ${state.workflowName} ${detail}; cleared its widget.`,
921
+ details: { action: "clear", workflowName: state.workflowName, runId: state.runId },
922
+ };
923
+ }
924
+ return {
925
+ message: "No workflow is running.",
926
+ details: { action: "cancel", active: false },
927
+ level: "warning",
928
+ };
929
+ };
930
+ const pauseWorkflowControl = (ctx) => {
931
+ if (!activeRun) {
932
+ return {
933
+ message: "No workflow is running.",
934
+ details: { action: "pause", active: false },
935
+ level: "warning",
936
+ };
937
+ }
938
+ if (runHeld()) {
939
+ return {
940
+ message: `Workflow ${activeRun.workflowName} is already pausing or paused.`,
941
+ details: {
942
+ action: "pause",
943
+ workflowName: activeRun.workflowName,
944
+ runId: activeRun.runId,
945
+ paused: true,
946
+ },
947
+ };
948
+ }
949
+ activeRun.engine.pause();
950
+ renderWidget(ctx);
951
+ return {
952
+ message: `Pausing workflow ${activeRun.workflowName}; the current step will finish before the run holds.`,
953
+ details: {
954
+ action: "pause",
955
+ workflowName: activeRun.workflowName,
956
+ runId: activeRun.runId,
957
+ paused: true,
958
+ },
959
+ };
960
+ };
961
+ const resumeWorkflowControl = (ctx) => {
962
+ if (!activeRun) {
963
+ return {
964
+ message: "No workflow is running.",
965
+ details: { action: "resume", active: false },
966
+ level: "warning",
967
+ };
968
+ }
969
+ if (!runHeld()) {
970
+ return {
971
+ message: `Workflow ${activeRun.workflowName} is not paused.`,
972
+ details: {
973
+ action: "resume",
974
+ workflowName: activeRun.workflowName,
975
+ runId: activeRun.runId,
976
+ paused: false,
977
+ },
978
+ level: "warning",
979
+ };
980
+ }
981
+ activeRun.engine.resume();
982
+ activeRun.executor.release();
983
+ renderWidget(ctx);
984
+ return {
985
+ message: `Workflow ${activeRun.workflowName} resumed.`,
986
+ details: {
987
+ action: "resume",
988
+ workflowName: activeRun.workflowName,
989
+ runId: activeRun.runId,
990
+ paused: false,
991
+ },
992
+ };
993
+ };
994
+ const statusWorkflowControl = async (ctx, runId) => {
995
+ if (runId !== undefined) {
996
+ const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
997
+ if (bundle === null) {
998
+ throw new Error(`Workflow run not found: ${runId}`);
999
+ }
1000
+ const { state } = bundle;
1001
+ return {
1002
+ message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
1003
+ details: workflowStateSummary(state),
1004
+ };
1005
+ }
1006
+ const state = activeRun?.lastState ?? widgetSource?.state;
1007
+ if ((state === undefined || state === null) && pendingToolLaunch !== null) {
1008
+ return {
1009
+ message: `Workflow ${pendingToolLaunch.ref} is queued until the current turn finishes.`,
1010
+ details: { active: false, queued: true, workflow: pendingToolLaunch.ref },
1011
+ };
1012
+ }
1013
+ if (state === undefined || state === null) {
1014
+ return {
1015
+ message: "No workflow run is active or displayed.",
1016
+ details: { active: false },
1017
+ level: "warning",
1018
+ };
1019
+ }
1020
+ return {
1021
+ message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
1022
+ details: workflowStateSummary(state),
1023
+ };
1024
+ };
1025
+ const resolveWaitingWorkflow = async (ctx, requestedRunId) => {
1026
+ let parentRunId = requestedRunId ?? lastWaitingRunId;
1027
+ if (parentRunId === null) {
1028
+ const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
1029
+ const known = new Set(rows.map((row) => row.runId));
1030
+ const continued = new Set(rows.map((row) => row.parentRunId).filter((parent) => parent !== null));
1031
+ const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
1032
+ parentRunId =
1033
+ bundles.find((bundle) => bundle.state.status === "waiting" &&
1034
+ known.has(bundle.state.runId) &&
1035
+ !continued.has(bundle.state.runId))?.state.runId ?? null;
1036
+ }
1037
+ if (parentRunId === null) {
1038
+ throw new Error("No workflow is waiting for an answer.");
1039
+ }
1040
+ const parent = await readRunBundle(new WorkflowRunStore().runDirFor(parentRunId));
1041
+ if (parent === null ||
1042
+ parent.state.status !== "waiting" ||
1043
+ parent.state.workflowPath === undefined) {
1044
+ if (parentRunId === lastWaitingRunId) {
1045
+ lastWaitingRunId = null;
1046
+ }
1047
+ throw new Error(`Workflow run ${parentRunId} is no longer waiting.`);
1048
+ }
1049
+ return { parentRunId, workflowPath: parent.state.workflowPath };
1050
+ };
1051
+ const answerWorkflowControl = async (ctx, input, requestedRunId) => {
1052
+ const waiting = await resolveWaitingWorkflow(ctx, requestedRunId);
1053
+ const continued = await startRun(ctx, waiting.workflowPath, input, {
1054
+ parentRunId: waiting.parentRunId,
1055
+ });
1056
+ if (continued === undefined) {
1057
+ throw new Error("Could not start the checkpoint continuation.");
1058
+ }
1059
+ lastWaitingRunId = null;
1060
+ return {
1061
+ message: `Answered checkpoint ${waiting.parentRunId}; continuation ${continued} started.`,
1062
+ details: {
1063
+ action: "answer",
1064
+ parentRunId: waiting.parentRunId,
1065
+ runId: continued,
1066
+ },
1067
+ };
1068
+ };
1069
+ const startWorkflowControl = async (ctx, ref, input) => {
1070
+ if (activeRun !== null) {
1071
+ throw new Error(`A workflow is already running: ${activeRun.workflowName}.`);
1072
+ }
1073
+ if (pendingToolLaunch !== null) {
1074
+ throw new Error("A workflow launch is already waiting for the current turn to finish.");
1075
+ }
1076
+ if (presentationPending !== null) {
1077
+ throw new Error("The previous workflow result is still being presented.");
1078
+ }
1079
+ const runId = await startRun(ctx, ref, input);
1080
+ if (runId === undefined) {
1081
+ throw new Error("The workflow could not start.");
1082
+ }
1083
+ return {
1084
+ message: `Workflow ${ref} started (run ${runId}).`,
1085
+ details: { action: "start", workflow: ref, runId },
1086
+ };
1087
+ };
1088
+ const queueToolLaunch = async (ctx, ref, input, options = {}) => {
1089
+ if (activeRun !== null) {
1090
+ throw new Error(`A workflow is already running: ${activeRun.workflowName}. Cancel it before starting another.`);
1091
+ }
1092
+ if (pendingToolLaunch !== null) {
1093
+ throw new Error("A workflow launch is already waiting for the current turn to finish.");
1094
+ }
1095
+ if (presentationPending !== null) {
1096
+ throw new Error("The previous workflow result is still being presented.");
1097
+ }
1098
+ const reservation = { ctx, ref, input, options };
1099
+ pendingToolLaunch = reservation;
1100
+ try {
1101
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
1102
+ const workflow = await loadWorkflowFile(resolved.path);
1103
+ if (pendingToolLaunch !== reservation) {
1104
+ throw new Error("The queued workflow launch was cancelled before validation finished.");
1105
+ }
1106
+ return {
1107
+ message: `Workflow ${workflow.name} will start after this turn finishes.`,
1108
+ details: {
1109
+ action: "start",
1110
+ workflow: workflow.name,
1111
+ source: resolved.source,
1112
+ queued: true,
1113
+ },
1114
+ };
1115
+ }
1116
+ catch (error) {
1117
+ if (pendingToolLaunch === reservation) {
1118
+ pendingToolLaunch = null;
1119
+ }
1120
+ throw error;
293
1121
  }
294
- const names = discovered.map((workflow) => `${workflow.name} (${workflow.source})`).join(", ");
295
- notify(ctx, `Workflows: ${names}. Run one with /workflow <name> [task].`);
296
1122
  };
297
1123
  pi.registerCommand("workflow", {
298
- description: "Run a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: pause, resume, cancel",
1124
+ description: "Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer",
299
1125
  getArgumentCompletions: async (prefix) => {
300
1126
  const discovered = await discoverWorkflows({ cwd: process.cwd() });
301
1127
  const items = [
302
1128
  ...discovered.map((workflow) => ({ value: workflow.name, label: workflow.name })),
1129
+ { value: "status", label: "status" },
303
1130
  { value: "pause", label: "pause" },
304
1131
  { value: "resume", label: "resume" },
305
1132
  { value: "cancel", label: "cancel" },
1133
+ { value: "answer", label: "answer" },
306
1134
  ].filter((item) => item.value.startsWith(prefix));
307
1135
  return items.length > 0 ? items : null;
308
1136
  },
@@ -320,87 +1148,174 @@ export default function piWorkflows(pi) {
320
1148
  return;
321
1149
  }
322
1150
  if (parsed.kind === "cancel") {
323
- if (activeRun) {
324
- activeRun.engine.cancel();
325
- notify(ctx, `Cancelling workflow ${activeRun.workflowName}…`);
326
- return;
327
- }
328
- // No live run, but a parked (waiting) or recently finished run may
329
- // still occupy the widget; cancel clears it.
330
- if (widgetSource) {
331
- const { state } = widgetSource;
332
- clearWidgetTimer();
333
- clearWidget(ctx);
334
- const detail = state.status === "waiting" && state.waitingOn
335
- ? `already ended at checkpoint ${state.waitingOn}`
336
- : `already ${state.status}`;
337
- notify(ctx, `Workflow ${state.workflowName} ${detail}; cleared its widget.`);
338
- return;
339
- }
340
- notify(ctx, "No workflow is running.", "warning");
1151
+ const result = cancelWorkflowControl(ctx);
1152
+ notify(ctx, result.message, result.level);
341
1153
  return;
342
1154
  }
343
1155
  if (parsed.kind === "pause") {
344
- if (!activeRun) {
345
- notify(ctx, "No workflow is running.", "warning");
346
- return;
1156
+ const result = pauseWorkflowControl(ctx);
1157
+ notify(ctx, result.message, result.level);
1158
+ return;
1159
+ }
1160
+ if (parsed.kind === "resume") {
1161
+ const result = resumeWorkflowControl(ctx);
1162
+ notify(ctx, result.message, result.level);
1163
+ return;
1164
+ }
1165
+ if (parsed.kind === "status") {
1166
+ try {
1167
+ const result = await statusWorkflowControl(ctx, parsed.runId);
1168
+ notify(ctx, result.message, result.level);
347
1169
  }
348
- if (runHeld()) {
349
- notify(ctx, `Workflow ${activeRun.workflowName} is already pausing or paused.`);
350
- return;
1170
+ catch (error) {
1171
+ notify(ctx, errorMessage(error), "error");
351
1172
  }
352
- activeRun.engine.pause();
353
- renderWidget(ctx);
354
- notify(ctx, `Pausing workflow ${activeRun.workflowName} — the current step finishes, then the run holds. /workflow resume to continue.`);
355
1173
  return;
356
1174
  }
357
- if (parsed.kind === "resume") {
358
- if (!activeRun) {
359
- notify(ctx, "No workflow is running.", "warning");
360
- return;
1175
+ if (parsed.kind === "answer") {
1176
+ try {
1177
+ const result = await answerWorkflowControl(ctx, parsed.input, parsed.runId);
1178
+ notify(ctx, result.message, result.level);
361
1179
  }
362
- if (!runHeld()) {
363
- notify(ctx, `Workflow ${activeRun.workflowName} is not paused.`);
364
- return;
1180
+ catch (error) {
1181
+ const message = errorMessage(error);
1182
+ notify(ctx, /workflow_run_queue_parent/.test(message)
1183
+ ? "That checkpoint was already answered; see its continuation run."
1184
+ : `Could not continue workflow: ${message}`, "error");
365
1185
  }
366
- activeRun.engine.resume();
367
- activeRun.executor.release();
368
- renderWidget(ctx);
369
- notify(ctx, `Workflow ${activeRun.workflowName} resumed.`);
370
1186
  return;
371
1187
  }
372
1188
  try {
373
- await startRun(ctx, parsed.ref, parsed.input);
1189
+ const result = await startWorkflowControl(ctx, parsed.ref, parsed.input);
1190
+ notify(ctx, result.message, result.level);
374
1191
  }
375
1192
  catch (error) {
376
1193
  notify(ctx, `Could not start workflow: ${errorMessage(error)}`, "error");
377
1194
  }
378
1195
  },
379
1196
  });
1197
+ pi.registerCommand("controller", {
1198
+ description: "Manage durable controllers: list, get, apply, reconcile, delete, start, or stop",
1199
+ getArgumentCompletions: async (prefix) => {
1200
+ const items = ["list", "get", "apply", "reconcile", "delete", "start", "stop"]
1201
+ .filter((value) => value.startsWith(prefix))
1202
+ .map((value) => ({ value, label: value }));
1203
+ return items.length > 0 ? items : null;
1204
+ },
1205
+ handler: async (args, ctx) => {
1206
+ let parsed;
1207
+ try {
1208
+ parsed = parseControllerArgs(args);
1209
+ }
1210
+ catch (error) {
1211
+ notify(ctx, errorMessage(error), "error");
1212
+ return;
1213
+ }
1214
+ const host = await ensureControllerHost(ctx);
1215
+ if (host === undefined) {
1216
+ notify(ctx, "No controllers found. Put *.controller.ts files in .pi/controllers/ or ~/.pi/agent/controllers/.", "warning");
1217
+ return;
1218
+ }
1219
+ try {
1220
+ switch (parsed.kind) {
1221
+ case "list":
1222
+ notify(ctx, host.list());
1223
+ break;
1224
+ case "get":
1225
+ notify(ctx, host.get(parsed.controller, parsed.key));
1226
+ break;
1227
+ case "apply": {
1228
+ const resource = host.apply(parsed.controller, parsed.key, parsed.spec);
1229
+ updateControllerStatus(ctx);
1230
+ notify(ctx, `Applied ${resource.metadata.controller}/${resource.metadata.key} generation ${resource.metadata.generation}.`);
1231
+ break;
1232
+ }
1233
+ case "reconcile":
1234
+ host.reconcile(parsed.controller, parsed.key);
1235
+ notify(ctx, `Queued ${parsed.controller}/${parsed.key}.`);
1236
+ break;
1237
+ case "delete":
1238
+ host.delete(parsed.controller, parsed.key);
1239
+ notify(ctx, `Requested deletion of ${parsed.controller}/${parsed.key}.`);
1240
+ break;
1241
+ case "start":
1242
+ host.start();
1243
+ notify(ctx, "Controller workers started.");
1244
+ break;
1245
+ case "stop":
1246
+ if (activeRun?.childKey !== undefined) {
1247
+ activeRun.interruptionRequested = true;
1248
+ activeRun.engine.cancel();
1249
+ }
1250
+ await host.stop();
1251
+ notify(ctx, "Controller workers stopped.");
1252
+ break;
1253
+ }
1254
+ }
1255
+ catch (error) {
1256
+ notify(ctx, `Controller command failed: ${errorMessage(error)}`, "error");
1257
+ }
1258
+ },
1259
+ });
380
1260
  pi.registerTool({
381
1261
  name: "workflow",
382
1262
  label: "Workflow",
383
1263
  description: [
384
- "Submit the output for the pending workflow step.",
385
- "Only call this tool when a workflow step contract in the conversation asks you to.",
386
- "Pass the exact step id from the contract and your result as the output.",
1264
+ "List, start, inspect, pause, resume, cancel, answer, or complete Pi Workflows runs.",
1265
+ "When the user asks to monitor, watch, poll, or check something repeatedly, start the built-in monitor workflow with input keys task, everyMinutes, reportWhen, stopWhen, and optional maxChecks.",
1266
+ "Use submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
1267
+ "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
387
1268
  ].join(" "),
388
- parameters: Type.Object({
389
- step: Type.String({ description: "The step id from the workflow step contract" }),
390
- attempt: Type.String({ description: "The attempt id from the workflow step contract" }),
391
- output: Type.Unknown({ description: "The step output, matching the expected output shape" }),
392
- }),
393
- async execute(_toolCallId, params) {
394
- if (!activeRun) {
395
- throw new Error("No workflow is running. Do not call the workflow tool outside a workflow.");
396
- }
397
- const result = await activeRun.executor.submit(params.step, params.attempt, params.output);
398
- if (!result.accepted) {
399
- throw new Error(result.message);
1269
+ parameters: WorkflowToolParameters,
1270
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1271
+ let control;
1272
+ switch (params.action) {
1273
+ case "list":
1274
+ control = await listWorkflowControl(ctx, params.offset);
1275
+ break;
1276
+ case "start":
1277
+ control = await queueToolLaunch(ctx, params.workflow, params.input ?? {});
1278
+ break;
1279
+ case "status":
1280
+ control = await statusWorkflowControl(ctx, params.runId);
1281
+ break;
1282
+ case "pause":
1283
+ control = pauseWorkflowControl(ctx);
1284
+ break;
1285
+ case "resume":
1286
+ control = resumeWorkflowControl(ctx);
1287
+ break;
1288
+ case "cancel":
1289
+ control = cancelWorkflowControl(ctx);
1290
+ break;
1291
+ case "answer": {
1292
+ const waiting = await resolveWaitingWorkflow(ctx, params.runId);
1293
+ control = await queueToolLaunch(ctx, waiting.workflowPath, params.input, {
1294
+ parentRunId: waiting.parentRunId,
1295
+ });
1296
+ break;
1297
+ }
1298
+ case "submit": {
1299
+ if (!activeRun) {
1300
+ throw new Error("No workflow step is waiting for output.");
1301
+ }
1302
+ // Flush the conversation into the bundle before accepting, so the
1303
+ // attempt range includes the assistant message carrying this call.
1304
+ await activeRun.recorder?.record(ctx).catch(() => undefined);
1305
+ await activeRun.recorder?.synchronize(ctx).catch(() => undefined);
1306
+ const result = await activeRun.executor.submit(params.step, params.attempt, params.output);
1307
+ if (!result.accepted) {
1308
+ throw new Error(result.message);
1309
+ }
1310
+ return {
1311
+ content: [{ type: "text", text: result.message }],
1312
+ details: { action: "submit", step: params.step, accepted: true },
1313
+ };
1314
+ }
400
1315
  }
401
1316
  return {
402
- content: [{ type: "text", text: result.message }],
403
- details: { step: params.step, accepted: true },
1317
+ content: [{ type: "text", text: control.message }],
1318
+ details: control.details,
404
1319
  };
405
1320
  },
406
1321
  });
@@ -412,6 +1327,29 @@ export default function piWorkflows(pi) {
412
1327
  description: "Scroll the workflow widget down",
413
1328
  handler: (ctx) => scrollWidget(ctx, WIDGET_SCROLL_STEP),
414
1329
  });
1330
+ pi.on("session_start", async (_event, ctx) => {
1331
+ sessionClosed = false;
1332
+ controllerContext = ctx;
1333
+ try {
1334
+ syncArmed = true;
1335
+ startRunSync(ctx);
1336
+ }
1337
+ catch {
1338
+ // Session sync is best-effort; runs themselves never depend on it.
1339
+ }
1340
+ try {
1341
+ await resumeParkedRun(ctx);
1342
+ }
1343
+ catch (error) {
1344
+ notify(ctx, `Could not resume a parked workflow: ${errorMessage(error)}`, "warning");
1345
+ }
1346
+ try {
1347
+ await ensureControllerHost(ctx);
1348
+ }
1349
+ catch (error) {
1350
+ notify(ctx, `Could not start controller workers: ${errorMessage(error)}`, "warning");
1351
+ }
1352
+ });
415
1353
  pi.on("agent_start", () => {
416
1354
  if (!activeRun && presentationPending === null && presentationAbort) {
417
1355
  // A normal user turn started while an async presentation prompt was
@@ -441,19 +1379,85 @@ export default function piWorkflows(pi) {
441
1379
  renderWidget(ctx);
442
1380
  notify(ctx, `Workflow ${run.workflowName} paused (turn interrupted). /workflow resume to continue, /workflow cancel to stop.`);
443
1381
  });
444
- pi.on("agent_settled", () => {
445
- if (!activeRun) {
1382
+ pi.on("turn_start", (event) => {
1383
+ activeRun?.recorder?.handleTurnStart(event);
1384
+ });
1385
+ pi.on("turn_end", async (event, ctx) => {
1386
+ await activeRun?.recorder?.handleTurnEnd(event, ctx).catch(() => undefined);
1387
+ });
1388
+ pi.on("message_start", async (event, ctx) => {
1389
+ await activeRun?.recorder?.handleMessageStart(event, ctx).catch(() => undefined);
1390
+ });
1391
+ pi.on("message_update", (event) => {
1392
+ activeRun?.recorder?.handleMessageUpdate(event);
1393
+ });
1394
+ pi.on("message_end", (event) => {
1395
+ activeRun?.recorder?.handleMessageEnd(event);
1396
+ });
1397
+ pi.on("tool_execution_start", (event) => {
1398
+ activeRun?.recorder?.handleToolStart(event);
1399
+ });
1400
+ pi.on("tool_execution_update", (event) => {
1401
+ activeRun?.recorder?.handleToolUpdate(event);
1402
+ });
1403
+ pi.on("tool_execution_end", (event) => {
1404
+ activeRun?.recorder?.handleToolEnd(event);
1405
+ });
1406
+ pi.on("agent_settled", async (_event, ctx) => {
1407
+ if (activeRun === null && pendingToolLaunch !== null) {
1408
+ const launch = pendingToolLaunch;
1409
+ pendingToolLaunch = null;
1410
+ try {
1411
+ const runId = await startRun(launch.ctx, launch.ref, launch.input, launch.options);
1412
+ if (runId === undefined) {
1413
+ notify(launch.ctx, "The queued workflow could not start.", "error");
1414
+ }
1415
+ else if (launch.options?.parentRunId !== undefined) {
1416
+ lastWaitingRunId = null;
1417
+ }
1418
+ }
1419
+ catch (error) {
1420
+ notify(launch.ctx, `Could not start queued workflow: ${errorMessage(error)}`, "error");
1421
+ }
1422
+ return;
1423
+ }
1424
+ const run = activeRun;
1425
+ if (!run) {
446
1426
  presentationPending = null;
447
1427
  return;
448
1428
  }
449
- activeRun.executor.setStreaming(false);
450
- activeRun.executor.handleAgentSettled();
1429
+ await run.recorder?.synchronize(ctx).catch(() => undefined);
1430
+ run.recorder?.settleAttempt();
1431
+ run.executor.setStreaming(false);
1432
+ run.executor.handleAgentSettled();
451
1433
  });
452
- pi.on("session_shutdown", () => {
1434
+ pi.on("session_shutdown", async () => {
453
1435
  sessionClosed = true;
454
1436
  supersedePresentation();
455
- activeRun?.engine.cancel();
1437
+ const run = activeRun;
1438
+ if (run !== null && run.claimToken !== undefined) {
1439
+ // Queued interactive runs park: no terminal event, no recorded partial
1440
+ // attempt, and the claim releases so another runner can resume.
1441
+ run.engine.park();
1442
+ }
1443
+ else {
1444
+ run?.engine.cancel();
1445
+ }
1446
+ await run?.recorder?.stop().catch(() => undefined);
1447
+ await run?.completion?.catch(() => undefined);
456
1448
  activeRun = null;
1449
+ pendingToolLaunch = null;
1450
+ lastWaitingRunId = null;
1451
+ if (runSyncTimer !== null) {
1452
+ clearInterval(runSyncTimer);
1453
+ runSyncTimer = null;
1454
+ }
1455
+ syncArmed = false;
1456
+ await controllerHost?.close().catch(() => undefined);
1457
+ controllerHost = undefined;
1458
+ controllerContext = null;
1459
+ runQueueStore?.close();
1460
+ runQueueStore = null;
457
1461
  presentationPending = null;
458
1462
  clearWidgetTimer();
459
1463
  stopWidgetTicker();
@@ -461,6 +1465,31 @@ export default function piWorkflows(pi) {
461
1465
  widgetScroll = null;
462
1466
  });
463
1467
  }
1468
+ function workflowSchedulerResult(state, interrupted = false) {
1469
+ if (interrupted) {
1470
+ return {
1471
+ state: "interrupted",
1472
+ runId: state.runId,
1473
+ ...(state.error !== undefined ? { error: state.error } : {}),
1474
+ };
1475
+ }
1476
+ switch (state.status) {
1477
+ case "running":
1478
+ return { state: "running", runId: state.runId };
1479
+ case "waiting":
1480
+ return { state: "waiting", runId: state.runId };
1481
+ case "completed":
1482
+ return { state: "succeeded", runId: state.runId };
1483
+ case "failed":
1484
+ case "timed_out":
1485
+ case "cancelled":
1486
+ return {
1487
+ state: "failed",
1488
+ runId: state.runId,
1489
+ ...(state.error !== undefined ? { error: state.error } : {}),
1490
+ };
1491
+ }
1492
+ }
464
1493
  async function resolvePresentationPrompt(buildPrompt, state, signal) {
465
1494
  const snapshot = structuredClone(state);
466
1495
  return await Promise.race([