@indigoai-us/hq-cloud 6.14.18 → 6.14.20
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/.github/workflows/ci.yml +35 -0
- package/dist/bin/sync-runner-company.d.ts +7 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +21 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +8 -16
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.js +138 -0
- package/dist/bin/sync-runner-planning.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +10 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +201 -25
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +14 -2
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +36 -6
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +550 -3
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-core.js +27 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts +2 -0
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts.map +1 -0
- package/dist/cli/rescue-drop-dir-symlink.test.js +206 -0
- package/dist/cli/rescue-drop-dir-symlink.test.js.map +1 -0
- package/dist/cli/share.d.ts +15 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -55
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +119 -6
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +19 -25
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +121 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +3 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +13 -2
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +26 -1
- package/dist/journal.test.js.map +1 -1
- package/dist/personal-vault.d.ts +12 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +20 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/qmd-reindex.d.ts +88 -11
- package/dist/qmd-reindex.d.ts.map +1 -1
- package/dist/qmd-reindex.js +354 -65
- package/dist/qmd-reindex.js.map +1 -1
- package/dist/qmd-reindex.test.d.ts +4 -0
- package/dist/qmd-reindex.test.d.ts.map +1 -1
- package/dist/qmd-reindex.test.js +285 -3
- package/dist/qmd-reindex.test.js.map +1 -1
- package/dist/s3.d.ts +13 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +34 -1
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +60 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/telemetry.d.ts +22 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +79 -0
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +117 -1
- package/dist/telemetry.test.js.map +1 -1
- package/dist/watcher.d.ts +26 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +75 -12
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +29 -1
- package/src/bin/sync-runner-planning.test.ts +153 -0
- package/src/bin/sync-runner-planning.ts +10 -17
- package/src/bin/sync-runner-watch-loop.ts +322 -32
- package/src/bin/sync-runner-watch-routes.ts +14 -1
- package/src/bin/sync-runner.test.ts +662 -6
- package/src/bin/sync-runner.ts +65 -7
- package/src/cli/rescue-core.ts +26 -1
- package/src/cli/rescue-drop-dir-symlink.test.ts +224 -0
- package/src/cli/share.test.ts +150 -6
- package/src/cli/share.ts +143 -48
- package/src/cli/sync.test.ts +150 -1
- package/src/cli/sync.ts +25 -29
- package/src/journal.test.ts +45 -0
- package/src/journal.ts +18 -1
- package/src/personal-vault.ts +23 -0
- package/src/qmd-reindex.test.ts +324 -4
- package/src/qmd-reindex.ts +414 -76
- package/src/s3.test.ts +79 -0
- package/src/s3.ts +48 -1
- package/src/telemetry.test.ts +128 -0
- package/src/telemetry.ts +80 -0
- package/src/watcher.ts +111 -14
- package/test/e2e/watcher-real-chokidar.test.ts +62 -2
- package/test/e2e/watcher-recursive-backend.test.ts +67 -1
package/src/journal.ts
CHANGED
|
@@ -607,9 +607,15 @@ export function markLocalDeleteIntent(
|
|
|
607
607
|
relativePath: string,
|
|
608
608
|
localHash: string,
|
|
609
609
|
localKind: "file" | "symlink",
|
|
610
|
+
expectedRemoteEtag: string,
|
|
610
611
|
): boolean {
|
|
611
612
|
const entry = journal.files[relativePath];
|
|
612
|
-
if (
|
|
613
|
+
if (
|
|
614
|
+
!entry?.remoteEtag ||
|
|
615
|
+
entry.remoteEtag !== normalizeEtag(expectedRemoteEtag) ||
|
|
616
|
+
entry.hash !== localHash ||
|
|
617
|
+
entry.kind !== localKind
|
|
618
|
+
) {
|
|
613
619
|
return false;
|
|
614
620
|
}
|
|
615
621
|
entry.localDeleteIntent = {
|
|
@@ -621,6 +627,17 @@ export function markLocalDeleteIntent(
|
|
|
621
627
|
return true;
|
|
622
628
|
}
|
|
623
629
|
|
|
630
|
+
/** Invalidate a prior delete authorization after a watcher-observed recreate. */
|
|
631
|
+
export function clearLocalDeleteIntent(
|
|
632
|
+
journal: SyncJournal,
|
|
633
|
+
relativePath: string,
|
|
634
|
+
): boolean {
|
|
635
|
+
const entry = journal.files[relativePath];
|
|
636
|
+
if (!entry?.localDeleteIntent) return false;
|
|
637
|
+
delete entry.localDeleteIntent;
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
|
|
624
641
|
/** True if the entry is a tombstone (set by `tombstoneEntry`). */
|
|
625
642
|
export function isTombstone(entry: JournalEntry | undefined): boolean {
|
|
626
643
|
return !!entry && typeof entry.removedAt === "string";
|
package/src/personal-vault.ts
CHANGED
|
@@ -106,6 +106,29 @@ export interface PersonalVaultOptions {
|
|
|
106
106
|
*/
|
|
107
107
|
export const PERSONAL_VAULT_MANIFEST_KEY = "companies/manifest.yaml";
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Return true for a path where HQ materializes generated links into the
|
|
111
|
+
* release-owned `core/` surface. The caller must additionally establish that
|
|
112
|
+
* the entry is a symlink: regular files in these namespaces remain canonical
|
|
113
|
+
* core content and must continue to sync.
|
|
114
|
+
*
|
|
115
|
+
* The generated links point at canonical content in `personal/` or
|
|
116
|
+
* `core/packages/`. Reindex / package wiring may remove and recreate them, so
|
|
117
|
+
* treating the links themselves as personal-vault data creates a pull →
|
|
118
|
+
* reindex → pull oscillation.
|
|
119
|
+
*/
|
|
120
|
+
export function isGeneratedCoreMirrorKey(key: string): boolean {
|
|
121
|
+
const parts = key.split("\\").join("/").replace(/^\.\//, "").split("/");
|
|
122
|
+
return (
|
|
123
|
+
parts.length >= 3 &&
|
|
124
|
+
parts[0] === "core" &&
|
|
125
|
+
(parts[1] === "knowledge" ||
|
|
126
|
+
parts[1] === "policies" ||
|
|
127
|
+
parts[1] === "workers" ||
|
|
128
|
+
parts[1] === "settings")
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
109
132
|
/**
|
|
110
133
|
* Release-owned scaffold and reindex-generated wrappers must never be
|
|
111
134
|
* delete-propagated from the personal vault. They are recreated locally by a
|
package/src/qmd-reindex.test.ts
CHANGED
|
@@ -5,22 +5,44 @@
|
|
|
5
5
|
* fake `exec` that records the calls and returns canned results — no real qmd
|
|
6
6
|
* is invoked. We assert on the *sequence of qmd commands* the function would
|
|
7
7
|
* run, which is the contract that keeps a teammate's index fresh after sync.
|
|
8
|
+
*
|
|
9
|
+
* The corruption-safety tests (lock serialization, timeout-kill handling, and
|
|
10
|
+
* SQLITE_CORRUPT quarantine) pin the fix for feedback_b9a369ff — the post-sync
|
|
11
|
+
* reindex racing an interactive qmd writer and corrupting the vector store.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
import { describe, it, expect } from "vitest";
|
|
11
15
|
import * as fs from "fs";
|
|
12
16
|
import * as os from "os";
|
|
13
17
|
import * as path from "path";
|
|
14
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
reindexAfterSync,
|
|
20
|
+
looksCorrupt,
|
|
21
|
+
resolveExecTimeoutMs,
|
|
22
|
+
DEFAULT_QMD_EXEC_TIMEOUT_MS,
|
|
23
|
+
type QmdExec,
|
|
24
|
+
type QmdExecResult,
|
|
25
|
+
type AcquireReindexLock,
|
|
26
|
+
type QuarantineCorruptIndex,
|
|
27
|
+
} from "./qmd-reindex.js";
|
|
28
|
+
|
|
29
|
+
/** A lock acquirer that always succeeds — keeps command-sequence tests pure. */
|
|
30
|
+
const noLock: AcquireReindexLock = () => ({ release() {} });
|
|
15
31
|
|
|
16
32
|
/** Build a fake exec that returns status 0 for everything and logs calls. */
|
|
17
|
-
function fakeExec(opts?: {
|
|
33
|
+
function fakeExec(opts?: {
|
|
34
|
+
listStdout?: string;
|
|
35
|
+
failOn?: string;
|
|
36
|
+
resultFor?: (args: string[]) => Partial<QmdExecResult> | undefined;
|
|
37
|
+
}): {
|
|
18
38
|
exec: QmdExec;
|
|
19
39
|
calls: string[][];
|
|
20
40
|
} {
|
|
21
41
|
const calls: string[][] = [];
|
|
22
42
|
const exec: QmdExec = (args) => {
|
|
23
43
|
calls.push(args);
|
|
44
|
+
const override = opts?.resultFor?.(args);
|
|
45
|
+
if (override) return { status: 0, stdout: "", ...override };
|
|
24
46
|
if (opts?.failOn && args[0] === opts.failOn) return { status: 1, stdout: "" };
|
|
25
47
|
if (args[0] === "collection" && args[1] === "list") {
|
|
26
48
|
return { status: 0, stdout: opts?.listStdout ?? "" };
|
|
@@ -35,6 +57,7 @@ describe("reindexAfterSync", () => {
|
|
|
35
57
|
const { exec, calls } = fakeExec();
|
|
36
58
|
const r = reindexAfterSync("/tmp/not-hq", {
|
|
37
59
|
exec,
|
|
60
|
+
acquireReindexLock: noLock,
|
|
38
61
|
existsSync: () => false, // core/core.yaml absent
|
|
39
62
|
});
|
|
40
63
|
expect(r.qmdAvailable).toBe(false);
|
|
@@ -45,6 +68,7 @@ describe("reindexAfterSync", () => {
|
|
|
45
68
|
const { exec, calls } = fakeExec({ failOn: "collection" });
|
|
46
69
|
const r = reindexAfterSync("/hq", {
|
|
47
70
|
exec,
|
|
71
|
+
acquireReindexLock: noLock,
|
|
48
72
|
existsSync: () => true, // core.yaml present
|
|
49
73
|
});
|
|
50
74
|
expect(r.qmdAvailable).toBe(false);
|
|
@@ -57,6 +81,7 @@ describe("reindexAfterSync", () => {
|
|
|
57
81
|
const { exec, calls } = fakeExec({ listStdout: "Collections (1):\n\nHQ (qmd://HQ/)\n" });
|
|
58
82
|
const r = reindexAfterSync("/hq", {
|
|
59
83
|
exec,
|
|
84
|
+
acquireReindexLock: noLock,
|
|
60
85
|
existsSync: () => true,
|
|
61
86
|
readCompanies: () => ["acme"],
|
|
62
87
|
hasIndexableMarkdown: () => true,
|
|
@@ -79,6 +104,7 @@ describe("reindexAfterSync", () => {
|
|
|
79
104
|
const { exec, calls } = fakeExec({ listStdout: "acme (qmd://acme/)\n" });
|
|
80
105
|
const r = reindexAfterSync("/hq", {
|
|
81
106
|
exec,
|
|
107
|
+
acquireReindexLock: noLock,
|
|
82
108
|
existsSync: () => true,
|
|
83
109
|
readCompanies: () => ["acme"],
|
|
84
110
|
hasIndexableMarkdown: () => true,
|
|
@@ -92,6 +118,7 @@ describe("reindexAfterSync", () => {
|
|
|
92
118
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
93
119
|
const r = reindexAfterSync("/hq", {
|
|
94
120
|
exec,
|
|
121
|
+
acquireReindexLock: noLock,
|
|
95
122
|
existsSync: () => true,
|
|
96
123
|
readCompanies: () => ["empty"],
|
|
97
124
|
hasIndexableMarkdown: () => false,
|
|
@@ -104,6 +131,7 @@ describe("reindexAfterSync", () => {
|
|
|
104
131
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
105
132
|
const r = reindexAfterSync("/hq", {
|
|
106
133
|
exec,
|
|
134
|
+
acquireReindexLock: noLock,
|
|
107
135
|
existsSync: () => true,
|
|
108
136
|
changedPaths: ["companies/acme/docs/readme.txt"],
|
|
109
137
|
});
|
|
@@ -120,6 +148,7 @@ describe("reindexAfterSync", () => {
|
|
|
120
148
|
|
|
121
149
|
const first = reindexAfterSync("/hq", {
|
|
122
150
|
exec,
|
|
151
|
+
acquireReindexLock: noLock,
|
|
123
152
|
existsSync: () => true,
|
|
124
153
|
changedPaths: ["companies/acme/knowledge/note.md"],
|
|
125
154
|
debounceMs: 60_000,
|
|
@@ -132,6 +161,7 @@ describe("reindexAfterSync", () => {
|
|
|
132
161
|
|
|
133
162
|
const second = reindexAfterSync("/hq", {
|
|
134
163
|
exec,
|
|
164
|
+
acquireReindexLock: noLock,
|
|
135
165
|
existsSync: () => true,
|
|
136
166
|
changedPaths: [],
|
|
137
167
|
debounceMs: 60_000,
|
|
@@ -154,6 +184,7 @@ describe("reindexAfterSync", () => {
|
|
|
154
184
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
155
185
|
const r = reindexAfterSync("/hq", {
|
|
156
186
|
exec,
|
|
187
|
+
acquireReindexLock: noLock,
|
|
157
188
|
existsSync: () => true,
|
|
158
189
|
readCompanies: () => [],
|
|
159
190
|
embed: true,
|
|
@@ -167,7 +198,7 @@ describe("reindexAfterSync", () => {
|
|
|
167
198
|
throw new Error("boom");
|
|
168
199
|
};
|
|
169
200
|
expect(() =>
|
|
170
|
-
reindexAfterSync("/hq", { exec, existsSync: () => true }),
|
|
201
|
+
reindexAfterSync("/hq", { exec, acquireReindexLock: noLock, existsSync: () => true }),
|
|
171
202
|
).not.toThrow();
|
|
172
203
|
});
|
|
173
204
|
|
|
@@ -183,11 +214,300 @@ describe("reindexAfterSync", () => {
|
|
|
183
214
|
fs.writeFileSync(path.join(kdir, "note.md"), "# real\n");
|
|
184
215
|
|
|
185
216
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
186
|
-
const r = reindexAfterSync(hq, { exec }); // real fs walk
|
|
217
|
+
const r = reindexAfterSync(hq, { exec, acquireReindexLock: noLock }); // real fs walk
|
|
187
218
|
expect(r.collectionsAdded).toEqual(["acme"]);
|
|
188
219
|
expect(calls.some((c) => c[0] === "collection" && c[1] === "add")).toBe(true);
|
|
189
220
|
} finally {
|
|
190
221
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
191
222
|
}
|
|
192
223
|
});
|
|
224
|
+
|
|
225
|
+
// ── Corruption safety: lock serialization (feedback_b9a369ff) ──────────────
|
|
226
|
+
|
|
227
|
+
it("skips the whole cycle without touching qmd when another writer holds the lock", () => {
|
|
228
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-lockbusy-"));
|
|
229
|
+
try {
|
|
230
|
+
const statePath = path.join(dir, "qmd-state.json");
|
|
231
|
+
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
232
|
+
const r = reindexAfterSync("/hq", {
|
|
233
|
+
exec,
|
|
234
|
+
existsSync: () => true,
|
|
235
|
+
statePath,
|
|
236
|
+
// Simulate a concurrent qmd writer already holding the lock.
|
|
237
|
+
acquireReindexLock: () => null,
|
|
238
|
+
});
|
|
239
|
+
// The runner never runs a single qmd command while another writer holds
|
|
240
|
+
// the lock — this is what prevents the concurrent-writer corruption.
|
|
241
|
+
expect(calls).toEqual([]);
|
|
242
|
+
expect(r.lockBusy).toBe(true);
|
|
243
|
+
expect(r.pendingDirty).toBe(true);
|
|
244
|
+
expect(r.updated).toBe(false);
|
|
245
|
+
// The tree is marked dirty so the next sync retries once the lock frees.
|
|
246
|
+
const persisted = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
247
|
+
expect(persisted.pendingDirty).toBe(true);
|
|
248
|
+
} finally {
|
|
249
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("always releases the lock, even when a qmd command throws", () => {
|
|
254
|
+
let released = false;
|
|
255
|
+
const exec: QmdExec = () => {
|
|
256
|
+
throw new Error("boom");
|
|
257
|
+
};
|
|
258
|
+
reindexAfterSync("/hq", {
|
|
259
|
+
exec,
|
|
260
|
+
existsSync: () => true,
|
|
261
|
+
acquireReindexLock: () => ({
|
|
262
|
+
release() {
|
|
263
|
+
released = true;
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
});
|
|
267
|
+
expect(released).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ── Corruption safety: timeout-kill handling ───────────────────────────────
|
|
271
|
+
|
|
272
|
+
it("treats a timed-out update as not-done, marks dirty, and does NOT embed", () => {
|
|
273
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-timeout-"));
|
|
274
|
+
try {
|
|
275
|
+
const statePath = path.join(dir, "qmd-state.json");
|
|
276
|
+
const { exec, calls } = fakeExec({
|
|
277
|
+
listStdout: "",
|
|
278
|
+
resultFor: (args) =>
|
|
279
|
+
args[0] === "update" ? { status: null, timedOut: true } : undefined,
|
|
280
|
+
});
|
|
281
|
+
const r = reindexAfterSync("/hq", {
|
|
282
|
+
exec,
|
|
283
|
+
acquireReindexLock: noLock,
|
|
284
|
+
existsSync: () => true,
|
|
285
|
+
readCompanies: () => [],
|
|
286
|
+
embed: true, // would normally run embed after update
|
|
287
|
+
statePath,
|
|
288
|
+
});
|
|
289
|
+
expect(r.timedOut).toBe(true);
|
|
290
|
+
expect(r.updated).toBe(false);
|
|
291
|
+
expect(r.embedded).toBe(false);
|
|
292
|
+
expect(r.pendingDirty).toBe(true);
|
|
293
|
+
// Crucially: no `embed` after a timed-out update — never write further.
|
|
294
|
+
expect(calls).toEqual([["collection", "list"], ["update"]]);
|
|
295
|
+
const persisted = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
296
|
+
expect(persisted.pendingDirty).toBe(true);
|
|
297
|
+
} finally {
|
|
298
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("treats a timed-out embed as not-embedded and marks dirty", () => {
|
|
303
|
+
const { exec } = fakeExec({
|
|
304
|
+
listStdout: "",
|
|
305
|
+
resultFor: (args) =>
|
|
306
|
+
args[0] === "embed" ? { status: null, timedOut: true } : undefined,
|
|
307
|
+
});
|
|
308
|
+
const r = reindexAfterSync("/hq", {
|
|
309
|
+
exec,
|
|
310
|
+
acquireReindexLock: noLock,
|
|
311
|
+
existsSync: () => true,
|
|
312
|
+
readCompanies: () => [],
|
|
313
|
+
embed: true,
|
|
314
|
+
});
|
|
315
|
+
expect(r.updated).toBe(true);
|
|
316
|
+
expect(r.embedded).toBe(false);
|
|
317
|
+
expect(r.timedOut).toBe(true);
|
|
318
|
+
expect(r.pendingDirty).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ── Corruption safety: SQLITE_CORRUPT detection + quarantine ────────────────
|
|
322
|
+
|
|
323
|
+
it("quarantines the DB and stops writing when update reports SQLITE_CORRUPT", () => {
|
|
324
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-corrupt-"));
|
|
325
|
+
try {
|
|
326
|
+
const statePath = path.join(dir, "qmd-state.json");
|
|
327
|
+
const quarantined: Array<{ qmdDir: string; suffix: string }> = [];
|
|
328
|
+
const quarantine: QuarantineCorruptIndex = (qmdDir, suffix) => {
|
|
329
|
+
quarantined.push({ qmdDir, suffix });
|
|
330
|
+
return path.join(qmdDir, `index.sqlite.corrupt-${suffix}`);
|
|
331
|
+
};
|
|
332
|
+
const { exec, calls } = fakeExec({
|
|
333
|
+
listStdout: "",
|
|
334
|
+
resultFor: (args) =>
|
|
335
|
+
args[0] === "update"
|
|
336
|
+
? { status: 1, stderr: "SqliteError: database disk image is malformed (SQLITE_CORRUPT)" }
|
|
337
|
+
: undefined,
|
|
338
|
+
});
|
|
339
|
+
const r = reindexAfterSync("/hq", {
|
|
340
|
+
exec,
|
|
341
|
+
acquireReindexLock: noLock,
|
|
342
|
+
existsSync: () => true,
|
|
343
|
+
readCompanies: () => [],
|
|
344
|
+
embed: true, // must NOT run after corruption
|
|
345
|
+
statePath,
|
|
346
|
+
quarantineCorruptIndex: quarantine,
|
|
347
|
+
});
|
|
348
|
+
expect(r.corruptionQuarantined).toBe(true);
|
|
349
|
+
expect(r.embedded).toBe(false);
|
|
350
|
+
expect(r.pendingDirty).toBe(true);
|
|
351
|
+
expect(calls).toEqual([["collection", "list"], ["update"]]); // no embed
|
|
352
|
+
expect(quarantined).toHaveLength(1);
|
|
353
|
+
expect(quarantined[0].qmdDir).toBe(path.join("/hq", ".qmd"));
|
|
354
|
+
const persisted = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
355
|
+
expect(persisted.pendingDirty).toBe(true);
|
|
356
|
+
} finally {
|
|
357
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("quarantines on a corrupt collection-list probe before any write", () => {
|
|
362
|
+
const quarantined: string[] = [];
|
|
363
|
+
const { exec, calls } = fakeExec({
|
|
364
|
+
resultFor: (args) =>
|
|
365
|
+
args[0] === "collection" && args[1] === "list"
|
|
366
|
+
? { status: 0, stdout: "database disk image is malformed" }
|
|
367
|
+
: undefined,
|
|
368
|
+
});
|
|
369
|
+
const r = reindexAfterSync("/hq", {
|
|
370
|
+
exec,
|
|
371
|
+
acquireReindexLock: noLock,
|
|
372
|
+
existsSync: () => true,
|
|
373
|
+
quarantineCorruptIndex: (qmdDir) => {
|
|
374
|
+
quarantined.push(qmdDir);
|
|
375
|
+
return null;
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
expect(r.corruptionQuarantined).toBe(true);
|
|
379
|
+
expect(r.updated).toBe(false);
|
|
380
|
+
// Detected on the probe — never runs update/embed onto the corrupt store.
|
|
381
|
+
expect(calls).toEqual([["collection", "list"]]);
|
|
382
|
+
expect(quarantined).toEqual([path.join("/hq", ".qmd")]);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("quarantines when embed reports SQLITE_CORRUPT", () => {
|
|
386
|
+
const quarantined: string[] = [];
|
|
387
|
+
const { exec, calls } = fakeExec({
|
|
388
|
+
listStdout: "",
|
|
389
|
+
resultFor: (args) =>
|
|
390
|
+
args[0] === "embed"
|
|
391
|
+
? { status: 1, stderr: "code SQLITE_CORRUPT at store.js:1546" }
|
|
392
|
+
: undefined,
|
|
393
|
+
});
|
|
394
|
+
const r = reindexAfterSync("/hq", {
|
|
395
|
+
exec,
|
|
396
|
+
acquireReindexLock: noLock,
|
|
397
|
+
existsSync: () => true,
|
|
398
|
+
readCompanies: () => [],
|
|
399
|
+
embed: true,
|
|
400
|
+
quarantineCorruptIndex: (qmdDir) => {
|
|
401
|
+
quarantined.push(qmdDir);
|
|
402
|
+
return null;
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
expect(r.corruptionQuarantined).toBe(true);
|
|
406
|
+
expect(r.embedded).toBe(false);
|
|
407
|
+
expect(calls).toEqual([["collection", "list"], ["update"], ["embed"]]);
|
|
408
|
+
expect(quarantined).toEqual([path.join("/hq", ".qmd")]);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ── The default reindex lock (real filesystem) ───────────────────────────────
|
|
413
|
+
|
|
414
|
+
describe("default reindex lock", () => {
|
|
415
|
+
it("holds the lock for the duration of the cycle and releases it after", () => {
|
|
416
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-lock-"));
|
|
417
|
+
try {
|
|
418
|
+
const hq = path.join(dir, "hq");
|
|
419
|
+
const kdir = path.join(hq, "companies", "acme", "knowledge");
|
|
420
|
+
fs.mkdirSync(kdir, { recursive: true });
|
|
421
|
+
fs.mkdirSync(path.join(hq, "core"), { recursive: true });
|
|
422
|
+
fs.writeFileSync(path.join(hq, "core", "core.yaml"), "v: 1\n");
|
|
423
|
+
fs.writeFileSync(path.join(kdir, "note.md"), "# real\n");
|
|
424
|
+
|
|
425
|
+
// While the cycle runs (mid-exec), a naive O_EXCL re-acquire of the same
|
|
426
|
+
// lock file must fail with EEXIST — proving the lock is genuinely held.
|
|
427
|
+
const lockPath = path.join(hq, ".qmd", ".reindex.lock");
|
|
428
|
+
let reAcquireBlockedWhileHeld = false;
|
|
429
|
+
const { exec } = fakeExec({ listStdout: "" });
|
|
430
|
+
const observingExec: QmdExec = (args) => {
|
|
431
|
+
if (args[0] === "update") {
|
|
432
|
+
try {
|
|
433
|
+
fs.closeSync(fs.openSync(lockPath, "wx"));
|
|
434
|
+
fs.unlinkSync(lockPath); // acquired — NOT what we want
|
|
435
|
+
} catch (err) {
|
|
436
|
+
reAcquireBlockedWhileHeld =
|
|
437
|
+
(err as NodeJS.ErrnoException).code === "EEXIST";
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return exec(args);
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const r = reindexAfterSync(hq, { exec: observingExec });
|
|
444
|
+
expect(r.updated).toBe(true);
|
|
445
|
+
expect(reAcquireBlockedWhileHeld).toBe(true);
|
|
446
|
+
// Released after the cycle — the lock file is gone.
|
|
447
|
+
expect(fs.existsSync(lockPath)).toBe(false);
|
|
448
|
+
} finally {
|
|
449
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("reclaims a lock held by a dead PID (stale takeover)", () => {
|
|
454
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-lock-stale-"));
|
|
455
|
+
try {
|
|
456
|
+
const hq = path.join(dir, "hq");
|
|
457
|
+
const kdir = path.join(hq, "companies", "acme", "knowledge");
|
|
458
|
+
fs.mkdirSync(kdir, { recursive: true });
|
|
459
|
+
fs.mkdirSync(path.join(hq, "core"), { recursive: true });
|
|
460
|
+
fs.writeFileSync(path.join(hq, "core", "core.yaml"), "v: 1\n");
|
|
461
|
+
fs.writeFileSync(path.join(kdir, "note.md"), "# real\n");
|
|
462
|
+
|
|
463
|
+
// Plant a lock owned by a PID that is certainly not alive.
|
|
464
|
+
const lockPath = path.join(hq, ".qmd", ".reindex.lock");
|
|
465
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
466
|
+
fs.writeFileSync(
|
|
467
|
+
lockPath,
|
|
468
|
+
JSON.stringify({ pid: 2147483646, startedAt: "2000-01-01T00:00:00.000Z", command: "dead" }),
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
472
|
+
const r = reindexAfterSync(hq, { exec });
|
|
473
|
+
// The dead holder was reclaimed, so the reindex ran to completion.
|
|
474
|
+
expect(r.updated).toBe(true);
|
|
475
|
+
expect(r.lockBusy).toBe(false);
|
|
476
|
+
expect(calls.some((c) => c[0] === "update")).toBe(true);
|
|
477
|
+
} finally {
|
|
478
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// ── Pure helpers ─────────────────────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
describe("resolveExecTimeoutMs", () => {
|
|
486
|
+
it("defaults to a generous bound, not the old 120s that killed writes", () => {
|
|
487
|
+
expect(resolveExecTimeoutMs({})).toBe(DEFAULT_QMD_EXEC_TIMEOUT_MS);
|
|
488
|
+
expect(DEFAULT_QMD_EXEC_TIMEOUT_MS).toBeGreaterThan(120_000);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("honors a valid HQ_QMD_EXEC_TIMEOUT_MS override", () => {
|
|
492
|
+
expect(resolveExecTimeoutMs({ HQ_QMD_EXEC_TIMEOUT_MS: "300000" })).toBe(300_000);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("ignores an invalid override", () => {
|
|
496
|
+
expect(resolveExecTimeoutMs({ HQ_QMD_EXEC_TIMEOUT_MS: "nope" })).toBe(DEFAULT_QMD_EXEC_TIMEOUT_MS);
|
|
497
|
+
expect(resolveExecTimeoutMs({ HQ_QMD_EXEC_TIMEOUT_MS: "-5" })).toBe(DEFAULT_QMD_EXEC_TIMEOUT_MS);
|
|
498
|
+
expect(resolveExecTimeoutMs({ HQ_QMD_EXEC_TIMEOUT_MS: "0" })).toBe(DEFAULT_QMD_EXEC_TIMEOUT_MS);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
describe("looksCorrupt", () => {
|
|
503
|
+
it("matches the SQLITE_CORRUPT signature on stdout or stderr", () => {
|
|
504
|
+
expect(looksCorrupt({ stderr: "SqliteError: ... code SQLITE_CORRUPT" })).toBe(true);
|
|
505
|
+
expect(looksCorrupt({ stdout: "database disk image is malformed" })).toBe(true);
|
|
506
|
+
expect(looksCorrupt({ stdout: "Database disk image IS MALFORMED" })).toBe(true);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("does not match clean output", () => {
|
|
510
|
+
expect(looksCorrupt({ stdout: "Updated 688 documents", stderr: "" })).toBe(false);
|
|
511
|
+
expect(looksCorrupt({})).toBe(false);
|
|
512
|
+
});
|
|
193
513
|
});
|