Merge branch 'feat/47-G-fix9-builder2-sdk' into feat/47-G-fix9-checker

This commit is contained in:
2026-11-12 01:34:12 +01:00
10 changed files with 181 additions and 17 deletions
+48 -8
View File
@@ -14,7 +14,7 @@ const ProcessBusField = "bus"
// ProcessBus is what one process of a module does on the bus (novox/hq ADR 0436 §1, §2, §3, §5): its own bus
// account, granted exactly this and nothing of the module's other grants or the tool runner's — never a seat
// to hold, never a restricted stream heard, never a warrant published (§3, §5). It answers a seat's verb only
// where it declares it in Serves (design 47 G A-107, A-109).
// where its resource declares `serves` beside this bus, never inside it (design 47 G A-115, A-127; ProcessServes).
type ProcessBus struct {
// Emits are the events this process emits, named locally, as the module's own Emits names them
// (EmitsAll): a process emits only what its module may emit as a whole.
@@ -28,10 +28,6 @@ type ProcessBus struct {
// Invokes are the tools and seat verbs this process calls, each <module>.<tool> or a role's
// seat:<seat>.<verb> (as Manifest.Invokes), or * for every tool on the mesh.
Invokes []string `json:"invokes,omitempty"`
// Serves are the seat verbs this process answers directly over the bus (design 47 G A-107, A-109, A-110), each
// seat:<seat>.<verb>: the grants part composes the process account's subscribe of the verb's call and tool
// subjects from it, and, for a verb whose seat definition says chunks: true, the chunk and ack subjects.
Serves []string `json:"serves,omitempty"`
// Removes are what a tools bundle's account may remove from the bus, from a closed set (BusRemovals; novox/hq ADR
// 0564 Limits 4): declared alone, by the module holding mesh-broker, never by a process.
Removes []string `json:"removes,omitempty"`
@@ -58,6 +54,8 @@ const (
type BusAccount struct {
ID string
Bus ProcessBus
// Serves are the seat verbs the process answers (ProcessServes): empty for a tools bundle's account.
Serves []string
// Tools says it is a tools bundle's, run by the node's tool runtime, not a process the node runs.
Tools bool
}
@@ -67,11 +65,11 @@ type BusAccount struct {
// bundle's is read from the repository's build, or from the resolved manifest's bundles.
func (m Manifest) BusAccounts() []BusAccount {
var out []BusAccount
buses := m.ProcessBuses()
buses, serves := m.ProcessBuses(), m.ProcessServes()
for _, r := range m.Resources {
id := fmt.Sprint(r["id"])
if b, ok := buses[id]; ok && fmt.Sprint(r["type"]) == "process" {
out = append(out, BusAccount{ID: id, Bus: b})
out = append(out, BusAccount{ID: id, Bus: b, Serves: serves[id]})
}
}
seen := map[string]bool{}
@@ -101,7 +99,7 @@ func (b *ProcessBus) UnmarshalJSON(raw []byte) error {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return fmt.Errorf("a process's bus is {emits, queues, state, asks, invokes, serves, keeps}: %w", typedUnknown(err))
return fmt.Errorf("a process's bus is {emits, queues, state, asks, invokes, removes, keeps}; `serves` is a field of the process beside its bus (design 47 G A-127): %w", typedUnknown(err))
}
*b = ProcessBus(p)
return nil
@@ -143,3 +141,45 @@ func (m Manifest) ProcessBuses() map[string]ProcessBus {
}
return out
}
// ProcessServesField is the key a process resource declares the seat verbs it answers on, beside its `bus` and never inside
// it (design 47 G A-115, A-127).
const ProcessServesField = "serves"
// ProcessServes is the seat verbs each process of this module answers, by its id: each seat:<seat>.<verb>, read from the
// process resource's own `serves` (design 47 G A-115), whether or not the process declares a bus. A `serves` that is not a
// list of strings is read as none; the validator names it (ProcessServesProblem).
func (m Manifest) ProcessServes() map[string][]string {
var out map[string][]string
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "process" {
continue
}
list, err := ProcessServesOf(r)
if err != nil || len(list) == 0 {
continue
}
if out == nil {
out = map[string][]string{}
}
out[fmt.Sprint(r["id"])] = list
}
return out
}
// ProcessServesOf is the `serves` of one process resource, and an error where it is not a list of strings.
func ProcessServesOf(r map[string]any) ([]string, error) {
raw, present := r[ProcessServesField]
if !present {
return nil, nil
}
body, err := json.Marshal(raw)
if err != nil {
return nil, err
}
var list []string
if err := json.Unmarshal(body, &list); err != nil {
return nil, fmt.Errorf("a process's serves is a list of seat:<seat>.<verb>: %w", err)
}
return list, nil
}
+23 -7
View File
@@ -17,15 +17,31 @@ func strSliceEqual(a, b []string) bool {
return true
}
func TestProcessBusReadsServesAndRefusesUnknownKeys(t *testing.T) {
var b ProcessBus
if err := json.Unmarshal([]byte(`{"serves":["seat:mesh-broker.snapshot-get","seat:mesh-broker.list"]}`), &b); err != nil {
t.Fatal(err)
}
if !strSliceEqual(b.Serves, []string{"seat:mesh-broker.snapshot-get", "seat:mesh-broker.list"}) {
t.Errorf("serves = %v", b.Serves)
// Design 47 G A-115, A-127: `serves` is a field of the process beside its bus; inside the bus it is refused.
func TestProcessServesIsReadBesideTheBusAndRefusedInsideIt(t *testing.T) {
if err := json.Unmarshal([]byte(`{"serves":["seat:mesh-broker.list"]}`), &ProcessBus{}); err == nil {
t.Error("a serves inside bus was accepted")
}
if err := json.Unmarshal([]byte(`{"holds":["x"]}`), &ProcessBus{}); err == nil {
t.Error("an unknown key was accepted")
}
m := Manifest{Module: "nats", Resources: []map[string]any{
{"id": "keeper", "type": "process", "serves": []any{"seat:mesh-broker.snapshot-get", "seat:mesh-broker.list"},
"bus": map[string]any{"keeps": true}},
{"id": "plain", "type": "process", "serves": []any{"seat:mesh-broker.snapshot-remove"}},
{"id": "mute", "type": "process"},
}}
got := m.ProcessServes()
if !strSliceEqual(got["keeper"], []string{"seat:mesh-broker.snapshot-get", "seat:mesh-broker.list"}) ||
!strSliceEqual(got["plain"], []string{"seat:mesh-broker.snapshot-remove"}) || len(got) != 2 {
t.Errorf("process serves = %v", got)
}
accounts := m.BusAccounts()
if len(accounts) != 1 || !strSliceEqual(accounts[0].Serves, got["keeper"]) {
t.Errorf("the bus account's serves = %+v", accounts)
}
bad := Manifest{Resources: []map[string]any{{"id": "x", "type": "process", "serves": "seat:a.b"}}}
if _, err := ProcessServesOf(bad.Resources[0]); err == nil {
t.Error("a serves that is not a list was accepted")
}
}
+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)
}
}
+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-]*$`)