mesh-delivery is the delivery's seat and the module holding it, which answers the seat's verbs with tools of the same names. Discovery keyed the seat's verbs and the module's tools in one namespace, so the module's tool took the key and the seat was listed with no verb; and every address with the name resolved to the seat, so neither the seat's verbs nor the module's tools could be called. The keys are apart, and `<node>/<module>.<tool>` reaches the module when the module serves it (novox/hq issue 284).
954 lines
32 KiB
Go
954 lines
32 KiB
Go
package console
|
|
|
|
// The mesh's tools found by address, not announced whole (novox/hq ADR 0195, to-be 34 §3a).
|
|
//
|
|
// The console announces six tools — five to find and call, and mesh_runtimes to say which runtimes
|
|
// discovery heard. Everything the mesh answers is reached through them by one address per layer:
|
|
//
|
|
// <seat>.<verb> a seat held once for the mesh — its holder answers
|
|
// <node>/<seat>.<verb> a seat held once per machine — that machine's holder answers
|
|
// <node>/<module>.<tool> a module assigned to a machine — that assignment answers
|
|
// <module>.<tool> also, for a module whose instances are interchangeable (ADR 0160)
|
|
//
|
|
// A module that is not interchangeable is called with its machine or refused, naming the machines
|
|
// it runs on: "whichever answers" is no answer for state a machine holds.
|
|
//
|
|
// Every discovery verb asks the mesh when it is called — kept a few seconds at most, never for a
|
|
// session — so a tool that arrived a minute ago is found without the client reconnecting.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/announce"
|
|
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
|
)
|
|
|
|
// IndexKept is how long what the mesh answered is kept before it is asked again: long enough that
|
|
// one agent turn's search, describe and call ask once, short enough that nothing goes stale.
|
|
var IndexKept = 5 * time.Second
|
|
|
|
// The six tools the console announces. Names of the API's kind — letters, digits, `_`, `-`.
|
|
const (
|
|
verbOverview = "mesh_overview"
|
|
verbMachine = "mesh_machine"
|
|
verbSearch = "mesh_search"
|
|
verbDescribe = "mesh_describe"
|
|
verbCall = "mesh_call"
|
|
verbRuntimes = "mesh_runtimes"
|
|
)
|
|
|
|
// searchCap is how many matches a search answers before it says how many more there were.
|
|
const searchCap = 25
|
|
|
|
const grammar = "Addresses: `<seat>.<verb>` for a seat held once for the mesh (e.g. `mesh-controller.nodes`); " +
|
|
"`<node>/<seat>.<verb>` for a seat every machine holds (e.g. `ace/node-packet-filter.rules`); " +
|
|
"`<node>/<module>.<tool>` for a module on one machine (e.g. `novox/postgres.postgres_list_databases`); " +
|
|
"and `<module>.<tool>` also for a module whose instances are interchangeable."
|
|
|
|
// discovery is the six tools as tools/list announces them.
|
|
func discovery() []map[string]any {
|
|
str := func(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
|
|
obj := func(props map[string]any, required ...string) map[string]any {
|
|
s := map[string]any{"type": "object", "properties": props}
|
|
if len(required) > 0 {
|
|
s["required"] = required
|
|
}
|
|
return s
|
|
}
|
|
return []map[string]any{
|
|
{"name": verbOverview, "inputSchema": obj(map[string]any{}),
|
|
"description": "The mesh at a glance: the seats it holds once for the whole mesh with their verbs, the seats " +
|
|
"every machine holds, and its machines. Start here, then `mesh_machine` for one machine. " + grammar},
|
|
{"name": verbMachine, "inputSchema": obj(map[string]any{"node": str("the machine, as mesh_overview names it")}, "node"),
|
|
"description": "One machine: the seats it holds with their verbs, and the modules assigned to it with their tools — " +
|
|
"each with the address to describe or call it by. " + grammar},
|
|
{"name": verbSearch, "inputSchema": obj(map[string]any{"query": str("words to find in tool names and descriptions, e.g. `postgres databases`")}, "query"),
|
|
"description": "Find tools anywhere in the mesh by words: every match's address and a line of what it does, across the " +
|
|
"mesh's seats, the machines' seats and every module on every machine. " + grammar},
|
|
{"name": verbDescribe, "inputSchema": obj(map[string]any{"address": str("the tool's address")}, "address"),
|
|
"description": "What one tool does and the arguments it takes, as a JSON schema. The machine is in the address, " +
|
|
"never an argument. " + grammar},
|
|
{"name": verbCall, "inputSchema": obj(map[string]any{
|
|
"address": str("the tool's address"),
|
|
"arguments": map[string]any{"type": "object", "description": "the tool's arguments, as mesh_describe gives its schema"},
|
|
}, "address"),
|
|
"description": "Call one tool by its address with its arguments; the answer says which machine gave it. " + grammar},
|
|
{"name": verbRuntimes, "inputSchema": obj(map[string]any{}),
|
|
"description": "Which runtimes answered discovery, asked now: for each, its machine, how long its answer took to " +
|
|
"arrive, its size in bytes, how many modules and tools it announced, whether it was shortened to fit the " +
|
|
"bus, and when it was last heard — and every runtime or machine expected that was not heard. Read-only; " +
|
|
"for when an address is said to be missing or on another machine."},
|
|
}
|
|
}
|
|
|
|
func isDiscovery(name string) bool {
|
|
switch name {
|
|
case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall, verbRuntimes:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// seatInfo is a seat as the mesh's records define it, and who holds it where.
|
|
type seatInfo struct {
|
|
Seat string
|
|
Scope string // "mesh" or "node"
|
|
Verbs []Tool
|
|
Holders []holder
|
|
}
|
|
|
|
type holder struct {
|
|
Module string `json:"module"`
|
|
Node string `json:"node"`
|
|
}
|
|
|
|
// moduleInfo is a module that answers tools: where it runs, whether any instance will do, and its
|
|
// tools as one of its instances described them.
|
|
type moduleInfo struct {
|
|
Module string
|
|
On []string
|
|
Interchangeable bool
|
|
Tools []Tool
|
|
}
|
|
|
|
// index is what the mesh answered about itself, at one moment.
|
|
type index struct {
|
|
Seats []seatInfo
|
|
Machines []string
|
|
Modules map[string]*moduleInfo
|
|
NotAnswering []string
|
|
Listing *Listing // the flat catalogue the call path resolves subjects with
|
|
Discovery announce.Discovery
|
|
// Recorded is where the controller's records place each module that declares tools.
|
|
Recorded map[string][]string
|
|
// Unheard is every runtime whose answer this index lacks: it said it was there and did not say
|
|
// what it serves in time, or it answered before and not now. An address it might answer is never
|
|
// called missing while it is here (2026-10-05).
|
|
Unheard []unheard
|
|
}
|
|
|
|
// unheard is one runtime discovery did not hear in full, and why.
|
|
type unheard struct {
|
|
Runtime string `json:"runtime"`
|
|
Machine string `json:"machine,omitempty"`
|
|
Why string `json:"why"`
|
|
}
|
|
|
|
// unheardOn is what is unheard on one machine; with no machine, everything unheard.
|
|
func (x *index) unheardOn(node string) []unheard {
|
|
var out []unheard
|
|
for _, u := range x.Unheard {
|
|
if node == "" || u.Machine == node || u.Machine == "" {
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sayUnheard is the unheard, as a sentence's end.
|
|
func sayUnheard(us []unheard) string {
|
|
var parts []string
|
|
for _, u := range us {
|
|
who := u.Runtime
|
|
if u.Machine != "" {
|
|
who += " on " + u.Machine
|
|
}
|
|
parts = append(parts, who+" ("+u.Why+")")
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
func (x *index) seat(name string) *seatInfo {
|
|
for i := range x.Seats {
|
|
if x.Seats[i].Seat == name {
|
|
return &x.Seats[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func findTool(tools []Tool, name string) *Tool {
|
|
for i := range tools {
|
|
if tools[i].Name == name {
|
|
return &tools[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// controllerOutput is a controller seat verb's answer: the command's printed output.
|
|
func controllerOutput(conn *bus.Conn, verb string) (string, error) {
|
|
got, err := conn.Ask("seat:mesh-controller."+verb, map[string]any{}, "")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var r struct {
|
|
Output string `json:"output"`
|
|
OK *bool `json:"ok"`
|
|
}
|
|
if json.Unmarshal(got.Result, &r) != nil {
|
|
return "", fmt.Errorf("mesh-controller.%s answered something that is not its output", verb)
|
|
}
|
|
if r.OK != nil && !*r.OK {
|
|
return "", fmt.Errorf("mesh-controller.%s: %s", verb, strings.TrimSpace(r.Output))
|
|
}
|
|
return r.Output, nil
|
|
}
|
|
|
|
// jsonIn is the JSON document a command printed, after any lines it said first: a seat verb runs
|
|
// the controller's command, and a command may warn before it answers.
|
|
func jsonIn(output string) string {
|
|
t := strings.TrimSpace(output)
|
|
if strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[") {
|
|
return t
|
|
}
|
|
for _, open := range []string{"\n{", "\n["} {
|
|
if i := strings.Index(output, open); i >= 0 {
|
|
return strings.TrimSpace(output[i+1:])
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// recordedModule is a module as the controller's records hold it (`module list --json`): where the
|
|
// mesh assigned it, and whether it declares tools — what should announce itself, and where.
|
|
type recordedModule struct {
|
|
Module string `json:"module"`
|
|
On []string `json:"on"`
|
|
Tools bool `json:"tools"`
|
|
}
|
|
|
|
// recordedMachine is a machine as the controller's records hold it (`node list --json`).
|
|
type recordedMachine struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// indexOn asks the mesh what it holds (novox/hq ADR 0197): what answers, from every runtime's own
|
|
// announcement on the bus — one `$SRV.INFO` request — and what should, from the controller's records
|
|
// read as JSON. Nothing is inferred from a roster and nothing is parsed from print.
|
|
func indexOn(conn *bus.Conn) (*index, error) {
|
|
var wg sync.WaitGroup
|
|
var nodesOut, modulesOut string
|
|
wg.Add(2)
|
|
go func() { defer wg.Done(); nodesOut, _ = controllerOutput(conn, "nodes") }()
|
|
go func() { defer wg.Done(); modulesOut, _ = controllerOutput(conn, "modules") }()
|
|
d, err := announce.Gather(conn)
|
|
wg.Wait()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var endpoints []announce.Endpoint
|
|
for _, h := range d.Heard {
|
|
endpoints = append(endpoints, announce.Endpoints(h.Info)...)
|
|
}
|
|
x, announced, machines := indexOfEndpoints(endpoints)
|
|
l := x.Listing
|
|
x.Discovery = d
|
|
for _, s := range d.Silent {
|
|
x.Unheard = append(x.Unheard, unheard{Runtime: s.Name, Machine: s.Machine,
|
|
Why: fmt.Sprintf("it answered PING; what it serves did not arrive within %s", announce.Patience)})
|
|
}
|
|
|
|
// What should have answered: every assignment of a module that declares tools. Silence is named;
|
|
// a module with no tools is never a name here.
|
|
var recorded []recordedModule
|
|
if json.Unmarshal([]byte(jsonIn(modulesOut)), &recorded) == nil {
|
|
for _, m := range recorded {
|
|
if !m.Tools {
|
|
continue
|
|
}
|
|
x.Recorded[m.Module] = append([]string{}, m.On...)
|
|
for _, n := range m.On {
|
|
// A holder of a seat held once for the mesh announces no machine — the seat is the mesh's,
|
|
// not a machine's — so what it announced without one answers for wherever it is assigned.
|
|
if !announced[m.Module][n] && !announced[m.Module][""] {
|
|
l.NotAnswering = append(l.NotAnswering, m.Module+" on "+n)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
l.NotAnswering = append(l.NotAnswering, "mesh-controller (its records of the modules did not answer, so what is missing cannot be said)")
|
|
}
|
|
for _, u := range x.Unheard {
|
|
l.NotAnswering = append(l.NotAnswering, "the runtime "+sayUnheard([]unheard{u}))
|
|
}
|
|
sort.Strings(l.NotAnswering)
|
|
x.NotAnswering = l.NotAnswering
|
|
|
|
var known []recordedMachine
|
|
if json.Unmarshal([]byte(jsonIn(nodesOut)), &known) == nil {
|
|
for _, n := range known {
|
|
if n.Name != "" {
|
|
machines[n.Name] = true
|
|
}
|
|
}
|
|
}
|
|
for n := range machines {
|
|
x.Machines = append(x.Machines, n)
|
|
}
|
|
sort.Strings(x.Machines)
|
|
return x, nil
|
|
}
|
|
|
|
func containsHolder(hs []holder, h holder) bool {
|
|
for _, x := range hs {
|
|
if x == h {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Surface) index() (*index, error) { return s.indexAsked(false) }
|
|
|
|
// indexAsked is what the mesh answered, asked again when `fresh` or when the kept answer is old.
|
|
func (s *Surface) indexAsked(fresh bool) (*index, error) {
|
|
s.mu.Lock()
|
|
if !fresh && s.idx != nil && time.Since(s.idxAt) <= IndexKept {
|
|
x := s.idx
|
|
s.mu.Unlock()
|
|
return x, nil
|
|
}
|
|
s.mu.Unlock()
|
|
x, err := indexOn(s.conn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
s.remember(x)
|
|
s.idx, s.idxAt = x, time.Now()
|
|
s.mu.Unlock()
|
|
return x, nil
|
|
}
|
|
|
|
// indexOfEndpoints is what the announced endpoints say: every seat with its verbs and holders, every
|
|
// module with its tools and machines, the flat listing, and which module announced on which machine.
|
|
func indexOfEndpoints(endpoints []announce.Endpoint) (*index, map[string]map[string]bool, map[string]bool) {
|
|
l := &Listing{Tools: []Tool{}, NotAnswering: []string{}}
|
|
x := &index{Modules: map[string]*moduleInfo{}, Listing: l, Recorded: map[string][]string{}}
|
|
announced := map[string]map[string]bool{} // module → node → announced something
|
|
seats := map[string]*seatInfo{}
|
|
// <module>.<tool>, or seat:<seat>.<verb>, → index in l.Tools. **Apart**, because a seat and a module may
|
|
// share a name — mesh-delivery is the delivery's seat and the module holding it (novox/hq ADR 0239), and
|
|
// the module answers the seat's verbs with tools of the same names: in one namespace the module's tool
|
|
// took the key first, and the seat was listed with no verb at all (novox/hq issue 284).
|
|
toolAt := map[string]int{}
|
|
machines := map[string]bool{}
|
|
for _, e := range endpoints {
|
|
if e.Node != "" {
|
|
machines[e.Node] = true
|
|
}
|
|
if announced[e.Module] == nil {
|
|
announced[e.Module] = map[string]bool{}
|
|
}
|
|
announced[e.Module][e.Node] = true
|
|
switch e.Kind {
|
|
case announce.KindSeat:
|
|
st := seats[e.Seat]
|
|
if st == nil {
|
|
st = &seatInfo{Seat: e.Seat, Scope: e.Scope}
|
|
seats[e.Seat] = st
|
|
}
|
|
if st.Scope != "node" && e.Scope == "node" {
|
|
st.Scope = "node"
|
|
}
|
|
h := holder{Module: e.Module, Node: e.Node}
|
|
if !containsHolder(st.Holders, h) {
|
|
st.Holders = append(st.Holders, h)
|
|
}
|
|
key := "seat:" + e.Seat + "." + e.Tool
|
|
if _, have := toolAt[key]; !have {
|
|
toolAt[key] = len(l.Tools)
|
|
t := Tool{Module: e.Seat, Name: e.Tool, Description: e.Description, Input: e.Schema, Seat: true, Scope: st.Scope}
|
|
l.Tools = append(l.Tools, t)
|
|
st.Verbs = append(st.Verbs, t)
|
|
}
|
|
case announce.KindTool:
|
|
m := x.Modules[e.Module]
|
|
if m == nil {
|
|
m = &moduleInfo{Module: e.Module}
|
|
x.Modules[e.Module] = m
|
|
}
|
|
if e.Node != "" && !contains(m.On, e.Node) {
|
|
m.On = append(m.On, e.Node)
|
|
}
|
|
m.Interchangeable = m.Interchangeable || e.Interchangeable
|
|
key := e.Module + "." + e.Tool
|
|
i, have := toolAt[key]
|
|
if !have {
|
|
i = len(l.Tools)
|
|
toolAt[key] = i
|
|
l.Tools = append(l.Tools, Tool{Module: e.Module, Name: e.Tool, Description: e.Description, Input: e.Schema})
|
|
}
|
|
if !contains(l.Tools[i].Subjects, e.Subject) {
|
|
l.Tools[i].Subjects = append(l.Tools[i].Subjects, e.Subject)
|
|
}
|
|
}
|
|
}
|
|
// A tool's subjects as a call looks them up: the plain one any instance answers first, then each
|
|
// machine's.
|
|
for i := range l.Tools {
|
|
t := &l.Tools[i]
|
|
if t.Seat {
|
|
continue
|
|
}
|
|
plain := "mesh.mod." + t.Module + ".tool." + t.Name
|
|
sort.SliceStable(t.Subjects, func(a, b int) bool {
|
|
if (t.Subjects[a] == plain) != (t.Subjects[b] == plain) {
|
|
return t.Subjects[a] == plain
|
|
}
|
|
return t.Subjects[a] < t.Subjects[b]
|
|
})
|
|
}
|
|
for _, t := range l.Tools {
|
|
if !t.Seat {
|
|
x.Modules[t.Module].Tools = append(x.Modules[t.Module].Tools, t)
|
|
}
|
|
}
|
|
for _, m := range x.Modules {
|
|
sort.Strings(m.On)
|
|
}
|
|
for _, st := range seats {
|
|
sort.Slice(st.Holders, func(a, b int) bool { return st.Holders[a].Node < st.Holders[b].Node })
|
|
x.Seats = append(x.Seats, *st)
|
|
}
|
|
sort.Slice(x.Seats, func(i, j int) bool { return x.Seats[i].Seat < x.Seats[j].Seat })
|
|
sort.SliceStable(l.Tools, func(i, j int) bool {
|
|
return l.Tools[i].Module+"."+l.Tools[i].Name < l.Tools[j].Module+"."+l.Tools[j].Name
|
|
})
|
|
return x, announced, machines
|
|
}
|
|
|
|
// target is what an address resolves to.
|
|
type target struct {
|
|
Address string
|
|
Key string // the call key the existing path takes: seat:<s>.<v>[@node] or <m>.<t>[@node]
|
|
Name string // <seat>.<verb> or <module>.<tool>, for the listing's subject lookup
|
|
Node string
|
|
Tool Tool
|
|
Seat bool
|
|
}
|
|
|
|
// resolve turns an address into exactly one target, or says why it cannot.
|
|
func resolve(x *index, address string) (target, error) {
|
|
address = strings.TrimSpace(address)
|
|
node, rest, hasNode := strings.Cut(address, "/")
|
|
if !hasNode {
|
|
rest, node = address, ""
|
|
}
|
|
dot := strings.Index(rest, ".")
|
|
if dot <= 0 || dot == len(rest)-1 || strings.Contains(rest, "/") {
|
|
return target{}, fmt.Errorf("%q is not an address. %s", address, grammar)
|
|
}
|
|
prefix, name := rest[:dot], rest[dot+1:]
|
|
|
|
s := x.seat(prefix)
|
|
if s != nil && x.Modules[prefix] != nil && findTool(x.Modules[prefix].Tools, name) != nil {
|
|
// **A seat and a module of one name** (novox/hq issue 284): mesh-delivery is the delivery's seat and
|
|
// the module holding it. The module's tool is meant when the seat has no such verb, or when a machine
|
|
// is named for a seat held once for the mesh — `<node>/<module>.<tool>` is a module on one machine.
|
|
if findTool(s.Verbs, name) == nil || (node != "" && s.Scope != "node") {
|
|
s = nil
|
|
}
|
|
}
|
|
if s != nil {
|
|
verb := findTool(s.Verbs, name)
|
|
if verb == nil {
|
|
if len(s.Verbs) == 0 {
|
|
return target{}, fmt.Errorf("the seat %s has no verb %s: its holder announced none", prefix, name)
|
|
}
|
|
return target{}, fmt.Errorf("the seat %s has no verb %s; it has %s", prefix, name, toolNames(s.Verbs))
|
|
}
|
|
if s.Scope == "node" {
|
|
if node == "" {
|
|
return target{}, fmt.Errorf("%s is held once per machine: write <node>/%s — it is held on %s",
|
|
prefix, rest, orNobody(nodesOf(s.Holders)))
|
|
}
|
|
return target{Address: node + "/" + rest, Key: "seat:" + rest + "@" + node, Name: rest, Node: node, Tool: *verb, Seat: true}, nil
|
|
}
|
|
if node != "" {
|
|
return target{}, fmt.Errorf("%s is held once for the whole mesh: write %s, without a machine", prefix, rest)
|
|
}
|
|
return target{Address: rest, Key: "seat:" + rest, Name: rest, Tool: *verb, Seat: true}, nil
|
|
}
|
|
|
|
m := x.Modules[prefix]
|
|
if m == nil {
|
|
if on := x.Recorded[prefix]; len(on) > 0 {
|
|
why := "it did not announce itself on the bus"
|
|
if us := x.unheardOnAny(on); len(us) > 0 {
|
|
why = "discovery did not hear in full from " + sayUnheard(us)
|
|
}
|
|
return target{}, fmt.Errorf("%s is assigned to %s in the mesh's records, but nothing that answered discovery serves it: %s. "+
|
|
"Ask again in a moment; mesh_runtimes shows which runtimes answered", prefix, strings.Join(on, ", "), why)
|
|
}
|
|
if len(x.Unheard) > 0 {
|
|
return target{}, fmt.Errorf("nothing that answered discovery is called %s, but not every runtime answered: %s — "+
|
|
"%s may be theirs. Ask again in a moment; mesh_runtimes shows which runtimes answered", prefix, sayUnheard(x.Unheard), prefix)
|
|
}
|
|
return target{}, fmt.Errorf("nothing in the mesh is called %s: no seat, and no module that answers tools. "+
|
|
"mesh_search finds a tool by words", prefix)
|
|
}
|
|
tool := findTool(m.Tools, name)
|
|
if tool == nil {
|
|
return target{}, fmt.Errorf("%s has no tool %s; it has %s", prefix, name, toolNames(m.Tools))
|
|
}
|
|
if node == "" {
|
|
if !m.Interchangeable {
|
|
return target{}, fmt.Errorf("%s keeps state on each machine it runs on, so a call names the machine: "+
|
|
"write <node>/%s — it runs on %s", prefix, rest, orNobody(m.On))
|
|
}
|
|
return target{Address: rest, Key: rest, Name: rest, Tool: *tool}, nil
|
|
}
|
|
if len(m.On) > 0 && !contains(m.On, node) {
|
|
if us := x.unheardOn(node); len(us) > 0 || contains(x.Recorded[prefix], node) {
|
|
why := "it did not announce itself there"
|
|
if len(us) > 0 {
|
|
why = "discovery did not hear in full from " + sayUnheard(us)
|
|
}
|
|
return target{}, fmt.Errorf("%s on %s did not answer discovery: %s. It was heard on %s. "+
|
|
"Ask again in a moment; mesh_runtimes shows which runtimes answered", prefix, node, why, orNobody(m.On))
|
|
}
|
|
return target{}, fmt.Errorf("%s does not run on %s; it runs on %s", prefix, node, orNobody(m.On))
|
|
}
|
|
return target{Address: node + "/" + rest, Key: rest + "@" + node, Name: rest, Node: node, Tool: *tool}, nil
|
|
}
|
|
|
|
// unheardOnAny is what is unheard on any of these machines.
|
|
func (x *index) unheardOnAny(nodes []string) []unheard {
|
|
var out []unheard
|
|
for _, u := range x.Unheard {
|
|
if u.Machine == "" || contains(nodes, u.Machine) {
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func contains(xs []string, s string) bool {
|
|
for _, x := range xs {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func toolNames(ts []Tool) string {
|
|
names := make([]string, 0, len(ts))
|
|
for _, t := range ts {
|
|
names = append(names, t.Name)
|
|
}
|
|
sort.Strings(names)
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
func nodesOf(hs []holder) []string {
|
|
var out []string
|
|
for _, h := range hs {
|
|
if h.Node != "" && !contains(out, h.Node) {
|
|
out = append(out, h.Node)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func orNobody(nodes []string) string {
|
|
if len(nodes) == 0 {
|
|
return "no machine the mesh knows of"
|
|
}
|
|
return strings.Join(nodes, ", ")
|
|
}
|
|
|
|
// firstLine is a description's first line, for a list.
|
|
func firstLine(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if i := strings.IndexAny(s, "\n"); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
if len(s) > 160 {
|
|
s = s[:157] + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// schemaAsPassed is a tool's schema as an agent passes it at this address. Where the address names
|
|
// the machine, `node` is taken out: the machine is in the address. Where it does not — a seat held
|
|
// once for the mesh, or an interchangeable module — `node` is the tool's own argument, and is kept.
|
|
//
|
|
// **Taking it out everywhere made the mesh's own verbs lie** (novox/hq issue 244): the controller's
|
|
// `push`, `plan`, `assign`, `pin` and `settings` take the machine they act on as `node`, and were
|
|
// described without it — `push` as taking nothing at all, so a push naming one machine arrived as a
|
|
// push of every machine behind.
|
|
func schemaAsPassed(t target) map[string]any {
|
|
if t.Node == "" {
|
|
return asSchema(t.Tool.Input)
|
|
}
|
|
return schemaWithoutNode(t.Tool.Input)
|
|
}
|
|
|
|
// argumentsAsSent are a call's arguments as the tool at this address receives them, or why the call
|
|
// is refused. **Nothing given is dropped without a word** (novox/hq issue 244):
|
|
// - where the address names the machine, a `node` naming the same machine is redundant and taken
|
|
// out; one naming another machine is refused — which of the two was meant is not guessed;
|
|
// - where it does not, `node` is the tool's own argument and goes to it like any other;
|
|
// - a seat's schema is the mesh's record of the verb, so an argument a seat's verb does not
|
|
// declare is refused here, naming it, rather than sent to be passed over.
|
|
func argumentsAsSent(t target, given map[string]any) (map[string]any, error) {
|
|
args := map[string]any{}
|
|
for k, v := range given {
|
|
args[k] = v
|
|
}
|
|
if n, has := args["node"]; has && t.Node != "" {
|
|
if named, _ := n.(string); strings.TrimSpace(named) != t.Node {
|
|
return nil, fmt.Errorf("%s names the machine %s in its address and %v in its arguments: say it once, in the address",
|
|
t.Address, t.Node, n)
|
|
}
|
|
delete(args, "node")
|
|
}
|
|
if !t.Seat {
|
|
return args, nil
|
|
}
|
|
declared := map[string]bool{}
|
|
var names []string
|
|
if p, ok := schemaAsPassed(t)["properties"].(map[string]any); ok {
|
|
for k := range p {
|
|
declared[k] = true
|
|
names = append(names, k)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
var strangers []string
|
|
for k := range args {
|
|
if !declared[k] {
|
|
strangers = append(strangers, k)
|
|
}
|
|
}
|
|
if len(strangers) > 0 {
|
|
sort.Strings(strangers)
|
|
takes := "nothing"
|
|
if len(names) > 0 {
|
|
takes = strings.Join(names, ", ")
|
|
}
|
|
return nil, fmt.Errorf("%s takes no argument %s — it takes %s (mesh_describe %s); nothing was sent",
|
|
t.Address, strings.Join(strangers, ", "), takes, t.Address)
|
|
}
|
|
return args, nil
|
|
}
|
|
|
|
// schemaWithoutNode is a tool's schema with `node` taken out, for an address that names the machine.
|
|
func schemaWithoutNode(raw json.RawMessage) map[string]any {
|
|
schema := asSchema(raw)
|
|
out := map[string]any{}
|
|
for k, v := range schema {
|
|
out[k] = v
|
|
}
|
|
if p, ok := schema["properties"].(map[string]any); ok {
|
|
props := map[string]any{}
|
|
for k, v := range p {
|
|
if k != "node" {
|
|
props[k] = v
|
|
}
|
|
}
|
|
out["properties"] = props
|
|
}
|
|
if r, ok := schema["required"].([]any); ok {
|
|
var keep []any
|
|
for _, k := range r {
|
|
if k != "node" {
|
|
keep = append(keep, k)
|
|
}
|
|
}
|
|
if len(keep) == 0 {
|
|
delete(out, "required")
|
|
} else {
|
|
out["required"] = keep
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// answerText is a discovery verb's answer as MCP content: JSON, indented.
|
|
func answerText(v any) map[string]any {
|
|
var b strings.Builder
|
|
enc := json.NewEncoder(&b)
|
|
enc.SetEscapeHTML(false) // `<node>/…` is read by an agent, not embedded in a page
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(v)
|
|
return map[string]any{"content": []map[string]any{{"type": "text", "text": strings.TrimRight(b.String(), "\n")}}}
|
|
}
|
|
|
|
func failure(text string) map[string]any {
|
|
return map[string]any{"content": []map[string]any{{"type": "text", "text": text}}, "isError": true}
|
|
}
|
|
|
|
// discover answers one of the discovery verbs.
|
|
func (s *Surface) discover(name string, args map[string]any) map[string]any {
|
|
if name == verbRuntimes {
|
|
return s.runtimesAnswer()
|
|
}
|
|
x, err := s.index()
|
|
if err != nil {
|
|
return failure("the mesh's discovery failed: " + err.Error())
|
|
}
|
|
str := func(k string) string { v, _ := args[k].(string); return strings.TrimSpace(v) }
|
|
|
|
switch name {
|
|
case verbOverview:
|
|
type verbLine struct {
|
|
Address string `json:"address"`
|
|
Description string `json:"description"`
|
|
}
|
|
var mesh, node []map[string]any
|
|
for _, st := range x.Seats {
|
|
var verbs []verbLine
|
|
for _, v := range st.Verbs {
|
|
addr := st.Seat + "." + v.Name
|
|
if st.Scope == "node" {
|
|
addr = "<node>/" + addr
|
|
}
|
|
verbs = append(verbs, verbLine{addr, firstLine(v.Description)})
|
|
}
|
|
entry := map[string]any{"seat": st.Seat, "verbs": verbs}
|
|
if st.Scope == "node" {
|
|
entry["held on"] = nodesOf(st.Holders)
|
|
node = append(node, entry)
|
|
} else {
|
|
if h := nodesOf(st.Holders); len(h) > 0 {
|
|
entry["held on"] = h
|
|
}
|
|
mesh = append(mesh, entry)
|
|
}
|
|
}
|
|
modules := 0
|
|
tools := 0
|
|
for _, m := range x.Modules {
|
|
modules++
|
|
tools += len(m.Tools)
|
|
}
|
|
return answerText(map[string]any{
|
|
"seats of the mesh": mesh,
|
|
"seats every machine": node,
|
|
"machines": x.Machines,
|
|
"modules with tools": fmt.Sprintf("%d modules, %d tools — mesh_machine lists a machine's, mesh_search finds one", modules, tools),
|
|
"not answering": x.NotAnswering,
|
|
})
|
|
|
|
case verbMachine:
|
|
node := str("node")
|
|
if node == "" {
|
|
return failure("mesh_machine needs `node`: one of " + orNobody(x.Machines))
|
|
}
|
|
if !contains(x.Machines, node) {
|
|
return failure(fmt.Sprintf("the mesh knows no machine %q; it has %s", node, orNobody(x.Machines)))
|
|
}
|
|
var seats []map[string]any
|
|
for _, st := range x.Seats {
|
|
if st.Scope != "node" || !contains(nodesOf(st.Holders), node) {
|
|
continue
|
|
}
|
|
var verbs []string
|
|
for _, v := range st.Verbs {
|
|
verbs = append(verbs, node+"/"+st.Seat+"."+v.Name)
|
|
}
|
|
var by string
|
|
for _, h := range st.Holders {
|
|
if h.Node == node {
|
|
by = h.Module
|
|
}
|
|
}
|
|
seats = append(seats, map[string]any{"seat": st.Seat, "held by": by, "verbs": verbs})
|
|
}
|
|
var modules []map[string]any
|
|
names := make([]string, 0, len(x.Modules))
|
|
for n := range x.Modules {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
for _, n := range names {
|
|
m := x.Modules[n]
|
|
if !contains(m.On, node) {
|
|
continue
|
|
}
|
|
var tools []map[string]string
|
|
for _, t := range m.Tools {
|
|
tools = append(tools, map[string]string{"address": node + "/" + m.Module + "." + t.Name, "does": firstLine(t.Description)})
|
|
}
|
|
entry := map[string]any{"module": m.Module, "tools": tools}
|
|
if m.Interchangeable {
|
|
entry["interchangeable"] = "any instance answers <module>.<tool> as well"
|
|
}
|
|
modules = append(modules, entry)
|
|
}
|
|
out := map[string]any{"machine": node, "seats": seats, "modules": modules}
|
|
if seats == nil {
|
|
out["seats"] = []map[string]any{}
|
|
}
|
|
if modules == nil {
|
|
out["modules"] = []map[string]any{}
|
|
}
|
|
if us := x.unheardOn(node); len(us) > 0 {
|
|
out["incomplete"] = "discovery did not hear in full from " + sayUnheard(us) +
|
|
"; what is listed may be missing what it serves. Ask again in a moment; mesh_runtimes shows which runtimes answered"
|
|
}
|
|
return answerText(out)
|
|
|
|
case verbSearch:
|
|
query := strings.ToLower(str("query"))
|
|
if query == "" {
|
|
return failure("mesh_search needs `query`: words to find, e.g. `postgres databases`")
|
|
}
|
|
words := strings.Fields(query)
|
|
type hit struct {
|
|
Address string `json:"address"`
|
|
Does string `json:"does"`
|
|
Also string `json:"also,omitempty"`
|
|
}
|
|
var hits []hit
|
|
matches := func(parts ...string) bool {
|
|
hay := strings.ToLower(strings.Join(parts, " "))
|
|
for _, w := range words {
|
|
if !strings.Contains(hay, w) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
for _, st := range x.Seats {
|
|
for _, v := range st.Verbs {
|
|
if !matches(st.Seat, v.Name, v.Description) {
|
|
continue
|
|
}
|
|
if st.Scope == "node" {
|
|
on := nodesOf(st.Holders)
|
|
first := "<node>"
|
|
also := ""
|
|
if len(on) > 0 {
|
|
first = on[0]
|
|
if len(on) > 1 {
|
|
also = "also on " + strings.Join(on[1:], ", ")
|
|
}
|
|
}
|
|
hits = append(hits, hit{first + "/" + st.Seat + "." + v.Name, firstLine(v.Description), also})
|
|
} else {
|
|
hits = append(hits, hit{st.Seat + "." + v.Name, firstLine(v.Description), ""})
|
|
}
|
|
}
|
|
}
|
|
names := make([]string, 0, len(x.Modules))
|
|
for n := range x.Modules {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
for _, n := range names {
|
|
m := x.Modules[n]
|
|
for _, t := range m.Tools {
|
|
if !matches(m.Module, t.Name, t.Description) {
|
|
continue
|
|
}
|
|
switch {
|
|
case m.Interchangeable:
|
|
hits = append(hits, hit{m.Module + "." + t.Name, firstLine(t.Description), "any instance; on " + orNobody(m.On)})
|
|
case len(m.On) == 0:
|
|
hits = append(hits, hit{"<node>/" + m.Module + "." + t.Name, firstLine(t.Description), "the mesh places it on no machine"})
|
|
default:
|
|
also := ""
|
|
if len(m.On) > 1 {
|
|
also = "also on " + strings.Join(m.On[1:], ", ")
|
|
}
|
|
hits = append(hits, hit{m.On[0] + "/" + m.Module + "." + t.Name, firstLine(t.Description), also})
|
|
}
|
|
}
|
|
}
|
|
out := map[string]any{"matches": hits}
|
|
if len(hits) > searchCap {
|
|
out["matches"] = hits[:searchCap]
|
|
out["more"] = fmt.Sprintf("%d more; narrow the words", len(hits)-searchCap)
|
|
}
|
|
if len(hits) == 0 {
|
|
out["matches"] = []hit{}
|
|
out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse"
|
|
}
|
|
if len(x.Unheard) > 0 {
|
|
out["incomplete"] = "discovery did not hear in full from " + sayUnheard(x.Unheard) +
|
|
"; what they serve is not searched. mesh_runtimes shows which runtimes answered"
|
|
}
|
|
return answerText(out)
|
|
|
|
case verbDescribe:
|
|
t, err := resolve(x, str("address"))
|
|
if err != nil {
|
|
return failure(err.Error())
|
|
}
|
|
if !t.Seat && len(t.Tool.Input) == 0 {
|
|
// Discovery carries a seat's schema, not a module tool's: asked of the runtime serving it.
|
|
t.Tool.Input = inputOf(s.conn, t.Tool.Module, t.Tool.Name)
|
|
}
|
|
description := t.Tool.Description
|
|
if description == "" {
|
|
description = t.Name
|
|
}
|
|
return answerText(map[string]any{"address": t.Address, "description": description,
|
|
"arguments": schemaAsPassed(t)})
|
|
|
|
case verbCall:
|
|
t, err := resolve(x, str("address"))
|
|
if err != nil {
|
|
return failure(err.Error())
|
|
}
|
|
given, _ := args["arguments"].(map[string]any)
|
|
callArgs, err := argumentsAsSent(t, given)
|
|
if err != nil {
|
|
return failure(err.Error())
|
|
}
|
|
var got bus.Answered
|
|
if t.Seat {
|
|
got, err = s.conn.Ask(t.Key, callArgs, "")
|
|
} else {
|
|
got, err = callTool(s.conn, t.Key, callArgs, nil, x.Listing)
|
|
}
|
|
if err != nil {
|
|
return failure(whyItFailed(t.Address, err))
|
|
}
|
|
content := []map[string]any{{"type": "text", "text": pretty(got.Result)}}
|
|
if got.Node != "" {
|
|
content = append(content, map[string]any{"type": "text", "text": "answered by " + got.Node})
|
|
}
|
|
return map[string]any{"content": content}
|
|
}
|
|
return failure("no discovery verb " + name)
|
|
}
|
|
|
|
// inputOf is a module tool's input schema, asked of a runtime that serves the module — its `tools` verb,
|
|
// answered on the module's plain subject by any instance — because discovery no longer carries it: every
|
|
// tool's schema made a machine's one discovery answer outgrow the bus (2026-10-04). Nil when nothing
|
|
// answers, which describes the tool as taking nothing rather than failing the description.
|
|
func inputOf(conn *bus.Conn, module, tool string) json.RawMessage {
|
|
got, err := conn.Ask(module+".tools", map[string]any{}, "")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var answer struct {
|
|
Tools []struct {
|
|
Name string `json:"name"`
|
|
Input json.RawMessage `json:"input"`
|
|
} `json:"tools"`
|
|
}
|
|
if json.Unmarshal(got.Result, &answer) != nil {
|
|
return nil
|
|
}
|
|
for _, t := range answer.Tools {
|
|
if t.Name == tool {
|
|
return t.Input
|
|
}
|
|
}
|
|
return nil
|
|
}
|