@indigoai-us/hq-cloud 5.40.0 → 5.42.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 (47) hide show
  1. package/dist/bin/sync-runner.d.ts +26 -1
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +105 -0
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +168 -1
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/sync-scope.test.d.ts +22 -0
  8. package/dist/cli/sync-scope.test.d.ts.map +1 -0
  9. package/dist/cli/sync-scope.test.js +273 -0
  10. package/dist/cli/sync-scope.test.js.map +1 -0
  11. package/dist/cli/sync.d.ts +64 -0
  12. package/dist/cli/sync.d.ts.map +1 -1
  13. package/dist/cli/sync.js +152 -4
  14. package/dist/cli/sync.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/prefix-coalesce.d.ts +29 -0
  20. package/dist/prefix-coalesce.d.ts.map +1 -1
  21. package/dist/prefix-coalesce.js +48 -0
  22. package/dist/prefix-coalesce.js.map +1 -1
  23. package/dist/prefix-coalesce.test.js +51 -1
  24. package/dist/prefix-coalesce.test.js.map +1 -1
  25. package/dist/qmd-reindex.d.ts +59 -0
  26. package/dist/qmd-reindex.d.ts.map +1 -0
  27. package/dist/qmd-reindex.js +128 -0
  28. package/dist/qmd-reindex.js.map +1 -0
  29. package/dist/qmd-reindex.test.d.ts +10 -0
  30. package/dist/qmd-reindex.test.d.ts.map +1 -0
  31. package/dist/qmd-reindex.test.js +129 -0
  32. package/dist/qmd-reindex.test.js.map +1 -0
  33. package/dist/scope-shrink.d.ts +18 -0
  34. package/dist/scope-shrink.d.ts.map +1 -1
  35. package/dist/scope-shrink.js +28 -0
  36. package/dist/scope-shrink.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/bin/sync-runner.test.ts +222 -0
  39. package/src/bin/sync-runner.ts +124 -0
  40. package/src/cli/sync-scope.test.ts +307 -0
  41. package/src/cli/sync.ts +240 -1
  42. package/src/index.ts +1 -0
  43. package/src/prefix-coalesce.test.ts +76 -1
  44. package/src/prefix-coalesce.ts +45 -0
  45. package/src/qmd-reindex.test.ts +143 -0
  46. package/src/qmd-reindex.ts +151 -0
  47. package/src/scope-shrink.ts +28 -0
@@ -71,8 +71,12 @@ import {
71
71
  type Membership,
72
72
  type EntityInfo,
73
73
  type PendingInviteByEmail,
74
+ type SyncMode,
75
+ type MembershipSyncConfig,
76
+ type ExplicitGrant,
74
77
  } from "../index.js";
75
78
  import { pickCanonicalPersonEntity } from "../vault-client.js";
