diff --git a/go/catalogue/process_bus.go b/go/catalogue/process_bus.go index 6559e59b..f563eaf5 100644 --- a/go/catalogue/process_bus.go +++ b/go/catalogue/process_bus.go @@ -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 . or a role's // seat:. (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:.: 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:., 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:.: %w", err) + } + return list, nil +} diff --git a/go/catalogue/process_bus_test.go b/go/catalogue/process_bus_test.go index 08a7c8a9..1f778c94 100644 --- a/go/catalogue/process_bus_test.go +++ b/go/catalogue/process_bus_test.go @@ -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") + } } diff --git a/go/catalogue/seat_from_definition.go b/go/catalogue/seat_from_definition.go index 1acc52c6..38994426 100644 --- a/go/catalogue/seat_from_definition.go +++ b/go/catalogue/seat_from_definition.go @@ -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 { diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index aa225667..ca1fe51e 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -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:.` (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::}. Compiled, never stored: like the protocol, it is the mesh's diff --git a/go/catalogue/state.go b/go/catalogue/state.go index daf041a9..3608fad9 100644 --- a/go/catalogue/state.go +++ b/go/catalogue/state.go @@ -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:.` (design 47 G A-116, A-128). +const SeatReadPrefix = "seat:" + +// SeatRead splits a read of a seat holder's state, `seat:.`, into the seat and the local name; ok is false for a +// read that is not of this kind (a module's `.`, 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:.", 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, ".") diff --git a/go/catalogue/state_test.go b/go/catalogue/state_test.go index a5549cc1..f6dc005c 100644 --- a/go/catalogue/state_test.go +++ b/go/catalogue/state_test.go @@ -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:.` (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) + } +} diff --git a/go/views/findings.go b/go/views/findings.go index 2d1699c4..d80c2cf8 100644 --- a/go/views/findings.go +++ b/go/views/findings.go @@ -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) +} diff --git a/go/views/reader.go b/go/views/reader.go index 1cdf6065..9dcd3380 100644 --- a/go/views/reader.go +++ b/go/views/reader.go @@ -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 `.`). 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 `_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 `_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): `_first_views` says where each first view stands and // `_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 (.), 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{ diff --git a/go/views/views_test.go b/go/views/views_test.go index 55a83494..07bdd0ac 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -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, diff --git a/go/views/writer.go b/go/views/writer.go index 965556ac..befc8d92 100644 --- a/go/views/writer.go +++ b/go/views/writer.go @@ -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-]*$`)