@telorun/debug-ui 0.3.0 → 0.5.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 CHANGED
@@ -7,18 +7,19 @@ import { type DebugEvent, type DebugFrame, eventSuffix, isLogFrame } from "./wir
7
7
  * tracks each node's lifecycle status and most-recent invocation. The component
8
8
  * layer adds positions (dagre) and rendering.
9
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.
10
+ * Two event families drive it:
11
+ * - Lifecycle events `<Kind>.<Name>.{Created,Initialized,Teardown}` — a node
12
+ * appears (its payload carries the resolved `{kind,name}` and `dependencies`),
13
+ * then flips to `initialized` / `torndown`.
14
+ * - Dispatch (trace) events — every capability call emits a structured payload
15
+ * `{ spanId, parentSpanId, capability, phase, outcome, ref:{kind,name}, … }`.
16
+ * The consumer reads the *payload*, never the dotted name: `ref` locates the
17
+ * node, `outcome` records the result. Terminal events carry an `outcome`;
18
+ * `phase:"start"` events carry none and are skipped here.
18
19
  *
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.
20
+ * When the producer has tracing on, dispatch events also carry `spanId` /
21
+ * `parentSpanId`; {@link deriveInvocations} folds those into the call tree behind
22
+ * the invocation list + scoped trace graph.
22
23
  */
23
24
 
24
25
  export type NodeStatus = "created" | "initialized" | "torndown";
@@ -28,7 +29,7 @@ export type InvokeOutcome = "ok" | "failed" | "rejected" | "cancelled";
28
29
  /** The most recent invocation observed for a node. */
