diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index 38c08f1f..b33f05c1 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -587,6 +587,11 @@ type Manifest struct { // person's account (design 25 §7) already had the same shape. Invokes []string `json:"invokes,omitempty"` + // InvokeFields maps a verb of Invokes to the fields it names, read from an entry of `invokes` in its long form + // {verb, fields}, which may stand beside `*` (design 47 G §617): Invokes keeps the verb, so none of its readers + // changes. A long-form entry naming no field is a verb of Invokes alone. + InvokeFields map[string][]string `json:"-"` + // Waits are the event families a session may wait on through the mesh MCP server's `mesh_wait`, each // `.` as `consumes` names one, a seat's or a module's (novox/hq ADR 0367 §2). Only the tool // runner's module declares them, one by one and never by a wildcard. **A grant apart from `consumes`**: the @@ -1252,6 +1257,11 @@ type Artifact struct { // validator checks their form. Calls []string `json:"calls,omitempty"` + // States are the capability words (design 47 G §488) the tool runner's artifact states, the edge of a package's Calls + // running to the module that states each (§545). Read here so the second reader release keeps a manifest that names + // them (§621). + States []string `json:"states,omitempty"` + // GeneratedFrom marks a package of a contracts module (novox/hq ADR 0608 §5, design 26 *Contracts modules*): the // directory, relative to the module's, of the seat definitions every file of the package is generated from. A // module carrying it holds contracts only and claims no seat, which the judge refuses naming the seat; the build @@ -1578,6 +1588,10 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error { if err := consumesEntryProblem(keys["consumes"]); err != nil { return err } + invokeFields, err := readInvokes(keys) + if err != nil { + return err + } plain := map[string]string{} many := map[string]map[string]string{} if secrets, ok := keys["secrets"]; ok && string(secrets) != "null" { @@ -1732,6 +1746,7 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error { m.forms = forms m.OptionalRequires = optionalRequires m.RequireShares = requireShares + m.InvokeFields = invokeFields if len(plain) > 0 { m.Secrets = plain } @@ -1750,6 +1765,61 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error { return nil } +// invokeLongForm is an entry of `invokes` in its long form: the verb, and the fields of its answer the caller relies on +// (design 47 G §617). +type invokeLongForm struct { + Verb string `json:"verb"` + Fields []string `json:"fields,omitempty"` +} + +// readInvokes reads `invokes` whose entries are verbs or, in their long form, {verb, fields}: it puts the verbs back in +// keys as plain names and answers the fields each long-form entry names. +func readInvokes(keys map[string]json.RawMessage) (map[string][]string, error) { + raw, ok := keys["invokes"] + if !ok || string(raw) == "null" { + return nil, nil + } + var entries []json.RawMessage + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, nil // not a list: the field's own decoder says so + } + var verbs []string + var fields map[string][]string + for i, e := range entries { + trimmed := bytes.TrimSpace(e) + if len(trimmed) == 0 || trimmed[0] != '{' { + var verb string + if err := json.Unmarshal(trimmed, &verb); err != nil { + return nil, fmt.Errorf("invokes[%d]: a verb, or {verb, fields}: %w", i, err) + } + verbs = append(verbs, verb) + continue + } + var long invokeLongForm + dec := json.NewDecoder(bytes.NewReader(trimmed)) + dec.DisallowUnknownFields() + if err := dec.Decode(&long); err != nil { + return nil, fmt.Errorf("invokes[%d]: a verb, or {verb, fields}: %w", i, err) + } + if long.Verb == "" { + return nil, fmt.Errorf("invokes[%d]: a long-form entry names its verb", i) + } + verbs = append(verbs, long.Verb) + if len(long.Fields) > 0 { + if fields == nil { + fields = map[string][]string{} + } + fields[long.Verb] = long.Fields + } + } + plain, err := json.Marshal(verbs) + if err != nil { + return nil, err + } + keys["invokes"] = plain + return fields, nil +} + // checkSharesNotContributed refuses a module whose own `contributes` to s3-bucket names `shares` (ADR 0468 §8). func (m Manifest) checkSharesNotContributed() error { if len(m.RequireShares[BucketRequirement]) == 0 { @@ -1803,13 +1873,30 @@ func (m Manifest) marshalFields() ([]byte, error) { if err != nil { return nil, err } - if len(m.SecretsMany) == 0 && len(m.ContributesMany) == 0 && len(m.OptionalRequires) == 0 && len(m.RequireShares) == 0 { + if len(m.SecretsMany) == 0 && len(m.ContributesMany) == 0 && len(m.OptionalRequires) == 0 && len(m.RequireShares) == 0 && + len(m.InvokeFields) == 0 { return raw, nil } var keys map[string]json.RawMessage if err := json.Unmarshal(raw, &keys); err != nil { return nil, err } + if len(m.InvokeFields) > 0 { + // Each verb that names fields is written back in its long form (design 47 G §617). + entries := make([]any, 0, len(m.Invokes)) + for _, verb := range m.Invokes { + if fields, named := m.InvokeFields[verb]; named { + entries = append(entries, invokeLongForm{Verb: verb, Fields: fields}) + continue + } + entries = append(entries, verb) + } + listed, err := json.Marshal(entries) + if err != nil { + return nil, err + } + keys["invokes"] = listed + } if optional := optionalRequiresList(m); len(optional) > 0 { // Beside `requires`, never inside it: an older controller reads `requires` as plain names, and one // manifest it cannot read stops the whole catalogue (OptionalRequiresKey). diff --git a/go/catalogue/reader_release_test.go b/go/catalogue/reader_release_test.go index fccd9ba5..064685be 100644 --- a/go/catalogue/reader_release_test.go +++ b/go/catalogue/reader_release_test.go @@ -1,6 +1,13 @@ package catalogue -import "testing" +import ( + "encoding/json" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) // A manifest carrying the words design 47 G writes is read whole by the reader release (design 47 G §340, §409): // `version` as MAJOR.MINOR (§368), a `*` emitter in `consumes` (§386), an observer's finding stream (§387) and an @@ -23,3 +30,74 @@ func TestTheReaderReleaseReadsTheManifestWordsGWrites(t *testing.T) { t.Fatalf("read as %+v", m) } } + +// The second reader release reads the words G's manifests write beyond the first's (design 47 G §494, §621): each row a +// manifest read whole, or refused naming what is wrong, and what the reader gives of it. +func TestTheSecondReaderReleaseReadsGsManifestWords(t *testing.T) { + for _, c := range []struct { + name, raw, refused string + check func(Manifest) bool + }{ + {name: "a state's cap (§469)", raw: `{"module":"m","version":"1.0","state":["plain",{"name":"pipelines","max-mib":256}]}`, + check: func(m Manifest) bool { return m.State[0].MaxMiB == 0 && m.State[1].MaxMiB == 256 }}, + {name: "a state's cap out of the objects section's bounds (§469)", + raw: `{"module":"m","version":"1.0","state":[{"name":"pipelines","max-mib":0}]}`, refused: "max-mib is between 1 and 8192"}, + {name: "a package's calls and the tool runner's states (§605, §545)", + raw: `{"module":"m","version":"1.0","build":{"artifacts":[{"name":"go","kind":"package","language":"go","calls":["put-sessions"]},` + + `{"name":"runtime","kind":"bundle","language":"go","states":["put-sessions","finding-calls"]}]}}`, + check: func(m Manifest) bool { + a := m.Build.Artifacts + return len(a) == 2 && len(a[0].Calls) == 1 && a[0].Calls[0] == "put-sessions" && len(a[1].States) == 2 + }}, + {name: "* with two long-form invokes (§617)", + raw: `{"module":"m","version":"1.0","invokes":["*",{"verb":"seat:mesh-catalog.revision","fields":["none-yet"]},` + + `{"verb":"seat:mesh-catalog.entry"}]}`, + check: func(m Manifest) bool { + out, _ := json.Marshal(m) + return slices.Equal(m.Invokes, []string{"*", "seat:mesh-catalog.revision", "seat:mesh-catalog.entry"}) && + len(m.InvokeFields) == 1 && slices.Equal(m.InvokeFields["seat:mesh-catalog.revision"], []string{"none-yet"}) && + strings.Contains(string(out), `"invokes":["*",{"verb":"seat:mesh-catalog.revision","fields":["none-yet"]},"seat:mesh-catalog.entry"]`) + }}, + {name: "a long-form invoke with a word it does not know (§617)", + raw: `{"module":"m","version":"1.0","invokes":[{"verb":"seat:mesh-catalog.revision","field":["none-yet"]}]}`, + refused: `invokes[0]: a verb, or {verb, fields}`}, + } { + m, err := decodeForTest([]byte(c.raw)) + switch { + case c.refused != "" && (err == nil || !strings.Contains(err.Error(), c.refused)): + t.Errorf("%s: read as %v, want refused naming %q", c.name, err, c.refused) + case c.refused == "" && err != nil: + t.Errorf("%s: refused: %v", c.name, err) + case c.refused == "" && (len(m.UnknownField()) != 0 || !c.check(m)): + t.Errorf("%s: read as %+v (set aside %v)", c.name, m, m.UnknownField()) + } + } +} + +// A consumed view of a seat, `seat:.views.`, is read apart from a consumed event, and a seat's compiled form +// names the states its definition offers as views (design 47 G §616, §699, §621). +func TestTheSecondReaderReleaseReadsASeatNamedView(t *testing.T) { + for _, row := range []struct { + consume, seat, state string + ok, bad bool + }{ + {"seat:mesh-module-assigner.views.assignments", "mesh-module-assigner", "assignments", true, false}, + {"mesh-delivery-assigner.views.assignments", "", "", false, false}, + {"seat:mesh-module-assigner.assignments", "", "", true, true}, + {"seat:.views.assignments", "", "", true, true}, + } { + seat, state, ok, err := SeatViewConsume(row.consume) + if seat != row.seat || state != row.state || ok != row.ok || (err != nil) != row.bad { + t.Errorf("SeatViewConsume(%q) = %q, %q, %v, %v", row.consume, seat, state, ok, err) + } + } + m, err := decodeForTest([]byte(`{"module":"m","version":"1.0","consumes":["seat:mesh-module-assigner.views.assignments"]}`)) + if err != nil || len(m.UnknownField()) != 0 || m.Consumes[0] != "seat:mesh-module-assigner.views.assignments" { + t.Errorf("a seat-named view in consumes read as %v, %v", m.Consumes, err) + } + s := SeatFromDefinition(seatdef.Definition{Seat: "mesh-module-assigner", Scope: "mesh", + States: []seatdef.State{{Name: "assignments", View: true}, {Name: "placement"}}}) + if !slices.Equal(s.Views, []string{"assignments"}) { + t.Errorf("the seat's views = %v", s.Views) + } +}