@voybio/ace-swarm 2.4.3 → 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.
Files changed (144) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +72 -532
  3. package/assets/agent-state/INTERFACE_REGISTRY.md +2 -2
  4. package/assets/agent-state/MODULES/gates/gate-autonomy.json +4 -2
  5. package/assets/agent-state/MODULES/gates/gate-completeness.json +4 -2
  6. package/assets/agent-state/MODULES/gates/gate-operability.json +4 -2
  7. package/assets/agent-state/MODULES/gates/gate-security.json +3 -1
  8. package/assets/agent-state/MODULES/registry.json +1 -4
  9. package/assets/agent-state/MODULES/roles/capability-build.json +1 -2
  10. package/assets/agent-state/MODULES/roles/capability-eval.json +1 -1
  11. package/assets/agent-state/MODULES/roles/capability-qa.json +1 -2
  12. package/assets/agent-state/QUALITY_GATES.md +25 -9
  13. package/assets/agent-state/TEAL_CONFIG.md +2 -11
  14. package/assets/eval-battery/battery.json +85 -0
  15. package/assets/eval-battery/fixtures/edit-target.js +3 -0
  16. package/assets/scripts/render-mcp-configs.sh +12 -0
  17. package/dist/ace-context.d.ts +18 -0
  18. package/dist/ace-context.js +161 -16
  19. package/dist/ace-server-instructions.js +36 -9
  20. package/dist/astgrep-index.d.ts +7 -1
  21. package/dist/astgrep-index.js +66 -39
  22. package/dist/bridge-trace.d.ts +17 -0
  23. package/dist/bridge-trace.js +23 -0
  24. package/dist/cli.d.ts +9 -1
  25. package/dist/cli.js +619 -10
  26. package/dist/discovery-runtime-wrappers.d.ts +16 -1
  27. package/dist/discovery-runtime-wrappers.js +65 -9
  28. package/dist/eval-harness.d.ts +110 -0
  29. package/dist/eval-harness.js +254 -0
  30. package/dist/experience-reflector.d.ts +13 -0
  31. package/dist/experience-reflector.js +62 -0
  32. package/dist/gate-contract.d.ts +63 -0
  33. package/dist/gate-contract.js +178 -0
  34. package/dist/goal-engine.d.ts +155 -0
  35. package/dist/goal-engine.js +795 -0
  36. package/dist/helpers/store-resolution.js +40 -39
  37. package/dist/hermes/session-manager.d.ts +10 -0
  38. package/dist/hermes/session-manager.js +130 -27
  39. package/dist/job-scheduler.d.ts +58 -0
  40. package/dist/job-scheduler.js +202 -2
  41. package/dist/job-worker.d.ts +87 -0
  42. package/dist/job-worker.js +182 -0
  43. package/dist/local-model-policy.d.ts +8 -2
  44. package/dist/local-model-policy.js +31 -17
  45. package/dist/local-model-runtime.d.ts +6 -0
  46. package/dist/local-model-runtime.js +130 -2
  47. package/dist/measured-classing.d.ts +66 -0
  48. package/dist/measured-classing.js +165 -0
  49. package/dist/model-bridge.d.ts +45 -0
  50. package/dist/model-bridge.js +502 -44
  51. package/dist/orchestrator-supervisor.js +1 -1
  52. package/dist/plan-proposal.d.ts +7 -0
  53. package/dist/plan-proposal.js +74 -4
  54. package/dist/playbook-adaptation.d.ts +37 -0
  55. package/dist/playbook-adaptation.js +159 -0
  56. package/dist/playbook-curator.d.ts +16 -0
  57. package/dist/playbook-curator.js +106 -0
  58. package/dist/playbook-llm-reflector.d.ts +26 -0
  59. package/dist/playbook-llm-reflector.js +157 -0
  60. package/dist/public-surface.d.ts +2 -0
  61. package/dist/public-surface.js +26 -14
  62. package/dist/runtime-executor.js +45 -2
  63. package/dist/runtime-tool-specs.d.ts +8 -1
  64. package/dist/runtime-tool-specs.js +19 -1
  65. package/dist/safe-edit.d.ts +20 -0
  66. package/dist/safe-edit.js +355 -78
  67. package/dist/schemas.js +16 -15
  68. package/dist/server.d.ts +11 -2
  69. package/dist/server.js +55 -5
  70. package/dist/shared.d.ts +12 -0
  71. package/dist/shared.js +13 -1
  72. package/dist/source-mutation-coordinator.d.ts +54 -0
  73. package/dist/source-mutation-coordinator.js +259 -0
  74. package/dist/status-events.d.ts +4 -1
  75. package/dist/status-events.js +41 -6
  76. package/dist/store/ace-packed-store.d.ts +28 -9
  77. package/dist/store/ace-packed-store.js +324 -57
  78. package/dist/store/bgzf.d.ts +4 -0
  79. package/dist/store/bgzf.js +94 -0
  80. package/dist/store/bootstrap-store.d.ts +4 -1
  81. package/dist/store/bootstrap-store.js +87 -9
  82. package/dist/store/cache-workspace.d.ts +8 -0
  83. package/dist/store/cache-workspace.js +67 -1
  84. package/dist/store/crc32.d.ts +2 -0
  85. package/dist/store/crc32.js +16 -0
  86. package/dist/store/fsck.d.ts +16 -0
  87. package/dist/store/fsck.js +240 -0
  88. package/dist/store/gate-contract-migration.d.ts +10 -0
  89. package/dist/store/gate-contract-migration.js +413 -0
  90. package/dist/store/materializers/context-snapshot-materializer.d.ts +1 -0
  91. package/dist/store/materializers/context-snapshot-materializer.js +4 -1
  92. package/dist/store/materializers/projection-manager.d.ts +7 -0
  93. package/dist/store/materializers/projection-manager.js +37 -0
  94. package/dist/store/materializers/scheduler-projection-materializer.js +10 -3
  95. package/dist/store/repositories/goal-repository.d.ts +112 -0
  96. package/dist/store/repositories/goal-repository.js +157 -0
  97. package/dist/store/repositories/playbook-repository.d.ts +107 -0
  98. package/dist/store/repositories/playbook-repository.js +314 -0
  99. package/dist/store/store-artifacts.js +44 -2
  100. package/dist/store/store-authority-audit.js +1 -0
  101. package/dist/store/store-cli.d.ts +30 -0
  102. package/dist/store/store-cli.js +242 -0
  103. package/dist/store/store-snapshot.d.ts +12 -0
  104. package/dist/store/store-snapshot.js +74 -11
  105. package/dist/store/types.d.ts +1 -0
  106. package/dist/store/write-coordinator.d.ts +7 -0
  107. package/dist/store/write-coordinator.js +11 -0
  108. package/dist/todo-state.d.ts +21 -0
  109. package/dist/todo-state.js +150 -53
  110. package/dist/tools-agent.js +86 -22
  111. package/dist/tools-discovery.js +5 -3
  112. package/dist/tools-files.js +107 -23
  113. package/dist/tools-framework.js +146 -40
  114. package/dist/tools-goal.d.ts +11 -0
  115. package/dist/tools-goal.js +108 -0
  116. package/dist/tools-handoff.js +7 -11
  117. package/dist/tools-lifecycle.js +2 -0
  118. package/dist/tools-scheduler.js +44 -1
  119. package/dist/tools-skills.js +3 -2
  120. package/dist/tools-todo.js +64 -3
  121. package/dist/tools.js +2 -0
  122. package/dist/tui/chat.js +4 -0
  123. package/dist/tui/index.js +4 -1
  124. package/dist/tui/ollama.d.ts +2 -0
  125. package/dist/tui/openai-compatible.d.ts +14 -0
  126. package/dist/tui/openai-compatible.js +15 -2
  127. package/dist/tui/provider-discovery.d.ts +1 -0
  128. package/dist/tui/provider-discovery.js +2 -2
  129. package/docs/cli-reference.md +125 -0
  130. package/docs/development.md +46 -0
  131. package/docs/evaluation-safety.md +27 -0
  132. package/docs/experience-playbook.md +35 -0
  133. package/docs/hermes.md +129 -0
  134. package/docs/library-api.md +31 -0
  135. package/docs/mcp-tool-reference.md +290 -0
  136. package/docs/provider-security.md +85 -0
  137. package/docs/state-model.md +113 -0
  138. package/docs/troubleshooting.md +95 -0
  139. package/docs/tui-operator-guide.md +75 -0
  140. package/package.json +5 -1
  141. package/scripts/dev/run_hermes_crispr_live.mjs +212 -0
  142. package/assets/agent-state/MODULES/gates/gate-correctness.json +0 -7
  143. package/assets/agent-state/MODULES/gates/gate-evaluation.json +0 -7
  144. package/assets/agent-state/MODULES/gates/gate-typescript-public-surface.json +0 -7
