clawmatrix 0.1.0

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.
@@ -0,0 +1,333 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { ServerWebSocket } from "bun";
3
+ import type { ClawMatrixConfig, PeerConfig } from "./config.ts";
4
+ import { Connection } from "./connection.ts";
5
+ import type { WsTransport } from "./connection.ts";
6
+ import { Router } from "./router.ts";
7
+ import type {
8
+ AnyClusterFrame,
9
+ ClusterFrame,
10
+ NodeCapabilities,
11
+ PeerInfo,
12
+ PeerSync,
13
+ } from "./types.ts";
14
+
15
+ const RECONNECT_BASE = 1_000;
16
+ const RECONNECT_MAX = 60_000;
17
+
18
+ export interface PeerManagerEvents {
19
+ frame: [frame: AnyClusterFrame, from: Connection];
20
+ peerConnected: [nodeId: string];
21
+ peerDisconnected: [nodeId: string];
22
+ }
23
+
24
+ export class PeerManager extends EventEmitter<PeerManagerEvents> {
25
+ readonly router: Router;
26
+ private config: ClawMatrixConfig;
27
+ private localCapabilities: NodeCapabilities;
28
+ private wsServer: ReturnType<typeof Bun.serve> | null = null;
29
+ private reconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
30
+ private reconnectAttempts = new Map<string, number>();
31
+ private stopped = false;
32
+ /** Map from ServerWebSocket to Connection for inbound connections. */
33
+ private inboundConnections = new Map<ServerWebSocket<unknown>, Connection>();
34
+
35
+ constructor(config: ClawMatrixConfig) {
36
+ super();
37
+ this.config = config;
38
+ this.localCapabilities = {
39
+ nodeId: config.nodeId,
40
+ agents: config.agents,
41
+ models: config.models,
42
+ tags: config.tags,
43
+ };
44
+ this.router = new Router(config.nodeId, {
45
+ agents: config.agents,
46
+ models: config.models,
47
+ tags: config.tags,
48
+ });
49
+ }
50
+
51
+ // ── Lifecycle ──────────────────────────────────────────────────
52
+ async start() {
53
+ if (this.config.listen) {
54
+ this.startListening();
55
+ }
56
+ for (const peer of this.config.peers) {
57
+ this.connectToPeer(peer);
58
+ }
59
+ }
60
+
61
+ async stop() {
62
+ this.stopped = true;
63
+ for (const timer of this.reconnectTimers.values()) {
64
+ clearTimeout(timer);
65
+ }
66
+ this.reconnectTimers.clear();
67
+
68
+ this.router.broadcast({
69
+ type: "peer_leave",
70
+ from: this.config.nodeId,
71
+ timestamp: Date.now(),
72
+ payload: { nodeId: this.config.nodeId },
73
+ } as AnyClusterFrame);
74
+
75
+ for (const conn of this.router.getDirectConnections()) {
76
+ conn.close(1000, "shutdown");
77
+ }
78
+
79
+ if (this.wsServer) {
80
+ this.wsServer.stop();
81
+ this.wsServer = null;
82
+ }
83
+ }
84
+
85
+ // ── Inbound WS server (Bun.serve) ─────────────────────────────
86
+ private startListening() {
87
+ const port = this.config.listenPort;
88
+ const hostname = this.config.listenHost;
89
+ const self = this;
90
+
91
+ this.wsServer = Bun.serve<undefined>({
92
+ port,
93
+ hostname,
94
+ fetch(req, server) {
95
+ if (server.upgrade(req)) {
96
+ return undefined;
97
+ }
98
+ return new Response("WebSocket upgrade required", { status: 426 });
99
+ },
100
+ websocket: {
101
+ open(ws) {
102
+ self.handleInboundOpen(ws);
103
+ },
104
+ message(ws, message) {
105
+ const conn = self.inboundConnections.get(ws);
106
+ if (conn) {
107
+ conn.feedMessage(typeof message === "string" ? message : Buffer.from(message));
108
+ }
109
+ },
110
+ close(ws, code, reason) {
111
+ const conn = self.inboundConnections.get(ws);
112
+ if (conn) {
113
+ conn.feedClose(code, reason);
114
+ self.inboundConnections.delete(ws);
115
+ }
116
+ },
117
+ },
118
+ });
119
+ }
120
+
121
+ private handleInboundOpen(ws: ServerWebSocket<unknown>) {
122
+ // Wrap Bun's ServerWebSocket into our WsTransport interface
123
+ const transport: WsTransport = {
124
+ send(data: string) {
125
+ ws.send(data);
126
+ },
127
+ close(code?: number, reason?: string) {
128
+ ws.close(code, reason);
129
+ },
130
+ get readyState() {
131
+ return ws.readyState;
132
+ },
133
+ };
134
+
135
+ const conn = new Connection(
136
+ transport,
137
+ "inbound",
138
+ this.config.nodeId,
139
+ this.config.secret,
140
+ this.localCapabilities,
141
+ );
142
+
143
+ this.inboundConnections.set(ws, conn);
144
+
145
+ conn.on("authenticated", (caps) => {
146
+ this.onPeerAuthenticated(conn, caps);
147
+ });
148
+
149
+ conn.on("error", () => {
150
+ // Connection handles cleanup
151
+ });
152
+ }
153
+
154
+ // ── Outbound connections (standard WebSocket) ──────────────────
155
+ private connectToPeer(peer: PeerConfig) {
156
+ if (this.stopped) return;
157
+
158
+ const ws = new WebSocket(peer.url);
159
+
160
+ ws.addEventListener("open", () => {
161
+ const conn = new Connection(
162
+ ws,
163
+ "outbound",
164
+ this.config.nodeId,
165
+ this.config.secret,
166
+ this.localCapabilities,
167
+ );
168
+ conn.bindWebSocket(ws);
169
+
170
+ conn.on("authenticated", (caps) => {
171
+ this.reconnectAttempts.delete(peer.nodeId);
172
+ this.onPeerAuthenticated(conn, caps);
173
+ });
174
+
175
+ conn.on("error", () => {
176
+ // will close
177
+ });
178
+ });
179
+
180
+ ws.addEventListener("error", () => {
181
+ this.scheduleReconnect(peer);
182
+ });
183
+
184
+ ws.addEventListener("close", () => {
185
+ this.scheduleReconnect(peer);
186
+ });
187
+ }
188
+
189
+ private scheduleReconnect(peer: PeerConfig) {
190
+ if (this.stopped) return;
191
+ if (this.reconnectTimers.has(peer.nodeId)) return;
192
+
193
+ const attempt = this.reconnectAttempts.get(peer.nodeId) ?? 0;
194
+ const delay = Math.min(RECONNECT_BASE * 2 ** attempt, RECONNECT_MAX);
195
+ this.reconnectAttempts.set(peer.nodeId, attempt + 1);
196
+
197
+ const timer = setTimeout(() => {
198
+ this.reconnectTimers.delete(peer.nodeId);
199
+ this.connectToPeer(peer);
200
+ }, delay);
201
+ this.reconnectTimers.set(peer.nodeId, timer);
202
+ }
203
+
204
+ // ── Peer lifecycle ─────────────────────────────────────────────
205
+ private onPeerAuthenticated(conn: Connection, caps: NodeCapabilities) {
206
+ const nodeId = conn.remoteNodeId!;
207
+ this.router.addDirectPeer(nodeId, conn, caps);
208
+
209
+ conn.on("message", (frame) => this.onFrame(frame, conn));
210
+ conn.on("close", () => this.onPeerDisconnected(conn));
211
+
212
+ this.sendPeerSync(conn);
213
+
214
+ // Broadcast peer_join to other peers
215
+ this.router.broadcast({
216
+ type: "peer_join",
217
+ from: this.config.nodeId,
218
+ timestamp: Date.now(),
219
+ payload: {
220
+ nodeId,
221
+ agents: caps.agents,
222
+ models: caps.models,
223
+ tags: caps.tags,
224
+ },
225
+ } as AnyClusterFrame);
226
+
227
+ // Re-sync with all existing peers so they learn about the new node
228
+ for (const existingConn of this.router.getDirectConnections()) {
229
+ if (existingConn !== conn && existingConn.isOpen) {
230
+ this.sendPeerSync(existingConn);
231
+ }
232
+ }
233
+
234
+ this.emit("peerConnected", nodeId);
235
+ }
236
+
237
+ private onPeerDisconnected(conn: Connection) {
238
+ const nodeId = conn.remoteNodeId;
239
+ if (!nodeId) return;
240
+
241
+ this.router.removePeer(nodeId);
242
+
243
+ this.router.broadcast({
244
+ type: "peer_leave",
245
+ from: this.config.nodeId,
246
+ timestamp: Date.now(),
247
+ payload: { nodeId },
248
+ } as AnyClusterFrame);
249
+
250
+ this.emit("peerDisconnected", nodeId);
251
+ }
252
+
253
+ // ── Message handling ───────────────────────────────────────────
254
+ private onFrame(frame: AnyClusterFrame, from: Connection) {
255
+ if (frame.id && this.router.isDuplicate(frame.id)) return;
256
+
257
+ if (frame.type === "peer_sync") {
258
+ this.handlePeerSync(frame as PeerSync, from);
259
+ return;
260
+ }
261
+
262
+ if (frame.type === "peer_join") {
263
+ const peer = (frame as AnyClusterFrame & { payload: PeerInfo }).payload;
264
+ if (peer.nodeId !== this.config.nodeId) {
265
+ this.router.addRelayPeer(peer, from.remoteNodeId!);
266
+ }
267
+ return;
268
+ }
269
+
270
+ if (frame.type === "peer_leave") {
271
+ const { nodeId } = (frame as AnyClusterFrame & { payload: { nodeId: string } }).payload;
272
+ const route = this.router.getRoute(nodeId);
273
+ if (route && route.reachableVia) {
274
+ this.router.removePeer(nodeId);
275
+ }
276
+ return;
277
+ }
278
+
279
+ if (frame.to && frame.to !== this.config.nodeId) {
280
+ this.router.tryRelay(frame);
281
+ return;
282
+ }
283
+
284
+ this.emit("frame", frame, from);
285
+ }
286
+
287
+ private sendPeerSync(conn: Connection) {
288
+ const peers = this.router.buildPeerSyncPayload();
289
+ conn.send({
290
+ type: "peer_sync",
291
+ from: this.config.nodeId,
292
+ timestamp: Date.now(),
293
+ payload: { peers },
294
+ } as AnyClusterFrame);
295
+ }
296
+
297
+ private handlePeerSync(frame: PeerSync, from: Connection) {
298
+ let changed = false;
299
+ for (const peer of frame.payload.peers) {
300
+ if (peer.nodeId === this.config.nodeId) continue;
301
+ if (peer.nodeId === from.remoteNodeId) {
302
+ const prev = this.router.getRoute(peer.nodeId);
303
+ const hadAgents = prev?.agents.length ?? 0;
304
+ this.router.updatePeerCapabilities(peer.nodeId, peer);
305
+ if (peer.agents.length !== hadAgents || peer.models.length !== (prev?.models.length ?? 0)) {
306
+ changed = true;
307
+ }
308
+ } else {
309
+ const existing = this.router.getRoute(peer.nodeId);
310
+ if (!existing) changed = true;
311
+ this.router.addRelayPeer(peer, from.remoteNodeId!);
312
+ }
313
+ }
314
+
315
+ // If we learned new info, re-sync with other peers
316
+ if (changed) {
317
+ for (const conn of this.router.getDirectConnections()) {
318
+ if (conn !== from && conn.isOpen) {
319
+ this.sendPeerSync(conn);
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ // ── Public API ─────────────────────────────────────────────────
326
+ sendTo(nodeId: string, frame: ClusterFrame | AnyClusterFrame): boolean {
327
+ return this.router.sendTo(nodeId, frame);
328
+ }
329
+
330
+ broadcast(frame: ClusterFrame | AnyClusterFrame) {
331
+ this.router.broadcast(frame);
332
+ }
333
+ }
package/src/router.ts ADDED
@@ -0,0 +1,275 @@
1
+ import type { ClusterFrame, AnyClusterFrame, PeerInfo, AgentInfo, ModelInfo } from "./types.ts";
2
+ import type { Connection } from "./connection.ts";
3
+
4
+ const DEFAULT_TTL = 3;
5
+ const SEEN_FRAME_TTL = 60_000; // 60s dedup window
6
+ const MAX_SEEN_FRAMES = 10_000;
7
+
8
+ export interface RouteEntry {
9
+ nodeId: string;
10
+ agents: AgentInfo[];
11
+ models: ModelInfo[];
12
+ tags: string[];
13
+ connection: Connection | null; // null = reachable via relay
14
+ reachableVia: string | null; // relay nodeId, null = direct
15
+ lastSeen: number;
16
+ latencyMs: number;
17
+ }
18
+
19
+ export class Router {
20
+ private nodeId: string;
21
+ private localAgents: AgentInfo[];
22
+ private localModels: ModelInfo[];
23
+ private localTags: string[];
24
+ private routes = new Map<string, RouteEntry>();
25
+ private connections = new Map<string, Connection>(); // nodeId → direct connection
26
+ private seenFrames = new Map<string, number>(); // frameId → timestamp
27
+
28
+ constructor(
29
+ nodeId: string,
30
+ localCapabilities?: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[] },
31
+ ) {
32
+ this.nodeId = nodeId;
33
+ this.localAgents = localCapabilities?.agents ?? [];
34
+ this.localModels = localCapabilities?.models ?? [];
35
+ this.localTags = localCapabilities?.tags ?? [];
36
+ }
37
+
38
+ // ── Route table management ─────────────────────────────────────
39
+ addDirectPeer(
40
+ nodeId: string,
41
+ connection: Connection,
42
+ capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[] },
43
+ ) {
44
+ this.connections.set(nodeId, connection);
45
+ this.routes.set(nodeId, {
46
+ nodeId,
47
+ agents: capabilities.agents,
48
+ models: capabilities.models,
49
+ tags: capabilities.tags,
50
+ connection,
51
+ reachableVia: null,
52
+ lastSeen: Date.now(),
53
+ latencyMs: 0,
54
+ });
55
+ }
56
+
57
+ addRelayPeer(peer: PeerInfo, viaNodeId: string) {
58
+ // Don't add ourselves
59
+ if (peer.nodeId === this.nodeId) return;
60
+ // Don't overwrite direct connections with relay entries
61
+ const existing = this.routes.get(peer.nodeId);
62
+ if (existing?.connection) return;
63
+
64
+ this.routes.set(peer.nodeId, {
65
+ nodeId: peer.nodeId,
66
+ agents: peer.agents,
67
+ models: peer.models,
68
+ tags: peer.tags,
69
+ connection: null,
70
+ reachableVia: viaNodeId,
71
+ lastSeen: Date.now(),
72
+ latencyMs: 0,
73
+ });
74
+ }
75
+
76
+ removePeer(nodeId: string) {
77
+ this.connections.delete(nodeId);
78
+ this.routes.delete(nodeId);
79
+ // Also remove routes that relied on this node as relay
80
+ for (const [id, entry] of this.routes) {
81
+ if (entry.reachableVia === nodeId) {
82
+ this.routes.delete(id);
83
+ }
84
+ }
85
+ }
86
+
87
+ updatePeerCapabilities(
88
+ nodeId: string,
89
+ capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[] },
90
+ ) {
91
+ const entry = this.routes.get(nodeId);
92
+ if (entry) {
93
+ entry.agents = capabilities.agents;
94
+ entry.models = capabilities.models;
95
+ entry.tags = capabilities.tags;
96
+ entry.lastSeen = Date.now();
97
+ }
98
+ }
99
+
100
+ // ── Routing ────────────────────────────────────────────────────
101
+ getRoute(nodeId: string): RouteEntry | undefined {
102
+ return this.routes.get(nodeId);
103
+ }
104
+
105
+ /** Resolve target agent to a specific nodeId. Supports agent ID or "tags:<tag>". */
106
+ resolveAgent(target: string): RouteEntry | undefined {
107
+ const isTagQuery = target.startsWith("tags:");
108
+ const tag = isTagQuery ? target.slice(5) : null;
109
+
110
+ let candidates: RouteEntry[] = [];
111
+ for (const entry of this.routes.values()) {
112
+ if (isTagQuery) {
113
+ if (entry.agents.some((a) => a.tags.includes(tag!)) || entry.tags.includes(tag!)) {
114
+ candidates.push(entry);
115
+ }
116
+ } else {
117
+ if (entry.agents.some((a) => a.id === target)) {
118
+ candidates.push(entry);
119
+ }
120
+ }
121
+ }
122
+
123
+ if (candidates.length === 0) return undefined;
124
+
125
+ // Sort: direct connections first, then by latency
126
+ candidates.sort((a, b) => {
127
+ const aDirect = a.connection ? 0 : 1;
128
+ const bDirect = b.connection ? 0 : 1;
129
+ if (aDirect !== bDirect) return aDirect - bDirect;
130
+ return a.latencyMs - b.latencyMs;
131
+ });
132
+
133
+ return candidates[0];
134
+ }
135
+
136
+ /** Find node that has a specific model. */
137
+ resolveModel(modelId: string): RouteEntry | undefined {
138
+ let candidates: RouteEntry[] = [];
139
+ for (const entry of this.routes.values()) {
140
+ if (entry.models.some((m) => m.id === modelId)) {
141
+ candidates.push(entry);
142
+ }
143
+ }
144
+ if (candidates.length === 0) return undefined;
145
+
146
+ candidates.sort((a, b) => {
147
+ const aDirect = a.connection ? 0 : 1;
148
+ const bDirect = b.connection ? 0 : 1;
149
+ if (aDirect !== bDirect) return aDirect - bDirect;
150
+ return a.latencyMs - b.latencyMs;
151
+ });
152
+ return candidates[0];
153
+ }
154
+
155
+ /** Resolve a node target (nodeId or "tags:<tag>"). */
156
+ resolveNode(target: string): RouteEntry | undefined {
157
+ if (target.startsWith("tags:")) {
158
+ const tag = target.slice(5);
159
+ let best: RouteEntry | undefined;
160
+ for (const entry of this.routes.values()) {
161
+ if (entry.tags.includes(tag)) {
162
+ if (!best || (entry.connection && !best.connection) || entry.latencyMs < best.latencyMs) {
163
+ best = entry;
164
+ }
165
+ }
166
+ }
167
+ return best;
168
+ }
169
+ return this.routes.get(target);
170
+ }
171
+
172
+ // ── Message sending and relay ──────────────────────────────────
173
+ /** Send a frame to a specific node, relaying if necessary. Returns true if sent. */
174
+ sendTo(targetNodeId: string, frame: ClusterFrame | AnyClusterFrame): boolean {
175
+ const route = this.routes.get(targetNodeId);
176
+ if (!route) return false;
177
+
178
+ if (route.connection?.isOpen) {
179
+ route.connection.send(frame);
180
+ return true;
181
+ }
182
+
183
+ // Relay through intermediate node
184
+ if (route.reachableVia) {
185
+ const relay = this.connections.get(route.reachableVia);
186
+ if (relay?.isOpen) {
187
+ relay.send(frame);
188
+ return true;
189
+ }
190
+ }
191
+
192
+ return false;
193
+ }
194
+
195
+ /** Broadcast a frame to all direct peers. */
196
+ broadcast(frame: ClusterFrame | AnyClusterFrame) {
197
+ for (const conn of this.connections.values()) {
198
+ if (conn.isOpen) {
199
+ conn.send(frame);
200
+ }
201
+ }
202
+ }
203
+
204
+ /** Handle relay: if message is not for us, forward it. Returns true if relayed. */
205
+ tryRelay(frame: ClusterFrame): boolean {
206
+ if (!frame.to || frame.to === this.nodeId) return false;
207
+
208
+ const ttl = (frame.ttl ?? DEFAULT_TTL) - 1;
209
+ if (ttl <= 0) return false;
210
+
211
+ const relayed = this.sendTo(frame.to, { ...frame, ttl });
212
+ return relayed;
213
+ }
214
+
215
+ // ── Deduplication ──────────────────────────────────────────────
216
+ /** Returns true if the frame has been seen before (duplicate). */
217
+ isDuplicate(frameId: string): boolean {
218
+ if (!frameId) return false;
219
+ if (this.seenFrames.has(frameId)) return true;
220
+
221
+ this.seenFrames.set(frameId, Date.now());
222
+ this.pruneSeenFrames();
223
+ return false;
224
+ }
225
+
226
+ /** Mark a request ID as failed so late responses are ignored. */
227
+ markFailed(requestId: string) {
228
+ this.seenFrames.set(`failed:${requestId}`, Date.now());
229
+ }
230
+
231
+ isFailed(requestId: string): boolean {
232
+ return this.seenFrames.has(`failed:${requestId}`);
233
+ }
234
+
235
+ private pruneSeenFrames() {
236
+ if (this.seenFrames.size <= MAX_SEEN_FRAMES) return;
237
+ const now = Date.now();
238
+ for (const [id, ts] of this.seenFrames) {
239
+ if (now - ts > SEEN_FRAME_TTL) {
240
+ this.seenFrames.delete(id);
241
+ }
242
+ }
243
+ }
244
+
245
+ // ── Accessors ──────────────────────────────────────────────────
246
+ getAllPeers(): RouteEntry[] {
247
+ return [...this.routes.values()];
248
+ }
249
+
250
+ getDirectConnections(): Connection[] {
251
+ return [...this.connections.values()];
252
+ }
253
+
254
+ /** Build PeerInfo list for peer_sync. */
255
+ buildPeerSyncPayload(): PeerInfo[] {
256
+ const peers: PeerInfo[] = [];
257
+ // Include ourselves
258
+ peers.push({
259
+ nodeId: this.nodeId,
260
+ agents: this.localAgents,
261
+ models: this.localModels,
262
+ tags: this.localTags,
263
+ });
264
+ for (const entry of this.routes.values()) {
265
+ peers.push({
266
+ nodeId: entry.nodeId,
267
+ agents: entry.agents,
268
+ models: entry.models,
269
+ tags: entry.tags,
270
+ reachableVia: entry.reachableVia ?? undefined,
271
+ });
272
+ }
273
+ return peers;
274
+ }
275
+ }