@telorun/debug-ui 0.4.0 → 0.6.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/endpoints.ts CHANGED
@@ -13,6 +13,12 @@ export interface AppEndpoint {
13
13
  url?: string;
14
14
  }
15
15
 
16
+ /** Per-port reachability of a running app's endpoint, surfaced as a status icon
17
+ * beside the link: a spinner while `checking`, ok when `reachable`, error when
18
+ * `unreachable`. Mirrors the runner contract's `ReachabilityState`, kept local so
19
+ * debug-ui stays browser-safe with no dependency on the runner package. */
20
+ export type EndpointReachability = "checking" | "reachable" | "unreachable";
21
+
16
22
  /** The link a tcp endpoint resolves to, or `null` for a non-linkable (udp)
17
23
  * endpoint. Prefers an explicit `url`; otherwise derives `http://host:port`. */
18
24
  export function endpointHref(endpoint: AppEndpoint): string | null {
package/src/graph.test.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { deriveGraph, deriveInvocations, traceSubgraph } from "./graph.js";
2
+ import {
3
+ collapseTopology,
4
+ deriveGraph,
5
+ deriveInvocations,
6
+ subtreeGraph,
7
+ traceSubgraph,
8
+ } from "./graph.js";
3
9
  import type { DebugFrame } from "./wire.js";
4
10
 
5
11
  let seq = 0;
@@ -12,6 +18,35 @@ const ev = (event: string, payload?: unknown): DebugFrame => ({
12
18
  const created = (kind: string, name: string, deps: { kind: string; name: string }[] = []) =>
13
19
  ev(`${kind}.${name}.Created`, { resource: { kind, name }, dependencies: deps });
14
20
 
21
+ type Owner = { kind: string; name: string; id: string };
22
+ const idOf = (kind: string, name: string, ownerId?: string) =>
23
+ ownerId ? `${ownerId}/${kind}.${name}` : `${kind}.${name}`;
24
+
25
+ /** A `Created` carrying the hierarchical `id` (and `owner`, for a spawned child)
26
+ * the current kernel emits — the shape that disambiguates two instances of the
27
+ * same templated kind. */
28
+ const createdR = (
29
+ kind: string,
30
+ name: string,
31
+ opts: { owner?: Owner; deps?: { kind: string; name: string; id?: string }[] } = {},
32
+ ) =>
33
+ ev(`${kind}.${name}.Created`, {
34
+ resource: { kind, name, id: idOf(kind, name, opts.owner?.id) },
35
+ ...(opts.owner ? { owner: opts.owner } : {}),
36
+ dependencies: opts.deps ?? [],
37
+ });
38
+
39
+ /** A terminal dispatch event whose `ref` carries the hierarchical `id`. */
40
+ const dispId = (
41
+ name: string,
42
+ kind: string,
43
+ id: string,
44
+ outcome: Outcome,
45
+ suffix: string,
46
+ detail: Record<string, unknown> = {},
47
+ ): DebugFrame =>
48
+ ev(`${name}.${suffix}`, { capability: "invoke", phase: "end", outcome, ref: { kind, name, id }, ...detail });
49
+
15
50
  type Outcome = "ok" | "failed" | "rejected" | "cancelled";
16
51
 
17
52
  /** A terminal dispatch (trace) event: the name drops the kind, the payload carries
@@ -141,6 +176,174 @@ describe("deriveGraph", () => {
141
176
  });
142
177
  });
143
178
 
179
+ describe("owner grouping", () => {
180
+ const todos: Owner = { kind: "Crud.Resource", name: "todos", id: "Crud.Resource.todos" };
181
+ const users: Owner = { kind: "Crud.Resource", name: "users", id: "Crud.Resource.users" };
182
+
183
+ it("keeps same-named children of two templated instances from colliding", () => {
184
+ const { nodes } = deriveGraph([
185
+ createdR("Crud.Resource", "todos"),
186
+ createdR("Crud.Resource", "users"),
187
+ createdR("SqlRepo.Read", "reader", { owner: todos }),
188
+ createdR("SqlRepo.Read", "reader", { owner: users }),
189
+ ]);
190
+ const readers = nodes.filter((n) => n.name === "reader");
191
+ expect(readers.map((n) => n.id).sort()).toEqual([
192
+ "Crud.Resource.todos/SqlRepo.Read.reader",
193
+ "Crud.Resource.users/SqlRepo.Read.reader",
194
+ ]);
195
+ expect(readers.map((n) => n.ownerId).sort()).toEqual([
196
+ "Crud.Resource.todos",
197
+ "Crud.Resource.users",
198
+ ]);
199
+ });
200
+
201
+ it("attributes an invocation to the right instance via ref.id", () => {
202
+ const { nodes } = deriveGraph([
203
+ createdR("Crud.Resource", "todos"),
204
+ createdR("Crud.Resource", "users"),
205
+ createdR("SqlRepo.Read", "reader", { owner: todos }),
206
+ createdR("SqlRepo.Read", "reader", { owner: users }),
207
+ dispId("reader", "SqlRepo.Read", "Crud.Resource.todos/SqlRepo.Read.reader", "ok", "Invoked", {
208
+ inputs: {},
209
+ outputs: 1,
210
+ }),
211
+ ]);
212
+ expect(nodes.find((n) => n.id === "Crud.Resource.todos/SqlRepo.Read.reader")?.invokeCount).toBe(1);
213
+ expect(nodes.find((n) => n.id === "Crud.Resource.users/SqlRepo.Read.reader")?.invokeCount).toBe(0);
214
+ });
215
+
216
+ it("collapseTopology hides children until their owner is expanded", () => {
217
+ const graph = deriveGraph([
218
+ createdR("Crud.Resource", "todos"),
219
+ createdR("SqlRepo.Read", "reader", { owner: todos }),
220
+ createdR("SqlRepo.Create", "creator", { owner: todos }),
221
+ ]);
222
+
223
+ const collapsedDefault = collapseTopology(graph, new Set());
224
+ expect(collapsedDefault.nodes.map((n) => n.id)).toEqual(["Crud.Resource.todos"]);
225
+ expect(collapsedDefault.nodes[0]).toMatchObject({ childCount: 2, expanded: false });
226
+
227
+ const expanded = collapseTopology(graph, new Set(["Crud.Resource.todos"]));
228
+ expect(expanded.nodes.map((n) => n.id).sort()).toEqual([
229
+ "Crud.Resource.todos",
230
+ "Crud.Resource.todos/SqlRepo.Create.creator",
231
+ "Crud.Resource.todos/SqlRepo.Read.reader",
232
+ ]);
233
+ expect(expanded.nodes.find((n) => n.id === "Crud.Resource.todos")).toMatchObject({ expanded: true });
234
+
235
+ // Revealed children stay attached to their owner via ownership edges.
236
+ const owns = expanded.edges.filter((e) => e.ownership);
237
+ expect(owns.map((e) => e.target).sort()).toEqual([
238
+ "Crud.Resource.todos/SqlRepo.Create.creator",
239
+ "Crud.Resource.todos/SqlRepo.Read.reader",
240
+ ]);
241
+ expect(owns.every((e) => e.source === "Crud.Resource.todos")).toBe(true);
242
+ });
243
+
244
+ it("adds no ownership edges while the owner is collapsed", () => {
245
+ const graph = deriveGraph([
246
+ createdR("Crud.Resource", "todos"),
247
+ createdR("SqlRepo.Read", "reader", { owner: todos }),
248
+ ]);
249
+ expect(collapseTopology(graph, new Set()).edges.some((e) => e.ownership)).toBe(false);
250
+ });
251
+
252
+ it("collapseTopology folds a hidden child's edge onto its owner", () => {
253
+ const graph = deriveGraph([
254
+ createdR("Sql.Connection", "db"),
255
+ createdR("Crud.Resource", "todos"),
256
+ createdR("SqlRepo.Read", "reader", {
257
+ owner: todos,
258
+ deps: [{ kind: "Sql.Connection", name: "db", id: "Sql.Connection.db" }],
259
+ }),
260
+ ]);
261
+ const collapsed = collapseTopology(graph, new Set());
262
+ expect(collapsed.edges).toContainEqual({
263
+ id: "Crud.Resource.todos->Sql.Connection.db",
264
+ source: "Crud.Resource.todos",
265
+ target: "Sql.Connection.db",
266
+ });
267
+ // The child node itself is hidden while collapsed.
268
+ expect(collapsed.nodes.some((n) => n.id.endsWith("SqlRepo.Read.reader"))).toBe(false);
269
+ });
270
+ });
271
+
272
+ describe("subtreeGraph (drill-down pane)", () => {
273
+ const todos: Owner = { kind: "Crud.Resource", name: "todos", id: "Crud.Resource.todos" };
274
+
275
+ it("links the parent only to children not already reached by a sibling", () => {
276
+ const topology = deriveGraph([
277
+ createdR("Crud.Resource", "todos"),
278
+ createdR("Http.Api", "api", {
279
+ owner: todos,
280
+ deps: [{ kind: "SqlRepo.Read", name: "reader", id: "Crud.Resource.todos/SqlRepo.Read.reader" }],
281
+ }),
282
+ createdR("SqlRepo.Read", "reader", { owner: todos }),
283
+ ]);
284
+ const sub = subtreeGraph(topology, "Crud.Resource.todos");
285
+
286
+ expect(sub.nodes.map((n) => n.id).sort()).toEqual([
287
+ "Crud.Resource.todos",
288
+ "Crud.Resource.todos/Http.Api.api",
289
+ "Crud.Resource.todos/SqlRepo.Read.reader",
290
+ ]);
291
+ expect(sub.nodes.find((n) => n.id === "Crud.Resource.todos")?.expanded).toBe(true);
292
+
293
+ // The parent owns `api` directly; `reader` is reached through `api`, so the
294
+ // redundant parent→reader ownership edge is dropped.
295
+ const owns = sub.edges.filter((e) => e.ownership);
296
+ expect(owns.map((e) => e.target)).toEqual(["Crud.Resource.todos/Http.Api.api"]);
297
+ expect(owns.some((e) => e.target.endsWith("SqlRepo.Read.reader"))).toBe(false);
298
+
299
+ // The dependency edge among children (api → reader) is kept.
300
+ expect(
301
+ sub.edges.some(
302
+ (e) =>
303
+ !e.ownership &&
304
+ e.source === "Crud.Resource.todos/Http.Api.api" &&
305
+ e.target === "Crud.Resource.todos/SqlRepo.Read.reader",
306
+ ),
307
+ ).toBe(true);
308
+ });
309
+
310
+ it("reports each child's own childCount and drills recursively", () => {
311
+ const reader: Owner = {
312
+ kind: "SqlRepo.Read",
313
+ name: "reader",
314
+ id: "Crud.Resource.todos/SqlRepo.Read.reader",
315
+ };
316
+ const topology = deriveGraph([
317
+ createdR("Crud.Resource", "todos"),
318
+ createdR("SqlRepo.Read", "reader", { owner: todos }),
319
+ createdR("Sql.Query", "reader-query", { owner: reader }),
320
+ ]);
321
+
322
+ const lvl1 = subtreeGraph(topology, "Crud.Resource.todos");
323
+ expect(lvl1.nodes.find((n) => n.id === reader.id)?.childCount).toBe(1);
324
+
325
+ const lvl2 = subtreeGraph(topology, reader.id);
326
+ expect(lvl2.nodes.map((n) => n.id).sort()).toEqual([
327
+ "Crud.Resource.todos/SqlRepo.Read.reader",
328
+ "Crud.Resource.todos/SqlRepo.Read.reader/Sql.Query.reader-query",
329
+ ]);
330
+ });
331
+
332
+ it("drops dependency edges that leave the subtree", () => {
333
+ const topology = deriveGraph([
334
+ createdR("Sql.Connection", "db"),
335
+ createdR("Crud.Resource", "todos"),
336
+ createdR("SqlRepo.Read", "reader", {
337
+ owner: todos,
338
+ deps: [{ kind: "Sql.Connection", name: "db", id: "Sql.Connection.db" }],
339
+ }),
340
+ ]);
341
+ const sub = subtreeGraph(topology, "Crud.Resource.todos");
342
+ expect(sub.nodes.some((n) => n.id === "Sql.Connection.db")).toBe(false);
343
+ expect(sub.edges.some((e) => e.target === "Sql.Connection.db")).toBe(false);
344
+ });
345
+ });
346
+
144
347
  /** A terminal dispatch event carrying trace ids — `spanId` / `parentSpanId`. */
145
348
  const inv = (
146
349
  name: string,
package/src/graph.ts CHANGED
@@ -41,11 +41,20 @@ export interface InvokeRecord {
41
41
  }
42
42
 
43
43
  export interface GraphNode {
44
- /** 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`. */
45
47
  id: string;
46
48
  kind: string;
47
49
  name: string;
48
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;
49
58
  status: NodeStatus;
50
59
  /** Total invocations seen across the stream. */
51
60
  invokeCount: number;
@@ -61,6 +70,10 @@ export interface GraphEdge {
61
70
  source: string;
62
71
  /** Target resource name (the dependency). */
63
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;
64
77
  }
65
78
 
66
79
  export interface GraphState {
@@ -72,9 +85,23 @@ interface WireResourceRef {
72
85
  kind: string;
73
86
  name: string;
74
87
  module?: string;
88
+ /** Full hierarchical id; absent on a legacy stream. */
89
+ id?: string;
75
90
  alias?: string;
76
91
  }
77
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
+
78
105
  function asResource(payload: unknown): WireResourceRef | undefined {
79
106
  const res = (payload as { resource?: unknown } | undefined)?.resource;
80
107
  if (res && typeof res === "object") {
@@ -86,12 +113,30 @@ function asResource(payload: unknown): WireResourceRef | undefined {
86
113
  return undefined;
87
114
  }
88
115
 
89
- /** The resolved `{kind,name}` a dispatch event's payload carries under `ref`. */
90
- function readRef(payload: Record<string, unknown> | undefined): { kind: string; name: string } | undefined {
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
+ }
126
+ return undefined;
127
+ }
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 {
91
134
  const ref = payload?.ref;
92
135
  if (ref && typeof ref === "object") {
93
- const { kind, name } = ref as Record<string, unknown>;
94
- if (typeof kind === "string" && typeof name === "string") return { kind, name };
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
+ }
95
140
  }
96
141
  return undefined;
97
142
  }
@@ -133,15 +178,18 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
133
178
  if (suffix === "Created") {
134
179
  const res = asResource(event.payload);
135
180
  if (!res) return;
136
- const existing = nodes.get(res.name);
181
+ const key = nodeKey(res);
182
+ const existing = nodes.get(key);
137
183
  if (existing) {
138
184
  existing.lastActivitySeq = seq;
139
185
  } else {
140
- nodes.set(res.name, {
141
- id: res.name,
186
+ nodes.set(key, {
187
+ id: key,
142
188
  kind: res.kind,
143
189
  name: res.name,
144
190
  module: res.module,
191
+ ownerId: readOwner(event.payload)?.id,
192
+ properties: (event.payload as { properties?: unknown }).properties,
145
193
  status: "created",
146
194
  invokeCount: 0,
147
195
  lastActivitySeq: seq,
@@ -149,16 +197,17 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
149
197
  }
150
198
  const deps = (event.payload as { dependencies?: WireResourceRef[] }).dependencies ?? [];
151
199
  for (const dep of deps) {
152
- if (!dep?.name || dep.name === res.name) continue;
153
- const id = `${res.name}->${dep.name}`;
154
- 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 });
155
204
  }
156
205
  return;
157
206
  }
158
207
 
159
208
  if (suffix === "Initialized" || suffix === "Teardown") {
160
209
  const res = asResource(event.payload);
161
- const node = res ? nodes.get(res.name) : undefined;
210
+ const node = res ? nodes.get(nodeKey(res)) : undefined;
162
211
  if (node) {
163
212
  node.status = suffix === "Teardown" ? "torndown" : "initialized";
164
213
  node.lastActivitySeq = seq;
@@ -166,13 +215,13 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
166
215
  return;
167
216
  }
168
217
 
169
- // Dispatch (trace) events: the payload's `ref.name` resolves the target node
170
- // directly (nodes are name-keyed). `phase:"start"` events carry no `outcome`
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`
171
220
  // and are skipped — only terminal events record a result.
172
221
  const p = (event.payload ?? undefined) as Record<string, unknown> | undefined;
173
222
  const ref = readRef(p);
174
223
  const outcome = asOutcome(p?.outcome);
175
- const node = ref ? nodes.get(ref.name) : undefined;
224
+ const node = ref ? nodes.get(nodeKey(ref)) : undefined;
176
225
  if (!node || !outcome) return;
177
226
  node.invokeCount += 1;
178
227
  node.lastActivitySeq = seq;
@@ -189,6 +238,148 @@ export function deriveGraph(frames: readonly DebugFrame[]): GraphState {
189
238
  return { nodes: [...nodes.values()], edges: [...edges.values()] };
190
239
  }
191
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
+
192
383
  // ── Invocation traces ──────────────────────────────────────────────────────
193
384
 
194
385
  /** Which capability the span represents. `"request"` is an inbound-boundary span. */
@@ -202,6 +393,9 @@ export interface Invocation {
202
393
  traceId?: string;
203
394
  kind: string;
204
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;
205
399
  capability?: SpanCapability;
206
400
  outcome: InvokeOutcome;
207
401
  /** The terminal event's suffix (`Invoked`, `InvokeFailed`, …) — for display only. */
@@ -259,6 +453,7 @@ export function deriveInvocations(frames: readonly DebugFrame[]): TraceState {
259
453
  traceId: typeof p?.traceId === "string" ? p.traceId : undefined,
260
454
  kind: ref.kind,
261
455
  name: ref.name,
456
+ resourceId: ref.id,
262
457
  capability: asCapability(p?.capability),
263
458
  outcome,
264
459
  suffix: eventSuffix(event.event),
@@ -323,7 +518,7 @@ export function traceSubgraph(trace: TraceState, rootId: number): TraceSubgraph
323
518
  const root = trace.byId.get(rootId);
324
519
  if (!root) return { nodes: [], edges: [] };
325
520
 
326
- const resourceKey = (inv: Invocation) => `${inv.kind}.${inv.name}`;
521
+ const resourceKey = (inv: Invocation) => inv.resourceId ?? `${inv.kind}.${inv.name}`;
327
522
  const nodes = new Map<string, TraceNode>();
328
523
  const edges = new Map<string, TraceEdge>();
329
524