/** * Topology view — pure selectors that shape the deep manifest's runtime * `topology` layer into the rows the specialized Hooks + Plugins pages render, * and tally the live hook-tap from telemetry. Reads `ManifestView` (the * `topology.hooks` registry, the `topology.plugins` list, capabilities, * `ctxByKind`) and `TelemetryRecord`s. * * Kept pure (no DOM, no fetch) so it unit-tests in isolation; the pages bind * `useManifest` + `useTelemetry` to these. */ import type { ManifestView } from "./manifest"; import type { TelemetryRecord } from "./telemetry"; /** A `plugin.boot:` / `plugin.shutdown:` lifecycle prefix. */ const LIFECYCLE_PREFIXES = ["plugin.boot:", "plugin.shutdown:"] as const; /** One named hook in the registry — the Hooks master row. */ export interface HookRow { /** Stable id for selection (`hook:` when the topology omits its own). */ readonly id: string; readonly name: string; readonly chain: number; readonly listeners: number; /** Where the hook was authored — drives the SourcePill. */ readonly source?: { readonly file: string; readonly line?: number; readonly column?: number }; } /** One plugin in the registry — the Plugins master row. */ export interface PluginRow { /** Stable id for selection (`plugin:`). */ readonly id: string; readonly name: string; } /** A boot/shutdown hook attributed to a plugin (the lifecycle internals). */ export interface PluginLifecycleHook { readonly name: string; readonly phase: "boot" | "shutdown"; readonly chain: number; readonly listeners: number; } /** The deep internals assembled for a selected plugin. */ export interface PluginInternals { readonly name: string; /** Boot/shutdown hooks the plugin registered (`plugin.boot:` / `…shutdown:`). */ readonly lifecycleHooks: readonly PluginLifecycleHook[]; /** Names this plugin contributes via `contributes` / `mounts` graph edges. */ readonly contributes: readonly string[]; /** A same-named capability's ctx-by-kind, when one exists. */ readonly ctxByKind: Readonly>; } /** A live-fire entry for one hook, tallied from `hook.step` telemetry. */ export interface HookFire { /** Count of `start`-phase steps seen for this hook (one per fire). */ readonly fires: number; /** ISO timestamp of the most recent step, if any. */ readonly lastTs?: string; } /** Read a string field off a loosely-typed record, or undefined. */ function str(rec: TelemetryRecord, key: string): string | undefined { const v = (rec as Record)[key]; return typeof v === "string" ? v : undefined; } /** * The hook registry from native topology, name-sorted. Tolerant of missing * `chain` / `listeners` (older topology) and a missing topology entirely. */ export function hookRegistry(view: ManifestView | null): HookRow[] { const hooks = view?.manifest.topology?.hooks ?? []; return hooks .filter((h) => typeof h.name === "string" && h.name.length > 0) .map((h) => ({ id: h.id ?? `hook:${h.name}`, name: h.name, chain: h.chain ?? 0, listeners: h.listeners ?? 0, source: h.source, })) .sort((a, b) => a.name.localeCompare(b.name)); } /** * The plugin name that registered a `plugin.boot:` / `plugin.shutdown:` * hook, or `null` for a non-lifecycle hook. Powers the Hooks "Registered by" * cross-link. */ export function registeringPlugin(hookName: string | undefined | null): string | null { if (!hookName) return null; for (const prefix of LIFECYCLE_PREFIXES) { if (hookName.startsWith(prefix)) return hookName.slice(prefix.length); } return null; } /** The plugin registry from native topology, name-sorted. */ export function pluginRegistry(view: ManifestView | null): PluginRow[] { const plugins = view?.manifest.topology?.plugins ?? []; return plugins .filter((p) => typeof p.name === "string" && p.name.length > 0) .map((p) => ({ id: `plugin:${p.name}`, name: p.name })) .sort((a, b) => a.name.localeCompare(b.name)); } /** * Assemble a plugin's deep internals: its boot/shutdown lifecycle hooks (parsed * back from `topology.hooks`), what it contributes (graph `contributes` / * `mounts` edges out of its node), and a same-named capability's ctx-by-kind. * Pure; tolerant of a missing topology / model. */ export function pluginInternals( view: ManifestView | null, name: string | null | undefined, ): PluginInternals | null { if (!view || !name) return null; const hooks = view.manifest.topology?.hooks ?? []; const lifecycleHooks: PluginLifecycleHook[] = []; for (const h of hooks) { if (h.name === `plugin.boot:${name}`) lifecycleHooks.push({ name: h.name, phase: "boot", chain: h.chain ?? 0, listeners: h.listeners ?? 0, }); else if (h.name === `plugin.shutdown:${name}`) lifecycleHooks.push({ name: h.name, phase: "shutdown", chain: h.chain ?? 0, listeners: h.listeners ?? 0, }); } // Boot before shutdown. lifecycleHooks.sort((a, b) => (a.phase === b.phase ? 0 : a.phase === "boot" ? -1 : 1)); // `contributes` / `mounts` edges out of this plugin node (emitted by richer // topology captures; absent ones simply yield no rows). const id = `plugin:${name}`; const contributes = view .edgesOf(id, "out") .filter((e) => e.type === "contributes" || e.type === "mounts") .map((e) => { const node = view.node(e.to); return node?.name ?? e.to; }) .sort((a, b) => a.localeCompare(b)); // A capability registered under the same name carries the ctx-by-kind it adds. const capByKind: Record = {}; const capNode = view.node(`capability:${name}`); const capKinds = capNode?.data?.kinds; const capCtxKeys = capNode?.data?.ctxKeys; if (Array.isArray(capCtxKeys)) { const kinds = Array.isArray(capKinds) && capKinds.length ? (capKinds as string[]) : ["*"]; for (const k of kinds) capByKind[k] = capCtxKeys as string[]; } return { name, lifecycleHooks, contributes, ctxByKind: capByKind }; } /** * Tally live hook fires from `hook.step` telemetry, keyed by hook name. Counts * one fire per `start`-phase step (a fire's first observation); records the most * recent step's timestamp regardless of phase. Records of other kinds are * ignored, so it degrades to an empty map when no stream/records exist. */ export function liveFireTally(records: readonly TelemetryRecord[]): Map { const out = new Map(); for (const rec of records) { if (rec.kind !== "hook.step") continue; const name = str(rec, "hookName"); if (!name) continue; const prev = out.get(name); const phase = str(rec, "phase"); const fires = (prev?.fires ?? 0) + (phase === "start" || phase === undefined ? 1 : 0); const ts = rec.ts ?? prev?.lastTs; out.set(name, { fires, lastTs: ts }); } return out; }