@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
package/src/agentdb-index.ts
CHANGED
|
@@ -12,10 +12,19 @@
|
|
|
12
12
|
* @packageDocumentation
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync, mkdirSync } from 'node:fs';
|
|
16
|
-
import { join, dirname } from 'node:path';
|
|
15
|
+
import { existsSync, mkdirSync, copyFileSync, realpathSync } from 'node:fs';
|
|
16
|
+
import { join, dirname, resolve, relative, isAbsolute, basename } from 'node:path';
|
|
17
17
|
import { pathToFileURL } from 'node:url';
|
|
18
18
|
import { createRequire } from 'node:module';
|
|
19
|
+
|
|
20
|
+
import { openSqliteReadOnly } from '@dzhechkov/memory';
|
|
21
|
+
|
|
22
|
+
import { applyReadonlyPragmas } from './sqlite-read-helpers.js';
|
|
23
|
+
import { rotatePreReindexSnapshotsUnlocked, type SnapshotRotationReport } from './agentdb-snapshot-rotation.js';
|
|
24
|
+
import { snapshotSqliteDatabase, restoreSqliteSnapshot, type SnapshotMethod, type SnapshotDbCtor } from './agentdb-snapshot.js';
|
|
25
|
+
import { withAgentdbSnapshotLock, writeReindexMarker, clearReindexMarker, markReindexMarkerRecoveryRequired, reindexMarkerPath, msFromBackupPath } from './agentdb-reindex-marker.js';
|
|
26
|
+
import { NamedLockTimeoutError } from './named-lock.js';
|
|
27
|
+
import type { StoreLockOptions } from './store-lock.js';
|
|
19
28
|
// The backlog dedup embed form (PURE, zero-dep — no cycle): dz-backlog rows must be embedded in the
|
|
20
29
|
// SAME bounded form the dedup query uses, including through the reindex path.
|
|
21
30
|
import { BACKLOG_TASK_TYPE, dedupEmbedText } from './backlog-embed.js';
|
|
@@ -84,6 +93,48 @@ CREATE TABLE IF NOT EXISTS pattern_embeddings (
|
|
|
84
93
|
FOREIGN KEY (pattern_id) REFERENCES reasoning_patterns(id) ON DELETE CASCADE
|
|
85
94
|
);`;
|
|
86
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Create (or verify) an EMPTY AgentDB-schema store at `resolveAgentdbPath(projectRoot, dbPath)`,
|
|
98
|
+
* without indexing any rows (AM-4, feature `setup-installs-apply-leg`, dz-harness-hub issue #10
|
|
99
|
+
* defect 4).
|
|
100
|
+
*
|
|
101
|
+
* WHY THIS EXISTS: before this, `.dz/agentdb.db` came into being only as a side effect of the
|
|
102
|
+
* SessionEnd/PreCompact writer's first `dz consolidate` — so a project that had run
|
|
103
|
+
* `dz setup --memory agentdb` but not yet completed one full session had `memory.backend=agentdb`
|
|
104
|
+
* configured with NO database file at all, and any lesson taught in that window before the first
|
|
105
|
+
* consolidate had nothing to mirror into (the apply leg's daemon reads THIS file — see
|
|
106
|
+
* `dz-embed-daemon.mjs`). `dz setup`'s "Install apply-leg" step now calls this directly so the
|
|
107
|
+
* store exists from the moment setup finishes, not from the moment a session happens to end.
|
|
108
|
+
*
|
|
109
|
+
* SYNCHRONOUS deliberately: `runSetup` is a synchronous function (a `child_process.execSync`
|
|
110
|
+
* install already precedes every write it does), and creating an empty schema needs only
|
|
111
|
+
* `better-sqlite3` — never the async `EmbeddingService` {@link indexPatternsToAgentdb} loads for a
|
|
112
|
+
* real write. Reuses {@link REASONING_BANK_SCHEMA} verbatim — the ONE schema string every writer in
|
|
113
|
+
* this module execs — so this path can never drift into declaring a second, competing schema.
|
|
114
|
+
*
|
|
115
|
+
* Best-effort, like every setup step: a project without `better-sqlite3` installed yet (or one
|
|
116
|
+
* whose native binary is unusable) gets `{ok:false, error}` and setup reports it in the step detail
|
|
117
|
+
* rather than throwing — the writer/daemon still self-heal on the next session either way.
|
|
118
|
+
*/
|
|
119
|
+
export function ensureAgentdbSchema(projectRoot: string, dbPath?: string): { readonly ok: boolean; readonly error?: string } {
|
|
120
|
+
try {
|
|
121
|
+
const req = createRequire(join(projectRoot, 'package.json'));
|
|
122
|
+
const Database = req('better-sqlite3') as new (p: string) => NativeDb;
|
|
123
|
+
const dbFile = resolveAgentdbPath(projectRoot, dbPath);
|
|
124
|
+
mkdirSync(dirname(dbFile), { recursive: true });
|
|
125
|
+
const db = new Database(dbFile);
|
|
126
|
+
try {
|
|
127
|
+
db.pragma('journal_mode = WAL');
|
|
128
|
+
db.exec(REASONING_BANK_SCHEMA);
|
|
129
|
+
} finally {
|
|
130
|
+
db.close();
|
|
131
|
+
}
|
|
132
|
+
return { ok: true };
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return { ok: false, error: `agentdb schema init failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
87
138
|
/**
|
|
88
139
|
* Index `rows` into the shared AgentDB vector store. Returns `{indexed:0}` for an empty input and
|
|
89
140
|
* `{indexed:0, error}` when `agentdb`/`better-sqlite3` cannot be resolved from the project.
|
|
@@ -286,9 +337,35 @@ function openReadonly(projectRoot: string, dbPath?: string): { db: ReadonlyDb }
|
|
|
286
337
|
return { error: DEPS_MISSING };
|
|
287
338
|
}
|
|
288
339
|
try {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
340
|
+
// ADR-001: `{ readonly: true }` alone still fails `unable to open database file` on a
|
|
341
|
+
// directory that cannot create `-wal`/`-shm` — the ladder in `openSqliteReadOnly` falls
|
|
342
|
+
// back to a tmp copy instead, so this "best-effort, never throws" contract keeps working
|
|
343
|
+
// from a read-only-mounted sandbox too. `close()` on the returned handle removes the copy.
|
|
344
|
+
const handle = openSqliteReadOnly(dbFile, { Database });
|
|
345
|
+
const db = handle.db as ReadonlyDb;
|
|
346
|
+
// FR-1 (readonly-residuals): a throwing pragma must not leak the connection or a tmp-copy —
|
|
347
|
+
// applyReadonlyPragmas closes + cleans up before rethrowing.
|
|
348
|
+
applyReadonlyPragmas(handle, dbFile);
|
|
349
|
+
return {
|
|
350
|
+
// `ReadonlyDb` (above) intentionally exposes only `pragma`/`prepare`/`close` — the real
|
|
351
|
+
// better-sqlite3 instance underneath also has `transaction`/`exec`/etc, but no caller in
|
|
352
|
+
// this file uses them (confirmed, fix round 1, Q4/#8), so they stay hidden by the type on
|
|
353
|
+
// purpose. Widening `ReadonlyDb` to add a method should be a deliberate decision, not an
|
|
354
|
+
// incidental leak through `db as ReadonlyDb` above.
|
|
355
|
+
db: {
|
|
356
|
+
pragma: db.pragma.bind(db),
|
|
357
|
+
prepare: db.prepare.bind(db),
|
|
358
|
+
close: () => {
|
|
359
|
+
// `cleanup()` in `finally` — the tmp-copy must be removed even if `db.close()` throws
|
|
360
|
+
// (fix round 1, MEDIUM #3; matches `SqliteReadOnlyStore.close()` in `sqlite-readonly.ts`).
|
|
361
|
+
try {
|
|
362
|
+
db.close();
|
|
363
|
+
} finally {
|
|
364
|
+
handle.cleanup();
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
};
|
|
292
369
|
} catch (err) {
|
|
293
370
|
return { error: `open failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
294
371
|
}
|
|
@@ -761,7 +838,17 @@ export function bumpAgentdbUses(
|
|
|
761
838
|
export async function reindexAgentdbRows(
|
|
762
839
|
projectRoot: string,
|
|
763
840
|
rows: readonly AgentdbRow[],
|
|
764
|
-
opts: {
|
|
841
|
+
opts: {
|
|
842
|
+
dbPath?: string;
|
|
843
|
+
taskTypes?: readonly string[];
|
|
844
|
+
backupPath?: string;
|
|
845
|
+
keepSnapshots?: number;
|
|
846
|
+
/** Force the fallback snapshot path — test-only injection for AC-2 (FR-2). */
|
|
847
|
+
snapshotStrategy?: 'vacuum-into' | 'copy+wal';
|
|
848
|
+
/** agentdb-snapshot-lock: test/tuning-only override for the snapshot-lock acquisition deadline
|
|
849
|
+
* — omitted, the lock uses its ordinary default timeout. */
|
|
850
|
+
lockTimeoutMs?: number;
|
|
851
|
+
} = {},
|
|
765
852
|
): Promise<{
|
|
766
853
|
reembedded: number;
|
|
767
854
|
model?: string;
|
|
@@ -770,52 +857,196 @@ export async function reindexAgentdbRows(
|
|
|
770
857
|
error?: string;
|
|
771
858
|
/** Task types left in the OLD embedding space because this reindex does not own them. */
|
|
772
859
|
staleTaskTypes?: string[];
|
|
860
|
+
/** Pre-reindex snapshot rotation outcome — present ONLY on a successful reindex (FR-6). */
|
|
861
|
+
snapshots?: SnapshotRotationReport;
|
|
862
|
+
/** How the pre-reindex snapshot was actually taken — always named when a snapshot ran (FR-2). */
|
|
863
|
+
snapshotMethod?: SnapshotMethod;
|
|
864
|
+
/** Present only for a fallback method — the reason `vacuum-into` did not run (FR-2). */
|
|
865
|
+
snapshotNote?: string;
|
|
866
|
+
/** AM-3: present whenever a rollback was attempted — 'restored' is the only success signal. */
|
|
867
|
+
rollback?: 'restored' | 'failed';
|
|
868
|
+
/** AM-3: present only when `rollback === 'failed'` — why the restore did not fully complete. */
|
|
869
|
+
rollbackError?: string;
|
|
773
870
|
}> {
|
|
774
871
|
const dbFile = resolveAgentdbPath(projectRoot, opts.dbPath);
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
872
|
+
const ms = Date.now();
|
|
873
|
+
const backupPath = opts.backupPath ?? `${dbFile}.pre-reindex-${ms}.bak`;
|
|
874
|
+
// agentdb-snapshot-lock: test/tuning-only override for every lock acquisition this call makes
|
|
875
|
+
// (snapshot, rollback, success-path rotation) — omitted, each uses its ordinary default timeout.
|
|
876
|
+
const lockOpts: StoreLockOptions = opts.lockTimeoutMs !== undefined ? { timeoutMs: opts.lockTimeoutMs } : {};
|
|
877
|
+
|
|
878
|
+
// AM-5: opts.backupPath must resolve INSIDE dirname(dbFile) — normalized via `resolve`, checked via
|
|
879
|
+
// `relative` so neither a `..`-escaping relative path nor a foreign absolute path can steer the
|
|
880
|
+
// snapshot (and its `-wal`/`-shm`/manifest siblings) outside the db's own directory. No snapshot is
|
|
881
|
+
// attempted when this check fails — the reindex aborts before sqlite is even resolved.
|
|
882
|
+
if (opts.backupPath !== undefined) {
|
|
883
|
+
// Lead edit after re-review (Codex C): the boundary is PHYSICAL, not lexical — a symlinked
|
|
884
|
+
// parent (`<dbDir>/link/x.bak` with `link` pointing outside) is resolved with realpath before the
|
|
885
|
+
// comparison. A parent that does not exist yet cannot be a symlink, so the lexical path stands.
|
|
886
|
+
const physical = (p: string): string => { try { return realpathSync(p); } catch { return p; } };
|
|
887
|
+
const dbDir = physical(resolve(dirname(dbFile)));
|
|
888
|
+
const candidate = resolve(opts.backupPath);
|
|
889
|
+
const rel = relative(dbDir, join(physical(dirname(candidate)), basename(candidate)));
|
|
890
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
891
|
+
return { reembedded: 0, error: `opts.backupPath must stay inside ${dbDir}, got: ${opts.backupPath}` };
|
|
785
892
|
}
|
|
786
893
|
}
|
|
894
|
+
|
|
895
|
+
// FR-3: sqlite resolves BEFORE any snapshot is taken — an unavailable dependency must abort with
|
|
896
|
+
// no new `pre-reindex-*` file on disk, not a snapshot immediately followed by a DEPS_MISSING error.
|
|
787
897
|
let sqliteUrl: string;
|
|
788
898
|
try {
|
|
789
899
|
const req = createRequire(join(projectRoot, 'package.json'));
|
|
790
900
|
sqliteUrl = pathToFileURL(req.resolve('better-sqlite3')).href;
|
|
791
901
|
} catch {
|
|
792
|
-
return { reembedded: 0,
|
|
902
|
+
return { reembedded: 0, error: DEPS_MISSING };
|
|
903
|
+
}
|
|
904
|
+
let Database: new (p: string) => UpsertDb;
|
|
905
|
+
try {
|
|
906
|
+
({ default: Database } = (await import(sqliteUrl)) as { default: new (p: string) => UpsertDb });
|
|
907
|
+
} catch {
|
|
908
|
+
return { reembedded: 0, error: DEPS_MISSING };
|
|
793
909
|
}
|
|
910
|
+
|
|
911
|
+
let snapshotMethod: SnapshotMethod | undefined;
|
|
912
|
+
let snapshotNote: string | undefined;
|
|
913
|
+
// exactOptionalPropertyTypes: an optional field must be OMITTED, never assigned `undefined` —
|
|
914
|
+
// spread `snapMeta()` in at every return site instead of naming the two fields directly. A
|
|
915
|
+
// function (not a value computed once) so a return that runs BEFORE the snapshot fully finishes
|
|
916
|
+
// (the manifest-copy failure below) still reports whatever method was already determined —
|
|
917
|
+
// FR-2/"absence of a receipt is not success": snapshotMethod is named whenever a snapshot ran,
|
|
918
|
+
// even one that failed on a LATER best-effort step.
|
|
919
|
+
const snapMeta = (): { snapshotMethod?: SnapshotMethod; snapshotNote?: string } => ({
|
|
920
|
+
...(snapshotMethod !== undefined ? { snapshotMethod } : {}),
|
|
921
|
+
...(snapshotNote !== undefined ? { snapshotNote } : {}),
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
// AM-2 (fix-round after Codex review Grade D): the reindex-in-progress marker is written INSIDE
|
|
925
|
+
// the SAME critical section as the snapshot itself, under ONE lock acquisition — never before it.
|
|
926
|
+
// A lock timeout now throws before EITHER the snapshot OR the marker exist, so a busy lock leaves
|
|
927
|
+
// the directory byte-identical (previously the marker was written unconditionally BEFORE the
|
|
928
|
+
// lock was even attempted, so a busy lock still left a transient marker on disk for the life of
|
|
929
|
+
// this call). AM-1: the marker write is attempted FIRST inside the callback, before any snapshot
|
|
930
|
+
// — a live marker from a still-running reindex refuses this call "без снимка" (no snapshot ever
|
|
931
|
+
// taken for the refused attempt; nothing has been deleted yet, so there is nothing to roll back).
|
|
932
|
+
// AM-4: the marker's `ms` is recomputed from the ACTUAL `backupPath` filename — decoupled from the
|
|
933
|
+
// `ms` variable above, which only seeds the DEFAULT backupPath. A non-standard `opts.backupPath`
|
|
934
|
+
// (no `.pre-reindex-<n>.bak` suffix) names no family, so the marker carries `ms: null`.
|
|
935
|
+
const markerMs = msFromBackupPath(backupPath);
|
|
936
|
+
let markerToken: string;
|
|
937
|
+
try {
|
|
938
|
+
const markerResult = withAgentdbSnapshotLock(dbFile, () => {
|
|
939
|
+
const written = writeReindexMarker(dbFile, { ms: markerMs, pid: process.pid, startedAt: Date.now(), backupPath });
|
|
940
|
+
if (!written.ok) return written; // AM-1: refuse before touching the database at all
|
|
941
|
+
// FR-1/FR-2 (agentdb-snapshot-lock): the critical section under the lock is exactly the file
|
|
942
|
+
// operations below (`VACUUM INTO`/copy + the manifest-sibling copy, plus the marker write
|
|
943
|
+
// above) — short and synchronous. Re-embedding (the long, unlocked part of a reindex) happens
|
|
944
|
+
// well after this block returns.
|
|
945
|
+
// Lead edit after re-review (Codex D, finding 2): a snapshot that THROWS inside this section
|
|
946
|
+
// must not leave the just-written marker behind until the TTL — clear it (we own the token)
|
|
947
|
+
// and rethrow so the outer catch reports the snapshot failure as before.
|
|
948
|
+
try {
|
|
949
|
+
if (existsSync(dbFile)) {
|
|
950
|
+
const outcome = snapshotSqliteDatabase(
|
|
951
|
+
Database as unknown as SnapshotDbCtor,
|
|
952
|
+
dbFile,
|
|
953
|
+
backupPath,
|
|
954
|
+
opts.snapshotStrategy !== undefined ? { strategy: opts.snapshotStrategy } : {},
|
|
955
|
+
);
|
|
956
|
+
snapshotMethod = outcome.method;
|
|
957
|
+
snapshotNote = outcome.note;
|
|
958
|
+
if (existsSync(`${dbFile}.embed-manifest.json`)) {
|
|
959
|
+
copyFileSync(`${dbFile}.embed-manifest.json`, `${backupPath}.embed-manifest.json`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
} catch (snapErr) {
|
|
963
|
+
clearReindexMarker(dbFile, written.token);
|
|
964
|
+
throw snapErr;
|
|
965
|
+
}
|
|
966
|
+
return written;
|
|
967
|
+
}, lockOpts);
|
|
968
|
+
if (!markerResult.ok) {
|
|
969
|
+
return { reembedded: 0, backupPath, ...snapMeta(), error: markerResult.error };
|
|
970
|
+
}
|
|
971
|
+
markerToken = markerResult.token;
|
|
972
|
+
} catch (err) {
|
|
973
|
+
// FR-4: a busy snapshot lock is reported distinctly ("snapshot lock busy: …") and aborts with
|
|
974
|
+
// NO snapshot, NO marker and NO change to the database — `fn` above never ran, so nothing was
|
|
975
|
+
// written (AM-2).
|
|
976
|
+
if (err instanceof NamedLockTimeoutError) {
|
|
977
|
+
return { reembedded: 0, error: `snapshot lock busy: ${err.message}` };
|
|
978
|
+
}
|
|
979
|
+
return { reembedded: 0, backupPath, ...snapMeta(), error: `snapshot failed — reindex aborted: ${err instanceof Error ? err.message : String(err)}` };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// AM-3: the marker is cleared in `finally` below only when no rollback was needed, or the
|
|
983
|
+
// rollback SUCCEEDED. A FAILED rollback leaves the marker in place as "requires manual recovery"
|
|
984
|
+
// — its family may be the only intact copy of the pre-reindex state, and clearing the marker here
|
|
985
|
+
// would let a concurrent `dz brain snapshots --prune` remove it right out from under an operator
|
|
986
|
+
// who has not yet acted on the advice named in the returned error.
|
|
987
|
+
let rollbackFailed = false;
|
|
988
|
+
try {
|
|
989
|
+
|
|
794
990
|
const model = resolveEmbedModel(projectRoot);
|
|
795
|
-
if ('error' in model) return { reembedded: 0, backupPath, error: model.error };
|
|
991
|
+
if ('error' in model) return { reembedded: 0, backupPath, ...snapMeta(), error: model.error };
|
|
796
992
|
const oldVersion = readEmbedManifest(dbFile)?.version ?? 1;
|
|
797
993
|
const version = Math.max(oldVersion + 1, 2);
|
|
798
994
|
|
|
995
|
+
const markerPath = reindexMarkerPath(dbFile);
|
|
996
|
+
/** AM-3: names both paths a failed rollback leaves an operator to reconcile by hand. */
|
|
997
|
+
const rollbackFailNote = (): string =>
|
|
998
|
+
`snapshot at ${backupPath} was not confirmed restored; marker at ${markerPath} is left in place — requires manual recovery`;
|
|
999
|
+
|
|
799
1000
|
/**
|
|
800
1001
|
* Undo a half-done reindex. The DELETE has already run and the manifest may already name the new
|
|
801
1002
|
* model, so leaving the store as-is would be WORSE than before we started: a manifest that claims a
|
|
802
|
-
* space the rows are not in. Restore both from the snapshot taken above.
|
|
803
|
-
*
|
|
1003
|
+
* space the rows are not in. Restore both from the snapshot taken above. FR-4: the caller of
|
|
1004
|
+
* `rollback()` has ALREADY closed every write connection this function opened (both the DELETE's
|
|
1005
|
+
* `db.close()` in the `finally` below and `indexPatternsToAgentdb`'s own `finally { db.close() }`)
|
|
1006
|
+
* before this runs.
|
|
1007
|
+
*
|
|
1008
|
+
* AM-3: never throws, but never silently reports a failed restore as a success either — "absence of
|
|
1009
|
+
* a receipt is not success" applies to a rollback exactly as much as to a forward operation. Returns
|
|
1010
|
+
* `{ restored: 'restored' }` or `{ restored: 'failed', error }`; the caller folds `error` into the
|
|
1011
|
+
* top-level `error` string as "; rollback failed: …" and surfaces `rollback`/`rollbackError`.
|
|
804
1012
|
*/
|
|
805
|
-
const rollback = async (): Promise<
|
|
1013
|
+
const rollback = async (): Promise<{ restored: 'restored' | 'failed'; error?: string }> => {
|
|
806
1014
|
try {
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
1015
|
+
// Lead edit after re-review: a snapshot that WAS taken but is now missing is a FAILED rollback,
|
|
1016
|
+
// never a silently "restored" one. Only the no-snapshot case (the db did not exist) has nothing to restore.
|
|
1017
|
+
if (!existsSync(backupPath) && snapshotMethod !== undefined) {
|
|
1018
|
+
return { restored: 'failed', error: `rollback failed: snapshot missing at ${backupPath}` };
|
|
1019
|
+
}
|
|
1020
|
+
// FR-1/FR-2 (agentdb-snapshot-lock): the restore + manifest-copy are the file operations this
|
|
1021
|
+
// lock guards. The caller has ALREADY closed every write connection before `rollback()` runs
|
|
1022
|
+
// (see the doc comment above), so this critical section stays exactly as short as the forward
|
|
1023
|
+
// snapshot's.
|
|
1024
|
+
return withAgentdbSnapshotLock(dbFile, (): { restored: 'restored' | 'failed'; error?: string } => {
|
|
1025
|
+
if (existsSync(backupPath)) {
|
|
1026
|
+
if (snapshotMethod === undefined) {
|
|
1027
|
+
// Structurally should not happen (a backup file with no recorded method), but AM-1's whole
|
|
1028
|
+
// point is: never guess the method from file presence. Fail loudly instead.
|
|
1029
|
+
return { restored: 'failed', error: 'rollback failed: snapshot method unknown, refusing to guess -wal handling' };
|
|
1030
|
+
}
|
|
1031
|
+
const outcome = restoreSqliteSnapshot(dbFile, backupPath, snapshotMethod);
|
|
1032
|
+
if (!outcome.ok) {
|
|
1033
|
+
return { restored: 'failed', error: `rollback failed: ${outcome.error ?? 'restore failed'}` };
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
const manifestBak = `${backupPath}.embed-manifest.json`;
|
|
1037
|
+
if (existsSync(manifestBak)) copyFileSync(manifestBak, `${dbFile}.embed-manifest.json`);
|
|
1038
|
+
return { restored: 'restored' };
|
|
1039
|
+
}, lockOpts);
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
if (err instanceof NamedLockTimeoutError) {
|
|
1042
|
+
return { restored: 'failed', error: `rollback failed: lock busy: ${err.message}` };
|
|
1043
|
+
}
|
|
1044
|
+
return { restored: 'failed', error: `rollback failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
813
1045
|
}
|
|
814
1046
|
};
|
|
815
1047
|
|
|
816
1048
|
let stale: string[] = [];
|
|
817
1049
|
try {
|
|
818
|
-
const { default: Database } = (await import(sqliteUrl)) as { default: new (p: string) => UpsertDb };
|
|
819
1050
|
mkdirSync(dirname(dbFile), { recursive: true });
|
|
820
1051
|
const db = new Database(dbFile);
|
|
821
1052
|
try {
|
|
@@ -851,19 +1082,71 @@ export async function reindexAgentdbRows(
|
|
|
851
1082
|
|
|
852
1083
|
const indexed = await indexPatternsToAgentdb(projectRoot, rows, { dbPath: dbFile });
|
|
853
1084
|
if (indexed.error !== undefined) {
|
|
854
|
-
await rollback();
|
|
855
|
-
|
|
1085
|
+
const rb = await rollback();
|
|
1086
|
+
if (rb.restored === 'failed') rollbackFailed = true; // AM-3: the `finally` below must not clear the marker
|
|
1087
|
+
return {
|
|
1088
|
+
reembedded: 0,
|
|
1089
|
+
backupPath,
|
|
1090
|
+
...snapMeta(),
|
|
1091
|
+
rollback: rb.restored,
|
|
1092
|
+
...(rb.error !== undefined ? { rollbackError: rb.error } : {}),
|
|
1093
|
+
error: rb.restored === 'failed' ? `${indexed.error}; ${rb.error}; ${rollbackFailNote()}` : indexed.error,
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
// FR-1/FR-6: rotation runs ONLY on this success path — an `error` return above never reaches
|
|
1097
|
+
// here, so old snapshots are never touched while they might be the only working copy left.
|
|
1098
|
+
// NFR-2: calls the UNLOCKED primitive under OUR OWN `withAgentdbSnapshotLock` — never the public
|
|
1099
|
+
// `rotatePreReindexSnapshots` wrapper, which would try to take the same named lock a second time.
|
|
1100
|
+
const keepSnapshots = opts.keepSnapshots ?? 3;
|
|
1101
|
+
let snapshots: SnapshotRotationReport;
|
|
1102
|
+
try {
|
|
1103
|
+
snapshots = withAgentdbSnapshotLock(
|
|
1104
|
+
dbFile,
|
|
1105
|
+
() => rotatePreReindexSnapshotsUnlocked(dbFile, { keep: keepSnapshots, protectPath: backupPath }),
|
|
1106
|
+
lockOpts,
|
|
1107
|
+
);
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
if (!(err instanceof NamedLockTimeoutError)) throw err;
|
|
1110
|
+
snapshots = { kept: [], removed: [], removedBytes: 0, keep: keepSnapshots, errors: [`lock busy: ${err.message}`] };
|
|
856
1111
|
}
|
|
857
1112
|
return {
|
|
858
1113
|
reembedded: indexed.indexed,
|
|
859
1114
|
model: model.model,
|
|
860
1115
|
version,
|
|
861
1116
|
backupPath,
|
|
1117
|
+
...snapMeta(),
|
|
862
1118
|
...(stale.length > 0 ? { staleTaskTypes: stale } : {}),
|
|
1119
|
+
snapshots,
|
|
863
1120
|
};
|
|
864
1121
|
} catch (err) {
|
|
865
|
-
await rollback();
|
|
866
|
-
|
|
1122
|
+
const rb = await rollback();
|
|
1123
|
+
if (rb.restored === 'failed') rollbackFailed = true; // AM-3: the `finally` below must not clear the marker
|
|
1124
|
+
const baseError = `reindex failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1125
|
+
return {
|
|
1126
|
+
reembedded: 0,
|
|
1127
|
+
backupPath,
|
|
1128
|
+
...snapMeta(),
|
|
1129
|
+
rollback: rb.restored,
|
|
1130
|
+
...(rb.error !== undefined ? { rollbackError: rb.error } : {}),
|
|
1131
|
+
error: rb.restored === 'failed' ? `${baseError}; ${rb.error}; ${rollbackFailNote()}` : baseError,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
} finally {
|
|
1135
|
+
// AM-3: never clear a marker left behind by a FAILED rollback (see the comment above
|
|
1136
|
+
// `rollbackFailed`'s declaration) — every other path (no rollback needed, or a rollback that
|
|
1137
|
+
// actually restored) clears it exactly as before.
|
|
1138
|
+
// Lead edit after re-review (Codex D, findings 3/4): every marker mutation runs under the snapshot
|
|
1139
|
+
// lock, so compare-and-delete and stale replacement can never interleave with another owner.
|
|
1140
|
+
// A failed rollback flags the marker as recovery-required instead (never expires, refuses reindex).
|
|
1141
|
+
try {
|
|
1142
|
+
withAgentdbSnapshotLock(dbFile, () => {
|
|
1143
|
+
if (rollbackFailed) markReindexMarkerRecoveryRequired(dbFile, markerToken, 'rollback failed — restore the snapshot manually');
|
|
1144
|
+
else clearReindexMarker(dbFile, markerToken);
|
|
1145
|
+
}, lockOpts);
|
|
1146
|
+
} catch {
|
|
1147
|
+
/* lock busy at cleanup: the marker stays; a live one expires by TTL, a recovery-required one is
|
|
1148
|
+
re-flagged on the next attempt — never throw out of finally over the real result */
|
|
1149
|
+
}
|
|
867
1150
|
}
|
|
868
1151
|
}
|
|
869
1152
|
|