@voybio/ace-swarm 2.4.4 → 2.4.6
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 +69 -0
- package/README.md +72 -532
- package/assets/eval-battery/battery.json +85 -0
- package/assets/eval-battery/fixtures/edit-target.js +3 -0
- package/assets/scripts/render-mcp-configs.sh +12 -0
- package/dist/ace-context.d.ts +18 -0
- package/dist/ace-context.js +161 -16
- package/dist/ace-server-instructions.js +36 -9
- package/dist/bridge-trace.d.ts +17 -0
- package/dist/bridge-trace.js +23 -0
- package/dist/cli.d.ts +9 -1
- package/dist/cli.js +619 -10
- package/dist/discovery-runtime-wrappers.d.ts +16 -1
- package/dist/discovery-runtime-wrappers.js +65 -9
- package/dist/eval-harness.d.ts +110 -0
- package/dist/eval-harness.js +254 -0
- package/dist/experience-reflector.d.ts +13 -0
- package/dist/experience-reflector.js +62 -0
- package/dist/goal-engine.d.ts +155 -0
- package/dist/goal-engine.js +795 -0
- package/dist/helpers/store-resolution.js +40 -39
- package/dist/hermes/session-manager.d.ts +10 -0
- package/dist/hermes/session-manager.js +130 -27
- package/dist/job-scheduler.d.ts +58 -0
- package/dist/job-scheduler.js +202 -2
- package/dist/job-worker.d.ts +87 -0
- package/dist/job-worker.js +182 -0
- package/dist/local-model-policy.d.ts +8 -2
- package/dist/local-model-policy.js +31 -17
- package/dist/local-model-runtime.d.ts +6 -0
- package/dist/local-model-runtime.js +130 -2
- package/dist/measured-classing.d.ts +66 -0
- package/dist/measured-classing.js +165 -0
- package/dist/model-bridge.d.ts +45 -0
- package/dist/model-bridge.js +502 -44
- package/dist/orchestrator-supervisor.js +1 -1
- package/dist/plan-proposal.d.ts +7 -0
- package/dist/plan-proposal.js +74 -4
- package/dist/playbook-adaptation.d.ts +37 -0
- package/dist/playbook-adaptation.js +159 -0
- package/dist/playbook-curator.d.ts +16 -0
- package/dist/playbook-curator.js +106 -0
- package/dist/playbook-llm-reflector.d.ts +26 -0
- package/dist/playbook-llm-reflector.js +157 -0
- package/dist/public-surface.d.ts +2 -0
- package/dist/public-surface.js +26 -14
- package/dist/safe-edit.d.ts +20 -0
- package/dist/safe-edit.js +355 -78
- package/dist/server.d.ts +11 -2
- package/dist/server.js +55 -5
- package/dist/shared.d.ts +12 -0
- package/dist/shared.js +13 -1
- package/dist/source-mutation-coordinator.d.ts +54 -0
- package/dist/source-mutation-coordinator.js +259 -0
- package/dist/status-events.d.ts +4 -1
- package/dist/status-events.js +41 -6
- package/dist/store/ace-packed-store.d.ts +28 -9
- package/dist/store/ace-packed-store.js +324 -57
- package/dist/store/bgzf.d.ts +4 -0
- package/dist/store/bgzf.js +94 -0
- package/dist/store/bootstrap-store.d.ts +4 -1
- package/dist/store/bootstrap-store.js +57 -3
- package/dist/store/cache-workspace.d.ts +8 -0
- package/dist/store/cache-workspace.js +67 -1
- package/dist/store/crc32.d.ts +2 -0
- package/dist/store/crc32.js +16 -0
- package/dist/store/fsck.d.ts +16 -0
- package/dist/store/fsck.js +240 -0
- package/dist/store/materializers/context-snapshot-materializer.d.ts +1 -0
- package/dist/store/materializers/context-snapshot-materializer.js +4 -1
- package/dist/store/materializers/projection-manager.d.ts +7 -0
- package/dist/store/materializers/projection-manager.js +37 -0
- package/dist/store/materializers/scheduler-projection-materializer.js +10 -3
- package/dist/store/repositories/goal-repository.d.ts +112 -0
- package/dist/store/repositories/goal-repository.js +157 -0
- package/dist/store/repositories/playbook-repository.d.ts +107 -0
- package/dist/store/repositories/playbook-repository.js +314 -0
- package/dist/store/store-artifacts.js +44 -2
- package/dist/store/store-authority-audit.js +1 -0
- package/dist/store/store-cli.d.ts +30 -0
- package/dist/store/store-cli.js +242 -0
- package/dist/store/store-snapshot.d.ts +12 -0
- package/dist/store/store-snapshot.js +74 -11
- package/dist/store/types.d.ts +1 -0
- package/dist/store/write-coordinator.d.ts +7 -0
- package/dist/store/write-coordinator.js +11 -0
- package/dist/todo-state.d.ts +21 -0
- package/dist/todo-state.js +150 -53
- package/dist/tools-agent.js +86 -22
- package/dist/tools-discovery.js +5 -3
- package/dist/tools-files.js +39 -18
- package/dist/tools-framework.js +31 -3
- package/dist/tools-goal.d.ts +11 -0
- package/dist/tools-goal.js +108 -0
- package/dist/tools-handoff.js +7 -11
- package/dist/tools-lifecycle.js +2 -0
- package/dist/tools-scheduler.js +44 -1
- package/dist/tools-skills.js +3 -2
- package/dist/tools-todo.js +64 -3
- package/dist/tools.js +2 -0
- package/dist/tui/chat.js +4 -0
- package/dist/tui/index.js +4 -1
- package/dist/tui/ollama.d.ts +2 -0
- package/dist/tui/openai-compatible.d.ts +14 -0
- package/dist/tui/openai-compatible.js +15 -2
- package/dist/tui/provider-discovery.d.ts +1 -0
- package/dist/tui/provider-discovery.js +2 -2
- package/docs/cli-reference.md +125 -0
- package/docs/development.md +46 -0
- package/docs/evaluation-safety.md +27 -0
- package/docs/experience-playbook.md +35 -0
- package/docs/hermes.md +129 -0
- package/docs/library-api.md +31 -0
- package/docs/mcp-tool-reference.md +290 -0
- package/docs/provider-security.md +85 -0
- package/docs/state-model.md +113 -0
- package/docs/troubleshooting.md +95 -0
- package/docs/tui-operator-guide.md +75 -0
- package/package.json +3 -1
- package/scripts/dev/run_hermes_crispr_live.mjs +212 -0
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* AcePackedStore — ACEPACK v2
|
|
3
3
|
*
|
|
4
4
|
* Single-file binary store. One file, no subdirectories, no companion files.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Recent events live in the hot columnar section. Older contiguous event
|
|
6
|
+
* prefixes can be moved into BGZF-compressed KV archive batches with
|
|
7
|
+
* archiveColdEvents(); getEntries() reads both layers transparently.
|
|
8
8
|
*
|
|
9
9
|
* File layout:
|
|
10
10
|
*
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
* Timestamp/kind scanning reads only the fixed-width columns, never the pool.
|
|
46
46
|
*
|
|
47
47
|
* compact() removes dead KV space (overwritten keys) via atomic tmp-rename.
|
|
48
|
-
* Events are preserved across compact()
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* Events are preserved across compact(). For very long-lived workspaces,
|
|
49
|
+
* archiveColdEvents() moves old event prefixes into archive/events/* BGZF
|
|
50
|
+
* batches and advances evtBaseId.
|
|
51
51
|
*
|
|
52
52
|
* Backward compat: v1 files (64-byte header) are migrated on first commit().
|
|
53
53
|
* v1 events live in core/log/ KV blobs and are pulled into the columnar section.
|
|
@@ -56,7 +56,16 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "
|
|
|
56
56
|
import { open as fsOpen } from "node:fs/promises";
|
|
57
57
|
import { dirname } from "node:path";
|
|
58
58
|
import { MAGIC, STORE_VERSION, HEADER_SIZE, HEADER_SIZE_V1, ContentSource, EntityKind, } from "./types.js";
|
|
59
|
-
import { invalidateStoreSnapshotCache } from "./store-snapshot.js";
|
|
59
|
+
import { invalidateStoreSnapshotCache, validateChunkBounds } from "./store-snapshot.js";
|
|
60
|
+
import { crc32 } from "./crc32.js";
|
|
61
|
+
import { bgzfCompress, bgzfDecompress } from "./bgzf.js";
|
|
62
|
+
// ── Corruption error ─────────────────────────────────────────────────────────
|
|
63
|
+
export class AcePackedStoreCorruptionError extends Error {
|
|
64
|
+
constructor(message) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "AcePackedStoreCorruptionError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
60
69
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
61
70
|
// Header field byte offsets (big-endian throughout)
|
|
62
71
|
const H_KV_IDX_OFF = 16; // uint64: KV index offset
|
|
@@ -118,6 +127,9 @@ function readHeaderV2(buf) {
|
|
|
118
127
|
evtBaseId: isV2 ? readU32(buf, H_EVT_BASE) : 0,
|
|
119
128
|
};
|
|
120
129
|
}
|
|
130
|
+
const ARCHIVE_INDEX_KEY = "archive/events/__index";
|
|
131
|
+
const DEFAULT_ARCHIVE_OLDER_THAN_MS = 7 * 24 * 60 * 60 * 1000;
|
|
132
|
+
const DEFAULT_ARCHIVE_BATCH_SIZE = 5_000;
|
|
121
133
|
// ── Columnar event section serialiser ────────────────────────────────────────
|
|
122
134
|
function serializeEventSection(events) {
|
|
123
135
|
const N = events.length;
|
|
@@ -162,6 +174,13 @@ function serializeEventSection(events) {
|
|
|
162
174
|
}
|
|
163
175
|
return buf;
|
|
164
176
|
}
|
|
177
|
+
/** Minimum byte length an event section header must have to hold N events. */
|
|
178
|
+
function minEventSectionLength(N) {
|
|
179
|
+
const pad = (4 - (N % 4)) % 4;
|
|
180
|
+
// [4] count + [8N] ts + [N] kind + [N] source + [N] flags + [pad]
|
|
181
|
+
// + [4N] pay_off + [4N] pay_len + [4] pool_len (payload pool bytes checked per-record below)
|
|
182
|
+
return 4 + 8 * N + N + N + N + pad + 4 * N + 4 * N + 4;
|
|
183
|
+
}
|
|
165
184
|
function deserializeEventSection(buf) {
|
|
166
185
|
if (buf.length < 4)
|
|
167
186
|
return [];
|
|
@@ -169,6 +188,10 @@ function deserializeEventSection(buf) {
|
|
|
169
188
|
const N = dv.getUint32(0, false);
|
|
170
189
|
if (N === 0)
|
|
171
190
|
return [];
|
|
191
|
+
const minLength = minEventSectionLength(N);
|
|
192
|
+
if (N < 0 || !Number.isFinite(N) || buf.length < minLength) {
|
|
193
|
+
throw new AcePackedStoreCorruptionError(`AcePackedStore: event section corrupt — count=${N} requires >= ${minLength} bytes, got ${buf.length}`);
|
|
194
|
+
}
|
|
172
195
|
let pos = 4;
|
|
173
196
|
const ts = [];
|
|
174
197
|
for (let i = 0; i < N; i++) {
|
|
@@ -195,8 +218,17 @@ function deserializeEventSection(buf) {
|
|
|
195
218
|
payLens.push(dv.getUint32(pos, false));
|
|
196
219
|
pos += 4;
|
|
197
220
|
}
|
|
198
|
-
|
|
221
|
+
const poolLength = dv.getUint32(pos, false);
|
|
222
|
+
pos += 4;
|
|
199
223
|
const poolStart = pos;
|
|
224
|
+
if (buf.length < poolStart + poolLength) {
|
|
225
|
+
throw new AcePackedStoreCorruptionError(`AcePackedStore: event section pool truncated — declared pool_length=${poolLength}, available=${buf.length - poolStart}`);
|
|
226
|
+
}
|
|
227
|
+
for (let i = 0; i < N; i++) {
|
|
228
|
+
if (payOffs[i] < 0 || payLens[i] < 0 || payOffs[i] + payLens[i] > poolLength) {
|
|
229
|
+
throw new AcePackedStoreCorruptionError(`AcePackedStore: event section record ${i} payload exceeds pool bounds (offset=${payOffs[i]}, length=${payLens[i]}, pool=${poolLength})`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
200
232
|
return Array.from({ length: N }, (_, i) => ({
|
|
201
233
|
ts: ts[i],
|
|
202
234
|
kind: kinds[i],
|
|
@@ -209,12 +241,20 @@ function readKvBlobFromBytes(file, index, key) {
|
|
|
209
241
|
const entry = index.get(key);
|
|
210
242
|
if (!entry)
|
|
211
243
|
return undefined;
|
|
244
|
+
if (!validateChunkBounds(entry, file.length)) {
|
|
245
|
+
throw new AcePackedStoreCorruptionError(`AcePackedStore: KV entry for '${key}' exceeds file bounds (offset=${entry.offset}, length=${entry.length}, fileSize=${file.length})`);
|
|
246
|
+
}
|
|
247
|
+
const prefixed = readU32(file, entry.offset);
|
|
248
|
+
if (prefixed !== entry.length) {
|
|
249
|
+
throw new AcePackedStoreCorruptionError(`AcePackedStore: KV entry for '${key}' length-prefix mismatch (index=${entry.length}, stored=${prefixed})`);
|
|
250
|
+
}
|
|
212
251
|
const start = entry.offset + 4;
|
|
213
252
|
const end = start + entry.length;
|
|
214
|
-
|
|
215
|
-
|
|
253
|
+
const chunk = file.slice(start, end);
|
|
254
|
+
if (typeof entry.crc === "number" && crc32(chunk) !== entry.crc) {
|
|
255
|
+
throw new AcePackedStoreCorruptionError(`AcePackedStore: KV entry for '${key}' CRC mismatch (expected=${entry.crc}, actual=${crc32(chunk)})`);
|
|
216
256
|
}
|
|
217
|
-
return
|
|
257
|
+
return chunk;
|
|
218
258
|
}
|
|
219
259
|
function loadCommittedStateFromBytes(file) {
|
|
220
260
|
if (file.length < HEADER_SIZE_V1)
|
|
@@ -229,6 +269,7 @@ function loadCommittedStateFromBytes(file) {
|
|
|
229
269
|
.filter((key) => key.startsWith("core/log/") && key !== "core/log/__seq")
|
|
230
270
|
.sort();
|
|
231
271
|
const committed = [];
|
|
272
|
+
let skippedMalformed = 0;
|
|
232
273
|
for (const key of logKeys) {
|
|
233
274
|
const entry = readKvBlobFromBytes(file, kvIndex, key);
|
|
234
275
|
if (!entry)
|
|
@@ -244,9 +285,13 @@ function loadCommittedStateFromBytes(file) {
|
|
|
244
285
|
});
|
|
245
286
|
}
|
|
246
287
|
catch {
|
|
247
|
-
// Skip malformed legacy events during migration.
|
|
288
|
+
// Skip malformed legacy events during migration — availability over strictness, but count it.
|
|
289
|
+
skippedMalformed += 1;
|
|
248
290
|
}
|
|
249
291
|
}
|
|
292
|
+
if (skippedMalformed > 0) {
|
|
293
|
+
console.error(JSON.stringify({ event: "store_v1_migration_skipped_malformed", count: skippedMalformed }));
|
|
294
|
+
}
|
|
250
295
|
for (const key of [...kvIndex.keys()]) {
|
|
251
296
|
if (key.startsWith("core/log/") || key === "meta/log_entry_seq") {
|
|
252
297
|
kvIndex.delete(key);
|
|
@@ -257,6 +302,7 @@ function loadCommittedStateFromBytes(file) {
|
|
|
257
302
|
kvChunkEnd: hdr.kvChunkEnd,
|
|
258
303
|
committed,
|
|
259
304
|
evtBaseId: 0,
|
|
305
|
+
skippedMalformed,
|
|
260
306
|
};
|
|
261
307
|
}
|
|
262
308
|
return {
|
|
@@ -266,15 +312,63 @@ function loadCommittedStateFromBytes(file) {
|
|
|
266
312
|
? deserializeEventSection(file.slice(hdr.evtOffset, hdr.evtOffset + hdr.evtLength))
|
|
267
313
|
: [],
|
|
268
314
|
evtBaseId: hdr.evtBaseId,
|
|
315
|
+
skippedMalformed: 0,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function quarantineCorruptSource(storePath, file, operation, error) {
|
|
319
|
+
const quarantinePath = `${storePath}.corrupt-${Date.now()}`;
|
|
320
|
+
writeFileSync(quarantinePath, file);
|
|
321
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
322
|
+
throw new AcePackedStoreCorruptionError(`AcePackedStore ${operation} refused: corrupt source store quarantined at ${quarantinePath}; original left in place. Run 'ace repair' or 'ace fsck --repair'. Cause: ${message}`);
|
|
323
|
+
}
|
|
324
|
+
function loadCommittedStateOrQuarantine(storePath, file, operation) {
|
|
325
|
+
try {
|
|
326
|
+
return loadCommittedStateFromBytes(file);
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
quarantineCorruptSource(storePath, file, operation, error);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function eventRecordToEntry(rec, id) {
|
|
333
|
+
let parsed;
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(rec.blob);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
id,
|
|
342
|
+
ts: rec.ts,
|
|
343
|
+
kind: rec.kind,
|
|
344
|
+
content_source: rec.source,
|
|
345
|
+
key: parsed.key,
|
|
346
|
+
payload: parsed.payload,
|
|
347
|
+
parent_id: parsed.parent_id,
|
|
348
|
+
flags: rec.flags,
|
|
269
349
|
};
|
|
270
350
|
}
|
|
351
|
+
function entryMatchesFilter(entry, filter) {
|
|
352
|
+
if (filter?.kind !== undefined && entry.kind !== filter.kind)
|
|
353
|
+
return false;
|
|
354
|
+
if (filter?.content_source !== undefined && entry.content_source !== filter.content_source)
|
|
355
|
+
return false;
|
|
356
|
+
if (filter?.keyPrefix && !entry.key.startsWith(filter.keyPrefix))
|
|
357
|
+
return false;
|
|
358
|
+
if (filter?.since !== undefined && entry.ts < filter.since)
|
|
359
|
+
return false;
|
|
360
|
+
if (!filter?.includeDeleted && (entry.flags ?? 0) & 0x01)
|
|
361
|
+
return false;
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
271
364
|
// ── File I/O helpers ──────────────────────────────────────────────────────────
|
|
272
|
-
async function readExact(fh, buf, position) {
|
|
365
|
+
async function readExact(fh, buf, position, context = {}) {
|
|
273
366
|
let off = 0;
|
|
274
367
|
while (off < buf.length) {
|
|
275
368
|
const { bytesRead } = await fh.read(buf, off, buf.length - off, position + off);
|
|
276
|
-
if (bytesRead <= 0)
|
|
277
|
-
throw new Error(
|
|
369
|
+
if (bytesRead <= 0) {
|
|
370
|
+
throw new Error(`AcePackedStore: unexpected EOF reading key=${context.key ?? "<unknown>"} at offset=${position + off}, wanted=${buf.length - off} more bytes, got 0 (total wanted=${buf.length}, read so far=${off})`);
|
|
371
|
+
}
|
|
278
372
|
off += bytesRead;
|
|
279
373
|
}
|
|
280
374
|
}
|
|
@@ -293,6 +387,7 @@ export class AcePackedStore {
|
|
|
293
387
|
committed = []; // loaded from file
|
|
294
388
|
pending = []; // written since last commit
|
|
295
389
|
evtBaseId = 0; // events [0..evtBaseId] are in the archive
|
|
390
|
+
skippedMalformedCount = 0; // events dropped as malformed since open() — observable data loss
|
|
296
391
|
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
|
297
392
|
async open(path, opts = {}) {
|
|
298
393
|
this.storePath = path;
|
|
@@ -345,6 +440,7 @@ export class AcePackedStore {
|
|
|
345
440
|
this.kvChunkEnd = loaded.kvChunkEnd;
|
|
346
441
|
this.committed = loaded.committed;
|
|
347
442
|
this.evtBaseId = loaded.evtBaseId;
|
|
443
|
+
this.skippedMalformedCount += loaded.skippedMalformed;
|
|
348
444
|
this.pending = [];
|
|
349
445
|
this.pendingKv.clear();
|
|
350
446
|
this.pendingDeletes.clear();
|
|
@@ -354,7 +450,8 @@ export class AcePackedStore {
|
|
|
354
450
|
if (this.readOnly || !this.fh || !this.storePath || !this.dirty)
|
|
355
451
|
return;
|
|
356
452
|
const currentFile = readFileSync(this.storePath);
|
|
357
|
-
const latest =
|
|
453
|
+
const latest = loadCommittedStateOrQuarantine(this.storePath, new Uint8Array(currentFile.buffer, currentFile.byteOffset, currentFile.byteLength), "commit");
|
|
454
|
+
this.skippedMalformedCount += latest.skippedMalformed;
|
|
358
455
|
const nextCommitted = [...latest.committed, ...this.pending];
|
|
359
456
|
const evtBytes = serializeEventSection(nextCommitted);
|
|
360
457
|
const nextKvIndex = new Map();
|
|
@@ -363,17 +460,23 @@ export class AcePackedStore {
|
|
|
363
460
|
for (const [key] of latest.kvIndex) {
|
|
364
461
|
if (this.pendingDeletes.has(key) || this.pendingKv.has(key))
|
|
365
462
|
continue;
|
|
366
|
-
|
|
463
|
+
let value;
|
|
464
|
+
try {
|
|
465
|
+
value = readKvBlobFromBytes(currentFile, latest.kvIndex, key);
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
quarantineCorruptSource(this.storePath, currentFile, "commit", error);
|
|
469
|
+
}
|
|
367
470
|
if (!value)
|
|
368
471
|
continue;
|
|
369
472
|
liveChunks.push({ value });
|
|
370
|
-
nextKvIndex.set(key, { offset: writeOff, length: value.length });
|
|
473
|
+
nextKvIndex.set(key, { offset: writeOff, length: value.length, crc: crc32(value) });
|
|
371
474
|
writeOff += 4 + value.length;
|
|
372
475
|
}
|
|
373
476
|
for (const [key, value] of this.pendingKv) {
|
|
374
477
|
const blob = value.slice();
|
|
375
478
|
liveChunks.push({ value: blob });
|
|
376
|
-
nextKvIndex.set(key, { offset: writeOff, length: blob.length });
|
|
479
|
+
nextKvIndex.set(key, { offset: writeOff, length: blob.length, crc: crc32(blob) });
|
|
377
480
|
writeOff += 4 + blob.length;
|
|
378
481
|
}
|
|
379
482
|
const idxJson = JSON.stringify(Object.fromEntries(nextKvIndex));
|
|
@@ -421,9 +524,8 @@ export class AcePackedStore {
|
|
|
421
524
|
}
|
|
422
525
|
/**
|
|
423
526
|
* Compacts the KV chunk region — removes dead space left by overwritten keys.
|
|
424
|
-
* Events are
|
|
425
|
-
*
|
|
426
|
-
* See HOT_COLD_EVENT_TIERING.md for the future branch that adds BGZF archiving.
|
|
527
|
+
* Events are preserved exactly as currently tiered. Use archiveColdEvents()
|
|
528
|
+
* first when old hot events should move into BGZF archive batches.
|
|
427
529
|
*/
|
|
428
530
|
async compact() {
|
|
429
531
|
if (!this.storePath || this.readOnly)
|
|
@@ -433,16 +535,22 @@ export class AcePackedStore {
|
|
|
433
535
|
}
|
|
434
536
|
// Rebuild KV chunk region with only live keys (removes dead space)
|
|
435
537
|
const srcFile = readFileSync(this.storePath);
|
|
436
|
-
const latest =
|
|
538
|
+
const latest = loadCommittedStateOrQuarantine(this.storePath, new Uint8Array(srcFile.buffer, srcFile.byteOffset, srcFile.byteLength), "compact");
|
|
437
539
|
const liveKv = new Map();
|
|
438
540
|
const chunks = [];
|
|
439
541
|
let writeOff = HEADER_SIZE;
|
|
440
542
|
for (const [key] of latest.kvIndex) {
|
|
441
|
-
|
|
543
|
+
let chunk;
|
|
544
|
+
try {
|
|
545
|
+
chunk = readKvBlobFromBytes(srcFile, latest.kvIndex, key);
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
quarantineCorruptSource(this.storePath, srcFile, "compact", error);
|
|
549
|
+
}
|
|
442
550
|
if (!chunk)
|
|
443
551
|
continue;
|
|
444
552
|
chunks.push(chunk);
|
|
445
|
-
liveKv.set(key, { offset: writeOff, length: chunk.length });
|
|
553
|
+
liveKv.set(key, { offset: writeOff, length: chunk.length, crc: crc32(chunk) });
|
|
446
554
|
writeOff += 4 + chunk.length;
|
|
447
555
|
}
|
|
448
556
|
this.kvIndex = liveKv;
|
|
@@ -492,13 +600,30 @@ export class AcePackedStore {
|
|
|
492
600
|
this.dirty = false;
|
|
493
601
|
}
|
|
494
602
|
async close() {
|
|
495
|
-
|
|
496
|
-
|
|
603
|
+
let commitError;
|
|
604
|
+
try {
|
|
605
|
+
if (this.dirty) {
|
|
606
|
+
await this.commit();
|
|
607
|
+
}
|
|
497
608
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
this.fh = null;
|
|
609
|
+
catch (error) {
|
|
610
|
+
commitError = error;
|
|
501
611
|
}
|
|
612
|
+
finally {
|
|
613
|
+
// A corrupt-source commit can fail before commit() closes the handle. Do
|
|
614
|
+
// not rely on FileHandle garbage collection: Node warns that this becomes
|
|
615
|
+
// an error in a future release, and long-lived processes would leak fds.
|
|
616
|
+
if (this.fh) {
|
|
617
|
+
try {
|
|
618
|
+
await this.fh.close();
|
|
619
|
+
}
|
|
620
|
+
finally {
|
|
621
|
+
this.fh = null;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (commitError !== undefined)
|
|
626
|
+
throw commitError;
|
|
502
627
|
}
|
|
503
628
|
// ── Core KV ─────────────────────────────────────────────────────────────────
|
|
504
629
|
async get(key) {
|
|
@@ -511,10 +636,21 @@ export class AcePackedStore {
|
|
|
511
636
|
const entry = this.kvIndex.get(key);
|
|
512
637
|
if (!entry || !this.fh)
|
|
513
638
|
return undefined;
|
|
639
|
+
const stats = await this.fh.stat();
|
|
640
|
+
if (!validateChunkBounds(entry, stats.size)) {
|
|
641
|
+
throw new AcePackedStoreCorruptionError(`AcePackedStore: KV entry for '${key}' exceeds file bounds (offset=${entry.offset}, length=${entry.length}, fileSize=${stats.size})`);
|
|
642
|
+
}
|
|
514
643
|
const buf = new Uint8Array(entry.length);
|
|
515
|
-
await readExact(this.fh, buf, entry.offset + 4);
|
|
644
|
+
await readExact(this.fh, buf, entry.offset + 4, { key });
|
|
645
|
+
if (typeof entry.crc === "number" && crc32(buf) !== entry.crc) {
|
|
646
|
+
throw new AcePackedStoreCorruptionError(`AcePackedStore: KV entry for '${key}' CRC mismatch (expected=${entry.crc}, actual=${crc32(buf)})`);
|
|
647
|
+
}
|
|
516
648
|
return buf;
|
|
517
649
|
}
|
|
650
|
+
/** Events dropped as malformed since this instance was opened (v1 migration only). */
|
|
651
|
+
get skippedMalformedEventCount() {
|
|
652
|
+
return this.skippedMalformedCount;
|
|
653
|
+
}
|
|
518
654
|
async set(key, value) {
|
|
519
655
|
if (this.readOnly)
|
|
520
656
|
throw new Error("AcePackedStore: read-only");
|
|
@@ -579,38 +715,169 @@ export class AcePackedStore {
|
|
|
579
715
|
this.dirty = true;
|
|
580
716
|
return { id, ts, kind: entry.kind, content_source: entry.content_source, key: entry.key, payload: entry.payload, flags: entry.flags };
|
|
581
717
|
}
|
|
582
|
-
async
|
|
583
|
-
|
|
718
|
+
async readArchiveIndex() {
|
|
719
|
+
return (await this.getJSON(ARCHIVE_INDEX_KEY)) ?? { version: 1, batches: [] };
|
|
720
|
+
}
|
|
721
|
+
async getArchivedEntries(filter) {
|
|
722
|
+
const index = await this.readArchiveIndex();
|
|
584
723
|
const results = [];
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
let parsed;
|
|
588
|
-
try {
|
|
589
|
-
parsed = JSON.parse(rec.blob);
|
|
590
|
-
}
|
|
591
|
-
catch {
|
|
592
|
-
id++;
|
|
724
|
+
for (const batch of index.batches) {
|
|
725
|
+
if (filter?.since !== undefined && batch.ts_end < filter.since)
|
|
593
726
|
continue;
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
id: id++,
|
|
597
|
-
ts: rec.ts,
|
|
598
|
-
kind: rec.kind,
|
|
599
|
-
content_source: rec.source,
|
|
600
|
-
key: parsed.key,
|
|
601
|
-
payload: parsed.payload,
|
|
602
|
-
parent_id: parsed.parent_id,
|
|
603
|
-
flags: rec.flags,
|
|
604
|
-
};
|
|
605
|
-
if (filter?.kind !== undefined && entry.kind !== filter.kind)
|
|
727
|
+
const compressed = await this.get(batch.kv_key);
|
|
728
|
+
if (!compressed)
|
|
606
729
|
continue;
|
|
607
|
-
|
|
730
|
+
const events = deserializeEventSection(bgzfDecompress(compressed));
|
|
731
|
+
let id = batch.evt_start;
|
|
732
|
+
for (const rec of events) {
|
|
733
|
+
const entry = eventRecordToEntry(rec, id++);
|
|
734
|
+
if (!entry) {
|
|
735
|
+
this.skippedMalformedCount += 1;
|
|
736
|
+
console.error(JSON.stringify({ event: "store_archive_entry_skipped_malformed", batch_id: batch.batch_id, total: this.skippedMalformedCount }));
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (entryMatchesFilter(entry, filter))
|
|
740
|
+
results.push(entry);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return results;
|
|
744
|
+
}
|
|
745
|
+
async archiveColdEvents(options = {}) {
|
|
746
|
+
if (!this.storePath || this.readOnly) {
|
|
747
|
+
return { batches_written: 0, events_archived: 0, bytes_written: 0 };
|
|
748
|
+
}
|
|
749
|
+
if (this.dirty) {
|
|
750
|
+
await this.commit();
|
|
751
|
+
}
|
|
752
|
+
const now = options.now ?? Date.now();
|
|
753
|
+
const olderThanMs = options.olderThanMs ?? DEFAULT_ARCHIVE_OLDER_THAN_MS;
|
|
754
|
+
const maxEventsPerBatch = Math.max(1, options.maxEventsPerBatch ?? DEFAULT_ARCHIVE_BATCH_SIZE);
|
|
755
|
+
const cutoff = now - olderThanMs;
|
|
756
|
+
const srcFile = readFileSync(this.storePath);
|
|
757
|
+
const latest = loadCommittedStateOrQuarantine(this.storePath, new Uint8Array(srcFile.buffer, srcFile.byteOffset, srcFile.byteLength), "archiveColdEvents");
|
|
758
|
+
let archiveCount = 0;
|
|
759
|
+
while (archiveCount < latest.committed.length && latest.committed[archiveCount]?.ts < cutoff) {
|
|
760
|
+
archiveCount += 1;
|
|
761
|
+
}
|
|
762
|
+
if (archiveCount === 0) {
|
|
763
|
+
return { batches_written: 0, events_archived: 0, bytes_written: 0 };
|
|
764
|
+
}
|
|
765
|
+
const existingIndexRaw = readKvBlobFromBytes(srcFile, latest.kvIndex, ARCHIVE_INDEX_KEY);
|
|
766
|
+
const existingIndex = existingIndexRaw
|
|
767
|
+
? JSON.parse(DECODE.decode(existingIndexRaw))
|
|
768
|
+
: { version: 1, batches: [] };
|
|
769
|
+
const newChunks = new Map();
|
|
770
|
+
const newBatches = [];
|
|
771
|
+
let bytesWritten = 0;
|
|
772
|
+
for (let start = 0; start < archiveCount; start += maxEventsPerBatch) {
|
|
773
|
+
const batchEvents = latest.committed.slice(start, Math.min(start + maxEventsPerBatch, archiveCount));
|
|
774
|
+
const batchSeq = existingIndex.batches.length + newBatches.length + 1;
|
|
775
|
+
const batchId = `batch-${String(batchSeq).padStart(6, "0")}`;
|
|
776
|
+
const kvKey = `archive/events/${batchId}`;
|
|
777
|
+
const raw = serializeEventSection(batchEvents);
|
|
778
|
+
const compressed = bgzfCompress(raw);
|
|
779
|
+
newChunks.set(kvKey, compressed);
|
|
780
|
+
bytesWritten += compressed.length;
|
|
781
|
+
newBatches.push({
|
|
782
|
+
batch_id: batchId,
|
|
783
|
+
kv_key: kvKey,
|
|
784
|
+
evt_start: latest.evtBaseId + start + 1,
|
|
785
|
+
evt_end: latest.evtBaseId + start + batchEvents.length,
|
|
786
|
+
ts_start: batchEvents[0]?.ts ?? 0,
|
|
787
|
+
ts_end: batchEvents.at(-1)?.ts ?? 0,
|
|
788
|
+
count: batchEvents.length,
|
|
789
|
+
compressed_length: compressed.length,
|
|
790
|
+
uncompressed_length: raw.length,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
const nextArchiveIndex = {
|
|
794
|
+
version: 1,
|
|
795
|
+
batches: [...existingIndex.batches, ...newBatches],
|
|
796
|
+
};
|
|
797
|
+
newChunks.set(ARCHIVE_INDEX_KEY, TEXT.encode(JSON.stringify(nextArchiveIndex)));
|
|
798
|
+
const liveKv = new Map();
|
|
799
|
+
const chunks = [];
|
|
800
|
+
let writeOff = HEADER_SIZE;
|
|
801
|
+
for (const [key] of latest.kvIndex) {
|
|
802
|
+
if (newChunks.has(key))
|
|
608
803
|
continue;
|
|
609
|
-
|
|
804
|
+
let chunk;
|
|
805
|
+
try {
|
|
806
|
+
chunk = readKvBlobFromBytes(srcFile, latest.kvIndex, key);
|
|
807
|
+
}
|
|
808
|
+
catch (error) {
|
|
809
|
+
quarantineCorruptSource(this.storePath, srcFile, "archiveColdEvents", error);
|
|
810
|
+
}
|
|
811
|
+
if (!chunk)
|
|
610
812
|
continue;
|
|
611
|
-
|
|
813
|
+
chunks.push({ value: chunk });
|
|
814
|
+
liveKv.set(key, { offset: writeOff, length: chunk.length, crc: crc32(chunk) });
|
|
815
|
+
writeOff += 4 + chunk.length;
|
|
816
|
+
}
|
|
817
|
+
for (const [key, value] of newChunks) {
|
|
818
|
+
chunks.push({ value });
|
|
819
|
+
liveKv.set(key, { offset: writeOff, length: value.length, crc: crc32(value) });
|
|
820
|
+
writeOff += 4 + value.length;
|
|
821
|
+
}
|
|
822
|
+
const hotEvents = latest.committed.slice(archiveCount);
|
|
823
|
+
const evtBytes = serializeEventSection(hotEvents);
|
|
824
|
+
const idxBytes = TEXT.encode(JSON.stringify(Object.fromEntries(liveKv)));
|
|
825
|
+
const evtOff = writeOff;
|
|
826
|
+
const kvIdxOff = evtOff + evtBytes.length;
|
|
827
|
+
const totalSize = kvIdxOff + idxBytes.length;
|
|
828
|
+
const out = new Uint8Array(totalSize);
|
|
829
|
+
const header = new Uint8Array(HEADER_SIZE);
|
|
830
|
+
writeHeaderV2(header, {
|
|
831
|
+
kvIndexOffset: kvIdxOff,
|
|
832
|
+
kvIndexLength: idxBytes.length,
|
|
833
|
+
kvChunkEnd: writeOff,
|
|
834
|
+
evtOffset: evtOff,
|
|
835
|
+
evtLength: evtBytes.length,
|
|
836
|
+
evtCount: hotEvents.length,
|
|
837
|
+
evtBaseId: latest.evtBaseId + archiveCount,
|
|
838
|
+
});
|
|
839
|
+
out.set(header, 0);
|
|
840
|
+
let cursor = HEADER_SIZE;
|
|
841
|
+
for (const chunk of chunks) {
|
|
842
|
+
out.set(u32be(chunk.value.length), cursor);
|
|
843
|
+
out.set(chunk.value, cursor + 4);
|
|
844
|
+
cursor += 4 + chunk.value.length;
|
|
845
|
+
}
|
|
846
|
+
out.set(evtBytes, evtOff);
|
|
847
|
+
out.set(idxBytes, kvIdxOff);
|
|
848
|
+
const tmpPath = `${this.storePath}.${process.pid}.${Date.now()}.archive.tmp`;
|
|
849
|
+
writeFileSync(tmpPath, out);
|
|
850
|
+
readHeaderV2(readFileSync(tmpPath).slice(0, HEADER_SIZE));
|
|
851
|
+
if (this.fh) {
|
|
852
|
+
await this.fh.close();
|
|
853
|
+
this.fh = null;
|
|
854
|
+
}
|
|
855
|
+
renameSync(tmpPath, this.storePath);
|
|
856
|
+
invalidateStoreSnapshotCache(this.storePath);
|
|
857
|
+
this.fh = await fsOpen(this.storePath, "r+");
|
|
858
|
+
this.kvIndex = liveKv;
|
|
859
|
+
this.kvChunkEnd = writeOff;
|
|
860
|
+
this.committed = hotEvents;
|
|
861
|
+
this.pending = [];
|
|
862
|
+
this.pendingKv.clear();
|
|
863
|
+
this.pendingDeletes.clear();
|
|
864
|
+
this.evtBaseId = latest.evtBaseId + archiveCount;
|
|
865
|
+
this.dirty = false;
|
|
866
|
+
return { batches_written: newBatches.length, events_archived: archiveCount, bytes_written: bytesWritten };
|
|
867
|
+
}
|
|
868
|
+
async getEntries(filter) {
|
|
869
|
+
const all = [...this.committed, ...this.pending];
|
|
870
|
+
const results = await this.getArchivedEntries(filter);
|
|
871
|
+
let id = this.evtBaseId + 1;
|
|
872
|
+
for (const rec of all) {
|
|
873
|
+
const currentId = id++;
|
|
874
|
+
const entry = eventRecordToEntry(rec, currentId);
|
|
875
|
+
if (!entry) {
|
|
876
|
+
this.skippedMalformedCount += 1;
|
|
877
|
+
console.error(JSON.stringify({ event: "store_entry_skipped_malformed", id: currentId, total: this.skippedMalformedCount }));
|
|
612
878
|
continue;
|
|
613
|
-
|
|
879
|
+
}
|
|
880
|
+
if (!entryMatchesFilter(entry, filter))
|
|
614
881
|
continue;
|
|
615
882
|
results.push(entry);
|
|
616
883
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
|
2
|
+
import { crc32 } from "./crc32.js";
|
|
3
|
+
const MAX_BGZF_PAYLOAD = 65_535;
|
|
4
|
+
const BGZF_EOF = new Uint8Array([
|
|
5
|
+
31, 139, 8, 4, 0, 0, 0, 0, 0, 255, 6, 0, 66, 67, 2, 0, 27, 0,
|
|
6
|
+
3, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
7
|
+
]);
|
|
8
|
+
function u16le(buf, offset, value) {
|
|
9
|
+
new DataView(buf.buffer, buf.byteOffset).setUint16(offset, value, true);
|
|
10
|
+
}
|
|
11
|
+
function u32le(buf, offset, value) {
|
|
12
|
+
new DataView(buf.buffer, buf.byteOffset).setUint32(offset, value >>> 0, true);
|
|
13
|
+
}
|
|
14
|
+
function readU16le(buf, offset) {
|
|
15
|
+
return new DataView(buf.buffer, buf.byteOffset).getUint16(offset, true);
|
|
16
|
+
}
|
|
17
|
+
function readU32le(buf, offset) {
|
|
18
|
+
return new DataView(buf.buffer, buf.byteOffset).getUint32(offset, true);
|
|
19
|
+
}
|
|
20
|
+
function concat(parts) {
|
|
21
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
22
|
+
const out = new Uint8Array(total);
|
|
23
|
+
let cursor = 0;
|
|
24
|
+
for (const part of parts) {
|
|
25
|
+
out.set(part, cursor);
|
|
26
|
+
cursor += part.length;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function makeBlock(payload) {
|
|
31
|
+
const compressed = deflateRawSync(payload);
|
|
32
|
+
const blockSize = 18 + compressed.length + 8;
|
|
33
|
+
if (blockSize > 65_536) {
|
|
34
|
+
throw new Error(`BGZF block too large after compression (${blockSize} bytes)`);
|
|
35
|
+
}
|
|
36
|
+
const out = new Uint8Array(blockSize);
|
|
37
|
+
out.set([31, 139, 8, 4, 0, 0, 0, 0, 0, 255, 6, 0, 66, 67, 2, 0], 0);
|
|
38
|
+
u16le(out, 16, blockSize - 1);
|
|
39
|
+
out.set(compressed, 18);
|
|
40
|
+
u32le(out, blockSize - 8, crc32(payload));
|
|
41
|
+
u32le(out, blockSize - 4, payload.length);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
export function bgzfEofBlock() {
|
|
45
|
+
return BGZF_EOF.slice();
|
|
46
|
+
}
|
|
47
|
+
export function bgzfCompress(uncompressed) {
|
|
48
|
+
const blocks = [];
|
|
49
|
+
for (let offset = 0; offset < uncompressed.length || offset === 0; offset += MAX_BGZF_PAYLOAD) {
|
|
50
|
+
const chunk = uncompressed.slice(offset, Math.min(offset + MAX_BGZF_PAYLOAD, uncompressed.length));
|
|
51
|
+
blocks.push(makeBlock(chunk));
|
|
52
|
+
if (uncompressed.length === 0)
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
blocks.push(bgzfEofBlock());
|
|
56
|
+
return concat(blocks);
|
|
57
|
+
}
|
|
58
|
+
export function bgzfDecompress(compressed) {
|
|
59
|
+
const chunks = [];
|
|
60
|
+
let offset = 0;
|
|
61
|
+
while (offset < compressed.length) {
|
|
62
|
+
if (compressed.length - offset < 18) {
|
|
63
|
+
throw new Error(`BGZF block truncated at offset ${offset}`);
|
|
64
|
+
}
|
|
65
|
+
if (compressed[offset] !== 31 ||
|
|
66
|
+
compressed[offset + 1] !== 139 ||
|
|
67
|
+
compressed[offset + 2] !== 8 ||
|
|
68
|
+
compressed[offset + 3] !== 4 ||
|
|
69
|
+
compressed[offset + 12] !== 66 ||
|
|
70
|
+
compressed[offset + 13] !== 67) {
|
|
71
|
+
throw new Error(`Invalid BGZF block header at offset ${offset}`);
|
|
72
|
+
}
|
|
73
|
+
const bsize = readU16le(compressed, offset + 16);
|
|
74
|
+
const blockLength = bsize + 1;
|
|
75
|
+
if (blockLength < 26 || offset + blockLength > compressed.length) {
|
|
76
|
+
throw new Error(`BGZF block extends past end of data at offset ${offset}`);
|
|
77
|
+
}
|
|
78
|
+
const compressedPayload = compressed.slice(offset + 18, offset + blockLength - 8);
|
|
79
|
+
const expectedCrc = readU32le(compressed, offset + blockLength - 8);
|
|
80
|
+
const expectedSize = readU32le(compressed, offset + blockLength - 4);
|
|
81
|
+
const inflated = inflateRawSync(compressedPayload);
|
|
82
|
+
if (inflated.length !== expectedSize) {
|
|
83
|
+
throw new Error(`BGZF ISIZE mismatch at offset ${offset} (expected=${expectedSize}, actual=${inflated.length})`);
|
|
84
|
+
}
|
|
85
|
+
if (crc32(inflated) !== expectedCrc) {
|
|
86
|
+
throw new Error(`BGZF CRC32 mismatch at offset ${offset}`);
|
|
87
|
+
}
|
|
88
|
+
if (inflated.length > 0)
|
|
89
|
+
chunks.push(inflated);
|
|
90
|
+
offset += blockLength;
|
|
91
|
+
}
|
|
92
|
+
return concat(chunks);
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=bgzf.js.map
|
|
@@ -41,8 +41,11 @@ export interface BootstrapResult {
|
|
|
41
41
|
}
|
|
42
42
|
export declare function bootstrapStoreWorkspace(opts: BootstrapOptions): Promise<BootstrapResult>;
|
|
43
43
|
export declare function repairWorkspace(workspaceRoot: string): Promise<string[]>;
|
|
44
|
-
export declare function compactWorkspace(workspaceRoot: string
|
|
44
|
+
export declare function compactWorkspace(workspaceRoot: string, options?: {
|
|
45
|
+
sweep?: boolean;
|
|
46
|
+
}): Promise<{
|
|
45
47
|
before: number;
|
|
46
48
|
after: number;
|
|
49
|
+
sweep?: Awaited<ReturnType<typeof import("./cache-workspace.js")["cacheWorkspaceArtifacts"]>>;
|
|
47
50
|
}>;
|
|
48
51
|
//# sourceMappingURL=bootstrap-store.d.ts.map
|