@claude-flow/swarm 3.0.0-alpha.1 → 3.0.0-alpha.8

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.
Files changed (85) hide show
  1. package/.claude-flow/data/pending-insights.jsonl +15 -0
  2. package/.claude-flow/hive-mind/state.json +18 -0
  3. package/__tests__/byzantine-transport.test.ts +147 -0
  4. package/__tests__/consensus-failure-injection.test.ts +161 -0
  5. package/__tests__/consensus-transport.test.ts +200 -0
  6. package/__tests__/federation-transport.test.ts +145 -0
  7. package/__tests__/gossip-transport.test.ts +81 -0
  8. package/__tests__/queen-coordinator.test.ts +2 -2
  9. package/__tests__/raft-transport.test.ts +121 -0
  10. package/dist/attention-coordinator.js +2 -2
  11. package/dist/attention-coordinator.js.map +1 -1
  12. package/dist/consensus/byzantine.d.ts +26 -0
  13. package/dist/consensus/byzantine.d.ts.map +1 -1
  14. package/dist/consensus/byzantine.js +92 -14
  15. package/dist/consensus/byzantine.js.map +1 -1
  16. package/dist/consensus/federation-transport.d.ts +82 -0
  17. package/dist/consensus/federation-transport.d.ts.map +1 -0
  18. package/dist/consensus/federation-transport.js +144 -0
  19. package/dist/consensus/federation-transport.js.map +1 -0
  20. package/dist/consensus/gossip.d.ts +31 -1
  21. package/dist/consensus/gossip.d.ts.map +1 -1
  22. package/dist/consensus/gossip.js +89 -12
  23. package/dist/consensus/gossip.js.map +1 -1
  24. package/dist/consensus/index.d.ts +4 -0
  25. package/dist/consensus/index.d.ts.map +1 -1
  26. package/dist/consensus/index.js +37 -0
  27. package/dist/consensus/index.js.map +1 -1
  28. package/dist/consensus/raft.d.ts +19 -0
  29. package/dist/consensus/raft.d.ts.map +1 -1
  30. package/dist/consensus/raft.js +113 -3
  31. package/dist/consensus/raft.js.map +1 -1
  32. package/dist/consensus/transport.d.ts +141 -0
  33. package/dist/consensus/transport.d.ts.map +1 -0
  34. package/dist/consensus/transport.js +205 -0
  35. package/dist/consensus/transport.js.map +1 -0
  36. package/dist/coordination/agent-registry.d.ts +2 -2
  37. package/dist/coordination/agent-registry.d.ts.map +1 -1
  38. package/dist/coordination/agent-registry.js +1 -1
  39. package/dist/coordination/agent-registry.js.map +1 -1
  40. package/dist/coordination/swarm-hub.d.ts +5 -5
  41. package/dist/coordination/swarm-hub.d.ts.map +1 -1
  42. package/dist/coordination/swarm-hub.js +5 -5
  43. package/dist/coordination/swarm-hub.js.map +1 -1
  44. package/dist/coordination/task-orchestrator.d.ts +3 -3
  45. package/dist/coordination/task-orchestrator.d.ts.map +1 -1
  46. package/dist/coordination/task-orchestrator.js +1 -1
  47. package/dist/coordination/task-orchestrator.js.map +1 -1
  48. package/dist/federation-hub.js +1 -1
  49. package/dist/federation-hub.js.map +1 -1
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +4 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/queen-coordinator.d.ts +2 -1
  55. package/dist/queen-coordinator.d.ts.map +1 -1
  56. package/dist/queen-coordinator.js +4 -3
  57. package/dist/queen-coordinator.js.map +1 -1
  58. package/dist/types.d.ts +10 -0
  59. package/dist/types.d.ts.map +1 -1
  60. package/dist/types.js.map +1 -1
  61. package/dist/workers/worker-dispatch.d.ts +6 -1
  62. package/dist/workers/worker-dispatch.d.ts.map +1 -1
  63. package/dist/workers/worker-dispatch.js +45 -41
  64. package/dist/workers/worker-dispatch.js.map +1 -1
  65. package/package.json +2 -3
  66. package/ruvector.db +0 -0
  67. package/src/attention-coordinator.ts +2 -2
  68. package/src/consensus/byzantine.ts +98 -15
  69. package/src/consensus/federation-transport.ts +185 -0
  70. package/src/consensus/gossip.ts +102 -16
  71. package/src/consensus/index.ts +59 -0
  72. package/src/consensus/raft.ts +125 -7
  73. package/src/consensus/transport.ts +284 -0
  74. package/src/coordination/agent-registry.ts +2 -2
  75. package/src/coordination/swarm-hub.ts +5 -5
  76. package/src/coordination/task-orchestrator.ts +3 -3
  77. package/src/federation-hub.ts +1 -1
  78. package/src/index.ts +26 -0
  79. package/src/queen-coordinator.ts +4 -3
  80. package/src/types.ts +10 -0
  81. package/src/workers/worker-dispatch.d.ts +1 -1
  82. package/src/workers/worker-dispatch.ts +45 -41
  83. package/tmp.json +0 -0
  84. package/tsconfig.tsbuildinfo +1 -1
  85. package/.agentic-flow/intelligence.json +0 -16
