@indigoai-us/hq-cloud 6.15.31 → 6.15.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bandwidth.d.ts +128 -0
- package/dist/bandwidth.d.ts.map +1 -0
- package/dist/bandwidth.js +254 -0
- package/dist/bandwidth.js.map +1 -0
- package/dist/bandwidth.test.d.ts +2 -0
- package/dist/bandwidth.test.d.ts.map +1 -0
- package/dist/bandwidth.test.js +243 -0
- package/dist/bandwidth.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +13 -24
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.js +71 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +10 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +181 -16
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +16 -1
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +40 -2
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.test.js +53 -1
- package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +15 -3
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +66 -13
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +433 -13
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict.d.ts +25 -2
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +29 -4
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/share.d.ts +40 -4
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +490 -90
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +881 -150
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +147 -18
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +20 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +261 -29
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +519 -90
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal-delta-writes.test.d.ts +2 -0
- package/dist/journal-delta-writes.test.d.ts.map +1 -0
- package/dist/journal-delta-writes.test.js +140 -0
- package/dist/journal-delta-writes.test.js.map +1 -0
- package/dist/journal.d.ts +1 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +155 -25
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +29 -11
- package/dist/journal.test.js.map +1 -1
- package/dist/lib/describe-error.d.ts +0 -19
- package/dist/lib/describe-error.d.ts.map +1 -1
- package/dist/lib/describe-error.js +27 -3
- package/dist/lib/describe-error.js.map +1 -1
- package/dist/lib/describe-error.test.js +23 -0
- package/dist/lib/describe-error.test.js.map +1 -1
- package/dist/object-io.d.ts +34 -13
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +154 -29
- package/dist/object-io.js.map +1 -1
- package/dist/operation-lock.d.ts +12 -0
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +103 -21
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +90 -0
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/outcome-telemetry.d.ts +7 -1
- package/dist/outcome-telemetry.d.ts.map +1 -1
- package/dist/outcome-telemetry.js +15 -9
- package/dist/outcome-telemetry.js.map +1 -1
- package/dist/outcome-telemetry.test.js +15 -0
- package/dist/outcome-telemetry.test.js.map +1 -1
- package/dist/outposts/client.d.ts +2 -2
- package/dist/outposts/client.d.ts.map +1 -1
- package/dist/outposts/client.js +4 -3
- package/dist/outposts/client.js.map +1 -1
- package/dist/outposts/command.d.ts.map +1 -1
- package/dist/outposts/command.js +9 -6
- package/dist/outposts/command.js.map +1 -1
- package/dist/outposts/command.test.js +59 -2
- package/dist/outposts/command.test.js.map +1 -1
- package/dist/outposts/remote-command.d.ts +8 -8
- package/dist/outposts/remote-command.d.ts.map +1 -1
- package/dist/outposts/remote-command.js +10 -8
- package/dist/outposts/remote-command.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +14 -0
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +29 -0
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +22 -1
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -8
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +43 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sts-credential-refresh.test.js +87 -2
- package/dist/sts-credential-refresh.test.js.map +1 -1
- package/dist/sync/state-store.d.ts.map +1 -1
- package/dist/sync/state-store.js +20 -1
- package/dist/sync/state-store.js.map +1 -1
- package/dist/sync/state-store.test.js +64 -0
- package/dist/sync/state-store.test.js.map +1 -1
- package/dist/sync-core.d.ts.map +1 -1
- package/dist/sync-core.js +7 -2
- package/dist/sync-core.js.map +1 -1
- package/dist/sync-core.test.js +36 -0
- package/dist/sync-core.test.js.map +1 -1
- package/dist/telemetry.d.ts +30 -6
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +331 -135
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +403 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +26 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +76 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +105 -3
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +165 -1
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/share.test.js
CHANGED
|
@@ -53,9 +53,49 @@ vi.mock("readline", () => ({
|
|
|
53
53
|
}));
|
|
54
54
|
import * as readline from "readline";
|
|
55
55
|
import { share, isForbiddenCompanyVaultKey, UnreachablePushPathsError, currentUploadBytes, _testing as shareTesting } from "./share.js";
|
|
56
|
-
import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink, WindowsSymlinkPrivilegeError } from "../s3.js";
|
|
56
|
+
import { deleteRemoteFile, downloadFile, headRemoteFile, primeObjectTransport, primeUploads, uploadFile, uploadSymlink, WindowsSymlinkPrivilegeError, } from "../s3.js";
|
|
57
57
|
import { VaultAuthError } from "../vault-client.js";
|
|
58
|
-
import { hashFile } from "../journal.js";
|
|
58
|
+
import { hashFile, readJournal, writeJournal } from "../journal.js";
|
|
59
|
+
/**
|
|
60
|
+
* Read a journal the way production does.
|
|
61
|
+
*
|
|
62
|
+
* These tests used to `JSON.parse` `sync-journal.<slug>.json` directly, back
|
|
63
|
+
* when that file held a full copy of the journal. It is now a small locator —
|
|
64
|
+
* the authority is the v3 state store — so the assertions below go through
|
|
65
|
+
* `readJournal`, which is what the sync engine itself uses. The slug is
|
|
66
|
+
* recovered from the path so each call site keeps naming the journal it means.
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* Seed a journal the way production does.
|
|
70
|
+
*
|
|
71
|
+
* These fixtures used to write `sync-journal.<slug>.json` by hand, which worked
|
|
72
|
+
* only while that file WAS the journal. It is a locator now, so a hand-written
|
|
73
|
+
* copy is simply ignored and the test silently runs against an empty journal.
|
|
74
|
+
* Going through `writeJournal` puts the state where the engine actually reads
|
|
75
|
+
* it, and migrates v1 fixtures on the way in exactly as a real upgrade would.
|
|
76
|
+
*/
|
|
77
|
+
function seedJournalAt(journalPath, journal) {
|
|
78
|
+
const slug = path
|
|
79
|
+
.basename(journalPath)
|
|
80
|
+
.replace(/^sync-journal\./, "")
|
|
81
|
+
.replace(/\.json$/, "");
|
|
82
|
+
writeJournal(slug, journal);
|
|
83
|
+
}
|
|
84
|
+
function journalAt(journalPath) {
|
|
85
|
+
const slug = path
|
|
86
|
+
.basename(journalPath)
|
|
87
|
+
.replace(/^sync-journal\./, "")
|
|
88
|
+
.replace(/\.json$/, "");
|
|
89
|
+
return readJournal(slug);
|
|
90
|
+
}
|
|
91
|
+
function sidecarIn(dir) {
|
|
92
|
+
const found = fs.readdirSync(dir).filter((name) => name.includes(".conflict-"));
|
|
93
|
+
expect(found).toHaveLength(1);
|
|
94
|
+
return path.join(dir, found[0]);
|
|
95
|
+
}
|
|
96
|
+
function conflictIndexAt(hqRoot) {
|
|
97
|
+
return JSON.parse(fs.readFileSync(path.join(hqRoot, ".hq-conflicts", "index.json"), "utf-8"));
|
|
98
|
+
}
|
|
59
99
|
const mockConfig = {
|
|
60
100
|
apiUrl: "https://vault-api.test",
|
|
61
101
|
authToken: "test-jwt-token",
|
|
@@ -369,6 +409,74 @@ describe("share", () => {
|
|
|
369
409
|
// Remote key must be company-relative, not hqRoot-relative
|
|
370
410
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "test.md", undefined, expect.anything());
|
|
371
411
|
});
|
|
412
|
+
it("replans an upload from the bytes present when its action starts", async () => {
|
|
413
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
414
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
415
|
+
const testFile = path.join(companyRoot, "changing.md");
|
|
416
|
+
fs.writeFileSync(testFile, "body seen by the whole-run preview");
|
|
417
|
+
let uploadedBody = "";
|
|
418
|
+
vi.mocked(uploadFile).mockImplementationOnce(async (_ctx, localPath) => {
|
|
419
|
+
uploadedBody = fs.readFileSync(localPath, "utf-8");
|
|
420
|
+
return { etag: '"fresh-upload-etag"' };
|
|
421
|
+
});
|
|
422
|
+
let changedAfterPlan = false;
|
|
423
|
+
let executionHeartbeatCalls = 0;
|
|
424
|
+
const result = await share({
|
|
425
|
+
paths: [testFile],
|
|
426
|
+
company: "acme",
|
|
427
|
+
vaultConfig: mockConfig,
|
|
428
|
+
hqRoot: tmpDir,
|
|
429
|
+
onEvent: (event) => {
|
|
430
|
+
if (event.type === "plan" && !changedAfterPlan) {
|
|
431
|
+
changedAfterPlan = true;
|
|
432
|
+
fs.writeFileSync(testFile, "body present when upload executes");
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
onHeartbeat: () => {
|
|
436
|
+
if (changedAfterPlan)
|
|
437
|
+
executionHeartbeatCalls++;
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
expect(changedAfterPlan).toBe(true);
|
|
441
|
+
expect(executionHeartbeatCalls).toBeGreaterThan(0);
|
|
442
|
+
expect(result.filesUploaded).toBe(1);
|
|
443
|
+
expect(uploadedBody).toBe("body present when upload executes");
|
|
444
|
+
expect(journalAt(path.join(stateDir, "sync-journal.acme.json")).files["changing.md"])
|
|
445
|
+
.toMatchObject({
|
|
446
|
+
hash: hashFile(testFile),
|
|
447
|
+
remoteEtag: "fresh-upload-etag",
|
|
448
|
+
direction: "up",
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
it("restarts the per-file decision when local bytes change during remote HEAD", async () => {
|
|
452
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
453
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
454
|
+
const testFile = path.join(companyRoot, "head-race.md");
|
|
455
|
+
fs.writeFileSync(testFile, "body before HEAD");
|
|
456
|
+
vi.mocked(headRemoteFile).mockImplementationOnce(async () => {
|
|
457
|
+
fs.writeFileSync(testFile, "body written while HEAD was in flight");
|
|
458
|
+
return null;
|
|
459
|
+
});
|
|
460
|
+
let uploadedBody = "";
|
|
461
|
+
vi.mocked(uploadFile).mockImplementationOnce(async (_ctx, localPath) => {
|
|
462
|
+
uploadedBody = fs.readFileSync(localPath, "utf-8");
|
|
463
|
+
return { etag: '"head-race-etag"' };
|
|
464
|
+
});
|
|
465
|
+
const result = await share({
|
|
466
|
+
paths: [testFile],
|
|
467
|
+
company: "acme",
|
|
468
|
+
vaultConfig: mockConfig,
|
|
469
|
+
hqRoot: tmpDir,
|
|
470
|
+
});
|
|
471
|
+
expect(result.filesUploaded).toBe(1);
|
|
472
|
+
expect(uploadedBody).toBe("body written while HEAD was in flight");
|
|
473
|
+
expect(journalAt(path.join(stateDir, "sync-journal.acme.json")).files["head-race.md"])
|
|
474
|
+
.toMatchObject({
|
|
475
|
+
hash: hashFile(testFile),
|
|
476
|
+
remoteEtag: "head-race-etag",
|
|
477
|
+
direction: "up",
|
|
478
|
+
});
|
|
479
|
+
});
|
|
372
480
|
it("propagates a per-file vault 401 to the runner auth boundary", async () => {
|
|
373
481
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
374
482
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -382,6 +490,42 @@ describe("share", () => {
|
|
|
382
490
|
hqRoot: tmpDir,
|
|
383
491
|
})).rejects.toBeInstanceOf(VaultAuthError);
|
|
384
492
|
});
|
|
493
|
+
it("surfaces a safe nested upload-worker cause without replacing the first error", async () => {
|
|
494
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
495
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
496
|
+
const firstFile = path.join(companyRoot, "first.md");
|
|
497
|
+
const secondFile = path.join(companyRoot, "second.md");
|
|
498
|
+
fs.writeFileSync(firstFile, "first");
|
|
499
|
+
fs.writeFileSync(secondFile, "second");
|
|
500
|
+
const networkCause = Object.assign(new Error("connect ECONNREFUSED sync.example.test Authorization: Bearer upload-secret"), { code: "ECONNREFUSED", syscall: "connect", hostname: "sync.example.test" });
|
|
501
|
+
const first = new VaultAuthError("UnknownError");
|
|
502
|
+
first.name = "UnknownError";
|
|
503
|
+
first.cause = networkCause;
|
|
504
|
+
const second = new VaultAuthError("second worker failed");
|
|
505
|
+
vi.mocked(uploadFile)
|
|
506
|
+
.mockRejectedValueOnce(first)
|
|
507
|
+
.mockRejectedValueOnce(second);
|
|
508
|
+
let thrown;
|
|
509
|
+
try {
|
|
510
|
+
await share({
|
|
511
|
+
paths: [firstFile, secondFile],
|
|
512
|
+
company: "acme",
|
|
513
|
+
vaultConfig: mockConfig,
|
|
514
|
+
hqRoot: tmpDir,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
thrown = err;
|
|
519
|
+
}
|
|
520
|
+
expect(thrown).toBe(first);
|
|
521
|
+
expect(first.cause).toBe(networkCause);
|
|
522
|
+
expect(first.message).toContain("cause=ECONNREFUSED");
|
|
523
|
+
expect(first.message).toContain("syscall=connect");
|
|
524
|
+
expect(first.message).toContain("host=sync.example.test");
|
|
525
|
+
expect(first.message).toContain("(and 1 more upload-worker errors)");
|
|
526
|
+
expect(first.message).not.toContain("upload-secret");
|
|
527
|
+
expect(first.message.length).toBeLessThanOrEqual(1_024);
|
|
528
|
+
});
|
|
385
529
|
it("respects ignore rules", async () => {
|
|
386
530
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
387
531
|
fs.mkdirSync(path.join(companyRoot, ".git"), { recursive: true });
|
|
@@ -528,7 +672,7 @@ describe("share", () => {
|
|
|
528
672
|
const { hashFile } = await import("../journal.js");
|
|
529
673
|
const hash = hashFile(testFile);
|
|
530
674
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
531
|
-
|
|
675
|
+
seedJournalAt(journalPath, ({
|
|
532
676
|
version: "1",
|
|
533
677
|
lastSync: new Date().toISOString(),
|
|
534
678
|
files: {
|
|
@@ -559,7 +703,7 @@ describe("share", () => {
|
|
|
559
703
|
// Journal has a stale hash for this path — simulating "local has been
|
|
560
704
|
// edited since the last push".
|
|
561
705
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
562
|
-
|
|
706
|
+
seedJournalAt(journalPath, ({
|
|
563
707
|
version: "1",
|
|
564
708
|
lastSync: new Date().toISOString(),
|
|
565
709
|
files: {
|
|
@@ -581,6 +725,226 @@ describe("share", () => {
|
|
|
581
725
|
expect(result.filesUploaded).toBe(1);
|
|
582
726
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "changed.md", undefined, expect.anything());
|
|
583
727
|
});
|
|
728
|
+
// ── Stat fast path for change detection ─────────────────────────────────
|
|
729
|
+
// Push used to hash EVERY regular file every pass before deciding whether to
|
|
730
|
+
// skip it — half a million reads on a real vault — because stat metadata was
|
|
731
|
+
// considered untrustworthy: a tool can rewrite a file to the same length and
|
|
732
|
+
// restore its mtime, and a size+mtime check would then skip that edit
|
|
733
|
+
// forever.
|
|
734
|
+
//
|
|
735
|
+
// These tests pin the narrower claim the fast path actually rests on: size,
|
|
736
|
+
// mtime AND ctime together. ctime is the inode-change stamp, and userspace
|
|
737
|
+
// cannot restore it — `cp -p`, `touch -r` and `rsync --times` all reset mtime
|
|
738
|
+
// while ctime moves to now. So the same-length-plus-restored-mtime rewrite
|
|
739
|
+
// that the old comment warned about is still caught, without reading a byte.
|
|
740
|
+
//
|
|
741
|
+
// Differential proof: drop ctime from the comparison and the "restored mtime"
|
|
742
|
+
// test below fails — the edit is skipped and never uploaded.
|
|
743
|
+
describe("stat fast path for change detection", () => {
|
|
744
|
+
const CONTENT_A = "aaaaaaaaaa"; // 10 bytes
|
|
745
|
+
const CONTENT_B = "bbbbbbbbbb"; // 10 bytes — same length, different bytes
|
|
746
|
+
/** Write `body` at `key` under the acme company root; return its path. */
|
|
747
|
+
function writeFile(key, body) {
|
|
748
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
749
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
750
|
+
const abs = path.join(companyRoot, key);
|
|
751
|
+
fs.writeFileSync(abs, body);
|
|
752
|
+
return abs;
|
|
753
|
+
}
|
|
754
|
+
function seed(key, entry) {
|
|
755
|
+
seedJournalAt(path.join(stateDir, "sync-journal.acme.json"), {
|
|
756
|
+
version: "2",
|
|
757
|
+
lastSync: new Date().toISOString(),
|
|
758
|
+
files: { [key]: { syncedAt: new Date().toISOString(), direction: "up", ...entry } },
|
|
759
|
+
pulls: [],
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
function push(abs, onConflict) {
|
|
763
|
+
return share({
|
|
764
|
+
paths: [abs],
|
|
765
|
+
company: "acme",
|
|
766
|
+
vaultConfig: mockConfig,
|
|
767
|
+
hqRoot: tmpDir,
|
|
768
|
+
skipUnchanged: true,
|
|
769
|
+
...(onConflict ? { onConflict } : {}),
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
it("skips a file whose size, mtime and ctime all match — without reading it", async () => {
|
|
773
|
+
const abs = writeFile("untouched.md", CONTENT_A);
|
|
774
|
+
const stat = fs.lstatSync(abs);
|
|
775
|
+
// The recorded hash is deliberately WRONG. Hashing the file would find a
|
|
776
|
+
// mismatch and upload, so a skip here can only mean the bytes were never
|
|
777
|
+
// read — which is the whole point of the fast path.
|
|
778
|
+
seed("untouched.md", {
|
|
779
|
+
hash: "deliberately-not-the-hash-of-this-file",
|
|
780
|
+
size: stat.size,
|
|
781
|
+
mtimeMs: stat.mtimeMs,
|
|
782
|
+
ctimeMs: stat.ctimeMs,
|
|
783
|
+
});
|
|
784
|
+
const result = await push(abs);
|
|
785
|
+
expect(result.filesSkipped).toBe(1);
|
|
786
|
+
expect(result.filesUploaded).toBe(0);
|
|
787
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
788
|
+
});
|
|
789
|
+
it("re-reads when the size differs", async () => {
|
|
790
|
+
const abs = writeFile("resized.md", CONTENT_A + "extra");
|
|
791
|
+
const stat = fs.lstatSync(abs);
|
|
792
|
+
seed("resized.md", {
|
|
793
|
+
hash: hashFile(abs) + "-stale",
|
|
794
|
+
size: CONTENT_A.length, // shorter than what is on disk now
|
|
795
|
+
mtimeMs: stat.mtimeMs,
|
|
796
|
+
ctimeMs: stat.ctimeMs,
|
|
797
|
+
});
|
|
798
|
+
const result = await push(abs);
|
|
799
|
+
expect(result.filesUploaded).toBe(1);
|
|
800
|
+
expect(uploadFile).toHaveBeenCalled();
|
|
801
|
+
});
|
|
802
|
+
it("re-reads when the mtime differs", async () => {
|
|
803
|
+
const abs = writeFile("retimed.md", CONTENT_B);
|
|
804
|
+
const stat = fs.lstatSync(abs);
|
|
805
|
+
seed("retimed.md", {
|
|
806
|
+
hash: "hash-of-the-previous-body",
|
|
807
|
+
size: stat.size, // same length
|
|
808
|
+
mtimeMs: stat.mtimeMs - 60_000, // journal remembers an older edit time
|
|
809
|
+
ctimeMs: stat.ctimeMs,
|
|
810
|
+
});
|
|
811
|
+
const result = await push(abs);
|
|
812
|
+
expect(result.filesUploaded).toBe(1);
|
|
813
|
+
expect(uploadFile).toHaveBeenCalled();
|
|
814
|
+
});
|
|
815
|
+
it("re-reads a same-length rewrite whose mtime was restored — ctime still moves", async () => {
|
|
816
|
+
// The exact case the old hash-everything comment existed to defend
|
|
817
|
+
// against, as produced by `cp -p`, `touch -r` or `rsync --times`.
|
|
818
|
+
const abs = writeFile("mtime-restored.md", CONTENT_A);
|
|
819
|
+
const before = fs.lstatSync(abs);
|
|
820
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
821
|
+
fs.writeFileSync(abs, CONTENT_B); // same length, different bytes
|
|
822
|
+
fs.utimesSync(abs, before.atime, before.mtime); // put the old mtime back
|
|
823
|
+
const after = fs.lstatSync(abs);
|
|
824
|
+
// Preconditions. The disguise worked on size, and as closely on mtime as
|
|
825
|
+
// the OS allows — `utimes` takes whole milliseconds, so a restored stamp
|
|
826
|
+
// lands within 1ms of the original rather than exactly on it. It failed
|
|
827
|
+
// on ctime, which is the only signal left.
|
|
828
|
+
expect(after.size).toBe(before.size);
|
|
829
|
+
expect(Math.abs(after.mtimeMs - before.mtimeMs)).toBeLessThan(1);
|
|
830
|
+
expect(after.ctimeMs).not.toBe(before.ctimeMs);
|
|
831
|
+
// Seed the mtime that is actually on disk, so mtime matches EXACTLY and
|
|
832
|
+
// cannot be what triggers the re-read. Only the stale ctime can.
|
|
833
|
+
seed("mtime-restored.md", {
|
|
834
|
+
hash: "hash-of-content-a",
|
|
835
|
+
size: before.size,
|
|
836
|
+
mtimeMs: after.mtimeMs,
|
|
837
|
+
ctimeMs: before.ctimeMs,
|
|
838
|
+
});
|
|
839
|
+
const result = await push(abs);
|
|
840
|
+
expect(result.filesUploaded).toBe(1);
|
|
841
|
+
expect(uploadFile).toHaveBeenCalled();
|
|
842
|
+
});
|
|
843
|
+
it("falls back to reading when the journal predates stat tracking, then stamps it", async () => {
|
|
844
|
+
// Entries written before ctime was recorded carry no stat metadata. They
|
|
845
|
+
// must not be skipped on faith; they are read once and stamped so the
|
|
846
|
+
// next pass takes the fast path.
|
|
847
|
+
const abs = writeFile("legacy.md", CONTENT_A);
|
|
848
|
+
seed("legacy.md", { hash: hashFile(abs), size: CONTENT_A.length });
|
|
849
|
+
const result = await push(abs);
|
|
850
|
+
expect(result.filesSkipped).toBe(1);
|
|
851
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
852
|
+
const entry = journalAt(path.join(stateDir, "sync-journal.acme.json")).files["legacy.md"];
|
|
853
|
+
const stat = fs.lstatSync(abs);
|
|
854
|
+
expect(entry.mtimeMs).toBe(stat.mtimeMs);
|
|
855
|
+
expect(entry.ctimeMs).toBe(stat.ctimeMs);
|
|
856
|
+
});
|
|
857
|
+
it("never lets the fast path skip a localDiverges entry", async () => {
|
|
858
|
+
// A pull-held divergent entry stores the LOCAL hash and local stat, so
|
|
859
|
+
// every field matches by construction. Skipping on that asserts "local
|
|
860
|
+
// matches remote", which is exactly what the flag exists to deny.
|
|
861
|
+
const abs = writeFile("divergent.md", CONTENT_A);
|
|
862
|
+
const stat = fs.lstatSync(abs);
|
|
863
|
+
seed("divergent.md", {
|
|
864
|
+
hash: hashFile(abs),
|
|
865
|
+
size: stat.size,
|
|
866
|
+
mtimeMs: stat.mtimeMs,
|
|
867
|
+
ctimeMs: stat.ctimeMs,
|
|
868
|
+
localDiverges: true,
|
|
869
|
+
});
|
|
870
|
+
// `keep` so the divergence resolves without an interactive prompt. Under
|
|
871
|
+
// `keep` the file is deliberately NOT uploaded, so the proof that the
|
|
872
|
+
// fast path did not swallow it is that it reached Stage 2 at all: a
|
|
873
|
+
// fast-path skip returns before any remote HEAD is issued.
|
|
874
|
+
await push(abs, "keep");
|
|
875
|
+
expect(headRemoteFile).toHaveBeenCalled();
|
|
876
|
+
});
|
|
877
|
+
it("stamps size, mtime and ctime on upload so the next pass can skip", async () => {
|
|
878
|
+
const abs = writeFile("fresh.md", CONTENT_A);
|
|
879
|
+
const result = await push(abs);
|
|
880
|
+
expect(result.filesUploaded).toBe(1);
|
|
881
|
+
const entry = journalAt(path.join(stateDir, "sync-journal.acme.json")).files["fresh.md"];
|
|
882
|
+
const stat = fs.lstatSync(abs);
|
|
883
|
+
expect(entry.size).toBe(stat.size);
|
|
884
|
+
expect(entry.mtimeMs).toBe(stat.mtimeMs);
|
|
885
|
+
expect(entry.ctimeMs).toBe(stat.ctimeMs);
|
|
886
|
+
});
|
|
887
|
+
it("replans and stamps the new body when the file changes during remote HEAD", async () => {
|
|
888
|
+
// The pass-level preview hashes CONTENT_A, then the asynchronous remote
|
|
889
|
+
// probe changes the file to CONTENT_B. The per-file execution seam must
|
|
890
|
+
// restart from CONTENT_B, upload it now, and journal that exact body — no
|
|
891
|
+
// stale hash and no redundant upload on the next pass.
|
|
892
|
+
const abs = writeFile("edited-mid-flight.md", CONTENT_A);
|
|
893
|
+
let mutated = false;
|
|
894
|
+
vi.mocked(headRemoteFile).mockImplementation(async () => {
|
|
895
|
+
if (!mutated) {
|
|
896
|
+
mutated = true;
|
|
897
|
+
fs.writeFileSync(abs, CONTENT_B); // same length, different bytes
|
|
898
|
+
}
|
|
899
|
+
return null;
|
|
900
|
+
});
|
|
901
|
+
await push(abs);
|
|
902
|
+
const entry = journalAt(path.join(stateDir, "sync-journal.acme.json")).files["edited-mid-flight.md"];
|
|
903
|
+
const currentStat = fs.lstatSync(abs);
|
|
904
|
+
expect(entry.hash).toBe(hashFile(abs));
|
|
905
|
+
expect(entry.mtimeMs).toBe(currentStat.mtimeMs);
|
|
906
|
+
expect(entry.ctimeMs).toBe(currentStat.ctimeMs);
|
|
907
|
+
// The current bytes were already shipped and journaled in this pass.
|
|
908
|
+
vi.mocked(uploadFile).mockClear();
|
|
909
|
+
const second = await push(abs);
|
|
910
|
+
expect(second.filesUploaded).toBe(0);
|
|
911
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
912
|
+
});
|
|
913
|
+
it("distrusts whole-second timestamps as a coarse-filesystem signal", () => {
|
|
914
|
+
// Codex P2. FAT/exFAT, some FUSE mounts and cached network filesystems
|
|
915
|
+
// report timestamps rounded to whole seconds, so a same-size rewrite
|
|
916
|
+
// inside one tick can leave both mtime and ctime equal to the journal's
|
|
917
|
+
// and be skipped indefinitely. Every filesystem HQ can trust here (APFS,
|
|
918
|
+
// ext4, NTFS) records sub-second precision, so a pair that is exactly on
|
|
919
|
+
// the second is treated as untrustworthy and sent back to hashing.
|
|
920
|
+
const abs = writeFile("coarse.md", CONTENT_A);
|
|
921
|
+
const coarse = { size: CONTENT_A.length, mtimeMs: 1_700_000_000_000, ctimeMs: 1_700_000_000_000 };
|
|
922
|
+
const spy = vi.spyOn(fs, "lstatSync").mockReturnValue({
|
|
923
|
+
isFile: () => true,
|
|
924
|
+
...coarse,
|
|
925
|
+
});
|
|
926
|
+
try {
|
|
927
|
+
expect(shareTesting.statMatchesJournal(abs, {
|
|
928
|
+
hash: "h",
|
|
929
|
+
direction: "up",
|
|
930
|
+
syncedAt: new Date().toISOString(),
|
|
931
|
+
...coarse,
|
|
932
|
+
})).toBe(false);
|
|
933
|
+
// Same values, one of them sub-second: a real filesystem, so trusted.
|
|
934
|
+
const fine = { ...coarse, ctimeMs: coarse.ctimeMs + 0.5 };
|
|
935
|
+
spy.mockReturnValue({ isFile: () => true, ...fine });
|
|
936
|
+
expect(shareTesting.statMatchesJournal(abs, {
|
|
937
|
+
hash: "h",
|
|
938
|
+
direction: "up",
|
|
939
|
+
syncedAt: new Date().toISOString(),
|
|
940
|
+
...fine,
|
|
941
|
+
})).toBe(true);
|
|
942
|
+
}
|
|
943
|
+
finally {
|
|
944
|
+
spy.mockRestore();
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
});
|
|
584
948
|
// ── Push-side FILE_TOMBSTONE consult (delete-resync, 3B) ────────────────────
|
|
585
949
|
// An authoritative delete (`hq files delete`) writes a FILE_TOMBSTONE and
|
|
586
950
|
// removes the S3 object. Without a push-side consult, a behind peer that still
|
|
@@ -593,7 +957,7 @@ describe("share", () => {
|
|
|
593
957
|
// share.ts makes the first test fail (uploadFile IS called → resurrection).
|
|
594
958
|
describe("push-side tombstone consult", () => {
|
|
595
959
|
function seedJournal(key, hash, size) {
|
|
596
|
-
|
|
960
|
+
seedJournalAt(path.join(stateDir, "sync-journal.acme.json"), ({
|
|
597
961
|
version: "1",
|
|
598
962
|
lastSync: new Date().toISOString(),
|
|
599
963
|
files: {
|
|
@@ -630,6 +994,8 @@ describe("share", () => {
|
|
|
630
994
|
});
|
|
631
995
|
// The resurrection is blocked at the source: no upload for the tombstoned key.
|
|
632
996
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
997
|
+
expect(primeUploads).toHaveBeenCalledWith(expect.anything(), []);
|
|
998
|
+
expect(primeObjectTransport).toHaveBeenCalledWith(expect.anything(), "get", []);
|
|
633
999
|
expect(result.filesUploaded).toBe(0);
|
|
634
1000
|
expect(result.filesSuppressedByTombstone).toBe(1);
|
|
635
1001
|
expect(events.some((e) => e.type === "upload-suppressed-tombstone" && e.path === "docs/shared.md")).toBe(true);
|
|
@@ -736,8 +1102,12 @@ describe("share", () => {
|
|
|
736
1102
|
etag: '"remote-new-etag"',
|
|
737
1103
|
size: 99,
|
|
738
1104
|
});
|
|
1105
|
+
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1106
|
+
fs.writeFileSync(dest, "remote edit");
|
|
1107
|
+
return undefined;
|
|
1108
|
+
});
|
|
739
1109
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
740
|
-
|
|
1110
|
+
seedJournalAt(journalPath, ({
|
|
741
1111
|
version: "1",
|
|
742
1112
|
lastSync: new Date().toISOString(),
|
|
743
1113
|
files: {
|
|
@@ -768,6 +1138,24 @@ describe("share", () => {
|
|
|
768
1138
|
direction: "push",
|
|
769
1139
|
resolution: "keep",
|
|
770
1140
|
});
|
|
1141
|
+
// `keep` is cloud-wins on the push leg too: remote takes the working
|
|
1142
|
+
// name, while the displaced local body remains recoverable beside it.
|
|
1143
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("remote edit");
|
|
1144
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe("local edit");
|
|
1145
|
+
const journal = journalAt(journalPath);
|
|
1146
|
+
expect(journal.files["drifted.md"]).toMatchObject({
|
|
1147
|
+
hash: hashFile(testFile),
|
|
1148
|
+
remoteEtag: "remote-new-etag",
|
|
1149
|
+
direction: "down",
|
|
1150
|
+
});
|
|
1151
|
+
const index = conflictIndexAt(tmpDir);
|
|
1152
|
+
expect(index.conflicts).toHaveLength(1);
|
|
1153
|
+
expect(index.conflicts[0]).toMatchObject({
|
|
1154
|
+
originalPath: "companies/acme/drifted.md",
|
|
1155
|
+
preserved: "local",
|
|
1156
|
+
});
|
|
1157
|
+
expect(fs.readFileSync(path.join(tmpDir, index.conflicts[0].conflictPath), "utf-8"))
|
|
1158
|
+
.toBe("local edit");
|
|
771
1159
|
});
|
|
772
1160
|
it("cloud-authoritative: never pushes a stale local ontology/.last-run over the server value (one-sided-change clobber guard)", async () => {
|
|
773
1161
|
// Regression for the gardener watermark clobber. A second HQ root sharing this
|
|
@@ -789,7 +1177,7 @@ describe("share", () => {
|
|
|
789
1177
|
// assert it is never consulted below — the skip is cheap, and a leftover
|
|
790
1178
|
// mockResolvedValueOnce would otherwise leak into a later test's HEAD queue.
|
|
791
1179
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
792
|
-
|
|
1180
|
+
seedJournalAt(journalPath, ({
|
|
793
1181
|
version: "1",
|
|
794
1182
|
lastSync: new Date().toISOString(),
|
|
795
1183
|
files: {
|
|
@@ -835,9 +1223,9 @@ describe("share", () => {
|
|
|
835
1223
|
//
|
|
836
1224
|
// Fix: even on first-time upload, if HEAD shows remote exists and
|
|
837
1225
|
// its content (etag) differs from what we'd put, emit a conflict.
|
|
838
|
-
// Under keep, the
|
|
839
|
-
//
|
|
840
|
-
//
|
|
1226
|
+
// Under keep, cloud takes the original name and local moves to
|
|
1227
|
+
// \`<orig>.conflict-<ts>-<short>\` so both versions survive on disk;
|
|
1228
|
+
// under abort, the company aborts before PUT.
|
|
841
1229
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
842
1230
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
843
1231
|
const testFile = path.join(companyRoot, "v7b-fresh.txt");
|
|
@@ -849,6 +1237,10 @@ describe("share", () => {
|
|
|
849
1237
|
etag: '"remote-version-etag"',
|
|
850
1238
|
size: 40,
|
|
851
1239
|
});
|
|
1240
|
+
vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, dest) => {
|
|
1241
|
+
fs.writeFileSync(dest, "peer remote content");
|
|
1242
|
+
return undefined;
|
|
1243
|
+
});
|
|
852
1244
|
// No journal file written — first-time upload contract.
|
|
853
1245
|
const events = [];
|
|
854
1246
|
const result = await share({
|
|
@@ -860,14 +1252,19 @@ describe("share", () => {
|
|
|
860
1252
|
onEvent: (e) => events.push(e),
|
|
861
1253
|
});
|
|
862
1254
|
expect(result.conflictPaths).toEqual(["v7b-fresh.txt"]);
|
|
863
|
-
// Under keep,
|
|
864
|
-
// the
|
|
1255
|
+
// Under keep, no upload occurs: the remote bytes take the working name
|
|
1256
|
+
// and the local body is preserved beside them.
|
|
865
1257
|
expect(result.filesUploaded).toBe(0);
|
|
866
1258
|
const conflicts = events.filter((e) => typeof e === "object" && e !== null && e.type === "conflict");
|
|
867
1259
|
expect(conflicts).toHaveLength(1);
|
|
868
1260
|
expect(conflicts[0].path).toBe("v7b-fresh.txt");
|
|
869
|
-
|
|
870
|
-
expect(fs.readFileSync(
|
|
1261
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("peer remote content");
|
|
1262
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe("macos-fresh-collision-content");
|
|
1263
|
+
const index = conflictIndexAt(tmpDir);
|
|
1264
|
+
expect(index.conflicts[0]).toMatchObject({
|
|
1265
|
+
originalPath: "companies/acme/v7b-fresh.txt",
|
|
1266
|
+
preserved: "local",
|
|
1267
|
+
});
|
|
871
1268
|
});
|
|
872
1269
|
it("first-time-upload-with-cloud-collision: aborts company under --on-conflict abort (Bug #7)", async () => {
|
|
873
1270
|
// Symmetric to the keep case: when --on-conflict abort is passed, a
|
|
@@ -895,11 +1292,11 @@ describe("share", () => {
|
|
|
895
1292
|
expect(result.filesUploaded).toBe(0);
|
|
896
1293
|
});
|
|
897
1294
|
it("HQ-DESKTOP-54: a privilege refusal while writing the push conflict mirror is a skip, not an error", async () => {
|
|
898
|
-
//
|
|
899
|
-
//
|
|
1295
|
+
// Push keep also materializes the remote object (at the original name).
|
|
1296
|
+
// On an unprivileged Windows host that throws the typed privilege
|
|
900
1297
|
// error; it must be downgraded to the deliberate skip so the push stays
|
|
901
1298
|
// complete and the runner exits 0 — never type:error. The conflict itself
|
|
902
|
-
// is still
|
|
1299
|
+
// is still surfaced and the local rename is rolled back.
|
|
903
1300
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
904
1301
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
905
1302
|
const testFile = path.join(companyRoot, "v7b-priv.txt");
|
|
@@ -910,7 +1307,7 @@ describe("share", () => {
|
|
|
910
1307
|
size: 40,
|
|
911
1308
|
});
|
|
912
1309
|
// On a real unprivileged host BOTH the fresh-collision reconciled probe and
|
|
913
|
-
// the keep
|
|
1310
|
+
// the keep adoption materialize the remote symlink, so both downloadFile calls
|
|
914
1311
|
// refuse. The probe's catch returns "differs" (conflict) without erroring;
|
|
915
1312
|
// the mirror must downgrade the refusal to the deliberate skip.
|
|
916
1313
|
vi.mocked(downloadFile)
|
|
@@ -931,7 +1328,7 @@ describe("share", () => {
|
|
|
931
1328
|
target: "../target.md",
|
|
932
1329
|
}));
|
|
933
1330
|
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
934
|
-
// The conflict is still
|
|
1331
|
+
// The conflict is still surfaced; the failed adoption restores local.
|
|
935
1332
|
expect(result.conflictPaths).toEqual(["v7b-priv.txt"]);
|
|
936
1333
|
expect(fs.readFileSync(testFile, "utf-8")).toBe("local-collision-content");
|
|
937
1334
|
});
|
|
@@ -969,7 +1366,7 @@ describe("share", () => {
|
|
|
969
1366
|
direction: "push",
|
|
970
1367
|
});
|
|
971
1368
|
expect(fs.readdirSync(companyRoot).filter((name) => name.includes(".conflict-"))).toEqual([]);
|
|
972
|
-
const journal =
|
|
1369
|
+
const journal = readJournal("acme");
|
|
973
1370
|
expect(journal.files["kms-scaffold.md"]).toMatchObject({
|
|
974
1371
|
direction: "up",
|
|
975
1372
|
remoteEtag: "288bfa91684ae21f9ae27c061254d412",
|
|
@@ -1043,7 +1440,7 @@ describe("share", () => {
|
|
|
1043
1440
|
etag: '"0123456789abcdef0123456789abcdef-3"',
|
|
1044
1441
|
size: 32,
|
|
1045
1442
|
});
|
|
1046
|
-
// The probe (and the subsequent keep
|
|
1443
|
+
// The probe (and the subsequent keep adoption download) return DIFFERENT
|
|
1047
1444
|
// remote bytes.
|
|
1048
1445
|
vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, dest) => {
|
|
1049
1446
|
fs.writeFileSync(dest, "a-peers-different-version");
|
|
@@ -1062,8 +1459,8 @@ describe("share", () => {
|
|
|
1062
1459
|
expect(result.filesUploaded).toBe(0);
|
|
1063
1460
|
const conflicts = events.filter((e) => typeof e === "object" && e !== null && e.type === "conflict");
|
|
1064
1461
|
expect(conflicts).toHaveLength(1);
|
|
1065
|
-
|
|
1066
|
-
expect(fs.readFileSync(
|
|
1462
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("a-peers-different-version");
|
|
1463
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe("my-local-version");
|
|
1067
1464
|
});
|
|
1068
1465
|
describe("conditional-write fence (If-Match / If-None-Match / 412)", () => {
|
|
1069
1466
|
const fence412 = () => Object.assign(new Error("Precondition failed"), {
|
|
@@ -1085,7 +1482,7 @@ describe("share", () => {
|
|
|
1085
1482
|
size: 5,
|
|
1086
1483
|
});
|
|
1087
1484
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1088
|
-
|
|
1485
|
+
seedJournalAt(journalPath, ({
|
|
1089
1486
|
version: "1",
|
|
1090
1487
|
lastSync: syncedAt,
|
|
1091
1488
|
files: {
|
|
@@ -1123,12 +1520,18 @@ describe("share", () => {
|
|
|
1123
1520
|
});
|
|
1124
1521
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "brand-new.md", undefined, { ifNoneMatch: "*" });
|
|
1125
1522
|
});
|
|
1126
|
-
it("412 under keep:
|
|
1523
|
+
it("412 under keep: adopts the racing remote, preserves local, and converges the journal", async () => {
|
|
1127
1524
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1128
1525
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1129
1526
|
const testFile = path.join(companyRoot, "raced.md");
|
|
1130
1527
|
fs.writeFileSync(testFile, "my local bytes");
|
|
1131
|
-
vi.mocked(headRemoteFile)
|
|
1528
|
+
vi.mocked(headRemoteFile)
|
|
1529
|
+
.mockResolvedValueOnce(null)
|
|
1530
|
+
.mockResolvedValueOnce({
|
|
1531
|
+
lastModified: new Date(),
|
|
1532
|
+
etag: '"racing-peer-etag"',
|
|
1533
|
+
size: 23,
|
|
1534
|
+
});
|
|
1132
1535
|
vi.mocked(uploadFile).mockRejectedValueOnce(fence412());
|
|
1133
1536
|
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1134
1537
|
fs.writeFileSync(dest, "the racing peer version");
|
|
@@ -1148,19 +1551,22 @@ describe("share", () => {
|
|
|
1148
1551
|
expect(result.filesUploaded).toBe(0);
|
|
1149
1552
|
expect(result.aborted).toBe(false);
|
|
1150
1553
|
expect(events.some((e) => e.type === "conflict" && e.path === "raced.md")).toBe(true);
|
|
1151
|
-
// The racing remote
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
// Local copy untouched.
|
|
1157
|
-
expect(fs.readFileSync(testFile, "utf-8")).toBe("my local bytes");
|
|
1158
|
-
// No journal stamp for the failed PUT — next pass re-evaluates fresh.
|
|
1554
|
+
// The racing remote wins the working name; local remains recoverable.
|
|
1555
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("the racing peer version");
|
|
1556
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe("my local bytes");
|
|
1557
|
+
// The pull-shaped result is journalled as the remote body, so the next
|
|
1558
|
+
// pass does not revive the same resolved conflict.
|
|
1159
1559
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1560
|
+
const journal = journalAt(journalPath);
|
|
1561
|
+
expect(journal.files["raced.md"]).toMatchObject({
|
|
1562
|
+
hash: hashFile(testFile),
|
|
1563
|
+
remoteEtag: "racing-peer-etag",
|
|
1564
|
+
direction: "down",
|
|
1565
|
+
});
|
|
1566
|
+
expect(conflictIndexAt(tmpDir).conflicts[0]).toMatchObject({
|
|
1567
|
+
originalPath: "companies/acme/raced.md",
|
|
1568
|
+
preserved: "local",
|
|
1569
|
+
});
|
|
1164
1570
|
});
|
|
1165
1571
|
it("412 under overwrite: retries exactly once WITHOUT the fence (explicit consent)", async () => {
|
|
1166
1572
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
@@ -1239,8 +1645,12 @@ describe("share", () => {
|
|
|
1239
1645
|
}
|
|
1240
1646
|
return { etag: '"should-not-land"' };
|
|
1241
1647
|
});
|
|
1648
|
+
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1649
|
+
fs.writeFileSync(dest, "reviewed peer body");
|
|
1650
|
+
return undefined;
|
|
1651
|
+
});
|
|
1242
1652
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1243
|
-
|
|
1653
|
+
seedJournalAt(journalPath, ({
|
|
1244
1654
|
version: "1",
|
|
1245
1655
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1246
1656
|
files: {
|
|
@@ -1283,8 +1693,9 @@ describe("share", () => {
|
|
|
1283
1693
|
}
|
|
1284
1694
|
// No unfenced retry under keep.
|
|
1285
1695
|
expect(vi.mocked(uploadFile).mock.calls.some((c) => c[4] === undefined)).toBe(false);
|
|
1286
|
-
//
|
|
1287
|
-
expect(fs.readFileSync(testFile, "utf-8")).toBe(
|
|
1696
|
+
// Keep adopts the peer body but retains the stale local body beside it.
|
|
1697
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("reviewed peer body");
|
|
1698
|
+
expect(fs.readFileSync(sidecarIn(path.dirname(testFile)), "utf-8")).toBe(staleBytes);
|
|
1288
1699
|
});
|
|
1289
1700
|
it("legitimate local-ahead still uploads when remote remains at journal baseline", async () => {
|
|
1290
1701
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
@@ -1300,7 +1711,7 @@ describe("share", () => {
|
|
|
1300
1711
|
});
|
|
1301
1712
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"new-upload-etag"' });
|
|
1302
1713
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1303
|
-
|
|
1714
|
+
seedJournalAt(journalPath, ({
|
|
1304
1715
|
version: "1",
|
|
1305
1716
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1306
1717
|
files: {
|
|
@@ -1334,8 +1745,12 @@ describe("share", () => {
|
|
|
1334
1745
|
etag: '"remote-concurrent"',
|
|
1335
1746
|
size: 20,
|
|
1336
1747
|
});
|
|
1748
|
+
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1749
|
+
fs.writeFileSync(dest, "remote concurrent");
|
|
1750
|
+
return undefined;
|
|
1751
|
+
});
|
|
1337
1752
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1338
|
-
|
|
1753
|
+
seedJournalAt(journalPath, ({
|
|
1339
1754
|
version: "1",
|
|
1340
1755
|
lastSync: new Date().toISOString(),
|
|
1341
1756
|
files: {
|
|
@@ -1358,12 +1773,14 @@ describe("share", () => {
|
|
|
1358
1773
|
expect(result.conflictPaths).toEqual(["both-changed.md"]);
|
|
1359
1774
|
expect(result.filesUploaded).toBe(0);
|
|
1360
1775
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
//
|
|
1366
|
-
// the
|
|
1776
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("remote concurrent");
|
|
1777
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe("local concurrent");
|
|
1778
|
+
});
|
|
1779
|
+
it("localDiverges under keep: a pull-held divergent local yields to cloud without becoming write authority", async () => {
|
|
1780
|
+
// After a pull-side hold (`skip` or pull-leg `publish-local`), the journal
|
|
1781
|
+
// stamps the peer remoteEtag and localDiverges=true because local never
|
|
1782
|
+
// matched that etag. A later push must not treat "remote still at stamped
|
|
1783
|
+
// etag" as license to PUT the divergent local body.
|
|
1367
1784
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1368
1785
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1369
1786
|
const testFile = path.join(companyRoot, "kept-divergent.md");
|
|
@@ -1376,8 +1793,12 @@ describe("share", () => {
|
|
|
1376
1793
|
etag: `"${REMOTE}"`,
|
|
1377
1794
|
size: 40,
|
|
1378
1795
|
});
|
|
1796
|
+
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1797
|
+
fs.writeFileSync(dest, "peer remote body");
|
|
1798
|
+
return undefined;
|
|
1799
|
+
});
|
|
1379
1800
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1380
|
-
|
|
1801
|
+
seedJournalAt(journalPath, ({
|
|
1381
1802
|
version: "1",
|
|
1382
1803
|
lastSync: new Date().toISOString(),
|
|
1383
1804
|
files: {
|
|
@@ -1407,7 +1828,10 @@ describe("share", () => {
|
|
|
1407
1828
|
expect(events.some((e) => e.type === "conflict" &&
|
|
1408
1829
|
e.path === "kept-divergent.md" &&
|
|
1409
1830
|
e.resolution === "keep")).toBe(true);
|
|
1410
|
-
expect(fs.readFileSync(testFile, "utf-8")).toBe(
|
|
1831
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("peer remote body");
|
|
1832
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe(localBytes);
|
|
1833
|
+
expect(journalAt(journalPath).files["kept-divergent.md"]?.localDiverges)
|
|
1834
|
+
.toBeFalsy();
|
|
1411
1835
|
});
|
|
1412
1836
|
it("localDiverges under overwrite: explicit intent may force-write", async () => {
|
|
1413
1837
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
@@ -1423,7 +1847,7 @@ describe("share", () => {
|
|
|
1423
1847
|
});
|
|
1424
1848
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"forced-etag"' });
|
|
1425
1849
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1426
|
-
|
|
1850
|
+
seedJournalAt(journalPath, ({
|
|
1427
1851
|
version: "1",
|
|
1428
1852
|
lastSync: new Date().toISOString(),
|
|
1429
1853
|
files: {
|
|
@@ -1450,6 +1874,304 @@ describe("share", () => {
|
|
|
1450
1874
|
// fence then retry — either is consent. Must not leave the file unuploaded.
|
|
1451
1875
|
expect(uploadFile).toHaveBeenCalled();
|
|
1452
1876
|
});
|
|
1877
|
+
// ── publish-local: fenced promotion of a kept-local copy ────────────────
|
|
1878
|
+
//
|
|
1879
|
+
// `keep` leaves a file permanently unpublishable: push refuses it on the
|
|
1880
|
+
// localDiverges flag, pull never clears the flag (remote is unchanged), and
|
|
1881
|
+
// `keep` re-arms it every pass. The only existing escape — `overwrite` — is
|
|
1882
|
+
// a blunt force-write that will PUT unfenced after a 412, clobbering a
|
|
1883
|
+
// remote that genuinely advanced.
|
|
1884
|
+
//
|
|
1885
|
+
// `publish-local` is the missing middle: the operator explicitly elects
|
|
1886
|
+
// their local copy as the new authority, and the engine accepts it ONLY
|
|
1887
|
+
// while the remote still sits at the etag recorded when the keep happened.
|
|
1888
|
+
// If the remote moved, consent was given against a version that no longer
|
|
1889
|
+
// exists, so the write must fail closed instead of forcing.
|
|
1890
|
+
it("localDiverges under publish-local: promotes local under an If-Match fence on the journal etag", async () => {
|
|
1891
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1892
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1893
|
+
const testFile = path.join(companyRoot, "publish-divergent.md");
|
|
1894
|
+
const localBytes = "my kept divergent local, now elected authority";
|
|
1895
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1896
|
+
const localHash = hashFile(testFile);
|
|
1897
|
+
const REMOTE = "peer-version-etag";
|
|
1898
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1899
|
+
lastModified: new Date(),
|
|
1900
|
+
etag: `"${REMOTE}"`,
|
|
1901
|
+
size: 40,
|
|
1902
|
+
});
|
|
1903
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
1904
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1905
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1906
|
+
version: "1",
|
|
1907
|
+
lastSync: new Date().toISOString(),
|
|
1908
|
+
files: {
|
|
1909
|
+
"publish-divergent.md": {
|
|
1910
|
+
hash: localHash,
|
|
1911
|
+
size: localBytes.length,
|
|
1912
|
+
syncedAt: new Date().toISOString(),
|
|
1913
|
+
direction: "down",
|
|
1914
|
+
remoteEtag: REMOTE,
|
|
1915
|
+
localDiverges: true,
|
|
1916
|
+
},
|
|
1917
|
+
},
|
|
1918
|
+
}));
|
|
1919
|
+
const result = await share({
|
|
1920
|
+
paths: [testFile],
|
|
1921
|
+
company: "acme",
|
|
1922
|
+
vaultConfig: mockConfig,
|
|
1923
|
+
hqRoot: tmpDir,
|
|
1924
|
+
skipUnchanged: false,
|
|
1925
|
+
onConflict: "publish-local",
|
|
1926
|
+
});
|
|
1927
|
+
expect(result.filesUploaded).toBe(1);
|
|
1928
|
+
// Fenced on the etag the keep recorded — never unfenced, never live-HEAD.
|
|
1929
|
+
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
1930
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1931
|
+
// The entry is rewritten as an ordinary upload: local is now authority,
|
|
1932
|
+
// so the journal-honesty flag must be gone rather than re-armed.
|
|
1933
|
+
const journal = journalAt(journalPath);
|
|
1934
|
+
expect(journal.files["publish-divergent.md"].direction).toBe("up");
|
|
1935
|
+
expect(journal.files["publish-divergent.md"].localDiverges).toBeFalsy();
|
|
1936
|
+
expect(journal.files["publish-divergent.md"].remoteEtag).toBe("published-etag");
|
|
1937
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1938
|
+
});
|
|
1939
|
+
it("localDiverges under publish-local: a 412 fails closed and never retries unfenced", async () => {
|
|
1940
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1941
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1942
|
+
const testFile = path.join(companyRoot, "publish-raced.md");
|
|
1943
|
+
const localBytes = "my local, but the remote moved under me";
|
|
1944
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1945
|
+
const localHash = hashFile(testFile);
|
|
1946
|
+
const REMOTE = "etag-at-keep-time";
|
|
1947
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1948
|
+
lastModified: new Date(),
|
|
1949
|
+
etag: `"${REMOTE}"`,
|
|
1950
|
+
size: 40,
|
|
1951
|
+
});
|
|
1952
|
+
const precondition = Object.assign(new Error("At least one of the pre-conditions you specified did not hold"), {
|
|
1953
|
+
name: "PreconditionFailed",
|
|
1954
|
+
});
|
|
1955
|
+
vi.mocked(uploadFile).mockRejectedValueOnce(precondition);
|
|
1956
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1957
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1958
|
+
version: "1",
|
|
1959
|
+
lastSync: new Date().toISOString(),
|
|
1960
|
+
files: {
|
|
1961
|
+
"publish-raced.md": {
|
|
1962
|
+
hash: localHash,
|
|
1963
|
+
size: localBytes.length,
|
|
1964
|
+
syncedAt: new Date().toISOString(),
|
|
1965
|
+
direction: "down",
|
|
1966
|
+
remoteEtag: REMOTE,
|
|
1967
|
+
localDiverges: true,
|
|
1968
|
+
},
|
|
1969
|
+
},
|
|
1970
|
+
}));
|
|
1971
|
+
const result = await share({
|
|
1972
|
+
paths: [testFile],
|
|
1973
|
+
company: "acme",
|
|
1974
|
+
vaultConfig: mockConfig,
|
|
1975
|
+
hqRoot: tmpDir,
|
|
1976
|
+
skipUnchanged: false,
|
|
1977
|
+
onConflict: "publish-local",
|
|
1978
|
+
});
|
|
1979
|
+
// Exactly one attempt: the fenced one. The `overwrite` escape hatch of
|
|
1980
|
+
// re-PUTting with no precondition must NOT be reachable from here.
|
|
1981
|
+
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
1982
|
+
expect(result.filesUploaded).toBe(0);
|
|
1983
|
+
expect(result.conflictPaths).toContain("publish-raced.md");
|
|
1984
|
+
// Consent was for a remote that no longer exists, so the flag stays put
|
|
1985
|
+
// and the operator is asked again rather than silently overruled.
|
|
1986
|
+
const journal = journalAt(journalPath);
|
|
1987
|
+
expect(journal.files["publish-raced.md"].localDiverges).toBe(true);
|
|
1988
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1989
|
+
});
|
|
1990
|
+
// Codex review (PR #356, P1). The sync runner's push leg always calls
|
|
1991
|
+
// share() with `skipUnchanged: true`, and a pull-held entry stores the
|
|
1992
|
+
// CURRENT local hash — that is exactly what the journal-honesty stamp
|
|
1993
|
+
// records. So `existing.hash === localHash` is true for every stuck file,
|
|
1994
|
+
// classifyPushCandidates() returns `skip-unchanged`, and executeUploads()
|
|
1995
|
+
// never runs: no HEAD, no conflict, no upload. Without this bypass the
|
|
1996
|
+
// whole recovery is a no-op in the one mode that can reach personal-vault
|
|
1997
|
+
// files, and the next pull leaves them stuck again. `hash === journal hash`
|
|
1998
|
+
// means "local is unchanged since the stamp", NOT "local matches remote" —
|
|
1999
|
+
// for a divergent entry those are different claims.
|
|
2000
|
+
it("localDiverges bypasses the skip-unchanged fast path so the recovery can run at all", async () => {
|
|
2001
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2002
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2003
|
+
const testFile = path.join(companyRoot, "stuck-under-skip-unchanged.md");
|
|
2004
|
+
const localBytes = "the body a pull hold stamped and then refused to publish";
|
|
2005
|
+
fs.writeFileSync(testFile, localBytes);
|
|
2006
|
+
const localHash = hashFile(testFile);
|
|
2007
|
+
const REMOTE = "etag-at-keep-time";
|
|
2008
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
2009
|
+
lastModified: new Date(),
|
|
2010
|
+
etag: `"${REMOTE}"`,
|
|
2011
|
+
size: 40,
|
|
2012
|
+
});
|
|
2013
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
2014
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2015
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2016
|
+
version: "1",
|
|
2017
|
+
lastSync: new Date().toISOString(),
|
|
2018
|
+
files: {
|
|
2019
|
+
"stuck-under-skip-unchanged.md": {
|
|
2020
|
+
// Deliberately the CURRENT local hash: this is what a pull hold
|
|
2021
|
+
// writes, and it is what makes the fast path swallow the file.
|
|
2022
|
+
hash: localHash,
|
|
2023
|
+
size: localBytes.length,
|
|
2024
|
+
syncedAt: new Date().toISOString(),
|
|
2025
|
+
direction: "down",
|
|
2026
|
+
remoteEtag: REMOTE,
|
|
2027
|
+
localDiverges: true,
|
|
2028
|
+
},
|
|
2029
|
+
},
|
|
2030
|
+
}));
|
|
2031
|
+
const result = await share({
|
|
2032
|
+
paths: [testFile],
|
|
2033
|
+
company: "acme",
|
|
2034
|
+
vaultConfig: mockConfig,
|
|
2035
|
+
hqRoot: tmpDir,
|
|
2036
|
+
skipUnchanged: true,
|
|
2037
|
+
onConflict: "publish-local",
|
|
2038
|
+
});
|
|
2039
|
+
expect(result.filesUploaded).toBe(1);
|
|
2040
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
2041
|
+
const journal = journalAt(journalPath);
|
|
2042
|
+
expect(journal.files["stuck-under-skip-unchanged.md"].direction).toBe("up");
|
|
2043
|
+
expect(journal.files["stuck-under-skip-unchanged.md"].localDiverges).toBeFalsy();
|
|
2044
|
+
});
|
|
2045
|
+
// Codex review (PR #356, P2). A genuine two-sided conflict is not the
|
|
2046
|
+
// localDiverges recovery: the journal etag is stale BY DEFINITION here
|
|
2047
|
+
// (that is what remoteChanged means), so fencing consent on it guarantees a
|
|
2048
|
+
// 412 and the file can never publish. Consent given at this prompt is
|
|
2049
|
+
// consent against the remote the operator was just shown, so the fence must
|
|
2050
|
+
// be the freshly observed etag.
|
|
2051
|
+
it("publish-local on a fresh two-sided conflict fences on the OBSERVED remote etag, not the stale journal one", async () => {
|
|
2052
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2053
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2054
|
+
const testFile = path.join(companyRoot, "two-sided.md");
|
|
2055
|
+
fs.writeFileSync(testFile, "my newer local body");
|
|
2056
|
+
const STALE_JOURNAL_ETAG = "etag-from-last-sync";
|
|
2057
|
+
const OBSERVED_REMOTE_ETAG = "etag-the-peer-just-wrote";
|
|
2058
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
2059
|
+
lastModified: new Date(),
|
|
2060
|
+
etag: `"${OBSERVED_REMOTE_ETAG}"`,
|
|
2061
|
+
size: 40,
|
|
2062
|
+
});
|
|
2063
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
2064
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2065
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2066
|
+
version: "1",
|
|
2067
|
+
lastSync: new Date().toISOString(),
|
|
2068
|
+
files: {
|
|
2069
|
+
"two-sided.md": {
|
|
2070
|
+
hash: "a-hash-that-is-neither-side",
|
|
2071
|
+
size: 10,
|
|
2072
|
+
syncedAt: new Date().toISOString(),
|
|
2073
|
+
direction: "up",
|
|
2074
|
+
remoteEtag: STALE_JOURNAL_ETAG,
|
|
2075
|
+
},
|
|
2076
|
+
},
|
|
2077
|
+
}));
|
|
2078
|
+
const result = await share({
|
|
2079
|
+
paths: [testFile],
|
|
2080
|
+
company: "acme",
|
|
2081
|
+
vaultConfig: mockConfig,
|
|
2082
|
+
hqRoot: tmpDir,
|
|
2083
|
+
skipUnchanged: false,
|
|
2084
|
+
onConflict: "publish-local",
|
|
2085
|
+
});
|
|
2086
|
+
expect(result.filesUploaded).toBe(1);
|
|
2087
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({
|
|
2088
|
+
ifMatch: OBSERVED_REMOTE_ETAG,
|
|
2089
|
+
});
|
|
2090
|
+
});
|
|
2091
|
+
// Codex review (PR #356, P2). HEAD returning null skips the whole conflict
|
|
2092
|
+
// block, so the precondition becomes If-None-Match:* and the object is
|
|
2093
|
+
// RECREATED. For a divergent entry that silently overrules a peer's
|
|
2094
|
+
// deletion — the remote is emphatically no longer at the recorded etag, so
|
|
2095
|
+
// the strategy's own fence says fail closed. `keep`'s recreate-once
|
|
2096
|
+
// behaviour for non-divergent entries is unaffected.
|
|
2097
|
+
it("publish-local does not resurrect a remote deleted after the keep", async () => {
|
|
2098
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2099
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2100
|
+
const testFile = path.join(companyRoot, "peer-deleted.md");
|
|
2101
|
+
const localBytes = "my divergent local body";
|
|
2102
|
+
fs.writeFileSync(testFile, localBytes);
|
|
2103
|
+
const localHash = hashFile(testFile);
|
|
2104
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
2105
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2106
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2107
|
+
version: "1",
|
|
2108
|
+
lastSync: new Date().toISOString(),
|
|
2109
|
+
files: {
|
|
2110
|
+
"peer-deleted.md": {
|
|
2111
|
+
hash: localHash,
|
|
2112
|
+
size: localBytes.length,
|
|
2113
|
+
syncedAt: new Date().toISOString(),
|
|
2114
|
+
direction: "down",
|
|
2115
|
+
remoteEtag: "etag-before-the-peer-deleted-it",
|
|
2116
|
+
localDiverges: true,
|
|
2117
|
+
},
|
|
2118
|
+
},
|
|
2119
|
+
}));
|
|
2120
|
+
const result = await share({
|
|
2121
|
+
paths: [testFile],
|
|
2122
|
+
company: "acme",
|
|
2123
|
+
vaultConfig: mockConfig,
|
|
2124
|
+
hqRoot: tmpDir,
|
|
2125
|
+
skipUnchanged: false,
|
|
2126
|
+
onConflict: "publish-local",
|
|
2127
|
+
});
|
|
2128
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
2129
|
+
expect(result.filesUploaded).toBe(0);
|
|
2130
|
+
expect(result.conflictPaths).toContain("peer-deleted.md");
|
|
2131
|
+
const journal = journalAt(journalPath);
|
|
2132
|
+
expect(journal.files["peer-deleted.md"].localDiverges).toBe(true);
|
|
2133
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
2134
|
+
});
|
|
2135
|
+
it("publish-local on a non-divergent file behaves like an ordinary fenced upload", async () => {
|
|
2136
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2137
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2138
|
+
const testFile = path.join(companyRoot, "ordinary.md");
|
|
2139
|
+
fs.writeFileSync(testFile, "locally edited since last sync");
|
|
2140
|
+
const REMOTE = "unchanged-remote-etag";
|
|
2141
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
2142
|
+
lastModified: new Date(),
|
|
2143
|
+
etag: `"${REMOTE}"`,
|
|
2144
|
+
size: 10,
|
|
2145
|
+
});
|
|
2146
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"next-etag"' });
|
|
2147
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2148
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2149
|
+
version: "1",
|
|
2150
|
+
lastSync: new Date().toISOString(),
|
|
2151
|
+
files: {
|
|
2152
|
+
"ordinary.md": {
|
|
2153
|
+
hash: "stale-hash-from-a-previous-body",
|
|
2154
|
+
size: 10,
|
|
2155
|
+
syncedAt: new Date().toISOString(),
|
|
2156
|
+
direction: "up",
|
|
2157
|
+
remoteEtag: REMOTE,
|
|
2158
|
+
},
|
|
2159
|
+
},
|
|
2160
|
+
}));
|
|
2161
|
+
const result = await share({
|
|
2162
|
+
paths: [testFile],
|
|
2163
|
+
company: "acme",
|
|
2164
|
+
vaultConfig: mockConfig,
|
|
2165
|
+
hqRoot: tmpDir,
|
|
2166
|
+
skipUnchanged: false,
|
|
2167
|
+
onConflict: "publish-local",
|
|
2168
|
+
});
|
|
2169
|
+
// No localDiverges flag means no special casing: the normal push fence
|
|
2170
|
+
// applies and the file uploads without being routed through conflict.
|
|
2171
|
+
expect(result.filesUploaded).toBe(1);
|
|
2172
|
+
expect(result.conflictPaths ?? []).not.toContain("ordinary.md");
|
|
2173
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
2174
|
+
});
|
|
1453
2175
|
// Journal still carries remoteEtag from a prior sync, but the remote key
|
|
1454
2176
|
// is gone (HEAD null) — e.g. peer delete or operator purge. Recreation
|
|
1455
2177
|
// must use If-None-Match:* (create-only), NOT If-Match on the stale
|
|
@@ -1467,7 +2189,7 @@ describe("share", () => {
|
|
|
1467
2189
|
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
1468
2190
|
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"recreated-etag"' });
|
|
1469
2191
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1470
|
-
|
|
2192
|
+
seedJournalAt(journalPath, ({
|
|
1471
2193
|
version: "1",
|
|
1472
2194
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1473
2195
|
files: {
|
|
@@ -1497,28 +2219,34 @@ describe("share", () => {
|
|
|
1497
2219
|
const pc = vi.mocked(uploadFile).mock.calls[0][4];
|
|
1498
2220
|
expect(pc?.ifMatch).toBeUndefined();
|
|
1499
2221
|
expect(pc?.ifNoneMatch).toBe("*");
|
|
1500
|
-
const journal =
|
|
2222
|
+
const journal = journalAt(journalPath);
|
|
1501
2223
|
expect(journal.files["recreate-deleted.md"]).toMatchObject({
|
|
1502
2224
|
hash: localHash,
|
|
1503
2225
|
remoteEtag: "recreated-etag",
|
|
1504
2226
|
direction: "up",
|
|
1505
2227
|
});
|
|
1506
2228
|
});
|
|
1507
|
-
it("deleted journaled remote + concurrent create 412 under keep:
|
|
2229
|
+
it("deleted journaled remote + concurrent create 412 under keep: adopts the winner and preserves local", async () => {
|
|
1508
2230
|
// Same setup as recreation, but a peer creates the key between HEAD and
|
|
1509
|
-
// PUT → If-None-Match:* 412s → keep
|
|
2231
|
+
// PUT → If-None-Match:* 412s → keep adopts the peer's winning create.
|
|
1510
2232
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1511
2233
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1512
2234
|
const testFile = path.join(companyRoot, "race-recreate.md");
|
|
1513
2235
|
fs.writeFileSync(testFile, "my recreated local");
|
|
1514
|
-
vi.mocked(headRemoteFile)
|
|
2236
|
+
vi.mocked(headRemoteFile)
|
|
2237
|
+
.mockResolvedValueOnce(null)
|
|
2238
|
+
.mockResolvedValueOnce({
|
|
2239
|
+
lastModified: new Date(),
|
|
2240
|
+
etag: '"peer-created-etag"',
|
|
2241
|
+
size: 18,
|
|
2242
|
+
});
|
|
1515
2243
|
vi.mocked(uploadFile).mockRejectedValueOnce(fence412());
|
|
1516
2244
|
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
1517
2245
|
fs.writeFileSync(dest, "peer created first");
|
|
1518
2246
|
return undefined;
|
|
1519
2247
|
});
|
|
1520
2248
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1521
|
-
|
|
2249
|
+
seedJournalAt(journalPath, ({
|
|
1522
2250
|
version: "1",
|
|
1523
2251
|
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
1524
2252
|
files: {
|
|
@@ -1548,12 +2276,15 @@ describe("share", () => {
|
|
|
1548
2276
|
// First (only) attempt must be create-fenced; no unfenced retry under keep.
|
|
1549
2277
|
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
1550
2278
|
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifNoneMatch: "*" });
|
|
1551
|
-
// Journal
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
2279
|
+
// Journal now describes the peer body at the original name.
|
|
2280
|
+
const journal = journalAt(journalPath);
|
|
2281
|
+
expect(journal.files["race-recreate.md"]).toMatchObject({
|
|
2282
|
+
remoteEtag: "peer-created-etag",
|
|
2283
|
+
hash: hashFile(testFile),
|
|
2284
|
+
direction: "down",
|
|
2285
|
+
});
|
|
2286
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("peer created first");
|
|
2287
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe("my recreated local");
|
|
1557
2288
|
});
|
|
1558
2289
|
});
|
|
1559
2290
|
it("scoped push (plan-exceeds-grant): syncs the in-scope subset, skips out-of-scope paths, never aborts (feedback_ded09d56)", async () => {
|
|
@@ -1874,7 +2605,7 @@ describe("share", () => {
|
|
|
1874
2605
|
size: 5,
|
|
1875
2606
|
});
|
|
1876
2607
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1877
|
-
|
|
2608
|
+
seedJournalAt(journalPath, ({
|
|
1878
2609
|
version: "1",
|
|
1879
2610
|
lastSync: syncedAt,
|
|
1880
2611
|
files: {
|
|
@@ -1914,7 +2645,7 @@ describe("share", () => {
|
|
|
1914
2645
|
size: 5,
|
|
1915
2646
|
});
|
|
1916
2647
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1917
|
-
|
|
2648
|
+
seedJournalAt(journalPath, ({
|
|
1918
2649
|
version: "1",
|
|
1919
2650
|
lastSync: syncedAt,
|
|
1920
2651
|
files: {
|
|
@@ -1950,7 +2681,7 @@ describe("share", () => {
|
|
|
1950
2681
|
hqRoot: tmpDir,
|
|
1951
2682
|
});
|
|
1952
2683
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1953
|
-
const journal =
|
|
2684
|
+
const journal = journalAt(journalPath);
|
|
1954
2685
|
expect(journal.files["fresh.md"].remoteEtag).toBe("new-upload-etag");
|
|
1955
2686
|
});
|
|
1956
2687
|
it("forwards UploadAuthor to uploadFile when present (created-by metadata)", async () => {
|
|
@@ -1995,7 +2726,7 @@ describe("share", () => {
|
|
|
1995
2726
|
const { hashFile } = await import("../journal.js");
|
|
1996
2727
|
const hash = hashFile(testFile);
|
|
1997
2728
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1998
|
-
|
|
2729
|
+
seedJournalAt(journalPath, ({
|
|
1999
2730
|
version: "1",
|
|
2000
2731
|
lastSync: new Date().toISOString(),
|
|
2001
2732
|
files: {
|
|
@@ -2215,7 +2946,7 @@ describe("share", () => {
|
|
|
2215
2946
|
.update("stable content")
|
|
2216
2947
|
.digest("hex");
|
|
2217
2948
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2218
|
-
|
|
2949
|
+
seedJournalAt(journalPath, ({
|
|
2219
2950
|
version: "1",
|
|
2220
2951
|
lastSync: new Date().toISOString(),
|
|
2221
2952
|
files: {
|
|
@@ -2276,7 +3007,7 @@ describe("share", () => {
|
|
|
2276
3007
|
// not be queued for delete.
|
|
2277
3008
|
fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
|
|
2278
3009
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2279
|
-
|
|
3010
|
+
seedJournalAt(journalPath, ({
|
|
2280
3011
|
version: "1",
|
|
2281
3012
|
lastSync: new Date().toISOString(),
|
|
2282
3013
|
files: {
|
|
@@ -2313,7 +3044,7 @@ describe("share", () => {
|
|
|
2313
3044
|
operation: "delete",
|
|
2314
3045
|
});
|
|
2315
3046
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
|
|
2316
|
-
const journal =
|
|
3047
|
+
const journal = journalAt(journalPath);
|
|
2317
3048
|
expect(journal.files["knowledge"]).toBeUndefined();
|
|
2318
3049
|
});
|
|
2319
3050
|
it("propagateDeletes: a dangling local symlink is NOT classified as gone (lstat, not existsSync)", async () => {
|
|
@@ -2330,7 +3061,7 @@ describe("share", () => {
|
|
|
2330
3061
|
const danglingLink = path.join(companyRoot, "dangling-link.md");
|
|
2331
3062
|
fs.symlinkSync("./missing-target.md", danglingLink);
|
|
2332
3063
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2333
|
-
|
|
3064
|
+
seedJournalAt(journalPath, ({
|
|
2334
3065
|
version: "1",
|
|
2335
3066
|
lastSync: new Date().toISOString(),
|
|
2336
3067
|
files: {
|
|
@@ -2360,7 +3091,7 @@ describe("share", () => {
|
|
|
2360
3091
|
expect(result.filesDeleted).toBe(0);
|
|
2361
3092
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2362
3093
|
// Journal entry must survive the run.
|
|
2363
|
-
const journal =
|
|
3094
|
+
const journal = journalAt(journalPath);
|
|
2364
3095
|
expect(journal.files["dangling-link.md"]).toBeDefined();
|
|
2365
3096
|
});
|
|
2366
3097
|
it("vault-litter drain: a `.drift-*` marker with direction:'down' and NO delete intent tombstones " +
|
|
@@ -2425,7 +3156,7 @@ describe("share", () => {
|
|
|
2425
3156
|
});
|
|
2426
3157
|
expect(result.filesDeleted).toBe(1);
|
|
2427
3158
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "personal/scripts/run-project.sh.drift-1779863862-42519");
|
|
2428
|
-
const journal =
|
|
3159
|
+
const journal = journalAt(journalPath);
|
|
2429
3160
|
expect(journal.files["personal/scripts/run-project.sh.drift-1779863862-42519"]).toBeUndefined();
|
|
2430
3161
|
}
|
|
2431
3162
|
finally {
|
|
@@ -2498,7 +3229,7 @@ describe("share", () => {
|
|
|
2498
3229
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2499
3230
|
// No file on disk under companies/acme/peer-uploaded.md → local missing.
|
|
2500
3231
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2501
|
-
|
|
3232
|
+
seedJournalAt(journalPath, ({
|
|
2502
3233
|
version: "1",
|
|
2503
3234
|
lastSync: new Date().toISOString(),
|
|
2504
3235
|
files: {
|
|
@@ -2523,7 +3254,7 @@ describe("share", () => {
|
|
|
2523
3254
|
});
|
|
2524
3255
|
expect(result.filesDeleted).toBe(0);
|
|
2525
3256
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2526
|
-
const journal =
|
|
3257
|
+
const journal = journalAt(journalPath);
|
|
2527
3258
|
expect(journal.files["peer-uploaded.md"]).toBeDefined();
|
|
2528
3259
|
});
|
|
2529
3260
|
it("ROLLBACK KNOB: owned-only is honored on a prs_ personal vault — an intent-less entry with a " +
|
|
@@ -2614,7 +3345,7 @@ describe("share", () => {
|
|
|
2614
3345
|
]);
|
|
2615
3346
|
// Journal entry survives, so a later run (or a watcher-minted intent)
|
|
2616
3347
|
// can still authorize the delete properly.
|
|
2617
|
-
const journal =
|
|
3348
|
+
const journal = journalAt(journalPath);
|
|
2618
3349
|
expect(journal.files["personal/knowledge/gone.md"]).toBeDefined();
|
|
2619
3350
|
}
|
|
2620
3351
|
finally {
|
|
@@ -2631,7 +3362,7 @@ describe("share", () => {
|
|
|
2631
3362
|
// deleted by the user.
|
|
2632
3363
|
fs.writeFileSync(path.join(companyRoot, "kept.md"), "still here");
|
|
2633
3364
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2634
|
-
|
|
3365
|
+
seedJournalAt(journalPath, ({
|
|
2635
3366
|
version: "1",
|
|
2636
3367
|
lastSync: new Date().toISOString(),
|
|
2637
3368
|
files: {
|
|
@@ -2667,7 +3398,7 @@ describe("share", () => {
|
|
|
2667
3398
|
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
2668
3399
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "gone.md");
|
|
2669
3400
|
// Journal entry for the gone file is removed; the kept entry stays.
|
|
2670
|
-
const journal =
|
|
3401
|
+
const journal = journalAt(journalPath);
|
|
2671
3402
|
expect(journal.files["gone.md"]).toBeUndefined();
|
|
2672
3403
|
expect(journal.files["kept.md"]).toBeDefined();
|
|
2673
3404
|
});
|
|
@@ -2675,7 +3406,7 @@ describe("share", () => {
|
|
|
2675
3406
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2676
3407
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2677
3408
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2678
|
-
|
|
3409
|
+
seedJournalAt(journalPath, ({
|
|
2679
3410
|
version: "1",
|
|
2680
3411
|
lastSync: new Date().toISOString(),
|
|
2681
3412
|
files: {
|
|
@@ -2717,7 +3448,7 @@ describe("share", () => {
|
|
|
2717
3448
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2718
3449
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2719
3450
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2720
|
-
|
|
3451
|
+
seedJournalAt(journalPath, ({
|
|
2721
3452
|
version: "1",
|
|
2722
3453
|
lastSync: new Date().toISOString(),
|
|
2723
3454
|
files: {
|
|
@@ -2740,7 +3471,7 @@ describe("share", () => {
|
|
|
2740
3471
|
expect(result.filesDeleted).toBe(0);
|
|
2741
3472
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2742
3473
|
// Journal entry survives so the next opt-in run can still propagate.
|
|
2743
|
-
const journal =
|
|
3474
|
+
const journal = journalAt(journalPath);
|
|
2744
3475
|
expect(journal.files["gone.md"]).toBeDefined();
|
|
2745
3476
|
});
|
|
2746
3477
|
it("propagateDeletes: scope is constrained to the supplied paths — sibling deletes are not swept", async () => {
|
|
@@ -2748,7 +3479,7 @@ describe("share", () => {
|
|
|
2748
3479
|
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
2749
3480
|
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
2750
3481
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2751
|
-
|
|
3482
|
+
seedJournalAt(journalPath, ({
|
|
2752
3483
|
version: "1",
|
|
2753
3484
|
lastSync: new Date().toISOString(),
|
|
2754
3485
|
files: {
|
|
@@ -2783,7 +3514,7 @@ describe("share", () => {
|
|
|
2783
3514
|
expect(result.filesDeleted).toBe(1);
|
|
2784
3515
|
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
2785
3516
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
|
|
2786
|
-
const journal =
|
|
3517
|
+
const journal = journalAt(journalPath);
|
|
2787
3518
|
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
2788
3519
|
// Sibling tree's journal entry is untouched — `hq share <subtree>` must
|
|
2789
3520
|
// not act on files outside the named scope.
|
|
@@ -2799,7 +3530,7 @@ describe("share", () => {
|
|
|
2799
3530
|
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
2800
3531
|
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
2801
3532
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2802
|
-
|
|
3533
|
+
seedJournalAt(journalPath, ({
|
|
2803
3534
|
version: "1",
|
|
2804
3535
|
lastSync: new Date().toISOString(),
|
|
2805
3536
|
files: {
|
|
@@ -2834,7 +3565,7 @@ describe("share", () => {
|
|
|
2834
3565
|
});
|
|
2835
3566
|
expect(result.filesDeleted).toBe(1);
|
|
2836
3567
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "in-scope/gone.md");
|
|
2837
|
-
const journal =
|
|
3568
|
+
const journal = journalAt(journalPath);
|
|
2838
3569
|
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
2839
3570
|
// Scope stays exactly as narrow as the absolute spelling's — no wider.
|
|
2840
3571
|
expect(journal.files["other/also-gone.md"]).toBeDefined();
|
|
@@ -2851,7 +3582,7 @@ describe("share", () => {
|
|
|
2851
3582
|
fs.mkdirSync(acmeRepo, { recursive: true });
|
|
2852
3583
|
fs.symlinkSync(acmeRepo, path.join(companyRoot, "knowledge"));
|
|
2853
3584
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2854
|
-
|
|
3585
|
+
seedJournalAt(journalPath, ({
|
|
2855
3586
|
version: "1",
|
|
2856
3587
|
lastSync: new Date().toISOString(),
|
|
2857
3588
|
files: {
|
|
@@ -2881,14 +3612,14 @@ describe("share", () => {
|
|
|
2881
3612
|
});
|
|
2882
3613
|
expect(result.filesDeleted).toBe(0);
|
|
2883
3614
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2884
|
-
const journal =
|
|
3615
|
+
const journal = journalAt(journalPath);
|
|
2885
3616
|
expect(journal.files["knowledge/agents/brief.md"]).toBeDefined();
|
|
2886
3617
|
});
|
|
2887
3618
|
it("propagateDeletes: a failed DeleteObject leaves the journal entry intact for retry", async () => {
|
|
2888
3619
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2889
3620
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2890
3621
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2891
|
-
|
|
3622
|
+
seedJournalAt(journalPath, ({
|
|
2892
3623
|
version: "1",
|
|
2893
3624
|
lastSync: new Date().toISOString(),
|
|
2894
3625
|
files: {
|
|
@@ -2921,7 +3652,7 @@ describe("share", () => {
|
|
|
2921
3652
|
const errorEvent = events.find((e) => e.type === "error");
|
|
2922
3653
|
expect(errorEvent).toMatchObject({ path: "flaky.md", message: expect.stringContaining("S3 down") });
|
|
2923
3654
|
// Entry survives — next run will retry the delete.
|
|
2924
|
-
const journal =
|
|
3655
|
+
const journal = journalAt(journalPath);
|
|
2925
3656
|
expect(journal.files["flaky.md"]).toBeDefined();
|
|
2926
3657
|
});
|
|
2927
3658
|
// ── Delete propagation safety: direction + filter guards (Bug B + C) ──────
|
|
@@ -2955,7 +3686,7 @@ describe("share", () => {
|
|
|
2955
3686
|
// and one this machine uploaded (direction:'up'). Only the 'up' one should
|
|
2956
3687
|
// be eligible for delete-propagation.
|
|
2957
3688
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2958
|
-
|
|
3689
|
+
seedJournalAt(journalPath, ({
|
|
2959
3690
|
version: "1",
|
|
2960
3691
|
lastSync: new Date().toISOString(),
|
|
2961
3692
|
files: {
|
|
@@ -2992,7 +3723,7 @@ describe("share", () => {
|
|
|
2992
3723
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2993
3724
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2994
3725
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2995
|
-
|
|
3726
|
+
seedJournalAt(journalPath, ({
|
|
2996
3727
|
version: "1",
|
|
2997
3728
|
lastSync: new Date().toISOString(),
|
|
2998
3729
|
files: {
|
|
@@ -3025,7 +3756,7 @@ describe("share", () => {
|
|
|
3025
3756
|
// absence is a filter outcome, not a user-intended delete.
|
|
3026
3757
|
fs.writeFileSync(path.join(tmpDir, ".hqignore"), "companies/acme/legacy/**\n");
|
|
3027
3758
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3028
|
-
|
|
3759
|
+
seedJournalAt(journalPath, ({
|
|
3029
3760
|
version: "1",
|
|
3030
3761
|
lastSync: new Date().toISOString(),
|
|
3031
3762
|
files: {
|
|
@@ -3084,7 +3815,7 @@ describe("share", () => {
|
|
|
3084
3815
|
// Under owned-only this would be stuck on remote forever; currency-gated
|
|
3085
3816
|
// checks the ETag and propagates the delete cleanly when match holds.
|
|
3086
3817
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3087
|
-
|
|
3818
|
+
seedJournalAt(journalPath, ({
|
|
3088
3819
|
version: "1",
|
|
3089
3820
|
lastSync: new Date().toISOString(),
|
|
3090
3821
|
files: {
|
|
@@ -3114,7 +3845,7 @@ describe("share", () => {
|
|
|
3114
3845
|
});
|
|
3115
3846
|
expect(result.filesDeleted).toBe(1);
|
|
3116
3847
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "core/policies/old.md");
|
|
3117
|
-
const journal =
|
|
3848
|
+
const journal = journalAt(journalPath);
|
|
3118
3849
|
expect(journal.files["core/policies/old.md"]).toBeUndefined();
|
|
3119
3850
|
});
|
|
3120
3851
|
it("currency-gated: propagates the S3 delete for a local-delete tombstone (pull-leg producer hand-off)", async () => {
|
|
@@ -3125,7 +3856,7 @@ describe("share", () => {
|
|
|
3125
3856
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3126
3857
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3127
3858
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3128
|
-
|
|
3859
|
+
seedJournalAt(journalPath, ({
|
|
3129
3860
|
version: "2",
|
|
3130
3861
|
lastSync: new Date().toISOString(),
|
|
3131
3862
|
files: {
|
|
@@ -3161,7 +3892,7 @@ describe("share", () => {
|
|
|
3161
3892
|
});
|
|
3162
3893
|
expect(result.filesDeleted).toBe(1);
|
|
3163
3894
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "docs/deleted.md");
|
|
3164
|
-
const journal =
|
|
3895
|
+
const journal = journalAt(journalPath);
|
|
3165
3896
|
expect(journal.files["docs/deleted.md"]).toBeUndefined();
|
|
3166
3897
|
});
|
|
3167
3898
|
it("currency-gated: refuses delete + emits stale-etag event when remote moved since last sync", async () => {
|
|
@@ -3170,7 +3901,7 @@ describe("share", () => {
|
|
|
3170
3901
|
// Another device modified `shared.md` after this device's last sync.
|
|
3171
3902
|
// The journal still records the old etag; HEAD returns the new one.
|
|
3172
3903
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3173
|
-
|
|
3904
|
+
seedJournalAt(journalPath, ({
|
|
3174
3905
|
version: "1",
|
|
3175
3906
|
lastSync: new Date().toISOString(),
|
|
3176
3907
|
files: {
|
|
@@ -3202,7 +3933,7 @@ describe("share", () => {
|
|
|
3202
3933
|
// No remote DELETE issued, journal entry preserved (pull will re-pull).
|
|
3203
3934
|
expect(result.filesDeleted).toBe(0);
|
|
3204
3935
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3205
|
-
const journal =
|
|
3936
|
+
const journal = journalAt(journalPath);
|
|
3206
3937
|
expect(journal.files["shared.md"]).toBeDefined();
|
|
3207
3938
|
// Dedicated event fired so UIs surface the refusal. `reason` field
|
|
3208
3939
|
// discriminates "stale-etag" (real drift) from "legacy-no-etag" so
|
|
@@ -3224,7 +3955,7 @@ describe("share", () => {
|
|
|
3224
3955
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3225
3956
|
const recreatedPath = path.join(companyRoot, "racy.md");
|
|
3226
3957
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3227
|
-
|
|
3958
|
+
seedJournalAt(journalPath, ({
|
|
3228
3959
|
version: "2",
|
|
3229
3960
|
lastSync: new Date().toISOString(),
|
|
3230
3961
|
files: {
|
|
@@ -3260,7 +3991,7 @@ describe("share", () => {
|
|
|
3260
3991
|
expect(result.filesDeleted).toBe(0);
|
|
3261
3992
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3262
3993
|
expect(fs.readFileSync(recreatedPath, "utf-8")).toBe("recreated after planning");
|
|
3263
|
-
const journal =
|
|
3994
|
+
const journal = journalAt(journalPath);
|
|
3264
3995
|
expect(journal.files["racy.md"]).toBeDefined();
|
|
3265
3996
|
expect(events).toContainEqual(expect.objectContaining({
|
|
3266
3997
|
type: "delete-refused-stale-etag",
|
|
@@ -3282,7 +4013,7 @@ describe("share", () => {
|
|
|
3282
4013
|
// genuine local==remote sync, so propagating the delete would destroy the
|
|
3283
4014
|
// (different) remote version. Must be REFUSED, not propagated.
|
|
3284
4015
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3285
|
-
|
|
4016
|
+
seedJournalAt(journalPath, ({
|
|
3286
4017
|
version: "2",
|
|
3287
4018
|
lastSync: new Date().toISOString(),
|
|
3288
4019
|
files: {
|
|
@@ -3317,7 +4048,7 @@ describe("share", () => {
|
|
|
3317
4048
|
// Refusal short-circuits before the currency HEAD — no network needed.
|
|
3318
4049
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
3319
4050
|
// Journal entry preserved, not silently dropped.
|
|
3320
|
-
const journal =
|
|
4051
|
+
const journal = journalAt(journalPath);
|
|
3321
4052
|
expect(journal.files["core/policies/kept.md"]).toBeDefined();
|
|
3322
4053
|
// Surfaced as a refusal with the divergent-local reason.
|
|
3323
4054
|
const refused = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
@@ -3334,7 +4065,7 @@ describe("share", () => {
|
|
|
3334
4065
|
// S3 console). Local copy also missing. Currency-gated should drop the
|
|
3335
4066
|
// journal entry without issuing a DELETE — the remote is already gone.
|
|
3336
4067
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3337
|
-
|
|
4068
|
+
seedJournalAt(journalPath, ({
|
|
3338
4069
|
version: "1",
|
|
3339
4070
|
lastSync: new Date().toISOString(),
|
|
3340
4071
|
files: {
|
|
@@ -3367,7 +4098,7 @@ describe("share", () => {
|
|
|
3367
4098
|
// from real deletes without re-counting events.
|
|
3368
4099
|
expect(result.filesTombstoned).toBe(1);
|
|
3369
4100
|
expect(result.filesRefusedStale).toBe(0);
|
|
3370
|
-
const journal =
|
|
4101
|
+
const journal = journalAt(journalPath);
|
|
3371
4102
|
expect(journal.files["removed-out-of-band.md"]).toBeUndefined();
|
|
3372
4103
|
// Synthetic progress event carries the tombstone marker. Without this,
|
|
3373
4104
|
// the tty stream renders tombstones identically to real deletes — operator
|
|
@@ -3388,7 +4119,7 @@ describe("share", () => {
|
|
|
3388
4119
|
// against. Refuse the delete in the safe direction; a future sync with
|
|
3389
4120
|
// a recorded etag can re-evaluate.
|
|
3390
4121
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3391
|
-
|
|
4122
|
+
seedJournalAt(journalPath, ({
|
|
3392
4123
|
version: "1",
|
|
3393
4124
|
lastSync: new Date().toISOString(),
|
|
3394
4125
|
files: {
|
|
@@ -3442,7 +4173,7 @@ describe("share", () => {
|
|
|
3442
4173
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3443
4174
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3444
4175
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3445
|
-
|
|
4176
|
+
seedJournalAt(journalPath, ({
|
|
3446
4177
|
version: "1",
|
|
3447
4178
|
lastSync: new Date().toISOString(),
|
|
3448
4179
|
files: {
|
|
@@ -3494,7 +4225,7 @@ describe("share", () => {
|
|
|
3494
4225
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3495
4226
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3496
4227
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3497
|
-
|
|
4228
|
+
seedJournalAt(journalPath, ({
|
|
3498
4229
|
version: "1",
|
|
3499
4230
|
lastSync: new Date().toISOString(),
|
|
3500
4231
|
files: {
|
|
@@ -3528,7 +4259,7 @@ describe("share", () => {
|
|
|
3528
4259
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3529
4260
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3530
4261
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3531
|
-
|
|
4262
|
+
seedJournalAt(journalPath, ({
|
|
3532
4263
|
version: "1",
|
|
3533
4264
|
lastSync: new Date().toISOString(),
|
|
3534
4265
|
files: {
|
|
@@ -3578,7 +4309,7 @@ describe("share", () => {
|
|
|
3578
4309
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3579
4310
|
const recreatedPath = path.join(companyRoot, "notes", "gone.md");
|
|
3580
4311
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3581
|
-
|
|
4312
|
+
seedJournalAt(journalPath, ({
|
|
3582
4313
|
version: "1",
|
|
3583
4314
|
lastSync: new Date().toISOString(),
|
|
3584
4315
|
files: {
|
|
@@ -3626,7 +4357,7 @@ describe("share", () => {
|
|
|
3626
4357
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3627
4358
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3628
4359
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3629
|
-
|
|
4360
|
+
seedJournalAt(journalPath, ({
|
|
3630
4361
|
version: "1",
|
|
3631
4362
|
lastSync: new Date().toISOString(),
|
|
3632
4363
|
files: {
|
|
@@ -3656,14 +4387,14 @@ describe("share", () => {
|
|
|
3656
4387
|
type: "delete-refused-stale-etag",
|
|
3657
4388
|
reason: "legacy-no-etag",
|
|
3658
4389
|
}));
|
|
3659
|
-
const journal =
|
|
4390
|
+
const journal = journalAt(journalPath);
|
|
3660
4391
|
expect(journal.files["notes/legacy.md"]).toBeDefined();
|
|
3661
4392
|
});
|
|
3662
4393
|
it("owned-only still requires an intent — it is the documented rollback knob", async () => {
|
|
3663
4394
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3664
4395
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3665
4396
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3666
|
-
|
|
4397
|
+
seedJournalAt(journalPath, ({
|
|
3667
4398
|
version: "1",
|
|
3668
4399
|
lastSync: new Date().toISOString(),
|
|
3669
4400
|
files: {
|
|
@@ -3850,7 +4581,7 @@ describe("share", () => {
|
|
|
3850
4581
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3851
4582
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3852
4583
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3853
|
-
|
|
4584
|
+
seedJournalAt(journalPath, ({
|
|
3854
4585
|
version: "1",
|
|
3855
4586
|
lastSync: new Date().toISOString(),
|
|
3856
4587
|
files: {
|
|
@@ -3891,7 +4622,7 @@ describe("share", () => {
|
|
|
3891
4622
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "regular.md");
|
|
3892
4623
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md");
|
|
3893
4624
|
// Both journal entries are removed by the delete loop's removeEntry call.
|
|
3894
|
-
const journal =
|
|
4625
|
+
const journal = journalAt(journalPath);
|
|
3895
4626
|
expect(journal.files["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md"]).toBeUndefined();
|
|
3896
4627
|
expect(journal.files["regular.md"]).toBeUndefined();
|
|
3897
4628
|
});
|
|
@@ -3906,7 +4637,7 @@ describe("share", () => {
|
|
|
3906
4637
|
process.env.HQ_STATE_DIR = stateDirPersonal;
|
|
3907
4638
|
try {
|
|
3908
4639
|
const journalPath = path.join(stateDirPersonal, "sync-journal.__hq_personal_vault__.json");
|
|
3909
|
-
|
|
4640
|
+
seedJournalAt(journalPath, ({
|
|
3910
4641
|
version: "1",
|
|
3911
4642
|
lastSync: new Date().toISOString(),
|
|
3912
4643
|
files: {
|
|
@@ -3932,7 +4663,7 @@ describe("share", () => {
|
|
|
3932
4663
|
});
|
|
3933
4664
|
expect(result.filesDeleted).toBe(1);
|
|
3934
4665
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "personal/.obsidian/graph.json.drift-1779863862-42519");
|
|
3935
|
-
const journal =
|
|
4666
|
+
const journal = journalAt(journalPath);
|
|
3936
4667
|
expect(journal.files["personal/.obsidian/graph.json.drift-1779863862-42519"]).toBeUndefined();
|
|
3937
4668
|
}
|
|
3938
4669
|
finally {
|
|
@@ -3955,7 +4686,7 @@ describe("share", () => {
|
|
|
3955
4686
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
3956
4687
|
direction: "up",
|
|
3957
4688
|
});
|
|
3958
|
-
|
|
4689
|
+
seedJournalAt(journalPath, ({
|
|
3959
4690
|
version: "1",
|
|
3960
4691
|
lastSync: new Date().toISOString(),
|
|
3961
4692
|
files: {
|
|
@@ -4003,7 +4734,7 @@ describe("share", () => {
|
|
|
4003
4734
|
expect(pruned[0].byReason["ignore-filtered"]).toBe(3);
|
|
4004
4735
|
expect(pruned[0].byReason["excluded-top-level"]).toBe(1);
|
|
4005
4736
|
// The persisted journal converged: excluded rows gone, legit row kept.
|
|
4006
|
-
const journal =
|
|
4737
|
+
const journal = journalAt(journalPath);
|
|
4007
4738
|
expect(Object.keys(journal.files)).toContain("personal/keep.md");
|
|
4008
4739
|
expect(journal.files[".claude/worktrees/wt/a.ts"]).toBeUndefined();
|
|
4009
4740
|
expect(journal.files["personal/node_modules/pkg/index.js"]).toBeUndefined();
|
|
@@ -4035,7 +4766,7 @@ describe("share", () => {
|
|
|
4035
4766
|
const key = "deleted-top-level.md";
|
|
4036
4767
|
const journalSlug = "personal-scoped-delete";
|
|
4037
4768
|
const journalPath = path.join(stateDir, `sync-journal.${journalSlug}.json`);
|
|
4038
|
-
|
|
4769
|
+
seedJournalAt(journalPath, ({
|
|
4039
4770
|
version: "2",
|
|
4040
4771
|
lastSync: new Date().toISOString(),
|
|
4041
4772
|
pulls: [],
|
|
@@ -4067,7 +4798,7 @@ describe("share", () => {
|
|
|
4067
4798
|
});
|
|
4068
4799
|
expect(result.filesDeleted).toBe(1);
|
|
4069
4800
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), key);
|
|
4070
|
-
expect(
|
|
4801
|
+
expect(journalAt(journalPath).files[key]).toBeUndefined();
|
|
4071
4802
|
});
|
|
4072
4803
|
// ── Decommission prefixes ──────────────────────────────────────────────────
|
|
4073
4804
|
//
|
|
@@ -4122,7 +4853,7 @@ describe("share", () => {
|
|
|
4122
4853
|
// Local file remains on disk — decommission is a remote-only sweep.
|
|
4123
4854
|
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "notes.md"))).toBe(true);
|
|
4124
4855
|
// Journal entry pruned in lockstep with the remote delete.
|
|
4125
|
-
const journalAfter =
|
|
4856
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4126
4857
|
expect(journalAfter.files["companies/foo/notes.md"]).toBeUndefined();
|
|
4127
4858
|
expect(journalAfter.files["knowledge/x.md"]).toBeDefined();
|
|
4128
4859
|
});
|
|
@@ -4158,7 +4889,7 @@ describe("share", () => {
|
|
|
4158
4889
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
4159
4890
|
// Journal entry preserved — the pulled-from-peer record stays so a
|
|
4160
4891
|
// future pull can still reconcile it correctly.
|
|
4161
|
-
const journalAfter =
|
|
4892
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4162
4893
|
expect(journalAfter.files["companies/foo/from-peer.md"]).toBeDefined();
|
|
4163
4894
|
});
|
|
4164
4895
|
it("decommissionPrefixes: dedupes with propagateDeletes plan — a key in both still emits one DeleteObject", async () => {
|
|
@@ -4278,7 +5009,7 @@ describe("share", () => {
|
|
|
4278
5009
|
expect(result.filesTombstoned).toBe(1);
|
|
4279
5010
|
expect(result.filesDeleted).toBe(0);
|
|
4280
5011
|
// Journal entry is gone.
|
|
4281
|
-
const journalAfter =
|
|
5012
|
+
const journalAfter = journalAt(personalJournalPath);
|
|
4282
5013
|
expect(journalAfter.files["companies/foo/gone.md"]).toBeUndefined();
|
|
4283
5014
|
});
|
|
4284
5015
|
describe("personalMode", () => {
|
|
@@ -4319,7 +5050,7 @@ describe("share", () => {
|
|
|
4319
5050
|
const acmeJournalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4320
5051
|
expect(fs.existsSync(personalJournalPath)).toBe(true);
|
|
4321
5052
|
expect(fs.existsSync(acmeJournalPath)).toBe(false);
|
|
4322
|
-
const journal =
|
|
5053
|
+
const journal = journalAt(personalJournalPath);
|
|
4323
5054
|
expect(journal.files["knowledge/notes.md"]).toBeDefined();
|
|
4324
5055
|
});
|
|
4325
5056
|
it("personalMode=true accepts files outside companies/<slug>/ (companion to the company-folder rejection)", async () => {
|
|
@@ -4607,7 +5338,7 @@ describe("share", () => {
|
|
|
4607
5338
|
.createHash("sha256")
|
|
4608
5339
|
.update(target)
|
|
4609
5340
|
.digest("hex");
|
|
4610
|
-
|
|
5341
|
+
seedJournalAt(journalPath, ({
|
|
4611
5342
|
version: "1",
|
|
4612
5343
|
lastSync: new Date().toISOString(),
|
|
4613
5344
|
files: {
|
|
@@ -5010,7 +5741,7 @@ describe("share", () => {
|
|
|
5010
5741
|
});
|
|
5011
5742
|
expect(pushed.filesUploaded).toBe(1);
|
|
5012
5743
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5013
|
-
const afterPush =
|
|
5744
|
+
const afterPush = journalAt(journalPath);
|
|
5014
5745
|
expect(Object.keys(afterPush.files)).toContain("knowledge/agents/brief.md");
|
|
5015
5746
|
vi.mocked(deleteRemoteFile).mockClear();
|
|
5016
5747
|
const walked = await share({
|
|
@@ -5024,7 +5755,7 @@ describe("share", () => {
|
|
|
5024
5755
|
expect(walked.filesDeleted).toBe(0);
|
|
5025
5756
|
const deletedKeys = vi.mocked(deleteRemoteFile).mock.calls.map((c) => c[1]);
|
|
5026
5757
|
expect(deletedKeys).not.toContain("knowledge/agents/brief.md");
|
|
5027
|
-
const afterWalk =
|
|
5758
|
+
const afterWalk = journalAt(journalPath);
|
|
5028
5759
|
expect(Object.keys(afterWalk.files)).toContain("knowledge/agents/brief.md");
|
|
5029
5760
|
});
|
|
5030
5761
|
});
|
|
@@ -5068,7 +5799,7 @@ describe("share", () => {
|
|
|
5068
5799
|
}
|
|
5069
5800
|
}
|
|
5070
5801
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5071
|
-
|
|
5802
|
+
seedJournalAt(journalPath, ({
|
|
5072
5803
|
version: "1",
|
|
5073
5804
|
lastSync: new Date().toISOString(),
|
|
5074
5805
|
files,
|
|
@@ -5126,7 +5857,7 @@ describe("share", () => {
|
|
|
5126
5857
|
expect(summary.samplePaths.length).toBeGreaterThan(0);
|
|
5127
5858
|
expect(summary.samplePaths.length).toBeLessThanOrEqual(10);
|
|
5128
5859
|
// Journal entries for the missing files SURVIVE — guard never mutates.
|
|
5129
|
-
const journal =
|
|
5860
|
+
const journal = journalAt(journalPath);
|
|
5130
5861
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5131
5862
|
// Per-key delete-refused-stale-etag events for each candidate.
|
|
5132
5863
|
const perKeyRefusals = events.filter((e) => e.type === "delete-refused-stale-etag" && e.reason === "bulk-asymmetry");
|
|
@@ -5176,7 +5907,7 @@ describe("share", () => {
|
|
|
5176
5907
|
expect(headedKeys).not.toContain(`f-${i.toString().padStart(4, "0")}.md`);
|
|
5177
5908
|
}
|
|
5178
5909
|
// Journal untouched — the entries stay recoverable.
|
|
5179
|
-
const journal =
|
|
5910
|
+
const journal = journalAt(journalPath);
|
|
5180
5911
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5181
5912
|
});
|
|
5182
5913
|
// The core of the #227 regression, isolated: pre-#227 a trip emptied
|
|
@@ -5207,7 +5938,7 @@ describe("share", () => {
|
|
|
5207
5938
|
const perKeyRefusals = events.filter((e) => e.type === "delete-refused-stale-etag" &&
|
|
5208
5939
|
e.reason === "bulk-asymmetry");
|
|
5209
5940
|
expect(perKeyRefusals.length).toBe(11);
|
|
5210
|
-
const journal =
|
|
5941
|
+
const journal = journalAt(journalPath);
|
|
5211
5942
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5212
5943
|
});
|
|
5213
5944
|
it("vault litter still drains when the breaker trips", async () => {
|
|
@@ -5221,7 +5952,7 @@ describe("share", () => {
|
|
|
5221
5952
|
withIntents: false,
|
|
5222
5953
|
});
|
|
5223
5954
|
const litterKey = "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md";
|
|
5224
|
-
const seeded =
|
|
5955
|
+
const seeded = journalAt(journalPath);
|
|
5225
5956
|
seeded.files[litterKey] = {
|
|
5226
5957
|
hash: "h",
|
|
5227
5958
|
size: 5,
|
|
@@ -5230,7 +5961,7 @@ describe("share", () => {
|
|
|
5230
5961
|
remoteEtag: "litter-etag",
|
|
5231
5962
|
kind: "file",
|
|
5232
5963
|
};
|
|
5233
|
-
|
|
5964
|
+
seedJournalAt(journalPath, seeded);
|
|
5234
5965
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
5235
5966
|
etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
|
|
5236
5967
|
lastModified: new Date(),
|
|
@@ -5370,7 +6101,7 @@ describe("share", () => {
|
|
|
5370
6101
|
expect(summary).toBeDefined();
|
|
5371
6102
|
expect(summary.ratio).toBeCloseTo(1.0, 5);
|
|
5372
6103
|
// Journal untouched.
|
|
5373
|
-
const journal =
|
|
6104
|
+
const journal = journalAt(journalPath);
|
|
5374
6105
|
expect(Object.keys(journal.files).length).toBe(50);
|
|
5375
6106
|
});
|
|
5376
6107
|
it("small absolute count (9/9 = 100% ratio but < MIN_ABS=10) does NOT trip: all 9 delete normally", async () => {
|
|
@@ -5414,7 +6145,7 @@ describe("share", () => {
|
|
|
5414
6145
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
5415
6146
|
expect(events.find((e) => e.type === "delete-refused-bulk-asymmetry")).toBeDefined();
|
|
5416
6147
|
// Journal entries survive.
|
|
5417
|
-
const journal =
|
|
6148
|
+
const journal = journalAt(journalPath);
|
|
5418
6149
|
expect(Object.keys(journal.files).length).toBe(100);
|
|
5419
6150
|
});
|
|
5420
6151
|
it("direction:'down' entries don't count toward the bulk numerator (owned-only silently skips them)", async () => {
|
|
@@ -5442,7 +6173,7 @@ describe("share", () => {
|
|
|
5442
6173
|
localDeleteIntent: deleteIntent("up-etag", "h"),
|
|
5443
6174
|
};
|
|
5444
6175
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5445
|
-
|
|
6176
|
+
seedJournalAt(journalPath, { version: "1", lastSync: now, files });
|
|
5446
6177
|
const result = await share({
|
|
5447
6178
|
paths: [companyRoot],
|
|
5448
6179
|
company: "acme",
|
|
@@ -5565,7 +6296,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5565
6296
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
5566
6297
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5567
6298
|
// v2-shaped fixture — same field set the production journal uses.
|
|
5568
|
-
|
|
6299
|
+
seedJournalAt(journalPath, ({
|
|
5569
6300
|
version: "2",
|
|
5570
6301
|
lastSync: new Date().toISOString(),
|
|
5571
6302
|
files: {
|
|
@@ -5611,7 +6342,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5611
6342
|
const { hashFile } = await import("../journal.js");
|
|
5612
6343
|
const baselineHash = hashFile(testFile);
|
|
5613
6344
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5614
|
-
|
|
6345
|
+
seedJournalAt(journalPath, ({
|
|
5615
6346
|
version: "2",
|
|
5616
6347
|
lastSync: new Date().toISOString(),
|
|
5617
6348
|
files: {
|
|
@@ -5652,7 +6383,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5652
6383
|
const originalLstat = fs.lstatSync(testFile);
|
|
5653
6384
|
// The recorded hash intentionally differs from the current bytes.
|
|
5654
6385
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5655
|
-
|
|
6386
|
+
seedJournalAt(journalPath, ({
|
|
5656
6387
|
version: "2",
|
|
5657
6388
|
lastSync: new Date().toISOString(),
|
|
5658
6389
|
files: {
|
|
@@ -5699,7 +6430,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5699
6430
|
const { hashFile } = await import("../journal.js");
|
|
5700
6431
|
const realHash = hashFile(testFile);
|
|
5701
6432
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5702
|
-
|
|
6433
|
+
seedJournalAt(journalPath, ({
|
|
5703
6434
|
version: "2",
|
|
5704
6435
|
lastSync: new Date().toISOString(),
|
|
5705
6436
|
files: {
|
|
@@ -5749,7 +6480,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5749
6480
|
const { hashFile } = await import("../journal.js");
|
|
5750
6481
|
const realHash = hashFile(testFile);
|
|
5751
6482
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5752
|
-
|
|
6483
|
+
seedJournalAt(journalPath, ({
|
|
5753
6484
|
version: "2",
|
|
5754
6485
|
lastSync: new Date().toISOString(),
|
|
5755
6486
|
files: {
|
|
@@ -5781,7 +6512,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5781
6512
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
5782
6513
|
// Journal mtimeMs is now the bumped value, while content hashing remains
|
|
5783
6514
|
// authoritative on the next sync.
|
|
5784
|
-
const journalAfter =
|
|
6515
|
+
const journalAfter = journalAt(journalPath);
|
|
5785
6516
|
expect(journalAfter.files["touched-then-fast.md"].mtimeMs).toBe(bumpedLstat.mtimeMs);
|
|
5786
6517
|
expect(journalAfter.files["touched-then-fast.md"].size).toBe(bumpedLstat.size);
|
|
5787
6518
|
// Content hash untouched — the file genuinely didn't change.
|
|
@@ -5801,7 +6532,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5801
6532
|
});
|
|
5802
6533
|
expect(result.filesUploaded).toBe(1);
|
|
5803
6534
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5804
|
-
const journal =
|
|
6535
|
+
const journal = journalAt(journalPath);
|
|
5805
6536
|
expect(journal.files["fresh.md"]).toBeDefined();
|
|
5806
6537
|
expect(journal.files["fresh.md"].mtimeMs).toBe(lstat.mtimeMs);
|
|
5807
6538
|
expect(journal.files["fresh.md"].size).toBe(lstat.size);
|
|
@@ -5818,7 +6549,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5818
6549
|
const realSize = fs.statSync(testFile).size;
|
|
5819
6550
|
// Real hash but NO mtimeMs. Hash matches → skip upload.
|
|
5820
6551
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5821
|
-
|
|
6552
|
+
seedJournalAt(journalPath, ({
|
|
5822
6553
|
version: "1",
|
|
5823
6554
|
lastSync: new Date().toISOString(),
|
|
5824
6555
|
files: {
|
|
@@ -6377,7 +7108,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6377
7108
|
// AND lost their etag/content-hash/stat journal metadata.
|
|
6378
7109
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6379
7110
|
expect(fs.existsSync(journalPath)).toBe(true);
|
|
6380
|
-
const journal =
|
|
7111
|
+
const journal = journalAt(journalPath);
|
|
6381
7112
|
const stampedKeys = Object.keys(journal.files);
|
|
6382
7113
|
expect(stampedKeys.length).toBe(FILE_COUNT - 1);
|
|
6383
7114
|
// The failed file is NOT in the journal (no successful upload).
|
|
@@ -6525,7 +7256,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6525
7256
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
6526
7257
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
6527
7258
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6528
|
-
|
|
7259
|
+
seedJournalAt(journalPath, ({
|
|
6529
7260
|
version: "1",
|
|
6530
7261
|
lastSync: new Date().toISOString(),
|
|
6531
7262
|
files: {
|
|
@@ -6554,7 +7285,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6554
7285
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
6555
7286
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
6556
7287
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
6557
|
-
const journal =
|
|
7288
|
+
const journal = journalAt(journalPath);
|
|
6558
7289
|
expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
|
|
6559
7290
|
});
|
|
6560
7291
|
it("defers an unreadable scope-invalid symlink tombstone without a fatal error", async () => {
|
|
@@ -6566,7 +7297,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6566
7297
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
6567
7298
|
fs.symlinkSync(targetDir, linkPath, "dir");
|
|
6568
7299
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6569
|
-
|
|
7300
|
+
seedJournalAt(journalPath, ({
|
|
6570
7301
|
version: "1",
|
|
6571
7302
|
lastSync: new Date().toISOString(),
|
|
6572
7303
|
files: {
|
|
@@ -6611,7 +7342,7 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
6611
7342
|
samplePaths: [linkKey],
|
|
6612
7343
|
});
|
|
6613
7344
|
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
6614
|
-
const journal =
|
|
7345
|
+
const journal = journalAt(journalPath);
|
|
6615
7346
|
expect(journal.files[linkKey]).toBeDefined();
|
|
6616
7347
|
}
|
|
6617
7348
|
finally {
|