git-subtree-dir: vendor/ruvector git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
546 lines
20 KiB
JavaScript
546 lines
20 KiB
JavaScript
"use strict";
|
|
/**
|
|
* Coordination Protocol - Inter-agent communication and consensus
|
|
*
|
|
* Handles:
|
|
* - Inter-agent messaging
|
|
* - Consensus for critical operations
|
|
* - Event-driven coordination
|
|
* - Pub/Sub integration
|
|
*/
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.CoordinationProtocol = void 0;
|
|
const events_1 = require("events");
|
|
const child_process_1 = require("child_process");
|
|
const util_1 = require("util");
|
|
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
class CoordinationProtocol extends events_1.EventEmitter {
|
|
constructor(config) {
|
|
super();
|
|
this.config = config;
|
|
this.messageQueue = [];
|
|
this.sentMessages = new Map();
|
|
this.pendingResponses = new Map();
|
|
this.consensusProposals = new Map();
|
|
this.pubSubTopics = new Map();
|
|
this.knownNodes = new Set();
|
|
this.lastHeartbeat = new Map();
|
|
this.messageCounter = 0;
|
|
this.initialize();
|
|
}
|
|
/**
|
|
* Initialize coordination protocol
|
|
*/
|
|
async initialize() {
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Initializing protocol...`);
|
|
// Initialize pub/sub topics
|
|
for (const topicName of this.config.pubSubTopics) {
|
|
this.createTopic(topicName);
|
|
}
|
|
// Start heartbeat
|
|
this.startHeartbeat();
|
|
// Start message processing
|
|
this.startMessageProcessing();
|
|
if (this.config.enableClaudeFlowHooks) {
|
|
try {
|
|
await execAsync(`npx claude-flow@alpha hooks pre-task --description "Initialize coordination protocol for node ${this.config.nodeId}"`);
|
|
}
|
|
catch (error) {
|
|
console.warn(`[CoordinationProtocol:${this.config.nodeId}] Claude-flow hooks not available`);
|
|
}
|
|
}
|
|
this.emit('protocol:initialized');
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Protocol initialized`);
|
|
}
|
|
/**
|
|
* Send message to another node
|
|
*/
|
|
async sendMessage(to, type, payload, options = {}) {
|
|
const message = {
|
|
id: `msg-${this.config.nodeId}-${this.messageCounter++}`,
|
|
type,
|
|
from: this.config.nodeId,
|
|
to,
|
|
topic: options.topic,
|
|
payload,
|
|
timestamp: Date.now(),
|
|
ttl: options.ttl || this.config.messageTimeout,
|
|
priority: options.priority || 0,
|
|
};
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Sending ${type} message ${message.id} to ${to}`);
|
|
// Add to queue
|
|
this.enqueueMessage(message);
|
|
// Track sent message
|
|
this.sentMessages.set(message.id, message);
|
|
// If expecting response, create promise
|
|
if (options.expectResponse) {
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
this.pendingResponses.delete(message.id);
|
|
reject(new Error(`Message ${message.id} timed out`));
|
|
}, message.ttl);
|
|
this.pendingResponses.set(message.id, {
|
|
resolve,
|
|
reject,
|
|
timeout,
|
|
});
|
|
});
|
|
}
|
|
this.emit('message:sent', message);
|
|
}
|
|
/**
|
|
* Broadcast message to all nodes
|
|
*/
|
|
async broadcastMessage(type, payload, options = {}) {
|
|
const recipients = Array.from(this.knownNodes);
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Broadcasting ${type} message to ${recipients.length} nodes`);
|
|
for (const recipient of recipients) {
|
|
await this.sendMessage(recipient, type, payload, {
|
|
...options,
|
|
expectResponse: false,
|
|
});
|
|
}
|
|
this.emit('message:broadcast', { type, recipientCount: recipients.length });
|
|
}
|
|
/**
|
|
* Receive and handle message
|
|
*/
|
|
async receiveMessage(message) {
|
|
// Check if message is expired
|
|
if (Date.now() - message.timestamp > message.ttl) {
|
|
console.warn(`[CoordinationProtocol:${this.config.nodeId}] Received expired message ${message.id}`);
|
|
return;
|
|
}
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Received ${message.type} message ${message.id} from ${message.from}`);
|
|
// Handle different message types
|
|
switch (message.type) {
|
|
case 'request':
|
|
await this.handleRequest(message);
|
|
break;
|
|
case 'response':
|
|
await this.handleResponse(message);
|
|
break;
|
|
case 'broadcast':
|
|
await this.handleBroadcast(message);
|
|
break;
|
|
case 'consensus':
|
|
await this.handleConsensusMessage(message);
|
|
break;
|
|
default:
|
|
console.warn(`[CoordinationProtocol:${this.config.nodeId}] Unknown message type: ${message.type}`);
|
|
}
|
|
// Update last contact time
|
|
this.lastHeartbeat.set(message.from, Date.now());
|
|
this.knownNodes.add(message.from);
|
|
this.emit('message:received', message);
|
|
}
|
|
/**
|
|
* Handle request message
|
|
*/
|
|
async handleRequest(message) {
|
|
this.emit('request:received', message);
|
|
// Application can handle request and send response
|
|
// Example auto-response for health checks
|
|
if (message.payload.type === 'health_check') {
|
|
await this.sendResponse(message.id, message.from, {
|
|
status: 'healthy',
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Send response to a request
|
|
*/
|
|
async sendResponse(requestId, to, payload) {
|
|
const response = {
|
|
id: `resp-${requestId}`,
|
|
type: 'response',
|
|
from: this.config.nodeId,
|
|
to,
|
|
payload: {
|
|
requestId,
|
|
...payload,
|
|
},
|
|
timestamp: Date.now(),
|
|
ttl: this.config.messageTimeout,
|
|
priority: 1,
|
|
};
|
|
await this.sendMessage(to, 'response', response.payload);
|
|
}
|
|
/**
|
|
* Handle response message
|
|
*/
|
|
async handleResponse(message) {
|
|
const requestId = message.payload.requestId;
|
|
const pending = this.pendingResponses.get(requestId);
|
|
if (pending) {
|
|
clearTimeout(pending.timeout);
|
|
pending.resolve(message.payload);
|
|
this.pendingResponses.delete(requestId);
|
|
}
|
|
this.emit('response:received', message);
|
|
}
|
|
/**
|
|
* Handle broadcast message
|
|
*/
|
|
async handleBroadcast(message) {
|
|
// If message has topic, deliver to topic subscribers
|
|
if (message.topic) {
|
|
const topic = this.pubSubTopics.get(message.topic);
|
|
if (topic) {
|
|
this.deliverToTopic(message, topic);
|
|
}
|
|
}
|
|
this.emit('broadcast:received', message);
|
|
}
|
|
/**
|
|
* Propose consensus for critical operation
|
|
*/
|
|
async proposeConsensus(type, data, requiredVotes = Math.floor(this.knownNodes.size / 2) + 1) {
|
|
const proposal = {
|
|
id: `consensus-${this.config.nodeId}-${Date.now()}`,
|
|
proposer: this.config.nodeId,
|
|
type,
|
|
data,
|
|
requiredVotes,
|
|
deadline: Date.now() + this.config.consensusTimeout,
|
|
votes: new Map([[this.config.nodeId, true]]), // Proposer votes yes
|
|
status: 'pending',
|
|
};
|
|
this.consensusProposals.set(proposal.id, proposal);
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Proposing consensus ${proposal.id} (type: ${type})`);
|
|
// Broadcast consensus proposal
|
|
await this.broadcastMessage('consensus', {
|
|
action: 'propose',
|
|
proposal: {
|
|
id: proposal.id,
|
|
proposer: proposal.proposer,
|
|
type: proposal.type,
|
|
data: proposal.data,
|
|
requiredVotes: proposal.requiredVotes,
|
|
deadline: proposal.deadline,
|
|
},
|
|
});
|
|
// Wait for consensus
|
|
return new Promise((resolve) => {
|
|
const checkInterval = setInterval(() => {
|
|
const currentProposal = this.consensusProposals.get(proposal.id);
|
|
if (!currentProposal) {
|
|
clearInterval(checkInterval);
|
|
resolve(false);
|
|
return;
|
|
}
|
|
if (currentProposal.status === 'accepted') {
|
|
clearInterval(checkInterval);
|
|
resolve(true);
|
|
}
|
|
else if (currentProposal.status === 'rejected' ||
|
|
currentProposal.status === 'expired') {
|
|
clearInterval(checkInterval);
|
|
resolve(false);
|
|
}
|
|
else if (Date.now() > currentProposal.deadline) {
|
|
currentProposal.status = 'expired';
|
|
clearInterval(checkInterval);
|
|
resolve(false);
|
|
}
|
|
}, 100);
|
|
});
|
|
}
|
|
/**
|
|
* Handle consensus message
|
|
*/
|
|
async handleConsensusMessage(message) {
|
|
const { action, proposal, vote } = message.payload;
|
|
switch (action) {
|
|
case 'propose':
|
|
// New proposal received
|
|
await this.handleConsensusProposal(proposal, message.from);
|
|
break;
|
|
case 'vote':
|
|
// Vote received for proposal
|
|
await this.handleConsensusVote(vote.proposalId, message.from, vote.approve);
|
|
break;
|
|
default:
|
|
console.warn(`[CoordinationProtocol:${this.config.nodeId}] Unknown consensus action: ${action}`);
|
|
}
|
|
}
|
|
/**
|
|
* Handle consensus proposal
|
|
*/
|
|
async handleConsensusProposal(proposalData, from) {
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Received consensus proposal ${proposalData.id} from ${from}`);
|
|
// Store proposal
|
|
const proposal = {
|
|
...proposalData,
|
|
votes: new Map([[proposalData.proposer, true]]),
|
|
status: 'pending',
|
|
};
|
|
this.consensusProposals.set(proposal.id, proposal);
|
|
// Emit event for application to decide
|
|
this.emit('consensus:proposed', proposal);
|
|
// Auto-approve for demo (in production, application decides)
|
|
const approve = true;
|
|
// Send vote
|
|
await this.sendMessage(proposal.proposer, 'consensus', {
|
|
action: 'vote',
|
|
vote: {
|
|
proposalId: proposal.id,
|
|
approve,
|
|
voter: this.config.nodeId,
|
|
},
|
|
});
|
|
}
|
|
/**
|
|
* Handle consensus vote
|
|
*/
|
|
async handleConsensusVote(proposalId, voter, approve) {
|
|
const proposal = this.consensusProposals.get(proposalId);
|
|
if (!proposal || proposal.status !== 'pending') {
|
|
return;
|
|
}
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Received ${approve ? 'approval' : 'rejection'} vote from ${voter} for proposal ${proposalId}`);
|
|
// Record vote
|
|
proposal.votes.set(voter, approve);
|
|
// Count votes
|
|
const approvals = Array.from(proposal.votes.values()).filter(v => v).length;
|
|
const rejections = proposal.votes.size - approvals;
|
|
// Check if consensus reached
|
|
if (approvals >= proposal.requiredVotes) {
|
|
proposal.status = 'accepted';
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Consensus ${proposalId} accepted (${approvals}/${proposal.requiredVotes} votes)`);
|
|
this.emit('consensus:accepted', proposal);
|
|
}
|
|
else if (rejections > this.knownNodes.size - proposal.requiredVotes) {
|
|
proposal.status = 'rejected';
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Consensus ${proposalId} rejected (${rejections} rejections)`);
|
|
this.emit('consensus:rejected', proposal);
|
|
}
|
|
}
|
|
/**
|
|
* Create pub/sub topic
|
|
*/
|
|
createTopic(name, maxHistorySize = 100) {
|
|
if (this.pubSubTopics.has(name)) {
|
|
console.warn(`[CoordinationProtocol:${this.config.nodeId}] Topic ${name} already exists`);
|
|
return;
|
|
}
|
|
const topic = {
|
|
name,
|
|
subscribers: new Set(),
|
|
messageHistory: [],
|
|
maxHistorySize,
|
|
};
|
|
this.pubSubTopics.set(name, topic);
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Created topic: ${name}`);
|
|
}
|
|
/**
|
|
* Subscribe to pub/sub topic
|
|
*/
|
|
subscribe(topicName, subscriberId) {
|
|
const topic = this.pubSubTopics.get(topicName);
|
|
if (!topic) {
|
|
throw new Error(`Topic ${topicName} does not exist`);
|
|
}
|
|
topic.subscribers.add(subscriberId);
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Node ${subscriberId} subscribed to topic ${topicName}`);
|
|
this.emit('topic:subscribed', { topicName, subscriberId });
|
|
}
|
|
/**
|
|
* Unsubscribe from pub/sub topic
|
|
*/
|
|
unsubscribe(topicName, subscriberId) {
|
|
const topic = this.pubSubTopics.get(topicName);
|
|
if (!topic) {
|
|
return;
|
|
}
|
|
topic.subscribers.delete(subscriberId);
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Node ${subscriberId} unsubscribed from topic ${topicName}`);
|
|
this.emit('topic:unsubscribed', { topicName, subscriberId });
|
|
}
|
|
/**
|
|
* Publish message to topic
|
|
*/
|
|
async publishToTopic(topicName, payload) {
|
|
const topic = this.pubSubTopics.get(topicName);
|
|
if (!topic) {
|
|
throw new Error(`Topic ${topicName} does not exist`);
|
|
}
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Publishing to topic ${topicName} (${topic.subscribers.size} subscribers)`);
|
|
// Broadcast to all subscribers
|
|
for (const subscriber of topic.subscribers) {
|
|
await this.sendMessage(subscriber, 'broadcast', payload, {
|
|
topic: topicName,
|
|
});
|
|
}
|
|
// Store in message history
|
|
const message = {
|
|
id: `topic-${topicName}-${Date.now()}`,
|
|
type: 'broadcast',
|
|
from: this.config.nodeId,
|
|
topic: topicName,
|
|
payload,
|
|
timestamp: Date.now(),
|
|
ttl: this.config.messageTimeout,
|
|
priority: 0,
|
|
};
|
|
topic.messageHistory.push(message);
|
|
// Trim history if needed
|
|
if (topic.messageHistory.length > topic.maxHistorySize) {
|
|
topic.messageHistory.shift();
|
|
}
|
|
this.emit('topic:published', { topicName, message });
|
|
}
|
|
/**
|
|
* Deliver message to topic subscribers
|
|
*/
|
|
deliverToTopic(message, topic) {
|
|
// Store in history
|
|
topic.messageHistory.push(message);
|
|
if (topic.messageHistory.length > topic.maxHistorySize) {
|
|
topic.messageHistory.shift();
|
|
}
|
|
// Emit to local subscribers
|
|
this.emit('topic:message', {
|
|
topicName: topic.name,
|
|
message,
|
|
});
|
|
}
|
|
/**
|
|
* Enqueue message for processing
|
|
*/
|
|
enqueueMessage(message) {
|
|
if (this.messageQueue.length >= this.config.maxMessageQueueSize) {
|
|
console.warn(`[CoordinationProtocol:${this.config.nodeId}] Message queue full, dropping lowest priority message`);
|
|
// Remove lowest priority message
|
|
this.messageQueue.sort((a, b) => b.priority - a.priority);
|
|
this.messageQueue.pop();
|
|
}
|
|
// Insert message by priority
|
|
let insertIndex = this.messageQueue.findIndex(m => m.priority < message.priority);
|
|
if (insertIndex === -1) {
|
|
this.messageQueue.push(message);
|
|
}
|
|
else {
|
|
this.messageQueue.splice(insertIndex, 0, message);
|
|
}
|
|
}
|
|
/**
|
|
* Start message processing loop
|
|
*/
|
|
startMessageProcessing() {
|
|
this.messageProcessingTimer = setInterval(() => {
|
|
this.processMessages();
|
|
}, 10); // Process every 10ms
|
|
}
|
|
/**
|
|
* Process queued messages
|
|
*/
|
|
async processMessages() {
|
|
while (this.messageQueue.length > 0) {
|
|
const message = this.messageQueue.shift();
|
|
// Check if message expired
|
|
if (Date.now() - message.timestamp > message.ttl) {
|
|
console.warn(`[CoordinationProtocol:${this.config.nodeId}] Message ${message.id} expired before processing`);
|
|
continue;
|
|
}
|
|
// Simulate message transmission (replace with actual network call)
|
|
this.emit('message:transmit', message);
|
|
}
|
|
}
|
|
/**
|
|
* Start heartbeat mechanism
|
|
*/
|
|
startHeartbeat() {
|
|
this.heartbeatTimer = setInterval(() => {
|
|
this.sendHeartbeat();
|
|
this.checkNodeHealth();
|
|
}, this.config.heartbeatInterval);
|
|
}
|
|
/**
|
|
* Send heartbeat to all known nodes
|
|
*/
|
|
async sendHeartbeat() {
|
|
await this.broadcastMessage('request', {
|
|
type: 'heartbeat',
|
|
nodeId: this.config.nodeId,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
/**
|
|
* Check health of known nodes
|
|
*/
|
|
checkNodeHealth() {
|
|
const now = Date.now();
|
|
const unhealthyThreshold = this.config.heartbeatInterval * 3;
|
|
for (const [nodeId, lastSeen] of this.lastHeartbeat.entries()) {
|
|
if (now - lastSeen > unhealthyThreshold) {
|
|
console.warn(`[CoordinationProtocol:${this.config.nodeId}] Node ${nodeId} appears unhealthy (last seen ${Math.floor((now - lastSeen) / 1000)}s ago)`);
|
|
this.emit('node:unhealthy', { nodeId, lastSeen });
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Register a node in the network
|
|
*/
|
|
registerNode(nodeId) {
|
|
this.knownNodes.add(nodeId);
|
|
this.lastHeartbeat.set(nodeId, Date.now());
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Registered node: ${nodeId}`);
|
|
this.emit('node:registered', { nodeId });
|
|
}
|
|
/**
|
|
* Unregister a node from the network
|
|
*/
|
|
unregisterNode(nodeId) {
|
|
this.knownNodes.delete(nodeId);
|
|
this.lastHeartbeat.delete(nodeId);
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Unregistered node: ${nodeId}`);
|
|
this.emit('node:unregistered', { nodeId });
|
|
}
|
|
/**
|
|
* Get protocol status
|
|
*/
|
|
getStatus() {
|
|
return {
|
|
nodeId: this.config.nodeId,
|
|
knownNodes: this.knownNodes.size,
|
|
queuedMessages: this.messageQueue.length,
|
|
pendingResponses: this.pendingResponses.size,
|
|
activeConsensus: Array.from(this.consensusProposals.values()).filter(p => p.status === 'pending').length,
|
|
topics: Array.from(this.pubSubTopics.keys()),
|
|
};
|
|
}
|
|
/**
|
|
* Shutdown protocol gracefully
|
|
*/
|
|
async shutdown() {
|
|
console.log(`[CoordinationProtocol:${this.config.nodeId}] Shutting down protocol...`);
|
|
// Stop timers
|
|
if (this.heartbeatTimer) {
|
|
clearInterval(this.heartbeatTimer);
|
|
}
|
|
if (this.messageProcessingTimer) {
|
|
clearInterval(this.messageProcessingTimer);
|
|
}
|
|
// Process remaining messages
|
|
await this.processMessages();
|
|
// Clear pending responses
|
|
for (const [messageId, pending] of this.pendingResponses.entries()) {
|
|
clearTimeout(pending.timeout);
|
|
pending.reject(new Error('Protocol shutting down'));
|
|
}
|
|
this.pendingResponses.clear();
|
|
if (this.config.enableClaudeFlowHooks) {
|
|
try {
|
|
await execAsync(`npx claude-flow@alpha hooks post-task --task-id "protocol-${this.config.nodeId}-shutdown"`);
|
|
}
|
|
catch (error) {
|
|
console.warn(`[CoordinationProtocol:${this.config.nodeId}] Error executing shutdown hooks`);
|
|
}
|
|
}
|
|
this.emit('protocol:shutdown');
|
|
}
|
|
}
|
|
exports.CoordinationProtocol = CoordinationProtocol;
|
|
//# sourceMappingURL=coordination-protocol.js.map
|