A runtime serves what the mesh issued it, and a seat's verbs are implemented under the seat's name (hq ADR 0160)
The one address a runtime derives for itself is mesh.assignment.<node>.<module>. It reads the
membership there with a direct get on the ASSIGNMENTS stream, serves each tool exactly where the
membership says — the plain subject in the module's queue when the mesh issued one, this machine's
beside it — and follows the subject, re-serving when a new membership arrives. A mesh that has issued
nothing yet gets the shape it always derived, and the log says so.
A seat's verbs are the role's, not the software's (ADR 0159): a module implements them with
registerModuleTools("<seat>", …), the runtime serves that on the seat's subjects when the credential
claims the seat, and never lists it among the module's own tools. A module named like its seat
registers once and is both.
The tools answer carries each tool's subjects, and the console and CLI call the subject the listing
gave them instead of composing one.
This commit is contained in:
+72
-21
@@ -6,7 +6,7 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
import { useBroker } from "@novox/mesh-sdk/messaging";
|
||||
import { collectTools, serveTools, listTools, toolKey } from "@novox/mesh-sdk/tools";
|
||||
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";
|
||||
|
||||
@@ -20,7 +20,14 @@ 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>> }[];
|
||||
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[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface RuntimeOptions {
|
||||
@@ -45,14 +52,36 @@ export async function runTools(opts: RuntimeOptions): Promise<() => void> {
|
||||
// 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.
|
||||
const tools = listTools();
|
||||
const stop = tools.length > 0 ? await serveTools(opts.broker) : () => {};
|
||||
// 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<string>();
|
||||
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<string, unknown> | 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 stops: Array<() => void> = [stop];
|
||||
for (const { module, tools: own } of collectTools()) {
|
||||
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();
|
||||
@@ -61,9 +90,16 @@ export async function runTools(opts: RuntimeOptions): Promise<() => void> {
|
||||
"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 })),
|
||||
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));
|
||||
}
|
||||
@@ -85,22 +121,37 @@ export async function runTools(opts: RuntimeOptions): Promise<() => void> {
|
||||
async function serveClaimedSeats(broker: RuntimeBroker, credential?: Credential): Promise<() => void> {
|
||||
const claims = credential?.claims ?? [];
|
||||
if (claims.length === 0 || typeof broker.handleSubject !== "function") return () => {};
|
||||
const byName = new Map<string, (args: Record<string, unknown>) => Promise<unknown>>();
|
||||
for (const { tools } of collectTools()) {
|
||||
for (const t of tools) byName.set(t.name, (args) => t.run(args));
|
||||
// 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>>>();
|
||||
for (const { module, tools } of collectTools()) {
|
||||
if (!claims.some((c) => c.seat === module)) continue;
|
||||
const verbs = new Map<string, (args: Record<string, unknown>) => Promise<unknown>>();
|
||||
for (const t of tools) verbs.set(t.name, (args) => t.run(args));
|
||||
implementations.set(module, verbs);
|
||||
}
|
||||
const stops: (() => void)[] = [];
|
||||
for (const claim of claims) {
|
||||
for (const verb of claim.serves ?? []) {
|
||||
const run = byName.get(verb);
|
||||
if (!run) {
|
||||
console.log(`[mesh-tools] claims ${claim.seat} and has no tool named ${verb}, which that seat promises; not served`);
|
||||
continue;
|
||||
let stops: (() => void)[] = [];
|
||||
const serve = async (): Promise<void> => {
|
||||
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`);
|
||||
}
|
||||
const 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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user