@indigoai-us/hq-cloud 6.15.20 → 6.15.21
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 +1 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +37 -5
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +77 -4
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.test.js +140 -108
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.test.js +59 -47
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal-delta-writes.test.d.ts +2 -0
- package/dist/journal-delta-writes.test.d.ts.map +1 -0
- package/dist/journal-delta-writes.test.js +140 -0
- package/dist/journal-delta-writes.test.js.map +1 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +151 -24
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +29 -11
- package/dist/journal.test.js.map +1 -1
- package/dist/outcome-telemetry.d.ts +7 -1
- package/dist/outcome-telemetry.d.ts.map +1 -1
- package/dist/outcome-telemetry.js +15 -9
- package/dist/outcome-telemetry.js.map +1 -1
- package/dist/outcome-telemetry.test.js +15 -0
- package/dist/outcome-telemetry.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -8
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +43 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/telemetry.d.ts +7 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +67 -60
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +12 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/share.test.js
CHANGED
|
@@ -55,7 +55,39 @@ import * as readline from "readline";
|
|
|
55
55
|
import { share, isForbiddenCompanyVaultKey, UnreachablePushPathsError, currentUploadBytes, _testing as shareTesting } from "./share.js";
|
|
56
56
|
import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink, WindowsSymlinkPrivilegeError } from "../s3.js";
|
|
57
57
|
import { VaultAuthError } from "../vault-client.js";
|
|
58
|
-
import { hashFile } from "../journal.js";
|
|
58
|
+
import { hashFile, readJournal, writeJournal } from "../journal.js";
|
|
59
|
+
/**
|
|
60
|
+
* Read a journal the way production does.
|
|
61
|
+
*
|
|
62
|
+
* These tests used to `JSON.parse` `sync-journal.<slug>.json` directly, back
|
|
63
|
+
* when that file held a full copy of the journal. It is now a small locator —
|
|
64
|
+
* the authority is the v3 state store — so the assertions below go through
|
|
65
|
+
* `readJournal`, which is what the sync engine itself uses. The slug is
|
|
66
|
+
* recovered from the path so each call site keeps naming the journal it means.
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* Seed a journal the way production does.
|
|
70
|
+
*
|
|
71
|
+
* These fixtures used to write `sync-journal.<slug>.json` by hand, which worked
|
|
72
|
+
* only while that file WAS the journal. It is a locator now, so a hand-written
|
|
73
|
+
* copy is simply ignored and the test silently runs against an empty journal.
|
|
74
|
+
* Going through `writeJournal` puts the state where the engine actually reads
|
|
75
|
+
* it, and migrates v1 fixtures on the way in exactly as a real upgrade would.
|
|
76
|
+
*/
|
|
77
|
+
function seedJournalAt(journalPath, journal) {
|
|
78
|
+
const slug = path
|
|
79
|
+
.basename(journalPath)
|
|
80
|
+
.replace(/^sync-journal\./, "")
|
|
81
|
+
.replace(/\.json$/, "");
|
|
82
|
+
writeJournal(slug, journal);
|
|
83
|
+
}
|
|
84
|
+
function journalAt(journalPath) {
|
|
85
|
+
const slug = path
|
|
86
|
+
.basename(journalPath)
|
|
87
|
+
.replace(/^sync-journal\./, "")
|
|
88
|
+
.replace(/\.json$/, "");
|
|
89
|
+
return readJournal(slug);
|
|
90
|
+
}
|
|
59
91
|
const mockConfig = {
|
|
60
92
|
apiUrl: "https://vault-api.test",
|
|
61
93
|
authToken: "test-jwt-token",
|
|
@@ -528,7 +560,7 @@ describe("share", () => {
|
|
|
528
560
|
const { hashFile } = await import("../journal.js");
|
|
529
561
|
const hash = hashFile(testFile);
|
|
530
562
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
531
|
-
|
|
563
|
+
seedJournalAt(journalPath, ({
|
|
532
564
|
version: "1",
|
|
533
565
|
lastSync: new Date().toISOString(),
|
|
534
566
|
files: {
|
|
@@ -559,7 +591,7 @@ describe("share", () => {
|
|
|
559
591
|
// Journal has a stale hash for this path — simulating "local has been
|
|
560
592
|
// edited since the last push".
|
|
561
593
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
562
|
-
|
|
594
|
+
seedJournalAt(journalPath, ({
|
|
563
595
|
version: "1",
|
|
564
596
|
lastSync: new Date().toISOString(),
|
|
565
597
|
files: {
|
|
@@ -593,7 +625,7 @@ describe("share", () => {
|
|
|
593
625
|
// share.ts makes the first test fail (uploadFile IS called → resurrection).
|
|
594
626
|
describe("push-side tombstone consult", () => {
|
|
595
627
|
function seedJournal(key, hash, size) {
|
|
596
|
-
|
|
628
|
+
seedJournalAt(path.join(stateDir, "sync-journal.acme.json"), ({
|
|
597
629
|
version: "1",
|
|
598
630
|
lastSync: new Date().toISOString(),
|
|
599
631
|
files: {
|
|
@@ -737,7 +769,7 @@ describe("share", () => {
|
|
|
737
769
|
size: 99,
|
|
738
770
|
});
|
|
739
771
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
740
|
-
|
|
772
|
+
seedJournalAt(journalPath, ({
|
|
741
773
|
version: "1",
|
|
742
774
|
lastSync: new Date().toISOString(),
|
|
743
775
|
files: {
|
|
@@ -789,7 +821,7 @@ describe("share", () => {
|
|
|
789
821
|
// assert it is never consulted below — the skip is cheap, and a leftover
|
|
790
822
|
// mockResolvedValueOnce would otherwise leak into a later test's HEAD queue.
|
|
791
823
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
792
|
-
|
|
824
|
+
seedJournalAt(journalPath, ({
|
|
793
825
|
version: "1",
|
|
794
826
|
lastSync: new Date().toISOString(),
|
|
795
827
|
files: {
|
|
@@ -969,7 +1001,7 @@ describe("share", () => {
|
|
|
969
1001
|
direction: "push",
|
|
970
1002
|
});
|
|
971
1003
|
expect(fs.readdirSync(companyRoot).filter((name) => name.includes(".conflict-"))).toEqual([]);
|
|
972
|
-
const journal =
|
|
1004
|
+
const journal = readJournal("acme");
|
|
973
1005
|
expect(journal.files["kms-scaffold.md"]).toMatchObject({
|
|
974
1006
|
direction: "up",
|
|
975
1007
|
remoteEtag: "288bfa91684ae21f9ae27c061254d412",
|
|
@@ -1085,7 +1117,7 @@ describe("share", () => {
|
|
|
1085
1117
|
size: 5,
|
|
1086
1118
|
});
|
|
1087
1119
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1088
|
-
|
|
1120
|
+
seedJournalAt(journalPath, ({
|
|
1089
1121
|
version: "1",
|
|
1090
1122
|
lastSync: syncedAt,
|
|
1091
1123
|
files: {
|
|
@@ -1158,7 +1190,7 @@ describe("share", () => {
|
|
|
1158
1190
|
// No journal stamp for the failed PUT — next pass re-evaluates fresh.
|
|
1159
1191
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1160
1192
|
if (fs.existsSync(journalPath)) {
|
|
1161
|
-
const journal =
|
|
1193
|
+
const journal = journalAt(journalPath);
|
|
1162
1194
|
expect(journal.files?.["raced.md"]).toBeUndefined();
|
|
1163
1195
|
}
|
|
1164
1196
|
});
|
|
@@ -1240,7 +1272,7 @@ describe("share", () => {
|
|
|
1240
1272
|
return { etag: '"should-not-land"' };
|
|
1241
1273
|
});
|
|
1242
1274
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1243
|
-
|
|
1275
|
+
seedJournalAt(journalPath, ({
|
|
1244
1276
|
version: "1",
|
|
1245
1277
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1246
1278
|
files: {
|
|
@@ -1300,7 +1332,7 @@ describe("share", () => {
|
|
|
1300
1332
|
});
|
|
1301
1333
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"new-upload-etag"' });
|
|
1302
1334
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1303
|
-
|
|
1335
|
+
seedJournalAt(journalPath, ({
|
|
1304
1336
|
version: "1",
|
|
1305
1337
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1306
1338
|
files: {
|
|
@@ -1335,7 +1367,7 @@ describe("share", () => {
|
|
|
1335
1367
|
size: 20,
|
|
1336
1368
|
});
|
|
1337
1369
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1338
|
-
|
|
1370
|
+
seedJournalAt(journalPath, ({
|
|
1339
1371
|
version: "1",
|
|
1340
1372
|
lastSync: new Date().toISOString(),
|
|
1341
1373
|
files: {
|
|
@@ -1377,7 +1409,7 @@ describe("share", () => {
|
|
|
1377
1409
|
size: 40,
|
|
1378
1410
|
});
|
|
1379
1411
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1380
|
-
|
|
1412
|
+
seedJournalAt(journalPath, ({
|
|
1381
1413
|
version: "1",
|
|
1382
1414
|
lastSync: new Date().toISOString(),
|
|
1383
1415
|
files: {
|
|
@@ -1423,7 +1455,7 @@ describe("share", () => {
|
|
|
1423
1455
|
});
|
|
1424
1456
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"forced-etag"' });
|
|
1425
1457
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1426
|
-
|
|
1458
|
+
seedJournalAt(journalPath, ({
|
|
1427
1459
|
version: "1",
|
|
1428
1460
|
lastSync: new Date().toISOString(),
|
|
1429
1461
|
files: {
|
|
@@ -1506,7 +1538,7 @@ describe("share", () => {
|
|
|
1506
1538
|
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1507
1539
|
// The entry is rewritten as an ordinary upload: local is now authority,
|
|
1508
1540
|
// so the journal-honesty flag must be gone rather than re-armed.
|
|
1509
|
-
const journal =
|
|
1541
|
+
const journal = journalAt(journalPath);
|
|
1510
1542
|
expect(journal.files["publish-divergent.md"].direction).toBe("up");
|
|
1511
1543
|
expect(journal.files["publish-divergent.md"].localDiverges).toBeFalsy();
|
|
1512
1544
|
expect(journal.files["publish-divergent.md"].remoteEtag).toBe("published-etag");
|
|
@@ -1559,7 +1591,7 @@ describe("share", () => {
|
|
|
1559
1591
|
expect(result.conflictPaths).toContain("publish-raced.md");
|
|
1560
1592
|
// Consent was for a remote that no longer exists, so the flag stays put
|
|
1561
1593
|
// and the operator is asked again rather than silently overruled.
|
|
1562
|
-
const journal =
|
|
1594
|
+
const journal = journalAt(journalPath);
|
|
1563
1595
|
expect(journal.files["publish-raced.md"].localDiverges).toBe(true);
|
|
1564
1596
|
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1565
1597
|
});
|
|
@@ -1614,7 +1646,7 @@ describe("share", () => {
|
|
|
1614
1646
|
});
|
|
1615
1647
|
expect(result.filesUploaded).toBe(1);
|
|
1616
1648
|
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1617
|
-
const journal =
|
|
1649
|
+
const journal = journalAt(journalPath);
|
|
1618
1650
|
expect(journal.files["stuck-under-skip-unchanged.md"].direction).toBe("up");
|
|
1619
1651
|
expect(journal.files["stuck-under-skip-unchanged.md"].localDiverges).toBeFalsy();
|
|
1620
1652
|
});
|
|
@@ -1704,7 +1736,7 @@ describe("share", () => {
|
|
|
1704
1736
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
1705
1737
|
expect(result.filesUploaded).toBe(0);
|
|
1706
1738
|
expect(result.conflictPaths).toContain("peer-deleted.md");
|
|
1707
|
-
const journal =
|
|
1739
|
+
const journal = journalAt(journalPath);
|
|
1708
1740
|
expect(journal.files["peer-deleted.md"].localDiverges).toBe(true);
|
|
1709
1741
|
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1710
1742
|
});
|
|
@@ -1765,7 +1797,7 @@ describe("share", () => {
|
|
|
1765
1797
|
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
1766
1798
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"recreated-etag"' });
|
|
1767
1799
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1768
|
-
|
|
1800
|
+
seedJournalAt(journalPath, ({
|
|
1769
1801
|
version: "1",
|
|
1770
1802
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1771
1803
|
files: {
|
|
@@ -1795,7 +1827,7 @@ describe("share", () => {
|
|
|
1795
1827
|
const pc = vi.mocked(uploadFile).mock.calls[0][4];
|
|
1796
1828
|
expect(pc?.ifMatch).toBeUndefined();
|
|
1797
1829
|
expect(pc?.ifNoneMatch).toBe("*");
|
|
1798
|
-
const journal =
|
|
1830
|
+
const journal = journalAt(journalPath);
|
|
1799
1831
|
expect(journal.files["recreate-deleted.md"]).toMatchObject({
|
|
1800
1832
|
hash: localHash,
|
|
1801
1833
|
remoteEtag: "recreated-etag",
|
|
@@ -1816,7 +1848,7 @@ describe("share", () => {
|
|
|
1816
1848
|
return undefined;
|
|
1817
1849
|
});
|
|
1818
1850
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1819
|
-
|
|
1851
|
+
seedJournalAt(journalPath, ({
|
|
1820
1852
|
version: "1",
|
|
1821
1853
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1822
1854
|
files: {
|
|
@@ -1848,7 +1880,7 @@ describe("share", () => {
|
|
|
1848
1880
|
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifNoneMatch: "*" });
|
|
1849
1881
|
// Journal not advanced to a successful upload etag (entry may still be
|
|
1850
1882
|
// the prior baseline — next pass re-evaluates).
|
|
1851
|
-
const journal =
|
|
1883
|
+
const journal = journalAt(journalPath);
|
|
1852
1884
|
expect(journal.files["race-recreate.md"]?.remoteEtag).toBe("etag-before-delete");
|
|
1853
1885
|
expect(journal.files["race-recreate.md"]?.hash).toBe("old-hash");
|
|
1854
1886
|
expect(fs.readFileSync(testFile, "utf-8")).toBe("my recreated local");
|
|
@@ -2172,7 +2204,7 @@ describe("share", () => {
|
|
|
2172
2204
|
size: 5,
|
|
2173
2205
|
});
|
|
2174
2206
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2175
|
-
|
|
2207
|
+
seedJournalAt(journalPath, ({
|
|
2176
2208
|
version: "1",
|
|
2177
2209
|
lastSync: syncedAt,
|
|
2178
2210
|
files: {
|
|
@@ -2212,7 +2244,7 @@ describe("share", () => {
|
|
|
2212
2244
|
size: 5,
|
|
2213
2245
|
});
|
|
2214
2246
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2215
|
-
|
|
2247
|
+
seedJournalAt(journalPath, ({
|
|
2216
2248
|
version: "1",
|
|
2217
2249
|
lastSync: syncedAt,
|
|
2218
2250
|
files: {
|
|
@@ -2248,7 +2280,7 @@ describe("share", () => {
|
|
|
2248
2280
|
hqRoot: tmpDir,
|
|
2249
2281
|
});
|
|
2250
2282
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2251
|
-
const journal =
|
|
2283
|
+
const journal = journalAt(journalPath);
|
|
2252
2284
|
expect(journal.files["fresh.md"].remoteEtag).toBe("new-upload-etag");
|
|
2253
2285
|
});
|
|
2254
2286
|
it("forwards UploadAuthor to uploadFile when present (created-by metadata)", async () => {
|
|
@@ -2293,7 +2325,7 @@ describe("share", () => {
|
|
|
2293
2325
|
const { hashFile } = await import("../journal.js");
|
|
2294
2326
|
const hash = hashFile(testFile);
|
|
2295
2327
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2296
|
-
|
|
2328
|
+
seedJournalAt(journalPath, ({
|
|
2297
2329
|
version: "1",
|
|
2298
2330
|
lastSync: new Date().toISOString(),
|
|
2299
2331
|
files: {
|
|
@@ -2513,7 +2545,7 @@ describe("share", () => {
|
|
|
2513
2545
|
.update("stable content")
|
|
2514
2546
|
.digest("hex");
|
|
2515
2547
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2516
|
-
|
|
2548
|
+
seedJournalAt(journalPath, ({
|
|
2517
2549
|
version: "1",
|
|
2518
2550
|
lastSync: new Date().toISOString(),
|
|
2519
2551
|
files: {
|
|
@@ -2574,7 +2606,7 @@ describe("share", () => {
|
|
|
2574
2606
|
// not be queued for delete.
|
|
2575
2607
|
fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
|
|
2576
2608
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2577
|
-
|
|
2609
|
+
seedJournalAt(journalPath, ({
|
|
2578
2610
|
version: "1",
|
|
2579
2611
|
lastSync: new Date().toISOString(),
|
|
2580
2612
|
files: {
|
|
@@ -2611,7 +2643,7 @@ describe("share", () => {
|
|
|
2611
2643
|
operation: "delete",
|
|
2612
2644
|
});
|
|
2613
2645
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
|
|
2614
|
-
const journal =
|
|
2646
|
+
const journal = journalAt(journalPath);
|
|
2615
2647
|
expect(journal.files["knowledge"]).toBeUndefined();
|
|
2616
2648
|
});
|
|
2617
2649
|
it("propagateDeletes: a dangling local symlink is NOT classified as gone (lstat, not existsSync)", async () => {
|
|
@@ -2628,7 +2660,7 @@ describe("share", () => {
|
|
|
2628
2660
|
const danglingLink = path.join(companyRoot, "dangling-link.md");
|
|
2629
2661
|
fs.symlinkSync("./missing-target.md", danglingLink);
|
|
2630
2662
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2631
|
-
|
|
2663
|
+
seedJournalAt(journalPath, ({
|
|
2632
2664
|
version: "1",
|
|
2633
2665
|
lastSync: new Date().toISOString(),
|
|
2634
2666
|
files: {
|
|
@@ -2658,7 +2690,7 @@ describe("share", () => {
|
|
|
2658
2690
|
expect(result.filesDeleted).toBe(0);
|
|
2659
2691
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2660
2692
|
// Journal entry must survive the run.
|
|
2661
|
-
const journal =
|
|
2693
|
+
const journal = journalAt(journalPath);
|
|
2662
2694
|
expect(journal.files["dangling-link.md"]).toBeDefined();
|
|
2663
2695
|
});
|
|
2664
2696
|
it("vault-litter drain: a `.drift-*` marker with direction:'down' and NO delete intent tombstones " +
|
|
@@ -2723,7 +2755,7 @@ describe("share", () => {
|
|
|
2723
2755
|
});
|
|
2724
2756
|
expect(result.filesDeleted).toBe(1);
|
|
2725
2757
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "personal/scripts/run-project.sh.drift-1779863862-42519");
|
|
2726
|
-
const journal =
|
|
2758
|
+
const journal = journalAt(journalPath);
|
|
2727
2759
|
expect(journal.files["personal/scripts/run-project.sh.drift-1779863862-42519"]).toBeUndefined();
|
|
2728
2760
|
}
|
|
2729
2761
|
finally {
|
|
@@ -2796,7 +2828,7 @@ describe("share", () => {
|
|
|
2796
2828
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2797
2829
|
// No file on disk under companies/acme/peer-uploaded.md → local missing.
|
|
2798
2830
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2799
|
-
|
|
2831
|
+
seedJournalAt(journalPath, ({
|
|
2800
2832
|
version: "1",
|
|
2801
2833
|
lastSync: new Date().toISOString(),
|
|
2802
2834
|
files: {
|
|
@@ -2821,7 +2853,7 @@ describe("share", () => {
|
|
|
2821
2853
|
});
|
|
2822
2854
|
expect(result.filesDeleted).toBe(0);
|
|
2823
2855
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2824
|
-
const journal =
|
|
2856
|
+
const journal = journalAt(journalPath);
|
|
2825
2857
|
expect(journal.files["peer-uploaded.md"]).toBeDefined();
|
|
2826
2858
|
});
|
|
2827
2859
|
it("ROLLBACK KNOB: owned-only is honored on a prs_ personal vault — an intent-less entry with a " +
|
|
@@ -2912,7 +2944,7 @@ describe("share", () => {
|
|
|
2912
2944
|
]);
|
|
2913
2945
|
// Journal entry survives, so a later run (or a watcher-minted intent)
|
|
2914
2946
|
// can still authorize the delete properly.
|
|
2915
|
-
const journal =
|
|
2947
|
+
const journal = journalAt(journalPath);
|
|
2916
2948
|
expect(journal.files["personal/knowledge/gone.md"]).toBeDefined();
|
|
2917
2949
|
}
|
|
2918
2950
|
finally {
|
|
@@ -2929,7 +2961,7 @@ describe("share", () => {
|
|
|
2929
2961
|
// deleted by the user.
|
|
2930
2962
|
fs.writeFileSync(path.join(companyRoot, "kept.md"), "still here");
|
|
2931
2963
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2932
|
-
|
|
2964
|
+
seedJournalAt(journalPath, ({
|
|
2933
2965
|
version: "1",
|
|
2934
2966
|
lastSync: new Date().toISOString(),
|
|
2935
2967
|
files: {
|
|
@@ -2965,7 +2997,7 @@ describe("share", () => {
|
|
|
2965
2997
|
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
2966
2998
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "gone.md");
|
|
2967
2999
|
// Journal entry for the gone file is removed; the kept entry stays.
|
|
2968
|
-
const journal =
|
|
3000
|
+
const journal = journalAt(journalPath);
|
|
2969
3001
|
expect(journal.files["gone.md"]).toBeUndefined();
|
|
2970
3002
|
expect(journal.files["kept.md"]).toBeDefined();
|
|
2971
3003
|
});
|
|
@@ -2973,7 +3005,7 @@ describe("share", () => {
|
|
|
2973
3005
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2974
3006
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2975
3007
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2976
|
-
|
|
3008
|
+
seedJournalAt(journalPath, ({
|
|
2977
3009
|
version: "1",
|
|
2978
3010
|
lastSync: new Date().toISOString(),
|
|
2979
3011
|
files: {
|
|
@@ -3015,7 +3047,7 @@ describe("share", () => {
|
|
|
3015
3047
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3016
3048
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3017
3049
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3018
|
-
|
|
3050
|
+
seedJournalAt(journalPath, ({
|
|
3019
3051
|
version: "1",
|
|
3020
3052
|
lastSync: new Date().toISOString(),
|
|
3021
3053
|
files: {
|
|
@@ -3038,7 +3070,7 @@ describe("share", () => {
|
|
|
3038
3070
|
expect(result.filesDeleted).toBe(0);
|
|
3039
3071
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3040
3072
|
// Journal entry survives so the next opt-in run can still propagate.
|
|
3041
|
-
const journal =
|
|
3073
|
+
const journal = journalAt(journalPath);
|
|
3042
3074
|
expect(journal.files["gone.md"]).toBeDefined();
|
|
3043
3075
|
});
|
|
3044
3076
|
it("propagateDeletes: scope is constrained to the supplied paths — sibling deletes are not swept", async () => {
|
|
@@ -3046,7 +3078,7 @@ describe("share", () => {
|
|
|
3046
3078
|
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
3047
3079
|
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
3048
3080
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3049
|
-
|
|
3081
|
+
seedJournalAt(journalPath, ({
|
|
3050
3082
|
version: "1",
|
|
3051
3083
|
lastSync: new Date().toISOString(),
|
|
3052
3084
|
files: {
|
|
@@ -3081,7 +3113,7 @@ describe("share", () => {
|
|
|
3081
3113
|
expect(result.filesDeleted).toBe(1);
|
|
3082
3114
|
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
3083
3115
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
|
|
3084
|
-
const journal =
|
|
3116
|
+
const journal = journalAt(journalPath);
|
|
3085
3117
|
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
3086
3118
|
// Sibling tree's journal entry is untouched — `hq share <subtree>` must
|
|
3087
3119
|
// not act on files outside the named scope.
|
|
@@ -3097,7 +3129,7 @@ describe("share", () => {
|
|
|
3097
3129
|
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
3098
3130
|
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
3099
3131
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3100
|
-
|
|
3132
|
+
seedJournalAt(journalPath, ({
|
|
3101
3133
|
version: "1",
|
|
3102
3134
|
lastSync: new Date().toISOString(),
|
|
3103
3135
|
files: {
|
|
@@ -3132,7 +3164,7 @@ describe("share", () => {
|
|
|
3132
3164
|
});
|
|
3133
3165
|
expect(result.filesDeleted).toBe(1);
|
|
3134
3166
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
|
|
3135
|
-
const journal =
|
|
3167
|
+
const journal = journalAt(journalPath);
|
|
3136
3168
|
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
3137
3169
|
// Scope stays exactly as narrow as the absolute spelling's — no wider.
|
|
3138
3170
|
expect(journal.files["other/also-gone.md"]).toBeDefined();
|
|
@@ -3149,7 +3181,7 @@ describe("share", () => {
|
|
|
3149
3181
|
fs.mkdirSync(acmeRepo, { recursive: true });
|
|
3150
3182
|
fs.symlinkSync(acmeRepo, path.join(companyRoot, "knowledge"));
|
|
3151
3183
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3152
|
-
|
|
3184
|
+
seedJournalAt(journalPath, ({
|
|
3153
3185
|
version: "1",
|
|
3154
3186
|
lastSync: new Date().toISOString(),
|
|
3155
3187
|
files: {
|
|
@@ -3179,14 +3211,14 @@ describe("share", () => {
|
|
|
3179
3211
|
});
|
|
3180
3212
|
expect(result.filesDeleted).toBe(0);
|
|
3181
3213
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3182
|
-
const journal =
|
|
3214
|
+
const journal = journalAt(journalPath);
|
|
3183
3215
|
expect(journal.files["knowledge/agents/brief.md"]).toBeDefined();
|
|
3184
3216
|
});
|
|
3185
3217
|
it("propagateDeletes: a failed DeleteObject leaves the journal entry intact for retry", async () => {
|
|
3186
3218
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3187
3219
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3188
3220
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3189
|
-
|
|
3221
|
+
seedJournalAt(journalPath, ({
|
|
3190
3222
|
version: "1",
|
|
3191
3223
|
lastSync: new Date().toISOString(),
|
|
3192
3224
|
files: {
|
|
@@ -3219,7 +3251,7 @@ describe("share", () => {
|
|
|
3219
3251
|
const errorEvent = events.find((e) => e.type === "error");
|
|
3220
3252
|
expect(errorEvent).toMatchObject({ path: "flaky.md", message: expect.stringContaining("S3 down") });
|
|
3221
3253
|
// Entry survives — next run will retry the delete.
|
|
3222
|
-
const journal =
|
|
3254
|
+
const journal = journalAt(journalPath);
|
|
3223
3255
|
expect(journal.files["flaky.md"]).toBeDefined();
|
|
3224
3256
|
});
|
|
3225
3257
|
// ── Delete propagation safety: direction + filter guards (Bug B + C) ──────
|
|
@@ -3253,7 +3285,7 @@ describe("share", () => {
|
|
|
3253
3285
|
// and one this machine uploaded (direction:'up'). Only the 'up' one should
|
|
3254
3286
|
// be eligible for delete-propagation.
|
|
3255
3287
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3256
|
-
|
|
3288
|
+
seedJournalAt(journalPath, ({
|
|
3257
3289
|
version: "1",
|
|
3258
3290
|
lastSync: new Date().toISOString(),
|
|
3259
3291
|
files: {
|
|
@@ -3290,7 +3322,7 @@ describe("share", () => {
|
|
|
3290
3322
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3291
3323
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3292
3324
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3293
|
-
|
|
3325
|
+
seedJournalAt(journalPath, ({
|
|
3294
3326
|
version: "1",
|
|
3295
3327
|
lastSync: new Date().toISOString(),
|
|
3296
3328
|
files: {
|
|
@@ -3323,7 +3355,7 @@ describe("share", () => {
|
|
|
3323
3355
|
// absence is a filter outcome, not a user-intended delete.
|
|
3324
3356
|
fs.writeFileSync(path.join(tmpDir, ".hqignore"), "companies/acme/legacy/**\n");
|
|
3325
3357
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3326
|
-
|
|
3358
|
+
seedJournalAt(journalPath, ({
|
|
3327
3359
|
version: "1",
|
|
3328
3360
|
lastSync: new Date().toISOString(),
|
|
3329
3361
|
files: {
|
|
@@ -3382,7 +3414,7 @@ describe("share", () => {
|
|
|
3382
3414
|
// Under owned-only this would be stuck on remote forever; currency-gated
|
|
3383
3415
|
// checks the ETag and propagates the delete cleanly when match holds.
|
|
3384
3416
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3385
|
-
|
|
3417
|
+
seedJournalAt(journalPath, ({
|
|
3386
3418
|
version: "1",
|
|
3387
3419
|
lastSync: new Date().toISOString(),
|
|
3388
3420
|
files: {
|
|
@@ -3412,7 +3444,7 @@ describe("share", () => {
|
|
|
3412
3444
|
});
|
|
3413
3445
|
expect(result.filesDeleted).toBe(1);
|
|
3414
3446
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "core/policies/old.md");
|
|
3415
|
-
const journal =
|
|
3447
|
+
const journal = journalAt(journalPath);
|
|
3416
3448
|
expect(journal.files["core/policies/old.md"]).toBeUndefined();
|
|
3417
3449
|
});
|
|
3418
3450
|
it("currency-gated: propagates the S3 delete for a local-delete tombstone (pull-leg producer hand-off)", async () => {
|
|
@@ -3423,7 +3455,7 @@ describe("share", () => {
|
|
|
3423
3455
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3424
3456
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3425
3457
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3426
|
-
|
|
3458
|
+
seedJournalAt(journalPath, ({
|
|
3427
3459
|
version: "2",
|
|
3428
3460
|
lastSync: new Date().toISOString(),
|
|
3429
3461
|
files: {
|
|
@@ -3459,7 +3491,7 @@ describe("share", () => {
|
|
|
3459
3491
|
});
|
|
3460
3492
|
expect(result.filesDeleted).toBe(1);
|
|
3461
3493
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "docs/deleted.md");
|
|
3462
|
-
const journal =
|
|
3494
|
+
const journal = journalAt(journalPath);
|
|
3463
3495
|
expect(journal.files["docs/deleted.md"]).toBeUndefined();
|
|
3464
3496
|
});
|
|
3465
3497
|
it("currency-gated: refuses delete + emits stale-etag event when remote moved since last sync", async () => {
|
|
@@ -3468,7 +3500,7 @@ describe("share", () => {
|
|
|
3468
3500
|
// Another device modified `shared.md` after this device's last sync.
|
|
3469
3501
|
// The journal still records the old etag; HEAD returns the new one.
|
|
3470
3502
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3471
|
-
|
|
3503
|
+
seedJournalAt(journalPath, ({
|
|
3472
3504
|
version: "1",
|
|
3473
3505
|
lastSync: new Date().toISOString(),
|
|
3474
3506
|
files: {
|
|
@@ -3500,7 +3532,7 @@ describe("share", () => {
|
|
|
3500
3532
|
// No remote DELETE issued, journal entry preserved (pull will re-pull).
|
|
3501
3533
|
expect(result.filesDeleted).toBe(0);
|
|
3502
3534
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3503
|
-
const journal =
|
|
3535
|
+
const journal = journalAt(journalPath);
|
|
3504
3536
|
expect(journal.files["shared.md"]).toBeDefined();
|
|
3505
3537
|
// Dedicated event fired so UIs surface the refusal. `reason` field
|
|
3506
3538
|
// discriminates "stale-etag" (real drift) from "legacy-no-etag" so
|
|
@@ -3522,7 +3554,7 @@ describe("share", () => {
|
|
|
3522
3554
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3523
3555
|
const recreatedPath = path.join(companyRoot, "racy.md");
|
|
3524
3556
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3525
|
-
|
|
3557
|
+
seedJournalAt(journalPath, ({
|
|
3526
3558
|
version: "2",
|
|
3527
3559
|
lastSync: new Date().toISOString(),
|
|
3528
3560
|
files: {
|
|
@@ -3558,7 +3590,7 @@ describe("share", () => {
|
|
|
3558
3590
|
expect(result.filesDeleted).toBe(0);
|
|
3559
3591
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3560
3592
|
expect(fs.readFileSync(recreatedPath, "utf-8")).toBe("recreated after planning");
|
|
3561
|
-
const journal =
|
|
3593
|
+
const journal = journalAt(journalPath);
|
|
3562
3594
|
expect(journal.files["racy.md"]).toBeDefined();
|
|
3563
3595
|
expect(events).toContainEqual(expect.objectContaining({
|
|
3564
3596
|
type: "delete-refused-stale-etag",
|
|
@@ -3580,7 +3612,7 @@ describe("share", () => {
|
|
|
3580
3612
|
// genuine local==remote sync, so propagating the delete would destroy the
|
|
3581
3613
|
// (different) remote version. Must be REFUSED, not propagated.
|
|
3582
3614
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3583
|
-
|
|
3615
|
+
seedJournalAt(journalPath, ({
|
|
3584
3616
|
version: "2",
|
|
3585
3617
|
lastSync: new Date().toISOString(),
|
|
3586
3618
|
files: {
|
|
@@ -3615,7 +3647,7 @@ describe("share", () => {
|
|
|
3615
3647
|
// Refusal short-circuits before the currency HEAD — no network needed.
|
|
3616
3648
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
3617
3649
|
// Journal entry preserved, not silently dropped.
|
|
3618
|
-
const journal =
|
|
3650
|
+
const journal = journalAt(journalPath);
|
|
3619
3651
|
expect(journal.files["core/policies/kept.md"]).toBeDefined();
|
|
3620
3652
|
// Surfaced as a refusal with the divergent-local reason.
|
|
3621
3653
|
const refused = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
@@ -3632,7 +3664,7 @@ describe("share", () => {
|
|
|
3632
3664
|
// S3 console). Local copy also missing. Currency-gated should drop the
|
|
3633
3665
|
// journal entry without issuing a DELETE — the remote is already gone.
|
|
3634
3666
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3635
|
-
|
|
3667
|
+
seedJournalAt(journalPath, ({
|
|
3636
3668
|
version: "1",
|
|
3637
3669
|
lastSync: new Date().toISOString(),
|
|
3638
3670
|
files: {
|
|
@@ -3665,7 +3697,7 @@ describe("share", () => {
|
|
|
3665
3697
|
// from real deletes without re-counting events.
|
|
3666
3698
|
expect(result.filesTombstoned).toBe(1);
|
|
3667
3699
|
expect(result.filesRefusedStale).toBe(0);
|
|
3668
|
-
const journal =
|
|
3700
|
+
const journal = journalAt(journalPath);
|
|
3669
3701
|
expect(journal.files["removed-out-of-band.md"]).toBeUndefined();
|
|
3670
3702
|
// Synthetic progress event carries the tombstone marker. Without this,
|
|
3671
3703
|
// the tty stream renders tombstones identically to real deletes — operator
|
|
@@ -3686,7 +3718,7 @@ describe("share", () => {
|
|
|
3686
3718
|
// against. Refuse the delete in the safe direction; a future sync with
|
|
3687
3719
|
// a recorded etag can re-evaluate.
|
|
3688
3720
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3689
|
-
|
|
3721
|
+
seedJournalAt(journalPath, ({
|
|
3690
3722
|
version: "1",
|
|
3691
3723
|
lastSync: new Date().toISOString(),
|
|
3692
3724
|
files: {
|
|
@@ -3740,7 +3772,7 @@ describe("share", () => {
|
|
|
3740
3772
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3741
3773
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3742
3774
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3743
|
-
|
|
3775
|
+
seedJournalAt(journalPath, ({
|
|
3744
3776
|
version: "1",
|
|
3745
3777
|
lastSync: new Date().toISOString(),
|
|
3746
3778
|
files: {
|
|
@@ -3792,7 +3824,7 @@ describe("share", () => {
|
|
|
3792
3824
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3793
3825
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3794
3826
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3795
|
-
|
|
3827
|
+
seedJournalAt(journalPath, ({
|
|
3796
3828
|
version: "1",
|
|
3797
3829
|
lastSync: new Date().toISOString(),
|
|
3798
3830
|
files: {
|
|
@@ -3826,7 +3858,7 @@ describe("share", () => {
|
|
|
3826
3858
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3827
3859
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3828
3860
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3829
|
-
|
|
3861
|
+
seedJournalAt(journalPath, ({
|
|
3830
3862
|
version: "1",
|
|
3831
3863
|
lastSync: new Date().toISOString(),
|
|
3832
3864
|
files: {
|
|
@@ -3876,7 +3908,7 @@ describe("share", () => {
|
|
|
3876
3908
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3877
3909
|
const recreatedPath = path.join(companyRoot, "notes", "gone.md");
|
|
3878
3910
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3879
|
-
|
|
3911
|
+
seedJournalAt(journalPath, ({
|
|
3880
3912
|
version: "1",
|
|
3881
3913
|
lastSync: new Date().toISOString(),
|
|
3882
3914
|
files: {
|
|
@@ -3924,7 +3956,7 @@ describe("share", () => {
|
|
|
3924
3956
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3925
3957
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3926
3958
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3927
|
-
|
|
3959
|
+
seedJournalAt(journalPath, ({
|
|
3928
3960
|
version: "1",
|
|
3929
3961
|
lastSync: new Date().toISOString(),
|
|
3930
3962
|
files: {
|
|
@@ -3954,14 +3986,14 @@ describe("share", () => {
|
|
|
3954
3986
|
type: "delete-refused-stale-etag",
|
|
3955
3987
|
reason: "legacy-no-etag",
|
|
3956
3988
|
}));
|
|
3957
|
-
const journal =
|
|
3989
|
+
const journal = journalAt(journalPath);
|
|
3958
3990
|
expect(journal.files["notes/legacy.md"]).toBeDefined();
|
|
3959
3991
|
});
|
|
3960
3992
|
it("owned-only still requires an intent — it is the documented rollback knob", async () => {
|
|
3961
3993
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3962
3994
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3963
3995
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3964
|
-
|
|
3996
|
+
seedJournalAt(journalPath, ({
|
|
3965
3997
|
version: "1",
|
|
3966
3998
|
lastSync: new Date().toISOString(),
|
|
3967
3999
|
files: {
|
|
@@ -4148,7 +4180,7 @@ describe("share", () => {
|
|
|
4148
4180
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4149
4181
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4150
4182
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4151
|
-
|
|
4183
|
+
seedJournalAt(journalPath, ({
|
|
4152
4184
|
version: "1",
|
|
4153
4185
|
lastSync: new Date().toISOString(),
|
|
4154
4186
|
files: {
|
|
@@ -4189,7 +4221,7 @@ describe("share", () => {
|
|
|
4189
4221
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "regular.md");
|
|
4190
4222
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md");
|
|
4191
4223
|
// Both journal entries are removed by the delete loop's removeEntry call.
|
|
4192
|
-
const journal =
|
|
4224
|
+
const journal = journalAt(journalPath);
|
|
4193
4225
|
expect(journal.files["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md"]).toBeUndefined();
|
|
4194
4226
|
expect(journal.files["regular.md"]).toBeUndefined();
|
|
4195
4227
|
});
|
|
@@ -4204,7 +4236,7 @@ describe("share", () => {
|
|
|
4204
4236
|
process.env.HQ_STATE_DIR = stateDirPersonal;
|
|
4205
4237
|
try {
|
|
4206
4238
|
const journalPath = path.join(stateDirPersonal, "sync-journal.__hq_personal_vault__.json");
|
|
4207
|
-
|
|
4239
|
+
seedJournalAt(journalPath, ({
|
|
4208
4240
|
version: "1",
|
|
4209
4241
|
lastSync: new Date().toISOString(),
|
|
4210
4242
|
files: {
|
|
@@ -4230,7 +4262,7 @@ describe("share", () => {
|
|
|
4230
4262
|
});
|
|
4231
4263
|
expect(result.filesDeleted).toBe(1);
|
|
4232
4264
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "personal/.obsidian/graph.json.drift-1779863862-42519");
|
|
4233
|
-
const journal =
|
|
4265
|
+
const journal = journalAt(journalPath);
|
|
4234
4266
|
expect(journal.files["personal/.obsidian/graph.json.drift-1779863862-42519"]).toBeUndefined();
|
|
4235
4267
|
}
|
|
4236
4268
|
finally {
|
|
@@ -4253,7 +4285,7 @@ describe("share", () => {
|
|
|
4253
4285
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
4254
4286
|
direction: "up",
|
|
4255
4287
|
});
|
|
4256
|
-
|
|
4288
|
+
seedJournalAt(journalPath, ({
|
|
4257
4289
|
version: "1",
|
|
4258
4290
|
lastSync: new Date().toISOString(),
|
|
4259
4291
|
files: {
|
|
@@ -4301,7 +4333,7 @@ describe("share", () => {
|
|
|
4301
4333
|
expect(pruned[0].byReason["ignore-filtered"]).toBe(3);
|
|
4302
4334
|
expect(pruned[0].byReason["excluded-top-level"]).toBe(1);
|
|
4303
4335
|
// The persisted journal converged: excluded rows gone, legit row kept.
|
|
4304
|
-
const journal =
|
|
4336
|
+
const journal = journalAt(journalPath);
|
|
4305
4337
|
expect(Object.keys(journal.files)).toContain("personal/keep.md");
|
|
4306
4338
|
expect(journal.files[".claude/worktrees/wt/a.ts"]).toBeUndefined();
|
|
4307
4339
|
expect(journal.files["personal/node_modules/pkg/index.js"]).toBeUndefined();
|
|
@@ -4333,7 +4365,7 @@ describe("share", () => {
|
|
|
4333
4365
|
const key = "deleted-top-level.md";
|
|
4334
4366
|
const journalSlug = "personal-scoped-delete";
|
|
4335
4367
|
const journalPath = path.join(stateDir, `sync-journal.${journalSlug}.json`);
|
|
4336
|
-
|
|
4368
|
+
seedJournalAt(journalPath, ({
|
|
4337
4369
|
version: "2",
|
|
4338
4370
|
lastSync: new Date().toISOString(),
|
|
4339
4371
|
pulls: [],
|
|
@@ -4365,7 +4397,7 @@ describe("share", () => {
|
|
|
4365
4397
|
});
|
|
4366
4398
|
expect(result.filesDeleted).toBe(1);
|
|
4367
4399
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), key);
|
|
4368
|
-
expect(
|
|
4400
|
+
expect(journalAt(journalPath).files[key]).toBeUndefined();
|
|
4369
4401
|
});
|
|
4370
4402
|
// ── Decommission prefixes ──────────────────────────────────────────────────
|
|
4371
4403
|
//
|
|
@@ -4420,7 +4452,7 @@ describe("share", () => {
|
|
|
4420
4452
|
// Local file remains on disk — decommission is a remote-only sweep.
|
|
4421
4453
|
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "notes.md"))).toBe(true);
|
|
4422
4454
|
// Journal entry pruned in lockstep with the remote delete.
|
|
4423
|
-
const journalAfter =
|
|
4455
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4424
4456
|
expect(journalAfter.files["companies/foo/notes.md"]).toBeUndefined();
|
|
4425
4457
|
expect(journalAfter.files["knowledge/x.md"]).toBeDefined();
|
|
4426
4458
|
});
|
|
@@ -4456,7 +4488,7 @@ describe("share", () => {
|
|
|
4456
4488
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
4457
4489
|
// Journal entry preserved — the pulled-from-peer record stays so a
|
|
4458
4490
|
// future pull can still reconcile it correctly.
|
|
4459
|
-
const journalAfter =
|
|
4491
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4460
4492
|
expect(journalAfter.files["companies/foo/from-peer.md"]).toBeDefined();
|
|
4461
4493
|
});
|
|
4462
4494
|
it("decommissionPrefixes: dedupes with propagateDeletes plan — a key in both still emits one DeleteObject", async () => {
|
|
@@ -4576,7 +4608,7 @@ describe("share", () => {
|
|
|
4576
4608
|
expect(result.filesTombstoned).toBe(1);
|
|
4577
4609
|
expect(result.filesDeleted).toBe(0);
|
|
4578
4610
|
// Journal entry is gone.
|
|
4579
|
-
const journalAfter =
|
|
4611
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4580
4612
|
expect(journalAfter.files["companies/foo/gone.md"]).toBeUndefined();
|
|
4581
4613
|
});
|
|
4582
4614
|
describe("personalMode", () => {
|
|
@@ -4617,7 +4649,7 @@ describe("share", () => {
|
|
|
4617
4649
|
const acmeJournalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4618
4650
|
expect(fs.existsSync(personalJournalPath)).toBe(true);
|
|
4619
4651
|
expect(fs.existsSync(acmeJournalPath)).toBe(false);
|
|
4620
|
-
const journal =
|
|
4652
|
+
const journal = journalAt(personalJournalPath);
|
|
4621
4653
|
expect(journal.files["knowledge/notes.md"]).toBeDefined();
|
|
4622
4654
|
});
|
|
4623
4655
|
it("personalMode=true accepts files outside companies/<slug>/ (companion to the company-folder rejection)", async () => {
|
|
@@ -4905,7 +4937,7 @@ describe("share", () => {
|
|
|
4905
4937
|
.createHash("sha256")
|
|
4906
4938
|
.update(target)
|
|
4907
4939
|
.digest("hex");
|
|
4908
|
-
|
|
4940
|
+
seedJournalAt(journalPath, ({
|
|
4909
4941
|
version: "1",
|
|
4910
4942
|
lastSync: new Date().toISOString(),
|
|
4911
4943
|
files: {
|
|
@@ -5308,7 +5340,7 @@ describe("share", () => {
|
|
|
5308
5340
|
});
|
|
5309
5341
|
expect(pushed.filesUploaded).toBe(1);
|
|
5310
5342
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5311
|
-
const afterPush =
|
|
5343
|
+
const afterPush = journalAt(journalPath);
|
|
5312
5344
|
expect(Object.keys(afterPush.files)).toContain("knowledge/agents/brief.md");
|
|
5313
5345
|
vi.mocked(deleteRemoteFile).mockClear();
|
|
5314
5346
|
const walked = await share({
|
|
@@ -5322,7 +5354,7 @@ describe("share", () => {
|
|
|
5322
5354
|
expect(walked.filesDeleted).toBe(0);
|
|
5323
5355
|
const deletedKeys = vi.mocked(deleteRemoteFile).mock.calls.map((c) => c[1]);
|
|
5324
5356
|
expect(deletedKeys).not.toContain("knowledge/agents/brief.md");
|
|
5325
|
-
const afterWalk =
|
|
5357
|
+
const afterWalk = journalAt(journalPath);
|
|
5326
5358
|
expect(Object.keys(afterWalk.files)).toContain("knowledge/agents/brief.md");
|
|
5327
5359
|
});
|
|
5328
5360
|
});
|
|
@@ -5366,7 +5398,7 @@ describe("share", () => {
|
|
|
5366
5398
|
}
|
|
5367
5399
|
}
|
|
5368
5400
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5369
|
-
|
|
5401
|
+
seedJournalAt(journalPath, ({
|
|
5370
5402
|
version: "1",
|
|
5371
5403
|
lastSync: new Date().toISOString(),
|
|
5372
5404
|
files,
|
|
@@ -5424,7 +5456,7 @@ describe("share", () => {
|
|
|
5424
5456
|
expect(summary.samplePaths.length).toBeGreaterThan(0);
|
|
5425
5457
|
expect(summary.samplePaths.length).toBeLessThanOrEqual(10);
|
|
5426
5458
|
// Journal entries for the missing files SURVIVE — guard never mutates.
|
|
5427
|
-
const journal =
|
|
5459
|
+
const journal = journalAt(journalPath);
|
|
5428
5460
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5429
5461
|
// Per-key delete-refused-stale-etag events for each candidate.
|
|
5430
5462
|
const perKeyRefusals = events.filter((e) => e.type === "delete-refused-stale-etag" && e.reason === "bulk-asymmetry");
|
|
@@ -5474,7 +5506,7 @@ describe("share", () => {
|
|
|
5474
5506
|
expect(headedKeys).not.toContain(`f-${i.toString().padStart(4, "0")}.md`);
|
|
5475
5507
|
}
|
|
5476
5508
|
// Journal untouched — the entries stay recoverable.
|
|
5477
|
-
const journal =
|
|
5509
|
+
const journal = journalAt(journalPath);
|
|
5478
5510
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5479
5511
|
});
|
|
5480
5512
|
// The core of the #227 regression, isolated: pre-#227 a trip emptied
|
|
@@ -5505,7 +5537,7 @@ describe("share", () => {
|
|
|
5505
5537
|
const perKeyRefusals = events.filter((e) => e.type === "delete-refused-stale-etag" &&
|
|
5506
5538
|
e.reason === "bulk-asymmetry");
|
|
5507
5539
|
expect(perKeyRefusals.length).toBe(11);
|
|
5508
|
-
const journal =
|
|
5540
|
+
const journal = journalAt(journalPath);
|
|
5509
5541
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5510
5542
|
});
|
|
5511
5543
|
it("vault litter still drains when the breaker trips", async () => {
|
|
@@ -5519,7 +5551,7 @@ describe("share", () => {
|
|
|
5519
5551
|
withIntents: false,
|
|
5520
5552
|
});
|
|
5521
5553
|
const litterKey = "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md";
|
|
5522
|
-
const seeded =
|
|
5554
|
+
const seeded = journalAt(journalPath);
|
|
5523
5555
|
seeded.files[litterKey] = {
|
|
5524
5556
|
hash: "h",
|
|
5525
5557
|
size: 5,
|
|
@@ -5528,7 +5560,7 @@ describe("share", () => {
|
|
|
5528
5560
|
remoteEtag: "litter-etag",
|
|
5529
5561
|
kind: "file",
|
|
5530
5562
|
};
|
|
5531
|
-
|
|
5563
|
+
seedJournalAt(journalPath, seeded);
|
|
5532
5564
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
5533
5565
|
etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
|
|
5534
5566
|
lastModified: new Date(),
|
|
@@ -5668,7 +5700,7 @@ describe("share", () => {
|
|
|
5668
5700
|
expect(summary).toBeDefined();
|
|
5669
5701
|
expect(summary.ratio).toBeCloseTo(1.0, 5);
|
|
5670
5702
|
// Journal untouched.
|
|
5671
|
-
const journal =
|
|
5703
|
+
const journal = journalAt(journalPath);
|
|
5672
5704
|
expect(Object.keys(journal.files).length).toBe(50);
|
|
5673
5705
|
});
|
|
5674
5706
|
it("small absolute count (9/9 = 100% ratio but < MIN_ABS=10) does NOT trip: all 9 delete normally", async () => {
|
|
@@ -5712,7 +5744,7 @@ describe("share", () => {
|
|
|
5712
5744
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
5713
5745
|
expect(events.find((e) => e.type === "delete-refused-bulk-asymmetry")).toBeDefined();
|
|
5714
5746
|
// Journal entries survive.
|
|
5715
|
-
const journal =
|
|
5747
|
+
const journal = journalAt(journalPath);
|
|
5716
5748
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5717
5749
|
});
|
|
5718
5750
|
it("direction:'down' entries don't count toward the bulk numerator (owned-only silently skips them)", async () => {
|
|
@@ -5740,7 +5772,7 @@ describe("share", () => {
|
|
|
5740
5772
|
localDeleteIntent: deleteIntent("up-etag", "h"),
|
|
5741
5773
|
};
|
|
5742
5774
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5743
|
-
|
|
5775
|
+
seedJournalAt(journalPath, { version: "1", lastSync: now, files });
|
|
5744
5776
|
const result = await share({
|
|
5745
5777
|
paths: [companyRoot],
|
|
5746
5778
|
company: "acme",
|
|
@@ -5863,7 +5895,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5863
5895
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
5864
5896
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5865
5897
|
// v2-shaped fixture — same field set the production journal uses.
|
|
5866
|
-
|
|
5898
|
+
seedJournalAt(journalPath, ({
|
|
5867
5899
|
version: "2",
|
|
5868
5900
|
lastSync: new Date().toISOString(),
|
|
5869
5901
|
files: {
|
|
@@ -5909,7 +5941,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5909
5941
|
const { hashFile } = await import("../journal.js");
|
|
5910
5942
|
const baselineHash = hashFile(testFile);
|
|
5911
5943
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5912
|
-
|
|
5944
|
+
seedJournalAt(journalPath, ({
|
|
5913
5945
|
version: "2",
|
|
5914
5946
|
lastSync: new Date().toISOString(),
|
|
5915
5947
|
files: {
|
|
@@ -5950,7 +5982,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5950
5982
|
const originalLstat = fs.lstatSync(testFile);
|
|
5951
5983
|
// The recorded hash intentionally differs from the current bytes.
|
|
5952
5984
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5953
|
-
|
|
5985
|
+
seedJournalAt(journalPath, ({
|
|
5954
5986
|
version: "2",
|
|
5955
5987
|
lastSync: new Date().toISOString(),
|
|
5956
5988
|
files: {
|
|
@@ -5997,7 +6029,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5997
6029
|
const { hashFile } = await import("../journal.js");
|
|
5998
6030
|
const realHash = hashFile(testFile);
|
|
5999
6031
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6000
|
-
|
|
6032
|
+
seedJournalAt(journalPath, ({
|
|
6001
6033
|
version: "2",
|
|
6002
6034
|
lastSync: new Date().toISOString(),
|
|
6003
6035
|
files: {
|
|
@@ -6047,7 +6079,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6047
6079
|
const { hashFile } = await import("../journal.js");
|
|
6048
6080
|
const realHash = hashFile(testFile);
|
|
6049
6081
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6050
|
-
|
|
6082
|
+
seedJournalAt(journalPath, ({
|
|
6051
6083
|
version: "2",
|
|
6052
6084
|
lastSync: new Date().toISOString(),
|
|
6053
6085
|
files: {
|
|
@@ -6079,7 +6111,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6079
6111
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
6080
6112
|
// Journal mtimeMs is now the bumped value, while content hashing remains
|
|
6081
6113
|
// authoritative on the next sync.
|
|
6082
|
-
const journalAfter =
|
|
6114
|
+
const journalAfter = journalAt(journalPath);
|
|
6083
6115
|
expect(journalAfter.files["touched-then-fast.md"].mtimeMs).toBe(bumpedLstat.mtimeMs);
|
|
6084
6116
|
expect(journalAfter.files["touched-then-fast.md"].size).toBe(bumpedLstat.size);
|
|
6085
6117
|
// Content hash untouched — the file genuinely didn't change.
|
|
@@ -6099,7 +6131,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6099
6131
|
});
|
|
6100
6132
|
expect(result.filesUploaded).toBe(1);
|
|
6101
6133
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6102
|
-
const journal =
|
|
6134
|
+
const journal = journalAt(journalPath);
|
|
6103
6135
|
expect(journal.files["fresh.md"]).toBeDefined();
|
|
6104
6136
|
expect(journal.files["fresh.md"].mtimeMs).toBe(lstat.mtimeMs);
|
|
6105
6137
|
expect(journal.files["fresh.md"].size).toBe(lstat.size);
|
|
@@ -6116,7 +6148,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6116
6148
|
const realSize = fs.statSync(testFile).size;
|
|
6117
6149
|
// Real hash but NO mtimeMs. Hash matches → skip upload.
|
|
6118
6150
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6119
|
-
|
|
6151
|
+
seedJournalAt(journalPath, ({
|
|
6120
6152
|
version: "1",
|
|
6121
6153
|
lastSync: new Date().toISOString(),
|
|
6122
6154
|
files: {
|
|
@@ -6675,7 +6707,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6675
6707
|
// AND lost their etag/content-hash/stat journal metadata.
|
|
6676
6708
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6677
6709
|
expect(fs.existsSync(journalPath)).toBe(true);
|
|
6678
|
-
const journal =
|
|
6710
|
+
const journal = journalAt(journalPath);
|
|
6679
6711
|
const stampedKeys = Object.keys(journal.files);
|
|
6680
6712
|
expect(stampedKeys.length).toBe(FILE_COUNT - 1);
|
|
6681
6713
|
// The failed file is NOT in the journal (no successful upload).
|
|
@@ -6823,7 +6855,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6823
6855
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
6824
6856
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
6825
6857
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6826
|
-
|
|
6858
|
+
seedJournalAt(journalPath, ({
|
|
6827
6859
|
version: "1",
|
|
6828
6860
|
lastSync: new Date().toISOString(),
|
|
6829
6861
|
files: {
|
|
@@ -6852,7 +6884,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6852
6884
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
6853
6885
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
6854
6886
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
6855
|
-
const journal =
|
|
6887
|
+
const journal = journalAt(journalPath);
|
|
6856
6888
|
expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
|
|
6857
6889
|
});
|
|
6858
6890
|
it("defers an unreadable scope-invalid symlink tombstone without a fatal error", async () => {
|
|
@@ -6864,7 +6896,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6864
6896
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
6865
6897
|
fs.symlinkSync(targetDir, linkPath, "dir");
|
|
6866
6898
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6867
|
-
|
|
6899
|
+
seedJournalAt(journalPath, ({
|
|
6868
6900
|
version: "1",
|
|
6869
6901
|
lastSync: new Date().toISOString(),
|
|
6870
6902
|
files: {
|
|
@@ -6909,7 +6941,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6909
6941
|
samplePaths: [linkKey],
|
|
6910
6942
|
});
|
|
6911
6943
|
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
6912
|
-
const journal =
|
|
6944
|
+
const journal = journalAt(journalPath);
|
|
6913
6945
|
expect(journal.files[linkKey]).toBeDefined();
|
|
6914
6946
|
}
|
|
6915
6947
|
finally {
|