@osolmaz/pi-workflows 0.3.0 → 0.4.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 (61) hide show
  1. package/README.md +3 -2
  2. package/dist/builtins/catalog.d.ts +2 -0
  3. package/dist/builtins/catalog.js +22 -0
  4. package/dist/builtins/catalog.js.map +1 -0
  5. package/dist/builtins/monitor.workflow.js +17 -1
  6. package/dist/builtins/monitor.workflow.js.map +1 -1
  7. package/dist/controllers/sqlite.d.ts +27 -4
  8. package/dist/controllers/sqlite.js +83 -5
  9. package/dist/controllers/sqlite.js.map +1 -1
  10. package/dist/controllers/workflow-engine-scheduler.d.ts +2 -2
  11. package/dist/controllers/workflow-engine-scheduler.js +3 -1
  12. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  13. package/dist/extension/executor.d.ts +3 -0
  14. package/dist/extension/executor.js +11 -1
  15. package/dist/extension/executor.js.map +1 -1
  16. package/dist/extension/index.js +84 -29
  17. package/dist/extension/index.js.map +1 -1
  18. package/dist/host/runner.d.ts +1 -0
  19. package/dist/host/runner.js +48 -19
  20. package/dist/host/runner.js.map +1 -1
  21. package/dist/workflows/catalog.d.ts +43 -0
  22. package/dist/workflows/catalog.js +79 -0
  23. package/dist/workflows/catalog.js.map +1 -0
  24. package/dist/workflows/engine.d.ts +5 -6
  25. package/dist/workflows/engine.js +71 -33
  26. package/dist/workflows/engine.js.map +1 -1
  27. package/dist/workflows/index.d.ts +2 -2
  28. package/dist/workflows/index.js +1 -1
  29. package/dist/workflows/index.js.map +1 -1
  30. package/dist/workflows/loader.d.ts +18 -16
  31. package/dist/workflows/loader.js +58 -23
  32. package/dist/workflows/loader.js.map +1 -1
  33. package/dist/workflows/migrate-sources.d.ts +41 -0
  34. package/dist/workflows/migrate-sources.js +129 -0
  35. package/dist/workflows/migrate-sources.js.map +1 -0
  36. package/dist/workflows/schema.js +2 -1
  37. package/dist/workflows/schema.js.map +1 -1
  38. package/dist/workflows/store.js +2 -2
  39. package/dist/workflows/store.js.map +1 -1
  40. package/dist/workflows/types.d.ts +18 -4
  41. package/docs/development.md +5 -3
  42. package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
  43. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
  44. package/docs/run-bundles.md +22 -4
  45. package/docs/workflows.md +34 -10
  46. package/package.json +1 -1
  47. package/src/builtins/catalog.ts +22 -0
  48. package/src/builtins/monitor.workflow.ts +25 -1
  49. package/src/controllers/sqlite.ts +128 -10
  50. package/src/controllers/workflow-engine-scheduler.ts +5 -2
  51. package/src/extension/executor.ts +12 -1
  52. package/src/extension/index.ts +106 -37
  53. package/src/host/runner.ts +58 -19
  54. package/src/workflows/catalog.ts +135 -0
  55. package/src/workflows/engine.ts +102 -53
  56. package/src/workflows/index.ts +2 -0
  57. package/src/workflows/loader.ts +70 -26
  58. package/src/workflows/migrate-sources.ts +167 -0
  59. package/src/workflows/schema.ts +2 -1
  60. package/src/workflows/store.ts +2 -2
  61. package/src/workflows/types.ts +13 -4
@@ -1,8 +1,11 @@
1
- import { agent, compute, defineWorkflow, shell } from "../workflows/index.js";
1
+ import { agent, compute, defineWorkflow, shell } from "../workflows/definition.js";
2
2
  import type { WorkflowNodeContext } from "../workflows/types.js";
3
3
 
4
4
  const MIN_INTERVAL_MINUTES = 1;
5
5
  const MAX_INTERVAL_MINUTES = 24 * 60;
6
+ const MIN_CHECK_TIMEOUT_MINUTES = 5;
7
+ const MAX_CHECK_TIMEOUT_MINUTES = 24 * 60;
8
+ const DEFAULT_MIN_CHECK_TIMEOUT_MINUTES = 60;
6
9
  const DEFAULT_MAX_CHECKS = 1_000;
7
10
  const MAX_CHECKS = 1_000;
8
11
  const MAX_OBSERVATION_CHARS = 8_000;
