@indigoai-us/hq-cloud 6.2.6 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/bin/sync-runner.d.ts +8 -7
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +51 -9
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +74 -2
  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.d.ts.map +1 -1
  14. package/dist/cli/rescue.js +39 -16
  15. package/dist/cli/rescue.js.map +1 -1
  16. package/dist/cli/rescue.reindex.test.js +15 -2
  17. package/dist/cli/rescue.reindex.test.js.map +1 -1
  18. package/dist/cli/sync.d.ts.map +1 -1
  19. package/dist/cli/sync.js +3 -1
  20. package/dist/cli/sync.js.map +1 -1
  21. package/dist/cli/sync.test.js +2 -1
  22. package/dist/cli/sync.test.js.map +1 -1
  23. package/dist/operation-lock.d.ts +100 -0
  24. package/dist/operation-lock.d.ts.map +1 -0
  25. package/dist/operation-lock.js +256 -0
  26. package/dist/operation-lock.js.map +1 -0
  27. package/dist/operation-lock.test.d.ts +5 -0
  28. package/dist/operation-lock.test.d.ts.map +1 -0
  29. package/dist/operation-lock.test.js +140 -0
  30. package/dist/operation-lock.test.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/bin/sync-runner.test.ts +91 -2
  33. package/src/bin/sync-runner.ts +52 -9
  34. package/src/cli/reindex.test.ts +45 -0
  35. package/src/cli/reindex.ts +36 -0
  36. package/src/cli/rescue.reindex.test.ts +17 -2
  37. package/src/cli/rescue.ts +40 -15
  38. package/src/cli/sync.test.ts +2 -1
  39. package/src/cli/sync.ts +3 -1
  40. package/src/operation-lock.test.ts +162 -0
  41. package/src/operation-lock.ts +293 -0
@@ -13,16 +13,24 @@ import * as fs from "fs";
13
13
  import * as path from "path";
14
14
  import * as os from "os";
15
15
  import { reindex } from "./reindex.js";
16
+ import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
16
17
 
17
18
  describe("reindex", () => {
18
19
  let root: string;
20
+ let stateDir: string;
19
21
 
20
22
  beforeEach(() => {
21
23
  root = fs.mkdtempSync(path.join(os.tmpdir(), "ms-test-"));
24
+ // Redirect the operation lock into a throwaway dir so reindex's default
25
+ // locking never touches the real ~/.hq during tests.
26
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "ms-state-"));
27
+ process.env.HQ_STATE_DIR = stateDir;
22
28
  });
23
29
 
24
30
  afterEach(() => {
25
31
  fs.rmSync(root, { recursive: true, force: true });
32
+ fs.rmSync(stateDir, { recursive: true, force: true });
33
+ delete process.env.HQ_STATE_DIR;
26
34
  });
27
35
 
28
36
  function writeSkill(rel: string): void {
@@ -124,4 +132,41 @@ describe("reindex", () => {
124
132
  expect(fs.existsSync(wrapper)).toBe(true);
125
133
  expect(fs.lstatSync(path.join(wrapper, "SKILL.md")).isSymbolicLink()).toBe(true);
126
134
  });
135
+
136
+ // ── operation lock (mutual exclusion with sync/rescue) ──────────────────
137
+
138
+ it("refuses (OPERATION_LOCKED_EXIT) when another op holds this root's lock", () => {
139
+ // A live holder in another process (pid 1) → reindex must refuse fast and
140
+ // do no work.
141
+ const lp = lockPathFor(root);
142
+ fs.mkdirSync(path.dirname(lp), { recursive: true });
143
+ fs.writeFileSync(
144
+ lp,
145
+ JSON.stringify({ pid: 1, command: "sync", startedAt: new Date(0).toISOString(), hqRoot: root }),
146
+ );
147
+ const before = fs.existsSync(path.join(root, ".claude/skills"));
148
+ const { status } = reindex({ repoRoot: root });
149
+ expect(status).toBe(OPERATION_LOCKED_EXIT);
150
+ // It refused before doing any work (didn't create .claude/skills).
151
+ expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(before);
152
+ });
153
+
154
+ it("skipLock bypasses the lock (internal sync/rescue caller path)", () => {
155
+ const lp = lockPathFor(root);
156
+ fs.mkdirSync(path.dirname(lp), { recursive: true });
157
+ fs.writeFileSync(
158
+ lp,
159
+ JSON.stringify({ pid: 1, command: "sync", startedAt: new Date(0).toISOString(), hqRoot: root }),
160
+ );
161
+ // Even with a live holder on record, the internal caller (which already
162
+ // holds the lock) runs to completion.
163
+ const { status } = reindex({ repoRoot: root, skipLock: true });
164
+ expect(status).toBe(0);
165
+ expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(true);
166
+ });
167
+
168
+ it("releases the lock after a normal run (no leftover lock file)", () => {
169
+ reindex({ repoRoot: root });
170
+ expect(fs.existsSync(lockPathFor(root))).toBe(false);
171
+ });
127
172
  });
@@ -17,10 +17,24 @@
17
17
  import { spawnSync } from "child_process";
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
20
+ import {
21
+ acquireOperationLock,
22
+ OperationLockedError,
23
+ OPERATION_LOCKED_EXIT,
24
+ type LockHandle,
25
+ } from "../operation-lock.js";
20
26
 
21
27
  export interface ReindexOptions {
22
28
  /** HQ root to operate on. Defaults to process.cwd(). */
23
29
  repoRoot?: string;
30
+ /**
31
+ * Skip the per-root operation lock. Internal callers (`sync()` / `rescue()`)
32
+ * already hold the lock for this root and pass `true` so reindex doesn't try
33
+ * to re-acquire it and refuse against their own live PID. Standalone callers
34
+ * (`hq reindex`, the reindex hook) leave it falsy so reindex is mutually
35
+ * exclusive with a running sync/rescue.
36
+ */
37
+ skipLock?: boolean;
24
38
  }
25
39
 
26
40
  export interface ReindexResult {
@@ -129,6 +143,25 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
129
143
  }
130
144
  const root = path.resolve(rawRoot);
131
145
 
146
+ // Acquire the per-root operation lock unless an internal caller (sync/rescue,
147
+ // which already hold it) opted out. A live holder → refuse fast with the
148
+ // holder's command + PID. The whole body runs inside the try so the lock is
149
+ // released on every exit path (the process-level signal/exit hooks are the
150
+ // crash backstop).
151
+ let opLock: LockHandle | null = null;
152
+ if (!opts.skipLock) {
153
+ try {
154
+ opLock = acquireOperationLock(root, "reindex");
155
+ } catch (err) {
156
+ if (err instanceof OperationLockedError) {
157
+ warn(err.message);
158
+ return { status: OPERATION_LOCKED_EXIT };
159
+ }
160
+ throw err;
161
+ }
162
+ }
163
+ try {
164
+
132
165
  fs.mkdirSync(path.join(root, ".claude", "skills"), { recursive: true });
133
166
 
134
167
  // --- Build (namespace, src_rel) pairs -------------------------------------
@@ -363,4 +396,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
363
396
  }
364
397
 
365
398
  return { status: 0 };
399
+ } finally {
400
+ opLock?.release();
401
+ }
366
402
  }
@@ -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
+ });