From 200249a84f7581d566d8a39674cc718e8976b796 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 29 Oct 2026 23:19:55 +0100 Subject: [PATCH] Let the TypeScript mesh client take every role's tools from the console on its machine, so it asks the controller for none (issue 1955, ADR 0571 clause 5) The Go console answers mesh/seats beside MCP's methods from mesh-sdk's seat definitions and placement's seat-holders. The console test that runs bundles expects the unread seat-holders line beside ghost. --- node-tools/internal/console/console_test.go | 4 +- node-tools/internal/console/mcp.go | 35 ++++++++++++++ node-tools/internal/console/runtimes_test.go | 6 +++ node-tools/src/client.ts | 48 ++++++++++++++------ node-tools/test/client.test.ts | 23 ++++++---- node-tools/test/console-seats.ts | 31 +++++++++++++ node-tools/test/mcp.test.ts | 22 +++++---- node-tools/test/node-runtime.test.ts | 7 ++- 8 files changed, 140 insertions(+), 36 deletions(-) create mode 100644 node-tools/test/console-seats.ts diff --git a/node-tools/internal/console/console_test.go b/node-tools/internal/console/console_test.go index c8e557b..11fbf5c 100644 --- a/node-tools/internal/console/console_test.go +++ b/node-tools/internal/console/console_test.go @@ -86,7 +86,9 @@ func TestTheConsoleListsAndCallsOverHTTP(t *testing.T) { if got := strings.Join(names, ","); got != "alpha.one,alpha.two,beta.five,beta.four,beta.three,node-shelf.clear,node-shelf.list" { t.Errorf("listed %s", got) } - if got := listed["_meta"].(map[string]any)["notAnswering"]; len(got.([]any)) != 1 || got.([]any)[0] != "ghost on desk" { + // The console's runtime is issued no read of seat-holders here: that is said beside ghost, never dropped (ADR 0571). + if got := listed["_meta"].(map[string]any)["notAnswering"]; len(got.([]any)) != 2 || got.([]any)[0] != "ghost on desk" || + !strings.HasPrefix(got.([]any)[1].(string), "mesh-delivery-assigner.seat-holders (who holds which seat by placement could not be read") { t.Errorf("not answering: %v", got) } for _, x := range listed["tools"].([]any) { diff --git a/node-tools/internal/console/mcp.go b/node-tools/internal/console/mcp.go index 70c5c76..b7d5e80 100644 --- a/node-tools/internal/console/mcp.go +++ b/node-tools/internal/console/mcp.go @@ -6,6 +6,8 @@ package console import ( "context" "encoding/json" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" "os" "strings" "sync" @@ -60,6 +62,34 @@ type Surface struct { Flat bool } +// MethodSeats is the console's JSON-RPC method beside MCP's that answers every seat's verbs and holders, in the shape +// the controller's `tools` answered: `{seats: [{seat, scope, tools: [{name, description, input, replaces}], holders: +// [{module, node}]}], holders_why}`. +const MethodSeats = "mesh/seats" + +// seatsAnswer is MethodSeats' answer: mesh-sdk's seat definitions, with each seat's holders by placement. +func (s *Surface) seatsAnswer() map[string]any { + placed, err := seatHoldersOn(s.conn) + x := &index{Placed: placed} + seats := []map[string]any{} + for _, st := range catalogue.DefaultSeats() { + holders := x.placedHolders(st.Name, "") + if holders == nil { + holders = []delivery.SeatHolder{} + } + tools := st.Serves + if tools == nil { + tools = []catalogue.Verb{} + } + seats = append(seats, map[string]any{"seat": st.Name, "scope": st.Scope, "tools": tools, "holders": holders}) + } + out := map[string]any{"seats": seats} + if err != nil { + out["holders_why"] = "who holds which seat by placement could not be read: " + err.Error() + } + return out +} + // NewSurface is the surface over a connection, as `who`. func NewSurface(conn *bus.Conn, who string) *Surface { return &Surface{conn: conn, who: who, Flat: os.Getenv("MESH_CONSOLE_FLAT") == "1"} @@ -125,6 +155,11 @@ func (s *Surface) HandleContext(ctx context.Context, r Request) *Reply { return nil } return answer(r.ID, map[string]any{}) + case MethodSeats: + // The seats' contract and who holds each, for a client on this machine that has no seat definitions of its own + // (the TypeScript `mesh`): read in-process from mesh-sdk and from placement, never asked of the controller + // (novox/hq ADR 0571 clauses 4 and 5). + return answer(r.ID, s.seatsAnswer()) case "tools/list": if !s.Flat { return answer(r.ID, map[string]any{"tools": discovery()}) diff --git a/node-tools/internal/console/runtimes_test.go b/node-tools/internal/console/runtimes_test.go index 7b1f8af..ad543fa 100644 --- a/node-tools/internal/console/runtimes_test.go +++ b/node-tools/internal/console/runtimes_test.go @@ -142,6 +142,12 @@ func TestARuntimeThatAnswersLateIsFoundAndOneThatNeverDoesIsNamed(t *testing.T) t.Errorf("the late runtime's machine lists nothing: %s", machine) } + // The seats' contract and holders, for a client on this machine with no seat definitions (the TypeScript `mesh`). + seats := post(t, endpoint, `{"jsonrpc":"2.0","id":9,"method":"mesh/seats"}`) + if raw, _ := json.Marshal(seats["result"]); !strings.Contains(string(raw), `"holders":[{"module":"mesh-delivery-assigner","node":"bench"}],"scope":"mesh","seat":"mesh-wants"`) || + !strings.Contains(string(raw), `"replaces":["journalctl"]`) { + t.Errorf("mesh/seats answered %s", raw) + } if n := toolsAsked.Load(); n != 0 { t.Errorf("the console asked the controller's tools %d times", n) } diff --git a/node-tools/src/client.ts b/node-tools/src/client.ts index 919839e..f9663cd 100644 --- a/node-tools/src/client.ts +++ b/node-tools/src/client.ts @@ -26,9 +26,28 @@ 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"; +/** Where every role's tools are answered: the console on this machine, the Go runtime, which holds mesh-sdk's seat + * definitions and reads who holds each seat from placement (novox/hq ADR 0571 clauses 4 and 5). Asked over the loopback + * channel `mesh --console` already uses, by the method `mesh/seats` beside MCP's; never the controller. */ +export const SEATS_METHOD = "mesh/seats"; +const DEFAULT_CONSOLE = "http://127.0.0.1:4270"; + +type SeatsAnswer = { seats?: { seat: string; scope?: string; tools?: ToolsAnswer["tools"] }[]; holders_why?: string }; + +/** Every role's tools from the console on this machine. */ +async function seatsFromConsole(consoleUrl: string): Promise { + const endpoint = consoleUrl.endsWith("/mcp") ? consoleUrl : `${consoleUrl.replace(/\/$/, "")}/mcp`; + const res = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: SEATS_METHOD }), + signal: AbortSignal.timeout(5_000), + }); + if (!res.ok) throw new Error(`the console at ${endpoint} answered ${res.status}`); + const reply = (await res.json()) as { result?: SeatsAnswer; error?: { message: string } }; + if (reply.error) throw new Error(reply.error.message); + return reply.result ?? {}; +} /** A tool as its module describes it. */ export interface Tool { @@ -58,8 +77,8 @@ export interface Tool { 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. */ + * before the runtime answered it. Each may still be called by name. The roles' tools are listed here, with the + * reason, when the console on this machine did not answer them. */ notAnswering: string[]; } @@ -159,17 +178,16 @@ export async function connectAsTheConsole(path: string): Promise<{ bus: Broker; * 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 { +export async function toolsOn(bus: Broker, consoleUrl: string = process.env.MESH_CONSOLE || DEFAULT_CONSOLE): Promise { + let rolesWhy = ""; const [answered, roles] = await Promise.all([ bus.request, { 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, { seats?: { seat: string; scope?: string; tools?: ToolsAnswer["tools"] }[] }>( - SEAT_TOOLS, - {}, - ) - .catch(() => undefined), + // The roles' tools, from the console on this machine (ADR 0571). Asked beside the modules rather than first: a + // console that is restarting must not hide every module's tools with it. + seatsFromConsole(consoleUrl).catch((e: unknown) => { + rolesWhy = e instanceof Error ? e.message : String(e); + return undefined; + }), ]); const names = (answered.modules ?? []).map((m) => m.module).filter((m) => typeof m === "string"); @@ -188,7 +206,7 @@ export async function toolsOn(bus: Broker): Promise { } } } else { - notAnswering.push("mesh-controller (seat)"); + notAnswering.push(`the roles' tools (the console at ${consoleUrl} did not answer ${SEATS_METHOD}: ${rolesWhy})`); } asked.forEach((outcome, i) => { const module = names[i]!; diff --git a/node-tools/test/client.test.ts b/node-tools/test/client.test.ts index 9c82ba2..9ff8401 100644 --- a/node-tools/test/client.test.ts +++ b/node-tools/test/client.test.ts @@ -16,6 +16,8 @@ import { test } from "node:test"; import { connectNats } from "../dist/broker-nats.js"; import { callTool, seatsIn, toolKey, toolsOn, whyItFailed } from "../dist/client.js"; +import { aConsoleWithSeats } from "./console-seats.ts"; + 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 @@ -36,21 +38,26 @@ async function aMeshWithTools() { 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. + // Every role's tools, from the console on this machine (ADR 0571), the method beside MCP's; the controller's + // verb answers a call of its seat and is never asked for the roles. 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: {} }] }, - ], - })); + let controllerToolsAsked = 0; + await controller.handle("seat:mesh-controller.tools", async () => { + controllerToolsAsked++; + return { seats: [] }; + }); await controller.handle("seat:mesh-controller.status", async () => ({ output: "all quiet", ok: true })); + const local = await aConsoleWithSeats([ + { 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: {} }] }, + ]); return { async close() { await catalogue.close(); await shop.close(); await controller.close(); + await local.close(); + assert.equal(controllerToolsAsked, 0, "the controller was asked for the roles' tools"); }, }; } diff --git a/node-tools/test/console-seats.ts b/node-tools/test/console-seats.ts new file mode 100644 index 0000000..6053591 --- /dev/null +++ b/node-tools/test/console-seats.ts @@ -0,0 +1,31 @@ +/** + * The console on this machine as a client asks it for every role's tools (novox/hq ADR 0571): the method `mesh/seats` + * beside MCP's, answered in the shape the Go console's `seatsAnswer` gives (internal/console/mcp.go). Sets + * MESH_CONSOLE to it, so a client, and a `mesh` it spawns, asks it. + */ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { SEATS_METHOD } from "../dist/client.js"; + +export type SeatOfConsole = { seat: string; scope: string; tools: { name: string; description?: string; input?: unknown }[] }; + +export async function aConsoleWithSeats(seats: SeatOfConsole[]): Promise<{ url: string; close: () => Promise }> { + const local = createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + const asked = JSON.parse(body) as { id: number; method: string }; + res.setHeader("content-type", "application/json"); + if (req.url !== "/mcp" || asked.method !== SEATS_METHOD) { + res.end(JSON.stringify({ jsonrpc: "2.0", id: asked.id, error: { code: -32601, message: "no such method" } })); + return; + } + res.end(JSON.stringify({ jsonrpc: "2.0", id: asked.id, result: { seats: seats.map((s) => ({ ...s, holders: [] })) } })); + }); + }); + await new Promise((done) => local.listen(0, "127.0.0.1", done)); + const url = `http://127.0.0.1:${(local.address() as AddressInfo).port}`; + process.env.MESH_CONSOLE = url; + return { url, close: () => new Promise((done) => local.close(() => done())) }; +} diff --git a/node-tools/test/mcp.test.ts b/node-tools/test/mcp.test.ts index dfec071..7b44f13 100644 --- a/node-tools/test/mcp.test.ts +++ b/node-tools/test/mcp.test.ts @@ -12,6 +12,8 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { spawn } from "node:child_process"; + +import { aConsoleWithSeats } from "./console-seats.ts"; import { connectNats } from "../dist/broker-nats.js"; const url = process.env.MESH_TEST_NATS; @@ -29,16 +31,15 @@ async function aMeshAndACredential(t: { after: (fn: () => Promise | void) })); await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 })); 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: {} }, - { name: "push", description: "tell a machine", input: { node: { type: "string" } } }, - ] }, - // A seat held once per machine (design 33 §4, ADR 0170): its verb is asked of one. - { seat: "node-dns-resolver", scope: "node", tools: [{ name: "lookup", description: "one machine's", input: {} }] }, - ], - })); + // Every role's tools, from the console on this machine (ADR 0571), which the spawned `mesh` asks through MESH_CONSOLE. + const local = await aConsoleWithSeats([ + { seat: "mesh-controller", scope: "mesh", tools: [ + { name: "status", description: "what is wrong", input: {} }, + { name: "push", description: "tell a machine", input: { node: { type: "string" } } }, + ] }, + // A seat held once per machine (design 33 §4, ADR 0170): its verb is asked of one. + { seat: "node-dns-resolver", scope: "node", tools: [{ name: "lookup", description: "one machine's", input: {} }] }, + ]); await controller.handle("seat:node-dns-resolver.lookup@anchor", async () => ({ machine: "anchor", answered: true })); await controller.handle("seat:mesh-controller.status", async () => ({ output: "all quiet", ok: true })); await controller.handle("seat:mesh-controller.push", async (body: { node?: string }) => ({ told: body.node ?? "nobody" })); @@ -46,6 +47,7 @@ async function aMeshAndACredential(t: { after: (fn: () => Promise | void) await catalogue.close(); await shop.close(); await controller.close(); + await local.close(); }); const { mkdtemp, writeFile } = await import("node:fs/promises"); diff --git a/node-tools/test/node-runtime.test.ts b/node-tools/test/node-runtime.test.ts index 5e0c050..406b648 100644 --- a/node-tools/test/node-runtime.test.ts +++ b/node-tools/test/node-runtime.test.ts @@ -172,9 +172,12 @@ test("the node's runtime serves five modules' bundles on one credential — two const catalogue = await connectNats({ url, module: "mesh-catalog" }); await catalogue.handle("catalog_modules", async () => ({ modules: [{ module: "alpha" }, { module: "beta" }, { module: "gamma" }, { module: "delta" }, { module: "epsilon" }] })); try { - const have = await toolsOn(asker); + // No console on this port: the roles' tools are said missing with the reason, never asked of the controller. + const have = await toolsOn(asker, "http://127.0.0.1:9"); assert.deepEqual(have.tools.map((x) => `${x.module}.${x.name}`), ["alpha.one", "alpha.two", "beta.five", "beta.four", "beta.three", "delta.die", "delta.greet", "epsilon.seven"]); - assert.deepEqual(have.notAnswering, ["gamma (its tools bundle failed to load: gamma's bundle exited (1): Error: gamma's bundle cannot find its client)", "mesh-controller (seat)"]); + assert.equal(have.notAnswering[0], "gamma (its tools bundle failed to load: gamma's bundle exited (1): Error: gamma's bundle cannot find its client)"); + assert.match(have.notAnswering[1] ?? "", /^the roles' tools \(the console at http:\/\/127\.0\.0\.1:9 did not answer mesh\/seats: /); + assert.equal(have.notAnswering.length, 2); } finally { await catalogue.close(); }