The per-module containers still run this runtime; their tools and the seats they hold (the store's, the catalogue's) must be found by the console the same way as the node runtime's. It answers $SRV.PING, $SRV.INFO and $SRV.STATS with one service per process, one endpoint per tool per subject and per seat verb served, the metadata as the Go runtime writes it.
382 lines
19 KiB
TypeScript
382 lines
19 KiB
TypeScript
// The tool runtime — the per-node process that makes the mesh's tools actually serve (novox/hq
|
|
// ADR 0175). It binds the mesh broker, loads the served modules' tool bundles (each of which calls
|
|
// registerModuleTools as it imports), and serves every module's tools on that module's subjects and
|
|
// every held seat's verbs on the seat's. Everything hard — dispatch, collection, duplicate-name
|
|
// safety — is the sdk's; this is the wrapper.
|
|
//
|
|
// One runtime, many modules. It was written for one module per process and ran that way in a
|
|
// container per module; it now serves a list, as the one process per node the host supervises,
|
|
// and the per-module shape is the list with one entry. A bundle that fails to import is named —
|
|
// in the log and in what `tools` answers for it — and the others serve.
|
|
|
|
import { pathToFileURL } from "node:url";
|
|
import { resolve } from "node:path";
|
|
import { useBroker } from "@novox/mesh-sdk/messaging";
|
|
import { collectTools, toolKey, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
|
import type { Broker } from "@novox/mesh-sdk/messaging";
|
|
import { atWork, seatToolSubject, type Credential, type RuntimeBroker } from "./broker-nats.js";
|
|
import { launch, launches } from "./launch.js";
|
|
import { announce, endpointsOf, type ServedSeatVerb } from "./announce.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<Record<string, unknown>>;
|
|
/** 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[];
|
|
}[];
|
|
/** Why this module serves nothing here, when its bundle failed to load (ADR 0175): said where
|
|
* discovery looks, so a module that is silent and one that is broken are told apart. */
|
|
failed?: string;
|
|
}
|
|
|
|
/** One module this runtime serves: its name and its compiled tool entrypoints. */
|
|
export interface ServedModule {
|
|
module: string;
|
|
/** Absolute paths to the module's compiled tool entrypoints (e.g. .../umami/tools/index.js). */
|
|
entrypoints: string[];
|
|
}
|
|
|
|
export interface RuntimeOptions {
|
|
/** The mesh broker to serve over. */
|
|
broker: Broker;
|
|
/** The modules to serve, each with its entrypoints. */
|
|
serves?: ServedModule[];
|
|
/** The credential's own module's entrypoints — the one-module form, which the per-module
|
|
* containers still use; the same as naming the credential's module in `serves`. */
|
|
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 its
|
|
* memberships do not name. */
|
|
credential?: Credential;
|
|
/** What each served module's bundles are given (novox/hq ADR 0192): module → words, composed by
|
|
* the mesh per machine. A module absent here is given the runtime's own words and nothing more. */
|
|
envs?: ReadonlyMap<string, Readonly<Record<string, string>>>;
|
|
}
|
|
|
|
/** The variable the mesh composes every served module's environment into, as JSON (ADR 0192). Read
|
|
* once at start and removed from the process's environment, so no bundle finds another's there. */
|
|
export const TOOL_ENV = "MESH_TOOL_ENV";
|
|
|
|
/** Read and remove the composed environments from an environment (the process's, by default). */
|
|
export function takeToolEnvs(env: NodeJS.ProcessEnv = process.env): Map<string, Record<string, string>> {
|
|
const raw = env[TOOL_ENV];
|
|
delete env[TOOL_ENV];
|
|
const out = new Map<string, Record<string, string>>();
|
|
if (!raw) return out;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
throw new Error(`${TOOL_ENV} is not JSON; the mesh composes it as {"<module>": {"<word>": "<value>"}}`);
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error(`${TOOL_ENV} is not an object of modules`);
|
|
}
|
|
for (const [module, words] of Object.entries(parsed as Record<string, unknown>)) {
|
|
if (!words || typeof words !== "object" || Array.isArray(words)) {
|
|
throw new Error(`${TOOL_ENV}: ${module}'s environment is not an object of words`);
|
|
}
|
|
const own: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(words as Record<string, unknown>)) own[k] = String(v);
|
|
out.set(module, own);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Two environment words the mesh sets for the node's runtime and every tool reads from its
|
|
* environment: whose machine this is (novox/hq to-be 37 §3, ADR 0175). */
|
|
export const OPERATOR_ACCOUNT = "MESH_OPERATOR_ACCOUNT";
|
|
export const OPERATOR_HOME = "MESH_OPERATOR_HOME";
|
|
|
|
/** 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);
|
|
const runtime = opts.broker as RuntimeBroker;
|
|
// Whose runtime this is: the credential's module, or the connection's own when a runtime is
|
|
// started by hand without one — the broker was told its module when it connected.
|
|
const self = opts.credential?.module ?? (typeof runtime.module === "string" ? runtime.module : undefined);
|
|
|
|
// What to serve: the list, with the one-module form folded in as the credential's own entry.
|
|
const served = new Map<string, string[]>();
|
|
for (const s of opts.serves ?? []) {
|
|
served.set(s.module, [...(served.get(s.module) ?? []), ...s.entrypoints]);
|
|
}
|
|
const ownEntrypoints = opts.moduleEntrypoints ?? [];
|
|
if (opts.moduleEntrypoints?.length) {
|
|
if (!self) {
|
|
throw new Error(
|
|
"entrypoints were given with no module to serve them as: name the module (MESH_TOOL_MODULES " +
|
|
"as <module>=<entrypoint>) or connect on a credential that names one",
|
|
);
|
|
}
|
|
served.set(self, [...(served.get(self) ?? []), ...opts.moduleEntrypoints]);
|
|
}
|
|
|
|
// The operator's machine, said once so a tool's behaviour under it can be read back from the
|
|
// log. Tools read the two words from their own environment, which is this process's.
|
|
const account = process.env[OPERATOR_ACCOUNT];
|
|
if (account) {
|
|
console.log(`[mesh-tools] the operator's account here is ${account}` +
|
|
(process.env[OPERATOR_HOME] ? ` (home ${process.env[OPERATOR_HOME]})` : ""));
|
|
}
|
|
|
|
// Follow every served module's membership before loading anything, so what each is issued is
|
|
// known when its tools are bound. A module's own runtime already follows its own.
|
|
if (typeof runtime.follow === "function") {
|
|
for (const module of served.keys()) await runtime.follow(module);
|
|
}
|
|
|
|
// What each module's bundles are given: the runtime's own words, and over them the module's own.
|
|
const envs = opts.envs ?? new Map<string, Record<string, string>>();
|
|
const envFor = (module: string): NodeJS.ProcessEnv => ({ ...process.env, ...(envs.get(module) ?? {}) });
|
|
|
|
// Every bundle this runtime serves is launched as a process and spoken to over MCP on stdio (ADR
|
|
// 0188, ADR 0193): given the runtime's words and its module's own, and told the module it serves
|
|
// it as. The runtime knows no language; an entrypoint that is not executable was not built to be
|
|
// served, and is refused by name. A bundle that fails to start is named, and the others serve
|
|
// (ADR 0175: one faulty bundle must not take the node's tools down).
|
|
//
|
|
// The one-module form — the credential's own module's entrypoints, which the per-module containers
|
|
// still use for their event handlers and provisioners until they move (to-be 38 WP4c) — is imported
|
|
// into this process as before: one module, one SDK, its container's own environment.
|
|
// The machine this runtime serves, which an event a launched tool emits is stamped with.
|
|
const node = opts.credential?.node ?? (typeof (runtime as unknown as { node?: unknown }).node === "string" ? (runtime as unknown as { node: string }).node : undefined);
|
|
const failed = new Map<string, string>();
|
|
const launched: { module: string; owner: string; tools: ToolDefinition[] }[] = [];
|
|
const children: Array<() => void> = [];
|
|
for (const [module, entrypoints] of served) {
|
|
const imported = module === self && ownEntrypoints.length > 0;
|
|
for (const entry of entrypoints) {
|
|
const path = resolve(entry);
|
|
try {
|
|
if (imported) {
|
|
await import(pathToFileURL(path).href);
|
|
continue;
|
|
}
|
|
if (!launches(path)) {
|
|
throw new Error(`${path} is not executable; a bundle the runtime serves is started, never imported, and its build makes it executable (novox/hq ADR 0193)`);
|
|
}
|
|
const child = await launch(module, path, {
|
|
...envFor(module), MESH_SERVED_MODULE: module, MESH_MODULE: module,
|
|
...(node ? { MESH_NODE: node } : {}),
|
|
});
|
|
children.push(child.stop);
|
|
for (const r of child.registrations) launched.push({ ...r, owner: module });
|
|
} catch (err) {
|
|
const why = err instanceof Error ? err.message : String(err);
|
|
failed.set(module, why);
|
|
console.log(`[mesh-tools] ${module}'s bundle ${entry} failed to load: ${why}; its tools are not served here`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A registration under a served module's name is that module's tools, served on its subjects.
|
|
// One 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 where some served module claims the seat,
|
|
// never as a module's tools and never listed among them. A module named like its seat (the
|
|
// catalogue is the mesh-catalog seat) registers once and is both. Anything else is said and left
|
|
// out rather than fatal — on 2026-10-01 the credential of a module that had just learned to
|
|
// implement a seat did not yet name the claim, and the whole runtime restarted for it.
|
|
const claimed = seatsClaimed(served.keys(), self, opts.credential, runtime);
|
|
const registrations = [
|
|
...collectTools().map((r) => ({ ...r, owner: self ?? r.module })),
|
|
...launched,
|
|
];
|
|
const ownRegistrations = registrations.filter(({ module, owner: by }) => {
|
|
if (served.has(module)) return true;
|
|
if (claimed.has(module)) return false;
|
|
console.log(`[mesh-tools] ${by} registers tools under "${module}", which is neither a module served here nor a seat one of them claims; not served until the mesh issues the claim`);
|
|
return false;
|
|
});
|
|
const stops: Array<() => void> = [...children];
|
|
const stop = (): void => stops.splice(0).forEach((s) => s());
|
|
|
|
// 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. Likewise two tools of one module under one name.
|
|
for (const { module, tools: own } of ownRegistrations) {
|
|
if (own.some((t) => t.name === TOOLS_VERB)) {
|
|
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 seen = new Set<string>();
|
|
for (const t of own) {
|
|
if (seen.has(t.name)) throw new Error(`${module} exposes two tools named ${t.name} — refused`);
|
|
seen.add(t.name);
|
|
}
|
|
}
|
|
|
|
// 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 module's membership (ADR 0160). A tool runs attributed to its
|
|
// module, so what it emits lands on the module's subject and not the runtime's.
|
|
const names: string[] = [];
|
|
for (const { module, tools: own } of ownRegistrations) {
|
|
for (const t of own) {
|
|
names.push(toolKey(module, t.name));
|
|
stops.push(await opts.broker.handle(toolKey(module, t.name), (args: Record<string, unknown> | undefined) =>
|
|
atWork.run({ module }, () => t.run(args ?? {}))));
|
|
}
|
|
}
|
|
|
|
// And, for every served module, the verb that says what it serves — nothing, and why, for a
|
|
// module whose bundle failed. A module that registered nothing and did not fail is a pure-events
|
|
// module (the audit logger), whose scoped account may not declare the serve queue; it is left
|
|
// silent as it always was.
|
|
const byModule = new Map<string, ToolDefinition[]>();
|
|
for (const { module, tools: own } of ownRegistrations) {
|
|
byModule.set(module, [...(byModule.get(module) ?? []), ...own]);
|
|
}
|
|
for (const module of served.keys()) {
|
|
const own = byModule.get(module) ?? [];
|
|
const why = failed.get(module);
|
|
if (own.length === 0 && !why) continue;
|
|
const subjectsOf = (tool: string): string[] | undefined => {
|
|
const m = typeof runtime.membership === "function" ? runtime.membership(module) : undefined;
|
|
if (!m) return undefined;
|
|
const plain = m.serves.filter((s) => s.queue).map((s) => s.subject.replace("{tool}", tool));
|
|
const mine = m.serves.filter((s) => !s.queue).map((s) => s.subject.replace("{tool}", tool));
|
|
return [...plain, ...mine];
|
|
};
|
|
stops.push(await opts.broker.handle(toolKey(module, TOOLS_VERB), async (): Promise<ToolsAnswer> => ({
|
|
module,
|
|
tools: own.map((t) => ({ name: t.name, description: t.description, input: t.input, subjects: subjectsOf(t.name) })),
|
|
...(why ? { failed: why } : {}),
|
|
})));
|
|
}
|
|
|
|
console.log(`[mesh-tools] serving ${names.length} tool(s) for ${served.size} module(s): ${names.join(", ") || "(none)"}` +
|
|
(failed.size ? `; not serving ${[...failed.keys()].join(", ")}, whose bundle(s) failed to load` : ""));
|
|
const seats = await serveClaimedSeats(runtime, [...served.keys()], self, opts.credential, registrations);
|
|
stops.push(seats.stop);
|
|
// **What it serves, it announces** (novox/hq ADR 0197), asked at the moment of the request.
|
|
if (typeof runtime.raw === "function") {
|
|
stops.push(announce(runtime, {
|
|
name: self ?? "runtime", id: runtime.node ?? self ?? "runtime",
|
|
description: `the tool runtime of ${self ?? "a module"}${runtime.node ? ` on ${runtime.node}` : ""}`,
|
|
metadata: runtime.node ? { node: runtime.node } : {},
|
|
}, () => endpointsOf(runtime, ownRegistrations, seats.serving())));
|
|
}
|
|
return () => stop();
|
|
}
|
|
|
|
/** The seats some served module claims: from the credential for its own module, and from every
|
|
* served module's membership (ADR 0160) — the node's runtime holds no claims of its own. */
|
|
function seatsClaimed(
|
|
modules: Iterable<string>,
|
|
self: string | undefined,
|
|
credential: Credential | undefined,
|
|
runtime: RuntimeBroker,
|
|
): Set<string> {
|
|
const out = new Set<string>();
|
|
for (const c of credential?.claims ?? []) if (credential?.module === self) out.add(c.seat);
|
|
for (const module of modules) {
|
|
const m = typeof runtime.membership === "function" ? runtime.membership(module) : undefined;
|
|
for (const s of m?.seats ?? []) out.add(s.seat);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** One seat's verb, where its callers ask, and which served module holds the seat. */
|
|
interface SeatVerb {
|
|
seat: string;
|
|
verb: string;
|
|
subject: string;
|
|
holder: string;
|
|
}
|
|
|
|
/**
|
|
* Holding a seat means serving its tools (design 33 §3, novox/hq ADR 0159). What a served module
|
|
* claims and promises comes from its membership (ADR 0160) — and, for a module's own runtime, from
|
|
* its credential, which named the claims before memberships did. Each verb is served on the seat's
|
|
* own subject by the tool of the same name registered under the seat's 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,
|
|
served: string[],
|
|
self: string | undefined,
|
|
credential: Credential | undefined,
|
|
registrations: { module: string; owner: string; tools: ToolDefinition[] }[],
|
|
): Promise<{ stop: () => void; serving: () => ServedSeatVerb[] }> {
|
|
if (typeof broker.handleSubject !== "function") return { stop: () => {}, serving: () => [] };
|
|
// 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<string, Map<string, (args: Record<string, unknown>) => Promise<unknown>>>();
|
|
const definitions = new Map<string, Map<string, ToolDefinition>>();
|
|
for (const { module, tools } of registrations) {
|
|
const defs = definitions.get(module) ?? new Map<string, ToolDefinition>();
|
|
for (const t of tools) defs.set(t.name, t);
|
|
definitions.set(module, defs);
|
|
}
|
|
for (const { module, owner, tools } of registrations) {
|
|
const verbs = implementations.get(module) ?? new Map<string, (args: Record<string, unknown>) => Promise<unknown>>();
|
|
for (const t of tools) verbs.set(t.name, (args) => atWork.run({ module: owner }, () => t.run(args)));
|
|
implementations.set(module, verbs);
|
|
}
|
|
|
|
/** Every verb of every seat a served module claims, where the mesh issued it. */
|
|
const wanted = (): SeatVerb[] => {
|
|
const out: SeatVerb[] = [];
|
|
const have = new Set<string>();
|
|
const add = (v: SeatVerb): void => {
|
|
if (have.has(v.subject)) return;
|
|
have.add(v.subject);
|
|
out.push(v);
|
|
};
|
|
for (const module of served) {
|
|
const m = typeof broker.membership === "function" ? broker.membership(module) : undefined;
|
|
for (const s of m?.seats ?? []) add({ seat: s.seat, verb: s.verb, subject: s.subject, holder: module });
|
|
// The credential's claims, for the module's own runtime: where the mesh issued the verb when
|
|
// it has; the derived shape until then.
|
|
if (module !== self) continue;
|
|
for (const claim of credential?.claims ?? []) {
|
|
for (const verb of claim.serves ?? []) {
|
|
const subject = m?.seats?.find((s) => s.seat === claim.seat && s.verb === verb)?.subject
|
|
?? seatToolSubject(claim.seat, verb, claim.scope, credential?.node);
|
|
add({ seat: claim.seat, verb, subject, holder: module });
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
let stops: (() => void)[] = [];
|
|
let servingNow: ServedSeatVerb[] = [];
|
|
const serve = async (): Promise<void> => {
|
|
stops.forEach((s) => s());
|
|
stops = [];
|
|
servingNow = [];
|
|
for (const v of wanted()) {
|
|
const run = implementations.get(v.seat)?.get(v.verb);
|
|
const tool = definitions.get(v.seat)?.get(v.verb);
|
|
if (!run) {
|
|
console.log(`[mesh-tools] ${v.holder} claims ${v.seat} and implements no ${v.verb}, which that seat promises; not served`);
|
|
continue;
|
|
}
|
|
stops.push(await broker.handleSubject(v.subject, run));
|
|
if (tool) servingNow.push({ ...v, tool });
|
|
console.log(`[mesh-tools] serving ${v.seat}'s ${v.verb} on ${v.subject}, admitted where ${v.holder} holds the seat`);
|
|
}
|
|
};
|
|
await serve();
|
|
// A membership issued to any served module may add, move or withdraw a seat's verbs.
|
|
if (typeof broker.onMembership === "function") broker.onMembership(() => void serve());
|
|
return { stop: () => stops.forEach((s) => s()), serving: () => servingNow };
|
|
}
|