@galdor/dashboard 0.3.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/dist/index.js ADDED
@@ -0,0 +1,1683 @@
1
+ /**
2
+ * Embedded observability dashboard for galdor.
3
+ *
4
+ * A self-contained web UI served over either {@link Bun.serve} (under Bun) or
5
+ * `node:http` (under Node), chosen at runtime by {@link startDashboard}. It reads
6
+ * the SQLite span store that the CLI and exporter write to and renders a full
7
+ * trace explorer:
8
+ *
9
+ * - `GET /` run list with a live (SSE) tail
10
+ * - `GET /runs/:id` run detail: timeline, span tree, graph topology
11
+ * - `GET /runs/:id/steps` step-by-step walkthrough of the run
12
+ * - `GET /runs/:id/spans/:spanId` one span: metadata, attributes, captured messages
13
+ * - `GET /api/runs` JSON run summaries
14
+ * - `GET /api/runs/:id/spans` JSON spans for a run
15
+ * - `GET /api/runs/:id/spans/:spanId`JSON for a single span
16
+ * - `GET /api/runs/:id/graph` JSON graph topology recorded for a run
17
+ * - `GET /api/orphans` orphan-span count
18
+ * - `GET /events` Server-Sent Events live-tail of the run list
19
+ *
20
+ * All HTML, CSS, and the one small client script are inlined in this module —
21
+ * the interactive timeline and graph are rendered server-side as SVG, so the UI
22
+ * works without any client-side framework and ships as a single importable unit.
23
+ *
24
+ * @see {@link startDashboard} to launch the server.
25
+ * @see {@link createHandler} to obtain the request handler on its own.
26
+ */
27
+ import { createServer } from "node:http";
28
+ import { isIP } from "node:net";
29
+ import { END, START } from "@galdor/core/graph";
30
+ import { runDuration, runStatus, spanDuration, Store } from "@galdor/core/store";
31
+ /** `true` when running on the Bun runtime; `false` under Node (or any other host). */
32
+ const isBun = typeof globalThis.Bun !== "undefined";
33
+ /**
34
+ * Attribute keys and span names recorded by the tracing layer. These mirror the
35
+ * constants exported from `@galdor/core/observability`; they are kept local so
36
+ * the viewer depends only on the span store, not on the tracing SDK.
37
+ */
38
+ const A = {
39
+ node: "galdor.node.name",
40
+ label: "galdor.span.label",
41
+ provider: "galdor.provider.name",
42
+ streaming: "galdor.provider.streaming",
43
+ step: "galdor.step",
44
+ prompt: "gen_ai.prompt",
45
+ completion: "gen_ai.completion",
46
+ reasoning: "gen_ai.reasoning",
47
+ reqModel: "gen_ai.request.model",
48
+ respModel: "gen_ai.response.model",
49
+ finish: "gen_ai.response.finish_reasons",
50
+ inTokens: "gen_ai.usage.input_tokens",
51
+ outTokens: "gen_ai.usage.output_tokens",
52
+ toolName: "gen_ai.tool.name",
53
+ toolIn: "gen_ai.tool.input_size_bytes",
54
+ toolOut: "gen_ai.tool.output_size_bytes",
55
+ reqTools: "gen_ai.request.tools",
56
+ system: "gen_ai.system",
57
+ };
58
+ const SPAN = {
59
+ run: "galdor.graph.run",
60
+ node: "galdor.graph.node",
61
+ generate: "galdor.provider.generate",
62
+ stream: "galdor.provider.stream",
63
+ tool: "galdor.tool.execute",
64
+ };
65
+ /** Map a span name to its display kind. */
66
+ function kindOf(name) {
67
+ if (name === SPAN.run)
68
+ return "run";
69
+ if (name === SPAN.node)
70
+ return "node";
71
+ if (name === SPAN.generate || name === SPAN.stream)
72
+ return "model";
73
+ if (name === SPAN.tool)
74
+ return "tool";
75
+ return "other";
76
+ }
77
+ /** CSS custom-property colour for each span kind. */
78
+ const KIND_FILL = {
79
+ run: "var(--accent)",
80
+ node: "var(--slate)",
81
+ model: "var(--blue)",
82
+ tool: "var(--violet)",
83
+ other: "var(--subtle)",
84
+ };
85
+ /** Convert a nanosecond count to whole milliseconds. */
86
+ const nanosToMs = (n) => Number(n / 1000000n);
87
+ /** Like {@link JSON.stringify} but renders `bigint` values as decimal numbers (safe at millisecond scale). */
88
+ function toJSON(value) {
89
+ return JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? Number(v) : v));
90
+ }
91
+ /** Wrap a value as a JSON {@link Response} with the given status. */
92
+ function json(value, status = 200) {
93
+ return new Response(toJSON(value), { status, headers: { "content-type": "application/json" } });
94
+ }
95
+ /** Wrap an HTML body as a UTF-8 {@link Response} with the given status. */
96
+ function html(body, status = 200) {
97
+ return new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8" } });
98
+ }
99
+ /** Wrap an SVG document as an `image/svg+xml` {@link Response}. */
100
+ function svg(body) {
101
+ return new Response(body, { headers: { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "no-cache" } });
102
+ }
103
+ // ── request guards (caps, limits, host allowlist) ────────────────────────────────
104
+ /**
105
+ * Body-size cap for `POST /api/graph/svg`. A spec larger than this is almost
106
+ * certainly an attempt to amplify a small POST into a multi-megabyte SVG
107
+ * buffered in memory, so it is rejected before decoding.
108
+ */
109
+ const MAX_GRAPH_SVG_BODY = 1 << 20; // 1 MiB
110
+ /**
111
+ * Node-count cap for a rendered graph. The SVG grows with the node count, so
112
+ * this bounds peak render memory even for a body that fits under
113
+ * {@link MAX_GRAPH_SVG_BODY}.
114
+ */
115
+ const MAX_GRAPH_NODES = 2000;
116
+ /** Floor applied to the SSE poll cadence so a caller can't drive a hot loop against SQLite. */
117
+ const MIN_STREAM_INTERVAL_MS = 100;
118
+ /**
119
+ * DNS-rebinding protection. A rebinding attack relies on a domain name (which
120
+ * the attacker can re-point at `127.0.0.1`) in the `Host` header, so only
121
+ * `localhost`, IP literals (which can't be rebound) and explicitly allowed
122
+ * hostnames are accepted. A bare/absent host is treated as loopback.
123
+ *
124
+ * @param hostname - The request URL hostname (IPv6 arrives bracketed).
125
+ * @param allowed - Extra hostnames to accept beyond localhost/IP literals.
126
+ */
127
+ function hostAllowed(hostname, allowed) {
128
+ let host = hostname;
129
+ if (host.startsWith("[") && host.endsWith("]"))
130
+ host = host.slice(1, -1); // unwrap IPv6 literal
131
+ if (host === "" || host === "localhost")
132
+ return true;
133
+ if (isIP(host) !== 0)
134
+ return true;
135
+ return allowed.has(host);
136
+ }
137
+ /** Parse a `?limit`, clamping to `1..500`; blank/invalid falls back to `fallback`. */
138
+ function parseLimit(raw, fallback) {
139
+ if (!raw || !/^-?\d+$/.test(raw.trim()))
140
+ return fallback;
141
+ const n = Number.parseInt(raw, 10);
142
+ if (n <= 0)
143
+ return fallback;
144
+ return n > 500 ? 500 : n;
145
+ }
146
+ /** Parse a Go-style duration (`500ms`, `2s`, `1us`) to milliseconds, or `undefined`. */
147
+ function parseDurationMs(raw) {
148
+ const m = raw.trim().match(/^(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)$/);
149
+ if (!m)
150
+ return undefined;
151
+ const factor = { ns: 1e-6, us: 1e-3, "µs": 1e-3, ms: 1, s: 1000, m: 60000, h: 3600000 };
152
+ return Number(m[1]) * factor[m[2]];
153
+ }
154
+ /**
155
+ * Parse the SSE `?interval`: a Go-style duration or a plain millisecond count,
156
+ * defaulting to `fallbackMs` and floored at {@link MIN_STREAM_INTERVAL_MS}.
157
+ */
158
+ function parseInterval(raw, fallbackMs) {
159
+ let ms = fallbackMs;
160
+ if (raw) {
161
+ const dur = parseDurationMs(raw);
162
+ if (dur !== undefined && dur > 0) {
163
+ ms = dur;
164
+ }
165
+ else {
166
+ const n = Number.parseInt(raw, 10);
167
+ if (Number.isFinite(n) && n > 0)
168
+ ms = n;
169
+ }
170
+ }
171
+ return ms < MIN_STREAM_INTERVAL_MS ? MIN_STREAM_INTERVAL_MS : ms;
172
+ }
173
+ /** Parse the test-only `_max-iterations` bound; `0` (the default) means run until the client leaves. */
174
+ function parseMaxIters(raw) {
175
+ if (!raw || !/^-?\d+$/.test(raw.trim()))
176
+ return 0;
177
+ return Number.parseInt(raw, 10);
178
+ }
179
+ /**
180
+ * Build the dashboard request handler bound to a span store.
181
+ *
182
+ * The returned function routes requests to the HTML pages, JSON endpoints, and
183
+ * the SSE live-tail described in the module overview, and answers `404` for any
184
+ * unmatched path. Use it directly when embedding the dashboard in an existing
185
+ * server, or let {@link startDashboard} wire it into {@link Bun.serve} for you.
186
+ *
187
+ * Requests whose `Host` header is a domain name (rather than `localhost` or an
188
+ * IP literal) are rejected with `403` as a DNS-rebinding guard; extend the
189
+ * allowlist via {@link HandlerOptions.allowedHosts}.
190
+ *
191
+ * @param store - The {@link Store} whose runs and spans are served.
192
+ * @param opts - Optional {@link HandlerOptions}.
193
+ * @returns A fetch handler that maps a {@link Request} to a {@link Response}.
194
+ * @example
195
+ * ```ts
196
+ * const handler = createHandler(Store.openExisting("spans.db"));
197
+ * const res = handler(new Request("http://127.0.0.1/api/runs"));
198
+ * ```
199
+ */
200
+ export function createHandler(store, opts = {}) {
201
+ const allowedHosts = new Set(opts.allowedHosts ?? []);
202
+ return (req) => {
203
+ const url = new URL(req.url);
204
+ const path = url.pathname;
205
+ // DNS-rebinding guard: reject any Host that isn't loopback / an IP literal.
206
+ if (!hostAllowed(url.hostname, allowedHosts))
207
+ return new Response("forbidden host", { status: 403 });
208
+ // Health check for liveness probes.
209
+ if (path === "/healthz")
210
+ return new Response("ok", { headers: { "content-type": "text/plain" } });
211
+ // Render an arbitrary client-supplied graph spec to SVG (not tied to a run).
212
+ if (path === "/api/graph/svg" && req.method === "POST")
213
+ return renderPostedGraphSVG(req, url);
214
+ if (path === "/")
215
+ return html(runListPage(store, parseLimit(url.searchParams.get("limit"), 50)));
216
+ if (path === "/graph")
217
+ return html(graphExplorerPage(store, url));
218
+ if (path === "/api/runs")
219
+ return json(runListJSON(store, parseLimit(url.searchParams.get("limit"), 50)));
220
+ if (path === "/api/orphans")
221
+ return json({ orphans: store.orphanSpanCount() });
222
+ if (path === "/events")
223
+ return sseLiveTail(store, url);
224
+ let m = path.match(/^\/api\/runs\/([^/]+)\/spans\/([^/]+)$/);
225
+ if (m) {
226
+ const s = findSpan(store, decodeURIComponent(m[1]), decodeURIComponent(m[2]));
227
+ return s ? json(spanJSON(s)) : json({ error: "span not found" }, 404);
228
+ }
229
+ m = path.match(/^\/api\/runs\/([^/]+)\/spans$/);
230
+ if (m) {
231
+ const runId = decodeURIComponent(m[1]);
232
+ const spans = store.spansForRun(runId);
233
+ if (spans.length === 0)
234
+ return json({ error: `run ${runId} not found` }, 404);
235
+ return json(spans.map(spanJSON));
236
+ }
237
+ m = path.match(/^\/api\/runs\/([^/]+)\/graph\/model$/);
238
+ if (m) {
239
+ const id = decodeURIComponent(m[1]);
240
+ const spec = graphSpec(store, id);
241
+ return spec
242
+ ? json(graphModel(spec, store.spansForRun(id)))
243
+ : json({ error: "no graph topology recorded for this run" }, 404);
244
+ }
245
+ m = path.match(/^\/api\/runs\/([^/]+)\/graph\/svg$/);
246
+ if (m) {
247
+ const id = decodeURIComponent(m[1]);
248
+ const spec = graphSpec(store, id);
249
+ if (!spec)
250
+ return json({ error: "no graph topology recorded for this run" }, 404);
251
+ const theme = url.searchParams.get("theme") === "dark" ? "dark" : "light";
252
+ return svg(graphSVG(id, spec, store.spansForRun(id), { standalone: true, theme }));
253
+ }
254
+ m = path.match(/^\/api\/runs\/([^/]+)\/graph$/);
255
+ if (m) {
256
+ const spec = graphSpec(store, decodeURIComponent(m[1]));
257
+ return spec ? json(spec) : json({ error: "no graph topology recorded for this run" }, 404);
258
+ }
259
+ m = path.match(/^\/runs\/([^/]+)\/spans\/([^/]+)$/);
260
+ if (m) {
261
+ const runId = decodeURIComponent(m[1]), spanId = decodeURIComponent(m[2]);
262
+ const found = findSpan(store, runId, spanId);
263
+ return html(spanDetailPage(store, runId, spanId), found ? 200 : 404);
264
+ }
265
+ m = path.match(/^\/runs\/([^/]+)\/steps$/);
266
+ if (m) {
267
+ const runId = decodeURIComponent(m[1]);
268
+ const exists = store.spansForRun(runId).length > 0;
269
+ return html(stepsPage(store, runId), exists ? 200 : 404);
270
+ }
271
+ m = path.match(/^\/runs\/([^/]+)$/);
272
+ if (m) {
273
+ const runId = decodeURIComponent(m[1]);
274
+ const exists = store.spansForRun(runId).length > 0;
275
+ return html(runDetailPage(store, runId), exists ? 200 : 404);
276
+ }
277
+ return new Response("not found", { status: 404 });
278
+ };
279
+ }
280
+ /**
281
+ * Start the dashboard HTTP server.
282
+ *
283
+ * Serves from {@link DashboardOptions.store} when provided, otherwise opens the
284
+ * store at {@link DashboardOptions.dbPath} (defaulting to an in-memory store).
285
+ *
286
+ * The transport is chosen by runtime: on Bun the server is backed by
287
+ * {@link Bun.serve}; on Node it is backed by `node:http`, with an adapter that
288
+ * converts each incoming request into a Web {@link Request}, runs the same
289
+ * {@link createHandler} handler, and streams the Web {@link Response} back —
290
+ * including unbounded Server-Sent Events bodies. Either way the return value
291
+ * conforms to {@link DashboardServer}.
292
+ *
293
+ * @param opts - Server and store {@link DashboardOptions | options}.
294
+ * @returns The running {@link DashboardServer}; call `.stop()` to shut it down.
295
+ * @example
296
+ * ```ts
297
+ * const server = startDashboard({ dbPath: "spans.db", port: 7777 });
298
+ * // ...later
299
+ * server.stop();
300
+ * ```
301
+ */
302
+ export function startDashboard(opts = {}) {
303
+ const store = opts.store ?? Store.openExisting(opts.dbPath ?? ":memory:");
304
+ const handler = createHandler(store);
305
+ const hostname = opts.hostname ?? "127.0.0.1";
306
+ const port = opts.port ?? 7777;
307
+ if (isBun) {
308
+ const server = Bun.serve({ hostname, port, fetch: handler });
309
+ return {
310
+ get port() {
311
+ return server.port ?? port;
312
+ },
313
+ get hostname() {
314
+ return server.hostname ?? hostname;
315
+ },
316
+ // Bun resolves the ephemeral port synchronously, so the server is ready at once.
317
+ ready: Promise.resolve(),
318
+ stop(force) {
319
+ void server.stop(force);
320
+ },
321
+ };
322
+ }
323
+ return startNodeDashboard(handler, hostname, port);
324
+ }
325
+ // ── Node (node:http) transport ───────────────────────────────────────────────────
326
+ /**
327
+ * Back the dashboard with `node:http` when not running under Bun.
328
+ *
329
+ * For every request the Node {@link IncomingMessage} is adapted to a Web
330
+ * {@link Request} (see {@link toWebRequest}), passed through the shared handler,
331
+ * and the resulting Web {@link Response} is written back to the
332
+ * {@link ServerResponse} by streaming its body (see {@link writeWebResponse}).
333
+ * Open responses are tracked so {@link DashboardServer.stop | stop} can tear
334
+ * down live SSE streams and let the listener close.
335
+ */
336
+ function startNodeDashboard(handler, hostname, port) {
337
+ const active = new Set();
338
+ const server = createServer((req, res) => {
339
+ active.add(res);
340
+ res.on("close", () => active.delete(res));
341
+ void (async () => {
342
+ let webRes;
343
+ try {
344
+ webRes = await handler(toWebRequest(req, hostname, boundPort()));
345
+ }
346
+ catch (e) {
347
+ res.writeHead(500, { "content-type": "text/plain" });
348
+ res.end(e.message);
349
+ return;
350
+ }
351
+ await writeWebResponse(res, webRes);
352
+ })();
353
+ });
354
+ // node:http binds asynchronously: server.address() (and thus an ephemeral
355
+ // port) is null until the "listening" event fires. Expose that as `ready`.
356
+ const ready = new Promise((resolve) => server.once("listening", () => resolve()));
357
+ server.listen(port, hostname);
358
+ /** The OS-assigned port once listening, falling back to the requested port. */
359
+ const boundPort = () => {
360
+ const addr = server.address();
361
+ return addr && typeof addr === "object" ? addr.port : port;
362
+ };
363
+ return {
364
+ get port() {
365
+ return boundPort();
366
+ },
367
+ hostname,
368
+ ready,
369
+ stop() {
370
+ // Destroy live connections (notably open SSE streams) so close() can settle.
371
+ for (const res of active)
372
+ res.destroy();
373
+ active.clear();
374
+ server.close();
375
+ },
376
+ };
377
+ }
378
+ /**
379
+ * Adapt a Node {@link IncomingMessage} to a Web {@link Request}.
380
+ *
381
+ * Reconstructs the absolute URL from the `Host` header (or the bound address),
382
+ * copies method and headers, and — for methods that carry a body — wraps the
383
+ * request stream in a {@link ReadableStream}. The dashboard only serves `GET`,
384
+ * but body forwarding keeps the adapter general.
385
+ */
386
+ function toWebRequest(req, hostname, port) {
387
+ const host = req.headers.host ?? `${hostname}:${port}`;
388
+ const url = `http://${host}${req.url ?? "/"}`;
389
+ const method = req.method ?? "GET";
390
+ const headers = new Headers();
391
+ for (const [key, value] of Object.entries(req.headers)) {
392
+ if (value === undefined)
393
+ continue;
394
+ if (Array.isArray(value))
395
+ for (const v of value)
396
+ headers.append(key, v);
397
+ else
398
+ headers.set(key, value);
399
+ }
400
+ const hasBody = method !== "GET" && method !== "HEAD";
401
+ if (!hasBody)
402
+ return new Request(url, { method, headers });
403
+ const body = new ReadableStream({
404
+ start(controller) {
405
+ req.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk)));
406
+ req.on("end", () => controller.close());
407
+ req.on("error", (err) => controller.error(err));
408
+ },
409
+ });
410
+ // `duplex` is required when streaming a request body but is missing from the lib DOM types.
411
+ return new Request(url, { method, headers, body, duplex: "half" });
412
+ }
413
+ /**
414
+ * Write a Web {@link Response} back to a Node {@link ServerResponse}.
415
+ *
416
+ * The status and headers are flushed first, then the response body is streamed
417
+ * chunk-by-chunk. This handles both finite bodies (HTML/JSON, whose stream ends)
418
+ * and the unbounded SSE live-tail (whose stream only ends when the client
419
+ * disconnects): when the socket closes, the reader is cancelled, which runs the
420
+ * body's `cancel()` and clears the SSE interval. String chunks (the SSE handler
421
+ * enqueues strings) and byte chunks are both encoded to a {@link Buffer}.
422
+ */
423
+ async function writeWebResponse(res, webRes) {
424
+ const headers = {};
425
+ webRes.headers.forEach((value, key) => {
426
+ headers[key] = value;
427
+ });
428
+ res.writeHead(webRes.status, headers);
429
+ const body = webRes.body;
430
+ if (!body) {
431
+ res.end();
432
+ return;
433
+ }
434
+ const reader = body.getReader();
435
+ let cancelled = false;
436
+ const cancel = () => {
437
+ if (cancelled)
438
+ return;
439
+ cancelled = true;
440
+ void reader.cancel().catch(() => { });
441
+ };
442
+ // If the client hangs up (e.g. closes the SSE connection), stop pulling.
443
+ res.on("close", cancel);
444
+ try {
445
+ for (;;) {
446
+ const { done, value } = await reader.read();
447
+ if (done)
448
+ break;
449
+ const chunk = typeof value === "string" ? Buffer.from(value) : Buffer.from(value);
450
+ if (!res.write(chunk))
451
+ await new Promise((resolve) => res.once("drain", resolve));
452
+ }
453
+ }
454
+ catch {
455
+ // Stream errored or was aborted mid-flight — fall through to end the response.
456
+ }
457
+ if (!res.writableEnded)
458
+ res.end();
459
+ }
460
+ // ── JSON shaping ───────────────────────────────────────────────────────────────
461
+ function runSummaryJSON(r) {
462
+ return {
463
+ runId: r.runId,
464
+ traceId: r.traceId,
465
+ startMs: nanosToMs(r.startTimeUnixNano),
466
+ durationMs: nanosToMs(runDuration(r)),
467
+ spanCount: r.spanCount,
468
+ errorCount: r.errorCount,
469
+ status: runStatus(r),
470
+ };
471
+ }
472
+ function runListJSON(store, limit = 100) {
473
+ return store.listRuns(limit).map(runSummaryJSON);
474
+ }
475
+ /** Parse the graph topology recorded for a run, or `undefined` when none exists. */
476
+ function graphSpec(store, runId) {
477
+ const raw = store.getGraphSpec(runId);
478
+ if (raw === "")
479
+ return undefined;
480
+ try {
481
+ return normalizeSpec(JSON.parse(raw));
482
+ }
483
+ catch {
484
+ return undefined;
485
+ }
486
+ }
487
+ /**
488
+ * Render an arbitrary {@link GraphSpec} POSTed in the request body to SVG. Lets
489
+ * tools preview a topology without first recording a run. Accepts either spec
490
+ * encoding (see {@link normalizeSpec}); `?theme=dark|light` selects colours.
491
+ */
492
+ async function renderPostedGraphSVG(req, url) {
493
+ const text = await req.text();
494
+ // Reject an oversized body before decoding — a spec this large is almost
495
+ // certainly an attempt to amplify a small POST into a multi-MB SVG.
496
+ if (byteLength(text) > MAX_GRAPH_SVG_BODY) {
497
+ return json({ error: "spec too large: body exceeds limit" }, 400);
498
+ }
499
+ let body;
500
+ try {
501
+ body = JSON.parse(text);
502
+ }
503
+ catch (e) {
504
+ return json({ error: `invalid JSON body: ${e.message}` }, 400);
505
+ }
506
+ const spec = normalizeSpec(body);
507
+ if (!spec)
508
+ return json({ error: "body is not a valid graph spec" }, 400);
509
+ // Cap the node count: the SVG grows with the node count, so this bounds peak
510
+ // render memory even for a body that fits under the byte cap.
511
+ if (spec.nodes.length > MAX_GRAPH_NODES) {
512
+ return json({ error: "spec too large: node count exceeds limit" }, 400);
513
+ }
514
+ const theme = url.searchParams.get("theme") === "dark" ? "dark" : "light";
515
+ return svg(graphSVG("(custom)", spec, [], { standalone: true, theme }));
516
+ }
517
+ /** Byte length of a UTF-8 string without allocating the full encoded buffer where possible. */
518
+ function byteLength(s) {
519
+ return new TextEncoder().encode(s).length;
520
+ }
521
+ /**
522
+ * Standalone graph-explorer page: a run picker that links to each run's recorded
523
+ * topology, plus a textarea to paste an arbitrary spec and render it via
524
+ * `POST /api/graph/svg`.
525
+ */
526
+ function graphExplorerPage(store, url) {
527
+ const runs = store.listRuns(100).filter((r) => store.getGraphSpec(r.runId) !== "");
528
+ // ?run=<id> selects a run directly; default to the latest run with a topology.
529
+ let selected = url.searchParams.get("run") ?? "";
530
+ if (selected === "" && runs.length > 0)
531
+ selected = runs[0].runId;
532
+ const options = runs.length === 0
533
+ ? `<li class="muted">(no runs with a recorded topology yet)</li>`
534
+ : runs.map((r) => {
535
+ const mark = r.runId === selected ? " ← selected" : "";
536
+ return `<li><a href="/graph?run=${enc(r.runId)}">${esc(r.runId)}</a>${mark} · <a href="/runs/${enc(r.runId)}">detail</a></li>`;
537
+ }).join("");
538
+ // Server-render the selected run's topology inline (clickable per-node SVG).
539
+ const theme = url.searchParams.get("theme") === "dark" ? "dark" : "light";
540
+ const spec = selected ? graphSpec(store, selected) : undefined;
541
+ const selectedSVG = spec
542
+ ? `<h2>topology for <code>${esc(selected)}</code></h2>${graphSVG(selected, spec, store.spansForRun(selected), { standalone: false, theme })}`
543
+ : selected
544
+ ? `<h2>topology for <code>${esc(selected)}</code></h2><p class="muted">no graph topology recorded for this run</p>`
545
+ : "";
546
+ const placeholder = esc('{ "entry": "a", "nodes": [{"name":"a"}], "edges": [], "conditional": [] }');
547
+ return shell("graph explorer", `<h1>graph explorer</h1>
548
+ <h2>runs with a recorded topology</h2>
549
+ <ul class="runs-graph">${options}</ul>
550
+ ${selectedSVG}
551
+ <h2>render an arbitrary spec</h2>
552
+ <p class="subtle">Paste a graph spec and render it to SVG.</p>
553
+ <textarea id="spec" rows="8" style="width:100%;font-family:var(--mono);" placeholder="${placeholder}"></textarea>
554
+ <p><button id="render">render</button></p>
555
+ <div id="out"></div>
556
+ <script>
557
+ document.getElementById('render').addEventListener('click', async () => {
558
+ const out = document.getElementById('out');
559
+ try {
560
+ const res = await fetch('/api/graph/svg', { method: 'POST', headers: { 'content-type': 'application/json' }, body: document.getElementById('spec').value });
561
+ if (!res.ok) { out.textContent = 'error: ' + (await res.json()).error; return; }
562
+ out.innerHTML = await res.text();
563
+ } catch (e) { out.textContent = 'error: ' + e.message; }
564
+ });
565
+ </script>`);
566
+ }
567
+ /**
568
+ * Normalize a recorded graph spec into {@link GraphSpec}.
569
+ *
570
+ * Accepts two equivalent encodings: nodes as plain names with `edges` /
571
+ * `conditional`, or nodes as `{name}` objects with `static_edges` /
572
+ * `conditional_edges` whose branches are `[{label,to}]`. Both describe the same
573
+ * topology, so either renders identically.
574
+ */
575
+ function normalizeSpec(v) {
576
+ if (!v || typeof v !== "object")
577
+ return undefined;
578
+ const o = v;
579
+ const entry = typeof o.entry === "string" ? o.entry : "";
580
+ const rawNodes = Array.isArray(o.nodes) ? o.nodes : [];
581
+ const nodes = rawNodes
582
+ .map((n) => {
583
+ if (typeof n === "string")
584
+ return { name: n };
585
+ const obj = n;
586
+ if (obj && typeof obj.name === "string") {
587
+ return obj.interrupt ? { name: obj.name, interrupt: true } : { name: obj.name };
588
+ }
589
+ return { name: "" };
590
+ })
591
+ .filter((n) => n.name.length > 0);
592
+ const rawEdges = Array.isArray(o.edges) ? o.edges : Array.isArray(o.static_edges) ? o.static_edges : [];
593
+ const edges = rawEdges
594
+ .filter((e) => !!e && typeof e.from === "string" && typeof e.to === "string")
595
+ .map((e) => ({ from: e.from, to: e.to }));
596
+ let conditional = [];
597
+ if (Array.isArray(o.conditional)) {
598
+ conditional = o.conditional
599
+ .filter((c) => !!c && typeof c.from === "string")
600
+ .map((c) => (c.labels ? { from: c.from, labels: c.labels } : { from: c.from }));
601
+ }
602
+ else if (Array.isArray(o.conditional_edges)) {
603
+ conditional = o.conditional_edges
604
+ .filter((c) => !!c && typeof c.from === "string")
605
+ .map((c) => {
606
+ if (Array.isArray(c.branches) && c.branches.length > 0) {
607
+ const labels = {};
608
+ for (const b of c.branches) {
609
+ if (b && typeof b.label === "string" && typeof b.to === "string")
610
+ labels[b.label] = b.to;
611
+ }
612
+ return { from: c.from, labels };
613
+ }
614
+ return { from: c.from };
615
+ });
616
+ }
617
+ if (!entry && nodes.length === 0 && edges.length === 0 && conditional.length === 0)
618
+ return undefined;
619
+ return { entry, nodes, edges, conditional };
620
+ }
621
+ function spanJSON(s) {
622
+ return {
623
+ spanId: s.spanId,
624
+ parentSpanId: s.parentSpanId,
625
+ traceId: s.traceId,
626
+ name: s.name,
627
+ startMs: nanosToMs(s.startTimeUnixNano),
628
+ durationMs: Number(spanDuration(s)) / 1e6,
629
+ statusCode: s.statusCode,
630
+ statusMessage: s.statusMessage,
631
+ attributes: s.attributes,
632
+ events: s.events.map((e) => ({ name: e.name, timeMs: nanosToMs(e.timeUnixNano), attributes: e.attributes })),
633
+ };
634
+ }
635
+ function spansJSON(store, runId) {
636
+ return store.spansForRun(runId).map(spanJSON);
637
+ }
638
+ function findSpan(store, runId, spanId) {
639
+ return store.spansForRun(runId).find((s) => s.spanId === spanId);
640
+ }
641
+ // ── SSE live tail ───────────────────────────────────────────────────────────────
642
+ /**
643
+ * Server-Sent Events feed of run summaries as new spans land. Mirrors the
644
+ * oracle's `handleStreamRuns`:
645
+ *
646
+ * event: run data: <run summary JSON> (one per newly-touched run)
647
+ * event: heartbeat data: <unix-ms int> (every tick, keeps proxies open)
648
+ *
649
+ * A rowid cursor (seeded at the current max) means a freshly-connected client
650
+ * only receives runs touched *after* it connected — the existing runs are
651
+ * already in the static page render. `?interval` sets the cadence (Go-style
652
+ * duration or plain ms; floored at {@link MIN_STREAM_INTERVAL_MS}); the internal
653
+ * `?_max-iterations` bounds the loop for tests.
654
+ */
655
+ function sseLiveTail(store, url) {
656
+ const interval = parseInterval(url.searchParams.get("interval"), 1000);
657
+ const maxIters = Number.parseInt(url.searchParams.get("_max-iterations") ?? "", 10);
658
+ let timer;
659
+ // Seed the cursor at the current max so historical runs aren't replayed.
660
+ let cursor = store.maxSpanRowid();
661
+ const emitted = new Map(); // runId -> last emitted end time, dedups updates
662
+ let iter = 0;
663
+ const stream = new ReadableStream({
664
+ start(controller) {
665
+ const send = (event, data) => controller.enqueue(`event: ${event}\ndata: ${data}\n\n`);
666
+ const tick = () => {
667
+ try {
668
+ iter++;
669
+ const { spans, cursor: next } = store.spansSince(cursor, 500);
670
+ cursor = next;
671
+ const touched = new Set(spans.map((s) => s.traceId));
672
+ if (touched.size > 0) {
673
+ for (const summary of store.listRuns(500)) {
674
+ if (!touched.has(summary.traceId))
675
+ continue;
676
+ const prev = emitted.get(summary.runId);
677
+ if (prev !== undefined && prev === summary.endTimeUnixNano)
678
+ continue;
679
+ emitted.set(summary.runId, summary.endTimeUnixNano);
680
+ send("run", toJSON(runSummaryJSON(summary)));
681
+ }
682
+ }
683
+ send("heartbeat", String(Date.now()));
684
+ if (Number.isFinite(maxIters) && maxIters > 0 && iter >= maxIters) {
685
+ if (timer)
686
+ clearInterval(timer);
687
+ controller.close();
688
+ }
689
+ }
690
+ catch {
691
+ if (timer)
692
+ clearInterval(timer);
693
+ }
694
+ };
695
+ timer = setInterval(tick, interval);
696
+ timer.unref?.();
697
+ },
698
+ cancel() {
699
+ if (timer)
700
+ clearInterval(timer);
701
+ },
702
+ });
703
+ return new Response(stream, {
704
+ headers: {
705
+ "content-type": "text/event-stream",
706
+ "cache-control": "no-cache",
707
+ connection: "keep-alive",
708
+ "x-accel-buffering": "no",
709
+ },
710
+ });
711
+ }
712
+ // ── formatting helpers ───────────────────────────────────────────────────────────
713
+ function esc(s) {
714
+ return s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
715
+ }
716
+ const enc = encodeURIComponent;
717
+ /** Format a nanosecond duration as a compact, human-readable string. */
718
+ function fmtDur(ns) {
719
+ const ms = Number(ns) / 1e6;
720
+ if (ms >= 1000)
721
+ return `${(ms / 1000).toFixed(2)} s`;
722
+ if (ms >= 1)
723
+ return `${ms.toFixed(1)} ms`;
724
+ if (ms > 0)
725
+ return `${ms.toFixed(2)} ms`;
726
+ return "0 ms";
727
+ }
728
+ /** Truncate a long id to `head…tail` form for compact display. */
729
+ function shortId(s) {
730
+ return s.length > 18 ? `${s.slice(0, 8)}…${s.slice(-4)}` : s;
731
+ }
732
+ /** Strip the `galdor.` prefix from a span name for readability. */
733
+ function shortName(name) {
734
+ return name.replace(/^galdor\./, "");
735
+ }
736
+ /** Display label for a graph node, naming the sentinel start/end nodes. */
737
+ function nodeLabel(n) {
738
+ return n === START ? "start" : n === END ? "end" : n;
739
+ }
740
+ /** Read a string attribute, or `undefined` when absent or not a string. */
741
+ function attrStr(attrs, key) {
742
+ const v = attrs[key];
743
+ return typeof v === "string" ? v : undefined;
744
+ }
745
+ /** Read a numeric attribute, coercing numeric strings, or `undefined`. */
746
+ function attrNum(attrs, key) {
747
+ const v = attrs[key];
748
+ if (typeof v === "number")
749
+ return v;
750
+ if (typeof v === "string" && v !== "" && !Number.isNaN(Number(v)))
751
+ return Number(v);
752
+ return undefined;
753
+ }
754
+ /** Render an arbitrary attribute value for the attributes table. */
755
+ function fmtVal(v) {
756
+ if (typeof v === "string")
757
+ return v;
758
+ if (typeof v === "number" || typeof v === "boolean")
759
+ return String(v);
760
+ try {
761
+ return JSON.stringify(v);
762
+ }
763
+ catch {
764
+ return String(v);
765
+ }
766
+ }
767
+ /** Lay spans out in depth-first preorder (children under parents, by start time). */
768
+ function orderSpans(spans) {
769
+ const byId = new Map(spans.map((s) => [s.spanId, s]));
770
+ const children = new Map();
771
+ const roots = [];
772
+ for (const s of spans) {
773
+ const p = s.parentSpanId;
774
+ if (p && byId.has(p)) {
775
+ const arr = children.get(p) ?? [];
776
+ arr.push(s);
777
+ children.set(p, arr);
778
+ }
779
+ else {
780
+ roots.push(s);
781
+ }
782
+ }
783
+ const cmp = (a, b) => a.startTimeUnixNano < b.startTimeUnixNano ? -1 : a.startTimeUnixNano > b.startTimeUnixNano ? 1 : 0;
784
+ const out = [];
785
+ const walk = (s, depth) => {
786
+ out.push({ span: s, depth });
787
+ for (const c of (children.get(s.spanId) ?? []).sort(cmp))
788
+ walk(c, depth + 1);
789
+ };
790
+ for (const r of roots.sort(cmp))
791
+ walk(r, 0);
792
+ return out;
793
+ }
794
+ /** The earliest start and latest end across a set of spans. */
795
+ function runBounds(spans) {
796
+ let t0 = spans[0].startTimeUnixNano;
797
+ let t1 = spans[0].endTimeUnixNano;
798
+ for (const s of spans) {
799
+ if (s.startTimeUnixNano < t0)
800
+ t0 = s.startTimeUnixNano;
801
+ if (s.endTimeUnixNano > t1)
802
+ t1 = s.endTimeUnixNano;
803
+ }
804
+ return { t0, t1 };
805
+ }
806
+ // ── HTML shell ───────────────────────────────────────────────────────────────────
807
+ const STYLE = `
808
+ :root{
809
+ --bg:#131314; --bg2:#1b1c1d; --panel:#1e1f20; --panel2:#28292b; --raised:#2d2f31;
810
+ --border:#303134; --border2:#3c4043; --fg:#e3e3e3; --muted:#bdc1c6; --subtle:#9aa0a6;
811
+ --accent:#8ab4f8; --accent-soft:rgba(138,180,248,.12);
812
+ --blue:#8ab4f8; --violet:#c58af9; --slate:#9aa0a6;
813
+ --ok:#81c995; --err:#f28b82; --warn:#fdd663;
814
+ --edge:#5b636f; --node-stroke:#454a52; --grid:#34363a;
815
+ --radius:12px; --mono:"Roboto Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
816
+ --sans:"Google Sans","Google Sans Text","Product Sans",Inter,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;
817
+ }
818
+ :root,:root[data-theme="dark"]{color-scheme:dark;}
819
+ :root[data-theme="light"]{
820
+ color-scheme:light;
821
+ --bg:#ffffff; --bg2:#f0f4f9; --panel:#ffffff; --panel2:#f3f6fc; --raised:#e9eef6;
822
+ --border:#dde3ea; --border2:#c4cad1; --fg:#1f1f1f; --muted:#444746; --subtle:#6f7378;
823
+ --accent:#0b57d0; --accent-soft:rgba(11,87,208,.08);
824
+ --blue:#0b57d0; --violet:#8430ce; --slate:#5f6368;
825
+ --ok:#188038; --err:#c5221f; --warn:#9a6700;
826
+ --edge:#80868b; --node-stroke:#9aa0a6; --grid:#c4cad1;
827
+ }
828
+ *{box-sizing:border-box;}
829
+ html,body{margin:0;}
830
+ body{
831
+ background:var(--bg);
832
+ color:var(--fg); font:14px/1.55 var(--sans); -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
833
+ }
834
+ a{color:var(--accent); text-decoration:none;} a:hover{text-decoration:underline;}
835
+ code,pre,.mono{font-family:var(--mono);}
836
+ .muted{color:var(--muted);} .subtle{color:var(--subtle);}
837
+
838
+ .topbar{position:sticky; top:0; z-index:10; display:flex; align-items:center; gap:14px;
839
+ padding:12px 26px; background:var(--bg2); border-bottom:1px solid var(--border);}
840
+ .brand{display:flex; align-items:baseline; gap:9px;}
841
+ .brand .mark{font-weight:500; font-size:16px; letter-spacing:0; color:var(--fg);}
842
+ .brand .mark a{color:var(--fg);}
843
+ .brand .tag{font-size:12px; color:var(--subtle); letter-spacing:0;}
844
+ .topbar .spacer{flex:1;}
845
+ .live{display:inline-flex; align-items:center; gap:7px; font-size:12px; color:var(--subtle);}
846
+ .icon-btn{display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px;
847
+ border:1px solid var(--border2); border-radius:9px; background:var(--panel2); color:var(--muted);
848
+ cursor:pointer; font:15px/1 var(--sans); transition:border-color .12s,color .12s,background .12s;}
849
+ .icon-btn:hover{border-color:var(--accent); color:var(--fg); background:var(--raised);}
850
+ .live .pulse{width:7px; height:7px; border-radius:50%; background:var(--ok); opacity:.9; animation:pulse 2.4s ease-in-out infinite;}
851
+ @keyframes pulse{0%,100%{opacity:.3;}50%{opacity:.9;}}
852
+
853
+ main{padding:26px; max-width:1180px; margin:0 auto;}
854
+ .crumbs{display:flex; gap:8px; align-items:center; font-size:13px; margin-bottom:14px; color:var(--subtle);}
855
+ .crumbs a{color:var(--muted);} .crumbs .sep{color:var(--border2);}
856
+
857
+ .page-head{display:flex; align-items:flex-start; gap:16px; flex-wrap:wrap; margin-bottom:20px;}
858
+ .page-head h1{margin:0; font-size:20px; letter-spacing:-.2px;}
859
+ .page-head .id{font-family:var(--mono); font-size:13px; color:var(--muted); word-break:break-all;}
860
+ .page-head .actions{margin-left:auto; display:flex; gap:8px;}
861
+
862
+ .btn{display:inline-block; padding:6px 13px; border:1px solid var(--border2); border-radius:8px;
863
+ background:var(--panel2); color:var(--fg); font-size:13px; cursor:pointer;}
864
+ .btn:hover{border-color:var(--accent); text-decoration:none; background:var(--raised);}
865
+ .btn.primary{background:var(--accent-soft); border-color:var(--accent);}
866
+
867
+ .stats{display:flex; gap:10px; flex-wrap:wrap; margin-bottom:18px;}
868
+ .stat{background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); padding:10px 16px; min-width:108px;}
869
+ .stat .k{font-size:11px; text-transform:uppercase; letter-spacing:.6px; color:var(--subtle);}
870
+ .stat .v{font-size:19px; font-weight:600; margin-top:2px; font-variant-numeric:tabular-nums;}
871
+
872
+ .panel{background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); margin-bottom:20px; overflow:hidden;}
873
+ .panel > h2{margin:0; padding:13px 18px; font-size:13px; font-weight:500; letter-spacing:0;
874
+ color:var(--muted); border-bottom:1px solid var(--border); background:var(--bg2); display:flex; gap:10px; align-items:center;}
875
+ .panel > h2 .count{color:var(--subtle); font-weight:400; letter-spacing:0;}
876
+ .panel-body{padding:16px 18px;}
877
+ .panel-scroll{overflow-x:auto;}
878
+
879
+ .banner{margin:0 0 20px; padding:11px 15px; border-radius:var(--radius); font-size:13px;
880
+ background:rgba(253,214,99,.08); border:1px solid rgba(253,214,99,.32); color:var(--warn);}
881
+
882
+ table{width:100%; border-collapse:collapse;}
883
+ th,td{text-align:left; padding:10px 16px; border-bottom:1px solid var(--border);}
884
+ tr:last-child td{border-bottom:0;}
885
+ th{color:var(--subtle); font-weight:500; font-size:12px; letter-spacing:0;}
886
+ tbody tr{transition:background .12s;} tbody tr:hover{background:var(--panel2);}
887
+ td.num{font-variant-numeric:tabular-nums;}
888
+
889
+ .badge{display:inline-block; padding:1px 9px; border-radius:20px; font-size:11.5px; font-weight:600; line-height:1.6;}
890
+ .badge.ok{background:rgba(129,201,149,.14); color:var(--ok);}
891
+ .badge.err,.badge.error{background:rgba(242,139,130,.14); color:var(--err);}
892
+ .dot{display:inline-block; width:9px; height:9px; border-radius:50%; vertical-align:middle;}
893
+ .dot.ok{background:var(--ok);} .dot.error{background:var(--err);}
894
+ .dot.run{background:var(--accent);} .dot.node{background:var(--slate);}
895
+ .dot.model{background:var(--blue);} .dot.tool{background:var(--violet);} .dot.other{background:var(--subtle);}
896
+
897
+ /* span tree */
898
+ .tree{display:flex; flex-direction:column;}
899
+ .trow{display:flex; align-items:center; gap:12px; padding:7px 18px; border-bottom:1px solid var(--border);
900
+ color:var(--fg); font-size:13px;}
901
+ .trow:last-child{border-bottom:0;}
902
+ .trow:hover{background:var(--panel2); text-decoration:none;}
903
+ .trow .nm{display:flex; align-items:center; gap:8px; min-width:240px; font-family:var(--mono);}
904
+ .trow .nm .lbl{color:var(--subtle); font-family:var(--sans);}
905
+ .trow .ex{margin-left:8px; color:var(--muted); font-family:var(--sans); font-size:12px; white-space:nowrap;}
906
+ .trow .ex .exk{color:var(--subtle); margin-right:3px;}
907
+ .trow .tbar{flex:1; height:8px; background:var(--bg2); border-radius:5px; position:relative; min-width:120px; overflow:hidden;}
908
+ .trow .tbar i{position:absolute; top:0; bottom:0; border-radius:5px;}
909
+ .trow .tdur{font-variant-numeric:tabular-nums; color:var(--muted); width:78px; text-align:right;}
910
+
911
+ /* timeline */
912
+ .timeline{display:block; width:100%; height:auto; font-family:var(--mono);}
913
+ .timeline .tl-grid{stroke:var(--grid); stroke-width:1;}
914
+ .timeline .tl-axis{fill:var(--subtle); font-size:10px;}
915
+ .timeline .tl-label{fill:var(--muted); font-size:11px;}
916
+ .timeline .tl-dur{fill:var(--subtle); font-size:10px;}
917
+ .timeline a .tl-hit{fill:transparent;}
918
+ .timeline a:hover .tl-hit{fill:rgba(122,162,255,.08);}
919
+ .timeline a:hover .tl-label{fill:var(--fg);}
920
+ .legend{display:flex; gap:16px; flex-wrap:wrap; padding:10px 18px 0; font-size:12px; color:var(--muted);}
921
+ .legend span{display:inline-flex; align-items:center; gap:6px;}
922
+
923
+ /* graph */
924
+ .graph{display:block; width:100%; height:auto; font-family:var(--sans);}
925
+ .graph .edge{fill:none; stroke:var(--edge); stroke-width:1.7;}
926
+ .graph .edge.cond{stroke-dasharray:5 4; stroke:var(--slate);}
927
+ .graph .arrow{fill:var(--edge);}
928
+ .graph .edge-label{fill:var(--subtle); font-size:10px; text-anchor:middle;}
929
+ .graph .gnode rect{fill:var(--raised); stroke:var(--node-stroke); stroke-width:1.5;}
930
+ .graph .gnode:hover rect{stroke:var(--accent);}
931
+ .graph .gnode.entry rect{stroke:var(--accent); fill:var(--accent-soft);}
932
+ .graph .gnode.term rect{fill:var(--panel2); stroke:var(--slate);}
933
+ .graph .gnode.interrupt rect{stroke:#d97706; fill:rgba(217,119,6,.16);}
934
+ .graph .gnode.err rect{stroke:var(--err);}
935
+ .graph .gnode-label{fill:var(--fg); font-size:12px; font-weight:600; text-anchor:middle;}
936
+ .graph .gnode-sub{fill:var(--muted); font-size:10px; text-anchor:middle; font-family:var(--mono);}
937
+
938
+ /* topology lists */
939
+ .topo{display:flex; gap:34px; flex-wrap:wrap;}
940
+ .topo h3{font-size:12px; text-transform:uppercase; letter-spacing:.5px; color:var(--subtle); margin:0 0 8px;}
941
+ .topo ul{margin:0; padding-left:18px; font-family:var(--mono); font-size:13px;}
942
+ .topo li{margin:3px 0;}
943
+
944
+ /* meta grid */
945
+ .meta{display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:1px; background:var(--border); border-radius:var(--radius); overflow:hidden;}
946
+ .meta .cell{background:var(--panel); padding:11px 16px;}
947
+ .meta .k{font-size:11px; text-transform:uppercase; letter-spacing:.5px; color:var(--subtle);}
948
+ .meta .v{margin-top:3px; font-family:var(--mono); font-size:13px; word-break:break-all;}
949
+
950
+ /* messages */
951
+ .msgs{display:grid; grid-template-columns:1fr 1fr; gap:16px;}
952
+ @media(max-width:820px){.msgs{grid-template-columns:1fr;}}
953
+ .msgcol h3{font-size:12px; text-transform:uppercase; letter-spacing:.5px; color:var(--subtle); margin:0 0 10px;}
954
+ .msg{border:1px solid var(--border); border-radius:9px; margin-bottom:10px; overflow:hidden; background:var(--bg2);}
955
+ .msg-role{font-size:11px; text-transform:uppercase; letter-spacing:.6px; padding:5px 12px; color:var(--muted); border-bottom:1px solid var(--border); background:var(--panel2);}
956
+ .msg.user .msg-role{color:var(--accent);}
957
+ .msg.assistant .msg-role{color:var(--ok);}
958
+ .msg.system .msg-role{color:var(--warn);}
959
+ .msg.tool .msg-role{color:var(--violet);}
960
+ .msg-body{padding:10px 12px;}
961
+ .msg-body pre{margin:0; white-space:pre-wrap; word-break:break-word; font-size:12.5px; line-height:1.5;}
962
+ .msg-body pre.text{font-family:var(--sans);}
963
+ .toolcall{font-family:var(--mono); font-size:12.5px; color:var(--violet); margin:5px 0; word-break:break-word;}
964
+ .toolcall .tname{color:var(--fg);}
965
+ .toolcall code{color:var(--muted);}
966
+ details.reason{margin:6px 0; border-left:2px solid var(--border2); padding-left:10px;}
967
+ details.reason summary{cursor:pointer; color:var(--subtle); font-size:12px;}
968
+ details.reason pre{margin:6px 0 0; white-space:pre-wrap; font-size:12px; color:var(--muted);}
969
+
970
+ /* steps */
971
+ .step{border:1px solid var(--border); border-radius:var(--radius); margin-bottom:16px; overflow:hidden; background:var(--panel);}
972
+ .step-head{display:flex; align-items:center; gap:12px; padding:11px 16px; background:var(--bg2); border-bottom:1px solid var(--border);}
973
+ .step-head .n{width:26px; height:26px; border-radius:7px; background:var(--accent-soft); color:var(--accent); display:flex; align-items:center; justify-content:center; font-weight:700; font-size:13px;}
974
+ .step-head .nm{font-weight:600;}
975
+ .step-head .meta-inline{margin-left:auto; color:var(--muted); font-size:12px; font-variant-numeric:tabular-nums;}
976
+ .step-body{padding:14px 16px;}
977
+ .call{border:1px solid var(--border); border-radius:9px; padding:11px 13px; margin-bottom:12px; background:var(--bg2);}
978
+ .call:last-child{margin-bottom:0;}
979
+ .call-head{display:flex; gap:10px; align-items:baseline; flex-wrap:wrap; margin-bottom:8px; font-size:13px;}
980
+ .call-head .kind{font-size:11px; text-transform:uppercase; letter-spacing:.5px; padding:1px 8px; border-radius:6px;}
981
+ .call-head .kind.model{background:rgba(138,180,248,.14); color:var(--blue);}
982
+ .call-head .kind.tool{background:rgba(197,138,249,.14); color:var(--violet);}
983
+ .call-head .meta-inline{margin-left:auto; color:var(--subtle); font-size:12px; font-variant-numeric:tabular-nums;}
984
+ .hint{font-size:12.5px; color:var(--subtle); font-style:italic;}
985
+
986
+ .empty{padding:30px; text-align:center; color:var(--subtle);}
987
+ `;
988
+ /** Top navigation bar shared by every page. */
989
+ function topbar(live) {
990
+ const liveDot = live
991
+ ? `<span class="live"><span class="pulse"></span> live</span>`
992
+ : "";
993
+ return `<div class="topbar">
994
+ <div class="brand"><span class="mark"><a href="/">galdor</a></span><span class="tag">trace explorer</span></div>
995
+ <span class="spacer"></span>${liveDot}
996
+ <button id="theme-toggle" class="icon-btn" type="button" aria-label="Toggle light or dark theme" title="Toggle light / dark"></button>
997
+ </div>`;
998
+ }
999
+ /** Inline, render-blocking theme bootstrap: applies the saved/system theme before first paint (no flash). */
1000
+ const THEME_INIT = `<script>(function(){try{var s=localStorage.getItem('galdor-theme');var t=s||((window.matchMedia&&window.matchMedia('(prefers-color-scheme: light)').matches)?'light':'dark');document.documentElement.setAttribute('data-theme',t);}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>`;
1001
+ /** Wire the topbar toggle to flip and persist the theme. */
1002
+ const THEME_TOGGLE = `<script>(function(){var b=document.getElementById('theme-toggle');if(!b)return;function sync(){var t=document.documentElement.getAttribute('data-theme');b.textContent=t==='light'?'\\u263E':'\\u2600';}sync();b.addEventListener('click',function(){var next=document.documentElement.getAttribute('data-theme')==='light'?'dark':'light';document.documentElement.setAttribute('data-theme',next);try{localStorage.setItem('galdor-theme',next);}catch(e){}sync();});})();</script>`;
1003
+ function shell(title, body, opts = {}) {
1004
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
1005
+ ${THEME_INIT}<title>${esc(title)} · galdor</title><style>${STYLE}</style></head>
1006
+ <body>${topbar(opts.live ?? false)}<main>${body}</main>${THEME_TOGGLE}</body></html>`;
1007
+ }
1008
+ // ── run list page ────────────────────────────────────────────────────────────────
1009
+ function runListPage(store, limit = 100) {
1010
+ const runs = runListJSON(store, limit);
1011
+ const orphans = store.orphanSpanCount();
1012
+ const banner = orphans > 0
1013
+ ? `<div class="banner">⚠ ${orphans} span(s) carry no run id and won't appear below — instrument through @galdor/core/observability so every span lands in a run.</div>`
1014
+ : "";
1015
+ const rowsHtml = runs.length === 0
1016
+ ? `<tr><td colspan="5" class="empty">No runs recorded yet. Run a traced graph and they'll appear here.</td></tr>`
1017
+ : runs.map(runRow).join("");
1018
+ // Live-tail: re-render the tbody as new runs arrive over SSE.
1019
+ const script = `<script>
1020
+ function row(r){
1021
+ var t=new Date(r.startMs).toLocaleTimeString();
1022
+ var dur=r.durationMs>=1000?(r.durationMs/1000).toFixed(2)+' s':r.durationMs+' ms';
1023
+ return '<tr><td><span class="dot '+r.status+'"></span> <a href="/runs/'+encodeURIComponent(r.runId)+'">'+
1024
+ (r.runId||'(no id)')+'</a></td><td><span class="badge '+r.status+'">'+r.status+'</span></td>'+
1025
+ '<td class="num">'+r.spanCount+'</td><td class="num">'+dur+'</td><td class="muted">'+t+'</td></tr>';
1026
+ }
1027
+ var es=new EventSource('/events');
1028
+ es.addEventListener('run',function(e){
1029
+ var r=JSON.parse(e.data); var tb=document.querySelector('tbody');
1030
+ var existing=document.getElementById('run-'+r.runId);
1031
+ var tmp=document.createElement('tbody'); tmp.innerHTML=row(r);
1032
+ var tr=tmp.firstElementChild; tr.id='run-'+r.runId;
1033
+ if(existing) existing.replaceWith(tr); else tb.insertBefore(tr, tb.firstChild);
1034
+ });
1035
+ </script>`;
1036
+ const body = `${banner}
1037
+ <div class="page-head"><h1>Runs</h1></div>
1038
+ <div class="panel panel-scroll">
1039
+ <table><thead><tr><th>run</th><th>status</th><th>spans</th><th>duration</th><th>started</th></tr></thead>
1040
+ <tbody>${rowsHtml}</tbody></table>
1041
+ </div>${script}`;
1042
+ return shell("runs", body, { live: true });
1043
+ }
1044
+ function runRow(r) {
1045
+ const dur = r.durationMs >= 1000 ? `${(r.durationMs / 1000).toFixed(2)} s` : `${r.durationMs} ms`;
1046
+ const time = new Date(r.startMs).toLocaleTimeString();
1047
+ return `<tr>
1048
+ <td><span class="dot ${r.status}"></span> <a href="/runs/${enc(r.runId)}">${esc(r.runId || "(no id)")}</a></td>
1049
+ <td><span class="badge ${r.status}">${r.status}</span></td>
1050
+ <td class="num">${r.spanCount}</td>
1051
+ <td class="num">${dur}</td>
1052
+ <td class="muted">${esc(time)}</td>
1053
+ </tr>`;
1054
+ }
1055
+ // ── run detail page ──────────────────────────────────────────────────────────────
1056
+ function runDetailPage(store, runId) {
1057
+ const spans = store.spansForRun(runId);
1058
+ if (spans.length === 0) {
1059
+ return shell(runId, `${crumbs([["/", "runs"], [null, shortId(runId)]])}<div class="panel"><div class="empty">No spans recorded for <code>${esc(runId)}</code>.</div></div>`);
1060
+ }
1061
+ const ordered = orderSpans(spans);
1062
+ const { t0, t1 } = runBounds(spans);
1063
+ const total = t1 > t0 ? t1 - t0 : 1n;
1064
+ const errors = spans.filter((s) => s.statusCode === "error").length;
1065
+ const status = errors > 0 ? "error" : "ok";
1066
+ const spec = graphSpec(store, runId);
1067
+ const tok = tokenTotals(spans);
1068
+ const tokStat = tok.input + tok.output > 0
1069
+ ? `<div class="stat"><div class="k">tokens</div><div class="v">${tok.input + tok.output}<span class="subtle" style="font-size:12px"> · ${tok.input}↑ ${tok.output}↓</span></div></div>`
1070
+ : "";
1071
+ const head = `<div class="page-head">
1072
+ <div><h1>Run</h1><div class="id">${esc(runId)}</div></div>
1073
+ <div class="actions"><a class="btn primary" href="/runs/${enc(runId)}/steps">Steps view →</a></div>
1074
+ </div>
1075
+ <div class="stats">
1076
+ <div class="stat"><div class="k">status</div><div class="v"><span class="badge ${status}">${status}</span></div></div>
1077
+ <div class="stat"><div class="k">duration</div><div class="v">${fmtDur(total)}</div></div>
1078
+ <div class="stat"><div class="k">spans</div><div class="v">${spans.length}</div></div>
1079
+ <div class="stat"><div class="k">errors</div><div class="v">${errors}</div></div>
1080
+ ${tokStat}
1081
+ <div class="stat"><div class="k">started</div><div class="v" style="font-size:13px">${esc(new Date(nanosToMs(t0)).toLocaleString())}</div></div>
1082
+ </div>`;
1083
+ const timeline = `<div class="panel">
1084
+ <h2>timeline <span class="count">${ordered.length} spans</span></h2>
1085
+ ${timelineLegend()}
1086
+ <div class="panel-body panel-scroll">${timelineSVG(runId, ordered, t0, total)}</div>
1087
+ </div>`;
1088
+ const tree = `<div class="panel">
1089
+ <h2>span tree</h2>
1090
+ <div class="tree">${ordered.map((o) => treeRow(runId, o, t0, total)).join("")}</div>
1091
+ </div>`;
1092
+ const graph = spec ? graphPanel(runId, spec, spans) : "";
1093
+ return shell(runId, `${crumbs([["/", "runs"], [null, shortId(runId)]])}${head}${timeline}${graph}${tree}`);
1094
+ }
1095
+ function timelineLegend() {
1096
+ const item = (k, label) => `<span><span class="dot ${k}"></span>${label}</span>`;
1097
+ return `<div class="legend">${item("run", "run")}${item("node", "node")}${item("model", "model call")}${item("tool", "tool")}<span><span class="dot error"></span>error</span></div>`;
1098
+ }
1099
+ /** Render the run's spans as a clickable SVG waterfall. */
1100
+ function timelineSVG(runId, ordered, t0, total) {
1101
+ const labelW = 250;
1102
+ const barW = 760;
1103
+ const padTop = 26;
1104
+ const rowH = 26;
1105
+ const W = labelW + barW + 70;
1106
+ const H = padTop + ordered.length * rowH + 8;
1107
+ const totalNum = Number(total);
1108
+ const xOf = (ns) => labelW + (Number(ns - t0) * barW) / totalNum;
1109
+ // Time axis: 6 evenly-spaced gridlines with relative-ms labels.
1110
+ let axis = "";
1111
+ for (let i = 0; i <= 5; i++) {
1112
+ const frac = i / 5;
1113
+ const x = labelW + frac * barW;
1114
+ const ms = (totalNum * frac) / 1e6;
1115
+ const lbl = ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms.toFixed(ms < 10 ? 1 : 0)}ms`;
1116
+ axis += `<line class="tl-grid" x1="${x.toFixed(1)}" y1="${padTop}" x2="${x.toFixed(1)}" y2="${H - 6}"/>`;
1117
+ axis += `<text class="tl-axis" x="${x.toFixed(1)}" y="${padTop - 8}" text-anchor="${i === 5 ? "end" : "middle"}">${lbl}</text>`;
1118
+ }
1119
+ let rows = "";
1120
+ ordered.forEach(({ span, depth }, i) => {
1121
+ const y = padTop + i * rowH;
1122
+ const cy = y + rowH / 2;
1123
+ const dur = spanDuration(span);
1124
+ let x = xOf(span.startTimeUnixNano);
1125
+ let w = (Number(dur) * barW) / totalNum;
1126
+ if (w < 3)
1127
+ w = 3;
1128
+ if (x + w > labelW + barW)
1129
+ x = labelW + barW - w;
1130
+ const kind = kindOf(span.name);
1131
+ const fill = span.statusCode === "error" ? "var(--err)" : KIND_FILL[kind];
1132
+ const lx = 10 + depth * 11;
1133
+ const label = esc(truncate(shortName(span.name), 30 - depth * 2));
1134
+ rows += `<a href="/runs/${enc(runId)}/spans/${enc(span.spanId)}">
1135
+ <rect class="tl-hit" x="0" y="${y}" width="${W}" height="${rowH}"/>
1136
+ <text class="tl-label" x="${lx}" y="${cy + 4}">${label}</text>
1137
+ <rect x="${x.toFixed(1)}" y="${y + (rowH - 12) / 2}" width="${w.toFixed(1)}" height="12" rx="3" fill="${fill}"><title>${esc(span.name)} — ${fmtDur(dur)}</title></rect>
1138
+ <text class="tl-dur" x="${(x + w + 6).toFixed(1)}" y="${cy + 4}">${fmtDur(dur)}</text>
1139
+ </a>`;
1140
+ });
1141
+ return `<svg class="timeline" viewBox="0 0 ${W} ${H}" width="${W}" preserveAspectRatio="xMinYMin meet" role="img">${axis}${rows}</svg>`;
1142
+ }
1143
+ function truncate(s, n) {
1144
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s;
1145
+ }
1146
+ /** One row of the textual span tree, with a proportional latency bar. */
1147
+ function treeRow(runId, { span, depth }, t0, total) {
1148
+ const dur = spanDuration(span);
1149
+ const kind = kindOf(span.name);
1150
+ const fill = span.statusCode === "error" ? "var(--err)" : KIND_FILL[kind];
1151
+ const totalNum = Number(total);
1152
+ const left = (Number(span.startTimeUnixNano - t0) * 100) / totalNum;
1153
+ const width = Math.max(1.5, (Number(dur) * 100) / totalNum);
1154
+ const err = span.statusCode === "error" ? `<span class="badge err">error</span>` : "";
1155
+ return `<a class="trow" href="/runs/${enc(runId)}/spans/${enc(span.spanId)}">
1156
+ <span class="nm" style="padding-left:${depth * 16}px"><span class="dot ${kind}"></span>${esc(shortName(span.name))}${spanExtras(span.attributes)}</span>
1157
+ <span class="tbar"><i style="left:${left.toFixed(2)}%;width:${width.toFixed(2)}%;background:${fill}"></i></span>
1158
+ <span class="tdur">${fmtDur(dur)}</span>${err}
1159
+ </a>`;
1160
+ }
1161
+ /** Inline key/value chips (label, node, provider, model, tokens) shown beside a span. */
1162
+ function spanExtras(a) {
1163
+ const bits = [];
1164
+ const push = (k, v) => {
1165
+ if (v)
1166
+ bits.push(`<span class="ex"><span class="exk">${k}</span>${esc(v)}</span>`);
1167
+ };
1168
+ push("label", attrStr(a, A.label));
1169
+ push("node", attrStr(a, A.node));
1170
+ push("provider", attrStr(a, A.provider) ?? attrStr(a, A.system));
1171
+ push("model", attrStr(a, A.reqModel));
1172
+ push("tool", attrStr(a, A.toolName));
1173
+ const inT = attrNum(a, A.inTokens);
1174
+ const outT = attrNum(a, A.outTokens);
1175
+ if (inT && inT > 0)
1176
+ push("in", String(inT));
1177
+ if (outT && outT > 0)
1178
+ push("out", String(outT));
1179
+ return bits.join("");
1180
+ }
1181
+ /** Sum input/output token usage across a run's provider spans. */
1182
+ function tokenTotals(spans) {
1183
+ let input = 0;
1184
+ let output = 0;
1185
+ for (const s of spans) {
1186
+ input += attrNum(s.attributes, A.inTokens) ?? 0;
1187
+ output += attrNum(s.attributes, A.outTokens) ?? 0;
1188
+ }
1189
+ return { input, output };
1190
+ }
1191
+ /** Aggregate recorded duration/status for the spans belonging to a graph node. */
1192
+ function nodeStat(spans, nodeName) {
1193
+ let dur = 0n;
1194
+ let status = "ok";
1195
+ let found = false;
1196
+ for (const s of spans) {
1197
+ if (s.name === SPAN.node && s.attributes[A.node] === nodeName) {
1198
+ found = true;
1199
+ dur += spanDuration(s);
1200
+ if (s.statusCode === "error")
1201
+ status = "error";
1202
+ }
1203
+ }
1204
+ return found ? { dur, status } : undefined;
1205
+ }
1206
+ function graphPanel(runId, spec, spans) {
1207
+ const api = `<span class="count" style="margin-left:auto;font-size:12px">
1208
+ API:
1209
+ <a href="/api/runs/${enc(runId)}/graph/model">JSON</a> ·
1210
+ <a href="/api/runs/${enc(runId)}/graph/svg">SVG</a> ·
1211
+ <a href="/api/runs/${enc(runId)}/graph">spec</a></span>`;
1212
+ return `<div class="panel">
1213
+ <h2>graph topology${api}</h2>
1214
+ <div class="panel-body panel-scroll">${graphSVG(runId, spec, spans)}</div>
1215
+ <div class="panel-body" style="border-top:1px solid var(--border)">${topologyLists(spec)}</div>
1216
+ </div>`;
1217
+ }
1218
+ /** Collect the full node set, flattened edge list, and interrupt-gated node names of a spec. */
1219
+ function collectGraph(spec) {
1220
+ const interrupt = new Set();
1221
+ const nodeSet = new Set();
1222
+ for (const n of spec.nodes) {
1223
+ nodeSet.add(n.name);
1224
+ if (n.interrupt)
1225
+ interrupt.add(n.name);
1226
+ }
1227
+ const edges = [];
1228
+ for (const e of spec.edges) {
1229
+ nodeSet.add(e.from);
1230
+ nodeSet.add(e.to);
1231
+ edges.push({ from: e.from, to: e.to, cond: false, label: "" });
1232
+ }
1233
+ for (const c of spec.conditional) {
1234
+ nodeSet.add(c.from);
1235
+ if (c.labels) {
1236
+ for (const [label, to] of Object.entries(c.labels)) {
1237
+ nodeSet.add(to);
1238
+ edges.push({ from: c.from, to, cond: true, label });
1239
+ }
1240
+ }
1241
+ }
1242
+ return { nodes: [...nodeSet], edges, interrupt };
1243
+ }
1244
+ /**
1245
+ * The graph as the dashboard models it: the recorded topology enriched with each
1246
+ * node's measured duration and status. This is the same data the SVG is drawn
1247
+ * from, exposed as plain JSON so it can be rendered or analysed elsewhere.
1248
+ */
1249
+ function graphModel(spec, spans) {
1250
+ const { nodes, edges, interrupt } = collectGraph(spec);
1251
+ return {
1252
+ entry: spec.entry,
1253
+ nodes: nodes.map((n) => {
1254
+ const stat = nodeStat(spans, n);
1255
+ return {
1256
+ name: n,
1257
+ role: n === START ? "start" : n === END ? "end" : "node",
1258
+ entry: n === spec.entry,
1259
+ interrupt: interrupt.has(n),
1260
+ durationMs: stat ? Number(stat.dur) / 1e6 : null,
1261
+ status: stat ? stat.status : null,
1262
+ };
1263
+ }),
1264
+ edges: edges.map((e) => ({ from: e.from, to: e.to, conditional: e.cond, label: e.cond ? e.label : null })),
1265
+ };
1266
+ }
1267
+ /** Render the recorded graph as a layered SVG with per-node run metrics. */
1268
+ function graphSVG(runId, spec, spans, opts = {}) {
1269
+ const { nodes, edges, interrupt } = collectGraph(spec);
1270
+ // Longest-path layering (start sinks at layer 0). Iteration count bounds cycles.
1271
+ const layer = new Map(nodes.map((n) => [n, 0]));
1272
+ for (let iter = 0; iter < nodes.length; iter++) {
1273
+ let changed = false;
1274
+ for (const e of edges) {
1275
+ const lu = layer.get(e.from) ?? 0;
1276
+ if ((layer.get(e.to) ?? 0) < lu + 1) {
1277
+ layer.set(e.to, lu + 1);
1278
+ changed = true;
1279
+ }
1280
+ }
1281
+ if (!changed)
1282
+ break;
1283
+ }
1284
+ const NODE_H = 42;
1285
+ const V_GAP = 100;
1286
+ const PAD = 24;
1287
+ const widthOf = (n) => Math.max(96, nodeLabel(n).length * 8 + 30);
1288
+ const layers = [];
1289
+ for (const n of nodes) {
1290
+ const L = layer.get(n) ?? 0;
1291
+ (layers[L] ??= []).push(n);
1292
+ }
1293
+ const maxPer = Math.max(1, ...layers.map((a) => a?.length ?? 0));
1294
+ const maxNodeW = Math.max(96, ...nodes.map(widthOf));
1295
+ const CW = Math.max(720, maxPer * (maxNodeW + 56));
1296
+ const CH = PAD * 2 + Math.max(0, layers.length - 1) * V_GAP + NODE_H;
1297
+ const pos = new Map();
1298
+ layers.forEach((arr, L) => {
1299
+ if (!arr)
1300
+ return;
1301
+ const m = arr.length;
1302
+ arr.forEach((n, i) => {
1303
+ pos.set(n, { cx: (CW * (i + 0.5)) / m, cy: PAD + NODE_H / 2 + L * V_GAP, w: widthOf(n) });
1304
+ });
1305
+ });
1306
+ let edgeSvg = "";
1307
+ for (const e of edges) {
1308
+ const a = pos.get(e.from);
1309
+ const b = pos.get(e.to);
1310
+ if (!a || !b)
1311
+ continue;
1312
+ const x1 = a.cx;
1313
+ const y1 = a.cy + NODE_H / 2;
1314
+ const x2 = b.cx;
1315
+ const y2 = b.cy - NODE_H / 2;
1316
+ const my = (y1 + y2) / 2;
1317
+ const d = `M${x1.toFixed(1)},${y1.toFixed(1)} C${x1.toFixed(1)},${my.toFixed(1)} ${x2.toFixed(1)},${my.toFixed(1)} ${x2.toFixed(1)},${y2.toFixed(1)}`;
1318
+ edgeSvg += `<path class="edge${e.cond ? " cond" : ""}" d="${d}" marker-end="url(#gv-arrow)"/>`;
1319
+ if (e.cond && e.label) {
1320
+ edgeSvg += `<text class="edge-label" x="${((x1 + x2) / 2).toFixed(1)}" y="${my.toFixed(1)}">${esc(e.label)}</text>`;
1321
+ }
1322
+ }
1323
+ let nodeSvg = "";
1324
+ for (const n of nodes) {
1325
+ const p = pos.get(n);
1326
+ if (!p)
1327
+ continue;
1328
+ const term = n === START || n === END;
1329
+ const entry = n === spec.entry;
1330
+ const stat = nodeStat(spans, n);
1331
+ const cls = ["gnode"];
1332
+ if (term)
1333
+ cls.push("term");
1334
+ if (entry)
1335
+ cls.push("entry");
1336
+ if (interrupt.has(n))
1337
+ cls.push("interrupt");
1338
+ if (stat?.status === "error")
1339
+ cls.push("err");
1340
+ const x = p.cx - p.w / 2;
1341
+ const y = p.cy - NODE_H / 2;
1342
+ const sub = stat ? fmtDur(stat.dur) : "";
1343
+ const tip = stat ? `${n} — ${fmtDur(stat.dur)} (${stat.status})` : n;
1344
+ // Link nodes to their step in-page; standalone SVGs carry no page links.
1345
+ const linkable = !opts.standalone && !!stat;
1346
+ const open = linkable
1347
+ ? `<a href="/runs/${enc(runId)}/steps#step-${enc(n)}" class="${cls.join(" ")}">`
1348
+ : `<g class="${cls.join(" ")}">`;
1349
+ const close = linkable ? "</a>" : "</g>";
1350
+ const labelY = sub ? p.cy - 2 : p.cy + 4;
1351
+ nodeSvg += `${open}
1352
+ <rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${p.w}" height="${NODE_H}" rx="10"/>
1353
+ <text class="gnode-label" x="${p.cx.toFixed(1)}" y="${labelY.toFixed(1)}">${esc(nodeLabel(n))}</text>
1354
+ ${sub ? `<text class="gnode-sub" x="${p.cx.toFixed(1)}" y="${(p.cy + 14).toFixed(1)}">${esc(sub)}</text>` : ""}
1355
+ <title>${esc(tip)}</title>
1356
+ ${close}`;
1357
+ }
1358
+ const defs = `<defs><marker id="gv-arrow" markerWidth="9" markerHeight="9" refX="7.5" refY="3" orient="auto" markerUnits="userSpaceOnUse"><path class="arrow" d="M0,0 L7,3 L0,6 z"/></marker></defs>`;
1359
+ const style = opts.standalone ? graphStyleBlock(opts.theme ?? "light") : "";
1360
+ const ns = opts.standalone ? ` xmlns="http://www.w3.org/2000/svg"` : "";
1361
+ return `<svg class="graph"${ns} viewBox="0 0 ${CW.toFixed(0)} ${CH.toFixed(0)}" width="${CW.toFixed(0)}" preserveAspectRatio="xMinYMin meet" role="img">${style}${defs}${edgeSvg}${nodeSvg}</svg>`;
1362
+ }
1363
+ /** Embedded stylesheet for a standalone graph SVG (concrete colours, no CSS variables). */
1364
+ function graphStyleBlock(theme) {
1365
+ const c = theme === "light"
1366
+ ? { edge: "#80868b", cond: "#5f6368", nodeFill: "#ffffff", nodeStroke: "#9aa0a6", termFill: "#f1f3f4", entryStroke: "#0b57d0", entryFill: "#e8f0fe", err: "#c5221f", label: "#1f1f1f", sub: "#5f6368", elabel: "#6f7378" }
1367
+ : { edge: "#5b636f", cond: "#9aa0a6", nodeFill: "#2d2f31", nodeStroke: "#454a52", termFill: "#28292b", entryStroke: "#8ab4f8", entryFill: "rgba(138,180,248,.14)", err: "#f28b82", label: "#e3e3e3", sub: "#9aa0a6", elabel: "#9aa0a6" };
1368
+ return `<style>
1369
+ .graph{font-family:system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;}
1370
+ .graph .edge{fill:none;stroke:${c.edge};stroke-width:1.7;}
1371
+ .graph .edge.cond{stroke-dasharray:5 4;stroke:${c.cond};}
1372
+ .graph .arrow{fill:${c.edge};}
1373
+ .graph .edge-label{fill:${c.elabel};font-size:10px;text-anchor:middle;}
1374
+ .graph .gnode rect{fill:${c.nodeFill};stroke:${c.nodeStroke};stroke-width:1.5;}
1375
+ .graph .gnode.term rect{fill:${c.termFill};stroke:${c.cond};}
1376
+ .graph .gnode.entry rect{stroke:${c.entryStroke};fill:${c.entryFill};}
1377
+ .graph .gnode.interrupt rect{stroke:#d97706;fill:${theme === "light" ? "#fef3c7" : "rgba(217,119,6,.16)"};}
1378
+ .graph .gnode.err rect{stroke:${c.err};}
1379
+ .graph .gnode-label{fill:${c.label};font-size:12px;font-weight:600;text-anchor:middle;}
1380
+ .graph .gnode-sub{fill:${c.sub};font-size:10px;text-anchor:middle;font-family:ui-monospace,Menlo,monospace;}
1381
+ </style>`;
1382
+ }
1383
+ /** Textual node/edge listing beneath the graph SVG. */
1384
+ function topologyLists(spec) {
1385
+ const nodes = spec.nodes.length === 0
1386
+ ? `<li class="muted">(none)</li>`
1387
+ : spec.nodes
1388
+ .map((n) => {
1389
+ const tags = [
1390
+ n.name === spec.entry ? ` <span class="subtle">(entry)</span>` : "",
1391
+ n.interrupt ? ` <span class="subtle">(interrupt)</span>` : "",
1392
+ ].join("");
1393
+ return `<li>${esc(n.name)}${tags}</li>`;
1394
+ })
1395
+ .join("");
1396
+ const staticEdges = spec.edges.map((e) => `<li>${esc(nodeLabel(e.from))} → ${esc(nodeLabel(e.to))}</li>`).join("");
1397
+ const condEdges = spec.conditional
1398
+ .map((c) => {
1399
+ if (!c.labels)
1400
+ return `<li>${esc(nodeLabel(c.from))} → <span class="subtle">(router)</span></li>`;
1401
+ const labels = Object.entries(c.labels).map(([l, to]) => `${esc(l)}:${esc(nodeLabel(to))}`).join(", ");
1402
+ return `<li>${esc(nodeLabel(c.from))} → <span class="subtle">{${labels}}</span></li>`;
1403
+ })
1404
+ .join("");
1405
+ const edges = staticEdges + condEdges || `<li class="muted">(none)</li>`;
1406
+ return `<div class="topo">
1407
+ <div><h3>nodes</h3><ul>${nodes}</ul></div>
1408
+ <div><h3>edges</h3><ul>${edges}</ul></div>
1409
+ </div>`;
1410
+ }
1411
+ // ── span detail page ─────────────────────────────────────────────────────────────
1412
+ function spanDetailPage(store, runId, spanId) {
1413
+ const span = findSpan(store, runId, spanId);
1414
+ if (!span) {
1415
+ return shell("span", `${crumbs([["/", "runs"], [`/runs/${enc(runId)}`, shortId(runId)], [null, "span"]])}<div class="panel"><div class="empty">Span not found.</div></div>`);
1416
+ }
1417
+ const dur = spanDuration(span);
1418
+ const kind = kindOf(span.name);
1419
+ const status = span.statusCode === "error" ? "error" : span.statusCode === "ok" ? "ok" : "unset";
1420
+ const head = `<div class="page-head">
1421
+ <div><h1><span class="dot ${kind}"></span> ${esc(shortName(span.name))}</h1><div class="id">${esc(span.spanId)}</div></div>
1422
+ </div>`;
1423
+ const cell = (k, v) => `<div class="cell"><div class="k">${k}</div><div class="v">${v}</div></div>`;
1424
+ const parentLink = span.parentSpanId
1425
+ ? `<a href="/runs/${enc(runId)}/spans/${enc(span.parentSpanId)}">${esc(shortId(span.parentSpanId))}</a>`
1426
+ : `<span class="subtle">(root)</span>`;
1427
+ const meta = `<div class="meta">
1428
+ ${cell("status", status === "unset" ? `<span class="subtle">unset</span>` : `<span class="badge ${status}">${status}</span>`)}
1429
+ ${cell("duration", fmtDur(dur))}
1430
+ ${cell("started", esc(new Date(nanosToMs(span.startTimeUnixNano)).toLocaleString()))}
1431
+ ${cell("parent", parentLink)}
1432
+ ${cell("trace", esc(shortId(span.traceId)))}
1433
+ ${span.statusMessage ? cell("message", esc(span.statusMessage)) : ""}
1434
+ </div>`;
1435
+ const modelPanel = modelCallPanel(span);
1436
+ const messages = messagesPanel(span);
1437
+ const toolPanel = toolIOPanel(span);
1438
+ const attrs = attributesPanel(span);
1439
+ const events = eventsPanel(span);
1440
+ return shell(span.name, `${crumbs([["/", "runs"], [`/runs/${enc(runId)}`, shortId(runId)], [null, "span"]])}${head}
1441
+ <div class="panel"><div class="panel-body">${meta}</div></div>${modelPanel}${messages}${toolPanel}${attrs}${events}`);
1442
+ }
1443
+ /** Provider/model/token summary for a provider span (empty for other spans). */
1444
+ function modelCallPanel(span) {
1445
+ const a = span.attributes;
1446
+ const provider = attrStr(a, A.provider) ?? attrStr(a, A.system);
1447
+ const reqModel = attrStr(a, A.reqModel);
1448
+ const respModel = attrStr(a, A.respModel);
1449
+ if (!provider && !reqModel && !respModel)
1450
+ return "";
1451
+ const inT = attrNum(a, A.inTokens);
1452
+ const outT = attrNum(a, A.outTokens);
1453
+ const finish = attrStr(a, A.finish);
1454
+ const streaming = a[A.streaming];
1455
+ const cell = (k, v) => v !== undefined && v !== "" ? `<div class="cell"><div class="k">${k}</div><div class="v">${v}</div></div>` : "";
1456
+ const total = inT !== undefined || outT !== undefined ? String((inT ?? 0) + (outT ?? 0)) : undefined;
1457
+ return `<div class="panel"><h2>model call</h2><div class="panel-body"><div class="meta">
1458
+ ${cell("provider", provider ? esc(provider) : undefined)}
1459
+ ${cell("request model", reqModel ? esc(reqModel) : undefined)}
1460
+ ${cell("response model", respModel ? esc(respModel) : undefined)}
1461
+ ${cell("input tokens", inT !== undefined ? String(inT) : undefined)}
1462
+ ${cell("output tokens", outT !== undefined ? String(outT) : undefined)}
1463
+ ${cell("total tokens", total)}
1464
+ ${cell("finish reason", finish ? esc(finish) : undefined)}
1465
+ ${cell("streaming", typeof streaming === "boolean" ? String(streaming) : undefined)}
1466
+ </div></div></div>`;
1467
+ }
1468
+ /** Parse `gen_ai.prompt` / `gen_ai.completion` into message arrays. */
1469
+ function parseMessages(raw) {
1470
+ if (!raw)
1471
+ return [];
1472
+ try {
1473
+ const v = JSON.parse(raw);
1474
+ return Array.isArray(v) ? v : [v];
1475
+ }
1476
+ catch {
1477
+ return [];
1478
+ }
1479
+ }
1480
+ /** Render a single captured message (text, reasoning, tool calls). */
1481
+ function renderMessage(m) {
1482
+ const role = String(m.role ?? "?");
1483
+ let body = "";
1484
+ for (const p of m.content ?? []) {
1485
+ if (p.type === "text" && p.text)
1486
+ body += `<pre class="text">${esc(p.text)}</pre>`;
1487
+ else if ((p.type === "thinking" || p.type === "redacted_thinking") && p.text)
1488
+ body += `<details class="reason"><summary>reasoning</summary><pre>${esc(p.text)}</pre></details>`;
1489
+ else if (p.type === "image")
1490
+ body += `<div class="muted">[image]</div>`;
1491
+ }
1492
+ // Tool calls may arrive as `toolCalls` or the snake_case `tool_calls`.
1493
+ const toolCalls = m.toolCalls ?? m.tool_calls ?? [];
1494
+ for (const tc of toolCalls) {
1495
+ let args = "";
1496
+ try {
1497
+ args = JSON.stringify(tc.arguments);
1498
+ }
1499
+ catch {
1500
+ args = String(tc.arguments);
1501
+ }
1502
+ body += `<div class="toolcall">→ <span class="tname">${esc(tc.name)}</span>(<code>${esc(truncate(args, 400))}</code>)</div>`;
1503
+ }
1504
+ if (!body)
1505
+ body = `<div class="muted">(empty)</div>`;
1506
+ return `<div class="msg ${esc(role)}"><div class="msg-role">${esc(role)}</div><div class="msg-body">${body}</div></div>`;
1507
+ }
1508
+ /** Side-by-side prompt/completion panel when content was captured. */
1509
+ function messagesPanel(span) {
1510
+ const prompt = parseMessages(attrStr(span.attributes, A.prompt));
1511
+ const completion = parseMessages(attrStr(span.attributes, A.completion));
1512
+ const reasoning = parseReasoning(attrStr(span.attributes, A.reasoning));
1513
+ if (prompt.length === 0 && completion.length === 0 && reasoning.length === 0)
1514
+ return "";
1515
+ const left = prompt.length
1516
+ ? prompt.map(renderMessage).join("")
1517
+ : `<div class="muted">(no prompt captured)</div>`;
1518
+ let right = completion.length ? completion.map(renderMessage).join("") : "";
1519
+ if (reasoning.length) {
1520
+ right += `<details class="reason" open><summary>reasoning (${reasoning.length})</summary>${reasoning.map((r) => `<pre>${esc(r)}</pre>`).join("")}</details>`;
1521
+ }
1522
+ if (!right)
1523
+ right = `<div class="muted">(no completion captured)</div>`;
1524
+ return `<div class="panel">
1525
+ <h2>messages</h2>
1526
+ <div class="panel-body"><div class="msgs">
1527
+ <div class="msgcol"><h3>prompt → API</h3>${left}</div>
1528
+ <div class="msgcol"><h3>completion ← API</h3>${right}</div>
1529
+ </div></div>
1530
+ </div>`;
1531
+ }
1532
+ function parseReasoning(raw) {
1533
+ if (!raw)
1534
+ return [];
1535
+ try {
1536
+ const v = JSON.parse(raw);
1537
+ return Array.isArray(v) ? v.map(String) : [String(v)];
1538
+ }
1539
+ catch {
1540
+ return [raw];
1541
+ }
1542
+ }
1543
+ /** Tool-call summary (name + input/output sizes) for tool spans. */
1544
+ function toolIOPanel(span) {
1545
+ const name = attrStr(span.attributes, A.toolName);
1546
+ if (!name)
1547
+ return "";
1548
+ const inB = attrNum(span.attributes, A.toolIn);
1549
+ const outB = attrNum(span.attributes, A.toolOut);
1550
+ return `<div class="panel">
1551
+ <h2>tool call</h2>
1552
+ <div class="panel-body"><div class="meta">
1553
+ <div class="cell"><div class="k">tool</div><div class="v">${esc(name)}</div></div>
1554
+ <div class="cell"><div class="k">input size</div><div class="v">${inB ?? "—"} bytes</div></div>
1555
+ <div class="cell"><div class="k">output size</div><div class="v">${outB ?? "—"} bytes</div></div>
1556
+ </div></div>
1557
+ </div>`;
1558
+ }
1559
+ /** Full attribute table (content attributes are shown in their own panels). */
1560
+ function attributesPanel(span) {
1561
+ const hidden = new Set([A.prompt, A.completion, A.reasoning]);
1562
+ const entries = Object.entries(span.attributes).filter(([k]) => !hidden.has(k)).sort(([a], [b]) => a.localeCompare(b));
1563
+ if (entries.length === 0)
1564
+ return "";
1565
+ const rows = entries
1566
+ .map(([k, v]) => `<tr><td class="mono">${esc(k)}</td><td class="mono">${esc(truncate(fmtVal(v), 600))}</td></tr>`)
1567
+ .join("");
1568
+ return `<div class="panel panel-scroll">
1569
+ <h2>attributes <span class="count">${entries.length}</span></h2>
1570
+ <table><tbody>${rows}</tbody></table>
1571
+ </div>`;
1572
+ }
1573
+ function eventsPanel(span) {
1574
+ if (span.events.length === 0)
1575
+ return "";
1576
+ const rows = span.events
1577
+ .map((e) => `<tr><td class="mono">${esc(e.name)}</td><td class="muted">${esc(new Date(nanosToMs(e.timeUnixNano)).toISOString())}</td></tr>`)
1578
+ .join("");
1579
+ return `<div class="panel panel-scroll"><h2>events <span class="count">${span.events.length}</span></h2><table><tbody>${rows}</tbody></table></div>`;
1580
+ }
1581
+ // ── steps page ───────────────────────────────────────────────────────────────────
1582
+ function stepsPage(store, runId) {
1583
+ const spans = store.spansForRun(runId);
1584
+ if (spans.length === 0) {
1585
+ return shell(runId, `${crumbs([["/", "runs"], [`/runs/${enc(runId)}`, shortId(runId)], [null, "steps"]])}<div class="panel"><div class="empty">No spans for this run.</div></div>`);
1586
+ }
1587
+ const byParent = new Map();
1588
+ for (const s of spans) {
1589
+ const arr = byParent.get(s.parentSpanId) ?? [];
1590
+ arr.push(s);
1591
+ byParent.set(s.parentSpanId, arr);
1592
+ }
1593
+ const cmp = (a, b) => a.startTimeUnixNano < b.startTimeUnixNano ? -1 : a.startTimeUnixNano > b.startTimeUnixNano ? 1 : 0;
1594
+ const nodeSpans = spans.filter((s) => s.name === SPAN.node).sort(cmp);
1595
+ const anyCaptured = spans.some((s) => s.name === SPAN.generate || s.name === SPAN.stream
1596
+ ? attrStr(s.attributes, A.prompt) !== undefined
1597
+ : false);
1598
+ const replayHint = anyCaptured
1599
+ ? ""
1600
+ : `<div class="banner">No prompts/completions were captured for this run. Re-run with content capture enabled — <code>instrumentProvider(provider, tracer, { captureContent: true })</code> — to see what is sent to and received from the API here.</div>`;
1601
+ const head = `<div class="page-head">
1602
+ <div><h1>Steps</h1><div class="id">${esc(runId)}</div></div>
1603
+ <div class="actions"><a class="btn" href="/runs/${enc(runId)}">← run detail</a></div>
1604
+ </div>`;
1605
+ let cards = "";
1606
+ if (nodeSpans.length === 0) {
1607
+ // No graph-node spans: fall back to listing model/tool calls flatly.
1608
+ const calls = spans.filter((s) => s.name !== SPAN.run).sort(cmp);
1609
+ cards = `<div class="step"><div class="step-body">${calls.map((s) => callCard(runId, s)).join("") || `<div class="empty">No calls recorded.</div>`}</div></div>`;
1610
+ }
1611
+ else {
1612
+ cards = nodeSpans
1613
+ .map((node, i) => {
1614
+ const nodeName = attrStr(node.attributes, A.node) ?? attrStr(node.attributes, A.label) ?? `step ${i + 1}`;
1615
+ const children = (byParent.get(node.spanId) ?? []).sort(cmp);
1616
+ const status = node.statusCode === "error" ? "error" : "ok";
1617
+ const inner = children.length
1618
+ ? children.map((c) => callCard(runId, c)).join("")
1619
+ : `<div class="hint">No provider or tool calls in this step.</div>`;
1620
+ return `<div class="step" id="step-${esc(nodeName)}">
1621
+ <div class="step-head"><span class="n">${i + 1}</span><span class="nm">${esc(nodeName)}</span>
1622
+ <span class="badge ${status}">${status}</span>
1623
+ <span class="meta-inline">${fmtDur(spanDuration(node))}</span></div>
1624
+ <div class="step-body">${inner}</div>
1625
+ </div>`;
1626
+ })
1627
+ .join("");
1628
+ }
1629
+ return shell(runId, `${crumbs([["/", "runs"], [`/runs/${enc(runId)}`, shortId(runId)], [null, "steps"]])}${replayHint}${head}${cards}`);
1630
+ }
1631
+ /** Render a provider or tool call inside a step card. */
1632
+ function callCard(runId, span) {
1633
+ const link = `/runs/${enc(runId)}/spans/${enc(span.spanId)}`;
1634
+ if (span.name === SPAN.generate || span.name === SPAN.stream) {
1635
+ const model = attrStr(span.attributes, A.respModel) ?? attrStr(span.attributes, A.reqModel) ?? "model";
1636
+ const provider = attrStr(span.attributes, A.provider) ?? attrStr(span.attributes, A.system);
1637
+ const finish = attrStr(span.attributes, A.finish);
1638
+ const inTok = attrNum(span.attributes, A.inTokens);
1639
+ const outTok = attrNum(span.attributes, A.outTokens);
1640
+ const tokens = inTok !== undefined || outTok !== undefined ? ` · in ${inTok ?? "?"} / out ${outTok ?? "?"} tok` : "";
1641
+ const tail = `${tokens}${finish ? ` · ${esc(finish)}` : ""}`;
1642
+ const provLabel = provider ? `<span class="subtle">${esc(provider)}</span> ` : "";
1643
+ const prompt = parseMessages(attrStr(span.attributes, A.prompt));
1644
+ const completion = parseMessages(attrStr(span.attributes, A.completion));
1645
+ const reasoning = parseReasoning(attrStr(span.attributes, A.reasoning));
1646
+ let content = "";
1647
+ if (prompt.length || completion.length || reasoning.length) {
1648
+ const left = prompt.length ? prompt.map(renderMessage).join("") : `<div class="muted">(no prompt)</div>`;
1649
+ let right = completion.map(renderMessage).join("");
1650
+ if (reasoning.length)
1651
+ right += `<details class="reason"><summary>reasoning</summary>${reasoning.map((r) => `<pre>${esc(r)}</pre>`).join("")}</details>`;
1652
+ if (!right)
1653
+ right = `<div class="muted">(no completion)</div>`;
1654
+ content = `<div class="msgs"><div class="msgcol"><h3>prompt → API</h3>${left}</div><div class="msgcol"><h3>completion ← API</h3>${right}</div></div>`;
1655
+ }
1656
+ else {
1657
+ content = `<div class="hint">No captured content. <a href="${link}">span details →</a></div>`;
1658
+ }
1659
+ return `<div class="call">
1660
+ <div class="call-head"><span class="kind model">model call</span>${provLabel}<a href="${link}">${esc(model)}</a><span class="meta-inline">${fmtDur(spanDuration(span))}${tail}</span></div>
1661
+ ${content}
1662
+ </div>`;
1663
+ }
1664
+ if (span.name === SPAN.tool) {
1665
+ const name = attrStr(span.attributes, A.toolName) ?? shortName(span.name);
1666
+ const inB = attrNum(span.attributes, A.toolIn);
1667
+ const outB = attrNum(span.attributes, A.toolOut);
1668
+ const sizes = `in ${inB ?? "?"}B / out ${outB ?? "?"}B`;
1669
+ const err = span.statusCode === "error" ? ` <span class="badge err">error</span>` : "";
1670
+ return `<div class="call">
1671
+ <div class="call-head"><span class="kind tool">tool</span><a href="${link}">${esc(name)}</a>${err}<span class="meta-inline">${fmtDur(spanDuration(span))} · ${sizes}</span></div>
1672
+ </div>`;
1673
+ }
1674
+ // Any other nested span: a compact line.
1675
+ return `<div class="call"><div class="call-head"><span class="kind tool">${esc(shortName(span.name))}</span><a href="${link}">details</a><span class="meta-inline">${fmtDur(spanDuration(span))}</span></div></div>`;
1676
+ }
1677
+ // ── shared bits ──────────────────────────────────────────────────────────────────
1678
+ /** Render a breadcrumb trail; a `null` href makes the segment plain text. */
1679
+ function crumbs(items) {
1680
+ const parts = items.map(([href, label]) => href ? `<a href="${href}">${esc(label)}</a>` : `<span>${esc(label)}</span>`);
1681
+ return `<div class="crumbs">${parts.join(`<span class="sep">/</span>`)}</div>`;
1682
+ }
1683
+ //# sourceMappingURL=index.js.map