a2acalling 0.6.71 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.6.70",
3
- "installed_at": "2026-02-26T07:50:29.975Z",
2
+ "version": "0.6.72",
3
+ "installed_at": "2026-02-27T03:05:52.375Z",
4
4
  "files": [
5
5
  {
6
6
  "path": "CLAUDE.md",
@@ -56,8 +56,7 @@
56
56
  },
57
57
  {
58
58
  "path": ".codex/AGENTS.md",
59
- "action": "error",
60
- "detail": "Source file not found"
59
+ "action": "skipped"
61
60
  },
62
61
  {
63
62
  "path": ".a2a-manifest.json",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.71",
3
+ "version": "0.6.72",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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 || {};
@@ -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 () => {