node-tools in Go: the node's runtime, launch-only, wire-compatible with the TypeScript (hq ADR 0193)
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.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user