@steadwing/openalerts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +208 -0
- package/dist/src/adapter.d.ts +138 -0
- package/dist/src/adapter.js +458 -0
- package/dist/src/commands.d.ts +18 -0
- package/dist/src/commands.js +122 -0
- package/dist/src/dashboard-html.d.ts +7 -0
- package/dist/src/dashboard-html.js +924 -0
- package/dist/src/dashboard-routes.d.ts +7 -0
- package/dist/src/dashboard-routes.js +298 -0
- package/dist/src/log-bridge.d.ts +22 -0
- package/dist/src/log-bridge.js +336 -0
- package/openclaw.plugin.json +57 -0
- package/package.json +41 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { OpenAlertsEngine } from "@steadwing/openalerts-core";
|
|
3
|
+
type HttpHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean> | boolean;
|
|
4
|
+
/** Close all active SSE connections. Call on engine stop. */
|
|
5
|
+
export declare function closeDashboardConnections(): void;
|
|
6
|
+
export declare function createDashboardHandler(getEngine: () => OpenAlertsEngine | null): HttpHandler;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getDashboardHtml } from "./dashboard-html.js";
|
|
4
|
+
// ─── SSE connection tracking ─────────────────────────────────────────────────
|
|
5
|
+
const sseConnections = new Set();
|
|
6
|
+
/** Close all active SSE connections. Call on engine stop. */
|
|
7
|
+
export function closeDashboardConnections() {
|
|
8
|
+
for (const conn of sseConnections) {
|
|
9
|
+
clearInterval(conn.heartbeat);
|
|
10
|
+
clearInterval(conn.logTailer);
|
|
11
|
+
conn.unsub();
|
|
12
|
+
try {
|
|
13
|
+
conn.res.end();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
/* already closed */
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
sseConnections.clear();
|
|
20
|
+
}
|
|
21
|
+
// ─── Rule status helper ──────────────────────────────────────────────────────
|
|
22
|
+
const RULE_IDS = [
|
|
23
|
+
"infra-errors",
|
|
24
|
+
"llm-errors",
|
|
25
|
+
"session-stuck",
|
|
26
|
+
"heartbeat-fail",
|
|
27
|
+
"queue-depth",
|
|
28
|
+
"high-error-rate",
|
|
29
|
+
"gateway-down",
|
|
30
|
+
];
|
|
31
|
+
function getRuleStatuses(engine) {
|
|
32
|
+
const state = engine.state;
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
return RULE_IDS.map((id) => {
|
|
35
|
+
const cooldownTs = state.cooldowns.get(id);
|
|
36
|
+
const fired = cooldownTs != null && cooldownTs > now - 15 * 60 * 1000;
|
|
37
|
+
return { id, status: fired ? "fired" : "ok" };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
// ─── OpenClaw log file ──────────────────────────────────────────────────────
|
|
41
|
+
function getOpenClawLogDir() {
|
|
42
|
+
// Use platform-appropriate default: C:\tmp\openclaw on Windows, /tmp/openclaw elsewhere
|
|
43
|
+
if (process.platform === "win32") {
|
|
44
|
+
return join("C:", "tmp", "openclaw");
|
|
45
|
+
}
|
|
46
|
+
return join("/tmp", "openclaw");
|
|
47
|
+
}
|
|
48
|
+
function getOpenClawLogPath() {
|
|
49
|
+
const d = new Date();
|
|
50
|
+
const y = d.getFullYear();
|
|
51
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
52
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
53
|
+
return join(getOpenClawLogDir(), `openclaw-${y}-${m}-${day}.log`);
|
|
54
|
+
}
|
|
55
|
+
/** Parse key=value pairs from a log message string. */
|
|
56
|
+
function parseLogKVs(msg) {
|
|
57
|
+
const kvs = {};
|
|
58
|
+
// Match key=value or key="quoted value"
|
|
59
|
+
const re = /(\w+)=(?:"([^"]*)"|(\S+))/g;
|
|
60
|
+
let m;
|
|
61
|
+
while ((m = re.exec(msg)) !== null) {
|
|
62
|
+
kvs[m[1]] = m[2] ?? m[3];
|
|
63
|
+
}
|
|
64
|
+
return kvs;
|
|
65
|
+
}
|
|
66
|
+
function parseLogLine(line) {
|
|
67
|
+
try {
|
|
68
|
+
const obj = JSON.parse(line);
|
|
69
|
+
const ts = obj.time || obj._meta?.date || "";
|
|
70
|
+
const tsMs = ts ? new Date(ts).getTime() : 0;
|
|
71
|
+
let subsystem = "";
|
|
72
|
+
try {
|
|
73
|
+
const nameObj = JSON.parse(obj["0"] || "{}");
|
|
74
|
+
subsystem = nameObj.subsystem || "";
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
subsystem = obj["0"] || "";
|
|
78
|
+
}
|
|
79
|
+
const message = obj["1"] || "";
|
|
80
|
+
const extra = parseLogKVs(message);
|
|
81
|
+
return {
|
|
82
|
+
ts,
|
|
83
|
+
tsMs,
|
|
84
|
+
level: obj._meta?.logLevelName || "DEBUG",
|
|
85
|
+
subsystem,
|
|
86
|
+
message,
|
|
87
|
+
sessionId: extra.sessionId,
|
|
88
|
+
runId: extra.runId,
|
|
89
|
+
durationMs: extra.durationMs ? parseInt(extra.durationMs, 10) : undefined,
|
|
90
|
+
filePath: obj._meta?.path?.fileNameWithLine,
|
|
91
|
+
method: obj._meta?.path?.method,
|
|
92
|
+
hostname: obj._meta?.hostname,
|
|
93
|
+
extra,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function readOpenClawLogs(maxEntries, afterTs) {
|
|
101
|
+
const logPath = getOpenClawLogPath();
|
|
102
|
+
if (!existsSync(logPath))
|
|
103
|
+
return { entries: [], truncated: false, subsystems: [] };
|
|
104
|
+
try {
|
|
105
|
+
const content = readFileSync(logPath, "utf-8");
|
|
106
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
107
|
+
const entries = [];
|
|
108
|
+
const subsystemSet = new Set();
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const parsed = parseLogLine(line);
|
|
111
|
+
if (!parsed)
|
|
112
|
+
continue;
|
|
113
|
+
if (afterTs && parsed.ts <= afterTs)
|
|
114
|
+
continue;
|
|
115
|
+
entries.push(parsed);
|
|
116
|
+
subsystemSet.add(parsed.subsystem);
|
|
117
|
+
}
|
|
118
|
+
const truncated = entries.length > maxEntries;
|
|
119
|
+
const sliced = entries.slice(-maxEntries);
|
|
120
|
+
const subsystems = Array.from(subsystemSet).sort();
|
|
121
|
+
return { entries: sliced, truncated, subsystems };
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return { entries: [], truncated: false, subsystems: [] };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Create a log tailer for an SSE connection.
|
|
129
|
+
* Polls the log file every 2 seconds, sends new lines as `event: oclog`.
|
|
130
|
+
*/
|
|
131
|
+
function createLogTailer(res) {
|
|
132
|
+
let lastLineCount = 0;
|
|
133
|
+
let lastPath = "";
|
|
134
|
+
// Initialize: count existing lines so we only send new ones
|
|
135
|
+
try {
|
|
136
|
+
const path = getOpenClawLogPath();
|
|
137
|
+
if (existsSync(path)) {
|
|
138
|
+
const content = readFileSync(path, "utf-8");
|
|
139
|
+
lastLineCount = content.trim().split("\n").length;
|
|
140
|
+
lastPath = path;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* ignore */
|
|
145
|
+
}
|
|
146
|
+
return setInterval(() => {
|
|
147
|
+
try {
|
|
148
|
+
const path = getOpenClawLogPath();
|
|
149
|
+
if (!existsSync(path))
|
|
150
|
+
return;
|
|
151
|
+
// If date changed (new log file), reset counter
|
|
152
|
+
if (path !== lastPath) {
|
|
153
|
+
lastLineCount = 0;
|
|
154
|
+
lastPath = path;
|
|
155
|
+
}
|
|
156
|
+
const content = readFileSync(path, "utf-8");
|
|
157
|
+
const lines = content.trim().split("\n");
|
|
158
|
+
if (lines.length <= lastLineCount)
|
|
159
|
+
return;
|
|
160
|
+
// Send only new lines
|
|
161
|
+
const newLines = lines.slice(lastLineCount);
|
|
162
|
+
lastLineCount = lines.length;
|
|
163
|
+
for (const line of newLines) {
|
|
164
|
+
const parsed = parseLogLine(line);
|
|
165
|
+
if (!parsed)
|
|
166
|
+
continue;
|
|
167
|
+
res.write(`event: oclog\ndata: ${JSON.stringify(parsed)}\n\n`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
/* ignore read errors */
|
|
172
|
+
}
|
|
173
|
+
}, 2000);
|
|
174
|
+
}
|
|
175
|
+
// ─── HTTP Handler ────────────────────────────────────────────────────────────
|
|
176
|
+
export function createDashboardHandler(getEngine) {
|
|
177
|
+
return async (req, res) => {
|
|
178
|
+
const url = req.url ?? "";
|
|
179
|
+
if (!url.startsWith("/openalerts"))
|
|
180
|
+
return false;
|
|
181
|
+
const engine = getEngine();
|
|
182
|
+
// ── GET /openalerts → Dashboard HTML ──────────────────────
|
|
183
|
+
if ((url === "/openalerts" || url === "/openalerts/") &&
|
|
184
|
+
req.method === "GET") {
|
|
185
|
+
if (!engine) {
|
|
186
|
+
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
187
|
+
res.end("OpenAlerts engine not running.");
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
res.writeHead(200, {
|
|
191
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
192
|
+
"Cache-Control": "no-cache",
|
|
193
|
+
});
|
|
194
|
+
res.end(getDashboardHtml());
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
// ── GET /openalerts/events → SSE stream (engine events + log tailing) ──
|
|
198
|
+
if (url === "/openalerts/events" && req.method === "GET") {
|
|
199
|
+
if (!engine) {
|
|
200
|
+
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
201
|
+
res.end("Engine not running.");
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
res.writeHead(200, {
|
|
205
|
+
"Content-Type": "text/event-stream",
|
|
206
|
+
"Cache-Control": "no-cache",
|
|
207
|
+
Connection: "keep-alive",
|
|
208
|
+
"Access-Control-Allow-Origin": "*",
|
|
209
|
+
});
|
|
210
|
+
res.flushHeaders();
|
|
211
|
+
// Subscribe to engine events
|
|
212
|
+
const unsub = engine.bus.on((event) => {
|
|
213
|
+
try {
|
|
214
|
+
res.write(`event: openalerts\ndata: ${JSON.stringify(event)}\n\n`);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
/* closed */
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
// Start log file tailer — streams OpenClaw internal logs as `event: oclog`
|
|
221
|
+
const logTailer = createLogTailer(res);
|
|
222
|
+
// Heartbeat every 15s
|
|
223
|
+
const heartbeat = setInterval(() => {
|
|
224
|
+
try {
|
|
225
|
+
res.write(":heartbeat\n\n");
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
/* closed */
|
|
229
|
+
}
|
|
230
|
+
}, 15_000);
|
|
231
|
+
const conn = { res, unsub, heartbeat, logTailer };
|
|
232
|
+
sseConnections.add(conn);
|
|
233
|
+
req.on("close", () => {
|
|
234
|
+
clearInterval(heartbeat);
|
|
235
|
+
clearInterval(logTailer);
|
|
236
|
+
unsub();
|
|
237
|
+
sseConnections.delete(conn);
|
|
238
|
+
});
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
// ── GET /openalerts/state → JSON snapshot ─────────────────
|
|
242
|
+
if (url === "/openalerts/state" && req.method === "GET") {
|
|
243
|
+
if (!engine) {
|
|
244
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
245
|
+
res.end(JSON.stringify({ error: "Engine not running" }));
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
const state = engine.state;
|
|
249
|
+
const recentAlerts = engine
|
|
250
|
+
.getRecentEvents(50)
|
|
251
|
+
.filter((e) => e.type === "alert");
|
|
252
|
+
const body = JSON.stringify({
|
|
253
|
+
uptimeMs: Date.now() - state.startedAt,
|
|
254
|
+
startedAt: state.startedAt,
|
|
255
|
+
lastHeartbeatTs: state.lastHeartbeatTs,
|
|
256
|
+
hourlyAlerts: state.hourlyAlerts,
|
|
257
|
+
stuckSessions: state.stats.stuckSessions,
|
|
258
|
+
lastResetTs: state.stats.lastResetTs,
|
|
259
|
+
stats: state.stats,
|
|
260
|
+
busListeners: engine.bus.size,
|
|
261
|
+
platformConnected: engine.platformConnected,
|
|
262
|
+
recentAlerts: recentAlerts.slice(0, 20),
|
|
263
|
+
rules: getRuleStatuses(engine),
|
|
264
|
+
cooldowns: Object.fromEntries(state.cooldowns),
|
|
265
|
+
});
|
|
266
|
+
res.writeHead(200, {
|
|
267
|
+
"Content-Type": "application/json",
|
|
268
|
+
"Cache-Control": "no-cache",
|
|
269
|
+
"Access-Control-Allow-Origin": "*",
|
|
270
|
+
});
|
|
271
|
+
res.end(body);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
// ── GET /openalerts/logs → OpenClaw log entries (for Logs tab) ────
|
|
275
|
+
if (url.startsWith("/openalerts/logs") && req.method === "GET") {
|
|
276
|
+
const urlObj = new URL(url, "http://localhost");
|
|
277
|
+
const limit = Math.min(parseInt(urlObj.searchParams.get("limit") || "200", 10), 1000);
|
|
278
|
+
const afterTs = urlObj.searchParams.get("after") || undefined;
|
|
279
|
+
const result = readOpenClawLogs(limit, afterTs);
|
|
280
|
+
res.writeHead(200, {
|
|
281
|
+
"Content-Type": "application/json",
|
|
282
|
+
"Cache-Control": "no-cache",
|
|
283
|
+
"Access-Control-Allow-Origin": "*",
|
|
284
|
+
});
|
|
285
|
+
res.end(JSON.stringify({
|
|
286
|
+
entries: result.entries,
|
|
287
|
+
truncated: result.truncated,
|
|
288
|
+
subsystems: result.subsystems,
|
|
289
|
+
logFile: getOpenClawLogPath(),
|
|
290
|
+
}));
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
// Unknown /openalerts sub-route
|
|
294
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
295
|
+
res.end("Not found");
|
|
296
|
+
return true;
|
|
297
|
+
};
|
|
298
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { OpenAlertsEngine } from "@steadwing/openalerts-core";
|
|
2
|
+
import type { LogTransportRecord } from "openclaw/plugin-sdk";
|
|
3
|
+
/**
|
|
4
|
+
* Create a log-transport bridge that parses structured log records and
|
|
5
|
+
* synthesizes OpenAlertsEvents to fill gaps left by non-firing plugin hooks.
|
|
6
|
+
*
|
|
7
|
+
* Covers:
|
|
8
|
+
* - Tool calls (tool start → tool end, with duration) — fills `after_tool_call` gap
|
|
9
|
+
* - Session lifecycle (idle ↔ processing transitions) — fills `session_start/end` gap
|
|
10
|
+
* - Run prompt duration — enriches session.end with durationMs
|
|
11
|
+
* - Agent run lifecycle — agent start/end at run level
|
|
12
|
+
* - Compaction events — detects costly context compaction
|
|
13
|
+
* - Message delivery — "Committed messaging text" fills `message_sent` gap
|
|
14
|
+
* - Exec commands — attaches elevated commands to tool events
|
|
15
|
+
*
|
|
16
|
+
* Returns the transport function to pass to registerLogTransport().
|
|
17
|
+
* Call the returned cleanup function to release internal state.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createLogBridge(engine: OpenAlertsEngine): {
|
|
20
|
+
transport: (logObj: LogTransportRecord) => void;
|
|
21
|
+
cleanup: () => void;
|
|
22
|
+
};
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { BoundedMap } from "@steadwing/openalerts-core";
|
|
2
|
+
// ─── Parsing Helpers ─────────────────────────────────────────────────────────
|
|
3
|
+
const KV_RE = /(\w+)=([\S]+)/g;
|
|
4
|
+
/** Parse key=value pairs from a log message string. */
|
|
5
|
+
function parseKvs(message) {
|
|
6
|
+
const result = {};
|
|
7
|
+
let m;
|
|
8
|
+
KV_RE.lastIndex = 0;
|
|
9
|
+
while ((m = KV_RE.exec(message)) !== null) {
|
|
10
|
+
result[m[1]] = m[2];
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
/** Extract subsystem string from field "0" which may be JSON or raw. */
|
|
15
|
+
function extractSubsystem(field0) {
|
|
16
|
+
if (typeof field0 !== "string")
|
|
17
|
+
return "";
|
|
18
|
+
if (field0.startsWith("{")) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(field0);
|
|
21
|
+
return typeof parsed.subsystem === "string" ? parsed.subsystem : "";
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return field0;
|
|
28
|
+
}
|
|
29
|
+
/** Extract timestamp from a log record. */
|
|
30
|
+
function extractTimestamp(logObj) {
|
|
31
|
+
const meta = logObj._meta;
|
|
32
|
+
if (meta?.date && typeof meta.date === "string") {
|
|
33
|
+
const t = new Date(meta.date).getTime();
|
|
34
|
+
if (!isNaN(t))
|
|
35
|
+
return t;
|
|
36
|
+
}
|
|
37
|
+
if (typeof logObj.time === "string") {
|
|
38
|
+
const t = new Date(logObj.time).getTime();
|
|
39
|
+
if (!isNaN(t))
|
|
40
|
+
return t;
|
|
41
|
+
}
|
|
42
|
+
if (typeof logObj.time === "number") {
|
|
43
|
+
return logObj.time;
|
|
44
|
+
}
|
|
45
|
+
return Date.now();
|
|
46
|
+
}
|
|
47
|
+
/** Subsystems the bridge cares about. Everything else is fast-skipped. */
|
|
48
|
+
const WATCHED_SUBSYSTEMS = new Set(["agent/embedded", "diagnostic", "exec"]);
|
|
49
|
+
/**
|
|
50
|
+
* Parse a raw LogTransportRecord into a structured form.
|
|
51
|
+
* Returns null if the record can't be parsed or is from an irrelevant subsystem.
|
|
52
|
+
*/
|
|
53
|
+
function parseLogRecord(logObj) {
|
|
54
|
+
const subsystem = extractSubsystem(logObj["0"]);
|
|
55
|
+
if (!WATCHED_SUBSYSTEMS.has(subsystem))
|
|
56
|
+
return null;
|
|
57
|
+
const message = typeof logObj["1"] === "string" ? logObj["1"] : "";
|
|
58
|
+
if (!message)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
subsystem,
|
|
62
|
+
message,
|
|
63
|
+
ts: extractTimestamp(logObj),
|
|
64
|
+
kvs: parseKvs(message),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// ─── Dedup set with periodic pruning ─────────────────────────────────────────
|
|
68
|
+
const DEDUP_MAX_SIZE = 2000;
|
|
69
|
+
const DEDUP_PRUNE_TARGET = 500;
|
|
70
|
+
/** Prune oldest entries from a dedup set when it grows too large. */
|
|
71
|
+
function pruneDedupeSet(set) {
|
|
72
|
+
if (set.size <= DEDUP_MAX_SIZE)
|
|
73
|
+
return;
|
|
74
|
+
// Set iterates in insertion order; delete oldest entries
|
|
75
|
+
const toDelete = set.size - DEDUP_PRUNE_TARGET;
|
|
76
|
+
let deleted = 0;
|
|
77
|
+
for (const key of set) {
|
|
78
|
+
if (deleted >= toDelete)
|
|
79
|
+
break;
|
|
80
|
+
set.delete(key);
|
|
81
|
+
deleted++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// ─── Bridge ──────────────────────────────────────────────────────────────────
|
|
85
|
+
/**
|
|
86
|
+
* Create a log-transport bridge that parses structured log records and
|
|
87
|
+
* synthesizes OpenAlertsEvents to fill gaps left by non-firing plugin hooks.
|
|
88
|
+
*
|
|
89
|
+
* Covers:
|
|
90
|
+
* - Tool calls (tool start → tool end, with duration) — fills `after_tool_call` gap
|
|
91
|
+
* - Session lifecycle (idle ↔ processing transitions) — fills `session_start/end` gap
|
|
92
|
+
* - Run prompt duration — enriches session.end with durationMs
|
|
93
|
+
* - Agent run lifecycle — agent start/end at run level
|
|
94
|
+
* - Compaction events — detects costly context compaction
|
|
95
|
+
* - Message delivery — "Committed messaging text" fills `message_sent` gap
|
|
96
|
+
* - Exec commands — attaches elevated commands to tool events
|
|
97
|
+
*
|
|
98
|
+
* Returns the transport function to pass to registerLogTransport().
|
|
99
|
+
* Call the returned cleanup function to release internal state.
|
|
100
|
+
*/
|
|
101
|
+
export function createLogBridge(engine) {
|
|
102
|
+
// ── State ──────────────────────────────────────────────────────────────────
|
|
103
|
+
// Use bounded maps to prevent memory leaks (max 1000 entries each)
|
|
104
|
+
const toolFlights = new BoundedMap({ maxSize: 1000 });
|
|
105
|
+
const sessionStates = new BoundedMap({ maxSize: 500 });
|
|
106
|
+
const dedupeSet = new Set();
|
|
107
|
+
let pendingCommand = null;
|
|
108
|
+
let lastRunDurationMs = null;
|
|
109
|
+
let pruneCounter = 0;
|
|
110
|
+
function ingest(event) {
|
|
111
|
+
engine.ingest(event);
|
|
112
|
+
}
|
|
113
|
+
// ── Tool call handling (agent/embedded) ────────────────────────────────────
|
|
114
|
+
// Fills the `after_tool_call` hook gap (hook is declared but never fires)
|
|
115
|
+
function handleToolStart(rec) {
|
|
116
|
+
const { toolCallId, tool, runId } = rec.kvs;
|
|
117
|
+
if (!toolCallId || !tool)
|
|
118
|
+
return;
|
|
119
|
+
toolFlights.set(toolCallId, {
|
|
120
|
+
tool,
|
|
121
|
+
runId: runId ?? "",
|
|
122
|
+
startTs: rec.ts,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function handleToolEnd(rec) {
|
|
126
|
+
const { toolCallId, tool, runId } = rec.kvs;
|
|
127
|
+
if (!toolCallId)
|
|
128
|
+
return;
|
|
129
|
+
const dedupeKey = `tool:${toolCallId}`;
|
|
130
|
+
if (dedupeSet.has(dedupeKey))
|
|
131
|
+
return;
|
|
132
|
+
dedupeSet.add(dedupeKey);
|
|
133
|
+
const flight = toolFlights.get(toolCallId);
|
|
134
|
+
toolFlights.delete(toolCallId);
|
|
135
|
+
const durationMs = flight ? rec.ts - flight.startTs : undefined;
|
|
136
|
+
const toolName = flight?.tool ?? tool ?? "unknown";
|
|
137
|
+
const event = {
|
|
138
|
+
type: "tool.call",
|
|
139
|
+
ts: rec.ts,
|
|
140
|
+
durationMs,
|
|
141
|
+
outcome: "success",
|
|
142
|
+
meta: {
|
|
143
|
+
toolName,
|
|
144
|
+
toolCallId,
|
|
145
|
+
runId: flight?.runId ?? runId,
|
|
146
|
+
source: "log-bridge",
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
if (pendingCommand) {
|
|
150
|
+
event.meta.command = pendingCommand;
|
|
151
|
+
pendingCommand = null;
|
|
152
|
+
}
|
|
153
|
+
ingest(event);
|
|
154
|
+
}
|
|
155
|
+
// ── Session lifecycle handling (diagnostic) ────────────────────────────────
|
|
156
|
+
// Fills the `session_start/end` hook gap (hooks declared but never fire)
|
|
157
|
+
function handleSessionState(rec) {
|
|
158
|
+
const { sessionId, sessionKey, prev, new: newState, reason } = rec.kvs;
|
|
159
|
+
const sid = sessionId ?? sessionKey;
|
|
160
|
+
if (!sid || !newState)
|
|
161
|
+
return;
|
|
162
|
+
const prevState = prev ?? sessionStates.get(sid);
|
|
163
|
+
sessionStates.set(sid, newState);
|
|
164
|
+
// session.start: idle → processing
|
|
165
|
+
if (newState === "processing" && prevState === "idle") {
|
|
166
|
+
const startKey = `session:start:${sid}`;
|
|
167
|
+
if (!dedupeSet.has(startKey)) {
|
|
168
|
+
dedupeSet.add(startKey);
|
|
169
|
+
ingest({
|
|
170
|
+
type: "session.start",
|
|
171
|
+
ts: rec.ts,
|
|
172
|
+
sessionKey: sid,
|
|
173
|
+
outcome: "success",
|
|
174
|
+
meta: { source: "log-bridge" },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// session.end: processing → idle with reason=run_completed
|
|
179
|
+
if (newState === "idle" &&
|
|
180
|
+
prevState === "processing" &&
|
|
181
|
+
reason === "run_completed") {
|
|
182
|
+
const endKey = `session:end:${sid}`;
|
|
183
|
+
if (!dedupeSet.has(endKey)) {
|
|
184
|
+
dedupeSet.add(endKey);
|
|
185
|
+
const event = {
|
|
186
|
+
type: "session.end",
|
|
187
|
+
ts: rec.ts,
|
|
188
|
+
sessionKey: sid,
|
|
189
|
+
outcome: "success",
|
|
190
|
+
meta: { source: "log-bridge" },
|
|
191
|
+
};
|
|
192
|
+
if (lastRunDurationMs !== null) {
|
|
193
|
+
event.durationMs = lastRunDurationMs;
|
|
194
|
+
lastRunDurationMs = null;
|
|
195
|
+
}
|
|
196
|
+
ingest(event);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// ── Run prompt duration (agent/embedded) ──────────────────────────────────
|
|
201
|
+
function handleRunPromptEnd(rec) {
|
|
202
|
+
const { durationMs } = rec.kvs;
|
|
203
|
+
if (durationMs) {
|
|
204
|
+
lastRunDurationMs = parseInt(durationMs, 10) || null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// ── Agent run lifecycle (agent/embedded) ──────────────────────────────────
|
|
208
|
+
// Complements before_agent_start / agent_end hooks with run-level tracking
|
|
209
|
+
function handleAgentRunStart(rec) {
|
|
210
|
+
const { runId } = rec.kvs;
|
|
211
|
+
ingest({
|
|
212
|
+
type: "custom",
|
|
213
|
+
ts: rec.ts,
|
|
214
|
+
outcome: "success",
|
|
215
|
+
meta: { runId, openclawLog: "agent_run_start", source: "log-bridge" },
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
function handleAgentRunEnd(rec) {
|
|
219
|
+
const { runId } = rec.kvs;
|
|
220
|
+
ingest({
|
|
221
|
+
type: "custom",
|
|
222
|
+
ts: rec.ts,
|
|
223
|
+
outcome: "success",
|
|
224
|
+
meta: { runId, openclawLog: "agent_run_end", source: "log-bridge" },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
// ── Compaction (agent/embedded) ───────────────────────────────────────────
|
|
228
|
+
// OpenClaw compacts when context is full — costly, can lose context.
|
|
229
|
+
// No hook exists for this. Only available via logs.
|
|
230
|
+
function handleCompactionStart(rec) {
|
|
231
|
+
const { runId } = rec.kvs;
|
|
232
|
+
ingest({
|
|
233
|
+
type: "custom",
|
|
234
|
+
ts: rec.ts,
|
|
235
|
+
outcome: "success",
|
|
236
|
+
meta: {
|
|
237
|
+
runId,
|
|
238
|
+
compaction: true,
|
|
239
|
+
openclawLog: "compaction_start",
|
|
240
|
+
source: "log-bridge",
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
function handleCompactionRetry(rec) {
|
|
245
|
+
const { runId } = rec.kvs;
|
|
246
|
+
ingest({
|
|
247
|
+
type: "custom",
|
|
248
|
+
ts: rec.ts,
|
|
249
|
+
outcome: "success",
|
|
250
|
+
meta: {
|
|
251
|
+
runId,
|
|
252
|
+
compaction: true,
|
|
253
|
+
openclawLog: "compaction_retry",
|
|
254
|
+
source: "log-bridge",
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
// ── Message delivery (agent/embedded) ─────────────────────────────────────
|
|
259
|
+
// "Committed messaging text" fires when a messaging tool (Telegram, Discord,
|
|
260
|
+
// etc.) successfully delivers a message. Fills the `message_sent` hook gap.
|
|
261
|
+
function handleMessageCommitted(rec) {
|
|
262
|
+
const { tool, len } = rec.kvs;
|
|
263
|
+
ingest({
|
|
264
|
+
type: "custom",
|
|
265
|
+
ts: rec.ts,
|
|
266
|
+
outcome: "success",
|
|
267
|
+
meta: {
|
|
268
|
+
toolName: tool,
|
|
269
|
+
textLength: len ? parseInt(len, 10) : undefined,
|
|
270
|
+
openclawLog: "message_committed",
|
|
271
|
+
source: "log-bridge",
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// ── Exec command (exec) ────────────────────────────────────────────────────
|
|
276
|
+
function handleExecCommand(rec) {
|
|
277
|
+
pendingCommand = rec.message;
|
|
278
|
+
}
|
|
279
|
+
// ── Main transport function ────────────────────────────────────────────────
|
|
280
|
+
function transport(logObj) {
|
|
281
|
+
const rec = parseLogRecord(logObj);
|
|
282
|
+
if (!rec)
|
|
283
|
+
return;
|
|
284
|
+
const msg = rec.message;
|
|
285
|
+
if (rec.subsystem === "agent/embedded") {
|
|
286
|
+
if (msg.startsWith("embedded run tool start:")) {
|
|
287
|
+
handleToolStart(rec);
|
|
288
|
+
}
|
|
289
|
+
else if (msg.startsWith("embedded run tool end:")) {
|
|
290
|
+
handleToolEnd(rec);
|
|
291
|
+
}
|
|
292
|
+
else if (msg.startsWith("embedded run prompt end:")) {
|
|
293
|
+
handleRunPromptEnd(rec);
|
|
294
|
+
}
|
|
295
|
+
else if (msg.startsWith("embedded run agent start:")) {
|
|
296
|
+
handleAgentRunStart(rec);
|
|
297
|
+
}
|
|
298
|
+
else if (msg.startsWith("embedded run agent end:")) {
|
|
299
|
+
handleAgentRunEnd(rec);
|
|
300
|
+
}
|
|
301
|
+
else if (msg.startsWith("embedded run compaction start:")) {
|
|
302
|
+
handleCompactionStart(rec);
|
|
303
|
+
}
|
|
304
|
+
else if (msg.startsWith("embedded run compaction retry:")) {
|
|
305
|
+
handleCompactionRetry(rec);
|
|
306
|
+
}
|
|
307
|
+
else if (msg.startsWith("Committed messaging text:")) {
|
|
308
|
+
handleMessageCommitted(rec);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else if (rec.subsystem === "diagnostic") {
|
|
312
|
+
if (msg.startsWith("session state:")) {
|
|
313
|
+
handleSessionState(rec);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else if (rec.subsystem === "exec") {
|
|
317
|
+
if (msg.startsWith("elevated command")) {
|
|
318
|
+
handleExecCommand(rec);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Periodic dedup set pruning (check every 100 log records)
|
|
322
|
+
if (++pruneCounter >= 100) {
|
|
323
|
+
pruneCounter = 0;
|
|
324
|
+
pruneDedupeSet(dedupeSet);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function cleanup() {
|
|
328
|
+
toolFlights.clear();
|
|
329
|
+
sessionStates.clear();
|
|
330
|
+
dedupeSet.clear();
|
|
331
|
+
pendingCommand = null;
|
|
332
|
+
lastRunDurationMs = null;
|
|
333
|
+
pruneCounter = 0;
|
|
334
|
+
}
|
|
335
|
+
return { transport, cleanup };
|
|
336
|
+
}
|