operatorask: one operator-channel asker helper (holder on the seat list, publish, router's warrant, decided)

Builds: design 47 G A-101, §700, A-74, A-89
This commit is contained in:
2026-11-11 23:36:05 +01:00
parent a2df6f3653
commit a67213a17e
3 changed files with 172 additions and 1 deletions
+111
View File
@@ -0,0 +1,111 @@
// 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
// 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
import (
"encoding/json"
"errors"
"fmt"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// 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)
// 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.
type Contract struct {
Lists string
ListBody any
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.
func Bucket(call Caller, c Contract) (string, error) {
raw, err := call(c.Lists, c.ListBody)
if err != nil {
return "", fmt.Errorf("who holds the seat %s is not asked of %s: %w", c.Seat, c.Lists, 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)
}
// Asker asks as one module. W is the generated contract's warrant type.
type Asker[W any] struct {
Module string
Contract Contract
// Call reaches the seat list's verb.
Call Caller
// Record reads one record of a bucket under the module's own name (process.Conn.Ask).
Record func(bucket, id string) (*stdio.StateEntry, error)
// Publish publishes a body de-duplicated by id: the same id twice is one message (stdio.SeatPublish).
Publish func(subject string, body any, id string) error
// Subscribe hears an event (stdio.Subscribe).
Subscribe func(pattern string, handle func(stdio.Envelope) error) error
// AskSubject and DecidedSubject are the generated contract's subjects for Module.
AskSubject, DecidedSubject func(module string) string
}
// Ask publishes the ask (the generated contract's Ask) under the module's own name, de-duplicated by its id.
func (a *Asker[W]) Ask(id string, ask any) error {
if a.Publish == nil || a.AskSubject == nil {
return errors.New("the asker has no way to publish")
}
return a.Publish(a.AskSubject(a.Module), ask, id)
}
// Where is an ask's state as the router keeps it, and its warrant once there is one; a record not yet there is an empty
// state. It is an error while the seat's holder or the record cannot be read.
func (a *Asker[W]) Where(id string) (state string, warrant *W, err error) {
bucket, err := Bucket(a.Call, a.Contract)
if err != nil {
return "", nil, err
}
e, err := a.Record(bucket, id)
if err != nil || e == nil {
return "", nil, err
}
var rec struct {
State string `json:"state"`
Warrant *W `json:"warrant"`
}
if err := json.Unmarshal(e.Value, &rec); err != nil {
return "", nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err)
}
return rec.State, rec.Warrant, nil
}
// Hear hands handle each warrant the router says to this module; a handler's error leaves it for the bus to give again.
func (a *Asker[W]) Hear(handle func(W) error) error {
if a.Subscribe == nil || a.DecidedSubject == nil {
return errors.New("the asker has no way to hear")
}
return a.Subscribe(a.DecidedSubject(a.Module), func(env stdio.Envelope) error {
var w W
if err := json.Unmarshal(env.Body, &w); err != nil {
return fmt.Errorf("%s holds what is no warrant: %w", env.Key, err)
}
return handle(w)
})
}
+60
View File
@@ -0,0 +1,60 @@
package operatorask
import (
"encoding/json"
"strings"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
)
type warrant struct {
Ask string `json:"ask"`
Choice string `json:"choice"`
}
// Design 47 G A-101: one asker finds the seat's holder, publishes, reads the warrant and hears decided.
func TestTheAskerFindsTheHolderPublishesReadsTheWarrantAndHearsDecided(t *testing.T) {
var asked []string
call := func(key string, body any) (json.RawMessage, error) {
asked = append(asked, key)
return json.RawMessage(`{"seats":[{"seat":"other","holders":[{"module":"x"}]},{"seat":"router-seat","holders":[{"module":"holder"}]}]}`), nil
}
var bucket, id, subject string
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"},
Call: call,
Record: func(b, i string) (*stdio.StateEntry, error) {
bucket, id = b, i
return &stdio.StateEntry{Value: json.RawMessage(`{"state":"chosen","warrant":{"ask":"a1","choice":"go"}}`)}, nil
},
Publish: func(s string, body any, _ string) error { subject, published = s, body; return nil },
Subscribe: func(_ string, h func(stdio.Envelope) error) error { heard = h; return nil },
AskSubject: func(m string) string { return "ask." + m },
DecidedSubject: func(m string) string { return "decided." + m },
}
if err := a.Ask("a1", "q"); err != nil || subject != "ask.me" || published != "q" {
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" {
t.Errorf("Where = %q %+v %v, read %q %q after %v", state, w, err, bucket, id, asked)
}
var got []warrant
if err := a.Hear(func(w warrant) error { got = append(got, w); return nil }); err != nil {
t.Fatal(err)
}
if err := heard(stdio.Envelope{Key: "decided.me", Body: []byte(`{"ask":"a1","choice":"go"}`)}); err != nil || len(got) != 1 || got[0].Ask != "a1" {
t.Errorf("heard %v: %v", got, err)
}
if err := heard(stdio.Envelope{Key: "decided.me", Body: []byte(`nope`)}); err == nil {
t.Errorf("a body that is no warrant was taken")
}
// No holder: nothing is read and the reason is said.
a.Call = func(string, any) (json.RawMessage, error) { return json.RawMessage(`{"seats":[]}`), nil }
if _, _, err := a.Where("a1"); err == nil || !strings.Contains(err.Error(), "no module holds the seat router-seat") {
t.Errorf("with no holder Where answered %v", err)
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ var meshSDKPackages = []string{
".", "announce", "announcetest", "busarchive", "buscalls", "buscompose", "busstate", "bussnap", "busretry", "calladdress", "catalogue",
"declaration", "forgekey", "health", "internal/beside", "keys", "kvlimit", "membership", "motion",
"objectlink", "outward", "pernode", "process",
"sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "stateobjects", "testbus", "views", "views/viewstest",
"sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "operatorask", "stateobjects", "testbus", "views", "views/viewstest",
}
// clientSeats are the seats whose logic is their client's in mesh-contracts (novox/hq ADR 0627 §1, §4): mesh-sdk names