UNPKG

claude-flow

Version:

Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration

394 lines 15.3 kB
/** * V3 Byzantine Fault Tolerant Consensus * PBFT-style consensus for handling malicious or faulty nodes */ import { EventEmitter } from 'events'; import { createHash } from 'node:crypto'; import { SWARM_CONSTANTS, } from '../types.js'; export class ByzantineConsensus extends EventEmitter { config; node; nodes = new Map(); proposals = new Map(); messageLog = new Map(); proposalCounter = 0; viewChangeTimeout; transport; constructor(nodeId, config = {}) { super(); this.config = { threshold: config.threshold ?? SWARM_CONSTANTS.DEFAULT_CONSENSUS_THRESHOLD, timeoutMs: config.timeoutMs ?? SWARM_CONSTANTS.DEFAULT_CONSENSUS_TIMEOUT_MS, maxRounds: config.maxRounds ?? 10, requireQuorum: config.requireQuorum ?? true, maxFaultyNodes: config.maxFaultyNodes, // #G2: undefined → byzantineF() derives from cluster size; a value caps it viewChangeTimeoutMs: config.viewChangeTimeoutMs ?? 5000, transport: config.transport, }; this.transport = config.transport; this.node = { id: nodeId, isPrimary: false, viewNumber: 0, sequenceNumber: 0, preparedMessages: new Map(), committedMessages: new Map(), }; // ADR-095 G2 — when a transport is wired, route inbound PBFT messages // through the protocol handlers. The transport handles signature // verification (if signing is enabled) before we ever see the message. if (this.transport) { this.transport.onMessage(async (msg) => { await this.handleInboundMessage(msg); }); } } /** * ADR-095 G2 — dispatch an inbound transport message to the right PBFT * handler. The transport has already verified the signature (if signing * is on); here we only need to demux by type and reconstruct the * ByzantineMessage shape the handlers expect. */ async handleInboundMessage(msg) { const p = msg.payload; if (!p || typeof p.viewNumber !== 'number' || typeof p.sequenceNumber !== 'number' || typeof p.digest !== 'string') return; const bm = { type: msg.type ?? p.type ?? 'prepare', viewNumber: p.viewNumber, sequenceNumber: p.sequenceNumber, digest: p.digest, senderId: msg.from, timestamp: p.timestamp ? new Date(p.timestamp) : new Date(), payload: p.payload, signature: msg.signature, }; switch (bm.type) { case 'pre-prepare': await this.handlePrePrepare(bm); break; case 'prepare': await this.handlePrepare(bm); break; case 'commit': await this.handleCommit(bm); break; default: break; } } async initialize() { this.emit('initialized', { nodeId: this.node.id }); } async shutdown() { if (this.viewChangeTimeout) { clearTimeout(this.viewChangeTimeout); } this.emit('shutdown'); } addNode(nodeId, isPrimary = false) { this.nodes.set(nodeId, { id: nodeId, isPrimary, viewNumber: 0, sequenceNumber: 0, preparedMessages: new Map(), committedMessages: new Map(), }); if (isPrimary && this.node.id === nodeId) { this.node.isPrimary = true; } } removeNode(nodeId) { this.nodes.delete(nodeId); } /** * ADR-095 G2 — BFT fault tolerance f, derived from the *actual* cluster * size unless explicitly capped via config.maxFaultyNodes. PBFT needs * n ≥ 3f+1, so f = floor((n-1)/3) where n = self + known peers. The * config value (if set) acts as an upper bound — never exceed what the * operator declared the cluster can tolerate. */ byzantineF() { const n = this.nodes.size + 1; // self + known peers const derived = Math.max(1, Math.floor((n - 1) / 3)); const cap = this.config.maxFaultyNodes; return cap === undefined ? derived : Math.min(derived, cap); } electPrimary() { const nodeIds = [this.node.id, ...Array.from(this.nodes.keys())]; const primaryIndex = this.node.viewNumber % nodeIds.length; const primaryId = nodeIds[primaryIndex]; this.node.isPrimary = primaryId === this.node.id; for (const [id, node] of this.nodes) { node.isPrimary = id === primaryId; } this.emit('primary.elected', { primaryId, viewNumber: this.node.viewNumber }); return primaryId; } async propose(value) { if (!this.node.isPrimary) { throw new Error('Only primary can propose values'); } this.proposalCounter++; const sequenceNumber = ++this.node.sequenceNumber; const digest = this.computeDigest(value); const proposalId = `bft_${this.node.viewNumber}_${sequenceNumber}`; const proposal = { id: proposalId, proposerId: this.node.id, value, term: this.node.viewNumber, timestamp: new Date(), votes: new Map(), status: 'pending', }; this.proposals.set(proposalId, proposal); // Phase 1: Pre-prepare const prePrepareMsg = { type: 'pre-prepare', viewNumber: this.node.viewNumber, sequenceNumber, digest, senderId: this.node.id, timestamp: new Date(), payload: value, }; await this.broadcastMessage(prePrepareMsg); // Self-prepare await this.handlePrepare({ type: 'prepare', viewNumber: this.node.viewNumber, sequenceNumber, digest, senderId: this.node.id, timestamp: new Date(), }); return proposal; } async vote(proposalId, vote) { const proposal = this.proposals.get(proposalId); if (!proposal || proposal.status !== 'pending') { return; } proposal.votes.set(vote.voterId, vote); // Check consensus const f = this.byzantineF(); const n = this.nodes.size + 1; const requiredVotes = 2 * f + 1; const approvingVotes = Array.from(proposal.votes.values()).filter(v => v.approve).length; if (approvingVotes >= requiredVotes) { proposal.status = 'accepted'; this.emit('consensus.achieved', { proposalId, approved: true }); } else if (proposal.votes.size >= n && approvingVotes < requiredVotes) { proposal.status = 'rejected'; this.emit('consensus.achieved', { proposalId, approved: false }); } } async awaitConsensus(proposalId) { const startTime = Date.now(); return new Promise((resolve, reject) => { const checkInterval = setInterval(() => { const proposal = this.proposals.get(proposalId); if (!proposal) { clearInterval(checkInterval); reject(new Error(`Proposal ${proposalId} not found`)); return; } if (proposal.status !== 'pending') { clearInterval(checkInterval); resolve(this.createResult(proposal, Date.now() - startTime)); return; } if (Date.now() - startTime > (this.config.timeoutMs ?? 30000)) { clearInterval(checkInterval); proposal.status = 'expired'; resolve(this.createResult(proposal, Date.now() - startTime)); } }, 10); }); } // ===== MESSAGE HANDLERS ===== async handlePrePrepare(message) { // Validate message if (message.viewNumber !== this.node.viewNumber) { return; } // Accept pre-prepare from primary const proposalId = `bft_${message.viewNumber}_${message.sequenceNumber}`; if (!this.proposals.has(proposalId) && message.payload !== undefined) { const proposal = { id: proposalId, proposerId: message.senderId, value: message.payload, term: message.viewNumber, timestamp: message.timestamp, votes: new Map(), status: 'pending', }; this.proposals.set(proposalId, proposal); } // Send prepare message const prepareMsg = { type: 'prepare', viewNumber: message.viewNumber, sequenceNumber: message.sequenceNumber, digest: message.digest, senderId: this.node.id, timestamp: new Date(), }; await this.broadcastMessage(prepareMsg); await this.handlePrepare(prepareMsg); } async handlePrepare(message) { const key = `${message.viewNumber}_${message.sequenceNumber}`; if (!this.messageLog.has(key)) { this.messageLog.set(key, []); } const messages = this.messageLog.get(key); const hasPrepare = messages.some(m => m.type === 'prepare' && m.senderId === message.senderId); if (!hasPrepare) { messages.push(message); } // Check if prepared (2f + 1 prepare messages) const f = this.byzantineF(); const prepareCount = messages.filter(m => m.type === 'prepare').length; if (prepareCount >= 2 * f + 1) { const proposalId = `bft_${message.viewNumber}_${message.sequenceNumber}`; this.node.preparedMessages.set(key, messages); // Send commit message const commitMsg = { type: 'commit', viewNumber: message.viewNumber, sequenceNumber: message.sequenceNumber, digest: message.digest, senderId: this.node.id, timestamp: new Date(), }; await this.broadcastMessage(commitMsg); await this.handleCommit(commitMsg); // Record vote const proposal = this.proposals.get(proposalId); if (proposal) { proposal.votes.set(this.node.id, { voterId: this.node.id, approve: true, confidence: 1.0, timestamp: new Date(), }); } } } async handleCommit(message) { const key = `${message.viewNumber}_${message.sequenceNumber}`; if (!this.messageLog.has(key)) { this.messageLog.set(key, []); } const messages = this.messageLog.get(key); const hasCommit = messages.some(m => m.type === 'commit' && m.senderId === message.senderId); if (!hasCommit) { messages.push(message); } // Check if committed (2f + 1 commit messages) const f = this.byzantineF(); const commitCount = messages.filter(m => m.type === 'commit').length; if (commitCount >= 2 * f + 1) { const proposalId = `bft_${message.viewNumber}_${message.sequenceNumber}`; this.node.committedMessages.set(key, messages); const proposal = this.proposals.get(proposalId); if (proposal && proposal.status === 'pending') { proposal.status = 'accepted'; this.emit('consensus.achieved', { proposalId, approved: true }); } } } // ===== VIEW CHANGE ===== async initiateViewChange() { this.node.viewNumber++; this.emit('view.changing', { newViewNumber: this.node.viewNumber }); // Elect new primary this.electPrimary(); this.emit('view.changed', { viewNumber: this.node.viewNumber }); } // ===== PRIVATE METHODS ===== async broadcastMessage(message) { // Always emit for observability / legacy external relay. this.emit('message.broadcast', { message }); for (const node of this.nodes.values()) { this.emit('message.sent', { to: node.id, message }); } // ADR-095 G2 — when a transport is wired, actually send the message // over it (signed by the transport if signing is enabled). This is // the real inter-node path; the emits above stay for observers. if (this.transport) { const cm = { type: message.type, payload: { type: message.type, viewNumber: message.viewNumber, sequenceNumber: message.sequenceNumber, digest: message.digest, timestamp: message.timestamp.toISOString(), payload: message.payload, }, viewNumber: message.viewNumber, }; try { await this.transport.broadcast(cm); } catch { // Transport failures are non-fatal at the protocol layer — the // proposal will simply not reach consensus and time out, which is // the correct failure mode (no liveness, but no incorrect commit). } } } computeDigest(value) { // ADR-095 G2 — was a 32-bit string-hash "for demonstration"; replaced // with sha256 so digests are collision-resistant (PBFT relies on the // digest uniquely identifying the request being agreed on). return createHash('sha256').update(JSON.stringify(value ?? null)).digest('hex'); } createResult(proposal, durationMs) { const n = this.nodes.size + 1; const approvingVotes = Array.from(proposal.votes.values()).filter(v => v.approve).length; return { proposalId: proposal.id, approved: proposal.status === 'accepted', approvalRate: proposal.votes.size > 0 ? approvingVotes / proposal.votes.size : 0, participationRate: proposal.votes.size / n, finalValue: proposal.value, rounds: 3, // pre-prepare, prepare, commit durationMs, }; } // ===== STATE QUERIES ===== isPrimary() { return this.node.isPrimary; } getViewNumber() { return this.node.viewNumber; } getSequenceNumber() { return this.node.sequenceNumber; } getPreparedCount() { return this.node.preparedMessages.size; } getCommittedCount() { return this.node.committedMessages.size; } getMaxFaultyNodes() { const n = this.nodes.size + 1; return Math.floor((n - 1) / 3); } canTolerate(faultyCount) { return faultyCount <= this.getMaxFaultyNodes(); } } export function createByzantineConsensus(nodeId, config) { return new ByzantineConsensus(nodeId, config); } //# sourceMappingURL=byzantine.js.map