@osolmaz/pi-workflows 0.12.0 → 0.13.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 (257) hide show
  1. package/README.md +56 -31
  2. package/dist/builtins/autodoc.workflow.d.ts +4 -4
  3. package/dist/builtins/autoimplement.workflow.d.ts +797 -142
  4. package/dist/builtins/autoimplement.workflow.js +17 -105
  5. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  6. package/dist/builtins/autoplan.workflow.d.ts +126 -13
  7. package/dist/builtins/autoplan.workflow.js +200 -29
  8. package/dist/builtins/autoplan.workflow.js.map +1 -1
  9. package/dist/builtins/catalog.js +7 -5
  10. package/dist/builtins/catalog.js.map +1 -1
  11. package/dist/builtins/index.d.ts +4 -2
  12. package/dist/builtins/index.js +3 -1
  13. package/dist/builtins/index.js.map +1 -1
  14. package/dist/builtins/monitor.workflow.d.ts +56 -23
  15. package/dist/builtins/monitor.workflow.js +508 -329
  16. package/dist/builtins/monitor.workflow.js.map +1 -1
  17. package/dist/builtins/pi-agent-group.d.ts +72 -0
  18. package/dist/builtins/pi-agent-group.js +1087 -0
  19. package/dist/builtins/pi-agent-group.js.map +1 -0
  20. package/dist/builtins/plain-summary.workflow.d.ts +32 -0
  21. package/dist/builtins/plain-summary.workflow.js +134 -0
  22. package/dist/builtins/plain-summary.workflow.js.map +1 -0
  23. package/dist/builtins/plan-approval.workflow.d.ts +39 -5
  24. package/dist/builtins/plan-approval.workflow.js +92 -14
  25. package/dist/builtins/plan-approval.workflow.js.map +1 -1
  26. package/dist/builtins/plan-change.workflow.d.ts +449 -0
  27. package/dist/builtins/plan-change.workflow.js +256 -0
  28. package/dist/builtins/plan-change.workflow.js.map +1 -0
  29. package/dist/builtins/plan-presentation.js +2 -2
  30. package/dist/builtins/plan-presentation.js.map +1 -1
  31. package/dist/builtins/sanity-check.workflow.d.ts +50 -6
  32. package/dist/builtins/sanity-check.workflow.js +150 -28
  33. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  34. package/dist/controllers/effects.d.ts +3 -2
  35. package/dist/controllers/effects.js +8 -1
  36. package/dist/controllers/effects.js.map +1 -1
  37. package/dist/controllers/index.d.ts +1 -1
  38. package/dist/controllers/index.js +1 -1
  39. package/dist/controllers/index.js.map +1 -1
  40. package/dist/controllers/manager.d.ts +1 -0
  41. package/dist/controllers/manager.js +41 -17
  42. package/dist/controllers/manager.js.map +1 -1
  43. package/dist/controllers/sqlite.d.ts +107 -85
  44. package/dist/controllers/sqlite.js +1611 -1486
  45. package/dist/controllers/sqlite.js.map +1 -1
  46. package/dist/controllers/store.d.ts +9 -8
  47. package/dist/controllers/store.js +1 -31
  48. package/dist/controllers/store.js.map +1 -1
  49. package/dist/controllers/types.d.ts +3 -0
  50. package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
  51. package/dist/controllers/workflow-engine-scheduler.js +3 -5
  52. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  53. package/dist/controllers/workflows.d.ts +2 -2
  54. package/dist/controllers/workflows.js +11 -10
  55. package/dist/controllers/workflows.js.map +1 -1
  56. package/dist/extension/controller-host.js +2 -2
  57. package/dist/extension/controller-host.js.map +1 -1
  58. package/dist/extension/decision-channels.d.ts +4 -2
  59. package/dist/extension/decision-channels.js +333 -146
  60. package/dist/extension/decision-channels.js.map +1 -1
  61. package/dist/extension/executor.d.ts +9 -2
  62. package/dist/extension/executor.js +89 -2
  63. package/dist/extension/executor.js.map +1 -1
  64. package/dist/extension/herdr-viewer.d.ts +0 -1
  65. package/dist/extension/herdr-viewer.js +0 -4
  66. package/dist/extension/herdr-viewer.js.map +1 -1
  67. package/dist/extension/index.js +209 -127
  68. package/dist/extension/index.js.map +1 -1
  69. package/dist/extension/recorder.d.ts +3 -10
  70. package/dist/extension/recorder.js +13 -22
  71. package/dist/extension/recorder.js.map +1 -1
  72. package/dist/extension/session-events.d.ts +2 -2
  73. package/dist/extension/step-message.d.ts +7 -2
  74. package/dist/extension/step-message.js +94 -2
  75. package/dist/extension/step-message.js.map +1 -1
  76. package/dist/extension/widget.js +29 -3
  77. package/dist/extension/widget.js.map +1 -1
  78. package/dist/host/rpc-executor.d.ts +3 -0
  79. package/dist/host/rpc-executor.js +2 -0
  80. package/dist/host/rpc-executor.js.map +1 -1
  81. package/dist/host/runner.d.ts +3 -6
  82. package/dist/host/runner.js +83 -32
  83. package/dist/host/runner.js.map +1 -1
  84. package/dist/render/graph-render.js +15 -10
  85. package/dist/render/graph-render.js.map +1 -1
  86. package/dist/state/database.d.ts +35 -0
  87. package/dist/state/database.js +287 -0
  88. package/dist/state/database.js.map +1 -0
  89. package/dist/state/index.d.ts +4 -0
  90. package/dist/state/index.js +5 -0
  91. package/dist/state/index.js.map +1 -0
  92. package/dist/state/json.d.ts +6 -0
  93. package/dist/state/json.js +38 -0
  94. package/dist/state/json.js.map +1 -0
  95. package/dist/state/mutation.d.ts +57 -0
  96. package/dist/state/mutation.js +255 -0
  97. package/dist/state/mutation.js.map +1 -0
  98. package/dist/state/schema.d.ts +6 -0
  99. package/dist/state/schema.js +473 -0
  100. package/dist/state/schema.js.map +1 -0
  101. package/dist/viewer/cli.d.ts +2 -2
  102. package/dist/viewer/cli.js +142 -122
  103. package/dist/viewer/cli.js.map +1 -1
  104. package/dist/viewer/render.d.ts +4 -4
  105. package/dist/viewer/render.js +9 -23
  106. package/dist/viewer/render.js.map +1 -1
  107. package/dist/viewer/session-reducer.d.ts +1 -1
  108. package/dist/viewer/session-reducer.js +1 -1
  109. package/dist/viewer/tui.d.ts +3 -3
  110. package/dist/viewer/tui.js +15 -13
  111. package/dist/viewer/tui.js.map +1 -1
  112. package/dist/viewer/watch.d.ts +2 -5
  113. package/dist/viewer/watch.js +13 -13
  114. package/dist/viewer/watch.js.map +1 -1
  115. package/dist/workflows/catalog.js +7 -2
  116. package/dist/workflows/catalog.js.map +1 -1
  117. package/dist/workflows/composition.js +20 -2
  118. package/dist/workflows/composition.js.map +1 -1
  119. package/dist/workflows/decision-presentation.d.ts +1 -1
  120. package/dist/workflows/decision-presentation.js +51 -38
  121. package/dist/workflows/decision-presentation.js.map +1 -1
  122. package/dist/workflows/definition.d.ts +6 -2
  123. package/dist/workflows/definition.js +17 -0
  124. package/dist/workflows/definition.js.map +1 -1
  125. package/dist/workflows/engine.d.ts +7 -7
  126. package/dist/workflows/engine.js +196 -115
  127. package/dist/workflows/engine.js.map +1 -1
  128. package/dist/workflows/errors.d.ts +16 -3
  129. package/dist/workflows/errors.js +18 -3
  130. package/dist/workflows/errors.js.map +1 -1
  131. package/dist/workflows/human-decision.d.ts +45 -8
  132. package/dist/workflows/human-decision.js +641 -274
  133. package/dist/workflows/human-decision.js.map +1 -1
  134. package/dist/workflows/index.d.ts +5 -6
  135. package/dist/workflows/index.js +4 -5
  136. package/dist/workflows/index.js.map +1 -1
  137. package/dist/workflows/progress.d.ts +1 -0
  138. package/dist/workflows/progress.js +15 -3
  139. package/dist/workflows/progress.js.map +1 -1
  140. package/dist/workflows/schema.js +27 -1
  141. package/dist/workflows/schema.js.map +1 -1
  142. package/dist/workflows/store.d.ts +83 -103
  143. package/dist/workflows/store.js +907 -951
  144. package/dist/workflows/store.js.map +1 -1
  145. package/dist/workflows/types.d.ts +88 -126
  146. package/docs/CONTROLLERS.md +10 -8
  147. package/docs/DEFERRED_TURNS.md +2 -2
  148. package/docs/DESIGN_PHILOSOPHY.md +4 -2
  149. package/docs/HUMAN_DECISIONS.md +36 -47
  150. package/docs/HUMAN_DECISION_PRESENTATIONS.md +14 -24
  151. package/docs/MONITOR.md +179 -237
  152. package/docs/SQLITE_STATE.md +219 -0
  153. package/docs/WORKFLOW_COMPOSITION.md +27 -26
  154. package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
  155. package/docs/WORKFLOW_UPDATES.md +10 -9
  156. package/docs/development.md +9 -9
  157. package/docs/live-replay-protocol.md +28 -37
  158. package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
  159. package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
  160. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
  161. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +322 -0
  162. package/docs/plans/2026-08-21-sanity-check-plan.md +217 -95
  163. package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
  164. package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
  165. package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
  166. package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
  167. package/docs/plans/session-event-replay-implementation-plan.md +1 -1
  168. package/docs/plans/tui-viewer-implementation-plan.md +1 -1
  169. package/docs/session-event-journal.md +63 -434
  170. package/docs/tui-viewer.md +13 -15
  171. package/docs/workflows.md +127 -53
  172. package/examples/workflows/approved-plan.workflow.ts +19 -46
  173. package/examples/workflows/plain-summary.workflow.ts +1 -0
  174. package/herdr-plugin.toml +1 -1
  175. package/package.json +7 -7
  176. package/plugins/herdr/viewer.mjs +1 -11
  177. package/schemas/human-decision-accepted-v1.schema.json +15 -3
  178. package/schemas/human-decision-continuation-v1.schema.json +10 -1
  179. package/schemas/human-decision-delivery-v1.schema.json +8 -0
  180. package/schemas/human-decision-receipt-v1.schema.json +8 -0
  181. package/schemas/human-decision-request-v1.schema.json +24 -4
  182. package/skills/autoimplement/SKILL.md +27 -0
  183. package/skills/autoplan/SKILL.md +5 -4
  184. package/skills/monitor/SKILL.md +91 -135
  185. package/skills/pi-workflows/SKILL.md +9 -8
  186. package/skills/sanity-check/SKILL.md +44 -0
  187. package/src/builtins/autoimplement.workflow.ts +19 -118
  188. package/src/builtins/autoplan.workflow.ts +290 -40
  189. package/src/builtins/catalog.ts +7 -5
  190. package/src/builtins/index.ts +18 -1
  191. package/src/builtins/monitor.workflow.ts +673 -382
  192. package/src/builtins/pi-agent-group.ts +1407 -0
  193. package/src/builtins/plain-summary.workflow.ts +185 -0
  194. package/src/builtins/plan-approval.workflow.ts +157 -24
  195. package/src/builtins/plan-change.workflow.ts +321 -0
  196. package/src/builtins/plan-presentation.ts +2 -2
  197. package/src/builtins/sanity-check.workflow.ts +248 -48
  198. package/src/controllers/effects.ts +7 -0
  199. package/src/controllers/index.ts +0 -6
  200. package/src/controllers/manager.ts +72 -32
  201. package/src/controllers/sqlite.ts +2420 -1948
  202. package/src/controllers/store.ts +17 -38
  203. package/src/controllers/types.ts +3 -0
  204. package/src/controllers/workflow-engine-scheduler.ts +4 -8
  205. package/src/controllers/workflows.ts +45 -21
  206. package/src/extension/controller-host.ts +1 -2
  207. package/src/extension/decision-channels.ts +456 -203
  208. package/src/extension/executor.ts +105 -2
  209. package/src/extension/herdr-viewer.ts +0 -5
  210. package/src/extension/index.ts +251 -151
  211. package/src/extension/recorder.ts +13 -22
  212. package/src/extension/session-events.ts +2 -2
  213. package/src/extension/step-message.ts +106 -4
  214. package/src/extension/widget.ts +32 -5
  215. package/src/host/rpc-executor.ts +4 -0
  216. package/src/host/runner.ts +93 -41
  217. package/src/render/graph-render.ts +12 -6
  218. package/src/state/database.ts +375 -0
  219. package/src/state/index.ts +29 -0
  220. package/src/state/json.ts +42 -0
  221. package/src/state/mutation.ts +435 -0
  222. package/src/state/schema.ts +475 -0
  223. package/src/viewer/cli.ts +151 -144
  224. package/src/viewer/render.ts +19 -30
  225. package/src/viewer/session-reducer.ts +1 -1
  226. package/src/viewer/tui.ts +18 -17
  227. package/src/viewer/watch.ts +13 -17
  228. package/src/workflows/catalog.ts +7 -2
  229. package/src/workflows/composition.ts +21 -3
  230. package/src/workflows/decision-presentation.ts +56 -43
  231. package/src/workflows/definition.ts +36 -3
  232. package/src/workflows/engine.ts +228 -130
  233. package/src/workflows/errors.ts +27 -3
  234. package/src/workflows/human-decision.ts +921 -349
  235. package/src/workflows/index.ts +20 -30
  236. package/src/workflows/progress.ts +18 -3
  237. package/src/workflows/schema.ts +36 -1
  238. package/src/workflows/store.ts +1300 -1163
  239. package/src/workflows/types.ts +100 -137
  240. package/dist/builtins/sanity-check-session.d.ts +0 -17
  241. package/dist/builtins/sanity-check-session.js +0 -168
  242. package/dist/builtins/sanity-check-session.js.map +0 -1
  243. package/dist/workflows/artifacts.d.ts +0 -40
  244. package/dist/workflows/artifacts.js +0 -155
  245. package/dist/workflows/artifacts.js.map +0 -1
  246. package/dist/workflows/migrate-sources.d.ts +0 -42
  247. package/dist/workflows/migrate-sources.js +0 -133
  248. package/dist/workflows/migrate-sources.js.map +0 -1
  249. package/docs/run-bundles.md +0 -481
  250. package/schemas/human-decision-accepted-v2.schema.json +0 -50
  251. package/schemas/human-decision-delivery-v2.schema.json +0 -36
  252. package/schemas/human-decision-receipt-v2.schema.json +0 -39
  253. package/schemas/human-decision-request-v2.schema.json +0 -69
  254. package/schemas/human-decision-resolution-v2.schema.json +0 -27
  255. package/src/builtins/sanity-check-session.ts +0 -205
  256. package/src/workflows/artifacts.ts +0 -188
  257. package/src/workflows/migrate-sources.ts +0 -178
@@ -1,76 +1,82 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import Database from "better-sqlite3";
4
+ import { StateDatabase, workflowStatePath } from "../state/database.js";
5
+ import { resourceIdFor, tokenHash } from "../state/mutation.js";
5
6
  import { EffectRequestConflictError, ResourceConflictError, ResourceNotFoundError, WorkflowRequestConflictError, } from "./errors.js";
6
- import { canonicalJson, parseStoredJson } from "./json.js";
7
- import { CONTROLLER_STORE_SCHEMA, controllerStorePath, } from "./store.js";
8
- const MAX_KEY_LENGTH = 512;
9
- const MAX_ERROR_LENGTH = 8_192;
10
- const MAX_EVENT_BYTES = 64 * 1024;
11
- const MAX_RESOURCE_VALUE_BYTES = 1024 * 1024;
12
7
  const TURN_INTENT_FACTS_SCHEMA = "pi-workflows.deferred-turn-facts.v1";