@@ -17,6 +20,7 @@ type MonitorInput = {
17
20
  reportWhen?: string;
18
21
  stopWhen?: string;
19
22
  maxChecks?: number;
23
+ checkTimeoutMinutes?: number;
20
24
  };
21
25
 
22
26
  type MonitorConfig = {
@@ -25,6 +29,7 @@ type MonitorConfig = {
25
29
  reportWhen: string;
26
30
  stopWhen: string;
27
31
  maxChecks: number;
32
+ checkTimeoutMinutes: number;
28
33
  };
29
34
 
30
35
  type MonitorRoute = "continue_quiet" | "continue_report" | "stop_quiet" | "stop_report";
@@ -81,6 +86,17 @@ function prepareInput(input: unknown): MonitorConfig {
81
86
  if (!Number.isInteger(maxChecks) || maxChecks <= 0 || maxChecks > MAX_CHECKS) {
82
87
  throw new Error(`maxChecks must be an integer from 1 through ${MAX_CHECKS}`);
83
88
  }
89
+ const checkTimeoutMinutes =
90
+ value.checkTimeoutMinutes ?? Math.max(DEFAULT_MIN_CHECK_TIMEOUT_MINUTES, value.everyMinutes);
91
+ if (
92
+ !Number.isInteger(checkTimeoutMinutes) ||
93
+ checkTimeoutMinutes < MIN_CHECK_TIMEOUT_MINUTES ||
94
+ checkTimeoutMinutes > MAX_CHECK_TIMEOUT_MINUTES
95
+ ) {
96
+ throw new Error(
97
+ `checkTimeoutMinutes must be an integer from ${MIN_CHECK_TIMEOUT_MINUTES} through ${MAX_CHECK_TIMEOUT_MINUTES}`,
98
+ );
99
+ }
84
100
  return {
85
101
  task,
86
102
  everyMinutes: value.everyMinutes,
@@ -93,6 +109,7 @@ function prepareInput(input: unknown): MonitorConfig {
93
109
  ? "The user cancels the monitor or it reaches its maximum check count."
94
110
  : requireBoundedString(value.stopWhen, "stopWhen", 4_000),
95
111
  maxChecks,
112
+ checkTimeoutMinutes,
96
113
  };
97
114
  }
98
115
 
@@ -100,6 +117,10 @@ function configFrom(outputs: Record<string, unknown>): MonitorConfig {
100
117
  return outputs.prepare as MonitorConfig;
101
118
  }
102
119
 
120
+ function agentTimeoutMs({ outputs }: WorkflowNodeContext): number {
121
+ return configFrom(outputs).checkTimeoutMinutes * 60_000;
122
+ }
123
+
103
124
  function completedChecks(context: WorkflowNodeContext): number {
104
125
  return context.state.steps.filter((step) => step.nodeId === "check" && step.outcome === "ok")
105
126
  .length;
@@ -190,6 +211,7 @@ export default defineWorkflow({
190
211
  }),
191
212
  check: agent({
192
213
  statusDetail: "checking monitored target",
214
+ timeoutMs: agentTimeoutMs,
193
215
  prompt: (context) => {
194
216
  const config = configFrom(context.outputs);
195
217
  const previous = context.outputs.check as MonitorCheck | undefined;
@@ -212,12 +234,14 @@ export default defineWorkflow({
212
234
  }),
213
235
  report_continue: agent({
214
236
  statusDetail: "reporting monitor update",
237
+ timeoutMs: agentTimeoutMs,
215
238
  prompt: ({ outputs }) => reportPrompt(outputs),
216
239
  expectedOutput: '{ "reported": true }',
217
240
  validate: (output) => validateReportAck(output),
218
241
  }),
219
242
  report_stop: agent({
220
243
  statusDetail: "reporting final monitor update",
244
+ timeoutMs: agentTimeoutMs,
221
245
  prompt: ({ outputs }) => reportPrompt(outputs),
222
246
  expectedOutput: '{ "reported": true }',
223
247
  validate: (output) => validateReportAck(output),
@@ -101,8 +101,10 @@ type WorkflowRunQueueRow = {
101
101
  /** A user-started workflow run tracked by the durable run queue. */
102
102
  export type WorkflowRunQueueRecord = {
103
103
  runId: string;
104
- workflowRef: string;
105
- workflowPath: string;
104
+ /** Human-readable workflow name used in status and event output. */
105
+ workflowName: string;
106
+ /** Canonical source reference used to reopen the run. */
107
+ workflowSourceRef: string;
106
108
  input: unknown;
107
109
  status: "claimed" | "parked" | "done";
108
110
  runnerId: string | null;
@@ -866,8 +868,8 @@ export class SqliteControllerStore implements ControllerStore {
866
868
  */
867
869
  enqueueWorkflowRun(options: {
868
870
  runId: string;
869
- workflowRef: string;
870
- workflowPath: string;
871
+ workflowName: string;
872
+ workflowSourceRef: string;
871
873
  input: unknown;
872
874
  runnerId: string;
873
875
  claimToken: string;
@@ -877,8 +879,8 @@ export class SqliteControllerStore implements ControllerStore {
877
879
  now?: string;
878
880
  }): WorkflowRunQueueRecord {
879
881
  validateRunId(options.runId);
880
- validateKey(options.workflowRef, "workflow ref");
881
- validateKey(options.workflowPath, "workflow path");
882
+ validateKey(options.workflowName, "workflow name");
883
+ validateKey(options.workflowSourceRef, "workflow source ref");
882
884
  validateKey(options.runnerId, "runner id");
883
885
  validateKey(options.claimToken, "claim token");
884
886
  validateDuration(options.leaseMs, "leaseMs");
@@ -896,8 +898,8 @@ export class SqliteControllerStore implements ControllerStore {
896
898
  )
897
899
  .run(
898
900
  options.runId,
899
- options.workflowRef,
900
- options.workflowPath,
901
+ options.workflowName,
902
+ options.workflowSourceRef,
901
903
  inputJson,
902
904
  options.runnerId,
903
905
  options.claimToken,
@@ -1068,6 +1070,122 @@ export class SqliteControllerStore implements ControllerStore {
1068
1070
  return result.changes === 1;
1069
1071
  }
1070
1072
 
1073
+ /** Repair a canonical bundle's queue source and claim it only when needed. */
1074
+ repairCanonicalWorkflowSourceRun(options: {
1075
+ runId: string;
1076
+ workflowName: string;
1077
+ workflowSourceRef: string;
1078
+ runnerId: string;
1079
+ claimToken: string;
1080
+ leaseMs: number;
1081
+ now?: string;
1082
+ }): "unchanged" | "claimed" | false {
1083
+ validateRunId(options.runId);
1084
+ validateKey(options.workflowName, "workflow name");
1085
+ validateKey(options.workflowSourceRef, "workflow source ref");
1086
+ validateKey(options.runnerId, "runner id");
1087
+ validateKey(options.claimToken, "claim token");
1088
+ validateDuration(options.leaseMs, "leaseMs");
1089
+ const now = validTimestamp(options.now);
1090
+ const nowMs = epoch(now);
1091
+ const expiresAt = nowMs + options.leaseMs;
1092
+ return this.transaction(() => {
1093
+ const row = this.database
1094
+ .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
1095
+ .get(options.runId) as WorkflowRunQueueRow | undefined;
1096
+ if (row === undefined || row.status === "done") return false;
1097
+ if (row.workflow_path === options.workflowSourceRef && row.status === "parked") {
1098
+ return "unchanged";
1099
+ }
1100
+ const claimable =
1101
+ row.status === "parked" ||
1102
+ (row.status === "claimed" &&
1103
+ row.claim_token === options.claimToken &&
1104
+ row.claim_expires_at !== null &&
1105
+ row.claim_expires_at > nowMs) ||
1106
+ (row.status === "claimed" &&
1107
+ row.claim_expires_at !== null &&
1108
+ row.claim_expires_at <= nowMs);
1109
+ if (!claimable) return false;
1110
+ const result = this.database
1111
+ .prepare(
1112
+ `UPDATE workflow_run_queue
1113
+ SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
1114
+ claim_token = ?, claim_expires_at = ?, updated_at = ?
1115
+ WHERE run_id = ? AND status != 'done'`,
1116
+ )
1117
+ .run(
1118
+ options.workflowName,
1119
+ options.workflowSourceRef,
1120
+ options.runnerId,
1121
+ options.claimToken,
1122
+ expiresAt,
1123
+ now,
1124
+ options.runId,
1125
+ );
1126
+ return result.changes === 1 ? "claimed" : false;
1127
+ });
1128
+ }
1129
+
1130
+ /** Claim and rewrite one proved legacy workflow source queue row atomically. */
1131
+ claimLegacyWorkflowSourceRun(options: {
1132
+ runId: string;
1133
+ workflowName: string;
1134
+ oldWorkflowPath: string;
1135
+ workflowSourceRef: string;
1136
+ runnerId: string;
1137
+ claimToken: string;
1138
+ leaseMs: number;
1139
+ now?: string;
1140
+ }): boolean {
1141
+ validateRunId(options.runId);
1142
+ validateKey(options.workflowName, "workflow name");
1143
+ validateKey(options.oldWorkflowPath, "legacy workflow path");
1144
+ validateKey(options.workflowSourceRef, "workflow source ref");
1145
+ validateKey(options.runnerId, "runner id");
1146
+ validateKey(options.claimToken, "claim token");
1147
+ validateDuration(options.leaseMs, "leaseMs");
1148
+ const now = validTimestamp(options.now);
1149
+ const nowMs = epoch(now);
1150
+ const expiresAt = nowMs + options.leaseMs;
1151
+ return this.transaction(() => {
1152
+ const row = this.database
1153
+ .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
1154
+ .get(options.runId) as WorkflowRunQueueRow | undefined;
1155
+ if (row === undefined || row.status === "done") return false;
1156
+ const sourceMatches =
1157
+ row.workflow_path === options.oldWorkflowPath ||
1158
+ row.workflow_path === options.workflowSourceRef;
1159
+ const claimable =
1160
+ row.status === "parked" ||
1161
+ (row.status === "claimed" &&
1162
+ row.claim_token === options.claimToken &&
1163
+ row.claim_expires_at !== null &&
1164
+ row.claim_expires_at > nowMs) ||
1165
+ (row.status === "claimed" &&
1166
+ row.claim_expires_at !== null &&
1167
+ row.claim_expires_at <= nowMs);
1168
+ if (!sourceMatches || !claimable) return false;
1169
+ const result = this.database
1170
+ .prepare(
1171
+ `UPDATE workflow_run_queue
1172
+ SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
1173
+ claim_token = ?, claim_expires_at = ?, updated_at = ?
1174
+ WHERE run_id = ? AND status != 'done'`,
1175
+ )
1176
+ .run(
1177
+ options.workflowName,
1178
+ options.workflowSourceRef,
1179
+ options.runnerId,
1180
+ options.claimToken,
1181
+ expiresAt,
1182
+ now,
1183
+ options.runId,
1184
+ );
1185
+ return result.changes === 1;
1186
+ });
1187
+ }
1188
+
1071
1189
  /** Append a run lifecycle transition to the event feed. */
1072
1190
  recordRunEvent(options: {
1073
1191
  runId: string;
@@ -1332,8 +1450,8 @@ function workflowFromRow(row: WorkflowRow): ChildWorkflowRecord {
1332
1450
  function workflowRunFromRow(row: WorkflowRunQueueRow): WorkflowRunQueueRecord {
1333
1451
  return {
1334
1452
  runId: row.run_id,
1335
- workflowRef: row.workflow_ref,
1336
- workflowPath: row.workflow_path,
1453
+ workflowName: row.workflow_ref,
1454
+ workflowSourceRef: row.workflow_path,
1337
1455
  input: parseStoredJson(row.input_json, "workflow run input"),
1338
1456
  status: row.status,
1339
1457
  runnerId: row.runner_id,
@@ -4,6 +4,7 @@ import type {
4
4
  WorkflowDefinition,
5
5
  WorkflowRunResult,
6
6
  WorkflowRunStatus,
7
+ WorkflowSource,
7
8
  } from "../workflows/types.js";
8
9
  import type {
9
10
  ControllerWorkflowScheduler,
@@ -13,7 +14,7 @@ import type {
13
14
 
14
15
  export type ResolvedChildWorkflow = {
15
16
  workflow: WorkflowDefinition;
16
- workflowPath?: string;
17
+ workflowSource?: WorkflowSource;
17
18
  };
18
19
 
19
20
  export type WorkflowEngineSchedulerOptions = {
@@ -77,7 +78,9 @@ export class WorkflowEngineScheduler implements ControllerWorkflowScheduler {
77
78
  const promise = engine
78
79
  .run(resolved.workflow, request.input, {
79
80
  runId,
80
- ...(resolved.workflowPath !== undefined ? { workflowPath: resolved.workflowPath } : {}),
81
+ ...(resolved.workflowSource !== undefined
82
+ ? { workflowSource: resolved.workflowSource }
83
+ : {}),
81
84
  })
82
85
  .then((result) => {
83
86
  callCompletion(onComplete, resultFromRun(result));
@@ -33,6 +33,8 @@ export type ConversationStepExecutorOptions = {
33
33
  maxNudges?: number;
34
34
  /** Conversation linkage hooks, wired to the session recorder. */
35
35
  conversation?: ConversationHooks;
36
+ /** Called when the engine aborts a pending agent step. */
37
+ onAbort?: (contract: AgentStepRequest["contract"], reason: unknown) => void;
36
38
  };
37
39
 
38
40
  type PendingStep = {
@@ -61,6 +63,7 @@ export class ConversationStepExecutor implements AgentStepExecutor {
61
63
  private readonly sendPrompt: (delivery: PromptDelivery) => void;
62
64
  private readonly maxNudges: number;
63
65
  private readonly conversation: ConversationHooks | undefined;
66
+ private readonly onAbort: ConversationStepExecutorOptions["onAbort"];
64
67
  private pending: PendingStep | null = null;
65
68
  private streaming = false;
66
69
  private heldByUser = false;
@@ -69,6 +72,7 @@ export class ConversationStepExecutor implements AgentStepExecutor {
69
72
  this.sendPrompt = options.sendPrompt;
70
73
  this.maxNudges = options.maxNudges ?? DEFAULT_MAX_NUDGES;
71
74
  this.conversation = options.conversation;
75
+ this.onAbort = options.onAbort;
72
76
  }
73
77
 
74
78
  /** Track agent streaming state (wire to agent_start / agent_settled). */
@@ -121,8 +125,15 @@ export class ConversationStepExecutor implements AgentStepExecutor {
121
125
  return await new Promise<AgentStepSubmission>((resolve, reject) => {
122
126
  const onAbort = () => {
123
127
  const reason: unknown = signal.reason ?? new Error("Workflow step aborted");
128
+ if (this.pending?.request !== request) {
129
+ return;
130
+ }
124
131
  this.clearPending();
125
- reject(reason);
132
+ try {
133
+ this.onAbort?.(request.contract, reason);
134
+ } finally {
135
+ reject(reason);
136
+ }
126
137
  };
127
138
  signal.addEventListener("abort", onAbort, { once: true });
128
139
  let markCleared!: () => void;
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
2
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { builtinWorkflowCatalog } from "../builtins/catalog.js";
3
5
  import {
4
6
  projectControllerStorePath,
5
7
  type RunEventRecord,
@@ -8,13 +10,14 @@ import {
8
10
  import type { JsonObject } from "../controllers/types.js";
9
11
  import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
10
12
  import { WorkflowEngine } from "../workflows/engine.js";
11
- import { ClaimLostError, errorMessage, isClaimLostError } from "../workflows/errors.js";
12
13
  import {
13
- discoverWorkflows,
14
- hashWorkflowSource,
15
- loadWorkflowFile,
16
- resolveWorkflowRef,
17
- } from "../workflows/loader.js";
14
+ ClaimLostError,
15
+ errorMessage,
16
+ isClaimLostError,
17
+ TimeoutError,
18
+ } from "../workflows/errors.js";
19
+ import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
20
+ import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
18
21
  import {
19
22
  createRunId,
20
23
  listRunBundles,
@@ -24,6 +27,7 @@ import {
24
27
  createDefinitionSnapshot,
25
28
  } from "../workflows/store.js";
26
29
  import type {
30
+ AgentStepContract,
27
31
  WorkflowDefinition,
28
32
  WorkflowDefinitionSnapshot,
29
33
  WorkflowRunResult,
@@ -207,6 +211,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
207
211
  // One runner identity per session; it names this session in run claims.
208
212
  const runnerId = randomUUID();
209
213
  let runQueueStore: SqliteControllerStore | null = null;
214
+ const migrationBlockedRuns = new Set<string>();
210
215
  const ensureRunQueueStore = (cwd: string): SqliteControllerStore => {
211
216
  runQueueStore ??= new SqliteControllerStore(projectControllerStorePath(cwd));
212
217
  return runQueueStore;
@@ -308,7 +313,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
308
313
  const rows = runQueueStore.listWorkflowRuns();
309
314
  for (const row of rows) {
310
315
  if (row.status === "parked") {
311
- lines.push(`${row.workflowRef} run ${row.runId} is parked and will resume`);
316
+ lines.push(`${row.workflowName} run ${row.runId} is parked and will resume`);
312
317
  }
313
318
  }
314
319
  const known = new Set(rows.map((row) => row.runId));
@@ -348,6 +353,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
348
353
  runSyncTimer.unref?.();
349
354
  };
350
355
  let activeRun: ActiveRun | null = null;
356
+ let systemTurnAbort: AgentStepContract | null = null;
357
+ let lastExpiredAttempt: { contract: AgentStepContract; reason: string } | null = null;
351
358
  let pendingToolLaunch: {
352
359
  ctx: ExtensionContext;
353
360
  ref: string;
@@ -506,7 +513,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
506
513
  if (
507
514
  sessionClosed ||
508
515
  run.generation !== runGeneration ||
509
- state.status === "cancelled" ||
516
+ (state.status !== "completed" && state.status !== "waiting") ||
510
517
  run.presentationPrompt === undefined
511
518
  ) {
512
519
  return;
@@ -639,7 +646,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
639
646
  const summary =
640
647
  state.status === "waiting" && state.waitingOn
641
648
  ? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
642
- : `Workflow ${state.workflowName} ${state.status} (run ${state.runId})`;
649
+ : `Workflow ${state.workflowName} ${state.status} (run ${state.runId})${
650
+ state.error !== undefined ? `: ${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}` : ""
651
+ }`;
643
652
  notify(ctx, summary, state.status === "completed" ? "info" : "warning");
644
653
  try {
645
654
  const childResult =
@@ -689,13 +698,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
689
698
  }
690
699
  supersedePresentation();
691
700
  const generation = runGeneration;
692
- const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
693
- const workflow = await loadWorkflowFile(resolved.path);
701
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
702
+ const workflow = resolved.definition;
694
703
  if (options.signal?.aborted) {
695
704
  throw options.signal.reason ?? new Error("Workflow startup aborted");
696
705
  }
697
706
  const snapshot = createDefinitionSnapshot(workflow);
698
- const workflowHash = await hashWorkflowSource(resolved.path);
707
+ const workflowSource = resolved.source;
699
708
  const runId = options.runId ?? createRunId(workflow.name);
700
709
 
701
710
  // Continuations validate the parent before touching the queue: a
@@ -706,7 +715,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
706
715
  if (parent === null || parent.state.status !== "waiting") {
707
716
  throw new Error(`Workflow run ${options.parentRunId} is not waiting at a checkpoint`);
708
717
  }
709
- if (parent.state.workflowHash !== undefined && parent.state.workflowHash !== workflowHash) {
718
+ if (
719
+ parent.state.workflowSource !== undefined &&
720
+ !isDeepStrictEqual(parent.state.workflowSource, workflowSource)
721
+ ) {
710
722
  throw new Error(
711
723
  `Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`,
712
724
  );
@@ -725,8 +737,11 @@ export default function piWorkflows(pi: ExtensionAPI) {
725
737
  const token = randomUUID();
726
738
  queueStore.enqueueWorkflowRun({
727
739
  runId,
728
- workflowRef: ref,
729
- workflowPath: resolved.path,
740
+ workflowName: workflow.name,
741
+ workflowSourceRef:
742
+ workflowSource.kind === "builtin"
743
+ ? `builtin:${workflowSource.id}`
744
+ : workflowSource.path,
730
745
  input,
731
746
  runnerId,
732
747
  claimToken: token,
@@ -757,6 +772,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
757
772
  sendPrompt: ({ prompt, streaming }) => {
758
773
  pi.sendUserMessage(prompt, streaming ? { deliverAs: "steer" } : undefined);
759
774
  },
775
+ onAbort: (contract, reason) => {
776
+ lastExpiredAttempt = {
777
+ contract,
778
+ reason: reason instanceof TimeoutError ? "timed out" : `ended: ${errorMessage(reason)}`,
779
+ };
780
+ if (!executor.held && !ctx.isIdle()) {
781
+ systemTurnAbort = contract;
782
+ ctx.abort();
783
+ }
784
+ },
760
785
  conversation: {
761
786
  beginAttempt: (contract) => run.recorder?.beginAttempt(contract),
762
787
  mark: () => run.recorder?.mark() ?? 0,
@@ -840,12 +865,11 @@ export default function piWorkflows(pi: ExtensionAPI) {
840
865
 
841
866
  run.completion = (
842
867
  options.resume === true
843
- ? engine.resumeRun(workflow, runId, { workflowHash })
868
+ ? engine.resumeRun(workflow, runId, { workflowSource })
844
869
  : options.parentRunId === undefined
845
- ? engine.run(workflow, input, { workflowPath: resolved.path, workflowHash, runId })
870
+ ? engine.run(workflow, input, { workflowSource, runId })
846
871
  : engine.continueRun(workflow, options.parentRunId, input, {
847
- workflowPath: resolved.path,
848
- workflowHash,
872
+ workflowSource,
849
873
  runId,
850
874
  })
851
875
  )
@@ -955,13 +979,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
955
979
  runnerId,
956
980
  claimToken,
957
981
  leaseMs: RUN_CLAIM_LEASE_MS,
982
+ excludeRunIds: [...migrationBlockedRuns],
958
983
  });
959
984
  if (claimed === undefined) {
960
985
  return;
961
986
  }
962
987
  let started: string | undefined;
963
988
  try {
964
- started = await startRun(ctx, claimed.workflowPath, claimed.input, {
989
+ const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(claimed.runId));
990
+ const sourceRef =
991
+ bundle?.state.workflowSource === undefined
992
+ ? claimed.workflowSourceRef
993
+ : bundle.state.workflowSource.kind === "builtin"
994
+ ? `builtin:${bundle.state.workflowSource.id}`
995
+ : bundle.state.workflowSource.path;
996
+ started = await startRun(ctx, sourceRef, claimed.input, {
965
997
  resume: true,
966
998
  runId: claimed.runId,
967
999
  claimToken,
@@ -971,7 +1003,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
971
1003
  throw error;
972
1004
  }
973
1005
  if (started !== undefined) {
974
- notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.workflowRef}).`);
1006
+ notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.workflowName}).`);
975
1007
  } else {
976
1008
  queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
977
1009
  }
@@ -1051,7 +1083,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1051
1083
  ctx: ExtensionContext,
1052
1084
  offset = 0,
1053
1085
  ): Promise<WorkflowControlResult> => {
1054
- const discovered = await discoverWorkflows({ cwd: ctx.cwd });
1086
+ const discovered = await discoverWorkflows({ cwd: ctx.cwd }, builtinWorkflowCatalog);
1055
1087
  if (discovered.length === 0) {
1056
1088
  return {
1057
1089
  message:
@@ -1255,7 +1287,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1255
1287
  const resolveWaitingWorkflow = async (
1256
1288
  ctx: ExtensionContext,
1257
1289
  requestedRunId?: string,
1258
- ): Promise<{ parentRunId: string; workflowPath: string }> => {
1290
+ ): Promise<{ parentRunId: string; workflowRef: string }> => {
1259
1291
  let parentRunId = requestedRunId ?? lastWaitingRunId;
1260
1292
  if (parentRunId === null) {
1261
1293
  const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
@@ -1279,14 +1311,20 @@ export default function piWorkflows(pi: ExtensionAPI) {
1279
1311
  if (
1280
1312
  parent === null ||
1281
1313
  parent.state.status !== "waiting" ||
1282
- parent.state.workflowPath === undefined
1314
+ parent.state.workflowSource === undefined
1283
1315
  ) {
1284
1316
  if (parentRunId === lastWaitingRunId) {
1285
1317
  lastWaitingRunId = null;
1286
1318
  }
1287
1319
  throw new Error(`Workflow run ${parentRunId} is no longer waiting.`);
1288
1320
  }
1289
- return { parentRunId, workflowPath: parent.state.workflowPath };
1321
+ return {
1322
+ parentRunId,
1323
+ workflowRef:
1324
+ parent.state.workflowSource.kind === "builtin"
1325
+ ? `builtin:${parent.state.workflowSource.id}`
1326
+ : parent.state.workflowSource.path,
1327
+ };
1290
1328
  };
1291
1329
 
1292
1330
  const answerWorkflowControl = async (
@@ -1295,7 +1333,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1295
1333
  requestedRunId?: string,
1296
1334
  ): Promise<WorkflowControlResult> => {
1297
1335
  const waiting = await resolveWaitingWorkflow(ctx, requestedRunId);
1298
- const continued = await startRun(ctx, waiting.workflowPath, input, {
1336
+ const continued = await startRun(ctx, waiting.workflowRef, input, {
1299
1337
  parentRunId: waiting.parentRunId,
1300
1338
  });
1301
1339
  if (continued === undefined) {
@@ -1356,8 +1394,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
1356
1394
  const reservation = { ctx, ref, input, options };
1357
1395
  pendingToolLaunch = reservation;
1358
1396
  try {
1359
- const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
1360
- const workflow = await loadWorkflowFile(resolved.path);
1397
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
1398
+ const workflow = resolved.definition;
1361
1399
  if (pendingToolLaunch !== reservation) {
1362
1400
  throw new Error("The queued workflow launch was cancelled before validation finished.");
1363
1401
  }
@@ -1382,7 +1420,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1382
1420
  description:
1383
1421
  "Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer",
1384
1422
  getArgumentCompletions: async (prefix: string) => {
1385
- const discovered = await discoverWorkflows({ cwd: process.cwd() });
1423
+ const discovered = await discoverWorkflows({ cwd: process.cwd() }, builtinWorkflowCatalog);
1386
1424
  const items = [
1387
1425
  ...discovered.map((workflow) => ({ value: workflow.name, label: workflow.name })),
1388
1426
  { value: "status", label: "status" },
@@ -1556,13 +1594,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
1556
1594
  break;
1557
1595
  case "answer": {
1558
1596
  const waiting = await resolveWaitingWorkflow(ctx, params.runId);
1559
- control = await queueToolLaunch(ctx, waiting.workflowPath, params.input, {
1597
+ control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
1560
1598
  parentRunId: waiting.parentRunId,
1561
1599
  });
1562
1600
  break;
1563
1601
  }
1564
1602
  case "submit": {
1565
1603
  if (!activeRun) {
1604
+ if (
1605
+ lastExpiredAttempt?.contract.attemptId === params.attempt &&
1606
+ lastExpiredAttempt.contract.nodeId === params.step
1607
+ ) {
1608
+ throw new Error(
1609
+ `Workflow step ${JSON.stringify(params.step)} attempt ${JSON.stringify(params.attempt)} ${lastExpiredAttempt.reason}; its output is no longer accepted.`,
1610
+ );
1611
+ }
1566
1612
  throw new Error("No workflow step is waiting for output.");
1567
1613
  }
1568
1614
  // Flush the conversation into the bundle before accepting, so the
@@ -1603,6 +1649,24 @@ export default function piWorkflows(pi: ExtensionAPI) {
1603
1649
  pi.on("session_start", async (_event, ctx) => {
1604
1650
  sessionClosed = false;
1605
1651
  controllerContext = ctx;
1652
+ try {
1653
+ const queue = ensureRunQueueStore(ctx.cwd);
1654
+ const migration = await migrateLegacyWorkflowSources({
1655
+ catalog: builtinWorkflowCatalog,
1656
+ queue,
1657
+ });
1658
+ migrationBlockedRuns.clear();
1659
+ for (const blocked of migration.blocked) migrationBlockedRuns.add(blocked.runId);
1660
+ if (migration.blocked.length > 0) {
1661
+ notify(
1662
+ ctx,
1663
+ `Could not migrate ${migration.blocked.length} legacy workflow source(s).`,
1664
+ "warning",
1665
+ );
1666
+ }
1667
+ } catch (error) {
1668
+ notify(ctx, `Could not migrate legacy workflow sources: ${errorMessage(error)}`, "warning");
1669
+ }
1606
1670
  try {
1607
1671
  syncArmed = true;
1608
1672
  startRunSync(ctx);
@@ -1632,13 +1696,6 @@ export default function piWorkflows(pi: ExtensionAPI) {
1632
1696
  });
1633
1697
 
1634
1698
  pi.on("agent_end", (event, ctx) => {
1635
- const run = activeRun;
1636
- if (!run) {
1637
- return;
1638
- }
1639
- // An aborted turn means the user hit escape to take the conversation
1640
- // back. Nudging or dispatching the next step would immediately steal it
1641
- // again, so hold the run until an explicit /workflow resume.
1642
1699
  const aborted = event.messages.some(
1643
1700
  (message) =>
1644
1701
  typeof message === "object" &&
@@ -1646,6 +1703,17 @@ export default function piWorkflows(pi: ExtensionAPI) {
1646
1703
  "stopReason" in message &&
1647
1704
  (message as { stopReason?: string }).stopReason === "aborted",
1648
1705
  );
1706
+ if (aborted && systemTurnAbort !== null) {
1707
+ systemTurnAbort = null;
1708
+ return;
1709
+ }
1710
+ const run = activeRun;
1711
+ if (!run) {
1712
+ return;
1713
+ }
1714
+ // An aborted turn means the user hit escape to take the conversation
1715
+ // back. Nudging or dispatching the next step would immediately steal it
1716
+ // again, so hold the run until an explicit /workflow resume.
1649
1717
  if (!aborted || runHeld()) {
1650
1718
  return;
1651
1719
  }
@@ -1719,6 +1787,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1719
1787
 
1720
1788
  pi.on("session_shutdown", async () => {
1721
1789
  sessionClosed = true;
1790
+ systemTurnAbort = null;
1722
1791
  supersedePresentation();
1723
1792
  const run = activeRun;
1724
1793
  if (run !== null && run.claimToken !== undefined) {