@osolmaz/pi-workflows 0.2.0 → 0.3.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 (133) hide show
  1. package/README.md +71 -5
  2. package/dist/builtins/monitor.workflow.d.ts +71 -0
  3. package/dist/builtins/monitor.workflow.js +234 -0
  4. package/dist/builtins/monitor.workflow.js.map +1 -0
  5. package/dist/controllers/conditions.d.ts +6 -0
  6. package/dist/controllers/conditions.js +68 -0
  7. package/dist/controllers/conditions.js.map +1 -0
  8. package/dist/controllers/definition.d.ts +6 -0
  9. package/dist/controllers/definition.js +45 -0
  10. package/dist/controllers/definition.js.map +1 -0
  11. package/dist/controllers/effects.d.ts +14 -0
  12. package/dist/controllers/effects.js +104 -0
  13. package/dist/controllers/effects.js.map +1 -0
  14. package/dist/controllers/errors.d.ts +12 -0
  15. package/dist/controllers/errors.js +25 -0
  16. package/dist/controllers/errors.js.map +1 -0
  17. package/dist/controllers/index.d.ts +13 -0
  18. package/dist/controllers/index.js +13 -0
  19. package/dist/controllers/index.js.map +1 -0
  20. package/dist/controllers/json.d.ts +5 -0
  21. package/dist/controllers/json.js +57 -0
  22. package/dist/controllers/json.js.map +1 -0
  23. package/dist/controllers/loader.d.ts +23 -0
  24. package/dist/controllers/loader.js +74 -0
  25. package/dist/controllers/loader.js.map +1 -0
  26. package/dist/controllers/manager.d.ts +58 -0
  27. package/dist/controllers/manager.js +399 -0
  28. package/dist/controllers/manager.js.map +1 -0
  29. package/dist/controllers/results.d.ts +5 -0
  30. package/dist/controllers/results.js +32 -0
  31. package/dist/controllers/results.js.map +1 -0
  32. package/dist/controllers/sqlite.d.ts +212 -0
  33. package/dist/controllers/sqlite.js +1009 -0
  34. package/dist/controllers/sqlite.js.map +1 -0
  35. package/dist/controllers/store.d.ts +112 -0
  36. package/dist/controllers/store.js +32 -0
  37. package/dist/controllers/store.js.map +1 -0
  38. package/dist/controllers/types.d.ts +159 -0
  39. package/dist/controllers/types.js +2 -0
  40. package/dist/controllers/types.js.map +1 -0
  41. package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
  42. package/dist/controllers/workflow-engine-scheduler.js +93 -0
  43. package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
  44. package/dist/controllers/workflows.d.ts +27 -0
  45. package/dist/controllers/workflows.js +109 -0
  46. package/dist/controllers/workflows.js.map +1 -0
  47. package/dist/extension/controller-host.d.ts +47 -0
  48. package/dist/extension/controller-host.js +110 -0
  49. package/dist/extension/controller-host.js.map +1 -0
  50. package/dist/extension/executor.js +1 -1
  51. package/dist/extension/executor.js.map +1 -1
  52. package/dist/extension/index.d.ts +7 -0
  53. package/dist/extension/index.js +1053 -96
  54. package/dist/extension/index.js.map +1 -1
  55. package/dist/extension/recorder.d.ts +6 -0
  56. package/dist/extension/recorder.js +20 -8
  57. package/dist/extension/recorder.js.map +1 -1
  58. package/dist/extension/workflow-tool.d.ts +28 -0
  59. package/dist/extension/workflow-tool.js +33 -0
  60. package/dist/extension/workflow-tool.js.map +1 -0
  61. package/dist/host/processes.d.ts +24 -0
  62. package/dist/host/processes.js +114 -0
  63. package/dist/host/processes.js.map +1 -0
  64. package/dist/host/rpc-bridge.d.ts +9 -0
  65. package/dist/host/rpc-bridge.js +39 -0
  66. package/dist/host/rpc-bridge.js.map +1 -0
  67. package/dist/host/rpc-executor.d.ts +38 -0
  68. package/dist/host/rpc-executor.js +254 -0
  69. package/dist/host/rpc-executor.js.map +1 -0
  70. package/dist/host/runner.d.ts +49 -0
  71. package/dist/host/runner.js +350 -0
  72. package/dist/host/runner.js.map +1 -0
  73. package/dist/viewer/cli.d.ts +7 -3
  74. package/dist/viewer/cli.js +150 -19
  75. package/dist/viewer/cli.js.map +1 -1
  76. package/dist/workflows/engine.d.ts +36 -0
  77. package/dist/workflows/engine.js +244 -14
  78. package/dist/workflows/engine.js.map +1 -1
  79. package/dist/workflows/errors.d.ts +23 -0
  80. package/dist/workflows/errors.js +38 -0
  81. package/dist/workflows/errors.js.map +1 -1
  82. package/dist/workflows/graph.js +0 -5
  83. package/dist/workflows/graph.js.map +1 -1
  84. package/dist/workflows/loader.d.ts +5 -3
  85. package/dist/workflows/loader.js +10 -1
  86. package/dist/workflows/loader.js.map +1 -1
  87. package/dist/workflows/schema.js +1 -1
  88. package/dist/workflows/schema.js.map +1 -1
  89. package/dist/workflows/store.d.ts +50 -6
  90. package/dist/workflows/store.js +446 -51
  91. package/dist/workflows/store.js.map +1 -1
  92. package/dist/workflows/types.d.ts +10 -0
  93. package/docs/CONTROLLERS.md +215 -0
  94. package/docs/development.md +12 -9
  95. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  96. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  97. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  98. package/docs/run-bundles.md +66 -27
  99. package/docs/workflows.md +131 -11
  100. package/examples/controllers/pull-request.controller.ts +215 -0
  101. package/package.json +10 -2
  102. package/src/builtins/monitor.workflow.ts +278 -0
  103. package/src/controllers/conditions.ts +110 -0
  104. package/src/controllers/definition.ts +65 -0
  105. package/src/controllers/effects.ts +123 -0
  106. package/src/controllers/errors.ts +27 -0
  107. package/src/controllers/index.ts +90 -0
  108. package/src/controllers/json.ts +62 -0
  109. package/src/controllers/loader.ts +104 -0
  110. package/src/controllers/manager.ts +533 -0
  111. package/src/controllers/results.ts +46 -0
  112. package/src/controllers/sqlite.ts +1427 -0
  113. package/src/controllers/store.ts +160 -0
  114. package/src/controllers/types.ts +183 -0
  115. package/src/controllers/workflow-engine-scheduler.ts +145 -0
  116. package/src/controllers/workflows.ts +152 -0
  117. package/src/extension/controller-host.ts +163 -0
  118. package/src/extension/executor.ts +1 -1
  119. package/src/extension/index.ts +1243 -117
  120. package/src/extension/recorder.ts +65 -36
  121. package/src/extension/workflow-tool.ts +59 -0
  122. package/src/host/processes.ts +119 -0
  123. package/src/host/rpc-bridge.ts +44 -0
  124. package/src/host/rpc-executor.ts +299 -0
  125. package/src/host/runner.ts +406 -0
  126. package/src/viewer/cli.ts +167 -21
  127. package/src/workflows/engine.ts +327 -13
  128. package/src/workflows/errors.ts +45 -0
  129. package/src/workflows/graph.ts +0 -5
  130. package/src/workflows/loader.ts +13 -3
  131. package/src/workflows/schema.ts +1 -1
  132. package/src/workflows/store.ts +555 -46
  133. package/src/workflows/types.ts +10 -0
