One tool runtime per node, host-side, is what the runtime was written to be; the catalogue built a container per module around it instead. This lets `serve` take a list — MESH_TOOL_MODULES as <module>=<entrypoint> entries — and do for every assigned module what it did for one: read that module's membership and follow it, serve its tools where the membership says, serve each held seat's verbs on the seat's subjects. The seats come from the memberships now, so the node's credential carries no claims; a module's own runtime still reads its credential's, so nothing built today changes behaviour. A bare path in MESH_TOOL_MODULES stays the one-module form. A bundle that throws on import is said in the log and in what `tools` answers for its module (`failed`), which discovery lists with the reason instead of as "not answering"; the other bundles serve. The filter that dropped every registration under a name but the one module goes; what stays is that a registration under a seat's name is served only where some served module claims the seat. A tool runs attributed to its module, so an event it emits lands on the module's subject and not the runtime's. MESH_OPERATOR_ACCOUNT and MESH_OPERATOR_HOME are read and said; tools take them from their environment. Proven against a real bus: three bundles, one broken; five tools and two seat verbs answer on their subjects; `tools` names the failed bundle; a membership re-issued mid-run re-serves.
281 lines
13 KiB
TypeScript
281 lines
13 KiB
TypeScript
/**
|
|
* The mesh's tools, for whoever is on a machine (novox/hq design 25 §7, design 34).
|
|
*
|
|
* Two surfaces over one thing. A command line, for somebody at a terminal; an MCP server, for an
|
|
* agent. Both are adapters over the same three calls — what tools are there, what does this one take,
|
|
* call it — because a second way of reaching a tool is a second thing to keep correct.
|
|
*
|
|
* **It uses the same client a module's runtime uses.** Not a second protocol and not a bridge: the
|
|
* caller connects as its own bus user — a person's, or the console's — publishes on the tool subjects
|
|
* that account permits, and the server refuses anything else. So "what may this ask" is answered by the
|
|
* same permission list that answers it for a module, and there is nothing here for an audit to read
|
|
* separately.
|
|
*
|
|
* What the caller may NOT do is the more interesting half, and none of it is enforced here — it is the
|
|
* account (design 25 §4): it cannot publish an event, so it cannot claim a module said something; it
|
|
* has no consumer, so there is no delivery to acknowledge; and it cannot answer a request, so it cannot
|
|
* impersonate a module on a bus where anyone may serve a tool.
|
|
*/
|
|
import { readFile } from "node:fs/promises";
|
|
|
|
import type { Broker } from "@novox/mesh-sdk/messaging";
|
|
|
|
import { connectNats, type Answered, type Credential } from "./broker-nats.js";
|
|
import { TOOLS_VERB, type ToolsAnswer } from "./runtime.js";
|
|
|
|
/** Where the catalogue answers which modules the mesh holds. */
|
|
const CATALOGUE_MODULES = "mesh-catalog.catalog_modules";
|
|
|
|
/** Where the mesh answers every role's tools, from its records: the mesh-controller seat's own
|
|
* `tools` verb (novox/hq ADR 0154, design 33 §5). */
|
|
const SEAT_TOOLS = "seat:mesh-controller.tools";
|
|
|
|
/** A tool as its module describes it. */
|
|
export interface Tool {
|
|
/** The module that serves it — or, for a role's tool, the seat. */
|
|
module: string;
|
|
name: string;
|
|
description?: string;
|
|
/** The JSON schema of what it takes, as the module declared it. */
|
|
input?: unknown;
|
|
/** True for a role's tool: addressed to the seat, answered by whoever holds it (ADR 0132). */
|
|
seat?: boolean;
|
|
/** A role's scope: `node` for a seat held once per machine, whose verb is asked of one machine
|
|
* (design 33 §4) and takes `node` for it; `mesh` or absent otherwise. */
|
|
scope?: string;
|
|
/** Where the tool is answered, as the mesh issued it (ADR 0160): the plain subject first when the
|
|
* module answers for itself anywhere, then one per machine. Absent for a runtime older than this. */
|
|
subjects?: string[];
|
|
}
|
|
|
|
/**
|
|
* What the mesh could say about its tools when asked (design 34 §3).
|
|
*
|
|
* **Silence is named, never dropped.** A module the catalogue holds and nothing answered for is in
|
|
* `notAnswering`, because a tool that is not offered looks exactly like a tool that does not exist,
|
|
* and those need different people to fix them.
|
|
*/
|
|
export interface Listing {
|
|
tools: Tool[];
|
|
/** Modules the catalogue holds whose runtime did not answer `tools`: not assigned, not up, or built
|
|
* before the runtime answered it. Each may still be called by name. The mesh's own records are
|
|
* listed here as `mesh-controller (seat)` when the control plane did not answer. */
|
|
notAnswering: string[];
|
|
}
|
|
|
|
/** The seats and the verbs each declares, from the last listing, so a call can tell a role's tool
|
|
* from a module's when the two share a prefix (a module and a seat may share a name). */
|
|
export type Seats = Map<string, Set<string>>;
|
|
|
|
/** The roles' tools, keyed the way `toolKey` names them. */
|
|
export function seatsIn(have: Listing): Seats {
|
|
const seats: Seats = new Map();
|
|
for (const t of have.tools) {
|
|
if (!t.seat) continue;
|
|
if (!seats.has(t.module)) seats.set(t.module, new Set());
|
|
seats.get(t.module)!.add(t.name);
|
|
}
|
|
return seats;
|
|
}
|
|
|
|
/** The key a call uses for `<prefix>.<name>`: a role's when the prefix is a seat declaring that
|
|
* verb, a module's otherwise. Both names for one capability are deliberate and bounded (ADR 0132);
|
|
* the seat wins only for a verb it actually declares, so a module's own tool is never shadowed. */
|
|
export function toolKey(name: string, seats?: Seats): string {
|
|
if (name.startsWith("seat:")) return name;
|
|
const dot = name.indexOf(".");
|
|
if (dot < 0) return name;
|
|
const prefix = name.slice(0, dot);
|
|
const verb = name.slice(dot + 1);
|
|
if (seats?.get(prefix)?.has(verb)) return `seat:${prefix}.${verb}`;
|
|
return name;
|
|
}
|
|
|
|
/**
|
|
* A person's credential, as `operator issue` prints it.
|
|
*
|
|
* The same shape a module is handed, minus the parts a module needs and a person does not: no node,
|
|
* because a person is not on a machine, and no module, because they are not one.
|
|
*/
|
|
export interface PersonCredential extends Credential {
|
|
person?: string;
|
|
invokes?: string[];
|
|
}
|
|
|
|
/** Read the credential from the file `operator issue` produced. */
|
|
export async function credentialFrom(path: string): Promise<PersonCredential> {
|
|
const raw = await readFile(path, "utf8");
|
|
let held: PersonCredential;
|
|
try {
|
|
held = JSON.parse(raw) as PersonCredential;
|
|
} catch (e) {
|
|
throw new Error(
|
|
`${path} is not a credential this mesh issued: ${(e as Error).message}. ` +
|
|
"It is the JSON `operator issue` printed, saved verbatim.",
|
|
);
|
|
}
|
|
if (!held.url || !held.user || !held.password) {
|
|
throw new Error(
|
|
`${path} names no bus, user or password. It is the JSON \`operator issue\` printed, saved ` +
|
|
"verbatim — not an edited copy of it.",
|
|
);
|
|
}
|
|
return held;
|
|
}
|
|
|
|
/** Connect as this person. The module name the runtime wants is their own user, because every subject
|
|
* it derives is for a tool somebody else serves. */
|
|
export async function connectAs(held: PersonCredential): Promise<Broker> {
|
|
return connectNats({ ...held, module: held.user });
|
|
}
|
|
|
|
/**
|
|
* Connect as the console: the module credential the mesh delivered (novox/hq ADR 0152), read from
|
|
* the same variable every runtime reads. It names the node and the module, so the account's inbox
|
|
* and subjects derive from what the mesh authorised and from nothing in this process's environment.
|
|
*/
|
|
export async function connectAsTheConsole(path: string): Promise<{ bus: Broker; who: string }> {
|
|
const raw = await readFile(path, "utf8");
|
|
let held: Credential;
|
|
try {
|
|
held = JSON.parse(raw) as Credential;
|
|
} catch (e) {
|
|
throw new Error(`${path} is not a broker credential: ${(e as Error).message}`);
|
|
}
|
|
if (!held.url || !held.module || !held.user) {
|
|
throw new Error(
|
|
`${path} names no bus, module or user: the console runs on the credential the mesh sealed to ` +
|
|
"this machine for it, and nothing else",
|
|
);
|
|
}
|
|
return { bus: await connectNats(held), who: `${held.node ?? "?"}.${held.module}` };
|
|
}
|
|
|
|
/**
|
|
* What tools the mesh has, asked of the modules (design 34 §3).
|
|
*
|
|
* The catalogue says which modules the mesh holds; each module says what it serves, through the one
|
|
* verb its runtime answers for it. **Asked, not configured**: a client carrying its own list would be a
|
|
* list that goes stale the first time a module is assigned. Every module is asked at once, and the bus
|
|
* refuses at once a request nothing serves, so the cost is bounded by the modules that are up.
|
|
*/
|
|
export async function toolsOn(bus: Broker): Promise<Listing> {
|
|
const [answered, roles] = await Promise.all([
|
|
bus.request<Record<string, never>, { modules?: { module: string }[] }>(CATALOGUE_MODULES, {}),
|
|
// The roles' tools, from the mesh's records (design 33 §5). Asked beside the modules rather
|
|
// than first: a control plane that is restarting must not hide every module's tools with it.
|
|
bus
|
|
.request<Record<string, never>, { seats?: { seat: string; scope?: string; tools?: ToolsAnswer["tools"] }[] }>(
|
|
SEAT_TOOLS,
|
|
{},
|
|
)
|
|
.catch(() => undefined),
|
|
]);
|
|
const names = (answered.modules ?? []).map((m) => m.module).filter((m) => typeof m === "string");
|
|
|
|
const asked = await Promise.allSettled(
|
|
names.map((module) => bus.request<Record<string, never>, ToolsAnswer>(`${module}.${TOOLS_VERB}`, {})),
|
|
);
|
|
const tools: Tool[] = [];
|
|
const notAnswering: string[] = [];
|
|
if (roles) {
|
|
for (const s of roles.seats ?? []) {
|
|
// A node-scoped seat's tool is asked of one machine (design 33 §4): listed with its scope, so
|
|
// a caller names the machine and the call carries it — `seat:<seat>.<verb>@<node>`. Left out
|
|
// of the listing, the verb never resolved as a seat's and nothing served it (ADR 0169).
|
|
for (const t of s.tools ?? []) {
|
|
tools.push({ module: s.seat, name: t.name, description: t.description, input: t.input, seat: true, scope: s.scope });
|
|
}
|
|
}
|
|
} else {
|
|
notAnswering.push("mesh-controller (seat)");
|
|
}
|
|
asked.forEach((outcome, i) => {
|
|
const module = names[i]!;
|
|
if (outcome.status === "fulfilled" && typeof outcome.value?.failed === "string") {
|
|
// The runtime answered for it and serves nothing: the bundle failed to load (ADR 0175). Said
|
|
// with the reason, because "not answering" would send somebody to check an assignment that
|
|
// is fine.
|
|
notAnswering.push(`${module} (its tools bundle failed to load: ${outcome.value.failed})`);
|
|
} else if (outcome.status === "fulfilled" && Array.isArray(outcome.value?.tools)) {
|
|
for (const t of outcome.value.tools) {
|
|
tools.push({ module, name: t.name, description: t.description, input: t.input, subjects: t.subjects });
|
|
}
|
|
} else {
|
|
notAnswering.push(module);
|
|
}
|
|
});
|
|
tools.sort((a, b) => `${a.module}.${a.name}`.localeCompare(`${b.module}.${b.name}`));
|
|
notAnswering.sort();
|
|
return { tools, notAnswering };
|
|
}
|
|
|
|
/** Call one tool. The key is `<module>.<tool>`, which is what a person types and what the account
|
|
* permits — one vocabulary, so a refusal names the thing they asked for. */
|
|
/** Call a tool and learn which machine answered (novox/hq ADR 0159). `<module>.<tool>@<node>` asks
|
|
* the instance on one machine; without it, whichever instance answers first does, and the answer
|
|
* says which. */
|
|
export async function callTool(
|
|
bus: Broker,
|
|
key: string,
|
|
args: unknown,
|
|
seats?: Seats,
|
|
listing?: Listing,
|
|
): Promise<Answered<unknown>> {
|
|
const at = key.indexOf("@");
|
|
const name = at < 0 ? key : key.slice(0, at);
|
|
const node = at < 0 ? "" : key.slice(at + 1);
|
|
if (!name.includes(".")) {
|
|
throw new Error(
|
|
`"${key}" does not name a tool: write <module>.<tool>, as \`mesh tools\` lists them, ` +
|
|
"or <module>.<tool>@<node> for the instance on one machine",
|
|
);
|
|
}
|
|
const resolved = toolKey(name, seats) + (node ? `@${node}` : "");
|
|
// Where the tool is answered is the module's to say and the mesh's to issue (ADR 0160): when the
|
|
// listing carried subjects for it, the call goes to one of those and composes nothing.
|
|
const on = subjectListed(name, node, listing);
|
|
const asking = bus as Broker & { ask?: <Req, Res>(k: string, b: Req, on?: string) => Promise<Answered<Res>> };
|
|
if (typeof asking.ask === "function") return asking.ask<unknown, unknown>(resolved, args ?? {}, on);
|
|
return { result: await bus.request<unknown, unknown>(resolved, args ?? {}) };
|
|
}
|
|
|
|
/** The subject the listing says answers `<module>.<tool>` — the machine's when one is named, else
|
|
* the plain one — or undefined when the listing said none, and the key is composed as before. */
|
|
export function subjectListed(name: string, node: string, listing?: Listing): string | undefined {
|
|
if (!listing) return undefined;
|
|
const dot = name.indexOf(".");
|
|
const module = name.slice(0, dot);
|
|
const tool = name.slice(dot + 1);
|
|
const found = listing.tools.find((t) => t.module === module && t.name === tool && !t.seat);
|
|
const subjects = found?.subjects ?? [];
|
|
if (subjects.length === 0) return undefined;
|
|
if (node) return subjects.find((s) => s.endsWith(`.${node}`));
|
|
return subjects[0];
|
|
}
|
|
|
|
/**
|
|
* Why a call failed, said so that the remedy is in the words.
|
|
*
|
|
* Three answers a person actually gets, and they need different things done: nobody serves that tool,
|
|
* the mesh refused this account, or the tool itself failed. Without this they are one timeout and a
|
|
* stack trace.
|
|
*/
|
|
export function whyItFailed(key: string, err: unknown): string {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (/no responders|503/i.test(message)) {
|
|
return `nothing serves ${key}. The module may not be assigned to any machine, or it is down` +
|
|
(key.startsWith("seat:") ? ", or nothing holds that seat" : "") +
|
|
" — `mesh tools` lists what answered.";
|
|
}
|
|
if (/permissions violation|authorization/i.test(message)) {
|
|
return `this account may not call ${key}. What it may call was fixed when it was issued — a ` +
|
|
"person's by `operator issue`, the console's by its manifest.";
|
|
}
|
|
if (/timeout/i.test(message)) {
|
|
return `${key} did not answer in time. Something is serving it, so this is the tool being slow ` +
|
|
"rather than absent.";
|
|
}
|
|
return `${key} failed: ${message}`;
|
|
}
|