@indigoai-us/hq-cloud 6.14.0 → 6.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner-company.d.ts +4 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +50 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +3 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +21 -7
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +6 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +162 -11
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +155 -17
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/accept.d.ts.map +1 -1
- package/dist/cli/accept.js +29 -0
- package/dist/cli/accept.js.map +1 -1
- package/dist/cli/invite.d.ts.map +1 -1
- package/dist/cli/invite.js +33 -0
- package/dist/cli/invite.js.map +1 -1
- package/dist/cli/promote.d.ts.map +1 -1
- package/dist/cli/promote.js +33 -0
- package/dist/cli/promote.js.map +1 -1
- package/dist/cli/reindex.d.ts +23 -11
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +39 -10
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +40 -13
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/share.js +47 -4
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +123 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +84 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +44 -3
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +156 -7
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +133 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/company-resolver.d.ts +21 -0
- package/dist/company-resolver.d.ts.map +1 -1
- package/dist/company-resolver.js +31 -6
- package/dist/company-resolver.js.map +1 -1
- package/dist/company-resolver.test.js +44 -1
- package/dist/company-resolver.test.js.map +1 -1
- package/dist/ignore.d.ts +10 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +23 -2
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +13 -1
- package/dist/ignore.test.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +12 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +12 -0
- package/dist/journal.js.map +1 -1
- package/dist/lib/exit-codes.d.ts +25 -0
- package/dist/lib/exit-codes.d.ts.map +1 -0
- package/dist/lib/exit-codes.js +25 -0
- package/dist/lib/exit-codes.js.map +1 -0
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +45 -14
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +61 -1
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +36 -6
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +34 -11
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +30 -0
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/s3.d.ts +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +75 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +56 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +12 -0
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +12 -0
- package/dist/scope-shrink.js.map +1 -1
- package/dist/scope-shrink.test.js +31 -0
- package/dist/scope-shrink.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +41 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +111 -7
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +187 -5
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +3 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +23 -0
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +21 -0
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +46 -13
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/telemetry-events.d.ts +31 -0
- package/dist/telemetry-events.d.ts.map +1 -0
- package/dist/telemetry-events.js +140 -0
- package/dist/telemetry-events.js.map +1 -0
- package/dist/telemetry-events.test.d.ts +2 -0
- package/dist/telemetry-events.test.d.ts.map +1 -0
- package/dist/telemetry-events.test.js +73 -0
- package/dist/telemetry-events.test.js.map +1 -0
- package/dist/telemetry.js +1 -1
- package/dist/telemetry.js.map +1 -1
- package/dist/vault-client.d.ts +38 -5
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +69 -6
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +31 -1
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +53 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +57 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +2 -0
- package/scripts/presign-transport-e2e.mjs +2 -2
- package/src/bin/sync-runner-company.ts +65 -1
- package/src/bin/sync-runner-watch-loop.ts +24 -8
- package/src/bin/sync-runner.test.ts +194 -16
- package/src/bin/sync-runner.ts +209 -11
- package/src/cli/accept.ts +27 -0
- package/src/cli/invite.ts +31 -0
- package/src/cli/promote.ts +31 -0
- package/src/cli/reindex.test.ts +44 -13
- package/src/cli/reindex.ts +74 -21
- package/src/cli/share.test.ts +139 -2
- package/src/cli/share.ts +48 -2
- package/src/cli/sync-scope.test.ts +90 -0
- package/src/cli/sync.test.ts +154 -0
- package/src/cli/sync.ts +224 -5
- package/src/company-resolver.test.ts +51 -0
- package/src/company-resolver.ts +40 -5
- package/src/ignore.test.ts +18 -1
- package/src/ignore.ts +24 -2
- package/src/index.ts +22 -1
- package/src/journal.ts +12 -0
- package/src/lib/exit-codes.ts +24 -0
- package/src/object-io.test.ts +66 -1
- package/src/object-io.ts +51 -14
- package/src/operation-lock.test.ts +33 -0
- package/src/operation-lock.ts +46 -11
- package/src/s3.test.ts +87 -0
- package/src/s3.ts +93 -2
- package/src/scope-shrink.test.ts +32 -0
- package/src/scope-shrink.ts +12 -0
- package/src/skill-telemetry.test.ts +247 -4
- package/src/skill-telemetry.ts +134 -6
- package/src/sync/event-sync.ts +37 -0
- package/src/sync/pull-scope.ts +62 -13
- package/src/telemetry-events.test.ts +88 -0
- package/src/telemetry-events.ts +205 -0
- package/src/telemetry.ts +1 -1
- package/src/vault-client.test.ts +39 -1
- package/src/vault-client.ts +115 -14
- package/src/watcher.test.ts +82 -0
- package/src/watcher.ts +69 -0
|
@@ -11,6 +11,7 @@ import * as path from "path";
|
|
|
11
11
|
import {
|
|
12
12
|
buildRepoCompanyMap,
|
|
13
13
|
resolveCompanyForCwd,
|
|
14
|
+
resolveCompanyForSkill,
|
|
14
15
|
} from "./company-resolver.js";
|
|
15
16
|
|
|
16
17
|
const INDIGO = "cmp_01INDIGO";
|
|
@@ -132,5 +133,55 @@ describe("buildRepoCompanyMap + resolveCompanyForCwd", () => {
|
|
|
132
133
|
hqRoot = setupHqRoot(":\n not: [valid: yaml");
|
|
133
134
|
const map = await buildRepoCompanyMap(hqRoot);
|
|
134
135
|
expect(map.entries).toHaveLength(0);
|
|
136
|
+
expect(map.bySlug.size).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("resolveCompanyForSkill", () => {
|
|
141
|
+
let hqRoot: string;
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
if (hqRoot) fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("attributes a company-namespaced skill to its company, regardless of cwd", async () => {
|
|
147
|
+
hqRoot = setupHqRoot();
|
|
148
|
+
const map = await buildRepoCompanyMap(hqRoot);
|
|
149
|
+
// The slug map is populated from the manifest keys with a cloud_uid.
|
|
150
|
+
expect(resolveCompanyForSkill("indigo:signals", map)).toBe(INDIGO);
|
|
151
|
+
expect(resolveCompanyForSkill("hpo:some-skill", map)).toBe(HPO);
|
|
152
|
+
// Multi-segment skill name: only the leading slug is the company.
|
|
153
|
+
expect(resolveCompanyForSkill("indigo:hq-dev-thread", map)).toBe(INDIGO);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns undefined for a core (un-namespaced) skill", async () => {
|
|
157
|
+
hqRoot = setupHqRoot();
|
|
158
|
+
const map = await buildRepoCompanyMap(hqRoot);
|
|
159
|
+
expect(resolveCompanyForSkill("startwork", map)).toBeUndefined();
|
|
160
|
+
expect(resolveCompanyForSkill("review", map)).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("returns undefined for a non-company namespace (personal/pack) and a leading colon", async () => {
|
|
164
|
+
hqRoot = setupHqRoot();
|
|
165
|
+
const map = await buildRepoCompanyMap(hqRoot);
|
|
166
|
+
// `personal` has no cloud_uid → not in the slug map.
|
|
167
|
+
expect(resolveCompanyForSkill("personal:startwork", map)).toBeUndefined();
|
|
168
|
+
expect(
|
|
169
|
+
resolveCompanyForSkill("hq-pack-engineering:review", map),
|
|
170
|
+
).toBeUndefined();
|
|
171
|
+
expect(resolveCompanyForSkill(":oops", map)).toBeUndefined();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("returns undefined for a slug whose company is not cloud-backed", async () => {
|
|
175
|
+
hqRoot = setupHqRoot();
|
|
176
|
+
const map = await buildRepoCompanyMap(hqRoot);
|
|
177
|
+
// `liverecover` is in the manifest but carries no cloud_uid.
|
|
178
|
+
expect(resolveCompanyForSkill("liverecover:foo", map)).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("handles a missing/undefined skill safely", async () => {
|
|
182
|
+
hqRoot = setupHqRoot();
|
|
183
|
+
const map = await buildRepoCompanyMap(hqRoot);
|
|
184
|
+
expect(resolveCompanyForSkill(undefined, map)).toBeUndefined();
|
|
185
|
+
expect(resolveCompanyForSkill("", map)).toBeUndefined();
|
|
135
186
|
});
|
|
136
187
|
});
|
package/src/company-resolver.ts
CHANGED
|
@@ -54,6 +54,14 @@ import yaml from "js-yaml";
|
|
|
54
54
|
export interface RepoCompanyMap {
|
|
55
55
|
/** Absolute, normalized repo root → owning company `cmp_*` uid. */
|
|
56
56
|
entries: Array<{ repoRoot: string; companyUid: string }>;
|
|
57
|
+
/**
|
|
58
|
+
* Company SLUG → owning company `cmp_*` uid, from the manifest keys. Backs
|
|
59
|
+
* skill-based attribution: a company skill is invoked as `<slug>:<skill>`
|
|
60
|
+
* (mirroring `companies/<slug>/skills/`), so its usage belongs to the company
|
|
61
|
+
* named by that slug regardless of the cwd it ran in. Manifest keys are unique,
|
|
62
|
+
* so a slug resolves to exactly one cloud-backed company here.
|
|
63
|
+
*/
|
|
64
|
+
bySlug: Map<string, string>;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
interface ManifestCompany {
|
|
@@ -87,23 +95,28 @@ export async function buildRepoCompanyMap(hqRoot: string): Promise<RepoCompanyMa
|
|
|
87
95
|
const raw = await fs.readFile(manifestPath, "utf-8");
|
|
88
96
|
const parsed = yaml.load(raw);
|
|
89
97
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
90
|
-
return { entries: [] };
|
|
98
|
+
return { entries: [], bySlug: new Map() };
|
|
91
99
|
}
|
|
92
100
|
doc = parsed as ManifestShape;
|
|
93
101
|
} catch {
|
|
94
102
|
// Missing / unreadable / unparseable — no attribution this run.
|
|
95
|
-
return { entries: [] };
|
|
103
|
+
return { entries: [], bySlug: new Map() };
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
const companies = doc.companies;
|
|
99
|
-
if (!companies || typeof companies !== "object")
|
|
107
|
+
if (!companies || typeof companies !== "object") {
|
|
108
|
+
return { entries: [], bySlug: new Map() };
|
|
109
|
+
}
|
|
100
110
|
|
|
101
111
|
const entries: Array<{ repoRoot: string; companyUid: string }> = [];
|
|
102
|
-
|
|
112
|
+
const bySlug = new Map<string, string>();
|
|
113
|
+
for (const [slug, company] of Object.entries(companies)) {
|
|
103
114
|
if (!company || typeof company !== "object") continue;
|
|
104
115
|
const cloudUid = company.cloud_uid;
|
|
105
116
|
// Only cloud-backed companies (with a cmp_* uid) attribute events.
|
|
106
117
|
if (typeof cloudUid !== "string" || !cloudUid.startsWith("cmp_")) continue;
|
|
118
|
+
// Slug → uid for skill-based attribution (a `<slug>:<skill>` invocation).
|
|
119
|
+
if (typeof slug === "string" && slug.length > 0) bySlug.set(slug, cloudUid);
|
|
107
120
|
const repos = company.repos;
|
|
108
121
|
if (!Array.isArray(repos)) continue;
|
|
109
122
|
for (const repo of repos) {
|
|
@@ -118,7 +131,29 @@ export async function buildRepoCompanyMap(hqRoot: string): Promise<RepoCompanyMa
|
|
|
118
131
|
// Longest repoRoot first so the first containment match is the most specific
|
|
119
132
|
// (handles nested repo layouts where one repo path prefixes another).
|
|
120
133
|
entries.sort((a, b) => b.repoRoot.length - a.repoRoot.length);
|
|
121
|
-
return { entries };
|
|
134
|
+
return { entries, bySlug };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve a skill INVOCATION to its owning company's `cmp_*` uid, or `undefined`
|
|
139
|
+
* when the skill is not a cloud-backed company's skill.
|
|
140
|
+
*
|
|
141
|
+
* A company skill is invoked as `<slug>:<skill>` (the namespaced form of
|
|
142
|
+
* `companies/<slug>/skills/<skill>`); the leading `<slug>` names the owning
|
|
143
|
+
* company. We look that slug up in the manifest's slug→uid map, so a company
|
|
144
|
+
* skill attributes to ITS company no matter which cwd it ran in — this is what
|
|
145
|
+
* surfaces company-skill usage in the company console. A core / `personal:` /
|
|
146
|
+
* `hq-pack-*:` / `anthropic-skills:` skill has no matching manifest company, so
|
|
147
|
+
* it resolves to `undefined` (the cwd resolver remains the fallback).
|
|
148
|
+
*/
|
|
149
|
+
export function resolveCompanyForSkill(
|
|
150
|
+
skill: string | undefined,
|
|
151
|
+
map: RepoCompanyMap,
|
|
152
|
+
): string | undefined {
|
|
153
|
+
if (typeof skill !== "string") return undefined;
|
|
154
|
+
const idx = skill.indexOf(":");
|
|
155
|
+
if (idx <= 0) return undefined; // un-namespaced (core) skill
|
|
156
|
+
return map.bySlug.get(skill.slice(0, idx));
|
|
122
157
|
}
|
|
123
158
|
|
|
124
159
|
/**
|
package/src/ignore.test.ts
CHANGED
|
@@ -29,6 +29,8 @@ describe("isExpectedIgnore", () => {
|
|
|
29
29
|
expect(isExpectedIgnore("x/.env")).toBe(true);
|
|
30
30
|
expect(isExpectedIgnore("a/b/.hq-sync.pid")).toBe(true);
|
|
31
31
|
expect(isExpectedIgnore("y/foo.pyc")).toBe(true);
|
|
32
|
+
// Cloud-only skill hero images (US-014) are an expected by-design exclusion.
|
|
33
|
+
expect(isExpectedIgnore(".claude/skills/foo/.skill-media/hero.png")).toBe(true);
|
|
32
34
|
});
|
|
33
35
|
|
|
34
36
|
it("keeps nested repos/workspace and ordinary content noteworthy", () => {
|
|
@@ -138,7 +140,7 @@ describe("createIgnoreFilter", () => {
|
|
|
138
140
|
|
|
139
141
|
it("v12–v14 layout (no core/core.yaml): root core.yaml IS the manifest and MUST sync", () => {
|
|
140
142
|
// Regression for DEV-1729 / the ~98 "no core.yaml in vault, version
|
|
141
|
-
// undetectable"
|
|
143
|
+
// undetectable" sync users. On v12–v14 roots there is NO `core/core.yaml`
|
|
142
144
|
// — the ROOT `core.yaml` is the scaffold/version manifest. The old
|
|
143
145
|
// unconditional `/core.yaml` exclusion silently dropped it (while
|
|
144
146
|
// computePersonalVaultPaths still enumerated it), so the vault never
|
|
@@ -181,6 +183,21 @@ describe("createIgnoreFilter", () => {
|
|
|
181
183
|
expect(shouldSync(path.join(hqRoot, ".claude/skills/foo/SKILL.md"))).toBe(true);
|
|
182
184
|
});
|
|
183
185
|
|
|
186
|
+
it("permissive mode: .skill-media/ (cloud-only skill images) is ignored (US-014)", () => {
|
|
187
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
188
|
+
// Skill hero images are cloud-only — excluded from push AND pull at any depth.
|
|
189
|
+
expect(
|
|
190
|
+
shouldSync(path.join(hqRoot, ".claude/skills/lead-qualifier/.skill-media/hero.png")),
|
|
191
|
+
).toBe(false);
|
|
192
|
+
expect(
|
|
193
|
+
shouldSync(path.join(hqRoot, "companies/indigo/skills/foo/.skill-media/cover.jpg")),
|
|
194
|
+
).toBe(false);
|
|
195
|
+
expect(shouldSync(path.join(hqRoot, "skills/bar/.skill-media"), true)).toBe(false);
|
|
196
|
+
// The skill's SKILL.md and other content still sync — the rule is scoped to
|
|
197
|
+
// the media dir only, never the skill.
|
|
198
|
+
expect(shouldSync(path.join(hqRoot, ".claude/skills/lead-qualifier/SKILL.md"))).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
184
201
|
it("permissive mode: .hq-* internal state is ignored, .hqignore family still sync", () => {
|
|
185
202
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
186
203
|
// Internal state files that must never round-trip through the bucket.
|
package/src/ignore.ts
CHANGED
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
* three exclusion layers still subtract on top — so even allowlisted
|
|
19
19
|
* subtrees won't push `node_modules/` or `.env`. Privacy-by-default for
|
|
20
20
|
* HQ trees that contain mixed personal + shareable data.
|
|
21
|
+
*
|
|
22
|
+
* SESSIONS ARE PUSH-ONLY, NOT PUSH-IGNORED (US-006). The company-vault
|
|
23
|
+
* `sessions/{personUid}/...` prefix (session transcripts) is intentionally NOT
|
|
24
|
+
* added to `DEFAULT_IGNORES`: push-only means it MUST still upload on the push
|
|
25
|
+
* walk. Its never-AUTO-pull semantics are enforced ONE layer over, in the pull
|
|
26
|
+
* scope resolver — `resolvePullScope().excludePrefixes` (src/sync/pull-scope.ts)
|
|
27
|
+
* subtracts `sessions/` from the effective pull scope in EVERY mode (including
|
|
28
|
+
* `all`), and `computePullPlan` classifies those keys `skip-out-of-scope`.
|
|
29
|
+
* Ignoring `sessions/` HERE would wrongly block the upload too, so the split is
|
|
30
|
+
* deliberate: this module governs the push walk; pull-scope governs never-pull.
|
|
21
31
|
*/
|
|
22
32
|
|
|
23
33
|
import * as fs from "fs";
|
|
@@ -93,7 +103,7 @@ export const DEFAULT_IGNORES = [
|
|
|
93
103
|
// `core.yaml` is a stale per-machine duplicate that must not round-trip; on
|
|
94
104
|
// v12–v14 roots there is NO `core/core.yaml` and the ROOT `core.yaml` IS the
|
|
95
105
|
// scaffold/version manifest, which MUST sync (else the vault has no version
|
|
96
|
-
// marker and the file is undetectable
|
|
106
|
+
// marker and the file is undetectable across user installs).
|
|
97
107
|
// hq modules manifest — local module-resolution state, never synced.
|
|
98
108
|
"modules/modules.yaml",
|
|
99
109
|
// per-company identity file — written locally on first sync, never round-tripped.
|
|
@@ -130,6 +140,16 @@ export const DEFAULT_IGNORES = [
|
|
|
130
140
|
"**/.claude/settings.json",
|
|
131
141
|
"**/.claude/settings.local.json",
|
|
132
142
|
|
|
143
|
+
// Skill hero images (skills-first-class US-014) — cloud-only artifacts stored
|
|
144
|
+
// under a skill's `.skill-media/` dir (e.g. `skills/<name>/.skill-media/hero.png`).
|
|
145
|
+
// They live in the vault/DB and are surfaced by the console, but must NEVER
|
|
146
|
+
// round-trip through the file sync onto agent machines: agents run the skill
|
|
147
|
+
// from SKILL.md and have no use for the image, and pulling it would litter
|
|
148
|
+
// every skill dir with binary blobs. Anchored `**/` so it matches at any depth
|
|
149
|
+
// (hqRoot skills, company skills, embedded knowledge repos) — excludes them
|
|
150
|
+
// from BOTH push and pull. Mirrors the `**/.claude/worktrees/` anchor shape.
|
|
151
|
+
"**/.skill-media/",
|
|
152
|
+
|
|
133
153
|
// HQ repos directory (managed separately, not synced). Root-anchored so
|
|
134
154
|
// only hqRoot/repos/ is excluded; bare `repos/` would match any nested
|
|
135
155
|
// directory named repos (for example companies/<co>/knowledge/repos/),
|
|
@@ -247,6 +267,8 @@ export const EXPECTED_IGNORE_SEGMENTS = new Set([
|
|
|
247
267
|
".tmp",
|
|
248
268
|
".hq",
|
|
249
269
|
".claude",
|
|
270
|
+
// Cloud-only skill hero images (US-014) — never synced onto agent machines.
|
|
271
|
+
".skill-media",
|
|
250
272
|
]);
|
|
251
273
|
|
|
252
274
|
/**
|
|
@@ -310,7 +332,7 @@ export function createIgnoreFilter(
|
|
|
310
332
|
// stale/duplicate per-machine marker, never the authoritative manifest.
|
|
311
333
|
// - v12–v14 (core/core.yaml absent): do NOT exclude root `core.yaml` — it
|
|
312
334
|
// IS the manifest and must round-trip, else the file is undetectable
|
|
313
|
-
//
|
|
335
|
+
// across user installs (the ~98 "no core.yaml in vault" users were all old-layout).
|
|
314
336
|
// Root-anchored (`/core.yaml`) so this never affects `core/core.yaml`.
|
|
315
337
|
if (fs.existsSync(path.join(hqRoot, "core", "core.yaml"))) {
|
|
316
338
|
ig.add("/core.yaml");
|
package/src/index.ts
CHANGED
|
@@ -152,7 +152,11 @@ export type {
|
|
|
152
152
|
// the SAME effective scope the menubar runner does, instead of defaulting every
|
|
153
153
|
// CLI pull to `syncMode: "all"` (the seed of the all→shared scope-shrink wedge,
|
|
154
154
|
// DEV-1768).
|
|
155
|
-
export {
|
|
155
|
+
export {
|
|
156
|
+
resolvePullScope,
|
|
157
|
+
readPinnedPrefixes,
|
|
158
|
+
SESSIONS_SCOPE_PREFIX,
|
|
159
|
+
} from "./sync/pull-scope.js";
|
|
156
160
|
export type { PullScope, PullScopeClient } from "./sync/pull-scope.js";
|
|
157
161
|
|
|
158
162
|
// Personal-vault scope helpers — shared between hq-sync-runner and `hq sync`
|
|
@@ -200,6 +204,9 @@ export type {
|
|
|
200
204
|
TelemetryOptInResponse,
|
|
201
205
|
UsageBatch,
|
|
202
206
|
UsageIngestResult,
|
|
207
|
+
RawTelemetryEventInput,
|
|
208
|
+
TelemetryEventsBatch,
|
|
209
|
+
TelemetryEventsIngestResult,
|
|
203
210
|
SkillInvocationBatch,
|
|
204
211
|
SkillInvocationIngestResult,
|
|
205
212
|
// US-004 — browse-vs-sync membership sync-mode + ACL surface
|
|
@@ -219,6 +226,20 @@ export type {
|
|
|
219
226
|
TelemetryClientSurface,
|
|
220
227
|
} from "./telemetry.js";
|
|
221
228
|
|
|
229
|
+
// hq-cloud ACTION telemetry (`/v1/telemetry/events`). Unlike token/skill
|
|
230
|
+
// collectors, these sync/CLI events are not gated by `/v1/usage` opt-in.
|
|
231
|
+
export {
|
|
232
|
+
agentUidFromClaims,
|
|
233
|
+
emitCloudTelemetry,
|
|
234
|
+
} from "./telemetry-events.js";
|
|
235
|
+
export type {
|
|
236
|
+
CloudTelemetryClient,
|
|
237
|
+
EmitCloudTelemetryOptions,
|
|
238
|
+
TelemetryClaims,
|
|
239
|
+
TelemetryEvent,
|
|
240
|
+
TelemetryProperties,
|
|
241
|
+
} from "./telemetry-events.js";
|
|
242
|
+
|
|
222
243
|
// Skill-invocation telemetry collector (`/v1/skill-invocations`). Reads the
|
|
223
244
|
// same Claude Code session logs as the token collector but extracts which
|
|
224
245
|
// skill / slash-command was invoked. Independent cursor; same opt-in gate.
|
package/src/journal.ts
CHANGED
|
@@ -404,6 +404,18 @@ export function hashSymlinkTarget(target: string): string {
|
|
|
404
404
|
.digest("hex");
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Record a per-file journal entry after a transfer.
|
|
409
|
+
*
|
|
410
|
+
* `direction` (`"up"` = pushed / locally authored, `"down"` = pulled) is what
|
|
411
|
+
* lets scope-shrink tell your own work apart from a mirror of someone else's.
|
|
412
|
+
* It underpins the US-006 push-only sessions contract: a session transcript
|
|
413
|
+
* this machine authored and pushed is stamped `direction:"up"`, so even though
|
|
414
|
+
* `sessions/` is excluded from every pull scope, `buildScopeShrinkPlan` skips
|
|
415
|
+
* `direction:"up"` entries and never orphans it. (Sessions fetched on demand
|
|
416
|
+
* via `hq files get` are pulled — `direction:"down"` — but ride the pin union
|
|
417
|
+
* in the caller's inclusion prefixSet, so they are likewise never pruned.)
|
|
418
|
+
*/
|
|
407
419
|
export function updateEntry(
|
|
408
420
|
journal: SyncJournal,
|
|
409
421
|
relativePath: string,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner exit code for a partial sync pass: the pass ran to completion but one
|
|
3
|
+
* or more per-file transfers threw (`errors.length > 0` in the all-complete
|
|
4
|
+
* aggregate). Deterministic per-file failures — an oversized object on a
|
|
5
|
+
* pre-skip runner, a presign denial, an expired token late in a long pass, a
|
|
6
|
+
* corrupt doubly-scoped vault key — all land here.
|
|
7
|
+
*
|
|
8
|
+
* A one-shot `hq sync` exits with this code so humans and scripts see the
|
|
9
|
+
* failure. The watch loop must NOT exit on it: auto-sync runs unattended, and
|
|
10
|
+
* a watcher that dies on every partial pass gets hot-respawned by the menubar
|
|
11
|
+
* supervisor, which Sentry-captures each death as "auto-sync watcher exited
|
|
12
|
+
* unexpectedly (code=Some(2))". Because the watcher typically runs healthily
|
|
13
|
+
* for minutes before the failing pass, the supervisor's fast-crash-loop
|
|
14
|
+
* dampening never engages and EVERY death ships an event — the July 2026
|
|
15
|
+
* #hq-sentry-alerts flood (~1,700 events across 60+ machines in 5 days).
|
|
16
|
+
* Instead the loop logs, stays alive, and re-attempts on the next tick; the
|
|
17
|
+
* per-file errors still surface through the ndjson `error` events and the
|
|
18
|
+
* `partial: true` all-complete aggregate.
|
|
19
|
+
*
|
|
20
|
+
* Kept off the other codes in use by the runner: 0 ok, 1 hard error, 17
|
|
21
|
+
* op-locked (`OPERATION_LOCKED_EXIT`), 75 transient network
|
|
22
|
+
* (`TRANSIENT_NETWORK_EXIT`).
|
|
23
|
+
*/
|
|
24
|
+
export const PARTIAL_SYNC_EXIT = 2;
|
package/src/object-io.test.ts
CHANGED
|
@@ -234,13 +234,78 @@ describe("PresignObjectIO.putObject", () => {
|
|
|
234
234
|
// per-path skip (scopeExcludedSet) + the "N paths skipped — outside your
|
|
235
235
|
// granted access" aggregate, instead of a fatal `type:"error"` that exits
|
|
236
236
|
// the runner non-zero and floods Sentry. This is the PUT/GET symmetry that
|
|
237
|
-
// fixes the
|
|
237
|
+
// fixes the presign-put-denied crash-loop across user machines.
|
|
238
238
|
expect((caught as Error).name).toBe("Forbidden");
|
|
239
239
|
expect(isAccessDenied(caught)).toBe(true);
|
|
240
240
|
// Denied at mint time — the byte-moving PUT is never attempted.
|
|
241
241
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
242
242
|
});
|
|
243
243
|
|
|
244
|
+
it("US-006 AC5: a transcript-consent denial (FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED) is skip-with-log, not fatal", async () => {
|
|
245
|
+
const { vault, setPresign } = makeVault();
|
|
246
|
+
// The US-005 consent code ends `_REQUIRED`, not `_FORBIDDEN`, so the plain
|
|
247
|
+
// suffix test alone would MISS it and let it fall through to the fatal
|
|
248
|
+
// generic Error — the recurring partial-failure this AC5 fix prevents.
|
|
249
|
+
setPresign([
|
|
250
|
+
{
|
|
251
|
+
key: "sessions/prs_1/2026-07-11T10-00-00.json",
|
|
252
|
+
op: "get",
|
|
253
|
+
error: "transcript consent required",
|
|
254
|
+
code: "FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED",
|
|
255
|
+
},
|
|
256
|
+
]);
|
|
257
|
+
const io = new PresignObjectIO(vault, COMPANY);
|
|
258
|
+
// Monkeypatch console.warn (vitest's console interception can shadow a spy)
|
|
259
|
+
// to assert the REAL skip log fires.
|
|
260
|
+
const warnings: string[] = [];
|
|
261
|
+
const originalWarn = console.warn;
|
|
262
|
+
console.warn = (...args: unknown[]) => {
|
|
263
|
+
warnings.push(args.map(String).join(" "));
|
|
264
|
+
};
|
|
265
|
+
let caught: unknown;
|
|
266
|
+
try {
|
|
267
|
+
await io
|
|
268
|
+
.getObject("sessions/prs_1/2026-07-11T10-00-00.json")
|
|
269
|
+
.catch((e) => {
|
|
270
|
+
caught = e;
|
|
271
|
+
});
|
|
272
|
+
} finally {
|
|
273
|
+
console.warn = originalWarn;
|
|
274
|
+
}
|
|
275
|
+
// Same shape a denied GET already throws → isAccessDenied → the per-key
|
|
276
|
+
// scope-excluded skip, NOT a fatal `type:"error"` that poisons the run.
|
|
277
|
+
expect((caught as Error).name).toBe("Forbidden");
|
|
278
|
+
expect(isAccessDenied(caught)).toBe(true);
|
|
279
|
+
// A one-line skip log fires so operators can see WHY the key was skipped.
|
|
280
|
+
expect(
|
|
281
|
+
warnings.some((w) =>
|
|
282
|
+
w.includes("FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED"),
|
|
283
|
+
),
|
|
284
|
+
).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("US-006 AC5: a sessions structural-ACL denial (FILES_PRESIGN_SESSIONS_FORBIDDEN) is skip-with-log", async () => {
|
|
288
|
+
const { vault, setPresign } = makeVault();
|
|
289
|
+
// A spoofed/foreign `sessions/{uid}/` prefix the hq-pro structural gate
|
|
290
|
+
// refuses to presign. Ends `_FORBIDDEN`, so the suffix test already covers
|
|
291
|
+
// it — this pins that coverage as a regression contract for US-006.
|
|
292
|
+
setPresign([
|
|
293
|
+
{
|
|
294
|
+
key: "sessions/prs_other/y.json",
|
|
295
|
+
op: "get",
|
|
296
|
+
error: "not your session prefix",
|
|
297
|
+
code: "FILES_PRESIGN_SESSIONS_FORBIDDEN",
|
|
298
|
+
},
|
|
299
|
+
]);
|
|
300
|
+
const io = new PresignObjectIO(vault, COMPANY);
|
|
301
|
+
let caught: unknown;
|
|
302
|
+
await io.getObject("sessions/prs_other/y.json").catch((e) => {
|
|
303
|
+
caught = e;
|
|
304
|
+
});
|
|
305
|
+
expect((caught as Error).name).toBe("Forbidden");
|
|
306
|
+
expect(isAccessDenied(caught)).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
244
309
|
it("a non-authorization presign failure stays a generic (fatal) error — not silently skipped", async () => {
|
|
245
310
|
const { vault, setPresign } = makeVault();
|
|
246
311
|
setPresign([
|
package/src/object-io.ts
CHANGED
|
@@ -344,6 +344,35 @@ function metaFromHeaders(headers: Headers): Record<string, string> {
|
|
|
344
344
|
return meta;
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Per-key presign denial codes that are EXPECTED and must be reclassified as
|
|
349
|
+
* skip-with-log — throwing the `name: "Forbidden"` shape (see
|
|
350
|
+
* {@link accessDeniedError}) so `isAccessDenied` in sync.ts / share.ts routes
|
|
351
|
+
* them into the silent per-key scope-excluded skip, never a FATAL sync error.
|
|
352
|
+
*
|
|
353
|
+
* Two families qualify:
|
|
354
|
+
* 1. Any `*_FORBIDDEN` code — an authorization/ACL denial. Covers
|
|
355
|
+
* FILES_PRESIGN_FORBIDDEN (no write on a shared prefix), FILES_RAW_FORBIDDEN,
|
|
356
|
+
* and — the structural sessions-ACL rejection this US-006 phase relies on —
|
|
357
|
+
* FILES_PRESIGN_SESSIONS_FORBIDDEN (a spoofed/foreign `sessions/{uid}/`
|
|
358
|
+
* prefix the server refuses to presign).
|
|
359
|
+
* 2. FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED — the US-005 transcript-consent
|
|
360
|
+
* denial. It ends `_REQUIRED`, not `_FORBIDDEN`, so the suffix test alone
|
|
361
|
+
* would MISS it and let it fall through to the fatal generic Error below,
|
|
362
|
+
* re-introducing exactly the recurring partial-failure that poisons every
|
|
363
|
+
* subsequent sync run (US-006 AC5).
|
|
364
|
+
*/
|
|
365
|
+
const SKIP_WITH_LOG_DENIAL_CODES: ReadonlySet<string> = new Set([
|
|
366
|
+
"FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED",
|
|
367
|
+
]);
|
|
368
|
+
|
|
369
|
+
function isSkipWithLogDenialCode(code: unknown): code is string {
|
|
370
|
+
return (
|
|
371
|
+
typeof code === "string" &&
|
|
372
|
+
(code.endsWith("_FORBIDDEN") || SKIP_WITH_LOG_DENIAL_CODES.has(code))
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
347
376
|
function firstRowOrThrow(
|
|
348
377
|
results: PresignResultRow[],
|
|
349
378
|
key: string,
|
|
@@ -354,22 +383,30 @@ function firstRowOrThrow(
|
|
|
354
383
|
throw new Error(`presign ${op} returned no row for ${key}`);
|
|
355
384
|
}
|
|
356
385
|
if (row.error || !row.url) {
|
|
357
|
-
// An
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// a
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
386
|
+
// An expected per-key denial (see {@link isSkipWithLogDenialCode}) must
|
|
387
|
+
// throw the SAME `name: "Forbidden"` shape the GET/HEAD path uses so it
|
|
388
|
+
// routes through the `isAccessDenied` skip in sync.ts / share.ts. Without
|
|
389
|
+
// this, a denied presign fell to the generic Error below — `isAccessDenied`
|
|
390
|
+
// returned false — and the per-file ACL skip the GET path handles gracefully
|
|
391
|
+
// became a FATAL sync error: the crash-loop + Sentry flood behind the
|
|
392
|
+
// widespread "presign put denied" incident (members pushing net-new keys to
|
|
393
|
+
// a company-wide-read prefix under private-by-default), and — for the
|
|
394
|
+
// sessions/consent codes — a recurring partial-failure that poisoned every
|
|
395
|
+
// subsequent sync run (US-006 AC5). The denial is expected; only the fatal
|
|
396
|
+
// handling of it was the bug. Log a one-line skip so the reason is visible
|
|
397
|
+
// without the run failing.
|
|
398
|
+
if (isSkipWithLogDenialCode(row.code)) {
|
|
399
|
+
// Shared presign layer: this fires for sync legs AND for non-sync callers
|
|
400
|
+
// (hq files get, signals/sources internals) where the denial surfaces as a
|
|
401
|
+
// user-facing error, not a continued sync. State only what THIS layer did
|
|
402
|
+
// (denied → skipped this row) and let the caller own any "sync continues"
|
|
403
|
+
// / error framing — don't assert an outcome the caller may not take.
|
|
404
|
+
console.warn(
|
|
405
|
+
`presign ${op} denied for '${key}' (${row.code}) — skipped`,
|
|
406
|
+
);
|
|
369
407
|
throw accessDeniedError(
|
|
370
408
|
key,
|
|
371
|
-
`no
|
|
372
|
-
`ask an admin to grant write on this shared prefix`,
|
|
409
|
+
`no ${op} access to '${key}' (server ${op} denied: ${row.code})`,
|
|
373
410
|
);
|
|
374
411
|
}
|
|
375
412
|
throw new Error(
|
|
@@ -73,6 +73,39 @@ describe("operation-lock", () => {
|
|
|
73
73
|
expect(lockPathFor(rootA + path.sep)).toBe(a);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
it("scopes partition the lock: same root, different scope → different file, no mutual block", () => {
|
|
77
|
+
// feedback_ed98d810: reindex must not be starved by a long-lived sync/watch
|
|
78
|
+
// holder. Distinct scopes hash to distinct lock files and never block each
|
|
79
|
+
// other, while the same scope stays a real cross-process mutex.
|
|
80
|
+
const opPath = lockPathFor(rootA); // default "operation" scope
|
|
81
|
+
const reindexPath = lockPathFor(rootA, "reindex");
|
|
82
|
+
expect(reindexPath).not.toBe(opPath);
|
|
83
|
+
expect(path.basename(reindexPath).startsWith("reindex-")).toBe(true);
|
|
84
|
+
expect(path.basename(opPath).startsWith("operation-")).toBe(true);
|
|
85
|
+
|
|
86
|
+
let opHandle: ReturnType<typeof acquireOperationLock> | undefined;
|
|
87
|
+
let reindexHandle: ReturnType<typeof acquireOperationLock> | undefined;
|
|
88
|
+
try {
|
|
89
|
+
// A live "operation" holder (as a watch-mode runner keeps) does NOT block
|
|
90
|
+
// a "reindex"-scope acquire, even with no wait.
|
|
91
|
+
opHandle = acquireOperationLock(rootA, "sync");
|
|
92
|
+
expect(() => {
|
|
93
|
+
reindexHandle = acquireOperationLock(rootA, "reindex", {
|
|
94
|
+
scope: "reindex",
|
|
95
|
+
wait: false,
|
|
96
|
+
});
|
|
97
|
+
}).not.toThrow();
|
|
98
|
+
expect(reindexHandle && fs.existsSync(reindexHandle.path)).toBe(true);
|
|
99
|
+
// ...but a second acquire on the SAME "reindex" scope is still refused.
|
|
100
|
+
expect(() =>
|
|
101
|
+
acquireOperationLock(rootA, "reindex", { scope: "reindex", wait: false }),
|
|
102
|
+
).toThrowError(OperationLockedError);
|
|
103
|
+
} finally {
|
|
104
|
+
reindexHandle?.release();
|
|
105
|
+
opHandle?.release();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
76
109
|
it("F14: symlink aliases share the same root lock", () => {
|
|
77
110
|
const aliasParent = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rootA-link-"));
|
|
78
111
|
const aliasRoot = path.join(aliasParent, "alias");
|
package/src/operation-lock.ts
CHANGED
|
@@ -3,10 +3,19 @@
|
|
|
3
3
|
* (`sync`, `rescue`, `reindex`).
|
|
4
4
|
*
|
|
5
5
|
* Contract:
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* - `sync` and `rescue` share ONE per-root lock (the "operation" scope, keyed
|
|
7
|
+
* only by the root, not the command), so at most one of them runs at a time
|
|
8
|
+
* and e.g. a rescue refuses while a sync holds it. Different HQ roots are
|
|
9
|
+
* fully independent — they hash to different lock files.
|
|
10
|
+
* - `reindex` uses a SEPARATE per-root scope ("reindex"), so it is guarded
|
|
11
|
+
* against other reindexes but is NOT blocked by a held sync/rescue lock.
|
|
12
|
+
* This is deliberate: a watch-mode `hq-sync-runner` holds the "operation"
|
|
13
|
+
* lock across its entire lifetime, and a shared lock would starve a
|
|
14
|
+
* standalone `hq reindex` indefinitely (feedback_ed98d810). reindex only
|
|
15
|
+
* READS the source trees to rebuild derived artifacts (skill wrappers,
|
|
16
|
+
* overlay mirrors, the workers registry) and is idempotent + re-run after
|
|
17
|
+
* every sync pass, so decoupling it from the sync writer lock is safe.
|
|
18
|
+
* See {@link lockPathFor} for how the scope partitions the lock file.
|
|
10
19
|
* - The push watcher / watch+event-push runner is EXEMPT: it never calls in
|
|
11
20
|
* here, so it neither takes the lock nor is blocked by it (its targeted
|
|
12
21
|
* in-process push passes are likewise lock-free).
|
|
@@ -202,6 +211,13 @@ export interface AcquireOptions {
|
|
|
202
211
|
* (or a no-op) to redirect/silence the status line.
|
|
203
212
|
*/
|
|
204
213
|
onWaitStart?: (holder: LockInfo, attempted: string) => void;
|
|
214
|
+
/**
|
|
215
|
+
* Lock scope — partitions the per-root mutex into independent lock files (see
|
|
216
|
+
* {@link lockPathFor}). Defaults to {@link DEFAULT_LOCK_SCOPE} ("operation"),
|
|
217
|
+
* which `sync`/`rescue` share. `reindex` passes "reindex" so it is guarded
|
|
218
|
+
* against other reindexes without being blocked by a held sync/rescue lock.
|
|
219
|
+
*/
|
|
220
|
+
scope?: string;
|
|
205
221
|
}
|
|
206
222
|
|
|
207
223
|
interface ResolvedWaitConfig {
|
|
@@ -280,11 +296,26 @@ function canonicalRoot(hqRoot: string): string {
|
|
|
280
296
|
}
|
|
281
297
|
}
|
|
282
298
|
|
|
283
|
-
/**
|
|
284
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Default lock scope. `sync` and `rescue` share this one so they stay mutually
|
|
301
|
+
* exclusive with each other, keyed only by the root.
|
|
302
|
+
*/
|
|
303
|
+
export const DEFAULT_LOCK_SCOPE = "operation";
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Absolute lock path for a given HQ root and `scope`. Exported for tests.
|
|
307
|
+
*
|
|
308
|
+
* The `scope` prefixes the lock filename so callers can partition the mutex:
|
|
309
|
+
* `sync`/`rescue` use {@link DEFAULT_LOCK_SCOPE} ("operation"); `reindex` uses
|
|
310
|
+
* its own "reindex" scope so a long-lived watch-mode sync-runner (which holds
|
|
311
|
+
* the "operation" lock across its whole lifetime) can never starve a standalone
|
|
312
|
+
* `hq reindex`. Different scopes hash to different lock files and never block
|
|
313
|
+
* one another; the same scope is a real cross-process mutex.
|
|
314
|
+
*/
|
|
315
|
+
export function lockPathFor(hqRoot: string, scope: string = DEFAULT_LOCK_SCOPE): string {
|
|
285
316
|
const canon = canonicalRoot(hqRoot);
|
|
286
317
|
const key = crypto.createHash("sha1").update(canon).digest("hex").slice(0, 16);
|
|
287
|
-
return path.join(stateDir(), "locks",
|
|
318
|
+
return path.join(stateDir(), "locks", `${scope}-${key}.lock`);
|
|
288
319
|
}
|
|
289
320
|
|
|
290
321
|
/**
|
|
@@ -425,8 +456,12 @@ export function rethrowLockCreateError(err: unknown, lockDir: string): never {
|
|
|
425
456
|
}
|
|
426
457
|
|
|
427
458
|
/** Build the lock payload + ensure the locks dir exists. */
|
|
428
|
-
function prepareLock(
|
|
429
|
-
|
|
459
|
+
function prepareLock(
|
|
460
|
+
hqRoot: string,
|
|
461
|
+
command: string,
|
|
462
|
+
scope: string = DEFAULT_LOCK_SCOPE,
|
|
463
|
+
): { p: string; info: LockInfo; payload: string } {
|
|
464
|
+
const p = lockPathFor(hqRoot, scope);
|
|
430
465
|
const dir = path.dirname(p);
|
|
431
466
|
try {
|
|
432
467
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -565,7 +600,7 @@ export function acquireOperationLock(
|
|
|
565
600
|
): LockHandle {
|
|
566
601
|
if (process.env.HQ_DISABLE_OP_LOCK === "1") return disabledHandle(hqRoot, command);
|
|
567
602
|
|
|
568
|
-
const { p, info, payload } = prepareLock(hqRoot, command);
|
|
603
|
+
const { p, info, payload } = prepareLock(hqRoot, command, opts.scope);
|
|
569
604
|
const cfg = resolveWaitConfig(opts);
|
|
570
605
|
const deadline = cfg.timeoutMs === null ? null : Date.now() + cfg.timeoutMs;
|
|
571
606
|
let announced = false;
|
|
@@ -596,7 +631,7 @@ export async function acquireOperationLockAsync(
|
|
|
596
631
|
): Promise<LockHandle> {
|
|
597
632
|
if (process.env.HQ_DISABLE_OP_LOCK === "1") return disabledHandle(hqRoot, command);
|
|
598
633
|
|
|
599
|
-
const { p, info, payload } = prepareLock(hqRoot, command);
|
|
634
|
+
const { p, info, payload } = prepareLock(hqRoot, command, opts.scope);
|
|
600
635
|
const cfg = resolveWaitConfig(opts);
|
|
601
636
|
const deadline = cfg.timeoutMs === null ? null : Date.now() + cfg.timeoutMs;
|
|
602
637
|
let announced = false;
|