@indigoai-us/hq-cloud 6.11.20 → 6.12.1
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-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +9 -11
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner.test.js +35 -64
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync.d.ts +11 -25
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +16 -17
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +40 -42
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +11 -0
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +18 -0
- package/dist/object-io.test.js.map +1 -1
- package/dist/personal-vault.d.ts +23 -24
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +38 -30
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.d.ts +9 -6
- package/dist/personal-vault.test.d.ts.map +1 -1
- package/dist/personal-vault.test.js +90 -79
- package/dist/personal-vault.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +9 -12
- package/src/bin/sync-runner.test.ts +35 -74
- package/src/cli/sync.test.ts +40 -42
- package/src/cli/sync.ts +25 -42
- package/src/object-io.test.ts +19 -0
- package/src/object-io.ts +11 -0
- package/src/personal-vault.test.ts +91 -83
- package/src/personal-vault.ts +45 -41
|
@@ -2290,16 +2290,16 @@ describe("personal slot fanout", () => {
|
|
|
2290
2290
|
}
|
|
2291
2291
|
});
|
|
2292
2292
|
|
|
2293
|
-
it("G: personal slot includes companies/{slug}/ subdirs
|
|
2294
|
-
//
|
|
2295
|
-
//
|
|
2296
|
-
//
|
|
2297
|
-
|
|
2293
|
+
it("G: personal slot includes local companies/{slug}/ subdirs by default, excluding _template + team-synced slugs + cloud:true markers", async () => {
|
|
2294
|
+
// Local (non-cloud) companies sync to the personal vault unconditionally
|
|
2295
|
+
// — no env-var gate. A missing/markerless company.yaml is INCLUDED; only
|
|
2296
|
+
// _template, team-synced slugs, and an explicit cloud:true marker are
|
|
2297
|
+
// excluded.
|
|
2298
2298
|
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
2299
2299
|
const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
|
|
2300
2300
|
try {
|
|
2301
2301
|
// Top-level personal-vault content — anchors the assertion that the
|
|
2302
|
-
// existing top-level walk still runs alongside the
|
|
2302
|
+
// existing top-level walk still runs alongside the company-subdir
|
|
2303
2303
|
// discovery.
|
|
2304
2304
|
fs.mkdirSync(path.join(tmpHqRoot, "knowledge"));
|
|
2305
2305
|
|
|
@@ -2312,10 +2312,10 @@ describe("personal slot fanout", () => {
|
|
|
2312
2312
|
}
|
|
2313
2313
|
};
|
|
2314
2314
|
mkCompany("free-co", "cloud: false\nname: Free Co\n"); // INCLUDED
|
|
2315
|
+
mkCompany("zilch", null); // INCLUDED — no company.yaml (default-on)
|
|
2315
2316
|
mkCompany("acme", "cloud: false\n"); // EXCLUDED — team-synced (membership below)
|
|
2316
|
-
mkCompany("_template",
|
|
2317
|
-
mkCompany("cloud-team", "cloud: true\n"); // EXCLUDED —
|
|
2318
|
-
mkCompany("zilch", null); // EXCLUDED — no company.yaml at all
|
|
2317
|
+
mkCompany("_template", null); // EXCLUDED — hard-listed slug
|
|
2318
|
+
mkCompany("cloud-team", "cloud: true\n"); // EXCLUDED — cloud-backed marker
|
|
2319
2319
|
|
|
2320
2320
|
const deps = makeDeps({
|
|
2321
2321
|
createVaultClient: () =>
|
|
@@ -2347,17 +2347,17 @@ describe("personal slot fanout", () => {
|
|
|
2347
2347
|
.map((p) => path.relative(tmpHqRoot, p))
|
|
2348
2348
|
.sort();
|
|
2349
2349
|
|
|
2350
|
-
// free-co MUST be present (
|
|
2350
|
+
// free-co + zilch MUST be present (local, no membership, not _template).
|
|
2351
2351
|
expect(rel).toContain(path.join("companies", "free-co"));
|
|
2352
|
+
expect(rel).toContain(path.join("companies", "zilch"));
|
|
2352
2353
|
// knowledge/ MUST still be present (top-level walk unchanged).
|
|
2353
2354
|
expect(rel).toContain("knowledge");
|
|
2354
2355
|
|
|
2355
|
-
//
|
|
2356
|
+
// Excluded companies MUST be filtered out.
|
|
2356
2357
|
for (const forbidden of [
|
|
2357
2358
|
path.join("companies", "acme"), // team-synced — goes to cmp_a's bucket
|
|
2358
2359
|
path.join("companies", "_template"), // hard-listed
|
|
2359
|
-
path.join("companies", "cloud-team"), // cloud:true
|
|
2360
|
-
path.join("companies", "zilch"), // no marker
|
|
2360
|
+
path.join("companies", "cloud-team"), // cloud:true marker
|
|
2361
2361
|
]) {
|
|
2362
2362
|
expect(rel).not.toContain(forbidden);
|
|
2363
2363
|
}
|
|
@@ -2382,21 +2382,20 @@ describe("personal slot fanout", () => {
|
|
|
2382
2382
|
}
|
|
2383
2383
|
});
|
|
2384
2384
|
|
|
2385
|
-
it("H:
|
|
2386
|
-
//
|
|
2387
|
-
//
|
|
2388
|
-
//
|
|
2389
|
-
//
|
|
2390
|
-
// an earlier test bleeds into this one.
|
|
2385
|
+
it("H: local company subdirs are added with NO env var set — inclusion is unconditional (no gate)", async () => {
|
|
2386
|
+
// Regression guard against re-introducing a gate. The old behavior
|
|
2387
|
+
// required HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1; that gate is removed.
|
|
2388
|
+
// With the env var explicitly unset, a markerless local company MUST
|
|
2389
|
+
// still be pushed to the personal vault. Defensive `unstub` in case a
|
|
2390
|
+
// leaked stub from an earlier test bleeds into this one.
|
|
2391
2391
|
vi.unstubAllEnvs();
|
|
2392
2392
|
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
2393
2393
|
const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
|
|
2394
2394
|
try {
|
|
2395
2395
|
fs.mkdirSync(path.join(tmpHqRoot, "knowledge"));
|
|
2396
|
-
//
|
|
2396
|
+
// A markerless local company — must appear even with no env var.
|
|
2397
2397
|
const dir = path.join(tmpHqRoot, "companies", "free-co");
|
|
2398
2398
|
fs.mkdirSync(dir, { recursive: true });
|
|
2399
|
-
fs.writeFileSync(path.join(dir, "company.yaml"), "cloud: false\n");
|
|
2400
2399
|
|
|
2401
2400
|
const deps = makeDeps({
|
|
2402
2401
|
createVaultClient: () =>
|
|
@@ -2423,9 +2422,9 @@ describe("personal slot fanout", () => {
|
|
|
2423
2422
|
.map((p) => path.relative(tmpHqRoot, p))
|
|
2424
2423
|
.sort();
|
|
2425
2424
|
|
|
2426
|
-
// free-co
|
|
2427
|
-
expect(rel).
|
|
2428
|
-
// knowledge/ must still appear — the
|
|
2425
|
+
// free-co MUST appear — inclusion is unconditional, no env gate.
|
|
2426
|
+
expect(rel).toContain(path.join("companies", "free-co"));
|
|
2427
|
+
// knowledge/ must still appear — the top-level walk is unchanged.
|
|
2429
2428
|
expect(rel).toContain("knowledge");
|
|
2430
2429
|
} finally {
|
|
2431
2430
|
fs.rmSync(tmpHqRoot, { recursive: true, force: true });
|
|
@@ -2495,17 +2494,16 @@ describe("personal slot fanout", () => {
|
|
|
2495
2494
|
}
|
|
2496
2495
|
});
|
|
2497
2496
|
|
|
2498
|
-
it("J: personal slot's syncFn (pull) receives
|
|
2497
|
+
it("J: personal slot's syncFn (pull) receives teamSyncedSlugs; company slots receive none", async () => {
|
|
2499
2498
|
// Symmetric to test I (push-side decommissionPrefixes). The pull side
|
|
2500
|
-
// needs the
|
|
2501
|
-
//
|
|
2502
|
-
//
|
|
2503
|
-
//
|
|
2504
|
-
// side decommission hasn't run (e.g. pull-only mode)
|
|
2499
|
+
// needs the team-synced slug set to drop `companies/{team-synced-slug}/...`
|
|
2500
|
+
// orphans even when push-side decommission hasn't run (e.g. pull-only
|
|
2501
|
+
// mode). There is no longer an includeLocalCompanies gate — inclusion is
|
|
2502
|
+
// unconditional.
|
|
2505
2503
|
//
|
|
2506
2504
|
// Default direction for runRunner is "pull" so this test exercises
|
|
2507
2505
|
// the pull call without needing --direction.
|
|
2508
|
-
vi.
|
|
2506
|
+
vi.unstubAllEnvs();
|
|
2509
2507
|
const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
|
|
2510
2508
|
try {
|
|
2511
2509
|
const deps = makeDeps({
|
|
@@ -2528,26 +2526,24 @@ describe("personal slot fanout", () => {
|
|
|
2528
2526
|
const code = await runRunner(["--companies"], deps);
|
|
2529
2527
|
expect(code).toBe(0);
|
|
2530
2528
|
|
|
2531
|
-
// Personal slot:
|
|
2532
|
-
// slug set
|
|
2529
|
+
// Personal slot: teamSyncedSlugs present, populated with the team-synced
|
|
2530
|
+
// slug set. No includeLocalCompanies key (the gate is gone).
|
|
2533
2531
|
const personalCall = (syncSpy.mock.calls as Array<[SyncOptions]>).find(
|
|
2534
2532
|
(c) => c[0].company?.startsWith("prs_"),
|
|
2535
2533
|
);
|
|
2536
2534
|
expect(personalCall).toBeDefined();
|
|
2537
2535
|
const personalArgs = personalCall![0] as SyncOptions & {
|
|
2538
|
-
includeLocalCompanies?: boolean;
|
|
2539
2536
|
teamSyncedSlugs?: ReadonlySet<string>;
|
|
2540
2537
|
};
|
|
2541
|
-
expect(personalArgs
|
|
2538
|
+
expect(Object.keys(personalArgs)).not.toContain("includeLocalCompanies");
|
|
2542
2539
|
expect(Array.from(personalArgs.teamSyncedSlugs ?? []).sort()).toEqual([
|
|
2543
2540
|
"acme",
|
|
2544
2541
|
"beta",
|
|
2545
2542
|
]);
|
|
2546
2543
|
|
|
2547
|
-
// Company slots:
|
|
2548
|
-
// company-target args stay identical
|
|
2549
|
-
//
|
|
2550
|
-
// decommissionPrefixes pin).
|
|
2544
|
+
// Company slots: no teamSyncedSlugs key (preserves the contract that
|
|
2545
|
+
// company-target args stay identical — symmetric to test C's
|
|
2546
|
+
// personalMode/journalSlug pin and test I's decommissionPrefixes pin).
|
|
2551
2547
|
const companyCalls = (syncSpy.mock.calls as Array<[SyncOptions]>).filter(
|
|
2552
2548
|
(c) => c[0].company?.startsWith("cmp_"),
|
|
2553
2549
|
);
|
|
@@ -2561,41 +2557,6 @@ describe("personal slot fanout", () => {
|
|
|
2561
2557
|
}
|
|
2562
2558
|
});
|
|
2563
2559
|
|
|
2564
|
-
it("K: personal slot's syncFn (pull) sets includeLocalCompanies=false when env var is unset — pull stays in legacy shape", async () => {
|
|
2565
|
-
// Symmetric to test H (env-var-OFF case for push). Without the gate,
|
|
2566
|
-
// the pull side preserves the pre-5.20 "drop all companies/... keys"
|
|
2567
|
-
// contract regardless of what's actually in the personal bucket.
|
|
2568
|
-
// Defensive unstub in case a previous test leaked a stub.
|
|
2569
|
-
vi.unstubAllEnvs();
|
|
2570
|
-
const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
|
|
2571
|
-
const deps = makeDeps({
|
|
2572
|
-
createVaultClient: () =>
|
|
2573
|
-
makeVaultStub({
|
|
2574
|
-
memberships: [{ companyUid: "cmp_a" }],
|
|
2575
|
-
entityGet: (uid: string) =>
|
|
2576
|
-
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
2577
|
-
listPersons: () => Promise.resolve([olderPerson]),
|
|
2578
|
-
}),
|
|
2579
|
-
sync: syncSpy,
|
|
2580
|
-
});
|
|
2581
|
-
|
|
2582
|
-
const code = await runRunner(["--companies"], deps);
|
|
2583
|
-
expect(code).toBe(0);
|
|
2584
|
-
|
|
2585
|
-
const personalCall = (syncSpy.mock.calls as Array<[SyncOptions]>).find(
|
|
2586
|
-
(c) => c[0].company?.startsWith("prs_"),
|
|
2587
|
-
);
|
|
2588
|
-
expect(personalCall).toBeDefined();
|
|
2589
|
-
const personalArgs = personalCall![0] as SyncOptions & {
|
|
2590
|
-
includeLocalCompanies?: boolean;
|
|
2591
|
-
};
|
|
2592
|
-
// Explicitly false — not "undefined" — because the runner always
|
|
2593
|
-
// computes the value from the env var, then spreads it for the
|
|
2594
|
-
// personal slot regardless of the value (allows downstream to know
|
|
2595
|
-
// the gate was evaluated, not just "caller forgot to set it").
|
|
2596
|
-
expect(personalArgs.includeLocalCompanies).toBe(false);
|
|
2597
|
-
});
|
|
2598
|
-
|
|
2599
2560
|
it("F: shareFn paths for company slots stay [hqRoot/companies/{slug}] with no personalMode/journalSlug keys", async () => {
|
|
2600
2561
|
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
2601
2562
|
const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -895,7 +895,7 @@ describe("sync", () => {
|
|
|
895
895
|
expect(fs.existsSync(journalPath)).toBe(false);
|
|
896
896
|
});
|
|
897
897
|
|
|
898
|
-
it("personalMode:
|
|
898
|
+
it("personalMode: downloads local companies/* keys and root keys to hqRoot by default", async () => {
|
|
899
899
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
900
900
|
{ key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
|
|
901
901
|
{ key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"abc000"' },
|
|
@@ -906,22 +906,24 @@ describe("sync", () => {
|
|
|
906
906
|
vaultConfig: mockConfig,
|
|
907
907
|
hqRoot: tmpDir,
|
|
908
908
|
personalMode: true,
|
|
909
|
+
// No teamSyncedSlugs → foo is a local (non-cloud) company, downloaded.
|
|
909
910
|
});
|
|
910
911
|
|
|
911
|
-
// Exact counts (regression-tight)
|
|
912
|
-
expect(result.filesSkipped).toBe(
|
|
913
|
-
expect(result.filesDownloaded).toBe(
|
|
912
|
+
// Exact counts (regression-tight): local company + root key both land.
|
|
913
|
+
expect(result.filesSkipped).toBe(0);
|
|
914
|
+
expect(result.filesDownloaded).toBe(2);
|
|
914
915
|
|
|
915
|
-
// companies
|
|
916
|
+
// companies/foo (local, non-cloud) lands at <hqRoot>/companies/foo/bar.md,
|
|
917
|
+
// NOT double-nested under <hqRoot>/companies/<slug>/companies/...
|
|
918
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(true);
|
|
916
919
|
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "companies", "foo", "bar.md"))).toBe(false);
|
|
917
|
-
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(false);
|
|
918
920
|
|
|
919
921
|
// docs/readme.md MUST land at <hqRoot>/docs/readme.md (NOT <hqRoot>/companies/<slug>/docs/readme.md)
|
|
920
922
|
expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
|
|
921
923
|
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
|
|
922
924
|
});
|
|
923
925
|
|
|
924
|
-
it("personalMode: downloads + journals companies/manifest.yaml (carve-out round-trips) while still skipping
|
|
926
|
+
it("personalMode: downloads + journals companies/manifest.yaml (carve-out round-trips) while still skipping team-synced orphan keys", async () => {
|
|
925
927
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
926
928
|
{ key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
|
|
927
929
|
{ key: "companies/manifest.yaml", size: 40, lastModified: new Date(), etag: '"man111"' },
|
|
@@ -932,10 +934,12 @@ describe("sync", () => {
|
|
|
932
934
|
vaultConfig: mockConfig,
|
|
933
935
|
hqRoot: tmpDir,
|
|
934
936
|
personalMode: true,
|
|
937
|
+
// foo is now cloud-backed (team-synced) → its key is a stale orphan.
|
|
938
|
+
teamSyncedSlugs: new Set(["foo"]),
|
|
935
939
|
});
|
|
936
940
|
|
|
937
|
-
// The manifest
|
|
938
|
-
//
|
|
941
|
+
// The manifest always downloads (routing source-of-truth carve-out);
|
|
942
|
+
// the team-synced orphan key is dropped.
|
|
939
943
|
expect(result.filesSkipped).toBe(1);
|
|
940
944
|
expect(result.filesDownloaded).toBe(1);
|
|
941
945
|
expect(fs.existsSync(path.join(tmpDir, "companies", "manifest.yaml"))).toBe(true);
|
|
@@ -1080,13 +1084,12 @@ describe("sync", () => {
|
|
|
1080
1084
|
expect(kept.thread_path).toBe("workspace/threads/T-machineB.json");
|
|
1081
1085
|
});
|
|
1082
1086
|
|
|
1083
|
-
it("personalMode
|
|
1084
|
-
//
|
|
1085
|
-
// Machine A pushed `companies/free-co/notes.md` to the personal bucket
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
// the feature is push-only and the destination machine never sees them.
|
|
1087
|
+
it("personalMode: downloads companies/{local-slug}/... keys by default when slug NOT in teamSyncedSlugs", async () => {
|
|
1088
|
+
// Local (non-cloud) companies sync to the personal vault by default.
|
|
1089
|
+
// Machine A pushed `companies/free-co/notes.md` to the personal bucket;
|
|
1090
|
+
// machine B subscribes: pull must allow these keys through the
|
|
1091
|
+
// personalMode filter, otherwise the feature is push-only and the
|
|
1092
|
+
// destination machine never sees them.
|
|
1090
1093
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1091
1094
|
{ key: "companies/free-co/notes.md", size: 50, lastModified: new Date(), etag: '"abc"' },
|
|
1092
1095
|
{ key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"def"' },
|
|
@@ -1097,7 +1100,6 @@ describe("sync", () => {
|
|
|
1097
1100
|
vaultConfig: mockConfig,
|
|
1098
1101
|
hqRoot: tmpDir,
|
|
1099
1102
|
personalMode: true,
|
|
1100
|
-
includeLocalCompanies: true,
|
|
1101
1103
|
teamSyncedSlugs: new Set(), // empty → no slug is "orphan"
|
|
1102
1104
|
});
|
|
1103
1105
|
|
|
@@ -1107,21 +1109,20 @@ describe("sync", () => {
|
|
|
1107
1109
|
expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
|
|
1108
1110
|
});
|
|
1109
1111
|
|
|
1110
|
-
it("personalMode
|
|
1111
|
-
// The orphan-cleanup half. Once a company
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
// Push-side `decommissionPrefixes` eventually removes them from the
|
|
1112
|
+
it("personalMode: drops companies/{team-synced-slug}/... keys as orphans (company became cloud-true)", async () => {
|
|
1113
|
+
// The orphan-cleanup half. Once a company becomes cloud-true, the
|
|
1114
|
+
// operator gains an active Membership for it (slug enters
|
|
1115
|
+
// teamSyncedSlugs). Any leftover `companies/{that-slug}/...` keys in
|
|
1116
|
+
// the personal bucket are stale residue — downloading them would clash
|
|
1117
|
+
// with the team-bucket pull at the same disk path. Filter drops them
|
|
1118
|
+
// silently. Push-side `decommissionPrefixes` removes them from the
|
|
1118
1119
|
// bucket; this filter keeps the local tree clean in the meantime
|
|
1119
1120
|
// (especially important for pull-only mode where decommission never
|
|
1120
1121
|
// runs).
|
|
1121
1122
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1122
1123
|
// Orphan: this slug is now team-synced — drop the key.
|
|
1123
1124
|
{ key: "companies/acme/file.md", size: 50, lastModified: new Date(), etag: '"old"' },
|
|
1124
|
-
// Legitimate
|
|
1125
|
+
// Legitimate local content: download.
|
|
1125
1126
|
{ key: "companies/free-co/notes.md", size: 30, lastModified: new Date(), etag: '"new"' },
|
|
1126
1127
|
]);
|
|
1127
1128
|
|
|
@@ -1130,7 +1131,6 @@ describe("sync", () => {
|
|
|
1130
1131
|
vaultConfig: mockConfig,
|
|
1131
1132
|
hqRoot: tmpDir,
|
|
1132
1133
|
personalMode: true,
|
|
1133
|
-
includeLocalCompanies: true,
|
|
1134
1134
|
teamSyncedSlugs: new Set(["acme"]),
|
|
1135
1135
|
});
|
|
1136
1136
|
|
|
@@ -1142,18 +1142,16 @@ describe("sync", () => {
|
|
|
1142
1142
|
expect(fs.existsSync(path.join(tmpDir, "companies", "free-co", "notes.md"))).toBe(true);
|
|
1143
1143
|
});
|
|
1144
1144
|
|
|
1145
|
-
it("personalMode
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
// the pull-side filter drops them as a safety net. This test pins
|
|
1151
|
-
// that contract under the new option shape: includeLocalCompanies
|
|
1152
|
-
// omitted → behaves identically to the original filter, even if
|
|
1153
|
-
// teamSyncedSlugs is supplied.
|
|
1145
|
+
it("personalMode: companies/manifest.yaml always downloads (routing source-of-truth), even when other slugs are team-synced", async () => {
|
|
1146
|
+
// The manifest is the one `companies/...` key carved into the personal
|
|
1147
|
+
// vault unconditionally. It must round-trip on pull even when the
|
|
1148
|
+
// operator has team-synced slugs (its key segment `manifest.yaml` is
|
|
1149
|
+
// not a real slug and must never be treated as an orphan).
|
|
1154
1150
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1155
|
-
{ key: "companies/
|
|
1156
|
-
|
|
1151
|
+
{ key: "companies/manifest.yaml", size: 40, lastModified: new Date(), etag: '"m"' },
|
|
1152
|
+
// A team-synced orphan that must still be dropped.
|
|
1153
|
+
{ key: "companies/acme/file.md", size: 50, lastModified: new Date(), etag: '"o"' },
|
|
1154
|
+
{ key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"d"' },
|
|
1157
1155
|
]);
|
|
1158
1156
|
|
|
1159
1157
|
const result = await sync({
|
|
@@ -1161,13 +1159,13 @@ describe("sync", () => {
|
|
|
1161
1159
|
vaultConfig: mockConfig,
|
|
1162
1160
|
hqRoot: tmpDir,
|
|
1163
1161
|
personalMode: true,
|
|
1164
|
-
|
|
1165
|
-
teamSyncedSlugs: new Set(["unrelated"]),
|
|
1162
|
+
teamSyncedSlugs: new Set(["acme"]),
|
|
1166
1163
|
});
|
|
1167
1164
|
|
|
1168
|
-
expect(result.filesDownloaded).toBe(
|
|
1165
|
+
expect(result.filesDownloaded).toBe(2);
|
|
1169
1166
|
expect(result.filesSkipped).toBe(1);
|
|
1170
|
-
expect(fs.existsSync(path.join(tmpDir, "companies", "
|
|
1167
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "manifest.yaml"))).toBe(true);
|
|
1168
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "file.md"))).toBe(false);
|
|
1171
1169
|
expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
|
|
1172
1170
|
});
|
|
1173
1171
|
|
package/src/cli/sync.ts
CHANGED
|
@@ -323,35 +323,21 @@ export interface SyncOptions {
|
|
|
323
323
|
onEvent?: (event: SyncProgressEvent) => void;
|
|
324
324
|
/**
|
|
325
325
|
* When true, the caller is syncing against the caller's person-entity
|
|
326
|
-
* bucket. Pulled keys whose path starts with `companies/` are
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
326
|
+
* bucket. Pulled keys whose path starts with `companies/` are local
|
|
327
|
+
* (non-cloud) companies that sync to the personal vault by default —
|
|
328
|
+
* they are allowed through EXCEPT for slugs in `teamSyncedSlugs` (which
|
|
329
|
+
* are cloud-backed orphans, see below). `companies/manifest.yaml` is
|
|
330
|
+
* always allowed (routing source-of-truth).
|
|
330
331
|
*/
|
|
331
332
|
personalMode?: boolean;
|
|
332
|
-
/**
|
|
333
|
-
* Opt-in: when `personalMode === true`, allow `companies/{slug}/...` keys
|
|
334
|
-
* through the pull filter EXCEPT for slugs in `teamSyncedSlugs` (which
|
|
335
|
-
* are orphan remnants from a company that was promoted to its own team
|
|
336
|
-
* bucket and remain pre-decommission). Mirrors the push-side
|
|
337
|
-
* `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1` gate: when an operator opts
|
|
338
|
-
* into the cloud:false → personal-bucket fallback on their machine,
|
|
339
|
-
* pull must also know about the new key shape on the OTHER machine that
|
|
340
|
-
* subscribes to those keys. Without this, the feature is push-only —
|
|
341
|
-
* the destination machine never sees the keys.
|
|
342
|
-
*
|
|
343
|
-
* Default false preserves the pre-5.20 behavior (drop all
|
|
344
|
-
* `companies/...` keys in personalMode).
|
|
345
|
-
*/
|
|
346
|
-
includeLocalCompanies?: boolean;
|
|
347
333
|
/**
|
|
348
334
|
* Slugs of companies the operator has an active team-bucket Membership
|
|
349
|
-
* for. Only consulted when `personalMode === true
|
|
350
|
-
* `
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
335
|
+
* for. Only consulted when `personalMode === true`: keys under
|
|
336
|
+
* `companies/{slug}/...` for any slug in this set are dropped as orphans
|
|
337
|
+
* from a pre-promotion personal-bucket fallback (the company became
|
|
338
|
+
* cloud-true). The push-side decommission cycle removes these from the
|
|
339
|
+
* bucket; this filter prevents them from re-downloading into the same
|
|
340
|
+
* disk paths the team-bucket pull manages.
|
|
355
341
|
*/
|
|
356
342
|
teamSyncedSlugs?: ReadonlySet<string>;
|
|
357
343
|
/**
|
|
@@ -798,7 +784,6 @@ function planPull(run: PullRunContext): PullPlan {
|
|
|
798
784
|
run.companyRoot,
|
|
799
785
|
run.shouldSync,
|
|
800
786
|
run.options.personalMode === true,
|
|
801
|
-
run.options.includeLocalCompanies === true,
|
|
802
787
|
run.options.teamSyncedSlugs ?? null,
|
|
803
788
|
run.currentPrefixSet,
|
|
804
789
|
run.fileTombstones,
|
|
@@ -1729,7 +1714,6 @@ function computePullPlan(
|
|
|
1729
1714
|
companyRoot: string,
|
|
1730
1715
|
shouldSync: (filePath: string, isDir?: boolean) => boolean,
|
|
1731
1716
|
personalMode: boolean,
|
|
1732
|
-
includeLocalCompanies: boolean,
|
|
1733
1717
|
teamSyncedSlugs: ReadonlySet<string> | null,
|
|
1734
1718
|
// Coalesced, company-relative prefixes the pull is scoped to (US-005).
|
|
1735
1719
|
// `[""]` (the `all`-mode value) covers everything via `isCoveredByAny`, so
|
|
@@ -1774,18 +1758,13 @@ function computePullPlan(
|
|
|
1774
1758
|
// fall through to download + journal like any personal file.
|
|
1775
1759
|
remoteFile.key !== PERSONAL_VAULT_MANIFEST_KEY
|
|
1776
1760
|
) {
|
|
1777
|
-
//
|
|
1778
|
-
//
|
|
1779
|
-
//
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
//
|
|
1783
|
-
//
|
|
1784
|
-
// company whose `company.yaml` declares `cloud: false`. Allow
|
|
1785
|
-
// them through EXCEPT for slugs the operator has an active team-
|
|
1786
|
-
// bucket Membership for: those are orphan remnants from before
|
|
1787
|
-
// the company was promoted, and downloading them would clash
|
|
1788
|
-
// with the team-bucket pull at the same disk path.
|
|
1761
|
+
// Local (non-cloud) companies sync to the personal vault: keys under
|
|
1762
|
+
// `companies/{slug}/...` are legitimate content a peer machine pushed
|
|
1763
|
+
// for a company that is not cloud-backed. Allow them through EXCEPT
|
|
1764
|
+
// for slugs the operator has an active team-bucket Membership for:
|
|
1765
|
+
// those are orphan remnants from before the company became cloud-true,
|
|
1766
|
+
// and downloading them would clash with the team-bucket pull at the
|
|
1767
|
+
// same disk path.
|
|
1789
1768
|
//
|
|
1790
1769
|
// Symmetric to the push-side `decommissionPrefixes` logic in
|
|
1791
1770
|
// share.ts — both target the same orphan class, both honor the
|
|
@@ -1794,7 +1773,7 @@ function computePullPlan(
|
|
|
1794
1773
|
const slug = remoteFile.key.split("/")[1] ?? "";
|
|
1795
1774
|
const isTeamSyncedOrphan =
|
|
1796
1775
|
teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
|
|
1797
|
-
if (
|
|
1776
|
+
if (isTeamSyncedOrphan) {
|
|
1798
1777
|
items.push({ action: "skip-personal-mode", remoteFile, localPath });
|
|
1799
1778
|
continue;
|
|
1800
1779
|
}
|
|
@@ -2138,12 +2117,16 @@ function computePullPlan(
|
|
|
2138
2117
|
if (remoteKeySet.has(posixKey)) continue;
|
|
2139
2118
|
const localPath = resolveContainedVaultPath(companyRoot, key);
|
|
2140
2119
|
if (localPath === null) continue;
|
|
2141
|
-
// PersonalMode key gating — mirror the download branch.
|
|
2120
|
+
// PersonalMode key gating — mirror the download branch. Local (non-cloud)
|
|
2121
|
+
// company keys are tombstone-eligible (a peer's delete should propagate),
|
|
2122
|
+
// but team-synced orphans are left alone (the team-bucket pull owns those
|
|
2123
|
+
// disk paths), and the manifest — the routing source-of-truth — is never
|
|
2124
|
+
// auto-tombstoned out of the personal vault.
|
|
2142
2125
|
if (personalMode && key.startsWith("companies/")) {
|
|
2143
2126
|
const slug = key.split("/")[1] ?? "";
|
|
2144
2127
|
const isTeamSyncedOrphan =
|
|
2145
2128
|
teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
|
|
2146
|
-
if (
|
|
2129
|
+
if (isTeamSyncedOrphan || key === PERSONAL_VAULT_MANIFEST_KEY) continue;
|
|
2147
2130
|
}
|
|
2148
2131
|
// Ephemeral keys are filtered both directions; never tombstone-
|
|
2149
2132
|
// propagate a conflict-mirror.
|
package/src/object-io.test.ts
CHANGED
|
@@ -516,6 +516,25 @@ describe("PresignObjectIO.headObject", () => {
|
|
|
516
516
|
expect(await io.headObject("gone")).toBeNull();
|
|
517
517
|
});
|
|
518
518
|
|
|
519
|
+
it("returns null on a server-confirmed FILES_PRESIGN_NOT_FOUND (absence, not denial — no GET issued)", async () => {
|
|
520
|
+
// The presign lambda HEAD'd S3 and confirmed absence. Unlike a read
|
|
521
|
+
// DENIAL (Forbidden, below), this is unambiguous "object missing", so
|
|
522
|
+
// headObject reports absent (null) and the push creates the new object —
|
|
523
|
+
// never reaching a 403-masked presigned GET.
|
|
524
|
+
const { vault, setPresign } = makeVault();
|
|
525
|
+
setPresign([
|
|
526
|
+
{
|
|
527
|
+
key: "new.md",
|
|
528
|
+
op: "get",
|
|
529
|
+
error: "Object not found: 'new.md'",
|
|
530
|
+
code: "FILES_PRESIGN_NOT_FOUND",
|
|
531
|
+
},
|
|
532
|
+
]);
|
|
533
|
+
const io = new PresignObjectIO(vault, COMPANY);
|
|
534
|
+
expect(await io.headObject("new.md")).toBeNull();
|
|
535
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
536
|
+
});
|
|
537
|
+
|
|
519
538
|
it("throws Forbidden when presign denies the key — denial is NOT absence", async () => {
|
|
520
539
|
// Regression: pre-fix this returned null ("absent"), which made
|
|
521
540
|
// share.ts's push guard (`if (remoteMeta)`) skip every conflict check
|
package/src/object-io.ts
CHANGED
|
@@ -853,6 +853,17 @@ export class PresignObjectIO implements ObjectIO {
|
|
|
853
853
|
throw err;
|
|
854
854
|
}
|
|
855
855
|
const row = results[0];
|
|
856
|
+
if (row?.code === "FILES_PRESIGN_NOT_FOUND") {
|
|
857
|
+
// The presign lambda HEAD'd S3 and confirmed the object does NOT
|
|
858
|
+
// exist — an unambiguous absence, distinct from the read DENIAL
|
|
859
|
+
// handled below. Report absent (null) so the push creates the new
|
|
860
|
+
// object. Without this, a brand-new file's first upload is blocked
|
|
861
|
+
// forever: its presigned GET 403s (S3 masks absence when the URL
|
|
862
|
+
// lacks `s3:ListBucket`) and routes through the access-denied skip
|
|
863
|
+
// path. The server's explicit NOT_FOUND is the ONLY safe absence
|
|
864
|
+
// signal — a 403 stays "unknown", never "missing" (see below).
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
856
867
|
if (!row || row.error || !row.url) {
|
|
857
868
|
// A per-key denial means the caller can't READ the key — it says
|
|
858
869
|
// nothing about whether the object EXISTS. Pre-fix this returned
|