One repository, two modules (ADR 0069). `node-tools/` holds the runtime — its code, tests, package and the manifest of the module the controller composes a process for on every machine it is assigned to: a bundle of `src/main.js`, the interpreter as a package, a place for the node's credential, the loopback port the console declared, and leave to call every tool. Nothing about how it runs: which bundles to load, where the credential is and whose machine it is are the controller's to compose (WP2). The root module `mesh-tools` keeps the two images TypeScript bundles are compiled in and a module's own service may run in; it is no longer how tools reach a node. As node-tools, `serve` is also the console (ADR 0175 §6): the same process answers MCP on loopback for whoever is on the machine, through which the tools it serves can be called. A module's own runtime in a container keeps serving without a listener. The toolchain image now carries /app/runtime — a package.json saying the compiled files are ES modules and the production node_modules — for the builder to copy into every TypeScript bundle, so a bundle unpacked on a machine starts (ADR 0188 §5; the builder's side is the controller's). Proven here by compiling node-tools with the toolchain's exact flags and starting the result. The AMQP probe script is gone with the bus it probed.
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 0170).
|
|
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}`;
|
|
}
|