@osolmaz/pi-workflows 0.14.0 → 0.15.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 (235) hide show
  1. package/README.md +78 -110
  2. package/dist/builtins/autoimplement.workflow.js +3 -1
  3. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  4. package/dist/builtins/change-verification.workflow.js +7 -2
  5. package/dist/builtins/change-verification.workflow.js.map +1 -1
  6. package/dist/builtins/metadata.d.ts +22 -0
  7. package/dist/builtins/metadata.js +10 -0
  8. package/dist/builtins/metadata.js.map +1 -0
  9. package/dist/builtins/monitor.workflow.js +4 -1
  10. package/dist/builtins/monitor.workflow.js.map +1 -1
  11. package/dist/builtins/sanity-check.workflow.js +4 -1
  12. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  13. package/dist/builtins/workspace-preparation.workflow.js +3 -1
  14. package/dist/builtins/workspace-preparation.workflow.js.map +1 -1
  15. package/dist/controllers/index.d.ts +0 -1
  16. package/dist/controllers/index.js +0 -1
  17. package/dist/controllers/index.js.map +1 -1
  18. package/dist/controllers/sqlite.d.ts +51 -0
  19. package/dist/controllers/sqlite.js +433 -57
  20. package/dist/controllers/sqlite.js.map +1 -1
  21. package/dist/controllers/store.d.ts +1 -0
  22. package/dist/extension/controller-command.d.ts +22 -0
  23. package/dist/extension/controller-command.js +32 -0
  24. package/dist/extension/controller-command.js.map +1 -0
  25. package/dist/extension/index.d.ts +2 -9
  26. package/dist/extension/index.js +876 -3447
  27. package/dist/extension/index.js.map +1 -1
  28. package/dist/extension/message-card.d.ts +13 -0
  29. package/dist/extension/message-card.js +46 -0
  30. package/dist/extension/message-card.js.map +1 -0
  31. package/dist/extension/step-message.d.ts +5 -6
  32. package/dist/extension/step-message.js +41 -52
  33. package/dist/extension/step-message.js.map +1 -1
  34. package/dist/host/child-worker-supervisor.d.ts +50 -0
  35. package/dist/host/child-worker-supervisor.js +159 -0
  36. package/dist/host/child-worker-supervisor.js.map +1 -0
  37. package/dist/host/client.d.ts +48 -0
  38. package/dist/host/client.js +216 -0
  39. package/dist/host/client.js.map +1 -0
  40. package/dist/host/controller-worker-entry.d.ts +2 -0
  41. package/dist/host/controller-worker-entry.js +244 -0
  42. package/dist/host/controller-worker-entry.js.map +1 -0
  43. package/dist/host/controller-worker-protocol.d.ts +36 -0
  44. package/dist/host/controller-worker-protocol.js +49 -0
  45. package/dist/host/controller-worker-protocol.js.map +1 -0
  46. package/dist/host/controller-worker-supervisor.d.ts +21 -0
  47. package/dist/host/controller-worker-supervisor.js +54 -0
  48. package/dist/host/controller-worker-supervisor.js.map +1 -0
  49. package/dist/host/host-entry.d.ts +2 -0
  50. package/dist/host/host-entry.js +23 -0
  51. package/dist/host/host-entry.js.map +1 -0
  52. package/dist/host/processes.d.ts +17 -12
  53. package/dist/host/processes.js +154 -50
  54. package/dist/host/processes.js.map +1 -1
  55. package/dist/host/protocol.d.ts +38 -0
  56. package/dist/host/protocol.js +156 -0
  57. package/dist/host/protocol.js.map +1 -0
  58. package/dist/host/resolver-entry.d.ts +49 -0
  59. package/dist/host/resolver-entry.js +149 -0
  60. package/dist/host/resolver-entry.js.map +1 -0
  61. package/dist/host/rpc-executor.d.ts +14 -3
  62. package/dist/host/rpc-executor.js +63 -30
  63. package/dist/host/rpc-executor.js.map +1 -1
  64. package/dist/host/runner.d.ts +95 -29
  65. package/dist/host/runner.js +2311 -348
  66. package/dist/host/runner.js.map +1 -1
  67. package/dist/host/state.d.ts +174 -0
  68. package/dist/host/state.js +689 -0
  69. package/dist/host/state.js.map +1 -0
  70. package/dist/host/worker-entry.d.ts +10 -0
  71. package/dist/host/worker-entry.js +446 -0
  72. package/dist/host/worker-entry.js.map +1 -0
  73. package/dist/host/worker-protocol.d.ts +31 -0
  74. package/dist/host/worker-protocol.js +122 -0
  75. package/dist/host/worker-protocol.js.map +1 -0
  76. package/dist/host/worker-store.d.ts +80 -0
  77. package/dist/host/worker-store.js +91 -0
  78. package/dist/host/worker-store.js.map +1 -0
  79. package/dist/host/worker-supervisor.d.ts +22 -0
  80. package/dist/host/worker-supervisor.js +54 -0
  81. package/dist/host/worker-supervisor.js.map +1 -0
  82. package/dist/render/canvas.d.ts +2 -1
  83. package/dist/render/canvas.js +40 -15
  84. package/dist/render/canvas.js.map +1 -1
  85. package/dist/render/graph-render.d.ts +7 -3
  86. package/dist/render/graph-render.js +125 -74
  87. package/dist/render/graph-render.js.map +1 -1
  88. package/dist/state/database.js +3 -1
  89. package/dist/state/database.js.map +1 -1
  90. package/dist/state/index.d.ts +1 -0
  91. package/dist/state/index.js +1 -0
  92. package/dist/state/index.js.map +1 -1
  93. package/dist/state/schema.d.ts +1 -1
  94. package/dist/state/schema.js +171 -3
  95. package/dist/state/schema.js.map +1 -1
  96. package/dist/state/viewer.d.ts +46 -0
  97. package/dist/state/viewer.js +249 -0
  98. package/dist/state/viewer.js.map +1 -0
  99. package/dist/viewer/cli.d.ts +1 -1
  100. package/dist/viewer/cli.js +47 -18
  101. package/dist/viewer/cli.js.map +1 -1
  102. package/dist/workflows/composition.js +25 -2
  103. package/dist/workflows/composition.js.map +1 -1
  104. package/dist/workflows/definition.d.ts +3 -1
  105. package/dist/workflows/definition.js +25 -0
  106. package/dist/workflows/definition.js.map +1 -1
  107. package/dist/workflows/engine.d.ts +1 -0
  108. package/dist/workflows/engine.js +157 -42
  109. package/dist/workflows/engine.js.map +1 -1
  110. package/dist/workflows/errors.d.ts +3 -1
  111. package/dist/workflows/errors.js +4 -7
  112. package/dist/workflows/errors.js.map +1 -1
  113. package/dist/workflows/human-decision.d.ts +3 -0
  114. package/dist/workflows/human-decision.js +31 -0
  115. package/dist/workflows/human-decision.js.map +1 -1
  116. package/dist/workflows/index.d.ts +1 -1
  117. package/dist/workflows/index.js +1 -1
  118. package/dist/workflows/index.js.map +1 -1
  119. package/dist/workflows/schema.js +17 -6
  120. package/dist/workflows/schema.js.map +1 -1
  121. package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
  122. package/dist/{viewer → workflows}/session-reducer.js +4 -0
  123. package/dist/workflows/session-reducer.js.map +1 -0
  124. package/dist/workflows/store.d.ts +87 -1
  125. package/dist/workflows/store.js +892 -93
  126. package/dist/workflows/store.js.map +1 -1
  127. package/dist/workflows/tool-input.d.ts +0 -26
  128. package/dist/workflows/tool-input.js +2 -50
  129. package/dist/workflows/tool-input.js.map +1 -1
  130. package/dist/workflows/types.d.ts +36 -4
  131. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
  132. package/docs/CONTROLLERS.md +11 -11
  133. package/docs/DEFERRED_TURNS.md +23 -2
  134. package/docs/SQLITE_STATE.md +41 -31
  135. package/docs/WORKFLOW_HOST.md +452 -0
  136. package/docs/development.md +46 -30
  137. package/docs/live-replay-protocol.md +129 -100
  138. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +42 -0
  139. package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
  140. package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
  141. package/docs/plans/tui-viewer-implementation-plan.md +5 -0
  142. package/docs/tui-viewer.md +18 -3
  143. package/docs/workflows.md +162 -210
  144. package/examples/workflows/command-batch.workflow.ts +2 -0
  145. package/examples/workflows/shell.workflow.ts +2 -1
  146. package/herdr-plugin.toml +1 -1
  147. package/package.json +1 -1
  148. package/src/builtins/autoimplement.workflow.ts +3 -0
  149. package/src/builtins/change-verification.workflow.ts +7 -2
  150. package/src/builtins/metadata.ts +9 -0
  151. package/src/builtins/monitor.workflow.ts +4 -0
  152. package/src/builtins/sanity-check.workflow.ts +4 -0
  153. package/src/builtins/workspace-preparation.workflow.ts +3 -1
  154. package/src/controllers/index.ts +0 -5
  155. package/src/controllers/sqlite.ts +709 -76
  156. package/src/controllers/store.ts +1 -0
  157. package/src/extension/controller-command.ts +45 -0
  158. package/src/extension/index.ts +944 -4163
  159. package/src/extension/message-card.ts +61 -0
  160. package/src/extension/step-message.ts +58 -63
  161. package/src/host/child-worker-supervisor.ts +183 -0
  162. package/src/host/client.ts +293 -0
  163. package/src/host/controller-worker-entry.ts +311 -0
  164. package/src/host/controller-worker-protocol.ts +104 -0
  165. package/src/host/controller-worker-supervisor.ts +79 -0
  166. package/src/host/host-entry.ts +23 -0
  167. package/src/host/processes.ts +171 -54
  168. package/src/host/protocol.ts +196 -0
  169. package/src/host/resolver-entry.ts +241 -0
  170. package/src/host/rpc-executor.ts +76 -34
  171. package/src/host/runner.ts +2813 -422
  172. package/src/host/state.ts +1160 -0
  173. package/src/host/worker-entry.ts +533 -0
  174. package/src/host/worker-protocol.ts +165 -0
  175. package/src/host/worker-store.ts +229 -0
  176. package/src/host/worker-supervisor.ts +74 -0
  177. package/src/render/canvas.ts +44 -10
  178. package/src/render/graph-render.ts +145 -90
  179. package/src/state/database.ts +2 -1
  180. package/src/state/index.ts +14 -0
  181. package/src/state/schema.ts +171 -3
  182. package/src/state/viewer.ts +356 -0
  183. package/src/viewer/cli.ts +49 -17
  184. package/src/workflows/composition.ts +36 -2
  185. package/src/workflows/definition.ts +32 -0
  186. package/src/workflows/engine.ts +157 -54
  187. package/src/workflows/errors.ts +11 -2
  188. package/src/workflows/human-decision.ts +49 -0
  189. package/src/workflows/index.ts +2 -0
  190. package/src/workflows/schema.ts +19 -6
  191. package/src/{viewer → workflows}/session-reducer.ts +11 -1
  192. package/src/workflows/store.ts +1316 -108
  193. package/src/workflows/tool-input.ts +3 -69
  194. package/src/workflows/types.ts +32 -4
  195. package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
  196. package/dist/controllers/workflow-engine-scheduler.js +0 -175
  197. package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
  198. package/dist/extension/controller-host.d.ts +0 -48
  199. package/dist/extension/controller-host.js +0 -110
  200. package/dist/extension/controller-host.js.map +0 -1
  201. package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
  202. package/dist/extension/deferred-turn-coordinator.js +0 -143
  203. package/dist/extension/deferred-turn-coordinator.js.map +0 -1
  204. package/dist/extension/deferred-turn.d.ts +0 -44
  205. package/dist/extension/deferred-turn.js +0 -110
  206. package/dist/extension/deferred-turn.js.map +0 -1
  207. package/dist/extension/executor.d.ts +0 -86
  208. package/dist/extension/executor.js +0 -311
  209. package/dist/extension/executor.js.map +0 -1
  210. package/dist/extension/follow-up-coordinator.d.ts +0 -27
  211. package/dist/extension/follow-up-coordinator.js +0 -131
  212. package/dist/extension/follow-up-coordinator.js.map +0 -1
  213. package/dist/extension/recorder.d.ts +0 -84
  214. package/dist/extension/recorder.js +0 -528
  215. package/dist/extension/recorder.js.map +0 -1
  216. package/dist/extension/restart-policy.d.ts +0 -38
  217. package/dist/extension/restart-policy.js +0 -116
  218. package/dist/extension/restart-policy.js.map +0 -1
  219. package/dist/extension/session-events.d.ts +0 -133
  220. package/dist/extension/session-events.js +0 -61
  221. package/dist/extension/session-events.js.map +0 -1
  222. package/dist/extension/terminal-decision.d.ts +0 -51
  223. package/dist/extension/terminal-decision.js +0 -110
  224. package/dist/extension/terminal-decision.js.map +0 -1
  225. package/dist/viewer/session-reducer.js.map +0 -1
  226. package/src/controllers/workflow-engine-scheduler.ts +0 -246
  227. package/src/extension/controller-host.ts +0 -167
  228. package/src/extension/deferred-turn-coordinator.ts +0 -171
  229. package/src/extension/deferred-turn.ts +0 -166
  230. package/src/extension/executor.ts +0 -411
  231. package/src/extension/follow-up-coordinator.ts +0 -151
  232. package/src/extension/recorder.ts +0 -655
  233. package/src/extension/restart-policy.ts +0 -163
  234. package/src/extension/session-events.ts +0 -121
  235. package/src/extension/terminal-decision.ts +0 -172
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { StateDatabase, workflowStatePath } from "../state/database.js";
5
5
  import { canonicalJson } from "../state/json.js";
