@indigoai-us/hq-cloud 6.12.4 → 6.13.0

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.
@@ -235,6 +235,64 @@ describe("sync", () => {
235
235
  expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
236
236
  expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
237
237
  });
238
+ it("conflict keep marks the entry localDiverges (journal-honesty: kept copy never matched the recorded remote etag)", async () => {
239
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
240
+ fs.mkdirSync(companyDocs, { recursive: true });
241
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
242
+ fs.writeFileSync(journalPath, JSON.stringify({
243
+ version: "1",
244
+ lastSync: new Date().toISOString(),
245
+ files: {
246
+ "docs/handoff.md": {
247
+ hash: "old-hash-from-last-sync",
248
+ size: 20,
249
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
250
+ direction: "down",
251
+ },
252
+ },
253
+ }));
254
+ const result = await sync({
255
+ company: "acme",
256
+ onConflict: "keep",
257
+ vaultConfig: mockConfig,
258
+ hqRoot: tmpDir,
259
+ });
260
+ expect(result.conflicts).toBe(1);
261
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
262
+ // remoteEtag still recorded so the conflict can't re-fire (#137 invariant)…
263
+ expect(journal.files["docs/handoff.md"].remoteEtag).toBeTruthy();
264
+ // …but the kept-local copy is flagged divergent so a later currency-gated
265
+ // delete cannot fire on a journal that lies about local==remote.
266
+ expect(journal.files["docs/handoff.md"].localDiverges).toBe(true);
267
+ });
268
+ it("a genuine download clears a stale localDiverges flag (entry is now truly local==remote)", async () => {
269
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
270
+ fs.mkdirSync(companyDocs, { recursive: true });
271
+ fs.writeFileSync(journalPath, JSON.stringify({
272
+ version: "2",
273
+ lastSync: new Date().toISOString(),
274
+ files: {
275
+ "docs/handoff.md": {
276
+ hash: "old-divergent-hash",
277
+ size: 20,
278
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
279
+ direction: "down",
280
+ remoteEtag: "old-etag",
281
+ localDiverges: true,
282
+ },
283
+ },
284
+ pulls: [],
285
+ }));
286
+ // Remote advanced to a new etag; local absent → genuine download lands the
287
+ // remote bytes, so local==remote now and the divergence flag must clear.
288
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
289
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"new-etag"' },
290
+ ]);
291
+ await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
292
+ expect(fs.existsSync(path.join(companyDocs, "handoff.md"))).toBe(true);
293
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
294
+ expect(journal.files["docs/handoff.md"]?.localDiverges).toBeFalsy();
295
+ });
238
296
  it("emits a conflict event with path + resolution on hash mismatch", async () => {
239
297
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
240
298
  fs.mkdirSync(companyDocs, { recursive: true });
@@ -370,6 +428,48 @@ describe("sync", () => {
370
428
  .filter((f) => f.includes(".conflict-"));
371
429
  expect(litter).toHaveLength(1);
372
430
  });
431
+ it("cloud-authoritative file is PULL-WINS on conflict: overwrites local, no mirror, no false-stamp", async () => {
432
+ // company-brief.md is server-regenerated (gardener), so a divergence is not
433
+ // a real conflict — the cloud copy wins. Even under --on-conflict keep, the
434
+ // conflict path must short-circuit to a plain overwrite: NO `.conflict-`
435
+ // mirror (the loop) and NO journal stamp of the remote etag over divergent
436
+ // local content (Finding B). Contrast the docs/handoff.md test above, which
437
+ // (correctly) keeps local + mirrors for a client-owned file.
438
+ const companyRoot = path.join(tmpDir, "companies", "acme");
439
+ fs.mkdirSync(companyRoot, { recursive: true });
440
+ fs.writeFileSync(path.join(companyRoot, "company-brief.md"), "local richer brief");
441
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
442
+ { key: "company-brief.md", size: 17, lastModified: new Date(), etag: '"newbrief"' },
443
+ ]);
444
+ // Default downloadFile mock writes "mock file content" — the cloud copy.
445
+ fs.writeFileSync(journalPath, JSON.stringify({
446
+ version: "1",
447
+ lastSync: new Date().toISOString(),
448
+ files: {
449
+ "company-brief.md": {
450
+ hash: "stale-hash",
451
+ size: 18,
452
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
453
+ direction: "down",
454
+ },
455
+ },
456
+ }));
457
+ const result = await sync({
458
+ company: "acme",
459
+ onConflict: "keep",
460
+ vaultConfig: mockConfig,
461
+ hqRoot: tmpDir,
462
+ });
463
+ // Cloud wins: not counted as a conflict; local overwritten with the cloud copy.
464
+ expect(result.conflicts).toBe(0);
465
+ expect(result.conflictPaths).toEqual([]);
466
+ expect(fs.readFileSync(path.join(companyRoot, "company-brief.md"), "utf-8")).toBe("mock file content");
467
+ // No conflict mirror littered.
468
+ const litter = fs
469
+ .readdirSync(companyRoot)
470
+ .filter((f) => f.includes(".conflict-"));
471
+ expect(litter).toHaveLength(0);
472
+ });
373
473
  it("pre-primes new-file GET presigns before per-file created-by HEADs (avoids the presign-burst breaker trip)", async () => {
374
474
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
375
475
  { key: "alpha.md", size: 10, lastModified: new Date(), etag: '"a1"' },
@@ -693,6 +793,152 @@ describe("sync", () => {
693
793
  expect(journal.files[untrackedKey]).toBeUndefined();
694
794
  expect(journal.files[trackedKey]).toBeUndefined();
695
795
  });
796
+ it("local journal tombstone suppresses drift-restore: a deleted-but-still-remote key is NOT re-downloaded (no respawn)", async () => {
797
+ // Repro of the intermittent delete-respawn: the user deleted a clean file,
798
+ // its delete has not (yet) cleared the S3 object, and the SERVER
799
+ // FILE_TOMBSTONE fetch is degraded (empty map — the logged failure mode).
800
+ // The only intent record is the LOCAL journal tombstone (`removedAt`).
801
+ // The drift-restore path must honor it and NOT re-download the key.
802
+ const key = "docs/handoff.md";
803
+ const localPath = path.join(tmpDir, "companies", "acme", key);
804
+ // Remote still lists the object, etag matching the journal, lastModified
805
+ // BEFORE the local delete — a stale resurrection of a deleted key, not a
806
+ // genuine remote re-create.
807
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
808
+ {
809
+ key,
810
+ size: 42,
811
+ lastModified: new Date(Date.now() - 2 * 86_400_000),
812
+ etag: '"abc123"',
813
+ },
814
+ ]);
815
+ // Degraded-fetch case: server FILE_TOMBSTONE map is EMPTY.
816
+ setupFetchMock({ tombstones: [] });
817
+ // Local file ABSENT (user deleted it). Journal entry is a TOMBSTONE,
818
+ // recent enough to survive TTL GC.
819
+ fs.writeFileSync(journalPath, JSON.stringify({
820
+ version: "2",
821
+ lastSync: "2026-06-19T00:00:00.000Z",
822
+ files: {
823
+ [key]: {
824
+ hash: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
825
+ size: 42,
826
+ syncedAt: "2026-06-19T00:00:00.000Z",
827
+ direction: "down",
828
+ remoteEtag: "abc123",
829
+ removedAt: new Date().toISOString(),
830
+ removedReason: "local-delete",
831
+ },
832
+ },
833
+ pulls: [],
834
+ }));
835
+ await sync({
836
+ company: "acme",
837
+ vaultConfig: mockConfig,
838
+ hqRoot: tmpDir,
839
+ onEvent: () => { },
840
+ });
841
+ // The intentionally-deleted key must NOT be re-materialised.
842
+ expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything());
843
+ expect(fs.existsSync(localPath)).toBe(false);
844
+ });
845
+ it("pull producer: a single clean/current/non-divergent missing file is tombstoned (intentional delete), not drift-restored", async () => {
846
+ // The user deleted a genuinely-synced file. Its entry is clean (etag matches
847
+ // the current remote — currency holds), not divergent. The pull leg must
848
+ // treat the absence as an intentional delete: stamp a persisting
849
+ // `local-delete` tombstone and SKIP the re-download — not resurrect it.
850
+ const key = "docs/gone.md";
851
+ const localPath = path.join(tmpDir, "companies", "acme", key);
852
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
853
+ { key, size: 42, lastModified: new Date(), etag: '"cur123"' },
854
+ ]);
855
+ setupFetchMock({ tombstones: [] });
856
+ fs.writeFileSync(journalPath, JSON.stringify({
857
+ version: "2",
858
+ lastSync: new Date().toISOString(),
859
+ files: {
860
+ [key]: {
861
+ hash: "h",
862
+ size: 42,
863
+ syncedAt: new Date().toISOString(),
864
+ direction: "down",
865
+ remoteEtag: "cur123",
866
+ },
867
+ },
868
+ pulls: [],
869
+ }));
870
+ await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
871
+ expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything());
872
+ expect(fs.existsSync(localPath)).toBe(false);
873
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
874
+ expect(journal.files[key]?.removedAt).toBeTruthy();
875
+ expect(journal.files[key]?.removedReason).toBe("local-delete");
876
+ });
877
+ it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
878
+ // 12 clean files vanish at once (corrupt mirror / bulk rm / checkout). The
879
+ // bulk-asymmetry guard refuses to treat this as intent — every file is
880
+ // healed (re-downloaded), none tombstoned.
881
+ const keys = Array.from({ length: 12 }, (_, i) => `docs/f${i}.md`);
882
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(keys.map((k) => ({ key: k, size: 10, lastModified: new Date(), etag: `"e-${k}"` })));
883
+ setupFetchMock({ tombstones: [] });
884
+ const files = {};
885
+ for (const k of keys) {
886
+ files[k] = {
887
+ hash: "h",
888
+ size: 10,
889
+ syncedAt: new Date().toISOString(),
890
+ direction: "down",
891
+ remoteEtag: `e-${k}`,
892
+ };
893
+ }
894
+ fs.writeFileSync(journalPath, JSON.stringify({
895
+ version: "2",
896
+ lastSync: new Date().toISOString(),
897
+ files,
898
+ pulls: [],
899
+ }));
900
+ await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
901
+ // All restored, none tombstoned.
902
+ expect(s3Module.downloadFile).toHaveBeenCalledTimes(12);
903
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
904
+ for (const k of keys) {
905
+ expect(journal.files[k]?.removedAt).toBeUndefined();
906
+ }
907
+ });
908
+ it("pull producer: a local-delete tombstone persists across pulls (not consumed/removed), so the delete stays propagatable", async () => {
909
+ // After the intentional-delete tombstone is stamped, a later pull must NOT
910
+ // drop the entry (the push leg still needs it to propagate the S3 delete).
911
+ // It stays skipped + preserved until the push propagates or TTL GC.
912
+ const key = "docs/gone.md";
913
+ const localPath = path.join(tmpDir, "companies", "acme", key);
914
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
915
+ { key, size: 42, lastModified: new Date(Date.now() - 2 * 86_400_000), etag: '"cur123"' },
916
+ ]);
917
+ setupFetchMock({ tombstones: [] });
918
+ fs.writeFileSync(journalPath, JSON.stringify({
919
+ version: "2",
920
+ lastSync: new Date().toISOString(),
921
+ files: {
922
+ [key]: {
923
+ hash: "h",
924
+ size: 42,
925
+ syncedAt: new Date().toISOString(),
926
+ direction: "down",
927
+ remoteEtag: "cur123",
928
+ removedAt: new Date().toISOString(),
929
+ removedReason: "local-delete",
930
+ },
931
+ },
932
+ pulls: [],
933
+ }));
934
+ await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
935
+ expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything());
936
+ expect(fs.existsSync(localPath)).toBe(false);
937
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
938
+ // Entry PRESERVED as a tombstone, not removed.
939
+ expect(journal.files[key]?.removedAt).toBeTruthy();
940
+ expect(journal.files[key]?.removedReason).toBe("local-delete");
941
+ });
696
942
  it("aborts on --on-conflict abort", async () => {
697
943
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
698
944
  fs.mkdirSync(companyDocs, { recursive: true });