Merge feat/47-G-fix9-routeproxy: the seat-holder helper (A-126) the recovery module reads the bus module with

This commit is contained in:
2026-11-12 01:32:46 +01:00
13 changed files with 208 additions and 31 deletions
+3
View File
@@ -25,6 +25,9 @@ func SeatFromDefinition(d seatdef.Definition) Seat {
Input: seatdef.ResolveRefs(v.Input, d.Defs), Output: seatdef.ResolveRefs(v.Answer, d.Defs),
Optional: v.Optional, Replaces: v.Replaces})
}
for _, st := range d.States {
s.States = append(s.States, st.Name)
}
for _, r := range d.Receives {
rv := Receivable{Kind: r.Kind, Comment: r.Comment, Dirs: r.Dirs, Offered: r.Offered}
if r.Shape != nil {
+3
View File
@@ -46,6 +46,9 @@ type Seat struct {
// Serves carries each verb in full — name, description, schema — because a role's tools are the
// mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
Serves []Verb
// States are the names of the state the seat's holder keeps, which a module reads by `seat:<seat>.<state>` (design 47 G
// A-116, A-128). Compiled from the definition's states.
States []string
// Receives is what other modules may contribute to the seat's holder, by kind (novox/hq ADR
// 0212): each kind is text in the tool's own grammar, placed by the holder with
// ${contribution:<seat>:<kind>}. Compiled, never stored: like the protocol, it is the mesh's
+19
View File
@@ -78,6 +78,25 @@ func (s StateDeclaration) MarshalJSON() ([]byte, error) {
return json.Marshal(plain(s))
}
// SeatReadPrefix opens a read of the state a seat's holder keeps: `seat:<seat>.<state>` (design 47 G A-116, A-128).
const SeatReadPrefix = "seat:"
// SeatRead splits a read of a seat holder's state, `seat:<seat>.<state>`, into the seat and the local name; ok is false for a
// read that is not of this kind (a module's `<module>.<state>`, which ReadState splits), and err says why a `seat:` read is
// malformed. The holder is not in it: it is resolved where the read is made (seatholder), so a new holder is read with no
// change of the manifest.
func SeatRead(read string) (seat, local string, ok bool, err error) {
rest, isSeat := strings.CutPrefix(read, SeatReadPrefix)
if !isSeat {
return "", "", false, nil
}
at := strings.LastIndex(rest, ".")
if at <= 0 || at == len(rest)-1 || !stateName.MatchString(rest[:at]) || !stateName.MatchString(rest[at+1:]) {
return "", "", true, fmt.Errorf("%q does not name a seat and its state: a read of a seat holder's state is seat:<seat>.<name>", read)
}
return rest[:at], rest[at+1:], true, nil
}
// ReadState splits a read into the owning module and the local name, or says why it is not one.
func ReadState(read string) (module, local string, err error) {
at := strings.LastIndex(read, ".")
+26
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// A module declares the state it keeps and the state it reads (novox/hq ADR 0201), a bucket by its
@@ -67,3 +69,27 @@ func TestAStateMayHaveKeysThatExpireOneByOne(t *testing.T) {
t.Fatalf("read-only given twice: %v", err)
}
}
// A read of a seat holder's state is its own kind, `seat:<seat>.<state>` (design 47 G A-128), and a seat's compiled form
// carries the names of the states its definition declares.
func TestSeatRead(t *testing.T) {
for _, row := range []struct {
read, seat, local string
ok, bad bool
}{
{"seat:mesh-provisioner.plans", "mesh-provisioner", "plans", true, false},
{"mesh-provisioner.plans", "", "", false, false},
{"seat:mesh-provisioner", "", "", true, true},
{"seat:.plans", "", "", true, true},
{"seat:Mesh.plans", "", "", true, true},
} {
seat, local, ok, err := SeatRead(row.read)
if seat != row.seat || local != row.local || ok != row.ok || (err != nil) != row.bad {
t.Errorf("SeatRead(%q) = %q, %q, %v, %v", row.read, seat, local, ok, err)
}
}
s := SeatFromDefinition(seatdef.Definition{Seat: "mesh-provisioner", Scope: "mesh", States: []seatdef.State{{Name: "plans"}, {Name: "provisions"}}})
if len(s.States) != 2 || s.States[0] != "plans" || s.States[1] != "provisions" {
t.Errorf("the seat's states = %v", s.States)
}
}
+10 -27
View File
@@ -1,5 +1,5 @@
// Package operatorask is a module's asking of a person through the seat that routes asks to the operator (design 47 G
// A-101, §700, A-74): the seat's holder is found on the seat list, an ask is published under the module's own name, the
// A-101, §700, A-74): the seat's holder is read by seatlist.HolderOf, an ask is published under the module's own name, the
// router's record of it is read for the warrant, and the router's `decided` is heard. It is shared code, so no module
// keeps its own copy; it names no seat, verb or module: the generated contracts' names and types are its parameters.
package operatorask
@@ -10,45 +10,28 @@ import (
"fmt"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seatlist"
)
// Caller calls a seat verb or module tool as `seat:<seat>.<verb>` or `<module>.<tool>` and returns its answer
// (stdio.Ask is one; so is a process's calls).
type Caller func(key string, body any) (json.RawMessage, error)
type Caller = seatlist.HolderCaller
// Contract is what the asker takes from the generated contracts: the verb that answers the seat list with its seats
// (`Lists` is its call key, `ListBody` its input) and the asking seat and its state of the router's records.
// Contract is what the asker takes from the generated contracts: the asking seat and its state of the router's records.
type Contract struct {
Lists string
ListBody any
Seat string
Seat string
// StateAsks is the seat's state holding the router's record of each ask.
StateAsks string
}
// Bucket is the bucket of the router's records: the seat's state in the bucket of the seat's holder on the seat list.
// Bucket is the bucket of the router's records: the seat's state in the bucket of the seat's holder, as seatlist reads it
// (design 47 G A-126).
func Bucket(call Caller, c Contract) (string, error) {
raw, err := call(c.Lists, c.ListBody)
module, err := seatlist.HolderOf(call, c.Seat)
if err != nil {
return "", fmt.Errorf("who holds the seat %s is not asked of %s: %w", c.Seat, c.Lists, err)
return "", fmt.Errorf("the router's record of an ask is not read: %w", err)
}
var held struct {
Seats []struct {
Seat string `json:"seat"`
Holders []struct {
Module string `json:"module"`
} `json:"holders"`
} `json:"seats"`
}
if err := json.Unmarshal(raw, &held); err != nil {
return "", fmt.Errorf("the answer of %s is not the seat list's: %w", c.Lists, err)
}
for _, s := range held.Seats {
if s.Seat == c.Seat && len(s.Holders) > 0 {
return s.Holders[0].Module + "_" + c.StateAsks, nil
}
}
return "", fmt.Errorf("no module holds the seat %s on the seat list, so the router's record of an ask is not read", c.Seat)
return module + "_" + c.StateAsks, nil
}
// Asker asks as one module. W is the generated contract's warrant type.
+2 -2
View File
@@ -24,7 +24,7 @@ func TestTheAskerFindsTheHolderPublishesReadsTheWarrantAndHearsDecided(t *testin
var published any
var heard func(stdio.Envelope) error
a := &Asker[warrant]{
Module: "me", Contract: Contract{Lists: "seat:lister.list", ListBody: map[string]bool{"seats": true}, Seat: "router-seat", StateAsks: "asks"},
Module: "me", Contract: Contract{Seat: "router-seat", StateAsks: "asks"},
Call: call,
Record: func(b, i string) (*stdio.StateEntry, error) {
bucket, id = b, i
@@ -39,7 +39,7 @@ func TestTheAskerFindsTheHolderPublishesReadsTheWarrantAndHearsDecided(t *testin
t.Errorf("Ask published %v on %q: %v", published, subject, err)
}
state, w, err := a.Where("a1")
if err != nil || state != "chosen" || w == nil || w.Choice != "go" || bucket != "holder_asks" || id != "a1" || len(asked) != 1 || asked[0] != "seat:lister.list" {
if err != nil || state != "chosen" || w == nil || w.Choice != "go" || bucket != "holder_asks" || id != "a1" || len(asked) != 1 || asked[0] != "seat:mesh-module-assigner.assignments" {
t.Errorf("Where = %q %+v %v, read %q %q after %v", state, w, err, bucket, id, asked)
}
var got []warrant
+52
View File
@@ -0,0 +1,52 @@
// Who holds a seat now (design 47 G A-126, beside the seat list): the module on record as the holder of a seat, asked of
// the seat that places modules (`mesh-module-assigner`) through its verb `assignments` with `seats` true, which answers the
// seat's holders. It is the one such reading: a module that reads a seat holder's state (a `reads` of `seat:<seat>.<state>`,
// A-116) and the asker of the operator (operatorask) call it, and no part keeps its own copy. It names the one seat and
// verb it asks, as this package names the seat that serves the seat list, and no module.
package seatlist
import (
"encoding/json"
"fmt"
)
const (
// HolderSeat is the seat whose verb answers who holds each seat.
HolderSeat = "mesh-module-assigner"
// HolderVerb is its verb, asked with `seats` true.
HolderVerb = "assignments"
)
// HolderCaller calls a seat verb or module tool as `seat:<seat>.<verb>` or `<module>.<tool>` and returns its answer (stdio.Ask is
// one; so is a process's calls).
type HolderCaller func(key string, body any) (json.RawMessage, error)
// HolderKey is the address asked, as stdio.Ask takes it; a host's manifest `invokes` lists it.
func HolderKey() string { return "seat:" + HolderSeat + "." + HolderVerb }
// HolderOf is the module holding the seat now: the first holder the seat's answer lists. It is an error while the answer cannot be
// read or no module holds the seat.
func HolderOf(call HolderCaller, seat string) (string, error) {
raw, err := call(HolderKey(), map[string]string{"seats": "true"})
if err != nil {
return "", fmt.Errorf("who holds the seat %s is not asked of %s: %w", seat, HolderKey(), err)
}
var held struct {
Seats []struct {
Seat string `json:"seat"`
Holders []struct {
Module string `json:"module"`
} `json:"holders"`
} `json:"seats"`
}
if err := json.Unmarshal(raw, &held); err != nil {
return "", fmt.Errorf("the answer of %s is not the seat holders': %w", HolderKey(), err)
}
for _, s := range held.Seats {
if s.Seat == seat && len(s.Holders) > 0 {
return s.Holders[0].Module, nil
}
}
return "", fmt.Errorf("no module holds the seat %s on record", seat)
}
+33
View File
@@ -0,0 +1,33 @@
package seatlist
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func TestHolderOf(t *testing.T) {
answer := `{"seats":[{"seat":"other","holders":[{"module":"x"}]},{"seat":"mesh-provisioner","holders":[{"module":"prov"},{"module":"second"}]}]}`
for _, row := range []struct {
name, seat, want, err string
call HolderCaller
}{
{"the first holder on record", "mesh-provisioner", "prov", "", func(k string, b any) (json.RawMessage, error) {
if k != "seat:mesh-module-assigner.assignments" || b.(map[string]string)["seats"] != "true" {
t.Errorf("asked %q with %v", k, b)
}
return json.RawMessage(answer), nil
}},
{"no holder", "absent", "", "no module holds the seat absent", func(string, any) (json.RawMessage, error) { return json.RawMessage(answer), nil }},
{"the ask fails", "x", "", "who holds the seat x is not asked", func(string, any) (json.RawMessage, error) { return nil, errors.New("down") }},
{"not the holders' answer", "x", "", "is not the seat holders'", func(string, any) (json.RawMessage, error) { return json.RawMessage(`nope`), nil }},
} {
t.Run(row.name, func(t *testing.T) {
got, err := HolderOf(row.call, row.seat)
if row.err == "" && (err != nil || got != row.want) || row.err != "" && (err == nil || !strings.Contains(err.Error(), row.err)) {
t.Errorf("HolderOf = %q, %v; want %q, %q", got, err, row.want, row.err)
}
})
}
}
+1
View File
@@ -25,4 +25,5 @@ catalogue/seat_dependencies.go: node-service-manager
catalogue/shares.go: node-mounts
catalogue/shares.go: node-nfs-server
internal/beside/beside.go: mesh-catalog
seatlist/holder.go: mesh-module-assigner
seatlist/seatlist.go: mesh-catalog
+18
View File
@@ -20,6 +20,9 @@ const (
// ViewWaitsKind is a part's finding of a view it has waited for past WaitsBound (design 47 *A part with no view says it
// waits*).
ViewWaitsKind = "view-waits"
// EntryNotFoundKind is a part's finding of an entry by fingerprint the seat mesh-catalog's `entry` answered it does not hold
// (design 47 G §614, A-15, A-103): scope the part, id the fingerprint.
EntryNotFoundKind = "entry-not-found"
)
// viewFinding is what a views part says is wrong with a state it owns or follows, in plain fields: kind and id name it, summary
@@ -99,3 +102,18 @@ func viewWaitsFinding(part string, s Status, at time.Time) viewFinding {
part, s.Owner, s.State, s.WaitsFor, s.WaitingSince.UTC().Format(time.RFC3339), cmp.Or(s.View, "none"))
return viewFinding{kind: ViewWaitsKind, id: part + "." + s.Owner + "." + s.State, summary: summary, headline: fmt.Sprintf("%s waits for a view of %s", part, s.Owner), at: at.UTC()}
}
// WriteEntryNotFound writes the part's finding that the catalog holds no entry for fingerprint, asked since `since`
// (design 47 G A-103); ClearEntryNotFound clears it, when nothing the part holds names the fingerprint or a later ask answers.
func WriteEntryNotFound(w WireWriter, fingerprint string, since, at time.Time) error {
module, _ := w.ModuleNode()
return viewFinding{kind: EntryNotFoundKind, id: fingerprint,
summary: fmt.Sprintf("%s holds a build of fingerprint %s that the catalog holds no entry for (asked since %s): what it needs of the entry stands still",
module, fingerprint, since.UTC().Format(time.RFC3339)),
headline: fmt.Sprintf("%s cannot find the catalog entry of a build", module), at: at.UTC()}.put(w)
}
// ClearEntryNotFound clears the finding WriteEntryNotFound wrote for fingerprint.
func ClearEntryNotFound(w WireWriter, fingerprint string) error {
return viewFinding{kind: EntryNotFoundKind, id: fingerprint}.drop(w)
}
+26 -1
View File
@@ -28,6 +28,8 @@ type Reader struct {
// says it in the log only.
Findings WireWriter
Logf func(string, ...any)
rows map[string]func() any // the lists Rows added to the first views (guarded by mu)
// Changed is told of each key a followed state changes, after the picture holds it (state is `<owner>.<state>`).
Changed func(state, key string, value json.RawMessage, deleted bool)
// Acts records a re-read by hand before it is asked (nil records nothing
@@ -574,13 +576,36 @@ func (r *Reader) Statuses() []Status {
return out
}
// Rows adds a list the part's `<prefix>_first_views` says beside the views' statuses, under name (design 47 G A-103: the
// entries by fingerprint the catalog half's follower keeps). rows is called at each reading.
func (r *Reader) Rows(name string, rows func() any) {
r.mu.Lock()
defer r.mu.Unlock()
if r.rows == nil {
r.rows = map[string]func() any{}
}
r.rows[name] = rows
}
// firstViews is what `<prefix>_first_views` answers: the views' statuses and each list added by Rows.
func (r *Reader) firstViews() map[string]any {
out := map[string]any{"views": r.Statuses()}
r.mu.Lock()
rows := maps.Clone(r.rows)
r.mu.Unlock()
for name, f := range rows {
out[name] = f()
}
return out
}
// Tools are the reader's two tools (design 25 § 12.2): `<prefix>_first_views` says where each first view stands and
// `<prefix>_first_view_again` asks one again by hand (a hand act, counted in the status).
func (r *Reader) Tools(prefix string) []stdio.Tool {
return []stdio.Tool{
{Name: prefix + "_first_views",
Description: "Where each first view this module keeps stands (novox/hq ADR 0615): the owner and its tool, the revision the picture holds, when it was asked and answered, how many asks (by hand among them) and whether its condition is raised. (r)",
Run: func(map[string]any) (any, error) { return map[string]any{"views": r.Statuses()}, nil }},
Run: func(map[string]any) (any, error) { return r.firstViews(), nil }},
{Name: prefix + "_first_view_again",
Description: "Ask a first view again by hand, for the state named (<owner>.<state>), or every one with none: a hand act, for a picture the operator doubts. Answers where it stands after. (a)",
Input: map[string]any{"type": "object", "properties": map[string]any{
+14
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"reflect"
"runtime"
"slices"
"strings"
@@ -558,6 +559,19 @@ func subViews(t *testing.T) {
if err != nil || len(again.(map[string]any)["views"].([]Status)) != 1 || f.Status().ByHand != 1 || acted != 1 {
t.Errorf("asked again by hand: %v, %v", again, err)
}
// a list added by Rows is said beside the statuses (design 47 G A-103), and the entry-not-found finding is written and cleared
r.Rows("entries", func() any { return []string{"fp1"} })
seen, _ := r.Tools("reader")[0].Run(nil)
if got := seen.(map[string]any)["entries"]; !reflect.DeepEqual(got, []string{"fp1"}) {
t.Errorf("first views entries = %v", got)
}
nf := &findingsMap{}
if err := WriteEntryNotFound(nf, "fp1", now(), now()); err != nil || nf.m[EntryNotFoundKind+"/fp1"].Summary == "" {
t.Errorf("entry-not-found not written: %+v, %v", nf.m, err)
}
if err := ClearEntryNotFound(nf, "fp1"); err != nil || len(nf.m) != 0 {
t.Errorf("entry-not-found not cleared: %+v, %v", nf.m, err)
}
})
t.Run("a process's reader follows the read tool its caller names, reads the state as a read-only bucket, each key's written time handed on, and opens no other", func(t *testing.T) {
r := &ProcessReader{Module: "reader", Bound: time.Nanosecond, Wait: time.Millisecond, Now: now,
+1 -1
View File
@@ -73,7 +73,7 @@ func requireFields[T any](what string, fields ...string) error {
}
// ViewKinds are the finding kinds a views reader writes through the part's writer (design 47 G §639).
var ViewKinds = []string{ViewWaitsKind, UnansweredKind, UnsaidKind, TooLargeKind}
var ViewKinds = []string{ViewWaitsKind, UnansweredKind, UnsaidKind, TooLargeKind, EntryNotFoundKind}
var (
sessionForm = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*@[a-z0-9][a-z0-9-]*$`)