@evoclock/pi-agentic-driver 0.9.0 → 0.9.2
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/.agentic-driver/router.defaults.json +64 -0
- package/LICENSE +11 -8
- package/README.md +94 -29
- package/extensions/attended-authority-guard.ts +1 -1
- package/extensions/code-phage.js +1 -1
- package/extensions/herdr-communication.ts +9 -2
- package/extensions/herdr-dispatch.ts +1 -1
- package/extensions/herdr-lifecycle.ts +5 -4
- package/extensions/linux-microvm.ts +1 -1
- package/extensions/pulse.ts +105 -0
- package/extensions/task-board.ts +1 -1
- package/lib/adapters/diff-scope.mjs +1 -1
- package/lib/adapters/evidence.mjs +1 -1
- package/lib/adapters/narrative.mjs +1 -1
- package/lib/adapters/review-feedback.mjs +1 -1
- package/lib/adapters/visualization.mjs +1 -1
- package/lib/code-phage-core.mjs +1 -1
- package/lib/python_ast_metrics.py +1 -1
- package/lib/typescript_ast_metrics.mjs +1 -1
- package/package.json +17 -3
- package/scripts/aidr_writing_review.js +1 -1
- package/scripts/enforcement/attended_authority_guard.js +1 -1
- package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
- package/scripts/enforcement/herdr_async_dispatch_pi.js +66 -4
- package/scripts/enforcement/herdr_communication_pi.js +55 -19
- package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
- package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
- package/scripts/enforcement/native_tui_context.js +1 -1
- package/scripts/enforcement/pulse_core_pi.js +292 -0
- package/scripts/enforcement/pulse_scheduler_pi.js +1083 -0
- package/scripts/enforcement/router_engine_pi.js +483 -0
- package/scripts/enforcement/router_ranking_pi.js +146 -0
- package/scripts/enforcement/router_replacement_pi.js +98 -0
- package/scripts/enforcement/router_runtime_pi.js +299 -0
- package/scripts/enforcement/router_schemas_pi.js +604 -0
- package/scripts/enforcement/router_store_pi.js +438 -0
- package/scripts/enforcement/task_board_core_pi.js +597 -48
- package/skills/pulse/SKILL.md +39 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Router operational state (ROUTER_DESIGN_TASK68 §8): local-first SQLite at
|
|
5
|
+
// ~/.local/share/agentic-driver/router/state.db (configurable). Stores
|
|
6
|
+
// collector snapshots, consumption samples, quantity-bearing reservations,
|
|
7
|
+
// route decisions, and ranking observations.
|
|
8
|
+
//
|
|
9
|
+
// GOVERNANCE BOUNDARY: this store is operational state ONLY. The
|
|
10
|
+
// authenticated claims file and the board writer remain the ONLY dispatch
|
|
11
|
+
// authority; SQLite is repaired from them, never the reverse. SQLite
|
|
12
|
+
// operations never hold the board writer lock, and vice versa — callers
|
|
13
|
+
// invoke this module outside any writer lock.
|
|
14
|
+
|
|
15
|
+
import { DatabaseSync } from "node:sqlite";
|
|
16
|
+
import { mkdirSync, existsSync } from "node:fs";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
|
|
21
|
+
export const RESERVATION_STATES = Object.freeze([
|
|
22
|
+
"reserved", "claimed", "consumed", "released", "expired",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
// Default TTL for unclaimed (reserved) reservations: 15 minutes. Claimed
|
|
26
|
+
// reservations never TTL-expire — lease renewal governs them.
|
|
27
|
+
export const RESERVED_TTL_SECONDS = 900;
|
|
28
|
+
|
|
29
|
+
export function encodeReservationIdentity({ decisionId, seatId, accountId, vectors }) {
|
|
30
|
+
return `res.${Buffer.from(JSON.stringify({ decisionId, seatId, accountId, vectors }), "utf8").toString("base64url")}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function decodeReservationIdentity(id) {
|
|
34
|
+
if (typeof id !== "string" || !id.startsWith("res.")) return null;
|
|
35
|
+
try { return JSON.parse(Buffer.from(id.slice(4), "base64url").toString("utf8")); } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function defaultDbPath() {
|
|
39
|
+
return join(homedir(), ".local", "share", "agentic-driver", "router", "state.db");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SCHEMA_SQL = `
|
|
43
|
+
CREATE TABLE IF NOT EXISTS quota_observations (
|
|
44
|
+
collector TEXT NOT NULL,
|
|
45
|
+
seat_id TEXT NOT NULL,
|
|
46
|
+
generation INTEGER NOT NULL,
|
|
47
|
+
captured_at TEXT NOT NULL,
|
|
48
|
+
expires_at TEXT NOT NULL,
|
|
49
|
+
source_status TEXT NOT NULL,
|
|
50
|
+
record_json TEXT NOT NULL,
|
|
51
|
+
PRIMARY KEY (collector, seat_id, generation)
|
|
52
|
+
);
|
|
53
|
+
CREATE TABLE IF NOT EXISTS consumption_samples (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
seat_id TEXT NOT NULL,
|
|
56
|
+
unit TEXT NOT NULL,
|
|
57
|
+
quantity REAL NOT NULL,
|
|
58
|
+
claim_id TEXT,
|
|
59
|
+
captured_at TEXT NOT NULL
|
|
60
|
+
);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_samples_seat_unit ON consumption_samples (seat_id, unit, captured_at);
|
|
62
|
+
CREATE TABLE IF NOT EXISTS reservations (
|
|
63
|
+
reservation_id TEXT PRIMARY KEY,
|
|
64
|
+
decision_id TEXT NOT NULL,
|
|
65
|
+
seat_id TEXT NOT NULL,
|
|
66
|
+
account_id TEXT NOT NULL,
|
|
67
|
+
vectors_json TEXT NOT NULL,
|
|
68
|
+
state TEXT NOT NULL,
|
|
69
|
+
created_at TEXT NOT NULL,
|
|
70
|
+
claimed_at TEXT,
|
|
71
|
+
lease_expires_at TEXT,
|
|
72
|
+
claim_id TEXT,
|
|
73
|
+
envelope_id TEXT,
|
|
74
|
+
ttl_seconds INTEGER NOT NULL
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_reservations_account ON reservations (account_id);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_reservations_state ON reservations (state);
|
|
78
|
+
CREATE TABLE IF NOT EXISTS route_decisions (
|
|
79
|
+
decision_id TEXT PRIMARY KEY,
|
|
80
|
+
created_at TEXT NOT NULL,
|
|
81
|
+
card_hash TEXT NOT NULL,
|
|
82
|
+
digest TEXT NOT NULL,
|
|
83
|
+
selected_seat_id TEXT NOT NULL,
|
|
84
|
+
parent_decision_id TEXT,
|
|
85
|
+
record_json TEXT NOT NULL
|
|
86
|
+
);
|
|
87
|
+
CREATE TABLE IF NOT EXISTS ranking_observations (
|
|
88
|
+
request_id TEXT NOT NULL,
|
|
89
|
+
created_at TEXT NOT NULL,
|
|
90
|
+
candidate_digest TEXT NOT NULL,
|
|
91
|
+
outcome TEXT NOT NULL,
|
|
92
|
+
detail_json TEXT,
|
|
93
|
+
PRIMARY KEY (request_id, created_at)
|
|
94
|
+
);
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
export function resolveRouterDbPath({ dbPath = null } = {}) {
|
|
98
|
+
return dbPath ?? defaultDbPath();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function routerDbExists({ dbPath = null } = {}) {
|
|
102
|
+
return existsSync(resolveRouterDbPath({ dbPath }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function openRouterStore({ dbPath = null, readOnly = false } = {}) {
|
|
106
|
+
// Runtime requirement (W6): the store depends on the built-in node:sqlite
|
|
107
|
+
// module. Declared in package.json engines; fail with a clear reason when
|
|
108
|
+
// the module is absent rather than a deep TypeError.
|
|
109
|
+
if (typeof DatabaseSync !== "function") {
|
|
110
|
+
throw new Error("router store requires Node.js >= 22.5 with the built-in node:sqlite module");
|
|
111
|
+
}
|
|
112
|
+
const path = resolveRouterDbPath({ dbPath });
|
|
113
|
+
if (readOnly) {
|
|
114
|
+
// Pure observation (W4): never create or migrate a store during check.
|
|
115
|
+
// A missing store is empty operational state, not an error.
|
|
116
|
+
if (!existsSync(path)) return { db: null, path, readOnly: true, closed: false, missing: true };
|
|
117
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
118
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
119
|
+
return { db, path, readOnly: true, closed: false, missing: false };
|
|
120
|
+
}
|
|
121
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
122
|
+
const db = new DatabaseSync(path);
|
|
123
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
124
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
125
|
+
db.exec(SCHEMA_SQL);
|
|
126
|
+
return { db, path, readOnly: false, closed: false, missing: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function closeRouterStore(store) {
|
|
130
|
+
if (store === null || typeof store !== "object") return;
|
|
131
|
+
try { store.db?.close(); } catch { /* already closed */ }
|
|
132
|
+
store.closed = true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Quota observations (§4): keyed by (collector, seatId, generation).
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
export function insertQuotaObservation(store, observation) {
|
|
140
|
+
const max = store.db.prepare("SELECT MAX(generation) AS generation FROM quota_observations WHERE collector = ? AND seat_id = ?")
|
|
141
|
+
.get(observation.collector, observation.seatId)?.generation;
|
|
142
|
+
if (Number.isInteger(max) && observation.generation < max) {
|
|
143
|
+
return { ok: false, stale: true, reason: `generation rollback ${observation.generation} < ${max}` };
|
|
144
|
+
}
|
|
145
|
+
store.db.prepare(`
|
|
146
|
+
INSERT INTO quota_observations (collector, seat_id, generation, captured_at, expires_at, source_status, record_json)
|
|
147
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
148
|
+
ON CONFLICT (collector, seat_id, generation) DO UPDATE SET
|
|
149
|
+
captured_at = excluded.captured_at, expires_at = excluded.expires_at,
|
|
150
|
+
source_status = excluded.source_status, record_json = excluded.record_json
|
|
151
|
+
`).run(
|
|
152
|
+
observation.collector, observation.seatId, observation.generation,
|
|
153
|
+
observation.capturedAt, observation.expiresAt, observation.sourceStatus,
|
|
154
|
+
JSON.stringify(observation),
|
|
155
|
+
);
|
|
156
|
+
return { ok: true, generation: observation.generation };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Latest generation per (collector, seatId). Rollback protection: a lower
|
|
160
|
+
// generation than the highest stored one is treated as stale by the reader.
|
|
161
|
+
export function latestQuotaObservations(store, { now = null } = {}) {
|
|
162
|
+
const rows = store.db.prepare(`
|
|
163
|
+
SELECT record_json FROM quota_observations q
|
|
164
|
+
WHERE generation = (
|
|
165
|
+
SELECT MAX(generation) FROM quota_observations q2
|
|
166
|
+
WHERE q2.collector = q.collector AND q2.seat_id = q.seat_id
|
|
167
|
+
)
|
|
168
|
+
`).all();
|
|
169
|
+
return rows
|
|
170
|
+
.map((row) => { try { return JSON.parse(row.record_json); } catch { return null; } })
|
|
171
|
+
.filter((obs) => obs !== null);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Consumption samples (§6.2 estimator feed): ingested from receipts at
|
|
176
|
+
// dispatch completion.
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
export function insertConsumptionSample(store, { seatId, unit, quantity, claimId = null, capturedAt }) {
|
|
180
|
+
store.db.prepare(`
|
|
181
|
+
INSERT INTO consumption_samples (seat_id, unit, quantity, claim_id, captured_at)
|
|
182
|
+
VALUES (?, ?, ?, ?, ?)
|
|
183
|
+
`).run(seatId, unit, quantity, claimId, capturedAt);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function consumptionSamples(store, { seatId = null, unit = null, limit = null } = {}) {
|
|
187
|
+
const clauses = [];
|
|
188
|
+
const params = [];
|
|
189
|
+
if (seatId !== null) { clauses.push("seat_id = ?"); params.push(seatId); }
|
|
190
|
+
if (unit !== null) { clauses.push("unit = ?"); params.push(unit); }
|
|
191
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
192
|
+
const sql = `SELECT seat_id AS seatId, unit, quantity, claim_id AS claimId, captured_at AS capturedAt
|
|
193
|
+
FROM consumption_samples ${where} ORDER BY captured_at ASC, id ASC${limit !== null ? ` LIMIT ${Number(limit)}` : ""}`;
|
|
194
|
+
return store.db.prepare(sql).all(...params);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Reservations (§6.2/§8): quantity-bearing, lifecycle
|
|
199
|
+
// reserved → claimed → consumed | released | expired.
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
export function createReservation(store, { reservationId = null, decisionId, seatId, accountId, vectors, now, ttlSeconds = RESERVED_TTL_SECONDS }) {
|
|
203
|
+
if (!Array.isArray(vectors) || vectors.length === 0) throw new Error("reservation vectors are required");
|
|
204
|
+
for (const vector of vectors) {
|
|
205
|
+
if (typeof vector.windowId !== "string" || vector.windowId === "" || typeof vector.unit !== "string" || !Number.isFinite(vector.quantity) || vector.quantity < 0) {
|
|
206
|
+
throw new Error("each reservation vector requires windowId, unit, and non-negative quantity");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const id = reservationId ?? randomUUID();
|
|
210
|
+
store.db.prepare(`
|
|
211
|
+
INSERT INTO reservations (reservation_id, decision_id, seat_id, account_id, vectors_json, state, created_at, ttl_seconds)
|
|
212
|
+
VALUES (?, ?, ?, ?, ?, 'reserved', ?, ?)
|
|
213
|
+
`).run(id, decisionId, seatId, accountId, JSON.stringify(vectors), now, ttlSeconds);
|
|
214
|
+
return id;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function getReservation(store, reservationId) {
|
|
218
|
+
const row = store.db.prepare("SELECT * FROM reservations WHERE reservation_id = ?").get(reservationId);
|
|
219
|
+
if (row === undefined) return null;
|
|
220
|
+
return {
|
|
221
|
+
reservationId: row.reservation_id,
|
|
222
|
+
decisionId: row.decision_id,
|
|
223
|
+
seatId: row.seat_id,
|
|
224
|
+
accountId: row.account_id,
|
|
225
|
+
vectors: JSON.parse(row.vectors_json),
|
|
226
|
+
state: row.state,
|
|
227
|
+
createdAt: row.created_at,
|
|
228
|
+
claimedAt: row.claimed_at,
|
|
229
|
+
leaseExpiresAt: row.lease_expires_at,
|
|
230
|
+
claimId: row.claim_id,
|
|
231
|
+
envelopeId: row.envelope_id,
|
|
232
|
+
ttlSeconds: row.ttl_seconds,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function reservationsForDecision(store, decisionId) {
|
|
237
|
+
return store.db.prepare(
|
|
238
|
+
"SELECT reservation_id AS reservationId, state, vectors_json AS vectorsJson, account_id AS accountId, seat_id AS seatId FROM reservations WHERE decision_id = ?",
|
|
239
|
+
).all(decisionId).map((row) => ({ ...row, vectors: JSON.parse(row.vectorsJson) }));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function activeReservations(store, { now }) {
|
|
243
|
+
return store.db.prepare(`
|
|
244
|
+
SELECT reservation_id AS reservationId, decision_id AS decisionId, seat_id AS seatId,
|
|
245
|
+
account_id AS accountId, vectors_json AS vectorsJson, state,
|
|
246
|
+
created_at AS createdAt, claimed_at AS claimedAt, lease_expires_at AS leaseExpiresAt,
|
|
247
|
+
claim_id AS claimId, envelope_id AS envelopeId, ttl_seconds AS ttlSeconds
|
|
248
|
+
FROM reservations WHERE state IN ('reserved', 'claimed')
|
|
249
|
+
`).all().flatMap((row) => JSON.parse(row.vectorsJson).map((vector) => ({ ...row, ...vector })));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Transition helpers. All idempotent where the design requires it.
|
|
253
|
+
|
|
254
|
+
// reserved → claimed, with lease. Non-reserved state is a no-op returning
|
|
255
|
+
// {ok: false, state} so a crash-window reconciliation can decide.
|
|
256
|
+
export function claimReservation(store, { reservationId, claimId, envelopeId, now, leaseSeconds = 3600 }) {
|
|
257
|
+
const current = getReservation(store, reservationId);
|
|
258
|
+
if (current === null) return { ok: false, state: null, reason: "reservation does not exist" };
|
|
259
|
+
if (current.state === "claimed") {
|
|
260
|
+
if (current.claimId !== claimId || current.envelopeId !== envelopeId) {
|
|
261
|
+
return { ok: false, state: "claimed", reason: "reservation is bound to a different claim/envelope" };
|
|
262
|
+
}
|
|
263
|
+
store.db.prepare("UPDATE reservations SET lease_expires_at = ? WHERE reservation_id = ?")
|
|
264
|
+
.run(leaseExpiry(now, leaseSeconds), reservationId);
|
|
265
|
+
return { ok: true, state: "claimed", renewed: true };
|
|
266
|
+
}
|
|
267
|
+
if (current.state !== "reserved") {
|
|
268
|
+
return { ok: false, state: current.state, reason: `reservation is ${current.state}, not reserved` };
|
|
269
|
+
}
|
|
270
|
+
store.db.prepare(`
|
|
271
|
+
UPDATE reservations SET state = 'claimed', claimed_at = ?, lease_expires_at = ?, claim_id = ?, envelope_id = ?
|
|
272
|
+
WHERE reservation_id = ? AND state = 'reserved'
|
|
273
|
+
`).run(now, leaseExpiry(now, leaseSeconds), claimId ?? null, envelopeId ?? null, reservationId);
|
|
274
|
+
return { ok: true, state: "claimed", renewed: false };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Lease renewal on claimed reservations (§8): the dispatch loop heartbeat.
|
|
278
|
+
// TTL expiry never applies to claimed reservations — renewal only extends.
|
|
279
|
+
export function renewReservationLease(store, { reservationId, now, leaseSeconds = 3600 }) {
|
|
280
|
+
const current = getReservation(store, reservationId);
|
|
281
|
+
if (current === null || current.state !== "claimed") {
|
|
282
|
+
return { ok: false, reason: "reservation is not claimed" };
|
|
283
|
+
}
|
|
284
|
+
store.db.prepare("UPDATE reservations SET lease_expires_at = ? WHERE reservation_id = ? AND state = 'claimed'")
|
|
285
|
+
.run(leaseExpiry(now, leaseSeconds), reservationId);
|
|
286
|
+
return { ok: true };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function leaseExpiry(now, leaseSeconds) {
|
|
290
|
+
return new Date(Date.parse(now) + leaseSeconds * 1000).toISOString();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// claimed → consumed. Idempotent: consuming an already-consumed reservation
|
|
294
|
+
// succeeds without effect; duplicates are prevented by the claim, not the
|
|
295
|
+
// reservation (§8).
|
|
296
|
+
export function consumeReservation(store, { reservationId, now }) {
|
|
297
|
+
const current = getReservation(store, reservationId);
|
|
298
|
+
if (current === null) return { ok: false, reason: "reservation does not exist" };
|
|
299
|
+
if (current.state === "consumed") return { ok: true, state: "consumed", idempotent: true };
|
|
300
|
+
if (current.state !== "claimed") {
|
|
301
|
+
return { ok: false, reason: `reservation is ${current.state}, not claimed` };
|
|
302
|
+
}
|
|
303
|
+
store.db.prepare("UPDATE reservations SET state = 'consumed' WHERE reservation_id = ? AND state = 'claimed'")
|
|
304
|
+
.run(reservationId);
|
|
305
|
+
return { ok: true, state: "consumed", idempotent: false };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// reserved|claimed → released. Idempotent.
|
|
309
|
+
export function releaseReservation(store, { reservationId, now }) {
|
|
310
|
+
const current = getReservation(store, reservationId);
|
|
311
|
+
if (current === null) return { ok: false, reason: "reservation does not exist" };
|
|
312
|
+
if (current.state === "released") return { ok: true, state: "released", idempotent: true };
|
|
313
|
+
if (current.state !== "reserved" && current.state !== "claimed") {
|
|
314
|
+
return { ok: false, reason: `reservation is ${current.state}; cannot release` };
|
|
315
|
+
}
|
|
316
|
+
store.db.prepare("UPDATE reservations SET state = 'released' WHERE reservation_id = ? AND state IN ('reserved', 'claimed')")
|
|
317
|
+
.run(reservationId);
|
|
318
|
+
return { ok: true, state: "released", idempotent: false };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// TTL expiry applies ONLY to reserved (unclaimed) reservations (§8). Claimed
|
|
322
|
+
// reservations are governed by lease renewal, never TTL.
|
|
323
|
+
export function expireStaleReservations(store, { now }) {
|
|
324
|
+
const result = store.db.prepare(`
|
|
325
|
+
UPDATE reservations SET state = 'expired'
|
|
326
|
+
WHERE state = 'reserved'
|
|
327
|
+
AND (CAST(strftime('%s', created_at) AS INTEGER) + ttl_seconds) * 1000 <= ?
|
|
328
|
+
`).run(Date.parse(now));
|
|
329
|
+
return { expired: result.changes };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Crash-window reconciliation (§8). The claims/envelope is AUTHORITY; SQLite
|
|
334
|
+
// is repaired from it. The caller supplies the authenticated claim (with its
|
|
335
|
+
// envelope) read from the claims file — this module never reads the board.
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
// Window 2+3: a claim exists in the authenticated claims file carrying a
|
|
339
|
+
// reservationId inside its envelope, but the reservation row is missing,
|
|
340
|
+
// still 'reserved', or expired. Re-materialize/repair from the envelope.
|
|
341
|
+
export function reconcileReservationFromClaim(store, { claim, now, leaseSeconds = 3600 }) {
|
|
342
|
+
const envelope = claim?.envelope;
|
|
343
|
+
const reservationId = typeof envelope?.reservationId === "string" ? envelope.reservationId : null;
|
|
344
|
+
if (reservationId === null) return { ok: true, repaired: false, reason: null };
|
|
345
|
+
const current = getReservation(store, reservationId);
|
|
346
|
+
if (current !== null && current.state === "claimed"
|
|
347
|
+
&& current.claimId === claim.envelopeId) {
|
|
348
|
+
return { ok: true, repaired: false, reason: null }; // already consistent
|
|
349
|
+
}
|
|
350
|
+
if (current === null) {
|
|
351
|
+
const carried = decodeReservationIdentity(reservationId);
|
|
352
|
+
if (!carried || carried.seatId !== envelope.seatId || carried.accountId !== envelope.accountId
|
|
353
|
+
|| !Array.isArray(carried.vectors) || carried.vectors.length === 0) {
|
|
354
|
+
return { ok: false, repaired: false, reason: "reservation identity lacks authenticated reconstruction data (fails closed)" };
|
|
355
|
+
}
|
|
356
|
+
store.db.prepare(`
|
|
357
|
+
INSERT INTO reservations (reservation_id, decision_id, seat_id, account_id, vectors_json, state, created_at, claimed_at, lease_expires_at, claim_id, envelope_id, ttl_seconds)
|
|
358
|
+
VALUES (?, ?, ?, ?, ?, 'claimed', ?, ?, ?, ?, ?, ?)
|
|
359
|
+
`).run(reservationId, carried.decisionId, carried.seatId, carried.accountId,
|
|
360
|
+
JSON.stringify(carried.vectors), now, now, leaseExpiry(now, leaseSeconds),
|
|
361
|
+
claim.envelopeId, envelope.envelopeId, 0);
|
|
362
|
+
return { ok: true, repaired: true, reason: "re-materialized from authenticated reservation identity" };
|
|
363
|
+
}
|
|
364
|
+
// Window 3: reservation expired (or still reserved) while the claim is
|
|
365
|
+
// active — the claim is authoritative; re-materialize as claimed.
|
|
366
|
+
store.db.prepare(`
|
|
367
|
+
UPDATE reservations SET state = 'claimed', claimed_at = ?, lease_expires_at = ?, claim_id = ?, envelope_id = ?
|
|
368
|
+
WHERE reservation_id = ?
|
|
369
|
+
`).run(now, leaseExpiry(now, leaseSeconds), claim.envelopeId, envelope.envelopeId, reservationId);
|
|
370
|
+
return { ok: true, repaired: true, reason: "re-materialized from authenticated claim (crash window 3)" };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Window 4: reclaim while spawn ambiguous — the existing reconciliation
|
|
374
|
+
// resolves spawn state first; the reservation follows the claim. When the
|
|
375
|
+
// claim was consumed with a terminal reason, the reservation follows.
|
|
376
|
+
export function followClaimConsumption(store, { envelopeId, reason, now }) {
|
|
377
|
+
const rows = store.db.prepare("SELECT reservation_id, state FROM reservations WHERE envelope_id = ? AND state IN ('reserved', 'claimed')").all(envelopeId);
|
|
378
|
+
const followed = [];
|
|
379
|
+
for (const row of rows) {
|
|
380
|
+
const target = reason === "completed" ? "consumed" : "released";
|
|
381
|
+
store.db.prepare("UPDATE reservations SET state = ? WHERE reservation_id = ?").run(target, row.reservation_id);
|
|
382
|
+
followed.push({ reservationId: row.reservation_id, state: target });
|
|
383
|
+
}
|
|
384
|
+
return { followed };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Route decisions and ranking observations (audit).
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
export function insertRouteDecision(store, { record, digest }) {
|
|
392
|
+
store.db.prepare(`
|
|
393
|
+
INSERT INTO route_decisions (decision_id, created_at, card_hash, digest, selected_seat_id, parent_decision_id, record_json)
|
|
394
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
395
|
+
ON CONFLICT (decision_id) DO UPDATE SET digest = excluded.digest, record_json = excluded.record_json
|
|
396
|
+
`).run(record.decisionId, record.createdAt, record.cardHash, digest, record.selectedSeatId, record.parentDecisionId, JSON.stringify(record));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function getRouteDecision(store, decisionId) {
|
|
400
|
+
const row = store.db.prepare("SELECT record_json FROM route_decisions WHERE decision_id = ?").get(decisionId);
|
|
401
|
+
if (row === undefined) return null;
|
|
402
|
+
try { return JSON.parse(row.record_json); } catch { return null; }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// A route-decision digest is the SHA-256 of the immutable record, which
|
|
406
|
+
// includes its own decisionId, so a digest maps to exactly one decision.
|
|
407
|
+
// The writer uses this to require that a replacement's parentDecisionId is
|
|
408
|
+
// the decision authenticated for the prior envelope, retrieved from the
|
|
409
|
+
// store (never trusting a caller-supplied parent id).
|
|
410
|
+
export function getRouteDecisionIdByDigest(store, digest) {
|
|
411
|
+
if (store === null || store.db === null || typeof digest !== "string") return null;
|
|
412
|
+
const row = store.db.prepare("SELECT decision_id AS decisionId FROM route_decisions WHERE digest = ?").get(digest);
|
|
413
|
+
return row?.decisionId ?? null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function insertRankingObservation(store, { requestId, createdAt, candidateDigest, outcome, detail = null }) {
|
|
417
|
+
store.db.prepare(`
|
|
418
|
+
INSERT INTO ranking_observations (request_id, created_at, candidate_digest, outcome, detail_json)
|
|
419
|
+
VALUES (?, ?, ?, ?, ?)
|
|
420
|
+
ON CONFLICT (request_id, created_at) DO UPDATE SET outcome = excluded.outcome, detail_json = excluded.detail_json
|
|
421
|
+
`).run(requestId, createdAt, candidateDigest, outcome, detail === null ? null : JSON.stringify(detail));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Corruption handling (§10): corruption/unavailable fails closed for NEW
|
|
426
|
+
// reservations only; existing claims are unaffected (the claims file is
|
|
427
|
+
// authority). openRouterStore throws on an unreadable/corrupt file; callers
|
|
428
|
+
// catch and apply the failure policy.
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
export function storeHealthy(store) {
|
|
432
|
+
try {
|
|
433
|
+
store.db.prepare("SELECT COUNT(*) AS n FROM reservations").get();
|
|
434
|
+
return true;
|
|
435
|
+
} catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|