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.
189 lines
9.2 KiB
TypeScript
189 lines
9.2 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, seatsIn, toolKey, 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", "node-dns-resolver.lookup", "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 carries its scope, so a caller names
|
|
// the machine and the verb resolves as the seat's (design 33 §4, ADR 0170).
|
|
assert.ok(have.tools.find((x) => x.module === "mesh-controller")!.seat);
|
|
const lookup = have.tools.find((x) => x.module === "node-dns-resolver")!;
|
|
assert.ok(lookup.seat && lookup.scope === "node");
|
|
assert.equal(toolKey("node-dns-resolver.lookup", seatsIn(have)), "seat:node-dns-resolver.lookup");
|
|
// 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();
|
|
}
|
|
});
|