@osolmaz/pi-workflows 0.12.1 → 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 (209) hide show
  1. package/README.md +52 -28
  2. package/dist/builtins/autodoc.workflow.d.ts +4 -4
  3. package/dist/builtins/autoimplement.workflow.d.ts +369 -73
  4. package/dist/builtins/autoplan.workflow.d.ts +126 -13
  5. package/dist/builtins/autoplan.workflow.js +200 -29
  6. package/dist/builtins/autoplan.workflow.js.map +1 -1
  7. package/dist/builtins/catalog.js +6 -4
  8. package/dist/builtins/catalog.js.map +1 -1
  9. package/dist/builtins/index.d.ts +2 -1
  10. package/dist/builtins/index.js +1 -0
  11. package/dist/builtins/index.js.map +1 -1
  12. package/dist/builtins/monitor.workflow.d.ts +56 -21
  13. package/dist/builtins/monitor.workflow.js +497 -216
  14. package/dist/builtins/monitor.workflow.js.map +1 -1
  15. package/dist/builtins/plain-summary.workflow.d.ts +32 -0
  16. package/dist/builtins/plain-summary.workflow.js +134 -0
  17. package/dist/builtins/plain-summary.workflow.js.map +1 -0
  18. package/dist/builtins/plan-change.workflow.d.ts +170 -22
  19. package/dist/builtins/sanity-check.workflow.d.ts +45 -3
  20. package/dist/builtins/sanity-check.workflow.js +45 -7
  21. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  22. package/dist/controllers/effects.d.ts +3 -2
  23. package/dist/controllers/effects.js +8 -1
  24. package/dist/controllers/effects.js.map +1 -1
  25. package/dist/controllers/index.d.ts +1 -1
  26. package/dist/controllers/index.js +1 -1
  27. package/dist/controllers/index.js.map +1 -1
  28. package/dist/controllers/manager.d.ts +1 -0
  29. package/dist/controllers/manager.js +41 -17
  30. package/dist/controllers/manager.js.map +1 -1
  31. package/dist/controllers/sqlite.d.ts +107 -85
  32. package/dist/controllers/sqlite.js +1611 -1486
  33. package/dist/controllers/sqlite.js.map +1 -1
  34. package/dist/controllers/store.d.ts +9 -8
  35. package/dist/controllers/store.js +1 -31
  36. package/dist/controllers/store.js.map +1 -1
  37. package/dist/controllers/types.d.ts +3 -0
  38. package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
  39. package/dist/controllers/workflow-engine-scheduler.js +3 -5
  40. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  41. package/dist/controllers/workflows.d.ts +2 -2
  42. package/dist/controllers/workflows.js +11 -10
  43. package/dist/controllers/workflows.js.map +1 -1
  44. package/dist/extension/controller-host.js +2 -2
  45. package/dist/extension/controller-host.js.map +1 -1
  46. package/dist/extension/decision-channels.d.ts +2 -0
  47. package/dist/extension/decision-channels.js +311 -118
  48. package/dist/extension/decision-channels.js.map +1 -1
  49. package/dist/extension/executor.d.ts +9 -2
  50. package/dist/extension/executor.js +89 -2
  51. package/dist/extension/executor.js.map +1 -1
  52. package/dist/extension/herdr-viewer.d.ts +0 -1
  53. package/dist/extension/herdr-viewer.js +0 -4
  54. package/dist/extension/herdr-viewer.js.map +1 -1
  55. package/dist/extension/index.js +156 -103
  56. package/dist/extension/index.js.map +1 -1
  57. package/dist/extension/recorder.d.ts +3 -10
  58. package/dist/extension/recorder.js +13 -22
  59. package/dist/extension/recorder.js.map +1 -1
  60. package/dist/extension/step-message.d.ts +7 -2
  61. package/dist/extension/step-message.js +94 -2
  62. package/dist/extension/step-message.js.map +1 -1
  63. package/dist/extension/widget.js +6 -0
  64. package/dist/extension/widget.js.map +1 -1
  65. package/dist/host/rpc-executor.d.ts +3 -0
  66. package/dist/host/rpc-executor.js +2 -0
  67. package/dist/host/rpc-executor.js.map +1 -1
  68. package/dist/host/runner.d.ts +3 -6
  69. package/dist/host/runner.js +83 -32
  70. package/dist/host/runner.js.map +1 -1
  71. package/dist/render/graph-render.js +14 -8
  72. package/dist/render/graph-render.js.map +1 -1
  73. package/dist/state/database.d.ts +35 -0
  74. package/dist/state/database.js +287 -0
  75. package/dist/state/database.js.map +1 -0
  76. package/dist/state/index.d.ts +4 -0
  77. package/dist/state/index.js +5 -0
  78. package/dist/state/index.js.map +1 -0
  79. package/dist/state/json.d.ts +6 -0
  80. package/dist/state/json.js +38 -0
  81. package/dist/state/json.js.map +1 -0
  82. package/dist/state/mutation.d.ts +57 -0
  83. package/dist/state/mutation.js +255 -0
  84. package/dist/state/mutation.js.map +1 -0
  85. package/dist/state/schema.d.ts +6 -0
  86. package/dist/state/schema.js +473 -0
  87. package/dist/state/schema.js.map +1 -0
  88. package/dist/viewer/cli.d.ts +2 -2
  89. package/dist/viewer/cli.js +142 -122
  90. package/dist/viewer/cli.js.map +1 -1
  91. package/dist/viewer/render.d.ts +4 -4
  92. package/dist/viewer/render.js +2 -17
  93. package/dist/viewer/render.js.map +1 -1
  94. package/dist/viewer/session-reducer.d.ts +1 -1
  95. package/dist/viewer/session-reducer.js +1 -1
  96. package/dist/viewer/tui.d.ts +3 -3
  97. package/dist/viewer/tui.js +15 -13
  98. package/dist/viewer/tui.js.map +1 -1
  99. package/dist/viewer/watch.d.ts +2 -5
  100. package/dist/viewer/watch.js +13 -13
  101. package/dist/viewer/watch.js.map +1 -1
  102. package/dist/workflows/composition.js +12 -2
  103. package/dist/workflows/composition.js.map +1 -1
  104. package/dist/workflows/definition.d.ts +6 -2
  105. package/dist/workflows/definition.js +17 -0
  106. package/dist/workflows/definition.js.map +1 -1
  107. package/dist/workflows/engine.d.ts +6 -6
  108. package/dist/workflows/engine.js +183 -103
  109. package/dist/workflows/engine.js.map +1 -1
  110. package/dist/workflows/errors.d.ts +3 -3
  111. package/dist/workflows/errors.js +3 -3
  112. package/dist/workflows/human-decision.d.ts +29 -4
  113. package/dist/workflows/human-decision.js +547 -283
  114. package/dist/workflows/human-decision.js.map +1 -1
  115. package/dist/workflows/index.d.ts +4 -5
  116. package/dist/workflows/index.js +3 -4
  117. package/dist/workflows/index.js.map +1 -1
  118. package/dist/workflows/schema.js +17 -1
  119. package/dist/workflows/schema.js.map +1 -1
  120. package/dist/workflows/store.d.ts +83 -103
  121. package/dist/workflows/store.js +902 -951
  122. package/dist/workflows/store.js.map +1 -1
  123. package/dist/workflows/types.d.ts +55 -81
  124. package/docs/CONTROLLERS.md +10 -8
  125. package/docs/DEFERRED_TURNS.md +2 -2
  126. package/docs/DESIGN_PHILOSOPHY.md +4 -2
  127. package/docs/HUMAN_DECISIONS.md +13 -14
  128. package/docs/MONITOR.md +179 -231
  129. package/docs/SQLITE_STATE.md +219 -0
  130. package/docs/WORKFLOW_COMPOSITION.md +19 -19
  131. package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
  132. package/docs/WORKFLOW_UPDATES.md +10 -9
  133. package/docs/development.md +9 -9
  134. package/docs/live-replay-protocol.md +28 -37
  135. package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
  136. package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
  137. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
  138. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +1 -1
  139. package/docs/plans/2026-08-21-sanity-check-plan.md +30 -16
  140. package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
  141. package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
  142. package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
  143. package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
  144. package/docs/plans/session-event-replay-implementation-plan.md +1 -1
  145. package/docs/plans/tui-viewer-implementation-plan.md +1 -1
  146. package/docs/session-event-journal.md +63 -434
  147. package/docs/tui-viewer.md +13 -15
  148. package/docs/workflows.md +104 -50
  149. package/examples/workflows/plain-summary.workflow.ts +1 -0
  150. package/herdr-plugin.toml +1 -1
  151. package/package.json +1 -1
  152. package/plugins/herdr/viewer.mjs +1 -11
  153. package/skills/autoplan/SKILL.md +5 -4
  154. package/skills/monitor/SKILL.md +91 -163
  155. package/skills/pi-workflows/SKILL.md +7 -7
  156. package/skills/sanity-check/SKILL.md +1 -1
  157. package/src/builtins/autoplan.workflow.ts +290 -40
  158. package/src/builtins/catalog.ts +6 -4
  159. package/src/builtins/index.ts +7 -1
  160. package/src/builtins/monitor.workflow.ts +663 -249
  161. package/src/builtins/plain-summary.workflow.ts +185 -0
  162. package/src/builtins/sanity-check.workflow.ts +62 -7
  163. package/src/controllers/effects.ts +7 -0
  164. package/src/controllers/index.ts +0 -6
  165. package/src/controllers/manager.ts +72 -32
  166. package/src/controllers/sqlite.ts +2420 -1948
  167. package/src/controllers/store.ts +17 -38
  168. package/src/controllers/types.ts +3 -0
  169. package/src/controllers/workflow-engine-scheduler.ts +4 -8
  170. package/src/controllers/workflows.ts +45 -21
  171. package/src/extension/controller-host.ts +1 -2
  172. package/src/extension/decision-channels.ts +427 -144
  173. package/src/extension/executor.ts +105 -2
  174. package/src/extension/herdr-viewer.ts +0 -5
  175. package/src/extension/index.ts +180 -118
  176. package/src/extension/recorder.ts +13 -22
  177. package/src/extension/step-message.ts +106 -4
  178. package/src/extension/widget.ts +8 -0
  179. package/src/host/rpc-executor.ts +4 -0
  180. package/src/host/runner.ts +93 -41
  181. package/src/render/graph-render.ts +11 -4
  182. package/src/state/database.ts +375 -0
  183. package/src/state/index.ts +29 -0
  184. package/src/state/json.ts +42 -0
  185. package/src/state/mutation.ts +435 -0
  186. package/src/state/schema.ts +475 -0
  187. package/src/viewer/cli.ts +151 -144
  188. package/src/viewer/render.ts +12 -24
  189. package/src/viewer/session-reducer.ts +1 -1
  190. package/src/viewer/tui.ts +18 -17
  191. package/src/viewer/watch.ts +13 -17
  192. package/src/workflows/composition.ts +12 -3
  193. package/src/workflows/definition.ts +36 -3
  194. package/src/workflows/engine.ts +212 -116
  195. package/src/workflows/errors.ts +3 -3
  196. package/src/workflows/human-decision.ts +795 -340
  197. package/src/workflows/index.ts +15 -19
  198. package/src/workflows/schema.ts +19 -1
  199. package/src/workflows/store.ts +1295 -1163
  200. package/src/workflows/types.ts +61 -81
  201. package/dist/workflows/artifacts.d.ts +0 -40
  202. package/dist/workflows/artifacts.js +0 -155
  203. package/dist/workflows/artifacts.js.map +0 -1
  204. package/dist/workflows/migrate-sources.d.ts +0 -42
  205. package/dist/workflows/migrate-sources.js +0 -133
  206. package/dist/workflows/migrate-sources.js.map +0 -1
  207. package/docs/run-bundles.md +0 -481
  208. package/src/workflows/artifacts.ts +0 -188
  209. package/src/workflows/migrate-sources.ts +0 -178
@@ -1,23 +1,21 @@
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 {
6
7
  EffectRequestConflictError,
7
8
  ResourceConflictError,
8
9
  ResourceNotFoundError,
9
10
  WorkflowRequestConflictError,
10
11
  } from "./errors.js";
