@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.
@@ -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) => hub.publish(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
- log(`denied ${req.method} ${req.path}: ${decision.reason}`);
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, context) => {
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
- log(`SSE client buffer exceeded ${MAX_SSE_BUFFERED_BYTES} bytes during ${context} (${res.writableLength} bytes queued); destroying connection`);
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
- const detach = hub.attach({ write: (chunk) => guardedWrite(chunk, "event fanout") }, lastEventId);
177
- const keepAlive = setInterval(() => {
178
- guardedWrite(":ka\n\n", "keepalive");
179
- }, 25_000);
180
- req.on("close", () => {
181
- clearInterval(keepAlive);
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 runtimes = await Promise.all(pool.list().map((h) => pool.describe(h)));
189
- const diskSessions = (await options.listAllSessions()).filter((session) => existsSync(session.cwd));
190
- const fleet = { runtimes, diskSessions };
191
- res.json(fleet);
192
- })().catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));
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) => {