From bd018e04b1abf9efd784ed2d8a93e006944661e1 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Tue, 10 Mar 2026 11:29:31 -0300 Subject: [PATCH] =?UTF-8?q?feat(communication):=20Agent=20Mesh=20Network?= =?UTF-8?q?=20=E2=80=94=20rede=20P2P=20descentralizada=20entre=20agentes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa modulo de comunicacao peer-to-peer para agentes se descobrirem, formarem grupos ad-hoc e rotearem mensagens atraves da mesh sem orquestrador central. Funcionalidades: peer discovery, direct messaging, broadcast/topics, roteamento multi-hop (BFS), heartbeat/pruning, message queue com TTL, rate limiting (token bucket), deteccao de particoes (DFS), persistencia em disco e metricas de rede. --- .../core/communication/agent-mesh-network.js | 2 + .../core/communication/agent-mesh-network.js | 1115 +++++++++++++++++ .aiox-core/install-manifest.yaml | 110 +- .../communication/agent-mesh-network.test.js | 1113 ++++++++++++++++ 4 files changed, 2285 insertions(+), 55 deletions(-) create mode 100644 .aios-core/core/communication/agent-mesh-network.js create mode 100644 .aiox-core/core/communication/agent-mesh-network.js create mode 100644 tests/core/communication/agent-mesh-network.test.js diff --git a/.aios-core/core/communication/agent-mesh-network.js b/.aios-core/core/communication/agent-mesh-network.js new file mode 100644 index 000000000..97e47bfb6 --- /dev/null +++ b/.aios-core/core/communication/agent-mesh-network.js @@ -0,0 +1,2 @@ +// Wrapper retrocompativel — modulo real em .aiox-core/ +module.exports = require('../../../.aiox-core/core/communication/agent-mesh-network'); diff --git a/.aiox-core/core/communication/agent-mesh-network.js b/.aiox-core/core/communication/agent-mesh-network.js new file mode 100644 index 000000000..3fa3408e7 --- /dev/null +++ b/.aiox-core/core/communication/agent-mesh-network.js @@ -0,0 +1,1115 @@ +/** + * Agent Mesh Network - Rede P2P Descentralizada entre Agentes + * + * Comunicacao peer-to-peer descentralizada. Agentes se descobrem, + * formam grupos ad-hoc, transmitem mensagens e roteiam atraves + * da mesh. Como uma rede neural de agentes. + * + * Features: + * - Peer Discovery: registro e descoberta automatica + * - Direct Messaging: agente A para agente B + * - Broadcast & Topics: pub/sub com grupos por topico + * - Message Routing: multi-hop via BFS shortest path + * - Heartbeat & Pruning: deteccao de peers desconectados + * - Message Queue: buffer para peers offline com TTL + * - Rate Limiting: token bucket por agente + * - Network Partitioning: deteccao de splits via DFS + * - Mesh Topology: grafo de adjacencia com metricas + * + * @module core/communication/agent-mesh-network + * @version 1.0.0 + */ + +const fs = require('fs'); +const path = require('path'); +const { randomUUID } = require('crypto'); +const EventEmitter = require('events'); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CONSTANTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +const DEFAULT_OPTIONS = { + heartbeatInterval: 30000, + peerTimeout: 90000, + maxQueueSize: 100, + messageTTL: 300000, + requestTimeout: 10000, + rateLimit: { + tokensPerInterval: 50, + interval: 60000, + }, + persistenceDir: '.aiox/mesh', + autoStart: true, +}; + +const MessageType = { + DIRECT: 'direct', + BROADCAST: 'broadcast', + REQUEST: 'request', + RESPONSE: 'response', + PUBSUB: 'pubsub', +}; + +const PeerState = { + ACTIVE: 'active', + INACTIVE: 'inactive', + TIMEOUT: 'timeout', +}; + +const MeshEvent = { + PEER_JOINED: 'peer-joined', + PEER_LEFT: 'peer-left', + PEER_TIMEOUT: 'peer-timeout', + MESSAGE_SENT: 'message-sent', + MESSAGE_RECEIVED: 'message-received', + BROADCAST: 'broadcast', + ROUTE_FOUND: 'route-found', + PARTITION_DETECTED: 'partition-detected', + QUEUE_OVERFLOW: 'queue-overflow', +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// AGENT MESH NETWORK +// ═══════════════════════════════════════════════════════════════════════════════════ + +class AgentMeshNetwork extends EventEmitter { + /** + * @param {string} projectRoot - Diretorio raiz do projeto + * @param {Object} [options] - Opcoes de configuracao + * @param {number} [options.heartbeatInterval=30000] - Intervalo do heartbeat em ms + * @param {number} [options.peerTimeout=90000] - Timeout para pruning de peers em ms + * @param {number} [options.maxQueueSize=100] - Tamanho maximo da fila por peer + * @param {number} [options.messageTTL=300000] - TTL de mensagens na fila em ms + * @param {number} [options.requestTimeout=10000] - Timeout para request/response em ms + * @param {Object} [options.rateLimit] - Configuracao de rate limiting + * @param {string} [options.persistenceDir='.aiox/mesh'] - Diretorio de persistencia + * @param {boolean} [options.autoStart=true] - Iniciar heartbeat automaticamente + */ + constructor(projectRoot, options = {}) { + super(); + + this.projectRoot = projectRoot ?? process.cwd(); + this.options = { + ...DEFAULT_OPTIONS, + ...options, + rateLimit: { + ...DEFAULT_OPTIONS.rateLimit, + ...(options.rateLimit ?? {}), + }, + }; + + /** @type {Map} Mapa de peers registrados */ + this.peers = new Map(); + + /** @type {Map>} Lista de adjacencia do grafo */ + this.adjacency = new Map(); + + /** @type {Map>} Mapa de topico -> subscribers */ + this.topics = new Map(); + + /** @type {Map} Fila de mensagens por peer */ + this.queues = new Map(); + + /** @type {Map} Token buckets para rate limiting */ + this.rateLimiters = new Map(); + + /** @type {Map} Pending requests aguardando resposta */ + this.pendingRequests = new Map(); + + /** @type {Object} Estatisticas da rede */ + this.stats = { + messagesSent: 0, + messagesReceived: 0, + messagesDropped: 0, + messagesBroadcast: 0, + messagesRouted: 0, + messagesQueued: 0, + peersJoined: 0, + peersLeft: 0, + peersTimedOut: 0, + partitionsDetected: 0, + }; + + /** @type {number|null} Referencia do intervalo de heartbeat */ + this._heartbeatTimer = null; + + /** @type {Promise} Cadeia de persistencia serializada */ + this._writeChain = Promise.resolve(); + + this._started = false; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // PEER MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Registra um agente na mesh network + * @param {string} agentId - Identificador unico do agente + * @param {Object} [meta] - Metadados do agente + * @param {string[]} [meta.capabilities] - Capacidades do agente + * @param {string[]} [meta.topics] - Topicos para inscrever automaticamente + * @returns {Object} Dados do peer registrado + */ + join(agentId, meta = {}) { + if (!agentId || typeof agentId !== 'string') { + throw new Error('agentId is required and must be a string'); + } + + if (this.peers.has(agentId)) { + throw new Error(`Peer "${agentId}" already exists in the mesh`); + } + + const peer = { + id: agentId, + capabilities: meta.capabilities ?? [], + topics: new Set(meta.topics ?? []), + state: PeerState.ACTIVE, + joinedAt: Date.now(), + lastSeen: Date.now(), + messageCount: 0, + }; + + this.peers.set(agentId, peer); + this.adjacency.set(agentId, new Set()); + this.queues.set(agentId, []); + this._initRateLimiter(agentId); + + // Auto-subscribe aos topicos + for (const topic of peer.topics) { + this._addToTopic(agentId, topic); + } + + // Conectar ao mesh — adicionar adjacencia bidirecional com todos os peers ativos + for (const [existingId, existingPeer] of this.peers) { + if (existingId !== agentId && existingPeer.state === PeerState.ACTIVE) { + this.adjacency.get(agentId).add(existingId); + this.adjacency.get(existingId).add(agentId); + } + } + + this.stats.peersJoined++; + this._emitSafe(MeshEvent.PEER_JOINED, { agentId, capabilities: peer.capabilities }); + this._schedulePersist(); + + return this._serializePeer(peer); + } + + /** + * Remove um agente da mesh network + * @param {string} agentId - Identificador do agente + * @returns {boolean} true se removido com sucesso + */ + leave(agentId) { + const peer = this.peers.get(agentId); + if (!peer) return false; + + // Remover de todos os topicos + for (const topic of peer.topics) { + this._removeFromTopic(agentId, topic); + } + + // Remover adjacencias + const neighbors = this.adjacency.get(agentId); + if (neighbors) { + for (const neighborId of neighbors) { + const neighborAdj = this.adjacency.get(neighborId); + if (neighborAdj) neighborAdj.delete(agentId); + } + } + + this.adjacency.delete(agentId); + this.peers.delete(agentId); + this.queues.delete(agentId); + this.rateLimiters.delete(agentId); + + this.stats.peersLeft++; + this._emitSafe(MeshEvent.PEER_LEFT, { agentId }); + this._schedulePersist(); + + return true; + } + + /** + * Retorna dados de um peer especifico + * @param {string} agentId - Identificador do agente + * @returns {Object|null} Dados do peer ou null + */ + getPeer(agentId) { + const peer = this.peers.get(agentId); + return peer ? this._serializePeer(peer) : null; + } + + /** + * Lista peers com filtros opcionais + * @param {Object} [filters] - Filtros de busca + * @param {string} [filters.topic] - Filtrar por topico + * @param {string} [filters.capability] - Filtrar por capacidade + * @returns {Object[]} Lista de peers + */ + listPeers(filters = {}) { + let peers = Array.from(this.peers.values()); + + if (filters.topic) { + const subscribers = this.topics.get(filters.topic); + if (subscribers) { + peers = peers.filter(p => subscribers.has(p.id)); + } else { + peers = []; + } + } + + if (filters.capability) { + peers = peers.filter(p => p.capabilities.includes(filters.capability)); + } + + return peers.map(p => this._serializePeer(p)); + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // MESSAGING + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Envia uma mensagem direta de um agente para outro + * @param {string} fromId - Remetente + * @param {string} toId - Destinatario + * @param {*} message - Conteudo da mensagem + * @param {Object} [opts] - Opcoes + * @param {string} [opts.type='direct'] - Tipo da mensagem + * @param {number} [opts.ttl] - Time-to-live em ms + * @returns {Object} Mensagem enviada + */ + send(fromId, toId, message, opts = {}) { + this._validatePeer(fromId); + this._checkRateLimit(fromId); + + const type = opts.type ?? MessageType.DIRECT; + const ttl = opts.ttl ?? this.options.messageTTL; + + const msg = { + id: randomUUID(), + from: fromId, + to: toId, + type, + payload: this._deepClone(message), + ttl, + createdAt: Date.now(), + hops: [], + }; + + const toPeer = this.peers.get(toId); + + // Peer nao existe + if (!toPeer) { + throw new Error(`Peer "${toId}" not found in the mesh`); + } + + // Peer esta ativo e adjacente — entrega direta + if (toPeer.state === PeerState.ACTIVE && this._isAdjacent(fromId, toId)) { + this._deliverMessage(msg); + } else if (toPeer.state === PeerState.ACTIVE) { + // Tenta rotear via mesh + const route = this.getShortestPath(fromId, toId); + if (route) { + msg.hops = route.slice(1, -1); + this.stats.messagesRouted++; + this._emitSafe(MeshEvent.ROUTE_FOUND, { from: fromId, to: toId, hops: msg.hops }); + this._deliverMessage(msg); + } else { + // Sem rota — enfileirar + this._enqueueMessage(toId, msg); + } + } else { + // Peer offline — enfileirar + this._enqueueMessage(toId, msg); + } + + this.stats.messagesSent++; + this._updateLastSeen(fromId); + this._emitSafe(MeshEvent.MESSAGE_SENT, { messageId: msg.id, from: fromId, to: toId, type }); + + return { id: msg.id, from: fromId, to: toId, type, createdAt: msg.createdAt }; + } + + /** + * Envia broadcast para todos os peers ou para um topico + * @param {string} fromId - Remetente + * @param {*} message - Conteudo da mensagem + * @param {Object} [opts] - Opcoes + * @param {string} [opts.topic] - Topico alvo (se omitido, todos os peers) + * @param {boolean} [opts.excludeSelf=true] - Excluir remetente + * @returns {Object} Resumo do broadcast + */ + broadcast(fromId, message, opts = {}) { + this._validatePeer(fromId); + this._checkRateLimit(fromId); + + const excludeSelf = opts.excludeSelf ?? true; + let targets; + + if (opts.topic) { + const subscribers = this.topics.get(opts.topic); + targets = subscribers ? Array.from(subscribers) : []; + } else { + targets = Array.from(this.peers.keys()); + } + + if (excludeSelf) { + targets = targets.filter(id => id !== fromId); + } + + const msgId = randomUUID(); + const delivered = []; + + for (const toId of targets) { + const toPeer = this.peers.get(toId); + if (!toPeer) continue; + + const msg = { + id: randomUUID(), + broadcastId: msgId, + from: fromId, + to: toId, + type: MessageType.BROADCAST, + topic: opts.topic ?? null, + payload: this._deepClone(message), + ttl: this.options.messageTTL, + createdAt: Date.now(), + hops: [], + }; + + if (toPeer.state === PeerState.ACTIVE) { + this._deliverMessage(msg); + delivered.push(toId); + } else { + this._enqueueMessage(toId, msg); + } + } + + this.stats.messagesBroadcast++; + this._updateLastSeen(fromId); + this._emitSafe(MeshEvent.BROADCAST, { + broadcastId: msgId, + from: fromId, + topic: opts.topic ?? null, + deliveredTo: delivered, + totalTargets: targets.length, + }); + + return { + broadcastId: msgId, + from: fromId, + topic: opts.topic ?? null, + delivered: delivered.length, + queued: targets.length - delivered.length, + totalTargets: targets.length, + }; + } + + /** + * Envia uma request e aguarda response + * @param {string} fromId - Remetente + * @param {string} toId - Destinatario + * @param {*} message - Conteudo da request + * @param {Object} [opts] - Opcoes + * @param {number} [opts.timeout] - Timeout da request em ms + * @returns {Promise} Response do destinatario + */ + request(fromId, toId, message, opts = {}) { + const timeout = opts.timeout ?? this.options.requestTimeout; + + const sent = this.send(fromId, toId, message, { type: MessageType.REQUEST }); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(sent.id); + reject(new Error(`Request to "${toId}" timed out after ${timeout}ms`)); + }, timeout); + + this.pendingRequests.set(sent.id, { + resolve: (response) => { + clearTimeout(timer); + this.pendingRequests.delete(sent.id); + resolve(response); + }, + reject: (err) => { + clearTimeout(timer); + this.pendingRequests.delete(sent.id); + reject(err); + }, + timer, + }); + }); + } + + /** + * Responde a uma request + * @param {string} fromId - Remetente da resposta + * @param {string} originalMessageId - ID da mensagem original + * @param {*} response - Conteudo da resposta + * @returns {boolean} true se a resposta foi entregue + */ + reply(fromId, originalMessageId, response) { + this._validatePeer(fromId); + + const pending = this.pendingRequests.get(originalMessageId); + if (!pending) return false; + + pending.resolve({ + from: fromId, + originalMessageId, + payload: this._deepClone(response), + respondedAt: Date.now(), + }); + + return true; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // TOPICS / PUB-SUB + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Inscreve um agente em um topico + * @param {string} agentId - Identificador do agente + * @param {string} topic - Nome do topico + * @returns {boolean} true se inscrito com sucesso + */ + subscribe(agentId, topic) { + this._validatePeer(agentId); + if (!topic || typeof topic !== 'string') { + throw new Error('topic is required and must be a string'); + } + + const peer = this.peers.get(agentId); + peer.topics.add(topic); + this._addToTopic(agentId, topic); + + return true; + } + + /** + * Remove inscricao de um agente em um topico + * @param {string} agentId - Identificador do agente + * @param {string} topic - Nome do topico + * @returns {boolean} true se removido com sucesso + */ + unsubscribe(agentId, topic) { + this._validatePeer(agentId); + + const peer = this.peers.get(agentId); + peer.topics.delete(topic); + this._removeFromTopic(agentId, topic); + + return true; + } + + /** + * Retorna subscribers de um topico + * @param {string} topic - Nome do topico + * @returns {string[]} Lista de agentIds inscritos + */ + getTopicSubscribers(topic) { + const subscribers = this.topics.get(topic); + return subscribers ? Array.from(subscribers) : []; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // ROUTING & TOPOLOGY + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Retorna a rota entre dois peers (alias para getShortestPath) + * @param {string} fromId - Origem + * @param {string} toId - Destino + * @returns {string[]|null} Rota ou null se nao encontrada + */ + getRoute(fromId, toId) { + return this.getShortestPath(fromId, toId); + } + + /** + * Calcula o menor caminho entre dois peers via BFS + * @param {string} fromId - Origem + * @param {string} toId - Destino + * @returns {string[]|null} Caminho mais curto ou null + */ + getShortestPath(fromId, toId) { + if (!this.peers.has(fromId) || !this.peers.has(toId)) return null; + if (fromId === toId) return [fromId]; + + const visited = new Set(); + const queue = [[fromId]]; + visited.add(fromId); + + while (queue.length > 0) { + const currentPath = queue.shift(); + const currentNode = currentPath[currentPath.length - 1]; + + const neighbors = this.adjacency.get(currentNode); + if (!neighbors) continue; + + for (const neighbor of neighbors) { + if (neighbor === toId) { + return [...currentPath, neighbor]; + } + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push([...currentPath, neighbor]); + } + } + } + + return null; + } + + /** + * Retorna a topologia atual da mesh + * @returns {Object} Topologia com peers e conexoes + */ + getTopology() { + const nodes = []; + const edges = []; + const edgeSet = new Set(); + + for (const [id, peer] of this.peers) { + nodes.push(this._serializePeer(peer)); + + const neighbors = this.adjacency.get(id); + if (neighbors) { + for (const neighborId of neighbors) { + const edgeKey = [id, neighborId].sort().join('::'); + if (!edgeSet.has(edgeKey)) { + edgeSet.add(edgeKey); + edges.push({ from: id, to: neighborId }); + } + } + } + } + + return { + nodes, + edges, + peerCount: this.peers.size, + edgeCount: edges.length, + timestamp: Date.now(), + }; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // MESSAGE QUEUE + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Retorna mensagens enfileiradas para um peer + * @param {string} agentId - Identificador do agente + * @returns {Object[]} Mensagens na fila + */ + getQueuedMessages(agentId) { + const queue = this.queues.get(agentId); + if (!queue) return []; + return queue.map(msg => ({ + id: msg.id, + from: msg.from, + type: msg.type, + payload: msg.payload, + createdAt: msg.createdAt, + ttl: msg.ttl, + })); + } + + /** + * Retorna o tamanho da fila de um peer + * @param {string} agentId - Identificador do agente + * @returns {number} Numero de mensagens na fila + */ + getQueueSize(agentId) { + const queue = this.queues.get(agentId); + return queue ? queue.length : 0; + } + + /** + * Limpa a fila de mensagens de um peer + * @param {string} agentId - Identificador do agente + * @returns {number} Numero de mensagens removidas + */ + purgeQueue(agentId) { + const queue = this.queues.get(agentId); + if (!queue) return 0; + const count = queue.length; + this.queues.set(agentId, []); + return count; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEALTH & PARTITIONS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Retorna saude geral da rede + * @returns {Object} Indicadores de saude + */ + getNetworkHealth() { + const totalPeers = this.peers.size; + const activePeers = Array.from(this.peers.values()) + .filter(p => p.state === PeerState.ACTIVE).length; + const partitions = this.detectPartitions(); + const totalQueuedMessages = Array.from(this.queues.values()) + .reduce((sum, q) => sum + q.length, 0); + + const healthScore = totalPeers === 0 ? 1.0 : + (activePeers / totalPeers) * (partitions.length <= 1 ? 1.0 : 0.5); + + return { + totalPeers, + activePeers, + inactivePeers: totalPeers - activePeers, + partitionCount: partitions.length, + partitions, + totalQueuedMessages, + healthScore: Math.round(healthScore * 100) / 100, + timestamp: Date.now(), + }; + } + + /** + * Detecta particoes na rede usando DFS para componentes conexos + * @returns {string[][]} Array de particoes (cada uma e um array de agentIds) + */ + detectPartitions() { + const visited = new Set(); + const partitions = []; + + const activePeers = Array.from(this.peers.entries()) + .filter(([, p]) => p.state === PeerState.ACTIVE) + .map(([id]) => id); + + for (const peerId of activePeers) { + if (visited.has(peerId)) continue; + + const component = []; + const stack = [peerId]; + + while (stack.length > 0) { + const current = stack.pop(); + if (visited.has(current)) continue; + visited.add(current); + component.push(current); + + const neighbors = this.adjacency.get(current); + if (neighbors) { + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + const neighborPeer = this.peers.get(neighbor); + if (neighborPeer && neighborPeer.state === PeerState.ACTIVE) { + stack.push(neighbor); + } + } + } + } + } + + if (component.length > 0) { + partitions.push(component.sort()); + } + } + + if (partitions.length > 1) { + this.stats.partitionsDetected++; + this._emitSafe(MeshEvent.PARTITION_DETECTED, { partitions }); + } + + return partitions; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // STATISTICS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Retorna estatisticas detalhadas da mesh + * @returns {Object} Estatisticas + */ + getMeshStats() { + const topology = this.getTopology(); + const health = this.getNetworkHealth(); + + return { + ...this.stats, + topology: { + peerCount: topology.peerCount, + edgeCount: topology.edgeCount, + }, + health: { + score: health.healthScore, + activePeers: health.activePeers, + partitions: health.partitionCount, + }, + queues: { + totalQueued: health.totalQueuedMessages, + peersWithQueue: Array.from(this.queues.values()).filter(q => q.length > 0).length, + }, + topics: { + count: this.topics.size, + subscriptions: Array.from(this.topics.values()).reduce((sum, s) => sum + s.size, 0), + }, + pendingRequests: this.pendingRequests.size, + timestamp: Date.now(), + }; + } + + /** + * Retorna estatisticas basicas (alias compacto) + * @returns {Object} Estatisticas basicas + */ + getStats() { + return { + ...this.stats, + peerCount: this.peers.size, + topicCount: this.topics.size, + pendingRequests: this.pendingRequests.size, + timestamp: Date.now(), + }; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEARTBEAT & LIFECYCLE + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Inicia o heartbeat da mesh + */ + startHeartbeat() { + if (this._heartbeatTimer) return; + + this._heartbeatTimer = setInterval(() => { + this._runHeartbeat(); + }, this.options.heartbeatInterval); + + // Nao segurar o processo + if (this._heartbeatTimer.unref) { + this._heartbeatTimer.unref(); + } + + this._started = true; + } + + /** + * Para o heartbeat da mesh + */ + stopHeartbeat() { + if (this._heartbeatTimer) { + clearInterval(this._heartbeatTimer); + this._heartbeatTimer = null; + } + this._started = false; + } + + /** + * Destroi a mesh e limpa todos os recursos + */ + destroy() { + this.stopHeartbeat(); + + // Limpar pending requests + for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(new Error('Mesh network destroyed')); + } + this.pendingRequests.clear(); + + this.peers.clear(); + this.adjacency.clear(); + this.topics.clear(); + this.queues.clear(); + this.rateLimiters.clear(); + this.removeAllListeners(); + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // PERSISTENCE + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Carrega topologia do disco + * @returns {Promise} true se carregado com sucesso + */ + async load() { + const filePath = this._getTopologyPath(); + try { + if (fs.existsSync(filePath)) { + const raw = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(raw); + + if (data.schemaVersion === 'agent-mesh-v1' && Array.isArray(data.peers)) { + for (const peerData of data.peers) { + if (!this.peers.has(peerData.id)) { + this.join(peerData.id, { + capabilities: peerData.capabilities ?? [], + topics: peerData.topics ?? [], + }); + } + } + return true; + } + } + } catch { + // Arquivo corrompido — iniciar do zero + } + return false; + } + + /** + * Salva topologia no disco (serializado via promise chain) + * @returns {Promise} + */ + async save() { + const filePath = this._getTopologyPath(); + const dir = path.dirname(filePath); + + const data = { + schemaVersion: 'agent-mesh-v1', + version: '1.0.0', + savedAt: new Date().toISOString(), + peers: Array.from(this.peers.values()).map(p => ({ + id: p.id, + capabilities: p.capabilities, + topics: Array.from(p.topics), + state: p.state, + joinedAt: p.joinedAt, + })), + stats: this.stats, + }; + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // INTERNAL + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @private */ + _validatePeer(agentId) { + if (!this.peers.has(agentId)) { + throw new Error(`Peer "${agentId}" not found in the mesh`); + } + } + + /** @private */ + _isAdjacent(fromId, toId) { + const neighbors = this.adjacency.get(fromId); + return neighbors ? neighbors.has(toId) : false; + } + + /** @private */ + _deliverMessage(msg) { + const toPeer = this.peers.get(msg.to); + if (toPeer) { + toPeer.messageCount++; + } + + this.stats.messagesReceived++; + this._emitSafe(MeshEvent.MESSAGE_RECEIVED, { + messageId: msg.id, + from: msg.from, + to: msg.to, + type: msg.type, + payload: msg.payload, + }); + } + + /** @private */ + _enqueueMessage(peerId, msg) { + const queue = this.queues.get(peerId); + if (!queue) return; + + if (queue.length >= this.options.maxQueueSize) { + this.stats.messagesDropped++; + this._emitSafe(MeshEvent.QUEUE_OVERFLOW, { + peerId, + queueSize: queue.length, + droppedMessageId: msg.id, + }); + return; + } + + queue.push(msg); + this.stats.messagesQueued++; + } + + /** @private */ + _drainQueue(agentId) { + const queue = this.queues.get(agentId); + if (!queue || queue.length === 0) return; + + const now = Date.now(); + const validMessages = []; + + for (const msg of queue) { + if (now - msg.createdAt < msg.ttl) { + validMessages.push(msg); + } + } + + this.queues.set(agentId, []); + + for (const msg of validMessages) { + this._deliverMessage(msg); + } + } + + /** @private */ + _addToTopic(agentId, topic) { + if (!this.topics.has(topic)) { + this.topics.set(topic, new Set()); + } + this.topics.get(topic).add(agentId); + } + + /** @private */ + _removeFromTopic(agentId, topic) { + const subscribers = this.topics.get(topic); + if (subscribers) { + subscribers.delete(agentId); + if (subscribers.size === 0) { + this.topics.delete(topic); + } + } + } + + /** @private */ + _updateLastSeen(agentId) { + const peer = this.peers.get(agentId); + if (peer) { + peer.lastSeen = Date.now(); + peer.state = PeerState.ACTIVE; + } + } + + /** @private */ + _initRateLimiter(agentId) { + this.rateLimiters.set(agentId, { + tokens: this.options.rateLimit.tokensPerInterval, + lastRefill: Date.now(), + }); + } + + /** @private */ + _checkRateLimit(agentId) { + const limiter = this.rateLimiters.get(agentId); + if (!limiter) return; + + const now = Date.now(); + const elapsed = now - limiter.lastRefill; + const { tokensPerInterval, interval } = this.options.rateLimit; + + // Refill tokens proporcionalmente ao tempo + const refill = Math.floor((elapsed / interval) * tokensPerInterval); + if (refill > 0) { + limiter.tokens = Math.min(tokensPerInterval, limiter.tokens + refill); + limiter.lastRefill = now; + } + + if (limiter.tokens <= 0) { + throw new Error(`Rate limit exceeded for peer "${agentId}"`); + } + + limiter.tokens--; + } + + /** @private */ + _runHeartbeat() { + const now = Date.now(); + const timeout = this.options.peerTimeout; + + for (const [id, peer] of this.peers) { + if (peer.state === PeerState.ACTIVE && (now - peer.lastSeen) > timeout) { + peer.state = PeerState.TIMEOUT; + this.stats.peersTimedOut++; + this._emitSafe(MeshEvent.PEER_TIMEOUT, { agentId: id, lastSeen: peer.lastSeen }); + + // Remover adjacencias do peer timeout + const neighbors = this.adjacency.get(id); + if (neighbors) { + for (const neighborId of neighbors) { + const neighborAdj = this.adjacency.get(neighborId); + if (neighborAdj) neighborAdj.delete(id); + } + neighbors.clear(); + } + } + } + + // Purge mensagens expiradas das filas + for (const [peerId, queue] of this.queues) { + const valid = queue.filter(msg => (now - msg.createdAt) < msg.ttl); + if (valid.length !== queue.length) { + this.stats.messagesDropped += (queue.length - valid.length); + this.queues.set(peerId, valid); + } + } + } + + /** @private */ + _schedulePersist() { + this._writeChain = this._writeChain.then(() => this.save()).catch(() => { + // Falha silenciosa na persistencia + }); + } + + /** @private */ + _getTopologyPath() { + return path.resolve(this.projectRoot, this.options.persistenceDir, 'topology.json'); + } + + /** @private */ + _serializePeer(peer) { + return { + id: peer.id, + capabilities: [...peer.capabilities], + topics: Array.from(peer.topics), + state: peer.state, + joinedAt: peer.joinedAt, + lastSeen: peer.lastSeen, + messageCount: peer.messageCount, + }; + } + + /** @private */ + _deepClone(obj) { + try { + return structuredClone(obj); + } catch { + return JSON.parse(JSON.stringify(obj)); + } + } + + /** + * Emite evento com guarda para 'error' + * @private + * @param {string} event - Nome do evento + * @param {*} data - Dados do evento + */ + _emitSafe(event, data) { + if (event === 'error' && this.listenerCount('error') === 0) { + return; + } + this.emit(event, data); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// EXPORTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +module.exports = AgentMeshNetwork; +module.exports.AgentMeshNetwork = AgentMeshNetwork; +module.exports.MessageType = MessageType; +module.exports.PeerState = PeerState; +module.exports.MeshEvent = MeshEvent; +module.exports.DEFAULT_OPTIONS = DEFAULT_OPTIONS; diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 3529ff13b..03eb6b1b0 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-11T12:38:10.304Z" +generated_at: "2026-03-12T18:33:02.875Z" generator: scripts/generate-install-manifest.js file_count: 1091 files: @@ -236,6 +236,10 @@ files: hash: sha256:701102a519457938d5b9187b75a1f97d85cb5466cef23bce5edfb48b34c69ab8 type: core size: 10891 + - path: core/communication/agent-mesh-network.js + hash: sha256:9149abe1f1cc874d8c5d48d4e00846c617eaf416c93bc0f075a78def17b1e99d + type: core + size: 36059 - path: core/config/config-cache.js hash: sha256:527a788cbe650aa6b13d1101ebc16419489bfef20b2ee93042f6eb6a51e898e9 type: core @@ -2348,10 +2352,6 @@ files: hash: sha256:dfb5f03fae16171777742b06a9e54ee25711d1d94cedc2152ef9c9331310b608 type: task size: 5254 - - path: development/tasks/review-prs.md - hash: sha256:f49dcdc3f16a1bb187ab76daa6c8bf28e69ea1506b0048661cbda7907ac45fba - type: task - size: 14109 - path: development/tasks/run-design-system-pipeline.md hash: sha256:ff4c225b922da347b63aeb6d8aa95484c1c9281eb1e4b4c4ab0ecef0a1a54c26 type: task @@ -2591,19 +2591,19 @@ files: - path: development/templates/service-template/__tests__/index.test.ts.hbs hash: sha256:4617c189e75ab362d4ef2cabcc3ccce3480f914fd915af550469c17d1b68a4fe type: template - size: 9810 + size: 9573 - path: development/templates/service-template/client.ts.hbs hash: sha256:f342c60695fe611192002bdb8c04b3a0dbce6345b7fa39834ea1898f71689198 type: template - size: 12213 + size: 11810 - path: development/templates/service-template/errors.ts.hbs hash: sha256:e0be40d8be19b71b26e35778eadffb20198e7ca88e9d140db9da1bfe12de01ec type: template - size: 5395 + size: 5213 - path: development/templates/service-template/index.ts.hbs hash: sha256:d44012d54b76ab98356c7163d257ca939f7fed122f10fecf896fe1e7e206d10a type: template - size: 3206 + size: 3086 - path: development/templates/service-template/jest.config.js hash: sha256:1681bfd7fbc0d330d3487d3427515847c4d57ef300833f573af59e0ad69ed159 type: template @@ -2611,11 +2611,11 @@ files: - path: development/templates/service-template/package.json.hbs hash: sha256:d89d35f56992ee95c2ceddf17fa1d455c18007a4d24af914ba83cf4abc38bca9 type: template - size: 2314 + size: 2227 - path: development/templates/service-template/README.md.hbs hash: sha256:2c3dd4c2bf6df56b9b6db439977be7e1cc35820438c0e023140eccf6ccd227a0 type: template - size: 3584 + size: 3426 - path: development/templates/service-template/tsconfig.json hash: sha256:8b465fcbdd45c4d6821ba99aea62f2bd7998b1bca8de80486a1525e77d43c9a1 type: template @@ -2623,7 +2623,7 @@ files: - path: development/templates/service-template/types.ts.hbs hash: sha256:3e52e0195003be8cd1225a3f27f4d040686c8b8c7762f71b41055f04cd1b841b type: template - size: 2661 + size: 2516 - path: development/templates/squad-template/agents/example-agent.yaml hash: sha256:824a1b349965e5d4ae85458c231b78260dc65497da75dada25b271f2cabbbe67 type: agent @@ -2631,7 +2631,7 @@ files: - path: development/templates/squad-template/LICENSE hash: sha256:ff7017aa403270cf2c440f5ccb4240d0b08e54d8bf8a0424d34166e8f3e10138 type: template - size: 1092 + size: 1071 - path: development/templates/squad-template/package.json hash: sha256:8f68627a0d74e49f94ae382d0c2b56ecb5889d00f3095966c742fb5afaf363db type: template @@ -3375,11 +3375,11 @@ files: - path: infrastructure/templates/aiox-sync.yaml.template hash: sha256:0040ad8a9e25716a28631b102c9448b72fd72e84f992c3926eb97e9e514744bb type: template - size: 8567 + size: 8385 - path: infrastructure/templates/coderabbit.yaml.template hash: sha256:91a4a76bbc40767a4072fb6a87c480902bb800cfb0a11e9fc1b3183d8f7f3a80 type: template - size: 8321 + size: 8042 - path: infrastructure/templates/core-config/core-config-brownfield.tmpl.yaml hash: sha256:9bdb0c0e09c765c991f9f142921f7f8e2c0d0ada717f41254161465dc0622d02 type: template @@ -3391,11 +3391,11 @@ files: - path: infrastructure/templates/github-workflows/ci.yml.template hash: sha256:acbfa2a8a84141fd6a6b205eac74719772f01c221c0afe22ce951356f06a605d type: template - size: 5089 + size: 4920 - path: infrastructure/templates/github-workflows/pr-automation.yml.template hash: sha256:c236077b4567965a917e48df9a91cc42153ff97b00a9021c41a7e28179be9d0f type: template - size: 10939 + size: 10609 - path: infrastructure/templates/github-workflows/README.md hash: sha256:6b7b5cb32c28b3e562c81a96e2573ea61849b138c93ccac6e93c3adac26cadb5 type: template @@ -3403,23 +3403,23 @@ files: - path: infrastructure/templates/github-workflows/release.yml.template hash: sha256:b771145e61a254a88dc6cca07869e4ece8229ce18be87132f59489cdf9a66ec6 type: template - size: 6791 + size: 6595 - path: infrastructure/templates/gitignore/gitignore-aiox-base.tmpl hash: sha256:9088975ee2bf4d88e23db6ac3ea5d27cccdc72b03db44450300e2f872b02e935 type: template - size: 851 + size: 788 - path: infrastructure/templates/gitignore/gitignore-brownfield-merge.tmpl hash: sha256:ce4291a3cf5677050c9dafa320809e6b0ca5db7e7f7da0382d2396e32016a989 type: template - size: 506 + size: 488 - path: infrastructure/templates/gitignore/gitignore-node.tmpl hash: sha256:5179f78de7483274f5d7182569229088c71934db1fd37a63a40b3c6b815c9c8e type: template - size: 1036 + size: 951 - path: infrastructure/templates/gitignore/gitignore-python.tmpl hash: sha256:d7aac0b1e6e340b774a372a9102b4379722588449ca82ac468cf77804bbc1e55 type: template - size: 1725 + size: 1580 - path: infrastructure/templates/project-docs/coding-standards-tmpl.md hash: sha256:377acf85463df8ac9923fc59d7cfeba68a82f8353b99948ea1d28688e88bc4a9 type: template @@ -3515,43 +3515,43 @@ files: - path: monitor/hooks/lib/__init__.py hash: sha256:bfab6ee249c52f412c02502479da649b69d044938acaa6ab0aa39dafe6dee9bf type: monitor - size: 30 + size: 29 - path: monitor/hooks/lib/enrich.py hash: sha256:20dfa73b4b20d7a767e52c3ec90919709c4447c6e230902ba797833fc6ddc22c type: monitor - size: 1702 + size: 1644 - path: monitor/hooks/lib/send_event.py hash: sha256:59d61311f718fb373a5cf85fd7a01c23a4fd727e8e022ad6930bba533ef4615d type: monitor - size: 1237 + size: 1190 - path: monitor/hooks/notification.py hash: sha256:8a1a6ce0ff2b542014de177006093b9caec9b594e938a343dc6bd62df2504f22 type: monitor - size: 528 + size: 499 - path: monitor/hooks/post_tool_use.py hash: sha256:47dbe37073d432c55657647fc5b907ddb56efa859d5c3205e8362aa916d55434 type: monitor - size: 1185 + size: 1140 - path: monitor/hooks/pre_compact.py hash: sha256:f287cf45e83deed6f1bc0e30bd9348dfa1bf08ad770c5e58bb34e3feb210b30b type: monitor - size: 529 + size: 500 - path: monitor/hooks/pre_tool_use.py hash: sha256:a4d1d3ffdae9349e26a383c67c9137effff7d164ac45b2c87eea9fa1ab0d6d98 type: monitor - size: 1021 + size: 981 - path: monitor/hooks/stop.py hash: sha256:edb382f0cf46281a11a8588bc20eafa7aa2b5cc3f4ad775d71b3d20a7cfab385 type: monitor - size: 519 + size: 490 - path: monitor/hooks/subagent_stop.py hash: sha256:fa5357309247c71551dba0a19f28dd09bebde749db033d6657203b50929c0a42 type: monitor - size: 541 + size: 512 - path: monitor/hooks/user_prompt_submit.py hash: sha256:af57dca79ef55cdf274432f4abb4c20a9778b95e107ca148f47ace14782c5828 type: monitor - size: 856 + size: 818 - path: package.json hash: sha256:9fdf0dcee2dcec6c0643634ee384ba181ad077dcff1267d8807434d4cb4809c7 type: other @@ -3699,7 +3699,7 @@ files: - path: product/templates/adr.hbs hash: sha256:d68653cae9e64414ad4f58ea941b6c6e337c5324c2c7247043eca1461a652d10 type: template - size: 2337 + size: 2212 - path: product/templates/agent-template.yaml hash: sha256:98676fcc493c0d5f09264dcc52fcc2cf1129f9a195824ecb4c2ec035c2515121 type: template @@ -3751,7 +3751,7 @@ files: - path: product/templates/dbdr.hbs hash: sha256:5a2781ffaa3da9fc663667b5a63a70b7edfc478ed14cad02fc6ed237ff216315 type: template - size: 4380 + size: 4139 - path: product/templates/design-story-tmpl.yaml hash: sha256:2bfefc11ae2bcfc679dbd924c58f8b764fa23538c14cb25344d6edef41968f29 type: template @@ -3815,7 +3815,7 @@ files: - path: product/templates/epic.hbs hash: sha256:dcbcc26f6dd8f3782b3ef17aee049b689f1d6d92931615c3df9513eca0de2ef7 type: template - size: 4080 + size: 3868 - path: product/templates/eslintrc-security.json hash: sha256:657d40117261d6a52083984d29f9f88e79040926a64aa4c2058a602bfe91e0d5 type: template @@ -3923,7 +3923,7 @@ files: - path: product/templates/pmdr.hbs hash: sha256:d529cebbb562faa82c70477ece70de7cda871eaa6896f2962b48b2a8b67b1cbe type: template - size: 3425 + size: 3239 - path: product/templates/prd-tmpl.yaml hash: sha256:25c239f40e05f24aee1986601a98865188dbe3ea00a705028efc3adad6d420f3 type: template @@ -3931,11 +3931,11 @@ files: - path: product/templates/prd-v2.0.hbs hash: sha256:21a20ef5333a85a11f5326d35714e7939b51bab22bd6e28d49bacab755763bea type: template - size: 4728 + size: 4512 - path: product/templates/prd.hbs hash: sha256:4a1a030a5388c6a8bf2ce6ea85e54cae6cf1fe64f1bb2af7f17d349d3c24bf1d type: template - size: 3626 + size: 3425 - path: product/templates/project-brief-tmpl.yaml hash: sha256:b8d388268c24dc5018f48a87036d591b11cb122fafe9b59c17809b06ea5d9d58 type: template @@ -3983,7 +3983,7 @@ files: - path: product/templates/story.hbs hash: sha256:3f0ac8b39907634a2b53f43079afc33663eee76f46e680d318ff253e0befc2c4 type: template - size: 5846 + size: 5583 - path: product/templates/task-execution-report.md hash: sha256:e0f08a3e199234f3d2207ba8f435786b7d8e1b36174f46cb82fc3666b9a9309e type: template @@ -3995,67 +3995,67 @@ files: - path: product/templates/task.hbs hash: sha256:621e987e142c455cd290dc85d990ab860faa0221f66cf1f57ac296b076889ea5 type: template - size: 2875 + size: 2705 - path: product/templates/tmpl-comment-on-examples.sql hash: sha256:254002c3fbc63cfcc5848b1d4b15822ce240bf5f57e6a1c8bb984e797edc2691 type: template - size: 6373 + size: 6215 - path: product/templates/tmpl-migration-script.sql hash: sha256:44ef63ea475526d21a11e3c667c9fdb78a9fddace80fdbaa2312b7f2724fbbb5 type: template - size: 3038 + size: 2947 - path: product/templates/tmpl-rls-granular-policies.sql hash: sha256:36c2fd8c6d9eebb5d164acb0fb0c87bc384d389264b4429ce21e77e06318f5f3 type: template - size: 3426 + size: 3322 - path: product/templates/tmpl-rls-kiss-policy.sql hash: sha256:5210d37fce62e5a9a00e8d5366f5f75653cd518be73fbf96333ed8a6712453c7 type: template - size: 309 + size: 299 - path: product/templates/tmpl-rls-roles.sql hash: sha256:2d032a608a8e87440c3a430c7d69ddf9393d8813d8d4129270f640dd847425c3 type: template - size: 4727 + size: 4592 - path: product/templates/tmpl-rls-simple.sql hash: sha256:f67af0fa1cdd2f2af9eab31575ac3656d82457421208fd9ccb8b57ca9785275e type: template - size: 2992 + size: 2915 - path: product/templates/tmpl-rls-tenant.sql hash: sha256:36629ed87a2c72311809cc3fb96298b6f38716bba35bc56c550ac39d3321757a type: template - size: 5130 + size: 4978 - path: product/templates/tmpl-rollback-script.sql hash: sha256:8b84046a98f1163faf7350322f43831447617c5a63a94c88c1a71b49804e022b type: template - size: 2734 + size: 2657 - path: product/templates/tmpl-seed-data.sql hash: sha256:a65e73298f46cd6a8e700f29b9d8d26e769e12a57751a943a63fd0fe15768615 type: template - size: 5716 + size: 5576 - path: product/templates/tmpl-smoke-test.sql hash: sha256:aee7e48bb6d9c093769dee215cacc9769939501914e20e5ea8435b25fad10f3c type: template - size: 739 + size: 723 - path: product/templates/tmpl-staging-copy-merge.sql hash: sha256:55988caeb47cc04261665ba7a37f4caa2aa5fac2e776fdbc5964e0587af24450 type: template - size: 4220 + size: 4081 - path: product/templates/tmpl-stored-proc.sql hash: sha256:2b205ff99dc0adfade6047a4d79f5b50109e50ceb45386e5c886437692c7a2a3 type: template - size: 3979 + size: 3839 - path: product/templates/tmpl-trigger.sql hash: sha256:93abdc92e1b475d1370094e69a9d1b18afd804da6acb768b878355c798bd8e0e type: template - size: 5424 + size: 5272 - path: product/templates/tmpl-view-materialized.sql hash: sha256:47935510f03d4ad9b2200748e65441ce6c2d6a7c74750395eca6831d77c48e91 type: template - size: 4496 + size: 4363 - path: product/templates/tmpl-view.sql hash: sha256:22557b076003a856b32397f05fa44245a126521de907058a95e14dd02da67aff type: template - size: 5093 + size: 4916 - path: product/templates/token-exports-css-tmpl.css hash: sha256:d937b8d61cdc9e5b10fdff871c6cb41c9f756004d060d671e0ae26624a047f62 type: template diff --git a/tests/core/communication/agent-mesh-network.test.js b/tests/core/communication/agent-mesh-network.test.js new file mode 100644 index 000000000..fc8607137 --- /dev/null +++ b/tests/core/communication/agent-mesh-network.test.js @@ -0,0 +1,1113 @@ +/** + * Tests: Agent Mesh Network + * + * Testes unitarios para a rede mesh P2P descentralizada entre agentes. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const AgentMeshNetwork = require('../../../.aiox-core/core/communication/agent-mesh-network'); +const { + MessageType, + PeerState, + MeshEvent, + DEFAULT_OPTIONS, +} = AgentMeshNetwork; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════════════ + +function createMesh(opts = {}) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-test-')); + const mesh = new AgentMeshNetwork(tmpDir, { + autoStart: false, + heartbeatInterval: 1000, + peerTimeout: 3000, + messageTTL: 5000, + requestTimeout: 2000, + ...opts, + }); + return { mesh, tmpDir }; +} + +function cleanupDir(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// TESTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('AgentMeshNetwork', () => { + let mesh; + let tmpDir; + + beforeEach(() => { + jest.useFakeTimers(); + const ctx = createMesh(); + mesh = ctx.mesh; + tmpDir = ctx.tmpDir; + }); + + afterEach(() => { + mesh.destroy(); + jest.useRealTimers(); + cleanupDir(tmpDir); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // CONSTRUCTOR & EXPORTS + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('constructor & exports', () => { + test('deve criar instancia com defaults', () => { + const m = new AgentMeshNetwork('/tmp/test'); + expect(m).toBeInstanceOf(AgentMeshNetwork); + expect(m.projectRoot).toBe('/tmp/test'); + expect(m.peers.size).toBe(0); + m.destroy(); + }); + + test('deve aceitar opcoes personalizadas', () => { + const m = new AgentMeshNetwork('/tmp/test', { + heartbeatInterval: 5000, + maxQueueSize: 50, + }); + expect(m.options.heartbeatInterval).toBe(5000); + expect(m.options.maxQueueSize).toBe(50); + expect(m.options.peerTimeout).toBe(DEFAULT_OPTIONS.peerTimeout); + m.destroy(); + }); + + test('deve exportar constantes', () => { + expect(MessageType.DIRECT).toBe('direct'); + expect(MessageType.BROADCAST).toBe('broadcast'); + expect(MessageType.REQUEST).toBe('request'); + expect(MessageType.RESPONSE).toBe('response'); + expect(PeerState.ACTIVE).toBe('active'); + expect(PeerState.TIMEOUT).toBe('timeout'); + expect(MeshEvent.PEER_JOINED).toBe('peer-joined'); + expect(MeshEvent.MESSAGE_SENT).toBe('message-sent'); + }); + + test('deve estender EventEmitter', () => { + expect(typeof mesh.on).toBe('function'); + expect(typeof mesh.emit).toBe('function'); + expect(typeof mesh.removeAllListeners).toBe('function'); + }); + + test('deve usar nullish coalescing para projectRoot', () => { + const m = new AgentMeshNetwork(null); + expect(m.projectRoot).toBe(process.cwd()); + m.destroy(); + }); + + test('deve inicializar estatisticas em zero', () => { + const stats = mesh.getStats(); + expect(stats.messagesSent).toBe(0); + expect(stats.messagesReceived).toBe(0); + expect(stats.peersJoined).toBe(0); + expect(stats.peerCount).toBe(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // PEER MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('join()', () => { + test('deve registrar um peer na mesh', () => { + const result = mesh.join('agent-1', { capabilities: ['coding'] }); + expect(result.id).toBe('agent-1'); + expect(result.capabilities).toEqual(['coding']); + expect(result.state).toBe('active'); + expect(mesh.peers.size).toBe(1); + }); + + test('deve emitir evento peer-joined', () => { + const handler = jest.fn(); + mesh.on(MeshEvent.PEER_JOINED, handler); + mesh.join('agent-1'); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-1' }) + ); + }); + + test('deve lancar erro para agentId invalido', () => { + expect(() => mesh.join('')).toThrow('agentId is required'); + expect(() => mesh.join(null)).toThrow('agentId is required'); + }); + + test('deve lancar erro para peer duplicado', () => { + mesh.join('agent-1'); + expect(() => mesh.join('agent-1')).toThrow('already exists'); + }); + + test('deve auto-inscrever em topicos', () => { + mesh.join('agent-1', { topics: ['deploy', 'test'] }); + expect(mesh.getTopicSubscribers('deploy')).toContain('agent-1'); + expect(mesh.getTopicSubscribers('test')).toContain('agent-1'); + }); + + test('deve criar adjacencia bidirecional com peers existentes', () => { + mesh.join('agent-1'); + mesh.join('agent-2'); + expect(mesh.adjacency.get('agent-1').has('agent-2')).toBe(true); + expect(mesh.adjacency.get('agent-2').has('agent-1')).toBe(true); + }); + + test('deve incrementar estatistica peersJoined', () => { + mesh.join('a1'); + mesh.join('a2'); + expect(mesh.getStats().peersJoined).toBe(2); + }); + }); + + describe('leave()', () => { + test('deve remover peer da mesh', () => { + mesh.join('agent-1'); + const result = mesh.leave('agent-1'); + expect(result).toBe(true); + expect(mesh.peers.size).toBe(0); + }); + + test('deve retornar false para peer inexistente', () => { + expect(mesh.leave('ghost')).toBe(false); + }); + + test('deve emitir evento peer-left', () => { + mesh.join('agent-1'); + const handler = jest.fn(); + mesh.on(MeshEvent.PEER_LEFT, handler); + mesh.leave('agent-1'); + expect(handler).toHaveBeenCalledWith({ agentId: 'agent-1' }); + }); + + test('deve remover adjacencias do peer removido', () => { + mesh.join('agent-1'); + mesh.join('agent-2'); + mesh.leave('agent-1'); + expect(mesh.adjacency.get('agent-2').has('agent-1')).toBe(false); + }); + + test('deve remover inscricoes de topicos', () => { + mesh.join('agent-1', { topics: ['deploy'] }); + mesh.leave('agent-1'); + expect(mesh.getTopicSubscribers('deploy')).toEqual([]); + }); + + test('deve incrementar estatistica peersLeft', () => { + mesh.join('a1'); + mesh.leave('a1'); + expect(mesh.getStats().peersLeft).toBe(1); + }); + }); + + describe('getPeer()', () => { + test('deve retornar dados do peer', () => { + mesh.join('agent-1', { capabilities: ['coding'] }); + const peer = mesh.getPeer('agent-1'); + expect(peer).not.toBeNull(); + expect(peer.id).toBe('agent-1'); + expect(peer.capabilities).toEqual(['coding']); + }); + + test('deve retornar null para peer inexistente', () => { + expect(mesh.getPeer('ghost')).toBeNull(); + }); + + test('deve retornar copia, nao referencia', () => { + mesh.join('agent-1', { capabilities: ['a'] }); + const p1 = mesh.getPeer('agent-1'); + const p2 = mesh.getPeer('agent-1'); + p1.capabilities.push('b'); + expect(p2.capabilities).toEqual(['a']); + }); + }); + + describe('listPeers()', () => { + test('deve listar todos os peers', () => { + mesh.join('a1'); + mesh.join('a2'); + const peers = mesh.listPeers(); + expect(peers).toHaveLength(2); + }); + + test('deve filtrar por topico', () => { + mesh.join('a1', { topics: ['deploy'] }); + mesh.join('a2', { topics: ['test'] }); + const peers = mesh.listPeers({ topic: 'deploy' }); + expect(peers).toHaveLength(1); + expect(peers[0].id).toBe('a1'); + }); + + test('deve filtrar por capability', () => { + mesh.join('a1', { capabilities: ['coding'] }); + mesh.join('a2', { capabilities: ['testing'] }); + const peers = mesh.listPeers({ capability: 'testing' }); + expect(peers).toHaveLength(1); + expect(peers[0].id).toBe('a2'); + }); + + test('deve retornar array vazio para topico inexistente', () => { + mesh.join('a1'); + expect(mesh.listPeers({ topic: 'ghost' })).toEqual([]); + }); + + test('deve combinar filtros de topico e capability', () => { + mesh.join('a1', { capabilities: ['coding'], topics: ['deploy'] }); + mesh.join('a2', { capabilities: ['testing'], topics: ['deploy'] }); + const peers = mesh.listPeers({ topic: 'deploy', capability: 'coding' }); + expect(peers).toHaveLength(1); + expect(peers[0].id).toBe('a1'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // DIRECT MESSAGING + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('send()', () => { + test('deve enviar mensagem direta entre peers adjacentes', () => { + mesh.join('a1'); + mesh.join('a2'); + const handler = jest.fn(); + mesh.on(MeshEvent.MESSAGE_RECEIVED, handler); + + const result = mesh.send('a1', 'a2', { text: 'hello' }); + expect(result.from).toBe('a1'); + expect(result.to).toBe('a2'); + expect(result.id).toBeDefined(); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'a1', + to: 'a2', + payload: { text: 'hello' }, + }) + ); + }); + + test('deve emitir evento message-sent', () => { + mesh.join('a1'); + mesh.join('a2'); + const handler = jest.fn(); + mesh.on(MeshEvent.MESSAGE_SENT, handler); + mesh.send('a1', 'a2', 'ping'); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ from: 'a1', to: 'a2' }) + ); + }); + + test('deve lancar erro se remetente nao existe', () => { + mesh.join('a2'); + expect(() => mesh.send('ghost', 'a2', 'hi')).toThrow('not found'); + }); + + test('deve lancar erro se destinatario nao existe', () => { + mesh.join('a1'); + expect(() => mesh.send('a1', 'ghost', 'hi')).toThrow('not found'); + }); + + test('deve incrementar messagesSent', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.send('a1', 'a2', 'msg1'); + mesh.send('a1', 'a2', 'msg2'); + expect(mesh.getStats().messagesSent).toBe(2); + }); + + test('deve clonar payload (nao compartilhar referencia)', () => { + mesh.join('a1'); + mesh.join('a2'); + const payload = { data: [1, 2, 3] }; + const handler = jest.fn(); + mesh.on(MeshEvent.MESSAGE_RECEIVED, handler); + mesh.send('a1', 'a2', payload); + payload.data.push(4); + expect(handler.mock.calls[0][0].payload.data).toEqual([1, 2, 3]); + }); + + test('deve enfileirar para peer offline', () => { + mesh.join('a1'); + mesh.join('a2'); + + // Simular peer offline + mesh.peers.get('a2').state = PeerState.INACTIVE; + + mesh.send('a1', 'a2', 'offline-msg'); + expect(mesh.getQueueSize('a2')).toBe(1); + }); + + test('deve atualizar lastSeen do remetente', () => { + mesh.join('a1'); + mesh.join('a2'); + const before = mesh.peers.get('a1').lastSeen; + jest.advanceTimersByTime(100); + mesh.send('a1', 'a2', 'ping'); + expect(mesh.peers.get('a1').lastSeen).toBeGreaterThanOrEqual(before); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // BROADCAST + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('broadcast()', () => { + test('deve enviar para todos os peers ativos', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.join('a3'); + const result = mesh.broadcast('a1', 'hello everyone'); + expect(result.delivered).toBe(2); + expect(result.totalTargets).toBe(2); + }); + + test('deve excluir remetente por padrao', () => { + mesh.join('a1'); + mesh.join('a2'); + const handler = jest.fn(); + mesh.on(MeshEvent.MESSAGE_RECEIVED, handler); + mesh.broadcast('a1', 'msg'); + const receivers = handler.mock.calls.map(c => c[0].to); + expect(receivers).not.toContain('a1'); + }); + + test('deve incluir remetente quando excludeSelf=false', () => { + mesh.join('a1'); + mesh.join('a2'); + const handler = jest.fn(); + mesh.on(MeshEvent.MESSAGE_RECEIVED, handler); + mesh.broadcast('a1', 'msg', { excludeSelf: false }); + const receivers = handler.mock.calls.map(c => c[0].to); + expect(receivers).toContain('a1'); + }); + + test('deve filtrar por topico', () => { + mesh.join('a1', { topics: ['deploy'] }); + mesh.join('a2', { topics: ['deploy'] }); + mesh.join('a3', { topics: ['test'] }); + const result = mesh.broadcast('a1', 'deploy update', { topic: 'deploy' }); + expect(result.delivered).toBe(1); // a2 only, a1 excluded + expect(result.totalTargets).toBe(1); + }); + + test('deve emitir evento broadcast', () => { + mesh.join('a1'); + mesh.join('a2'); + const handler = jest.fn(); + mesh.on(MeshEvent.BROADCAST, handler); + mesh.broadcast('a1', 'msg'); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ from: 'a1', deliveredTo: expect.any(Array) }) + ); + }); + + test('deve incrementar messagesBroadcast', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.broadcast('a1', 'msg1'); + mesh.broadcast('a1', 'msg2'); + expect(mesh.getStats().messagesBroadcast).toBe(2); + }); + + test('deve enfileirar para peers offline', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.peers.get('a2').state = PeerState.INACTIVE; + const result = mesh.broadcast('a1', 'msg'); + expect(result.queued).toBe(1); + expect(mesh.getQueueSize('a2')).toBe(1); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // REQUEST / RESPONSE + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('request() & reply()', () => { + test('deve resolver quando reply e chamado', async () => { + mesh.join('a1'); + mesh.join('a2'); + + const promise = mesh.request('a1', 'a2', { q: 'status?' }); + + // Pegar o messageId da request enviada + const sentEvents = []; + mesh.on(MeshEvent.MESSAGE_SENT, (e) => sentEvents.push(e)); + + // O promise ja foi criado, precisamos encontrar o pending request + const [requestId] = Array.from(mesh.pendingRequests.keys()); + + mesh.reply('a2', requestId, { status: 'ok' }); + + const response = await promise; + expect(response.from).toBe('a2'); + expect(response.payload.status).toBe('ok'); + }); + + test('deve rejeitar com timeout', async () => { + mesh.join('a1'); + mesh.join('a2'); + + const promise = mesh.request('a1', 'a2', 'ping', { timeout: 1000 }); + + jest.advanceTimersByTime(1100); + + await expect(promise).rejects.toThrow('timed out'); + }); + + test('reply deve retornar false para request inexistente', () => { + mesh.join('a1'); + expect(mesh.reply('a1', 'fake-id', 'nope')).toBe(false); + }); + + test('reply deve lancar erro para peer inexistente', () => { + expect(() => mesh.reply('ghost', 'id', 'data')).toThrow('not found'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // TOPICS / PUB-SUB + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('subscribe() / unsubscribe()', () => { + test('deve inscrever peer em topico', () => { + mesh.join('a1'); + mesh.subscribe('a1', 'deploy'); + expect(mesh.getTopicSubscribers('deploy')).toContain('a1'); + }); + + test('deve desinscrever peer de topico', () => { + mesh.join('a1'); + mesh.subscribe('a1', 'deploy'); + mesh.unsubscribe('a1', 'deploy'); + expect(mesh.getTopicSubscribers('deploy')).not.toContain('a1'); + }); + + test('deve remover topico vazio', () => { + mesh.join('a1'); + mesh.subscribe('a1', 'deploy'); + mesh.unsubscribe('a1', 'deploy'); + expect(mesh.topics.has('deploy')).toBe(false); + }); + + test('deve lancar erro para peer inexistente', () => { + expect(() => mesh.subscribe('ghost', 'topic')).toThrow('not found'); + expect(() => mesh.unsubscribe('ghost', 'topic')).toThrow('not found'); + }); + + test('deve lancar erro para topico invalido', () => { + mesh.join('a1'); + expect(() => mesh.subscribe('a1', '')).toThrow('topic is required'); + expect(() => mesh.subscribe('a1', null)).toThrow('topic is required'); + }); + + test('getTopicSubscribers deve retornar array vazio para topico inexistente', () => { + expect(mesh.getTopicSubscribers('ghost')).toEqual([]); + }); + + test('deve suportar multiplos subscribers por topico', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.join('a3'); + mesh.subscribe('a1', 'events'); + mesh.subscribe('a2', 'events'); + mesh.subscribe('a3', 'events'); + expect(mesh.getTopicSubscribers('events')).toHaveLength(3); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // ROUTING & TOPOLOGY + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('getShortestPath()', () => { + test('deve retornar caminho direto entre adjacentes', () => { + mesh.join('a1'); + mesh.join('a2'); + const route = mesh.getShortestPath('a1', 'a2'); + expect(route).toEqual(['a1', 'a2']); + }); + + test('deve retornar [self] para mesmo peer', () => { + mesh.join('a1'); + expect(mesh.getShortestPath('a1', 'a1')).toEqual(['a1']); + }); + + test('deve retornar null para peer inexistente', () => { + mesh.join('a1'); + expect(mesh.getShortestPath('a1', 'ghost')).toBeNull(); + expect(mesh.getShortestPath('ghost', 'a1')).toBeNull(); + }); + + test('deve encontrar caminho multi-hop', () => { + // Criar topologia: a1 - a2 - a3 (remover adjacencia direta a1-a3) + mesh.join('a1'); + mesh.join('a2'); + mesh.join('a3'); + + // Remover adjacencia direta a1<->a3 + mesh.adjacency.get('a1').delete('a3'); + mesh.adjacency.get('a3').delete('a1'); + + const route = mesh.getShortestPath('a1', 'a3'); + expect(route).toEqual(['a1', 'a2', 'a3']); + }); + + test('deve retornar null quando nao ha caminho', () => { + mesh.join('a1'); + mesh.join('a2'); + + // Remover toda adjacencia + mesh.adjacency.get('a1').clear(); + mesh.adjacency.get('a2').clear(); + + expect(mesh.getShortestPath('a1', 'a2')).toBeNull(); + }); + }); + + describe('getRoute()', () => { + test('deve ser alias para getShortestPath', () => { + mesh.join('a1'); + mesh.join('a2'); + expect(mesh.getRoute('a1', 'a2')).toEqual(mesh.getShortestPath('a1', 'a2')); + }); + }); + + describe('getTopology()', () => { + test('deve retornar topologia vazia para mesh vazia', () => { + const topo = mesh.getTopology(); + expect(topo.nodes).toEqual([]); + expect(topo.edges).toEqual([]); + expect(topo.peerCount).toBe(0); + }); + + test('deve retornar nos e arestas', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.join('a3'); + const topo = mesh.getTopology(); + expect(topo.peerCount).toBe(3); + expect(topo.edgeCount).toBe(3); // fully connected: 3 edges + expect(topo.nodes).toHaveLength(3); + }); + + test('deve evitar arestas duplicadas', () => { + mesh.join('a1'); + mesh.join('a2'); + const topo = mesh.getTopology(); + // a1-a2 aparece apenas uma vez + expect(topo.edges).toHaveLength(1); + }); + + test('deve incluir timestamp', () => { + const topo = mesh.getTopology(); + expect(topo.timestamp).toBeDefined(); + expect(typeof topo.timestamp).toBe('number'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // MESSAGE QUEUE + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('message queue', () => { + test('getQueuedMessages deve retornar mensagens enfileiradas', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.peers.get('a2').state = PeerState.INACTIVE; + mesh.send('a1', 'a2', { data: 'queued' }); + const msgs = mesh.getQueuedMessages('a2'); + expect(msgs).toHaveLength(1); + expect(msgs[0].payload.data).toBe('queued'); + }); + + test('getQueueSize deve retornar tamanho da fila', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.peers.get('a2').state = PeerState.INACTIVE; + mesh.send('a1', 'a2', 'msg1'); + mesh.send('a1', 'a2', 'msg2'); + expect(mesh.getQueueSize('a2')).toBe(2); + }); + + test('purgeQueue deve limpar a fila', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.peers.get('a2').state = PeerState.INACTIVE; + mesh.send('a1', 'a2', 'msg'); + const purged = mesh.purgeQueue('a2'); + expect(purged).toBe(1); + expect(mesh.getQueueSize('a2')).toBe(0); + }); + + test('purgeQueue deve retornar 0 para fila inexistente', () => { + expect(mesh.purgeQueue('ghost')).toBe(0); + }); + + test('getQueuedMessages deve retornar vazio para peer sem fila', () => { + expect(mesh.getQueuedMessages('ghost')).toEqual([]); + }); + + test('deve emitir queue-overflow quando fila cheia', () => { + const ctx = createMesh({ maxQueueSize: 2 }); + const m = ctx.mesh; + m.join('a1'); + m.join('a2'); + m.peers.get('a2').state = PeerState.INACTIVE; + + const handler = jest.fn(); + m.on(MeshEvent.QUEUE_OVERFLOW, handler); + + m.send('a1', 'a2', 'msg1'); + m.send('a1', 'a2', 'msg2'); + m.send('a1', 'a2', 'msg3'); // overflow + + expect(handler).toHaveBeenCalledTimes(1); + expect(m.getQueueSize('a2')).toBe(2); + expect(m.getStats().messagesDropped).toBe(1); + + m.destroy(); + cleanupDir(ctx.tmpDir); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // RATE LIMITING + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('rate limiting', () => { + test('deve permitir envios dentro do limite', () => { + const ctx = createMesh({ rateLimit: { tokensPerInterval: 5, interval: 60000 } }); + const m = ctx.mesh; + m.join('a1'); + m.join('a2'); + + expect(() => { + for (let i = 0; i < 5; i++) { + m.send('a1', 'a2', `msg${i}`); + } + }).not.toThrow(); + + m.destroy(); + cleanupDir(ctx.tmpDir); + }); + + test('deve bloquear envios acima do limite', () => { + const ctx = createMesh({ rateLimit: { tokensPerInterval: 3, interval: 60000 } }); + const m = ctx.mesh; + m.join('a1'); + m.join('a2'); + + m.send('a1', 'a2', 'msg1'); + m.send('a1', 'a2', 'msg2'); + m.send('a1', 'a2', 'msg3'); + + expect(() => m.send('a1', 'a2', 'msg4')).toThrow('Rate limit exceeded'); + + m.destroy(); + cleanupDir(ctx.tmpDir); + }); + + test('deve reabastecer tokens apos intervalo', () => { + const ctx = createMesh({ rateLimit: { tokensPerInterval: 2, interval: 1000 } }); + const m = ctx.mesh; + m.join('a1'); + m.join('a2'); + + m.send('a1', 'a2', 'msg1'); + m.send('a1', 'a2', 'msg2'); + expect(() => m.send('a1', 'a2', 'msg3')).toThrow('Rate limit'); + + jest.advanceTimersByTime(1000); + + expect(() => m.send('a1', 'a2', 'msg4')).not.toThrow(); + + m.destroy(); + cleanupDir(ctx.tmpDir); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEARTBEAT & PRUNING + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('heartbeat & pruning', () => { + test('deve detectar peers com timeout', () => { + mesh.join('a1'); + mesh.join('a2'); + + const handler = jest.fn(); + mesh.on(MeshEvent.PEER_TIMEOUT, handler); + + // Avançar tempo alem do peerTimeout + jest.advanceTimersByTime(4000); + mesh._runHeartbeat(); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ agentId: expect.any(String) }) + ); + }); + + test('deve marcar peer como timeout', () => { + mesh.join('a1'); + jest.advanceTimersByTime(4000); + mesh._runHeartbeat(); + expect(mesh.peers.get('a1').state).toBe(PeerState.TIMEOUT); + }); + + test('deve remover adjacencias de peer timeout', () => { + mesh.join('a1'); + mesh.join('a2'); + jest.advanceTimersByTime(4000); + mesh._runHeartbeat(); + expect(mesh.adjacency.get('a1').size).toBe(0); + expect(mesh.adjacency.get('a2').size).toBe(0); + }); + + test('nao deve marcar peer ativo com timeout', () => { + mesh.join('a1'); + mesh.join('a2'); + + jest.advanceTimersByTime(2000); + mesh.send('a1', 'a2', 'keep-alive'); // atualiza lastSeen de a1 + + jest.advanceTimersByTime(2000); + mesh._runHeartbeat(); + + // a1 enviou mensagem recentemente, nao deveria ter timeout + expect(mesh.peers.get('a1').state).toBe(PeerState.ACTIVE); + }); + + test('startHeartbeat deve iniciar timer', () => { + mesh.startHeartbeat(); + expect(mesh._heartbeatTimer).not.toBeNull(); + expect(mesh._started).toBe(true); + }); + + test('stopHeartbeat deve parar timer', () => { + mesh.startHeartbeat(); + mesh.stopHeartbeat(); + expect(mesh._heartbeatTimer).toBeNull(); + expect(mesh._started).toBe(false); + }); + + test('startHeartbeat duplo nao deve criar dois timers', () => { + mesh.startHeartbeat(); + const timer1 = mesh._heartbeatTimer; + mesh.startHeartbeat(); + expect(mesh._heartbeatTimer).toBe(timer1); + }); + + test('deve purge mensagens expiradas na fila durante heartbeat', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.peers.get('a2').state = PeerState.INACTIVE; + + mesh.send('a1', 'a2', 'will-expire'); + expect(mesh.getQueueSize('a2')).toBe(1); + + // Avançar tempo alem do messageTTL (5000ms) + jest.advanceTimersByTime(6000); + mesh._runHeartbeat(); + + expect(mesh.getQueueSize('a2')).toBe(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // PARTITION DETECTION + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('detectPartitions()', () => { + test('deve retornar uma unica particao para rede conectada', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.join('a3'); + const partitions = mesh.detectPartitions(); + expect(partitions).toHaveLength(1); + expect(partitions[0]).toHaveLength(3); + }); + + test('deve detectar duas particoes', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.join('a3'); + mesh.join('a4'); + + // Desconectar a1,a2 de a3,a4 + mesh.adjacency.get('a1').delete('a3'); + mesh.adjacency.get('a1').delete('a4'); + mesh.adjacency.get('a2').delete('a3'); + mesh.adjacency.get('a2').delete('a4'); + mesh.adjacency.get('a3').delete('a1'); + mesh.adjacency.get('a3').delete('a2'); + mesh.adjacency.get('a4').delete('a1'); + mesh.adjacency.get('a4').delete('a2'); + + const partitions = mesh.detectPartitions(); + expect(partitions).toHaveLength(2); + }); + + test('deve emitir partition-detected quando ha split', () => { + mesh.join('a1'); + mesh.join('a2'); + + mesh.adjacency.get('a1').clear(); + mesh.adjacency.get('a2').clear(); + + const handler = jest.fn(); + mesh.on(MeshEvent.PARTITION_DETECTED, handler); + mesh.detectPartitions(); + expect(handler).toHaveBeenCalledTimes(1); + }); + + test('nao deve emitir para rede conectada', () => { + mesh.join('a1'); + mesh.join('a2'); + const handler = jest.fn(); + mesh.on(MeshEvent.PARTITION_DETECTED, handler); + mesh.detectPartitions(); + expect(handler).not.toHaveBeenCalled(); + }); + + test('deve retornar array vazio para mesh vazia', () => { + expect(mesh.detectPartitions()).toEqual([]); + }); + + test('deve ignorar peers com timeout', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.peers.get('a2').state = PeerState.TIMEOUT; + const partitions = mesh.detectPartitions(); + expect(partitions).toHaveLength(1); + expect(partitions[0]).toEqual(['a1']); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // NETWORK HEALTH & STATS + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('getNetworkHealth()', () => { + test('deve retornar saude 1.0 para rede vazia', () => { + const health = mesh.getNetworkHealth(); + expect(health.healthScore).toBe(1.0); + expect(health.totalPeers).toBe(0); + }); + + test('deve retornar saude 1.0 para rede saudavel', () => { + mesh.join('a1'); + mesh.join('a2'); + const health = mesh.getNetworkHealth(); + expect(health.healthScore).toBe(1.0); + expect(health.activePeers).toBe(2); + }); + + test('deve reduzir score com peers inativos', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.peers.get('a2').state = PeerState.TIMEOUT; + const health = mesh.getNetworkHealth(); + expect(health.healthScore).toBeLessThan(1.0); + expect(health.inactivePeers).toBe(1); + }); + + test('deve incluir contagem de filas', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.peers.get('a2').state = PeerState.INACTIVE; + mesh.send('a1', 'a2', 'queued'); + const health = mesh.getNetworkHealth(); + expect(health.totalQueuedMessages).toBe(1); + }); + }); + + describe('getMeshStats()', () => { + test('deve retornar estatisticas completas', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.send('a1', 'a2', 'msg'); + mesh.subscribe('a1', 'topic1'); + + const stats = mesh.getMeshStats(); + expect(stats.messagesSent).toBe(1); + expect(stats.topology.peerCount).toBe(2); + expect(stats.topics.count).toBe(1); + expect(stats.topics.subscriptions).toBe(1); + expect(stats.health).toBeDefined(); + expect(stats.queues).toBeDefined(); + }); + }); + + describe('getStats()', () => { + test('deve retornar estatisticas basicas', () => { + mesh.join('a1'); + const stats = mesh.getStats(); + expect(stats.peerCount).toBe(1); + expect(stats.topicCount).toBe(0); + expect(stats.pendingRequests).toBe(0); + expect(stats.timestamp).toBeDefined(); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // PERSISTENCE + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('persistence', () => { + test('save() deve criar arquivo de topologia', async () => { + mesh.join('a1', { capabilities: ['coding'] }); + await mesh.save(); + + const filePath = path.resolve(tmpDir, '.aiox/mesh/topology.json'); + expect(fs.existsSync(filePath)).toBe(true); + + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + expect(data.schemaVersion).toBe('agent-mesh-v1'); + expect(data.peers).toHaveLength(1); + expect(data.peers[0].id).toBe('a1'); + }); + + test('load() deve restaurar topologia do disco', async () => { + mesh.join('a1', { capabilities: ['coding'], topics: ['deploy'] }); + mesh.join('a2'); + await mesh.save(); + + // Criar nova instancia e carregar + const mesh2 = new AgentMeshNetwork(tmpDir, { autoStart: false }); + const loaded = await mesh2.load(); + expect(loaded).toBe(true); + expect(mesh2.peers.size).toBe(2); + + const peer = mesh2.getPeer('a1'); + expect(peer.capabilities).toEqual(['coding']); + expect(peer.topics).toContain('deploy'); + + mesh2.destroy(); + }); + + test('load() deve retornar false se arquivo nao existe', async () => { + const ctx = createMesh(); + const loaded = await ctx.mesh.load(); + expect(loaded).toBe(false); + ctx.mesh.destroy(); + cleanupDir(ctx.tmpDir); + }); + + test('load() deve retornar false para arquivo corrompido', async () => { + const dir = path.resolve(tmpDir, '.aiox/mesh'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'topology.json'), 'INVALID JSON', 'utf-8'); + + const loaded = await mesh.load(); + expect(loaded).toBe(false); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // ROUTING (MULTI-HOP) + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('message routing (multi-hop)', () => { + test('deve rotear mensagem via peer intermediario', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.join('a3'); + + // Remover adjacencia direta a1<->a3 + mesh.adjacency.get('a1').delete('a3'); + mesh.adjacency.get('a3').delete('a1'); + + const handler = jest.fn(); + mesh.on(MeshEvent.ROUTE_FOUND, handler); + + mesh.send('a1', 'a3', 'routed-msg'); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'a1', + to: 'a3', + hops: ['a2'], + }) + ); + expect(mesh.getStats().messagesRouted).toBe(1); + }); + + test('deve enfileirar quando nao ha rota', () => { + mesh.join('a1'); + mesh.join('a2'); + + // Desconectar completamente + mesh.adjacency.get('a1').clear(); + mesh.adjacency.get('a2').clear(); + + mesh.send('a1', 'a2', 'no-route'); + expect(mesh.getQueueSize('a2')).toBe(1); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // DESTROY + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('destroy()', () => { + test('deve limpar todos os recursos', () => { + mesh.join('a1'); + mesh.join('a2'); + mesh.subscribe('a1', 'topic'); + mesh.startHeartbeat(); + + mesh.destroy(); + + expect(mesh.peers.size).toBe(0); + expect(mesh.adjacency.size).toBe(0); + expect(mesh.topics.size).toBe(0); + expect(mesh.queues.size).toBe(0); + expect(mesh._heartbeatTimer).toBeNull(); + }); + + test('deve rejeitar pending requests', async () => { + mesh.join('a1'); + mesh.join('a2'); + + const promise = mesh.request('a1', 'a2', 'ping'); + mesh.destroy(); + + await expect(promise).rejects.toThrow('destroyed'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════════ + // SAFE EMIT + // ═══════════════════════════════════════════════════════════════════════════════ + + describe('_emitSafe()', () => { + test('nao deve lancar erro quando nao ha listener para error', () => { + expect(() => { + mesh._emitSafe('error', new Error('test')); + }).not.toThrow(); + }); + + test('deve emitir error quando ha listener', () => { + const handler = jest.fn(); + mesh.on('error', handler); + mesh._emitSafe('error', new Error('test')); + expect(handler).toHaveBeenCalled(); + }); + + test('deve emitir eventos normais', () => { + const handler = jest.fn(); + mesh.on('custom-event', handler); + mesh._emitSafe('custom-event', { data: 1 }); + expect(handler).toHaveBeenCalledWith({ data: 1 }); + }); + }); +});