a2acalling 0.6.50 → 0.6.51

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.
@@ -0,0 +1,205 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { EventEmitter } = require('events');
4
+
5
+ const DEFAULT_CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
6
+ process.env.OPENCLAW_CONFIG_DIR ||
7
+ path.join(process.env.HOME || '/tmp', '.config', 'openclaw');
8
+
9
+ const DB_FILENAME = 'a2a-events.db';
10
+ const DEFAULT_RETENTION_COUNT = 5000;
11
+
12
+ function nowIso() {
13
+ return new Date().toISOString();
14
+ }
15
+
16
+ class DashboardEventStore {
17
+ constructor(configDir = DEFAULT_CONFIG_DIR, options = {}) {
18
+ this.configDir = configDir;
19
+ this.dbPath = options.dbPath || path.join(configDir, DB_FILENAME);
20
+ this.retentionCount = Number.isFinite(options.retentionCount)
21
+ ? Math.max(100, Math.floor(options.retentionCount))
22
+ : DEFAULT_RETENTION_COUNT;
23
+ this.db = null;
24
+ this._dbError = null;
25
+ this._stmts = null;
26
+ this._emitter = new EventEmitter();
27
+ this._ensureDir();
28
+ }
29
+
30
+ _ensureDir() {
31
+ if (!fs.existsSync(this.configDir)) {
32
+ fs.mkdirSync(this.configDir, { recursive: true });
33
+ }
34
+ }
35
+
36
+ _initDb() {
37
+ if (this.db) return this.db;
38
+ if (this._dbError) return null;
39
+ try {
40
+ const Database = require('better-sqlite3');
41
+ this.db = new Database(this.dbPath);
42
+ try {
43
+ fs.chmodSync(this.dbPath, 0o600);
44
+ } catch (_) {
45
+ // Best effort.
46
+ }
47
+ this._migrate();
48
+ this._prepareStatements();
49
+ return this.db;
50
+ } catch (err) {
51
+ this._dbError = err && err.message ? err.message : 'failed_to_initialize_event_db';
52
+ return null;
53
+ }
54
+ }
55
+
56
+ _migrate() {
57
+ this.db.exec(`
58
+ PRAGMA journal_mode = WAL;
59
+
60
+ CREATE TABLE IF NOT EXISTS dashboard_events (
61
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
62
+ event_type TEXT NOT NULL,
63
+ created_at TEXT NOT NULL,
64
+ payload_json TEXT NOT NULL,
65
+ conversation_id TEXT,
66
+ contact_id TEXT
67
+ );
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_dashboard_events_created ON dashboard_events(created_at);
70
+ CREATE INDEX IF NOT EXISTS idx_dashboard_events_type ON dashboard_events(event_type);
71
+ CREATE INDEX IF NOT EXISTS idx_dashboard_events_conversation ON dashboard_events(conversation_id);
72
+ CREATE INDEX IF NOT EXISTS idx_dashboard_events_contact ON dashboard_events(contact_id);
73
+ `);
74
+ }
75
+
76
+ _prepareStatements() {
77
+ this._stmts = {
78
+ insertEvent: this.db.prepare(`
79
+ INSERT INTO dashboard_events (event_type, created_at, payload_json, conversation_id, contact_id)
80
+ VALUES (@event_type, @created_at, @payload_json, @conversation_id, @contact_id)
81
+ `),
82
+ listSince: this.db.prepare(`
83
+ SELECT id, event_type, created_at, payload_json, conversation_id, contact_id
84
+ FROM dashboard_events
85
+ WHERE id > @since_id
86
+ ORDER BY id ASC
87
+ LIMIT @limit
88
+ `),
89
+ listLatest: this.db.prepare(`
90
+ SELECT id, event_type, created_at, payload_json, conversation_id, contact_id
91
+ FROM dashboard_events
92
+ ORDER BY id DESC
93
+ LIMIT @limit
94
+ `),
95
+ prune: this.db.prepare(`
96
+ DELETE FROM dashboard_events
97
+ WHERE id NOT IN (
98
+ SELECT id FROM dashboard_events
99
+ ORDER BY id DESC
100
+ LIMIT @retention
101
+ )
102
+ `)
103
+ };
104
+ }
105
+
106
+ isAvailable() {
107
+ return Boolean(this._initDb());
108
+ }
109
+
110
+ getDbError() {
111
+ this._initDb();
112
+ return this._dbError;
113
+ }
114
+
115
+ emitEvent(eventType, payload = {}, meta = {}) {
116
+ const db = this._initDb();
117
+ if (!db) {
118
+ return { success: false, error: 'event_storage_unavailable', message: this._dbError };
119
+ }
120
+
121
+ const event_type = String(eventType || '').trim().slice(0, 80);
122
+ if (!event_type) {
123
+ return { success: false, error: 'event_type_required' };
124
+ }
125
+
126
+ const created_at = nowIso();
127
+ const row = {
128
+ event_type,
129
+ created_at,
130
+ payload_json: JSON.stringify(payload || {}),
131
+ conversation_id: meta && meta.conversationId ? String(meta.conversationId).slice(0, 120) : null,
132
+ contact_id: meta && meta.contactId ? String(meta.contactId).slice(0, 120) : null
133
+ };
134
+
135
+ const info = this._stmts.insertEvent.run(row);
136
+ const id = Number(info.lastInsertRowid);
137
+ const event = {
138
+ id,
139
+ type: event_type,
140
+ created_at,
141
+ conversation_id: row.conversation_id,
142
+ contact_id: row.contact_id,
143
+ payload: payload || {}
144
+ };
145
+
146
+ if (id % 100 === 0) {
147
+ try {
148
+ this._stmts.prune.run({ retention: this.retentionCount });
149
+ } catch (_) {
150
+ // Best effort.
151
+ }
152
+ }
153
+
154
+ this._emitter.emit('event', event);
155
+ return { success: true, event };
156
+ }
157
+
158
+ listSince(sinceId, options = {}) {
159
+ const db = this._initDb();
160
+ if (!db) return [];
161
+ const limit = Math.min(500, Math.max(1, Number.parseInt(String(options.limit || '200'), 10) || 200));
162
+ const parsedSince = Number.parseInt(String(sinceId || '0'), 10);
163
+ if (Number.isFinite(parsedSince) && parsedSince > 0) {
164
+ const rows = this._stmts.listSince.all({ since_id: parsedSince, limit });
165
+ return rows.map((row) => this._toEvent(row));
166
+ }
167
+ const rows = this._stmts.listLatest.all({ limit }).reverse();
168
+ return rows.map((row) => this._toEvent(row));
169
+ }
170
+
171
+ subscribe(listener) {
172
+ const safe = (event) => {
173
+ try {
174
+ listener(event);
175
+ } catch (_) {
176
+ // Keep stream robust.
177
+ }
178
+ };
179
+ this._emitter.on('event', safe);
180
+ return () => {
181
+ this._emitter.off('event', safe);
182
+ };
183
+ }
184
+
185
+ _toEvent(row) {
186
+ let payload = {};
187
+ try {
188
+ payload = row.payload_json ? JSON.parse(row.payload_json) : {};
189
+ } catch (_) {
190
+ payload = {};
191
+ }
192
+ return {
193
+ id: row.id,
194
+ type: row.event_type,
195
+ created_at: row.created_at,
196
+ conversation_id: row.conversation_id || null,
197
+ contact_id: row.contact_id || null,
198
+ payload
199
+ };
200
+ }
201
+ }
202
+
203
+ module.exports = {
204
+ DashboardEventStore
205
+ };
package/src/routes/a2a.js CHANGED
@@ -15,11 +15,14 @@ const { createLogger, createTraceId } = require('../lib/logger');
15
15
  // Lazy-load conversation store (optional dependency)
