a2acalling 0.6.68 → 0.6.70
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 +17 -0
- package/package.json +1 -1
- package/src/dashboard/public/app.js +22 -21
- package/src/routes/a2a.js +59 -6
- package/src/server.js +6 -0
package/.a2a-manifest.json
CHANGED
package/CONVENTIONS.md
CHANGED
|
@@ -66,6 +66,7 @@ All modules use CommonJS (`require`/`module.exports`). Each lib file exports a f
|
|
|
66
66
|
- Dark theme is the default; uses CSS custom properties for theming
|
|
67
67
|
- Sidebar navigation with tab switching (Contacts, Calls, Invites, Logs, Settings, Permissions, Health)
|
|
68
68
|
- Permissions tab uses tier cards with tool toggles and auto-save
|
|
69
|
+
- Drag-and-drop uses event delegation on stable parent containers (`.perm-sidebar` for sidebar items, zone containers for drop targets) — do NOT bind listeners directly to innerHTML-generated elements (A2A-61)
|
|
69
70
|
|
|
70
71
|
## Network Resilience (A2A-54)
|
|
71
72
|
|
|
@@ -85,6 +86,22 @@ Dashboard API integration tests follow the pattern in `test/integration/dashboar
|
|
|
85
86
|
- Call `loggerModule.closeAllLoggerStores()` in teardown to prevent SQLite handle leaks
|
|
86
87
|
- Pass `convStore` directly via `options.convStore` when testing calls endpoints
|
|
87
88
|
|
|
89
|
+
## Store Lifecycle (A2A-57)
|
|
90
|
+
|
|
91
|
+
All SQLite store classes (`ConversationStore`, `DashboardEventStore`, `CallbookStore`, `LoggerStore`) implement a `close()` method following this pattern:
|
|
92
|
+
```js
|
|
93
|
+
close() {
|
|
94
|
+
if (this.db) {
|
|
95
|
+
try { this.db.close(); } catch (_) {}
|
|
96
|
+
this.db = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
- `close()` must be idempotent (safe to call multiple times)
|
|
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
|
|
103
|
+
- Test teardown should call `store.close()` to prevent SQLite handle leaks
|
|
104
|
+
|
|
88
105
|
## Permission Tiers
|
|
89
106
|
|
|
90
107
|
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`.
|
package/package.json
CHANGED
|
@@ -1307,7 +1307,6 @@ function renderPermissions() {
|
|
|
1307
1307
|
renderTierWarnings(tier);
|
|
1308
1308
|
renderSidebarPreview(state.activeTierId);
|
|
1309
1309
|
renderSidebarLists(tier);
|
|
1310
|
-
bindSidebarDrag();
|
|
1311
1310
|
}
|
|
1312
1311
|
|
|
1313
1312
|
// A2A-48: Renders the inline "Preview as Caller" card in the right sidebar.
|
|
@@ -1479,22 +1478,6 @@ function autoSaveTier() {
|
|
|
1479
1478
|
}, 250);
|
|
1480
1479
|
}
|
|
1481
1480
|
|
|
1482
|
-
// A2A-48: Binds dragstart/dragend on sidebar items (re-created each render).
|
|
1483
|
-
// Zone listeners (dragover/dragleave/drop) are bound ONCE in
|
|
1484
|
-
// bindPermissionsActions() to avoid listener accumulation — the zone
|
|
1485
|
-
// containers persist across renders while only their innerHTML changes.
|
|
1486
|
-
function bindSidebarDrag() {
|
|
1487
|
-
document.querySelectorAll('.sidebar-item[draggable="true"]').forEach(item => {
|
|
1488
|
-
item.addEventListener('dragstart', (e) => {
|
|
1489
|
-
const itemType = item.dataset.itemType || 'topic';
|
|
1490
|
-
const name = item.dataset.sidebarTopic || item.dataset.sidebarGoal || '';
|
|
1491
|
-
const desc = item.dataset.description || '';
|
|
1492
|
-
e.dataTransfer.setData('application/json', JSON.stringify({ name, description: desc, type: itemType }));
|
|
1493
|
-
item.style.opacity = '0.5';
|
|
1494
|
-
});
|
|
1495
|
-
item.addEventListener('dragend', () => { item.style.opacity = ''; });
|
|
1496
|
-
});
|
|
1497
|
-
}
|
|
1498
1481
|
|
|
1499
1482
|
// A2A-50: Drop handler for active topic/goal zones. Routes items to the
|
|
1500
1483
|
// CORRECT zone based on data.type (not the zone they were dropped on).
|
|
@@ -1537,9 +1520,6 @@ function handleZoneDrop(zone, e) {
|
|
|
1537
1520
|
const freshTier = (state.settings?.tiers || []).find(t => t.id === state.activeTierId);
|
|
1538
1521
|
if (freshTier) {
|
|
1539
1522
|
renderSidebarLists(freshTier);
|
|
1540
|
-
// A2A-51: Re-bind drag listeners after innerHTML replacement in renderSidebarLists().
|
|
1541
|
-
// Without this, sidebar items lose dragstart/dragend handlers after the first drop.
|
|
1542
|
-
bindSidebarDrag();
|
|
1543
1523
|
}
|
|
1544
1524
|
}, 300);
|
|
1545
1525
|
}
|
|
@@ -1803,7 +1783,7 @@ function bindPermissionsActions() {
|
|
|
1803
1783
|
// A2A-48: Drop zone listeners — bound ONCE here because the zone containers
|
|
1804
1784
|
// (#active-topics-zone, #active-goals-zone) persist across renders. Only
|
|
1805
1785
|
// their innerHTML is replaced by renderActiveTopics/renderActiveGoals.
|
|
1806
|
-
// Binding
|
|
1786
|
+
// Binding per-element would cause listener accumulation.
|
|
1807
1787
|
// A2A-51: Uses dragenter/dragleave counter to prevent flickering when
|
|
1808
1788
|
// cursor moves over child elements (cards, placeholder) inside the zone.
|
|
1809
1789
|
const topicZone = document.getElementById('active-topics-zone');
|
|
@@ -1817,6 +1797,27 @@ function bindPermissionsActions() {
|
|
|
1817
1797
|
zone.addEventListener('drop', (e) => { dragCounter = 0; handleZoneDrop(zone, e); });
|
|
1818
1798
|
});
|
|
1819
1799
|
|
|
1800
|
+
// A2A-61: Delegated drag listeners on .perm-sidebar — survives innerHTML
|
|
1801
|
+
// rewrites in renderSidebarLists(). Replaces bindSidebarDrag() which bound
|
|
1802
|
+
// directly to elements destroyed on each render, causing the drag-drop
|
|
1803
|
+
// regression in A2A-41/48/50/51.
|
|
1804
|
+
const sidebar = document.querySelector('.perm-sidebar');
|
|
1805
|
+
if (sidebar) {
|
|
1806
|
+
sidebar.addEventListener('dragstart', (e) => {
|
|
1807
|
+
const item = e.target.closest('.sidebar-item[draggable="true"]');
|
|
1808
|
+
if (!item) return;
|
|
1809
|
+
const itemType = item.dataset.itemType || 'topic';
|
|
1810
|
+
const name = item.dataset.sidebarTopic || item.dataset.sidebarGoal || '';
|
|
1811
|
+
const desc = item.dataset.description || '';
|
|
1812
|
+
e.dataTransfer.setData('application/json', JSON.stringify({ name, description: desc, type: itemType }));
|
|
1813
|
+
item.style.opacity = '0.5';
|
|
1814
|
+
});
|
|
1815
|
+
sidebar.addEventListener('dragend', (e) => {
|
|
1816
|
+
const item = e.target.closest('.sidebar-item[draggable="true"]');
|
|
1817
|
+
if (item) item.style.opacity = '';
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1820
1821
|
// A2A-48: Tool toggle change — auto-save and update card styling
|
|
1821
1822
|
panel.addEventListener('change', (e) => {
|
|
1822
1823
|
const toggle = e.target.closest('#tool-toggles .toggle-switch input');
|
package/src/routes/a2a.js
CHANGED
|
@@ -57,6 +57,10 @@ function getCallMonitor(options = {}) {
|
|
|
57
57
|
// For production: use Redis or persistent store
|
|
58
58
|
const rateLimits = new Map();
|
|
59
59
|
|
|
60
|
+
// Rate limit eviction constants
|
|
61
|
+
const RATE_LIMIT_MAX_ENTRIES = 1000;
|
|
62
|
+
const RATE_LIMIT_STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
63
|
+
|
|
60
64
|
// Constants
|
|
61
65
|
const MAX_MESSAGE_LENGTH = 10000; // 10KB max message
|
|
62
66
|
const MAX_TIMEOUT_SECONDS = 300; // 5 min max timeout
|
|
@@ -104,6 +108,20 @@ function normalizeRequestMetadata(req) {
|
|
|
104
108
|
};
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Timing-safe comparison of two token strings.
|
|
113
|
+
* Returns true if tokens match, false otherwise.
|
|
114
|
+
* Short-circuits (non-timing-safe) only when a value is missing or empty,
|
|
115
|
+
* which is acceptable since the absence of a token is not secret.
|
|
116
|
+
*/
|
|
117
|
+
function timingSafeTokenEqual(a, b) {
|
|
118
|
+
if (!a || !b) return false;
|
|
119
|
+
const bufA = Buffer.from(String(a));
|
|
120
|
+
const bufB = Buffer.from(String(b));
|
|
121
|
+
if (bufA.length !== bufB.length) return false;
|
|
122
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
123
|
+
}
|
|
124
|
+
|
|
107
125
|
function checkRateLimit(tokenId, limits = { minute: 10, hour: 100, day: 1000 }) {
|
|
108
126
|
const now = Date.now();
|
|
109
127
|
const minute = Math.floor(now / 60000);
|
|
@@ -141,6 +159,34 @@ function checkRateLimit(tokenId, limits = { minute: 10, hour: 100, day: 1000 })
|
|
|
141
159
|
state.hour.count++;
|
|
142
160
|
state.day.count++;
|
|
143
161
|
|
|
162
|
+
// Evict stale entries when the Map exceeds the size threshold
|
|
163
|
+
if (rateLimits.size > RATE_LIMIT_MAX_ENTRIES) {
|
|
164
|
+
const staleThreshold = now - RATE_LIMIT_STALE_MS;
|
|
165
|
+
let evicted = 0;
|
|
166
|
+
for (const [key, entry] of rateLimits) {
|
|
167
|
+
// An entry is stale if all three bucket timestamps are older than 24h
|
|
168
|
+
const latestBucket = Math.max(
|
|
169
|
+
entry.minute.bucket * 60000,
|
|
170
|
+
entry.hour.bucket * 3600000,
|
|
171
|
+
entry.day.bucket * 86400000
|
|
172
|
+
);
|
|
173
|
+
if (latestBucket < staleThreshold) {
|
|
174
|
+
rateLimits.delete(key);
|
|
175
|
+
evicted++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// If no stale entries found, evict the oldest (first-inserted) entries
|
|
179
|
+
if (evicted === 0) {
|
|
180
|
+
const excess = rateLimits.size - RATE_LIMIT_MAX_ENTRIES;
|
|
181
|
+
let removed = 0;
|
|
182
|
+
for (const key of rateLimits.keys()) {
|
|
183
|
+
if (removed >= excess) break;
|
|
184
|
+
rateLimits.delete(key);
|
|
185
|
+
removed++;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
144
190
|
return { limited: false };
|
|
145
191
|
}
|
|
146
192
|
|
|
@@ -758,7 +804,7 @@ function createRoutes(options = {}) {
|
|
|
758
804
|
message: 'Set A2A_ADMIN_TOKEN to access conversation admin routes from non-local addresses'
|
|
759
805
|
});
|
|
760
806
|
}
|
|
761
|
-
if (adminToken
|
|
807
|
+
if (!timingSafeTokenEqual(adminToken, expected)) {
|
|
762
808
|
return res.status(401).json({ error: 'unauthorized' });
|
|
763
809
|
}
|
|
764
810
|
}
|
|
@@ -773,7 +819,7 @@ function createRoutes(options = {}) {
|
|
|
773
819
|
const conversations = convStore.listConversations({
|
|
774
820
|
contactId: contact_id,
|
|
775
821
|
status,
|
|
776
|
-
limit: parseInt(limit),
|
|
822
|
+
limit: Math.min(100, Math.max(1, Number.parseInt(String(limit), 10) || 20)),
|
|
777
823
|
includeMessages: false
|
|
778
824
|
});
|
|
779
825
|
|
|
@@ -794,7 +840,7 @@ function createRoutes(options = {}) {
|
|
|
794
840
|
message: 'Set A2A_ADMIN_TOKEN to access conversation admin routes from non-local addresses'
|
|
795
841
|
});
|
|
796
842
|
}
|
|
797
|
-
if (adminToken
|
|
843
|
+
if (!timingSafeTokenEqual(adminToken, expected)) {
|
|
798
844
|
return res.status(401).json({ error: 'unauthorized' });
|
|
799
845
|
}
|
|
800
846
|
}
|
|
@@ -806,8 +852,8 @@ function createRoutes(options = {}) {
|
|
|
806
852
|
|
|
807
853
|
const { recent_messages = 10 } = req.query;
|
|
808
854
|
const context = convStore.getConversationContext(
|
|
809
|
-
req.params.id,
|
|
810
|
-
parseInt(recent_messages)
|
|
855
|
+
req.params.id,
|
|
856
|
+
Math.min(50, Math.max(1, Number.parseInt(String(recent_messages), 10) || 10))
|
|
811
857
|
);
|
|
812
858
|
|
|
813
859
|
if (!context) {
|
|
@@ -830,4 +876,11 @@ async function defaultMessageHandler(message, context, options) {
|
|
|
830
876
|
};
|
|
831
877
|
}
|
|
832
878
|
|
|
833
|
-
module.exports = {
|
|
879
|
+
module.exports = {
|
|
880
|
+
createRoutes,
|
|
881
|
+
checkRateLimit,
|
|
882
|
+
timingSafeTokenEqual,
|
|
883
|
+
// Exposed for testing only
|
|
884
|
+
_rateLimits: rateLimits,
|
|
885
|
+
_RATE_LIMIT_MAX_ENTRIES: RATE_LIMIT_MAX_ENTRIES
|
|
886
|
+
};
|
package/src/server.js
CHANGED
|
@@ -27,6 +27,7 @@ const { buildUnifiedSummaryPrompt } = require('./lib/summary-prompt');
|
|
|
27
27
|
const { A2AConfig } = require('./lib/config');
|
|
28
28
|
const { UpdateManager } = require('./lib/update-manager');
|
|
29
29
|
const { DashboardEventStore } = require('./lib/dashboard-events');
|
|
30
|
+
const { CallbookStore } = require('./lib/callbook');
|
|
30
31
|
const { spawn } = require('child_process');
|
|
31
32
|
const { resolveTurnTimeoutMs } = require('./lib/turn-timeout');
|
|
32
33
|
|
|
@@ -69,6 +70,8 @@ const agentContext = loadAgentContext();
|
|
|
69
70
|
const tokenStore = new TokenStore();
|
|
70
71
|
const config = new A2AConfig();
|
|
71
72
|
const eventStore = new DashboardEventStore(tokenStore.configDir);
|
|
73
|
+
// A2A-59: Hoist CallbookStore to server.js so shutdown() can close it
|
|
74
|
+
const callbookStore = new CallbookStore(tokenStore.configDir);
|
|
72
75
|
const runtime = createRuntimeAdapter({
|
|
73
76
|
workspaceDir,
|
|
74
77
|
agentContext,
|
|
@@ -878,6 +881,7 @@ app.use('/api/a2a/dashboard', createDashboardApiRouter({
|
|
|
878
881
|
agentContext,
|
|
879
882
|
config,
|
|
880
883
|
eventStore,
|
|
884
|
+
callbookStore,
|
|
881
885
|
getUpdateManager: () => updateManager,
|
|
882
886
|
logger: logger.child({ component: 'a2a.dashboard' })
|
|
883
887
|
}));
|
|
@@ -1081,6 +1085,8 @@ async function startServer() {
|
|
|
1081
1085
|
try { serverConvStore.close(); } catch (_) {}
|
|
1082
1086
|
}
|
|
1083
1087
|
try { eventStore.close(); } catch (_) {}
|
|
1088
|
+
// A2A-59: Close CallbookStore to flush WAL on shutdown
|
|
1089
|
+
try { callbookStore.close(); } catch (_) {}
|
|
1084
1090
|
try { closeAllLoggerStores(); } catch (_) {}
|
|
1085
1091
|
server.close(() => process.exit(0));
|
|
1086
1092
|
// Force exit after 5s if connections won't close
|