@osolmaz/pi-workflows 0.2.0 → 0.4.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 (152) hide show
  1. package/README.md +72 -5
  2. package/dist/builtins/catalog.d.ts +2 -0
  3. package/dist/builtins/catalog.js +22 -0
  4. package/dist/builtins/catalog.js.map +1 -0
  5. package/dist/builtins/monitor.workflow.d.ts +71 -0
  6. package/dist/builtins/monitor.workflow.js +250 -0
  7. package/dist/builtins/monitor.workflow.js.map +1 -0
  8. package/dist/controllers/conditions.d.ts +6 -0
  9. package/dist/controllers/conditions.js +68 -0
  10. package/dist/controllers/conditions.js.map +1 -0
  11. package/dist/controllers/definition.d.ts +6 -0
  12. package/dist/controllers/definition.js +45 -0
  13. package/dist/controllers/definition.js.map +1 -0
  14. package/dist/controllers/effects.d.ts +14 -0
  15. package/dist/controllers/effects.js +104 -0
  16. package/dist/controllers/effects.js.map +1 -0
  17. package/dist/controllers/errors.d.ts +12 -0
  18. package/dist/controllers/errors.js +25 -0
  19. package/dist/controllers/errors.js.map +1 -0
  20. package/dist/controllers/index.d.ts +13 -0
  21. package/dist/controllers/index.js +13 -0
  22. package/dist/controllers/index.js.map +1 -0
  23. package/dist/controllers/json.d.ts +5 -0
  24. package/dist/controllers/json.js +57 -0
  25. package/dist/controllers/json.js.map +1 -0
  26. package/dist/controllers/loader.d.ts +23 -0
  27. package/dist/controllers/loader.js +74 -0
  28. package/dist/controllers/loader.js.map +1 -0
  29. package/dist/controllers/manager.d.ts +58 -0
  30. package/dist/controllers/manager.js +399 -0
  31. package/dist/controllers/manager.js.map +1 -0
  32. package/dist/controllers/results.d.ts +5 -0
  33. package/dist/controllers/results.js +32 -0
  34. package/dist/controllers/results.js.map +1 -0
  35. package/dist/controllers/sqlite.d.ts +235 -0
  36. package/dist/controllers/sqlite.js +1087 -0
  37. package/dist/controllers/sqlite.js.map +1 -0
  38. package/dist/controllers/store.d.ts +112 -0
  39. package/dist/controllers/store.js +32 -0
  40. package/dist/controllers/store.js.map +1 -0
  41. package/dist/controllers/types.d.ts +159 -0
  42. package/dist/controllers/types.js +2 -0
  43. package/dist/controllers/types.js.map +1 -0
  44. package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
  45. package/dist/controllers/workflow-engine-scheduler.js +95 -0
  46. package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
  47. package/dist/controllers/workflows.d.ts +27 -0
  48. package/dist/controllers/workflows.js +109 -0
  49. package/dist/controllers/workflows.js.map +1 -0
  50. package/dist/extension/controller-host.d.ts +47 -0
  51. package/dist/extension/controller-host.js +110 -0
  52. package/dist/extension/controller-host.js.map +1 -0
  53. package/dist/extension/executor.d.ts +3 -0
  54. package/dist/extension/executor.js +12 -2
  55. package/dist/extension/executor.js.map +1 -1
  56. package/dist/extension/index.d.ts +7 -0
  57. package/dist/extension/index.js +1118 -106
  58. package/dist/extension/index.js.map +1 -1
  59. package/dist/extension/recorder.d.ts +6 -0
  60. package/dist/extension/recorder.js +20 -8
  61. package/dist/extension/recorder.js.map +1 -1
  62. package/dist/extension/workflow-tool.d.ts +28 -0
  63. package/dist/extension/workflow-tool.js +33 -0
  64. package/dist/extension/workflow-tool.js.map +1 -0
  65. package/dist/host/processes.d.ts +24 -0
  66. package/dist/host/processes.js +114 -0
  67. package/dist/host/processes.js.map +1 -0
  68. package/dist/host/rpc-bridge.d.ts +9 -0
  69. package/dist/host/rpc-bridge.js +39 -0
  70. package/dist/host/rpc-bridge.js.map +1 -0
  71. package/dist/host/rpc-executor.d.ts +38 -0
  72. package/dist/host/rpc-executor.js +254 -0
  73. package/dist/host/rpc-executor.js.map +1 -0
  74. package/dist/host/runner.d.ts +50 -0
  75. package/dist/host/runner.js +379 -0
  76. package/dist/host/runner.js.map +1 -0
  77. package/dist/viewer/cli.d.ts +7 -3
  78. package/dist/viewer/cli.js +150 -19
  79. package/dist/viewer/cli.js.map +1 -1
  80. package/dist/workflows/catalog.d.ts +43 -0
  81. package/dist/workflows/catalog.js +79 -0
  82. package/dist/workflows/catalog.js.map +1 -0
  83. package/dist/workflows/engine.d.ts +37 -2
  84. package/dist/workflows/engine.js +297 -29
  85. package/dist/workflows/engine.js.map +1 -1
  86. package/dist/workflows/errors.d.ts +23 -0
  87. package/dist/workflows/errors.js +38 -0
  88. package/dist/workflows/errors.js.map +1 -1
  89. package/dist/workflows/graph.js +0 -5
  90. package/dist/workflows/graph.js.map +1 -1
  91. package/dist/workflows/index.d.ts +2 -2
  92. package/dist/workflows/index.js +1 -1
  93. package/dist/workflows/index.js.map +1 -1
  94. package/dist/workflows/loader.d.ts +19 -15
  95. package/dist/workflows/loader.js +63 -19
  96. package/dist/workflows/loader.js.map +1 -1
  97. package/dist/workflows/migrate-sources.d.ts +41 -0
  98. package/dist/workflows/migrate-sources.js +129 -0
  99. package/dist/workflows/migrate-sources.js.map +1 -0
  100. package/dist/workflows/schema.js +3 -2
  101. package/dist/workflows/schema.js.map +1 -1
  102. package/dist/workflows/store.d.ts +50 -6
  103. package/dist/workflows/store.js +448 -53
  104. package/dist/workflows/store.js.map +1 -1
  105. package/dist/workflows/types.d.ts +27 -3
  106. package/docs/CONTROLLERS.md +215 -0
  107. package/docs/development.md +14 -9
  108. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  109. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  110. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  111. package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
  112. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
  113. package/docs/run-bundles.md +85 -28
  114. package/docs/workflows.md +161 -17
  115. package/examples/controllers/pull-request.controller.ts +215 -0
  116. package/package.json +10 -2
  117. package/src/builtins/catalog.ts +22 -0
  118. package/src/builtins/monitor.workflow.ts +302 -0
  119. package/src/controllers/conditions.ts +110 -0
  120. package/src/controllers/definition.ts +65 -0
  121. package/src/controllers/effects.ts +123 -0
  122. package/src/controllers/errors.ts +27 -0
  123. package/src/controllers/index.ts +90 -0
  124. package/src/controllers/json.ts +62 -0
  125. package/src/controllers/loader.ts +104 -0
  126. package/src/controllers/manager.ts +533 -0
  127. package/src/controllers/results.ts +46 -0
  128. package/src/controllers/sqlite.ts +1545 -0
  129. package/src/controllers/store.ts +160 -0
  130. package/src/controllers/types.ts +183 -0
  131. package/src/controllers/workflow-engine-scheduler.ts +148 -0
  132. package/src/controllers/workflows.ts +152 -0
  133. package/src/extension/controller-host.ts +163 -0
  134. package/src/extension/executor.ts +13 -2
  135. package/src/extension/index.ts +1325 -130
  136. package/src/extension/recorder.ts +65 -36
  137. package/src/extension/workflow-tool.ts +59 -0
  138. package/src/host/processes.ts +119 -0
  139. package/src/host/rpc-bridge.ts +44 -0
  140. package/src/host/rpc-executor.ts +299 -0
  141. package/src/host/runner.ts +445 -0
  142. package/src/viewer/cli.ts +167 -21
  143. package/src/workflows/catalog.ts +135 -0
  144. package/src/workflows/engine.ts +404 -41
  145. package/src/workflows/errors.ts +45 -0
  146. package/src/workflows/graph.ts +0 -5
  147. package/src/workflows/index.ts +2 -0
  148. package/src/workflows/loader.ts +76 -22
  149. package/src/workflows/migrate-sources.ts +167 -0
  150. package/src/workflows/schema.ts +3 -2
  151. package/src/workflows/store.ts +557 -48
  152. package/src/workflows/types.ts +22 -3
