clawmatrix 0.1.15 → 0.1.16
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/BOOTSTRAP.md +17 -2
- package/package.json +1 -1
- package/src/auth.ts +42 -12
- package/src/cluster-service.ts +31 -4
- package/src/compat.ts +3 -0
- package/src/connection.ts +15 -6
- package/src/handoff.ts +311 -17
- package/src/http-utils.ts +35 -0
- package/src/index.ts +33 -15
- package/src/model-proxy.ts +19 -16
- package/src/peer-manager.ts +55 -5
- package/src/router.ts +62 -28
- package/src/tool-proxy.ts +22 -7
- package/src/tools/cluster-events.ts +119 -0
- package/src/tools/cluster-exec.ts +4 -0
- package/src/tools/cluster-handoff-reply.ts +77 -0
- package/src/tools/cluster-handoff.ts +12 -0
- package/src/tools/cluster-peers.ts +17 -1
- package/src/tools/cluster-send.ts +1 -3
- package/src/tools/cluster-tool.ts +2 -5
- package/src/types.ts +93 -0
- package/src/web-ui.ts +490 -345
- package/src/web.ts +675 -53
package/src/web.ts
CHANGED
|
@@ -2,11 +2,36 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import type { PeerManager } from "./peer-manager.ts";
|
|
3
3
|
import type { HandoffManager } from "./handoff.ts";
|
|
4
4
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
5
|
+
import type { SatelliteContext, IngestedEvent } from "./types.ts";
|
|
5
6
|
import { timingSafeEqual } from "./auth.ts";
|
|
6
7
|
import { renderDashboard } from "./web-ui.ts";
|
|
8
|
+
import { readBody } from "./http-utils.ts";
|
|
7
9
|
|
|
8
10
|
const COOKIE_NAME = "clawmatrix_token";
|
|
9
11
|
const SESSION_MAX_AGE = 86400 * 7; // 7 days
|
|
12
|
+
const MAX_EVENTS = 100; // ring buffer for satellite polling
|
|
13
|
+
const LOGIN_RATE_WINDOW = 60_000; // 1 minute
|
|
14
|
+
const LOGIN_RATE_MAX = 10; // max attempts per window per IP
|
|
15
|
+
const MAX_INGESTED_EVENTS = 500; // ring buffer for ingested events
|
|
16
|
+
const INGESTED_EVENT_TTL = 86400_000; // 24 hours
|
|
17
|
+
const SATELLITE_TOOL_TIMEOUT = 120_000; // 2 min timeout for satellite tool requests
|
|
18
|
+
|
|
19
|
+
interface SatelliteEvent {
|
|
20
|
+
ts: number;
|
|
21
|
+
type: "peer_online" | "peer_offline" | "handoff_done" | "context_update" | "event_ingested";
|
|
22
|
+
data: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface PendingSatelliteTool {
|
|
26
|
+
id: string;
|
|
27
|
+
tool: string;
|
|
28
|
+
params: Record<string, unknown>;
|
|
29
|
+
from: string;
|
|
30
|
+
ts: number;
|
|
31
|
+
resolve: (result: Record<string, unknown>) => void;
|
|
32
|
+
reject: (error: Error) => void;
|
|
33
|
+
timer: ReturnType<typeof setTimeout>;
|
|
34
|
+
}
|
|
10
35
|
|
|
11
36
|
export class WebHandler {
|
|
12
37
|
private config: ClawMatrixConfig;
|
|
@@ -14,12 +39,82 @@ export class WebHandler {
|
|
|
14
39
|
private handoffManager: HandoffManager;
|
|
15
40
|
private token: string;
|
|
16
41
|
private startTime = Date.now();
|
|
42
|
+
private satelliteEvents: SatelliteEvent[] = [];
|
|
43
|
+
private satelliteContexts = new Map<string, SatelliteContext>(); // nodeId → context
|
|
44
|
+
private knownPeerState = new Map<string, boolean>(); // nodeId → online
|
|
45
|
+
private satelliteToolQueue = new Map<string, PendingSatelliteTool[]>(); // nodeId → pending tools
|
|
46
|
+
private ingestedEvents: IngestedEvent[] = []; // ring buffer for ingested events
|
|
47
|
+
private loginAttempts = new Map<string, { count: number; resetAt: number }>(); // IP → rate limit
|
|
48
|
+
private loginCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
49
|
+
private onPeerConnected: (nodeId: string) => void;
|
|
50
|
+
private onPeerDisconnected: (nodeId: string) => void;
|
|
17
51
|
|
|
18
52
|
constructor(config: ClawMatrixConfig, peerManager: PeerManager, handoffManager: HandoffManager) {
|
|
19
53
|
this.config = config;
|
|
20
54
|
this.peerManager = peerManager;
|
|
21
55
|
this.handoffManager = handoffManager;
|
|
22
56
|
this.token = config.web!.token;
|
|
57
|
+
|
|
58
|
+
// Periodically clean up stale login rate-limit entries
|
|
59
|
+
this.loginCleanupTimer = setInterval(() => {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
for (const [ip, entry] of this.loginAttempts) {
|
|
62
|
+
if (now > entry.resetAt) this.loginAttempts.delete(ip);
|
|
63
|
+
}
|
|
64
|
+
}, LOGIN_RATE_WINDOW);
|
|
65
|
+
|
|
66
|
+
// Listen for peer changes in real-time and push satellite events immediately
|
|
67
|
+
this.onPeerConnected = (nodeId: string) => {
|
|
68
|
+
const route = peerManager.router.getRoute(nodeId);
|
|
69
|
+
this.pushSatelliteEvent({
|
|
70
|
+
ts: Date.now(),
|
|
71
|
+
type: "peer_online",
|
|
72
|
+
data: {
|
|
73
|
+
nodeId,
|
|
74
|
+
agents: route?.agents.map(a => a.id) ?? [],
|
|
75
|
+
models: route?.models.map(m => m.id) ?? [],
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
this.knownPeerState.set(nodeId, true);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
this.onPeerDisconnected = (nodeId: string) => {
|
|
82
|
+
this.pushSatelliteEvent({
|
|
83
|
+
ts: Date.now(),
|
|
84
|
+
type: "peer_offline",
|
|
85
|
+
data: { nodeId },
|
|
86
|
+
});
|
|
87
|
+
this.knownPeerState.set(nodeId, false);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
peerManager.on("peerConnected", this.onPeerConnected);
|
|
91
|
+
peerManager.on("peerDisconnected", this.onPeerDisconnected);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Clean up timers and pending requests on shutdown. */
|
|
95
|
+
destroy() {
|
|
96
|
+
// Remove event listeners to prevent post-destroy callbacks
|
|
97
|
+
this.peerManager.removeListener("peerConnected", this.onPeerConnected);
|
|
98
|
+
this.peerManager.removeListener("peerDisconnected", this.onPeerDisconnected);
|
|
99
|
+
|
|
100
|
+
if (this.loginCleanupTimer) {
|
|
101
|
+
clearInterval(this.loginCleanupTimer);
|
|
102
|
+
this.loginCleanupTimer = null;
|
|
103
|
+
}
|
|
104
|
+
// Clear all satellite tool timeouts
|
|
105
|
+
for (const [, queue] of this.satelliteToolQueue) {
|
|
106
|
+
for (const entry of queue) {
|
|
107
|
+
clearTimeout(entry.timer);
|
|
108
|
+
entry.reject(new Error("Shutting down"));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
this.satelliteToolQueue.clear();
|
|
112
|
+
// Clear long-poll waiters
|
|
113
|
+
for (const w of this.satelliteWaiters) {
|
|
114
|
+
clearTimeout(w.timer);
|
|
115
|
+
try { w.res.end(); } catch { /* already closed */ }
|
|
116
|
+
}
|
|
117
|
+
this.satelliteWaiters.length = 0;
|
|
23
118
|
}
|
|
24
119
|
|
|
25
120
|
/** Handle an HTTP request. Returns true if handled, false to fall through. */
|
|
@@ -40,51 +135,84 @@ export class WebHandler {
|
|
|
40
135
|
return true;
|
|
41
136
|
}
|
|
42
137
|
|
|
43
|
-
// All /api/* routes require auth
|
|
138
|
+
// All /api/* routes require auth (async)
|
|
44
139
|
if (path.startsWith("/api/")) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return true;
|
|
49
|
-
}
|
|
140
|
+
this.handleAuthenticatedRoute(req, res, path);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
50
143
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
55
146
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
147
|
+
private async handleAuthenticatedRoute(req: IncomingMessage, res: ServerResponse, path: string) {
|
|
148
|
+
if (!await this.checkAuth(req)) {
|
|
149
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
150
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
60
153
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
154
|
+
if (path === "/api/status" && req.method === "GET") {
|
|
155
|
+
this.handleStatus(res);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
65
158
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
159
|
+
if (path === "/api/chat" && req.method === "POST") {
|
|
160
|
+
this.handleChat(req, res);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
70
163
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
164
|
+
if (path === "/api/handoff" && req.method === "POST") {
|
|
165
|
+
this.handleHandoff(req, res);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
75
168
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return
|
|
169
|
+
if (path === "/api/events/ingest" && req.method === "POST") {
|
|
170
|
+
this.handleEventIngest(req, res);
|
|
171
|
+
return;
|
|
79
172
|
}
|
|
80
173
|
|
|
81
|
-
|
|
174
|
+
if (path === "/api/events" && req.method === "GET") {
|
|
175
|
+
this.handleEventQuery(req, res);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (path === "/api/events/consume" && req.method === "POST") {
|
|
180
|
+
this.handleEventConsume(req, res);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (path === "/api/satellite/poll" && req.method === "GET") {
|
|
185
|
+
this.handleSatellitePoll(req, res);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (path === "/api/satellite/context" && req.method === "POST") {
|
|
190
|
+
this.handleSatelliteContext(req, res);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (path === "/api/satellite/tool/result" && req.method === "POST") {
|
|
195
|
+
this.handleSatelliteToolResult(req, res);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (path === "/api/logout" && req.method === "POST") {
|
|
200
|
+
res.writeHead(200, {
|
|
201
|
+
"Content-Type": "application/json",
|
|
202
|
+
"Set-Cookie": `${COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict`,
|
|
203
|
+
});
|
|
204
|
+
res.end(JSON.stringify({ ok: true }));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
209
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
82
210
|
}
|
|
83
211
|
|
|
84
|
-
private checkAuth(req: IncomingMessage): boolean {
|
|
212
|
+
private async checkAuth(req: IncomingMessage): Promise<boolean> {
|
|
85
213
|
// Check Authorization header
|
|
86
214
|
const authHeader = req.headers.authorization;
|
|
87
|
-
if (authHeader?.startsWith("Bearer ") && timingSafeEqual(authHeader.slice(7), this.token)) {
|
|
215
|
+
if (authHeader?.startsWith("Bearer ") && await timingSafeEqual(authHeader.slice(7), this.token)) {
|
|
88
216
|
return true;
|
|
89
217
|
}
|
|
90
218
|
|
|
@@ -92,7 +220,8 @@ export class WebHandler {
|
|
|
92
220
|
const cookies = req.headers.cookie ?? "";
|
|
93
221
|
const match = cookies.split(";").find((c) => c.trim().startsWith(`${COOKIE_NAME}=`));
|
|
94
222
|
if (match) {
|
|
95
|
-
const
|
|
223
|
+
const raw = match.trim().slice(COOKIE_NAME.length + 1);
|
|
224
|
+
const value = decodeURIComponent(raw);
|
|
96
225
|
return timingSafeEqual(value, this.token);
|
|
97
226
|
}
|
|
98
227
|
|
|
@@ -100,19 +229,40 @@ export class WebHandler {
|
|
|
100
229
|
}
|
|
101
230
|
|
|
102
231
|
private async handleLogin(req: IncomingMessage, res: ServerResponse) {
|
|
232
|
+
// Rate limiting by IP
|
|
233
|
+
const ip = req.socket.remoteAddress ?? "unknown";
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
let entry = this.loginAttempts.get(ip);
|
|
236
|
+
if (entry && now > entry.resetAt) entry = undefined;
|
|
237
|
+
if (entry && entry.count >= LOGIN_RATE_MAX) {
|
|
238
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
239
|
+
res.end(JSON.stringify({ error: "Too many login attempts, try again later" }));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
103
243
|
try {
|
|
104
244
|
const body = await readBody(req);
|
|
105
245
|
const { token } = JSON.parse(body);
|
|
106
246
|
|
|
107
|
-
if (!token || !timingSafeEqual(String(token), this.token)) {
|
|
247
|
+
if (!token || !await timingSafeEqual(String(token), this.token)) {
|
|
248
|
+
// Track failed attempt
|
|
249
|
+
if (!entry) {
|
|
250
|
+
entry = { count: 0, resetAt: now + LOGIN_RATE_WINDOW };
|
|
251
|
+
this.loginAttempts.set(ip, entry);
|
|
252
|
+
}
|
|
253
|
+
entry.count++;
|
|
254
|
+
|
|
108
255
|
res.writeHead(403, { "Content-Type": "application/json" });
|
|
109
256
|
res.end(JSON.stringify({ error: "Invalid token" }));
|
|
110
257
|
return;
|
|
111
258
|
}
|
|
112
259
|
|
|
260
|
+
// Success — clear rate limit for this IP
|
|
261
|
+
this.loginAttempts.delete(ip);
|
|
262
|
+
|
|
113
263
|
res.writeHead(200, {
|
|
114
264
|
"Content-Type": "application/json",
|
|
115
|
-
"Set-Cookie": `${COOKIE_NAME}=${token}; Path=/; Max-Age=${SESSION_MAX_AGE}; HttpOnly; SameSite=Strict`,
|
|
265
|
+
"Set-Cookie": `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; Max-Age=${SESSION_MAX_AGE}; HttpOnly; SameSite=Strict`,
|
|
116
266
|
});
|
|
117
267
|
res.end(JSON.stringify({ ok: true }));
|
|
118
268
|
} catch {
|
|
@@ -131,9 +281,24 @@ export class WebHandler {
|
|
|
131
281
|
connection: "self" as const,
|
|
132
282
|
online: true,
|
|
133
283
|
deviceInfo: this.peerManager.localDeviceInfo,
|
|
284
|
+
toolProxy: this.config.toolProxy ? {
|
|
285
|
+
enabled: this.config.toolProxy.enabled,
|
|
286
|
+
allow: this.config.toolProxy.allow,
|
|
287
|
+
deny: this.config.toolProxy.deny,
|
|
288
|
+
} : undefined,
|
|
289
|
+
clusterTools: [
|
|
290
|
+
"cluster_handoff", "cluster_send", "cluster_peers",
|
|
291
|
+
"cluster_exec", "cluster_read", "cluster_write", "cluster_tool",
|
|
292
|
+
],
|
|
134
293
|
};
|
|
135
294
|
|
|
136
|
-
|
|
295
|
+
// All mesh peers share these cluster tools (they all run the ClawMatrix plugin)
|
|
296
|
+
const CLUSTER_TOOLS = [
|
|
297
|
+
"cluster_handoff", "cluster_send", "cluster_peers",
|
|
298
|
+
"cluster_exec", "cluster_read", "cluster_write", "cluster_tool",
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
const peerNodes: Record<string, unknown>[] = peers.map((p) => ({
|
|
137
302
|
nodeId: p.nodeId,
|
|
138
303
|
agents: p.agents,
|
|
139
304
|
models: p.models,
|
|
@@ -145,8 +310,38 @@ export class WebHandler {
|
|
|
145
310
|
latencyMs: p.latencyMs,
|
|
146
311
|
directPeers: p.directPeers.length > 0 ? p.directPeers : undefined,
|
|
147
312
|
deviceInfo: p.deviceInfo,
|
|
313
|
+
toolProxy: p.toolProxy,
|
|
314
|
+
clusterTools: CLUSTER_TOOLS,
|
|
148
315
|
}));
|
|
149
316
|
|
|
317
|
+
// Merge satellite nodes into peers list
|
|
318
|
+
for (const sat of this.getSatelliteContexts()) {
|
|
319
|
+
peerNodes.push({
|
|
320
|
+
nodeId: sat.nodeId,
|
|
321
|
+
agents: [],
|
|
322
|
+
models: [],
|
|
323
|
+
tags: [],
|
|
324
|
+
connection: "satellite" as const,
|
|
325
|
+
online: true,
|
|
326
|
+
toolProxy: sat.tools?.length ? {
|
|
327
|
+
enabled: true,
|
|
328
|
+
allow: sat.tools,
|
|
329
|
+
deny: [],
|
|
330
|
+
} : undefined,
|
|
331
|
+
// Satellite-specific fields
|
|
332
|
+
ssid: sat.ssid,
|
|
333
|
+
ip: sat.ip,
|
|
334
|
+
router: sat.router,
|
|
335
|
+
cellular: sat.cellular,
|
|
336
|
+
country: sat.country,
|
|
337
|
+
battery: sat.battery,
|
|
338
|
+
charging: sat.charging,
|
|
339
|
+
platform: sat.platform,
|
|
340
|
+
location: sat.location,
|
|
341
|
+
lastSeen: sat.ts,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
150
345
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
151
346
|
res.end(JSON.stringify({
|
|
152
347
|
nodeId: this.config.nodeId,
|
|
@@ -261,24 +456,451 @@ export class WebHandler {
|
|
|
261
456
|
}
|
|
262
457
|
}
|
|
263
458
|
}
|
|
264
|
-
|
|
459
|
+
// ── Satellite integration ────────────────────────────────────────
|
|
265
460
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
461
|
+
/** Sync knownPeerState with current routes (no event emission — events come from real-time listeners). */
|
|
462
|
+
private syncPeerState() {
|
|
463
|
+
const peers = this.peerManager.router.getAllPeers();
|
|
464
|
+
for (const p of peers) {
|
|
465
|
+
const online = this.peerManager.canReach(p.nodeId);
|
|
466
|
+
this.knownPeerState.set(p.nodeId, online);
|
|
467
|
+
}
|
|
468
|
+
// Remove peers no longer in routes
|
|
469
|
+
for (const [nodeId] of this.knownPeerState) {
|
|
470
|
+
if (!peers.some(p => p.nodeId === nodeId)) {
|
|
471
|
+
this.knownPeerState.delete(nodeId);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private pushSatelliteEvent(event: SatelliteEvent) {
|
|
477
|
+
this.satelliteEvents.push(event);
|
|
478
|
+
if (this.satelliteEvents.length > MAX_EVENTS) {
|
|
479
|
+
this.satelliteEvents.splice(0, this.satelliteEvents.length - MAX_EVENTS);
|
|
480
|
+
}
|
|
481
|
+
// Wake up any long-polling clients immediately
|
|
482
|
+
if (this.satelliteWaiters.length > 0) {
|
|
483
|
+
this.flushSatelliteWaiters();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Notify satellite of handoff completion. Called externally. */
|
|
488
|
+
notifyHandoffDone(nodeId: string, agent: string, success: boolean, summary?: string) {
|
|
489
|
+
this.pushSatelliteEvent({
|
|
490
|
+
ts: Date.now(),
|
|
491
|
+
type: "handoff_done",
|
|
492
|
+
data: { nodeId, agent, success, summary },
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Pending long-poll waiters. */
|
|
497
|
+
private satelliteWaiters: Array<{ res: ServerResponse; since: number; nodeId?: string; timer: ReturnType<typeof setTimeout> }> = [];
|
|
498
|
+
|
|
499
|
+
/** GET /api/satellite/poll?since=<ts>&wait=<seconds>&nodeId=<id> — poll with optional long-poll */
|
|
500
|
+
private handleSatellitePoll(req: IncomingMessage, res: ServerResponse) {
|
|
501
|
+
this.syncPeerState();
|
|
502
|
+
|
|
503
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
504
|
+
const since = parseInt(url.searchParams.get("since") ?? "0", 10) || 0;
|
|
505
|
+
const wait = Math.min(Math.max(parseInt(url.searchParams.get("wait") ?? "0", 10) || 0, 0), 55);
|
|
506
|
+
const nodeId = url.searchParams.get("nodeId") || undefined;
|
|
507
|
+
|
|
508
|
+
const events = this.satelliteEvents.filter(e => e.ts > since);
|
|
509
|
+
const hasPendingTools = nodeId ? (this.satelliteToolQueue.get(nodeId)?.length ?? 0) > 0 : false;
|
|
510
|
+
|
|
511
|
+
// If there are already events/tools or no wait requested, respond immediately
|
|
512
|
+
if (events.length > 0 || hasPendingTools || wait === 0) {
|
|
513
|
+
this.sendSatellitePollResponse(res, since, nodeId);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Long poll: hold the connection until events arrive or timeout
|
|
518
|
+
const timer = setTimeout(() => {
|
|
519
|
+
this.removeSatelliteWaiter(res);
|
|
520
|
+
this.sendSatellitePollResponse(res, since, nodeId);
|
|
521
|
+
}, wait * 1000);
|
|
522
|
+
|
|
523
|
+
this.satelliteWaiters.push({ res, since, nodeId, timer });
|
|
524
|
+
|
|
525
|
+
// If client disconnects, clean up
|
|
526
|
+
req.on("close", () => {
|
|
527
|
+
this.removeSatelliteWaiter(res);
|
|
528
|
+
clearTimeout(timer);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Flush all waiting long-poll clients (called when new events are pushed). */
|
|
533
|
+
private flushSatelliteWaiters(targetNodeId?: string) {
|
|
534
|
+
if (targetNodeId) {
|
|
535
|
+
// Only flush waiters for this specific satellite node
|
|
536
|
+
const toFlush: typeof this.satelliteWaiters = [];
|
|
537
|
+
this.satelliteWaiters = this.satelliteWaiters.filter(w => {
|
|
538
|
+
if (w.nodeId === targetNodeId) {
|
|
539
|
+
toFlush.push(w);
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
return true;
|
|
543
|
+
});
|
|
544
|
+
for (const w of toFlush) {
|
|
545
|
+
clearTimeout(w.timer);
|
|
546
|
+
this.sendSatellitePollResponse(w.res, w.since, w.nodeId);
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
const waiters = this.satelliteWaiters.splice(0);
|
|
550
|
+
for (const w of waiters) {
|
|
551
|
+
clearTimeout(w.timer);
|
|
552
|
+
this.sendSatellitePollResponse(w.res, w.since, w.nodeId);
|
|
278
553
|
}
|
|
279
|
-
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private removeSatelliteWaiter(res: ServerResponse) {
|
|
558
|
+
const idx = this.satelliteWaiters.findIndex(w => w.res === res);
|
|
559
|
+
if (idx >= 0) this.satelliteWaiters.splice(idx, 1);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private sendSatellitePollResponse(res: ServerResponse, since: number, satelliteNodeId?: string) {
|
|
563
|
+
this.syncPeerState();
|
|
564
|
+
const peers = this.peerManager.router.getAllPeers();
|
|
565
|
+
const events = this.satelliteEvents.filter(e => e.ts > since);
|
|
566
|
+
|
|
567
|
+
// Include pending tool requests for this satellite node
|
|
568
|
+
let pendingTools: Array<{ id: string; tool: string; params: Record<string, unknown> }> | undefined;
|
|
569
|
+
if (satelliteNodeId) {
|
|
570
|
+
const queue = this.satelliteToolQueue.get(satelliteNodeId);
|
|
571
|
+
if (queue && queue.length > 0) {
|
|
572
|
+
pendingTools = queue.map(t => ({ id: t.id, tool: t.tool, params: t.params }));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Merge satellite nodes into peers list
|
|
577
|
+
const allPeers: Record<string, unknown>[] = peers.map(p => ({
|
|
578
|
+
nodeId: p.nodeId,
|
|
579
|
+
online: this.peerManager.canReach(p.nodeId),
|
|
580
|
+
agents: p.agents.map(a => a.id),
|
|
581
|
+
models: p.models.map(m => m.id),
|
|
582
|
+
tags: p.tags,
|
|
583
|
+
latencyMs: p.latencyMs,
|
|
584
|
+
connection: p.connection ? "direct" : "relay",
|
|
585
|
+
}));
|
|
586
|
+
|
|
587
|
+
for (const sat of this.getSatelliteContexts()) {
|
|
588
|
+
allPeers.push({
|
|
589
|
+
nodeId: sat.nodeId,
|
|
590
|
+
online: true,
|
|
591
|
+
agents: [],
|
|
592
|
+
models: [],
|
|
593
|
+
tags: [],
|
|
594
|
+
connection: "satellite",
|
|
595
|
+
tools: sat.tools ?? [],
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const status = {
|
|
600
|
+
nodeId: this.config.nodeId,
|
|
601
|
+
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
|
602
|
+
peers: allPeers,
|
|
603
|
+
events,
|
|
604
|
+
pendingTools,
|
|
605
|
+
ts: Date.now(),
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
if (res.writableEnded) return;
|
|
610
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
611
|
+
res.end(JSON.stringify(status));
|
|
612
|
+
} catch {
|
|
613
|
+
// Client already disconnected
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** POST /api/satellite/context — receive network context from satellite */
|
|
618
|
+
private async handleSatelliteContext(req: IncomingMessage, res: ServerResponse) {
|
|
619
|
+
try {
|
|
620
|
+
const body = await readBody(req);
|
|
621
|
+
const ctx = JSON.parse(body);
|
|
622
|
+
|
|
623
|
+
const satCtx: SatelliteContext = {
|
|
624
|
+
nodeId: ctx.nodeId || "unknown",
|
|
625
|
+
ssid: ctx.ssid || undefined,
|
|
626
|
+
ip: ctx.ip || undefined,
|
|
627
|
+
router: ctx.router || undefined,
|
|
628
|
+
cellular: !ctx.ssid,
|
|
629
|
+
country: ctx.country || undefined,
|
|
630
|
+
tools: Array.isArray(ctx.tools) ? ctx.tools : undefined,
|
|
631
|
+
ts: Date.now(),
|
|
632
|
+
// Extended context
|
|
633
|
+
battery: typeof ctx.battery === "number" ? ctx.battery : undefined,
|
|
634
|
+
charging: typeof ctx.charging === "boolean" ? ctx.charging : undefined,
|
|
635
|
+
platform: ctx.platform || undefined,
|
|
636
|
+
location: ctx.location || undefined,
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
this.satelliteContexts.set(satCtx.nodeId, satCtx);
|
|
640
|
+
|
|
641
|
+
// Propagate to PeerManager so it gets gossiped to all mesh nodes
|
|
642
|
+
this.peerManager.satelliteContexts = this.getSatelliteContexts();
|
|
643
|
+
|
|
644
|
+
this.pushSatelliteEvent({
|
|
645
|
+
ts: Date.now(),
|
|
646
|
+
type: "context_update",
|
|
647
|
+
data: { ...satCtx },
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
651
|
+
res.end(JSON.stringify({ ok: true }));
|
|
652
|
+
} catch {
|
|
653
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
654
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── Satellite tool proxy ────────────────────────────────────────
|
|
659
|
+
|
|
660
|
+
/** Check if a nodeId is a known satellite node. */
|
|
661
|
+
isSatelliteNode(nodeId: string): boolean {
|
|
662
|
+
const ctx = this.satelliteContexts.get(nodeId);
|
|
663
|
+
if (!ctx) return false;
|
|
664
|
+
// Stale check (10 min)
|
|
665
|
+
return Date.now() - ctx.ts < 600_000;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Queue a tool request for a satellite node. Returns a promise that resolves when the satellite responds. */
|
|
669
|
+
queueToolForSatellite(
|
|
670
|
+
nodeId: string,
|
|
671
|
+
id: string,
|
|
672
|
+
tool: string,
|
|
673
|
+
params: Record<string, unknown>,
|
|
674
|
+
timeout?: number,
|
|
675
|
+
): Promise<Record<string, unknown>> {
|
|
676
|
+
// Check if satellite supports this tool
|
|
677
|
+
const ctx = this.satelliteContexts.get(nodeId);
|
|
678
|
+
if (ctx?.tools && !ctx.tools.includes(tool)) {
|
|
679
|
+
return Promise.reject(new Error(`Satellite "${nodeId}" does not support tool "${tool}"`));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return new Promise((resolve, reject) => {
|
|
683
|
+
const timer = setTimeout(() => {
|
|
684
|
+
this.removeSatelliteTool(nodeId, id);
|
|
685
|
+
reject(new Error(`Satellite tool "${tool}" timed out on "${nodeId}"`));
|
|
686
|
+
}, timeout ?? SATELLITE_TOOL_TIMEOUT);
|
|
687
|
+
|
|
688
|
+
const entry: PendingSatelliteTool = { id, tool, params, from: this.config.nodeId, ts: Date.now(), resolve, reject, timer };
|
|
689
|
+
const queue = this.satelliteToolQueue.get(nodeId) ?? [];
|
|
690
|
+
queue.push(entry);
|
|
691
|
+
this.satelliteToolQueue.set(nodeId, queue);
|
|
692
|
+
|
|
693
|
+
// Wake up any long-polling satellite client for this node
|
|
694
|
+
this.flushSatelliteWaiters(nodeId);
|
|
280
695
|
});
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private removeSatelliteTool(nodeId: string, id: string) {
|
|
699
|
+
const queue = this.satelliteToolQueue.get(nodeId);
|
|
700
|
+
if (!queue) return;
|
|
701
|
+
const idx = queue.findIndex(t => t.id === id);
|
|
702
|
+
if (idx >= 0) {
|
|
703
|
+
clearTimeout(queue[idx].timer);
|
|
704
|
+
queue.splice(idx, 1);
|
|
705
|
+
}
|
|
706
|
+
if (queue.length === 0) this.satelliteToolQueue.delete(nodeId);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** POST /api/satellite/tool/result — satellite posts tool execution result */
|
|
710
|
+
private async handleSatelliteToolResult(req: IncomingMessage, res: ServerResponse) {
|
|
711
|
+
try {
|
|
712
|
+
const body = await readBody(req);
|
|
713
|
+
const { id, success, result, error } = JSON.parse(body);
|
|
714
|
+
|
|
715
|
+
if (!id) {
|
|
716
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
717
|
+
res.end(JSON.stringify({ error: "id required" }));
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Find and resolve the pending tool request
|
|
722
|
+
let found = false;
|
|
723
|
+
for (const [nodeId, queue] of this.satelliteToolQueue) {
|
|
724
|
+
const idx = queue.findIndex(t => t.id === id);
|
|
725
|
+
if (idx >= 0) {
|
|
726
|
+
const pending = queue[idx];
|
|
727
|
+
clearTimeout(pending.timer);
|
|
728
|
+
queue.splice(idx, 1);
|
|
729
|
+
if (queue.length === 0) this.satelliteToolQueue.delete(nodeId);
|
|
730
|
+
|
|
731
|
+
if (success && result) {
|
|
732
|
+
pending.resolve(typeof result === "object" ? result : { result });
|
|
733
|
+
} else {
|
|
734
|
+
pending.reject(new Error(error ?? "Satellite tool execution failed"));
|
|
735
|
+
}
|
|
736
|
+
found = true;
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
742
|
+
res.end(JSON.stringify({ ok: found }));
|
|
743
|
+
} catch {
|
|
744
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
745
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ── Event ingestion (Shortcuts automations, etc.) ──────────────
|
|
750
|
+
|
|
751
|
+
/** POST /api/events/ingest — receive events from external sources (e.g. Apple Shortcuts) */
|
|
752
|
+
private async handleEventIngest(req: IncomingMessage, res: ServerResponse) {
|
|
753
|
+
try {
|
|
754
|
+
const body = await readBody(req);
|
|
755
|
+
const raw = JSON.parse(body);
|
|
756
|
+
|
|
757
|
+
// Support single event or batch
|
|
758
|
+
const items: Array<Record<string, unknown>> = Array.isArray(raw) ? raw : [raw];
|
|
759
|
+
const ingested: IngestedEvent[] = [];
|
|
760
|
+
|
|
761
|
+
for (const item of items) {
|
|
762
|
+
if (!item.type) continue;
|
|
763
|
+
const event: IngestedEvent = {
|
|
764
|
+
id: crypto.randomUUID(),
|
|
765
|
+
source: String(item.source || "unknown"),
|
|
766
|
+
type: String(item.type),
|
|
767
|
+
data: (typeof item.data === "object" && item.data !== null ? item.data : { value: item.data ?? item }) as Record<string, unknown>,
|
|
768
|
+
ts: typeof item.ts === "number" ? Math.min(item.ts, Date.now()) : Date.now(),
|
|
769
|
+
consumed: false,
|
|
770
|
+
};
|
|
771
|
+
this.ingestedEvents.push(event);
|
|
772
|
+
ingested.push(event);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Trim ring buffer
|
|
776
|
+
if (this.ingestedEvents.length > MAX_INGESTED_EVENTS) {
|
|
777
|
+
this.ingestedEvents.splice(0, this.ingestedEvents.length - MAX_INGESTED_EVENTS);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Also push as satellite event so polling clients get notified
|
|
781
|
+
for (const event of ingested) {
|
|
782
|
+
this.pushSatelliteEvent({
|
|
783
|
+
ts: event.ts,
|
|
784
|
+
type: "event_ingested" as SatelliteEvent["type"],
|
|
785
|
+
data: { id: event.id, source: event.source, type: event.type },
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
790
|
+
res.end(JSON.stringify({ ok: true, count: ingested.length, ids: ingested.map(e => e.id) }));
|
|
791
|
+
} catch {
|
|
792
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
793
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** GET /api/events?type=<type>&source=<source>&since=<ts>&unconsumed=true&limit=<n> */
|
|
798
|
+
private handleEventQuery(req: IncomingMessage, res: ServerResponse) {
|
|
799
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
800
|
+
const type = url.searchParams.get("type") || undefined;
|
|
801
|
+
const source = url.searchParams.get("source") || undefined;
|
|
802
|
+
const since = parseInt(url.searchParams.get("since") ?? "0", 10) || 0;
|
|
803
|
+
const unconsumed = url.searchParams.get("unconsumed") === "true";
|
|
804
|
+
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50", 10) || 50, 200);
|
|
805
|
+
|
|
806
|
+
this.evictStaleEvents();
|
|
807
|
+
|
|
808
|
+
let events = this.ingestedEvents;
|
|
809
|
+
if (since > 0) events = events.filter(e => e.ts > since);
|
|
810
|
+
if (type) events = events.filter(e => e.type === type);
|
|
811
|
+
if (source) events = events.filter(e => e.source === source);
|
|
812
|
+
if (unconsumed) events = events.filter(e => !e.consumed);
|
|
813
|
+
|
|
814
|
+
// Return newest first, limited
|
|
815
|
+
const result = events.slice(-limit).reverse();
|
|
816
|
+
|
|
817
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
818
|
+
res.end(JSON.stringify({ events: result, total: events.length }));
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** POST /api/events/consume — mark events as consumed by id(s) */
|
|
822
|
+
private async handleEventConsume(req: IncomingMessage, res: ServerResponse) {
|
|
823
|
+
try {
|
|
824
|
+
const body = await readBody(req);
|
|
825
|
+
const { ids } = JSON.parse(body);
|
|
826
|
+
if (!Array.isArray(ids)) {
|
|
827
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
828
|
+
res.end(JSON.stringify({ error: "ids array required" }));
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const idSet = new Set<string>(ids);
|
|
833
|
+
let consumed = 0;
|
|
834
|
+
for (const event of this.ingestedEvents) {
|
|
835
|
+
if (idSet.has(event.id) && !event.consumed) {
|
|
836
|
+
event.consumed = true;
|
|
837
|
+
consumed++;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
842
|
+
res.end(JSON.stringify({ ok: true, consumed }));
|
|
843
|
+
} catch {
|
|
844
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
845
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Evict events older than TTL. Centralizes stale-event cleanup. */
|
|
850
|
+
private evictStaleEvents() {
|
|
851
|
+
const cutoff = Date.now() - INGESTED_EVENT_TTL;
|
|
852
|
+
const firstValid = this.ingestedEvents.findIndex(e => e.ts > cutoff);
|
|
853
|
+
if (firstValid > 0) {
|
|
854
|
+
this.ingestedEvents.splice(0, firstValid);
|
|
855
|
+
} else if (firstValid === -1 && this.ingestedEvents.length > 0) {
|
|
856
|
+
this.ingestedEvents.length = 0;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** Get unconsumed events (for agent context injection). */
|
|
861
|
+
getUnconsumedEvents(limit = 10): IngestedEvent[] {
|
|
862
|
+
this.evictStaleEvents();
|
|
863
|
+
return this.ingestedEvents.filter(e => !e.consumed).slice(-limit);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Get all ingested events (for tool queries). */
|
|
867
|
+
queryEvents(opts?: { type?: string; source?: string; since?: number; unconsumed?: boolean; limit?: number }): IngestedEvent[] {
|
|
868
|
+
this.evictStaleEvents();
|
|
869
|
+
|
|
870
|
+
let events = this.ingestedEvents;
|
|
871
|
+
if (opts?.since) events = events.filter(e => e.ts > opts.since!);
|
|
872
|
+
if (opts?.type) events = events.filter(e => e.type === opts.type);
|
|
873
|
+
if (opts?.source) events = events.filter(e => e.source === opts.source);
|
|
874
|
+
if (opts?.unconsumed) events = events.filter(e => !e.consumed);
|
|
875
|
+
return events.slice(-(opts?.limit ?? 50)).reverse();
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Mark events as consumed by id(s). */
|
|
879
|
+
consumeEvents(ids: string[]): number {
|
|
880
|
+
const idSet = new Set(ids);
|
|
881
|
+
let consumed = 0;
|
|
882
|
+
for (const event of this.ingestedEvents) {
|
|
883
|
+
if (idSet.has(event.id) && !event.consumed) {
|
|
884
|
+
event.consumed = true;
|
|
885
|
+
consumed++;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return consumed;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** Get all satellite contexts (for use by other components). */
|
|
892
|
+
getSatelliteContexts(): SatelliteContext[] {
|
|
893
|
+
// Filter out stale contexts (> 10 minutes)
|
|
894
|
+
const now = Date.now();
|
|
895
|
+
const result: SatelliteContext[] = [];
|
|
896
|
+
for (const [nodeId, ctx] of this.satelliteContexts) {
|
|
897
|
+
if (now - ctx.ts < 600_000) {
|
|
898
|
+
result.push(ctx);
|
|
899
|
+
} else {
|
|
900
|
+
this.satelliteContexts.delete(nodeId);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return result;
|
|
904
|
+
}
|
|
284
905
|
}
|
|
906
|
+
|