@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 { EffectRequestConflictError, ResourceConflictError, ResourceNotFoundError, WorkflowRequestConflictError, } from "./errors.js";
8
9
  const TURN_INTENT_FACTS_SCHEMA = "pi-workflows.deferred-turn-facts.v1";
9
10
  export class SqliteControllerStore {
@@ -22,9 +23,14 @@ export class SqliteControllerStore {
22
23
  checkLegacyState: filePath === workflowStatePath(),
23
24
  });
24
25
  this.filePath = this.state.filePath;
25
- const projectPath = options.projectPath === undefined ? process.cwd() : options.projectPath;
26
- this.projectId =
27
- options.readOnly === true ? this.findProject(projectPath) : this.ensureProject(projectPath);
26
+ if (options.global === true) {
27
+ this.projectId = null;
28
+ }
29
+ else {
30
+ const projectPath = options.projectPath === undefined ? process.cwd() : options.projectPath;
31
+ this.projectId =
32
+ options.readOnly === true ? this.findProject(projectPath) : this.ensureProject(projectPath);
33
+ }
28
34
  }
29
35
  close() {
30
36
  if (this.closed)
@@ -189,6 +195,11 @@ export class SqliteControllerStore {
189
195
  return undefined;
190
196
  const now = epoch(validTimestamp(options.now));
191
197
  const placeholders = options.controllers.map(() => "?").join(", ");
198
+ const exclusions = options.exclude ?? [];
199
+ const exclusionSql = exclusions
200
+ .map(() => "AND NOT (c.controller_name = ? AND c.resource_key = ?)")
201
+ .join("\n ");
202
+ const exclusionParams = exclusions.flatMap((ref) => [ref.controller, ref.key]);
192
203
  return this.state.transaction(() => {
193
204
  const row = this.state.connection
194
205
  .prepare(`${controllerSelect(`
@@ -196,11 +207,12 @@ export class SqliteControllerStore {
196
207
  JOIN leases l ON l.resource_id = c.resource_id
197
208
  WHERE c.project_id = ?
198
209
  AND c.controller_name IN (${placeholders})
210
+ ${exclusionSql}
199
211
  AND q.available_at <= ?
200
212
  AND (l.owner_id IS NULL OR l.expires_at <= ?)
201
213
  ORDER BY q.available_at, c.controller_name, c.resource_key
202
214
  LIMIT 1`)}`)
203
- .get(this.requireProjectId(), ...options.controllers, now, now);
215
+ .get(this.requireProjectId(), ...options.controllers, ...exclusionParams, now, now);
204
216
  if (!isControllerRow(row))
205
217
  return undefined;
206
218
  const lease = this.requireLease(row.resourceId);
@@ -559,11 +571,12 @@ export class SqliteControllerStore {
559
571
  input_hash, created_at, updated_at
560
572
  ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`)
561
573
  .run(options.runId, resourceId, this.requireProjectId(), options.parentRunId ?? null, definitionDigest, options.workflowSourceRef, launchHash, inputHash, now, now);
574
+ initializeViewerRun(this.state, options.runId, now);
562
575
  insertQueuedRunSources(this.state, options.runId, queuedSource);
563
576
  this.state.connection
564
577
  .prepare(`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
565
- VALUES (?, ?, 'interactive', ?)`)
566
- .run(options.runId, options.originSessionId, now);
578
+ VALUES (?, ?, ?, ?)`)
579
+ .run(options.runId, options.originSessionId, options.executionMode ?? "interactive", now);
567
580
  this.state.connection
568
581
  .prepare(`INSERT INTO run_queue(
569
582
  run_id, status, available_at, affinity_runner_id, origin_session_id,
@@ -627,9 +640,20 @@ export class SqliteControllerStore {
627
640
  const row = this.workflowRunRow(runId);
628
641
  return row === undefined ? undefined : this.mapWorkflowRun(row);
629
642
  }
643
+ workflowRunProjectPath(runId) {
644
+ const row = this.state.connection
645
+ .prepare(`SELECT p.canonical_path AS canonicalPath
646
+ FROM runs r JOIN projects p ON p.project_id = r.project_id WHERE r.run_id = ?`)
647
+ .get(runId);
648
+ return isCanonicalPathRow(row) ? row.canonicalPath : undefined;
649
+ }
630
650
  listWorkflowRuns(options = {}) {
631
- const clauses = ["r.project_id = ?"];
632
- const params = [this.requireProjectId()];
651
+ const clauses = [];
652
+ const params = [];
653
+ if (this.projectId !== null) {
654
+ clauses.push("r.project_id = ?");
655
+ params.push(this.projectId);
656
+ }
633
657
  if (options.statuses !== undefined && options.statuses.length > 0) {
634
658
  clauses.push(`q.status IN (${options.statuses.map(() => "?").join(", ")})`);
635
659
  params.push(...options.statuses);
@@ -640,7 +664,7 @@ export class SqliteControllerStore {
640
664
  }
641
665
  params.push(options.limit ?? 100);
642
666
  const rows = this.state.connection
643
- .prepare(workflowRunSelect(`WHERE ${clauses.join(" AND ")} ORDER BY q.created_at DESC LIMIT ?`))
667
+ .prepare(workflowRunSelect(`${clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`} ORDER BY q.created_at DESC LIMIT ?`))
644
668
  .all(...params);
645
669
  return rows.filter(isRunRow).map((row) => this.mapWorkflowRun(row));
646
670
  }
@@ -654,25 +678,93 @@ export class SqliteControllerStore {
654
678
  const now = epoch(validTimestamp(options.now));
655
679
  return this.claimRun(options.runId, options.runnerId, options.claimToken, options.leaseMs, now);
656
680
  }
681
+ /** Schedule a worker that validates one pending interactive submission. */
682
+ claimWorkflowRunForInteractionValidation(options) {
683
+ const now = epoch(validTimestamp(options.now));
684
+ return this.claimRun(options.runId, options.runnerId, options.claimToken, options.leaseMs, now, {
685
+ allowPendingInteraction: true,
686
+ });
687
+ }
688
+ /** Take a short host claim without scheduling a parked interactive run. */
689
+ claimWorkflowRunForControl(options) {
690
+ const now = epoch(validTimestamp(options.now));
691
+ return this.claimRun(options.runId, options.runnerId, options.claimToken, options.leaseMs, now, { allowPendingInteraction: true, preserveQueueStatus: true });
692
+ }
693
+ beginWorkflowRunInteractionTimeout(options) {
694
+ const now = epoch(validTimestamp(options.now));
695
+ return this.state.transaction(() => {
696
+ if (!this.verifyWorkflowRunClaim({
697
+ runId: options.runId,
698
+ claimToken: options.claimToken,
699
+ now: new Date(now).toISOString(),
700
+ })) {
701
+ return false;
702
+ }
703
+ const row = this.requireWorkflowRunRow(options.runId);
704
+ const interaction = this.state.connection
705
+ .prepare(`SELECT i.request_id AS requestId, i.attempt_id AS attemptId
706
+ FROM interactive_requests i
707
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
708
+ WHERE i.run_id = ? AND i.status IN ('pending', 'presenting')
709
+ AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
710
+ ORDER BY a.deadline_at, i.request_id LIMIT 1`)
711
+ .get(options.runId, now);
712
+ if (!isExpiredInteractionRow(interaction))
713
+ return false;
714
+ const request = this.state.connection
715
+ .prepare(`UPDATE interactive_requests
716
+ SET status = 'cancelled', presenter_id = NULL,
717
+ presentation_claim_expires_at = NULL, revision = revision + 1, updated_at = ?
718
+ WHERE request_id = ? AND run_id = ? AND status IN ('pending', 'presenting')`)
719
+ .run(now, interaction.requestId, options.runId);
720
+ const run = this.state.connection
721
+ .prepare(`UPDATE runs
722
+ SET status = 'running', status_detail = 'finishing expired interaction deadline',
723
+ updated_at = ?, finished_at = NULL
724
+ WHERE run_id = ? AND status = 'waiting'`)
725
+ .run(now, options.runId);
726
+ const queue = this.state.connection
727
+ .prepare(`UPDATE run_queue SET status = 'starting', error_code = NULL, error_hash = NULL,
728
+ updated_at = ?, finished_at = NULL
729
+ WHERE run_id = ? AND status = 'parked'`)
730
+ .run(now, options.runId);
731
+ /* istanbul ignore if -- the expired parked interaction was selected above */
732
+ if (request.changes !== 1 || run.changes !== 1 || queue.changes !== 1) {
733
+ throw new Error(`Workflow run ${options.runId} changed during interaction timeout`);
734
+ }
735
+ const error = "Workflow node deadline expired before origin-session input arrived";
736
+ const receiptHash = this.state.putJson({ status: "rejected", error }, now);
737
+ this.state.connection
738
+ .prepare(`UPDATE interactive_submissions SET outcome = 'rejected', receipt_hash = ?
739
+ WHERE request_id = ? AND outcome = 'validating'`)
740
+ .run(receiptHash, interaction.requestId);
741
+ const revision = this.resourceRevision(row.resourceId);
742
+ const lease = this.requireLease(row.resourceId);
743
+ this.bumpResource(row.resourceId, revision, now);
744
+ this.insertEvent(row.resourceId, revision + 1, "run.interaction_timeout_started", lease.ownerType ?? "system", lease.ownerId, { requestId: interaction.requestId, attemptId: interaction.attemptId }, now, lease.generation || undefined);
745
+ recordViewerDeltas(this.state, options.runId, [{ targetType: "summary" }, { targetType: "replay" }, { targetType: "conversation" }], now);
746
+ return true;
747
+ });
748
+ }
657
749
  markWorkflowRunRunning(options) {
658
750
  return this.updateClaimedRunStatus(options.runId, options.claimToken, "running", options.now);
659
751
  }
660
752
  claimNextWorkflowRun(options) {
661
753
  const now = epoch(validTimestamp(options.now));
662
754
  const clauses = [
663
- "r.project_id = ?",
664
755
  "q.status IN ('queued', 'parked', 'starting', 'running')",
665
756
  "q.available_at <= ?",
666
757
  "(l.owner_id IS NULL OR l.expires_at <= ? OR l.owner_id = ?)",
667
758
  "(q.affinity_runner_id IS NULL OR q.affinity_runner_id = ? OR q.status IN ('parked', 'starting', 'running'))",
759
+ "NOT (q.status = 'parked' AND (r.status = 'waiting' OR r.paused = 1))",
760
+ "NOT EXISTS (SELECT 1 FROM interactive_requests i WHERE i.run_id = r.run_id AND i.status IN ('pending', 'presenting'))",
761
+ "(q.error_code IS NULL OR q.error_code <> 'workflowSourceChanged')",
668
762
  ];
669
- const params = [
670
- this.requireProjectId(),
671
- now,
672
- now,
673
- options.runnerId,
674
- options.runnerId,
675
- ];
763
+ const params = [now, now, options.runnerId, options.runnerId];
764
+ if (this.projectId !== null) {
765
+ clauses.unshift("r.project_id = ?");
766
+ params.unshift(this.projectId);
767
+ }
676
768
  if (options.sessionId !== undefined) {
677
769
  clauses.push("b.origin_session_id = ?");
678
770
  params.push(options.sessionId);
@@ -693,14 +785,22 @@ export class SqliteControllerStore {
693
785
  }
694
786
  renewWorkflowRunClaim(options) {
695
787
  const now = epoch(validTimestamp(options.now));
696
- const row = this.workflowRunRow(options.runId);
697
- if (row === undefined || row.ownerId === null)
788
+ return this.state.transaction(() => {
789
+ const row = this.workflowRunRow(options.runId);
790
+ if (row === undefined || row.ownerId === null)
791
+ return false;
792
+ const lease = this.requireLease(row.resourceId);
793
+ const result = this.state.connection
794
+ .prepare(`UPDATE leases SET heartbeat_at = ?, expires_at = ?
795
+ WHERE resource_id = ? AND owner_type = ? AND owner_id = ?
796
+ AND token_hash = ? AND generation = ? AND expires_at > ?`)
797
+ .run(now, now + options.leaseMs, row.resourceId, lease.ownerType, row.ownerId, tokenHash(options.claimToken), lease.generation, now);
798
+ if (result.changes === 1) {
799
+ recordViewerDeltas(this.state, options.runId, [{ targetType: "summary" }, { targetType: "replay" }], now);
800
+ return true;
801
+ }
698
802
  return false;
699
- const result = this.state.connection
700
- .prepare(`UPDATE leases SET heartbeat_at = ?, expires_at = ?
701
- WHERE resource_id = ? AND token_hash = ? AND owner_id = ? AND expires_at > ?`)
702
- .run(now, now + options.leaseMs, row.resourceId, tokenHash(options.claimToken), row.ownerId, now);
703
- return result.changes === 1;
803
+ });
704
804
  }
705
805
  verifyWorkflowRunClaim(options) {
706
806
  const now = epoch(validTimestamp(options.now));
@@ -714,10 +814,7 @@ export class SqliteControllerStore {
714
814
  }
715
815
  workflowRunAuthority(runId, claimToken) {
716
816
  const row = this.workflowRunRow(runId);
717
- if (row === undefined ||
718
- row.ownerId === null ||
719
- row.claimExpiresAt === null ||
720
- row.claimExpiresAt <= Date.now())
817
+ if (row === undefined || row.ownerId === null || row.claimExpiresAt === null)
721
818
  return undefined;
722
819
  const lease = this.requireLease(row.resourceId);
723
820
  if (lease.tokenHash === null || !lease.tokenHash.equals(tokenHash(claimToken)))
@@ -729,16 +826,180 @@ export class SqliteControllerStore {
729
826
  ownerId: row.ownerId,
730
827
  token: claimToken,
731
828
  generation: row.leaseGeneration,
829
+ leaseMs: 30_000,
732
830
  };
733
831
  }
734
832
  parkWorkflowRun(options) {
735
833
  return this.releaseRunClaim(options.runId, options.claimToken, "parked", options.now);
736
834
  }
835
+ parkWorkflowRunForSourceChange(options) {
836
+ const now = epoch(validTimestamp(options.now));
837
+ return this.state.transaction(() => {
838
+ const row = this.workflowRunRow(options.runId);
839
+ if (row === undefined ||
840
+ !this.verifyWorkflowRunClaim({
841
+ runId: options.runId,
842
+ claimToken: options.claimToken,
843
+ now: new Date(now).toISOString(),
844
+ })) {
845
+ return false;
846
+ }
847
+ const lease = this.requireLease(row.resourceId);
848
+ const detail = options.detail.slice(0, 8_192);
849
+ const errorHash = this.state.putText(detail, now);
850
+ const run = this.state.connection
851
+ .prepare(`UPDATE runs SET status_detail = ?, updated_at = ?, finished_at = NULL
852
+ WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'timed_out', 'cancelled')`)
853
+ .run(detail, now, options.runId);
854
+ const queue = this.state.connection
855
+ .prepare(`UPDATE run_queue
856
+ SET status = 'parked', error_code = 'workflowSourceChanged', error_hash = ?,
857
+ available_at = ?, updated_at = ?, finished_at = NULL
858
+ WHERE run_id = ? AND status IN ('queued', 'starting', 'running', 'parked')`)
859
+ .run(errorHash, now, now, options.runId);
860
+ /* istanbul ignore if -- exact live claim and nonterminal checks make both updates mandatory */
861
+ if (run.changes !== 1 || queue.changes !== 1) {
862
+ throw new Error(`Workflow run ${options.runId} has inconsistent source-change state`);
863
+ }
864
+ const released = this.state.connection
865
+ .prepare(`UPDATE leases
866
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
867
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
868
+ WHERE resource_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?`)
869
+ .run(row.resourceId, tokenHash(options.claimToken), lease.generation, now);
870
+ /* istanbul ignore if -- the exact live claim is stable in this transaction */
871
+ if (released.changes !== 1) {
872
+ throw new Error(`Workflow run ${options.runId} claim changed during source recovery`);
873
+ }
874
+ const revision = this.resourceRevision(row.resourceId);
875
+ this.bumpResource(row.resourceId, revision, now);
876
+ this.insertEvent(row.resourceId, revision + 1, "run.source_changed", lease.ownerType ?? "system", lease.ownerId, { status: "parked", code: "workflowSourceChanged" }, now, lease.generation || undefined);
877
+ recordViewerDeltas(this.state, options.runId, [{ targetType: "summary" }, { targetType: "replay" }], now);
878
+ return true;
879
+ });
880
+ }
881
+ parkWorkflowRunForAmbiguousEffect(options) {
882
+ const now = epoch(validTimestamp(options.now));
883
+ return this.state.transaction(() => {
884
+ const row = this.workflowRunRow(options.runId);
885
+ if (row === undefined ||
886
+ !this.verifyWorkflowRunClaim({
887
+ runId: options.runId,
888
+ claimToken: options.claimToken,
889
+ now: new Date(now).toISOString(),
890
+ })) {
891
+ return false;
892
+ }
893
+ const lease = this.requireLease(row.resourceId);
894
+ const detail = "effect outcome is ambiguous; explicit recovery is required";
895
+ const errorHash = this.state.putText(detail, now);
896
+ this.state.connection
897
+ .prepare(`UPDATE node_attempts SET status = 'waiting', updated_at = ?
898
+ WHERE run_id = ? AND status IN ('pending', 'running')`)
899
+ .run(now, options.runId);
900
+ const run = this.state.connection
901
+ .prepare(`UPDATE runs SET status = 'waiting', status_detail = ?, updated_at = ?, finished_at = ?
902
+ WHERE run_id = ? AND status = 'running'`)
903
+ .run(detail, now, now, options.runId);
904
+ const queue = this.state.connection
905
+ .prepare(`UPDATE run_queue
906
+ SET status = 'parked', error_code = 'effectAmbiguous', error_hash = ?, updated_at = ?
907
+ WHERE run_id = ? AND status IN ('starting', 'running', 'parked')`)
908
+ .run(errorHash, now, options.runId);
909
+ /* istanbul ignore if -- exact live claim and run checks make both updates mandatory */
910
+ if (run.changes !== 1 || queue.changes !== 1) {
911
+ throw new Error(`Workflow run ${options.runId} has inconsistent ambiguous-effect state`);
912
+ }
913
+ const released = this.state.connection
914
+ .prepare(`UPDATE leases
915
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
916
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
917
+ WHERE resource_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?`)
918
+ .run(row.resourceId, tokenHash(options.claimToken), lease.generation, now);
919
+ /* istanbul ignore if -- the exact live claim is stable in this transaction */
920
+ if (released.changes !== 1) {
921
+ throw new Error(`Workflow run ${options.runId} claim changed during effect recovery`);
922
+ }
923
+ const revision = this.resourceRevision(row.resourceId);
924
+ this.bumpResource(row.resourceId, revision, now);
925
+ this.insertEvent(row.resourceId, revision + 1, "run.effect_ambiguous", lease.ownerType ?? "system", lease.ownerId, { status: "waiting", code: "effectAmbiguous" }, now, lease.generation || undefined);
926
+ recordViewerDeltas(this.state, options.runId, [{ targetType: "summary" }, { targetType: "replay" }], now);
927
+ return true;
928
+ });
929
+ }
737
930
  failWorkflowRun(options) {
738
931
  return this.terminalRun(options.runId, options.claimToken, "failed", options.errorCode, options.errorMessage, options.now);
739
932
  }
740
933
  cancelWorkflowRun(options) {
741
- return this.terminalRun(options.runId, options.claimToken, "cancelled", "cancelled", "Workflow run cancelled", options.now);
934
+ const cancelled = this.terminalRun(options.runId, options.claimToken, "cancelled", "cancelled", "Workflow run cancelled", options.now);
935
+ if (cancelled || options.claimToken !== undefined)
936
+ return cancelled;
937
+ return this.cancelStaleWorkflowRun({
938
+ runId: options.runId,
939
+ ...(options.now === undefined ? {} : { now: options.now }),
940
+ });
941
+ }
942
+ /** Cancel a nonterminal run only when its claim is absent or expired. */
943
+ cancelStaleWorkflowRun(options) {
944
+ const now = epoch(validTimestamp(options.now));
945
+ const controlId = options.controlId ?? "workflow-control";
946
+ const claimToken = options.claimToken ?? randomUUID();
947
+ return this.state.transaction(() => {
948
+ const row = this.workflowRunRow(options.runId);
949
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
950
+ return false;
951
+ const lease = this.requireLease(row.resourceId);
952
+ if (lease.ownerId !== null && lease.expiresAt !== null && lease.expiresAt > now)
953
+ return false;
954
+ const generation = lease.generation + 1;
955
+ const claimed = this.state.connection
956
+ .prepare(`UPDATE leases
957
+ SET generation = ?, owner_type = 'system', owner_id = ?, token_hash = ?,
958
+ acquired_at = ?, heartbeat_at = ?, expires_at = ?
959
+ WHERE resource_id = ? AND generation = ?
960
+ AND (owner_id IS NULL OR expires_at IS NULL OR expires_at <= ?)`)
961
+ .run(generation, controlId, tokenHash(claimToken), now, now, now + 30_000, row.resourceId, lease.generation, now);
962
+ if (claimed.changes !== 1)
963
+ return false;
964
+ const errorHash = this.state.putText("Workflow run cancelled", now);
965
+ const queue = this.state.connection
966
+ .prepare(`UPDATE run_queue
967
+ SET status = 'cancelled', error_code = 'cancelled', error_hash = ?,
968
+ updated_at = ?, finished_at = ?
969
+ WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`)
970
+ .run(errorHash, now, now, options.runId);
971
+ /* istanbul ignore if -- the control claim selects one nonterminal queue row */
972
+ if (queue.changes !== 1) {
973
+ throw new Error(`Workflow run ${options.runId} has inconsistent queue state`);
974
+ }
975
+ const run = this.state.connection
976
+ .prepare(`UPDATE runs
977
+ SET status = 'cancelled', paused = 0, status_detail = NULL, error_hash = ?,
978
+ updated_at = ?, finished_at = ?
979
+ WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'timed_out', 'cancelled')`)
980
+ .run(errorHash, now, now, options.runId);
981
+ /* istanbul ignore if -- the control claim selects one nonterminal durable run */
982
+ if (run.changes !== 1) {
983
+ throw new Error(`Workflow run ${options.runId} has inconsistent durable state`);
984
+ }
985
+ this.cancelWorkflowRunDependents(options.runId, controlId, errorHash, now);
986
+ const released = this.state.connection
987
+ .prepare(`UPDATE leases
988
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
989
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
990
+ WHERE resource_id = ? AND owner_type = 'system' AND owner_id = ?
991
+ AND token_hash = ? AND generation = ? AND expires_at > ?`)
992
+ .run(row.resourceId, controlId, tokenHash(claimToken), generation, now);
993
+ /* istanbul ignore if -- the exact control claim was written in this transaction */
994
+ if (released.changes !== 1) {
995
+ throw new Error(`Workflow run ${options.runId} control claim changed during cancellation`);
996
+ }
997
+ const revision = this.resourceRevision(row.resourceId);
998
+ this.bumpResource(row.resourceId, revision, now);
999
+ this.insertEvent(row.resourceId, revision + 1, "run.queue_cancelled", "control", controlId, { status: "cancelled", code: "cancelled", staleControl: true }, now, generation);
1000
+ recordViewerDeltas(this.state, options.runId, [{ targetType: "summary" }, { targetType: "replay" }], now);
1001
+ return true;
1002
+ });
742
1003
  }
743
1004
  deleteWorkflowRun(options) {
744
1005
  const now = Date.now();
@@ -769,26 +1030,28 @@ export class SqliteControllerStore {
769
1030
  throw new Error("Legacy workflow source claims are not available after the SQLite cutover");
770
1031
  }
771
1032
  recordRunEvent(options) {
772
- const row = this.requireWorkflowRunRow(options.runId);
773
- const now = epoch(validTimestamp(options.now));
774
- const revision = this.resourceRevision(row.resourceId) + 1;
775
- this.bumpResource(row.resourceId, revision - 1, now);
776
- const eventId = this.insertEvent(row.resourceId, revision, options.type, options.runnerId?.startsWith("host-") ? "host" : "session", options.runnerId ?? null, options.payload ?? {}, now, row.leaseGeneration || undefined);
777
- const seq = this.state.connection
778
- .prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
779
- .get(eventId);
780
- /* istanbul ignore if -- exact schema and internal query shape */
781
- if (!isSequenceRow(seq))
782
- throw new Error("Run event was not recorded");
783
- return {
784
- seq: seq.seq,
785
- recordedAt: new Date(now).toISOString(),
786
- runId: options.runId,
787
- workflowRef: options.workflowRef,
788
- type: options.type,
789
- runnerId: options.runnerId ?? null,
790
- payload: options.payload ?? {},
791
- };
1033
+ return this.state.transaction(() => {
1034
+ const row = this.requireWorkflowRunRow(options.runId);
1035
+ const now = epoch(validTimestamp(options.now));
1036
+ const revision = this.resourceRevision(row.resourceId) + 1;
1037
+ this.bumpResource(row.resourceId, revision - 1, now);
1038
+ const eventId = this.insertEvent(row.resourceId, revision, options.type, options.runnerId?.startsWith("host-") ? "host" : "session", options.runnerId ?? null, options.payload ?? {}, now, row.leaseGeneration || undefined);
1039
+ const seq = this.state.connection
1040
+ .prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
1041
+ .get(eventId);
1042
+ /* istanbul ignore if -- exact schema and internal query shape */
1043
+ if (!isSequenceRow(seq))
1044
+ throw new Error("Run event was not recorded");
1045
+ return {
1046
+ seq: seq.seq,
1047
+ recordedAt: new Date(now).toISOString(),
1048
+ runId: options.runId,
1049
+ workflowRef: options.workflowRef,
1050
+ type: options.type,
1051
+ runnerId: options.runnerId ?? null,
1052
+ payload: options.payload ?? {},
1053
+ };
1054
+ });
792
1055
  }
793
1056
  listRunEventsAfter(seq, options = {}) {
794
1057
  const rows = this.state.connection
@@ -946,6 +1209,13 @@ export class SqliteControllerStore {
946
1209
  });
947
1210
  return this.requireNotification(options.runId, options.attemptId, options.notificationIndex);
948
1211
  }
1212
+ listPendingWorkflowNotifications(options) {
1213
+ const now = Date.now();
1214
+ return this.notificationRows(options.targetSessionId, options.limit ?? 20)
1215
+ .map((row) => this.mapNotification(row))
1216
+ .filter((notification) => notification.deliveryClaimExpiresAt === null ||
1217
+ Date.parse(notification.deliveryClaimExpiresAt) <= now);
1218
+ }
949
1219
  claimPendingWorkflowNotifications(options) {
950
1220
  const rows = this.notificationRows(options.targetSessionId, options.limit ?? 20);
951
1221
  const result = [];
@@ -1200,6 +1470,19 @@ export class SqliteControllerStore {
1200
1470
  .run(now, resourceId, expectedRevision);
1201
1471
  if (result.changes !== 1)
1202
1472
  throw new Error("Resource revision conflict");
1473
+ const run = this.state.connection
1474
+ .prepare(`SELECT r.run_id AS runId
1475
+ FROM runs r JOIN viewer_runs v ON v.run_id = r.run_id
1476
+ WHERE r.resource_id = ?`)
1477
+ .get(resourceId);
1478
+ if (isRecord(run) && typeof run.runId === "string") {
1479
+ recordViewerDeltas(this.state, run.runId, [
1480
+ { targetType: "summary" },
1481
+ { targetType: "graph" },
1482
+ { targetType: "replay" },
1483
+ { targetType: "inspector", targetKey: "run" },
1484
+ ], now);
1485
+ }
1203
1486
  }
1204
1487
  insertEvent(resourceId, revision, type, actorType, actorId, payload, now, leaseGeneration) {
1205
1488
  const eventId = `event-${randomUUID()}`;
@@ -1308,6 +1591,7 @@ export class SqliteControllerStore {
1308
1591
  canonicalJson(this.state.readJson(row.launchOptionsHash)) ===
1309
1592
  canonicalJson(options.launchOptions ?? {}) &&
1310
1593
  row.originSessionId === options.originSessionId &&
1594
+ row.executionMode === (options.executionMode ?? "interactive") &&
1311
1595
  row.parentRunId === (options.parentRunId ?? null);
1312
1596
  if (!compatible) {
1313
1597
  throw new Error(`Workflow run preparation conflicts: ${options.runId}`);
@@ -1330,7 +1614,8 @@ export class SqliteControllerStore {
1330
1614
  claimGeneration: row.ownerId === null ? null : row.leaseGeneration,
1331
1615
  claimExpiresAt: row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
1332
1616
  affinityRunnerId: row.affinityRunnerId,
1333
- originSessionId: row.originSessionId,
1617
+ originSessionId: row.executionMode === "headless" ? null : row.originSessionId,
1618
+ executionMode: row.executionMode,
1334
1619
  parentRunId: row.parentRunId,
1335
1620
  errorCode: row.errorCode,
1336
1621
  errorMessage: row.errorHash === null
@@ -1342,13 +1627,20 @@ export class SqliteControllerStore {
1342
1627
  finishedAt: row.finishedAt === null ? null : new Date(row.finishedAt).toISOString(),
1343
1628
  };
1344
1629
  }
1345
- claimRun(runId, runnerId, claimToken, leaseMs, now) {
1346
- return this.state.transaction(() => this.claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now));
1630
+ claimRun(runId, runnerId, claimToken, leaseMs, now, options = {}) {
1631
+ return this.state.transaction(() => this.claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now, options));
1347
1632
  }
1348
- claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now) {
1633
+ claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now, options = {}) {
1349
1634
  const row = this.workflowRunRow(runId);
1350
1635
  if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
1351
1636
  return undefined;
1637
+ if (options.allowPendingInteraction !== true &&
1638
+ this.state.connection
1639
+ .prepare(`SELECT 1 FROM interactive_requests
1640
+ WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`)
1641
+ .get(runId) !== undefined) {
1642
+ return undefined;
1643
+ }
1352
1644
  const lease = this.requireLease(row.resourceId);
1353
1645
  if (lease.ownerId !== null &&
1354
1646
  lease.expiresAt !== null &&
@@ -1365,9 +1657,13 @@ export class SqliteControllerStore {
1365
1657
  /* istanbul ignore if -- impossible after exact schema and transaction checks */
1366
1658
  if (result.changes !== 1)
1367
1659
  return undefined;
1368
- this.state.connection
1369
- .prepare("UPDATE run_queue SET status = 'starting', updated_at = ? WHERE run_id = ?")
1370
- .run(now, runId);
1660
+ if (options.preserveQueueStatus !== true) {
1661
+ this.state.connection
1662
+ .prepare(`UPDATE run_queue
1663
+ SET status = 'starting', error_code = NULL, error_hash = NULL, updated_at = ?
1664
+ WHERE run_id = ?`)
1665
+ .run(now, runId);
1666
+ }
1371
1667
  const revision = this.resourceRevision(row.resourceId);
1372
1668
  this.bumpResource(row.resourceId, revision, now);
1373
1669
  this.insertEvent(row.resourceId, revision + 1, "lease.claimed", runnerId.startsWith("host-") ? "host" : "session", runnerId, { expiresAt }, now, generation);
@@ -1419,6 +1715,7 @@ export class SqliteControllerStore {
1419
1715
  const revision = this.resourceRevision(row.resourceId);
1420
1716
  this.bumpResource(row.resourceId, revision, now);
1421
1717
  this.insertEvent(row.resourceId, revision + 1, `run.queue_${status}`, lease.ownerType ?? "system", lease.ownerId, { status }, now, lease.generation || undefined);
1718
+ recordViewerDeltas(this.state, runId, [{ targetType: "summary" }, { targetType: "replay" }], now);
1422
1719
  return true;
1423
1720
  });
1424
1721
  }
@@ -1469,11 +1766,76 @@ export class SqliteControllerStore {
1469
1766
  updated_at = ?, finished_at = ?
1470
1767
  WHERE run_id = ?`)
1471
1768
  .run(status, errorHash, now, now, runId);
1769
+ if (status === "cancelled") {
1770
+ this.cancelWorkflowRunDependents(runId, lease.ownerId ?? "workflow-control", errorHash, now);
1771
+ }
1472
1772
  this.bumpResource(row.resourceId, revision, now);
1473
1773
  this.insertEvent(row.resourceId, revision + 1, `run.queue_${status}`, lease.ownerType ?? "system", lease.ownerId, { status, code, error }, now, lease.generation || undefined);
1774
+ recordViewerDeltas(this.state, runId, [{ targetType: "summary" }, { targetType: "replay" }], now);
1474
1775
  return true;
1475
1776
  });
1476
1777
  }
1778
+ cancelWorkflowRunDependents(runId, actorId, errorHash, now) {
1779
+ this.state.connection
1780
+ .prepare(`UPDATE node_attempts
1781
+ SET status = 'cancelled', error_hash = COALESCE(error_hash, ?),
1782
+ updated_at = ?, finished_at = COALESCE(finished_at, ?)
1783
+ WHERE run_id = ? AND status IN ('pending', 'running', 'waiting', 'interrupted')`)
1784
+ .run(errorHash, now, now, runId);
1785
+ const effects = this.state.connection
1786
+ .prepare(`SELECT e.effect_id AS effectId, e.resource_id AS resourceId,
1787
+ e.status, e.attempt_count AS attemptCount
1788
+ FROM effects e JOIN runs r ON r.resource_id = e.source_resource_id
1789
+ WHERE r.run_id = ? AND e.owner_scope = 'run' AND e.status IN ('pending', 'applying')
1790
+ ORDER BY e.effect_id`)
1791
+ .all(runId)
1792
+ .filter(isCancelledRunEffectRow);
1793
+ for (const effect of effects) {
1794
+ const status = effect.status === "applying" ? "ambiguous" : "cancelled";
1795
+ const revision = this.resourceRevision(effect.resourceId);
1796
+ const changed = this.state.connection
1797
+ .prepare(`UPDATE effects
1798
+ SET status = ?, next_attempt_at = NULL, error_hash = ?, updated_at = ?, settled_at = ?
1799
+ WHERE effect_id = ? AND status = ? AND attempt_count = ?`)
1800
+ .run(status, errorHash, now, now, effect.effectId, effect.status, effect.attemptCount);
1801
+ /* istanbul ignore if -- cancellation serializes the selected effect transition */
1802
+ if (changed.changes !== 1) {
1803
+ throw new Error(`Workflow effect ${effect.effectId} changed during cancellation`);
1804
+ }
1805
+ if (effect.status === "applying") {
1806
+ this.state.connection
1807
+ .prepare(`UPDATE effect_attempts
1808
+ SET finished_at = ?, outcome = 'interrupted', error_hash = ?
1809
+ WHERE effect_id = ? AND attempt_number = ? AND finished_at IS NULL`)
1810
+ .run(now, errorHash, effect.effectId, effect.attemptCount);
1811
+ }
1812
+ this.bumpResource(effect.resourceId, revision, now);
1813
+ this.insertEvent(effect.resourceId, revision + 1, `effect.${status}`, "control", actorId, { runId, reason: "workflowCancelled" }, now);
1814
+ }
1815
+ this.state.connection
1816
+ .prepare(`INSERT INTO human_decision_resolutions(
1817
+ decision_id, outcome, provenance, response_hash, reason, channel,
1818
+ actor_id, request_digest, resolved_at
1819
+ )
1820
+ SELECT d.decision_id, 'cancelled', 'explicit_cancel', NULL,
1821
+ 'Workflow run cancelled', NULL, ?, d.request_digest, ?
1822
+ FROM human_decisions d
1823
+ LEFT JOIN human_decision_resolutions r ON r.decision_id = d.decision_id
1824
+ WHERE d.run_id = ? AND r.decision_id IS NULL`)
1825
+ .run(actorId, now, runId);
1826
+ const receiptHash = this.state.putJson({ status: "rejected", error: "Workflow run cancelled" }, now);
1827
+ this.state.connection
1828
+ .prepare(`UPDATE interactive_submissions SET outcome = 'rejected', receipt_hash = ?
1829
+ WHERE outcome = 'validating'
1830
+ AND request_id IN (SELECT request_id FROM interactive_requests WHERE run_id = ?)`)
1831
+ .run(receiptHash, runId);
1832
+ this.state.connection
1833
+ .prepare(`UPDATE interactive_requests
1834
+ SET status = 'cancelled', presenter_id = NULL,
1835
+ presentation_claim_expires_at = NULL, revision = revision + 1, updated_at = ?
1836
+ WHERE run_id = ? AND status IN ('pending', 'presenting')`)
1837
+ .run(now, runId);
1838
+ }
1477
1839
  turnIntent(intentId) {
1478
1840
  const row = this.turnIntentRow(intentId);
1479
1841
  return row === undefined ? undefined : this.mapTurnIntent(row);
@@ -1668,7 +2030,8 @@ function workflowRunSelect(clause) {
1668
2030
  q.status, q.available_at AS availableAt, q.affinity_runner_id AS affinityRunnerId,
1669
2031
  q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
1670
2032
  q.error_hash AS errorHash, b.origin_session_id AS originSessionId,
1671
- r.parent_run_id AS parentRunId, q.created_at AS createdAt, q.updated_at AS updatedAt,
2033
+ b.execution_mode AS executionMode, r.parent_run_id AS parentRunId,
2034
+ q.created_at AS createdAt, q.updated_at AS updatedAt,
1672
2035
  q.started_at AS startedAt, q.finished_at AS finishedAt,
1673
2036
  l.generation AS leaseGeneration, l.owner_id AS ownerId, l.expires_at AS claimExpiresAt
1674
2037
  FROM runs r JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
@@ -1717,6 +2080,16 @@ function isRunSourceIdentityRow(value) {
1717
2080
  function isEffectRow(value) {
1718
2081
  return isRecord(value);
1719
2082
  }
2083
+ function isCancelledRunEffectRow(value) {
2084
+ return (isRecord(value) &&
2085
+ typeof value.effectId === "string" &&
2086
+ typeof value.resourceId === "string" &&
2087
+ (value.status === "pending" || value.status === "applying") &&
2088
+ typeof value.attemptCount === "number");
2089
+ }
2090
+ function isExpiredInteractionRow(value) {
2091
+ return (isRecord(value) && typeof value.requestId === "string" && typeof value.attemptId === "string");
2092
+ }
1720
2093
  function isWorkflowRow(value) {
1721
2094
  return isRecord(value);
1722
2095
  }
@@ -1738,6 +2111,9 @@ function isNotificationRow(value) {
1738
2111
  function isProjectRow(value) {
1739
2112
  return isRecord(value);
1740
2113
  }
2114
+ function isCanonicalPathRow(value) {
2115
+ return isRecord(value) && typeof value.canonicalPath === "string";
2116
+ }
1741
2117
  function isFinalizerRow(value) {
1742
2118
  return isRecord(value);
1743
2119
  }