@osolmaz/pi-workflows 0.13.4 → 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 (237) hide show
  1. package/README.md +146 -160
  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 +1 -2
  16. package/dist/controllers/index.js +0 -1
  17. package/dist/controllers/index.js.map +1 -1
  18. package/dist/controllers/sqlite.d.ts +85 -31
  19. package/dist/controllers/sqlite.js +541 -126
  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 -2928
  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/prune.js +36 -10
  94. package/dist/state/prune.js.map +1 -1
  95. package/dist/state/schema.d.ts +1 -1
  96. package/dist/state/schema.js +171 -3
  97. package/dist/state/schema.js.map +1 -1
  98. package/dist/state/viewer.d.ts +46 -0
  99. package/dist/state/viewer.js +249 -0
  100. package/dist/state/viewer.js.map +1 -0
  101. package/dist/viewer/cli.d.ts +1 -1
  102. package/dist/viewer/cli.js +47 -18
  103. package/dist/viewer/cli.js.map +1 -1
  104. package/dist/workflows/composition.js +25 -2
  105. package/dist/workflows/composition.js.map +1 -1
  106. package/dist/workflows/definition.d.ts +3 -1
  107. package/dist/workflows/definition.js +25 -0
  108. package/dist/workflows/definition.js.map +1 -1
  109. package/dist/workflows/engine.d.ts +1 -0
  110. package/dist/workflows/engine.js +157 -42
  111. package/dist/workflows/engine.js.map +1 -1
  112. package/dist/workflows/errors.d.ts +3 -1
  113. package/dist/workflows/errors.js +4 -7
  114. package/dist/workflows/errors.js.map +1 -1
  115. package/dist/workflows/human-decision.d.ts +3 -0
  116. package/dist/workflows/human-decision.js +31 -0
  117. package/dist/workflows/human-decision.js.map +1 -1
  118. package/dist/workflows/index.d.ts +1 -1
  119. package/dist/workflows/index.js +1 -1
  120. package/dist/workflows/index.js.map +1 -1
  121. package/dist/workflows/schema.js +17 -6
  122. package/dist/workflows/schema.js.map +1 -1
  123. package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
  124. package/dist/{viewer → workflows}/session-reducer.js +4 -0
  125. package/dist/workflows/session-reducer.js.map +1 -0
  126. package/dist/workflows/store.d.ts +87 -1
  127. package/dist/workflows/store.js +892 -93
  128. package/dist/workflows/store.js.map +1 -1
  129. package/dist/workflows/tool-input.d.ts +0 -22
  130. package/dist/workflows/tool-input.js +1 -44
  131. package/dist/workflows/tool-input.js.map +1 -1
  132. package/dist/workflows/types.d.ts +36 -4
  133. package/docs/2026-08-25-workflow-follow-ups.md +8 -6
  134. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
  135. package/docs/CONTROLLERS.md +11 -11
  136. package/docs/DEFERRED_TURNS.md +61 -27
  137. package/docs/HUMAN_DECISIONS.md +12 -4
  138. package/docs/SQLITE_STATE.md +42 -8
  139. package/docs/WORKFLOW_HOST.md +452 -0
  140. package/docs/development.md +46 -30
  141. package/docs/live-replay-protocol.md +129 -100
  142. package/docs/plans/2026-08-19-human-decision-gates-plan.md +34 -8
  143. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +399 -0
  144. package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
  145. package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
  146. package/docs/plans/tui-viewer-implementation-plan.md +5 -0
  147. package/docs/tui-viewer.md +18 -3
  148. package/docs/workflows.md +164 -156
  149. package/examples/workflows/command-batch.workflow.ts +2 -0
  150. package/examples/workflows/shell.workflow.ts +2 -1
  151. package/herdr-plugin.toml +1 -1
  152. package/package.json +1 -1
  153. package/skills/autodoc/SKILL.md +1 -1
  154. package/skills/autoimplement/SKILL.md +1 -1
  155. package/skills/autoplan/SKILL.md +1 -1
  156. package/skills/pi-workflows/SKILL.md +2 -0
  157. package/src/builtins/autoimplement.workflow.ts +3 -0
  158. package/src/builtins/change-verification.workflow.ts +7 -2
  159. package/src/builtins/metadata.ts +9 -0
  160. package/src/builtins/monitor.workflow.ts +4 -0
  161. package/src/builtins/sanity-check.workflow.ts +4 -0
  162. package/src/builtins/workspace-preparation.workflow.ts +3 -1
  163. package/src/controllers/index.ts +3 -5
  164. package/src/controllers/sqlite.ts +929 -225
  165. package/src/controllers/store.ts +1 -0
  166. package/src/extension/controller-command.ts +45 -0
  167. package/src/extension/index.ts +948 -3506
  168. package/src/extension/message-card.ts +61 -0
  169. package/src/extension/step-message.ts +58 -63
  170. package/src/host/child-worker-supervisor.ts +183 -0
  171. package/src/host/client.ts +293 -0
  172. package/src/host/controller-worker-entry.ts +311 -0
  173. package/src/host/controller-worker-protocol.ts +104 -0
  174. package/src/host/controller-worker-supervisor.ts +79 -0
  175. package/src/host/host-entry.ts +23 -0
  176. package/src/host/processes.ts +171 -54
  177. package/src/host/protocol.ts +196 -0
  178. package/src/host/resolver-entry.ts +241 -0
  179. package/src/host/rpc-executor.ts +76 -34
  180. package/src/host/runner.ts +2813 -422
  181. package/src/host/state.ts +1160 -0
  182. package/src/host/worker-entry.ts +533 -0
  183. package/src/host/worker-protocol.ts +165 -0
  184. package/src/host/worker-store.ts +229 -0
  185. package/src/host/worker-supervisor.ts +74 -0
  186. package/src/render/canvas.ts +44 -10
  187. package/src/render/graph-render.ts +145 -90
  188. package/src/state/database.ts +2 -1
  189. package/src/state/index.ts +14 -0
  190. package/src/state/prune.ts +35 -9
  191. package/src/state/schema.ts +171 -3
  192. package/src/state/viewer.ts +356 -0
  193. package/src/viewer/cli.ts +49 -17
  194. package/src/workflows/composition.ts +36 -2
  195. package/src/workflows/definition.ts +32 -0
  196. package/src/workflows/engine.ts +157 -54
  197. package/src/workflows/errors.ts +11 -2
  198. package/src/workflows/human-decision.ts +49 -0
  199. package/src/workflows/index.ts +2 -0
  200. package/src/workflows/schema.ts +19 -6
  201. package/src/{viewer → workflows}/session-reducer.ts +11 -1
  202. package/src/workflows/store.ts +1316 -108
  203. package/src/workflows/tool-input.ts +2 -60
  204. package/src/workflows/types.ts +32 -4
  205. package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
  206. package/dist/controllers/workflow-engine-scheduler.js +0 -175
  207. package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
  208. package/dist/extension/controller-host.d.ts +0 -48
  209. package/dist/extension/controller-host.js +0 -110
  210. package/dist/extension/controller-host.js.map +0 -1
  211. package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
  212. package/dist/extension/deferred-turn-coordinator.js +0 -143
  213. package/dist/extension/deferred-turn-coordinator.js.map +0 -1
  214. package/dist/extension/deferred-turn.d.ts +0 -44
  215. package/dist/extension/deferred-turn.js +0 -110
  216. package/dist/extension/deferred-turn.js.map +0 -1
  217. package/dist/extension/executor.d.ts +0 -86
  218. package/dist/extension/executor.js +0 -311
  219. package/dist/extension/executor.js.map +0 -1
  220. package/dist/extension/follow-up-coordinator.d.ts +0 -27
  221. package/dist/extension/follow-up-coordinator.js +0 -131
  222. package/dist/extension/follow-up-coordinator.js.map +0 -1
  223. package/dist/extension/recorder.d.ts +0 -84
  224. package/dist/extension/recorder.js +0 -528
  225. package/dist/extension/recorder.js.map +0 -1
  226. package/dist/extension/session-events.d.ts +0 -133
  227. package/dist/extension/session-events.js +0 -61
  228. package/dist/extension/session-events.js.map +0 -1
  229. package/dist/viewer/session-reducer.js.map +0 -1
  230. package/src/controllers/workflow-engine-scheduler.ts +0 -246
  231. package/src/extension/controller-host.ts +0 -167
  232. package/src/extension/deferred-turn-coordinator.ts +0 -171
  233. package/src/extension/deferred-turn.ts +0 -166
  234. package/src/extension/executor.ts +0 -411
  235. package/src/extension/follow-up-coordinator.ts +0 -151
  236. package/src/extension/recorder.ts +0 -655
  237. package/src/extension/session-events.ts +0 -121
