@indigoai-us/hq-cloud 6.2.7 → 6.3.1

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 (60) hide show
  1. package/dist/bin/sync-runner.d.ts +22 -2
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +105 -3
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +262 -0
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/reindex.d.ts +8 -0
  8. package/dist/cli/reindex.d.ts.map +1 -1
  9. package/dist/cli/reindex.js +222 -198
  10. package/dist/cli/reindex.js.map +1 -1
  11. package/dist/cli/reindex.test.js +35 -0
  12. package/dist/cli/reindex.test.js.map +1 -1
  13. package/dist/cli/rescue-core.js +14 -2
  14. package/dist/cli/rescue-core.js.map +1 -1
  15. package/dist/cli/rescue-hq-root-guard.test.d.ts +2 -0
  16. package/dist/cli/rescue-hq-root-guard.test.d.ts.map +1 -0
  17. package/dist/cli/rescue-hq-root-guard.test.js +176 -0
  18. package/dist/cli/rescue-hq-root-guard.test.js.map +1 -0
  19. package/dist/cli/rescue.d.ts.map +1 -1
  20. package/dist/cli/rescue.js +39 -16
  21. package/dist/cli/rescue.js.map +1 -1
  22. package/dist/cli/rescue.reindex.test.js +15 -2
  23. package/dist/cli/rescue.reindex.test.js.map +1 -1
  24. package/dist/cli/sync.d.ts.map +1 -1
  25. package/dist/cli/sync.js +3 -1
  26. package/dist/cli/sync.js.map +1 -1
  27. package/dist/cli/sync.test.js +2 -1
  28. package/dist/cli/sync.test.js.map +1 -1
  29. package/dist/operation-lock.d.ts +100 -0
  30. package/dist/operation-lock.d.ts.map +1 -0
  31. package/dist/operation-lock.js +256 -0
  32. package/dist/operation-lock.js.map +1 -0
  33. package/dist/operation-lock.test.d.ts +5 -0
  34. package/dist/operation-lock.test.d.ts.map +1 -0
  35. package/dist/operation-lock.test.js +140 -0
  36. package/dist/operation-lock.test.js.map +1 -0
  37. package/dist/sync/event-sync.d.ts +181 -0
  38. package/dist/sync/event-sync.d.ts.map +1 -0
  39. package/dist/sync/event-sync.js +316 -0
  40. package/dist/sync/event-sync.js.map +1 -0
  41. package/dist/sync/event-sync.test.d.ts +14 -0
  42. package/dist/sync/event-sync.test.d.ts.map +1 -0
  43. package/dist/sync/event-sync.test.js +440 -0
  44. package/dist/sync/event-sync.test.js.map +1 -0
  45. package/package.json +1 -1
  46. package/src/bin/sync-runner.test.ts +323 -0
  47. package/src/bin/sync-runner.ts +139 -4
  48. package/src/cli/reindex.test.ts +45 -0
  49. package/src/cli/reindex.ts +36 -0
  50. package/src/cli/rescue-core.ts +15 -2
  51. package/src/cli/rescue-hq-root-guard.test.ts +193 -0
  52. package/src/cli/rescue.reindex.test.ts +17 -2
  53. package/src/cli/rescue.ts +40 -15
  54. package/src/cli/sync.test.ts +2 -1
  55. package/src/cli/sync.ts +3 -1
  56. package/src/operation-lock.test.ts +162 -0
  57. package/src/operation-lock.ts +293 -0
  58. package/src/sync/event-sync.test.ts +533 -0
  59. package/src/sync/event-sync.ts +481 -0
  60. package/test/e2e/sync/cross-tenant-isolation.test.ts +126 -0
@@ -384,9 +384,22 @@ function doRescue(
384
384
  hqRoot = fs.realpathSync(process.cwd());
385
385
  }
386
386
 
