@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.
- package/README.md +71 -5
- package/dist/builtins/monitor.workflow.d.ts +71 -0
- package/dist/builtins/monitor.workflow.js +234 -0
- package/dist/builtins/monitor.workflow.js.map +1 -0
- package/dist/controllers/conditions.d.ts +6 -0
- package/dist/controllers/conditions.js +68 -0
- package/dist/controllers/conditions.js.map +1 -0
- package/dist/controllers/definition.d.ts +6 -0
- package/dist/controllers/definition.js +45 -0
- package/dist/controllers/definition.js.map +1 -0
- package/dist/controllers/effects.d.ts +14 -0
- package/dist/controllers/effects.js +104 -0
- package/dist/controllers/effects.js.map +1 -0
- package/dist/controllers/errors.d.ts +12 -0
- package/dist/controllers/errors.js +25 -0
- package/dist/controllers/errors.js.map +1 -0
- package/dist/controllers/index.d.ts +13 -0
- package/dist/controllers/index.js +13 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/controllers/json.d.ts +5 -0
- package/dist/controllers/json.js +57 -0
- package/dist/controllers/json.js.map +1 -0
- package/dist/controllers/loader.d.ts +23 -0
- package/dist/controllers/loader.js +74 -0
- package/dist/controllers/loader.js.map +1 -0
- package/dist/controllers/manager.d.ts +58 -0
- package/dist/controllers/manager.js +399 -0
- package/dist/controllers/manager.js.map +1 -0
- package/dist/controllers/results.d.ts +5 -0
- package/dist/controllers/results.js +32 -0
- package/dist/controllers/results.js.map +1 -0
- package/dist/controllers/sqlite.d.ts +212 -0
- package/dist/controllers/sqlite.js +1009 -0
- package/dist/controllers/sqlite.js.map +1 -0
- package/dist/controllers/store.d.ts +112 -0
- package/dist/controllers/store.js +32 -0
- package/dist/controllers/store.js.map +1 -0
- package/dist/controllers/types.d.ts +159 -0
- package/dist/controllers/types.js +2 -0
- package/dist/controllers/types.js.map +1 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
- package/dist/controllers/workflow-engine-scheduler.js +93 -0
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
- package/dist/controllers/workflows.d.ts +27 -0
- package/dist/controllers/workflows.js +109 -0
- package/dist/controllers/workflows.js.map +1 -0
- package/dist/extension/controller-host.d.ts +47 -0
- package/dist/extension/controller-host.js +110 -0
- package/dist/extension/controller-host.js.map +1 -0
- package/dist/extension/executor.js +1 -1
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.js +1053 -96
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +6 -0
- package/dist/extension/recorder.js +20 -8
- package/dist/extension/recorder.js.map +1 -1
- package/dist/extension/workflow-tool.d.ts +28 -0
- package/dist/extension/workflow-tool.js +33 -0
- package/dist/extension/workflow-tool.js.map +1 -0
- package/dist/host/processes.d.ts +24 -0
- package/dist/host/processes.js +114 -0
- package/dist/host/processes.js.map +1 -0
- package/dist/host/rpc-bridge.d.ts +9 -0
- package/dist/host/rpc-bridge.js +39 -0
- package/dist/host/rpc-bridge.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +38 -0
- package/dist/host/rpc-executor.js +254 -0
- package/dist/host/rpc-executor.js.map +1 -0
- package/dist/host/runner.d.ts +49 -0
- package/dist/host/runner.js +350 -0
- package/dist/host/runner.js.map +1 -0
- package/dist/viewer/cli.d.ts +7 -3
- package/dist/viewer/cli.js +150 -19
- package/dist/viewer/cli.js.map +1 -1
- package/dist/workflows/engine.d.ts +36 -0
- package/dist/workflows/engine.js +244 -14
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +23 -0
- package/dist/workflows/errors.js +38 -0
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/graph.js +0 -5
- package/dist/workflows/graph.js.map +1 -1
- package/dist/workflows/loader.d.ts +5 -3
- package/dist/workflows/loader.js +10 -1
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/schema.js +1 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +50 -6
- package/dist/workflows/store.js +446 -51
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +10 -0
- package/docs/CONTROLLERS.md +215 -0
- package/docs/development.md +12 -9
- package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
- package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
- package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
- package/docs/run-bundles.md +66 -27
- package/docs/workflows.md +131 -11
- package/examples/controllers/pull-request.controller.ts +215 -0
- package/package.json +10 -2
- package/src/builtins/monitor.workflow.ts +278 -0
- package/src/controllers/conditions.ts +110 -0
- package/src/controllers/definition.ts +65 -0
- package/src/controllers/effects.ts +123 -0
- package/src/controllers/errors.ts +27 -0
- package/src/controllers/index.ts +90 -0
- package/src/controllers/json.ts +62 -0
- package/src/controllers/loader.ts +104 -0
- package/src/controllers/manager.ts +533 -0
- package/src/controllers/results.ts +46 -0
- package/src/controllers/sqlite.ts +1427 -0
- package/src/controllers/store.ts +160 -0
- package/src/controllers/types.ts +183 -0
- package/src/controllers/workflow-engine-scheduler.ts +145 -0
- package/src/controllers/workflows.ts +152 -0
- package/src/extension/controller-host.ts +163 -0
- package/src/extension/executor.ts +1 -1
- package/src/extension/index.ts +1243 -117
- package/src/extension/recorder.ts +65 -36
- package/src/extension/workflow-tool.ts +59 -0
- package/src/host/processes.ts +119 -0
- package/src/host/rpc-bridge.ts +44 -0
- package/src/host/rpc-executor.ts +299 -0
- package/src/host/runner.ts +406 -0
- package/src/viewer/cli.ts +167 -21
- package/src/workflows/engine.ts +327 -13
- package/src/workflows/errors.ts +45 -0
- package/src/workflows/graph.ts +0 -5
- package/src/workflows/loader.ts +13 -3
- package/src/workflows/schema.ts +1 -1
- package/src/workflows/store.ts +555 -46
- 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
|