@indigoai-us/hq-cloud 6.15.30 → 6.15.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +24 -13
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.js +0 -71
- package/dist/bin/sync-runner-company.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +0 -10
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +13 -170
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -16
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +2 -40
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.test.js +1 -53
- package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +3 -15
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +13 -66
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +13 -433
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict.d.ts +2 -25
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +4 -29
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/share.d.ts +4 -40
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +86 -478
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +150 -845
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +18 -147
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +0 -20
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +29 -261
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +90 -519
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +0 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +25 -155
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +11 -29
- package/dist/journal.test.js.map +1 -1
- package/dist/object-io.d.ts +13 -34
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +29 -154
- package/dist/object-io.js.map +1 -1
- package/dist/outcome-telemetry.d.ts +1 -7
- package/dist/outcome-telemetry.d.ts.map +1 -1
- package/dist/outcome-telemetry.js +9 -15
- package/dist/outcome-telemetry.js.map +1 -1
- package/dist/outcome-telemetry.test.js +0 -15
- 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 +3 -4
- package/dist/outposts/client.js.map +1 -1
- package/dist/outposts/command.d.ts.map +1 -1
- package/dist/outposts/command.js +6 -9
- package/dist/outposts/command.js.map +1 -1
- package/dist/outposts/command.test.js +2 -59
- 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 +8 -10
- package/dist/outposts/remote-command.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +0 -14
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +0 -29
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +1 -22
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +1 -6
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +8 -14
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +0 -43
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sts-credential-refresh.test.js +2 -87
- 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 +1 -20
- package/dist/sync/state-store.js.map +1 -1
- package/dist/sync/state-store.test.js +0 -64
- package/dist/sync/state-store.test.js.map +1 -1
- package/dist/sync-core.d.ts.map +1 -1
- package/dist/sync-core.js +2 -7
- package/dist/sync-core.js.map +1 -1
- package/dist/sync-core.test.js +0 -36
- package/dist/sync-core.test.js.map +1 -1
- package/dist/telemetry.d.ts +6 -30
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +135 -331
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +0 -403
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +3 -26
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +0 -76
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +3 -105
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +1 -165
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/dist/bandwidth.d.ts +0 -128
- package/dist/bandwidth.d.ts.map +0 -1
- package/dist/bandwidth.js +0 -254
- package/dist/bandwidth.js.map +0 -1
- package/dist/bandwidth.test.d.ts +0 -2
- package/dist/bandwidth.test.d.ts.map +0 -1
- package/dist/bandwidth.test.js +0 -243
- package/dist/bandwidth.test.js.map +0 -1
- package/dist/journal-delta-writes.test.d.ts +0 -2
- package/dist/journal-delta-writes.test.d.ts.map +0 -1
- package/dist/journal-delta-writes.test.js +0 -140
- package/dist/journal-delta-writes.test.js.map +0 -1
package/dist/cli/share.test.js
CHANGED
|
@@ -53,49 +53,9 @@ 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,
|
|
56
|
+
import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink, WindowsSymlinkPrivilegeError } from "../s3.js";
|
|
57
57
|
import { VaultAuthError } from "../vault-client.js";
|
|
58
|
-
import { hashFile
|
|
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
|
-
}
|
|
58
|
+
import { hashFile } from "../journal.js";
|
|
99
59
|
const mockConfig = {
|
|
100
60
|
apiUrl: "https://vault-api.test",
|
|
101
61
|
authToken: "test-jwt-token",
|
|
@@ -409,74 +369,6 @@ describe("share", () => {
|
|
|
409
369
|
// Remote key must be company-relative, not hqRoot-relative
|
|
410
370
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "test.md", undefined, expect.anything());
|
|
411
371
|
});
|
|
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
|
-
});
|
|
480
372
|
it("propagates a per-file vault 401 to the runner auth boundary", async () => {
|
|
481
373
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
482
374
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -636,7 +528,7 @@ describe("share", () => {
|
|
|
636
528
|
const { hashFile } = await import("../journal.js");
|
|
637
529
|
const hash = hashFile(testFile);
|
|
638
530
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
639
|
-
|
|
531
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
640
532
|
version: "1",
|
|
641
533
|
lastSync: new Date().toISOString(),
|
|
642
534
|
files: {
|
|
@@ -667,7 +559,7 @@ describe("share", () => {
|
|
|
667
559
|
// Journal has a stale hash for this path — simulating "local has been
|
|
668
560
|
// edited since the last push".
|
|
669
561
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
670
|
-
|
|
562
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
671
563
|
version: "1",
|
|
672
564
|
lastSync: new Date().toISOString(),
|
|
673
565
|
files: {
|
|
@@ -689,226 +581,6 @@ describe("share", () => {
|
|
|
689
581
|
expect(result.filesUploaded).toBe(1);
|
|
690
582
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "changed.md", undefined, expect.anything());
|
|
691
583
|
});
|
|
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
|
-
});
|
|
912
584
|
// ── Push-side FILE_TOMBSTONE consult (delete-resync, 3B) ────────────────────
|
|
913
585
|
// An authoritative delete (`hq files delete`) writes a FILE_TOMBSTONE and
|
|
914
586
|
// removes the S3 object. Without a push-side consult, a behind peer that still
|
|
@@ -921,7 +593,7 @@ describe("share", () => {
|
|
|
921
593
|
// share.ts makes the first test fail (uploadFile IS called → resurrection).
|
|
922
594
|
describe("push-side tombstone consult", () => {
|
|
923
595
|
function seedJournal(key, hash, size) {
|
|
924
|
-
|
|
596
|
+
fs.writeFileSync(path.join(stateDir, "sync-journal.acme.json"), JSON.stringify({
|
|
925
597
|
version: "1",
|
|
926
598
|
lastSync: new Date().toISOString(),
|
|
927
599
|
files: {
|
|
@@ -958,8 +630,6 @@ describe("share", () => {
|
|
|
958
630
|
});
|
|
959
631
|
// The resurrection is blocked at the source: no upload for the tombstoned key.
|
|
960
632
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
961
|
-
expect(primeUploads).toHaveBeenCalledWith(expect.anything(), []);
|
|
962
|
-
expect(primeObjectTransport).toHaveBeenCalledWith(expect.anything(), "get", []);
|
|
963
633
|
expect(result.filesUploaded).toBe(0);
|
|
964
634
|
expect(result.filesSuppressedByTombstone).toBe(1);
|
|
965
635
|
expect(events.some((e) => e.type === "upload-suppressed-tombstone" && e.path === "docs/shared.md")).toBe(true);
|
|
@@ -1066,12 +736,8 @@ describe("share", () => {
|
|
|
1066
736
|
etag: '"remote-new-etag"',
|
|
1067
737
|
size: 99,
|
|
1068
738
|
});
|
|
1069
|
-
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1070
|
-
fs.writeFileSync(dest, "remote edit");
|
|
1071
|
-
return undefined;
|
|
1072
|
-
});
|
|
1073
739
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1074
|
-
|
|
740
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1075
741
|
version: "1",
|
|
1076
742
|
lastSync: new Date().toISOString(),
|
|
1077
743
|
files: {
|
|
@@ -1102,24 +768,6 @@ describe("share", () => {
|
|
|
1102
768
|
direction: "push",
|
|
1103
769
|
resolution: "keep",
|
|
1104
770
|
});
|
|
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");
|
|
1123
771
|
});
|
|
1124
772
|
it("cloud-authoritative: never pushes a stale local ontology/.last-run over the server value (one-sided-change clobber guard)", async () => {
|
|
1125
773
|
// Regression for the gardener watermark clobber. A second HQ root sharing this
|
|
@@ -1141,7 +789,7 @@ describe("share", () => {
|
|
|
1141
789
|
// assert it is never consulted below — the skip is cheap, and a leftover
|
|
1142
790
|
// mockResolvedValueOnce would otherwise leak into a later test's HEAD queue.
|
|
1143
791
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1144
|
-
|
|
792
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1145
793
|
version: "1",
|
|
1146
794
|
lastSync: new Date().toISOString(),
|
|
1147
795
|
files: {
|
|
@@ -1187,9 +835,9 @@ describe("share", () => {
|
|
|
1187
835
|
//
|
|
1188
836
|
// Fix: even on first-time upload, if HEAD shows remote exists and
|
|
1189
837
|
// its content (etag) differs from what we'd put, emit a conflict.
|
|
1190
|
-
// Under keep,
|
|
1191
|
-
// \`<orig>.conflict-<ts>-<short>\` so both versions
|
|
1192
|
-
// under abort, the company aborts before PUT.
|
|
838
|
+
// Under keep, the existing pull-side mirror routine downloads the
|
|
839
|
+
// remote bytes to \`<orig>.conflict-<ts>-<short>\` so both versions
|
|
840
|
+
// survive on disk; under abort, the company aborts before PUT.
|
|
1193
841
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1194
842
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1195
843
|
const testFile = path.join(companyRoot, "v7b-fresh.txt");
|
|
@@ -1201,10 +849,6 @@ describe("share", () => {
|
|
|
1201
849
|
etag: '"remote-version-etag"',
|
|
1202
850
|
size: 40,
|
|
1203
851
|
});
|
|
1204
|
-
vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, dest) => {
|
|
1205
|
-
fs.writeFileSync(dest, "peer remote content");
|
|
1206
|
-
return undefined;
|
|
1207
|
-
});
|
|
1208
852
|
// No journal file written — first-time upload contract.
|
|
1209
853
|
const events = [];
|
|
1210
854
|
const result = await share({
|
|
@@ -1216,19 +860,14 @@ describe("share", () => {
|
|
|
1216
860
|
onEvent: (e) => events.push(e),
|
|
1217
861
|
});
|
|
1218
862
|
expect(result.conflictPaths).toEqual(["v7b-fresh.txt"]);
|
|
1219
|
-
// Under keep,
|
|
1220
|
-
//
|
|
863
|
+
// Under keep, local stays as-is (no upload) and the mirror records
|
|
864
|
+
// the remote bytes so neither version is lost.
|
|
1221
865
|
expect(result.filesUploaded).toBe(0);
|
|
1222
866
|
const conflicts = events.filter((e) => typeof e === "object" && e !== null && e.type === "conflict");
|
|
1223
867
|
expect(conflicts).toHaveLength(1);
|
|
1224
868
|
expect(conflicts[0].path).toBe("v7b-fresh.txt");
|
|
1225
|
-
|
|
1226
|
-
expect(fs.readFileSync(
|
|
1227
|
-
const index = conflictIndexAt(tmpDir);
|
|
1228
|
-
expect(index.conflicts[0]).toMatchObject({
|
|
1229
|
-
originalPath: "companies/acme/v7b-fresh.txt",
|
|
1230
|
-
preserved: "local",
|
|
1231
|
-
});
|
|
869
|
+
// Local file unmodified — it must still contain the local content.
|
|
870
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("macos-fresh-collision-content");
|
|
1232
871
|
});
|
|
1233
872
|
it("first-time-upload-with-cloud-collision: aborts company under --on-conflict abort (Bug #7)", async () => {
|
|
1234
873
|
// Symmetric to the keep case: when --on-conflict abort is passed, a
|
|
@@ -1256,11 +895,11 @@ describe("share", () => {
|
|
|
1256
895
|
expect(result.filesUploaded).toBe(0);
|
|
1257
896
|
});
|
|
1258
897
|
it("HQ-DESKTOP-54: a privilege refusal while writing the push conflict mirror is a skip, not an error", async () => {
|
|
1259
|
-
//
|
|
1260
|
-
// On an unprivileged Windows host that throws the typed privilege
|
|
898
|
+
// The keep-mirror also materializes the remote object (to a .conflict-*
|
|
899
|
+
// file). On an unprivileged Windows host that throws the typed privilege
|
|
1261
900
|
// error; it must be downgraded to the deliberate skip so the push stays
|
|
1262
901
|
// complete and the runner exits 0 — never type:error. The conflict itself
|
|
1263
|
-
// is still
|
|
902
|
+
// is still recorded and the local file is untouched.
|
|
1264
903
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1265
904
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1266
905
|
const testFile = path.join(companyRoot, "v7b-priv.txt");
|
|
@@ -1271,7 +910,7 @@ describe("share", () => {
|
|
|
1271
910
|
size: 40,
|
|
1272
911
|
});
|
|
1273
912
|
// On a real unprivileged host BOTH the fresh-collision reconciled probe and
|
|
1274
|
-
// the keep
|
|
913
|
+
// the keep-mirror materialize the remote symlink, so both downloadFile calls
|
|
1275
914
|
// refuse. The probe's catch returns "differs" (conflict) without erroring;
|
|
1276
915
|
// the mirror must downgrade the refusal to the deliberate skip.
|
|
1277
916
|
vi.mocked(downloadFile)
|
|
@@ -1292,7 +931,7 @@ describe("share", () => {
|
|
|
1292
931
|
target: "../target.md",
|
|
1293
932
|
}));
|
|
1294
933
|
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
1295
|
-
// The conflict is still
|
|
934
|
+
// The conflict is still recorded; the local file is preserved.
|
|
1296
935
|
expect(result.conflictPaths).toEqual(["v7b-priv.txt"]);
|
|
1297
936
|
expect(fs.readFileSync(testFile, "utf-8")).toBe("local-collision-content");
|
|
1298
937
|
});
|
|
@@ -1330,7 +969,7 @@ describe("share", () => {
|
|
|
1330
969
|
direction: "push",
|
|
1331
970
|
});
|
|
1332
971
|
expect(fs.readdirSync(companyRoot).filter((name) => name.includes(".conflict-"))).toEqual([]);
|
|
1333
|
-
const journal =
|
|
972
|
+
const journal = JSON.parse(fs.readFileSync(path.join(stateDir, "sync-journal.acme.json"), "utf-8"));
|
|
1334
973
|
expect(journal.files["kms-scaffold.md"]).toMatchObject({
|
|
1335
974
|
direction: "up",
|
|
1336
975
|
remoteEtag: "288bfa91684ae21f9ae27c061254d412",
|
|
@@ -1404,7 +1043,7 @@ describe("share", () => {
|
|
|
1404
1043
|
etag: '"0123456789abcdef0123456789abcdef-3"',
|
|
1405
1044
|
size: 32,
|
|
1406
1045
|
});
|
|
1407
|
-
// The probe (and the subsequent keep
|
|
1046
|
+
// The probe (and the subsequent keep-mirror download) return DIFFERENT
|
|
1408
1047
|
// remote bytes.
|
|
1409
1048
|
vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, dest) => {
|
|
1410
1049
|
fs.writeFileSync(dest, "a-peers-different-version");
|
|
@@ -1423,8 +1062,8 @@ describe("share", () => {
|
|
|
1423
1062
|
expect(result.filesUploaded).toBe(0);
|
|
1424
1063
|
const conflicts = events.filter((e) => typeof e === "object" && e !== null && e.type === "conflict");
|
|
1425
1064
|
expect(conflicts).toHaveLength(1);
|
|
1426
|
-
|
|
1427
|
-
expect(fs.readFileSync(
|
|
1065
|
+
// Local file untouched under keep.
|
|
1066
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("my-local-version");
|
|
1428
1067
|
});
|
|
1429
1068
|
describe("conditional-write fence (If-Match / If-None-Match / 412)", () => {
|
|
1430
1069
|
const fence412 = () => Object.assign(new Error("Precondition failed"), {
|
|
@@ -1446,7 +1085,7 @@ describe("share", () => {
|
|
|
1446
1085
|
size: 5,
|
|
1447
1086
|
});
|
|
1448
1087
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1449
|
-
|
|
1088
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1450
1089
|
version: "1",
|
|
1451
1090
|
lastSync: syncedAt,
|
|
1452
1091
|
files: {
|
|
@@ -1484,18 +1123,12 @@ describe("share", () => {
|
|
|
1484
1123
|
});
|
|
1485
1124
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "brand-new.md", undefined, { ifNoneMatch: "*" });
|
|
1486
1125
|
});
|
|
1487
|
-
it("412 under keep:
|
|
1126
|
+
it("412 under keep: conflict event + remote mirrored + ledger entry + NO journal stamp", async () => {
|
|
1488
1127
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1489
1128
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1490
1129
|
const testFile = path.join(companyRoot, "raced.md");
|
|
1491
1130
|
fs.writeFileSync(testFile, "my local bytes");
|
|
1492
|
-
vi.mocked(headRemoteFile)
|
|
1493
|
-
.mockResolvedValueOnce(null)
|
|
1494
|
-
.mockResolvedValueOnce({
|
|
1495
|
-
lastModified: new Date(),
|
|
1496
|
-
etag: '"racing-peer-etag"',
|
|
1497
|
-
size: 23,
|
|
1498
|
-
});
|
|
1131
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
1499
1132
|
vi.mocked(uploadFile).mockRejectedValueOnce(fence412());
|
|
1500
1133
|
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1501
1134
|
fs.writeFileSync(dest, "the racing peer version");
|
|
@@ -1515,22 +1148,19 @@ describe("share", () => {
|
|
|
1515
1148
|
expect(result.filesUploaded).toBe(0);
|
|
1516
1149
|
expect(result.aborted).toBe(false);
|
|
1517
1150
|
expect(events.some((e) => e.type === "conflict" && e.path === "raced.md")).toBe(true);
|
|
1518
|
-
// The racing remote
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1151
|
+
// The racing remote bytes were preserved next to the local copy.
|
|
1152
|
+
const mirrors = fs
|
|
1153
|
+
.readdirSync(companyRoot)
|
|
1154
|
+
.filter((f) => f.includes(".conflict-"));
|
|
1155
|
+
expect(mirrors).toHaveLength(1);
|
|
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.
|
|
1523
1159
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
direction: "down",
|
|
1529
|
-
});
|
|
1530
|
-
expect(conflictIndexAt(tmpDir).conflicts[0]).toMatchObject({
|
|
1531
|
-
originalPath: "companies/acme/raced.md",
|
|
1532
|
-
preserved: "local",
|
|
1533
|
-
});
|
|
1160
|
+
if (fs.existsSync(journalPath)) {
|
|
1161
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1162
|
+
expect(journal.files?.["raced.md"]).toBeUndefined();
|
|
1163
|
+
}
|
|
1534
1164
|
});
|
|
1535
1165
|
it("412 under overwrite: retries exactly once WITHOUT the fence (explicit consent)", async () => {
|
|
1536
1166
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
@@ -1609,12 +1239,8 @@ describe("share", () => {
|
|
|
1609
1239
|
}
|
|
1610
1240
|
return { etag: '"should-not-land"' };
|
|
1611
1241
|
});
|
|
1612
|
-
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1613
|
-
fs.writeFileSync(dest, "reviewed peer body");
|
|
1614
|
-
return undefined;
|
|
1615
|
-
});
|
|
1616
1242
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1617
|
-
|
|
1243
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1618
1244
|
version: "1",
|
|
1619
1245
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1620
1246
|
files: {
|
|
@@ -1657,9 +1283,8 @@ describe("share", () => {
|
|
|
1657
1283
|
}
|
|
1658
1284
|
// No unfenced retry under keep.
|
|
1659
1285
|
expect(vi.mocked(uploadFile).mock.calls.some((c) => c[4] === undefined)).toBe(false);
|
|
1660
|
-
//
|
|
1661
|
-
expect(fs.readFileSync(testFile, "utf-8")).toBe(
|
|
1662
|
-
expect(fs.readFileSync(sidecarIn(path.dirname(testFile)), "utf-8")).toBe(staleBytes);
|
|
1286
|
+
// Local stale bytes preserved on disk (keep does not pull).
|
|
1287
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(staleBytes);
|
|
1663
1288
|
});
|
|
1664
1289
|
it("legitimate local-ahead still uploads when remote remains at journal baseline", async () => {
|
|
1665
1290
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
@@ -1675,7 +1300,7 @@ describe("share", () => {
|
|
|
1675
1300
|
});
|
|
1676
1301
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"new-upload-etag"' });
|
|
1677
1302
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1678
|
-
|
|
1303
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1679
1304
|
version: "1",
|
|
1680
1305
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1681
1306
|
files: {
|
|
@@ -1709,12 +1334,8 @@ describe("share", () => {
|
|
|
1709
1334
|
etag: '"remote-concurrent"',
|
|
1710
1335
|
size: 20,
|
|
1711
1336
|
});
|
|
1712
|
-
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1713
|
-
fs.writeFileSync(dest, "remote concurrent");
|
|
1714
|
-
return undefined;
|
|
1715
|
-
});
|
|
1716
1337
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1717
|
-
|
|
1338
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1718
1339
|
version: "1",
|
|
1719
1340
|
lastSync: new Date().toISOString(),
|
|
1720
1341
|
files: {
|
|
@@ -1737,14 +1358,12 @@ describe("share", () => {
|
|
|
1737
1358
|
expect(result.conflictPaths).toEqual(["both-changed.md"]);
|
|
1738
1359
|
expect(result.filesUploaded).toBe(0);
|
|
1739
1360
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
//
|
|
1745
|
-
//
|
|
1746
|
-
// matched that etag. A later push must not treat "remote still at stamped
|
|
1747
|
-
// etag" as license to PUT the divergent local body.
|
|
1361
|
+
});
|
|
1362
|
+
it("localDiverges under keep: pull-kept divergent local cannot silently become write authority", async () => {
|
|
1363
|
+
// After pull --on-conflict keep, journal stamps remoteEtag of the peer
|
|
1364
|
+
// version AND localDiverges=true (local never matched that etag). A later
|
|
1365
|
+
// push must not treat "remote still at stamped etag" as license to PUT
|
|
1366
|
+
// the divergent local body (journal-baseline If-Match would succeed).
|
|
1748
1367
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1749
1368
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1750
1369
|
const testFile = path.join(companyRoot, "kept-divergent.md");
|
|
@@ -1757,12 +1376,8 @@ describe("share", () => {
|
|
|
1757
1376
|
etag: `"${REMOTE}"`,
|
|
1758
1377
|
size: 40,
|
|
1759
1378
|
});
|
|
1760
|
-
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1761
|
-
fs.writeFileSync(dest, "peer remote body");
|
|
1762
|
-
return undefined;
|
|
1763
|
-
});
|
|
1764
1379
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1765
|
-
|
|
1380
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1766
1381
|
version: "1",
|
|
1767
1382
|
lastSync: new Date().toISOString(),
|
|
1768
1383
|
files: {
|
|
@@ -1792,10 +1407,7 @@ describe("share", () => {
|
|
|
1792
1407
|
expect(events.some((e) => e.type === "conflict" &&
|
|
1793
1408
|
e.path === "kept-divergent.md" &&
|
|
1794
1409
|
e.resolution === "keep")).toBe(true);
|
|
1795
|
-
expect(fs.readFileSync(testFile, "utf-8")).toBe(
|
|
1796
|
-
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe(localBytes);
|
|
1797
|
-
expect(journalAt(journalPath).files["kept-divergent.md"]?.localDiverges)
|
|
1798
|
-
.toBeFalsy();
|
|
1410
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1799
1411
|
});
|
|
1800
1412
|
it("localDiverges under overwrite: explicit intent may force-write", async () => {
|
|
1801
1413
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
@@ -1811,7 +1423,7 @@ describe("share", () => {
|
|
|
1811
1423
|
});
|
|
1812
1424
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"forced-etag"' });
|
|
1813
1425
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1814
|
-
|
|
1426
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1815
1427
|
version: "1",
|
|
1816
1428
|
lastSync: new Date().toISOString(),
|
|
1817
1429
|
files: {
|
|
@@ -1838,304 +1450,6 @@ describe("share", () => {
|
|
|
1838
1450
|
// fence then retry — either is consent. Must not leave the file unuploaded.
|
|
1839
1451
|
expect(uploadFile).toHaveBeenCalled();
|
|
1840
1452
|
});
|
|
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
|
-
});
|
|
2139
1453
|
// Journal still carries remoteEtag from a prior sync, but the remote key
|
|
2140
1454
|
// is gone (HEAD null) — e.g. peer delete or operator purge. Recreation
|
|
2141
1455
|
// must use If-None-Match:* (create-only), NOT If-Match on the stale
|
|
@@ -2153,7 +1467,7 @@ describe("share", () => {
|
|
|
2153
1467
|
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
2154
1468
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"recreated-etag"' });
|
|
2155
1469
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2156
|
-
|
|
1470
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2157
1471
|
version: "1",
|
|
2158
1472
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
2159
1473
|
files: {
|
|
@@ -2183,34 +1497,28 @@ describe("share", () => {
|
|
|
2183
1497
|
const pc = vi.mocked(uploadFile).mock.calls[0][4];
|
|
2184
1498
|
expect(pc?.ifMatch).toBeUndefined();
|
|
2185
1499
|
expect(pc?.ifNoneMatch).toBe("*");
|
|
2186
|
-
const journal =
|
|
1500
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2187
1501
|
expect(journal.files["recreate-deleted.md"]).toMatchObject({
|
|
2188
1502
|
hash: localHash,
|
|
2189
1503
|
remoteEtag: "recreated-etag",
|
|
2190
1504
|
direction: "up",
|
|
2191
1505
|
});
|
|
2192
1506
|
});
|
|
2193
|
-
it("deleted journaled remote + concurrent create 412 under keep:
|
|
1507
|
+
it("deleted journaled remote + concurrent create 412 under keep: existing conflict semantics", async () => {
|
|
2194
1508
|
// Same setup as recreation, but a peer creates the key between HEAD and
|
|
2195
|
-
// PUT → If-None-Match:* 412s → keep
|
|
1509
|
+
// PUT → If-None-Match:* 412s → keep surfaces conflict, no journal stamp.
|
|
2196
1510
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2197
1511
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2198
1512
|
const testFile = path.join(companyRoot, "race-recreate.md");
|
|
2199
1513
|
fs.writeFileSync(testFile, "my recreated local");
|
|
2200
|
-
vi.mocked(headRemoteFile)
|
|
2201
|
-
.mockResolvedValueOnce(null)
|
|
2202
|
-
.mockResolvedValueOnce({
|
|
2203
|
-
lastModified: new Date(),
|
|
2204
|
-
etag: '"peer-created-etag"',
|
|
2205
|
-
size: 18,
|
|
2206
|
-
});
|
|
1514
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
2207
1515
|
vi.mocked(uploadFile).mockRejectedValueOnce(fence412());
|
|
2208
1516
|
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
2209
1517
|
fs.writeFileSync(dest, "peer created first");
|
|
2210
1518
|
return undefined;
|
|
2211
1519
|
});
|
|
2212
1520
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2213
|
-
|
|
1521
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2214
1522
|
version: "1",
|
|
2215
1523
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
2216
1524
|
files: {
|
|
@@ -2240,15 +1548,12 @@ describe("share", () => {
|
|
|
2240
1548
|
// First (only) attempt must be create-fenced; no unfenced retry under keep.
|
|
2241
1549
|
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
2242
1550
|
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifNoneMatch: "*" });
|
|
2243
|
-
// Journal
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
});
|
|
2250
|
-
expect(fs.readFileSync(testFile, "utf-8")).toBe("peer created first");
|
|
2251
|
-
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe("my recreated local");
|
|
1551
|
+
// Journal not advanced to a successful upload etag (entry may still be
|
|
1552
|
+
// the prior baseline — next pass re-evaluates).
|
|
1553
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1554
|
+
expect(journal.files["race-recreate.md"]?.remoteEtag).toBe("etag-before-delete");
|
|
1555
|
+
expect(journal.files["race-recreate.md"]?.hash).toBe("old-hash");
|
|
1556
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("my recreated local");
|
|
2252
1557
|
});
|
|
2253
1558
|
});
|
|
2254
1559
|
it("scoped push (plan-exceeds-grant): syncs the in-scope subset, skips out-of-scope paths, never aborts (feedback_ded09d56)", async () => {
|
|
@@ -2569,7 +1874,7 @@ describe("share", () => {
|
|
|
2569
1874
|
size: 5,
|
|
2570
1875
|
});
|
|
2571
1876
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2572
|
-
|
|
1877
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2573
1878
|
version: "1",
|
|
2574
1879
|
lastSync: syncedAt,
|
|
2575
1880
|
files: {
|
|
@@ -2609,7 +1914,7 @@ describe("share", () => {
|
|
|
2609
1914
|
size: 5,
|
|
2610
1915
|
});
|
|
2611
1916
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2612
|
-
|
|
1917
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2613
1918
|
version: "1",
|
|
2614
1919
|
lastSync: syncedAt,
|
|
2615
1920
|
files: {
|
|
@@ -2645,7 +1950,7 @@ describe("share", () => {
|
|
|
2645
1950
|
hqRoot: tmpDir,
|
|
2646
1951
|
});
|
|
2647
1952
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2648
|
-
const journal =
|
|
1953
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2649
1954
|
expect(journal.files["fresh.md"].remoteEtag).toBe("new-upload-etag");
|
|
2650
1955
|
});
|
|
2651
1956
|
it("forwards UploadAuthor to uploadFile when present (created-by metadata)", async () => {
|
|
@@ -2690,7 +1995,7 @@ describe("share", () => {
|
|
|
2690
1995
|
const { hashFile } = await import("../journal.js");
|
|
2691
1996
|
const hash = hashFile(testFile);
|
|
2692
1997
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2693
|
-
|
|
1998
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2694
1999
|
version: "1",
|
|
2695
2000
|
lastSync: new Date().toISOString(),
|
|
2696
2001
|
files: {
|
|
@@ -2910,7 +2215,7 @@ describe("share", () => {
|
|
|
2910
2215
|
.update("stable content")
|
|
2911
2216
|
.digest("hex");
|
|
2912
2217
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2913
|
-
|
|
2218
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2914
2219
|
version: "1",
|
|
2915
2220
|
lastSync: new Date().toISOString(),
|
|
2916
2221
|
files: {
|
|
@@ -2971,7 +2276,7 @@ describe("share", () => {
|
|
|
2971
2276
|
// not be queued for delete.
|
|
2972
2277
|
fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
|
|
2973
2278
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2974
|
-
|
|
2279
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2975
2280
|
version: "1",
|
|
2976
2281
|
lastSync: new Date().toISOString(),
|
|
2977
2282
|
files: {
|
|
@@ -3008,7 +2313,7 @@ describe("share", () => {
|
|
|
3008
2313
|
operation: "delete",
|
|
3009
2314
|
});
|
|
3010
2315
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
|
|
3011
|
-
const journal =
|
|
2316
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3012
2317
|
expect(journal.files["knowledge"]).toBeUndefined();
|
|
3013
2318
|
});
|
|
3014
2319
|
it("propagateDeletes: a dangling local symlink is NOT classified as gone (lstat, not existsSync)", async () => {
|
|
@@ -3025,7 +2330,7 @@ describe("share", () => {
|
|
|
3025
2330
|
const danglingLink = path.join(companyRoot, "dangling-link.md");
|
|
3026
2331
|
fs.symlinkSync("./missing-target.md", danglingLink);
|
|
3027
2332
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3028
|
-
|
|
2333
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3029
2334
|
version: "1",
|
|
3030
2335
|
lastSync: new Date().toISOString(),
|
|
3031
2336
|
files: {
|
|
@@ -3055,7 +2360,7 @@ describe("share", () => {
|
|
|
3055
2360
|
expect(result.filesDeleted).toBe(0);
|
|
3056
2361
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3057
2362
|
// Journal entry must survive the run.
|
|
3058
|
-
const journal =
|
|
2363
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3059
2364
|
expect(journal.files["dangling-link.md"]).toBeDefined();
|
|
3060
2365
|
});
|
|
3061
2366
|
it("vault-litter drain: a `.drift-*` marker with direction:'down' and NO delete intent tombstones " +
|
|
@@ -3120,7 +2425,7 @@ describe("share", () => {
|
|
|
3120
2425
|
});
|
|
3121
2426
|
expect(result.filesDeleted).toBe(1);
|
|
3122
2427
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "personal/scripts/run-project.sh.drift-1779863862-42519");
|
|
3123
|
-
const journal =
|
|
2428
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3124
2429
|
expect(journal.files["personal/scripts/run-project.sh.drift-1779863862-42519"]).toBeUndefined();
|
|
3125
2430
|
}
|
|
3126
2431
|
finally {
|
|
@@ -3193,7 +2498,7 @@ describe("share", () => {
|
|
|
3193
2498
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3194
2499
|
// No file on disk under companies/acme/peer-uploaded.md → local missing.
|
|
3195
2500
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3196
|
-
|
|
2501
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3197
2502
|
version: "1",
|
|
3198
2503
|
lastSync: new Date().toISOString(),
|
|
3199
2504
|
files: {
|
|
@@ -3218,7 +2523,7 @@ describe("share", () => {
|
|
|
3218
2523
|
});
|
|
3219
2524
|
expect(result.filesDeleted).toBe(0);
|
|
3220
2525
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3221
|
-
const journal =
|
|
2526
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3222
2527
|
expect(journal.files["peer-uploaded.md"]).toBeDefined();
|
|
3223
2528
|
});
|
|
3224
2529
|
it("ROLLBACK KNOB: owned-only is honored on a prs_ personal vault — an intent-less entry with a " +
|
|
@@ -3309,7 +2614,7 @@ describe("share", () => {
|
|
|
3309
2614
|
]);
|
|
3310
2615
|
// Journal entry survives, so a later run (or a watcher-minted intent)
|
|
3311
2616
|
// can still authorize the delete properly.
|
|
3312
|
-
const journal =
|
|
2617
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3313
2618
|
expect(journal.files["personal/knowledge/gone.md"]).toBeDefined();
|
|
3314
2619
|
}
|
|
3315
2620
|
finally {
|
|
@@ -3326,7 +2631,7 @@ describe("share", () => {
|
|
|
3326
2631
|
// deleted by the user.
|
|
3327
2632
|
fs.writeFileSync(path.join(companyRoot, "kept.md"), "still here");
|
|
3328
2633
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3329
|
-
|
|
2634
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3330
2635
|
version: "1",
|
|
3331
2636
|
lastSync: new Date().toISOString(),
|
|
3332
2637
|
files: {
|
|
@@ -3362,7 +2667,7 @@ describe("share", () => {
|
|
|
3362
2667
|
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
3363
2668
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "gone.md");
|
|
3364
2669
|
// Journal entry for the gone file is removed; the kept entry stays.
|
|
3365
|
-
const journal =
|
|
2670
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3366
2671
|
expect(journal.files["gone.md"]).toBeUndefined();
|
|
3367
2672
|
expect(journal.files["kept.md"]).toBeDefined();
|
|
3368
2673
|
});
|
|
@@ -3370,7 +2675,7 @@ describe("share", () => {
|
|
|
3370
2675
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3371
2676
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3372
2677
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3373
|
-
|
|
2678
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3374
2679
|
version: "1",
|
|
3375
2680
|
lastSync: new Date().toISOString(),
|
|
3376
2681
|
files: {
|
|
@@ -3412,7 +2717,7 @@ describe("share", () => {
|
|
|
3412
2717
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3413
2718
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3414
2719
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3415
|
-
|
|
2720
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3416
2721
|
version: "1",
|
|
3417
2722
|
lastSync: new Date().toISOString(),
|
|
3418
2723
|
files: {
|
|
@@ -3435,7 +2740,7 @@ describe("share", () => {
|
|
|
3435
2740
|
expect(result.filesDeleted).toBe(0);
|
|
3436
2741
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3437
2742
|
// Journal entry survives so the next opt-in run can still propagate.
|
|
3438
|
-
const journal =
|
|
2743
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3439
2744
|
expect(journal.files["gone.md"]).toBeDefined();
|
|
3440
2745
|
});
|
|
3441
2746
|
it("propagateDeletes: scope is constrained to the supplied paths — sibling deletes are not swept", async () => {
|
|
@@ -3443,7 +2748,7 @@ describe("share", () => {
|
|
|
3443
2748
|
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
3444
2749
|
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
3445
2750
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3446
|
-
|
|
2751
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3447
2752
|
version: "1",
|
|
3448
2753
|
lastSync: new Date().toISOString(),
|
|
3449
2754
|
files: {
|
|
@@ -3478,7 +2783,7 @@ describe("share", () => {
|
|
|
3478
2783
|
expect(result.filesDeleted).toBe(1);
|
|
3479
2784
|
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
3480
2785
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
|
|
3481
|
-
const journal =
|
|
2786
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3482
2787
|
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
3483
2788
|
// Sibling tree's journal entry is untouched — `hq share <subtree>` must
|
|
3484
2789
|
// not act on files outside the named scope.
|
|
@@ -3494,7 +2799,7 @@ describe("share", () => {
|
|
|
3494
2799
|
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
3495
2800
|
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
3496
2801
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3497
|
-
|
|
2802
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3498
2803
|
version: "1",
|
|
3499
2804
|
lastSync: new Date().toISOString(),
|
|
3500
2805
|
files: {
|
|
@@ -3529,7 +2834,7 @@ describe("share", () => {
|
|
|
3529
2834
|
});
|
|
3530
2835
|
expect(result.filesDeleted).toBe(1);
|
|
3531
2836
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
|
|
3532
|
-
const journal =
|
|
2837
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3533
2838
|
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
3534
2839
|
// Scope stays exactly as narrow as the absolute spelling's — no wider.
|
|
3535
2840
|
expect(journal.files["other/also-gone.md"]).toBeDefined();
|
|
@@ -3546,7 +2851,7 @@ describe("share", () => {
|
|
|
3546
2851
|
fs.mkdirSync(acmeRepo, { recursive: true });
|
|
3547
2852
|
fs.symlinkSync(acmeRepo, path.join(companyRoot, "knowledge"));
|
|
3548
2853
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3549
|
-
|
|
2854
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3550
2855
|
version: "1",
|
|
3551
2856
|
lastSync: new Date().toISOString(),
|
|
3552
2857
|
files: {
|
|
@@ -3576,14 +2881,14 @@ describe("share", () => {
|
|
|
3576
2881
|
});
|
|
3577
2882
|
expect(result.filesDeleted).toBe(0);
|
|
3578
2883
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3579
|
-
const journal =
|
|
2884
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3580
2885
|
expect(journal.files["knowledge/agents/brief.md"]).toBeDefined();
|
|
3581
2886
|
});
|
|
3582
2887
|
it("propagateDeletes: a failed DeleteObject leaves the journal entry intact for retry", async () => {
|
|
3583
2888
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3584
2889
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3585
2890
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3586
|
-
|
|
2891
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3587
2892
|
version: "1",
|
|
3588
2893
|
lastSync: new Date().toISOString(),
|
|
3589
2894
|
files: {
|
|
@@ -3616,7 +2921,7 @@ describe("share", () => {
|
|
|
3616
2921
|
const errorEvent = events.find((e) => e.type === "error");
|
|
3617
2922
|
expect(errorEvent).toMatchObject({ path: "flaky.md", message: expect.stringContaining("S3 down") });
|
|
3618
2923
|
// Entry survives — next run will retry the delete.
|
|
3619
|
-
const journal =
|
|
2924
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3620
2925
|
expect(journal.files["flaky.md"]).toBeDefined();
|
|
3621
2926
|
});
|
|
3622
2927
|
// ── Delete propagation safety: direction + filter guards (Bug B + C) ──────
|
|
@@ -3650,7 +2955,7 @@ describe("share", () => {
|
|
|
3650
2955
|
// and one this machine uploaded (direction:'up'). Only the 'up' one should
|
|
3651
2956
|
// be eligible for delete-propagation.
|
|
3652
2957
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3653
|
-
|
|
2958
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3654
2959
|
version: "1",
|
|
3655
2960
|
lastSync: new Date().toISOString(),
|
|
3656
2961
|
files: {
|
|
@@ -3687,7 +2992,7 @@ describe("share", () => {
|
|
|
3687
2992
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3688
2993
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3689
2994
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3690
|
-
|
|
2995
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3691
2996
|
version: "1",
|
|
3692
2997
|
lastSync: new Date().toISOString(),
|
|
3693
2998
|
files: {
|
|
@@ -3720,7 +3025,7 @@ describe("share", () => {
|
|
|
3720
3025
|
// absence is a filter outcome, not a user-intended delete.
|
|
3721
3026
|
fs.writeFileSync(path.join(tmpDir, ".hqignore"), "companies/acme/legacy/**\n");
|
|
3722
3027
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3723
|
-
|
|
3028
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3724
3029
|
version: "1",
|
|
3725
3030
|
lastSync: new Date().toISOString(),
|
|
3726
3031
|
files: {
|
|
@@ -3779,7 +3084,7 @@ describe("share", () => {
|
|
|
3779
3084
|
// Under owned-only this would be stuck on remote forever; currency-gated
|
|
3780
3085
|
// checks the ETag and propagates the delete cleanly when match holds.
|
|
3781
3086
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3782
|
-
|
|
3087
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3783
3088
|
version: "1",
|
|
3784
3089
|
lastSync: new Date().toISOString(),
|
|
3785
3090
|
files: {
|
|
@@ -3809,7 +3114,7 @@ describe("share", () => {
|
|
|
3809
3114
|
});
|
|
3810
3115
|
expect(result.filesDeleted).toBe(1);
|
|
3811
3116
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "core/policies/old.md");
|
|
3812
|
-
const journal =
|
|
3117
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3813
3118
|
expect(journal.files["core/policies/old.md"]).toBeUndefined();
|
|
3814
3119
|
});
|
|
3815
3120
|
it("currency-gated: propagates the S3 delete for a local-delete tombstone (pull-leg producer hand-off)", async () => {
|
|
@@ -3820,7 +3125,7 @@ describe("share", () => {
|
|
|
3820
3125
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3821
3126
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3822
3127
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3823
|
-
|
|
3128
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3824
3129
|
version: "2",
|
|
3825
3130
|
lastSync: new Date().toISOString(),
|
|
3826
3131
|
files: {
|
|
@@ -3856,7 +3161,7 @@ describe("share", () => {
|
|
|
3856
3161
|
});
|
|
3857
3162
|
expect(result.filesDeleted).toBe(1);
|
|
3858
3163
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "docs/deleted.md");
|
|
3859
|
-
const journal =
|
|
3164
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3860
3165
|
expect(journal.files["docs/deleted.md"]).toBeUndefined();
|
|
3861
3166
|
});
|
|
3862
3167
|
it("currency-gated: refuses delete + emits stale-etag event when remote moved since last sync", async () => {
|
|
@@ -3865,7 +3170,7 @@ describe("share", () => {
|
|
|
3865
3170
|
// Another device modified `shared.md` after this device's last sync.
|
|
3866
3171
|
// The journal still records the old etag; HEAD returns the new one.
|
|
3867
3172
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3868
|
-
|
|
3173
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3869
3174
|
version: "1",
|
|
3870
3175
|
lastSync: new Date().toISOString(),
|
|
3871
3176
|
files: {
|
|
@@ -3897,7 +3202,7 @@ describe("share", () => {
|
|
|
3897
3202
|
// No remote DELETE issued, journal entry preserved (pull will re-pull).
|
|
3898
3203
|
expect(result.filesDeleted).toBe(0);
|
|
3899
3204
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3900
|
-
const journal =
|
|
3205
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3901
3206
|
expect(journal.files["shared.md"]).toBeDefined();
|
|
3902
3207
|
// Dedicated event fired so UIs surface the refusal. `reason` field
|
|
3903
3208
|
// discriminates "stale-etag" (real drift) from "legacy-no-etag" so
|
|
@@ -3919,7 +3224,7 @@ describe("share", () => {
|
|
|
3919
3224
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3920
3225
|
const recreatedPath = path.join(companyRoot, "racy.md");
|
|
3921
3226
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3922
|
-
|
|
3227
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3923
3228
|
version: "2",
|
|
3924
3229
|
lastSync: new Date().toISOString(),
|
|
3925
3230
|
files: {
|
|
@@ -3955,7 +3260,7 @@ describe("share", () => {
|
|
|
3955
3260
|
expect(result.filesDeleted).toBe(0);
|
|
3956
3261
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3957
3262
|
expect(fs.readFileSync(recreatedPath, "utf-8")).toBe("recreated after planning");
|
|
3958
|
-
const journal =
|
|
3263
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3959
3264
|
expect(journal.files["racy.md"]).toBeDefined();
|
|
3960
3265
|
expect(events).toContainEqual(expect.objectContaining({
|
|
3961
3266
|
type: "delete-refused-stale-etag",
|
|
@@ -3977,7 +3282,7 @@ describe("share", () => {
|
|
|
3977
3282
|
// genuine local==remote sync, so propagating the delete would destroy the
|
|
3978
3283
|
// (different) remote version. Must be REFUSED, not propagated.
|
|
3979
3284
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3980
|
-
|
|
3285
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3981
3286
|
version: "2",
|
|
3982
3287
|
lastSync: new Date().toISOString(),
|
|
3983
3288
|
files: {
|
|
@@ -4012,7 +3317,7 @@ describe("share", () => {
|
|
|
4012
3317
|
// Refusal short-circuits before the currency HEAD — no network needed.
|
|
4013
3318
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
4014
3319
|
// Journal entry preserved, not silently dropped.
|
|
4015
|
-
const journal =
|
|
3320
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
4016
3321
|
expect(journal.files["core/policies/kept.md"]).toBeDefined();
|
|
4017
3322
|
// Surfaced as a refusal with the divergent-local reason.
|
|
4018
3323
|
const refused = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
@@ -4029,7 +3334,7 @@ describe("share", () => {
|
|
|
4029
3334
|
// S3 console). Local copy also missing. Currency-gated should drop the
|
|
4030
3335
|
// journal entry without issuing a DELETE — the remote is already gone.
|
|
4031
3336
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4032
|
-
|
|
3337
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
4033
3338
|
version: "1",
|
|
4034
3339
|
lastSync: new Date().toISOString(),
|
|
4035
3340
|
files: {
|
|
@@ -4062,7 +3367,7 @@ describe("share", () => {
|
|
|
4062
3367
|
// from real deletes without re-counting events.
|
|
4063
3368
|
expect(result.filesTombstoned).toBe(1);
|
|
4064
3369
|
expect(result.filesRefusedStale).toBe(0);
|
|
4065
|
-
const journal =
|
|
3370
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
4066
3371
|
expect(journal.files["removed-out-of-band.md"]).toBeUndefined();
|
|
4067
3372
|
// Synthetic progress event carries the tombstone marker. Without this,
|
|
4068
3373
|
// the tty stream renders tombstones identically to real deletes — operator
|
|
@@ -4083,7 +3388,7 @@ describe("share", () => {
|
|
|
4083
3388
|
// against. Refuse the delete in the safe direction; a future sync with
|
|
4084
3389
|
// a recorded etag can re-evaluate.
|
|
4085
3390
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4086
|
-
|
|
3391
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
4087
3392
|
version: "1",
|
|
4088
3393
|
lastSync: new Date().toISOString(),
|
|
4089
3394
|
files: {
|
|
@@ -4137,7 +3442,7 @@ describe("share", () => {
|
|
|
4137
3442
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4138
3443
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4139
3444
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4140
|
-
|
|
3445
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
4141
3446
|
version: "1",
|
|
4142
3447
|
lastSync: new Date().toISOString(),
|
|
4143
3448
|
files: {
|
|
@@ -4189,7 +3494,7 @@ describe("share", () => {
|
|
|
4189
3494
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4190
3495
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4191
3496
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4192
|
-
|
|
3497
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
4193
3498
|
version: "1",
|
|
4194
3499
|
lastSync: new Date().toISOString(),
|
|
4195
3500
|
files: {
|
|
@@ -4223,7 +3528,7 @@ describe("share", () => {
|
|
|
4223
3528
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4224
3529
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4225
3530
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4226
|
-
|
|
3531
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
4227
3532
|
version: "1",
|
|
4228
3533
|
lastSync: new Date().toISOString(),
|
|
4229
3534
|
files: {
|
|
@@ -4273,7 +3578,7 @@ describe("share", () => {
|
|
|
4273
3578
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4274
3579
|
const recreatedPath = path.join(companyRoot, "notes", "gone.md");
|
|
4275
3580
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4276
|
-
|
|
3581
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
4277
3582
|
version: "1",
|
|
4278
3583
|
lastSync: new Date().toISOString(),
|
|
4279
3584
|
files: {
|
|
@@ -4321,7 +3626,7 @@ describe("share", () => {
|
|
|
4321
3626
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4322
3627
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4323
3628
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4324
|
-
|
|
3629
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
4325
3630
|
version: "1",
|
|
4326
3631
|
lastSync: new Date().toISOString(),
|
|
4327
3632
|
files: {
|
|
@@ -4351,14 +3656,14 @@ describe("share", () => {
|
|
|
4351
3656
|
type: "delete-refused-stale-etag",
|
|
4352
3657
|
reason: "legacy-no-etag",
|
|
4353
3658
|
}));
|
|
4354
|
-
const journal =
|
|
3659
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
4355
3660
|
expect(journal.files["notes/legacy.md"]).toBeDefined();
|
|
4356
3661
|
});
|
|
4357
3662
|
it("owned-only still requires an intent — it is the documented rollback knob", async () => {
|
|
4358
3663
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4359
3664
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4360
3665
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4361
|
-
|
|
3666
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
4362
3667
|
version: "1",
|
|
4363
3668
|
lastSync: new Date().toISOString(),
|
|
4364
3669
|
files: {
|
|
@@ -4545,7 +3850,7 @@ describe("share", () => {
|
|
|
4545
3850
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4546
3851
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4547
3852
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4548
|
-
|
|
3853
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
4549
3854
|
version: "1",
|
|
4550
3855
|
lastSync: new Date().toISOString(),
|
|
4551
3856
|
files: {
|
|
@@ -4586,7 +3891,7 @@ describe("share", () => {
|
|
|
4586
3891
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "regular.md");
|
|
4587
3892
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md");
|
|
4588
3893
|
// Both journal entries are removed by the delete loop's removeEntry call.
|
|
4589
|
-
const journal =
|
|
3894
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
4590
3895
|
expect(journal.files["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md"]).toBeUndefined();
|
|
4591
3896
|
expect(journal.files["regular.md"]).toBeUndefined();
|
|
4592
3897
|
});
|
|
@@ -4601,7 +3906,7 @@ describe("share", () => {
|
|
|
4601
3906
|
process.env.HQ_STATE_DIR = stateDirPersonal;
|
|
4602
3907
|
try {
|
|
4603
3908
|
const journalPath = path.join(stateDirPersonal, "sync-journal.__hq_personal_vault__.json");
|
|
4604
|
-
|
|
3909
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
4605
3910
|
version: "1",
|
|
4606
3911
|
lastSync: new Date().toISOString(),
|
|
4607
3912
|
files: {
|
|
@@ -4627,7 +3932,7 @@ describe("share", () => {
|
|
|
4627
3932
|
});
|
|
4628
3933
|
expect(result.filesDeleted).toBe(1);
|
|
4629
3934
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "personal/.obsidian/graph.json.drift-1779863862-42519");
|
|
4630
|
-
const journal =
|
|
3935
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
4631
3936
|
expect(journal.files["personal/.obsidian/graph.json.drift-1779863862-42519"]).toBeUndefined();
|
|
4632
3937
|
}
|
|
4633
3938
|
finally {
|
|
@@ -4650,7 +3955,7 @@ describe("share", () => {
|
|
|
4650
3955
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
4651
3956
|
direction: "up",
|
|
4652
3957
|
});
|
|
4653
|
-
|
|
3958
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
4654
3959
|
version: "1",
|
|
4655
3960
|
lastSync: new Date().toISOString(),
|
|
4656
3961
|
files: {
|
|
@@ -4698,7 +4003,7 @@ describe("share", () => {
|
|
|
4698
4003
|
expect(pruned[0].byReason["ignore-filtered"]).toBe(3);
|
|
4699
4004
|
expect(pruned[0].byReason["excluded-top-level"]).toBe(1);
|
|
4700
4005
|
// The persisted journal converged: excluded rows gone, legit row kept.
|
|
4701
|
-
const journal =
|
|
4006
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
4702
4007
|
expect(Object.keys(journal.files)).toContain("personal/keep.md");
|
|
4703
4008
|
expect(journal.files[".claude/worktrees/wt/a.ts"]).toBeUndefined();
|
|
4704
4009
|
expect(journal.files["personal/node_modules/pkg/index.js"]).toBeUndefined();
|
|
@@ -4730,7 +4035,7 @@ describe("share", () => {
|
|
|
4730
4035
|
const key = "deleted-top-level.md";
|
|
4731
4036
|
const journalSlug = "personal-scoped-delete";
|
|
4732
4037
|
const journalPath = path.join(stateDir, `sync-journal.${journalSlug}.json`);
|
|
4733
|
-
|
|
4038
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
4734
4039
|
version: "2",
|
|
4735
4040
|
lastSync: new Date().toISOString(),
|
|
4736
4041
|
pulls: [],
|
|
@@ -4762,7 +4067,7 @@ describe("share", () => {
|
|
|
4762
4067
|
});
|
|
4763
4068
|
expect(result.filesDeleted).toBe(1);
|
|
4764
4069
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), key);
|
|
4765
|
-
expect(
|
|
4070
|
+
expect(JSON.parse(fs.readFileSync(journalPath, "utf8")).files[key]).toBeUndefined();
|
|
4766
4071
|
});
|
|
4767
4072
|
// ── Decommission prefixes ──────────────────────────────────────────────────
|
|
4768
4073
|
//
|
|
@@ -4817,7 +4122,7 @@ describe("share", () => {
|
|
|
4817
4122
|
// Local file remains on disk — decommission is a remote-only sweep.
|
|
4818
4123
|
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "notes.md"))).toBe(true);
|
|
4819
4124
|
// Journal entry pruned in lockstep with the remote delete.
|
|
4820
|
-
const journalAfter =
|
|
4125
|
+
const journalAfter = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
4821
4126
|
expect(journalAfter.files["companies/foo/notes.md"]).toBeUndefined();
|
|
4822
4127
|
expect(journalAfter.files["knowledge/x.md"]).toBeDefined();
|
|
4823
4128
|
});
|
|
@@ -4853,7 +4158,7 @@ describe("share", () => {
|
|
|
4853
4158
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
4854
4159
|
// Journal entry preserved — the pulled-from-peer record stays so a
|
|
4855
4160
|
// future pull can still reconcile it correctly.
|
|
4856
|
-
const journalAfter =
|
|
4161
|
+
const journalAfter = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
4857
4162
|
expect(journalAfter.files["companies/foo/from-peer.md"]).toBeDefined();
|
|
4858
4163
|
});
|
|
4859
4164
|
it("decommissionPrefixes: dedupes with propagateDeletes plan — a key in both still emits one DeleteObject", async () => {
|
|
@@ -4973,7 +4278,7 @@ describe("share", () => {
|
|
|
4973
4278
|
expect(result.filesTombstoned).toBe(1);
|
|
4974
4279
|
expect(result.filesDeleted).toBe(0);
|
|
4975
4280
|
// Journal entry is gone.
|
|
4976
|
-
const journalAfter =
|
|
4281
|
+
const journalAfter = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
4977
4282
|
expect(journalAfter.files["companies/foo/gone.md"]).toBeUndefined();
|
|
4978
4283
|
});
|
|
4979
4284
|
describe("personalMode", () => {
|
|
@@ -5014,7 +4319,7 @@ describe("share", () => {
|
|
|
5014
4319
|
const acmeJournalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5015
4320
|
expect(fs.existsSync(personalJournalPath)).toBe(true);
|
|
5016
4321
|
expect(fs.existsSync(acmeJournalPath)).toBe(false);
|
|
5017
|
-
const journal =
|
|
4322
|
+
const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
5018
4323
|
expect(journal.files["knowledge/notes.md"]).toBeDefined();
|
|
5019
4324
|
});
|
|
5020
4325
|
it("personalMode=true accepts files outside companies/<slug>/ (companion to the company-folder rejection)", async () => {
|
|
@@ -5302,7 +4607,7 @@ describe("share", () => {
|
|
|
5302
4607
|
.createHash("sha256")
|
|
5303
4608
|
.update(target)
|
|
5304
4609
|
.digest("hex");
|
|
5305
|
-
|
|
4610
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
5306
4611
|
version: "1",
|
|
5307
4612
|
lastSync: new Date().toISOString(),
|
|
5308
4613
|
files: {
|
|
@@ -5705,7 +5010,7 @@ describe("share", () => {
|
|
|
5705
5010
|
});
|
|
5706
5011
|
expect(pushed.filesUploaded).toBe(1);
|
|
5707
5012
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5708
|
-
const afterPush =
|
|
5013
|
+
const afterPush = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5709
5014
|
expect(Object.keys(afterPush.files)).toContain("knowledge/agents/brief.md");
|
|
5710
5015
|
vi.mocked(deleteRemoteFile).mockClear();
|
|
5711
5016
|
const walked = await share({
|
|
@@ -5719,7 +5024,7 @@ describe("share", () => {
|
|
|
5719
5024
|
expect(walked.filesDeleted).toBe(0);
|
|
5720
5025
|
const deletedKeys = vi.mocked(deleteRemoteFile).mock.calls.map((c) => c[1]);
|
|
5721
5026
|
expect(deletedKeys).not.toContain("knowledge/agents/brief.md");
|
|
5722
|
-
const afterWalk =
|
|
5027
|
+
const afterWalk = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5723
5028
|
expect(Object.keys(afterWalk.files)).toContain("knowledge/agents/brief.md");
|
|
5724
5029
|
});
|
|
5725
5030
|
});
|
|
@@ -5763,7 +5068,7 @@ describe("share", () => {
|
|
|
5763
5068
|
}
|
|
5764
5069
|
}
|
|
5765
5070
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5766
|
-
|
|
5071
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
5767
5072
|
version: "1",
|
|
5768
5073
|
lastSync: new Date().toISOString(),
|
|
5769
5074
|
files,
|
|
@@ -5821,7 +5126,7 @@ describe("share", () => {
|
|
|
5821
5126
|
expect(summary.samplePaths.length).toBeGreaterThan(0);
|
|
5822
5127
|
expect(summary.samplePaths.length).toBeLessThanOrEqual(10);
|
|
5823
5128
|
// Journal entries for the missing files SURVIVE — guard never mutates.
|
|
5824
|
-
const journal =
|
|
5129
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5825
5130
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5826
5131
|
// Per-key delete-refused-stale-etag events for each candidate.
|
|
5827
5132
|
const perKeyRefusals = events.filter((e) => e.type === "delete-refused-stale-etag" && e.reason === "bulk-asymmetry");
|
|
@@ -5871,7 +5176,7 @@ describe("share", () => {
|
|
|
5871
5176
|
expect(headedKeys).not.toContain(`f-${i.toString().padStart(4, "0")}.md`);
|
|
5872
5177
|
}
|
|
5873
5178
|
// Journal untouched — the entries stay recoverable.
|
|
5874
|
-
const journal =
|
|
5179
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5875
5180
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5876
5181
|
});
|
|
5877
5182
|
// The core of the #227 regression, isolated: pre-#227 a trip emptied
|
|
@@ -5902,7 +5207,7 @@ describe("share", () => {
|
|
|
5902
5207
|
const perKeyRefusals = events.filter((e) => e.type === "delete-refused-stale-etag" &&
|
|
5903
5208
|
e.reason === "bulk-asymmetry");
|
|
5904
5209
|
expect(perKeyRefusals.length).toBe(11);
|
|
5905
|
-
const journal =
|
|
5210
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5906
5211
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5907
5212
|
});
|
|
5908
5213
|
it("vault litter still drains when the breaker trips", async () => {
|
|
@@ -5916,7 +5221,7 @@ describe("share", () => {
|
|
|
5916
5221
|
withIntents: false,
|
|
5917
5222
|
});
|
|
5918
5223
|
const litterKey = "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md";
|
|
5919
|
-
const seeded =
|
|
5224
|
+
const seeded = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5920
5225
|
seeded.files[litterKey] = {
|
|
5921
5226
|
hash: "h",
|
|
5922
5227
|
size: 5,
|
|
@@ -5925,7 +5230,7 @@ describe("share", () => {
|
|
|
5925
5230
|
remoteEtag: "litter-etag",
|
|
5926
5231
|
kind: "file",
|
|
5927
5232
|
};
|
|
5928
|
-
|
|
5233
|
+
fs.writeFileSync(journalPath, JSON.stringify(seeded));
|
|
5929
5234
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
5930
5235
|
etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
|
|
5931
5236
|
lastModified: new Date(),
|
|
@@ -6065,7 +5370,7 @@ describe("share", () => {
|
|
|
6065
5370
|
expect(summary).toBeDefined();
|
|
6066
5371
|
expect(summary.ratio).toBeCloseTo(1.0, 5);
|
|
6067
5372
|
// Journal untouched.
|
|
6068
|
-
const journal =
|
|
5373
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
6069
5374
|
expect(Object.keys(journal.files).length).toBe(50);
|
|
6070
5375
|
});
|
|
6071
5376
|
it("small absolute count (9/9 = 100% ratio but < MIN_ABS=10) does NOT trip: all 9 delete normally", async () => {
|
|
@@ -6109,7 +5414,7 @@ describe("share", () => {
|
|
|
6109
5414
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
6110
5415
|
expect(events.find((e) => e.type === "delete-refused-bulk-asymmetry")).toBeDefined();
|
|
6111
5416
|
// Journal entries survive.
|
|
6112
|
-
const journal =
|
|
5417
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
6113
5418
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
6114
5419
|
});
|
|
6115
5420
|
it("direction:'down' entries don't count toward the bulk numerator (owned-only silently skips them)", async () => {
|
|
@@ -6137,7 +5442,7 @@ describe("share", () => {
|
|
|
6137
5442
|
localDeleteIntent: deleteIntent("up-etag", "h"),
|
|
6138
5443
|
};
|
|
6139
5444
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6140
|
-
|
|
5445
|
+
fs.writeFileSync(journalPath, JSON.stringify({ version: "1", lastSync: now, files }));
|
|
6141
5446
|
const result = await share({
|
|
6142
5447
|
paths: [companyRoot],
|
|
6143
5448
|
company: "acme",
|
|
@@ -6260,7 +5565,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6260
5565
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
6261
5566
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6262
5567
|
// v2-shaped fixture — same field set the production journal uses.
|
|
6263
|
-
|
|
5568
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
6264
5569
|
version: "2",
|
|
6265
5570
|
lastSync: new Date().toISOString(),
|
|
6266
5571
|
files: {
|
|
@@ -6306,7 +5611,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6306
5611
|
const { hashFile } = await import("../journal.js");
|
|
6307
5612
|
const baselineHash = hashFile(testFile);
|
|
6308
5613
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6309
|
-
|
|
5614
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
6310
5615
|
version: "2",
|
|
6311
5616
|
lastSync: new Date().toISOString(),
|
|
6312
5617
|
files: {
|
|
@@ -6347,7 +5652,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6347
5652
|
const originalLstat = fs.lstatSync(testFile);
|
|
6348
5653
|
// The recorded hash intentionally differs from the current bytes.
|
|
6349
5654
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6350
|
-
|
|
5655
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
6351
5656
|
version: "2",
|
|
6352
5657
|
lastSync: new Date().toISOString(),
|
|
6353
5658
|
files: {
|
|
@@ -6394,7 +5699,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6394
5699
|
const { hashFile } = await import("../journal.js");
|
|
6395
5700
|
const realHash = hashFile(testFile);
|
|
6396
5701
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6397
|
-
|
|
5702
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
6398
5703
|
version: "2",
|
|
6399
5704
|
lastSync: new Date().toISOString(),
|
|
6400
5705
|
files: {
|
|
@@ -6444,7 +5749,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6444
5749
|
const { hashFile } = await import("../journal.js");
|
|
6445
5750
|
const realHash = hashFile(testFile);
|
|
6446
5751
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6447
|
-
|
|
5752
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
6448
5753
|
version: "2",
|
|
6449
5754
|
lastSync: new Date().toISOString(),
|
|
6450
5755
|
files: {
|
|
@@ -6476,7 +5781,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6476
5781
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
6477
5782
|
// Journal mtimeMs is now the bumped value, while content hashing remains
|
|
6478
5783
|
// authoritative on the next sync.
|
|
6479
|
-
const journalAfter =
|
|
5784
|
+
const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
6480
5785
|
expect(journalAfter.files["touched-then-fast.md"].mtimeMs).toBe(bumpedLstat.mtimeMs);
|
|
6481
5786
|
expect(journalAfter.files["touched-then-fast.md"].size).toBe(bumpedLstat.size);
|
|
6482
5787
|
// Content hash untouched — the file genuinely didn't change.
|
|
@@ -6496,7 +5801,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6496
5801
|
});
|
|
6497
5802
|
expect(result.filesUploaded).toBe(1);
|
|
6498
5803
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6499
|
-
const journal =
|
|
5804
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
6500
5805
|
expect(journal.files["fresh.md"]).toBeDefined();
|
|
6501
5806
|
expect(journal.files["fresh.md"].mtimeMs).toBe(lstat.mtimeMs);
|
|
6502
5807
|
expect(journal.files["fresh.md"].size).toBe(lstat.size);
|
|
@@ -6513,7 +5818,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6513
5818
|
const realSize = fs.statSync(testFile).size;
|
|
6514
5819
|
// Real hash but NO mtimeMs. Hash matches → skip upload.
|
|
6515
5820
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6516
|
-
|
|
5821
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
6517
5822
|
version: "1",
|
|
6518
5823
|
lastSync: new Date().toISOString(),
|
|
6519
5824
|
files: {
|
|
@@ -7072,7 +6377,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
7072
6377
|
// AND lost their etag/content-hash/stat journal metadata.
|
|
7073
6378
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
7074
6379
|
expect(fs.existsSync(journalPath)).toBe(true);
|
|
7075
|
-
const journal =
|
|
6380
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
7076
6381
|
const stampedKeys = Object.keys(journal.files);
|
|
7077
6382
|
expect(stampedKeys.length).toBe(FILE_COUNT - 1);
|
|
7078
6383
|
// The failed file is NOT in the journal (no successful upload).
|
|
@@ -7220,7 +6525,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
7220
6525
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
7221
6526
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
7222
6527
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
7223
|
-
|
|
6528
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
7224
6529
|
version: "1",
|
|
7225
6530
|
lastSync: new Date().toISOString(),
|
|
7226
6531
|
files: {
|
|
@@ -7249,7 +6554,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
7249
6554
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
7250
6555
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
7251
6556
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
7252
|
-
const journal =
|
|
6557
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
7253
6558
|
expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
|
|
7254
6559
|
});
|
|
7255
6560
|
it("defers an unreadable scope-invalid symlink tombstone without a fatal error", async () => {
|
|
@@ -7261,7 +6566,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
7261
6566
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
7262
6567
|
fs.symlinkSync(targetDir, linkPath, "dir");
|
|
7263
6568
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
7264
|
-
|
|
6569
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
7265
6570
|
version: "1",
|
|
7266
6571
|
lastSync: new Date().toISOString(),
|
|
7267
6572
|
files: {
|
|
@@ -7306,7 +6611,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
7306
6611
|
samplePaths: [linkKey],
|
|
7307
6612
|
});
|
|
7308
6613
|
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
7309
|
-
const journal =
|
|
6614
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
7310
6615
|
expect(journal.files[linkKey]).toBeDefined();
|
|
7311
6616
|
}
|
|
7312
6617
|
finally {
|