@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.
Files changed (49) hide show
  1. package/dist/bin/sync-runner-watch-loop.d.ts +4 -1
  2. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-watch-loop.js +117 -17
  4. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  5. package/dist/bin/sync-runner-watch-loop.test.js +211 -3
  6. package/dist/bin/sync-runner-watch-loop.test.js.map +1 -1
  7. package/dist/bin/sync-runner.d.ts +12 -1
  8. package/dist/bin/sync-runner.d.ts.map +1 -1
  9. package/dist/bin/sync-runner.js +32 -14
  10. package/dist/bin/sync-runner.js.map +1 -1
  11. package/dist/bin/sync-runner.test.js +59 -4
  12. package/dist/bin/sync-runner.test.js.map +1 -1
  13. package/dist/qmd-reindex.d.ts +30 -10
  14. package/dist/qmd-reindex.d.ts.map +1 -1
  15. package/dist/qmd-reindex.js +148 -41
  16. package/dist/qmd-reindex.js.map +1 -1
  17. package/dist/qmd-reindex.test.js +191 -78
  18. package/dist/qmd-reindex.test.js.map +1 -1
  19. package/dist/sync/event-sync.d.ts +2 -0
  20. package/dist/sync/event-sync.d.ts.map +1 -1
  21. package/dist/sync/event-sync.js +15 -1
  22. package/dist/sync/event-sync.js.map +1 -1
  23. package/dist/sync/event-sync.test.js +32 -0
  24. package/dist/sync/event-sync.test.js.map +1 -1
  25. package/dist/sync/push-receiver.d.ts +21 -1
  26. package/dist/sync/push-receiver.d.ts.map +1 -1
  27. package/dist/sync/push-receiver.js +166 -8
  28. package/dist/sync/push-receiver.js.map +1 -1
  29. package/dist/sync/push-receiver.test.js +258 -1
  30. package/dist/sync/push-receiver.test.js.map +1 -1
  31. package/dist/sync/receiver-last-delivery.d.ts +22 -0
  32. package/dist/sync/receiver-last-delivery.d.ts.map +1 -0
  33. package/dist/sync/receiver-last-delivery.js +139 -0
  34. package/dist/sync/receiver-last-delivery.js.map +1 -0
  35. package/dist/sync/receiver-last-delivery.test.d.ts +2 -0
  36. package/dist/sync/receiver-last-delivery.test.d.ts.map +1 -0
  37. package/dist/sync/receiver-last-delivery.test.js +110 -0
  38. package/dist/sync/receiver-last-delivery.test.js.map +1 -0
  39. package/dist/sync/sync-contract.d.ts.map +1 -1
  40. package/dist/sync/sync-contract.js +1 -0
  41. package/dist/sync/sync-contract.js.map +1 -1
  42. package/dist/sync-contract.json +2 -1
  43. package/dist/watch-owner-lease.d.ts +2 -0
  44. package/dist/watch-owner-lease.d.ts.map +1 -1
  45. package/dist/watch-owner-lease.js +3 -2
  46. package/dist/watch-owner-lease.js.map +1 -1
  47. package/dist/watch-owner-lease.test.js +22 -4
  48. package/dist/watch-owner-lease.test.js.map +1 -1
  49. package/package.json +1 -1
@@ -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 { reindexAfterSync, looksCorrupt, resolveExecTimeoutMs, DEFAULT_QMD_EXEC_TIMEOUT_MS, parseIndexDirFromStatus, parseCollectionPathFromShow, pathsEquivalent, repairPathDriftEnabled, acquireDualReindexLocks, } from "./qmd-reindex.js";
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(() => reindexAfterSync("/hq", { exec, acquireReindexLock: noLock, existsSync: () => true })).not.toThrow();
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);