@indigoai-us/hq-cloud 6.15.19 → 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 +2 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +43 -8
- 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/conflict.d.ts +25 -2
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +29 -4
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +90 -6
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +434 -104
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +9 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +95 -46
- 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: {
|
|
@@ -1450,6 +1482,304 @@ describe("share", () => {
|
|
|
1450
1482
|
// fence then retry — either is consent. Must not leave the file unuploaded.
|
|
1451
1483
|
expect(uploadFile).toHaveBeenCalled();
|
|
1452
1484
|
});
|
|
1485
|
+
// ── publish-local: fenced promotion of a kept-local copy ────────────────
|
|
1486
|
+
//
|
|
1487
|
+
// `keep` leaves a file permanently unpublishable: push refuses it on the
|
|
1488
|
+
// localDiverges flag, pull never clears the flag (remote is unchanged), and
|
|
1489
|
+
// `keep` re-arms it every pass. The only existing escape — `overwrite` — is
|
|
1490
|
+
// a blunt force-write that will PUT unfenced after a 412, clobbering a
|
|
1491
|
+
// remote that genuinely advanced.
|
|
1492
|
+
//
|
|
1493
|
+
// `publish-local` is the missing middle: the operator explicitly elects
|
|
1494
|
+
// their local copy as the new authority, and the engine accepts it ONLY
|
|
1495
|
+
// while the remote still sits at the etag recorded when the keep happened.
|
|
1496
|
+
// If the remote moved, consent was given against a version that no longer
|
|
1497
|
+
// exists, so the write must fail closed instead of forcing.
|
|
1498
|
+
it("localDiverges under publish-local: promotes local under an If-Match fence on the journal etag", async () => {
|
|
1499
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1500
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1501
|
+
const testFile = path.join(companyRoot, "publish-divergent.md");
|
|
1502
|
+
const localBytes = "my kept divergent local, now elected authority";
|
|
1503
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1504
|
+
const localHash = hashFile(testFile);
|
|
1505
|
+
const REMOTE = "peer-version-etag";
|
|
1506
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1507
|
+
lastModified: new Date(),
|
|
1508
|
+
etag: `"${REMOTE}"`,
|
|
1509
|
+
size: 40,
|
|
1510
|
+
});
|
|
1511
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
1512
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1513
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1514
|
+
version: "1",
|
|
1515
|
+
lastSync: new Date().toISOString(),
|
|
1516
|
+
files: {
|
|
1517
|
+
"publish-divergent.md": {
|
|
1518
|
+
hash: localHash,
|
|
1519
|
+
size: localBytes.length,
|
|
1520
|
+
syncedAt: new Date().toISOString(),
|
|
1521
|
+
direction: "down",
|
|
1522
|
+
remoteEtag: REMOTE,
|
|
1523
|
+
localDiverges: true,
|
|
1524
|
+
},
|
|
1525
|
+
},
|
|
1526
|
+
}));
|
|
1527
|
+
const result = await share({
|
|
1528
|
+
paths: [testFile],
|
|
1529
|
+
company: "acme",
|
|
1530
|
+
vaultConfig: mockConfig,
|
|
1531
|
+
hqRoot: tmpDir,
|
|
1532
|
+
skipUnchanged: false,
|
|
1533
|
+
onConflict: "publish-local",
|
|
1534
|
+
});
|
|
1535
|
+
expect(result.filesUploaded).toBe(1);
|
|
1536
|
+
// Fenced on the etag the keep recorded — never unfenced, never live-HEAD.
|
|
1537
|
+
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
1538
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1539
|
+
// The entry is rewritten as an ordinary upload: local is now authority,
|
|
1540
|
+
// so the journal-honesty flag must be gone rather than re-armed.
|
|
1541
|
+
const journal = journalAt(journalPath);
|
|
1542
|
+
expect(journal.files["publish-divergent.md"].direction).toBe("up");
|
|
1543
|
+
expect(journal.files["publish-divergent.md"].localDiverges).toBeFalsy();
|
|
1544
|
+
expect(journal.files["publish-divergent.md"].remoteEtag).toBe("published-etag");
|
|
1545
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1546
|
+
});
|
|
1547
|
+
it("localDiverges under publish-local: a 412 fails closed and never retries unfenced", async () => {
|
|
1548
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1549
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1550
|
+
const testFile = path.join(companyRoot, "publish-raced.md");
|
|
1551
|
+
const localBytes = "my local, but the remote moved under me";
|
|
1552
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1553
|
+
const localHash = hashFile(testFile);
|
|
1554
|
+
const REMOTE = "etag-at-keep-time";
|
|
1555
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1556
|
+
lastModified: new Date(),
|
|
1557
|
+
etag: `"${REMOTE}"`,
|
|
1558
|
+
size: 40,
|
|
1559
|
+
});
|
|
1560
|
+
const precondition = Object.assign(new Error("At least one of the pre-conditions you specified did not hold"), {
|
|
1561
|
+
name: "PreconditionFailed",
|
|
1562
|
+
});
|
|
1563
|
+
vi.mocked(uploadFile).mockRejectedValueOnce(precondition);
|
|
1564
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1565
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1566
|
+
version: "1",
|
|
1567
|
+
lastSync: new Date().toISOString(),
|
|
1568
|
+
files: {
|
|
1569
|
+
"publish-raced.md": {
|
|
1570
|
+
hash: localHash,
|
|
1571
|
+
size: localBytes.length,
|
|
1572
|
+
syncedAt: new Date().toISOString(),
|
|
1573
|
+
direction: "down",
|
|
1574
|
+
remoteEtag: REMOTE,
|
|
1575
|
+
localDiverges: true,
|
|
1576
|
+
},
|
|
1577
|
+
},
|
|
1578
|
+
}));
|
|
1579
|
+
const result = await share({
|
|
1580
|
+
paths: [testFile],
|
|
1581
|
+
company: "acme",
|
|
1582
|
+
vaultConfig: mockConfig,
|
|
1583
|
+
hqRoot: tmpDir,
|
|
1584
|
+
skipUnchanged: false,
|
|
1585
|
+
onConflict: "publish-local",
|
|
1586
|
+
});
|
|
1587
|
+
// Exactly one attempt: the fenced one. The `overwrite` escape hatch of
|
|
1588
|
+
// re-PUTting with no precondition must NOT be reachable from here.
|
|
1589
|
+
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
1590
|
+
expect(result.filesUploaded).toBe(0);
|
|
1591
|
+
expect(result.conflictPaths).toContain("publish-raced.md");
|
|
1592
|
+
// Consent was for a remote that no longer exists, so the flag stays put
|
|
1593
|
+
// and the operator is asked again rather than silently overruled.
|
|
1594
|
+
const journal = journalAt(journalPath);
|
|
1595
|
+
expect(journal.files["publish-raced.md"].localDiverges).toBe(true);
|
|
1596
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1597
|
+
});
|
|
1598
|
+
// Codex review (PR #356, P1). The sync runner's push leg always calls
|
|
1599
|
+
// share() with `skipUnchanged: true`, and a pull-kept entry stores the
|
|
1600
|
+
// CURRENT local hash — that is exactly what the journal-honesty stamp
|
|
1601
|
+
// records. So `existing.hash === localHash` is true for every stuck file,
|
|
1602
|
+
// classifyPushCandidates() returns `skip-unchanged`, and executeUploads()
|
|
1603
|
+
// never runs: no HEAD, no conflict, no upload. Without this bypass the
|
|
1604
|
+
// whole recovery is a no-op in the one mode that can reach personal-vault
|
|
1605
|
+
// files, and the next pull leaves them stuck again. `hash === journal hash`
|
|
1606
|
+
// means "local is unchanged since the stamp", NOT "local matches remote" —
|
|
1607
|
+
// for a divergent entry those are different claims.
|
|
1608
|
+
it("localDiverges bypasses the skip-unchanged fast path so the recovery can run at all", async () => {
|
|
1609
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1610
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1611
|
+
const testFile = path.join(companyRoot, "stuck-under-skip-unchanged.md");
|
|
1612
|
+
const localBytes = "the body a pull-keep stamped and then refused to publish";
|
|
1613
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1614
|
+
const localHash = hashFile(testFile);
|
|
1615
|
+
const REMOTE = "etag-at-keep-time";
|
|
1616
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1617
|
+
lastModified: new Date(),
|
|
1618
|
+
etag: `"${REMOTE}"`,
|
|
1619
|
+
size: 40,
|
|
1620
|
+
});
|
|
1621
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
1622
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1623
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1624
|
+
version: "1",
|
|
1625
|
+
lastSync: new Date().toISOString(),
|
|
1626
|
+
files: {
|
|
1627
|
+
"stuck-under-skip-unchanged.md": {
|
|
1628
|
+
// Deliberately the CURRENT local hash: this is what a pull-keep
|
|
1629
|
+
// writes, and it is what makes the fast path swallow the file.
|
|
1630
|
+
hash: localHash,
|
|
1631
|
+
size: localBytes.length,
|
|
1632
|
+
syncedAt: new Date().toISOString(),
|
|
1633
|
+
direction: "down",
|
|
1634
|
+
remoteEtag: REMOTE,
|
|
1635
|
+
localDiverges: true,
|
|
1636
|
+
},
|
|
1637
|
+
},
|
|
1638
|
+
}));
|
|
1639
|
+
const result = await share({
|
|
1640
|
+
paths: [testFile],
|
|
1641
|
+
company: "acme",
|
|
1642
|
+
vaultConfig: mockConfig,
|
|
1643
|
+
hqRoot: tmpDir,
|
|
1644
|
+
skipUnchanged: true,
|
|
1645
|
+
onConflict: "publish-local",
|
|
1646
|
+
});
|
|
1647
|
+
expect(result.filesUploaded).toBe(1);
|
|
1648
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1649
|
+
const journal = journalAt(journalPath);
|
|
1650
|
+
expect(journal.files["stuck-under-skip-unchanged.md"].direction).toBe("up");
|
|
1651
|
+
expect(journal.files["stuck-under-skip-unchanged.md"].localDiverges).toBeFalsy();
|
|
1652
|
+
});
|
|
1653
|
+
// Codex review (PR #356, P2). A genuine two-sided conflict is not the
|
|
1654
|
+
// localDiverges recovery: the journal etag is stale BY DEFINITION here
|
|
1655
|
+
// (that is what remoteChanged means), so fencing consent on it guarantees a
|
|
1656
|
+
// 412 and the file can never publish. Consent given at this prompt is
|
|
1657
|
+
// consent against the remote the operator was just shown, so the fence must
|
|
1658
|
+
// be the freshly observed etag.
|
|
1659
|
+
it("publish-local on a fresh two-sided conflict fences on the OBSERVED remote etag, not the stale journal one", async () => {
|
|
1660
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1661
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1662
|
+
const testFile = path.join(companyRoot, "two-sided.md");
|
|
1663
|
+
fs.writeFileSync(testFile, "my newer local body");
|
|
1664
|
+
const STALE_JOURNAL_ETAG = "etag-from-last-sync";
|
|
1665
|
+
const OBSERVED_REMOTE_ETAG = "etag-the-peer-just-wrote";
|
|
1666
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1667
|
+
lastModified: new Date(),
|
|
1668
|
+
etag: `"${OBSERVED_REMOTE_ETAG}"`,
|
|
1669
|
+
size: 40,
|
|
1670
|
+
});
|
|
1671
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
1672
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1673
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1674
|
+
version: "1",
|
|
1675
|
+
lastSync: new Date().toISOString(),
|
|
1676
|
+
files: {
|
|
1677
|
+
"two-sided.md": {
|
|
1678
|
+
hash: "a-hash-that-is-neither-side",
|
|
1679
|
+
size: 10,
|
|
1680
|
+
syncedAt: new Date().toISOString(),
|
|
1681
|
+
direction: "up",
|
|
1682
|
+
remoteEtag: STALE_JOURNAL_ETAG,
|
|
1683
|
+
},
|
|
1684
|
+
},
|
|
1685
|
+
}));
|
|
1686
|
+
const result = await share({
|
|
1687
|
+
paths: [testFile],
|
|
1688
|
+
company: "acme",
|
|
1689
|
+
vaultConfig: mockConfig,
|
|
1690
|
+
hqRoot: tmpDir,
|
|
1691
|
+
skipUnchanged: false,
|
|
1692
|
+
onConflict: "publish-local",
|
|
1693
|
+
});
|
|
1694
|
+
expect(result.filesUploaded).toBe(1);
|
|
1695
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({
|
|
1696
|
+
ifMatch: OBSERVED_REMOTE_ETAG,
|
|
1697
|
+
});
|
|
1698
|
+
});
|
|
1699
|
+
// Codex review (PR #356, P2). HEAD returning null skips the whole conflict
|
|
1700
|
+
// block, so the precondition becomes If-None-Match:* and the object is
|
|
1701
|
+
// RECREATED. For a divergent entry that silently overrules a peer's
|
|
1702
|
+
// deletion — the remote is emphatically no longer at the recorded etag, so
|
|
1703
|
+
// the strategy's own fence says fail closed. `keep`'s recreate-once
|
|
1704
|
+
// behaviour for non-divergent entries is unaffected.
|
|
1705
|
+
it("publish-local does not resurrect a remote deleted after the keep", async () => {
|
|
1706
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1707
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1708
|
+
const testFile = path.join(companyRoot, "peer-deleted.md");
|
|
1709
|
+
const localBytes = "my divergent local body";
|
|
1710
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1711
|
+
const localHash = hashFile(testFile);
|
|
1712
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
1713
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1714
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1715
|
+
version: "1",
|
|
1716
|
+
lastSync: new Date().toISOString(),
|
|
1717
|
+
files: {
|
|
1718
|
+
"peer-deleted.md": {
|
|
1719
|
+
hash: localHash,
|
|
1720
|
+
size: localBytes.length,
|
|
1721
|
+
syncedAt: new Date().toISOString(),
|
|
1722
|
+
direction: "down",
|
|
1723
|
+
remoteEtag: "etag-before-the-peer-deleted-it",
|
|
1724
|
+
localDiverges: true,
|
|
1725
|
+
},
|
|
1726
|
+
},
|
|
1727
|
+
}));
|
|
1728
|
+
const result = await share({
|
|
1729
|
+
paths: [testFile],
|
|
1730
|
+
company: "acme",
|
|
1731
|
+
vaultConfig: mockConfig,
|
|
1732
|
+
hqRoot: tmpDir,
|
|
1733
|
+
skipUnchanged: false,
|
|
1734
|
+
onConflict: "publish-local",
|
|
1735
|
+
});
|
|
1736
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
1737
|
+
expect(result.filesUploaded).toBe(0);
|
|
1738
|
+
expect(result.conflictPaths).toContain("peer-deleted.md");
|
|
1739
|
+
const journal = journalAt(journalPath);
|
|
1740
|
+
expect(journal.files["peer-deleted.md"].localDiverges).toBe(true);
|
|
1741
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1742
|
+
});
|
|
1743
|
+
it("publish-local on a non-divergent file behaves like an ordinary fenced upload", async () => {
|
|
1744
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1745
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1746
|
+
const testFile = path.join(companyRoot, "ordinary.md");
|
|
1747
|
+
fs.writeFileSync(testFile, "locally edited since last sync");
|
|
1748
|
+
const REMOTE = "unchanged-remote-etag";
|
|
1749
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1750
|
+
lastModified: new Date(),
|
|
1751
|
+
etag: `"${REMOTE}"`,
|
|
1752
|
+
size: 10,
|
|
1753
|
+
});
|
|
1754
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"next-etag"' });
|
|
1755
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1756
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1757
|
+
version: "1",
|
|
1758
|
+
lastSync: new Date().toISOString(),
|
|
1759
|
+
files: {
|
|
1760
|
+
"ordinary.md": {
|
|
1761
|
+
hash: "stale-hash-from-a-previous-body",
|
|
1762
|
+
size: 10,
|
|
1763
|
+
syncedAt: new Date().toISOString(),
|
|
1764
|
+
direction: "up",
|
|
1765
|
+
remoteEtag: REMOTE,
|
|
1766
|
+
},
|
|
1767
|
+
},
|
|
1768
|
+
}));
|
|
1769
|
+
const result = await share({
|
|
1770
|
+
paths: [testFile],
|
|
1771
|
+
company: "acme",
|
|
1772
|
+
vaultConfig: mockConfig,
|
|
1773
|
+
hqRoot: tmpDir,
|
|
1774
|
+
skipUnchanged: false,
|
|
1775
|
+
onConflict: "publish-local",
|
|
1776
|
+
});
|
|
1777
|
+
// No localDiverges flag means no special casing: the normal push fence
|
|
1778
|
+
// applies and the file uploads without being routed through conflict.
|
|
1779
|
+
expect(result.filesUploaded).toBe(1);
|
|
1780
|
+
expect(result.conflictPaths ?? []).not.toContain("ordinary.md");
|
|
1781
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1782
|
+
});
|
|
1453
1783
|
// Journal still carries remoteEtag from a prior sync, but the remote key
|
|
1454
1784
|
// is gone (HEAD null) — e.g. peer delete or operator purge. Recreation
|
|
1455
1785
|
// must use If-None-Match:* (create-only), NOT If-Match on the stale
|
|
@@ -1467,7 +1797,7 @@ describe("share", () => {
|
|
|
1467
1797
|
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
1468
1798
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"recreated-etag"' });
|
|
1469
1799
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1470
|
-
|
|
1800
|
+
seedJournalAt(journalPath, ({
|
|
1471
1801
|
version: "1",
|
|
1472
1802
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1473
1803
|
files: {
|
|
@@ -1497,7 +1827,7 @@ describe("share", () => {
|
|
|
1497
1827
|
const pc = vi.mocked(uploadFile).mock.calls[0][4];
|
|
1498
1828
|
expect(pc?.ifMatch).toBeUndefined();
|
|
1499
1829
|
expect(pc?.ifNoneMatch).toBe("*");
|
|
1500
|
-
const journal =
|
|
1830
|
+
const journal = journalAt(journalPath);
|
|
1501
1831
|
expect(journal.files["recreate-deleted.md"]).toMatchObject({
|
|
1502
1832
|
hash: localHash,
|
|
1503
1833
|
remoteEtag: "recreated-etag",
|
|
@@ -1518,7 +1848,7 @@ describe("share", () => {
|
|
|
1518
1848
|
return undefined;
|
|
1519
1849
|
});
|
|
1520
1850
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1521
|
-
|
|
1851
|
+
seedJournalAt(journalPath, ({
|
|
1522
1852
|
version: "1",
|
|
1523
1853
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1524
1854
|
files: {
|
|
@@ -1550,7 +1880,7 @@ describe("share", () => {
|
|
|
1550
1880
|
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifNoneMatch: "*" });
|
|
1551
1881
|
// Journal not advanced to a successful upload etag (entry may still be
|
|
1552
1882
|
// the prior baseline — next pass re-evaluates).
|
|
1553
|
-
const journal =
|
|
1883
|
+
const journal = journalAt(journalPath);
|
|
1554
1884
|
expect(journal.files["race-recreate.md"]?.remoteEtag).toBe("etag-before-delete");
|
|
1555
1885
|
expect(journal.files["race-recreate.md"]?.hash).toBe("old-hash");
|
|
1556
1886
|
expect(fs.readFileSync(testFile, "utf-8")).toBe("my recreated local");
|
|
@@ -1874,7 +2204,7 @@ describe("share", () => {
|
|
|
1874
2204
|
size: 5,
|
|
1875
2205
|
});
|
|
1876
2206
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1877
|
-
|
|
2207
|
+
seedJournalAt(journalPath, ({
|
|
1878
2208
|
version: "1",
|
|
1879
2209
|
lastSync: syncedAt,
|
|
1880
2210
|
files: {
|
|
@@ -1914,7 +2244,7 @@ describe("share", () => {
|
|
|
1914
2244
|
size: 5,
|
|
1915
2245
|
});
|
|
1916
2246
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1917
|
-
|
|
2247
|
+
seedJournalAt(journalPath, ({
|
|
1918
2248
|
version: "1",
|
|
1919
2249
|
lastSync: syncedAt,
|
|
1920
2250
|
files: {
|
|
@@ -1950,7 +2280,7 @@ describe("share", () => {
|
|
|
1950
2280
|
hqRoot: tmpDir,
|
|
1951
2281
|
});
|
|
1952
2282
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1953
|
-
const journal =
|
|
2283
|
+
const journal = journalAt(journalPath);
|
|
1954
2284
|
expect(journal.files["fresh.md"].remoteEtag).toBe("new-upload-etag");
|
|
1955
2285
|
});
|
|
1956
2286
|
it("forwards UploadAuthor to uploadFile when present (created-by metadata)", async () => {
|
|
@@ -1995,7 +2325,7 @@ describe("share", () => {
|
|
|
1995
2325
|
const { hashFile } = await import("../journal.js");
|
|
1996
2326
|
const hash = hashFile(testFile);
|
|
1997
2327
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1998
|
-
|
|
2328
|
+
seedJournalAt(journalPath, ({
|
|
1999
2329
|
version: "1",
|
|
2000
2330
|
lastSync: new Date().toISOString(),
|
|
2001
2331
|
files: {
|
|
@@ -2215,7 +2545,7 @@ describe("share", () => {
|
|
|
2215
2545
|
.update("stable content")
|
|
2216
2546
|
.digest("hex");
|
|
2217
2547
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2218
|
-
|
|
2548
|
+
seedJournalAt(journalPath, ({
|
|
2219
2549
|
version: "1",
|
|
2220
2550
|
lastSync: new Date().toISOString(),
|
|
2221
2551
|
files: {
|
|
@@ -2276,7 +2606,7 @@ describe("share", () => {
|
|
|
2276
2606
|
// not be queued for delete.
|
|
2277
2607
|
fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
|
|
2278
2608
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2279
|
-
|
|
2609
|
+
seedJournalAt(journalPath, ({
|
|
2280
2610
|
version: "1",
|
|
2281
2611
|
lastSync: new Date().toISOString(),
|
|
2282
2612
|
files: {
|
|
@@ -2313,7 +2643,7 @@ describe("share", () => {
|
|
|
2313
2643
|
operation: "delete",
|
|
2314
2644
|
});
|
|
2315
2645
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
|
|
2316
|
-
const journal =
|
|
2646
|
+
const journal = journalAt(journalPath);
|
|
2317
2647
|
expect(journal.files["knowledge"]).toBeUndefined();
|
|
2318
2648
|
});
|
|
2319
2649
|
it("propagateDeletes: a dangling local symlink is NOT classified as gone (lstat, not existsSync)", async () => {
|
|
@@ -2330,7 +2660,7 @@ describe("share", () => {
|
|
|
2330
2660
|
const danglingLink = path.join(companyRoot, "dangling-link.md");
|
|
2331
2661
|
fs.symlinkSync("./missing-target.md", danglingLink);
|
|
2332
2662
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2333
|
-
|
|
2663
|
+
seedJournalAt(journalPath, ({
|
|
2334
2664
|
version: "1",
|
|
2335
2665
|
lastSync: new Date().toISOString(),
|
|
2336
2666
|
files: {
|
|
@@ -2360,7 +2690,7 @@ describe("share", () => {
|
|
|
2360
2690
|
expect(result.filesDeleted).toBe(0);
|
|
2361
2691
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2362
2692
|
// Journal entry must survive the run.
|
|
2363
|
-
const journal =
|
|
2693
|
+
const journal = journalAt(journalPath);
|
|
2364
2694
|
expect(journal.files["dangling-link.md"]).toBeDefined();
|
|
2365
2695
|
});
|
|
2366
2696
|
it("vault-litter drain: a `.drift-*` marker with direction:'down' and NO delete intent tombstones " +
|
|
@@ -2425,7 +2755,7 @@ describe("share", () => {
|
|
|
2425
2755
|
});
|
|
2426
2756
|
expect(result.filesDeleted).toBe(1);
|
|
2427
2757
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "personal/scripts/run-project.sh.drift-1779863862-42519");
|
|
2428
|
-
const journal =
|
|
2758
|
+
const journal = journalAt(journalPath);
|
|
2429
2759
|
expect(journal.files["personal/scripts/run-project.sh.drift-1779863862-42519"]).toBeUndefined();
|
|
2430
2760
|
}
|
|
2431
2761
|
finally {
|
|
@@ -2498,7 +2828,7 @@ describe("share", () => {
|
|
|
2498
2828
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2499
2829
|
// No file on disk under companies/acme/peer-uploaded.md → local missing.
|
|
2500
2830
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2501
|
-
|
|
2831
|
+
seedJournalAt(journalPath, ({
|
|
2502
2832
|
version: "1",
|
|
2503
2833
|
lastSync: new Date().toISOString(),
|
|
2504
2834
|
files: {
|
|
@@ -2523,7 +2853,7 @@ describe("share", () => {
|
|
|
2523
2853
|
});
|
|
2524
2854
|
expect(result.filesDeleted).toBe(0);
|
|
2525
2855
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2526
|
-
const journal =
|
|
2856
|
+
const journal = journalAt(journalPath);
|
|
2527
2857
|
expect(journal.files["peer-uploaded.md"]).toBeDefined();
|
|
2528
2858
|
});
|
|
2529
2859
|
it("ROLLBACK KNOB: owned-only is honored on a prs_ personal vault — an intent-less entry with a " +
|
|
@@ -2614,7 +2944,7 @@ describe("share", () => {
|
|
|
2614
2944
|
]);
|
|
2615
2945
|
// Journal entry survives, so a later run (or a watcher-minted intent)
|
|
2616
2946
|
// can still authorize the delete properly.
|
|
2617
|
-
const journal =
|
|
2947
|
+
const journal = journalAt(journalPath);
|
|
2618
2948
|
expect(journal.files["personal/knowledge/gone.md"]).toBeDefined();
|
|
2619
2949
|
}
|
|
2620
2950
|
finally {
|
|
@@ -2631,7 +2961,7 @@ describe("share", () => {
|
|
|
2631
2961
|
// deleted by the user.
|
|
2632
2962
|
fs.writeFileSync(path.join(companyRoot, "kept.md"), "still here");
|
|
2633
2963
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2634
|
-
|
|
2964
|
+
seedJournalAt(journalPath, ({
|
|
2635
2965
|
version: "1",
|
|
2636
2966
|
lastSync: new Date().toISOString(),
|
|
2637
2967
|
files: {
|
|
@@ -2667,7 +2997,7 @@ describe("share", () => {
|
|
|
2667
2997
|
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
2668
2998
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "gone.md");
|
|
2669
2999
|
// Journal entry for the gone file is removed; the kept entry stays.
|
|
2670
|
-
const journal =
|
|
3000
|
+
const journal = journalAt(journalPath);
|
|
2671
3001
|
expect(journal.files["gone.md"]).toBeUndefined();
|
|
2672
3002
|
expect(journal.files["kept.md"]).toBeDefined();
|
|
2673
3003
|
});
|
|
@@ -2675,7 +3005,7 @@ describe("share", () => {
|
|
|
2675
3005
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2676
3006
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2677
3007
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2678
|
-
|
|
3008
|
+
seedJournalAt(journalPath, ({
|
|
2679
3009
|
version: "1",
|
|
2680
3010
|
lastSync: new Date().toISOString(),
|
|
2681
3011
|
files: {
|
|
@@ -2717,7 +3047,7 @@ describe("share", () => {
|
|
|
2717
3047
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2718
3048
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2719
3049
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2720
|
-
|
|
3050
|
+
seedJournalAt(journalPath, ({
|
|
2721
3051
|
version: "1",
|
|
2722
3052
|
lastSync: new Date().toISOString(),
|
|
2723
3053
|
files: {
|
|
@@ -2740,7 +3070,7 @@ describe("share", () => {
|
|
|
2740
3070
|
expect(result.filesDeleted).toBe(0);
|
|
2741
3071
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2742
3072
|
// Journal entry survives so the next opt-in run can still propagate.
|
|
2743
|
-
const journal =
|
|
3073
|
+
const journal = journalAt(journalPath);
|
|
2744
3074
|
expect(journal.files["gone.md"]).toBeDefined();
|
|
2745
3075
|
});
|
|
2746
3076
|
it("propagateDeletes: scope is constrained to the supplied paths — sibling deletes are not swept", async () => {
|
|
@@ -2748,7 +3078,7 @@ describe("share", () => {
|
|
|
2748
3078
|
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
2749
3079
|
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
2750
3080
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2751
|
-
|
|
3081
|
+
seedJournalAt(journalPath, ({
|
|
2752
3082
|
version: "1",
|
|
2753
3083
|
lastSync: new Date().toISOString(),
|
|
2754
3084
|
files: {
|
|
@@ -2783,7 +3113,7 @@ describe("share", () => {
|
|
|
2783
3113
|
expect(result.filesDeleted).toBe(1);
|
|
2784
3114
|
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
2785
3115
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
|
|
2786
|
-
const journal =
|
|
3116
|
+
const journal = journalAt(journalPath);
|
|
2787
3117
|
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
2788
3118
|
// Sibling tree's journal entry is untouched — `hq share <subtree>` must
|
|
2789
3119
|
// not act on files outside the named scope.
|
|
@@ -2799,7 +3129,7 @@ describe("share", () => {
|
|
|
2799
3129
|
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
2800
3130
|
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
2801
3131
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2802
|
-
|
|
3132
|
+
seedJournalAt(journalPath, ({
|
|
2803
3133
|
version: "1",
|
|
2804
3134
|
lastSync: new Date().toISOString(),
|
|
2805
3135
|
files: {
|
|
@@ -2834,7 +3164,7 @@ describe("share", () => {
|
|
|
2834
3164
|
});
|
|
2835
3165
|
expect(result.filesDeleted).toBe(1);
|
|
2836
3166
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
|
|
2837
|
-
const journal =
|
|
3167
|
+
const journal = journalAt(journalPath);
|
|
2838
3168
|
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
2839
3169
|
// Scope stays exactly as narrow as the absolute spelling's — no wider.
|
|
2840
3170
|
expect(journal.files["other/also-gone.md"]).toBeDefined();
|
|
@@ -2851,7 +3181,7 @@ describe("share", () => {
|
|
|
2851
3181
|
fs.mkdirSync(acmeRepo, { recursive: true });
|
|
2852
3182
|
fs.symlinkSync(acmeRepo, path.join(companyRoot, "knowledge"));
|
|
2853
3183
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2854
|
-
|
|
3184
|
+
seedJournalAt(journalPath, ({
|
|
2855
3185
|
version: "1",
|
|
2856
3186
|
lastSync: new Date().toISOString(),
|
|
2857
3187
|
files: {
|
|
@@ -2881,14 +3211,14 @@ describe("share", () => {
|
|
|
2881
3211
|
});
|
|
2882
3212
|
expect(result.filesDeleted).toBe(0);
|
|
2883
3213
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2884
|
-
const journal =
|
|
3214
|
+
const journal = journalAt(journalPath);
|
|
2885
3215
|
expect(journal.files["knowledge/agents/brief.md"]).toBeDefined();
|
|
2886
3216
|
});
|
|
2887
3217
|
it("propagateDeletes: a failed DeleteObject leaves the journal entry intact for retry", async () => {
|
|
2888
3218
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2889
3219
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2890
3220
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2891
|
-
|
|
3221
|
+
seedJournalAt(journalPath, ({
|
|
2892
3222
|
version: "1",
|
|
2893
3223
|
lastSync: new Date().toISOString(),
|
|
2894
3224
|
files: {
|
|
@@ -2921,7 +3251,7 @@ describe("share", () => {
|
|
|
2921
3251
|
const errorEvent = events.find((e) => e.type === "error");
|
|
2922
3252
|
expect(errorEvent).toMatchObject({ path: "flaky.md", message: expect.stringContaining("S3 down") });
|
|
2923
3253
|
// Entry survives — next run will retry the delete.
|
|
2924
|
-
const journal =
|
|
3254
|
+
const journal = journalAt(journalPath);
|
|
2925
3255
|
expect(journal.files["flaky.md"]).toBeDefined();
|
|
2926
3256
|
});
|
|
2927
3257
|
// ── Delete propagation safety: direction + filter guards (Bug B + C) ──────
|
|
@@ -2955,7 +3285,7 @@ describe("share", () => {
|
|
|
2955
3285
|
// and one this machine uploaded (direction:'up'). Only the 'up' one should
|
|
2956
3286
|
// be eligible for delete-propagation.
|
|
2957
3287
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2958
|
-
|
|
3288
|
+
seedJournalAt(journalPath, ({
|
|
2959
3289
|
version: "1",
|
|
2960
3290
|
lastSync: new Date().toISOString(),
|
|
2961
3291
|
files: {
|
|
@@ -2992,7 +3322,7 @@ describe("share", () => {
|
|
|
2992
3322
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2993
3323
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2994
3324
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2995
|
-
|
|
3325
|
+
seedJournalAt(journalPath, ({
|
|
2996
3326
|
version: "1",
|
|
2997
3327
|
lastSync: new Date().toISOString(),
|
|
2998
3328
|
files: {
|
|
@@ -3025,7 +3355,7 @@ describe("share", () => {
|
|
|
3025
3355
|
// absence is a filter outcome, not a user-intended delete.
|
|
3026
3356
|
fs.writeFileSync(path.join(tmpDir, ".hqignore"), "companies/acme/legacy/**\n");
|
|
3027
3357
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3028
|
-
|
|
3358
|
+
seedJournalAt(journalPath, ({
|
|
3029
3359
|
version: "1",
|
|
3030
3360
|
lastSync: new Date().toISOString(),
|
|
3031
3361
|
files: {
|
|
@@ -3084,7 +3414,7 @@ describe("share", () => {
|
|
|
3084
3414
|
// Under owned-only this would be stuck on remote forever; currency-gated
|
|
3085
3415
|
// checks the ETag and propagates the delete cleanly when match holds.
|
|
3086
3416
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3087
|
-
|
|
3417
|
+
seedJournalAt(journalPath, ({
|
|
3088
3418
|
version: "1",
|
|
3089
3419
|
lastSync: new Date().toISOString(),
|
|
3090
3420
|
files: {
|
|
@@ -3114,7 +3444,7 @@ describe("share", () => {
|
|
|
3114
3444
|
});
|
|
3115
3445
|
expect(result.filesDeleted).toBe(1);
|
|
3116
3446
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "core/policies/old.md");
|
|
3117
|
-
const journal =
|
|
3447
|
+
const journal = journalAt(journalPath);
|
|
3118
3448
|
expect(journal.files["core/policies/old.md"]).toBeUndefined();
|
|
3119
3449
|
});
|
|
3120
3450
|
it("currency-gated: propagates the S3 delete for a local-delete tombstone (pull-leg producer hand-off)", async () => {
|
|
@@ -3125,7 +3455,7 @@ describe("share", () => {
|
|
|
3125
3455
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3126
3456
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3127
3457
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3128
|
-
|
|
3458
|
+
seedJournalAt(journalPath, ({
|
|
3129
3459
|
version: "2",
|
|
3130
3460
|
lastSync: new Date().toISOString(),
|
|
3131
3461
|
files: {
|
|
@@ -3161,7 +3491,7 @@ describe("share", () => {
|
|
|
3161
3491
|
});
|
|
3162
3492
|
expect(result.filesDeleted).toBe(1);
|
|
3163
3493
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "docs/deleted.md");
|
|
3164
|
-
const journal =
|
|
3494
|
+
const journal = journalAt(journalPath);
|
|
3165
3495
|
expect(journal.files["docs/deleted.md"]).toBeUndefined();
|
|
3166
3496
|
});
|
|
3167
3497
|
it("currency-gated: refuses delete + emits stale-etag event when remote moved since last sync", async () => {
|
|
@@ -3170,7 +3500,7 @@ describe("share", () => {
|
|
|
3170
3500
|
// Another device modified `shared.md` after this device's last sync.
|
|
3171
3501
|
// The journal still records the old etag; HEAD returns the new one.
|
|
3172
3502
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3173
|
-
|
|
3503
|
+
seedJournalAt(journalPath, ({
|
|
3174
3504
|
version: "1",
|
|
3175
3505
|
lastSync: new Date().toISOString(),
|
|
3176
3506
|
files: {
|
|
@@ -3202,7 +3532,7 @@ describe("share", () => {
|
|
|
3202
3532
|
// No remote DELETE issued, journal entry preserved (pull will re-pull).
|
|
3203
3533
|
expect(result.filesDeleted).toBe(0);
|
|
3204
3534
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3205
|
-
const journal =
|
|
3535
|
+
const journal = journalAt(journalPath);
|
|
3206
3536
|
expect(journal.files["shared.md"]).toBeDefined();
|
|
3207
3537
|
// Dedicated event fired so UIs surface the refusal. `reason` field
|
|
3208
3538
|
// discriminates "stale-etag" (real drift) from "legacy-no-etag" so
|
|
@@ -3224,7 +3554,7 @@ describe("share", () => {
|
|
|
3224
3554
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3225
3555
|
const recreatedPath = path.join(companyRoot, "racy.md");
|
|
3226
3556
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3227
|
-
|
|
3557
|
+
seedJournalAt(journalPath, ({
|
|
3228
3558
|
version: "2",
|
|
3229
3559
|
lastSync: new Date().toISOString(),
|
|
3230
3560
|
files: {
|
|
@@ -3260,7 +3590,7 @@ describe("share", () => {
|
|
|
3260
3590
|
expect(result.filesDeleted).toBe(0);
|
|
3261
3591
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3262
3592
|
expect(fs.readFileSync(recreatedPath, "utf-8")).toBe("recreated after planning");
|
|
3263
|
-
const journal =
|
|
3593
|
+
const journal = journalAt(journalPath);
|
|
3264
3594
|
expect(journal.files["racy.md"]).toBeDefined();
|
|
3265
3595
|
expect(events).toContainEqual(expect.objectContaining({
|
|
3266
3596
|
type: "delete-refused-stale-etag",
|
|
@@ -3282,7 +3612,7 @@ describe("share", () => {
|
|
|
3282
3612
|
// genuine local==remote sync, so propagating the delete would destroy the
|
|
3283
3613
|
// (different) remote version. Must be REFUSED, not propagated.
|
|
3284
3614
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3285
|
-
|
|
3615
|
+
seedJournalAt(journalPath, ({
|
|
3286
3616
|
version: "2",
|
|
3287
3617
|
lastSync: new Date().toISOString(),
|
|
3288
3618
|
files: {
|
|
@@ -3317,7 +3647,7 @@ describe("share", () => {
|
|
|
3317
3647
|
// Refusal short-circuits before the currency HEAD — no network needed.
|
|
3318
3648
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
3319
3649
|
// Journal entry preserved, not silently dropped.
|
|
3320
|
-
const journal =
|
|
3650
|
+
const journal = journalAt(journalPath);
|
|
3321
3651
|
expect(journal.files["core/policies/kept.md"]).toBeDefined();
|
|
3322
3652
|
// Surfaced as a refusal with the divergent-local reason.
|
|
3323
3653
|
const refused = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
@@ -3334,7 +3664,7 @@ describe("share", () => {
|
|
|
3334
3664
|
// S3 console). Local copy also missing. Currency-gated should drop the
|
|
3335
3665
|
// journal entry without issuing a DELETE — the remote is already gone.
|
|
3336
3666
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3337
|
-
|
|
3667
|
+
seedJournalAt(journalPath, ({
|
|
3338
3668
|
version: "1",
|
|
3339
3669
|
lastSync: new Date().toISOString(),
|
|
3340
3670
|
files: {
|
|
@@ -3367,7 +3697,7 @@ describe("share", () => {
|
|
|
3367
3697
|
// from real deletes without re-counting events.
|
|
3368
3698
|
expect(result.filesTombstoned).toBe(1);
|
|
3369
3699
|
expect(result.filesRefusedStale).toBe(0);
|
|
3370
|
-
const journal =
|
|
3700
|
+
const journal = journalAt(journalPath);
|
|
3371
3701
|
expect(journal.files["removed-out-of-band.md"]).toBeUndefined();
|
|
3372
3702
|
// Synthetic progress event carries the tombstone marker. Without this,
|
|
3373
3703
|
// the tty stream renders tombstones identically to real deletes — operator
|
|
@@ -3388,7 +3718,7 @@ describe("share", () => {
|
|
|
3388
3718
|
// against. Refuse the delete in the safe direction; a future sync with
|
|
3389
3719
|
// a recorded etag can re-evaluate.
|
|
3390
3720
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3391
|
-
|
|
3721
|
+
seedJournalAt(journalPath, ({
|
|
3392
3722
|
version: "1",
|
|
3393
3723
|
lastSync: new Date().toISOString(),
|
|
3394
3724
|
files: {
|
|
@@ -3442,7 +3772,7 @@ describe("share", () => {
|
|
|
3442
3772
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3443
3773
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3444
3774
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3445
|
-
|
|
3775
|
+
seedJournalAt(journalPath, ({
|
|
3446
3776
|
version: "1",
|
|
3447
3777
|
lastSync: new Date().toISOString(),
|
|
3448
3778
|
files: {
|
|
@@ -3494,7 +3824,7 @@ describe("share", () => {
|
|
|
3494
3824
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3495
3825
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3496
3826
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3497
|
-
|
|
3827
|
+
seedJournalAt(journalPath, ({
|
|
3498
3828
|
version: "1",
|
|
3499
3829
|
lastSync: new Date().toISOString(),
|
|
3500
3830
|
files: {
|
|
@@ -3528,7 +3858,7 @@ describe("share", () => {
|
|
|
3528
3858
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3529
3859
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3530
3860
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3531
|
-
|
|
3861
|
+
seedJournalAt(journalPath, ({
|
|
3532
3862
|
version: "1",
|
|
3533
3863
|
lastSync: new Date().toISOString(),
|
|
3534
3864
|
files: {
|
|
@@ -3578,7 +3908,7 @@ describe("share", () => {
|
|
|
3578
3908
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3579
3909
|
const recreatedPath = path.join(companyRoot, "notes", "gone.md");
|
|
3580
3910
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3581
|
-
|
|
3911
|
+
seedJournalAt(journalPath, ({
|
|
3582
3912
|
version: "1",
|
|
3583
3913
|
lastSync: new Date().toISOString(),
|
|
3584
3914
|
files: {
|
|
@@ -3626,7 +3956,7 @@ describe("share", () => {
|
|
|
3626
3956
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3627
3957
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3628
3958
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3629
|
-
|
|
3959
|
+
seedJournalAt(journalPath, ({
|
|
3630
3960
|
version: "1",
|
|
3631
3961
|
lastSync: new Date().toISOString(),
|
|
3632
3962
|
files: {
|
|
@@ -3656,14 +3986,14 @@ describe("share", () => {
|
|
|
3656
3986
|
type: "delete-refused-stale-etag",
|
|
3657
3987
|
reason: "legacy-no-etag",
|
|
3658
3988
|
}));
|
|
3659
|
-
const journal =
|
|
3989
|
+
const journal = journalAt(journalPath);
|
|
3660
3990
|
expect(journal.files["notes/legacy.md"]).toBeDefined();
|
|
3661
3991
|
});
|
|
3662
3992
|
it("owned-only still requires an intent — it is the documented rollback knob", async () => {
|
|
3663
3993
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3664
3994
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3665
3995
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3666
|
-
|
|
3996
|
+
seedJournalAt(journalPath, ({
|
|
3667
3997
|
version: "1",
|
|
3668
3998
|
lastSync: new Date().toISOString(),
|
|
3669
3999
|
files: {
|
|
@@ -3850,7 +4180,7 @@ describe("share", () => {
|
|
|
3850
4180
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3851
4181
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3852
4182
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3853
|
-
|
|
4183
|
+
seedJournalAt(journalPath, ({
|
|
3854
4184
|
version: "1",
|
|
3855
4185
|
lastSync: new Date().toISOString(),
|
|
3856
4186
|
files: {
|
|
@@ -3891,7 +4221,7 @@ describe("share", () => {
|
|
|
3891
4221
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "regular.md");
|
|
3892
4222
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md");
|
|
3893
4223
|
// Both journal entries are removed by the delete loop's removeEntry call.
|
|
3894
|
-
const journal =
|
|
4224
|
+
const journal = journalAt(journalPath);
|
|
3895
4225
|
expect(journal.files["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md"]).toBeUndefined();
|
|
3896
4226
|
expect(journal.files["regular.md"]).toBeUndefined();
|
|
3897
4227
|
});
|
|
@@ -3906,7 +4236,7 @@ describe("share", () => {
|
|
|
3906
4236
|
process.env.HQ_STATE_DIR = stateDirPersonal;
|
|
3907
4237
|
try {
|
|
3908
4238
|
const journalPath = path.join(stateDirPersonal, "sync-journal.__hq_personal_vault__.json");
|
|
3909
|
-
|
|
4239
|
+
seedJournalAt(journalPath, ({
|
|
3910
4240
|
version: "1",
|
|
3911
4241
|
lastSync: new Date().toISOString(),
|
|
3912
4242
|
files: {
|
|
@@ -3932,7 +4262,7 @@ describe("share", () => {
|
|
|
3932
4262
|
});
|
|
3933
4263
|
expect(result.filesDeleted).toBe(1);
|
|
3934
4264
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "personal/.obsidian/graph.json.drift-1779863862-42519");
|
|
3935
|
-
const journal =
|
|
4265
|
+
const journal = journalAt(journalPath);
|
|
3936
4266
|
expect(journal.files["personal/.obsidian/graph.json.drift-1779863862-42519"]).toBeUndefined();
|
|
3937
4267
|
}
|
|
3938
4268
|
finally {
|
|
@@ -3955,7 +4285,7 @@ describe("share", () => {
|
|
|
3955
4285
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
3956
4286
|
direction: "up",
|
|
3957
4287
|
});
|
|
3958
|
-
|
|
4288
|
+
seedJournalAt(journalPath, ({
|
|
3959
4289
|
version: "1",
|
|
3960
4290
|
lastSync: new Date().toISOString(),
|
|
3961
4291
|
files: {
|
|
@@ -4003,7 +4333,7 @@ describe("share", () => {
|
|
|
4003
4333
|
expect(pruned[0].byReason["ignore-filtered"]).toBe(3);
|
|
4004
4334
|
expect(pruned[0].byReason["excluded-top-level"]).toBe(1);
|
|
4005
4335
|
// The persisted journal converged: excluded rows gone, legit row kept.
|
|
4006
|
-
const journal =
|
|
4336
|
+
const journal = journalAt(journalPath);
|
|
4007
4337
|
expect(Object.keys(journal.files)).toContain("personal/keep.md");
|
|
4008
4338
|
expect(journal.files[".claude/worktrees/wt/a.ts"]).toBeUndefined();
|
|
4009
4339
|
expect(journal.files["personal/node_modules/pkg/index.js"]).toBeUndefined();
|
|
@@ -4035,7 +4365,7 @@ describe("share", () => {
|
|
|
4035
4365
|
const key = "deleted-top-level.md";
|
|
4036
4366
|
const journalSlug = "personal-scoped-delete";
|
|
4037
4367
|
const journalPath = path.join(stateDir, `sync-journal.${journalSlug}.json`);
|
|
4038
|
-
|
|
4368
|
+
seedJournalAt(journalPath, ({
|
|
4039
4369
|
version: "2",
|
|
4040
4370
|
lastSync: new Date().toISOString(),
|
|
4041
4371
|
pulls: [],
|
|
@@ -4067,7 +4397,7 @@ describe("share", () => {
|
|
|
4067
4397
|
});
|
|
4068
4398
|
expect(result.filesDeleted).toBe(1);
|
|
4069
4399
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), key);
|
|
4070
|
-
expect(
|
|
4400
|
+
expect(journalAt(journalPath).files[key]).toBeUndefined();
|
|
4071
4401
|
});
|
|
4072
4402
|
// ── Decommission prefixes ──────────────────────────────────────────────────
|
|
4073
4403
|
//
|
|
@@ -4122,7 +4452,7 @@ describe("share", () => {
|
|
|
4122
4452
|
// Local file remains on disk — decommission is a remote-only sweep.
|
|
4123
4453
|
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "notes.md"))).toBe(true);
|
|
4124
4454
|
// Journal entry pruned in lockstep with the remote delete.
|
|
4125
|
-
const journalAfter =
|
|
4455
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4126
4456
|
expect(journalAfter.files["companies/foo/notes.md"]).toBeUndefined();
|
|
4127
4457
|
expect(journalAfter.files["knowledge/x.md"]).toBeDefined();
|
|
4128
4458
|
});
|
|
@@ -4158,7 +4488,7 @@ describe("share", () => {
|
|
|
4158
4488
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
4159
4489
|
// Journal entry preserved — the pulled-from-peer record stays so a
|
|
4160
4490
|
// future pull can still reconcile it correctly.
|
|
4161
|
-
const journalAfter =
|
|
4491
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4162
4492
|
expect(journalAfter.files["companies/foo/from-peer.md"]).toBeDefined();
|
|
4163
4493
|
});
|
|
4164
4494
|
it("decommissionPrefixes: dedupes with propagateDeletes plan — a key in both still emits one DeleteObject", async () => {
|
|
@@ -4278,7 +4608,7 @@ describe("share", () => {
|
|
|
4278
4608
|
expect(result.filesTombstoned).toBe(1);
|
|
4279
4609
|
expect(result.filesDeleted).toBe(0);
|
|
4280
4610
|
// Journal entry is gone.
|
|
4281
|
-
const journalAfter =
|
|
4611
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4282
4612
|
expect(journalAfter.files["companies/foo/gone.md"]).toBeUndefined();
|
|
4283
4613
|
});
|
|
4284
4614
|
describe("personalMode", () => {
|
|
@@ -4319,7 +4649,7 @@ describe("share", () => {
|
|
|
4319
4649
|
const acmeJournalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4320
4650
|
expect(fs.existsSync(personalJournalPath)).toBe(true);
|
|
4321
4651
|
expect(fs.existsSync(acmeJournalPath)).toBe(false);
|
|
4322
|
-
const journal =
|
|
4652
|
+
const journal = journalAt(personalJournalPath);
|
|
4323
4653
|
expect(journal.files["knowledge/notes.md"]).toBeDefined();
|
|
4324
4654
|
});
|
|
4325
4655
|
it("personalMode=true accepts files outside companies/<slug>/ (companion to the company-folder rejection)", async () => {
|
|
@@ -4607,7 +4937,7 @@ describe("share", () => {
|
|
|
4607
4937
|
.createHash("sha256")
|
|
4608
4938
|
.update(target)
|
|
4609
4939
|
.digest("hex");
|
|
4610
|
-
|
|
4940
|
+
seedJournalAt(journalPath, ({
|
|
4611
4941
|
version: "1",
|
|
4612
4942
|
lastSync: new Date().toISOString(),
|
|
4613
4943
|
files: {
|
|
@@ -5010,7 +5340,7 @@ describe("share", () => {
|
|
|
5010
5340
|
});
|
|
5011
5341
|
expect(pushed.filesUploaded).toBe(1);
|
|
5012
5342
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5013
|
-
const afterPush =
|
|
5343
|
+
const afterPush = journalAt(journalPath);
|
|
5014
5344
|
expect(Object.keys(afterPush.files)).toContain("knowledge/agents/brief.md");
|
|
5015
5345
|
vi.mocked(deleteRemoteFile).mockClear();
|
|
5016
5346
|
const walked = await share({
|
|
@@ -5024,7 +5354,7 @@ describe("share", () => {
|
|
|
5024
5354
|
expect(walked.filesDeleted).toBe(0);
|
|
5025
5355
|
const deletedKeys = vi.mocked(deleteRemoteFile).mock.calls.map((c) => c[1]);
|
|
5026
5356
|
expect(deletedKeys).not.toContain("knowledge/agents/brief.md");
|
|
5027
|
-
const afterWalk =
|
|
5357
|
+
const afterWalk = journalAt(journalPath);
|
|
5028
5358
|
expect(Object.keys(afterWalk.files)).toContain("knowledge/agents/brief.md");
|
|
5029
5359
|
});
|
|
5030
5360
|
});
|
|
@@ -5068,7 +5398,7 @@ describe("share", () => {
|
|
|
5068
5398
|
}
|
|
5069
5399
|
}
|
|
5070
5400
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5071
|
-
|
|
5401
|
+
seedJournalAt(journalPath, ({
|
|
5072
5402
|
version: "1",
|
|
5073
5403
|
lastSync: new Date().toISOString(),
|
|
5074
5404
|
files,
|
|
@@ -5126,7 +5456,7 @@ describe("share", () => {
|
|
|
5126
5456
|
expect(summary.samplePaths.length).toBeGreaterThan(0);
|
|
5127
5457
|
expect(summary.samplePaths.length).toBeLessThanOrEqual(10);
|
|
5128
5458
|
// Journal entries for the missing files SURVIVE — guard never mutates.
|
|
5129
|
-
const journal =
|
|
5459
|
+
const journal = journalAt(journalPath);
|
|
5130
5460
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5131
5461
|
// Per-key delete-refused-stale-etag events for each candidate.
|
|
5132
5462
|
const perKeyRefusals = events.filter((e) => e.type === "delete-refused-stale-etag" && e.reason === "bulk-asymmetry");
|
|
@@ -5176,7 +5506,7 @@ describe("share", () => {
|
|
|
5176
5506
|
expect(headedKeys).not.toContain(`f-${i.toString().padStart(4, "0")}.md`);
|
|
5177
5507
|
}
|
|
5178
5508
|
// Journal untouched — the entries stay recoverable.
|
|
5179
|
-
const journal =
|
|
5509
|
+
const journal = journalAt(journalPath);
|
|
5180
5510
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5181
5511
|
});
|
|
5182
5512
|
// The core of the #227 regression, isolated: pre-#227 a trip emptied
|
|
@@ -5207,7 +5537,7 @@ describe("share", () => {
|
|
|
5207
5537
|
const perKeyRefusals = events.filter((e) => e.type === "delete-refused-stale-etag" &&
|
|
5208
5538
|
e.reason === "bulk-asymmetry");
|
|
5209
5539
|
expect(perKeyRefusals.length).toBe(11);
|
|
5210
|
-
const journal =
|
|
5540
|
+
const journal = journalAt(journalPath);
|
|
5211
5541
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5212
5542
|
});
|
|
5213
5543
|
it("vault litter still drains when the breaker trips", async () => {
|
|
@@ -5221,7 +5551,7 @@ describe("share", () => {
|
|
|
5221
5551
|
withIntents: false,
|
|
5222
5552
|
});
|
|
5223
5553
|
const litterKey = "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md";
|
|
5224
|
-
const seeded =
|
|
5554
|
+
const seeded = journalAt(journalPath);
|
|
5225
5555
|
seeded.files[litterKey] = {
|
|
5226
5556
|
hash: "h",
|
|
5227
5557
|
size: 5,
|
|
@@ -5230,7 +5560,7 @@ describe("share", () => {
|
|
|
5230
5560
|
remoteEtag: "litter-etag",
|
|
5231
5561
|
kind: "file",
|
|
5232
5562
|
};
|
|
5233
|
-
|
|
5563
|
+
seedJournalAt(journalPath, seeded);
|
|
5234
5564
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
5235
5565
|
etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
|
|
5236
5566
|
lastModified: new Date(),
|
|
@@ -5370,7 +5700,7 @@ describe("share", () => {
|
|
|
5370
5700
|
expect(summary).toBeDefined();
|
|
5371
5701
|
expect(summary.ratio).toBeCloseTo(1.0, 5);
|
|
5372
5702
|
// Journal untouched.
|
|
5373
|
-
const journal =
|
|
5703
|
+
const journal = journalAt(journalPath);
|
|
5374
5704
|
expect(Object.keys(journal.files).length).toBe(50);
|
|
5375
5705
|
});
|
|
5376
5706
|
it("small absolute count (9/9 = 100% ratio but < MIN_ABS=10) does NOT trip: all 9 delete normally", async () => {
|
|
@@ -5414,7 +5744,7 @@ describe("share", () => {
|
|
|
5414
5744
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
5415
5745
|
expect(events.find((e) => e.type === "delete-refused-bulk-asymmetry")).toBeDefined();
|
|
5416
5746
|
// Journal entries survive.
|
|
5417
|
-
const journal =
|
|
5747
|
+
const journal = journalAt(journalPath);
|
|
5418
5748
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5419
5749
|
});
|
|
5420
5750
|
it("direction:'down' entries don't count toward the bulk numerator (owned-only silently skips them)", async () => {
|
|
@@ -5442,7 +5772,7 @@ describe("share", () => {
|
|
|
5442
5772
|
localDeleteIntent: deleteIntent("up-etag", "h"),
|
|
5443
5773
|
};
|
|
5444
5774
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5445
|
-
|
|
5775
|
+
seedJournalAt(journalPath, { version: "1", lastSync: now, files });
|
|
5446
5776
|
const result = await share({
|
|
5447
5777
|
paths: [companyRoot],
|
|
5448
5778
|
company: "acme",
|
|
@@ -5565,7 +5895,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5565
5895
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
5566
5896
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5567
5897
|
// v2-shaped fixture — same field set the production journal uses.
|
|
5568
|
-
|
|
5898
|
+
seedJournalAt(journalPath, ({
|
|
5569
5899
|
version: "2",
|
|
5570
5900
|
lastSync: new Date().toISOString(),
|
|
5571
5901
|
files: {
|
|
@@ -5611,7 +5941,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5611
5941
|
const { hashFile } = await import("../journal.js");
|
|
5612
5942
|
const baselineHash = hashFile(testFile);
|
|
5613
5943
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5614
|
-
|
|
5944
|
+
seedJournalAt(journalPath, ({
|
|
5615
5945
|
version: "2",
|
|
5616
5946
|
lastSync: new Date().toISOString(),
|
|
5617
5947
|
files: {
|
|
@@ -5652,7 +5982,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5652
5982
|
const originalLstat = fs.lstatSync(testFile);
|
|
5653
5983
|
// The recorded hash intentionally differs from the current bytes.
|
|
5654
5984
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5655
|
-
|
|
5985
|
+
seedJournalAt(journalPath, ({
|
|
5656
5986
|
version: "2",
|
|
5657
5987
|
lastSync: new Date().toISOString(),
|
|
5658
5988
|
files: {
|
|
@@ -5699,7 +6029,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5699
6029
|
const { hashFile } = await import("../journal.js");
|
|
5700
6030
|
const realHash = hashFile(testFile);
|
|
5701
6031
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5702
|
-
|
|
6032
|
+
seedJournalAt(journalPath, ({
|
|
5703
6033
|
version: "2",
|
|
5704
6034
|
lastSync: new Date().toISOString(),
|
|
5705
6035
|
files: {
|
|
@@ -5749,7 +6079,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5749
6079
|
const { hashFile } = await import("../journal.js");
|
|
5750
6080
|
const realHash = hashFile(testFile);
|
|
5751
6081
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5752
|
-
|
|
6082
|
+
seedJournalAt(journalPath, ({
|
|
5753
6083
|
version: "2",
|
|
5754
6084
|
lastSync: new Date().toISOString(),
|
|
5755
6085
|
files: {
|
|
@@ -5781,7 +6111,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5781
6111
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
5782
6112
|
// Journal mtimeMs is now the bumped value, while content hashing remains
|
|
5783
6113
|
// authoritative on the next sync.
|
|
5784
|
-
const journalAfter =
|
|
6114
|
+
const journalAfter = journalAt(journalPath);
|
|
5785
6115
|
expect(journalAfter.files["touched-then-fast.md"].mtimeMs).toBe(bumpedLstat.mtimeMs);
|
|
5786
6116
|
expect(journalAfter.files["touched-then-fast.md"].size).toBe(bumpedLstat.size);
|
|
5787
6117
|
// Content hash untouched — the file genuinely didn't change.
|
|
@@ -5801,7 +6131,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5801
6131
|
});
|
|
5802
6132
|
expect(result.filesUploaded).toBe(1);
|
|
5803
6133
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5804
|
-
const journal =
|
|
6134
|
+
const journal = journalAt(journalPath);
|
|
5805
6135
|
expect(journal.files["fresh.md"]).toBeDefined();
|
|
5806
6136
|
expect(journal.files["fresh.md"].mtimeMs).toBe(lstat.mtimeMs);
|
|
5807
6137
|
expect(journal.files["fresh.md"].size).toBe(lstat.size);
|
|
@@ -5818,7 +6148,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5818
6148
|
const realSize = fs.statSync(testFile).size;
|
|
5819
6149
|
// Real hash but NO mtimeMs. Hash matches → skip upload.
|
|
5820
6150
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5821
|
-
|
|
6151
|
+
seedJournalAt(journalPath, ({
|
|
5822
6152
|
version: "1",
|
|
5823
6153
|
lastSync: new Date().toISOString(),
|
|
5824
6154
|
files: {
|
|
@@ -6377,7 +6707,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6377
6707
|
// AND lost their etag/content-hash/stat journal metadata.
|
|
6378
6708
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6379
6709
|
expect(fs.existsSync(journalPath)).toBe(true);
|
|
6380
|
-
const journal =
|
|
6710
|
+
const journal = journalAt(journalPath);
|
|
6381
6711
|
const stampedKeys = Object.keys(journal.files);
|
|
6382
6712
|
expect(stampedKeys.length).toBe(FILE_COUNT - 1);
|
|
6383
6713
|
// The failed file is NOT in the journal (no successful upload).
|
|
@@ -6525,7 +6855,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6525
6855
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
6526
6856
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
6527
6857
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6528
|
-
|
|
6858
|
+
seedJournalAt(journalPath, ({
|
|
6529
6859
|
version: "1",
|
|
6530
6860
|
lastSync: new Date().toISOString(),
|
|
6531
6861
|
files: {
|
|
@@ -6554,7 +6884,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6554
6884
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
6555
6885
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
6556
6886
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
6557
|
-
const journal =
|
|
6887
|
+
const journal = journalAt(journalPath);
|
|
6558
6888
|
expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
|
|
6559
6889
|
});
|
|
6560
6890
|
it("defers an unreadable scope-invalid symlink tombstone without a fatal error", async () => {
|
|
@@ -6566,7 +6896,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6566
6896
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
6567
6897
|
fs.symlinkSync(targetDir, linkPath, "dir");
|
|
6568
6898
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6569
|
-
|
|
6899
|
+
seedJournalAt(journalPath, ({
|
|
6570
6900
|
version: "1",
|
|
6571
6901
|
lastSync: new Date().toISOString(),
|
|
6572
6902
|
files: {
|
|
@@ -6611,7 +6941,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6611
6941
|
samplePaths: [linkKey],
|
|
6612
6942
|
});
|
|
6613
6943
|
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
6614
|
-
const journal =
|
|
6944
|
+
const journal = journalAt(journalPath);
|
|
6615
6945
|
expect(journal.files[linkKey]).toBeDefined();
|
|
6616
6946
|
}
|
|
6617
6947
|
finally {
|