11
- import { canonicalJson, parseStoredJson } from "./json.js";
12
- import {
13
- CONTROLLER_STORE_SCHEMA,
14
- type ControllerStore,
15
- type EffectReservation,
16
- type QueueItem,
17
- type QueueRequeueOptions,
18
- type WorkflowRecordUpdate,
19
- type WorkflowReservation,
20
- controllerStorePath,
12
+ import type {
13
+ ControllerStore,
14
+ EffectReservation,
15
+ QueueItem,
16
+ QueueRequeueOptions,
17
+ WorkflowRecordUpdate,
18
+ WorkflowReservation,
21
19
  } from "./store.js";
22
20
  import type {
23
21
  ChildWorkflowRecord,
@@ -30,60 +28,8 @@ import type {
30
28
  JsonObject,
31
29
  } from "./types.js";
32
30
 
33
- const MAX_KEY_LENGTH = 512;
34
- const MAX_ERROR_LENGTH = 8_192;
35
- const MAX_EVENT_BYTES = 64 * 1024;
36
- const MAX_RESOURCE_VALUE_BYTES = 1024 * 1024;
37
31
  const TURN_INTENT_FACTS_SCHEMA = "pi-workflows.deferred-turn-facts.v1";
38
32
 
39
- type ResourceRow = {
40
- controller: string;
41
- resource_key: string;
42
- uid: string;
43
- resource_version: number;
44
- generation: number;
45
- spec_json: string;
46
- status_json: string;
47
- deletion_timestamp: string | null;
48
- finalizers_json: string;
49
- };
50
-
51
- type QueueRow = {
52
- controller: string;
53
- resource_key: string;
54
- available_at: number;
55
- version: number;
56
- consecutive_errors: number;
57
- claim_token: string | null;
58
- claim_version: number | null;
59
- claim_expires_at: number | null;
60
- };
61
-
62
- type EffectRow = {
63
- effect_key: string;
64
- resource_uid: string;
65
- generation: number;
66
- kind: string;
67
- state: EffectRecord["state"];
68
- request_fingerprint: string;
69
- started_at: string;
70
- completed_at: string | null;
71
- external_ref: string | null;
72
- error: string | null;
73
- };
74
-
75
- type WorkflowRow = {
76
- request_id: string;
77
- resource_uid: string;
78
- request_key: string;
79
- input_fingerprint: string;
80
- workflow: string;
81
- run_id: string | null;
82
- state: ChildWorkflowRecord["state"];
83
- attempt: number;
84
- error: string | null;
85
- };
86
-
87
33
  export type WorkflowRunLaunchStatus =
88
34
  | "queued"
89
35
  | "starting"
@@ -93,46 +39,21 @@ export type WorkflowRunLaunchStatus =
93
39
  | "failed"
94
40
  | "cancelled";
95
41
 
96
- type WorkflowRunQueueRow = {
97
- run_id: string;
98
- workflow_ref: string;
99
- workflow_path: string;
100
- workflow_source_json: string;
101
- definition_digest: string;
102
- input_json: string;
103
- launch_options_json: string;
104
- status: WorkflowRunLaunchStatus;
105
- runner_id: string | null;
106
- claim_token: string | null;
107
- claim_expires_at: number | null;
108
- affinity_runner_id: string | null;
109
- origin_session_id: string | null;
110
- parent_run_id: string | null;
111
- error_code: string | null;
112
- error_message: string | null;
113
- created_at: string;
114
- updated_at: string;
115
- started_at: string | null;
116
- finished_at: string | null;
117
- };
118
-
119
- /** A user-started workflow run tracked by the durable run queue. */
120
42
  export type WorkflowRunQueueRecord = {
121
43
  runId: string;
122
- /** Human-readable workflow name used in status and event output. */
123
44
  workflowName: string;
124
- /** Canonical source reference used to reopen the run. */
125
45
  workflowSourceRef: string;
126
46
  workflowSource: unknown;
47
+ initialized: boolean;
127
48
  definitionDigest: string;
128
49
  input: unknown;
129
50
  launchOptions: unknown;
130
51
  status: WorkflowRunLaunchStatus;
131
52
  runnerId: string | null;
132
53
  claimToken: string | null;
54
+ claimGeneration: number | null;
133
55
  claimExpiresAt: string | null;
134
56
  affinityRunnerId: string | null;
135
- /** Pi session that owns delivery and interactive execution, or null for detached runs. */
136
57
  originSessionId: string | null;
137
58
  parentRunId: string | null;
138
59
  errorCode: string | null;
@@ -143,21 +64,6 @@ export type WorkflowRunQueueRecord = {
143
64
  finishedAt: string | null;
144
65
  };
145
66
 
146
- type WorkflowNotificationRow = {
147
- notification_id: string;
148
- run_id: string;
149
- node_id: string;
150
- attempt_id: string;
151
- notification_index: number;
152
- target_session_id: string;
153
- kind: string;
154
- content: string;
155
- created_at: string;
156
- delivery_claim_token: string | null;
157
- delivery_claim_expires_at: number | null;
158
- delivered_at: string | null;
159
- };
160
-
161
67
  export type WorkflowNotificationRecord = {
162
68
  notificationId: string;
163
69
  runId: string;
@@ -194,25 +100,6 @@ export type WorkflowTurnIntentFacts = JsonObject & {
194
100
  handoff: boolean;
195
101
  };
196
102
 
197
- type WorkflowTurnIntentRow = {
198
- intent_id: string;
199
- source_event_id: string;
200
- run_id: string;
201
- workflow_ref: string;
202
- target_session_id: string;
203
- cause: string;
204
- node_id: string | null;
205
- attempt_id: string | null;
206
- fallback_facts_json: string;
207
- requested_at: string;
208
- eligible_at: string | null;
209
- resolved_at: string | null;
210
- resolution: string | null;
211
- resolution_message_id: string | null;
212
- delivery_claim_token: string | null;
213
- delivery_claim_expires_at: number | null;
214
- };
215
-
216
103
  export type WorkflowTurnIntentRecord = {
217
104
  intentId: string;
218
105
  sourceEventId: string;
@@ -231,17 +118,6 @@ export type WorkflowTurnIntentRecord = {
231
118
  deliveryClaimExpiresAt: string | null;
232
119
  };
233
120
 
234
- type RunEventRow = {
235
- seq: number;
236
- recorded_at: string;
237
- run_id: string;
238
- workflow_ref: string;
239
- type: string;
240
- runner_id: string | null;
241
- payload_json: string;
242
- };
243
-
244
- /** One run lifecycle transition in the cross-session event feed. */
245
121
  export type RunEventRecord = {
246
122
  seq: number;
247
123
  recordedAt: string;
@@ -252,44 +128,109 @@ export type RunEventRecord = {
252
128
  payload: JsonObject;
253
129
  };
254
130
 
255
- type EventRow = {
256
- seq: number;
257
- recorded_at: string;
258
- controller: string;
259
- resource_key: string;
260
- type: string;
261
- payload_json: string;
131
+ type ControllerRow = {
132
+ controllerResourceId: string;
133
+ resourceId: string;
134
+ resourceVersion: number;
135
+ controllerName: string;
136
+ resourceKey: string;
137
+ uid: string;
138
+ generation: number;
139
+ specHash: Buffer;
140
+ statusHash: Buffer;
141
+ deletionRequestedAt: number | null;
142
+ };
143
+
144
+ type LeaseRow = {
145
+ generation: number;
146
+ ownerType: string | null;
147
+ ownerId: string | null;
148
+ tokenHash: Buffer | null;
149
+ expiresAt: number | null;
150
+ };
151
+
152
+ type RunRow = {
153
+ runId: string;
154
+ resourceId: string;
155
+ workflowName: string;
156
+ workflowRef: string;
157
+ runStatus: string;
158
+ workflowSourceHash: Buffer;
159
+ definitionDigest: Buffer;
160
+ inputHash: Buffer;
161
+ outputHash: Buffer | null;
162
+ launchOptionsHash: Buffer;
163
+ status: WorkflowRunLaunchStatus;
164
+ availableAt: number;
165
+ affinityRunnerId: string | null;
166
+ consecutiveErrors: number;
167
+ errorCode: string | null;
168
+ errorHash: Buffer | null;
169
+ originSessionId: string | null;
170
+ parentRunId: string | null;
171
+ createdAt: number;
172
+ updatedAt: number;
173
+ startedAt: number | null;
174
+ finishedAt: number | null;
175
+ leaseGeneration: number;
176
+ ownerId: string | null;
177
+ claimExpiresAt: number | null;
178
+ };
179
+
180
+ type EffectRow = {
181
+ effectId: string;
182
+ resourceUid: string;
183
+ generation: number;
184
+ kind: string;
185
+ state: string;
186
+ requestFingerprint: string;
187
+ startedAt: number;
188
+ completedAt: number | null;
189
+ externalRef: string | null;
190
+ errorHash: Buffer | null;
191
+ };
192
+
193
+ type WorkflowRow = {
194
+ requestId: string;
195
+ resourceUid: string;
196
+ requestKey: string;
197
+ inputFingerprint: Buffer;
198
+ workflowName: string;
199
+ runId: string | null;
200
+ status: ChildWorkflowRecord["state"];
201
+ attemptCount: number;
202
+ errorHash: Buffer | null;
262
203
  };
263
204
 
264
205
  export class SqliteControllerStore implements ControllerStore {
265
206
  readonly filePath: string;
266
- private readonly database: Database.Database;
207
+ readonly state: StateDatabase;
208
+ private readonly ownsState: boolean;
209
+ private readonly projectId: string | null;
267
210
  private closed = false;
268
211
 
269
- constructor(filePath: string = controllerStorePath(), options: { readOnly?: boolean } = {}) {
270
- this.filePath = path.resolve(filePath);
271
- const readOnly = options.readOnly ?? false;
272
- if (!readOnly) {
273
- fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
274
- fs.chmodSync(path.dirname(this.filePath), 0o700);
275
- }
276
- this.database = new Database(this.filePath, {
277
- readonly: readOnly,
278
- fileMustExist: readOnly,
279
- });
280
- if (!readOnly) {
281
- fs.chmodSync(this.filePath, 0o600);
282
- }
283
- this.configure(readOnly);
284
- this.initializeSchema(readOnly);
212
+ constructor(
213
+ filePath: string = workflowStatePath(),
214
+ options: { readOnly?: boolean; projectPath?: string; state?: StateDatabase } = {},
215
+ ) {
216
+ this.ownsState = options.state === undefined;
217
+ this.state =
218
+ options.state ??
219
+ new StateDatabase({
220
+ filePath,
221
+ mode: options.readOnly === true ? "read-only" : "read-write",
222
+ checkLegacyState: filePath === workflowStatePath(),
223
+ });
224
+ this.filePath = this.state.filePath;
225
+ const projectPath = options.projectPath === undefined ? process.cwd() : options.projectPath;
226
+ this.projectId =
227
+ options.readOnly === true ? this.findProject(projectPath) : this.ensureProject(projectPath);
285
228
  }
286
229
 
287
230
  close(): void {
288
- if (this.closed) {
289
- return;
290
- }
231
+ if (this.closed) return;
291
232
  this.closed = true;
292
- this.database.close();
233
+ if (this.ownsState) this.state.close();
293
234
  }
294
235
 
295
236
  putResource<TSpec, TStatus>(options: {
@@ -299,144 +240,193 @@ export class SqliteControllerStore implements ControllerStore {
299
240
  initialStatus: TStatus;
300
241
  now?: string;
301
242
  }): ControllerResource<TSpec, TStatus> {
243
+ this.assertWritable();
302
244
  validateName(options.controller, "controller");
303
245
  validateKey(options.key, "resource key");
304
- const now = validTimestamp(options.now);
305
- const specJson = canonicalJson(options.spec, "controller resource spec");
306
- validateJsonSize(specJson, "Controller resource spec", MAX_RESOURCE_VALUE_BYTES);
246
+ const now = epoch(validTimestamp(options.now));
247
+ const specHash = this.state.putJson(options.spec, now);
307
248
  const initialStatus: ControllerResourceStatus<TStatus> = {
308
249
  observedGeneration: 0,
309
250
  conditions: [],
310
251
  controllerStatus: options.initialStatus,
311
252
  };
312
- const statusJson = canonicalJson(initialStatus, "controller resource status");
313
- validateJsonSize(statusJson, "Controller resource status", MAX_RESOURCE_VALUE_BYTES);
314
-
315
- return this.transaction(() => {
316
- const existing = this.resourceRow({ controller: options.controller, key: options.key });
253
+ const statusHash = this.state.putJson(initialStatus, now);
254
+ return this.state.transaction(() => {
255
+ const existing = this.controllerRow({ controller: options.controller, key: options.key });
317
256
  if (existing === undefined) {
318
257
  const uid = randomUUID();
319
- this.database
258
+ const controllerResourceId = `controller-resource-${randomUUID()}`;
259
+ const resourceId = resourceIdFor(
260
+ "controller",
261
+ `${this.requireProjectId()}:${options.controller}:${options.key}`,
262
+ );
263
+ this.state.connection
264
+ .prepare(
265
+ `INSERT INTO resources(
266
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
267
+ ) VALUES (?, 'controller', ?, 1, ?, ?)`,
268
+ )
269
+ .run(resourceId, controllerResourceId, now, now);
270
+ this.state.connection
271
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
272
+ .run(resourceId);
273
+ this.state.connection
320
274
  .prepare(
321
- `INSERT INTO resources (
322
- controller, resource_key, uid, resource_version, generation,
323
- spec_json, status_json, deletion_timestamp, finalizers_json, updated_at
324
- ) VALUES (?, ?, ?, 1, 1, ?, ?, NULL, '[]', ?)`,
275
+ `INSERT INTO controller_resources(
276
+ controller_resource_id, resource_id, project_id, controller_name,
277
+ resource_key, uid, generation, spec_hash, status_hash, created_at, updated_at
278
+ ) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)`,
325
279
  )
326
- .run(options.controller, options.key, uid, specJson, statusJson, now);
327
- this.enqueueRow({ controller: options.controller, key: options.key }, epoch(now));
328
- } else if (existing.spec_json !== specJson) {
329
- this.database
280
+ .run(
281
+ controllerResourceId,
282
+ resourceId,
283
+ this.requireProjectId(),
284
+ options.controller,
285
+ options.key,
286
+ uid,
287
+ specHash,
288
+ statusHash,
289
+ now,
290
+ now,
291
+ );
292
+ this.enqueueControllerRow(controllerResourceId, now, now);
293
+ this.insertEvent(
294
+ resourceId,
295
+ 1,
296
+ "resource.created",
297
+ "control",
298
+ null,
299
+ { generation: 1 },
300
+ now,
301
+ );
302
+ } else if (!existing.specHash.equals(specHash)) {
303
+ const revision = existing.resourceVersion + 1;
304
+ this.state.connection
330
305
  .prepare(
331
- `UPDATE resources
332
- SET spec_json = ?, resource_version = resource_version + 1,
333
- generation = generation + 1, updated_at = ?
334
- WHERE controller = ? AND resource_key = ?`,
306
+ `UPDATE controller_resources
307
+ SET spec_hash = ?, generation = generation + 1, updated_at = ?
308
+ WHERE controller_resource_id = ?`,
335
309
  )
336
- .run(specJson, now, options.controller, options.key);
337
- this.enqueueRow({ controller: options.controller, key: options.key }, epoch(now));
310
+ .run(specHash, now, existing.controllerResourceId);
311
+ this.bumpResource(existing.resourceId, existing.resourceVersion, now);
312
+ this.enqueueControllerRow(existing.controllerResourceId, now, now);
313
+ this.insertEvent(
314
+ existing.resourceId,
315
+ revision,
316
+ "resource.spec_updated",
317
+ "control",
318
+ null,
319
+ { generation: existing.generation + 1 },
320
+ now,
321
+ );
338
322
  }
339
- return this.requireResource<TSpec, TStatus>({
323
+ return this.requireResource({
340
324
  controller: options.controller,
341
325
  key: options.key,
342
- });
326
+ }) as ControllerResource<TSpec, TStatus>;
343
327
  });
344
328
  }
345
329
 
346
330
  getResource<TSpec = unknown, TStatus = unknown>(
347
331
  ref: ControllerResourceRef,
348
332
  ): ControllerResource<TSpec, TStatus> | undefined {
349
- const row = this.resourceRow(ref);
350
- return row === undefined ? undefined : resourceFromRow<TSpec, TStatus>(row);
333
+ const row = this.controllerRow(ref);
334
+ return row === undefined
335
+ ? undefined
336
+ : (this.mapControllerResource(row) as ControllerResource<TSpec, TStatus>);
351
337
  }
352
338
 
353
339
  getResourceByUid(uid: string): ControllerResource | undefined {
354
- const row = this.database.prepare("SELECT * FROM resources WHERE uid = ?").get(uid) as
355
- | ResourceRow
356
- | undefined;
357
- return row === undefined ? undefined : resourceFromRow(row);
340
+ const row = this.state.connection.prepare(controllerSelect("WHERE c.uid = ?")).get(uid);
341
+ return isControllerRow(row) ? this.mapControllerResource(row) : undefined;
358
342
  }
359
343
 
360
344
  listResources<TSpec = unknown, TStatus = unknown>(
361
- options: {
362
- controller?: string;
363
- } = {},
345
+ options: { controller?: string } = {},
364
346
  ): ControllerResource<TSpec, TStatus>[] {
365
- const rows =
366
- options.controller === undefined
367
- ? (this.database
368
- .prepare("SELECT * FROM resources ORDER BY controller, resource_key")
369
- .all() as ResourceRow[])
370
- : (this.database
371
- .prepare("SELECT * FROM resources WHERE controller = ? ORDER BY resource_key")
372
- .all(options.controller) as ResourceRow[]);
373
- return rows.map((row) => resourceFromRow<TSpec, TStatus>(row));
347
+ const clauses = ["1 = 1"];
348
+ const params: unknown[] = [];
349
+ if (this.projectId !== null) {
350
+ clauses.push("c.project_id = ?");
351
+ params.push(this.projectId);
352
+ }
353
+ if (options.controller !== undefined) {
354
+ clauses.push("c.controller_name = ?");
355
+ params.push(options.controller);
356
+ }
357
+ const rows = this.state.connection
358
+ .prepare(
359
+ controllerSelect(
360
+ `WHERE ${clauses.join(" AND ")} ORDER BY c.controller_name, c.resource_key`,
361
+ ),
362
+ )
363
+ .all(...params);
364
+ return rows
365
+ .filter(isControllerRow)
366
+ .map((row) => this.mapControllerResource(row) as ControllerResource<TSpec, TStatus>);
374
367
  }
375
368
 
376
369
  updateStatus<TStatus>(options: {
377
370
  ref: ControllerResourceRef;
378
371
  expectedResourceVersion: number;
372
+ claim: ControllerQueueClaim;
379
373
  status: ControllerResourceStatus<TStatus>;
380
374
  finalizers?: string[];
381
375
  now?: string;
382
376
  }): ControllerResource<unknown, TStatus> {
383
- const now = validTimestamp(options.now);
384
- const statusJson = canonicalJson(options.status, "controller resource status");
385
- validateJsonSize(statusJson, "Controller resource status", MAX_RESOURCE_VALUE_BYTES);
386
- if (options.finalizers !== undefined) {
387
- validateFinalizers(options.finalizers);
388
- }
389
- return this.transaction(() => {
390
- const current = this.resourceRow(options.ref);
391
- if (current === undefined) {
392
- throw new ResourceNotFoundError(options.ref.controller, options.ref.key);
393
- }
394
- if (current.resource_version !== options.expectedResourceVersion) {
377
+ const now = epoch(validTimestamp(options.now));
378
+ return this.state.transaction(() => {
379
+ const row = this.requireControllerRow(options.ref);
380
+ this.assertControllerClaim(row, options.claim, now);
381
+ if (row.resourceVersion !== options.expectedResourceVersion) {
395
382
  throw new ResourceConflictError(options.ref.controller, options.ref.key);
396
383
  }
397
- const finalizersJson =
398
- options.finalizers === undefined
399
- ? current.finalizers_json
400
- : canonicalJson(options.finalizers);
401
- if (current.status_json === statusJson && current.finalizers_json === finalizersJson) {
402
- return resourceFromRow<unknown, TStatus>(current);
403
- }
404
- this.database
384
+ const statusHash = this.state.putJson(options.status, now);
385
+ this.state.connection
405
386
  .prepare(
406
- `UPDATE resources
407
- SET status_json = ?, finalizers_json = ?,
408
- resource_version = resource_version + 1, updated_at = ?
409
- WHERE controller = ? AND resource_key = ? AND resource_version = ?`,
387
+ "UPDATE controller_resources SET status_hash = ?, updated_at = ? WHERE controller_resource_id = ?",
410
388
  )
411
- .run(
412
- statusJson,
413
- finalizersJson,
414
- now,
415
- options.ref.controller,
416
- options.ref.key,
417
- options.expectedResourceVersion,
418
- );
419
- return this.requireResource<unknown, TStatus>(options.ref);
389
+ .run(statusHash, now, row.controllerResourceId);
390
+ if (options.finalizers !== undefined)
391
+ this.replaceFinalizers(row.controllerResourceId, options.finalizers);
392
+ this.bumpResource(row.resourceId, row.resourceVersion, now);
393
+ this.insertEvent(
394
+ row.resourceId,
395
+ row.resourceVersion + 1,
396
+ "resource.status_updated",
397
+ "controller",
398
+ options.claim.ownerId,
399
+ {},
400
+ now,
401
+ options.claim.generation,
402
+ );
403
+ options.claim.resourceVersion = row.resourceVersion + 1;
404
+ return this.requireResource(options.ref) as ControllerResource<unknown, TStatus>;
420
405
  });
421
406
  }
422
407
 
423
- requestDeletion(ref: ControllerResourceRef, now?: string): ControllerResource {
424
- const timestamp = validTimestamp(now);
425
- return this.transaction(() => {
426
- const row = this.resourceRow(ref);
427
- if (row === undefined) {
428
- throw new ResourceNotFoundError(ref.controller, ref.key);
429
- }
430
- if (row.deletion_timestamp === null) {
431
- this.database
408
+ requestDeletion(ref: ControllerResourceRef, nowValue?: string): ControllerResource {
409
+ const now = epoch(validTimestamp(nowValue));
410
+ return this.state.transaction(() => {
411
+ const row = this.requireControllerRow(ref);
412
+ if (row.deletionRequestedAt === null) {
413
+ this.state.connection
432
414
  .prepare(
433
- `UPDATE resources
434
- SET deletion_timestamp = ?, resource_version = resource_version + 1, updated_at = ?
435
- WHERE controller = ? AND resource_key = ?`,
415
+ "UPDATE controller_resources SET deletion_requested_at = ?, updated_at = ? WHERE controller_resource_id = ?",
436
416
  )
437
- .run(timestamp, timestamp, ref.controller, ref.key);
417
+ .run(now, now, row.controllerResourceId);
418
+ this.bumpResource(row.resourceId, row.resourceVersion, now);
419
+ this.enqueueControllerRow(row.controllerResourceId, now, now);
420
+ this.insertEvent(
421
+ row.resourceId,
422
+ row.resourceVersion + 1,
423
+ "resource.deletion_requested",
424
+ "control",
425
+ null,
426
+ {},
427
+ now,
428
+ );
438
429
  }
439
- this.enqueueRow(ref, epoch(timestamp));
440
430
  return this.requireResource(ref);
441
431
  });
442
432
  }
@@ -447,223 +437,265 @@ export class SqliteControllerStore implements ControllerStore {
447
437
  finalizers: string[];
448
438
  now?: string;
449
439
  }): ControllerResource {
450
- validateFinalizers(options.finalizers);
451
- const now = validTimestamp(options.now);
452
- const result = this.database
453
- .prepare(
454
- `UPDATE resources
455
- SET finalizers_json = ?, resource_version = resource_version + 1, updated_at = ?
456
- WHERE controller = ? AND resource_key = ? AND resource_version = ?`,
457
- )
458
- .run(
459
- canonicalJson(options.finalizers),
440
+ const now = epoch(validTimestamp(options.now));
441
+ return this.state.transaction(() => {
442
+ const row = this.requireControllerRow(options.ref);
443
+ if (row.resourceVersion !== options.expectedResourceVersion) {
444
+ throw new ResourceConflictError(options.ref.controller, options.ref.key);
445
+ }
446
+ this.replaceFinalizers(row.controllerResourceId, options.finalizers);
447
+ this.state.connection
448
+ .prepare("UPDATE controller_resources SET updated_at = ? WHERE controller_resource_id = ?")
449
+ .run(now, row.controllerResourceId);
450
+ this.bumpResource(row.resourceId, row.resourceVersion, now);
451
+ this.insertEvent(
452
+ row.resourceId,
453
+ row.resourceVersion + 1,
454
+ "resource.finalizers_updated",
455
+ "control",
456
+ null,
457
+ {},
460
458
  now,
461
- options.ref.controller,
462
- options.ref.key,
463
- options.expectedResourceVersion,
464
459
  );
465
- if (result.changes !== 1) {
466
- this.throwMissingOrConflict(options.ref);
467
- }
468
- return this.requireResource(options.ref);
460
+ return this.requireResource(options.ref);
461
+ });
469
462
  }
470
463
 
471
- deleteResource(ref: ControllerResourceRef, expectedResourceVersion: number): boolean {
472
- const row = this.resourceRow(ref);
473
- if (row === undefined) {
474
- return false;
475
- }
476
- const finalizers = parseStoredJson<string[]>(row.finalizers_json, "resource finalizers");
477
- if (row.deletion_timestamp === null || finalizers.length > 0) {
478
- throw new Error(`Controller resource ${ref.controller}/${ref.key} is not ready for deletion`);
479
- }
480
- const result = this.database
481
- .prepare(
482
- `DELETE FROM resources
483
- WHERE controller = ? AND resource_key = ? AND resource_version = ?`,
484
- )
485
- .run(ref.controller, ref.key, expectedResourceVersion);
486
- if (result.changes !== 1) {
487
- throw new ResourceConflictError(ref.controller, ref.key);
488
- }
489
- return true;
464
+ deleteResource(
465
+ ref: ControllerResourceRef,
466
+ expectedResourceVersion: number,
467
+ claim: ControllerQueueClaim,
468
+ ): boolean {
469
+ return this.state.transaction(() => {
470
+ const row = this.requireControllerRow(ref);
471
+ this.assertControllerClaim(row, claim, Date.now());
472
+ if (row.resourceVersion !== expectedResourceVersion) {
473
+ throw new ResourceConflictError(ref.controller, ref.key);
474
+ }
475
+ if (row.deletionRequestedAt === null || this.finalizers(row.controllerResourceId).length > 0)
476
+ return false;
477
+ this.state.connection
478
+ .prepare("DELETE FROM controller_resources WHERE controller_resource_id = ?")
479
+ .run(row.controllerResourceId);
480
+ return true;
481
+ });
490
482
  }
491
483
 
492
484
  enqueue(ref: ControllerResourceRef, availableAt?: string): void {
493
- const at = validTimestamp(availableAt);
494
- if (this.resourceRow(ref) === undefined) {
495
- throw new ResourceNotFoundError(ref.controller, ref.key);
496
- }
497
- this.enqueueRow(ref, epoch(at));
485
+ const row = this.requireControllerRow(ref);
486
+ const now = Date.now();
487
+ this.enqueueControllerRow(row.controllerResourceId, epoch(validTimestamp(availableAt)), now);
498
488
  }
499
489
 
500
490
  claimNext(options: {
501
491
  controllers: string[];
492
+ ownerId: string;
502
493
  leaseMs: number;
503
494
  now?: string;
504
495
  }): ControllerQueueClaim | undefined {
505
- validateDuration(options.leaseMs, "leaseMs");
506
- if (options.controllers.length === 0) {
507
- return undefined;
508
- }
509
- const now = validTimestamp(options.now);
510
- const nowMs = epoch(now);
496
+ if (options.controllers.length === 0) return undefined;
497
+ const now = epoch(validTimestamp(options.now));
511
498
  const placeholders = options.controllers.map(() => "?").join(", ");
512
- return this.transaction(() => {
513
- const row = this.database
499
+ return this.state.transaction(() => {
500
+ const row = this.state.connection
514
501
  .prepare(
515
- `SELECT * FROM queue
516
- WHERE controller IN (${placeholders})
517
- AND available_at <= ?
518
- AND (claim_token IS NULL OR claim_expires_at <= ?)
519
- ORDER BY available_at, controller, resource_key
520
- LIMIT 1`,
502
+ `${controllerSelect(`
503
+ JOIN controller_queue q ON q.controller_resource_id = c.controller_resource_id
504
+ JOIN leases l ON l.resource_id = c.resource_id
505
+ WHERE c.project_id = ?
506
+ AND c.controller_name IN (${placeholders})
507
+ AND q.available_at <= ?
508
+ AND (l.owner_id IS NULL OR l.expires_at <= ?)
509
+ ORDER BY q.available_at, c.controller_name, c.resource_key
510
+ LIMIT 1`)}`,
521
511
  )
522
- .get(...options.controllers, nowMs, nowMs) as QueueRow | undefined;
523
- if (row === undefined) {
524
- return undefined;
525
- }
526
- const token = randomUUID();
527
- const expiresAtMs = nowMs + options.leaseMs;
528
- const result = this.database
512
+ .get(this.requireProjectId(), ...options.controllers, now, now);
513
+ if (!isControllerRow(row)) return undefined;
514
+ const lease = this.requireLease(row.resourceId);
515
+ const token = randomBytes(32).toString("base64url");
516
+ const generation = lease.generation + 1;
517
+ const expiresAt = now + options.leaseMs;
518
+ const result = this.state.connection
529
519
  .prepare(
530
- `UPDATE queue
531
- SET claim_token = ?, claim_version = version, claim_expires_at = ?
532
- WHERE controller = ? AND resource_key = ?
533
- AND (claim_token IS NULL OR claim_expires_at <= ?)`,
520
+ `UPDATE leases
521
+ SET generation = ?, owner_type = 'controller', owner_id = ?, token_hash = ?,
522
+ acquired_at = ?, heartbeat_at = ?, expires_at = ?
523
+ WHERE resource_id = ? AND generation = ?
524
+ AND (owner_id IS NULL OR expires_at <= ?)`,
534
525
  )
535
- .run(token, expiresAtMs, row.controller, row.resource_key, nowMs);
536
- if (result.changes !== 1) {
537
- return undefined;
538
- }
526
+ .run(
527
+ generation,
528
+ options.ownerId,
529
+ tokenHash(token),
530
+ now,
531
+ now,
532
+ expiresAt,
533
+ row.resourceId,
534
+ lease.generation,
535
+ now,
536
+ );
537
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
538
+ if (result.changes !== 1) return undefined;
539
+ this.bumpResource(row.resourceId, row.resourceVersion, now);
540
+ this.insertEvent(
541
+ row.resourceId,
542
+ row.resourceVersion + 1,
543
+ "lease.claimed",
544
+ "controller",
545
+ options.ownerId,
546
+ { expiresAt },
547
+ now,
548
+ generation,
549
+ );
539
550
  return {
540
- controller: row.controller,
541
- key: row.resource_key,
551
+ controller: row.controllerName,
552
+ key: row.resourceKey,
553
+ ownerId: options.ownerId,
542
554
  token,
543
- queueVersion: row.version,
544
- consecutiveErrors: row.consecutive_errors,
545
- expiresAt: iso(expiresAtMs),
555
+ generation,
556
+ queueVersion: this.queueVersion(row.controllerResourceId),
557
+ resourceVersion: row.resourceVersion + 1,
558
+ consecutiveErrors: this.queueErrors(row.controllerResourceId),
559
+ expiresAt: new Date(expiresAt).toISOString(),
546
560
  };
547
561
  });
548
562
  }
549
563
 
550
- renewClaim(claim: ControllerQueueClaim, leaseMs: number, now?: string): boolean {
551
- validateDuration(leaseMs, "leaseMs");
552
- const nowMs = epoch(validTimestamp(now));
553
- const expiresAt = nowMs + leaseMs;
554
- const result = this.database
564
+ renewClaim(claim: ControllerQueueClaim, leaseMs: number, nowValue?: string): boolean {
565
+ const now = epoch(validTimestamp(nowValue));
566
+ const expiresAt = now + leaseMs;
567
+ const row = this.controllerRow({ controller: claim.controller, key: claim.key });
568
+ if (row === undefined) return false;
569
+ const result = this.state.connection
555
570
  .prepare(
556
- `UPDATE queue SET claim_expires_at = ?
557
- WHERE controller = ? AND resource_key = ? AND claim_token = ?
558
- AND claim_expires_at > ?`,
571
+ `UPDATE leases SET heartbeat_at = ?, expires_at = ?
572
+ WHERE resource_id = ? AND owner_type = 'controller' AND owner_id = ?
573
+ AND token_hash = ? AND generation = ? AND expires_at > ?`,
559
574
  )
560
- .run(expiresAt, claim.controller, claim.key, claim.token, nowMs);
575
+ .run(
576
+ now,
577
+ expiresAt,
578
+ row.resourceId,
579
+ claim.ownerId,
580
+ tokenHash(claim.token),
581
+ claim.generation,
582
+ now,
583
+ );
584
+ if (result.changes === 1) claim.expiresAt = new Date(expiresAt).toISOString();
561
585
  return result.changes === 1;
562
586
  }
563
587
 
564
- settleClaim(claim: ControllerQueueClaim, now?: string): boolean {
565
- const nowMs = epoch(validTimestamp(now));
566
- return this.transaction(() => {
567
- const row = this.claimedRow(claim);
568
- if (row === undefined) {
569
- return false;
570
- }
571
- if (row.version === row.claim_version) {
572
- this.database
573
- .prepare(
574
- "DELETE FROM queue WHERE controller = ? AND resource_key = ? AND claim_token = ?",
575
- )
576
- .run(claim.controller, claim.key, claim.token);
577
- } else {
578
- this.database
579
- .prepare(
580
- `UPDATE queue
581
- SET claim_token = NULL, claim_version = NULL, claim_expires_at = NULL,
582
- available_at = MIN(available_at, ?), consecutive_errors = 0, last_error = NULL
583
- WHERE controller = ? AND resource_key = ? AND claim_token = ?`,
584
- )
585
- .run(nowMs, claim.controller, claim.key, claim.token);
586
- }
587
- return true;
588
- });
588
+ settleClaim(claim: ControllerQueueClaim, nowValue?: string): boolean {
589
+ return this.settleControllerClaim(claim, undefined, nowValue);
589
590
  }
590
591
 
591
- requeueClaim(claim: ControllerQueueClaim, options: QueueRequeueOptions, _now?: string): boolean {
592
- const availableAt = epoch(validTimestamp(options.availableAt));
593
- const error = options.error === undefined ? null : truncateError(options.error);
594
- return this.transaction(() => {
595
- const row = this.claimedRow(claim);
596
- if (row === undefined) {
597
- return false;
598
- }
599
- const eventArrived = row.version !== row.claim_version;
600
- const nextAvailableAt = eventArrived ? Math.min(row.available_at, availableAt) : availableAt;
601
- const nextErrors = options.error === undefined ? 0 : row.consecutive_errors + 1;
602
- this.database
603
- .prepare(
604
- `UPDATE queue
605
- SET claim_token = NULL, claim_version = NULL, claim_expires_at = NULL,
606
- available_at = ?, consecutive_errors = ?, last_error = ?
607
- WHERE controller = ? AND resource_key = ? AND claim_token = ?`,
608
- )
609
- .run(nextAvailableAt, nextErrors, error, claim.controller, claim.key, claim.token);
610
- return true;
611
- });
592
+ requeueClaim(
593
+ claim: ControllerQueueClaim,
594
+ options: QueueRequeueOptions,
595
+ nowValue?: string,
596
+ ): boolean {
597
+ return this.settleControllerClaim(claim, options, nowValue);
612
598
  }
613
599
 
614
600
  listQueue(): QueueItem[] {
615
- const rows = this.database
616
- .prepare("SELECT * FROM queue ORDER BY available_at, controller, resource_key")
617
- .all() as QueueRow[];
618
- return rows.map((row) => ({
601
+ const rows = this.state.connection
602
+ .prepare(
603
+ `SELECT c.controller_name AS controller, c.resource_key AS resourceKey,
604
+ q.available_at AS availableAt, q.consecutive_errors AS consecutiveErrors,
605
+ l.expires_at AS claimExpiresAt
606
+ FROM controller_queue q
607
+ JOIN controller_resources c ON c.controller_resource_id = q.controller_resource_id
608
+ JOIN leases l ON l.resource_id = c.resource_id
609
+ WHERE c.project_id = ? ORDER BY q.available_at, c.controller_name, c.resource_key`,
610
+ )
611
+ .all(this.requireProjectId());
612
+ return rows.filter(isQueueListRow).map((row) => ({
619
613
  controller: row.controller,
620
- key: row.resource_key,
621
- availableAt: iso(row.available_at),
622
- consecutiveErrors: row.consecutive_errors,
623
- ...(row.claim_expires_at === null ? {} : { claimExpiresAt: iso(row.claim_expires_at) }),
614
+ key: row.resourceKey,
615
+ availableAt: new Date(row.availableAt).toISOString(),
616
+ consecutiveErrors: row.consecutiveErrors,
617
+ ...(row.claimExpiresAt === null
618
+ ? {}
619
+ : { claimExpiresAt: new Date(row.claimExpiresAt).toISOString() }),
624
620
  }));
625
621
  }
626
622
 
627
623
  reserveEffect(options: {
628
624
  key: string;
629
625
  resourceUid: string;
626
+ claim: ControllerQueueClaim;
630
627
  generation: number;
631
628
  kind: string;
632
629
  requestFingerprint: string;
633
630
  now?: string;
634
631
  }): EffectReservation {
635
- validateKey(options.key, "effect key");
636
- validateName(options.kind, "effect kind");
637
- const now = validTimestamp(options.now);
638
- return this.transaction(() => {
632
+ const now = epoch(validTimestamp(options.now));
633
+ return this.state.transaction(() => {
634
+ const source = this.requireControllerRowByUid(options.resourceUid);
635
+ this.assertControllerClaim(source, options.claim, now);
636
+ const effectId = effectIdFor(source.resourceId, options.key);
639
637
  const existing = this.effectRow(options.resourceUid, options.key);
640
638
  if (existing !== undefined) {
641
639
  if (
642
- existing.request_fingerprint !== options.requestFingerprint ||
643
640
  existing.generation !== options.generation ||
644
- existing.kind !== options.kind
641
+ existing.kind !== options.kind ||
642
+ existing.requestFingerprint !== options.requestFingerprint
645
643
  ) {
646
644
  throw new EffectRequestConflictError(options.key);
647
645
  }
648
- return { record: effectFromRow(existing), created: false };
646
+ return { record: this.mapEffect(existing), created: false };
649
647
  }
650
- this.database
648
+ const effectResourceId = resourceIdFor("effect", effectId);
649
+ const payloadHash = this.state.putJson(
650
+ {
651
+ key: options.key,
652
+ resourceUid: options.resourceUid,
653
+ generation: options.generation,
654
+ kind: options.kind,
655
+ requestFingerprint: options.requestFingerprint,
656
+ },
657
+ now,
658
+ );
659
+ this.state.connection
660
+ .prepare(
661
+ `INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
662
+ VALUES (?, 'effect', ?, 1, ?, ?)`,
663
+ )
664
+ .run(effectResourceId, effectId, now, now);
665
+ this.state.connection
666
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
667
+ .run(effectResourceId);
668
+ this.state.connection
651
669
  .prepare(
652
- `INSERT INTO effects (
653
- effect_key, resource_uid, generation, kind, state,
654
- request_fingerprint, started_at
655
- ) VALUES (?, ?, ?, ?, 'pending', ?, ?)`,
670
+ `INSERT INTO effects(
671
+ effect_id, resource_id, source_resource_id, source_revision, effect_type,
672
+ idempotency_key, payload_hash, owner_scope, status, attempt_count,
673
+ created_at, updated_at
674
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'controller', 'pending', 0, ?, ?)`,
656
675
  )
657
676
  .run(
658
- options.key,
659
- options.resourceUid,
660
- options.generation,
677
+ effectId,
678
+ effectResourceId,
679
+ source.resourceId,
680
+ source.resourceVersion,
661
681
  options.kind,
662
- options.requestFingerprint,
682
+ options.key,
683
+ payloadHash,
684
+ now,
663
685
  now,
664
686
  );
687
+ this.insertEvent(
688
+ effectResourceId,
689
+ 1,
690
+ "effect.reserved",
691
+ "controller",
692
+ options.claim.ownerId,
693
+ {},
694
+ now,
695
+ options.claim.generation,
696
+ );
665
697
  return {
666
- record: this.requireEffect(options.resourceUid, options.key),
698
+ record: this.mapEffect(this.requireEffectRow(options.resourceUid, options.key)),
667
699
  created: true,
668
700
  };
669
701
  });
@@ -671,567 +703,528 @@ export class SqliteControllerStore implements ControllerStore {
671
703
 
672
704
  getEffect(resourceUid: string, key: string): EffectRecord | undefined {
673
705
  const row = this.effectRow(resourceUid, key);
674
- return row === undefined ? undefined : effectFromRow(row);
706
+ return row === undefined ? undefined : this.mapEffect(row);
675
707
  }
676
708
 
677
709
  updateEffect(options: {
678
710
  resourceUid: string;
679
711
  key: string;
712
+ claim: ControllerQueueClaim;
680
713
  state: EffectRecord["state"];
681
714
  externalRef?: string;
682
715
  error?: string;
683
716
  now?: string;
684
717
  }): EffectRecord {
685
- const row = this.effectRow(options.resourceUid, options.key);
686
- if (row === undefined) {
687
- throw new Error(`Effect record not found: ${options.resourceUid}/${options.key}`);
688
- }
689
- if ((row.state === "applied" || row.state === "rejected") && row.state !== options.state) {
690
- throw new Error(`Effect ${JSON.stringify(options.key)} is already ${row.state}`);
691
- }
692
- const completedAt =
693
- options.state === "applied" || options.state === "rejected"
694
- ? validTimestamp(options.now)
695
- : null;
696
- this.database
697
- .prepare(
698
- `UPDATE effects
699
- SET state = ?, external_ref = COALESCE(?, external_ref), error = ?, completed_at = ?
700
- WHERE resource_uid = ? AND effect_key = ?`,
701
- )
702
- .run(
703
- options.state,
704
- options.externalRef ?? null,
705
- options.error === undefined ? null : truncateError(options.error),
706
- completedAt,
707
- options.resourceUid,
708
- options.key,
718
+ const now = epoch(validTimestamp(options.now));
719
+ return this.state.transaction(() => {
720
+ const source = this.requireControllerRowByUid(options.resourceUid);
721
+ this.assertControllerClaim(source, options.claim, now);
722
+ const row = this.requireEffectRow(options.resourceUid, options.key);
723
+ const effectResourceId = resourceIdFor("effect", row.effectId);
724
+ const revision = this.resourceRevision(effectResourceId);
725
+ const errorHash = options.error === undefined ? null : this.state.putText(options.error, now);
726
+ this.state.connection
727
+ .prepare(
728
+ `UPDATE effects
729
+ SET status = ?, external_ref = ?, error_hash = ?, updated_at = ?, settled_at = ?
730
+ WHERE effect_id = ?`,
731
+ )
732
+ .run(
733
+ effectStatus(options.state),
734
+ options.externalRef ?? null,
735
+ errorHash,
736
+ now,
737
+ options.state === "pending" ? null : now,
738
+ row.effectId,
739
+ );
740
+ this.bumpResource(effectResourceId, revision, now);
741
+ this.insertEvent(
742
+ effectResourceId,
743
+ revision + 1,
744
+ `effect.${options.state}`,
745
+ "controller",
746
+ options.claim.ownerId,
747
+ {},
748
+ now,
749
+ options.claim.generation,
709
750
  );
710
- return this.requireEffect(options.resourceUid, options.key);
751
+ return this.mapEffect(this.requireEffectRow(options.resourceUid, options.key));
752
+ });
711
753
  }
712
754
 
713
755
  listEffects(resourceUid: string): EffectRecord[] {
714
- const rows = this.database
715
- .prepare("SELECT * FROM effects WHERE resource_uid = ? ORDER BY started_at, effect_key")
716
- .all(resourceUid) as EffectRow[];
717
- return rows.map(effectFromRow);
756
+ const rows = this.state.connection
757
+ .prepare(effectSelect("WHERE c.uid = ? ORDER BY e.created_at, e.effect_id"))
758
+ .all(resourceUid);
759
+ return rows.filter(isEffectRow).map((row) => this.mapEffect(row));
718
760
  }
719
761
 
720
762
  reserveWorkflow(options: {
721
763
  resourceUid: string;
764
+ claim: ControllerQueueClaim;
722
765
  requestKey: string;
723
766
  workflow: string;
724
767
  inputFingerprint: string;
725
768
  }): WorkflowReservation {
726
- validateKey(options.requestKey, "workflow request key");
727
- validateKey(options.workflow, "workflow name");
728
- return this.transaction(() => {
769
+ const now = Date.now();
770
+ return this.state.transaction(() => {
771
+ const source = this.requireControllerRowByUid(options.resourceUid);
772
+ this.assertControllerClaim(source, options.claim, now);
729
773
  const existing = this.workflowRow(options.resourceUid, options.requestKey);
730
774
  if (existing !== undefined) {
731
775
  if (
732
- existing.input_fingerprint !== options.inputFingerprint ||
733
- existing.workflow !== options.workflow
776
+ existing.workflowName !== options.workflow ||
777
+ existing.inputFingerprint.toString("hex") !== options.inputFingerprint
734
778
  ) {
735
779
  throw new WorkflowRequestConflictError(options.requestKey);
736
780
  }
737
- return { record: workflowFromRow(existing), created: false };
781
+ return { record: this.mapWorkflow(existing), created: false };
738
782
  }
739
783
  const requestId = randomUUID();
740
- this.database
784
+ this.state.connection
741
785
  .prepare(
742
- `INSERT INTO workflow_requests (
743
- request_id, resource_uid, request_key, input_fingerprint,
744
- workflow, state, attempt
745
- ) VALUES (?, ?, ?, ?, ?, 'pending', 0)`,
786
+ `INSERT INTO controller_workflows(
787
+ request_id, controller_resource_id, request_key, workflow_name,
788
+ input_fingerprint, status, attempt_count, created_at, updated_at
789
+ ) VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
746
790
  )
747
791
  .run(
748
792
  requestId,
749
- options.resourceUid,
793
+ source.controllerResourceId,
750
794
  options.requestKey,
751
- options.inputFingerprint,
752
795
  options.workflow,
796
+ Buffer.from(options.inputFingerprint, "hex"),
797
+ now,
798
+ now,
753
799
  );
754
- return {
755
- record: this.requireWorkflow(requestId),
756
- created: true,
757
- };
800
+ return { record: this.mapWorkflow(this.requireWorkflowRow(requestId)), created: true };
758
801
  });
759
802
  }
760
803
 
761
804
  getWorkflow(resourceUid: string, requestKey: string): ChildWorkflowRecord | undefined {
762
805
  const row = this.workflowRow(resourceUid, requestKey);
763
- return row === undefined ? undefined : workflowFromRow(row);
806
+ return row === undefined ? undefined : this.mapWorkflow(row);
764
807
  }
765
808
 
766
809
  getWorkflowByRequestId(requestId: string): ChildWorkflowRecord | undefined {
767
- const row = this.database
768
- .prepare("SELECT * FROM workflow_requests WHERE request_id = ?")
769
- .get(requestId) as WorkflowRow | undefined;
770
- return row === undefined ? undefined : workflowFromRow(row);
771
- }
772
-
773
- updateWorkflow(requestId: string, update: WorkflowRecordUpdate): ChildWorkflowRecord {
774
- const existing = this.database
775
- .prepare("SELECT * FROM workflow_requests WHERE request_id = ?")
776
- .get(requestId) as WorkflowRow | undefined;
777
- if (existing === undefined) {
778
- throw new Error(`Workflow request not found: ${requestId}`);
810
+ const row = this.workflowRowById(requestId);
811
+ return row === undefined ? undefined : this.mapWorkflow(row);
812
+ }
813
+
814
+ updateWorkflow(
815
+ requestId: string,
816
+ update: WorkflowRecordUpdate,
817
+ claim: ControllerQueueClaim,
818
+ ): ChildWorkflowRecord {
819
+ const now = Date.now();
820
+ return this.state.transaction(() => {
821
+ const row = this.requireWorkflowRow(requestId);
822
+ const source = this.requireControllerRowByUid(row.resourceUid);
823
+ this.assertControllerClaim(source, claim, now);
824
+ const errorHash =
825
+ update.error === undefined || update.error === null
826
+ ? null
827
+ : this.state.putText(update.error, now);
828
+ this.state.connection
829
+ .prepare(
830
+ `UPDATE controller_workflows
831
+ SET reserved_run_id = COALESCE(?, reserved_run_id),
832
+ run_id = CASE
833
+ WHEN ? IS NOT NULL AND EXISTS(SELECT 1 FROM runs WHERE run_id = ?) THEN ?
834
+ ELSE run_id
835
+ END,
836
+ status = ?, attempt_count = COALESCE(?, attempt_count),
837
+ error_hash = ?, updated_at = ?
838
+ WHERE request_id = ?`,
839
+ )
840
+ .run(
841
+ update.runId ?? null,
842
+ update.runId ?? null,
843
+ update.runId ?? null,
844
+ update.runId ?? null,
845
+ update.state,
846
+ update.attempt ?? null,
847
+ errorHash,
848
+ now,
849
+ requestId,
850
+ );
851
+ return this.mapWorkflow(this.requireWorkflowRow(requestId));
852
+ });
853
+ }
854
+
855
+ completeWorkflow(requestId: string, update: WorkflowRecordUpdate): ChildWorkflowRecord {
856
+ const row = this.requireWorkflowRow(requestId);
857
+ if (update.state === "pending" || update.state === "running") {
858
+ throw new Error("Scheduler completion must report a settled child state");
859
+ }
860
+ if (update.runId !== undefined && row.runId !== null && update.runId !== row.runId) {
861
+ throw new Error("Scheduler completion run ID does not match the reserved child run");
779
862
  }
780
- const runId = update.runId === undefined ? existing.run_id : update.runId;
781
- const attempt = update.attempt ?? existing.attempt;
782
- const error =
783
- update.error === undefined
784
- ? existing.error
785
- : update.error === null
863
+ const source = this.requireControllerRowByUid(row.resourceUid);
864
+ const now = Date.now();
865
+ return this.state.transaction(() => {
866
+ const errorHash =
867
+ update.error === undefined || update.error === null
786
868
  ? null
787
- : truncateError(update.error);
788
- this.database
789
- .prepare(
790
- `UPDATE workflow_requests
791
- SET run_id = ?, state = ?, attempt = ?, error = ?
792
- WHERE request_id = ?`,
793
- )
794
- .run(runId, update.state, attempt, error, requestId);
795
- return this.requireWorkflow(requestId);
869
+ : this.state.putText(update.error, now);
870
+ this.state.connection
871
+ .prepare(
872
+ `UPDATE controller_workflows
873
+ SET run_id = CASE
874
+ WHEN ? IS NOT NULL AND EXISTS(SELECT 1 FROM runs WHERE run_id = ?) THEN ?
875
+ ELSE run_id
876
+ END,
877
+ status = ?, attempt_count = COALESCE(?, attempt_count),
878
+ error_hash = ?, updated_at = ?
879
+ WHERE request_id = ?`,
880
+ )
881
+ .run(
882
+ update.runId ?? null,
883
+ update.runId ?? null,
884
+ update.runId ?? null,
885
+ update.state,
886
+ update.attempt ?? null,
887
+ errorHash,
888
+ now,
889
+ requestId,
890
+ );
891
+ const revision = this.resourceRevision(source.resourceId);
892
+ this.bumpResource(source.resourceId, revision, now);
893
+ this.insertEvent(
894
+ source.resourceId,
895
+ revision + 1,
896
+ "workflow_state_changed",
897
+ "system",
898
+ null,
899
+ { requestId, state: update.state, runId: update.runId ?? row.runId },
900
+ now,
901
+ );
902
+ return this.mapWorkflow(this.requireWorkflowRow(requestId));
903
+ });
796
904
  }
797
905
 
798
906
  listWorkflows(resourceUid: string): ChildWorkflowRecord[] {
799
- const rows = this.database
800
- .prepare("SELECT * FROM workflow_requests WHERE resource_uid = ? ORDER BY rowid")
801
- .all(resourceUid) as WorkflowRow[];
802
- return rows.map(workflowFromRow);
907
+ const rows = this.state.connection
908
+ .prepare(workflowSelect("WHERE c.uid = ? ORDER BY w.created_at, w.request_id"))
909
+ .all(resourceUid);
910
+ return rows.filter(isWorkflowRow).map((row) => this.mapWorkflow(row));
803
911
  }
804
912
 
805
913
  recordEvent(options: {
806
914
  controller: string;
807
915
  key: string;
916
+ claim?: ControllerQueueClaim;
808
917
  type: string;
809
918
  payload?: JsonObject;
810
919
  now?: string;
811
920
  }): ControllerEvent {
812
- validateName(options.controller, "controller");
813
- validateKey(options.key, "resource key");
814
- validateName(options.type, "event type");
815
- const recordedAt = validTimestamp(options.now);
816
- const payloadJson = canonicalJson(options.payload ?? {}, "controller event payload");
817
- if (Buffer.byteLength(payloadJson, "utf8") > MAX_EVENT_BYTES) {
818
- throw new Error(`Controller event payload exceeds ${MAX_EVENT_BYTES} bytes`);
819
- }
820
- const result = this.database
821
- .prepare(
822
- `INSERT INTO events (recorded_at, controller, resource_key, type, payload_json)
823
- VALUES (?, ?, ?, ?, ?)`,
824
- )
825
- .run(recordedAt, options.controller, options.key, options.type, payloadJson);
826
- return this.requireEvent(Number(result.lastInsertRowid));
921
+ const now = epoch(validTimestamp(options.now));
922
+ return this.state.transaction(() => {
923
+ const row = this.requireControllerRow({ controller: options.controller, key: options.key });
924
+ if (options.claim !== undefined) this.assertControllerClaim(row, options.claim, now);
925
+ const revision = this.resourceRevision(row.resourceId) + 1;
926
+ this.bumpResource(row.resourceId, revision - 1, now);
927
+ const eventId = this.insertEvent(
928
+ row.resourceId,
929
+ revision,
930
+ options.type,
931
+ options.claim === undefined ? "control" : "controller",
932
+ options.claim?.ownerId ?? null,
933
+ options.payload ?? {},
934
+ now,
935
+ options.claim?.generation,
936
+ );
937
+ if (options.claim !== undefined) options.claim.resourceVersion = revision;
938
+ const event = this.state.connection
939
+ .prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
940
+ .get(eventId);
941
+ /* istanbul ignore if -- exact schema and internal query shape */
942
+ if (!isSequenceRow(event)) throw new Error("Controller event was not recorded");
943
+ return {
944
+ seq: event.seq,
945
+ recordedAt: new Date(now).toISOString(),
946
+ controller: options.controller,
947
+ key: options.key,
948
+ type: options.type,
949
+ payload: options.payload ?? {},
950
+ };
951
+ });
827
952
  }
828
953
 
829
954
  listEvents(
830
- options: {
831
- controller?: string;
832
- key?: string;
833
- limit?: number;
834
- } = {},
955
+ options: { controller?: string; key?: string; limit?: number } = {},
835
956
  ): ControllerEvent[] {
836
- const limit = options.limit ?? 100;
837
- if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 10_000) {
838
- throw new Error("Event limit must be between 1 and 10000");
957
+ const clauses = ["r.resource_type = 'controller'"];
958
+ const params: unknown[] = [];
959
+ if (this.projectId !== null) {
960
+ clauses.push("c.project_id = ?");
961
+ params.push(this.projectId);
839
962
  }
840
- const clauses: string[] = [];
841
- const values: unknown[] = [];
842
963
  if (options.controller !== undefined) {
843
- clauses.push("controller = ?");
844
- values.push(options.controller);
964
+ clauses.push("c.controller_name = ?");
965
+ params.push(options.controller);
845
966
  }
846
967
  if (options.key !== undefined) {
847
- clauses.push("resource_key = ?");
848
- values.push(options.key);
849
- }
850
- const where = clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`;
851
- const rows = this.database
852
- .prepare(`SELECT * FROM events ${where} ORDER BY seq DESC LIMIT ?`)
853
- .all(...values, limit) as EventRow[];
854
- return rows.map(eventFromRow);
855
- }
856
-
857
- private configure(readOnly: boolean): void {
858
- if (!readOnly) {
859
- this.database.pragma("journal_mode = WAL");
860
- this.database.pragma("synchronous = FULL");
861
- this.database.pragma("foreign_keys = ON");
968
+ clauses.push("c.resource_key = ?");
969
+ params.push(options.key);
862
970
  }
863
- this.database.pragma("busy_timeout = 5000");
971
+ params.push(options.limit ?? 100);
972
+ const rows = this.state.connection
973
+ .prepare(
974
+ `SELECT e.event_seq AS seq, e.recorded_at AS recordedAt,
975
+ c.controller_name AS controller, c.resource_key AS resourceKey,
976
+ e.event_type AS eventType, e.payload_hash AS payloadHash
977
+ FROM events e
978
+ JOIN resources r ON r.resource_id = e.resource_id
979
+ JOIN controller_resources c ON c.resource_id = r.resource_id
980
+ WHERE ${clauses.join(" AND ")}
981
+ ORDER BY e.event_seq DESC LIMIT ?`,
982
+ )
983
+ .all(...params);
984
+ return rows.filter(isControllerEventRow).map((row) => ({
985
+ seq: row.seq,
986
+ recordedAt: new Date(row.recordedAt).toISOString(),
987
+ controller: row.controller,
988
+ key: row.resourceKey,
989
+ type: row.eventType,
990
+ payload: row.payloadHash === null ? {} : (this.state.readJson(row.payloadHash) as JsonObject),
991
+ }));
864
992
  }
865
993
 
866
- private initializeSchema(readOnly: boolean): void {
867
- if (!readOnly) {
868
- this.database.exec(`
869
- CREATE TABLE IF NOT EXISTS schema_info (
870
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
871
- schema_id TEXT NOT NULL
872
- );
873
- `);
874
- }
875
- const row = this.database
876
- .prepare("SELECT schema_id FROM schema_info WHERE singleton = 1")
877
- .get() as { schema_id: string } | undefined;
878
- if (row !== undefined && row.schema_id !== CONTROLLER_STORE_SCHEMA) {
879
- throw new Error(`Unsupported controller store schema: ${row.schema_id}`);
880
- }
881
- if (readOnly) {
882
- if (row === undefined) {
883
- throw new Error("Controller store has no schema identifier");
994
+ reserveWorkflowRun(options: {
995
+ runId: string;
996
+ workflowName: string;
997
+ workflowSourceRef: string;
998
+ workflowSource: unknown;
999
+ definitionDigest: string;
1000
+ definitionSnapshot: unknown;
1001
+ input: unknown;
1002
+ launchOptions?: unknown;
1003
+ runnerId: string;
1004
+ originSessionId: string;
1005
+ parentRunId?: string;
1006
+ now?: string;
1007
+ }): WorkflowRunQueueRecord {
1008
+ validateRunId(options.runId);
1009
+ const now = epoch(validTimestamp(options.now));
1010
+ const definitionDigest = digestBuffer(options.definitionDigest);
1011
+ return this.state.transaction(() => {
1012
+ if (this.getWorkflowRun(options.runId) !== undefined) {
1013
+ throw new Error(`Workflow run already reserved: ${options.runId}`);
884
1014
  }
885
- } else {
886
- const existingQueue = this.database
887
- .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'workflow_run_queue'")
888
- .get();
889
- if (existingQueue !== undefined) {
890
- this.assertAlphaSchemaLayout(false);
891
- this.assertNoLegacyPendingLaunchNotifications();
1015
+ if (options.parentRunId !== undefined) {
1016
+ const parent = this.requireWorkflowRunRow(options.parentRunId);
1017
+ if (parent.originSessionId !== options.originSessionId) {
1018
+ throw new Error("Continuation parent belongs to another Pi session");
1019
+ }
1020
+ if (parent.status === "parked") {
1021
+ const parentLease = this.requireLease(parent.resourceId);
1022
+ if (parentLease.ownerId !== null) {
1023
+ throw new Error("Continuation parent still has an active owner");
1024
+ }
1025
+ this.state.connection
1026
+ .prepare(
1027
+ `UPDATE run_queue
1028
+ SET status = 'done', updated_at = ?, finished_at = ?
1029
+ WHERE run_id = ? AND status = 'parked'`,
1030
+ )
1031
+ .run(now, now, parent.runId);
1032
+ const parentRevision = this.resourceRevision(parent.resourceId);
1033
+ this.bumpResource(parent.resourceId, parentRevision, now);
1034
+ this.insertEvent(
1035
+ parent.resourceId,
1036
+ parentRevision + 1,
1037
+ "run.queue_done_for_continuation",
1038
+ "session",
1039
+ options.originSessionId,
1040
+ { continuationRunId: options.runId },
1041
+ now,
1042
+ );
1043
+ } else if (parent.status === "done") {
1044
+ throw new Error("Continuation parent already has a reserved continuation");
1045
+ } else {
1046
+ throw new Error(`Continuation parent queue is ${parent.status}`);
1047
+ }
892
1048
  }
893
- this.transaction(() => {
894
- this.database
895
- .prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
896
- .run(CONTROLLER_STORE_SCHEMA);
897
- this.database.exec(SCHEMA_SQL);
898
- });
899
- }
900
- this.assertAlphaSchemaLayout(true);
901
- this.assertNoLegacyPendingLaunchNotifications();
902
- }
903
-
904
- private assertAlphaSchemaLayout(requireTurnIntents: boolean): void {
905
- const requiredQueueColumns = new Set([
906
- "run_id",
907
- "workflow_ref",
908
- "workflow_path",
909
- "workflow_source_json",
910
- "definition_digest",
911
- "input_json",
912
- "launch_options_json",
913
- "status",
914
- "runner_id",
915
- "claim_token",
916
- "claim_expires_at",
917
- "affinity_runner_id",
918
- "origin_session_id",
919
- "parent_run_id",
920
- "error_code",
921
- "error_message",
922
- "created_at",
923
- "updated_at",
924
- "started_at",
925
- "finished_at",
926
- ]);
927
- const actual = new Set(
928
- (
929
- this.database.pragma("table_info(workflow_run_queue)") as {
930
- name: string;
931
- }[]
932
- ).map((column) => column.name),
933
- );
934
- const missing = [...requiredQueueColumns].filter((column) => !actual.has(column));
935
- if (missing.length > 0) {
936
- throw new Error(
937
- "Controller store uses an incompatible alpha layout. Preserve needed run bundles, then reset the project controller store.",
938
- );
939
- }
940
- if (!requireTurnIntents) return;
941
- const requiredIntentColumns = new Set([
942
- "intent_id",
943
- "source_event_id",
944
- "run_id",
945
- "workflow_ref",
946
- "target_session_id",
947
- "cause",
948
- "node_id",
949
- "attempt_id",
950
- "fallback_facts_json",
951
- "requested_at",
952
- "eligible_at",
953
- "resolved_at",
954
- "resolution",
955
- "resolution_message_id",
956
- "delivery_claim_token",
957
- "delivery_claim_expires_at",
958
- ]);
959
- const intentColumns = new Set(
960
- (
961
- this.database.pragma("table_info(workflow_turn_intents)") as {
962
- name: string;
963
- }[]
964
- ).map((column) => column.name),
965
- );
966
- const missingIntentColumns = [...requiredIntentColumns].filter(
967
- (column) => !intentColumns.has(column),
968
- );
969
- if (missingIntentColumns.length > 0) {
970
- throw new Error(
971
- "Controller store uses an incompatible alpha turn-intent layout. Preserve needed run bundles, then reset the project controller store.",
972
- );
973
- }
974
- }
975
-
976
- private assertNoLegacyPendingLaunchNotifications(): void {
977
- const notificationTable = this.database
978
- .prepare(
979
- "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'workflow_notifications'",
980
- )
981
- .get();
982
- if (notificationTable === undefined) {
983
- throw new Error(
984
- "Controller store uses an incompatible alpha notification layout. Preserve needed run bundles, then reset the project controller store.",
985
- );
986
- }
987
- const legacy = this.database
988
- .prepare(
989
- `SELECT 1 FROM workflow_notifications
990
- WHERE kind = 'launch_failure' AND delivered_at IS NULL LIMIT 1`,
991
- )
992
- .get();
993
- if (legacy !== undefined) {
994
- throw new Error(
995
- "Controller store contains pending alpha launch-failure notifications. Preserve needed run bundles, then reset the project controller store.",
1049
+ const resourceId = resourceIdFor("run", options.runId);
1050
+ const definitionHash = this.state.putJson(options.definitionSnapshot, now);
1051
+ const queuedSource = queuedWorkflowSource(options.workflowSource);
1052
+ const sourceHash = this.state.putJson(options.workflowSource, now);
1053
+ const inputHash = this.state.putJson(options.input ?? null, now);
1054
+ const launchHash = this.state.putJson(options.launchOptions ?? {}, now);
1055
+ const queuedStateHash = this.state.putJson(
1056
+ {
1057
+ schema: "pi-workflows.run-state.v1",
1058
+ traceSeq: 1,
1059
+ runId: options.runId,
1060
+ workflowName: options.workflowName,
1061
+ workflowSource: queuedSource.root,
1062
+ ...(queuedSource.mounted.length === 0
1063
+ ? {}
1064
+ : {
1065
+ workflowSources: queuedSource.mounted,
1066
+ definitionDigest: options.definitionDigest,
1067
+ }),
1068
+ ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
1069
+ startedAt: new Date(now).toISOString(),
1070
+ updatedAt: new Date(now).toISOString(),
1071
+ status: "running",
1072
+ input: options.input ?? null,
1073
+ outputs: {},
1074
+ results: {},
1075
+ steps: [],
1076
+ updates: [],
1077
+ },
1078
+ now,
996
1079
  );
997
- }
998
- }
999
-
1000
- private transaction<T>(task: () => T): T {
1001
- this.database.exec("BEGIN IMMEDIATE");
1002
- try {
1003
- const value = task();
1004
- this.database.exec("COMMIT");
1005
- return value;
1006
- } catch (error) {
1007
- this.database.exec("ROLLBACK");
1008
- throw error;
1009
- }
1010
- }
1011
-
1012
- private resourceRow(ref: ControllerResourceRef): ResourceRow | undefined {
1013
- return this.database
1014
- .prepare("SELECT * FROM resources WHERE controller = ? AND resource_key = ?")
1015
- .get(ref.controller, ref.key) as ResourceRow | undefined;
1016
- }
1017
-
1018
- private requireResource<TSpec = unknown, TStatus = unknown>(
1019
- ref: ControllerResourceRef,
1020
- ): ControllerResource<TSpec, TStatus> {
1021
- const resource = this.getResource<TSpec, TStatus>(ref);
1022
- if (resource === undefined) {
1023
- throw new ResourceNotFoundError(ref.controller, ref.key);
1024
- }
1025
- return resource;
1026
- }
1027
-
1028
- private throwMissingOrConflict(ref: ControllerResourceRef): never {
1029
- if (this.resourceRow(ref) === undefined) {
1030
- throw new ResourceNotFoundError(ref.controller, ref.key);
1031
- }
1032
- throw new ResourceConflictError(ref.controller, ref.key);
1033
- }
1034
-
1035
- private enqueueRow(ref: ControllerResourceRef, availableAt: number): void {
1036
- this.database
1037
- .prepare(
1038
- `INSERT INTO queue (
1039
- controller, resource_key, available_at, version, consecutive_errors
1040
- ) VALUES (?, ?, ?, 1, 0)
1041
- ON CONFLICT(controller, resource_key) DO UPDATE SET
1042
- available_at = MIN(queue.available_at, excluded.available_at),
1043
- version = queue.version + 1`,
1044
- )
1045
- .run(ref.controller, ref.key, availableAt);
1046
- }
1047
-
1048
- private claimedRow(claim: ControllerQueueClaim): QueueRow | undefined {
1049
- return this.database
1050
- .prepare(
1051
- `SELECT * FROM queue
1052
- WHERE controller = ? AND resource_key = ? AND claim_token = ?`,
1053
- )
1054
- .get(claim.controller, claim.key, claim.token) as QueueRow | undefined;
1055
- }
1056
-
1057
- private effectRow(resourceUid: string, key: string): EffectRow | undefined {
1058
- return this.database
1059
- .prepare("SELECT * FROM effects WHERE resource_uid = ? AND effect_key = ?")
1060
- .get(resourceUid, key) as EffectRow | undefined;
1061
- }
1062
-
1063
- private requireEffect(resourceUid: string, key: string): EffectRecord {
1064
- const effect = this.getEffect(resourceUid, key);
1065
- if (effect === undefined) {
1066
- throw new Error(`Effect record not found: ${resourceUid}/${key}`);
1067
- }
1068
- return effect;
1069
- }
1070
-
1071
- private workflowRow(resourceUid: string, requestKey: string): WorkflowRow | undefined {
1072
- return this.database
1073
- .prepare("SELECT * FROM workflow_requests WHERE resource_uid = ? AND request_key = ?")
1074
- .get(resourceUid, requestKey) as WorkflowRow | undefined;
1075
- }
1076
-
1077
- private requireWorkflow(requestId: string): ChildWorkflowRecord {
1078
- const workflow = this.getWorkflowByRequestId(requestId);
1079
- if (workflow === undefined) {
1080
- throw new Error(`Workflow request not found: ${requestId}`);
1081
- }
1082
- return workflow;
1080
+ this.state.connection
1081
+ .prepare(
1082
+ `INSERT INTO workflow_definitions(
1083
+ definition_digest, workflow_name, definition_hash, created_at
1084
+ ) VALUES (?, ?, ?, ?)
1085
+ ON CONFLICT(definition_digest) DO NOTHING`,
1086
+ )
1087
+ .run(definitionDigest, options.workflowName, definitionHash, now);
1088
+ this.state.connection
1089
+ .prepare(
1090
+ `INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
1091
+ VALUES (?, 'run', ?, 1, ?, ?)`,
1092
+ )
1093
+ .run(resourceId, options.runId, now, now);
1094
+ this.state.connection
1095
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
1096
+ .run(resourceId);
1097
+ const source = sourceParts(options.workflowSourceRef, options.definitionDigest);
1098
+ this.state.connection
1099
+ .prepare(
1100
+ `INSERT INTO runs(
1101
+ run_id, resource_id, project_id, parent_run_id, definition_digest,
1102
+ workflow_ref, workflow_source_hash, launch_options_hash,
1103
+ source_type, source_ref, source_revision, status, paused,
1104
+ input_hash, output_hash, created_at, updated_at
1105
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?, ?)`,
1106
+ )
1107
+ .run(
1108
+ options.runId,
1109
+ resourceId,
1110
+ this.requireProjectId(),
1111
+ options.parentRunId ?? null,
1112
+ definitionDigest,
1113
+ options.workflowSourceRef,
1114
+ sourceHash,
1115
+ launchHash,
1116
+ source.type,
1117
+ source.ref,
1118
+ source.revision,
1119
+ inputHash,
1120
+ queuedStateHash,
1121
+ now,
1122
+ now,
1123
+ );
1124
+ this.state.connection
1125
+ .prepare(
1126
+ `INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
1127
+ VALUES (?, ?, 'interactive', ?)`,
1128
+ )
1129
+ .run(options.runId, options.originSessionId, now);
1130
+ this.state.connection
1131
+ .prepare(
1132
+ `INSERT INTO run_queue(
1133
+ run_id, status, available_at, affinity_runner_id, origin_session_id,
1134
+ consecutive_errors, created_at, updated_at
1135
+ ) VALUES (?, 'queued', ?, ?, ?, 0, ?, ?)`,
1136
+ )
1137
+ .run(options.runId, now, options.runnerId, options.originSessionId, now, now);
1138
+ this.insertEvent(resourceId, 1, "run.queued", "session", options.originSessionId, {}, now);
1139
+ return this.requireWorkflowRun(options.runId);
1140
+ });
1083
1141
  }
1084
1142
 
1085
- /** Reserve a user-started run before the initiating agent turn settles. */
1086
- reserveWorkflowRun(options: {
1143
+ enqueueWorkflowRun(options: {
1087
1144
  runId: string;
1088
1145
  workflowName: string;
1089
1146
  workflowSourceRef: string;
1090
1147
  workflowSource: unknown;
1091
1148
  definitionDigest: string;
1092
- input: unknown;
1093
- launchOptions?: unknown;
1094
- runnerId: string;
1095
- originSessionId: string;
1096
- parentRunId?: string;
1097
- now?: string;
1098
- }): WorkflowRunQueueRecord {
1099
- validateRunId(options.runId);
1100
- validateKey(options.workflowName, "workflow name");
1101
- validateKey(options.workflowSourceRef, "workflow source ref");
1102
- validateKey(options.definitionDigest, "workflow definition digest");
1103
- validateKey(options.runnerId, "runner id");
1104
- validateKey(options.originSessionId, "origin session id");
1105
- const inputJson = canonicalJson(options.input ?? null, "workflow run input");
1106
- const sourceJson = canonicalJson(options.workflowSource, "workflow run source");
1107
- const launchJson = canonicalJson(options.launchOptions ?? {}, "workflow launch options");
1108
- validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
1109
- validateJsonSize(sourceJson, "Workflow run source", MAX_EVENT_BYTES);
1110
- validateJsonSize(launchJson, "Workflow launch options", MAX_EVENT_BYTES);
1111
- const now = validTimestamp(options.now);
1112
- this.database
1113
- .prepare(
1114
- `INSERT INTO workflow_run_queue (
1115
- run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
1116
- input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
1117
- affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
1118
- created_at, updated_at, started_at, finished_at
1119
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', NULL, NULL, NULL, ?, ?, ?, NULL, NULL, ?, ?, NULL, NULL)`,
1120
- )
1121
- .run(
1122
- options.runId,
1123
- options.workflowName,
1124
- options.workflowSourceRef,
1125
- sourceJson,
1126
- options.definitionDigest,
1127
- inputJson,
1128
- launchJson,
1129
- options.runnerId,
1130
- options.originSessionId,
1131
- options.parentRunId ?? null,
1132
- now,
1133
- now,
1134
- );
1135
- return this.requireWorkflowRun(options.runId);
1136
- }
1137
-
1138
- /** Insert a run that will start immediately outside a live model turn. */
1139
- enqueueWorkflowRun(options: {
1140
- runId: string;
1141
- workflowName: string;
1142
- workflowSourceRef: string;
1143
- workflowSource?: unknown;
1144
- definitionDigest?: string;
1149
+ definitionSnapshot: unknown;
1145
1150
  input: unknown;
1146
1151
  launchOptions?: unknown;
1147
1152
  runnerId: string;
1148
1153
  claimToken: string;
1149
1154
  leaseMs: number;
1150
- affinityRunnerId?: string;
1151
- originSessionId?: string;
1155
+ originSessionId: string;
1152
1156
  parentRunId?: string;
1153
1157
  now?: string;
1154
1158
  }): WorkflowRunQueueRecord {
1155
- validateRunId(options.runId);
1156
- validateKey(options.workflowName, "workflow name");
1157
- validateKey(options.workflowSourceRef, "workflow source ref");
1158
- validateKey(options.runnerId, "runner id");
1159
- validateKey(options.claimToken, "claim token");
1160
- validateDuration(options.leaseMs, "leaseMs");
1161
- if (options.originSessionId !== undefined) {
1162
- validateKey(options.originSessionId, "origin session id");
1159
+ if (this.getWorkflowRun(options.runId) === undefined) {
1160
+ this.reserveWorkflowRun({
1161
+ runId: options.runId,
1162
+ workflowName: options.workflowName,
1163
+ workflowSourceRef: options.workflowSourceRef,
1164
+ workflowSource: options.workflowSource,
1165
+ definitionDigest: options.definitionDigest,
1166
+ definitionSnapshot: options.definitionSnapshot,
1167
+ input: options.input,
1168
+ launchOptions: options.launchOptions ?? {},
1169
+ runnerId: options.runnerId,
1170
+ originSessionId: options.originSessionId,
1171
+ ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
1172
+ ...(options.now === undefined ? {} : { now: options.now }),
1173
+ });
1163
1174
  }
1164
- const inputJson = canonicalJson(options.input ?? null, "workflow run input");
1165
- const sourceJson = canonicalJson(
1166
- options.workflowSource ?? { ref: options.workflowSourceRef },
1167
- "workflow run source",
1168
- );
1169
- const launchJson = canonicalJson(options.launchOptions ?? {}, "workflow launch options");
1170
- validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
1171
- const now = validTimestamp(options.now);
1172
- const expiresAt = epoch(now) + options.leaseMs;
1173
- this.database
1174
- .prepare(
1175
- `INSERT INTO workflow_run_queue (
1176
- run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
1177
- input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
1178
- affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
1179
- created_at, updated_at, started_at, finished_at
1180
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL)`,
1181
- )
1182
- .run(
1183
- options.runId,
1184
- options.workflowName,
1185
- options.workflowSourceRef,
1186
- sourceJson,
1187
- options.definitionDigest ?? "unavailable",
1188
- inputJson,
1189
- launchJson,
1190
- options.runnerId,
1191
- options.claimToken,
1192
- expiresAt,
1193
- options.affinityRunnerId ?? options.runnerId,
1194
- options.originSessionId ?? null,
1195
- options.parentRunId ?? null,
1196
- now,
1197
- now,
1198
- now,
1199
- );
1200
- return this.requireWorkflowRun(options.runId);
1175
+ const claimed = this.claimWorkflowRun({
1176
+ runId: options.runId,
1177
+ runnerId: options.runnerId,
1178
+ claimToken: options.claimToken,
1179
+ leaseMs: options.leaseMs,
1180
+ ...(options.now === undefined ? {} : { now: options.now }),
1181
+ });
1182
+ if (claimed === undefined) {
1183
+ throw new Error(`Workflow run could not be claimed: ${options.runId}`);
1184
+ }
1185
+ return claimed;
1201
1186
  }
1202
1187
 
1203
1188
  getWorkflowRun(runId: string): WorkflowRunQueueRecord | undefined {
1204
- validateRunId(runId);
1205
- const row = this.database
1206
- .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
1207
- .get(runId) as WorkflowRunQueueRow | undefined;
1208
- return row === undefined ? undefined : workflowRunFromRow(row);
1189
+ const row = this.workflowRunRow(runId);
1190
+ return row === undefined ? undefined : this.mapWorkflowRun(row);
1209
1191
  }
1210
1192
 
1211
1193
  listWorkflowRuns(
1212
- options: { status?: WorkflowRunQueueRecord["status"] } = {},
1194
+ options: {
1195
+ statuses?: WorkflowRunLaunchStatus[];
1196
+ excludeRunIds?: string[];
1197
+ limit?: number;
1198
+ } = {},
1213
1199
  ): WorkflowRunQueueRecord[] {
1214
- const rows =
1215
- options.status === undefined
1216
- ? (this.database
1217
- .prepare("SELECT * FROM workflow_run_queue ORDER BY created_at")
1218
- .all() as WorkflowRunQueueRow[])
1219
- : (this.database
1220
- .prepare("SELECT * FROM workflow_run_queue WHERE status = ? ORDER BY created_at")
1221
- .all(options.status) as WorkflowRunQueueRow[]);
1222
- return rows.map(workflowRunFromRow);
1200
+ const clauses = ["r.project_id = ?"];
1201
+ const params: unknown[] = [this.requireProjectId()];
1202
+ if (options.statuses !== undefined && options.statuses.length > 0) {
1203
+ clauses.push(`q.status IN (${options.statuses.map(() => "?").join(", ")})`);
1204
+ params.push(...options.statuses);
1205
+ }
1206
+ if (options.excludeRunIds !== undefined && options.excludeRunIds.length > 0) {
1207
+ clauses.push(`r.run_id NOT IN (${options.excludeRunIds.map(() => "?").join(", ")})`);
1208
+ params.push(...options.excludeRunIds);
1209
+ }
1210
+ params.push(options.limit ?? 100);
1211
+ const rows = this.state.connection
1212
+ .prepare(
1213
+ workflowRunSelect(`WHERE ${clauses.join(" AND ")} ORDER BY q.created_at DESC LIMIT ?`),
1214
+ )
1215
+ .all(...params);
1216
+ return rows.filter(isRunRow).map((row) => this.mapWorkflowRun(row));
1223
1217
  }
1224
1218
 
1225
1219
  findSessionReservation(sessionId: string): WorkflowRunQueueRecord | undefined {
1226
- validateKey(sessionId, "session id");
1227
- const row = this.database
1220
+ const row = this.state.connection
1228
1221
  .prepare(
1229
- `SELECT * FROM workflow_run_queue
1230
- WHERE origin_session_id = ? AND status IN ('queued', 'starting', 'running')
1231
- ORDER BY created_at LIMIT 1`,
1222
+ workflowRunSelect(
1223
+ "WHERE b.origin_session_id = ? AND q.status NOT IN ('done', 'failed', 'cancelled') ORDER BY q.created_at DESC LIMIT 1",
1224
+ ),
1232
1225
  )
1233
- .get(sessionId) as WorkflowRunQueueRow | undefined;
1234
- return row === undefined ? undefined : workflowRunFromRow(row);
1226
+ .get(sessionId);
1227
+ return isRunRow(row) ? this.mapWorkflowRun(row) : undefined;
1235
1228
  }
1236
1229
 
1237
1230
  claimWorkflowRun(options: {
@@ -1241,157 +1234,130 @@ export class SqliteControllerStore implements ControllerStore {
1241
1234
  leaseMs: number;
1242
1235
  now?: string;
1243
1236
  }): WorkflowRunQueueRecord | undefined {
1244
- validateRunId(options.runId);
1245
- validateKey(options.runnerId, "runner id");
1246
- validateKey(options.claimToken, "claim token");
1247
- validateDuration(options.leaseMs, "leaseMs");
1248
- const now = validTimestamp(options.now);
1249
- const nowMs = epoch(now);
1250
- const expiresAt = nowMs + options.leaseMs;
1251
- const result = this.database
1252
- .prepare(
1253
- `UPDATE workflow_run_queue
1254
- SET status = 'starting', runner_id = ?, claim_token = ?, claim_expires_at = ?,
1255
- started_at = COALESCE(started_at, ?), updated_at = ?
1256
- WHERE run_id = ? AND (
1257
- status IN ('queued', 'parked')
1258
- OR (status = 'starting' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
1259
- )`,
1260
- )
1261
- .run(options.runnerId, options.claimToken, expiresAt, now, now, options.runId, nowMs);
1262
- return result.changes === 1 ? this.requireWorkflowRun(options.runId) : undefined;
1237
+ const now = epoch(validTimestamp(options.now));
1238
+ return this.claimRun(options.runId, options.runnerId, options.claimToken, options.leaseMs, now);
1263
1239
  }
1264
1240
 
1265
1241
  markWorkflowRunRunning(options: { runId: string; claimToken: string; now?: string }): boolean {
1266
- validateRunId(options.runId);
1267
- const now = validTimestamp(options.now);
1268
- const result = this.database
1269
- .prepare(
1270
- `UPDATE workflow_run_queue SET status = 'running', updated_at = ?
1271
- WHERE run_id = ? AND claim_token = ? AND status = 'starting'`,
1272
- )
1273
- .run(now, options.runId, options.claimToken);
1274
- return result.changes === 1;
1242
+ return this.updateClaimedRunStatus(options.runId, options.claimToken, "running", options.now);
1275
1243
  }
1276
1244
 
1277
- /**
1278
- * Claim the oldest claimable run, preferring runs with affinity to this
1279
- * runner. Parked rows are claimable immediately; claimed rows become
1280
- * claimable once their lease expires, so a dead or stalled runner never
1281
- * strands a run. Returns undefined when nothing is claimable.
1282
- */
1283
1245
  claimNextWorkflowRun(options: {
1284
1246
  runnerId: string;
1247
+ sessionId?: string;
1285
1248
  claimToken: string;
1286
1249
  leaseMs: number;
1287
- excludeRunIds?: string[];
1288
- /** Interactive sessions provide their id; detached hosts omit it. */
1289
- sessionId?: string;
1290
1250
  now?: string;
1251
+ excludeRunIds?: string[];
1291
1252
  }): WorkflowRunQueueRecord | undefined {
1292
- validateKey(options.runnerId, "runner id");
1293
- validateKey(options.claimToken, "claim token");
1294
- validateDuration(options.leaseMs, "leaseMs");
1295
- if (options.sessionId !== undefined) validateKey(options.sessionId, "session id");
1296
- const now = validTimestamp(options.now);
1297
- const nowMs = epoch(now);
1298
- const expiresAt = nowMs + options.leaseMs;
1299
- const excluded = options.excludeRunIds ?? [];
1300
- for (const runId of excluded) {
1301
- validateRunId(runId);
1253
+ const now = epoch(validTimestamp(options.now));
1254
+ const clauses = [
1255
+ "r.project_id = ?",
1256
+ "q.status IN ('queued', 'parked', 'starting', 'running')",
1257
+ "q.available_at <= ?",
1258
+ "(l.owner_id IS NULL OR l.expires_at <= ? OR l.owner_id = ?)",
1259
+ "(q.affinity_runner_id IS NULL OR q.affinity_runner_id = ? OR q.status IN ('parked', 'starting', 'running'))",
1260
+ ];
1261
+ const params: unknown[] = [
1262
+ this.requireProjectId(),
1263
+ now,
1264
+ now,
1265
+ options.runnerId,
1266
+ options.runnerId,
1267
+ ];
1268
+ if (options.sessionId !== undefined) {
1269
+ clauses.push("b.origin_session_id = ?");
1270
+ params.push(options.sessionId);
1271
+ } else {
1272
+ clauses.push("(b.execution_mode = 'headless' OR q.status <> 'queued')");
1302
1273
  }
1303
- const exclusion =
1304
- excluded.length === 0 ? "" : `AND run_id NOT IN (${excluded.map(() => "?").join(", ")})`;
1305
- const sessionFilter =
1306
- options.sessionId === undefined
1307
- ? "AND status != 'queued' AND (status != 'starting' OR origin_session_id IS NULL)"
1308
- : "AND (origin_session_id IS NULL OR origin_session_id = ?)";
1309
- const claimable = `(
1310
- status IN ('queued', 'parked')
1311
- OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
1312
- )`;
1313
- return this.transaction(() => {
1314
- const candidate = this.database
1315
- .prepare(
1316
- `SELECT run_id FROM workflow_run_queue
1317
- WHERE ${claimable} ${exclusion} ${sessionFilter}
1318
- ORDER BY
1319
- CASE WHEN affinity_runner_id = ? THEN 0 ELSE 1 END,
1320
- created_at ASC
1321
- LIMIT 1`,
1322
- )
1323
- .get(
1324
- nowMs,
1325
- ...excluded,
1326
- ...(options.sessionId === undefined ? [] : [options.sessionId]),
1327
- options.runnerId,
1328
- ) as { run_id: string } | undefined;
1329
- if (candidate === undefined) {
1330
- return undefined;
1331
- }
1332
- const result = this.database
1333
- .prepare(
1334
- `UPDATE workflow_run_queue
1335
- SET status = 'starting', runner_id = ?, claim_token = ?,
1336
- claim_expires_at = ?, started_at = COALESCE(started_at, ?), updated_at = ?
1337
- WHERE run_id = ? AND (
1338
- status IN ('queued', 'parked')
1339
- OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
1340
- )`,
1341
- )
1342
- .run(options.runnerId, options.claimToken, expiresAt, now, now, candidate.run_id, nowMs);
1343
- return result.changes === 1 ? this.requireWorkflowRun(candidate.run_id) : undefined;
1344
- });
1274
+ if (options.excludeRunIds !== undefined && options.excludeRunIds.length > 0) {
1275
+ clauses.push(`r.run_id NOT IN (${options.excludeRunIds.map(() => "?").join(", ")})`);
1276
+ params.push(...options.excludeRunIds);
1277
+ }
1278
+ const row = this.state.connection
1279
+ .prepare(
1280
+ workflowRunSelect(
1281
+ `WHERE ${clauses.join(" AND ")} ORDER BY q.available_at, q.created_at LIMIT 1`,
1282
+ ),
1283
+ )
1284
+ .get(...params);
1285
+ if (!isRunRow(row)) return undefined;
1286
+ return this.claimRun(row.runId, options.runnerId, options.claimToken, options.leaseMs, now);
1345
1287
  }
1346
1288
 
1347
- /** Extend a live claim. Rejects expired or foreign claims. */
1348
1289
  renewWorkflowRunClaim(options: {
1349
1290
  runId: string;
1350
1291
  claimToken: string;
1351
1292
  leaseMs: number;
1352
1293
  now?: string;
1353
1294
  }): boolean {
1354
- validateRunId(options.runId);
1355
- validateDuration(options.leaseMs, "leaseMs");
1356
- const nowMs = epoch(validTimestamp(options.now));
1357
- const expiresAt = nowMs + options.leaseMs;
1358
- const result = this.database
1295
+ const now = epoch(validTimestamp(options.now));
1296
+ const row = this.workflowRunRow(options.runId);
1297
+ if (row === undefined || row.ownerId === null) return false;
1298
+ const result = this.state.connection
1359
1299
  .prepare(
1360
- `UPDATE workflow_run_queue SET claim_expires_at = ?
1361
- WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')
1362
- AND claim_expires_at > ?`,
1300
+ `UPDATE leases SET heartbeat_at = ?, expires_at = ?
1301
+ WHERE resource_id = ? AND token_hash = ? AND owner_id = ? AND expires_at > ?`,
1363
1302
  )
1364
- .run(expiresAt, options.runId, options.claimToken, nowMs);
1303
+ .run(
1304
+ now,
1305
+ now + options.leaseMs,
1306
+ row.resourceId,
1307
+ tokenHash(options.claimToken),
1308
+ row.ownerId,
1309
+ now,
1310
+ );
1365
1311
  return result.changes === 1;
1366
1312
  }
1367
1313
 
1368
- /** The fence check: true only while this exact claim is live. */
1369
1314
  verifyWorkflowRunClaim(options: { runId: string; claimToken: string; now?: string }): boolean {
1370
- validateRunId(options.runId);
1371
- const nowMs = epoch(validTimestamp(options.now));
1372
- const row = this.database
1373
- .prepare(
1374
- `SELECT 1 AS live FROM workflow_run_queue
1375
- WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')
1376
- AND claim_expires_at > ?`,
1377
- )
1378
- .get(options.runId, options.claimToken, nowMs);
1379
- return row !== undefined;
1315
+ const now = epoch(validTimestamp(options.now));
1316
+ const row = this.workflowRunRow(options.runId);
1317
+ if (row === undefined || row.ownerId === null || row.claimExpiresAt === null) return false;
1318
+ const lease = this.requireLease(row.resourceId);
1319
+ return (
1320
+ lease.tokenHash !== null &&
1321
+ lease.tokenHash.equals(tokenHash(options.claimToken)) &&
1322
+ row.claimExpiresAt > now
1323
+ );
1324
+ }
1325
+
1326
+ workflowRunAuthority(
1327
+ runId: string,
1328
+ claimToken: string,
1329
+ ):
1330
+ | {
1331
+ actor: { type: "session" | "host"; id: string };
1332
+ ownerType: "session" | "host";
1333
+ ownerId: string;
1334
+ token: string;
1335
+ generation: number;
1336
+ }
1337
+ | undefined {
1338
+ const row = this.workflowRunRow(runId);
1339
+ if (
1340
+ row === undefined ||
1341
+ row.ownerId === null ||
1342
+ row.claimExpiresAt === null ||
1343
+ row.claimExpiresAt <= Date.now()
1344
+ )
1345
+ return undefined;
1346
+ const lease = this.requireLease(row.resourceId);
1347
+ if (lease.tokenHash === null || !lease.tokenHash.equals(tokenHash(claimToken)))
1348
+ return undefined;
1349
+ const ownerType = row.ownerId.startsWith("host-") ? "host" : "session";
1350
+ return {
1351
+ actor: { type: ownerType, id: row.ownerId },
1352
+ ownerType,
1353
+ ownerId: row.ownerId,
1354
+ token: claimToken,
1355
+ generation: row.leaseGeneration,
1356
+ };
1380
1357
  }
1381
1358
 
1382
- /** Release a claim and park the run so another runner can resume it. */
1383
1359
  parkWorkflowRun(options: { runId: string; claimToken: string; now?: string }): boolean {
1384
- validateRunId(options.runId);
1385
- const now = validTimestamp(options.now);
1386
- const result = this.database
1387
- .prepare(
1388
- `UPDATE workflow_run_queue
1389
- SET status = 'parked', runner_id = NULL, claim_token = NULL,
1390
- claim_expires_at = NULL, updated_at = ?
1391
- WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`,
1392
- )
1393
- .run(now, options.runId, options.claimToken);
1394
- return result.changes === 1;
1360
+ return this.releaseRunClaim(options.runId, options.claimToken, "parked", options.now);
1395
1361
  }
1396
1362
 
1397
1363
  failWorkflowRun(options: {
@@ -1401,250 +1367,118 @@ export class SqliteControllerStore implements ControllerStore {
1401
1367
  errorMessage: string;
1402
1368
  now?: string;
1403
1369
  }): boolean {
1404
- validateRunId(options.runId);
1405
- validateKey(options.errorCode, "workflow launch error code");
1406
- const message = options.errorMessage.trim();
1407
- if (message.length === 0 || Buffer.byteLength(message) > 2_048) {
1408
- throw new Error("Workflow launch error message must be between 1 and 2048 bytes");
1409
- }
1410
- const now = validTimestamp(options.now);
1411
- const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
1412
- const result = this.database
1413
- .prepare(
1414
- `UPDATE workflow_run_queue
1415
- SET status = 'failed', runner_id = NULL, claim_token = NULL,
1416
- claim_expires_at = NULL, parent_run_id = NULL, input_json = 'null',
1417
- launch_options_json = '{}', error_code = ?, error_message = ?,
1418
- finished_at = ?, updated_at = ?
1419
- WHERE run_id = ? AND status IN ('queued', 'starting', 'running') ${claimFilter}`,
1420
- )
1421
- .run(
1422
- options.errorCode,
1423
- message,
1424
- now,
1425
- now,
1426
- options.runId,
1427
- ...(options.claimToken === undefined ? [] : [options.claimToken]),
1428
- );
1429
- return result.changes === 1;
1370
+ return this.terminalRun(
1371
+ options.runId,
1372
+ options.claimToken,
1373
+ "failed",
1374
+ options.errorCode,
1375
+ options.errorMessage,
1376
+ options.now,
1377
+ );
1430
1378
  }
1431
1379
 
1432
1380
  cancelWorkflowRun(options: { runId: string; claimToken?: string; now?: string }): boolean {
1433
- validateRunId(options.runId);
1434
- const now = validTimestamp(options.now);
1435
- const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
1436
- const result = this.database
1437
- .prepare(
1438
- `UPDATE workflow_run_queue
1439
- SET status = 'cancelled', runner_id = NULL, claim_token = NULL,
1440
- claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
1441
- finished_at = ?, updated_at = ?
1442
- WHERE run_id = ? AND status IN ('queued', 'starting') ${claimFilter}`,
1443
- )
1444
- .run(
1445
- now,
1446
- now,
1447
- options.runId,
1448
- ...(options.claimToken === undefined ? [] : [options.claimToken]),
1449
- );
1450
- return result.changes === 1;
1381
+ return this.terminalRun(
1382
+ options.runId,
1383
+ options.claimToken,
1384
+ "cancelled",
1385
+ "cancelled",
1386
+ "Workflow run cancelled",
1387
+ options.now,
1388
+ );
1451
1389
  }
1452
1390
 
1453
- /**
1454
- * Delete a claimed row. Used when a continuation fails before its bundle
1455
- * exists, so the parent's one-continuation slot is not consumed by a run
1456
- * that never happened.
1457
- */
1458
1391
  deleteWorkflowRun(options: { runId: string; claimToken: string }): boolean {
1459
- validateRunId(options.runId);
1460
- const result = this.database
1461
- .prepare("DELETE FROM workflow_run_queue WHERE run_id = ? AND claim_token = ?")
1462
- .run(options.runId, options.claimToken);
1463
- return result.changes === 1;
1392
+ const now = Date.now();
1393
+ return this.state.transaction(() => {
1394
+ const row = this.workflowRunRow(options.runId);
1395
+ if (row === undefined) return false;
1396
+ const lease = this.requireLease(row.resourceId);
1397
+ if (
1398
+ lease.ownerId === null ||
1399
+ lease.tokenHash === null ||
1400
+ lease.expiresAt === null ||
1401
+ lease.expiresAt <= now ||
1402
+ !lease.tokenHash.equals(tokenHash(options.claimToken))
1403
+ ) {
1404
+ return false;
1405
+ }
1406
+ return (
1407
+ this.state.connection
1408
+ .prepare("DELETE FROM resources WHERE resource_id = ?")
1409
+ .run(row.resourceId).changes === 1
1410
+ );
1411
+ });
1464
1412
  }
1465
1413
 
1466
- /** Release a claim and mark the run terminal in the queue. */
1467
1414
  completeWorkflowRun(options: { runId: string; claimToken: string; now?: string }): boolean {
1468
- validateRunId(options.runId);
1469
- const now = validTimestamp(options.now);
1470
- const result = this.database
1471
- .prepare(
1472
- `UPDATE workflow_run_queue
1473
- SET status = 'done', runner_id = NULL, claim_token = NULL,
1474
- claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
1475
- finished_at = ?, updated_at = ?
1476
- WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`,
1477
- )
1478
- .run(now, now, options.runId, options.claimToken);
1479
- return result.changes === 1;
1415
+ return this.releaseRunClaim(options.runId, options.claimToken, "done", options.now);
1480
1416
  }
1481
1417
 
1482
- /** Repair a canonical bundle's queue source and claim it only when needed. */
1483
- repairCanonicalWorkflowSourceRun(options: {
1484
- runId: string;
1485
- workflowName: string;
1486
- workflowSourceRef: string;
1487
- runnerId: string;
1488
- claimToken: string;
1489
- leaseMs: number;
1490
- now?: string;
1491
- }): "unchanged" | "claimed" | false {
1492
- validateRunId(options.runId);
1493
- validateKey(options.workflowName, "workflow name");
1494
- validateKey(options.workflowSourceRef, "workflow source ref");
1495
- validateKey(options.runnerId, "runner id");
1496
- validateKey(options.claimToken, "claim token");
1497
- validateDuration(options.leaseMs, "leaseMs");
1498
- const now = validTimestamp(options.now);
1499
- const nowMs = epoch(now);
1500
- const expiresAt = nowMs + options.leaseMs;
1501
- return this.transaction(() => {
1502
- const row = this.database
1503
- .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
1504
- .get(options.runId) as WorkflowRunQueueRow | undefined;
1505
- if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
1506
- return false;
1507
- }
1508
- if (row.workflow_path === options.workflowSourceRef && row.status === "parked") {
1509
- return "unchanged";
1510
- }
1511
- const claimable =
1512
- row.status === "parked" ||
1513
- (row.status === "starting" &&
1514
- row.claim_token === options.claimToken &&
1515
- row.claim_expires_at !== null &&
1516
- row.claim_expires_at > nowMs) ||
1517
- (row.status === "starting" &&
1518
- row.claim_expires_at !== null &&
1519
- row.claim_expires_at <= nowMs);
1520
- if (!claimable) return false;
1521
- const result = this.database
1522
- .prepare(
1523
- `UPDATE workflow_run_queue
1524
- SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
1525
- claim_token = ?, claim_expires_at = ?, updated_at = ?
1526
- WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`,
1527
- )
1528
- .run(
1529
- options.workflowName,
1530
- options.workflowSourceRef,
1531
- options.runnerId,
1532
- options.claimToken,
1533
- expiresAt,
1534
- now,
1535
- options.runId,
1536
- );
1537
- return result.changes === 1 ? "claimed" : false;
1538
- });
1418
+ repairCanonicalWorkflowSourceRun(): never {
1419
+ throw new Error("Legacy workflow source repair is not available after the SQLite cutover");
1539
1420
  }
1540
1421
 
1541
- /** Claim and rewrite one proved legacy workflow source queue row atomically. */
1542
- claimLegacyWorkflowSourceRun(options: {
1543
- runId: string;
1544
- workflowName: string;
1545
- oldWorkflowPath: string;
1546
- workflowSourceRef: string;
1547
- runnerId: string;
1548
- claimToken: string;
1549
- leaseMs: number;
1550
- now?: string;
1551
- }): boolean {
1552
- validateRunId(options.runId);
1553
- validateKey(options.workflowName, "workflow name");
1554
- validateKey(options.oldWorkflowPath, "legacy workflow path");
1555
- validateKey(options.workflowSourceRef, "workflow source ref");
1556
- validateKey(options.runnerId, "runner id");
1557
- validateKey(options.claimToken, "claim token");
1558
- validateDuration(options.leaseMs, "leaseMs");
1559
- const now = validTimestamp(options.now);
1560
- const nowMs = epoch(now);
1561
- const expiresAt = nowMs + options.leaseMs;
1562
- return this.transaction(() => {
1563
- const row = this.database
1564
- .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
1565
- .get(options.runId) as WorkflowRunQueueRow | undefined;
1566
- if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
1567
- return false;
1568
- }
1569
- const sourceMatches =
1570
- row.workflow_path === options.oldWorkflowPath ||
1571
- row.workflow_path === options.workflowSourceRef;
1572
- const claimable =
1573
- row.status === "parked" ||
1574
- (row.status === "starting" &&
1575
- row.claim_token === options.claimToken &&
1576
- row.claim_expires_at !== null &&
1577
- row.claim_expires_at > nowMs) ||
1578
- (row.status === "starting" &&
1579
- row.claim_expires_at !== null &&
1580
- row.claim_expires_at <= nowMs);
1581
- if (!sourceMatches || !claimable) return false;
1582
- const result = this.database
1583
- .prepare(
1584
- `UPDATE workflow_run_queue
1585
- SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
1586
- claim_token = ?, claim_expires_at = ?, updated_at = ?
1587
- WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`,
1588
- )
1589
- .run(
1590
- options.workflowName,
1591
- options.workflowSourceRef,
1592
- options.runnerId,
1593
- options.claimToken,
1594
- expiresAt,
1595
- now,
1596
- options.runId,
1597
- );
1598
- return result.changes === 1;
1599
- });
1422
+ claimLegacyWorkflowSourceRun(): never {
1423
+ throw new Error("Legacy workflow source claims are not available after the SQLite cutover");
1600
1424
  }
1601
1425
 
1602
- /** Append a run lifecycle transition to the event feed. */
1603
1426
  recordRunEvent(options: {
1604
1427
  runId: string;
1605
1428
  workflowRef: string;
1606
1429
  type: string;
1607
- runnerId?: string;
1608
1430
  payload?: JsonObject;
1431
+ runnerId?: string;
1609
1432
  now?: string;
1610
- }): number {
1611
- validateRunId(options.runId);
1612
- validateKey(options.type, "run event type");
1613
- const payloadJson = canonicalJson(options.payload ?? {}, "run event payload");
1614
- validateJsonSize(payloadJson, "Run event payload", MAX_EVENT_BYTES);
1615
- const result = this.database
1616
- .prepare(
1617
- `INSERT INTO run_events (recorded_at, run_id, workflow_ref, type, runner_id, payload_json)
1618
- VALUES (?, ?, ?, ?, ?, ?)`,
1619
- )
1620
- .run(
1621
- validTimestamp(options.now),
1622
- options.runId,
1623
- options.workflowRef,
1624
- options.type,
1625
- options.runnerId ?? null,
1626
- payloadJson,
1627
- );
1628
- return Number(result.lastInsertRowid);
1433
+ }): RunEventRecord {
1434
+ const row = this.requireWorkflowRunRow(options.runId);
1435
+ const now = epoch(validTimestamp(options.now));
1436
+ const revision = this.resourceRevision(row.resourceId) + 1;
1437
+ this.bumpResource(row.resourceId, revision - 1, now);
1438
+ const eventId = this.insertEvent(
1439
+ row.resourceId,
1440
+ revision,
1441
+ options.type,
1442
+ options.runnerId?.startsWith("host-") ? "host" : "session",
1443
+ options.runnerId ?? null,
1444
+ options.payload ?? {},
1445
+ now,
1446
+ row.leaseGeneration || undefined,
1447
+ );
1448
+ const seq = this.state.connection
1449
+ .prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
1450
+ .get(eventId);
1451
+ /* istanbul ignore if -- exact schema and internal query shape */
1452
+ if (!isSequenceRow(seq)) throw new Error("Run event was not recorded");
1453
+ return {
1454
+ seq: seq.seq,
1455
+ recordedAt: new Date(now).toISOString(),
1456
+ runId: options.runId,
1457
+ workflowRef: options.workflowRef,
1458
+ type: options.type,
1459
+ runnerId: options.runnerId ?? null,
1460
+ payload: options.payload ?? {},
1461
+ };
1629
1462
  }
1630
1463
 
1631
- /** List run events after a watermark, oldest first. */
1632
1464
  listRunEventsAfter(seq: number, options: { limit?: number } = {}): RunEventRecord[] {
1633
- const limit = options.limit ?? 50;
1634
- if (!Number.isSafeInteger(seq) || seq < 0) {
1635
- throw new Error(`Invalid run event watermark: ${seq}`);
1636
- }
1637
- const rows = this.database
1638
- .prepare("SELECT * FROM run_events WHERE seq > ? ORDER BY seq ASC LIMIT ?")
1639
- .all(seq, limit) as RunEventRow[];
1640
- return rows.map((row) => ({
1465
+ const rows = this.state.connection
1466
+ .prepare(
1467
+ `SELECT e.event_seq AS seq, e.recorded_at AS recordedAt, r.run_id AS runId,
1468
+ r.workflow_ref AS workflowRef, e.event_type AS eventType,
1469
+ e.actor_id AS runnerId, e.payload_hash AS payloadHash
1470
+ FROM events e JOIN runs r ON r.resource_id = e.resource_id
1471
+ WHERE e.event_seq > ? ORDER BY e.event_seq LIMIT ?`,
1472
+ )
1473
+ .all(seq, options.limit ?? 100);
1474
+ return rows.filter(isRunEventRow).map((row) => ({
1641
1475
  seq: row.seq,
1642
- recordedAt: row.recorded_at,
1643
- runId: row.run_id,
1644
- workflowRef: row.workflow_ref,
1645
- type: row.type,
1646
- runnerId: row.runner_id,
1647
- payload: parseStoredJson<JsonObject>(row.payload_json, "run event payload"),
1476
+ recordedAt: new Date(row.recordedAt).toISOString(),
1477
+ runId: row.runId,
1478
+ workflowRef: row.workflowRef,
1479
+ type: row.eventType,
1480
+ runnerId: row.runnerId,
1481
+ payload: row.payloadHash === null ? {} : (this.state.readJson(row.payloadHash) as JsonObject),
1648
1482
  }));
1649
1483
  }
1650
1484
 
@@ -1658,85 +1492,90 @@ export class SqliteControllerStore implements ControllerStore {
1658
1492
  nodeId?: string | null;
1659
1493
  attemptId?: string | null;
1660
1494
  fallbackFacts: WorkflowTurnIntentFacts;
1661
- eligible?: boolean;
1662
- now?: string;
1495
+ eligible: boolean;
1496
+ requestedAt?: string;
1663
1497
  }): WorkflowTurnIntentRecord {
1664
- validateKey(options.intentId, "workflow turn intent id");
1665
- validateKey(options.sourceEventId, "workflow turn source event id");
1666
- validateRunId(options.runId);
1667
- validateKey(options.workflowRef, "workflow turn workflow ref");
1668
- validateKey(options.targetSessionId, "workflow turn target session id");
1669
- validateWorkflowTurnIntentCause(options.cause);
1670
- const nodeId = options.nodeId ?? null;
1671
- const attemptId = options.attemptId ?? null;
1672
- if (nodeId !== null) validateKey(nodeId, "workflow turn node id");
1673
- if (attemptId !== null) validateKey(attemptId, "workflow turn attempt id");
1674
- const factsJson = workflowTurnIntentFactsJson(options.fallbackFacts);
1675
- const now = validTimestamp(options.now);
1676
- const eligibleAt = options.eligible === true ? now : null;
1677
- this.database
1678
- .prepare(
1679
- `INSERT INTO workflow_turn_intents (
1680
- intent_id, source_event_id, run_id, workflow_ref, target_session_id,
1681
- cause, node_id, attempt_id, fallback_facts_json, requested_at, eligible_at,
1682
- resolved_at, resolution, resolution_message_id,
1683
- delivery_claim_token, delivery_claim_expires_at
1684
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL)
1685
- ON CONFLICT(intent_id) DO NOTHING`,
1686
- )
1687
- .run(
1688
- options.intentId,
1689
- options.sourceEventId,
1690
- options.runId,
1691
- options.workflowRef,
1498
+ const existing = this.turnIntentBySource(options.sourceEventId);
1499
+ if (existing !== undefined) return existing;
1500
+ const now = epoch(validTimestamp(options.requestedAt));
1501
+ const run = this.requireWorkflowRunRow(options.runId);
1502
+ const intentId = options.intentId;
1503
+ const resourceId = resourceIdFor("turn_intent", intentId);
1504
+ const effectId = `effect-${randomUUID()}`;
1505
+ this.state.transaction(() => {
1506
+ const factsHash = this.state.putJson(options.fallbackFacts, now);
1507
+ this.insertEffectResource(
1508
+ effectId,
1509
+ resourceIdFor("effect", effectId),
1510
+ run.resourceId,
1511
+ this.resourceRevision(run.resourceId),
1512
+ "turn.deliver",
1513
+ intentId,
1514
+ factsHash,
1515
+ "run",
1516
+ now,
1517
+ );
1518
+ this.state.connection
1519
+ .prepare(
1520
+ "INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'turn_intent', ?, 1, ?, ?)",
1521
+ )
1522
+ .run(resourceId, intentId, now, now);
1523
+ this.state.connection
1524
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
1525
+ .run(resourceId);
1526
+ this.state.connection
1527
+ .prepare(
1528
+ `INSERT INTO turn_intents(
1529
+ turn_intent_id, resource_id, effect_id, source_event_id, run_id,
1530
+ workflow_ref, target_session_id, cause, node_id, attempt_id,
1531
+ facts_hash, requested_at, eligible_at, created_at
1532
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1533
+ )
1534
+ .run(
1535
+ intentId,
1536
+ resourceId,
1537
+ effectId,
1538
+ options.sourceEventId,
1539
+ options.runId,
1540
+ options.workflowRef,
1541
+ options.targetSessionId,
1542
+ options.cause,
1543
+ options.nodeId ?? null,
1544
+ options.attemptId ?? null,
1545
+ factsHash,
1546
+ now,
1547
+ options.eligible ? now : null,
1548
+ now,
1549
+ );
1550
+ this.insertEvent(
1551
+ resourceId,
1552
+ 1,
1553
+ "turn.requested",
1554
+ "session",
1692
1555
  options.targetSessionId,
1693
- options.cause,
1694
- nodeId,
1695
- attemptId,
1696
- factsJson,
1556
+ {},
1697
1557
  now,
1698
- eligibleAt,
1699
1558
  );
1700
- const existing = this.getWorkflowTurnIntent(options.intentId);
1701
- if (existing === undefined) {
1702
- throw new Error(`Workflow turn intent was not stored: ${options.intentId}`);
1703
- }
1704
- if (
1705
- existing.sourceEventId !== options.sourceEventId ||
1706
- existing.runId !== options.runId ||
1707
- existing.workflowRef !== options.workflowRef ||
1708
- existing.targetSessionId !== options.targetSessionId ||
1709
- existing.cause !== options.cause ||
1710
- existing.nodeId !== nodeId ||
1711
- existing.attemptId !== attemptId
1712
- ) {
1713
- throw new Error(`Workflow turn intent identity conflict: ${options.intentId}`);
1714
- }
1715
- return existing;
1559
+ });
1560
+ return this.requireTurnIntent(intentId);
1716
1561
  }
1717
1562
 
1718
1563
  getWorkflowTurnIntent(intentId: string): WorkflowTurnIntentRecord | undefined {
1719
- validateKey(intentId, "workflow turn intent id");
1720
- const row = this.database
1721
- .prepare("SELECT * FROM workflow_turn_intents WHERE intent_id = ?")
1722
- .get(intentId) as WorkflowTurnIntentRow | undefined;
1723
- return row === undefined ? undefined : workflowTurnIntentFromRow(row);
1564
+ return this.turnIntent(intentId);
1724
1565
  }
1725
1566
 
1726
1567
  findPendingWorkflowTurnIntent(options: {
1727
1568
  runId: string;
1728
1569
  targetSessionId: string;
1729
1570
  }): WorkflowTurnIntentRecord | undefined {
1730
- validateRunId(options.runId);
1731
- validateKey(options.targetSessionId, "workflow turn target session id");
1732
- const row = this.database
1571
+ const row = this.state.connection
1733
1572
  .prepare(
1734
- `SELECT * FROM workflow_turn_intents
1735
- WHERE run_id = ? AND target_session_id = ? AND resolved_at IS NULL
1736
- ORDER BY requested_at DESC, intent_id DESC LIMIT 1`,
1573
+ turnIntentSelect(
1574
+ "WHERE t.run_id = ? AND t.target_session_id = ? AND t.resolved_at IS NULL ORDER BY t.requested_at DESC LIMIT 1",
1575
+ ),
1737
1576
  )
1738
- .get(options.runId, options.targetSessionId) as WorkflowTurnIntentRow | undefined;
1739
- return row === undefined ? undefined : workflowTurnIntentFromRow(row);
1577
+ .get(options.runId, options.targetSessionId);
1578
+ return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
1740
1579
  }
1741
1580
 
1742
1581
  claimWorkflowTurnIntent(options: {
@@ -1746,101 +1585,60 @@ export class SqliteControllerStore implements ControllerStore {
1746
1585
  leaseMs: number;
1747
1586
  now?: string;
1748
1587
  }): WorkflowTurnIntentRecord | undefined {
1749
- validateKey(options.intentId, "workflow turn intent id");
1750
- validateKey(options.targetSessionId, "workflow turn target session id");
1751
- validateKey(options.claimToken, "workflow turn claim token");
1752
- validateDuration(options.leaseMs, "leaseMs");
1753
- const nowMs = epoch(validTimestamp(options.now));
1754
- const result = this.database
1755
- .prepare(
1756
- `UPDATE workflow_turn_intents
1757
- SET delivery_claim_token = ?, delivery_claim_expires_at = ?
1758
- WHERE intent_id = ? AND target_session_id = ? AND resolved_at IS NULL
1759
- AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`,
1760
- )
1761
- .run(
1762
- options.claimToken,
1763
- nowMs + options.leaseMs,
1764
- options.intentId,
1765
- options.targetSessionId,
1766
- nowMs,
1767
- );
1768
- if (result.changes !== 1) return undefined;
1769
- const row = this.database
1770
- .prepare("SELECT * FROM workflow_turn_intents WHERE intent_id = ?")
1771
- .get(options.intentId) as WorkflowTurnIntentRow;
1772
- return workflowTurnIntentFromRow(row);
1588
+ const intent = this.turnIntent(options.intentId);
1589
+ if (intent?.targetSessionId !== options.targetSessionId) return undefined;
1590
+ return this.claimEffectForTurn(
1591
+ options.intentId,
1592
+ options.claimToken,
1593
+ options.leaseMs,
1594
+ options.now,
1595
+ )
1596
+ ? this.requireTurnIntent(options.intentId)
1597
+ : undefined;
1773
1598
  }
1774
1599
 
1775
1600
  claimEligibleWorkflowTurnIntents(options: {
1776
1601
  targetSessionId: string;
1777
1602
  claimToken: string;
1778
1603
  leaseMs: number;
1779
- limit?: number;
1780
1604
  now?: string;
1605
+ limit?: number;
1781
1606
  }): WorkflowTurnIntentRecord[] {
1782
- validateKey(options.targetSessionId, "workflow turn target session id");
1783
- validateKey(options.claimToken, "workflow turn claim token");
1784
- validateDuration(options.leaseMs, "leaseMs");
1785
- const limit = options.limit ?? 20;
1786
- if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
1787
- throw new Error("Workflow turn intent limit must be an integer from 1 through 100");
1607
+ const now = epoch(validTimestamp(options.now));
1608
+ const rows = this.state.connection
1609
+ .prepare(
1610
+ turnIntentSelect(
1611
+ "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 ?",
1612
+ ),
1613
+ )
1614
+ .all(options.targetSessionId, now, options.limit ?? 10);
1615
+ const claimed: WorkflowTurnIntentRecord[] = [];
1616
+ for (const row of rows) {
1617
+ if (
1618
+ isTurnIntentRow(row) &&
1619
+ this.claimEffectForTurn(row.intentId, options.claimToken, options.leaseMs, options.now)
1620
+ ) {
1621
+ claimed.push(this.requireTurnIntent(row.intentId));
1622
+ }
1788
1623
  }
1789
- const now = validTimestamp(options.now);
1790
- const nowMs = epoch(now);
1791
- return this.transaction(() => {
1792
- const ids = this.database
1793
- .prepare(
1794
- `SELECT intent_id FROM workflow_turn_intents
1795
- WHERE target_session_id = ? AND resolved_at IS NULL AND eligible_at IS NOT NULL
1796
- AND eligible_at <= ?
1797
- AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)
1798
- ORDER BY eligible_at, intent_id LIMIT ?`,
1799
- )
1800
- .all(options.targetSessionId, now, nowMs, limit) as { intent_id: string }[];
1801
- if (ids.length === 0) return [];
1802
- const placeholders = ids.map(() => "?").join(", ");
1803
- this.database
1804
- .prepare(
1805
- `UPDATE workflow_turn_intents
1806
- SET delivery_claim_token = ?, delivery_claim_expires_at = ?
1807
- WHERE intent_id IN (${placeholders}) AND resolved_at IS NULL
1808
- AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`,
1809
- )
1810
- .run(
1811
- options.claimToken,
1812
- nowMs + options.leaseMs,
1813
- ...ids.map((row) => row.intent_id),
1814
- nowMs,
1815
- );
1816
- const rows = this.database
1817
- .prepare(
1818
- `SELECT * FROM workflow_turn_intents WHERE delivery_claim_token = ?
1819
- ORDER BY eligible_at, intent_id`,
1820
- )
1821
- .all(options.claimToken) as WorkflowTurnIntentRow[];
1822
- return rows.map(workflowTurnIntentFromRow);
1823
- });
1624
+ return claimed;
1824
1625
  }
1825
1626
 
1826
1627
  makeWorkflowTurnIntentEligible(options: {
1827
1628
  intentId: string;
1828
1629
  fallbackFacts: WorkflowTurnIntentFacts;
1630
+ eligibleAt?: string;
1829
1631
  now?: string;
1830
1632
  }): boolean {
1831
- validateKey(options.intentId, "workflow turn intent id");
1832
- const result = this.database
1833
- .prepare(
1834
- `UPDATE workflow_turn_intents
1835
- SET fallback_facts_json = ?, eligible_at = COALESCE(eligible_at, ?)
1836
- WHERE intent_id = ? AND resolved_at IS NULL`,
1837
- )
1838
- .run(
1839
- workflowTurnIntentFactsJson(options.fallbackFacts),
1840
- validTimestamp(options.now),
1841
- options.intentId,
1842
- );
1843
- return result.changes === 1;
1633
+ const eligibleAt = epoch(validTimestamp(options.eligibleAt ?? options.now));
1634
+ const factsHash = this.state.putJson(options.fallbackFacts, eligibleAt);
1635
+ return (
1636
+ this.state.connection
1637
+ .prepare(
1638
+ "UPDATE turn_intents SET eligible_at = ?, facts_hash = ? WHERE turn_intent_id = ? AND resolved_at IS NULL",
1639
+ )
1640
+ .run(eligibleAt, factsHash, options.intentId).changes === 1
1641
+ );
1844
1642
  }
1845
1643
 
1846
1644
  resolveWorkflowTurnIntent(options: {
@@ -1848,31 +1646,28 @@ export class SqliteControllerStore implements ControllerStore {
1848
1646
  targetSessionId: string;
1849
1647
  claimToken: string;
1850
1648
  resolution: WorkflowTurnIntentResolution;
1851
- messageId: string;
1649
+ messageId?: string;
1852
1650
  now?: string;
1853
1651
  }): boolean {
1854
- validateKey(options.intentId, "workflow turn intent id");
1855
- validateKey(options.targetSessionId, "workflow turn target session id");
1856
- validateKey(options.claimToken, "workflow turn claim token");
1857
- validateWorkflowTurnIntentResolution(options.resolution);
1858
- validateKey(options.messageId, "workflow turn resolution message id");
1859
- const result = this.database
1860
- .prepare(
1861
- `UPDATE workflow_turn_intents
1862
- SET resolved_at = ?, resolution = ?, resolution_message_id = ?,
1863
- delivery_claim_token = NULL, delivery_claim_expires_at = NULL
1864
- WHERE intent_id = ? AND target_session_id = ? AND resolved_at IS NULL
1865
- AND delivery_claim_token = ?`,
1866
- )
1867
- .run(
1868
- validTimestamp(options.now),
1869
- options.resolution,
1870
- options.messageId,
1871
- options.intentId,
1872
- options.targetSessionId,
1873
- options.claimToken,
1874
- );
1875
- return result.changes === 1;
1652
+ const now = epoch(validTimestamp(options.now));
1653
+ return this.state.transaction(() => {
1654
+ const row = this.turnIntentRow(options.intentId);
1655
+ if (
1656
+ row === undefined ||
1657
+ row.targetSessionId !== options.targetSessionId ||
1658
+ !this.verifyEffectToken(row.effectId, options.claimToken, options.now)
1659
+ ) {
1660
+ return false;
1661
+ }
1662
+ const result = this.state.connection
1663
+ .prepare(
1664
+ `UPDATE turn_intents SET resolved_at = ?, resolution_type = ?, resolution_message_id = ?
1665
+ WHERE turn_intent_id = ? AND resolved_at IS NULL`,
1666
+ )
1667
+ .run(now, options.resolution, options.messageId ?? null, options.intentId);
1668
+ if (result.changes === 1) this.completeEffect(row.effectId, "applied", now);
1669
+ return result.changes === 1;
1670
+ });
1876
1671
  }
1877
1672
 
1878
1673
  releaseWorkflowTurnIntentClaim(options: {
@@ -1880,53 +1675,40 @@ export class SqliteControllerStore implements ControllerStore {
1880
1675
  targetSessionId: string;
1881
1676
  claimToken: string;
1882
1677
  }): boolean {
1883
- validateKey(options.intentId, "workflow turn intent id");
1884
- validateKey(options.targetSessionId, "workflow turn target session id");
1885
- validateKey(options.claimToken, "workflow turn claim token");
1886
- const result = this.database
1887
- .prepare(
1888
- `UPDATE workflow_turn_intents
1889
- SET delivery_claim_token = NULL, delivery_claim_expires_at = NULL
1890
- WHERE intent_id = ? AND target_session_id = ? AND resolved_at IS NULL
1891
- AND delivery_claim_token = ?`,
1892
- )
1893
- .run(options.intentId, options.targetSessionId, options.claimToken);
1894
- return result.changes === 1;
1678
+ const row = this.turnIntentRow(options.intentId);
1679
+ return (
1680
+ row !== undefined &&
1681
+ row.targetSessionId === options.targetSessionId &&
1682
+ this.releaseEffectLease(row.effectId, options.claimToken)
1683
+ );
1895
1684
  }
1896
1685
 
1897
1686
  listWorkflowTurnIntents(
1898
1687
  options: {
1899
- targetSessionId?: string;
1900
1688
  runId?: string;
1689
+ targetSessionId?: string;
1690
+ unresolvedOnly?: boolean;
1901
1691
  limit?: number;
1902
1692
  } = {},
1903
1693
  ): WorkflowTurnIntentRecord[] {
1904
- if (options.targetSessionId !== undefined) {
1905
- validateKey(options.targetSessionId, "workflow turn target session id");
1906
- }
1907
- if (options.runId !== undefined) validateRunId(options.runId);
1908
- const limit = options.limit ?? 20;
1909
- if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
1910
- throw new Error("Workflow turn intent limit must be an integer from 1 through 100");
1694
+ const clauses = ["1 = 1"];
1695
+ const params: unknown[] = [];
1696
+ if (options.runId !== undefined) {
1697
+ clauses.push("t.run_id = ?");
1698
+ params.push(options.runId);
1911
1699
  }
1912
- const clauses: string[] = [];
1913
- const values: unknown[] = [];
1914
1700
  if (options.targetSessionId !== undefined) {
1915
- clauses.push("target_session_id = ?");
1916
- values.push(options.targetSessionId);
1701
+ clauses.push("t.target_session_id = ?");
1702
+ params.push(options.targetSessionId);
1917
1703
  }
1918
- if (options.runId !== undefined) {
1919
- clauses.push("run_id = ?");
1920
- values.push(options.runId);
1921
- }
1922
- const where = clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`;
1923
- const rows = this.database
1704
+ if (options.unresolvedOnly === true) clauses.push("t.resolved_at IS NULL");
1705
+ params.push(options.limit ?? 100);
1706
+ const rows = this.state.connection
1924
1707
  .prepare(
1925
- `SELECT * FROM workflow_turn_intents ${where}
1926
- ORDER BY requested_at DESC, intent_id DESC LIMIT ?`,
1708
+ turnIntentSelect(`WHERE ${clauses.join(" AND ")} ORDER BY t.requested_at DESC LIMIT ?`),
1927
1709
  )
1928
- .all(...values, limit) as WorkflowTurnIntentRow[];
1929
- return rows.map(workflowTurnIntentFromRow);
1710
+ .all(...params);
1711
+ return rows.filter(isTurnIntentRow).map((row) => this.mapTurnIntent(row));
1930
1712
  }
1931
1713
 
1932
1714
  enqueueWorkflowNotification(options: {
@@ -1937,96 +1719,90 @@ export class SqliteControllerStore implements ControllerStore {
1937
1719
  targetSessionId: string;
1938
1720
  kind: "progress" | "final";
1939
1721
  content: string;
1940
- notificationId?: string;
1941
1722
  now?: string;
1942
1723
  }): WorkflowNotificationRecord {
1943
- validateRunId(options.runId);
1944
- validateKey(options.nodeId, "workflow node id");
1945
- validateKey(options.attemptId, "workflow attempt id");
1946
- if (!Number.isSafeInteger(options.notificationIndex) || options.notificationIndex <= 0) {
1947
- throw new Error("Workflow notification index must be a positive safe integer");
1948
- }
1949
- validateKey(options.targetSessionId, "target session id");
1950
- if (options.content.trim().length === 0 || options.content.length > MAX_EVENT_BYTES) {
1951
- throw new Error(`Workflow notification content must be 1-${MAX_EVENT_BYTES} characters`);
1952
- }
1953
- const notificationId = options.notificationId ?? randomUUID();
1954
- validateKey(notificationId, "notification id");
1955
- this.database
1956
- .prepare(
1957
- `INSERT INTO workflow_notifications (
1958
- notification_id, run_id, node_id, attempt_id, notification_index,
1959
- target_session_id, kind, content, created_at, delivered_at
1960
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
1961
- ON CONFLICT(run_id, node_id, notification_index) DO NOTHING`,
1962
- )
1963
- .run(
1724
+ const existing = this.notification(options.runId, options.attemptId, options.notificationIndex);
1725
+ if (existing !== undefined) return existing;
1726
+ const run = this.requireWorkflowRunRow(options.runId);
1727
+ const now = epoch(validTimestamp(options.now));
1728
+ const notificationId = `notification-${randomUUID()}`;
1729
+ const resourceId = resourceIdFor("notification", notificationId);
1730
+ const effectId = `effect-${randomUUID()}`;
1731
+ this.state.transaction(() => {
1732
+ const contentHash = this.state.putText(options.content, now);
1733
+ this.insertEffectResource(
1734
+ effectId,
1735
+ resourceIdFor("effect", effectId),
1736
+ run.resourceId,
1737
+ this.resourceRevision(run.resourceId),
1738
+ "notification.deliver",
1964
1739
  notificationId,
1965
- options.runId,
1966
- options.nodeId,
1967
- options.attemptId,
1968
- options.notificationIndex,
1740
+ contentHash,
1741
+ "run",
1742
+ now,
1743
+ );
1744
+ this.state.connection
1745
+ .prepare(
1746
+ "INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'notification', ?, 1, ?, ?)",
1747
+ )
1748
+ .run(resourceId, notificationId, now, now);
1749
+ this.state.connection
1750
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
1751
+ .run(resourceId);
1752
+ this.state.connection
1753
+ .prepare(
1754
+ `INSERT INTO notifications(
1755
+ notification_id, effect_id, run_id, attempt_id, notification_index,
1756
+ target_session_id, notification_type, content_hash, created_at
1757
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1758
+ )
1759
+ .run(
1760
+ notificationId,
1761
+ effectId,
1762
+ options.runId,
1763
+ options.attemptId,
1764
+ options.notificationIndex,
1765
+ options.targetSessionId,
1766
+ options.kind,
1767
+ contentHash,
1768
+ now,
1769
+ );
1770
+ this.insertEvent(
1771
+ resourceId,
1772
+ 1,
1773
+ "notification.queued",
1774
+ "session",
1969
1775
  options.targetSessionId,
1970
- options.kind,
1971
- options.content.trim(),
1972
- validTimestamp(options.now),
1776
+ {},
1777
+ now,
1973
1778
  );
1974
- const row = this.database
1975
- .prepare(
1976
- "SELECT * FROM workflow_notifications WHERE run_id = ? AND node_id = ? AND notification_index = ?",
1977
- )
1978
- .get(options.runId, options.nodeId, options.notificationIndex) as WorkflowNotificationRow;
1979
- return workflowNotificationFromRow(row);
1779
+ });
1780
+ return this.requireNotification(options.runId, options.attemptId, options.notificationIndex);
1980
1781
  }
1981
1782
 
1982
1783
  claimPendingWorkflowNotifications(options: {
1983
1784
  targetSessionId: string;
1984
1785
  claimToken: string;
1985
1786
  leaseMs: number;
1986
- limit?: number;
1987
1787
  now?: string;
1788
+ limit?: number;
1988
1789
  }): WorkflowNotificationRecord[] {
1989
- validateKey(options.targetSessionId, "target session id");
1990
- validateKey(options.claimToken, "notification claim token");
1991
- validateDuration(options.leaseMs, "notification leaseMs");
1992
- const limit = options.limit ?? 20;
1993
- if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
1994
- throw new Error("Workflow notification limit must be an integer from 1 through 100");
1995
- }
1996
- const now = validTimestamp(options.now);
1997
- const nowMs = epoch(now);
1998
- return this.transaction(() => {
1999
- const ids = this.database
2000
- .prepare(
2001
- `SELECT notification_id FROM workflow_notifications
2002
- WHERE target_session_id = ? AND delivered_at IS NULL
2003
- AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)
2004
- ORDER BY created_at, notification_id LIMIT ?`,
2005
- )
2006
- .all(options.targetSessionId, nowMs, limit) as { notification_id: string }[];
2007
- if (ids.length === 0) return [];
2008
- const placeholders = ids.map(() => "?").join(", ");
2009
- this.database
2010
- .prepare(
2011
- `UPDATE workflow_notifications
2012
- SET delivery_claim_token = ?, delivery_claim_expires_at = ?
2013
- WHERE notification_id IN (${placeholders}) AND delivered_at IS NULL
2014
- AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`,
2015
- )
2016
- .run(
1790
+ const rows = this.notificationRows(options.targetSessionId, options.limit ?? 20);
1791
+ const result: WorkflowNotificationRecord[] = [];
1792
+ for (const row of rows) {
1793
+ if (
1794
+ this.claimEffect(
1795
+ row.effectId,
1796
+ options.targetSessionId,
2017
1797
  options.claimToken,
2018
- nowMs + options.leaseMs,
2019
- ...ids.map((row) => row.notification_id),
2020
- nowMs,
2021
- );
2022
- const rows = this.database
2023
- .prepare(
2024
- `SELECT * FROM workflow_notifications WHERE delivery_claim_token = ?
2025
- ORDER BY created_at, notification_id`,
1798
+ options.leaseMs,
1799
+ options.now,
2026
1800
  )
2027
- .all(options.claimToken) as WorkflowNotificationRow[];
2028
- return rows.map(workflowNotificationFromRow);
2029
- });
1801
+ ) {
1802
+ result.push(this.mapNotification(row));
1803
+ }
1804
+ }
1805
+ return result;
2030
1806
  }
2031
1807
 
2032
1808
  markWorkflowNotificationDelivered(options: {
@@ -2035,530 +1811,1226 @@ export class SqliteControllerStore implements ControllerStore {
2035
1811
  claimToken: string;
2036
1812
  now?: string;
2037
1813
  }): boolean {
2038
- validateKey(options.notificationId, "notification id");
2039
- validateKey(options.targetSessionId, "target session id");
2040
- validateKey(options.claimToken, "notification claim token");
2041
- const result = this.database
1814
+ const now = epoch(validTimestamp(options.now));
1815
+ return this.state.transaction(() => {
1816
+ const row = this.notificationRowById(options.notificationId);
1817
+ if (
1818
+ row === undefined ||
1819
+ row.targetSessionId !== options.targetSessionId ||
1820
+ !this.verifyEffectToken(row.effectId, options.claimToken, options.now)
1821
+ ) {
1822
+ return false;
1823
+ }
1824
+ this.completeEffect(row.effectId, "applied", now);
1825
+ return true;
1826
+ });
1827
+ }
1828
+
1829
+ settleRunEffect(runId: string, effectType: "run.park_queue" | "run.settle_queue"): void {
1830
+ const rows = this.state.connection
2042
1831
  .prepare(
2043
- `UPDATE workflow_notifications
2044
- SET delivered_at = ?, delivery_claim_token = NULL, delivery_claim_expires_at = NULL
2045
- WHERE notification_id = ? AND target_session_id = ?
2046
- AND delivery_claim_token = ? AND delivered_at IS NULL`,
1832
+ `SELECT e.effect_id AS effectId, e.resource_id AS resourceId
1833
+ FROM effects e JOIN runs r ON r.resource_id = e.source_resource_id
1834
+ WHERE r.run_id = ? AND e.effect_type = ? AND e.status = 'pending'`,
2047
1835
  )
2048
- .run(
2049
- validTimestamp(options.now),
2050
- options.notificationId,
2051
- options.targetSessionId,
2052
- options.claimToken,
2053
- );
2054
- return result.changes === 1;
1836
+ .all(runId, effectType);
1837
+ for (const row of rows) {
1838
+ /* istanbul ignore if -- exact schema and internal query shape */
1839
+ if (!isEffectIdentityRow(row)) continue;
1840
+ this.state.transaction(() => {
1841
+ const now = Date.now();
1842
+ const revision = this.resourceRevision(row.resourceId);
1843
+ this.state.connection
1844
+ .prepare(
1845
+ `UPDATE effects SET status = 'applied', updated_at = ?, settled_at = ?
1846
+ WHERE effect_id = ? AND status = 'pending'`,
1847
+ )
1848
+ .run(now, now, row.effectId);
1849
+ this.bumpResource(row.resourceId, revision, now);
1850
+ this.insertEvent(
1851
+ row.resourceId,
1852
+ revision + 1,
1853
+ "effect.applied",
1854
+ "system",
1855
+ null,
1856
+ { runId, effectType },
1857
+ now,
1858
+ );
1859
+ });
1860
+ }
2055
1861
  }
2056
1862
 
2057
1863
  setWorkflowRunOriginSession(runId: string, originSessionId: string): boolean {
2058
- validateRunId(runId);
2059
- validateKey(originSessionId, "origin session id");
2060
- const result = this.database
1864
+ const now = Date.now();
1865
+ return (
1866
+ this.state.connection
1867
+ .prepare(
1868
+ `INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
1869
+ VALUES (?, ?, 'interactive', ?)
1870
+ ON CONFLICT(run_id) DO UPDATE SET origin_session_id = excluded.origin_session_id`,
1871
+ )
1872
+ .run(runId, originSessionId, now).changes === 1
1873
+ );
1874
+ }
1875
+
1876
+ private ensureProject(projectPath: string): string {
1877
+ const canonicalPath = canonicalProjectPath(projectPath);
1878
+ const projectId = projectIdFor(canonicalPath);
1879
+ this.state.connection
2061
1880
  .prepare(
2062
- `UPDATE workflow_run_queue SET origin_session_id = ?
2063
- WHERE run_id = ? AND origin_session_id IS NULL`,
1881
+ `INSERT INTO projects(project_id, canonical_path, created_at)
1882
+ VALUES (?, ?, ?) ON CONFLICT(project_id) DO NOTHING`,
2064
1883
  )
2065
- .run(originSessionId, runId);
2066
- return result.changes === 1;
1884
+ .run(projectId, canonicalPath, Date.now());
1885
+ return projectId;
2067
1886
  }
2068
1887
 
2069
- private requireWorkflowRun(runId: string): WorkflowRunQueueRecord {
2070
- const record = this.getWorkflowRun(runId);
2071
- if (record === undefined) {
2072
- throw new Error(`Workflow run queue record not found: ${runId}`);
1888
+ private findProject(projectPath: string): string | null {
1889
+ const row = this.state.connection
1890
+ .prepare("SELECT project_id AS projectId FROM projects WHERE canonical_path = ?")
1891
+ .get(canonicalProjectPath(projectPath));
1892
+ return isProjectRow(row) ? row.projectId : null;
1893
+ }
1894
+
1895
+ private requireProjectId(): string {
1896
+ if (this.projectId === null)
1897
+ throw new Error("Controller project is not registered in the state database");
1898
+ return this.projectId;
1899
+ }
1900
+
1901
+ private controllerRow(ref: ControllerResourceRef): ControllerRow | undefined {
1902
+ if (this.projectId === null) return undefined;
1903
+ const row = this.state.connection
1904
+ .prepare(
1905
+ controllerSelect("WHERE c.project_id = ? AND c.controller_name = ? AND c.resource_key = ?"),
1906
+ )
1907
+ .get(this.projectId, ref.controller, ref.key);
1908
+ return isControllerRow(row) ? row : undefined;
1909
+ }
1910
+
1911
+ private requireControllerRow(ref: ControllerResourceRef): ControllerRow {
1912
+ const row = this.controllerRow(ref);
1913
+ if (row === undefined) throw new ResourceNotFoundError(ref.controller, ref.key);
1914
+ return row;
1915
+ }
1916
+
1917
+ private requireControllerRowByUid(uid: string): ControllerRow {
1918
+ const row = this.state.connection.prepare(controllerSelect("WHERE c.uid = ?")).get(uid);
1919
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
1920
+ if (!isControllerRow(row)) throw new Error(`Controller resource not found: ${uid}`);
1921
+ return row;
1922
+ }
1923
+
1924
+ private requireResource(ref: ControllerResourceRef): ControllerResource {
1925
+ const resource = this.getResource(ref);
1926
+ if (resource === undefined) throw new ResourceNotFoundError(ref.controller, ref.key);
1927
+ return resource;
1928
+ }
1929
+
1930
+ /* istanbul ignore next -- pure projection covered by integration tests */
1931
+ private mapControllerResource(row: ControllerRow): ControllerResource {
1932
+ const spec = this.state.readJson(row.specHash);
1933
+ const status = this.state.readJson(row.statusHash) as ControllerResourceStatus<unknown>;
1934
+ return {
1935
+ metadata: {
1936
+ uid: row.uid,
1937
+ controller: row.controllerName,
1938
+ key: row.resourceKey,
1939
+ resourceVersion: row.resourceVersion,
1940
+ generation: row.generation,
1941
+ ...(row.deletionRequestedAt === null
1942
+ ? {}
1943
+ : { deletionTimestamp: new Date(row.deletionRequestedAt).toISOString() }),
1944
+ finalizers: this.finalizers(row.controllerResourceId),
1945
+ },
1946
+ spec,
1947
+ status,
1948
+ };
1949
+ }
1950
+
1951
+ private finalizers(controllerResourceId: string): string[] {
1952
+ const rows = this.state.connection
1953
+ .prepare(
1954
+ "SELECT finalizer FROM controller_finalizers WHERE controller_resource_id = ? ORDER BY position",
1955
+ )
1956
+ .all(controllerResourceId);
1957
+ return rows.flatMap((row) => (isFinalizerRow(row) ? [row.finalizer] : []));
1958
+ }
1959
+
1960
+ private replaceFinalizers(controllerResourceId: string, finalizers: string[]): void {
1961
+ const unique = [...new Set(finalizers)];
1962
+ if (unique.length !== finalizers.length)
1963
+ throw new Error("Controller finalizers must be unique");
1964
+ this.state.connection
1965
+ .prepare("DELETE FROM controller_finalizers WHERE controller_resource_id = ?")
1966
+ .run(controllerResourceId);
1967
+ const insert = this.state.connection.prepare(
1968
+ "INSERT INTO controller_finalizers(controller_resource_id, finalizer, position) VALUES (?, ?, ?)",
1969
+ );
1970
+ unique.forEach((finalizer, index) => insert.run(controllerResourceId, finalizer, index));
1971
+ }
1972
+
1973
+ private enqueueControllerRow(
1974
+ controllerResourceId: string,
1975
+ availableAt: number,
1976
+ now: number,
1977
+ ): void {
1978
+ this.state.connection
1979
+ .prepare(
1980
+ `INSERT INTO controller_queue(
1981
+ controller_resource_id, available_at, queue_version,
1982
+ consecutive_errors, created_at, updated_at
1983
+ ) VALUES (?, ?, 1, 0, ?, ?)
1984
+ ON CONFLICT(controller_resource_id) DO UPDATE SET
1985
+ available_at = MIN(controller_queue.available_at, excluded.available_at),
1986
+ queue_version = controller_queue.queue_version + 1,
1987
+ updated_at = excluded.updated_at`,
1988
+ )
1989
+ .run(controllerResourceId, availableAt, now, now);
1990
+ }
1991
+
1992
+ private queueErrors(controllerResourceId: string): number {
1993
+ const row = this.state.connection
1994
+ .prepare(
1995
+ "SELECT consecutive_errors AS consecutiveErrors FROM controller_queue WHERE controller_resource_id = ?",
1996
+ )
1997
+ .get(controllerResourceId);
1998
+ return isErrorCountRow(row) ? row.consecutiveErrors : 0;
1999
+ }
2000
+
2001
+ private queueVersion(controllerResourceId: string): number {
2002
+ const row = this.state.connection
2003
+ .prepare(
2004
+ "SELECT queue_version AS queueVersion FROM controller_queue WHERE controller_resource_id = ?",
2005
+ )
2006
+ .get(controllerResourceId);
2007
+ /* istanbul ignore if -- exact schema and internal query shape */
2008
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
2009
+ if (!isQueueVersionRow(row)) throw new Error("Controller queue item is missing");
2010
+ return row.queueVersion;
2011
+ }
2012
+
2013
+ private settleControllerClaim(
2014
+ claim: ControllerQueueClaim,
2015
+ requeue: QueueRequeueOptions | undefined,
2016
+ nowValue?: string,
2017
+ ): boolean {
2018
+ const now = epoch(validTimestamp(nowValue));
2019
+ return this.state.transaction(() => {
2020
+ const row = this.controllerRow({ controller: claim.controller, key: claim.key });
2021
+ if (row === undefined) return false;
2022
+ try {
2023
+ this.assertControllerClaim(row, claim, now, false);
2024
+ } catch {
2025
+ return false;
2026
+ }
2027
+ const currentQueueVersion = this.queueVersion(row.controllerResourceId);
2028
+ if (requeue === undefined) {
2029
+ if (currentQueueVersion === claim.queueVersion) {
2030
+ this.state.connection
2031
+ .prepare("DELETE FROM controller_queue WHERE controller_resource_id = ?")
2032
+ .run(row.controllerResourceId);
2033
+ }
2034
+ } else {
2035
+ const errorHash =
2036
+ requeue.error === undefined ? null : this.state.putText(requeue.error, now);
2037
+ this.state.connection
2038
+ .prepare(
2039
+ `UPDATE controller_queue
2040
+ SET available_at = ?, queue_version = queue_version + 1,
2041
+ consecutive_errors = consecutive_errors + 1,
2042
+ last_error_hash = ?, updated_at = ? WHERE controller_resource_id = ?`,
2043
+ )
2044
+ .run(epoch(requeue.availableAt), errorHash, now, row.controllerResourceId);
2045
+ }
2046
+ this.releaseLease(row.resourceId, claim, now);
2047
+ return true;
2048
+ });
2049
+ }
2050
+
2051
+ private assertControllerClaim(
2052
+ row: ControllerRow,
2053
+ claim: ControllerQueueClaim,
2054
+ now: number,
2055
+ requireUnexpired = true,
2056
+ ): void {
2057
+ if (row.controllerName !== claim.controller || row.resourceKey !== claim.key)
2058
+ throw new Error("Controller claim targets another resource");
2059
+ const lease = this.requireLease(row.resourceId);
2060
+ if (
2061
+ lease.ownerType !== "controller" ||
2062
+ lease.ownerId !== claim.ownerId ||
2063
+ lease.generation !== claim.generation ||
2064
+ lease.tokenHash === null ||
2065
+ !lease.tokenHash.equals(tokenHash(claim.token)) ||
2066
+ (requireUnexpired && (lease.expiresAt === null || lease.expiresAt <= now))
2067
+ ) {
2068
+ throw new Error("Controller claim is stale");
2073
2069
  }
2074
- return record;
2075
2070
  }
2076
2071
 
2077
- private requireEvent(seq: number): ControllerEvent {
2078
- const row = this.database.prepare("SELECT * FROM events WHERE seq = ?").get(seq) as
2079
- | EventRow
2080
- | undefined;
2081
- if (row === undefined) {
2082
- throw new Error(`Controller event not found: ${seq}`);
2072
+ private requireLease(resourceId: string): LeaseRow {
2073
+ const row = this.state.connection
2074
+ .prepare(
2075
+ `SELECT generation, owner_type AS ownerType, owner_id AS ownerId,
2076
+ token_hash AS tokenHash, expires_at AS expiresAt
2077
+ FROM leases WHERE resource_id = ?`,
2078
+ )
2079
+ .get(resourceId);
2080
+ /* istanbul ignore if -- exact schema and internal query shape */
2081
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
2082
+ if (!isLeaseRow(row)) throw new Error(`Lease is missing: ${resourceId}`);
2083
+ return row;
2084
+ }
2085
+
2086
+ private releaseLease(resourceId: string, claim: ControllerQueueClaim, now: number): void {
2087
+ const row = this.controllerRow({ controller: claim.controller, key: claim.key });
2088
+ if (row === undefined) return;
2089
+ const revision = this.resourceRevision(resourceId);
2090
+ const result = this.state.connection
2091
+ .prepare(
2092
+ `UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
2093
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
2094
+ WHERE resource_id = ? AND owner_id = ? AND token_hash = ? AND generation = ?`,
2095
+ )
2096
+ .run(resourceId, claim.ownerId, tokenHash(claim.token), claim.generation);
2097
+ if (result.changes === 1) {
2098
+ this.bumpResource(resourceId, revision, now);
2099
+ this.insertEvent(
2100
+ resourceId,
2101
+ revision + 1,
2102
+ "lease.released",
2103
+ "controller",
2104
+ claim.ownerId,
2105
+ {},
2106
+ now,
2107
+ claim.generation,
2108
+ );
2109
+ claim.resourceVersion = revision + 1;
2083
2110
  }
2084
- return eventFromRow(row);
2085
2111
  }
2086
- }
2087
2112
 
2088
- const SCHEMA_SQL = `
2089
- CREATE TABLE IF NOT EXISTS resources (
2090
- controller TEXT NOT NULL,
2091
- resource_key TEXT NOT NULL,
2092
- uid TEXT NOT NULL UNIQUE,
2093
- resource_version INTEGER NOT NULL CHECK (resource_version > 0),
2094
- generation INTEGER NOT NULL CHECK (generation > 0),
2095
- spec_json TEXT NOT NULL,
2096
- status_json TEXT NOT NULL,
2097
- deletion_timestamp TEXT,
2098
- finalizers_json TEXT NOT NULL,
2099
- updated_at TEXT NOT NULL,
2100
- PRIMARY KEY (controller, resource_key)
2101
- );
2102
-
2103
- CREATE TABLE IF NOT EXISTS queue (
2104
- controller TEXT NOT NULL,
2105
- resource_key TEXT NOT NULL,
2106
- available_at INTEGER NOT NULL,
2107
- version INTEGER NOT NULL CHECK (version > 0),
2108
- consecutive_errors INTEGER NOT NULL CHECK (consecutive_errors >= 0),
2109
- last_error TEXT,
2110
- claim_token TEXT,
2111
- claim_version INTEGER,
2112
- claim_expires_at INTEGER,
2113
- PRIMARY KEY (controller, resource_key),
2114
- FOREIGN KEY (controller, resource_key)
2115
- REFERENCES resources(controller, resource_key) ON DELETE CASCADE
2116
- );
2117
- CREATE INDEX IF NOT EXISTS queue_ready
2118
- ON queue(available_at, controller, resource_key);
2119
-
2120
- CREATE TABLE IF NOT EXISTS workflow_run_queue (
2121
- run_id TEXT PRIMARY KEY,
2122
- workflow_ref TEXT NOT NULL,
2123
- workflow_path TEXT NOT NULL,
2124
- workflow_source_json TEXT NOT NULL,
2125
- definition_digest TEXT NOT NULL,
2126
- input_json TEXT NOT NULL,
2127
- launch_options_json TEXT NOT NULL,
2128
- status TEXT NOT NULL CHECK (
2129
- status IN ('queued', 'starting', 'running', 'parked', 'done', 'failed', 'cancelled')
2130
- ),
2131
- runner_id TEXT,
2132
- claim_token TEXT,
2133
- claim_expires_at INTEGER,
2134
- affinity_runner_id TEXT,
2135
- origin_session_id TEXT,
2136
- parent_run_id TEXT,
2137
- error_code TEXT,
2138
- error_message TEXT,
2139
- created_at TEXT NOT NULL,
2140
- updated_at TEXT NOT NULL,
2141
- started_at TEXT,
2142
- finished_at TEXT
2143
- );
2144
- CREATE INDEX IF NOT EXISTS workflow_run_queue_claimable
2145
- ON workflow_run_queue(status, claim_expires_at);
2146
- CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_session_reservation
2147
- ON workflow_run_queue(origin_session_id)
2148
- WHERE origin_session_id IS NOT NULL AND status IN ('queued', 'starting', 'running');
2149
- -- A checkpointed parent admits exactly one continuation run, across
2150
- -- sessions and processes.
2151
- CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_parent
2152
- ON workflow_run_queue(parent_run_id) WHERE parent_run_id IS NOT NULL;
2153
-
2154
- CREATE TABLE IF NOT EXISTS run_events (
2155
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
2156
- recorded_at TEXT NOT NULL,
2157
- run_id TEXT NOT NULL,
2158
- workflow_ref TEXT NOT NULL,
2159
- type TEXT NOT NULL,
2160
- runner_id TEXT,
2161
- payload_json TEXT NOT NULL
2162
- );
2163
- CREATE INDEX IF NOT EXISTS run_events_run ON run_events(run_id);
2164
-
2165
- CREATE TABLE IF NOT EXISTS workflow_notifications (
2166
- notification_id TEXT PRIMARY KEY,
2167
- run_id TEXT NOT NULL,
2168
- node_id TEXT NOT NULL,
2169
- attempt_id TEXT NOT NULL,
2170
- notification_index INTEGER NOT NULL CHECK (notification_index > 0),
2171
- target_session_id TEXT NOT NULL,
2172
- kind TEXT NOT NULL CHECK (kind IN ('progress', 'final')),
2173
- content TEXT NOT NULL,
2174
- created_at TEXT NOT NULL,
2175
- delivery_claim_token TEXT,
2176
- delivery_claim_expires_at INTEGER,
2177
- delivered_at TEXT,
2178
- UNIQUE (run_id, node_id, notification_index)
2179
- );
2180
- CREATE INDEX IF NOT EXISTS workflow_notifications_pending
2181
- ON workflow_notifications(target_session_id, delivery_claim_expires_at, created_at)
2182
- WHERE delivered_at IS NULL;
2183
-
2184
- CREATE TABLE IF NOT EXISTS workflow_turn_intents (
2185
- intent_id TEXT PRIMARY KEY,
2186
- source_event_id TEXT NOT NULL,
2187
- run_id TEXT NOT NULL,
2188
- workflow_ref TEXT NOT NULL,
2189
- target_session_id TEXT NOT NULL,
2190
- cause TEXT NOT NULL CHECK (
2191
- cause IN ('agentCancelled', 'timedOut', 'failed', 'launchFailed',
2192
- 'controllerInterrupted', 'claimLost')
2193
- ),
2194
- node_id TEXT,
2195
- attempt_id TEXT,
2196
- fallback_facts_json TEXT NOT NULL,
2197
- requested_at TEXT NOT NULL,
2198
- eligible_at TEXT,
2199
- resolved_at TEXT,
2200
- resolution TEXT CHECK (resolution IN ('workflowPrompt', 'presentation', 'fallback')),
2201
- resolution_message_id TEXT,
2202
- delivery_claim_token TEXT,
2203
- delivery_claim_expires_at INTEGER,
2204
- CHECK (
2205
- (resolved_at IS NULL AND resolution IS NULL AND resolution_message_id IS NULL) OR
2206
- (resolved_at IS NOT NULL AND resolution IS NOT NULL AND resolution_message_id IS NOT NULL)
2207
- )
2208
- );
2209
- CREATE UNIQUE INDEX IF NOT EXISTS workflow_turn_intents_one_pending_run
2210
- ON workflow_turn_intents(run_id, target_session_id) WHERE resolved_at IS NULL;
2211
- CREATE INDEX IF NOT EXISTS workflow_turn_intents_pending_run
2212
- ON workflow_turn_intents(run_id, target_session_id, requested_at)
2213
- WHERE resolved_at IS NULL;
2214
- CREATE INDEX IF NOT EXISTS workflow_turn_intents_eligible_session
2215
- ON workflow_turn_intents(target_session_id, eligible_at, intent_id)
2216
- WHERE resolved_at IS NULL AND eligible_at IS NOT NULL;
2217
-
2218
- CREATE TABLE IF NOT EXISTS effects (
2219
- effect_key TEXT NOT NULL,
2220
- resource_uid TEXT NOT NULL,
2221
- generation INTEGER NOT NULL CHECK (generation > 0),
2222
- kind TEXT NOT NULL,
2223
- state TEXT NOT NULL CHECK (state IN ('pending', 'applied', 'rejected', 'indeterminate')),
2224
- request_fingerprint TEXT NOT NULL,
2225
- started_at TEXT NOT NULL,
2226
- completed_at TEXT,
2227
- external_ref TEXT,
2228
- error TEXT,
2229
- PRIMARY KEY (resource_uid, effect_key),
2230
- FOREIGN KEY (resource_uid) REFERENCES resources(uid) ON DELETE CASCADE
2231
- );
2232
-
2233
- CREATE TABLE IF NOT EXISTS workflow_requests (
2234
- request_id TEXT PRIMARY KEY,
2235
- resource_uid TEXT NOT NULL,
2236
- request_key TEXT NOT NULL,
2237
- input_fingerprint TEXT NOT NULL,
2238
- workflow TEXT NOT NULL,
2239
- run_id TEXT,
2240
- state TEXT NOT NULL CHECK (
2241
- state IN ('pending', 'running', 'waiting', 'succeeded', 'failed', 'interrupted')
2242
- ),
2243
- attempt INTEGER NOT NULL CHECK (attempt >= 0),
2244
- error TEXT,
2245
- UNIQUE (resource_uid, request_key),
2246
- FOREIGN KEY (resource_uid) REFERENCES resources(uid) ON DELETE CASCADE
2247
- );
2248
-
2249
- CREATE TABLE IF NOT EXISTS events (
2250
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
2251
- recorded_at TEXT NOT NULL,
2252
- controller TEXT NOT NULL,
2253
- resource_key TEXT NOT NULL,
2254
- type TEXT NOT NULL,
2255
- payload_json TEXT NOT NULL
2256
- );
2257
- CREATE INDEX IF NOT EXISTS events_resource
2258
- ON events(controller, resource_key, seq DESC);
2259
- `;
2260
-
2261
- function resourceFromRow<TSpec, TStatus>(row: ResourceRow): ControllerResource<TSpec, TStatus> {
2262
- const finalizers = parseStoredJson<string[]>(row.finalizers_json, "resource finalizers");
2263
- validateFinalizers(finalizers);
2264
- return {
2265
- metadata: {
2266
- uid: row.uid,
2267
- controller: row.controller,
2268
- key: row.resource_key,
2269
- resourceVersion: row.resource_version,
2270
- generation: row.generation,
2271
- ...(row.deletion_timestamp === null ? {} : { deletionTimestamp: row.deletion_timestamp }),
2272
- finalizers,
2273
- },
2274
- spec: parseStoredJson<TSpec>(row.spec_json, "resource spec"),
2275
- status: parseStoredJson<ControllerResourceStatus<TStatus>>(row.status_json, "resource status"),
2276
- };
2277
- }
2113
+ private resourceRevision(resourceId: string): number {
2114
+ const row = this.state.connection
2115
+ .prepare("SELECT revision FROM resources WHERE resource_id = ?")
2116
+ .get(resourceId);
2117
+ /* istanbul ignore if -- exact schema and internal query shape */
2118
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
2119
+ if (!isRevisionRow(row)) throw new Error(`Resource is missing: ${resourceId}`);
2120
+ return row.revision;
2121
+ }
2278
2122
 
2279
- function effectFromRow(row: EffectRow): EffectRecord {
2280
- return {
2281
- key: row.effect_key,
2282
- resourceUid: row.resource_uid,
2283
- generation: row.generation,
2284
- kind: row.kind,
2285
- state: row.state,
2286
- requestFingerprint: row.request_fingerprint,
2287
- startedAt: row.started_at,
2288
- ...(row.completed_at === null ? {} : { completedAt: row.completed_at }),
2289
- ...(row.external_ref === null ? {} : { externalRef: row.external_ref }),
2290
- ...(row.error === null ? {} : { error: row.error }),
2291
- };
2292
- }
2123
+ private bumpResource(resourceId: string, expectedRevision: number, now: number): void {
2124
+ const result = this.state.connection
2125
+ .prepare(
2126
+ "UPDATE resources SET revision = revision + 1, updated_at = ? WHERE resource_id = ? AND revision = ?",
2127
+ )
2128
+ .run(now, resourceId, expectedRevision);
2129
+ if (result.changes !== 1) throw new Error("Resource revision conflict");
2130
+ }
2131
+
2132
+ private insertEvent(
2133
+ resourceId: string,
2134
+ revision: number,
2135
+ type: string,
2136
+ actorType: string,
2137
+ actorId: string | null,
2138
+ payload: unknown,
2139
+ now: number,
2140
+ leaseGeneration?: number,
2141
+ ): string {
2142
+ const eventId = `event-${randomUUID()}`;
2143
+ const payloadHash = this.state.putJson(payload, now);
2144
+ this.state.connection
2145
+ .prepare(
2146
+ `INSERT INTO events(
2147
+ event_id, resource_id, resource_revision, event_type, actor_type,
2148
+ actor_id, lease_generation, payload_hash, recorded_at
2149
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2150
+ )
2151
+ .run(
2152
+ eventId,
2153
+ resourceId,
2154
+ revision,
2155
+ type,
2156
+ actorType,
2157
+ actorId,
2158
+ leaseGeneration ?? null,
2159
+ payloadHash,
2160
+ now,
2161
+ );
2162
+ return eventId;
2163
+ }
2293
2164
 
2294
- function workflowFromRow(row: WorkflowRow): ChildWorkflowRecord {
2295
- return {
2296
- requestId: row.request_id,
2297
- resourceUid: row.resource_uid,
2298
- requestKey: row.request_key,
2299
- inputFingerprint: row.input_fingerprint,
2300
- workflow: row.workflow,
2301
- state: row.state,
2302
- attempt: row.attempt,
2303
- ...(row.run_id === null ? {} : { runId: row.run_id }),
2304
- ...(row.error === null ? {} : { error: row.error }),
2305
- };
2306
- }
2165
+ private effectRow(resourceUid: string, key: string): EffectRow | undefined {
2166
+ const row = this.state.connection
2167
+ .prepare(effectSelect("WHERE c.uid = ? AND e.idempotency_key = ?"))
2168
+ .get(resourceUid, key);
2169
+ return isEffectRow(row) ? row : undefined;
2170
+ }
2307
2171
 
2308
- function workflowTurnIntentFromRow(row: WorkflowTurnIntentRow): WorkflowTurnIntentRecord {
2309
- const cause = validateWorkflowTurnIntentCause(row.cause);
2310
- const resolution =
2311
- row.resolution === null ? null : validateWorkflowTurnIntentResolution(row.resolution);
2312
- const fallbackFacts = parseStoredJson<unknown>(
2313
- row.fallback_facts_json,
2314
- "workflow turn intent facts",
2315
- );
2316
- validateWorkflowTurnIntentFacts(fallbackFacts);
2317
- validateKey(row.intent_id, "workflow turn intent id");
2318
- validateKey(row.source_event_id, "workflow turn source event id");
2319
- validateRunId(row.run_id);
2320
- validateKey(row.workflow_ref, "workflow turn workflow ref");
2321
- validateKey(row.target_session_id, "workflow turn target session id");
2322
- validTimestamp(row.requested_at);
2323
- if (row.eligible_at !== null) validTimestamp(row.eligible_at);
2324
- if (row.resolved_at !== null) validTimestamp(row.resolved_at);
2325
- const allResolutionFieldsNull =
2326
- row.resolved_at === null && row.resolution === null && row.resolution_message_id === null;
2327
- const allResolutionFieldsSet =
2328
- row.resolved_at !== null && row.resolution !== null && row.resolution_message_id !== null;
2329
- if (!allResolutionFieldsNull && !allResolutionFieldsSet) {
2330
- throw new Error(`Workflow turn intent ${row.intent_id} has inconsistent resolution fields`);
2331
- }
2332
- return {
2333
- intentId: row.intent_id,
2334
- sourceEventId: row.source_event_id,
2335
- runId: row.run_id,
2336
- workflowRef: row.workflow_ref,
2337
- targetSessionId: row.target_session_id,
2338
- cause,
2339
- nodeId: row.node_id,
2340
- attemptId: row.attempt_id,
2341
- fallbackFacts,
2342
- requestedAt: row.requested_at,
2343
- eligibleAt: row.eligible_at,
2344
- resolvedAt: row.resolved_at,
2345
- resolution,
2346
- resolutionMessageId: row.resolution_message_id,
2347
- deliveryClaimExpiresAt:
2348
- row.delivery_claim_expires_at === null ? null : iso(row.delivery_claim_expires_at),
2349
- };
2350
- }
2172
+ private requireEffectRow(resourceUid: string, key: string): EffectRow {
2173
+ const row = this.effectRow(resourceUid, key);
2174
+ if (row === undefined) throw new Error(`Effect record not found: ${resourceUid}/${key}`);
2175
+ return row;
2176
+ }
2351
2177
 
2352
- function workflowNotificationFromRow(row: WorkflowNotificationRow): WorkflowNotificationRecord {
2353
- if (row.kind !== "progress" && row.kind !== "final") {
2354
- throw new Error(
2355
- "Controller store contains an incompatible pending alpha workflow notification. Preserve needed run bundles, then reset the project controller store.",
2356
- );
2178
+ /* istanbul ignore next -- pure projection covered by integration tests */
2179
+ private mapEffect(row: EffectRow): EffectRecord {
2180
+ return {
2181
+ key: this.effectKey(row.effectId),
2182
+ resourceUid: row.resourceUid,
2183
+ generation: row.generation,
2184
+ kind: row.kind,
2185
+ state: controllerEffectState(row.state),
2186
+ requestFingerprint: row.requestFingerprint,
2187
+ startedAt: new Date(row.startedAt).toISOString(),
2188
+ ...(row.completedAt === null ? {} : { completedAt: new Date(row.completedAt).toISOString() }),
2189
+ ...(row.externalRef === null ? {} : { externalRef: row.externalRef }),
2190
+ ...(row.errorHash === null
2191
+ ? {}
2192
+ : { error: this.state.readBlob(row.errorHash)?.content.toString("utf8") ?? "" }),
2193
+ };
2357
2194
  }
2358
- return {
2359
- notificationId: row.notification_id,
2360
- runId: row.run_id,
2361
- nodeId: row.node_id,
2362
- attemptId: row.attempt_id,
2363
- notificationIndex: row.notification_index,
2364
- targetSessionId: row.target_session_id,
2365
- kind: row.kind,
2366
- content: row.content,
2367
- createdAt: row.created_at,
2368
- deliveryClaimExpiresAt:
2369
- row.delivery_claim_expires_at === null ? null : iso(row.delivery_claim_expires_at),
2370
- deliveredAt: row.delivered_at,
2371
- };
2372
- }
2373
2195
 
2374
- function workflowTurnIntentFactsJson(facts: WorkflowTurnIntentFacts): string {
2375
- validateWorkflowTurnIntentFacts(facts);
2376
- const json = canonicalJson(facts, "workflow turn intent facts");
2377
- validateJsonSize(json, "Workflow turn intent facts", MAX_EVENT_BYTES);
2378
- return json;
2379
- }
2196
+ private effectKey(effectId: string): string {
2197
+ const row = this.state.connection
2198
+ .prepare("SELECT idempotency_key AS key FROM effects WHERE effect_id = ?")
2199
+ .get(effectId);
2200
+ /* istanbul ignore if -- exact schema and internal query shape */
2201
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
2202
+ if (!isKeyRow(row)) throw new Error(`Effect is missing: ${effectId}`);
2203
+ return row.key;
2204
+ }
2380
2205
 
2381
- function validateWorkflowTurnIntentFacts(facts: unknown): asserts facts is WorkflowTurnIntentFacts {
2382
- if (facts === null || typeof facts !== "object" || Array.isArray(facts)) {
2383
- throw new Error("Workflow turn intent facts must be an object");
2206
+ private workflowRow(resourceUid: string, requestKey: string): WorkflowRow | undefined {
2207
+ const row = this.state.connection
2208
+ .prepare(workflowSelect("WHERE c.uid = ? AND w.request_key = ?"))
2209
+ .get(resourceUid, requestKey);
2210
+ return isWorkflowRow(row) ? row : undefined;
2211
+ }
2212
+
2213
+ private workflowRowById(requestId: string): WorkflowRow | undefined {
2214
+ const row = this.state.connection
2215
+ .prepare(workflowSelect("WHERE w.request_id = ?"))
2216
+ .get(requestId);
2217
+ return isWorkflowRow(row) ? row : undefined;
2218
+ }
2219
+
2220
+ private requireWorkflowRow(requestId: string): WorkflowRow {
2221
+ const row = this.workflowRowById(requestId);
2222
+ if (row === undefined) throw new Error(`Workflow request not found: ${requestId}`);
2223
+ return row;
2224
+ }
2225
+
2226
+ /* istanbul ignore next -- pure projection covered by integration tests */
2227
+ private mapWorkflow(row: WorkflowRow): ChildWorkflowRecord {
2228
+ return {
2229
+ requestId: row.requestId,
2230
+ resourceUid: row.resourceUid,
2231
+ requestKey: row.requestKey,
2232
+ inputFingerprint: row.inputFingerprint.toString("hex"),
2233
+ workflow: row.workflowName,
2234
+ ...(row.runId === null ? {} : { runId: row.runId }),
2235
+ state: row.status,
2236
+ attempt: row.attemptCount,
2237
+ ...(row.errorHash === null
2238
+ ? {}
2239
+ : { error: this.state.readBlob(row.errorHash)?.content.toString("utf8") ?? "" }),
2240
+ };
2384
2241
  }
2385
- const candidate = facts as Record<string, unknown>;
2386
- if (candidate.schema !== TURN_INTENT_FACTS_SCHEMA) {
2387
- throw new Error(`Workflow turn intent facts schema must be ${TURN_INTENT_FACTS_SCHEMA}`);
2242
+
2243
+ private workflowRunRow(runId: string): RunRow | undefined {
2244
+ const row = this.state.connection.prepare(workflowRunSelect("WHERE r.run_id = ?")).get(runId);
2245
+ return isRunRow(row) ? row : undefined;
2388
2246
  }
2389
- const allowedKeys = new Set([
2390
- "schema",
2391
- "workflowName",
2392
- "runId",
2393
- "observedState",
2394
- "cause",
2395
- "nodeId",
2396
- "attemptId",
2397
- "reason",
2398
- "handoff",
2399
- ]);
2400
- for (const key of Object.keys(candidate)) {
2401
- if (!allowedKeys.has(key)) throw new Error(`Unknown workflow turn intent facts field: ${key}`);
2247
+
2248
+ private requireWorkflowRunRow(runId: string): RunRow {
2249
+ const row = this.workflowRunRow(runId);
2250
+ if (row === undefined) throw new Error(`Workflow run not found: ${runId}`);
2251
+ return row;
2402
2252
  }
2403
- if (typeof candidate.workflowName !== "string") {
2404
- throw new Error("Workflow turn intent workflowName must be a string");
2253
+
2254
+ private requireWorkflowRun(runId: string): WorkflowRunQueueRecord {
2255
+ return this.mapWorkflowRun(this.requireWorkflowRunRow(runId));
2256
+ }
2257
+
2258
+ /* istanbul ignore next -- pure projection covered by integration tests */
2259
+ private mapWorkflowRun(row: RunRow): WorkflowRunQueueRecord {
2260
+ return {
2261
+ runId: row.runId,
2262
+ workflowName: row.workflowName,
2263
+ workflowSourceRef: row.workflowRef,
2264
+ workflowSource: this.state.readJson(row.workflowSourceHash),
2265
+ initialized: row.runStatus !== "queued",
2266
+ definitionDigest: `sha256:${row.definitionDigest.toString("hex")}`,
2267
+ input: this.state.readJson(row.inputHash),
2268
+ launchOptions: this.state.readJson(row.launchOptionsHash),
2269
+ status: row.status,
2270
+ runnerId: row.ownerId,
2271
+ claimToken: null,
2272
+ claimGeneration: row.ownerId === null ? null : row.leaseGeneration,
2273
+ claimExpiresAt:
2274
+ row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
2275
+ affinityRunnerId: row.affinityRunnerId,
2276
+ originSessionId: row.originSessionId,
2277
+ parentRunId: row.parentRunId,
2278
+ errorCode: row.errorCode,
2279
+ errorMessage:
2280
+ row.errorHash === null
2281
+ ? null
2282
+ : (this.state.readBlob(row.errorHash)?.content.toString("utf8") ?? null),
2283
+ createdAt: new Date(row.createdAt).toISOString(),
2284
+ updatedAt: new Date(row.updatedAt).toISOString(),
2285
+ startedAt: row.startedAt === null ? null : new Date(row.startedAt).toISOString(),
2286
+ finishedAt: row.finishedAt === null ? null : new Date(row.finishedAt).toISOString(),
2287
+ };
2405
2288
  }
2406
- if (typeof candidate.runId !== "string") {
2407
- throw new Error("Workflow turn intent runId must be a string");
2289
+
2290
+ private claimRun(
2291
+ runId: string,
2292
+ runnerId: string,
2293
+ claimToken: string,
2294
+ leaseMs: number,
2295
+ now: number,
2296
+ ): WorkflowRunQueueRecord | undefined {
2297
+ return this.state.transaction(() => {
2298
+ const row = this.workflowRunRow(runId);
2299
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
2300
+ return undefined;
2301
+ const lease = this.requireLease(row.resourceId);
2302
+ if (
2303
+ lease.ownerId !== null &&
2304
+ lease.expiresAt !== null &&
2305
+ lease.expiresAt > now &&
2306
+ lease.ownerId !== runnerId
2307
+ )
2308
+ return undefined;
2309
+ const generation = lease.generation + 1;
2310
+ const expiresAt = now + leaseMs;
2311
+ const result = this.state.connection
2312
+ .prepare(
2313
+ `UPDATE leases SET generation = ?, owner_type = ?, owner_id = ?, token_hash = ?,
2314
+ acquired_at = ?, heartbeat_at = ?, expires_at = ?
2315
+ WHERE resource_id = ? AND generation = ?`,
2316
+ )
2317
+ .run(
2318
+ generation,
2319
+ runnerId.startsWith("host-") ? "host" : "session",
2320
+ runnerId,
2321
+ tokenHash(claimToken),
2322
+ now,
2323
+ now,
2324
+ expiresAt,
2325
+ row.resourceId,
2326
+ lease.generation,
2327
+ );
2328
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
2329
+ if (result.changes !== 1) return undefined;
2330
+ this.state.connection
2331
+ .prepare("UPDATE run_queue SET status = 'starting', updated_at = ? WHERE run_id = ?")
2332
+ .run(now, runId);
2333
+ const revision = this.resourceRevision(row.resourceId);
2334
+ this.bumpResource(row.resourceId, revision, now);
2335
+ this.insertEvent(
2336
+ row.resourceId,
2337
+ revision + 1,
2338
+ "lease.claimed",
2339
+ runnerId.startsWith("host-") ? "host" : "session",
2340
+ runnerId,
2341
+ { expiresAt },
2342
+ now,
2343
+ generation,
2344
+ );
2345
+ return { ...this.requireWorkflowRun(runId), claimToken };
2346
+ });
2408
2347
  }
2409
- if (typeof candidate.observedState !== "string") {
2410
- throw new Error("Workflow turn intent observedState must be a string");
2348
+
2349
+ private updateClaimedRunStatus(
2350
+ runId: string,
2351
+ claimToken: string,
2352
+ status: WorkflowRunLaunchStatus,
2353
+ nowValue?: string,
2354
+ ): boolean {
2355
+ const now = epoch(validTimestamp(nowValue));
2356
+ return this.state.transaction(() => {
2357
+ if (
2358
+ !this.verifyWorkflowRunClaim({
2359
+ runId,
2360
+ claimToken,
2361
+ now: new Date(now).toISOString(),
2362
+ })
2363
+ ) {
2364
+ return false;
2365
+ }
2366
+ const row = this.requireWorkflowRunRow(runId);
2367
+ const changed =
2368
+ this.state.connection
2369
+ .prepare(
2370
+ "UPDATE run_queue SET status = ?, started_at = COALESCE(started_at, ?), updated_at = ? WHERE run_id = ?",
2371
+ )
2372
+ .run(status, now, now, runId).changes === 1;
2373
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
2374
+ if (!changed) return false;
2375
+ const revision = this.resourceRevision(row.resourceId);
2376
+ const lease = this.requireLease(row.resourceId);
2377
+ this.bumpResource(row.resourceId, revision, now);
2378
+ this.insertEvent(
2379
+ row.resourceId,
2380
+ revision + 1,
2381
+ `run.queue_${status}`,
2382
+ lease.ownerType ?? "system",
2383
+ lease.ownerId,
2384
+ { status },
2385
+ now,
2386
+ lease.generation || undefined,
2387
+ );
2388
+ return true;
2389
+ });
2411
2390
  }
2412
- if (typeof candidate.cause !== "string") {
2413
- throw new Error("Workflow turn intent cause must be a string");
2391
+
2392
+ private releaseRunClaim(
2393
+ runId: string,
2394
+ claimToken: string,
2395
+ status: WorkflowRunLaunchStatus,
2396
+ nowValue?: string,
2397
+ ): boolean {
2398
+ const now = epoch(validTimestamp(nowValue));
2399
+ return this.state.transaction(() => {
2400
+ const row = this.workflowRunRow(runId);
2401
+ if (
2402
+ row === undefined ||
2403
+ !this.verifyWorkflowRunClaim({ runId, claimToken, now: new Date(now).toISOString() })
2404
+ )
2405
+ return false;
2406
+ this.state.connection
2407
+ .prepare(
2408
+ "UPDATE run_queue SET status = ?, updated_at = ?, finished_at = ? WHERE run_id = ?",
2409
+ )
2410
+ .run(status, now, status === "done" ? now : null, runId);
2411
+ const lease = this.requireLease(row.resourceId);
2412
+ const update = this.state.connection
2413
+ .prepare(
2414
+ `UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
2415
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
2416
+ WHERE resource_id = ? AND token_hash = ? AND generation = ?`,
2417
+ )
2418
+ .run(row.resourceId, tokenHash(claimToken), lease.generation);
2419
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
2420
+ if (update.changes !== 1) return false;
2421
+ const revision = this.resourceRevision(row.resourceId);
2422
+ this.bumpResource(row.resourceId, revision, now);
2423
+ this.insertEvent(
2424
+ row.resourceId,
2425
+ revision + 1,
2426
+ `run.queue_${status}`,
2427
+ lease.ownerType ?? "system",
2428
+ lease.ownerId,
2429
+ { status },
2430
+ now,
2431
+ lease.generation || undefined,
2432
+ );
2433
+ return true;
2434
+ });
2414
2435
  }
2415
- validateKey(candidate.workflowName, "workflow turn facts workflow name");
2416
- validateRunId(candidate.runId);
2417
- validateKey(candidate.observedState, "workflow turn facts observed state");
2418
- validateWorkflowTurnIntentCause(candidate.cause);
2419
- if (candidate.nodeId !== null && typeof candidate.nodeId !== "string") {
2420
- throw new Error("Workflow turn intent nodeId must be a string or null");
2436
+
2437
+ private terminalRun(
2438
+ runId: string,
2439
+ claimToken: string | undefined,
2440
+ status: "failed" | "cancelled",
2441
+ code: string,
2442
+ error: string,
2443
+ nowValue?: string,
2444
+ ): boolean {
2445
+ const now = epoch(validTimestamp(nowValue));
2446
+ return this.state.transaction(() => {
2447
+ const row = this.workflowRunRow(runId);
2448
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) return false;
2449
+ const lease = this.requireLease(row.resourceId);
2450
+ if (claimToken === undefined) {
2451
+ if (!(["queued", "starting"] as WorkflowRunLaunchStatus[]).includes(row.status)) {
2452
+ return false;
2453
+ }
2454
+ if (lease.ownerId !== null) return false;
2455
+ } else if (
2456
+ lease.ownerId === null ||
2457
+ lease.tokenHash === null ||
2458
+ lease.expiresAt === null ||
2459
+ lease.expiresAt <= now ||
2460
+ !lease.tokenHash.equals(tokenHash(claimToken))
2461
+ ) {
2462
+ return false;
2463
+ }
2464
+ if (row.outputHash === null) throw new Error(`Workflow run ${runId} has no state projection`);
2465
+ const projected = this.state.readJson(row.outputHash);
2466
+ if (!isRecord(projected)) throw new Error(`Workflow run ${runId} state is invalid`);
2467
+ const revision = this.resourceRevision(row.resourceId);
2468
+ const at = new Date(now).toISOString();
2469
+ const terminalState: Record<string, unknown> = {
2470
+ ...projected,
2471
+ traceSeq: revision + 1,
2472
+ status,
2473
+ updatedAt: at,
2474
+ finishedAt: at,
2475
+ error,
2476
+ };
2477
+ delete terminalState.currentNode;
2478
+ delete terminalState.currentAttemptId;
2479
+ delete terminalState.currentNodeStartedAt;
2480
+ delete terminalState.statusDetail;
2481
+ delete terminalState.paused;
2482
+ delete terminalState.waitingOn;
2483
+ const errorHash = this.state.putText(error, now);
2484
+ const outputHash = this.state.putJson(terminalState, now);
2485
+ const queueUpdate = this.state.connection
2486
+ .prepare(
2487
+ `UPDATE run_queue
2488
+ SET status = ?, error_code = ?, error_hash = ?, updated_at = ?, finished_at = ?
2489
+ WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`,
2490
+ )
2491
+ .run(status, code, errorHash, now, now, runId);
2492
+ if (queueUpdate.changes !== 1) return false;
2493
+ if (claimToken !== undefined) {
2494
+ const leaseUpdate = this.state.connection
2495
+ .prepare(
2496
+ `UPDATE leases
2497
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
2498
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
2499
+ WHERE resource_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?`,
2500
+ )
2501
+ .run(row.resourceId, tokenHash(claimToken), lease.generation, now);
2502
+ if (leaseUpdate.changes !== 1) {
2503
+ throw new Error(`Workflow run ${runId} claim changed during terminal transition`);
2504
+ }
2505
+ }
2506
+ this.state.connection
2507
+ .prepare(
2508
+ `UPDATE runs
2509
+ SET status = ?, output_hash = ?, error_hash = ?, updated_at = ?, finished_at = ?
2510
+ WHERE run_id = ?`,
2511
+ )
2512
+ .run(status, outputHash, errorHash, now, now, runId);
2513
+ this.bumpResource(row.resourceId, revision, now);
2514
+ this.insertEvent(
2515
+ row.resourceId,
2516
+ revision + 1,
2517
+ `run.queue_${status}`,
2518
+ lease.ownerType ?? "system",
2519
+ lease.ownerId,
2520
+ { status, code, error },
2521
+ now,
2522
+ lease.generation || undefined,
2523
+ );
2524
+ return true;
2525
+ });
2421
2526
  }
2422
- if (candidate.attemptId !== null && typeof candidate.attemptId !== "string") {
2423
- throw new Error("Workflow turn intent attemptId must be a string or null");
2527
+
2528
+ private turnIntent(intentId: string): WorkflowTurnIntentRecord | undefined {
2529
+ const row = this.turnIntentRow(intentId);
2530
+ return row === undefined ? undefined : this.mapTurnIntent(row);
2531
+ }
2532
+
2533
+ private requireTurnIntent(intentId: string): WorkflowTurnIntentRecord {
2534
+ const row = this.turnIntent(intentId);
2535
+ if (row === undefined) throw new Error(`Workflow turn intent not found: ${intentId}`);
2536
+ return row;
2537
+ }
2538
+
2539
+ private turnIntentRow(intentId: string): TurnIntentRow | undefined {
2540
+ const row = this.state.connection
2541
+ .prepare(turnIntentSelect("WHERE t.turn_intent_id = ?"))
2542
+ .get(intentId);
2543
+ return isTurnIntentRow(row) ? row : undefined;
2544
+ }
2545
+
2546
+ private turnIntentBySource(sourceEventId: string): WorkflowTurnIntentRecord | undefined {
2547
+ const row = this.state.connection
2548
+ .prepare(turnIntentSelect("WHERE t.source_event_id = ?"))
2549
+ .get(sourceEventId);
2550
+ return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
2551
+ }
2552
+
2553
+ /* istanbul ignore next -- pure projection covered by integration tests */
2554
+ private mapTurnIntent(row: TurnIntentRow): WorkflowTurnIntentRecord {
2555
+ return {
2556
+ intentId: row.intentId,
2557
+ sourceEventId: row.sourceEventId,
2558
+ runId: row.runId,
2559
+ workflowRef: row.workflowRef,
2560
+ targetSessionId: row.targetSessionId,
2561
+ cause: row.cause,
2562
+ nodeId: row.nodeId,
2563
+ attemptId: row.attemptId,
2564
+ fallbackFacts: this.state.readJson(row.factsHash) as WorkflowTurnIntentFacts,
2565
+ requestedAt: new Date(row.requestedAt).toISOString(),
2566
+ eligibleAt: row.eligibleAt === null ? null : new Date(row.eligibleAt).toISOString(),
2567
+ resolvedAt: row.resolvedAt === null ? null : new Date(row.resolvedAt).toISOString(),
2568
+ resolution: row.resolution,
2569
+ resolutionMessageId: row.resolutionMessageId,
2570
+ deliveryClaimExpiresAt:
2571
+ row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
2572
+ };
2424
2573
  }
2425
- if (candidate.reason !== null && typeof candidate.reason !== "string") {
2426
- throw new Error("Workflow turn intent reason must be a string or null");
2574
+
2575
+ private claimEffectForTurn(
2576
+ intentId: string,
2577
+ token: string,
2578
+ leaseMs: number,
2579
+ nowValue?: string,
2580
+ ): boolean {
2581
+ const row = this.turnIntentRow(intentId);
2582
+ return (
2583
+ row !== undefined &&
2584
+ this.claimEffect(row.effectId, row.targetSessionId, token, leaseMs, nowValue)
2585
+ );
2427
2586
  }
2428
- if (candidate.nodeId !== null) validateKey(candidate.nodeId, "workflow turn facts node id");
2429
- if (candidate.attemptId !== null) {
2430
- validateKey(candidate.attemptId, "workflow turn facts attempt id");
2587
+
2588
+ private claimEffect(
2589
+ effectId: string,
2590
+ ownerId: string,
2591
+ token: string,
2592
+ leaseMs: number,
2593
+ nowValue?: string,
2594
+ ): boolean {
2595
+ const now = epoch(validTimestamp(nowValue));
2596
+ return this.state.transaction(() => {
2597
+ const row = this.state.connection
2598
+ .prepare(
2599
+ "SELECT resource_id AS resourceId FROM effects WHERE effect_id = ? AND status = 'pending'",
2600
+ )
2601
+ .get(effectId);
2602
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
2603
+ if (!isResourceIdRow(row)) return false;
2604
+ const lease = this.requireLease(row.resourceId);
2605
+ if (lease.ownerId !== null && lease.expiresAt !== null && lease.expiresAt > now) return false;
2606
+ return (
2607
+ this.state.connection
2608
+ .prepare(
2609
+ `UPDATE leases SET generation = ?, owner_type = 'session', owner_id = ?, token_hash = ?,
2610
+ acquired_at = ?, heartbeat_at = ?, expires_at = ?
2611
+ WHERE resource_id = ? AND generation = ?`,
2612
+ )
2613
+ .run(
2614
+ lease.generation + 1,
2615
+ ownerId,
2616
+ tokenHash(token),
2617
+ now,
2618
+ now,
2619
+ now + leaseMs,
2620
+ row.resourceId,
2621
+ lease.generation,
2622
+ ).changes === 1
2623
+ );
2624
+ });
2431
2625
  }
2432
- if (candidate.reason !== null && candidate.reason.length > MAX_ERROR_LENGTH) {
2433
- throw new Error(`Workflow turn intent reason must be at most ${MAX_ERROR_LENGTH} characters`);
2626
+
2627
+ private verifyEffectToken(effectId: string, token: string, nowValue?: string): boolean {
2628
+ const now = epoch(validTimestamp(nowValue));
2629
+ const row = this.state.connection
2630
+ .prepare(
2631
+ `SELECT l.token_hash AS tokenHash, l.expires_at AS expiresAt
2632
+ FROM effects e JOIN leases l ON l.resource_id = e.resource_id
2633
+ WHERE e.effect_id = ?`,
2634
+ )
2635
+ .get(effectId);
2636
+ return (
2637
+ isEffectTokenRow(row) &&
2638
+ row.tokenHash !== null &&
2639
+ row.tokenHash.equals(tokenHash(token)) &&
2640
+ row.expiresAt !== null &&
2641
+ row.expiresAt > now
2642
+ );
2434
2643
  }
2435
- if (typeof candidate.handoff !== "boolean") {
2436
- throw new Error("Workflow turn intent handoff must be a boolean");
2644
+
2645
+ private releaseEffectLease(effectId: string, token: string): boolean {
2646
+ return (
2647
+ this.state.connection
2648
+ .prepare(
2649
+ `UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
2650
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
2651
+ WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)
2652
+ AND token_hash = ?`,
2653
+ )
2654
+ .run(effectId, tokenHash(token)).changes === 1
2655
+ );
2437
2656
  }
2438
- }
2439
2657
 
2440
- function validateWorkflowTurnIntentCause(value: string): WorkflowTurnIntentCause {
2441
- if (
2442
- value !== "agentCancelled" &&
2443
- value !== "timedOut" &&
2444
- value !== "failed" &&
2445
- value !== "launchFailed" &&
2446
- value !== "controllerInterrupted" &&
2447
- value !== "claimLost"
2448
- ) {
2449
- throw new Error(`Invalid workflow turn intent cause: ${value}`);
2658
+ private completeEffect(
2659
+ effectId: string,
2660
+ status: "applied" | "rejected" | "ambiguous",
2661
+ now: number,
2662
+ ): void {
2663
+ this.state.connection
2664
+ .prepare("UPDATE effects SET status = ?, updated_at = ?, settled_at = ? WHERE effect_id = ?")
2665
+ .run(status, now, now, effectId);
2666
+ this.state.connection
2667
+ .prepare(
2668
+ `UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
2669
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
2670
+ WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)`,
2671
+ )
2672
+ .run(effectId);
2673
+ }
2674
+
2675
+ private insertEffectResource(
2676
+ effectId: string,
2677
+ resourceId: string,
2678
+ sourceResourceId: string,
2679
+ sourceRevision: number,
2680
+ type: string,
2681
+ key: string,
2682
+ payloadHash: Buffer,
2683
+ scope: "run" | "controller" | "channel" | "system",
2684
+ now: number,
2685
+ ): void {
2686
+ this.state.connection
2687
+ .prepare(
2688
+ "INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'effect', ?, 1, ?, ?)",
2689
+ )
2690
+ .run(resourceId, effectId, now, now);
2691
+ this.state.connection
2692
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
2693
+ .run(resourceId);
2694
+ this.state.connection
2695
+ .prepare(
2696
+ `INSERT INTO effects(
2697
+ effect_id, resource_id, source_resource_id, source_revision, effect_type,
2698
+ idempotency_key, payload_hash, owner_scope, status, attempt_count,
2699
+ created_at, updated_at
2700
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
2701
+ )
2702
+ .run(
2703
+ effectId,
2704
+ resourceId,
2705
+ sourceResourceId,
2706
+ sourceRevision,
2707
+ type,
2708
+ key,
2709
+ payloadHash,
2710
+ scope,
2711
+ now,
2712
+ now,
2713
+ );
2714
+ this.insertEvent(resourceId, 1, "effect.created", "system", null, {}, now);
2450
2715
  }
2451
- return value;
2452
- }
2453
2716
 
2454
- function validateWorkflowTurnIntentResolution(value: string): WorkflowTurnIntentResolution {
2455
- if (value !== "workflowPrompt" && value !== "presentation" && value !== "fallback") {
2456
- throw new Error(`Invalid workflow turn intent resolution: ${value}`);
2717
+ private notification(
2718
+ runId: string,
2719
+ attemptId: string,
2720
+ index: number,
2721
+ ): WorkflowNotificationRecord | undefined {
2722
+ const row = this.state.connection
2723
+ .prepare(
2724
+ notificationSelect("WHERE n.run_id = ? AND n.attempt_id = ? AND n.notification_index = ?"),
2725
+ )
2726
+ .get(runId, attemptId, index);
2727
+ return isNotificationRow(row) ? this.mapNotification(row) : undefined;
2728
+ }
2729
+
2730
+ private requireNotification(
2731
+ runId: string,
2732
+ attemptId: string,
2733
+ index: number,
2734
+ ): WorkflowNotificationRecord {
2735
+ const record = this.notification(runId, attemptId, index);
2736
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
2737
+ if (record === undefined) throw new Error("Workflow notification was not stored");
2738
+ return record;
2457
2739
  }
2458
- return value;
2459
- }
2460
2740
 
2461
- function workflowRunFromRow(row: WorkflowRunQueueRow): WorkflowRunQueueRecord {
2462
- return {
2463
- runId: row.run_id,
2464
- workflowName: row.workflow_ref,
2465
- workflowSourceRef: row.workflow_path,
2466
- workflowSource: parseStoredJson(row.workflow_source_json, "workflow run source"),
2467
- definitionDigest: row.definition_digest,
2468
- input: parseStoredJson(row.input_json, "workflow run input"),
2469
- launchOptions: parseStoredJson(row.launch_options_json, "workflow launch options"),
2470
- status: row.status,
2471
- runnerId: row.runner_id,
2472
- claimToken: row.claim_token,
2473
- claimExpiresAt: row.claim_expires_at === null ? null : iso(row.claim_expires_at),
2474
- affinityRunnerId: row.affinity_runner_id,
2475
- originSessionId: row.origin_session_id,
2476
- parentRunId: row.parent_run_id,
2477
- errorCode: row.error_code,
2478
- errorMessage: row.error_message,
2479
- createdAt: row.created_at,
2480
- updatedAt: row.updated_at,
2481
- startedAt: row.started_at,
2482
- finishedAt: row.finished_at,
2483
- };
2484
- }
2741
+ private notificationRows(sessionId: string, limit: number): NotificationRow[] {
2742
+ return this.state.connection
2743
+ .prepare(
2744
+ notificationSelect(
2745
+ "WHERE n.target_session_id = ? AND e.status = 'pending' ORDER BY n.created_at LIMIT ?",
2746
+ ),
2747
+ )
2748
+ .all(sessionId, limit)
2749
+ .filter(isNotificationRow);
2750
+ }
2751
+
2752
+ private notificationRowById(id: string): NotificationRow | undefined {
2753
+ const row = this.state.connection
2754
+ .prepare(notificationSelect("WHERE n.notification_id = ?"))
2755
+ .get(id);
2756
+ return isNotificationRow(row) ? row : undefined;
2757
+ }
2758
+
2759
+ /* istanbul ignore next -- pure projection covered by integration tests */
2760
+ private mapNotification(row: NotificationRow): WorkflowNotificationRecord {
2761
+ return {
2762
+ notificationId: row.notificationId,
2763
+ runId: row.runId,
2764
+ nodeId: row.nodeId,
2765
+ attemptId: row.attemptId,
2766
+ notificationIndex: row.notificationIndex,
2767
+ targetSessionId: row.targetSessionId,
2768
+ kind: row.kind,
2769
+ content: this.state.readBlob(row.contentHash)?.content.toString("utf8") ?? "",
2770
+ createdAt: new Date(row.createdAt).toISOString(),
2771
+ deliveryClaimExpiresAt:
2772
+ row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
2773
+ deliveredAt: row.deliveredAt === null ? null : new Date(row.deliveredAt).toISOString(),
2774
+ };
2775
+ }
2485
2776
 
2486
- function validateRunId(runId: string): void {
2487
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
2488
- throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
2777
+ private assertWritable(): void {
2778
+ if (this.state.mode === "read-only") throw new Error("Controller store is read-only");
2489
2779
  }
2490
2780
  }
2491
2781
 
2492
- function eventFromRow(row: EventRow): ControllerEvent {
2493
- return {
2494
- seq: row.seq,
2495
- recordedAt: row.recorded_at,
2496
- controller: row.controller,
2497
- key: row.resource_key,
2498
- type: row.type,
2499
- payload: parseStoredJson<JsonObject>(row.payload_json, "controller event payload"),
2500
- };
2782
+ function controllerSelect(clause: string): string {
2783
+ return `SELECT c.controller_resource_id AS controllerResourceId,
2784
+ c.resource_id AS resourceId, r.revision AS resourceVersion,
2785
+ c.controller_name AS controllerName, c.resource_key AS resourceKey,
2786
+ c.uid, c.generation, c.spec_hash AS specHash, c.status_hash AS statusHash,
2787
+ c.deletion_requested_at AS deletionRequestedAt
2788
+ FROM controller_resources c JOIN resources r ON r.resource_id = c.resource_id ${clause}`;
2501
2789
  }
2502
2790
 
2503
- function validTimestamp(value: string | undefined): string {
2504
- const timestamp = value ?? new Date().toISOString();
2505
- if (!Number.isFinite(Date.parse(timestamp))) {
2506
- throw new Error(`Invalid timestamp: ${JSON.stringify(timestamp)}`);
2507
- }
2508
- return timestamp;
2791
+ function effectSelect(clause: string): string {
2792
+ return `SELECT e.effect_id AS effectId, c.uid AS resourceUid, c.generation,
2793
+ e.effect_type AS kind, e.status AS state,
2794
+ json_extract(CAST(b.content AS TEXT), '$.requestFingerprint') AS requestFingerprint,
2795
+ e.created_at AS startedAt, e.settled_at AS completedAt,
2796
+ e.external_ref AS externalRef, e.error_hash AS errorHash
2797
+ FROM effects e
2798
+ JOIN controller_resources c ON c.resource_id = e.source_resource_id
2799
+ JOIN blobs b ON b.blob_hash = e.payload_hash ${clause}`;
2509
2800
  }
2510
2801
 
2511
- function epoch(value: string): number {
2512
- const parsed = Date.parse(value);
2513
- if (!Number.isSafeInteger(parsed)) {
2514
- throw new Error(`Timestamp is outside the supported range: ${value}`);
2515
- }
2516
- return parsed;
2802
+ function workflowSelect(clause: string): string {
2803
+ return `SELECT w.request_id AS requestId, c.uid AS resourceUid,
2804
+ w.request_key AS requestKey, w.input_fingerprint AS inputFingerprint,
2805
+ w.workflow_name AS workflowName, COALESCE(w.run_id, w.reserved_run_id) AS runId, w.status,
2806
+ w.attempt_count AS attemptCount, w.error_hash AS errorHash
2807
+ FROM controller_workflows w
2808
+ JOIN controller_resources c ON c.controller_resource_id = w.controller_resource_id ${clause}`;
2517
2809
  }
2518
2810
 
2519
- function iso(value: number): string {
2520
- return new Date(value).toISOString();
2811
+ function workflowRunSelect(clause: string): string {
2812
+ return `SELECT r.run_id AS runId, r.resource_id AS resourceId,
2813
+ d.workflow_name AS workflowName, r.workflow_ref AS workflowRef, r.status AS runStatus,
2814
+ r.workflow_source_hash AS workflowSourceHash, r.definition_digest AS definitionDigest,
2815
+ r.input_hash AS inputHash, r.output_hash AS outputHash,
2816
+ r.launch_options_hash AS launchOptionsHash,
2817
+ q.status, q.available_at AS availableAt, q.affinity_runner_id AS affinityRunnerId,
2818
+ q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
2819
+ q.error_hash AS errorHash, b.origin_session_id AS originSessionId,
2820
+ r.parent_run_id AS parentRunId, q.created_at AS createdAt, q.updated_at AS updatedAt,
2821
+ q.started_at AS startedAt, q.finished_at AS finishedAt,
2822
+ l.generation AS leaseGeneration, l.owner_id AS ownerId, l.expires_at AS claimExpiresAt
2823
+ FROM runs r JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
2824
+ JOIN run_queue q ON q.run_id = r.run_id
2825
+ LEFT JOIN run_bindings b ON b.run_id = r.run_id
2826
+ JOIN leases l ON l.resource_id = r.resource_id ${clause}`;
2521
2827
  }
2522
2828
 
2523
- function validateDuration(value: number, name: string): void {
2524
- if (!Number.isSafeInteger(value) || value <= 0) {
2525
- throw new Error(`${name} must be a positive safe integer`);
2526
- }
2829
+ function turnIntentSelect(clause: string): string {
2830
+ return `SELECT t.turn_intent_id AS intentId, t.source_event_id AS sourceEventId,
2831
+ t.run_id AS runId, t.workflow_ref AS workflowRef,
2832
+ t.target_session_id AS targetSessionId, t.cause,
2833
+ t.node_id AS nodeId, t.attempt_id AS attemptId, t.facts_hash AS factsHash,
2834
+ t.requested_at AS requestedAt, t.eligible_at AS eligibleAt,
2835
+ t.resolved_at AS resolvedAt, t.resolution_type AS resolution,
2836
+ t.resolution_message_id AS resolutionMessageId, l.expires_at AS claimExpiresAt,
2837
+ t.effect_id AS effectId
2838
+ FROM turn_intents t JOIN effects e ON e.effect_id = t.effect_id
2839
+ JOIN leases l ON l.resource_id = e.resource_id ${clause}`;
2527
2840
  }
2528
2841
 
2529
- function validateName(value: string, description: string): void {
2530
- if (!/^[A-Za-z][A-Za-z0-9_.-]*$/.test(value)) {
2531
- throw new Error(`${description} contains unsupported characters: ${JSON.stringify(value)}`);
2532
- }
2842
+ function notificationSelect(clause: string): string {
2843
+ return `SELECT n.notification_id AS notificationId, n.effect_id AS effectId,
2844
+ n.run_id AS runId, a.node_id AS nodeId, n.attempt_id AS attemptId,
2845
+ n.notification_index AS notificationIndex, n.target_session_id AS targetSessionId,
2846
+ n.notification_type AS kind, n.content_hash AS contentHash,
2847
+ n.created_at AS createdAt, l.expires_at AS claimExpiresAt,
2848
+ e.settled_at AS deliveredAt
2849
+ FROM notifications n JOIN effects e ON e.effect_id = n.effect_id
2850
+ JOIN leases l ON l.resource_id = e.resource_id
2851
+ JOIN node_attempts a ON a.attempt_id = n.attempt_id ${clause}`;
2533
2852
  }
2534
2853
 
2535
- function validateKey(value: string, description: string): void {
2536
- const hasControl = [...value].some((character) => {
2537
- const code = character.codePointAt(0) as number;
2538
- return code < 32 || code === 127;
2539
- });
2540
- if (value.length === 0 || value.length > MAX_KEY_LENGTH || hasControl) {
2541
- throw new Error(`${description} must be 1-${MAX_KEY_LENGTH} printable characters`);
2542
- }
2854
+ type QueueListRow = {
2855
+ controller: string;
2856
+ resourceKey: string;
2857
+ availableAt: number;
2858
+ consecutiveErrors: number;
2859
+ claimExpiresAt: number | null;
2860
+ };
2861
+ type ControllerEventRow = {
2862
+ seq: number;
2863
+ recordedAt: number;
2864
+ controller: string;
2865
+ resourceKey: string;
2866
+ eventType: string;
2867
+ payloadHash: Buffer | null;
2868
+ };
2869
+ type RunEventRow = {
2870
+ seq: number;
2871
+ recordedAt: number;
2872
+ runId: string;
2873
+ workflowRef: string;
2874
+ eventType: string;
2875
+ runnerId: string | null;
2876
+ payloadHash: Buffer | null;
2877
+ };
2878
+ type TurnIntentRow = {
2879
+ intentId: string;
2880
+ sourceEventId: string;
2881
+ runId: string;
2882
+ workflowRef: string;
2883
+ targetSessionId: string;
2884
+ cause: WorkflowTurnIntentCause;
2885
+ nodeId: string | null;
2886
+ attemptId: string | null;
2887
+ factsHash: Buffer;
2888
+ requestedAt: number;
2889
+ eligibleAt: number | null;
2890
+ resolvedAt: number | null;
2891
+ resolution: WorkflowTurnIntentResolution | null;
2892
+ resolutionMessageId: string | null;
2893
+ claimExpiresAt: number | null;
2894
+ effectId: string;
2895
+ };
2896
+ type NotificationRow = {
2897
+ notificationId: string;
2898
+ effectId: string;
2899
+ runId: string;
2900
+ nodeId: string;
2901
+ attemptId: string;
2902
+ notificationIndex: number;
2903
+ targetSessionId: string;
2904
+ kind: "progress" | "final";
2905
+ contentHash: Buffer;
2906
+ createdAt: number;
2907
+ claimExpiresAt: number | null;
2908
+ deliveredAt: number | null;
2909
+ };
2910
+
2911
+ function isRecord(value: unknown): value is Record<string, unknown> {
2912
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2913
+ }
2914
+ function isControllerRow(value: unknown): value is ControllerRow {
2915
+ return isRecord(value);
2916
+ }
2917
+ function isLeaseRow(value: unknown): value is LeaseRow {
2918
+ return isRecord(value);
2919
+ }
2920
+ function isRunRow(value: unknown): value is RunRow {
2921
+ return isRecord(value);
2922
+ }
2923
+ function isEffectRow(value: unknown): value is EffectRow {
2924
+ return isRecord(value);
2925
+ }
2926
+ function isWorkflowRow(value: unknown): value is WorkflowRow {
2927
+ return isRecord(value);
2928
+ }
2929
+ function isQueueListRow(value: unknown): value is QueueListRow {
2930
+ return isRecord(value);
2931
+ }
2932
+ function isControllerEventRow(value: unknown): value is ControllerEventRow {
2933
+ return isRecord(value);
2934
+ }
2935
+ function isRunEventRow(value: unknown): value is RunEventRow {
2936
+ return isRecord(value);
2937
+ }
2938
+ function isTurnIntentRow(value: unknown): value is TurnIntentRow {
2939
+ return isRecord(value);
2940
+ }
2941
+ function isNotificationRow(value: unknown): value is NotificationRow {
2942
+ return isRecord(value);
2943
+ }
2944
+ function isProjectRow(value: unknown): value is { projectId: string } {
2945
+ return isRecord(value);
2946
+ }
2947
+ function isFinalizerRow(value: unknown): value is { finalizer: string } {
2948
+ return isRecord(value);
2949
+ }
2950
+ function isErrorCountRow(value: unknown): value is { consecutiveErrors: number } {
2951
+ return isRecord(value);
2952
+ }
2953
+ function isQueueVersionRow(value: unknown): value is { queueVersion: number } {
2954
+ return isRecord(value);
2955
+ }
2956
+ function isRevisionRow(value: unknown): value is { revision: number } {
2957
+ return isRecord(value);
2958
+ }
2959
+ function isSequenceRow(value: unknown): value is { seq: number } {
2960
+ return isRecord(value);
2961
+ }
2962
+ function isKeyRow(value: unknown): value is { key: string } {
2963
+ return isRecord(value);
2964
+ }
2965
+ function isResourceIdRow(value: unknown): value is { resourceId: string } {
2966
+ return isRecord(value);
2967
+ }
2968
+ function isEffectTokenRow(
2969
+ value: unknown,
2970
+ ): value is { tokenHash: Buffer | null; expiresAt: number | null } {
2971
+ return isRecord(value);
2972
+ }
2973
+ function isEffectIdentityRow(value: unknown): value is { effectId: string; resourceId: string } {
2974
+ return isRecord(value);
2543
2975
  }
2544
2976
 
2545
- function validateFinalizers(finalizers: string[]): void {
2546
- const seen = new Set<string>();
2547
- for (const finalizer of finalizers) {
2548
- validateName(finalizer, "finalizer");
2549
- if (seen.has(finalizer)) {
2550
- throw new Error(`Duplicate finalizer: ${finalizer}`);
2551
- }
2552
- seen.add(finalizer);
2977
+ function effectIdFor(sourceResourceId: string, key: string): string {
2978
+ return `effect-${createHash("sha256").update(`${sourceResourceId}\0${key}`).digest("hex").slice(0, 40)}`;
2979
+ }
2980
+ function projectIdFor(canonicalPath: string): string {
2981
+ return `project-${createHash("sha256").update(canonicalPath).digest("hex").slice(0, 40)}`;
2982
+ }
2983
+ function canonicalProjectPath(value: string): string {
2984
+ try {
2985
+ return fs.realpathSync.native(value);
2986
+ } catch {
2987
+ return path.resolve(value);
2553
2988
  }
2554
2989
  }
2555
-
2556
- function validateJsonSize(value: string, description: string, limit: number): void {
2557
- if (Buffer.byteLength(value, "utf8") > limit) {
2558
- throw new Error(`${description} exceeds the ${limit}-byte storage limit`);
2990
+ function digestBuffer(value: string): Buffer {
2991
+ const hex = value.startsWith("sha256:") ? value.slice(7) : value;
2992
+ if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error("Expected a SHA-256 digest");
2993
+ return Buffer.from(hex, "hex");
2994
+ }
2995
+ function queuedWorkflowSource(value: unknown): { root: unknown; mounted: unknown[] } {
2996
+ if (isRecord(value) && (value.kind === "builtin" || value.kind === "file")) {
2997
+ return { root: value, mounted: [] };
2998
+ }
2999
+ if (isRecord(value) && isRecord(value.root) && Array.isArray(value.mounted)) {
3000
+ return { root: value.root, mounted: value.mounted };
2559
3001
  }
3002
+ throw new Error("Stored workflow source identity is invalid");
2560
3003
  }
2561
3004
 
2562
- function truncateError(error: string): string {
2563
- return error.length <= MAX_ERROR_LENGTH ? error : `${error.slice(0, MAX_ERROR_LENGTH)}…`;
3005
+ function sourceParts(
3006
+ ref: string,
3007
+ revision: string,
3008
+ ): { type: "builtin" | "file"; ref: string; revision: string } {
3009
+ return ref.startsWith("builtin:")
3010
+ ? { type: "builtin", ref: ref.slice(8), revision }
3011
+ : { type: "file", ref, revision };
3012
+ }
3013
+ function effectStatus(value: EffectRecord["state"]): string {
3014
+ return value === "indeterminate" ? "ambiguous" : value;
3015
+ }
3016
+ function controllerEffectState(value: string): EffectRecord["state"] {
3017
+ return value === "ambiguous" ? "indeterminate" : (value as EffectRecord["state"]);
3018
+ }
3019
+ function validTimestamp(value?: string): string {
3020
+ const date = value === undefined ? new Date() : new Date(value);
3021
+ if (!Number.isFinite(date.getTime())) throw new Error("Invalid timestamp");
3022
+ return date.toISOString();
3023
+ }
3024
+ function epoch(value: string): number {
3025
+ return Date.parse(value);
3026
+ }
3027
+ function validateName(value: string, label: string): void {
3028
+ validateKey(value, label);
3029
+ }
3030
+ function validateKey(value: string, label: string): void {
3031
+ if (value.length === 0 || value.length > 512) throw new Error(`${label} is invalid`);
3032
+ }
3033
+ function validateRunId(value: string): void {
3034
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(value))
3035
+ throw new Error(`Invalid workflow run id: ${value}`);
2564
3036
  }