@indigoai-us/hq-cloud 6.14.41 → 6.14.43

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.
@@ -50,6 +50,7 @@ import type { SyncProgressEvent } from "./sync.js";
50
50
  import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
51
51
  import type { EntityContext } from "../types.js";
52
52
  import { VaultAuthError } from "../vault-client.js";
53
+ import { hashFile } from "../journal.js";
53
54
 
54
55
  const mockConfig: VaultServiceConfig = {
55
56
  apiUrl: "https://vault-api.test",
@@ -1276,12 +1277,14 @@ describe("share", () => {
1276
1277
  });
1277
1278
 
1278
1279
  expect(result.filesUploaded).toBe(1);
1280
+ // Prefer journal-baseline If-Match when present (stale-writer guard);
1281
+ // quote form is optional (object-io quoteEtag accepts both).
1279
1282
  expect(uploadFile).toHaveBeenCalledWith(
1280
1283
  expect.anything(),
1281
1284
  testFile,
1282
1285
  "fenced.md",
1283
1286
  undefined,
1284
- { ifMatch: '"baseline-etag"' },
1287
+ { ifMatch: "baseline-etag" },
1285
1288
  );
1286
1289
  });
1287
1290
 
@@ -1401,6 +1404,448 @@ describe("share", () => {
1401
1404
  expect(result.aborted).toBe(true);
1402
1405
  expect(result.conflictPaths).toEqual(["halting.md"]);
1403
1406
  });
