@indigoai-us/hq-cloud 6.14.18 → 6.14.20

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