@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.
- 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/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
|
@@ -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
|
+
});
|
package/src/cli/sync.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import * as fs from "fs";
|
|
9
9
|
import * as path from "path";
|
|
10
10
|
import type { VaultServiceConfig, SyncJournal } from "../types.js";
|
|
11
|
+
import type { SyncMode } from "../vault-client.js";
|
|
11
12
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
12
13
|
import { downloadFile, listRemoteFiles, headRemoteFile } from "../s3.js";
|
|
13
14
|
import type { RemoteFile } from "../s3.js";
|
|
@@ -20,7 +21,19 @@ import {
|
|
|
20
21
|
removeEntry,
|
|
21
22
|
getEntry,
|
|
22
23
|
normalizeEtag,
|
|
24
|
+
migrateToV2,
|
|
25
|
+
gcTombstones,
|
|
26
|
+
lastPullRecord,
|
|
27
|
+
appendPullRecord,
|
|
28
|
+
generatePullId,
|
|
23
29
|
} from "../journal.js";
|
|
30
|
+
import {
|
|
31
|
+
buildScopeShrinkPlan,
|
|
32
|
+
applyScopeShrink,
|
|
33
|
+
ScopeShrinkBlockedError,
|
|
34
|
+
ScopeShrinkLargePruneError,
|
|
35
|
+
} from "../scope-shrink.js";
|
|
36
|
+
import { coalescePrefixes, isCoveredByAny } from "../prefix-coalesce.js";
|
|
24
37
|
import { createIgnoreFilter } from "../ignore.js";
|
|
25
38
|
import { isEphemeralPath } from "./share.js";
|
|
26
39
|
import { resolveConflict } from "./conflict.js";
|
|
@@ -255,6 +268,40 @@ export interface SyncOptions {
|
|
|
255
268
|
* TS runner and Rust first-push share idempotency state.
|
|
256
269
|
*/
|
|
257
270
|
journalSlug?: string;
|
|
271
|
+
/**
|
|
272
|
+
* Effective sync mode for this leg (US-005 wiring). Defaults to `"all"`
|
|
273
|
+
* when absent, preserving the legacy full-bucket pull. The runner resolves
|
|
274
|
+
* this from the membership's sync-config (`getMembershipSyncConfig`).
|
|
275
|
+
*
|
|
276
|
+
* SECURITY NOTE: this is a footprint/UX filter, NOT an authorization
|
|
277
|
+
* boundary. The security boundary is the server (STS credential scope +
|
|
278
|
+
* ACL). An owner's STS is wide (role-bypass), so this client-side scope is
|
|
279
|
+
* what makes selective download durable for owners — but it never grants
|
|
280
|
+
* access beyond what STS already permits.
|
|
281
|
+
*/
|
|
282
|
+
syncMode?: SyncMode;
|
|
283
|
+
/**
|
|
284
|
+
* Coalesced, COMPANY-RELATIVE prefixes the current pull is scoped to when
|
|
285
|
+
* `syncMode` is `"shared"` or `"custom"` (same namespace as `RemoteFile.key`
|
|
286
|
+
* and the per-slug journal keys — e.g. `"knowledge/"`, `"projects/x/"`).
|
|
287
|
+
* Ignored when `syncMode` is `"all"`. The runner derives this from the
|
|
288
|
+
* caller's explicit grants (`shared`) or `customPaths` (`custom`) and is
|
|
289
|
+
* responsible for normalizing into the company-relative namespace.
|
|
290
|
+
*
|
|
291
|
+
* A `shared` leg with an empty/undefined `prefixSet` means "nothing is
|
|
292
|
+
* shared with me" → download nothing. The runner MUST fall back to `"all"`
|
|
293
|
+
* (not empty `"shared"`) on any grant-resolution error, so a transient
|
|
294
|
+
* failure can never silently prune the local tree.
|
|
295
|
+
*/
|
|
296
|
+
prefixSet?: string[];
|
|
297
|
+
/**
|
|
298
|
+
* When the effective scope shrinks relative to the last pull and the shrink
|
|
299
|
+
* would orphan locally-modified ("dirty") files, `sync()` aborts with a
|
|
300
|
+
* `ScopeShrinkBlockedError` by default. Set `true` to proceed anyway:
|
|
301
|
+
* dirty files are LEFT ON DISK and only their journal entries are
|
|
302
|
+
* tombstoned. Mirrors `hq sync narrow --force`.
|
|
303
|
+
*/
|
|
304
|
+
forceScopeShrink?: boolean;
|
|
258
305
|
}
|
|
259
306
|
|
|
260
307
|
export interface SyncResult {
|
|
@@ -297,6 +344,42 @@ export interface SyncResult {
|
|
|
297
344
|
* disappeared from the remote.
|
|
298
345
|
*/
|
|
299
346
|
filesTombstoned: number;
|
|
347
|
+
/**
|
|
348
|
+
* Count of remote keys NOT downloaded this run because they fall outside
|
|
349
|
+
* the effective `syncMode` scope (US-005). Always 0 in `all` mode. Distinct
|
|
350
|
+
* from `filesSkipped` (which measures "unchanged on this run") so consumers
|
|
351
|
+
* can render a "N outside your sync scope" line. The matching local cleanup
|
|
352
|
+
* of previously-downloaded-now-out-of-scope files is reported via
|
|
353
|
+
* `scopeOrphansRemoved`.
|
|
354
|
+
*/
|
|
355
|
+
filesOutOfScope: number;
|
|
356
|
+
/**
|
|
357
|
+
* Clean local orphans deleted this run because a scope shrink moved them
|
|
358
|
+
* outside the effective scope (US-005). 0 when scope did not shrink.
|
|
359
|
+
*/
|
|
360
|
+
scopeOrphansRemoved: number;
|
|
361
|
+
/**
|
|
362
|
+
* Dirty (locally-modified) orphans that a scope shrink would have pruned.
|
|
363
|
+
* When `forceScopeShrink` is false these are surfaced via a thrown
|
|
364
|
+
* `ScopeShrinkBlockedError` and the leg never reaches this result; when
|
|
365
|
+
* true they are left on disk and tombstoned, and counted here.
|
|
366
|
+
*/
|
|
367
|
+
scopeOrphansBlocked: number;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Resolve the auto-prune safety cap (US-005 bulk-delete guard). An automatic
|
|
372
|
+
* scope shrink that would delete more than this many CLEAN local files in one
|
|
373
|
+
* pull is refused with `ScopeShrinkLargePruneError`. Default 100; `0` (or a
|
|
374
|
+
* non-positive / unparseable value) disables the cap (unlimited). Override via
|
|
375
|
+
* `HQ_SYNC_MAX_AUTO_PRUNE`.
|
|
376
|
+
*/
|
|
377
|
+
export function resolveAutoPruneCap(): number {
|
|
378
|
+
const raw = process.env.HQ_SYNC_MAX_AUTO_PRUNE;
|
|
379
|
+
if (raw === undefined || raw === "") return 100;
|
|
380
|
+
const parsed = Number.parseInt(raw, 10);
|
|
381
|
+
// NaN or negative → treat as "unlimited" (0) rather than silently capping.
|
|
382
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
300
383
|
}
|
|
301
384
|
|
|
302
385
|
/**
|
|
@@ -330,13 +413,32 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
330
413
|
: path.join(hqRoot, "companies", ctx.slug);
|
|
331
414
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
332
415
|
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
333
|
-
const
|
|
416
|
+
const startedAt = new Date().toISOString();
|
|
417
|
+
// Migrate v1 → v2 in place so the scope-shrink / pull-record machinery has
|
|
418
|
+
// its fields, and GC any tombstones past the 30-day retention window before
|
|
419
|
+
// we re-evaluate orphans (so a long-pruned path can re-download cleanly).
|
|
420
|
+
const journal = migrateToV2(readJournal(journalSlug));
|
|
421
|
+
gcTombstones(journal, Date.now());
|
|
422
|
+
|
|
423
|
+
// ── Effective download scope (US-005) ─────────────────────────────────────
|
|
424
|
+
// `all` → prefixSet `[""]`, which `isCoveredByAny` treats as "covers
|
|
425
|
+
// everything" — so the download filter and the scope-shrink
|
|
426
|
+
// comparison both become no-ops, preserving legacy full-bucket
|
|
427
|
+
// behavior bit-for-bit.
|
|
428
|
+
// `shared`/`custom` → the coalesced, company-relative prefix set the runner
|
|
429
|
+
// resolved. An empty set means "nothing in scope" → download
|
|
430
|
+
// nothing (the runner falls back to `all` on resolution errors, so
|
|
431
|
+
// empty here is an intentional "nothing shared", never a failure).
|
|
432
|
+
const syncMode: SyncMode = options.syncMode ?? "all";
|
|
433
|
+
const currentPrefixSet =
|
|
434
|
+
syncMode === "all" ? [""] : coalescePrefixes(options.prefixSet ?? []);
|
|
334
435
|
|
|
335
436
|
let filesDownloaded = 0;
|
|
336
437
|
let bytesDownloaded = 0;
|
|
337
438
|
let filesSkipped = 0;
|
|
338
439
|
let conflicts = 0;
|
|
339
440
|
let filesTombstoned = 0;
|
|
441
|
+
let filesOutOfScope = 0;
|
|
340
442
|
const conflictPaths: string[] = [];
|
|
341
443
|
|
|
342
444
|
// List all remote files (IAM session policy filters at the AWS layer)
|
|
@@ -353,6 +455,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
353
455
|
options.personalMode === true,
|
|
354
456
|
options.includeLocalCompanies === true,
|
|
355
457
|
options.teamSyncedSlugs ?? null,
|
|
458
|
+
currentPrefixSet,
|
|
356
459
|
);
|
|
357
460
|
|
|
358
461
|
emit({
|
|
@@ -367,6 +470,83 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
367
470
|
filesToDelete: 0,
|
|
368
471
|
});
|
|
369
472
|
|
|
473
|
+
// ── Scope-shrink cleanup (US-005) ─────────────────────────────────────────
|
|
474
|
+
// If the effective scope narrowed since the last pull, files that were
|
|
475
|
+
// pulled under the old scope but fall outside the new one are orphans. We
|
|
476
|
+
// delete only CLEAN orphans (provably unchanged since last sync); dirty
|
|
477
|
+
// (locally-modified) orphans are sacred. By default a dirty orphan aborts
|
|
478
|
+
// the leg with a structured error the CLI renders; `forceScopeShrink` keeps
|
|
479
|
+
// dirty files on disk and only tombstones their journal entries.
|
|
480
|
+
//
|
|
481
|
+
// `companyRoot` is passed as the module's `hqRoot` so its `path.join(root,
|
|
482
|
+
// key)` resolves company-relative journal keys correctly (the scope-shrink
|
|
483
|
+
// module is namespace-agnostic — root + keys + prefixSet must simply agree).
|
|
484
|
+
//
|
|
485
|
+
// Note: this is the durable selective-download fix for OWNERS. An owner's
|
|
486
|
+
// STS is wide (role-bypass), so the remote LIST returns everything and the
|
|
487
|
+
// AWS layer never narrows the pull. This client-side shrink is what makes
|
|
488
|
+
// `hq sync mode shared` actually stick across re-syncs for an owner.
|
|
489
|
+
const lastRecord = lastPullRecord(journal, ctx.uid);
|
|
490
|
+
// A missing record, or a v1-migrated record with an empty prefixSet, means
|
|
491
|
+
// "no recorded scope" → treat the last scope as full-bucket `all` (`[""]`),
|
|
492
|
+
// per the PullRecord.prefixSet contract in types.ts.
|
|
493
|
+
const lastPrefixSet =
|
|
494
|
+
lastRecord && lastRecord.prefixSet.length > 0
|
|
495
|
+
? lastRecord.prefixSet
|
|
496
|
+
: [""];
|
|
497
|
+
const shrinkPlan = buildScopeShrinkPlan({
|
|
498
|
+
journal,
|
|
499
|
+
hqRoot: companyRoot,
|
|
500
|
+
lastPrefixSet,
|
|
501
|
+
currentPrefixSet,
|
|
502
|
+
});
|
|
503
|
+
if (shrinkPlan.dirty.length > 0 && options.forceScopeShrink !== true) {
|
|
504
|
+
throw new ScopeShrinkBlockedError(
|
|
505
|
+
ctx.uid,
|
|
506
|
+
lastRecord?.syncMode ?? "unknown",
|
|
507
|
+
syncMode,
|
|
508
|
+
shrinkPlan.dirty,
|
|
509
|
+
shrinkPlan.clean,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
// Bulk-delete guard: refuse to auto-prune more than the safety cap of CLEAN
|
|
513
|
+
// files in a single background sync. A deliberate large narrow goes through
|
|
514
|
+
// `hq sync narrow --apply` (its own confirmation), and `--force-scope-shrink`
|
|
515
|
+
// (or raising HQ_SYNC_MAX_AUTO_PRUNE) overrides. Cap of 0 = unlimited (opt
|
|
516
|
+
// out). The engine deletes nothing when it throws here.
|
|
517
|
+
const autoPruneCap = resolveAutoPruneCap();
|
|
518
|
+
if (
|
|
519
|
+
options.forceScopeShrink !== true &&
|
|
520
|
+
autoPruneCap > 0 &&
|
|
521
|
+
shrinkPlan.clean.length > autoPruneCap
|
|
522
|
+
) {
|
|
523
|
+
throw new ScopeShrinkLargePruneError(
|
|
524
|
+
ctx.uid,
|
|
525
|
+
syncMode,
|
|
526
|
+
shrinkPlan.clean.length,
|
|
527
|
+
autoPruneCap,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
const shrinkResult = applyScopeShrink({
|
|
531
|
+
journal,
|
|
532
|
+
plan: shrinkPlan,
|
|
533
|
+
hqRoot: companyRoot,
|
|
534
|
+
forceScopeShrink: options.forceScopeShrink === true,
|
|
535
|
+
reason: "scope_shrink",
|
|
536
|
+
});
|
|
537
|
+
// Surface each removed clean orphan as a `deleted` progress event so the
|
|
538
|
+
// menubar stream renders the prune the same way it renders a cross-machine
|
|
539
|
+
// tombstone (the Rust parser already handles `deleted: true`).
|
|
540
|
+
for (const orphan of shrinkPlan.clean) {
|
|
541
|
+
emit({
|
|
542
|
+
type: "progress",
|
|
543
|
+
path: orphan.path,
|
|
544
|
+
bytes: 0,
|
|
545
|
+
deleted: true,
|
|
546
|
+
message: "scope-narrowed (removed local copy outside sync scope)",
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
370
550
|
// Stage 2: execute the plan. Per-item branching mirrors the pre-refactor
|
|
371
551
|
// inline loop; the only structural change is that classification has
|
|
372
552
|
// already happened (so `localHash` is reused instead of re-hashing).
|
|
@@ -419,6 +599,13 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
419
599
|
// run", not a catch-all for everything we didn't download.
|
|
420
600
|
continue;
|
|
421
601
|
}
|
|
602
|
+
if (item.action === "skip-out-of-scope") {
|
|
603
|
+
// Outside the effective `syncMode` scope (US-005). Counted on its own
|
|
604
|
+
// axis so `filesSkipped` keeps meaning "unchanged on this run" — these
|
|
605
|
+
// are "deliberately not downloaded because of your sync scope".
|
|
606
|
+
filesOutOfScope++;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
422
609
|
|
|
423
610
|
if (item.action === "download") {
|
|
424
611
|
downloadItems.push(item);
|
|
@@ -515,6 +702,12 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
515
702
|
// 0 so the field shape stays stable for consumers that
|
|
516
703
|
// destructure it.
|
|
517
704
|
filesTombstoned: 0,
|
|
705
|
+
// Scope-shrink ran before execution, so its counts are real even on
|
|
706
|
+
// a conflict abort. `filesOutOfScope` reflects how far the serial
|
|
707
|
+
// pass got before the abort; that's acceptable for an abort result.
|
|
708
|
+
filesOutOfScope,
|
|
709
|
+
scopeOrphansRemoved: shrinkResult.cleanRemoved,
|
|
710
|
+
scopeOrphansBlocked: shrinkResult.dirtyTombstoned,
|
|
518
711
|
};
|
|
519
712
|
break;
|
|
520
713
|
}
|
|
@@ -841,6 +1034,22 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
841
1034
|
});
|
|
842
1035
|
}
|
|
843
1036
|
|
|
1037
|
+
// Record this pull's boundary (US-005) so the NEXT pull can diff its scope
|
|
1038
|
+
// against ours and detect a shrink. Append before the journal write so it
|
|
1039
|
+
// persists. `prefixSet` is stored in the same company-relative namespace as
|
|
1040
|
+
// the journal keys; `all` mode records `[""]` (covers everything).
|
|
1041
|
+
appendPullRecord(journal, {
|
|
1042
|
+
pullId: generatePullId(),
|
|
1043
|
+
companyUid: ctx.uid,
|
|
1044
|
+
startedAt,
|
|
1045
|
+
completedAt: new Date().toISOString(),
|
|
1046
|
+
syncMode,
|
|
1047
|
+
prefixSet: currentPrefixSet,
|
|
1048
|
+
scopeChangeDetected: shrinkPlan.scopeChangeDetected,
|
|
1049
|
+
orphansRemoved: shrinkResult.cleanRemoved,
|
|
1050
|
+
orphansBlocked: shrinkResult.dirtyTombstoned,
|
|
1051
|
+
});
|
|
1052
|
+
|
|
844
1053
|
// Stamp lastSync on every successful run so the menubar's "Last sync · X ago"
|
|
845
1054
|
// ticks even when nothing transferred. updateEntry only fires on actual
|
|
846
1055
|
// downloads; without this, a no-op sync leaves lastSync at the time of the
|
|
@@ -859,6 +1068,9 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
859
1068
|
newFilesCount: plan.newFilesCount,
|
|
860
1069
|
filesExcludedByPolicy: plan.filesExcludedByPolicy,
|
|
861
1070
|
filesTombstoned,
|
|
1071
|
+
filesOutOfScope,
|
|
1072
|
+
scopeOrphansRemoved: shrinkResult.cleanRemoved,
|
|
1073
|
+
scopeOrphansBlocked: shrinkResult.dirtyTombstoned,
|
|
862
1074
|
};
|
|
863
1075
|
}
|
|
864
1076
|
|
|
@@ -911,6 +1123,10 @@ type PullPlanItem =
|
|
|
911
1123
|
// refused to upload these since 5.33.0; the pull walker now refuses to
|
|
912
1124
|
// download them so legacy litter in cloud staging drains naturally.
|
|
913
1125
|
| { action: "skip-excluded-policy"; remoteFile: RemoteFile; localPath: string }
|
|
1126
|
+
// Remote keys outside the effective `syncMode` scope (US-005). Present in
|
|
1127
|
+
// the remote LIST (and accessible per STS) but deliberately not downloaded
|
|
1128
|
+
// because the membership's sync scope doesn't cover them.
|
|
1129
|
+
| { action: "skip-out-of-scope"; remoteFile: RemoteFile; localPath: string }
|
|
914
1130
|
| {
|
|
915
1131
|
action: "conflict";
|
|
916
1132
|
remoteFile: RemoteFile;
|
|
@@ -945,6 +1161,8 @@ interface PullPlan {
|
|
|
945
1161
|
newFilesCount: number;
|
|
946
1162
|
/** Count of remote keys refused by ephemeral-mirror policy. */
|
|
947
1163
|
filesExcludedByPolicy: number;
|
|
1164
|
+
/** Count of remote keys skipped because they fall outside the sync scope. */
|
|
1165
|
+
filesOutOfScope: number;
|
|
948
1166
|
/**
|
|
949
1167
|
* Journal-known keys missing from the remote LIST. The executor will
|
|
950
1168
|
* apply each as a local delete (file or symlink) + journal removal,
|
|
@@ -973,6 +1191,10 @@ function computePullPlan(
|
|
|
973
1191
|
personalMode: boolean,
|
|
974
1192
|
includeLocalCompanies: boolean,
|
|
975
1193
|
teamSyncedSlugs: ReadonlySet<string> | null,
|
|
1194
|
+
// Coalesced, company-relative prefixes the pull is scoped to (US-005).
|
|
1195
|
+
// `[""]` (the `all`-mode value) covers everything via `isCoveredByAny`, so
|
|
1196
|
+
// the scope filter below becomes a no-op and legacy behavior is preserved.
|
|
1197
|
+
prefixSet: string[],
|
|
976
1198
|
): PullPlan {
|
|
977
1199
|
const items: PullPlanItem[] = [];
|
|
978
1200
|
|
|
@@ -1017,6 +1239,17 @@ function computePullPlan(
|
|
|
1017
1239
|
}
|
|
1018
1240
|
}
|
|
1019
1241
|
|
|
1242
|
+
// Scope filter (US-005). Keys outside the effective `syncMode` prefix set
|
|
1243
|
+
// are not downloaded. `prefixSet` is `[""]` in `all` mode, which
|
|
1244
|
+
// `isCoveredByAny` treats as covering everything — so this is a no-op for
|
|
1245
|
+
// `all` and preserves the legacy full-bucket pull bit-for-bit. The
|
|
1246
|
+
// previously-downloaded counterparts of these keys (if scope just shrank)
|
|
1247
|
+
// are pruned separately by the scope-shrink pass in `sync()`.
|
|
1248
|
+
if (!isCoveredByAny(remoteFile.key, prefixSet)) {
|
|
1249
|
+
items.push({ action: "skip-out-of-scope", remoteFile, localPath });
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1020
1253
|
// LIST gives us no kind signal for the remote object — we don't
|
|
1021
1254
|
// know whether this key is a regular file or a symlink record
|
|
1022
1255
|
// until we either HEAD it (expensive — N extra calls per pull) or
|
|
@@ -1175,6 +1408,7 @@ function computePullPlan(
|
|
|
1175
1408
|
let filesToSkip = 0;
|
|
1176
1409
|
let filesToConflict = 0;
|
|
1177
1410
|
let filesExcludedByPolicy = 0;
|
|
1411
|
+
let filesOutOfScope = 0;
|
|
1178
1412
|
const newFiles: Array<{ path: string; bytes: number }> = [];
|
|
1179
1413
|
for (const item of items) {
|
|
1180
1414
|
if (item.action === "download") {
|
|
@@ -1191,6 +1425,10 @@ function computePullPlan(
|
|
|
1191
1425
|
// distinct class surfaced via filesExcludedByPolicy so consumers
|
|
1192
1426
|
// can render a "N refused by policy" line independently of the
|
|
1193
1427
|
// generic "N unchanged" tally.
|
|
1428
|
+
} else if (item.action === "skip-out-of-scope") {
|
|
1429
|
+
// Out-of-scope items get their own axis too, mirroring excluded-policy:
|
|
1430
|
+
// they're "deliberately not downloaded (sync scope)", not "unchanged".
|
|
1431
|
+
filesOutOfScope++;
|
|
1194
1432
|
} else {
|
|
1195
1433
|
filesToSkip++;
|
|
1196
1434
|
}
|
|
@@ -1294,6 +1532,7 @@ function computePullPlan(
|
|
|
1294
1532
|
newFiles,
|
|
1295
1533
|
newFilesCount: newFiles.length,
|
|
1296
1534
|
filesExcludedByPolicy,
|
|
1535
|
+
filesOutOfScope,
|
|
1297
1536
|
tombstones,
|
|
1298
1537
|
};
|
|
1299
1538
|
}
|