@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
@@ -16,48 +16,29 @@
16
16
  *
17
17
  * What it does, best-effort and idempotent:
18
18
  * 1. Auto-registers any `companies/<slug>/knowledge` dir that isn't yet a
19
- * qmd collection (kills the manual "map" step).
19
+ * qmd collection (kills the manual "map" step). Detects path drift when
20
+ * the name exists but points elsewhere; repairs only when
21
+ * `HQ_QMD_REPAIR_PATH_DRIFT=1` (non-breaking default: detect-only).
20
22
  * 2. Runs an incremental lexical `qmd update` (fast — qmd skips unchanged
21
23
  * files by mtime).
22
24
  * 3. Rebuilds embeddings only when `embed: true` (slow on a multi-GB
23
- * index; meant for an idle pass, not every sync).
25
+ * index; meant for an idle pass, not every sync). Never embeds in the
26
+ * same cycle as a path-drift repair (repair drops that collection's
27
+ * vectors).
24
28
  *
25
29
  * The index itself is never synced — it is large, binary, and embeds absolute
26
30
  * local paths. Only its *freshness* is automated here.
27
31
  *
28
- * ## Corruption safety (feedback_b9a369ff)
32
+ * ## Corruption safety (feedback_b9a369ff + feedback_332c7ccc)
29
33
  *
30
- * The qmd store (`.qmd/index.sqlite`, backed by a sqlite-vec virtual table) was
31
- * being corrupted on the vector side (`content_vectors` / `vectors_vec_rowids`)
32
- * because TWO uncoordinated writers raced on it: this post-sync reindex and an
33
- * interactive session's own qmd maintenance (e.g. a skill's `qmd embed`). Two
34
- * defects compounded:
35
- * 1. A hard 120s `spawnSync` timeout that KILLED a long `qmd update`/`embed`
36
- * mid-write, tearing down the process between vector-table writes.
37
- * 2. No cross-process serialization, so an interactive `qmd embed` and a
38
- * runner-spawned `qmd update` overlapped with near certainty.
39
- *
40
- * The mitigations here, all inside the runner so every teammate gets them on
41
- * their next sync:
42
- * - The exec timeout is raised substantially so a legitimate long pass is no
43
- * longer killed mid-write in the first place. A timed-out pass is treated as
44
- * "not done" (left dirty, retried next cycle) and NEVER advances to embed.
45
- * That not-done handling — together with the writer serialization below — is
46
- * the real protection; the signal used for the (now rare) kill is secondary.
47
- * When the bound does fire the child is sent SIGINT rather than the default
48
- * SIGTERM, but qmd 2.5.3 traps both identically (each just restores the
49
- * cursor and exits), so this is only the same clean exit path qmd takes on
50
- * Ctrl-C — not a guarantee the in-flight SQLite write unwinds.
51
- * - A shared advisory lock at `<hqRoot>/.qmd/.reindex.lock` serializes qmd
52
- * writers. The runner takes it before touching qmd and SKIPS the cycle (and
53
- * marks the tree dirty so the next sync retries) when another writer holds
54
- * it. The lock path sits next to the DB so interactive HQ qmd maintenance
55
- * can honor the same rendezvous.
56
- * - Corruption is detected cheaply from qmd's own output (the SQLITE_CORRUPT
57
- * signature). On detection the corrupt DB files are quarantined aside (moved
58
- * to `index.sqlite.corrupt-<ts>`, never deleted) and the cycle stops writing
59
- * so a clean rebuild happens on the next pass — instead of writing further
60
- * onto an already-damaged store.
34
+ * The qmd store (sqlite-vec) was being corrupted on the vector side because
35
+ * TWO uncoordinated writers raced on it. Mitigations:
36
+ * - Raised exec timeout so legitimate long passes are not killed mid-write.
37
+ * - Dual advisory lock: legacy `<hqRoot>/.qmd/.reindex.lock` AND
38
+ * `<resolvedIndexDir>/.reindex.lock` (deduped when equal). Default index
39
+ * is often `~/.cache/qmd/index.sqlite`, not `<hqRoot>/.qmd`.
40
+ * - Corruption quarantine targets the resolved index dir; reports success
41
+ * only when files were actually moved.
61
42
  */
