R-2: W1.9: a part's seat-list reader and its two tools are wired once

Four delivery parts each wrote the same wiring of the seat-list reader
(start from the bootstrap, give the catalogue what it holds, read again
when events go live, two tools over a reader set later). seatlist.Part
is that wiring, its tools named by the part's prefix and its again
answered to the callers the part gives (ADR 0636 §7), so W4-W8 delete
their copies.

Builds: design 47 G §204, §246

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-11 10:51:06 +01:00
parent b37ed85559
commit fc19a53e1d
2 changed files with 144 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
package seatlist
import (
"errors"
"sync/atomic"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// A part that reads the seat list wires its reader one way (novox/hq ADR 0636 §1, §7): started from the bootstrapped
// definitions, what it holds given to catalogue at once and at every change, read again when the part's path for the
// seat's events becomes live, and its two tools `<prefix>_seat_list` and `<prefix>_seat_list_again` served over whatever
// reader the part holds at the call. Part is that wiring, so no part writes it again; the part names its tools' prefix
// and the callers that may read again, which are its own to give (stdio.VerbCallers' entries).
// Part is a part's reader of the seat list, set once Follow starts it; before, the list counts as unread and the part
// acts on nothing new.
type Part struct {
prefix string
callers []string
reader atomic.Pointer[Reader]
}
// NewPart is the wiring of a part whose tools are named `<prefix>_seat_list` and `<prefix>_seat_list_again`, the second
// answered only to callers.
func NewPart(prefix string, callers []string) *Part {
return &Part{prefix: prefix, callers: append([]string(nil), callers...)}
}
// Follow starts the reader over bus from the bootstrapped definitions: what it holds is given to catalogue at once and at
// every change, and changed, when given, is called after each change (the part acts again). A reader that cannot
// subscribe is said through logf and still held, from the bootstrap. It returns the reader.
func (p *Part) Follow(bus Bus, changed func(), logf func(string, ...any)) *Reader {
reader, err := Start(Config{Bus: bus, Bootstrap: Bootstrapped(),
OnChange: func(seats []catalogue.Seat) {
catalogue.UseSeats(seats)
if changed != nil {
changed()
}
}})
if err != nil && logf != nil {
logf("%s cannot follow its seat list, so it acts only on the seats the bootstrapped definitions name: %v", p.prefix, err)
}
if reader != nil {
catalogue.UseSeats(reader.Seats())
p.reader.Store(reader)
}
return reader
}
// Reader is the reader Follow started, or nil before.
func (p *Part) Reader() *Reader {
if p == nil {
return nil
}
return p.reader.Load()
}
// MayAct is the reader's MayAct, false before it is started.
func (p *Part) MayAct(seats ...string) bool {
r := p.Reader()
return r != nil && r.MayAct(seats...)
}
// Heard says the seat has answered the reader (Status's Heard), false before it is started.
func (p *Part) Heard() bool {
r := p.Reader()
return r != nil && r.Status().Heard
}
// Health is why the list is not read, or empty when it is.
func (p *Part) Health() string {
r := p.Reader()
if r == nil {
return "the seat list reader has not started yet"
}
return r.Status().Health()
}
// EventsLive is called when the part's path for the seat's events is heard: while the list is unread it is read now, on a
// goroutine of its own (Reader.ReloadIfUnread).
func (p *Part) EventsLive() {
if r := p.Reader(); r != nil {
r.ReloadIfUnread()
}
}
// ToolNames are the two tools' names.
func (p *Part) ToolNames() (read, again string) {
return p.prefix + "_seat_list", p.prefix + "_seat_list_again"
}
// Tools are the reader's two tools (Tools) over whatever reader the part holds at the call: before Follow, each says the
// reader has not started.
func (p *Part) Tools() []stdio.Tool {
read, again := p.ToolNames()
shape := Tools(&Reader{}, read, again, p.callers)
for i := range shape {
run := i
shape[i].Run = func(args map[string]any) (any, error) {
r := p.Reader()
if r == nil {
return nil, errors.New("the seat list reader has not started yet: the runtime has not served this module")
}
return Tools(r, read, again, p.callers)[run].Run(args)
}
}
return shape
}
+34
View File
@@ -240,6 +240,40 @@ func TestTheTwoToolsReadTheHeldListAndReadItAgainForTheirCallers(t *testing.T) {
if err := r.Reload(); err != nil || !r.Status().Heard || len(r.Status().Held) != len(Bootstrapped())+1 {
t.Fatalf("again: %v %+v", err, r.Status())
}
// W1.9: a part's wiring is one (Part): its two tools are named by its prefix and answer before the reader is started,
// the list counts as unread then, and once followed what the reader holds is the catalogue's and a change wakes the part.
saved := catalogue.Seats()
t.Cleanup(func() { catalogue.UseSeats(saved) }) // Follow sets the catalogue's seats, a global; given back after
part := NewPart("assigner", callers)
if read, again := part.ToolNames(); read != "assigner_seat_list" || again != "assigner_seat_list_again" {
t.Fatalf("tool names %s, %s", read, again)
}
before := part.Tools()
if len(before) != 2 || before[0].Name != "assigner_seat_list" || before[1].Name != "assigner_seat_list_again" {
t.Fatalf("tools %+v", before)
}
if _, err := before[0].Run(map[string]any{}); err == nil || !strings.Contains(err.Error(), "has not started") || part.Heard() ||
part.MayAct("node-b") || part.Health() == "" {
t.Fatalf("an unstarted part: %v, heard %v", err, part.Heard())
}
owner2 := &fakeOwner{table: map[string]string{"node-b": def("node-b", "node", "put")}}
woken := make(chan bool, 4)
pr := part.Follow(owner2, func() { woken <- true }, t.Logf)
<-pr.Ready()
if !part.Heard() || !part.MayAct("node-b") || part.Health() != pr.Status().Health() || part.Reader() != pr {
t.Fatalf("a followed part: heard %v, health %q", part.Heard(), part.Health())
}
if _, ok := catalogue.SeatNamed("node-b"); !ok {
t.Fatal("what the part's reader holds is not the catalogue's")
}
if _, err := part.Tools()[1].Run(map[string]any{}); err == nil || !strings.Contains(err.Error(), "it answers only operator, mesh-healers") {
t.Fatalf("again without a proven caller: %v", err)
}
owner2.table["node-c"] = def("node-c", "node", "put")
if err := owner2.say(ChangedEvent, "node-c", SourceTrunk); err != nil || len(woken) == 0 {
t.Fatalf("a change did not wake the part: %v", err)
}
}
// A reader started while the seat was silent is heard at the seat's changed event: the whole list is read then (ADR 0636 §1).