@nwire/studio 0.12.1 → 0.13.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.
Files changed (130) hide show
  1. package/package.json +5 -3
  2. package/src/App.vue +62 -56
  3. package/src/components/BcCard.stories.ts +47 -0
  4. package/src/components/BcCard.vue +152 -0
  5. package/src/components/DurationBar.stories.ts +55 -0
  6. package/src/components/DurationBar.vue +72 -0
  7. package/src/components/ErrorCard.stories.ts +133 -0
  8. package/src/components/ErrorCard.vue +153 -0
  9. package/src/components/GraphCanvas.stories.ts +48 -0
  10. package/src/components/GraphCanvas.vue +88 -0
  11. package/src/components/KpiTile.stories.ts +32 -0
  12. package/src/components/KpiTile.vue +39 -0
  13. package/src/components/LiveTable.stories.ts +78 -0
  14. package/src/components/LiveTable.vue +186 -0
  15. package/src/components/MetadataInspector.stories.ts +53 -0
  16. package/src/components/MetadataInspector.vue +105 -0
  17. package/src/components/NodeCard.stories.ts +44 -0
  18. package/src/components/NodeCard.vue +150 -0
  19. package/src/components/RcaPanel.stories.ts +95 -0
  20. package/src/components/RcaPanel.vue +223 -0
  21. package/src/components/ServiceNode.vue +134 -0
  22. package/src/components/SourceDrawer.vue +6 -4
  23. package/src/components/SourcePill.vue +10 -3
  24. package/src/components/StatusBadge.stories.ts +33 -0
  25. package/src/components/StatusBadge.vue +54 -0
  26. package/src/components/Waterfall.stories.ts +85 -0
  27. package/src/components/Waterfall.vue +53 -0
  28. package/src/components/WaterfallRow.vue +74 -0
  29. package/src/components/__tests__/BcCard.test.ts +53 -0
  30. package/src/components/__tests__/DurationBar.test.ts +31 -0
  31. package/src/components/__tests__/ErrorCard.test.ts +71 -0
  32. package/src/components/__tests__/KpiTile.test.ts +23 -0
  33. package/src/components/__tests__/LiveTable.test.ts +100 -0
  34. package/src/components/__tests__/MetadataInspector.test.ts +38 -0
  35. package/src/components/__tests__/NodeCard.test.ts +116 -0
  36. package/src/components/__tests__/RcaPanel.test.ts +81 -0
  37. package/src/components/__tests__/StatusBadge.test.ts +23 -0
  38. package/src/components/__tests__/Waterfall.test.ts +54 -0
  39. package/src/components/index.ts +13 -0
  40. package/src/composables/__tests__/composables-context.test.ts +107 -0
  41. package/src/composables/__tests__/useTelemetry.test.ts +104 -0
  42. package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
  43. package/src/composables/useDiscovery.ts +73 -0
  44. package/src/composables/useEndpoints.ts +94 -0
  45. package/src/composables/useLogTail.ts +51 -0
  46. package/src/composables/useManifest.ts +43 -0
  47. package/src/composables/useProcesses.ts +114 -0
  48. package/src/composables/useProject.ts +34 -0
  49. package/src/composables/useTelemetry.ts +270 -0
  50. package/src/lib/__tests__/bc-graph.test.ts +218 -0
  51. package/src/lib/__tests__/dispatch-form.test.ts +113 -0
  52. package/src/lib/__tests__/error-friendly.test.ts +198 -0
  53. package/src/lib/__tests__/home.test.ts +231 -0
  54. package/src/lib/__tests__/inspect.test.ts +160 -0
  55. package/src/lib/__tests__/kind-colors.test.ts +59 -0
  56. package/src/lib/__tests__/live-table.test.ts +194 -0
  57. package/src/lib/__tests__/manifest-health.test.ts +120 -0
  58. package/src/lib/__tests__/manifest.test.ts +87 -0
  59. package/src/lib/__tests__/metadata.test.ts +47 -0
  60. package/src/lib/__tests__/node-metrics.test.ts +144 -0
  61. package/src/lib/__tests__/operate.test.ts +97 -0
  62. package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
  63. package/src/lib/__tests__/rca.test.ts +124 -0
  64. package/src/lib/__tests__/telemetry.test.ts +91 -0
  65. package/src/lib/__tests__/topology-graph.test.ts +331 -0
  66. package/src/lib/__tests__/topology-view.test.ts +154 -0
  67. package/src/lib/__tests__/waterfall.test.ts +165 -0
  68. package/src/lib/bc-graph.ts +298 -0
  69. package/src/lib/dispatch-form.ts +160 -0
  70. package/src/lib/error-friendly.ts +288 -0
  71. package/src/lib/home.ts +191 -0
  72. package/src/lib/inspect.ts +226 -0
  73. package/src/lib/kind-colors.ts +132 -0
  74. package/src/lib/live-table.ts +204 -0
  75. package/src/lib/manifest-health.ts +71 -0
  76. package/src/lib/manifest.ts +139 -0
  77. package/src/lib/metadata.ts +52 -0
  78. package/src/lib/node-metrics.ts +242 -0
  79. package/src/lib/operate.ts +114 -0
  80. package/src/lib/pipeline-flow.ts +120 -0
  81. package/src/lib/rca.ts +193 -0
  82. package/src/lib/telemetry.ts +155 -0
  83. package/src/lib/topology-graph.ts +551 -0
  84. package/src/lib/topology-view.ts +185 -0
  85. package/src/lib/waterfall.ts +148 -0
  86. package/src/main.ts +63 -29
  87. package/src/pages/Errors.vue +272 -0
  88. package/src/pages/Home.stories.ts +7 -8
  89. package/src/pages/Home.vue +255 -540
  90. package/src/pages/Hooks.stories.ts +44 -0
  91. package/src/pages/Hooks.vue +165 -164
  92. package/src/pages/Inspect.vue +240 -0
  93. package/src/pages/Map.vue +187 -0
  94. package/src/pages/Operate.vue +74 -0
  95. package/src/pages/Plugins.stories.ts +1 -1
  96. package/src/pages/Plugins.vue +174 -238
  97. package/src/pages/Projects.vue +62 -60
  98. package/src/pages/Streams.vue +344 -0
  99. package/src/pages/Topology.vue +318 -136
  100. package/src/pages/Trace.vue +174 -412
  101. package/src/pages/__tests__/Home.test.ts +109 -54
  102. package/src/pages/__tests__/Hooks.test.ts +5 -5
  103. package/src/pages/__tests__/Inspect.test.ts +111 -0
  104. package/src/pages/__tests__/Plugins.test.ts +85 -35
  105. package/src/pages/__tests__/Trace.test.ts +117 -0
  106. package/src/pages/operate/CommandsPanel.vue +186 -0
  107. package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
  108. package/src/pages/operate/EndpointPicker.vue +56 -0
  109. package/src/pages/operate/RunPanel.vue +316 -0
  110. package/src/server/__tests__/nwire-read.test.ts +80 -0
  111. package/src/server/nwire-read.ts +63 -0
  112. package/vite.config.ts +220 -2
  113. package/src/lib/__tests__/normalize-cache.test.ts +0 -105
  114. package/src/lib/cache.ts +0 -312
  115. package/src/lib/normalize-cache.ts +0 -92
  116. package/src/pages/Actions.vue +0 -171
  117. package/src/pages/Apps.vue +0 -177
  118. package/src/pages/Commands.vue +0 -262
  119. package/src/pages/Events.vue +0 -210
  120. package/src/pages/Live.vue +0 -249
  121. package/src/pages/Overview.vue +0 -161
  122. package/src/pages/Projections.vue +0 -148
  123. package/src/pages/Queries.vue +0 -148
  124. package/src/pages/Run.vue +0 -618
  125. package/src/pages/Sinks.vue +0 -124
  126. package/src/pages/TraceNode.vue +0 -164
  127. package/src/pages/Workflows.vue +0 -184
  128. package/src/pages/__tests__/Actions.test.ts +0 -98
  129. package/src/pages/__tests__/Projections.test.ts +0 -90
  130. package/src/pages/__tests__/Queries.test.ts +0 -86
