Merge branch 'feat/47-G-fix9-builder2-sdk' into feat/47-G-fix9-checker
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ".")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
@@ -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-]*$`)
|
||||
|
||||
Reference in New Issue
Block a user