6
6
  import { resourceIdFor, tokenHash } from "../state/mutation.js";
7
+ import { initializeViewerRun, recordViewerDeltas } from "../state/viewer.js";
7
8
  import {
8
9
  EffectRequestConflictError,
9
10
  ResourceConflictError,
@@ -51,6 +52,7 @@ export type WorkflowRunReservationOptions = {
51
52
  launchOptions?: unknown;
52
53
  runnerId: string;
53
54
  originSessionId: string;
55
+ executionMode?: "interactive" | "headless";
54
56
  parentRunId?: string;
55
57
  now?: string;
56
58
  };
@@ -76,6 +78,7 @@ export type WorkflowRunQueueRecord = {
76
78
  claimExpiresAt: string | null;
77
79
  affinityRunnerId: string | null;
78
80
  originSessionId: string | null;
81
+ executionMode: "interactive" | "headless";
79
82
  parentRunId: string | null;
80
83
  errorCode: string | null;
81
84
  errorMessage: string | null;
@@ -199,6 +202,7 @@ type RunRow = {
199
202
  errorCode: string | null;
200
203
  errorHash: Buffer | null;
201
204
  originSessionId: string | null;
205
+ executionMode: "interactive" | "headless";
202
206
  parentRunId: string | null;
203
207
  createdAt: number;
204
208
  updatedAt: number;
@@ -228,6 +232,18 @@ type EffectRow = {
228
232
  errorHash: Buffer | null;
229
233
  };
230
234
 
235
+ type CancelledRunEffectRow = {
236
+ effectId: string;
237
+ resourceId: string;
238
+ status: "pending" | "applying";
239
+ attemptCount: number;
240
+ };
241
+
242
+ type ExpiredInteractionRow = {
243
+ requestId: string;
244
+ attemptId: string;
245
+ };
246
+
231
247
  type WorkflowRow = {
232
248
  requestId: string;
233
249
  resourceUid: string;
@@ -249,7 +265,13 @@ export class SqliteControllerStore implements ControllerStore {
249
265
 
250
266
  constructor(
251
267
  filePath: string = workflowStatePath(),
252
- options: { readOnly?: boolean; projectPath?: string; state?: StateDatabase } = {},
268
+ options: {
269
+ readOnly?: boolean;
270
+ projectPath?: string;
271
+ state?: StateDatabase;
272
+ /** Host-only global view. Project-scoped mutations still require projectPath. */
273
+ global?: boolean;
274
+ } = {},
253
275
  ) {
254
276
  this.ownsState = options.state === undefined;
255
277
  this.state =
@@ -260,9 +282,13 @@ export class SqliteControllerStore implements ControllerStore {
260
282
  checkLegacyState: filePath === workflowStatePath(),
261
283
  });
262
284
  this.filePath = this.state.filePath;
263
- const projectPath = options.projectPath === undefined ? process.cwd() : options.projectPath;
264
- this.projectId =
265
- options.readOnly === true ? this.findProject(projectPath) : this.ensureProject(projectPath);
285
+ if (options.global === true) {
286
+ this.projectId = null;
287
+ } else {
288
+ const projectPath = options.projectPath === undefined ? process.cwd() : options.projectPath;
289
+ this.projectId =
290
+ options.readOnly === true ? this.findProject(projectPath) : this.ensureProject(projectPath);
291
+ }
266
292
  }
267
293
 
268
294
  close(): void {
@@ -530,10 +556,16 @@ export class SqliteControllerStore implements ControllerStore {
530
556
  ownerId: string;
531
557
  leaseMs: number;
532
558
  now?: string;
559
+ exclude?: ControllerResourceRef[];
533
560
  }): ControllerQueueClaim | undefined {
534
561
  if (options.controllers.length === 0) return undefined;
535
562
  const now = epoch(validTimestamp(options.now));
536
563
  const placeholders = options.controllers.map(() => "?").join(", ");
564
+ const exclusions = options.exclude ?? [];
565
+ const exclusionSql = exclusions
566
+ .map(() => "AND NOT (c.controller_name = ? AND c.resource_key = ?)")
567
+ .join("\n ");
568
+ const exclusionParams = exclusions.flatMap((ref) => [ref.controller, ref.key]);
537
569
  return this.state.transaction(() => {
538
570
  const row = this.state.connection
539
571
  .prepare(
@@ -542,12 +574,13 @@ export class SqliteControllerStore implements ControllerStore {
542
574
  JOIN leases l ON l.resource_id = c.resource_id
543
575
  WHERE c.project_id = ?
544
576
  AND c.controller_name IN (${placeholders})
577
+ ${exclusionSql}
545
578
  AND q.available_at <= ?
546
579
  AND (l.owner_id IS NULL OR l.expires_at <= ?)
547
580
  ORDER BY q.available_at, c.controller_name, c.resource_key
548
581
  LIMIT 1`)}`,
549
582
  )
550
- .get(this.requireProjectId(), ...options.controllers, now, now);
583
+ .get(this.requireProjectId(), ...options.controllers, ...exclusionParams, now, now);
551
584
  if (!isControllerRow(row)) return undefined;
552
585
  const lease = this.requireLease(row.resourceId);
553
586
  const token = randomBytes(32).toString("base64url");
@@ -1122,13 +1155,14 @@ export class SqliteControllerStore implements ControllerStore {
1122
1155
  now,
1123
1156
  now,
1124
1157
  );
1158
+ initializeViewerRun(this.state, options.runId, now);
1125
1159
  insertQueuedRunSources(this.state, options.runId, queuedSource);
1126
1160
  this.state.connection
1127
1161
  .prepare(
1128
1162
  `INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
1129
- VALUES (?, ?, 'interactive', ?)`,
1163
+ VALUES (?, ?, ?, ?)`,
1130
1164
  )
1131
- .run(options.runId, options.originSessionId, now);
1165
+ .run(options.runId, options.originSessionId, options.executionMode ?? "interactive", now);
1132
1166
  this.state.connection
1133
1167
  .prepare(
1134
1168
  `INSERT INTO run_queue(
@@ -1204,6 +1238,16 @@ export class SqliteControllerStore implements ControllerStore {
1204
1238
  return row === undefined ? undefined : this.mapWorkflowRun(row);
1205
1239
  }
1206
1240
 
1241
+ workflowRunProjectPath(runId: string): string | undefined {
1242
+ const row = this.state.connection
1243
+ .prepare(
1244
+ `SELECT p.canonical_path AS canonicalPath
1245
+ FROM runs r JOIN projects p ON p.project_id = r.project_id WHERE r.run_id = ?`,
1246
+ )
1247
+ .get(runId);
1248
+ return isCanonicalPathRow(row) ? row.canonicalPath : undefined;
1249
+ }
1250
+
1207
1251
  listWorkflowRuns(
1208
1252
  options: {
1209
1253
  statuses?: WorkflowRunLaunchStatus[];
@@ -1211,8 +1255,12 @@ export class SqliteControllerStore implements ControllerStore {
1211
1255
  limit?: number;
1212
1256
  } = {},
1213
1257
  ): WorkflowRunQueueRecord[] {
1214
- const clauses = ["r.project_id = ?"];
1215
- const params: unknown[] = [this.requireProjectId()];
1258
+ const clauses: string[] = [];
1259
+ const params: unknown[] = [];
1260
+ if (this.projectId !== null) {
1261
+ clauses.push("r.project_id = ?");
1262
+ params.push(this.projectId);
1263
+ }
1216
1264
  if (options.statuses !== undefined && options.statuses.length > 0) {
1217
1265
  clauses.push(`q.status IN (${options.statuses.map(() => "?").join(", ")})`);
1218
1266
  params.push(...options.statuses);
@@ -1224,7 +1272,9 @@ export class SqliteControllerStore implements ControllerStore {
1224
1272
  params.push(options.limit ?? 100);
1225
1273
  const rows = this.state.connection
1226
1274
  .prepare(
1227
- workflowRunSelect(`WHERE ${clauses.join(" AND ")} ORDER BY q.created_at DESC LIMIT ?`),
1275
+ workflowRunSelect(
1276
+ `${clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`} ORDER BY q.created_at DESC LIMIT ?`,
1277
+ ),
1228
1278
  )
1229
1279
  .all(...params);
1230
1280
  return rows.filter(isRunRow).map((row) => this.mapWorkflowRun(row));
@@ -1252,6 +1302,132 @@ export class SqliteControllerStore implements ControllerStore {
1252
1302
  return this.claimRun(options.runId, options.runnerId, options.claimToken, options.leaseMs, now);
1253
1303
  }
1254
1304
 
1305
+ /** Schedule a worker that validates one pending interactive submission. */
1306
+ claimWorkflowRunForInteractionValidation(options: {
1307
+ runId: string;
1308
+ runnerId: string;
1309
+ claimToken: string;
1310
+ leaseMs: number;
1311
+ now?: string;
1312
+ }): WorkflowRunQueueRecord | undefined {
1313
+ const now = epoch(validTimestamp(options.now));
1314
+ return this.claimRun(
1315
+ options.runId,
1316
+ options.runnerId,
1317
+ options.claimToken,
1318
+ options.leaseMs,
1319
+ now,
1320
+ {
1321
+ allowPendingInteraction: true,
1322
+ },
1323
+ );
1324
+ }
1325
+
1326
+ /** Take a short host claim without scheduling a parked interactive run. */
1327
+ claimWorkflowRunForControl(options: {
1328
+ runId: string;
1329
+ runnerId: string;
1330
+ claimToken: string;
1331
+ leaseMs: number;
1332
+ now?: string;
1333
+ }): WorkflowRunQueueRecord | undefined {
1334
+ const now = epoch(validTimestamp(options.now));
1335
+ return this.claimRun(
1336
+ options.runId,
1337
+ options.runnerId,
1338
+ options.claimToken,
1339
+ options.leaseMs,
1340
+ now,
1341
+ { allowPendingInteraction: true, preserveQueueStatus: true },
1342
+ );
1343
+ }
1344
+
1345
+ beginWorkflowRunInteractionTimeout(options: {
1346
+ runId: string;
1347
+ claimToken: string;
1348
+ now?: string;
1349
+ }): boolean {
1350
+ const now = epoch(validTimestamp(options.now));
1351
+ return this.state.transaction(() => {
1352
+ if (
1353
+ !this.verifyWorkflowRunClaim({
1354
+ runId: options.runId,
1355
+ claimToken: options.claimToken,
1356
+ now: new Date(now).toISOString(),
1357
+ })
1358
+ ) {
1359
+ return false;
1360
+ }
1361
+ const row = this.requireWorkflowRunRow(options.runId);
1362
+ const interaction = this.state.connection
1363
+ .prepare(
1364
+ `SELECT i.request_id AS requestId, i.attempt_id AS attemptId
1365
+ FROM interactive_requests i
1366
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
1367
+ WHERE i.run_id = ? AND i.status IN ('pending', 'presenting')
1368
+ AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
1369
+ ORDER BY a.deadline_at, i.request_id LIMIT 1`,
1370
+ )
1371
+ .get(options.runId, now);
1372
+ if (!isExpiredInteractionRow(interaction)) return false;
1373
+ const request = this.state.connection
1374
+ .prepare(
1375
+ `UPDATE interactive_requests
1376
+ SET status = 'cancelled', presenter_id = NULL,
1377
+ presentation_claim_expires_at = NULL, revision = revision + 1, updated_at = ?
1378
+ WHERE request_id = ? AND run_id = ? AND status IN ('pending', 'presenting')`,
1379
+ )
1380
+ .run(now, interaction.requestId, options.runId);
1381
+ const run = this.state.connection
1382
+ .prepare(
1383
+ `UPDATE runs
1384
+ SET status = 'running', status_detail = 'finishing expired interaction deadline',
1385
+ updated_at = ?, finished_at = NULL
1386
+ WHERE run_id = ? AND status = 'waiting'`,
1387
+ )
1388
+ .run(now, options.runId);
1389
+ const queue = this.state.connection
1390
+ .prepare(
1391
+ `UPDATE run_queue SET status = 'starting', error_code = NULL, error_hash = NULL,
1392
+ updated_at = ?, finished_at = NULL
1393
+ WHERE run_id = ? AND status = 'parked'`,
1394
+ )
1395
+ .run(now, options.runId);
1396
+ /* istanbul ignore if -- the expired parked interaction was selected above */
1397
+ if (request.changes !== 1 || run.changes !== 1 || queue.changes !== 1) {
1398
+ throw new Error(`Workflow run ${options.runId} changed during interaction timeout`);
1399
+ }
1400
+ const error = "Workflow node deadline expired before origin-session input arrived";
1401
+ const receiptHash = this.state.putJson({ status: "rejected", error }, now);
1402
+ this.state.connection
1403
+ .prepare(
1404
+ `UPDATE interactive_submissions SET outcome = 'rejected', receipt_hash = ?
1405
+ WHERE request_id = ? AND outcome = 'validating'`,
1406
+ )
1407
+ .run(receiptHash, interaction.requestId);
1408
+ const revision = this.resourceRevision(row.resourceId);
1409
+ const lease = this.requireLease(row.resourceId);
1410
+ this.bumpResource(row.resourceId, revision, now);
1411
+ this.insertEvent(
1412
+ row.resourceId,
1413
+ revision + 1,
1414
+ "run.interaction_timeout_started",
1415
+ lease.ownerType ?? "system",
1416
+ lease.ownerId,
1417
+ { requestId: interaction.requestId, attemptId: interaction.attemptId },
1418
+ now,
1419
+ lease.generation || undefined,
1420
+ );
1421
+ recordViewerDeltas(
1422
+ this.state,
1423
+ options.runId,
1424
+ [{ targetType: "summary" }, { targetType: "replay" }, { targetType: "conversation" }],
1425
+ now,
1426
+ );
1427
+ return true;
1428
+ });
1429
+ }
1430
+
1255
1431
  markWorkflowRunRunning(options: { runId: string; claimToken: string; now?: string }): boolean {
1256
1432
  return this.updateClaimedRunStatus(options.runId, options.claimToken, "running", options.now);
1257
1433
  }
@@ -1266,19 +1442,19 @@ export class SqliteControllerStore implements ControllerStore {
1266
1442
  }): WorkflowRunQueueRecord | undefined {
1267
1443
  const now = epoch(validTimestamp(options.now));
1268
1444
  const clauses = [
1269
- "r.project_id = ?",
1270
1445
  "q.status IN ('queued', 'parked', 'starting', 'running')",
1271
1446
  "q.available_at <= ?",
1272
1447
  "(l.owner_id IS NULL OR l.expires_at <= ? OR l.owner_id = ?)",
1273
1448
  "(q.affinity_runner_id IS NULL OR q.affinity_runner_id = ? OR q.status IN ('parked', 'starting', 'running'))",
1449
+ "NOT (q.status = 'parked' AND (r.status = 'waiting' OR r.paused = 1))",
1450
+ "NOT EXISTS (SELECT 1 FROM interactive_requests i WHERE i.run_id = r.run_id AND i.status IN ('pending', 'presenting'))",
1451
+ "(q.error_code IS NULL OR q.error_code <> 'workflowSourceChanged')",
1274
1452
  ];
1275
- const params: unknown[] = [
1276
- this.requireProjectId(),
1277
- now,
1278
- now,
1279
- options.runnerId,
1280
- options.runnerId,
1281
- ];
1453
+ const params: unknown[] = [now, now, options.runnerId, options.runnerId];
1454
+ if (this.projectId !== null) {
1455
+ clauses.unshift("r.project_id = ?");
1456
+ params.unshift(this.projectId);
1457
+ }
1282
1458
  if (options.sessionId !== undefined) {
1283
1459
  clauses.push("b.origin_session_id = ?");
1284
1460
  params.push(options.sessionId);
@@ -1307,22 +1483,37 @@ export class SqliteControllerStore implements ControllerStore {
1307
1483
  now?: string;
1308
1484
  }): boolean {
1309
1485
  const now = epoch(validTimestamp(options.now));
1310
- const row = this.workflowRunRow(options.runId);
1311
- if (row === undefined || row.ownerId === null) return false;
1312
- const result = this.state.connection
1313
- .prepare(
1314
- `UPDATE leases SET heartbeat_at = ?, expires_at = ?
1315
- WHERE resource_id = ? AND token_hash = ? AND owner_id = ? AND expires_at > ?`,
1316
- )
1317
- .run(
1318
- now,
1319
- now + options.leaseMs,
1320
- row.resourceId,
1321
- tokenHash(options.claimToken),
1322
- row.ownerId,
1323
- now,
1324
- );
1325
- return result.changes === 1;
1486
+ return this.state.transaction(() => {
1487
+ const row = this.workflowRunRow(options.runId);
1488
+ if (row === undefined || row.ownerId === null) return false;
1489
+ const lease = this.requireLease(row.resourceId);
1490
+ const result = this.state.connection
1491
+ .prepare(
1492
+ `UPDATE leases SET heartbeat_at = ?, expires_at = ?
1493
+ WHERE resource_id = ? AND owner_type = ? AND owner_id = ?
1494
+ AND token_hash = ? AND generation = ? AND expires_at > ?`,
1495
+ )
1496
+ .run(
1497
+ now,
1498
+ now + options.leaseMs,
1499
+ row.resourceId,
1500
+ lease.ownerType,
1501
+ row.ownerId,
1502
+ tokenHash(options.claimToken),
1503
+ lease.generation,
1504
+ now,
1505
+ );
1506
+ if (result.changes === 1) {
1507
+ recordViewerDeltas(
1508
+ this.state,
1509
+ options.runId,
1510
+ [{ targetType: "summary" }, { targetType: "replay" }],
1511
+ now,
1512
+ );
1513
+ return true;
1514
+ }
1515
+ return false;
1516
+ });
1326
1517
  }
1327
1518
 
1328
1519
  verifyWorkflowRunClaim(options: { runId: string; claimToken: string; now?: string }): boolean {
@@ -1347,16 +1538,11 @@ export class SqliteControllerStore implements ControllerStore {
1347
1538
  ownerId: string;
1348
1539
  token: string;
1349
1540
  generation: number;
1541
+ leaseMs: number;
1350
1542
  }
1351
1543
  | undefined {
1352
1544
  const row = this.workflowRunRow(runId);
1353
- if (
1354
- row === undefined ||
1355
- row.ownerId === null ||
1356
- row.claimExpiresAt === null ||
1357
- row.claimExpiresAt <= Date.now()
1358
- )
1359
- return undefined;
1545
+ if (row === undefined || row.ownerId === null || row.claimExpiresAt === null) return undefined;
1360
1546
  const lease = this.requireLease(row.resourceId);
1361
1547
  if (lease.tokenHash === null || !lease.tokenHash.equals(tokenHash(claimToken)))
1362
1548
  return undefined;
@@ -1367,6 +1553,7 @@ export class SqliteControllerStore implements ControllerStore {
1367
1553
  ownerId: row.ownerId,
1368
1554
  token: claimToken,
1369
1555
  generation: row.leaseGeneration,
1556
+ leaseMs: 30_000,
1370
1557
  };
1371
1558
  }
1372
1559
 
@@ -1374,6 +1561,158 @@ export class SqliteControllerStore implements ControllerStore {
1374
1561
  return this.releaseRunClaim(options.runId, options.claimToken, "parked", options.now);
1375
1562
  }
1376
1563
 
1564
+ parkWorkflowRunForSourceChange(options: {
1565
+ runId: string;
1566
+ claimToken: string;
1567
+ detail: string;
1568
+ now?: string;
1569
+ }): boolean {
1570
+ const now = epoch(validTimestamp(options.now));
1571
+ return this.state.transaction(() => {
1572
+ const row = this.workflowRunRow(options.runId);
1573
+ if (
1574
+ row === undefined ||
1575
+ !this.verifyWorkflowRunClaim({
1576
+ runId: options.runId,
1577
+ claimToken: options.claimToken,
1578
+ now: new Date(now).toISOString(),
1579
+ })
1580
+ ) {
1581
+ return false;
1582
+ }
1583
+ const lease = this.requireLease(row.resourceId);
1584
+ const detail = options.detail.slice(0, 8_192);
1585
+ const errorHash = this.state.putText(detail, now);
1586
+ const run = this.state.connection
1587
+ .prepare(
1588
+ `UPDATE runs SET status_detail = ?, updated_at = ?, finished_at = NULL
1589
+ WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'timed_out', 'cancelled')`,
1590
+ )
1591
+ .run(detail, now, options.runId);
1592
+ const queue = this.state.connection
1593
+ .prepare(
1594
+ `UPDATE run_queue
1595
+ SET status = 'parked', error_code = 'workflowSourceChanged', error_hash = ?,
1596
+ available_at = ?, updated_at = ?, finished_at = NULL
1597
+ WHERE run_id = ? AND status IN ('queued', 'starting', 'running', 'parked')`,
1598
+ )
1599
+ .run(errorHash, now, now, options.runId);
1600
+ /* istanbul ignore if -- exact live claim and nonterminal checks make both updates mandatory */
1601
+ if (run.changes !== 1 || queue.changes !== 1) {
1602
+ throw new Error(`Workflow run ${options.runId} has inconsistent source-change state`);
1603
+ }
1604
+ const released = this.state.connection
1605
+ .prepare(
1606
+ `UPDATE leases
1607
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
1608
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
1609
+ WHERE resource_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?`,
1610
+ )
1611
+ .run(row.resourceId, tokenHash(options.claimToken), lease.generation, now);
1612
+ /* istanbul ignore if -- the exact live claim is stable in this transaction */
1613
+ if (released.changes !== 1) {
1614
+ throw new Error(`Workflow run ${options.runId} claim changed during source recovery`);
1615
+ }
1616
+ const revision = this.resourceRevision(row.resourceId);
1617
+ this.bumpResource(row.resourceId, revision, now);
1618
+ this.insertEvent(
1619
+ row.resourceId,
1620
+ revision + 1,
1621
+ "run.source_changed",
1622
+ lease.ownerType ?? "system",
1623
+ lease.ownerId,
1624
+ { status: "parked", code: "workflowSourceChanged" },
1625
+ now,
1626
+ lease.generation || undefined,
1627
+ );
1628
+ recordViewerDeltas(
1629
+ this.state,
1630
+ options.runId,
1631
+ [{ targetType: "summary" }, { targetType: "replay" }],
1632
+ now,
1633
+ );
1634
+ return true;
1635
+ });
1636
+ }
1637
+
1638
+ parkWorkflowRunForAmbiguousEffect(options: {
1639
+ runId: string;
1640
+ claimToken: string;
1641
+ now?: string;
1642
+ }): boolean {
1643
+ const now = epoch(validTimestamp(options.now));
1644
+ return this.state.transaction(() => {
1645
+ const row = this.workflowRunRow(options.runId);
1646
+ if (
1647
+ row === undefined ||
1648
+ !this.verifyWorkflowRunClaim({
1649
+ runId: options.runId,
1650
+ claimToken: options.claimToken,
1651
+ now: new Date(now).toISOString(),
1652
+ })
1653
+ ) {
1654
+ return false;
1655
+ }
1656
+ const lease = this.requireLease(row.resourceId);
1657
+ const detail = "effect outcome is ambiguous; explicit recovery is required";
1658
+ const errorHash = this.state.putText(detail, now);
1659
+ this.state.connection
1660
+ .prepare(
1661
+ `UPDATE node_attempts SET status = 'waiting', updated_at = ?
1662
+ WHERE run_id = ? AND status IN ('pending', 'running')`,
1663
+ )
1664
+ .run(now, options.runId);
1665
+ const run = this.state.connection
1666
+ .prepare(
1667
+ `UPDATE runs SET status = 'waiting', status_detail = ?, updated_at = ?, finished_at = ?
1668
+ WHERE run_id = ? AND status = 'running'`,
1669
+ )
1670
+ .run(detail, now, now, options.runId);
1671
+ const queue = this.state.connection
1672
+ .prepare(
1673
+ `UPDATE run_queue
1674
+ SET status = 'parked', error_code = 'effectAmbiguous', error_hash = ?, updated_at = ?
1675
+ WHERE run_id = ? AND status IN ('starting', 'running', 'parked')`,
1676
+ )
1677
+ .run(errorHash, now, options.runId);
1678
+ /* istanbul ignore if -- exact live claim and run checks make both updates mandatory */
1679
+ if (run.changes !== 1 || queue.changes !== 1) {
1680
+ throw new Error(`Workflow run ${options.runId} has inconsistent ambiguous-effect state`);
1681
+ }
1682
+ const released = this.state.connection
1683
+ .prepare(
1684
+ `UPDATE leases
1685
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
1686
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
1687
+ WHERE resource_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?`,
1688
+ )
1689
+ .run(row.resourceId, tokenHash(options.claimToken), lease.generation, now);
1690
+ /* istanbul ignore if -- the exact live claim is stable in this transaction */
1691
+ if (released.changes !== 1) {
1692
+ throw new Error(`Workflow run ${options.runId} claim changed during effect recovery`);
1693
+ }
1694
+ const revision = this.resourceRevision(row.resourceId);
1695
+ this.bumpResource(row.resourceId, revision, now);
1696
+ this.insertEvent(
1697
+ row.resourceId,
1698
+ revision + 1,
1699
+ "run.effect_ambiguous",
1700
+ lease.ownerType ?? "system",
1701
+ lease.ownerId,
1702
+ { status: "waiting", code: "effectAmbiguous" },
1703
+ now,
1704
+ lease.generation || undefined,
1705
+ );
1706
+ recordViewerDeltas(
1707
+ this.state,
1708
+ options.runId,
1709
+ [{ targetType: "summary" }, { targetType: "replay" }],
1710
+ now,
1711
+ );
1712
+ return true;
1713
+ });
1714
+ }
1715
+
1377
1716
  failWorkflowRun(options: {
1378
1717
  runId: string;
1379
1718
  claimToken?: string;
@@ -1392,7 +1731,7 @@ export class SqliteControllerStore implements ControllerStore {
1392
1731
  }
1393
1732
 
1394
1733
  cancelWorkflowRun(options: { runId: string; claimToken?: string; now?: string }): boolean {
1395
- return this.terminalRun(
1734
+ const cancelled = this.terminalRun(
1396
1735
  options.runId,
1397
1736
  options.claimToken,
1398
1737
  "cancelled",
@@ -1400,6 +1739,111 @@ export class SqliteControllerStore implements ControllerStore {
1400
1739
  "Workflow run cancelled",
1401
1740
  options.now,
1402
1741
  );
1742
+ if (cancelled || options.claimToken !== undefined) return cancelled;
1743
+ return this.cancelStaleWorkflowRun({
1744
+ runId: options.runId,
1745
+ ...(options.now === undefined ? {} : { now: options.now }),
1746
+ });
1747
+ }
1748
+
1749
+ /** Cancel a nonterminal run only when its claim is absent or expired. */
1750
+ cancelStaleWorkflowRun(options: {
1751
+ runId: string;
1752
+ controlId?: string;
1753
+ claimToken?: string;
1754
+ now?: string;
1755
+ }): boolean {
1756
+ const now = epoch(validTimestamp(options.now));
1757
+ const controlId = options.controlId ?? "workflow-control";
1758
+ const claimToken = options.claimToken ?? randomUUID();
1759
+ return this.state.transaction(() => {
1760
+ const row = this.workflowRunRow(options.runId);
1761
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) return false;
1762
+ const lease = this.requireLease(row.resourceId);
1763
+ if (lease.ownerId !== null && lease.expiresAt !== null && lease.expiresAt > now) return false;
1764
+
1765
+ const generation = lease.generation + 1;
1766
+ const claimed = this.state.connection
1767
+ .prepare(
1768
+ `UPDATE leases
1769
+ SET generation = ?, owner_type = 'system', owner_id = ?, token_hash = ?,
1770
+ acquired_at = ?, heartbeat_at = ?, expires_at = ?
1771
+ WHERE resource_id = ? AND generation = ?
1772
+ AND (owner_id IS NULL OR expires_at IS NULL OR expires_at <= ?)`,
1773
+ )
1774
+ .run(
1775
+ generation,
1776
+ controlId,
1777
+ tokenHash(claimToken),
1778
+ now,
1779
+ now,
1780
+ now + 30_000,
1781
+ row.resourceId,
1782
+ lease.generation,
1783
+ now,
1784
+ );
1785
+ if (claimed.changes !== 1) return false;
1786
+
1787
+ const errorHash = this.state.putText("Workflow run cancelled", now);
1788
+ const queue = this.state.connection
1789
+ .prepare(
1790
+ `UPDATE run_queue
1791
+ SET status = 'cancelled', error_code = 'cancelled', error_hash = ?,
1792
+ updated_at = ?, finished_at = ?
1793
+ WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`,
1794
+ )
1795
+ .run(errorHash, now, now, options.runId);
1796
+ /* istanbul ignore if -- the control claim selects one nonterminal queue row */
1797
+ if (queue.changes !== 1) {
1798
+ throw new Error(`Workflow run ${options.runId} has inconsistent queue state`);
1799
+ }
1800
+ const run = this.state.connection
1801
+ .prepare(
1802
+ `UPDATE runs
1803
+ SET status = 'cancelled', paused = 0, status_detail = NULL, error_hash = ?,
1804
+ updated_at = ?, finished_at = ?
1805
+ WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'timed_out', 'cancelled')`,
1806
+ )
1807
+ .run(errorHash, now, now, options.runId);
1808
+ /* istanbul ignore if -- the control claim selects one nonterminal durable run */
1809
+ if (run.changes !== 1) {
1810
+ throw new Error(`Workflow run ${options.runId} has inconsistent durable state`);
1811
+ }
1812
+ this.cancelWorkflowRunDependents(options.runId, controlId, errorHash, now);
1813
+
1814
+ const released = this.state.connection
1815
+ .prepare(
1816
+ `UPDATE leases
1817
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
1818
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
1819
+ WHERE resource_id = ? AND owner_type = 'system' AND owner_id = ?
1820
+ AND token_hash = ? AND generation = ? AND expires_at > ?`,
1821
+ )
1822
+ .run(row.resourceId, controlId, tokenHash(claimToken), generation, now);
1823
+ /* istanbul ignore if -- the exact control claim was written in this transaction */
1824
+ if (released.changes !== 1) {
1825
+ throw new Error(`Workflow run ${options.runId} control claim changed during cancellation`);
1826
+ }
1827
+ const revision = this.resourceRevision(row.resourceId);
1828
+ this.bumpResource(row.resourceId, revision, now);
1829
+ this.insertEvent(
1830
+ row.resourceId,
1831
+ revision + 1,
1832
+ "run.queue_cancelled",
1833
+ "control",
1834
+ controlId,
1835
+ { status: "cancelled", code: "cancelled", staleControl: true },
1836
+ now,
1837
+ generation,
1838
+ );
1839
+ recordViewerDeltas(
1840
+ this.state,
1841
+ options.runId,
1842
+ [{ targetType: "summary" }, { targetType: "replay" }],
1843
+ now,
1844
+ );
1845
+ return true;
1846
+ });
1403
1847
  }
1404
1848
 
1405
1849
  deleteWorkflowRun(options: { runId: string; claimToken: string }): boolean {
@@ -1445,34 +1889,36 @@ export class SqliteControllerStore implements ControllerStore {
1445
1889
  runnerId?: string;
1446
1890
  now?: string;
1447
1891
  }): RunEventRecord {
1448
- const row = this.requireWorkflowRunRow(options.runId);
1449
- const now = epoch(validTimestamp(options.now));
1450
- const revision = this.resourceRevision(row.resourceId) + 1;
1451
- this.bumpResource(row.resourceId, revision - 1, now);
1452
- const eventId = this.insertEvent(
1453
- row.resourceId,
1454
- revision,
1455
- options.type,
1456
- options.runnerId?.startsWith("host-") ? "host" : "session",
1457
- options.runnerId ?? null,
1458
- options.payload ?? {},
1459
- now,
1460
- row.leaseGeneration || undefined,
1461
- );
1462
- const seq = this.state.connection
1463
- .prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
1464
- .get(eventId);
1465
- /* istanbul ignore if -- exact schema and internal query shape */
1466
- if (!isSequenceRow(seq)) throw new Error("Run event was not recorded");
1467
- return {
1468
- seq: seq.seq,
1469
- recordedAt: new Date(now).toISOString(),
1470
- runId: options.runId,
1471
- workflowRef: options.workflowRef,
1472
- type: options.type,
1473
- runnerId: options.runnerId ?? null,
1474
- payload: options.payload ?? {},
1475
- };
1892
+ return this.state.transaction(() => {
1893
+ const row = this.requireWorkflowRunRow(options.runId);
1894
+ const now = epoch(validTimestamp(options.now));
1895
+ const revision = this.resourceRevision(row.resourceId) + 1;
1896
+ this.bumpResource(row.resourceId, revision - 1, now);
1897
+ const eventId = this.insertEvent(
1898
+ row.resourceId,
1899
+ revision,
1900
+ options.type,
1901
+ options.runnerId?.startsWith("host-") ? "host" : "session",
1902
+ options.runnerId ?? null,
1903
+ options.payload ?? {},
1904
+ now,
1905
+ row.leaseGeneration || undefined,
1906
+ );
1907
+ const seq = this.state.connection
1908
+ .prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
1909
+ .get(eventId);
1910
+ /* istanbul ignore if -- exact schema and internal query shape */
1911
+ if (!isSequenceRow(seq)) throw new Error("Run event was not recorded");
1912
+ return {
1913
+ seq: seq.seq,
1914
+ recordedAt: new Date(now).toISOString(),
1915
+ runId: options.runId,
1916
+ workflowRef: options.workflowRef,
1917
+ type: options.type,
1918
+ runnerId: options.runnerId ?? null,
1919
+ payload: options.payload ?? {},
1920
+ };
1921
+ });
1476
1922
  }
1477
1923
 
1478
1924
  listRunEventsAfter(seq: number, options: { limit?: number } = {}): RunEventRecord[] {
@@ -1794,6 +2240,20 @@ export class SqliteControllerStore implements ControllerStore {
1794
2240
  return this.requireNotification(options.runId, options.attemptId, options.notificationIndex);
1795
2241
  }
1796
2242
 
2243
+ listPendingWorkflowNotifications(options: {
2244
+ targetSessionId: string;
2245
+ limit?: number;
2246
+ }): WorkflowNotificationRecord[] {
2247
+ const now = Date.now();
2248
+ return this.notificationRows(options.targetSessionId, options.limit ?? 20)
2249
+ .map((row) => this.mapNotification(row))
2250
+ .filter(
2251
+ (notification) =>
2252
+ notification.deliveryClaimExpiresAt === null ||
2253
+ Date.parse(notification.deliveryClaimExpiresAt) <= now,
2254
+ );
2255
+ }
2256
+
1797
2257
  claimPendingWorkflowNotifications(options: {
1798
2258
  targetSessionId: string;
1799
2259
  claimToken: string;
@@ -2141,6 +2601,26 @@ export class SqliteControllerStore implements ControllerStore {
2141
2601
  )
2142
2602
  .run(now, resourceId, expectedRevision);
2143
2603
  if (result.changes !== 1) throw new Error("Resource revision conflict");
2604
+ const run = this.state.connection
2605
+ .prepare(
2606
+ `SELECT r.run_id AS runId
2607
+ FROM runs r JOIN viewer_runs v ON v.run_id = r.run_id
2608
+ WHERE r.resource_id = ?`,
2609
+ )
2610
+ .get(resourceId);
2611
+ if (isRecord(run) && typeof run.runId === "string") {
2612
+ recordViewerDeltas(
2613
+ this.state,
2614
+ run.runId,
2615
+ [
2616
+ { targetType: "summary" },
2617
+ { targetType: "graph" },
2618
+ { targetType: "replay" },
2619
+ { targetType: "inspector", targetKey: "run" },
2620
+ ],
2621
+ now,
2622
+ );
2623
+ }
2144
2624
  }
2145
2625
 
2146
2626
  private insertEvent(
@@ -2284,6 +2764,7 @@ export class SqliteControllerStore implements ControllerStore {
2284
2764
  canonicalJson(this.state.readJson(row.launchOptionsHash)) ===
2285
2765
  canonicalJson(options.launchOptions ?? {}) &&
2286
2766
  row.originSessionId === options.originSessionId &&
2767
+ row.executionMode === (options.executionMode ?? "interactive") &&
2287
2768
  row.parentRunId === (options.parentRunId ?? null);
2288
2769
  if (!compatible) {
2289
2770
  throw new Error(`Workflow run preparation conflicts: ${options.runId}`);
@@ -2308,7 +2789,8 @@ export class SqliteControllerStore implements ControllerStore {
2308
2789
  claimExpiresAt:
2309
2790
  row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
2310
2791
  affinityRunnerId: row.affinityRunnerId,
2311
- originSessionId: row.originSessionId,
2792
+ originSessionId: row.executionMode === "headless" ? null : row.originSessionId,
2793
+ executionMode: row.executionMode,
2312
2794
  parentRunId: row.parentRunId,
2313
2795
  errorCode: row.errorCode,
2314
2796
  errorMessage:
@@ -2328,9 +2810,10 @@ export class SqliteControllerStore implements ControllerStore {
2328
2810
  claimToken: string,
2329
2811
  leaseMs: number,
2330
2812
  now: number,
2813
+ options: { allowPendingInteraction?: boolean; preserveQueueStatus?: boolean } = {},
2331
2814
  ): WorkflowRunQueueRecord | undefined {
2332
2815
  return this.state.transaction(() =>
2333
- this.claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now),
2816
+ this.claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now, options),
2334
2817
  );
2335
2818
  }
2336
2819
 
@@ -2340,9 +2823,21 @@ export class SqliteControllerStore implements ControllerStore {
2340
2823
  claimToken: string,
2341
2824
  leaseMs: number,
2342
2825
  now: number,
2826
+ options: { allowPendingInteraction?: boolean; preserveQueueStatus?: boolean } = {},
2343
2827
  ): WorkflowRunQueueRecord | undefined {
2344
2828
  const row = this.workflowRunRow(runId);
2345
2829
  if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) return undefined;
2830
+ if (
2831
+ options.allowPendingInteraction !== true &&
2832
+ this.state.connection
2833
+ .prepare(
2834
+ `SELECT 1 FROM interactive_requests
2835
+ WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`,
2836
+ )
2837
+ .get(runId) !== undefined
2838
+ ) {
2839
+ return undefined;
2840
+ }
2346
2841
  const lease = this.requireLease(row.resourceId);
2347
2842
  if (
2348
2843
  lease.ownerId !== null &&
@@ -2372,9 +2867,15 @@ export class SqliteControllerStore implements ControllerStore {
2372
2867
  );
2373
2868
  /* istanbul ignore if -- impossible after exact schema and transaction checks */
2374
2869
  if (result.changes !== 1) return undefined;
2375
- this.state.connection
2376
- .prepare("UPDATE run_queue SET status = 'starting', updated_at = ? WHERE run_id = ?")
2377
- .run(now, runId);
2870
+ if (options.preserveQueueStatus !== true) {
2871
+ this.state.connection
2872
+ .prepare(
2873
+ `UPDATE run_queue
2874
+ SET status = 'starting', error_code = NULL, error_hash = NULL, updated_at = ?
2875
+ WHERE run_id = ?`,
2876
+ )
2877
+ .run(now, runId);
2878
+ }
2378
2879
  const revision = this.resourceRevision(row.resourceId);
2379
2880
  this.bumpResource(row.resourceId, revision, now);
2380
2881
  this.insertEvent(
@@ -2474,6 +2975,12 @@ export class SqliteControllerStore implements ControllerStore {
2474
2975
  now,
2475
2976
  lease.generation || undefined,
2476
2977
  );
2978
+ recordViewerDeltas(
2979
+ this.state,
2980
+ runId,
2981
+ [{ targetType: "summary" }, { targetType: "replay" }],
2982
+ now,
2983
+ );
2477
2984
  return true;
2478
2985
  });
2479
2986
  }
@@ -2536,6 +3043,14 @@ export class SqliteControllerStore implements ControllerStore {
2536
3043
  WHERE run_id = ?`,
2537
3044
  )
2538
3045
  .run(status, errorHash, now, now, runId);
3046
+ if (status === "cancelled") {
3047
+ this.cancelWorkflowRunDependents(
3048
+ runId,
3049
+ lease.ownerId ?? "workflow-control",
3050
+ errorHash,
3051
+ now,
3052
+ );
3053
+ }
2539
3054
  this.bumpResource(row.resourceId, revision, now);
2540
3055
  this.insertEvent(
2541
3056
  row.resourceId,
@@ -2547,10 +3062,110 @@ export class SqliteControllerStore implements ControllerStore {
2547
3062
  now,
2548
3063
  lease.generation || undefined,
2549
3064
  );
3065
+ recordViewerDeltas(
3066
+ this.state,
3067
+ runId,
3068
+ [{ targetType: "summary" }, { targetType: "replay" }],
3069
+ now,
3070
+ );
2550
3071
  return true;
2551
3072
  });
2552
3073
  }
2553
3074
 
3075
+ private cancelWorkflowRunDependents(
3076
+ runId: string,
3077
+ actorId: string,
3078
+ errorHash: Buffer,
3079
+ now: number,
3080
+ ): void {
3081
+ this.state.connection
3082
+ .prepare(
3083
+ `UPDATE node_attempts
3084
+ SET status = 'cancelled', error_hash = COALESCE(error_hash, ?),
3085
+ updated_at = ?, finished_at = COALESCE(finished_at, ?)
3086
+ WHERE run_id = ? AND status IN ('pending', 'running', 'waiting', 'interrupted')`,
3087
+ )
3088
+ .run(errorHash, now, now, runId);
3089
+
3090
+ const effects = this.state.connection
3091
+ .prepare(
3092
+ `SELECT e.effect_id AS effectId, e.resource_id AS resourceId,
3093
+ e.status, e.attempt_count AS attemptCount
3094
+ FROM effects e JOIN runs r ON r.resource_id = e.source_resource_id
3095
+ WHERE r.run_id = ? AND e.owner_scope = 'run' AND e.status IN ('pending', 'applying')
3096
+ ORDER BY e.effect_id`,
3097
+ )
3098
+ .all(runId)
3099
+ .filter(isCancelledRunEffectRow);
3100
+ for (const effect of effects) {
3101
+ const status = effect.status === "applying" ? "ambiguous" : "cancelled";
3102
+ const revision = this.resourceRevision(effect.resourceId);
3103
+ const changed = this.state.connection
3104
+ .prepare(
3105
+ `UPDATE effects
3106
+ SET status = ?, next_attempt_at = NULL, error_hash = ?, updated_at = ?, settled_at = ?
3107
+ WHERE effect_id = ? AND status = ? AND attempt_count = ?`,
3108
+ )
3109
+ .run(status, errorHash, now, now, effect.effectId, effect.status, effect.attemptCount);
3110
+ /* istanbul ignore if -- cancellation serializes the selected effect transition */
3111
+ if (changed.changes !== 1) {
3112
+ throw new Error(`Workflow effect ${effect.effectId} changed during cancellation`);
3113
+ }
3114
+ if (effect.status === "applying") {
3115
+ this.state.connection
3116
+ .prepare(
3117
+ `UPDATE effect_attempts
3118
+ SET finished_at = ?, outcome = 'interrupted', error_hash = ?
3119
+ WHERE effect_id = ? AND attempt_number = ? AND finished_at IS NULL`,
3120
+ )
3121
+ .run(now, errorHash, effect.effectId, effect.attemptCount);
3122
+ }
3123
+ this.bumpResource(effect.resourceId, revision, now);
3124
+ this.insertEvent(
3125
+ effect.resourceId,
3126
+ revision + 1,
3127
+ `effect.${status}`,
3128
+ "control",
3129
+ actorId,
3130
+ { runId, reason: "workflowCancelled" },
3131
+ now,
3132
+ );
3133
+ }
3134
+
3135
+ this.state.connection
3136
+ .prepare(
3137
+ `INSERT INTO human_decision_resolutions(
3138
+ decision_id, outcome, provenance, response_hash, reason, channel,
3139
+ actor_id, request_digest, resolved_at
3140
+ )
3141
+ SELECT d.decision_id, 'cancelled', 'explicit_cancel', NULL,
3142
+ 'Workflow run cancelled', NULL, ?, d.request_digest, ?
3143
+ FROM human_decisions d
3144
+ LEFT JOIN human_decision_resolutions r ON r.decision_id = d.decision_id
3145
+ WHERE d.run_id = ? AND r.decision_id IS NULL`,
3146
+ )
3147
+ .run(actorId, now, runId);
3148
+ const receiptHash = this.state.putJson(
3149
+ { status: "rejected", error: "Workflow run cancelled" },
3150
+ now,
3151
+ );
3152
+ this.state.connection
3153
+ .prepare(
3154
+ `UPDATE interactive_submissions SET outcome = 'rejected', receipt_hash = ?
3155
+ WHERE outcome = 'validating'
3156
+ AND request_id IN (SELECT request_id FROM interactive_requests WHERE run_id = ?)`,
3157
+ )
3158
+ .run(receiptHash, runId);
3159
+ this.state.connection
3160
+ .prepare(
3161
+ `UPDATE interactive_requests
3162
+ SET status = 'cancelled', presenter_id = NULL,
3163
+ presentation_claim_expires_at = NULL, revision = revision + 1, updated_at = ?
3164
+ WHERE run_id = ? AND status IN ('pending', 'presenting')`,
3165
+ )
3166
+ .run(now, runId);
3167
+ }
3168
+
2554
3169
  private turnIntent(intentId: string): WorkflowTurnIntentRecord | undefined {
2555
3170
  const row = this.turnIntentRow(intentId);
2556
3171
  return row === undefined ? undefined : this.mapTurnIntent(row);
@@ -2843,7 +3458,8 @@ function workflowRunSelect(clause: string): string {
2843
3458
  q.status, q.available_at AS availableAt, q.affinity_runner_id AS affinityRunnerId,
2844
3459
  q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
2845
3460
  q.error_hash AS errorHash, b.origin_session_id AS originSessionId,
2846
- r.parent_run_id AS parentRunId, q.created_at AS createdAt, q.updated_at AS updatedAt,
3461
+ b.execution_mode AS executionMode, r.parent_run_id AS parentRunId,
3462
+ q.created_at AS createdAt, q.updated_at AS updatedAt,
2847
3463
  q.started_at AS startedAt, q.finished_at AS finishedAt,
2848
3464
  l.generation AS leaseGeneration, l.owner_id AS ownerId, l.expires_at AS claimExpiresAt
2849
3465
  FROM runs r JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
@@ -2952,6 +3568,20 @@ function isRunSourceIdentityRow(value: unknown): value is RunSourceIdentityRow {
2952
3568
  function isEffectRow(value: unknown): value is EffectRow {
2953
3569
  return isRecord(value);
2954
3570
  }
3571
+ function isCancelledRunEffectRow(value: unknown): value is CancelledRunEffectRow {
3572
+ return (
3573
+ isRecord(value) &&
3574
+ typeof value.effectId === "string" &&
3575
+ typeof value.resourceId === "string" &&
3576
+ (value.status === "pending" || value.status === "applying") &&
3577
+ typeof value.attemptCount === "number"
3578
+ );
3579
+ }
3580
+ function isExpiredInteractionRow(value: unknown): value is ExpiredInteractionRow {
3581
+ return (
3582
+ isRecord(value) && typeof value.requestId === "string" && typeof value.attemptId === "string"
3583
+ );
3584
+ }
2955
3585
  function isWorkflowRow(value: unknown): value is WorkflowRow {
2956
3586
  return isRecord(value);
2957
3587
  }
@@ -2973,6 +3603,9 @@ function isNotificationRow(value: unknown): value is NotificationRow {
2973
3603
  function isProjectRow(value: unknown): value is { projectId: string } {
2974
3604
  return isRecord(value);
2975
3605
  }
3606
+ function isCanonicalPathRow(value: unknown): value is { canonicalPath: string } {
3607
+ return isRecord(value) && typeof value.canonicalPath === "string";
3608
+ }
2976
3609
  function isFinalizerRow(value: unknown): value is { finalizer: string } {
2977
3610
  return isRecord(value);
2978
3611
  }