16
16
  let ConversationStore = null;
17
17
  let conversationStore = null;
18
- function getConversationStore() {
18
+ function getConversationStore(options = {}) {
19
19
  if (!ConversationStore) {
20
20
  try {
21
21
  ConversationStore = require('../lib/conversations').ConversationStore;
22
- conversationStore = new ConversationStore();
22
+ const configDir = options.configDir || undefined;
23
+ conversationStore = new ConversationStore(configDir, {
24
+ eventStore: options.eventStore || null
25
+ });
23
26
  if (!conversationStore.isAvailable()) {
24
27
  conversationStore = null;
25
28
  }
@@ -162,9 +165,13 @@ function createRoutes(options = {}) {
162
165
  const notifyOwner = options.notifyOwner || (() => Promise.resolve());
163
166
  const limits = options.rateLimits || { minute: 10, hour: 100, day: 1000 };
164
167
  const logger = options.logger || createLogger({ component: 'a2a.routes' });
168
+ const eventStore = options.eventStore || null;
165
169
 
166
170
  // Initialize conversation store and call monitor
167
- const convStore = getConversationStore();
171
+ const convStore = getConversationStore({
172
+ eventStore,
173
+ configDir: tokenStore.configDir
174
+ });
168
175
  const monitor = getCallMonitor({
169
176
  convStore,
170
177
  summarizer: options.summarizer,
@@ -367,6 +374,17 @@ function createRoutes(options = {}) {
367
374
  tokenId: validation.id,
368
375
  direction: 'inbound'
369
376
  });
377
+ if (isNewConversation && eventStore && eventStore.isAvailable && eventStore.isAvailable()) {
378
+ eventStore.emitEvent('call.inbound', {
379
+ conversation_id: a2aContext.conversation_id,
380
+ token_id: validation.id,
381
+ caller_name: sanitizedCaller.name || validation.name || null,
382
+ caller_owner: sanitizedCaller.owner || null
383
+ }, {
384
+ conversationId: a2aContext.conversation_id,
385
+ contactId: ensuredContact?.id || validation.id
386
+ });
387
+ }
370
388
 
371
389
  // Track activity for auto-conclude
372
390
  if (monitor) {
@@ -563,7 +581,10 @@ function createRoutes(options = {}) {
563
581
  }));
564
582
  }
565
583
 
566
- const convStore = getConversationStore();
584
+ const convStore = getConversationStore({
585
+ eventStore,
586
+ configDir: tokenStore.configDir
587
+ });
567
588
  if (!convStore) {
568
589
  return res.json(withTracePayload({ success: true, message: 'Conversation storage not enabled' }));
569
590
  }
@@ -18,6 +18,7 @@ const { A2AConfig } = require('../lib/config');
18
18
  const { loadManifest, saveManifest } = require('../lib/disclosure');
19
19
  const { resolveInviteHost } = require('../lib/invite-host');
20
20
  const { CallbookStore } = require('../lib/callbook');
21
+ const { DashboardEventStore } = require('../lib/dashboard-events');
21
22
  const { createLogger } = require('../lib/logger');
22
23
 
23
24
  const DASHBOARD_STATIC_DIR = path.join(__dirname, '..', 'dashboard', 'public');
@@ -192,11 +193,12 @@ function buildContext(options = {}) {
192
193
  const config = options.config || new A2AConfig();
193
194
  const logger = options.logger || createLogger({ component: 'a2a.dashboard' });
194
195
  const callbookStore = options.callbookStore || new CallbookStore(tokenStore.configDir);
196
+ const eventStore = options.eventStore || new DashboardEventStore(tokenStore.configDir);
195
197
  const agentContext = resolveAgentContext(options);
196
198
  let convStore = options.convStore || null;
197
199
  if (!convStore) {
198
200
  try {
199
- convStore = new ConversationStore();
201
+ convStore = new ConversationStore(tokenStore.configDir, { eventStore });
200
202
  if (!convStore.isAvailable()) {
201
203
  convStore = null;
202
204
  }
@@ -210,6 +212,7 @@ function buildContext(options = {}) {
210
212
  config,
211
213
  convStore,
212
214
  callbookStore,
215
+ eventStore,
213
216
  getUpdateManager: typeof options.getUpdateManager === 'function'
214
217
  ? options.getUpdateManager
215
218
  : (() => null),
@@ -444,6 +447,23 @@ function createDashboardApiRouter(options = {}) {
444
447
  const context = buildContext(options);
445
448
  router.use(express.json());
446
449
  const ensureDashboardAccess = makeEnsureDashboardAccess(context);
450
+ const writeSseEvent = (res, event) => {
451
+ const eventName = sanitizeString(event?.type || 'message', 80) || 'message';
452
+ const eventId = Number.parseInt(String(event?.id || ''), 10);
453
+ const payload = {
454
+ id: eventId || null,
455
+ type: eventName,
456
+ created_at: event?.created_at || new Date().toISOString(),
457
+ conversation_id: event?.conversation_id || null,
458
+ contact_id: event?.contact_id || null,
459
+ payload: event?.payload || {}
460
+ };
461
+ if (Number.isFinite(eventId) && eventId > 0) {
462
+ res.write(`id: ${eventId}\n`);
463
+ }
464
+ res.write(`event: ${eventName}\n`);
465
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
466
+ };
447
467
 
448
468
  // Callbook Remote: exchange a short-lived provisioning code for a long-lived session cookie.
449
469
  // This route must be reachable BEFORE dashboard access is established.
@@ -481,6 +501,14 @@ function createDashboardApiRouter(options = {}) {
481
501
  });
482
502
  res.setHeader('Set-Cookie', cookie);
483
503
 
504
+ if (context.eventStore && context.eventStore.isAvailable()) {
505
+ context.eventStore.emitEvent('invite.used', {
506
+ device_id: result.device?.id || null,
507
+ device_label: result.device?.label || null,
508
+ source: 'callbook_exchange'
509
+ });
510
+ }
511
+
484
512
  return res.json({
485
513
  success: true,
486
514
  device: result.device,
@@ -491,6 +519,72 @@ function createDashboardApiRouter(options = {}) {
491
519
  // All other dashboard API routes require owner access.
492
520
  router.use(ensureDashboardAccess);
493
521
 
522
+ router.get('/events', (req, res) => {
523
+ if (!context.eventStore || !context.eventStore.isAvailable()) {
524
+ return res.status(503).json({
525
+ success: false,
526
+ error: 'event_stream_unavailable',
527
+ message: context.eventStore ? context.eventStore.getDbError() : 'missing_event_store'
528
+ });
529
+ }
530
+
531
+ const lastIdHeader = req.headers['last-event-id'];
532
+ const since = sanitizeString(
533
+ req.query.since || lastIdHeader || req.query.last_event_id || '',
534
+ 32
535
+ );
536
+ const replayLimit = Math.min(500, Math.max(1, Number.parseInt(req.query.replay || '200', 10) || 200));
537
+
538
+ res.status(200);
539
+ res.setHeader('Content-Type', 'text/event-stream');
540
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
541
+ res.setHeader('Connection', 'keep-alive');
542
+ res.flushHeaders?.();
543
+
544
+ let lastSentId = Number.parseInt(String(since || '0'), 10);
545
+ if (!Number.isFinite(lastSentId) || lastSentId < 0) {
546
+ lastSentId = 0;
547
+ }
548
+
549
+ const pendingLive = [];
550
+ const unsubscribe = context.eventStore.subscribe((event) => {
551
+ if (!event || !Number.isFinite(event.id)) return;
552
+ if (event.id <= lastSentId) return;
553
+ pendingLive.push(event);
554
+ });
555
+
556
+ const replay = context.eventStore.listSince(since, { limit: replayLimit });
557
+ for (const row of replay) {
558
+ writeSseEvent(res, row);
559
+ if (Number.isFinite(row.id) && row.id > lastSentId) {
560
+ lastSentId = row.id;
561
+ }
562
+ }
563
+ while (pendingLive.length > 0) {
564
+ const row = pendingLive.shift();
565
+ if (!row || !Number.isFinite(row.id) || row.id <= lastSentId) continue;
566
+ writeSseEvent(res, row);
567
+ lastSentId = row.id;
568
+ }
569
+ res.write(': connected\n\n');
570
+
571
+ const liveUnsubscribe = context.eventStore.subscribe((event) => {
572
+ if (!event || !Number.isFinite(event.id) || event.id <= lastSentId) return;
573
+ writeSseEvent(res, event);
574
+ lastSentId = event.id;
575
+ });
576
+ const heartbeat = setInterval(() => {
577
+ res.write(`: heartbeat ${Date.now()}\n\n`);
578
+ }, 15000);
579
+
580
+ req.on('close', () => {
581
+ clearInterval(heartbeat);
582
+ unsubscribe();
583
+ liveUnsubscribe();
584
+ res.end();
585
+ });
586
+ });
587
+
494
588
  router.get('/status', async (req, res) => {
495
589
  context.logger.debug('Dashboard status requested', { event: 'dashboard_status' });
496
590
  const refreshIp = String(req.query.refresh_ip || 'false') === 'true';
@@ -1028,7 +1122,16 @@ function createDashboardApiRouter(options = {}) {
1028
1122
  const url = `a2a://${contact.host}/${contact.token}`;
1029
1123
  try {
1030
1124
  const result = await client.call(url, message, { conversationId, timeoutSeconds });
1125
+ const previousStatus = String(contact.status || '');
1031
1126
  context.tokenStore.updateContactStatus(contact.id, 'online');
1127
+ if (context.eventStore && context.eventStore.isAvailable() && previousStatus !== 'online') {
1128
+ context.eventStore.emitEvent('contact.status.changed', {
1129
+ contact_id: contact.id,
1130
+ contact_name: contact.name || contact.host || null,
1131
+ previous_status: previousStatus || null,
1132
+ status: 'online'
1133
+ }, { contactId: contact.id });
1134
+ }
1032
1135
 
1033
1136
  if (context.convStore) {
1034
1137
  try {
@@ -1053,7 +1156,17 @@ function createDashboardApiRouter(options = {}) {
1053
1156
  can_continue: result?.can_continue !== false
1054
1157
  });
1055
1158
  } catch (err) {
1159
+ const previousStatus = String(contact.status || '');
1056
1160
  context.tokenStore.updateContactStatus(contact.id, 'offline', err.message);
1161
+ if (context.eventStore && context.eventStore.isAvailable() && previousStatus !== 'offline') {
1162
+ context.eventStore.emitEvent('contact.status.changed', {
1163
+ contact_id: contact.id,
1164
+ contact_name: contact.name || contact.host || null,
1165
+ previous_status: previousStatus || null,
1166
+ status: 'offline',
1167
+ reason: err.message || null
1168
+ }, { contactId: contact.id });
1169
+ }
1057
1170
  return res.status(502).json({
1058
1171
  success: false,
1059
1172
  error: 'contact_call_failed',
package/src/server.js CHANGED
@@ -26,6 +26,7 @@ const { writePidFile, removePidFile } = require('./lib/pid-file');
26
26
  const { buildUnifiedSummaryPrompt } = require('./lib/summary-prompt');
27
27
  const { A2AConfig } = require('./lib/config');
28
28
  const { UpdateManager } = require('./lib/update-manager');
29
+ const { DashboardEventStore } = require('./lib/dashboard-events');
29
30
  const { spawn } = require('child_process');
30
31
  const { resolveTurnTimeoutMs } = require('./lib/turn-timeout');
31
32
 
@@ -67,6 +68,7 @@ function loadAgentContext() {
67
68
  const agentContext = loadAgentContext();
68
69
  const tokenStore = new TokenStore();
69
70
  const config = new A2AConfig();
71
+ const eventStore = new DashboardEventStore(tokenStore.configDir);
70
72
  const runtime = createRuntimeAdapter({
71
73
  workspaceDir,
72
74
  agentContext,
@@ -833,6 +835,7 @@ app.use('/api/a2a/dashboard', createDashboardApiRouter({
833
835
  tokenStore,
834
836
  agentContext,
835
837
  config,
838
+ eventStore,
836
839
  getUpdateManager: () => updateManager,
837
840
  logger: logger.child({ component: 'a2a.dashboard' })
838
841
  }));
@@ -858,6 +861,7 @@ app.use('/callbook', createCallbookRouter());
858
861
 
859
862
  app.use('/api/a2a', createRoutes({
860
863
  tokenStore,
864
+ eventStore,
861
865
  logger: logger.child({ component: 'a2a.routes' }),
862
866
  onCallMonitor: (monitor) => {
863
867
  activeCallMonitor = monitor;