@indigoai-us/hq-cloud 6.14.28 → 6.14.30

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 (93) hide show
  1. package/dist/bin/sync-runner-company.d.ts +4 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +3 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-planning.d.ts +7 -6
  6. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  7. package/dist/bin/sync-runner-planning.js +1 -1
  8. package/dist/bin/sync-runner-planning.js.map +1 -1
  9. package/dist/bin/sync-runner-planning.test.js +23 -0
  10. package/dist/bin/sync-runner-planning.test.js.map +1 -1
  11. package/dist/bin/sync-runner-rollup.d.ts.map +1 -1
  12. package/dist/bin/sync-runner-rollup.js +3 -1
  13. package/dist/bin/sync-runner-rollup.js.map +1 -1
  14. package/dist/bin/sync-runner-rollup.test.d.ts +2 -0
  15. package/dist/bin/sync-runner-rollup.test.d.ts.map +1 -0
  16. package/dist/bin/sync-runner-rollup.test.js +24 -0
  17. package/dist/bin/sync-runner-rollup.test.js.map +1 -0
  18. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  19. package/dist/bin/sync-runner-watch-loop.js +15 -1
  20. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  21. package/dist/bin/sync-runner.d.ts +11 -7
  22. package/dist/bin/sync-runner.d.ts.map +1 -1
  23. package/dist/bin/sync-runner.js +51 -9
  24. package/dist/bin/sync-runner.js.map +1 -1
  25. package/dist/bin/sync-runner.test.js +97 -0
  26. package/dist/bin/sync-runner.test.js.map +1 -1
  27. package/dist/cli/doctor.d.ts.map +1 -1
  28. package/dist/cli/doctor.js +3 -1
  29. package/dist/cli/doctor.js.map +1 -1
  30. package/dist/cli/reindex.d.ts.map +1 -1
  31. package/dist/cli/reindex.js +24 -13
  32. package/dist/cli/reindex.js.map +1 -1
  33. package/dist/cli/rescue-core.js +3 -1
  34. package/dist/cli/rescue-core.js.map +1 -1
  35. package/dist/cli/share.d.ts +35 -0
  36. package/dist/cli/share.d.ts.map +1 -1
  37. package/dist/cli/share.js +29 -11
  38. package/dist/cli/share.js.map +1 -1
  39. package/dist/cli/share.test.js +46 -0
  40. package/dist/cli/share.test.js.map +1 -1
  41. package/dist/ignore.d.ts.map +1 -1
  42. package/dist/ignore.js +5 -0
  43. package/dist/ignore.js.map +1 -1
  44. package/dist/ignore.test.js +9 -0
  45. package/dist/ignore.test.js.map +1 -1
  46. package/dist/outcome-telemetry.d.ts +167 -0
  47. package/dist/outcome-telemetry.d.ts.map +1 -0
  48. package/dist/outcome-telemetry.js +479 -0
  49. package/dist/outcome-telemetry.js.map +1 -0
  50. package/dist/outcome-telemetry.test.d.ts +9 -0
  51. package/dist/outcome-telemetry.test.d.ts.map +1 -0
  52. package/dist/outcome-telemetry.test.js +412 -0
  53. package/dist/outcome-telemetry.test.js.map +1 -0
  54. package/dist/qmd-reindex.d.ts +48 -35
  55. package/dist/qmd-reindex.d.ts.map +1 -1
  56. package/dist/qmd-reindex.js +188 -60
  57. package/dist/qmd-reindex.js.map +1 -1
  58. package/dist/qmd-reindex.test.d.ts +3 -3
  59. package/dist/qmd-reindex.test.js +203 -41
  60. package/dist/qmd-reindex.test.js.map +1 -1
  61. package/dist/telemetry.d.ts +5 -4
  62. package/dist/telemetry.d.ts.map +1 -1
  63. package/dist/telemetry.js +187 -13
  64. package/dist/telemetry.js.map +1 -1
  65. package/dist/telemetry.test.js +157 -0
  66. package/dist/telemetry.test.js.map +1 -1
  67. package/dist/vault-client.d.ts +34 -0
  68. package/dist/vault-client.d.ts.map +1 -1
  69. package/dist/vault-client.js +23 -0
  70. package/dist/vault-client.js.map +1 -1
  71. package/package.json +1 -1
  72. package/src/bin/sync-runner-company.ts +4 -0
  73. package/src/bin/sync-runner-planning.test.ts +26 -0
  74. package/src/bin/sync-runner-planning.ts +8 -7
  75. package/src/bin/sync-runner-rollup.test.ts +37 -0
  76. package/src/bin/sync-runner-rollup.ts +3 -1
  77. package/src/bin/sync-runner-watch-loop.ts +22 -1
  78. package/src/bin/sync-runner.test.ts +111 -0
  79. package/src/bin/sync-runner.ts +62 -17
  80. package/src/cli/doctor.ts +3 -1
  81. package/src/cli/reindex.ts +24 -12
  82. package/src/cli/rescue-core.ts +3 -1
  83. package/src/cli/share.test.ts +60 -0
  84. package/src/cli/share.ts +29 -11
  85. package/src/ignore.test.ts +10 -0
  86. package/src/ignore.ts +5 -0
  87. package/src/outcome-telemetry.test.ts +498 -0
  88. package/src/outcome-telemetry.ts +639 -0
  89. package/src/qmd-reindex.test.ts +226 -40
  90. package/src/qmd-reindex.ts +209 -61
  91. package/src/telemetry.test.ts +194 -0
  92. package/src/telemetry.ts +233 -14
  93. package/src/vault-client.ts +55 -0
