@osolmaz/pi-workflows 0.3.0 → 0.5.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 (74) hide show
  1. package/README.md +11 -7
  2. package/dist/builtins/catalog.d.ts +2 -0
  3. package/dist/builtins/catalog.js +32 -0
  4. package/dist/builtins/catalog.js.map +1 -0
  5. package/dist/builtins/monitor.workflow.d.ts +2 -2
  6. package/dist/builtins/monitor.workflow.js +25 -25
  7. package/dist/builtins/monitor.workflow.js.map +1 -1
  8. package/dist/controllers/index.d.ts +1 -1
  9. package/dist/controllers/index.js.map +1 -1
  10. package/dist/controllers/sqlite.d.ts +70 -9
  11. package/dist/controllers/sqlite.js +209 -35
  12. package/dist/controllers/sqlite.js.map +1 -1
  13. package/dist/controllers/workflow-engine-scheduler.d.ts +2 -2
  14. package/dist/controllers/workflow-engine-scheduler.js +3 -1
  15. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  16. package/dist/extension/executor.d.ts +3 -0
  17. package/dist/extension/executor.js +11 -1
  18. package/dist/extension/executor.js.map +1 -1
  19. package/dist/extension/index.js +157 -108
  20. package/dist/extension/index.js.map +1 -1
  21. package/dist/host/runner.d.ts +1 -0
  22. package/dist/host/runner.js +68 -20
  23. package/dist/host/runner.js.map +1 -1
  24. package/dist/render/graph-render.js +3 -0
  25. package/dist/render/graph-render.js.map +1 -1
  26. package/dist/workflows/catalog.d.ts +43 -0
  27. package/dist/workflows/catalog.js +79 -0
  28. package/dist/workflows/catalog.js.map +1 -0
  29. package/dist/workflows/definition.d.ts +2 -1
  30. package/dist/workflows/definition.js +9 -1
  31. package/dist/workflows/definition.js.map +1 -1
  32. package/dist/workflows/engine.d.ts +6 -6
  33. package/dist/workflows/engine.js +93 -33
  34. package/dist/workflows/engine.js.map +1 -1
  35. package/dist/workflows/index.d.ts +3 -3
  36. package/dist/workflows/index.js +2 -2
  37. package/dist/workflows/index.js.map +1 -1
  38. package/dist/workflows/loader.d.ts +18 -16
  39. package/dist/workflows/loader.js +58 -23
  40. package/dist/workflows/loader.js.map +1 -1
  41. package/dist/workflows/migrate-sources.d.ts +42 -0
  42. package/dist/workflows/migrate-sources.js +133 -0
  43. package/dist/workflows/migrate-sources.js.map +1 -0
  44. package/dist/workflows/schema.d.ts +2 -1
  45. package/dist/workflows/schema.js +14 -1
  46. package/dist/workflows/schema.js.map +1 -1
  47. package/dist/workflows/store.js +5 -2
  48. package/dist/workflows/store.js.map +1 -1
  49. package/dist/workflows/types.d.ts +44 -5
  50. package/docs/development.md +5 -3
  51. package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
  52. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
  53. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
  54. package/docs/run-bundles.md +22 -4
  55. package/docs/workflows.md +57 -14
  56. package/package.json +1 -1
  57. package/src/builtins/catalog.ts +32 -0
  58. package/src/builtins/monitor.workflow.ts +33 -26
  59. package/src/controllers/index.ts +1 -0
  60. package/src/controllers/sqlite.ts +353 -43
  61. package/src/controllers/workflow-engine-scheduler.ts +5 -2
  62. package/src/extension/executor.ts +12 -1
  63. package/src/extension/index.ts +181 -140
  64. package/src/host/runner.ts +78 -20
  65. package/src/render/graph-render.ts +3 -0
  66. package/src/workflows/catalog.ts +135 -0
  67. package/src/workflows/definition.ts +11 -0
  68. package/src/workflows/engine.ts +128 -53
  69. package/src/workflows/index.ts +7 -0
  70. package/src/workflows/loader.ts +70 -26
  71. package/src/workflows/migrate-sources.ts +174 -0
  72. package/src/workflows/schema.ts +16 -1
  73. package/src/workflows/store.ts +5 -2
  74. package/src/workflows/types.ts +43 -4
