a2acalling 0.6.70 → 0.6.72
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/.a2a-manifest.json +2 -2
- package/CONVENTIONS.md +8 -1
- package/package.json +1 -1
- package/src/dashboard/public/app.js +199 -75
- package/src/dashboard/public/style.css +4 -0
- package/src/lib/config.js +25 -1
- package/src/lib/conversations.js +131 -0
- package/src/lib/logger.js +98 -1
- package/src/lib/tokens.js +67 -0
- package/src/server.js +62 -1
package/.a2a-manifest.json
CHANGED
package/CONVENTIONS.md
CHANGED
|
@@ -99,13 +99,20 @@ close() {
|
|
|
99
99
|
```
|
|
100
100
|
- `close()` must be idempotent (safe to call multiple times)
|
|
101
101
|
- `close()` must be a no-op when DB was never initialized (`this.db === null`)
|
|
102
|
-
- The `server.js` `shutdown()` function closes all stores on SIGTERM/SIGINT
|
|
102
|
+
- The `server.js` `shutdown()` function closes all stores on SIGTERM/SIGINT: `serverConvStore`, `eventStore`, `callbookStore` (A2A-59), and logger stores
|
|
103
|
+
- All stores should be created at the `server.js` module level and passed to route factories — do NOT create stores inside route factories (A2A-59)
|
|
103
104
|
- Test teardown should call `store.close()` to prevent SQLite handle leaks
|
|
104
105
|
|
|
105
106
|
## Permission Tiers
|
|
106
107
|
|
|
107
108
|
Tokens have a tier (`public`, `friends`, `family`) and a disclosure level (`public`, `minimal`, `none`). These are enforced at the route level in `src/routes/a2a.js`.
|
|
108
109
|
|
|
110
|
+
## Route Hardening (A2A-53)
|
|
111
|
+
|
|
112
|
+
- Rate limit Map has eviction: entries are swept when Map exceeds 1000 entries (stale >24h first, then oldest by insertion order)
|
|
113
|
+
- Admin token comparison uses `timingSafeTokenEqual()` from `src/routes/a2a.js` — do NOT use `!==` for secret comparison
|
|
114
|
+
- Query parameter parsing follows the dashboard.js pattern: `Math.min(max, Math.max(min, Number.parseInt(String(value), 10) || defaultValue))`
|
|
115
|
+
|
|
109
116
|
## Anti-Patterns
|
|
110
117
|
|
|
111
118
|
- Do NOT use `console.log` — use the structured logger
|
package/package.json
CHANGED
|
@@ -1351,10 +1351,13 @@ function renderSidebarPreview(tierId) {
|
|
|
1351
1351
|
function renderSidebarLists(tier) {
|
|
1352
1352
|
const topicContainer = document.getElementById('sidebar-topics');
|
|
1353
1353
|
const goalContainer = document.getElementById('sidebar-goals');
|
|
1354
|
-
if (!topicContainer || !goalContainer) return;
|
|
1354
|
+
if (!topicContainer || !goalContainer || !tier) return;
|
|
1355
1355
|
|
|
1356
|
-
// Collect ALL topics across
|
|
1357
|
-
|
|
1356
|
+
// Collect ALL topics/goals across tiers, but prioritize the currently
|
|
1357
|
+
// rendered tier first so sidebar drag payloads carry the right metadata.
|
|
1358
|
+
// This also ensures unsaved in-DOM items from create/drop appear immediately.
|
|
1359
|
+
const baseTiers = state.settings?.tiers || [];
|
|
1360
|
+
const allTiers = [tier, ...baseTiers.filter(t => t.id !== tier.id)];
|
|
1358
1361
|
const allTopicMap = new Map();
|
|
1359
1362
|
const allGoalMap = new Map();
|
|
1360
1363
|
for (const t of allTiers) {
|
|
@@ -1362,17 +1365,24 @@ function renderSidebarLists(tier) {
|
|
|
1362
1365
|
const fTopics = t.topics || [];
|
|
1363
1366
|
const topics = mTopics.length > 0 ? mTopics : fTopics.map(x => ({ topic: x, description: '' }));
|
|
1364
1367
|
for (const item of topics) {
|
|
1365
|
-
if (
|
|
1366
|
-
|
|
1368
|
+
if (!item.topic) continue;
|
|
1369
|
+
const existingDesc = allTopicMap.get(item.topic);
|
|
1370
|
+
const nextDesc = item.description || '';
|
|
1371
|
+
if (!allTopicMap.has(item.topic) || (!existingDesc && nextDesc)) {
|
|
1372
|
+
allTopicMap.set(item.topic, nextDesc);
|
|
1367
1373
|
}
|
|
1368
1374
|
}
|
|
1375
|
+
|
|
1369
1376
|
const mGoals = t.manifest?.objectives || [];
|
|
1370
1377
|
const fGoals = t.goals || [];
|
|
1371
1378
|
const goals = mGoals.length > 0 ? mGoals : fGoals.map(x => ({ topic: x, description: '' }));
|
|
1372
1379
|
for (const item of goals) {
|
|
1373
1380
|
const label = item.objective || item.topic;
|
|
1374
|
-
if (
|
|
1375
|
-
|
|
1381
|
+
if (!label) continue;
|
|
1382
|
+
const existingDesc = allGoalMap.get(label);
|
|
1383
|
+
const nextDesc = item.description || '';
|
|
1384
|
+
if (!allGoalMap.has(label) || (!existingDesc && nextDesc)) {
|
|
1385
|
+
allGoalMap.set(label, nextDesc);
|
|
1376
1386
|
}
|
|
1377
1387
|
}
|
|
1378
1388
|
}
|
|
@@ -1428,53 +1438,128 @@ function renderSidebarLists(tier) {
|
|
|
1428
1438
|
`;
|
|
1429
1439
|
}
|
|
1430
1440
|
|
|
1431
|
-
// A2A-
|
|
1432
|
-
//
|
|
1441
|
+
// A2A-61: Snapshot permissions payload from live DOM at mutation time.
|
|
1442
|
+
// This prevents polling/re-render races from dropping unsaved drag changes.
|
|
1443
|
+
function collectTierPayloadFromDom() {
|
|
1444
|
+
const toggles = document.querySelectorAll('#tool-toggles .toggle-switch input');
|
|
1445
|
+
const allowed_tools = Array.from(toggles).filter(t => t.checked).map(t => t.dataset.tool);
|
|
1446
|
+
|
|
1447
|
+
const topicCards = document.querySelectorAll('#active-topics-zone .active-item-card');
|
|
1448
|
+
// A2A-48: uses dataset.topic for BOTH topics and goals (NOT dataset.objective)
|
|
1449
|
+
// because parseTopicObjects() in dashboard.js only reads entry.topic.
|
|
1450
|
+
const topics = Array.from(topicCards).map(c => c.dataset.topic).filter(Boolean);
|
|
1451
|
+
const manifestTopics = Array.from(topicCards).map(c => ({
|
|
1452
|
+
topic: c.dataset.topic,
|
|
1453
|
+
description: c.dataset.description || ''
|
|
1454
|
+
})).filter(t => t.topic);
|
|
1455
|
+
|
|
1456
|
+
const goalCards = document.querySelectorAll('#active-goals-zone .active-item-card');
|
|
1457
|
+
const goals = Array.from(goalCards).map(c => c.dataset.topic).filter(Boolean);
|
|
1458
|
+
const manifestObjectives = Array.from(goalCards).map(c => ({
|
|
1459
|
+
topic: c.dataset.topic,
|
|
1460
|
+
description: c.dataset.description || ''
|
|
1461
|
+
})).filter(g => g.topic);
|
|
1462
|
+
|
|
1463
|
+
return {
|
|
1464
|
+
allowed_tools,
|
|
1465
|
+
topics,
|
|
1466
|
+
goals,
|
|
1467
|
+
manifest: {
|
|
1468
|
+
topics: manifestTopics,
|
|
1469
|
+
objectives: manifestObjectives
|
|
1470
|
+
}
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// A2A-61: Build a tier view using live DOM state so sidebar active badges
|
|
1475
|
+
// stay accurate immediately after create/drop/delete (before round-trip save).
|
|
1476
|
+
function getTierSnapshotFromDom() {
|
|
1477
|
+
const tierId = state.activeTierId;
|
|
1478
|
+
const tier = (state.settings?.tiers || []).find(t => t.id === tierId);
|
|
1479
|
+
if (!tier) return null;
|
|
1480
|
+
const payload = collectTierPayloadFromDom();
|
|
1481
|
+
return {
|
|
1482
|
+
...tier,
|
|
1483
|
+
allowed_tools: payload.allowed_tools,
|
|
1484
|
+
topics: payload.topics,
|
|
1485
|
+
goals: payload.goals,
|
|
1486
|
+
manifest: {
|
|
1487
|
+
...(tier.manifest || {}),
|
|
1488
|
+
topics: payload.manifest.topics,
|
|
1489
|
+
objectives: payload.manifest.objectives
|
|
1490
|
+
}
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function renderSidebarListsFromDom() {
|
|
1495
|
+
const snapshot = getTierSnapshotFromDom();
|
|
1496
|
+
if (snapshot) renderSidebarLists(snapshot);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// A2A-61: Debounced auto-save with per-tier queueing.
|
|
1500
|
+
// Snapshot is captured at mutation time; timer only controls network cadence.
|
|
1433
1501
|
let _autoSaveTimer = null;
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
const tierId = state.activeTierId;
|
|
1438
|
-
if (!tierId) return;
|
|
1502
|
+
const _pendingTierSaves = new Map();
|
|
1503
|
+
let _tierSaveQueue = [];
|
|
1504
|
+
let _tierSaveInFlight = false;
|
|
1439
1505
|
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1506
|
+
function hasPendingTierSaves() {
|
|
1507
|
+
return Boolean(_autoSaveTimer) || _tierSaveInFlight || _tierSaveQueue.length > 0 || _pendingTierSaves.size > 0;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function queueTierSave(tierId, payload) {
|
|
1511
|
+
if (!tierId || !payload) return;
|
|
1512
|
+
if (!_pendingTierSaves.has(tierId)) {
|
|
1513
|
+
_tierSaveQueue.push(tierId);
|
|
1514
|
+
}
|
|
1515
|
+
_pendingTierSaves.set(tierId, payload);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
async function flushTierSaveQueue() {
|
|
1519
|
+
if (_tierSaveInFlight) return;
|
|
1520
|
+
_tierSaveInFlight = true;
|
|
1521
|
+
|
|
1522
|
+
try {
|
|
1523
|
+
while (_tierSaveQueue.length > 0) {
|
|
1524
|
+
const tierId = _tierSaveQueue.shift();
|
|
1525
|
+
const body = _pendingTierSaves.get(tierId);
|
|
1526
|
+
_pendingTierSaves.delete(tierId);
|
|
1527
|
+
if (!tierId || !body) continue;
|
|
1528
|
+
|
|
1529
|
+
try {
|
|
1530
|
+
await request(`/settings/tiers/${encodeURIComponent(tierId)}`, {
|
|
1531
|
+
method: 'PUT',
|
|
1532
|
+
body: JSON.stringify(body)
|
|
1533
|
+
});
|
|
1534
|
+
showNotice('Saved');
|
|
1535
|
+
|
|
1536
|
+
// Refresh canonical settings after each successful save so future
|
|
1537
|
+
// renders and sidebar catalogs stay in sync with persisted state.
|
|
1538
|
+
const payload = await request('/settings');
|
|
1539
|
+
state.settings = payload;
|
|
1540
|
+
} catch (err) {
|
|
1541
|
+
showNotice(`Save failed: ${err.message}`);
|
|
1542
|
+
}
|
|
1477
1543
|
}
|
|
1544
|
+
} finally {
|
|
1545
|
+
_tierSaveInFlight = false;
|
|
1546
|
+
if (_tierSaveQueue.length > 0) {
|
|
1547
|
+
flushTierSaveQueue().catch(() => {});
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function autoSaveTier(snapshotPayload = null) {
|
|
1553
|
+
const tierId = state.activeTierId;
|
|
1554
|
+
if (!tierId) return;
|
|
1555
|
+
|
|
1556
|
+
const payload = snapshotPayload || collectTierPayloadFromDom();
|
|
1557
|
+
queueTierSave(tierId, payload);
|
|
1558
|
+
|
|
1559
|
+
clearTimeout(_autoSaveTimer);
|
|
1560
|
+
_autoSaveTimer = setTimeout(() => {
|
|
1561
|
+
_autoSaveTimer = null;
|
|
1562
|
+
flushTierSaveQueue().catch(() => {});
|
|
1478
1563
|
}, 250);
|
|
1479
1564
|
}
|
|
1480
1565
|
|
|
@@ -1486,13 +1571,24 @@ function autoSaveTier() {
|
|
|
1486
1571
|
function handleZoneDrop(zone, e) {
|
|
1487
1572
|
e.preventDefault();
|
|
1488
1573
|
zone.classList.remove('drag-over');
|
|
1574
|
+
|
|
1489
1575
|
let data;
|
|
1490
1576
|
try { data = JSON.parse(e.dataTransfer.getData('application/json')); } catch { return; }
|
|
1491
|
-
if (!data.name) return;
|
|
1492
1577
|
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1578
|
+
const name = String(data?.name || '').trim();
|
|
1579
|
+
if (!name) return;
|
|
1580
|
+
|
|
1581
|
+
// A2A-61: strict payload validation. Unknown explicit types are rejected
|
|
1582
|
+
// instead of silently defaulting into the goals column.
|
|
1583
|
+
let itemType = null;
|
|
1584
|
+
if (data.type === 'topic' || data.type === 'goal') {
|
|
1585
|
+
itemType = data.type;
|
|
1586
|
+
} else if (!data.type) {
|
|
1587
|
+
itemType = zone.id === 'active-topics-zone' ? 'topic' : 'goal';
|
|
1588
|
+
} else {
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1496
1592
|
const targetZoneId = itemType === 'topic' ? 'active-topics-zone' : 'active-goals-zone';
|
|
1497
1593
|
const targetZone = document.getElementById(targetZoneId);
|
|
1498
1594
|
if (!targetZone) return;
|
|
@@ -1504,24 +1600,18 @@ function handleZoneDrop(zone, e) {
|
|
|
1504
1600
|
// Check if already in target zone
|
|
1505
1601
|
const existing = targetZone.querySelectorAll('.active-item-card');
|
|
1506
1602
|
for (const card of existing) {
|
|
1507
|
-
if (card.dataset.topic ===
|
|
1603
|
+
if (card.dataset.topic === name) return;
|
|
1508
1604
|
}
|
|
1509
1605
|
|
|
1510
1606
|
// A2A-50: Shared helper builds the card HTML to avoid duplication with
|
|
1511
1607
|
// the create-item-submit handler. Both paths now use buildItemCard().
|
|
1512
|
-
const card = buildItemCard(
|
|
1608
|
+
const card = buildItemCard(name, String(data.description || ''), accentClass, typeLabel, removeAttr);
|
|
1513
1609
|
const placeholder = targetZone.querySelector('.drop-placeholder');
|
|
1514
|
-
targetZone.insertBefore(card, placeholder);
|
|
1515
|
-
|
|
1610
|
+
if (placeholder) targetZone.insertBefore(card, placeholder);
|
|
1611
|
+
else targetZone.appendChild(card);
|
|
1516
1612
|
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
setTimeout(() => {
|
|
1520
|
-
const freshTier = (state.settings?.tiers || []).find(t => t.id === state.activeTierId);
|
|
1521
|
-
if (freshTier) {
|
|
1522
|
-
renderSidebarLists(freshTier);
|
|
1523
|
-
}
|
|
1524
|
-
}, 300);
|
|
1613
|
+
autoSaveTier();
|
|
1614
|
+
renderSidebarListsFromDom();
|
|
1525
1615
|
}
|
|
1526
1616
|
|
|
1527
1617
|
// A2A-50: Shared helper to build an active-item card DOM element.
|
|
@@ -1791,10 +1881,28 @@ function bindPermissionsActions() {
|
|
|
1791
1881
|
[topicZone, goalZone].forEach(zone => {
|
|
1792
1882
|
if (!zone) return;
|
|
1793
1883
|
let dragCounter = 0;
|
|
1794
|
-
|
|
1884
|
+
const clearZoneDragState = () => {
|
|
1885
|
+
dragCounter = 0;
|
|
1886
|
+
zone.classList.remove('drag-over');
|
|
1887
|
+
};
|
|
1888
|
+
|
|
1889
|
+
zone.addEventListener('dragenter', (e) => {
|
|
1890
|
+
e.preventDefault();
|
|
1891
|
+
dragCounter += 1;
|
|
1892
|
+
zone.classList.add('drag-over');
|
|
1893
|
+
});
|
|
1795
1894
|
zone.addEventListener('dragover', (e) => { e.preventDefault(); });
|
|
1796
|
-
zone.addEventListener('dragleave', () => {
|
|
1797
|
-
|
|
1895
|
+
zone.addEventListener('dragleave', () => {
|
|
1896
|
+
dragCounter = Math.max(0, dragCounter - 1);
|
|
1897
|
+
if (dragCounter <= 0) zone.classList.remove('drag-over');
|
|
1898
|
+
});
|
|
1899
|
+
zone.addEventListener('drop', (e) => {
|
|
1900
|
+
clearZoneDragState();
|
|
1901
|
+
handleZoneDrop(zone, e);
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
// A2A-61: Ensure highlight never gets stuck if drag ends outside zone.
|
|
1905
|
+
window.addEventListener('dragend', clearZoneDragState);
|
|
1798
1906
|
});
|
|
1799
1907
|
|
|
1800
1908
|
// A2A-61: Delegated drag listeners on .perm-sidebar — survives innerHTML
|
|
@@ -1809,12 +1917,13 @@ function bindPermissionsActions() {
|
|
|
1809
1917
|
const itemType = item.dataset.itemType || 'topic';
|
|
1810
1918
|
const name = item.dataset.sidebarTopic || item.dataset.sidebarGoal || '';
|
|
1811
1919
|
const desc = item.dataset.description || '';
|
|
1920
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
1812
1921
|
e.dataTransfer.setData('application/json', JSON.stringify({ name, description: desc, type: itemType }));
|
|
1813
|
-
item.
|
|
1922
|
+
item.classList.add('dragging');
|
|
1814
1923
|
});
|
|
1815
1924
|
sidebar.addEventListener('dragend', (e) => {
|
|
1816
1925
|
const item = e.target.closest('.sidebar-item[draggable="true"]');
|
|
1817
|
-
if (item) item.
|
|
1926
|
+
if (item) item.classList.remove('dragging');
|
|
1818
1927
|
});
|
|
1819
1928
|
}
|
|
1820
1929
|
|
|
@@ -1852,6 +1961,16 @@ function bindPermissionsActions() {
|
|
|
1852
1961
|
const typeLabel = type === 'topic' ? 'Topic' : 'Goal';
|
|
1853
1962
|
const removeAttr = type === 'topic' ? 'data-remove-topic' : 'data-remove-goal';
|
|
1854
1963
|
|
|
1964
|
+
// A2A-61: Match drop-path dedupe behavior for create flow.
|
|
1965
|
+
const existing = zone.querySelectorAll('.active-item-card');
|
|
1966
|
+
for (const existingCard of existing) {
|
|
1967
|
+
if (existingCard.dataset.topic === title) {
|
|
1968
|
+
dialog.hide();
|
|
1969
|
+
showNotice(`Already active: ${title}`);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1855
1974
|
const card = buildItemCard(title, desc, accentClass, typeLabel, removeAttr);
|
|
1856
1975
|
const placeholder = zone.querySelector('.drop-placeholder');
|
|
1857
1976
|
if (placeholder) zone.insertBefore(card, placeholder);
|
|
@@ -1859,6 +1978,7 @@ function bindPermissionsActions() {
|
|
|
1859
1978
|
|
|
1860
1979
|
dialog.hide();
|
|
1861
1980
|
autoSaveTier();
|
|
1981
|
+
renderSidebarListsFromDom();
|
|
1862
1982
|
});
|
|
1863
1983
|
|
|
1864
1984
|
// A2A-48: Create Item dialog — cancel handler
|
|
@@ -1915,6 +2035,7 @@ function bindPermissionsActions() {
|
|
|
1915
2035
|
}
|
|
1916
2036
|
dialog.hide();
|
|
1917
2037
|
autoSaveTier();
|
|
2038
|
+
renderSidebarListsFromDom();
|
|
1918
2039
|
});
|
|
1919
2040
|
|
|
1920
2041
|
// A2A-50: Delete confirm dialog — cancel handler
|
|
@@ -2310,9 +2431,12 @@ const tabLoaders = {
|
|
|
2310
2431
|
contacts: loadContacts,
|
|
2311
2432
|
calls: loadCalls,
|
|
2312
2433
|
logs: () => { loadLogs(); loadLogStats(); },
|
|
2313
|
-
// A2A-48: Load fresh settings data when switching to Permissions
|
|
2314
|
-
//
|
|
2315
|
-
permissions:
|
|
2434
|
+
// A2A-48/A2A-61: Load fresh settings data when switching to Permissions,
|
|
2435
|
+
// but skip polling refresh while local drag/create/delete saves are pending.
|
|
2436
|
+
permissions: () => {
|
|
2437
|
+
if (hasPendingTierSaves()) return Promise.resolve();
|
|
2438
|
+
return loadSettings();
|
|
2439
|
+
},
|
|
2316
2440
|
invites: loadInvites,
|
|
2317
2441
|
health: loadHealth,
|
|
2318
2442
|
// A2A-50: Settings tab loads dashboard status, auto-update, and callbook data
|
|
@@ -981,6 +981,10 @@ table tbody tr:hover td {
|
|
|
981
981
|
border-color: rgba(255, 255, 255, 0.18);
|
|
982
982
|
}
|
|
983
983
|
|
|
984
|
+
.sidebar-item.dragging {
|
|
985
|
+
opacity: 0.5;
|
|
986
|
+
}
|
|
987
|
+
|
|
984
988
|
/* A2A-48: Active items in the sidebar are dimmed with dashed border and
|
|
985
989
|
non-draggable to indicate they are already in the active zone. */
|
|
986
990
|
.sidebar-item.active-in-zone {
|
package/src/lib/config.js
CHANGED
|
@@ -246,7 +246,15 @@ const DEFAULT_CONFIG = {
|
|
|
246
246
|
allowMajor: false,
|
|
247
247
|
lastGoodVersion: null
|
|
248
248
|
},
|
|
249
|
-
|
|
249
|
+
|
|
250
|
+
// A2A-63: Retention policy defaults for database lifecycle management
|
|
251
|
+
retention: {
|
|
252
|
+
conversations_days: 90,
|
|
253
|
+
logs_days: 30,
|
|
254
|
+
compress_after_days: 7,
|
|
255
|
+
token_expiry_grace_days: 30
|
|
256
|
+
},
|
|
257
|
+
|
|
250
258
|
// Timestamps
|
|
251
259
|
createdAt: null,
|
|
252
260
|
updatedAt: null
|
|
@@ -442,6 +450,22 @@ class A2AConfig {
|
|
|
442
450
|
return next;
|
|
443
451
|
}
|
|
444
452
|
|
|
453
|
+
// A2A-63: Retention config accessor — returns merged defaults when
|
|
454
|
+
// the retention section is missing or partially present in the config file.
|
|
455
|
+
// Does NOT write defaults to disk automatically.
|
|
456
|
+
getRetention() {
|
|
457
|
+
const defaults = DEFAULT_CONFIG.retention;
|
|
458
|
+
const current = (this.config && typeof this.config.retention === 'object' && this.config.retention)
|
|
459
|
+
? this.config.retention
|
|
460
|
+
: {};
|
|
461
|
+
return {
|
|
462
|
+
conversations_days: Number.isFinite(current.conversations_days) ? current.conversations_days : defaults.conversations_days,
|
|
463
|
+
logs_days: Number.isFinite(current.logs_days) ? current.logs_days : defaults.logs_days,
|
|
464
|
+
compress_after_days: Number.isFinite(current.compress_after_days) ? current.compress_after_days : defaults.compress_after_days,
|
|
465
|
+
token_expiry_grace_days: Number.isFinite(current.token_expiry_grace_days) ? current.token_expiry_grace_days : defaults.token_expiry_grace_days
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
445
469
|
// Export for sharing (strips private_key to prevent leakage — A2A-52)
|
|
446
470
|
export() {
|
|
447
471
|
const { private_key, ...agentPublic } = this.config.agent || {};
|
package/src/lib/conversations.js
CHANGED
|
@@ -16,6 +16,8 @@ const DEFAULT_CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
|
|
|
16
16
|
|
|
17
17
|
const DB_FILENAME = 'a2a-conversations.db';
|
|
18
18
|
const logger = createLogger({ component: 'a2a.conversations' });
|
|
19
|
+
// A2A-63: Dedicated cleanup logger for retention pruning operations
|
|
20
|
+
const cleanupLogger = createLogger({ component: 'a2a.cleanup' });
|
|
19
21
|
|
|
20
22
|
class ConversationStore {
|
|
21
23
|
constructor(configDir = DEFAULT_CONFIG_DIR, options = {}) {
|
|
@@ -573,6 +575,135 @@ class ConversationStore {
|
|
|
573
575
|
return { compressed, total: messages.length };
|
|
574
576
|
}
|
|
575
577
|
|
|
578
|
+
/**
|
|
579
|
+
* A2A-63: Prune old concluded/timeout conversations and their messages.
|
|
580
|
+
*
|
|
581
|
+
* Pipeline:
|
|
582
|
+
* 1. Compress messages in the compress window (compress_after_days..conversations_days)
|
|
583
|
+
* 2. Delete messages belonging to expired conversations
|
|
584
|
+
* 3. Delete the expired conversations themselves
|
|
585
|
+
* 4. VACUUM only when >100 total rows deleted
|
|
586
|
+
*
|
|
587
|
+
* Active conversations are NEVER deleted regardless of age.
|
|
588
|
+
*
|
|
589
|
+
* @param {object} options
|
|
590
|
+
* @param {number} [options.conversations_days=90] - Delete concluded/timeout conversations older than this
|
|
591
|
+
* @param {number} [options.compress_after_days=7] - Compress messages older than this
|
|
592
|
+
* @returns {{ compressed: number, deletedMessages: number, deletedConversations: number, vacuumed: boolean }}
|
|
593
|
+
*/
|
|
594
|
+
pruneOld(options = {}) {
|
|
595
|
+
const db = this._initDb();
|
|
596
|
+
if (!db) {
|
|
597
|
+
return {
|
|
598
|
+
compressed: 0,
|
|
599
|
+
deletedMessages: 0,
|
|
600
|
+
deletedConversations: 0,
|
|
601
|
+
vacuumed: false,
|
|
602
|
+
error: this._dbError
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const conversationsDays = Number.isFinite(options.conversations_days)
|
|
607
|
+
? options.conversations_days
|
|
608
|
+
: 90;
|
|
609
|
+
const compressAfterDays = Number.isFinite(options.compress_after_days)
|
|
610
|
+
? options.compress_after_days
|
|
611
|
+
: 7;
|
|
612
|
+
|
|
613
|
+
// Step 1: Compress messages in the compress window before deletion
|
|
614
|
+
const compressResult = this.compressOldMessages(compressAfterDays);
|
|
615
|
+
|
|
616
|
+
// Step 2: Find expired conversations (concluded or timeout, older than retention threshold)
|
|
617
|
+
// A2A-63: Only prune conversations with a non-NULL ended_at that is older than the threshold.
|
|
618
|
+
// Active conversations have NULL ended_at and are never touched.
|
|
619
|
+
const retentionThreshold = new Date(
|
|
620
|
+
Date.now() - conversationsDays * 24 * 60 * 60 * 1000
|
|
621
|
+
).toISOString();
|
|
622
|
+
|
|
623
|
+
const expiredConvIds = db.prepare(`
|
|
624
|
+
SELECT id FROM conversations
|
|
625
|
+
WHERE status IN ('concluded', 'timeout')
|
|
626
|
+
AND ended_at IS NOT NULL
|
|
627
|
+
AND ended_at < ?
|
|
628
|
+
`).all(retentionThreshold).map(row => row.id);
|
|
629
|
+
|
|
630
|
+
let deletedMessages = 0;
|
|
631
|
+
let deletedConversations = 0;
|
|
632
|
+
|
|
633
|
+
if (expiredConvIds.length > 0) {
|
|
634
|
+
// A2A-63: Delete messages BEFORE their parent conversations (foreign key safety)
|
|
635
|
+
const deleteMsgs = db.prepare(
|
|
636
|
+
'DELETE FROM messages WHERE conversation_id = ?'
|
|
637
|
+
);
|
|
638
|
+
const deleteConv = db.prepare(
|
|
639
|
+
'DELETE FROM conversations WHERE id = ?'
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
const pruneTransaction = db.transaction((ids) => {
|
|
643
|
+
for (const id of ids) {
|
|
644
|
+
const msgResult = deleteMsgs.run(id);
|
|
645
|
+
deletedMessages += msgResult.changes;
|
|
646
|
+
const convResult = deleteConv.run(id);
|
|
647
|
+
deletedConversations += convResult.changes;
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
pruneTransaction(expiredConvIds);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Step 3: VACUUM only when >100 total rows deleted
|
|
655
|
+
const totalDeleted = deletedMessages + deletedConversations;
|
|
656
|
+
let vacuumed = false;
|
|
657
|
+
if (totalDeleted > 100) {
|
|
658
|
+
try {
|
|
659
|
+
db.exec('VACUUM');
|
|
660
|
+
vacuumed = true;
|
|
661
|
+
} catch (_) {
|
|
662
|
+
// A2A-63: Best effort — VACUUM can fail if another connection holds a lock
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Step 4: Log results
|
|
667
|
+
cleanupLogger.info('Conversation retention prune completed', {
|
|
668
|
+
event: 'conversations_pruned',
|
|
669
|
+
data: {
|
|
670
|
+
compressed: compressResult.compressed,
|
|
671
|
+
deleted_messages: deletedMessages,
|
|
672
|
+
deleted_conversations: deletedConversations,
|
|
673
|
+
vacuumed,
|
|
674
|
+
retention_days: conversationsDays,
|
|
675
|
+
compress_after_days: compressAfterDays
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
return {
|
|
680
|
+
compressed: compressResult.compressed,
|
|
681
|
+
deletedMessages,
|
|
682
|
+
deletedConversations,
|
|
683
|
+
vacuumed
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* A2A-63: Get database row counts for monitoring.
|
|
689
|
+
*
|
|
690
|
+
* @returns {{ conversations: number, messages: number }}
|
|
691
|
+
*/
|
|
692
|
+
getDatabaseStats() {
|
|
693
|
+
const db = this._initDb();
|
|
694
|
+
if (!db) {
|
|
695
|
+
return { conversations: 0, messages: 0 };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const convCount = db.prepare('SELECT COUNT(*) AS count FROM conversations').get();
|
|
699
|
+
const msgCount = db.prepare('SELECT COUNT(*) AS count FROM messages').get();
|
|
700
|
+
|
|
701
|
+
return {
|
|
702
|
+
conversations: convCount ? convCount.count : 0,
|
|
703
|
+
messages: msgCount ? msgCount.count : 0
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
576
707
|
/**
|
|
577
708
|
* Get conversation context for retrieval (summary + recent messages)
|
|
578
709
|
*/
|
package/src/lib/logger.js
CHANGED
|
@@ -200,6 +200,8 @@ class LogStore {
|
|
|
200
200
|
trace_id, conversation_id, token_id, request_id, error_code, status_code, hint, data
|
|
201
201
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
202
202
|
`);
|
|
203
|
+
// A2A-64: Prepared statement for time-based retention pruning
|
|
204
|
+
this.pruneStmt = this.db.prepare('DELETE FROM logs WHERE timestamp < ?');
|
|
203
205
|
}
|
|
204
206
|
|
|
205
207
|
isAvailable() {
|
|
@@ -231,6 +233,19 @@ class LogStore {
|
|
|
231
233
|
entry.hint,
|
|
232
234
|
dataText
|
|
233
235
|
);
|
|
236
|
+
|
|
237
|
+
// A2A-64: Auto-prune on every 1000th write (dashboard-events.js pattern).
|
|
238
|
+
// Best effort — prune failures must not affect write operations.
|
|
239
|
+
this._writeCount = (this._writeCount || 0) + 1;
|
|
240
|
+
if (this._writeCount % 1000 === 0 && !this._pruning) {
|
|
241
|
+
try {
|
|
242
|
+
const threshold = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
243
|
+
this.pruneStmt.run(threshold);
|
|
244
|
+
} catch (_) {
|
|
245
|
+
// A2A-64: Best effort — silent catch like dashboard-events.js:149
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
234
249
|
return true;
|
|
235
250
|
} catch (err) {
|
|
236
251
|
this._dbError = err.message || 'failed_to_write_log_entry';
|
|
@@ -409,6 +424,72 @@ class LogStore {
|
|
|
409
424
|
this.db = null;
|
|
410
425
|
}
|
|
411
426
|
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* A2A-64: Delete log entries older than the retention period.
|
|
430
|
+
*
|
|
431
|
+
* @param {object} [options]
|
|
432
|
+
* @param {number} [options.days=30] - Retention period in days
|
|
433
|
+
* @returns {{ deleted: number }}
|
|
434
|
+
*/
|
|
435
|
+
pruneOld(options = {}) {
|
|
436
|
+
const db = this._initDb();
|
|
437
|
+
if (!db) return { deleted: 0 };
|
|
438
|
+
|
|
439
|
+
const days = Number.isFinite(options.days) ? options.days : 30;
|
|
440
|
+
const threshold = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
441
|
+
|
|
442
|
+
// A2A-64: Set _pruning flag to prevent auto-prune recursion if the
|
|
443
|
+
// cleanup logger writes back to this same store.
|
|
444
|
+
this._pruning = true;
|
|
445
|
+
let deleted = 0;
|
|
446
|
+
try {
|
|
447
|
+
const result = this.pruneStmt.run(threshold);
|
|
448
|
+
deleted = result.changes;
|
|
449
|
+
|
|
450
|
+
// A2A-64: Only VACUUM after bulk deletions (>100 rows) to avoid unnecessary I/O
|
|
451
|
+
if (deleted > 100) {
|
|
452
|
+
db.exec('VACUUM');
|
|
453
|
+
}
|
|
454
|
+
} finally {
|
|
455
|
+
this._pruning = false;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// A2A-64: Log prune results AFTER prune completes to avoid recursion.
|
|
459
|
+
// Use a Logger instance with stdout:false for the cleanup component.
|
|
460
|
+
try {
|
|
461
|
+
const cleanupLogger = new Logger(this, { component: 'a2a.cleanup', stdout: true, minLevel: 'info' });
|
|
462
|
+
cleanupLogger.info(`Pruned ${deleted} log entries older than ${days} days`, {
|
|
463
|
+
event: 'logs_pruned',
|
|
464
|
+
data: { deleted, days, threshold }
|
|
465
|
+
});
|
|
466
|
+
} catch (_) {
|
|
467
|
+
// A2A-64: Best effort — logging about prune results must not throw
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return { deleted };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* A2A-64: Return database-level stats for monitoring.
|
|
475
|
+
* Complements the existing stats() method which provides level/component breakdowns.
|
|
476
|
+
*
|
|
477
|
+
* @returns {{ total: number, oldest_entry: string|null, newest_entry: string|null }}
|
|
478
|
+
*/
|
|
479
|
+
getDatabaseStats() {
|
|
480
|
+
const db = this._initDb();
|
|
481
|
+
if (!db) return { total: 0, oldest_entry: null, newest_entry: null };
|
|
482
|
+
|
|
483
|
+
const row = db.prepare(
|
|
484
|
+
'SELECT COUNT(*) AS total, MIN(timestamp) AS oldest, MAX(timestamp) AS newest FROM logs'
|
|
485
|
+
).get();
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
total: row?.total || 0,
|
|
489
|
+
oldest_entry: row?.oldest || null,
|
|
490
|
+
newest_entry: row?.newest || null
|
|
491
|
+
};
|
|
492
|
+
}
|
|
412
493
|
}
|
|
413
494
|
|
|
414
495
|
class Logger {
|
|
@@ -570,9 +651,25 @@ function closeAllLoggerStores() {
|
|
|
570
651
|
storeCache.clear();
|
|
571
652
|
}
|
|
572
653
|
|
|
654
|
+
// A2A-65: Prune old entries from all cached logger stores.
|
|
655
|
+
// Best effort — individual store failures are caught and logged.
|
|
656
|
+
function pruneAllLoggerStores(options = {}) {
|
|
657
|
+
const results = [];
|
|
658
|
+
for (const store of storeCache.values()) {
|
|
659
|
+
try {
|
|
660
|
+
results.push(store.pruneOld(options));
|
|
661
|
+
} catch (_) {
|
|
662
|
+
// A2A-65: Best effort — one store failure must not block others
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return results;
|
|
666
|
+
}
|
|
667
|
+
|
|
573
668
|
module.exports = {
|
|
574
669
|
LOG_DB_FILENAME,
|
|
670
|
+
LogStore,
|
|
575
671
|
createLogger,
|
|
576
672
|
createTraceId,
|
|
577
|
-
closeAllLoggerStores
|
|
673
|
+
closeAllLoggerStores,
|
|
674
|
+
pruneAllLoggerStores
|
|
578
675
|
};
|
package/src/lib/tokens.js
CHANGED
|
@@ -383,6 +383,73 @@ class TokenStore {
|
|
|
383
383
|
return { success: true, record };
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
+
/**
|
|
387
|
+
* A2A-65: Remove expired and old-revoked tokens from the store.
|
|
388
|
+
*
|
|
389
|
+
* - Tokens expired for >1 hour are removed (grace period for in-flight calls)
|
|
390
|
+
* - Tokens revoked >token_expiry_grace_days ago are removed
|
|
391
|
+
* - Valid and recently-expired tokens are preserved
|
|
392
|
+
*
|
|
393
|
+
* @param {object} [options]
|
|
394
|
+
* @param {number} [options.token_expiry_grace_days=30] - Days after revocation before removal
|
|
395
|
+
* @returns {{ removed_expired: number, removed_revoked: number }}
|
|
396
|
+
*/
|
|
397
|
+
cleanupExpired(options = {}) {
|
|
398
|
+
const graceDays = Number.isFinite(options.token_expiry_grace_days)
|
|
399
|
+
? options.token_expiry_grace_days
|
|
400
|
+
: 30;
|
|
401
|
+
|
|
402
|
+
const db = this._load();
|
|
403
|
+
const now = Date.now();
|
|
404
|
+
const oneHourMs = 60 * 60 * 1000;
|
|
405
|
+
const gracePeriodMs = graceDays * 24 * 60 * 60 * 1000;
|
|
406
|
+
|
|
407
|
+
let removed_expired = 0;
|
|
408
|
+
let removed_revoked = 0;
|
|
409
|
+
|
|
410
|
+
const original = db.tokens.length;
|
|
411
|
+
|
|
412
|
+
db.tokens = db.tokens.filter(token => {
|
|
413
|
+
// Remove tokens expired for > 1 hour
|
|
414
|
+
if (token.expires_at) {
|
|
415
|
+
const expiresAt = new Date(token.expires_at).getTime();
|
|
416
|
+
if (expiresAt < now - oneHourMs) {
|
|
417
|
+
removed_expired++;
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Remove tokens revoked > graceDays ago
|
|
423
|
+
if (token.revoked && token.revoked_at) {
|
|
424
|
+
const revokedAt = new Date(token.revoked_at).getTime();
|
|
425
|
+
if (revokedAt < now - gracePeriodMs) {
|
|
426
|
+
removed_revoked++;
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return true;
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Only write if something changed
|
|
435
|
+
if (db.tokens.length < original) {
|
|
436
|
+
this._save(db);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// A2A-65: Log cleanup results (best effort)
|
|
440
|
+
try {
|
|
441
|
+
const cleanupLogger = require('./logger').createLogger({ component: 'a2a.cleanup' });
|
|
442
|
+
cleanupLogger.info('Token cleanup completed', {
|
|
443
|
+
event: 'tokens_cleaned',
|
|
444
|
+
data: { removed_expired, removed_revoked, remaining: db.tokens.length, grace_days: graceDays }
|
|
445
|
+
});
|
|
446
|
+
} catch (_) {
|
|
447
|
+
// Best effort
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return { removed_expired, removed_revoked };
|
|
451
|
+
}
|
|
452
|
+
|
|
386
453
|
/**
|
|
387
454
|
* Add a remote agent endpoint (contact)
|
|
388
455
|
* Note: Token is encrypted at rest using a derived key
|
package/src/server.js
CHANGED
|
@@ -21,7 +21,7 @@ const {
|
|
|
21
21
|
extractCollaborationState
|
|
22
22
|
} = require('./lib/prompt-template');
|
|
23
23
|
const { findAvailablePort } = require('./lib/port-scanner');
|
|
24
|
-
const { createLogger, closeAllLoggerStores } = require('./lib/logger');
|
|
24
|
+
const { createLogger, closeAllLoggerStores, pruneAllLoggerStores } = require('./lib/logger');
|
|
25
25
|
const { writePidFile, removePidFile } = require('./lib/pid-file');
|
|
26
26
|
const { buildUnifiedSummaryPrompt } = require('./lib/summary-prompt');
|
|
27
27
|
const { A2AConfig } = require('./lib/config');
|
|
@@ -1019,6 +1019,67 @@ async function startServer() {
|
|
|
1019
1019
|
});
|
|
1020
1020
|
writePidFile(process.pid);
|
|
1021
1021
|
|
|
1022
|
+
// A2A-65: Run retention cleanup on startup (best effort — failures must not prevent server startup)
|
|
1023
|
+
try {
|
|
1024
|
+
const retention = config.getRetention();
|
|
1025
|
+
|
|
1026
|
+
// Conversations retention
|
|
1027
|
+
const convStore = getServerConvStore();
|
|
1028
|
+
if (convStore) {
|
|
1029
|
+
try {
|
|
1030
|
+
const convResult = convStore.pruneOld({
|
|
1031
|
+
conversations_days: retention.conversations_days,
|
|
1032
|
+
compress_after_days: retention.compress_after_days
|
|
1033
|
+
});
|
|
1034
|
+
logger.info('Startup retention: conversations pruned', {
|
|
1035
|
+
event: 'startup_retention_conversations',
|
|
1036
|
+
data: convResult
|
|
1037
|
+
});
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
logger.warn('Startup retention: conversations prune failed', {
|
|
1040
|
+
event: 'startup_retention_conversations_failed',
|
|
1041
|
+
error: err
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Logger retention
|
|
1047
|
+
try {
|
|
1048
|
+
const logResults = pruneAllLoggerStores({ days: retention.logs_days });
|
|
1049
|
+
const totalDeleted = logResults.reduce((sum, r) => sum + (r.deleted || 0), 0);
|
|
1050
|
+
logger.info('Startup retention: logs pruned', {
|
|
1051
|
+
event: 'startup_retention_logs',
|
|
1052
|
+
data: { total_deleted: totalDeleted, stores_pruned: logResults.length }
|
|
1053
|
+
});
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
logger.warn('Startup retention: logs prune failed', {
|
|
1056
|
+
event: 'startup_retention_logs_failed',
|
|
1057
|
+
error: err
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Token retention
|
|
1062
|
+
try {
|
|
1063
|
+
const tokenResult = tokenStore.cleanupExpired({
|
|
1064
|
+
token_expiry_grace_days: retention.token_expiry_grace_days
|
|
1065
|
+
});
|
|
1066
|
+
logger.info('Startup retention: tokens cleaned', {
|
|
1067
|
+
event: 'startup_retention_tokens',
|
|
1068
|
+
data: tokenResult
|
|
1069
|
+
});
|
|
1070
|
+
} catch (err) {
|
|
1071
|
+
logger.warn('Startup retention: token cleanup failed', {
|
|
1072
|
+
event: 'startup_retention_tokens_failed',
|
|
1073
|
+
error: err
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
logger.warn('Startup retention failed', {
|
|
1078
|
+
event: 'startup_retention_failed',
|
|
1079
|
+
error: err
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1022
1083
|
if (!updateManager) {
|
|
1023
1084
|
const pkg = require('../package.json');
|
|
1024
1085
|
const restartFn = async () => {
|