The runtime knows no language, so nothing ties it to Node.js. This ports its serve mode — the pinned bus connection and patient connect, following memberships, launching every served bundle over MCP on stdio with its own environment, a child's emit published as its module, each tool, the tools verb and seat verbs served where the mesh issued them, and the console on loopback — to one static binary. Same subjects, request and reply bodies, event headers and MCP answers. The TypeScript stays: it is still the runtime inside the per-module containers until WP4c. Tests run against a real bus and share the TypeScript fixtures.
241 lines
6.5 KiB
Go
241 lines
6.5 KiB
Go
package console
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
|
)
|
|
|
|
const (
|
|
catalogueModules = "mesh-catalog.catalog_modules"
|
|
seatTools = "seat:mesh-controller.tools"
|
|
toolsVerb = "tools"
|
|
)
|
|
|
|
// Tool is a tool as its module — or, for a role's tool, the mesh's records — describes it.
|
|
type Tool struct {
|
|
Module string
|
|
Name string
|
|
Description string
|
|
Input json.RawMessage
|
|
Seat bool
|
|
Scope string
|
|
Subjects []string
|
|
}
|
|
|
|
// Listing is what the mesh could say about its tools; silence is named, never dropped (design 34 §3).
|
|
type Listing struct {
|
|
Tools []Tool
|
|
NotAnswering []string
|
|
}
|
|
|
|
// Seats is the seats and their verbs, so `<seat>.<verb>` resolves to the role.
|
|
type Seats map[string]map[string]bool
|
|
|
|
func seatsIn(l *Listing) Seats {
|
|
out := Seats{}
|
|
for _, t := range l.Tools {
|
|
if !t.Seat {
|
|
continue
|
|
}
|
|
if out[t.Module] == nil {
|
|
out[t.Module] = map[string]bool{}
|
|
}
|
|
out[t.Module][t.Name] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
// toolKey is the key a call uses: a role's when the prefix is a seat declaring that verb.
|
|
func toolKey(name string, seats Seats) string {
|
|
if strings.HasPrefix(name, "seat:") {
|
|
return name
|
|
}
|
|
dot := strings.Index(name, ".")
|
|
if dot < 0 {
|
|
return name
|
|
}
|
|
if seats[name[:dot]][name[dot+1:]] {
|
|
return "seat:" + name
|
|
}
|
|
return name
|
|
}
|
|
|
|
// toolsOn asks the mesh what tools it has: the catalogue which modules it holds, each module what it
|
|
// serves, the controller's seat every role's tools — at once, so a restarting control plane hides
|
|
// nothing else.
|
|
func toolsOn(conn *bus.Conn) (*Listing, error) {
|
|
type rolesAnswer struct {
|
|
Seats []struct {
|
|
Seat string `json:"seat"`
|
|
Scope string `json:"scope"`
|
|
Tools []struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Input json.RawMessage `json:"input"`
|
|
} `json:"tools"`
|
|
} `json:"seats"`
|
|
}
|
|
var wg sync.WaitGroup
|
|
var roles *rolesAnswer
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if got, err := conn.Ask(seatTools, map[string]any{}, ""); err == nil {
|
|
var r rolesAnswer
|
|
if json.Unmarshal(got.Result, &r) == nil {
|
|
roles = &r
|
|
}
|
|
}
|
|
}()
|
|
answered, err := conn.Ask(catalogueModules, map[string]any{}, "")
|
|
if err != nil {
|
|
wg.Wait()
|
|
return nil, err
|
|
}
|
|
var held struct {
|
|
Modules []struct {
|
|
Module string `json:"module"`
|
|
} `json:"modules"`
|
|
}
|
|
_ = json.Unmarshal(answered.Result, &held)
|
|
names := make([]string, 0, len(held.Modules))
|
|
for _, m := range held.Modules {
|
|
if m.Module != "" {
|
|
names = append(names, m.Module)
|
|
}
|
|
}
|
|
type outcome struct {
|
|
ok bool
|
|
answer struct {
|
|
Tools *[]struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Input json.RawMessage `json:"input"`
|
|
Subjects []string `json:"subjects"`
|
|
} `json:"tools"`
|
|
Failed *string `json:"failed"`
|
|
}
|
|
}
|
|
outcomes := make([]outcome, len(names))
|
|
for i, module := range names {
|
|
wg.Add(1)
|
|
go func(i int, module string) {
|
|
defer wg.Done()
|
|
got, err := conn.Ask(module+"."+toolsVerb, map[string]any{}, "")
|
|
if err != nil {
|
|
return
|
|
}
|
|
if json.Unmarshal(got.Result, &outcomes[i].answer) == nil {
|
|
outcomes[i].ok = true
|
|
}
|
|
}(i, module)
|
|
}
|
|
wg.Wait()
|
|
l := &Listing{Tools: []Tool{}, NotAnswering: []string{}}
|
|
if roles != nil {
|
|
for _, s := range roles.Seats {
|
|
for _, t := range s.Tools {
|
|
l.Tools = append(l.Tools, Tool{Module: s.Seat, Name: t.Name, Description: t.Description,
|
|
Input: t.Input, Seat: true, Scope: s.Scope})
|
|
}
|
|
}
|
|
} else {
|
|
l.NotAnswering = append(l.NotAnswering, "mesh-controller (seat)")
|
|
}
|
|
for i, module := range names {
|
|
o := outcomes[i]
|
|
switch {
|
|
case o.ok && o.answer.Failed != nil:
|
|
l.NotAnswering = append(l.NotAnswering, fmt.Sprintf("%s (its tools bundle failed to load: %s)", module, *o.answer.Failed))
|
|
case o.ok && o.answer.Tools != nil:
|
|
for _, t := range *o.answer.Tools {
|
|
l.Tools = append(l.Tools, Tool{Module: module, Name: t.Name, Description: t.Description,
|
|
Input: t.Input, Subjects: t.Subjects})
|
|
}
|
|
default:
|
|
l.NotAnswering = append(l.NotAnswering, module)
|
|
}
|
|
}
|
|
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
|
|
})
|
|
sort.Strings(l.NotAnswering)
|
|
return l, nil
|
|
}
|
|
|
|
// callTool calls `<module>.<tool>[@<node>]`, on the subject the listing names for it when it names one.
|
|
func callTool(conn *bus.Conn, key string, args any, seats Seats, l *Listing) (bus.Answered, error) {
|
|
name, node, _ := strings.Cut(key, "@")
|
|
if !strings.Contains(name, ".") {
|
|
return bus.Answered{}, fmt.Errorf("%q does not name a tool: write <module>.<tool>, as `mesh tools` lists "+
|
|
"them, or <module>.<tool>@<node> for the instance on one machine", key)
|
|
}
|
|
resolved := toolKey(name, seats)
|
|
if node != "" {
|
|
resolved += "@" + node
|
|
}
|
|
return conn.Ask(resolved, args, subjectListed(name, node, l))
|
|
}
|
|
|
|
func subjectListed(name, node string, l *Listing) string {
|
|
if l == nil {
|
|
return ""
|
|
}
|
|
dot := strings.Index(name, ".")
|
|
module, tool := name[:dot], name[dot+1:]
|
|
for _, t := range l.Tools {
|
|
if t.Module == module && t.Name == tool && !t.Seat {
|
|
if len(t.Subjects) == 0 {
|
|
return ""
|
|
}
|
|
if node == "" {
|
|
return t.Subjects[0]
|
|
}
|
|
for _, s := range t.Subjects {
|
|
if strings.HasSuffix(s, "."+node) {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var (
|
|
noResponders = regexp.MustCompile(`(?i)no responders|503`)
|
|
refused = regexp.MustCompile(`(?i)permissions violation|authorization`)
|
|
timedOut = regexp.MustCompile(`(?i)timeout`)
|
|
)
|
|
|
|
// whyItFailed says why a call failed, so the remedy is in the words.
|
|
func whyItFailed(key string, err error) string {
|
|
if err == nil {
|
|
err = errors.New("failed")
|
|
}
|
|
msg := err.Error()
|
|
switch {
|
|
case noResponders.MatchString(msg):
|
|
extra := ""
|
|
if strings.HasPrefix(key, "seat:") {
|
|
extra = ", or nothing holds that seat"
|
|
}
|
|
return "nothing serves " + key + ". The module may not be assigned to any machine, or it is down" +
|
|
extra + " — `mesh tools` lists what answered."
|
|
case refused.MatchString(msg):
|
|
return "this account may not call " + key + ". What it may call was fixed when it was issued — a " +
|
|
"person's by `operator issue`, the console's by its manifest."
|
|
case timedOut.MatchString(msg):
|
|
return key + " did not answer in time. Something is serving it, so this is the tool being slow " +
|
|
"rather than absent."
|
|
}
|
|
return key + " failed: " + msg
|
|
}
|