@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.
- package/.dz-manifest.json +233 -93
- package/README.md +269 -0
- package/dist/agentdb-index.d.ts +45 -0
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +345 -85
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-reindex-marker.d.ts +170 -0
- package/dist/agentdb-reindex-marker.d.ts.map +1 -0
- package/dist/agentdb-reindex-marker.js +293 -0
- package/dist/agentdb-reindex-marker.js.map +1 -0
- package/dist/agentdb-snapshot-rotation.d.ts +169 -0
- package/dist/agentdb-snapshot-rotation.d.ts.map +1 -0
- package/dist/agentdb-snapshot-rotation.js +322 -0
- package/dist/agentdb-snapshot-rotation.js.map +1 -0
- package/dist/agentdb-snapshot.d.ts +126 -0
- package/dist/agentdb-snapshot.d.ts.map +1 -0
- package/dist/agentdb-snapshot.js +195 -0
- package/dist/agentdb-snapshot.js.map +1 -0
- package/dist/apply-leg.d.ts +202 -0
- package/dist/apply-leg.d.ts.map +1 -0
- package/dist/apply-leg.js +975 -0
- package/dist/apply-leg.js.map +1 -0
- package/dist/book-kb.d.ts.map +1 -1
- package/dist/book-kb.js +57 -1
- package/dist/book-kb.js.map +1 -1
- package/dist/brain.d.ts +3 -0
- package/dist/brain.d.ts.map +1 -1
- package/dist/brain.js +1 -1
- package/dist/brain.js.map +1 -1
- package/dist/cadence.d.ts +30 -1
- package/dist/cadence.d.ts.map +1 -1
- package/dist/cadence.js +66 -2
- package/dist/cadence.js.map +1 -1
- package/dist/guard.d.ts +14 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +36 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +23 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -5
- package/dist/index.js.map +1 -1
- package/dist/integration-probe-worker.d.ts.map +1 -1
- package/dist/integration-probe-worker.js +4 -6
- package/dist/integration-probe-worker.js.map +1 -1
- package/dist/integrations-verify.d.ts.map +1 -1
- package/dist/integrations-verify.js +3 -1
- package/dist/integrations-verify.js.map +1 -1
- package/dist/managed-hooks.d.ts +11 -2
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +33 -7
- package/dist/managed-hooks.js.map +1 -1
- package/dist/mutation-gate.d.ts +35 -1
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +49 -9
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +94 -0
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +23 -4
- package/dist/patterns.js.map +1 -1
- package/dist/repository-origin.d.ts +1 -1
- package/dist/repository-origin.d.ts.map +1 -1
- package/dist/repository-origin.js +7 -1
- package/dist/repository-origin.js.map +1 -1
- package/dist/round-exec.d.ts +41 -0
- package/dist/round-exec.d.ts.map +1 -0
- package/dist/round-exec.js +45 -0
- package/dist/round-exec.js.map +1 -0
- package/dist/round.d.ts +109 -0
- package/dist/round.d.ts.map +1 -0
- package/dist/round.js +148 -0
- package/dist/round.js.map +1 -0
- package/dist/run-records.d.ts +8 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +69 -3
- package/dist/run-records.js.map +1 -1
- package/dist/setup.d.ts +8 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +166 -3
- package/dist/setup.js.map +1 -1
- package/dist/sqlite-read-helpers.d.ts +60 -0
- package/dist/sqlite-read-helpers.d.ts.map +1 -0
- package/dist/sqlite-read-helpers.js +118 -0
- package/dist/sqlite-read-helpers.js.map +1 -0
- package/dist/statusline.d.ts +20 -3
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +40 -7
- package/dist/statusline.js.map +1 -1
- package/dist/store-counts.d.ts +30 -0
- package/dist/store-counts.d.ts.map +1 -1
- package/dist/store-counts.js +189 -0
- package/dist/store-counts.js.map +1 -1
- package/dist/usage.d.ts +55 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +184 -33
- package/dist/usage.js.map +1 -1
- package/dist/vector-tier.d.ts +18 -1
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +26 -2
- package/dist/vector-tier.js.map +1 -1
- package/package.json +6 -6
- package/sbom.json +442 -92
- package/src/agentdb-index.ts +315 -32
- package/src/agentdb-reindex-marker.ts +330 -0
- package/src/agentdb-snapshot-rotation.ts +393 -0
- package/src/agentdb-snapshot.ts +222 -0
- package/src/apply-leg.ts +1046 -0
- package/src/book-kb.ts +64 -1
- package/src/brain.ts +11 -2
- package/src/cadence.ts +87 -2
- package/src/guard.ts +46 -0
- package/src/index.ts +58 -5
- package/src/integration-probe-worker.ts +4 -6
- package/src/integrations-verify.ts +3 -1
- package/src/managed-hooks.ts +32 -7
- package/src/mutation-gate.ts +87 -8
- package/src/operations.ts +89 -0
- package/src/patterns.ts +23 -4
- package/src/repository-origin.ts +7 -1
- package/src/round-exec.ts +85 -0
- package/src/round.ts +228 -0
- package/src/run-records.ts +77 -2
- package/src/setup.ts +198 -3
- package/src/sqlite-read-helpers.ts +128 -0
- package/src/statusline.ts +57 -9
- package/src/store-counts.ts +245 -1
- package/src/usage.ts +243 -29
- package/src/vector-tier.ts +45 -4
|
@@ -0,0 +1,330 @@
|
|
|
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
|
+
|
|
55
|
+
import { closeSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
56
|
+
import { randomBytes } from 'node:crypto';
|
|
57
|
+
import { basename, dirname } from 'node:path';
|
|
58
|
+
|
|
59
|
+
import { withNamedLockSync } from './named-lock.js';
|
|
60
|
+
import type { StoreLockOptions } from './store-lock.js';
|
|
61
|
+
|
|
62
|
+
/** A marker older than this is abandoned — its family is no longer protected (FR-3). `pid` is
|
|
63
|
+
* recorded for operator debugging only and is NEVER consulted to decide liveness. */
|
|
64
|
+
export const REINDEX_MARKER_TTL_MS = 60 * 60 * 1000;
|
|
65
|
+
|
|
66
|
+
/** AM-7: MEASURED (lead, this fix round) — `VACUUM INTO` on the hub's 8.45 MB agentdb store took
|
|
67
|
+
* 96 ms. 5 minutes is ~3000x that, and stays inside named-lock's 600 000 ms override ceiling. A
|
|
68
|
+
* base whose snapshot genuinely needs longer than this needs external coordination, not a bigger
|
|
69
|
+
* default — see the README. */
|
|
70
|
+
export const AGENTDB_SNAPSHOT_LOCK_STALE_MS = 300_000;
|
|
71
|
+
|
|
72
|
+
/** The reindex-in-progress marker written beside the database while a reindex is running. */
|
|
73
|
+
export interface ReindexMarker {
|
|
74
|
+
/** The `<ms>` timestamp of THIS reindex's pre-reindex snapshot family — the value rotation must
|
|
75
|
+
* protect while the marker is live. AM-4: `null` when the reindex's `backupPath` does not match
|
|
76
|
+
* the standard `<db>.pre-reindex-<ms>.bak` family shape (a non-standard target names no family,
|
|
77
|
+
* so it is never a rotation candidate in the first place). */
|
|
78
|
+
readonly ms: number | null;
|
|
79
|
+
/** Operator-debugging only — never consulted for liveness (pid is not authority). */
|
|
80
|
+
readonly pid: number;
|
|
81
|
+
/** Wall-clock start time (`Date.now()`); liveness = `now - startedAt < REINDEX_MARKER_TTL_MS`. */
|
|
82
|
+
readonly startedAt: number;
|
|
83
|
+
/** AM-1: ownership token (16 random hex chars) — {@link clearReindexMarker} removes the marker
|
|
84
|
+
* only when the caller presents this exact token back. */
|
|
85
|
+
readonly token: string;
|
|
86
|
+
/** AM-4: the reindex's actual backup path, always recorded — the only way to identify the
|
|
87
|
+
* snapshot a `ms: null` marker is protecting (an operator debugging a stuck non-standard target
|
|
88
|
+
* has nothing else to go on). */
|
|
89
|
+
readonly backupPath?: string;
|
|
90
|
+
/** Lead edit after re-review (Codex D): set by {@link markReindexMarkerRecoveryRequired} when a
|
|
91
|
+
* rollback FAILED. Such a marker never expires — its family stays protected and every new reindex
|
|
92
|
+
* of this store is refused until an operator restores the snapshot and removes the marker. */
|
|
93
|
+
readonly requiresRecovery?: boolean;
|
|
94
|
+
readonly recoveryNote?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The marker's path — always beside the database, never in a separate directory. */
|
|
98
|
+
export function reindexMarkerPath(dbFile: string): string {
|
|
99
|
+
return `${dbFile}.reindex-inprogress.json`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* AM-4: recompute a marker's `ms` from the ACTUAL backup path's filename — decoupled from whatever
|
|
104
|
+
* internal `ms` variable a caller used to build a DEFAULT `backupPath`. A caller-supplied
|
|
105
|
+
* `opts.backupPath` that does not end in the standard `.pre-reindex-<digits>.bak` shape (or whose
|
|
106
|
+
* digits overflow a safe integer) names no family at all, so it maps to `null` — such a target is
|
|
107
|
+
* never a rotation candidate, and the marker must not claim to protect one.
|
|
108
|
+
*/
|
|
109
|
+
export function msFromBackupPath(backupPath: string): number | null {
|
|
110
|
+
const match = /\.pre-reindex-(\d+)\.bak$/.exec(basename(backupPath));
|
|
111
|
+
if (match === null) return null;
|
|
112
|
+
const ms = Number(match[1]);
|
|
113
|
+
return Number.isSafeInteger(ms) ? ms : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Run `fn` while holding the agentdb snapshot lock for `dbFile` — a pure function of the
|
|
118
|
+
* database's own directory (AC-5), never of `process.cwd()`. Propagates `NamedLockTimeoutError`
|
|
119
|
+
* (FR-4): a caller that cannot acquire the lock by the deadline must report the failure explicitly,
|
|
120
|
+
* never proceed unlocked and never silently skip its work. AM-7: `staleMs` defaults to
|
|
121
|
+
* {@link AGENTDB_SNAPSHOT_LOCK_STALE_MS} unless the caller names its own.
|
|
122
|
+
*/
|
|
123
|
+
export function withAgentdbSnapshotLock<T>(
|
|
124
|
+
dbFile: string,
|
|
125
|
+
fn: () => T,
|
|
126
|
+
opts: StoreLockOptions = {},
|
|
127
|
+
): T {
|
|
128
|
+
return withNamedLockSync(dirname(dbFile), 'agentdb-snapshot', fn, {
|
|
129
|
+
staleMs: AGENTDB_SNAPSHOT_LOCK_STALE_MS,
|
|
130
|
+
...opts,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Read + best-effort-parse the marker at `path`; `undefined` on any missing/unreadable/corrupt
|
|
135
|
+
* field — callers treat that identically to "no marker" or "not provably live", never as live. */
|
|
136
|
+
function tryReadStartedAt(path: string): number | undefined {
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<ReindexMarker>;
|
|
139
|
+
return typeof parsed.startedAt === 'number' && Number.isFinite(parsed.startedAt) ? parsed.startedAt : undefined;
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* AM-1: write the reindex-in-progress marker EXCLUSIVELY. A marker already at this path that is
|
|
147
|
+
* still LIVE (younger than {@link REINDEX_MARKER_TTL_MS}) refuses this write outright —
|
|
148
|
+
* `{ ok: false, error }` naming the marker path, with no snapshot ever attempted by the caller for
|
|
149
|
+
* this call (the caller checks `ok` BEFORE touching the database — see `reindexAgentdbRows`). A
|
|
150
|
+
* marker that is missing, unreadable/corrupt, or past the TTL is replaced. The written marker
|
|
151
|
+
* carries a fresh ownership `token`; only {@link clearReindexMarker} presenting that SAME token may
|
|
152
|
+
* remove it.
|
|
153
|
+
*/
|
|
154
|
+
export function writeReindexMarker(
|
|
155
|
+
dbFile: string,
|
|
156
|
+
marker: { readonly ms: number | null; readonly pid: number; readonly startedAt: number; readonly backupPath?: string },
|
|
157
|
+
): { ok: true; token: string } | { ok: false; error: string } {
|
|
158
|
+
const path = reindexMarkerPath(dbFile);
|
|
159
|
+
const token = randomBytes(8).toString('hex'); // 16 hex chars
|
|
160
|
+
const full: ReindexMarker = { ...marker, token };
|
|
161
|
+
const content = `${JSON.stringify(full)}\n`;
|
|
162
|
+
|
|
163
|
+
const tryCreate = (): boolean => {
|
|
164
|
+
let fd: number;
|
|
165
|
+
try {
|
|
166
|
+
fd = openSync(path, 'wx'); // exclusive create — EEXIST if a marker is already there
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
writeFileSync(fd, content);
|
|
173
|
+
} finally {
|
|
174
|
+
closeSync(fd);
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
if (tryCreate()) return { ok: true, token };
|
|
180
|
+
|
|
181
|
+
const existing = tryReadMarker(path);
|
|
182
|
+
if (existing?.requiresRecovery === true) {
|
|
183
|
+
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}` };
|
|
184
|
+
}
|
|
185
|
+
const existingStartedAt = tryReadStartedAt(path);
|
|
186
|
+
const live = existingStartedAt !== undefined && Date.now() - existingStartedAt < REINDEX_MARKER_TTL_MS;
|
|
187
|
+
if (live) {
|
|
188
|
+
return { ok: false, error: `reindex already in progress (marker ${path})` };
|
|
189
|
+
}
|
|
190
|
+
// Stale, missing-field, or unparseable — treated as abandoned and replaced.
|
|
191
|
+
rmSync(path, { force: true });
|
|
192
|
+
if (!tryCreate()) {
|
|
193
|
+
// Practically unreachable (nothing else writes this path outside this module's own lock), but
|
|
194
|
+
// never silently claim success over an unexplained race.
|
|
195
|
+
return { ok: false, error: `reindex already in progress (marker ${path})` };
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, token };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function tryReadMarker(path: string): Partial<ReindexMarker> | undefined {
|
|
201
|
+
try {
|
|
202
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
203
|
+
return parsed !== null && typeof parsed === 'object' ? (parsed as Partial<ReindexMarker>) : undefined;
|
|
204
|
+
} catch {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Lead edit after re-review (Codex D, finding 1): a marker left behind by a FAILED rollback must not
|
|
211
|
+
* quietly expire after the TTL — the snapshot it names may be the only good copy of the store. The
|
|
212
|
+
* owner (token) rewrites the marker atomically (tmp + rename) with `requiresRecovery: true`; from
|
|
213
|
+
* then on {@link readLiveReindexMarkers} protects its family without expiry and
|
|
214
|
+
* {@link writeReindexMarker} refuses every new reindex until an operator removes the marker.
|
|
215
|
+
* Call it under the snapshot lock, like every other marker mutation.
|
|
216
|
+
*/
|
|
217
|
+
export function markReindexMarkerRecoveryRequired(
|
|
218
|
+
dbFile: string,
|
|
219
|
+
token: string,
|
|
220
|
+
recoveryNote: string,
|
|
221
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
222
|
+
const path = reindexMarkerPath(dbFile);
|
|
223
|
+
const existing = tryReadMarker(path);
|
|
224
|
+
if (existing === undefined) return { ok: false, reason: `marker at ${path} is missing or unreadable` };
|
|
225
|
+
if (existing.token !== token) return { ok: false, reason: `marker at ${path} is owned by a different token` };
|
|
226
|
+
const full = { ...existing, requiresRecovery: true, recoveryNote };
|
|
227
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
228
|
+
try {
|
|
229
|
+
writeFileSync(tmp, `${JSON.stringify(full)}\n`);
|
|
230
|
+
renameSync(tmp, path);
|
|
231
|
+
return { ok: true };
|
|
232
|
+
} catch (err) {
|
|
233
|
+
try { rmSync(tmp, { force: true }); } catch { /* best-effort */ }
|
|
234
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* AM-1: compare-and-delete. Removes the marker ONLY when its `token` matches the one on disk — a
|
|
240
|
+
* caller can never tear down a marker it does not own. Absence of the marker file is treated as an
|
|
241
|
+
* already-cleared success (idempotent). Called from `reindexAgentdbRows`'s cleanup on every path
|
|
242
|
+
* that does NOT leave a failed rollback behind (AM-3) — "absence of a receipt is not success" cuts
|
|
243
|
+
* the other way here too: a marker left behind after its owner finished would falsely protect a
|
|
244
|
+
* family forever (until the TTL), so ownership-checked removal must still run unconditionally on
|
|
245
|
+
* every path that is safe to clear.
|
|
246
|
+
*/
|
|
247
|
+
export function clearReindexMarker(
|
|
248
|
+
dbFile: string,
|
|
249
|
+
token: string,
|
|
250
|
+
): { cleared: true } | { cleared: false; reason: string } {
|
|
251
|
+
const path = reindexMarkerPath(dbFile);
|
|
252
|
+
let raw: string;
|
|
253
|
+
try {
|
|
254
|
+
raw = readFileSync(path, 'utf8');
|
|
255
|
+
} catch (err) {
|
|
256
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { cleared: true };
|
|
257
|
+
return { cleared: false, reason: `cannot read marker: ${err instanceof Error ? err.message : String(err)}` };
|
|
258
|
+
}
|
|
259
|
+
let parsed: unknown;
|
|
260
|
+
try {
|
|
261
|
+
parsed = JSON.parse(raw);
|
|
262
|
+
} catch {
|
|
263
|
+
return { cleared: false, reason: 'marker is not owned by this token (unparseable marker content)' };
|
|
264
|
+
}
|
|
265
|
+
const owner = parsed !== null && typeof parsed === 'object' ? (parsed as { token?: unknown }).token : undefined;
|
|
266
|
+
if (owner !== token) {
|
|
267
|
+
return { cleared: false, reason: `marker at ${path} is owned by a different token` };
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
rmSync(path, { force: true });
|
|
271
|
+
return { cleared: true };
|
|
272
|
+
} catch (err) {
|
|
273
|
+
return { cleared: false, reason: `removal failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Read the marker beside `dbFile` and classify it: a marker younger than
|
|
279
|
+
* {@link REINDEX_MARKER_TTL_MS} protects its `ms` (FR-3) — but ONLY when `ms` is an actual number
|
|
280
|
+
* (AM-4: a `ms: null` marker names no family, so it protects nothing, live or not). A marker at or
|
|
281
|
+
* past the TTL is abandoned — it is removed here (rotation must not act on stale-but-still-present
|
|
282
|
+
* state on its NEXT call). AM-6: the note reports "ignored and removed" ONLY once `rmSync` actually
|
|
283
|
+
* succeeded; a removal failure (e.g. a permission error) is reported as "ignored, removal failed:
|
|
284
|
+
* <err>" — never silently claimed as removed. No marker, or a marker this function cannot parse
|
|
285
|
+
* (corrupt / missing fields), yields `{ protectedMs: [], notes: [] }` — never a throw: a malformed
|
|
286
|
+
* marker must not abort rotation, and it also cannot honestly claim to protect anything.
|
|
287
|
+
*/
|
|
288
|
+
export function readLiveReindexMarkers(
|
|
289
|
+
dbFile: string,
|
|
290
|
+
now: number = Date.now(),
|
|
291
|
+
): { protectedMs: number[]; notes: string[] } {
|
|
292
|
+
const path = reindexMarkerPath(dbFile);
|
|
293
|
+
let raw: string;
|
|
294
|
+
try {
|
|
295
|
+
raw = readFileSync(path, 'utf8');
|
|
296
|
+
} catch {
|
|
297
|
+
return { protectedMs: [], notes: [] };
|
|
298
|
+
}
|
|
299
|
+
let parsed: unknown;
|
|
300
|
+
try {
|
|
301
|
+
parsed = JSON.parse(raw);
|
|
302
|
+
} catch {
|
|
303
|
+
return { protectedMs: [], notes: [] };
|
|
304
|
+
}
|
|
305
|
+
if (parsed === null || typeof parsed !== 'object') return { protectedMs: [], notes: [] };
|
|
306
|
+
const { ms, startedAt } = parsed as Partial<ReindexMarker>;
|
|
307
|
+
if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) {
|
|
308
|
+
return { protectedMs: [], notes: [] };
|
|
309
|
+
}
|
|
310
|
+
const hasFamily = typeof ms === 'number' && Number.isFinite(ms);
|
|
311
|
+
if ((parsed as Partial<ReindexMarker>).requiresRecovery === true) {
|
|
312
|
+
const label = hasFamily ? `ms=${ms}` : 'ms=(none — non-standard backupPath)';
|
|
313
|
+
return { protectedMs: hasFamily ? [ms] : [], notes: [`recovery-required reindex marker (${label}) protects its family without expiry — restore the snapshot manually, then remove ${path}`] };
|
|
314
|
+
}
|
|
315
|
+
if (now - startedAt < REINDEX_MARKER_TTL_MS) {
|
|
316
|
+
return { protectedMs: hasFamily ? [ms] : [], notes: [] };
|
|
317
|
+
}
|
|
318
|
+
// AM-6: only claim "removed" once `rmSync` actually succeeded.
|
|
319
|
+
let removalError: string | undefined;
|
|
320
|
+
try {
|
|
321
|
+
rmSync(path, { force: true });
|
|
322
|
+
} catch (err) {
|
|
323
|
+
removalError = err instanceof Error ? err.message : String(err);
|
|
324
|
+
}
|
|
325
|
+
const label = hasFamily ? `ms=${ms}` : 'ms=(none — non-standard backupPath)';
|
|
326
|
+
const note = removalError === undefined
|
|
327
|
+
? `stale reindex marker (${label}, startedAt=${new Date(startedAt).toISOString()}) ignored and removed`
|
|
328
|
+
: `stale reindex marker (${label}, startedAt=${new Date(startedAt).toISOString()}) ignored, removal failed: ${removalError}`;
|
|
329
|
+
return { protectedMs: [], notes: [note] };
|
|
330
|
+
}
|