1407
+
1408
+ // ── Stale-writer guard (Ace / vault multi-writer LWW, 2026-08-03) ──────
1409
+ //
1410
+ // Incident: Ace held Jul-28 worker bytes (journal still at that baseline)
1411
+ // while a peer had already advanced the vault object. Push conflict
1412
+ // requires localChanged && remoteChanged, so an unchanged-but-stale local
1413
+ // (skipUnchanged=false full re-push) fell through to PUT with
1414
+ // ifMatch: *live HEAD etag* — which succeeds and last-write-wins the old
1415
+ // body over the peer. Fix: fence with journal remoteEtag so peer advance
1416
+ // yields PreconditionFailed → existing keep/abort/overwrite path.
1417
+
1418
+ it("Ace-shaped stale local under keep: journal-baseline If-Match fails closed (no silent LWW overwrite)", async () => {
1419
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1420
+ fs.mkdirSync(path.join(companyRoot, "workers", "fleet-agent-monitoring", "scripts"), {
1421
+ recursive: true,
1422
+ });
1423
+ const rel =
1424
+ "workers/fleet-agent-monitoring/scripts/hourly-enrich.py";
1425
+ const testFile = path.join(companyRoot, rel);
1426
+ // Stale local body (Ace-shaped: old smaller worker artifact).
1427
+ const staleBytes = "# old Jul-28 enricher\n".repeat(50);
1428
+ fs.writeFileSync(testFile, staleBytes);
1429
+ const staleHash = hashFile(testFile);
1430
+
1431
+ const JOURNAL_ETAG = "etag-jul28-old";
1432
+ const PEER_ETAG = "etag-reviewed-new";
1433
+
1434
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
1435
+ lastModified: new Date(),
1436
+ etag: `"${PEER_ETAG}"`,
1437
+ size: 34768,
1438
+ });
1439
+
1440
+ // S3-shaped fence: only land the PUT when If-Match equals live HEAD.
1441
+ vi.mocked(uploadFile).mockImplementation(async (_ctx, _p, _k, _a, pc) => {
1442
+ if (pc?.ifMatch) {
1443
+ const want = String(pc.ifMatch).replace(/^"|"$/g, "");
1444
+ if (want !== PEER_ETAG) {
1445
+ throw fence412();
1446
+ }
1447
+ }
1448
+ return { etag: '"should-not-land"' };
1449
+ });
1450
+
1451
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1452
+ fs.writeFileSync(
1453
+ journalPath,
1454
+ JSON.stringify({
1455
+ version: "1",
1456
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
1457
+ files: {
1458
+ [rel]: {
1459
+ // Local matches journal → localChanged=false; remote advanced →
1460
+ // remoteChanged=true. Pre-fix 3-way did NOT conflict, and
1461
+ // ifMatch used live HEAD so the stale PUT succeeded.
1462
+ hash: staleHash,
1463
+ size: staleBytes.length,
1464
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
1465
+ direction: "down",
1466
+ remoteEtag: JOURNAL_ETAG,
1467
+ },
1468
+ },
1469
+ }),
1470
+ );
1471
+
1472
+ const events: Array<{ type?: string; path?: string; resolution?: string }> = [];
1473
+ const result = await share({
1474
+ paths: [testFile],
1475
+ company: "acme",
1476
+ vaultConfig: mockConfig,
1477
+ hqRoot: tmpDir,
1478
+ // Explicit full re-push path (models skipUnchanged !== true).
1479
+ skipUnchanged: false,
1480
+ onConflict: "keep",
1481
+ onEvent: (e) => events.push(e as { type?: string }),
1482
+ });
1483
+
1484
+ // Must not land the stale body under keep.
1485
+ expect(result.filesUploaded).toBe(0);
1486
+ expect(result.conflictPaths).toEqual([rel]);
1487
+ expect(
1488
+ events.some(
1489
+ (e) => e.type === "conflict" && e.path === rel && e.resolution === "keep",
1490
+ ),
1491
+ ).toBe(true);
1492
+
1493
+ // Any PUT attempt must fence on the journal baseline, not live HEAD.
1494
+ // (If the planner conflicts pre-PUT that is also fine — no unconditional
1495
+ // overwrite of the peer-advanced object.)
1496
+ for (const call of vi.mocked(uploadFile).mock.calls) {
1497
+ const pc = call[4] as { ifMatch?: string; ifNoneMatch?: string } | undefined;
1498
+ expect(pc).toBeDefined();
1499
+ expect(pc!.ifNoneMatch).toBeUndefined();
1500
+ const want = String(pc!.ifMatch).replace(/^"|"$/g, "");
1501
+ expect(want).toBe(JOURNAL_ETAG);
1502
+ }
1503
+ // No unfenced retry under keep.
1504
+ expect(
1505
+ vi.mocked(uploadFile).mock.calls.some((c) => c[4] === undefined),
1506
+ ).toBe(false);
1507
+ // Local stale bytes preserved on disk (keep does not pull).
1508
+ expect(fs.readFileSync(testFile, "utf-8")).toBe(staleBytes);
1509
+ });
1510
+
1511
+ it("legitimate local-ahead still uploads when remote remains at journal baseline", async () => {
1512
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1513
+ fs.mkdirSync(companyRoot, { recursive: true });
1514
+ const testFile = path.join(companyRoot, "workers", "legit-edit.md");
1515
+ fs.mkdirSync(path.dirname(testFile), { recursive: true });
1516
+ fs.writeFileSync(testFile, "my ahead edit");
1517
+ const BASE = "still-current-etag";
1518
+
1519
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
1520
+ lastModified: new Date(Date.now() - 120_000),
1521
+ etag: `"${BASE}"`,
1522
+ size: 4,
1523
+ });
1524
+ vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"new-upload-etag"' });
1525
+
1526
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1527
+ fs.writeFileSync(
1528
+ journalPath,
1529
+ JSON.stringify({
1530
+ version: "1",
1531
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
1532
+ files: {
1533
+ "workers/legit-edit.md": {
1534
+ hash: "stale-journal-hash",
1535
+ size: 4,
1536
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
1537
+ direction: "up",
1538
+ remoteEtag: BASE,
1539
+ },
1540
+ },
1541
+ }),
1542
+ );
1543
+
1544
+ const result = await share({
1545
+ paths: [testFile],
1546
+ company: "acme",
1547
+ vaultConfig: mockConfig,
1548
+ hqRoot: tmpDir,
1549
+ onConflict: "keep",
1550
+ });
1551
+
1552
+ expect(result.filesUploaded).toBe(1);
1553
+ expect(result.conflictPaths).toEqual([]);
1554
+ expect(uploadFile).toHaveBeenCalledWith(
1555
+ expect.anything(),
1556
+ testFile,
1557
+ "workers/legit-edit.md",
1558
+ undefined,
1559
+ { ifMatch: BASE },
1560
+ );
1561
+ });
1562
+
1563
+ it("true concurrent edit (both sides changed) still conflicts under keep", async () => {
1564
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1565
+ fs.mkdirSync(companyRoot, { recursive: true });
1566
+ const testFile = path.join(companyRoot, "both-changed.md");
1567
+ fs.writeFileSync(testFile, "local concurrent");
1568
+
1569
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
1570
+ lastModified: new Date(),
1571
+ etag: '"remote-concurrent"',
1572
+ size: 20,
1573
+ });
1574
+
1575
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1576
+ fs.writeFileSync(
1577
+ journalPath,
1578
+ JSON.stringify({
1579
+ version: "1",
1580
+ lastSync: new Date().toISOString(),
1581
+ files: {
1582
+ "both-changed.md": {
1583
+ hash: "stale-hash",
1584
+ size: 10,
1585
+ syncedAt: new Date().toISOString(),
1586
+ direction: "up",
1587
+ remoteEtag: "baseline-before-both",
1588
+ },
1589
+ },
1590
+ }),
1591
+ );
1592
+
1593
+ const result = await share({
1594
+ paths: [testFile],
1595
+ company: "acme",
1596
+ vaultConfig: mockConfig,
1597
+ hqRoot: tmpDir,
1598
+ onConflict: "keep",
1599
+ });
1600
+
1601
+ expect(result.conflictPaths).toEqual(["both-changed.md"]);
1602
+ expect(result.filesUploaded).toBe(0);
1603
+ expect(uploadFile).not.toHaveBeenCalled();
1604
+ });
1605
+
1606
+ it("localDiverges under keep: pull-kept divergent local cannot silently become write authority", async () => {
1607
+ // After pull --on-conflict keep, journal stamps remoteEtag of the peer
1608
+ // version AND localDiverges=true (local never matched that etag). A later
1609
+ // push must not treat "remote still at stamped etag" as license to PUT
1610
+ // the divergent local body (journal-baseline If-Match would succeed).
1611
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1612
+ fs.mkdirSync(companyRoot, { recursive: true });
1613
+ const testFile = path.join(companyRoot, "kept-divergent.md");
1614
+ const localBytes = "my kept divergent local";
1615
+ fs.writeFileSync(testFile, localBytes);
1616
+ const localHash = hashFile(testFile);
1617
+ const REMOTE = "peer-version-etag";
1618
+
1619
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
1620
+ lastModified: new Date(),
1621
+ etag: `"${REMOTE}"`,
1622
+ size: 40,
1623
+ });
1624
+
1625
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1626
+ fs.writeFileSync(
1627
+ journalPath,
1628
+ JSON.stringify({
1629
+ version: "1",
1630
+ lastSync: new Date().toISOString(),
1631
+ files: {
1632
+ "kept-divergent.md": {
1633
+ hash: localHash,
1634
+ size: localBytes.length,
1635
+ syncedAt: new Date().toISOString(),
1636
+ direction: "down",
1637
+ remoteEtag: REMOTE,
1638
+ localDiverges: true,
1639
+ },
1640
+ },
1641
+ }),
1642
+ );
1643
+
1644
+ const events: Array<{ type?: string; path?: string; resolution?: string }> = [];
1645
+ const result = await share({
1646
+ paths: [testFile],
1647
+ company: "acme",
1648
+ vaultConfig: mockConfig,
1649
+ hqRoot: tmpDir,
1650
+ skipUnchanged: false,
1651
+ onConflict: "keep",
1652
+ onEvent: (e) => events.push(e as { type?: string }),
1653
+ });
1654
+
1655
+ expect(result.filesUploaded).toBe(0);
1656
+ expect(result.conflictPaths).toEqual(["kept-divergent.md"]);
1657
+ expect(uploadFile).not.toHaveBeenCalled();
1658
+ expect(
1659
+ events.some(
1660
+ (e) =>
1661
+ e.type === "conflict" &&
1662
+ e.path === "kept-divergent.md" &&
1663
+ e.resolution === "keep",
1664
+ ),
1665
+ ).toBe(true);
1666
+ expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
1667
+ });
1668
+
1669
+ it("localDiverges under overwrite: explicit intent may force-write", async () => {
1670
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1671
+ fs.mkdirSync(companyRoot, { recursive: true });
1672
+ const testFile = path.join(companyRoot, "force-divergent.md");
1673
+ fs.writeFileSync(testFile, "force my local");
1674
+ const localHash = hashFile(testFile);
1675
+ const REMOTE = "peer-etag";
1676
+
1677
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
1678
+ lastModified: new Date(),
1679
+ etag: `"${REMOTE}"`,
1680
+ size: 10,
1681
+ });
1682
+ vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"forced-etag"' });
1683
+
1684
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1685
+ fs.writeFileSync(
1686
+ journalPath,
1687
+ JSON.stringify({
1688
+ version: "1",
1689
+ lastSync: new Date().toISOString(),
1690
+ files: {
1691
+ "force-divergent.md": {
1692
+ hash: localHash,
1693
+ size: 14,
1694
+ syncedAt: new Date().toISOString(),
1695
+ direction: "down",
1696
+ remoteEtag: REMOTE,
1697
+ localDiverges: true,
1698
+ },
1699
+ },
1700
+ }),
1701
+ );
1702
+
1703
+ const result = await share({
1704
+ paths: [testFile],
1705
+ company: "acme",
1706
+ vaultConfig: mockConfig,
1707
+ hqRoot: tmpDir,
1708
+ skipUnchanged: false,
1709
+ onConflict: "overwrite",
1710
+ });
1711
+
1712
+ expect(result.filesUploaded).toBe(1);
1713
+ // Overwrite path may PUT unfenced after conflict resolution, or with a
1714
+ // fence then retry — either is consent. Must not leave the file unuploaded.
1715
+ expect(uploadFile).toHaveBeenCalled();
1716
+ });
1717
+
1718
+ // Journal still carries remoteEtag from a prior sync, but the remote key
1719
+ // is gone (HEAD null) — e.g. peer delete or operator purge. Recreation
1720
+ // must use If-None-Match:* (create-only), NOT If-Match on the stale
1721
+ // journal etag (which cannot match a missing object and would false-
1722
+ // conflict forever under keep). Concurrent create still 412s via the
1723
+ // existing fence path.
1724
+ it("deleted journaled remote under keep: recreates once with If-None-Match:* and updates journal", async () => {
1725
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1726
+ fs.mkdirSync(companyRoot, { recursive: true });
1727
+ const testFile = path.join(companyRoot, "recreate-deleted.md");
1728
+ const localBytes = "local edit after remote was deleted";
1729
+ fs.writeFileSync(testFile, localBytes);
1730
+ const localHash = hashFile(testFile);
1731
+ const STALE_JOURNAL_ETAG = "etag-before-delete";
1732
+
1733
+ vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
1734
+ vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"recreated-etag"' });
1735
+
1736
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1737
+ fs.writeFileSync(
1738
+ journalPath,
1739
+ JSON.stringify({
1740
+ version: "1",
1741
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
1742
+ files: {
1743
+ "recreate-deleted.md": {
1744
+ hash: "stale-journal-hash",
1745
+ size: 8,
1746
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
1747
+ direction: "up",
1748
+ remoteEtag: STALE_JOURNAL_ETAG,
1749
+ },
1750
+ },
1751
+ }),
1752
+ );
1753
+
1754
+ const result = await share({
1755
+ paths: [testFile],
1756
+ company: "acme",
1757
+ vaultConfig: mockConfig,
1758
+ hqRoot: tmpDir,
1759
+ skipUnchanged: false,
1760
+ onConflict: "keep",
1761
+ });
1762
+
1763
+ expect(result.filesUploaded).toBe(1);
1764
+ expect(result.conflictPaths).toEqual([]);
1765
+ expect(result.filesSkipped).toBe(0);
1766
+ // Exactly one create-fenced PUT — not journal If-Match, not unfenced.
1767
+ expect(uploadFile).toHaveBeenCalledTimes(1);
1768
+ expect(uploadFile).toHaveBeenCalledWith(
1769
+ expect.anything(),
1770
+ testFile,
1771
+ "recreate-deleted.md",
1772
+ undefined,
1773
+ { ifNoneMatch: "*" },
1774
+ );
1775
+ const pc = vi.mocked(uploadFile).mock.calls[0][4] as
1776
+ | { ifMatch?: string; ifNoneMatch?: string }
1777
+ | undefined;
1778
+ expect(pc?.ifMatch).toBeUndefined();
1779
+ expect(pc?.ifNoneMatch).toBe("*");
1780
+
1781
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1782
+ expect(journal.files["recreate-deleted.md"]).toMatchObject({
1783
+ hash: localHash,
1784
+ remoteEtag: "recreated-etag",
1785
+ direction: "up",
1786
+ });
1787
+ });
1788
+
1789
+ it("deleted journaled remote + concurrent create 412 under keep: existing conflict semantics", async () => {
1790
+ // Same setup as recreation, but a peer creates the key between HEAD and
1791
+ // PUT → If-None-Match:* 412s → keep surfaces conflict, no journal stamp.
1792
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1793
+ fs.mkdirSync(companyRoot, { recursive: true });
1794
+ const testFile = path.join(companyRoot, "race-recreate.md");
1795
+ fs.writeFileSync(testFile, "my recreated local");
1796
+
1797
+ vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
1798
+ vi.mocked(uploadFile).mockRejectedValueOnce(fence412());
1799
+ vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
1800
+ fs.writeFileSync(dest as string, "peer created first");
1801
+ return undefined as never;
1802
+ });
1803
+
1804
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1805
+ fs.writeFileSync(
1806
+ journalPath,
1807
+ JSON.stringify({
1808
+ version: "1",
1809
+ lastSync: new Date(Date.now() - 60_000).toISOString(),
1810
+ files: {
1811
+ "race-recreate.md": {
1812
+ hash: "old-hash",
1813
+ size: 4,
1814
+ syncedAt: new Date(Date.now() - 60_000).toISOString(),
1815
+ direction: "up",
1816
+ remoteEtag: "etag-before-delete",
1817
+ },
1818
+ },
1819
+ }),
1820
+ );
1821
+
1822
+ const events: Array<{ type?: string; path?: string }> = [];
1823
+ const result = await share({
1824
+ paths: [testFile],
1825
+ company: "acme",
1826
+ vaultConfig: mockConfig,
1827
+ hqRoot: tmpDir,
1828
+ skipUnchanged: false,
1829
+ onConflict: "keep",
1830
+ onEvent: (e) => events.push(e as { type?: string }),
1831
+ });
1832
+
1833
+ expect(result.filesUploaded).toBe(0);
1834
+ expect(result.conflictPaths).toEqual(["race-recreate.md"]);
1835
+ expect(result.aborted).toBe(false);
1836
+ expect(
1837
+ events.some((e) => e.type === "conflict" && e.path === "race-recreate.md"),
1838
+ ).toBe(true);
1839
+ // First (only) attempt must be create-fenced; no unfenced retry under keep.
1840
+ expect(uploadFile).toHaveBeenCalledTimes(1);
1841
+ expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifNoneMatch: "*" });
1842
+ // Journal not advanced to a successful upload etag (entry may still be
1843
+ // the prior baseline — next pass re-evaluates).
1844
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1845
+ expect(journal.files["race-recreate.md"]?.remoteEtag).toBe("etag-before-delete");
1846
+ expect(journal.files["race-recreate.md"]?.hash).toBe("old-hash");
1847
+ expect(fs.readFileSync(testFile, "utf-8")).toBe("my recreated local");
1848
+ });
1404
1849
  });
