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/operatorask/operatorask.go b/go/operatorask/operatorask.go index 82121a28..2c51d004 100644 --- a/go/operatorask/operatorask.go +++ b/go/operatorask/operatorask.go @@ -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:.` or `.` 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. diff --git a/go/operatorask/operatorask_test.go b/go/operatorask/operatorask_test.go index c0fe2a3d..746df64f 100644 --- a/go/operatorask/operatorask_test.go +++ b/go/operatorask/operatorask_test.go @@ -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 diff --git a/go/seatlist/holder.go b/go/seatlist/holder.go new file mode 100644 index 00000000..27a12e2d --- /dev/null +++ b/go/seatlist/holder.go @@ -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:.`, +// 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:.` or `.` 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) +} diff --git a/go/seatlist/holder_test.go b/go/seatlist/holder_test.go new file mode 100644 index 00000000..8a808db5 --- /dev/null +++ b/go/seatlist/holder_test.go @@ -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) + } + }) + } +} diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index 7892fbce..cb5940f1 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -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 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-]*$`)