@@ -0,0 +1,185 @@
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
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { EventEmitter } from 'events';
7
+ import type { ConsensusTransport, ConsensusMessage } from './transport.js';
7
8
  import {
8
9
  ConsensusProposal,
9
10
  ConsensusVote,
@@ -24,12 +25,50 @@ export interface GossipMessage {
24
25
  path: string[];
25
26
  }
26
27
 
28
+ /**
29
+ * Bounded set that evicts oldest entries when capacity is reached.
30
+ * Uses Map insertion-order for O(1) FIFO eviction. (PERF-01)
31
+ */
32
+ export class BoundedSet<T> {
33
+ private map = new Map<T, true>();
34
+ private readonly maxSize: number;
35
+
36
+ constructor(maxSize: number) {
37
+ this.maxSize = maxSize;
38
+ }
39
+
40
+ has(value: T): boolean {
41
+ return this.map.has(value);
42
+ }
43
+
44
+ add(value: T): void {
45
+ if (this.map.has(value)) return;
46
+
47
+ if (this.map.size >= this.maxSize) {
48
+ // Evict oldest (first inserted)
49
+ const oldest = this.map.keys().next().value;
50
+ if (oldest !== undefined) {
51
+ this.map.delete(oldest);
52
+ }
53
+ }
54
+ this.map.set(value, true);
55
+ }
56
+
57
+ get size(): number {
58
+ return this.map.size;
59
+ }
60
+
61
+ clear(): void {
62
+ this.map.clear();
63
+ }
64
+ }
65
+
27
66
  export interface GossipNode {
28
67
  id: string;
29
68
  state: Map<string, unknown>;
30
69
  version: number;
31
70
  neighbors: Set<string>;
32
- seenMessages: Set<string>;
71
+ seenMessages: BoundedSet<string>;
33
72
  lastSync: Date;
34
73
  }
35
74
 
@@ -38,6 +77,14 @@ export interface GossipConfig extends Partial<ConsensusConfig> {
38
77
  gossipIntervalMs?: number;
39
78
  maxHops?: number;
40
79
  convergenceThreshold?: number;
80
+ /**
81
+ * ADR-095 G2 — optional pluggable transport. When set, gossip messages
82
+ * to neighbors actually go over it (signed if the transport has a
83
+ * keypair) and inbound gossip is routed back into the merge logic.
84
+ * When unset, behavior is unchanged: the legacy in-process path mutates
85
+ * the local `nodes` map directly (single-process).
86
+ */
87
+ transport?: ConsensusTransport;
41
88
  }
42
89
 
43
90
  export class GossipConsensus extends EventEmitter {
@@ -48,6 +95,7 @@ export class GossipConsensus extends EventEmitter {
48
95
  private messageQueue: GossipMessage[] = [];
49
96
  private gossipInterval?: NodeJS.Timeout;
50
97
  private proposalCounter: number = 0;
98
+ private readonly transport?: ConsensusTransport;
51
99
 
52
100
  constructor(nodeId: string, config: GossipConfig = {}) {
53
101
  super();
@@ -60,16 +108,46 @@ export class GossipConsensus extends EventEmitter {
60
108
  gossipIntervalMs: config.gossipIntervalMs ?? 100,
61
109
  maxHops: config.maxHops ?? 10,
62
110
  convergenceThreshold: config.convergenceThreshold ?? 0.9,
111
+ transport: config.transport,
63
112
  };
113
+ this.transport = config.transport;
64
114
 
65
115
  this.node = {
66
116
  id: nodeId,
67
117
  state: new Map(),
68
118
  version: 0,
69
119
  neighbors: new Set(),
70
- seenMessages: new Set(),
120
+ seenMessages: new BoundedSet(100_000), // PERF-01: ~4MB cap (100K × ~40B IDs)
71
121
  lastSync: new Date(),
72
122
  };
123
+
124
+ if (this.transport) {
125
+ this.transport.onMessage(async (msg: ConsensusMessage) => this.handleInboundGossipMessage(msg));
126
+ }
127
+ }
128
+
129
+ /**
130
+ * ADR-095 G2 — route an inbound transport message into the gossip merge
131
+ * logic. The transport handles signature verification (if enabled). We
132
+ * dedupe by message id (the BoundedSet `seenMessages`) and process it as
133
+ * though it arrived from a neighbor.
134
+ */
135
+ private async handleInboundGossipMessage(msg: ConsensusMessage): Promise<void> {
136
+ if (msg.type !== 'gossip') return;
137
+ const gm = msg.payload as GossipMessage | undefined;
138
+ if (!gm || typeof gm.id !== 'string') return;
139
+ if (this.node.seenMessages.has(gm.id)) return;
140
+ // Ensure the sender is known as a neighbor so processReceivedMessage works.
141
+ if (!this.nodes.has(msg.from)) {
142
+ this.nodes.set(msg.from, { id: msg.from, state: new Map(), version: 0, neighbors: new Set(), seenMessages: new BoundedSet(100_000), lastSync: new Date() });
143
+ }
144
+ this.node.neighbors.add(msg.from);
145
+ // Process against THIS node's state (the message reached us).
146
+ await this.processReceivedMessage(this.node, {
147
+ ...gm,
148
+ timestamp: gm.timestamp ? new Date(gm.timestamp as unknown as string) : new Date(),
149
+ path: Array.isArray(gm.path) ? gm.path : [],
150
+ });
73
151
  }
74
152
 
75
153
  async initialize(): Promise<void> {
@@ -90,7 +168,7 @@ export class GossipConsensus extends EventEmitter {
90
168
  state: new Map(),
91
169
  version: 0,
92
170
  neighbors: new Set(),
93
- seenMessages: new Set(),
171
+ seenMessages: new BoundedSet(100_000), // PERF-01: bounded to prevent memory leak (~4MB cap)
94
172
  lastSync: new Date(),
95
173
  });
96
174
 
@@ -276,26 +354,34 @@ export class GossipConsensus extends EventEmitter {
276
354
  }
277
355
 
278
356
  private async sendToNeighbor(neighborId: string, message: GossipMessage): Promise<void> {
279
- const neighbor = this.nodes.get(neighborId);
280
- if (!neighbor) {
281
- return;
282
- }
283
-
284
- // Check if already seen
285
- if (neighbor.seenMessages.has(message.id)) {
286
- return;
287
- }
288
-
289
- // Simulate network delivery
290
357
  const deliveredMessage: GossipMessage = {
291
358
  ...message,
292
359
  hops: message.hops + 1,
293
360
  path: [...message.path, neighborId],
294
361
  };
295
362
 
296
- // Process at neighbor
297
- await this.processReceivedMessage(neighbor, deliveredMessage);
363
+ // ADR-095 G2 — over the transport when wired: actually send the gossip
364
+ // message to the neighbor (signed by the transport if signing is on).
365
+ // The emit stays for observability.
366
+ if (this.transport) {
367
+ this.emit('message.sent', { to: neighborId, message: deliveredMessage });
368
+ try {
369
+ await this.transport.send(neighborId, {
370
+ type: 'gossip',
371
+ payload: { ...deliveredMessage, timestamp: deliveredMessage.timestamp.toISOString() },
372
+ });
373
+ } catch {
374
+ // Unreachable neighbor — gossip tolerates this; it'll converge via
375
+ // other paths or the next gossip round.
376
+ }
377
+ return;
378
+ }
298
379
 
380
+ // Legacy in-process path — deliver to the fake neighbor state.
381
+ const neighbor = this.nodes.get(neighborId);
382
+ if (!neighbor) return;
383
+ if (neighbor.seenMessages.has(message.id)) return;
384
+ await this.processReceivedMessage(neighbor, deliveredMessage);
299
385
  this.emit('message.sent', { to: neighborId, message: deliveredMessage });
300
386
  }
301
387
 
@@ -16,10 +16,40 @@ import {
16
16
  import { RaftConsensus, createRaftConsensus, RaftConfig } from './raft.js';
17
17
  import { ByzantineConsensus, createByzantineConsensus, ByzantineConfig } from './byzantine.js';
18
18
  import { GossipConsensus, createGossipConsensus, GossipConfig } from './gossip.js';
19
+ import type { ConsensusTransport } from './transport.js';
19
20
 
20
21
  export { RaftConsensus, ByzantineConsensus, GossipConsensus };
21
22
  export type { RaftConfig, ByzantineConfig, GossipConfig };
22
23
 
24
+ // ADR-095 G2 — pluggable consensus transport. Replaces the implicit
25
+ // single-process EventEmitter messaging in the consensus protocols.
26
+ // LocalTransport is the default (matches current behavior); FederationTransport
27
+ // (separate file, ADR-104 wire) is the multi-host one.
28
+ export {
29
+ LocalTransport,
30
+ LocalTransportRegistry,
31
+ defaultLocalRegistry,
32
+ generateNodeKeyPair,
33
+ signMessage,
34
+ verifyMessage,
35
+ canonicalizeForSigning,
36
+ messageDigest,
37
+ } from './transport.js';
38
+ export type {
39
+ ConsensusTransport,
40
+ ConsensusMessage,
41
+ ConsensusReply,
42
+ ConsensusMessageHandler,
43
+ NodeKeyPair,
44
+ LocalTransportOptions,
45
+ } from './transport.js';
46
+
47
+ // ADR-095 G2 — FederationTransport: ConsensusTransport over the federation
48
+ // plugin's ADR-104 WS wire (agentic-flow/transport/loader). Structural —
49
+ // swarm doesn't import agentic-flow; the caller passes a transport instance.
50
+ export { FederationTransport } from './federation-transport.js';
51
+ export type { AgenticFlowTransportLike, FederationTransportOptions } from './federation-transport.js';
52
+
23
53
  type ConsensusImplementation = RaftConsensus | ByzantineConsensus | GossipConsensus;
24
54
 
25
55
  export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
@@ -45,6 +75,14 @@ export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
45
75
  this.config = { ...this.config, ...config };
46
76
  }
47
77
 
78
+ // ADR-095 G2.2 — narrow the typed-as-unknown `transport` from
79
+ // ConsensusConfig into the real ConsensusTransport so Raft / Byzantine /
80
+ // Gossip get a working inter-node wire. Structural check, not
81
+ // instanceof, so test mocks satisfy it without importing transport.ts.
82
+ const transport = isConsensusTransport(this.config.transport)
83
+ ? this.config.transport
84
+ : undefined;
85
+
48
86
  // Create implementation based on algorithm
49
87
  switch (this.config.algorithm) {
50
88
  case 'raft':
@@ -53,6 +91,7 @@ export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
53
91
  timeoutMs: this.config.timeoutMs,
54
92
  maxRounds: this.config.maxRounds,
55
93
  requireQuorum: this.config.requireQuorum,
94
+ transport,
56
95
  });
57
96
  break;
58
97
 
@@ -62,6 +101,7 @@ export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
62
101
  timeoutMs: this.config.timeoutMs,
63
102
  maxRounds: this.config.maxRounds,
64
103
  requireQuorum: this.config.requireQuorum,
104
+ transport,
65
105
  });
