@indigoai-us/hq-cloud 6.14.8 → 6.14.10
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 +8 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +1 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +15 -1
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +12 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +26 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +68 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +89 -14
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +105 -3
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +3 -1
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +7 -7
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +166 -82
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +292 -27
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +7 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +22 -1
- package/dist/journal.js.map +1 -1
- package/dist/manifest-reconcile.d.ts +27 -0
- package/dist/manifest-reconcile.d.ts.map +1 -0
- package/dist/manifest-reconcile.js +120 -0
- package/dist/manifest-reconcile.js.map +1 -0
- package/dist/manifest-reconcile.test.d.ts +2 -0
- package/dist/manifest-reconcile.test.d.ts.map +1 -0
- package/dist/manifest-reconcile.test.js +97 -0
- package/dist/manifest-reconcile.test.js.map +1 -0
- package/dist/personal-vault.d.ts +6 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +12 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/s3.d.ts +15 -2
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +54 -19
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +12 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +7 -0
- package/src/bin/sync-runner-planning.ts +16 -2
- package/src/bin/sync-runner.test.ts +82 -1
- package/src/bin/sync-runner.ts +43 -0
- package/src/cli/share.test.ts +129 -3
- package/src/cli/share.ts +114 -15
- package/src/cli/sync-scope.test.ts +3 -1
- package/src/cli/sync.test.ts +352 -26
- package/src/cli/sync.ts +226 -103
- package/src/journal.ts +28 -0
- package/src/manifest-reconcile.test.ts +107 -0
- package/src/manifest-reconcile.ts +160 -0
- package/src/personal-vault.ts +15 -0
- package/src/s3.test.ts +18 -0
- package/src/s3.ts +67 -30
- package/src/types.ts +13 -0
package/dist/cli/sync.test.js
CHANGED
|
@@ -8,7 +8,8 @@ import * as os from "os";
|
|
|
8
8
|
import { clearContextCache } from "../context.js";
|
|
9
9
|
import { lockPathFor } from "../operation-lock.js";
|
|
10
10
|
// Mock s3 module at the top level
|
|
11
|
-
vi.mock("../s3.js", async () => {
|
|
11
|
+
vi.mock("../s3.js", async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal();
|
|
12
13
|
const { vi: innerVi } = await import("vitest");
|
|
13
14
|
const innerFs = await import("fs");
|
|
14
15
|
const innerPath = await import("path");
|
|
@@ -17,12 +18,15 @@ vi.mock("../s3.js", async () => {
|
|
|
17
18
|
{ key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
|
|
18
19
|
];
|
|
19
20
|
return {
|
|
21
|
+
...actual,
|
|
20
22
|
toPosixKey: (key) => key.split("\\").join("/"),
|
|
21
23
|
uploadFile: innerVi.fn().mockResolvedValue(undefined),
|
|
22
|
-
|
|
24
|
+
uploadSymlink: innerVi.fn().mockResolvedValue(undefined),
|
|
25
|
+
downloadFile: innerVi.fn().mockImplementation(async (_ctx, _key, localPath, options) => {
|
|
23
26
|
const dir = innerPath.dirname(localPath);
|
|
24
27
|
if (!innerFs.existsSync(dir))
|
|
25
28
|
innerFs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
options?.beforeReplace?.();
|
|
26
30
|
innerFs.writeFileSync(localPath, "mock file content");
|
|
27
31
|
return { metadata: {} };
|
|
28
32
|
}),
|
|
@@ -30,6 +34,7 @@ vi.mock("../s3.js", async () => {
|
|
|
30
34
|
deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
|
|
31
35
|
headRemoteFile: innerVi.fn().mockResolvedValue(null),
|
|
32
36
|
primeObjectTransport: innerVi.fn().mockResolvedValue(undefined),
|
|
37
|
+
primeUploads: innerVi.fn().mockResolvedValue(undefined),
|
|
33
38
|
};
|
|
34
39
|
});
|
|
35
40
|
// Mock the post-sync refresh so tests neither spawn the real reindex.sh nor
|
|
@@ -38,6 +43,7 @@ vi.mock("./reindex.js", () => ({
|
|
|
38
43
|
reindex: vi.fn(() => ({ status: 0 })),
|
|
39
44
|
}));
|
|
40
45
|
import { sync, reportNewFilesToNotify } from "./sync.js";
|
|
46
|
+
import { share } from "./share.js";
|
|
41
47
|
import * as s3Module from "../s3.js";
|
|
42
48
|
import { reindex } from "./reindex.js";
|
|
43
49
|
import { readSyncProgress } from "../sync-progress.js";
|
|
@@ -123,6 +129,9 @@ describe("sync", () => {
|
|
|
123
129
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
124
130
|
delete process.env.HQ_STATE_DIR;
|
|
125
131
|
});
|
|
132
|
+
it("keeps classifyVaultKey available from the partial S3 mock", () => {
|
|
133
|
+
expect(s3Module.classifyVaultKey("docs/readme.md", "company")).toBeNull();
|
|
134
|
+
});
|
|
126
135
|
it("runs reindex against hqRoot after a sync that downloaded files", async () => {
|
|
127
136
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
128
137
|
// skipLock: the surrounding sync run already holds the per-root lock.
|
|
@@ -504,6 +513,8 @@ describe("sync", () => {
|
|
|
504
513
|
const localContent = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
|
|
505
514
|
fs.writeFileSync(localPath, localContent);
|
|
506
515
|
const st = fs.statSync(localPath);
|
|
516
|
+
const crypto = await import("node:crypto");
|
|
517
|
+
const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
|
|
507
518
|
// Remote object is SMALLER (an older, truncated copy) with a CHANGED etag.
|
|
508
519
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
509
520
|
{ key: "docs/handoff.md", size: 12, lastModified: new Date(), etag: '"regressed-etag"' },
|
|
@@ -520,7 +531,7 @@ describe("sync", () => {
|
|
|
520
531
|
lastSync: new Date().toISOString(),
|
|
521
532
|
files: {
|
|
522
533
|
"docs/handoff.md": {
|
|
523
|
-
hash:
|
|
534
|
+
hash: localHash,
|
|
524
535
|
size: st.size,
|
|
525
536
|
mtimeMs: st.mtimeMs,
|
|
526
537
|
remoteEtag: "old-etag",
|
|
@@ -559,6 +570,8 @@ describe("sync", () => {
|
|
|
559
570
|
const localContent = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
|
|
560
571
|
fs.writeFileSync(localPath, localContent);
|
|
561
572
|
const st = fs.statSync(localPath);
|
|
573
|
+
const crypto = await import("node:crypto");
|
|
574
|
+
const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
|
|
562
575
|
// Remote is SMALLER with a changed etag — a legitimate shorter rewrite.
|
|
563
576
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
564
577
|
{ key: "docs/handoff.md", size: 12, lastModified: new Date(), etag: '"shorter-etag"' },
|
|
@@ -568,7 +581,7 @@ describe("sync", () => {
|
|
|
568
581
|
lastSync: new Date().toISOString(),
|
|
569
582
|
files: {
|
|
570
583
|
"docs/handoff.md": {
|
|
571
|
-
hash:
|
|
584
|
+
hash: localHash,
|
|
572
585
|
size: st.size,
|
|
573
586
|
mtimeMs: st.mtimeMs,
|
|
574
587
|
remoteEtag: "old-etag",
|
|
@@ -602,6 +615,8 @@ describe("sync", () => {
|
|
|
602
615
|
const localContent = "short local\n";
|
|
603
616
|
fs.writeFileSync(localPath, localContent);
|
|
604
617
|
const st = fs.statSync(localPath);
|
|
618
|
+
const crypto = await import("node:crypto");
|
|
619
|
+
const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
|
|
605
620
|
// Remote is LARGER with a changed etag → legitimate advance.
|
|
606
621
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
607
622
|
{ key: "docs/handoff.md", size: 9999, lastModified: new Date(), etag: '"advanced-etag"' },
|
|
@@ -611,7 +626,7 @@ describe("sync", () => {
|
|
|
611
626
|
lastSync: new Date().toISOString(),
|
|
612
627
|
files: {
|
|
613
628
|
"docs/handoff.md": {
|
|
614
|
-
hash:
|
|
629
|
+
hash: localHash,
|
|
615
630
|
size: st.size,
|
|
616
631
|
mtimeMs: st.mtimeMs,
|
|
617
632
|
remoteEtag: "old-etag",
|
|
@@ -793,12 +808,9 @@ describe("sync", () => {
|
|
|
793
808
|
expect(journal.files[untrackedKey]).toBeUndefined();
|
|
794
809
|
expect(journal.files[trackedKey]).toBeUndefined();
|
|
795
810
|
});
|
|
796
|
-
it("
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
// FILE_TOMBSTONE fetch is degraded (empty map — the logged failure mode).
|
|
800
|
-
// The only intent record is the LOCAL journal tombstone (`removedAt`).
|
|
801
|
-
// The drift-restore path must honor it and NOT re-download the key.
|
|
811
|
+
it("fails closed for a legacy local-delete tombstone and restores the remote key", async () => {
|
|
812
|
+
// A legacy `removedReason:local-delete` was inferred from an ENOENT. It
|
|
813
|
+
// has no version-bound explicit intent, so it must never suppress a pull.
|
|
802
814
|
const key = "docs/handoff.md";
|
|
803
815
|
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
804
816
|
// Remote still lists the object, etag matching the journal, lastModified
|
|
@@ -838,15 +850,10 @@ describe("sync", () => {
|
|
|
838
850
|
hqRoot: tmpDir,
|
|
839
851
|
onEvent: () => { },
|
|
840
852
|
});
|
|
841
|
-
|
|
842
|
-
expect(
|
|
843
|
-
expect(fs.existsSync(localPath)).toBe(false);
|
|
853
|
+
expect(s3Module.downloadFile).toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
|
|
854
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
844
855
|
});
|
|
845
|
-
it("
|
|
846
|
-
// The user deleted a genuinely-synced file. Its entry is clean (etag matches
|
|
847
|
-
// the current remote — currency holds), not divergent. The pull leg must
|
|
848
|
-
// treat the absence as an intentional delete: stamp a persisting
|
|
849
|
-
// `local-delete` tombstone and SKIP the re-download — not resurrect it.
|
|
856
|
+
it("does not infer local-delete intent from a single missing file", async () => {
|
|
850
857
|
const key = "docs/gone.md";
|
|
851
858
|
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
852
859
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
@@ -868,11 +875,10 @@ describe("sync", () => {
|
|
|
868
875
|
pulls: [],
|
|
869
876
|
}));
|
|
870
877
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
871
|
-
expect(s3Module.downloadFile).
|
|
872
|
-
expect(fs.
|
|
878
|
+
expect(s3Module.downloadFile).toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
|
|
879
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
873
880
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
874
|
-
expect(journal.files[key]?.removedAt).
|
|
875
|
-
expect(journal.files[key]?.removedReason).toBe("local-delete");
|
|
881
|
+
expect(journal.files[key]?.removedAt).toBeUndefined();
|
|
876
882
|
});
|
|
877
883
|
it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
|
|
878
884
|
// 12 clean files vanish at once (corrupt mirror / bulk rm / checkout). The
|
|
@@ -888,7 +894,14 @@ describe("sync", () => {
|
|
|
888
894
|
size: 10,
|
|
889
895
|
syncedAt: new Date().toISOString(),
|
|
890
896
|
direction: "down",
|
|
897
|
+
kind: "file",
|
|
891
898
|
remoteEtag: `e-${k}`,
|
|
899
|
+
localDeleteIntent: {
|
|
900
|
+
version: 1,
|
|
901
|
+
remoteEtag: `e-${k}`,
|
|
902
|
+
localHash: "h",
|
|
903
|
+
localKind: "file",
|
|
904
|
+
},
|
|
892
905
|
};
|
|
893
906
|
}
|
|
894
907
|
fs.writeFileSync(journalPath, JSON.stringify({
|
|
@@ -924,9 +937,16 @@ describe("sync", () => {
|
|
|
924
937
|
size: 42,
|
|
925
938
|
syncedAt: new Date().toISOString(),
|
|
926
939
|
direction: "down",
|
|
940
|
+
kind: "file",
|
|
927
941
|
remoteEtag: "cur123",
|
|
928
942
|
removedAt: new Date().toISOString(),
|
|
929
943
|
removedReason: "local-delete",
|
|
944
|
+
localDeleteIntent: {
|
|
945
|
+
version: 1,
|
|
946
|
+
remoteEtag: "cur123",
|
|
947
|
+
localHash: "h",
|
|
948
|
+
localKind: "file",
|
|
949
|
+
},
|
|
930
950
|
},
|
|
931
951
|
},
|
|
932
952
|
pulls: [],
|
|
@@ -1460,6 +1480,207 @@ describe("sync", () => {
|
|
|
1460
1480
|
expect(journal.files["docs/racy-delete.md"]).toBeDefined();
|
|
1461
1481
|
expect(journal.files["docs/racy-delete.md"].hash).toBe(baselineHash);
|
|
1462
1482
|
});
|
|
1483
|
+
it("CAS: preserves a file created after a remote-delete plan", async () => {
|
|
1484
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1485
|
+
const localPath = path.join(companyRoot, "docs", "created-after-plan.md");
|
|
1486
|
+
const baseline = "synced baseline";
|
|
1487
|
+
const crypto = await import("node:crypto");
|
|
1488
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1489
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1490
|
+
version: "2",
|
|
1491
|
+
lastSync: new Date().toISOString(),
|
|
1492
|
+
files: {
|
|
1493
|
+
"docs/created-after-plan.md": {
|
|
1494
|
+
hash: baselineHash,
|
|
1495
|
+
size: baseline.length,
|
|
1496
|
+
syncedAt: new Date().toISOString(),
|
|
1497
|
+
direction: "down",
|
|
1498
|
+
kind: "file",
|
|
1499
|
+
remoteEtag: "before-delete",
|
|
1500
|
+
},
|
|
1501
|
+
},
|
|
1502
|
+
pulls: [],
|
|
1503
|
+
}));
|
|
1504
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
|
|
1505
|
+
vi.mocked(s3Module.headRemoteFile).mockImplementationOnce(async () => {
|
|
1506
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
1507
|
+
// Same bytes as the journal baseline: hash-only revalidation would unlink
|
|
1508
|
+
// this newly-created file, but the planned snapshot was "absent".
|
|
1509
|
+
fs.writeFileSync(localPath, baseline);
|
|
1510
|
+
return null;
|
|
1511
|
+
});
|
|
1512
|
+
const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1513
|
+
expect(result.filesTombstoned).toBe(0);
|
|
1514
|
+
expect(result.conflicts).toBe(1);
|
|
1515
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe(baseline);
|
|
1516
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1517
|
+
expect(journal.files["docs/created-after-plan.md"]).toBeDefined();
|
|
1518
|
+
});
|
|
1519
|
+
it("CAS: preserves a concurrent local edit when a staged download is ready to replace it", async () => {
|
|
1520
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1521
|
+
const localPath = path.join(companyRoot, "docs", "staged-race.md");
|
|
1522
|
+
const baseline = "synced baseline";
|
|
1523
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
1524
|
+
fs.writeFileSync(localPath, baseline);
|
|
1525
|
+
const crypto = await import("node:crypto");
|
|
1526
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1527
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1528
|
+
version: "2",
|
|
1529
|
+
lastSync: new Date().toISOString(),
|
|
1530
|
+
files: {
|
|
1531
|
+
"docs/staged-race.md": {
|
|
1532
|
+
hash: baselineHash,
|
|
1533
|
+
size: baseline.length,
|
|
1534
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1535
|
+
direction: "down",
|
|
1536
|
+
kind: "file",
|
|
1537
|
+
remoteEtag: "old-etag",
|
|
1538
|
+
},
|
|
1539
|
+
},
|
|
1540
|
+
pulls: [],
|
|
1541
|
+
}));
|
|
1542
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1543
|
+
{ key: "docs/staged-race.md", size: baseline.length + 10, lastModified: new Date(), etag: '"new-etag"' },
|
|
1544
|
+
]);
|
|
1545
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(async (_ctx, _key, destination, options) => {
|
|
1546
|
+
fs.writeFileSync(destination, "concurrent local edit");
|
|
1547
|
+
options?.beforeReplace?.();
|
|
1548
|
+
fs.writeFileSync(destination, "remote replacement");
|
|
1549
|
+
return { metadata: {} };
|
|
1550
|
+
});
|
|
1551
|
+
const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1552
|
+
expect(result.filesDownloaded).toBe(0);
|
|
1553
|
+
expect(result.conflicts).toBe(1);
|
|
1554
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("concurrent local edit");
|
|
1555
|
+
});
|
|
1556
|
+
it("personal vault delete planning excludes release core and generated skill mirrors", async () => {
|
|
1557
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal-delete.json");
|
|
1558
|
+
const intent = (remoteEtag) => ({
|
|
1559
|
+
version: 1,
|
|
1560
|
+
remoteEtag,
|
|
1561
|
+
localHash: "synced-hash",
|
|
1562
|
+
localKind: "file",
|
|
1563
|
+
});
|
|
1564
|
+
fs.writeFileSync(personalJournalPath, JSON.stringify({
|
|
1565
|
+
version: "2",
|
|
1566
|
+
lastSync: new Date().toISOString(),
|
|
1567
|
+
files: {
|
|
1568
|
+
"core/core.yaml": {
|
|
1569
|
+
hash: "synced-hash",
|
|
1570
|
+
size: 1,
|
|
1571
|
+
syncedAt: new Date().toISOString(),
|
|
1572
|
+
direction: "up",
|
|
1573
|
+
kind: "file",
|
|
1574
|
+
remoteEtag: "core-etag",
|
|
1575
|
+
localDeleteIntent: intent("core-etag"),
|
|
1576
|
+
},
|
|
1577
|
+
".claude/skills/core:demo/SKILL.md": {
|
|
1578
|
+
hash: "synced-hash",
|
|
1579
|
+
size: 1,
|
|
1580
|
+
syncedAt: new Date().toISOString(),
|
|
1581
|
+
direction: "up",
|
|
1582
|
+
kind: "file",
|
|
1583
|
+
remoteEtag: "mirror-etag",
|
|
1584
|
+
localDeleteIntent: intent("mirror-etag"),
|
|
1585
|
+
},
|
|
1586
|
+
},
|
|
1587
|
+
pulls: [],
|
|
1588
|
+
}));
|
|
1589
|
+
await share({
|
|
1590
|
+
company: "acme",
|
|
1591
|
+
vaultConfig: mockConfig,
|
|
1592
|
+
hqRoot: tmpDir,
|
|
1593
|
+
paths: [tmpDir],
|
|
1594
|
+
personalMode: true,
|
|
1595
|
+
journalSlug: "personal-delete",
|
|
1596
|
+
propagateDeletes: true,
|
|
1597
|
+
propagateDeletePolicy: "all",
|
|
1598
|
+
});
|
|
1599
|
+
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
1600
|
+
const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
1601
|
+
expect(journal.files["core/core.yaml"]).toBeDefined();
|
|
1602
|
+
expect(journal.files[".claude/skills/core:demo/SKILL.md"]).toBeDefined();
|
|
1603
|
+
});
|
|
1604
|
+
it("does not issue DeleteObject for two ordinary missing journal files without explicit intent", async () => {
|
|
1605
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1606
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1607
|
+
const entry = (remoteEtag) => ({
|
|
1608
|
+
hash: "synced-hash",
|
|
1609
|
+
size: 1,
|
|
1610
|
+
syncedAt: new Date().toISOString(),
|
|
1611
|
+
direction: "up",
|
|
1612
|
+
kind: "file",
|
|
1613
|
+
remoteEtag,
|
|
1614
|
+
});
|
|
1615
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1616
|
+
version: "2",
|
|
1617
|
+
lastSync: new Date().toISOString(),
|
|
1618
|
+
files: {
|
|
1619
|
+
"docs/a.md": entry("a-etag"),
|
|
1620
|
+
"docs/b.md": entry("b-etag"),
|
|
1621
|
+
},
|
|
1622
|
+
pulls: [],
|
|
1623
|
+
}));
|
|
1624
|
+
const result = await share({
|
|
1625
|
+
company: "acme",
|
|
1626
|
+
vaultConfig: mockConfig,
|
|
1627
|
+
hqRoot: tmpDir,
|
|
1628
|
+
paths: [companyRoot],
|
|
1629
|
+
propagateDeletes: true,
|
|
1630
|
+
propagateDeletePolicy: "all",
|
|
1631
|
+
});
|
|
1632
|
+
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
1633
|
+
expect(result.filesRefusedStale).toBe(2);
|
|
1634
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1635
|
+
expect(journal.files["docs/a.md"]).toBeDefined();
|
|
1636
|
+
expect(journal.files["docs/b.md"]).toBeDefined();
|
|
1637
|
+
});
|
|
1638
|
+
it("uses a conditional PUT conflict for concurrent board.json appends", async () => {
|
|
1639
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1640
|
+
const boardPath = path.join(companyRoot, "board.json");
|
|
1641
|
+
const baseline = '{"items":[]}';
|
|
1642
|
+
const localAppend = '{"items":["local append"]}';
|
|
1643
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1644
|
+
fs.writeFileSync(boardPath, localAppend);
|
|
1645
|
+
const crypto = await import("node:crypto");
|
|
1646
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1647
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1648
|
+
version: "2",
|
|
1649
|
+
lastSync: new Date().toISOString(),
|
|
1650
|
+
files: {
|
|
1651
|
+
"board.json": {
|
|
1652
|
+
hash: baselineHash,
|
|
1653
|
+
size: baseline.length,
|
|
1654
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1655
|
+
direction: "up",
|
|
1656
|
+
kind: "file",
|
|
1657
|
+
remoteEtag: "old-etag",
|
|
1658
|
+
},
|
|
1659
|
+
},
|
|
1660
|
+
pulls: [],
|
|
1661
|
+
}));
|
|
1662
|
+
vi.mocked(s3Module.headRemoteFile).mockResolvedValueOnce({
|
|
1663
|
+
etag: "old-etag",
|
|
1664
|
+
size: baseline.length,
|
|
1665
|
+
lastModified: new Date(),
|
|
1666
|
+
});
|
|
1667
|
+
const preconditionError = Object.assign(new Error("remote changed"), {
|
|
1668
|
+
name: "PreconditionFailed",
|
|
1669
|
+
});
|
|
1670
|
+
vi.mocked(s3Module.uploadFile).mockRejectedValueOnce(preconditionError);
|
|
1671
|
+
const { isCloudAuthoritative } = await import("../lib/cloud-authoritative.js");
|
|
1672
|
+
expect(isCloudAuthoritative("companies/acme/board.json")).toBe(true);
|
|
1673
|
+
const result = await share({
|
|
1674
|
+
company: "acme",
|
|
1675
|
+
vaultConfig: mockConfig,
|
|
1676
|
+
hqRoot: tmpDir,
|
|
1677
|
+
paths: [companyRoot],
|
|
1678
|
+
onConflict: "keep",
|
|
1679
|
+
});
|
|
1680
|
+
expect(result.conflictPaths).toContain("board.json");
|
|
1681
|
+
expect(fs.readFileSync(boardPath, "utf-8")).toBe(localAppend);
|
|
1682
|
+
expect(s3Module.uploadFile).toHaveBeenCalledWith(expect.anything(), boardPath, "board.json", undefined, { ifMatch: "old-etag" });
|
|
1683
|
+
});
|
|
1463
1684
|
it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
|
|
1464
1685
|
// Codex review on PR #24 round 4 caught: the round-3 local-edit
|
|
1465
1686
|
// divergence guard only covered regular files (`isFile()` is false
|
|
@@ -2174,7 +2395,7 @@ describe("sync", () => {
|
|
|
2174
2395
|
throw new Error("missing default download mock");
|
|
2175
2396
|
try {
|
|
2176
2397
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
|
|
2177
|
-
vi.mocked(s3Module.downloadFile).mockImplementation(async (ctx, key, localPath) => {
|
|
2398
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(async (ctx, key, localPath, options) => {
|
|
2178
2399
|
if (key === "bulk/file-8.md") {
|
|
2179
2400
|
// This models the next transfer crashing the process. The first
|
|
2180
2401
|
// eight entries must already be durable before the final writer.
|
|
@@ -2182,7 +2403,7 @@ describe("sync", () => {
|
|
|
2182
2403
|
expect(Object.keys(checkpoint.files)).toHaveLength(8);
|
|
2183
2404
|
throw new Error("simulated crash after checkpoint");
|
|
2184
2405
|
}
|
|
2185
|
-
return defaultDownload(ctx, key, localPath);
|
|
2406
|
+
return defaultDownload(ctx, key, localPath, options);
|
|
2186
2407
|
});
|
|
2187
2408
|
const first = await sync({
|
|
2188
2409
|
company: "acme",
|
|
@@ -2205,7 +2426,7 @@ describe("sync", () => {
|
|
|
2205
2426
|
onEvent: () => undefined,
|
|
2206
2427
|
});
|
|
2207
2428
|
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
|
|
2208
|
-
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledWith(expect.anything(), "bulk/file-8.md", expect.any(String));
|
|
2429
|
+
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledWith(expect.anything(), "bulk/file-8.md", expect.any(String), expect.objectContaining({ beforeReplace: expect.any(Function) }));
|
|
2209
2430
|
}
|
|
2210
2431
|
finally {
|
|
2211
2432
|
vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
|
|
@@ -3206,10 +3427,54 @@ describe("scope-invalid key hardening (companies/ prefix in a company vault —
|
|
|
3206
3427
|
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
3207
3428
|
const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
|
|
3208
3429
|
expect(skipEvents).toEqual([
|
|
3209
|
-
{
|
|
3430
|
+
{
|
|
3431
|
+
type: "skip-invalid-scoped-key",
|
|
3432
|
+
path: "companies/acme/knowledge/poison.md",
|
|
3433
|
+
errorCode: "INVALID_KEY_COMPANIES_SCOPED",
|
|
3434
|
+
},
|
|
3210
3435
|
]);
|
|
3211
3436
|
expect(events.filter((e) => e.type === "error")).toEqual([]);
|
|
3212
3437
|
});
|
|
3438
|
+
it("skips a control-character remote key before presigning while transferring valid keys", async () => {
|
|
3439
|
+
const invalidKey = "docs/bad\x00key.md";
|
|
3440
|
+
vi.mocked(s3Module.listRemoteFiles).mockReset();
|
|
3441
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([
|
|
3442
|
+
{ key: invalidKey, size: 10, lastModified: new Date(), etag: '"bad"' },
|
|
3443
|
+
{ key: "docs/good.md", size: 5, lastModified: new Date(), etag: '"good"' },
|
|
3444
|
+
]);
|
|
3445
|
+
vi.mocked(s3Module.downloadFile).mockReset();
|
|
3446
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(async (_ctx, key, localPath) => {
|
|
3447
|
+
if (key === invalidKey) {
|
|
3448
|
+
throw Object.assign(new Error("presign rejected invalid key"), {
|
|
3449
|
+
code: "INVALID_KEY_CONTROL_CHARS",
|
|
3450
|
+
});
|
|
3451
|
+
}
|
|
3452
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
3453
|
+
fs.writeFileSync(localPath, "mock file content");
|
|
3454
|
+
return { metadata: {} };
|
|
3455
|
+
});
|
|
3456
|
+
const events = [];
|
|
3457
|
+
const result = await sync({
|
|
3458
|
+
company: "acme",
|
|
3459
|
+
vaultConfig: mockConfig,
|
|
3460
|
+
hqRoot: tmpDir,
|
|
3461
|
+
onEvent: (event) => events.push(event),
|
|
3462
|
+
});
|
|
3463
|
+
expect(result.aborted).toBe(false);
|
|
3464
|
+
expect(result.filesDownloaded).toBe(1);
|
|
3465
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "good.md"))).toBe(true);
|
|
3466
|
+
expect(vi.mocked(s3Module.downloadFile).mock.calls.map((call) => call[1])).toEqual([
|
|
3467
|
+
"docs/good.md",
|
|
3468
|
+
]);
|
|
3469
|
+
expect(events.filter((event) => event.type === "skip-invalid-scoped-key")).toEqual([
|
|
3470
|
+
{
|
|
3471
|
+
type: "skip-invalid-scoped-key",
|
|
3472
|
+
path: invalidKey,
|
|
3473
|
+
errorCode: "INVALID_KEY_CONTROL_CHARS",
|
|
3474
|
+
},
|
|
3475
|
+
]);
|
|
3476
|
+
expect(events.filter((event) => event.type === "error")).toEqual([]);
|
|
3477
|
+
});
|
|
3213
3478
|
it("cleans a doubled-tree journal entry via tombstone (local delete + journal drop) without HEAD-verifying the invalid key", async () => {
|
|
3214
3479
|
// A doubled local tree (companies/acme/companies/acme/…) previously
|
|
3215
3480
|
// poisoned the bucket; the poisoned objects were deleted server-side.
|