@@ -0,0 +1,1009 @@
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 { EffectRequestConflictError, ResourceConflictError, ResourceNotFoundError, WorkflowRequestConflictError, } from "./errors.js";
6
+ import { canonicalJson, parseStoredJson } from "./json.js";
7
+ import { CONTROLLER_STORE_SCHEMA, controllerStorePath, } from "./store.js";
8
+ const MAX_KEY_LENGTH = 512;
9
+ const MAX_ERROR_LENGTH = 8_192;
10
+ const MAX_EVENT_BYTES = 64 * 1024;
11
+ const MAX_RESOURCE_VALUE_BYTES = 1024 * 1024;
12
+ export class SqliteControllerStore {
13
+ filePath;
14
+ database;
15
+ closed = false;
16
+ constructor(filePath = controllerStorePath(), options = {}) {
17
+ this.filePath = path.resolve(filePath);
18
+ const readOnly = options.readOnly ?? false;
19
+ if (!readOnly) {
20
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
21
+ fs.chmodSync(path.dirname(this.filePath), 0o700);
22
+ }
23
+ this.database = new Database(this.filePath, {
24
+ readonly: readOnly,
25
+ fileMustExist: readOnly,
26
+ });
27
+ if (!readOnly) {
28
+ fs.chmodSync(this.filePath, 0o600);
29
+ }
30
+ this.configure(readOnly);
31
+ this.initializeSchema(readOnly);
32
+ }
33
+ close() {
34
+ if (this.closed) {
35
+ return;
36
+ }
37
+ this.closed = true;
38
+ this.database.close();
39
+ }
40
+ putResource(options) {
41
+ validateName(options.controller, "controller");
42
+ validateKey(options.key, "resource key");
43
+ const now = validTimestamp(options.now);
44
+ const specJson = canonicalJson(options.spec, "controller resource spec");
45
+ validateJsonSize(specJson, "Controller resource spec", MAX_RESOURCE_VALUE_BYTES);
46
+ const initialStatus = {
47
+ observedGeneration: 0,
48
+ conditions: [],
49
+ controllerStatus: options.initialStatus,
50
+ };
51
+ const statusJson = canonicalJson(initialStatus, "controller resource status");
52
+ validateJsonSize(statusJson, "Controller resource status", MAX_RESOURCE_VALUE_BYTES);
53
+ return this.transaction(() => {
54
+ const existing = this.resourceRow({ controller: options.controller, key: options.key });
55
+ if (existing === undefined) {
56
+ const uid = randomUUID();
57
+ this.database
58
+ .prepare(`INSERT INTO resources (
59
+ controller, resource_key, uid, resource_version, generation,
60
+ spec_json, status_json, deletion_timestamp, finalizers_json, updated_at
61
+ ) VALUES (?, ?, ?, 1, 1, ?, ?, NULL, '[]', ?)`)
62
+ .run(options.controller, options.key, uid, specJson, statusJson, now);
63
+ this.enqueueRow({ controller: options.controller, key: options.key }, epoch(now));
64
+ }
65
+ else if (existing.spec_json !== specJson) {
66
+ this.database
67
+ .prepare(`UPDATE resources
68
+ SET spec_json = ?, resource_version = resource_version + 1,
69
+ generation = generation + 1, updated_at = ?
70
+ WHERE controller = ? AND resource_key = ?`)
71
+ .run(specJson, now, options.controller, options.key);
72
+ this.enqueueRow({ controller: options.controller, key: options.key }, epoch(now));
73
+ }
74
+ return this.requireResource({
75
+ controller: options.controller,
76
+ key: options.key,
77
+ });
78
+ });
79
+ }
80
+ getResource(ref) {
81
+ const row = this.resourceRow(ref);
82
+ return row === undefined ? undefined : resourceFromRow(row);
83
+ }
84
+ getResourceByUid(uid) {
85
+ const row = this.database.prepare("SELECT * FROM resources WHERE uid = ?").get(uid);
86
+ return row === undefined ? undefined : resourceFromRow(row);
87
+ }
88
+ listResources(options = {}) {
89
+ const rows = options.controller === undefined
90
+ ? this.database
91
+ .prepare("SELECT * FROM resources ORDER BY controller, resource_key")
92
+ .all()
93
+ : this.database
94
+ .prepare("SELECT * FROM resources WHERE controller = ? ORDER BY resource_key")
95
+ .all(options.controller);
96
+ return rows.map((row) => resourceFromRow(row));
97
+ }
98
+ updateStatus(options) {
99
+ const now = validTimestamp(options.now);
100
+ const statusJson = canonicalJson(options.status, "controller resource status");
101
+ validateJsonSize(statusJson, "Controller resource status", MAX_RESOURCE_VALUE_BYTES);
102
+ if (options.finalizers !== undefined) {
103
+ validateFinalizers(options.finalizers);
104
+ }
105
+ return this.transaction(() => {
106
+ const current = this.resourceRow(options.ref);
107
+ if (current === undefined) {
108
+ throw new ResourceNotFoundError(options.ref.controller, options.ref.key);
109
+ }
110
+ if (current.resource_version !== options.expectedResourceVersion) {
111
+ throw new ResourceConflictError(options.ref.controller, options.ref.key);
112
+ }
113
+ const finalizersJson = options.finalizers === undefined
114
+ ? current.finalizers_json
115
+ : canonicalJson(options.finalizers);
116
+ if (current.status_json === statusJson && current.finalizers_json === finalizersJson) {
117
+ return resourceFromRow(current);
118
+ }
119
+ this.database
120
+ .prepare(`UPDATE resources
121
+ SET status_json = ?, finalizers_json = ?,
122
+ resource_version = resource_version + 1, updated_at = ?
123
+ WHERE controller = ? AND resource_key = ? AND resource_version = ?`)
124
+ .run(statusJson, finalizersJson, now, options.ref.controller, options.ref.key, options.expectedResourceVersion);
125
+ return this.requireResource(options.ref);
126
+ });
127
+ }
128
+ requestDeletion(ref, now) {
129
+ const timestamp = validTimestamp(now);
130
+ return this.transaction(() => {
131
+ const row = this.resourceRow(ref);
132
+ if (row === undefined) {
133
+ throw new ResourceNotFoundError(ref.controller, ref.key);
134
+ }
135
+ if (row.deletion_timestamp === null) {
136
+ this.database
137
+ .prepare(`UPDATE resources
138
+ SET deletion_timestamp = ?, resource_version = resource_version + 1, updated_at = ?
139
+ WHERE controller = ? AND resource_key = ?`)
140
+ .run(timestamp, timestamp, ref.controller, ref.key);
141
+ }
142
+ this.enqueueRow(ref, epoch(timestamp));
143
+ return this.requireResource(ref);
144
+ });
145
+ }
146
+ updateFinalizers(options) {
147
+ validateFinalizers(options.finalizers);
148
+ const now = validTimestamp(options.now);
149
+ const result = this.database
150
+ .prepare(`UPDATE resources
151
+ SET finalizers_json = ?, resource_version = resource_version + 1, updated_at = ?
152
+ WHERE controller = ? AND resource_key = ? AND resource_version = ?`)
153
+ .run(canonicalJson(options.finalizers), now, options.ref.controller, options.ref.key, options.expectedResourceVersion);
154
+ if (result.changes !== 1) {
155
+ this.throwMissingOrConflict(options.ref);
156
+ }
157
+ return this.requireResource(options.ref);
158
+ }
159
+ deleteResource(ref, expectedResourceVersion) {
160
+ const row = this.resourceRow(ref);
161
+ if (row === undefined) {
162
+ return false;
163
+ }
164
+ const finalizers = parseStoredJson(row.finalizers_json, "resource finalizers");
165
+ if (row.deletion_timestamp === null || finalizers.length > 0) {
166
+ throw new Error(`Controller resource ${ref.controller}/${ref.key} is not ready for deletion`);
167
+ }
168
+ const result = this.database
169
+ .prepare(`DELETE FROM resources
170
+ WHERE controller = ? AND resource_key = ? AND resource_version = ?`)
171
+ .run(ref.controller, ref.key, expectedResourceVersion);
172
+ if (result.changes !== 1) {
173
+ throw new ResourceConflictError(ref.controller, ref.key);
174
+ }
175
+ return true;
176
+ }
177
+ enqueue(ref, availableAt) {
178
+ const at = validTimestamp(availableAt);
179
+ if (this.resourceRow(ref) === undefined) {
180
+ throw new ResourceNotFoundError(ref.controller, ref.key);
181
+ }
182
+ this.enqueueRow(ref, epoch(at));
183
+ }
184
+ claimNext(options) {
185
+ validateDuration(options.leaseMs, "leaseMs");
186
+ if (options.controllers.length === 0) {
187
+ return undefined;
188
+ }
189
+ const now = validTimestamp(options.now);
190
+ const nowMs = epoch(now);
191
+ const placeholders = options.controllers.map(() => "?").join(", ");
192
+ return this.transaction(() => {
193
+ const row = this.database
194
+ .prepare(`SELECT * FROM queue
195
+ WHERE controller IN (${placeholders})
196
+ AND available_at <= ?
197
+ AND (claim_token IS NULL OR claim_expires_at <= ?)
198
+ ORDER BY available_at, controller, resource_key
199
+ LIMIT 1`)
200
+ .get(...options.controllers, nowMs, nowMs);
201
+ if (row === undefined) {
202
+ return undefined;
203
+ }
204
+ const token = randomUUID();
205
+ const expiresAtMs = nowMs + options.leaseMs;
206
+ const result = this.database
207
+ .prepare(`UPDATE queue
208
+ SET claim_token = ?, claim_version = version, claim_expires_at = ?
209
+ WHERE controller = ? AND resource_key = ?
210
+ AND (claim_token IS NULL OR claim_expires_at <= ?)`)
211
+ .run(token, expiresAtMs, row.controller, row.resource_key, nowMs);
212
+ if (result.changes !== 1) {
213
+ return undefined;
214
+ }
215
+ return {
216
+ controller: row.controller,
217
+ key: row.resource_key,
218
+ token,
219
+ queueVersion: row.version,
220
+ consecutiveErrors: row.consecutive_errors,
221
+ expiresAt: iso(expiresAtMs),
222
+ };
223
+ });
224
+ }
225
+ renewClaim(claim, leaseMs, now) {
226
+ validateDuration(leaseMs, "leaseMs");
227
+ const nowMs = epoch(validTimestamp(now));
228
+ const expiresAt = nowMs + leaseMs;
229
+ const result = this.database
230
+ .prepare(`UPDATE queue SET claim_expires_at = ?
231
+ WHERE controller = ? AND resource_key = ? AND claim_token = ?
232
+ AND claim_expires_at > ?`)
233
+ .run(expiresAt, claim.controller, claim.key, claim.token, nowMs);
234
+ return result.changes === 1;
235
+ }
236
+ settleClaim(claim, now) {
237
+ const nowMs = epoch(validTimestamp(now));
238
+ return this.transaction(() => {
239
+ const row = this.claimedRow(claim);
240
+ if (row === undefined) {
241
+ return false;
242
+ }
243
+ if (row.version === row.claim_version) {
244
+ this.database
245
+ .prepare("DELETE FROM queue WHERE controller = ? AND resource_key = ? AND claim_token = ?")
246
+ .run(claim.controller, claim.key, claim.token);
247
+ }
248
+ else {
249
+ this.database
250
+ .prepare(`UPDATE queue
251
+ SET claim_token = NULL, claim_version = NULL, claim_expires_at = NULL,
252
+ available_at = MIN(available_at, ?), consecutive_errors = 0, last_error = NULL
253
+ WHERE controller = ? AND resource_key = ? AND claim_token = ?`)
254
+ .run(nowMs, claim.controller, claim.key, claim.token);
255
+ }
256
+ return true;
257
+ });
258
+ }
259
+ requeueClaim(claim, options, _now) {
260
+ const availableAt = epoch(validTimestamp(options.availableAt));
261
+ const error = options.error === undefined ? null : truncateError(options.error);
262
+ return this.transaction(() => {
263
+ const row = this.claimedRow(claim);
264
+ if (row === undefined) {
265
+ return false;
266
+ }
267
+ const eventArrived = row.version !== row.claim_version;
268
+ const nextAvailableAt = eventArrived ? Math.min(row.available_at, availableAt) : availableAt;
269
+ const nextErrors = options.error === undefined ? 0 : row.consecutive_errors + 1;
270
+ this.database
271
+ .prepare(`UPDATE queue
272
+ SET claim_token = NULL, claim_version = NULL, claim_expires_at = NULL,
273
+ available_at = ?, consecutive_errors = ?, last_error = ?
274
+ WHERE controller = ? AND resource_key = ? AND claim_token = ?`)
275
+ .run(nextAvailableAt, nextErrors, error, claim.controller, claim.key, claim.token);
276
+ return true;
277
+ });
278
+ }
279
+ listQueue() {
280
+ const rows = this.database
281
+ .prepare("SELECT * FROM queue ORDER BY available_at, controller, resource_key")
282
+ .all();
283
+ return rows.map((row) => ({
284
+ controller: row.controller,
285
+ key: row.resource_key,
286
+ availableAt: iso(row.available_at),
287
+ consecutiveErrors: row.consecutive_errors,
288
+ ...(row.claim_expires_at === null ? {} : { claimExpiresAt: iso(row.claim_expires_at) }),
289
+ }));
290
+ }
291
+ reserveEffect(options) {
292
+ validateKey(options.key, "effect key");
293
+ validateName(options.kind, "effect kind");
294
+ const now = validTimestamp(options.now);
295
+ return this.transaction(() => {
296
+ const existing = this.effectRow(options.resourceUid, options.key);
297
+ if (existing !== undefined) {
298
+ if (existing.request_fingerprint !== options.requestFingerprint ||
299
+ existing.generation !== options.generation ||
300
+ existing.kind !== options.kind) {
301
+ throw new EffectRequestConflictError(options.key);
302
+ }
303
+ return { record: effectFromRow(existing), created: false };
304
+ }
305
+ this.database
306
+ .prepare(`INSERT INTO effects (
307
+ effect_key, resource_uid, generation, kind, state,
308
+ request_fingerprint, started_at
309
+ ) VALUES (?, ?, ?, ?, 'pending', ?, ?)`)
310
+ .run(options.key, options.resourceUid, options.generation, options.kind, options.requestFingerprint, now);
311
+ return {
312
+ record: this.requireEffect(options.resourceUid, options.key),
313
+ created: true,
314
+ };
315
+ });
316
+ }
317
+ getEffect(resourceUid, key) {
318
+ const row = this.effectRow(resourceUid, key);
319
+ return row === undefined ? undefined : effectFromRow(row);
320
+ }
321
+ updateEffect(options) {
322
+ const row = this.effectRow(options.resourceUid, options.key);
323
+ if (row === undefined) {
324
+ throw new Error(`Effect record not found: ${options.resourceUid}/${options.key}`);
325
+ }
326
+ if ((row.state === "applied" || row.state === "rejected") && row.state !== options.state) {
327
+ throw new Error(`Effect ${JSON.stringify(options.key)} is already ${row.state}`);
328
+ }
329
+ const completedAt = options.state === "applied" || options.state === "rejected"
330
+ ? validTimestamp(options.now)
331
+ : null;
332
+ this.database
333
+ .prepare(`UPDATE effects
334
+ SET state = ?, external_ref = COALESCE(?, external_ref), error = ?, completed_at = ?
335
+ WHERE resource_uid = ? AND effect_key = ?`)
336
+ .run(options.state, options.externalRef ?? null, options.error === undefined ? null : truncateError(options.error), completedAt, options.resourceUid, options.key);
337
+ return this.requireEffect(options.resourceUid, options.key);
338
+ }
339
+ listEffects(resourceUid) {
340
+ const rows = this.database
341
+ .prepare("SELECT * FROM effects WHERE resource_uid = ? ORDER BY started_at, effect_key")
342
+ .all(resourceUid);
343
+ return rows.map(effectFromRow);
344
+ }
345
+ reserveWorkflow(options) {
346
+ validateKey(options.requestKey, "workflow request key");
347
+ validateKey(options.workflow, "workflow name");
348
+ return this.transaction(() => {
349
+ const existing = this.workflowRow(options.resourceUid, options.requestKey);
350
+ if (existing !== undefined) {
351
+ if (existing.input_fingerprint !== options.inputFingerprint ||
352
+ existing.workflow !== options.workflow) {
353
+ throw new WorkflowRequestConflictError(options.requestKey);
354
+ }
355
+ return { record: workflowFromRow(existing), created: false };
356
+ }
357
+ const requestId = randomUUID();
358
+ this.database
359
+ .prepare(`INSERT INTO workflow_requests (
360
+ request_id, resource_uid, request_key, input_fingerprint,
361
+ workflow, state, attempt
362
+ ) VALUES (?, ?, ?, ?, ?, 'pending', 0)`)
363
+ .run(requestId, options.resourceUid, options.requestKey, options.inputFingerprint, options.workflow);
364
+ return {
365
+ record: this.requireWorkflow(requestId),
366
+ created: true,
367
+ };
368
+ });
369
+ }
370
+ getWorkflow(resourceUid, requestKey) {
371
+ const row = this.workflowRow(resourceUid, requestKey);
372
+ return row === undefined ? undefined : workflowFromRow(row);
373
+ }
374
+ getWorkflowByRequestId(requestId) {
375
+ const row = this.database
376
+ .prepare("SELECT * FROM workflow_requests WHERE request_id = ?")
377
+ .get(requestId);
378
+ return row === undefined ? undefined : workflowFromRow(row);
379
+ }
380
+ updateWorkflow(requestId, update) {
381
+ const existing = this.database
382
+ .prepare("SELECT * FROM workflow_requests WHERE request_id = ?")
383
+ .get(requestId);
384
+ if (existing === undefined) {
385
+ throw new Error(`Workflow request not found: ${requestId}`);
386
+ }
387
+ const runId = update.runId === undefined ? existing.run_id : update.runId;
388
+ const attempt = update.attempt ?? existing.attempt;
389
+ const error = update.error === undefined
390
+ ? existing.error
391
+ : update.error === null
392
+ ? null
393
+ : truncateError(update.error);
394
+ this.database
395
+ .prepare(`UPDATE workflow_requests
396
+ SET run_id = ?, state = ?, attempt = ?, error = ?
397
+ WHERE request_id = ?`)
398
+ .run(runId, update.state, attempt, error, requestId);
399
+ return this.requireWorkflow(requestId);
400
+ }
401
+ listWorkflows(resourceUid) {
402
+ const rows = this.database
403
+ .prepare("SELECT * FROM workflow_requests WHERE resource_uid = ? ORDER BY rowid")
404
+ .all(resourceUid);
405
+ return rows.map(workflowFromRow);
406
+ }
407
+ recordEvent(options) {
408
+ validateName(options.controller, "controller");
409
+ validateKey(options.key, "resource key");
410
+ validateName(options.type, "event type");
411
+ const recordedAt = validTimestamp(options.now);
412
+ const payloadJson = canonicalJson(options.payload ?? {}, "controller event payload");
413
+ if (Buffer.byteLength(payloadJson, "utf8") > MAX_EVENT_BYTES) {
414
+ throw new Error(`Controller event payload exceeds ${MAX_EVENT_BYTES} bytes`);
415
+ }
416
+ const result = this.database
417
+ .prepare(`INSERT INTO events (recorded_at, controller, resource_key, type, payload_json)
418
+ VALUES (?, ?, ?, ?, ?)`)
419
+ .run(recordedAt, options.controller, options.key, options.type, payloadJson);
420
+ return this.requireEvent(Number(result.lastInsertRowid));
421
+ }
422
+ listEvents(options = {}) {
423
+ const limit = options.limit ?? 100;
424
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 10_000) {
425
+ throw new Error("Event limit must be between 1 and 10000");
426
+ }
427
+ const clauses = [];
428
+ const values = [];
429
+ if (options.controller !== undefined) {
430
+ clauses.push("controller = ?");
431
+ values.push(options.controller);
432
+ }
433
+ if (options.key !== undefined) {
434
+ clauses.push("resource_key = ?");
435
+ values.push(options.key);
436
+ }
437
+ const where = clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`;
438
+ const rows = this.database
439
+ .prepare(`SELECT * FROM events ${where} ORDER BY seq DESC LIMIT ?`)
440
+ .all(...values, limit);
441
+ return rows.map(eventFromRow);
442
+ }
443
+ configure(readOnly) {
444
+ if (!readOnly) {
445
+ this.database.pragma("journal_mode = WAL");
446
+ this.database.pragma("synchronous = FULL");
447
+ this.database.pragma("foreign_keys = ON");
448
+ }
449
+ this.database.pragma("busy_timeout = 5000");
450
+ }
451
+ initializeSchema(readOnly) {
452
+ if (!readOnly) {
453
+ this.database.exec(`
454
+ CREATE TABLE IF NOT EXISTS schema_info (
455
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
456
+ schema_id TEXT NOT NULL
457
+ );
458
+ `);
459
+ }
460
+ const row = this.database
461
+ .prepare("SELECT schema_id FROM schema_info WHERE singleton = 1")
462
+ .get();
463
+ if (row !== undefined && row.schema_id !== CONTROLLER_STORE_SCHEMA) {
464
+ throw new Error(`Unsupported controller store schema: ${row.schema_id}`);
465
+ }
466
+ if (readOnly) {
467
+ if (row === undefined) {
468
+ throw new Error("Controller store has no schema identifier");
469
+ }
470
+ return;
471
+ }
472
+ this.transaction(() => {
473
+ this.database
474
+ .prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
475
+ .run(CONTROLLER_STORE_SCHEMA);
476
+ this.database.exec(SCHEMA_SQL);
477
+ });
478
+ }
479
+ transaction(task) {
480
+ this.database.exec("BEGIN IMMEDIATE");
481
+ try {
482
+ const value = task();
483
+ this.database.exec("COMMIT");
484
+ return value;
485
+ }
486
+ catch (error) {
487
+ this.database.exec("ROLLBACK");
488
+ throw error;
489
+ }
490
+ }
491
+ resourceRow(ref) {
492
+ return this.database
493
+ .prepare("SELECT * FROM resources WHERE controller = ? AND resource_key = ?")
494
+ .get(ref.controller, ref.key);
495
+ }
496
+ requireResource(ref) {
497
+ const resource = this.getResource(ref);
498
+ if (resource === undefined) {
499
+ throw new ResourceNotFoundError(ref.controller, ref.key);
500
+ }
501
+ return resource;
502
+ }
503
+ throwMissingOrConflict(ref) {
504
+ if (this.resourceRow(ref) === undefined) {
505
+ throw new ResourceNotFoundError(ref.controller, ref.key);
506
+ }
507
+ throw new ResourceConflictError(ref.controller, ref.key);
508
+ }
509
+ enqueueRow(ref, availableAt) {
510
+ this.database
511
+ .prepare(`INSERT INTO queue (
512
+ controller, resource_key, available_at, version, consecutive_errors
513
+ ) VALUES (?, ?, ?, 1, 0)
514
+ ON CONFLICT(controller, resource_key) DO UPDATE SET
515
+ available_at = MIN(queue.available_at, excluded.available_at),
516
+ version = queue.version + 1`)
517
+ .run(ref.controller, ref.key, availableAt);
518
+ }
519
+ claimedRow(claim) {
520
+ return this.database
521
+ .prepare(`SELECT * FROM queue
522
+ WHERE controller = ? AND resource_key = ? AND claim_token = ?`)
523
+ .get(claim.controller, claim.key, claim.token);
524
+ }
525
+ effectRow(resourceUid, key) {
526
+ return this.database
527
+ .prepare("SELECT * FROM effects WHERE resource_uid = ? AND effect_key = ?")
528
+ .get(resourceUid, key);
529
+ }
530
+ requireEffect(resourceUid, key) {
531
+ const effect = this.getEffect(resourceUid, key);
532
+ if (effect === undefined) {
533
+ throw new Error(`Effect record not found: ${resourceUid}/${key}`);
534
+ }
535
+ return effect;
536
+ }
537
+ workflowRow(resourceUid, requestKey) {
538
+ return this.database
539
+ .prepare("SELECT * FROM workflow_requests WHERE resource_uid = ? AND request_key = ?")
540
+ .get(resourceUid, requestKey);
541
+ }
542
+ requireWorkflow(requestId) {
543
+ const workflow = this.getWorkflowByRequestId(requestId);
544
+ if (workflow === undefined) {
545
+ throw new Error(`Workflow request not found: ${requestId}`);
546
+ }
547
+ return workflow;
548
+ }
549
+ /**
550
+ * Insert a user-started run and claim it in one statement, so the
551
+ * originating runner owns the run from birth (origin affinity).
552
+ */
553
+ enqueueWorkflowRun(options) {
554
+ validateRunId(options.runId);
555
+ validateKey(options.workflowRef, "workflow ref");
556
+ validateKey(options.workflowPath, "workflow path");
557
+ validateKey(options.runnerId, "runner id");
558
+ validateKey(options.claimToken, "claim token");
559
+ validateDuration(options.leaseMs, "leaseMs");
560
+ const inputJson = canonicalJson(options.input ?? null, "workflow run input");
561
+ validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
562
+ const now = validTimestamp(options.now);
563
+ const expiresAt = epoch(now) + options.leaseMs;
564
+ this.database
565
+ .prepare(`INSERT INTO workflow_run_queue (
566
+ run_id, workflow_ref, workflow_path, input_json, status,
567
+ runner_id, claim_token, claim_expires_at, affinity_runner_id,
568
+ parent_run_id, created_at, updated_at
569
+ ) VALUES (?, ?, ?, ?, 'claimed', ?, ?, ?, ?, ?, ?, ?)`)
570
+ .run(options.runId, options.workflowRef, options.workflowPath, inputJson, options.runnerId, options.claimToken, expiresAt, options.affinityRunnerId ?? options.runnerId, options.parentRunId ?? null, now, now);
571
+ return this.requireWorkflowRun(options.runId);
572
+ }
573
+ getWorkflowRun(runId) {
574
+ validateRunId(runId);
575
+ const row = this.database
576
+ .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
577
+ .get(runId);
578
+ return row === undefined ? undefined : workflowRunFromRow(row);
579
+ }
580
+ listWorkflowRuns(options = {}) {
581
+ const rows = options.status === undefined
582
+ ? this.database
583
+ .prepare("SELECT * FROM workflow_run_queue ORDER BY created_at")
584
+ .all()
585
+ : this.database
586
+ .prepare("SELECT * FROM workflow_run_queue WHERE status = ? ORDER BY created_at")
587
+ .all(options.status);
588
+ return rows.map(workflowRunFromRow);
589
+ }
590
+ /**
591
+ * Claim the oldest claimable run, preferring runs with affinity to this
592
+ * runner. Parked rows are claimable immediately; claimed rows become
593
+ * claimable once their lease expires, so a dead or stalled runner never
594
+ * strands a run. Returns undefined when nothing is claimable.
595
+ */
596
+ claimNextWorkflowRun(options) {
597
+ validateKey(options.runnerId, "runner id");
598
+ validateKey(options.claimToken, "claim token");
599
+ validateDuration(options.leaseMs, "leaseMs");
600
+ const now = validTimestamp(options.now);
601
+ const nowMs = epoch(now);
602
+ const expiresAt = nowMs + options.leaseMs;
603
+ const excluded = options.excludeRunIds ?? [];
604
+ for (const runId of excluded) {
605
+ validateRunId(runId);
606
+ }
607
+ const exclusion = excluded.length === 0 ? "" : `AND run_id NOT IN (${excluded.map(() => "?").join(", ")})`;
608
+ const claimable = `(
609
+ status = 'parked'
610
+ OR (status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
611
+ )`;
612
+ return this.transaction(() => {
613
+ const candidate = this.database
614
+ .prepare(`SELECT run_id FROM workflow_run_queue
615
+ WHERE ${claimable} ${exclusion}
616
+ ORDER BY
617
+ CASE WHEN affinity_runner_id = ? THEN 0 ELSE 1 END,
618
+ created_at ASC
619
+ LIMIT 1`)
620
+ .get(nowMs, ...excluded, options.runnerId);
621
+ if (candidate === undefined) {
622
+ return undefined;
623
+ }
624
+ const result = this.database
625
+ .prepare(`UPDATE workflow_run_queue
626
+ SET status = 'claimed', runner_id = ?, claim_token = ?,
627
+ claim_expires_at = ?, updated_at = ?
628
+ WHERE run_id = ? AND (
629
+ status = 'parked'
630
+ OR (status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
631
+ )`)
632
+ .run(options.runnerId, options.claimToken, expiresAt, now, candidate.run_id, nowMs);
633
+ return result.changes === 1 ? this.requireWorkflowRun(candidate.run_id) : undefined;
634
+ });
635
+ }
636
+ /** Extend a live claim. Rejects expired or foreign claims. */
637
+ renewWorkflowRunClaim(options) {
638
+ validateRunId(options.runId);
639
+ validateDuration(options.leaseMs, "leaseMs");
640
+ const nowMs = epoch(validTimestamp(options.now));
641
+ const expiresAt = nowMs + options.leaseMs;
642
+ const result = this.database
643
+ .prepare(`UPDATE workflow_run_queue SET claim_expires_at = ?
644
+ WHERE run_id = ? AND claim_token = ? AND status = 'claimed'
645
+ AND claim_expires_at > ?`)
646
+ .run(expiresAt, options.runId, options.claimToken, nowMs);
647
+ return result.changes === 1;
648
+ }
649
+ /** The fence check: true only while this exact claim is live. */
650
+ verifyWorkflowRunClaim(options) {
651
+ validateRunId(options.runId);
652
+ const nowMs = epoch(validTimestamp(options.now));
653
+ const row = this.database
654
+ .prepare(`SELECT 1 AS live FROM workflow_run_queue
655
+ WHERE run_id = ? AND claim_token = ? AND status = 'claimed'
656
+ AND claim_expires_at > ?`)
657
+ .get(options.runId, options.claimToken, nowMs);
658
+ return row !== undefined;
659
+ }
660
+ /** Release a claim and park the run so another runner can resume it. */
661
+ parkWorkflowRun(options) {
662
+ validateRunId(options.runId);
663
+ const now = validTimestamp(options.now);
664
+ const result = this.database
665
+ .prepare(`UPDATE workflow_run_queue
666
+ SET status = 'parked', runner_id = NULL, claim_token = NULL,
667
+ claim_expires_at = NULL, updated_at = ?
668
+ WHERE run_id = ? AND claim_token = ? AND status = 'claimed'`)
669
+ .run(now, options.runId, options.claimToken);
670
+ return result.changes === 1;
671
+ }
672
+ /**
673
+ * Delete a claimed row. Used when a continuation fails before its bundle
674
+ * exists, so the parent's one-continuation slot is not consumed by a run
675
+ * that never happened.
676
+ */
677
+ deleteWorkflowRun(options) {
678
+ validateRunId(options.runId);
679
+ const result = this.database
680
+ .prepare("DELETE FROM workflow_run_queue WHERE run_id = ? AND claim_token = ?")
681
+ .run(options.runId, options.claimToken);
682
+ return result.changes === 1;
683
+ }
684
+ /** Release a claim and mark the run terminal in the queue. */
685
+ completeWorkflowRun(options) {
686
+ validateRunId(options.runId);
687
+ const now = validTimestamp(options.now);
688
+ const result = this.database
689
+ .prepare(`UPDATE workflow_run_queue
690
+ SET status = 'done', runner_id = NULL, claim_token = NULL,
691
+ claim_expires_at = NULL, updated_at = ?
692
+ WHERE run_id = ? AND claim_token = ? AND status = 'claimed'`)
693
+ .run(now, options.runId, options.claimToken);
694
+ return result.changes === 1;
695
+ }
696
+ /** Append a run lifecycle transition to the event feed. */
697
+ recordRunEvent(options) {
698
+ validateRunId(options.runId);
699
+ validateKey(options.type, "run event type");
700
+ const payloadJson = canonicalJson(options.payload ?? {}, "run event payload");
701
+ validateJsonSize(payloadJson, "Run event payload", MAX_EVENT_BYTES);
702
+ const result = this.database
703
+ .prepare(`INSERT INTO run_events (recorded_at, run_id, workflow_ref, type, runner_id, payload_json)
704
+ VALUES (?, ?, ?, ?, ?, ?)`)
705
+ .run(validTimestamp(options.now), options.runId, options.workflowRef, options.type, options.runnerId ?? null, payloadJson);
706
+ return Number(result.lastInsertRowid);
707
+ }
708
+ /** List run events after a watermark, oldest first. */
709
+ listRunEventsAfter(seq, options = {}) {
710
+ const limit = options.limit ?? 50;
711
+ if (!Number.isSafeInteger(seq) || seq < 0) {
712
+ throw new Error(`Invalid run event watermark: ${seq}`);
713
+ }
714
+ const rows = this.database
715
+ .prepare("SELECT * FROM run_events WHERE seq > ? ORDER BY seq ASC LIMIT ?")
716
+ .all(seq, limit);
717
+ return rows.map((row) => ({
718
+ seq: row.seq,
719
+ recordedAt: row.recorded_at,
720
+ runId: row.run_id,
721
+ workflowRef: row.workflow_ref,
722
+ type: row.type,
723
+ runnerId: row.runner_id,
724
+ payload: parseStoredJson(row.payload_json, "run event payload"),
725
+ }));
726
+ }
727
+ /** The highest run event seq in the feed; 0 when empty. */
728
+ latestRunEventSeq() {
729
+ const row = this.database.prepare("SELECT MAX(seq) AS latest FROM run_events").get();
730
+ return row.latest ?? 0;
731
+ }
732
+ /** The last run event this session was told about; 0 before any sync. */
733
+ getSessionWatermark(sessionId) {
734
+ validateKey(sessionId, "session id");
735
+ const row = this.database
736
+ .prepare("SELECT last_event_seq FROM session_watermarks WHERE session_id = ?")
737
+ .get(sessionId);
738
+ return row?.last_event_seq ?? 0;
739
+ }
740
+ setSessionWatermark(sessionId, seq, now) {
741
+ validateKey(sessionId, "session id");
742
+ if (!Number.isSafeInteger(seq) || seq < 0) {
743
+ throw new Error(`Invalid run event watermark: ${seq}`);
744
+ }
745
+ this.database
746
+ .prepare(`INSERT INTO session_watermarks (session_id, last_event_seq, updated_at)
747
+ VALUES (?, ?, ?)
748
+ ON CONFLICT(session_id) DO UPDATE SET
749
+ last_event_seq = MAX(session_watermarks.last_event_seq, excluded.last_event_seq),
750
+ updated_at = excluded.updated_at`)
751
+ .run(sessionId, seq, validTimestamp(now));
752
+ }
753
+ requireWorkflowRun(runId) {
754
+ const record = this.getWorkflowRun(runId);
755
+ if (record === undefined) {
756
+ throw new Error(`Workflow run queue record not found: ${runId}`);
757
+ }
758
+ return record;
759
+ }
760
+ requireEvent(seq) {
761
+ const row = this.database.prepare("SELECT * FROM events WHERE seq = ?").get(seq);
762
+ if (row === undefined) {
763
+ throw new Error(`Controller event not found: ${seq}`);
764
+ }
765
+ return eventFromRow(row);
766
+ }
767
+ }
768
+ const SCHEMA_SQL = `
769
+ CREATE TABLE IF NOT EXISTS resources (
770
+ controller TEXT NOT NULL,
771
+ resource_key TEXT NOT NULL,
772
+ uid TEXT NOT NULL UNIQUE,
773
+ resource_version INTEGER NOT NULL CHECK (resource_version > 0),
774
+ generation INTEGER NOT NULL CHECK (generation > 0),
775
+ spec_json TEXT NOT NULL,
776
+ status_json TEXT NOT NULL,
777
+ deletion_timestamp TEXT,
778
+ finalizers_json TEXT NOT NULL,
779
+ updated_at TEXT NOT NULL,
780
+ PRIMARY KEY (controller, resource_key)
781
+ );
782
+
783
+ CREATE TABLE IF NOT EXISTS queue (
784
+ controller TEXT NOT NULL,
785
+ resource_key TEXT NOT NULL,
786
+ available_at INTEGER NOT NULL,
787
+ version INTEGER NOT NULL CHECK (version > 0),
788
+ consecutive_errors INTEGER NOT NULL CHECK (consecutive_errors >= 0),
789
+ last_error TEXT,
790
+ claim_token TEXT,
791
+ claim_version INTEGER,
792
+ claim_expires_at INTEGER,
793
+ PRIMARY KEY (controller, resource_key),
794
+ FOREIGN KEY (controller, resource_key)
795
+ REFERENCES resources(controller, resource_key) ON DELETE CASCADE
796
+ );
797
+ CREATE INDEX IF NOT EXISTS queue_ready
798
+ ON queue(available_at, controller, resource_key);
799
+
800
+ CREATE TABLE IF NOT EXISTS workflow_run_queue (
801
+ run_id TEXT PRIMARY KEY,
802
+ workflow_ref TEXT NOT NULL,
803
+ workflow_path TEXT NOT NULL,
804
+ input_json TEXT NOT NULL,
805
+ status TEXT NOT NULL CHECK (status IN ('claimed', 'parked', 'done')),
806
+ runner_id TEXT,
807
+ claim_token TEXT,
808
+ claim_expires_at INTEGER,
809
+ affinity_runner_id TEXT,
810
+ parent_run_id TEXT,
811
+ created_at TEXT NOT NULL,
812
+ updated_at TEXT NOT NULL
813
+ );
814
+ CREATE INDEX IF NOT EXISTS workflow_run_queue_claimable
815
+ ON workflow_run_queue(status, claim_expires_at);
816
+ -- A checkpointed parent admits exactly one continuation run, across
817
+ -- sessions and processes.
818
+ CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_parent
819
+ ON workflow_run_queue(parent_run_id) WHERE parent_run_id IS NOT NULL;
820
+
821
+ CREATE TABLE IF NOT EXISTS run_events (
822
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
823
+ recorded_at TEXT NOT NULL,
824
+ run_id TEXT NOT NULL,
825
+ workflow_ref TEXT NOT NULL,
826
+ type TEXT NOT NULL,
827
+ runner_id TEXT,
828
+ payload_json TEXT NOT NULL
829
+ );
830
+ CREATE INDEX IF NOT EXISTS run_events_run ON run_events(run_id);
831
+
832
+ CREATE TABLE IF NOT EXISTS session_watermarks (
833
+ session_id TEXT PRIMARY KEY,
834
+ last_event_seq INTEGER NOT NULL CHECK (last_event_seq >= 0),
835
+ updated_at TEXT NOT NULL
836
+ );
837
+
838
+ CREATE TABLE IF NOT EXISTS effects (
839
+ effect_key TEXT NOT NULL,
840
+ resource_uid TEXT NOT NULL,
841
+ generation INTEGER NOT NULL CHECK (generation > 0),
842
+ kind TEXT NOT NULL,
843
+ state TEXT NOT NULL CHECK (state IN ('pending', 'applied', 'rejected', 'indeterminate')),
844
+ request_fingerprint TEXT NOT NULL,
845
+ started_at TEXT NOT NULL,
846
+ completed_at TEXT,
847
+ external_ref TEXT,
848
+ error TEXT,
849
+ PRIMARY KEY (resource_uid, effect_key),
850
+ FOREIGN KEY (resource_uid) REFERENCES resources(uid) ON DELETE CASCADE
851
+ );
852
+
853
+ CREATE TABLE IF NOT EXISTS workflow_requests (
854
+ request_id TEXT PRIMARY KEY,
855
+ resource_uid TEXT NOT NULL,
856
+ request_key TEXT NOT NULL,
857
+ input_fingerprint TEXT NOT NULL,
858
+ workflow TEXT NOT NULL,
859
+ run_id TEXT,
860
+ state TEXT NOT NULL CHECK (
861
+ state IN ('pending', 'running', 'waiting', 'succeeded', 'failed', 'interrupted')
862
+ ),
863
+ attempt INTEGER NOT NULL CHECK (attempt >= 0),
864
+ error TEXT,
865
+ UNIQUE (resource_uid, request_key),
866
+ FOREIGN KEY (resource_uid) REFERENCES resources(uid) ON DELETE CASCADE
867
+ );
868
+
869
+ CREATE TABLE IF NOT EXISTS events (
870
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
871
+ recorded_at TEXT NOT NULL,
872
+ controller TEXT NOT NULL,
873
+ resource_key TEXT NOT NULL,
874
+ type TEXT NOT NULL,
875
+ payload_json TEXT NOT NULL
876
+ );
877
+ CREATE INDEX IF NOT EXISTS events_resource
878
+ ON events(controller, resource_key, seq DESC);
879
+ `;
880
+ function resourceFromRow(row) {
881
+ const finalizers = parseStoredJson(row.finalizers_json, "resource finalizers");
882
+ validateFinalizers(finalizers);
883
+ return {
884
+ metadata: {
885
+ uid: row.uid,
886
+ controller: row.controller,
887
+ key: row.resource_key,
888
+ resourceVersion: row.resource_version,
889
+ generation: row.generation,
890
+ ...(row.deletion_timestamp === null ? {} : { deletionTimestamp: row.deletion_timestamp }),
891
+ finalizers,
892
+ },
893
+ spec: parseStoredJson(row.spec_json, "resource spec"),
894
+ status: parseStoredJson(row.status_json, "resource status"),
895
+ };
896
+ }
897
+ function effectFromRow(row) {
898
+ return {
899
+ key: row.effect_key,
900
+ resourceUid: row.resource_uid,
901
+ generation: row.generation,
902
+ kind: row.kind,
903
+ state: row.state,
904
+ requestFingerprint: row.request_fingerprint,
905
+ startedAt: row.started_at,
906
+ ...(row.completed_at === null ? {} : { completedAt: row.completed_at }),
907
+ ...(row.external_ref === null ? {} : { externalRef: row.external_ref }),
908
+ ...(row.error === null ? {} : { error: row.error }),
909
+ };
910
+ }
911
+ function workflowFromRow(row) {
912
+ return {
913
+ requestId: row.request_id,
914
+ resourceUid: row.resource_uid,
915
+ requestKey: row.request_key,
916
+ inputFingerprint: row.input_fingerprint,
917
+ workflow: row.workflow,
918
+ state: row.state,
919
+ attempt: row.attempt,
920
+ ...(row.run_id === null ? {} : { runId: row.run_id }),
921
+ ...(row.error === null ? {} : { error: row.error }),
922
+ };
923
+ }
924
+ function workflowRunFromRow(row) {
925
+ return {
926
+ runId: row.run_id,
927
+ workflowRef: row.workflow_ref,
928
+ workflowPath: row.workflow_path,
929
+ input: parseStoredJson(row.input_json, "workflow run input"),
930
+ status: row.status,
931
+ runnerId: row.runner_id,
932
+ claimToken: row.claim_token,
933
+ claimExpiresAt: row.claim_expires_at === null ? null : iso(row.claim_expires_at),
934
+ affinityRunnerId: row.affinity_runner_id,
935
+ parentRunId: row.parent_run_id,
936
+ createdAt: row.created_at,
937
+ updatedAt: row.updated_at,
938
+ };
939
+ }
940
+ function validateRunId(runId) {
941
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
942
+ throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
943
+ }
944
+ }
945
+ function eventFromRow(row) {
946
+ return {
947
+ seq: row.seq,
948
+ recordedAt: row.recorded_at,
949
+ controller: row.controller,
950
+ key: row.resource_key,
951
+ type: row.type,
952
+ payload: parseStoredJson(row.payload_json, "controller event payload"),
953
+ };
954
+ }
955
+ function validTimestamp(value) {
956
+ const timestamp = value ?? new Date().toISOString();
957
+ if (!Number.isFinite(Date.parse(timestamp))) {
958
+ throw new Error(`Invalid timestamp: ${JSON.stringify(timestamp)}`);
959
+ }
960
+ return timestamp;
961
+ }
962
+ function epoch(value) {
963
+ const parsed = Date.parse(value);
964
+ if (!Number.isSafeInteger(parsed)) {
965
+ throw new Error(`Timestamp is outside the supported range: ${value}`);
966
+ }
967
+ return parsed;
968
+ }
969
+ function iso(value) {
970
+ return new Date(value).toISOString();
971
+ }
972
+ function validateDuration(value, name) {
973
+ if (!Number.isSafeInteger(value) || value <= 0) {
974
+ throw new Error(`${name} must be a positive safe integer`);
975
+ }
976
+ }
977
+ function validateName(value, description) {
978
+ if (!/^[A-Za-z][A-Za-z0-9_.-]*$/.test(value)) {
979
+ throw new Error(`${description} contains unsupported characters: ${JSON.stringify(value)}`);
980
+ }
981
+ }
982
+ function validateKey(value, description) {
983
+ const hasControl = [...value].some((character) => {
984
+ const code = character.codePointAt(0);
985
+ return code < 32 || code === 127;
986
+ });
987
+ if (value.length === 0 || value.length > MAX_KEY_LENGTH || hasControl) {
988
+ throw new Error(`${description} must be 1-${MAX_KEY_LENGTH} printable characters`);
989
+ }
990
+ }
991
+ function validateFinalizers(finalizers) {
992
+ const seen = new Set();
993
+ for (const finalizer of finalizers) {
994
+ validateName(finalizer, "finalizer");
995
+ if (seen.has(finalizer)) {
996
+ throw new Error(`Duplicate finalizer: ${finalizer}`);
997
+ }
998
+ seen.add(finalizer);
999
+ }
1000
+ }
1001
+ function validateJsonSize(value, description, limit) {
1002
+ if (Buffer.byteLength(value, "utf8") > limit) {
1003
+ throw new Error(`${description} exceeds the ${limit}-byte storage limit`);
1004
+ }
1005
+ }
1006
+ function truncateError(error) {
1007
+ return error.length <= MAX_ERROR_LENGTH ? error : `${error.slice(0, MAX_ERROR_LENGTH)}…`;
1008
+ }
1009
+ //# sourceMappingURL=sqlite.js.map