@indigoai-us/hq-cloud 6.18.15 → 6.18.16-alpha.1
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/dist/bin/sync-runner-watch-loop.d.ts +4 -1
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +117 -17
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.test.js +211 -3
- package/dist/bin/sync-runner-watch-loop.test.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +12 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +32 -14
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +59 -4
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/qmd-reindex.d.ts +30 -10
- package/dist/qmd-reindex.d.ts.map +1 -1
- package/dist/qmd-reindex.js +148 -41
- package/dist/qmd-reindex.js.map +1 -1
- package/dist/qmd-reindex.test.js +191 -78
- package/dist/qmd-reindex.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +2 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +15 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +32 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +21 -1
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +166 -8
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +258 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/receiver-last-delivery.d.ts +22 -0
- package/dist/sync/receiver-last-delivery.d.ts.map +1 -0
- package/dist/sync/receiver-last-delivery.js +139 -0
- package/dist/sync/receiver-last-delivery.js.map +1 -0
- package/dist/sync/receiver-last-delivery.test.d.ts +2 -0
- package/dist/sync/receiver-last-delivery.test.d.ts.map +1 -0
- package/dist/sync/receiver-last-delivery.test.js +110 -0
- package/dist/sync/receiver-last-delivery.test.js.map +1 -0
- package/dist/sync/sync-contract.d.ts.map +1 -1
- package/dist/sync/sync-contract.js +1 -0
- package/dist/sync/sync-contract.js.map +1 -1
- package/dist/sync-contract.json +2 -1
- package/dist/watch-owner-lease.d.ts +2 -0
- package/dist/watch-owner-lease.d.ts.map +1 -1
- package/dist/watch-owner-lease.js +3 -2
- package/dist/watch-owner-lease.js.map +1 -1
- package/dist/watch-owner-lease.test.js +22 -4
- package/dist/watch-owner-lease.test.js.map +1 -1
- package/package.json +1 -1
package/dist/qmd-reindex.test.js
CHANGED
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
* index path, dual-lock, honest quarantine). Path-drift tests pin
|
|
11
11
|
* feedback_f8e233c6 (detect-only by default; opt-in repair).
|
|
12
12
|
*/
|
|
13
|
-
import { describe, it, expect } from "vitest";
|
|
13
|
+
import { describe, it, expect, vi } from "vitest";
|
|
14
14
|
import * as fs from "fs";
|
|
15
15
|
import * as os from "os";
|
|
16
16
|
import * as path from "path";
|
|
17
|
-
import {
|
|
17
|
+
import { clearTimeout as clearRealTimeout, setTimeout as setRealTimeout } from "node:timers";
|
|
18
|
+
import { reindexAfterSync, createQmdExec, looksCorrupt, resolveExecTimeoutMs, DEFAULT_QMD_EXEC_TIMEOUT_MS, parseIndexDirFromStatus, parseCollectionPathFromShow, pathsEquivalent, repairPathDriftEnabled, acquireDualReindexLocks, } from "./qmd-reindex.js";
|
|
18
19
|
/** A lock acquirer that always succeeds — keeps command-sequence tests pure. */
|
|
19
20
|
const noLock = () => ({ release() { } });
|
|
20
21
|
/** Identity realpath so path-drift comparisons stay deterministic in fakes. */
|
|
@@ -22,7 +23,7 @@ const identityRealpath = (p) => path.resolve(p);
|
|
|
22
23
|
/** Build a fake exec that returns status 0 for everything and logs calls. */
|
|
23
24
|
function fakeExec(opts) {
|
|
24
25
|
const calls = [];
|
|
25
|
-
const exec = (args) => {
|
|
26
|
+
const exec = async (args) => {
|
|
26
27
|
calls.push(args);
|
|
27
28
|
const override = opts?.resultFor?.(args);
|
|
28
29
|
if (override)
|
|
@@ -48,9 +49,9 @@ function fakeExec(opts) {
|
|
|
48
49
|
return { exec, calls };
|
|
49
50
|
}
|
|
50
51
|
describe("reindexAfterSync", () => {
|
|
51
|
-
it("no-ops when the path is not an HQ root", () => {
|
|
52
|
+
it("no-ops when the path is not an HQ root", async () => {
|
|
52
53
|
const { exec, calls } = fakeExec();
|
|
53
|
-
const r = reindexAfterSync("/tmp/not-hq", {
|
|
54
|
+
const r = await reindexAfterSync("/tmp/not-hq", {
|
|
54
55
|
exec,
|
|
55
56
|
acquireReindexLock: noLock,
|
|
56
57
|
existsSync: () => false, // core/core.yaml absent
|
|
@@ -58,9 +59,9 @@ describe("reindexAfterSync", () => {
|
|
|
58
59
|
expect(r.qmdAvailable).toBe(false);
|
|
59
60
|
expect(calls).toHaveLength(0);
|
|
60
61
|
});
|
|
61
|
-
it("no-ops when qmd is unavailable (collection list errors)", () => {
|
|
62
|
+
it("no-ops when qmd is unavailable (collection list errors)", async () => {
|
|
62
63
|
const { exec, calls } = fakeExec({ failOn: "collection" });
|
|
63
|
-
const r = reindexAfterSync("/hq", {
|
|
64
|
+
const r = await reindexAfterSync("/hq", {
|
|
64
65
|
exec,
|
|
65
66
|
acquireReindexLock: noLock,
|
|
66
67
|
existsSync: () => true, // core.yaml present
|
|
@@ -69,9 +70,9 @@ describe("reindexAfterSync", () => {
|
|
|
69
70
|
expect(r.updated).toBe(false);
|
|
70
71
|
expect(calls).toEqual([["status"], ["collection", "list"]]);
|
|
71
72
|
});
|
|
72
|
-
it("registers a missing company collection then runs an incremental update", () => {
|
|
73
|
+
it("registers a missing company collection then runs an incremental update", async () => {
|
|
73
74
|
const { exec, calls } = fakeExec({ listStdout: "Collections (1):\n\nHQ (qmd://HQ/)\n" });
|
|
74
|
-
const r = reindexAfterSync("/hq", {
|
|
75
|
+
const r = await reindexAfterSync("/hq", {
|
|
75
76
|
exec,
|
|
76
77
|
acquireReindexLock: noLock,
|
|
77
78
|
existsSync: () => true,
|
|
@@ -90,9 +91,9 @@ describe("reindexAfterSync", () => {
|
|
|
90
91
|
["update"],
|
|
91
92
|
]);
|
|
92
93
|
});
|
|
93
|
-
it("skips collections that already exist at the desired path", () => {
|
|
94
|
+
it("skips collections that already exist at the desired path", async () => {
|
|
94
95
|
const { exec, calls } = fakeExec({ listStdout: "acme (qmd://acme/)\n" });
|
|
95
|
-
const r = reindexAfterSync("/hq", {
|
|
96
|
+
const r = await reindexAfterSync("/hq", {
|
|
96
97
|
exec,
|
|
97
98
|
acquireReindexLock: noLock,
|
|
98
99
|
existsSync: () => true,
|
|
@@ -109,7 +110,7 @@ describe("reindexAfterSync", () => {
|
|
|
109
110
|
["update"],
|
|
110
111
|
]);
|
|
111
112
|
});
|
|
112
|
-
it("detects path drift without repairing when HQ_QMD_REPAIR_PATH_DRIFT is off", () => {
|
|
113
|
+
it("detects path drift without repairing when HQ_QMD_REPAIR_PATH_DRIFT is off", async () => {
|
|
113
114
|
const drifted = path.join("/mirror", "knowledge-acme");
|
|
114
115
|
const { exec, calls } = fakeExec({
|
|
115
116
|
listStdout: "acme (qmd://acme/)\n",
|
|
@@ -120,7 +121,7 @@ describe("reindexAfterSync", () => {
|
|
|
120
121
|
}
|
|
121
122
|
: undefined,
|
|
122
123
|
});
|
|
123
|
-
const r = reindexAfterSync("/hq", {
|
|
124
|
+
const r = await reindexAfterSync("/hq", {
|
|
124
125
|
exec,
|
|
125
126
|
acquireReindexLock: noLock,
|
|
126
127
|
existsSync: () => true,
|
|
@@ -136,7 +137,7 @@ describe("reindexAfterSync", () => {
|
|
|
136
137
|
// Detect-only must still allow embed (no repair this cycle).
|
|
137
138
|
expect(r.embedded).toBe(true);
|
|
138
139
|
});
|
|
139
|
-
it("repairs path drift when HQ_QMD_REPAIR_PATH_DRIFT=1 and skips embed", () => {
|
|
140
|
+
it("repairs path drift when HQ_QMD_REPAIR_PATH_DRIFT=1 and skips embed", async () => {
|
|
140
141
|
const drifted = path.join("/mirror", "knowledge-acme");
|
|
141
142
|
const desired = path.join("/hq", "companies", "acme", "knowledge");
|
|
142
143
|
const { exec, calls } = fakeExec({
|
|
@@ -145,7 +146,7 @@ describe("reindexAfterSync", () => {
|
|
|
145
146
|
? { status: 0, stdout: `Collection: acme\n Path: ${drifted}\n` }
|
|
146
147
|
: undefined,
|
|
147
148
|
});
|
|
148
|
-
const r = reindexAfterSync("/hq", {
|
|
149
|
+
const r = await reindexAfterSync("/hq", {
|
|
149
150
|
exec,
|
|
150
151
|
acquireReindexLock: noLock,
|
|
151
152
|
existsSync: () => true,
|
|
@@ -168,7 +169,7 @@ describe("reindexAfterSync", () => {
|
|
|
168
169
|
["update"],
|
|
169
170
|
]);
|
|
170
171
|
});
|
|
171
|
-
it("does not remove a drifted collection when desired path has no indexable markdown", () => {
|
|
172
|
+
it("does not remove a drifted collection when desired path has no indexable markdown", async () => {
|
|
172
173
|
const drifted = path.join("/mirror", "knowledge-acme");
|
|
173
174
|
const { exec, calls } = fakeExec({
|
|
174
175
|
listStdout: "acme (qmd://acme/)\n",
|
|
@@ -177,7 +178,7 @@ describe("reindexAfterSync", () => {
|
|
|
177
178
|
: undefined,
|
|
178
179
|
});
|
|
179
180
|
// hasIndexableMarkdown false → skipped before show/repair (desired empty).
|
|
180
|
-
const r = reindexAfterSync("/hq", {
|
|
181
|
+
const r = await reindexAfterSync("/hq", {
|
|
181
182
|
exec,
|
|
182
183
|
acquireReindexLock: noLock,
|
|
183
184
|
existsSync: () => true,
|
|
@@ -189,9 +190,9 @@ describe("reindexAfterSync", () => {
|
|
|
189
190
|
expect(r.collectionsRepaired).toEqual([]);
|
|
190
191
|
expect(calls.some((c) => c[1] === "remove")).toBe(false);
|
|
191
192
|
});
|
|
192
|
-
it("skips company dirs with no indexable markdown", () => {
|
|
193
|
+
it("skips company dirs with no indexable markdown", async () => {
|
|
193
194
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
194
|
-
const r = reindexAfterSync("/hq", {
|
|
195
|
+
const r = await reindexAfterSync("/hq", {
|
|
195
196
|
exec,
|
|
196
197
|
acquireReindexLock: noLock,
|
|
197
198
|
existsSync: () => true,
|
|
@@ -201,9 +202,9 @@ describe("reindexAfterSync", () => {
|
|
|
201
202
|
expect(r.collectionsAdded).toEqual([]);
|
|
202
203
|
expect(calls).toEqual([["status"], ["collection", "list"], ["update"]]);
|
|
203
204
|
});
|
|
204
|
-
it("registers populated company projects with the shell name and mask", () => {
|
|
205
|
+
it("registers populated company projects with the shell name and mask", async () => {
|
|
205
206
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
206
|
-
const r = reindexAfterSync("/hq", {
|
|
207
|
+
const r = await reindexAfterSync("/hq", {
|
|
207
208
|
exec,
|
|
208
209
|
acquireReindexLock: noLock,
|
|
209
210
|
existsSync: () => true,
|
|
@@ -228,10 +229,10 @@ describe("reindexAfterSync", () => {
|
|
|
228
229
|
["update"],
|
|
229
230
|
]);
|
|
230
231
|
});
|
|
231
|
-
it("skips missing and empty company projects dirs", () => {
|
|
232
|
+
it("skips missing and empty company projects dirs", async () => {
|
|
232
233
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
233
234
|
const emptyProjects = path.join("/hq", "companies", "empty", "projects");
|
|
234
|
-
const r = reindexAfterSync("/hq", {
|
|
235
|
+
const r = await reindexAfterSync("/hq", {
|
|
235
236
|
exec,
|
|
236
237
|
acquireReindexLock: noLock,
|
|
237
238
|
existsSync: (p) => p !== path.join("/hq", "companies", "missing", "projects"),
|
|
@@ -242,10 +243,10 @@ describe("reindexAfterSync", () => {
|
|
|
242
243
|
expect(r.collectionsAdded).toEqual([]);
|
|
243
244
|
expect(calls).toEqual([["status"], ["collection", "list"], ["update"]]);
|
|
244
245
|
});
|
|
245
|
-
it("registers populated personal knowledge with the shell name and mask", () => {
|
|
246
|
+
it("registers populated personal knowledge with the shell name and mask", async () => {
|
|
246
247
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
247
248
|
const personalKnowledge = path.join("/hq", "personal", "knowledge");
|
|
248
|
-
const r = reindexAfterSync("/hq", {
|
|
249
|
+
const r = await reindexAfterSync("/hq", {
|
|
249
250
|
exec,
|
|
250
251
|
acquireReindexLock: noLock,
|
|
251
252
|
existsSync: () => true,
|
|
@@ -269,9 +270,9 @@ describe("reindexAfterSync", () => {
|
|
|
269
270
|
["update"],
|
|
270
271
|
]);
|
|
271
272
|
});
|
|
272
|
-
it("skips absent and empty personal knowledge", () => {
|
|
273
|
+
it("skips absent and empty personal knowledge", async () => {
|
|
273
274
|
const absent = fakeExec({ listStdout: "" });
|
|
274
|
-
const absentResult = reindexAfterSync("/hq", {
|
|
275
|
+
const absentResult = await reindexAfterSync("/hq", {
|
|
275
276
|
exec: absent.exec,
|
|
276
277
|
acquireReindexLock: noLock,
|
|
277
278
|
existsSync: (p) => p !== path.join("/hq", "personal", "knowledge"),
|
|
@@ -280,7 +281,7 @@ describe("reindexAfterSync", () => {
|
|
|
280
281
|
expect(absentResult.collectionsAdded).toEqual([]);
|
|
281
282
|
expect(absent.calls).toEqual([["status"], ["collection", "list"], ["update"]]);
|
|
282
283
|
const empty = fakeExec({ listStdout: "" });
|
|
283
|
-
const emptyResult = reindexAfterSync("/hq", {
|
|
284
|
+
const emptyResult = await reindexAfterSync("/hq", {
|
|
284
285
|
exec: empty.exec,
|
|
285
286
|
acquireReindexLock: noLock,
|
|
286
287
|
existsSync: () => true,
|
|
@@ -290,7 +291,7 @@ describe("reindexAfterSync", () => {
|
|
|
290
291
|
expect(emptyResult.collectionsAdded).toEqual([]);
|
|
291
292
|
expect(empty.calls).toEqual([["status"], ["collection", "list"], ["update"]]);
|
|
292
293
|
});
|
|
293
|
-
it("does not add projects or personal knowledge a second time", () => {
|
|
294
|
+
it("does not add projects or personal knowledge a second time", async () => {
|
|
294
295
|
let listCount = 0;
|
|
295
296
|
const { exec, calls } = fakeExec({
|
|
296
297
|
resultFor: (args) => {
|
|
@@ -314,15 +315,15 @@ describe("reindexAfterSync", () => {
|
|
|
314
315
|
hasIndexableProjectContent: () => true,
|
|
315
316
|
realpathSync: identityRealpath,
|
|
316
317
|
};
|
|
317
|
-
expect(reindexAfterSync("/hq", options).collectionsAdded).toEqual([
|
|
318
|
+
expect((await reindexAfterSync("/hq", options)).collectionsAdded).toEqual([
|
|
318
319
|
"acme",
|
|
319
320
|
"acme-projects",
|
|
320
321
|
"personal-knowledge",
|
|
321
322
|
]);
|
|
322
|
-
expect(reindexAfterSync("/hq", options).collectionsAdded).toEqual([]);
|
|
323
|
+
expect((await reindexAfterSync("/hq", options)).collectionsAdded).toEqual([]);
|
|
323
324
|
expect(calls.filter((c) => c[0] === "collection" && c[1] === "add")).toHaveLength(3);
|
|
324
325
|
});
|
|
325
|
-
it("emits the same collection-add commands as the shell for a fixture tree", () => {
|
|
326
|
+
it("emits the same collection-add commands as the shell for a fixture tree", async () => {
|
|
326
327
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-parity-"));
|
|
327
328
|
try {
|
|
328
329
|
const hq = path.join(dir, "hq");
|
|
@@ -340,7 +341,7 @@ describe("reindexAfterSync", () => {
|
|
|
340
341
|
fs.writeFileSync(path.join(acmeProjects, "prd.json"), "{}\n");
|
|
341
342
|
fs.writeFileSync(path.join(personalKnowledge, "note.md"), "# personal\n");
|
|
342
343
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
343
|
-
reindexAfterSync(hq, { exec, acquireReindexLock: noLock });
|
|
344
|
+
await reindexAfterSync(hq, { exec, acquireReindexLock: noLock });
|
|
344
345
|
expect(calls.filter((c) => c[0] === "collection" && c[1] === "add")).toEqual([
|
|
345
346
|
["collection", "add", acmeKnowledge, "--name", "acme", "--mask", "**/*.md"],
|
|
346
347
|
[
|
|
@@ -367,9 +368,9 @@ describe("reindexAfterSync", () => {
|
|
|
367
368
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
368
369
|
}
|
|
369
370
|
});
|
|
370
|
-
it("skips qmd entirely when provided changed paths do not touch knowledge markdown", () => {
|
|
371
|
+
it("skips qmd entirely when provided changed paths do not touch knowledge markdown", async () => {
|
|
371
372
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
372
|
-
const r = reindexAfterSync("/hq", {
|
|
373
|
+
const r = await reindexAfterSync("/hq", {
|
|
373
374
|
exec,
|
|
374
375
|
acquireReindexLock: noLock,
|
|
375
376
|
existsSync: () => true,
|
|
@@ -379,12 +380,12 @@ describe("reindexAfterSync", () => {
|
|
|
379
380
|
expect(r.updated).toBe(false);
|
|
380
381
|
expect(calls).toEqual([]);
|
|
381
382
|
});
|
|
382
|
-
it("debounces dirty updates with a pending marker and flushes the final update later", () => {
|
|
383
|
+
it("debounces dirty updates with a pending marker and flushes the final update later", async () => {
|
|
383
384
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-debounce-"));
|
|
384
385
|
try {
|
|
385
386
|
const statePath = path.join(dir, "qmd-state.json");
|
|
386
387
|
const { exec, calls } = fakeExec({ listStdout: "acme (qmd://acme/)\n" });
|
|
387
|
-
const first = reindexAfterSync("/hq", {
|
|
388
|
+
const first = await reindexAfterSync("/hq", {
|
|
388
389
|
exec,
|
|
389
390
|
acquireReindexLock: noLock,
|
|
390
391
|
existsSync: () => true,
|
|
@@ -403,7 +404,7 @@ describe("reindexAfterSync", () => {
|
|
|
403
404
|
["collection", "list"],
|
|
404
405
|
["collection", "show", "acme"],
|
|
405
406
|
]);
|
|
406
|
-
const second = reindexAfterSync("/hq", {
|
|
407
|
+
const second = await reindexAfterSync("/hq", {
|
|
407
408
|
exec,
|
|
408
409
|
acquireReindexLock: noLock,
|
|
409
410
|
existsSync: () => true,
|
|
@@ -427,9 +428,9 @@ describe("reindexAfterSync", () => {
|
|
|
427
428
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
428
429
|
}
|
|
429
430
|
});
|
|
430
|
-
it("runs embed only when embed:true", () => {
|
|
431
|
+
it("runs embed only when embed:true", async () => {
|
|
431
432
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
432
|
-
const r = reindexAfterSync("/hq", {
|
|
433
|
+
const r = await reindexAfterSync("/hq", {
|
|
433
434
|
exec,
|
|
434
435
|
acquireReindexLock: noLock,
|
|
435
436
|
existsSync: () => true,
|
|
@@ -439,13 +440,13 @@ describe("reindexAfterSync", () => {
|
|
|
439
440
|
expect(r.embedded).toBe(true);
|
|
440
441
|
expect(calls).toEqual([["status"], ["collection", "list"], ["update"], ["embed"]]);
|
|
441
442
|
});
|
|
442
|
-
it("never throws even if exec misbehaves", () => {
|
|
443
|
-
const exec = () => {
|
|
443
|
+
it("never throws even if exec misbehaves", async () => {
|
|
444
|
+
const exec = async () => {
|
|
444
445
|
throw new Error("boom");
|
|
445
446
|
};
|
|
446
|
-
expect(
|
|
447
|
+
await expect(reindexAfterSync("/hq", { exec, acquireReindexLock: noLock, existsSync: () => true })).resolves.toMatchObject({ qmdAvailable: false });
|
|
447
448
|
});
|
|
448
|
-
it("hasIndexableMarkdown finds a real .md and ignores INDEX.md", () => {
|
|
449
|
+
it("hasIndexableMarkdown finds a real .md and ignores INDEX.md", async () => {
|
|
449
450
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-test-"));
|
|
450
451
|
try {
|
|
451
452
|
const hq = path.join(dir, "hq");
|
|
@@ -456,7 +457,7 @@ describe("reindexAfterSync", () => {
|
|
|
456
457
|
fs.writeFileSync(path.join(kdir, "INDEX.md"), "# index\n");
|
|
457
458
|
fs.writeFileSync(path.join(kdir, "note.md"), "# real\n");
|
|
458
459
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
459
|
-
const r = reindexAfterSync(hq, { exec, acquireReindexLock: noLock }); // real fs walk
|
|
460
|
+
const r = await reindexAfterSync(hq, { exec, acquireReindexLock: noLock }); // real fs walk
|
|
460
461
|
expect(r.collectionsAdded).toEqual(["acme"]);
|
|
461
462
|
expect(calls.some((c) => c[0] === "collection" && c[1] === "add")).toBe(true);
|
|
462
463
|
}
|
|
@@ -465,12 +466,12 @@ describe("reindexAfterSync", () => {
|
|
|
465
466
|
}
|
|
466
467
|
});
|
|
467
468
|
// ── Corruption safety: lock serialization (feedback_b9a369ff) ──────────────
|
|
468
|
-
it("skips the cycle after status when another writer holds the lock", () => {
|
|
469
|
+
it("skips the cycle after status when another writer holds the lock", async () => {
|
|
469
470
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-lockbusy-"));
|
|
470
471
|
try {
|
|
471
472
|
const statePath = path.join(dir, "qmd-state.json");
|
|
472
473
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
473
|
-
const r = reindexAfterSync("/hq", {
|
|
474
|
+
const r = await reindexAfterSync("/hq", {
|
|
474
475
|
exec,
|
|
475
476
|
existsSync: () => true,
|
|
476
477
|
statePath,
|
|
@@ -489,14 +490,14 @@ describe("reindexAfterSync", () => {
|
|
|
489
490
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
490
491
|
}
|
|
491
492
|
});
|
|
492
|
-
it("always releases the lock, even when a qmd command throws after status", () => {
|
|
493
|
+
it("always releases the lock, even when a qmd command throws after status", async () => {
|
|
493
494
|
let released = false;
|
|
494
|
-
const exec = (args) => {
|
|
495
|
+
const exec = async (args) => {
|
|
495
496
|
if (args[0] === "status")
|
|
496
497
|
return { status: 0, stdout: "" };
|
|
497
498
|
throw new Error("boom");
|
|
498
499
|
};
|
|
499
|
-
reindexAfterSync("/hq", {
|
|
500
|
+
await reindexAfterSync("/hq", {
|
|
500
501
|
exec,
|
|
501
502
|
existsSync: () => true,
|
|
502
503
|
acquireReindexLock: () => ({
|
|
@@ -508,7 +509,7 @@ describe("reindexAfterSync", () => {
|
|
|
508
509
|
expect(released).toBe(true);
|
|
509
510
|
});
|
|
510
511
|
// ── Corruption safety: timeout-kill handling ───────────────────────────────
|
|
511
|
-
it("treats a timed-out update as not-done, marks dirty, and does NOT embed", () => {
|
|
512
|
+
it("treats a timed-out update as not-done, marks dirty, and does NOT embed", async () => {
|
|
512
513
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-timeout-"));
|
|
513
514
|
try {
|
|
514
515
|
const statePath = path.join(dir, "qmd-state.json");
|
|
@@ -516,7 +517,7 @@ describe("reindexAfterSync", () => {
|
|
|
516
517
|
listStdout: "",
|
|
517
518
|
resultFor: (args) => args[0] === "update" ? { status: null, timedOut: true } : undefined,
|
|
518
519
|
});
|
|
519
|
-
const r = reindexAfterSync("/hq", {
|
|
520
|
+
const r = await reindexAfterSync("/hq", {
|
|
520
521
|
exec,
|
|
521
522
|
acquireReindexLock: noLock,
|
|
522
523
|
existsSync: () => true,
|
|
@@ -536,12 +537,12 @@ describe("reindexAfterSync", () => {
|
|
|
536
537
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
537
538
|
}
|
|
538
539
|
});
|
|
539
|
-
it("treats a timed-out embed as not-embedded and marks dirty", () => {
|
|
540
|
+
it("treats a timed-out embed as not-embedded and marks dirty", async () => {
|
|
540
541
|
const { exec } = fakeExec({
|
|
541
542
|
listStdout: "",
|
|
542
543
|
resultFor: (args) => args[0] === "embed" ? { status: null, timedOut: true } : undefined,
|
|
543
544
|
});
|
|
544
|
-
const r = reindexAfterSync("/hq", {
|
|
545
|
+
const r = await reindexAfterSync("/hq", {
|
|
545
546
|
exec,
|
|
546
547
|
acquireReindexLock: noLock,
|
|
547
548
|
existsSync: () => true,
|
|
@@ -554,7 +555,7 @@ describe("reindexAfterSync", () => {
|
|
|
554
555
|
expect(r.pendingDirty).toBe(true);
|
|
555
556
|
});
|
|
556
557
|
// ── Corruption safety: SQLITE_CORRUPT detection + quarantine ────────────────
|
|
557
|
-
it("quarantines the resolved index dir when update reports SQLITE_CORRUPT", () => {
|
|
558
|
+
it("quarantines the resolved index dir when update reports SQLITE_CORRUPT", async () => {
|
|
558
559
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-corrupt-"));
|
|
559
560
|
try {
|
|
560
561
|
const statePath = path.join(dir, "qmd-state.json");
|
|
@@ -571,7 +572,7 @@ describe("reindexAfterSync", () => {
|
|
|
571
572
|
? { status: 1, stderr: "SqliteError: database disk image is malformed (SQLITE_CORRUPT)" }
|
|
572
573
|
: undefined,
|
|
573
574
|
});
|
|
574
|
-
const r = reindexAfterSync("/hq", {
|
|
575
|
+
const r = await reindexAfterSync("/hq", {
|
|
575
576
|
exec,
|
|
576
577
|
acquireReindexLock: noLock,
|
|
577
578
|
existsSync: () => true,
|
|
@@ -593,14 +594,14 @@ describe("reindexAfterSync", () => {
|
|
|
593
594
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
594
595
|
}
|
|
595
596
|
});
|
|
596
|
-
it("reports quarantine failure when corruption is detected but nothing moves", () => {
|
|
597
|
+
it("reports quarantine failure when corruption is detected but nothing moves", async () => {
|
|
597
598
|
const quarantined = [];
|
|
598
599
|
const { exec, calls } = fakeExec({
|
|
599
600
|
resultFor: (args) => args[0] === "collection" && args[1] === "list"
|
|
600
601
|
? { status: 0, stdout: "database disk image is malformed" }
|
|
601
602
|
: undefined,
|
|
602
603
|
});
|
|
603
|
-
const r = reindexAfterSync("/hq", {
|
|
604
|
+
const r = await reindexAfterSync("/hq", {
|
|
604
605
|
exec,
|
|
605
606
|
acquireReindexLock: noLock,
|
|
606
607
|
existsSync: () => true,
|
|
@@ -615,7 +616,7 @@ describe("reindexAfterSync", () => {
|
|
|
615
616
|
expect(calls).toEqual([["status"], ["collection", "list"]]);
|
|
616
617
|
expect(quarantined).toEqual([path.join("/hq", ".qmd")]);
|
|
617
618
|
});
|
|
618
|
-
it("quarantines when embed reports SQLITE_CORRUPT", () => {
|
|
619
|
+
it("quarantines when embed reports SQLITE_CORRUPT", async () => {
|
|
619
620
|
const quarantined = [];
|
|
620
621
|
const { exec, calls } = fakeExec({
|
|
621
622
|
listStdout: "",
|
|
@@ -623,7 +624,7 @@ describe("reindexAfterSync", () => {
|
|
|
623
624
|
? { status: 1, stderr: "code SQLITE_CORRUPT at store.js:1546" }
|
|
624
625
|
: undefined,
|
|
625
626
|
});
|
|
626
|
-
const r = reindexAfterSync("/hq", {
|
|
627
|
+
const r = await reindexAfterSync("/hq", {
|
|
627
628
|
exec,
|
|
628
629
|
acquireReindexLock: noLock,
|
|
629
630
|
existsSync: () => true,
|
|
@@ -642,7 +643,7 @@ describe("reindexAfterSync", () => {
|
|
|
642
643
|
});
|
|
643
644
|
// ── The default reindex lock (real filesystem) ───────────────────────────────
|
|
644
645
|
describe("default reindex lock", () => {
|
|
645
|
-
it("holds the lock for the duration of the cycle and releases it after", () => {
|
|
646
|
+
it("holds the lock for the duration of the cycle and releases it after", async () => {
|
|
646
647
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-lock-"));
|
|
647
648
|
try {
|
|
648
649
|
const hq = path.join(dir, "hq");
|
|
@@ -654,7 +655,7 @@ describe("default reindex lock", () => {
|
|
|
654
655
|
const lockPath = path.join(hq, ".qmd", ".reindex.lock");
|
|
655
656
|
let reAcquireBlockedWhileHeld = false;
|
|
656
657
|
const { exec } = fakeExec({ listStdout: "" });
|
|
657
|
-
const observingExec = (args) => {
|
|
658
|
+
const observingExec = async (args) => {
|
|
658
659
|
if (args[0] === "update") {
|
|
659
660
|
try {
|
|
660
661
|
fs.closeSync(fs.openSync(lockPath, "wx"));
|
|
@@ -665,9 +666,9 @@ describe("default reindex lock", () => {
|
|
|
665
666
|
err.code === "EEXIST";
|
|
666
667
|
}
|
|
667
668
|
}
|
|
668
|
-
return exec(args);
|
|
669
|
+
return await exec(args);
|
|
669
670
|
};
|
|
670
|
-
const r = reindexAfterSync(hq, { exec: observingExec });
|
|
671
|
+
const r = await reindexAfterSync(hq, { exec: observingExec });
|
|
671
672
|
expect(r.updated).toBe(true);
|
|
672
673
|
expect(reAcquireBlockedWhileHeld).toBe(true);
|
|
673
674
|
expect(fs.existsSync(lockPath)).toBe(false);
|
|
@@ -676,7 +677,7 @@ describe("default reindex lock", () => {
|
|
|
676
677
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
677
678
|
}
|
|
678
679
|
});
|
|
679
|
-
it("reclaims a lock held by a dead PID (stale takeover)", () => {
|
|
680
|
+
it("reclaims a lock held by a dead PID (stale takeover)", async () => {
|
|
680
681
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-lock-stale-"));
|
|
681
682
|
try {
|
|
682
683
|
const hq = path.join(dir, "hq");
|
|
@@ -689,7 +690,7 @@ describe("default reindex lock", () => {
|
|
|
689
690
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
690
691
|
fs.writeFileSync(lockPath, JSON.stringify({ pid: 2147483646, startedAt: "2000-01-01T00:00:00.000Z", command: "dead" }));
|
|
691
692
|
const { exec, calls } = fakeExec({ listStdout: "" });
|
|
692
|
-
const r = reindexAfterSync(hq, { exec });
|
|
693
|
+
const r = await reindexAfterSync(hq, { exec });
|
|
693
694
|
expect(r.updated).toBe(true);
|
|
694
695
|
expect(r.lockBusy).toBe(false);
|
|
695
696
|
expect(calls.some((c) => c[0] === "update")).toBe(true);
|
|
@@ -700,7 +701,7 @@ describe("default reindex lock", () => {
|
|
|
700
701
|
});
|
|
701
702
|
});
|
|
702
703
|
describe("acquireDualReindexLocks", () => {
|
|
703
|
-
it("dedupes identical paths so a process cannot lock itself out", () => {
|
|
704
|
+
it("dedupes identical paths so a process cannot lock itself out", async () => {
|
|
704
705
|
let acquires = 0;
|
|
705
706
|
const acquire = () => {
|
|
706
707
|
acquires++;
|
|
@@ -712,7 +713,7 @@ describe("acquireDualReindexLocks", () => {
|
|
|
712
713
|
expect(locks).toHaveLength(1);
|
|
713
714
|
expect(acquires).toBe(1);
|
|
714
715
|
});
|
|
715
|
-
it("releases the first lock when the second is busy", () => {
|
|
716
|
+
it("releases the first lock when the second is busy", async () => {
|
|
716
717
|
const released = [];
|
|
717
718
|
let n = 0;
|
|
718
719
|
const acquire = (lockPath) => {
|
|
@@ -736,46 +737,158 @@ describe("acquireDualReindexLocks", () => {
|
|
|
736
737
|
});
|
|
737
738
|
// ── Pure helpers ─────────────────────────────────────────────────────────────
|
|
738
739
|
describe("resolveExecTimeoutMs", () => {
|
|
739
|
-
it("defaults to a generous bound, not the old 120s that killed writes", () => {
|
|
740
|
+
it("defaults to a generous bound, not the old 120s that killed writes", async () => {
|
|
740
741
|
expect(resolveExecTimeoutMs({})).toBe(DEFAULT_QMD_EXEC_TIMEOUT_MS);
|
|
741
742
|
expect(DEFAULT_QMD_EXEC_TIMEOUT_MS).toBeGreaterThan(120_000);
|
|
742
743
|
});
|
|
743
|
-
it("honors a valid HQ_QMD_EXEC_TIMEOUT_MS override", () => {
|
|
744
|
+
it("honors a valid HQ_QMD_EXEC_TIMEOUT_MS override", async () => {
|
|
744
745
|
expect(resolveExecTimeoutMs({ HQ_QMD_EXEC_TIMEOUT_MS: "300000" })).toBe(300_000);
|
|
745
746
|
});
|
|
746
|
-
it("ignores an invalid override", () => {
|
|
747
|
+
it("ignores an invalid override", async () => {
|
|
747
748
|
expect(resolveExecTimeoutMs({ HQ_QMD_EXEC_TIMEOUT_MS: "nope" })).toBe(DEFAULT_QMD_EXEC_TIMEOUT_MS);
|
|
748
749
|
expect(resolveExecTimeoutMs({ HQ_QMD_EXEC_TIMEOUT_MS: "-5" })).toBe(DEFAULT_QMD_EXEC_TIMEOUT_MS);
|
|
749
750
|
expect(resolveExecTimeoutMs({ HQ_QMD_EXEC_TIMEOUT_MS: "0" })).toBe(DEFAULT_QMD_EXEC_TIMEOUT_MS);
|
|
750
751
|
});
|
|
751
752
|
});
|
|
753
|
+
describe("createQmdExec", () => {
|
|
754
|
+
function waitForReadyMarker(markerPath) {
|
|
755
|
+
if (fs.existsSync(markerPath))
|
|
756
|
+
return Promise.resolve();
|
|
757
|
+
return new Promise((resolve, reject) => {
|
|
758
|
+
let settled = false;
|
|
759
|
+
const watcher = fs.watch(path.dirname(markerPath));
|
|
760
|
+
const timeout = setRealTimeout(() => {
|
|
761
|
+
if (settled)
|
|
762
|
+
return;
|
|
763
|
+
settled = true;
|
|
764
|
+
watcher.close();
|
|
765
|
+
reject(new Error(`qmd child did not become ready: ${markerPath}`));
|
|
766
|
+
}, 10_000);
|
|
767
|
+
timeout.unref();
|
|
768
|
+
const finish = (error) => {
|
|
769
|
+
if (settled)
|
|
770
|
+
return;
|
|
771
|
+
settled = true;
|
|
772
|
+
watcher.close();
|
|
773
|
+
clearRealTimeout(timeout);
|
|
774
|
+
if (error)
|
|
775
|
+
reject(error);
|
|
776
|
+
else
|
|
777
|
+
resolve();
|
|
778
|
+
};
|
|
779
|
+
watcher.on("change", (_event, filename) => {
|
|
780
|
+
if ((filename === null || filename.toString() === path.basename(markerPath)) &&
|
|
781
|
+
fs.existsSync(markerPath)) {
|
|
782
|
+
finish();
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
watcher.once("error", finish);
|
|
786
|
+
if (fs.existsSync(markerPath))
|
|
787
|
+
finish();
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
function readyChildScript(markerPath, sigintHandler) {
|
|
791
|
+
return [
|
|
792
|
+
"const fs = require('node:fs');",
|
|
793
|
+
`process.on('SIGINT', ${sigintHandler});`,
|
|
794
|
+
`fs.writeFileSync(${JSON.stringify(markerPath)}, 'ready');`,
|
|
795
|
+
"setInterval(() => {}, 1000);",
|
|
796
|
+
].join(" ");
|
|
797
|
+
}
|
|
798
|
+
it("captures output and sends SIGINT when the configured timeout expires", async () => {
|
|
799
|
+
const markerDir = fs.mkdtempSync(path.join(os.tmpdir(), "qmd-exec-timeout-"));
|
|
800
|
+
const markerPath = path.join(markerDir, "ready");
|
|
801
|
+
const controller = new AbortController();
|
|
802
|
+
const exec = createQmdExec({
|
|
803
|
+
command: process.execPath,
|
|
804
|
+
timeoutMs: 250,
|
|
805
|
+
// Keep the escalation outside the 250ms timeout-clock advance.
|
|
806
|
+
sigintGraceMs: 1_000,
|
|
807
|
+
});
|
|
808
|
+
const ready = waitForReadyMarker(markerPath);
|
|
809
|
+
let resultPromise;
|
|
810
|
+
vi.useFakeTimers();
|
|
811
|
+
try {
|
|
812
|
+
resultPromise = exec([
|
|
813
|
+
"-e",
|
|
814
|
+
readyChildScript(markerPath, "() => { process.stdout.write('sigint'); process.exit(0); }"),
|
|
815
|
+
], { signal: controller.signal });
|
|
816
|
+
await Promise.race([
|
|
817
|
+
ready,
|
|
818
|
+
resultPromise.then(() => {
|
|
819
|
+
throw new Error("qmd child exited before installing its SIGINT handler");
|
|
820
|
+
}),
|
|
821
|
+
]);
|
|
822
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
823
|
+
const result = await resultPromise;
|
|
824
|
+
expect(result).toMatchObject({ status: 0, timedOut: true });
|
|
825
|
+
expect(result.stdout).toContain("sigint");
|
|
826
|
+
}
|
|
827
|
+
finally {
|
|
828
|
+
vi.useRealTimers();
|
|
829
|
+
controller.abort();
|
|
830
|
+
if (resultPromise)
|
|
831
|
+
await resultPromise;
|
|
832
|
+
fs.rmSync(markerDir, { recursive: true, force: true });
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
it("sends SIGINT to an in-flight child on shutdown and escalates after the grace period", async () => {
|
|
836
|
+
const markerDir = fs.mkdtempSync(path.join(os.tmpdir(), "qmd-exec-abort-"));
|
|
837
|
+
const markerPath = path.join(markerDir, "ready");
|
|
838
|
+
const controller = new AbortController();
|
|
839
|
+
const exec = createQmdExec({
|
|
840
|
+
command: process.execPath,
|
|
841
|
+
timeoutMs: 10_000,
|
|
842
|
+
sigintGraceMs: 50,
|
|
843
|
+
});
|
|
844
|
+
let resultPromise;
|
|
845
|
+
try {
|
|
846
|
+
resultPromise = exec([
|
|
847
|
+
"-e",
|
|
848
|
+
readyChildScript(markerPath, "() => process.stdout.write('sigint')"),
|
|
849
|
+
], { signal: controller.signal });
|
|
850
|
+
await waitForReadyMarker(markerPath);
|
|
851
|
+
controller.abort();
|
|
852
|
+
const result = await resultPromise;
|
|
853
|
+
expect(result.timedOut).toBe(false);
|
|
854
|
+
expect(result.stdout).toContain("sigint");
|
|
855
|
+
expect(result.status).toBeNull();
|
|
856
|
+
}
|
|
857
|
+
finally {
|
|
858
|
+
controller.abort();
|
|
859
|
+
if (resultPromise)
|
|
860
|
+
await resultPromise;
|
|
861
|
+
fs.rmSync(markerDir, { recursive: true, force: true });
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
});
|
|
752
865
|
describe("looksCorrupt", () => {
|
|
753
|
-
it("matches the SQLITE_CORRUPT signature on stdout or stderr", () => {
|
|
866
|
+
it("matches the SQLITE_CORRUPT signature on stdout or stderr", async () => {
|
|
754
867
|
expect(looksCorrupt({ stderr: "SqliteError: ... code SQLITE_CORRUPT" })).toBe(true);
|
|
755
868
|
expect(looksCorrupt({ stdout: "database disk image is malformed" })).toBe(true);
|
|
756
869
|
expect(looksCorrupt({ stdout: "Database disk image IS MALFORMED" })).toBe(true);
|
|
757
870
|
});
|
|
758
|
-
it("does not match clean output", () => {
|
|
871
|
+
it("does not match clean output", async () => {
|
|
759
872
|
expect(looksCorrupt({ stdout: "Updated 688 documents", stderr: "" })).toBe(false);
|
|
760
873
|
expect(looksCorrupt({})).toBe(false);
|
|
761
874
|
});
|
|
762
875
|
});
|
|
763
876
|
describe("parseIndexDirFromStatus / parseCollectionPathFromShow", () => {
|
|
764
|
-
it("parses Index file path into its directory", () => {
|
|
877
|
+
it("parses Index file path into its directory", async () => {
|
|
765
878
|
expect(parseIndexDirFromStatus("Index: C:/Users/me/.cache/qmd/index.sqlite\nSize: 1.3 GB\n")).toBe(path.dirname("C:/Users/me/.cache/qmd/index.sqlite"));
|
|
766
879
|
});
|
|
767
|
-
it("returns null when Index line is missing", () => {
|
|
880
|
+
it("returns null when Index line is missing", async () => {
|
|
768
881
|
expect(parseIndexDirFromStatus("Collections: 2\n")).toBeNull();
|
|
769
882
|
});
|
|
770
|
-
it("parses Path from collection show", () => {
|
|
883
|
+
it("parses Path from collection show", async () => {
|
|
771
884
|
expect(parseCollectionPathFromShow("Collection: indigo\n Path: /hq/companies/indigo/knowledge\n")).toBe("/hq/companies/indigo/knowledge");
|
|
772
885
|
});
|
|
773
886
|
});
|
|
774
887
|
describe("pathsEquivalent / repairPathDriftEnabled", () => {
|
|
775
|
-
it("treats case and trailing separators as equal", () => {
|
|
888
|
+
it("treats case and trailing separators as equal", async () => {
|
|
776
889
|
expect(pathsEquivalent("/HQ/Companies/Acme/knowledge/", "/hq/companies/acme/knowledge", (p) => p)).toBe(true);
|
|
777
890
|
});
|
|
778
|
-
it("enables repair only for 1/true/yes", () => {
|
|
891
|
+
it("enables repair only for 1/true/yes", async () => {
|
|
779
892
|
expect(repairPathDriftEnabled({})).toBe(false);
|
|
780
893
|
expect(repairPathDriftEnabled({ HQ_QMD_REPAIR_PATH_DRIFT: "1" })).toBe(true);
|
|
781
894
|
expect(repairPathDriftEnabled({ HQ_QMD_REPAIR_PATH_DRIFT: "true" })).toBe(true);
|