@telorun/debug-ui 0.2.1 → 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/src/graph.ts ADDED
@@ -0,0 +1,332 @@
1
+ import { type DebugEvent, type DebugFrame, eventSuffix, isLogFrame } from "./wire.js";
2
+
3
+ /**
4
+ * Pure, framework-agnostic derivation of a resource graph from a debug event
5
+ * stream — the model behind the Graph view. No React, no layout: it folds the
6
+ * frame buffer into nodes (one per resource) and edges (dependency wiring), and
7
+ * tracks each node's lifecycle status and most-recent invocation. The component
8
+ * layer adds positions (dagre) and rendering.
9
+ *
10
+ * Three event families drive it, all named `<Kind>.<Name>.<Suffix>`:
11
+ * - `.Created` → a node appears (status `created`); its payload carries the
12
+ * resolved `{kind,name}` and the `dependencies` (the edges).
13
+ * - `.Initialized` → the node flips to `initialized`.
14
+ * - `.Teardown` → the node flips to `torndown`.
15
+ * - invocation suffixes (`Invoked`, `InvokeFailed`, `InvokeRejected[.Undeclared]`,
16
+ * `InvokeCancelled`, `RunCancelled`) → record the call's outcome, inputs and
17
+ * outputs on the matching node.
18
+ *
19
+ * When the producer has tracing on, invocation events also carry
20
+ * `metadata.invocationId` / `metadata.parentInvocationId`; {@link deriveInvocations}
21
+ * folds those into the call tree behind the invocation list + scoped trace graph.
22
+ */
23
+
24
+ export type NodeStatus = "created" | "initialized" | "torndown";
25
+
26
+ export type InvokeOutcome = "ok" | "failed" | "rejected" | "cancelled";
27
+
28
+ /** The most recent invocation observed for a node. */
29
+ export interface InvokeRecord {
30
+ outcome: InvokeOutcome;
31
+ /** The suffix part after `<Kind>.<Name>.` (e.g. `Invoked`, `InvokeFailed`). */
32
+ suffix: string;
33
+ timestamp: string;
34
+ /** The dispatched inputs (present on every invocation event the kernel emits). */
35
+ inputs?: unknown;
36
+ /** The returned outputs — only on an `ok` outcome. */
37
+ outputs?: unknown;
38
+ /** The full raw event payload (error / cancellation detail for non-`ok`). */
39
+ payload: unknown;
40
+ }
41
+
42
+ export interface GraphNode {
43
+ /** Stable id — the resource name (dot-free, unique within its module scope). */
44
+ id: string;
45
+ kind: string;
46
+ name: string;
47
+ module?: string;
48
+ status: NodeStatus;
49
+ /** Total invocations seen across the stream. */
50
+ invokeCount: number;
51
+ lastInvoke?: InvokeRecord;
52
+ /** Buffer index of the last frame that touched this node — drives the pulse:
53
+ * the renderer flashes a node whose activity index advanced since last paint. */
54
+ lastActivitySeq: number;
55
+ }
56
+
57
+ export interface GraphEdge {
58
+ id: string;
59
+ /** Source resource name (the dependant). */
60
+ source: string;
61
+ /** Target resource name (the dependency). */
62
+ target: string;
63
+ }
64
+
65
+ export interface GraphState {
66
+ nodes: GraphNode[];
67
+ edges: GraphEdge[];
68
+ }
69
+
70
+ interface WireResourceRef {
71
+ kind: string;
72
+ name: string;
73
+ module?: string;
74
+ alias?: string;
75
+ }
76
+
77
+ function asResource(payload: unknown): WireResourceRef | undefined {
78
+ const res = (payload as { resource?: unknown } | undefined)?.resource;
79
+ if (res && typeof res === "object") {
80
+ const { kind, name } = res as Record<string, unknown>;
81
+ if (typeof kind === "string" && typeof name === "string") {
82
+ return { kind, name, ...res } as WireResourceRef;
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ function classify(suffix: string): InvokeOutcome | undefined {
89
+ if (suffix === "Invoked") return "ok";
90
+ if (suffix.startsWith("InvokeFailed")) return "failed";
91
+ if (suffix.startsWith("InvokeRejected")) return "rejected";
92
+ if (suffix === "InvokeCancelled" || suffix === "RunCancelled") return "cancelled";
93
+ return undefined;
94
+ }
95
+
96
+ /**
97
+ * Fold a frame buffer into the resource graph. Logs are ignored; events are
98
+ * applied in arrival order so the returned status / `lastInvoke` reflect the
99
+ * latest state. Edges are deduped by `source→target` and retained even when the
100
+ * target node hasn't been seen (an imported dependency may live in another scope);
101
+ * the renderer drops dangling edges.
102
+ */
103
+ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
104
+ const nodes = new Map<string, GraphNode>();
105
+ const edges = new Map<string, GraphEdge>();
106
+
107
+ frames.forEach((frame, seq) => {
108
+ if (isLogFrame(frame)) return;
109
+ const event = frame as DebugEvent;
110
+ const suffix = eventSuffix(event.event);
111
+
112
+ if (suffix === "Created") {
113
+ const res = asResource(event.payload);
114
+ if (!res) return;
115
+ const existing = nodes.get(res.name);
116
+ if (existing) {
117
+ existing.lastActivitySeq = seq;
118
+ } else {
119
+ nodes.set(res.name, {
120
+ id: res.name,
121
+ kind: res.kind,
122
+ name: res.name,
123
+ module: res.module,
124
+ status: "created",
125
+ invokeCount: 0,
126
+ lastActivitySeq: seq,
127
+ });
128
+ }
129
+ const deps = (event.payload as { dependencies?: WireResourceRef[] }).dependencies ?? [];
130
+ for (const dep of deps) {
131
+ if (!dep?.name || dep.name === res.name) continue;
132
+ const id = `${res.name}->${dep.name}`;
133
+ if (!edges.has(id)) edges.set(id, { id, source: res.name, target: dep.name });
134
+ }
135
+ return;
136
+ }
137
+
138
+ if (suffix === "Initialized" || suffix === "Teardown") {
139
+ const res = asResource(event.payload);
140
+ const node = res ? nodes.get(res.name) : undefined;
141
+ if (node) {
142
+ node.status = suffix === "Teardown" ? "torndown" : "initialized";
143
+ node.lastActivitySeq = seq;
144
+ }
145
+ return;
146
+ }
147
+
148
+ // Invocation events share `parseInvocationEvent` with the trace fold — nodes
149
+ // are name-keyed, so the parsed resource name resolves the target directly.
150
+ const parsed = parseInvocationEvent(event.event);
151
+ const node = parsed ? nodes.get(parsed.name) : undefined;
152
+ if (!parsed || !node) return;
153
+ const outcome = classify(parsed.suffix);
154
+ if (!outcome) return;
155
+ node.invokeCount += 1;
156
+ node.lastActivitySeq = seq;
157
+ const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
158
+ node.lastInvoke = {
159
+ outcome,
160
+ suffix: parsed.suffix,
161
+ timestamp: event.timestamp,
162
+ inputs: p && "inputs" in p ? p.inputs : undefined,
163
+ outputs: p && "outputs" in p ? p.outputs : undefined,
164
+ payload: event.payload,
165
+ };
166
+ });
167
+
168
+ return { nodes: [...nodes.values()], edges: [...edges.values()] };
169
+ }
170
+
171
+ // ── Invocation traces ──────────────────────────────────────────────────────
172
+
173
+ /** One invocation — a single dispatched call, identified by its `invocationId`. */
174
+ export interface Invocation {
175
+ id: number;
176
+ parentId?: number;
177
+ kind: string;
178
+ name: string;
179
+ outcome: InvokeOutcome;
180
+ /** The event suffix (`Invoked`, `InvokeFailed`, …). */
181
+ suffix: string;
182
+ timestamp: string;
183
+ inputs?: unknown;
184
+ outputs?: unknown;
185
+ /** Full raw event payload (error / cancellation detail for non-`ok`). */
186
+ payload: unknown;
187
+ }
188
+
189
+ export interface TraceState {
190
+ /** Every invocation, keyed by id. */
191
+ byId: Map<number, Invocation>;
192
+ /** Child invocation ids per parent id. */
193
+ childrenOf: Map<number, number[]>;
194
+ /** Root invocations (no parent), in arrival order. */
195
+ roots: Invocation[];
196
+ }
197
+
198
+ /** Known invocation suffixes, longest first so `InvokeRejected.Undeclared` wins
199
+ * over `InvokeRejected`. */
200
+ const INVOKE_SUFFIXES = [
201
+ "InvokeRejected.Undeclared",
202
+ "InvokeCancelled",
203
+ "InvokeRejected",
204
+ "InvokeFailed",
205
+ "Invoked",
206
+ "RunCancelled",
207
+ ] as const;
208
+
209
+ /** Split a dotted invocation event into `{ kind, name, suffix }` by stripping a
210
+ * known trailing suffix — self-contained, no node registry needed. */
211
+ function parseInvocationEvent(event: string): { kind: string; name: string; suffix: string } | undefined {
212
+ for (const suffix of INVOKE_SUFFIXES) {
213
+ if (event.endsWith(`.${suffix}`)) {
214
+ const head = event.slice(0, event.length - suffix.length - 1);
215
+ const dot = head.lastIndexOf(".");
216
+ if (dot <= 0) return undefined;
217
+ return { kind: head.slice(0, dot), name: head.slice(dot + 1), suffix };
218
+ }
219
+ }
220
+ return undefined;
221
+ }
222
+
223
+ function metaId(value: unknown): number | undefined {
224
+ return typeof value === "number" ? value : undefined;
225
+ }
226
+
227
+ /**
228
+ * Fold the invocation events that carry `metadata.invocationId` into a call tree.
229
+ * Each invocation appears once (the `.Undeclared` rejection echo is deduped by id,
230
+ * keeping the primary event). Empty when the producer isn't tracing.
231
+ */
232
+ export function deriveInvocations(frames: readonly DebugFrame[]): TraceState {
233
+ const byId = new Map<number, Invocation>();
234
+ const childrenOf = new Map<number, number[]>();
235
+ const roots: Invocation[] = [];
236
+
237
+ for (const frame of frames) {
238
+ if (isLogFrame(frame)) continue;
239
+ const event = frame as DebugEvent;
240
+ const id = metaId((event.metadata as Record<string, unknown> | undefined)?.invocationId);
241
+ if (id === undefined || byId.has(id)) continue;
242
+ const parsed = parseInvocationEvent(event.event);
243
+ if (!parsed) continue;
244
+ const outcome = classify(parsed.suffix);
245
+ if (!outcome) continue;
246
+
247
+ const parentId = metaId((event.metadata as Record<string, unknown>).parentInvocationId);
248
+ const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
249
+ const invocation: Invocation = {
250
+ id,
251
+ parentId,
252
+ kind: parsed.kind,
253
+ name: parsed.name,
254
+ outcome,
255
+ suffix: parsed.suffix,
256
+ timestamp: event.timestamp,
257
+ inputs: p && "inputs" in p ? p.inputs : undefined,
258
+ outputs: p && "outputs" in p ? p.outputs : undefined,
259
+ payload: event.payload,
260
+ };
261
+ byId.set(id, invocation);
262
+ if (parentId === undefined) {
263
+ roots.push(invocation);
264
+ } else {
265
+ const list = childrenOf.get(parentId);
266
+ if (list) list.push(id);
267
+ else childrenOf.set(parentId, [id]);
268
+ }
269
+ }
270
+
271
+ return { byId, childrenOf, roots };
272
+ }
273
+
274
+ /** One resource in a scoped trace graph — collapses repeated calls to the same
275
+ * resource into a single node carrying all its invocations within that trace. */
276
+ export interface TraceNode {
277
+ /** Resource key `kind.name`. */
278
+ id: string;
279
+ kind: string;
280
+ name: string;
281
+ isRoot: boolean;
282
+ invocations: Invocation[];
283
+ }
284
+
285
+ export interface TraceEdge {
286
+ id: string;
287
+ source: string;
288
+ target: string;
289
+ }
290
+
291
+ export interface TraceSubgraph {
292
+ nodes: TraceNode[];
293
+ edges: TraceEdge[];
294
+ }
295
+
296
+ /**
297
+ * The call graph of a single trace: the resources that participated in `rootId`'s
298
+ * subtree, wired by the actual parent→child call edges. A resource invoked more
299
+ * than once in the trace is one node holding all its calls; edges are deduped by
300
+ * `source→target`.
301
+ */
302
+ export function traceSubgraph(trace: TraceState, rootId: number): TraceSubgraph {
303
+ const root = trace.byId.get(rootId);
304
+ if (!root) return { nodes: [], edges: [] };
305
+
306
+ const resourceKey = (inv: Invocation) => `${inv.kind}.${inv.name}`;
307
+ const nodes = new Map<string, TraceNode>();
308
+ const edges = new Map<string, TraceEdge>();
309
+
310
+ const visit = (id: number): void => {
311
+ const inv = trace.byId.get(id);
312
+ if (!inv) return;
313
+ const key = resourceKey(inv);
314
+ const node = nodes.get(key);
315
+ if (node) node.invocations.push(inv);
316
+ else nodes.set(key, { id: key, kind: inv.kind, name: inv.name, isRoot: id === rootId, invocations: [inv] });
317
+
318
+ for (const childId of trace.childrenOf.get(id) ?? []) {
319
+ const child = trace.byId.get(childId);
320
+ if (!child) continue;
321
+ const target = resourceKey(child);
322
+ if (key !== target) {
323
+ const edgeId = `${key}->${target}`;
324
+ if (!edges.has(edgeId)) edges.set(edgeId, { id: edgeId, source: key, target });
325
+ }
326
+ visit(childId);
327
+ }
328
+ };
329
+ visit(rootId);
330
+
331
+ return { nodes: [...nodes.values()], edges: [...edges.values()] };
332
+ }
package/src/index.ts CHANGED
@@ -19,3 +19,19 @@ export { type EventFilter, matchesFilter, distinctSuffixes } from "./filter.js";
19
19
  export type { DebugTheme } from "./theme.js";
20
20
  export { type DebugStreamHandlers, connectDebugStream } from "./sse-client.js";
21
21
  export { type FoundBlob, collectBlobs, blobDimensions, formatBytes } from "./media.js";
22
+ export {
23
+ type GraphState,
24
+ type GraphNode,
25
+ type GraphEdge,
26
+ type NodeStatus,
27
+ type InvokeOutcome,
28
+ type InvokeRecord,
29
+ type Invocation,
30
+ type TraceState,
31
+ type TraceNode,
32
+ type TraceEdge,
33
+ type TraceSubgraph,
34
+ deriveGraph,
35
+ deriveInvocations,
36
+ traceSubgraph,
37
+ } from "./graph.js";
@@ -1 +0,0 @@
1
- .tdbg-root{--tdbg-bg: #0f1115;--tdbg-panel: #161a21;--tdbg-line: #232a34;--tdbg-text: #d7dce3;--tdbg-muted: #7a8493;--tdbg-accent: #4f9cf0;--tdbg-bad: #ff8079;--tdbg-bad-bg: rgba(248, 81, 73, .2);--tdbg-good: #5fd673;--tdbg-good-bg: rgba(63, 185, 80, .18);display:flex;flex-direction:column;height:100%;min-height:0;font:13px/1.5 ui-monospace,SF Mono,Menlo,Consolas,monospace;color:var(--tdbg-text);background:var(--tdbg-bg)}.tdbg-root[data-theme=light]{--tdbg-bg: #ffffff;--tdbg-panel: #f6f7f9;--tdbg-line: #e2e5e9;--tdbg-text: #1f2328;--tdbg-muted: #656d76;--tdbg-accent: #2f6feb;--tdbg-bad: #cf222e;--tdbg-bad-bg: rgba(207, 34, 46, .1);--tdbg-good: #1a7f37;--tdbg-good-bg: rgba(26, 127, 55, .1)}.tdbg-tabs{display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--tdbg-panel);border-bottom:1px solid var(--tdbg-line)}.tdbg-tab{padding:4px 10px;color:var(--tdbg-muted);background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font:inherit}.tdbg-tab:hover{color:var(--tdbg-text)}.tdbg-tab-on{color:var(--tdbg-text);background:var(--tdbg-bg);border-color:var(--tdbg-line)}.tdbg-tabcount{margin-left:4px;color:var(--tdbg-muted);font-size:11px}.tdbg-tabs-spacer{flex:1}.tdbg-endpoints{display:flex;align-items:center;gap:6px}.tdbg-endpoint{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;color:var(--tdbg-muted);background:var(--tdbg-bg);border:1px solid var(--tdbg-line);border-radius:999px;font-size:11px;white-space:nowrap}.tdbg-endpoint-link{color:var(--tdbg-accent);text-decoration:none}.tdbg-endpoint-link:hover{border-color:var(--tdbg-accent)}.tdbg-bar{position:sticky;top:0;z-index:1;padding:8px 10px;background:var(--tdbg-panel);border-bottom:1px solid var(--tdbg-line)}.tdbg-bar-row{display:flex;align-items:center;gap:8px}.tdbg-search{flex:1}.tdbg-search,.tdbg-kind{padding:5px 8px;color:var(--tdbg-text);background:var(--tdbg-bg);border:1px solid var(--tdbg-line);border-radius:5px;font:inherit}.tdbg-kind{width:140px}.tdbg-btn{padding:5px 10px;color:var(--tdbg-text);background:var(--tdbg-bg);border:1px solid var(--tdbg-line);border-radius:5px;cursor:pointer;font:inherit}.tdbg-btn:hover{border-color:var(--tdbg-accent)}.tdbg-icon-btn{display:inline-flex;align-items:center;justify-content:center;padding:5px 7px}.tdbg-counts{color:var(--tdbg-muted);white-space:nowrap}.tdbg-status{width:9px;height:9px;border-radius:50%;background:var(--tdbg-muted);flex:0 0 auto}.tdbg-status-open{background:#3fb950}.tdbg-status-connecting{background:#d29922}.tdbg-status-closed{background:#f85149}.tdbg-facets{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}.tdbg-facet{padding:2px 8px;color:var(--tdbg-muted);background:var(--tdbg-bg);border:1px solid var(--tdbg-line);border-radius:999px;cursor:pointer;font:inherit}.tdbg-facet-on{color:var(--tdbg-text);border-color:var(--tdbg-accent);background:#4f9cf02e}.tdbg-table{flex:1;min-height:0;overflow:auto}.tdbg-empty{padding:24px;color:var(--tdbg-muted);text-align:center}.tdbg-row{border-bottom:1px solid var(--tdbg-line)}.tdbg-rowhead{display:flex;align-items:baseline;gap:8px;width:100%;padding:4px 10px;background:none;border:none;color:inherit;cursor:pointer;text-align:left;font:inherit}.tdbg-rowhead:hover{background:var(--tdbg-panel)}.tdbg-caret{width:12px;color:var(--tdbg-muted)}.tdbg-time{color:var(--tdbg-muted);white-space:nowrap}.tdbg-badge{padding:1px 6px;border-radius:4px;background:var(--tdbg-line);color:var(--tdbg-text);white-space:nowrap}.tdbg-suffix-failed,.tdbg-suffix-rejected,.tdbg-suffix-cancelled{background:var(--tdbg-bad-bg);color:var(--tdbg-bad)}.tdbg-suffix-invoked,.tdbg-suffix-listening,.tdbg-suffix-started,.tdbg-suffix-initialized{background:var(--tdbg-good-bg);color:var(--tdbg-good)}.tdbg-name{white-space:nowrap}.tdbg-preview{color:var(--tdbg-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tdbg-payload-wrap{background:var(--tdbg-panel)}.tdbg-payload{margin:0;padding:8px 10px 10px 30px;background:var(--tdbg-panel);overflow:auto;white-space:pre-wrap;word-break:break-word}.tdbg-media{display:flex;flex-wrap:wrap;gap:12px;padding:10px 10px 4px 30px}.tdbg-blob{margin:0;display:flex;flex-direction:column;gap:4px;max-width:320px}.tdbg-blob-img{max-width:320px;max-height:240px;border:1px solid var(--tdbg-line);border-radius:6px;background:repeating-conic-gradient(#1b202840 0% 25%,transparent 0% 50%) 50% / 16px 16px;object-fit:contain}.tdbg-blob-file{display:inline-block;padding:8px 12px;color:var(--tdbg-accent);border:1px solid var(--tdbg-line);border-radius:6px;text-decoration:none}.tdbg-blob-file:hover{border-color:var(--tdbg-accent)}.tdbg-blob-cap{color:var(--tdbg-muted);font-size:11px;word-break:break-all}.tdbg-muted{color:var(--tdbg-muted)}.tdbg-ref{color:var(--tdbg-accent)}.tdbg-slot{display:flex;flex:1;min-height:0;overflow:hidden}.tdbg-logs{flex:1;min-height:0;overflow:auto;padding:4px 0}.tdbg-logline{display:flex;gap:8px;padding:1px 10px;white-space:pre-wrap;word-break:break-word}.tdbg-logline:hover{background:var(--tdbg-panel)}.tdbg-logtext{flex:1}.tdbg-log-stderr .tdbg-logtext{color:var(--tdbg-bad)}