@@ -0,0 +1,551 @@
1
+ /**
2
+ * Topology graph — pure selectors that roll the deep manifest up into the
3
+ * app-level picture the Topology page renders: one cluster per app with its
4
+ * installed plugins, outbound sinks, per-app primitive counts, plus the
5
+ * cross-app event-flow edges between clusters.
6
+ *
7
+ * Native: reads `ManifestView` only. App membership comes from the manifest's
8
+ * flat per-kind arrays (every entry carries an `app`) — the same resilient
9
+ * source `bc-graph` uses, so a flat/legacy manifest groups just as well as a
10
+ * deep one. Edges prefer the deep `model.edges`, falling back to the flat
11
+ * `graph.events`. Kept pure (no DOM, no fetch) so it unit-tests in isolation;
12
+ * the page binds `useManifest` to these.
13
+ */
14
+ import type { ManifestView, GraphNode, GraphEdge } from "./manifest";
15
+
16
+ const UNSCOPED = "(unscoped)";
17
+
18
+ /** One outbound sink on an app cluster. */
19
+ export interface AppSink {
20
+ readonly label: string;
21
+ readonly position?: string;
22
+ readonly kind?: string;
23
+ }
24
+
25
+ /** One app cluster — what a Topology node renders. */
26
+ export interface AppTopology {
27
+ readonly name: string;
28
+ /** The app's declared description, when one exists. */
29
+ readonly description?: string;
30
+ /** Installed plugins, name-sorted, de-duped. */
31
+ readonly plugins: readonly string[];
32
+ /** Outbound sinks, position-then-name ordered. */
33
+ readonly sinks: readonly AppSink[];
34
+ readonly actions: number;
35
+ readonly events: number;
36
+ readonly projections: number;
37
+ }
38
+
39
+ /** A collapsed cross-app flow edge. */
40
+ export interface AppFlowEdge {
41
+ readonly from: string;
42
+ readonly to: string;
43
+ /** The boundary-crossing primitive (the event endpoint), when known. */
44
+ readonly label?: string;
45
+ }
46
+
47
+ type Entry = { name?: unknown; app?: unknown };
48
+ type RawEdge = { from?: unknown; to?: unknown; via?: unknown; type?: unknown };
49
+
50
+ function arr(m: Record<string, unknown>, key: string): Entry[] {
51
+ const v = m[key];
52
+ return Array.isArray(v) ? (v as Entry[]) : [];
53
+ }
54
+
55
+ function str(v: unknown): string | undefined {
56
+ return typeof v === "string" && v ? v : undefined;
57
+ }
58
+
59
+ /** `"action:orders.place"` → `"orders.place"`. */
60
+ function nameOfId(id: string): string {
61
+ const i = id.indexOf(":");
62
+ return i === -1 ? id : id.slice(i + 1);
63
+ }
64
+
65
+ const POSITION_ORDER: Record<string, number> = { early: 0, middle: 1, terminal: 2 };
66
+
67
+ /**
68
+ * Roll the manifest up into one cluster per declared app. Plugins merge an
69
+ * app's declared `plugins` list with any flat `plugins` entries scoped to it;
70
+ * sinks/counts come from the flat per-kind arrays by `app`. Apps are name-sorted
71
+ * for a stable layout. Tolerant of a null view and missing arrays.
72
+ */
73
+ export function buildAppTopology(view: ManifestView | null): AppTopology[] {
74
+ if (!view) return [];
75
+ const m = view.manifest as unknown as Record<string, unknown>;
76
+
77
+ const pluginsByApp = new Map<string, Set<string>>();
78
+ const sinksByApp = new Map<string, AppSink[]>();
79
+ const countsByApp = new Map<string, { actions: number; events: number; projections: number }>();
80
+ const descByApp = new Map<string, string>();
81
+
82
+ const appOf = (e: Entry) => str(e.app) ?? UNSCOPED;
83
+ const counts = (app: string) => {
84
+ let c = countsByApp.get(app);
85
+ if (!c) {
86
+ c = { actions: 0, events: 0, projections: 0 };
87
+ countsByApp.set(app, c);
88
+ }
89
+ return c;
90
+ };
91
+ const pluginSet = (app: string) => {
92
+ let s = pluginsByApp.get(app);
93
+ if (!s) {
94
+ s = new Set<string>();
95
+ pluginsByApp.set(app, s);
96
+ }
97
+ return s;
98
+ };
99
+
100
+ // Declared apps + their inline plugin lists.
101
+ for (const a of arr(m, "apps")) {
102
+ const name = str(a.name);
103
+ if (!name) continue;
104
+ const raw = a as { plugins?: unknown; description?: unknown };
105
+ const desc = str(raw.description);
106
+ if (desc) descByApp.set(name, desc);
107
+ const set = pluginSet(name);
108
+ if (Array.isArray(raw.plugins)) for (const p of raw.plugins) if (str(p)) set.add(p as string);
109
+ }
110
+
111
+ // Flat plugin entries (carry their own `app`).
112
+ for (const p of arr(m, "plugins")) {
113
+ const name = str(p.name);
114
+ if (name) pluginSet(appOf(p)).add(name);
115
+ }
116
+
117
+ // Outbound sinks.
118
+ for (const s of arr(m, "sinks")) {
119
+ const app = appOf(s);
120
+ const raw = s as { position?: unknown; kind?: unknown };
121
+ const position = str(raw.position);
122
+ const kind = str(raw.kind) ?? str(s.name);
123
+ const label = position ? `${position} · ${kind ?? "sink"}` : (kind ?? str(s.name) ?? "sink");
124
+ const list = sinksByApp.get(app) ?? [];
125
+ list.push({ label, position, kind });
126
+ sinksByApp.set(app, list);
127
+ }
128
+
129
+ // Per-app primitive counts.
130
+ for (const e of arr(m, "actions")) counts(appOf(e)).actions++;
131
+ for (const e of arr(m, "events")) counts(appOf(e)).events++;
132
+ for (const e of arr(m, "projections")) counts(appOf(e)).projections++;
133
+
134
+ // Union of every app name seen anywhere.
135
+ const names = new Set<string>([
136
+ ...pluginsByApp.keys(),
137
+ ...sinksByApp.keys(),
138
+ ...countsByApp.keys(),
139
+ ]);
140
+
141
+ return [...names].sort().map((name) => {
142
+ const c = countsByApp.get(name) ?? { actions: 0, events: 0, projections: 0 };
143
+ const sinks = (sinksByApp.get(name) ?? [])
144
+ .slice()
145
+ .sort(
146
+ (a, b) =>
147
+ (POSITION_ORDER[a.position ?? ""] ?? 9) - (POSITION_ORDER[b.position ?? ""] ?? 9) ||
148
+ a.label.localeCompare(b.label),
149
+ );
150
+ return {
151
+ name,
152
+ description: descByApp.get(name),
153
+ plugins: [...(pluginsByApp.get(name) ?? [])].sort(),
154
+ sinks,
155
+ actions: c.actions,
156
+ events: c.events,
157
+ projections: c.projections,
158
+ };
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Collapse the primitive-level event graph into cross-app flow edges: resolve
164
+ * each edge's endpoints to their owning app and keep one labelled edge per
165
+ * distinct producer-app → consumer-app pair. In-app edges are dropped (the
166
+ * Topology cares about boundaries). Prefers the deep `model.edges`, falling back
167
+ * to the flat `graph.events`. Pure; tolerant of a null view.
168
+ */
169
+ export function buildAppFlowEdges(view: ManifestView | null): AppFlowEdge[] {
170
+ if (!view) return [];
171
+ const m = view.manifest as unknown as Record<string, unknown>;
172
+
173
+ // Index every named primitive to its owning app, and the event names so we
174
+ // can label an edge by the event endpoint it crosses.
175
+ const nameToApp = new Map<string, string>();
176
+ const eventNames = new Set<string>();
177
+ const index = (key: string, isEvent = false) => {
178
+ for (const e of arr(m, key)) {
179
+ const name = str(e.name);
180
+ if (!name) continue;
181
+ if (!nameToApp.has(name)) nameToApp.set(name, str(e.app) ?? UNSCOPED);
182
+ if (isEvent) eventNames.add(name);
183
+ }
184
+ };
185
+ index("actions");
186
+ index("events", true);
187
+ index("projections");
188
+ index("workflows");
189
+ index("queries");
190
+
191
+ const out = new Map<string, AppFlowEdge>();
192
+ const add = (fromName: string, toName: string) => {
193
+ const fromApp = nameToApp.get(fromName);
194
+ const toApp = nameToApp.get(toName);
195
+ if (!fromApp || !toApp || fromApp === toApp) return;
196
+ const label = eventNames.has(fromName) ? fromName : eventNames.has(toName) ? toName : undefined;
197
+ const key = `${fromApp}->${toApp}::${label ?? ""}`;
198
+ if (!out.has(key)) out.set(key, { from: fromApp, to: toApp, label });
199
+ };
200
+
201
+ const model = m.model as { edges?: RawEdge[] } | undefined;
202
+ if (model && Array.isArray(model.edges) && model.edges.length) {
203
+ for (const e of model.edges) {
204
+ const from = str(e.from);
205
+ const to = str(e.to);
206
+ if (from && to) add(nameOfId(from), nameOfId(to));
207
+ }
208
+ } else {
209
+ const graph = m.graph as { events?: RawEdge[] } | undefined;
210
+ for (const e of graph?.events ?? []) {
211
+ const from = str(e.from);
212
+ const to = str(e.to);
213
+ if (from && to) add(from, to);
214
+ }
215
+ }
216
+
217
+ return [...out.values()];
218
+ }
219
+
220
+ /** A positioned app cluster — what the canvas lays out. */
221
+ export interface AppLayoutNode extends AppTopology {
222
+ readonly x: number;
223
+ readonly y: number;
224
+ readonly width: number;
225
+ readonly height: number;
226
+ }
227
+
228
+ export interface AppLayoutOptions {
229
+ readonly columns?: number;
230
+ readonly width?: number;
231
+ readonly gapX?: number;
232
+ readonly gapY?: number;
233
+ readonly originX?: number;
234
+ readonly originY?: number;
235
+ }
236
+
237
+ /**
238
+ * Pure grid layout for app clusters — deterministic positions so the canvas is
239
+ * stable across renders and unit-testable without a DOM. Card height grows with
240
+ * the plugin + sink count so tall clusters don't overlap their neighbours; each
241
+ * grid row clears its tallest card.
242
+ */
243
+ export function layoutAppTopology(
244
+ apps: readonly AppTopology[],
245
+ opts: AppLayoutOptions = {},
246
+ ): AppLayoutNode[] {
247
+ const columns = Math.max(1, opts.columns ?? 3);
248
+ const width = opts.width ?? 260;
249
+ const gapX = opts.gapX ?? 80;
250
+ const gapY = opts.gapY ?? 80;
251
+ const originX = opts.originX ?? 40;
252
+ const originY = opts.originY ?? 40;
253
+
254
+ const heightOf = (a: AppTopology) => 110 + a.plugins.length * 18 + a.sinks.length * 18;
255
+
256
+ const out: AppLayoutNode[] = [];
257
+ let rowTop = originY;
258
+ for (let i = 0; i < apps.length; i += columns) {
259
+ const rowNodes = apps.slice(i, i + columns);
260
+ const rowMax = Math.max(...rowNodes.map(heightOf));
261
+ rowNodes.forEach((a, col) => {
262
+ out.push({
263
+ ...a,
264
+ x: originX + col * (width + gapX),
265
+ y: rowTop,
266
+ width,
267
+ height: heightOf(a),
268
+ });
269
+ });
270
+ rowTop += rowMax + gapY;
271
+ }
272
+ return out;
273
+ }
274
+
275
+ /* ─────────────────────────────────────────────────────────────────────────
276
+ * Internal wiring graph — the intra-app pipeline the Topology view renders:
277
+ * sources → handlers → effects → sinks, drawn left→right from the deep
278
+ * `model` (nodes + typed edges). Where the app-level selectors above collapse
279
+ * everything to one cluster per app, these expand a single app's real wiring.
280
+ * ───────────────────────────────────────────────────────────────────────── */
281
+
282
+ /**
283
+ * The four wiring lanes, left→right. A node's lane is fixed by its kind, so the
284
+ * pipeline always reads source → handler → effect → sink regardless of which
285
+ * edges exist — and back-edges (a workflow that `dispatches` an action) simply
286
+ * draw right→left without breaking the layout.
287
+ */
288
+ export type WiringLayer = "source" | "handler" | "effect" | "sink";
289
+
290
+ /** Node kind → wiring lane. Kinds not listed are not part of the pipeline. */
291
+ const LAYER_OF_KIND: Record<string, WiringLayer> = {
292
+ // sources — what enters the app
293
+ route: "source",
294
+ inboundWebhook: "source",
295
+ cron: "source",
296
+ command: "source",
297
+ // handlers — what runs
298
+ action: "handler",
299
+ query: "handler",
300
+ // effects — what running produces
301
+ event: "effect",
302
+ externalCall: "effect",
303
+ workflow: "effect",
304
+ actor: "effect",
305
+ projection: "effect",
306
+ // sinks — where outbound goes
307
+ sinkStage: "sink",
308
+ outbox: "sink",
309
+ inbox: "sink",
310
+ };
311
+
312
+ const LAYER_INDEX: Record<WiringLayer, number> = { source: 0, handler: 1, effect: 2, sink: 3 };
313
+
314
+ /** Edge types that form the wiring flow (others — provides/contributes — are infra). */
315
+ const WIRING_EDGE_TYPES = new Set([
316
+ "triggers",
317
+ "emits",
318
+ "delivers",
319
+ "dispatches",
320
+ "reads",
321
+ "calls",
322
+ "transitions",
323
+ "mounts",
324
+ ]);
325
+
326
+ /** One node in the wiring graph. */
327
+ export interface WiringNode {
328
+ /** Graph node id (`${kind}:${name}`) — the detail panel resolves off this. */
329
+ readonly id: string;
330
+ readonly kind: string;
331
+ readonly name: string;
332
+ readonly layer: WiringLayer;
333
+ readonly app: string;
334
+ readonly description?: string;
335
+ readonly public?: boolean;
336
+ }
337
+
338
+ /** One directed edge in the wiring graph. */
339
+ export interface WiringEdge {
340
+ readonly id: string;
341
+ readonly from: string;
342
+ readonly to: string;
343
+ readonly type: string;
344
+ /** True when it points right→left (e.g. workflow → action dispatch). */
345
+ readonly back: boolean;
346
+ }
347
+
348
+ /** The intra-app pipeline graph for one or more apps. */
349
+ export interface WiringGraph {
350
+ readonly nodes: readonly WiringNode[];
351
+ readonly edges: readonly WiringEdge[];
352
+ /** App names present in the graph, sorted — for per-app grouping. */
353
+ readonly apps: readonly string[];
354
+ }
355
+
356
+ /** The owning app of a graph node — from `data.app`, else the dotted name's head. */
357
+ function appOfNode(n: GraphNode): string {
358
+ const fromData = str((n.data as Record<string, unknown> | undefined)?.app);
359
+ if (fromData) return fromData;
360
+ const dot = n.name.indexOf(".");
361
+ return dot > 0 ? n.name.slice(0, dot) : UNSCOPED;
362
+ }
363
+
364
+ /**
365
+ * Build the source→handler→effect→sink wiring graph from the manifest model.
366
+ * Only pipeline kinds (`LAYER_OF_KIND`) become nodes; only wiring edge types
367
+ * (`WIRING_EDGE_TYPES`) between two such nodes become edges. An edge that runs
368
+ * from a later lane back to an earlier one (a workflow dispatching an action)
369
+ * is flagged `back` so the renderer can style it. When `appFilter` is set, the
370
+ * graph is scoped to that app (cross-app edges to it are kept; their far node is
371
+ * dropped, leaving a dangling edge the renderer skips). Pure; tolerant of null.
372
+ */
373
+ export function buildWiringGraph(view: ManifestView | null, appFilter?: string): WiringGraph {
374
+ if (!view) return { nodes: [], edges: [], apps: [] };
375
+
376
+ const nodes: WiringNode[] = [];
377
+ const byId = new Map<string, WiringNode>();
378
+ const apps = new Set<string>();
379
+
380
+ for (const n of view.model.nodes ?? []) {
381
+ const layer = LAYER_OF_KIND[n.kind];
382
+ if (!layer) continue;
383
+ const app = appOfNode(n);
384
+ if (appFilter && app !== appFilter) continue;
385
+ const wn: WiringNode = {
386
+ id: n.id,
387
+ kind: n.kind,
388
+ name: n.name,
389
+ layer,
390
+ app,
391
+ description: str(n.intent?.description),
392
+ public: n.intent?.public === true,
393
+ };
394
+ nodes.push(wn);
395
+ byId.set(n.id, wn);
396
+ apps.add(app);
397
+ }
398
+
399
+ const edges: WiringEdge[] = [];
400
+ const seen = new Set<string>();
401
+ for (const e of (view.model.edges ?? []) as readonly GraphEdge[]) {
402
+ if (!WIRING_EDGE_TYPES.has(e.type)) continue;
403
+ const from = byId.get(e.from);
404
+ const to = byId.get(e.to);
405
+ if (!from || !to) continue;
406
+ const id = `${e.from}=>${e.to}::${e.type}`;
407
+ if (seen.has(id)) continue;
408
+ seen.add(id);
409
+ edges.push({
410
+ id,
411
+ from: e.from,
412
+ to: e.to,
413
+ type: e.type,
414
+ back: LAYER_INDEX[to.layer] < LAYER_INDEX[from.layer],
415
+ });
416
+ }
417
+
418
+ return { nodes, edges, apps: [...apps].sort() };
419
+ }
420
+
421
+ /** A positioned wiring node — what the canvas lays out. */
422
+ export interface WiringLayoutNode extends WiringNode {
423
+ readonly x: number;
424
+ readonly y: number;
425
+ readonly width: number;
426
+ readonly height: number;
427
+ }
428
+
429
+ export interface WiringLayoutOptions {
430
+ readonly nodeWidth?: number;
431
+ readonly nodeHeight?: number;
432
+ /** Horizontal gap between lanes. */
433
+ readonly laneGap?: number;
434
+ /** Vertical gap between nodes in a lane. */
435
+ readonly rowGap?: number;
436
+ readonly originX?: number;
437
+ readonly originY?: number;
438
+ }
439
+
440
+ const LAYER_ORDER: readonly WiringLayer[] = ["source", "handler", "effect", "sink"];
441
+
442
+ /**
443
+ * Layered left→right layout: each lane is a column (source, handler, effect,
444
+ * sink), nodes stack vertically within their lane and the whole graph is
445
+ * centred so short lanes sit beside tall ones. Deterministic — node order is
446
+ * preserved, so it unit-tests without a DOM. Empty lanes collapse (no gap).
447
+ */
448
+ export function layoutWiringGraph(
449
+ graph: WiringGraph,
450
+ opts: WiringLayoutOptions = {},
451
+ ): WiringLayoutNode[] {
452
+ const w = opts.nodeWidth ?? 200;
453
+ const h = opts.nodeHeight ?? 56;
454
+ const laneGap = opts.laneGap ?? 120;
455
+ const rowGap = opts.rowGap ?? 28;
456
+ const originX = opts.originX ?? 0;
457
+ const originY = opts.originY ?? 0;
458
+
459
+ const byLayer = new Map<WiringLayer, WiringNode[]>();
460
+ for (const n of graph.nodes) {
461
+ const list = byLayer.get(n.layer) ?? [];
462
+ list.push(n);
463
+ byLayer.set(n.layer, list);
464
+ }
465
+
466
+ // Only lanes that hold nodes take a column, so the pipeline never shows gaps.
467
+ const lanes = LAYER_ORDER.filter((l) => (byLayer.get(l)?.length ?? 0) > 0);
468
+ const tallest = Math.max(1, ...lanes.map((l) => byLayer.get(l)!.length));
469
+ const laneH = (count: number) => count * h + (count - 1) * rowGap;
470
+ const fullH = laneH(tallest);
471
+
472
+ const out: WiringLayoutNode[] = [];
473
+ lanes.forEach((layer, col) => {
474
+ const inLane = byLayer.get(layer)!;
475
+ // Centre each lane against the tallest one.
476
+ const top = originY + (fullH - laneH(inLane.length)) / 2;
477
+ inLane.forEach((n, row) => {
478
+ out.push({
479
+ ...n,
480
+ x: originX + col * (w + laneGap),
481
+ y: top + row * (h + rowGap),
482
+ width: w,
483
+ height: h,
484
+ });
485
+ });
486
+ });
487
+ return out;
488
+ }
489
+
490
+ /** One exposed endpoint — a `route` node and the handler it drives. */
491
+ export interface EndpointRow {
492
+ /** The route's graph node id. */
493
+ readonly id: string;
494
+ readonly method: string;
495
+ readonly path: string;
496
+ /** The handler this route triggers/mounts, when resolvable. */
497
+ readonly handler?: { id: string; kind: string; name: string };
498
+ readonly app: string;
499
+ readonly public?: boolean;
500
+ }
501
+
502
+ /** `"route:GET /posts"` / a route node's data → its METHOD + path. */
503
+ function routeMethodPath(n: GraphNode): { method: string; path: string } {
504
+ const d = (n.data ?? {}) as Record<string, unknown>;
505
+ const method = (str(d.method) ?? str(d.verb) ?? "").toUpperCase();
506
+ const path = str(d.path) ?? str(d.route) ?? "";
507
+ if (method || path) return { method: method || "ANY", path: path || n.name };
508
+ // Fall back to parsing the name (e.g. "GET /posts" or just a path).
509
+ const m = n.name.match(/^([A-Z]+)\s+(.+)$/);
510
+ if (m) return { method: m[1]!, path: m[2]! };
511
+ return { method: "ANY", path: n.name };
512
+ }
513
+
514
+ /**
515
+ * The "what's exposed" view: every `route` node as `METHOD /path → handler`,
516
+ * resolving the handler through the route's `triggers` / `mounts` edges (in
517
+ * either direction — a route may `mount` from an app, or `trigger` a handler).
518
+ * Sorted by path then method for a stable list. Pure; tolerant of null.
519
+ */
520
+ export function buildEndpoints(view: ManifestView | null): EndpointRow[] {
521
+ if (!view) return [];
522
+ const routes = view.byKind("route");
523
+ const out: EndpointRow[] = [];
524
+
525
+ for (const r of routes) {
526
+ const { method, path } = routeMethodPath(r);
527
+ // A route's handler is the action/query it triggers (out) or is mounted
528
+ // by/onto (either direction). Prefer a triggers edge to a handler node.
529
+ let handler: EndpointRow["handler"];
530
+ for (const e of view.edgesOf(r.id, "both")) {
531
+ if (e.type !== "triggers" && e.type !== "mounts") continue;
532
+ const otherId = e.from === r.id ? e.to : e.from;
533
+ const node = view.node(otherId);
534
+ if (!node) continue;
535
+ if (LAYER_OF_KIND[node.kind] === "handler") {
536
+ handler = { id: node.id, kind: node.kind, name: node.name };
537
+ break;
538
+ }
539
+ }
540
+ out.push({
541
+ id: r.id,
542
+ method,
543
+ path,
544
+ handler,
545
+ app: appOfNode(r),
546
+ public: r.intent?.public === true,
547
+ });
548
+ }
549
+
550
+ return out.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
551
+ }