The mesh MCP server gains mesh_approvals: with no argument the calls asked on the machine in the last day, newest first, with state and fingerprint, the marks the gate applies with their sources and the catalog revision, and the state's fill against its cap (every machine's keys together); an ask read whole with the act's digest; record asking the hand-act again, and cancel taking an open ask back, each recorded in the hand-act log first and refused when it cannot be. mesh_describe answers needs-approval with its source, and a marked call that reached no one names withheld and mesh-grants.grants_user_list. Builds: design 46 7-approval-mark §18, §6 (the mesh MCP server's withheld answer), §22 (eight tools) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
1192 lines
44 KiB
Go
1192 lines
44 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 seven tools — five to find and call, mesh_runtimes to say which runtimes
|
|
// discovery heard, and mesh_wait to wait on the bus for an event (novox/hq ADR 0367). 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 (
|
|
assigner "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
|
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/announce"
|
|
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
|
"github.com/novox/mesh-tools/node-tools/internal/firstview"
|
|
)
|
|
|
|
// 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 seven 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"
|
|
verbFirstViews = "mesh_first_views"
|
|
verbFirstAgain = "mesh_first_view_again"
|
|
verbWait = "mesh_wait"
|
|
)
|
|
|
|
// 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-module-assigner.assignments`); " +
|
|
"`<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 seven 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` — or the shell command you would have run, e.g. `journalctl -u x`, `docker ps`")}, "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, best first. A shell command finds the tool " +
|
|
"that replaces it — search before reaching for ssh, journalctl, systemctl or docker. " + 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."},
|
|
{"name": verbFirstViews, "inputSchema": obj(map[string]any{}),
|
|
"description": "The tool runner's first views: the assigner's assignments and seat-holders, and the catalog half's registrations, the gate's source and the view of each watched branch (which modules exist, each with its manifest): for each, the owner, the tool " +
|
|
"asked, the revision held and when it was made, how many keys, when it was asked and answered, how often " +
|
|
"it was asked and how often by hand, whether its condition is raised, and why it was last asked. Read-only."},
|
|
{"name": verbFirstAgain, "inputSchema": obj(map[string]any{
|
|
"why": map[string]any{"type": "string", "description": "why it is asked again, kept in the log"},
|
|
}), "description": "Ask the tool runner's four first views again, now, and say what each answers. A hand act, recorded in " +
|
|
"the runner's log with its reason; for when a picture is doubted, or after an owner's state was made again."},
|
|
waitTool(),
|
|
approvalsTool(),
|
|
}
|
|
}
|
|
|
|
func isDiscovery(name string) bool {
|
|
switch name {
|
|
case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall, verbRuntimes, verbFirstViews, verbFirstAgain, verbWait, verbApprovals:
|
|
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 assigner places 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
|
|
// Placed is the state seat-holders as the seat mesh-module-assigner's holder wrote it (novox/hq ADR 0571 clause 4), by its
|
|
// keys `<seat>` and `<seat>.<node>`, read with assigner.HoldersOf; PlacedWhy says why it could not be read.
|
|
Placed map[string][]byte
|
|
PlacedWhy string
|
|
// Replaces is what each verb and tool replaces (novox/hq ADR 0245):
|
|
// `seat:<seat>.<verb>` and `<module>.<tool>`.
|
|
Replaces map[string][]string
|
|
// Earlier is every runtime not heard this round whose last answer, within RuntimeForgotten, this
|
|
// index holds instead (novox/hq issue 554), by runtimeKey: when it was heard.
|
|
Earlier map[string]time.Time
|
|
// Stale is every module on a machine (`mod:<module>@<node>`) and every seat's holder
|
|
// (`seat:<seat>@<node>`) this index knows only from an earlier answer: from whom, and when.
|
|
Stale map[string]staleFrom
|
|
}
|
|
|
|
// staleFrom is where an address known only from an earlier round of discovery was heard.
|
|
type staleFrom struct {
|
|
Runtime string
|
|
Machine string
|
|
At time.Time
|
|
}
|
|
|
|
// say is the sentence a call or a description carries for it.
|
|
func (f staleFrom) say() string {
|
|
who := f.Runtime
|
|
if f.Machine != "" {
|
|
who += " on " + f.Machine
|
|
}
|
|
return fmt.Sprintf("found from what %s announced %s ago: it did not answer the latest round of discovery",
|
|
who, time.Since(f.At).Round(time.Second))
|
|
}
|
|
|
|
// earlier is a runtime's last answer, used for a round that did not hear it.
|
|
type earlier struct {
|
|
Heard announce.Heard
|
|
At time.Time
|
|
}
|
|
|
|
func endpointKey(e announce.Endpoint) string {
|
|
if e.Kind == announce.KindSeat {
|
|
return "seat:" + e.Seat + "@" + e.Node
|
|
}
|
|
return "mod:" + e.Module + "@" + e.Node
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// recordedModule is a module as the bus holds it: where the assigner seat places it, and whether its manifest declares
|
|
// tools — what should announce itself, and where (novox/hq ADR 0571).
|
|
type recordedModule struct {
|
|
Module string `json:"module"`
|
|
On []string `json:"on"`
|
|
Tools bool `json:"tools"`
|
|
// Replaces is what each of its own tools replaces (novox/hq ADR 0245).
|
|
Replaces map[string][]string `json:"replaces,omitempty"`
|
|
}
|
|
|
|
// 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 indexer's records
|
|
// read as JSON. Nothing is inferred from a roster and nothing is parsed from print.
|
|
//
|
|
// `held` is what the console holds already, so an instance whose announcement is unchanged is not
|
|
// asked for it again; `before` is the last answer of every runtime this round did not hear, which the
|
|
// index holds instead, marked with its age (novox/hq issue 554).
|
|
func indexOn(conn *bus.Conn, src Source, held announce.Known, before func(announce.Discovery) []earlier) (*index, error) {
|
|
var wg sync.WaitGroup
|
|
var placed map[string][]byte
|
|
var placedErr error
|
|
var recorded []recordedModule
|
|
var known []string
|
|
var recordsWhy []string
|
|
wg.Add(2)
|
|
// Which modules there are and where they are placed, and which machines the mesh has, from the bus (ADR 0571
|
|
// clauses 4 and 5): the indexer's modules and manifests and the assigner seat's assignments, never any other record.
|
|
go func() { defer wg.Done(); recorded, known, recordsWhy = recordsOn(src) }()
|
|
// Who holds which seat on which node, from the assigner (ADR 0571 clause 4): no other call.
|
|
go func() { defer wg.Done(); placed, placedErr = seatHoldersOn(src) }()
|
|
d, err := announce.Gather(conn, held)
|
|
wg.Wait()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var endpoints []announce.Endpoint
|
|
fresh := map[string]bool{}
|
|
for _, h := range d.Heard {
|
|
for _, e := range announce.Endpoints(h.Info) {
|
|
fresh[endpointKey(e)] = true
|
|
endpoints = append(endpoints, e)
|
|
}
|
|
}
|
|
// **A runtime not heard this round is not gone** (novox/hq issue 554): a round in which no answer
|
|
// crossed the laptop's link in time replaced a full index with an empty one, and every seat and
|
|
// module call was refused. What it announced last, within RuntimeForgotten, stands — said with its age.
|
|
stale := map[string]staleFrom{}
|
|
earlierAt := map[string]time.Time{}
|
|
if before != nil {
|
|
for _, b := range before(d) {
|
|
earlierAt[runtimeKey(b.Heard.Info.Name, b.Heard.Info.ID)] = b.At
|
|
for _, e := range announce.Endpoints(b.Heard.Info) {
|
|
k := endpointKey(e)
|
|
if fresh[k] {
|
|
continue
|
|
}
|
|
if _, have := stale[k]; !have {
|
|
stale[k] = staleFrom{Runtime: b.Heard.Info.Name, Machine: b.Heard.Machine(), At: b.At}
|
|
}
|
|
endpoints = append(endpoints, e)
|
|
}
|
|
}
|
|
}
|
|
x, announced, machines := indexOfEndpoints(endpoints)
|
|
l := x.Listing
|
|
x.Discovery = d
|
|
x.Stale, x.Earlier = stale, earlierAt
|
|
x.Placed = placed
|
|
if placedErr != nil {
|
|
x.PlacedWhy = placedErr.Error()
|
|
}
|
|
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.
|
|
x.Replaces = replacesOf(catalogue.Seats(), recorded)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
l.NotAnswering = append(l.NotAnswering, recordsWhy...)
|
|
if x.PlacedWhy != "" {
|
|
l.NotAnswering = append(l.NotAnswering, firstview.SeatHoldersRead+" (who holds which seat by assignment could not be read: "+
|
|
x.PlacedWhy+")")
|
|
}
|
|
for _, u := range x.Unheard {
|
|
l.NotAnswering = append(l.NotAnswering, "the runtime "+sayUnheard([]unheard{u}))
|
|
}
|
|
sort.Strings(l.NotAnswering)
|
|
x.NotAnswering = l.NotAnswering
|
|
|
|
for _, n := range known {
|
|
machines[n] = true
|
|
}
|
|
for n := range machines {
|
|
x.Machines = append(x.Machines, n)
|
|
}
|
|
sort.Strings(x.Machines)
|
|
return x, nil
|
|
}
|
|
|
|
// Source is where the console reads what it indexes the mesh by: the assigner's assignments and seat-holders, by
|
|
// `<owner>.<state>` (firstview.AssignmentsRead and SeatHoldersRead), and the modules of the catalog half with their entries.
|
|
// The tool runner's is its first views (firstview.Views), never the owners' state (novox/hq ADR 0615 §1; design 47 G §196, §594).
|
|
type Source interface {
|
|
// State is every current value of a state by key; an error says why it cannot be told yet.
|
|
State(name string) (map[string][]byte, error)
|
|
// Modules is every module the catalog half holds, by name, with its entry; an error says why it cannot be told yet.
|
|
Modules() (map[string]meshcatalog.CatalogEntry, error)
|
|
}
|
|
|
|
// seatHoldersOn reads the state seat-holders whole (novox/hq ADR 0571 clause 4), as firstview.SeatHoldersRead.
|
|
func seatHoldersOn(src Source) (map[string][]byte, error) {
|
|
return src.State(firstview.SeatHoldersRead)
|
|
}
|
|
|
|
// manifestFacts is what the console takes of a manifest: the tools it declares and what each replaces.
|
|
type manifestFacts struct {
|
|
Tools []string `json:"tools"`
|
|
Replaces map[string][]string `json:"replaces"`
|
|
}
|
|
|
|
// recordsOn is every module the indexer holds, with where the assigner seat places it and whether it declares tools,
|
|
// and every machine the assignments name (moduleNames, assigner.MachineNames). What cannot be read is said, one line
|
|
// per state, for the listing's not answering.
|
|
func recordsOn(src Source) (recorded []recordedModule, machines []string, why []string) {
|
|
held, err := src.State(firstview.AssignmentsRead)
|
|
if err != nil {
|
|
why = append(why, firstview.AssignmentsRead+" (where modules are placed could not be read, so what is missing cannot "+
|
|
"be said: "+err.Error()+")")
|
|
}
|
|
machines = assigner.MachineNames(held)
|
|
on := map[string][]string{}
|
|
// The assignments' keys in either form, a node key and its module keys or the form before (research 072 row 37).
|
|
assembled, err := assigner.AssignmentsOf(held)
|
|
if err != nil {
|
|
why = append(why, firstview.AssignmentsRead+" (not every key is an assignment: "+err.Error()+")")
|
|
}
|
|
for _, node := range machines {
|
|
for _, m := range assembled[node].Modules {
|
|
on[m.Module] = append(on[m.Module], node)
|
|
}
|
|
}
|
|
entries, err := src.Modules()
|
|
if err != nil {
|
|
return nil, machines, append(why, "the catalog half's modules (which modules there are could not be read: "+err.Error()+")")
|
|
}
|
|
names := make([]string, 0, len(entries))
|
|
for name := range entries {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
r := recordedModule{Module: name, On: on[name]}
|
|
if m := entries[name].Manifest; len(m) > 0 {
|
|
facts, err := manifestOf(m)
|
|
if err != nil {
|
|
why = append(why, name+" (its manifest could not be read, so whether it answers tools is not known: "+err.Error()+")")
|
|
} else {
|
|
r.Tools, r.Replaces = len(facts.Tools) > 0, facts.Replaces
|
|
}
|
|
}
|
|
recorded = append(recorded, r)
|
|
}
|
|
return recorded, machines, why
|
|
}
|
|
|
|
// manifestOf is what the console takes of the manifest an entry of the catalog half carries whole (design 47 G §443).
|
|
func manifestOf(manifest map[string]any) (manifestFacts, error) {
|
|
raw, err := json.Marshal(manifest)
|
|
if err != nil {
|
|
return manifestFacts{}, err
|
|
}
|
|
var facts manifestFacts
|
|
if err := json.Unmarshal(raw, &facts); err != nil {
|
|
return manifestFacts{}, err
|
|
}
|
|
return facts, nil
|
|
}
|
|
|
|
// placedHolders is who holds seat (on node, for a seat held per machine) by assignment, or none.
|
|
func (x *index) placedHolders(seat, node string) []meshmoduleassigner.SeatHoldersHoldersItem {
|
|
if len(x.Placed) == 0 {
|
|
return nil
|
|
}
|
|
hs, err := assigner.HoldersOf(x.Placed, seat, node)
|
|
if err != nil || len(hs) > 0 || node != "" {
|
|
return hs
|
|
}
|
|
// A seat held per machine, asked without one: every node's key of it.
|
|
var all []meshmoduleassigner.SeatHoldersHoldersItem
|
|
for key := range x.Placed {
|
|
if s, n, ok := strings.Cut(key, "."); ok && s == seat {
|
|
if more, err := assigner.HoldersOf(x.Placed, seat, n); err == nil {
|
|
all = append(all, more...)
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(all, func(i, j int) bool { return all[i].Node+"/"+all[i].Module < all[j].Node+"/"+all[j].Module })
|
|
return all
|
|
}
|
|
|
|
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.
|
|
//
|
|
// **A round that heard nothing is not kept** (novox/hq issue 554): the index it gives holds what every
|
|
// runtime announced last, marked with its age, and the next question asks again rather than living
|
|
// with it for IndexKept.
|
|
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, s.src, s.held, s.before)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
s.remember(x)
|
|
s.sayRound(x)
|
|
s.idx = x
|
|
if !x.Discovery.Empty() {
|
|
s.idxAt = 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 287).
|
|
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
|
|
// Stale is set when the target is known only from an earlier round of discovery (novox/hq issue 554).
|
|
Stale *staleFrom
|
|
}
|
|
|
|
// staleOf is where a target was heard, when every place it answers is known only from an earlier round.
|
|
func (x *index) staleOf(keys []string) *staleFrom {
|
|
if len(keys) == 0 || len(x.Stale) == 0 {
|
|
return nil
|
|
}
|
|
var first *staleFrom
|
|
for _, k := range keys {
|
|
f, ok := x.Stale[k]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if first == nil {
|
|
first = &f
|
|
}
|
|
}
|
|
return first
|
|
}
|
|
|
|
// 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 287): 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,
|
|
Stale: x.staleOf([]string{"seat:" + prefix + "@" + node})}, nil
|
|
}
|
|
if node != "" {
|
|
return target{}, fmt.Errorf("%s is held once for the whole mesh: write %s, without a machine", prefix, rest)
|
|
}
|
|
var holders []string
|
|
for _, h := range s.Holders {
|
|
holders = append(holders, "seat:"+prefix+"@"+h.Node)
|
|
}
|
|
return target{Address: rest, Key: "seat:" + rest, Name: rest, Tool: *verb, Seat: true, Stale: x.staleOf(holders)}, 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 hs := x.placedHolders(prefix, node); len(hs) > 0 {
|
|
var held []string
|
|
for _, h := range hs {
|
|
held = append(held, h.Module+" on "+h.Node)
|
|
}
|
|
return target{}, fmt.Errorf("the seat %s is held by %s by assignment, but no holder of it announced itself on the bus. "+
|
|
"Ask again in a moment; mesh_runtimes shows which runtimes answered", prefix, strings.Join(held, ", "))
|
|
}
|
|
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)
|
|
}
|
|
if x.Discovery.Empty() {
|
|
return target{}, fmt.Errorf("nothing answered discovery at all within %s, not even PING, and this console holds no "+
|
|
"earlier answer: whether anything is called %s is not known. Ask again in a moment; mesh_runtimes shows which "+
|
|
"runtimes answered", x.Discovery.Waited.Round(time.Millisecond), 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))
|
|
}
|
|
var on []string
|
|
for _, n := range m.On {
|
|
on = append(on, "mod:"+prefix+"@"+n)
|
|
}
|
|
return target{Address: rest, Key: rest, Name: rest, Tool: *tool, Stale: x.staleOf(on)}, 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,
|
|
Stale: x.staleOf([]string{"mod:" + prefix + "@" + node})}, 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 old controller verbs
|
|
// `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
|
|
}
|
|
t, held := seatDefinitionHeld(t)
|
|
if !held {
|
|
// Nothing here says what the verb takes: its holder judges the arguments (issue 1966).
|
|
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
|
|
}
|
|
|
|
// seatDefinitionHeld is a seat verb's target with the input the console judges a call by, and whether anything here
|
|
// says what the verb takes (issue 1966): the schema its holder announced when it declares an argument, else the verb's
|
|
// input in the seat's definition this runner holds. Neither holds while the runner waits for the seat list holding
|
|
// only the bootstrapped definitions and the holder announced an empty schema: then a call's arguments are passed on to
|
|
// the holder, which judges its own input, and mesh_describe says the definition is not held.
|
|
func seatDefinitionHeld(t target) (target, bool) {
|
|
if p, ok := asSchema(t.Tool.Input)["properties"].(map[string]any); ok && len(p) > 0 {
|
|
return t, true
|
|
}
|
|
seat, ok := catalogue.SeatNamed(t.Tool.Module)
|
|
if !ok {
|
|
return t, false
|
|
}
|
|
for _, v := range seat.Serves {
|
|
if v.Name == t.Tool.Name && v.Input != nil {
|
|
if raw, err := json.Marshal(v.Input); err == nil {
|
|
t.Tool.Input = raw
|
|
}
|
|
}
|
|
}
|
|
return t, true
|
|
}
|
|
|
|
// notHeld is what mesh_describe says of a seat verb whose definition this runner does not hold.
|
|
func notHeld(seat string) string {
|
|
return "the seat list this tool runner holds has no definition of " + seat + " yet (it holds the bootstrapped ones " +
|
|
"until it reads the list): what the verb takes is not known here, so the arguments of a call are passed to " +
|
|
"its holder, which judges them"
|
|
}
|
|
|
|
// 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(ctx context.Context, name string, args map[string]any) map[string]any {
|
|
if name == verbRuntimes {
|
|
return s.runtimesAnswer()
|
|
}
|
|
if name == verbFirstViews || name == verbFirstAgain {
|
|
return s.firstViewsAnswer(ctx, name, args)
|
|
}
|
|
if name == verbWait {
|
|
return s.waitAnswer(ctx, args)
|
|
}
|
|
if name == verbApprovals {
|
|
return s.approvalsAnswer(args)
|
|
}
|
|
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`, or the command you would " +
|
|
"have run, e.g. `journalctl` or `docker ps`")
|
|
}
|
|
hits := x.search(query)
|
|
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 no tool does it, one is created in the module that owns it, on its seat — never worked around (novox/hq ADR 0245)"
|
|
}
|
|
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
|
|
}
|
|
held := true
|
|
if t.Seat {
|
|
t, held = seatDefinitionHeld(t)
|
|
}
|
|
described := map[string]any{"address": t.Address, "description": description, "arguments": schemaAsPassed(t)}
|
|
if !held {
|
|
described["arguments"] = notHeld(t.Tool.Module)
|
|
}
|
|
key := t.Tool.Module + "." + t.Tool.Name
|
|
if t.Seat {
|
|
key = "seat:" + key
|
|
}
|
|
if r := x.Replaces[key]; len(r) > 0 {
|
|
described["instead_of"] = r
|
|
}
|
|
if t.Stale != nil {
|
|
described["found"] = t.Stale.say()
|
|
}
|
|
if ok, source := s.markedTool(t); ok {
|
|
described["needs-approval"] = map[string]any{"source": source,
|
|
"says": "the first call asks the operator and runs nothing; the same call again within 30 minutes of the Approve runs once"}
|
|
}
|
|
return answerText(described)
|
|
|
|
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 {
|
|
why := whyItFailed(t.Address, err)
|
|
if ok, _ := s.markedTool(t); ok && errors.Is(err, bus.ErrNoResponders) {
|
|
// A marked subject withheld from a machine whose tool runner does not honour the mark (§6).
|
|
why += "; it needs the operator's approval, so it may be withheld where the machine's tool runner does not state " +
|
|
"approval-mark/1: mesh-grants.grants_user_list names it under withheld"
|
|
}
|
|
if t.Stale != nil {
|
|
why += " (" + t.Stale.say() + ")"
|
|
}
|
|
return failure(why)
|
|
}
|
|
result, unread := resolveSent(s.conn, got.Result)
|
|
content := []map[string]any{{"type": "text", "text": pretty(result)}}
|
|
if unread != "" {
|
|
content = append(content, map[string]any{"type": "text", "text": unread})
|
|
}
|
|
if got.Node != "" {
|
|
content = append(content, map[string]any{"type": "text", "text": "answered by " + got.Node})
|
|
}
|
|
if t.Stale != nil {
|
|
content = append(content, map[string]any{"type": "text", "text": t.Stale.say()})
|
|
}
|
|
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
|
|
}
|