@indigoai-us/hq-cloud 6.15.24 → 6.15.26
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.d.ts +10 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +17 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +58 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +5 -4
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +87 -15
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +125 -50
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +99 -20
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +327 -40
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +13 -2
- package/dist/object-io.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/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/cli/sync.test.js
CHANGED
|
@@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
|
5
5
|
import * as fs from "fs";
|
|
6
6
|
import * as path from "path";
|
|
7
7
|
import * as os from "os";
|
|
8
|
+
import * as crypto from "crypto";
|
|
8
9
|
import { clearContextCache } from "../context.js";
|
|
9
10
|
import { lockPathFor } from "../operation-lock.js";
|
|
10
11
|
// Re-export node:fs as a mutable module so unreadable-link regressions can
|
|
@@ -13,6 +14,13 @@ vi.mock("fs", async (importOriginal) => {
|
|
|
13
14
|
const actual = await importOriginal();
|
|
14
15
|
return { ...actual };
|
|
15
16
|
});
|
|
17
|
+
// Same mutable-namespace trick as `fs` above: `skip` is an interactive-only
|
|
18
|
+
// resolution, so the only way to exercise its arm is to stand in for the
|
|
19
|
+
// prompt. Re-exporting the real module keeps every other call site genuine.
|
|
20
|
+
vi.mock("./conflict.js", async (importOriginal) => {
|
|
21
|
+
const actual = await importOriginal();
|
|
22
|
+
return { ...actual };
|
|
23
|
+
});
|
|
16
24
|
describe("sync mutation stdin boundary", () => {
|
|
17
25
|
it("accepts only the exact sidecar command and emits one discriminated upsert result", async () => {
|
|
18
26
|
expect(SYNC_MUTATION_STDIN_COMMAND).toEqual(["sync", "mutation", "--stdin-json"]);
|
|
@@ -115,6 +123,7 @@ vi.mock("./reindex.js", () => ({
|
|
|
115
123
|
import { sync, reportNewFilesToNotify, applyRealtimeDeltaPage, isSyncMutationStdinCommand, runSyncMutationStdinJson, SYNC_MUTATION_STDIN_COMMAND, win32SymlinkFlavorBroken, } from "./sync.js";
|
|
116
124
|
import { share } from "./share.js";
|
|
117
125
|
import * as s3Module from "../s3.js";
|
|
126
|
+
import * as conflictModule from "./conflict.js";
|
|
118
127
|
import { reindex } from "./reindex.js";
|
|
119
128
|
import { readSyncProgress } from "../sync-progress.js";
|
|
120
129
|
import { VaultAuthError } from "../vault-client.js";
|
|
@@ -135,6 +144,16 @@ function journalAt(journalPath) {
|
|
|
135
144
|
.replace(/\.json$/, "");
|
|
136
145
|
return readJournal(slug);
|
|
137
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* The single `.conflict-` sidecar in a directory. Asserting through this rather
|
|
149
|
+
* than re-globbing inline keeps "exactly one was minted" enforced everywhere a
|
|
150
|
+
* test reaches for the preserved side.
|
|
151
|
+
*/
|
|
152
|
+
function sidecarIn(dir) {
|
|
153
|
+
const found = fs.readdirSync(dir).filter((f) => f.includes(".conflict-"));
|
|
154
|
+
expect(found).toHaveLength(1);
|
|
155
|
+
return path.join(dir, found[0]);
|
|
156
|
+
}
|
|
138
157
|
const mockConfig = {
|
|
139
158
|
apiUrl: "https://vault-api.test",
|
|
140
159
|
authToken: "test-jwt-token",
|
|
@@ -482,7 +501,7 @@ describe("sync", () => {
|
|
|
482
501
|
const result = await sync({ vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
483
502
|
expect(result.filesDownloaded).toBe(2);
|
|
484
503
|
});
|
|
485
|
-
it("detects conflicts with local changes and
|
|
504
|
+
it("detects conflicts with local changes and adopts the cloud copy on --on-conflict keep", async () => {
|
|
486
505
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
487
506
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
488
507
|
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
@@ -506,10 +525,12 @@ describe("sync", () => {
|
|
|
506
525
|
});
|
|
507
526
|
expect(result.conflicts).toBe(1);
|
|
508
527
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
509
|
-
|
|
510
|
-
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("
|
|
528
|
+
// `keep` adopts the cloud body at the original name…
|
|
529
|
+
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("mock file content");
|
|
530
|
+
// …and the local one survives beside it.
|
|
531
|
+
expect(fs.readFileSync(sidecarIn(companyDocs), "utf-8")).toBe("local version");
|
|
511
532
|
});
|
|
512
|
-
it("conflict
|
|
533
|
+
it("conflict skip marks the entry localDiverges (journal-honesty: the held copy never matched the recorded remote etag)", async () => {
|
|
513
534
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
514
535
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
515
536
|
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
@@ -525,17 +546,28 @@ describe("sync", () => {
|
|
|
525
546
|
},
|
|
526
547
|
},
|
|
527
548
|
}));
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
549
|
+
// `keep` no longer holds, so the honesty flag now belongs to the arms that
|
|
550
|
+
// still do. `skip` is the interactive one and is exercised through the
|
|
551
|
+
// prompt stand-in.
|
|
552
|
+
const resolveSpy = vi
|
|
553
|
+
.spyOn(conflictModule, "resolveConflict")
|
|
554
|
+
.mockResolvedValue("skip");
|
|
555
|
+
let result;
|
|
556
|
+
try {
|
|
557
|
+
result = await sync({
|
|
558
|
+
company: "acme",
|
|
559
|
+
vaultConfig: mockConfig,
|
|
560
|
+
hqRoot: tmpDir,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
finally {
|
|
564
|
+
resolveSpy.mockRestore();
|
|
565
|
+
}
|
|
534
566
|
expect(result.conflicts).toBe(1);
|
|
535
567
|
const journal = journalAt(journalPath);
|
|
536
568
|
// remoteEtag still recorded so the conflict can't re-fire (#137 invariant)…
|
|
537
569
|
expect(journal.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
538
|
-
// …but the
|
|
570
|
+
// …but the held local copy is flagged divergent so a later currency-gated
|
|
539
571
|
// delete cannot fire on a journal that lies about local==remote.
|
|
540
572
|
expect(journal.files["docs/handoff.md"].localDiverges).toBe(true);
|
|
541
573
|
});
|
|
@@ -576,6 +608,249 @@ describe("sync", () => {
|
|
|
576
608
|
expect(journal.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
577
609
|
expect(journal.files["docs/handoff.md"].localDiverges).toBe(true);
|
|
578
610
|
});
|
|
611
|
+
// --- `keep` semantics, revised 2026-08-21 -------------------------------
|
|
612
|
+
//
|
|
613
|
+
// `keep` now means "the CLOUD copy becomes the working copy; my local body is
|
|
614
|
+
// preserved beside it as a `.conflict-` sidecar." It used to mean the
|
|
615
|
+
// opposite — hold local, stamp the remote etag, arm `localDiverges` — and
|
|
616
|
+
// that combination was terminal: push refused the divergent local, pull saw
|
|
617
|
+
// the remote unchanged so it never replaced the entry, and every subsequent
|
|
618
|
+
// `keep` re-armed the flag. The file could converge in neither direction, and
|
|
619
|
+
// because the desktop app hardcodes `--on-conflict keep`, that was the
|
|
620
|
+
// default outcome for every real conflict.
|
|
621
|
+
//
|
|
622
|
+
// Electing the cloud body clears the divergence BY CONSTRUCTION — the entry
|
|
623
|
+
// then genuinely records what sits on disk — while the local bytes survive
|
|
624
|
+
// under the sidecar for the operator to re-apply. Nothing is destroyed; the
|
|
625
|
+
// working copy changes and the old one is renamed.
|
|
626
|
+
it("conflict keep takes the cloud body at the original path and preserves local in a `.conflict-` sidecar", async () => {
|
|
627
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
628
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
629
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
630
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
631
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
|
|
632
|
+
]);
|
|
633
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
634
|
+
version: "1",
|
|
635
|
+
lastSync: new Date().toISOString(),
|
|
636
|
+
files: {
|
|
637
|
+
"docs/handoff.md": {
|
|
638
|
+
hash: "old-hash-from-last-sync",
|
|
639
|
+
size: 20,
|
|
640
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
641
|
+
direction: "down",
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
}));
|
|
645
|
+
const result = await sync({
|
|
646
|
+
company: "acme",
|
|
647
|
+
onConflict: "keep",
|
|
648
|
+
vaultConfig: mockConfig,
|
|
649
|
+
hqRoot: tmpDir,
|
|
650
|
+
});
|
|
651
|
+
expect(result.conflicts).toBe(1);
|
|
652
|
+
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
653
|
+
// The cloud body is the working copy now.
|
|
654
|
+
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("mock file content");
|
|
655
|
+
// …and the local body is not lost — it is sitting beside it.
|
|
656
|
+
const sidecars = fs
|
|
657
|
+
.readdirSync(companyDocs)
|
|
658
|
+
.filter((f) => f.includes(".conflict-"));
|
|
659
|
+
expect(sidecars).toHaveLength(1);
|
|
660
|
+
expect(fs.readFileSync(path.join(companyDocs, sidecars[0]), "utf-8")).toBe("local version");
|
|
661
|
+
});
|
|
662
|
+
it("conflict keep clears the fetched sidecar before rename for Windows compatibility", async () => {
|
|
663
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
664
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
665
|
+
const originalPath = path.join(companyDocs, "handoff.md");
|
|
666
|
+
fs.writeFileSync(originalPath, "local version");
|
|
667
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
668
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
|
|
669
|
+
]);
|
|
670
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
671
|
+
version: "1",
|
|
672
|
+
lastSync: new Date().toISOString(),
|
|
673
|
+
files: {
|
|
674
|
+
"docs/handoff.md": {
|
|
675
|
+
hash: "old-hash-from-last-sync",
|
|
676
|
+
size: 20,
|
|
677
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
678
|
+
direction: "down",
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
}));
|
|
682
|
+
// Node's Windows rename does not replace an occupied destination. Model
|
|
683
|
+
// that rule only for the local→sidecar move; unrelated atomic index and
|
|
684
|
+
// journal renames keep using the host filesystem's normal behaviour.
|
|
685
|
+
const realRenameSync = fs.renameSync;
|
|
686
|
+
const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
|
|
687
|
+
if (String(from) === originalPath &&
|
|
688
|
+
String(to).includes(".conflict-") &&
|
|
689
|
+
fs.existsSync(to)) {
|
|
690
|
+
throw errnoError("EEXIST");
|
|
691
|
+
}
|
|
692
|
+
return realRenameSync(from, to);
|
|
693
|
+
});
|
|
694
|
+
try {
|
|
695
|
+
await sync({
|
|
696
|
+
company: "acme",
|
|
697
|
+
onConflict: "keep",
|
|
698
|
+
vaultConfig: mockConfig,
|
|
699
|
+
hqRoot: tmpDir,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
finally {
|
|
703
|
+
renameSpy.mockRestore();
|
|
704
|
+
}
|
|
705
|
+
expect(fs.readFileSync(originalPath, "utf-8")).toBe("mock file content");
|
|
706
|
+
expect(fs.readFileSync(sidecarIn(companyDocs), "utf-8")).toBe("local version");
|
|
707
|
+
});
|
|
708
|
+
it("conflict keep converges the journal on the REMOTE body and leaves localDiverges unset", async () => {
|
|
709
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
710
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
711
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
712
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
713
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
|
|
714
|
+
]);
|
|
715
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
716
|
+
version: "1",
|
|
717
|
+
lastSync: new Date().toISOString(),
|
|
718
|
+
files: {
|
|
719
|
+
"docs/handoff.md": {
|
|
720
|
+
hash: "old-hash-from-last-sync",
|
|
721
|
+
size: 20,
|
|
722
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
723
|
+
direction: "down",
|
|
724
|
+
},
|
|
725
|
+
},
|
|
726
|
+
}));
|
|
727
|
+
await sync({
|
|
728
|
+
company: "acme",
|
|
729
|
+
onConflict: "keep",
|
|
730
|
+
vaultConfig: mockConfig,
|
|
731
|
+
hqRoot: tmpDir,
|
|
732
|
+
});
|
|
733
|
+
const entry = journalAt(journalPath).files["docs/handoff.md"];
|
|
734
|
+
expect(entry.remoteEtag).toBeTruthy();
|
|
735
|
+
// The whole point of the change: the entry describes the body that is
|
|
736
|
+
// actually on disk, so the honesty flag has nothing to report. A set flag
|
|
737
|
+
// here would re-create the deadlock this test exists to prevent.
|
|
738
|
+
expect(entry.localDiverges).toBeFalsy();
|
|
739
|
+
});
|
|
740
|
+
it("conflict keep records the REMOTE body's hash, so the entry is pushable instead of permanently divergent", async () => {
|
|
741
|
+
// This is the deadlock in one assertion. The old `keep` wrote the LOCAL
|
|
742
|
+
// hash next to the REMOTE etag, which is a self-contradictory entry: the
|
|
743
|
+
// push leg refuses to send a body the journal calls divergent, and the pull
|
|
744
|
+
// leg sees a remote whose etag already matches and leaves it alone. Writing
|
|
745
|
+
// the remote hash is what makes the entry describable — and therefore
|
|
746
|
+
// sendable — again.
|
|
747
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
748
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
749
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
750
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
751
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
|
|
752
|
+
]);
|
|
753
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
754
|
+
version: "1",
|
|
755
|
+
lastSync: new Date().toISOString(),
|
|
756
|
+
files: {
|
|
757
|
+
"docs/handoff.md": {
|
|
758
|
+
hash: "old-hash-from-last-sync",
|
|
759
|
+
size: 20,
|
|
760
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
761
|
+
direction: "down",
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
}));
|
|
765
|
+
await sync({
|
|
766
|
+
company: "acme",
|
|
767
|
+
onConflict: "keep",
|
|
768
|
+
vaultConfig: mockConfig,
|
|
769
|
+
hqRoot: tmpDir,
|
|
770
|
+
});
|
|
771
|
+
// Pinned to the REMOTE body specifically, not to "whatever is on disk" —
|
|
772
|
+
// the old behaviour also left those two agreeing (it recorded the local
|
|
773
|
+
// hash and left the local file in place), so a disk-relative assertion
|
|
774
|
+
// would pass against the very bug this guards.
|
|
775
|
+
const remoteHash = crypto
|
|
776
|
+
.createHash("sha256")
|
|
777
|
+
.update("mock file content")
|
|
778
|
+
.digest("hex");
|
|
779
|
+
const entry = journalAt(journalPath).files["docs/handoff.md"];
|
|
780
|
+
expect(entry.hash).toBe(remoteHash);
|
|
781
|
+
});
|
|
782
|
+
it("conflict keep indexes the sidecar as the LOCAL body so /resolve-conflicts offers the right side", async () => {
|
|
783
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
784
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
785
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
786
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
787
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
|
|
788
|
+
]);
|
|
789
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
790
|
+
version: "1",
|
|
791
|
+
lastSync: new Date().toISOString(),
|
|
792
|
+
files: {
|
|
793
|
+
"docs/handoff.md": {
|
|
794
|
+
hash: "old-hash-from-last-sync",
|
|
795
|
+
size: 20,
|
|
796
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
797
|
+
direction: "down",
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
}));
|
|
801
|
+
await sync({
|
|
802
|
+
company: "acme",
|
|
803
|
+
onConflict: "keep",
|
|
804
|
+
vaultConfig: mockConfig,
|
|
805
|
+
hqRoot: tmpDir,
|
|
806
|
+
});
|
|
807
|
+
const index = JSON.parse(fs.readFileSync(path.join(tmpDir, ".hq-conflicts", "index.json"), "utf-8"));
|
|
808
|
+
expect(index.conflicts).toHaveLength(1);
|
|
809
|
+
const entry = index.conflicts[0];
|
|
810
|
+
expect(entry.originalPath).toBe("companies/acme/docs/handoff.md");
|
|
811
|
+
expect(entry.preserved).toBe("local");
|
|
812
|
+
// The sidecar it points at holds the LOCAL body — the side the operator
|
|
813
|
+
// may want back. Reading the recorded path is the contract /resolve-conflicts
|
|
814
|
+
// relies on, so assert through it rather than re-globbing the directory.
|
|
815
|
+
expect(fs.readFileSync(path.join(tmpDir, entry.conflictPath), "utf-8")).toBe("local version");
|
|
816
|
+
});
|
|
817
|
+
it("interactive `skip` still leaves local in place — skip is not keep", async () => {
|
|
818
|
+
// `skip` means "do not touch this one, I will deal with it later". It is a
|
|
819
|
+
// separate arm from `keep` and must NOT adopt the cloud body; a strategy
|
|
820
|
+
// that says "leave it alone" may not rename the operator's file.
|
|
821
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
822
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
823
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
824
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
825
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
|
|
826
|
+
]);
|
|
827
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
828
|
+
version: "1",
|
|
829
|
+
lastSync: new Date().toISOString(),
|
|
830
|
+
files: {
|
|
831
|
+
"docs/handoff.md": {
|
|
832
|
+
hash: "old-hash-from-last-sync",
|
|
833
|
+
size: 20,
|
|
834
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
835
|
+
direction: "down",
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
}));
|
|
839
|
+
const resolveSpy = vi
|
|
840
|
+
.spyOn(conflictModule, "resolveConflict")
|
|
841
|
+
.mockResolvedValue("skip");
|
|
842
|
+
try {
|
|
843
|
+
await sync({
|
|
844
|
+
company: "acme",
|
|
845
|
+
vaultConfig: mockConfig,
|
|
846
|
+
hqRoot: tmpDir,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
finally {
|
|
850
|
+
resolveSpy.mockRestore();
|
|
851
|
+
}
|
|
852
|
+
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
|
|
853
|
+
});
|
|
579
854
|
it("a genuine download clears a stale localDiverges flag (entry is now truly local==remote)", async () => {
|
|
580
855
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
581
856
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
@@ -835,13 +1110,10 @@ describe("sync", () => {
|
|
|
835
1110
|
});
|
|
836
1111
|
expect(result.conflicts).toBe(1);
|
|
837
1112
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
838
|
-
//
|
|
839
|
-
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("
|
|
840
|
-
//
|
|
841
|
-
|
|
842
|
-
.readdirSync(companyDocs)
|
|
843
|
-
.filter((f) => f.includes(".conflict-"));
|
|
844
|
-
expect(litter).toHaveLength(1);
|
|
1113
|
+
// Cloud body adopted at the original name on keep…
|
|
1114
|
+
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("mock file content");
|
|
1115
|
+
// …and exactly one sidecar retained, holding the displaced local body.
|
|
1116
|
+
expect(fs.readFileSync(sidecarIn(companyDocs), "utf-8")).toBe("local version");
|
|
845
1117
|
});
|
|
846
1118
|
it("cloud-authoritative file is PULL-WINS on conflict: overwrites local, no mirror, no false-stamp", async () => {
|
|
847
1119
|
// company-brief.md is server-regenerated (gardener), so a divergence is not
|
|
@@ -954,10 +1226,12 @@ describe("sync", () => {
|
|
|
954
1226
|
// Surfaced as a conflict, NOT a silent download.
|
|
955
1227
|
expect(result.conflicts).toBe(1);
|
|
956
1228
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
957
|
-
expect(result.filesDownloaded).toBe(
|
|
958
|
-
// The newer local bytes are
|
|
959
|
-
|
|
960
|
-
//
|
|
1229
|
+
expect(result.filesDownloaded).toBe(1);
|
|
1230
|
+
// The newer local bytes are not lost — the rollback is survivable because
|
|
1231
|
+
// `keep` renames them aside rather than discarding them. The guard's job is
|
|
1232
|
+
// to route this through conflict resolution instead of a silent download;
|
|
1233
|
+
// which side ends up at the original name is the strategy's call.
|
|
1234
|
+
expect(fs.readFileSync(sidecarIn(path.dirname(localPath)), "utf-8")).toBe(localContent);
|
|
961
1235
|
const litter = fs
|
|
962
1236
|
.readdirSync(companyDocs)
|
|
963
1237
|
.filter((f) => f.includes(".conflict-"));
|
|
@@ -1017,8 +1291,8 @@ describe("sync", () => {
|
|
|
1017
1291
|
});
|
|
1018
1292
|
expect(result.conflicts).toBe(1);
|
|
1019
1293
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
1020
|
-
expect(result.filesDownloaded).toBe(
|
|
1021
|
-
expect(fs.readFileSync(localPath, "utf-8")).toBe(localEdit);
|
|
1294
|
+
expect(result.filesDownloaded).toBe(1);
|
|
1295
|
+
expect(fs.readFileSync(sidecarIn(path.dirname(localPath)), "utf-8")).toBe(localEdit);
|
|
1022
1296
|
});
|
|
1023
1297
|
it("anti-rollback guard is direction-gated: a SMALLER remote on a clean CONSUMER copy (direction=down) downloads, not conflict", async () => {
|
|
1024
1298
|
// The device only ever PULLED this file (direction: "down") — a consumer
|
|
@@ -1142,18 +1416,16 @@ describe("sync", () => {
|
|
|
1142
1416
|
// Surfaced as a conflict, NOT a silent download.
|
|
1143
1417
|
expect(result.conflicts).toBe(1);
|
|
1144
1418
|
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
1145
|
-
expect(result.filesDownloaded).toBe(
|
|
1146
|
-
// The local
|
|
1147
|
-
|
|
1148
|
-
//
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
expect(litter).toHaveLength(1);
|
|
1153
|
-
expect(fs.readFileSync(path.join(companyDocs, litter[0]), "utf-8")).toBe("mock file content");
|
|
1419
|
+
expect(result.filesDownloaded).toBe(1);
|
|
1420
|
+
// The unjournaled local body is not lost — it is renamed aside, which is
|
|
1421
|
+
// what makes adopting the cloud copy survivable for a file that had no
|
|
1422
|
+
// sync history at all.
|
|
1423
|
+
expect(fs.readFileSync(sidecarIn(path.dirname(localPath)), "utf-8")).toBe(localContent);
|
|
1424
|
+
// The cloud body took the original name.
|
|
1425
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
1154
1426
|
// And a durable record exists so the overwrite can never be silent.
|
|
1155
1427
|
const index = JSON.parse(fs.readFileSync(path.join(tmpDir, ".hq-conflicts", "index.json"), "utf-8"));
|
|
1156
|
-
expect(index.conflicts.some((c) => c.originalPath === "companies/acme/docs/handoff.md")).toBe(true);
|
|
1428
|
+
expect(index.conflicts.some((c) => c.originalPath === "companies/acme/docs/handoff.md" && c.preserved === "local")).toBe(true);
|
|
1157
1429
|
});
|
|
1158
1430
|
it("untracked-local guard is free on first pull: an unjournaled local file that already MATCHES the cloud reconciles silently", async () => {
|
|
1159
1431
|
// The guard must not manufacture conflicts for the common case — a fresh
|
|
@@ -1803,9 +2075,10 @@ describe("sync", () => {
|
|
|
1803
2075
|
});
|
|
1804
2076
|
expect(result.conflicts).toBe(1);
|
|
1805
2077
|
expect(result.conflictPaths).toEqual(["workspace/threads/handoff.json"]);
|
|
1806
|
-
|
|
1807
|
-
//
|
|
1808
|
-
|
|
2078
|
+
// B's newer local pointer is preserved verbatim — renamed aside rather than
|
|
2079
|
+
// destroyed. DEV-1778 was about the pointer being LOST; a recoverable
|
|
2080
|
+
// sidecar satisfies that, and the machine can still resume from it.
|
|
2081
|
+
const kept = JSON.parse(fs.readFileSync(sidecarIn(threadsLocal), "utf-8"));
|
|
1809
2082
|
expect(kept.message).toBe("newer local from B");
|
|
1810
2083
|
expect(kept.thread_path).toBe("workspace/threads/T-machineB.json");
|
|
1811
2084
|
});
|
|
@@ -2307,10 +2580,16 @@ describe("sync", () => {
|
|
|
2307
2580
|
},
|
|
2308
2581
|
pulls: [],
|
|
2309
2582
|
}));
|
|
2310
|
-
vi.mocked(s3Module.headRemoteFile)
|
|
2583
|
+
vi.mocked(s3Module.headRemoteFile)
|
|
2584
|
+
.mockResolvedValueOnce({
|
|
2311
2585
|
etag: "old-etag",
|
|
2312
2586
|
size: baseline.length,
|
|
2313
2587
|
lastModified: new Date(),
|
|
2588
|
+
})
|
|
2589
|
+
.mockResolvedValueOnce({
|
|
2590
|
+
etag: "peer-etag",
|
|
2591
|
+
size: "mock file content".length,
|
|
2592
|
+
lastModified: new Date(),
|
|
2314
2593
|
});
|
|
2315
2594
|
const preconditionError = Object.assign(new Error("remote changed"), {
|
|
2316
2595
|
name: "PreconditionFailed",
|
|
@@ -2326,7 +2605,15 @@ describe("sync", () => {
|
|
|
2326
2605
|
onConflict: "keep",
|
|
2327
2606
|
});
|
|
2328
2607
|
expect(result.conflictPaths).toContain("board.json");
|
|
2329
|
-
|
|
2608
|
+
// The conditional PUT still fails closed, but keep is cloud-wins: the
|
|
2609
|
+
// peer body takes the working name and the local append remains in the
|
|
2610
|
+
// sidecar for explicit resolution.
|
|
2611
|
+
expect(fs.readFileSync(boardPath, "utf-8")).toBe("mock file content");
|
|
2612
|
+
expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe(localAppend);
|
|
2613
|
+
expect(journalAt(journalPath).files["board.json"]).toMatchObject({
|
|
2614
|
+
remoteEtag: "peer-etag",
|
|
2615
|
+
direction: "down",
|
|
2616
|
+
});
|
|
2330
2617
|
expect(s3Module.uploadFile).toHaveBeenCalledWith(expect.anything(), boardPath, "board.json", undefined, { ifMatch: "old-etag" });
|
|
2331
2618
|
});
|
|
2332
2619
|
it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
|
|
@@ -2604,7 +2891,7 @@ describe("sync", () => {
|
|
|
2604
2891
|
expect(result.conflicts).toBeGreaterThanOrEqual(1);
|
|
2605
2892
|
expect(result.conflictPaths).toContain("docs/edited.md");
|
|
2606
2893
|
expect(fs.existsSync(editedPath)).toBe(true);
|
|
2607
|
-
expect(fs.readFileSync(editedPath, "utf-8")).toBe("my local edits");
|
|
2894
|
+
expect(fs.readFileSync(sidecarIn(path.dirname(editedPath)), "utf-8")).toBe("my local edits");
|
|
2608
2895
|
expect(result.filesTombstoned).toBe(0);
|
|
2609
2896
|
});
|
|
2610
2897
|
it("delete-resync: suppression is per-key and the fetch is company-scoped (no cross-tenant bleed)", async () => {
|