runtime serves its seat's verbs (novox/hq ADR 0159) A module on several machines served one subject in one queue group, so a call reached whichever instance answered first and nobody could ask one machine's instance. Now each instance also serves its subject with its machine as the last token, `<module>.<tool>@<node>` addresses it, and every answer carries the machine that gave it: the console lists `node` on every module tool, strips it into the subject, and appends "answered by <node>" to the answer; `mesh call` prints it. And a seat's verbs are served by whoever claims the seat: the credential names the claimed seats and the verbs each promises, the runtime serves each verb with the module's tool of the same name on the seat's own subject, and the bus — which admits only the holder's subscription — decides where that serving is real. Design 33 §3 and §4, built.
186 lines
8.9 KiB
TypeScript
186 lines
8.9 KiB
TypeScript
/**
|
|
* A person's client, against a real bus.
|
|
*
|
|
* What is worth checking is not that a request/reply works — the runtime's own tests cover that — but
|
|
* that the two surfaces are the same thing. An agent and a person must see the same tools and get the
|
|
* same answers, or the MCP surface becomes a second definition of what a tool is.
|
|
*
|
|
* docker run -d --rm --name t -p 14232:4222 nats:2.10-alpine -js
|
|
* MESH_TEST_NATS=nats://127.0.0.1:14232 node --test --experimental-strip-types test/client.test.ts
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
|
|
// The built output, not the source: the client imports its siblings as `.js`, which is what ships and
|
|
// what every other file here does, and cannot be loaded as TypeScript directly. `pretest` builds.
|
|
import { connectNats } from "../dist/broker-nats.js";
|
|
import { callTool, toolsOn, whyItFailed } from "../dist/client.js";
|
|
|
|
const url = process.env.MESH_TEST_NATS;
|
|
|
|
/** A mesh as discovery sees it (design 34 §3): the catalogue holding three modules, two of them up
|
|
* and answering `tools`, one held and not running. Two connections, because a person and a module
|
|
* are different users even in a test. */
|
|
async function aMeshWithTools() {
|
|
const catalogue = await connectNats({ url: url!, module: "mesh-catalog" });
|
|
const shop = await connectNats({ url: url!, module: "shop" });
|
|
await catalogue.handle("catalog_modules", async () => ({
|
|
modules: [{ module: "shop" }, { module: "mesh-catalog" }, { module: "ghost" }],
|
|
}));
|
|
await catalogue.handle("tools", async () => ({
|
|
module: "mesh-catalog",
|
|
tools: [{ name: "catalog_modules", description: "what modules the mesh has", input: {} }],
|
|
}));
|
|
await shop.handle("tools", async () => ({
|
|
module: "shop",
|
|
tools: [{ name: "price", description: "what something costs", input: { type: "object" } }],
|
|
}));
|
|
await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 }));
|
|
// And the mesh's own records, served by the holder of the mesh-controller seat (ADR 0154): one
|
|
// role's tool, and the verb that lists every role's.
|
|
const controller = await connectNats({ url: url!, module: "mesh-controller" });
|
|
await controller.handle("seat:mesh-controller.tools", async () => ({
|
|
seats: [
|
|
{ seat: "mesh-controller", scope: "mesh", tools: [{ name: "status", description: "what is wrong", input: {} }] },
|
|
{ seat: "node-dns-resolver", scope: "node", tools: [{ name: "lookup", description: "one machine's", input: {} }] },
|
|
],
|
|
}));
|
|
await controller.handle("seat:mesh-controller.status", async () => ({ output: "all quiet", ok: true }));
|
|
return {
|
|
async close() {
|
|
await catalogue.close();
|
|
await shop.close();
|
|
await controller.close();
|
|
},
|
|
};
|
|
}
|
|
|
|
test("a person sees what the running modules answer, sorted, and who did not answer", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
const mesh = await aMeshWithTools();
|
|
const person = await connectNats({ url, module: "person.ada" });
|
|
try {
|
|
const began = Date.now();
|
|
const have = await toolsOn(person);
|
|
assert.deepEqual(
|
|
have.tools.map((x) => `${x.module}.${x.name}`),
|
|
["mesh-catalog.catalog_modules", "mesh-controller.status", "shop.price"],
|
|
"the list is what the modules answered plus every role's tools, in a stable order",
|
|
);
|
|
// A role's tool is marked as one; a node-scoped seat's waits for a caller naming the node.
|
|
assert.ok(have.tools.find((x) => x.module === "mesh-controller")!.seat);
|
|
assert.ok(!have.tools.some((x) => x.module === "node-dns-resolver"));
|
|
// Silence is named, never dropped: a module the catalogue holds and nothing answered for.
|
|
assert.deepEqual(have.notAnswering, ["ghost"]);
|
|
// And at once: a module that is not running costs nothing, or the list is unusable.
|
|
assert.ok(Date.now() - began < 5_000, "an absent module waited out the timeout");
|
|
} finally {
|
|
await person.close();
|
|
await mesh.close();
|
|
}
|
|
});
|
|
|
|
test("a person calls a tool and gets the module's own answer, unshaped", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
const mesh = await aMeshWithTools();
|
|
const person = await connectNats({ url, module: "person.ada" });
|
|
try {
|
|
const answer = await callTool(person, "shop.price", { of: "a hat" });
|
|
assert.deepEqual(answer.result, { of: "a hat", cost: 12 });
|
|
} finally {
|
|
await person.close();
|
|
await mesh.close();
|
|
}
|
|
});
|
|
|
|
test("a tool nobody serves says so at once, and says what to do about it", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
const person = await connectNats({ url: url!, module: "person.ada" });
|
|
try {
|
|
const began = Date.now();
|
|
await assert.rejects(() => callTool(person, "ghost.missing", {}));
|
|
// At once, not after the whole wait: "that module is down" and "that tool is slow" need
|
|
// different things done, and a timeout cannot tell them apart.
|
|
assert.ok(Date.now() - began < 5_000, "a tool nobody serves waited out the timeout");
|
|
} finally {
|
|
await person.close();
|
|
}
|
|
});
|
|
|
|
test("a name that is not <module>.<tool> is refused before anything is sent", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
const person = await connectNats({ url: url!, module: "person.ada" });
|
|
try {
|
|
await assert.rejects(() => callTool(person, "price", {}), /does not name a tool/);
|
|
} finally {
|
|
await person.close();
|
|
}
|
|
});
|
|
|
|
test("each way a call fails says what to do about it", () => {
|
|
// The three answers a person actually gets. Without this they are one timeout and a stack trace,
|
|
// and the remedies are in three different places.
|
|
assert.match(whyItFailed("shop.price", new Error("no responders")), /nothing serves shop\.price/);
|
|
assert.match(
|
|
whyItFailed("shop.price", new Error("Permissions Violation for Publish")),
|
|
/may not call shop\.price/,
|
|
);
|
|
assert.match(whyItFailed("shop.price", new Error("timeout")), /did not answer in time/);
|
|
assert.match(whyItFailed("shop.price", new Error("something else")), /something else/);
|
|
});
|
|
|
|
test("a role's tool is reached through the seat, and a module's own name is never shadowed", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
const { seatsIn, toolKey } = await import("../dist/client.js");
|
|
const mesh = await aMeshWithTools();
|
|
const person = await connectNats({ url, module: "person.ada" });
|
|
try {
|
|
const roles = seatsIn(await toolsOn(person));
|
|
assert.equal(toolKey("mesh-controller.status", roles), "seat:mesh-controller.status");
|
|
assert.equal(toolKey("mesh-controller.other", roles), "mesh-controller.other", "a verb the seat does not declare is a module's");
|
|
assert.equal(toolKey("shop.price", roles), "shop.price");
|
|
const answer = await callTool(person, "mesh-controller.status", {}, roles);
|
|
assert.deepEqual(answer.result, { output: "all quiet", ok: true });
|
|
const direct = await callTool(person, "seat:mesh-controller.status", {});
|
|
assert.deepEqual(direct.result, { output: "all quiet", ok: true });
|
|
} finally {
|
|
await person.close();
|
|
await mesh.close();
|
|
}
|
|
});
|
|
|
|
// A module on two machines (novox/hq ADR 0159): a call names the machine and reaches that instance
|
|
// and no other; a call that names none reaches one of them and says which; a seat's verb is served
|
|
// by the claimant's tool of the same name on the seat's own subject.
|
|
test("a tool call names the machine it is for, and every answer says which machine answered", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
const onAnchor = await connectNats({ url: url!, module: "store", node: "anchor" });
|
|
const onHome = await connectNats({ url: url!, module: "store", node: "home-server" });
|
|
const asker = await connectNats({ url: url!, module: "console", node: "workstation" });
|
|
try {
|
|
await onAnchor.handle("databases", async () => ({ at: "anchor" }));
|
|
await onHome.handle("databases", async () => ({ at: "home-server" }));
|
|
|
|
const home = await callTool(asker, "store.databases@home-server", {});
|
|
assert.deepEqual(home.result, { at: "home-server" });
|
|
assert.equal(home.node, "home-server");
|
|
const anchor = await callTool(asker, "store.databases@anchor", {});
|
|
assert.deepEqual(anchor.result, { at: "anchor" });
|
|
assert.equal(anchor.node, "anchor");
|
|
|
|
const whichever = await callTool(asker, "store.databases", {});
|
|
assert.ok(["anchor", "home-server"].includes(whichever.node ?? ""), `an unnamed call still says who answered: ${whichever.node}`);
|
|
assert.deepEqual(whichever.result, { at: whichever.node });
|
|
|
|
// A seat's verb, served by the claimant on the seat's subject; a mesh seat's is flat.
|
|
await onAnchor.handleSubject("mesh.seat.mesh-store.tool.databases", async () => ({ seat: "mesh-store", at: "anchor" }));
|
|
const viaSeat = await callTool(asker, "seat:mesh-store.databases", {});
|
|
assert.deepEqual(viaSeat.result, { seat: "mesh-store", at: "anchor" });
|
|
assert.equal(viaSeat.node, "anchor");
|
|
} finally {
|
|
await onAnchor.close();
|
|
await onHome.close();
|
|
await asker.close();
|
|
}
|
|
});
|