@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/src/cli/sync.test.ts
CHANGED
|
@@ -11,7 +11,8 @@ import type { VaultServiceConfig } from "../types.js";
|
|
|
11
11
|
import { lockPathFor } from "../operation-lock.js";
|
|
12
12
|
|
|
13
13
|
// Mock s3 module at the top level
|
|
14
|
-
vi.mock("../s3.js", async () => {
|
|
14
|
+
vi.mock("../s3.js", async (importOriginal) => {
|
|
15
|
+
const actual = await importOriginal<typeof import("../s3.js")>();
|
|
15
16
|
const { vi: innerVi } = await import("vitest");
|
|
16
17
|
const innerFs = await import("fs");
|
|
17
18
|
const innerPath = await import("path");
|
|
@@ -22,11 +23,19 @@ vi.mock("../s3.js", async () => {
|
|
|
22
23
|
];
|
|
23
24
|
|
|
24
25
|
return {
|
|
26
|
+
...actual,
|
|
25
27
|
toPosixKey: (key: string) => key.split("\\").join("/"),
|
|
26
28
|
uploadFile: innerVi.fn().mockResolvedValue(undefined),
|
|
27
|
-
|
|
29
|
+
uploadSymlink: innerVi.fn().mockResolvedValue(undefined),
|
|
30
|
+
downloadFile: innerVi.fn().mockImplementation(async (
|
|
31
|
+
_ctx: unknown,
|
|
32
|
+
_key: string,
|
|
33
|
+
localPath: string,
|
|
34
|
+
options?: { beforeReplace?: () => void },
|
|
35
|
+
) => {
|
|
28
36
|
const dir = innerPath.dirname(localPath);
|
|
29
37
|
if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
|
|
38
|
+
options?.beforeReplace?.();
|
|
30
39
|
innerFs.writeFileSync(localPath, "mock file content");
|
|
31
40
|
return { metadata: {} };
|
|
32
41
|
}),
|
|
@@ -34,6 +43,7 @@ vi.mock("../s3.js", async () => {
|
|
|
34
43
|
deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
|
|
35
44
|
headRemoteFile: innerVi.fn().mockResolvedValue(null),
|
|
36
45
|
primeObjectTransport: innerVi.fn().mockResolvedValue(undefined),
|
|
46
|
+
primeUploads: innerVi.fn().mockResolvedValue(undefined),
|
|
37
47
|
};
|
|
38
48
|
});
|
|
39
49
|
|
|
@@ -44,6 +54,7 @@ vi.mock("./reindex.js", () => ({
|
|
|
44
54
|
}));
|
|
45
55
|
|
|
46
56
|
import { sync, reportNewFilesToNotify } from "./sync.js";
|
|
57
|
+
import { share } from "./share.js";
|
|
47
58
|
import * as s3Module from "../s3.js";
|
|
48
59
|
import { reindex } from "./reindex.js";
|
|
49
60
|
import { readSyncProgress } from "../sync-progress.js";
|
|
@@ -139,6 +150,10 @@ describe("sync", () => {
|
|
|
139
150
|
delete process.env.HQ_STATE_DIR;
|
|
140
151
|
});
|
|
141
152
|
|
|
153
|
+
it("keeps classifyVaultKey available from the partial S3 mock", () => {
|
|
154
|
+
expect(s3Module.classifyVaultKey("docs/readme.md", "company")).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
142
157
|
it("runs reindex against hqRoot after a sync that downloaded files", async () => {
|
|
143
158
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
144
159
|
// skipLock: the surrounding sync run already holds the per-root lock.
|
|
@@ -612,6 +627,8 @@ describe("sync", () => {
|
|
|
612
627
|
const localContent = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
|
|
613
628
|
fs.writeFileSync(localPath, localContent);
|
|
614
629
|
const st = fs.statSync(localPath);
|
|
630
|
+
const crypto = await import("node:crypto");
|
|
631
|
+
const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
|
|
615
632
|
|
|
616
633
|
// Remote object is SMALLER (an older, truncated copy) with a CHANGED etag.
|
|
617
634
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
@@ -632,7 +649,7 @@ describe("sync", () => {
|
|
|
632
649
|
lastSync: new Date().toISOString(),
|
|
633
650
|
files: {
|
|
634
651
|
"docs/handoff.md": {
|
|
635
|
-
hash:
|
|
652
|
+
hash: localHash,
|
|
636
653
|
size: st.size,
|
|
637
654
|
mtimeMs: st.mtimeMs,
|
|
638
655
|
remoteEtag: "old-etag",
|
|
@@ -676,6 +693,8 @@ describe("sync", () => {
|
|
|
676
693
|
Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
|
|
677
694
|
fs.writeFileSync(localPath, localContent);
|
|
678
695
|
const st = fs.statSync(localPath);
|
|
696
|
+
const crypto = await import("node:crypto");
|
|
697
|
+
const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
|
|
679
698
|
|
|
680
699
|
// Remote is SMALLER with a changed etag — a legitimate shorter rewrite.
|
|
681
700
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
@@ -689,7 +708,7 @@ describe("sync", () => {
|
|
|
689
708
|
lastSync: new Date().toISOString(),
|
|
690
709
|
files: {
|
|
691
710
|
"docs/handoff.md": {
|
|
692
|
-
hash:
|
|
711
|
+
hash: localHash,
|
|
693
712
|
size: st.size,
|
|
694
713
|
mtimeMs: st.mtimeMs,
|
|
695
714
|
remoteEtag: "old-etag",
|
|
@@ -727,6 +746,8 @@ describe("sync", () => {
|
|
|
727
746
|
const localContent = "short local\n";
|
|
728
747
|
fs.writeFileSync(localPath, localContent);
|
|
729
748
|
const st = fs.statSync(localPath);
|
|
749
|
+
const crypto = await import("node:crypto");
|
|
750
|
+
const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
|
|
730
751
|
|
|
731
752
|
// Remote is LARGER with a changed etag → legitimate advance.
|
|
732
753
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
@@ -740,7 +761,7 @@ describe("sync", () => {
|
|
|
740
761
|
lastSync: new Date().toISOString(),
|
|
741
762
|
files: {
|
|
742
763
|
"docs/handoff.md": {
|
|
743
|
-
hash:
|
|
764
|
+
hash: localHash,
|
|
744
765
|
size: st.size,
|
|
745
766
|
mtimeMs: st.mtimeMs,
|
|
746
767
|
remoteEtag: "old-etag",
|
|
@@ -954,12 +975,9 @@ describe("sync", () => {
|
|
|
954
975
|
expect(journal.files[trackedKey]).toBeUndefined();
|
|
955
976
|
});
|
|
956
977
|
|
|
957
|
-
it("
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
// FILE_TOMBSTONE fetch is degraded (empty map — the logged failure mode).
|
|
961
|
-
// The only intent record is the LOCAL journal tombstone (`removedAt`).
|
|
962
|
-
// The drift-restore path must honor it and NOT re-download the key.
|
|
978
|
+
it("fails closed for a legacy local-delete tombstone and restores the remote key", async () => {
|
|
979
|
+
// A legacy `removedReason:local-delete` was inferred from an ENOENT. It
|
|
980
|
+
// has no version-bound explicit intent, so it must never suppress a pull.
|
|
963
981
|
const key = "docs/handoff.md";
|
|
964
982
|
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
965
983
|
|
|
@@ -1006,20 +1024,16 @@ describe("sync", () => {
|
|
|
1006
1024
|
onEvent: () => {},
|
|
1007
1025
|
});
|
|
1008
1026
|
|
|
1009
|
-
|
|
1010
|
-
expect(s3Module.downloadFile).not.toHaveBeenCalledWith(
|
|
1027
|
+
expect(s3Module.downloadFile).toHaveBeenCalledWith(
|
|
1011
1028
|
expect.anything(),
|
|
1012
1029
|
key,
|
|
1013
1030
|
expect.anything(),
|
|
1031
|
+
expect.anything(),
|
|
1014
1032
|
);
|
|
1015
|
-
expect(fs.
|
|
1033
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
1016
1034
|
});
|
|
1017
1035
|
|
|
1018
|
-
it("
|
|
1019
|
-
// The user deleted a genuinely-synced file. Its entry is clean (etag matches
|
|
1020
|
-
// the current remote — currency holds), not divergent. The pull leg must
|
|
1021
|
-
// treat the absence as an intentional delete: stamp a persisting
|
|
1022
|
-
// `local-delete` tombstone and SKIP the re-download — not resurrect it.
|
|
1036
|
+
it("does not infer local-delete intent from a single missing file", async () => {
|
|
1023
1037
|
const key = "docs/gone.md";
|
|
1024
1038
|
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
1025
1039
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
@@ -1046,15 +1060,15 @@ describe("sync", () => {
|
|
|
1046
1060
|
|
|
1047
1061
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1048
1062
|
|
|
1049
|
-
expect(s3Module.downloadFile).
|
|
1063
|
+
expect(s3Module.downloadFile).toHaveBeenCalledWith(
|
|
1050
1064
|
expect.anything(),
|
|
1051
1065
|
key,
|
|
1052
1066
|
expect.anything(),
|
|
1067
|
+
expect.anything(),
|
|
1053
1068
|
);
|
|
1054
|
-
expect(fs.
|
|
1069
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
1055
1070
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1056
|
-
expect(journal.files[key]?.removedAt).
|
|
1057
|
-
expect(journal.files[key]?.removedReason).toBe("local-delete");
|
|
1071
|
+
expect(journal.files[key]?.removedAt).toBeUndefined();
|
|
1058
1072
|
});
|
|
1059
1073
|
|
|
1060
1074
|
it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
|
|
@@ -1073,7 +1087,14 @@ describe("sync", () => {
|
|
|
1073
1087
|
size: 10,
|
|
1074
1088
|
syncedAt: new Date().toISOString(),
|
|
1075
1089
|
direction: "down",
|
|
1090
|
+
kind: "file",
|
|
1076
1091
|
remoteEtag: `e-${k}`,
|
|
1092
|
+
localDeleteIntent: {
|
|
1093
|
+
version: 1,
|
|
1094
|
+
remoteEtag: `e-${k}`,
|
|
1095
|
+
localHash: "h",
|
|
1096
|
+
localKind: "file",
|
|
1097
|
+
},
|
|
1077
1098
|
};
|
|
1078
1099
|
}
|
|
1079
1100
|
fs.writeFileSync(
|
|
@@ -1117,9 +1138,16 @@ describe("sync", () => {
|
|
|
1117
1138
|
size: 42,
|
|
1118
1139
|
syncedAt: new Date().toISOString(),
|
|
1119
1140
|
direction: "down",
|
|
1141
|
+
kind: "file",
|
|
1120
1142
|
remoteEtag: "cur123",
|
|
1121
1143
|
removedAt: new Date().toISOString(),
|
|
1122
1144
|
removedReason: "local-delete",
|
|
1145
|
+
localDeleteIntent: {
|
|
1146
|
+
version: 1,
|
|
1147
|
+
remoteEtag: "cur123",
|
|
1148
|
+
localHash: "h",
|
|
1149
|
+
localKind: "file",
|
|
1150
|
+
},
|
|
1123
1151
|
},
|
|
1124
1152
|
},
|
|
1125
1153
|
pulls: [],
|
|
@@ -1765,6 +1793,252 @@ describe("sync", () => {
|
|
|
1765
1793
|
expect(journal.files["docs/racy-delete.md"].hash).toBe(baselineHash);
|
|
1766
1794
|
});
|
|
1767
1795
|
|
|
1796
|
+
it("CAS: preserves a file created after a remote-delete plan", async () => {
|
|
1797
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1798
|
+
const localPath = path.join(companyRoot, "docs", "created-after-plan.md");
|
|
1799
|
+
const baseline = "synced baseline";
|
|
1800
|
+
const crypto = await import("node:crypto");
|
|
1801
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1802
|
+
|
|
1803
|
+
fs.writeFileSync(
|
|
1804
|
+
journalPath,
|
|
1805
|
+
JSON.stringify({
|
|
1806
|
+
version: "2",
|
|
1807
|
+
lastSync: new Date().toISOString(),
|
|
1808
|
+
files: {
|
|
1809
|
+
"docs/created-after-plan.md": {
|
|
1810
|
+
hash: baselineHash,
|
|
1811
|
+
size: baseline.length,
|
|
1812
|
+
syncedAt: new Date().toISOString(),
|
|
1813
|
+
direction: "down",
|
|
1814
|
+
kind: "file",
|
|
1815
|
+
remoteEtag: "before-delete",
|
|
1816
|
+
},
|
|
1817
|
+
},
|
|
1818
|
+
pulls: [],
|
|
1819
|
+
}),
|
|
1820
|
+
);
|
|
1821
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
|
|
1822
|
+
vi.mocked(s3Module.headRemoteFile).mockImplementationOnce(async () => {
|
|
1823
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
1824
|
+
// Same bytes as the journal baseline: hash-only revalidation would unlink
|
|
1825
|
+
// this newly-created file, but the planned snapshot was "absent".
|
|
1826
|
+
fs.writeFileSync(localPath, baseline);
|
|
1827
|
+
return null;
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1831
|
+
|
|
1832
|
+
expect(result.filesTombstoned).toBe(0);
|
|
1833
|
+
expect(result.conflicts).toBe(1);
|
|
1834
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe(baseline);
|
|
1835
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1836
|
+
expect(journal.files["docs/created-after-plan.md"]).toBeDefined();
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
it("CAS: preserves a concurrent local edit when a staged download is ready to replace it", async () => {
|
|
1840
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1841
|
+
const localPath = path.join(companyRoot, "docs", "staged-race.md");
|
|
1842
|
+
const baseline = "synced baseline";
|
|
1843
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
1844
|
+
fs.writeFileSync(localPath, baseline);
|
|
1845
|
+
const crypto = await import("node:crypto");
|
|
1846
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1847
|
+
fs.writeFileSync(
|
|
1848
|
+
journalPath,
|
|
1849
|
+
JSON.stringify({
|
|
1850
|
+
version: "2",
|
|
1851
|
+
lastSync: new Date().toISOString(),
|
|
1852
|
+
files: {
|
|
1853
|
+
"docs/staged-race.md": {
|
|
1854
|
+
hash: baselineHash,
|
|
1855
|
+
size: baseline.length,
|
|
1856
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1857
|
+
direction: "down",
|
|
1858
|
+
kind: "file",
|
|
1859
|
+
remoteEtag: "old-etag",
|
|
1860
|
+
},
|
|
1861
|
+
},
|
|
1862
|
+
pulls: [],
|
|
1863
|
+
}),
|
|
1864
|
+
);
|
|
1865
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1866
|
+
{ key: "docs/staged-race.md", size: baseline.length + 10, lastModified: new Date(), etag: '"new-etag"' },
|
|
1867
|
+
]);
|
|
1868
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(async (
|
|
1869
|
+
_ctx: unknown,
|
|
1870
|
+
_key: string,
|
|
1871
|
+
destination: string,
|
|
1872
|
+
options?: { beforeReplace?: () => void },
|
|
1873
|
+
) => {
|
|
1874
|
+
fs.writeFileSync(destination, "concurrent local edit");
|
|
1875
|
+
options?.beforeReplace?.();
|
|
1876
|
+
fs.writeFileSync(destination, "remote replacement");
|
|
1877
|
+
return { metadata: {} };
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1881
|
+
|
|
1882
|
+
expect(result.filesDownloaded).toBe(0);
|
|
1883
|
+
expect(result.conflicts).toBe(1);
|
|
1884
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("concurrent local edit");
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
it("personal vault delete planning excludes release core and generated skill mirrors", async () => {
|
|
1888
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal-delete.json");
|
|
1889
|
+
const intent = (remoteEtag: string) => ({
|
|
1890
|
+
version: 1 as const,
|
|
1891
|
+
remoteEtag,
|
|
1892
|
+
localHash: "synced-hash",
|
|
1893
|
+
localKind: "file" as const,
|
|
1894
|
+
});
|
|
1895
|
+
fs.writeFileSync(
|
|
1896
|
+
personalJournalPath,
|
|
1897
|
+
JSON.stringify({
|
|
1898
|
+
version: "2",
|
|
1899
|
+
lastSync: new Date().toISOString(),
|
|
1900
|
+
files: {
|
|
1901
|
+
"core/core.yaml": {
|
|
1902
|
+
hash: "synced-hash",
|
|
1903
|
+
size: 1,
|
|
1904
|
+
syncedAt: new Date().toISOString(),
|
|
1905
|
+
direction: "up",
|
|
1906
|
+
kind: "file",
|
|
1907
|
+
remoteEtag: "core-etag",
|
|
1908
|
+
localDeleteIntent: intent("core-etag"),
|
|
1909
|
+
},
|
|
1910
|
+
".claude/skills/core:demo/SKILL.md": {
|
|
1911
|
+
hash: "synced-hash",
|
|
1912
|
+
size: 1,
|
|
1913
|
+
syncedAt: new Date().toISOString(),
|
|
1914
|
+
direction: "up",
|
|
1915
|
+
kind: "file",
|
|
1916
|
+
remoteEtag: "mirror-etag",
|
|
1917
|
+
localDeleteIntent: intent("mirror-etag"),
|
|
1918
|
+
},
|
|
1919
|
+
},
|
|
1920
|
+
pulls: [],
|
|
1921
|
+
}),
|
|
1922
|
+
);
|
|
1923
|
+
|
|
1924
|
+
await share({
|
|
1925
|
+
company: "acme",
|
|
1926
|
+
vaultConfig: mockConfig,
|
|
1927
|
+
hqRoot: tmpDir,
|
|
1928
|
+
paths: [tmpDir],
|
|
1929
|
+
personalMode: true,
|
|
1930
|
+
journalSlug: "personal-delete",
|
|
1931
|
+
propagateDeletes: true,
|
|
1932
|
+
propagateDeletePolicy: "all",
|
|
1933
|
+
});
|
|
1934
|
+
|
|
1935
|
+
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
1936
|
+
const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
1937
|
+
expect(journal.files["core/core.yaml"]).toBeDefined();
|
|
1938
|
+
expect(journal.files[".claude/skills/core:demo/SKILL.md"]).toBeDefined();
|
|
1939
|
+
});
|
|
1940
|
+
|
|
1941
|
+
it("does not issue DeleteObject for two ordinary missing journal files without explicit intent", async () => {
|
|
1942
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1943
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1944
|
+
const entry = (remoteEtag: string) => ({
|
|
1945
|
+
hash: "synced-hash",
|
|
1946
|
+
size: 1,
|
|
1947
|
+
syncedAt: new Date().toISOString(),
|
|
1948
|
+
direction: "up",
|
|
1949
|
+
kind: "file",
|
|
1950
|
+
remoteEtag,
|
|
1951
|
+
});
|
|
1952
|
+
fs.writeFileSync(
|
|
1953
|
+
journalPath,
|
|
1954
|
+
JSON.stringify({
|
|
1955
|
+
version: "2",
|
|
1956
|
+
lastSync: new Date().toISOString(),
|
|
1957
|
+
files: {
|
|
1958
|
+
"docs/a.md": entry("a-etag"),
|
|
1959
|
+
"docs/b.md": entry("b-etag"),
|
|
1960
|
+
},
|
|
1961
|
+
pulls: [],
|
|
1962
|
+
}),
|
|
1963
|
+
);
|
|
1964
|
+
|
|
1965
|
+
const result = await share({
|
|
1966
|
+
company: "acme",
|
|
1967
|
+
vaultConfig: mockConfig,
|
|
1968
|
+
hqRoot: tmpDir,
|
|
1969
|
+
paths: [companyRoot],
|
|
1970
|
+
propagateDeletes: true,
|
|
1971
|
+
propagateDeletePolicy: "all",
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
1975
|
+
expect(result.filesRefusedStale).toBe(2);
|
|
1976
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1977
|
+
expect(journal.files["docs/a.md"]).toBeDefined();
|
|
1978
|
+
expect(journal.files["docs/b.md"]).toBeDefined();
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
it("uses a conditional PUT conflict for concurrent board.json appends", async () => {
|
|
1982
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1983
|
+
const boardPath = path.join(companyRoot, "board.json");
|
|
1984
|
+
const baseline = '{"items":[]}';
|
|
1985
|
+
const localAppend = '{"items":["local append"]}';
|
|
1986
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1987
|
+
fs.writeFileSync(boardPath, localAppend);
|
|
1988
|
+
const crypto = await import("node:crypto");
|
|
1989
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1990
|
+
fs.writeFileSync(
|
|
1991
|
+
journalPath,
|
|
1992
|
+
JSON.stringify({
|
|
1993
|
+
version: "2",
|
|
1994
|
+
lastSync: new Date().toISOString(),
|
|
1995
|
+
files: {
|
|
1996
|
+
"board.json": {
|
|
1997
|
+
hash: baselineHash,
|
|
1998
|
+
size: baseline.length,
|
|
1999
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
2000
|
+
direction: "up",
|
|
2001
|
+
kind: "file",
|
|
2002
|
+
remoteEtag: "old-etag",
|
|
2003
|
+
},
|
|
2004
|
+
},
|
|
2005
|
+
pulls: [],
|
|
2006
|
+
}),
|
|
2007
|
+
);
|
|
2008
|
+
vi.mocked(s3Module.headRemoteFile).mockResolvedValueOnce({
|
|
2009
|
+
etag: "old-etag",
|
|
2010
|
+
size: baseline.length,
|
|
2011
|
+
lastModified: new Date(),
|
|
2012
|
+
});
|
|
2013
|
+
const preconditionError = Object.assign(new Error("remote changed"), {
|
|
2014
|
+
name: "PreconditionFailed",
|
|
2015
|
+
});
|
|
2016
|
+
vi.mocked(s3Module.uploadFile).mockRejectedValueOnce(preconditionError);
|
|
2017
|
+
|
|
2018
|
+
const { isCloudAuthoritative } = await import(
|
|
2019
|
+
"../lib/cloud-authoritative.js",
|
|
2020
|
+
);
|
|
2021
|
+
expect(isCloudAuthoritative("companies/acme/board.json")).toBe(true);
|
|
2022
|
+
|
|
2023
|
+
const result = await share({
|
|
2024
|
+
company: "acme",
|
|
2025
|
+
vaultConfig: mockConfig,
|
|
2026
|
+
hqRoot: tmpDir,
|
|
2027
|
+
paths: [companyRoot],
|
|
2028
|
+
onConflict: "keep",
|
|
2029
|
+
});
|
|
2030
|
+
|
|
2031
|
+
expect(result.conflictPaths).toContain("board.json");
|
|
2032
|
+
expect(fs.readFileSync(boardPath, "utf-8")).toBe(localAppend);
|
|
2033
|
+
expect(s3Module.uploadFile).toHaveBeenCalledWith(
|
|
2034
|
+
expect.anything(),
|
|
2035
|
+
boardPath,
|
|
2036
|
+
"board.json",
|
|
2037
|
+
undefined,
|
|
2038
|
+
{ ifMatch: "old-etag" },
|
|
2039
|
+
);
|
|
2040
|
+
});
|
|
2041
|
+
|
|
1768
2042
|
it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
|
|
1769
2043
|
// Codex review on PR #24 round 4 caught: the round-3 local-edit
|
|
1770
2044
|
// divergence guard only covered regular files (`isFile()` is false
|
|
@@ -2580,7 +2854,7 @@ describe("sync", () => {
|
|
|
2580
2854
|
try {
|
|
2581
2855
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
|
|
2582
2856
|
vi.mocked(s3Module.downloadFile).mockImplementation(
|
|
2583
|
-
async (ctx, key, localPath) => {
|
|
2857
|
+
async (ctx, key, localPath, options) => {
|
|
2584
2858
|
if (key === "bulk/file-8.md") {
|
|
2585
2859
|
// This models the next transfer crashing the process. The first
|
|
2586
2860
|
// eight entries must already be durable before the final writer.
|
|
@@ -2588,7 +2862,7 @@ describe("sync", () => {
|
|
|
2588
2862
|
expect(Object.keys(checkpoint.files)).toHaveLength(8);
|
|
2589
2863
|
throw new Error("simulated crash after checkpoint");
|
|
2590
2864
|
}
|
|
2591
|
-
return defaultDownload(ctx, key, localPath);
|
|
2865
|
+
return defaultDownload(ctx, key, localPath, options);
|
|
2592
2866
|
},
|
|
2593
2867
|
);
|
|
2594
2868
|
|
|
@@ -2621,6 +2895,7 @@ describe("sync", () => {
|
|
|
2621
2895
|
expect.anything(),
|
|
2622
2896
|
"bulk/file-8.md",
|
|
2623
2897
|
expect.any(String),
|
|
2898
|
+
expect.objectContaining({ beforeReplace: expect.any(Function) }),
|
|
2624
2899
|
);
|
|
2625
2900
|
} finally {
|
|
2626
2901
|
vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
|
|
@@ -3815,11 +4090,62 @@ describe("scope-invalid key hardening (companies/ prefix in a company vault —
|
|
|
3815
4090
|
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
3816
4091
|
const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
|
|
3817
4092
|
expect(skipEvents).toEqual([
|
|
3818
|
-
{
|
|
4093
|
+
{
|
|
4094
|
+
type: "skip-invalid-scoped-key",
|
|
4095
|
+
path: "companies/acme/knowledge/poison.md",
|
|
4096
|
+
errorCode: "INVALID_KEY_COMPANIES_SCOPED",
|
|
4097
|
+
},
|
|
3819
4098
|
]);
|
|
3820
4099
|
expect(events.filter((e) => e.type === "error")).toEqual([]);
|
|
3821
4100
|
});
|
|
3822
4101
|
|
|
4102
|
+
it("skips a control-character remote key before presigning while transferring valid keys", async () => {
|
|
4103
|
+
const invalidKey = "docs/bad\x00key.md";
|
|
4104
|
+
vi.mocked(s3Module.listRemoteFiles).mockReset();
|
|
4105
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([
|
|
4106
|
+
{ key: invalidKey, size: 10, lastModified: new Date(), etag: '"bad"' },
|
|
4107
|
+
{ key: "docs/good.md", size: 5, lastModified: new Date(), etag: '"good"' },
|
|
4108
|
+
]);
|
|
4109
|
+
vi.mocked(s3Module.downloadFile).mockReset();
|
|
4110
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(
|
|
4111
|
+
async (_ctx: unknown, key: string, localPath: string) => {
|
|
4112
|
+
if (key === invalidKey) {
|
|
4113
|
+
throw Object.assign(new Error("presign rejected invalid key"), {
|
|
4114
|
+
code: "INVALID_KEY_CONTROL_CHARS",
|
|
4115
|
+
});
|
|
4116
|
+
}
|
|
4117
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
4118
|
+
fs.writeFileSync(localPath, "mock file content");
|
|
4119
|
+
return { metadata: {} };
|
|
4120
|
+
},
|
|
4121
|
+
);
|
|
4122
|
+
const events: Array<{ type: string; [k: string]: unknown }> = [];
|
|
4123
|
+
|
|
4124
|
+
const result = await sync({
|
|
4125
|
+
company: "acme",
|
|
4126
|
+
vaultConfig: mockConfig,
|
|
4127
|
+
hqRoot: tmpDir,
|
|
4128
|
+
onEvent: (event) => events.push(event as never),
|
|
4129
|
+
});
|
|
4130
|
+
|
|
4131
|
+
expect(result.aborted).toBe(false);
|
|
4132
|
+
expect(result.filesDownloaded).toBe(1);
|
|
4133
|
+
expect(
|
|
4134
|
+
fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "good.md")),
|
|
4135
|
+
).toBe(true);
|
|
4136
|
+
expect(vi.mocked(s3Module.downloadFile).mock.calls.map((call) => call[1])).toEqual([
|
|
4137
|
+
"docs/good.md",
|
|
4138
|
+
]);
|
|
4139
|
+
expect(events.filter((event) => event.type === "skip-invalid-scoped-key")).toEqual([
|
|
4140
|
+
{
|
|
4141
|
+
type: "skip-invalid-scoped-key",
|
|
4142
|
+
path: invalidKey,
|
|
4143
|
+
errorCode: "INVALID_KEY_CONTROL_CHARS",
|
|
4144
|
+
},
|
|
4145
|
+
]);
|
|
4146
|
+
expect(events.filter((event) => event.type === "error")).toEqual([]);
|
|
4147
|
+
});
|
|
4148
|
+
|
|
3823
4149
|
it("cleans a doubled-tree journal entry via tombstone (local delete + journal drop) without HEAD-verifying the invalid key", async () => {
|
|
3824
4150
|
// A doubled local tree (companies/acme/companies/acme/…) previously
|
|
3825
4151
|
// poisoned the bucket; the poisoned objects were deleted server-side.
|