node-engine: the verbs seat-list and seat-list-again (the operator and the healers) and the state findings, with fixtures (ADR 0636 §1, §7; issue 1966)

This commit is contained in:
2026-11-05 14:34:07 +01:00
parent af804f5abf
commit f4a69e85f2
6 changed files with 629 additions and 1 deletions
+156 -1
View File
@@ -25,6 +25,8 @@ const (
VerbCompose = "compose"
VerbComposeCandidate = "compose-candidate"
VerbManifestReads = "manifest-reads"
VerbSeatList = "seat-list"
VerbSeatListAgain = "seat-list-again"
)
const (
@@ -36,7 +38,7 @@ const (
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
func Verbs() []string {
return []string{VerbHandOver, VerbAccessAct, VerbJudgeDirectories, VerbSetuidSearch, VerbRootFree, VerbActs, VerbPackages, VerbComposition, VerbCompose, VerbComposeCandidate, VerbManifestReads}
return []string{VerbHandOver, VerbAccessAct, VerbJudgeDirectories, VerbSetuidSearch, VerbRootFree, VerbActs, VerbPackages, VerbComposition, VerbCompose, VerbComposeCandidate, VerbManifestReads, VerbSeatList, VerbSeatListAgain}
}
// Events is every event published on the seat's subject: a new copy each call.
@@ -70,6 +72,7 @@ const (
StateData = "data"
StatePackages = "packages"
StateHealth = "health"
StateFindings = "findings"
)
// HaveByNodeModuleKey is the key of one value of the state have under the form "<node>.<module>".
@@ -280,6 +283,37 @@ func DecodeHealthByNodeValue(raw []byte) (HealthByNodeValue, error) {
return v, nil
}
// FindingsByKeyKey is the key of one value of the state findings under the form "<key>".
func FindingsByKeyKey(key string) string { return key }
// ParseFindingsByKeyKey reads the parts of a key of the form "<key>", in the order of the form; the last result is false for a key of another form.
func ParseFindingsByKeyKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeFindingsByKeyValue reads one value of the state findings under the form "<key>", refusing a field FindingsByKeyValue does not know and any text after the value.
func DecodeFindingsByKeyValue(raw []byte) (FindingsByKeyValue, error) {
var v FindingsByKeyValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// Act is one act asked through the seat, as acts reads it (ADR 0563 §9).
type Act struct {
// the act: hand-over, access-act, judge-directories or setuid-search
@@ -371,6 +405,40 @@ type Asked struct {
Says string `json:"says"`
}
// FindingAction is one answer the operator may give from a notification.
type FindingAction struct {
Arguments map[string]string `json:"arguments,omitempty"`
Label string `json:"label"`
Level string `json:"level,omitempty"`
Machine string `json:"machine,omitempty"`
// the seat verb the operator channel calls
Verb string `json:"verb"`
}
// The values of FindingAction's level.
const (
FindingActionLevelAcknowledge = "acknowledge"
FindingActionLevelApprove = "approve"
)
// FindingEvidence is one observation, as it was said.
type FindingEvidence struct {
At string `json:"at"`
Said string `json:"said"`
}
// FindingSubject is what the condition is about.
type FindingSubject struct {
// the other machines it concerns
Also []string `json:"also,omitempty"`
// its id within the scope
ID string `json:"id"`
// the machine it concerns, when there is one
Machine string `json:"machine,omitempty"`
// machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module
Scope string `json:"scope"`
}
// ManifestReadsChanged is what a node-engine says when what it reads changed or its node returned (ADR 0618 §4).
type ManifestReadsChanged struct {
// when it says it, RFC 3339
@@ -450,6 +518,42 @@ type Said struct {
Said string `json:"said"`
}
// SeatListStatus is a type of the contract.
type SeatListStatus struct {
// the seat mesh-catalog has answered
Heard bool `json:"heard"`
Held []SeatListHeld `json:"held"`
LastError string `json:"last-error,omitempty"`
// a trunk definition that does not parse, by seat: the one held stands
Refused map[string]string `json:"refused,omitempty"`
Revision int64 `json:"revision,omitempty"`
// the seats held from the bootstrap alone
Waiting []string `json:"waiting,omitempty"`
}
// SeatList is what an engine holds of the seat list (novox/hq ADR 0636 §7).
type SeatList struct {
// why the read failed, for seat-list-again
Error string `json:"error,omitempty"`
// the one line the engine says of it; empty when nothing waits
Health string `json:"health"`
Status SeatListStatus `json:"status"`
}
// SeatListHeld is a type of the contract.
type SeatListHeld struct {
// the digest of the definition held; empty for a bootstrapped one
Digest string `json:"digest,omitempty"`
Seat string `json:"seat"`
Source string `json:"source"`
}
// The values of SeatListHeld's source.
const (
SeatListHeldSourceTrunk = "trunk"
SeatListHeldSourceBootstrap = "bootstrap"
)
// HandOverInput is what hand-over takes.
type HandOverInput struct {
// the directory's absolute path, as the engine states it
@@ -773,6 +877,20 @@ type ManifestReadsAnswer struct {
V int64 `json:"v"`
}
// SeatListInput is what seat-list takes.
type SeatListInput struct {
}
// SeatListAnswer is what seat-list answers.
type SeatListAnswer = SeatList
// SeatListAgainInput is what seat-list-again takes.
type SeatListAgainInput struct {
}
// SeatListAgainAnswer is what seat-list-again answers.
type SeatListAgainAnswer = SeatList
// AskedEvent is the body of the event asked.
type AskedEvent = ActSaid
@@ -885,3 +1003,40 @@ type HealthByNodeValue struct {
Units map[string]any `json:"units,omitempty"`
V int64 `json:"v"`
}
// FindingsByKeyValue is one value of the state findings under the key <key>.
type FindingsByKeyValue struct {
Actions []FindingAction `json:"actions,omitempty"`
At string `json:"at"`
// newest first, at most 10
Evidence []FindingEvidence `json:"evidence,omitempty"`
Explanation string `json:"explanation,omitempty"`
Headline string `json:"headline,omitempty"`
// scope.id.token
Key string `json:"key"`
Kind string `json:"kind"`
Needs string `json:"needs,omitempty"`
// the module that wrote it
Observer string `json:"observer"`
// the key's last part when it is not the kind
Ref string `json:"ref,omitempty"`
Resolved string `json:"resolved,omitempty"`
// self, operator, agent or healer:<name>
Resolver string `json:"resolver,omitempty"`
// what of its own saw it last
Run string `json:"run,omitempty"`
Severity string `json:"severity"`
// the row, probe or event that saw it
Source string `json:"source"`
Subject FindingSubject `json:"subject"`
// one line
Summary string `json:"summary"`
// layout version, 1
V int64 `json:"v"`
}
// The values of FindingsByKeyValue's severity.
const (
FindingsByKeyValueSeverityUrgent = "urgent"
FindingsByKeyValueSeverityWarning = "warning"
)
+6
View File
@@ -35,6 +35,8 @@ func TestFixtures(t *testing.T) {
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-engine\\.event\\.manifest-reads-changed\\.[A-Za-z0-9_-]+$")},
{name: "the finding of a seat list unread, as the engine writes it", subject: "", given: "{\"at\":\"2026-11-05T14:00:00Z\",\"key\":\"machine.ace.seat-list-unread\",\"kind\":\"seat-list-unread\",\"observer\":\"mesh-host\",\"severity\":\"warning\",\"source\":\"node-engine.seat-list\",\"subject\":{\"id\":\"ace\",\"machine\":\"ace\",\"scope\":\"machine\"},\"summary\":\"the seat list has not been read for five minutes\",\"v\":1}", wire: "{\"at\":\"2026-11-05T14:00:00Z\",\"key\":\"machine.ace.seat-list-unread\",\"kind\":\"seat-list-unread\",\"observer\":\"mesh-host\",\"severity\":\"warning\",\"source\":\"node-engine.seat-list\",\"subject\":{\"id\":\"ace\",\"machine\":\"ace\",\"scope\":\"machine\"},\"summary\":\"the seat list has not been read for five minutes\",\"v\":1}",
decode: func(b []byte) (any, error) { var v FindingsByKeyValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "manifest-reads answers what the engine reads and when it stated it", subject: "", given: "{\"manifest-reads\":[\"form:seat-reference\",\"word:series\"],\"node\":\"ace\",\"stated-at\":\"2026-11-04T10:00:00Z\",\"v\":1}", wire: "{\"manifest-reads\":[\"form:seat-reference\",\"word:series\"],\"node\":\"ace\",\"stated-at\":\"2026-11-04T10:00:00Z\",\"v\":1}",
decode: func(b []byte) (any, error) { var v ManifestReadsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "manifest-reads of an engine built before it said them is the baseline", subject: "", given: "{\"node\":\"g14\",\"stated-at\":\"2026-11-04T10:05:00Z\",\"v\":1}", wire: "{\"node\":\"g14\",\"stated-at\":\"2026-11-04T10:05:00Z\",\"v\":1}",
@@ -45,6 +47,10 @@ func TestFixtures(t *testing.T) {
decode: func(b []byte) (any, error) { var v ComposeCandidateAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "acts answers each act kept", subject: "", given: "{\"acts\":[{\"act\":\"hand-over\",\"ask\":\"hand-over-1\",\"at\":\"2026-11-04T09:00:00Z\",\"caller\":\"mesh-tools\",\"expires\":\"2026-11-05T09:00:00Z\",\"id\":\"hand-over-1\",\"state\":\"asked\",\"subject\":\"/srv/media\"}],\"node\":\"ace\"}", wire: "{\"acts\":[{\"act\":\"hand-over\",\"ask\":\"hand-over-1\",\"at\":\"2026-11-04T09:00:00Z\",\"caller\":\"mesh-tools\",\"expires\":\"2026-11-05T09:00:00Z\",\"id\":\"hand-over-1\",\"state\":\"asked\",\"subject\":\"/srv/media\"}],\"node\":\"ace\"}",
decode: func(b []byte) (any, error) { var v ActsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "seat-list answers what the engine holds and what it waits for", subject: "", given: "{\"health\":\"the seat list is held from the bootstrap and nothing new is acted on\",\"status\":{\"heard\":false,\"held\":[{\"seat\":\"node-engine\",\"source\":\"bootstrap\"},{\"digest\":\"3f2a9c\",\"seat\":\"node-files\",\"source\":\"trunk\"}],\"last-error\":\"no answer\",\"waiting\":[\"node-engine\"]}}", wire: "{\"health\":\"the seat list is held from the bootstrap and nothing new is acted on\",\"status\":{\"heard\":false,\"held\":[{\"seat\":\"node-engine\",\"source\":\"bootstrap\"},{\"digest\":\"3f2a9c\",\"seat\":\"node-files\",\"source\":\"trunk\"}],\"last-error\":\"no answer\",\"waiting\":[\"node-engine\"]}}",
decode: func(b []byte) (any, error) { var v SeatListAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "seat-list-again answers the error when the read failed", subject: "", given: "{\"error\":\"seatlist: no answer\",\"health\":\"the seat list is held from the bootstrap\",\"status\":{\"heard\":false,\"held\":[{\"seat\":\"node-engine\",\"source\":\"bootstrap\"}],\"waiting\":[\"node-engine\"]}}", wire: "{\"error\":\"seatlist: no answer\",\"health\":\"the seat list is held from the bootstrap\",\"status\":{\"heard\":false,\"held\":[{\"seat\":\"node-engine\",\"source\":\"bootstrap\"}],\"waiting\":[\"node-engine\"]}}",
decode: func(b []byte) (any, error) { var v SeatListAgainAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))