@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.
- package/dist/bin/sync-runner-company.d.ts +4 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +3 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +7 -6
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +1 -1
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.js +23 -0
- package/dist/bin/sync-runner-planning.test.js.map +1 -1
- package/dist/bin/sync-runner-rollup.d.ts.map +1 -1
- package/dist/bin/sync-runner-rollup.js +3 -1
- package/dist/bin/sync-runner-rollup.js.map +1 -1
- package/dist/bin/sync-runner-rollup.test.d.ts +2 -0
- package/dist/bin/sync-runner-rollup.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-rollup.test.js +24 -0
- package/dist/bin/sync-runner-rollup.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +15 -1
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +11 -7
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +51 -9
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +97 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +3 -1
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +24 -13
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/rescue-core.js +3 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/share.d.ts +35 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +29 -11
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +46 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +5 -0
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +9 -0
- package/dist/ignore.test.js.map +1 -1
- package/dist/outcome-telemetry.d.ts +167 -0
- package/dist/outcome-telemetry.d.ts.map +1 -0
- package/dist/outcome-telemetry.js +479 -0
- package/dist/outcome-telemetry.js.map +1 -0
- package/dist/outcome-telemetry.test.d.ts +9 -0
- package/dist/outcome-telemetry.test.d.ts.map +1 -0
- package/dist/outcome-telemetry.test.js +412 -0
- package/dist/outcome-telemetry.test.js.map +1 -0
- package/dist/qmd-reindex.d.ts +48 -35
- package/dist/qmd-reindex.d.ts.map +1 -1
- package/dist/qmd-reindex.js +188 -60
- package/dist/qmd-reindex.js.map +1 -1
- package/dist/qmd-reindex.test.d.ts +3 -3
- package/dist/qmd-reindex.test.js +203 -41
- package/dist/qmd-reindex.test.js.map +1 -1
- package/dist/telemetry.d.ts +5 -4
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +187 -13
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +157 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/vault-client.d.ts +34 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +23 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +4 -0
- package/src/bin/sync-runner-planning.test.ts +26 -0
- package/src/bin/sync-runner-planning.ts +8 -7
- package/src/bin/sync-runner-rollup.test.ts +37 -0
- package/src/bin/sync-runner-rollup.ts +3 -1
- package/src/bin/sync-runner-watch-loop.ts +22 -1
- package/src/bin/sync-runner.test.ts +111 -0
- package/src/bin/sync-runner.ts +62 -17
- package/src/cli/doctor.ts +3 -1
- package/src/cli/reindex.ts +24 -12
- package/src/cli/rescue-core.ts +3 -1
- package/src/cli/share.test.ts +60 -0
- package/src/cli/share.ts +29 -11
- package/src/ignore.test.ts +10 -0
- package/src/ignore.ts +5 -0
- package/src/outcome-telemetry.test.ts +498 -0
- package/src/outcome-telemetry.ts +639 -0
- package/src/qmd-reindex.test.ts +226 -40
- package/src/qmd-reindex.ts +209 -61
- package/src/telemetry.test.ts +194 -0
- package/src/telemetry.ts +233 -14
- package/src/vault-client.ts +55 -0
package/src/qmd-reindex.ts
CHANGED
|
@@ -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 (
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
272
|
-
//
|
|
273
|
-
//
|
|
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
|
|
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
|
-
//
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
const
|
|
423
|
-
|
|
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(
|
|
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
|
|
457
|
-
|
|
458
|
-
result.
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
547
|
-
quarantine(qmdDir, quarantineSuffix(nowMs));
|
|
691
|
+
const moved = quarantine(indexDir, quarantineSuffix(nowMs));
|
|
548
692
|
writeState(statePath, { ...state, pendingDirty: true, pendingSinceMs });
|
|
549
|
-
|
|
693
|
+
if (moved != null) {
|
|
694
|
+
result.corruptionQuarantined = true;
|
|
695
|
+
} else {
|
|
696
|
+
result.corruptionQuarantineFailed = true;
|
|
697
|
+
}
|
|
550
698
|
result.pendingDirty = true;
|
|
551
699
|
}
|
|
552
700
|
|
package/src/telemetry.test.ts
CHANGED
|
@@ -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", () => {
|