@indigoai-us/hq-cloud 5.41.0 → 5.43.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.
- package/dist/bin/sync-runner.d.ts +26 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +90 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +168 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync-scope.test.d.ts +22 -0
- package/dist/cli/sync-scope.test.d.ts.map +1 -0
- package/dist/cli/sync-scope.test.js +273 -0
- package/dist/cli/sync-scope.test.js.map +1 -0
- package/dist/cli/sync.d.ts +64 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +152 -4
- package/dist/cli/sync.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +29 -0
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +48 -0
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +51 -1
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +18 -0
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +28 -0
- package/dist/scope-shrink.js.map +1 -1
- package/dist/vault-client.d.ts +22 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +14 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +18 -0
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +222 -0
- package/src/bin/sync-runner.ts +108 -0
- package/src/cli/sync-scope.test.ts +307 -0
- package/src/cli/sync.ts +240 -1
- package/src/index.ts +1 -0
- package/src/prefix-coalesce.test.ts +76 -1
- package/src/prefix-coalesce.ts +45 -0
- package/src/scope-shrink.ts +28 -0
- package/src/vault-client.test.ts +24 -0
- package/src/vault-client.ts +24 -0
|
@@ -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":"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"}
|
package/dist/cli/sync.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/cli/sync.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/cli/sync.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,kBAAkB,EAAe,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/cli/sync.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,kBAAkB,EAAe,MAAM,aAAa,CAAC;AACnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AA6BnD,OAAO,KAAK,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAQ1E;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,iBAAiB,GACzB;IACE,IAAI,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,eAAe,EAAE,MAAM,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,iEAAiE;IACjE,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,0EAA0E;IAC1E,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,eAAe,EAAE,MAAM,CAAC;IACxB;;;;;;;OAOG;IACH,aAAa,EAAE,MAAM,CAAC;CACvB,GACD;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;IAC1B;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB,GACD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAChD;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;IAC3B,UAAU,EAAE,kBAAkB,CAAC;CAChC,GACD;IACE,IAAI,EAAE,WAAW,CAAC;IAClB,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC;CACvE,GACD;IACE;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACH,IAAI,EAAE,2BAA2B,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,YAAY,GAAG,gBAAgB,GAAG,gBAAgB,CAAC;CAC5D,GACD;IACE;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,IAAI,EAAE,+BAA+B,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB,GACD;IACE;;;;;;;;;;;;;;;OAeG;IACH,IAAI,EAAE,8BAA8B,CAAC;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9B,CAAC;AAEN,MAAM,WAAW,WAAW;IAC1B,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wCAAwC;IACxC,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,2BAA2B;IAC3B,WAAW,EAAE,kBAAkB,CAAC;IAChC,wBAAwB;IACxB,MAAM,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC7C;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;;;;;;;;OAaG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC;;;;;;;;OAQG;IACH,eAAe,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACtC;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;;;;;;;;;;OAYG;IACH,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,UAAU;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjD,4CAA4C;IAC5C,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;;;OAOG;IACH,qBAAqB,EAAE,MAAM,CAAC;IAC9B;;;;;;;;;OASG;IACH,eAAe,EAAE,MAAM,CAAC;IACxB;;;;;;;OAOG;IACH,eAAe,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,mBAAmB,EAAE,MAAM,CAAC;IAC5B;;;;;OAKG;IACH,mBAAmB,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,IAAI,MAAM,CAM5C;AAED;;GAEG;AACH,wBAAsB,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,CA+qBpE"}
|
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
|
|
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
|
}
|