@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.
Files changed (47) hide show
  1. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +30 -0
  2. package/dist/bin/sync-runner.d.ts +2 -0
  3. package/dist/bin/sync-runner.d.ts.map +1 -1
  4. package/dist/bin/sync-runner.js +35 -2
  5. package/dist/bin/sync-runner.js.map +1 -1
  6. package/dist/bin/sync-runner.test.js +14 -0
  7. package/dist/bin/sync-runner.test.js.map +1 -1
  8. package/dist/cli/reindex.d.ts.map +1 -1
  9. package/dist/cli/reindex.js +49 -5
  10. package/dist/cli/reindex.js.map +1 -1
  11. package/dist/cli/reindex.test.js +55 -0
  12. package/dist/cli/reindex.test.js.map +1 -1
  13. package/dist/ignore.d.ts.map +1 -1
  14. package/dist/ignore.js +10 -0
  15. package/dist/ignore.js.map +1 -1
  16. package/dist/ignore.test.js +29 -6
  17. package/dist/ignore.test.js.map +1 -1
  18. package/dist/journal.d.ts +7 -1
  19. package/dist/journal.d.ts.map +1 -1
  20. package/dist/journal.js +32 -1
  21. package/dist/journal.js.map +1 -1
  22. package/dist/journal.test.js +34 -1
  23. package/dist/journal.test.js.map +1 -1
  24. package/dist/operation-lock.d.ts +31 -0
  25. package/dist/operation-lock.d.ts.map +1 -1
  26. package/dist/operation-lock.js +75 -2
  27. package/dist/operation-lock.js.map +1 -1
  28. package/dist/operation-lock.test.js +81 -1
  29. package/dist/operation-lock.test.js.map +1 -1
  30. package/dist/skill-telemetry.d.ts.map +1 -1
  31. package/dist/skill-telemetry.js +7 -3
  32. package/dist/skill-telemetry.js.map +1 -1
  33. package/dist/skill-telemetry.test.js +12 -0
  34. package/dist/skill-telemetry.test.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/bin/sync-runner.test.ts +20 -0
  37. package/src/bin/sync-runner.ts +57 -14
  38. package/src/cli/reindex.test.ts +60 -0
  39. package/src/cli/reindex.ts +58 -8
  40. package/src/ignore.test.ts +32 -6
  41. package/src/ignore.ts +11 -0
  42. package/src/journal.test.ts +44 -0
  43. package/src/journal.ts +36 -1
  44. package/src/operation-lock.test.ts +93 -0
  45. package/src/operation-lock.ts +78 -2
  46. package/src/skill-telemetry.test.ts +24 -0
  47. package/src/skill-telemetry.ts +6 -3
@@ -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 emitTelemetry({
1173
- collectTelemetry: deps.collectTelemetry,
1174
- defaultCollectTelemetry: () =>
1175
- defaultCollectTelemetry(
1176
- client,
1177
- deps.createVaultClient !== undefined,
1178
- parsed.hqRoot,
1179
- reportDiagnostic,
1180
- ),
1181
- onError: (err) =>
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.collector_failed",
1185
- message: "telemetry collector failed",
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",
@@ -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 });
@@ -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
- warn(
345
- `reindex: .claude/skills/${wrapperName}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
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
- warn(`reindex: ${linkPath} already exists and is not a symlink; skipping`);
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
- warn(
444
- `reindex: core/${type}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
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
- warn(`reindex: core/${type}/${entry} already exists and is not a symlink; skipping`);
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();
@@ -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 (settings, commands, skills) still syncs.
181
- expect(shouldSync(path.join(hqRoot, ".claude/settings.json"))).toBe(true);
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 (settings, commands, skills,
230
- // hooks) still sync — the rule MUST NOT broaden to .claude/.
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/settings.json"))).toBe(true);
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/),
@@ -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
- /** Append a `PullRecord` (mutates `journal.pulls`). */
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