@@ -1,8 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { projectControllerStorePath, SqliteControllerStore, } from "../controllers/index.js";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import { builtinWorkflowCatalog } from "../builtins/catalog.js";
4
+ import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
3
5
  import { WorkflowEngine } from "../workflows/engine.js";
4
- import { ClaimLostError, errorMessage, isClaimLostError } from "../workflows/errors.js";
5
- import { discoverWorkflows, hashWorkflowSource, loadWorkflowFile, resolveWorkflowRef, } from "../workflows/loader.js";
6
+ import { ClaimLostError, errorMessage, isClaimLostError, TimeoutError, } from "../workflows/errors.js";
7
+ import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
8
+ import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
6
9
  import { createRunId, listRunBundles, readLastTraceEvent, readRunBundle, WorkflowRunStore, createDefinitionSnapshot, } from "../workflows/store.js";
7
10
  import { PiControllerHost, parseControllerArgs, } from "./controller-host.js";
8
11
  import { ConversationStepExecutor } from "./executor.js";
@@ -12,6 +15,7 @@ import { WorkflowToolParameters } from "./workflow-tool.js";
12
15
  const RUN_CLAIM_LEASE_MS = 30_000;
13
16
  const RUN_CLAIM_RENEW_MS = 10_000;
14
17
  const RUN_SYNC_POLL_MS = 3_000;
18
+ const NOTIFICATION_DELIVERY_LEASE_MS = 30_000;
15
19
  const WIDGET_KEY = "pi-workflows";
16
20
  const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
17
21
  const FINAL_WIDGET_TTL_MS = 60_000;
@@ -115,15 +119,13 @@ export default function piWorkflows(pi) {
115
119
  // One runner identity per session; it names this session in run claims.
116
120
  const runnerId = randomUUID();
117
121
  let runQueueStore = null;
122
+ const migrationBlockedRuns = new Set();
118
123
  const ensureRunQueueStore = (cwd) => {
119
124
  runQueueStore ??= new SqliteControllerStore(projectControllerStorePath(cwd));
120
125
  return runQueueStore;
121
126
  };
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
+ // Session-addressed delivery: each session polls only its durable outbox.
128
+ // Run events remain an audit feed and never enter a conversation.
127
129
  let syncArmed = false;
128
130
  let runSyncTimer = null;
129
131
  const recordRunEvent = (event) => {
@@ -134,90 +136,56 @@ export default function piWorkflows(pi) {
134
136
  // The event feed is best-effort; never fail a run for it.
135
137
  }
136
138
  };
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`;
139
+ const deliveredNotificationIds = (ctx) => {
140
+ const ids = new Set();
141
+ for (const entry of ctx.sessionManager.getBranch()) {
142
+ if (entry.type !== "custom_message" || entry.customType !== "pi-workflows-notification") {
143
+ continue;
143
144
  }
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}`;
145
+ const details = entry.details;
146
+ if (details !== null && typeof details === "object" && !Array.isArray(details)) {
147
+ const notificationId = details.notificationId;
148
+ if (typeof notificationId === "string")
149
+ ids.add(notificationId);
149
150
  }
150
- default:
151
- return `${label} ${event.type}`;
152
151
  }
152
+ return ids;
153
153
  };
154
- const runSyncPass = async (ctx) => {
155
- if (runQueueStore === null || !syncArmed) {
154
+ const runSyncPass = (ctx) => {
155
+ if (runQueueStore === null || !syncArmed)
156
156
  return;
157
- }
158
157
  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);
158
+ const sessionId = ctx.sessionManager.getSessionId();
159
+ const alreadyDelivered = deliveredNotificationIds(ctx);
160
+ const claimToken = randomUUID();
161
+ for (const notification of runQueueStore.claimPendingWorkflowNotifications({
162
+ targetSessionId: sessionId,
163
+ claimToken,
164
+ leaseMs: NOTIFICATION_DELIVERY_LEASE_MS,
165
+ })) {
166
+ if (!alreadyDelivered.has(notification.notificationId)) {
167
+ pi.sendMessage({
168
+ customType: "pi-workflows-notification",
169
+ content: notification.content,
170
+ display: true,
171
+ details: {
172
+ notificationId: notification.notificationId,
173
+ runId: notification.runId,
174
+ kind: notification.kind,
175
+ },
176
+ });
177
+ alreadyDelivered.add(notification.notificationId);
168
178
  }
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;
179
+ runQueueStore.markWorkflowNotificationDelivered({
180
+ notificationId: notification.notificationId,
181
+ targetSessionId: sessionId,
182
+ claimToken,
183
+ });
184
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
185
  }
189
186
  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;
187
+ // Delivery retries on the next poll. It never affects workflow execution.
197
188
  }
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
189
  };
