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