@indigoai-us/hq-cloud 6.15.31 → 6.15.32
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/bandwidth.d.ts +128 -0
- package/dist/bandwidth.d.ts.map +1 -0
- package/dist/bandwidth.js +254 -0
- package/dist/bandwidth.js.map +1 -0
- package/dist/bandwidth.test.d.ts +2 -0
- package/dist/bandwidth.test.d.ts.map +1 -0
- package/dist/bandwidth.test.js +243 -0
- package/dist/bandwidth.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +13 -24
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.js +71 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +10 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +181 -16
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +16 -1
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +40 -2
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.test.js +53 -1
- package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +15 -3
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +66 -13
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +433 -13
- 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 +40 -4
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +478 -86
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +845 -150
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +147 -18
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +20 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +261 -29
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +519 -90
- 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 +1 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +155 -25
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +29 -11
- package/dist/journal.test.js.map +1 -1
- package/dist/object-io.d.ts +34 -13
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +154 -29
- package/dist/object-io.js.map +1 -1
- package/dist/operation-lock.d.ts +12 -0
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +103 -21
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +90 -0
- package/dist/operation-lock.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/outposts/client.d.ts +2 -2
- package/dist/outposts/client.d.ts.map +1 -1
- package/dist/outposts/client.js +4 -3
- package/dist/outposts/client.js.map +1 -1
- package/dist/outposts/command.d.ts.map +1 -1
- package/dist/outposts/command.js +9 -6
- package/dist/outposts/command.js.map +1 -1
- package/dist/outposts/command.test.js +59 -2
- package/dist/outposts/command.test.js.map +1 -1
- package/dist/outposts/remote-command.d.ts +8 -8
- package/dist/outposts/remote-command.d.ts.map +1 -1
- package/dist/outposts/remote-command.js +10 -8
- package/dist/outposts/remote-command.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +14 -0
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +29 -0
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +22 -1
- package/dist/prefix-coalesce.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/sts-credential-refresh.test.js +87 -2
- package/dist/sts-credential-refresh.test.js.map +1 -1
- package/dist/sync/state-store.d.ts.map +1 -1
- package/dist/sync/state-store.js +20 -1
- package/dist/sync/state-store.js.map +1 -1
- package/dist/sync/state-store.test.js +64 -0
- package/dist/sync/state-store.test.js.map +1 -1
- package/dist/sync-core.d.ts.map +1 -1
- package/dist/sync-core.js +7 -2
- package/dist/sync-core.js.map +1 -1
- package/dist/sync-core.test.js +36 -0
- package/dist/sync-core.test.js.map +1 -1
- package/dist/telemetry.d.ts +30 -6
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +331 -135
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +403 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +26 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +76 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +105 -3
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +165 -1
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/share.test.js
CHANGED
|
@@ -53,9 +53,49 @@ vi.mock("readline", () => ({
|
|
|
53
53
|
}));
|
|
54
54
|
import * as readline from "readline";
|
|
55
55
|
import { share, isForbiddenCompanyVaultKey, UnreachablePushPathsError, currentUploadBytes, _testing as shareTesting } from "./share.js";
|
|
56
|
-
import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink, WindowsSymlinkPrivilegeError } from "../s3.js";
|
|
56
|
+
import { deleteRemoteFile, downloadFile, headRemoteFile, primeObjectTransport, primeUploads, 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
|
+
}
|
|
91
|
+
function sidecarIn(dir) {
|
|
92
|
+
const found = fs.readdirSync(dir).filter((name) => name.includes(".conflict-"));
|
|
93
|
+
expect(found).toHaveLength(1);
|
|
94
|
+
return path.join(dir, found[0]);
|
|
95
|
+
}
|
|
96
|
+
function conflictIndexAt(hqRoot) {
|
|
97
|
+
return JSON.parse(fs.readFileSync(path.join(hqRoot, ".hq-conflicts", "index.json"), "utf-8"));
|
|
98
|
+
}
|
|
59
99
|
const mockConfig = {
|
|
60
100
|
apiUrl: "https://vault-api.test",
|
|
61
101
|
authToken: "test-jwt-token",
|
|
@@ -369,6 +409,74 @@ describe("share", () => {
|
|
|
369
409
|
// Remote key must be company-relative, not hqRoot-relative
|
|
370
410
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "test.md", undefined, expect.anything());
|
|
371
411
|
});
|
|
412
|
+
it("replans an upload from the bytes present when its action starts", async () => {
|
|
413
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
414
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
415
|
+
const testFile = path.join(companyRoot, "changing.md");
|
|
416
|
+
fs.writeFileSync(testFile, "body seen by the whole-run preview");
|
|
417
|
+
let uploadedBody = "";
|
|
418
|
+
vi.mocked(uploadFile).mockImplementationOnce(async (_ctx, localPath) => {
|
|
419
|
+
uploadedBody = fs.readFileSync(localPath, "utf-8");
|
|
420
|
+
return { etag: '"fresh-upload-etag"' };
|
|
421
|
+
});
|
|
422
|
+
let changedAfterPlan = false;
|
|
423
|
+
let executionHeartbeatCalls = 0;
|
|
424
|
+
const result = await share({
|
|
425
|
+
paths: [testFile],
|
|
426
|
+
company: "acme",
|
|
427
|
+
vaultConfig: mockConfig,
|
|
428
|
+
hqRoot: tmpDir,
|
|
429
|
+
onEvent: (event) => {
|
|
430
|
+
if (event.type === "plan" && !changedAfterPlan) {
|
|
431
|
+
changedAfterPlan = true;
|
|
432
|
+
fs.writeFileSync(testFile, "body present when upload executes");
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
onHeartbeat: () => {
|
|
436
|
+
if (changedAfterPlan)
|
|
437
|
+
executionHeartbeatCalls++;
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
expect(changedAfterPlan).toBe(true);
|
|
441
|
+
expect(executionHeartbeatCalls).toBeGreaterThan(0);
|
|
442
|
+
expect(result.filesUploaded).toBe(1);
|
|
443
|
+
expect(uploadedBody).toBe("body present when upload executes");
|
|
444
|
+
expect(journalAt(path.join(stateDir, "sync-journal.acme.json")).files["changing.md"])
|
|
445
|
+
.toMatchObject({
|
|
446
|
+
hash: hashFile(testFile),
|
|
447
|
+
remoteEtag: "fresh-upload-etag",
|
|
448
|
+
direction: "up",
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
it("restarts the per-file decision when local bytes change during remote HEAD", async () => {
|
|
452
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
453
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
454
|
+
const testFile = path.join(companyRoot, "head-race.md");
|
|
455
|
+
fs.writeFileSync(testFile, "body before HEAD");
|
|
456
|
+
vi.mocked(headRemoteFile).mockImplementationOnce(async () => {
|
|
457
|
+
fs.writeFileSync(testFile, "body written while HEAD was in flight");
|
|
458
|
+
return null;
|
|
459
|
+
});
|
|
460
|
+
let uploadedBody = "";
|
|
461
|
+
vi.mocked(uploadFile).mockImplementationOnce(async (_ctx, localPath) => {
|
|
462
|
+
uploadedBody = fs.readFileSync(localPath, "utf-8");
|
|
463
|
+
return { etag: '"head-race-etag"' };
|
|
464
|
+
});
|
|
465
|
+
const result = await share({
|
|
466
|
+
paths: [testFile],
|
|
467
|
+
company: "acme",
|
|
468
|
+
vaultConfig: mockConfig,
|
|
469
|
+
hqRoot: tmpDir,
|
|
470
|
+
});
|
|
471
|
+
expect(result.filesUploaded).toBe(1);
|
|
472
|
+
expect(uploadedBody).toBe("body written while HEAD was in flight");
|
|
473
|
+
expect(journalAt(path.join(stateDir, "sync-journal.acme.json")).files["head-race.md"])
|
|
474
|
+
.toMatchObject({
|
|
475
|
+
hash: hashFile(testFile),
|
|
476
|
+
remoteEtag: "head-race-etag",
|
|
477
|
+
direction: "up",
|
|
478
|
+
});
|
|
479
|
+
});
|
|
372
480
|
it("propagates a per-file vault 401 to the runner auth boundary", async () => {
|
|
373
481
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
374
482
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -528,7 +636,7 @@ describe("share", () => {
|
|
|
528
636
|
const { hashFile } = await import("../journal.js");
|
|
529
637
|
const hash = hashFile(testFile);
|
|
530
638
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
531
|
-
|
|
639
|
+
seedJournalAt(journalPath, ({
|
|
532
640
|
version: "1",
|
|
533
641
|
lastSync: new Date().toISOString(),
|
|
534
642
|
files: {
|
|
@@ -559,7 +667,7 @@ describe("share", () => {
|
|
|
559
667
|
// Journal has a stale hash for this path — simulating "local has been
|
|
560
668
|
// edited since the last push".
|
|
561
669
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
562
|
-
|
|
670
|
+
seedJournalAt(journalPath, ({
|
|
563
671
|
version: "1",
|
|
564
672
|
lastSync: new Date().toISOString(),
|
|
565
673
|
files: {
|
|
@@ -581,6 +689,226 @@ describe("share", () => {
|
|
|
581
689
|
expect(result.filesUploaded).toBe(1);
|
|
582
690
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "changed.md", undefined, expect.anything());
|
|
583
691
|
});
|
|
692
|
+
// ── Stat fast path for change detection ─────────────────────────────────
|
|
693
|
+
// Push used to hash EVERY regular file every pass before deciding whether to
|
|
694
|
+
// skip it — half a million reads on a real vault — because stat metadata was
|
|
695
|
+
// considered untrustworthy: a tool can rewrite a file to the same length and
|
|
696
|
+
// restore its mtime, and a size+mtime check would then skip that edit
|
|
697
|
+
// forever.
|
|
698
|
+
//
|
|
699
|
+
// These tests pin the narrower claim the fast path actually rests on: size,
|
|
700
|
+
// mtime AND ctime together. ctime is the inode-change stamp, and userspace
|
|
701
|
+
// cannot restore it — `cp -p`, `touch -r` and `rsync --times` all reset mtime
|
|
702
|
+
// while ctime moves to now. So the same-length-plus-restored-mtime rewrite
|
|
703
|
+
// that the old comment warned about is still caught, without reading a byte.
|
|
704
|
+
//
|
|
705
|
+
// Differential proof: drop ctime from the comparison and the "restored mtime"
|
|
706
|
+
// test below fails — the edit is skipped and never uploaded.
|
|
707
|
+
describe("stat fast path for change detection", () => {
|
|
708
|
+
const CONTENT_A = "aaaaaaaaaa"; // 10 bytes
|
|
709
|
+
const CONTENT_B = "bbbbbbbbbb"; // 10 bytes — same length, different bytes
|
|
710
|
+
/** Write `body` at `key` under the acme company root; return its path. */
|
|
711
|
+
function writeFile(key, body) {
|
|
712
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
713
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
714
|
+
const abs = path.join(companyRoot, key);
|
|
715
|
+
fs.writeFileSync(abs, body);
|
|
716
|
+
return abs;
|
|
717
|
+
}
|
|
718
|
+
function seed(key, entry) {
|
|
719
|
+
seedJournalAt(path.join(stateDir, "sync-journal.acme.json"), {
|
|
720
|
+
version: "2",
|
|
721
|
+
lastSync: new Date().toISOString(),
|
|
722
|
+
files: { [key]: { syncedAt: new Date().toISOString(), direction: "up", ...entry } },
|
|
723
|
+
pulls: [],
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
function push(abs, onConflict) {
|
|
727
|
+
return share({
|
|
728
|
+
paths: [abs],
|
|
729
|
+
company: "acme",
|
|
730
|
+
vaultConfig: mockConfig,
|
|
731
|
+
hqRoot: tmpDir,
|
|
732
|
+
skipUnchanged: true,
|
|
733
|
+
...(onConflict ? { onConflict } : {}),
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
it("skips a file whose size, mtime and ctime all match — without reading it", async () => {
|
|
737
|
+
const abs = writeFile("untouched.md", CONTENT_A);
|
|
738
|
+
const stat = fs.lstatSync(abs);
|
|
739
|
+
// The recorded hash is deliberately WRONG. Hashing the file would find a
|
|
740
|
+
// mismatch and upload, so a skip here can only mean the bytes were never
|
|
741
|
+
// read — which is the whole point of the fast path.
|
|
742
|
+
seed("untouched.md", {
|
|
743
|
+
hash: "deliberately-not-the-hash-of-this-file",
|
|
744
|
+
size: stat.size,
|
|
745
|
+
mtimeMs: stat.mtimeMs,
|
|
746
|
+
ctimeMs: stat.ctimeMs,
|
|
747
|
+
});
|
|
748
|
+
const result = await push(abs);
|
|
749
|
+
expect(result.filesSkipped).toBe(1);
|
|
750
|
+
expect(result.filesUploaded).toBe(0);
|
|
751
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
752
|
+
});
|
|
753
|
+
it("re-reads when the size differs", async () => {
|
|
754
|
+
const abs = writeFile("resized.md", CONTENT_A + "extra");
|
|
755
|
+
const stat = fs.lstatSync(abs);
|
|
756
|
+
seed("resized.md", {
|
|
757
|
+
hash: hashFile(abs) + "-stale",
|
|
758
|
+
size: CONTENT_A.length, // shorter than what is on disk now
|
|
759
|
+
mtimeMs: stat.mtimeMs,
|
|
760
|
+
ctimeMs: stat.ctimeMs,
|
|
761
|
+
});
|
|
762
|
+
const result = await push(abs);
|
|
763
|
+
expect(result.filesUploaded).toBe(1);
|
|
764
|
+
expect(uploadFile).toHaveBeenCalled();
|
|
765
|
+
});
|
|
766
|
+
it("re-reads when the mtime differs", async () => {
|
|
767
|
+
const abs = writeFile("retimed.md", CONTENT_B);
|
|
768
|
+
const stat = fs.lstatSync(abs);
|
|
769
|
+
seed("retimed.md", {
|
|
770
|
+
hash: "hash-of-the-previous-body",
|
|
771
|
+
size: stat.size, // same length
|
|
772
|
+
mtimeMs: stat.mtimeMs - 60_000, // journal remembers an older edit time
|
|
773
|
+
ctimeMs: stat.ctimeMs,
|
|
774
|
+
});
|
|
775
|
+
const result = await push(abs);
|
|
776
|
+
expect(result.filesUploaded).toBe(1);
|
|
777
|
+
expect(uploadFile).toHaveBeenCalled();
|
|
778
|
+
});
|
|
779
|
+
it("re-reads a same-length rewrite whose mtime was restored — ctime still moves", async () => {
|
|
780
|
+
// The exact case the old hash-everything comment existed to defend
|
|
781
|
+
// against, as produced by `cp -p`, `touch -r` or `rsync --times`.
|
|
782
|
+
const abs = writeFile("mtime-restored.md", CONTENT_A);
|
|
783
|
+
const before = fs.lstatSync(abs);
|
|
784
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
785
|
+
fs.writeFileSync(abs, CONTENT_B); // same length, different bytes
|
|
786
|
+
fs.utimesSync(abs, before.atime, before.mtime); // put the old mtime back
|
|
787
|
+
const after = fs.lstatSync(abs);
|
|
788
|
+
// Preconditions. The disguise worked on size, and as closely on mtime as
|
|
789
|
+
// the OS allows — `utimes` takes whole milliseconds, so a restored stamp
|
|
790
|
+
// lands within 1ms of the original rather than exactly on it. It failed
|
|
791
|
+
// on ctime, which is the only signal left.
|
|
792
|
+
expect(after.size).toBe(before.size);
|
|
793
|
+
expect(Math.abs(after.mtimeMs - before.mtimeMs)).toBeLessThan(1);
|
|
794
|
+
expect(after.ctimeMs).not.toBe(before.ctimeMs);
|
|
795
|
+
// Seed the mtime that is actually on disk, so mtime matches EXACTLY and
|
|
796
|
+
// cannot be what triggers the re-read. Only the stale ctime can.
|
|
797
|
+
seed("mtime-restored.md", {
|
|
798
|
+
hash: "hash-of-content-a",
|
|
799
|
+
size: before.size,
|
|
800
|
+
mtimeMs: after.mtimeMs,
|
|
801
|
+
ctimeMs: before.ctimeMs,
|
|
802
|
+
});
|
|
803
|
+
const result = await push(abs);
|
|
804
|
+
expect(result.filesUploaded).toBe(1);
|
|
805
|
+
expect(uploadFile).toHaveBeenCalled();
|
|
806
|
+
});
|
|
807
|
+
it("falls back to reading when the journal predates stat tracking, then stamps it", async () => {
|
|
808
|
+
// Entries written before ctime was recorded carry no stat metadata. They
|
|
809
|
+
// must not be skipped on faith; they are read once and stamped so the
|
|
810
|
+
// next pass takes the fast path.
|
|
811
|
+
const abs = writeFile("legacy.md", CONTENT_A);
|
|
812
|
+
seed("legacy.md", { hash: hashFile(abs), size: CONTENT_A.length });
|
|
813
|
+
const result = await push(abs);
|
|
814
|
+
expect(result.filesSkipped).toBe(1);
|
|
815
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
816
|
+
const entry = journalAt(path.join(stateDir, "sync-journal.acme.json")).files["legacy.md"];
|
|
817
|
+
const stat = fs.lstatSync(abs);
|
|
818
|
+
expect(entry.mtimeMs).toBe(stat.mtimeMs);
|
|
819
|
+
expect(entry.ctimeMs).toBe(stat.ctimeMs);
|
|
820
|
+
});
|
|
821
|
+
it("never lets the fast path skip a localDiverges entry", async () => {
|
|
822
|
+
// A pull-held divergent entry stores the LOCAL hash and local stat, so
|
|
823
|
+
// every field matches by construction. Skipping on that asserts "local
|
|
824
|
+
// matches remote", which is exactly what the flag exists to deny.
|
|
825
|
+
const abs = writeFile("divergent.md", CONTENT_A);
|
|
826
|
+
const stat = fs.lstatSync(abs);
|
|
827
|
+
seed("divergent.md", {
|
|
828
|
+
hash: hashFile(abs),
|
|
829
|
+
size: stat.size,
|
|
830
|
+
mtimeMs: stat.mtimeMs,
|
|
831
|
+
ctimeMs: stat.ctimeMs,
|
|
832
|
+
localDiverges: true,
|
|
833
|
+
});
|
|
834
|
+
// `keep` so the divergence resolves without an interactive prompt. Under
|
|
835
|
+
// `keep` the file is deliberately NOT uploaded, so the proof that the
|
|
836
|
+
// fast path did not swallow it is that it reached Stage 2 at all: a
|
|
837
|
+
// fast-path skip returns before any remote HEAD is issued.
|
|
838
|
+
await push(abs, "keep");
|
|
839
|
+
expect(headRemoteFile).toHaveBeenCalled();
|
|
840
|
+
});
|
|
841
|
+
it("stamps size, mtime and ctime on upload so the next pass can skip", async () => {
|
|
842
|
+
const abs = writeFile("fresh.md", CONTENT_A);
|
|
843
|
+
const result = await push(abs);
|
|
844
|
+
expect(result.filesUploaded).toBe(1);
|
|
845
|
+
const entry = journalAt(path.join(stateDir, "sync-journal.acme.json")).files["fresh.md"];
|
|
846
|
+
const stat = fs.lstatSync(abs);
|
|
847
|
+
expect(entry.size).toBe(stat.size);
|
|
848
|
+
expect(entry.mtimeMs).toBe(stat.mtimeMs);
|
|
849
|
+
expect(entry.ctimeMs).toBe(stat.ctimeMs);
|
|
850
|
+
});
|
|
851
|
+
it("replans and stamps the new body when the file changes during remote HEAD", async () => {
|
|
852
|
+
// The pass-level preview hashes CONTENT_A, then the asynchronous remote
|
|
853
|
+
// probe changes the file to CONTENT_B. The per-file execution seam must
|
|
854
|
+
// restart from CONTENT_B, upload it now, and journal that exact body — no
|
|
855
|
+
// stale hash and no redundant upload on the next pass.
|
|
856
|
+
const abs = writeFile("edited-mid-flight.md", CONTENT_A);
|
|
857
|
+
let mutated = false;
|
|
858
|
+
vi.mocked(headRemoteFile).mockImplementation(async () => {
|
|
859
|
+
if (!mutated) {
|
|
860
|
+
mutated = true;
|
|
861
|
+
fs.writeFileSync(abs, CONTENT_B); // same length, different bytes
|
|
862
|
+
}
|
|
863
|
+
return null;
|
|
864
|
+
});
|
|
865
|
+
await push(abs);
|
|
866
|
+
const entry = journalAt(path.join(stateDir, "sync-journal.acme.json")).files["edited-mid-flight.md"];
|
|
867
|
+
const currentStat = fs.lstatSync(abs);
|
|
868
|
+
expect(entry.hash).toBe(hashFile(abs));
|
|
869
|
+
expect(entry.mtimeMs).toBe(currentStat.mtimeMs);
|
|
870
|
+
expect(entry.ctimeMs).toBe(currentStat.ctimeMs);
|
|
871
|
+
// The current bytes were already shipped and journaled in this pass.
|
|
872
|
+
vi.mocked(uploadFile).mockClear();
|
|
873
|
+
const second = await push(abs);
|
|
874
|
+
expect(second.filesUploaded).toBe(0);
|
|
875
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
876
|
+
});
|
|
877
|
+
it("distrusts whole-second timestamps as a coarse-filesystem signal", () => {
|
|
878
|
+
// Codex P2. FAT/exFAT, some FUSE mounts and cached network filesystems
|
|
879
|
+
// report timestamps rounded to whole seconds, so a same-size rewrite
|
|
880
|
+
// inside one tick can leave both mtime and ctime equal to the journal's
|
|
881
|
+
// and be skipped indefinitely. Every filesystem HQ can trust here (APFS,
|
|
882
|
+
// ext4, NTFS) records sub-second precision, so a pair that is exactly on
|
|
883
|
+
// the second is treated as untrustworthy and sent back to hashing.
|
|
884
|
+
const abs = writeFile("coarse.md", CONTENT_A);
|
|
885
|
+
const coarse = { size: CONTENT_A.length, mtimeMs: 1_700_000_000_000, ctimeMs: 1_700_000_000_000 };
|
|
886
|
+
const spy = vi.spyOn(fs, "lstatSync").mockReturnValue({
|
|
887
|
+
isFile: () => true,
|
|
888
|
+
...coarse,
|
|
889
|
+
});
|
|
890
|
+
try {
|
|
891
|
+
expect(shareTesting.statMatchesJournal(abs, {
|
|
892
|
+
hash: "h",
|
|
893
|
+
direction: "up",
|
|
894
|
+
syncedAt: new Date().toISOString(),
|
|
895
|
+
...coarse,
|
|
896
|
+
})).toBe(false);
|
|
897
|
+
// Same values, one of them sub-second: a real filesystem, so trusted.
|
|
898
|
+
const fine = { ...coarse, ctimeMs: coarse.ctimeMs + 0.5 };
|
|
899
|
+
spy.mockReturnValue({ isFile: () => true, ...fine });
|
|
900
|
+
expect(shareTesting.statMatchesJournal(abs, {
|
|
901
|
+
hash: "h",
|
|
902
|
+
direction: "up",
|
|
903
|
+
syncedAt: new Date().toISOString(),
|
|
904
|
+
...fine,
|
|
905
|
+
})).toBe(true);
|
|
906
|
+
}
|
|
907
|
+
finally {
|
|
908
|
+
spy.mockRestore();
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
});
|
|
584
912
|
// ── Push-side FILE_TOMBSTONE consult (delete-resync, 3B) ────────────────────
|
|
585
913
|
// An authoritative delete (`hq files delete`) writes a FILE_TOMBSTONE and
|
|
586
914
|
// removes the S3 object. Without a push-side consult, a behind peer that still
|
|
@@ -593,7 +921,7 @@ describe("share", () => {
|
|
|
593
921
|
// share.ts makes the first test fail (uploadFile IS called → resurrection).
|
|
594
922
|
describe("push-side tombstone consult", () => {
|
|
595
923
|
function seedJournal(key, hash, size) {
|
|
596
|
-
|
|
924
|
+
seedJournalAt(path.join(stateDir, "sync-journal.acme.json"), ({
|
|
597
925
|
version: "1",
|
|
598
926
|
lastSync: new Date().toISOString(),
|
|
599
927
|
files: {
|
|
@@ -630,6 +958,8 @@ describe("share", () => {
|
|
|
630
958
|
});
|
|
631
959
|
// The resurrection is blocked at the source: no upload for the tombstoned key.
|
|
632
960
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
961
|
+
expect(primeUploads).toHaveBeenCalledWith(expect.anything(), []);
|
|
962
|
+
expect(primeObjectTransport).toHaveBeenCalledWith(expect.anything(), "get", []);
|
|
633
963
|
expect(result.filesUploaded).toBe(0);
|
|
634
964
|
expect(result.filesSuppressedByTombstone).toBe(1);
|
|
635
965
|
expect(events.some((e) => e.type === "upload-suppressed-tombstone" && e.path === "docs/shared.md")).toBe(true);
|
|
@@ -736,8 +1066,12 @@ describe("share", () => {
|
|
|
736
1066
|
etag: '"remote-new-etag"',
|
|
737
1067
|
size: 99,
|
|
738
1068
|
});
|
|
1069
|
+
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1070
|
+
fs.writeFileSync(dest, "remote edit");
|
|
1071
|
+
return undefined;
|
|
1072
|
+
});
|
|
739
1073
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
740
|
-
|
|
1074
|
+
seedJournalAt(journalPath, ({
|
|
741
1075
|
version: "1",
|
|
742
1076
|
lastSync: new Date().toISOString(),
|
|
743
1077
|
files: {
|
|
@@ -768,6 +1102,24 @@ describe("share", () => {
|
|
|
768
1102
|
direction: "push",
|
|
769
1103
|
resolution: "keep",
|
|
770
1104
|
});
|
|
1105
|
+
// `keep` is cloud-wins on the push leg too: remote takes the working
|
|
1106
|
+
// name, while the displaced local body remains recoverable beside it.
|
|
1107
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("remote edit");
|
|
1108
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe("local edit");
|
|
1109
|
+
const journal = journalAt(journalPath);
|
|
1110
|
+
expect(journal.files["drifted.md"]).toMatchObject({
|
|
1111
|
+
hash: hashFile(testFile),
|
|
1112
|
+
remoteEtag: "remote-new-etag",
|
|
1113
|
+
direction: "down",
|
|
1114
|
+
});
|
|
1115
|
+
const index = conflictIndexAt(tmpDir);
|
|
1116
|
+
expect(index.conflicts).toHaveLength(1);
|
|
1117
|
+
expect(index.conflicts[0]).toMatchObject({
|
|
1118
|
+
originalPath: "companies/acme/drifted.md",
|
|
1119
|
+
preserved: "local",
|
|
1120
|
+
});
|
|
1121
|
+
expect(fs.readFileSync(path.join(tmpDir, index.conflicts[0].conflictPath), "utf-8"))
|
|
1122
|
+
.toBe("local edit");
|
|
771
1123
|
});
|
|
772
1124
|
it("cloud-authoritative: never pushes a stale local ontology/.last-run over the server value (one-sided-change clobber guard)", async () => {
|
|
773
1125
|
// Regression for the gardener watermark clobber. A second HQ root sharing this
|
|
@@ -789,7 +1141,7 @@ describe("share", () => {
|
|
|
789
1141
|
// assert it is never consulted below — the skip is cheap, and a leftover
|
|
790
1142
|
// mockResolvedValueOnce would otherwise leak into a later test's HEAD queue.
|
|
791
1143
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
792
|
-
|
|
1144
|
+
seedJournalAt(journalPath, ({
|
|
793
1145
|
version: "1",
|
|
794
1146
|
lastSync: new Date().toISOString(),
|
|
795
1147
|
files: {
|
|
@@ -835,9 +1187,9 @@ describe("share", () => {
|
|
|
835
1187
|
//
|
|
836
1188
|
// Fix: even on first-time upload, if HEAD shows remote exists and
|
|
837
1189
|
// its content (etag) differs from what we'd put, emit a conflict.
|
|
838
|
-
// Under keep, the
|
|
839
|
-
//
|
|
840
|
-
//
|
|
1190
|
+
// Under keep, cloud takes the original name and local moves to
|
|
1191
|
+
// \`<orig>.conflict-<ts>-<short>\` so both versions survive on disk;
|
|
1192
|
+
// under abort, the company aborts before PUT.
|
|
841
1193
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
842
1194
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
843
1195
|
const testFile = path.join(companyRoot, "v7b-fresh.txt");
|
|
@@ -849,6 +1201,10 @@ describe("share", () => {
|
|
|
849
1201
|
etag: '"remote-version-etag"',
|
|
850
1202
|
size: 40,
|
|
851
1203
|
});
|
|
1204
|
+
vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, dest) => {
|
|
1205
|
+
fs.writeFileSync(dest, "peer remote content");
|
|
1206
|
+
return undefined;
|
|
1207
|
+
});
|
|
852
1208
|
// No journal file written — first-time upload contract.
|
|
853
1209
|
const events = [];
|
|
854
1210
|
const result = await share({
|
|
@@ -860,14 +1216,19 @@ describe("share", () => {
|
|
|
860
1216
|
onEvent: (e) => events.push(e),
|
|
861
1217
|
});
|
|
862
1218
|
expect(result.conflictPaths).toEqual(["v7b-fresh.txt"]);
|
|
863
|
-
// Under keep,
|
|
864
|
-
// the
|
|
1219
|
+
// Under keep, no upload occurs: the remote bytes take the working name
|
|
1220
|
+
// and the local body is preserved beside them.
|
|
865
1221
|
expect(result.filesUploaded).toBe(0);
|
|
866
1222
|
const conflicts = events.filter((e) => typeof e === "object" && e !== null && e.type === "conflict");
|
|
867
1223
|
expect(conflicts).toHaveLength(1);
|
|
868
1224
|
expect(conflicts[0].path).toBe("v7b-fresh.txt");
|
|
869
|
-
|
|
870
|
-
expect(fs.readFileSync(
|
|
1225
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("peer remote content");
|
|
1226
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe("macos-fresh-collision-content");
|
|
1227
|
+
const index = conflictIndexAt(tmpDir);
|
|
1228
|
+
expect(index.conflicts[0]).toMatchObject({
|
|
1229
|
+
originalPath: "companies/acme/v7b-fresh.txt",
|
|
1230
|
+
preserved: "local",
|
|
1231
|
+
});
|
|
871
1232
|
});
|
|
872
1233
|
it("first-time-upload-with-cloud-collision: aborts company under --on-conflict abort (Bug #7)", async () => {
|
|
873
1234
|
// Symmetric to the keep case: when --on-conflict abort is passed, a
|
|
@@ -895,11 +1256,11 @@ describe("share", () => {
|
|
|
895
1256
|
expect(result.filesUploaded).toBe(0);
|
|
896
1257
|
});
|
|
897
1258
|
it("HQ-DESKTOP-54: a privilege refusal while writing the push conflict mirror is a skip, not an error", async () => {
|
|
898
|
-
//
|
|
899
|
-
//
|
|
1259
|
+
// Push keep also materializes the remote object (at the original name).
|
|
1260
|
+
// On an unprivileged Windows host that throws the typed privilege
|
|
900
1261
|
// error; it must be downgraded to the deliberate skip so the push stays
|
|
901
1262
|
// complete and the runner exits 0 — never type:error. The conflict itself
|
|
902
|
-
// is still
|
|
1263
|
+
// is still surfaced and the local rename is rolled back.
|
|
903
1264
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
904
1265
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
905
1266
|
const testFile = path.join(companyRoot, "v7b-priv.txt");
|
|
@@ -910,7 +1271,7 @@ describe("share", () => {
|
|
|
910
1271
|
size: 40,
|
|
911
1272
|
});
|
|
912
1273
|
// On a real unprivileged host BOTH the fresh-collision reconciled probe and
|
|
913
|
-
// the keep
|
|
1274
|
+
// the keep adoption materialize the remote symlink, so both downloadFile calls
|
|
914
1275
|
// refuse. The probe's catch returns "differs" (conflict) without erroring;
|
|
915
1276
|
// the mirror must downgrade the refusal to the deliberate skip.
|
|
916
1277
|
vi.mocked(downloadFile)
|
|
@@ -931,7 +1292,7 @@ describe("share", () => {
|
|
|
931
1292
|
target: "../target.md",
|
|
932
1293
|
}));
|
|
933
1294
|
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
934
|
-
// The conflict is still
|
|
1295
|
+
// The conflict is still surfaced; the failed adoption restores local.
|
|
935
1296
|
expect(result.conflictPaths).toEqual(["v7b-priv.txt"]);
|
|
936
1297
|
expect(fs.readFileSync(testFile, "utf-8")).toBe("local-collision-content");
|
|
937
1298
|
});
|
|
@@ -969,7 +1330,7 @@ describe("share", () => {
|
|
|
969
1330
|
direction: "push",
|
|
970
1331
|
});
|
|
971
1332
|
expect(fs.readdirSync(companyRoot).filter((name) => name.includes(".conflict-"))).toEqual([]);
|
|
972
|
-
const journal =
|
|
1333
|
+
const journal = readJournal("acme");
|
|
973
1334
|
expect(journal.files["kms-scaffold.md"]).toMatchObject({
|
|
974
1335
|
direction: "up",
|
|
975
1336
|
remoteEtag: "288bfa91684ae21f9ae27c061254d412",
|
|
@@ -1043,7 +1404,7 @@ describe("share", () => {
|
|
|
1043
1404
|
etag: '"0123456789abcdef0123456789abcdef-3"',
|
|
1044
1405
|
size: 32,
|
|
1045
1406
|
});
|
|
1046
|
-
// The probe (and the subsequent keep
|
|
1407
|
+
// The probe (and the subsequent keep adoption download) return DIFFERENT
|
|
1047
1408
|
// remote bytes.
|
|
1048
1409
|
vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, dest) => {
|
|
1049
1410
|
fs.writeFileSync(dest, "a-peers-different-version");
|
|
@@ -1062,8 +1423,8 @@ describe("share", () => {
|
|
|
1062
1423
|
expect(result.filesUploaded).toBe(0);
|
|
1063
1424
|
const conflicts = events.filter((e) => typeof e === "object" && e !== null && e.type === "conflict");
|
|
1064
1425
|
expect(conflicts).toHaveLength(1);
|
|
1065
|
-
|
|
1066
|
-
expect(fs.readFileSync(
|
|
1426
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("a-peers-different-version");
|
|
1427
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe("my-local-version");
|
|
1067
1428
|
});
|
|
1068
1429
|
describe("conditional-write fence (If-Match / If-None-Match / 412)", () => {
|
|
1069
1430
|
const fence412 = () => Object.assign(new Error("Precondition failed"), {
|
|
@@ -1085,7 +1446,7 @@ describe("share", () => {
|
|
|
1085
1446
|
size: 5,
|
|
1086
1447
|
});
|
|
1087
1448
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1088
|
-
|
|
1449
|
+
seedJournalAt(journalPath, ({
|
|
1089
1450
|
version: "1",
|
|
1090
1451
|
lastSync: syncedAt,
|
|
1091
1452
|
files: {
|
|
@@ -1123,12 +1484,18 @@ describe("share", () => {
|
|
|
1123
1484
|
});
|
|
1124
1485
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "brand-new.md", undefined, { ifNoneMatch: "*" });
|
|
1125
1486
|
});
|
|
1126
|
-
it("412 under keep:
|
|
1487
|
+
it("412 under keep: adopts the racing remote, preserves local, and converges the journal", async () => {
|
|
1127
1488
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1128
1489
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1129
1490
|
const testFile = path.join(companyRoot, "raced.md");
|
|
1130
1491
|
fs.writeFileSync(testFile, "my local bytes");
|
|
1131
|
-
vi.mocked(headRemoteFile)
|
|
1492
|
+
vi.mocked(headRemoteFile)
|
|
1493
|
+
.mockResolvedValueOnce(null)
|
|
1494
|
+
.mockResolvedValueOnce({
|
|
1495
|
+
lastModified: new Date(),
|
|
1496
|
+
etag: '"racing-peer-etag"',
|
|
1497
|
+
size: 23,
|
|
1498
|
+
});
|
|
1132
1499
|
vi.mocked(uploadFile).mockRejectedValueOnce(fence412());
|
|
1133
1500
|
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1134
1501
|
fs.writeFileSync(dest, "the racing peer version");
|
|
@@ -1148,19 +1515,22 @@ describe("share", () => {
|
|
|
1148
1515
|
expect(result.filesUploaded).toBe(0);
|
|
1149
1516
|
expect(result.aborted).toBe(false);
|
|
1150
1517
|
expect(events.some((e) => e.type === "conflict" && e.path === "raced.md")).toBe(true);
|
|
1151
|
-
// The racing remote
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
// Local copy untouched.
|
|
1157
|
-
expect(fs.readFileSync(testFile, "utf-8")).toBe("my local bytes");
|
|
1158
|
-
// No journal stamp for the failed PUT — next pass re-evaluates fresh.
|
|
1518
|
+
// The racing remote wins the working name; local remains recoverable.
|
|
1519
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("the racing peer version");
|
|
1520
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe("my local bytes");
|
|
1521
|
+
// The pull-shaped result is journalled as the remote body, so the next
|
|
1522
|
+
// pass does not revive the same resolved conflict.
|
|
1159
1523
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1524
|
+
const journal = journalAt(journalPath);
|
|
1525
|
+
expect(journal.files["raced.md"]).toMatchObject({
|
|
1526
|
+
hash: hashFile(testFile),
|
|
1527
|
+
remoteEtag: "racing-peer-etag",
|
|
1528
|
+
direction: "down",
|
|
1529
|
+
});
|
|
1530
|
+
expect(conflictIndexAt(tmpDir).conflicts[0]).toMatchObject({
|
|
1531
|
+
originalPath: "companies/acme/raced.md",
|
|
1532
|
+
preserved: "local",
|
|
1533
|
+
});
|
|
1164
1534
|
});
|
|
1165
1535
|
it("412 under overwrite: retries exactly once WITHOUT the fence (explicit consent)", async () => {
|
|
1166
1536
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
@@ -1239,8 +1609,12 @@ describe("share", () => {
|
|
|
1239
1609
|
}
|
|
1240
1610
|
return { etag: '"should-not-land"' };
|
|
1241
1611
|
});
|
|
1612
|
+
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1613
|
+
fs.writeFileSync(dest, "reviewed peer body");
|
|
1614
|
+
return undefined;
|
|
1615
|
+
});
|
|
1242
1616
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1243
|
-
|
|
1617
|
+
seedJournalAt(journalPath, ({
|
|
1244
1618
|
version: "1",
|
|
1245
1619
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1246
1620
|
files: {
|
|
@@ -1283,8 +1657,9 @@ describe("share", () => {
|
|
|
1283
1657
|
}
|
|
1284
1658
|
// No unfenced retry under keep.
|
|
1285
1659
|
expect(vi.mocked(uploadFile).mock.calls.some((c) => c[4] === undefined)).toBe(false);
|
|
1286
|
-
//
|
|
1287
|
-
expect(fs.readFileSync(testFile, "utf-8")).toBe(
|
|
1660
|
+
// Keep adopts the peer body but retains the stale local body beside it.
|
|
1661
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("reviewed peer body");
|
|
1662
|
+
expect(fs.readFileSync(sidecarIn(path.dirname(testFile)), "utf-8")).toBe(staleBytes);
|
|
1288
1663
|
});
|
|
1289
1664
|
it("legitimate local-ahead still uploads when remote remains at journal baseline", async () => {
|
|
1290
1665
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
@@ -1300,7 +1675,7 @@ describe("share", () => {
|
|
|
1300
1675
|
});
|
|
1301
1676
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"new-upload-etag"' });
|
|
1302
1677
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1303
|
-
|
|
1678
|
+
seedJournalAt(journalPath, ({
|
|
1304
1679
|
version: "1",
|
|
1305
1680
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1306
1681
|
files: {
|
|
@@ -1334,8 +1709,12 @@ describe("share", () => {
|
|
|
1334
1709
|
etag: '"remote-concurrent"',
|
|
1335
1710
|
size: 20,
|
|
1336
1711
|
});
|
|
1712
|
+
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1713
|
+
fs.writeFileSync(dest, "remote concurrent");
|
|
1714
|
+
return undefined;
|
|
1715
|
+
});
|
|
1337
1716
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1338
|
-
|
|
1717
|
+
seedJournalAt(journalPath, ({
|
|
1339
1718
|
version: "1",
|
|
1340
1719
|
lastSync: new Date().toISOString(),
|
|
1341
1720
|
files: {
|
|
@@ -1358,12 +1737,14 @@ describe("share", () => {
|
|
|
1358
1737
|
expect(result.conflictPaths).toEqual(["both-changed.md"]);
|
|
1359
1738
|
expect(result.filesUploaded).toBe(0);
|
|
1360
1739
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
//
|
|
1366
|
-
// the
|
|
1740
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("remote concurrent");
|
|
1741
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe("local concurrent");
|
|
1742
|
+
});
|
|
1743
|
+
it("localDiverges under keep: a pull-held divergent local yields to cloud without becoming write authority", async () => {
|
|
1744
|
+
// After a pull-side hold (`skip` or pull-leg `publish-local`), the journal
|
|
1745
|
+
// stamps the peer remoteEtag and localDiverges=true because local never
|
|
1746
|
+
// matched that etag. A later push must not treat "remote still at stamped
|
|
1747
|
+
// etag" as license to PUT the divergent local body.
|
|
1367
1748
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1368
1749
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1369
1750
|
const testFile = path.join(companyRoot, "kept-divergent.md");
|
|
@@ -1376,8 +1757,12 @@ describe("share", () => {
|
|
|
1376
1757
|
etag: `"${REMOTE}"`,
|
|
1377
1758
|
size: 40,
|
|
1378
1759
|
});
|
|
1760
|
+
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1761
|
+
fs.writeFileSync(dest, "peer remote body");
|
|
1762
|
+
return undefined;
|
|
1763
|
+
});
|
|
1379
1764
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1380
|
-
|
|
1765
|
+
seedJournalAt(journalPath, ({
|
|
1381
1766
|
version: "1",
|
|
1382
1767
|
lastSync: new Date().toISOString(),
|
|
1383
1768
|
files: {
|
|
@@ -1407,7 +1792,10 @@ describe("share", () => {
|
|
|
1407
1792
|
expect(events.some((e) => e.type === "conflict" &&
|
|
1408
1793
|
e.path === "kept-divergent.md" &&
|
|
1409
1794
|
e.resolution === "keep")).toBe(true);
|
|
1410
|
-
expect(fs.readFileSync(testFile, "utf-8")).toBe(
|
|
1795
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("peer remote body");
|
|
1796
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe(localBytes);
|
|
1797
|
+
expect(journalAt(journalPath).files["kept-divergent.md"]?.localDiverges)
|
|
1798
|
+
.toBeFalsy();
|
|
1411
1799
|
});
|
|
1412
1800
|
it("localDiverges under overwrite: explicit intent may force-write", async () => {
|
|
1413
1801
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
@@ -1423,7 +1811,7 @@ describe("share", () => {
|
|
|
1423
1811
|
});
|
|
1424
1812
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"forced-etag"' });
|
|
1425
1813
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1426
|
-
|
|
1814
|
+
seedJournalAt(journalPath, ({
|
|
1427
1815
|
version: "1",
|
|
1428
1816
|
lastSync: new Date().toISOString(),
|
|
1429
1817
|
files: {
|
|
@@ -1450,6 +1838,304 @@ describe("share", () => {
|
|
|
1450
1838
|
// fence then retry — either is consent. Must not leave the file unuploaded.
|
|
1451
1839
|
expect(uploadFile).toHaveBeenCalled();
|
|
1452
1840
|
});
|
|
1841
|
+
// ── publish-local: fenced promotion of a kept-local copy ────────────────
|
|
1842
|
+
//
|
|
1843
|
+
// `keep` leaves a file permanently unpublishable: push refuses it on the
|
|
1844
|
+
// localDiverges flag, pull never clears the flag (remote is unchanged), and
|
|
1845
|
+
// `keep` re-arms it every pass. The only existing escape — `overwrite` — is
|
|
1846
|
+
// a blunt force-write that will PUT unfenced after a 412, clobbering a
|
|
1847
|
+
// remote that genuinely advanced.
|
|
1848
|
+
//
|
|
1849
|
+
// `publish-local` is the missing middle: the operator explicitly elects
|
|
1850
|
+
// their local copy as the new authority, and the engine accepts it ONLY
|
|
1851
|
+
// while the remote still sits at the etag recorded when the keep happened.
|
|
1852
|
+
// If the remote moved, consent was given against a version that no longer
|
|
1853
|
+
// exists, so the write must fail closed instead of forcing.
|
|
1854
|
+
it("localDiverges under publish-local: promotes local under an If-Match fence on the journal etag", async () => {
|
|
1855
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1856
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1857
|
+
const testFile = path.join(companyRoot, "publish-divergent.md");
|
|
1858
|
+
const localBytes = "my kept divergent local, now elected authority";
|
|
1859
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1860
|
+
const localHash = hashFile(testFile);
|
|
1861
|
+
const REMOTE = "peer-version-etag";
|
|
1862
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1863
|
+
lastModified: new Date(),
|
|
1864
|
+
etag: `"${REMOTE}"`,
|
|
1865
|
+
size: 40,
|
|
1866
|
+
});
|
|
1867
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
1868
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1869
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1870
|
+
version: "1",
|
|
1871
|
+
lastSync: new Date().toISOString(),
|
|
1872
|
+
files: {
|
|
1873
|
+
"publish-divergent.md": {
|
|
1874
|
+
hash: localHash,
|
|
1875
|
+
size: localBytes.length,
|
|
1876
|
+
syncedAt: new Date().toISOString(),
|
|
1877
|
+
direction: "down",
|
|
1878
|
+
remoteEtag: REMOTE,
|
|
1879
|
+
localDiverges: true,
|
|
1880
|
+
},
|
|
1881
|
+
},
|
|
1882
|
+
}));
|
|
1883
|
+
const result = await share({
|
|
1884
|
+
paths: [testFile],
|
|
1885
|
+
company: "acme",
|
|
1886
|
+
vaultConfig: mockConfig,
|
|
1887
|
+
hqRoot: tmpDir,
|
|
1888
|
+
skipUnchanged: false,
|
|
1889
|
+
onConflict: "publish-local",
|
|
1890
|
+
});
|
|
1891
|
+
expect(result.filesUploaded).toBe(1);
|
|
1892
|
+
// Fenced on the etag the keep recorded — never unfenced, never live-HEAD.
|
|
1893
|
+
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
1894
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1895
|
+
// The entry is rewritten as an ordinary upload: local is now authority,
|
|
1896
|
+
// so the journal-honesty flag must be gone rather than re-armed.
|
|
1897
|
+
const journal = journalAt(journalPath);
|
|
1898
|
+
expect(journal.files["publish-divergent.md"].direction).toBe("up");
|
|
1899
|
+
expect(journal.files["publish-divergent.md"].localDiverges).toBeFalsy();
|
|
1900
|
+
expect(journal.files["publish-divergent.md"].remoteEtag).toBe("published-etag");
|
|
1901
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1902
|
+
});
|
|
1903
|
+
it("localDiverges under publish-local: a 412 fails closed and never retries unfenced", async () => {
|
|
1904
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1905
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1906
|
+
const testFile = path.join(companyRoot, "publish-raced.md");
|
|
1907
|
+
const localBytes = "my local, but the remote moved under me";
|
|
1908
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1909
|
+
const localHash = hashFile(testFile);
|
|
1910
|
+
const REMOTE = "etag-at-keep-time";
|
|
1911
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1912
|
+
lastModified: new Date(),
|
|
1913
|
+
etag: `"${REMOTE}"`,
|
|
1914
|
+
size: 40,
|
|
1915
|
+
});
|
|
1916
|
+
const precondition = Object.assign(new Error("At least one of the pre-conditions you specified did not hold"), {
|
|
1917
|
+
name: "PreconditionFailed",
|
|
1918
|
+
});
|
|
1919
|
+
vi.mocked(uploadFile).mockRejectedValueOnce(precondition);
|
|
1920
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1921
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1922
|
+
version: "1",
|
|
1923
|
+
lastSync: new Date().toISOString(),
|
|
1924
|
+
files: {
|
|
1925
|
+
"publish-raced.md": {
|
|
1926
|
+
hash: localHash,
|
|
1927
|
+
size: localBytes.length,
|
|
1928
|
+
syncedAt: new Date().toISOString(),
|
|
1929
|
+
direction: "down",
|
|
1930
|
+
remoteEtag: REMOTE,
|
|
1931
|
+
localDiverges: true,
|
|
1932
|
+
},
|
|
1933
|
+
},
|
|
1934
|
+
}));
|
|
1935
|
+
const result = await share({
|
|
1936
|
+
paths: [testFile],
|
|
1937
|
+
company: "acme",
|
|
1938
|
+
vaultConfig: mockConfig,
|
|
1939
|
+
hqRoot: tmpDir,
|
|
1940
|
+
skipUnchanged: false,
|
|
1941
|
+
onConflict: "publish-local",
|
|
1942
|
+
});
|
|
1943
|
+
// Exactly one attempt: the fenced one. The `overwrite` escape hatch of
|
|
1944
|
+
// re-PUTting with no precondition must NOT be reachable from here.
|
|
1945
|
+
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
1946
|
+
expect(result.filesUploaded).toBe(0);
|
|
1947
|
+
expect(result.conflictPaths).toContain("publish-raced.md");
|
|
1948
|
+
// Consent was for a remote that no longer exists, so the flag stays put
|
|
1949
|
+
// and the operator is asked again rather than silently overruled.
|
|
1950
|
+
const journal = journalAt(journalPath);
|
|
1951
|
+
expect(journal.files["publish-raced.md"].localDiverges).toBe(true);
|
|
1952
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1953
|
+
});
|
|
1954
|
+
// Codex review (PR #356, P1). The sync runner's push leg always calls
|
|
1955
|
+
// share() with `skipUnchanged: true`, and a pull-held entry stores the
|
|
1956
|
+
// CURRENT local hash — that is exactly what the journal-honesty stamp
|
|
1957
|
+
// records. So `existing.hash === localHash` is true for every stuck file,
|
|
1958
|
+
// classifyPushCandidates() returns `skip-unchanged`, and executeUploads()
|
|
1959
|
+
// never runs: no HEAD, no conflict, no upload. Without this bypass the
|
|
1960
|
+
// whole recovery is a no-op in the one mode that can reach personal-vault
|
|
1961
|
+
// files, and the next pull leaves them stuck again. `hash === journal hash`
|
|
1962
|
+
// means "local is unchanged since the stamp", NOT "local matches remote" —
|
|
1963
|
+
// for a divergent entry those are different claims.
|
|
1964
|
+
it("localDiverges bypasses the skip-unchanged fast path so the recovery can run at all", async () => {
|
|
1965
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1966
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1967
|
+
const testFile = path.join(companyRoot, "stuck-under-skip-unchanged.md");
|
|
1968
|
+
const localBytes = "the body a pull hold stamped and then refused to publish";
|
|
1969
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1970
|
+
const localHash = hashFile(testFile);
|
|
1971
|
+
const REMOTE = "etag-at-keep-time";
|
|
1972
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1973
|
+
lastModified: new Date(),
|
|
1974
|
+
etag: `"${REMOTE}"`,
|
|
1975
|
+
size: 40,
|
|
1976
|
+
});
|
|
1977
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
1978
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1979
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1980
|
+
version: "1",
|
|
1981
|
+
lastSync: new Date().toISOString(),
|
|
1982
|
+
files: {
|
|
1983
|
+
"stuck-under-skip-unchanged.md": {
|
|
1984
|
+
// Deliberately the CURRENT local hash: this is what a pull hold
|
|
1985
|
+
// writes, and it is what makes the fast path swallow the file.
|
|
1986
|
+
hash: localHash,
|
|
1987
|
+
size: localBytes.length,
|
|
1988
|
+
syncedAt: new Date().toISOString(),
|
|
1989
|
+
direction: "down",
|
|
1990
|
+
remoteEtag: REMOTE,
|
|
1991
|
+
localDiverges: true,
|
|
1992
|
+
},
|
|
1993
|
+
},
|
|
1994
|
+
}));
|
|
1995
|
+
const result = await share({
|
|
1996
|
+
paths: [testFile],
|
|
1997
|
+
company: "acme",
|
|
1998
|
+
vaultConfig: mockConfig,
|
|
1999
|
+
hqRoot: tmpDir,
|
|
2000
|
+
skipUnchanged: true,
|
|
2001
|
+
onConflict: "publish-local",
|
|
2002
|
+
});
|
|
2003
|
+
expect(result.filesUploaded).toBe(1);
|
|
2004
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
2005
|
+
const journal = journalAt(journalPath);
|
|
2006
|
+
expect(journal.files["stuck-under-skip-unchanged.md"].direction).toBe("up");
|
|
2007
|
+
expect(journal.files["stuck-under-skip-unchanged.md"].localDiverges).toBeFalsy();
|
|
2008
|
+
});
|
|
2009
|
+
// Codex review (PR #356, P2). A genuine two-sided conflict is not the
|
|
2010
|
+
// localDiverges recovery: the journal etag is stale BY DEFINITION here
|
|
2011
|
+
// (that is what remoteChanged means), so fencing consent on it guarantees a
|
|
2012
|
+
// 412 and the file can never publish. Consent given at this prompt is
|
|
2013
|
+
// consent against the remote the operator was just shown, so the fence must
|
|
2014
|
+
// be the freshly observed etag.
|
|
2015
|
+
it("publish-local on a fresh two-sided conflict fences on the OBSERVED remote etag, not the stale journal one", async () => {
|
|
2016
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2017
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2018
|
+
const testFile = path.join(companyRoot, "two-sided.md");
|
|
2019
|
+
fs.writeFileSync(testFile, "my newer local body");
|
|
2020
|
+
const STALE_JOURNAL_ETAG = "etag-from-last-sync";
|
|
2021
|
+
const OBSERVED_REMOTE_ETAG = "etag-the-peer-just-wrote";
|
|
2022
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
2023
|
+
lastModified: new Date(),
|
|
2024
|
+
etag: `"${OBSERVED_REMOTE_ETAG}"`,
|
|
2025
|
+
size: 40,
|
|
2026
|
+
});
|
|
2027
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
2028
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2029
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2030
|
+
version: "1",
|
|
2031
|
+
lastSync: new Date().toISOString(),
|
|
2032
|
+
files: {
|
|
2033
|
+
"two-sided.md": {
|
|
2034
|
+
hash: "a-hash-that-is-neither-side",
|
|
2035
|
+
size: 10,
|
|
2036
|
+
syncedAt: new Date().toISOString(),
|
|
2037
|
+
direction: "up",
|
|
2038
|
+
remoteEtag: STALE_JOURNAL_ETAG,
|
|
2039
|
+
},
|
|
2040
|
+
},
|
|
2041
|
+
}));
|
|
2042
|
+
const result = await share({
|
|
2043
|
+
paths: [testFile],
|
|
2044
|
+
company: "acme",
|
|
2045
|
+
vaultConfig: mockConfig,
|
|
2046
|
+
hqRoot: tmpDir,
|
|
2047
|
+
skipUnchanged: false,
|
|
2048
|
+
onConflict: "publish-local",
|
|
2049
|
+
});
|
|
2050
|
+
expect(result.filesUploaded).toBe(1);
|
|
2051
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({
|
|
2052
|
+
ifMatch: OBSERVED_REMOTE_ETAG,
|
|
2053
|
+
});
|
|
2054
|
+
});
|
|
2055
|
+
// Codex review (PR #356, P2). HEAD returning null skips the whole conflict
|
|
2056
|
+
// block, so the precondition becomes If-None-Match:* and the object is
|
|
2057
|
+
// RECREATED. For a divergent entry that silently overrules a peer's
|
|
2058
|
+
// deletion — the remote is emphatically no longer at the recorded etag, so
|
|
2059
|
+
// the strategy's own fence says fail closed. `keep`'s recreate-once
|
|
2060
|
+
// behaviour for non-divergent entries is unaffected.
|
|
2061
|
+
it("publish-local does not resurrect a remote deleted after the keep", async () => {
|
|
2062
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2063
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2064
|
+
const testFile = path.join(companyRoot, "peer-deleted.md");
|
|
2065
|
+
const localBytes = "my divergent local body";
|
|
2066
|
+
fs.writeFileSync(testFile, localBytes);
|
|
2067
|
+
const localHash = hashFile(testFile);
|
|
2068
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
2069
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2070
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2071
|
+
version: "1",
|
|
2072
|
+
lastSync: new Date().toISOString(),
|
|
2073
|
+
files: {
|
|
2074
|
+
"peer-deleted.md": {
|
|
2075
|
+
hash: localHash,
|
|
2076
|
+
size: localBytes.length,
|
|
2077
|
+
syncedAt: new Date().toISOString(),
|
|
2078
|
+
direction: "down",
|
|
2079
|
+
remoteEtag: "etag-before-the-peer-deleted-it",
|
|
2080
|
+
localDiverges: true,
|
|
2081
|
+
},
|
|
2082
|
+
},
|
|
2083
|
+
}));
|
|
2084
|
+
const result = await share({
|
|
2085
|
+
paths: [testFile],
|
|
2086
|
+
company: "acme",
|
|
2087
|
+
vaultConfig: mockConfig,
|
|
2088
|
+
hqRoot: tmpDir,
|
|
2089
|
+
skipUnchanged: false,
|
|
2090
|
+
onConflict: "publish-local",
|
|
2091
|
+
});
|
|
2092
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
2093
|
+
expect(result.filesUploaded).toBe(0);
|
|
2094
|
+
expect(result.conflictPaths).toContain("peer-deleted.md");
|
|
2095
|
+
const journal = journalAt(journalPath);
|
|
2096
|
+
expect(journal.files["peer-deleted.md"].localDiverges).toBe(true);
|
|
2097
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
2098
|
+
});
|
|
2099
|
+
it("publish-local on a non-divergent file behaves like an ordinary fenced upload", async () => {
|
|
2100
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2101
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2102
|
+
const testFile = path.join(companyRoot, "ordinary.md");
|
|
2103
|
+
fs.writeFileSync(testFile, "locally edited since last sync");
|
|
2104
|
+
const REMOTE = "unchanged-remote-etag";
|
|
2105
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
2106
|
+
lastModified: new Date(),
|
|
2107
|
+
etag: `"${REMOTE}"`,
|
|
2108
|
+
size: 10,
|
|
2109
|
+
});
|
|
2110
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"next-etag"' });
|
|
2111
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2112
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2113
|
+
version: "1",
|
|
2114
|
+
lastSync: new Date().toISOString(),
|
|
2115
|
+
files: {
|
|
2116
|
+
"ordinary.md": {
|
|
2117
|
+
hash: "stale-hash-from-a-previous-body",
|
|
2118
|
+
size: 10,
|
|
2119
|
+
syncedAt: new Date().toISOString(),
|
|
2120
|
+
direction: "up",
|
|
2121
|
+
remoteEtag: REMOTE,
|
|
2122
|
+
},
|
|
2123
|
+
},
|
|
2124
|
+
}));
|
|
2125
|
+
const result = await share({
|
|
2126
|
+
paths: [testFile],
|
|
2127
|
+
company: "acme",
|
|
2128
|
+
vaultConfig: mockConfig,
|
|
2129
|
+
hqRoot: tmpDir,
|
|
2130
|
+
skipUnchanged: false,
|
|
2131
|
+
onConflict: "publish-local",
|
|
2132
|
+
});
|
|
2133
|
+
// No localDiverges flag means no special casing: the normal push fence
|
|
2134
|
+
// applies and the file uploads without being routed through conflict.
|
|
2135
|
+
expect(result.filesUploaded).toBe(1);
|
|
2136
|
+
expect(result.conflictPaths ?? []).not.toContain("ordinary.md");
|
|
2137
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
2138
|
+
});
|
|
1453
2139
|
// Journal still carries remoteEtag from a prior sync, but the remote key
|
|
1454
2140
|
// is gone (HEAD null) — e.g. peer delete or operator purge. Recreation
|
|
1455
2141
|
// must use If-None-Match:* (create-only), NOT If-Match on the stale
|
|
@@ -1467,7 +2153,7 @@ describe("share", () => {
|
|
|
1467
2153
|
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
1468
2154
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"recreated-etag"' });
|
|
1469
2155
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1470
|
-
|
|
2156
|
+
seedJournalAt(journalPath, ({
|
|
1471
2157
|
version: "1",
|
|
1472
2158
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1473
2159
|
files: {
|
|
@@ -1497,28 +2183,34 @@ describe("share", () => {
|
|
|
1497
2183
|
const pc = vi.mocked(uploadFile).mock.calls[0][4];
|
|
1498
2184
|
expect(pc?.ifMatch).toBeUndefined();
|
|
1499
2185
|
expect(pc?.ifNoneMatch).toBe("*");
|
|
1500
|
-
const journal =
|
|
2186
|
+
const journal = journalAt(journalPath);
|
|
1501
2187
|
expect(journal.files["recreate-deleted.md"]).toMatchObject({
|
|
1502
2188
|
hash: localHash,
|
|
1503
2189
|
remoteEtag: "recreated-etag",
|
|
1504
2190
|
direction: "up",
|
|
1505
2191
|
});
|
|
1506
2192
|
});
|
|
1507
|
-
it("deleted journaled remote + concurrent create 412 under keep:
|
|
2193
|
+
it("deleted journaled remote + concurrent create 412 under keep: adopts the winner and preserves local", async () => {
|
|
1508
2194
|
// Same setup as recreation, but a peer creates the key between HEAD and
|
|
1509
|
-
// PUT → If-None-Match:* 412s → keep
|
|
2195
|
+
// PUT → If-None-Match:* 412s → keep adopts the peer's winning create.
|
|
1510
2196
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1511
2197
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1512
2198
|
const testFile = path.join(companyRoot, "race-recreate.md");
|
|
1513
2199
|
fs.writeFileSync(testFile, "my recreated local");
|
|
1514
|
-
vi.mocked(headRemoteFile)
|
|
2200
|
+
vi.mocked(headRemoteFile)
|
|
2201
|
+
.mockResolvedValueOnce(null)
|
|
2202
|
+
.mockResolvedValueOnce({
|
|
2203
|
+
lastModified: new Date(),
|
|
2204
|
+
etag: '"peer-created-etag"',
|
|
2205
|
+
size: 18,
|
|
2206
|
+
});
|
|
1515
2207
|
vi.mocked(uploadFile).mockRejectedValueOnce(fence412());
|
|
1516
2208
|
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1517
2209
|
fs.writeFileSync(dest, "peer created first");
|
|
1518
2210
|
return undefined;
|
|
1519
2211
|
});
|
|
1520
2212
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1521
|
-
|
|
2213
|
+
seedJournalAt(journalPath, ({
|
|
1522
2214
|
version: "1",
|
|
1523
2215
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1524
2216
|
files: {
|
|
@@ -1548,12 +2240,15 @@ describe("share", () => {
|
|
|
1548
2240
|
// First (only) attempt must be create-fenced; no unfenced retry under keep.
|
|
1549
2241
|
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
1550
2242
|
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifNoneMatch: "*" });
|
|
1551
|
-
// Journal
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
2243
|
+
// Journal now describes the peer body at the original name.
|
|
2244
|
+
const journal = journalAt(journalPath);
|
|
2245
|
+
expect(journal.files["race-recreate.md"]).toMatchObject({
|
|
2246
|
+
remoteEtag: "peer-created-etag",
|
|
2247
|
+
hash: hashFile(testFile),
|
|
2248
|
+
direction: "down",
|
|
2249
|
+
});
|
|
2250
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("peer created first");
|
|
2251
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe("my recreated local");
|
|
1557
2252
|
});
|
|
1558
2253
|
});
|
|
1559
2254
|
it("scoped push (plan-exceeds-grant): syncs the in-scope subset, skips out-of-scope paths, never aborts (feedback_ded09d56)", async () => {
|
|
@@ -1874,7 +2569,7 @@ describe("share", () => {
|
|
|
1874
2569
|
size: 5,
|
|
1875
2570
|
});
|
|
1876
2571
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1877
|
-
|
|
2572
|
+
seedJournalAt(journalPath, ({
|
|
1878
2573
|
version: "1",
|
|
1879
2574
|
lastSync: syncedAt,
|
|
1880
2575
|
files: {
|
|
@@ -1914,7 +2609,7 @@ describe("share", () => {
|
|
|
1914
2609
|
size: 5,
|
|
1915
2610
|
});
|
|
1916
2611
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1917
|
-
|
|
2612
|
+
seedJournalAt(journalPath, ({
|
|
1918
2613
|
version: "1",
|
|
1919
2614
|
lastSync: syncedAt,
|
|
1920
2615
|
files: {
|
|
@@ -1950,7 +2645,7 @@ describe("share", () => {
|
|
|
1950
2645
|
hqRoot: tmpDir,
|
|
1951
2646
|
});
|
|
1952
2647
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1953
|
-
const journal =
|
|
2648
|
+
const journal = journalAt(journalPath);
|
|
1954
2649
|
expect(journal.files["fresh.md"].remoteEtag).toBe("new-upload-etag");
|
|
1955
2650
|
});
|
|
1956
2651
|
it("forwards UploadAuthor to uploadFile when present (created-by metadata)", async () => {
|
|
@@ -1995,7 +2690,7 @@ describe("share", () => {
|
|
|
1995
2690
|
const { hashFile } = await import("../journal.js");
|
|
1996
2691
|
const hash = hashFile(testFile);
|
|
1997
2692
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1998
|
-
|
|
2693
|
+
seedJournalAt(journalPath, ({
|
|
1999
2694
|
version: "1",
|
|
2000
2695
|
lastSync: new Date().toISOString(),
|
|
2001
2696
|
files: {
|
|
@@ -2215,7 +2910,7 @@ describe("share", () => {
|
|
|
2215
2910
|
.update("stable content")
|
|
2216
2911
|
.digest("hex");
|
|
2217
2912
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2218
|
-
|
|
2913
|
+
seedJournalAt(journalPath, ({
|
|
2219
2914
|
version: "1",
|
|
2220
2915
|
lastSync: new Date().toISOString(),
|
|
2221
2916
|
files: {
|
|
@@ -2276,7 +2971,7 @@ describe("share", () => {
|
|
|
2276
2971
|
// not be queued for delete.
|
|
2277
2972
|
fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
|
|
2278
2973
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2279
|
-
|
|
2974
|
+
seedJournalAt(journalPath, ({
|
|
2280
2975
|
version: "1",
|
|
2281
2976
|
lastSync: new Date().toISOString(),
|
|
2282
2977
|
files: {
|
|
@@ -2313,7 +3008,7 @@ describe("share", () => {
|
|
|
2313
3008
|
operation: "delete",
|
|
2314
3009
|
});
|
|
2315
3010
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
|
|
2316
|
-
const journal =
|
|
3011
|
+
const journal = journalAt(journalPath);
|
|
2317
3012
|
expect(journal.files["knowledge"]).toBeUndefined();
|
|
2318
3013
|
});
|
|
2319
3014
|
it("propagateDeletes: a dangling local symlink is NOT classified as gone (lstat, not existsSync)", async () => {
|
|
@@ -2330,7 +3025,7 @@ describe("share", () => {
|
|
|
2330
3025
|
const danglingLink = path.join(companyRoot, "dangling-link.md");
|
|
2331
3026
|
fs.symlinkSync("./missing-target.md", danglingLink);
|
|
2332
3027
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2333
|
-
|
|
3028
|
+
seedJournalAt(journalPath, ({
|
|
2334
3029
|
version: "1",
|
|
2335
3030
|
lastSync: new Date().toISOString(),
|
|
2336
3031
|
files: {
|
|
@@ -2360,7 +3055,7 @@ describe("share", () => {
|
|
|
2360
3055
|
expect(result.filesDeleted).toBe(0);
|
|
2361
3056
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2362
3057
|
// Journal entry must survive the run.
|
|
2363
|
-
const journal =
|
|
3058
|
+
const journal = journalAt(journalPath);
|
|
2364
3059
|
expect(journal.files["dangling-link.md"]).toBeDefined();
|
|
2365
3060
|
});
|
|
2366
3061
|
it("vault-litter drain: a `.drift-*` marker with direction:'down' and NO delete intent tombstones " +
|
|
@@ -2425,7 +3120,7 @@ describe("share", () => {
|
|
|
2425
3120
|
});
|
|
2426
3121
|
expect(result.filesDeleted).toBe(1);
|
|
2427
3122
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "personal/scripts/run-project.sh.drift-1779863862-42519");
|
|
2428
|
-
const journal =
|
|
3123
|
+
const journal = journalAt(journalPath);
|
|
2429
3124
|
expect(journal.files["personal/scripts/run-project.sh.drift-1779863862-42519"]).toBeUndefined();
|
|
2430
3125
|
}
|
|
2431
3126
|
finally {
|
|
@@ -2498,7 +3193,7 @@ describe("share", () => {
|
|
|
2498
3193
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2499
3194
|
// No file on disk under companies/acme/peer-uploaded.md → local missing.
|
|
2500
3195
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2501
|
-
|
|
3196
|
+
seedJournalAt(journalPath, ({
|
|
2502
3197
|
version: "1",
|
|
2503
3198
|
lastSync: new Date().toISOString(),
|
|
2504
3199
|
files: {
|
|
@@ -2523,7 +3218,7 @@ describe("share", () => {
|
|
|
2523
3218
|
});
|
|
2524
3219
|
expect(result.filesDeleted).toBe(0);
|
|
2525
3220
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2526
|
-
const journal =
|
|
3221
|
+
const journal = journalAt(journalPath);
|
|
2527
3222
|
expect(journal.files["peer-uploaded.md"]).toBeDefined();
|
|
2528
3223
|
});
|
|
2529
3224
|
it("ROLLBACK KNOB: owned-only is honored on a prs_ personal vault — an intent-less entry with a " +
|
|
@@ -2614,7 +3309,7 @@ describe("share", () => {
|
|
|
2614
3309
|
]);
|
|
2615
3310
|
// Journal entry survives, so a later run (or a watcher-minted intent)
|
|
2616
3311
|
// can still authorize the delete properly.
|
|
2617
|
-
const journal =
|
|
3312
|
+
const journal = journalAt(journalPath);
|
|
2618
3313
|
expect(journal.files["personal/knowledge/gone.md"]).toBeDefined();
|
|
2619
3314
|
}
|
|
2620
3315
|
finally {
|
|
@@ -2631,7 +3326,7 @@ describe("share", () => {
|
|
|
2631
3326
|
// deleted by the user.
|
|
2632
3327
|
fs.writeFileSync(path.join(companyRoot, "kept.md"), "still here");
|
|
2633
3328
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2634
|
-
|
|
3329
|
+
seedJournalAt(journalPath, ({
|
|
2635
3330
|
version: "1",
|
|
2636
3331
|
lastSync: new Date().toISOString(),
|
|
2637
3332
|
files: {
|
|
@@ -2667,7 +3362,7 @@ describe("share", () => {
|
|
|
2667
3362
|
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
2668
3363
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "gone.md");
|
|
2669
3364
|
// Journal entry for the gone file is removed; the kept entry stays.
|
|
2670
|
-
const journal =
|
|
3365
|
+
const journal = journalAt(journalPath);
|
|
2671
3366
|
expect(journal.files["gone.md"]).toBeUndefined();
|
|
2672
3367
|
expect(journal.files["kept.md"]).toBeDefined();
|
|
2673
3368
|
});
|
|
@@ -2675,7 +3370,7 @@ describe("share", () => {
|
|
|
2675
3370
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2676
3371
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2677
3372
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2678
|
-
|
|
3373
|
+
seedJournalAt(journalPath, ({
|
|
2679
3374
|
version: "1",
|
|
2680
3375
|
lastSync: new Date().toISOString(),
|
|
2681
3376
|
files: {
|
|
@@ -2717,7 +3412,7 @@ describe("share", () => {
|
|
|
2717
3412
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2718
3413
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2719
3414
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2720
|
-
|
|
3415
|
+
seedJournalAt(journalPath, ({
|
|
2721
3416
|
version: "1",
|
|
2722
3417
|
lastSync: new Date().toISOString(),
|
|
2723
3418
|
files: {
|
|
@@ -2740,7 +3435,7 @@ describe("share", () => {
|
|
|
2740
3435
|
expect(result.filesDeleted).toBe(0);
|
|
2741
3436
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2742
3437
|
// Journal entry survives so the next opt-in run can still propagate.
|
|
2743
|
-
const journal =
|
|
3438
|
+
const journal = journalAt(journalPath);
|
|
2744
3439
|
expect(journal.files["gone.md"]).toBeDefined();
|
|
2745
3440
|
});
|
|
2746
3441
|
it("propagateDeletes: scope is constrained to the supplied paths — sibling deletes are not swept", async () => {
|
|
@@ -2748,7 +3443,7 @@ describe("share", () => {
|
|
|
2748
3443
|
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
2749
3444
|
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
2750
3445
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2751
|
-
|
|
3446
|
+
seedJournalAt(journalPath, ({
|
|
2752
3447
|
version: "1",
|
|
2753
3448
|
lastSync: new Date().toISOString(),
|
|
2754
3449
|
files: {
|
|
@@ -2783,7 +3478,7 @@ describe("share", () => {
|
|
|
2783
3478
|
expect(result.filesDeleted).toBe(1);
|
|
2784
3479
|
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
2785
3480
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
|
|
2786
|
-
const journal =
|
|
3481
|
+
const journal = journalAt(journalPath);
|
|
2787
3482
|
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
2788
3483
|
// Sibling tree's journal entry is untouched — `hq share <subtree>` must
|
|
2789
3484
|
// not act on files outside the named scope.
|
|
@@ -2799,7 +3494,7 @@ describe("share", () => {
|
|
|
2799
3494
|
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
2800
3495
|
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
2801
3496
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2802
|
-
|
|
3497
|
+
seedJournalAt(journalPath, ({
|
|
2803
3498
|
version: "1",
|
|
2804
3499
|
lastSync: new Date().toISOString(),
|
|
2805
3500
|
files: {
|
|
@@ -2834,7 +3529,7 @@ describe("share", () => {
|
|
|
2834
3529
|
});
|
|
2835
3530
|
expect(result.filesDeleted).toBe(1);
|
|
2836
3531
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
|
|
2837
|
-
const journal =
|
|
3532
|
+
const journal = journalAt(journalPath);
|
|
2838
3533
|
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
2839
3534
|
// Scope stays exactly as narrow as the absolute spelling's — no wider.
|
|
2840
3535
|
expect(journal.files["other/also-gone.md"]).toBeDefined();
|
|
@@ -2851,7 +3546,7 @@ describe("share", () => {
|
|
|
2851
3546
|
fs.mkdirSync(acmeRepo, { recursive: true });
|
|
2852
3547
|
fs.symlinkSync(acmeRepo, path.join(companyRoot, "knowledge"));
|
|
2853
3548
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2854
|
-
|
|
3549
|
+
seedJournalAt(journalPath, ({
|
|
2855
3550
|
version: "1",
|
|
2856
3551
|
lastSync: new Date().toISOString(),
|
|
2857
3552
|
files: {
|
|
@@ -2881,14 +3576,14 @@ describe("share", () => {
|
|
|
2881
3576
|
});
|
|
2882
3577
|
expect(result.filesDeleted).toBe(0);
|
|
2883
3578
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2884
|
-
const journal =
|
|
3579
|
+
const journal = journalAt(journalPath);
|
|
2885
3580
|
expect(journal.files["knowledge/agents/brief.md"]).toBeDefined();
|
|
2886
3581
|
});
|
|
2887
3582
|
it("propagateDeletes: a failed DeleteObject leaves the journal entry intact for retry", async () => {
|
|
2888
3583
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2889
3584
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2890
3585
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2891
|
-
|
|
3586
|
+
seedJournalAt(journalPath, ({
|
|
2892
3587
|
version: "1",
|
|
2893
3588
|
lastSync: new Date().toISOString(),
|
|
2894
3589
|
files: {
|
|
@@ -2921,7 +3616,7 @@ describe("share", () => {
|
|
|
2921
3616
|
const errorEvent = events.find((e) => e.type === "error");
|
|
2922
3617
|
expect(errorEvent).toMatchObject({ path: "flaky.md", message: expect.stringContaining("S3 down") });
|
|
2923
3618
|
// Entry survives — next run will retry the delete.
|
|
2924
|
-
const journal =
|
|
3619
|
+
const journal = journalAt(journalPath);
|
|
2925
3620
|
expect(journal.files["flaky.md"]).toBeDefined();
|
|
2926
3621
|
});
|
|
2927
3622
|
// ── Delete propagation safety: direction + filter guards (Bug B + C) ──────
|
|
@@ -2955,7 +3650,7 @@ describe("share", () => {
|
|
|
2955
3650
|
// and one this machine uploaded (direction:'up'). Only the 'up' one should
|
|
2956
3651
|
// be eligible for delete-propagation.
|
|
2957
3652
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2958
|
-
|
|
3653
|
+
seedJournalAt(journalPath, ({
|
|
2959
3654
|
version: "1",
|
|
2960
3655
|
lastSync: new Date().toISOString(),
|
|
2961
3656
|
files: {
|
|
@@ -2992,7 +3687,7 @@ describe("share", () => {
|
|
|
2992
3687
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2993
3688
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2994
3689
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2995
|
-
|
|
3690
|
+
seedJournalAt(journalPath, ({
|
|
2996
3691
|
version: "1",
|
|
2997
3692
|
lastSync: new Date().toISOString(),
|
|
2998
3693
|
files: {
|
|
@@ -3025,7 +3720,7 @@ describe("share", () => {
|
|
|
3025
3720
|
// absence is a filter outcome, not a user-intended delete.
|
|
3026
3721
|
fs.writeFileSync(path.join(tmpDir, ".hqignore"), "companies/acme/legacy/**\n");
|
|
3027
3722
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3028
|
-
|
|
3723
|
+
seedJournalAt(journalPath, ({
|
|
3029
3724
|
version: "1",
|
|
3030
3725
|
lastSync: new Date().toISOString(),
|
|
3031
3726
|
files: {
|
|
@@ -3084,7 +3779,7 @@ describe("share", () => {
|
|
|
3084
3779
|
// Under owned-only this would be stuck on remote forever; currency-gated
|
|
3085
3780
|
// checks the ETag and propagates the delete cleanly when match holds.
|
|
3086
3781
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3087
|
-
|
|
3782
|
+
seedJournalAt(journalPath, ({
|
|
3088
3783
|
version: "1",
|
|
3089
3784
|
lastSync: new Date().toISOString(),
|
|
3090
3785
|
files: {
|
|
@@ -3114,7 +3809,7 @@ describe("share", () => {
|
|
|
3114
3809
|
});
|
|
3115
3810
|
expect(result.filesDeleted).toBe(1);
|
|
3116
3811
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "core/policies/old.md");
|
|
3117
|
-
const journal =
|
|
3812
|
+
const journal = journalAt(journalPath);
|
|
3118
3813
|
expect(journal.files["core/policies/old.md"]).toBeUndefined();
|
|
3119
3814
|
});
|
|
3120
3815
|
it("currency-gated: propagates the S3 delete for a local-delete tombstone (pull-leg producer hand-off)", async () => {
|
|
@@ -3125,7 +3820,7 @@ describe("share", () => {
|
|
|
3125
3820
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3126
3821
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3127
3822
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3128
|
-
|
|
3823
|
+
seedJournalAt(journalPath, ({
|
|
3129
3824
|
version: "2",
|
|
3130
3825
|
lastSync: new Date().toISOString(),
|
|
3131
3826
|
files: {
|
|
@@ -3161,7 +3856,7 @@ describe("share", () => {
|
|
|
3161
3856
|
});
|
|
3162
3857
|
expect(result.filesDeleted).toBe(1);
|
|
3163
3858
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "docs/deleted.md");
|
|
3164
|
-
const journal =
|
|
3859
|
+
const journal = journalAt(journalPath);
|
|
3165
3860
|
expect(journal.files["docs/deleted.md"]).toBeUndefined();
|
|
3166
3861
|
});
|
|
3167
3862
|
it("currency-gated: refuses delete + emits stale-etag event when remote moved since last sync", async () => {
|
|
@@ -3170,7 +3865,7 @@ describe("share", () => {
|
|
|
3170
3865
|
// Another device modified `shared.md` after this device's last sync.
|
|
3171
3866
|
// The journal still records the old etag; HEAD returns the new one.
|
|
3172
3867
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3173
|
-
|
|
3868
|
+
seedJournalAt(journalPath, ({
|
|
3174
3869
|
version: "1",
|
|
3175
3870
|
lastSync: new Date().toISOString(),
|
|
3176
3871
|
files: {
|
|
@@ -3202,7 +3897,7 @@ describe("share", () => {
|
|
|
3202
3897
|
// No remote DELETE issued, journal entry preserved (pull will re-pull).
|
|
3203
3898
|
expect(result.filesDeleted).toBe(0);
|
|
3204
3899
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3205
|
-
const journal =
|
|
3900
|
+
const journal = journalAt(journalPath);
|
|
3206
3901
|
expect(journal.files["shared.md"]).toBeDefined();
|
|
3207
3902
|
// Dedicated event fired so UIs surface the refusal. `reason` field
|
|
3208
3903
|
// discriminates "stale-etag" (real drift) from "legacy-no-etag" so
|
|
@@ -3224,7 +3919,7 @@ describe("share", () => {
|
|
|
3224
3919
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3225
3920
|
const recreatedPath = path.join(companyRoot, "racy.md");
|
|
3226
3921
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3227
|
-
|
|
3922
|
+
seedJournalAt(journalPath, ({
|
|
3228
3923
|
version: "2",
|
|
3229
3924
|
lastSync: new Date().toISOString(),
|
|
3230
3925
|
files: {
|
|
@@ -3260,7 +3955,7 @@ describe("share", () => {
|
|
|
3260
3955
|
expect(result.filesDeleted).toBe(0);
|
|
3261
3956
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3262
3957
|
expect(fs.readFileSync(recreatedPath, "utf-8")).toBe("recreated after planning");
|
|
3263
|
-
const journal =
|
|
3958
|
+
const journal = journalAt(journalPath);
|
|
3264
3959
|
expect(journal.files["racy.md"]).toBeDefined();
|
|
3265
3960
|
expect(events).toContainEqual(expect.objectContaining({
|
|
3266
3961
|
type: "delete-refused-stale-etag",
|
|
@@ -3282,7 +3977,7 @@ describe("share", () => {
|
|
|
3282
3977
|
// genuine local==remote sync, so propagating the delete would destroy the
|
|
3283
3978
|
// (different) remote version. Must be REFUSED, not propagated.
|
|
3284
3979
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3285
|
-
|
|
3980
|
+
seedJournalAt(journalPath, ({
|
|
3286
3981
|
version: "2",
|
|
3287
3982
|
lastSync: new Date().toISOString(),
|
|
3288
3983
|
files: {
|
|
@@ -3317,7 +4012,7 @@ describe("share", () => {
|
|
|
3317
4012
|
// Refusal short-circuits before the currency HEAD — no network needed.
|
|
3318
4013
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
3319
4014
|
// Journal entry preserved, not silently dropped.
|
|
3320
|
-
const journal =
|
|
4015
|
+
const journal = journalAt(journalPath);
|
|
3321
4016
|
expect(journal.files["core/policies/kept.md"]).toBeDefined();
|
|
3322
4017
|
// Surfaced as a refusal with the divergent-local reason.
|
|
3323
4018
|
const refused = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
@@ -3334,7 +4029,7 @@ describe("share", () => {
|
|
|
3334
4029
|
// S3 console). Local copy also missing. Currency-gated should drop the
|
|
3335
4030
|
// journal entry without issuing a DELETE — the remote is already gone.
|
|
3336
4031
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3337
|
-
|
|
4032
|
+
seedJournalAt(journalPath, ({
|
|
3338
4033
|
version: "1",
|
|
3339
4034
|
lastSync: new Date().toISOString(),
|
|
3340
4035
|
files: {
|
|
@@ -3367,7 +4062,7 @@ describe("share", () => {
|
|
|
3367
4062
|
// from real deletes without re-counting events.
|
|
3368
4063
|
expect(result.filesTombstoned).toBe(1);
|
|
3369
4064
|
expect(result.filesRefusedStale).toBe(0);
|
|
3370
|
-
const journal =
|
|
4065
|
+
const journal = journalAt(journalPath);
|
|
3371
4066
|
expect(journal.files["removed-out-of-band.md"]).toBeUndefined();
|
|
3372
4067
|
// Synthetic progress event carries the tombstone marker. Without this,
|
|
3373
4068
|
// the tty stream renders tombstones identically to real deletes — operator
|
|
@@ -3388,7 +4083,7 @@ describe("share", () => {
|
|
|
3388
4083
|
// against. Refuse the delete in the safe direction; a future sync with
|
|
3389
4084
|
// a recorded etag can re-evaluate.
|
|
3390
4085
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3391
|
-
|
|
4086
|
+
seedJournalAt(journalPath, ({
|
|
3392
4087
|
version: "1",
|
|
3393
4088
|
lastSync: new Date().toISOString(),
|
|
3394
4089
|
files: {
|
|
@@ -3442,7 +4137,7 @@ describe("share", () => {
|
|
|
3442
4137
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3443
4138
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3444
4139
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3445
|
-
|
|
4140
|
+
seedJournalAt(journalPath, ({
|
|
3446
4141
|
version: "1",
|
|
3447
4142
|
lastSync: new Date().toISOString(),
|
|
3448
4143
|
files: {
|
|
@@ -3494,7 +4189,7 @@ describe("share", () => {
|
|
|
3494
4189
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3495
4190
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3496
4191
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3497
|
-
|
|
4192
|
+
seedJournalAt(journalPath, ({
|
|
3498
4193
|
version: "1",
|
|
3499
4194
|
lastSync: new Date().toISOString(),
|
|
3500
4195
|
files: {
|
|
@@ -3528,7 +4223,7 @@ describe("share", () => {
|
|
|
3528
4223
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3529
4224
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3530
4225
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3531
|
-
|
|
4226
|
+
seedJournalAt(journalPath, ({
|
|
3532
4227
|
version: "1",
|
|
3533
4228
|
lastSync: new Date().toISOString(),
|
|
3534
4229
|
files: {
|
|
@@ -3578,7 +4273,7 @@ describe("share", () => {
|
|
|
3578
4273
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3579
4274
|
const recreatedPath = path.join(companyRoot, "notes", "gone.md");
|
|
3580
4275
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3581
|
-
|
|
4276
|
+
seedJournalAt(journalPath, ({
|
|
3582
4277
|
version: "1",
|
|
3583
4278
|
lastSync: new Date().toISOString(),
|
|
3584
4279
|
files: {
|
|
@@ -3626,7 +4321,7 @@ describe("share", () => {
|
|
|
3626
4321
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3627
4322
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3628
4323
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3629
|
-
|
|
4324
|
+
seedJournalAt(journalPath, ({
|
|
3630
4325
|
version: "1",
|
|
3631
4326
|
lastSync: new Date().toISOString(),
|
|
3632
4327
|
files: {
|
|
@@ -3656,14 +4351,14 @@ describe("share", () => {
|
|
|
3656
4351
|
type: "delete-refused-stale-etag",
|
|
3657
4352
|
reason: "legacy-no-etag",
|
|
3658
4353
|
}));
|
|
3659
|
-
const journal =
|
|
4354
|
+
const journal = journalAt(journalPath);
|
|
3660
4355
|
expect(journal.files["notes/legacy.md"]).toBeDefined();
|
|
3661
4356
|
});
|
|
3662
4357
|
it("owned-only still requires an intent — it is the documented rollback knob", async () => {
|
|
3663
4358
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3664
4359
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3665
4360
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3666
|
-
|
|
4361
|
+
seedJournalAt(journalPath, ({
|
|
3667
4362
|
version: "1",
|
|
3668
4363
|
lastSync: new Date().toISOString(),
|
|
3669
4364
|
files: {
|
|
@@ -3850,7 +4545,7 @@ describe("share", () => {
|
|
|
3850
4545
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3851
4546
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3852
4547
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3853
|
-
|
|
4548
|
+
seedJournalAt(journalPath, ({
|
|
3854
4549
|
version: "1",
|
|
3855
4550
|
lastSync: new Date().toISOString(),
|
|
3856
4551
|
files: {
|
|
@@ -3891,7 +4586,7 @@ describe("share", () => {
|
|
|
3891
4586
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "regular.md");
|
|
3892
4587
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md");
|
|
3893
4588
|
// Both journal entries are removed by the delete loop's removeEntry call.
|
|
3894
|
-
const journal =
|
|
4589
|
+
const journal = journalAt(journalPath);
|
|
3895
4590
|
expect(journal.files["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md"]).toBeUndefined();
|
|
3896
4591
|
expect(journal.files["regular.md"]).toBeUndefined();
|
|
3897
4592
|
});
|
|
@@ -3906,7 +4601,7 @@ describe("share", () => {
|
|
|
3906
4601
|
process.env.HQ_STATE_DIR = stateDirPersonal;
|
|
3907
4602
|
try {
|
|
3908
4603
|
const journalPath = path.join(stateDirPersonal, "sync-journal.__hq_personal_vault__.json");
|
|
3909
|
-
|
|
4604
|
+
seedJournalAt(journalPath, ({
|
|
3910
4605
|
version: "1",
|
|
3911
4606
|
lastSync: new Date().toISOString(),
|
|
3912
4607
|
files: {
|
|
@@ -3932,7 +4627,7 @@ describe("share", () => {
|
|
|
3932
4627
|
});
|
|
3933
4628
|
expect(result.filesDeleted).toBe(1);
|
|
3934
4629
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "personal/.obsidian/graph.json.drift-1779863862-42519");
|
|
3935
|
-
const journal =
|
|
4630
|
+
const journal = journalAt(journalPath);
|
|
3936
4631
|
expect(journal.files["personal/.obsidian/graph.json.drift-1779863862-42519"]).toBeUndefined();
|
|
3937
4632
|
}
|
|
3938
4633
|
finally {
|
|
@@ -3955,7 +4650,7 @@ describe("share", () => {
|
|
|
3955
4650
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
3956
4651
|
direction: "up",
|
|
3957
4652
|
});
|
|
3958
|
-
|
|
4653
|
+
seedJournalAt(journalPath, ({
|
|
3959
4654
|
version: "1",
|
|
3960
4655
|
lastSync: new Date().toISOString(),
|
|
3961
4656
|
files: {
|
|
@@ -4003,7 +4698,7 @@ describe("share", () => {
|
|
|
4003
4698
|
expect(pruned[0].byReason["ignore-filtered"]).toBe(3);
|
|
4004
4699
|
expect(pruned[0].byReason["excluded-top-level"]).toBe(1);
|
|
4005
4700
|
// The persisted journal converged: excluded rows gone, legit row kept.
|
|
4006
|
-
const journal =
|
|
4701
|
+
const journal = journalAt(journalPath);
|
|
4007
4702
|
expect(Object.keys(journal.files)).toContain("personal/keep.md");
|
|
4008
4703
|
expect(journal.files[".claude/worktrees/wt/a.ts"]).toBeUndefined();
|
|
4009
4704
|
expect(journal.files["personal/node_modules/pkg/index.js"]).toBeUndefined();
|
|
@@ -4035,7 +4730,7 @@ describe("share", () => {
|
|
|
4035
4730
|
const key = "deleted-top-level.md";
|
|
4036
4731
|
const journalSlug = "personal-scoped-delete";
|
|
4037
4732
|
const journalPath = path.join(stateDir, `sync-journal.${journalSlug}.json`);
|
|
4038
|
-
|
|
4733
|
+
seedJournalAt(journalPath, ({
|
|
4039
4734
|
version: "2",
|
|
4040
4735
|
lastSync: new Date().toISOString(),
|
|
4041
4736
|
pulls: [],
|
|
@@ -4067,7 +4762,7 @@ describe("share", () => {
|
|
|
4067
4762
|
});
|
|
4068
4763
|
expect(result.filesDeleted).toBe(1);
|
|
4069
4764
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), key);
|
|
4070
|
-
expect(
|
|
4765
|
+
expect(journalAt(journalPath).files[key]).toBeUndefined();
|
|
4071
4766
|
});
|
|
4072
4767
|
// ── Decommission prefixes ──────────────────────────────────────────────────
|
|
4073
4768
|
//
|
|
@@ -4122,7 +4817,7 @@ describe("share", () => {
|
|
|
4122
4817
|
// Local file remains on disk — decommission is a remote-only sweep.
|
|
4123
4818
|
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "notes.md"))).toBe(true);
|
|
4124
4819
|
// Journal entry pruned in lockstep with the remote delete.
|
|
4125
|
-
const journalAfter =
|
|
4820
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4126
4821
|
expect(journalAfter.files["companies/foo/notes.md"]).toBeUndefined();
|
|
4127
4822
|
expect(journalAfter.files["knowledge/x.md"]).toBeDefined();
|
|
4128
4823
|
});
|
|
@@ -4158,7 +4853,7 @@ describe("share", () => {
|
|
|
4158
4853
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
4159
4854
|
// Journal entry preserved — the pulled-from-peer record stays so a
|
|
4160
4855
|
// future pull can still reconcile it correctly.
|
|
4161
|
-
const journalAfter =
|
|
4856
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4162
4857
|
expect(journalAfter.files["companies/foo/from-peer.md"]).toBeDefined();
|
|
4163
4858
|
});
|
|
4164
4859
|
it("decommissionPrefixes: dedupes with propagateDeletes plan — a key in both still emits one DeleteObject", async () => {
|
|
@@ -4278,7 +4973,7 @@ describe("share", () => {
|
|
|
4278
4973
|
expect(result.filesTombstoned).toBe(1);
|
|
4279
4974
|
expect(result.filesDeleted).toBe(0);
|
|
4280
4975
|
// Journal entry is gone.
|
|
4281
|
-
const journalAfter =
|
|
4976
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4282
4977
|
expect(journalAfter.files["companies/foo/gone.md"]).toBeUndefined();
|
|
4283
4978
|
});
|
|
4284
4979
|
describe("personalMode", () => {
|
|
@@ -4319,7 +5014,7 @@ describe("share", () => {
|
|
|
4319
5014
|
const acmeJournalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4320
5015
|
expect(fs.existsSync(personalJournalPath)).toBe(true);
|
|
4321
5016
|
expect(fs.existsSync(acmeJournalPath)).toBe(false);
|
|
4322
|
-
const journal =
|
|
5017
|
+
const journal = journalAt(personalJournalPath);
|
|
4323
5018
|
expect(journal.files["knowledge/notes.md"]).toBeDefined();
|
|
4324
5019
|
});
|
|
4325
5020
|
it("personalMode=true accepts files outside companies/<slug>/ (companion to the company-folder rejection)", async () => {
|
|
@@ -4607,7 +5302,7 @@ describe("share", () => {
|
|
|
4607
5302
|
.createHash("sha256")
|
|
4608
5303
|
.update(target)
|
|
4609
5304
|
.digest("hex");
|
|
4610
|
-
|
|
5305
|
+
seedJournalAt(journalPath, ({
|
|
4611
5306
|
version: "1",
|
|
4612
5307
|
lastSync: new Date().toISOString(),
|
|
4613
5308
|
files: {
|
|
@@ -5010,7 +5705,7 @@ describe("share", () => {
|
|
|
5010
5705
|
});
|
|
5011
5706
|
expect(pushed.filesUploaded).toBe(1);
|
|
5012
5707
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5013
|
-
const afterPush =
|
|
5708
|
+
const afterPush = journalAt(journalPath);
|
|
5014
5709
|
expect(Object.keys(afterPush.files)).toContain("knowledge/agents/brief.md");
|
|
5015
5710
|
vi.mocked(deleteRemoteFile).mockClear();
|
|
5016
5711
|
const walked = await share({
|
|
@@ -5024,7 +5719,7 @@ describe("share", () => {
|
|
|
5024
5719
|
expect(walked.filesDeleted).toBe(0);
|
|
5025
5720
|
const deletedKeys = vi.mocked(deleteRemoteFile).mock.calls.map((c) => c[1]);
|
|
5026
5721
|
expect(deletedKeys).not.toContain("knowledge/agents/brief.md");
|
|
5027
|
-
const afterWalk =
|
|
5722
|
+
const afterWalk = journalAt(journalPath);
|
|
5028
5723
|
expect(Object.keys(afterWalk.files)).toContain("knowledge/agents/brief.md");
|
|
5029
5724
|
});
|
|
5030
5725
|
});
|
|
@@ -5068,7 +5763,7 @@ describe("share", () => {
|
|
|
5068
5763
|
}
|
|
5069
5764
|
}
|
|
5070
5765
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5071
|
-
|
|
5766
|
+
seedJournalAt(journalPath, ({
|
|
5072
5767
|
version: "1",
|
|
5073
5768
|
lastSync: new Date().toISOString(),
|
|
5074
5769
|
files,
|
|
@@ -5126,7 +5821,7 @@ describe("share", () => {
|
|
|
5126
5821
|
expect(summary.samplePaths.length).toBeGreaterThan(0);
|
|
5127
5822
|
expect(summary.samplePaths.length).toBeLessThanOrEqual(10);
|
|
5128
5823
|
// Journal entries for the missing files SURVIVE — guard never mutates.
|
|
5129
|
-
const journal =
|
|
5824
|
+
const journal = journalAt(journalPath);
|
|
5130
5825
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5131
5826
|
// Per-key delete-refused-stale-etag events for each candidate.
|
|
5132
5827
|
const perKeyRefusals = events.filter((e) => e.type === "delete-refused-stale-etag" && e.reason === "bulk-asymmetry");
|
|
@@ -5176,7 +5871,7 @@ describe("share", () => {
|
|
|
5176
5871
|
expect(headedKeys).not.toContain(`f-${i.toString().padStart(4, "0")}.md`);
|
|
5177
5872
|
}
|
|
5178
5873
|
// Journal untouched — the entries stay recoverable.
|
|
5179
|
-
const journal =
|
|
5874
|
+
const journal = journalAt(journalPath);
|
|
5180
5875
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5181
5876
|
});
|
|
5182
5877
|
// The core of the #227 regression, isolated: pre-#227 a trip emptied
|
|
@@ -5207,7 +5902,7 @@ describe("share", () => {
|
|
|
5207
5902
|
const perKeyRefusals = events.filter((e) => e.type === "delete-refused-stale-etag" &&
|
|
5208
5903
|
e.reason === "bulk-asymmetry");
|
|
5209
5904
|
expect(perKeyRefusals.length).toBe(11);
|
|
5210
|
-
const journal =
|
|
5905
|
+
const journal = journalAt(journalPath);
|
|
5211
5906
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5212
5907
|
});
|
|
5213
5908
|
it("vault litter still drains when the breaker trips", async () => {
|
|
@@ -5221,7 +5916,7 @@ describe("share", () => {
|
|
|
5221
5916
|
withIntents: false,
|
|
5222
5917
|
});
|
|
5223
5918
|
const litterKey = "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md";
|
|
5224
|
-
const seeded =
|
|
5919
|
+
const seeded = journalAt(journalPath);
|
|
5225
5920
|
seeded.files[litterKey] = {
|
|
5226
5921
|
hash: "h",
|
|
5227
5922
|
size: 5,
|
|
@@ -5230,7 +5925,7 @@ describe("share", () => {
|
|
|
5230
5925
|
remoteEtag: "litter-etag",
|
|
5231
5926
|
kind: "file",
|
|
5232
5927
|
};
|
|
5233
|
-
|
|
5928
|
+
seedJournalAt(journalPath, seeded);
|
|
5234
5929
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
5235
5930
|
etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
|
|
5236
5931
|
lastModified: new Date(),
|
|
@@ -5370,7 +6065,7 @@ describe("share", () => {
|
|
|
5370
6065
|
expect(summary).toBeDefined();
|
|
5371
6066
|
expect(summary.ratio).toBeCloseTo(1.0, 5);
|
|
5372
6067
|
// Journal untouched.
|
|
5373
|
-
const journal =
|
|
6068
|
+
const journal = journalAt(journalPath);
|
|
5374
6069
|
expect(Object.keys(journal.files).length).toBe(50);
|
|
5375
6070
|
});
|
|
5376
6071
|
it("small absolute count (9/9 = 100% ratio but < MIN_ABS=10) does NOT trip: all 9 delete normally", async () => {
|
|
@@ -5414,7 +6109,7 @@ describe("share", () => {
|
|
|
5414
6109
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
5415
6110
|
expect(events.find((e) => e.type === "delete-refused-bulk-asymmetry")).toBeDefined();
|
|
5416
6111
|
// Journal entries survive.
|
|
5417
|
-
const journal =
|
|
6112
|
+
const journal = journalAt(journalPath);
|
|
5418
6113
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5419
6114
|
});
|
|
5420
6115
|
it("direction:'down' entries don't count toward the bulk numerator (owned-only silently skips them)", async () => {
|
|
@@ -5442,7 +6137,7 @@ describe("share", () => {
|
|
|
5442
6137
|
localDeleteIntent: deleteIntent("up-etag", "h"),
|
|
5443
6138
|
};
|
|
5444
6139
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5445
|
-
|
|
6140
|
+
seedJournalAt(journalPath, { version: "1", lastSync: now, files });
|
|
5446
6141
|
const result = await share({
|
|
5447
6142
|
paths: [companyRoot],
|
|
5448
6143
|
company: "acme",
|
|
@@ -5565,7 +6260,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5565
6260
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
5566
6261
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5567
6262
|
// v2-shaped fixture — same field set the production journal uses.
|
|
5568
|
-
|
|
6263
|
+
seedJournalAt(journalPath, ({
|
|
5569
6264
|
version: "2",
|
|
5570
6265
|
lastSync: new Date().toISOString(),
|
|
5571
6266
|
files: {
|
|
@@ -5611,7 +6306,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5611
6306
|
const { hashFile } = await import("../journal.js");
|
|
5612
6307
|
const baselineHash = hashFile(testFile);
|
|
5613
6308
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5614
|
-
|
|
6309
|
+
seedJournalAt(journalPath, ({
|
|
5615
6310
|
version: "2",
|
|
5616
6311
|
lastSync: new Date().toISOString(),
|
|
5617
6312
|
files: {
|
|
@@ -5652,7 +6347,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5652
6347
|
const originalLstat = fs.lstatSync(testFile);
|
|
5653
6348
|
// The recorded hash intentionally differs from the current bytes.
|
|
5654
6349
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5655
|
-
|
|
6350
|
+
seedJournalAt(journalPath, ({
|
|
5656
6351
|
version: "2",
|
|
5657
6352
|
lastSync: new Date().toISOString(),
|
|
5658
6353
|
files: {
|
|
@@ -5699,7 +6394,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5699
6394
|
const { hashFile } = await import("../journal.js");
|
|
5700
6395
|
const realHash = hashFile(testFile);
|
|
5701
6396
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5702
|
-
|
|
6397
|
+
seedJournalAt(journalPath, ({
|
|
5703
6398
|
version: "2",
|
|
5704
6399
|
lastSync: new Date().toISOString(),
|
|
5705
6400
|
files: {
|
|
@@ -5749,7 +6444,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5749
6444
|
const { hashFile } = await import("../journal.js");
|
|
5750
6445
|
const realHash = hashFile(testFile);
|
|
5751
6446
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5752
|
-
|
|
6447
|
+
seedJournalAt(journalPath, ({
|
|
5753
6448
|
version: "2",
|
|
5754
6449
|
lastSync: new Date().toISOString(),
|
|
5755
6450
|
files: {
|
|
@@ -5781,7 +6476,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5781
6476
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
5782
6477
|
// Journal mtimeMs is now the bumped value, while content hashing remains
|
|
5783
6478
|
// authoritative on the next sync.
|
|
5784
|
-
const journalAfter =
|
|
6479
|
+
const journalAfter = journalAt(journalPath);
|
|
5785
6480
|
expect(journalAfter.files["touched-then-fast.md"].mtimeMs).toBe(bumpedLstat.mtimeMs);
|
|
5786
6481
|
expect(journalAfter.files["touched-then-fast.md"].size).toBe(bumpedLstat.size);
|
|
5787
6482
|
// Content hash untouched — the file genuinely didn't change.
|
|
@@ -5801,7 +6496,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5801
6496
|
});
|
|
5802
6497
|
expect(result.filesUploaded).toBe(1);
|
|
5803
6498
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5804
|
-
const journal =
|
|
6499
|
+
const journal = journalAt(journalPath);
|
|
5805
6500
|
expect(journal.files["fresh.md"]).toBeDefined();
|
|
5806
6501
|
expect(journal.files["fresh.md"].mtimeMs).toBe(lstat.mtimeMs);
|
|
5807
6502
|
expect(journal.files["fresh.md"].size).toBe(lstat.size);
|
|
@@ -5818,7 +6513,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5818
6513
|
const realSize = fs.statSync(testFile).size;
|
|
5819
6514
|
// Real hash but NO mtimeMs. Hash matches → skip upload.
|
|
5820
6515
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5821
|
-
|
|
6516
|
+
seedJournalAt(journalPath, ({
|
|
5822
6517
|
version: "1",
|
|
5823
6518
|
lastSync: new Date().toISOString(),
|
|
5824
6519
|
files: {
|
|
@@ -6377,7 +7072,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6377
7072
|
// AND lost their etag/content-hash/stat journal metadata.
|
|
6378
7073
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6379
7074
|
expect(fs.existsSync(journalPath)).toBe(true);
|
|
6380
|
-
const journal =
|
|
7075
|
+
const journal = journalAt(journalPath);
|
|
6381
7076
|
const stampedKeys = Object.keys(journal.files);
|
|
6382
7077
|
expect(stampedKeys.length).toBe(FILE_COUNT - 1);
|
|
6383
7078
|
// The failed file is NOT in the journal (no successful upload).
|
|
@@ -6525,7 +7220,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6525
7220
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
6526
7221
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
6527
7222
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6528
|
-
|
|
7223
|
+
seedJournalAt(journalPath, ({
|
|
6529
7224
|
version: "1",
|
|
6530
7225
|
lastSync: new Date().toISOString(),
|
|
6531
7226
|
files: {
|
|
@@ -6554,7 +7249,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6554
7249
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
6555
7250
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
6556
7251
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
6557
|
-
const journal =
|
|
7252
|
+
const journal = journalAt(journalPath);
|
|
6558
7253
|
expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
|
|
6559
7254
|
});
|
|
6560
7255
|
it("defers an unreadable scope-invalid symlink tombstone without a fatal error", async () => {
|
|
@@ -6566,7 +7261,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6566
7261
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
6567
7262
|
fs.symlinkSync(targetDir, linkPath, "dir");
|
|
6568
7263
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6569
|
-
|
|
7264
|
+
seedJournalAt(journalPath, ({
|
|
6570
7265
|
version: "1",
|
|
6571
7266
|
lastSync: new Date().toISOString(),
|
|
6572
7267
|
files: {
|
|
@@ -6611,7 +7306,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6611
7306
|
samplePaths: [linkKey],
|
|
6612
7307
|
});
|
|
6613
7308
|
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
6614
|
-
const journal =
|
|
7309
|
+
const journal = journalAt(journalPath);
|
|
6615
7310
|
expect(journal.files[linkKey]).toBeDefined();
|
|
6616
7311
|
}
|
|
6617
7312
|
finally {
|