@steadwing/openalerts 0.2.6 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/collections/collection-manager.d.ts +50 -0
- package/dist/collections/collection-manager.js +583 -0
- package/dist/collections/event-parser.d.ts +27 -0
- package/dist/collections/event-parser.js +321 -0
- package/dist/collections/index.d.ts +6 -0
- package/dist/collections/index.js +6 -0
- package/dist/collections/persistence.d.ts +25 -0
- package/dist/collections/persistence.js +213 -0
- package/dist/collections/types.d.ts +177 -0
- package/dist/collections/types.js +15 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +23 -0
- package/dist/core/llm-enrichment.d.ts +21 -0
- package/dist/core/llm-enrichment.js +180 -0
- package/dist/core/platform.d.ts +17 -0
- package/dist/core/platform.js +93 -0
- package/dist/db/queries.d.ts.map +1 -1
- package/dist/db/queries.js +11 -5
- package/dist/db/queries.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +600 -0
- package/dist/plugin/adapter.d.ts +150 -0
- package/dist/plugin/adapter.js +530 -0
- package/dist/plugin/commands.d.ts +18 -0
- package/dist/plugin/commands.js +103 -0
- package/dist/plugin/dashboard-html.d.ts +7 -0
- package/dist/plugin/dashboard-html.js +968 -0
- package/dist/plugin/dashboard-routes.d.ts +12 -0
- package/dist/plugin/dashboard-routes.js +444 -0
- package/dist/plugin/gateway-client.d.ts +39 -0
- package/dist/plugin/gateway-client.js +200 -0
- package/dist/plugin/log-bridge.d.ts +22 -0
- package/dist/plugin/log-bridge.js +363 -0
- package/dist/watchers/gateway-adapter.d.ts.map +1 -1
- package/dist/watchers/gateway-adapter.js +2 -1
- package/dist/watchers/gateway-adapter.js.map +1 -1
- package/package.json +2 -10
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { OpenAlertsEngine } from "../core/index.js";
|
|
3
|
+
type HttpHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean> | boolean;
|
|
4
|
+
type AgentMonitorEvent = {
|
|
5
|
+
type: string;
|
|
6
|
+
data: unknown;
|
|
7
|
+
};
|
|
8
|
+
export declare function emitAgentMonitorEvent(event: AgentMonitorEvent): void;
|
|
9
|
+
export declare function recordGatewayEvent(eventName: string, payload: unknown): void;
|
|
10
|
+
export declare function closeDashboardConnections(): void;
|
|
11
|
+
export declare function createDashboardHandler(getEngine: () => OpenAlertsEngine | null, getCollections: () => unknown): HttpHandler;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { DEFAULTS } from "../core/index.js";
|
|
4
|
+
import { getDashboardHtml } from "./dashboard-html.js";
|
|
5
|
+
const sseConnections = new Set();
|
|
6
|
+
const monitorListeners = new Set();
|
|
7
|
+
const monitorConnections = new Set();
|
|
8
|
+
// Ring buffer — last 150 agent monitor events for history replay
|
|
9
|
+
const agentMonitorBuffer = [];
|
|
10
|
+
const AGENT_MONITOR_BUF_SIZE = 150;
|
|
11
|
+
export function emitAgentMonitorEvent(event) {
|
|
12
|
+
// Store in ring buffer
|
|
13
|
+
agentMonitorBuffer.push(event);
|
|
14
|
+
if (agentMonitorBuffer.length > AGENT_MONITOR_BUF_SIZE)
|
|
15
|
+
agentMonitorBuffer.shift();
|
|
16
|
+
for (const fn of monitorListeners) {
|
|
17
|
+
try {
|
|
18
|
+
fn(event);
|
|
19
|
+
}
|
|
20
|
+
catch { /* ignore */ }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// ── Gateway event recorder ──────────────────────────────────────────────────
|
|
24
|
+
const recentGatewayEvents = [];
|
|
25
|
+
export function recordGatewayEvent(eventName, payload) {
|
|
26
|
+
recentGatewayEvents.unshift({ eventName, payload, ts: Date.now() });
|
|
27
|
+
while (recentGatewayEvents.length > 10) {
|
|
28
|
+
recentGatewayEvents.pop();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function closeDashboardConnections() {
|
|
32
|
+
for (const conn of monitorConnections) {
|
|
33
|
+
clearInterval(conn.heartbeat);
|
|
34
|
+
try {
|
|
35
|
+
conn.res.end();
|
|
36
|
+
}
|
|
37
|
+
catch { /* ignore */ }
|
|
38
|
+
}
|
|
39
|
+
monitorConnections.clear();
|
|
40
|
+
monitorListeners.clear();
|
|
41
|
+
for (const conn of sseConnections) {
|
|
42
|
+
clearInterval(conn.heartbeat);
|
|
43
|
+
clearInterval(conn.logTailer);
|
|
44
|
+
conn.unsub();
|
|
45
|
+
try {
|
|
46
|
+
conn.res.end();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* already closed */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
sseConnections.clear();
|
|
53
|
+
}
|
|
54
|
+
// ─── Rule status helper ──────────────────────────────────────────────────────
|
|
55
|
+
const RULE_IDS = [
|
|
56
|
+
"infra-errors",
|
|
57
|
+
"llm-errors",
|
|
58
|
+
"session-stuck",
|
|
59
|
+
"heartbeat-fail",
|
|
60
|
+
"queue-depth",
|
|
61
|
+
"high-error-rate",
|
|
62
|
+
"cost-hourly-spike",
|
|
63
|
+
"cost-daily-budget",
|
|
64
|
+
"tool-errors",
|
|
65
|
+
"gateway-down",
|
|
66
|
+
];
|
|
67
|
+
function getRuleStatuses(engine) {
|
|
68
|
+
const state = engine.state;
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const cooldownWindow = 15 * 60 * 1000;
|
|
71
|
+
return RULE_IDS.map((id) => {
|
|
72
|
+
// For gateway-down, reflect current condition: if heartbeats have resumed,
|
|
73
|
+
// show OK even if the rule fired recently.
|
|
74
|
+
if (id === "gateway-down") {
|
|
75
|
+
const silenceMs = state.lastHeartbeatTs > 0
|
|
76
|
+
? now - state.lastHeartbeatTs
|
|
77
|
+
: 0;
|
|
78
|
+
const isCurrentlyDown = state.lastHeartbeatTs > 0 &&
|
|
79
|
+
silenceMs >= DEFAULTS.gatewayDownThresholdMs;
|
|
80
|
+
return { id, status: isCurrentlyDown ? "fired" : "ok" };
|
|
81
|
+
}
|
|
82
|
+
// Cooldown keys are fingerprints like "llm-errors:unknown", not bare rule IDs.
|
|
83
|
+
// Check if ANY cooldown key starting with this rule ID has fired recently.
|
|
84
|
+
let fired = false;
|
|
85
|
+
for (const [key, ts] of state.cooldowns) {
|
|
86
|
+
if (key === id || key.startsWith(id + ":")) {
|
|
87
|
+
if (ts > now - cooldownWindow) {
|
|
88
|
+
fired = true;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { id, status: fired ? "fired" : "ok" };
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// ─── OpenClaw log file ──────────────────────────────────────────────────────
|
|
97
|
+
function getOpenClawLogDir() {
|
|
98
|
+
// Use platform-appropriate default: C:\tmp\openclaw on Windows, /tmp/openclaw elsewhere
|
|
99
|
+
if (process.platform === "win32") {
|
|
100
|
+
return join("C:", "tmp", "openclaw");
|
|
101
|
+
}
|
|
102
|
+
return join("/tmp", "openclaw");
|
|
103
|
+
}
|
|
104
|
+
function getOpenClawLogPath() {
|
|
105
|
+
const d = new Date();
|
|
106
|
+
const y = d.getFullYear();
|
|
107
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
108
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
109
|
+
return join(getOpenClawLogDir(), `openclaw-${y}-${m}-${day}.log`);
|
|
110
|
+
}
|
|
111
|
+
/** Parse key=value pairs from a log message string. */
|
|
112
|
+
function parseLogKVs(msg) {
|
|
113
|
+
const kvs = {};
|
|
114
|
+
// Match key=value or key="quoted value"
|
|
115
|
+
const re = /(\w+)=(?:"([^"]*)"|(\S+))/g;
|
|
116
|
+
let m;
|
|
117
|
+
while ((m = re.exec(msg)) !== null) {
|
|
118
|
+
kvs[m[1]] = m[2] ?? m[3];
|
|
119
|
+
}
|
|
120
|
+
return kvs;
|
|
121
|
+
}
|
|
122
|
+
function parseLogLine(line) {
|
|
123
|
+
try {
|
|
124
|
+
const obj = JSON.parse(line);
|
|
125
|
+
const ts = obj.time || obj._meta?.date || "";
|
|
126
|
+
const tsMs = ts ? new Date(ts).getTime() : 0;
|
|
127
|
+
let subsystem = "";
|
|
128
|
+
try {
|
|
129
|
+
const nameObj = JSON.parse(obj["0"] || "{}");
|
|
130
|
+
subsystem = nameObj.subsystem || "";
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
subsystem = obj["0"] || "";
|
|
134
|
+
}
|
|
135
|
+
const message = obj["1"] || "";
|
|
136
|
+
const extra = parseLogKVs(message);
|
|
137
|
+
return {
|
|
138
|
+
ts,
|
|
139
|
+
tsMs,
|
|
140
|
+
level: obj._meta?.logLevelName || "DEBUG",
|
|
141
|
+
subsystem,
|
|
142
|
+
message,
|
|
143
|
+
sessionId: extra.sessionId,
|
|
144
|
+
runId: extra.runId,
|
|
145
|
+
durationMs: extra.durationMs ? parseInt(extra.durationMs, 10) : undefined,
|
|
146
|
+
filePath: obj._meta?.path?.fileNameWithLine,
|
|
147
|
+
method: obj._meta?.path?.method,
|
|
148
|
+
hostname: obj._meta?.hostname,
|
|
149
|
+
extra,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function readOpenClawLogs(maxEntries, afterTs) {
|
|
157
|
+
const logPath = getOpenClawLogPath();
|
|
158
|
+
if (!existsSync(logPath))
|
|
159
|
+
return { entries: [], truncated: false, subsystems: [] };
|
|
160
|
+
try {
|
|
161
|
+
const content = readFileSync(logPath, "utf-8");
|
|
162
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
163
|
+
const entries = [];
|
|
164
|
+
const subsystemSet = new Set();
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
const parsed = parseLogLine(line);
|
|
167
|
+
if (!parsed)
|
|
168
|
+
continue;
|
|
169
|
+
if (afterTs && parsed.ts <= afterTs)
|
|
170
|
+
continue;
|
|
171
|
+
entries.push(parsed);
|
|
172
|
+
subsystemSet.add(parsed.subsystem);
|
|
173
|
+
}
|
|
174
|
+
const truncated = maxEntries > 0 && entries.length > maxEntries;
|
|
175
|
+
const sliced = maxEntries > 0 ? entries.slice(-maxEntries) : entries;
|
|
176
|
+
const subsystems = Array.from(subsystemSet).sort();
|
|
177
|
+
return { entries: sliced, truncated, subsystems };
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return { entries: [], truncated: false, subsystems: [] };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Create a log tailer for an SSE connection.
|
|
185
|
+
* Polls the log file every 2 seconds, sends new lines as `event: oclog`.
|
|
186
|
+
*/
|
|
187
|
+
function createLogTailer(res) {
|
|
188
|
+
let lastLineCount = 0;
|
|
189
|
+
let lastPath = "";
|
|
190
|
+
// Initialize: count existing lines so we only send new ones
|
|
191
|
+
try {
|
|
192
|
+
const path = getOpenClawLogPath();
|
|
193
|
+
if (existsSync(path)) {
|
|
194
|
+
const content = readFileSync(path, "utf-8");
|
|
195
|
+
lastLineCount = content.trim().split("\n").length;
|
|
196
|
+
lastPath = path;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
/* ignore */
|
|
201
|
+
}
|
|
202
|
+
return setInterval(() => {
|
|
203
|
+
try {
|
|
204
|
+
const path = getOpenClawLogPath();
|
|
205
|
+
if (!existsSync(path))
|
|
206
|
+
return;
|
|
207
|
+
// If date changed (new log file), reset counter
|
|
208
|
+
if (path !== lastPath) {
|
|
209
|
+
lastLineCount = 0;
|
|
210
|
+
lastPath = path;
|
|
211
|
+
}
|
|
212
|
+
const content = readFileSync(path, "utf-8");
|
|
213
|
+
const lines = content.trim().split("\n");
|
|
214
|
+
if (lines.length <= lastLineCount)
|
|
215
|
+
return;
|
|
216
|
+
// Send only new lines
|
|
217
|
+
const newLines = lines.slice(lastLineCount);
|
|
218
|
+
lastLineCount = lines.length;
|
|
219
|
+
for (const line of newLines) {
|
|
220
|
+
const parsed = parseLogLine(line);
|
|
221
|
+
if (!parsed)
|
|
222
|
+
continue;
|
|
223
|
+
res.write(`event: oclog\ndata: ${JSON.stringify(parsed)}\n\n`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
/* ignore read errors */
|
|
228
|
+
}
|
|
229
|
+
}, 2000);
|
|
230
|
+
}
|
|
231
|
+
// ─── HTTP Handler ────────────────────────────────────────────────────────────
|
|
232
|
+
export function createDashboardHandler(getEngine, getCollections) {
|
|
233
|
+
return async (req, res) => {
|
|
234
|
+
const url = req.url ?? "";
|
|
235
|
+
if (!url.startsWith("/openalerts"))
|
|
236
|
+
return false;
|
|
237
|
+
const engine = getEngine();
|
|
238
|
+
// ── GET /openalerts → Dashboard HTML ──────────────────────
|
|
239
|
+
if ((url === "/openalerts" || url === "/openalerts/") &&
|
|
240
|
+
req.method === "GET") {
|
|
241
|
+
if (!engine) {
|
|
242
|
+
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
243
|
+
res.end("OpenAlerts engine not running.");
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
res.writeHead(200, {
|
|
247
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
248
|
+
"Cache-Control": "no-cache",
|
|
249
|
+
});
|
|
250
|
+
res.end(getDashboardHtml());
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
// ── GET /openalerts/events → SSE stream (engine events + log tailing) ──
|
|
254
|
+
if (url === "/openalerts/events" && req.method === "GET") {
|
|
255
|
+
if (!engine) {
|
|
256
|
+
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
257
|
+
res.end("Engine not running.");
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
res.writeHead(200, {
|
|
261
|
+
"Content-Type": "text/event-stream",
|
|
262
|
+
"Cache-Control": "no-cache",
|
|
263
|
+
Connection: "keep-alive",
|
|
264
|
+
"Access-Control-Allow-Origin": "*",
|
|
265
|
+
});
|
|
266
|
+
res.flushHeaders();
|
|
267
|
+
// Send initial connection event so the browser knows the stream is live
|
|
268
|
+
res.write(`:ok\n\n`);
|
|
269
|
+
// Send current state snapshot as initial event
|
|
270
|
+
const state = engine.state;
|
|
271
|
+
res.write(`event: state\ndata: ${JSON.stringify({
|
|
272
|
+
uptimeMs: Date.now() - state.startedAt,
|
|
273
|
+
stats: state.stats,
|
|
274
|
+
rules: getRuleStatuses(engine),
|
|
275
|
+
})}\n\n`);
|
|
276
|
+
// Send event history so dashboard survives refreshes
|
|
277
|
+
const history = engine.getRecentLiveEvents(200);
|
|
278
|
+
if (history.length > 0) {
|
|
279
|
+
res.write(`event: history\ndata: ${JSON.stringify(history)}\n\n`);
|
|
280
|
+
}
|
|
281
|
+
// Subscribe to engine events
|
|
282
|
+
const unsub = engine.bus.on((event) => {
|
|
283
|
+
try {
|
|
284
|
+
res.write(`event: openalerts\ndata: ${JSON.stringify(event)}\n\n`);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
/* closed */
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
// Start log file tailer — streams OpenClaw internal logs as `event: oclog`
|
|
291
|
+
const logTailer = createLogTailer(res);
|
|
292
|
+
// Heartbeat every 15s
|
|
293
|
+
const heartbeat = setInterval(() => {
|
|
294
|
+
try {
|
|
295
|
+
res.write(":heartbeat\n\n");
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
/* closed */
|
|
299
|
+
}
|
|
300
|
+
}, 15_000);
|
|
301
|
+
const conn = { res, unsub, heartbeat, logTailer };
|
|
302
|
+
sseConnections.add(conn);
|
|
303
|
+
req.on("close", () => {
|
|
304
|
+
clearInterval(heartbeat);
|
|
305
|
+
clearInterval(logTailer);
|
|
306
|
+
unsub();
|
|
307
|
+
sseConnections.delete(conn);
|
|
308
|
+
});
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
// ── GET /openalerts/state → JSON snapshot ─────────────────
|
|
312
|
+
if (url === "/openalerts/state" && req.method === "GET") {
|
|
313
|
+
if (!engine) {
|
|
314
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
315
|
+
res.end(JSON.stringify({ error: "Engine not running" }));
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
const state = engine.state;
|
|
319
|
+
const recentAlerts = engine
|
|
320
|
+
.getRecentEvents(50)
|
|
321
|
+
.filter((e) => e.type === "alert");
|
|
322
|
+
const body = JSON.stringify({
|
|
323
|
+
uptimeMs: Date.now() - state.startedAt,
|
|
324
|
+
startedAt: state.startedAt,
|
|
325
|
+
lastHeartbeatTs: state.lastHeartbeatTs,
|
|
326
|
+
hourlyAlerts: state.hourlyAlerts,
|
|
327
|
+
stuckSessions: state.stats.stuckSessions,
|
|
328
|
+
lastResetTs: state.stats.lastResetTs,
|
|
329
|
+
stats: state.stats,
|
|
330
|
+
busListeners: engine.bus.size,
|
|
331
|
+
platformConnected: engine.platformConnected,
|
|
332
|
+
recentAlerts: recentAlerts.slice(0, 20),
|
|
333
|
+
rules: getRuleStatuses(engine),
|
|
334
|
+
cooldowns: Object.fromEntries(state.cooldowns),
|
|
335
|
+
});
|
|
336
|
+
res.writeHead(200, {
|
|
337
|
+
"Content-Type": "application/json",
|
|
338
|
+
"Cache-Control": "no-cache",
|
|
339
|
+
"Access-Control-Allow-Origin": "*",
|
|
340
|
+
});
|
|
341
|
+
res.end(body);
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
// ── GET /openalerts/collections → Sessions, actions, execs from CollectionManager ────
|
|
345
|
+
if (url === "/openalerts/collections" && req.method === "GET") {
|
|
346
|
+
const collections = getCollections();
|
|
347
|
+
if (!collections) {
|
|
348
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
349
|
+
res.end(JSON.stringify({ error: "Collections not available" }));
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
// getActiveSessions filters out stale idle sessions (>2h inactive) from filesystem hydration
|
|
353
|
+
const sessions = collections.getActiveSessions();
|
|
354
|
+
const actions = collections.getActions();
|
|
355
|
+
const execs = collections.getExecs();
|
|
356
|
+
const stats = collections.getStats();
|
|
357
|
+
res.writeHead(200, {
|
|
358
|
+
"Content-Type": "application/json",
|
|
359
|
+
"Cache-Control": "no-cache",
|
|
360
|
+
"Access-Control-Allow-Origin": "*",
|
|
361
|
+
});
|
|
362
|
+
res.end(JSON.stringify({ sessions, actions, execs, stats }));
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
// ── GET /openalerts/logs → OpenClaw log entries (for Logs tab) ────
|
|
366
|
+
if (url.startsWith("/openalerts/logs") && req.method === "GET") {
|
|
367
|
+
const urlObj = new URL(url, "http://localhost");
|
|
368
|
+
const rawLimit = urlObj.searchParams.get("limit") || "200";
|
|
369
|
+
// limit=0 means "no limit" — return all log entries
|
|
370
|
+
const limit = rawLimit === "0" ? 0 : Math.min(parseInt(rawLimit, 10), 50000);
|
|
371
|
+
const afterTs = urlObj.searchParams.get("after") || undefined;
|
|
372
|
+
const result = readOpenClawLogs(limit, afterTs);
|
|
373
|
+
res.writeHead(200, {
|
|
374
|
+
"Content-Type": "application/json",
|
|
375
|
+
"Cache-Control": "no-cache",
|
|
376
|
+
"Access-Control-Allow-Origin": "*",
|
|
377
|
+
});
|
|
378
|
+
res.end(JSON.stringify({
|
|
379
|
+
entries: result.entries,
|
|
380
|
+
truncated: result.truncated,
|
|
381
|
+
subsystems: result.subsystems,
|
|
382
|
+
logFile: getOpenClawLogPath(),
|
|
383
|
+
}));
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
// ── GET /openalerts/agent-monitor/stream → Live agent activity SSE ────
|
|
387
|
+
// Receives events from plugin hooks (before_agent_start, agent_end, after_tool_call, message_received)
|
|
388
|
+
if (url === "/openalerts/agent-monitor/stream" && req.method === "GET") {
|
|
389
|
+
res.writeHead(200, {
|
|
390
|
+
"Content-Type": "text/event-stream",
|
|
391
|
+
"Cache-Control": "no-cache",
|
|
392
|
+
Connection: "keep-alive",
|
|
393
|
+
"Access-Control-Allow-Origin": "*",
|
|
394
|
+
});
|
|
395
|
+
res.flushHeaders();
|
|
396
|
+
res.write(`:ok\n\n`);
|
|
397
|
+
// Replay buffered events so client sees full current session history
|
|
398
|
+
if (agentMonitorBuffer.length > 0) {
|
|
399
|
+
try {
|
|
400
|
+
res.write(`event: history\ndata: ${JSON.stringify(agentMonitorBuffer)}\n\n`);
|
|
401
|
+
}
|
|
402
|
+
catch { /* closed before flush */ }
|
|
403
|
+
}
|
|
404
|
+
const listener = (event) => {
|
|
405
|
+
try {
|
|
406
|
+
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`);
|
|
407
|
+
}
|
|
408
|
+
catch { /* closed */ }
|
|
409
|
+
};
|
|
410
|
+
monitorListeners.add(listener);
|
|
411
|
+
const heartbeat = setInterval(() => {
|
|
412
|
+
try {
|
|
413
|
+
res.write(`:heartbeat\n\n`);
|
|
414
|
+
}
|
|
415
|
+
catch { /* closed */ }
|
|
416
|
+
}, 15_000);
|
|
417
|
+
const conn = { res, heartbeat };
|
|
418
|
+
monitorConnections.add(conn);
|
|
419
|
+
req.on("close", () => {
|
|
420
|
+
clearInterval(heartbeat);
|
|
421
|
+
monitorListeners.delete(listener);
|
|
422
|
+
monitorConnections.delete(conn);
|
|
423
|
+
});
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
// ── GET /openalerts/test/events → Last 10 gateway events ────
|
|
427
|
+
if (url === "/openalerts/test/events" && req.method === "GET") {
|
|
428
|
+
res.writeHead(200, {
|
|
429
|
+
"Content-Type": "application/json",
|
|
430
|
+
"Cache-Control": "no-cache",
|
|
431
|
+
"Access-Control-Allow-Origin": "*",
|
|
432
|
+
});
|
|
433
|
+
res.end(JSON.stringify({
|
|
434
|
+
count: recentGatewayEvents.length,
|
|
435
|
+
events: recentGatewayEvents,
|
|
436
|
+
}));
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
// Unknown /openalerts sub-route
|
|
440
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
441
|
+
res.end("Not found");
|
|
442
|
+
return true;
|
|
443
|
+
};
|
|
444
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import EventEmitter from "eventemitter3";
|
|
2
|
+
interface GatewayClientConfig {
|
|
3
|
+
url: string;
|
|
4
|
+
token: string;
|
|
5
|
+
reconnectInterval: number;
|
|
6
|
+
maxRetries: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* WebSocket client for OpenClaw Gateway
|
|
10
|
+
* - Connects to ws://127.0.0.1:18789
|
|
11
|
+
* - Handles JSON-RPC requests/responses
|
|
12
|
+
* - Emits events: agent, health, cron, chat
|
|
13
|
+
* - Auto-reconnects on disconnect
|
|
14
|
+
*/
|
|
15
|
+
export declare class GatewayClient extends EventEmitter {
|
|
16
|
+
private ws;
|
|
17
|
+
private config;
|
|
18
|
+
private pending;
|
|
19
|
+
private backoffMs;
|
|
20
|
+
private closed;
|
|
21
|
+
private connectTimer;
|
|
22
|
+
private ready;
|
|
23
|
+
constructor(config?: Partial<GatewayClientConfig>);
|
|
24
|
+
start(): void;
|
|
25
|
+
stop(): void;
|
|
26
|
+
isReady(): boolean;
|
|
27
|
+
private doConnect;
|
|
28
|
+
private sendConnectHandshake;
|
|
29
|
+
private handleMessage;
|
|
30
|
+
/**
|
|
31
|
+
* Send RPC request to gateway
|
|
32
|
+
* @example
|
|
33
|
+
* const cost = await client.request("usage.cost", { period: "day" });
|
|
34
|
+
* const sessions = await client.request("sessions.list");
|
|
35
|
+
*/
|
|
36
|
+
request<T = unknown>(method: string, params?: unknown): Promise<T>;
|
|
37
|
+
private scheduleReconnect;
|
|
38
|
+
}
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
import EventEmitter from "eventemitter3";
|
|
4
|
+
/**
|
|
5
|
+
* WebSocket client for OpenClaw Gateway
|
|
6
|
+
* - Connects to ws://127.0.0.1:18789
|
|
7
|
+
* - Handles JSON-RPC requests/responses
|
|
8
|
+
* - Emits events: agent, health, cron, chat
|
|
9
|
+
* - Auto-reconnects on disconnect
|
|
10
|
+
*/
|
|
11
|
+
export class GatewayClient extends EventEmitter {
|
|
12
|
+
ws = null;
|
|
13
|
+
config;
|
|
14
|
+
pending = new Map();
|
|
15
|
+
backoffMs = 1000;
|
|
16
|
+
closed = false;
|
|
17
|
+
connectTimer = null;
|
|
18
|
+
ready = false;
|
|
19
|
+
constructor(config) {
|
|
20
|
+
super();
|
|
21
|
+
this.config = {
|
|
22
|
+
url: config?.url ?? "ws://127.0.0.1:18789",
|
|
23
|
+
token: config?.token ?? "",
|
|
24
|
+
reconnectInterval: config?.reconnectInterval ?? 1000,
|
|
25
|
+
maxRetries: config?.maxRetries ?? 60,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
start() {
|
|
29
|
+
if (this.closed) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
this.doConnect();
|
|
33
|
+
}
|
|
34
|
+
stop() {
|
|
35
|
+
this.closed = true;
|
|
36
|
+
if (this.connectTimer) {
|
|
37
|
+
clearTimeout(this.connectTimer);
|
|
38
|
+
this.connectTimer = null;
|
|
39
|
+
}
|
|
40
|
+
if (this.ws) {
|
|
41
|
+
this.ws.close();
|
|
42
|
+
this.ws = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
isReady() {
|
|
46
|
+
return this.ready;
|
|
47
|
+
}
|
|
48
|
+
doConnect() {
|
|
49
|
+
if (this.closed || this.ws) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
this.ws = new WebSocket(this.config.url, {
|
|
53
|
+
maxPayload: 25 * 1024 * 1024,
|
|
54
|
+
});
|
|
55
|
+
this.ws.on("open", () => {
|
|
56
|
+
this.backoffMs = 1000;
|
|
57
|
+
// Gateway sends 'connect.challenge' event first
|
|
58
|
+
});
|
|
59
|
+
this.ws.on("message", (data) => {
|
|
60
|
+
this.handleMessage(data.toString());
|
|
61
|
+
});
|
|
62
|
+
this.ws.on("error", (err) => {
|
|
63
|
+
this.emit("error", err);
|
|
64
|
+
});
|
|
65
|
+
this.ws.on("close", () => {
|
|
66
|
+
this.ws = null;
|
|
67
|
+
this.ready = false;
|
|
68
|
+
this.emit("disconnected");
|
|
69
|
+
if (!this.closed) {
|
|
70
|
+
this.scheduleReconnect();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
sendConnectHandshake() {
|
|
75
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const id = randomUUID();
|
|
79
|
+
const frame = {
|
|
80
|
+
type: "req",
|
|
81
|
+
id,
|
|
82
|
+
method: "connect",
|
|
83
|
+
params: {
|
|
84
|
+
minProtocol: 3,
|
|
85
|
+
maxProtocol: 3,
|
|
86
|
+
client: {
|
|
87
|
+
id: "cli",
|
|
88
|
+
displayName: "OpenAlerts Monitor",
|
|
89
|
+
version: "0.1.0",
|
|
90
|
+
platform: process.platform,
|
|
91
|
+
mode: "cli",
|
|
92
|
+
},
|
|
93
|
+
role: "operator",
|
|
94
|
+
scopes: ["operator.read"],
|
|
95
|
+
caps: [],
|
|
96
|
+
commands: [],
|
|
97
|
+
permissions: {},
|
|
98
|
+
locale: "en-US",
|
|
99
|
+
userAgent: "openalerts-monitor/0.1.0",
|
|
100
|
+
auth: this.config.token ? { token: this.config.token } : undefined,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
this.pending.set(id, {
|
|
104
|
+
resolve: (result) => {
|
|
105
|
+
this.ready = true;
|
|
106
|
+
this.emit("ready", result);
|
|
107
|
+
},
|
|
108
|
+
reject: (err) => {
|
|
109
|
+
this.emit("error", new Error(`Connect handshake failed: ${err.message}`));
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
this.ws.send(JSON.stringify(frame));
|
|
113
|
+
}
|
|
114
|
+
handleMessage(raw) {
|
|
115
|
+
try {
|
|
116
|
+
const frame = JSON.parse(raw);
|
|
117
|
+
// Handle challenge-response auth
|
|
118
|
+
if (frame.type === "event" && frame.event === "connect.challenge") {
|
|
119
|
+
this.sendConnectHandshake();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (frame.type === "res") {
|
|
123
|
+
const pending = this.pending.get(frame.id);
|
|
124
|
+
if (pending) {
|
|
125
|
+
if (frame.error || frame.ok === false) {
|
|
126
|
+
const errMsg = typeof frame.error === "string"
|
|
127
|
+
? frame.error
|
|
128
|
+
: typeof frame.payload === "object" &&
|
|
129
|
+
frame.payload &&
|
|
130
|
+
"message" in frame.payload
|
|
131
|
+
? String(frame.payload.message)
|
|
132
|
+
: JSON.stringify(frame.error ?? frame.payload);
|
|
133
|
+
pending.reject(new Error(errMsg));
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
pending.resolve(frame.payload ?? frame.result);
|
|
137
|
+
}
|
|
138
|
+
this.pending.delete(frame.id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else if (frame.type === "event") {
|
|
142
|
+
// Emit the event for subscribers: agent, health, cron, chat
|
|
143
|
+
this.emit(frame.event, frame.payload);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
this.emit("error", new Error(`Failed to parse frame: ${err}`));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Send RPC request to gateway
|
|
152
|
+
* @example
|
|
153
|
+
* const cost = await client.request("usage.cost", { period: "day" });
|
|
154
|
+
* const sessions = await client.request("sessions.list");
|
|
155
|
+
*/
|
|
156
|
+
request(method, params) {
|
|
157
|
+
if (!this.ready || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
158
|
+
return Promise.reject(new Error("Gateway not ready"));
|
|
159
|
+
}
|
|
160
|
+
const id = randomUUID();
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const timeout = setTimeout(() => {
|
|
163
|
+
this.pending.delete(id);
|
|
164
|
+
reject(new Error(`Request timeout: ${method}`));
|
|
165
|
+
}, 10000);
|
|
166
|
+
this.pending.set(id, {
|
|
167
|
+
resolve: (value) => {
|
|
168
|
+
clearTimeout(timeout);
|
|
169
|
+
resolve(value);
|
|
170
|
+
},
|
|
171
|
+
reject: (err) => {
|
|
172
|
+
clearTimeout(timeout);
|
|
173
|
+
reject(err);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
const frame = {
|
|
177
|
+
type: "req",
|
|
178
|
+
id,
|
|
179
|
+
method,
|
|
180
|
+
params,
|
|
181
|
+
};
|
|
182
|
+
if (!this.ws) {
|
|
183
|
+
this.pending.delete(id);
|
|
184
|
+
clearTimeout(timeout);
|
|
185
|
+
reject(new Error("WebSocket not connected"));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.ws.send(JSON.stringify(frame));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
scheduleReconnect() {
|
|
192
|
+
if (this.connectTimer) {
|
|
193
|
+
clearTimeout(this.connectTimer);
|
|
194
|
+
}
|
|
195
|
+
this.connectTimer = setTimeout(() => {
|
|
196
|
+
this.backoffMs = Math.min(this.backoffMs * 2, 60000);
|
|
197
|
+
this.doConnect();
|
|
198
|
+
}, this.backoffMs);
|
|
199
|
+
}
|
|
200
|
+
}
|