@indigoai-us/hq-cloud 6.15.19 → 6.15.20
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.d.ts +1 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +6 -3
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/conflict.d.ts +25 -2
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +29 -4
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +90 -6
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +298 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +9 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +37 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/share.test.js
CHANGED
|
@@ -1450,6 +1450,304 @@ describe("share", () => {
|
|
|
1450
1450
|
// fence then retry — either is consent. Must not leave the file unuploaded.
|
|
1451
1451
|
expect(uploadFile).toHaveBeenCalled();
|
|
1452
1452
|
});
|
|
1453
|
+
// ── publish-local: fenced promotion of a kept-local copy ────────────────
|
|
1454
|
+
//
|
|
1455
|
+
// `keep` leaves a file permanently unpublishable: push refuses it on the
|
|
1456
|
+
// localDiverges flag, pull never clears the flag (remote is unchanged), and
|
|
1457
|
+
// `keep` re-arms it every pass. The only existing escape — `overwrite` — is
|
|
1458
|
+
// a blunt force-write that will PUT unfenced after a 412, clobbering a
|
|
1459
|
+
// remote that genuinely advanced.
|
|
1460
|
+
//
|
|
1461
|
+
// `publish-local` is the missing middle: the operator explicitly elects
|
|
1462
|
+
// their local copy as the new authority, and the engine accepts it ONLY
|
|
1463
|
+
// while the remote still sits at the etag recorded when the keep happened.
|
|
1464
|
+
// If the remote moved, consent was given against a version that no longer
|
|
1465
|
+
// exists, so the write must fail closed instead of forcing.
|
|
1466
|
+
it("localDiverges under publish-local: promotes local under an If-Match fence on the journal etag", async () => {
|
|
1467
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1468
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1469
|
+
const testFile = path.join(companyRoot, "publish-divergent.md");
|
|
1470
|
+
const localBytes = "my kept divergent local, now elected authority";
|
|
1471
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1472
|
+
const localHash = hashFile(testFile);
|
|
1473
|
+
const REMOTE = "peer-version-etag";
|
|
1474
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1475
|
+
lastModified: new Date(),
|
|
1476
|
+
etag: `"${REMOTE}"`,
|
|
1477
|
+
size: 40,
|
|
1478
|
+
});
|
|
1479
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
1480
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1481
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1482
|
+
version: "1",
|
|
1483
|
+
lastSync: new Date().toISOString(),
|
|
1484
|
+
files: {
|
|
1485
|
+
"publish-divergent.md": {
|
|
1486
|
+
hash: localHash,
|
|
1487
|
+
size: localBytes.length,
|
|
1488
|
+
syncedAt: new Date().toISOString(),
|
|
1489
|
+
direction: "down",
|
|
1490
|
+
remoteEtag: REMOTE,
|
|
1491
|
+
localDiverges: true,
|
|
1492
|
+
},
|
|
1493
|
+
},
|
|
1494
|
+
}));
|
|
1495
|
+
const result = await share({
|
|
1496
|
+
paths: [testFile],
|
|
1497
|
+
company: "acme",
|
|
1498
|
+
vaultConfig: mockConfig,
|
|
1499
|
+
hqRoot: tmpDir,
|
|
1500
|
+
skipUnchanged: false,
|
|
1501
|
+
onConflict: "publish-local",
|
|
1502
|
+
});
|
|
1503
|
+
expect(result.filesUploaded).toBe(1);
|
|
1504
|
+
// Fenced on the etag the keep recorded — never unfenced, never live-HEAD.
|
|
1505
|
+
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
1506
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1507
|
+
// The entry is rewritten as an ordinary upload: local is now authority,
|
|
1508
|
+
// so the journal-honesty flag must be gone rather than re-armed.
|
|
1509
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1510
|
+
expect(journal.files["publish-divergent.md"].direction).toBe("up");
|
|
1511
|
+
expect(journal.files["publish-divergent.md"].localDiverges).toBeFalsy();
|
|
1512
|
+
expect(journal.files["publish-divergent.md"].remoteEtag).toBe("published-etag");
|
|
1513
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1514
|
+
});
|
|
1515
|
+
it("localDiverges under publish-local: a 412 fails closed and never retries unfenced", async () => {
|
|
1516
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1517
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1518
|
+
const testFile = path.join(companyRoot, "publish-raced.md");
|
|
1519
|
+
const localBytes = "my local, but the remote moved under me";
|
|
1520
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1521
|
+
const localHash = hashFile(testFile);
|
|
1522
|
+
const REMOTE = "etag-at-keep-time";
|
|
1523
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1524
|
+
lastModified: new Date(),
|
|
1525
|
+
etag: `"${REMOTE}"`,
|
|
1526
|
+
size: 40,
|
|
1527
|
+
});
|
|
1528
|
+
const precondition = Object.assign(new Error("At least one of the pre-conditions you specified did not hold"), {
|
|
1529
|
+
name: "PreconditionFailed",
|
|
1530
|
+
});
|
|
1531
|
+
vi.mocked(uploadFile).mockRejectedValueOnce(precondition);
|
|
1532
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1533
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1534
|
+
version: "1",
|
|
1535
|
+
lastSync: new Date().toISOString(),
|
|
1536
|
+
files: {
|
|
1537
|
+
"publish-raced.md": {
|
|
1538
|
+
hash: localHash,
|
|
1539
|
+
size: localBytes.length,
|
|
1540
|
+
syncedAt: new Date().toISOString(),
|
|
1541
|
+
direction: "down",
|
|
1542
|
+
remoteEtag: REMOTE,
|
|
1543
|
+
localDiverges: true,
|
|
1544
|
+
},
|
|
1545
|
+
},
|
|
1546
|
+
}));
|
|
1547
|
+
const result = await share({
|
|
1548
|
+
paths: [testFile],
|
|
1549
|
+
company: "acme",
|
|
1550
|
+
vaultConfig: mockConfig,
|
|
1551
|
+
hqRoot: tmpDir,
|
|
1552
|
+
skipUnchanged: false,
|
|
1553
|
+
onConflict: "publish-local",
|
|
1554
|
+
});
|
|
1555
|
+
// Exactly one attempt: the fenced one. The `overwrite` escape hatch of
|
|
1556
|
+
// re-PUTting with no precondition must NOT be reachable from here.
|
|
1557
|
+
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
1558
|
+
expect(result.filesUploaded).toBe(0);
|
|
1559
|
+
expect(result.conflictPaths).toContain("publish-raced.md");
|
|
1560
|
+
// Consent was for a remote that no longer exists, so the flag stays put
|
|
1561
|
+
// and the operator is asked again rather than silently overruled.
|
|
1562
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1563
|
+
expect(journal.files["publish-raced.md"].localDiverges).toBe(true);
|
|
1564
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1565
|
+
});
|
|
1566
|
+
// Codex review (PR #356, P1). The sync runner's push leg always calls
|
|
1567
|
+
// share() with `skipUnchanged: true`, and a pull-kept entry stores the
|
|
1568
|
+
// CURRENT local hash — that is exactly what the journal-honesty stamp
|
|
1569
|
+
// records. So `existing.hash === localHash` is true for every stuck file,
|
|
1570
|
+
// classifyPushCandidates() returns `skip-unchanged`, and executeUploads()
|
|
1571
|
+
// never runs: no HEAD, no conflict, no upload. Without this bypass the
|
|
1572
|
+
// whole recovery is a no-op in the one mode that can reach personal-vault
|
|
1573
|
+
// files, and the next pull leaves them stuck again. `hash === journal hash`
|
|
1574
|
+
// means "local is unchanged since the stamp", NOT "local matches remote" —
|
|
1575
|
+
// for a divergent entry those are different claims.
|
|
1576
|
+
it("localDiverges bypasses the skip-unchanged fast path so the recovery can run at all", async () => {
|
|
1577
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1578
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1579
|
+
const testFile = path.join(companyRoot, "stuck-under-skip-unchanged.md");
|
|
1580
|
+
const localBytes = "the body a pull-keep stamped and then refused to publish";
|
|
1581
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1582
|
+
const localHash = hashFile(testFile);
|
|
1583
|
+
const REMOTE = "etag-at-keep-time";
|
|
1584
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1585
|
+
lastModified: new Date(),
|
|
1586
|
+
etag: `"${REMOTE}"`,
|
|
1587
|
+
size: 40,
|
|
1588
|
+
});
|
|
1589
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
1590
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1591
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1592
|
+
version: "1",
|
|
1593
|
+
lastSync: new Date().toISOString(),
|
|
1594
|
+
files: {
|
|
1595
|
+
"stuck-under-skip-unchanged.md": {
|
|
1596
|
+
// Deliberately the CURRENT local hash: this is what a pull-keep
|
|
1597
|
+
// writes, and it is what makes the fast path swallow the file.
|
|
1598
|
+
hash: localHash,
|
|
1599
|
+
size: localBytes.length,
|
|
1600
|
+
syncedAt: new Date().toISOString(),
|
|
1601
|
+
direction: "down",
|
|
1602
|
+
remoteEtag: REMOTE,
|
|
1603
|
+
localDiverges: true,
|
|
1604
|
+
},
|
|
1605
|
+
},
|
|
1606
|
+
}));
|
|
1607
|
+
const result = await share({
|
|
1608
|
+
paths: [testFile],
|
|
1609
|
+
company: "acme",
|
|
1610
|
+
vaultConfig: mockConfig,
|
|
1611
|
+
hqRoot: tmpDir,
|
|
1612
|
+
skipUnchanged: true,
|
|
1613
|
+
onConflict: "publish-local",
|
|
1614
|
+
});
|
|
1615
|
+
expect(result.filesUploaded).toBe(1);
|
|
1616
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1617
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1618
|
+
expect(journal.files["stuck-under-skip-unchanged.md"].direction).toBe("up");
|
|
1619
|
+
expect(journal.files["stuck-under-skip-unchanged.md"].localDiverges).toBeFalsy();
|
|
1620
|
+
});
|
|
1621
|
+
// Codex review (PR #356, P2). A genuine two-sided conflict is not the
|
|
1622
|
+
// localDiverges recovery: the journal etag is stale BY DEFINITION here
|
|
1623
|
+
// (that is what remoteChanged means), so fencing consent on it guarantees a
|
|
1624
|
+
// 412 and the file can never publish. Consent given at this prompt is
|
|
1625
|
+
// consent against the remote the operator was just shown, so the fence must
|
|
1626
|
+
// be the freshly observed etag.
|
|
1627
|
+
it("publish-local on a fresh two-sided conflict fences on the OBSERVED remote etag, not the stale journal one", async () => {
|
|
1628
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1629
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1630
|
+
const testFile = path.join(companyRoot, "two-sided.md");
|
|
1631
|
+
fs.writeFileSync(testFile, "my newer local body");
|
|
1632
|
+
const STALE_JOURNAL_ETAG = "etag-from-last-sync";
|
|
1633
|
+
const OBSERVED_REMOTE_ETAG = "etag-the-peer-just-wrote";
|
|
1634
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1635
|
+
lastModified: new Date(),
|
|
1636
|
+
etag: `"${OBSERVED_REMOTE_ETAG}"`,
|
|
1637
|
+
size: 40,
|
|
1638
|
+
});
|
|
1639
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
1640
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1641
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1642
|
+
version: "1",
|
|
1643
|
+
lastSync: new Date().toISOString(),
|
|
1644
|
+
files: {
|
|
1645
|
+
"two-sided.md": {
|
|
1646
|
+
hash: "a-hash-that-is-neither-side",
|
|
1647
|
+
size: 10,
|
|
1648
|
+
syncedAt: new Date().toISOString(),
|
|
1649
|
+
direction: "up",
|
|
1650
|
+
remoteEtag: STALE_JOURNAL_ETAG,
|
|
1651
|
+
},
|
|
1652
|
+
},
|
|
1653
|
+
}));
|
|
1654
|
+
const result = await share({
|
|
1655
|
+
paths: [testFile],
|
|
1656
|
+
company: "acme",
|
|
1657
|
+
vaultConfig: mockConfig,
|
|
1658
|
+
hqRoot: tmpDir,
|
|
1659
|
+
skipUnchanged: false,
|
|
1660
|
+
onConflict: "publish-local",
|
|
1661
|
+
});
|
|
1662
|
+
expect(result.filesUploaded).toBe(1);
|
|
1663
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({
|
|
1664
|
+
ifMatch: OBSERVED_REMOTE_ETAG,
|
|
1665
|
+
});
|
|
1666
|
+
});
|
|
1667
|
+
// Codex review (PR #356, P2). HEAD returning null skips the whole conflict
|
|
1668
|
+
// block, so the precondition becomes If-None-Match:* and the object is
|
|
1669
|
+
// RECREATED. For a divergent entry that silently overrules a peer's
|
|
1670
|
+
// deletion — the remote is emphatically no longer at the recorded etag, so
|
|
1671
|
+
// the strategy's own fence says fail closed. `keep`'s recreate-once
|
|
1672
|
+
// behaviour for non-divergent entries is unaffected.
|
|
1673
|
+
it("publish-local does not resurrect a remote deleted after the keep", async () => {
|
|
1674
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1675
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1676
|
+
const testFile = path.join(companyRoot, "peer-deleted.md");
|
|
1677
|
+
const localBytes = "my divergent local body";
|
|
1678
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1679
|
+
const localHash = hashFile(testFile);
|
|
1680
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
1681
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1682
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1683
|
+
version: "1",
|
|
1684
|
+
lastSync: new Date().toISOString(),
|
|
1685
|
+
files: {
|
|
1686
|
+
"peer-deleted.md": {
|
|
1687
|
+
hash: localHash,
|
|
1688
|
+
size: localBytes.length,
|
|
1689
|
+
syncedAt: new Date().toISOString(),
|
|
1690
|
+
direction: "down",
|
|
1691
|
+
remoteEtag: "etag-before-the-peer-deleted-it",
|
|
1692
|
+
localDiverges: true,
|
|
1693
|
+
},
|
|
1694
|
+
},
|
|
1695
|
+
}));
|
|
1696
|
+
const result = await share({
|
|
1697
|
+
paths: [testFile],
|
|
1698
|
+
company: "acme",
|
|
1699
|
+
vaultConfig: mockConfig,
|
|
1700
|
+
hqRoot: tmpDir,
|
|
1701
|
+
skipUnchanged: false,
|
|
1702
|
+
onConflict: "publish-local",
|
|
1703
|
+
});
|
|
1704
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
1705
|
+
expect(result.filesUploaded).toBe(0);
|
|
1706
|
+
expect(result.conflictPaths).toContain("peer-deleted.md");
|
|
1707
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1708
|
+
expect(journal.files["peer-deleted.md"].localDiverges).toBe(true);
|
|
1709
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1710
|
+
});
|
|
1711
|
+
it("publish-local on a non-divergent file behaves like an ordinary fenced upload", async () => {
|
|
1712
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1713
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1714
|
+
const testFile = path.join(companyRoot, "ordinary.md");
|
|
1715
|
+
fs.writeFileSync(testFile, "locally edited since last sync");
|
|
1716
|
+
const REMOTE = "unchanged-remote-etag";
|
|
1717
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1718
|
+
lastModified: new Date(),
|
|
1719
|
+
etag: `"${REMOTE}"`,
|
|
1720
|
+
size: 10,
|
|
1721
|
+
});
|
|
1722
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"next-etag"' });
|
|
1723
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1724
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1725
|
+
version: "1",
|
|
1726
|
+
lastSync: new Date().toISOString(),
|
|
1727
|
+
files: {
|
|
1728
|
+
"ordinary.md": {
|
|
1729
|
+
hash: "stale-hash-from-a-previous-body",
|
|
1730
|
+
size: 10,
|
|
1731
|
+
syncedAt: new Date().toISOString(),
|
|
1732
|
+
direction: "up",
|
|
1733
|
+
remoteEtag: REMOTE,
|
|
1734
|
+
},
|
|
1735
|
+
},
|
|
1736
|
+
}));
|
|
1737
|
+
const result = await share({
|
|
1738
|
+
paths: [testFile],
|
|
1739
|
+
company: "acme",
|
|
1740
|
+
vaultConfig: mockConfig,
|
|
1741
|
+
hqRoot: tmpDir,
|
|
1742
|
+
skipUnchanged: false,
|
|
1743
|
+
onConflict: "publish-local",
|
|
1744
|
+
});
|
|
1745
|
+
// No localDiverges flag means no special casing: the normal push fence
|
|
1746
|
+
// applies and the file uploads without being routed through conflict.
|
|
1747
|
+
expect(result.filesUploaded).toBe(1);
|
|
1748
|
+
expect(result.conflictPaths ?? []).not.toContain("ordinary.md");
|
|
1749
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1750
|
+
});
|
|
1453
1751
|
// Journal still carries remoteEtag from a prior sync, but the remote key
|
|
1454
1752
|
// is gone (HEAD null) — e.g. peer delete or operator purge. Recreation
|
|
1455
1753
|
// must use If-None-Match:* (create-only), NOT If-Match on the stale
|