@@ -6,9 +6,9 @@
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
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.
9
+ * Corruption-safety tests pin feedback_b9a369ff + feedback_332c7ccc (resolved
10
+ * index path, dual-lock, honest quarantine). Path-drift tests pin
11
+ * feedback_f8e233c6 (detect-only by default; opt-in repair).
12
12
  */
13
13
 
14
14
  import { describe, it, expect } from "vitest";
@@ -20,6 +20,11 @@ import {
20
20
  looksCorrupt,
21
21
  resolveExecTimeoutMs,
22
22
  DEFAULT_QMD_EXEC_TIMEOUT_MS,
23
+ parseIndexDirFromStatus,
24
+ parseCollectionPathFromShow,
25
+ pathsEquivalent,
26
+ repairPathDriftEnabled,
27
+ acquireDualReindexLocks,
23
28
  type QmdExec,
24
29
  type QmdExecResult,
25
30
  type AcquireReindexLock,
@@ -29,9 +34,13 @@ import {
29
34
  /** A lock acquirer that always succeeds — keeps command-sequence tests pure. */
30
35
  const noLock: AcquireReindexLock = () => ({ release() {} });
31
36
 
37
+ /** Identity realpath so path-drift comparisons stay deterministic in fakes. */
38
+ const identityRealpath = (p: string) => path.resolve(p);
39
+
32
40
  /** Build a fake exec that returns status 0 for everything and logs calls. */
33
41
  function fakeExec(opts?: {
34
42
  listStdout?: string;
43
+ statusStdout?: string;
35
44
  failOn?: string;
36
45
  resultFor?: (args: string[]) => Partial<QmdExecResult> | undefined;
37
46
  }): {
@@ -44,9 +53,20 @@ function fakeExec(opts?: {
44
53
  const override = opts?.resultFor?.(args);
45
54
  if (override) return { status: 0, stdout: "", ...override };
46
55
  if (opts?.failOn && args[0] === opts.failOn) return { status: 1, stdout: "" };
56
+ if (args[0] === "status") {
57
+ return { status: 0, stdout: opts?.statusStdout ?? "" };
58
+ }
47
59
  if (args[0] === "collection" && args[1] === "list") {
48
60
  return { status: 0, stdout: opts?.listStdout ?? "" };
49
61
  }
62
+ if (args[0] === "collection" && args[1] === "show") {
63
+ const name = args[2] ?? "unknown";
64
+ const registered = path.join("/hq", "companies", name, "knowledge");
65
+ return {
66
+ status: 0,
67
+ stdout: `Collection: ${name}\n Path: ${registered}\n Pattern: **/*.md\n`,
68
+ };
69
+ }
50
70
  return { status: 0, stdout: "" };
51
71
  };
52
72
  return { exec, calls };
@@ -73,8 +93,7 @@ describe("reindexAfterSync", () => {
73
93
  });
74
94
  expect(r.qmdAvailable).toBe(false);
75
95
  expect(r.updated).toBe(false);
76
- // Only the availability probe ran, then bailed.
77
- expect(calls).toEqual([["collection", "list"]]);
96
+ expect(calls).toEqual([["status"], ["collection", "list"]]);
78
97
  });
79
98
 
80
99
  it("registers a missing company collection then runs an incremental update", () => {
@@ -91,8 +110,8 @@ describe("reindexAfterSync", () => {
91
110
  expect(r.updated).toBe(true);
92
111
  expect(r.embedded).toBe(false);
93
112
 
94
- // Expected command sequence: probe, add, context, update.
95
113
  expect(calls).toEqual([
114
+ ["status"],
96
115
  ["collection", "list"],
97
116
  ["collection", "add", path.join("/hq", "companies", "acme", "knowledge"), "--name", "acme", "--mask", "**/*.md"],
98
117
  ["context", "add", "qmd://acme", "Knowledge base for acme."],
@@ -100,7 +119,7 @@ describe("reindexAfterSync", () => {
100
119
  ]);
101
120
  });
102
121
 
103
- it("skips collections that already exist", () => {
122
+ it("skips collections that already exist at the desired path", () => {
104
123
  const { exec, calls } = fakeExec({ listStdout: "acme (qmd://acme/)\n" });
105
124
  const r = reindexAfterSync("/hq", {
106
125
  exec,
@@ -108,10 +127,102 @@ describe("reindexAfterSync", () => {
108
127
  existsSync: () => true,
109
128
  readCompanies: () => ["acme"],
110
129
  hasIndexableMarkdown: () => true,
130
+ realpathSync: identityRealpath,
111
131
  });
112
132
  expect(r.collectionsAdded).toEqual([]);
113
- // No `collection add` — straight to update.
114
- expect(calls).toEqual([["collection", "list"], ["update"]]);
133
+ expect(r.pathDriftDetected).toEqual([]);
134
+ expect(calls).toEqual([
135
+ ["status"],
136
+ ["collection", "list"],
137
+ ["collection", "show", "acme"],
138
+ ["update"],
139
+ ]);
140
+ });
141
+
142
+ it("detects path drift without repairing when HQ_QMD_REPAIR_PATH_DRIFT is off", () => {
143
+ const drifted = path.join("/mirror", "knowledge-acme");
144
+ const { exec, calls } = fakeExec({
145
+ listStdout: "acme (qmd://acme/)\n",
146
+ resultFor: (args) =>
147
+ args[0] === "collection" && args[1] === "show"
148
+ ? {
149
+ status: 0,
150
+ stdout: `Collection: acme\n Path: ${drifted}\n`,
151
+ }
152
+ : undefined,
153
+ });
154
+ const r = reindexAfterSync("/hq", {
155
+ exec,
156
+ acquireReindexLock: noLock,
157
+ existsSync: () => true,
158
+ readCompanies: () => ["acme"],
159
+ hasIndexableMarkdown: () => true,
160
+ realpathSync: identityRealpath,
161
+ env: {},
162
+ embed: true,
163
+ });
164
+ expect(r.pathDriftDetected).toEqual(["acme"]);
165
+ expect(r.collectionsRepaired).toEqual([]);
166
+ expect(calls.some((c) => c[0] === "collection" && c[1] === "remove")).toBe(false);
167
+ // Detect-only must still allow embed (no repair this cycle).
168
+ expect(r.embedded).toBe(true);
169
+ });
170
+
171
+ it("repairs path drift when HQ_QMD_REPAIR_PATH_DRIFT=1 and skips embed", () => {
172
+ const drifted = path.join("/mirror", "knowledge-acme");
173
+ const desired = path.join("/hq", "companies", "acme", "knowledge");
174
+ const { exec, calls } = fakeExec({
175
+ listStdout: "acme (qmd://acme/)\n",
176
+ resultFor: (args) =>
177
+ args[0] === "collection" && args[1] === "show"
178
+ ? { status: 0, stdout: `Collection: acme\n Path: ${drifted}\n` }
179
+ : undefined,
180
+ });
181
+ const r = reindexAfterSync("/hq", {
182
+ exec,
183
+ acquireReindexLock: noLock,
184
+ existsSync: () => true,
185
+ readCompanies: () => ["acme"],
186
+ hasIndexableMarkdown: () => true,
187
+ realpathSync: identityRealpath,
188
+ env: { HQ_QMD_REPAIR_PATH_DRIFT: "1" },
189
+ embed: true,
190
+ });
191
+ expect(r.pathDriftDetected).toEqual(["acme"]);
192
+ expect(r.collectionsRepaired).toEqual(["acme"]);
193
+ expect(r.embedded).toBe(false);
194
+ expect(calls).toEqual([
195
+ ["status"],
196
+ ["collection", "list"],
197
+ ["collection", "show", "acme"],
198
+ ["collection", "remove", "acme"],
199
+ ["collection", "add", desired, "--name", "acme", "--mask", "**/*.md"],
200
+ ["context", "add", "qmd://acme", "Knowledge base for acme."],
201
+ ["update"],
202
+ ]);
203
+ });
204
+
205
+ it("does not remove a drifted collection when desired path has no indexable markdown", () => {
206
+ const drifted = path.join("/mirror", "knowledge-acme");
207
+ const { exec, calls } = fakeExec({
208
+ listStdout: "acme (qmd://acme/)\n",
209
+ resultFor: (args) =>
210
+ args[0] === "collection" && args[1] === "show"
211
+ ? { status: 0, stdout: `Collection: acme\n Path: ${drifted}\n` }
212
+ : undefined,
213
+ });
214
+ // hasIndexableMarkdown false → skipped before show/repair (desired empty).
215
+ const r = reindexAfterSync("/hq", {
216
+ exec,
217
+ acquireReindexLock: noLock,
218
+ existsSync: () => true,
219
+ readCompanies: () => ["acme"],
220
+ hasIndexableMarkdown: () => false,
221
+ env: { HQ_QMD_REPAIR_PATH_DRIFT: "1" },
222
+ });
223
+ expect(r.pathDriftDetected).toEqual([]);
224
+ expect(r.collectionsRepaired).toEqual([]);
225
+ expect(calls.some((c) => c[1] === "remove")).toBe(false);
115
226
  });
116
227
 
117
228
  it("skips company dirs with no indexable markdown", () => {
@@ -124,7 +235,7 @@ describe("reindexAfterSync", () => {
124
235
  hasIndexableMarkdown: () => false,
125
236
  });
126
237
  expect(r.collectionsAdded).toEqual([]);
127
- expect(calls).toEqual([["collection", "list"], ["update"]]);
238
+ expect(calls).toEqual([["status"], ["collection", "list"], ["update"]]);
128
239
  });
129
240
 
130
241
  it("skips qmd entirely when provided changed paths do not touch knowledge markdown", () => {
@@ -150,14 +261,21 @@ describe("reindexAfterSync", () => {
150
261
  exec,
151
262
  acquireReindexLock: noLock,
152
263
  existsSync: () => true,
264
+ readCompanies: () => ["acme"],
265
+ hasIndexableMarkdown: () => true,
153
266
  changedPaths: ["companies/acme/knowledge/note.md"],
154
267
  debounceMs: 60_000,
155
268
  nowMs: 100_000,
156
269
  statePath,
270
+ realpathSync: identityRealpath,
157
271
  });
158
272
  expect(first.updated).toBe(false);
159
273
  expect(first.pendingDirty).toBe(true);
160
- expect(calls).toEqual([["collection", "list"]]);
274
+ expect(calls).toEqual([
275
+ ["status"],
276
+ ["collection", "list"],
277
+ ["collection", "show", "acme"],
278
+ ]);
161
279
 
162
280
  const second = reindexAfterSync("/hq", {
163
281
  exec,
@@ -171,7 +289,10 @@ describe("reindexAfterSync", () => {
171
289
  expect(second.updated).toBe(true);
172
290
  expect(second.pendingDirty).toBe(false);
173
291
  expect(calls).toEqual([
292
+ ["status"],
174
293
  ["collection", "list"],
294
+ ["collection", "show", "acme"],
295
+ ["status"],
175
296
  ["collection", "list"],
176
297
  ["update"],
177
298
  ]);
@@ -190,7 +311,7 @@ describe("reindexAfterSync", () => {
190
311
  embed: true,
191
312
  });
192
313
  expect(r.embedded).toBe(true);
193
- expect(calls).toEqual([["collection", "list"], ["update"], ["embed"]]);
314
+ expect(calls).toEqual([["status"], ["collection", "list"], ["update"], ["embed"]]);
194
315
  });
195
316
 
196
317
  it("never throws even if exec misbehaves", () => {
@@ -224,7 +345,7 @@ describe("reindexAfterSync", () => {
224
345
 
225
346
  // ── Corruption safety: lock serialization (feedback_b9a369ff) ──────────────
226
347
 
227
- it("skips the whole cycle without touching qmd when another writer holds the lock", () => {
348
+ it("skips the cycle after status when another writer holds the lock", () => {
228
349
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-lockbusy-"));
229
350
  try {
230
351
  const statePath = path.join(dir, "qmd-state.json");
@@ -236,13 +357,11 @@ describe("reindexAfterSync", () => {
236
357
  // Simulate a concurrent qmd writer already holding the lock.
237
358
  acquireReindexLock: () => null,
238
359
  });
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([]);
360
+ // status is read-only and runs before lock; no write commands follow.
361
+ expect(calls).toEqual([["status"]]);
242
362
  expect(r.lockBusy).toBe(true);
243
363
  expect(r.pendingDirty).toBe(true);
244
364
  expect(r.updated).toBe(false);
245
- // The tree is marked dirty so the next sync retries once the lock frees.
246
365
  const persisted = JSON.parse(fs.readFileSync(statePath, "utf8"));
247
366
  expect(persisted.pendingDirty).toBe(true);
248
367
  } finally {
@@ -250,9 +369,10 @@ describe("reindexAfterSync", () => {
250
369
  }
251
370
  });
252
371
 
253
- it("always releases the lock, even when a qmd command throws", () => {
372
+ it("always releases the lock, even when a qmd command throws after status", () => {
254
373
  let released = false;
255
- const exec: QmdExec = () => {
374
+ const exec: QmdExec = (args) => {
375
+ if (args[0] === "status") return { status: 0, stdout: "" };
256
376
  throw new Error("boom");
257
377
  };
258
378
  reindexAfterSync("/hq", {
@@ -283,15 +403,14 @@ describe("reindexAfterSync", () => {
283
403
  acquireReindexLock: noLock,
284
404
  existsSync: () => true,
285
405
  readCompanies: () => [],
286
- embed: true, // would normally run embed after update
406
+ embed: true,
287
407
  statePath,
288
408
  });
289
409
  expect(r.timedOut).toBe(true);
290
410
  expect(r.updated).toBe(false);
291
411
  expect(r.embedded).toBe(false);
292
412
  expect(r.pendingDirty).toBe(true);
293
- // Crucially: no `embed` after a timed-out update — never write further.
294
- expect(calls).toEqual([["collection", "list"], ["update"]]);
413
+ expect(calls).toEqual([["status"], ["collection", "list"], ["update"]]);
295
414
  const persisted = JSON.parse(fs.readFileSync(statePath, "utf8"));
296
415
  expect(persisted.pendingDirty).toBe(true);
297
416
  } finally {
@@ -320,10 +439,11 @@ describe("reindexAfterSync", () => {
320
439
 
321
440
  // ── Corruption safety: SQLITE_CORRUPT detection + quarantine ────────────────
322
441
 
323
- it("quarantines the DB and stops writing when update reports SQLITE_CORRUPT", () => {
442
+ it("quarantines the resolved index dir when update reports SQLITE_CORRUPT", () => {
324
443
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-corrupt-"));
325
444
  try {
326
445
  const statePath = path.join(dir, "qmd-state.json");
446
+ const cacheDir = path.join(dir, "cache-qmd");
327
447
  const quarantined: Array<{ qmdDir: string; suffix: string }> = [];
328
448
  const quarantine: QuarantineCorruptIndex = (qmdDir, suffix) => {
329
449
  quarantined.push({ qmdDir, suffix });
@@ -331,6 +451,7 @@ describe("reindexAfterSync", () => {
331
451
  };
332
452
  const { exec, calls } = fakeExec({
333
453
  listStdout: "",
454
+ statusStdout: `Index: ${path.join(cacheDir, "index.sqlite")}\nSize: 1 GB\n`,
334
455
  resultFor: (args) =>
335
456
  args[0] === "update"
336
457
  ? { status: 1, stderr: "SqliteError: database disk image is malformed (SQLITE_CORRUPT)" }
@@ -341,24 +462,24 @@ describe("reindexAfterSync", () => {
341
462
  acquireReindexLock: noLock,
342
463
  existsSync: () => true,
343
464
  readCompanies: () => [],
344
- embed: true, // must NOT run after corruption
465
+ embed: true,
345
466
  statePath,
346
467
  quarantineCorruptIndex: quarantine,
347
468
  });
348
469
  expect(r.corruptionQuarantined).toBe(true);
470
+ expect(r.corruptionQuarantineFailed).toBe(false);
349
471
  expect(r.embedded).toBe(false);
350
472
  expect(r.pendingDirty).toBe(true);
351
- expect(calls).toEqual([["collection", "list"], ["update"]]); // no embed
473
+ expect(calls).toEqual([["status"], ["collection", "list"], ["update"]]);
352
474
  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);
475
+ expect(quarantined[0].qmdDir).toBe(cacheDir);
476
+ expect(r.indexDir).toBe(cacheDir);
356
477
  } finally {
357
478
  fs.rmSync(dir, { recursive: true, force: true });
358
479
  }
359
480
  });
360
481
 
361
- it("quarantines on a corrupt collection-list probe before any write", () => {
482
+ it("reports quarantine failure when corruption is detected but nothing moves", () => {
362
483
  const quarantined: string[] = [];
363
484
  const { exec, calls } = fakeExec({
364
485
  resultFor: (args) =>
@@ -375,10 +496,10 @@ describe("reindexAfterSync", () => {
375
496
  return null;
376
497
  },
377
498
  });
378
- expect(r.corruptionQuarantined).toBe(true);
499
+ expect(r.corruptionQuarantined).toBe(false);
500
+ expect(r.corruptionQuarantineFailed).toBe(true);
379
501
  expect(r.updated).toBe(false);
380
- // Detected on the probe — never runs update/embed onto the corrupt store.
381
- expect(calls).toEqual([["collection", "list"]]);
502
+ expect(calls).toEqual([["status"], ["collection", "list"]]);
382
503
  expect(quarantined).toEqual([path.join("/hq", ".qmd")]);
383
504
  });
384
505
 
@@ -399,12 +520,12 @@ describe("reindexAfterSync", () => {
399
520
  embed: true,
400
521
  quarantineCorruptIndex: (qmdDir) => {
401
522
  quarantined.push(qmdDir);
402
- return null;
523
+ return path.join(qmdDir, "index.sqlite.corrupt-x");
403
524
  },
404
525
  });
405
526
  expect(r.corruptionQuarantined).toBe(true);
406
527
  expect(r.embedded).toBe(false);
407
- expect(calls).toEqual([["collection", "list"], ["update"], ["embed"]]);
528
+ expect(calls).toEqual([["status"], ["collection", "list"], ["update"], ["embed"]]);
408
529
  expect(quarantined).toEqual([path.join("/hq", ".qmd")]);
409
530
  });
410
531
  });
@@ -422,8 +543,6 @@ describe("default reindex lock", () => {
422
543
  fs.writeFileSync(path.join(hq, "core", "core.yaml"), "v: 1\n");
423
544
  fs.writeFileSync(path.join(kdir, "note.md"), "# real\n");
424
545
 
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
546
  const lockPath = path.join(hq, ".qmd", ".reindex.lock");
428
547
  let reAcquireBlockedWhileHeld = false;
429
548
  const { exec } = fakeExec({ listStdout: "" });
@@ -431,7 +550,7 @@ describe("default reindex lock", () => {
431
550
  if (args[0] === "update") {
432
551
  try {
433
552
  fs.closeSync(fs.openSync(lockPath, "wx"));
434
- fs.unlinkSync(lockPath); // acquired — NOT what we want
553
+ fs.unlinkSync(lockPath);
435
554
  } catch (err) {
436
555
  reAcquireBlockedWhileHeld =
437
556
  (err as NodeJS.ErrnoException).code === "EEXIST";
@@ -443,7 +562,6 @@ describe("default reindex lock", () => {
443
562
  const r = reindexAfterSync(hq, { exec: observingExec });
444
563
  expect(r.updated).toBe(true);
445
564
  expect(reAcquireBlockedWhileHeld).toBe(true);
446
- // Released after the cycle — the lock file is gone.
447
565
  expect(fs.existsSync(lockPath)).toBe(false);
448
566
  } finally {
449
567
  fs.rmSync(dir, { recursive: true, force: true });
@@ -460,7 +578,6 @@ describe("default reindex lock", () => {
460
578
  fs.writeFileSync(path.join(hq, "core", "core.yaml"), "v: 1\n");
461
579
  fs.writeFileSync(path.join(kdir, "note.md"), "# real\n");
462
580
 
463
- // Plant a lock owned by a PID that is certainly not alive.
464
581
  const lockPath = path.join(hq, ".qmd", ".reindex.lock");
465
582
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
466
583
  fs.writeFileSync(
@@ -470,7 +587,6 @@ describe("default reindex lock", () => {
470
587
 
471
588
  const { exec, calls } = fakeExec({ listStdout: "" });
472
589
  const r = reindexAfterSync(hq, { exec });
473
- // The dead holder was reclaimed, so the reindex ran to completion.
474
590
  expect(r.updated).toBe(true);
475
591
  expect(r.lockBusy).toBe(false);
476
592
  expect(calls.some((c) => c[0] === "update")).toBe(true);
@@ -480,6 +596,42 @@ describe("default reindex lock", () => {
480
596
  });
481
597
  });
482
598
 
599
+ describe("acquireDualReindexLocks", () => {
600
+ it("dedupes identical paths so a process cannot lock itself out", () => {
601
+ let acquires = 0;
602
+ const acquire: AcquireReindexLock = () => {
603
+ acquires++;
604
+ return { release() {} };
605
+ };
606
+ const same = path.join("/hq", ".qmd", ".reindex.lock");
607
+ const { locks, busy } = acquireDualReindexLocks(acquire, [same, same]);
608
+ expect(busy).toBe(false);
609
+ expect(locks).toHaveLength(1);
610
+ expect(acquires).toBe(1);
611
+ });
612
+
613
+ it("releases the first lock when the second is busy", () => {
614
+ const released: string[] = [];
615
+ let n = 0;
616
+ const acquire: AcquireReindexLock = (lockPath) => {
617
+ n++;
618
+ if (n === 2) return null;
619
+ return {
620
+ release() {
621
+ released.push(lockPath);
622
+ },
623
+ };
624
+ };
625
+ const { locks, busy } = acquireDualReindexLocks(acquire, [
626
+ "/a/.reindex.lock",
627
+ "/b/.reindex.lock",
628
+ ]);
629
+ expect(busy).toBe(true);
630
+ expect(locks).toHaveLength(0);
631
+ expect(released).toEqual(["/a/.reindex.lock"]);
632
+ });
633
+ });
634
+
483
635
  // ── Pure helpers ─────────────────────────────────────────────────────────────
484
636
 
485
637
  describe("resolveExecTimeoutMs", () => {
@@ -511,3 +663,37 @@ describe("looksCorrupt", () => {
511
663
  expect(looksCorrupt({})).toBe(false);
512
664
  });
513
665
  });
666
+
667
+ describe("parseIndexDirFromStatus / parseCollectionPathFromShow", () => {
668
+ it("parses Index file path into its directory", () => {
669
+ expect(
670
+ parseIndexDirFromStatus("Index: C:/Users/me/.cache/qmd/index.sqlite\nSize: 1.3 GB\n"),
671
+ ).toBe(path.dirname("C:/Users/me/.cache/qmd/index.sqlite"));
672
+ });
673
+
674
+ it("returns null when Index line is missing", () => {
675
+ expect(parseIndexDirFromStatus("Collections: 2\n")).toBeNull();
676
+ });
677
+
678
+ it("parses Path from collection show", () => {
679
+ expect(
680
+ parseCollectionPathFromShow("Collection: indigo\n Path: /hq/companies/indigo/knowledge\n"),
681
+ ).toBe("/hq/companies/indigo/knowledge");
682
+ });
683
+ });
684
+
685
+ describe("pathsEquivalent / repairPathDriftEnabled", () => {
686
+ it("treats case and trailing separators as equal", () => {
687
+ expect(
688
+ pathsEquivalent("/HQ/Companies/Acme/knowledge/", "/hq/companies/acme/knowledge", (p) => p),
689
+ ).toBe(true);
690
+ });
691
+
692
+ it("enables repair only for 1/true/yes", () => {
693
+ expect(repairPathDriftEnabled({})).toBe(false);
694
+ expect(repairPathDriftEnabled({ HQ_QMD_REPAIR_PATH_DRIFT: "1" })).toBe(true);
695
+ expect(repairPathDriftEnabled({ HQ_QMD_REPAIR_PATH_DRIFT: "true" })).toBe(true);
696
+ expect(repairPathDriftEnabled({ HQ_QMD_REPAIR_PATH_DRIFT: "yes" })).toBe(true);
697
+ expect(repairPathDriftEnabled({ HQ_QMD_REPAIR_PATH_DRIFT: "0" })).toBe(false);
698
+ });
699
+ });