@dreb/dashboard 2.41.0 → 2.42.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/README.md +47 -11
- package/dist/server/event-hub.d.ts +70 -14
- package/dist/server/event-hub.d.ts.map +1 -1
- package/dist/server/event-hub.js +170 -43
- package/dist/server/event-hub.js.map +1 -1
- package/dist/server/runtime-pool.d.ts +39 -0
- package/dist/server/runtime-pool.d.ts.map +1 -1
- package/dist/server/runtime-pool.js +78 -0
- package/dist/server/runtime-pool.js.map +1 -1
- package/dist/server/server.d.ts +6 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +242 -22
- package/dist/server/server.js.map +1 -1
- package/dist/shared/protocol.d.ts +51 -0
- package/dist/shared/protocol.d.ts.map +1 -1
- package/dist/shared/protocol.js +2 -0
- package/dist/shared/protocol.js.map +1 -1
- package/dist/static/assets/index-C36pWEQW.css +1 -0
- package/dist/static/assets/{index-Eu81MSE7.js → index-CBUAw_qM.js} +30 -30
- package/dist/static/index.html +2 -2
- package/dist/static/sw.js +1 -1
- package/package.json +1 -1
- package/dist/static/assets/index-u6OwgXeH.css +0 -1
package/dist/server/server.js
CHANGED
|
@@ -6,16 +6,58 @@
|
|
|
6
6
|
* caller decides the bind address; `createDashboardServer` never listens by
|
|
7
7
|
* itself. Remote mode still passes every request through DashboardAuth.
|
|
8
8
|
*/
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
9
10
|
import { existsSync } from "node:fs";
|
|
10
11
|
import { homedir } from "node:os";
|
|
11
12
|
import { basename, join } from "node:path";
|
|
12
13
|
import express from "express";
|
|
13
|
-
import { MAX_PROMPT_BODY_BYTES } from "../shared/protocol.js";
|
|
14
|
-
import { EventHub } from "./event-hub.js";
|
|
14
|
+
import { MAX_CLIENT_DIAGNOSTIC_BYTES, MAX_PROMPT_BODY_BYTES } from "../shared/protocol.js";
|
|
15
|
+
import { EventHub, formatHeartbeatFrame } from "./event-hub.js";
|
|
15
16
|
import { defaultPlaces, FileApi } from "./files.js";
|
|
16
17
|
import { readSubagentMessages } from "./subagent-log.js";
|
|
17
18
|
const DEVICE_COOKIE = "dreb_dashboard_device";
|
|
18
19
|
export const MAX_SSE_BUFFERED_BYTES = 4 * 1024 * 1024;
|
|
20
|
+
export const CLIENT_DIAGNOSTIC_RATE_LIMIT_MS = 30_000;
|
|
21
|
+
const CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS = 10 * 60_000;
|
|
22
|
+
function isClientDiagnostic(value) {
|
|
23
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
24
|
+
return false;
|
|
25
|
+
const body = value;
|
|
26
|
+
const allowed = new Set([
|
|
27
|
+
"connectionId",
|
|
28
|
+
"state",
|
|
29
|
+
"previousState",
|
|
30
|
+
"attempt",
|
|
31
|
+
"delayMs",
|
|
32
|
+
"visibility",
|
|
33
|
+
"lastAppliedSeq",
|
|
34
|
+
"heartbeatAgeMs",
|
|
35
|
+
"eventCount",
|
|
36
|
+
"eventRatePerMinute",
|
|
37
|
+
"processingLagTotalMs",
|
|
38
|
+
"processingLagMaxMs",
|
|
39
|
+
]);
|
|
40
|
+
if (Object.keys(body).some((key) => !allowed.has(key)))
|
|
41
|
+
return false;
|
|
42
|
+
const states = new Set(["connecting", "connected", "retrying", "resyncing", "disconnected", "auth_failed"]);
|
|
43
|
+
const nonNegativeNumber = (item) => typeof item === "number" && Number.isFinite(item) && item >= 0;
|
|
44
|
+
const nonNegativeInteger = (item) => typeof item === "number" && Number.isSafeInteger(item) && item >= 0;
|
|
45
|
+
return (typeof body.connectionId === "string" &&
|
|
46
|
+
/^[0-9a-f-]{36}$/i.test(body.connectionId) &&
|
|
47
|
+
typeof body.state === "string" &&
|
|
48
|
+
states.has(body.state) &&
|
|
49
|
+
(body.previousState === undefined ||
|
|
50
|
+
(typeof body.previousState === "string" && states.has(body.previousState))) &&
|
|
51
|
+
nonNegativeInteger(body.attempt) &&
|
|
52
|
+
nonNegativeNumber(body.eventCount) &&
|
|
53
|
+
nonNegativeNumber(body.eventRatePerMinute) &&
|
|
54
|
+
nonNegativeNumber(body.processingLagTotalMs) &&
|
|
55
|
+
nonNegativeNumber(body.processingLagMaxMs) &&
|
|
56
|
+
(body.delayMs === undefined || nonNegativeNumber(body.delayMs)) &&
|
|
57
|
+
(body.lastAppliedSeq === undefined || nonNegativeInteger(body.lastAppliedSeq)) &&
|
|
58
|
+
(body.heartbeatAgeMs === undefined || nonNegativeNumber(body.heartbeatAgeMs)) &&
|
|
59
|
+
(body.visibility === "visible" || body.visibility === "hidden"));
|
|
60
|
+
}
|
|
19
61
|
/** Parse the device cookie from a Cookie header. */
|
|
20
62
|
export function parseDeviceCookie(cookieHeader) {
|
|
21
63
|
if (!cookieHeader)
|
|
@@ -32,15 +74,25 @@ export function parseDeviceCookie(cookieHeader) {
|
|
|
32
74
|
export function createDashboardServer(options) {
|
|
33
75
|
const { auth, pool } = options;
|
|
34
76
|
const serverStartedAt = new Date().toISOString();
|
|
77
|
+
const diagnosticConnections = new Map();
|
|
35
78
|
const log = options.logger ?? ((line) => console.log(`[dashboard] ${line}`));
|
|
36
79
|
const files = new FileApi((op, path, detail) => log(`file ${op}: ${path}${detail ? ` (${detail})` : ""}`));
|
|
37
|
-
const hub = new EventHub();
|
|
38
|
-
pool.onEvent((key, event) =>
|
|
80
|
+
const hub = options.eventHub ?? new EventHub();
|
|
81
|
+
pool.onEvent((key, event) => {
|
|
82
|
+
if (event.type === "dashboard_snapshot_barrier" && typeof event.snapshotId === "string") {
|
|
83
|
+
// This RPC marker has no browser frame: its synchronous sequence capture
|
|
84
|
+
// orders the HTTP snapshot before all later EventHub publications.
|
|
85
|
+
pool.recordDashboardBarrier(key, event.snapshotId, hub.currentSequence);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
hub.publish(key, event);
|
|
89
|
+
});
|
|
39
90
|
const app = express();
|
|
40
91
|
app.disable("x-powered-by");
|
|
41
|
-
app.use(express.json({ limit: MAX_PROMPT_BODY_BYTES }));
|
|
42
92
|
// -- auth middleware (every route, fail-closed) ---------------------------
|
|
43
93
|
app.use((req, res, next) => {
|
|
94
|
+
if (req.path === "/api/events")
|
|
95
|
+
req.sseConnectionId = randomUUID();
|
|
44
96
|
auth
|
|
45
97
|
.authenticate({
|
|
46
98
|
remoteAddress: req.socket.remoteAddress,
|
|
@@ -65,7 +117,18 @@ export function createDashboardServer(options) {
|
|
|
65
117
|
if (req.method === "GET" && !req.path.startsWith("/api/"))
|
|
66
118
|
return next();
|
|
67
119
|
}
|
|
68
|
-
|
|
120
|
+
if (req.sseConnectionId) {
|
|
121
|
+
log(`sse ${JSON.stringify({
|
|
122
|
+
connectionId: req.sseConnectionId,
|
|
123
|
+
kind: "auth_denial",
|
|
124
|
+
method: req.method,
|
|
125
|
+
path: req.path,
|
|
126
|
+
status: decision.status,
|
|
127
|
+
})}`);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
log(`denied ${req.method} ${req.path}: ${decision.reason}`);
|
|
131
|
+
}
|
|
69
132
|
res.status(decision.status).json({
|
|
70
133
|
error: decision.reason,
|
|
71
134
|
needsPairing: decision.needsPairing ?? false,
|
|
@@ -78,6 +141,17 @@ export function createDashboardServer(options) {
|
|
|
78
141
|
res.status(500).json({ error: "Auth subsystem error — denied" });
|
|
79
142
|
});
|
|
80
143
|
});
|
|
144
|
+
// Authenticate before consuming request bodies. Diagnostics have their own
|
|
145
|
+
// small parser limit; the larger limit exists only for prompt image payloads.
|
|
146
|
+
app.use("/api/events/diagnostic", express.json({ limit: MAX_CLIENT_DIAGNOSTIC_BYTES }));
|
|
147
|
+
app.use(express.json({ limit: MAX_PROMPT_BODY_BYTES }));
|
|
148
|
+
app.use((err, _req, res, next) => {
|
|
149
|
+
if (err.type === "entity.too.large") {
|
|
150
|
+
res.status(413).json({ error: "Request body is too large" });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
next(err);
|
|
154
|
+
});
|
|
81
155
|
// -- auth/pairing ----------------------------------------------------------
|
|
82
156
|
app.get("/api/auth", (req, res) => {
|
|
83
157
|
const decision = req.authDecision;
|
|
@@ -153,43 +227,189 @@ export function createDashboardServer(options) {
|
|
|
153
227
|
});
|
|
154
228
|
// -- events (SSE) ----------------------------------------------------------
|
|
155
229
|
app.get("/api/events", (req, res) => {
|
|
230
|
+
const connectionId = req.sseConnectionId ?? randomUUID();
|
|
231
|
+
const diagnostic = (kind, metadata = {}) => log(`sse ${JSON.stringify({ connectionId, kind, ...metadata })}`);
|
|
156
232
|
res.writeHead(200, {
|
|
157
233
|
"content-type": "text/event-stream",
|
|
158
234
|
"cache-control": "no-cache",
|
|
159
235
|
connection: "keep-alive",
|
|
160
236
|
});
|
|
161
|
-
const guardedWrite = (chunk,
|
|
162
|
-
if (res.destroyed || res.writableEnded)
|
|
237
|
+
const guardedWrite = (chunk, metadata) => {
|
|
238
|
+
if (res.destroyed || res.writableEnded) {
|
|
239
|
+
diagnostic("write_closed", { writeKind: metadata.kind });
|
|
163
240
|
return false;
|
|
241
|
+
}
|
|
164
242
|
const accepted = res.write(chunk);
|
|
243
|
+
const details = {
|
|
244
|
+
writeKind: metadata.kind,
|
|
245
|
+
...("seq" in metadata
|
|
246
|
+
? { seq: metadata.seq, type: metadata.type, frameBytes: metadata.frameBytes, reason: metadata.reason }
|
|
247
|
+
: {}),
|
|
248
|
+
writableLength: res.writableLength,
|
|
249
|
+
};
|
|
250
|
+
diagnostic("write", details);
|
|
165
251
|
if (!accepted && res.writableLength > MAX_SSE_BUFFERED_BYTES) {
|
|
166
|
-
|
|
252
|
+
diagnostic("backpressure", details);
|
|
167
253
|
res.destroy();
|
|
168
254
|
return false;
|
|
169
255
|
}
|
|
170
256
|
return true;
|
|
171
257
|
};
|
|
172
|
-
if (!guardedWrite(":ok\n\n", "initial handshake"))
|
|
173
|
-
return;
|
|
174
258
|
const lastIdRaw = req.headers["last-event-id"] ?? req.query.lastEventId;
|
|
175
259
|
const lastEventId = typeof lastIdRaw === "string" && /^\d+$/.test(lastIdRaw) ? Number.parseInt(lastIdRaw, 10) : undefined;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
260
|
+
diagnostic("connect", { cursor: lastEventId });
|
|
261
|
+
if (!guardedWrite(":ok\n\n", { kind: "handshake" }))
|
|
262
|
+
return;
|
|
263
|
+
// Unnumbered connection metadata lets a browser correlate optional,
|
|
264
|
+
// payload-free diagnostics without mutating its application SSE cursor.
|
|
265
|
+
const issuedAt = Date.now();
|
|
266
|
+
for (const [id, record] of diagnosticConnections) {
|
|
267
|
+
if (issuedAt - record.issuedAt > CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS)
|
|
268
|
+
diagnosticConnections.delete(id);
|
|
269
|
+
}
|
|
270
|
+
diagnosticConnections.set(connectionId, { issuedAt });
|
|
271
|
+
if (!guardedWrite(`event: connection\ndata: ${JSON.stringify({ connectionId })}\n\n`, { kind: "connection" }))
|
|
272
|
+
return;
|
|
273
|
+
let detach = () => { };
|
|
274
|
+
let keepAlive;
|
|
275
|
+
const stop = () => {
|
|
276
|
+
if (keepAlive)
|
|
277
|
+
clearInterval(keepAlive);
|
|
182
278
|
detach();
|
|
279
|
+
};
|
|
280
|
+
let usable = true;
|
|
281
|
+
detach = hub.attach({
|
|
282
|
+
write: (chunk, metadata) => {
|
|
283
|
+
if (!metadata)
|
|
284
|
+
return false;
|
|
285
|
+
usable = guardedWrite(chunk, metadata);
|
|
286
|
+
return usable;
|
|
287
|
+
},
|
|
288
|
+
}, lastEventId, (replay) => diagnostic(replay.kind, replay));
|
|
289
|
+
// A rejected/destroyed replay must not leave a timer or live client behind.
|
|
290
|
+
if (!usable)
|
|
291
|
+
return;
|
|
292
|
+
// Named heartbeats are visible to EventSource but have no id, so they do
|
|
293
|
+
// not alter the application cursor or consume replay history.
|
|
294
|
+
keepAlive = setInterval(() => {
|
|
295
|
+
if (!guardedWrite(formatHeartbeatFrame(), { kind: "heartbeat" }))
|
|
296
|
+
stop();
|
|
297
|
+
}, options.heartbeatIntervalMs ?? 25_000);
|
|
298
|
+
req.on("close", () => {
|
|
299
|
+
diagnostic("close", { writableLength: res.writableLength });
|
|
300
|
+
stop();
|
|
183
301
|
});
|
|
184
302
|
});
|
|
303
|
+
// -- optional client stream diagnostics -----------------------------------
|
|
304
|
+
app.post("/api/events/diagnostic", (req, res) => {
|
|
305
|
+
const declaredLength = Number(req.headers["content-length"] ?? 0);
|
|
306
|
+
const encodedBytes = Buffer.byteLength(JSON.stringify(req.body ?? null));
|
|
307
|
+
if (declaredLength > MAX_CLIENT_DIAGNOSTIC_BYTES || encodedBytes > MAX_CLIENT_DIAGNOSTIC_BYTES) {
|
|
308
|
+
res.status(413).json({ error: "Diagnostic summary exceeds the 4 KiB limit" });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (!isClientDiagnostic(req.body)) {
|
|
312
|
+
res.status(400).json({ error: "Invalid diagnostic summary" });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
for (const [id, record] of diagnosticConnections) {
|
|
317
|
+
if (now - record.issuedAt > CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS)
|
|
318
|
+
diagnosticConnections.delete(id);
|
|
319
|
+
}
|
|
320
|
+
const record = diagnosticConnections.get(req.body.connectionId);
|
|
321
|
+
if (!record) {
|
|
322
|
+
res.status(400).json({ error: "Unknown or expired SSE connection" });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (record.lastAt !== undefined && now - record.lastAt < CLIENT_DIAGNOSTIC_RATE_LIMIT_MS) {
|
|
326
|
+
res.status(429).json({ error: "Diagnostic summary rate limited" });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
record.lastAt = now;
|
|
330
|
+
// Never log the request body wholesale. The schema is intentionally only
|
|
331
|
+
// connection metadata, and this explicit projection prevents future fields
|
|
332
|
+
// from accidentally turning diagnostics into a payload side-channel.
|
|
333
|
+
log(`sse ${JSON.stringify({
|
|
334
|
+
connectionId: req.body.connectionId,
|
|
335
|
+
kind: "client_diagnostic",
|
|
336
|
+
state: req.body.state,
|
|
337
|
+
previousState: req.body.previousState,
|
|
338
|
+
attempt: req.body.attempt,
|
|
339
|
+
delayMs: req.body.delayMs,
|
|
340
|
+
visibility: req.body.visibility,
|
|
341
|
+
lastAppliedSeq: req.body.lastAppliedSeq,
|
|
342
|
+
heartbeatAgeMs: req.body.heartbeatAgeMs,
|
|
343
|
+
eventCount: req.body.eventCount,
|
|
344
|
+
eventRatePerMinute: req.body.eventRatePerMinute,
|
|
345
|
+
processingLagTotalMs: req.body.processingLagTotalMs,
|
|
346
|
+
processingLagMaxMs: req.body.processingLagMaxMs,
|
|
347
|
+
})}`);
|
|
348
|
+
res.json({ ok: true });
|
|
349
|
+
});
|
|
185
350
|
// -- fleet -----------------------------------------------------------------
|
|
351
|
+
const getFleet = async () => {
|
|
352
|
+
const runtimes = await Promise.all(pool.list().map((h) => pool.describe(h)));
|
|
353
|
+
const diskSessions = (await options.listAllSessions()).filter((session) => existsSync(session.cwd));
|
|
354
|
+
return { runtimes, diskSessions };
|
|
355
|
+
};
|
|
186
356
|
app.get("/api/fleet", (_req, res) => {
|
|
357
|
+
getFleet()
|
|
358
|
+
.then((fleet) => res.json(fleet))
|
|
359
|
+
.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));
|
|
360
|
+
});
|
|
361
|
+
/**
|
|
362
|
+
* Full recovery snapshot. For an active runtime, its RPC marker captures the
|
|
363
|
+
* current EventHub sequence before the response; later publications have a
|
|
364
|
+
* higher sequence. This is an ordering contract, not a timing heuristic.
|
|
365
|
+
*/
|
|
366
|
+
app.get("/api/resync", (req, res) => {
|
|
187
367
|
(async () => {
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
368
|
+
const activeKey = typeof req.query.key === "string" ? req.query.key : undefined;
|
|
369
|
+
const activeAgentId = typeof req.query.agentId === "string" ? req.query.agentId : undefined;
|
|
370
|
+
let active;
|
|
371
|
+
let barrierSeq;
|
|
372
|
+
if (activeKey) {
|
|
373
|
+
const handle = pool.get(activeKey);
|
|
374
|
+
if (!handle) {
|
|
375
|
+
const body = { fleet: await getFleet(), barrierSeq: hub.currentSequence };
|
|
376
|
+
res.json(body);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
// The disk transcript has its own sequence boundary because it is read
|
|
380
|
+
// before the parent RPC snapshot. Relays between these two barriers must
|
|
381
|
+
// be reapplied so a subagent delta cannot disappear during recovery.
|
|
382
|
+
let preBarrierSubagent;
|
|
383
|
+
if (activeAgentId) {
|
|
384
|
+
const agents = await handle.client.listBackgroundAgents();
|
|
385
|
+
const agent = agents.find((candidate) => candidate.agentId === activeAgentId);
|
|
386
|
+
if (!agent)
|
|
387
|
+
throw new Error(`No background agent ${activeAgentId} in this runtime`);
|
|
388
|
+
const messages = readSubagentMessages(agent);
|
|
389
|
+
preBarrierSubagent = {
|
|
390
|
+
agentId: activeAgentId,
|
|
391
|
+
agent,
|
|
392
|
+
messages,
|
|
393
|
+
barrierSeq: hub.currentSequence,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const snapshot = await pool.snapshotDashboard(handle);
|
|
397
|
+
barrierSeq = snapshot.barrierSeq;
|
|
398
|
+
active = {
|
|
399
|
+
key: activeKey,
|
|
400
|
+
state: snapshot.snapshot.state,
|
|
401
|
+
messages: snapshot.snapshot.messages,
|
|
402
|
+
backgroundAgents: snapshot.snapshot.backgroundAgents,
|
|
403
|
+
barrierSeq,
|
|
404
|
+
...(preBarrierSubagent ? { subagent: preBarrierSubagent } : {}),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
barrierSeq = hub.currentSequence;
|
|
409
|
+
}
|
|
410
|
+
const body = { fleet: await getFleet(), ...(active ? { active } : {}), barrierSeq };
|
|
411
|
+
res.json(body);
|
|
412
|
+
})().catch((err) => res.status(502).json({ error: String(err?.message ?? err) }));
|
|
193
413
|
});
|
|
194
414
|
// -- runtimes ---------------------------------------------------------------
|
|
195
415
|
app.post("/api/runtimes", (req, res) => {
|