@indigoai-us/hq-cloud 5.39.0 → 5.41.0

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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Unit tests for reindexAfterSync.
3
+ *
4
+ * The function shells out to the global `qmd` binary, so every test injects a
5
+ * fake `exec` that records the calls and returns canned results — no real qmd
6
+ * is invoked. We assert on the *sequence of qmd commands* the function would
7
+ * run, which is the contract that keeps a teammate's index fresh after sync.
8
+ */
9
+
10
+ import { describe, it, expect } from "vitest";
11
+ import * as fs from "fs";
12
+ import * as os from "os";
13
+ import * as path from "path";
14
+ import { reindexAfterSync, type QmdExec } from "./qmd-reindex.js";
15
+
16
+ /** Build a fake exec that returns status 0 for everything and logs calls. */
17
+ function fakeExec(opts?: { listStdout?: string; failOn?: string }): {
18
+ exec: QmdExec;
19
+ calls: string[][];
20
+ } {
21
+ const calls: string[][] = [];
22
+ const exec: QmdExec = (args) => {
23
+ calls.push(args);
24
+ if (opts?.failOn && args[0] === opts.failOn) return { status: 1, stdout: "" };
25
+ if (args[0] === "collection" && args[1] === "list") {
26
+ return { status: 0, stdout: opts?.listStdout ?? "" };
27
+ }
28
+ return { status: 0, stdout: "" };
29
+ };
30
+ return { exec, calls };
31
+ }
32
+
33
+ describe("reindexAfterSync", () => {
34
+ it("no-ops when the path is not an HQ root", () => {
35
+ const { exec, calls } = fakeExec();
36
+ const r = reindexAfterSync("/tmp/not-hq", {
37
+ exec,
38
+ existsSync: () => false, // core/core.yaml absent
39
+ });
40
+ expect(r.qmdAvailable).toBe(false);
41
+ expect(calls).toHaveLength(0);
42
+ });
43
+
44
+ it("no-ops when qmd is unavailable (collection list errors)", () => {
45
+ const { exec, calls } = fakeExec({ failOn: "collection" });
46
+ const r = reindexAfterSync("/hq", {
47
+ exec,
48
+ existsSync: () => true, // core.yaml present
49
+ });
50
+ expect(r.qmdAvailable).toBe(false);
51
+ expect(r.updated).toBe(false);
52
+ // Only the availability probe ran, then bailed.
53
+ expect(calls).toEqual([["collection", "list"]]);
54
+ });
55
+
56
+ it("registers a missing company collection then runs an incremental update", () => {
57
+ const { exec, calls } = fakeExec({ listStdout: "Collections (1):\n\nHQ (qmd://HQ/)\n" });
58
+ const r = reindexAfterSync("/hq", {
59
+ exec,
60
+ existsSync: () => true,
61
+ readCompanies: () => ["acme"],
62
+ hasIndexableMarkdown: () => true,
63
+ });
64
+ expect(r.qmdAvailable).toBe(true);
65
+ expect(r.collectionsAdded).toEqual(["acme"]);
66
+ expect(r.updated).toBe(true);
67
+ expect(r.embedded).toBe(false);
68
+
69
+ // Expected command sequence: probe, add, context, update.
70
+ expect(calls).toEqual([
71
+ ["collection", "list"],
72
+ ["collection", "add", path.join("/hq", "companies", "acme", "knowledge"), "--name", "acme", "--mask", "**/*.md"],
73
+ ["context", "add", "qmd://acme", "Knowledge base for acme."],
74
+ ["update"],
75
+ ]);
76
+ });
77
+
78
+ it("skips collections that already exist", () => {
79
+ const { exec, calls } = fakeExec({ listStdout: "acme (qmd://acme/)\n" });
80
+ const r = reindexAfterSync("/hq", {
81
+ exec,
82
+ existsSync: () => true,
83
+ readCompanies: () => ["acme"],
84
+ hasIndexableMarkdown: () => true,
85
+ });
86
+ expect(r.collectionsAdded).toEqual([]);
87
+ // No `collection add` — straight to update.
88
+ expect(calls).toEqual([["collection", "list"], ["update"]]);
89
+ });
90
+
91
+ it("skips company dirs with no indexable markdown", () => {
92
+ const { exec, calls } = fakeExec({ listStdout: "" });
93
+ const r = reindexAfterSync("/hq", {
94
+ exec,
95
+ existsSync: () => true,
96
+ readCompanies: () => ["empty"],
97
+ hasIndexableMarkdown: () => false,
98
+ });
99
+ expect(r.collectionsAdded).toEqual([]);
100
+ expect(calls).toEqual([["collection", "list"], ["update"]]);
101
+ });
102
+
103
+ it("runs embed only when embed:true", () => {
104
+ const { exec, calls } = fakeExec({ listStdout: "" });
105
+ const r = reindexAfterSync("/hq", {
106
+ exec,
107
+ existsSync: () => true,
108
+ readCompanies: () => [],
109
+ embed: true,
110
+ });
111
+ expect(r.embedded).toBe(true);
112
+ expect(calls).toEqual([["collection", "list"], ["update"], ["embed"]]);
113
+ });
114
+
115
+ it("never throws even if exec misbehaves", () => {
116
+ const exec: QmdExec = () => {
117
+ throw new Error("boom");
118
+ };
119
+ expect(() =>
120
+ reindexAfterSync("/hq", { exec, existsSync: () => true }),
121
+ ).not.toThrow();
122
+ });
123
+
124
+ it("hasIndexableMarkdown finds a real .md and ignores INDEX.md", () => {
125
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-test-"));
126
+ try {
127
+ const hq = path.join(dir, "hq");
128
+ const kdir = path.join(hq, "companies", "acme", "knowledge");
129
+ fs.mkdirSync(kdir, { recursive: true });
130
+ fs.mkdirSync(path.join(hq, "core"), { recursive: true });
131
+ fs.writeFileSync(path.join(hq, "core", "core.yaml"), "hqVersion: 1\n");
132
+ fs.writeFileSync(path.join(kdir, "INDEX.md"), "# index\n");
133
+ fs.writeFileSync(path.join(kdir, "note.md"), "# real\n");
134
+
135
+ const { exec, calls } = fakeExec({ listStdout: "" });
136
+ const r = reindexAfterSync(hq, { exec }); // real fs walk for md detection
137
+ expect(r.collectionsAdded).toEqual(["acme"]);
138
+ expect(calls.some((c) => c[0] === "collection" && c[1] === "add")).toBe(true);
139
+ } finally {
140
+ fs.rmSync(dir, { recursive: true, force: true });
141
+ }
142
+ });
143
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Post-sync qmd reindex.
3
+ *
4
+ * Why this lives in the runner (not just an HQ-core script): the qmd search
5
+ * index is a per-machine local store, and nothing re-indexed it after a sync
6
+ * pulled new files in — so teammates saw divergent search results depending
7
+ * on who ran `qmd update` most recently, and newly-synced knowledge folders
8
+ * weren't searchable until someone manually registered them as a collection.
9
+ *
10
+ * The runner ships via `npx @indigoai-us/hq-cloud@latest` (both the AppBar
11
+ * menubar and the `/hq-sync` CLI pull it at runtime), so putting the fix here
12
+ * reaches every teammate on their next sync WITHOUT requiring them to update
13
+ * their HQ core. It is therefore intentionally self-contained: it shells out
14
+ * to the globally-installed `qmd` binary directly and does NOT depend on any
15
+ * script inside the synced HQ tree (which may be stale).
16
+ *
17
+ * What it does, best-effort and idempotent:
18
+ * 1. Auto-registers any `companies/<slug>/knowledge` dir that isn't yet a
19
+ * qmd collection (kills the manual "map" step).
20
+ * 2. Runs an incremental lexical `qmd update` (fast — qmd skips unchanged
21
+ * files by mtime).
22
+ * 3. Rebuilds embeddings only when `embed: true` (slow on a multi-GB
23
+ * index; meant for an idle pass, not every sync).
24
+ *
25
+ * The index itself is never synced — it is large, binary, and embeds absolute
26
+ * local paths. Only its *freshness* is automated here.
27
+ */
28
+
29
+ import * as fs from "fs";
30
+ import * as path from "path";
31
+ import { spawnSync } from "child_process";
32
+
33
+ /** Injectable command runner — real `spawnSync` in prod, a fake in tests. */
34
+ export interface QmdExec {
35
+ (args: string[]): { status: number | null; stdout: string };
36
+ }
37
+
38
+ const defaultExec: QmdExec = (args) => {
39
+ const res = spawnSync("qmd", args, {
40
+ encoding: "utf8",
41
+ // qmd update on a large index can take a while; bound it so a wedged
42
+ // index never hangs the runner forever.
43
+ timeout: 120_000,
44
+ });
45
+ return { status: res.status, stdout: res.stdout ?? "" };
46
+ };
47
+
48
+ export interface ReindexOptions {
49
+ /** Rebuild embeddings too (slow). Default false — lexical-only. */
50
+ embed?: boolean;
51
+ /** Command runner override for tests. */
52
+ exec?: QmdExec;
53
+ /** `existsSync` override for tests. */
54
+ existsSync?: (p: string) => boolean;
55
+ /** `readdirSync` override for tests (returns subdir names of companies/). */
56
+ readCompanies?: (companiesDir: string) => string[];
57
+ /** Returns true if the knowledge dir has at least one indexable .md file. */
58
+ hasIndexableMarkdown?: (knowledgeDir: string) => boolean;
59
+ }
60
+
61
+ /**
62
+ * Reindex qmd for an HQ tree after a sync. Never throws — all failures are
63
+ * swallowed so a reindex problem can never mask or fail the sync result.
64
+ *
65
+ * @returns a small summary for logging/telemetry (collectionsAdded, updated).
66
+ */
67
+ export function reindexAfterSync(
68
+ hqRoot: string,
69
+ opts: ReindexOptions = {},
70
+ ): { qmdAvailable: boolean; collectionsAdded: string[]; updated: boolean; embedded: boolean } {
71
+ const exec = opts.exec ?? defaultExec;
72
+ const existsSync = opts.existsSync ?? fs.existsSync;
73
+ const result = { qmdAvailable: false, collectionsAdded: [] as string[], updated: false, embedded: false };
74
+
75
+ try {
76
+ // Guard: only operate on a real HQ tree.
77
+ if (!existsSync(path.join(hqRoot, "core", "core.yaml"))) return result;
78
+
79
+ // Guard: qmd must be installed. `qmd collection list` doubles as the
80
+ // availability probe AND the source for which collections already exist.
81
+ const list = exec(["collection", "list"]);
82
+ if (list.status !== 0) return result; // qmd absent or errored — no-op
83
+ result.qmdAvailable = true;
84
+ const existingCollections = list.stdout;
85
+
86
+ // 1. Auto-register missing company knowledge collections.
87
+ const companiesDir = path.join(hqRoot, "companies");
88
+ const slugs = (opts.readCompanies ?? defaultReadCompanies)(companiesDir);
89
+ for (const slug of slugs) {
90
+ const knowledgeDir = path.join(companiesDir, slug, "knowledge");
91
+ if (!existsSync(knowledgeDir)) continue;
92
+ const hasMd = (opts.hasIndexableMarkdown ?? defaultHasIndexableMarkdown)(knowledgeDir);
93
+ if (!hasMd) continue;
94
+ // Already registered? qmd collection URIs look like `qmd://<slug>/`.
95
+ if (existingCollections.includes(`qmd://${slug}/`)) continue;
96
+
97
+ const add = exec(["collection", "add", knowledgeDir, "--name", slug, "--mask", "**/*.md"]);
98
+ if (add.status === 0) {
99
+ exec(["context", "add", `qmd://${slug}`, `Knowledge base for ${slug}.`]);
100
+ result.collectionsAdded.push(slug);
101
+ }
102
+ }
103
+
104
+ // 2. Incremental lexical reindex.
105
+ const update = exec(["update"]);
106
+ result.updated = update.status === 0;
107
+
108
+ // 3. Embeddings only on explicit request.
109
+ if (opts.embed) {
110
+ const embed = exec(["embed"]);
111
+ result.embedded = embed.status === 0;
112
+ }
113
+ } catch {
114
+ // Reindex is best-effort; the sync result is authoritative. Swallow.
115
+ }
116
+
117
+ return result;
118
+ }
119
+
120
+ function defaultReadCompanies(companiesDir: string): string[] {
121
+ try {
122
+ return fs
123
+ .readdirSync(companiesDir, { withFileTypes: true })
124
+ .filter((e) => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith("."))
125
+ .map((e) => e.name);
126
+ } catch {
127
+ return [];
128
+ }
129
+ }
130
+
131
+ function defaultHasIndexableMarkdown(knowledgeDir: string): boolean {
132
+ // Shallow check is enough to decide "is this collection worth registering":
133
+ // walk a bounded depth looking for any .md that isn't INDEX.md.
134
+ const stack: Array<{ dir: string; depth: number }> = [{ dir: knowledgeDir, depth: 0 }];
135
+ while (stack.length) {
136
+ const { dir, depth } = stack.pop()!;
137
+ let entries: fs.Dirent[];
138
+ try {
139
+ entries = fs.readdirSync(dir, { withFileTypes: true });
140
+ } catch {
141
+ continue;
142
+ }
143
+ for (const e of entries) {
144
+ if (e.isFile() && e.name.endsWith(".md") && e.name !== "INDEX.md") return true;
145
+ if (e.isDirectory() && depth < 4 && !e.name.startsWith(".")) {
146
+ stack.push({ dir: path.join(dir, e.name), depth: depth + 1 });
147
+ }
148
+ }
149
+ }
150
+ return false;
151
+ }