@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,170 @@
1
+ /**
2
+ * The agentdb snapshot lock + reindex-in-progress marker (feature `agentdb-snapshot-lock`,
3
+ * FR-1..FR-4; fix-round AM-1..AM-4/AM-7 after Codex review Grade D).
4
+ *
5
+ * WHY. Snapshot creation (in `reindexAgentdbRows`), snapshot rotation (`rotatePreReindexSnapshots`,
6
+ * including `dz brain snapshots --prune`) and snapshot restore (`restoreSqliteSnapshot` via
7
+ * `reindexAgentdbRows`'s rollback) are three writers of ONE directory with no mutual exclusion: a
8
+ * concurrent `rotate --keep 0` can delete the very snapshot family a live reindex is relying on as
9
+ * its undo point, with only a 10-minute grace period standing in the way.
10
+ *
11
+ * {@link withAgentdbSnapshotLock} is a thin, dbFile-addressed wrapper over `withNamedLockSync`: the
12
+ * lock lives at `<dirname(dbFile)>/.dz/locks/agentdb-snapshot.lock` — a pure function of the
13
+ * database's OWN directory, never of the caller's cwd, so a project store and the home brain each
14
+ * get their own lock (AC-5) regardless of where `dz` happens to be invoked from. AM-7: it defaults
15
+ * `staleMs` to {@link AGENTDB_SNAPSHOT_LOCK_STALE_MS} (5 minutes) rather than named-lock's ordinary
16
+ * 30-second default — MEASURED (lead, this fix round): `VACUUM INTO` on the owner's hub store
17
+ * (8.45 MB) took 96 ms, so 5 minutes leaves roughly 3000x headroom while staying inside
18
+ * named-lock's own 600 000 ms environment-override ceiling. A caller doing something unusually
19
+ * large may still override via `opts.staleMs`.
20
+ *
21
+ * The critical section this lock guards must stay SHORT and SYNCHRONOUS — file operations only
22
+ * (`VACUUM INTO` / copies / unlink / restore). MEASURED (named-lock.ts round-2, reused verbatim
23
+ * here): a 6s synchronous body under a 2s stale threshold let a THIRD process break in while the
24
+ * second was still inside its own critical section — a long body doesn't just block callers, it
25
+ * breaks the lock's own guarantee. The actual re-embedding (network/CPU-bound `EmbeddingService`
26
+ * calls, row writes) therefore runs OUTSIDE this lock — see `reindexAgentdbRows`.
27
+ *
28
+ * {@link writeReindexMarker} / {@link clearReindexMarker} / {@link readLiveReindexMarkers} implement
29
+ * the SECOND protection this feature adds — a "reindex is currently in flight" marker file beside
30
+ * the database, so a family a live reindex just created is protected from rotation for the
31
+ * DURATION of the reindex, not merely for the few milliseconds the snapshot lock itself is held
32
+ * (the embedding phase can run for a while, unlocked, per FR-2). The marker's liveness is judged by
33
+ * `startedAt` (a wall-clock age, {@link REINDEX_MARKER_TTL_MS}), never by `pid` — a pid is recorded
34
+ * for operator debugging only; a live pid on a different host, or a recycled pid, proves nothing
35
+ * ("pid is not authority over liveness" — the same lesson `store-lock.ts`/`named-lock.ts` already
36
+ * encode for lock staleness). A marker older than the TTL is treated as abandoned: it protects
37
+ * nothing, and rotation removes it, recording why in the report's `notes` — that is an honest
38
+ * observation about a stale marker, never a scan error.
39
+ *
40
+ * AM-1 (fix-round): {@link writeReindexMarker} creates the marker file EXCLUSIVELY
41
+ * (`openSync(path, 'wx')`) and stamps it with an ownership `token` (16 random hex chars). A LIVE
42
+ * marker already at that path (another reindex genuinely in flight) refuses this call outright —
43
+ * `{ ok: false, error }`, no snapshot ever taken for the refused call. A marker at or past the TTL
44
+ * is treated as abandoned and replaced. {@link clearReindexMarker} is compare-and-delete: it removes
45
+ * the marker only when the caller's `token` matches the one on disk, so a process can never tear
46
+ * down a marker it does not own (`{ cleared: false, reason }` otherwise). The narrow race this
47
+ * leaves — two `writeReindexMarker` calls racing the exclusive-create step itself — cannot happen
48
+ * in practice: every caller in this codebase invokes it from INSIDE the very snapshot lock this
49
+ * module also provides (see `reindexAgentdbRows`), so at most one writer is ever in this function
50
+ * at a time.
51
+ *
52
+ * @packageDocumentation
53
+ */
54
+ import type { StoreLockOptions } from './store-lock.js';
55
+ /** A marker older than this is abandoned — its family is no longer protected (FR-3). `pid` is
56
+ * recorded for operator debugging only and is NEVER consulted to decide liveness. */
57
+ export declare const REINDEX_MARKER_TTL_MS: number;
58
+ /** AM-7: MEASURED (lead, this fix round) — `VACUUM INTO` on the hub's 8.45 MB agentdb store took
59
+ * 96 ms. 5 minutes is ~3000x that, and stays inside named-lock's 600 000 ms override ceiling. A
60
+ * base whose snapshot genuinely needs longer than this needs external coordination, not a bigger
61
+ * default — see the README. */
62
+ export declare const AGENTDB_SNAPSHOT_LOCK_STALE_MS = 300000;
63
+ /** The reindex-in-progress marker written beside the database while a reindex is running. */
64
+ export interface ReindexMarker {
65
+ /** The `<ms>` timestamp of THIS reindex's pre-reindex snapshot family — the value rotation must
66
+ * protect while the marker is live. AM-4: `null` when the reindex's `backupPath` does not match
67
+ * the standard `<db>.pre-reindex-<ms>.bak` family shape (a non-standard target names no family,
68
+ * so it is never a rotation candidate in the first place). */
69
+ readonly ms: number | null;
70
+ /** Operator-debugging only — never consulted for liveness (pid is not authority). */
71
+ readonly pid: number;
72
+ /** Wall-clock start time (`Date.now()`); liveness = `now - startedAt < REINDEX_MARKER_TTL_MS`. */
73
+ readonly startedAt: number;
74
+ /** AM-1: ownership token (16 random hex chars) — {@link clearReindexMarker} removes the marker
75
+ * only when the caller presents this exact token back. */
76
+ readonly token: string;
77
+ /** AM-4: the reindex's actual backup path, always recorded — the only way to identify the
78
+ * snapshot a `ms: null` marker is protecting (an operator debugging a stuck non-standard target
79
+ * has nothing else to go on). */
80
+ readonly backupPath?: string;
81
+ /** Lead edit after re-review (Codex D): set by {@link markReindexMarkerRecoveryRequired} when a
82
+ * rollback FAILED. Such a marker never expires — its family stays protected and every new reindex
83
+ * of this store is refused until an operator restores the snapshot and removes the marker. */
84
+ readonly requiresRecovery?: boolean;
85
+ readonly recoveryNote?: string;
86
+ }
87
+ /** The marker's path — always beside the database, never in a separate directory. */
88
+ export declare function reindexMarkerPath(dbFile: string): string;
89
+ /**
90
+ * AM-4: recompute a marker's `ms` from the ACTUAL backup path's filename — decoupled from whatever
91
+ * internal `ms` variable a caller used to build a DEFAULT `backupPath`. A caller-supplied
92
+ * `opts.backupPath` that does not end in the standard `.pre-reindex-<digits>.bak` shape (or whose
93
+ * digits overflow a safe integer) names no family at all, so it maps to `null` — such a target is
94
+ * never a rotation candidate, and the marker must not claim to protect one.
95
+ */
96
+ export declare function msFromBackupPath(backupPath: string): number | null;
97
+ /**
98
+ * Run `fn` while holding the agentdb snapshot lock for `dbFile` — a pure function of the
99
+ * database's own directory (AC-5), never of `process.cwd()`. Propagates `NamedLockTimeoutError`
100
+ * (FR-4): a caller that cannot acquire the lock by the deadline must report the failure explicitly,
101
+ * never proceed unlocked and never silently skip its work. AM-7: `staleMs` defaults to
102
+ * {@link AGENTDB_SNAPSHOT_LOCK_STALE_MS} unless the caller names its own.
103
+ */
104
+ export declare function withAgentdbSnapshotLock<T>(dbFile: string, fn: () => T, opts?: StoreLockOptions): T;
105
+ /**
106
+ * AM-1: write the reindex-in-progress marker EXCLUSIVELY. A marker already at this path that is
107
+ * still LIVE (younger than {@link REINDEX_MARKER_TTL_MS}) refuses this write outright —
108
+ * `{ ok: false, error }` naming the marker path, with no snapshot ever attempted by the caller for
109
+ * this call (the caller checks `ok` BEFORE touching the database — see `reindexAgentdbRows`). A
110
+ * marker that is missing, unreadable/corrupt, or past the TTL is replaced. The written marker
111
+ * carries a fresh ownership `token`; only {@link clearReindexMarker} presenting that SAME token may
112
+ * remove it.
113
+ */
114
+ export declare function writeReindexMarker(dbFile: string, marker: {
115
+ readonly ms: number | null;
116
+ readonly pid: number;
117
+ readonly startedAt: number;
118
+ readonly backupPath?: string;
119
+ }): {
120
+ ok: true;
121
+ token: string;
122
+ } | {
123
+ ok: false;
124
+ error: string;
125
+ };
126
+ /**
127
+ * Lead edit after re-review (Codex D, finding 1): a marker left behind by a FAILED rollback must not
128
+ * quietly expire after the TTL — the snapshot it names may be the only good copy of the store. The
129
+ * owner (token) rewrites the marker atomically (tmp + rename) with `requiresRecovery: true`; from
130
+ * then on {@link readLiveReindexMarkers} protects its family without expiry and
131
+ * {@link writeReindexMarker} refuses every new reindex until an operator removes the marker.
132
+ * Call it under the snapshot lock, like every other marker mutation.
133
+ */
134
+ export declare function markReindexMarkerRecoveryRequired(dbFile: string, token: string, recoveryNote: string): {
135
+ ok: true;
136
+ } | {
137
+ ok: false;
138
+ reason: string;
139
+ };
140
+ /**
141
+ * AM-1: compare-and-delete. Removes the marker ONLY when its `token` matches the one on disk — a
142
+ * caller can never tear down a marker it does not own. Absence of the marker file is treated as an
143
+ * already-cleared success (idempotent). Called from `reindexAgentdbRows`'s cleanup on every path
144
+ * that does NOT leave a failed rollback behind (AM-3) — "absence of a receipt is not success" cuts
145
+ * the other way here too: a marker left behind after its owner finished would falsely protect a
146
+ * family forever (until the TTL), so ownership-checked removal must still run unconditionally on
147
+ * every path that is safe to clear.
148
+ */
149
+ export declare function clearReindexMarker(dbFile: string, token: string): {
150
+ cleared: true;
151
+ } | {
152
+ cleared: false;
153
+ reason: string;
154
+ };
155
+ /**
156
+ * Read the marker beside `dbFile` and classify it: a marker younger than
157
+ * {@link REINDEX_MARKER_TTL_MS} protects its `ms` (FR-3) — but ONLY when `ms` is an actual number
158
+ * (AM-4: a `ms: null` marker names no family, so it protects nothing, live or not). A marker at or
159
+ * past the TTL is abandoned — it is removed here (rotation must not act on stale-but-still-present
160
+ * state on its NEXT call). AM-6: the note reports "ignored and removed" ONLY once `rmSync` actually
161
+ * succeeded; a removal failure (e.g. a permission error) is reported as "ignored, removal failed:
162
+ * <err>" — never silently claimed as removed. No marker, or a marker this function cannot parse
163
+ * (corrupt / missing fields), yields `{ protectedMs: [], notes: [] }` — never a throw: a malformed
164
+ * marker must not abort rotation, and it also cannot honestly claim to protect anything.
165
+ */
166
+ export declare function readLiveReindexMarkers(dbFile: string, now?: number): {
167
+ protectedMs: number[];
168
+ notes: string[];
169
+ };
170
+ //# sourceMappingURL=agentdb-reindex-marker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agentdb-reindex-marker.d.ts","sourceRoot":"","sources":["../src/agentdb-reindex-marker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoDG;AAOH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAExD;qFACqF;AACrF,eAAO,MAAM,qBAAqB,QAAiB,CAAC;AAEpD;;;+BAG+B;AAC/B,eAAO,MAAM,8BAA8B,SAAU,CAAC;AAEtD,6FAA6F;AAC7F,MAAM,WAAW,aAAa;IAC5B;;;kEAG8D;IAC9D,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,qFAAqF;IACrF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,kGAAkG;IAClG,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;8DAC0D;IAC1D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;qCAEiC;IACjC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;;kGAE8F;IAC9F,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,CAAC;IACpC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,qFAAqF;AACrF,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAKlE;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,EACvC,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,MAAM,CAAC,EACX,IAAI,GAAE,gBAAqB,GAC1B,CAAC,CAKH;AAaD;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE;IAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,GACrH;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAyC5D;AAWD;;;;;;;GAOG;AACH,wBAAgB,iCAAiC,CAC/C,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,MAAM,GACnB;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAe9C;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,GACZ;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAyBxD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,MAAM,EACd,GAAG,GAAE,MAAmB,GACvB;IAAE,WAAW,EAAE,MAAM,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,CAuC5C"}
@@ -0,0 +1,293 @@
1
+ /**
2
+ * The agentdb snapshot lock + reindex-in-progress marker (feature `agentdb-snapshot-lock`,
3
+ * FR-1..FR-4; fix-round AM-1..AM-4/AM-7 after Codex review Grade D).
4
+ *
5
+ * WHY. Snapshot creation (in `reindexAgentdbRows`), snapshot rotation (`rotatePreReindexSnapshots`,
6
+ * including `dz brain snapshots --prune`) and snapshot restore (`restoreSqliteSnapshot` via
7
+ * `reindexAgentdbRows`'s rollback) are three writers of ONE directory with no mutual exclusion: a
8
+ * concurrent `rotate --keep 0` can delete the very snapshot family a live reindex is relying on as
9
+ * its undo point, with only a 10-minute grace period standing in the way.
10
+ *
11
+ * {@link withAgentdbSnapshotLock} is a thin, dbFile-addressed wrapper over `withNamedLockSync`: the
12
+ * lock lives at `<dirname(dbFile)>/.dz/locks/agentdb-snapshot.lock` — a pure function of the
13
+ * database's OWN directory, never of the caller's cwd, so a project store and the home brain each
14
+ * get their own lock (AC-5) regardless of where `dz` happens to be invoked from. AM-7: it defaults
15
+ * `staleMs` to {@link AGENTDB_SNAPSHOT_LOCK_STALE_MS} (5 minutes) rather than named-lock's ordinary
16
+ * 30-second default — MEASURED (lead, this fix round): `VACUUM INTO` on the owner's hub store
17
+ * (8.45 MB) took 96 ms, so 5 minutes leaves roughly 3000x headroom while staying inside
18
+ * named-lock's own 600 000 ms environment-override ceiling. A caller doing something unusually
19
+ * large may still override via `opts.staleMs`.
20
+ *
21
+ * The critical section this lock guards must stay SHORT and SYNCHRONOUS — file operations only
22
+ * (`VACUUM INTO` / copies / unlink / restore). MEASURED (named-lock.ts round-2, reused verbatim
23
+ * here): a 6s synchronous body under a 2s stale threshold let a THIRD process break in while the
24
+ * second was still inside its own critical section — a long body doesn't just block callers, it
25
+ * breaks the lock's own guarantee. The actual re-embedding (network/CPU-bound `EmbeddingService`
26
+ * calls, row writes) therefore runs OUTSIDE this lock — see `reindexAgentdbRows`.
27
+ *
28
+ * {@link writeReindexMarker} / {@link clearReindexMarker} / {@link readLiveReindexMarkers} implement
29
+ * the SECOND protection this feature adds — a "reindex is currently in flight" marker file beside
30
+ * the database, so a family a live reindex just created is protected from rotation for the
31
+ * DURATION of the reindex, not merely for the few milliseconds the snapshot lock itself is held
32
+ * (the embedding phase can run for a while, unlocked, per FR-2). The marker's liveness is judged by
33
+ * `startedAt` (a wall-clock age, {@link REINDEX_MARKER_TTL_MS}), never by `pid` — a pid is recorded
34
+ * for operator debugging only; a live pid on a different host, or a recycled pid, proves nothing
35
+ * ("pid is not authority over liveness" — the same lesson `store-lock.ts`/`named-lock.ts` already
36
+ * encode for lock staleness). A marker older than the TTL is treated as abandoned: it protects
37
+ * nothing, and rotation removes it, recording why in the report's `notes` — that is an honest
38
+ * observation about a stale marker, never a scan error.
39
+ *
40
+ * AM-1 (fix-round): {@link writeReindexMarker} creates the marker file EXCLUSIVELY
41
+ * (`openSync(path, 'wx')`) and stamps it with an ownership `token` (16 random hex chars). A LIVE
42
+ * marker already at that path (another reindex genuinely in flight) refuses this call outright —
43
+ * `{ ok: false, error }`, no snapshot ever taken for the refused call. A marker at or past the TTL
44
+ * is treated as abandoned and replaced. {@link clearReindexMarker} is compare-and-delete: it removes
45
+ * the marker only when the caller's `token` matches the one on disk, so a process can never tear
46
+ * down a marker it does not own (`{ cleared: false, reason }` otherwise). The narrow race this
47
+ * leaves — two `writeReindexMarker` calls racing the exclusive-create step itself — cannot happen
48
+ * in practice: every caller in this codebase invokes it from INSIDE the very snapshot lock this
49
+ * module also provides (see `reindexAgentdbRows`), so at most one writer is ever in this function
50
+ * at a time.
51
+ *
52
+ * @packageDocumentation
53
+ */
54
+ import { closeSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
55
+ import { randomBytes } from 'node:crypto';
56
+ import { basename, dirname } from 'node:path';
57
+ import { withNamedLockSync } from './named-lock.js';
58
+ /** A marker older than this is abandoned — its family is no longer protected (FR-3). `pid` is
59
+ * recorded for operator debugging only and is NEVER consulted to decide liveness. */
60
+ export const REINDEX_MARKER_TTL_MS = 60 * 60 * 1000;
61
+ /** AM-7: MEASURED (lead, this fix round) — `VACUUM INTO` on the hub's 8.45 MB agentdb store took
62
+ * 96 ms. 5 minutes is ~3000x that, and stays inside named-lock's 600 000 ms override ceiling. A
63
+ * base whose snapshot genuinely needs longer than this needs external coordination, not a bigger
64
+ * default — see the README. */
65
+ export const AGENTDB_SNAPSHOT_LOCK_STALE_MS = 300_000;
66
+ /** The marker's path — always beside the database, never in a separate directory. */
67
+ export function reindexMarkerPath(dbFile) {
68
+ return `${dbFile}.reindex-inprogress.json`;
69
+ }
70
+ /**
71
+ * AM-4: recompute a marker's `ms` from the ACTUAL backup path's filename — decoupled from whatever
72
+ * internal `ms` variable a caller used to build a DEFAULT `backupPath`. A caller-supplied
73
+ * `opts.backupPath` that does not end in the standard `.pre-reindex-<digits>.bak` shape (or whose
74
+ * digits overflow a safe integer) names no family at all, so it maps to `null` — such a target is
75
+ * never a rotation candidate, and the marker must not claim to protect one.
76
+ */
77
+ export function msFromBackupPath(backupPath) {
78
+ const match = /\.pre-reindex-(\d+)\.bak$/.exec(basename(backupPath));
79
+ if (match === null)
80
+ return null;
81
+ const ms = Number(match[1]);
82
+ return Number.isSafeInteger(ms) ? ms : null;
83
+ }
84
+ /**
85
+ * Run `fn` while holding the agentdb snapshot lock for `dbFile` — a pure function of the
86
+ * database's own directory (AC-5), never of `process.cwd()`. Propagates `NamedLockTimeoutError`
87
+ * (FR-4): a caller that cannot acquire the lock by the deadline must report the failure explicitly,
88
+ * never proceed unlocked and never silently skip its work. AM-7: `staleMs` defaults to
89
+ * {@link AGENTDB_SNAPSHOT_LOCK_STALE_MS} unless the caller names its own.
90
+ */
91
+ export function withAgentdbSnapshotLock(dbFile, fn, opts = {}) {
92
+ return withNamedLockSync(dirname(dbFile), 'agentdb-snapshot', fn, {
93
+ staleMs: AGENTDB_SNAPSHOT_LOCK_STALE_MS,
94
+ ...opts,
95
+ });
96
+ }
97
+ /** Read + best-effort-parse the marker at `path`; `undefined` on any missing/unreadable/corrupt
98
+ * field — callers treat that identically to "no marker" or "not provably live", never as live. */
99
+ function tryReadStartedAt(path) {
100
+ try {
101
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
102
+ return typeof parsed.startedAt === 'number' && Number.isFinite(parsed.startedAt) ? parsed.startedAt : undefined;
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ }
108
+ /**
109
+ * AM-1: write the reindex-in-progress marker EXCLUSIVELY. A marker already at this path that is
110
+ * still LIVE (younger than {@link REINDEX_MARKER_TTL_MS}) refuses this write outright —
111
+ * `{ ok: false, error }` naming the marker path, with no snapshot ever attempted by the caller for
112
+ * this call (the caller checks `ok` BEFORE touching the database — see `reindexAgentdbRows`). A
113
+ * marker that is missing, unreadable/corrupt, or past the TTL is replaced. The written marker
114
+ * carries a fresh ownership `token`; only {@link clearReindexMarker} presenting that SAME token may
115
+ * remove it.
116
+ */
117
+ export function writeReindexMarker(dbFile, marker) {
118
+ const path = reindexMarkerPath(dbFile);
119
+ const token = randomBytes(8).toString('hex'); // 16 hex chars
120
+ const full = { ...marker, token };
121
+ const content = `${JSON.stringify(full)}\n`;
122
+ const tryCreate = () => {
123
+ let fd;
124
+ try {
125
+ fd = openSync(path, 'wx'); // exclusive create — EEXIST if a marker is already there
126
+ }
127
+ catch (err) {
128
+ if (err.code === 'EEXIST')
129
+ return false;
130
+ throw err;
131
+ }
132
+ try {
133
+ writeFileSync(fd, content);
134
+ }
135
+ finally {
136
+ closeSync(fd);
137
+ }
138
+ return true;
139
+ };
140
+ if (tryCreate())
141
+ return { ok: true, token };
142
+ const existing = tryReadMarker(path);
143
+ if (existing?.requiresRecovery === true) {
144
+ return { ok: false, error: `recovery required: a previous reindex of this store failed to roll back — restore ${existing.backupPath ?? 'its snapshot'} manually, then remove marker ${path}` };
145
+ }
146
+ const existingStartedAt = tryReadStartedAt(path);
147
+ const live = existingStartedAt !== undefined && Date.now() - existingStartedAt < REINDEX_MARKER_TTL_MS;
148
+ if (live) {
149
+ return { ok: false, error: `reindex already in progress (marker ${path})` };
150
+ }
151
+ // Stale, missing-field, or unparseable — treated as abandoned and replaced.
152
+ rmSync(path, { force: true });
153
+ if (!tryCreate()) {
154
+ // Practically unreachable (nothing else writes this path outside this module's own lock), but
155
+ // never silently claim success over an unexplained race.
156
+ return { ok: false, error: `reindex already in progress (marker ${path})` };
157
+ }
158
+ return { ok: true, token };
159
+ }
160
+ function tryReadMarker(path) {
161
+ try {
162
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
163
+ return parsed !== null && typeof parsed === 'object' ? parsed : undefined;
164
+ }
165
+ catch {
166
+ return undefined;
167
+ }
168
+ }
169
+ /**
170
+ * Lead edit after re-review (Codex D, finding 1): a marker left behind by a FAILED rollback must not
171
+ * quietly expire after the TTL — the snapshot it names may be the only good copy of the store. The
172
+ * owner (token) rewrites the marker atomically (tmp + rename) with `requiresRecovery: true`; from
173
+ * then on {@link readLiveReindexMarkers} protects its family without expiry and
174
+ * {@link writeReindexMarker} refuses every new reindex until an operator removes the marker.
175
+ * Call it under the snapshot lock, like every other marker mutation.
176
+ */
177
+ export function markReindexMarkerRecoveryRequired(dbFile, token, recoveryNote) {
178
+ const path = reindexMarkerPath(dbFile);
179
+ const existing = tryReadMarker(path);
180
+ if (existing === undefined)
181
+ return { ok: false, reason: `marker at ${path} is missing or unreadable` };
182
+ if (existing.token !== token)
183
+ return { ok: false, reason: `marker at ${path} is owned by a different token` };
184
+ const full = { ...existing, requiresRecovery: true, recoveryNote };
185
+ const tmp = `${path}.${process.pid}.tmp`;
186
+ try {
187
+ writeFileSync(tmp, `${JSON.stringify(full)}\n`);
188
+ renameSync(tmp, path);
189
+ return { ok: true };
190
+ }
191
+ catch (err) {
192
+ try {
193
+ rmSync(tmp, { force: true });
194
+ }
195
+ catch { /* best-effort */ }
196
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
197
+ }
198
+ }
199
+ /**
200
+ * AM-1: compare-and-delete. Removes the marker ONLY when its `token` matches the one on disk — a
201
+ * caller can never tear down a marker it does not own. Absence of the marker file is treated as an
202
+ * already-cleared success (idempotent). Called from `reindexAgentdbRows`'s cleanup on every path
203
+ * that does NOT leave a failed rollback behind (AM-3) — "absence of a receipt is not success" cuts
204
+ * the other way here too: a marker left behind after its owner finished would falsely protect a
205
+ * family forever (until the TTL), so ownership-checked removal must still run unconditionally on
206
+ * every path that is safe to clear.
207
+ */
208
+ export function clearReindexMarker(dbFile, token) {
209
+ const path = reindexMarkerPath(dbFile);
210
+ let raw;
211
+ try {
212
+ raw = readFileSync(path, 'utf8');
213
+ }
214
+ catch (err) {
215
+ if (err.code === 'ENOENT')
216
+ return { cleared: true };
217
+ return { cleared: false, reason: `cannot read marker: ${err instanceof Error ? err.message : String(err)}` };
218
+ }
219
+ let parsed;
220
+ try {
221
+ parsed = JSON.parse(raw);
222
+ }
223
+ catch {
224
+ return { cleared: false, reason: 'marker is not owned by this token (unparseable marker content)' };
225
+ }
226
+ const owner = parsed !== null && typeof parsed === 'object' ? parsed.token : undefined;
227
+ if (owner !== token) {
228
+ return { cleared: false, reason: `marker at ${path} is owned by a different token` };
229
+ }
230
+ try {
231
+ rmSync(path, { force: true });
232
+ return { cleared: true };
233
+ }
234
+ catch (err) {
235
+ return { cleared: false, reason: `removal failed: ${err instanceof Error ? err.message : String(err)}` };
236
+ }
237
+ }
238
+ /**
239
+ * Read the marker beside `dbFile` and classify it: a marker younger than
240
+ * {@link REINDEX_MARKER_TTL_MS} protects its `ms` (FR-3) — but ONLY when `ms` is an actual number
241
+ * (AM-4: a `ms: null` marker names no family, so it protects nothing, live or not). A marker at or
242
+ * past the TTL is abandoned — it is removed here (rotation must not act on stale-but-still-present
243
+ * state on its NEXT call). AM-6: the note reports "ignored and removed" ONLY once `rmSync` actually
244
+ * succeeded; a removal failure (e.g. a permission error) is reported as "ignored, removal failed:
245
+ * <err>" — never silently claimed as removed. No marker, or a marker this function cannot parse
246
+ * (corrupt / missing fields), yields `{ protectedMs: [], notes: [] }` — never a throw: a malformed
247
+ * marker must not abort rotation, and it also cannot honestly claim to protect anything.
248
+ */
249
+ export function readLiveReindexMarkers(dbFile, now = Date.now()) {
250
+ const path = reindexMarkerPath(dbFile);
251
+ let raw;
252
+ try {
253
+ raw = readFileSync(path, 'utf8');
254
+ }
255
+ catch {
256
+ return { protectedMs: [], notes: [] };
257
+ }
258
+ let parsed;
259
+ try {
260
+ parsed = JSON.parse(raw);
261
+ }
262
+ catch {
263
+ return { protectedMs: [], notes: [] };
264
+ }
265
+ if (parsed === null || typeof parsed !== 'object')
266
+ return { protectedMs: [], notes: [] };
267
+ const { ms, startedAt } = parsed;
268
+ if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) {
269
+ return { protectedMs: [], notes: [] };
270
+ }
271
+ const hasFamily = typeof ms === 'number' && Number.isFinite(ms);
272
+ if (parsed.requiresRecovery === true) {
273
+ const label = hasFamily ? `ms=${ms}` : 'ms=(none — non-standard backupPath)';
274
+ return { protectedMs: hasFamily ? [ms] : [], notes: [`recovery-required reindex marker (${label}) protects its family without expiry — restore the snapshot manually, then remove ${path}`] };
275
+ }
276
+ if (now - startedAt < REINDEX_MARKER_TTL_MS) {
277
+ return { protectedMs: hasFamily ? [ms] : [], notes: [] };
278
+ }
279
+ // AM-6: only claim "removed" once `rmSync` actually succeeded.
280
+ let removalError;
281
+ try {
282
+ rmSync(path, { force: true });
283
+ }
284
+ catch (err) {
285
+ removalError = err instanceof Error ? err.message : String(err);
286
+ }
287
+ const label = hasFamily ? `ms=${ms}` : 'ms=(none — non-standard backupPath)';
288
+ const note = removalError === undefined
289
+ ? `stale reindex marker (${label}, startedAt=${new Date(startedAt).toISOString()}) ignored and removed`
290
+ : `stale reindex marker (${label}, startedAt=${new Date(startedAt).toISOString()}) ignored, removal failed: ${removalError}`;
291
+ return { protectedMs: [], notes: [note] };
292
+ }
293
+ //# sourceMappingURL=agentdb-reindex-marker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agentdb-reindex-marker.js","sourceRoot":"","sources":["../src/agentdb-reindex-marker.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,169 @@
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
+ /** One file belonging to a snapshot family — always a basename, never a full path. */
17
+ export interface SnapshotFile {
18
+ readonly name: string;
19
+ readonly bytes: number;
20
+ }
21
+ /** Every file sharing one `<ms>` timestamp — the unit rotation acts on (FR-2). */
22
+ export interface SnapshotFamily {
23
+ readonly ms: number;
24
+ readonly files: readonly SnapshotFile[];
25
+ readonly bytes: number;
26
+ }
27
+ /** A rotation decision: which families survive, which are slated for removal. */
28
+ export interface SnapshotRotationPlan {
29
+ readonly kept: readonly SnapshotFamily[];
30
+ readonly removed: readonly SnapshotFamily[];
31
+ }
32
+ /** The fs-effect outcome (FR-4) — reported poimённо (by file name), never just a count. */
33
+ export interface SnapshotRotationReport {
34
+ readonly kept: string[];
35
+ readonly removed: string[];
36
+ readonly removedBytes: number;
37
+ readonly keep: number;
38
+ /** Per-file unlink failures (FR-5) — never fatal to the caller's own success. */
39
+ readonly errors?: string[];
40
+ /**
41
+ * AM-4 (fix-round after Codex review Grade D): a `readdirSync`/`lstatSync` failure other than
42
+ * ENOENT makes the candidate list INCOMPLETE — a family the scan never saw would look "not old"
43
+ * only because it was invisible, never because it truly survives review. Present ⇒ this call
44
+ * removed NOTHING, however conservative that looks against a nonzero `kept`.
45
+ */
46
+ readonly scanErrors?: string[];
47
+ /**
48
+ * AM-2: `<ms>` of every family where a sibling unlink failed and the family's `.bak` — the one
49
+ * file that alone still proves the snapshot ever existed — was therefore left in place rather
50
+ * than deleted out from under a family whose removal turned out to be only partial.
51
+ */
52
+ readonly partialFamilies?: number[];
53
+ /**
54
+ * FR-3 (agentdb-snapshot-lock): honest observations that are neither an error nor a removal —
55
+ * today, exactly one shape: a reindex-in-progress marker found PAST its TTL, ignored and deleted.
56
+ * Never populated for a marker that is still live (it simply protects its family silently) nor
57
+ * for the ordinary case of no marker at all.
58
+ */
59
+ readonly notes?: string[];
60
+ }
61
+ /**
62
+ * Scan the directory next to `dbFile` for candidate families — the shared innards of
63
+ * {@link listPreReindexSnapshots} (which drops `scanErrors` for callers that only ever listed) and
64
+ * {@link rotatePreReindexSnapshots} (which must see them, per AM-4: an incomplete scan must never be
65
+ * read as "these are all the old ones").
66
+ *
67
+ * NFR-1: symlinks are never followed and never treated as candidates. `lstatSync` — never `stat` —
68
+ * decides this, so a matching NAME that resolves to a symlink is excluded entirely (AC-3), not
69
+ * "rotated by its link size". An orphaned sibling (e.g. a `-shm` with no `.bak`) still forms its
70
+ * own one-file family under its `<ms>` (AC-5) — FR-2 groups by timestamp, not by which siblings
71
+ * happen to exist.
72
+ *
73
+ * AM-5: a `<ms>` that is not a safe non-negative integer within `Date`'s representable range
74
+ * (`±8.64e15`) is never grouped into a candidate family — so it never reaches `new Date(ms)`, which
75
+ * throws `RangeError` past that bound, and it is never silently treated as "the oldest one".
76
+ */
77
+ export declare function scanSnapshotDir(dbFile: string): {
78
+ families: SnapshotFamily[];
79
+ scanErrors: string[];
80
+ };
81
+ /**
82
+ * List every pre-reindex snapshot family sitting next to `dbFile`, newest `ms` first (families
83
+ * unsorted internally are sorted here; per-family files are name-sorted for determinism). See
84
+ * {@link scanSnapshotDir} for the scan rules (NFR-1, AM-5); this wrapper drops `scanErrors` — callers
85
+ * that only ever want to LIST (never delete) have no unsafe decision to gate on them.
86
+ */
87
+ export declare function listPreReindexSnapshots(dbFile: string): SnapshotFamily[];
88
+ /**
89
+ * Pure decision (NFR-2, zero fs): given the families found in a directory, decide which survive.
90
+ * The newest `keep` families survive (`keep` clamped to ≥ 0); the freshly created backup is
91
+ * normally the newest family, so it is ordinarily already inside that top-`keep` slice. `protectMs`
92
+ * names it explicitly and is the FALLBACK that fires only when it would otherwise fall OUTSIDE that
93
+ * slice — the boundary case named by FR-1: at `keep=0` the top slice is empty, yet the snapshot this
94
+ * very call just wrote must never be deleted. `families` need not be pre-sorted.
95
+ *
96
+ * AM-3 (fix-round): a family younger than `graceMs` (default {@link DEFAULT_GRACE_MS}, 10 minutes;
97
+ * `ms > now - graceMs`) is rescued exactly like `protectMs`, even past `keep` — a fresh snapshot from
98
+ * a DIFFERENT process/run than the one calling this must never look "old" just because nobody named
99
+ * it via `protectMs`. There is no override: `now`/`graceMs` exist for deterministic tests, not for a
100
+ * CLI knob that would let someone talk the grace period down to zero.
101
+ */
102
+ export declare function planSnapshotRotation(families: readonly SnapshotFamily[], opts: {
103
+ readonly keep: number;
104
+ readonly protectMs?: number;
105
+ readonly now?: number;
106
+ readonly graceMs?: number;
107
+ }): SnapshotRotationPlan;
108
+ /** Options shared by {@link rotatePreReindexSnapshotsUnlocked} and {@link rotatePreReindexSnapshots}. */
109
+ export interface RotateSnapshotsOptions {
110
+ readonly keep?: number;
111
+ readonly protectPath?: string;
112
+ readonly now?: number;
113
+ readonly graceMs?: number;
114
+ }
115
+ /**
116
+ * The fs-effect half: list, plan, delete. A per-file `unlink` failure lands in `errors[]` and never
117
+ * stops the rest of the rotation (FR-5) — by the time this runs, the caller has already produced a
118
+ * successful reindex, and a rotation hiccup must never be read as a reindex failure. `removedBytes`
119
+ * counts only bytes of files ACTUALLY unlinked, so a partial failure never overstates what was freed.
120
+ *
121
+ * AM-1 (fix-round, Codex review Grade D): `keep` is validated HERE, before any fs read or delete.
122
+ * The bug this closes: `Math.max(0, NaN)` is `NaN`, and `sorted.slice(0, NaN)` is `[]` — an EMPTY
123
+ * kept slice, so every existing family looked "older than keep" and got removed. A `keep` that is
124
+ * not a non-negative safe integer is refused outright; nothing is read, nothing is touched.
125
+ *
126
+ * AM-4: a scan error (readdir/lstat failing with anything but ENOENT) makes the candidate list
127
+ * INCOMPLETE — deciding "these are the old ones" off a partial list is exactly the class of mistake
128
+ * this rotation exists to avoid for the snapshots themselves, so this call removes NOTHING.
129
+ *
130
+ * AM-2: within a removed family, siblings unlink FIRST and the `.bak` LAST, and only once every
131
+ * sibling actually unlinked — a failed sibling leaves the `.bak` in place (the one file that alone
132
+ * still proves the snapshot ever existed) and names the family in `partialFamilies` rather than
133
+ * guessing the family is gone.
134
+ *
135
+ * FR-3 (agentdb-snapshot-lock): a family named by a LIVE reindex-in-progress marker
136
+ * ({@link readLiveReindexMarkers}) is rescued from `removed` exactly like `protectPath`, even past
137
+ * `keep` and even past `graceMs` — a reindex that is still running must never lose the undo point it
138
+ * is relying on. An EXPIRED marker is not a scan error: it is removed and named in `notes`.
139
+ *
140
+ * UNLOCKED by design (NFR-2): this is the internal primitive `rotatePreReindexSnapshots` (the public,
141
+ * locked entry point) and `reindexAgentdbRows`'s own success-path rotation both call — each under
142
+ * their OWN `withAgentdbSnapshotLock`, so the lock is never acquired twice in one call stack.
143
+ */
144
+ export declare function rotatePreReindexSnapshotsUnlocked(dbFile: string, opts?: RotateSnapshotsOptions): SnapshotRotationReport;
145
+ /**
146
+ * The PUBLIC, locked rotation entry point (FR-1) — used by `dz brain snapshots --prune` and by any
147
+ * other caller outside `reindexAgentdbRows`'s own success path (which calls
148
+ * {@link rotatePreReindexSnapshotsUnlocked} directly under its own lock, per NFR-2). A
149
+ * `NamedLockTimeoutError` — the lock stayed held past the deadline — is reported exactly like any
150
+ * other rotation refusal: `{ removed: [], errors: ['lock busy: …'] }` (FR-4), never a throw.
151
+ *
152
+ * `lockTimeoutMs` is test/tuning-only: omitted, the lock uses its ordinary default deadline.
153
+ *
154
+ * AM-4 (carried forward): `withNamedLockSync` creates `<dirname(dbFile)>/.dz/locks/` before it ever
155
+ * runs `fn` — if `dirname(dbFile)` exists but is NOT a directory (a pathological store path, exactly
156
+ * the shape AM-4's own fixture uses), that `mkdirSync` throws `ENOTDIR` before any lock is even
157
+ * attempted. Scanning is therefore checked FIRST, outside the lock: an unreadable directory is a
158
+ * property of the directory, not of contention over it, and must report the SAME honest
159
+ * `scanErrors` (never touching, never throwing) whether or not locking is even reachable. MEASURED
160
+ * (fix round, this feature): omitting this pre-check turned the pre-existing
161
+ * `rotatePreReindexSnapshots(brokenDbFile, …)` / `dz brain snapshots --prune` "unscannable
162
+ * directory" fixtures from a clean `scanErrors` report into an uncaught `ENOTDIR` thrown out of the
163
+ * lock's own `mkdirSync` — reddening both `test/agentdb-snapshot-rotation.test.ts`'s AM-4 case and
164
+ * `harness-cli/test/brain-snapshots.test.ts`'s "prune mode: an unscannable store directory" case.
165
+ */
166
+ export declare function rotatePreReindexSnapshots(dbFile: string, opts?: RotateSnapshotsOptions & {
167
+ readonly lockTimeoutMs?: number;
168
+ }): SnapshotRotationReport;
169
+ //# sourceMappingURL=agentdb-snapshot-rotation.d.ts.map