// The tool runtime — the thin per-node process that makes a module's tools actually serve. It // binds the mesh broker, loads the assigned modules' tool entrypoints (each of which calls // registerModuleTools as it imports), and hands them to the sdk's serving harness. Everything hard // — dispatch, collection, duplicate-name safety — is the sdk's; this is the wrapper. import { pathToFileURL } from "node:url"; import { resolve } from "node:path"; import { useBroker } from "@novox/mesh-sdk/messaging"; import { collectTools, toolKey } from "@novox/mesh-sdk/tools"; import type { Broker } from "@novox/mesh-sdk/messaging"; import { seatToolSubject, type Credential, type RuntimeBroker } from "./broker-nats.js"; /** * The one verb every module's runtime answers for it (novox/hq ADR 0152, design 34 §3): the * module's tool names, descriptions and argument schemas, from the code that answers them and * from nowhere else. Discovery asks the module, because a copy kept anywhere else drifts. */ export const TOOLS_VERB = "tools"; /** What `tools` answers for one module. */ export interface ToolsAnswer { module: string; tools: { name: string; description: string; input: Readonly>; /** Where this tool is answered, as the mesh issued it (ADR 0160): the module's plain subject * first when there is one, then this machine's. A caller composes nothing. */ subjects?: string[]; }[]; } export interface RuntimeOptions { /** The mesh broker to serve over. */ broker: Broker; /** Absolute paths to the assigned modules' compiled tool entrypoints (e.g. .../umami/tools/index.js). */ moduleEntrypoints: string[]; /** The credential the mesh delivered, for what it says about the seats this module claims * (novox/hq ADR 0159). Absent for a runtime started by hand, which then serves no seat. */ credential?: Credential; } /** Load the modules, bind the broker, and serve. Returns a stop function that unhooks serving. */ export async function runTools(opts: RuntimeOptions): Promise<() => void> { useBroker(() => opts.broker); for (const entry of opts.moduleEntrypoints) { // Importing the entrypoint runs its registerModuleTools(...) — that is the whole handshake. await import(pathToFileURL(resolve(entry)).href); } // Serve the RPC endpoint only if a module actually registered a tool. A pure-events module (the // audit logger) registers none, and its scoped account may not declare the serve queue — so a // runtime that always served would fail for exactly the modules that never needed it. // A registration under a seat's name is the module's implementation of that seat's verbs // (ADR 0159, 0160): served on the seat's subjects by serveClaimedSeats, never as a module's // tools and never listed among them. Everything else is the module's own. // A module named like its seat (the catalogue is the mesh-catalog seat) registers once and is // both: its tools are the module's and the seat's verbs alike. const self = opts.credential?.module; const seatNames = new Set((opts.credential?.claims ?? []).map((c) => c.seat)); const ownRegistrations = collectTools().filter(({ module }) => module === self || !seatNames.has(module)); const tools = ownRegistrations.flatMap(({ module, tools: own }) => own.map((t) => ({ module, name: t.name }))); const stops: Array<() => void> = []; const stop = (): void => stops.splice(0).forEach((s) => s()); // Each tool on its own key, namespaced by its module (ADR 0047); where that key is answered is // the broker's to know from the membership (ADR 0160). for (const { module, tools: own } of ownRegistrations) { const seen = new Set(); for (const t of own) { if (seen.has(t.name)) { stop(); throw new Error(`${module} exposes two tools named ${t.name} — refused`); } seen.add(t.name); stops.push(await opts.broker.handle(toolKey(module, t.name), (args: Record | undefined) => t.run(args ?? {}))); } } // And, for every module that serves any, the verb that says what it serves. Refused before // anything is bound if a module named a tool of its own `tools`: one name answering two things // is the fault nobody can diagnose afterwards, and the runtime is the only place that sees both. const runtime = opts.broker as RuntimeBroker; for (const { module, tools: own } of ownRegistrations) { if (own.length === 0) continue; if (own.some((t) => t.name === TOOLS_VERB)) { stop(); throw new Error( `${module} names a tool "${TOOLS_VERB}", which is the verb the runtime answers for every ` + "module with what it serves (novox/hq ADR 0152) — refused, rename it", ); } const subjectsOf = (tool: string): string[] | undefined => { const issued = typeof runtime.membership === "function" ? runtime.membership() : undefined; if (!issued) return undefined; const plain = issued.serves.filter((s) => s.queue).map((s) => s.subject.replace("{tool}", tool)); const mine = issued.serves.filter((s) => !s.queue).map((s) => s.subject.replace("{tool}", tool)); return [...plain, ...mine]; }; const answer: ToolsAnswer = { module, tools: own.map((t) => ({ name: t.name, description: t.description, input: t.input, subjects: subjectsOf(t.name) })), }; stops.push(await opts.broker.handle(toolKey(module, TOOLS_VERB), async () => answer)); } console.log(`[mesh-tools] serving ${tools.length} tool(s): ${tools.map((t) => t.name).join(", ") || "(none)"}`); stops.push(await serveClaimedSeats(opts.broker as RuntimeBroker, opts.credential)); return () => { for (const s of stops) s(); }; } /** * Holding a seat means serving its tools (design 33 §3, novox/hq ADR 0159). The credential names the * seats this module claims and the verbs each promises; each verb is served on the seat's own * subject by the module's tool of the same name. Whether this instance *holds* the seat is the bus's * to decide: only the holder's account may subscribe the seat's subjects, so a claimant that does not * hold it here is refused the subscription and serves nothing — never a failure of its own tools. */ async function serveClaimedSeats(broker: RuntimeBroker, credential?: Credential): Promise<() => void> { const claims = credential?.claims ?? []; if (claims.length === 0 || typeof broker.handleSubject !== "function") return () => {}; // A seat's verbs are the role's, not the software's (ADR 0159): implemented under the seat's // name — `registerModuleTools("mesh-store", …)` — and never confused with the module's own tools. const implementations = new Map) => Promise>>(); for (const { module, tools } of collectTools()) { if (!claims.some((c) => c.seat === module)) continue; const verbs = new Map) => Promise>(); for (const t of tools) verbs.set(t.name, (args) => t.run(args)); implementations.set(module, verbs); } let stops: (() => void)[] = []; const serve = async (): Promise => { stops.forEach((s) => s()); stops = []; const issued = typeof broker.membership === "function" ? broker.membership() : undefined; for (const claim of claims) { const verbs = implementations.get(claim.seat); for (const verb of claim.serves ?? []) { const run = verbs?.get(verb); if (!run) { console.log(`[mesh-tools] claims ${claim.seat} and implements no ${verb}, which that seat promises; not served`); continue; } // Where the mesh issued the verb when it has; the derived shape until then. const subject = issued?.seats?.find((s) => s.seat === claim.seat && s.verb === verb)?.subject ?? seatToolSubject(claim.seat, verb, claim.scope, credential?.node); stops.push(await broker.handleSubject(subject, run)); console.log(`[mesh-tools] serving ${claim.seat}'s ${verb} on ${subject}, admitted where this module holds the seat`); } } }; await serve(); if (typeof broker.onMembership === "function") broker.onMembership(() => void serve()); return () => stops.forEach((s) => s()); }