/** * Topology graph — pure selectors that roll the deep manifest up into the * app-level picture the Topology page renders: one cluster per app with its * installed plugins, outbound sinks, per-app primitive counts, plus the * cross-app event-flow edges between clusters. * * Native: reads `ManifestView` only. App membership comes from the manifest's * flat per-kind arrays (every entry carries an `app`) — the same resilient * source `bc-graph` uses, so a flat/legacy manifest groups just as well as a * deep one. Edges prefer the deep `model.edges`, falling back to the flat * `graph.events`. Kept pure (no DOM, no fetch) so it unit-tests in isolation; * the page binds `useManifest` to these. */ import type { ManifestView, GraphNode, GraphEdge } from "./manifest"; const UNSCOPED = "(unscoped)"; /** One outbound sink on an app cluster. */ export interface AppSink { readonly label: string; readonly position?: string; readonly kind?: string; } /** One app cluster — what a Topology node renders. */ export interface AppTopology { readonly name: string; /** The app's declared description, when one exists. */ readonly description?: string; /** Installed plugins, name-sorted, de-duped. */ readonly plugins: readonly string[]; /** Outbound sinks, position-then-name ordered. */ readonly sinks: readonly AppSink[]; readonly actions: number; readonly events: number; readonly projections: number; } /** A collapsed cross-app flow edge. */ export interface AppFlowEdge { readonly from: string; readonly to: string; /** The boundary-crossing primitive (the event endpoint), when known. */ readonly label?: string; } type Entry = { name?: unknown; app?: unknown }; type RawEdge = { from?: unknown; to?: unknown; via?: unknown; type?: unknown }; function arr(m: Record, key: string): Entry[] { const v = m[key]; return Array.isArray(v) ? (v as Entry[]) : []; } function str(v: unknown): string | undefined { return typeof v === "string" && v ? v : undefined; } /** `"action:orders.place"` → `"orders.place"`. */ function nameOfId(id: string): string { const i = id.indexOf(":"); return i === -1 ? id : id.slice(i + 1); } const POSITION_ORDER: Record = { early: 0, middle: 1, terminal: 2 }; /** * Roll the manifest up into one cluster per declared app. Plugins merge an * app's declared `plugins` list with any flat `plugins` entries scoped to it; * sinks/counts come from the flat per-kind arrays by `app`. Apps are name-sorted * for a stable layout. Tolerant of a null view and missing arrays. */ export function buildAppTopology(view: ManifestView | null): AppTopology[] { if (!view) return []; const m = view.manifest as unknown as Record; const pluginsByApp = new Map>(); const sinksByApp = new Map(); const countsByApp = new Map(); const descByApp = new Map(); const appOf = (e: Entry) => str(e.app) ?? UNSCOPED; const counts = (app: string) => { let c = countsByApp.get(app); if (!c) { c = { actions: 0, events: 0, projections: 0 }; countsByApp.set(app, c); } return c; }; const pluginSet = (app: string) => { let s = pluginsByApp.get(app); if (!s) { s = new Set(); pluginsByApp.set(app, s); } return s; }; // Declared apps + their inline plugin lists. for (const a of arr(m, "apps")) { const name = str(a.name); if (!name) continue; const raw = a as { plugins?: unknown; description?: unknown }; const desc = str(raw.description); if (desc) descByApp.set(name, desc); const set = pluginSet(name); if (Array.isArray(raw.plugins)) for (const p of raw.plugins) if (str(p)) set.add(p as string); } // Flat plugin entries (carry their own `app`). for (const p of arr(m, "plugins")) { const name = str(p.name); if (name) pluginSet(appOf(p)).add(name); } // Outbound sinks. for (const s of arr(m, "sinks")) { const app = appOf(s); const raw = s as { position?: unknown; kind?: unknown }; const position = str(raw.position); const kind = str(raw.kind) ?? str(s.name); const label = position ? `${position} · ${kind ?? "sink"}` : (kind ?? str(s.name) ?? "sink"); const list = sinksByApp.get(app) ?? []; list.push({ label, position, kind }); sinksByApp.set(app, list); } // Per-app primitive counts. for (const e of arr(m, "actions")) counts(appOf(e)).actions++; for (const e of arr(m, "events")) counts(appOf(e)).events++; for (const e of arr(m, "projections")) counts(appOf(e)).projections++; // Union of every app name seen anywhere. const names = new Set([ ...pluginsByApp.keys(), ...sinksByApp.keys(), ...countsByApp.keys(), ]); return [...names].sort().map((name) => { const c = countsByApp.get(name) ?? { actions: 0, events: 0, projections: 0 }; const sinks = (sinksByApp.get(name) ?? []) .slice() .sort( (a, b) => (POSITION_ORDER[a.position ?? ""] ?? 9) - (POSITION_ORDER[b.position ?? ""] ?? 9) || a.label.localeCompare(b.label), ); return { name, description: descByApp.get(name), plugins: [...(pluginsByApp.get(name) ?? [])].sort(), sinks, actions: c.actions, events: c.events, projections: c.projections, }; }); } /** * Collapse the primitive-level event graph into cross-app flow edges: resolve * each edge's endpoints to their owning app and keep one labelled edge per * distinct producer-app → consumer-app pair. In-app edges are dropped (the * Topology cares about boundaries). Prefers the deep `model.edges`, falling back * to the flat `graph.events`. Pure; tolerant of a null view. */ export function buildAppFlowEdges(view: ManifestView | null): AppFlowEdge[] { if (!view) return []; const m = view.manifest as unknown as Record; // Index every named primitive to its owning app, and the event names so we // can label an edge by the event endpoint it crosses. const nameToApp = new Map(); const eventNames = new Set(); const index = (key: string, isEvent = false) => { for (const e of arr(m, key)) { const name = str(e.name); if (!name) continue; if (!nameToApp.has(name)) nameToApp.set(name, str(e.app) ?? UNSCOPED); if (isEvent) eventNames.add(name); } }; index("actions"); index("events", true); index("projections"); index("workflows"); index("queries"); const out = new Map(); const add = (fromName: string, toName: string) => { const fromApp = nameToApp.get(fromName); const toApp = nameToApp.get(toName); if (!fromApp || !toApp || fromApp === toApp) return; const label = eventNames.has(fromName) ? fromName : eventNames.has(toName) ? toName : undefined; const key = `${fromApp}->${toApp}::${label ?? ""}`; if (!out.has(key)) out.set(key, { from: fromApp, to: toApp, label }); }; const model = m.model as { edges?: RawEdge[] } | undefined; if (model && Array.isArray(model.edges) && model.edges.length) { for (const e of model.edges) { const from = str(e.from); const to = str(e.to); if (from && to) add(nameOfId(from), nameOfId(to)); } } else { const graph = m.graph as { events?: RawEdge[] } | undefined; for (const e of graph?.events ?? []) { const from = str(e.from); const to = str(e.to); if (from && to) add(from, to); } } return [...out.values()]; } /** A positioned app cluster — what the canvas lays out. */ export interface AppLayoutNode extends AppTopology { readonly x: number; readonly y: number; readonly width: number; readonly height: number; } export interface AppLayoutOptions { readonly columns?: number; readonly width?: number; readonly gapX?: number; readonly gapY?: number; readonly originX?: number; readonly originY?: number; } /** * Pure grid layout for app clusters — deterministic positions so the canvas is * stable across renders and unit-testable without a DOM. Card height grows with * the plugin + sink count so tall clusters don't overlap their neighbours; each * grid row clears its tallest card. */ export function layoutAppTopology( apps: readonly AppTopology[], opts: AppLayoutOptions = {}, ): AppLayoutNode[] { const columns = Math.max(1, opts.columns ?? 3); const width = opts.width ?? 260; const gapX = opts.gapX ?? 80; const gapY = opts.gapY ?? 80; const originX = opts.originX ?? 40; const originY = opts.originY ?? 40; const heightOf = (a: AppTopology) => 110 + a.plugins.length * 18 + a.sinks.length * 18; const out: AppLayoutNode[] = []; let rowTop = originY; for (let i = 0; i < apps.length; i += columns) { const rowNodes = apps.slice(i, i + columns); const rowMax = Math.max(...rowNodes.map(heightOf)); rowNodes.forEach((a, col) => { out.push({ ...a, x: originX + col * (width + gapX), y: rowTop, width, height: heightOf(a), }); }); rowTop += rowMax + gapY; } return out; } /* ───────────────────────────────────────────────────────────────────────── * Internal wiring graph — the intra-app pipeline the Topology view renders: * sources → handlers → effects → sinks, drawn left→right from the deep * `model` (nodes + typed edges). Where the app-level selectors above collapse * everything to one cluster per app, these expand a single app's real wiring. * ───────────────────────────────────────────────────────────────────────── */ /** * The four wiring lanes, left→right. A node's lane is fixed by its kind, so the * pipeline always reads source → handler → effect → sink regardless of which * edges exist — and back-edges (a workflow that `dispatches` an action) simply * draw right→left without breaking the layout. */ export type WiringLayer = "source" | "handler" | "effect" | "sink"; /** Node kind → wiring lane. Kinds not listed are not part of the pipeline. */ const LAYER_OF_KIND: Record = { // sources — what enters the app route: "source", inboundWebhook: "source", cron: "source", command: "source", // handlers — what runs action: "handler", query: "handler", // effects — what running produces event: "effect", externalCall: "effect", workflow: "effect", actor: "effect", projection: "effect", // sinks — where outbound goes sinkStage: "sink", outbox: "sink", inbox: "sink", }; const LAYER_INDEX: Record = { source: 0, handler: 1, effect: 2, sink: 3 }; /** Edge types that form the wiring flow (others — provides/contributes — are infra). */ const WIRING_EDGE_TYPES = new Set([ "triggers", "emits", "delivers", "dispatches", "reads", "calls", "transitions", "mounts", ]); /** One node in the wiring graph. */ export interface WiringNode { /** Graph node id (`${kind}:${name}`) — the detail panel resolves off this. */ readonly id: string; readonly kind: string; readonly name: string; readonly layer: WiringLayer; readonly app: string; readonly description?: string; readonly public?: boolean; } /** One directed edge in the wiring graph. */ export interface WiringEdge { readonly id: string; readonly from: string; readonly to: string; readonly type: string; /** True when it points right→left (e.g. workflow → action dispatch). */ readonly back: boolean; } /** The intra-app pipeline graph for one or more apps. */ export interface WiringGraph { readonly nodes: readonly WiringNode[]; readonly edges: readonly WiringEdge[]; /** App names present in the graph, sorted — for per-app grouping. */ readonly apps: readonly string[]; } /** The owning app of a graph node — from `data.app`, else the dotted name's head. */ function appOfNode(n: GraphNode): string { const fromData = str((n.data as Record | undefined)?.app); if (fromData) return fromData; const dot = n.name.indexOf("."); return dot > 0 ? n.name.slice(0, dot) : UNSCOPED; } /** * Build the source→handler→effect→sink wiring graph from the manifest model. * Only pipeline kinds (`LAYER_OF_KIND`) become nodes; only wiring edge types * (`WIRING_EDGE_TYPES`) between two such nodes become edges. An edge that runs * from a later lane back to an earlier one (a workflow dispatching an action) * is flagged `back` so the renderer can style it. When `appFilter` is set, the * graph is scoped to that app (cross-app edges to it are kept; their far node is * dropped, leaving a dangling edge the renderer skips). Pure; tolerant of null. */ export function buildWiringGraph(view: ManifestView | null, appFilter?: string): WiringGraph { if (!view) return { nodes: [], edges: [], apps: [] }; const nodes: WiringNode[] = []; const byId = new Map(); const apps = new Set(); for (const n of view.model.nodes ?? []) { const layer = LAYER_OF_KIND[n.kind]; if (!layer) continue; const app = appOfNode(n); if (appFilter && app !== appFilter) continue; const wn: WiringNode = { id: n.id, kind: n.kind, name: n.name, layer, app, description: str(n.intent?.description), public: n.intent?.public === true, }; nodes.push(wn); byId.set(n.id, wn); apps.add(app); } const edges: WiringEdge[] = []; const seen = new Set(); for (const e of (view.model.edges ?? []) as readonly GraphEdge[]) { if (!WIRING_EDGE_TYPES.has(e.type)) continue; const from = byId.get(e.from); const to = byId.get(e.to); if (!from || !to) continue; const id = `${e.from}=>${e.to}::${e.type}`; if (seen.has(id)) continue; seen.add(id); edges.push({ id, from: e.from, to: e.to, type: e.type, back: LAYER_INDEX[to.layer] < LAYER_INDEX[from.layer], }); } return { nodes, edges, apps: [...apps].sort() }; } /** A positioned wiring node — what the canvas lays out. */ export interface WiringLayoutNode extends WiringNode { readonly x: number; readonly y: number; readonly width: number; readonly height: number; } export interface WiringLayoutOptions { readonly nodeWidth?: number; readonly nodeHeight?: number; /** Horizontal gap between lanes. */ readonly laneGap?: number; /** Vertical gap between nodes in a lane. */ readonly rowGap?: number; readonly originX?: number; readonly originY?: number; } const LAYER_ORDER: readonly WiringLayer[] = ["source", "handler", "effect", "sink"]; /** * Layered left→right layout: each lane is a column (source, handler, effect, * sink), nodes stack vertically within their lane and the whole graph is * centred so short lanes sit beside tall ones. Deterministic — node order is * preserved, so it unit-tests without a DOM. Empty lanes collapse (no gap). */ export function layoutWiringGraph( graph: WiringGraph, opts: WiringLayoutOptions = {}, ): WiringLayoutNode[] { const w = opts.nodeWidth ?? 200; const h = opts.nodeHeight ?? 56; const laneGap = opts.laneGap ?? 120; const rowGap = opts.rowGap ?? 28; const originX = opts.originX ?? 0; const originY = opts.originY ?? 0; const byLayer = new Map(); for (const n of graph.nodes) { const list = byLayer.get(n.layer) ?? []; list.push(n); byLayer.set(n.layer, list); } // Only lanes that hold nodes take a column, so the pipeline never shows gaps. const lanes = LAYER_ORDER.filter((l) => (byLayer.get(l)?.length ?? 0) > 0); const tallest = Math.max(1, ...lanes.map((l) => byLayer.get(l)!.length)); const laneH = (count: number) => count * h + (count - 1) * rowGap; const fullH = laneH(tallest); const out: WiringLayoutNode[] = []; lanes.forEach((layer, col) => { const inLane = byLayer.get(layer)!; // Centre each lane against the tallest one. const top = originY + (fullH - laneH(inLane.length)) / 2; inLane.forEach((n, row) => { out.push({ ...n, x: originX + col * (w + laneGap), y: top + row * (h + rowGap), width: w, height: h, }); }); }); return out; } /** One exposed endpoint — a `route` node and the handler it drives. */ export interface EndpointRow { /** The route's graph node id. */ readonly id: string; readonly method: string; readonly path: string; /** The handler this route triggers/mounts, when resolvable. */ readonly handler?: { id: string; kind: string; name: string }; readonly app: string; readonly public?: boolean; } /** `"route:GET /posts"` / a route node's data → its METHOD + path. */ function routeMethodPath(n: GraphNode): { method: string; path: string } { const d = (n.data ?? {}) as Record; const method = (str(d.method) ?? str(d.verb) ?? "").toUpperCase(); const path = str(d.path) ?? str(d.route) ?? ""; if (method || path) return { method: method || "ANY", path: path || n.name }; // Fall back to parsing the name (e.g. "GET /posts" or just a path). const m = n.name.match(/^([A-Z]+)\s+(.+)$/); if (m) return { method: m[1]!, path: m[2]! }; return { method: "ANY", path: n.name }; } /** * The "what's exposed" view: every `route` node as `METHOD /path → handler`, * resolving the handler through the route's `triggers` / `mounts` edges (in * either direction — a route may `mount` from an app, or `trigger` a handler). * Sorted by path then method for a stable list. Pure; tolerant of null. */ export function buildEndpoints(view: ManifestView | null): EndpointRow[] { if (!view) return []; const routes = view.byKind("route"); const out: EndpointRow[] = []; for (const r of routes) { const { method, path } = routeMethodPath(r); // A route's handler is the action/query it triggers (out) or is mounted // by/onto (either direction). Prefer a triggers edge to a handler node. let handler: EndpointRow["handler"]; for (const e of view.edgesOf(r.id, "both")) { if (e.type !== "triggers" && e.type !== "mounts") continue; const otherId = e.from === r.id ? e.to : e.from; const node = view.node(otherId); if (!node) continue; if (LAYER_OF_KIND[node.kind] === "handler") { handler = { id: node.id, kind: node.kind, name: node.name }; break; } } out.push({ id: r.id, method, path, handler, app: appOfNode(r), public: r.intent?.public === true, }); } return out.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); }