@@ -2,7 +2,9 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { StateDatabase, workflowStatePath } from "../state/database.js";
5
+ import { canonicalJson } from "../state/json.js";
5
6
  import { resourceIdFor, tokenHash } from "../state/mutation.js";
7
+ import { initializeViewerRun, recordViewerDeltas } from "../state/viewer.js";
6
8
  import { EffectRequestConflictError, ResourceConflictError, ResourceNotFoundError, WorkflowRequestConflictError, } from "./errors.js";
7
9
  const TURN_INTENT_FACTS_SCHEMA = "pi-workflows.deferred-turn-facts.v1";
8
10
  export class SqliteControllerStore {
@@ -21,9 +23,14 @@ export class SqliteControllerStore {
21
23
  checkLegacyState: filePath === workflowStatePath(),
22
24
  });
23
25
  this.filePath = this.state.filePath;
24
- const projectPath = options.projectPath === undefined ? process.cwd() : options.projectPath;
25
- this.projectId =
26
- 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
+ }
27
34
  }
28
35
  close() {
29
36
  if (this.closed)
@@ -188,6 +195,11 @@ export class SqliteControllerStore {
188
195
  return undefined;
189
196
  const now = epoch(validTimestamp(options.now));
190
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]);
191
203
  return this.state.transaction(() => {
192
204
  const row = this.state.connection
193
205
  .prepare(`${controllerSelect(`
@@ -195,11 +207,12 @@ export class SqliteControllerStore {
195
207
  JOIN leases l ON l.resource_id = c.resource_id
196
208
  WHERE c.project_id = ?
197
209
  AND c.controller_name IN (${placeholders})
210
+ ${exclusionSql}
198
211
  AND q.available_at <= ?
199
212
  AND (l.owner_id IS NULL OR l.expires_at <= ?)
200
213
  ORDER BY q.available_at, c.controller_name, c.resource_key
201
214
  LIMIT 1`)}`)
202
- .get(this.requireProjectId(), ...options.controllers, now, now);
215
+ .get(this.requireProjectId(), ...options.controllers, ...exclusionParams, now, now);
203
216
  if (!isControllerRow(row))
204
217
  return undefined;
205
218
  const lease = this.requireLease(row.resourceId);
@@ -501,74 +514,97 @@ export class SqliteControllerStore {
501
514
  validateRunId(options.runId);
502
515
  const now = epoch(validTimestamp(options.now));
503
516
  const definitionDigest = digestBuffer(options.definitionDigest);
504
- return this.state.transaction(() => {
505
- if (this.getWorkflowRun(options.runId) !== undefined) {
506
- throw new Error(`Workflow run already reserved: ${options.runId}`);
517
+ return this.state.transaction(() => this.reserveWorkflowRunInTransaction(options, now, definitionDigest));
518
+ }
519
+ reserveWorkflowRunInTransaction(options, now, definitionDigest) {
520
+ if (this.getWorkflowRun(options.runId) !== undefined) {
521
+ throw new Error(`Workflow run already reserved: ${options.runId}`);
522
+ }
523
+ if (options.parentRunId !== undefined) {
524
+ const parent = this.requireWorkflowRunRow(options.parentRunId);
525
+ if (parent.originSessionId !== options.originSessionId) {
526
+ throw new Error("Continuation parent belongs to another Pi session");
507
527
  }
508
- if (options.parentRunId !== undefined) {
509
- const parent = this.requireWorkflowRunRow(options.parentRunId);
510
- if (parent.originSessionId !== options.originSessionId) {
511
- throw new Error("Continuation parent belongs to another Pi session");
528
+ if (parent.status === "parked") {
529
+ const parentLease = this.requireLease(parent.resourceId);
530
+ if (parentLease.ownerId !== null) {
531
+ throw new Error("Continuation parent still has an active owner");
512
532
  }
513
- if (parent.status === "parked") {
514
- const parentLease = this.requireLease(parent.resourceId);
515
- if (parentLease.ownerId !== null) {
516
- throw new Error("Continuation parent still has an active owner");
517
- }
518
- this.state.connection
519
- .prepare(`UPDATE run_queue
533
+ this.state.connection
534
+ .prepare(`UPDATE run_queue
520
535
  SET status = 'done', updated_at = ?, finished_at = ?
521
536
  WHERE run_id = ? AND status = 'parked'`)
522
- .run(now, now, parent.runId);
523
- const parentRevision = this.resourceRevision(parent.resourceId);
524
- this.bumpResource(parent.resourceId, parentRevision, now);
525
- this.insertEvent(parent.resourceId, parentRevision + 1, "run.queue_done_for_continuation", "session", options.originSessionId, { continuationRunId: options.runId }, now);
526
- }
527
- else if (parent.status === "done") {
528
- throw new Error("Continuation parent already has a reserved continuation");
529
- }
530
- else {
531
- throw new Error(`Continuation parent queue is ${parent.status}`);
532
- }
537
+ .run(now, now, parent.runId);
538
+ const parentRevision = this.resourceRevision(parent.resourceId);
539
+ this.bumpResource(parent.resourceId, parentRevision, now);
540
+ this.insertEvent(parent.resourceId, parentRevision + 1, "run.queue_done_for_continuation", "session", options.originSessionId, { continuationRunId: options.runId }, now);
533
541
  }
534
- const resourceId = resourceIdFor("run", options.runId);
535
- const definitionHash = this.state.putJson(options.definitionSnapshot, now);
536
- const queuedSource = queuedWorkflowSource(options.workflowSource);
537
- const inputHash = this.state.putJson(options.input ?? null, now);
538
- const launchHash = this.state.putJson(options.launchOptions ?? {}, now);
539
- this.state.connection
540
- .prepare(`INSERT INTO workflow_definitions(
542
+ else if (parent.status === "done") {
543
+ throw new Error("Continuation parent already has a reserved continuation");
544
+ }
545
+ else {
546
+ throw new Error(`Continuation parent queue is ${parent.status}`);
547
+ }
548
+ }
549
+ const resourceId = resourceIdFor("run", options.runId);
550
+ const definitionHash = this.state.putJson(options.definitionSnapshot, now);
551
+ const queuedSource = queuedWorkflowSource(options.workflowSource);
552
+ const inputHash = this.state.putJson(options.input ?? null, now);
553
+ const launchHash = this.state.putJson(options.launchOptions ?? {}, now);
554
+ this.state.connection
555
+ .prepare(`INSERT INTO workflow_definitions(
541
556
  definition_digest, workflow_name, definition_hash, created_at
542
557
  ) VALUES (?, ?, ?, ?)
543
558
  ON CONFLICT(definition_digest) DO NOTHING`)
544
- .run(definitionDigest, options.workflowName, definitionHash, now);
545
- this.state.connection
546
- .prepare(`INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
559
+ .run(definitionDigest, options.workflowName, definitionHash, now);
560
+ this.state.connection
561
+ .prepare(`INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
547
562
  VALUES (?, 'run', ?, 1, ?, ?)`)
548
- .run(resourceId, options.runId, now, now);
549
- this.state.connection
550
- .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
551
- .run(resourceId);
552
- this.state.connection
553
- .prepare(`INSERT INTO runs(
563
+ .run(resourceId, options.runId, now, now);
564
+ this.state.connection
565
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
566
+ .run(resourceId);
567
+ this.state.connection
568
+ .prepare(`INSERT INTO runs(
554
569
  run_id, resource_id, project_id, parent_run_id, definition_digest,
555
570
  workflow_ref, launch_options_hash, status, paused,
556
571
  input_hash, created_at, updated_at
557
572
  ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`)
558
- .run(options.runId, resourceId, this.requireProjectId(), options.parentRunId ?? null, definitionDigest, options.workflowSourceRef, launchHash, inputHash, now, now);
559
- insertQueuedRunSources(this.state, options.runId, queuedSource);
560
- this.state.connection
561
- .prepare(`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
562
- VALUES (?, ?, 'interactive', ?)`)
563
- .run(options.runId, options.originSessionId, now);
564
- this.state.connection
565
- .prepare(`INSERT INTO run_queue(
573
+ .run(options.runId, resourceId, this.requireProjectId(), options.parentRunId ?? null, definitionDigest, options.workflowSourceRef, launchHash, inputHash, now, now);
574
+ initializeViewerRun(this.state, options.runId, now);
575
+ insertQueuedRunSources(this.state, options.runId, queuedSource);
576
+ this.state.connection
577
+ .prepare(`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
578
+ VALUES (?, ?, ?, ?)`)
579
+ .run(options.runId, options.originSessionId, options.executionMode ?? "interactive", now);
580
+ this.state.connection
581
+ .prepare(`INSERT INTO run_queue(
566
582
  run_id, status, available_at, affinity_runner_id, origin_session_id,
567
583
  consecutive_errors, created_at, updated_at
568
584
  ) VALUES (?, 'queued', ?, ?, ?, 0, ?, ?)`)
569
- .run(options.runId, now, options.runnerId, options.originSessionId, now, now);
570
- this.insertEvent(resourceId, 1, "run.queued", "session", options.originSessionId, {}, now);
571
- return this.requireWorkflowRun(options.runId);
585
+ .run(options.runId, now, options.runnerId, options.originSessionId, now, now);
586
+ this.insertEvent(resourceId, 1, "run.queued", "session", options.originSessionId, {}, now);
587
+ return this.requireWorkflowRun(options.runId);
588
+ }
589
+ prepareOrAdoptWorkflowRun(options) {
590
+ validateRunId(options.runId);
591
+ const now = epoch(validTimestamp(options.now));
592
+ const definitionDigest = digestBuffer(options.definitionDigest);
593
+ return this.state.transaction(() => {
594
+ const existing = this.workflowRunRow(options.runId);
595
+ if (existing !== undefined) {
596
+ this.assertWorkflowRunPreparationCompatible(existing, options, definitionDigest);
597
+ return { state: "adopted", run: this.mapWorkflowRun(existing) };
598
+ }
599
+ this.reserveWorkflowRunInTransaction(options, now, definitionDigest);
600
+ const claimed = this.claimRunInTransaction(options.runId, options.runnerId, options.claimToken, options.leaseMs, now);
601
+ if (claimed === undefined) {
602
+ throw new Error(`Workflow run could not be claimed: ${options.runId}`);
603
+ }
604
+ return {
605
+ state: "claimed",
606
+ run: claimed,
607
+ };
572
608
  });
573
609
  }
574
610
  enqueueWorkflowRun(options) {
@@ -604,9 +640,20 @@ export class SqliteControllerStore {
604
640
  const row = this.workflowRunRow(runId);
605
641
  return row === undefined ? undefined : this.mapWorkflowRun(row);
606
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
+ }
607
650
  listWorkflowRuns(options = {}) {
608
- const clauses = ["r.project_id = ?"];
609
- 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
+ }
610
657
  if (options.statuses !== undefined && options.statuses.length > 0) {
611
658
  clauses.push(`q.status IN (${options.statuses.map(() => "?").join(", ")})`);
612
659
  params.push(...options.statuses);
@@ -617,7 +664,7 @@ export class SqliteControllerStore {
617
664
  }
618
665
  params.push(options.limit ?? 100);
619
666
  const rows = this.state.connection
620
- .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 ?`))
621
668
  .all(...params);
622
669
  return rows.filter(isRunRow).map((row) => this.mapWorkflowRun(row));
623
670
  }
@@ -631,25 +678,93 @@ export class SqliteControllerStore {
631
678
  const now = epoch(validTimestamp(options.now));
632
679
  return this.claimRun(options.runId, options.runnerId, options.claimToken, options.leaseMs, now);
633
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
+ }
634
749
  markWorkflowRunRunning(options) {
635
750
  return this.updateClaimedRunStatus(options.runId, options.claimToken, "running", options.now);
636
751
  }
637
752
  claimNextWorkflowRun(options) {
638
753
  const now = epoch(validTimestamp(options.now));
639
754
  const clauses = [
640
- "r.project_id = ?",
641
755
  "q.status IN ('queued', 'parked', 'starting', 'running')",
642
756
  "q.available_at <= ?",
643
757
  "(l.owner_id IS NULL OR l.expires_at <= ? OR l.owner_id = ?)",
644
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')",
645
762
  ];
646
- const params = [
647
- this.requireProjectId(),
648
- now,
649
- now,
650
- options.runnerId,
651
- options.runnerId,
652
- ];
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
+ }
653
768
  if (options.sessionId !== undefined) {
654
769
  clauses.push("b.origin_session_id = ?");
655
770
  params.push(options.sessionId);
@@ -670,14 +785,22 @@ export class SqliteControllerStore {
670
785
  }
671
786
  renewWorkflowRunClaim(options) {
672
787
  const now = epoch(validTimestamp(options.now));
673
- const row = this.workflowRunRow(options.runId);
674
- 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
+ }
675
802
  return false;
676
- const result = this.state.connection
677
- .prepare(`UPDATE leases SET heartbeat_at = ?, expires_at = ?
678
- WHERE resource_id = ? AND token_hash = ? AND owner_id = ? AND expires_at > ?`)
679
- .run(now, now + options.leaseMs, row.resourceId, tokenHash(options.claimToken), row.ownerId, now);
680
- return result.changes === 1;
803
+ });
681
804
  }
682
805
  verifyWorkflowRunClaim(options) {
683
806
  const now = epoch(validTimestamp(options.now));
@@ -691,10 +814,7 @@ export class SqliteControllerStore {
691
814
  }
692
815
  workflowRunAuthority(runId, claimToken) {
693
816
  const row = this.workflowRunRow(runId);
694
- if (row === undefined ||
695
- row.ownerId === null ||
696
- row.claimExpiresAt === null ||
697
- row.claimExpiresAt <= Date.now())
817
+ if (row === undefined || row.ownerId === null || row.claimExpiresAt === null)
698
818
  return undefined;
699
819
  const lease = this.requireLease(row.resourceId);
700
820
  if (lease.tokenHash === null || !lease.tokenHash.equals(tokenHash(claimToken)))
@@ -706,16 +826,180 @@ export class SqliteControllerStore {
706
826
  ownerId: row.ownerId,
707
827
  token: claimToken,
708
828
  generation: row.leaseGeneration,
829
+ leaseMs: 30_000,
709
830
  };
710
831
  }
711
832
  parkWorkflowRun(options) {
712
833
  return this.releaseRunClaim(options.runId, options.claimToken, "parked", options.now);
713
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
+ }
714
930
  failWorkflowRun(options) {
715
931
  return this.terminalRun(options.runId, options.claimToken, "failed", options.errorCode, options.errorMessage, options.now);
716
932
  }
717
933
  cancelWorkflowRun(options) {
718
- 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
+ });
719
1003
  }
720
1004
  deleteWorkflowRun(options) {
721
1005
  const now = Date.now();
@@ -746,26 +1030,28 @@ export class SqliteControllerStore {
746
1030
  throw new Error("Legacy workflow source claims are not available after the SQLite cutover");
747
1031
  }
748
1032
  recordRunEvent(options) {
749
- const row = this.requireWorkflowRunRow(options.runId);
750
- const now = epoch(validTimestamp(options.now));
751
- const revision = this.resourceRevision(row.resourceId) + 1;
752
- this.bumpResource(row.resourceId, revision - 1, now);
753
- const eventId = this.insertEvent(row.resourceId, revision, options.type, options.runnerId?.startsWith("host-") ? "host" : "session", options.runnerId ?? null, options.payload ?? {}, now, row.leaseGeneration || undefined);
754
- const seq = this.state.connection
755
- .prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
756
- .get(eventId);
757
- /* istanbul ignore if -- exact schema and internal query shape */
758
- if (!isSequenceRow(seq))
759
- throw new Error("Run event was not recorded");
760
- return {
761
- seq: seq.seq,
762
- recordedAt: new Date(now).toISOString(),
763
- runId: options.runId,
764
- workflowRef: options.workflowRef,
765
- type: options.type,
766
- runnerId: options.runnerId ?? null,
767
- payload: options.payload ?? {},
768
- };
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
+ });
769
1055
  }
770
1056
  listRunEventsAfter(seq, options = {}) {
771
1057
  const rows = this.state.connection
@@ -923,6 +1209,13 @@ export class SqliteControllerStore {
923
1209
  });
924
1210
  return this.requireNotification(options.runId, options.attemptId, options.notificationIndex);
925
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
+ }
926
1219
  claimPendingWorkflowNotifications(options) {
927
1220
  const rows = this.notificationRows(options.targetSessionId, options.limit ?? 20);
928
1221
  const result = [];
@@ -1177,6 +1470,19 @@ export class SqliteControllerStore {
1177
1470
  .run(now, resourceId, expectedRevision);
1178
1471
  if (result.changes !== 1)
1179
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
+ }
1180
1486
  }
1181
1487
  insertEvent(resourceId, revision, type, actorType, actorId, payload, now, leaseGeneration) {
1182
1488
  const eventId = `event-${randomUUID()}`;
@@ -1275,6 +1581,22 @@ export class SqliteControllerStore {
1275
1581
  requireWorkflowRun(runId) {
1276
1582
  return this.mapWorkflowRun(this.requireWorkflowRunRow(runId));
1277
1583
  }
1584
+ assertWorkflowRunPreparationCompatible(row, options, definitionDigest) {
1585
+ const compatible = row.workflowName === options.workflowName &&
1586
+ row.workflowRef === options.workflowSourceRef &&
1587
+ row.definitionDigest.equals(definitionDigest) &&
1588
+ canonicalJson(readQueuedRunSources(this.state, row)) ===
1589
+ canonicalJson(queuedWorkflowSource(options.workflowSource)) &&
1590
+ canonicalJson(this.state.readJson(row.inputHash)) === canonicalJson(options.input ?? null) &&
1591
+ canonicalJson(this.state.readJson(row.launchOptionsHash)) ===
1592
+ canonicalJson(options.launchOptions ?? {}) &&
1593
+ row.originSessionId === options.originSessionId &&
1594
+ row.executionMode === (options.executionMode ?? "interactive") &&
1595
+ row.parentRunId === (options.parentRunId ?? null);
1596
+ if (!compatible) {
1597
+ throw new Error(`Workflow run preparation conflicts: ${options.runId}`);
1598
+ }
1599
+ }
1278
1600
  /* istanbul ignore next -- pure projection covered by integration tests */
1279
1601
  mapWorkflowRun(row) {
1280
1602
  return {
@@ -1292,7 +1614,8 @@ export class SqliteControllerStore {
1292
1614
  claimGeneration: row.ownerId === null ? null : row.leaseGeneration,
1293
1615
  claimExpiresAt: row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
1294
1616
  affinityRunnerId: row.affinityRunnerId,
1295
- originSessionId: row.originSessionId,
1617
+ originSessionId: row.executionMode === "headless" ? null : row.originSessionId,
1618
+ executionMode: row.executionMode,
1296
1619
  parentRunId: row.parentRunId,
1297
1620
  errorCode: row.errorCode,
1298
1621
  errorMessage: row.errorHash === null
@@ -1304,35 +1627,47 @@ export class SqliteControllerStore {
1304
1627
  finishedAt: row.finishedAt === null ? null : new Date(row.finishedAt).toISOString(),
1305
1628
  };
1306
1629
  }
1307
- claimRun(runId, runnerId, claimToken, leaseMs, now) {
1308
- return this.state.transaction(() => {
1309
- const row = this.workflowRunRow(runId);
1310
- if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
1311
- return undefined;
1312
- const lease = this.requireLease(row.resourceId);
1313
- if (lease.ownerId !== null &&
1314
- lease.expiresAt !== null &&
1315
- lease.expiresAt > now &&
1316
- lease.ownerId !== runnerId)
1317
- return undefined;
1318
- const generation = lease.generation + 1;
1319
- const expiresAt = now + leaseMs;
1320
- const result = this.state.connection
1321
- .prepare(`UPDATE leases SET generation = ?, owner_type = ?, owner_id = ?, token_hash = ?,
1630
+ claimRun(runId, runnerId, claimToken, leaseMs, now, options = {}) {
1631
+ return this.state.transaction(() => this.claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now, options));
1632
+ }
1633
+ claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now, options = {}) {
1634
+ const row = this.workflowRunRow(runId);
1635
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
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
+ }
1644
+ const lease = this.requireLease(row.resourceId);
1645
+ if (lease.ownerId !== null &&
1646
+ lease.expiresAt !== null &&
1647
+ lease.expiresAt > now &&
1648
+ lease.ownerId !== runnerId)
1649
+ return undefined;
1650
+ const generation = lease.generation + 1;
1651
+ const expiresAt = now + leaseMs;
1652
+ const result = this.state.connection
1653
+ .prepare(`UPDATE leases SET generation = ?, owner_type = ?, owner_id = ?, token_hash = ?,
1322
1654
  acquired_at = ?, heartbeat_at = ?, expires_at = ?
1323
1655
  WHERE resource_id = ? AND generation = ?`)
1324
- .run(generation, runnerId.startsWith("host-") ? "host" : "session", runnerId, tokenHash(claimToken), now, now, expiresAt, row.resourceId, lease.generation);
1325
- /* istanbul ignore if -- impossible after exact schema and transaction checks */
1326
- if (result.changes !== 1)
1327
- return undefined;
1656
+ .run(generation, runnerId.startsWith("host-") ? "host" : "session", runnerId, tokenHash(claimToken), now, now, expiresAt, row.resourceId, lease.generation);
1657
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
1658
+ if (result.changes !== 1)
1659
+ return undefined;
1660
+ if (options.preserveQueueStatus !== true) {
1328
1661
  this.state.connection
1329
- .prepare("UPDATE run_queue SET status = 'starting', updated_at = ? WHERE run_id = ?")
1662
+ .prepare(`UPDATE run_queue
1663
+ SET status = 'starting', error_code = NULL, error_hash = NULL, updated_at = ?
1664
+ WHERE run_id = ?`)
1330
1665
  .run(now, runId);
1331
- const revision = this.resourceRevision(row.resourceId);
1332
- this.bumpResource(row.resourceId, revision, now);
1333
- this.insertEvent(row.resourceId, revision + 1, "lease.claimed", runnerId.startsWith("host-") ? "host" : "session", runnerId, { expiresAt }, now, generation);
1334
- return { ...this.requireWorkflowRun(runId), claimToken };
1335
- });
1666
+ }
1667
+ const revision = this.resourceRevision(row.resourceId);
1668
+ this.bumpResource(row.resourceId, revision, now);
1669
+ this.insertEvent(row.resourceId, revision + 1, "lease.claimed", runnerId.startsWith("host-") ? "host" : "session", runnerId, { expiresAt }, now, generation);
1670
+ return { ...this.requireWorkflowRun(runId), claimToken };
1336
1671
  }
1337
1672
  updateClaimedRunStatus(runId, claimToken, status, nowValue) {
1338
1673
  const now = epoch(validTimestamp(nowValue));
@@ -1380,6 +1715,7 @@ export class SqliteControllerStore {
1380
1715
  const revision = this.resourceRevision(row.resourceId);
1381
1716
  this.bumpResource(row.resourceId, revision, now);
1382
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);
1383
1719
  return true;
1384
1720
  });
1385
1721
  }
@@ -1430,11 +1766,76 @@ export class SqliteControllerStore {
1430
1766
  updated_at = ?, finished_at = ?
1431
1767
  WHERE run_id = ?`)
1432
1768
  .run(status, errorHash, now, now, runId);
1769
+ if (status === "cancelled") {
1770
+ this.cancelWorkflowRunDependents(runId, lease.ownerId ?? "workflow-control", errorHash, now);
1771
+ }
1433
1772
  this.bumpResource(row.resourceId, revision, now);
1434
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);
1435
1775
  return true;
1436
1776
  });
1437
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
+ }
1438
1839
  turnIntent(intentId) {
1439
1840
  const row = this.turnIntentRow(intentId);
1440
1841
  return row === undefined ? undefined : this.mapTurnIntent(row);
@@ -1629,7 +2030,8 @@ function workflowRunSelect(clause) {
1629
2030
  q.status, q.available_at AS availableAt, q.affinity_runner_id AS affinityRunnerId,
1630
2031
  q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
1631
2032
  q.error_hash AS errorHash, b.origin_session_id AS originSessionId,
1632
- 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,
1633
2035
  q.started_at AS startedAt, q.finished_at AS finishedAt,
1634
2036
  l.generation AS leaseGeneration, l.owner_id AS ownerId, l.expires_at AS claimExpiresAt
1635
2037
  FROM runs r JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
@@ -1678,6 +2080,16 @@ function isRunSourceIdentityRow(value) {
1678
2080
  function isEffectRow(value) {
1679
2081
  return isRecord(value);
1680
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
+ }
1681
2093
  function isWorkflowRow(value) {
1682
2094
  return isRecord(value);
1683
2095
  }
@@ -1699,6 +2111,9 @@ function isNotificationRow(value) {
1699
2111
  function isProjectRow(value) {
1700
2112
  return isRecord(value);
1701
2113
  }
2114
+ function isCanonicalPathRow(value) {
2115
+ return isRecord(value) && typeof value.canonicalPath === "string";
2116
+ }
1702
2117
  function isFinalizerRow(value) {
1703
2118
  return isRecord(value);
1704
2119
  }