@elpapi42/pi-fleet 0.1.0-beta.10 → 0.1.0-beta.13
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/CHANGELOG.md +28 -1
- package/README.md +99 -167
- package/dist/cli-meta.json +313 -315
- package/dist/cli.mjs +2504 -1812
- package/dist/cli.mjs.map +4 -4
- package/dist/client/agent-target.d.ts +33 -0
- package/dist/client/contracts.d.ts +81 -0
- package/dist/client/fleet-client.d.ts +118 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/sdk-connector.d.ts +7 -0
- package/dist/client/sdk-facade.d.ts +84 -0
- package/dist/client/sdk-transport.d.ts +24 -0
- package/dist/client/shared-client.d.ts +18 -0
- package/dist/client/socket-fleet-client.d.ts +21 -0
- package/dist/client-meta.json +5736 -0
- package/dist/client.mjs +4434 -0
- package/dist/client.mjs.map +7 -0
- package/dist/installer-meta.json +7 -7
- package/dist/installer.mjs +47 -13
- package/dist/installer.mjs.map +2 -2
- package/dist/journal-sqlite-worker-meta.json +149 -0
- package/dist/journal-sqlite-worker.mjs +1110 -0
- package/dist/journal-sqlite-worker.mjs.map +7 -0
- package/dist/pi/external-installation.d.ts +23 -0
- package/dist/platform/client/start-runtime.d.ts +25 -0
- package/dist/platform/install/runtime-release.d.ts +8 -0
- package/dist/platform/install/tree-integrity.d.ts +7 -0
- package/dist/platform/shared/paths.d.ts +8 -0
- package/dist/platform/shared/runtime-ownership.d.ts +14 -0
- package/dist/protocol/jsonl.d.ts +3 -0
- package/dist/protocol/pi-identity.d.ts +16 -0
- package/dist/protocol/semantic-segmentation.d.ts +22 -0
- package/dist/protocol/version.d.ts +2 -0
- package/dist/runtime/semantic-events.d.ts +43 -0
- package/dist/runtime-manifest.json +156 -31
- package/dist/runtime-meta.json +378 -53
- package/dist/runtime.mjs +3161 -581
- package/dist/runtime.mjs.map +4 -4
- package/dist/shared/result.d.ts +9 -0
- package/package.json +14 -1
- package/dist/sqlite-worker-meta.json +0 -84
- package/dist/sqlite-worker.mjs +0 -359
- package/dist/sqlite-worker.mjs.map +0 -7
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
// src/store/journal-sqlite-worker.ts
|
|
2
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
3
|
+
|
|
4
|
+
// src/store/journal-worker-dispatch.ts
|
|
5
|
+
async function dispatchJournalStoreRequest(store2, request) {
|
|
6
|
+
switch (request.method) {
|
|
7
|
+
case "createAgent":
|
|
8
|
+
return store2.createAgent(request.args[0]);
|
|
9
|
+
case "createAgentWithOperation":
|
|
10
|
+
return store2.createAgentWithOperation(
|
|
11
|
+
request.args[0],
|
|
12
|
+
request.args[1]
|
|
13
|
+
);
|
|
14
|
+
case "rollbackProvisionalCreate":
|
|
15
|
+
return store2.rollbackProvisionalCreate(
|
|
16
|
+
request.args[0],
|
|
17
|
+
request.args[1]
|
|
18
|
+
);
|
|
19
|
+
case "getAgentByName":
|
|
20
|
+
return store2.getAgentByName(request.args[0]);
|
|
21
|
+
case "getAgentById":
|
|
22
|
+
return store2.getAgentById(request.args[0]);
|
|
23
|
+
case "listAgents":
|
|
24
|
+
return store2.listAgents();
|
|
25
|
+
case "putAgent":
|
|
26
|
+
return store2.putAgent(request.args[0]);
|
|
27
|
+
case "putOperation":
|
|
28
|
+
return store2.putOperation(request.args[0]);
|
|
29
|
+
case "getOperation":
|
|
30
|
+
return store2.getOperation(request.args[0]);
|
|
31
|
+
case "listPendingOperations":
|
|
32
|
+
return store2.listPendingOperations();
|
|
33
|
+
case "deleteOperation":
|
|
34
|
+
return store2.deleteOperation(request.args[0]);
|
|
35
|
+
case "putSend":
|
|
36
|
+
return store2.putSend(request.args[0]);
|
|
37
|
+
case "getSend":
|
|
38
|
+
return store2.getSend(request.args[0]);
|
|
39
|
+
case "nextSendOrdinal":
|
|
40
|
+
return store2.nextSendOrdinal(request.args[0]);
|
|
41
|
+
case "listNonterminalSends":
|
|
42
|
+
return store2.listNonterminalSends();
|
|
43
|
+
case "putCompact":
|
|
44
|
+
return store2.putCompact(request.args[0]);
|
|
45
|
+
case "getCompact":
|
|
46
|
+
return store2.getCompact(request.args[0]);
|
|
47
|
+
case "listNonterminalCompacts":
|
|
48
|
+
return store2.listNonterminalCompacts();
|
|
49
|
+
case "putIncarnation":
|
|
50
|
+
return store2.putIncarnation(request.args[0]);
|
|
51
|
+
case "listActiveIncarnations":
|
|
52
|
+
return store2.listActiveIncarnations();
|
|
53
|
+
case "putEpoch":
|
|
54
|
+
return store2.putEpoch(request.args[0]);
|
|
55
|
+
case "getEpochs":
|
|
56
|
+
return store2.getEpochs(request.args[0]);
|
|
57
|
+
case "beginIncarnation":
|
|
58
|
+
return store2.beginIncarnation(
|
|
59
|
+
request.args[0],
|
|
60
|
+
request.args[1],
|
|
61
|
+
request.args[2],
|
|
62
|
+
request.args[3]
|
|
63
|
+
);
|
|
64
|
+
case "append":
|
|
65
|
+
return store2.append(normalizeAppend(request.args[0]));
|
|
66
|
+
case "openReceive":
|
|
67
|
+
return store2.openReceive(request.args[0]);
|
|
68
|
+
case "readEvents":
|
|
69
|
+
return store2.readEvents(request.args[0]);
|
|
70
|
+
case "getProjectorState":
|
|
71
|
+
return store2.getProjectorState(
|
|
72
|
+
request.args[0],
|
|
73
|
+
request.args[1],
|
|
74
|
+
request.args[2]
|
|
75
|
+
);
|
|
76
|
+
case "getHighWater":
|
|
77
|
+
return store2.getHighWater(request.args[0]);
|
|
78
|
+
case "markIdle":
|
|
79
|
+
return store2.markIdle(request.args[0], request.args[1]);
|
|
80
|
+
case "getRawRecords":
|
|
81
|
+
return store2.getRawRecords(
|
|
82
|
+
request.args[0],
|
|
83
|
+
request.args[1],
|
|
84
|
+
request.args[2]
|
|
85
|
+
);
|
|
86
|
+
case "destroyAgent":
|
|
87
|
+
return store2.destroyAgent(
|
|
88
|
+
request.args[0],
|
|
89
|
+
request.args[1]
|
|
90
|
+
);
|
|
91
|
+
case "getDestroyReceipt":
|
|
92
|
+
return store2.getDestroyReceipt(request.args[0]);
|
|
93
|
+
case "maintain":
|
|
94
|
+
return store2.maintain(request.args[0]);
|
|
95
|
+
case "getDiagnostics":
|
|
96
|
+
return store2.getDiagnostics();
|
|
97
|
+
case "close":
|
|
98
|
+
return store2.close();
|
|
99
|
+
default:
|
|
100
|
+
throw new Error(`Unknown journal worker method ${request.method}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function normalizeAppend(value) {
|
|
104
|
+
const batch = value;
|
|
105
|
+
return {
|
|
106
|
+
...batch,
|
|
107
|
+
records: batch.records.map((record) => ({ ...record, bytes: Buffer.from(record.bytes) }))
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/store/sqlite-journal-store.ts
|
|
112
|
+
import { mkdirSync } from "node:fs";
|
|
113
|
+
import { dirname as dirname2 } from "node:path";
|
|
114
|
+
import { DatabaseSync } from "node:sqlite";
|
|
115
|
+
|
|
116
|
+
// src/platform/shared/state-security.ts
|
|
117
|
+
import { chmodSync, constants, lstatSync } from "node:fs";
|
|
118
|
+
import { dirname, join, parse, resolve, sep } from "node:path";
|
|
119
|
+
var PRIVATE_DIRECTORY_MODE = 448;
|
|
120
|
+
var PRIVATE_FILE_MODE = 384;
|
|
121
|
+
var GROUP_OR_OTHER_WRITE = 18;
|
|
122
|
+
var STICKY_BIT = 512;
|
|
123
|
+
var SAFE_ROOT_STICKY_DIRECTORIES = /* @__PURE__ */ new Set(["/tmp", "/var/tmp"]);
|
|
124
|
+
function hardenPrivateDirectorySync(path) {
|
|
125
|
+
assertTrustedDirectoryComponentsSync(resolve(path));
|
|
126
|
+
const stats = lstatSync(path);
|
|
127
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
128
|
+
throw new Error(`Refusing unsafe pi-fleet directory ${path}`);
|
|
129
|
+
}
|
|
130
|
+
assertCurrentUser(path, stats.uid);
|
|
131
|
+
chmodSync(path, PRIVATE_DIRECTORY_MODE);
|
|
132
|
+
}
|
|
133
|
+
function hardenSqliteSidecarsSync(path) {
|
|
134
|
+
for (const candidate of [path, `${path}-wal`, `${path}-shm`]) {
|
|
135
|
+
let stats;
|
|
136
|
+
try {
|
|
137
|
+
stats = lstatSync(candidate);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error.code === "ENOENT") continue;
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
143
|
+
throw new Error(`Refusing unsafe pi-fleet state file ${candidate}`);
|
|
144
|
+
}
|
|
145
|
+
assertCurrentUser(candidate, stats.uid);
|
|
146
|
+
chmodSync(candidate, PRIVATE_FILE_MODE);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function assertTrustedDirectoryComponentsSync(absolutePath) {
|
|
150
|
+
const root = parse(absolutePath).root;
|
|
151
|
+
let current = root;
|
|
152
|
+
const rootStats = lstatSync(root);
|
|
153
|
+
assertTrustedDirectoryAncestor(root, rootStats.uid, rootStats.mode);
|
|
154
|
+
for (const component of absolutePath.slice(root.length).split(sep).filter(Boolean)) {
|
|
155
|
+
current = join(current, component);
|
|
156
|
+
const stats = lstatSync(current);
|
|
157
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
158
|
+
throw new Error(`Refusing unsafe pi-fleet directory ${current}`);
|
|
159
|
+
}
|
|
160
|
+
assertTrustedDirectoryAncestor(current, stats.uid, stats.mode);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function assertTrustedDirectoryAncestor(path, ownerUid, mode, currentUid = process.getuid?.()) {
|
|
164
|
+
if (currentUid === void 0 || ownerUid === currentUid) return;
|
|
165
|
+
if (ownerUid === 0) {
|
|
166
|
+
const writable = (mode & GROUP_OR_OTHER_WRITE) !== 0;
|
|
167
|
+
if (!writable) return;
|
|
168
|
+
if ((mode & STICKY_BIT) !== 0 && SAFE_ROOT_STICKY_DIRECTORIES.has(path)) return;
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`Refusing untrusted pi-fleet directory ancestor: ${path}`);
|
|
171
|
+
}
|
|
172
|
+
function assertCurrentUser(path, ownerUid) {
|
|
173
|
+
const uid = process.getuid?.();
|
|
174
|
+
if (uid !== void 0 && ownerUid !== uid) {
|
|
175
|
+
throw new Error(`Refusing pi-fleet path not owned by the current user: ${path}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/store/journal-store.ts
|
|
180
|
+
function boundedSemanticEvents(events, range) {
|
|
181
|
+
if (!Number.isSafeInteger(range.limit) || range.limit <= 0) {
|
|
182
|
+
throw new Error("Journal event range limit must be positive");
|
|
183
|
+
}
|
|
184
|
+
if (!Number.isSafeInteger(range.maxBytes) || range.maxBytes <= 0) {
|
|
185
|
+
throw new Error("Journal event range byte limit must be positive");
|
|
186
|
+
}
|
|
187
|
+
if (!Number.isSafeInteger(range.maxEventBytes) || range.maxEventBytes <= 0) {
|
|
188
|
+
throw new Error("Journal semantic event limit must be positive");
|
|
189
|
+
}
|
|
190
|
+
const selected = [];
|
|
191
|
+
let selectedBytes = 0;
|
|
192
|
+
for (const stored of events) {
|
|
193
|
+
if (stored.agentId !== range.agentId || stored.event.epoch !== range.epoch) continue;
|
|
194
|
+
if (stored.position <= range.afterPosition) continue;
|
|
195
|
+
const bytes = Buffer.byteLength(JSON.stringify(stored));
|
|
196
|
+
if (bytes > range.maxEventBytes) throw new Error("Semantic event exceeds storage read limit");
|
|
197
|
+
if (selected.length > 0 && selectedBytes + bytes > range.maxBytes) break;
|
|
198
|
+
selected.push(stored);
|
|
199
|
+
selectedBytes += bytes;
|
|
200
|
+
if (selected.length >= range.limit || selectedBytes >= range.maxBytes) break;
|
|
201
|
+
}
|
|
202
|
+
return selected;
|
|
203
|
+
}
|
|
204
|
+
function sameJournalDestroyReceipt(left, right) {
|
|
205
|
+
return left.operationId === right.operationId && left.agentId === right.agentId && left.agentName === right.agentName && left.fingerprint === right.fingerprint && left.destroyedAt === right.destroyedAt && left.status === right.status;
|
|
206
|
+
}
|
|
207
|
+
function assertJournalAgentIdentity(agent) {
|
|
208
|
+
if (agent.summary.id !== agent.agentId || agent.summary.name !== agent.name) {
|
|
209
|
+
throw new Error("Journal agent identity does not match its summary");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function assertJournalAppend(batch, current, epoch) {
|
|
213
|
+
if (epoch === null) throw new Error("Journal append references an unknown continuity epoch");
|
|
214
|
+
if (epoch.state !== "open")
|
|
215
|
+
throw new Error("Journal append references a closed continuity epoch");
|
|
216
|
+
let rawPosition = current.rawPosition;
|
|
217
|
+
const appendedRawPositions = /* @__PURE__ */ new Set();
|
|
218
|
+
for (const record of batch.records) {
|
|
219
|
+
if (record.agentId !== batch.agentId) throw new Error("Raw record targets another agent");
|
|
220
|
+
if (record.incarnationId !== batch.incarnationId) {
|
|
221
|
+
throw new Error("Raw record targets another incarnation");
|
|
222
|
+
}
|
|
223
|
+
if (record.position !== ++rawPosition) throw new Error("Raw record position is not contiguous");
|
|
224
|
+
appendedRawPositions.add(record.position);
|
|
225
|
+
}
|
|
226
|
+
let eventPosition = current.eventPosition;
|
|
227
|
+
for (const stored of batch.events) {
|
|
228
|
+
if (stored.agentId !== batch.agentId || stored.event.agentId !== batch.agentId) {
|
|
229
|
+
throw new Error("Semantic event targets another agent");
|
|
230
|
+
}
|
|
231
|
+
if (stored.event.epoch !== batch.epoch) throw new Error("Semantic event epoch mismatch");
|
|
232
|
+
if (stored.position !== ++eventPosition) {
|
|
233
|
+
throw new Error("Semantic event position is not contiguous");
|
|
234
|
+
}
|
|
235
|
+
if (!appendedRawPositions.has(stored.event.sourceRawPosition)) {
|
|
236
|
+
throw new Error("Semantic event must reference a raw record from the same append and epoch");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (batch.highWater.rawPosition !== rawPosition || batch.highWater.eventPosition !== eventPosition) {
|
|
240
|
+
throw new Error("Journal high-water position does not match the append");
|
|
241
|
+
}
|
|
242
|
+
if (batch.highWater.idleEventPosition !== null && batch.highWater.idleEventPosition > eventPosition) {
|
|
243
|
+
throw new Error("Idle high-water position exceeds committed events");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/store/sqlite-journal-store.ts
|
|
248
|
+
var JOURNAL_SCHEMA_VERSION = 3;
|
|
249
|
+
var JOURNAL_SCHEMA_CHECKSUM = "003_uuid_journal_v3";
|
|
250
|
+
var LEGACY_JOURNAL_SCHEMA_CHECKSUMS = /* @__PURE__ */ new Map([
|
|
251
|
+
[1, "001_initial_v1"],
|
|
252
|
+
[2, "002_compact_v1"]
|
|
253
|
+
]);
|
|
254
|
+
function classifyJournalMigrationRows(rows) {
|
|
255
|
+
if (rows.length === 0) return "fresh";
|
|
256
|
+
for (const row of rows) {
|
|
257
|
+
if (!Number.isSafeInteger(row.version) || typeof row.checksum !== "string") {
|
|
258
|
+
throw new Error("pi-fleet database migration ledger is malformed");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (rows.length === 1 && rows[0]?.version === JOURNAL_SCHEMA_VERSION) {
|
|
262
|
+
if (rows[0].checksum !== JOURNAL_SCHEMA_CHECKSUM) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`pi-fleet database migration ${String(JOURNAL_SCHEMA_VERSION)} checksum mismatch`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return "current";
|
|
268
|
+
}
|
|
269
|
+
const legacyOne = rows.length === 1 && rows[0]?.version === 1;
|
|
270
|
+
const legacyTwo = rows.length === 2 && rows[0]?.version === 1 && rows[1]?.version === 2;
|
|
271
|
+
if (legacyOne || legacyTwo) {
|
|
272
|
+
for (const row of rows) {
|
|
273
|
+
if (LEGACY_JOURNAL_SCHEMA_CHECKSUMS.get(row.version) !== row.checksum) {
|
|
274
|
+
throw new Error(`pi-fleet database migration ${String(row.version)} checksum mismatch`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return "legacy";
|
|
278
|
+
}
|
|
279
|
+
const newer = rows.find((row) => row.version > JOURNAL_SCHEMA_VERSION);
|
|
280
|
+
if (newer !== void 0) {
|
|
281
|
+
throw new Error(`pi-fleet database schema ${String(newer.version)} is newer than this runtime`);
|
|
282
|
+
}
|
|
283
|
+
throw new Error("pi-fleet database migration ledger sequence is invalid");
|
|
284
|
+
}
|
|
285
|
+
function* parseSemanticEventRows(rows) {
|
|
286
|
+
for (const row of rows) yield JSON.parse(row.data_json);
|
|
287
|
+
}
|
|
288
|
+
var idleMaintenance = () => ({
|
|
289
|
+
state: "idle",
|
|
290
|
+
lastCheckpointAt: null,
|
|
291
|
+
busy: false,
|
|
292
|
+
logFrames: 0,
|
|
293
|
+
checkpointedFrames: 0,
|
|
294
|
+
autoVacuumMode: "none",
|
|
295
|
+
freelistPagesBefore: 0,
|
|
296
|
+
freelistPagesAfter: 0,
|
|
297
|
+
requestedReclaimPages: 0
|
|
298
|
+
});
|
|
299
|
+
var SqliteJournalStore = class {
|
|
300
|
+
#database;
|
|
301
|
+
#checkpointCommitInterval;
|
|
302
|
+
#reclaimPagesPerPass;
|
|
303
|
+
#now;
|
|
304
|
+
#commitsSinceCheckpoint = 0;
|
|
305
|
+
#lastAppendDurationMs = null;
|
|
306
|
+
#storageFailureLatched = false;
|
|
307
|
+
#maintenance = idleMaintenance();
|
|
308
|
+
#closed = false;
|
|
309
|
+
constructor(path, options2 = {}) {
|
|
310
|
+
const directory = dirname2(path);
|
|
311
|
+
mkdirSync(directory, { recursive: true, mode: 448 });
|
|
312
|
+
hardenPrivateDirectorySync(directory);
|
|
313
|
+
hardenSqliteSidecarsSync(path);
|
|
314
|
+
this.#checkpointCommitInterval = positiveInteger(
|
|
315
|
+
options2.checkpointCommitInterval ?? 128,
|
|
316
|
+
"Journal checkpoint commit interval"
|
|
317
|
+
);
|
|
318
|
+
this.#reclaimPagesPerPass = positiveInteger(
|
|
319
|
+
options2.reclaimPagesPerPass ?? 32,
|
|
320
|
+
"Journal reclaim pages per pass"
|
|
321
|
+
);
|
|
322
|
+
this.#now = options2.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
323
|
+
this.#database = new DatabaseSync(path);
|
|
324
|
+
this.#database.exec(
|
|
325
|
+
"PRAGMA auto_vacuum=INCREMENTAL; PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;"
|
|
326
|
+
);
|
|
327
|
+
hardenSqliteSidecarsSync(path);
|
|
328
|
+
try {
|
|
329
|
+
const integrity = this.#database.prepare("PRAGMA quick_check").get();
|
|
330
|
+
if (integrity?.quick_check !== "ok") throw new Error("pi-fleet database quick_check failed");
|
|
331
|
+
this.#migrate();
|
|
332
|
+
this.#database.prepare(
|
|
333
|
+
"UPDATE journal_runtime_health SET clean_shutdown = 0, started_at = ? WHERE singleton_key = 1"
|
|
334
|
+
).run((/* @__PURE__ */ new Date()).toISOString());
|
|
335
|
+
} catch (error) {
|
|
336
|
+
this.#database.close();
|
|
337
|
+
this.#closed = true;
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async createAgent(agent) {
|
|
342
|
+
this.#assertOpen();
|
|
343
|
+
assertJournalAgentIdentity(agent);
|
|
344
|
+
const result = this.#database.prepare("INSERT OR IGNORE INTO journal_agents(agent_id, name, data_json) VALUES(?, ?, ?)").run(agent.agentId, agent.name, JSON.stringify(agent));
|
|
345
|
+
return result.changes === 1;
|
|
346
|
+
}
|
|
347
|
+
async createAgentWithOperation(agent, operation) {
|
|
348
|
+
this.#assertOpen();
|
|
349
|
+
assertJournalAgentIdentity(agent);
|
|
350
|
+
if (operation.agentId !== agent.agentId || operation.targetName !== agent.name) {
|
|
351
|
+
throw new Error("Create operation does not target the provisional agent");
|
|
352
|
+
}
|
|
353
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
354
|
+
try {
|
|
355
|
+
const existing = await this.getOperation(operation.operationId);
|
|
356
|
+
if (existing !== null && (existing.method !== operation.method || existing.fingerprint !== operation.fingerprint || existing.agentId !== null && existing.agentId !== agent.agentId)) {
|
|
357
|
+
throw new Error("Operation was already used with a different request");
|
|
358
|
+
}
|
|
359
|
+
const created = this.#database.prepare(
|
|
360
|
+
"INSERT OR IGNORE INTO journal_agents(agent_id, name, data_json) VALUES(?, ?, ?)"
|
|
361
|
+
).run(agent.agentId, agent.name, JSON.stringify(agent)).changes === 1;
|
|
362
|
+
if (created) this.#putOperationSync(operation);
|
|
363
|
+
this.#database.exec("COMMIT");
|
|
364
|
+
return created;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
this.#database.exec("ROLLBACK");
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async rollbackProvisionalCreate(agentId, completedOperation) {
|
|
371
|
+
this.#assertOpen();
|
|
372
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
373
|
+
try {
|
|
374
|
+
const existing = await this.getAgentById(agentId);
|
|
375
|
+
const pending = await this.getOperation(completedOperation.operationId);
|
|
376
|
+
if (existing === null) {
|
|
377
|
+
this.#database.exec("ROLLBACK");
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
if (pending === null || pending.method !== "create" || pending.state !== "pending" || pending.agentId !== agentId || pending.targetName !== existing.name || completedOperation.method !== "create" || completedOperation.state !== "completed" || completedOperation.agentId !== null || completedOperation.targetName !== existing.name || completedOperation.fingerprint !== pending.fingerprint) {
|
|
381
|
+
throw new Error("Provisional create rollback does not match the pending operation");
|
|
382
|
+
}
|
|
383
|
+
this.#database.prepare("DELETE FROM journal_operations WHERE agent_id = ? AND operation_id <> ?").run(agentId, completedOperation.operationId);
|
|
384
|
+
this.#putOperationSync(completedOperation);
|
|
385
|
+
this.#database.prepare("DELETE FROM journal_agents WHERE agent_id = ?").run(agentId);
|
|
386
|
+
this.#database.exec("COMMIT");
|
|
387
|
+
return existing;
|
|
388
|
+
} catch (error) {
|
|
389
|
+
this.#database.exec("ROLLBACK");
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async getAgentByName(name) {
|
|
394
|
+
return this.#readJson(
|
|
395
|
+
"SELECT data_json FROM journal_agents WHERE name = ?",
|
|
396
|
+
name
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
async getAgentById(agentId) {
|
|
400
|
+
return this.#readJson(
|
|
401
|
+
"SELECT data_json FROM journal_agents WHERE agent_id = ?",
|
|
402
|
+
agentId
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
async listAgents() {
|
|
406
|
+
return this.#readJsonRows("SELECT data_json FROM journal_agents ORDER BY name");
|
|
407
|
+
}
|
|
408
|
+
async putAgent(agent) {
|
|
409
|
+
this.#assertOpen();
|
|
410
|
+
assertJournalAgentIdentity(agent);
|
|
411
|
+
this.#database.prepare(
|
|
412
|
+
`INSERT INTO journal_agents(agent_id, name, data_json) VALUES(?, ?, ?)
|
|
413
|
+
ON CONFLICT(agent_id) DO UPDATE SET name=excluded.name, data_json=excluded.data_json`
|
|
414
|
+
).run(agent.agentId, agent.name, JSON.stringify(agent));
|
|
415
|
+
}
|
|
416
|
+
async putOperation(operation) {
|
|
417
|
+
this.#putOperationSync(operation);
|
|
418
|
+
}
|
|
419
|
+
async getOperation(operationId) {
|
|
420
|
+
return this.#readJson(
|
|
421
|
+
"SELECT data_json FROM journal_operations WHERE operation_id = ?",
|
|
422
|
+
operationId
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
async listPendingOperations() {
|
|
426
|
+
return this.#readJsonRows(
|
|
427
|
+
"SELECT data_json FROM journal_operations WHERE json_extract(data_json, '$.state') = 'pending' ORDER BY rowid"
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
async deleteOperation(operationId) {
|
|
431
|
+
this.#assertOpen();
|
|
432
|
+
this.#database.prepare("DELETE FROM journal_operations WHERE operation_id = ?").run(operationId);
|
|
433
|
+
}
|
|
434
|
+
async putSend(send) {
|
|
435
|
+
this.#putOwned("journal_sends", "send_id", send.sendId, send.agentId, send);
|
|
436
|
+
}
|
|
437
|
+
async getSend(sendId) {
|
|
438
|
+
return this.#readJson(
|
|
439
|
+
"SELECT data_json FROM journal_sends WHERE send_id = ?",
|
|
440
|
+
sendId
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
async nextSendOrdinal(agentId) {
|
|
444
|
+
const row = this.#database.prepare(
|
|
445
|
+
`SELECT COALESCE(MAX(CAST(json_extract(data_json, '$.ordinal') AS INTEGER)), 0) + 1 AS ordinal
|
|
446
|
+
FROM journal_sends WHERE agent_id = ?`
|
|
447
|
+
).get(agentId);
|
|
448
|
+
return row.ordinal;
|
|
449
|
+
}
|
|
450
|
+
async listNonterminalSends() {
|
|
451
|
+
return this.#readJsonRows(
|
|
452
|
+
`SELECT data_json FROM journal_sends
|
|
453
|
+
WHERE json_extract(data_json, '$.state') IN ('pending', 'dispatching')
|
|
454
|
+
ORDER BY CAST(json_extract(data_json, '$.ordinal') AS INTEGER), rowid`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
async putCompact(compact) {
|
|
458
|
+
this.#putOwned("journal_compacts", "compact_id", compact.compactId, compact.agentId, compact);
|
|
459
|
+
}
|
|
460
|
+
async getCompact(compactId) {
|
|
461
|
+
return this.#readJson(
|
|
462
|
+
"SELECT data_json FROM journal_compacts WHERE compact_id = ?",
|
|
463
|
+
compactId
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
async listNonterminalCompacts() {
|
|
467
|
+
return this.#readJsonRows(
|
|
468
|
+
`SELECT data_json FROM journal_compacts
|
|
469
|
+
WHERE json_extract(data_json, '$.state') IN ('pending', 'dispatching') ORDER BY rowid`
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
async putIncarnation(incarnation) {
|
|
473
|
+
this.#putOwned(
|
|
474
|
+
"journal_incarnations",
|
|
475
|
+
"incarnation_id",
|
|
476
|
+
incarnation.incarnationId,
|
|
477
|
+
incarnation.agentId,
|
|
478
|
+
incarnation
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
async listActiveIncarnations() {
|
|
482
|
+
return this.#readJsonRows(
|
|
483
|
+
`SELECT data_json FROM journal_incarnations
|
|
484
|
+
WHERE json_extract(data_json, '$.state') IN
|
|
485
|
+
('starting', 'live', 'stopping', 'cleanup_uncertain') ORDER BY rowid`
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
async putEpoch(epoch) {
|
|
489
|
+
this.#assertOpen();
|
|
490
|
+
this.#database.prepare(
|
|
491
|
+
`INSERT INTO journal_epochs(agent_id, epoch, state, data_json) VALUES(?, ?, ?, ?)
|
|
492
|
+
ON CONFLICT(agent_id, epoch) DO UPDATE SET
|
|
493
|
+
state=excluded.state, data_json=excluded.data_json`
|
|
494
|
+
).run(epoch.agentId, epoch.epoch, epoch.state, JSON.stringify(epoch));
|
|
495
|
+
}
|
|
496
|
+
async getEpochs(agentId) {
|
|
497
|
+
return this.#readJsonRows(
|
|
498
|
+
"SELECT data_json FROM journal_epochs WHERE agent_id = ? ORDER BY epoch",
|
|
499
|
+
agentId
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
async beginIncarnation(agentId, incarnationId, epoch, projectorState) {
|
|
503
|
+
this.#assertOpen();
|
|
504
|
+
const incarnation = this.#database.prepare("SELECT 1 FROM journal_incarnations WHERE agent_id = ? AND incarnation_id = ?").get(agentId, incarnationId);
|
|
505
|
+
const epochRow = this.#database.prepare("SELECT state FROM journal_epochs WHERE agent_id = ? AND epoch = ?").get(agentId, epoch);
|
|
506
|
+
if (incarnation === void 0) {
|
|
507
|
+
throw new Error("Projector binding references an unknown incarnation");
|
|
508
|
+
}
|
|
509
|
+
if (epochRow?.state !== "open") {
|
|
510
|
+
throw new Error("Projector binding requires an open continuity epoch");
|
|
511
|
+
}
|
|
512
|
+
this.#database.prepare(
|
|
513
|
+
`INSERT INTO journal_projector_state(
|
|
514
|
+
agent_id, incarnation_id, epoch, data_json
|
|
515
|
+
) VALUES(?, ?, ?, ?)
|
|
516
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
517
|
+
incarnation_id=excluded.incarnation_id,
|
|
518
|
+
epoch=excluded.epoch,
|
|
519
|
+
data_json=excluded.data_json`
|
|
520
|
+
).run(agentId, incarnationId, epoch, JSON.stringify(projectorState));
|
|
521
|
+
}
|
|
522
|
+
async append(batch) {
|
|
523
|
+
this.#assertOpen();
|
|
524
|
+
const startedAt = performance.now();
|
|
525
|
+
let transactionOpen = false;
|
|
526
|
+
try {
|
|
527
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
528
|
+
transactionOpen = true;
|
|
529
|
+
const current = this.#getHighWaterSync(batch.agentId) ?? {
|
|
530
|
+
rawPosition: 0,
|
|
531
|
+
eventPosition: 0,
|
|
532
|
+
idleEventPosition: null
|
|
533
|
+
};
|
|
534
|
+
const epochRow = this.#database.prepare("SELECT data_json FROM journal_epochs WHERE agent_id = ? AND epoch = ?").get(batch.agentId, batch.epoch);
|
|
535
|
+
assertJournalAppend(
|
|
536
|
+
batch,
|
|
537
|
+
current,
|
|
538
|
+
epochRow === void 0 ? null : JSON.parse(epochRow.data_json)
|
|
539
|
+
);
|
|
540
|
+
const projectorBinding = this.#database.prepare(
|
|
541
|
+
`SELECT incarnation_id, epoch FROM journal_projector_state
|
|
542
|
+
WHERE agent_id = ?`
|
|
543
|
+
).get(batch.agentId);
|
|
544
|
+
if (projectorBinding?.incarnation_id !== batch.incarnationId || projectorBinding.epoch !== batch.epoch) {
|
|
545
|
+
throw new Error("Journal append does not match the active projector incarnation");
|
|
546
|
+
}
|
|
547
|
+
const putRecord = this.#database.prepare(
|
|
548
|
+
`INSERT INTO journal_raw_records(
|
|
549
|
+
agent_id, raw_position, incarnation_id, epoch, observed_at, record_bytes
|
|
550
|
+
) VALUES(?, ?, ?, ?, ?, ?)`
|
|
551
|
+
);
|
|
552
|
+
for (const record of batch.records) {
|
|
553
|
+
putRecord.run(
|
|
554
|
+
record.agentId,
|
|
555
|
+
record.position,
|
|
556
|
+
record.incarnationId,
|
|
557
|
+
batch.epoch,
|
|
558
|
+
record.observedAt,
|
|
559
|
+
record.bytes
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
const putEvent = this.#database.prepare(
|
|
563
|
+
`INSERT INTO journal_semantic_events(
|
|
564
|
+
agent_id, event_position, event_id, event_epoch, raw_position, event_type, data_json
|
|
565
|
+
) VALUES(?, ?, ?, ?, ?, ?, ?)`
|
|
566
|
+
);
|
|
567
|
+
for (const stored of batch.events) {
|
|
568
|
+
putEvent.run(
|
|
569
|
+
stored.agentId,
|
|
570
|
+
stored.position,
|
|
571
|
+
stored.event.id,
|
|
572
|
+
stored.event.epoch,
|
|
573
|
+
stored.event.sourceRawPosition,
|
|
574
|
+
stored.event.type,
|
|
575
|
+
JSON.stringify(stored)
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
this.#database.prepare(
|
|
579
|
+
`UPDATE journal_projector_state SET data_json = ?
|
|
580
|
+
WHERE agent_id = ? AND incarnation_id = ? AND epoch = ?`
|
|
581
|
+
).run(JSON.stringify(batch.projectorState), batch.agentId, batch.incarnationId, batch.epoch);
|
|
582
|
+
this.#database.prepare(
|
|
583
|
+
`INSERT INTO journal_high_water(
|
|
584
|
+
agent_id, raw_position, event_position, idle_event_position
|
|
585
|
+
) VALUES(?, ?, ?, ?)
|
|
586
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
587
|
+
raw_position=excluded.raw_position,
|
|
588
|
+
event_position=excluded.event_position,
|
|
589
|
+
idle_event_position=excluded.idle_event_position`
|
|
590
|
+
).run(
|
|
591
|
+
batch.agentId,
|
|
592
|
+
batch.highWater.rawPosition,
|
|
593
|
+
batch.highWater.eventPosition,
|
|
594
|
+
batch.highWater.idleEventPosition
|
|
595
|
+
);
|
|
596
|
+
this.#database.prepare(
|
|
597
|
+
`UPDATE journal_runtime_health SET last_commit_at = ?
|
|
598
|
+
WHERE singleton_key = 1`
|
|
599
|
+
).run((/* @__PURE__ */ new Date()).toISOString());
|
|
600
|
+
this.#database.exec("COMMIT");
|
|
601
|
+
transactionOpen = false;
|
|
602
|
+
} catch (error) {
|
|
603
|
+
if (transactionOpen) {
|
|
604
|
+
try {
|
|
605
|
+
this.#database.exec("ROLLBACK");
|
|
606
|
+
} catch {
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
this.#latchStorageFailure();
|
|
610
|
+
throw error;
|
|
611
|
+
} finally {
|
|
612
|
+
this.#lastAppendDurationMs = Math.max(0, performance.now() - startedAt);
|
|
613
|
+
}
|
|
614
|
+
this.#commitsSinceCheckpoint += 1;
|
|
615
|
+
if (this.#commitsSinceCheckpoint >= this.#checkpointCommitInterval) {
|
|
616
|
+
await this.maintain(0);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async openReceive(agentId) {
|
|
620
|
+
this.#assertOpen();
|
|
621
|
+
this.#database.exec("BEGIN");
|
|
622
|
+
try {
|
|
623
|
+
const agent = this.#readJson(
|
|
624
|
+
"SELECT data_json FROM journal_agents WHERE agent_id = ?",
|
|
625
|
+
agentId
|
|
626
|
+
);
|
|
627
|
+
if (agent === null) {
|
|
628
|
+
this.#database.exec("COMMIT");
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
const snapshot = {
|
|
632
|
+
agent,
|
|
633
|
+
epochs: this.#readJsonRows(
|
|
634
|
+
"SELECT data_json FROM journal_epochs WHERE agent_id = ? ORDER BY epoch",
|
|
635
|
+
agentId
|
|
636
|
+
),
|
|
637
|
+
highWater: this.#getHighWaterSync(agentId) ?? {
|
|
638
|
+
rawPosition: 0,
|
|
639
|
+
eventPosition: 0,
|
|
640
|
+
idleEventPosition: null
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
this.#database.exec("COMMIT");
|
|
644
|
+
return snapshot;
|
|
645
|
+
} catch (error) {
|
|
646
|
+
this.#database.exec("ROLLBACK");
|
|
647
|
+
throw error;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
async readEvents(range) {
|
|
651
|
+
const rows = this.#database.prepare(
|
|
652
|
+
`SELECT data_json FROM journal_semantic_events
|
|
653
|
+
WHERE agent_id = ? AND event_epoch = ? AND event_position > ?
|
|
654
|
+
ORDER BY event_position LIMIT ?`
|
|
655
|
+
).iterate(range.agentId, range.epoch, range.afterPosition, range.limit);
|
|
656
|
+
return boundedSemanticEvents(parseSemanticEventRows(rows), range);
|
|
657
|
+
}
|
|
658
|
+
async getProjectorState(agentId, incarnationId, epoch) {
|
|
659
|
+
this.#assertOpen();
|
|
660
|
+
const row = this.#database.prepare(
|
|
661
|
+
`SELECT incarnation_id, epoch, data_json FROM journal_projector_state
|
|
662
|
+
WHERE agent_id = ?`
|
|
663
|
+
).get(agentId);
|
|
664
|
+
if (row === void 0) return null;
|
|
665
|
+
if (row.incarnation_id !== incarnationId || row.epoch !== epoch) {
|
|
666
|
+
throw new Error("Projector state belongs to another incarnation or continuity epoch");
|
|
667
|
+
}
|
|
668
|
+
return JSON.parse(row.data_json);
|
|
669
|
+
}
|
|
670
|
+
async getHighWater(agentId) {
|
|
671
|
+
return this.#getHighWaterSync(agentId);
|
|
672
|
+
}
|
|
673
|
+
async markIdle(agentId, epoch) {
|
|
674
|
+
this.#assertOpen();
|
|
675
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
676
|
+
try {
|
|
677
|
+
const epochRow = this.#database.prepare("SELECT state FROM journal_epochs WHERE agent_id = ? AND epoch = ?").get(agentId, epoch);
|
|
678
|
+
if (epochRow?.state !== "open") throw new Error("Idle marker requires an open epoch");
|
|
679
|
+
const current = this.#getHighWaterSync(agentId) ?? {
|
|
680
|
+
rawPosition: 0,
|
|
681
|
+
eventPosition: 0,
|
|
682
|
+
idleEventPosition: null
|
|
683
|
+
};
|
|
684
|
+
this.#database.prepare(
|
|
685
|
+
`INSERT INTO journal_high_water(
|
|
686
|
+
agent_id, raw_position, event_position, idle_event_position
|
|
687
|
+
) VALUES(?, ?, ?, ?)
|
|
688
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
689
|
+
idle_event_position=excluded.idle_event_position`
|
|
690
|
+
).run(agentId, current.rawPosition, current.eventPosition, current.eventPosition);
|
|
691
|
+
this.#database.prepare("UPDATE journal_runtime_health SET last_commit_at = ? WHERE singleton_key = 1").run((/* @__PURE__ */ new Date()).toISOString());
|
|
692
|
+
this.#database.exec("COMMIT");
|
|
693
|
+
return current.eventPosition;
|
|
694
|
+
} catch (error) {
|
|
695
|
+
this.#database.exec("ROLLBACK");
|
|
696
|
+
this.#latchStorageFailure();
|
|
697
|
+
throw error;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
async getRawRecords(agentId, afterPosition, limit) {
|
|
701
|
+
this.#assertPositiveLimit(limit);
|
|
702
|
+
const rows = this.#database.prepare(
|
|
703
|
+
`SELECT raw_position, incarnation_id, observed_at, record_bytes
|
|
704
|
+
FROM journal_raw_records WHERE agent_id = ? AND raw_position > ?
|
|
705
|
+
ORDER BY raw_position LIMIT ?`
|
|
706
|
+
).all(agentId, afterPosition, limit);
|
|
707
|
+
return rows.map((row) => ({
|
|
708
|
+
agentId,
|
|
709
|
+
incarnationId: row.incarnation_id,
|
|
710
|
+
position: row.raw_position,
|
|
711
|
+
observedAt: row.observed_at,
|
|
712
|
+
bytes: Buffer.from(row.record_bytes)
|
|
713
|
+
}));
|
|
714
|
+
}
|
|
715
|
+
async destroyAgent(agentId, receipt) {
|
|
716
|
+
this.#assertOpen();
|
|
717
|
+
if (receipt.agentId !== agentId) throw new Error("Destroy receipt targets another agent");
|
|
718
|
+
let destroyed = null;
|
|
719
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
720
|
+
try {
|
|
721
|
+
const priorReceipt = await this.getDestroyReceipt(receipt.operationId);
|
|
722
|
+
if (priorReceipt !== null) {
|
|
723
|
+
if (!sameJournalDestroyReceipt(priorReceipt, receipt)) {
|
|
724
|
+
throw new Error("Destroy operation was already used");
|
|
725
|
+
}
|
|
726
|
+
this.#database.exec("ROLLBACK");
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
const existing = await this.getAgentById(agentId);
|
|
730
|
+
if (existing === null) {
|
|
731
|
+
this.#database.exec("ROLLBACK");
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
this.#database.prepare("DELETE FROM journal_operations WHERE agent_id = ?").run(agentId);
|
|
735
|
+
this.#database.prepare("DELETE FROM journal_agents WHERE agent_id = ?").run(agentId);
|
|
736
|
+
this.#database.prepare(
|
|
737
|
+
`INSERT INTO journal_destroy_receipts(
|
|
738
|
+
operation_id, agent_id, agent_name, fingerprint, destroyed_at, status
|
|
739
|
+
) VALUES(?, ?, ?, ?, ?, ?)
|
|
740
|
+
ON CONFLICT(operation_id) DO NOTHING`
|
|
741
|
+
).run(
|
|
742
|
+
receipt.operationId,
|
|
743
|
+
receipt.agentId,
|
|
744
|
+
receipt.agentName,
|
|
745
|
+
receipt.fingerprint,
|
|
746
|
+
receipt.destroyedAt,
|
|
747
|
+
receipt.status
|
|
748
|
+
);
|
|
749
|
+
this.#database.exec("COMMIT");
|
|
750
|
+
destroyed = existing;
|
|
751
|
+
} catch (error) {
|
|
752
|
+
this.#database.exec("ROLLBACK");
|
|
753
|
+
throw error;
|
|
754
|
+
}
|
|
755
|
+
await this.maintain(this.#reclaimPagesPerPass);
|
|
756
|
+
return destroyed;
|
|
757
|
+
}
|
|
758
|
+
async getDestroyReceipt(operationId) {
|
|
759
|
+
const row = this.#database.prepare(
|
|
760
|
+
`SELECT operation_id, agent_id, agent_name, fingerprint, destroyed_at, status
|
|
761
|
+
FROM journal_destroy_receipts WHERE operation_id = ?`
|
|
762
|
+
).get(operationId);
|
|
763
|
+
return row === void 0 ? null : {
|
|
764
|
+
operationId: row.operation_id,
|
|
765
|
+
agentId: row.agent_id,
|
|
766
|
+
agentName: row.agent_name,
|
|
767
|
+
fingerprint: row.fingerprint,
|
|
768
|
+
destroyedAt: row.destroyed_at,
|
|
769
|
+
status: row.status
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
async maintain(reclaimPages = this.#reclaimPagesPerPass) {
|
|
773
|
+
this.#assertOpen();
|
|
774
|
+
if (!Number.isSafeInteger(reclaimPages) || reclaimPages < 0) {
|
|
775
|
+
throw new Error("Journal reclaim page limit must be a non-negative integer");
|
|
776
|
+
}
|
|
777
|
+
this.#maintenance = {
|
|
778
|
+
...this.#maintenance,
|
|
779
|
+
state: "running",
|
|
780
|
+
requestedReclaimPages: reclaimPages
|
|
781
|
+
};
|
|
782
|
+
try {
|
|
783
|
+
const checkpoint = this.#database.prepare("PRAGMA wal_checkpoint(PASSIVE)").get();
|
|
784
|
+
const autoVacuumValue = pragmaInteger(this.#database, "auto_vacuum");
|
|
785
|
+
const freelistPagesBefore = pragmaInteger(this.#database, "freelist_count");
|
|
786
|
+
if (reclaimPages > 0 && autoVacuumValue === 2 && freelistPagesBefore > 0) {
|
|
787
|
+
this.#database.exec(`PRAGMA incremental_vacuum(${String(reclaimPages)})`);
|
|
788
|
+
}
|
|
789
|
+
const result = {
|
|
790
|
+
state: "idle",
|
|
791
|
+
lastCheckpointAt: this.#now(),
|
|
792
|
+
busy: checkpoint.busy !== 0,
|
|
793
|
+
logFrames: checkpoint.log,
|
|
794
|
+
checkpointedFrames: checkpoint.checkpointed,
|
|
795
|
+
autoVacuumMode: autoVacuumMode(autoVacuumValue),
|
|
796
|
+
freelistPagesBefore,
|
|
797
|
+
freelistPagesAfter: pragmaInteger(this.#database, "freelist_count"),
|
|
798
|
+
requestedReclaimPages: reclaimPages
|
|
799
|
+
};
|
|
800
|
+
this.#maintenance = result;
|
|
801
|
+
this.#commitsSinceCheckpoint = 0;
|
|
802
|
+
return { ...result };
|
|
803
|
+
} catch (error) {
|
|
804
|
+
this.#maintenance = { ...this.#maintenance, state: "failed" };
|
|
805
|
+
this.#latchStorageFailure();
|
|
806
|
+
throw error;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
async getDiagnostics() {
|
|
810
|
+
const retainedByAgent = this.#database.prepare(
|
|
811
|
+
`SELECT a.agent_id,
|
|
812
|
+
(SELECT COUNT(*) FROM journal_raw_records r
|
|
813
|
+
WHERE r.agent_id = a.agent_id) AS raw_record_count,
|
|
814
|
+
(SELECT COALESCE(SUM(length(r.record_bytes)), 0) FROM journal_raw_records r
|
|
815
|
+
WHERE r.agent_id = a.agent_id) AS raw_bytes,
|
|
816
|
+
(SELECT COUNT(*) FROM journal_semantic_events e
|
|
817
|
+
WHERE e.agent_id = a.agent_id) AS semantic_event_count
|
|
818
|
+
FROM journal_agents a ORDER BY a.agent_id`
|
|
819
|
+
).all();
|
|
820
|
+
const row = this.#database.prepare(
|
|
821
|
+
`SELECT
|
|
822
|
+
(SELECT COUNT(*) FROM journal_raw_records) AS raw_record_count,
|
|
823
|
+
(SELECT COALESCE(SUM(length(record_bytes)), 0) FROM journal_raw_records) AS raw_bytes,
|
|
824
|
+
(SELECT COUNT(*) FROM journal_semantic_events) AS semantic_event_count,
|
|
825
|
+
(SELECT COUNT(*) FROM journal_agents) AS agent_count,
|
|
826
|
+
(SELECT COALESCE(SUM(json_array_length(data_json, '$.openActivities')), 0)
|
|
827
|
+
FROM journal_projector_state) AS open_projector_activities,
|
|
828
|
+
(SELECT COUNT(*) FROM journal_epochs
|
|
829
|
+
WHERE state = 'closed'
|
|
830
|
+
AND json_extract(data_json, '$.reason') = 'observation_uncertain')
|
|
831
|
+
AS continuity_gap_count,
|
|
832
|
+
(SELECT storage_state FROM journal_runtime_health WHERE singleton_key = 1) AS storage_state,
|
|
833
|
+
(SELECT last_commit_at FROM journal_runtime_health WHERE singleton_key = 1) AS last_commit_at`
|
|
834
|
+
).get();
|
|
835
|
+
return {
|
|
836
|
+
rawRecordCount: row.raw_record_count,
|
|
837
|
+
rawBytes: row.raw_bytes,
|
|
838
|
+
semanticEventCount: row.semantic_event_count,
|
|
839
|
+
agentCount: row.agent_count,
|
|
840
|
+
retainedByAgent: retainedByAgent.map((agent) => ({
|
|
841
|
+
agentId: agent.agent_id,
|
|
842
|
+
rawRecordCount: agent.raw_record_count,
|
|
843
|
+
rawBytes: agent.raw_bytes,
|
|
844
|
+
semanticEventCount: agent.semantic_event_count
|
|
845
|
+
})),
|
|
846
|
+
openProjectorActivities: row.open_projector_activities,
|
|
847
|
+
continuityGapCount: row.continuity_gap_count,
|
|
848
|
+
storageState: this.#storageFailureLatched ? "failed" : row.storage_state,
|
|
849
|
+
lastCommitAt: row.last_commit_at,
|
|
850
|
+
lastAppendDurationMs: this.#lastAppendDurationMs,
|
|
851
|
+
maintenance: { ...this.#maintenance }
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
#latchStorageFailure() {
|
|
855
|
+
this.#storageFailureLatched = true;
|
|
856
|
+
try {
|
|
857
|
+
this.#database.prepare(
|
|
858
|
+
"UPDATE journal_runtime_health SET storage_state = 'failed' WHERE singleton_key = 1"
|
|
859
|
+
).run();
|
|
860
|
+
} catch {
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
async close() {
|
|
864
|
+
if (this.#closed) return;
|
|
865
|
+
this.#database.prepare("UPDATE journal_runtime_health SET clean_shutdown = 1 WHERE singleton_key = 1").run();
|
|
866
|
+
this.#database.close();
|
|
867
|
+
this.#closed = true;
|
|
868
|
+
}
|
|
869
|
+
#migrate() {
|
|
870
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
871
|
+
try {
|
|
872
|
+
this.#database.exec(
|
|
873
|
+
`CREATE TABLE IF NOT EXISTS schema_migrations(
|
|
874
|
+
version INTEGER PRIMARY KEY,
|
|
875
|
+
checksum TEXT NOT NULL,
|
|
876
|
+
applied_at TEXT NOT NULL
|
|
877
|
+
)`
|
|
878
|
+
);
|
|
879
|
+
const applied = this.#database.prepare("SELECT version, checksum FROM schema_migrations ORDER BY version").all();
|
|
880
|
+
const migrationState = classifyJournalMigrationRows(applied);
|
|
881
|
+
if (migrationState === "current") {
|
|
882
|
+
this.#database.exec("COMMIT");
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
this.#replaceLegacySchema();
|
|
886
|
+
this.#database.prepare("DELETE FROM schema_migrations").run();
|
|
887
|
+
this.#database.prepare("INSERT INTO schema_migrations(version, checksum, applied_at) VALUES(?, ?, ?)").run(JOURNAL_SCHEMA_VERSION, JOURNAL_SCHEMA_CHECKSUM, (/* @__PURE__ */ new Date()).toISOString());
|
|
888
|
+
this.#database.exec("COMMIT");
|
|
889
|
+
} catch (error) {
|
|
890
|
+
this.#database.exec("ROLLBACK");
|
|
891
|
+
throw error;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
#replaceLegacySchema() {
|
|
895
|
+
this.#database.exec(`
|
|
896
|
+
DROP TABLE IF EXISTS compact_records;
|
|
897
|
+
DROP TABLE IF EXISTS send_records;
|
|
898
|
+
DROP TABLE IF EXISTS incarnations;
|
|
899
|
+
DROP TABLE IF EXISTS operations;
|
|
900
|
+
DROP TABLE IF EXISTS agents;
|
|
901
|
+
DROP TABLE IF EXISTS runtime_metadata;
|
|
902
|
+
|
|
903
|
+
CREATE TABLE journal_agents(
|
|
904
|
+
agent_id TEXT PRIMARY KEY,
|
|
905
|
+
name TEXT NOT NULL UNIQUE,
|
|
906
|
+
data_json TEXT NOT NULL CHECK(json_valid(data_json))
|
|
907
|
+
);
|
|
908
|
+
CREATE TABLE journal_operations(
|
|
909
|
+
operation_id TEXT PRIMARY KEY,
|
|
910
|
+
agent_id TEXT REFERENCES journal_agents(agent_id),
|
|
911
|
+
data_json TEXT NOT NULL CHECK(json_valid(data_json))
|
|
912
|
+
);
|
|
913
|
+
CREATE TABLE journal_sends(
|
|
914
|
+
send_id TEXT PRIMARY KEY,
|
|
915
|
+
agent_id TEXT NOT NULL REFERENCES journal_agents(agent_id) ON DELETE CASCADE,
|
|
916
|
+
data_json TEXT NOT NULL CHECK(json_valid(data_json))
|
|
917
|
+
);
|
|
918
|
+
CREATE TABLE journal_compacts(
|
|
919
|
+
compact_id TEXT PRIMARY KEY,
|
|
920
|
+
agent_id TEXT NOT NULL REFERENCES journal_agents(agent_id) ON DELETE CASCADE,
|
|
921
|
+
data_json TEXT NOT NULL CHECK(json_valid(data_json))
|
|
922
|
+
);
|
|
923
|
+
CREATE TABLE journal_incarnations(
|
|
924
|
+
incarnation_id TEXT PRIMARY KEY,
|
|
925
|
+
agent_id TEXT NOT NULL REFERENCES journal_agents(agent_id) ON DELETE CASCADE,
|
|
926
|
+
data_json TEXT NOT NULL CHECK(json_valid(data_json)),
|
|
927
|
+
UNIQUE(agent_id, incarnation_id)
|
|
928
|
+
);
|
|
929
|
+
CREATE TABLE journal_epochs(
|
|
930
|
+
agent_id TEXT NOT NULL REFERENCES journal_agents(agent_id) ON DELETE CASCADE,
|
|
931
|
+
epoch INTEGER NOT NULL,
|
|
932
|
+
state TEXT NOT NULL CHECK(state IN ('open', 'closed')),
|
|
933
|
+
data_json TEXT NOT NULL CHECK(json_valid(data_json)),
|
|
934
|
+
PRIMARY KEY(agent_id, epoch)
|
|
935
|
+
);
|
|
936
|
+
CREATE TABLE journal_raw_records(
|
|
937
|
+
agent_id TEXT NOT NULL REFERENCES journal_agents(agent_id) ON DELETE CASCADE,
|
|
938
|
+
raw_position INTEGER NOT NULL,
|
|
939
|
+
incarnation_id TEXT NOT NULL,
|
|
940
|
+
epoch INTEGER NOT NULL,
|
|
941
|
+
observed_at TEXT NOT NULL,
|
|
942
|
+
record_bytes BLOB NOT NULL,
|
|
943
|
+
PRIMARY KEY(agent_id, raw_position),
|
|
944
|
+
UNIQUE(agent_id, raw_position, epoch),
|
|
945
|
+
FOREIGN KEY(agent_id, epoch) REFERENCES journal_epochs(agent_id, epoch),
|
|
946
|
+
FOREIGN KEY(agent_id, incarnation_id)
|
|
947
|
+
REFERENCES journal_incarnations(agent_id, incarnation_id)
|
|
948
|
+
);
|
|
949
|
+
CREATE TABLE journal_semantic_events(
|
|
950
|
+
agent_id TEXT NOT NULL REFERENCES journal_agents(agent_id) ON DELETE CASCADE,
|
|
951
|
+
event_position INTEGER NOT NULL,
|
|
952
|
+
event_id TEXT NOT NULL UNIQUE,
|
|
953
|
+
event_epoch INTEGER NOT NULL,
|
|
954
|
+
raw_position INTEGER NOT NULL,
|
|
955
|
+
event_type TEXT NOT NULL,
|
|
956
|
+
data_json TEXT NOT NULL CHECK(json_valid(data_json)),
|
|
957
|
+
PRIMARY KEY(agent_id, event_position),
|
|
958
|
+
FOREIGN KEY(agent_id, event_epoch) REFERENCES journal_epochs(agent_id, epoch),
|
|
959
|
+
FOREIGN KEY(agent_id, raw_position, event_epoch)
|
|
960
|
+
REFERENCES journal_raw_records(agent_id, raw_position, epoch)
|
|
961
|
+
);
|
|
962
|
+
CREATE TABLE journal_projector_state(
|
|
963
|
+
agent_id TEXT PRIMARY KEY REFERENCES journal_agents(agent_id) ON DELETE CASCADE,
|
|
964
|
+
incarnation_id TEXT NOT NULL,
|
|
965
|
+
epoch INTEGER NOT NULL,
|
|
966
|
+
data_json TEXT NOT NULL CHECK(json_valid(data_json)),
|
|
967
|
+
FOREIGN KEY(agent_id, incarnation_id)
|
|
968
|
+
REFERENCES journal_incarnations(agent_id, incarnation_id),
|
|
969
|
+
FOREIGN KEY(agent_id, epoch) REFERENCES journal_epochs(agent_id, epoch)
|
|
970
|
+
);
|
|
971
|
+
CREATE TABLE journal_high_water(
|
|
972
|
+
agent_id TEXT PRIMARY KEY REFERENCES journal_agents(agent_id) ON DELETE CASCADE,
|
|
973
|
+
raw_position INTEGER NOT NULL,
|
|
974
|
+
event_position INTEGER NOT NULL,
|
|
975
|
+
idle_event_position INTEGER
|
|
976
|
+
);
|
|
977
|
+
CREATE TABLE journal_runtime_health(
|
|
978
|
+
singleton_key INTEGER PRIMARY KEY CHECK(singleton_key = 1),
|
|
979
|
+
storage_state TEXT NOT NULL CHECK(storage_state IN ('healthy', 'failed')),
|
|
980
|
+
last_commit_at TEXT,
|
|
981
|
+
clean_shutdown INTEGER NOT NULL CHECK(clean_shutdown IN (0, 1)),
|
|
982
|
+
started_at TEXT
|
|
983
|
+
);
|
|
984
|
+
INSERT INTO journal_runtime_health(
|
|
985
|
+
singleton_key, storage_state, last_commit_at, clean_shutdown, started_at
|
|
986
|
+
) VALUES(1, 'healthy', NULL, 1, NULL);
|
|
987
|
+
CREATE TABLE journal_destroy_receipts(
|
|
988
|
+
operation_id TEXT PRIMARY KEY,
|
|
989
|
+
agent_id TEXT NOT NULL,
|
|
990
|
+
agent_name TEXT NOT NULL,
|
|
991
|
+
fingerprint TEXT NOT NULL,
|
|
992
|
+
destroyed_at TEXT NOT NULL,
|
|
993
|
+
status TEXT NOT NULL CHECK(status = 'destroyed')
|
|
994
|
+
);
|
|
995
|
+
CREATE INDEX journal_events_range
|
|
996
|
+
ON journal_semantic_events(agent_id, event_position);
|
|
997
|
+
CREATE INDEX journal_raw_range
|
|
998
|
+
ON journal_raw_records(agent_id, raw_position);
|
|
999
|
+
`);
|
|
1000
|
+
}
|
|
1001
|
+
#putOperationSync(operation) {
|
|
1002
|
+
this.#assertOpen();
|
|
1003
|
+
if (operation.agentId !== null && this.#readJson(
|
|
1004
|
+
"SELECT data_json FROM journal_agents WHERE agent_id = ?",
|
|
1005
|
+
operation.agentId
|
|
1006
|
+
) === null) {
|
|
1007
|
+
throw new Error("Operation targets an unknown agent generation");
|
|
1008
|
+
}
|
|
1009
|
+
const existing = this.#database.prepare("SELECT agent_id, data_json FROM journal_operations WHERE operation_id = ?").get(operation.operationId);
|
|
1010
|
+
if (existing !== void 0) {
|
|
1011
|
+
const prior = JSON.parse(existing.data_json);
|
|
1012
|
+
if (existing.agent_id !== null && operation.agentId !== null && existing.agent_id !== operation.agentId) {
|
|
1013
|
+
throw new Error("Agent-owned record cannot change generations");
|
|
1014
|
+
}
|
|
1015
|
+
if (prior.method !== operation.method || prior.fingerprint !== operation.fingerprint) {
|
|
1016
|
+
throw new Error("Operation was already used with a different request");
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
this.#database.prepare(
|
|
1020
|
+
`INSERT INTO journal_operations(operation_id, agent_id, data_json) VALUES(?, ?, ?)
|
|
1021
|
+
ON CONFLICT(operation_id) DO UPDATE SET
|
|
1022
|
+
agent_id=excluded.agent_id, data_json=excluded.data_json`
|
|
1023
|
+
).run(operation.operationId, operation.agentId, JSON.stringify(operation));
|
|
1024
|
+
}
|
|
1025
|
+
#putOwned(table, idColumn, id, agentId, value) {
|
|
1026
|
+
this.#assertOpen();
|
|
1027
|
+
const existing = this.#database.prepare(`SELECT agent_id FROM ${table} WHERE ${idColumn} = ?`).get(id);
|
|
1028
|
+
if (existing !== void 0 && existing.agent_id !== agentId) {
|
|
1029
|
+
throw new Error("Agent-owned record cannot change generations");
|
|
1030
|
+
}
|
|
1031
|
+
this.#database.prepare(
|
|
1032
|
+
`INSERT INTO ${table}(${idColumn}, agent_id, data_json) VALUES(?, ?, ?)
|
|
1033
|
+
ON CONFLICT(${idColumn}) DO UPDATE SET data_json=excluded.data_json`
|
|
1034
|
+
).run(id, agentId, JSON.stringify(value));
|
|
1035
|
+
}
|
|
1036
|
+
#readJson(sql, value) {
|
|
1037
|
+
this.#assertOpen();
|
|
1038
|
+
const row = this.#database.prepare(sql).get(value);
|
|
1039
|
+
return row === void 0 ? null : JSON.parse(row.data_json);
|
|
1040
|
+
}
|
|
1041
|
+
#readJsonRows(sql, ...values) {
|
|
1042
|
+
this.#assertOpen();
|
|
1043
|
+
const rows = this.#database.prepare(sql).all(...values);
|
|
1044
|
+
return rows.map((row) => JSON.parse(row.data_json));
|
|
1045
|
+
}
|
|
1046
|
+
#getHighWaterSync(agentId) {
|
|
1047
|
+
this.#assertOpen();
|
|
1048
|
+
const row = this.#database.prepare(
|
|
1049
|
+
`SELECT raw_position, event_position, idle_event_position
|
|
1050
|
+
FROM journal_high_water WHERE agent_id = ?`
|
|
1051
|
+
).get(agentId);
|
|
1052
|
+
return row === void 0 ? null : {
|
|
1053
|
+
rawPosition: row.raw_position,
|
|
1054
|
+
eventPosition: row.event_position,
|
|
1055
|
+
idleEventPosition: row.idle_event_position
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
#assertPositiveLimit(limit) {
|
|
1059
|
+
if (!Number.isSafeInteger(limit) || limit <= 0)
|
|
1060
|
+
throw new Error("Journal range limit must be positive");
|
|
1061
|
+
}
|
|
1062
|
+
#assertOpen() {
|
|
1063
|
+
if (this.#closed) throw new Error("pi-fleet journal store is closed");
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
function positiveInteger(value, label) {
|
|
1067
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
1068
|
+
throw new Error(`${label} must be a positive integer`);
|
|
1069
|
+
}
|
|
1070
|
+
return value;
|
|
1071
|
+
}
|
|
1072
|
+
function pragmaInteger(database, name) {
|
|
1073
|
+
const row = database.prepare(`PRAGMA ${name}`).get();
|
|
1074
|
+
const value = row?.[name];
|
|
1075
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
1076
|
+
throw new Error(`SQLite PRAGMA ${name} returned an invalid value`);
|
|
1077
|
+
}
|
|
1078
|
+
return value;
|
|
1079
|
+
}
|
|
1080
|
+
function autoVacuumMode(value) {
|
|
1081
|
+
if (value === 0) return "none";
|
|
1082
|
+
if (value === 1) return "full";
|
|
1083
|
+
if (value === 2) return "incremental";
|
|
1084
|
+
throw new Error("SQLite returned an unsupported auto_vacuum mode");
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// src/store/journal-sqlite-worker.ts
|
|
1088
|
+
var port = parentPort;
|
|
1089
|
+
if (port === null) throw new Error("Journal SQLite worker requires a parent port");
|
|
1090
|
+
var options = workerData;
|
|
1091
|
+
if (typeof options.path !== "string") {
|
|
1092
|
+
throw new Error("Journal SQLite worker requires a database path");
|
|
1093
|
+
}
|
|
1094
|
+
if (typeof options.checkpointCommitInterval !== "number" || typeof options.reclaimPagesPerPass !== "number") {
|
|
1095
|
+
throw new Error("Journal SQLite worker requires maintenance limits");
|
|
1096
|
+
}
|
|
1097
|
+
var store = new SqliteJournalStore(options.path, {
|
|
1098
|
+
checkpointCommitInterval: options.checkpointCommitInterval,
|
|
1099
|
+
reclaimPagesPerPass: options.reclaimPagesPerPass
|
|
1100
|
+
});
|
|
1101
|
+
port.on("message", (request) => {
|
|
1102
|
+
void dispatchJournalStoreRequest(store, request).then((value) => port.postMessage({ id: request.id, ok: true, value })).catch(
|
|
1103
|
+
(error) => port.postMessage({
|
|
1104
|
+
id: request.id,
|
|
1105
|
+
ok: false,
|
|
1106
|
+
error: error instanceof Error ? error.message : "Journal SQLite worker failed"
|
|
1107
|
+
})
|
|
1108
|
+
);
|
|
1109
|
+
});
|
|
1110
|
+
//# sourceMappingURL=journal-sqlite-worker.mjs.map
|