387
- if (!isDir(path.join(hqRoot, "companies")) || !isDir(path.join(hqRoot, "personal"))) {
387
+ // HQ-root sanity gate — guards against wiping a non-HQ directory. `companies/`
388
+ // is the stable anchor (present and preserved on every HQ install), confirmed
389
+ // by at least one core scaffold marker. We accept ANY of `.claude/`, `core/`,
390
+ // or `personal/` because the layout has drifted across releases: v14.0.0 ships
391
+ // NEITHER `personal/` (added v15) NOR `core/` (the v15 scaffold home) — it only
392
+ // has `.claude/`. Requiring `personal/` here left v14.0.0 users with no upgrade
393
+ // path (rescue aborted; DEV-1741). `.claude/` exists on every version v14→v15,
394
+ // so it admits the full upgrade range while still rejecting a bare directory.
395
+ const hasHqMarker =
396
+ isDir(path.join(hqRoot, ".claude")) ||
397
+ isDir(path.join(hqRoot, "core")) ||
398
+ isDir(path.join(hqRoot, "personal"));
399
+ if (!isDir(path.join(hqRoot, "companies")) || !hasHqMarker) {
388
400
  err(
389
- `error: ${hqRoot} does not look like an HQ root (missing companies/ or personal/). Aborting.\n`,
401
+ `error: ${hqRoot} does not look like an HQ root ` +
402
+ `(need companies/ plus one of .claude/, core/, or personal/). Aborting.\n`,
390
403
  );
391
404
  err(" pass --hq-root <dir> if the script is not at personal/skills/<skill>/.\n");
392
405
  throw new ExitError(3);
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Regression test for the rescue HQ-root sanity gate in src/cli/rescue-core.ts.
3
+ *
4
+ * Bug (DEV-1741): the gate required BOTH `companies/` AND `personal/`. The
5
+ * `personal/` directory was only introduced in v15 — a faithful v14.0.0 install
6
+ * ships NEITHER `personal/` NOR `core/` (its scaffold lived in top-level dirs
7
+ * like `scripts/`, `workers/`, `knowledge/`, plus `.claude/`). So every v14.0.0
8
+ * user hit `error: ... missing companies/ or personal/. Aborting.` (exit 3) and
9
+ * had no supported upgrade path to v15.
10
+ *
11
+ * The gate now anchors on `companies/` plus ANY ONE of `.claude/`, `core/`, or
12
+ * `personal/`. `.claude/` exists on every release from v14 through v15, so the
13
+ * relaxed gate admits the full upgrade range while still rejecting a directory
14
+ * that is not an HQ root at all.
15
+ *
16
+ * Like the sibling drift test, the rescue clones its source from GitHub, so we
17
+ * shim `git clone` to a local fixture repo. All runs are `--dry-run` (no
18
+ * destructive ops, no backup) and we assert on the gate's behavior (exit code +
19
+ * message), not the full overlay.
20
+ */
21
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
22
+ import { execFileSync } from "child_process";
23
+ import * as fs from "fs";
24
+ import * as os from "os";
25
+ import * as path from "path";
26
+ import { runRescue } from "./rescue-core.js";
27
+
28
+ function hasGit(): boolean {
29
+ try {
30
+ execFileSync("git", ["--version"], { stdio: "ignore" });
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ const gitAvailable = hasGit();
38
+
39
+ /** Run the rescue in-process, capturing its stdout/stderr. */
40
+ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
41
+ let stdout = "";
42
+ let stderr = "";
43
+ const origOut = process.stdout.write.bind(process.stdout);
44
+ const origErr = process.stderr.write.bind(process.stderr);
45
+ process.stdout.write = ((chunk: unknown) => {
46
+ stdout += String(chunk);
47
+ return true;
48
+ }) as typeof process.stdout.write;
49
+ process.stderr.write = ((chunk: unknown) => {
50
+ stderr += String(chunk);
51
+ return true;
52
+ }) as typeof process.stderr.write;
53
+ let status: number;
54
+ try {
55
+ status = runRescue(argv, { env }).status;
56
+ } finally {
57
+ process.stdout.write = origOut;
58
+ process.stderr.write = origErr;
59
+ }
60
+ return { status, stdout, stderr };
61
+ }
62
+
63
+ describe.skipIf(!gitAvailable)("rescue HQ-root sanity gate", () => {
64
+ let workDir: string;
65
+ let upstream: string;
66
+ let shimDir: string;
67
+ let floorSha: string;
68
+ let env: NodeJS.ProcessEnv;
69
+
70
+ const git = (cwd: string, ...args: string[]) =>
71
+ execFileSync("git", args, {
72
+ cwd,
73
+ stdio: ["ignore", "pipe", "pipe"],
74
+ env: {
75
+ ...process.env,
76
+ GIT_AUTHOR_NAME: "t",
77
+ GIT_AUTHOR_EMAIL: "t@t",
78
+ GIT_COMMITTER_NAME: "t",
79
+ GIT_COMMITTER_EMAIL: "t@t",
80
+ },
81
+ })
82
+ .toString()
83
+ .trim();
84
+
85
+ beforeAll(() => {
86
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-gate-"));
87
+
88
+ // --- Minimal local "upstream" repo (one core file, floor + HEAD). ---
89
+ upstream = path.join(workDir, "upstream");
90
+ fs.mkdirSync(path.join(upstream, "core"), { recursive: true });
91
+ git(workDir, "init", "-b", "main", "upstream");
92
+ fs.writeFileSync(path.join(upstream, "core/x.md"), "v1\n");
93
+ git(upstream, "add", "-A");
94
+ git(upstream, "commit", "-m", "floor");
95
+ floorSha = git(upstream, "rev-parse", "HEAD");
96
+ fs.writeFileSync(path.join(upstream, "core/x.md"), "v2\n");
97
+ git(upstream, "add", "-A");
98
+ git(upstream, "commit", "-m", "head");
99
+
100
+ // --- git shim: rewrite `git clone <github-url> dest` to the fixture. ---
101
+ const realGit =
102
+ execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
103
+ shimDir = path.join(workDir, "shim");
104
+ fs.mkdirSync(shimDir, { recursive: true });
105
+ const shim = `#!/usr/bin/env bash
106
+ if [ "$1" = "clone" ]; then
107
+ args=()
108
+ for a in "$@"; do
109
+ case "$a" in
110
+ https://github.com/*) a=${JSON.stringify(upstream)} ;;
111
+ esac
112
+ args+=("$a")
113
+ done
114
+ exec ${JSON.stringify(realGit)} "\${args[@]}"
115
+ fi
116
+ exec ${JSON.stringify(realGit)} "$@"
117
+ `;
118
+ fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
119
+ env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
120
+ }, 60_000); // git init + commits can exceed vitest's 10s default under parallel load
121
+
122
+ afterAll(() => {
123
+ if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
124
+ });
125
+
126
+ function makeRoot(name: string, dirs: string[]): string {
127
+ const root = path.join(workDir, name);
128
+ for (const d of dirs) fs.mkdirSync(path.join(root, d), { recursive: true });
129
+ return root;
130
+ }
131
+
132
+ function rescueDry(hqRoot: string) {
133
+ return runRescueCapture(
134
+ [
135
+ "--hq-root", hqRoot,
136
+ "--source", "test/repo",
137
+ "--ref", "main",
138
+ "--floor-sha", floorSha,
139
+ "--dry-run",
140
+ "--yes",
141
+ "--no-backup",
142
+ ],
143
+ env,
144
+ );
145
+ }
146
+
147
+ it("admits a faithful v14.0.0 root (companies/ + .claude/, NO personal/, NO core/)", () => {
148
+ // Exactly the shape that aborted before the fix.
149
+ const root = makeRoot("v14", ["companies", ".claude", "repos", "workspace"]);
150
+ expect(fs.existsSync(path.join(root, "personal"))).toBe(false);
151
+ expect(fs.existsSync(path.join(root, "core"))).toBe(false);
152
+
153
+ const r = rescueDry(root);
154
+ const out = `${r.stdout}\n${r.stderr}`;
155
+ // Must get PAST the gate — not the exit-3 abort.
156
+ expect(r.status, out).not.toBe(3);
157
+ expect(out).not.toContain("does not look like an HQ root");
158
+ expect(r.status, out).toBe(0);
159
+ });
160
+
161
+ it("admits a v15 root (companies/ + core/ + personal/)", () => {
162
+ const root = makeRoot("v15", ["companies", ".claude", "core", "personal", "repos", "workspace"]);
163
+ const r = rescueDry(root);
164
+ const out = `${r.stdout}\n${r.stderr}`;
165
+ expect(r.status, out).toBe(0);
166
+ expect(out).not.toContain("does not look like an HQ root");
167
+ });
168
+
169
+ it("admits a core-only root (companies/ + core/, no .claude/ or personal/)", () => {
170
+ const root = makeRoot("coreonly", ["companies", "core"]);
171
+ const r = rescueDry(root);
172
+ const out = `${r.stdout}\n${r.stderr}`;
173
+ expect(r.status, out).not.toBe(3);
174
+ expect(out).not.toContain("does not look like an HQ root");
175
+ });
176
+
177
+ it("still rejects a non-HQ directory (companies/ only — no scaffold marker)", () => {
178
+ const root = makeRoot("bare", ["companies"]);
179
+ const r = rescueDry(root);
180
+ const out = `${r.stdout}\n${r.stderr}`;
181
+ expect(r.status, out).toBe(3);
182
+ expect(out).toContain("does not look like an HQ root");
183
+ expect(out).toContain("need companies/ plus one of .claude/, core/, or personal/");
184
+ });
185
+
186
+ it("still rejects a directory with a marker but no companies/", () => {
187
+ const root = makeRoot("nocompanies", [".claude", "core", "personal"]);
188
+ const r = rescueDry(root);
189
+ const out = `${r.stdout}\n${r.stderr}`;
190
+ expect(r.status, out).toBe(3);
191
+ expect(out).toContain("does not look like an HQ root");
192
+ });
193
+ });
@@ -7,7 +7,10 @@
7
7
  * runs, and ./reindex.js is mocked to a spy so we assert the call without
8
8
  * touching disk.
9
9
  */
10
- import { describe, it, expect, vi, beforeEach } from "vitest";
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
11
+ import * as fs from "fs";
12
+ import * as os from "os";
13
+ import * as path from "path";
11
14
 
12
15
  vi.mock("./rescue-core.js", () => ({
13
16
  runRescue: vi.fn(() => ({ status: 0 })),
@@ -21,16 +24,28 @@ import { reindex } from "./reindex.js";
21
24
  import { rescue } from "./rescue.js";
22
25
 
23
26
  describe("rescue → reindex", () => {
27
+ let stateDir: string;
28
+
24
29
  beforeEach(() => {
25
30
  vi.clearAllMocks();
26
31
  (runRescue as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ status: 0 });
32
+ // rescue() now takes the per-root operation lock; redirect it to a tmp dir.
33
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "rescue-reindex-state-"));
34
+ process.env.HQ_STATE_DIR = stateDir;
35
+ });
36
+
37
+ afterEach(() => {
38
+ fs.rmSync(stateDir, { recursive: true, force: true });
39
+ delete process.env.HQ_STATE_DIR;
27
40
  });
28
41
 
29
42
  it("refreshes via reindex after a successful rescue", () => {
30
43
  const r = rescue({ hqRoot: "/tmp/hq", assumeYes: true });
31
44
  expect(r.status).toBe(0);
32
45
  expect(reindex).toHaveBeenCalledTimes(1);
33
- expect(reindex).toHaveBeenCalledWith({ repoRoot: "/tmp/hq" });
46
+ // skipLock: rescue already holds the per-root operation lock, so the
47
+ // internal reindex must not try to re-acquire it.
48
+ expect(reindex).toHaveBeenCalledWith({ repoRoot: "/tmp/hq", skipLock: true });
34
49
  });
35
50
 
36
51
  it("does NOT run reindex on a dry-run", () => {
package/src/cli/rescue.ts CHANGED
@@ -14,6 +14,11 @@
14
14
  */
15
15
  import { reindex } from "./reindex.js";
16
16
  import { runRescue } from "./rescue-core.js";
17
+ import {
18
+ acquireOperationLock,
19
+ OperationLockedError,
20
+ OPERATION_LOCKED_EXIT,
21
+ } from "../operation-lock.js";
17
22
 
18
23
  export interface RescueOptions {
19
24
  /** HQ root to operate on. Passed as `--hq-root`. Defaults to the script's
@@ -91,21 +96,41 @@ export function buildRescueArgs(opts: RescueOptions = {}): string[] {
91
96
  * confirmation prompt (unless `assumeYes` is set).
92
97
  */
93
98
  export function rescue(opts: RescueOptions = {}): RescueResult {
94
- const args = buildRescueArgs(opts);
95
- const env: NodeJS.ProcessEnv = { ...process.env };
96
- if (opts.ghToken) env.GH_TOKEN = opts.ghToken;
97
- const { status } = runRescue(args, { env });
98
- // A successful, non-dry-run rescue re-lays-down core/, so refresh the
99
- // generated skill wrappers, personal-overlay mirrors, and workers registry.
100
- // Best-effort + idempotent — never overrides the rescue's own exit status.
101
- // repoRoot falls back to process.cwd() (reindex's default) when hqRoot is
102
- // omitted, matching the rescue script's own cwd-based default.
103
- if (status === 0 && !opts.dryRun) {
104
- try {
105
- reindex({ repoRoot: opts.hqRoot });
106
- } catch {
107
- // best-effort
99
+ // Rescue is mutually exclusive with sync/reindex on this HQ root. Key the
100
+ // lock on the same root the rescue script resolves (cwd when --hq-root is
101
+ // omitted). Rescue is never the exempt push watcher, so it always locks.
102
+ const lockRoot = opts.hqRoot ?? process.cwd();
103
+ let handle;
104
+ try {
105
+ handle = acquireOperationLock(lockRoot, "rescue");
106
+ } catch (err) {
107
+ if (err instanceof OperationLockedError) {
108
+ process.stderr.write(err.message + "\n");
109
+ return { status: OPERATION_LOCKED_EXIT };
108
110
  }
111
+ throw err;
112
+ }
113
+ try {
114
+ const args = buildRescueArgs(opts);
115
+ const env: NodeJS.ProcessEnv = { ...process.env };
116
+ if (opts.ghToken) env.GH_TOKEN = opts.ghToken;
117
+ const { status } = runRescue(args, { env });
118
+ // A successful, non-dry-run rescue re-lays-down core/, so refresh the
119
+ // generated skill wrappers, personal-overlay mirrors, and workers registry.
120
+ // Best-effort + idempotent — never overrides the rescue's own exit status.
121
+ // repoRoot falls back to process.cwd() (reindex's default) when hqRoot is
122
+ // omitted, matching the rescue script's own cwd-based default. `skipLock`
123
+ // because we already hold the per-root lock — reindex's own acquire would
124
+ // otherwise see our live PID and refuse.
125
+ if (status === 0 && !opts.dryRun) {
126
+ try {
127
+ reindex({ repoRoot: opts.hqRoot, skipLock: true });
128
+ } catch {
129
+ // best-effort
130
+ }
131
+ }
132
+ return { status };
133
+ } finally {
134
+ handle.release();
109
135
  }
110
- return { status };
111
136
  }
@@ -123,7 +123,8 @@ describe("sync", () => {
123
123
 
124
124
  it("runs reindex against hqRoot after a sync that downloaded files", async () => {
125
125
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
126
- expect(reindex).toHaveBeenCalledWith({ repoRoot: tmpDir });
126
+ // skipLock: the surrounding sync run already holds the per-root lock.
127
+ expect(reindex).toHaveBeenCalledWith({ repoRoot: tmpDir, skipLock: true });
127
128
  });
128
129
 
129
130
  it("skips reindex when skipReindex is set", async () => {
package/src/cli/sync.ts CHANGED
@@ -1338,7 +1338,9 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
1338
1338
  shrinkResult.cleanRemoved > 0;
1339
1339
  if (!options.skipReindex && changedOnDisk) {
1340
1340
  try {
1341
- reindex({ repoRoot: hqRoot });
1341
+ // skipLock: the surrounding sync run already holds this root's operation
1342
+ // lock; reindex re-acquiring would refuse against our own live PID.
1343
+ reindex({ repoRoot: hqRoot, skipLock: true });
1342
1344
  } catch {
1343
1345
  // best-effort: a post-sync refresh failure never fails the sync
1344
1346
  }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Unit tests for the per-HQ-root operation mutex.
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
6
+ import { spawnSync } from "child_process";
7
+ import * as fs from "fs";
8
+ import * as os from "os";
9
+ import * as path from "path";
10
+ import {
11
+ acquireOperationLock,
12
+ withOperationLockSync,
13
+ lockPathFor,
14
+ OperationLockedError,
15
+ OPERATION_LOCKED_EXIT,
16
+ type LockInfo,
17
+ } from "./operation-lock.js";
18
+
19
+ /** A PID that is guaranteed dead: spawn a node that exits immediately, reuse its pid. */
20
+ function deadPid(): number {
21
+ const r = spawnSync(process.execPath, ["-e", ""], { stdio: "ignore" });
22
+ if (!r.pid) throw new Error("could not spawn to obtain a dead pid");
23
+ return r.pid;
24
+ }
25
+
26
+ function writeLock(p: string, info: Partial<LockInfo>): void {
27
+ fs.mkdirSync(path.dirname(p), { recursive: true });
28
+ const full: LockInfo = {
29
+ pid: 1,
30
+ command: "sync",
31
+ startedAt: new Date(0).toISOString(),
32
+ hqRoot: "/x",
33
+ ...info,
34
+ };
35
+ fs.writeFileSync(p, JSON.stringify(full));
36
+ }
37
+
38
+ describe("operation-lock", () => {
39
+ let stateDir: string;
40
+ let rootA: string;
41
+ let rootB: string;
42
+
43
+ beforeEach(() => {
44
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-oplock-state-"));
45
+ process.env.HQ_STATE_DIR = stateDir;
46
+ delete process.env.HQ_DISABLE_OP_LOCK;
47
+ rootA = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rootA-"));
48
+ rootB = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rootB-"));
49
+ });
50
+
51
+ afterEach(() => {
52
+ fs.rmSync(stateDir, { recursive: true, force: true });
53
+ fs.rmSync(rootA, { recursive: true, force: true });
54
+ fs.rmSync(rootB, { recursive: true, force: true });
55
+ delete process.env.HQ_STATE_DIR;
56
+ delete process.env.HQ_DISABLE_OP_LOCK;
57
+ });
58
+
59
+ it("the lock path is under the state dir, keyed per canonical root", () => {
60
+ const a = lockPathFor(rootA);
61
+ const b = lockPathFor(rootB);
62
+ expect(a.startsWith(path.join(stateDir, "locks"))).toBe(true);
63
+ expect(a).not.toBe(b); // different roots → different lock files
64
+ // canonical: a trailing-slash variant maps to the same lock
65
+ expect(lockPathFor(rootA + path.sep)).toBe(a);
66
+ });
67
+
68
+ it("acquires, writes holder info, and releases (file gone after release)", () => {
69
+ const h = acquireOperationLock(rootA, "sync");
70
+ expect(fs.existsSync(h.path)).toBe(true);
71
+ const info = JSON.parse(fs.readFileSync(h.path, "utf8")) as LockInfo;
72
+ expect(info.pid).toBe(process.pid);
73
+ expect(info.command).toBe("sync");
74
+ h.release();
75
+ expect(fs.existsSync(h.path)).toBe(false);
76
+ });
77
+
78
+ it("refuses fast with the holder's command + pid when a LIVE process holds it", () => {
79
+ // Simulate a DIFFERENT live process holding the lock. PID 1 (init/systemd)
80
+ // is always alive and is never our own pid, so kill(1,0) reports alive and
81
+ // the same-process reclaim path does not apply.
82
+ writeLock(lockPathFor(rootA), { pid: 1, command: "rescue" });
83
+ expect(() => acquireOperationLock(rootA, "sync")).toThrowError(OperationLockedError);
84
+ try {
85
+ acquireOperationLock(rootA, "sync");
86
+ } catch (e) {
87
+ const err = e as OperationLockedError;
88
+ expect(err.holder.command).toBe("rescue");
89
+ expect(err.holder.pid).toBe(1);
90
+ expect(err.message).toContain("rescue");
91
+ expect(err.message).toContain("pid 1");
92
+ }
93
+ });
94
+
95
+ it("reclaims a stale lock whose holder PID is dead (takeover)", () => {
96
+ const stale = deadPid();
97
+ writeLock(lockPathFor(rootA), { pid: stale, command: "sync" });
98
+ // The dead holder must not block us.
99
+ const h = acquireOperationLock(rootA, "rescue");
100
+ const info = JSON.parse(fs.readFileSync(h.path, "utf8")) as LockInfo;
101
+ expect(info.pid).toBe(process.pid); // we took it over
102
+ expect(info.command).toBe("rescue");
103
+ h.release();
104
+ });
105
+
106
+ it("reclaims a torn/unreadable lock file", () => {
107
+ const p = lockPathFor(rootA);
108
+ fs.mkdirSync(path.dirname(p), { recursive: true });
109
+ fs.writeFileSync(p, "{ this is not valid json");
110
+ const h = acquireOperationLock(rootA, "reindex");
111
+ expect(fs.existsSync(h.path)).toBe(true);
112
+ h.release();
113
+ });
114
+
115
+ it("different HQ roots are independent — both may hold concurrently", () => {
116
+ const a = acquireOperationLock(rootA, "sync");
117
+ const b = acquireOperationLock(rootB, "rescue"); // must NOT refuse
118
+ expect(fs.existsSync(a.path)).toBe(true);
119
+ expect(fs.existsSync(b.path)).toBe(true);
120
+ expect(a.path).not.toBe(b.path);
121
+ a.release();
122
+ b.release();
123
+ });
124
+
125
+ it("the same root is mutually exclusive across different commands", () => {
126
+ // A live sync in ANOTHER process holds the root (pid 1 stands in for it).
127
+ const p = lockPathFor(rootA);
128
+ writeLock(p, { pid: 1, command: "sync" });
129
+ // Neither rescue nor reindex may acquire while that sync holds it.
130
+ expect(() => acquireOperationLock(rootA, "rescue")).toThrowError(OperationLockedError);
131
+ expect(() => acquireOperationLock(rootA, "reindex")).toThrowError(OperationLockedError);
132
+ // Once that sync finishes (its lock is gone), the next command acquires.
133
+ fs.unlinkSync(p);
134
+ const h2 = acquireOperationLock(rootA, "reindex");
135
+ expect(fs.existsSync(h2.path)).toBe(true);
136
+ h2.release();
137
+ });
138
+
139
+ it("withOperationLockSync releases even when the body throws", () => {
140
+ const p = lockPathFor(rootA);
141
+ expect(() =>
142
+ withOperationLockSync(rootA, "reindex", () => {
143
+ expect(fs.existsSync(p)).toBe(true); // held during the body
144
+ throw new Error("boom");
145
+ }),
146
+ ).toThrow("boom");
147
+ expect(fs.existsSync(p)).toBe(false); // released on the way out
148
+ });
149
+
150
+ it("HQ_DISABLE_OP_LOCK=1 makes acquisition a no-op", () => {
151
+ process.env.HQ_DISABLE_OP_LOCK = "1";
152
+ // Even with a live holder on record, the escape hatch acquires without error.
153
+ writeLock(lockPathFor(rootA), { pid: process.pid, command: "sync" });
154
+ const h = acquireOperationLock(rootA, "rescue");
155
+ expect(h.path).toBe(""); // no real lock file written
156
+ h.release(); // no-op, no throw
157
+ });
158
+
159
+ it("OPERATION_LOCKED_EXIT is a stable non-zero code", () => {
160
+ expect(OPERATION_LOCKED_EXIT).toBe(17);
161
+ });
162
+ });