62
43
 
63
44
  import * as fs from "fs";
@@ -100,6 +81,53 @@ export function resolveExecTimeoutMs(env: NodeJS.ProcessEnv = process.env): numb
100
81
  return DEFAULT_QMD_EXEC_TIMEOUT_MS;
101
82
  }
102
83
 
84
+ /** Opt-in destructive path-drift repair (remove + re-add). Default off. */
85
+ export function repairPathDriftEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
86
+ const raw = (env.HQ_QMD_REPAIR_PATH_DRIFT ?? "").trim().toLowerCase();
87
+ return raw === "1" || raw === "true" || raw === "yes";
88
+ }
89
+
90
+ /**
91
+ * Parse the directory that holds `index.sqlite` from `qmd status` output.
92
+ * Returns null when the Index line is missing/unparseable.
93
+ */
94
+ export function parseIndexDirFromStatus(stdout: string): string | null {
95
+ const m = stdout.match(/^\s*Index:\s*(.+?)\s*$/m);
96
+ if (!m) return null;
97
+ const indexFile = m[1].trim();
98
+ if (!indexFile) return null;
99
+ return path.dirname(indexFile);
100
+ }
101
+
102
+ /** Parse `Path:` from `qmd collection show` human output. */
103
+ export function parseCollectionPathFromShow(stdout: string): string | null {
104
+ const m = stdout.match(/^\s*Path:\s*(.+?)\s*$/m);
105
+ if (!m) return null;
106
+ const p = m[1].trim();
107
+ return p || null;
108
+ }
109
+
110
+ /** Case-insensitive, trailing-separator-tolerant path equality after realpath. */
111
+ export function pathsEquivalent(
112
+ a: string,
113
+ b: string,
114
+ realpathSync: (p: string) => string = safeRealpathSync,
115
+ ): boolean {
116
+ return normalizePathKey(realpathSync(a)) === normalizePathKey(realpathSync(b));
117
+ }
118
+
119
+ function normalizePathKey(p: string): string {
120
+ return path.resolve(p).replace(/[/\\]+$/, "").toLowerCase();
121
+ }
122
+
123
+ function safeRealpathSync(p: string): string {
124
+ try {
125
+ return fs.realpathSync(p);
126
+ } catch {
127
+ return path.resolve(p);
128
+ }
129
+ }
130
+
103
131
  const defaultExec: QmdExec = (args) => {
104
132
  const res = spawnSync("qmd", args, {
105
133
  encoding: "utf8",
@@ -268,9 +296,9 @@ const defaultAcquireReindexLock: AcquireReindexLock = (lockPath) => {
268
296
  };
269
297
  } catch (err) {
270
298
  if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") {
271
- // The `.qmd` dir isn't writable (unusual — it holds the DB). Fail OPEN
272
- // rather than block the reindex on an infra problem: proceed without the
273
- // advisory lock. The corruption guard below is the remaining backstop.
299
+ // The lock dir isn't writable. Fail OPEN rather than block the reindex
300
+ // on an infra problem: proceed without the advisory lock. The corruption
301
+ // guard below is the remaining backstop.
274
302
  return NOOP_LOCK;
275
303
  }
276
304
  // Held. Reclaim iff the holder is dead/stale, then retry once.
@@ -281,6 +309,35 @@ const defaultAcquireReindexLock: AcquireReindexLock = (lockPath) => {
281
309
  return null;
282
310
  };
283
311
 
312
+ /**
313
+ * Acquire unique lock paths (legacy + resolved index). If any is busy, release
314
+ * what was acquired and return busy. Dedupes when both paths are the same so a
315
+ * process never O_EXCL-locks itself out.
316
+ */
317
+ export function acquireDualReindexLocks(
318
+ acquireLock: AcquireReindexLock,
319
+ lockPaths: string[],
320
+ ): { locks: ReindexLockHandle[]; busy: boolean } {
321
+ const unique: string[] = [];
322
+ const seen = new Set<string>();
323
+ for (const p of lockPaths) {
324
+ const key = normalizePathKey(p);
325
+ if (seen.has(key)) continue;
326
+ seen.add(key);
327
+ unique.push(p);
328
+ }
329
+ const locks: ReindexLockHandle[] = [];
330
+ for (const p of unique) {
331
+ const handle = acquireLock(p);
332
+ if (!handle) {
333
+ for (const acquired of locks) acquired.release();
334
+ return { locks: [], busy: true };
335
+ }
336
+ locks.push(handle);
337
+ }
338
+ return { locks, busy: false };
339
+ }
340
+
284
341
  /** Move corrupt DB files aside (never delete) so a clean rebuild can follow. */
285
342
  export type QuarantineCorruptIndex = (
286
343
  qmdDir: string,
@@ -298,6 +355,7 @@ const defaultQuarantineCorruptIndex: QuarantineCorruptIndex = (qmdDir, tsSuffix)
298
355
  const dest = `${src}.corrupt-${tsSuffix}`;
299
356
  fs.renameSync(src, dest);
300
357
  if (name === "index.sqlite") movedBase = dest;
358
+ else if (movedBase === null) movedBase = dest;
301
359
  } catch {
302
360
  // Best-effort: move what we can. A file we can't move is left in place;
303
361
  // the corruption guard still prevents further writes this cycle.
@@ -341,6 +399,10 @@ export interface ReindexOptions {
341
399
  acquireReindexLock?: AcquireReindexLock;
342
400
  /** Corrupt-index quarantine override for tests. */
343
401
  quarantineCorruptIndex?: QuarantineCorruptIndex;
402
+ /** realpath override for path-drift comparison in tests. */
403
+ realpathSync?: (p: string) => string;
404
+ /** Env override for tests (repair flag, etc.). */
405
+ env?: NodeJS.ProcessEnv;
344
406
  /** Optional diagnostic sink for unexpected swallowed failures. */
345
407
  log?: (diagnostic: {
346
408
  event: string;
@@ -353,6 +415,10 @@ export interface ReindexOptions {
353
415
  export interface ReindexResult {
354
416
  qmdAvailable: boolean;
355
417
  collectionsAdded: string[];
418
+ /** HQ-convention collections whose registered path differs from desired. */
419
+ pathDriftDetected: string[];
420
+ /** Collections successfully remove+re-added when repair was enabled. */
421
+ collectionsRepaired: string[];
356
422
  updated: boolean;
357
423
  embedded: boolean;
358
424
  pendingDirty: boolean;
@@ -360,8 +426,12 @@ export interface ReindexResult {
360
426
  lockBusy: boolean;
361
427
  /** True when a qmd command exceeded the exec timeout and was aborted. */
362
428
  timedOut: boolean;
363
- /** True when a corrupt index was detected and quarantined this cycle. */
429
+ /** True when a corrupt index was detected and files were quarantined. */
364
430
  corruptionQuarantined: boolean;
431
+ /** True when corruption was detected but quarantine moved nothing. */
432
+ corruptionQuarantineFailed: boolean;
433
+ /** Directory used for quarantine / index-side lock (resolved or fallback). */
434
+ indexDir: string | null;
365
435
  }
366
436
 
367
437
  /**
@@ -378,15 +448,21 @@ export function reindexAfterSync(
378
448
  const existsSync = opts.existsSync ?? fs.existsSync;
379
449
  const acquireLock = opts.acquireReindexLock ?? defaultAcquireReindexLock;
380
450
  const quarantine = opts.quarantineCorruptIndex ?? defaultQuarantineCorruptIndex;
451
+ const realpathSync = opts.realpathSync ?? safeRealpathSync;
452
+ const env = opts.env ?? process.env;
381
453
  const result: ReindexResult = {
382
454
  qmdAvailable: false,
383
455
  collectionsAdded: [],
456
+ pathDriftDetected: [],
457
+ collectionsRepaired: [],
384
458
  updated: false,
385
459
  embedded: false,
386
460
  pendingDirty: false,
387
461
  lockBusy: false,
388
462
  timedOut: false,
389
463
  corruptionQuarantined: false,
464
+ corruptionQuarantineFailed: false,
465
+ indexDir: null,
390
466
  };
391
467
 
392
468
  try {
@@ -414,13 +490,20 @@ export function reindexAfterSync(
414
490
  ? state.pendingSinceMs
415
491
  : nowMs;
416
492
 
417
- // Serialize qmd writers. If another writer (this runner from a prior cycle,
418
- // or an interactive session's qmd maintenance) holds the lock, SKIP this
419
- // cycle and mark the tree dirty so the next sync retries — never write onto
420
- // the vector store concurrently (feedback_b9a369ff).
421
- const lockPath = path.join(hqRoot, ".qmd", ".reindex.lock");
422
- const lock = acquireLock(lockPath);
423
- if (!lock) {
493
+ // Resolve the real index directory before locking. Default qmd layout is
494
+ // ~/.cache/qmd, not <hqRoot>/.qmd — status is read-only.
495
+ const legacyQmdDir = path.join(hqRoot, ".qmd");
496
+ const status = exec(["status"]);
497
+ const parsedIndexDir = parseIndexDirFromStatus(status.stdout ?? "");
498
+ const indexDir = parsedIndexDir ?? legacyQmdDir;
499
+ result.indexDir = indexDir;
500
+
501
+ // Serialize qmd writers across legacy + resolved layouts (feedback_332c7ccc).
502
+ const { locks, busy } = acquireDualReindexLocks(acquireLock, [
503
+ path.join(legacyQmdDir, ".reindex.lock"),
504
+ path.join(indexDir, ".reindex.lock"),
505
+ ]);
506
+ if (busy) {
424
507
  writeState(statePath, { ...state, pendingDirty: true, pendingSinceMs });
425
508
  result.lockBusy = true;
426
509
  result.pendingDirty = true;
@@ -428,35 +511,95 @@ export function reindexAfterSync(
428
511
  }
429
512
 
430
513
  try {
514
+ if (looksCorrupt(status)) {
515
+ handleCorruption(indexDir, statePath, state, pendingSinceMs, nowMs, quarantine, result);
516
+ return result;
517
+ }
518
+
431
519
  // Guard: qmd must be installed. `qmd collection list` doubles as the
432
520
  // availability probe AND the source for which collections already exist.
433
521
  const list = exec(["collection", "list"]);
434
522
  if (list.status !== 0) return result; // qmd absent or errored — no-op
435
523
  // An already-corrupt store can surface here even on the lexical side.
436
524
  if (looksCorrupt(list)) {
437
- handleCorruption(hqRoot, statePath, state, pendingSinceMs, nowMs, quarantine, result);
525
+ handleCorruption(indexDir, statePath, state, pendingSinceMs, nowMs, quarantine, result);
438
526
  return result;
439
527
  }
440
528
  result.qmdAvailable = true;
441
529
  const existingCollections = list.stdout;
530
+ let repairedThisCycle = false;
442
531
 
443
- // 1. Auto-register missing company knowledge collections.
532
+ // 1. Auto-register missing / reconcile drifted company knowledge collections.
444
533
  if (registrationMayBeStale) {
445
534
  const companiesDir = path.join(hqRoot, "companies");
446
535
  const slugs = (opts.readCompanies ?? defaultReadCompanies)(companiesDir);
536
+ const allowRepair = repairPathDriftEnabled(env);
447
537
  for (const slug of slugs) {
448
538
  const knowledgeDir = path.join(companiesDir, slug, "knowledge");
449
539
  if (!existsSync(knowledgeDir)) continue;
450
540
  const hasMd = (opts.hasIndexableMarkdown ?? defaultHasIndexableMarkdown)(knowledgeDir);
451
541
  if (!hasMd) continue;
452
- // Already registered? qmd collection URIs look like `qmd://<slug>/`.
453
- if (existingCollections.includes(`qmd://${slug}/`)) continue;
454
542
 
543
+ const namePresent = existingCollections.includes(`qmd://${slug}/`);
544
+ if (!namePresent) {
545
+ const add = exec(["collection", "add", knowledgeDir, "--name", slug, "--mask", "**/*.md"]);
546
+ if (add.status === 0) {
547
+ exec(["context", "add", `qmd://${slug}`, `Knowledge base for ${slug}.`]);
548
+ result.collectionsAdded.push(slug);
549
+ }
550
+ continue;
551
+ }
552
+
553
+ // Name exists — check path (feedback_f8e233c6). Paths only on `show`.
554
+ const show = exec(["collection", "show", slug]);
555
+ if (show.status !== 0) continue;
556
+ const registeredPath = parseCollectionPathFromShow(show.stdout ?? "");
557
+ if (!registeredPath) continue;
558
+ if (pathsEquivalent(registeredPath, knowledgeDir, realpathSync)) continue;
559
+
560
+ result.pathDriftDetected.push(slug);
561
+ opts.log?.({
562
+ event: "runner.qmd_reindex.path_drift",
563
+ message: `qmd collection ${slug} path drifted`,
564
+ err: null,
565
+ context: {
566
+ slug,
567
+ registeredPath,
568
+ desiredPath: knowledgeDir,
569
+ repairEnabled: allowRepair,
570
+ },
571
+ });
572
+
573
+ if (!allowRepair) continue;
574
+
575
+ // Guaranteed hasMd + exists above. Remove + re-add; never embed inline.
576
+ const removed = exec(["collection", "remove", slug]);
577
+ if (removed.status !== 0) {
578
+ writeState(statePath, { ...state, pendingDirty: true, pendingSinceMs });
579
+ result.pendingDirty = true;
580
+ opts.log?.({
581
+ event: "runner.qmd_reindex.path_drift_repair_failed",
582
+ message: `failed to remove drifted collection ${slug}`,
583
+ err: removed.stderr ?? removed.stdout,
584
+ context: { slug },
585
+ });
586
+ continue;
587
+ }
455
588
  const add = exec(["collection", "add", knowledgeDir, "--name", slug, "--mask", "**/*.md"]);
456
- if (add.status === 0) {
457
- exec(["context", "add", `qmd://${slug}`, `Knowledge base for ${slug}.`]);
458
- result.collectionsAdded.push(slug);
589
+ if (add.status !== 0) {
590
+ writeState(statePath, { ...state, pendingDirty: true, pendingSinceMs });
591
+ result.pendingDirty = true;
592
+ opts.log?.({
593
+ event: "runner.qmd_reindex.path_drift_repair_failed",
594
+ message: `removed ${slug} but failed to re-add at desired path`,
595
+ err: add.stderr ?? add.stdout,
596
+ context: { slug, knowledgeDir },
597
+ });
598
+ continue;
459
599
  }
600
+ exec(["context", "add", `qmd://${slug}`, `Knowledge base for ${slug}.`]);
601
+ result.collectionsRepaired.push(slug);
602
+ repairedThisCycle = true;
460
603
  }
461
604
  }
462
605
 
@@ -474,7 +617,7 @@ export function reindexAfterSync(
474
617
 
475
618
  const update = exec(["update"]);
476
619
  if (looksCorrupt(update)) {
477
- handleCorruption(hqRoot, statePath, state, pendingSinceMs, nowMs, quarantine, result);
620
+ handleCorruption(indexDir, statePath, state, pendingSinceMs, nowMs, quarantine, result);
478
621
  return result;
479
622
  }
480
623
  if (update.timedOut) {
@@ -494,11 +637,12 @@ export function reindexAfterSync(
494
637
  }
495
638
  }
496
639
 
497
- // 3. Embeddings only on explicit request.
498
- if (opts.embed) {
640
+ // 3. Embeddings only on explicit request — never after a path-drift repair
641
+ // in the same cycle (repair destroys that collection's vectors).
642
+ if (opts.embed && !repairedThisCycle) {
499
643
  const embed = exec(["embed"]);
500
644
  if (looksCorrupt(embed)) {
501
- handleCorruption(hqRoot, statePath, state, pendingSinceMs, nowMs, quarantine, result);
645
+ handleCorruption(indexDir, statePath, state, pendingSinceMs, nowMs, quarantine, result);
502
646
  return result;
503
647
  }
504
648
  if (embed.timedOut) {
@@ -510,7 +654,7 @@ export function reindexAfterSync(
510
654
  result.embedded = embed.status === 0;
511
655
  }
512
656
  } finally {
513
- lock.release();
657
+ for (const lock of locks) lock.release();
514
658
  }
515
659
  } catch (err) {
516
660
  try {
@@ -533,9 +677,10 @@ export function reindexAfterSync(
533
677
  * Quarantine the corrupt DB aside and mark the tree dirty so the NEXT cycle
534
678
  * rebuilds from scratch under the lock, instead of writing further onto an
535
679
  * already-damaged vector store. Mutates `result` in place.
680
+ * `corruptionQuarantined` is true only when files were actually moved.
536
681
  */
537
682
  function handleCorruption(
538
- hqRoot: string,
683
+ indexDir: string,
539
684
  statePath: string,
540
685
  state: QmdReindexState,
541
686
  pendingSinceMs: number,
@@ -543,10 +688,13 @@ function handleCorruption(
543
688
  quarantine: QuarantineCorruptIndex,
544
689
  result: ReindexResult,
545
690
  ): void {
546
- const qmdDir = path.join(hqRoot, ".qmd");
547
- quarantine(qmdDir, quarantineSuffix(nowMs));
691
+ const moved = quarantine(indexDir, quarantineSuffix(nowMs));
548
692
  writeState(statePath, { ...state, pendingDirty: true, pendingSinceMs });
549
- result.corruptionQuarantined = true;
693
+ if (moved != null) {
694
+ result.corruptionQuarantined = true;
695
+ } else {
696
+ result.corruptionQuarantineFailed = true;
697
+ }
550
698
  result.pendingDirty = true;
551
699
  }
552
700
 
@@ -81,6 +81,7 @@ function makeClient(opts: {
81
81
  interface TestEnv {
82
82
  root: string;
83
83
  claudeProjects: string;
84
+ codexRoot: string;
84
85
  cursorPath: string;
85
86
  menubarPath: string;
86
87
  }
@@ -88,11 +89,15 @@ interface TestEnv {
88
89
  function setupEnv(): TestEnv {
89
90
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-telemetry-test-"));
90
91
  const claudeProjects = path.join(root, ".claude", "projects");
92
+ const codexRoot = path.join(root, ".codex");
91
93
  fs.mkdirSync(claudeProjects, { recursive: true });
94
+ fs.mkdirSync(path.join(codexRoot, "sessions"), { recursive: true });
95
+ fs.mkdirSync(path.join(codexRoot, "archived_sessions"), { recursive: true });
92
96
  fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
93
97
  return {
94
98
  root,
95
99
  claudeProjects,
100
+ codexRoot,
96
101
  cursorPath: path.join(root, ".hq", "telemetry-cursor.json"),
97
102
  menubarPath: path.join(root, ".hq", "menubar.json"),
98
103
  };
@@ -120,6 +125,7 @@ function makeOpts(env: TestEnv, client: TelemetryClientSurface): CollectTelemetr
120
125
  machineId: "test-machine",
121
126
  installerVersion: "test-version",
122
127
  claudeProjectsRoot: env.claudeProjects,
128
+ codexRoot: env.codexRoot,
123
129
  cursorPath: env.cursorPath,
124
130
  menubarPath: env.menubarPath,
125
131
  };
@@ -634,6 +640,194 @@ describe("collectAndSendTelemetry", () => {
634
640
  });
635
641
  });
636
642
 
643
+ // ── Codex rollout collection ────────────────────────────────────────────────
644
+
645
+ function codexLine(value: Record<string, unknown>): string {
646
+ return JSON.stringify(value);
647
+ }
648
+
649
+ function writeCodexRollout(
650
+ env: TestEnv,
651
+ location: "sessions" | "archived_sessions",
652
+ name: string,
653
+ lines: Array<Record<string, unknown>>,
654
+ ): string {
655
+ const dir =
656
+ location === "sessions"
657
+ ? path.join(env.codexRoot, location, "2026", "07", "30")
658
+ : path.join(env.codexRoot, location);
659
+ fs.mkdirSync(dir, { recursive: true });
660
+ const file = path.join(dir, name);
661
+ fs.writeFileSync(file, lines.map(codexLine).join("\n") + "\n", "utf-8");
662
+ return file;
663
+ }
664
+
665
+ function codexToken(
666
+ timestamp: string,
667
+ uuid: string,
668
+ input: number,
669
+ output: number,
670
+ reasoning = 0,
671
+ cached = 0,
672
+ ): Record<string, unknown> {
673
+ return {
674
+ timestamp,
675
+ uuid,
676
+ type: "event_msg",
677
+ payload: {
678
+ type: "token_count",
679
+ info: {
680
+ last_token_usage: {
681
+ input_tokens: input,
682
+ output_tokens: output,
683
+ reasoning_output_tokens: reasoning,
684
+ cached_input_tokens: cached,
685
+ },
686
+ total_token_usage: {
687
+ input_tokens: 999_999,
688
+ output_tokens: 999_999,
689
+ },
690
+ },
691
+ raw_content: "must never leave the machine",
692
+ },
693
+ };
694
+ }
695
+
696
+ describe("collectAndSendTelemetry — Codex rollouts", () => {
697
+ let env: TestEnv;
698
+ beforeEach(() => {
699
+ env = setupEnv();
700
+ });
701
+ afterEach(() => teardownEnv(env));
702
+
703
+ it("collects token deltas from live and archived Linux rollouts", async () => {
704
+ writeCodexRollout(env, "sessions", "rollout-live.jsonl", [
705
+ {
706
+ timestamp: "2026-07-30T10:00:00Z",
707
+ type: "session_meta",
708
+ payload: {
709
+ id: "codex-live",
710
+ cwd: "/home/yousuf/hq/repos/private/hq-cloud",
711
+ git: { branch: "feature/live" },
712
+ model: "session-model",
713
+ instructions: "private prompt",
714
+ },
715
+ },
716
+ {
717
+ timestamp: "2026-07-30T10:00:01Z",
718
+ type: "turn_context",
719
+ payload: { model: "codex-auto-review" },
720
+ },
721
+ codexToken("2026-07-30T10:00:02Z", "codex-event-live", 11, 7, 5, 3),
722
+ ]);
723
+ writeCodexRollout(env, "archived_sessions", "rollout-archived.jsonl", [
724
+ {
725
+ timestamp: "2026-07-29T10:00:00Z",
726
+ type: "session_meta",
727
+ payload: { id: "codex-archived", cwd: "/repo", model: "gpt-5-codex" },
728
+ },
729
+ codexToken("2026-07-29T10:00:01Z", "codex-event-archived", 2, 3, 1, 1),
730
+ ]);
731
+
732
+ const client = makeClient();
733
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
734
+ const events = client.posts.flatMap((post) => post.events);
735
+
736
+ expect(result.filesScanned).toBe(2);
737
+ expect(result.eventsSent).toBe(2);
738
+ expect(events).toEqual(
739
+ expect.arrayContaining([
740
+ {
741
+ sessionId: "codex-live",
742
+ timestamp: "2026-07-30T10:00:02Z",
743
+ uuid: "codex-event-live",
744
+ cwd: "/home/yousuf/hq/repos/private/hq-cloud",
745
+ gitBranch: "feature/live",
746
+ model: "codex-auto-review",
747
+ inputTokens: 11,
748
+ outputTokens: 12,
749
+ cacheReadInputTokens: 3,
750
+ },
751
+ expect.objectContaining({
752
+ sessionId: "codex-archived",
753
+ model: "gpt-5-codex",
754
+ inputTokens: 2,
755
+ outputTokens: 4,
756
+ cacheReadInputTokens: 1,
757
+ }),
758
+ ]),
759
+ );
760
+ expect(JSON.stringify(events)).not.toContain("private prompt");
761
+ expect(JSON.stringify(events)).not.toContain("raw_content");
762
+ expect(JSON.stringify(events)).not.toContain("999999");
763
+
764
+ await collectAndSendTelemetry(makeOpts(env, client));
765
+ expect(client.posts.flatMap((post) => post.events)).toHaveLength(2);
766
+ });
767
+
768
+ it("persists model context for incremental rollouts and generates stable IDs", async () => {
769
+ const file = writeCodexRollout(env, "sessions", "rollout-incremental.jsonl", [
770
+ {
771
+ type: "session_meta",
772
+ payload: { id: "incremental", cwd: "/repo", model: "session-model" },
773
+ },
774
+ { type: "turn_context", payload: { model: "model-a" } },
775
+ codexToken("2026-07-30T13:00:00Z", "first-event", 1, 2),
776
+ ]);
777
+ fs.writeFileSync(
778
+ env.cursorPath,
779
+ JSON.stringify({ version: "1", files: {}, codex_next_rollout: file }),
780
+ );
781
+ const client = makeClient();
782
+
783
+ await collectAndSendTelemetry(makeOpts(env, client));
784
+ fs.appendFileSync(
785
+ file,
786
+ [
787
+ codexLine({ type: "turn_context", payload: { model: "model-b" } }),
788
+ codexLine({
789
+ ...codexToken("2026-07-30T13:01:00Z", "temporary", 3, 4),
790
+ uuid: undefined,
791
+ }),
792
+ ].join("\n") + "\n",
793
+ );
794
+ await collectAndSendTelemetry(makeOpts(env, client));
795
+
796
+ const events = client.posts.flatMap((post) => post.events);
797
+ expect(events).toHaveLength(2);
798
+ expect(events[1]).toMatchObject({
799
+ sessionId: "incremental",
800
+ cwd: "/repo",
801
+ model: "model-b",
802
+ inputTokens: 3,
803
+ outputTokens: 4,
804
+ });
805
+ expect(events[1].uuid).toMatch(/^codex-[a-f0-9]{64}$/);
806
+ const cursor = JSON.parse(fs.readFileSync(env.cursorPath, "utf-8"));
807
+ expect(cursor.files[file].context).toMatchObject({
808
+ session_id: "incremental",
809
+ cwd: "/repo",
810
+ session_model: "session-model",
811
+ turn_model: "model-b",
812
+ });
813
+ expect(cursor.codex_next_rollout).toBe(file);
814
+ });
815
+
816
+ it("does not advance a Codex rollout cursor when upload fails", async () => {
817
+ const file = writeCodexRollout(env, "sessions", "rollout-retry.jsonl", [
818
+ { type: "session_meta", payload: { id: "retry", model: "gpt-5-codex" } },
819
+ codexToken("2026-07-30T12:00:00Z", "retry-event", 1, 2),
820
+ ]);
821
+ const client = makeClient({ postResponse: new Error("server 500") });
822
+
823
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
824
+
825
+ expect(result.eventsSent).toBe(0);
826
+ const cursor = readCursor(env);
827
+ expect(cursor.files[file]?.offset ?? 0).toBe(0);
828
+ });
829
+ });
830
+
637
831
  // ── companyUid edge attribution (US-002) ────────────────────────────────────────
638
832
 
639
833
  describe("collectAndSendTelemetry — companyUid attribution", () => {