From dac4d423a88f21f0c17ac773971f01b815128ff5 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 7 Oct 2026 01:37:46 +0200 Subject: [PATCH 1/3] Reach a seat and a module of one name each: list the seat's verbs, and route a machine's address to the module mesh-delivery is the delivery's seat and the module holding it, which answers the seat's verbs with tools of the same names. Discovery keyed the seat's verbs and the module's tools in one namespace, so the module's tool took the key and the seat was listed with no verb; and every address with the name resolved to the seat, so neither the seat's verbs nor the module's tools could be called. The keys are apart, and `/.` reaches the module when the module serves it (novox/hq issue 284). --- merge-check.sh | 6 + node-tools/internal/console/address.go | 209 +++++++++++-------- node-tools/internal/console/one_name_test.go | 57 +++++ 3 files changed, 181 insertions(+), 91 deletions(-) create mode 100644 node-tools/internal/console/one_name_test.go diff --git a/merge-check.sh b/merge-check.sh index 37449c2..e4912d2 100644 --- a/merge-check.sh +++ b/merge-check.sh @@ -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 284). + 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" diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index 3754936..d585500 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -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{} // . or . → 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{} + // ., or seat:., → 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 284). + 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 284): 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 — `/.` 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" { diff --git a/node-tools/internal/console/one_name_test.go b/node-tools/internal/console/one_name_test.go new file mode 100644 index 0000000..14c7c98 --- /dev/null +++ b/node-tools/internal/console/one_name_test.go @@ -0,0 +1,57 @@ +package console + +import ( + "strings" + "testing" + + "github.com/novox/mesh-tools/node-tools/internal/announce" +) + +// **Issue 284**: 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(), "/") { + t.Errorf("the module's tool without a machine: %v", err) + } +} From 0a5dbc7e9a47fc167d020af56ab164ee7f27119e Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 7 Oct 2026 02:00:11 +0200 Subject: [PATCH 2/3] Cite the hq issues by the numbers they were given: 285, 286, 287 --- merge-check.sh | 2 +- node-tools/internal/console/address.go | 4 ++-- node-tools/internal/console/one_name_test.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/merge-check.sh b/merge-check.sh index e4912d2..cca6af2 100644 --- a/merge-check.sh +++ b/merge-check.sh @@ -30,7 +30,7 @@ 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 284). + # 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 diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index d585500..f8236aa 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -337,7 +337,7 @@ func indexOfEndpoints(endpoints []announce.Endpoint) (*index, map[string]map[str // ., or seat:., → 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 284). + // 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 { @@ -450,7 +450,7 @@ func resolve(x *index, address string) (target, error) { 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 284): mesh-delivery is the delivery's seat and + // **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 — `/.` is a module on one machine. if findTool(s.Verbs, name) == nil || (node != "" && s.Scope != "node") { diff --git a/node-tools/internal/console/one_name_test.go b/node-tools/internal/console/one_name_test.go index 14c7c98..c8ae226 100644 --- a/node-tools/internal/console/one_name_test.go +++ b/node-tools/internal/console/one_name_test.go @@ -7,7 +7,7 @@ import ( "github.com/novox/mesh-tools/node-tools/internal/announce" ) -// **Issue 284**: mesh-delivery is the delivery's seat, held once for the mesh, and the module holding it, +// **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` From 41911f7f15649b3c673ec28ca4bb5844388a6336 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 7 Oct 2026 02:06:52 +0200 Subject: [PATCH 3/3] Check again, judged by the controller with the gate's fix (novox/hq issue 285)