diff --git a/sdk/findings/findings.go b/sdk/findings/findings.go new file mode 100644 index 00000000..e9d6f3c2 --- /dev/null +++ b/sdk/findings/findings.go @@ -0,0 +1,54 @@ +// Package findings is a part's own bucket `findings` as the state the seat mesh-conditions' client writes into +// (meshconditionsclient.FindingState), so a part gives mesh-sdk's views the FindingWriter of the seat's layout without +// each writing the same adapter over its bucket (novox/hq ADR 0564 §2, ADR 0635 §4). +package findings + +import ( + "context" + "encoding/json" + "errors" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// ErrUnbound says the bucket is not on the bus yet, so the finding is not written (the part says it in its log). +var ErrUnbound = errors.New("the findings state is not bound yet") + +// State is the bucket the part's state `findings` is, given when it is bound (process.AwaitedBucket.KV): nil is not bound yet. +type State struct { + Ctx context.Context + KV func() jetstream.KeyValue +} + +var _ meshconditionsclient.FindingState = State{} + +// Put writes one value under its key as JSON. +func (s State) Put(key string, value any) (uint64, error) { + kv := s.KV() + if kv == nil { + return 0, ErrUnbound + } + data, err := json.Marshal(value) + if err != nil { + return 0, err + } + return kv.Put(s.Ctx, key, data) +} + +// Delete clears one key. +func (s State) Delete(key string) error { + kv := s.KV() + if kv == nil { + return ErrUnbound + } + return kv.Delete(s.Ctx, key) +} + +// Writer is the part's views.FindingWriter over its bucket; the function is what views.ProcessReader.Findings and the +// emitter's Findings take. +func Writer(ctx context.Context, kv func() jetstream.KeyValue) views.FindingWriter { + return meshconditionsclient.FindingWriter{State: State{Ctx: ctx, KV: kv}} +} diff --git a/sdk/findings/findings_test.go b/sdk/findings/findings_test.go new file mode 100644 index 00000000..22d9d184 --- /dev/null +++ b/sdk/findings/findings_test.go @@ -0,0 +1,59 @@ +package findings + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/views" +) + +// fakeKV is the two calls a findings state makes of its bucket. +type fakeKV struct { + jetstream.KeyValue + held map[string][]byte +} + +func (f *fakeKV) Put(_ context.Context, key string, value []byte) (uint64, error) { + f.held[key] = value + return uint64(len(f.held)), nil +} + +func (f *fakeKV) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error { + delete(f.held, key) + return nil +} + +// A part's finding is written in the seat mesh-conditions' layout under the key the condition keeper reads, cleared by the +// same finding, and refused while the bucket is not bound (the part says that in its log). +func TestAFindingIsWrittenInTheSeatsLayoutAndClearedAndRefusedUnbound(t *testing.T) { + kv := &fakeKV{held: map[string][]byte{}} + var bound jetstream.KeyValue + w := Writer(context.Background(), func() jetstream.KeyValue { return bound }) + f := views.Finding{Observer: "mesh-delivery-teller", Kind: views.UnansweredKind, ID: "mesh-delivery-teller.asks", Summary: "s", Headline: "h", + Explanation: "e", At: time.Date(2026, 11, 5, 15, 0, 0, 0, time.UTC)} + if err := w.Put(f); !errors.Is(err, ErrUnbound) { + t.Fatalf("an unbound bucket took the finding: %v", err) + } + bound = kv + if err := w.Put(f); err != nil { + t.Fatal(err) + } + var got struct{ Key, Kind, Observer, Source string } + for key, value := range kv.held { + if err := json.Unmarshal(value, &got); err != nil || got.Key != key || got.Kind != views.UnansweredKind || + got.Observer != f.Observer || got.Source != f.Observer+"."+f.Kind { + t.Fatalf("%s holds %s (%v)", key, value, err) + } + } + if len(kv.held) != 1 { + t.Fatalf("the bucket holds %d findings, want 1", len(kv.held)) + } + if err := w.Delete(f); err != nil || len(kv.held) != 0 { + t.Fatalf("the finding is not cleared: %v, %d left", err, len(kv.held)) + } +} diff --git a/sdk/go.mod b/sdk/go.mod index fcb6a170..5d0874d0 100644 --- a/sdk/go.mod +++ b/sdk/go.mod @@ -4,6 +4,8 @@ go 1.26.0 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f +require git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 + require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect require ( diff --git a/sdk/heard/heard.go b/sdk/heard/heard.go index 4c53f299..2bd9383c 100644 --- a/sdk/heard/heard.go +++ b/sdk/heard/heard.go @@ -7,11 +7,13 @@ package heard import ( + "encoding/json" "fmt" "strings" "time" - "git.novox.be/novox/mesh-sdk/go/health" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-sdk/go/views" ) // Kind is the finding's kind. @@ -23,7 +25,7 @@ func ID(observer, family string) string { return observer + "." + family } // Key is the finding's key in the observer's findings state. func Key(observer, family string) string { - return health.FindingKey(health.ScopeModule, ID(observer, family), Kind) + return meshconditionsclient.Key(meshconditionsclient.ScopeModule, ID(observer, family), Kind) } // Words are the finding's words: its summary, headline and explanation. consequence says what the observer misses while @@ -41,14 +43,16 @@ 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, as health.Finding encodes it. +// Finding is observer's finding that it does not hear family, in the layout of the seat mesh-conditions' state findings. func Finding(observer, family, consequence string, why error, at time.Time) (string, []byte, error) { - key := Key(observer, family) summary, headline, explanation := Words(observer, family, consequence, why) - value, err := health.EncodeFinding(health.Finding{Key: key, Kind: Kind, Subject: health.Subject{Scope: health.ScopeModule, ID: ID(observer, family)}, - Severity: health.Warning, Summary: summary, Headline: headline, Explanation: explanation, - Evidence: []health.Evidence{{At: at.UTC(), Said: summary}}, Source: observer + "." + Kind, Observer: observer, At: at.UTC()}) - return key, value, err + 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 + } + value, err := json.Marshal(v) + return v.Key, value, err } // Writer writes and deletes the finding in the observer's findings state. Put or Delete nil writes nothing, said in the