79
+ import { coalescePrefixes, grantPathToPrefix } from "../prefix-coalesce.js";
76
80
  import {
77
81
  PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
78
82
  computePersonalVaultPaths,
@@ -88,6 +92,7 @@ import type { ShareOptions, ShareResult } from "../cli/share.js";
88
92
  import type { ConflictStrategy } from "../cli/conflict.js";
89
93
  import type { UploadAuthor } from "../s3.js";
90
94
  import { collectAndSendTelemetry } from "../telemetry.js";
95
+ import { reindexAfterSync } from "../qmd-reindex.js";
91
96
  import { describeError } from "../lib/describe-error.js";
92
97
  import { getOrCreateMachineId } from "../lib/machine-id.js";
93
98
  import {
@@ -338,6 +343,84 @@ export interface VaultClientSurface {
338
343
  get: (uid: string) => Promise<EntityInfo>;
339
344
  listByType: (type: string) => Promise<EntityInfo[]>;
340
345
  };
346
+ // US-005 scope resolution. Optional so older test stubs (and any
347
+ // VaultClientSurface impl that predates sync-config) still satisfy the
348
+ // interface; when absent, `resolvePullScope` degrades to `all`.
349
+ getMembershipSyncConfig?: (membershipId: string) => Promise<MembershipSyncConfig>;
350
+ listMyExplicitGrants?: (companyUid: string) => Promise<ExplicitGrant[]>;
351
+ }
352
+
353
+ /**
354
+ * Effective download scope for one company leg (US-005). Resolved per company
355
+ * just before its pull, then handed to `sync()` as `{ syncMode, prefixSet }`.
356
+ */
357
+ export interface PullScope {
358
+ syncMode: SyncMode;
359
+ /** Coalesced company-relative prefixes; omitted/undefined for `all`. */
360
+ prefixSet?: string[];
361
+ }
362
+
363
+ /**
364
+ * Resolve the effective download scope for a company target.
365
+ *
366
+ * - `all` → no prefix set; full-bucket pull (legacy behavior).
367
+ * - `shared` → coalesced caller explicit grants (company-relative paths,
368
+ * same namespace as `RemoteFile.key`).
369
+ * - `custom` → coalesced `customPaths` from the sync-config row.
370
+ *
371
+ * DEGRADE-TO-`all` CONTRACT: any failure (missing client method, membership
372
+ * not found, network error, grant fetch error) returns `{ syncMode: "all" }`.
373
+ * A transient failure must NEVER silently narrow scope — that would prune the
374
+ * local tree. Mirrors the CLI's `resolvePerCompanyPullPlan` degrade behavior.
375
+ */
376
+ export async function resolvePullScope(
377
+ client: VaultClientSurface,
378
+ companyUid: string,
379
+ // Company slug — required to normalize grant paths (which may be anchored
380
+ // at `companies/<slug>/` or `<slug>/`) into the company-relative namespace.
381
+ slug: string,
382
+ ): Promise<PullScope> {
383
+ if (!client.getMembershipSyncConfig) return { syncMode: "all" };
384
+ try {
385
+ const memberships = await client.listMyMemberships();
386
+ const m = memberships.find((x) => x.companyUid === companyUid);
387
+ if (!m) return { syncMode: "all" };
388
+ const cfg = await client.getMembershipSyncConfig(m.membershipKey);
389
+ if (cfg.syncMode === "all") return { syncMode: "all" };
390
+ if (cfg.syncMode === "custom") {
391
+ const customPrefixes = (cfg.customPaths ?? []).map((p) =>
392
+ grantPathToPrefix(p, slug),
393
+ );
394
+ // A bare-everything entry ("" — e.g. a `*` path) collapses under
395
+ // `coalescePrefixes` (which drops empties) to "nothing", which would
396
+ // prune the whole tree. An everything-scope is semantically `all`.
397
+ if (customPrefixes.some((p) => p === "")) return { syncMode: "all" };
398
+ return { syncMode: "custom", prefixSet: coalescePrefixes(customPrefixes) };
399
+ }
400
+ // shared: scope to the caller's explicit grants. Real grant paths are
401
+ // inconsistent — full (`companies/<slug>/x/*`), slug-anchored
402
+ // (`<slug>/x/*`), company-relative (`x/*`), bare globs (`*`), and exact
403
+ // files all coexist in production — so each is normalized via
404
+ // `grantPathToPrefix` into a company-relative, startsWith-friendly prefix
405
+ // (the namespace the engine's `RemoteFile.key`s live in) before coalescing.
406
+ //
407
+ // SAFETY: if the client can't fetch grants, we must NOT fall through to an
408
+ // empty `shared` scope — that would tell the engine "nothing is in scope"
409
+ // and scope-shrink would prune every clean local file. Degrade to `all`
410
+ // instead. A genuinely-empty grant list (the method exists and returns
411
+ // []) is a real "nothing shared with me" and is allowed to narrow.
412
+ if (!client.listMyExplicitGrants) return { syncMode: "all" };
413
+ const grants = await client.listMyExplicitGrants(companyUid);
414
+ const sharedPrefixes = grants.map((g) => grantPathToPrefix(g.path, slug));
415
+ // A wildcard grant (`*`) normalizes to "" = everything. Since
416
+ // `coalescePrefixes` drops empties (collapsing "everything" to "nothing"),
417
+ // treat any such grant as full-access `all` rather than risk pruning.
418
+ if (sharedPrefixes.some((p) => p === "")) return { syncMode: "all" };
419
+ return { syncMode: "shared", prefixSet: coalescePrefixes(sharedPrefixes) };
420
+ } catch {
421
+ // Degrade to `all` — never prune on a resolution failure.
422
+ return { syncMode: "all" };
423
+ }
341
424
  }
342
425
 
343
426
  /**
@@ -1065,6 +1148,9 @@ export async function runRunner(
1065
1148
  newFilesCount: 0,
1066
1149
  filesExcludedByPolicy: 0,
1067
1150
  filesTombstoned: 0,
1151
+ filesOutOfScope: 0,
1152
+ scopeOrphansRemoved: 0,
1153
+ scopeOrphansBlocked: 0,
1068
1154
  };
1069
1155
 
1070
1156
  // Push first so a subsequent pull doesn't overwrite files we were about
@@ -1165,11 +1251,24 @@ export async function runRunner(
1165
1251
  // whichever side `--on-conflict abort` just protected.
1166
1252
  if (doPull && !pushResult.aborted) {
1167
1253
  activePhase = "pull";
1254
+ // US-005: resolve the membership's effective download scope so the
1255
+ // pull only materializes in-scope keys (and prunes clean orphans when
1256
+ // scope shrank). Personal-vault legs have no membership sync-config —
1257
+ // they stay full-scope (`all`). Degrades to `all` on any error so a
1258
+ // transient failure can't silently prune the tree.
1259
+ const pullScope: PullScope =
1260
+ target.personalMode === true
1261
+ ? { syncMode: "all" }
1262
+ : await resolvePullScope(client, target.uid, target.slug);
1168
1263
  pullResult = await syncFn({
1169
1264
  company: target.uid,
1170
1265
  vaultConfig,
1171
1266
  hqRoot: parsed.hqRoot,
1172
1267
  onConflict: parsed.onConflict,
1268
+ syncMode: pullScope.syncMode,
1269
+ ...(pullScope.prefixSet !== undefined
1270
+ ? { prefixSet: pullScope.prefixSet }
1271
+ : {}),
1173
1272
  ...(target.personalMode !== undefined ? { personalMode: target.personalMode } : {}),
1174
1273
  ...(target.journalSlug !== undefined ? { journalSlug: target.journalSlug } : {}),
1175
1274
  // Symmetric to the push side: for the personal slot, tell sync()
@@ -1274,6 +1373,11 @@ export async function runRunner(
1274
1373
  aborted,
1275
1374
  newFiles: pullResult.newFiles,
1276
1375
  newFilesCount: pullResult.newFilesCount,
1376
+ // Scope-aware download counters (US-005). Pull-only — the push leg
1377
+ // has no scope concept — so they pass through from `pullResult`.
1378
+ filesOutOfScope: pullResult.filesOutOfScope,
1379
+ scopeOrphansRemoved: pullResult.scopeOrphansRemoved,
1380
+ scopeOrphansBlocked: pullResult.scopeOrphansBlocked,
1277
1381
  });
1278
1382
  for (const p of pullResult.conflictPaths) {
1279
1383
  allConflicts.push({ company: companyLabel, path: p, direction: "pull" });
@@ -1315,6 +1419,11 @@ export async function runRunner(
1315
1419
  aborted: true,
1316
1420
  newFiles: [],
1317
1421
  newFilesCount: 0,
1422
+ // Mid-flight throw: no clean scope counts to report. 0 keeps the
1423
+ // event shape stable (US-005).
1424
+ filesOutOfScope: 0,
1425
+ scopeOrphansRemoved: 0,
1426
+ scopeOrphansBlocked: 0,
1318
1427
  });
1319
1428
  emit({
1320
1429
  type: "error",
@@ -1389,6 +1498,21 @@ export async function runRunner(
1389
1498
  partial,
1390
1499
  companies,
1391
1500
  });
1501
+
1502
+ // Post-sync qmd reindex — runs AFTER `all-complete` is emitted so the
1503
+ // menubar/CLI already shows the sync as done; this is a best-effort tail
1504
+ // step that never affects the exit code. Only when files were actually
1505
+ // pulled in (nothing to reindex otherwise) and not explicitly disabled.
1506
+ // Self-contained: shells out to the global `qmd` binary, no dependency on
1507
+ // any (possibly stale) script inside the synced HQ tree. See qmd-reindex.ts.
1508
+ if (totalDownloaded > 0 && process.env.HQ_QMD_REINDEX_ON_SYNC !== "0") {
1509
+ try {
1510
+ reindexAfterSync(parsed.hqRoot);
1511
+ } catch {
1512
+ // Defensive: reindexAfterSync already swallows internally.
1513
+ }
1514
+ }
1515
+
1392
1516
  // Exit 2 only when something actually threw (`errors.length > 0`). A clean
1393
1517
  // conflict-abort sets `partial: true` in the JSON but exits 0 — the Tauri
1394
1518
  // menubar's non-zero-exit Sentry capture would otherwise fire for normal
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Unit tests for scope-aware download (US-005 wiring into `sync()`).
3
+ *
4
+ * Covers the contract added when `syncMode` / `prefixSet` were threaded
5
+ * through `computePullPlan` + the scope-shrink pass:
6
+ *
7
+ * - `all` → no filtering (regression guard lives in sync.test.ts).
8
+ * - `shared` → only keys covered by `prefixSet` download; the rest are
9
+ * classified `skip-out-of-scope` (NOT downloaded).
10
+ * - `custom` → same mechanism, driven by the explicit path list.
11
+ * - Idempotency → a second `shared` pull downloads nothing and removes
12
+ * nothing (the PullRecord makes scope-change a no-op).
13
+ * - Scope shrink → narrowing `all → shared` prunes the now-out-of-scope
14
+ * CLEAN local orphan; a DIRTY orphan aborts with
15
+ * `ScopeShrinkBlockedError` unless `forceScopeShrink`.
16
+ *
17
+ * The security contract (this filter is footprint-only, never an authz
18
+ * boundary) is asserted indirectly: out-of-scope keys are still LISTED and
19
+ * accessible — the engine simply chooses not to materialize them.
20
+ */
21
+
22
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
23
+ import * as fs from "fs";
24
+ import * as path from "path";
25
+ import * as os from "os";
26
+ import { clearContextCache } from "../context.js";
27
+ import type { VaultServiceConfig } from "../types.js";
28
+
29
+ // Mutable remote-file list so each test controls what the vault returns.
30
+ const REMOTE: { current: Array<{ key: string; size: number; lastModified: Date; etag: string }> } = {
31
+ current: [
32
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
33
+ { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
34
+ ],
35
+ };
36
+
37
+ vi.mock("../s3.js", async () => {
38
+ const innerFs = await import("fs");
39
+ const innerPath = await import("path");
40
+ const { vi: innerVi } = await import("vitest");
41
+ return {
42
+ uploadFile: innerVi.fn().mockResolvedValue(undefined),
43
+ downloadFile: innerVi
44
+ .fn()
45
+ .mockImplementation(async (_ctx: unknown, key: string, localPath: string) => {
46
+ const dir = innerPath.dirname(localPath);
47
+ if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
48
+ // Deterministic per-key body so re-downloads produce a stable hash.
49
+ innerFs.writeFileSync(localPath, `mock:${key}`);
50
+ return { metadata: {} };
51
+ }),
52
+ listRemoteFiles: innerVi.fn().mockImplementation(async () => REMOTE.current),
53
+ deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
54
+ // HEAD returns metadata (object exists) for any key still in REMOTE,
55
+ // null otherwise — mirrors the real bucket so the tombstone HEAD-verify
56
+ // pass behaves correctly for out-of-scope (still-present) keys.
57
+ headRemoteFile: innerVi.fn().mockImplementation(async (_ctx: unknown, key: string) => {
58
+ const hit = REMOTE.current.find((r) => r.key === key);
59
+ return hit ? { metadata: {}, size: hit.size, etag: hit.etag } : null;
60
+ }),
61
+ };
62
+ });
63
+
64
+ import { sync } from "./sync.js";
65
+ import {
66
+ ScopeShrinkBlockedError,
67
+ ScopeShrinkLargePruneError,
68
+ } from "../scope-shrink.js";
69
+
70
+ const mockConfig: VaultServiceConfig = {
71
+ apiUrl: "https://vault-api.test",
72
+ authToken: "test-jwt-token",
73
+ region: "us-east-1",
74
+ };
75
+
76
+ const mockEntity = {
77
+ uid: "cmp_01ABCDEF",
78
+ slug: "acme",
79
+ bucketName: "hq-vault-acme-123",
80
+ status: "active",
81
+ };
82
+
83
+ const mockVendResponse = {
84
+ credentials: {
85
+ accessKeyId: "ASIA_TEST_KEY",
86
+ secretAccessKey: "test-secret",
87
+ sessionToken: "test-session-token",
88
+ expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
89
+ },
90
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
91
+ };
92
+
93
+ function setupFetchMock() {
94
+ const fetchMock = vi.fn().mockImplementation(async (url: string) => {
95
+ const urlStr = String(url);
96
+ if (urlStr.includes("/entity/check-slug/me")) {
97
+ return {
98
+ ok: true,
99
+ status: 200,
100
+ json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
101
+ text: async () => "",
102
+ };
103
+ }
104
+ if (urlStr.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(urlStr)) {
105
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
106
+ }
107
+ if (urlStr.includes("/sts/vend")) {
108
+ return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
109
+ }
110
+ return { ok: false, status: 404, text: async () => "Not found" };
111
+ });
112
+ vi.stubGlobal("fetch", fetchMock);
113
+ return fetchMock;
114
+ }
115
+
116
+ describe("sync — scope-aware download (US-005)", () => {
117
+ let tmpDir: string;
118
+ let stateDir: string;
119
+ const companyRel = (p: string) => path.join(tmpDir, "companies", "acme", p);
120
+
121
+ beforeEach(() => {
122
+ clearContextCache();
123
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-scope-"));
124
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-scope-"));
125
+ process.env.HQ_STATE_DIR = stateDir;
126
+ // Reset the remote list every test (a prior test may have mutated it).
127
+ REMOTE.current = [
128
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
129
+ { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
130
+ ];
131
+ setupFetchMock();
132
+ });
133
+
134
+ afterEach(() => {
135
+ vi.unstubAllGlobals();
136
+ vi.clearAllMocks();
137
+ fs.rmSync(tmpDir, { recursive: true, force: true });
138
+ fs.rmSync(stateDir, { recursive: true, force: true });
139
+ delete process.env.HQ_STATE_DIR;
140
+ });
141
+
142
+ it("shared mode downloads only keys covered by prefixSet; rest are skip-out-of-scope", async () => {
143
+ const result = await sync({
144
+ company: "acme",
145
+ vaultConfig: mockConfig,
146
+ hqRoot: tmpDir,
147
+ syncMode: "shared",
148
+ prefixSet: ["knowledge/"],
149
+ });
150
+
151
+ expect(result.filesDownloaded).toBe(1);
152
+ expect(result.filesOutOfScope).toBe(1);
153
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
154
+ // docs/handoff.md is out of scope — never materialized.
155
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
156
+ });
157
+
158
+ it("custom mode behaves like shared, driven by the explicit prefix list", async () => {
159
+ const result = await sync({
160
+ company: "acme",
161
+ vaultConfig: mockConfig,
162
+ hqRoot: tmpDir,
163
+ syncMode: "custom",
164
+ prefixSet: ["docs/"],
165
+ });
166
+
167
+ expect(result.filesDownloaded).toBe(1);
168
+ expect(result.filesOutOfScope).toBe(1);
169
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
170
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
171
+ });
172
+
173
+ it("shared mode with empty prefixSet downloads nothing", async () => {
174
+ const result = await sync({
175
+ company: "acme",
176
+ vaultConfig: mockConfig,
177
+ hqRoot: tmpDir,
178
+ syncMode: "shared",
179
+ prefixSet: [],
180
+ });
181
+
182
+ expect(result.filesDownloaded).toBe(0);
183
+ expect(result.filesOutOfScope).toBe(2);
184
+ });
185
+
186
+ it("is idempotent: a second shared pull downloads and removes nothing", async () => {
187
+ const opts = {
188
+ company: "acme",
189
+ vaultConfig: mockConfig,
190
+ hqRoot: tmpDir,
191
+ syncMode: "shared" as const,
192
+ prefixSet: ["knowledge/"],
193
+ };
194
+ const first = await sync(opts);
195
+ expect(first.filesDownloaded).toBe(1);
196
+
197
+ const second = await sync(opts);
198
+ expect(second.filesDownloaded).toBe(0);
199
+ expect(second.scopeOrphansRemoved).toBe(0);
200
+ expect(second.scopeOrphansBlocked).toBe(0);
201
+ // knowledge file still present; nothing churned.
202
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
203
+ });
204
+
205
+ it("scope shrink (all → shared) prunes the clean out-of-scope orphan", async () => {
206
+ // First pull EVERYTHING under all-mode.
207
+ const all = await sync({
208
+ company: "acme",
209
+ vaultConfig: mockConfig,
210
+ hqRoot: tmpDir,
211
+ syncMode: "all",
212
+ });
213
+ expect(all.filesDownloaded).toBe(2);
214
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
215
+
216
+ // Narrow to shared/knowledge → docs/handoff.md is now a clean orphan.
217
+ const shared = await sync({
218
+ company: "acme",
219
+ vaultConfig: mockConfig,
220
+ hqRoot: tmpDir,
221
+ syncMode: "shared",
222
+ prefixSet: ["knowledge/"],
223
+ });
224
+ expect(shared.scopeOrphansRemoved).toBe(1);
225
+ expect(shared.scopeOrphansBlocked).toBe(0);
226
+ // The clean orphan was pruned; the in-scope file stays.
227
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
228
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
229
+ });
230
+
231
+ it("refuses a bulk auto-prune over the safety cap, then proceeds when forced", async () => {
232
+ // Pull both files under all-mode, then narrow to a scope covering neither
233
+ // → 2 clean orphans. With the cap set to 1, the auto-prune is refused.
234
+ await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
235
+
236
+ // Narrow to a scope covering neither file → 2 clean orphans; cap at 1.
237
+ process.env.HQ_SYNC_MAX_AUTO_PRUNE = "1";
238
+ try {
239
+ await expect(
240
+ sync({
241
+ company: "acme",
242
+ vaultConfig: mockConfig,
243
+ hqRoot: tmpDir,
244
+ syncMode: "shared",
245
+ prefixSet: ["nonexistent/"],
246
+ }),
247
+ ).rejects.toBeInstanceOf(ScopeShrinkLargePruneError);
248
+ // Nothing deleted on the refused run.
249
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
250
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
251
+
252
+ // Forced: the prune proceeds despite the cap.
253
+ const forced = await sync({
254
+ company: "acme",
255
+ vaultConfig: mockConfig,
256
+ hqRoot: tmpDir,
257
+ syncMode: "shared",
258
+ prefixSet: ["nonexistent/"],
259
+ forceScopeShrink: true,
260
+ });
261
+ expect(forced.scopeOrphansRemoved).toBe(2);
262
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
263
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
264
+ } finally {
265
+ delete process.env.HQ_SYNC_MAX_AUTO_PRUNE;
266
+ }
267
+ });
268
+
269
+ it("scope shrink aborts on a DIRTY orphan unless forceScopeShrink is set", async () => {
270
+ await sync({
271
+ company: "acme",
272
+ vaultConfig: mockConfig,
273
+ hqRoot: tmpDir,
274
+ syncMode: "all",
275
+ });
276
+ // Locally modify the soon-to-be-orphan so it's dirty (hash mismatch).
277
+ fs.writeFileSync(companyRel("docs/handoff.md"), "LOCAL EDIT — do not delete");
278
+
279
+ // Default: abort with the structured error; the dirty file is untouched.
280
+ await expect(
281
+ sync({
282
+ company: "acme",
283
+ vaultConfig: mockConfig,
284
+ hqRoot: tmpDir,
285
+ syncMode: "shared",
286
+ prefixSet: ["knowledge/"],
287
+ }),
288
+ ).rejects.toBeInstanceOf(ScopeShrinkBlockedError);
289
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
290
+ expect(fs.readFileSync(companyRel("docs/handoff.md"), "utf-8")).toBe(
291
+ "LOCAL EDIT — do not delete",
292
+ );
293
+
294
+ // With force: the leg proceeds, the dirty file is LEFT ON DISK, only its
295
+ // journal entry is tombstoned.
296
+ const forced = await sync({
297
+ company: "acme",
298
+ vaultConfig: mockConfig,
299
+ hqRoot: tmpDir,
300
+ syncMode: "shared",
301
+ prefixSet: ["knowledge/"],
302
+ forceScopeShrink: true,
303
+ });
304
+ expect(forced.scopeOrphansBlocked).toBe(1);
305
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
306
+ });
307
+ });