The Go runtime answers $SRV.PING, $SRV.INFO and $SRV.STATS (and per name and id) in the io.nats.micro.v1 format with what it serves at the moment it is asked: one service per runtime process, since the bus admits one reply per request from each responder, and one endpoint per tool per subject, its metadata saying module, seat, scope, machine, description, schema and whether the module is interchangeable. Serving is unchanged. The console gathers one $SRV.INFO request's answers instead of asking the catalogue's roster and each module's tools, and reads the controller's records as JSON for what should have answered: an assignment with tools that did not announce is named, a module without tools never is. The text parsers of node list and module list are gone. Packages share the test bus: go test -p 1.
729 lines
23 KiB
Go
729 lines
23 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 five tools. 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 five 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"
|
|
)
|
|
|
|
// 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 five 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},
|
|
}
|
|
}
|
|
|
|
func isDiscovery(name string) bool {
|
|
switch name {
|
|
case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall:
|
|
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
|
|
}
|
|
|
|
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") }()
|
|
infos, err := announce.Gather(conn)
|
|
wg.Wait()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
l := &Listing{Tools: []Tool{}, NotAnswering: []string{}}
|
|
x := &index{Modules: map[string]*moduleInfo{}, Listing: l}
|
|
announced := map[string]map[string]bool{} // module → node → announced something
|
|
seats := map[string]*seatInfo{}
|
|
toolAt := map[string]int{} // <module>.<tool> or <seat>.<verb> → index in l.Tools
|
|
machines := map[string]bool{}
|
|
for _, info := range infos {
|
|
for _, e := range announce.Endpoints(info) {
|
|
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 := 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
|
|
})
|
|
|
|
// 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
|
|
}
|
|
for _, n := range m.On {
|
|
if !announced[m.Module][n] {
|
|
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)")
|
|
}
|
|
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) {
|
|
s.mu.Lock()
|
|
if 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.idx, s.idxAt = x, time.Now()
|
|
s.mu.Unlock()
|
|
return x, nil
|
|
}
|
|
|
|
// 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:]
|
|
|
|
if s := x.seat(prefix); s != nil {
|
|
verb := findTool(s.Verbs, name)
|
|
if verb == nil {
|
|
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 {
|
|
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) {
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// schemaWithoutNode is a tool's schema as an agent passes it: the machine is in the address.
|
|
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 five.
|
|
func (s *Surface) discover(name string, args map[string]any) map[string]any {
|
|
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)
|
|
}
|
|
return answerText(map[string]any{"machine": node, "seats": seats, "modules": modules})
|
|
|
|
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"
|
|
}
|
|
return answerText(out)
|
|
|
|
case verbDescribe:
|
|
t, err := resolve(x, str("address"))
|
|
if err != nil {
|
|
return failure(err.Error())
|
|
}
|
|
description := t.Tool.Description
|
|
if description == "" {
|
|
description = t.Name
|
|
}
|
|
return answerText(map[string]any{"address": t.Address, "description": description,
|
|
"arguments": schemaWithoutNode(t.Tool.Input)})
|
|
|
|
case verbCall:
|
|
t, err := resolve(x, str("address"))
|
|
if err != nil {
|
|
return failure(err.Error())
|
|
}
|
|
callArgs := map[string]any{}
|
|
if a, ok := args["arguments"].(map[string]any); ok {
|
|
for k, v := range a {
|
|
if k != "node" {
|
|
callArgs[k] = v
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
}
|