@indigoai-us/hq-cloud 6.14.9 → 6.14.10

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.
@@ -21,10 +21,12 @@ vi.mock("../s3.js", async (importOriginal) => {
21
21
  ...actual,
22
22
  toPosixKey: (key) => key.split("\\").join("/"),
23
23
  uploadFile: innerVi.fn().mockResolvedValue(undefined),
24
- downloadFile: innerVi.fn().mockImplementation(async (_ctx, _key, localPath) => {
24
+ uploadSymlink: innerVi.fn().mockResolvedValue(undefined),
25
+ downloadFile: innerVi.fn().mockImplementation(async (_ctx, _key, localPath, options) => {
25
26
  const dir = innerPath.dirname(localPath);
26
27
  if (!innerFs.existsSync(dir))
27
28
  innerFs.mkdirSync(dir, { recursive: true });
29
+ options?.beforeReplace?.();
28
30
  innerFs.writeFileSync(localPath, "mock file content");
29
31
  return { metadata: {} };
30
32
  }),
@@ -32,6 +34,7 @@ vi.mock("../s3.js", async (importOriginal) => {
32
34
  deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
33
35
  headRemoteFile: innerVi.fn().mockResolvedValue(null),
34
36
  primeObjectTransport: innerVi.fn().mockResolvedValue(undefined),
37
+ primeUploads: innerVi.fn().mockResolvedValue(undefined),
35
38
  };
36
39
  });
37
40
  // Mock the post-sync refresh so tests neither spawn the real reindex.sh nor
@@ -40,6 +43,7 @@ vi.mock("./reindex.js", () => ({
40
43
  reindex: vi.fn(() => ({ status: 0 })),
41
44
  }));
42
45
  import { sync, reportNewFilesToNotify } from "./sync.js";
46
+ import { share } from "./share.js";
43
47
  import * as s3Module from "../s3.js";
44
48
  import { reindex } from "./reindex.js";
45
49
  import { readSyncProgress } from "../sync-progress.js";
@@ -509,6 +513,8 @@ describe("sync", () => {
509
513
  const localContent = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
510
514
  fs.writeFileSync(localPath, localContent);
511
515
  const st = fs.statSync(localPath);
516
+ const crypto = await import("node:crypto");
517
+ const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
512
518
  // Remote object is SMALLER (an older, truncated copy) with a CHANGED etag.
513
519
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
514
520
  { key: "docs/handoff.md", size: 12, lastModified: new Date(), etag: '"regressed-etag"' },
@@ -525,7 +531,7 @@ describe("sync", () => {
525
531
  lastSync: new Date().toISOString(),
526
532
  files: {
527
533
  "docs/handoff.md": {
528
- hash: "clean-local-baseline-hash",
534
+ hash: localHash,
529
535
  size: st.size,
530
536
  mtimeMs: st.mtimeMs,
531
537
  remoteEtag: "old-etag",
@@ -564,6 +570,8 @@ describe("sync", () => {
564
570
  const localContent = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
565
571
  fs.writeFileSync(localPath, localContent);
566
572
  const st = fs.statSync(localPath);
573
+ const crypto = await import("node:crypto");
574
+ const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
567
575
  // Remote is SMALLER with a changed etag — a legitimate shorter rewrite.
568
576
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
569
577
  { key: "docs/handoff.md", size: 12, lastModified: new Date(), etag: '"shorter-etag"' },
@@ -573,7 +581,7 @@ describe("sync", () => {
573
581
  lastSync: new Date().toISOString(),
574
582
  files: {
575
583
  "docs/handoff.md": {
576
- hash: "clean-local-baseline-hash",
584
+ hash: localHash,
577
585
  size: st.size,
578
586
  mtimeMs: st.mtimeMs,
579
587
  remoteEtag: "old-etag",
@@ -607,6 +615,8 @@ describe("sync", () => {
607
615
  const localContent = "short local\n";
608
616
  fs.writeFileSync(localPath, localContent);
609
617
  const st = fs.statSync(localPath);
618
+ const crypto = await import("node:crypto");
619
+ const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
610
620
  // Remote is LARGER with a changed etag → legitimate advance.
611
621
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
612
622
  { key: "docs/handoff.md", size: 9999, lastModified: new Date(), etag: '"advanced-etag"' },
@@ -616,7 +626,7 @@ describe("sync", () => {
616
626
  lastSync: new Date().toISOString(),
617
627
  files: {
618
628
  "docs/handoff.md": {
619
- hash: "clean-local-baseline-hash",
629
+ hash: localHash,
620
630
  size: st.size,
621
631
  mtimeMs: st.mtimeMs,
622
632
  remoteEtag: "old-etag",
@@ -798,12 +808,9 @@ describe("sync", () => {
798
808
  expect(journal.files[untrackedKey]).toBeUndefined();
799
809
  expect(journal.files[trackedKey]).toBeUndefined();
800
810
  });
801
- it("local journal tombstone suppresses drift-restore: a deleted-but-still-remote key is NOT re-downloaded (no respawn)", async () => {
802
- // Repro of the intermittent delete-respawn: the user deleted a clean file,
803
- // its delete has not (yet) cleared the S3 object, and the SERVER
804
- // FILE_TOMBSTONE fetch is degraded (empty map — the logged failure mode).
805
- // The only intent record is the LOCAL journal tombstone (`removedAt`).
806
- // The drift-restore path must honor it and NOT re-download the key.
811
+ it("fails closed for a legacy local-delete tombstone and restores the remote key", async () => {
812
+ // A legacy `removedReason:local-delete` was inferred from an ENOENT. It
813
+ // has no version-bound explicit intent, so it must never suppress a pull.
807
814
  const key = "docs/handoff.md";
808
815
  const localPath = path.join(tmpDir, "companies", "acme", key);
809
816
  // Remote still lists the object, etag matching the journal, lastModified
@@ -843,15 +850,10 @@ describe("sync", () => {
843
850
  hqRoot: tmpDir,
844
851
  onEvent: () => { },
845
852
  });
846
- // The intentionally-deleted key must NOT be re-materialised.
847
- expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything());
848
- expect(fs.existsSync(localPath)).toBe(false);
853
+ expect(s3Module.downloadFile).toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
854
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
849
855
  });
850
- it("pull producer: a single clean/current/non-divergent missing file is tombstoned (intentional delete), not drift-restored", async () => {
851
- // The user deleted a genuinely-synced file. Its entry is clean (etag matches
852
- // the current remote — currency holds), not divergent. The pull leg must
853
- // treat the absence as an intentional delete: stamp a persisting
854
- // `local-delete` tombstone and SKIP the re-download — not resurrect it.
856
+ it("does not infer local-delete intent from a single missing file", async () => {
855
857
  const key = "docs/gone.md";
856
858
  const localPath = path.join(tmpDir, "companies", "acme", key);
857
859
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
@@ -873,11 +875,10 @@ describe("sync", () => {
873
875
  pulls: [],
874
876
  }));
875
877
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
876
- expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything());
877
- expect(fs.existsSync(localPath)).toBe(false);
878
+ expect(s3Module.downloadFile).toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
879
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
878
880
  const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
879
- expect(journal.files[key]?.removedAt).toBeTruthy();
880
- expect(journal.files[key]?.removedReason).toBe("local-delete");
881
+ expect(journal.files[key]?.removedAt).toBeUndefined();
881
882
  });
882
883
  it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
883
884
  // 12 clean files vanish at once (corrupt mirror / bulk rm / checkout). The
@@ -893,7 +894,14 @@ describe("sync", () => {
893
894
  size: 10,
894
895
  syncedAt: new Date().toISOString(),
895
896
  direction: "down",
897
+ kind: "file",
896
898
  remoteEtag: `e-${k}`,
899
+ localDeleteIntent: {
900
+ version: 1,
901
+ remoteEtag: `e-${k}`,
902
+ localHash: "h",
903
+ localKind: "file",
904
+ },
897
905
  };
898
906
  }
899
907
  fs.writeFileSync(journalPath, JSON.stringify({
@@ -929,9 +937,16 @@ describe("sync", () => {
929
937
  size: 42,
930
938
  syncedAt: new Date().toISOString(),
931
939
  direction: "down",
940
+ kind: "file",
932
941
  remoteEtag: "cur123",
933
942
  removedAt: new Date().toISOString(),
934
943
  removedReason: "local-delete",
944
+ localDeleteIntent: {
945
+ version: 1,
946
+ remoteEtag: "cur123",
947
+ localHash: "h",
948
+ localKind: "file",
949
+ },
935
950
  },
936
951
  },
937
952
  pulls: [],
@@ -1465,6 +1480,207 @@ describe("sync", () => {
1465
1480
  expect(journal.files["docs/racy-delete.md"]).toBeDefined();
1466
1481
  expect(journal.files["docs/racy-delete.md"].hash).toBe(baselineHash);
1467
1482
  });
1483
+ it("CAS: preserves a file created after a remote-delete plan", async () => {
1484
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1485
+ const localPath = path.join(companyRoot, "docs", "created-after-plan.md");
1486
+ const baseline = "synced baseline";
1487
+ const crypto = await import("node:crypto");
1488
+ const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
1489
+ fs.writeFileSync(journalPath, JSON.stringify({
1490
+ version: "2",
1491
+ lastSync: new Date().toISOString(),
1492
+ files: {
1493
+ "docs/created-after-plan.md": {
1494
+ hash: baselineHash,
1495
+ size: baseline.length,
1496
+ syncedAt: new Date().toISOString(),
1497
+ direction: "down",
1498
+ kind: "file",
1499
+ remoteEtag: "before-delete",
1500
+ },
1501
+ },
1502
+ pulls: [],
1503
+ }));
1504
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
1505
+ vi.mocked(s3Module.headRemoteFile).mockImplementationOnce(async () => {
1506
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
1507
+ // Same bytes as the journal baseline: hash-only revalidation would unlink
1508
+ // this newly-created file, but the planned snapshot was "absent".
1509
+ fs.writeFileSync(localPath, baseline);
1510
+ return null;
1511
+ });
1512
+ const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1513
+ expect(result.filesTombstoned).toBe(0);
1514
+ expect(result.conflicts).toBe(1);
1515
+ expect(fs.readFileSync(localPath, "utf-8")).toBe(baseline);
1516
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1517
+ expect(journal.files["docs/created-after-plan.md"]).toBeDefined();
1518
+ });
1519
+ it("CAS: preserves a concurrent local edit when a staged download is ready to replace it", async () => {
1520
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1521
+ const localPath = path.join(companyRoot, "docs", "staged-race.md");
1522
+ const baseline = "synced baseline";
1523
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
1524
+ fs.writeFileSync(localPath, baseline);
1525
+ const crypto = await import("node:crypto");
1526
+ const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
1527
+ fs.writeFileSync(journalPath, JSON.stringify({
1528
+ version: "2",
1529
+ lastSync: new Date().toISOString(),
1530
+ files: {
1531
+ "docs/staged-race.md": {
1532
+ hash: baselineHash,
1533
+ size: baseline.length,
1534
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
1535
+ direction: "down",
1536
+ kind: "file",
1537
+ remoteEtag: "old-etag",
1538
+ },
1539
+ },
1540
+ pulls: [],
1541
+ }));
1542
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1543
+ { key: "docs/staged-race.md", size: baseline.length + 10, lastModified: new Date(), etag: '"new-etag"' },
1544
+ ]);
1545
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(async (_ctx, _key, destination, options) => {
1546
+ fs.writeFileSync(destination, "concurrent local edit");
1547
+ options?.beforeReplace?.();
1548
+ fs.writeFileSync(destination, "remote replacement");
1549
+ return { metadata: {} };
1550
+ });
1551
+ const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1552
+ expect(result.filesDownloaded).toBe(0);
1553
+ expect(result.conflicts).toBe(1);
1554
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("concurrent local edit");
1555
+ });
1556
+ it("personal vault delete planning excludes release core and generated skill mirrors", async () => {
1557
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal-delete.json");
1558
+ const intent = (remoteEtag) => ({
1559
+ version: 1,
1560
+ remoteEtag,
1561
+ localHash: "synced-hash",
1562
+ localKind: "file",
1563
+ });
1564
+ fs.writeFileSync(personalJournalPath, JSON.stringify({
1565
+ version: "2",
1566
+ lastSync: new Date().toISOString(),
1567
+ files: {
1568
+ "core/core.yaml": {
1569
+ hash: "synced-hash",
1570
+ size: 1,
1571
+ syncedAt: new Date().toISOString(),
1572
+ direction: "up",
1573
+ kind: "file",
1574
+ remoteEtag: "core-etag",
1575
+ localDeleteIntent: intent("core-etag"),
1576
+ },
1577
+ ".claude/skills/core:demo/SKILL.md": {
1578
+ hash: "synced-hash",
1579
+ size: 1,
1580
+ syncedAt: new Date().toISOString(),
1581
+ direction: "up",
1582
+ kind: "file",
1583
+ remoteEtag: "mirror-etag",
1584
+ localDeleteIntent: intent("mirror-etag"),
1585
+ },
1586
+ },
1587
+ pulls: [],
1588
+ }));
1589
+ await share({
1590
+ company: "acme",
1591
+ vaultConfig: mockConfig,
1592
+ hqRoot: tmpDir,
1593
+ paths: [tmpDir],
1594
+ personalMode: true,
1595
+ journalSlug: "personal-delete",
1596
+ propagateDeletes: true,
1597
+ propagateDeletePolicy: "all",
1598
+ });
1599
+ expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
1600
+ const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
1601
+ expect(journal.files["core/core.yaml"]).toBeDefined();
1602
+ expect(journal.files[".claude/skills/core:demo/SKILL.md"]).toBeDefined();
1603
+ });
1604
+ it("does not issue DeleteObject for two ordinary missing journal files without explicit intent", async () => {
1605
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1606
+ fs.mkdirSync(companyRoot, { recursive: true });
1607
+ const entry = (remoteEtag) => ({
1608
+ hash: "synced-hash",
1609
+ size: 1,
1610
+ syncedAt: new Date().toISOString(),
1611
+ direction: "up",
1612
+ kind: "file",
1613
+ remoteEtag,
1614
+ });
1615
+ fs.writeFileSync(journalPath, JSON.stringify({
1616
+ version: "2",
1617
+ lastSync: new Date().toISOString(),
1618
+ files: {
1619
+ "docs/a.md": entry("a-etag"),
1620
+ "docs/b.md": entry("b-etag"),
1621
+ },
1622
+ pulls: [],
1623
+ }));
1624
+ const result = await share({
1625
+ company: "acme",
1626
+ vaultConfig: mockConfig,
1627
+ hqRoot: tmpDir,
1628
+ paths: [companyRoot],
1629
+ propagateDeletes: true,
1630
+ propagateDeletePolicy: "all",
1631
+ });
1632
+ expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
1633
+ expect(result.filesRefusedStale).toBe(2);
1634
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1635
+ expect(journal.files["docs/a.md"]).toBeDefined();
1636
+ expect(journal.files["docs/b.md"]).toBeDefined();
1637
+ });
1638
+ it("uses a conditional PUT conflict for concurrent board.json appends", async () => {
1639
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1640
+ const boardPath = path.join(companyRoot, "board.json");
1641
+ const baseline = '{"items":[]}';
1642
+ const localAppend = '{"items":["local append"]}';
1643
+ fs.mkdirSync(companyRoot, { recursive: true });
1644
+ fs.writeFileSync(boardPath, localAppend);
1645
+ const crypto = await import("node:crypto");
1646
+ const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
1647
+ fs.writeFileSync(journalPath, JSON.stringify({
1648
+ version: "2",
1649
+ lastSync: new Date().toISOString(),
1650
+ files: {
1651
+ "board.json": {
1652
+ hash: baselineHash,
1653
+ size: baseline.length,
1654
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
1655
+ direction: "up",
1656
+ kind: "file",
1657
+ remoteEtag: "old-etag",
1658
+ },
1659
+ },
1660
+ pulls: [],
1661
+ }));
1662
+ vi.mocked(s3Module.headRemoteFile).mockResolvedValueOnce({
1663
+ etag: "old-etag",
1664
+ size: baseline.length,
1665
+ lastModified: new Date(),
1666
+ });
1667
+ const preconditionError = Object.assign(new Error("remote changed"), {
1668
+ name: "PreconditionFailed",
1669
+ });
1670
+ vi.mocked(s3Module.uploadFile).mockRejectedValueOnce(preconditionError);
1671
+ const { isCloudAuthoritative } = await import("../lib/cloud-authoritative.js");
1672
+ expect(isCloudAuthoritative("companies/acme/board.json")).toBe(true);
1673
+ const result = await share({
1674
+ company: "acme",
1675
+ vaultConfig: mockConfig,
1676
+ hqRoot: tmpDir,
1677
+ paths: [companyRoot],
1678
+ onConflict: "keep",
1679
+ });
1680
+ expect(result.conflictPaths).toContain("board.json");
1681
+ expect(fs.readFileSync(boardPath, "utf-8")).toBe(localAppend);
1682
+ expect(s3Module.uploadFile).toHaveBeenCalledWith(expect.anything(), boardPath, "board.json", undefined, { ifMatch: "old-etag" });
1683
+ });
1468
1684
  it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
1469
1685
  // Codex review on PR #24 round 4 caught: the round-3 local-edit
1470
1686
  // divergence guard only covered regular files (`isFile()` is false
@@ -2179,7 +2395,7 @@ describe("sync", () => {
2179
2395
  throw new Error("missing default download mock");
2180
2396
  try {
2181
2397
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
2182
- vi.mocked(s3Module.downloadFile).mockImplementation(async (ctx, key, localPath) => {
2398
+ vi.mocked(s3Module.downloadFile).mockImplementation(async (ctx, key, localPath, options) => {
2183
2399
  if (key === "bulk/file-8.md") {
2184
2400
  // This models the next transfer crashing the process. The first
2185
2401
  // eight entries must already be durable before the final writer.
@@ -2187,7 +2403,7 @@ describe("sync", () => {
2187
2403
  expect(Object.keys(checkpoint.files)).toHaveLength(8);
2188
2404
  throw new Error("simulated crash after checkpoint");
2189
2405
  }
2190
- return defaultDownload(ctx, key, localPath);
2406
+ return defaultDownload(ctx, key, localPath, options);
2191
2407
  });
2192
2408
  const first = await sync({
2193
2409
  company: "acme",
@@ -2210,7 +2426,7 @@ describe("sync", () => {
2210
2426
  onEvent: () => undefined,
2211
2427
  });
2212
2428
  expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
2213
- expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledWith(expect.anything(), "bulk/file-8.md", expect.any(String));
2429
+ expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledWith(expect.anything(), "bulk/file-8.md", expect.any(String), expect.objectContaining({ beforeReplace: expect.any(Function) }));
2214
2430
  }
2215
2431
  finally {
2216
2432
  vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);