@indigoai-us/hq-cloud 6.13.0 → 6.13.2
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/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +30 -0
- package/dist/bin/sync-runner.d.ts +2 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +35 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +14 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +49 -5
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +55 -0
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +10 -0
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +29 -6
- package/dist/ignore.test.js.map +1 -1
- package/dist/journal.d.ts +7 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +32 -1
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +34 -1
- package/dist/journal.test.js.map +1 -1
- package/dist/operation-lock.d.ts +31 -0
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +75 -2
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +81 -1
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +7 -3
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +12 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +20 -0
- package/src/bin/sync-runner.ts +57 -14
- package/src/cli/reindex.test.ts +60 -0
- package/src/cli/reindex.ts +58 -8
- package/src/ignore.test.ts +32 -6
- package/src/ignore.ts +11 -0
- package/src/journal.test.ts +44 -0
- package/src/journal.ts +36 -1
- package/src/operation-lock.test.ts +93 -0
- package/src/operation-lock.ts +78 -2
- package/src/skill-telemetry.test.ts +24 -0
- package/src/skill-telemetry.ts +6 -3
package/src/bin/sync-runner.ts
CHANGED
|
@@ -171,6 +171,37 @@ const DEFAULT_VAULT_API_URL =
|
|
|
171
171
|
process.env.HQ_VAULT_API_URL ?? "https://hqapi.getindigo.ai";
|
|
172
172
|
|
|
173
173
|
const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
|
|
174
|
+
const DEFAULT_TELEMETRY_TIMEOUT_MS = 120_000;
|
|
175
|
+
|
|
176
|
+
function resolveTelemetryTimeoutMs(override?: number): number {
|
|
177
|
+
if (override !== undefined && Number.isFinite(override) && override >= 0) {
|
|
178
|
+
return Math.trunc(override);
|
|
179
|
+
}
|
|
180
|
+
const raw = process.env.HQ_TELEMETRY_TIMEOUT_MS;
|
|
181
|
+
if (raw) {
|
|
182
|
+
const parsed = Number.parseInt(raw, 10);
|
|
183
|
+
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
|
184
|
+
}
|
|
185
|
+
return DEFAULT_TELEMETRY_TIMEOUT_MS;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function awaitTelemetryWithTimeout(
|
|
189
|
+
telemetry: Promise<void>,
|
|
190
|
+
timeoutMs: number,
|
|
191
|
+
onTimeout: () => void,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
194
|
+
const timeoutPromise = new Promise<"timeout">((resolve) => {
|
|
195
|
+
timeout = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
196
|
+
timeout.unref?.();
|
|
197
|
+
});
|
|
198
|
+
const result = await Promise.race([
|
|
199
|
+
telemetry.then(() => "done" as const),
|
|
200
|
+
timeoutPromise,
|
|
201
|
+
]);
|
|
202
|
+
if (timeout) clearTimeout(timeout);
|
|
203
|
+
if (result === "timeout") onTimeout();
|
|
204
|
+
}
|
|
174
205
|
|
|
175
206
|
/**
|
|
176
207
|
* Delete-propagation policy honored by the push leg of bidirectional sync.
|
|
@@ -559,6 +590,8 @@ export interface RunnerDeps {
|
|
|
559
590
|
* an explicit stub here.
|
|
560
591
|
*/
|
|
561
592
|
collectTelemetry?: () => Promise<void>;
|
|
593
|
+
/** Maximum time to wait for pre-completion telemetry. Defaults to 120s. */
|
|
594
|
+
telemetryTimeoutMs?: number;
|
|
562
595
|
}
|
|
563
596
|
|
|
564
597
|
// ---------------------------------------------------------------------------
|
|
@@ -1169,24 +1202,34 @@ export async function runRunner(
|
|
|
1169
1202
|
|
|
1170
1203
|
// Fire telemetry collector before the all-complete emit so the cursor at
|
|
1171
1204
|
// `~/.hq/telemetry-cursor.json` is consistent with what the menubar sees.
|
|
1172
|
-
await
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
defaultCollectTelemetry(
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1205
|
+
await awaitTelemetryWithTimeout(
|
|
1206
|
+
emitTelemetry({
|
|
1207
|
+
collectTelemetry: deps.collectTelemetry,
|
|
1208
|
+
defaultCollectTelemetry: () =>
|
|
1209
|
+
defaultCollectTelemetry(
|
|
1210
|
+
client,
|
|
1211
|
+
deps.createVaultClient !== undefined,
|
|
1212
|
+
parsed.hqRoot,
|
|
1213
|
+
reportDiagnostic,
|
|
1214
|
+
),
|
|
1215
|
+
onError: (err) =>
|
|
1216
|
+
reportDiagnostic({
|
|
1217
|
+
component: "telemetry",
|
|
1218
|
+
event: "runner.telemetry.collector_failed",
|
|
1219
|
+
message: "telemetry collector failed",
|
|
1220
|
+
err,
|
|
1221
|
+
context: { hqRoot: parsed.hqRoot },
|
|
1222
|
+
}),
|
|
1223
|
+
}),
|
|
1224
|
+
resolveTelemetryTimeoutMs(deps.telemetryTimeoutMs),
|
|
1225
|
+
() =>
|
|
1182
1226
|
reportDiagnostic({
|
|
1183
1227
|
component: "telemetry",
|
|
1184
|
-
event: "runner.telemetry.
|
|
1185
|
-
message: "telemetry collector
|
|
1186
|
-
err,
|
|
1228
|
+
event: "runner.telemetry.timeout",
|
|
1229
|
+
message: "telemetry collector timed out; continuing sync completion",
|
|
1187
1230
|
context: { hqRoot: parsed.hqRoot },
|
|
1188
1231
|
}),
|
|
1189
|
-
|
|
1232
|
+
);
|
|
1190
1233
|
|
|
1191
1234
|
emit({
|
|
1192
1235
|
type: "all-complete",
|
package/src/cli/reindex.test.ts
CHANGED
|
@@ -124,6 +124,66 @@ describe("reindex", () => {
|
|
|
124
124
|
expect(fs.readlinkSync(link)).toBe("../../personal/policies/myrule.md");
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
it("collapses per-file overlay-skip spam into ONE summary line (HQ-SYNC-2)", () => {
|
|
128
|
+
// A REAL core file sits where an overlay symlink would go — the legitimate
|
|
129
|
+
// no-op that used to spam one stderr line per entry and flood the Sentry
|
|
130
|
+
// breadcrumb ring, evicting the real per-company sync error.
|
|
131
|
+
fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
|
|
132
|
+
fs.writeFileSync(path.join(root, "personal/policies/collide.md"), "rule\n");
|
|
133
|
+
fs.mkdirSync(path.join(root, "core/policies"), { recursive: true });
|
|
134
|
+
fs.writeFileSync(path.join(root, "core/policies/collide.md"), "REAL core file\n");
|
|
135
|
+
|
|
136
|
+
const writes: string[] = [];
|
|
137
|
+
const spy = vi
|
|
138
|
+
.spyOn(process.stderr, "write")
|
|
139
|
+
.mockImplementation(((chunk: unknown) => {
|
|
140
|
+
writes.push(String(chunk));
|
|
141
|
+
return true;
|
|
142
|
+
}) as typeof process.stderr.write);
|
|
143
|
+
let status: number;
|
|
144
|
+
try {
|
|
145
|
+
status = reindex({ repoRoot: root }).status;
|
|
146
|
+
} finally {
|
|
147
|
+
spy.mockRestore();
|
|
148
|
+
}
|
|
149
|
+
const stderr = writes.join("");
|
|
150
|
+
|
|
151
|
+
expect(status).toBe(0);
|
|
152
|
+
// The pre-existing real file is left untouched (never overwritten).
|
|
153
|
+
expect(fs.lstatSync(path.join(root, "core/policies/collide.md")).isSymbolicLink()).toBe(false);
|
|
154
|
+
// No per-file spam…
|
|
155
|
+
expect(stderr).not.toContain("already exists and is not a symlink; skipping");
|
|
156
|
+
// …just ONE collapsed summary naming the count.
|
|
157
|
+
expect(stderr).toMatch(/left 1 pre-existing non-symlink entry untouched/);
|
|
158
|
+
expect(stderr).toContain("HQ_REINDEX_VERBOSE=1");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("keeps per-file overlay-skip detail under HQ_REINDEX_VERBOSE=1", () => {
|
|
162
|
+
fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
|
|
163
|
+
fs.writeFileSync(path.join(root, "personal/policies/collide.md"), "rule\n");
|
|
164
|
+
fs.mkdirSync(path.join(root, "core/policies"), { recursive: true });
|
|
165
|
+
fs.writeFileSync(path.join(root, "core/policies/collide.md"), "REAL\n");
|
|
166
|
+
|
|
167
|
+
const writes: string[] = [];
|
|
168
|
+
const spy = vi
|
|
169
|
+
.spyOn(process.stderr, "write")
|
|
170
|
+
.mockImplementation(((chunk: unknown) => {
|
|
171
|
+
writes.push(String(chunk));
|
|
172
|
+
return true;
|
|
173
|
+
}) as typeof process.stderr.write);
|
|
174
|
+
process.env.HQ_REINDEX_VERBOSE = "1";
|
|
175
|
+
try {
|
|
176
|
+
reindex({ repoRoot: root });
|
|
177
|
+
} finally {
|
|
178
|
+
spy.mockRestore();
|
|
179
|
+
delete process.env.HQ_REINDEX_VERBOSE;
|
|
180
|
+
}
|
|
181
|
+
const stderr = writes.join("");
|
|
182
|
+
expect(stderr).toContain(
|
|
183
|
+
"core/policies/collide.md already exists and is not a symlink; skipping",
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
127
187
|
it("prunes orphan managed wrappers when the source skill disappears", () => {
|
|
128
188
|
writeSkill("core/skills/demo");
|
|
129
189
|
reindex({ repoRoot: root });
|
package/src/cli/reindex.ts
CHANGED
|
@@ -20,7 +20,9 @@ import * as path from "path";
|
|
|
20
20
|
import {
|
|
21
21
|
acquireOperationLock,
|
|
22
22
|
OperationLockedError,
|
|
23
|
+
OperationLockUnwritableError,
|
|
23
24
|
OPERATION_LOCKED_EXIT,
|
|
25
|
+
OPERATION_LOCK_UNWRITABLE_EXIT,
|
|
24
26
|
type LockHandle,
|
|
25
27
|
} from "../operation-lock.js";
|
|
26
28
|
|
|
@@ -218,11 +220,31 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
218
220
|
warn(err.message);
|
|
219
221
|
return { status: OPERATION_LOCKED_EXIT };
|
|
220
222
|
}
|
|
223
|
+
if (err instanceof OperationLockUnwritableError) {
|
|
224
|
+
// The lock dir isn't writable (e.g. macOS EPERM / Linux EACCES on a
|
|
225
|
+
// root-owned or restricted ~/.hq). This is an environmental problem on
|
|
226
|
+
// the user's machine, not an HQ fault — surface the actionable guidance
|
|
227
|
+
// and exit cleanly instead of crashing with a raw `EPERM ... open`
|
|
228
|
+
// (HQ-CLI-2).
|
|
229
|
+
warn(err.message);
|
|
230
|
+
return { status: OPERATION_LOCK_UNWRITABLE_EXIT };
|
|
231
|
+
}
|
|
221
232
|
throw err;
|
|
222
233
|
}
|
|
223
234
|
}
|
|
224
235
|
try {
|
|
225
236
|
|
|
237
|
+
// Overlay/skill-wrapper mirroring is best-effort and, on a normal synced HQ,
|
|
238
|
+
// legitimately no-ops for MANY entries (a real core file already sits where a
|
|
239
|
+
// symlink would go, or a link already points elsewhere). Emitting one stderr
|
|
240
|
+
// line PER such entry floods the runner's Sentry breadcrumb ring (100 entries)
|
|
241
|
+
// and evicts the real per-company sync error, making code-2 syncs impossible to
|
|
242
|
+
// triage (HQ-SYNC-2 / HQ-SYNC-WEB-6). Collapse those per-file lines into a
|
|
243
|
+
// single end-of-run summary; keep per-file detail behind HQ_REINDEX_VERBOSE=1.
|
|
244
|
+
const verbose = process.env.HQ_REINDEX_VERBOSE === "1";
|
|
245
|
+
let skippedNonSymlink = 0;
|
|
246
|
+
let skippedPointsElsewhere = 0;
|
|
247
|
+
|
|
226
248
|
// Best-effort: each wrapper mkdir below is recursive and re-creates this
|
|
227
249
|
// parent as needed, so a failure here is non-fatal — warn and carry on so
|
|
228
250
|
// personal-overlay mirroring and registry regeneration still run.
|
|
@@ -341,12 +363,18 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
341
363
|
if (lst && lst.isSymbolicLink()) {
|
|
342
364
|
const current = readlinkOrNull(linkPath);
|
|
343
365
|
if (current === relativeTarget) continue;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
366
|
+
skippedPointsElsewhere++;
|
|
367
|
+
if (verbose) {
|
|
368
|
+
warn(
|
|
369
|
+
`reindex: .claude/skills/${wrapperName}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
347
372
|
continue;
|
|
348
373
|
} else if (lst) {
|
|
349
|
-
|
|
374
|
+
skippedNonSymlink++;
|
|
375
|
+
if (verbose) {
|
|
376
|
+
warn(`reindex: ${linkPath} already exists and is not a symlink; skipping`);
|
|
377
|
+
}
|
|
350
378
|
continue;
|
|
351
379
|
}
|
|
352
380
|
|
|
@@ -440,12 +468,18 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
440
468
|
if (lst && lst.isSymbolicLink()) {
|
|
441
469
|
const current = readlinkOrNull(linkPath);
|
|
442
470
|
if (current === relativeTarget) continue;
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
471
|
+
skippedPointsElsewhere++;
|
|
472
|
+
if (verbose) {
|
|
473
|
+
warn(
|
|
474
|
+
`reindex: core/${type}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
446
477
|
continue;
|
|
447
478
|
} else if (lst) {
|
|
448
|
-
|
|
479
|
+
skippedNonSymlink++;
|
|
480
|
+
if (verbose) {
|
|
481
|
+
warn(`reindex: core/${type}/${entry} already exists and is not a symlink; skipping`);
|
|
482
|
+
}
|
|
449
483
|
continue;
|
|
450
484
|
}
|
|
451
485
|
|
|
@@ -463,6 +497,22 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
463
497
|
spawnSync("bash", [genScript], { stdio: ["ignore", 2, 2] });
|
|
464
498
|
}
|
|
465
499
|
|
|
500
|
+
// One-line summary in place of the per-file overlay-skip spam (see the comment
|
|
501
|
+
// at the top of the try). Only emitted when something was skipped, so a clean
|
|
502
|
+
// run stays silent and the breadcrumb ring is free for real sync errors.
|
|
503
|
+
if (skippedNonSymlink > 0 || skippedPointsElsewhere > 0) {
|
|
504
|
+
const parts: string[] = [];
|
|
505
|
+
if (skippedNonSymlink > 0) {
|
|
506
|
+
parts.push(`${skippedNonSymlink} pre-existing non-symlink ${skippedNonSymlink === 1 ? "entry" : "entries"}`);
|
|
507
|
+
}
|
|
508
|
+
if (skippedPointsElsewhere > 0) {
|
|
509
|
+
parts.push(`${skippedPointsElsewhere} ${skippedPointsElsewhere === 1 ? "entry" : "entries"} already pointing elsewhere`);
|
|
510
|
+
}
|
|
511
|
+
warn(
|
|
512
|
+
`reindex: left ${parts.join(" + ")} untouched (overlay mirror is best-effort; set HQ_REINDEX_VERBOSE=1 for per-file detail)`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
466
516
|
return { status: 0 };
|
|
467
517
|
} finally {
|
|
468
518
|
opLock?.release();
|
package/src/ignore.test.ts
CHANGED
|
@@ -177,8 +177,8 @@ describe("createIgnoreFilter", () => {
|
|
|
177
177
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
178
178
|
expect(shouldSync(path.join(hqRoot, ".claude/worktrees/foo/file.ts"))).toBe(false);
|
|
179
179
|
expect(shouldSync(path.join(hqRoot, "companies/indigo/.claude/worktrees/bar/x.md"))).toBe(false);
|
|
180
|
-
// The .claude/ dir itself (
|
|
181
|
-
expect(shouldSync(path.join(hqRoot, ".claude/
|
|
180
|
+
// The .claude/ dir itself (commands, skills, hooks) still syncs.
|
|
181
|
+
expect(shouldSync(path.join(hqRoot, ".claude/skills/foo/SKILL.md"))).toBe(true);
|
|
182
182
|
});
|
|
183
183
|
|
|
184
184
|
it("permissive mode: .hq-* internal state is ignored, .hqignore family still sync", () => {
|
|
@@ -226,13 +226,36 @@ describe("createIgnoreFilter", () => {
|
|
|
226
226
|
expect(
|
|
227
227
|
shouldSync(path.join(hqRoot, "companies/indigo/.claude/audit/instructions.md")),
|
|
228
228
|
).toBe(false);
|
|
229
|
-
// The .claude/ dir's other contents (
|
|
230
|
-
//
|
|
231
|
-
expect(shouldSync(path.join(hqRoot, ".claude/settings.json"))).toBe(true);
|
|
229
|
+
// The .claude/ dir's other contents (commands, skills, hooks) still
|
|
230
|
+
// sync — the rule MUST NOT broaden to .claude/.
|
|
232
231
|
expect(shouldSync(path.join(hqRoot, ".claude/skills/foo/SKILL.md"))).toBe(true);
|
|
233
232
|
expect(shouldSync(path.join(hqRoot, ".claude/hooks/foo.sh"))).toBe(true);
|
|
234
233
|
});
|
|
235
234
|
|
|
235
|
+
it("permissive mode: .claude/settings*.json is machine-local, never synced (qmd PATH regression)", () => {
|
|
236
|
+
// 2026-07-01 e2e-mac report (20260701-qmd-path): cloud sync pulled the
|
|
237
|
+
// operator's .claude/settings.json over a fresh install's copy ~40s
|
|
238
|
+
// after install, byte-identical to the source machine — reverting the
|
|
239
|
+
// installer's day-one env.PATH fix and propagating one host's PATH
|
|
240
|
+
// snapshot to every machine/teammate. This is the recurrence mechanism
|
|
241
|
+
// behind the recurring "qmd: command not found" reports. Settings are
|
|
242
|
+
// machine-local (PATH snapshot, model prefs) and must never round-trip
|
|
243
|
+
// in EITHER direction — the pull planner consults this same filter, so
|
|
244
|
+
// an existing vault copy is also never applied locally.
|
|
245
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
246
|
+
expect(shouldSync(path.join(hqRoot, ".claude/settings.json"))).toBe(false);
|
|
247
|
+
expect(shouldSync(path.join(hqRoot, ".claude/settings.local.json"))).toBe(false);
|
|
248
|
+
// Nested .claude/ inside a company stays local too — same anchor shape
|
|
249
|
+
// as the state/audit rules above.
|
|
250
|
+
expect(
|
|
251
|
+
shouldSync(path.join(hqRoot, "companies/indigo/.claude/settings.json")),
|
|
252
|
+
).toBe(false);
|
|
253
|
+
// Siblings still sync — the rule must not broaden past settings files.
|
|
254
|
+
expect(shouldSync(path.join(hqRoot, ".claude/skills/foo/SKILL.md"))).toBe(true);
|
|
255
|
+
expect(shouldSync(path.join(hqRoot, ".claude/hooks/foo.sh"))).toBe(true);
|
|
256
|
+
expect(shouldSync(path.join(hqRoot, ".claude/CLAUDE.md"))).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
236
259
|
it("permissive mode: .hq/ directory is per-host state, never synced (Bug #1/#6/#8)", () => {
|
|
237
260
|
// Bug catalog: `.hq/install-manifest.json`, `.hq/machine-id`, and
|
|
238
261
|
// `.hq/machine.json` are per-host source-of-truth files. The original
|
|
@@ -289,7 +312,10 @@ describe("createIgnoreFilter", () => {
|
|
|
289
312
|
expect(shouldSync(path.join(hqRoot, ".claude"), true)).toBe(true);
|
|
290
313
|
// Files inside still resolve correctly without the hint.
|
|
291
314
|
expect(shouldSync(path.join(hqRoot, "companies/indigo/knowledge/x.md"))).toBe(true);
|
|
292
|
-
expect(shouldSync(path.join(hqRoot, ".claude/
|
|
315
|
+
expect(shouldSync(path.join(hqRoot, ".claude/skills/foo/SKILL.md"))).toBe(true);
|
|
316
|
+
// Machine-local settings stay excluded even inside an allowlisted
|
|
317
|
+
// subtree — exclusion layers subtract on top of the allowlist.
|
|
318
|
+
expect(shouldSync(path.join(hqRoot, ".claude/settings.json"))).toBe(false);
|
|
293
319
|
// Non-allowlisted siblings stay excluded — privacy guarantee intact.
|
|
294
320
|
expect(shouldSync(path.join(hqRoot, "companies/indigo/data"), true)).toBe(false);
|
|
295
321
|
expect(shouldSync(path.join(hqRoot, "companies/indigo/workers"), true)).toBe(false);
|
package/src/ignore.ts
CHANGED
|
@@ -119,6 +119,17 @@ export const DEFAULT_IGNORES = [
|
|
|
119
119
|
"**/.claude/state/",
|
|
120
120
|
"**/.claude/audit/",
|
|
121
121
|
|
|
122
|
+
// Claude Code settings are machine-local: `env.PATH` is a snapshot of the
|
|
123
|
+
// host's shell PATH taken at install time, and model/permission prefs are
|
|
124
|
+
// per-operator-per-machine. Syncing them propagates one machine's PATH
|
|
125
|
+
// onto every other machine (and overwrites the installer's day-one PATH
|
|
126
|
+
// fix ~40s after a fresh install pulls existing cloud state) — the root
|
|
127
|
+
// cause of the recurring "qmd: command not found" reports (see
|
|
128
|
+
// workspace/e2e-mac/reports/20260701-qmd-path in HQ). Never round-trip
|
|
129
|
+
// them, in either direction.
|
|
130
|
+
"**/.claude/settings.json",
|
|
131
|
+
"**/.claude/settings.local.json",
|
|
132
|
+
|
|
122
133
|
// HQ repos directory (managed separately, not synced). Root-anchored so
|
|
123
134
|
// only hqRoot/repos/ is excluded; bare `repos/` would match any nested
|
|
124
135
|
// directory named repos (for example companies/<co>/knowledge/repos/),
|
package/src/journal.test.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
gcTombstones,
|
|
26
26
|
generatePullId,
|
|
27
27
|
TOMBSTONE_TTL_MS,
|
|
28
|
+
MAX_PULLS_PER_COMPANY,
|
|
28
29
|
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
29
30
|
migratePersonalVaultJournal,
|
|
30
31
|
listJournals,
|
|
@@ -463,6 +464,49 @@ describe("journal", () => {
|
|
|
463
464
|
"2026-05-20T00:00:00.000Z",
|
|
464
465
|
);
|
|
465
466
|
});
|
|
467
|
+
|
|
468
|
+
it("bounds pulls per company and preserves newest lastPullRecord", () => {
|
|
469
|
+
const j: SyncJournal = {
|
|
470
|
+
version: "2",
|
|
471
|
+
lastSync: "",
|
|
472
|
+
files: {},
|
|
473
|
+
pulls: [],
|
|
474
|
+
};
|
|
475
|
+
const completedAt = (n: number) =>
|
|
476
|
+
`2026-05-20T00:00:${String(n).padStart(2, "0")}.000Z`;
|
|
477
|
+
|
|
478
|
+
for (let i = 0; i < MAX_PULLS_PER_COMPANY + 5; i++) {
|
|
479
|
+
appendPullRecord(
|
|
480
|
+
j,
|
|
481
|
+
pull({
|
|
482
|
+
pullId: `pull-a-${String(i).padStart(2, "0")}`,
|
|
483
|
+
companyUid: "cmp_a",
|
|
484
|
+
completedAt: completedAt(i),
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
for (let i = 0; i < 3; i++) {
|
|
489
|
+
appendPullRecord(
|
|
490
|
+
j,
|
|
491
|
+
pull({
|
|
492
|
+
pullId: `pull-b-${i}`,
|
|
493
|
+
companyUid: "cmp_b",
|
|
494
|
+
completedAt: completedAt(i),
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const pulls = j.pulls ?? [];
|
|
500
|
+
const companyA = pulls.filter((p) => p.companyUid === "cmp_a");
|
|
501
|
+
const companyB = pulls.filter((p) => p.companyUid === "cmp_b");
|
|
502
|
+
expect(companyA).toHaveLength(MAX_PULLS_PER_COMPANY);
|
|
503
|
+
expect(companyB).toHaveLength(3);
|
|
504
|
+
expect(companyA[0].pullId).toBe("pull-a-05");
|
|
505
|
+
expect(companyA.at(-1)?.pullId).toBe("pull-a-54");
|
|
506
|
+
expect(companyA.some((p) => p.pullId === "pull-a-00")).toBe(false);
|
|
507
|
+
expect(lastPullRecord(j, "cmp_a")?.pullId).toBe("pull-a-54");
|
|
508
|
+
expect(lastPullRecord(j, "cmp_b")?.pullId).toBe("pull-b-2");
|
|
509
|
+
});
|
|
466
510
|
});
|
|
467
511
|
|
|
468
512
|
describe("tombstoneEntry + isTombstone", () => {
|
package/src/journal.ts
CHANGED
|
@@ -26,6 +26,13 @@ export const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
26
26
|
/** Current journal schema version written by all v2-aware writers. */
|
|
27
27
|
export const JOURNAL_VERSION_CURRENT = "2" as const;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Retain bounded pull history per company. Scope-shrink logic only needs the
|
|
31
|
+
* newest record, but a small tail keeps diagnostics useful without letting
|
|
32
|
+
* long-running sync loops grow journals forever.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_PULLS_PER_COMPANY = 50;
|
|
35
|
+
|
|
29
36
|
const JOURNAL_FILE_PREFIX = "sync-journal.";
|
|
30
37
|
const JOURNAL_FILE_SUFFIX = ".json";
|
|
31
38
|
const JOURNAL_LAST_GOOD_SUFFIX = ".last-good";
|
|
@@ -517,13 +524,41 @@ export function lastPullRecord(
|
|
|
517
524
|
return best;
|
|
518
525
|
}
|
|
519
526
|
|
|
520
|
-
|
|
527
|
+
function trimPullRecords(journal: SyncJournal): void {
|
|
528
|
+
const pulls = journal.pulls;
|
|
529
|
+
if (!pulls || pulls.length === 0) return;
|
|
530
|
+
|
|
531
|
+
const byCompany = new Map<string, Array<{ record: PullRecord; index: number }>>();
|
|
532
|
+
pulls.forEach((record, index) => {
|
|
533
|
+
const entries = byCompany.get(record.companyUid);
|
|
534
|
+
if (entries) entries.push({ record, index });
|
|
535
|
+
else byCompany.set(record.companyUid, [{ record, index }]);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const keep = new Set<number>();
|
|
539
|
+
for (const entries of byCompany.values()) {
|
|
540
|
+
entries
|
|
541
|
+
.sort((a, b) => {
|
|
542
|
+
const byCompletedAt = b.record.completedAt.localeCompare(
|
|
543
|
+
a.record.completedAt,
|
|
544
|
+
);
|
|
545
|
+
return byCompletedAt === 0 ? b.index - a.index : byCompletedAt;
|
|
546
|
+
})
|
|
547
|
+
.slice(0, MAX_PULLS_PER_COMPANY)
|
|
548
|
+
.forEach((entry) => keep.add(entry.index));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
journal.pulls = pulls.filter((_, index) => keep.has(index));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Append a `PullRecord` (mutates `journal.pulls`) and cap retained history. */
|
|
521
555
|
export function appendPullRecord(
|
|
522
556
|
journal: SyncJournal,
|
|
523
557
|
record: PullRecord,
|
|
524
558
|
): void {
|
|
525
559
|
migrateToV2(journal);
|
|
526
560
|
journal.pulls!.push(record);
|
|
561
|
+
trimPullRecords(journal);
|
|
527
562
|
}
|
|
528
563
|
|
|
529
564
|
/**
|
|
@@ -13,8 +13,11 @@ import {
|
|
|
13
13
|
withOperationLock,
|
|
14
14
|
withOperationLockSync,
|
|
15
15
|
lockPathFor,
|
|
16
|
+
rethrowLockCreateError,
|
|
16
17
|
OperationLockedError,
|
|
18
|
+
OperationLockUnwritableError,
|
|
17
19
|
OPERATION_LOCKED_EXIT,
|
|
20
|
+
OPERATION_LOCK_UNWRITABLE_EXIT,
|
|
18
21
|
DEFAULT_LOCK_POLL_MS,
|
|
19
22
|
type LockInfo,
|
|
20
23
|
} from "./operation-lock.js";
|
|
@@ -101,6 +104,96 @@ describe("operation-lock", () => {
|
|
|
101
104
|
expect(fs.existsSync(h.path)).toBe(false);
|
|
102
105
|
});
|
|
103
106
|
|
|
107
|
+
// Regression (HQ-CLI-2): when the lock's state dir is not writable, acquiring
|
|
108
|
+
// must fail with an actionable OperationLockUnwritableError, NOT a raw
|
|
109
|
+
// `EPERM: operation not permitted, open` (macOS) / `EACCES` (Linux) crash.
|
|
110
|
+
describe("unwritable lock dir (HQ-CLI-2)", () => {
|
|
111
|
+
// The classification decision (which fs error codes mean "unwritable lock"
|
|
112
|
+
// vs. a genuinely different fault) is verified directly against the exported
|
|
113
|
+
// `rethrowLockCreateError` helper. ESM module namespaces can't be spied, so
|
|
114
|
+
// we can't mock `fs.openSync`; testing the classifier is both deterministic
|
|
115
|
+
// (uid/OS-independent) and a tighter unit of the actual decision.
|
|
116
|
+
const permErr = (code: string, msg: string): NodeJS.ErrnoException =>
|
|
117
|
+
Object.assign(new Error(`${code}: ${msg}, open`), { code, syscall: "open" });
|
|
118
|
+
|
|
119
|
+
it.each(["EPERM", "EACCES", "EROFS"])(
|
|
120
|
+
"classifies %s as OperationLockUnwritableError with actionable guidance",
|
|
121
|
+
(code) => {
|
|
122
|
+
const dir = path.join(stateDir, "locks");
|
|
123
|
+
let thrown: unknown;
|
|
124
|
+
try {
|
|
125
|
+
rethrowLockCreateError(permErr(code, "not permitted"), dir);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
thrown = e;
|
|
128
|
+
}
|
|
129
|
+
expect(thrown).toBeInstanceOf(OperationLockUnwritableError);
|
|
130
|
+
const err = thrown as OperationLockUnwritableError;
|
|
131
|
+
expect(err.lockDir).toBe(dir);
|
|
132
|
+
expect(err.cause.code).toBe(code);
|
|
133
|
+
// Actionable: names the recovery levers so the user can self-serve.
|
|
134
|
+
expect(err.message).toContain("HQ_DISABLE_OP_LOCK=1");
|
|
135
|
+
expect(err.message).toContain("HQ_STATE_DIR");
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
it("POSITIVE CONTROL: a NON-permission fs error propagates unchanged (not masked)", () => {
|
|
140
|
+
// A disk-full error is a genuine, different fault — it must NOT be
|
|
141
|
+
// reclassified as an unwritable-lock error.
|
|
142
|
+
const dir = path.join(stateDir, "locks");
|
|
143
|
+
let thrown: unknown;
|
|
144
|
+
try {
|
|
145
|
+
rethrowLockCreateError(permErr("ENOSPC", "no space left on device"), dir);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
thrown = e;
|
|
148
|
+
}
|
|
149
|
+
expect(thrown).not.toBeInstanceOf(OperationLockUnwritableError);
|
|
150
|
+
expect((thrown as NodeJS.ErrnoException).code).toBe("ENOSPC");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("real unwritable locks dir → OperationLockUnwritableError, distinct exit (skipped as root)", () => {
|
|
154
|
+
// End-to-end proof through the real acquire path: a locks dir with no write
|
|
155
|
+
// bit makes the temp-file create fail (EACCES on Linux / EPERM on macOS),
|
|
156
|
+
// and acquire must surface the typed error rather than crash. CI runners
|
|
157
|
+
// run non-root, so this exercises the full path there.
|
|
158
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) {
|
|
159
|
+
// root bypasses DAC permission bits, so a chmod-based unwritable dir
|
|
160
|
+
// cannot be exercised — the classifier cases above cover the logic.
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const locksDir = path.join(stateDir, "locks");
|
|
164
|
+
fs.mkdirSync(locksDir, { recursive: true });
|
|
165
|
+
fs.chmodSync(locksDir, 0o500); // r-x: cannot create files inside
|
|
166
|
+
try {
|
|
167
|
+
expect(() => acquireOperationLock(rootA, "reindex", { wait: false })).toThrowError(
|
|
168
|
+
OperationLockUnwritableError,
|
|
169
|
+
);
|
|
170
|
+
} finally {
|
|
171
|
+
fs.chmodSync(locksDir, 0o700); // restore so afterEach cleanup can rm it
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("async acquire also surfaces the typed error on an unwritable locks dir (skipped as root)", async () => {
|
|
176
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const locksDir = path.join(stateDir, "locks");
|
|
180
|
+
fs.mkdirSync(locksDir, { recursive: true });
|
|
181
|
+
fs.chmodSync(locksDir, 0o500);
|
|
182
|
+
try {
|
|
183
|
+
await expect(
|
|
184
|
+
acquireOperationLockAsync(rootB, "reindex", { wait: false }),
|
|
185
|
+
).rejects.toBeInstanceOf(OperationLockUnwritableError);
|
|
186
|
+
} finally {
|
|
187
|
+
fs.chmodSync(locksDir, 0o700);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("exposes a distinct exit code from the busy case", () => {
|
|
192
|
+
expect(OPERATION_LOCK_UNWRITABLE_EXIT).not.toBe(OPERATION_LOCKED_EXIT);
|
|
193
|
+
expect(OPERATION_LOCK_UNWRITABLE_EXIT).toBe(18);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
104
197
|
it("refuses immediately (wait:false) with the holder's command + pid when a LIVE process holds it", () => {
|
|
105
198
|
// Simulate a DIFFERENT live process holding the lock. PID 1 (init/systemd)
|
|
106
199
|
// is always alive and is never our own pid, so kill(1,0) reports alive and
|