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(); }