222
190
  const startRunSync = (ctx) => {
223
191
  if (runSyncTimer !== null) {
@@ -229,6 +197,8 @@ export default function piWorkflows(pi) {
229
197
  runSyncTimer.unref?.();
230
198
  };
231
199
  let activeRun = null;
200
+ let systemTurnAbort = null;
201
+ let lastExpiredAttempt = null;
232
202
  let pendingToolLaunch = null;
233
203
  // The interactive run currently parked at a checkpoint, if any.
234
204
  let lastWaitingRunId = null;
@@ -355,7 +325,7 @@ export default function piWorkflows(pi) {
355
325
  const presentRun = async (ctx, run, state) => {
356
326
  if (sessionClosed ||
357
327
  run.generation !== runGeneration ||
358
- state.status === "cancelled" ||
328
+ (state.status !== "completed" && state.status !== "waiting") ||
359
329
  run.presentationPrompt === undefined) {
360
330
  return;
361
331
  }
@@ -473,7 +443,7 @@ export default function piWorkflows(pi) {
473
443
  }
474
444
  const summary = state.status === "waiting" && state.waitingOn
475
445
  ? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
476
- : `Workflow ${state.workflowName} ${state.status} (run ${state.runId})`;
446
+ : `Workflow ${state.workflowName} ${state.status} (run ${state.runId})${state.error !== undefined ? `: ${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}` : ""}`;
477
447
  notify(ctx, summary, state.status === "completed" ? "info" : "warning");
478
448
  try {
479
449
  const childResult = run.childKey !== undefined &&
@@ -510,13 +480,13 @@ export default function piWorkflows(pi) {
510
480
  }
511
481
  supersedePresentation();
512
482
  const generation = runGeneration;
513
- const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
514
- const workflow = await loadWorkflowFile(resolved.path);
483
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
484
+ const workflow = resolved.definition;
515
485
  if (options.signal?.aborted) {
516
486
  throw options.signal.reason ?? new Error("Workflow startup aborted");
517
487
  }
518
488
  const snapshot = createDefinitionSnapshot(workflow);
519
- const workflowHash = await hashWorkflowSource(resolved.path);
489
+ const workflowSource = resolved.source;
520
490
  const runId = options.runId ?? createRunId(workflow.name);
521
491
  // Continuations validate the parent before touching the queue: a
522
492
  // refused continuation (edited source, missing parent) must not consume
@@ -526,7 +496,8 @@ export default function piWorkflows(pi) {
526
496
  if (parent === null || parent.state.status !== "waiting") {
527
497
  throw new Error(`Workflow run ${options.parentRunId} is not waiting at a checkpoint`);
528
498
  }
529
- if (parent.state.workflowHash !== undefined && parent.state.workflowHash !== workflowHash) {
499
+ if (parent.state.workflowSource !== undefined &&
500
+ !isDeepStrictEqual(parent.state.workflowSource, workflowSource)) {
530
501
  throw new Error(`Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`);
531
502
  }
532
503
  }
@@ -542,12 +513,15 @@ export default function piWorkflows(pi) {
542
513
  const token = randomUUID();
543
514
  queueStore.enqueueWorkflowRun({
544
515
  runId,
545
- workflowRef: ref,
546
- workflowPath: resolved.path,
516
+ workflowName: workflow.name,
517
+ workflowSourceRef: workflowSource.kind === "builtin"
518
+ ? `builtin:${workflowSource.id}`
519
+ : workflowSource.path,
547
520
  input,
548
521
  runnerId,
549
522
  claimToken: token,
550
523
  leaseMs: RUN_CLAIM_LEASE_MS,
524
+ originSessionId: ctx.sessionManager.getSessionId(),
551
525
  ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
552
526
  });
553
527
  claimToken = token;
@@ -573,6 +547,16 @@ export default function piWorkflows(pi) {
573
547
  sendPrompt: ({ prompt, streaming }) => {
574
548
  pi.sendUserMessage(prompt, streaming ? { deliverAs: "steer" } : undefined);
575
549
  },
550
+ onAbort: (contract, reason) => {
551
+ lastExpiredAttempt = {
552
+ contract,
553
+ reason: reason instanceof TimeoutError ? "timed out" : `ended: ${errorMessage(reason)}`,
554
+ };
555
+ if (!executor.held && !ctx.isIdle()) {
556
+ systemTurnAbort = contract;
557
+ ctx.abort();
558
+ }
559
+ },
576
560
  conversation: {
577
561
  beginAttempt: (contract) => run.recorder?.beginAttempt(contract),
578
562
  mark: () => run.recorder?.mark() ?? 0,
@@ -586,6 +570,25 @@ export default function piWorkflows(pi) {
586
570
  const engine = new WorkflowEngine({
587
571
  executor,
588
572
  store,
573
+ notificationSink: {
574
+ notify: (request) => {
575
+ fence?.();
576
+ if (queueStore === null)
577
+ throw new Error("Workflow notifications require a queued run");
578
+ const record = queueStore.getWorkflowRun(request.runId);
579
+ if (record?.originSessionId === null || record?.originSessionId === undefined) {
580
+ throw new Error(`Workflow run ${request.runId} has no origin session`);
581
+ }
582
+ const notification = queueStore.enqueueWorkflowNotification({
583
+ ...request,
584
+ targetSessionId: record.originSessionId,
585
+ });
586
+ return {
587
+ notificationId: notification.notificationId,
588
+ targetSessionId: notification.targetSessionId,
589
+ };
590
+ },
591
+ },
589
592
  // Awaited by the engine after run_started is persisted, so the session
590
593
  // binding and its trace event always precede node and terminal events.
591
594
  onRunStarted: async (runDir, state) => {
@@ -651,12 +654,11 @@ export default function piWorkflows(pi) {
651
654
  notify(ctx, `Workflow ${workflow.name} started. Follow it live with: pi-workflows view`);
652
655
  }
653
656
  run.completion = (options.resume === true
654
- ? engine.resumeRun(workflow, runId, { workflowHash })
657
+ ? engine.resumeRun(workflow, runId, { workflowSource })
655
658
  : options.parentRunId === undefined
656
- ? engine.run(workflow, input, { workflowPath: resolved.path, workflowHash, runId })
659
+ ? engine.run(workflow, input, { workflowSource, runId })
657
660
  : engine.continueRun(workflow, options.parentRunId, input, {
658
- workflowPath: resolved.path,
659
- workflowHash,
661
+ workflowSource,
660
662
  runId,
661
663
  }))
662
664
  .then((result) => finishRun(ctx, run, result))
@@ -758,13 +760,21 @@ export default function piWorkflows(pi) {
758
760
  runnerId,
759
761
  claimToken,
760
762
  leaseMs: RUN_CLAIM_LEASE_MS,
763
+ excludeRunIds: [...migrationBlockedRuns],
764
+ sessionId: ctx.sessionManager.getSessionId(),
761
765
  });
762
766
  if (claimed === undefined) {
763
767
  return;
764
768
  }
765
769
  let started;
766
770
  try {
767
- started = await startRun(ctx, claimed.workflowPath, claimed.input, {
771
+ const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(claimed.runId));
772
+ const sourceRef = bundle?.state.workflowSource === undefined
773
+ ? claimed.workflowSourceRef
774
+ : bundle.state.workflowSource.kind === "builtin"
775
+ ? `builtin:${bundle.state.workflowSource.id}`
776
+ : bundle.state.workflowSource.path;
777
+ started = await startRun(ctx, sourceRef, claimed.input, {
768
778
  resume: true,
769
779
  runId: claimed.runId,
770
780
  claimToken,
@@ -775,7 +785,7 @@ export default function piWorkflows(pi) {
775
785
  throw error;
776
786
  }
777
787
  if (started !== undefined) {
778
- notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.workflowRef}).`);
788
+ notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.workflowName}).`);
779
789
  }
780
790
  else {
781
791
  queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
@@ -837,7 +847,7 @@ export default function piWorkflows(pi) {
837
847
  return controllerHost;
838
848
  };
839
849
  const listWorkflowControl = async (ctx, offset = 0) => {
840
- const discovered = await discoverWorkflows({ cwd: ctx.cwd });
850
+ const discovered = await discoverWorkflows({ cwd: ctx.cwd }, builtinWorkflowCatalog);
841
851
  if (discovered.length === 0) {
842
852
  return {
843
853
  message: "No workflows found. Put *.workflow.ts files in .pi/workflows/ or ~/.pi/agent/workflows/, or pass a path.",
@@ -1026,7 +1036,10 @@ export default function piWorkflows(pi) {
1026
1036
  let parentRunId = requestedRunId ?? lastWaitingRunId;
1027
1037
  if (parentRunId === null) {
1028
1038
  const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
1029
- const known = new Set(rows.map((row) => row.runId));
1039
+ const sessionId = ctx.sessionManager.getSessionId();
1040
+ const known = new Set(rows
1041
+ .filter((row) => row.originSessionId === null || row.originSessionId === sessionId)
1042
+ .map((row) => row.runId));
1030
1043
  const continued = new Set(rows.map((row) => row.parentRunId).filter((parent) => parent !== null));
1031
1044
  const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
1032
1045
  parentRunId =
@@ -1037,20 +1050,31 @@ export default function piWorkflows(pi) {
1037
1050
  if (parentRunId === null) {
1038
1051
  throw new Error("No workflow is waiting for an answer.");
1039
1052
  }
1053
+ const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(parentRunId);
1054
+ if (queueRecord?.originSessionId !== null &&
1055
+ queueRecord?.originSessionId !== undefined &&
1056
+ queueRecord.originSessionId !== ctx.sessionManager.getSessionId()) {
1057
+ throw new Error(`Workflow run ${parentRunId} belongs to another Pi session.`);
1058
+ }
1040
1059
  const parent = await readRunBundle(new WorkflowRunStore().runDirFor(parentRunId));
1041
1060
  if (parent === null ||
1042
1061
  parent.state.status !== "waiting" ||
1043
- parent.state.workflowPath === undefined) {
1062
+ parent.state.workflowSource === undefined) {
1044
1063
  if (parentRunId === lastWaitingRunId) {
1045
1064
  lastWaitingRunId = null;
1046
1065
  }
1047
1066
  throw new Error(`Workflow run ${parentRunId} is no longer waiting.`);
1048
1067
  }
1049
- return { parentRunId, workflowPath: parent.state.workflowPath };
1068
+ return {
1069
+ parentRunId,
1070
+ workflowRef: parent.state.workflowSource.kind === "builtin"
1071
+ ? `builtin:${parent.state.workflowSource.id}`
1072
+ : parent.state.workflowSource.path,
1073
+ };
1050
1074
  };
1051
1075
  const answerWorkflowControl = async (ctx, input, requestedRunId) => {
1052
1076
  const waiting = await resolveWaitingWorkflow(ctx, requestedRunId);
1053
- const continued = await startRun(ctx, waiting.workflowPath, input, {
1077
+ const continued = await startRun(ctx, waiting.workflowRef, input, {
1054
1078
  parentRunId: waiting.parentRunId,
1055
1079
  });
1056
1080
  if (continued === undefined) {
@@ -1098,8 +1122,8 @@ export default function piWorkflows(pi) {
1098
1122
  const reservation = { ctx, ref, input, options };
1099
1123
  pendingToolLaunch = reservation;
1100
1124
  try {
1101
- const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
1102
- const workflow = await loadWorkflowFile(resolved.path);
1125
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
1126
+ const workflow = resolved.definition;
1103
1127
  if (pendingToolLaunch !== reservation) {
1104
1128
  throw new Error("The queued workflow launch was cancelled before validation finished.");
1105
1129
  }
@@ -1123,7 +1147,7 @@ export default function piWorkflows(pi) {
1123
1147
  pi.registerCommand("workflow", {
1124
1148
  description: "Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer",
1125
1149
  getArgumentCompletions: async (prefix) => {
1126
- const discovered = await discoverWorkflows({ cwd: process.cwd() });
1150
+ const discovered = await discoverWorkflows({ cwd: process.cwd() }, builtinWorkflowCatalog);
1127
1151
  const items = [
1128
1152
  ...discovered.map((workflow) => ({ value: workflow.name, label: workflow.name })),
1129
1153
  { value: "status", label: "status" },
@@ -1290,13 +1314,17 @@ export default function piWorkflows(pi) {
1290
1314
  break;
1291
1315
  case "answer": {
1292
1316
  const waiting = await resolveWaitingWorkflow(ctx, params.runId);
1293
- control = await queueToolLaunch(ctx, waiting.workflowPath, params.input, {
1317
+ control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
1294
1318
  parentRunId: waiting.parentRunId,
1295
1319
  });
1296
1320
  break;
1297
1321
  }
1298
1322
  case "submit": {
1299
1323
  if (!activeRun) {
1324
+ if (lastExpiredAttempt?.contract.attemptId === params.attempt &&
1325
+ lastExpiredAttempt.contract.nodeId === params.step) {
1326
+ throw new Error(`Workflow step ${JSON.stringify(params.step)} attempt ${JSON.stringify(params.attempt)} ${lastExpiredAttempt.reason}; its output is no longer accepted.`);
1327
+ }
1300
1328
  throw new Error("No workflow step is waiting for output.");
1301
1329
  }
1302
1330
  // Flush the conversation into the bundle before accepting, so the
@@ -1330,6 +1358,22 @@ export default function piWorkflows(pi) {
1330
1358
  pi.on("session_start", async (_event, ctx) => {
1331
1359
  sessionClosed = false;
1332
1360
  controllerContext = ctx;
1361
+ try {
1362
+ const queue = ensureRunQueueStore(ctx.cwd);
1363
+ const migration = await migrateLegacyWorkflowSources({
1364
+ catalog: builtinWorkflowCatalog,
1365
+ queue,
1366
+ });
1367
+ migrationBlockedRuns.clear();
1368
+ for (const blocked of migration.blocked)
1369
+ migrationBlockedRuns.add(blocked.runId);
1370
+ if (migration.blocked.length > 0) {
1371
+ notify(ctx, `Could not migrate ${migration.blocked.length} legacy workflow source(s).`, "warning");
1372
+ }
1373
+ }
1374
+ catch (error) {
1375
+ notify(ctx, `Could not migrate legacy workflow sources: ${errorMessage(error)}`, "warning");
1376
+ }
1333
1377
  try {
1334
1378
  syncArmed = true;
1335
1379
  startRunSync(ctx);
@@ -1360,6 +1404,14 @@ export default function piWorkflows(pi) {
1360
1404
  activeRun?.executor.setStreaming(true);
1361
1405
  });
1362
1406
  pi.on("agent_end", (event, ctx) => {
1407
+ const aborted = event.messages.some((message) => typeof message === "object" &&
1408
+ message !== null &&
1409
+ "stopReason" in message &&
1410
+ message.stopReason === "aborted");
1411
+ if (aborted && systemTurnAbort !== null) {
1412
+ systemTurnAbort = null;
1413
+ return;
1414
+ }
1363
1415
  const run = activeRun;
1364
1416
  if (!run) {
1365
1417
  return;
@@ -1367,10 +1419,6 @@ export default function piWorkflows(pi) {
1367
1419
  // An aborted turn means the user hit escape to take the conversation
1368
1420
  // back. Nudging or dispatching the next step would immediately steal it
1369
1421
  // again, so hold the run until an explicit /workflow resume.
1370
- const aborted = event.messages.some((message) => typeof message === "object" &&
1371
- message !== null &&
1372
- "stopReason" in message &&
1373
- message.stopReason === "aborted");
1374
1422
  if (!aborted || runHeld()) {
1375
1423
  return;
1376
1424
  }
@@ -1433,6 +1481,7 @@ export default function piWorkflows(pi) {
1433
1481
  });
1434
1482
  pi.on("session_shutdown", async () => {
1435
1483
  sessionClosed = true;
1484
+ systemTurnAbort = null;
1436
1485
  supersedePresentation();
1437
1486
  const run = activeRun;
1438
1487
  if (run !== null && run.claimToken !== undefined) {