@claude-flow/swarm 3.0.0-alpha.8 → 3.0.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-pool.d.ts.map +1 -1
- package/dist/agent-pool.js +10 -2
- package/dist/agent-pool.js.map +1 -1
- package/dist/attention-coordinator.d.ts +5 -4
- package/dist/attention-coordinator.d.ts.map +1 -1
- package/dist/attention-coordinator.js +17 -10
- package/dist/attention-coordinator.js.map +1 -1
- package/dist/consensus/byzantine.d.ts +2 -1
- package/dist/consensus/byzantine.d.ts.map +1 -1
- package/dist/consensus/byzantine.js +34 -8
- package/dist/consensus/byzantine.js.map +1 -1
- package/dist/consensus/gossip.d.ts +2 -1
- package/dist/consensus/gossip.d.ts.map +1 -1
- package/dist/consensus/gossip.js +27 -8
- package/dist/consensus/gossip.js.map +1 -1
- package/dist/consensus/index.d.ts +1 -1
- package/dist/consensus/index.d.ts.map +1 -1
- package/dist/consensus/index.js +2 -30
- package/dist/consensus/index.js.map +1 -1
- package/dist/consensus/raft.d.ts +2 -1
- package/dist/consensus/raft.d.ts.map +1 -1
- package/dist/consensus/raft.js +25 -9
- package/dist/consensus/raft.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/message-bus.d.ts.map +1 -1
- package/dist/message-bus.js +13 -4
- package/dist/message-bus.js.map +1 -1
- package/dist/queen-coordinator.d.ts +1 -1
- package/dist/queen-coordinator.d.ts.map +1 -1
- package/dist/queen-coordinator.js +10 -3
- package/dist/queen-coordinator.js.map +1 -1
- package/dist/topology-manager.d.ts.map +1 -1
- package/dist/topology-manager.js +26 -1
- package/dist/topology-manager.js.map +1 -1
- package/dist/types.d.ts +3 -12
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/unified-coordinator.d.ts +1 -1
- package/dist/unified-coordinator.d.ts.map +1 -1
- package/dist/unified-coordinator.js +85 -29
- package/dist/unified-coordinator.js.map +1 -1
- package/package.json +7 -2
- package/.claude-flow/data/pending-insights.jsonl +0 -15
- package/.claude-flow/hive-mind/state.json +0 -18
- package/__tests__/byzantine-transport.test.ts +0 -147
- package/__tests__/consensus-failure-injection.test.ts +0 -161
- package/__tests__/consensus-transport.test.ts +0 -200
- package/__tests__/consensus.test.ts +0 -577
- package/__tests__/coordinator.test.ts +0 -501
- package/__tests__/federation-transport.test.ts +0 -145
- package/__tests__/gossip-transport.test.ts +0 -81
- package/__tests__/queen-coordinator.test.ts +0 -1335
- package/__tests__/raft-transport.test.ts +0 -121
- package/__tests__/topology.test.ts +0 -621
- package/ruvector.db +0 -0
- package/src/agent-pool.ts +0 -476
- package/src/application/commands/create-task.command.ts +0 -124
- package/src/application/commands/spawn-agent.command.ts +0 -122
- package/src/application/index.ts +0 -30
- package/src/application/services/swarm-application-service.ts +0 -200
- package/src/attention-coordinator.ts +0 -1000
- package/src/consensus/byzantine.ts +0 -514
- package/src/consensus/federation-transport.ts +0 -185
- package/src/consensus/gossip.ts +0 -599
- package/src/consensus/index.ts +0 -326
- package/src/consensus/raft.ts +0 -561
- package/src/consensus/transport.ts +0 -284
- package/src/coordination/agent-registry.ts +0 -544
- package/src/coordination/index.ts +0 -23
- package/src/coordination/swarm-hub.ts +0 -776
- package/src/coordination/task-orchestrator.ts +0 -605
- package/src/domain/entities/agent.d.ts +0 -151
- package/src/domain/entities/agent.d.ts.map +0 -1
- package/src/domain/entities/agent.js +0 -280
- package/src/domain/entities/agent.js.map +0 -1
- package/src/domain/entities/agent.ts +0 -370
- package/src/domain/entities/task.d.ts +0 -133
- package/src/domain/entities/task.d.ts.map +0 -1
- package/src/domain/entities/task.js +0 -261
- package/src/domain/entities/task.js.map +0 -1
- package/src/domain/entities/task.ts +0 -319
- package/src/domain/index.ts +0 -41
- package/src/domain/repositories/agent-repository.interface.d.ts +0 -57
- package/src/domain/repositories/agent-repository.interface.d.ts.map +0 -1
- package/src/domain/repositories/agent-repository.interface.js +0 -9
- package/src/domain/repositories/agent-repository.interface.js.map +0 -1
- package/src/domain/repositories/agent-repository.interface.ts +0 -69
- package/src/domain/repositories/task-repository.interface.d.ts +0 -61
- package/src/domain/repositories/task-repository.interface.d.ts.map +0 -1
- package/src/domain/repositories/task-repository.interface.js +0 -9
- package/src/domain/repositories/task-repository.interface.js.map +0 -1
- package/src/domain/repositories/task-repository.interface.ts +0 -75
- package/src/domain/services/coordination-service.ts +0 -320
- package/src/federation-hub.d.ts +0 -284
- package/src/federation-hub.d.ts.map +0 -1
- package/src/federation-hub.js +0 -692
- package/src/federation-hub.js.map +0 -1
- package/src/federation-hub.ts +0 -979
- package/src/index.ts +0 -374
- package/src/message-bus.ts +0 -607
- package/src/queen-coordinator.ts +0 -2025
- package/src/shared/events.ts +0 -285
- package/src/shared/types.ts +0 -389
- package/src/topology-manager.ts +0 -656
- package/src/types.ts +0 -555
- package/src/unified-coordinator.ts +0 -1844
- package/src/workers/index.ts +0 -65
- package/src/workers/worker-dispatch.d.ts +0 -234
- package/src/workers/worker-dispatch.d.ts.map +0 -1
- package/src/workers/worker-dispatch.js +0 -842
- package/src/workers/worker-dispatch.js.map +0 -1
- package/src/workers/worker-dispatch.ts +0 -1076
- package/tmp.json +0 -0
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
- package/vitest.config.ts +0 -20
|
@@ -1,514 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* V3 Byzantine Fault Tolerant Consensus
|
|
3
|
-
* PBFT-style consensus for handling malicious or faulty nodes
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { EventEmitter } from 'events';
|
|
7
|
-
import { createHash } from 'node:crypto';
|
|
8
|
-
import {
|
|
9
|
-
ConsensusProposal,
|
|
10
|
-
ConsensusVote,
|
|
11
|
-
ConsensusResult,
|
|
12
|
-
ConsensusConfig,
|
|
13
|
-
SWARM_CONSTANTS,
|
|
14
|
-
} from '../types.js';
|
|
15
|
-
import type { ConsensusTransport, ConsensusMessage } from './transport.js';
|
|
16
|
-
|
|
17
|
-
export type ByzantinePhase = 'pre-prepare' | 'prepare' | 'commit' | 'reply';
|
|
18
|
-
|
|
19
|
-
export interface ByzantineMessage {
|
|
20
|
-
type: ByzantinePhase;
|
|
21
|
-
viewNumber: number;
|
|
22
|
-
sequenceNumber: number;
|
|
23
|
-
digest: string;
|
|
24
|
-
senderId: string;
|
|
25
|
-
timestamp: Date;
|
|
26
|
-
payload?: unknown;
|
|
27
|
-
signature?: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface ByzantineNode {
|
|
31
|
-
id: string;
|
|
32
|
-
isPrimary: boolean;
|
|
33
|
-
viewNumber: number;
|
|
34
|
-
sequenceNumber: number;
|
|
35
|
-
preparedMessages: Map<string, ByzantineMessage[]>;
|
|
36
|
-
committedMessages: Map<string, ByzantineMessage[]>;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface ByzantineConfig extends Partial<ConsensusConfig> {
|
|
40
|
-
maxFaultyNodes?: number;
|
|
41
|
-
viewChangeTimeoutMs?: number;
|
|
42
|
-
/**
|
|
43
|
-
* ADR-095 G2 — optional pluggable transport. When set, PBFT messages
|
|
44
|
-
* (pre-prepare/prepare/commit) go over it (and are signed if the
|
|
45
|
-
* transport has a keypair) instead of being `emit`ted into the void.
|
|
46
|
-
* When unset, behavior is unchanged: messages are emitted as
|
|
47
|
-
* `message.broadcast` / `message.sent` events for an external wiring
|
|
48
|
-
* layer to relay (the legacy single-process path).
|
|
49
|
-
*/
|
|
50
|
-
transport?: ConsensusTransport;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export class ByzantineConsensus extends EventEmitter {
|
|
54
|
-
private config: ByzantineConfig;
|
|
55
|
-
private node: ByzantineNode;
|
|
56
|
-
private nodes: Map<string, ByzantineNode> = new Map();
|
|
57
|
-
private proposals: Map<string, ConsensusProposal> = new Map();
|
|
58
|
-
private messageLog: Map<string, ByzantineMessage[]> = new Map();
|
|
59
|
-
private proposalCounter: number = 0;
|
|
60
|
-
private viewChangeTimeout?: NodeJS.Timeout;
|
|
61
|
-
private readonly transport?: ConsensusTransport;
|
|
62
|
-
|
|
63
|
-
constructor(nodeId: string, config: ByzantineConfig = {}) {
|
|
64
|
-
super();
|
|
65
|
-
this.config = {
|
|
66
|
-
threshold: config.threshold ?? SWARM_CONSTANTS.DEFAULT_CONSENSUS_THRESHOLD,
|
|
67
|
-
timeoutMs: config.timeoutMs ?? SWARM_CONSTANTS.DEFAULT_CONSENSUS_TIMEOUT_MS,
|
|
68
|
-
maxRounds: config.maxRounds ?? 10,
|
|
69
|
-
requireQuorum: config.requireQuorum ?? true,
|
|
70
|
-
maxFaultyNodes: config.maxFaultyNodes, // #G2: undefined → byzantineF() derives from cluster size; a value caps it
|
|
71
|
-
viewChangeTimeoutMs: config.viewChangeTimeoutMs ?? 5000,
|
|
72
|
-
transport: config.transport,
|
|
73
|
-
};
|
|
74
|
-
this.transport = config.transport;
|
|
75
|
-
|
|
76
|
-
this.node = {
|
|
77
|
-
id: nodeId,
|
|
78
|
-
isPrimary: false,
|
|
79
|
-
viewNumber: 0,
|
|
80
|
-
sequenceNumber: 0,
|
|
81
|
-
preparedMessages: new Map(),
|
|
82
|
-
committedMessages: new Map(),
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
// ADR-095 G2 — when a transport is wired, route inbound PBFT messages
|
|
86
|
-
// through the protocol handlers. The transport handles signature
|
|
87
|
-
// verification (if signing is enabled) before we ever see the message.
|
|
88
|
-
if (this.transport) {
|
|
89
|
-
this.transport.onMessage(async (msg: ConsensusMessage) => {
|
|
90
|
-
await this.handleInboundMessage(msg);
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* ADR-095 G2 — dispatch an inbound transport message to the right PBFT
|
|
97
|
-
* handler. The transport has already verified the signature (if signing
|
|
98
|
-
* is on); here we only need to demux by type and reconstruct the
|
|
99
|
-
* ByzantineMessage shape the handlers expect.
|
|
100
|
-
*/
|
|
101
|
-
private async handleInboundMessage(msg: ConsensusMessage): Promise<void> {
|
|
102
|
-
const p = msg.payload as Partial<ByzantineMessage> | undefined;
|
|
103
|
-
if (!p || typeof p.viewNumber !== 'number' || typeof p.sequenceNumber !== 'number' || typeof p.digest !== 'string') return;
|
|
104
|
-
const bm: ByzantineMessage = {
|
|
105
|
-
type: (msg.type as ByzantinePhase) ?? p.type ?? 'prepare',
|
|
106
|
-
viewNumber: p.viewNumber,
|
|
107
|
-
sequenceNumber: p.sequenceNumber,
|
|
108
|
-
digest: p.digest,
|
|
109
|
-
senderId: msg.from,
|
|
110
|
-
timestamp: p.timestamp ? new Date(p.timestamp as unknown as string) : new Date(),
|
|
111
|
-
payload: p.payload,
|
|
112
|
-
signature: msg.signature,
|
|
113
|
-
};
|
|
114
|
-
switch (bm.type) {
|
|
115
|
-
case 'pre-prepare': await this.handlePrePrepare(bm); break;
|
|
116
|
-
case 'prepare': await this.handlePrepare(bm); break;
|
|
117
|
-
case 'commit': await this.handleCommit(bm); break;
|
|
118
|
-
default: break;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async initialize(): Promise<void> {
|
|
123
|
-
this.emit('initialized', { nodeId: this.node.id });
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async shutdown(): Promise<void> {
|
|
127
|
-
if (this.viewChangeTimeout) {
|
|
128
|
-
clearTimeout(this.viewChangeTimeout);
|
|
129
|
-
}
|
|
130
|
-
this.emit('shutdown');
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
addNode(nodeId: string, isPrimary: boolean = false): void {
|
|
134
|
-
this.nodes.set(nodeId, {
|
|
135
|
-
id: nodeId,
|
|
136
|
-
isPrimary,
|
|
137
|
-
viewNumber: 0,
|
|
138
|
-
sequenceNumber: 0,
|
|
139
|
-
preparedMessages: new Map(),
|
|
140
|
-
committedMessages: new Map(),
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
if (isPrimary && this.node.id === nodeId) {
|
|
144
|
-
this.node.isPrimary = true;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
removeNode(nodeId: string): void {
|
|
149
|
-
this.nodes.delete(nodeId);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* ADR-095 G2 — BFT fault tolerance f, derived from the *actual* cluster
|
|
154
|
-
* size unless explicitly capped via config.maxFaultyNodes. PBFT needs
|
|
155
|
-
* n ≥ 3f+1, so f = floor((n-1)/3) where n = self + known peers. The
|
|
156
|
-
* config value (if set) acts as an upper bound — never exceed what the
|
|
157
|
-
* operator declared the cluster can tolerate.
|
|
158
|
-
*/
|
|
159
|
-
private byzantineF(): number {
|
|
160
|
-
const n = this.nodes.size + 1; // self + known peers
|
|
161
|
-
const derived = Math.max(1, Math.floor((n - 1) / 3));
|
|
162
|
-
const cap = this.config.maxFaultyNodes;
|
|
163
|
-
return cap === undefined ? derived : Math.min(derived, cap);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
electPrimary(): string {
|
|
167
|
-
const nodeIds = [this.node.id, ...Array.from(this.nodes.keys())];
|
|
168
|
-
const primaryIndex = this.node.viewNumber % nodeIds.length;
|
|
169
|
-
const primaryId = nodeIds[primaryIndex];
|
|
170
|
-
|
|
171
|
-
this.node.isPrimary = primaryId === this.node.id;
|
|
172
|
-
|
|
173
|
-
for (const [id, node] of this.nodes) {
|
|
174
|
-
node.isPrimary = id === primaryId;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
this.emit('primary.elected', { primaryId, viewNumber: this.node.viewNumber });
|
|
178
|
-
|
|
179
|
-
return primaryId;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async propose(value: unknown): Promise<ConsensusProposal> {
|
|
183
|
-
if (!this.node.isPrimary) {
|
|
184
|
-
throw new Error('Only primary can propose values');
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
this.proposalCounter++;
|
|
188
|
-
const sequenceNumber = ++this.node.sequenceNumber;
|
|
189
|
-
const digest = this.computeDigest(value);
|
|
190
|
-
const proposalId = `bft_${this.node.viewNumber}_${sequenceNumber}`;
|
|
191
|
-
|
|
192
|
-
const proposal: ConsensusProposal = {
|
|
193
|
-
id: proposalId,
|
|
194
|
-
proposerId: this.node.id,
|
|
195
|
-
value,
|
|
196
|
-
term: this.node.viewNumber,
|
|
197
|
-
timestamp: new Date(),
|
|
198
|
-
votes: new Map(),
|
|
199
|
-
status: 'pending',
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
this.proposals.set(proposalId, proposal);
|
|
203
|
-
|
|
204
|
-
// Phase 1: Pre-prepare
|
|
205
|
-
const prePrepareMsg: ByzantineMessage = {
|
|
206
|
-
type: 'pre-prepare',
|
|
207
|
-
viewNumber: this.node.viewNumber,
|
|
208
|
-
sequenceNumber,
|
|
209
|
-
digest,
|
|
210
|
-
senderId: this.node.id,
|
|
211
|
-
timestamp: new Date(),
|
|
212
|
-
payload: value,
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
await this.broadcastMessage(prePrepareMsg);
|
|
216
|
-
|
|
217
|
-
// Self-prepare
|
|
218
|
-
await this.handlePrepare({
|
|
219
|
-
type: 'prepare',
|
|
220
|
-
viewNumber: this.node.viewNumber,
|
|
221
|
-
sequenceNumber,
|
|
222
|
-
digest,
|
|
223
|
-
senderId: this.node.id,
|
|
224
|
-
timestamp: new Date(),
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
return proposal;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
async vote(proposalId: string, vote: ConsensusVote): Promise<void> {
|
|
231
|
-
const proposal = this.proposals.get(proposalId);
|
|
232
|
-
if (!proposal || proposal.status !== 'pending') {
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
proposal.votes.set(vote.voterId, vote);
|
|
237
|
-
|
|
238
|
-
// Check consensus
|
|
239
|
-
const f = this.byzantineF();
|
|
240
|
-
const n = this.nodes.size + 1;
|
|
241
|
-
const requiredVotes = 2 * f + 1;
|
|
242
|
-
|
|
243
|
-
const approvingVotes = Array.from(proposal.votes.values()).filter(
|
|
244
|
-
v => v.approve
|
|
245
|
-
).length;
|
|
246
|
-
|
|
247
|
-
if (approvingVotes >= requiredVotes) {
|
|
248
|
-
proposal.status = 'accepted';
|
|
249
|
-
this.emit('consensus.achieved', { proposalId, approved: true });
|
|
250
|
-
} else if (proposal.votes.size >= n && approvingVotes < requiredVotes) {
|
|
251
|
-
proposal.status = 'rejected';
|
|
252
|
-
this.emit('consensus.achieved', { proposalId, approved: false });
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
async awaitConsensus(proposalId: string): Promise<ConsensusResult> {
|
|
257
|
-
const startTime = Date.now();
|
|
258
|
-
|
|
259
|
-
return new Promise((resolve, reject) => {
|
|
260
|
-
const checkInterval = setInterval(() => {
|
|
261
|
-
const proposal = this.proposals.get(proposalId);
|
|
262
|
-
if (!proposal) {
|
|
263
|
-
clearInterval(checkInterval);
|
|
264
|
-
reject(new Error(`Proposal ${proposalId} not found`));
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (proposal.status !== 'pending') {
|
|
269
|
-
clearInterval(checkInterval);
|
|
270
|
-
resolve(this.createResult(proposal, Date.now() - startTime));
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (Date.now() - startTime > (this.config.timeoutMs ?? 30000)) {
|
|
275
|
-
clearInterval(checkInterval);
|
|
276
|
-
proposal.status = 'expired';
|
|
277
|
-
resolve(this.createResult(proposal, Date.now() - startTime));
|
|
278
|
-
}
|
|
279
|
-
}, 10);
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// ===== MESSAGE HANDLERS =====
|
|
284
|
-
|
|
285
|
-
async handlePrePrepare(message: ByzantineMessage): Promise<void> {
|
|
286
|
-
// Validate message
|
|
287
|
-
if (message.viewNumber !== this.node.viewNumber) {
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Accept pre-prepare from primary
|
|
292
|
-
const proposalId = `bft_${message.viewNumber}_${message.sequenceNumber}`;
|
|
293
|
-
|
|
294
|
-
if (!this.proposals.has(proposalId) && message.payload !== undefined) {
|
|
295
|
-
const proposal: ConsensusProposal = {
|
|
296
|
-
id: proposalId,
|
|
297
|
-
proposerId: message.senderId,
|
|
298
|
-
value: message.payload,
|
|
299
|
-
term: message.viewNumber,
|
|
300
|
-
timestamp: message.timestamp,
|
|
301
|
-
votes: new Map(),
|
|
302
|
-
status: 'pending',
|
|
303
|
-
};
|
|
304
|
-
this.proposals.set(proposalId, proposal);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Send prepare message
|
|
308
|
-
const prepareMsg: ByzantineMessage = {
|
|
309
|
-
type: 'prepare',
|
|
310
|
-
viewNumber: message.viewNumber,
|
|
311
|
-
sequenceNumber: message.sequenceNumber,
|
|
312
|
-
digest: message.digest,
|
|
313
|
-
senderId: this.node.id,
|
|
314
|
-
timestamp: new Date(),
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
await this.broadcastMessage(prepareMsg);
|
|
318
|
-
await this.handlePrepare(prepareMsg);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
async handlePrepare(message: ByzantineMessage): Promise<void> {
|
|
322
|
-
const key = `${message.viewNumber}_${message.sequenceNumber}`;
|
|
323
|
-
|
|
324
|
-
if (!this.messageLog.has(key)) {
|
|
325
|
-
this.messageLog.set(key, []);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const messages = this.messageLog.get(key)!;
|
|
329
|
-
const hasPrepare = messages.some(
|
|
330
|
-
m => m.type === 'prepare' && m.senderId === message.senderId
|
|
331
|
-
);
|
|
332
|
-
|
|
333
|
-
if (!hasPrepare) {
|
|
334
|
-
messages.push(message);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Check if prepared (2f + 1 prepare messages)
|
|
338
|
-
const f = this.byzantineF();
|
|
339
|
-
const prepareCount = messages.filter(m => m.type === 'prepare').length;
|
|
340
|
-
|
|
341
|
-
if (prepareCount >= 2 * f + 1) {
|
|
342
|
-
const proposalId = `bft_${message.viewNumber}_${message.sequenceNumber}`;
|
|
343
|
-
this.node.preparedMessages.set(key, messages);
|
|
344
|
-
|
|
345
|
-
// Send commit message
|
|
346
|
-
const commitMsg: ByzantineMessage = {
|
|
347
|
-
type: 'commit',
|
|
348
|
-
viewNumber: message.viewNumber,
|
|
349
|
-
sequenceNumber: message.sequenceNumber,
|
|
350
|
-
digest: message.digest,
|
|
351
|
-
senderId: this.node.id,
|
|
352
|
-
timestamp: new Date(),
|
|
353
|
-
};
|
|
354
|
-
|
|
355
|
-
await this.broadcastMessage(commitMsg);
|
|
356
|
-
await this.handleCommit(commitMsg);
|
|
357
|
-
|
|
358
|
-
// Record vote
|
|
359
|
-
const proposal = this.proposals.get(proposalId);
|
|
360
|
-
if (proposal) {
|
|
361
|
-
proposal.votes.set(this.node.id, {
|
|
362
|
-
voterId: this.node.id,
|
|
363
|
-
approve: true,
|
|
364
|
-
confidence: 1.0,
|
|
365
|
-
timestamp: new Date(),
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
async handleCommit(message: ByzantineMessage): Promise<void> {
|
|
372
|
-
const key = `${message.viewNumber}_${message.sequenceNumber}`;
|
|
373
|
-
|
|
374
|
-
if (!this.messageLog.has(key)) {
|
|
375
|
-
this.messageLog.set(key, []);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const messages = this.messageLog.get(key)!;
|
|
379
|
-
const hasCommit = messages.some(
|
|
380
|
-
m => m.type === 'commit' && m.senderId === message.senderId
|
|
381
|
-
);
|
|
382
|
-
|
|
383
|
-
if (!hasCommit) {
|
|
384
|
-
messages.push(message);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Check if committed (2f + 1 commit messages)
|
|
388
|
-
const f = this.byzantineF();
|
|
389
|
-
const commitCount = messages.filter(m => m.type === 'commit').length;
|
|
390
|
-
|
|
391
|
-
if (commitCount >= 2 * f + 1) {
|
|
392
|
-
const proposalId = `bft_${message.viewNumber}_${message.sequenceNumber}`;
|
|
393
|
-
this.node.committedMessages.set(key, messages);
|
|
394
|
-
|
|
395
|
-
const proposal = this.proposals.get(proposalId);
|
|
396
|
-
if (proposal && proposal.status === 'pending') {
|
|
397
|
-
proposal.status = 'accepted';
|
|
398
|
-
this.emit('consensus.achieved', { proposalId, approved: true });
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// ===== VIEW CHANGE =====
|
|
404
|
-
|
|
405
|
-
async initiateViewChange(): Promise<void> {
|
|
406
|
-
this.node.viewNumber++;
|
|
407
|
-
|
|
408
|
-
this.emit('view.changing', { newViewNumber: this.node.viewNumber });
|
|
409
|
-
|
|
410
|
-
// Elect new primary
|
|
411
|
-
this.electPrimary();
|
|
412
|
-
|
|
413
|
-
this.emit('view.changed', { viewNumber: this.node.viewNumber });
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// ===== PRIVATE METHODS =====
|
|
417
|
-
|
|
418
|
-
private async broadcastMessage(message: ByzantineMessage): Promise<void> {
|
|
419
|
-
// Always emit for observability / legacy external relay.
|
|
420
|
-
this.emit('message.broadcast', { message });
|
|
421
|
-
for (const node of this.nodes.values()) {
|
|
422
|
-
this.emit('message.sent', { to: node.id, message });
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// ADR-095 G2 — when a transport is wired, actually send the message
|
|
426
|
-
// over it (signed by the transport if signing is enabled). This is
|
|
427
|
-
// the real inter-node path; the emits above stay for observers.
|
|
428
|
-
if (this.transport) {
|
|
429
|
-
const cm: Omit<ConsensusMessage, 'from'> = {
|
|
430
|
-
type: message.type,
|
|
431
|
-
payload: {
|
|
432
|
-
type: message.type,
|
|
433
|
-
viewNumber: message.viewNumber,
|
|
434
|
-
sequenceNumber: message.sequenceNumber,
|
|
435
|
-
digest: message.digest,
|
|
436
|
-
timestamp: message.timestamp.toISOString(),
|
|
437
|
-
payload: message.payload,
|
|
438
|
-
},
|
|
439
|
-
viewNumber: message.viewNumber,
|
|
440
|
-
};
|
|
441
|
-
try {
|
|
442
|
-
await this.transport.broadcast(cm);
|
|
443
|
-
} catch {
|
|
444
|
-
// Transport failures are non-fatal at the protocol layer — the
|
|
445
|
-
// proposal will simply not reach consensus and time out, which is
|
|
446
|
-
// the correct failure mode (no liveness, but no incorrect commit).
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
private computeDigest(value: unknown): string {
|
|
452
|
-
// ADR-095 G2 — was a 32-bit string-hash "for demonstration"; replaced
|
|
453
|
-
// with sha256 so digests are collision-resistant (PBFT relies on the
|
|
454
|
-
// digest uniquely identifying the request being agreed on).
|
|
455
|
-
return createHash('sha256').update(JSON.stringify(value ?? null)).digest('hex');
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
private createResult(proposal: ConsensusProposal, durationMs: number): ConsensusResult {
|
|
459
|
-
const n = this.nodes.size + 1;
|
|
460
|
-
const approvingVotes = Array.from(proposal.votes.values()).filter(
|
|
461
|
-
v => v.approve
|
|
462
|
-
).length;
|
|
463
|
-
|
|
464
|
-
return {
|
|
465
|
-
proposalId: proposal.id,
|
|
466
|
-
approved: proposal.status === 'accepted',
|
|
467
|
-
approvalRate: proposal.votes.size > 0
|
|
468
|
-
? approvingVotes / proposal.votes.size
|
|
469
|
-
: 0,
|
|
470
|
-
participationRate: proposal.votes.size / n,
|
|
471
|
-
finalValue: proposal.value,
|
|
472
|
-
rounds: 3, // pre-prepare, prepare, commit
|
|
473
|
-
durationMs,
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// ===== STATE QUERIES =====
|
|
478
|
-
|
|
479
|
-
isPrimary(): boolean {
|
|
480
|
-
return this.node.isPrimary;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
getViewNumber(): number {
|
|
484
|
-
return this.node.viewNumber;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
getSequenceNumber(): number {
|
|
488
|
-
return this.node.sequenceNumber;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
getPreparedCount(): number {
|
|
492
|
-
return this.node.preparedMessages.size;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
getCommittedCount(): number {
|
|
496
|
-
return this.node.committedMessages.size;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
getMaxFaultyNodes(): number {
|
|
500
|
-
const n = this.nodes.size + 1;
|
|
501
|
-
return Math.floor((n - 1) / 3);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
canTolerate(faultyCount: number): boolean {
|
|
505
|
-
return faultyCount <= this.getMaxFaultyNodes();
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
export function createByzantineConsensus(
|
|
510
|
-
nodeId: string,
|
|
511
|
-
config?: ByzantineConfig
|
|
512
|
-
): ByzantineConsensus {
|
|
513
|
-
return new ByzantineConsensus(nodeId, config);
|
|
514
|
-
}
|
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ADR-095 G2 — FederationTransport: a ConsensusTransport backed by the
|
|
3
|
-
* federation plugin's ADR-104 WS wire (agentic-flow/transport/loader).
|
|
4
|
-
*
|
|
5
|
-
* @claude-flow/swarm has zero runtime dependencies and shouldn't take a
|
|
6
|
-
* hard dep on @claude-flow/plugin-agent-federation (the wiring would also
|
|
7
|
-
* be circular-ish). So this adapter takes its WS primitives by injection:
|
|
8
|
-
* pass an object that satisfies `AgenticFlowTransportLike` (which the
|
|
9
|
-
* federation plugin's `loadQuicTransport` result does) plus a small amount
|
|
10
|
-
* of consensus-level metadata (this node's id, how to address peers, who
|
|
11
|
-
* the peers are).
|
|
12
|
-
*
|
|
13
|
-
* The agentic-flow transport is fire-and-forget message delivery; this
|
|
14
|
-
* adapter layers request-response on top via per-message correlation ids
|
|
15
|
-
* (`corr`) + a pending-replies map + per-call timeout. Stream multiplexing
|
|
16
|
-
* (ADR-104 stream-mux) is used for the `streamId` so consensus traffic
|
|
17
|
-
* doesn't interleave with application traffic on the same connection.
|
|
18
|
-
*
|
|
19
|
-
* Optional Ed25519 signing: when a keypair is supplied, outbound messages
|
|
20
|
-
* are signed and inbound are verified against the sender's published
|
|
21
|
-
* public key (resolved via `resolvePeerPublicKey`) before reaching the
|
|
22
|
-
* handler. Fail-closed — an unverifiable inbound message is dropped.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { randomBytes } from 'node:crypto';
|
|
26
|
-
import type {
|
|
27
|
-
ConsensusTransport,
|
|
28
|
-
ConsensusMessage,
|
|
29
|
-
ConsensusReply,
|
|
30
|
-
ConsensusMessageHandler,
|
|
31
|
-
NodeKeyPair,
|
|
32
|
-
} from './transport.js';
|
|
33
|
-
import { signMessage, verifyMessage } from './transport.js';
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Minimal shape of the agentic-flow transport (the `loadQuicTransport`
|
|
37
|
-
* result). We only need send + onMessage + (optional) close. Keeping this
|
|
38
|
-
* structural means swarm doesn't import agentic-flow.
|
|
39
|
-
*/
|
|
40
|
-
export interface AgenticFlowTransportLike {
|
|
41
|
-
send(address: string, message: { type?: string; payload: unknown; streamId?: string }): Promise<void>;
|
|
42
|
-
onMessage(handler: (msg: { from?: string; address?: string; type?: string; payload: unknown }) => void | Promise<void>): void;
|
|
43
|
-
close?(): Promise<void> | void;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** What we put on the wire — a ConsensusMessage plus correlation metadata. */
|
|
47
|
-
interface WireEnvelope {
|
|
48
|
-
/** Correlation id — present on requests; the reply echoes it. */
|
|
49
|
-
readonly corr?: string;
|
|
50
|
-
/** True if this envelope is a reply to a `corr` request. */
|
|
51
|
-
readonly isReply?: boolean;
|
|
52
|
-
/** The consensus message. `from` is filled by us. */
|
|
53
|
-
readonly msg: ConsensusMessage;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface FederationTransportOptions {
|
|
57
|
-
/** This node's consensus id (the `from` on outbound messages). */
|
|
58
|
-
readonly nodeId: string;
|
|
59
|
-
/** Map a consensus nodeId → the WS address agentic-flow uses to reach it. Return undefined for unknown peers. */
|
|
60
|
-
readonly addressOf: (nodeId: string) => string | undefined;
|
|
61
|
-
/** Currently-known peer consensus node ids (excludes self — or includes; we filter). */
|
|
62
|
-
readonly peerIds: () => readonly string[];
|
|
63
|
-
/** Stream id for consensus traffic (ADR-104 stream-mux). Defaults to 'ruflo-consensus'. */
|
|
64
|
-
readonly streamId?: string;
|
|
65
|
-
/** Default per-`send` timeout in ms. Defaults to 5000. */
|
|
66
|
-
readonly defaultTimeoutMs?: number;
|
|
67
|
-
/** Optional Ed25519 keypair — signs outbound, verifies inbound. */
|
|
68
|
-
readonly keyPair?: NodeKeyPair;
|
|
69
|
-
/** Resolve a peer consensus nodeId → its Ed25519 public key PEM. Required if `keyPair` is set and you want verification. */
|
|
70
|
-
readonly resolvePeerPublicKey?: (nodeId: string) => string | undefined;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export class FederationTransport implements ConsensusTransport {
|
|
74
|
-
readonly nodeId: string;
|
|
75
|
-
private readonly wire: AgenticFlowTransportLike;
|
|
76
|
-
private readonly addressOf: (nodeId: string) => string | undefined;
|
|
77
|
-
private readonly peerIdsFn: () => readonly string[];
|
|
78
|
-
private readonly streamId: string;
|
|
79
|
-
private readonly defaultTimeoutMs: number;
|
|
80
|
-
private readonly keyPair?: NodeKeyPair;
|
|
81
|
-
private readonly resolvePeerPublicKey?: (nodeId: string) => string | undefined;
|
|
82
|
-
private handler: ConsensusMessageHandler | null = null;
|
|
83
|
-
private closed = false;
|
|
84
|
-
private seqCounter = 0;
|
|
85
|
-
private readonly pending = new Map<string, { resolve: (r: ConsensusReply) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
86
|
-
private readonly lastSeenSeq = new Map<string, number>();
|
|
87
|
-
|
|
88
|
-
constructor(wire: AgenticFlowTransportLike, opts: FederationTransportOptions) {
|
|
89
|
-
this.wire = wire;
|
|
90
|
-
this.nodeId = opts.nodeId;
|
|
91
|
-
this.addressOf = opts.addressOf;
|
|
92
|
-
this.peerIdsFn = opts.peerIds;
|
|
93
|
-
this.streamId = opts.streamId ?? 'ruflo-consensus';
|
|
94
|
-
this.defaultTimeoutMs = opts.defaultTimeoutMs ?? 5_000;
|
|
95
|
-
this.keyPair = opts.keyPair;
|
|
96
|
-
this.resolvePeerPublicKey = opts.resolvePeerPublicKey;
|
|
97
|
-
|
|
98
|
-
this.wire.onMessage(async (raw) => {
|
|
99
|
-
if (this.closed) return;
|
|
100
|
-
const env = raw.payload as WireEnvelope | undefined;
|
|
101
|
-
if (!env || !env.msg) return;
|
|
102
|
-
const msg = env.msg;
|
|
103
|
-
|
|
104
|
-
// Signature verification (if this node expects signed messages).
|
|
105
|
-
if (this.keyPair && this.resolvePeerPublicKey) {
|
|
106
|
-
const pub = this.resolvePeerPublicKey(msg.from);
|
|
107
|
-
if (!pub || !verifyMessage(msg, pub)) return; // fail-closed: drop
|
|
108
|
-
if (typeof msg.seq === 'number') {
|
|
109
|
-
const last = this.lastSeenSeq.get(msg.from) ?? 0;
|
|
110
|
-
if (msg.seq <= last) return; // replayed / out of order: drop
|
|
111
|
-
this.lastSeenSeq.set(msg.from, msg.seq);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Reply to a pending `send`?
|
|
116
|
-
if (env.isReply && env.corr) {
|
|
117
|
-
const p = this.pending.get(env.corr);
|
|
118
|
-
if (p) { clearTimeout(p.timer); this.pending.delete(env.corr); p.resolve(msg); }
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Inbound request → run the handler; if it returns a reply, send it back.
|
|
123
|
-
if (this.handler) {
|
|
124
|
-
const reply = await this.handler(msg);
|
|
125
|
-
if (reply && env.corr) {
|
|
126
|
-
const addr = this.addressOf(msg.from);
|
|
127
|
-
if (addr) {
|
|
128
|
-
const replyMsg = this.stamp({ ...reply, to: msg.from });
|
|
129
|
-
await this.wire.send(addr, { type: 'consensus', payload: { corr: env.corr, isReply: true, msg: replyMsg } as WireEnvelope, streamId: this.streamId }).catch(() => {});
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
onMessage(handler: ConsensusMessageHandler): void {
|
|
137
|
-
this.handler = handler;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
peers(): readonly string[] {
|
|
141
|
-
return this.peerIdsFn().filter(id => id !== this.nodeId);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
private stamp(msg: Omit<ConsensusMessage, 'from'>): ConsensusMessage {
|
|
145
|
-
const base: Omit<ConsensusMessage, 'signature'> = {
|
|
146
|
-
...msg,
|
|
147
|
-
from: this.nodeId,
|
|
148
|
-
seq: this.keyPair ? ++this.seqCounter : msg.seq,
|
|
149
|
-
};
|
|
150
|
-
return this.keyPair ? { ...base, signature: signMessage(base, this.keyPair.privateKeyPem) } : base;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async send(to: string, msg: Omit<ConsensusMessage, 'from'>, timeoutMs?: number): Promise<ConsensusReply> {
|
|
154
|
-
if (this.closed) throw new Error('FederationTransport: closed');
|
|
155
|
-
const addr = this.addressOf(to);
|
|
156
|
-
if (!addr) throw new Error(`FederationTransport: no address for peer ${to}`);
|
|
157
|
-
const corr = randomBytes(8).toString('hex');
|
|
158
|
-
const stamped = this.stamp({ ...msg, to });
|
|
159
|
-
const t = timeoutMs ?? this.defaultTimeoutMs;
|
|
160
|
-
return new Promise<ConsensusReply>((resolve, reject) => {
|
|
161
|
-
const timer = setTimeout(() => { this.pending.delete(corr); reject(new Error(`FederationTransport: send to ${to} timed out (${t}ms)`)); }, t);
|
|
162
|
-
this.pending.set(corr, { resolve, reject, timer });
|
|
163
|
-
this.wire.send(addr, { type: 'consensus', payload: { corr, msg: stamped } as WireEnvelope, streamId: this.streamId })
|
|
164
|
-
.catch((e) => { clearTimeout(timer); this.pending.delete(corr); reject(e instanceof Error ? e : new Error(String(e))); });
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
async broadcast(msg: Omit<ConsensusMessage, 'from'>): Promise<void> {
|
|
169
|
-
if (this.closed) throw new Error('FederationTransport: closed');
|
|
170
|
-
const stamped = this.stamp(msg);
|
|
171
|
-
await Promise.allSettled(this.peers().map(async (to) => {
|
|
172
|
-
const addr = this.addressOf(to);
|
|
173
|
-
if (!addr) return;
|
|
174
|
-
await this.wire.send(addr, { type: 'consensus', payload: { msg: stamped } as WireEnvelope, streamId: this.streamId }).catch(() => {});
|
|
175
|
-
}));
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async close(): Promise<void> {
|
|
179
|
-
this.closed = true;
|
|
180
|
-
this.handler = null;
|
|
181
|
-
for (const [, p] of this.pending) { clearTimeout(p.timer); p.reject(new Error('FederationTransport: closed')); }
|
|
182
|
-
this.pending.clear();
|
|
183
|
-
if (this.wire.close) await this.wire.close();
|
|
184
|
-
}
|
|
185
|
-
}
|