K22 (finding helpers gone): meshconditionsclient keeps what concerns conditions, and heard builds the seat's generated finding
The helpers CheckFinding, FindingFor, FindingWriter, FindingState, FindingVersion, Observation, EncodeFinding, DecodeFinding, Says, FindingBucket, FindingsBucket, Raise, Clear and CallUnanswered go; mesh-sdk's writer is the one writer and checks. Importers outside this repository move in G's change (named in the line). Builds: design 47 G §689 (§461, §503, §640, §660) Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
package meshconditionsclient
|
||||
|
||||
import "time"
|
||||
|
||||
// KindCallUnanswered is the kind of a kept call still unanswered past CallUnansweredAfter (novox/hq ADR 0668 §3, issue
|
||||
// 1966): a call whose answer the caller keeps, asked again through every transport fault, that no answer has ended. The
|
||||
// caller writes it into its own findings, keeps the wait, and clears it when the call is answered.
|
||||
const KindCallUnanswered = "call-unanswered"
|
||||
|
||||
// CallUnansweredAfter is how long a kept call stays unanswered before it is a finding (ADR 0668 §3; ADR 0564 §2's bound).
|
||||
const CallUnansweredAfter = 5 * time.Minute
|
||||
@@ -1,17 +1,15 @@
|
||||
// Package meshconditionsclient is the logic every observer, healer and reader of conditions shares over the seat
|
||||
// mesh-conditions (novox/hq ADR 0564, ADR 0634 §4): how a finding's key is made and checked, the scopes and the bounds,
|
||||
// mesh-conditions (novox/hq ADR 0564, ADR 0634 §4): how a finding's key is made, the scopes and the bounds,
|
||||
// the order a person reads conditions in, and the answers a notification offers. The layouts are the seat's contract's.
|
||||
package meshconditionsclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
// The two severities, how soon the operator is needed.
|
||||
@@ -111,39 +109,6 @@ func SilenceAction(key string) seat.Action {
|
||||
Arguments: map[string]string{"key": key, "for": "7d", "why": "", "cause": CauseOperatorAnswer}}
|
||||
}
|
||||
|
||||
// CheckFinding refuses a finding the keeper could not make a condition of, naming why.
|
||||
func CheckFinding(f seat.FindingsByKeyValue) error {
|
||||
known := false
|
||||
for _, s := range Scopes {
|
||||
known = known || s == f.Subject.Scope
|
||||
}
|
||||
token := f.Ref
|
||||
if token == "" {
|
||||
token = f.Kind
|
||||
}
|
||||
switch {
|
||||
case !known:
|
||||
return fmt.Errorf("health: a finding's scope is one of %s, not %q", strings.Join(Scopes, ", "), f.Subject.Scope)
|
||||
case strings.TrimSpace(f.Subject.ID) == "":
|
||||
return fmt.Errorf("health: a %s finding names what it is about", f.Subject.Scope)
|
||||
case strings.TrimSpace(f.Kind) == "":
|
||||
return fmt.Errorf("health: the finding %s has no kind", f.Key)
|
||||
case f.Key != Key(f.Subject.Scope, f.Subject.ID, token):
|
||||
return fmt.Errorf("health: the finding's key %q is not %q, its scope, id and kind", f.Key, Key(f.Subject.Scope, f.Subject.ID, token))
|
||||
case f.Severity != Urgent && f.Severity != Warning:
|
||||
return fmt.Errorf("health: the finding %s is urgent or a warning, not %q", f.Key, f.Severity)
|
||||
case strings.TrimSpace(f.Summary) == "":
|
||||
return fmt.Errorf("health: the finding %s says nothing", f.Key)
|
||||
case strings.TrimSpace(f.Source) == "":
|
||||
return fmt.Errorf("health: the finding %s does not say what saw it", f.Key)
|
||||
case strings.TrimSpace(f.Observer) == "":
|
||||
return fmt.Errorf("health: the finding %s does not name its observer", f.Key)
|
||||
case len(f.Evidence) > KeptEvidence:
|
||||
return fmt.Errorf("health: the finding %s keeps %d observations, at most %d", f.Key, len(f.Evidence), KeptEvidence)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Order sorts conditions as a person reads them: urgent first, then oldest raised, then by key.
|
||||
func Order(list []seat.ConditionsByKeyValue) {
|
||||
raised := func(c seat.ConditionsByKeyValue) time.Time { return c.Raised }
|
||||
@@ -171,44 +136,3 @@ func SilencedAt(c seat.ConditionsByKeyValue, now time.Time) bool {
|
||||
func Escalated(c seat.ConditionsByKeyValue) bool {
|
||||
return c.Resolver == ResolverOperator && len(c.Tried) > 0
|
||||
}
|
||||
|
||||
// FindingVersion is the layout version of a finding's value.
|
||||
const FindingVersion = 1
|
||||
|
||||
// FindingState is a part's own state `findings`, where the condition keeper reads what a part saw: the runtime's kept
|
||||
// state (mesh-sdk's KeptState) satisfies it.
|
||||
type FindingState interface {
|
||||
Put(key string, value any) (uint64, error)
|
||||
Delete(key string) error
|
||||
}
|
||||
|
||||
// FindingWriter writes and clears a part's findings of a module in its own state `findings`, in this seat's layout; it
|
||||
// is mesh-sdk's views.FindingWriter. A finding is a warning about the module its ID names, and is checked
|
||||
// (CheckFinding) before it is written.
|
||||
type FindingWriter struct{ State FindingState }
|
||||
|
||||
var _ views.FindingWriter = FindingWriter{}
|
||||
|
||||
// FindingFor is the value and key of the seat's finding for f.
|
||||
func FindingFor(f views.Finding) seat.FindingsByKeyValue {
|
||||
at := f.At.UTC()
|
||||
return seat.FindingsByKeyValue{V: FindingVersion, Key: Key(ScopeModule, f.ID, f.Kind), Kind: f.Kind,
|
||||
Subject: seat.Subject{Scope: ScopeModule, ID: f.ID}, Severity: Warning, Summary: f.Summary, Headline: f.Headline,
|
||||
Explanation: f.Explanation, Evidence: []seat.Evidence{{At: at, Said: f.Summary}}, Source: f.Observer + "." + f.Kind,
|
||||
Observer: f.Observer, At: at}
|
||||
}
|
||||
|
||||
// Put writes the finding.
|
||||
func (w FindingWriter) Put(f views.Finding) error {
|
||||
v := FindingFor(f)
|
||||
if err := CheckFinding(v); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := w.State.Put(v.Key, v)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete clears the finding.
|
||||
func (w FindingWriter) Delete(f views.Finding) error {
|
||||
return w.State.Delete(Key(ScopeModule, f.ID, f.Kind))
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package meshconditionsclient
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
// ADR 0564 clause 3: the key rule, the checks of a finding, the order and the silence a person reads conditions by.
|
||||
@@ -19,33 +15,8 @@ func TestAFindingsKeyAndTheOrderOfConditions(t *testing.T) {
|
||||
if k := Key("call", "a b", ""); k != "call.a_b._" {
|
||||
t.Errorf("an unsafe part and an empty token: %s", k)
|
||||
}
|
||||
// Issue 1966, ADR 0668 §3: a kept call unanswered is a finding the keeper can make a condition of, keyed by what waits.
|
||||
o := CallUnanswered("ace", "seat:node-engine.manifest-reads", "ace", "no responders", "enginereads", time.Unix(0, 0))
|
||||
if f := o.Finding("mesh-delivery-assigner", "r1", time.Unix(300, 0)); o.Key() != "call.ace.call-unanswered" || CheckFinding(f) != nil {
|
||||
t.Errorf("a call unanswered: key %s, %v", o.Key(), CheckFinding(f))
|
||||
}
|
||||
good := seat.FindingsByKeyValue{Key: "machine.g14.silent", Kind: "silent", Severity: Warning, Summary: "x", Source: "S1",
|
||||
Observer: "mesh-delivery-watchman", Subject: seat.Subject{Scope: ScopeMachine, ID: "g14"}}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
change func(*seat.FindingsByKeyValue)
|
||||
refuse string
|
||||
}{
|
||||
{"a good finding", func(*seat.FindingsByKeyValue) {}, ""},
|
||||
{"an unknown scope", func(f *seat.FindingsByKeyValue) { f.Subject.Scope = "moon" }, "scope is one of"},
|
||||
{"a key that is not its subject's", func(f *seat.FindingsByKeyValue) { f.Key = "machine.g14.other" }, "is not"},
|
||||
{"a reference as the key's last part", func(f *seat.FindingsByKeyValue) { f.Ref = "gone"; f.Key = "machine.g14.gone" }, ""},
|
||||
{"no summary", func(f *seat.FindingsByKeyValue) { f.Summary = " " }, "says nothing"},
|
||||
{"no observer", func(f *seat.FindingsByKeyValue) { f.Observer = "" }, "observer"},
|
||||
{"a severity of neither kind", func(f *seat.FindingsByKeyValue) { f.Severity = "info" }, "urgent or a warning"},
|
||||
{"too much evidence", func(f *seat.FindingsByKeyValue) { f.Evidence = make([]seat.Evidence, KeptEvidence+1) }, "keeps 11"},
|
||||
} {
|
||||
f := good
|
||||
c.change(&f)
|
||||
err := CheckFinding(f)
|
||||
if (c.refuse == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.refuse)) {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
}
|
||||
if KindCallUnanswered != "call-unanswered" || CallUnansweredAfter != 5*time.Minute {
|
||||
t.Error("the kept call's finding kind and bound")
|
||||
}
|
||||
list := []seat.ConditionsByKeyValue{
|
||||
{Key: "b", Severity: Warning, Raised: when("2026-11-01T10:00:00Z")},
|
||||
@@ -69,44 +40,6 @@ func TestAFindingsKeyAndTheOrderOfConditions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type fakeFindings struct {
|
||||
put map[string]any
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (f *fakeFindings) Put(key string, value any) (uint64, error) {
|
||||
if f.put == nil {
|
||||
f.put = map[string]any{}
|
||||
}
|
||||
f.put[key] = value
|
||||
return 1, nil
|
||||
}
|
||||
func (f *fakeFindings) Delete(key string) error { f.deleted = append(f.deleted, key); return nil }
|
||||
|
||||
func TestTheFindingWriterWritesAndClearsAModulesFindingInTheSeatsLayout(t *testing.T) {
|
||||
state := &fakeFindings{}
|
||||
var w views.FindingWriter = FindingWriter{State: state}
|
||||
f := views.Finding{Observer: "mesh-config", Kind: "unanswered", ID: "mesh-config.mesh-vault.secrets", Summary: "no first view",
|
||||
Headline: "waits", Explanation: "look", At: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)}
|
||||
if err := w.Put(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := "module.mesh-config.mesh-vault.secrets.unanswered"
|
||||
v, ok := state.put[key].(seat.FindingsByKeyValue)
|
||||
if !ok || v.Key != key || v.Severity != Warning || v.Source != "mesh-config.unanswered" || v.Subject.Scope != ScopeModule || len(v.Evidence) != 1 {
|
||||
t.Fatalf("written %#v", state.put)
|
||||
}
|
||||
if err := CheckFinding(v); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := w.Delete(f); err != nil || len(state.deleted) != 1 || state.deleted[0] != key {
|
||||
t.Errorf("deleted %v: %v", state.deleted, err)
|
||||
}
|
||||
if err := w.Put(views.Finding{Observer: "x", Kind: "k", ID: "i"}); err == nil {
|
||||
t.Error("a finding that says nothing was written")
|
||||
}
|
||||
}
|
||||
|
||||
func when(s string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
@@ -116,85 +49,3 @@ func when(s string) time.Time {
|
||||
}
|
||||
|
||||
func ptr(t time.Time) *time.Time { return &t }
|
||||
|
||||
type fakeBucket struct {
|
||||
kept map[string][]byte
|
||||
puts, dels int
|
||||
}
|
||||
|
||||
func (b *fakeBucket) Get(key string) ([]byte, bool, error) { v, ok := b.kept[key]; return v, ok, nil }
|
||||
func (b *fakeBucket) Put(key string, v []byte) error {
|
||||
if b.kept == nil {
|
||||
b.kept = map[string][]byte{}
|
||||
}
|
||||
b.kept[key] = v
|
||||
b.puts++
|
||||
return nil
|
||||
}
|
||||
func (b *fakeBucket) Delete(key string) error { delete(b.kept, key); b.dels++; return nil }
|
||||
|
||||
// The goldens are what mesh-sdk's go/health EncodeFinding wrote for the same findings: the helpers write the same
|
||||
// values, read by the seat's strict decoder.
|
||||
func TestAnObservationIsWrittenAsMeshSDKsHealthWroteItsFinding(t *testing.T) {
|
||||
at := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
for _, c := range []struct {
|
||||
golden string
|
||||
o Observation
|
||||
run string
|
||||
}{
|
||||
{"finding_full.json", Observation{Scope: ScopeMachine, ID: "g14", Kind: "silent-kind", Ref: "silent", Machine: "g14", Also: []string{"ace"},
|
||||
Severity: Urgent, Summary: "g14 is silent", Said: "no pulse", Headline: "g14 is silent", Explanation: "It said nothing.",
|
||||
Resolved: "It speaks.", Needs: "look", Source: "S1", Resolver: "operator"}, "r1"},
|
||||
{"finding_minimal.json", Observation{Scope: ScopeModule, ID: "nats.g14", Kind: "build-failed", Summary: "failed",
|
||||
Source: "builder.build-failed"}, ""},
|
||||
} {
|
||||
raw, err := os.ReadFile("testdata/" + c.golden)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := seat.DecodeFindingsByKeyValue(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.golden, err)
|
||||
}
|
||||
observer := want.Observer
|
||||
got, err := EncodeFinding(c.o.Finding(observer, c.run, at))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.golden, err)
|
||||
}
|
||||
have, err := seat.DecodeFindingsByKeyValue(got)
|
||||
if err != nil || !reflect.DeepEqual(have, want) {
|
||||
t.Errorf("%s: wrote %s, want %s (%v)", c.golden, got, raw, err)
|
||||
}
|
||||
if back, err := DecodeFinding(raw); err != nil || !reflect.DeepEqual(back, want) {
|
||||
t.Errorf("%s: read back %v", c.golden, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaiseWritesWhenTheWordsChangeAndClearDeletesWhatIsThere(t *testing.T) {
|
||||
b := &fakeBucket{}
|
||||
o := Observation{Scope: ScopeModule, ID: "m", Kind: "k", Summary: "one", Source: "x.k"}
|
||||
at := when("2026-11-05T12:00:00Z")
|
||||
if w, err := Raise(b, "obs", "", o, at); err != nil || !w {
|
||||
t.Fatalf("first: %v %v", w, err)
|
||||
}
|
||||
if w, err := Raise(b, "obs", "r2", o, at.Add(time.Hour)); err != nil || w || b.puts != 1 {
|
||||
t.Fatalf("the same words later: wrote %v, %d puts, %v", w, b.puts, err)
|
||||
}
|
||||
o.Summary = "two"
|
||||
if w, err := Raise(b, "obs", "", o, at); err != nil || !w || b.puts != 2 {
|
||||
t.Fatalf("changed: %v %d %v", w, b.puts, err)
|
||||
}
|
||||
if _, err := Raise(b, "obs", "", Observation{Scope: "moon", ID: "m", Kind: "k"}, at); err == nil || b.puts != 2 {
|
||||
t.Errorf("a finding the keeper cannot use was written: %v", err)
|
||||
}
|
||||
if c, err := Clear(b, ScopeModule, "m", "k"); err != nil || !c || b.dels != 1 {
|
||||
t.Fatalf("clear: %v %v %d", c, err, b.dels)
|
||||
}
|
||||
if c, err := Clear(b, ScopeModule, "m", "k"); err != nil || c || b.dels != 1 {
|
||||
t.Errorf("clearing what is not there: %v %v %d", c, err, b.dels)
|
||||
}
|
||||
if FindingsBucket("obs") != "obs_findings" {
|
||||
t.Error(FindingsBucket("obs"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
)
|
||||
|
||||
// Kind is the finding's kind.
|
||||
@@ -43,16 +43,18 @@ func Words(observer, family, consequence string, why error) (summary, headline,
|
||||
return summary, headline, explanation
|
||||
}
|
||||
|
||||
// Finding is observer's finding that it does not hear family, in the layout of the seat mesh-conditions' state findings.
|
||||
// Finding is observer's finding that it does not hear family, as the seat mesh-conditions' generated type
|
||||
// (design 47 G 689): its key and its value. The check of its rules is mesh-sdk's writer's, which refuses a finding
|
||||
// breaking them before it writes.
|
||||
func Finding(observer, family, consequence string, why error, at time.Time) (string, []byte, error) {
|
||||
summary, headline, explanation := Words(observer, family, consequence, why)
|
||||
v := meshconditionsclient.FindingFor(views.Finding{Observer: observer, Kind: Kind, ID: ID(observer, family), Summary: summary,
|
||||
Headline: headline, Explanation: explanation, At: at})
|
||||
if err := meshconditionsclient.CheckFinding(v); err != nil {
|
||||
return v.Key, nil, err
|
||||
}
|
||||
at = at.UTC()
|
||||
v := seat.Finding{Scope: meshconditionsclient.ScopeModule, ID: ID(observer, family), Kind: Kind,
|
||||
Subject: seat.Subject{Scope: meshconditionsclient.ScopeModule, ID: ID(observer, family)},
|
||||
Severity: meshconditionsclient.Warning, Summary: summary, Headline: headline, Explanation: explanation,
|
||||
Evidence: []seat.Evidence{{At: at, Said: summary}}, Source: observer + "." + Kind, Observer: observer, At: at, Since: at}
|
||||
value, err := json.Marshal(v)
|
||||
return v.Key, value, err
|
||||
return Key(observer, family), value, err
|
||||
}
|
||||
|
||||
// Writer writes and deletes the finding in the observer's findings state. Put or Delete nil writes nothing, said in the
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
)
|
||||
|
||||
// A consumer that does not bind writes the finding, with why, under the observer's findings key; the one that binds
|
||||
@@ -22,6 +24,11 @@ func TestBindWritesTheFindingUntilTheConsumerBinds(t *testing.T) {
|
||||
if err := w.Bind("placement", "the first view is the only picture", func() error { return errors.New("no consumer") }); err == nil {
|
||||
t.Fatal("a failed bind was not answered")
|
||||
}
|
||||
// Design 47 G 689: the value is the seat's generated finding, read by its strict decoder, key and subject by the rule.
|
||||
if f, err := seat.DecodeFindingsByScopeIDKindValue(puts[key]); err != nil || f.Observer != "mesh-x" || f.Kind != Kind ||
|
||||
f.Scope != "module" || f.ID != "mesh-x.placement" || f.Subject.ID != f.ID || f.Severity != "warning" || f.Source == "" {
|
||||
t.Errorf("the finding is not the seat's generated one: %+v (%v)", f, err)
|
||||
}
|
||||
if v := string(puts[key]); !strings.Contains(v, "no consumer") || !strings.Contains(v, "mesh-x does not hear placement") {
|
||||
t.Errorf("the finding is %q", v)
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
package meshconditionsclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
)
|
||||
|
||||
// What an observer says it sees wrong, raised and cleared as a finding in its own state `findings` (novox/hq ADR 0564
|
||||
// clause 2, ADR 0634 §4): one write shared by every observer (the self-check, the healers, the watchman, the builder,
|
||||
// the indexer, the node-engine), in this seat's layout. The condition keeper raises and clears the conditions from them.
|
||||
|
||||
// FindingsState is the name of an observer's state of findings.
|
||||
const FindingsState = "findings"
|
||||
|
||||
// FindingsBucket is the bucket of an observer's findings, `<module>_findings`.
|
||||
func FindingsBucket(observer string) string { return observer + "_" + FindingsState }
|
||||
|
||||
// Observation is one thing an observer sees wrong now: what it is about (Scope, ID, Machine, Also), which kind of fault
|
||||
// (Kind; Ref when the key's last part is not the kind), and its words.
|
||||
type Observation struct {
|
||||
Scope, ID, Kind, Ref, Machine string
|
||||
Also []string
|
||||
// Severity is Urgent or Warning; Warning when empty.
|
||||
Severity string
|
||||
// Summary is one line; Said the one observation kept as evidence (the summary when empty).
|
||||
Summary, Said string
|
||||
Headline, Explanation, Resolved, Needs string
|
||||
// Source is the row, probe or event that saw it.
|
||||
Source string
|
||||
// Resolver is who resolves it, self when empty.
|
||||
Resolver string
|
||||
Actions []seat.Action
|
||||
// Since is when the finding is stamped; the zero value leaves it to the caller's moment.
|
||||
Since time.Time
|
||||
}
|
||||
|
||||
// Key is the finding's key: `<scope>.<id>.<token>`, the token Ref or else Kind.
|
||||
func (o Observation) Key() string {
|
||||
token := o.Ref
|
||||
if token == "" {
|
||||
token = o.Kind
|
||||
}
|
||||
return Key(o.Scope, o.ID, token)
|
||||
}
|
||||
|
||||
// Finding is the observation as the seat's finding written by observer in run, at the moment at.
|
||||
func (o Observation) Finding(observer, run string, at time.Time) seat.FindingsByKeyValue {
|
||||
at = at.UTC()
|
||||
said := o.Said
|
||||
if said == "" {
|
||||
said = o.Summary
|
||||
}
|
||||
sev := o.Severity
|
||||
if sev == "" {
|
||||
sev = Warning
|
||||
}
|
||||
f := seat.FindingsByKeyValue{V: FindingVersion, Key: o.Key(), Kind: o.Kind,
|
||||
Subject: seat.Subject{Scope: o.Scope, ID: o.ID, Machine: o.Machine, Also: o.Also},
|
||||
Severity: sev, Summary: o.Summary, Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved,
|
||||
Needs: o.Needs, Actions: o.Actions, Evidence: []seat.Evidence{{At: at, Said: said}}, Source: o.Source,
|
||||
Resolver: o.Resolver, Observer: observer, Run: run, At: at}
|
||||
if o.Ref != "" && o.Ref != o.Kind {
|
||||
f.Ref = o.Ref
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// EncodeFinding is f as JSON at FindingVersion; it refuses what CheckFinding refuses.
|
||||
func EncodeFinding(f seat.FindingsByKeyValue) ([]byte, error) {
|
||||
f.V = FindingVersion
|
||||
if f.Evidence == nil {
|
||||
f.Evidence = []seat.Evidence{}
|
||||
}
|
||||
if err := CheckFinding(f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(f)
|
||||
}
|
||||
|
||||
// DecodeFinding reads a finding's value; it refuses another version and what CheckFinding refuses. A finding written
|
||||
// before the field was named `ref` (novox/hq issue 1952) is read by its old name, `token`.
|
||||
func DecodeFinding(data []byte) (seat.FindingsByKeyValue, error) {
|
||||
f, err := seat.DecodeFindingsByKeyValue(data)
|
||||
if err != nil {
|
||||
return seat.FindingsByKeyValue{}, fmt.Errorf("health: a finding is not readable: %w", err)
|
||||
}
|
||||
if f.V != FindingVersion {
|
||||
return seat.FindingsByKeyValue{}, fmt.Errorf("health: a finding of version %d, and this reads %d", f.V, FindingVersion)
|
||||
}
|
||||
if err := CheckFinding(f); err != nil {
|
||||
return seat.FindingsByKeyValue{}, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Says is what a finding says, the moments and the run that wrote it aside: two findings that say the same are one
|
||||
// write (novox/hq ADR 0289 §1), so the condition moves only when the words do.
|
||||
func Says(f seat.FindingsByKeyValue) string {
|
||||
f.At, f.Run, f.V = time.Time{}, "", 0
|
||||
ev := make([]seat.Evidence, len(f.Evidence))
|
||||
for i, e := range f.Evidence {
|
||||
ev[i] = seat.Evidence{Said: e.Said}
|
||||
}
|
||||
f.Evidence = ev
|
||||
raw, _ := json.Marshal(f)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// FindingBucket is the part of an observer's state `findings` that Raise and Clear use.
|
||||
type FindingBucket interface {
|
||||
// Get answers the value at key; found is false when there is none.
|
||||
Get(key string) (value []byte, found bool, err error)
|
||||
Put(key string, value []byte) error
|
||||
Delete(key string) error
|
||||
}
|
||||
|
||||
// Raise writes the observation as observer's finding in run at at, only when what it says differs from the finding kept
|
||||
// (so the keeper's condition moves only then); wrote says whether it wrote. A finding the keeper could not make a
|
||||
// condition of is refused, nothing written.
|
||||
func Raise(b FindingBucket, observer, run string, o Observation, at time.Time) (wrote bool, err error) {
|
||||
f := o.Finding(observer, run, at)
|
||||
value, err := EncodeFinding(f)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if kept, found, err := b.Get(f.Key); err != nil {
|
||||
return false, err
|
||||
} else if found {
|
||||
if old, err := DecodeFinding(kept); err == nil && Says(old) == Says(f) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, b.Put(f.Key, value)
|
||||
}
|
||||
|
||||
// Clear deletes the finding about scope and id of kind (its token); one not there is nothing to clear, and cleared says
|
||||
// whether one was.
|
||||
func Clear(b FindingBucket, scope, id, token string) (cleared bool, err error) {
|
||||
key := Key(scope, id, token)
|
||||
if _, found, err := b.Get(key); err != nil || !found {
|
||||
return false, err
|
||||
}
|
||||
return true, b.Delete(key)
|
||||
}
|
||||
|
||||
// KindCallUnanswered is the kind of a kept call still unanswered past CallUnansweredAfter (novox/hq ADR 0668 §3, issue
|
||||
// 1966): a call whose answer the caller keeps, asked again through every transport fault, that no answer has ended. The
|
||||
// caller writes it into its own findings, keeps the wait, and clears it when the call is answered.
|
||||
const KindCallUnanswered = "call-unanswered"
|
||||
|
||||
// CallUnansweredAfter is how long a kept call stays unanswered before it is a finding (ADR 0668 §3; ADR 0564 §2's bound).
|
||||
const CallUnansweredAfter = 5 * time.Minute
|
||||
|
||||
// CallUnanswered is the observation of a kept call to callee (a seat verb's or a tool's key, as process.Conn.Call names
|
||||
// it) about id (what the caller waits on: a node, a merge, a key), unanswered since since, its last fault said. Its key
|
||||
// is `call.<id>.call-unanswered`, cleared by Clear(b, ScopeCall, id, KindCallUnanswered).
|
||||
func CallUnanswered(id, callee, machine, fault, source string, since time.Time) Observation {
|
||||
return Observation{Scope: ScopeCall, ID: id, Kind: KindCallUnanswered, Machine: machine, Severity: Warning,
|
||||
Summary: fmt.Sprintf("the call %s about %s is unanswered since %s", callee, id, since.UTC().Format(time.RFC3339)),
|
||||
Said: fault,
|
||||
Headline: "A call whose answer is kept is not answered",
|
||||
Explanation: fmt.Sprintf("The call %s is made again on every transport fault and on the callee's event; it has "+
|
||||
"not been answered since %s. Its last fault: %s.", callee, since.UTC().Format(time.RFC3339), fault),
|
||||
Resolved: "the call is answered", Source: source}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"v":1,"key":"machine.g14.silent","kind":"silent-kind","ref":"silent","subject":{"scope":"machine","id":"g14","machine":"g14","also":["ace"]},"severity":"urgent","summary":"g14 is silent","headline":"g14 is silent","explanation":"It said nothing.","resolved":"It speaks.","needs":"look","evidence":[{"at":"2026-11-05T12:00:00Z","said":"no pulse"}],"source":"S1","resolver":"operator","observer":"mesh-delivery-watchman","run":"r1","at":"2026-11-05T12:00:00Z"}
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"v":1,"key":"module.nats.g14.build-failed","kind":"build-failed","subject":{"scope":"module","id":"nats.g14"},"severity":"warning","summary":"failed","headline":"","explanation":"","resolved":"","evidence":[{"at":"2026-11-05T12:00:00Z","said":"failed"}],"source":"builder.build-failed","observer":"mesh-delivery-builder","at":"2026-11-05T12:00:00Z"}
|
||||
Reference in New Issue
Block a user