@abelxiaoxing/cadence 1.0.14 → 1.1.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 +29 -13
- package/agents/contract-reviewer.md +2 -2
- package/agents/design-explorer.md +1 -1
- package/agents/diagnosis-worker.md +6 -3
- package/agents/implementation-worker.md +14 -9
- package/config/.env.example +5 -17
- package/config/routes.example.json +42 -0
- package/package.json +3 -3
- package/prompts/abel-design.md +150 -228
- package/prompts/abel-diagnose.md +37 -18
- package/prompts/abel-implement.md +111 -178
- package/prompts/abel-init.md +36 -33
- package/skills/abel-workflow/SKILL.md +156 -168
- package/src/apply-transaction.ts +1427 -0
- package/src/artifact-store.ts +204 -0
- package/src/child-session.ts +136 -17
- package/src/contracts.ts +105 -696
- package/src/control-contracts.ts +259 -0
- package/src/delivery-compiler.ts +1088 -0
- package/src/file-snapshot.ts +5 -0
- package/src/implement-graph.ts +11 -4
- package/src/index.ts +1465 -133
- package/src/isolation-backend.ts +269 -0
- package/src/packet-runtime.ts +533 -0
- package/src/parent-provider.ts +23 -4
- package/src/route-policy.ts +483 -0
- package/src/run-state.ts +393 -0
- package/src/run-store.ts +918 -0
- package/src/safe-path.ts +11 -0
- package/src/scoped-tools.ts +36 -0
- package/src/state-root.ts +215 -0
- package/src/subagent-activity.ts +526 -27
- package/src/submit-tool.ts +274 -0
- package/src/task-ledger.ts +1380 -0
- package/src/verification-capability.ts +529 -42
- package/src/worker-broker.ts +703 -0
- package/src/workflow-engine.ts +7391 -0
- package/src/workspace-store.ts +544 -0
- package/src/candidate-preflight.ts +0 -1618
- package/src/drain.ts +0 -20
- package/src/patch.ts +0 -505
- package/src/result-store.ts +0 -312
- package/src/runtime.ts +0 -2436
- package/src/scheduler.ts +0 -384
- package/src/subagent-endpoint.ts +0 -158
- package/src/worker.ts +0 -204
package/src/run-store.ts
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, lstatSync, openSync } from "node:fs";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import type { ControlStage } from "./control-contracts.ts";
|
|
5
|
+
import {
|
|
6
|
+
createRunEvent,
|
|
7
|
+
type DeliveryGate,
|
|
8
|
+
type RunEvent,
|
|
9
|
+
type RunProjection,
|
|
10
|
+
type RunState,
|
|
11
|
+
reduceRunEvents,
|
|
12
|
+
ZERO_EVENT_HASH,
|
|
13
|
+
} from "./run-state.ts";
|
|
14
|
+
import {
|
|
15
|
+
prepareStateRoot,
|
|
16
|
+
type ResolvedStateRoot,
|
|
17
|
+
StateRootError,
|
|
18
|
+
} from "./state-root.ts";
|
|
19
|
+
|
|
20
|
+
export const RUN_STORE_SCHEMA_VERSION = 2 as const;
|
|
21
|
+
|
|
22
|
+
const CHANGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u;
|
|
23
|
+
const IDENTIFIER = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
|
|
24
|
+
const SHA256 = /^[a-f0-9]{64}$/u;
|
|
25
|
+
|
|
26
|
+
interface RunRow {
|
|
27
|
+
run_id: string;
|
|
28
|
+
projection_json: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface EventRow {
|
|
32
|
+
sequence: number;
|
|
33
|
+
type: string;
|
|
34
|
+
payload_json: string;
|
|
35
|
+
prior_hash: string;
|
|
36
|
+
event_hash: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface OperationRow {
|
|
40
|
+
state: string;
|
|
41
|
+
outcome_json: string | null;
|
|
42
|
+
lease_token: string | null;
|
|
43
|
+
lease_expires_at: number | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RunStoreOptions {
|
|
47
|
+
now?: () => number;
|
|
48
|
+
busyTimeoutMs?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StartRunInput {
|
|
52
|
+
stage: ControlStage;
|
|
53
|
+
change?: string;
|
|
54
|
+
provisionalKey?: string;
|
|
55
|
+
operationId: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface BindDeliveryInput {
|
|
59
|
+
runId: string;
|
|
60
|
+
gate: DeliveryGate;
|
|
61
|
+
revision: number;
|
|
62
|
+
receiptHash: string;
|
|
63
|
+
operationId: string;
|
|
64
|
+
lease?: OperationLease;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface OperationLease {
|
|
68
|
+
runId: string;
|
|
69
|
+
operationId: string;
|
|
70
|
+
token: string;
|
|
71
|
+
expiresAt: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface OperationLeaseStatus {
|
|
75
|
+
state: "running" | "committed" | "interrupted";
|
|
76
|
+
outcome?: Record<string, unknown>;
|
|
77
|
+
expiresAt?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const SCHEMA = `
|
|
81
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
82
|
+
version INTEGER PRIMARY KEY CHECK (version = 2)
|
|
83
|
+
) STRICT;
|
|
84
|
+
|
|
85
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
86
|
+
run_id TEXT PRIMARY KEY,
|
|
87
|
+
root_hash TEXT NOT NULL,
|
|
88
|
+
stage TEXT NOT NULL,
|
|
89
|
+
lookup_key TEXT NOT NULL,
|
|
90
|
+
change_name TEXT,
|
|
91
|
+
provisional_key TEXT,
|
|
92
|
+
state TEXT NOT NULL,
|
|
93
|
+
sequence INTEGER NOT NULL,
|
|
94
|
+
event_hash TEXT NOT NULL,
|
|
95
|
+
delivery_revision INTEGER,
|
|
96
|
+
projection_json TEXT NOT NULL,
|
|
97
|
+
terminal_tombstone TEXT,
|
|
98
|
+
UNIQUE (root_hash, stage, lookup_key)
|
|
99
|
+
) STRICT;
|
|
100
|
+
|
|
101
|
+
CREATE TABLE IF NOT EXISTS delivery_bindings (
|
|
102
|
+
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
103
|
+
gate TEXT NOT NULL,
|
|
104
|
+
revision INTEGER NOT NULL,
|
|
105
|
+
receipt_hash TEXT NOT NULL,
|
|
106
|
+
operation_id TEXT NOT NULL,
|
|
107
|
+
PRIMARY KEY (run_id, gate, revision),
|
|
108
|
+
UNIQUE (run_id, operation_id)
|
|
109
|
+
) STRICT;
|
|
110
|
+
|
|
111
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
112
|
+
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
113
|
+
sequence INTEGER NOT NULL,
|
|
114
|
+
type TEXT NOT NULL,
|
|
115
|
+
payload_json TEXT NOT NULL,
|
|
116
|
+
prior_hash TEXT NOT NULL,
|
|
117
|
+
event_hash TEXT NOT NULL,
|
|
118
|
+
PRIMARY KEY (run_id, sequence),
|
|
119
|
+
UNIQUE (run_id, event_hash)
|
|
120
|
+
) STRICT;
|
|
121
|
+
|
|
122
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
123
|
+
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
124
|
+
task_id TEXT NOT NULL,
|
|
125
|
+
state TEXT NOT NULL,
|
|
126
|
+
phase TEXT,
|
|
127
|
+
projection_json TEXT NOT NULL,
|
|
128
|
+
PRIMARY KEY (run_id, task_id)
|
|
129
|
+
) STRICT;
|
|
130
|
+
|
|
131
|
+
CREATE TABLE IF NOT EXISTS operations (
|
|
132
|
+
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
133
|
+
operation_id TEXT NOT NULL,
|
|
134
|
+
kind TEXT NOT NULL,
|
|
135
|
+
state TEXT NOT NULL,
|
|
136
|
+
outcome_json TEXT,
|
|
137
|
+
lease_token TEXT,
|
|
138
|
+
lease_expires_at INTEGER,
|
|
139
|
+
PRIMARY KEY (run_id, operation_id)
|
|
140
|
+
) STRICT;
|
|
141
|
+
|
|
142
|
+
CREATE TABLE IF NOT EXISTS route_health (
|
|
143
|
+
route_fingerprint TEXT PRIMARY KEY,
|
|
144
|
+
state TEXT NOT NULL,
|
|
145
|
+
next_half_open_at INTEGER,
|
|
146
|
+
projection_json TEXT NOT NULL
|
|
147
|
+
) STRICT;
|
|
148
|
+
|
|
149
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
150
|
+
content_hash TEXT PRIMARY KEY,
|
|
151
|
+
size_bytes INTEGER NOT NULL,
|
|
152
|
+
seal_state TEXT NOT NULL,
|
|
153
|
+
owner_run_id TEXT REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
154
|
+
reference_count INTEGER NOT NULL
|
|
155
|
+
) STRICT;
|
|
156
|
+
|
|
157
|
+
CREATE TABLE IF NOT EXISTS workspace_revisions (
|
|
158
|
+
revision_id TEXT PRIMARY KEY,
|
|
159
|
+
owner_run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
160
|
+
parent_revision_id TEXT,
|
|
161
|
+
manifest_hash TEXT NOT NULL
|
|
162
|
+
) STRICT;
|
|
163
|
+
|
|
164
|
+
CREATE TABLE IF NOT EXISTS apply_transactions (
|
|
165
|
+
transaction_id TEXT PRIMARY KEY,
|
|
166
|
+
owner_run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
167
|
+
state TEXT NOT NULL,
|
|
168
|
+
intent_json TEXT NOT NULL,
|
|
169
|
+
recovery_json TEXT
|
|
170
|
+
) STRICT;
|
|
171
|
+
|
|
172
|
+
CREATE TABLE IF NOT EXISTS bootstrap_handoffs (
|
|
173
|
+
handoff_id TEXT PRIMARY KEY,
|
|
174
|
+
owner_run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
175
|
+
receipt_hash TEXT NOT NULL UNIQUE,
|
|
176
|
+
state TEXT NOT NULL,
|
|
177
|
+
facts_json TEXT NOT NULL
|
|
178
|
+
) STRICT;
|
|
179
|
+
`;
|
|
180
|
+
|
|
181
|
+
function requireIdentifier(value: string, label: string): void {
|
|
182
|
+
if (!IDENTIFIER.test(value)) throw new Error(`invalid-${label}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseProjection(value: string): RunProjection {
|
|
186
|
+
const parsed = JSON.parse(value) as RunProjection;
|
|
187
|
+
if (
|
|
188
|
+
!parsed ||
|
|
189
|
+
typeof parsed !== "object" ||
|
|
190
|
+
typeof parsed.runId !== "string"
|
|
191
|
+
) {
|
|
192
|
+
throw new Error("run-projection-invalid");
|
|
193
|
+
}
|
|
194
|
+
return parsed;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function lookupKey(input: StartRunInput): string {
|
|
198
|
+
const hasChange = typeof input.change === "string";
|
|
199
|
+
const hasProvisional = typeof input.provisionalKey === "string";
|
|
200
|
+
if (hasChange === hasProvisional) throw new Error("invalid-run-lookup-key");
|
|
201
|
+
if (hasChange) {
|
|
202
|
+
if (!CHANGE_NAME.test(input.change as string))
|
|
203
|
+
throw new Error("invalid-change");
|
|
204
|
+
return `change:${input.change}`;
|
|
205
|
+
}
|
|
206
|
+
if (!SHA256.test(input.provisionalKey as string)) {
|
|
207
|
+
throw new Error("invalid-provisional-key");
|
|
208
|
+
}
|
|
209
|
+
if (input.stage !== "abel-design") {
|
|
210
|
+
throw new Error("provisional-run-requires-design");
|
|
211
|
+
}
|
|
212
|
+
return `provisional:${input.provisionalKey}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export class RunStore {
|
|
216
|
+
readonly stateRoot: ResolvedStateRoot;
|
|
217
|
+
readonly #database: DatabaseSync;
|
|
218
|
+
readonly #now: () => number;
|
|
219
|
+
#closed = false;
|
|
220
|
+
|
|
221
|
+
private constructor(
|
|
222
|
+
stateRoot: ResolvedStateRoot,
|
|
223
|
+
database: DatabaseSync,
|
|
224
|
+
options: RunStoreOptions,
|
|
225
|
+
) {
|
|
226
|
+
this.stateRoot = stateRoot;
|
|
227
|
+
this.#database = database;
|
|
228
|
+
this.#now = options.now ?? Date.now;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
static open(
|
|
232
|
+
unresolved: ResolvedStateRoot,
|
|
233
|
+
options: RunStoreOptions = {},
|
|
234
|
+
): RunStore {
|
|
235
|
+
const stateRoot = prepareStateRoot(unresolved);
|
|
236
|
+
try {
|
|
237
|
+
const existing = lstatSync(stateRoot.databasePath);
|
|
238
|
+
if (existing.isSymbolicLink()) {
|
|
239
|
+
throw new StateRootError("state-component-symlink");
|
|
240
|
+
}
|
|
241
|
+
if (!existing.isFile()) {
|
|
242
|
+
throw new StateRootError("state-component-type");
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (
|
|
246
|
+
error instanceof StateRootError ||
|
|
247
|
+
(error as NodeJS.ErrnoException).code !== "ENOENT"
|
|
248
|
+
) {
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
const descriptor = openSync(stateRoot.databasePath, "ax", 0o600);
|
|
252
|
+
closeSync(descriptor);
|
|
253
|
+
}
|
|
254
|
+
chmodSync(stateRoot.databasePath, 0o600);
|
|
255
|
+
|
|
256
|
+
const database = new DatabaseSync(stateRoot.databasePath);
|
|
257
|
+
database.exec("PRAGMA foreign_keys = ON");
|
|
258
|
+
database.exec("PRAGMA journal_mode = WAL");
|
|
259
|
+
database.exec("PRAGMA synchronous = FULL");
|
|
260
|
+
database.exec(
|
|
261
|
+
`PRAGMA busy_timeout = ${Math.max(1, Math.min(30_000, options.busyTimeoutMs ?? 5_000))}`,
|
|
262
|
+
);
|
|
263
|
+
database.exec(SCHEMA);
|
|
264
|
+
const schemaRow = database
|
|
265
|
+
.prepare("SELECT version FROM schema_meta LIMIT 1")
|
|
266
|
+
.get() as { version: number } | undefined;
|
|
267
|
+
if (!schemaRow) {
|
|
268
|
+
database
|
|
269
|
+
.prepare("INSERT INTO schema_meta(version) VALUES (?)")
|
|
270
|
+
.run(RUN_STORE_SCHEMA_VERSION);
|
|
271
|
+
} else if (schemaRow.version !== RUN_STORE_SCHEMA_VERSION) {
|
|
272
|
+
database.close();
|
|
273
|
+
throw new Error("run-store-schema-version-rejected");
|
|
274
|
+
}
|
|
275
|
+
const store = new RunStore(stateRoot, database, options);
|
|
276
|
+
store.#secureDatabaseFiles();
|
|
277
|
+
store.recoverExpiredLeases();
|
|
278
|
+
return store;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#assertOpen(): void {
|
|
282
|
+
if (this.#closed) throw new Error("run-store-closed");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#transaction<T>(work: () => T): T {
|
|
286
|
+
this.#assertOpen();
|
|
287
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
288
|
+
let value: T;
|
|
289
|
+
try {
|
|
290
|
+
value = work();
|
|
291
|
+
this.#database.exec("COMMIT");
|
|
292
|
+
} catch (error) {
|
|
293
|
+
this.#database.exec("ROLLBACK");
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
this.#secureDatabaseFiles();
|
|
297
|
+
return value;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#secureDatabaseFiles(): void {
|
|
301
|
+
for (const file of [
|
|
302
|
+
this.stateRoot.databasePath,
|
|
303
|
+
`${this.stateRoot.databasePath}-wal`,
|
|
304
|
+
`${this.stateRoot.databasePath}-shm`,
|
|
305
|
+
]) {
|
|
306
|
+
try {
|
|
307
|
+
const stat = lstatSync(file);
|
|
308
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
309
|
+
throw new StateRootError(
|
|
310
|
+
stat.isSymbolicLink()
|
|
311
|
+
? "state-component-symlink"
|
|
312
|
+
: "state-component-type",
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
chmodSync(file, 0o600);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#runRow(runId: string): RunRow {
|
|
323
|
+
const row = this.#database
|
|
324
|
+
.prepare("SELECT run_id, projection_json FROM runs WHERE run_id = ?")
|
|
325
|
+
.get(runId) as RunRow | undefined;
|
|
326
|
+
if (!row) throw new Error("run-not-found");
|
|
327
|
+
return row;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#operation(runId: string, operationId: string): OperationRow | undefined {
|
|
331
|
+
return this.#database
|
|
332
|
+
.prepare(
|
|
333
|
+
`SELECT state, outcome_json, lease_token, lease_expires_at
|
|
334
|
+
FROM operations WHERE run_id = ? AND operation_id = ?`,
|
|
335
|
+
)
|
|
336
|
+
.get(runId, operationId) as OperationRow | undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#operationOutcome(
|
|
340
|
+
runId: string,
|
|
341
|
+
operationId: string,
|
|
342
|
+
): RunProjection | undefined {
|
|
343
|
+
const row = this.#operation(runId, operationId);
|
|
344
|
+
if (row?.state !== "committed" || row.outcome_json === null)
|
|
345
|
+
return undefined;
|
|
346
|
+
return parseProjection(row.outcome_json);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#recordOperation(
|
|
350
|
+
runId: string,
|
|
351
|
+
operationId: string,
|
|
352
|
+
kind: string,
|
|
353
|
+
outcome: RunProjection,
|
|
354
|
+
): void {
|
|
355
|
+
this.#database
|
|
356
|
+
.prepare(
|
|
357
|
+
`INSERT INTO operations(
|
|
358
|
+
run_id, operation_id, kind, state, outcome_json
|
|
359
|
+
) VALUES (?, ?, ?, 'committed', ?)
|
|
360
|
+
ON CONFLICT(run_id, operation_id) DO UPDATE SET
|
|
361
|
+
state = 'committed', outcome_json = excluded.outcome_json,
|
|
362
|
+
lease_token = NULL, lease_expires_at = NULL`,
|
|
363
|
+
)
|
|
364
|
+
.run(runId, operationId, kind, JSON.stringify(outcome));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#events(runId: string): RunEvent[] {
|
|
368
|
+
const rows = this.#database
|
|
369
|
+
.prepare(
|
|
370
|
+
`SELECT sequence, type, payload_json, prior_hash, event_hash
|
|
371
|
+
FROM events WHERE run_id = ? ORDER BY sequence`,
|
|
372
|
+
)
|
|
373
|
+
.all(runId) as unknown as EventRow[];
|
|
374
|
+
return rows.map((row) => ({
|
|
375
|
+
sequence: Number(row.sequence),
|
|
376
|
+
type: row.type as RunEvent["type"],
|
|
377
|
+
payload: JSON.parse(row.payload_json) as Record<string, unknown>,
|
|
378
|
+
priorHash: row.prior_hash,
|
|
379
|
+
hash: row.event_hash,
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#appendEvent(
|
|
384
|
+
runId: string,
|
|
385
|
+
type: RunEvent["type"],
|
|
386
|
+
payload: Record<string, unknown>,
|
|
387
|
+
): RunProjection {
|
|
388
|
+
const row = this.#runRow(runId);
|
|
389
|
+
const current = parseProjection(row.projection_json);
|
|
390
|
+
const event = createRunEvent(
|
|
391
|
+
current.sequence + 1,
|
|
392
|
+
type,
|
|
393
|
+
payload,
|
|
394
|
+
current.eventHash,
|
|
395
|
+
);
|
|
396
|
+
const projection = reduceRunEvents([...this.#events(runId), event]);
|
|
397
|
+
this.#database
|
|
398
|
+
.prepare(
|
|
399
|
+
`INSERT INTO events(
|
|
400
|
+
run_id, sequence, type, payload_json, prior_hash, event_hash
|
|
401
|
+
) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
402
|
+
)
|
|
403
|
+
.run(
|
|
404
|
+
runId,
|
|
405
|
+
event.sequence,
|
|
406
|
+
event.type,
|
|
407
|
+
JSON.stringify(event.payload),
|
|
408
|
+
event.priorHash,
|
|
409
|
+
event.hash,
|
|
410
|
+
);
|
|
411
|
+
this.#database
|
|
412
|
+
.prepare(
|
|
413
|
+
`UPDATE runs SET state = ?, sequence = ?, event_hash = ?,
|
|
414
|
+
delivery_revision = ?, projection_json = ?, terminal_tombstone = ?
|
|
415
|
+
WHERE run_id = ?`,
|
|
416
|
+
)
|
|
417
|
+
.run(
|
|
418
|
+
projection.state,
|
|
419
|
+
projection.sequence,
|
|
420
|
+
projection.eventHash,
|
|
421
|
+
projection.deliveryRevision ?? null,
|
|
422
|
+
JSON.stringify(projection),
|
|
423
|
+
projection.terminal ?? null,
|
|
424
|
+
runId,
|
|
425
|
+
);
|
|
426
|
+
return projection;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
startRun(input: StartRunInput): RunProjection {
|
|
430
|
+
requireIdentifier(input.operationId, "operation-id");
|
|
431
|
+
if (input.stage !== "abel-design" && input.stage !== "abel-implement") {
|
|
432
|
+
throw new Error("invalid-run-stage");
|
|
433
|
+
}
|
|
434
|
+
const key = lookupKey(input);
|
|
435
|
+
return this.#transaction(() => {
|
|
436
|
+
const existing = this.#database
|
|
437
|
+
.prepare(
|
|
438
|
+
`SELECT run_id, projection_json FROM runs
|
|
439
|
+
WHERE root_hash = ? AND stage = ? AND lookup_key = ?`,
|
|
440
|
+
)
|
|
441
|
+
.get(this.stateRoot.consumerRootHash, input.stage, key) as
|
|
442
|
+
| RunRow
|
|
443
|
+
| undefined;
|
|
444
|
+
if (existing) {
|
|
445
|
+
const replay = this.#operationOutcome(
|
|
446
|
+
existing.run_id,
|
|
447
|
+
input.operationId,
|
|
448
|
+
);
|
|
449
|
+
if (replay) return replay;
|
|
450
|
+
const projection = parseProjection(existing.projection_json);
|
|
451
|
+
this.#recordOperation(
|
|
452
|
+
existing.run_id,
|
|
453
|
+
input.operationId,
|
|
454
|
+
"start",
|
|
455
|
+
projection,
|
|
456
|
+
);
|
|
457
|
+
return projection;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const runId = randomUUID();
|
|
461
|
+
const event = createRunEvent(
|
|
462
|
+
1,
|
|
463
|
+
"run-created",
|
|
464
|
+
{
|
|
465
|
+
runId,
|
|
466
|
+
rootHash: this.stateRoot.consumerRootHash,
|
|
467
|
+
stage: input.stage,
|
|
468
|
+
...(input.change ? { change: input.change } : {}),
|
|
469
|
+
...(input.provisionalKey
|
|
470
|
+
? { provisionalKey: input.provisionalKey }
|
|
471
|
+
: {}),
|
|
472
|
+
},
|
|
473
|
+
ZERO_EVENT_HASH,
|
|
474
|
+
);
|
|
475
|
+
const projection = reduceRunEvents([event]);
|
|
476
|
+
this.#database
|
|
477
|
+
.prepare(
|
|
478
|
+
`INSERT INTO runs(
|
|
479
|
+
run_id, root_hash, stage, lookup_key, change_name,
|
|
480
|
+
provisional_key, state, sequence, event_hash, projection_json
|
|
481
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
482
|
+
)
|
|
483
|
+
.run(
|
|
484
|
+
runId,
|
|
485
|
+
this.stateRoot.consumerRootHash,
|
|
486
|
+
input.stage,
|
|
487
|
+
key,
|
|
488
|
+
input.change ?? null,
|
|
489
|
+
input.provisionalKey ?? null,
|
|
490
|
+
projection.state,
|
|
491
|
+
projection.sequence,
|
|
492
|
+
projection.eventHash,
|
|
493
|
+
JSON.stringify(projection),
|
|
494
|
+
);
|
|
495
|
+
this.#database
|
|
496
|
+
.prepare(
|
|
497
|
+
`INSERT INTO events(
|
|
498
|
+
run_id, sequence, type, payload_json, prior_hash, event_hash
|
|
499
|
+
) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
500
|
+
)
|
|
501
|
+
.run(
|
|
502
|
+
runId,
|
|
503
|
+
event.sequence,
|
|
504
|
+
event.type,
|
|
505
|
+
JSON.stringify(event.payload),
|
|
506
|
+
event.priorHash,
|
|
507
|
+
event.hash,
|
|
508
|
+
);
|
|
509
|
+
this.#recordOperation(runId, input.operationId, "start", projection);
|
|
510
|
+
return projection;
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
bindChange(input: {
|
|
515
|
+
runId: string;
|
|
516
|
+
change: string;
|
|
517
|
+
operationId: string;
|
|
518
|
+
}): RunProjection {
|
|
519
|
+
requireIdentifier(input.operationId, "operation-id");
|
|
520
|
+
if (!CHANGE_NAME.test(input.change)) throw new Error("invalid-change");
|
|
521
|
+
return this.#transaction(() => {
|
|
522
|
+
const replay = this.#operationOutcome(input.runId, input.operationId);
|
|
523
|
+
if (replay) return replay;
|
|
524
|
+
const current = this.status(input.runId);
|
|
525
|
+
if (current.stage !== "abel-design" || !current.provisionalKey) {
|
|
526
|
+
throw new Error("change-binding-not-allowed");
|
|
527
|
+
}
|
|
528
|
+
const conflict = this.#database
|
|
529
|
+
.prepare(
|
|
530
|
+
`SELECT run_id FROM runs
|
|
531
|
+
WHERE root_hash = ? AND stage = ? AND lookup_key = ?`,
|
|
532
|
+
)
|
|
533
|
+
.get(current.rootHash, current.stage, `change:${input.change}`) as
|
|
534
|
+
| { run_id: string }
|
|
535
|
+
| undefined;
|
|
536
|
+
if (conflict && conflict.run_id !== input.runId) {
|
|
537
|
+
throw new Error("change-run-already-exists");
|
|
538
|
+
}
|
|
539
|
+
this.#database
|
|
540
|
+
.prepare(
|
|
541
|
+
`UPDATE runs SET lookup_key = ?, change_name = ?, provisional_key = NULL
|
|
542
|
+
WHERE run_id = ?`,
|
|
543
|
+
)
|
|
544
|
+
.run(`change:${input.change}`, input.change, input.runId);
|
|
545
|
+
const projection = this.#appendEvent(input.runId, "change-bound", {
|
|
546
|
+
change: input.change,
|
|
547
|
+
});
|
|
548
|
+
this.#recordOperation(
|
|
549
|
+
input.runId,
|
|
550
|
+
input.operationId,
|
|
551
|
+
"change-bound",
|
|
552
|
+
projection,
|
|
553
|
+
);
|
|
554
|
+
return projection;
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
#requireDeliveryBinding(input: BindDeliveryInput): void {
|
|
559
|
+
requireIdentifier(input.operationId, "operation-id");
|
|
560
|
+
if (
|
|
561
|
+
(input.gate !== "gate-a" && input.gate !== "gate-b") ||
|
|
562
|
+
!Number.isSafeInteger(input.revision) ||
|
|
563
|
+
input.revision < 1 ||
|
|
564
|
+
!SHA256.test(input.receiptHash)
|
|
565
|
+
) {
|
|
566
|
+
throw new Error("invalid-delivery-binding");
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
#bindDelivery(input: BindDeliveryInput): RunProjection {
|
|
571
|
+
if (input.lease) {
|
|
572
|
+
if (input.lease.runId !== input.runId) throw new Error("lease-fenced");
|
|
573
|
+
this.#assertLease(input.lease);
|
|
574
|
+
}
|
|
575
|
+
const replay = this.#operationOutcome(input.runId, input.operationId);
|
|
576
|
+
if (replay) return replay;
|
|
577
|
+
const existing = this.#database
|
|
578
|
+
.prepare(
|
|
579
|
+
`SELECT receipt_hash FROM delivery_bindings
|
|
580
|
+
WHERE run_id = ? AND gate = ? AND revision = ?`,
|
|
581
|
+
)
|
|
582
|
+
.get(input.runId, input.gate, input.revision) as
|
|
583
|
+
| { receipt_hash: string }
|
|
584
|
+
| undefined;
|
|
585
|
+
if (existing) {
|
|
586
|
+
if (existing.receipt_hash !== input.receiptHash) {
|
|
587
|
+
throw new Error("delivery-revision-conflict");
|
|
588
|
+
}
|
|
589
|
+
const projection = this.status(input.runId);
|
|
590
|
+
this.#recordOperation(
|
|
591
|
+
input.runId,
|
|
592
|
+
input.operationId,
|
|
593
|
+
"delivery-bound",
|
|
594
|
+
projection,
|
|
595
|
+
);
|
|
596
|
+
return projection;
|
|
597
|
+
}
|
|
598
|
+
this.#runRow(input.runId);
|
|
599
|
+
this.#database
|
|
600
|
+
.prepare(
|
|
601
|
+
`INSERT INTO delivery_bindings(
|
|
602
|
+
run_id, gate, revision, receipt_hash, operation_id
|
|
603
|
+
) VALUES (?, ?, ?, ?, ?)`,
|
|
604
|
+
)
|
|
605
|
+
.run(
|
|
606
|
+
input.runId,
|
|
607
|
+
input.gate,
|
|
608
|
+
input.revision,
|
|
609
|
+
input.receiptHash,
|
|
610
|
+
input.operationId,
|
|
611
|
+
);
|
|
612
|
+
const projection = this.#appendEvent(input.runId, "delivery-bound", {
|
|
613
|
+
gate: input.gate,
|
|
614
|
+
revision: input.revision,
|
|
615
|
+
receiptHash: input.receiptHash,
|
|
616
|
+
});
|
|
617
|
+
this.#recordOperation(
|
|
618
|
+
input.runId,
|
|
619
|
+
input.operationId,
|
|
620
|
+
"delivery-bound",
|
|
621
|
+
projection,
|
|
622
|
+
);
|
|
623
|
+
return projection;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
bindDelivery(input: BindDeliveryInput): RunProjection {
|
|
627
|
+
this.#requireDeliveryBinding(input);
|
|
628
|
+
return this.#transaction(() => this.#bindDelivery(input));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
bindDeliveryAtomically(
|
|
632
|
+
input: BindDeliveryInput,
|
|
633
|
+
write: (database: DatabaseSync) => void,
|
|
634
|
+
): RunProjection {
|
|
635
|
+
this.#requireDeliveryBinding(input);
|
|
636
|
+
if (typeof write !== "function") {
|
|
637
|
+
throw new Error("delivery-binding-write-invalid");
|
|
638
|
+
}
|
|
639
|
+
return this.#transaction(() => {
|
|
640
|
+
const projection = this.#bindDelivery(input);
|
|
641
|
+
write(this.#database);
|
|
642
|
+
return projection;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
acquireLease(input: {
|
|
647
|
+
runId: string;
|
|
648
|
+
operationId: string;
|
|
649
|
+
ttlMs: number;
|
|
650
|
+
preempt?: boolean;
|
|
651
|
+
exclusive?: boolean;
|
|
652
|
+
}): OperationLease {
|
|
653
|
+
requireIdentifier(input.operationId, "operation-id");
|
|
654
|
+
if (!Number.isSafeInteger(input.ttlMs) || input.ttlMs < 1) {
|
|
655
|
+
throw new Error("invalid-lease-ttl");
|
|
656
|
+
}
|
|
657
|
+
return this.#transaction(() => {
|
|
658
|
+
this.#runRow(input.runId);
|
|
659
|
+
const now = this.#now();
|
|
660
|
+
const existing = this.#operation(input.runId, input.operationId);
|
|
661
|
+
if (
|
|
662
|
+
existing?.state === "running" &&
|
|
663
|
+
existing.lease_token &&
|
|
664
|
+
existing.lease_expires_at !== null &&
|
|
665
|
+
existing.lease_expires_at >= now
|
|
666
|
+
) {
|
|
667
|
+
if (!input.preempt) {
|
|
668
|
+
if (input.exclusive) throw new Error("operation-already-running");
|
|
669
|
+
return {
|
|
670
|
+
runId: input.runId,
|
|
671
|
+
operationId: input.operationId,
|
|
672
|
+
token: existing.lease_token,
|
|
673
|
+
expiresAt: existing.lease_expires_at,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const token = randomUUID();
|
|
678
|
+
const expiresAt = now + input.ttlMs;
|
|
679
|
+
this.#database
|
|
680
|
+
.prepare(
|
|
681
|
+
`INSERT INTO operations(
|
|
682
|
+
run_id, operation_id, kind, state, lease_token, lease_expires_at
|
|
683
|
+
) VALUES (?, ?, 'lease', 'running', ?, ?)
|
|
684
|
+
ON CONFLICT(run_id, operation_id) DO UPDATE SET
|
|
685
|
+
state = 'running', outcome_json = NULL,
|
|
686
|
+
lease_token = excluded.lease_token,
|
|
687
|
+
lease_expires_at = excluded.lease_expires_at`,
|
|
688
|
+
)
|
|
689
|
+
.run(input.runId, input.operationId, token, expiresAt);
|
|
690
|
+
return {
|
|
691
|
+
runId: input.runId,
|
|
692
|
+
operationId: input.operationId,
|
|
693
|
+
token,
|
|
694
|
+
expiresAt,
|
|
695
|
+
};
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
renewLease(lease: OperationLease, ttlMs: number): OperationLease {
|
|
700
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1)
|
|
701
|
+
throw new Error("invalid-lease-ttl");
|
|
702
|
+
return this.#transaction(() => {
|
|
703
|
+
const existing = this.#operation(lease.runId, lease.operationId);
|
|
704
|
+
if (
|
|
705
|
+
existing?.state !== "running" ||
|
|
706
|
+
existing.lease_token !== lease.token ||
|
|
707
|
+
existing.lease_expires_at === null ||
|
|
708
|
+
existing.lease_expires_at < this.#now()
|
|
709
|
+
) {
|
|
710
|
+
throw new Error("lease-fenced");
|
|
711
|
+
}
|
|
712
|
+
const expiresAt = this.#now() + ttlMs;
|
|
713
|
+
this.#database
|
|
714
|
+
.prepare(
|
|
715
|
+
`UPDATE operations SET lease_expires_at = ?
|
|
716
|
+
WHERE run_id = ? AND operation_id = ? AND lease_token = ?`,
|
|
717
|
+
)
|
|
718
|
+
.run(expiresAt, lease.runId, lease.operationId, lease.token);
|
|
719
|
+
return { ...lease, expiresAt };
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
#assertLease(lease: OperationLease): void {
|
|
724
|
+
const existing = this.#operation(lease.runId, lease.operationId);
|
|
725
|
+
if (
|
|
726
|
+
existing?.state !== "running" ||
|
|
727
|
+
existing.lease_token !== lease.token ||
|
|
728
|
+
existing.lease_expires_at === null ||
|
|
729
|
+
existing.lease_expires_at < this.#now()
|
|
730
|
+
) {
|
|
731
|
+
throw new Error("lease-fenced");
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
assertLease(lease: OperationLease): void {
|
|
736
|
+
this.#transaction(() => this.#assertLease(lease));
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
inspectOperation(
|
|
740
|
+
runId: string,
|
|
741
|
+
operationId: string,
|
|
742
|
+
): OperationLeaseStatus | undefined {
|
|
743
|
+
this.#assertOpen();
|
|
744
|
+
requireIdentifier(operationId, "operation-id");
|
|
745
|
+
this.#runRow(runId);
|
|
746
|
+
let operation = this.#operation(runId, operationId);
|
|
747
|
+
if (!operation) return undefined;
|
|
748
|
+
if (
|
|
749
|
+
operation.state === "running" &&
|
|
750
|
+
operation.lease_expires_at !== null &&
|
|
751
|
+
operation.lease_expires_at < this.#now()
|
|
752
|
+
) {
|
|
753
|
+
this.#transaction(() => {
|
|
754
|
+
this.#database
|
|
755
|
+
.prepare(
|
|
756
|
+
`UPDATE operations
|
|
757
|
+
SET state = 'interrupted', lease_token = NULL,
|
|
758
|
+
lease_expires_at = NULL
|
|
759
|
+
WHERE run_id = ? AND operation_id = ? AND state = 'running'
|
|
760
|
+
AND lease_expires_at < ?`,
|
|
761
|
+
)
|
|
762
|
+
.run(runId, operationId, this.#now());
|
|
763
|
+
});
|
|
764
|
+
operation = this.#operation(runId, operationId);
|
|
765
|
+
if (!operation) throw new Error("operation-recovery-invalid");
|
|
766
|
+
}
|
|
767
|
+
if (
|
|
768
|
+
operation.state !== "running" &&
|
|
769
|
+
operation.state !== "committed" &&
|
|
770
|
+
operation.state !== "interrupted"
|
|
771
|
+
) {
|
|
772
|
+
throw new Error("operation-state-invalid");
|
|
773
|
+
}
|
|
774
|
+
let outcome: Record<string, unknown> | undefined;
|
|
775
|
+
if (operation.outcome_json !== null) {
|
|
776
|
+
const parsed = JSON.parse(operation.outcome_json) as unknown;
|
|
777
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
778
|
+
throw new Error("operation-outcome-invalid");
|
|
779
|
+
}
|
|
780
|
+
outcome = parsed as Record<string, unknown>;
|
|
781
|
+
}
|
|
782
|
+
return {
|
|
783
|
+
state: operation.state,
|
|
784
|
+
...(outcome ? { outcome: structuredClone(outcome) } : {}),
|
|
785
|
+
...(operation.lease_expires_at === null
|
|
786
|
+
? {}
|
|
787
|
+
: { expiresAt: operation.lease_expires_at }),
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
commitLease(
|
|
792
|
+
lease: OperationLease,
|
|
793
|
+
outcome: Record<string, unknown>,
|
|
794
|
+
): Record<string, unknown> {
|
|
795
|
+
const serialized = JSON.stringify(outcome);
|
|
796
|
+
if (!serialized) throw new Error("operation-outcome-invalid");
|
|
797
|
+
return this.#transaction(() => {
|
|
798
|
+
this.#assertLease(lease);
|
|
799
|
+
const result = this.#database
|
|
800
|
+
.prepare(
|
|
801
|
+
`UPDATE operations
|
|
802
|
+
SET state = 'committed', outcome_json = ?,
|
|
803
|
+
lease_token = NULL, lease_expires_at = NULL
|
|
804
|
+
WHERE run_id = ? AND operation_id = ? AND state = 'running'
|
|
805
|
+
AND lease_token = ?`,
|
|
806
|
+
)
|
|
807
|
+
.run(serialized, lease.runId, lease.operationId, lease.token);
|
|
808
|
+
if (Number(result.changes) !== 1) throw new Error("lease-fenced");
|
|
809
|
+
return structuredClone(outcome);
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
interruptLease(lease: OperationLease): void {
|
|
814
|
+
this.#transaction(() => {
|
|
815
|
+
this.#assertLease(lease);
|
|
816
|
+
const result = this.#database
|
|
817
|
+
.prepare(
|
|
818
|
+
`UPDATE operations
|
|
819
|
+
SET state = 'interrupted', outcome_json = NULL,
|
|
820
|
+
lease_token = NULL, lease_expires_at = NULL
|
|
821
|
+
WHERE run_id = ? AND operation_id = ? AND state = 'running'
|
|
822
|
+
AND lease_token = ?`,
|
|
823
|
+
)
|
|
824
|
+
.run(lease.runId, lease.operationId, lease.token);
|
|
825
|
+
if (Number(result.changes) !== 1) throw new Error("lease-fenced");
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
transition(input: {
|
|
830
|
+
runId: string;
|
|
831
|
+
to: RunState;
|
|
832
|
+
operationId: string;
|
|
833
|
+
code?: string;
|
|
834
|
+
lease?: OperationLease;
|
|
835
|
+
}): RunProjection {
|
|
836
|
+
requireIdentifier(input.operationId, "operation-id");
|
|
837
|
+
return this.#transaction(() => {
|
|
838
|
+
if (input.lease) {
|
|
839
|
+
if (input.lease.runId !== input.runId) throw new Error("lease-fenced");
|
|
840
|
+
this.#assertLease(input.lease);
|
|
841
|
+
}
|
|
842
|
+
const replay = this.#operationOutcome(input.runId, input.operationId);
|
|
843
|
+
if (replay) return replay;
|
|
844
|
+
const current = this.status(input.runId);
|
|
845
|
+
const projection = this.#appendEvent(input.runId, "state-transitioned", {
|
|
846
|
+
from: current.state,
|
|
847
|
+
to: input.to,
|
|
848
|
+
...(input.code ? { code: input.code } : {}),
|
|
849
|
+
});
|
|
850
|
+
this.#recordOperation(
|
|
851
|
+
input.runId,
|
|
852
|
+
input.operationId,
|
|
853
|
+
"state-transitioned",
|
|
854
|
+
projection,
|
|
855
|
+
);
|
|
856
|
+
return projection;
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
status(runId: string): RunProjection {
|
|
861
|
+
this.#assertOpen();
|
|
862
|
+
return parseProjection(this.#runRow(runId).projection_json);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
listEvents(runId: string): RunEvent[] {
|
|
866
|
+
this.#assertOpen();
|
|
867
|
+
this.#runRow(runId);
|
|
868
|
+
return structuredClone(this.#events(runId));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
rebuildProjection(runId: string): RunProjection {
|
|
872
|
+
return reduceRunEvents(this.listEvents(runId));
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
verifyIntegrity(runId: string): RunProjection {
|
|
876
|
+
const rebuilt = this.rebuildProjection(runId);
|
|
877
|
+
const stored = this.status(runId);
|
|
878
|
+
if (JSON.stringify(rebuilt) !== JSON.stringify(stored)) {
|
|
879
|
+
throw new Error("run-projection-integrity-mismatch");
|
|
880
|
+
}
|
|
881
|
+
return rebuilt;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
recoverExpiredLeases(): number {
|
|
885
|
+
this.#assertOpen();
|
|
886
|
+
const expired = this.#database
|
|
887
|
+
.prepare(
|
|
888
|
+
`SELECT run_id, operation_id FROM operations
|
|
889
|
+
WHERE state = 'running' AND lease_expires_at < ?`,
|
|
890
|
+
)
|
|
891
|
+
.all(this.#now()) as unknown as Array<{
|
|
892
|
+
run_id: string;
|
|
893
|
+
operation_id: string;
|
|
894
|
+
}>;
|
|
895
|
+
if (expired.length === 0) return 0;
|
|
896
|
+
return this.#transaction(() => {
|
|
897
|
+
for (const row of expired) {
|
|
898
|
+
this.#database
|
|
899
|
+
.prepare(
|
|
900
|
+
`UPDATE operations
|
|
901
|
+
SET state = 'interrupted', lease_token = NULL,
|
|
902
|
+
lease_expires_at = NULL
|
|
903
|
+
WHERE run_id = ? AND operation_id = ? AND state = 'running'`,
|
|
904
|
+
)
|
|
905
|
+
.run(row.run_id, row.operation_id);
|
|
906
|
+
}
|
|
907
|
+
return expired.length;
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
close(): void {
|
|
912
|
+
if (this.#closed) return;
|
|
913
|
+
this.#database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
914
|
+
this.#secureDatabaseFiles();
|
|
915
|
+
this.#database.close();
|
|
916
|
+
this.#closed = true;
|
|
917
|
+
}
|
|
918
|
+
}
|