1405
1850
 
1406
1851
  it("scoped push (plan-exceeds-grant): syncs the in-scope subset, skips out-of-scope paths, never aborts (feedback_ded09d56)", async () => {
package/src/cli/share.ts CHANGED
@@ -1482,8 +1482,14 @@ async function executeUploads(
1482
1482
  }
1483
1483
  throw headErr;
1484
1484
  }
1485
+ // Journal entry for this key (if any). Used both for 3-way conflict
1486
+ // classification and for the write fence below: Prefer last-synced
1487
+ // `remoteEtag` over live HEAD so a peer-advanced object cannot be
1488
+ // silently LWW-overwritten by a journaled stale local body (Ace-shaped
1489
+ // vault regression 2026-08-03).
1490
+ const journalEntry = run.journal.files[relativePath];
1491
+
1485
1492
  if (remoteMeta) {
1486
- const journalEntry = run.journal.files[relativePath];
1487
1493
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
1488
1494
  const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
1489
1495
 
@@ -1523,7 +1529,14 @@ async function executeUploads(
1523
1529
  return;
1524
1530
  }
1525
1531
 
1526
- if ((localChanged && remoteChanged) || isFreshCollision) {
1532
+ // `localDiverges` means a prior pull-keep stamped the remote etag for
1533
+ // re-fire silence (#137) while local never matched that remote. The
1534
+ // journal-baseline If-Match fence alone would SUCCEED when remote is
1535
+ // still at that etag, silently promoting the divergent local to write
1536
+ // authority. Route through conflict (keep/abort/overwrite) instead.
1537
+ const divergentLocalHonesty = !!journalEntry?.localDiverges;
1538
+
1539
+ if ((localChanged && remoteChanged) || isFreshCollision || divergentLocalHonesty) {
1527
1540
  // Cloud-authoritative paths are already skipped at the top of
1528
1541
  // processUploadItem (before HEAD), so anything reaching here is a genuine
1529
1542
  // conflict on a client-owned file.
@@ -1555,13 +1568,24 @@ async function executeUploads(
1555
1568
  counters.filesSkipped++;
1556
1569
  return;
1557
1570
  }
1571
+ // overwrite: fall through to conditional PUT (or unfenced retry on 412).
1558
1572
  }
1559
1573
  }
1560
1574
 
1561
1575
  if (aborted) return;
1562
1576
 
1577
+ // Write fence: when remote exists, prefer last-synced journal remoteEtag
1578
+ // over live HEAD so a peer-advanced object fails closed (412 → keep/
1579
+ // abort/overwrite). Live-HEAD If-Match alone only covers HEAD→PUT TOCTOU.
1580
+ // When remote is missing (HEAD null), always create-fence with
1581
+ // If-None-Match:* — even if the journal still carries a stale remoteEtag
1582
+ // from a prior sync (deleted key recreation under keep). Using journal
1583
+ // If-Match against a missing object cannot succeed and false-conflicts.
1584
+ // Legacy entries without remoteEtag fall back to live HEAD (prior behavior).
1563
1585
  const precondition: PutPrecondition = remoteMeta
1564
- ? { ifMatch: remoteMeta.etag }
1586
+ ? journalEntry?.remoteEtag
1587
+ ? { ifMatch: journalEntry.remoteEtag }
1588
+ : { ifMatch: remoteMeta.etag }
1565
1589
  : { ifNoneMatch: "*" };
1566
1590
 
1567
1591
  const performUpload = async (pc: PutPrecondition | undefined): Promise<void> => {
@@ -8,7 +8,7 @@ describe("isTransientNetworkError", () => {
8
8
  expect(isTransientNetworkError(new TypeError("fetch failed"))).toBe(true);
9
9
  });
10
10
 
11
- it("treats known transport `.code`s as transient", () => {
11
+ it("treats direct transport `.code`s as transient", () => {
12
12
  for (const code of ["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT"]) {
13
13
  const err = Object.assign(new Error("boom"), { code });
14
14
  expect(isTransientNetworkError(err)).toBe(true);
@@ -23,6 +23,18 @@ describe("isTransientNetworkError", () => {
23
23
  expect(isTransientNetworkError(err)).toBe(true);
24
24
  });
25
25
 
26
+ it("walks an AWS SDK UnknownError cause chain to the underlying DNS error", () => {
27
+ const cause = Object.assign(
28
+ new Error("getaddrinfo ENOTFOUND hq-vault-prs-test.s3.us-east-1.amazonaws.com"),
29
+ { code: "ENOTFOUND", syscall: "getaddrinfo" },
30
+ );
31
+ const err = Object.assign(new Error("UnknownError"), {
32
+ name: "UnknownError",
33
+ cause,
34
+ });
35
+ expect(isTransientNetworkError(err)).toBe(true);
36
+ });
37
+
26
38
  it("walks AggregateError.errors (happy-eyeballs)", () => {
27
39
  const agg = Object.assign(new AggregateError([
28
40
  Object.assign(new Error("v4"), { code: "ECONNREFUSED" }),
@@ -2,9 +2,10 @@
2
2
  * Transient network-failure classification for the sync runner.
3
3
  *
4
4
  * The auto-sync watcher runs an unattended poll loop. At the top of every pass
5
- * it calls `GET /membership/me` to resolve which companies to sync. When the
6
- * machine is briefly offline (wifi drop, waking from sleep, a DNS blip, the
7
- * vault API momentarily unreachable) that call fails at the transport layer —
5
+ * it calls `GET /membership/me` to resolve which companies to sync, then it
6
+ * runs a per-company fanout. When the machine is briefly offline (wifi drop,
7
+ * waking from sleep, a DNS blip, the vault API or a vault S3 endpoint
8
+ * momentarily unreachable), either boundary can fail at the transport layer —
8
9
  * Node's `fetch` throws `TypeError: fetch failed` with the real cause (an
9
10
  * `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT` / … Error) on `.cause`.
10
11
  *
@@ -12,9 +13,11 @@
12
13
  * back. But the runner used to `return 1` for it, the watch loop propagated
13
14
  * that non-zero exit, the process exited, and the menubar supervisor reported
14
15
  * "auto-sync watcher exited unexpectedly (code=Some(1))" for every blip — the
15
- * HQ-SYNC-1W cluster. We classify these so the watch loop can stay alive and
16
- * retry instead of surfacing a false crash, while a one-shot `hq sync` still
17
- * exits non-zero so a human running it by hand sees the failure.
16
+ * HQ-SYNC-1W cluster. A company-leg failure was also previously counted as an
17
+ * exit-2 partial sync, producing the HQ-SYNC-X symptom even though it was just
18
+ * retryable DNS/transport loss. We classify both boundaries so the watch loop
19
+ * can stay alive and retry instead of surfacing a false crash, while a one-shot
20
+ * `hq sync` still exits non-zero so a human running it by hand sees the failure.
18
21
  */
19
22
 
20
23
  /**