13
8
  export class SqliteControllerStore {
14
9
  filePath;
15
- database;
10
+ state;
11
+ ownsState;
12
+ projectId;
16
13
  closed = false;
17
- constructor(filePath = controllerStorePath(), options = {}) {
18
- this.filePath = path.resolve(filePath);
19
- const readOnly = options.readOnly ?? false;
20
- if (!readOnly) {
21
- fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
22
- fs.chmodSync(path.dirname(this.filePath), 0o700);
23
- }
24
- this.database = new Database(this.filePath, {
25
- readonly: readOnly,
26
- fileMustExist: readOnly,
27
- });
28
- if (!readOnly) {
29
- fs.chmodSync(this.filePath, 0o600);
30
- }
31
- this.configure(readOnly);
32
- this.initializeSchema(readOnly);
14
+ constructor(filePath = workflowStatePath(), options = {}) {
15
+ this.ownsState = options.state === undefined;
16
+ this.state =
17
+ options.state ??
18
+ new StateDatabase({
19
+ filePath,
20
+ mode: options.readOnly === true ? "read-only" : "read-write",
21
+ checkLegacyState: filePath === workflowStatePath(),
22
+ });
23
+ 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);
33
27
  }
34
28
  close() {
35
- if (this.closed) {
29
+ if (this.closed)
36
30
  return;
37
- }
38
31
  this.closed = true;
39
- this.database.close();
32
+ if (this.ownsState)
33
+ this.state.close();
40
34
  }
41
35
  putResource(options) {
36
+ this.assertWritable();
42
37
  validateName(options.controller, "controller");
43
38
  validateKey(options.key, "resource key");
44
- const now = validTimestamp(options.now);
45
- const specJson = canonicalJson(options.spec, "controller resource spec");
46
- validateJsonSize(specJson, "Controller resource spec", MAX_RESOURCE_VALUE_BYTES);
39
+ const now = epoch(validTimestamp(options.now));
40
+ const specHash = this.state.putJson(options.spec, now);
47
41
  const initialStatus = {
48
42
  observedGeneration: 0,
49
43
  conditions: [],
50
44
  controllerStatus: options.initialStatus,
51
45
  };
52
- const statusJson = canonicalJson(initialStatus, "controller resource status");
53
- validateJsonSize(statusJson, "Controller resource status", MAX_RESOURCE_VALUE_BYTES);
54
- return this.transaction(() => {
55
- const existing = this.resourceRow({ controller: options.controller, key: options.key });
46
+ const statusHash = this.state.putJson(initialStatus, now);
47
+ return this.state.transaction(() => {
48
+ const existing = this.controllerRow({ controller: options.controller, key: options.key });
56
49
  if (existing === undefined) {
57
50
  const uid = randomUUID();
58
- this.database
59
- .prepare(`INSERT INTO resources (
60
- controller, resource_key, uid, resource_version, generation,
61
- spec_json, status_json, deletion_timestamp, finalizers_json, updated_at
62
- ) VALUES (?, ?, ?, 1, 1, ?, ?, NULL, '[]', ?)`)
63
- .run(options.controller, options.key, uid, specJson, statusJson, now);
64
- this.enqueueRow({ controller: options.controller, key: options.key }, epoch(now));
51
+ const controllerResourceId = `controller-resource-${randomUUID()}`;
52
+ const resourceId = resourceIdFor("controller", `${this.requireProjectId()}:${options.controller}:${options.key}`);
53
+ this.state.connection
54
+ .prepare(`INSERT INTO resources(
55
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
56
+ ) VALUES (?, 'controller', ?, 1, ?, ?)`)
57
+ .run(resourceId, controllerResourceId, now, now);
58
+ this.state.connection
59
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
60
+ .run(resourceId);
61
+ this.state.connection
62
+ .prepare(`INSERT INTO controller_resources(
63
+ controller_resource_id, resource_id, project_id, controller_name,
64
+ resource_key, uid, generation, spec_hash, status_hash, created_at, updated_at
65
+ ) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)`)
66
+ .run(controllerResourceId, resourceId, this.requireProjectId(), options.controller, options.key, uid, specHash, statusHash, now, now);
67
+ this.enqueueControllerRow(controllerResourceId, now, now);
68
+ this.insertEvent(resourceId, 1, "resource.created", "control", null, { generation: 1 }, now);
65
69
  }
66
- else if (existing.spec_json !== specJson) {
67
- this.database
68
- .prepare(`UPDATE resources
69
- SET spec_json = ?, resource_version = resource_version + 1,
70
- generation = generation + 1, updated_at = ?
71
- WHERE controller = ? AND resource_key = ?`)
72
- .run(specJson, now, options.controller, options.key);
73
- this.enqueueRow({ controller: options.controller, key: options.key }, epoch(now));
70
+ else if (!existing.specHash.equals(specHash)) {
71
+ const revision = existing.resourceVersion + 1;
72
+ this.state.connection
73
+ .prepare(`UPDATE controller_resources
74
+ SET spec_hash = ?, generation = generation + 1, updated_at = ?
75
+ WHERE controller_resource_id = ?`)
76
+ .run(specHash, now, existing.controllerResourceId);
77
+ this.bumpResource(existing.resourceId, existing.resourceVersion, now);
78
+ this.enqueueControllerRow(existing.controllerResourceId, now, now);
79
+ this.insertEvent(existing.resourceId, revision, "resource.spec_updated", "control", null, { generation: existing.generation + 1 }, now);
74
80
  }
75
81
  return this.requireResource({
76
82
  controller: options.controller,
@@ -79,1627 +85,1746 @@ export class SqliteControllerStore {
79
85
  });
80
86
  }
81
87
  getResource(ref) {
82
- const row = this.resourceRow(ref);
83
- return row === undefined ? undefined : resourceFromRow(row);
88
+ const row = this.controllerRow(ref);
89
+ return row === undefined
90
+ ? undefined
91
+ : this.mapControllerResource(row);
84
92
  }
85
93
  getResourceByUid(uid) {
86
- const row = this.database.prepare("SELECT * FROM resources WHERE uid = ?").get(uid);
87
- return row === undefined ? undefined : resourceFromRow(row);
94
+ const row = this.state.connection.prepare(controllerSelect("WHERE c.uid = ?")).get(uid);
95
+ return isControllerRow(row) ? this.mapControllerResource(row) : undefined;
88
96
  }
89
97
  listResources(options = {}) {
90
- const rows = options.controller === undefined
91
- ? this.database
92
- .prepare("SELECT * FROM resources ORDER BY controller, resource_key")
93
- .all()
94
- : this.database
95
- .prepare("SELECT * FROM resources WHERE controller = ? ORDER BY resource_key")
96
- .all(options.controller);
97
- return rows.map((row) => resourceFromRow(row));
98
+ const clauses = ["1 = 1"];
99
+ const params = [];
100
+ if (this.projectId !== null) {
101
+ clauses.push("c.project_id = ?");
102
+ params.push(this.projectId);
103
+ }
104
+ if (options.controller !== undefined) {
105
+ clauses.push("c.controller_name = ?");
106
+ params.push(options.controller);
107
+ }
108
+ const rows = this.state.connection
109
+ .prepare(controllerSelect(`WHERE ${clauses.join(" AND ")} ORDER BY c.controller_name, c.resource_key`))
110
+ .all(...params);
111
+ return rows
112
+ .filter(isControllerRow)
113
+ .map((row) => this.mapControllerResource(row));
98
114
  }
99
115
  updateStatus(options) {
100
- const now = validTimestamp(options.now);
101
- const statusJson = canonicalJson(options.status, "controller resource status");
102
- validateJsonSize(statusJson, "Controller resource status", MAX_RESOURCE_VALUE_BYTES);
103
- if (options.finalizers !== undefined) {
104
- validateFinalizers(options.finalizers);
105
- }
106
- return this.transaction(() => {
107
- const current = this.resourceRow(options.ref);
108
- if (current === undefined) {
109
- throw new ResourceNotFoundError(options.ref.controller, options.ref.key);
110
- }
111
- if (current.resource_version !== options.expectedResourceVersion) {
116
+ const now = epoch(validTimestamp(options.now));
117
+ return this.state.transaction(() => {
118
+ const row = this.requireControllerRow(options.ref);
119
+ this.assertControllerClaim(row, options.claim, now);
120
+ if (row.resourceVersion !== options.expectedResourceVersion) {
112
121
  throw new ResourceConflictError(options.ref.controller, options.ref.key);
113
122
  }
114
- const finalizersJson = options.finalizers === undefined
115
- ? current.finalizers_json
116
- : canonicalJson(options.finalizers);
117
- if (current.status_json === statusJson && current.finalizers_json === finalizersJson) {
118
- return resourceFromRow(current);
119
- }
120
- this.database
121
- .prepare(`UPDATE resources
122
- SET status_json = ?, finalizers_json = ?,
123
- resource_version = resource_version + 1, updated_at = ?
124
- WHERE controller = ? AND resource_key = ? AND resource_version = ?`)
125
- .run(statusJson, finalizersJson, now, options.ref.controller, options.ref.key, options.expectedResourceVersion);
123
+ const statusHash = this.state.putJson(options.status, now);
124
+ this.state.connection
125
+ .prepare("UPDATE controller_resources SET status_hash = ?, updated_at = ? WHERE controller_resource_id = ?")
126
+ .run(statusHash, now, row.controllerResourceId);
127
+ if (options.finalizers !== undefined)
128
+ this.replaceFinalizers(row.controllerResourceId, options.finalizers);
129
+ this.bumpResource(row.resourceId, row.resourceVersion, now);
130
+ this.insertEvent(row.resourceId, row.resourceVersion + 1, "resource.status_updated", "controller", options.claim.ownerId, {}, now, options.claim.generation);
131
+ options.claim.resourceVersion = row.resourceVersion + 1;
126
132
  return this.requireResource(options.ref);
127
133
  });
128
134
  }
129
- requestDeletion(ref, now) {
130
- const timestamp = validTimestamp(now);
131
- return this.transaction(() => {
132
- const row = this.resourceRow(ref);
133
- if (row === undefined) {
134
- throw new ResourceNotFoundError(ref.controller, ref.key);
135
- }
136
- if (row.deletion_timestamp === null) {
137
- this.database
138
- .prepare(`UPDATE resources
139
- SET deletion_timestamp = ?, resource_version = resource_version + 1, updated_at = ?
140
- WHERE controller = ? AND resource_key = ?`)
141
- .run(timestamp, timestamp, ref.controller, ref.key);
135
+ requestDeletion(ref, nowValue) {
136
+ const now = epoch(validTimestamp(nowValue));
137
+ return this.state.transaction(() => {
138
+ const row = this.requireControllerRow(ref);
139
+ if (row.deletionRequestedAt === null) {
140
+ this.state.connection
141
+ .prepare("UPDATE controller_resources SET deletion_requested_at = ?, updated_at = ? WHERE controller_resource_id = ?")
142
+ .run(now, now, row.controllerResourceId);
143
+ this.bumpResource(row.resourceId, row.resourceVersion, now);
144
+ this.enqueueControllerRow(row.controllerResourceId, now, now);
145
+ this.insertEvent(row.resourceId, row.resourceVersion + 1, "resource.deletion_requested", "control", null, {}, now);
142
146
  }
143
- this.enqueueRow(ref, epoch(timestamp));
144
147
  return this.requireResource(ref);
145
148
  });
146
149
  }
147
150
  updateFinalizers(options) {
148
- validateFinalizers(options.finalizers);
149
- const now = validTimestamp(options.now);
150
- const result = this.database
151
- .prepare(`UPDATE resources
152
- SET finalizers_json = ?, resource_version = resource_version + 1, updated_at = ?
153
- WHERE controller = ? AND resource_key = ? AND resource_version = ?`)
154
- .run(canonicalJson(options.finalizers), now, options.ref.controller, options.ref.key, options.expectedResourceVersion);
155
- if (result.changes !== 1) {
156
- this.throwMissingOrConflict(options.ref);
157
- }
158
- return this.requireResource(options.ref);
151
+ const now = epoch(validTimestamp(options.now));
152
+ return this.state.transaction(() => {
153
+ const row = this.requireControllerRow(options.ref);
154
+ if (row.resourceVersion !== options.expectedResourceVersion) {
155
+ throw new ResourceConflictError(options.ref.controller, options.ref.key);
156
+ }
157
+ this.replaceFinalizers(row.controllerResourceId, options.finalizers);
158
+ this.state.connection
159
+ .prepare("UPDATE controller_resources SET updated_at = ? WHERE controller_resource_id = ?")
160
+ .run(now, row.controllerResourceId);
161
+ this.bumpResource(row.resourceId, row.resourceVersion, now);
162
+ this.insertEvent(row.resourceId, row.resourceVersion + 1, "resource.finalizers_updated", "control", null, {}, now);
163
+ return this.requireResource(options.ref);
164
+ });
159
165
  }
160
- deleteResource(ref, expectedResourceVersion) {
161
- const row = this.resourceRow(ref);
162
- if (row === undefined) {
163
- return false;
164
- }
165
- const finalizers = parseStoredJson(row.finalizers_json, "resource finalizers");
166
- if (row.deletion_timestamp === null || finalizers.length > 0) {
167
- throw new Error(`Controller resource ${ref.controller}/${ref.key} is not ready for deletion`);
168
- }
169
- const result = this.database
170
- .prepare(`DELETE FROM resources
171
- WHERE controller = ? AND resource_key = ? AND resource_version = ?`)
172
- .run(ref.controller, ref.key, expectedResourceVersion);
173
- if (result.changes !== 1) {
174
- throw new ResourceConflictError(ref.controller, ref.key);
175
- }
176
- return true;
166
+ deleteResource(ref, expectedResourceVersion, claim) {
167
+ return this.state.transaction(() => {
168
+ const row = this.requireControllerRow(ref);
169
+ this.assertControllerClaim(row, claim, Date.now());
170
+ if (row.resourceVersion !== expectedResourceVersion) {
171
+ throw new ResourceConflictError(ref.controller, ref.key);
172
+ }
173
+ if (row.deletionRequestedAt === null || this.finalizers(row.controllerResourceId).length > 0)
174
+ return false;
175
+ this.state.connection
176
+ .prepare("DELETE FROM controller_resources WHERE controller_resource_id = ?")
177
+ .run(row.controllerResourceId);
178
+ return true;
179
+ });
177
180
  }
178
181
  enqueue(ref, availableAt) {
179
- const at = validTimestamp(availableAt);
180
- if (this.resourceRow(ref) === undefined) {
181
- throw new ResourceNotFoundError(ref.controller, ref.key);
182
- }
183
- this.enqueueRow(ref, epoch(at));
182
+ const row = this.requireControllerRow(ref);
183
+ const now = Date.now();
184
+ this.enqueueControllerRow(row.controllerResourceId, epoch(validTimestamp(availableAt)), now);
184
185
  }
185
186
  claimNext(options) {
186
- validateDuration(options.leaseMs, "leaseMs");
187
- if (options.controllers.length === 0) {
187
+ if (options.controllers.length === 0)
188
188
  return undefined;
189
- }
190
- const now = validTimestamp(options.now);
191
- const nowMs = epoch(now);
189
+ const now = epoch(validTimestamp(options.now));
192
190
  const placeholders = options.controllers.map(() => "?").join(", ");
193
- return this.transaction(() => {
194
- const row = this.database
195
- .prepare(`SELECT * FROM queue
196
- WHERE controller IN (${placeholders})
197
- AND available_at <= ?
198
- AND (claim_token IS NULL OR claim_expires_at <= ?)
199
- ORDER BY available_at, controller, resource_key
200
- LIMIT 1`)
201
- .get(...options.controllers, nowMs, nowMs);
202
- if (row === undefined) {
191
+ return this.state.transaction(() => {
192
+ const row = this.state.connection
193
+ .prepare(`${controllerSelect(`
194
+ JOIN controller_queue q ON q.controller_resource_id = c.controller_resource_id
195
+ JOIN leases l ON l.resource_id = c.resource_id
196
+ WHERE c.project_id = ?
197
+ AND c.controller_name IN (${placeholders})
198
+ AND q.available_at <= ?
199
+ AND (l.owner_id IS NULL OR l.expires_at <= ?)
200
+ ORDER BY q.available_at, c.controller_name, c.resource_key
201
+ LIMIT 1`)}`)
202
+ .get(this.requireProjectId(), ...options.controllers, now, now);
203
+ if (!isControllerRow(row))
203
204
  return undefined;
204
- }
205
- const token = randomUUID();
206
- const expiresAtMs = nowMs + options.leaseMs;
207
- const result = this.database
208
- .prepare(`UPDATE queue
209
- SET claim_token = ?, claim_version = version, claim_expires_at = ?
210
- WHERE controller = ? AND resource_key = ?
211
- AND (claim_token IS NULL OR claim_expires_at <= ?)`)
212
- .run(token, expiresAtMs, row.controller, row.resource_key, nowMs);
213
- if (result.changes !== 1) {
205
+ const lease = this.requireLease(row.resourceId);
206
+ const token = randomBytes(32).toString("base64url");
207
+ const generation = lease.generation + 1;
208
+ const expiresAt = now + options.leaseMs;
209
+ const result = this.state.connection
210
+ .prepare(`UPDATE leases
211
+ SET generation = ?, owner_type = 'controller', owner_id = ?, token_hash = ?,
212
+ acquired_at = ?, heartbeat_at = ?, expires_at = ?
213
+ WHERE resource_id = ? AND generation = ?
214
+ AND (owner_id IS NULL OR expires_at <= ?)`)
215
+ .run(generation, options.ownerId, tokenHash(token), now, now, expiresAt, row.resourceId, lease.generation, now);
216
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
217
+ if (result.changes !== 1)
214
218
  return undefined;
215
- }
219
+ this.bumpResource(row.resourceId, row.resourceVersion, now);
220
+ this.insertEvent(row.resourceId, row.resourceVersion + 1, "lease.claimed", "controller", options.ownerId, { expiresAt }, now, generation);
216
221
  return {
217
- controller: row.controller,
218
- key: row.resource_key,
222
+ controller: row.controllerName,
223
+ key: row.resourceKey,
224
+ ownerId: options.ownerId,
219
225
  token,
220
- queueVersion: row.version,
221
- consecutiveErrors: row.consecutive_errors,
222
- expiresAt: iso(expiresAtMs),
226
+ generation,
227
+ queueVersion: this.queueVersion(row.controllerResourceId),
228
+ resourceVersion: row.resourceVersion + 1,
229
+ consecutiveErrors: this.queueErrors(row.controllerResourceId),
230
+ expiresAt: new Date(expiresAt).toISOString(),
223
231
  };
224
232
  });
225
233
  }
226
- renewClaim(claim, leaseMs, now) {
227
- validateDuration(leaseMs, "leaseMs");
228
- const nowMs = epoch(validTimestamp(now));
229
- const expiresAt = nowMs + leaseMs;
230
- const result = this.database
231
- .prepare(`UPDATE queue SET claim_expires_at = ?
232
- WHERE controller = ? AND resource_key = ? AND claim_token = ?
233
- AND claim_expires_at > ?`)
234
- .run(expiresAt, claim.controller, claim.key, claim.token, nowMs);
234
+ renewClaim(claim, leaseMs, nowValue) {
235
+ const now = epoch(validTimestamp(nowValue));
236
+ const expiresAt = now + leaseMs;
237
+ const row = this.controllerRow({ controller: claim.controller, key: claim.key });
238
+ if (row === undefined)
239
+ return false;
240
+ const result = this.state.connection
241
+ .prepare(`UPDATE leases SET heartbeat_at = ?, expires_at = ?
242
+ WHERE resource_id = ? AND owner_type = 'controller' AND owner_id = ?
243
+ AND token_hash = ? AND generation = ? AND expires_at > ?`)
244
+ .run(now, expiresAt, row.resourceId, claim.ownerId, tokenHash(claim.token), claim.generation, now);
245
+ if (result.changes === 1)
246
+ claim.expiresAt = new Date(expiresAt).toISOString();
235
247
  return result.changes === 1;
236
248
  }
237
- settleClaim(claim, now) {
238
- const nowMs = epoch(validTimestamp(now));
239
- return this.transaction(() => {
240
- const row = this.claimedRow(claim);
241
- if (row === undefined) {
242
- return false;
243
- }
244
- if (row.version === row.claim_version) {
245
- this.database
246
- .prepare("DELETE FROM queue WHERE controller = ? AND resource_key = ? AND claim_token = ?")
247
- .run(claim.controller, claim.key, claim.token);
248
- }
249
- else {
250
- this.database
251
- .prepare(`UPDATE queue
252
- SET claim_token = NULL, claim_version = NULL, claim_expires_at = NULL,
253
- available_at = MIN(available_at, ?), consecutive_errors = 0, last_error = NULL
254
- WHERE controller = ? AND resource_key = ? AND claim_token = ?`)
255
- .run(nowMs, claim.controller, claim.key, claim.token);
256
- }
257
- return true;
258
- });
249
+ settleClaim(claim, nowValue) {
250
+ return this.settleControllerClaim(claim, undefined, nowValue);
259
251
  }
260
- requeueClaim(claim, options, _now) {
261
- const availableAt = epoch(validTimestamp(options.availableAt));
262
- const error = options.error === undefined ? null : truncateError(options.error);
263
- return this.transaction(() => {
264
- const row = this.claimedRow(claim);
265
- if (row === undefined) {
266
- return false;
267
- }
268
- const eventArrived = row.version !== row.claim_version;
269
- const nextAvailableAt = eventArrived ? Math.min(row.available_at, availableAt) : availableAt;
270
- const nextErrors = options.error === undefined ? 0 : row.consecutive_errors + 1;
271
- this.database
272
- .prepare(`UPDATE queue
273
- SET claim_token = NULL, claim_version = NULL, claim_expires_at = NULL,
274
- available_at = ?, consecutive_errors = ?, last_error = ?
275
- WHERE controller = ? AND resource_key = ? AND claim_token = ?`)
276
- .run(nextAvailableAt, nextErrors, error, claim.controller, claim.key, claim.token);
277
- return true;
278
- });
252
+ requeueClaim(claim, options, nowValue) {
253
+ return this.settleControllerClaim(claim, options, nowValue);
279
254
  }
280
255
  listQueue() {
281
- const rows = this.database
282
- .prepare("SELECT * FROM queue ORDER BY available_at, controller, resource_key")
283
- .all();
284
- return rows.map((row) => ({
256
+ const rows = this.state.connection
257
+ .prepare(`SELECT c.controller_name AS controller, c.resource_key AS resourceKey,
258
+ q.available_at AS availableAt, q.consecutive_errors AS consecutiveErrors,
259
+ l.expires_at AS claimExpiresAt
260
+ FROM controller_queue q
261
+ JOIN controller_resources c ON c.controller_resource_id = q.controller_resource_id
262
+ JOIN leases l ON l.resource_id = c.resource_id
263
+ WHERE c.project_id = ? ORDER BY q.available_at, c.controller_name, c.resource_key`)
264
+ .all(this.requireProjectId());
265
+ return rows.filter(isQueueListRow).map((row) => ({
285
266
  controller: row.controller,
286
- key: row.resource_key,
287
- availableAt: iso(row.available_at),
288
- consecutiveErrors: row.consecutive_errors,
289
- ...(row.claim_expires_at === null ? {} : { claimExpiresAt: iso(row.claim_expires_at) }),
267
+ key: row.resourceKey,
268
+ availableAt: new Date(row.availableAt).toISOString(),
269
+ consecutiveErrors: row.consecutiveErrors,
270
+ ...(row.claimExpiresAt === null
271
+ ? {}
272
+ : { claimExpiresAt: new Date(row.claimExpiresAt).toISOString() }),
290
273
  }));
291
274
  }
292
275
  reserveEffect(options) {
293
- validateKey(options.key, "effect key");
294
- validateName(options.kind, "effect kind");
295
- const now = validTimestamp(options.now);
296
- return this.transaction(() => {
276
+ const now = epoch(validTimestamp(options.now));
277
+ return this.state.transaction(() => {
278
+ const source = this.requireControllerRowByUid(options.resourceUid);
279
+ this.assertControllerClaim(source, options.claim, now);
280
+ const effectId = effectIdFor(source.resourceId, options.key);
297
281
  const existing = this.effectRow(options.resourceUid, options.key);
298
282
  if (existing !== undefined) {
299
- if (existing.request_fingerprint !== options.requestFingerprint ||
300
- existing.generation !== options.generation ||
301
- existing.kind !== options.kind) {
283
+ if (existing.generation !== options.generation ||
284
+ existing.kind !== options.kind ||
285
+ existing.requestFingerprint !== options.requestFingerprint) {
302
286
  throw new EffectRequestConflictError(options.key);
303
287
  }
304
- return { record: effectFromRow(existing), created: false };
288
+ return { record: this.mapEffect(existing), created: false };
305
289
  }
306
- this.database
307
- .prepare(`INSERT INTO effects (
308
- effect_key, resource_uid, generation, kind, state,
309
- request_fingerprint, started_at
310
- ) VALUES (?, ?, ?, ?, 'pending', ?, ?)`)
311
- .run(options.key, options.resourceUid, options.generation, options.kind, options.requestFingerprint, now);
290
+ const effectResourceId = resourceIdFor("effect", effectId);
291
+ const payloadHash = this.state.putJson({
292
+ key: options.key,
293
+ resourceUid: options.resourceUid,
294
+ generation: options.generation,
295
+ kind: options.kind,
296
+ requestFingerprint: options.requestFingerprint,
297
+ }, now);
298
+ this.state.connection
299
+ .prepare(`INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
300
+ VALUES (?, 'effect', ?, 1, ?, ?)`)
301
+ .run(effectResourceId, effectId, now, now);
302
+ this.state.connection
303
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
304
+ .run(effectResourceId);
305
+ this.state.connection
306
+ .prepare(`INSERT INTO effects(
307
+ effect_id, resource_id, source_resource_id, source_revision, effect_type,
308
+ idempotency_key, payload_hash, owner_scope, status, attempt_count,
309
+ created_at, updated_at
310
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'controller', 'pending', 0, ?, ?)`)
311
+ .run(effectId, effectResourceId, source.resourceId, source.resourceVersion, options.kind, options.key, payloadHash, now, now);
312
+ this.insertEvent(effectResourceId, 1, "effect.reserved", "controller", options.claim.ownerId, {}, now, options.claim.generation);
312
313
  return {
313
- record: this.requireEffect(options.resourceUid, options.key),
314
+ record: this.mapEffect(this.requireEffectRow(options.resourceUid, options.key)),
314
315
  created: true,
315
316
  };
316
317
  });
317
318
  }
318
319
  getEffect(resourceUid, key) {
319
320
  const row = this.effectRow(resourceUid, key);
320
- return row === undefined ? undefined : effectFromRow(row);
321
+ return row === undefined ? undefined : this.mapEffect(row);
321
322
  }
322
323
  updateEffect(options) {
323
- const row = this.effectRow(options.resourceUid, options.key);
324
- if (row === undefined) {
325
- throw new Error(`Effect record not found: ${options.resourceUid}/${options.key}`);
326
- }
327
- if ((row.state === "applied" || row.state === "rejected") && row.state !== options.state) {
328
- throw new Error(`Effect ${JSON.stringify(options.key)} is already ${row.state}`);
329
- }
330
- const completedAt = options.state === "applied" || options.state === "rejected"
331
- ? validTimestamp(options.now)
332
- : null;
333
- this.database
334
- .prepare(`UPDATE effects
335
- SET state = ?, external_ref = COALESCE(?, external_ref), error = ?, completed_at = ?
336
- WHERE resource_uid = ? AND effect_key = ?`)
337
- .run(options.state, options.externalRef ?? null, options.error === undefined ? null : truncateError(options.error), completedAt, options.resourceUid, options.key);
338
- return this.requireEffect(options.resourceUid, options.key);
324
+ const now = epoch(validTimestamp(options.now));
325
+ return this.state.transaction(() => {
326
+ const source = this.requireControllerRowByUid(options.resourceUid);
327
+ this.assertControllerClaim(source, options.claim, now);
328
+ const row = this.requireEffectRow(options.resourceUid, options.key);
329
+ const effectResourceId = resourceIdFor("effect", row.effectId);
330
+ const revision = this.resourceRevision(effectResourceId);
331
+ const errorHash = options.error === undefined ? null : this.state.putText(options.error, now);
332
+ this.state.connection
333
+ .prepare(`UPDATE effects
334
+ SET status = ?, external_ref = ?, error_hash = ?, updated_at = ?, settled_at = ?
335
+ WHERE effect_id = ?`)
336
+ .run(effectStatus(options.state), options.externalRef ?? null, errorHash, now, options.state === "pending" ? null : now, row.effectId);
337
+ this.bumpResource(effectResourceId, revision, now);
338
+ this.insertEvent(effectResourceId, revision + 1, `effect.${options.state}`, "controller", options.claim.ownerId, {}, now, options.claim.generation);
339
+ return this.mapEffect(this.requireEffectRow(options.resourceUid, options.key));
340
+ });
339
341
  }
340
342
  listEffects(resourceUid) {
341
- const rows = this.database
342
- .prepare("SELECT * FROM effects WHERE resource_uid = ? ORDER BY started_at, effect_key")
343
+ const rows = this.state.connection
344
+ .prepare(effectSelect("WHERE c.uid = ? ORDER BY e.created_at, e.effect_id"))
343
345
  .all(resourceUid);
344
- return rows.map(effectFromRow);
346
+ return rows.filter(isEffectRow).map((row) => this.mapEffect(row));
345
347
  }
346
348
  reserveWorkflow(options) {
347
- validateKey(options.requestKey, "workflow request key");
348
- validateKey(options.workflow, "workflow name");
349
- return this.transaction(() => {
349
+ const now = Date.now();
350
+ return this.state.transaction(() => {
351
+ const source = this.requireControllerRowByUid(options.resourceUid);
352
+ this.assertControllerClaim(source, options.claim, now);
350
353
  const existing = this.workflowRow(options.resourceUid, options.requestKey);
351
354
  if (existing !== undefined) {
352
- if (existing.input_fingerprint !== options.inputFingerprint ||
353
- existing.workflow !== options.workflow) {
355
+ if (existing.workflowName !== options.workflow ||
356
+ existing.inputFingerprint.toString("hex") !== options.inputFingerprint) {
354
357
  throw new WorkflowRequestConflictError(options.requestKey);
355
358
  }
356
- return { record: workflowFromRow(existing), created: false };
359
+ return { record: this.mapWorkflow(existing), created: false };
357
360
  }
358
361
  const requestId = randomUUID();
359
- this.database
360
- .prepare(`INSERT INTO workflow_requests (
361
- request_id, resource_uid, request_key, input_fingerprint,
362
- workflow, state, attempt
363
- ) VALUES (?, ?, ?, ?, ?, 'pending', 0)`)
364
- .run(requestId, options.resourceUid, options.requestKey, options.inputFingerprint, options.workflow);
365
- return {
366
- record: this.requireWorkflow(requestId),
367
- created: true,
368
- };
362
+ this.state.connection
363
+ .prepare(`INSERT INTO controller_workflows(
364
+ request_id, controller_resource_id, request_key, workflow_name,
365
+ input_fingerprint, status, attempt_count, created_at, updated_at
366
+ ) VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`)
367
+ .run(requestId, source.controllerResourceId, options.requestKey, options.workflow, Buffer.from(options.inputFingerprint, "hex"), now, now);
368
+ return { record: this.mapWorkflow(this.requireWorkflowRow(requestId)), created: true };
369
369
  });
370
370
  }
371
371
  getWorkflow(resourceUid, requestKey) {
372
372
  const row = this.workflowRow(resourceUid, requestKey);
373
- return row === undefined ? undefined : workflowFromRow(row);
373
+ return row === undefined ? undefined : this.mapWorkflow(row);
374
374
  }
375
375
  getWorkflowByRequestId(requestId) {
376
- const row = this.database
377
- .prepare("SELECT * FROM workflow_requests WHERE request_id = ?")
378
- .get(requestId);
379
- return row === undefined ? undefined : workflowFromRow(row);
376
+ const row = this.workflowRowById(requestId);
377
+ return row === undefined ? undefined : this.mapWorkflow(row);
378
+ }
379
+ updateWorkflow(requestId, update, claim) {
380
+ const now = Date.now();
381
+ return this.state.transaction(() => {
382
+ const row = this.requireWorkflowRow(requestId);
383
+ const source = this.requireControllerRowByUid(row.resourceUid);
384
+ this.assertControllerClaim(source, claim, now);
385
+ const errorHash = update.error === undefined || update.error === null
386
+ ? null
387
+ : this.state.putText(update.error, now);
388
+ this.state.connection
389
+ .prepare(`UPDATE controller_workflows
390
+ SET reserved_run_id = COALESCE(?, reserved_run_id),
391
+ run_id = CASE
392
+ WHEN ? IS NOT NULL AND EXISTS(SELECT 1 FROM runs WHERE run_id = ?) THEN ?
393
+ ELSE run_id
394
+ END,
395
+ status = ?, attempt_count = COALESCE(?, attempt_count),
396
+ error_hash = ?, updated_at = ?
397
+ WHERE request_id = ?`)
398
+ .run(update.runId ?? null, update.runId ?? null, update.runId ?? null, update.runId ?? null, update.state, update.attempt ?? null, errorHash, now, requestId);
399
+ return this.mapWorkflow(this.requireWorkflowRow(requestId));
400
+ });
380
401
  }
381
- updateWorkflow(requestId, update) {
382
- const existing = this.database
383
- .prepare("SELECT * FROM workflow_requests WHERE request_id = ?")
384
- .get(requestId);
385
- if (existing === undefined) {
386
- throw new Error(`Workflow request not found: ${requestId}`);
402
+ completeWorkflow(requestId, update) {
403
+ const row = this.requireWorkflowRow(requestId);
404
+ if (update.state === "pending" || update.state === "running") {
405
+ throw new Error("Scheduler completion must report a settled child state");
387
406
  }
388
- const runId = update.runId === undefined ? existing.run_id : update.runId;
389
- const attempt = update.attempt ?? existing.attempt;
390
- const error = update.error === undefined
391
- ? existing.error
392
- : update.error === null
407
+ if (update.runId !== undefined && row.runId !== null && update.runId !== row.runId) {
408
+ throw new Error("Scheduler completion run ID does not match the reserved child run");
409
+ }
410
+ const source = this.requireControllerRowByUid(row.resourceUid);
411
+ const now = Date.now();
412
+ return this.state.transaction(() => {
413
+ const errorHash = update.error === undefined || update.error === null
393
414
  ? null
394
- : truncateError(update.error);
395
- this.database
396
- .prepare(`UPDATE workflow_requests
397
- SET run_id = ?, state = ?, attempt = ?, error = ?
398
- WHERE request_id = ?`)
399
- .run(runId, update.state, attempt, error, requestId);
400
- return this.requireWorkflow(requestId);
415
+ : this.state.putText(update.error, now);
416
+ this.state.connection
417
+ .prepare(`UPDATE controller_workflows
418
+ SET run_id = CASE
419
+ WHEN ? IS NOT NULL AND EXISTS(SELECT 1 FROM runs WHERE run_id = ?) THEN ?
420
+ ELSE run_id
421
+ END,
422
+ status = ?, attempt_count = COALESCE(?, attempt_count),
423
+ error_hash = ?, updated_at = ?
424
+ WHERE request_id = ?`)
425
+ .run(update.runId ?? null, update.runId ?? null, update.runId ?? null, update.state, update.attempt ?? null, errorHash, now, requestId);
426
+ const revision = this.resourceRevision(source.resourceId);
427
+ this.bumpResource(source.resourceId, revision, now);
428
+ this.insertEvent(source.resourceId, revision + 1, "workflow_state_changed", "system", null, { requestId, state: update.state, runId: update.runId ?? row.runId }, now);
429
+ return this.mapWorkflow(this.requireWorkflowRow(requestId));
430
+ });
401
431
  }
402
432
  listWorkflows(resourceUid) {
403
- const rows = this.database
404
- .prepare("SELECT * FROM workflow_requests WHERE resource_uid = ? ORDER BY rowid")
433
+ const rows = this.state.connection
434
+ .prepare(workflowSelect("WHERE c.uid = ? ORDER BY w.created_at, w.request_id"))
405
435
  .all(resourceUid);
406
- return rows.map(workflowFromRow);
436
+ return rows.filter(isWorkflowRow).map((row) => this.mapWorkflow(row));
407
437
  }
408
438
  recordEvent(options) {
409
- validateName(options.controller, "controller");
410
- validateKey(options.key, "resource key");
411
- validateName(options.type, "event type");
412
- const recordedAt = validTimestamp(options.now);
413
- const payloadJson = canonicalJson(options.payload ?? {}, "controller event payload");
414
- if (Buffer.byteLength(payloadJson, "utf8") > MAX_EVENT_BYTES) {
415
- throw new Error(`Controller event payload exceeds ${MAX_EVENT_BYTES} bytes`);
416
- }
417
- const result = this.database
418
- .prepare(`INSERT INTO events (recorded_at, controller, resource_key, type, payload_json)
419
- VALUES (?, ?, ?, ?, ?)`)
420
- .run(recordedAt, options.controller, options.key, options.type, payloadJson);
421
- return this.requireEvent(Number(result.lastInsertRowid));
439
+ const now = epoch(validTimestamp(options.now));
440
+ return this.state.transaction(() => {
441
+ const row = this.requireControllerRow({ controller: options.controller, key: options.key });
442
+ if (options.claim !== undefined)
443
+ this.assertControllerClaim(row, options.claim, now);
444
+ const revision = this.resourceRevision(row.resourceId) + 1;
445
+ this.bumpResource(row.resourceId, revision - 1, now);
446
+ const eventId = this.insertEvent(row.resourceId, revision, options.type, options.claim === undefined ? "control" : "controller", options.claim?.ownerId ?? null, options.payload ?? {}, now, options.claim?.generation);
447
+ if (options.claim !== undefined)
448
+ options.claim.resourceVersion = revision;
449
+ const event = this.state.connection
450
+ .prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
451
+ .get(eventId);
452
+ /* istanbul ignore if -- exact schema and internal query shape */
453
+ if (!isSequenceRow(event))
454
+ throw new Error("Controller event was not recorded");
455
+ return {
456
+ seq: event.seq,
457
+ recordedAt: new Date(now).toISOString(),
458
+ controller: options.controller,
459
+ key: options.key,
460
+ type: options.type,
461
+ payload: options.payload ?? {},
462
+ };
463
+ });
422
464
  }
423
465
  listEvents(options = {}) {
424
- const limit = options.limit ?? 100;
425
- if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 10_000) {
426
- throw new Error("Event limit must be between 1 and 10000");
466
+ const clauses = ["r.resource_type = 'controller'"];
467
+ const params = [];
468
+ if (this.projectId !== null) {
469
+ clauses.push("c.project_id = ?");
470
+ params.push(this.projectId);
427
471
  }
428
- const clauses = [];
429
- const values = [];
430
472
  if (options.controller !== undefined) {
431
- clauses.push("controller = ?");
432
- values.push(options.controller);
473
+ clauses.push("c.controller_name = ?");
474
+ params.push(options.controller);
433
475
  }
434
476
  if (options.key !== undefined) {
435
- clauses.push("resource_key = ?");
436
- values.push(options.key);
437
- }
438
- const where = clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`;
439
- const rows = this.database
440
- .prepare(`SELECT * FROM events ${where} ORDER BY seq DESC LIMIT ?`)
441
- .all(...values, limit);
442
- return rows.map(eventFromRow);
443
- }
444
- configure(readOnly) {
445
- if (!readOnly) {
446
- this.database.pragma("journal_mode = WAL");
447
- this.database.pragma("synchronous = FULL");
448
- this.database.pragma("foreign_keys = ON");
449
- }
450
- this.database.pragma("busy_timeout = 5000");
451
- }
452
- initializeSchema(readOnly) {
453
- if (!readOnly) {
454
- this.database.exec(`
455
- CREATE TABLE IF NOT EXISTS schema_info (
456
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
457
- schema_id TEXT NOT NULL
458
- );
459
- `);
460
- }
461
- const row = this.database
462
- .prepare("SELECT schema_id FROM schema_info WHERE singleton = 1")
463
- .get();
464
- if (row !== undefined && row.schema_id !== CONTROLLER_STORE_SCHEMA) {
465
- throw new Error(`Unsupported controller store schema: ${row.schema_id}`);
466
- }
467
- if (readOnly) {
468
- if (row === undefined) {
469
- throw new Error("Controller store has no schema identifier");
470
- }
471
- }
472
- else {
473
- const existingQueue = this.database
474
- .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'workflow_run_queue'")
475
- .get();
476
- if (existingQueue !== undefined) {
477
- this.assertAlphaSchemaLayout(false);
478
- this.assertNoLegacyPendingLaunchNotifications();
479
- }
480
- this.transaction(() => {
481
- this.database
482
- .prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
483
- .run(CONTROLLER_STORE_SCHEMA);
484
- this.database.exec(SCHEMA_SQL);
485
- });
486
- }
487
- this.assertAlphaSchemaLayout(true);
488
- this.assertNoLegacyPendingLaunchNotifications();
489
- }
490
- assertAlphaSchemaLayout(requireTurnIntents) {
491
- const requiredQueueColumns = new Set([
492
- "run_id",
493
- "workflow_ref",
494
- "workflow_path",
495
- "workflow_source_json",
496
- "definition_digest",
497
- "input_json",
498
- "launch_options_json",
499
- "status",
500
- "runner_id",
501
- "claim_token",
502
- "claim_expires_at",
503
- "affinity_runner_id",
504
- "origin_session_id",
505
- "parent_run_id",
506
- "error_code",
507
- "error_message",
508
- "created_at",
509
- "updated_at",
510
- "started_at",
511
- "finished_at",
512
- ]);
513
- const actual = new Set(this.database.pragma("table_info(workflow_run_queue)").map((column) => column.name));
514
- const missing = [...requiredQueueColumns].filter((column) => !actual.has(column));
515
- if (missing.length > 0) {
516
- throw new Error("Controller store uses an incompatible alpha layout. Preserve needed run bundles, then reset the project controller store.");
517
- }
518
- if (!requireTurnIntents)
519
- return;
520
- const requiredIntentColumns = new Set([
521
- "intent_id",
522
- "source_event_id",
523
- "run_id",
524
- "workflow_ref",
525
- "target_session_id",
526
- "cause",
527
- "node_id",
528
- "attempt_id",
529
- "fallback_facts_json",
530
- "requested_at",
531
- "eligible_at",
532
- "resolved_at",
533
- "resolution",
534
- "resolution_message_id",
535
- "delivery_claim_token",
536
- "delivery_claim_expires_at",
537
- ]);
538
- const intentColumns = new Set(this.database.pragma("table_info(workflow_turn_intents)").map((column) => column.name));
539
- const missingIntentColumns = [...requiredIntentColumns].filter((column) => !intentColumns.has(column));
540
- if (missingIntentColumns.length > 0) {
541
- throw new Error("Controller store uses an incompatible alpha turn-intent layout. Preserve needed run bundles, then reset the project controller store.");
542
- }
543
- }
544
- assertNoLegacyPendingLaunchNotifications() {
545
- const notificationTable = this.database
546
- .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'workflow_notifications'")
547
- .get();
548
- if (notificationTable === undefined) {
549
- throw new Error("Controller store uses an incompatible alpha notification layout. Preserve needed run bundles, then reset the project controller store.");
550
- }
551
- const legacy = this.database
552
- .prepare(`SELECT 1 FROM workflow_notifications
553
- WHERE kind = 'launch_failure' AND delivered_at IS NULL LIMIT 1`)
554
- .get();
555
- if (legacy !== undefined) {
556
- throw new Error("Controller store contains pending alpha launch-failure notifications. Preserve needed run bundles, then reset the project controller store.");
557
- }
558
- }
559
- transaction(task) {
560
- this.database.exec("BEGIN IMMEDIATE");
561
- try {
562
- const value = task();
563
- this.database.exec("COMMIT");
564
- return value;
565
- }
566
- catch (error) {
567
- this.database.exec("ROLLBACK");
568
- throw error;
569
- }
570
- }
571
- resourceRow(ref) {
572
- return this.database
573
- .prepare("SELECT * FROM resources WHERE controller = ? AND resource_key = ?")
574
- .get(ref.controller, ref.key);
575
- }
576
- requireResource(ref) {
577
- const resource = this.getResource(ref);
578
- if (resource === undefined) {
579
- throw new ResourceNotFoundError(ref.controller, ref.key);
477
+ clauses.push("c.resource_key = ?");
478
+ params.push(options.key);
580
479
  }
581
- return resource;
582
- }
583
- throwMissingOrConflict(ref) {
584
- if (this.resourceRow(ref) === undefined) {
585
- throw new ResourceNotFoundError(ref.controller, ref.key);
586
- }
587
- throw new ResourceConflictError(ref.controller, ref.key);
588
- }
589
- enqueueRow(ref, availableAt) {
590
- this.database
591
- .prepare(`INSERT INTO queue (
592
- controller, resource_key, available_at, version, consecutive_errors
593
- ) VALUES (?, ?, ?, 1, 0)
594
- ON CONFLICT(controller, resource_key) DO UPDATE SET
595
- available_at = MIN(queue.available_at, excluded.available_at),
596
- version = queue.version + 1`)
597
- .run(ref.controller, ref.key, availableAt);
598
- }
599
- claimedRow(claim) {
600
- return this.database
601
- .prepare(`SELECT * FROM queue
602
- WHERE controller = ? AND resource_key = ? AND claim_token = ?`)
603
- .get(claim.controller, claim.key, claim.token);
604
- }
605
- effectRow(resourceUid, key) {
606
- return this.database
607
- .prepare("SELECT * FROM effects WHERE resource_uid = ? AND effect_key = ?")
608
- .get(resourceUid, key);
609
- }
610
- requireEffect(resourceUid, key) {
611
- const effect = this.getEffect(resourceUid, key);
612
- if (effect === undefined) {
613
- throw new Error(`Effect record not found: ${resourceUid}/${key}`);
614
- }
615
- return effect;
616
- }
617
- workflowRow(resourceUid, requestKey) {
618
- return this.database
619
- .prepare("SELECT * FROM workflow_requests WHERE resource_uid = ? AND request_key = ?")
620
- .get(resourceUid, requestKey);
621
- }
622
- requireWorkflow(requestId) {
623
- const workflow = this.getWorkflowByRequestId(requestId);
624
- if (workflow === undefined) {
625
- throw new Error(`Workflow request not found: ${requestId}`);
626
- }
627
- return workflow;
480
+ params.push(options.limit ?? 100);
481
+ const rows = this.state.connection
482
+ .prepare(`SELECT e.event_seq AS seq, e.recorded_at AS recordedAt,
483
+ c.controller_name AS controller, c.resource_key AS resourceKey,
484
+ e.event_type AS eventType, e.payload_hash AS payloadHash
485
+ FROM events e
486
+ JOIN resources r ON r.resource_id = e.resource_id
487
+ JOIN controller_resources c ON c.resource_id = r.resource_id
488
+ WHERE ${clauses.join(" AND ")}
489
+ ORDER BY e.event_seq DESC LIMIT ?`)
490
+ .all(...params);
491
+ return rows.filter(isControllerEventRow).map((row) => ({
492
+ seq: row.seq,
493
+ recordedAt: new Date(row.recordedAt).toISOString(),
494
+ controller: row.controller,
495
+ key: row.resourceKey,
496
+ type: row.eventType,
497
+ payload: row.payloadHash === null ? {} : this.state.readJson(row.payloadHash),
498
+ }));
628
499
  }
629
- /** Reserve a user-started run before the initiating agent turn settles. */
630
500
  reserveWorkflowRun(options) {
631
501
  validateRunId(options.runId);
632
- validateKey(options.workflowName, "workflow name");
633
- validateKey(options.workflowSourceRef, "workflow source ref");
634
- validateKey(options.definitionDigest, "workflow definition digest");
635
- validateKey(options.runnerId, "runner id");
636
- validateKey(options.originSessionId, "origin session id");
637
- const inputJson = canonicalJson(options.input ?? null, "workflow run input");
638
- const sourceJson = canonicalJson(options.workflowSource, "workflow run source");
639
- const launchJson = canonicalJson(options.launchOptions ?? {}, "workflow launch options");
640
- validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
641
- validateJsonSize(sourceJson, "Workflow run source", MAX_EVENT_BYTES);
642
- validateJsonSize(launchJson, "Workflow launch options", MAX_EVENT_BYTES);
643
- const now = validTimestamp(options.now);
644
- this.database
645
- .prepare(`INSERT INTO workflow_run_queue (
646
- run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
647
- input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
648
- affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
649
- created_at, updated_at, started_at, finished_at
650
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', NULL, NULL, NULL, ?, ?, ?, NULL, NULL, ?, ?, NULL, NULL)`)
651
- .run(options.runId, options.workflowName, options.workflowSourceRef, sourceJson, options.definitionDigest, inputJson, launchJson, options.runnerId, options.originSessionId, options.parentRunId ?? null, now, now);
652
- return this.requireWorkflowRun(options.runId);
653
- }
654
- /** Insert a run that will start immediately outside a live model turn. */
502
+ const now = epoch(validTimestamp(options.now));
503
+ 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}`);
507
+ }
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");
512
+ }
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
520
+ SET status = 'done', updated_at = ?, finished_at = ?
521
+ 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
+ }
533
+ }
534
+ const resourceId = resourceIdFor("run", options.runId);
535
+ const definitionHash = this.state.putJson(options.definitionSnapshot, now);
536
+ const queuedSource = queuedWorkflowSource(options.workflowSource);
537
+ const sourceHash = this.state.putJson(options.workflowSource, now);
538
+ const inputHash = this.state.putJson(options.input ?? null, now);
539
+ const launchHash = this.state.putJson(options.launchOptions ?? {}, now);
540
+ const queuedStateHash = this.state.putJson({
541
+ schema: "pi-workflows.run-state.v1",
542
+ traceSeq: 1,
543
+ runId: options.runId,
544
+ workflowName: options.workflowName,
545
+ workflowSource: queuedSource.root,
546
+ ...(queuedSource.mounted.length === 0
547
+ ? {}
548
+ : {
549
+ workflowSources: queuedSource.mounted,
550
+ definitionDigest: options.definitionDigest,
551
+ }),
552
+ ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
553
+ startedAt: new Date(now).toISOString(),
554
+ updatedAt: new Date(now).toISOString(),
555
+ status: "running",
556
+ input: options.input ?? null,
557
+ outputs: {},
558
+ results: {},
559
+ steps: [],
560
+ updates: [],
561
+ }, now);
562
+ this.state.connection
563
+ .prepare(`INSERT INTO workflow_definitions(
564
+ definition_digest, workflow_name, definition_hash, created_at
565
+ ) VALUES (?, ?, ?, ?)
566
+ ON CONFLICT(definition_digest) DO NOTHING`)
567
+ .run(definitionDigest, options.workflowName, definitionHash, now);
568
+ this.state.connection
569
+ .prepare(`INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
570
+ VALUES (?, 'run', ?, 1, ?, ?)`)
571
+ .run(resourceId, options.runId, now, now);
572
+ this.state.connection
573
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
574
+ .run(resourceId);
575
+ const source = sourceParts(options.workflowSourceRef, options.definitionDigest);
576
+ this.state.connection
577
+ .prepare(`INSERT INTO runs(
578
+ run_id, resource_id, project_id, parent_run_id, definition_digest,
579
+ workflow_ref, workflow_source_hash, launch_options_hash,
580
+ source_type, source_ref, source_revision, status, paused,
581
+ input_hash, output_hash, created_at, updated_at
582
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?, ?)`)
583
+ .run(options.runId, resourceId, this.requireProjectId(), options.parentRunId ?? null, definitionDigest, options.workflowSourceRef, sourceHash, launchHash, source.type, source.ref, source.revision, inputHash, queuedStateHash, now, now);
584
+ this.state.connection
585
+ .prepare(`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
586
+ VALUES (?, ?, 'interactive', ?)`)
587
+ .run(options.runId, options.originSessionId, now);
588
+ this.state.connection
589
+ .prepare(`INSERT INTO run_queue(
590
+ run_id, status, available_at, affinity_runner_id, origin_session_id,
591
+ consecutive_errors, created_at, updated_at
592
+ ) VALUES (?, 'queued', ?, ?, ?, 0, ?, ?)`)
593
+ .run(options.runId, now, options.runnerId, options.originSessionId, now, now);
594
+ this.insertEvent(resourceId, 1, "run.queued", "session", options.originSessionId, {}, now);
595
+ return this.requireWorkflowRun(options.runId);
596
+ });
597
+ }
655
598
  enqueueWorkflowRun(options) {
656
- validateRunId(options.runId);
657
- validateKey(options.workflowName, "workflow name");
658
- validateKey(options.workflowSourceRef, "workflow source ref");
659
- validateKey(options.runnerId, "runner id");
660
- validateKey(options.claimToken, "claim token");
661
- validateDuration(options.leaseMs, "leaseMs");
662
- if (options.originSessionId !== undefined) {
663
- validateKey(options.originSessionId, "origin session id");
599
+ if (this.getWorkflowRun(options.runId) === undefined) {
600
+ this.reserveWorkflowRun({
601
+ runId: options.runId,
602
+ workflowName: options.workflowName,
603
+ workflowSourceRef: options.workflowSourceRef,
604
+ workflowSource: options.workflowSource,
605
+ definitionDigest: options.definitionDigest,
606
+ definitionSnapshot: options.definitionSnapshot,
607
+ input: options.input,
608
+ launchOptions: options.launchOptions ?? {},
609
+ runnerId: options.runnerId,
610
+ originSessionId: options.originSessionId,
611
+ ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
612
+ ...(options.now === undefined ? {} : { now: options.now }),
613
+ });
664
614
  }
665
- const inputJson = canonicalJson(options.input ?? null, "workflow run input");
666
- const sourceJson = canonicalJson(options.workflowSource ?? { ref: options.workflowSourceRef }, "workflow run source");
667
- const launchJson = canonicalJson(options.launchOptions ?? {}, "workflow launch options");
668
- validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
669
- const now = validTimestamp(options.now);
670
- const expiresAt = epoch(now) + options.leaseMs;
671
- this.database
672
- .prepare(`INSERT INTO workflow_run_queue (
673
- run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
674
- input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
675
- affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
676
- created_at, updated_at, started_at, finished_at
677
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL)`)
678
- .run(options.runId, options.workflowName, options.workflowSourceRef, sourceJson, options.definitionDigest ?? "unavailable", inputJson, launchJson, options.runnerId, options.claimToken, expiresAt, options.affinityRunnerId ?? options.runnerId, options.originSessionId ?? null, options.parentRunId ?? null, now, now, now);
679
- return this.requireWorkflowRun(options.runId);
615
+ const claimed = this.claimWorkflowRun({
616
+ runId: options.runId,
617
+ runnerId: options.runnerId,
618
+ claimToken: options.claimToken,
619
+ leaseMs: options.leaseMs,
620
+ ...(options.now === undefined ? {} : { now: options.now }),
621
+ });
622
+ if (claimed === undefined) {
623
+ throw new Error(`Workflow run could not be claimed: ${options.runId}`);
624
+ }
625
+ return claimed;
680
626
  }
681
627
  getWorkflowRun(runId) {
682
- validateRunId(runId);
683
- const row = this.database
684
- .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
685
- .get(runId);
686
- return row === undefined ? undefined : workflowRunFromRow(row);
628
+ const row = this.workflowRunRow(runId);
629
+ return row === undefined ? undefined : this.mapWorkflowRun(row);
687
630
  }
688
631
  listWorkflowRuns(options = {}) {
689
- const rows = options.status === undefined
690
- ? this.database
691
- .prepare("SELECT * FROM workflow_run_queue ORDER BY created_at")
692
- .all()
693
- : this.database
694
- .prepare("SELECT * FROM workflow_run_queue WHERE status = ? ORDER BY created_at")
695
- .all(options.status);
696
- return rows.map(workflowRunFromRow);
632
+ const clauses = ["r.project_id = ?"];
633
+ const params = [this.requireProjectId()];
634
+ if (options.statuses !== undefined && options.statuses.length > 0) {
635
+ clauses.push(`q.status IN (${options.statuses.map(() => "?").join(", ")})`);
636
+ params.push(...options.statuses);
637
+ }
638
+ if (options.excludeRunIds !== undefined && options.excludeRunIds.length > 0) {
639
+ clauses.push(`r.run_id NOT IN (${options.excludeRunIds.map(() => "?").join(", ")})`);
640
+ params.push(...options.excludeRunIds);
641
+ }
642
+ params.push(options.limit ?? 100);
643
+ const rows = this.state.connection
644
+ .prepare(workflowRunSelect(`WHERE ${clauses.join(" AND ")} ORDER BY q.created_at DESC LIMIT ?`))
645
+ .all(...params);
646
+ return rows.filter(isRunRow).map((row) => this.mapWorkflowRun(row));
697
647
  }
698
648
  findSessionReservation(sessionId) {
699
- validateKey(sessionId, "session id");
700
- const row = this.database
701
- .prepare(`SELECT * FROM workflow_run_queue
702
- WHERE origin_session_id = ? AND status IN ('queued', 'starting', 'running')
703
- ORDER BY created_at LIMIT 1`)
649
+ const row = this.state.connection
650
+ .prepare(workflowRunSelect("WHERE b.origin_session_id = ? AND q.status NOT IN ('done', 'failed', 'cancelled') ORDER BY q.created_at DESC LIMIT 1"))
704
651
  .get(sessionId);
705
- return row === undefined ? undefined : workflowRunFromRow(row);
652
+ return isRunRow(row) ? this.mapWorkflowRun(row) : undefined;
706
653
  }
707
654
  claimWorkflowRun(options) {
708
- validateRunId(options.runId);
709
- validateKey(options.runnerId, "runner id");
710
- validateKey(options.claimToken, "claim token");
711
- validateDuration(options.leaseMs, "leaseMs");
712
- const now = validTimestamp(options.now);
713
- const nowMs = epoch(now);
714
- const expiresAt = nowMs + options.leaseMs;
715
- const result = this.database
716
- .prepare(`UPDATE workflow_run_queue
717
- SET status = 'starting', runner_id = ?, claim_token = ?, claim_expires_at = ?,
718
- started_at = COALESCE(started_at, ?), updated_at = ?
719
- WHERE run_id = ? AND (
720
- status IN ('queued', 'parked')
721
- OR (status = 'starting' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
722
- )`)
723
- .run(options.runnerId, options.claimToken, expiresAt, now, now, options.runId, nowMs);
724
- return result.changes === 1 ? this.requireWorkflowRun(options.runId) : undefined;
655
+ const now = epoch(validTimestamp(options.now));
656
+ return this.claimRun(options.runId, options.runnerId, options.claimToken, options.leaseMs, now);
725
657
  }
726
658
  markWorkflowRunRunning(options) {
727
- validateRunId(options.runId);
728
- const now = validTimestamp(options.now);
729
- const result = this.database
730
- .prepare(`UPDATE workflow_run_queue SET status = 'running', updated_at = ?
731
- WHERE run_id = ? AND claim_token = ? AND status = 'starting'`)
732
- .run(now, options.runId, options.claimToken);
733
- return result.changes === 1;
659
+ return this.updateClaimedRunStatus(options.runId, options.claimToken, "running", options.now);
734
660
  }
735
- /**
736
- * Claim the oldest claimable run, preferring runs with affinity to this
737
- * runner. Parked rows are claimable immediately; claimed rows become
738
- * claimable once their lease expires, so a dead or stalled runner never
739
- * strands a run. Returns undefined when nothing is claimable.
740
- */
741
661
  claimNextWorkflowRun(options) {
742
- validateKey(options.runnerId, "runner id");
743
- validateKey(options.claimToken, "claim token");
744
- validateDuration(options.leaseMs, "leaseMs");
745
- if (options.sessionId !== undefined)
746
- validateKey(options.sessionId, "session id");
747
- const now = validTimestamp(options.now);
748
- const nowMs = epoch(now);
749
- const expiresAt = nowMs + options.leaseMs;
750
- const excluded = options.excludeRunIds ?? [];
751
- for (const runId of excluded) {
752
- validateRunId(runId);
662
+ const now = epoch(validTimestamp(options.now));
663
+ const clauses = [
664
+ "r.project_id = ?",
665
+ "q.status IN ('queued', 'parked', 'starting', 'running')",
666
+ "q.available_at <= ?",
667
+ "(l.owner_id IS NULL OR l.expires_at <= ? OR l.owner_id = ?)",
668
+ "(q.affinity_runner_id IS NULL OR q.affinity_runner_id = ? OR q.status IN ('parked', 'starting', 'running'))",
669
+ ];
670
+ const params = [
671
+ this.requireProjectId(),
672
+ now,
673
+ now,
674
+ options.runnerId,
675
+ options.runnerId,
676
+ ];
677
+ if (options.sessionId !== undefined) {
678
+ clauses.push("b.origin_session_id = ?");
679
+ params.push(options.sessionId);
753
680
  }
754
- const exclusion = excluded.length === 0 ? "" : `AND run_id NOT IN (${excluded.map(() => "?").join(", ")})`;
755
- const sessionFilter = options.sessionId === undefined
756
- ? "AND status != 'queued' AND (status != 'starting' OR origin_session_id IS NULL)"
757
- : "AND (origin_session_id IS NULL OR origin_session_id = ?)";
758
- const claimable = `(
759
- status IN ('queued', 'parked')
760
- OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
761
- )`;
762
- return this.transaction(() => {
763
- const candidate = this.database
764
- .prepare(`SELECT run_id FROM workflow_run_queue
765
- WHERE ${claimable} ${exclusion} ${sessionFilter}
766
- ORDER BY
767
- CASE WHEN affinity_runner_id = ? THEN 0 ELSE 1 END,
768
- created_at ASC
769
- LIMIT 1`)
770
- .get(nowMs, ...excluded, ...(options.sessionId === undefined ? [] : [options.sessionId]), options.runnerId);
771
- if (candidate === undefined) {
772
- return undefined;
773
- }
774
- const result = this.database
775
- .prepare(`UPDATE workflow_run_queue
776
- SET status = 'starting', runner_id = ?, claim_token = ?,
777
- claim_expires_at = ?, started_at = COALESCE(started_at, ?), updated_at = ?
778
- WHERE run_id = ? AND (
779
- status IN ('queued', 'parked')
780
- OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
781
- )`)
782
- .run(options.runnerId, options.claimToken, expiresAt, now, now, candidate.run_id, nowMs);
783
- return result.changes === 1 ? this.requireWorkflowRun(candidate.run_id) : undefined;
784
- });
681
+ else {
682
+ clauses.push("(b.execution_mode = 'headless' OR q.status <> 'queued')");
683
+ }
684
+ if (options.excludeRunIds !== undefined && options.excludeRunIds.length > 0) {
685
+ clauses.push(`r.run_id NOT IN (${options.excludeRunIds.map(() => "?").join(", ")})`);
686
+ params.push(...options.excludeRunIds);
687
+ }
688
+ const row = this.state.connection
689
+ .prepare(workflowRunSelect(`WHERE ${clauses.join(" AND ")} ORDER BY q.available_at, q.created_at LIMIT 1`))
690
+ .get(...params);
691
+ if (!isRunRow(row))
692
+ return undefined;
693
+ return this.claimRun(row.runId, options.runnerId, options.claimToken, options.leaseMs, now);
785
694
  }
786
- /** Extend a live claim. Rejects expired or foreign claims. */
787
695
  renewWorkflowRunClaim(options) {
788
- validateRunId(options.runId);
789
- validateDuration(options.leaseMs, "leaseMs");
790
- const nowMs = epoch(validTimestamp(options.now));
791
- const expiresAt = nowMs + options.leaseMs;
792
- const result = this.database
793
- .prepare(`UPDATE workflow_run_queue SET claim_expires_at = ?
794
- WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')
795
- AND claim_expires_at > ?`)
796
- .run(expiresAt, options.runId, options.claimToken, nowMs);
696
+ const now = epoch(validTimestamp(options.now));
697
+ const row = this.workflowRunRow(options.runId);
698
+ if (row === undefined || row.ownerId === null)
699
+ return false;
700
+ const result = this.state.connection
701
+ .prepare(`UPDATE leases SET heartbeat_at = ?, expires_at = ?
702
+ WHERE resource_id = ? AND token_hash = ? AND owner_id = ? AND expires_at > ?`)
703
+ .run(now, now + options.leaseMs, row.resourceId, tokenHash(options.claimToken), row.ownerId, now);
797
704
  return result.changes === 1;
798
705
  }
799
- /** The fence check: true only while this exact claim is live. */
800
706
  verifyWorkflowRunClaim(options) {
801
- validateRunId(options.runId);
802
- const nowMs = epoch(validTimestamp(options.now));
803
- const row = this.database
804
- .prepare(`SELECT 1 AS live FROM workflow_run_queue
805
- WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')
806
- AND claim_expires_at > ?`)
807
- .get(options.runId, options.claimToken, nowMs);
808
- return row !== undefined;
809
- }
810
- /** Release a claim and park the run so another runner can resume it. */
707
+ const now = epoch(validTimestamp(options.now));
708
+ const row = this.workflowRunRow(options.runId);
709
+ if (row === undefined || row.ownerId === null || row.claimExpiresAt === null)
710
+ return false;
711
+ const lease = this.requireLease(row.resourceId);
712
+ return (lease.tokenHash !== null &&
713
+ lease.tokenHash.equals(tokenHash(options.claimToken)) &&
714
+ row.claimExpiresAt > now);
715
+ }
716
+ workflowRunAuthority(runId, claimToken) {
717
+ const row = this.workflowRunRow(runId);
718
+ if (row === undefined ||
719
+ row.ownerId === null ||
720
+ row.claimExpiresAt === null ||
721
+ row.claimExpiresAt <= Date.now())
722
+ return undefined;
723
+ const lease = this.requireLease(row.resourceId);
724
+ if (lease.tokenHash === null || !lease.tokenHash.equals(tokenHash(claimToken)))
725
+ return undefined;
726
+ const ownerType = row.ownerId.startsWith("host-") ? "host" : "session";
727
+ return {
728
+ actor: { type: ownerType, id: row.ownerId },
729
+ ownerType,
730
+ ownerId: row.ownerId,
731
+ token: claimToken,
732
+ generation: row.leaseGeneration,
733
+ };
734
+ }
811
735
  parkWorkflowRun(options) {
812
- validateRunId(options.runId);
813
- const now = validTimestamp(options.now);
814
- const result = this.database
815
- .prepare(`UPDATE workflow_run_queue
816
- SET status = 'parked', runner_id = NULL, claim_token = NULL,
817
- claim_expires_at = NULL, updated_at = ?
818
- WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`)
819
- .run(now, options.runId, options.claimToken);
820
- return result.changes === 1;
736
+ return this.releaseRunClaim(options.runId, options.claimToken, "parked", options.now);
821
737
  }
822
738
  failWorkflowRun(options) {
823
- validateRunId(options.runId);
824
- validateKey(options.errorCode, "workflow launch error code");
825
- const message = options.errorMessage.trim();
826
- if (message.length === 0 || Buffer.byteLength(message) > 2_048) {
827
- throw new Error("Workflow launch error message must be between 1 and 2048 bytes");
828
- }
829
- const now = validTimestamp(options.now);
830
- const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
831
- const result = this.database
832
- .prepare(`UPDATE workflow_run_queue
833
- SET status = 'failed', runner_id = NULL, claim_token = NULL,
834
- claim_expires_at = NULL, parent_run_id = NULL, input_json = 'null',
835
- launch_options_json = '{}', error_code = ?, error_message = ?,
836
- finished_at = ?, updated_at = ?
837
- WHERE run_id = ? AND status IN ('queued', 'starting', 'running') ${claimFilter}`)
838
- .run(options.errorCode, message, now, now, options.runId, ...(options.claimToken === undefined ? [] : [options.claimToken]));
839
- return result.changes === 1;
739
+ return this.terminalRun(options.runId, options.claimToken, "failed", options.errorCode, options.errorMessage, options.now);
840
740
  }
841
741
  cancelWorkflowRun(options) {
842
- validateRunId(options.runId);
843
- const now = validTimestamp(options.now);
844
- const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
845
- const result = this.database
846
- .prepare(`UPDATE workflow_run_queue
847
- SET status = 'cancelled', runner_id = NULL, claim_token = NULL,
848
- claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
849
- finished_at = ?, updated_at = ?
850
- WHERE run_id = ? AND status IN ('queued', 'starting') ${claimFilter}`)
851
- .run(now, now, options.runId, ...(options.claimToken === undefined ? [] : [options.claimToken]));
852
- return result.changes === 1;
742
+ return this.terminalRun(options.runId, options.claimToken, "cancelled", "cancelled", "Workflow run cancelled", options.now);
853
743
  }
854
- /**
855
- * Delete a claimed row. Used when a continuation fails before its bundle
856
- * exists, so the parent's one-continuation slot is not consumed by a run
857
- * that never happened.
858
- */
859
744
  deleteWorkflowRun(options) {
860
- validateRunId(options.runId);
861
- const result = this.database
862
- .prepare("DELETE FROM workflow_run_queue WHERE run_id = ? AND claim_token = ?")
863
- .run(options.runId, options.claimToken);
864
- return result.changes === 1;
865
- }
866
- /** Release a claim and mark the run terminal in the queue. */
867
- completeWorkflowRun(options) {
868
- validateRunId(options.runId);
869
- const now = validTimestamp(options.now);
870
- const result = this.database
871
- .prepare(`UPDATE workflow_run_queue
872
- SET status = 'done', runner_id = NULL, claim_token = NULL,
873
- claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
874
- finished_at = ?, updated_at = ?
875
- WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`)
876
- .run(now, now, options.runId, options.claimToken);
877
- return result.changes === 1;
878
- }
879
- /** Repair a canonical bundle's queue source and claim it only when needed. */
880
- repairCanonicalWorkflowSourceRun(options) {
881
- validateRunId(options.runId);
882
- validateKey(options.workflowName, "workflow name");
883
- validateKey(options.workflowSourceRef, "workflow source ref");
884
- validateKey(options.runnerId, "runner id");
885
- validateKey(options.claimToken, "claim token");
886
- validateDuration(options.leaseMs, "leaseMs");
887
- const now = validTimestamp(options.now);
888
- const nowMs = epoch(now);
889
- const expiresAt = nowMs + options.leaseMs;
890
- return this.transaction(() => {
891
- const row = this.database
892
- .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
893
- .get(options.runId);
894
- if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
745
+ const now = Date.now();
746
+ return this.state.transaction(() => {
747
+ const row = this.workflowRunRow(options.runId);
748
+ if (row === undefined)
895
749
  return false;
896
- }
897
- if (row.workflow_path === options.workflowSourceRef && row.status === "parked") {
898
- return "unchanged";
899
- }
900
- const claimable = row.status === "parked" ||
901
- (row.status === "starting" &&
902
- row.claim_token === options.claimToken &&
903
- row.claim_expires_at !== null &&
904
- row.claim_expires_at > nowMs) ||
905
- (row.status === "starting" &&
906
- row.claim_expires_at !== null &&
907
- row.claim_expires_at <= nowMs);
908
- if (!claimable)
909
- return false;
910
- const result = this.database
911
- .prepare(`UPDATE workflow_run_queue
912
- SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
913
- claim_token = ?, claim_expires_at = ?, updated_at = ?
914
- WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`)
915
- .run(options.workflowName, options.workflowSourceRef, options.runnerId, options.claimToken, expiresAt, now, options.runId);
916
- return result.changes === 1 ? "claimed" : false;
917
- });
918
- }
919
- /** Claim and rewrite one proved legacy workflow source queue row atomically. */
920
- claimLegacyWorkflowSourceRun(options) {
921
- validateRunId(options.runId);
922
- validateKey(options.workflowName, "workflow name");
923
- validateKey(options.oldWorkflowPath, "legacy workflow path");
924
- validateKey(options.workflowSourceRef, "workflow source ref");
925
- validateKey(options.runnerId, "runner id");
926
- validateKey(options.claimToken, "claim token");
927
- validateDuration(options.leaseMs, "leaseMs");
928
- const now = validTimestamp(options.now);
929
- const nowMs = epoch(now);
930
- const expiresAt = nowMs + options.leaseMs;
931
- return this.transaction(() => {
932
- const row = this.database
933
- .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
934
- .get(options.runId);
935
- if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
750
+ const lease = this.requireLease(row.resourceId);
751
+ if (lease.ownerId === null ||
752
+ lease.tokenHash === null ||
753
+ lease.expiresAt === null ||
754
+ lease.expiresAt <= now ||
755
+ !lease.tokenHash.equals(tokenHash(options.claimToken))) {
936
756
  return false;
937
757
  }
938
- const sourceMatches = row.workflow_path === options.oldWorkflowPath ||
939
- row.workflow_path === options.workflowSourceRef;
940
- const claimable = row.status === "parked" ||
941
- (row.status === "starting" &&
942
- row.claim_token === options.claimToken &&
943
- row.claim_expires_at !== null &&
944
- row.claim_expires_at > nowMs) ||
945
- (row.status === "starting" &&
946
- row.claim_expires_at !== null &&
947
- row.claim_expires_at <= nowMs);
948
- if (!sourceMatches || !claimable)
949
- return false;
950
- const result = this.database
951
- .prepare(`UPDATE workflow_run_queue
952
- SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
953
- claim_token = ?, claim_expires_at = ?, updated_at = ?
954
- WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`)
955
- .run(options.workflowName, options.workflowSourceRef, options.runnerId, options.claimToken, expiresAt, now, options.runId);
956
- return result.changes === 1;
758
+ return (this.state.connection
759
+ .prepare("DELETE FROM resources WHERE resource_id = ?")
760
+ .run(row.resourceId).changes === 1);
957
761
  });
958
762
  }
959
- /** Append a run lifecycle transition to the event feed. */
763
+ completeWorkflowRun(options) {
764
+ return this.releaseRunClaim(options.runId, options.claimToken, "done", options.now);
765
+ }
766
+ repairCanonicalWorkflowSourceRun() {
767
+ throw new Error("Legacy workflow source repair is not available after the SQLite cutover");
768
+ }
769
+ claimLegacyWorkflowSourceRun() {
770
+ throw new Error("Legacy workflow source claims are not available after the SQLite cutover");
771
+ }
960
772
  recordRunEvent(options) {
961
- validateRunId(options.runId);
962
- validateKey(options.type, "run event type");
963
- const payloadJson = canonicalJson(options.payload ?? {}, "run event payload");
964
- validateJsonSize(payloadJson, "Run event payload", MAX_EVENT_BYTES);
965
- const result = this.database
966
- .prepare(`INSERT INTO run_events (recorded_at, run_id, workflow_ref, type, runner_id, payload_json)
967
- VALUES (?, ?, ?, ?, ?, ?)`)
968
- .run(validTimestamp(options.now), options.runId, options.workflowRef, options.type, options.runnerId ?? null, payloadJson);
969
- return Number(result.lastInsertRowid);
970
- }
971
- /** List run events after a watermark, oldest first. */
773
+ const row = this.requireWorkflowRunRow(options.runId);
774
+ const now = epoch(validTimestamp(options.now));
775
+ const revision = this.resourceRevision(row.resourceId) + 1;
776
+ this.bumpResource(row.resourceId, revision - 1, now);
777
+ const eventId = this.insertEvent(row.resourceId, revision, options.type, options.runnerId?.startsWith("host-") ? "host" : "session", options.runnerId ?? null, options.payload ?? {}, now, row.leaseGeneration || undefined);
778
+ const seq = this.state.connection
779
+ .prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
780
+ .get(eventId);
781
+ /* istanbul ignore if -- exact schema and internal query shape */
782
+ if (!isSequenceRow(seq))
783
+ throw new Error("Run event was not recorded");
784
+ return {
785
+ seq: seq.seq,
786
+ recordedAt: new Date(now).toISOString(),
787
+ runId: options.runId,
788
+ workflowRef: options.workflowRef,
789
+ type: options.type,
790
+ runnerId: options.runnerId ?? null,
791
+ payload: options.payload ?? {},
792
+ };
793
+ }
972
794
  listRunEventsAfter(seq, options = {}) {
973
- const limit = options.limit ?? 50;
974
- if (!Number.isSafeInteger(seq) || seq < 0) {
975
- throw new Error(`Invalid run event watermark: ${seq}`);
976
- }
977
- const rows = this.database
978
- .prepare("SELECT * FROM run_events WHERE seq > ? ORDER BY seq ASC LIMIT ?")
979
- .all(seq, limit);
980
- return rows.map((row) => ({
795
+ const rows = this.state.connection
796
+ .prepare(`SELECT e.event_seq AS seq, e.recorded_at AS recordedAt, r.run_id AS runId,
797
+ r.workflow_ref AS workflowRef, e.event_type AS eventType,
798
+ e.actor_id AS runnerId, e.payload_hash AS payloadHash
799
+ FROM events e JOIN runs r ON r.resource_id = e.resource_id
800
+ WHERE e.event_seq > ? ORDER BY e.event_seq LIMIT ?`)
801
+ .all(seq, options.limit ?? 100);
802
+ return rows.filter(isRunEventRow).map((row) => ({
981
803
  seq: row.seq,
982
- recordedAt: row.recorded_at,
983
- runId: row.run_id,
984
- workflowRef: row.workflow_ref,
985
- type: row.type,
986
- runnerId: row.runner_id,
987
- payload: parseStoredJson(row.payload_json, "run event payload"),
804
+ recordedAt: new Date(row.recordedAt).toISOString(),
805
+ runId: row.runId,
806
+ workflowRef: row.workflowRef,
807
+ type: row.eventType,
808
+ runnerId: row.runnerId,
809
+ payload: row.payloadHash === null ? {} : this.state.readJson(row.payloadHash),
988
810
  }));
989
811
  }
990
812
  ensureWorkflowTurnIntent(options) {
991
- validateKey(options.intentId, "workflow turn intent id");
992
- validateKey(options.sourceEventId, "workflow turn source event id");
993
- validateRunId(options.runId);
994
- validateKey(options.workflowRef, "workflow turn workflow ref");
995
- validateKey(options.targetSessionId, "workflow turn target session id");
996
- validateWorkflowTurnIntentCause(options.cause);
997
- const nodeId = options.nodeId ?? null;
998
- const attemptId = options.attemptId ?? null;
999
- if (nodeId !== null)
1000
- validateKey(nodeId, "workflow turn node id");
1001
- if (attemptId !== null)
1002
- validateKey(attemptId, "workflow turn attempt id");
1003
- const factsJson = workflowTurnIntentFactsJson(options.fallbackFacts);
1004
- const now = validTimestamp(options.now);
1005
- const eligibleAt = options.eligible === true ? now : null;
1006
- this.database
1007
- .prepare(`INSERT INTO workflow_turn_intents (
1008
- intent_id, source_event_id, run_id, workflow_ref, target_session_id,
1009
- cause, node_id, attempt_id, fallback_facts_json, requested_at, eligible_at,
1010
- resolved_at, resolution, resolution_message_id,
1011
- delivery_claim_token, delivery_claim_expires_at
1012
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL)
1013
- ON CONFLICT(intent_id) DO NOTHING`)
1014
- .run(options.intentId, options.sourceEventId, options.runId, options.workflowRef, options.targetSessionId, options.cause, nodeId, attemptId, factsJson, now, eligibleAt);
1015
- const existing = this.getWorkflowTurnIntent(options.intentId);
1016
- if (existing === undefined) {
1017
- throw new Error(`Workflow turn intent was not stored: ${options.intentId}`);
1018
- }
1019
- if (existing.sourceEventId !== options.sourceEventId ||
1020
- existing.runId !== options.runId ||
1021
- existing.workflowRef !== options.workflowRef ||
1022
- existing.targetSessionId !== options.targetSessionId ||
1023
- existing.cause !== options.cause ||
1024
- existing.nodeId !== nodeId ||
1025
- existing.attemptId !== attemptId) {
1026
- throw new Error(`Workflow turn intent identity conflict: ${options.intentId}`);
1027
- }
1028
- return existing;
813
+ const existing = this.turnIntentBySource(options.sourceEventId);
814
+ if (existing !== undefined)
815
+ return existing;
816
+ const now = epoch(validTimestamp(options.requestedAt));
817
+ const run = this.requireWorkflowRunRow(options.runId);
818
+ const intentId = options.intentId;
819
+ const resourceId = resourceIdFor("turn_intent", intentId);
820
+ const effectId = `effect-${randomUUID()}`;
821
+ this.state.transaction(() => {
822
+ const factsHash = this.state.putJson(options.fallbackFacts, now);
823
+ this.insertEffectResource(effectId, resourceIdFor("effect", effectId), run.resourceId, this.resourceRevision(run.resourceId), "turn.deliver", intentId, factsHash, "run", now);
824
+ this.state.connection
825
+ .prepare("INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'turn_intent', ?, 1, ?, ?)")
826
+ .run(resourceId, intentId, now, now);
827
+ this.state.connection
828
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
829
+ .run(resourceId);
830
+ this.state.connection
831
+ .prepare(`INSERT INTO turn_intents(
832
+ turn_intent_id, resource_id, effect_id, source_event_id, run_id,
833
+ workflow_ref, target_session_id, cause, node_id, attempt_id,
834
+ facts_hash, requested_at, eligible_at, created_at
835
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
836
+ .run(intentId, resourceId, effectId, options.sourceEventId, options.runId, options.workflowRef, options.targetSessionId, options.cause, options.nodeId ?? null, options.attemptId ?? null, factsHash, now, options.eligible ? now : null, now);
837
+ this.insertEvent(resourceId, 1, "turn.requested", "session", options.targetSessionId, {}, now);
838
+ });
839
+ return this.requireTurnIntent(intentId);
1029
840
  }
1030
841
  getWorkflowTurnIntent(intentId) {
1031
- validateKey(intentId, "workflow turn intent id");
1032
- const row = this.database
1033
- .prepare("SELECT * FROM workflow_turn_intents WHERE intent_id = ?")
1034
- .get(intentId);
1035
- return row === undefined ? undefined : workflowTurnIntentFromRow(row);
842
+ return this.turnIntent(intentId);
1036
843
  }
1037
844
  findPendingWorkflowTurnIntent(options) {
1038
- validateRunId(options.runId);
1039
- validateKey(options.targetSessionId, "workflow turn target session id");
1040
- const row = this.database
1041
- .prepare(`SELECT * FROM workflow_turn_intents
1042
- WHERE run_id = ? AND target_session_id = ? AND resolved_at IS NULL
1043
- ORDER BY requested_at DESC, intent_id DESC LIMIT 1`)
845
+ const row = this.state.connection
846
+ .prepare(turnIntentSelect("WHERE t.run_id = ? AND t.target_session_id = ? AND t.resolved_at IS NULL ORDER BY t.requested_at DESC LIMIT 1"))
1044
847
  .get(options.runId, options.targetSessionId);
1045
- return row === undefined ? undefined : workflowTurnIntentFromRow(row);
848
+ return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
1046
849
  }
1047
850
  claimWorkflowTurnIntent(options) {
1048
- validateKey(options.intentId, "workflow turn intent id");
1049
- validateKey(options.targetSessionId, "workflow turn target session id");
1050
- validateKey(options.claimToken, "workflow turn claim token");
1051
- validateDuration(options.leaseMs, "leaseMs");
1052
- const nowMs = epoch(validTimestamp(options.now));
1053
- const result = this.database
1054
- .prepare(`UPDATE workflow_turn_intents
1055
- SET delivery_claim_token = ?, delivery_claim_expires_at = ?
1056
- WHERE intent_id = ? AND target_session_id = ? AND resolved_at IS NULL
1057
- AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`)
1058
- .run(options.claimToken, nowMs + options.leaseMs, options.intentId, options.targetSessionId, nowMs);
1059
- if (result.changes !== 1)
851
+ const intent = this.turnIntent(options.intentId);
852
+ if (intent?.targetSessionId !== options.targetSessionId)
1060
853
  return undefined;
1061
- const row = this.database
1062
- .prepare("SELECT * FROM workflow_turn_intents WHERE intent_id = ?")
1063
- .get(options.intentId);
1064
- return workflowTurnIntentFromRow(row);
854
+ return this.claimEffectForTurn(options.intentId, options.claimToken, options.leaseMs, options.now)
855
+ ? this.requireTurnIntent(options.intentId)
856
+ : undefined;
1065
857
  }
1066
858
  claimEligibleWorkflowTurnIntents(options) {
1067
- validateKey(options.targetSessionId, "workflow turn target session id");
1068
- validateKey(options.claimToken, "workflow turn claim token");
1069
- validateDuration(options.leaseMs, "leaseMs");
1070
- const limit = options.limit ?? 20;
1071
- if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
1072
- throw new Error("Workflow turn intent limit must be an integer from 1 through 100");
859
+ const now = epoch(validTimestamp(options.now));
860
+ const rows = this.state.connection
861
+ .prepare(turnIntentSelect("WHERE t.target_session_id = ? AND t.resolved_at IS NULL AND t.eligible_at IS NOT NULL AND t.eligible_at <= ? ORDER BY t.eligible_at LIMIT ?"))
862
+ .all(options.targetSessionId, now, options.limit ?? 10);
863
+ const claimed = [];
864
+ for (const row of rows) {
865
+ if (isTurnIntentRow(row) &&
866
+ this.claimEffectForTurn(row.intentId, options.claimToken, options.leaseMs, options.now)) {
867
+ claimed.push(this.requireTurnIntent(row.intentId));
868
+ }
1073
869
  }
1074
- const now = validTimestamp(options.now);
1075
- const nowMs = epoch(now);
1076
- return this.transaction(() => {
1077
- const ids = this.database
1078
- .prepare(`SELECT intent_id FROM workflow_turn_intents
1079
- WHERE target_session_id = ? AND resolved_at IS NULL AND eligible_at IS NOT NULL
1080
- AND eligible_at <= ?
1081
- AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)
1082
- ORDER BY eligible_at, intent_id LIMIT ?`)
1083
- .all(options.targetSessionId, now, nowMs, limit);
1084
- if (ids.length === 0)
1085
- return [];
1086
- const placeholders = ids.map(() => "?").join(", ");
1087
- this.database
1088
- .prepare(`UPDATE workflow_turn_intents
1089
- SET delivery_claim_token = ?, delivery_claim_expires_at = ?
1090
- WHERE intent_id IN (${placeholders}) AND resolved_at IS NULL
1091
- AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`)
1092
- .run(options.claimToken, nowMs + options.leaseMs, ...ids.map((row) => row.intent_id), nowMs);
1093
- const rows = this.database
1094
- .prepare(`SELECT * FROM workflow_turn_intents WHERE delivery_claim_token = ?
1095
- ORDER BY eligible_at, intent_id`)
1096
- .all(options.claimToken);
1097
- return rows.map(workflowTurnIntentFromRow);
1098
- });
870
+ return claimed;
1099
871
  }
1100
872
  makeWorkflowTurnIntentEligible(options) {
1101
- validateKey(options.intentId, "workflow turn intent id");
1102
- const result = this.database
1103
- .prepare(`UPDATE workflow_turn_intents
1104
- SET fallback_facts_json = ?, eligible_at = COALESCE(eligible_at, ?)
1105
- WHERE intent_id = ? AND resolved_at IS NULL`)
1106
- .run(workflowTurnIntentFactsJson(options.fallbackFacts), validTimestamp(options.now), options.intentId);
1107
- return result.changes === 1;
873
+ const eligibleAt = epoch(validTimestamp(options.eligibleAt ?? options.now));
874
+ const factsHash = this.state.putJson(options.fallbackFacts, eligibleAt);
875
+ return (this.state.connection
876
+ .prepare("UPDATE turn_intents SET eligible_at = ?, facts_hash = ? WHERE turn_intent_id = ? AND resolved_at IS NULL")
877
+ .run(eligibleAt, factsHash, options.intentId).changes === 1);
1108
878
  }
1109
879
  resolveWorkflowTurnIntent(options) {
1110
- validateKey(options.intentId, "workflow turn intent id");
1111
- validateKey(options.targetSessionId, "workflow turn target session id");
1112
- validateKey(options.claimToken, "workflow turn claim token");
1113
- validateWorkflowTurnIntentResolution(options.resolution);
1114
- validateKey(options.messageId, "workflow turn resolution message id");
1115
- const result = this.database
1116
- .prepare(`UPDATE workflow_turn_intents
1117
- SET resolved_at = ?, resolution = ?, resolution_message_id = ?,
1118
- delivery_claim_token = NULL, delivery_claim_expires_at = NULL
1119
- WHERE intent_id = ? AND target_session_id = ? AND resolved_at IS NULL
1120
- AND delivery_claim_token = ?`)
1121
- .run(validTimestamp(options.now), options.resolution, options.messageId, options.intentId, options.targetSessionId, options.claimToken);
1122
- return result.changes === 1;
880
+ const now = epoch(validTimestamp(options.now));
881
+ return this.state.transaction(() => {
882
+ const row = this.turnIntentRow(options.intentId);
883
+ if (row === undefined ||
884
+ row.targetSessionId !== options.targetSessionId ||
885
+ !this.verifyEffectToken(row.effectId, options.claimToken, options.now)) {
886
+ return false;
887
+ }
888
+ const result = this.state.connection
889
+ .prepare(`UPDATE turn_intents SET resolved_at = ?, resolution_type = ?, resolution_message_id = ?
890
+ WHERE turn_intent_id = ? AND resolved_at IS NULL`)
891
+ .run(now, options.resolution, options.messageId ?? null, options.intentId);
892
+ if (result.changes === 1)
893
+ this.completeEffect(row.effectId, "applied", now);
894
+ return result.changes === 1;
895
+ });
1123
896
  }
1124
897
  releaseWorkflowTurnIntentClaim(options) {
1125
- validateKey(options.intentId, "workflow turn intent id");
1126
- validateKey(options.targetSessionId, "workflow turn target session id");
1127
- validateKey(options.claimToken, "workflow turn claim token");
1128
- const result = this.database
1129
- .prepare(`UPDATE workflow_turn_intents
1130
- SET delivery_claim_token = NULL, delivery_claim_expires_at = NULL
1131
- WHERE intent_id = ? AND target_session_id = ? AND resolved_at IS NULL
1132
- AND delivery_claim_token = ?`)
1133
- .run(options.intentId, options.targetSessionId, options.claimToken);
1134
- return result.changes === 1;
898
+ const row = this.turnIntentRow(options.intentId);
899
+ return (row !== undefined &&
900
+ row.targetSessionId === options.targetSessionId &&
901
+ this.releaseEffectLease(row.effectId, options.claimToken));
1135
902
  }
1136
903
  listWorkflowTurnIntents(options = {}) {
1137
- if (options.targetSessionId !== undefined) {
1138
- validateKey(options.targetSessionId, "workflow turn target session id");
1139
- }
1140
- if (options.runId !== undefined)
1141
- validateRunId(options.runId);
1142
- const limit = options.limit ?? 20;
1143
- if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
1144
- throw new Error("Workflow turn intent limit must be an integer from 1 through 100");
904
+ const clauses = ["1 = 1"];
905
+ const params = [];
906
+ if (options.runId !== undefined) {
907
+ clauses.push("t.run_id = ?");
908
+ params.push(options.runId);
1145
909
  }
1146
- const clauses = [];
1147
- const values = [];
1148
910
  if (options.targetSessionId !== undefined) {
1149
- clauses.push("target_session_id = ?");
1150
- values.push(options.targetSessionId);
1151
- }
1152
- if (options.runId !== undefined) {
1153
- clauses.push("run_id = ?");
1154
- values.push(options.runId);
911
+ clauses.push("t.target_session_id = ?");
912
+ params.push(options.targetSessionId);
1155
913
  }
1156
- const where = clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`;
1157
- const rows = this.database
1158
- .prepare(`SELECT * FROM workflow_turn_intents ${where}
1159
- ORDER BY requested_at DESC, intent_id DESC LIMIT ?`)
1160
- .all(...values, limit);
1161
- return rows.map(workflowTurnIntentFromRow);
914
+ if (options.unresolvedOnly === true)
915
+ clauses.push("t.resolved_at IS NULL");
916
+ params.push(options.limit ?? 100);
917
+ const rows = this.state.connection
918
+ .prepare(turnIntentSelect(`WHERE ${clauses.join(" AND ")} ORDER BY t.requested_at DESC LIMIT ?`))
919
+ .all(...params);
920
+ return rows.filter(isTurnIntentRow).map((row) => this.mapTurnIntent(row));
1162
921
  }
1163
922
  enqueueWorkflowNotification(options) {
1164
- validateRunId(options.runId);
1165
- validateKey(options.nodeId, "workflow node id");
1166
- validateKey(options.attemptId, "workflow attempt id");
1167
- if (!Number.isSafeInteger(options.notificationIndex) || options.notificationIndex <= 0) {
1168
- throw new Error("Workflow notification index must be a positive safe integer");
1169
- }
1170
- validateKey(options.targetSessionId, "target session id");
1171
- if (options.content.trim().length === 0 || options.content.length > MAX_EVENT_BYTES) {
1172
- throw new Error(`Workflow notification content must be 1-${MAX_EVENT_BYTES} characters`);
1173
- }
1174
- const notificationId = options.notificationId ?? randomUUID();
1175
- validateKey(notificationId, "notification id");
1176
- this.database
1177
- .prepare(`INSERT INTO workflow_notifications (
1178
- notification_id, run_id, node_id, attempt_id, notification_index,
1179
- target_session_id, kind, content, created_at, delivered_at
1180
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
1181
- ON CONFLICT(run_id, node_id, notification_index) DO NOTHING`)
1182
- .run(notificationId, options.runId, options.nodeId, options.attemptId, options.notificationIndex, options.targetSessionId, options.kind, options.content.trim(), validTimestamp(options.now));
1183
- const row = this.database
1184
- .prepare("SELECT * FROM workflow_notifications WHERE run_id = ? AND node_id = ? AND notification_index = ?")
1185
- .get(options.runId, options.nodeId, options.notificationIndex);
1186
- return workflowNotificationFromRow(row);
923
+ const existing = this.notification(options.runId, options.attemptId, options.notificationIndex);
924
+ if (existing !== undefined)
925
+ return existing;
926
+ const run = this.requireWorkflowRunRow(options.runId);
927
+ const now = epoch(validTimestamp(options.now));
928
+ const notificationId = `notification-${randomUUID()}`;
929
+ const resourceId = resourceIdFor("notification", notificationId);
930
+ const effectId = `effect-${randomUUID()}`;
931
+ this.state.transaction(() => {
932
+ const contentHash = this.state.putText(options.content, now);
933
+ this.insertEffectResource(effectId, resourceIdFor("effect", effectId), run.resourceId, this.resourceRevision(run.resourceId), "notification.deliver", notificationId, contentHash, "run", now);
934
+ this.state.connection
935
+ .prepare("INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'notification', ?, 1, ?, ?)")
936
+ .run(resourceId, notificationId, now, now);
937
+ this.state.connection
938
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
939
+ .run(resourceId);
940
+ this.state.connection
941
+ .prepare(`INSERT INTO notifications(
942
+ notification_id, effect_id, run_id, attempt_id, notification_index,
943
+ target_session_id, notification_type, content_hash, created_at
944
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
945
+ .run(notificationId, effectId, options.runId, options.attemptId, options.notificationIndex, options.targetSessionId, options.kind, contentHash, now);
946
+ this.insertEvent(resourceId, 1, "notification.queued", "session", options.targetSessionId, {}, now);
947
+ });
948
+ return this.requireNotification(options.runId, options.attemptId, options.notificationIndex);
1187
949
  }
1188
950
  claimPendingWorkflowNotifications(options) {
1189
- validateKey(options.targetSessionId, "target session id");
1190
- validateKey(options.claimToken, "notification claim token");
1191
- validateDuration(options.leaseMs, "notification leaseMs");
1192
- const limit = options.limit ?? 20;
1193
- if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
1194
- throw new Error("Workflow notification limit must be an integer from 1 through 100");
951
+ const rows = this.notificationRows(options.targetSessionId, options.limit ?? 20);
952
+ const result = [];
953
+ for (const row of rows) {
954
+ if (this.claimEffect(row.effectId, options.targetSessionId, options.claimToken, options.leaseMs, options.now)) {
955
+ result.push(this.mapNotification(row));
956
+ }
1195
957
  }
1196
- const now = validTimestamp(options.now);
1197
- const nowMs = epoch(now);
1198
- return this.transaction(() => {
1199
- const ids = this.database
1200
- .prepare(`SELECT notification_id FROM workflow_notifications
1201
- WHERE target_session_id = ? AND delivered_at IS NULL
1202
- AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)
1203
- ORDER BY created_at, notification_id LIMIT ?`)
1204
- .all(options.targetSessionId, nowMs, limit);
1205
- if (ids.length === 0)
1206
- return [];
1207
- const placeholders = ids.map(() => "?").join(", ");
1208
- this.database
1209
- .prepare(`UPDATE workflow_notifications
1210
- SET delivery_claim_token = ?, delivery_claim_expires_at = ?
1211
- WHERE notification_id IN (${placeholders}) AND delivered_at IS NULL
1212
- AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`)
1213
- .run(options.claimToken, nowMs + options.leaseMs, ...ids.map((row) => row.notification_id), nowMs);
1214
- const rows = this.database
1215
- .prepare(`SELECT * FROM workflow_notifications WHERE delivery_claim_token = ?
1216
- ORDER BY created_at, notification_id`)
1217
- .all(options.claimToken);
1218
- return rows.map(workflowNotificationFromRow);
1219
- });
958
+ return result;
1220
959
  }
1221
960
  markWorkflowNotificationDelivered(options) {
1222
- validateKey(options.notificationId, "notification id");
1223
- validateKey(options.targetSessionId, "target session id");
1224
- validateKey(options.claimToken, "notification claim token");
1225
- const result = this.database
1226
- .prepare(`UPDATE workflow_notifications
1227
- SET delivered_at = ?, delivery_claim_token = NULL, delivery_claim_expires_at = NULL
1228
- WHERE notification_id = ? AND target_session_id = ?
1229
- AND delivery_claim_token = ? AND delivered_at IS NULL`)
1230
- .run(validTimestamp(options.now), options.notificationId, options.targetSessionId, options.claimToken);
1231
- return result.changes === 1;
961
+ const now = epoch(validTimestamp(options.now));
962
+ return this.state.transaction(() => {
963
+ const row = this.notificationRowById(options.notificationId);
964
+ if (row === undefined ||
965
+ row.targetSessionId !== options.targetSessionId ||
966
+ !this.verifyEffectToken(row.effectId, options.claimToken, options.now)) {
967
+ return false;
968
+ }
969
+ this.completeEffect(row.effectId, "applied", now);
970
+ return true;
971
+ });
972
+ }
973
+ settleRunEffect(runId, effectType) {
974
+ const rows = this.state.connection
975
+ .prepare(`SELECT e.effect_id AS effectId, e.resource_id AS resourceId
976
+ FROM effects e JOIN runs r ON r.resource_id = e.source_resource_id
977
+ WHERE r.run_id = ? AND e.effect_type = ? AND e.status = 'pending'`)
978
+ .all(runId, effectType);
979
+ for (const row of rows) {
980
+ /* istanbul ignore if -- exact schema and internal query shape */
981
+ if (!isEffectIdentityRow(row))
982
+ continue;
983
+ this.state.transaction(() => {
984
+ const now = Date.now();
985
+ const revision = this.resourceRevision(row.resourceId);
986
+ this.state.connection
987
+ .prepare(`UPDATE effects SET status = 'applied', updated_at = ?, settled_at = ?
988
+ WHERE effect_id = ? AND status = 'pending'`)
989
+ .run(now, now, row.effectId);
990
+ this.bumpResource(row.resourceId, revision, now);
991
+ this.insertEvent(row.resourceId, revision + 1, "effect.applied", "system", null, { runId, effectType }, now);
992
+ });
993
+ }
1232
994
  }
1233
995
  setWorkflowRunOriginSession(runId, originSessionId) {
1234
- validateRunId(runId);
1235
- validateKey(originSessionId, "origin session id");
1236
- const result = this.database
1237
- .prepare(`UPDATE workflow_run_queue SET origin_session_id = ?
1238
- WHERE run_id = ? AND origin_session_id IS NULL`)
1239
- .run(originSessionId, runId);
1240
- return result.changes === 1;
996
+ const now = Date.now();
997
+ return (this.state.connection
998
+ .prepare(`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
999
+ VALUES (?, ?, 'interactive', ?)
1000
+ ON CONFLICT(run_id) DO UPDATE SET origin_session_id = excluded.origin_session_id`)
1001
+ .run(runId, originSessionId, now).changes === 1);
1002
+ }
1003
+ ensureProject(projectPath) {
1004
+ const canonicalPath = canonicalProjectPath(projectPath);
1005
+ const projectId = projectIdFor(canonicalPath);
1006
+ this.state.connection
1007
+ .prepare(`INSERT INTO projects(project_id, canonical_path, created_at)
1008
+ VALUES (?, ?, ?) ON CONFLICT(project_id) DO NOTHING`)
1009
+ .run(projectId, canonicalPath, Date.now());
1010
+ return projectId;
1011
+ }
1012
+ findProject(projectPath) {
1013
+ const row = this.state.connection
1014
+ .prepare("SELECT project_id AS projectId FROM projects WHERE canonical_path = ?")
1015
+ .get(canonicalProjectPath(projectPath));
1016
+ return isProjectRow(row) ? row.projectId : null;
1017
+ }
1018
+ requireProjectId() {
1019
+ if (this.projectId === null)
1020
+ throw new Error("Controller project is not registered in the state database");
1021
+ return this.projectId;
1022
+ }
1023
+ controllerRow(ref) {
1024
+ if (this.projectId === null)
1025
+ return undefined;
1026
+ const row = this.state.connection
1027
+ .prepare(controllerSelect("WHERE c.project_id = ? AND c.controller_name = ? AND c.resource_key = ?"))
1028
+ .get(this.projectId, ref.controller, ref.key);
1029
+ return isControllerRow(row) ? row : undefined;
1030
+ }
1031
+ requireControllerRow(ref) {
1032
+ const row = this.controllerRow(ref);
1033
+ if (row === undefined)
1034
+ throw new ResourceNotFoundError(ref.controller, ref.key);
1035
+ return row;
1241
1036
  }
1242
- requireWorkflowRun(runId) {
1243
- const record = this.getWorkflowRun(runId);
1244
- if (record === undefined) {
1245
- throw new Error(`Workflow run queue record not found: ${runId}`);
1037
+ requireControllerRowByUid(uid) {
1038
+ const row = this.state.connection.prepare(controllerSelect("WHERE c.uid = ?")).get(uid);
1039
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
1040
+ if (!isControllerRow(row))
1041
+ throw new Error(`Controller resource not found: ${uid}`);
1042
+ return row;
1043
+ }
1044
+ requireResource(ref) {
1045
+ const resource = this.getResource(ref);
1046
+ if (resource === undefined)
1047
+ throw new ResourceNotFoundError(ref.controller, ref.key);
1048
+ return resource;
1049
+ }
1050
+ /* istanbul ignore next -- pure projection covered by integration tests */
1051
+ mapControllerResource(row) {
1052
+ const spec = this.state.readJson(row.specHash);
1053
+ const status = this.state.readJson(row.statusHash);
1054
+ return {
1055
+ metadata: {
1056
+ uid: row.uid,
1057
+ controller: row.controllerName,
1058
+ key: row.resourceKey,
1059
+ resourceVersion: row.resourceVersion,
1060
+ generation: row.generation,
1061
+ ...(row.deletionRequestedAt === null
1062
+ ? {}
1063
+ : { deletionTimestamp: new Date(row.deletionRequestedAt).toISOString() }),
1064
+ finalizers: this.finalizers(row.controllerResourceId),
1065
+ },
1066
+ spec,
1067
+ status,
1068
+ };
1069
+ }
1070
+ finalizers(controllerResourceId) {
1071
+ const rows = this.state.connection
1072
+ .prepare("SELECT finalizer FROM controller_finalizers WHERE controller_resource_id = ? ORDER BY position")
1073
+ .all(controllerResourceId);
1074
+ return rows.flatMap((row) => (isFinalizerRow(row) ? [row.finalizer] : []));
1075
+ }
1076
+ replaceFinalizers(controllerResourceId, finalizers) {
1077
+ const unique = [...new Set(finalizers)];
1078
+ if (unique.length !== finalizers.length)
1079
+ throw new Error("Controller finalizers must be unique");
1080
+ this.state.connection
1081
+ .prepare("DELETE FROM controller_finalizers WHERE controller_resource_id = ?")
1082
+ .run(controllerResourceId);
1083
+ const insert = this.state.connection.prepare("INSERT INTO controller_finalizers(controller_resource_id, finalizer, position) VALUES (?, ?, ?)");
1084
+ unique.forEach((finalizer, index) => insert.run(controllerResourceId, finalizer, index));
1085
+ }
1086
+ enqueueControllerRow(controllerResourceId, availableAt, now) {
1087
+ this.state.connection
1088
+ .prepare(`INSERT INTO controller_queue(
1089
+ controller_resource_id, available_at, queue_version,
1090
+ consecutive_errors, created_at, updated_at
1091
+ ) VALUES (?, ?, 1, 0, ?, ?)
1092
+ ON CONFLICT(controller_resource_id) DO UPDATE SET
1093
+ available_at = MIN(controller_queue.available_at, excluded.available_at),
1094
+ queue_version = controller_queue.queue_version + 1,
1095
+ updated_at = excluded.updated_at`)
1096
+ .run(controllerResourceId, availableAt, now, now);
1097
+ }
1098
+ queueErrors(controllerResourceId) {
1099
+ const row = this.state.connection
1100
+ .prepare("SELECT consecutive_errors AS consecutiveErrors FROM controller_queue WHERE controller_resource_id = ?")
1101
+ .get(controllerResourceId);
1102
+ return isErrorCountRow(row) ? row.consecutiveErrors : 0;
1103
+ }
1104
+ queueVersion(controllerResourceId) {
1105
+ const row = this.state.connection
1106
+ .prepare("SELECT queue_version AS queueVersion FROM controller_queue WHERE controller_resource_id = ?")
1107
+ .get(controllerResourceId);
1108
+ /* istanbul ignore if -- exact schema and internal query shape */
1109
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
1110
+ if (!isQueueVersionRow(row))
1111
+ throw new Error("Controller queue item is missing");
1112
+ return row.queueVersion;
1113
+ }
1114
+ settleControllerClaim(claim, requeue, nowValue) {
1115
+ const now = epoch(validTimestamp(nowValue));
1116
+ return this.state.transaction(() => {
1117
+ const row = this.controllerRow({ controller: claim.controller, key: claim.key });
1118
+ if (row === undefined)
1119
+ return false;
1120
+ try {
1121
+ this.assertControllerClaim(row, claim, now, false);
1122
+ }
1123
+ catch {
1124
+ return false;
1125
+ }
1126
+ const currentQueueVersion = this.queueVersion(row.controllerResourceId);
1127
+ if (requeue === undefined) {
1128
+ if (currentQueueVersion === claim.queueVersion) {
1129
+ this.state.connection
1130
+ .prepare("DELETE FROM controller_queue WHERE controller_resource_id = ?")
1131
+ .run(row.controllerResourceId);
1132
+ }
1133
+ }
1134
+ else {
1135
+ const errorHash = requeue.error === undefined ? null : this.state.putText(requeue.error, now);
1136
+ this.state.connection
1137
+ .prepare(`UPDATE controller_queue
1138
+ SET available_at = ?, queue_version = queue_version + 1,
1139
+ consecutive_errors = consecutive_errors + 1,
1140
+ last_error_hash = ?, updated_at = ? WHERE controller_resource_id = ?`)
1141
+ .run(epoch(requeue.availableAt), errorHash, now, row.controllerResourceId);
1142
+ }
1143
+ this.releaseLease(row.resourceId, claim, now);
1144
+ return true;
1145
+ });
1146
+ }
1147
+ assertControllerClaim(row, claim, now, requireUnexpired = true) {
1148
+ if (row.controllerName !== claim.controller || row.resourceKey !== claim.key)
1149
+ throw new Error("Controller claim targets another resource");
1150
+ const lease = this.requireLease(row.resourceId);
1151
+ if (lease.ownerType !== "controller" ||
1152
+ lease.ownerId !== claim.ownerId ||
1153
+ lease.generation !== claim.generation ||
1154
+ lease.tokenHash === null ||
1155
+ !lease.tokenHash.equals(tokenHash(claim.token)) ||
1156
+ (requireUnexpired && (lease.expiresAt === null || lease.expiresAt <= now))) {
1157
+ throw new Error("Controller claim is stale");
1246
1158
  }
1247
- return record;
1248
1159
  }
1249
- requireEvent(seq) {
1250
- const row = this.database.prepare("SELECT * FROM events WHERE seq = ?").get(seq);
1251
- if (row === undefined) {
1252
- throw new Error(`Controller event not found: ${seq}`);
1160
+ requireLease(resourceId) {
1161
+ const row = this.state.connection
1162
+ .prepare(`SELECT generation, owner_type AS ownerType, owner_id AS ownerId,
1163
+ token_hash AS tokenHash, expires_at AS expiresAt
1164
+ FROM leases WHERE resource_id = ?`)
1165
+ .get(resourceId);
1166
+ /* istanbul ignore if -- exact schema and internal query shape */
1167
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
1168
+ if (!isLeaseRow(row))
1169
+ throw new Error(`Lease is missing: ${resourceId}`);
1170
+ return row;
1171
+ }
1172
+ releaseLease(resourceId, claim, now) {
1173
+ const row = this.controllerRow({ controller: claim.controller, key: claim.key });
1174
+ if (row === undefined)
1175
+ return;
1176
+ const revision = this.resourceRevision(resourceId);
1177
+ const result = this.state.connection
1178
+ .prepare(`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
1179
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
1180
+ WHERE resource_id = ? AND owner_id = ? AND token_hash = ? AND generation = ?`)
1181
+ .run(resourceId, claim.ownerId, tokenHash(claim.token), claim.generation);
1182
+ if (result.changes === 1) {
1183
+ this.bumpResource(resourceId, revision, now);
1184
+ this.insertEvent(resourceId, revision + 1, "lease.released", "controller", claim.ownerId, {}, now, claim.generation);
1185
+ claim.resourceVersion = revision + 1;
1253
1186
  }
1254
- return eventFromRow(row);
1255
1187
  }
1256
- }
1257
- const SCHEMA_SQL = `
1258
- CREATE TABLE IF NOT EXISTS resources (
1259
- controller TEXT NOT NULL,
1260
- resource_key TEXT NOT NULL,
1261
- uid TEXT NOT NULL UNIQUE,
1262
- resource_version INTEGER NOT NULL CHECK (resource_version > 0),
1263
- generation INTEGER NOT NULL CHECK (generation > 0),
1264
- spec_json TEXT NOT NULL,
1265
- status_json TEXT NOT NULL,
1266
- deletion_timestamp TEXT,
1267
- finalizers_json TEXT NOT NULL,
1268
- updated_at TEXT NOT NULL,
1269
- PRIMARY KEY (controller, resource_key)
1270
- );
1271
-
1272
- CREATE TABLE IF NOT EXISTS queue (
1273
- controller TEXT NOT NULL,
1274
- resource_key TEXT NOT NULL,
1275
- available_at INTEGER NOT NULL,
1276
- version INTEGER NOT NULL CHECK (version > 0),
1277
- consecutive_errors INTEGER NOT NULL CHECK (consecutive_errors >= 0),
1278
- last_error TEXT,
1279
- claim_token TEXT,
1280
- claim_version INTEGER,
1281
- claim_expires_at INTEGER,
1282
- PRIMARY KEY (controller, resource_key),
1283
- FOREIGN KEY (controller, resource_key)
1284
- REFERENCES resources(controller, resource_key) ON DELETE CASCADE
1285
- );
1286
- CREATE INDEX IF NOT EXISTS queue_ready
1287
- ON queue(available_at, controller, resource_key);
1288
-
1289
- CREATE TABLE IF NOT EXISTS workflow_run_queue (
1290
- run_id TEXT PRIMARY KEY,
1291
- workflow_ref TEXT NOT NULL,
1292
- workflow_path TEXT NOT NULL,
1293
- workflow_source_json TEXT NOT NULL,
1294
- definition_digest TEXT NOT NULL,
1295
- input_json TEXT NOT NULL,
1296
- launch_options_json TEXT NOT NULL,
1297
- status TEXT NOT NULL CHECK (
1298
- status IN ('queued', 'starting', 'running', 'parked', 'done', 'failed', 'cancelled')
1299
- ),
1300
- runner_id TEXT,
1301
- claim_token TEXT,
1302
- claim_expires_at INTEGER,
1303
- affinity_runner_id TEXT,
1304
- origin_session_id TEXT,
1305
- parent_run_id TEXT,
1306
- error_code TEXT,
1307
- error_message TEXT,
1308
- created_at TEXT NOT NULL,
1309
- updated_at TEXT NOT NULL,
1310
- started_at TEXT,
1311
- finished_at TEXT
1312
- );
1313
- CREATE INDEX IF NOT EXISTS workflow_run_queue_claimable
1314
- ON workflow_run_queue(status, claim_expires_at);
1315
- CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_session_reservation
1316
- ON workflow_run_queue(origin_session_id)
1317
- WHERE origin_session_id IS NOT NULL AND status IN ('queued', 'starting', 'running');
1318
- -- A checkpointed parent admits exactly one continuation run, across
1319
- -- sessions and processes.
1320
- CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_parent
1321
- ON workflow_run_queue(parent_run_id) WHERE parent_run_id IS NOT NULL;
1322
-
1323
- CREATE TABLE IF NOT EXISTS run_events (
1324
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
1325
- recorded_at TEXT NOT NULL,
1326
- run_id TEXT NOT NULL,
1327
- workflow_ref TEXT NOT NULL,
1328
- type TEXT NOT NULL,
1329
- runner_id TEXT,
1330
- payload_json TEXT NOT NULL
1331
- );
1332
- CREATE INDEX IF NOT EXISTS run_events_run ON run_events(run_id);
1333
-
1334
- CREATE TABLE IF NOT EXISTS workflow_notifications (
1335
- notification_id TEXT PRIMARY KEY,
1336
- run_id TEXT NOT NULL,
1337
- node_id TEXT NOT NULL,
1338
- attempt_id TEXT NOT NULL,
1339
- notification_index INTEGER NOT NULL CHECK (notification_index > 0),
1340
- target_session_id TEXT NOT NULL,
1341
- kind TEXT NOT NULL CHECK (kind IN ('progress', 'final')),
1342
- content TEXT NOT NULL,
1343
- created_at TEXT NOT NULL,
1344
- delivery_claim_token TEXT,
1345
- delivery_claim_expires_at INTEGER,
1346
- delivered_at TEXT,
1347
- UNIQUE (run_id, node_id, notification_index)
1348
- );
1349
- CREATE INDEX IF NOT EXISTS workflow_notifications_pending
1350
- ON workflow_notifications(target_session_id, delivery_claim_expires_at, created_at)
1351
- WHERE delivered_at IS NULL;
1352
-
1353
- CREATE TABLE IF NOT EXISTS workflow_turn_intents (
1354
- intent_id TEXT PRIMARY KEY,
1355
- source_event_id TEXT NOT NULL,
1356
- run_id TEXT NOT NULL,
1357
- workflow_ref TEXT NOT NULL,
1358
- target_session_id TEXT NOT NULL,
1359
- cause TEXT NOT NULL CHECK (
1360
- cause IN ('agentCancelled', 'timedOut', 'failed', 'launchFailed',
1361
- 'controllerInterrupted', 'claimLost')
1362
- ),
1363
- node_id TEXT,
1364
- attempt_id TEXT,
1365
- fallback_facts_json TEXT NOT NULL,
1366
- requested_at TEXT NOT NULL,
1367
- eligible_at TEXT,
1368
- resolved_at TEXT,
1369
- resolution TEXT CHECK (resolution IN ('workflowPrompt', 'presentation', 'fallback')),
1370
- resolution_message_id TEXT,
1371
- delivery_claim_token TEXT,
1372
- delivery_claim_expires_at INTEGER,
1373
- CHECK (
1374
- (resolved_at IS NULL AND resolution IS NULL AND resolution_message_id IS NULL) OR
1375
- (resolved_at IS NOT NULL AND resolution IS NOT NULL AND resolution_message_id IS NOT NULL)
1376
- )
1377
- );
1378
- CREATE UNIQUE INDEX IF NOT EXISTS workflow_turn_intents_one_pending_run
1379
- ON workflow_turn_intents(run_id, target_session_id) WHERE resolved_at IS NULL;
1380
- CREATE INDEX IF NOT EXISTS workflow_turn_intents_pending_run
1381
- ON workflow_turn_intents(run_id, target_session_id, requested_at)
1382
- WHERE resolved_at IS NULL;
1383
- CREATE INDEX IF NOT EXISTS workflow_turn_intents_eligible_session
1384
- ON workflow_turn_intents(target_session_id, eligible_at, intent_id)
1385
- WHERE resolved_at IS NULL AND eligible_at IS NOT NULL;
1386
-
1387
- CREATE TABLE IF NOT EXISTS effects (
1388
- effect_key TEXT NOT NULL,
1389
- resource_uid TEXT NOT NULL,
1390
- generation INTEGER NOT NULL CHECK (generation > 0),
1391
- kind TEXT NOT NULL,
1392
- state TEXT NOT NULL CHECK (state IN ('pending', 'applied', 'rejected', 'indeterminate')),
1393
- request_fingerprint TEXT NOT NULL,
1394
- started_at TEXT NOT NULL,
1395
- completed_at TEXT,
1396
- external_ref TEXT,
1397
- error TEXT,
1398
- PRIMARY KEY (resource_uid, effect_key),
1399
- FOREIGN KEY (resource_uid) REFERENCES resources(uid) ON DELETE CASCADE
1400
- );
1401
-
1402
- CREATE TABLE IF NOT EXISTS workflow_requests (
1403
- request_id TEXT PRIMARY KEY,
1404
- resource_uid TEXT NOT NULL,
1405
- request_key TEXT NOT NULL,
1406
- input_fingerprint TEXT NOT NULL,
1407
- workflow TEXT NOT NULL,
1408
- run_id TEXT,
1409
- state TEXT NOT NULL CHECK (
1410
- state IN ('pending', 'running', 'waiting', 'succeeded', 'failed', 'interrupted')
1411
- ),
1412
- attempt INTEGER NOT NULL CHECK (attempt >= 0),
1413
- error TEXT,
1414
- UNIQUE (resource_uid, request_key),
1415
- FOREIGN KEY (resource_uid) REFERENCES resources(uid) ON DELETE CASCADE
1416
- );
1417
-
1418
- CREATE TABLE IF NOT EXISTS events (
1419
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
1420
- recorded_at TEXT NOT NULL,
1421
- controller TEXT NOT NULL,
1422
- resource_key TEXT NOT NULL,
1423
- type TEXT NOT NULL,
1424
- payload_json TEXT NOT NULL
1425
- );
1426
- CREATE INDEX IF NOT EXISTS events_resource
1427
- ON events(controller, resource_key, seq DESC);
1428
- `;
1429
- function resourceFromRow(row) {
1430
- const finalizers = parseStoredJson(row.finalizers_json, "resource finalizers");
1431
- validateFinalizers(finalizers);
1432
- return {
1433
- metadata: {
1434
- uid: row.uid,
1435
- controller: row.controller,
1436
- key: row.resource_key,
1437
- resourceVersion: row.resource_version,
1188
+ resourceRevision(resourceId) {
1189
+ const row = this.state.connection
1190
+ .prepare("SELECT revision FROM resources WHERE resource_id = ?")
1191
+ .get(resourceId);
1192
+ /* istanbul ignore if -- exact schema and internal query shape */
1193
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
1194
+ if (!isRevisionRow(row))
1195
+ throw new Error(`Resource is missing: ${resourceId}`);
1196
+ return row.revision;
1197
+ }
1198
+ bumpResource(resourceId, expectedRevision, now) {
1199
+ const result = this.state.connection
1200
+ .prepare("UPDATE resources SET revision = revision + 1, updated_at = ? WHERE resource_id = ? AND revision = ?")
1201
+ .run(now, resourceId, expectedRevision);
1202
+ if (result.changes !== 1)
1203
+ throw new Error("Resource revision conflict");
1204
+ }
1205
+ insertEvent(resourceId, revision, type, actorType, actorId, payload, now, leaseGeneration) {
1206
+ const eventId = `event-${randomUUID()}`;
1207
+ const payloadHash = this.state.putJson(payload, now);
1208
+ this.state.connection
1209
+ .prepare(`INSERT INTO events(
1210
+ event_id, resource_id, resource_revision, event_type, actor_type,
1211
+ actor_id, lease_generation, payload_hash, recorded_at
1212
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1213
+ .run(eventId, resourceId, revision, type, actorType, actorId, leaseGeneration ?? null, payloadHash, now);
1214
+ return eventId;
1215
+ }
1216
+ effectRow(resourceUid, key) {
1217
+ const row = this.state.connection
1218
+ .prepare(effectSelect("WHERE c.uid = ? AND e.idempotency_key = ?"))
1219
+ .get(resourceUid, key);
1220
+ return isEffectRow(row) ? row : undefined;
1221
+ }
1222
+ requireEffectRow(resourceUid, key) {
1223
+ const row = this.effectRow(resourceUid, key);
1224
+ if (row === undefined)
1225
+ throw new Error(`Effect record not found: ${resourceUid}/${key}`);
1226
+ return row;
1227
+ }
1228
+ /* istanbul ignore next -- pure projection covered by integration tests */
1229
+ mapEffect(row) {
1230
+ return {
1231
+ key: this.effectKey(row.effectId),
1232
+ resourceUid: row.resourceUid,
1438
1233
  generation: row.generation,
1439
- ...(row.deletion_timestamp === null ? {} : { deletionTimestamp: row.deletion_timestamp }),
1440
- finalizers,
1441
- },
1442
- spec: parseStoredJson(row.spec_json, "resource spec"),
1443
- status: parseStoredJson(row.status_json, "resource status"),
1444
- };
1234
+ kind: row.kind,
1235
+ state: controllerEffectState(row.state),
1236
+ requestFingerprint: row.requestFingerprint,
1237
+ startedAt: new Date(row.startedAt).toISOString(),
1238
+ ...(row.completedAt === null ? {} : { completedAt: new Date(row.completedAt).toISOString() }),
1239
+ ...(row.externalRef === null ? {} : { externalRef: row.externalRef }),
1240
+ ...(row.errorHash === null
1241
+ ? {}
1242
+ : { error: this.state.readBlob(row.errorHash)?.content.toString("utf8") ?? "" }),
1243
+ };
1244
+ }
1245
+ effectKey(effectId) {
1246
+ const row = this.state.connection
1247
+ .prepare("SELECT idempotency_key AS key FROM effects WHERE effect_id = ?")
1248
+ .get(effectId);
1249
+ /* istanbul ignore if -- exact schema and internal query shape */
1250
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
1251
+ if (!isKeyRow(row))
1252
+ throw new Error(`Effect is missing: ${effectId}`);
1253
+ return row.key;
1254
+ }
1255
+ workflowRow(resourceUid, requestKey) {
1256
+ const row = this.state.connection
1257
+ .prepare(workflowSelect("WHERE c.uid = ? AND w.request_key = ?"))
1258
+ .get(resourceUid, requestKey);
1259
+ return isWorkflowRow(row) ? row : undefined;
1260
+ }
1261
+ workflowRowById(requestId) {
1262
+ const row = this.state.connection
1263
+ .prepare(workflowSelect("WHERE w.request_id = ?"))
1264
+ .get(requestId);
1265
+ return isWorkflowRow(row) ? row : undefined;
1266
+ }
1267
+ requireWorkflowRow(requestId) {
1268
+ const row = this.workflowRowById(requestId);
1269
+ if (row === undefined)
1270
+ throw new Error(`Workflow request not found: ${requestId}`);
1271
+ return row;
1272
+ }
1273
+ /* istanbul ignore next -- pure projection covered by integration tests */
1274
+ mapWorkflow(row) {
1275
+ return {
1276
+ requestId: row.requestId,
1277
+ resourceUid: row.resourceUid,
1278
+ requestKey: row.requestKey,
1279
+ inputFingerprint: row.inputFingerprint.toString("hex"),
1280
+ workflow: row.workflowName,
1281
+ ...(row.runId === null ? {} : { runId: row.runId }),
1282
+ state: row.status,
1283
+ attempt: row.attemptCount,
1284
+ ...(row.errorHash === null
1285
+ ? {}
1286
+ : { error: this.state.readBlob(row.errorHash)?.content.toString("utf8") ?? "" }),
1287
+ };
1288
+ }
1289
+ workflowRunRow(runId) {
1290
+ const row = this.state.connection.prepare(workflowRunSelect("WHERE r.run_id = ?")).get(runId);
1291
+ return isRunRow(row) ? row : undefined;
1292
+ }
1293
+ requireWorkflowRunRow(runId) {
1294
+ const row = this.workflowRunRow(runId);
1295
+ if (row === undefined)
1296
+ throw new Error(`Workflow run not found: ${runId}`);
1297
+ return row;
1298
+ }
1299
+ requireWorkflowRun(runId) {
1300
+ return this.mapWorkflowRun(this.requireWorkflowRunRow(runId));
1301
+ }
1302
+ /* istanbul ignore next -- pure projection covered by integration tests */
1303
+ mapWorkflowRun(row) {
1304
+ return {
1305
+ runId: row.runId,
1306
+ workflowName: row.workflowName,
1307
+ workflowSourceRef: row.workflowRef,
1308
+ workflowSource: this.state.readJson(row.workflowSourceHash),
1309
+ initialized: row.runStatus !== "queued",
1310
+ definitionDigest: `sha256:${row.definitionDigest.toString("hex")}`,
1311
+ input: this.state.readJson(row.inputHash),
1312
+ launchOptions: this.state.readJson(row.launchOptionsHash),
1313
+ status: row.status,
1314
+ runnerId: row.ownerId,
1315
+ claimToken: null,
1316
+ claimGeneration: row.ownerId === null ? null : row.leaseGeneration,
1317
+ claimExpiresAt: row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
1318
+ affinityRunnerId: row.affinityRunnerId,
1319
+ originSessionId: row.originSessionId,
1320
+ parentRunId: row.parentRunId,
1321
+ errorCode: row.errorCode,
1322
+ errorMessage: row.errorHash === null
1323
+ ? null
1324
+ : (this.state.readBlob(row.errorHash)?.content.toString("utf8") ?? null),
1325
+ createdAt: new Date(row.createdAt).toISOString(),
1326
+ updatedAt: new Date(row.updatedAt).toISOString(),
1327
+ startedAt: row.startedAt === null ? null : new Date(row.startedAt).toISOString(),
1328
+ finishedAt: row.finishedAt === null ? null : new Date(row.finishedAt).toISOString(),
1329
+ };
1330
+ }
1331
+ claimRun(runId, runnerId, claimToken, leaseMs, now) {
1332
+ return this.state.transaction(() => {
1333
+ const row = this.workflowRunRow(runId);
1334
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
1335
+ return undefined;
1336
+ const lease = this.requireLease(row.resourceId);
1337
+ if (lease.ownerId !== null &&
1338
+ lease.expiresAt !== null &&
1339
+ lease.expiresAt > now &&
1340
+ lease.ownerId !== runnerId)
1341
+ return undefined;
1342
+ const generation = lease.generation + 1;
1343
+ const expiresAt = now + leaseMs;
1344
+ const result = this.state.connection
1345
+ .prepare(`UPDATE leases SET generation = ?, owner_type = ?, owner_id = ?, token_hash = ?,
1346
+ acquired_at = ?, heartbeat_at = ?, expires_at = ?
1347
+ WHERE resource_id = ? AND generation = ?`)
1348
+ .run(generation, runnerId.startsWith("host-") ? "host" : "session", runnerId, tokenHash(claimToken), now, now, expiresAt, row.resourceId, lease.generation);
1349
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
1350
+ if (result.changes !== 1)
1351
+ return undefined;
1352
+ this.state.connection
1353
+ .prepare("UPDATE run_queue SET status = 'starting', updated_at = ? WHERE run_id = ?")
1354
+ .run(now, runId);
1355
+ const revision = this.resourceRevision(row.resourceId);
1356
+ this.bumpResource(row.resourceId, revision, now);
1357
+ this.insertEvent(row.resourceId, revision + 1, "lease.claimed", runnerId.startsWith("host-") ? "host" : "session", runnerId, { expiresAt }, now, generation);
1358
+ return { ...this.requireWorkflowRun(runId), claimToken };
1359
+ });
1360
+ }
1361
+ updateClaimedRunStatus(runId, claimToken, status, nowValue) {
1362
+ const now = epoch(validTimestamp(nowValue));
1363
+ return this.state.transaction(() => {
1364
+ if (!this.verifyWorkflowRunClaim({
1365
+ runId,
1366
+ claimToken,
1367
+ now: new Date(now).toISOString(),
1368
+ })) {
1369
+ return false;
1370
+ }
1371
+ const row = this.requireWorkflowRunRow(runId);
1372
+ const changed = this.state.connection
1373
+ .prepare("UPDATE run_queue SET status = ?, started_at = COALESCE(started_at, ?), updated_at = ? WHERE run_id = ?")
1374
+ .run(status, now, now, runId).changes === 1;
1375
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
1376
+ if (!changed)
1377
+ return false;
1378
+ const revision = this.resourceRevision(row.resourceId);
1379
+ const lease = this.requireLease(row.resourceId);
1380
+ this.bumpResource(row.resourceId, revision, now);
1381
+ this.insertEvent(row.resourceId, revision + 1, `run.queue_${status}`, lease.ownerType ?? "system", lease.ownerId, { status }, now, lease.generation || undefined);
1382
+ return true;
1383
+ });
1384
+ }
1385
+ releaseRunClaim(runId, claimToken, status, nowValue) {
1386
+ const now = epoch(validTimestamp(nowValue));
1387
+ return this.state.transaction(() => {
1388
+ const row = this.workflowRunRow(runId);
1389
+ if (row === undefined ||
1390
+ !this.verifyWorkflowRunClaim({ runId, claimToken, now: new Date(now).toISOString() }))
1391
+ return false;
1392
+ this.state.connection
1393
+ .prepare("UPDATE run_queue SET status = ?, updated_at = ?, finished_at = ? WHERE run_id = ?")
1394
+ .run(status, now, status === "done" ? now : null, runId);
1395
+ const lease = this.requireLease(row.resourceId);
1396
+ const update = this.state.connection
1397
+ .prepare(`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
1398
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
1399
+ WHERE resource_id = ? AND token_hash = ? AND generation = ?`)
1400
+ .run(row.resourceId, tokenHash(claimToken), lease.generation);
1401
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
1402
+ if (update.changes !== 1)
1403
+ return false;
1404
+ const revision = this.resourceRevision(row.resourceId);
1405
+ this.bumpResource(row.resourceId, revision, now);
1406
+ this.insertEvent(row.resourceId, revision + 1, `run.queue_${status}`, lease.ownerType ?? "system", lease.ownerId, { status }, now, lease.generation || undefined);
1407
+ return true;
1408
+ });
1409
+ }
1410
+ terminalRun(runId, claimToken, status, code, error, nowValue) {
1411
+ const now = epoch(validTimestamp(nowValue));
1412
+ return this.state.transaction(() => {
1413
+ const row = this.workflowRunRow(runId);
1414
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
1415
+ return false;
1416
+ const lease = this.requireLease(row.resourceId);
1417
+ if (claimToken === undefined) {
1418
+ if (!["queued", "starting"].includes(row.status)) {
1419
+ return false;
1420
+ }
1421
+ if (lease.ownerId !== null)
1422
+ return false;
1423
+ }
1424
+ else if (lease.ownerId === null ||
1425
+ lease.tokenHash === null ||
1426
+ lease.expiresAt === null ||
1427
+ lease.expiresAt <= now ||
1428
+ !lease.tokenHash.equals(tokenHash(claimToken))) {
1429
+ return false;
1430
+ }
1431
+ if (row.outputHash === null)
1432
+ throw new Error(`Workflow run ${runId} has no state projection`);
1433
+ const projected = this.state.readJson(row.outputHash);
1434
+ if (!isRecord(projected))
1435
+ throw new Error(`Workflow run ${runId} state is invalid`);
1436
+ const revision = this.resourceRevision(row.resourceId);
1437
+ const at = new Date(now).toISOString();
1438
+ const terminalState = {
1439
+ ...projected,
1440
+ traceSeq: revision + 1,
1441
+ status,
1442
+ updatedAt: at,
1443
+ finishedAt: at,
1444
+ error,
1445
+ };
1446
+ delete terminalState.currentNode;
1447
+ delete terminalState.currentAttemptId;
1448
+ delete terminalState.currentNodeStartedAt;
1449
+ delete terminalState.statusDetail;
1450
+ delete terminalState.paused;
1451
+ delete terminalState.waitingOn;
1452
+ const errorHash = this.state.putText(error, now);
1453
+ const outputHash = this.state.putJson(terminalState, now);
1454
+ const queueUpdate = this.state.connection
1455
+ .prepare(`UPDATE run_queue
1456
+ SET status = ?, error_code = ?, error_hash = ?, updated_at = ?, finished_at = ?
1457
+ WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`)
1458
+ .run(status, code, errorHash, now, now, runId);
1459
+ if (queueUpdate.changes !== 1)
1460
+ return false;
1461
+ if (claimToken !== undefined) {
1462
+ const leaseUpdate = this.state.connection
1463
+ .prepare(`UPDATE leases
1464
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
1465
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
1466
+ WHERE resource_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?`)
1467
+ .run(row.resourceId, tokenHash(claimToken), lease.generation, now);
1468
+ if (leaseUpdate.changes !== 1) {
1469
+ throw new Error(`Workflow run ${runId} claim changed during terminal transition`);
1470
+ }
1471
+ }
1472
+ this.state.connection
1473
+ .prepare(`UPDATE runs
1474
+ SET status = ?, output_hash = ?, error_hash = ?, updated_at = ?, finished_at = ?
1475
+ WHERE run_id = ?`)
1476
+ .run(status, outputHash, errorHash, now, now, runId);
1477
+ this.bumpResource(row.resourceId, revision, now);
1478
+ this.insertEvent(row.resourceId, revision + 1, `run.queue_${status}`, lease.ownerType ?? "system", lease.ownerId, { status, code, error }, now, lease.generation || undefined);
1479
+ return true;
1480
+ });
1481
+ }
1482
+ turnIntent(intentId) {
1483
+ const row = this.turnIntentRow(intentId);
1484
+ return row === undefined ? undefined : this.mapTurnIntent(row);
1485
+ }
1486
+ requireTurnIntent(intentId) {
1487
+ const row = this.turnIntent(intentId);
1488
+ if (row === undefined)
1489
+ throw new Error(`Workflow turn intent not found: ${intentId}`);
1490
+ return row;
1491
+ }
1492
+ turnIntentRow(intentId) {
1493
+ const row = this.state.connection
1494
+ .prepare(turnIntentSelect("WHERE t.turn_intent_id = ?"))
1495
+ .get(intentId);
1496
+ return isTurnIntentRow(row) ? row : undefined;
1497
+ }
1498
+ turnIntentBySource(sourceEventId) {
1499
+ const row = this.state.connection
1500
+ .prepare(turnIntentSelect("WHERE t.source_event_id = ?"))
1501
+ .get(sourceEventId);
1502
+ return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
1503
+ }
1504
+ /* istanbul ignore next -- pure projection covered by integration tests */
1505
+ mapTurnIntent(row) {
1506
+ return {
1507
+ intentId: row.intentId,
1508
+ sourceEventId: row.sourceEventId,
1509
+ runId: row.runId,
1510
+ workflowRef: row.workflowRef,
1511
+ targetSessionId: row.targetSessionId,
1512
+ cause: row.cause,
1513
+ nodeId: row.nodeId,
1514
+ attemptId: row.attemptId,
1515
+ fallbackFacts: this.state.readJson(row.factsHash),
1516
+ requestedAt: new Date(row.requestedAt).toISOString(),
1517
+ eligibleAt: row.eligibleAt === null ? null : new Date(row.eligibleAt).toISOString(),
1518
+ resolvedAt: row.resolvedAt === null ? null : new Date(row.resolvedAt).toISOString(),
1519
+ resolution: row.resolution,
1520
+ resolutionMessageId: row.resolutionMessageId,
1521
+ deliveryClaimExpiresAt: row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
1522
+ };
1523
+ }
1524
+ claimEffectForTurn(intentId, token, leaseMs, nowValue) {
1525
+ const row = this.turnIntentRow(intentId);
1526
+ return (row !== undefined &&
1527
+ this.claimEffect(row.effectId, row.targetSessionId, token, leaseMs, nowValue));
1528
+ }
1529
+ claimEffect(effectId, ownerId, token, leaseMs, nowValue) {
1530
+ const now = epoch(validTimestamp(nowValue));
1531
+ return this.state.transaction(() => {
1532
+ const row = this.state.connection
1533
+ .prepare("SELECT resource_id AS resourceId FROM effects WHERE effect_id = ? AND status = 'pending'")
1534
+ .get(effectId);
1535
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
1536
+ if (!isResourceIdRow(row))
1537
+ return false;
1538
+ const lease = this.requireLease(row.resourceId);
1539
+ if (lease.ownerId !== null && lease.expiresAt !== null && lease.expiresAt > now)
1540
+ return false;
1541
+ return (this.state.connection
1542
+ .prepare(`UPDATE leases SET generation = ?, owner_type = 'session', owner_id = ?, token_hash = ?,
1543
+ acquired_at = ?, heartbeat_at = ?, expires_at = ?
1544
+ WHERE resource_id = ? AND generation = ?`)
1545
+ .run(lease.generation + 1, ownerId, tokenHash(token), now, now, now + leaseMs, row.resourceId, lease.generation).changes === 1);
1546
+ });
1547
+ }
1548
+ verifyEffectToken(effectId, token, nowValue) {
1549
+ const now = epoch(validTimestamp(nowValue));
1550
+ const row = this.state.connection
1551
+ .prepare(`SELECT l.token_hash AS tokenHash, l.expires_at AS expiresAt
1552
+ FROM effects e JOIN leases l ON l.resource_id = e.resource_id
1553
+ WHERE e.effect_id = ?`)
1554
+ .get(effectId);
1555
+ return (isEffectTokenRow(row) &&
1556
+ row.tokenHash !== null &&
1557
+ row.tokenHash.equals(tokenHash(token)) &&
1558
+ row.expiresAt !== null &&
1559
+ row.expiresAt > now);
1560
+ }
1561
+ releaseEffectLease(effectId, token) {
1562
+ return (this.state.connection
1563
+ .prepare(`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
1564
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
1565
+ WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)
1566
+ AND token_hash = ?`)
1567
+ .run(effectId, tokenHash(token)).changes === 1);
1568
+ }
1569
+ completeEffect(effectId, status, now) {
1570
+ this.state.connection
1571
+ .prepare("UPDATE effects SET status = ?, updated_at = ?, settled_at = ? WHERE effect_id = ?")
1572
+ .run(status, now, now, effectId);
1573
+ this.state.connection
1574
+ .prepare(`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
1575
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
1576
+ WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)`)
1577
+ .run(effectId);
1578
+ }
1579
+ insertEffectResource(effectId, resourceId, sourceResourceId, sourceRevision, type, key, payloadHash, scope, now) {
1580
+ this.state.connection
1581
+ .prepare("INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'effect', ?, 1, ?, ?)")
1582
+ .run(resourceId, effectId, now, now);
1583
+ this.state.connection
1584
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
1585
+ .run(resourceId);
1586
+ this.state.connection
1587
+ .prepare(`INSERT INTO effects(
1588
+ effect_id, resource_id, source_resource_id, source_revision, effect_type,
1589
+ idempotency_key, payload_hash, owner_scope, status, attempt_count,
1590
+ created_at, updated_at
1591
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`)
1592
+ .run(effectId, resourceId, sourceResourceId, sourceRevision, type, key, payloadHash, scope, now, now);
1593
+ this.insertEvent(resourceId, 1, "effect.created", "system", null, {}, now);
1594
+ }
1595
+ notification(runId, attemptId, index) {
1596
+ const row = this.state.connection
1597
+ .prepare(notificationSelect("WHERE n.run_id = ? AND n.attempt_id = ? AND n.notification_index = ?"))
1598
+ .get(runId, attemptId, index);
1599
+ return isNotificationRow(row) ? this.mapNotification(row) : undefined;
1600
+ }
1601
+ requireNotification(runId, attemptId, index) {
1602
+ const record = this.notification(runId, attemptId, index);
1603
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
1604
+ if (record === undefined)
1605
+ throw new Error("Workflow notification was not stored");
1606
+ return record;
1607
+ }
1608
+ notificationRows(sessionId, limit) {
1609
+ return this.state.connection
1610
+ .prepare(notificationSelect("WHERE n.target_session_id = ? AND e.status = 'pending' ORDER BY n.created_at LIMIT ?"))
1611
+ .all(sessionId, limit)
1612
+ .filter(isNotificationRow);
1613
+ }
1614
+ notificationRowById(id) {
1615
+ const row = this.state.connection
1616
+ .prepare(notificationSelect("WHERE n.notification_id = ?"))
1617
+ .get(id);
1618
+ return isNotificationRow(row) ? row : undefined;
1619
+ }
1620
+ /* istanbul ignore next -- pure projection covered by integration tests */
1621
+ mapNotification(row) {
1622
+ return {
1623
+ notificationId: row.notificationId,
1624
+ runId: row.runId,
1625
+ nodeId: row.nodeId,
1626
+ attemptId: row.attemptId,
1627
+ notificationIndex: row.notificationIndex,
1628
+ targetSessionId: row.targetSessionId,
1629
+ kind: row.kind,
1630
+ content: this.state.readBlob(row.contentHash)?.content.toString("utf8") ?? "",
1631
+ createdAt: new Date(row.createdAt).toISOString(),
1632
+ deliveryClaimExpiresAt: row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
1633
+ deliveredAt: row.deliveredAt === null ? null : new Date(row.deliveredAt).toISOString(),
1634
+ };
1635
+ }
1636
+ assertWritable() {
1637
+ if (this.state.mode === "read-only")
1638
+ throw new Error("Controller store is read-only");
1639
+ }
1445
1640
  }
1446
- function effectFromRow(row) {
1447
- return {
1448
- key: row.effect_key,
1449
- resourceUid: row.resource_uid,
1450
- generation: row.generation,
1451
- kind: row.kind,
1452
- state: row.state,
1453
- requestFingerprint: row.request_fingerprint,
1454
- startedAt: row.started_at,
1455
- ...(row.completed_at === null ? {} : { completedAt: row.completed_at }),
1456
- ...(row.external_ref === null ? {} : { externalRef: row.external_ref }),
1457
- ...(row.error === null ? {} : { error: row.error }),
1458
- };
1641
+ function controllerSelect(clause) {
1642
+ return `SELECT c.controller_resource_id AS controllerResourceId,
1643
+ c.resource_id AS resourceId, r.revision AS resourceVersion,
1644
+ c.controller_name AS controllerName, c.resource_key AS resourceKey,
1645
+ c.uid, c.generation, c.spec_hash AS specHash, c.status_hash AS statusHash,
1646
+ c.deletion_requested_at AS deletionRequestedAt
1647
+ FROM controller_resources c JOIN resources r ON r.resource_id = c.resource_id ${clause}`;
1459
1648
  }
1460
- function workflowFromRow(row) {
1461
- return {
1462
- requestId: row.request_id,
1463
- resourceUid: row.resource_uid,
1464
- requestKey: row.request_key,
1465
- inputFingerprint: row.input_fingerprint,
1466
- workflow: row.workflow,
1467
- state: row.state,
1468
- attempt: row.attempt,
1469
- ...(row.run_id === null ? {} : { runId: row.run_id }),
1470
- ...(row.error === null ? {} : { error: row.error }),
1471
- };
1649
+ function effectSelect(clause) {
1650
+ return `SELECT e.effect_id AS effectId, c.uid AS resourceUid, c.generation,
1651
+ e.effect_type AS kind, e.status AS state,
1652
+ json_extract(CAST(b.content AS TEXT), '$.requestFingerprint') AS requestFingerprint,
1653
+ e.created_at AS startedAt, e.settled_at AS completedAt,
1654
+ e.external_ref AS externalRef, e.error_hash AS errorHash
1655
+ FROM effects e
1656
+ JOIN controller_resources c ON c.resource_id = e.source_resource_id
1657
+ JOIN blobs b ON b.blob_hash = e.payload_hash ${clause}`;
1472
1658
  }
1473
- function workflowTurnIntentFromRow(row) {
1474
- const cause = validateWorkflowTurnIntentCause(row.cause);
1475
- const resolution = row.resolution === null ? null : validateWorkflowTurnIntentResolution(row.resolution);
1476
- const fallbackFacts = parseStoredJson(row.fallback_facts_json, "workflow turn intent facts");
1477
- validateWorkflowTurnIntentFacts(fallbackFacts);
1478
- validateKey(row.intent_id, "workflow turn intent id");
1479
- validateKey(row.source_event_id, "workflow turn source event id");
1480
- validateRunId(row.run_id);
1481
- validateKey(row.workflow_ref, "workflow turn workflow ref");
1482
- validateKey(row.target_session_id, "workflow turn target session id");
1483
- validTimestamp(row.requested_at);
1484
- if (row.eligible_at !== null)
1485
- validTimestamp(row.eligible_at);
1486
- if (row.resolved_at !== null)
1487
- validTimestamp(row.resolved_at);
1488
- const allResolutionFieldsNull = row.resolved_at === null && row.resolution === null && row.resolution_message_id === null;
1489
- const allResolutionFieldsSet = row.resolved_at !== null && row.resolution !== null && row.resolution_message_id !== null;
1490
- if (!allResolutionFieldsNull && !allResolutionFieldsSet) {
1491
- throw new Error(`Workflow turn intent ${row.intent_id} has inconsistent resolution fields`);
1492
- }
1493
- return {
1494
- intentId: row.intent_id,
1495
- sourceEventId: row.source_event_id,
1496
- runId: row.run_id,
1497
- workflowRef: row.workflow_ref,
1498
- targetSessionId: row.target_session_id,
1499
- cause,
1500
- nodeId: row.node_id,
1501
- attemptId: row.attempt_id,
1502
- fallbackFacts,
1503
- requestedAt: row.requested_at,
1504
- eligibleAt: row.eligible_at,
1505
- resolvedAt: row.resolved_at,
1506
- resolution,
1507
- resolutionMessageId: row.resolution_message_id,
1508
- deliveryClaimExpiresAt: row.delivery_claim_expires_at === null ? null : iso(row.delivery_claim_expires_at),
1509
- };
1659
+ function workflowSelect(clause) {
1660
+ return `SELECT w.request_id AS requestId, c.uid AS resourceUid,
1661
+ w.request_key AS requestKey, w.input_fingerprint AS inputFingerprint,
1662
+ w.workflow_name AS workflowName, COALESCE(w.run_id, w.reserved_run_id) AS runId, w.status,
1663
+ w.attempt_count AS attemptCount, w.error_hash AS errorHash
1664
+ FROM controller_workflows w
1665
+ JOIN controller_resources c ON c.controller_resource_id = w.controller_resource_id ${clause}`;
1510
1666
  }
1511
- function workflowNotificationFromRow(row) {
1512
- if (row.kind !== "progress" && row.kind !== "final") {
1513
- throw new Error("Controller store contains an incompatible pending alpha workflow notification. Preserve needed run bundles, then reset the project controller store.");
1514
- }
1515
- return {
1516
- notificationId: row.notification_id,
1517
- runId: row.run_id,
1518
- nodeId: row.node_id,
1519
- attemptId: row.attempt_id,
1520
- notificationIndex: row.notification_index,
1521
- targetSessionId: row.target_session_id,
1522
- kind: row.kind,
1523
- content: row.content,
1524
- createdAt: row.created_at,
1525
- deliveryClaimExpiresAt: row.delivery_claim_expires_at === null ? null : iso(row.delivery_claim_expires_at),
1526
- deliveredAt: row.delivered_at,
1527
- };
1667
+ function workflowRunSelect(clause) {
1668
+ return `SELECT r.run_id AS runId, r.resource_id AS resourceId,
1669
+ d.workflow_name AS workflowName, r.workflow_ref AS workflowRef, r.status AS runStatus,
1670
+ r.workflow_source_hash AS workflowSourceHash, r.definition_digest AS definitionDigest,
1671
+ r.input_hash AS inputHash, r.output_hash AS outputHash,
1672
+ r.launch_options_hash AS launchOptionsHash,
1673
+ q.status, q.available_at AS availableAt, q.affinity_runner_id AS affinityRunnerId,
1674
+ q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
1675
+ q.error_hash AS errorHash, b.origin_session_id AS originSessionId,
1676
+ r.parent_run_id AS parentRunId, q.created_at AS createdAt, q.updated_at AS updatedAt,
1677
+ q.started_at AS startedAt, q.finished_at AS finishedAt,
1678
+ l.generation AS leaseGeneration, l.owner_id AS ownerId, l.expires_at AS claimExpiresAt
1679
+ FROM runs r JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
1680
+ JOIN run_queue q ON q.run_id = r.run_id
1681
+ LEFT JOIN run_bindings b ON b.run_id = r.run_id
1682
+ JOIN leases l ON l.resource_id = r.resource_id ${clause}`;
1528
1683
  }
1529
- function workflowTurnIntentFactsJson(facts) {
1530
- validateWorkflowTurnIntentFacts(facts);
1531
- const json = canonicalJson(facts, "workflow turn intent facts");
1532
- validateJsonSize(json, "Workflow turn intent facts", MAX_EVENT_BYTES);
1533
- return json;
1684
+ function turnIntentSelect(clause) {
1685
+ return `SELECT t.turn_intent_id AS intentId, t.source_event_id AS sourceEventId,
1686
+ t.run_id AS runId, t.workflow_ref AS workflowRef,
1687
+ t.target_session_id AS targetSessionId, t.cause,
1688
+ t.node_id AS nodeId, t.attempt_id AS attemptId, t.facts_hash AS factsHash,
1689
+ t.requested_at AS requestedAt, t.eligible_at AS eligibleAt,
1690
+ t.resolved_at AS resolvedAt, t.resolution_type AS resolution,
1691
+ t.resolution_message_id AS resolutionMessageId, l.expires_at AS claimExpiresAt,
1692
+ t.effect_id AS effectId
1693
+ FROM turn_intents t JOIN effects e ON e.effect_id = t.effect_id
1694
+ JOIN leases l ON l.resource_id = e.resource_id ${clause}`;
1534
1695
  }
1535
- function validateWorkflowTurnIntentFacts(facts) {
1536
- if (facts === null || typeof facts !== "object" || Array.isArray(facts)) {
1537
- throw new Error("Workflow turn intent facts must be an object");
1538
- }
1539
- const candidate = facts;
1540
- if (candidate.schema !== TURN_INTENT_FACTS_SCHEMA) {
1541
- throw new Error(`Workflow turn intent facts schema must be ${TURN_INTENT_FACTS_SCHEMA}`);
1542
- }
1543
- const allowedKeys = new Set([
1544
- "schema",
1545
- "workflowName",
1546
- "runId",
1547
- "observedState",
1548
- "cause",
1549
- "nodeId",
1550
- "attemptId",
1551
- "reason",
1552
- "handoff",
1553
- ]);
1554
- for (const key of Object.keys(candidate)) {
1555
- if (!allowedKeys.has(key))
1556
- throw new Error(`Unknown workflow turn intent facts field: ${key}`);
1557
- }
1558
- if (typeof candidate.workflowName !== "string") {
1559
- throw new Error("Workflow turn intent workflowName must be a string");
1560
- }
1561
- if (typeof candidate.runId !== "string") {
1562
- throw new Error("Workflow turn intent runId must be a string");
1563
- }
1564
- if (typeof candidate.observedState !== "string") {
1565
- throw new Error("Workflow turn intent observedState must be a string");
1566
- }
1567
- if (typeof candidate.cause !== "string") {
1568
- throw new Error("Workflow turn intent cause must be a string");
1569
- }
1570
- validateKey(candidate.workflowName, "workflow turn facts workflow name");
1571
- validateRunId(candidate.runId);
1572
- validateKey(candidate.observedState, "workflow turn facts observed state");
1573
- validateWorkflowTurnIntentCause(candidate.cause);
1574
- if (candidate.nodeId !== null && typeof candidate.nodeId !== "string") {
1575
- throw new Error("Workflow turn intent nodeId must be a string or null");
1576
- }
1577
- if (candidate.attemptId !== null && typeof candidate.attemptId !== "string") {
1578
- throw new Error("Workflow turn intent attemptId must be a string or null");
1579
- }
1580
- if (candidate.reason !== null && typeof candidate.reason !== "string") {
1581
- throw new Error("Workflow turn intent reason must be a string or null");
1582
- }
1583
- if (candidate.nodeId !== null)
1584
- validateKey(candidate.nodeId, "workflow turn facts node id");
1585
- if (candidate.attemptId !== null) {
1586
- validateKey(candidate.attemptId, "workflow turn facts attempt id");
1587
- }
1588
- if (candidate.reason !== null && candidate.reason.length > MAX_ERROR_LENGTH) {
1589
- throw new Error(`Workflow turn intent reason must be at most ${MAX_ERROR_LENGTH} characters`);
1590
- }
1591
- if (typeof candidate.handoff !== "boolean") {
1592
- throw new Error("Workflow turn intent handoff must be a boolean");
1593
- }
1696
+ function notificationSelect(clause) {
1697
+ return `SELECT n.notification_id AS notificationId, n.effect_id AS effectId,
1698
+ n.run_id AS runId, a.node_id AS nodeId, n.attempt_id AS attemptId,
1699
+ n.notification_index AS notificationIndex, n.target_session_id AS targetSessionId,
1700
+ n.notification_type AS kind, n.content_hash AS contentHash,
1701
+ n.created_at AS createdAt, l.expires_at AS claimExpiresAt,
1702
+ e.settled_at AS deliveredAt
1703
+ FROM notifications n JOIN effects e ON e.effect_id = n.effect_id
1704
+ JOIN leases l ON l.resource_id = e.resource_id
1705
+ JOIN node_attempts a ON a.attempt_id = n.attempt_id ${clause}`;
1594
1706
  }
1595
- function validateWorkflowTurnIntentCause(value) {
1596
- if (value !== "agentCancelled" &&
1597
- value !== "timedOut" &&
1598
- value !== "failed" &&
1599
- value !== "launchFailed" &&
1600
- value !== "controllerInterrupted" &&
1601
- value !== "claimLost") {
1602
- throw new Error(`Invalid workflow turn intent cause: ${value}`);
1603
- }
1604
- return value;
1707
+ function isRecord(value) {
1708
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1605
1709
  }
1606
- function validateWorkflowTurnIntentResolution(value) {
1607
- if (value !== "workflowPrompt" && value !== "presentation" && value !== "fallback") {
1608
- throw new Error(`Invalid workflow turn intent resolution: ${value}`);
1609
- }
1610
- return value;
1710
+ function isControllerRow(value) {
1711
+ return isRecord(value);
1611
1712
  }
1612
- function workflowRunFromRow(row) {
1613
- return {
1614
- runId: row.run_id,
1615
- workflowName: row.workflow_ref,
1616
- workflowSourceRef: row.workflow_path,
1617
- workflowSource: parseStoredJson(row.workflow_source_json, "workflow run source"),
1618
- definitionDigest: row.definition_digest,
1619
- input: parseStoredJson(row.input_json, "workflow run input"),
1620
- launchOptions: parseStoredJson(row.launch_options_json, "workflow launch options"),
1621
- status: row.status,
1622
- runnerId: row.runner_id,
1623
- claimToken: row.claim_token,
1624
- claimExpiresAt: row.claim_expires_at === null ? null : iso(row.claim_expires_at),
1625
- affinityRunnerId: row.affinity_runner_id,
1626
- originSessionId: row.origin_session_id,
1627
- parentRunId: row.parent_run_id,
1628
- errorCode: row.error_code,
1629
- errorMessage: row.error_message,
1630
- createdAt: row.created_at,
1631
- updatedAt: row.updated_at,
1632
- startedAt: row.started_at,
1633
- finishedAt: row.finished_at,
1634
- };
1713
+ function isLeaseRow(value) {
1714
+ return isRecord(value);
1635
1715
  }
1636
- function validateRunId(runId) {
1637
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
1638
- throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
1639
- }
1716
+ function isRunRow(value) {
1717
+ return isRecord(value);
1640
1718
  }
1641
- function eventFromRow(row) {
1642
- return {
1643
- seq: row.seq,
1644
- recordedAt: row.recorded_at,
1645
- controller: row.controller,
1646
- key: row.resource_key,
1647
- type: row.type,
1648
- payload: parseStoredJson(row.payload_json, "controller event payload"),
1649
- };
1719
+ function isEffectRow(value) {
1720
+ return isRecord(value);
1650
1721
  }
1651
- function validTimestamp(value) {
1652
- const timestamp = value ?? new Date().toISOString();
1653
- if (!Number.isFinite(Date.parse(timestamp))) {
1654
- throw new Error(`Invalid timestamp: ${JSON.stringify(timestamp)}`);
1655
- }
1656
- return timestamp;
1722
+ function isWorkflowRow(value) {
1723
+ return isRecord(value);
1657
1724
  }
1658
- function epoch(value) {
1659
- const parsed = Date.parse(value);
1660
- if (!Number.isSafeInteger(parsed)) {
1661
- throw new Error(`Timestamp is outside the supported range: ${value}`);
1662
- }
1663
- return parsed;
1725
+ function isQueueListRow(value) {
1726
+ return isRecord(value);
1664
1727
  }
1665
- function iso(value) {
1666
- return new Date(value).toISOString();
1728
+ function isControllerEventRow(value) {
1729
+ return isRecord(value);
1667
1730
  }
1668
- function validateDuration(value, name) {
1669
- if (!Number.isSafeInteger(value) || value <= 0) {
1670
- throw new Error(`${name} must be a positive safe integer`);
1671
- }
1731
+ function isRunEventRow(value) {
1732
+ return isRecord(value);
1672
1733
  }
1673
- function validateName(value, description) {
1674
- if (!/^[A-Za-z][A-Za-z0-9_.-]*$/.test(value)) {
1675
- throw new Error(`${description} contains unsupported characters: ${JSON.stringify(value)}`);
1676
- }
1734
+ function isTurnIntentRow(value) {
1735
+ return isRecord(value);
1677
1736
  }
1678
- function validateKey(value, description) {
1679
- const hasControl = [...value].some((character) => {
1680
- const code = character.codePointAt(0);
1681
- return code < 32 || code === 127;
1682
- });
1683
- if (value.length === 0 || value.length > MAX_KEY_LENGTH || hasControl) {
1684
- throw new Error(`${description} must be 1-${MAX_KEY_LENGTH} printable characters`);
1685
- }
1737
+ function isNotificationRow(value) {
1738
+ return isRecord(value);
1686
1739
  }
1687
- function validateFinalizers(finalizers) {
1688
- const seen = new Set();
1689
- for (const finalizer of finalizers) {
1690
- validateName(finalizer, "finalizer");
1691
- if (seen.has(finalizer)) {
1692
- throw new Error(`Duplicate finalizer: ${finalizer}`);
1693
- }
1694
- seen.add(finalizer);
1740
+ function isProjectRow(value) {
1741
+ return isRecord(value);
1742
+ }
1743
+ function isFinalizerRow(value) {
1744
+ return isRecord(value);
1745
+ }
1746
+ function isErrorCountRow(value) {
1747
+ return isRecord(value);
1748
+ }
1749
+ function isQueueVersionRow(value) {
1750
+ return isRecord(value);
1751
+ }
1752
+ function isRevisionRow(value) {
1753
+ return isRecord(value);
1754
+ }
1755
+ function isSequenceRow(value) {
1756
+ return isRecord(value);
1757
+ }
1758
+ function isKeyRow(value) {
1759
+ return isRecord(value);
1760
+ }
1761
+ function isResourceIdRow(value) {
1762
+ return isRecord(value);
1763
+ }
1764
+ function isEffectTokenRow(value) {
1765
+ return isRecord(value);
1766
+ }
1767
+ function isEffectIdentityRow(value) {
1768
+ return isRecord(value);
1769
+ }
1770
+ function effectIdFor(sourceResourceId, key) {
1771
+ return `effect-${createHash("sha256").update(`${sourceResourceId}\0${key}`).digest("hex").slice(0, 40)}`;
1772
+ }
1773
+ function projectIdFor(canonicalPath) {
1774
+ return `project-${createHash("sha256").update(canonicalPath).digest("hex").slice(0, 40)}`;
1775
+ }
1776
+ function canonicalProjectPath(value) {
1777
+ try {
1778
+ return fs.realpathSync.native(value);
1695
1779
  }
1780
+ catch {
1781
+ return path.resolve(value);
1782
+ }
1783
+ }
1784
+ function digestBuffer(value) {
1785
+ const hex = value.startsWith("sha256:") ? value.slice(7) : value;
1786
+ if (!/^[a-f0-9]{64}$/i.test(hex))
1787
+ throw new Error("Expected a SHA-256 digest");
1788
+ return Buffer.from(hex, "hex");
1696
1789
  }
1697
- function validateJsonSize(value, description, limit) {
1698
- if (Buffer.byteLength(value, "utf8") > limit) {
1699
- throw new Error(`${description} exceeds the ${limit}-byte storage limit`);
1790
+ function queuedWorkflowSource(value) {
1791
+ if (isRecord(value) && (value.kind === "builtin" || value.kind === "file")) {
1792
+ return { root: value, mounted: [] };
1700
1793
  }
1794
+ if (isRecord(value) && isRecord(value.root) && Array.isArray(value.mounted)) {
1795
+ return { root: value.root, mounted: value.mounted };
1796
+ }
1797
+ throw new Error("Stored workflow source identity is invalid");
1798
+ }
1799
+ function sourceParts(ref, revision) {
1800
+ return ref.startsWith("builtin:")
1801
+ ? { type: "builtin", ref: ref.slice(8), revision }
1802
+ : { type: "file", ref, revision };
1803
+ }
1804
+ function effectStatus(value) {
1805
+ return value === "indeterminate" ? "ambiguous" : value;
1806
+ }
1807
+ function controllerEffectState(value) {
1808
+ return value === "ambiguous" ? "indeterminate" : value;
1809
+ }
1810
+ function validTimestamp(value) {
1811
+ const date = value === undefined ? new Date() : new Date(value);
1812
+ if (!Number.isFinite(date.getTime()))
1813
+ throw new Error("Invalid timestamp");
1814
+ return date.toISOString();
1815
+ }
1816
+ function epoch(value) {
1817
+ return Date.parse(value);
1818
+ }
1819
+ function validateName(value, label) {
1820
+ validateKey(value, label);
1821
+ }
1822
+ function validateKey(value, label) {
1823
+ if (value.length === 0 || value.length > 512)
1824
+ throw new Error(`${label} is invalid`);
1701
1825
  }
1702
- function truncateError(error) {
1703
- return error.length <= MAX_ERROR_LENGTH ? error : `${error.slice(0, MAX_ERROR_LENGTH)}…`;
1826
+ function validateRunId(value) {
1827
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(value))
1828
+ throw new Error(`Invalid workflow run id: ${value}`);
1704
1829
  }
1705
1830
  //# sourceMappingURL=sqlite.js.map