diff --git a/go/seatlist/part.go b/go/seatlist/part.go new file mode 100644 index 00000000..c4a3ad6f --- /dev/null +++ b/go/seatlist/part.go @@ -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 `_seat_list` and `_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 `_seat_list` and `_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 +} diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index 93a4b91e..2cb73afb 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -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).