66
106
  break;
67
107
 
@@ -71,6 +111,7 @@ export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
71
111
  timeoutMs: this.config.timeoutMs,
72
112
  maxRounds: this.config.maxRounds,
73
113
  requireQuorum: this.config.requireQuorum,
114
+ transport,
74
115
  });
75
116
  break;
76
117
 
@@ -81,6 +122,7 @@ export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
81
122
  timeoutMs: this.config.timeoutMs,
82
123
  maxRounds: this.config.maxRounds,
83
124
  requireQuorum: this.config.requireQuorum,
125
+ transport,
84
126
  });
85
127
  break;
86
128
 
@@ -233,6 +275,23 @@ export function createConsensusEngine(
233
275
  return new ConsensusEngine(nodeId, { ...config, algorithm });
234
276
  }
235
277
 
278
+ /**
279
+ * ADR-095 G2.2 — structural check that an opaque value implements the
280
+ * ConsensusTransport interface. Used by the engine to safely narrow the
281
+ * `transport` field on `ConsensusConfig` (typed as `unknown` to keep
282
+ * `types.ts` free of cross-module imports).
283
+ */
284
+ function isConsensusTransport(value: unknown): value is ConsensusTransport {
285
+ if (!value || typeof value !== 'object') return false;
286
+ const t = value as Record<string, unknown>;
287
+ return typeof t.nodeId === 'string'
288
+ && typeof t.send === 'function'
289
+ && typeof t.broadcast === 'function'
290
+ && typeof t.onMessage === 'function'
291
+ && typeof t.peers === 'function'
292
+ && typeof t.close === 'function';
293
+ }
294
+
236
295
  // Helper to select optimal algorithm based on requirements
