clawmatrix 0.1.14 → 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 +55 -8
- package/package.json +4 -2
- package/src/auth.ts +42 -12
- package/src/cluster-service.ts +35 -7
- package/src/compat.ts +3 -0
- package/src/config.ts +57 -6
- package/src/connection.ts +34 -8
- package/src/device-info.ts +48 -0
- package/src/handoff.ts +330 -21
- package/src/http-utils.ts +35 -0
- package/src/index.ts +47 -19
- package/src/model-proxy.ts +546 -242
- package/src/peer-manager.ts +65 -6
- package/src/router.ts +89 -47
- 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 +117 -0
- package/src/web-ui.ts +694 -342
- package/src/web.ts +726 -50
package/src/web.ts
CHANGED
|
@@ -1,22 +1,120 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import type { PeerManager } from "./peer-manager.ts";
|
|
3
|
+
import type { HandoffManager } from "./handoff.ts";
|
|
3
4
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
5
|
+
import type { SatelliteContext, IngestedEvent } from "./types.ts";
|
|
4
6
|
import { timingSafeEqual } from "./auth.ts";
|
|
5
7
|
import { renderDashboard } from "./web-ui.ts";
|
|
8
|
+
import { readBody } from "./http-utils.ts";
|
|
6
9
|
|
|
7
10
|
const COOKIE_NAME = "clawmatrix_token";
|
|
8
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
|
+
}
|
|
9
35
|
|
|
10
36
|
export class WebHandler {
|
|
11
37
|
private config: ClawMatrixConfig;
|
|
12
38
|
private peerManager: PeerManager;
|
|
39
|
+
private handoffManager: HandoffManager;
|
|
13
40
|
private token: string;
|
|
14
41
|
private startTime = Date.now();
|
|
15
|
-
|
|
16
|
-
|
|
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;
|
|
51
|
+
|
|
52
|
+
constructor(config: ClawMatrixConfig, peerManager: PeerManager, handoffManager: HandoffManager) {
|
|
17
53
|
this.config = config;
|
|
18
54
|
this.peerManager = peerManager;
|
|
55
|
+
this.handoffManager = handoffManager;
|
|
19
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;
|
|
20
118
|
}
|
|
21
119
|
|
|
22
120
|
/** Handle an HTTP request. Returns true if handled, false to fall through. */
|
|
@@ -37,46 +135,84 @@ export class WebHandler {
|
|
|
37
135
|
return true;
|
|
38
136
|
}
|
|
39
137
|
|
|
40
|
-
// All /api/* routes require auth
|
|
138
|
+
// All /api/* routes require auth (async)
|
|
41
139
|
if (path.startsWith("/api/")) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return true;
|
|
46
|
-
}
|
|
140
|
+
this.handleAuthenticatedRoute(req, res, path);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
47
143
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
52
146
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
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
|
+
}
|
|
57
153
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
154
|
+
if (path === "/api/status" && req.method === "GET") {
|
|
155
|
+
this.handleStatus(res);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
62
158
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
159
|
+
if (path === "/api/chat" && req.method === "POST") {
|
|
160
|
+
this.handleChat(req, res);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
67
163
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return
|
|
164
|
+
if (path === "/api/handoff" && req.method === "POST") {
|
|
165
|
+
this.handleHandoff(req, res);
|
|
166
|
+
return;
|
|
71
167
|
}
|
|
72
168
|
|
|
73
|
-
|
|
169
|
+
if (path === "/api/events/ingest" && req.method === "POST") {
|
|
170
|
+
this.handleEventIngest(req, res);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
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" }));
|
|
74
210
|
}
|
|
75
211
|
|
|
76
|
-
private checkAuth(req: IncomingMessage): boolean {
|
|
212
|
+
private async checkAuth(req: IncomingMessage): Promise<boolean> {
|
|
77
213
|
// Check Authorization header
|
|
78
214
|
const authHeader = req.headers.authorization;
|
|
79
|
-
if (authHeader?.startsWith("Bearer ") && timingSafeEqual(authHeader.slice(7), this.token)) {
|
|
215
|
+
if (authHeader?.startsWith("Bearer ") && await timingSafeEqual(authHeader.slice(7), this.token)) {
|
|
80
216
|
return true;
|
|
81
217
|
}
|
|
82
218
|
|
|
@@ -84,7 +220,8 @@ export class WebHandler {
|
|
|
84
220
|
const cookies = req.headers.cookie ?? "";
|
|
85
221
|
const match = cookies.split(";").find((c) => c.trim().startsWith(`${COOKIE_NAME}=`));
|
|
86
222
|
if (match) {
|
|
87
|
-
const
|
|
223
|
+
const raw = match.trim().slice(COOKIE_NAME.length + 1);
|
|
224
|
+
const value = decodeURIComponent(raw);
|
|
88
225
|
return timingSafeEqual(value, this.token);
|
|
89
226
|
}
|
|
90
227
|
|
|
@@ -92,19 +229,40 @@ export class WebHandler {
|
|
|
92
229
|
}
|
|
93
230
|
|
|
94
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
|
+
|
|
95
243
|
try {
|
|
96
244
|
const body = await readBody(req);
|
|
97
245
|
const { token } = JSON.parse(body);
|
|
98
246
|
|
|
99
|
-
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
|
+
|
|
100
255
|
res.writeHead(403, { "Content-Type": "application/json" });
|
|
101
256
|
res.end(JSON.stringify({ error: "Invalid token" }));
|
|
102
257
|
return;
|
|
103
258
|
}
|
|
104
259
|
|
|
260
|
+
// Success — clear rate limit for this IP
|
|
261
|
+
this.loginAttempts.delete(ip);
|
|
262
|
+
|
|
105
263
|
res.writeHead(200, {
|
|
106
264
|
"Content-Type": "application/json",
|
|
107
|
-
"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`,
|
|
108
266
|
});
|
|
109
267
|
res.end(JSON.stringify({ ok: true }));
|
|
110
268
|
} catch {
|
|
@@ -122,9 +280,25 @@ export class WebHandler {
|
|
|
122
280
|
tags: this.config.tags,
|
|
123
281
|
connection: "self" as const,
|
|
124
282
|
online: true,
|
|
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
|
+
],
|
|
125
293
|
};
|
|
126
294
|
|
|
127
|
-
|
|
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) => ({
|
|
128
302
|
nodeId: p.nodeId,
|
|
129
303
|
agents: p.agents,
|
|
130
304
|
models: p.models,
|
|
@@ -134,8 +308,40 @@ export class WebHandler {
|
|
|
134
308
|
online: this.peerManager.canReach(p.nodeId),
|
|
135
309
|
lastSeen: p.lastSeen,
|
|
136
310
|
latencyMs: p.latencyMs,
|
|
311
|
+
directPeers: p.directPeers.length > 0 ? p.directPeers : undefined,
|
|
312
|
+
deviceInfo: p.deviceInfo,
|
|
313
|
+
toolProxy: p.toolProxy,
|
|
314
|
+
clusterTools: CLUSTER_TOOLS,
|
|
137
315
|
}));
|
|
138
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
|
+
|
|
139
345
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
140
346
|
res.end(JSON.stringify({
|
|
141
347
|
nodeId: this.config.nodeId,
|
|
@@ -207,24 +413,494 @@ export class WebHandler {
|
|
|
207
413
|
}
|
|
208
414
|
}
|
|
209
415
|
}
|
|
210
|
-
|
|
416
|
+
private async handleHandoff(req: IncomingMessage, res: ServerResponse) {
|
|
417
|
+
try {
|
|
418
|
+
const body = await readBody(req);
|
|
419
|
+
const { nodeId, agent, task } = JSON.parse(body);
|
|
211
420
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
return new Promise((resolve, reject) => {
|
|
216
|
-
const chunks: Buffer[] = [];
|
|
217
|
-
let size = 0;
|
|
218
|
-
req.on("data", (chunk: Buffer) => {
|
|
219
|
-
size += chunk.length;
|
|
220
|
-
if (size > MAX_BODY_SIZE) {
|
|
221
|
-
req.destroy();
|
|
222
|
-
reject(new Error("Request body too large"));
|
|
421
|
+
if (!nodeId || !agent || !task) {
|
|
422
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
423
|
+
res.end(JSON.stringify({ error: "nodeId, agent and task required" }));
|
|
223
424
|
return;
|
|
224
425
|
}
|
|
225
|
-
|
|
426
|
+
|
|
427
|
+
// SSE headers
|
|
428
|
+
res.writeHead(200, {
|
|
429
|
+
"Content-Type": "text/event-stream",
|
|
430
|
+
"Cache-Control": "no-cache",
|
|
431
|
+
"Connection": "keep-alive",
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
let closed = false;
|
|
435
|
+
req.on("close", () => { closed = true; });
|
|
436
|
+
|
|
437
|
+
const result = await this.handoffManager.handoff(agent, task, undefined, {
|
|
438
|
+
nodeId,
|
|
439
|
+
onStream: (delta) => {
|
|
440
|
+
if (closed) return;
|
|
441
|
+
res.write(`data: ${JSON.stringify({ type: "delta", content: delta })}\n\n`);
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
if (!closed) {
|
|
446
|
+
res.write(`data: ${JSON.stringify({ type: "done", ...result })}\n\n`);
|
|
447
|
+
}
|
|
448
|
+
res.end();
|
|
449
|
+
} catch (err) {
|
|
450
|
+
if (!res.headersSent) {
|
|
451
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
452
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Internal error" }));
|
|
453
|
+
} else {
|
|
454
|
+
res.write(`data: ${JSON.stringify({ type: "error", error: err instanceof Error ? err.message : "Internal error" })}\n\n`);
|
|
455
|
+
res.end();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// ── Satellite integration ────────────────────────────────────────
|
|
460
|
+
|
|
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);
|
|
226
529
|
});
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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);
|
|
553
|
+
}
|
|
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);
|
|
695
|
+
});
|
|
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
|
+
}
|
|
230
905
|
}
|
|
906
|
+
|