@indigoai-us/hq-cloud 6.15.20 → 6.15.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner.d.ts +1 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +37 -5
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +77 -4
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +36 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +139 -20
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +360 -108
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +6 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +59 -47
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal-delta-writes.test.d.ts +2 -0
- package/dist/journal-delta-writes.test.d.ts.map +1 -0
- package/dist/journal-delta-writes.test.js +140 -0
- package/dist/journal-delta-writes.test.js.map +1 -0
- package/dist/journal.d.ts +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/outcome-telemetry.d.ts +7 -1
- package/dist/outcome-telemetry.d.ts.map +1 -1
- package/dist/outcome-telemetry.js +15 -9
- package/dist/outcome-telemetry.js.map +1 -1
- package/dist/outcome-telemetry.test.js +15 -0
- package/dist/outcome-telemetry.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -8
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +43 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/telemetry.d.ts +7 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +67 -60
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +12 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +17 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/cli/share.test.js
CHANGED
|
@@ -55,7 +55,39 @@ import * as readline from "readline";
|
|
|
55
55
|
import { share, isForbiddenCompanyVaultKey, UnreachablePushPathsError, currentUploadBytes, _testing as shareTesting } from "./share.js";
|
|
56
56
|
import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink, WindowsSymlinkPrivilegeError } from "../s3.js";
|
|
57
57
|
import { VaultAuthError } from "../vault-client.js";
|
|
58
|
-
import { hashFile } from "../journal.js";
|
|
58
|
+
import { hashFile, readJournal, writeJournal } from "../journal.js";
|
|
59
|
+
/**
|
|
60
|
+
* Read a journal the way production does.
|
|
61
|
+
*
|
|
62
|
+
* These tests used to `JSON.parse` `sync-journal.<slug>.json` directly, back
|
|
63
|
+
* when that file held a full copy of the journal. It is now a small locator —
|
|
64
|
+
* the authority is the v3 state store — so the assertions below go through
|
|
65
|
+
* `readJournal`, which is what the sync engine itself uses. The slug is
|
|
66
|
+
* recovered from the path so each call site keeps naming the journal it means.
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* Seed a journal the way production does.
|
|
70
|
+
*
|
|
71
|
+
* These fixtures used to write `sync-journal.<slug>.json` by hand, which worked
|
|
72
|
+
* only while that file WAS the journal. It is a locator now, so a hand-written
|
|
73
|
+
* copy is simply ignored and the test silently runs against an empty journal.
|
|
74
|
+
* Going through `writeJournal` puts the state where the engine actually reads
|
|
75
|
+
* it, and migrates v1 fixtures on the way in exactly as a real upgrade would.
|
|
76
|
+
*/
|
|
77
|
+
function seedJournalAt(journalPath, journal) {
|
|
78
|
+
const slug = path
|
|
79
|
+
.basename(journalPath)
|
|
80
|
+
.replace(/^sync-journal\./, "")
|
|
81
|
+
.replace(/\.json$/, "");
|
|
82
|
+
writeJournal(slug, journal);
|
|
83
|
+
}
|
|
84
|
+
function journalAt(journalPath) {
|
|
85
|
+
const slug = path
|
|
86
|
+
.basename(journalPath)
|
|
87
|
+
.replace(/^sync-journal\./, "")
|
|
88
|
+
.replace(/\.json$/, "");
|
|
89
|
+
return readJournal(slug);
|
|
90
|
+
}
|
|
59
91
|
const mockConfig = {
|
|
60
92
|
apiUrl: "https://vault-api.test",
|
|
61
93
|
authToken: "test-jwt-token",
|
|
@@ -528,7 +560,7 @@ describe("share", () => {
|
|
|
528
560
|
const { hashFile } = await import("../journal.js");
|
|
529
561
|
const hash = hashFile(testFile);
|
|
530
562
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
531
|
-
|
|
563
|
+
seedJournalAt(journalPath, ({
|
|
532
564
|
version: "1",
|
|
533
565
|
lastSync: new Date().toISOString(),
|
|
534
566
|
files: {
|
|
@@ -559,7 +591,7 @@ describe("share", () => {
|
|
|
559
591
|
// Journal has a stale hash for this path — simulating "local has been
|
|
560
592
|
// edited since the last push".
|
|
561
593
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
562
|
-
|
|
594
|
+
seedJournalAt(journalPath, ({
|
|
563
595
|
version: "1",
|
|
564
596
|
lastSync: new Date().toISOString(),
|
|
565
597
|
files: {
|
|
@@ -581,6 +613,226 @@ describe("share", () => {
|
|
|
581
613
|
expect(result.filesUploaded).toBe(1);
|
|
582
614
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "changed.md", undefined, expect.anything());
|
|
583
615
|
});
|
|
616
|
+
// ── Stat fast path for change detection ─────────────────────────────────
|
|
617
|
+
// Push used to hash EVERY regular file every pass before deciding whether to
|
|
618
|
+
// skip it — half a million reads on a real vault — because stat metadata was
|
|
619
|
+
// considered untrustworthy: a tool can rewrite a file to the same length and
|
|
620
|
+
// restore its mtime, and a size+mtime check would then skip that edit
|
|
621
|
+
// forever.
|
|
622
|
+
//
|
|
623
|
+
// These tests pin the narrower claim the fast path actually rests on: size,
|
|
624
|
+
// mtime AND ctime together. ctime is the inode-change stamp, and userspace
|
|
625
|
+
// cannot restore it — `cp -p`, `touch -r` and `rsync --times` all reset mtime
|
|
626
|
+
// while ctime moves to now. So the same-length-plus-restored-mtime rewrite
|
|
627
|
+
// that the old comment warned about is still caught, without reading a byte.
|
|
628
|
+
//
|
|
629
|
+
// Differential proof: drop ctime from the comparison and the "restored mtime"
|
|
630
|
+
// test below fails — the edit is skipped and never uploaded.
|
|
631
|
+
describe("stat fast path for change detection", () => {
|
|
632
|
+
const CONTENT_A = "aaaaaaaaaa"; // 10 bytes
|
|
633
|
+
const CONTENT_B = "bbbbbbbbbb"; // 10 bytes — same length, different bytes
|
|
634
|
+
/** Write `body` at `key` under the acme company root; return its path. */
|
|
635
|
+
function writeFile(key, body) {
|
|
636
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
637
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
638
|
+
const abs = path.join(companyRoot, key);
|
|
639
|
+
fs.writeFileSync(abs, body);
|
|
640
|
+
return abs;
|
|
641
|
+
}
|
|
642
|
+
function seed(key, entry) {
|
|
643
|
+
seedJournalAt(path.join(stateDir, "sync-journal.acme.json"), {
|
|
644
|
+
version: "2",
|
|
645
|
+
lastSync: new Date().toISOString(),
|
|
646
|
+
files: { [key]: { syncedAt: new Date().toISOString(), direction: "up", ...entry } },
|
|
647
|
+
pulls: [],
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
function push(abs, onConflict) {
|
|
651
|
+
return share({
|
|
652
|
+
paths: [abs],
|
|
653
|
+
company: "acme",
|
|
654
|
+
vaultConfig: mockConfig,
|
|
655
|
+
hqRoot: tmpDir,
|
|
656
|
+
skipUnchanged: true,
|
|
657
|
+
...(onConflict ? { onConflict } : {}),
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
it("skips a file whose size, mtime and ctime all match — without reading it", async () => {
|
|
661
|
+
const abs = writeFile("untouched.md", CONTENT_A);
|
|
662
|
+
const stat = fs.lstatSync(abs);
|
|
663
|
+
// The recorded hash is deliberately WRONG. Hashing the file would find a
|
|
664
|
+
// mismatch and upload, so a skip here can only mean the bytes were never
|
|
665
|
+
// read — which is the whole point of the fast path.
|
|
666
|
+
seed("untouched.md", {
|
|
667
|
+
hash: "deliberately-not-the-hash-of-this-file",
|
|
668
|
+
size: stat.size,
|
|
669
|
+
mtimeMs: stat.mtimeMs,
|
|
670
|
+
ctimeMs: stat.ctimeMs,
|
|
671
|
+
});
|
|
672
|
+
const result = await push(abs);
|
|
673
|
+
expect(result.filesSkipped).toBe(1);
|
|
674
|
+
expect(result.filesUploaded).toBe(0);
|
|
675
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
676
|
+
});
|
|
677
|
+
it("re-reads when the size differs", async () => {
|
|
678
|
+
const abs = writeFile("resized.md", CONTENT_A + "extra");
|
|
679
|
+
const stat = fs.lstatSync(abs);
|
|
680
|
+
seed("resized.md", {
|
|
681
|
+
hash: hashFile(abs) + "-stale",
|
|
682
|
+
size: CONTENT_A.length, // shorter than what is on disk now
|
|
683
|
+
mtimeMs: stat.mtimeMs,
|
|
684
|
+
ctimeMs: stat.ctimeMs,
|
|
685
|
+
});
|
|
686
|
+
const result = await push(abs);
|
|
687
|
+
expect(result.filesUploaded).toBe(1);
|
|
688
|
+
expect(uploadFile).toHaveBeenCalled();
|
|
689
|
+
});
|
|
690
|
+
it("re-reads when the mtime differs", async () => {
|
|
691
|
+
const abs = writeFile("retimed.md", CONTENT_B);
|
|
692
|
+
const stat = fs.lstatSync(abs);
|
|
693
|
+
seed("retimed.md", {
|
|
694
|
+
hash: "hash-of-the-previous-body",
|
|
695
|
+
size: stat.size, // same length
|
|
696
|
+
mtimeMs: stat.mtimeMs - 60_000, // journal remembers an older edit time
|
|
697
|
+
ctimeMs: stat.ctimeMs,
|
|
698
|
+
});
|
|
699
|
+
const result = await push(abs);
|
|
700
|
+
expect(result.filesUploaded).toBe(1);
|
|
701
|
+
expect(uploadFile).toHaveBeenCalled();
|
|
702
|
+
});
|
|
703
|
+
it("re-reads a same-length rewrite whose mtime was restored — ctime still moves", async () => {
|
|
704
|
+
// The exact case the old hash-everything comment existed to defend
|
|
705
|
+
// against, as produced by `cp -p`, `touch -r` or `rsync --times`.
|
|
706
|
+
const abs = writeFile("mtime-restored.md", CONTENT_A);
|
|
707
|
+
const before = fs.lstatSync(abs);
|
|
708
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
709
|
+
fs.writeFileSync(abs, CONTENT_B); // same length, different bytes
|
|
710
|
+
fs.utimesSync(abs, before.atime, before.mtime); // put the old mtime back
|
|
711
|
+
const after = fs.lstatSync(abs);
|
|
712
|
+
// Preconditions. The disguise worked on size, and as closely on mtime as
|
|
713
|
+
// the OS allows — `utimes` takes whole milliseconds, so a restored stamp
|
|
714
|
+
// lands within 1ms of the original rather than exactly on it. It failed
|
|
715
|
+
// on ctime, which is the only signal left.
|
|
716
|
+
expect(after.size).toBe(before.size);
|
|
717
|
+
expect(Math.abs(after.mtimeMs - before.mtimeMs)).toBeLessThan(1);
|
|
718
|
+
expect(after.ctimeMs).not.toBe(before.ctimeMs);
|
|
719
|
+
// Seed the mtime that is actually on disk, so mtime matches EXACTLY and
|
|
720
|
+
// cannot be what triggers the re-read. Only the stale ctime can.
|
|
721
|
+
seed("mtime-restored.md", {
|
|
722
|
+
hash: "hash-of-content-a",
|
|
723
|
+
size: before.size,
|
|
724
|
+
mtimeMs: after.mtimeMs,
|
|
725
|
+
ctimeMs: before.ctimeMs,
|
|
726
|
+
});
|
|
727
|
+
const result = await push(abs);
|
|
728
|
+
expect(result.filesUploaded).toBe(1);
|
|
729
|
+
expect(uploadFile).toHaveBeenCalled();
|
|
730
|
+
});
|
|
731
|
+
it("falls back to reading when the journal predates stat tracking, then stamps it", async () => {
|
|
732
|
+
// Entries written before ctime was recorded carry no stat metadata. They
|
|
733
|
+
// must not be skipped on faith; they are read once and stamped so the
|
|
734
|
+
// next pass takes the fast path.
|
|
735
|
+
const abs = writeFile("legacy.md", CONTENT_A);
|
|
736
|
+
seed("legacy.md", { hash: hashFile(abs), size: CONTENT_A.length });
|
|
737
|
+
const result = await push(abs);
|
|
738
|
+
expect(result.filesSkipped).toBe(1);
|
|
739
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
740
|
+
const entry = journalAt(path.join(stateDir, "sync-journal.acme.json")).files["legacy.md"];
|
|
741
|
+
const stat = fs.lstatSync(abs);
|
|
742
|
+
expect(entry.mtimeMs).toBe(stat.mtimeMs);
|
|
743
|
+
expect(entry.ctimeMs).toBe(stat.ctimeMs);
|
|
744
|
+
});
|
|
745
|
+
it("never lets the fast path skip a localDiverges entry", async () => {
|
|
746
|
+
// A pull-kept divergent entry stores the LOCAL hash and local stat, so
|
|
747
|
+
// every field matches by construction. Skipping on that asserts "local
|
|
748
|
+
// matches remote", which is exactly what the flag exists to deny.
|
|
749
|
+
const abs = writeFile("divergent.md", CONTENT_A);
|
|
750
|
+
const stat = fs.lstatSync(abs);
|
|
751
|
+
seed("divergent.md", {
|
|
752
|
+
hash: hashFile(abs),
|
|
753
|
+
size: stat.size,
|
|
754
|
+
mtimeMs: stat.mtimeMs,
|
|
755
|
+
ctimeMs: stat.ctimeMs,
|
|
756
|
+
localDiverges: true,
|
|
757
|
+
});
|
|
758
|
+
// `keep` so the divergence resolves without an interactive prompt. Under
|
|
759
|
+
// `keep` the file is deliberately NOT uploaded, so the proof that the
|
|
760
|
+
// fast path did not swallow it is that it reached Stage 2 at all: a
|
|
761
|
+
// fast-path skip returns before any remote HEAD is issued.
|
|
762
|
+
await push(abs, "keep");
|
|
763
|
+
expect(headRemoteFile).toHaveBeenCalled();
|
|
764
|
+
});
|
|
765
|
+
it("stamps size, mtime and ctime on upload so the next pass can skip", async () => {
|
|
766
|
+
const abs = writeFile("fresh.md", CONTENT_A);
|
|
767
|
+
const result = await push(abs);
|
|
768
|
+
expect(result.filesUploaded).toBe(1);
|
|
769
|
+
const entry = journalAt(path.join(stateDir, "sync-journal.acme.json")).files["fresh.md"];
|
|
770
|
+
const stat = fs.lstatSync(abs);
|
|
771
|
+
expect(entry.size).toBe(stat.size);
|
|
772
|
+
expect(entry.mtimeMs).toBe(stat.mtimeMs);
|
|
773
|
+
expect(entry.ctimeMs).toBe(stat.ctimeMs);
|
|
774
|
+
});
|
|
775
|
+
it("does not stamp ctime when the file changes between the hash and the stamp", async () => {
|
|
776
|
+
// Codex P1. Stage 1 hashes the file; the stamp happens later, after the
|
|
777
|
+
// upload. If the bytes change in between, pairing the OLD hash with the
|
|
778
|
+
// NEW timestamps would make every later pass skip the new body on stat
|
|
779
|
+
// alone — the edit would never upload. Withholding ctime forces the next
|
|
780
|
+
// pass to hash, which is the pre-fast-path behaviour.
|
|
781
|
+
const abs = writeFile("edited-mid-flight.md", CONTENT_A);
|
|
782
|
+
// Land the edit inside the exact window: Stage 1 has hashed the file, and
|
|
783
|
+
// the asynchronous remote probe runs before the stamp is taken.
|
|
784
|
+
let mutated = false;
|
|
785
|
+
vi.mocked(headRemoteFile).mockImplementation(async () => {
|
|
786
|
+
if (!mutated) {
|
|
787
|
+
mutated = true;
|
|
788
|
+
fs.writeFileSync(abs, CONTENT_B); // same length, different bytes
|
|
789
|
+
}
|
|
790
|
+
return null;
|
|
791
|
+
});
|
|
792
|
+
await push(abs);
|
|
793
|
+
const entry = journalAt(path.join(stateDir, "sync-journal.acme.json")).files["edited-mid-flight.md"];
|
|
794
|
+
expect(entry.ctimeMs).toBeUndefined();
|
|
795
|
+
// Proof it is not stranded: the next pass reads it and ships the new body.
|
|
796
|
+
vi.mocked(uploadFile).mockClear();
|
|
797
|
+
const second = await push(abs);
|
|
798
|
+
expect(second.filesUploaded).toBe(1);
|
|
799
|
+
expect(uploadFile).toHaveBeenCalled();
|
|
800
|
+
});
|
|
801
|
+
it("distrusts whole-second timestamps as a coarse-filesystem signal", () => {
|
|
802
|
+
// Codex P2. FAT/exFAT, some FUSE mounts and cached network filesystems
|
|
803
|
+
// report timestamps rounded to whole seconds, so a same-size rewrite
|
|
804
|
+
// inside one tick can leave both mtime and ctime equal to the journal's
|
|
805
|
+
// and be skipped indefinitely. Every filesystem HQ can trust here (APFS,
|
|
806
|
+
// ext4, NTFS) records sub-second precision, so a pair that is exactly on
|
|
807
|
+
// the second is treated as untrustworthy and sent back to hashing.
|
|
808
|
+
const abs = writeFile("coarse.md", CONTENT_A);
|
|
809
|
+
const coarse = { size: CONTENT_A.length, mtimeMs: 1_700_000_000_000, ctimeMs: 1_700_000_000_000 };
|
|
810
|
+
const spy = vi.spyOn(fs, "lstatSync").mockReturnValue({
|
|
811
|
+
isFile: () => true,
|
|
812
|
+
...coarse,
|
|
813
|
+
});
|
|
814
|
+
try {
|
|
815
|
+
expect(shareTesting.statMatchesJournal(abs, {
|
|
816
|
+
hash: "h",
|
|
817
|
+
direction: "up",
|
|
818
|
+
syncedAt: new Date().toISOString(),
|
|
819
|
+
...coarse,
|
|
820
|
+
})).toBe(false);
|
|
821
|
+
// Same values, one of them sub-second: a real filesystem, so trusted.
|
|
822
|
+
const fine = { ...coarse, ctimeMs: coarse.ctimeMs + 0.5 };
|
|
823
|
+
spy.mockReturnValue({ isFile: () => true, ...fine });
|
|
824
|
+
expect(shareTesting.statMatchesJournal(abs, {
|
|
825
|
+
hash: "h",
|
|
826
|
+
direction: "up",
|
|
827
|
+
syncedAt: new Date().toISOString(),
|
|
828
|
+
...fine,
|
|
829
|
+
})).toBe(true);
|
|
830
|
+
}
|
|
831
|
+
finally {
|
|
832
|
+
spy.mockRestore();
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
});
|
|
584
836
|
// ── Push-side FILE_TOMBSTONE consult (delete-resync, 3B) ────────────────────
|
|
585
837
|
// An authoritative delete (`hq files delete`) writes a FILE_TOMBSTONE and
|
|
586
838
|
// removes the S3 object. Without a push-side consult, a behind peer that still
|
|
@@ -593,7 +845,7 @@ describe("share", () => {
|
|
|
593
845
|
// share.ts makes the first test fail (uploadFile IS called → resurrection).
|
|
594
846
|
describe("push-side tombstone consult", () => {
|
|
595
847
|
function seedJournal(key, hash, size) {
|
|
596
|
-
|
|
848
|
+
seedJournalAt(path.join(stateDir, "sync-journal.acme.json"), ({
|
|
597
849
|
version: "1",
|
|
598
850
|
lastSync: new Date().toISOString(),
|
|
599
851
|
files: {
|
|
@@ -737,7 +989,7 @@ describe("share", () => {
|
|
|
737
989
|
size: 99,
|
|
738
990
|
});
|
|
739
991
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
740
|
-
|
|
992
|
+
seedJournalAt(journalPath, ({
|
|
741
993
|
version: "1",
|
|
742
994
|
lastSync: new Date().toISOString(),
|
|
743
995
|
files: {
|
|
@@ -789,7 +1041,7 @@ describe("share", () => {
|
|
|
789
1041
|
// assert it is never consulted below — the skip is cheap, and a leftover
|
|
790
1042
|
// mockResolvedValueOnce would otherwise leak into a later test's HEAD queue.
|
|
791
1043
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
792
|
-
|
|
1044
|
+
seedJournalAt(journalPath, ({
|
|
793
1045
|
version: "1",
|
|
794
1046
|
lastSync: new Date().toISOString(),
|
|
795
1047
|
files: {
|
|
@@ -969,7 +1221,7 @@ describe("share", () => {
|
|
|
969
1221
|
direction: "push",
|
|
970
1222
|
});
|
|
971
1223
|
expect(fs.readdirSync(companyRoot).filter((name) => name.includes(".conflict-"))).toEqual([]);
|
|
972
|
-
const journal =
|
|
1224
|
+
const journal = readJournal("acme");
|
|
973
1225
|
expect(journal.files["kms-scaffold.md"]).toMatchObject({
|
|
974
1226
|
direction: "up",
|
|
975
1227
|
remoteEtag: "288bfa91684ae21f9ae27c061254d412",
|
|
@@ -1085,7 +1337,7 @@ describe("share", () => {
|
|
|
1085
1337
|
size: 5,
|
|
1086
1338
|
});
|
|
1087
1339
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1088
|
-
|
|
1340
|
+
seedJournalAt(journalPath, ({
|
|
1089
1341
|
version: "1",
|
|
1090
1342
|
lastSync: syncedAt,
|
|
1091
1343
|
files: {
|
|
@@ -1158,7 +1410,7 @@ describe("share", () => {
|
|
|
1158
1410
|
// No journal stamp for the failed PUT — next pass re-evaluates fresh.
|
|
1159
1411
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1160
1412
|
if (fs.existsSync(journalPath)) {
|
|
1161
|
-
const journal =
|
|
1413
|
+
const journal = journalAt(journalPath);
|
|
1162
1414
|
expect(journal.files?.["raced.md"]).toBeUndefined();
|
|
1163
1415
|
}
|
|
1164
1416
|
});
|
|
@@ -1240,7 +1492,7 @@ describe("share", () => {
|
|
|
1240
1492
|
return { etag: '"should-not-land"' };
|
|
1241
1493
|
});
|
|
1242
1494
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1243
|
-
|
|
1495
|
+
seedJournalAt(journalPath, ({
|
|
1244
1496
|
version: "1",
|
|
1245
1497
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1246
1498
|
files: {
|
|
@@ -1300,7 +1552,7 @@ describe("share", () => {
|
|
|
1300
1552
|
});
|
|
1301
1553
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"new-upload-etag"' });
|
|
1302
1554
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1303
|
-
|
|
1555
|
+
seedJournalAt(journalPath, ({
|
|
1304
1556
|
version: "1",
|
|
1305
1557
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1306
1558
|
files: {
|
|
@@ -1335,7 +1587,7 @@ describe("share", () => {
|
|
|
1335
1587
|
size: 20,
|
|
1336
1588
|
});
|
|
1337
1589
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1338
|
-
|
|
1590
|
+
seedJournalAt(journalPath, ({
|
|
1339
1591
|
version: "1",
|
|
1340
1592
|
lastSync: new Date().toISOString(),
|
|
1341
1593
|
files: {
|
|
@@ -1377,7 +1629,7 @@ describe("share", () => {
|
|
|
1377
1629
|
size: 40,
|
|
1378
1630
|
});
|
|
1379
1631
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1380
|
-
|
|
1632
|
+
seedJournalAt(journalPath, ({
|
|
1381
1633
|
version: "1",
|
|
1382
1634
|
lastSync: new Date().toISOString(),
|
|
1383
1635
|
files: {
|
|
@@ -1423,7 +1675,7 @@ describe("share", () => {
|
|
|
1423
1675
|
});
|
|
1424
1676
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"forced-etag"' });
|
|
1425
1677
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1426
|
-
|
|
1678
|
+
seedJournalAt(journalPath, ({
|
|
1427
1679
|
version: "1",
|
|
1428
1680
|
lastSync: new Date().toISOString(),
|
|
1429
1681
|
files: {
|
|
@@ -1506,7 +1758,7 @@ describe("share", () => {
|
|
|
1506
1758
|
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1507
1759
|
// The entry is rewritten as an ordinary upload: local is now authority,
|
|
1508
1760
|
// so the journal-honesty flag must be gone rather than re-armed.
|
|
1509
|
-
const journal =
|
|
1761
|
+
const journal = journalAt(journalPath);
|
|
1510
1762
|
expect(journal.files["publish-divergent.md"].direction).toBe("up");
|
|
1511
1763
|
expect(journal.files["publish-divergent.md"].localDiverges).toBeFalsy();
|
|
1512
1764
|
expect(journal.files["publish-divergent.md"].remoteEtag).toBe("published-etag");
|
|
@@ -1559,7 +1811,7 @@ describe("share", () => {
|
|
|
1559
1811
|
expect(result.conflictPaths).toContain("publish-raced.md");
|
|
1560
1812
|
// Consent was for a remote that no longer exists, so the flag stays put
|
|
1561
1813
|
// and the operator is asked again rather than silently overruled.
|
|
1562
|
-
const journal =
|
|
1814
|
+
const journal = journalAt(journalPath);
|
|
1563
1815
|
expect(journal.files["publish-raced.md"].localDiverges).toBe(true);
|
|
1564
1816
|
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1565
1817
|
});
|
|
@@ -1614,7 +1866,7 @@ describe("share", () => {
|
|
|
1614
1866
|
});
|
|
1615
1867
|
expect(result.filesUploaded).toBe(1);
|
|
1616
1868
|
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1617
|
-
const journal =
|
|
1869
|
+
const journal = journalAt(journalPath);
|
|
1618
1870
|
expect(journal.files["stuck-under-skip-unchanged.md"].direction).toBe("up");
|
|
1619
1871
|
expect(journal.files["stuck-under-skip-unchanged.md"].localDiverges).toBeFalsy();
|
|
1620
1872
|
});
|
|
@@ -1704,7 +1956,7 @@ describe("share", () => {
|
|
|
1704
1956
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
1705
1957
|
expect(result.filesUploaded).toBe(0);
|
|
1706
1958
|
expect(result.conflictPaths).toContain("peer-deleted.md");
|
|
1707
|
-
const journal =
|
|
1959
|
+
const journal = journalAt(journalPath);
|
|
1708
1960
|
expect(journal.files["peer-deleted.md"].localDiverges).toBe(true);
|
|
1709
1961
|
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1710
1962
|
});
|
|
@@ -1765,7 +2017,7 @@ describe("share", () => {
|
|
|
1765
2017
|
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
1766
2018
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"recreated-etag"' });
|
|
1767
2019
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1768
|
-
|
|
2020
|
+
seedJournalAt(journalPath, ({
|
|
1769
2021
|
version: "1",
|
|
1770
2022
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1771
2023
|
files: {
|
|
@@ -1795,7 +2047,7 @@ describe("share", () => {
|
|
|
1795
2047
|
const pc = vi.mocked(uploadFile).mock.calls[0][4];
|
|
1796
2048
|
expect(pc?.ifMatch).toBeUndefined();
|
|
1797
2049
|
expect(pc?.ifNoneMatch).toBe("*");
|
|
1798
|
-
const journal =
|
|
2050
|
+
const journal = journalAt(journalPath);
|
|
1799
2051
|
expect(journal.files["recreate-deleted.md"]).toMatchObject({
|
|
1800
2052
|
hash: localHash,
|
|
1801
2053
|
remoteEtag: "recreated-etag",
|
|
@@ -1816,7 +2068,7 @@ describe("share", () => {
|
|
|
1816
2068
|
return undefined;
|
|
1817
2069
|
});
|
|
1818
2070
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1819
|
-
|
|
2071
|
+
seedJournalAt(journalPath, ({
|
|
1820
2072
|
version: "1",
|
|
1821
2073
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1822
2074
|
files: {
|
|
@@ -1848,7 +2100,7 @@ describe("share", () => {
|
|
|
1848
2100
|
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifNoneMatch: "*" });
|
|
1849
2101
|
// Journal not advanced to a successful upload etag (entry may still be
|
|
1850
2102
|
// the prior baseline — next pass re-evaluates).
|
|
1851
|
-
const journal =
|
|
2103
|
+
const journal = journalAt(journalPath);
|
|
1852
2104
|
expect(journal.files["race-recreate.md"]?.remoteEtag).toBe("etag-before-delete");
|
|
1853
2105
|
expect(journal.files["race-recreate.md"]?.hash).toBe("old-hash");
|
|
1854
2106
|
expect(fs.readFileSync(testFile, "utf-8")).toBe("my recreated local");
|
|
@@ -2172,7 +2424,7 @@ describe("share", () => {
|
|
|
2172
2424
|
size: 5,
|
|
2173
2425
|
});
|
|
2174
2426
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2175
|
-
|
|
2427
|
+
seedJournalAt(journalPath, ({
|
|
2176
2428
|
version: "1",
|
|
2177
2429
|
lastSync: syncedAt,
|
|
2178
2430
|
files: {
|
|
@@ -2212,7 +2464,7 @@ describe("share", () => {
|
|
|
2212
2464
|
size: 5,
|
|
2213
2465
|
});
|
|
2214
2466
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2215
|
-
|
|
2467
|
+
seedJournalAt(journalPath, ({
|
|
2216
2468
|
version: "1",
|
|
2217
2469
|
lastSync: syncedAt,
|
|
2218
2470
|
files: {
|
|
@@ -2248,7 +2500,7 @@ describe("share", () => {
|
|
|
2248
2500
|
hqRoot: tmpDir,
|
|
2249
2501
|
});
|
|
2250
2502
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2251
|
-
const journal =
|
|
2503
|
+
const journal = journalAt(journalPath);
|
|
2252
2504
|
expect(journal.files["fresh.md"].remoteEtag).toBe("new-upload-etag");
|
|
2253
2505
|
});
|
|
2254
2506
|
it("forwards UploadAuthor to uploadFile when present (created-by metadata)", async () => {
|
|
@@ -2293,7 +2545,7 @@ describe("share", () => {
|
|
|
2293
2545
|
const { hashFile } = await import("../journal.js");
|
|
2294
2546
|
const hash = hashFile(testFile);
|
|
2295
2547
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2296
|
-
|
|
2548
|
+
seedJournalAt(journalPath, ({
|
|
2297
2549
|
version: "1",
|
|
2298
2550
|
lastSync: new Date().toISOString(),
|
|
2299
2551
|
files: {
|
|
@@ -2513,7 +2765,7 @@ describe("share", () => {
|
|
|
2513
2765
|
.update("stable content")
|
|
2514
2766
|
.digest("hex");
|
|
2515
2767
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2516
|
-
|
|
2768
|
+
seedJournalAt(journalPath, ({
|
|
2517
2769
|
version: "1",
|
|
2518
2770
|
lastSync: new Date().toISOString(),
|
|
2519
2771
|
files: {
|
|
@@ -2574,7 +2826,7 @@ describe("share", () => {
|
|
|
2574
2826
|
// not be queued for delete.
|
|
2575
2827
|
fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
|
|
2576
2828
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2577
|
-
|
|
2829
|
+
seedJournalAt(journalPath, ({
|
|
2578
2830
|
version: "1",
|
|
2579
2831
|
lastSync: new Date().toISOString(),
|
|
2580
2832
|
files: {
|
|
@@ -2611,7 +2863,7 @@ describe("share", () => {
|
|
|
2611
2863
|
operation: "delete",
|
|
2612
2864
|
});
|
|
2613
2865
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
|
|
2614
|
-
const journal =
|
|
2866
|
+
const journal = journalAt(journalPath);
|
|
2615
2867
|
expect(journal.files["knowledge"]).toBeUndefined();
|
|
2616
2868
|
});
|
|
2617
2869
|
it("propagateDeletes: a dangling local symlink is NOT classified as gone (lstat, not existsSync)", async () => {
|
|
@@ -2628,7 +2880,7 @@ describe("share", () => {
|
|
|
2628
2880
|
const danglingLink = path.join(companyRoot, "dangling-link.md");
|
|
2629
2881
|
fs.symlinkSync("./missing-target.md", danglingLink);
|
|
2630
2882
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2631
|
-
|
|
2883
|
+
seedJournalAt(journalPath, ({
|
|
2632
2884
|
version: "1",
|
|
2633
2885
|
lastSync: new Date().toISOString(),
|
|
2634
2886
|
files: {
|
|
@@ -2658,7 +2910,7 @@ describe("share", () => {
|
|
|
2658
2910
|
expect(result.filesDeleted).toBe(0);
|
|
2659
2911
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2660
2912
|
// Journal entry must survive the run.
|
|
2661
|
-
const journal =
|
|
2913
|
+
const journal = journalAt(journalPath);
|
|
2662
2914
|
expect(journal.files["dangling-link.md"]).toBeDefined();
|
|
2663
2915
|
});
|
|
2664
2916
|
it("vault-litter drain: a `.drift-*` marker with direction:'down' and NO delete intent tombstones " +
|
|
@@ -2723,7 +2975,7 @@ describe("share", () => {
|
|
|
2723
2975
|
});
|
|
2724
2976
|
expect(result.filesDeleted).toBe(1);
|
|
2725
2977
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "personal/scripts/run-project.sh.drift-1779863862-42519");
|
|
2726
|
-
const journal =
|
|
2978
|
+
const journal = journalAt(journalPath);
|
|
2727
2979
|
expect(journal.files["personal/scripts/run-project.sh.drift-1779863862-42519"]).toBeUndefined();
|
|
2728
2980
|
}
|
|
2729
2981
|
finally {
|
|
@@ -2796,7 +3048,7 @@ describe("share", () => {
|
|
|
2796
3048
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2797
3049
|
// No file on disk under companies/acme/peer-uploaded.md → local missing.
|
|
2798
3050
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2799
|
-
|
|
3051
|
+
seedJournalAt(journalPath, ({
|
|
2800
3052
|
version: "1",
|
|
2801
3053
|
lastSync: new Date().toISOString(),
|
|
2802
3054
|
files: {
|
|
@@ -2821,7 +3073,7 @@ describe("share", () => {
|
|
|
2821
3073
|
});
|
|
2822
3074
|
expect(result.filesDeleted).toBe(0);
|
|
2823
3075
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2824
|
-
const journal =
|
|
3076
|
+
const journal = journalAt(journalPath);
|
|
2825
3077
|
expect(journal.files["peer-uploaded.md"]).toBeDefined();
|
|
2826
3078
|
});
|
|
2827
3079
|
it("ROLLBACK KNOB: owned-only is honored on a prs_ personal vault — an intent-less entry with a " +
|
|
@@ -2912,7 +3164,7 @@ describe("share", () => {
|
|
|
2912
3164
|
]);
|
|
2913
3165
|
// Journal entry survives, so a later run (or a watcher-minted intent)
|
|
2914
3166
|
// can still authorize the delete properly.
|
|
2915
|
-
const journal =
|
|
3167
|
+
const journal = journalAt(journalPath);
|
|
2916
3168
|
expect(journal.files["personal/knowledge/gone.md"]).toBeDefined();
|
|
2917
3169
|
}
|
|
2918
3170
|
finally {
|
|
@@ -2929,7 +3181,7 @@ describe("share", () => {
|
|
|
2929
3181
|
// deleted by the user.
|
|
2930
3182
|
fs.writeFileSync(path.join(companyRoot, "kept.md"), "still here");
|
|
2931
3183
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2932
|
-
|
|
3184
|
+
seedJournalAt(journalPath, ({
|
|
2933
3185
|
version: "1",
|
|
2934
3186
|
lastSync: new Date().toISOString(),
|
|
2935
3187
|
files: {
|
|
@@ -2965,7 +3217,7 @@ describe("share", () => {
|
|
|
2965
3217
|
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
2966
3218
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "gone.md");
|
|
2967
3219
|
// Journal entry for the gone file is removed; the kept entry stays.
|
|
2968
|
-
const journal =
|
|
3220
|
+
const journal = journalAt(journalPath);
|
|
2969
3221
|
expect(journal.files["gone.md"]).toBeUndefined();
|
|
2970
3222
|
expect(journal.files["kept.md"]).toBeDefined();
|
|
2971
3223
|
});
|
|
@@ -2973,7 +3225,7 @@ describe("share", () => {
|
|
|
2973
3225
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2974
3226
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2975
3227
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2976
|
-
|
|
3228
|
+
seedJournalAt(journalPath, ({
|
|
2977
3229
|
version: "1",
|
|
2978
3230
|
lastSync: new Date().toISOString(),
|
|
2979
3231
|
files: {
|
|
@@ -3015,7 +3267,7 @@ describe("share", () => {
|
|
|
3015
3267
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3016
3268
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3017
3269
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3018
|
-
|
|
3270
|
+
seedJournalAt(journalPath, ({
|
|
3019
3271
|
version: "1",
|
|
3020
3272
|
lastSync: new Date().toISOString(),
|
|
3021
3273
|
files: {
|
|
@@ -3038,7 +3290,7 @@ describe("share", () => {
|
|
|
3038
3290
|
expect(result.filesDeleted).toBe(0);
|
|
3039
3291
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3040
3292
|
// Journal entry survives so the next opt-in run can still propagate.
|
|
3041
|
-
const journal =
|
|
3293
|
+
const journal = journalAt(journalPath);
|
|
3042
3294
|
expect(journal.files["gone.md"]).toBeDefined();
|
|
3043
3295
|
});
|
|
3044
3296
|
it("propagateDeletes: scope is constrained to the supplied paths — sibling deletes are not swept", async () => {
|
|
@@ -3046,7 +3298,7 @@ describe("share", () => {
|
|
|
3046
3298
|
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
3047
3299
|
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
3048
3300
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3049
|
-
|
|
3301
|
+
seedJournalAt(journalPath, ({
|
|
3050
3302
|
version: "1",
|
|
3051
3303
|
lastSync: new Date().toISOString(),
|
|
3052
3304
|
files: {
|
|
@@ -3081,7 +3333,7 @@ describe("share", () => {
|
|
|
3081
3333
|
expect(result.filesDeleted).toBe(1);
|
|
3082
3334
|
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
3083
3335
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
|
|
3084
|
-
const journal =
|
|
3336
|
+
const journal = journalAt(journalPath);
|
|
3085
3337
|
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
3086
3338
|
// Sibling tree's journal entry is untouched — `hq share <subtree>` must
|
|
3087
3339
|
// not act on files outside the named scope.
|
|
@@ -3097,7 +3349,7 @@ describe("share", () => {
|
|
|
3097
3349
|
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
3098
3350
|
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
3099
3351
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3100
|
-
|
|
3352
|
+
seedJournalAt(journalPath, ({
|
|
3101
3353
|
version: "1",
|
|
3102
3354
|
lastSync: new Date().toISOString(),
|
|
3103
3355
|
files: {
|
|
@@ -3132,7 +3384,7 @@ describe("share", () => {
|
|
|
3132
3384
|
});
|
|
3133
3385
|
expect(result.filesDeleted).toBe(1);
|
|
3134
3386
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
|
|
3135
|
-
const journal =
|
|
3387
|
+
const journal = journalAt(journalPath);
|
|
3136
3388
|
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
3137
3389
|
// Scope stays exactly as narrow as the absolute spelling's — no wider.
|
|
3138
3390
|
expect(journal.files["other/also-gone.md"]).toBeDefined();
|
|
@@ -3149,7 +3401,7 @@ describe("share", () => {
|
|
|
3149
3401
|
fs.mkdirSync(acmeRepo, { recursive: true });
|
|
3150
3402
|
fs.symlinkSync(acmeRepo, path.join(companyRoot, "knowledge"));
|
|
3151
3403
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3152
|
-
|
|
3404
|
+
seedJournalAt(journalPath, ({
|
|
3153
3405
|
version: "1",
|
|
3154
3406
|
lastSync: new Date().toISOString(),
|
|
3155
3407
|
files: {
|
|
@@ -3179,14 +3431,14 @@ describe("share", () => {
|
|
|
3179
3431
|
});
|
|
3180
3432
|
expect(result.filesDeleted).toBe(0);
|
|
3181
3433
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3182
|
-
const journal =
|
|
3434
|
+
const journal = journalAt(journalPath);
|
|
3183
3435
|
expect(journal.files["knowledge/agents/brief.md"]).toBeDefined();
|
|
3184
3436
|
});
|
|
3185
3437
|
it("propagateDeletes: a failed DeleteObject leaves the journal entry intact for retry", async () => {
|
|
3186
3438
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3187
3439
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3188
3440
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3189
|
-
|
|
3441
|
+
seedJournalAt(journalPath, ({
|
|
3190
3442
|
version: "1",
|
|
3191
3443
|
lastSync: new Date().toISOString(),
|
|
3192
3444
|
files: {
|
|
@@ -3219,7 +3471,7 @@ describe("share", () => {
|
|
|
3219
3471
|
const errorEvent = events.find((e) => e.type === "error");
|
|
3220
3472
|
expect(errorEvent).toMatchObject({ path: "flaky.md", message: expect.stringContaining("S3 down") });
|
|
3221
3473
|
// Entry survives — next run will retry the delete.
|
|
3222
|
-
const journal =
|
|
3474
|
+
const journal = journalAt(journalPath);
|
|
3223
3475
|
expect(journal.files["flaky.md"]).toBeDefined();
|
|
3224
3476
|
});
|
|
3225
3477
|
// ── Delete propagation safety: direction + filter guards (Bug B + C) ──────
|
|
@@ -3253,7 +3505,7 @@ describe("share", () => {
|
|
|
3253
3505
|
// and one this machine uploaded (direction:'up'). Only the 'up' one should
|
|
3254
3506
|
// be eligible for delete-propagation.
|
|
3255
3507
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3256
|
-
|
|
3508
|
+
seedJournalAt(journalPath, ({
|
|
3257
3509
|
version: "1",
|
|
3258
3510
|
lastSync: new Date().toISOString(),
|
|
3259
3511
|
files: {
|
|
@@ -3290,7 +3542,7 @@ describe("share", () => {
|
|
|
3290
3542
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3291
3543
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3292
3544
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3293
|
-
|
|
3545
|
+
seedJournalAt(journalPath, ({
|
|
3294
3546
|
version: "1",
|
|
3295
3547
|
lastSync: new Date().toISOString(),
|
|
3296
3548
|
files: {
|
|
@@ -3323,7 +3575,7 @@ describe("share", () => {
|
|
|
3323
3575
|
// absence is a filter outcome, not a user-intended delete.
|
|
3324
3576
|
fs.writeFileSync(path.join(tmpDir, ".hqignore"), "companies/acme/legacy/**\n");
|
|
3325
3577
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3326
|
-
|
|
3578
|
+
seedJournalAt(journalPath, ({
|
|
3327
3579
|
version: "1",
|
|
3328
3580
|
lastSync: new Date().toISOString(),
|
|
3329
3581
|
files: {
|
|
@@ -3382,7 +3634,7 @@ describe("share", () => {
|
|
|
3382
3634
|
// Under owned-only this would be stuck on remote forever; currency-gated
|
|
3383
3635
|
// checks the ETag and propagates the delete cleanly when match holds.
|
|
3384
3636
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3385
|
-
|
|
3637
|
+
seedJournalAt(journalPath, ({
|
|
3386
3638
|
version: "1",
|
|
3387
3639
|
lastSync: new Date().toISOString(),
|
|
3388
3640
|
files: {
|
|
@@ -3412,7 +3664,7 @@ describe("share", () => {
|
|
|
3412
3664
|
});
|
|
3413
3665
|
expect(result.filesDeleted).toBe(1);
|
|
3414
3666
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "core/policies/old.md");
|
|
3415
|
-
const journal =
|
|
3667
|
+
const journal = journalAt(journalPath);
|
|
3416
3668
|
expect(journal.files["core/policies/old.md"]).toBeUndefined();
|
|
3417
3669
|
});
|
|
3418
3670
|
it("currency-gated: propagates the S3 delete for a local-delete tombstone (pull-leg producer hand-off)", async () => {
|
|
@@ -3423,7 +3675,7 @@ describe("share", () => {
|
|
|
3423
3675
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3424
3676
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3425
3677
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3426
|
-
|
|
3678
|
+
seedJournalAt(journalPath, ({
|
|
3427
3679
|
version: "2",
|
|
3428
3680
|
lastSync: new Date().toISOString(),
|
|
3429
3681
|
files: {
|
|
@@ -3459,7 +3711,7 @@ describe("share", () => {
|
|
|
3459
3711
|
});
|
|
3460
3712
|
expect(result.filesDeleted).toBe(1);
|
|
3461
3713
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "docs/deleted.md");
|
|
3462
|
-
const journal =
|
|
3714
|
+
const journal = journalAt(journalPath);
|
|
3463
3715
|
expect(journal.files["docs/deleted.md"]).toBeUndefined();
|
|
3464
3716
|
});
|
|
3465
3717
|
it("currency-gated: refuses delete + emits stale-etag event when remote moved since last sync", async () => {
|
|
@@ -3468,7 +3720,7 @@ describe("share", () => {
|
|
|
3468
3720
|
// Another device modified `shared.md` after this device's last sync.
|
|
3469
3721
|
// The journal still records the old etag; HEAD returns the new one.
|
|
3470
3722
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3471
|
-
|
|
3723
|
+
seedJournalAt(journalPath, ({
|
|
3472
3724
|
version: "1",
|
|
3473
3725
|
lastSync: new Date().toISOString(),
|
|
3474
3726
|
files: {
|
|
@@ -3500,7 +3752,7 @@ describe("share", () => {
|
|
|
3500
3752
|
// No remote DELETE issued, journal entry preserved (pull will re-pull).
|
|
3501
3753
|
expect(result.filesDeleted).toBe(0);
|
|
3502
3754
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3503
|
-
const journal =
|
|
3755
|
+
const journal = journalAt(journalPath);
|
|
3504
3756
|
expect(journal.files["shared.md"]).toBeDefined();
|
|
3505
3757
|
// Dedicated event fired so UIs surface the refusal. `reason` field
|
|
3506
3758
|
// discriminates "stale-etag" (real drift) from "legacy-no-etag" so
|
|
@@ -3522,7 +3774,7 @@ describe("share", () => {
|
|
|
3522
3774
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3523
3775
|
const recreatedPath = path.join(companyRoot, "racy.md");
|
|
3524
3776
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3525
|
-
|
|
3777
|
+
seedJournalAt(journalPath, ({
|
|
3526
3778
|
version: "2",
|
|
3527
3779
|
lastSync: new Date().toISOString(),
|
|
3528
3780
|
files: {
|
|
@@ -3558,7 +3810,7 @@ describe("share", () => {
|
|
|
3558
3810
|
expect(result.filesDeleted).toBe(0);
|
|
3559
3811
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3560
3812
|
expect(fs.readFileSync(recreatedPath, "utf-8")).toBe("recreated after planning");
|
|
3561
|
-
const journal =
|
|
3813
|
+
const journal = journalAt(journalPath);
|
|
3562
3814
|
expect(journal.files["racy.md"]).toBeDefined();
|
|
3563
3815
|
expect(events).toContainEqual(expect.objectContaining({
|
|
3564
3816
|
type: "delete-refused-stale-etag",
|
|
@@ -3580,7 +3832,7 @@ describe("share", () => {
|
|
|
3580
3832
|
// genuine local==remote sync, so propagating the delete would destroy the
|
|
3581
3833
|
// (different) remote version. Must be REFUSED, not propagated.
|
|
3582
3834
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3583
|
-
|
|
3835
|
+
seedJournalAt(journalPath, ({
|
|
3584
3836
|
version: "2",
|
|
3585
3837
|
lastSync: new Date().toISOString(),
|
|
3586
3838
|
files: {
|
|
@@ -3615,7 +3867,7 @@ describe("share", () => {
|
|
|
3615
3867
|
// Refusal short-circuits before the currency HEAD — no network needed.
|
|
3616
3868
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
3617
3869
|
// Journal entry preserved, not silently dropped.
|
|
3618
|
-
const journal =
|
|
3870
|
+
const journal = journalAt(journalPath);
|
|
3619
3871
|
expect(journal.files["core/policies/kept.md"]).toBeDefined();
|
|
3620
3872
|
// Surfaced as a refusal with the divergent-local reason.
|
|
3621
3873
|
const refused = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
@@ -3632,7 +3884,7 @@ describe("share", () => {
|
|
|
3632
3884
|
// S3 console). Local copy also missing. Currency-gated should drop the
|
|
3633
3885
|
// journal entry without issuing a DELETE — the remote is already gone.
|
|
3634
3886
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3635
|
-
|
|
3887
|
+
seedJournalAt(journalPath, ({
|
|
3636
3888
|
version: "1",
|
|
3637
3889
|
lastSync: new Date().toISOString(),
|
|
3638
3890
|
files: {
|
|
@@ -3665,7 +3917,7 @@ describe("share", () => {
|
|
|
3665
3917
|
// from real deletes without re-counting events.
|
|
3666
3918
|
expect(result.filesTombstoned).toBe(1);
|
|
3667
3919
|
expect(result.filesRefusedStale).toBe(0);
|
|
3668
|
-
const journal =
|
|
3920
|
+
const journal = journalAt(journalPath);
|
|
3669
3921
|
expect(journal.files["removed-out-of-band.md"]).toBeUndefined();
|
|
3670
3922
|
// Synthetic progress event carries the tombstone marker. Without this,
|
|
3671
3923
|
// the tty stream renders tombstones identically to real deletes — operator
|
|
@@ -3686,7 +3938,7 @@ describe("share", () => {
|
|
|
3686
3938
|
// against. Refuse the delete in the safe direction; a future sync with
|
|
3687
3939
|
// a recorded etag can re-evaluate.
|
|
3688
3940
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3689
|
-
|
|
3941
|
+
seedJournalAt(journalPath, ({
|
|
3690
3942
|
version: "1",
|
|
3691
3943
|
lastSync: new Date().toISOString(),
|
|
3692
3944
|
files: {
|
|
@@ -3740,7 +3992,7 @@ describe("share", () => {
|
|
|
3740
3992
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3741
3993
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3742
3994
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3743
|
-
|
|
3995
|
+
seedJournalAt(journalPath, ({
|
|
3744
3996
|
version: "1",
|
|
3745
3997
|
lastSync: new Date().toISOString(),
|
|
3746
3998
|
files: {
|
|
@@ -3792,7 +4044,7 @@ describe("share", () => {
|
|
|
3792
4044
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3793
4045
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3794
4046
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3795
|
-
|
|
4047
|
+
seedJournalAt(journalPath, ({
|
|
3796
4048
|
version: "1",
|
|
3797
4049
|
lastSync: new Date().toISOString(),
|
|
3798
4050
|
files: {
|
|
@@ -3826,7 +4078,7 @@ describe("share", () => {
|
|
|
3826
4078
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3827
4079
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3828
4080
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3829
|
-
|
|
4081
|
+
seedJournalAt(journalPath, ({
|
|
3830
4082
|
version: "1",
|
|
3831
4083
|
lastSync: new Date().toISOString(),
|
|
3832
4084
|
files: {
|
|
@@ -3876,7 +4128,7 @@ describe("share", () => {
|
|
|
3876
4128
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3877
4129
|
const recreatedPath = path.join(companyRoot, "notes", "gone.md");
|
|
3878
4130
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3879
|
-
|
|
4131
|
+
seedJournalAt(journalPath, ({
|
|
3880
4132
|
version: "1",
|
|
3881
4133
|
lastSync: new Date().toISOString(),
|
|
3882
4134
|
files: {
|
|
@@ -3924,7 +4176,7 @@ describe("share", () => {
|
|
|
3924
4176
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3925
4177
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3926
4178
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3927
|
-
|
|
4179
|
+
seedJournalAt(journalPath, ({
|
|
3928
4180
|
version: "1",
|
|
3929
4181
|
lastSync: new Date().toISOString(),
|
|
3930
4182
|
files: {
|
|
@@ -3954,14 +4206,14 @@ describe("share", () => {
|
|
|
3954
4206
|
type: "delete-refused-stale-etag",
|
|
3955
4207
|
reason: "legacy-no-etag",
|
|
3956
4208
|
}));
|
|
3957
|
-
const journal =
|
|
4209
|
+
const journal = journalAt(journalPath);
|
|
3958
4210
|
expect(journal.files["notes/legacy.md"]).toBeDefined();
|
|
3959
4211
|
});
|
|
3960
4212
|
it("owned-only still requires an intent — it is the documented rollback knob", async () => {
|
|
3961
4213
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3962
4214
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3963
4215
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3964
|
-
|
|
4216
|
+
seedJournalAt(journalPath, ({
|
|
3965
4217
|
version: "1",
|
|
3966
4218
|
lastSync: new Date().toISOString(),
|
|
3967
4219
|
files: {
|
|
@@ -4148,7 +4400,7 @@ describe("share", () => {
|
|
|
4148
4400
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4149
4401
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4150
4402
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4151
|
-
|
|
4403
|
+
seedJournalAt(journalPath, ({
|
|
4152
4404
|
version: "1",
|
|
4153
4405
|
lastSync: new Date().toISOString(),
|
|
4154
4406
|
files: {
|
|
@@ -4189,7 +4441,7 @@ describe("share", () => {
|
|
|
4189
4441
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "regular.md");
|
|
4190
4442
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md");
|
|
4191
4443
|
// Both journal entries are removed by the delete loop's removeEntry call.
|
|
4192
|
-
const journal =
|
|
4444
|
+
const journal = journalAt(journalPath);
|
|
4193
4445
|
expect(journal.files["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md"]).toBeUndefined();
|
|
4194
4446
|
expect(journal.files["regular.md"]).toBeUndefined();
|
|
4195
4447
|
});
|
|
@@ -4204,7 +4456,7 @@ describe("share", () => {
|
|
|
4204
4456
|
process.env.HQ_STATE_DIR = stateDirPersonal;
|
|
4205
4457
|
try {
|
|
4206
4458
|
const journalPath = path.join(stateDirPersonal, "sync-journal.__hq_personal_vault__.json");
|
|
4207
|
-
|
|
4459
|
+
seedJournalAt(journalPath, ({
|
|
4208
4460
|
version: "1",
|
|
4209
4461
|
lastSync: new Date().toISOString(),
|
|
4210
4462
|
files: {
|
|
@@ -4230,7 +4482,7 @@ describe("share", () => {
|
|
|
4230
4482
|
});
|
|
4231
4483
|
expect(result.filesDeleted).toBe(1);
|
|
4232
4484
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "personal/.obsidian/graph.json.drift-1779863862-42519");
|
|
4233
|
-
const journal =
|
|
4485
|
+
const journal = journalAt(journalPath);
|
|
4234
4486
|
expect(journal.files["personal/.obsidian/graph.json.drift-1779863862-42519"]).toBeUndefined();
|
|
4235
4487
|
}
|
|
4236
4488
|
finally {
|
|
@@ -4253,7 +4505,7 @@ describe("share", () => {
|
|
|
4253
4505
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
4254
4506
|
direction: "up",
|
|
4255
4507
|
});
|
|
4256
|
-
|
|
4508
|
+
seedJournalAt(journalPath, ({
|
|
4257
4509
|
version: "1",
|
|
4258
4510
|
lastSync: new Date().toISOString(),
|
|
4259
4511
|
files: {
|
|
@@ -4301,7 +4553,7 @@ describe("share", () => {
|
|
|
4301
4553
|
expect(pruned[0].byReason["ignore-filtered"]).toBe(3);
|
|
4302
4554
|
expect(pruned[0].byReason["excluded-top-level"]).toBe(1);
|
|
4303
4555
|
// The persisted journal converged: excluded rows gone, legit row kept.
|
|
4304
|
-
const journal =
|
|
4556
|
+
const journal = journalAt(journalPath);
|
|
4305
4557
|
expect(Object.keys(journal.files)).toContain("personal/keep.md");
|
|
4306
4558
|
expect(journal.files[".claude/worktrees/wt/a.ts"]).toBeUndefined();
|
|
4307
4559
|
expect(journal.files["personal/node_modules/pkg/index.js"]).toBeUndefined();
|
|
@@ -4333,7 +4585,7 @@ describe("share", () => {
|
|
|
4333
4585
|
const key = "deleted-top-level.md";
|
|
4334
4586
|
const journalSlug = "personal-scoped-delete";
|
|
4335
4587
|
const journalPath = path.join(stateDir, `sync-journal.${journalSlug}.json`);
|
|
4336
|
-
|
|
4588
|
+
seedJournalAt(journalPath, ({
|
|
4337
4589
|
version: "2",
|
|
4338
4590
|
lastSync: new Date().toISOString(),
|
|
4339
4591
|
pulls: [],
|
|
@@ -4365,7 +4617,7 @@ describe("share", () => {
|
|
|
4365
4617
|
});
|
|
4366
4618
|
expect(result.filesDeleted).toBe(1);
|
|
4367
4619
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), key);
|
|
4368
|
-
expect(
|
|
4620
|
+
expect(journalAt(journalPath).files[key]).toBeUndefined();
|
|
4369
4621
|
});
|
|
4370
4622
|
// ── Decommission prefixes ──────────────────────────────────────────────────
|
|
4371
4623
|
//
|
|
@@ -4420,7 +4672,7 @@ describe("share", () => {
|
|
|
4420
4672
|
// Local file remains on disk — decommission is a remote-only sweep.
|
|
4421
4673
|
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "notes.md"))).toBe(true);
|
|
4422
4674
|
// Journal entry pruned in lockstep with the remote delete.
|
|
4423
|
-
const journalAfter =
|
|
4675
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4424
4676
|
expect(journalAfter.files["companies/foo/notes.md"]).toBeUndefined();
|
|
4425
4677
|
expect(journalAfter.files["knowledge/x.md"]).toBeDefined();
|
|
4426
4678
|
});
|
|
@@ -4456,7 +4708,7 @@ describe("share", () => {
|
|
|
4456
4708
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
4457
4709
|
// Journal entry preserved — the pulled-from-peer record stays so a
|
|
4458
4710
|
// future pull can still reconcile it correctly.
|
|
4459
|
-
const journalAfter =
|
|
4711
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4460
4712
|
expect(journalAfter.files["companies/foo/from-peer.md"]).toBeDefined();
|
|
4461
4713
|
});
|
|
4462
4714
|
it("decommissionPrefixes: dedupes with propagateDeletes plan — a key in both still emits one DeleteObject", async () => {
|
|
@@ -4576,7 +4828,7 @@ describe("share", () => {
|
|
|
4576
4828
|
expect(result.filesTombstoned).toBe(1);
|
|
4577
4829
|
expect(result.filesDeleted).toBe(0);
|
|
4578
4830
|
// Journal entry is gone.
|
|
4579
|
-
const journalAfter =
|
|
4831
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4580
4832
|
expect(journalAfter.files["companies/foo/gone.md"]).toBeUndefined();
|
|
4581
4833
|
});
|
|
4582
4834
|
describe("personalMode", () => {
|
|
@@ -4617,7 +4869,7 @@ describe("share", () => {
|
|
|
4617
4869
|
const acmeJournalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4618
4870
|
expect(fs.existsSync(personalJournalPath)).toBe(true);
|
|
4619
4871
|
expect(fs.existsSync(acmeJournalPath)).toBe(false);
|
|
4620
|
-
const journal =
|
|
4872
|
+
const journal = journalAt(personalJournalPath);
|
|
4621
4873
|
expect(journal.files["knowledge/notes.md"]).toBeDefined();
|
|
4622
4874
|
});
|
|
4623
4875
|
it("personalMode=true accepts files outside companies/<slug>/ (companion to the company-folder rejection)", async () => {
|
|
@@ -4905,7 +5157,7 @@ describe("share", () => {
|
|
|
4905
5157
|
.createHash("sha256")
|
|
4906
5158
|
.update(target)
|
|
4907
5159
|
.digest("hex");
|
|
4908
|
-
|
|
5160
|
+
seedJournalAt(journalPath, ({
|
|
4909
5161
|
version: "1",
|
|
4910
5162
|
lastSync: new Date().toISOString(),
|
|
4911
5163
|
files: {
|
|
@@ -5308,7 +5560,7 @@ describe("share", () => {
|
|
|
5308
5560
|
});
|
|
5309
5561
|
expect(pushed.filesUploaded).toBe(1);
|
|
5310
5562
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5311
|
-
const afterPush =
|
|
5563
|
+
const afterPush = journalAt(journalPath);
|
|
5312
5564
|
expect(Object.keys(afterPush.files)).toContain("knowledge/agents/brief.md");
|
|
5313
5565
|
vi.mocked(deleteRemoteFile).mockClear();
|
|
5314
5566
|
const walked = await share({
|
|
@@ -5322,7 +5574,7 @@ describe("share", () => {
|
|
|
5322
5574
|
expect(walked.filesDeleted).toBe(0);
|
|
5323
5575
|
const deletedKeys = vi.mocked(deleteRemoteFile).mock.calls.map((c) => c[1]);
|
|
5324
5576
|
expect(deletedKeys).not.toContain("knowledge/agents/brief.md");
|
|
5325
|
-
const afterWalk =
|
|
5577
|
+
const afterWalk = journalAt(journalPath);
|
|
5326
5578
|
expect(Object.keys(afterWalk.files)).toContain("knowledge/agents/brief.md");
|
|
5327
5579
|
});
|
|
5328
5580
|
});
|
|
@@ -5366,7 +5618,7 @@ describe("share", () => {
|
|
|
5366
5618
|
}
|
|
5367
5619
|
}
|
|
5368
5620
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5369
|
-
|
|
5621
|
+
seedJournalAt(journalPath, ({
|
|
5370
5622
|
version: "1",
|
|
5371
5623
|
lastSync: new Date().toISOString(),
|
|
5372
5624
|
files,
|
|
@@ -5424,7 +5676,7 @@ describe("share", () => {
|
|
|
5424
5676
|
expect(summary.samplePaths.length).toBeGreaterThan(0);
|
|
5425
5677
|
expect(summary.samplePaths.length).toBeLessThanOrEqual(10);
|
|
5426
5678
|
// Journal entries for the missing files SURVIVE — guard never mutates.
|
|
5427
|
-
const journal =
|
|
5679
|
+
const journal = journalAt(journalPath);
|
|
5428
5680
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5429
5681
|
// Per-key delete-refused-stale-etag events for each candidate.
|
|
5430
5682
|
const perKeyRefusals = events.filter((e) => e.type === "delete-refused-stale-etag" && e.reason === "bulk-asymmetry");
|
|
@@ -5474,7 +5726,7 @@ describe("share", () => {
|
|
|
5474
5726
|
expect(headedKeys).not.toContain(`f-${i.toString().padStart(4, "0")}.md`);
|
|
5475
5727
|
}
|
|
5476
5728
|
// Journal untouched — the entries stay recoverable.
|
|
5477
|
-
const journal =
|
|
5729
|
+
const journal = journalAt(journalPath);
|
|
5478
5730
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5479
5731
|
});
|
|
5480
5732
|
// The core of the #227 regression, isolated: pre-#227 a trip emptied
|
|
@@ -5505,7 +5757,7 @@ describe("share", () => {
|
|
|
5505
5757
|
const perKeyRefusals = events.filter((e) => e.type === "delete-refused-stale-etag" &&
|
|
5506
5758
|
e.reason === "bulk-asymmetry");
|
|
5507
5759
|
expect(perKeyRefusals.length).toBe(11);
|
|
5508
|
-
const journal =
|
|
5760
|
+
const journal = journalAt(journalPath);
|
|
5509
5761
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5510
5762
|
});
|
|
5511
5763
|
it("vault litter still drains when the breaker trips", async () => {
|
|
@@ -5519,7 +5771,7 @@ describe("share", () => {
|
|
|
5519
5771
|
withIntents: false,
|
|
5520
5772
|
});
|
|
5521
5773
|
const litterKey = "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md";
|
|
5522
|
-
const seeded =
|
|
5774
|
+
const seeded = journalAt(journalPath);
|
|
5523
5775
|
seeded.files[litterKey] = {
|
|
5524
5776
|
hash: "h",
|
|
5525
5777
|
size: 5,
|
|
@@ -5528,7 +5780,7 @@ describe("share", () => {
|
|
|
5528
5780
|
remoteEtag: "litter-etag",
|
|
5529
5781
|
kind: "file",
|
|
5530
5782
|
};
|
|
5531
|
-
|
|
5783
|
+
seedJournalAt(journalPath, seeded);
|
|
5532
5784
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
5533
5785
|
etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
|
|
5534
5786
|
lastModified: new Date(),
|
|
@@ -5668,7 +5920,7 @@ describe("share", () => {
|
|
|
5668
5920
|
expect(summary).toBeDefined();
|
|
5669
5921
|
expect(summary.ratio).toBeCloseTo(1.0, 5);
|
|
5670
5922
|
// Journal untouched.
|
|
5671
|
-
const journal =
|
|
5923
|
+
const journal = journalAt(journalPath);
|
|
5672
5924
|
expect(Object.keys(journal.files).length).toBe(50);
|
|
5673
5925
|
});
|
|
5674
5926
|
it("small absolute count (9/9 = 100% ratio but < MIN_ABS=10) does NOT trip: all 9 delete normally", async () => {
|
|
@@ -5712,7 +5964,7 @@ describe("share", () => {
|
|
|
5712
5964
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
5713
5965
|
expect(events.find((e) => e.type === "delete-refused-bulk-asymmetry")).toBeDefined();
|
|
5714
5966
|
// Journal entries survive.
|
|
5715
|
-
const journal =
|
|
5967
|
+
const journal = journalAt(journalPath);
|
|
5716
5968
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5717
5969
|
});
|
|
5718
5970
|
it("direction:'down' entries don't count toward the bulk numerator (owned-only silently skips them)", async () => {
|
|
@@ -5740,7 +5992,7 @@ describe("share", () => {
|
|
|
5740
5992
|
localDeleteIntent: deleteIntent("up-etag", "h"),
|
|
5741
5993
|
};
|
|
5742
5994
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5743
|
-
|
|
5995
|
+
seedJournalAt(journalPath, { version: "1", lastSync: now, files });
|
|
5744
5996
|
const result = await share({
|
|
5745
5997
|
paths: [companyRoot],
|
|
5746
5998
|
company: "acme",
|
|
@@ -5863,7 +6115,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5863
6115
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
5864
6116
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5865
6117
|
// v2-shaped fixture — same field set the production journal uses.
|
|
5866
|
-
|
|
6118
|
+
seedJournalAt(journalPath, ({
|
|
5867
6119
|
version: "2",
|
|
5868
6120
|
lastSync: new Date().toISOString(),
|
|
5869
6121
|
files: {
|
|
@@ -5909,7 +6161,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5909
6161
|
const { hashFile } = await import("../journal.js");
|
|
5910
6162
|
const baselineHash = hashFile(testFile);
|
|
5911
6163
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5912
|
-
|
|
6164
|
+
seedJournalAt(journalPath, ({
|
|
5913
6165
|
version: "2",
|
|
5914
6166
|
lastSync: new Date().toISOString(),
|
|
5915
6167
|
files: {
|
|
@@ -5950,7 +6202,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5950
6202
|
const originalLstat = fs.lstatSync(testFile);
|
|
5951
6203
|
// The recorded hash intentionally differs from the current bytes.
|
|
5952
6204
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5953
|
-
|
|
6205
|
+
seedJournalAt(journalPath, ({
|
|
5954
6206
|
version: "2",
|
|
5955
6207
|
lastSync: new Date().toISOString(),
|
|
5956
6208
|
files: {
|
|
@@ -5997,7 +6249,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5997
6249
|
const { hashFile } = await import("../journal.js");
|
|
5998
6250
|
const realHash = hashFile(testFile);
|
|
5999
6251
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6000
|
-
|
|
6252
|
+
seedJournalAt(journalPath, ({
|
|
6001
6253
|
version: "2",
|
|
6002
6254
|
lastSync: new Date().toISOString(),
|
|
6003
6255
|
files: {
|
|
@@ -6047,7 +6299,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6047
6299
|
const { hashFile } = await import("../journal.js");
|
|
6048
6300
|
const realHash = hashFile(testFile);
|
|
6049
6301
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6050
|
-
|
|
6302
|
+
seedJournalAt(journalPath, ({
|
|
6051
6303
|
version: "2",
|
|
6052
6304
|
lastSync: new Date().toISOString(),
|
|
6053
6305
|
files: {
|
|
@@ -6079,7 +6331,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6079
6331
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
6080
6332
|
// Journal mtimeMs is now the bumped value, while content hashing remains
|
|
6081
6333
|
// authoritative on the next sync.
|
|
6082
|
-
const journalAfter =
|
|
6334
|
+
const journalAfter = journalAt(journalPath);
|
|
6083
6335
|
expect(journalAfter.files["touched-then-fast.md"].mtimeMs).toBe(bumpedLstat.mtimeMs);
|
|
6084
6336
|
expect(journalAfter.files["touched-then-fast.md"].size).toBe(bumpedLstat.size);
|
|
6085
6337
|
// Content hash untouched — the file genuinely didn't change.
|
|
@@ -6099,7 +6351,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6099
6351
|
});
|
|
6100
6352
|
expect(result.filesUploaded).toBe(1);
|
|
6101
6353
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6102
|
-
const journal =
|
|
6354
|
+
const journal = journalAt(journalPath);
|
|
6103
6355
|
expect(journal.files["fresh.md"]).toBeDefined();
|
|
6104
6356
|
expect(journal.files["fresh.md"].mtimeMs).toBe(lstat.mtimeMs);
|
|
6105
6357
|
expect(journal.files["fresh.md"].size).toBe(lstat.size);
|
|
@@ -6116,7 +6368,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6116
6368
|
const realSize = fs.statSync(testFile).size;
|
|
6117
6369
|
// Real hash but NO mtimeMs. Hash matches → skip upload.
|
|
6118
6370
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6119
|
-
|
|
6371
|
+
seedJournalAt(journalPath, ({
|
|
6120
6372
|
version: "1",
|
|
6121
6373
|
lastSync: new Date().toISOString(),
|
|
6122
6374
|
files: {
|
|
@@ -6675,7 +6927,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6675
6927
|
// AND lost their etag/content-hash/stat journal metadata.
|
|
6676
6928
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6677
6929
|
expect(fs.existsSync(journalPath)).toBe(true);
|
|
6678
|
-
const journal =
|
|
6930
|
+
const journal = journalAt(journalPath);
|
|
6679
6931
|
const stampedKeys = Object.keys(journal.files);
|
|
6680
6932
|
expect(stampedKeys.length).toBe(FILE_COUNT - 1);
|
|
6681
6933
|
// The failed file is NOT in the journal (no successful upload).
|
|
@@ -6823,7 +7075,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6823
7075
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
6824
7076
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
6825
7077
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6826
|
-
|
|
7078
|
+
seedJournalAt(journalPath, ({
|
|
6827
7079
|
version: "1",
|
|
6828
7080
|
lastSync: new Date().toISOString(),
|
|
6829
7081
|
files: {
|
|
@@ -6852,7 +7104,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6852
7104
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
6853
7105
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
6854
7106
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
6855
|
-
const journal =
|
|
7107
|
+
const journal = journalAt(journalPath);
|
|
6856
7108
|
expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
|
|
6857
7109
|
});
|
|
6858
7110
|
it("defers an unreadable scope-invalid symlink tombstone without a fatal error", async () => {
|
|
@@ -6864,7 +7116,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6864
7116
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
6865
7117
|
fs.symlinkSync(targetDir, linkPath, "dir");
|
|
6866
7118
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6867
|
-
|
|
7119
|
+
seedJournalAt(journalPath, ({
|
|
6868
7120
|
version: "1",
|
|
6869
7121
|
lastSync: new Date().toISOString(),
|
|
6870
7122
|
files: {
|
|
@@ -6909,7 +7161,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6909
7161
|
samplePaths: [linkKey],
|
|
6910
7162
|
});
|
|
6911
7163
|
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
6912
|
-
const journal =
|
|
7164
|
+
const journal = journalAt(journalPath);
|
|
6913
7165
|
expect(journal.files[linkKey]).toBeDefined();
|
|
6914
7166
|
}
|
|
6915
7167
|
finally {
|