29
30
  export interface InvokeRecord {
30
31
  outcome: InvokeOutcome;
31
- /** The suffix part after `<Kind>.<Name>.` (e.g. `Invoked`, `InvokeFailed`). */
32
+ /** The terminal event's suffix (`Invoked`, `InvokeFailed`, …) — for display only. */
32
33
  suffix: string;
33
34
  timestamp: string;
34
35
  /** The dispatched inputs (present on every invocation event the kernel emits). */
@@ -40,11 +41,20 @@ export interface InvokeRecord {
40
41
  }
41
42
 
42
43
  export interface GraphNode {
43
- /** Stable id — the resource name (dot-free, unique within its module scope). */
44
+ /** Stable id — the resource's full hierarchical id (`<owner.id>/<kind>.<name>`),
45
+ * unique across instances of the same templated kind. Falls back to the bare
46
+ * name on a legacy stream that carries no `id`. */
44
47
  id: string;
45
48
  kind: string;
46
49
  name: string;
47
50
  module?: string;
51
+ /** The owning resource's id, when this resource was spawned by another (a
52
+ * templated kind's child). Drives the collapsible parent/child grouping. */
53
+ ownerId?: string;
54
+ /** The resource's resolved config "after templating" (from the `Created`
55
+ * payload) — concrete values for compile-time CEL, `{kind,name}` for refs,
56
+ * `${{ … }}` for deferred runtime expressions, secrets scrubbed. */
57
+ properties?: unknown;
48
58
  status: NodeStatus;
49
59
  /** Total invocations seen across the stream. */
50
60
  invokeCount: number;
@@ -60,6 +70,10 @@ export interface GraphEdge {
60
70
  source: string;
61
71
  /** Target resource name (the dependency). */
62
72
  target: string;
73
+ /** An ownership edge (owner → spawned child), not a dependency. Added when a
74
+ * templated resource is expanded so its revealed children stay attached to it;
75
+ * the renderer draws it distinctly (dashed/muted). */
76
+ ownership?: boolean;
63
77
  }
64
78
 
65
79
  export interface GraphState {
@@ -71,9 +85,23 @@ interface WireResourceRef {
71
85
  kind: string;
72
86
  name: string;
73
87
  module?: string;
88
+ /** Full hierarchical id; absent on a legacy stream. */
89
+ id?: string;
74
90
  alias?: string;
75
91
  }
76
92
 
93
+ interface WireOwner {
94
+ kind: string;
95
+ name: string;
96
+ id: string;
97
+ }
98
+
99
+ /** The id a node is keyed by: the producer's hierarchical `id`, or the bare name
100
+ * on a legacy stream. Globally unique when `id` is present. */
101
+ function nodeKey(ref: { id?: string; name: string }): string {
102
+ return ref.id ?? ref.name;
103
+ }
104
+
77
105
  function asResource(payload: unknown): WireResourceRef | undefined {
78
106
  const res = (payload as { resource?: unknown } | undefined)?.resource;
79
107
  if (res && typeof res === "object") {
@@ -85,14 +113,52 @@ function asResource(payload: unknown): WireResourceRef | undefined {
85
113
  return undefined;
86
114
  }
87
115
 
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";
116
+ /** The owning resource a lifecycle/dispatch payload carries under `owner`, when
117
+ * the resource was spawned by another (a templated kind's child). */
118
+ function readOwner(payload: unknown): WireOwner | undefined {
119
+ const owner = (payload as { owner?: unknown } | undefined)?.owner;
120
+ if (owner && typeof owner === "object") {
121
+ const { kind, name, id } = owner as Record<string, unknown>;
122
+ if (typeof kind === "string" && typeof name === "string" && typeof id === "string") {
123
+ return { kind, name, id };
124
+ }
125
+ }
93
126
  return undefined;
94
127
  }
95
128
 
129
+ /** The resolved ref a dispatch event's payload carries under `ref` — `id` keys
130
+ * the node (a templated child's calls land on the right instance). */
131
+ function readRef(
132
+ payload: Record<string, unknown> | undefined,
133
+ ): { kind: string; name: string; id?: string } | undefined {
134
+ const ref = payload?.ref;
135
+ if (ref && typeof ref === "object") {
136
+ const { kind, name, id } = ref as Record<string, unknown>;
137
+ if (typeof kind === "string" && typeof name === "string") {
138
+ return { kind, name, id: typeof id === "string" ? id : undefined };
139
+ }
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ /** Narrow a payload `outcome` to a known {@link InvokeOutcome}. Absent on
145
+ * `phase:"start"` events, so they are naturally skipped by callers. */
146
+ function asOutcome(value: unknown): InvokeOutcome | undefined {
147
+ return value === "ok" || value === "failed" || value === "rejected" || value === "cancelled"
148
+ ? value
149
+ : undefined;
150
+ }
151
+
152
+ function num(value: unknown): number | undefined {
153
+ return typeof value === "number" ? value : undefined;
154
+ }
155
+
156
+ function asCapability(value: unknown): SpanCapability | undefined {
157
+ return value === "invoke" || value === "run" || value === "provide" || value === "request"
158
+ ? value
159
+ : undefined;
160
+ }
161
+
96
162
  /**
97
163
  * Fold a frame buffer into the resource graph. Logs are ignored; events are
98
164
  * applied in arrival order so the returned status / `lastInvoke` reflect the
@@ -112,15 +178,18 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
112
178
  if (suffix === "Created") {
113
179
  const res = asResource(event.payload);
114
180
  if (!res) return;
115
- const existing = nodes.get(res.name);
181
+ const key = nodeKey(res);
182
+ const existing = nodes.get(key);
116
183
  if (existing) {
117
184
  existing.lastActivitySeq = seq;
118
185
  } else {
119
- nodes.set(res.name, {
120
- id: res.name,
186
+ nodes.set(key, {
187
+ id: key,
121
188
  kind: res.kind,
122
189
  name: res.name,
123
190
  module: res.module,
191
+ ownerId: readOwner(event.payload)?.id,
192
+ properties: (event.payload as { properties?: unknown }).properties,
124
193
  status: "created",
125
194
  invokeCount: 0,
126
195
  lastActivitySeq: seq,
@@ -128,16 +197,17 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
128
197
  }
129
198
  const deps = (event.payload as { dependencies?: WireResourceRef[] }).dependencies ?? [];
130
199
  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 });
200
+ const depKey = dep?.name ? nodeKey(dep) : undefined;
201
+ if (!depKey || depKey === key) continue;
202
+ const id = `${key}->${depKey}`;
203
+ if (!edges.has(id)) edges.set(id, { id, source: key, target: depKey });
134
204
  }
135
205
  return;
136
206
  }
137
207
 
138
208
  if (suffix === "Initialized" || suffix === "Teardown") {
139
209
  const res = asResource(event.payload);
140
- const node = res ? nodes.get(res.name) : undefined;
210
+ const node = res ? nodes.get(nodeKey(res)) : undefined;
141
211
  if (node) {
142
212
  node.status = suffix === "Teardown" ? "torndown" : "initialized";
143
213
  node.lastActivitySeq = seq;
@@ -145,19 +215,19 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
145
215
  return;
146
216
  }
147
217
 
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;
218
+ // Dispatch (trace) events: the payload's `ref.id` resolves the target node
219
+ // directly (nodes are id-keyed). `phase:"start"` events carry no `outcome`
220
+ // and are skipped — only terminal events record a result.
221
+ const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
222
+ const ref = readRef(p);
223
+ const outcome = asOutcome(p?.outcome);
224
+ const node = ref ? nodes.get(nodeKey(ref)) : undefined;
225
+ if (!node || !outcome) return;
155
226
  node.invokeCount += 1;
156
227
  node.lastActivitySeq = seq;
157
- const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
158
228
  node.lastInvoke = {
159
229
  outcome,
160
- suffix: parsed.suffix,
230
+ suffix: eventSuffix(event.event),
161
231
  timestamp: event.timestamp,
162
232
  inputs: p && "inputs" in p ? p.inputs : undefined,
163
233
  outputs: p && "outputs" in p ? p.outputs : undefined,
@@ -168,17 +238,175 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
168
238
  return { nodes: [...nodes.values()], edges: [...edges.values()] };
169
239
  }
170
240
 
241
+ // ── Owner grouping (collapsible parents) ────────────────────────────────────
242
+
243
+ /** A topology node augmented with its grouping state — how many direct children
244
+ * it owns and whether they are currently revealed. */
245
+ export interface GroupedGraphNode extends GraphNode {
246
+ /** Number of resources directly owned by this node (spawned children). */
247
+ childCount: number;
248
+ /** Whether this node's children are currently shown (only meaningful when
249
+ * `childCount > 0`). */
250
+ expanded: boolean;
251
+ }
252
+
253
+ export interface GroupedGraphState {
254
+ nodes: GroupedGraphNode[];
255
+ edges: GraphEdge[];
256
+ }
257
+
258
+ /** Count direct children per owner id — how many resources each node spawned. */
259
+ function countChildren(nodes: readonly GraphNode[]): Map<string, number> {
260
+ const counts = new Map<string, number>();
261
+ for (const n of nodes) {
262
+ if (n.ownerId) counts.set(n.ownerId, (counts.get(n.ownerId) ?? 0) + 1);
263
+ }
264
+ return counts;
265
+ }
266
+
267
+ /**
268
+ * Collapse a topology by owner: a resource spawned by another (a templated kind's
269
+ * child) is hidden unless every ancestor in its owner chain is in `expanded`. A
270
+ * collapsed parent absorbs its hidden descendants' edges — each edge endpoint is
271
+ * remapped to its nearest visible ancestor — so the parent stays wired to the
272
+ * rest of the graph as one node. Pure: drives the Graph view's collapse toggles
273
+ * and is independently testable. With an empty `expanded` set, every owner is
274
+ * collapsed (the default), so the top-level topology stays readable.
275
+ */
276
+ export function collapseTopology(
277
+ graph: GraphState,
278
+ expanded: ReadonlySet<string>,
279
+ ): GroupedGraphState {
280
+ const byId = new Map<string, GraphNode>(graph.nodes.map((n) => [n.id, n] as [string, GraphNode]));
281
+ const childCount = countChildren(graph.nodes);
282
+
283
+ // A node is visible when no present ancestor in its owner chain is collapsed.
284
+ const visible = (node: GraphNode): boolean => {
285
+ let cur: GraphNode | undefined = node;
286
+ while (cur?.ownerId) {
287
+ const parent = byId.get(cur.ownerId);
288
+ if (!parent) return true; // owner not in this stream — don't hide under an unknown
289
+ if (!expanded.has(parent.id)) return false;
290
+ cur = parent;
291
+ }
292
+ return true;
293
+ };
294
+
295
+ // The nearest visible ancestor an edge endpoint folds into.
296
+ const resolveVisible = (id: string): string => {
297
+ const start = byId.get(id);
298
+ if (!start) return id;
299
+ let node: GraphNode = start;
300
+ while (!visible(node)) {
301
+ const parent = node.ownerId ? byId.get(node.ownerId) : undefined;
302
+ if (!parent) return node.id;
303
+ node = parent;
304
+ }
305
+ return node.id;
306
+ };
307
+
308
+ const nodes = graph.nodes
309
+ .filter(visible)
310
+ .map((n) => ({ ...n, childCount: childCount.get(n.id) ?? 0, expanded: expanded.has(n.id) }));
311
+
312
+ const edges = new Map<string, GraphEdge>();
313
+ for (const e of graph.edges) {
314
+ const source = resolveVisible(e.source);
315
+ const target = resolveVisible(e.target);
316
+ if (source === target) continue;
317
+ const id = `${source}->${target}`;
318
+ if (!edges.has(id)) edges.set(id, { id, source, target });
319
+ }
320
+
321
+ // Attach each revealed child to its owner: when a parent is expanded both it
322
+ // and its children are visible, but no dependency edge ties them together, so
323
+ // they would float as a detached cluster. An ownership edge keeps them grouped
324
+ // (and pulls the children next to the parent in the layout).
325
+ const visibleIds = new Set(nodes.map((n) => n.id));
326
+ for (const n of nodes) {
327
+ if (n.ownerId && visibleIds.has(n.ownerId)) {
328
+ const id = `owner:${n.ownerId}->${n.id}`;
329
+ edges.set(id, { id, source: n.ownerId, target: n.id, ownership: true });
330
+ }
331
+ }
332
+
333
+ return { nodes, edges: [...edges.values()] };
334
+ }
335
+
336
+ /**
337
+ * The drill-down view of one templated resource: the parent node plus the direct
338
+ * children it spawned. Children are wired by the dependency edges that run *among*
339
+ * them (e.g. an Http.Api → its SQL handlers); anything pointing outside the
340
+ * subtree is dropped (that wiring is visible at the level above). The parent
341
+ * connects by an ownership edge only to children that aren't already reached by a
342
+ * sibling's dependency edge — so a handler reached through the Http.Api isn't also
343
+ * tied directly to the parent, leaving a clean tree instead of a redundant fan.
344
+ * Each child that itself owns children reports its `childCount` for a further
345
+ * drill-in. Pure and depth-independent: drilling into a child calls this again.
346
+ */
347
+ export function subtreeGraph(topology: GraphState, ownerId: string): GroupedGraphState {
348
+ const childCount = countChildren(topology.nodes);
349
+
350
+ const parent = topology.nodes.find((n) => n.id === ownerId);
351
+ const children = topology.nodes.filter((n) => n.ownerId === ownerId);
352
+
353
+ const nodes: GroupedGraphNode[] = [];
354
+ if (parent) nodes.push({ ...parent, childCount: childCount.get(parent.id) ?? 0, expanded: true });
355
+ for (const c of children) {
356
+ nodes.push({ ...c, childCount: childCount.get(c.id) ?? 0, expanded: false });
357
+ }
358
+
359
+ const present = new Set(nodes.map((n) => n.id));
360
+ const edges = new Map<string, GraphEdge>();
361
+
362
+ // Dependency edges among the shown nodes; track which are reached by one so the
363
+ // parent doesn't also tie to a child already connected through a sibling.
364
+ const reachedByDep = new Set<string>();
365
+ for (const e of topology.edges) {
366
+ if (e.source !== e.target && present.has(e.source) && present.has(e.target) && !edges.has(e.id)) {
367
+ edges.set(e.id, e);
368
+ reachedByDep.add(e.target);
369
+ }
370
+ }
371
+
372
+ // Ownership edge parent → child, only for children not already wired in by a
373
+ // sibling's dependency edge.
374
+ for (const c of children) {
375
+ if (reachedByDep.has(c.id)) continue;
376
+ const id = `owner:${ownerId}->${c.id}`;
377
+ edges.set(id, { id, source: ownerId, target: c.id, ownership: true });
378
+ }
379
+
380
+ return { nodes, edges: [...edges.values()] };
381
+ }
382
+
171
383
  // ── Invocation traces ──────────────────────────────────────────────────────
172
384
 
173
- /** One invocation — a single dispatched call, identified by its `invocationId`. */
385
+ /** Which capability the span represents. `"request"` is an inbound-boundary span. */
386
+ export type SpanCapability = "invoke" | "run" | "provide" | "request";
387
+
388
+ /** One invocation — a single dispatched call, identified by its `spanId`. */
174
389
  export interface Invocation {
175
390
  id: number;
176
391
  parentId?: number;
392
+ /** Trace this span belongs to — present while tracing; groups the call tree. */
393
+ traceId?: string;
177
394
  kind: string;
178
395
  name: string;
396
+ /** The dispatched resource's full hierarchical id; absent on a legacy stream.
397
+ * Keys the scoped trace graph so a templated child collapses per instance. */
398
+ resourceId?: string;
399
+ capability?: SpanCapability;
179
400
  outcome: InvokeOutcome;
180
- /** The event suffix (`Invoked`, `InvokeFailed`, …). */
401
+ /** The terminal event's suffix (`Invoked`, `InvokeFailed`, …) — for display only. */
181
402
  suffix: string;
403
+ /** Human label (e.g. a route `"POST /feedback"`), for `request` spans. */
404
+ label?: string;
405
+ /** Structured span attributes (e.g. `{ method, path }`). */
406
+ attributes?: Record<string, unknown>;
407
+ /** On a trace root: the redacted CEL root scope (variables / secrets / resources
408
+ * / ports) the trace could reference. */
409
+ context?: Record<string, unknown>;
182
410
  timestamp: string;
183
411
  inputs?: unknown;
184
412
  outputs?: unknown;
@@ -195,39 +423,12 @@ export interface TraceState {
195
423
  roots: Invocation[];
196
424
  }
197
425
 
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
426
  /**
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.
427
+ * Fold the dispatch events that carry a `spanId` into a call tree. Each
428
+ * invocation appears once — keyed by `spanId`, the first terminal event wins, so
429
+ * the `InvokeRejected.Undeclared` echo (same id, emitted after `InvokeRejected`)
430
+ * is deduped. `phase:"start"` events carry no `outcome` and are skipped. Empty
431
+ * when the producer isn't tracing (no `spanId`).
231
432
  */
232
433
  export function deriveInvocations(frames: readonly DebugFrame[]): TraceState {
233
434
  const byId = new Map<number, Invocation>();
@@ -237,22 +438,34 @@ export function deriveInvocations(frames: readonly DebugFrame[]): TraceState {
237
438
  for (const frame of frames) {
238
439
  if (isLogFrame(frame)) continue;
239
440
  const event = frame as DebugEvent;
240
- const id = metaId((event.metadata as Record<string, unknown> | undefined)?.invocationId);
441
+ const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
442
+ const id = num(p?.spanId);
241
443
  if (id === undefined || byId.has(id)) continue;
242
- const parsed = parseInvocationEvent(event.event);
243
- if (!parsed) continue;
244
- const outcome = classify(parsed.suffix);
444
+ const outcome = asOutcome(p?.outcome);
245
445
  if (!outcome) continue;
446
+ const ref = readRef(p);
447
+ if (!ref) continue;
246
448
 
247
- const parentId = metaId((event.metadata as Record<string, unknown>).parentInvocationId);
248
- const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
449
+ const parentId = num(p?.parentSpanId);
249
450
  const invocation: Invocation = {
250
451
  id,
251
452
  parentId,
252
- kind: parsed.kind,
253
- name: parsed.name,
453
+ traceId: typeof p?.traceId === "string" ? p.traceId : undefined,
454
+ kind: ref.kind,
455
+ name: ref.name,
456
+ resourceId: ref.id,
457
+ capability: asCapability(p?.capability),
254
458
  outcome,
255
- suffix: parsed.suffix,
459
+ suffix: eventSuffix(event.event),
460
+ label: typeof p?.label === "string" ? p.label : undefined,
461
+ attributes:
462
+ p?.attributes && typeof p.attributes === "object"
463
+ ? (p.attributes as Record<string, unknown>)
464
+ : undefined,
465
+ context:
466
+ p?.context && typeof p.context === "object"
467
+ ? (p.context as Record<string, unknown>)
468
+ : undefined,
256
469
  timestamp: event.timestamp,
257
470
  inputs: p && "inputs" in p ? p.inputs : undefined,
258
471
  outputs: p && "outputs" in p ? p.outputs : undefined,
@@ -278,6 +491,8 @@ export interface TraceNode {
278
491
  id: string;
279
492
  kind: string;
280
493
  name: string;
494
+ /** Human label from the span (e.g. a route `"POST /feedback"`), if any. */
495
+ label?: string;
281
496
  isRoot: boolean;
282
497
  invocations: Invocation[];
283
498
  }
@@ -303,7 +518,7 @@ export function traceSubgraph(trace: TraceState, rootId: number): TraceSubgraph
303
518
  const root = trace.byId.get(rootId);
304
519
  if (!root) return { nodes: [], edges: [] };
305
520
 
306
- const resourceKey = (inv: Invocation) => `${inv.kind}.${inv.name}`;
521
+ const resourceKey = (inv: Invocation) => inv.resourceId ?? `${inv.kind}.${inv.name}`;
307
522
  const nodes = new Map<string, TraceNode>();
308
523
  const edges = new Map<string, TraceEdge>();
309
524
 
@@ -313,7 +528,15 @@ export function traceSubgraph(trace: TraceState, rootId: number): TraceSubgraph
313
528
  const key = resourceKey(inv);
314
529
  const node = nodes.get(key);
315
530
  if (node) node.invocations.push(inv);
316
- else nodes.set(key, { id: key, kind: inv.kind, name: inv.name, isRoot: id === rootId, invocations: [inv] });
531
+ else
532
+ nodes.set(key, {
533
+ id: key,
534
+ kind: inv.kind,
535
+ name: inv.name,
536
+ label: inv.label,
537
+ isRoot: id === rootId,
538
+ invocations: [inv],
539
+ });
317
540
 
318
541
  for (const childId of trace.childrenOf.get(id) ?? []) {
319
542
  const child = trace.byId.get(childId);