@dzhechkov/harness-core 0.8.30 → 0.8.31

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 (129) hide show
  1. package/.dz-manifest.json +233 -93
  2. package/README.md +269 -0
  3. package/dist/agentdb-index.d.ts +45 -0
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +345 -85
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/agentdb-reindex-marker.d.ts +170 -0
  8. package/dist/agentdb-reindex-marker.d.ts.map +1 -0
  9. package/dist/agentdb-reindex-marker.js +293 -0
  10. package/dist/agentdb-reindex-marker.js.map +1 -0
  11. package/dist/agentdb-snapshot-rotation.d.ts +169 -0
  12. package/dist/agentdb-snapshot-rotation.d.ts.map +1 -0
  13. package/dist/agentdb-snapshot-rotation.js +322 -0
  14. package/dist/agentdb-snapshot-rotation.js.map +1 -0
  15. package/dist/agentdb-snapshot.d.ts +126 -0
  16. package/dist/agentdb-snapshot.d.ts.map +1 -0
  17. package/dist/agentdb-snapshot.js +195 -0
  18. package/dist/agentdb-snapshot.js.map +1 -0
  19. package/dist/apply-leg.d.ts +202 -0
  20. package/dist/apply-leg.d.ts.map +1 -0
  21. package/dist/apply-leg.js +975 -0
  22. package/dist/apply-leg.js.map +1 -0
  23. package/dist/book-kb.d.ts.map +1 -1
  24. package/dist/book-kb.js +57 -1
  25. package/dist/book-kb.js.map +1 -1
  26. package/dist/brain.d.ts +3 -0
  27. package/dist/brain.d.ts.map +1 -1
  28. package/dist/brain.js +1 -1
  29. package/dist/brain.js.map +1 -1
  30. package/dist/cadence.d.ts +30 -1
  31. package/dist/cadence.d.ts.map +1 -1
  32. package/dist/cadence.js +66 -2
  33. package/dist/cadence.js.map +1 -1
  34. package/dist/guard.d.ts +14 -0
  35. package/dist/guard.d.ts.map +1 -1
  36. package/dist/guard.js +36 -0
  37. package/dist/guard.js.map +1 -1
  38. package/dist/index.d.ts +23 -8
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +18 -5
  41. package/dist/index.js.map +1 -1
  42. package/dist/integration-probe-worker.d.ts.map +1 -1
  43. package/dist/integration-probe-worker.js +4 -6
  44. package/dist/integration-probe-worker.js.map +1 -1
  45. package/dist/integrations-verify.d.ts.map +1 -1
  46. package/dist/integrations-verify.js +3 -1
  47. package/dist/integrations-verify.js.map +1 -1
  48. package/dist/managed-hooks.d.ts +11 -2
  49. package/dist/managed-hooks.d.ts.map +1 -1
  50. package/dist/managed-hooks.js +33 -7
  51. package/dist/managed-hooks.js.map +1 -1
  52. package/dist/mutation-gate.d.ts +35 -1
  53. package/dist/mutation-gate.d.ts.map +1 -1
  54. package/dist/mutation-gate.js +49 -9
  55. package/dist/mutation-gate.js.map +1 -1
  56. package/dist/operations.d.ts.map +1 -1
  57. package/dist/operations.js +94 -0
  58. package/dist/operations.js.map +1 -1
  59. package/dist/patterns.d.ts.map +1 -1
  60. package/dist/patterns.js +23 -4
  61. package/dist/patterns.js.map +1 -1
  62. package/dist/repository-origin.d.ts +1 -1
  63. package/dist/repository-origin.d.ts.map +1 -1
  64. package/dist/repository-origin.js +7 -1
  65. package/dist/repository-origin.js.map +1 -1
  66. package/dist/round-exec.d.ts +41 -0
  67. package/dist/round-exec.d.ts.map +1 -0
  68. package/dist/round-exec.js +45 -0
  69. package/dist/round-exec.js.map +1 -0
  70. package/dist/round.d.ts +109 -0
  71. package/dist/round.d.ts.map +1 -0
  72. package/dist/round.js +148 -0
  73. package/dist/round.js.map +1 -0
  74. package/dist/run-records.d.ts +8 -0
  75. package/dist/run-records.d.ts.map +1 -1
  76. package/dist/run-records.js +69 -3
  77. package/dist/run-records.js.map +1 -1
  78. package/dist/setup.d.ts +8 -0
  79. package/dist/setup.d.ts.map +1 -1
  80. package/dist/setup.js +166 -3
  81. package/dist/setup.js.map +1 -1
  82. package/dist/sqlite-read-helpers.d.ts +60 -0
  83. package/dist/sqlite-read-helpers.d.ts.map +1 -0
  84. package/dist/sqlite-read-helpers.js +118 -0
  85. package/dist/sqlite-read-helpers.js.map +1 -0
  86. package/dist/statusline.d.ts +20 -3
  87. package/dist/statusline.d.ts.map +1 -1
  88. package/dist/statusline.js +40 -7
  89. package/dist/statusline.js.map +1 -1
  90. package/dist/store-counts.d.ts +30 -0
  91. package/dist/store-counts.d.ts.map +1 -1
  92. package/dist/store-counts.js +189 -0
  93. package/dist/store-counts.js.map +1 -1
  94. package/dist/usage.d.ts +55 -0
  95. package/dist/usage.d.ts.map +1 -1
  96. package/dist/usage.js +184 -33
  97. package/dist/usage.js.map +1 -1
  98. package/dist/vector-tier.d.ts +18 -1
  99. package/dist/vector-tier.d.ts.map +1 -1
  100. package/dist/vector-tier.js +26 -2
  101. package/dist/vector-tier.js.map +1 -1
  102. package/package.json +6 -6
  103. package/sbom.json +442 -92
  104. package/src/agentdb-index.ts +315 -32
  105. package/src/agentdb-reindex-marker.ts +330 -0
  106. package/src/agentdb-snapshot-rotation.ts +393 -0
  107. package/src/agentdb-snapshot.ts +222 -0
  108. package/src/apply-leg.ts +1046 -0
  109. package/src/book-kb.ts +64 -1
  110. package/src/brain.ts +11 -2
  111. package/src/cadence.ts +87 -2
  112. package/src/guard.ts +46 -0
  113. package/src/index.ts +58 -5
  114. package/src/integration-probe-worker.ts +4 -6
  115. package/src/integrations-verify.ts +3 -1
  116. package/src/managed-hooks.ts +32 -7
  117. package/src/mutation-gate.ts +87 -8
  118. package/src/operations.ts +89 -0
  119. package/src/patterns.ts +23 -4
  120. package/src/repository-origin.ts +7 -1
  121. package/src/round-exec.ts +85 -0
  122. package/src/round.ts +228 -0
  123. package/src/run-records.ts +77 -2
  124. package/src/setup.ts +198 -3
  125. package/src/sqlite-read-helpers.ts +128 -0
  126. package/src/statusline.ts +57 -9
  127. package/src/store-counts.ts +245 -1
  128. package/src/usage.ts +243 -29
  129. package/src/vector-tier.ts +45 -4
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Pre-reindex snapshot rotation (feature `reindex-snapshot-rotation`, FR-1..FR-8).
3
+ *
4
+ * `reindexAgentdbRows` copies the store to `<db>.pre-reindex-<ms>.bak` (+ `.embed-manifest.json` /
5
+ * `-shm` / `-wal` siblings) before every reindex, as an undo point. Nothing has ever pruned them,
6
+ * so they accumulate without bound — 13 snapshots / 50 MB observed on the owner's hub, and no
7
+ * command removed a single one.
8
+ *
9
+ * This module is the PURE planning half (NFR-2) — {@link listPreReindexSnapshots} (fs READ) and
10
+ * {@link planSnapshotRotation} (no fs at all) — plus the thin fs-EFFECT wrapper
11
+ * {@link rotatePreReindexSnapshots} that acts on the plan. A snapshot is a FAMILY of files sharing
12
+ * one `<ms>` timestamp (FR-2): the family is removed or kept as a unit, never partially.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+
17
+ import { lstatSync, readdirSync, unlinkSync } from 'node:fs';
18
+ import { basename, dirname, join } from 'node:path';
19
+
20
+ import { readLiveReindexMarkers, withAgentdbSnapshotLock } from './agentdb-reindex-marker.js';
21
+ import { NamedLockTimeoutError } from './named-lock.js';
22
+
23
+ /** One file belonging to a snapshot family — always a basename, never a full path. */
24
+ export interface SnapshotFile {
25
+ readonly name: string;
26
+ readonly bytes: number;
27
+ }
28
+
29
+ /** Every file sharing one `<ms>` timestamp — the unit rotation acts on (FR-2). */
30
+ export interface SnapshotFamily {
31
+ readonly ms: number;
32
+ readonly files: readonly SnapshotFile[];
33
+ readonly bytes: number;
34
+ }
35
+
36
+ /** A rotation decision: which families survive, which are slated for removal. */
37
+ export interface SnapshotRotationPlan {
38
+ readonly kept: readonly SnapshotFamily[];
39
+ readonly removed: readonly SnapshotFamily[];
40
+ }
41
+
42
+ /** The fs-effect outcome (FR-4) — reported poimённо (by file name), never just a count. */
43
+ export interface SnapshotRotationReport {
44
+ readonly kept: string[];
45
+ readonly removed: string[];
46
+ readonly removedBytes: number;
47
+ readonly keep: number;
48
+ /** Per-file unlink failures (FR-5) — never fatal to the caller's own success. */
49
+ readonly errors?: string[];
50
+ /**
51
+ * AM-4 (fix-round after Codex review Grade D): a `readdirSync`/`lstatSync` failure other than
52
+ * ENOENT makes the candidate list INCOMPLETE — a family the scan never saw would look "not old"
53
+ * only because it was invisible, never because it truly survives review. Present ⇒ this call
54
+ * removed NOTHING, however conservative that looks against a nonzero `kept`.
55
+ */
56
+ readonly scanErrors?: string[];
57
+ /**
58
+ * AM-2: `<ms>` of every family where a sibling unlink failed and the family's `.bak` — the one
59
+ * file that alone still proves the snapshot ever existed — was therefore left in place rather
60
+ * than deleted out from under a family whose removal turned out to be only partial.
61
+ */
62
+ readonly partialFamilies?: number[];
63
+ /**
64
+ * FR-3 (agentdb-snapshot-lock): honest observations that are neither an error nor a removal —
65
+ * today, exactly one shape: a reindex-in-progress marker found PAST its TTL, ignored and deleted.
66
+ * Never populated for a marker that is still live (it simply protects its family silently) nor
67
+ * for the ordinary case of no marker at all.
68
+ */
69
+ readonly notes?: string[];
70
+ }
71
+
72
+ /** AM-3: a family younger than this survives rotation even beyond `keep` — no override flag exists;
73
+ * the intent is "never delete something from the last few minutes", not a tunable retention window. */
74
+ const DEFAULT_GRACE_MS = 10 * 60 * 1000;
75
+
76
+ /** Escape a string for literal use inside a `RegExp` — basenames may carry `.`, `+`, etc. */
77
+ function escapeRegExp(s: string): string {
78
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
79
+ }
80
+
81
+ /**
82
+ * The strict per-db snapshot-family pattern (FR-3): only names literally derived from `dbBasename`
83
+ * match — `agentdb.db.bak`, `other.pre-reindex-1.bak`, or anything else in the directory is left
84
+ * alone. Group 1 is the `<ms>` timestamp; group 2 (optional) is the sibling suffix.
85
+ */
86
+ function snapshotNamePattern(dbBasename: string): RegExp {
87
+ return new RegExp(`^${escapeRegExp(dbBasename)}\\.pre-reindex-(\\d+)\\.bak(\\.embed-manifest\\.json|-shm|-wal)?$`);
88
+ }
89
+
90
+ /**
91
+ * Scan the directory next to `dbFile` for candidate families — the shared innards of
92
+ * {@link listPreReindexSnapshots} (which drops `scanErrors` for callers that only ever listed) and
93
+ * {@link rotatePreReindexSnapshots} (which must see them, per AM-4: an incomplete scan must never be
94
+ * read as "these are all the old ones").
95
+ *
96
+ * NFR-1: symlinks are never followed and never treated as candidates. `lstatSync` — never `stat` —
97
+ * decides this, so a matching NAME that resolves to a symlink is excluded entirely (AC-3), not
98
+ * "rotated by its link size". An orphaned sibling (e.g. a `-shm` with no `.bak`) still forms its
99
+ * own one-file family under its `<ms>` (AC-5) — FR-2 groups by timestamp, not by which siblings
100
+ * happen to exist.
101
+ *
102
+ * AM-5: a `<ms>` that is not a safe non-negative integer within `Date`'s representable range
103
+ * (`±8.64e15`) is never grouped into a candidate family — so it never reaches `new Date(ms)`, which
104
+ * throws `RangeError` past that bound, and it is never silently treated as "the oldest one".
105
+ */
106
+ export function scanSnapshotDir(dbFile: string): { families: SnapshotFamily[]; scanErrors: string[] } {
107
+ const dir = dirname(dbFile);
108
+ const pattern = snapshotNamePattern(basename(dbFile));
109
+ const scanErrors: string[] = [];
110
+ let entries: string[];
111
+ try {
112
+ entries = readdirSync(dir);
113
+ } catch (err) {
114
+ const code = (err as NodeJS.ErrnoException).code;
115
+ if (code !== 'ENOENT') {
116
+ scanErrors.push(`readdir ${dir}: ${err instanceof Error ? err.message : String(err)}`);
117
+ }
118
+ return { families: [], scanErrors };
119
+ }
120
+ const byMs = new Map<number, SnapshotFile[]>();
121
+ for (const name of entries) {
122
+ const match = pattern.exec(name);
123
+ if (match === null) continue;
124
+ const full = join(dir, name);
125
+ let size: number;
126
+ try {
127
+ const st = lstatSync(full);
128
+ if (!st.isFile()) continue; // symlinks (and anything else non-regular) are left alone (NFR-1)
129
+ size = st.size;
130
+ } catch (err) {
131
+ const code = (err as NodeJS.ErrnoException).code;
132
+ if (code !== 'ENOENT') {
133
+ scanErrors.push(`lstat ${name}: ${err instanceof Error ? err.message : String(err)}`);
134
+ }
135
+ continue; // ENOENT: vanished between readdir and lstat — nothing left to rotate, not an error
136
+ }
137
+ const ms = Number(match[1]);
138
+ if (!Number.isSafeInteger(ms) || ms < 0 || ms > 8.64e15) continue; // AM-5: not a candidate
139
+ const list = byMs.get(ms) ?? [];
140
+ list.push({ name, bytes: size });
141
+ byMs.set(ms, list);
142
+ }
143
+ const families = [...byMs.entries()]
144
+ .map(([ms, files]) => ({
145
+ ms,
146
+ files: [...files].sort((a, b) => a.name.localeCompare(b.name)),
147
+ bytes: files.reduce((sum, f) => sum + f.bytes, 0),
148
+ }))
149
+ .sort((a, b) => b.ms - a.ms);
150
+ return { families, scanErrors };
151
+ }
152
+
153
+ /**
154
+ * List every pre-reindex snapshot family sitting next to `dbFile`, newest `ms` first (families
155
+ * unsorted internally are sorted here; per-family files are name-sorted for determinism). See
156
+ * {@link scanSnapshotDir} for the scan rules (NFR-1, AM-5); this wrapper drops `scanErrors` — callers
157
+ * that only ever want to LIST (never delete) have no unsafe decision to gate on them.
158
+ */
159
+ export function listPreReindexSnapshots(dbFile: string): SnapshotFamily[] {
160
+ return scanSnapshotDir(dbFile).families;
161
+ }
162
+
163
+ /**
164
+ * Resolve a candidate path (typically the fresh `backupPath` a reindex just wrote) to the `<ms>` it
165
+ * belongs to, under the SAME strict pattern `listPreReindexSnapshots` uses. `undefined` when the
166
+ * path does not name this db's snapshot family at all — e.g. the db never existed, so no backup was
167
+ * ever written.
168
+ */
169
+ function msOf(dbFile: string, candidatePath: string): number | undefined {
170
+ const match = snapshotNamePattern(basename(dbFile)).exec(basename(candidatePath));
171
+ return match === null ? undefined : Number(match[1]);
172
+ }
173
+
174
+ /**
175
+ * Pure decision (NFR-2, zero fs): given the families found in a directory, decide which survive.
176
+ * The newest `keep` families survive (`keep` clamped to ≥ 0); the freshly created backup is
177
+ * normally the newest family, so it is ordinarily already inside that top-`keep` slice. `protectMs`
178
+ * names it explicitly and is the FALLBACK that fires only when it would otherwise fall OUTSIDE that
179
+ * slice — the boundary case named by FR-1: at `keep=0` the top slice is empty, yet the snapshot this
180
+ * very call just wrote must never be deleted. `families` need not be pre-sorted.
181
+ *
182
+ * AM-3 (fix-round): a family younger than `graceMs` (default {@link DEFAULT_GRACE_MS}, 10 minutes;
183
+ * `ms > now - graceMs`) is rescued exactly like `protectMs`, even past `keep` — a fresh snapshot from
184
+ * a DIFFERENT process/run than the one calling this must never look "old" just because nobody named
185
+ * it via `protectMs`. There is no override: `now`/`graceMs` exist for deterministic tests, not for a
186
+ * CLI knob that would let someone talk the grace period down to zero.
187
+ */
188
+ export function planSnapshotRotation(
189
+ families: readonly SnapshotFamily[],
190
+ opts: { readonly keep: number; readonly protectMs?: number; readonly now?: number; readonly graceMs?: number },
191
+ ): SnapshotRotationPlan {
192
+ const sorted = [...families].sort((a, b) => b.ms - a.ms);
193
+ const keepCount = Math.max(0, opts.keep);
194
+ const topKeep = sorted.slice(0, keepCount);
195
+ const protectMs = opts.protectMs;
196
+ const alreadyKept = protectMs !== undefined && topKeep.some((f) => f.ms === protectMs);
197
+ const rescued = protectMs !== undefined && !alreadyKept ? sorted.find((f) => f.ms === protectMs) : undefined;
198
+ const kept = rescued !== undefined ? [rescued, ...topKeep].sort((a, b) => b.ms - a.ms) : topKeep;
199
+ const keptMs = new Set(kept.map((f) => f.ms));
200
+ const removed = sorted.filter((f) => !keptMs.has(f.ms));
201
+ const now = opts.now ?? Date.now();
202
+ const graceMs = opts.graceMs ?? DEFAULT_GRACE_MS;
203
+ const graced = removed.filter((f) => f.ms > now - graceMs);
204
+ if (graced.length === 0) return { kept, removed };
205
+ const gracedMs = new Set(graced.map((f) => f.ms));
206
+ return {
207
+ kept: [...kept, ...graced].sort((a, b) => b.ms - a.ms),
208
+ removed: removed.filter((f) => !gracedMs.has(f.ms)),
209
+ };
210
+ }
211
+
212
+ /** Options shared by {@link rotatePreReindexSnapshotsUnlocked} and {@link rotatePreReindexSnapshots}. */
213
+ export interface RotateSnapshotsOptions {
214
+ readonly keep?: number;
215
+ readonly protectPath?: string;
216
+ readonly now?: number;
217
+ readonly graceMs?: number;
218
+ }
219
+
220
+ /** A lock timeout is reported exactly like any other rotation refusal (FR-4): nothing removed,
221
+ * `errors: ['lock busy: …']` — never a throw out of the public rotation entry point. */
222
+ function lockBusyReport(keep: number, err: Error): SnapshotRotationReport {
223
+ return { kept: [], removed: [], removedBytes: 0, keep, errors: [`lock busy: ${err.message}`] };
224
+ }
225
+
226
+ /** AM-1's guarantee ("an invalid `keep` reads nothing, touches nothing") checked ONCE and shared by
227
+ * both the unlocked primitive and the public locked wrapper — so an invalid `keep` short-circuits
228
+ * before the wrapper's own pre-scan `readdir`, not just before the unlocked primitive's. */
229
+ function invalidKeepReport(keep: number): SnapshotRotationReport | undefined {
230
+ if (!Number.isSafeInteger(keep) || keep < 0) {
231
+ return { kept: [], removed: [], removedBytes: 0, keep, errors: [`invalid keep: ${keep} (must be a non-negative safe integer)`] };
232
+ }
233
+ return undefined;
234
+ }
235
+
236
+ /**
237
+ * The fs-effect half: list, plan, delete. A per-file `unlink` failure lands in `errors[]` and never
238
+ * stops the rest of the rotation (FR-5) — by the time this runs, the caller has already produced a
239
+ * successful reindex, and a rotation hiccup must never be read as a reindex failure. `removedBytes`
240
+ * counts only bytes of files ACTUALLY unlinked, so a partial failure never overstates what was freed.
241
+ *
242
+ * AM-1 (fix-round, Codex review Grade D): `keep` is validated HERE, before any fs read or delete.
243
+ * The bug this closes: `Math.max(0, NaN)` is `NaN`, and `sorted.slice(0, NaN)` is `[]` — an EMPTY
244
+ * kept slice, so every existing family looked "older than keep" and got removed. A `keep` that is
245
+ * not a non-negative safe integer is refused outright; nothing is read, nothing is touched.
246
+ *
247
+ * AM-4: a scan error (readdir/lstat failing with anything but ENOENT) makes the candidate list
248
+ * INCOMPLETE — deciding "these are the old ones" off a partial list is exactly the class of mistake
249
+ * this rotation exists to avoid for the snapshots themselves, so this call removes NOTHING.
250
+ *
251
+ * AM-2: within a removed family, siblings unlink FIRST and the `.bak` LAST, and only once every
252
+ * sibling actually unlinked — a failed sibling leaves the `.bak` in place (the one file that alone
253
+ * still proves the snapshot ever existed) and names the family in `partialFamilies` rather than
254
+ * guessing the family is gone.
255
+ *
256
+ * FR-3 (agentdb-snapshot-lock): a family named by a LIVE reindex-in-progress marker
257
+ * ({@link readLiveReindexMarkers}) is rescued from `removed` exactly like `protectPath`, even past
258
+ * `keep` and even past `graceMs` — a reindex that is still running must never lose the undo point it
259
+ * is relying on. An EXPIRED marker is not a scan error: it is removed and named in `notes`.
260
+ *
261
+ * UNLOCKED by design (NFR-2): this is the internal primitive `rotatePreReindexSnapshots` (the public,
262
+ * locked entry point) and `reindexAgentdbRows`'s own success-path rotation both call — each under
263
+ * their OWN `withAgentdbSnapshotLock`, so the lock is never acquired twice in one call stack.
264
+ */
265
+ export function rotatePreReindexSnapshotsUnlocked(
266
+ dbFile: string,
267
+ opts: RotateSnapshotsOptions = {},
268
+ ): SnapshotRotationReport {
269
+ const keep = opts.keep ?? 3;
270
+ const invalid = invalidKeepReport(keep);
271
+ if (invalid !== undefined) return invalid;
272
+ const dir = dirname(dbFile);
273
+ const { families, scanErrors } = scanSnapshotDir(dbFile);
274
+ if (scanErrors.length > 0) {
275
+ return {
276
+ kept: families.flatMap((f) => f.files.map((file) => file.name)),
277
+ removed: [],
278
+ removedBytes: 0,
279
+ keep,
280
+ scanErrors,
281
+ };
282
+ }
283
+ const protectMs = opts.protectPath !== undefined ? msOf(dbFile, opts.protectPath) : undefined;
284
+ let { kept, removed } = planSnapshotRotation(families, {
285
+ keep,
286
+ ...(protectMs !== undefined ? { protectMs } : {}),
287
+ ...(opts.now !== undefined ? { now: opts.now } : {}),
288
+ ...(opts.graceMs !== undefined ? { graceMs: opts.graceMs } : {}),
289
+ });
290
+
291
+ // FR-3: rescue any family a LIVE reindex marker names, however `planSnapshotRotation` above
292
+ // decided — this protection is orthogonal to keep/grace/protectPath and must win over all of them.
293
+ const { protectedMs: markerProtectedMs, notes } = readLiveReindexMarkers(dbFile, opts.now);
294
+ if (markerProtectedMs.length > 0) {
295
+ const protect = new Set(markerProtectedMs);
296
+ const rescued = removed.filter((f) => protect.has(f.ms));
297
+ if (rescued.length > 0) {
298
+ removed = removed.filter((f) => !protect.has(f.ms));
299
+ kept = [...kept, ...rescued].sort((a, b) => b.ms - a.ms);
300
+ }
301
+ }
302
+
303
+ const errors: string[] = [];
304
+ const partialFamilies: number[] = [];
305
+ const removedNames: string[] = [];
306
+ let removedBytes = 0;
307
+ for (const family of removed) {
308
+ const bakFile = family.files.find((f) => f.name.endsWith('.bak'));
309
+ const siblings = family.files.filter((f) => f.name !== bakFile?.name);
310
+ let allOk = true;
311
+ for (const file of siblings) {
312
+ try {
313
+ unlinkSync(join(dir, file.name));
314
+ removedNames.push(file.name);
315
+ removedBytes += file.bytes;
316
+ } catch (err) {
317
+ allOk = false;
318
+ errors.push(`${file.name}: ${err instanceof Error ? err.message : String(err)}`);
319
+ }
320
+ }
321
+ if (bakFile !== undefined && allOk) {
322
+ try {
323
+ unlinkSync(join(dir, bakFile.name));
324
+ removedNames.push(bakFile.name);
325
+ removedBytes += bakFile.bytes;
326
+ } catch (err) {
327
+ allOk = false;
328
+ errors.push(`${bakFile.name}: ${err instanceof Error ? err.message : String(err)}`);
329
+ }
330
+ }
331
+ if (!allOk) partialFamilies.push(family.ms);
332
+ }
333
+ const keptNames = kept.flatMap((f) => f.files.map((file) => file.name));
334
+ return {
335
+ kept: keptNames,
336
+ removed: removedNames,
337
+ removedBytes,
338
+ keep,
339
+ ...(errors.length > 0 ? { errors } : {}),
340
+ ...(partialFamilies.length > 0 ? { partialFamilies } : {}),
341
+ ...(notes.length > 0 ? { notes } : {}),
342
+ };
343
+ }
344
+
345
+ /**
346
+ * The PUBLIC, locked rotation entry point (FR-1) — used by `dz brain snapshots --prune` and by any
347
+ * other caller outside `reindexAgentdbRows`'s own success path (which calls
348
+ * {@link rotatePreReindexSnapshotsUnlocked} directly under its own lock, per NFR-2). A
349
+ * `NamedLockTimeoutError` — the lock stayed held past the deadline — is reported exactly like any
350
+ * other rotation refusal: `{ removed: [], errors: ['lock busy: …'] }` (FR-4), never a throw.
351
+ *
352
+ * `lockTimeoutMs` is test/tuning-only: omitted, the lock uses its ordinary default deadline.
353
+ *
354
+ * AM-4 (carried forward): `withNamedLockSync` creates `<dirname(dbFile)>/.dz/locks/` before it ever
355
+ * runs `fn` — if `dirname(dbFile)` exists but is NOT a directory (a pathological store path, exactly
356
+ * the shape AM-4's own fixture uses), that `mkdirSync` throws `ENOTDIR` before any lock is even
357
+ * attempted. Scanning is therefore checked FIRST, outside the lock: an unreadable directory is a
358
+ * property of the directory, not of contention over it, and must report the SAME honest
359
+ * `scanErrors` (never touching, never throwing) whether or not locking is even reachable. MEASURED
360
+ * (fix round, this feature): omitting this pre-check turned the pre-existing
361
+ * `rotatePreReindexSnapshots(brokenDbFile, …)` / `dz brain snapshots --prune` "unscannable
362
+ * directory" fixtures from a clean `scanErrors` report into an uncaught `ENOTDIR` thrown out of the
363
+ * lock's own `mkdirSync` — reddening both `test/agentdb-snapshot-rotation.test.ts`'s AM-4 case and
364
+ * `harness-cli/test/brain-snapshots.test.ts`'s "prune mode: an unscannable store directory" case.
365
+ */
366
+ export function rotatePreReindexSnapshots(
367
+ dbFile: string,
368
+ opts: RotateSnapshotsOptions & { readonly lockTimeoutMs?: number } = {},
369
+ ): SnapshotRotationReport {
370
+ const keep = opts.keep ?? 3;
371
+ const invalid = invalidKeepReport(keep);
372
+ if (invalid !== undefined) return invalid;
373
+ const preScan = scanSnapshotDir(dbFile);
374
+ if (preScan.scanErrors.length > 0) {
375
+ return {
376
+ kept: preScan.families.flatMap((f) => f.files.map((file) => file.name)),
377
+ removed: [],
378
+ removedBytes: 0,
379
+ keep,
380
+ scanErrors: preScan.scanErrors,
381
+ };
382
+ }
383
+ try {
384
+ return withAgentdbSnapshotLock(
385
+ dbFile,
386
+ () => rotatePreReindexSnapshotsUnlocked(dbFile, opts),
387
+ opts.lockTimeoutMs !== undefined ? { timeoutMs: opts.lockTimeoutMs } : {},
388
+ );
389
+ } catch (err) {
390
+ if (err instanceof NamedLockTimeoutError) return lockBusyReport(keep, err);
391
+ throw err;
392
+ }
393
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Consistent sqlite snapshot + restore for the pre-reindex undo point (feature
3
+ * `snapshot-wal-consistency`, FR-1..FR-4; fix round AM-1..AM-4).
4
+ *
5
+ * A WAL-mode sqlite database's committed data can live in TWO files: the main `dbFile` and its
6
+ * `-wal` sidecar (frames not yet checkpointed into the main file). The snapshot this module
7
+ * replaces was a bare `copyFileSync(dbFile, backupPath)` — one file, no `-wal` — so a reindex that
8
+ * ran while any writer held the WAL open (autocheckpoint disabled, or simply a live connection
9
+ * between commits) copied a "backup" that was MISSING committed rows. MEASURED (scratch repro,
10
+ * 2026-09-13, `node` + real `better-sqlite3`): a schema + one row checkpointed, then a second row
11
+ * inserted on a connection kept open (`wal_autocheckpoint = 0`) — `copyFileSync` alone yields a
12
+ * backup with 1 row; `VACUUM INTO` on a fresh read-only connection to the SAME live db yields 2.
13
+ * The live db's main file hash and `-wal` size are unchanged before/after the `VACUUM INTO` (the
14
+ * read-only connection never checkpoints or writes) — the safety property NFR-2 names.
15
+ *
16
+ * {@link snapshotSqliteDatabase} (FR-1/FR-2) takes the snapshot through `VACUUM INTO` on a fresh
17
+ * READ-ONLY connection — one self-contained output file with every committed transaction, WAL
18
+ * frames included. If `VACUUM INTO` cannot run (older sqlite, a locked/foreign file, no free disk)
19
+ * — or a caller forces it for a test — it falls back to copying `dbFile` plus its `-wal` sibling
20
+ * (present and non-empty) to `backupPath`/`backupPath-wal`; still strictly better than the old
21
+ * single-file copy, and the fallback is never silent: `method`/`note` name it honestly so a report
22
+ * downstream (`reindexAgentdbRows`'s `snapshotMethod`) never claims a guarantee it did not get.
23
+ *
24
+ * **AM-1 (fix round).** Codex review, Grade C: reusing a `backupPath` left a stale `-wal`/`-shm`
25
+ * sidecar from an EARLIER, unrelated snapshot family sitting next to the new one; `restoreSqliteSnapshot`
26
+ * used to decide whether to restore `-wal` by checking whether `backupPath-wal` merely EXISTS, so that
27
+ * stale sidecar could ride along onto a `vacuum-into` restore that never produced a matching `-wal` of
28
+ * its own. Fixed two ways: `snapshotSqliteDatabase` now clears any `backupPath-wal`/`-shm` sidecar
29
+ * BEFORE either strategy writes anything, and again right after a successful `VACUUM INTO` (whose
30
+ * output is one self-contained file and must never be shadowed); `restoreSqliteSnapshot` now takes the
31
+ * `method` the caller already knows and restores `-wal` ONLY when `method === 'copy+wal'` — never by
32
+ * re-deriving it from file presence.
33
+ *
34
+ * **AM-2 (fix round).** The old WAL-size probe was `existsSync` + best-effort `statSync` inside a
35
+ * `catch { walSize = 0 }` — ANY stat failure (`EACCES`, `EIO`, a raced deletion) was silently read as
36
+ * "no WAL", so a snapshot could report the honest-sounding `method: 'copy'` while actually having
37
+ * skipped a WAL it could not even check. {@link statSizeIfExists} now calls `statSync` directly and
38
+ * treats only a confirmed `ENOENT` as "does not exist" — every other error propagates and aborts the
39
+ * whole snapshot, so `reindexAgentdbRows` reports "snapshot failed", never a falsely-successful `copy`.
40
+ *
41
+ * **AM-4 (fix round).** `snapshotSqliteDatabase` used to happily write into (overwrite) an existing
42
+ * `backupPath`, silently discarding whatever it held — including, combined with AM-1's stale-sidecar
43
+ * bug, a backup that looked complete but carried a wrong-generation `-wal`. It now refuses up front
44
+ * (before `VACUUM INTO` or any copy) when `backupPath` already exists and is non-empty.
45
+ *
46
+ * {@link restoreSqliteSnapshot} (FR-4) is the paired rollback. MEASURED (same repro): copying the
47
+ * old main file back over `dbFile` WITHOUT removing a `-wal` left over from the aborted operation
48
+ * — reopening the "restored" db returned ZERO rows, not the restored one, because sqlite replayed
49
+ * the stale WAL frames (a since-superseded DELETE) on top of the reverted main file. Removing
50
+ * `dbFile-wal`/`dbFile-shm` as part of the restore fixed this: the reopened db then returned
51
+ * exactly the restored row. The caller MUST close its own write connection to `dbFile` before
52
+ * calling this — restoring a file sqlite still holds open is undefined behaviour.
53
+ *
54
+ * **AM-3 (fix round).** `restoreSqliteSnapshot` used to return `void` — a failing `copyFileSync`/
55
+ * `rmSync` inside it threw straight out of the best-effort `rollback()` wrapper in
56
+ * `agentdb-index.ts`, which swallowed it in an empty `catch` and reported nothing: "absence of a
57
+ * receipt is not success" applies to a rollback exactly as much as to a forward operation. It now
58
+ * returns `{ ok: true } | { ok: false, error }` so the caller can name a failed rollback as failed,
59
+ * never as restored.
60
+ *
61
+ * @packageDocumentation
62
+ */
63
+
64
+ import { copyFileSync, existsSync, rmSync, statSync } from 'node:fs';
65
+
66
+ /** How a snapshot was actually taken — always reported by the caller, never assumed. */
67
+ export type SnapshotMethod = 'vacuum-into' | 'copy+wal' | 'copy';
68
+
69
+ /** Outcome of {@link snapshotSqliteDatabase}. */
70
+ export interface SnapshotOutcome {
71
+ readonly method: SnapshotMethod;
72
+ /** Present only when a fallback method was used — the reason `vacuum-into` did not run. */
73
+ readonly note?: string;
74
+ }
75
+
76
+ /** Outcome of {@link restoreSqliteSnapshot} (AM-3) — a failed restore is reported, never assumed. */
77
+ export interface RestoreOutcome {
78
+ readonly ok: boolean;
79
+ /** Present only when `ok` is false — why the restore did not fully complete. */
80
+ readonly error?: string;
81
+ }
82
+
83
+ /** Minimal better-sqlite3 constructor surface this module needs (read-only open + exec + close). */
84
+ export interface SnapshotDbCtor {
85
+ new (path: string, opts?: { readonly?: boolean }): { exec: (sql: string) => void; close: () => void };
86
+ }
87
+
88
+ /** Escape `path` as a single-quoted sqlite string literal (double any embedded `'`). */
89
+ function sqlQuote(path: string): string {
90
+ return `'${path.replace(/'/g, "''")}'`;
91
+ }
92
+
93
+ /**
94
+ * AM-2: the size of `path`, or `undefined` when it does not exist — but ONLY on a confirmed
95
+ * `ENOENT`. Any other stat error (`EACCES`, `EIO`, a raced deletion mid-call, …) propagates to the
96
+ * caller instead of being folded into "does not exist": a snapshot that cannot even determine
97
+ * whether a `-wal` sidecar exists must abort, not silently report the weaker `copy` method as if it
98
+ * had made an informed choice.
99
+ */
100
+ function statSizeIfExists(path: string): number | undefined {
101
+ try {
102
+ return statSync(path).size;
103
+ } catch (err) {
104
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return undefined;
105
+ throw err;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * AM-4: refuse to snapshot into an existing, non-empty `backupPath` — silently overwriting it would
111
+ * discard whatever it held (possibly itself a valid undo point) with no trace. An existing but EMPTY
112
+ * file is not a real prior snapshot and is allowed through untouched.
113
+ */
114
+ function assertBackupTargetFree(backupPath: string): void {
115
+ const size = statSizeIfExists(backupPath);
116
+ if (size !== undefined && size > 0) {
117
+ throw new Error(`snapshot target exists and is non-empty: ${backupPath}`);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * AM-1: remove any `-wal`/`-shm` sidecar already sitting at `backupPath` — a leftover from an
123
+ * earlier, unrelated snapshot family that happened to reuse this name. Called BEFORE either strategy
124
+ * below writes anything, and again immediately after a successful `VACUUM INTO` (whose output is one
125
+ * self-contained file that must never be shadowed by a stale sidecar from a previous attempt at the
126
+ * same path).
127
+ */
128
+ function clearBackupSidecars(backupPath: string): void {
129
+ rmSync(`${backupPath}-wal`, { force: true });
130
+ rmSync(`${backupPath}-shm`, { force: true });
131
+ }
132
+
133
+ function copyWithWal(dbFile: string, backupPath: string, note: string): SnapshotOutcome {
134
+ // AM-2: statSync directly; only a confirmed ENOENT means "no -wal to copy" — any other stat error
135
+ // propagates out of this function (and out of snapshotSqliteDatabase) as a thrown exception.
136
+ // Lead edit after re-review: the stat runs BEFORE the main file is copied, so a stat failure leaves
137
+ // no half-written target behind (which AM-4 would otherwise refuse on the retry).
138
+ const walSize = statSizeIfExists(`${dbFile}-wal`) ?? 0;
139
+ copyFileSync(dbFile, backupPath);
140
+ if (walSize > 0) {
141
+ copyFileSync(`${dbFile}-wal`, `${backupPath}-wal`);
142
+ return { method: 'copy+wal', note };
143
+ }
144
+ return { method: 'copy', note };
145
+ }
146
+
147
+ /**
148
+ * Take a consistent snapshot of `dbFile` into `backupPath` (FR-1/FR-2). Default: open `dbFile`
149
+ * READ-ONLY and run `VACUUM INTO <backupPath>` — one output file holding every committed
150
+ * transaction (including `-wal` frames), with no write to the live database (NFR-2 — MEASURED:
151
+ * main-file hash and `-wal` size are unchanged across the call). On any failure — an older sqlite
152
+ * without `VACUUM INTO`, a locked file, no disk space — or when `opts.strategy` forces it (tests),
153
+ * falls back to `copyFileSync(dbFile, backupPath)` plus a copy of `dbFile-wal` to `backupPath-wal`
154
+ * when the WAL sibling exists and is non-empty; `method` is `'copy+wal'` when the sibling was
155
+ * copied, `'copy'` when there was none to copy. `note` names the fallback reason (FR-2) — set even
156
+ * for a forced-strategy test call, so a caller never has to guess why the fast path was skipped.
157
+ * The read-only connection opened for `VACUUM INTO` is always closed before returning.
158
+ *
159
+ * Throws (no snapshot taken, or an incomplete one left in a fully-cleared state) when: `backupPath`
160
+ * already names an existing, non-empty file (AM-4); or a `-wal` stat probe hits a non-ENOENT error
161
+ * (AM-2, inside the fallback path). Never silently overwrites, never mis-reports a lesser guarantee
162
+ * as a stronger one.
163
+ */
164
+ export function snapshotSqliteDatabase(
165
+ Database: SnapshotDbCtor,
166
+ dbFile: string,
167
+ backupPath: string,
168
+ opts: { strategy?: 'vacuum-into' | 'copy+wal' } = {},
169
+ ): SnapshotOutcome {
170
+ assertBackupTargetFree(backupPath); // AM-4 — before any write, on either strategy
171
+ clearBackupSidecars(backupPath); // AM-1 — clean slate before either strategy writes anything
172
+ if (opts.strategy === 'copy+wal') {
173
+ return copyWithWal(dbFile, backupPath, 'snapshotStrategy=copy+wal forced by caller');
174
+ }
175
+ try {
176
+ const db = new Database(dbFile, { readonly: true });
177
+ try {
178
+ db.exec(`VACUUM INTO ${sqlQuote(backupPath)}`);
179
+ } finally {
180
+ db.close();
181
+ }
182
+ clearBackupSidecars(backupPath); // AM-1 — VACUUM INTO's output never has a matching -wal of its own
183
+ return { method: 'vacuum-into' };
184
+ } catch (err) {
185
+ return copyWithWal(dbFile, backupPath, `VACUUM INTO failed: ${err instanceof Error ? err.message : String(err)}`);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Restore `dbFile` from a snapshot taken by {@link snapshotSqliteDatabase} (FR-4). The caller MUST
191
+ * close its write connection to `dbFile` BEFORE calling this — this restore is a plain file copy,
192
+ * not a sqlite-mediated rollback, and a live handle can reintroduce exactly the frames being undone.
193
+ *
194
+ * `method` is the EXACT {@link SnapshotMethod} the paired `snapshotSqliteDatabase` call returned
195
+ * (AM-1) — never re-derived from whether `backupPath-wal` happens to exist on disk, which a stale
196
+ * sidecar from an unrelated earlier snapshot at the same path could satisfy. Copies `backupPath`
197
+ * over `dbFile`; when `method === 'copy+wal'`, also restores `backupPath-wal` to `dbFile-wal`.
198
+ * Otherwise (`'vacuum-into'` or `'copy'`, neither of which produced a matching `-wal` of its own) any
199
+ * LIVE `dbFile-wal` — left over from the operation being undone — is removed instead: MEASURED,
200
+ * leaving it in place made a reopened "restored" db replay that WAL's frames (a since-superseded
201
+ * write) on top of the reverted main file, returning zero rows instead of the restored set.
202
+ * `dbFile-shm` is always removed — its offsets are valid only for the `-wal` that no longer matches.
203
+ *
204
+ * Returns `{ ok: true }`, or `{ ok: false, error }` (AM-3) when any step throws — a caller that
205
+ * reported a failed restore as a successful one would leave the store worse than before rollback was
206
+ * attempted, with nothing on record to say so.
207
+ */
208
+ export function restoreSqliteSnapshot(dbFile: string, backupPath: string, method: SnapshotMethod): RestoreOutcome {
209
+ try {
210
+ if (!existsSync(backupPath)) return { ok: false, error: `snapshot missing at ${backupPath}` };
211
+ copyFileSync(backupPath, dbFile);
212
+ if (method === 'copy+wal') {
213
+ copyFileSync(`${backupPath}-wal`, `${dbFile}-wal`);
214
+ } else {
215
+ rmSync(`${dbFile}-wal`, { force: true });
216
+ }
217
+ rmSync(`${dbFile}-shm`, { force: true });
218
+ return { ok: true };
219
+ } catch (err) {
220
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
221
+ }
222
+ }