@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.
- package/dist/bin/sync-runner-company.d.ts +4 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +38 -9
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-rollup.d.ts +1 -1
- package/dist/bin/sync-runner-rollup.d.ts.map +1 -1
- package/dist/bin/sync-runner.d.ts +30 -5
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +9 -6
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +180 -21
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +25 -3
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +357 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/lib/net-errors.d.ts +9 -6
- package/dist/lib/net-errors.d.ts.map +1 -1
- package/dist/lib/net-errors.js +9 -6
- package/dist/lib/net-errors.js.map +1 -1
- package/dist/lib/net-errors.test.js +9 -1
- package/dist/lib/net-errors.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +36 -9
- package/src/bin/sync-runner-rollup.ts +1 -1
- package/src/bin/sync-runner.test.ts +246 -23
- package/src/bin/sync-runner.ts +34 -8
- package/src/cli/share.test.ts +446 -1
- package/src/cli/share.ts +27 -3
- package/src/lib/net-errors.test.ts +13 -1
- package/src/lib/net-errors.ts +9 -6
- package/test/e2e/sync/transient-company-leg.test.ts +380 -0
package/src/cli/share.test.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
|
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" }),
|
package/src/lib/net-errors.ts
CHANGED
|
@@ -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
|
|
6
|
-
* machine is briefly offline (wifi drop,
|
|
7
|
-
*
|
|
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.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
/**
|