@@ -0,0 +1,1545 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import Database from "better-sqlite3";
5
+ import {
6
+ EffectRequestConflictError,
7
+ ResourceConflictError,
8
+ ResourceNotFoundError,
9
+ WorkflowRequestConflictError,
10
+ } 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,
21
+ } from "./store.js";
22
+ import type {
23
+ ChildWorkflowRecord,
24
+ ControllerEvent,
25
+ ControllerQueueClaim,
26
+ ControllerResource,
27
+ ControllerResourceRef,
28
+ ControllerResourceStatus,
29
+ EffectRecord,
30
+ JsonObject,
31
+ } from "./types.js";
32
+
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
+
38
+ type ResourceRow = {
39
+ controller: string;
40
+ resource_key: string;
41
+ uid: string;
42
+ resource_version: number;
43
+ generation: number;
44
+ spec_json: string;
45
+ status_json: string;
46
+ deletion_timestamp: string | null;
47
+ finalizers_json: string;
48
+ };
49
+
50
+ type QueueRow = {
51
+ controller: string;
52
+ resource_key: string;
53
+ available_at: number;
54
+ version: number;
55
+ consecutive_errors: number;
56
+ claim_token: string | null;
57
+ claim_version: number | null;
58
+ claim_expires_at: number | null;
59
+ };
60
+
61
+ type EffectRow = {
62
+ effect_key: string;
63
+ resource_uid: string;
64
+ generation: number;
65
+ kind: string;
66
+ state: EffectRecord["state"];
67
+ request_fingerprint: string;
68
+ started_at: string;
69
+ completed_at: string | null;
70
+ external_ref: string | null;
71
+ error: string | null;
72
+ };
73
+
74
+ type WorkflowRow = {
75
+ request_id: string;
76
+ resource_uid: string;
77
+ request_key: string;
78
+ input_fingerprint: string;
79
+ workflow: string;
80
+ run_id: string | null;
81
+ state: ChildWorkflowRecord["state"];
82
+ attempt: number;
83
+ error: string | null;
84
+ };
85
+
86
+ type WorkflowRunQueueRow = {
87
+ run_id: string;
88
+ workflow_ref: string;
89
+ workflow_path: string;
90
+ input_json: string;
91
+ status: "claimed" | "parked" | "done";
92
+ runner_id: string | null;
93
+ claim_token: string | null;
94
+ claim_expires_at: number | null;
95
+ affinity_runner_id: string | null;
96
+ parent_run_id: string | null;
97
+ created_at: string;
98
+ updated_at: string;
99
+ };
100
+
101
+ /** A user-started workflow run tracked by the durable run queue. */
102
+ export type WorkflowRunQueueRecord = {
103
+ runId: string;
104
+ /** Human-readable workflow name used in status and event output. */
105
+ workflowName: string;
106
+ /** Canonical source reference used to reopen the run. */
107
+ workflowSourceRef: string;
108
+ input: unknown;
109
+ status: "claimed" | "parked" | "done";
110
+ runnerId: string | null;
111
+ claimToken: string | null;
112
+ claimExpiresAt: string | null;
113
+ affinityRunnerId: string | null;
114
+ parentRunId: string | null;
115
+ createdAt: string;
116
+ updatedAt: string;
117
+ };
118
+
119
+ type RunEventRow = {
120
+ seq: number;
121
+ recorded_at: string;
122
+ run_id: string;
123
+ workflow_ref: string;
124
+ type: string;
125
+ runner_id: string | null;
126
+ payload_json: string;
127
+ };
128
+
129
+ /** One run lifecycle transition in the cross-session event feed. */
130
+ export type RunEventRecord = {
131
+ seq: number;
132
+ recordedAt: string;
133
+ runId: string;
134
+ workflowRef: string;
135
+ type: string;
136
+ runnerId: string | null;
137
+ payload: JsonObject;
138
+ };
139
+
140
+ type EventRow = {
141
+ seq: number;
142
+ recorded_at: string;
143
+ controller: string;
144
+ resource_key: string;
145
+ type: string;
146
+ payload_json: string;
147
+ };
148
+
149
+ export class SqliteControllerStore implements ControllerStore {
150
+ readonly filePath: string;
151
+ private readonly database: Database.Database;
152
+ private closed = false;
153
+
154
+ constructor(filePath: string = controllerStorePath(), options: { readOnly?: boolean } = {}) {
155
+ this.filePath = path.resolve(filePath);
156
+ const readOnly = options.readOnly ?? false;
157
+ if (!readOnly) {
158
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
159
+ fs.chmodSync(path.dirname(this.filePath), 0o700);
160
+ }
161
+ this.database = new Database(this.filePath, {
162
+ readonly: readOnly,
163
+ fileMustExist: readOnly,
164
+ });
165
+ if (!readOnly) {
166
+ fs.chmodSync(this.filePath, 0o600);
167
+ }
168
+ this.configure(readOnly);
169
+ this.initializeSchema(readOnly);
170
+ }
171
+
172
+ close(): void {
173
+ if (this.closed) {
174
+ return;
175
+ }
176
+ this.closed = true;
177
+ this.database.close();
178
+ }
179
+
180
+ putResource<TSpec, TStatus>(options: {
181
+ controller: string;
182
+ key: string;
183
+ spec: TSpec;
184
+ initialStatus: TStatus;
185
+ now?: string;
186
+ }): ControllerResource<TSpec, TStatus> {
187
+ validateName(options.controller, "controller");
188
+ validateKey(options.key, "resource key");
189
+ const now = validTimestamp(options.now);
190
+ const specJson = canonicalJson(options.spec, "controller resource spec");
191
+ validateJsonSize(specJson, "Controller resource spec", MAX_RESOURCE_VALUE_BYTES);
192
+ const initialStatus: ControllerResourceStatus<TStatus> = {
193
+ observedGeneration: 0,
194
+ conditions: [],
195
+ controllerStatus: options.initialStatus,
196
+ };
197
+ const statusJson = canonicalJson(initialStatus, "controller resource status");
198
+ validateJsonSize(statusJson, "Controller resource status", MAX_RESOURCE_VALUE_BYTES);
199
+
200
+ return this.transaction(() => {
201
+ const existing = this.resourceRow({ controller: options.controller, key: options.key });
202
+ if (existing === undefined) {
203
+ const uid = randomUUID();
204
+ this.database
205
+ .prepare(
206
+ `INSERT INTO resources (
207
+ controller, resource_key, uid, resource_version, generation,
208
+ spec_json, status_json, deletion_timestamp, finalizers_json, updated_at
209
+ ) VALUES (?, ?, ?, 1, 1, ?, ?, NULL, '[]', ?)`,
210
+ )
211
+ .run(options.controller, options.key, uid, specJson, statusJson, now);
212
+ this.enqueueRow({ controller: options.controller, key: options.key }, epoch(now));
213
+ } else if (existing.spec_json !== specJson) {
214
+ this.database
215
+ .prepare(
216
+ `UPDATE resources
217
+ SET spec_json = ?, resource_version = resource_version + 1,
218
+ generation = generation + 1, updated_at = ?
219
+ WHERE controller = ? AND resource_key = ?`,
220
+ )
221
+ .run(specJson, now, options.controller, options.key);
222
+ this.enqueueRow({ controller: options.controller, key: options.key }, epoch(now));
223
+ }
224
+ return this.requireResource<TSpec, TStatus>({
225
+ controller: options.controller,
226
+ key: options.key,
227
+ });
228
+ });
229
+ }
230
+
231
+ getResource<TSpec = unknown, TStatus = unknown>(
232
+ ref: ControllerResourceRef,
233
+ ): ControllerResource<TSpec, TStatus> | undefined {
234
+ const row = this.resourceRow(ref);
235
+ return row === undefined ? undefined : resourceFromRow<TSpec, TStatus>(row);
236
+ }
237
+
238
+ getResourceByUid(uid: string): ControllerResource | undefined {
239
+ const row = this.database.prepare("SELECT * FROM resources WHERE uid = ?").get(uid) as
240
+ | ResourceRow
241
+ | undefined;
242
+ return row === undefined ? undefined : resourceFromRow(row);
243
+ }
244
+
245
+ listResources<TSpec = unknown, TStatus = unknown>(
246
+ options: {
247
+ controller?: string;
248
+ } = {},
249
+ ): ControllerResource<TSpec, TStatus>[] {
250
+ const rows =
251
+ options.controller === undefined
252
+ ? (this.database
253
+ .prepare("SELECT * FROM resources ORDER BY controller, resource_key")
254
+ .all() as ResourceRow[])
255
+ : (this.database
256
+ .prepare("SELECT * FROM resources WHERE controller = ? ORDER BY resource_key")
257
+ .all(options.controller) as ResourceRow[]);
258
+ return rows.map((row) => resourceFromRow<TSpec, TStatus>(row));
259
+ }
260
+
261
+ updateStatus<TStatus>(options: {
262
+ ref: ControllerResourceRef;
263
+ expectedResourceVersion: number;
264
+ status: ControllerResourceStatus<TStatus>;
265
+ finalizers?: string[];
266
+ now?: string;
267
+ }): ControllerResource<unknown, TStatus> {
268
+ const now = validTimestamp(options.now);
269
+ const statusJson = canonicalJson(options.status, "controller resource status");
270
+ validateJsonSize(statusJson, "Controller resource status", MAX_RESOURCE_VALUE_BYTES);
271
+ if (options.finalizers !== undefined) {
272
+ validateFinalizers(options.finalizers);
273
+ }
274
+ return this.transaction(() => {
275
+ const current = this.resourceRow(options.ref);
276
+ if (current === undefined) {
277
+ throw new ResourceNotFoundError(options.ref.controller, options.ref.key);
278
+ }
279
+ if (current.resource_version !== options.expectedResourceVersion) {
280
+ throw new ResourceConflictError(options.ref.controller, options.ref.key);
281
+ }
282
+ const finalizersJson =
283
+ options.finalizers === undefined
284
+ ? current.finalizers_json
285
+ : canonicalJson(options.finalizers);
286
+ if (current.status_json === statusJson && current.finalizers_json === finalizersJson) {
287
+ return resourceFromRow<unknown, TStatus>(current);
288
+ }
289
+ this.database
290
+ .prepare(
291
+ `UPDATE resources
292
+ SET status_json = ?, finalizers_json = ?,
293
+ resource_version = resource_version + 1, updated_at = ?
294
+ WHERE controller = ? AND resource_key = ? AND resource_version = ?`,
295
+ )
296
+ .run(
297
+ statusJson,
298
+ finalizersJson,
299
+ now,
300
+ options.ref.controller,
301
+ options.ref.key,
302
+ options.expectedResourceVersion,
303
+ );
304
+ return this.requireResource<unknown, TStatus>(options.ref);
305
+ });
306
+ }
307
+
308
+ requestDeletion(ref: ControllerResourceRef, now?: string): ControllerResource {
309
+ const timestamp = validTimestamp(now);
310
+ return this.transaction(() => {
311
+ const row = this.resourceRow(ref);
312
+ if (row === undefined) {
313
+ throw new ResourceNotFoundError(ref.controller, ref.key);
314
+ }
315
+ if (row.deletion_timestamp === null) {
316
+ this.database
317
+ .prepare(
318
+ `UPDATE resources
319
+ SET deletion_timestamp = ?, resource_version = resource_version + 1, updated_at = ?
320
+ WHERE controller = ? AND resource_key = ?`,
321
+ )
322
+ .run(timestamp, timestamp, ref.controller, ref.key);
323
+ }
324
+ this.enqueueRow(ref, epoch(timestamp));
325
+ return this.requireResource(ref);
326
+ });
327
+ }
328
+
329
+ updateFinalizers(options: {
330
+ ref: ControllerResourceRef;
331
+ expectedResourceVersion: number;
332
+ finalizers: string[];
333
+ now?: string;
334
+ }): ControllerResource {
335
+ validateFinalizers(options.finalizers);
336
+ const now = validTimestamp(options.now);
337
+ const result = this.database
338
+ .prepare(
339
+ `UPDATE resources
340
+ SET finalizers_json = ?, resource_version = resource_version + 1, updated_at = ?
341
+ WHERE controller = ? AND resource_key = ? AND resource_version = ?`,
342
+ )
343
+ .run(
344
+ canonicalJson(options.finalizers),
345
+ now,
346
+ options.ref.controller,
347
+ options.ref.key,
348
+ options.expectedResourceVersion,
349
+ );
350
+ if (result.changes !== 1) {
351
+ this.throwMissingOrConflict(options.ref);
352
+ }
353
+ return this.requireResource(options.ref);
354
+ }
355
+
356
+ deleteResource(ref: ControllerResourceRef, expectedResourceVersion: number): boolean {
357
+ const row = this.resourceRow(ref);
358
+ if (row === undefined) {
359
+ return false;
360
+ }
361
+ const finalizers = parseStoredJson<string[]>(row.finalizers_json, "resource finalizers");
362
+ if (row.deletion_timestamp === null || finalizers.length > 0) {
363
+ throw new Error(`Controller resource ${ref.controller}/${ref.key} is not ready for deletion`);
364
+ }
365
+ const result = this.database
366
+ .prepare(
367
+ `DELETE FROM resources
368
+ WHERE controller = ? AND resource_key = ? AND resource_version = ?`,
369
+ )
370
+ .run(ref.controller, ref.key, expectedResourceVersion);
371
+ if (result.changes !== 1) {
372
+ throw new ResourceConflictError(ref.controller, ref.key);
373
+ }
374
+ return true;
375
+ }
376
+
377
+ enqueue(ref: ControllerResourceRef, availableAt?: string): void {
378
+ const at = validTimestamp(availableAt);
379
+ if (this.resourceRow(ref) === undefined) {
380
+ throw new ResourceNotFoundError(ref.controller, ref.key);
381
+ }
382
+ this.enqueueRow(ref, epoch(at));
383
+ }
384
+
385
+ claimNext(options: {
386
+ controllers: string[];
387
+ leaseMs: number;
388
+ now?: string;
389
+ }): ControllerQueueClaim | undefined {
390
+ validateDuration(options.leaseMs, "leaseMs");
391
+ if (options.controllers.length === 0) {
392
+ return undefined;
393
+ }
394
+ const now = validTimestamp(options.now);
395
+ const nowMs = epoch(now);
396
+ const placeholders = options.controllers.map(() => "?").join(", ");
397
+ return this.transaction(() => {
398
+ const row = this.database
399
+ .prepare(
400
+ `SELECT * FROM queue
401
+ WHERE controller IN (${placeholders})
402
+ AND available_at <= ?
403
+ AND (claim_token IS NULL OR claim_expires_at <= ?)
404
+ ORDER BY available_at, controller, resource_key
405
+ LIMIT 1`,
406
+ )
407
+ .get(...options.controllers, nowMs, nowMs) as QueueRow | undefined;
408
+ if (row === undefined) {
409
+ return undefined;
410
+ }
411
+ const token = randomUUID();
412
+ const expiresAtMs = nowMs + options.leaseMs;
413
+ const result = this.database
414
+ .prepare(
415
+ `UPDATE queue
416
+ SET claim_token = ?, claim_version = version, claim_expires_at = ?
417
+ WHERE controller = ? AND resource_key = ?
418
+ AND (claim_token IS NULL OR claim_expires_at <= ?)`,
419
+ )
420
+ .run(token, expiresAtMs, row.controller, row.resource_key, nowMs);
421
+ if (result.changes !== 1) {
422
+ return undefined;
423
+ }
424
+ return {
425
+ controller: row.controller,
426
+ key: row.resource_key,
427
+ token,
428
+ queueVersion: row.version,
429
+ consecutiveErrors: row.consecutive_errors,
430
+ expiresAt: iso(expiresAtMs),
431
+ };
432
+ });
433
+ }
434
+
435
+ renewClaim(claim: ControllerQueueClaim, leaseMs: number, now?: string): boolean {
436
+ validateDuration(leaseMs, "leaseMs");
437
+ const nowMs = epoch(validTimestamp(now));
438
+ const expiresAt = nowMs + leaseMs;
439
+ const result = this.database
440
+ .prepare(
441
+ `UPDATE queue SET claim_expires_at = ?
442
+ WHERE controller = ? AND resource_key = ? AND claim_token = ?
443
+ AND claim_expires_at > ?`,
444
+ )
445
+ .run(expiresAt, claim.controller, claim.key, claim.token, nowMs);
446
+ return result.changes === 1;
447
+ }
448
+
449
+ settleClaim(claim: ControllerQueueClaim, now?: string): boolean {
450
+ const nowMs = epoch(validTimestamp(now));
451
+ return this.transaction(() => {
452
+ const row = this.claimedRow(claim);
453
+ if (row === undefined) {
454
+ return false;
455
+ }
456
+ if (row.version === row.claim_version) {
457
+ this.database
458
+ .prepare(
459
+ "DELETE FROM queue WHERE controller = ? AND resource_key = ? AND claim_token = ?",
460
+ )
461
+ .run(claim.controller, claim.key, claim.token);
462
+ } else {
463
+ this.database
464
+ .prepare(
465
+ `UPDATE queue
466
+ SET claim_token = NULL, claim_version = NULL, claim_expires_at = NULL,
467
+ available_at = MIN(available_at, ?), consecutive_errors = 0, last_error = NULL
468
+ WHERE controller = ? AND resource_key = ? AND claim_token = ?`,
469
+ )
470
+ .run(nowMs, claim.controller, claim.key, claim.token);
471
+ }
472
+ return true;
473
+ });
474
+ }
475
+
476
+ requeueClaim(claim: ControllerQueueClaim, options: QueueRequeueOptions, _now?: string): boolean {
477
+ const availableAt = epoch(validTimestamp(options.availableAt));
478
+ const error = options.error === undefined ? null : truncateError(options.error);
479
+ return this.transaction(() => {
480
+ const row = this.claimedRow(claim);
481
+ if (row === undefined) {
482
+ return false;
483
+ }
484
+ const eventArrived = row.version !== row.claim_version;
485
+ const nextAvailableAt = eventArrived ? Math.min(row.available_at, availableAt) : availableAt;
486
+ const nextErrors = options.error === undefined ? 0 : row.consecutive_errors + 1;
487
+ this.database
488
+ .prepare(
489
+ `UPDATE queue
490
+ SET claim_token = NULL, claim_version = NULL, claim_expires_at = NULL,
491
+ available_at = ?, consecutive_errors = ?, last_error = ?
492
+ WHERE controller = ? AND resource_key = ? AND claim_token = ?`,
493
+ )
494
+ .run(nextAvailableAt, nextErrors, error, claim.controller, claim.key, claim.token);
495
+ return true;
496
+ });
497
+ }
498
+
499
+ listQueue(): QueueItem[] {
500
+ const rows = this.database
501
+ .prepare("SELECT * FROM queue ORDER BY available_at, controller, resource_key")
502
+ .all() as QueueRow[];
503
+ return rows.map((row) => ({
504
+ controller: row.controller,
505
+ key: row.resource_key,
506
+ availableAt: iso(row.available_at),
507
+ consecutiveErrors: row.consecutive_errors,
508
+ ...(row.claim_expires_at === null ? {} : { claimExpiresAt: iso(row.claim_expires_at) }),
509
+ }));
510
+ }
511
+
512
+ reserveEffect(options: {
513
+ key: string;
514
+ resourceUid: string;
515
+ generation: number;
516
+ kind: string;
517
+ requestFingerprint: string;
518
+ now?: string;
519
+ }): EffectReservation {
520
+ validateKey(options.key, "effect key");
521
+ validateName(options.kind, "effect kind");
522
+ const now = validTimestamp(options.now);
523
+ return this.transaction(() => {
524
+ const existing = this.effectRow(options.resourceUid, options.key);
525
+ if (existing !== undefined) {
526
+ if (
527
+ existing.request_fingerprint !== options.requestFingerprint ||
528
+ existing.generation !== options.generation ||
529
+ existing.kind !== options.kind
530
+ ) {
531
+ throw new EffectRequestConflictError(options.key);
532
+ }
533
+ return { record: effectFromRow(existing), created: false };
534
+ }
535
+ this.database
536
+ .prepare(
537
+ `INSERT INTO effects (
538
+ effect_key, resource_uid, generation, kind, state,
539
+ request_fingerprint, started_at
540
+ ) VALUES (?, ?, ?, ?, 'pending', ?, ?)`,
541
+ )
542
+ .run(
543
+ options.key,
544
+ options.resourceUid,
545
+ options.generation,
546
+ options.kind,
547
+ options.requestFingerprint,
548
+ now,
549
+ );
550
+ return {
551
+ record: this.requireEffect(options.resourceUid, options.key),
552
+ created: true,
553
+ };
554
+ });
555
+ }
556
+
557
+ getEffect(resourceUid: string, key: string): EffectRecord | undefined {
558
+ const row = this.effectRow(resourceUid, key);
559
+ return row === undefined ? undefined : effectFromRow(row);
560
+ }
561
+
562
+ updateEffect(options: {
563
+ resourceUid: string;
564
+ key: string;
565
+ state: EffectRecord["state"];
566
+ externalRef?: string;
567
+ error?: string;
568
+ now?: string;
569
+ }): EffectRecord {
570
+ const row = this.effectRow(options.resourceUid, options.key);
571
+ if (row === undefined) {
572
+ throw new Error(`Effect record not found: ${options.resourceUid}/${options.key}`);
573
+ }
574
+ if ((row.state === "applied" || row.state === "rejected") && row.state !== options.state) {
575
+ throw new Error(`Effect ${JSON.stringify(options.key)} is already ${row.state}`);
576
+ }
577
+ const completedAt =
578
+ options.state === "applied" || options.state === "rejected"
579
+ ? validTimestamp(options.now)
580
+ : null;
581
+ this.database
582
+ .prepare(
583
+ `UPDATE effects
584
+ SET state = ?, external_ref = COALESCE(?, external_ref), error = ?, completed_at = ?
585
+ WHERE resource_uid = ? AND effect_key = ?`,
586
+ )
587
+ .run(
588
+ options.state,
589
+ options.externalRef ?? null,
590
+ options.error === undefined ? null : truncateError(options.error),
591
+ completedAt,
592
+ options.resourceUid,
593
+ options.key,
594
+ );
595
+ return this.requireEffect(options.resourceUid, options.key);
596
+ }
597
+
598
+ listEffects(resourceUid: string): EffectRecord[] {
599
+ const rows = this.database
600
+ .prepare("SELECT * FROM effects WHERE resource_uid = ? ORDER BY started_at, effect_key")
601
+ .all(resourceUid) as EffectRow[];
602
+ return rows.map(effectFromRow);
603
+ }
604
+
605
+ reserveWorkflow(options: {
606
+ resourceUid: string;
607
+ requestKey: string;
608
+ workflow: string;
609
+ inputFingerprint: string;
610
+ }): WorkflowReservation {
611
+ validateKey(options.requestKey, "workflow request key");
612
+ validateKey(options.workflow, "workflow name");
613
+ return this.transaction(() => {
614
+ const existing = this.workflowRow(options.resourceUid, options.requestKey);
615
+ if (existing !== undefined) {
616
+ if (
617
+ existing.input_fingerprint !== options.inputFingerprint ||
618
+ existing.workflow !== options.workflow
619
+ ) {
620
+ throw new WorkflowRequestConflictError(options.requestKey);
621
+ }
622
+ return { record: workflowFromRow(existing), created: false };
623
+ }
624
+ const requestId = randomUUID();
625
+ this.database
626
+ .prepare(
627
+ `INSERT INTO workflow_requests (
628
+ request_id, resource_uid, request_key, input_fingerprint,
629
+ workflow, state, attempt
630
+ ) VALUES (?, ?, ?, ?, ?, 'pending', 0)`,
631
+ )
632
+ .run(
633
+ requestId,
634
+ options.resourceUid,
635
+ options.requestKey,
636
+ options.inputFingerprint,
637
+ options.workflow,
638
+ );
639
+ return {
640
+ record: this.requireWorkflow(requestId),
641
+ created: true,
642
+ };
643
+ });
644
+ }
645
+
646
+ getWorkflow(resourceUid: string, requestKey: string): ChildWorkflowRecord | undefined {
647
+ const row = this.workflowRow(resourceUid, requestKey);
648
+ return row === undefined ? undefined : workflowFromRow(row);
649
+ }
650
+
651
+ getWorkflowByRequestId(requestId: string): ChildWorkflowRecord | undefined {
652
+ const row = this.database
653
+ .prepare("SELECT * FROM workflow_requests WHERE request_id = ?")
654
+ .get(requestId) as WorkflowRow | undefined;
655
+ return row === undefined ? undefined : workflowFromRow(row);
656
+ }
657
+
658
+ updateWorkflow(requestId: string, update: WorkflowRecordUpdate): ChildWorkflowRecord {
659
+ const existing = this.database
660
+ .prepare("SELECT * FROM workflow_requests WHERE request_id = ?")
661
+ .get(requestId) as WorkflowRow | undefined;
662
+ if (existing === undefined) {
663
+ throw new Error(`Workflow request not found: ${requestId}`);
664
+ }
665
+ const runId = update.runId === undefined ? existing.run_id : update.runId;
666
+ const attempt = update.attempt ?? existing.attempt;
667
+ const error =
668
+ update.error === undefined
669
+ ? existing.error
670
+ : update.error === null
671
+ ? null
672
+ : truncateError(update.error);
673
+ this.database
674
+ .prepare(
675
+ `UPDATE workflow_requests
676
+ SET run_id = ?, state = ?, attempt = ?, error = ?
677
+ WHERE request_id = ?`,
678
+ )
679
+ .run(runId, update.state, attempt, error, requestId);
680
+ return this.requireWorkflow(requestId);
681
+ }
682
+
683
+ listWorkflows(resourceUid: string): ChildWorkflowRecord[] {
684
+ const rows = this.database
685
+ .prepare("SELECT * FROM workflow_requests WHERE resource_uid = ? ORDER BY rowid")
686
+ .all(resourceUid) as WorkflowRow[];
687
+ return rows.map(workflowFromRow);
688
+ }
689
+
690
+ recordEvent(options: {
691
+ controller: string;
692
+ key: string;
693
+ type: string;
694
+ payload?: JsonObject;
695
+ now?: string;
696
+ }): ControllerEvent {
697
+ validateName(options.controller, "controller");
698
+ validateKey(options.key, "resource key");
699
+ validateName(options.type, "event type");
700
+ const recordedAt = validTimestamp(options.now);
701
+ const payloadJson = canonicalJson(options.payload ?? {}, "controller event payload");
702
+ if (Buffer.byteLength(payloadJson, "utf8") > MAX_EVENT_BYTES) {
703
+ throw new Error(`Controller event payload exceeds ${MAX_EVENT_BYTES} bytes`);
704
+ }
705
+ const result = this.database
706
+ .prepare(
707
+ `INSERT INTO events (recorded_at, controller, resource_key, type, payload_json)
708
+ VALUES (?, ?, ?, ?, ?)`,
709
+ )
710
+ .run(recordedAt, options.controller, options.key, options.type, payloadJson);
711
+ return this.requireEvent(Number(result.lastInsertRowid));
712
+ }
713
+
714
+ listEvents(
715
+ options: {
716
+ controller?: string;
717
+ key?: string;
718
+ limit?: number;
719
+ } = {},
720
+ ): ControllerEvent[] {
721
+ const limit = options.limit ?? 100;
722
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 10_000) {
723
+ throw new Error("Event limit must be between 1 and 10000");
724
+ }
725
+ const clauses: string[] = [];
726
+ const values: unknown[] = [];
727
+ if (options.controller !== undefined) {
728
+ clauses.push("controller = ?");
729
+ values.push(options.controller);
730
+ }
731
+ if (options.key !== undefined) {
732
+ clauses.push("resource_key = ?");
733
+ values.push(options.key);
734
+ }
735
+ const where = clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`;
736
+ const rows = this.database
737
+ .prepare(`SELECT * FROM events ${where} ORDER BY seq DESC LIMIT ?`)
738
+ .all(...values, limit) as EventRow[];
739
+ return rows.map(eventFromRow);
740
+ }
741
+
742
+ private configure(readOnly: boolean): void {
743
+ if (!readOnly) {
744
+ this.database.pragma("journal_mode = WAL");
745
+ this.database.pragma("synchronous = FULL");
746
+ this.database.pragma("foreign_keys = ON");
747
+ }
748
+ this.database.pragma("busy_timeout = 5000");
749
+ }
750
+
751
+ private initializeSchema(readOnly: boolean): void {
752
+ if (!readOnly) {
753
+ this.database.exec(`
754
+ CREATE TABLE IF NOT EXISTS schema_info (
755
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
756
+ schema_id TEXT NOT NULL
757
+ );
758
+ `);
759
+ }
760
+ const row = this.database
761
+ .prepare("SELECT schema_id FROM schema_info WHERE singleton = 1")
762
+ .get() as { schema_id: string } | undefined;
763
+ if (row !== undefined && row.schema_id !== CONTROLLER_STORE_SCHEMA) {
764
+ throw new Error(`Unsupported controller store schema: ${row.schema_id}`);
765
+ }
766
+ if (readOnly) {
767
+ if (row === undefined) {
768
+ throw new Error("Controller store has no schema identifier");
769
+ }
770
+ return;
771
+ }
772
+ this.transaction(() => {
773
+ this.database
774
+ .prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
775
+ .run(CONTROLLER_STORE_SCHEMA);
776
+ this.database.exec(SCHEMA_SQL);
777
+ });
778
+ }
779
+
780
+ private transaction<T>(task: () => T): T {
781
+ this.database.exec("BEGIN IMMEDIATE");
782
+ try {
783
+ const value = task();
784
+ this.database.exec("COMMIT");
785
+ return value;
786
+ } catch (error) {
787
+ this.database.exec("ROLLBACK");
788
+ throw error;
789
+ }
790
+ }
791
+
792
+ private resourceRow(ref: ControllerResourceRef): ResourceRow | undefined {
793
+ return this.database
794
+ .prepare("SELECT * FROM resources WHERE controller = ? AND resource_key = ?")
795
+ .get(ref.controller, ref.key) as ResourceRow | undefined;
796
+ }
797
+
798
+ private requireResource<TSpec = unknown, TStatus = unknown>(
799
+ ref: ControllerResourceRef,
800
+ ): ControllerResource<TSpec, TStatus> {
801
+ const resource = this.getResource<TSpec, TStatus>(ref);
802
+ if (resource === undefined) {
803
+ throw new ResourceNotFoundError(ref.controller, ref.key);
804
+ }
805
+ return resource;
806
+ }
807
+
808
+ private throwMissingOrConflict(ref: ControllerResourceRef): never {
809
+ if (this.resourceRow(ref) === undefined) {
810
+ throw new ResourceNotFoundError(ref.controller, ref.key);
811
+ }
812
+ throw new ResourceConflictError(ref.controller, ref.key);
813
+ }
814
+
815
+ private enqueueRow(ref: ControllerResourceRef, availableAt: number): void {
816
+ this.database
817
+ .prepare(
818
+ `INSERT INTO queue (
819
+ controller, resource_key, available_at, version, consecutive_errors
820
+ ) VALUES (?, ?, ?, 1, 0)
821
+ ON CONFLICT(controller, resource_key) DO UPDATE SET
822
+ available_at = MIN(queue.available_at, excluded.available_at),
823
+ version = queue.version + 1`,
824
+ )
825
+ .run(ref.controller, ref.key, availableAt);
826
+ }
827
+
828
+ private claimedRow(claim: ControllerQueueClaim): QueueRow | undefined {
829
+ return this.database
830
+ .prepare(
831
+ `SELECT * FROM queue
832
+ WHERE controller = ? AND resource_key = ? AND claim_token = ?`,
833
+ )
834
+ .get(claim.controller, claim.key, claim.token) as QueueRow | undefined;
835
+ }
836
+
837
+ private effectRow(resourceUid: string, key: string): EffectRow | undefined {
838
+ return this.database
839
+ .prepare("SELECT * FROM effects WHERE resource_uid = ? AND effect_key = ?")
840
+ .get(resourceUid, key) as EffectRow | undefined;
841
+ }
842
+
843
+ private requireEffect(resourceUid: string, key: string): EffectRecord {
844
+ const effect = this.getEffect(resourceUid, key);
845
+ if (effect === undefined) {
846
+ throw new Error(`Effect record not found: ${resourceUid}/${key}`);
847
+ }
848
+ return effect;
849
+ }
850
+
851
+ private workflowRow(resourceUid: string, requestKey: string): WorkflowRow | undefined {
852
+ return this.database
853
+ .prepare("SELECT * FROM workflow_requests WHERE resource_uid = ? AND request_key = ?")
854
+ .get(resourceUid, requestKey) as WorkflowRow | undefined;
855
+ }
856
+
857
+ private requireWorkflow(requestId: string): ChildWorkflowRecord {
858
+ const workflow = this.getWorkflowByRequestId(requestId);
859
+ if (workflow === undefined) {
860
+ throw new Error(`Workflow request not found: ${requestId}`);
861
+ }
862
+ return workflow;
863
+ }
864
+
865
+ /**
866
+ * Insert a user-started run and claim it in one statement, so the
867
+ * originating runner owns the run from birth (origin affinity).
868
+ */
869
+ enqueueWorkflowRun(options: {
870
+ runId: string;
871
+ workflowName: string;
872
+ workflowSourceRef: string;
873
+ input: unknown;
874
+ runnerId: string;
875
+ claimToken: string;
876
+ leaseMs: number;
877
+ affinityRunnerId?: string;
878
+ parentRunId?: string;
879
+ now?: string;
880
+ }): WorkflowRunQueueRecord {
881
+ validateRunId(options.runId);
882
+ validateKey(options.workflowName, "workflow name");
883
+ validateKey(options.workflowSourceRef, "workflow source ref");
884
+ validateKey(options.runnerId, "runner id");
885
+ validateKey(options.claimToken, "claim token");
886
+ validateDuration(options.leaseMs, "leaseMs");
887
+ const inputJson = canonicalJson(options.input ?? null, "workflow run input");
888
+ validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
889
+ const now = validTimestamp(options.now);
890
+ const expiresAt = epoch(now) + options.leaseMs;
891
+ this.database
892
+ .prepare(
893
+ `INSERT INTO workflow_run_queue (
894
+ run_id, workflow_ref, workflow_path, input_json, status,
895
+ runner_id, claim_token, claim_expires_at, affinity_runner_id,
896
+ parent_run_id, created_at, updated_at
897
+ ) VALUES (?, ?, ?, ?, 'claimed', ?, ?, ?, ?, ?, ?, ?)`,
898
+ )
899
+ .run(
900
+ options.runId,
901
+ options.workflowName,
902
+ options.workflowSourceRef,
903
+ inputJson,
904
+ options.runnerId,
905
+ options.claimToken,
906
+ expiresAt,
907
+ options.affinityRunnerId ?? options.runnerId,
908
+ options.parentRunId ?? null,
909
+ now,
910
+ now,
911
+ );
912
+ return this.requireWorkflowRun(options.runId);
913
+ }
914
+
915
+ getWorkflowRun(runId: string): WorkflowRunQueueRecord | undefined {
916
+ validateRunId(runId);
917
+ const row = this.database
918
+ .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
919
+ .get(runId) as WorkflowRunQueueRow | undefined;
920
+ return row === undefined ? undefined : workflowRunFromRow(row);
921
+ }
922
+
923
+ listWorkflowRuns(
924
+ options: { status?: WorkflowRunQueueRecord["status"] } = {},
925
+ ): WorkflowRunQueueRecord[] {
926
+ const rows =
927
+ options.status === undefined
928
+ ? (this.database
929
+ .prepare("SELECT * FROM workflow_run_queue ORDER BY created_at")
930
+ .all() as WorkflowRunQueueRow[])
931
+ : (this.database
932
+ .prepare("SELECT * FROM workflow_run_queue WHERE status = ? ORDER BY created_at")
933
+ .all(options.status) as WorkflowRunQueueRow[]);
934
+ return rows.map(workflowRunFromRow);
935
+ }
936
+
937
+ /**
938
+ * Claim the oldest claimable run, preferring runs with affinity to this
939
+ * runner. Parked rows are claimable immediately; claimed rows become
940
+ * claimable once their lease expires, so a dead or stalled runner never
941
+ * strands a run. Returns undefined when nothing is claimable.
942
+ */
943
+ claimNextWorkflowRun(options: {
944
+ runnerId: string;
945
+ claimToken: string;
946
+ leaseMs: number;
947
+ excludeRunIds?: string[];
948
+ now?: string;
949
+ }): WorkflowRunQueueRecord | undefined {
950
+ validateKey(options.runnerId, "runner id");
951
+ validateKey(options.claimToken, "claim token");
952
+ validateDuration(options.leaseMs, "leaseMs");
953
+ const now = validTimestamp(options.now);
954
+ const nowMs = epoch(now);
955
+ const expiresAt = nowMs + options.leaseMs;
956
+ const excluded = options.excludeRunIds ?? [];
957
+ for (const runId of excluded) {
958
+ validateRunId(runId);
959
+ }
960
+ const exclusion =
961
+ excluded.length === 0 ? "" : `AND run_id NOT IN (${excluded.map(() => "?").join(", ")})`;
962
+ const claimable = `(
963
+ status = 'parked'
964
+ OR (status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
965
+ )`;
966
+ return this.transaction(() => {
967
+ const candidate = this.database
968
+ .prepare(
969
+ `SELECT run_id FROM workflow_run_queue
970
+ WHERE ${claimable} ${exclusion}
971
+ ORDER BY
972
+ CASE WHEN affinity_runner_id = ? THEN 0 ELSE 1 END,
973
+ created_at ASC
974
+ LIMIT 1`,
975
+ )
976
+ .get(nowMs, ...excluded, options.runnerId) as { run_id: string } | undefined;
977
+ if (candidate === undefined) {
978
+ return undefined;
979
+ }
980
+ const result = this.database
981
+ .prepare(
982
+ `UPDATE workflow_run_queue
983
+ SET status = 'claimed', runner_id = ?, claim_token = ?,
984
+ claim_expires_at = ?, updated_at = ?
985
+ WHERE run_id = ? AND (
986
+ status = 'parked'
987
+ OR (status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
988
+ )`,
989
+ )
990
+ .run(options.runnerId, options.claimToken, expiresAt, now, candidate.run_id, nowMs);
991
+ return result.changes === 1 ? this.requireWorkflowRun(candidate.run_id) : undefined;
992
+ });
993
+ }
994
+
995
+ /** Extend a live claim. Rejects expired or foreign claims. */
996
+ renewWorkflowRunClaim(options: {
997
+ runId: string;
998
+ claimToken: string;
999
+ leaseMs: number;
1000
+ now?: string;
1001
+ }): boolean {
1002
+ validateRunId(options.runId);
1003
+ validateDuration(options.leaseMs, "leaseMs");
1004
+ const nowMs = epoch(validTimestamp(options.now));
1005
+ const expiresAt = nowMs + options.leaseMs;
1006
+ const result = this.database
1007
+ .prepare(
1008
+ `UPDATE workflow_run_queue SET claim_expires_at = ?
1009
+ WHERE run_id = ? AND claim_token = ? AND status = 'claimed'
1010
+ AND claim_expires_at > ?`,
1011
+ )
1012
+ .run(expiresAt, options.runId, options.claimToken, nowMs);
1013
+ return result.changes === 1;
1014
+ }
1015
+
1016
+ /** The fence check: true only while this exact claim is live. */
1017
+ verifyWorkflowRunClaim(options: { runId: string; claimToken: string; now?: string }): boolean {
1018
+ validateRunId(options.runId);
1019
+ const nowMs = epoch(validTimestamp(options.now));
1020
+ const row = this.database
1021
+ .prepare(
1022
+ `SELECT 1 AS live FROM workflow_run_queue
1023
+ WHERE run_id = ? AND claim_token = ? AND status = 'claimed'
1024
+ AND claim_expires_at > ?`,
1025
+ )
1026
+ .get(options.runId, options.claimToken, nowMs);
1027
+ return row !== undefined;
1028
+ }
1029
+
1030
+ /** Release a claim and park the run so another runner can resume it. */
1031
+ parkWorkflowRun(options: { runId: string; claimToken: string; now?: string }): boolean {
1032
+ validateRunId(options.runId);
1033
+ const now = validTimestamp(options.now);
1034
+ const result = this.database
1035
+ .prepare(
1036
+ `UPDATE workflow_run_queue
1037
+ SET status = 'parked', runner_id = NULL, claim_token = NULL,
1038
+ claim_expires_at = NULL, updated_at = ?
1039
+ WHERE run_id = ? AND claim_token = ? AND status = 'claimed'`,
1040
+ )
1041
+ .run(now, options.runId, options.claimToken);
1042
+ return result.changes === 1;
1043
+ }
1044
+
1045
+ /**
1046
+ * Delete a claimed row. Used when a continuation fails before its bundle
1047
+ * exists, so the parent's one-continuation slot is not consumed by a run
1048
+ * that never happened.
1049
+ */
1050
+ deleteWorkflowRun(options: { runId: string; claimToken: string }): boolean {
1051
+ validateRunId(options.runId);
1052
+ const result = this.database
1053
+ .prepare("DELETE FROM workflow_run_queue WHERE run_id = ? AND claim_token = ?")
1054
+ .run(options.runId, options.claimToken);
1055
+ return result.changes === 1;
1056
+ }
1057
+
1058
+ /** Release a claim and mark the run terminal in the queue. */
1059
+ completeWorkflowRun(options: { runId: string; claimToken: string; now?: string }): boolean {
1060
+ validateRunId(options.runId);
1061
+ const now = validTimestamp(options.now);
1062
+ const result = this.database
1063
+ .prepare(
1064
+ `UPDATE workflow_run_queue
1065
+ SET status = 'done', runner_id = NULL, claim_token = NULL,
1066
+ claim_expires_at = NULL, updated_at = ?
1067
+ WHERE run_id = ? AND claim_token = ? AND status = 'claimed'`,
1068
+ )
1069
+ .run(now, options.runId, options.claimToken);
1070
+ return result.changes === 1;
1071
+ }
1072
+
1073
+ /** Repair a canonical bundle's queue source and claim it only when needed. */
1074
+ repairCanonicalWorkflowSourceRun(options: {
1075
+ runId: string;
1076
+ workflowName: string;
1077
+ workflowSourceRef: string;
1078
+ runnerId: string;
1079
+ claimToken: string;
1080
+ leaseMs: number;
1081
+ now?: string;
1082
+ }): "unchanged" | "claimed" | false {
1083
+ validateRunId(options.runId);
1084
+ validateKey(options.workflowName, "workflow name");
1085
+ validateKey(options.workflowSourceRef, "workflow source ref");
1086
+ validateKey(options.runnerId, "runner id");
1087
+ validateKey(options.claimToken, "claim token");
1088
+ validateDuration(options.leaseMs, "leaseMs");
1089
+ const now = validTimestamp(options.now);
1090
+ const nowMs = epoch(now);
1091
+ const expiresAt = nowMs + options.leaseMs;
1092
+ return this.transaction(() => {
1093
+ const row = this.database
1094
+ .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
1095
+ .get(options.runId) as WorkflowRunQueueRow | undefined;
1096
+ if (row === undefined || row.status === "done") return false;
1097
+ if (row.workflow_path === options.workflowSourceRef && row.status === "parked") {
1098
+ return "unchanged";
1099
+ }
1100
+ const claimable =
1101
+ row.status === "parked" ||
1102
+ (row.status === "claimed" &&
1103
+ row.claim_token === options.claimToken &&
1104
+ row.claim_expires_at !== null &&
1105
+ row.claim_expires_at > nowMs) ||
1106
+ (row.status === "claimed" &&
1107
+ row.claim_expires_at !== null &&
1108
+ row.claim_expires_at <= nowMs);
1109
+ if (!claimable) return false;
1110
+ const result = this.database
1111
+ .prepare(
1112
+ `UPDATE workflow_run_queue
1113
+ SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
1114
+ claim_token = ?, claim_expires_at = ?, updated_at = ?
1115
+ WHERE run_id = ? AND status != 'done'`,
1116
+ )
1117
+ .run(
1118
+ options.workflowName,
1119
+ options.workflowSourceRef,
1120
+ options.runnerId,
1121
+ options.claimToken,
1122
+ expiresAt,
1123
+ now,
1124
+ options.runId,
1125
+ );
1126
+ return result.changes === 1 ? "claimed" : false;
1127
+ });
1128
+ }
1129
+
1130
+ /** Claim and rewrite one proved legacy workflow source queue row atomically. */
1131
+ claimLegacyWorkflowSourceRun(options: {
1132
+ runId: string;
1133
+ workflowName: string;
1134
+ oldWorkflowPath: string;
1135
+ workflowSourceRef: string;
1136
+ runnerId: string;
1137
+ claimToken: string;
1138
+ leaseMs: number;
1139
+ now?: string;
1140
+ }): boolean {
1141
+ validateRunId(options.runId);
1142
+ validateKey(options.workflowName, "workflow name");
1143
+ validateKey(options.oldWorkflowPath, "legacy workflow path");
1144
+ validateKey(options.workflowSourceRef, "workflow source ref");
1145
+ validateKey(options.runnerId, "runner id");
1146
+ validateKey(options.claimToken, "claim token");
1147
+ validateDuration(options.leaseMs, "leaseMs");
1148
+ const now = validTimestamp(options.now);
1149
+ const nowMs = epoch(now);
1150
+ const expiresAt = nowMs + options.leaseMs;
1151
+ return this.transaction(() => {
1152
+ const row = this.database
1153
+ .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
1154
+ .get(options.runId) as WorkflowRunQueueRow | undefined;
1155
+ if (row === undefined || row.status === "done") return false;
1156
+ const sourceMatches =
1157
+ row.workflow_path === options.oldWorkflowPath ||
1158
+ row.workflow_path === options.workflowSourceRef;
1159
+ const claimable =
1160
+ row.status === "parked" ||
1161
+ (row.status === "claimed" &&
1162
+ row.claim_token === options.claimToken &&
1163
+ row.claim_expires_at !== null &&
1164
+ row.claim_expires_at > nowMs) ||
1165
+ (row.status === "claimed" &&
1166
+ row.claim_expires_at !== null &&
1167
+ row.claim_expires_at <= nowMs);
1168
+ if (!sourceMatches || !claimable) return false;
1169
+ const result = this.database
1170
+ .prepare(
1171
+ `UPDATE workflow_run_queue
1172
+ SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
1173
+ claim_token = ?, claim_expires_at = ?, updated_at = ?
1174
+ WHERE run_id = ? AND status != 'done'`,
1175
+ )
1176
+ .run(
1177
+ options.workflowName,
1178
+ options.workflowSourceRef,
1179
+ options.runnerId,
1180
+ options.claimToken,
1181
+ expiresAt,
1182
+ now,
1183
+ options.runId,
1184
+ );
1185
+ return result.changes === 1;
1186
+ });
1187
+ }
1188
+
1189
+ /** Append a run lifecycle transition to the event feed. */
1190
+ recordRunEvent(options: {
1191
+ runId: string;
1192
+ workflowRef: string;
1193
+ type: string;
1194
+ runnerId?: string;
1195
+ payload?: JsonObject;
1196
+ now?: string;
1197
+ }): number {
1198
+ validateRunId(options.runId);
1199
+ validateKey(options.type, "run event type");
1200
+ const payloadJson = canonicalJson(options.payload ?? {}, "run event payload");
1201
+ validateJsonSize(payloadJson, "Run event payload", MAX_EVENT_BYTES);
1202
+ const result = this.database
1203
+ .prepare(
1204
+ `INSERT INTO run_events (recorded_at, run_id, workflow_ref, type, runner_id, payload_json)
1205
+ VALUES (?, ?, ?, ?, ?, ?)`,
1206
+ )
1207
+ .run(
1208
+ validTimestamp(options.now),
1209
+ options.runId,
1210
+ options.workflowRef,
1211
+ options.type,
1212
+ options.runnerId ?? null,
1213
+ payloadJson,
1214
+ );
1215
+ return Number(result.lastInsertRowid);
1216
+ }
1217
+
1218
+ /** List run events after a watermark, oldest first. */
1219
+ listRunEventsAfter(seq: number, options: { limit?: number } = {}): RunEventRecord[] {
1220
+ const limit = options.limit ?? 50;
1221
+ if (!Number.isSafeInteger(seq) || seq < 0) {
1222
+ throw new Error(`Invalid run event watermark: ${seq}`);
1223
+ }
1224
+ const rows = this.database
1225
+ .prepare("SELECT * FROM run_events WHERE seq > ? ORDER BY seq ASC LIMIT ?")
1226
+ .all(seq, limit) as RunEventRow[];
1227
+ return rows.map((row) => ({
1228
+ seq: row.seq,
1229
+ recordedAt: row.recorded_at,
1230
+ runId: row.run_id,
1231
+ workflowRef: row.workflow_ref,
1232
+ type: row.type,
1233
+ runnerId: row.runner_id,
1234
+ payload: parseStoredJson<JsonObject>(row.payload_json, "run event payload"),
1235
+ }));
1236
+ }
1237
+
1238
+ /** The highest run event seq in the feed; 0 when empty. */
1239
+ latestRunEventSeq(): number {
1240
+ const row = this.database.prepare("SELECT MAX(seq) AS latest FROM run_events").get() as {
1241
+ latest: number | null;
1242
+ };
1243
+ return row.latest ?? 0;
1244
+ }
1245
+
1246
+ /** The last run event this session was told about; 0 before any sync. */
1247
+ getSessionWatermark(sessionId: string): number {
1248
+ validateKey(sessionId, "session id");
1249
+ const row = this.database
1250
+ .prepare("SELECT last_event_seq FROM session_watermarks WHERE session_id = ?")
1251
+ .get(sessionId) as { last_event_seq: number } | undefined;
1252
+ return row?.last_event_seq ?? 0;
1253
+ }
1254
+
1255
+ setSessionWatermark(sessionId: string, seq: number, now?: string): void {
1256
+ validateKey(sessionId, "session id");
1257
+ if (!Number.isSafeInteger(seq) || seq < 0) {
1258
+ throw new Error(`Invalid run event watermark: ${seq}`);
1259
+ }
1260
+ this.database
1261
+ .prepare(
1262
+ `INSERT INTO session_watermarks (session_id, last_event_seq, updated_at)
1263
+ VALUES (?, ?, ?)
1264
+ ON CONFLICT(session_id) DO UPDATE SET
1265
+ last_event_seq = MAX(session_watermarks.last_event_seq, excluded.last_event_seq),
1266
+ updated_at = excluded.updated_at`,
1267
+ )
1268
+ .run(sessionId, seq, validTimestamp(now));
1269
+ }
1270
+
1271
+ private requireWorkflowRun(runId: string): WorkflowRunQueueRecord {
1272
+ const record = this.getWorkflowRun(runId);
1273
+ if (record === undefined) {
1274
+ throw new Error(`Workflow run queue record not found: ${runId}`);
1275
+ }
1276
+ return record;
1277
+ }
1278
+
1279
+ private requireEvent(seq: number): ControllerEvent {
1280
+ const row = this.database.prepare("SELECT * FROM events WHERE seq = ?").get(seq) as
1281
+ | EventRow
1282
+ | undefined;
1283
+ if (row === undefined) {
1284
+ throw new Error(`Controller event not found: ${seq}`);
1285
+ }
1286
+ return eventFromRow(row);
1287
+ }
1288
+ }
1289
+
1290
+ const SCHEMA_SQL = `
1291
+ CREATE TABLE IF NOT EXISTS resources (
1292
+ controller TEXT NOT NULL,
1293
+ resource_key TEXT NOT NULL,
1294
+ uid TEXT NOT NULL UNIQUE,
1295
+ resource_version INTEGER NOT NULL CHECK (resource_version > 0),
1296
+ generation INTEGER NOT NULL CHECK (generation > 0),
1297
+ spec_json TEXT NOT NULL,
1298
+ status_json TEXT NOT NULL,
1299
+ deletion_timestamp TEXT,
1300
+ finalizers_json TEXT NOT NULL,
1301
+ updated_at TEXT NOT NULL,
1302
+ PRIMARY KEY (controller, resource_key)
1303
+ );
1304
+
1305
+ CREATE TABLE IF NOT EXISTS queue (
1306
+ controller TEXT NOT NULL,
1307
+ resource_key TEXT NOT NULL,
1308
+ available_at INTEGER NOT NULL,
1309
+ version INTEGER NOT NULL CHECK (version > 0),
1310
+ consecutive_errors INTEGER NOT NULL CHECK (consecutive_errors >= 0),
1311
+ last_error TEXT,
1312
+ claim_token TEXT,
1313
+ claim_version INTEGER,
1314
+ claim_expires_at INTEGER,
1315
+ PRIMARY KEY (controller, resource_key),
1316
+ FOREIGN KEY (controller, resource_key)
1317
+ REFERENCES resources(controller, resource_key) ON DELETE CASCADE
1318
+ );
1319
+ CREATE INDEX IF NOT EXISTS queue_ready
1320
+ ON queue(available_at, controller, resource_key);
1321
+
1322
+ CREATE TABLE IF NOT EXISTS workflow_run_queue (
1323
+ run_id TEXT PRIMARY KEY,
1324
+ workflow_ref TEXT NOT NULL,
1325
+ workflow_path TEXT NOT NULL,
1326
+ input_json TEXT NOT NULL,
1327
+ status TEXT NOT NULL CHECK (status IN ('claimed', 'parked', 'done')),
1328
+ runner_id TEXT,
1329
+ claim_token TEXT,
1330
+ claim_expires_at INTEGER,
1331
+ affinity_runner_id TEXT,
1332
+ parent_run_id TEXT,
1333
+ created_at TEXT NOT NULL,
1334
+ updated_at TEXT NOT NULL
1335
+ );
1336
+ CREATE INDEX IF NOT EXISTS workflow_run_queue_claimable
1337
+ ON workflow_run_queue(status, claim_expires_at);
1338
+ -- A checkpointed parent admits exactly one continuation run, across
1339
+ -- sessions and processes.
1340
+ CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_parent
1341
+ ON workflow_run_queue(parent_run_id) WHERE parent_run_id IS NOT NULL;
1342
+
1343
+ CREATE TABLE IF NOT EXISTS run_events (
1344
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
1345
+ recorded_at TEXT NOT NULL,
1346
+ run_id TEXT NOT NULL,
1347
+ workflow_ref TEXT NOT NULL,
1348
+ type TEXT NOT NULL,
1349
+ runner_id TEXT,
1350
+ payload_json TEXT NOT NULL
1351
+ );
1352
+ CREATE INDEX IF NOT EXISTS run_events_run ON run_events(run_id);
1353
+
1354
+ CREATE TABLE IF NOT EXISTS session_watermarks (
1355
+ session_id TEXT PRIMARY KEY,
1356
+ last_event_seq INTEGER NOT NULL CHECK (last_event_seq >= 0),
1357
+ updated_at TEXT NOT NULL
1358
+ );
1359
+
1360
+ CREATE TABLE IF NOT EXISTS effects (
1361
+ effect_key TEXT NOT NULL,
1362
+ resource_uid TEXT NOT NULL,
1363
+ generation INTEGER NOT NULL CHECK (generation > 0),
1364
+ kind TEXT NOT NULL,
1365
+ state TEXT NOT NULL CHECK (state IN ('pending', 'applied', 'rejected', 'indeterminate')),
1366
+ request_fingerprint TEXT NOT NULL,
1367
+ started_at TEXT NOT NULL,
1368
+ completed_at TEXT,
1369
+ external_ref TEXT,
1370
+ error TEXT,
1371
+ PRIMARY KEY (resource_uid, effect_key),
1372
+ FOREIGN KEY (resource_uid) REFERENCES resources(uid) ON DELETE CASCADE
1373
+ );
1374
+
1375
+ CREATE TABLE IF NOT EXISTS workflow_requests (
1376
+ request_id TEXT PRIMARY KEY,
1377
+ resource_uid TEXT NOT NULL,
1378
+ request_key TEXT NOT NULL,
1379
+ input_fingerprint TEXT NOT NULL,
1380
+ workflow TEXT NOT NULL,
1381
+ run_id TEXT,
1382
+ state TEXT NOT NULL CHECK (
1383
+ state IN ('pending', 'running', 'waiting', 'succeeded', 'failed', 'interrupted')
1384
+ ),
1385
+ attempt INTEGER NOT NULL CHECK (attempt >= 0),
1386
+ error TEXT,
1387
+ UNIQUE (resource_uid, request_key),
1388
+ FOREIGN KEY (resource_uid) REFERENCES resources(uid) ON DELETE CASCADE
1389
+ );
1390
+
1391
+ CREATE TABLE IF NOT EXISTS events (
1392
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
1393
+ recorded_at TEXT NOT NULL,
1394
+ controller TEXT NOT NULL,
1395
+ resource_key TEXT NOT NULL,
1396
+ type TEXT NOT NULL,
1397
+ payload_json TEXT NOT NULL
1398
+ );
1399
+ CREATE INDEX IF NOT EXISTS events_resource
1400
+ ON events(controller, resource_key, seq DESC);
1401
+ `;
1402
+
1403
+ function resourceFromRow<TSpec, TStatus>(row: ResourceRow): ControllerResource<TSpec, TStatus> {
1404
+ const finalizers = parseStoredJson<string[]>(row.finalizers_json, "resource finalizers");
1405
+ validateFinalizers(finalizers);
1406
+ return {
1407
+ metadata: {
1408
+ uid: row.uid,
1409
+ controller: row.controller,
1410
+ key: row.resource_key,
1411
+ resourceVersion: row.resource_version,
1412
+ generation: row.generation,
1413
+ ...(row.deletion_timestamp === null ? {} : { deletionTimestamp: row.deletion_timestamp }),
1414
+ finalizers,
1415
+ },
1416
+ spec: parseStoredJson<TSpec>(row.spec_json, "resource spec"),
1417
+ status: parseStoredJson<ControllerResourceStatus<TStatus>>(row.status_json, "resource status"),
1418
+ };
1419
+ }
1420
+
1421
+ function effectFromRow(row: EffectRow): EffectRecord {
1422
+ return {
1423
+ key: row.effect_key,
1424
+ resourceUid: row.resource_uid,
1425
+ generation: row.generation,
1426
+ kind: row.kind,
1427
+ state: row.state,
1428
+ requestFingerprint: row.request_fingerprint,
1429
+ startedAt: row.started_at,
1430
+ ...(row.completed_at === null ? {} : { completedAt: row.completed_at }),
1431
+ ...(row.external_ref === null ? {} : { externalRef: row.external_ref }),
1432
+ ...(row.error === null ? {} : { error: row.error }),
1433
+ };
1434
+ }
1435
+
1436
+ function workflowFromRow(row: WorkflowRow): ChildWorkflowRecord {
1437
+ return {
1438
+ requestId: row.request_id,
1439
+ resourceUid: row.resource_uid,
1440
+ requestKey: row.request_key,
1441
+ inputFingerprint: row.input_fingerprint,
1442
+ workflow: row.workflow,
1443
+ state: row.state,
1444
+ attempt: row.attempt,
1445
+ ...(row.run_id === null ? {} : { runId: row.run_id }),
1446
+ ...(row.error === null ? {} : { error: row.error }),
1447
+ };
1448
+ }
1449
+
1450
+ function workflowRunFromRow(row: WorkflowRunQueueRow): WorkflowRunQueueRecord {
1451
+ return {
1452
+ runId: row.run_id,
1453
+ workflowName: row.workflow_ref,
1454
+ workflowSourceRef: row.workflow_path,
1455
+ input: parseStoredJson(row.input_json, "workflow run input"),
1456
+ status: row.status,
1457
+ runnerId: row.runner_id,
1458
+ claimToken: row.claim_token,
1459
+ claimExpiresAt: row.claim_expires_at === null ? null : iso(row.claim_expires_at),
1460
+ affinityRunnerId: row.affinity_runner_id,
1461
+ parentRunId: row.parent_run_id,
1462
+ createdAt: row.created_at,
1463
+ updatedAt: row.updated_at,
1464
+ };
1465
+ }
1466
+
1467
+ function validateRunId(runId: string): void {
1468
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
1469
+ throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
1470
+ }
1471
+ }
1472
+
1473
+ function eventFromRow(row: EventRow): ControllerEvent {
1474
+ return {
1475
+ seq: row.seq,
1476
+ recordedAt: row.recorded_at,
1477
+ controller: row.controller,
1478
+ key: row.resource_key,
1479
+ type: row.type,
1480
+ payload: parseStoredJson<JsonObject>(row.payload_json, "controller event payload"),
1481
+ };
1482
+ }
1483
+
1484
+ function validTimestamp(value: string | undefined): string {
1485
+ const timestamp = value ?? new Date().toISOString();
1486
+ if (!Number.isFinite(Date.parse(timestamp))) {
1487
+ throw new Error(`Invalid timestamp: ${JSON.stringify(timestamp)}`);
1488
+ }
1489
+ return timestamp;
1490
+ }
1491
+
1492
+ function epoch(value: string): number {
1493
+ const parsed = Date.parse(value);
1494
+ if (!Number.isSafeInteger(parsed)) {
1495
+ throw new Error(`Timestamp is outside the supported range: ${value}`);
1496
+ }
1497
+ return parsed;
1498
+ }
1499
+
1500
+ function iso(value: number): string {
1501
+ return new Date(value).toISOString();
1502
+ }
1503
+
1504
+ function validateDuration(value: number, name: string): void {
1505
+ if (!Number.isSafeInteger(value) || value <= 0) {
1506
+ throw new Error(`${name} must be a positive safe integer`);
1507
+ }
1508
+ }
1509
+
1510
+ function validateName(value: string, description: string): void {
1511
+ if (!/^[A-Za-z][A-Za-z0-9_.-]*$/.test(value)) {
1512
+ throw new Error(`${description} contains unsupported characters: ${JSON.stringify(value)}`);
1513
+ }
1514
+ }
1515
+
1516
+ function validateKey(value: string, description: string): void {
1517
+ const hasControl = [...value].some((character) => {
1518
+ const code = character.codePointAt(0) as number;
1519
+ return code < 32 || code === 127;
1520
+ });
1521
+ if (value.length === 0 || value.length > MAX_KEY_LENGTH || hasControl) {
1522
+ throw new Error(`${description} must be 1-${MAX_KEY_LENGTH} printable characters`);
1523
+ }
1524
+ }
1525
+
1526
+ function validateFinalizers(finalizers: string[]): void {
1527
+ const seen = new Set<string>();
1528
+ for (const finalizer of finalizers) {
1529
+ validateName(finalizer, "finalizer");
1530
+ if (seen.has(finalizer)) {
1531
+ throw new Error(`Duplicate finalizer: ${finalizer}`);
1532
+ }
1533
+ seen.add(finalizer);
1534
+ }
1535
+ }
1536
+
1537
+ function validateJsonSize(value: string, description: string, limit: number): void {
1538
+ if (Buffer.byteLength(value, "utf8") > limit) {
1539
+ throw new Error(`${description} exceeds the ${limit}-byte storage limit`);
1540
+ }
1541
+ }
1542
+
1543
+ function truncateError(error: string): string {
1544
+ return error.length <= MAX_ERROR_LENGTH ? error : `${error.slice(0, MAX_ERROR_LENGTH)}…`;
1545
+ }