@indigoai-us/hq-cloud 5.41.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.
@@ -0,0 +1,273 @@
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
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
22
+ import * as fs from "fs";
23
+ import * as path from "path";
24
+ import * as os from "os";
25
+ import { clearContextCache } from "../context.js";
26
+ // Mutable remote-file list so each test controls what the vault returns.
27
+ const REMOTE = {
28
+ current: [
29
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
30
+ { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
31
+ ],
32
+ };
33
+ vi.mock("../s3.js", async () => {
34
+ const innerFs = await import("fs");
35
+ const innerPath = await import("path");
36
+ const { vi: innerVi } = await import("vitest");
37
+ return {
38
+ uploadFile: innerVi.fn().mockResolvedValue(undefined),
39
+ downloadFile: innerVi
40
+ .fn()
41
+ .mockImplementation(async (_ctx, key, localPath) => {
42
+ const dir = innerPath.dirname(localPath);
43
+ if (!innerFs.existsSync(dir))
44
+ innerFs.mkdirSync(dir, { recursive: true });
45
+ // Deterministic per-key body so re-downloads produce a stable hash.
46
+ innerFs.writeFileSync(localPath, `mock:${key}`);
47
+ return { metadata: {} };
48
+ }),
49
+ listRemoteFiles: innerVi.fn().mockImplementation(async () => REMOTE.current),
50
+ deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
51
+ // HEAD returns metadata (object exists) for any key still in REMOTE,
52
+ // null otherwise — mirrors the real bucket so the tombstone HEAD-verify
53
+ // pass behaves correctly for out-of-scope (still-present) keys.
54
+ headRemoteFile: innerVi.fn().mockImplementation(async (_ctx, key) => {
55
+ const hit = REMOTE.current.find((r) => r.key === key);
56
+ return hit ? { metadata: {}, size: hit.size, etag: hit.etag } : null;
57
+ }),
58
+ };
59
+ });
60
+ import { sync } from "./sync.js";
61
+ import { ScopeShrinkBlockedError, ScopeShrinkLargePruneError, } from "../scope-shrink.js";
62
+ const mockConfig = {
63
+ apiUrl: "https://vault-api.test",
64
+ authToken: "test-jwt-token",
65
+ region: "us-east-1",
66
+ };
67
+ const mockEntity = {
68
+ uid: "cmp_01ABCDEF",
69
+ slug: "acme",
70
+ bucketName: "hq-vault-acme-123",
71
+ status: "active",
72
+ };
73
+ const mockVendResponse = {
74
+ credentials: {
75
+ accessKeyId: "ASIA_TEST_KEY",
76
+ secretAccessKey: "test-secret",
77
+ sessionToken: "test-session-token",
78
+ expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
79
+ },
80
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
81
+ };
82
+ function setupFetchMock() {
83
+ const fetchMock = vi.fn().mockImplementation(async (url) => {
84
+ const urlStr = String(url);
85
+ if (urlStr.includes("/entity/check-slug/me")) {
86
+ return {
87
+ ok: true,
88
+ status: 200,
89
+ json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
90
+ text: async () => "",
91
+ };
92
+ }
93
+ if (urlStr.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(urlStr)) {
94
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
95
+ }
96
+ if (urlStr.includes("/sts/vend")) {
97
+ return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
98
+ }
99
+ return { ok: false, status: 404, text: async () => "Not found" };
100
+ });
101
+ vi.stubGlobal("fetch", fetchMock);
102
+ return fetchMock;
103
+ }
104
+ describe("sync — scope-aware download (US-005)", () => {
105
+ let tmpDir;
106
+ let stateDir;
107
+ const companyRel = (p) => path.join(tmpDir, "companies", "acme", p);
108
+ beforeEach(() => {
109
+ clearContextCache();
110
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-scope-"));
111
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-scope-"));
112
+ process.env.HQ_STATE_DIR = stateDir;
113
+ // Reset the remote list every test (a prior test may have mutated it).
114
+ REMOTE.current = [
115
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
116
+ { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
117
+ ];
118
+ setupFetchMock();
119
+ });
120
+ afterEach(() => {
121
+ vi.unstubAllGlobals();
122
+ vi.clearAllMocks();
123
+ fs.rmSync(tmpDir, { recursive: true, force: true });
124
+ fs.rmSync(stateDir, { recursive: true, force: true });
125
+ delete process.env.HQ_STATE_DIR;
126
+ });
127
+ it("shared mode downloads only keys covered by prefixSet; rest are skip-out-of-scope", async () => {
128
+ const result = await sync({
129
+ company: "acme",
130
+ vaultConfig: mockConfig,
131
+ hqRoot: tmpDir,
132
+ syncMode: "shared",
133
+ prefixSet: ["knowledge/"],
134
+ });
135
+ expect(result.filesDownloaded).toBe(1);
136
+ expect(result.filesOutOfScope).toBe(1);
137
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
138
+ // docs/handoff.md is out of scope — never materialized.
139
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
140
+ });
141
+ it("custom mode behaves like shared, driven by the explicit prefix list", async () => {
142
+ const result = await sync({
143
+ company: "acme",
144
+ vaultConfig: mockConfig,
145
+ hqRoot: tmpDir,
146
+ syncMode: "custom",
147
+ prefixSet: ["docs/"],
148
+ });
149
+ expect(result.filesDownloaded).toBe(1);
150
+ expect(result.filesOutOfScope).toBe(1);
151
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
152
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
153
+ });
154
+ it("shared mode with empty prefixSet downloads nothing", async () => {
155
+ const result = await sync({
156
+ company: "acme",
157
+ vaultConfig: mockConfig,
158
+ hqRoot: tmpDir,
159
+ syncMode: "shared",
160
+ prefixSet: [],
161
+ });
162
+ expect(result.filesDownloaded).toBe(0);
163
+ expect(result.filesOutOfScope).toBe(2);
164
+ });
165
+ it("is idempotent: a second shared pull downloads and removes nothing", async () => {
166
+ const opts = {
167
+ company: "acme",
168
+ vaultConfig: mockConfig,
169
+ hqRoot: tmpDir,
170
+ syncMode: "shared",
171
+ prefixSet: ["knowledge/"],
172
+ };
173
+ const first = await sync(opts);
174
+ expect(first.filesDownloaded).toBe(1);
175
+ const second = await sync(opts);
176
+ expect(second.filesDownloaded).toBe(0);
177
+ expect(second.scopeOrphansRemoved).toBe(0);
178
+ expect(second.scopeOrphansBlocked).toBe(0);
179
+ // knowledge file still present; nothing churned.
180
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
181
+ });
182
+ it("scope shrink (all → shared) prunes the clean out-of-scope orphan", async () => {
183
+ // First pull EVERYTHING under all-mode.
184
+ const all = await sync({
185
+ company: "acme",
186
+ vaultConfig: mockConfig,
187
+ hqRoot: tmpDir,
188
+ syncMode: "all",
189
+ });
190
+ expect(all.filesDownloaded).toBe(2);
191
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
192
+ // Narrow to shared/knowledge → docs/handoff.md is now a clean orphan.
193
+ const shared = await sync({
194
+ company: "acme",
195
+ vaultConfig: mockConfig,
196
+ hqRoot: tmpDir,
197
+ syncMode: "shared",
198
+ prefixSet: ["knowledge/"],
199
+ });
200
+ expect(shared.scopeOrphansRemoved).toBe(1);
201
+ expect(shared.scopeOrphansBlocked).toBe(0);
202
+ // The clean orphan was pruned; the in-scope file stays.
203
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
204
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
205
+ });
206
+ it("refuses a bulk auto-prune over the safety cap, then proceeds when forced", async () => {
207
+ // Pull both files under all-mode, then narrow to a scope covering neither
208
+ // → 2 clean orphans. With the cap set to 1, the auto-prune is refused.
209
+ await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
210
+ // Narrow to a scope covering neither file → 2 clean orphans; cap at 1.
211
+ process.env.HQ_SYNC_MAX_AUTO_PRUNE = "1";
212
+ try {
213
+ await expect(sync({
214
+ company: "acme",
215
+ vaultConfig: mockConfig,
216
+ hqRoot: tmpDir,
217
+ syncMode: "shared",
218
+ prefixSet: ["nonexistent/"],
219
+ })).rejects.toBeInstanceOf(ScopeShrinkLargePruneError);
220
+ // Nothing deleted on the refused run.
221
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
222
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
223
+ // Forced: the prune proceeds despite the cap.
224
+ const forced = await sync({
225
+ company: "acme",
226
+ vaultConfig: mockConfig,
227
+ hqRoot: tmpDir,
228
+ syncMode: "shared",
229
+ prefixSet: ["nonexistent/"],
230
+ forceScopeShrink: true,
231
+ });
232
+ expect(forced.scopeOrphansRemoved).toBe(2);
233
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
234
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
235
+ }
236
+ finally {
237
+ delete process.env.HQ_SYNC_MAX_AUTO_PRUNE;
238
+ }
239
+ });
240
+ it("scope shrink aborts on a DIRTY orphan unless forceScopeShrink is set", async () => {
241
+ await sync({
242
+ company: "acme",
243
+ vaultConfig: mockConfig,
244
+ hqRoot: tmpDir,
245
+ syncMode: "all",
246
+ });
247
+ // Locally modify the soon-to-be-orphan so it's dirty (hash mismatch).
248
+ fs.writeFileSync(companyRel("docs/handoff.md"), "LOCAL EDIT — do not delete");
249
+ // Default: abort with the structured error; the dirty file is untouched.
250
+ await expect(sync({
251
+ company: "acme",
252
+ vaultConfig: mockConfig,
253
+ hqRoot: tmpDir,
254
+ syncMode: "shared",
255
+ prefixSet: ["knowledge/"],
256
+ })).rejects.toBeInstanceOf(ScopeShrinkBlockedError);
257
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
258
+ expect(fs.readFileSync(companyRel("docs/handoff.md"), "utf-8")).toBe("LOCAL EDIT — do not delete");
259
+ // With force: the leg proceeds, the dirty file is LEFT ON DISK, only its
260
+ // journal entry is tombstoned.
261
+ const forced = await sync({
262
+ company: "acme",
263
+ vaultConfig: mockConfig,
264
+ hqRoot: tmpDir,
265
+ syncMode: "shared",
266
+ prefixSet: ["knowledge/"],
267
+ forceScopeShrink: true,
268
+ });
269
+ expect(forced.scopeOrphansBlocked).toBe(1);
270
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
271
+ });
272
+ });
273
+ //# sourceMappingURL=sync-scope.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-scope.test.js","sourceRoot":"","sources":["../../src/cli/sync-scope.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AACzE,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAC7B,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAGlD,yEAAyE;AACzE,MAAM,MAAM,GAAwF;IAClG,OAAO,EAAE;QACP,EAAE,GAAG,EAAE,iBAAiB,EAAE,IAAI,EAAE,EAAE,EAAE,YAAY,EAAE,IAAI,IAAI,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE;QAChF,EAAE,GAAG,EAAE,qBAAqB,EAAE,IAAI,EAAE,GAAG,EAAE,YAAY,EAAE,IAAI,IAAI,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE;KACtF;CACF,CAAC;AAEF,EAAE,CAAC,IAAI,CAAC,UAAU,EAAE,KAAK,IAAI,EAAE;IAC7B,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC;IACnC,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,CAAC;IACvC,MAAM,EAAE,EAAE,EAAE,OAAO,EAAE,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC/C,OAAO;QACL,UAAU,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,SAAS,CAAC;QACrD,YAAY,EAAE,OAAO;aAClB,EAAE,EAAE;aACJ,kBAAkB,CAAC,KAAK,EAAE,IAAa,EAAE,GAAW,EAAE,SAAiB,EAAE,EAAE;YAC1E,MAAM,GAAG,GAAG,SAAS,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;YACzC,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;gBAAE,OAAO,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YAC1E,oEAAoE;YACpE,OAAO,CAAC,aAAa,CAAC,SAAS,EAAE,QAAQ,GAAG,EAAE,CAAC,CAAC;YAChD,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC;QAC1B,CAAC,CAAC;QACJ,eAAe,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC,kBAAkB,CAAC,KAAK,IAAI,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC;QAC5E,gBAAgB,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,SAAS,CAAC;QAC3D,qEAAqE;QACrE,wEAAwE;QACxE,gEAAgE;QAChE,cAAc,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC,kBAAkB,CAAC,KAAK,EAAE,IAAa,EAAE,GAAW,EAAE,EAAE;YACnF,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,GAAG,CAAC,CAAC;YACtD,OAAO,GAAG,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,EAAE,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;QACvE,CAAC,CAAC;KACH,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EACL,uBAAuB,EACvB,0BAA0B,GAC3B,MAAM,oBAAoB,CAAC;AAE5B,MAAM,UAAU,GAAuB;IACrC,MAAM,EAAE,wBAAwB;IAChC,SAAS,EAAE,gBAAgB;IAC3B,MAAM,EAAE,WAAW;CACpB,CAAC;AAEF,MAAM,UAAU,GAAG;IACjB,GAAG,EAAE,cAAc;IACnB,IAAI,EAAE,MAAM;IACZ,UAAU,EAAE,mBAAmB;IAC/B,MAAM,EAAE,QAAQ;CACjB,CAAC;AAEF,MAAM,gBAAgB,GAAG;IACvB,WAAW,EAAE;QACX,WAAW,EAAE,eAAe;QAC5B,eAAe,EAAE,aAAa;QAC9B,YAAY,EAAE,oBAAoB;QAClC,UAAU,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE;KAChE;IACD,SAAS,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE;CAC/D,CAAC;AAEF,SAAS,cAAc;IACrB,MAAM,SAAS,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,kBAAkB,CAAC,KAAK,EAAE,GAAW,EAAE,EAAE;QACjE,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;QAC3B,IAAI,MAAM,CAAC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,CAAC;YAC7C,OAAO;gBACL,EAAE,EAAE,IAAI;gBACR,MAAM,EAAE,GAAG;gBACX,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,qBAAqB,EAAE,UAAU,CAAC,GAAG,EAAE,CAAC;gBAC/E,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE;aACrB,CAAC;QACJ,CAAC;QACD,IAAI,MAAM,CAAC,QAAQ,CAAC,kBAAkB,CAAC,IAAI,gBAAgB,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;YACzE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC,EAAE,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC;QACrG,CAAC;QACD,IAAI,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC;YACjC,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,gBAAgB,EAAE,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC;QAC7F,CAAC;QACD,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACnE,CAAC,CAAC,CAAC;IACH,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;IAClC,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,QAAQ,CAAC,sCAAsC,EAAE,GAAG,EAAE;IACpD,IAAI,MAAc,CAAC;IACnB,IAAI,QAAgB,CAAC;IACrB,MAAM,UAAU,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;IAE5E,UAAU,CAAC,GAAG,EAAE;QACd,iBAAiB,EAAE,CAAC;QACpB,MAAM,GAAG,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,EAAE,gBAAgB,CAAC,CAAC,CAAC;QAClE,QAAQ,GAAG,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,EAAE,iBAAiB,CAAC,CAAC,CAAC;QACrE,OAAO,CAAC,GAAG,CAAC,YAAY,GAAG,QAAQ,CAAC;QACpC,uEAAuE;QACvE,MAAM,CAAC,OAAO,GAAG;YACf,EAAE,GAAG,EAAE,iBAAiB,EAAE,IAAI,EAAE,EAAE,EAAE,YAAY,EAAE,IAAI,IAAI,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE;YAChF,EAAE,GAAG,EAAE,qBAAqB,EAAE,IAAI,EAAE,GAAG,EAAE,YAAY,EAAE,IAAI,IAAI,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE;SACtF,CAAC;QACF,cAAc,EAAE,CAAC;IACnB,CAAC,CAAC,CAAC;IAEH,SAAS,CAAC,GAAG,EAAE;QACb,EAAE,CAAC,gBAAgB,EAAE,CAAC;QACtB,EAAE,CAAC,aAAa,EAAE,CAAC;QACnB,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QACpD,EAAE,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QACtD,OAAO,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC;IAClC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kFAAkF,EAAE,KAAK,IAAI,EAAE;QAChG,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC;YACxB,OAAO,EAAE,MAAM;YACf,WAAW,EAAE,UAAU;YACvB,MAAM,EAAE,MAAM;YACd,QAAQ,EAAE,QAAQ;YAClB,SAAS,EAAE,CAAC,YAAY,CAAC;SAC1B,CAAC,CAAC;QAEH,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACvC,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACvC,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACpE,wDAAwD;QACxD,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACnE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qEAAqE,EAAE,KAAK,IAAI,EAAE;QACnF,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC;YACxB,OAAO,EAAE,MAAM;YACf,WAAW,EAAE,UAAU;YACvB,MAAM,EAAE,MAAM;YACd,QAAQ,EAAE,QAAQ;YAClB,SAAS,EAAE,CAAC,OAAO,CAAC;SACrB,CAAC,CAAC;QAEH,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACvC,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACvC,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAChE,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACvE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oDAAoD,EAAE,KAAK,IAAI,EAAE;QAClE,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC;YACxB,OAAO,EAAE,MAAM;YACf,WAAW,EAAE,UAAU;YACvB,MAAM,EAAE,MAAM;YACd,QAAQ,EAAE,QAAQ;YAClB,SAAS,EAAE,EAAE;SACd,CAAC,CAAC;QAEH,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACvC,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACzC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mEAAmE,EAAE,KAAK,IAAI,EAAE;QACjF,MAAM,IAAI,GAAG;YACX,OAAO,EAAE,MAAM;YACf,WAAW,EAAE,UAAU;YACvB,MAAM,EAAE,MAAM;YACd,QAAQ,EAAE,QAAiB;YAC3B,SAAS,EAAE,CAAC,YAAY,CAAC;SAC1B,CAAC;QACF,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC;QAC/B,MAAM,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAEtC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC;QAChC,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACvC,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC3C,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC3C,iDAAiD;QACjD,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACtE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kEAAkE,EAAE,KAAK,IAAI,EAAE;QAChF,wCAAwC;QACxC,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC;YACrB,OAAO,EAAE,MAAM;YACf,WAAW,EAAE,UAAU;YACvB,MAAM,EAAE,MAAM;YACd,QAAQ,EAAE,KAAK;SAChB,CAAC,CAAC;QACH,MAAM,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACpC,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAEhE,sEAAsE;QACtE,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC;YACxB,OAAO,EAAE,MAAM;YACf,WAAW,EAAE,UAAU;YACvB,MAAM,EAAE,MAAM;YACd,QAAQ,EAAE,QAAQ;YAClB,SAAS,EAAE,CAAC,YAAY,CAAC;SAC1B,CAAC,CAAC;QACH,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC3C,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC3C,wDAAwD;QACxD,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACjE,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACtE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0EAA0E,EAAE,KAAK,IAAI,EAAE;QACxF,0EAA0E;QAC1E,uEAAuE;QACvE,MAAM,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC;QAE1F,uEAAuE;QACvE,OAAO,CAAC,GAAG,CAAC,sBAAsB,GAAG,GAAG,CAAC;QACzC,IAAI,CAAC;YACH,MAAM,MAAM,CACV,IAAI,CAAC;gBACH,OAAO,EAAE,MAAM;gBACf,WAAW,EAAE,UAAU;gBACvB,MAAM,EAAE,MAAM;gBACd,QAAQ,EAAE,QAAQ;gBAClB,SAAS,EAAE,CAAC,cAAc,CAAC;aAC5B,CAAC,CACH,CAAC,OAAO,CAAC,cAAc,CAAC,0BAA0B,CAAC,CAAC;YACrD,sCAAsC;YACtC,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAChE,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAEpE,8CAA8C;YAC9C,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC;gBACxB,OAAO,EAAE,MAAM;gBACf,WAAW,EAAE,UAAU;gBACvB,MAAM,EAAE,MAAM;gBACd,QAAQ,EAAE,QAAQ;gBAClB,SAAS,EAAE,CAAC,cAAc,CAAC;gBAC3B,gBAAgB,EAAE,IAAI;aACvB,CAAC,CAAC;YACH,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YAC3C,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACjE,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACvE,CAAC;gBAAS,CAAC;YACT,OAAO,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC;QAC5C,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sEAAsE,EAAE,KAAK,IAAI,EAAE;QACpF,MAAM,IAAI,CAAC;YACT,OAAO,EAAE,MAAM;YACf,WAAW,EAAE,UAAU;YACvB,MAAM,EAAE,MAAM;YACd,QAAQ,EAAE,KAAK;SAChB,CAAC,CAAC;QACH,sEAAsE;QACtE,EAAE,CAAC,aAAa,CAAC,UAAU,CAAC,iBAAiB,CAAC,EAAE,4BAA4B,CAAC,CAAC;QAE9E,yEAAyE;QACzE,MAAM,MAAM,CACV,IAAI,CAAC;YACH,OAAO,EAAE,MAAM;YACf,WAAW,EAAE,UAAU;YACvB,MAAM,EAAE,MAAM;YACd,QAAQ,EAAE,QAAQ;YAClB,SAAS,EAAE,CAAC,YAAY,CAAC;SAC1B,CAAC,CACH,CAAC,OAAO,CAAC,cAAc,CAAC,uBAAuB,CAAC,CAAC;QAClD,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAChE,MAAM,CAAC,EAAE,CAAC,YAAY,CAAC,UAAU,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAClE,4BAA4B,CAC7B,CAAC;QAEF,yEAAyE;QACzE,+BAA+B;QAC/B,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC;YACxB,OAAO,EAAE,MAAM;YACf,WAAW,EAAE,UAAU;YACvB,MAAM,EAAE,MAAM;YACd,QAAQ,EAAE,QAAQ;YAClB,SAAS,EAAE,CAAC,YAAY,CAAC;YACzB,gBAAgB,EAAE,IAAI;SACvB,CAAC,CAAC;QACH,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC3C,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -5,6 +5,7 @@
5
5
  * Never auto-overwrites local changes — prompts on conflict.
6
6
  */
7
7
  import type { VaultServiceConfig } from "../types.js";
8
+ import type { SyncMode } from "../vault-client.js";
8
9
  import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
9
10
  /**
10
11
  * Per-file events emitted by `sync()` as it progresses.
@@ -228,6 +229,40 @@ export interface SyncOptions {
228
229
  * TS runner and Rust first-push share idempotency state.
229
230
  */
230
231
  journalSlug?: string;
232
+ /**
233
+ * Effective sync mode for this leg (US-005 wiring). Defaults to `"all"`
234
+ * when absent, preserving the legacy full-bucket pull. The runner resolves
235
+ * this from the membership's sync-config (`getMembershipSyncConfig`).
236
+ *
237
+ * SECURITY NOTE: this is a footprint/UX filter, NOT an authorization
238
+ * boundary. The security boundary is the server (STS credential scope +
239
+ * ACL). An owner's STS is wide (role-bypass), so this client-side scope is
240
+ * what makes selective download durable for owners — but it never grants
241
+ * access beyond what STS already permits.
242
+ */
243
+ syncMode?: SyncMode;
244
+ /**
245
+ * Coalesced, COMPANY-RELATIVE prefixes the current pull is scoped to when
246
+ * `syncMode` is `"shared"` or `"custom"` (same namespace as `RemoteFile.key`
247
+ * and the per-slug journal keys — e.g. `"knowledge/"`, `"projects/x/"`).
248
+ * Ignored when `syncMode` is `"all"`. The runner derives this from the
249
+ * caller's explicit grants (`shared`) or `customPaths` (`custom`) and is
250
+ * responsible for normalizing into the company-relative namespace.
251
+ *
252
+ * A `shared` leg with an empty/undefined `prefixSet` means "nothing is
253
+ * shared with me" → download nothing. The runner MUST fall back to `"all"`
254
+ * (not empty `"shared"`) on any grant-resolution error, so a transient
255
+ * failure can never silently prune the local tree.
256
+ */
257
+ prefixSet?: string[];
258
+ /**
259
+ * When the effective scope shrinks relative to the last pull and the shrink
260
+ * would orphan locally-modified ("dirty") files, `sync()` aborts with a
261
+ * `ScopeShrinkBlockedError` by default. Set `true` to proceed anyway:
262
+ * dirty files are LEFT ON DISK and only their journal entries are
263
+ * tombstoned. Mirrors `hq sync narrow --force`.
264
+ */
265
+ forceScopeShrink?: boolean;
231
266
  }
232
267
  export interface SyncResult {
233
268
  filesDownloaded: number;
@@ -272,7 +307,36 @@ export interface SyncResult {
272
307
  * disappeared from the remote.
273
308
  */
274
309
  filesTombstoned: number;
310
+ /**
311
+ * Count of remote keys NOT downloaded this run because they fall outside
312
+ * the effective `syncMode` scope (US-005). Always 0 in `all` mode. Distinct
313
+ * from `filesSkipped` (which measures "unchanged on this run") so consumers
314
+ * can render a "N outside your sync scope" line. The matching local cleanup
315
+ * of previously-downloaded-now-out-of-scope files is reported via
316
+ * `scopeOrphansRemoved`.
317
+ */
318
+ filesOutOfScope: number;
319
+ /**
320
+ * Clean local orphans deleted this run because a scope shrink moved them
321
+ * outside the effective scope (US-005). 0 when scope did not shrink.
322
+ */
323
+ scopeOrphansRemoved: number;
324
+ /**
325
+ * Dirty (locally-modified) orphans that a scope shrink would have pruned.
326
+ * When `forceScopeShrink` is false these are surfaced via a thrown
327
+ * `ScopeShrinkBlockedError` and the leg never reaches this result; when
328
+ * true they are left on disk and tombstoned, and counted here.
329
+ */
330
+ scopeOrphansBlocked: number;
275
331
  }
332
+ /**
333
+ * Resolve the auto-prune safety cap (US-005 bulk-delete guard). An automatic
334
+ * scope shrink that would delete more than this many CLEAN local files in one
335
+ * pull is refused with `ScopeShrinkLargePruneError`. Default 100; `0` (or a
336
+ * non-positive / unparseable value) disables the cap (unlimited). Override via
337
+ * `HQ_SYNC_MAX_AUTO_PRUNE`.
338
+ */
339
+ export declare function resolveAutoPruneCap(): number;
276
340
  /**
277
341
  * Sync (pull) all allowed files from the entity vault.
278
342
  */
@@ -1 +1 @@
1
- {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/cli/sync.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/cli/sync.ts"],"names":[],"mappings":"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"}
package/dist/cli/sync.js CHANGED
@@ -8,12 +8,29 @@ import * as fs from "fs";
8
8
  import * as path from "path";
9
9
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
10
10
  import { downloadFile, listRemoteFiles, headRemoteFile } from "../s3.js";
11
- import { readJournal, writeJournal, hashFile, hashSymlinkTarget, updateEntry, removeEntry, getEntry, normalizeEtag, } from "../journal.js";
11
+ import { readJournal, writeJournal, hashFile, hashSymlinkTarget, updateEntry, removeEntry, getEntry, normalizeEtag, migrateToV2, gcTombstones, lastPullRecord, appendPullRecord, generatePullId, } from "../journal.js";
12
+ import { buildScopeShrinkPlan, applyScopeShrink, ScopeShrinkBlockedError, ScopeShrinkLargePruneError, } from "../scope-shrink.js";
13
+ import { coalescePrefixes, isCoveredByAny } from "../prefix-coalesce.js";
12
14
  import { createIgnoreFilter } from "../ignore.js";
13
15
  import { isEphemeralPath } from "./share.js";
14
16
  import { resolveConflict } from "./conflict.js";
15
17
  import { buildConflictId, buildConflictPath, readShortMachineId, } from "../lib/conflict-file.js";
16
18
  import { appendConflictEntry } from "../lib/conflict-index.js";
19
+ /**
20
+ * Resolve the auto-prune safety cap (US-005 bulk-delete guard). An automatic
21
+ * scope shrink that would delete more than this many CLEAN local files in one
22
+ * pull is refused with `ScopeShrinkLargePruneError`. Default 100; `0` (or a
23
+ * non-positive / unparseable value) disables the cap (unlimited). Override via
24
+ * `HQ_SYNC_MAX_AUTO_PRUNE`.
25
+ */
26
+ export function resolveAutoPruneCap() {
27
+ const raw = process.env.HQ_SYNC_MAX_AUTO_PRUNE;
28
+ if (raw === undefined || raw === "")
29
+ return 100;
30
+ const parsed = Number.parseInt(raw, 10);
31
+ // NaN or negative → treat as "unlimited" (0) rather than silently capping.
32
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
33
+ }
17
34
  /**
18
35
  * Sync (pull) all allowed files from the entity vault.
19
36
  */
@@ -41,19 +58,36 @@ export async function sync(options) {
41
58
  : path.join(hqRoot, "companies", ctx.slug);
42
59
  const shouldSync = createIgnoreFilter(hqRoot);
43
60
  const journalSlug = options.journalSlug ?? ctx.slug;
44
- const journal = readJournal(journalSlug);
61
+ const startedAt = new Date().toISOString();
62
+ // Migrate v1 → v2 in place so the scope-shrink / pull-record machinery has
63
+ // its fields, and GC any tombstones past the 30-day retention window before
64
+ // we re-evaluate orphans (so a long-pruned path can re-download cleanly).
65
+ const journal = migrateToV2(readJournal(journalSlug));
66
+ gcTombstones(journal, Date.now());
67
+ // ── Effective download scope (US-005) ─────────────────────────────────────
68
+ // `all` → prefixSet `[""]`, which `isCoveredByAny` treats as "covers
69
+ // everything" — so the download filter and the scope-shrink
70
+ // comparison both become no-ops, preserving legacy full-bucket
71
+ // behavior bit-for-bit.
72
+ // `shared`/`custom` → the coalesced, company-relative prefix set the runner
73
+ // resolved. An empty set means "nothing in scope" → download
74
+ // nothing (the runner falls back to `all` on resolution errors, so
75
+ // empty here is an intentional "nothing shared", never a failure).
76
+ const syncMode = options.syncMode ?? "all";
77
+ const currentPrefixSet = syncMode === "all" ? [""] : coalescePrefixes(options.prefixSet ?? []);
45
78
  let filesDownloaded = 0;
46
79
  let bytesDownloaded = 0;
47
80
  let filesSkipped = 0;
48
81
  let conflicts = 0;
49
82
  let filesTombstoned = 0;
83
+ let filesOutOfScope = 0;
50
84
  const conflictPaths = [];
51
85
  // List all remote files (IAM session policy filters at the AWS layer)
52
86
  const remoteFiles = await listRemoteFiles(ctx);
53
87
  // Stage 1: classify every remote file against the journal + local disk.
54
88
  // Hashing happens here (not in the transfer loop) so the plan event below
55
89
  // carries an accurate denominator before any progress events fire.
56
- const plan = computePullPlan(remoteFiles, journal, companyRoot, shouldSync, options.personalMode === true, options.includeLocalCompanies === true, options.teamSyncedSlugs ?? null);
90
+ const plan = computePullPlan(remoteFiles, journal, companyRoot, shouldSync, options.personalMode === true, options.includeLocalCompanies === true, options.teamSyncedSlugs ?? null, currentPrefixSet);
57
91
  emit({
58
92
  type: "plan",
59
93
  filesToDownload: plan.filesToDownload,
@@ -65,6 +99,68 @@ export async function sync(options) {
65
99
  filesToConflict: plan.filesToConflict,
66
100
  filesToDelete: 0,
67
101
  });
102
+ // ── Scope-shrink cleanup (US-005) ─────────────────────────────────────────
103
+ // If the effective scope narrowed since the last pull, files that were
104
+ // pulled under the old scope but fall outside the new one are orphans. We
105
+ // delete only CLEAN orphans (provably unchanged since last sync); dirty
106
+ // (locally-modified) orphans are sacred. By default a dirty orphan aborts
107
+ // the leg with a structured error the CLI renders; `forceScopeShrink` keeps
108
+ // dirty files on disk and only tombstones their journal entries.
109
+ //
110
+ // `companyRoot` is passed as the module's `hqRoot` so its `path.join(root,
111
+ // key)` resolves company-relative journal keys correctly (the scope-shrink
112
+ // module is namespace-agnostic — root + keys + prefixSet must simply agree).
113
+ //
114
+ // Note: this is the durable selective-download fix for OWNERS. An owner's
115
+ // STS is wide (role-bypass), so the remote LIST returns everything and the
116
+ // AWS layer never narrows the pull. This client-side shrink is what makes
117
+ // `hq sync mode shared` actually stick across re-syncs for an owner.
118
+ const lastRecord = lastPullRecord(journal, ctx.uid);
119
+ // A missing record, or a v1-migrated record with an empty prefixSet, means
120
+ // "no recorded scope" → treat the last scope as full-bucket `all` (`[""]`),
121
+ // per the PullRecord.prefixSet contract in types.ts.
122
+ const lastPrefixSet = lastRecord && lastRecord.prefixSet.length > 0
123
+ ? lastRecord.prefixSet
124
+ : [""];
125
+ const shrinkPlan = buildScopeShrinkPlan({
126
+ journal,
127
+ hqRoot: companyRoot,
128
+ lastPrefixSet,
129
+ currentPrefixSet,
130
+ });
131
+ if (shrinkPlan.dirty.length > 0 && options.forceScopeShrink !== true) {
132
+ throw new ScopeShrinkBlockedError(ctx.uid, lastRecord?.syncMode ?? "unknown", syncMode, shrinkPlan.dirty, shrinkPlan.clean);
133
+ }
134
+ // Bulk-delete guard: refuse to auto-prune more than the safety cap of CLEAN
135
+ // files in a single background sync. A deliberate large narrow goes through
136
+ // `hq sync narrow --apply` (its own confirmation), and `--force-scope-shrink`
137
+ // (or raising HQ_SYNC_MAX_AUTO_PRUNE) overrides. Cap of 0 = unlimited (opt
138
+ // out). The engine deletes nothing when it throws here.
139
+ const autoPruneCap = resolveAutoPruneCap();
140
+ if (options.forceScopeShrink !== true &&
141
+ autoPruneCap > 0 &&
142
+ shrinkPlan.clean.length > autoPruneCap) {
143
+ throw new ScopeShrinkLargePruneError(ctx.uid, syncMode, shrinkPlan.clean.length, autoPruneCap);
144
+ }
145
+ const shrinkResult = applyScopeShrink({
146
+ journal,
147
+ plan: shrinkPlan,
148
+ hqRoot: companyRoot,
149
+ forceScopeShrink: options.forceScopeShrink === true,
150
+ reason: "scope_shrink",
151
+ });
152
+ // Surface each removed clean orphan as a `deleted` progress event so the
153
+ // menubar stream renders the prune the same way it renders a cross-machine
154
+ // tombstone (the Rust parser already handles `deleted: true`).
155
+ for (const orphan of shrinkPlan.clean) {
156
+ emit({
157
+ type: "progress",
158
+ path: orphan.path,
159
+ bytes: 0,
160
+ deleted: true,
161
+ message: "scope-narrowed (removed local copy outside sync scope)",
162
+ });
163
+ }
68
164
  // Stage 2: execute the plan. Per-item branching mirrors the pre-refactor
69
165
  // inline loop; the only structural change is that classification has
70
166
  // already happened (so `localHash` is reused instead of re-hashing).
@@ -115,6 +211,13 @@ export async function sync(options) {
115
211
  // run", not a catch-all for everything we didn't download.
116
212
  continue;
117
213
  }
214
+ if (item.action === "skip-out-of-scope") {
215
+ // Outside the effective `syncMode` scope (US-005). Counted on its own
216
+ // axis so `filesSkipped` keeps meaning "unchanged on this run" — these
217
+ // are "deliberately not downloaded because of your sync scope".
218
+ filesOutOfScope++;
219
+ continue;
220
+ }
118
221
  if (item.action === "download") {
119
222
  downloadItems.push(item);
120
223
  continue;
@@ -195,6 +298,12 @@ export async function sync(options) {
195
298
  // 0 so the field shape stays stable for consumers that
196
299
  // destructure it.
197
300
  filesTombstoned: 0,
301
+ // Scope-shrink ran before execution, so its counts are real even on
302
+ // a conflict abort. `filesOutOfScope` reflects how far the serial
303
+ // pass got before the abort; that's acceptable for an abort result.
304
+ filesOutOfScope,
305
+ scopeOrphansRemoved: shrinkResult.cleanRemoved,
306
+ scopeOrphansBlocked: shrinkResult.dirtyTombstoned,
198
307
  };
199
308
  break;
200
309
  }
@@ -485,6 +594,21 @@ export async function sync(options) {
485
594
  message: removedSomething ? "tombstone (cross-machine delete)" : "tombstone (already absent locally)",
486
595
  });
487
596
  }
597
+ // Record this pull's boundary (US-005) so the NEXT pull can diff its scope
598
+ // against ours and detect a shrink. Append before the journal write so it
599
+ // persists. `prefixSet` is stored in the same company-relative namespace as
600
+ // the journal keys; `all` mode records `[""]` (covers everything).
601
+ appendPullRecord(journal, {
602
+ pullId: generatePullId(),
603
+ companyUid: ctx.uid,
604
+ startedAt,
605
+ completedAt: new Date().toISOString(),
606
+ syncMode,
607
+ prefixSet: currentPrefixSet,
608
+ scopeChangeDetected: shrinkPlan.scopeChangeDetected,
609
+ orphansRemoved: shrinkResult.cleanRemoved,
610
+ orphansBlocked: shrinkResult.dirtyTombstoned,
611
+ });
488
612
  // Stamp lastSync on every successful run so the menubar's "Last sync · X ago"
489
613
  // ticks even when nothing transferred. updateEntry only fires on actual
490
614
  // downloads; without this, a no-op sync leaves lastSync at the time of the
@@ -502,6 +626,9 @@ export async function sync(options) {
502
626
  newFilesCount: plan.newFilesCount,
503
627
  filesExcludedByPolicy: plan.filesExcludedByPolicy,
504
628
  filesTombstoned,
629
+ filesOutOfScope,
630
+ scopeOrphansRemoved: shrinkResult.cleanRemoved,
631
+ scopeOrphansBlocked: shrinkResult.dirtyTombstoned,
505
632
  };
506
633
  }
507
634
  /**
@@ -544,7 +671,11 @@ function hasRemoteChanged(remote, entry) {
544
671
  * caller (`sync()`) is responsible for emitting the resulting plan event
545
672
  * before iterating `items`.
546
673
  */
547
- function computePullPlan(remoteFiles, journal, companyRoot, shouldSync, personalMode, includeLocalCompanies, teamSyncedSlugs) {
674
+ function computePullPlan(remoteFiles, journal, companyRoot, shouldSync, personalMode, includeLocalCompanies, teamSyncedSlugs,
675
+ // Coalesced, company-relative prefixes the pull is scoped to (US-005).
676
+ // `[""]` (the `all`-mode value) covers everything via `isCoveredByAny`, so
677
+ // the scope filter below becomes a no-op and legacy behavior is preserved.
678
+ prefixSet) {
548
679
  const items = [];
549
680
  for (const remoteFile of remoteFiles) {
550
681
  const localPath = path.join(companyRoot, remoteFile.key);
@@ -583,6 +714,16 @@ function computePullPlan(remoteFiles, journal, companyRoot, shouldSync, personal
583
714
  continue;
584
715
  }
585
716
  }
717
+ // Scope filter (US-005). Keys outside the effective `syncMode` prefix set
718
+ // are not downloaded. `prefixSet` is `[""]` in `all` mode, which
719
+ // `isCoveredByAny` treats as covering everything — so this is a no-op for
720
+ // `all` and preserves the legacy full-bucket pull bit-for-bit. The
721
+ // previously-downloaded counterparts of these keys (if scope just shrank)
722
+ // are pruned separately by the scope-shrink pass in `sync()`.
723
+ if (!isCoveredByAny(remoteFile.key, prefixSet)) {
724
+ items.push({ action: "skip-out-of-scope", remoteFile, localPath });
725
+ continue;
726
+ }
586
727
  // LIST gives us no kind signal for the remote object — we don't
587
728
  // know whether this key is a regular file or a symlink record
588
729
  // until we either HEAD it (expensive — N extra calls per pull) or
@@ -729,6 +870,7 @@ function computePullPlan(remoteFiles, journal, companyRoot, shouldSync, personal
729
870
  let filesToSkip = 0;
730
871
  let filesToConflict = 0;
731
872
  let filesExcludedByPolicy = 0;
873
+ let filesOutOfScope = 0;
732
874
  const newFiles = [];
733
875
  for (const item of items) {
734
876
  if (item.action === "download") {
@@ -748,6 +890,11 @@ function computePullPlan(remoteFiles, journal, companyRoot, shouldSync, personal
748
890
  // can render a "N refused by policy" line independently of the
749
891
  // generic "N unchanged" tally.
750
892
  }
893
+ else if (item.action === "skip-out-of-scope") {
894
+ // Out-of-scope items get their own axis too, mirroring excluded-policy:
895
+ // they're "deliberately not downloaded (sync scope)", not "unchanged".
896
+ filesOutOfScope++;
897
+ }
751
898
  else {
752
899
  filesToSkip++;
753
900
  }
@@ -856,6 +1003,7 @@ function computePullPlan(remoteFiles, journal, companyRoot, shouldSync, personal
856
1003
  newFiles,
857
1004
  newFilesCount: newFiles.length,
858
1005
  filesExcludedByPolicy,
1006
+ filesOutOfScope,
859
1007
  tombstones,
860
1008
  };
861
1009
  }