@@ -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
- * Events grow in the columnar section indefinitely (Option A). compact() only
6
- * reclaims dead space in the KV chunk region — it does NOT reset or archive
7
- * events. Historical event replay across all past runs is always available.
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() — they accumulate for the workspace
49
- * lifetime. See HOT_COLD_EVENT_TIERING.md for a future branch that adds
50
- * BGZF batch archiving for very long-lived workspaces.
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
- pos += 4; // pool_length (implicit from payLens sum)
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
- if (start < 0 || end > file.length) {
215
- throw new Error(`AcePackedStore: KV entry for '${key}' exceeds file bounds`);
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 file.slice(start, end);
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("AcePackedStore: unexpected EOF");
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 = loadCommittedStateFromBytes(new Uint8Array(currentFile.buffer, currentFile.byteOffset, currentFile.byteLength));
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
- const value = readKvBlobFromBytes(currentFile, latest.kvIndex, key);
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 NOT reset or archived; they accumulate in the columnar section
425
- * for the workspace lifetime (Option A — grow forever).
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 = loadCommittedStateFromBytes(new Uint8Array(srcFile.buffer, srcFile.byteOffset, srcFile.byteLength));
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
- const chunk = readKvBlobFromBytes(srcFile, latest.kvIndex, key);
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
- if (this.dirty) {
496
- await this.commit();
603
+ let commitError;
604
+ try {
605
+ if (this.dirty) {
606
+ await this.commit();
607
+ }
497
608
  }
498
- if (this.fh) {
499
- await this.fh.close();
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 getEntries(filter) {
583
- const all = [...this.committed, ...this.pending];
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
- let id = this.evtBaseId + 1;
586
- for (const rec of all) {
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
- const entry = {
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
- if (filter?.content_source !== undefined && entry.content_source !== filter.content_source)
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
- if (filter?.keyPrefix && !entry.key.startsWith(filter.keyPrefix))
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
- if (filter?.since !== undefined && entry.ts < filter.since)
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
- if (!filter?.includeDeleted && (entry.flags ?? 0) & 0x01)
879
+ }
880
+ if (!entryMatchesFilter(entry, filter))
614
881
  continue;
615
882
  results.push(entry);
616
883
  }
@@ -0,0 +1,4 @@
1
+ export declare function bgzfEofBlock(): Uint8Array;
2
+ export declare function bgzfCompress(uncompressed: Uint8Array): Uint8Array;
3
+ export declare function bgzfDecompress(compressed: Uint8Array): Uint8Array;
4
+ //# sourceMappingURL=bgzf.d.ts.map
@@ -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): Promise<{
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