237
296
  export function selectOptimalAlgorithm(requirements: {
238
297
  faultTolerance: 'crash' | 'byzantine';
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { EventEmitter } from 'events';
7
+ import type { ConsensusTransport, ConsensusMessage } from './transport.js';
7
8
  import {
8
9
  ConsensusProposal,
9
10
  ConsensusVote,
@@ -35,6 +36,15 @@ export interface RaftConfig extends Partial<ConsensusConfig> {
35
36
  electionTimeoutMinMs?: number;
36
37
  electionTimeoutMaxMs?: number;
37
38
  heartbeatIntervalMs?: number;
39
+ /**
40
+ * ADR-095 G2 — optional pluggable transport. When set, RequestVote and
41
+ * AppendEntries RPCs go over it (request-response) and this node also
42
+ * answers inbound RequestVote / AppendEntries from peers using proper
43
+ * Raft receiver rules (term comparison, vote-once-per-term, log-up-to-date
44
+ * check). When unset, behavior is unchanged: the legacy in-process path
45
+ * mutates the local `peers` map directly (single-process).
46
+ */
47
+ transport?: ConsensusTransport;
38
48
  }
39
49
 
40
50
  export class RaftConsensus extends EventEmitter {
@@ -45,6 +55,7 @@ export class RaftConsensus extends EventEmitter {
45
55
  private electionTimeout?: NodeJS.Timeout;
46
56
  private heartbeatInterval?: NodeJS.Timeout;
47
57
  private proposalCounter: number = 0;
58
+ private readonly transport?: ConsensusTransport;
48
59
 
49
60
  constructor(nodeId: string, config: RaftConfig = {}) {
50
61
  super();
@@ -56,7 +67,9 @@ export class RaftConsensus extends EventEmitter {
56
67
  electionTimeoutMinMs: config.electionTimeoutMinMs ?? 150,
57
68
  electionTimeoutMaxMs: config.electionTimeoutMaxMs ?? 300,
58
69
  heartbeatIntervalMs: config.heartbeatIntervalMs ?? 50,
70
+ transport: config.transport,
59
71
  };
72
+ this.transport = config.transport;
60
73
 
61
74
  this.node = {
62
75
  id: nodeId,
@@ -66,6 +79,71 @@ export class RaftConsensus extends EventEmitter {
66
79
  commitIndex: 0,
67
80
  lastApplied: 0,
68
81
  };
82
+
83
+ if (this.transport) {
84
+ this.transport.onMessage(async (msg: ConsensusMessage) => this.handleInboundRaftMessage(msg));
85
+ }
86
+ }
87
+
88
+ /** Index/term of the last entry in this node's log (Raft "up-to-date" comparison). */
89
+ private lastLogInfo(): { index: number; term: number } {
90
+ const last = this.node.log[this.node.log.length - 1];
91
+ return last ? { index: last.index, term: last.term } : { index: 0, term: 0 };
92
+ }
93
+
94
+ /**
95
+ * ADR-095 G2 — Raft receiver. Answers inbound RequestVote / AppendEntries
96
+ * with proper Raft rules. Returns the RPC response the transport relays
97
+ * back to the caller (the transport's `send` resolves with it).
98
+ */
99
+ private async handleInboundRaftMessage(msg: ConsensusMessage): Promise<ConsensusMessage | void> {
100
+ const p = (msg.payload ?? {}) as Record<string, unknown>;
101
+ const term = typeof p.term === 'number' ? p.term : 0;
102
+
103
+ // §5.1 — any RPC with a higher term makes us a follower and adopts it.
104
+ if (term > this.node.currentTerm) {
105
+ this.node.currentTerm = term;
106
+ this.node.votedFor = undefined;
107
+ this.node.state = 'follower';
108
+ }
109
+
110
+ if (msg.type === 'request-vote') {
111
+ const candidateId = String(p.candidateId ?? msg.from);
112
+ const candLastIndex = typeof p.lastLogIndex === 'number' ? p.lastLogIndex : 0;
113
+ const candLastTerm = typeof p.lastLogTerm === 'number' ? p.lastLogTerm : 0;
114
+ const my = this.lastLogInfo();
115
+ const logOk = candLastTerm > my.term || (candLastTerm === my.term && candLastIndex >= my.index);
116
+ const termOk = term >= this.node.currentTerm;
117
+ const notVotedOrSame = this.node.votedFor === undefined || this.node.votedFor === candidateId;
118
+ const granted = termOk && notVotedOrSame && logOk;
119
+ if (granted) {
120
+ this.node.votedFor = candidateId;
121
+ this.resetElectionTimeout();
122
+ }
123
+ return { type: 'vote-response', from: this.node.id, to: msg.from, payload: { term: this.node.currentTerm, granted }, term: this.node.currentTerm };
124
+ }
125
+
126
+ if (msg.type === 'append-entries') {
127
+ // §5.2/5.3 — reject if leader's term is stale.
128
+ if (term < this.node.currentTerm) {
129
+ return { type: 'append-entries-response', from: this.node.id, to: msg.from, payload: { term: this.node.currentTerm, success: false }, term: this.node.currentTerm };
130
+ }
131
+ // Valid leader heartbeat/append → become/stay follower, reset election timer.
132
+ this.node.state = 'follower';
133
+ this.resetElectionTimeout();
134
+ const entries = Array.isArray(p.entries) ? (p.entries as RaftLogEntry[]) : [];
135
+ // (Simplified log matching: append entries not already present by index.
136
+ // Full prevLogIndex/prevLogTerm conflict resolution is the next refinement.)
137
+ for (const e of entries) {
138
+ if (!this.node.log.some(x => x.index === e.index)) this.node.log.push(e);
139
+ }
140
+ const leaderCommit = typeof p.leaderCommit === 'number' ? p.leaderCommit : this.node.commitIndex;
141
+ if (leaderCommit > this.node.commitIndex) {
142
+ this.node.commitIndex = Math.min(leaderCommit, this.lastLogInfo().index);
143
+ }
144
+ return { type: 'append-entries-response', from: this.node.id, to: msg.from, payload: { term: this.node.currentTerm, success: true }, term: this.node.currentTerm };
145
+ }
146
+ return;
69
147
  }
70
148
 
71
149
  async initialize(): Promise<void> {
@@ -255,17 +333,37 @@ export class RaftConsensus extends EventEmitter {
255
333
  }
256
334
 
257
335
  private async requestVote(peerId: string): Promise<boolean> {
336
+ // ADR-095 G2 — over the transport when wired: real RequestVote RPC.
337
+ if (this.transport) {
338
+ const my = this.lastLogInfo();
339
+ try {
340
+ const reply = await this.transport.send(peerId, {
341
+ type: 'request-vote',
342
+ payload: { term: this.node.currentTerm, candidateId: this.node.id, lastLogIndex: my.index, lastLogTerm: my.term },
343
+ term: this.node.currentTerm,
344
+ });
345
+ const rp = (reply?.payload ?? {}) as Record<string, unknown>;
346
+ // §5.1 — if the responder's term is higher, step down.
347
+ if (typeof rp.term === 'number' && rp.term > this.node.currentTerm) {
348
+ this.node.currentTerm = rp.term;
349
+ this.node.votedFor = undefined;
350
+ this.node.state = 'follower';
351
+ return false;
352
+ }
353
+ return rp.granted === true;
354
+ } catch {
355
+ return false; // unreachable peer / timeout — counts as no vote.
356
+ }
357
+ }
358
+
359
+ // Legacy in-process path — mutates the local fake peer state.
258
360
  const peer = this.peers.get(peerId);
259
361
  if (!peer) return false;
260
-
261
- // Simulate vote request (in real implementation, this would be RPC)
262
- // For now, grant vote if candidate's term is higher
263
362
  if (this.node.currentTerm > peer.currentTerm) {
264
363
  peer.votedFor = this.node.id;
265
364
  peer.currentTerm = this.node.currentTerm;
266
365
  return true;
267
366
  }
268
-
269
367
  return false;
270
368
  }
271
369
 
@@ -294,17 +392,37 @@ export class RaftConsensus extends EventEmitter {
294
392
  }
295
393
 
296
394
  private async appendEntries(peerId: string, entries: RaftLogEntry[]): Promise<boolean> {
395
+ // ADR-095 G2 — over the transport when wired: real AppendEntries RPC.
396
+ if (this.transport) {
397
+ try {
398
+ const reply = await this.transport.send(peerId, {
399
+ type: 'append-entries',
400
+ payload: { term: this.node.currentTerm, leaderId: this.node.id, entries, leaderCommit: this.node.commitIndex },
401
+ term: this.node.currentTerm,
402
+ });
403
+ const rp = (reply?.payload ?? {}) as Record<string, unknown>;
404
+ if (typeof rp.term === 'number' && rp.term > this.node.currentTerm) {
405
+ this.node.currentTerm = rp.term;
406
+ this.node.votedFor = undefined;
407
+ this.node.state = 'follower';
408
+ if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = undefined; }
409
+ return false;
410
+ }
411
+ return rp.success === true;
412
+ } catch {
413
+ return false; // unreachable peer / timeout.
414
+ }
415
+ }
416
+
417
+ // Legacy in-process path.
297
418
  const peer = this.peers.get(peerId);
298
419
  if (!peer) return false;
299
-
300
- // Simulate AppendEntries RPC
301
420
  if (this.node.currentTerm >= peer.currentTerm) {
302
421
  peer.currentTerm = this.node.currentTerm;
303
422
  peer.state = 'follower';
304
423
  peer.log.push(...entries);
305
424
  return true;
306
425
  }
307
-
308
426
  return false;
309
427
  }
310
428