@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.
- package/dist/bin/sync-runner.d.ts +22 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +105 -3
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +262 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts +8 -0
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +222 -198
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +35 -0
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-core.js +14 -2
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-hq-root-guard.test.d.ts +2 -0
- package/dist/cli/rescue-hq-root-guard.test.d.ts.map +1 -0
- package/dist/cli/rescue-hq-root-guard.test.js +176 -0
- package/dist/cli/rescue-hq-root-guard.test.js.map +1 -0
- package/dist/cli/rescue.d.ts.map +1 -1
- package/dist/cli/rescue.js +39 -16
- package/dist/cli/rescue.js.map +1 -1
- package/dist/cli/rescue.reindex.test.js +15 -2
- package/dist/cli/rescue.reindex.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +3 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +2 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/operation-lock.d.ts +100 -0
- package/dist/operation-lock.d.ts.map +1 -0
- package/dist/operation-lock.js +256 -0
- package/dist/operation-lock.js.map +1 -0
- package/dist/operation-lock.test.d.ts +5 -0
- package/dist/operation-lock.test.d.ts.map +1 -0
- package/dist/operation-lock.test.js +140 -0
- package/dist/operation-lock.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +181 -0
- package/dist/sync/event-sync.d.ts.map +1 -0
- package/dist/sync/event-sync.js +316 -0
- package/dist/sync/event-sync.js.map +1 -0
- package/dist/sync/event-sync.test.d.ts +14 -0
- package/dist/sync/event-sync.test.d.ts.map +1 -0
- package/dist/sync/event-sync.test.js +440 -0
- package/dist/sync/event-sync.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +323 -0
- package/src/bin/sync-runner.ts +139 -4
- package/src/cli/reindex.test.ts +45 -0
- package/src/cli/reindex.ts +36 -0
- package/src/cli/rescue-core.ts +15 -2
- package/src/cli/rescue-hq-root-guard.test.ts +193 -0
- package/src/cli/rescue.reindex.test.ts +17 -2
- package/src/cli/rescue.ts +40 -15
- package/src/cli/sync.test.ts +2 -1
- package/src/cli/sync.ts +3 -1
- package/src/operation-lock.test.ts +162 -0
- package/src/operation-lock.ts +293 -0
- package/src/sync/event-sync.test.ts +533 -0
- package/src/sync/event-sync.ts +481 -0
- package/test/e2e/sync/cross-tenant-isolation.test.ts +126 -0
package/src/cli/rescue-core.ts
CHANGED
|
@@ -384,9 +384,22 @@ function doRescue(
|
|
|
384
384
|
hqRoot = fs.realpathSync(process.cwd());
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
}
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|