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