Merge pull request 'Console: a seat and a module of one name are each reached (hq issue 287)' (#19) from fix/a-seat-and-a-module-of-one-name into main

This commit was merged in pull request #19.
This commit is contained in:
2026-10-07 00:27:49 +00:00
3 changed files with 181 additions and 91 deletions
+6
View File
@@ -26,9 +26,12 @@ fi
CGO_ENABLED=0 go vet ./...
packages=$(go list ./...)
bundleless=""
if ! command -v node >/dev/null 2>&1 || [ ! -d node_modules/@novox/mesh-sdk ]; then
echo "NOT TESTED HERE: internal/runtime and internal/console launch bundles that need node and @novox/mesh-sdk"
packages=$(printf '%s\n' "$packages" | grep -v -e '/internal/runtime$' -e '/internal/console$')
# What of the console launches no bundle runs anyway: how an address resolves (novox/hq issue 287).
bundleless='^TestASeatAndAModuleOfOneNameAreEachReached$'
fi
if command -v gcc >/dev/null 2>&1; then
CGO_ENABLED=1 go test -race -count=1 $packages
@@ -36,4 +39,7 @@ else
echo "NOT RACE-CHECKED: the toolchain holds no C compiler; the suite runs without the race detector"
CGO_ENABLED=0 go test -count=1 $packages
fi
if [ -n "$bundleless" ]; then
CGO_ENABLED=0 go test -count=1 -run "$bundleless" ./internal/console
fi
echo "NOT TESTED HERE: node-tools/src (TypeScript) needs @novox/mesh-sdk from the package registry"
+118 -91
View File
@@ -243,100 +243,17 @@ func indexOn(conn *bus.Conn) (*index, error) {
return nil, err
}
l := &Listing{Tools: []Tool{}, NotAnswering: []string{}}
x := &index{Modules: map[string]*moduleInfo{}, Listing: l, Discovery: d, Recorded: map[string][]string{}}
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)})
}
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 _, h := range d.Heard {
for _, e := range announce.Endpoints(h.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.
@@ -410,6 +327,104 @@ func (s *Surface) indexAsked(fresh bool) (*index, error) {
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
@@ -433,9 +448,21 @@ func resolve(x *index, address string) (target, error) {
}
prefix, name := rest[:dot], rest[dot+1:]
if s := x.seat(prefix); s != nil {
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" {
@@ -0,0 +1,57 @@
package console
import (
"strings"
"testing"
"github.com/novox/mesh-tools/node-tools/internal/announce"
)
// **Issue 287**: mesh-delivery is the delivery's seat, held once for the mesh, and the module holding it,
// which answers the seat's verbs with tools of the same names and has tools of its own. Discovery put the
// module's tool and the seat's verb under one key, so the seat was listed with no verb; and every address
// with the name resolved to the seat — `mesh-delivery.deliveries` and `novox/mesh-delivery.delivery_status`
// alike were refused, the seat's verbs and the module's tools both out of reach.
func TestASeatAndAModuleOfOneNameAreEachReached(t *testing.T) {
var endpoints []announce.Endpoint
for _, tool := range []string{"deliveries", "delivery_status"} {
endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindTool, Module: "mesh-delivery", Tool: tool,
Node: "anchor", Subject: "mesh.mod.mesh-delivery.tool." + tool + ".anchor"})
}
for _, verb := range []string{"deliveries", "stalled"} {
endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "mesh-delivery", Seat: "mesh-delivery",
Tool: verb, Scope: "mesh", Subject: "mesh.seat.mesh-delivery.tool." + verb})
}
x, _, _ := indexOfEndpoints(endpoints)
s := x.seat("mesh-delivery")
if s == nil || len(s.Verbs) != 2 {
t.Fatalf("the seat is listed with verbs %v, not its two", s)
}
if m := x.Modules["mesh-delivery"]; m == nil || len(m.Tools) != 2 {
t.Fatalf("the module is listed with tools %v, not its two", m)
}
for address, want := range map[string]struct {
seat bool
key string
}{
"mesh-delivery.deliveries": {true, "seat:mesh-delivery.deliveries"},
"mesh-delivery.stalled": {true, "seat:mesh-delivery.stalled"},
"anchor/mesh-delivery.delivery_status": {false, "mesh-delivery.delivery_status@anchor"},
"anchor/mesh-delivery.deliveries": {false, "mesh-delivery.deliveries@anchor"},
} {
got, err := resolve(x, address)
if err != nil {
t.Errorf("%s: %v", address, err)
continue
}
if got.Seat != want.seat || got.Key != want.key {
t.Errorf("%s resolves to %+v, not the %s", address, got, want.key)
}
}
// A module tool of the name without a machine: the module keeps state on its machine, so it says so.
if _, err := resolve(x, "mesh-delivery.delivery_status"); err == nil || !strings.Contains(err.Error(), "<node>/") {
t.Errorf("the module's tool without a machine: %v", err)
}
}