views: the hand-act recorder and the finding writer come from the caller as interfaces, no hand-acts or health import (ADR 0635 §4; issue 1966)

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-05 13:44:38 +01:00
parent e8ae3ec8d3
commit 351f8abba7
7 changed files with 115 additions and 96 deletions
+3 -14
View File
@@ -29,10 +29,7 @@ type Emitter struct {
TooLarge func(state, key string, size int)
// Findings writes and clears the owner's finding of a change not said past UnsaidAfter (UnsaidKind, its own state
// `findings`); nil says it in the log only.
Findings interface {
Put(key string, value []byte) error
Delete(key string) error
}
Findings FindingWriter
// UnsaidAfter is how long a change may stay not said before it is the owner's finding; zero is Bound.
UnsaidAfter time.Duration
// Wait pauses before a change not said is said again (its attempt so far); it answers false when ctx ended. Nil is
@@ -180,11 +177,7 @@ func (e Emitter) raiseUnsaid(state string, u jetstream.KeyValueEntry, first time
if e.Findings == nil {
return false
}
key, value, err := UnsaidFinding(e.Owner, state, u.Key(), u.Revision(), first, cause.Error(), e.now())
if err == nil {
err = e.Findings.Put(key, value)
}
if err != nil {
if err := e.Findings.Put(UnsaidFinding(e.Owner, state, u.Key(), u.Revision(), first, cause.Error(), e.now())); err != nil {
e.logf("views: the finding of %s's change not said could not be written: %v", state, err)
return false
}
@@ -193,11 +186,7 @@ func (e Emitter) raiseUnsaid(state string, u jetstream.KeyValueEntry, first time
// clearUnsaid clears the finding once the change is said; it answers whether it is cleared.
func (e Emitter) clearUnsaid(state string) bool {
key, _, err := UnsaidFinding(e.Owner, state, "", 0, time.Time{}, "", e.now())
if err == nil {
err = e.Findings.Delete(key)
}
if err != nil {
if err := e.Findings.Delete(UnsaidFinding(e.Owner, state, "", 0, time.Time{}, "", e.now())); err != nil {
e.logf("views: the finding of %s's change not said could not be cleared: %v", state, err)
return false
}
+26 -11
View File
@@ -4,8 +4,6 @@ import (
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
)
// The findings a views part writes in its own state `findings`, which the condition keeper raises and clears
@@ -17,9 +15,30 @@ const (
UnansweredKind = "first-view-unanswered"
)
// Finding is what a views part says is wrong with a state it owns or follows, in plain fields: Kind and ID name it (a module-scoped
// finding of the observer), Summary, Headline and Explanation say it. It carries no layout: the writer a part gives
// (FindingWriter) encodes it as the condition keeper reads it, which is the seat mesh-conditions' contract, not this
// package's (novox/hq ADR 0634 §4, ADR 0635 §4).
type Finding struct {
Observer string
Kind string
ID string
Summary string
Headline string
Explanation string
At time.Time
}
// FindingWriter writes and clears a part's findings in its own state `findings`, where the condition keeper raises and
// clears conditions from them (novox/hq ADR 0564 §2): the part gives the one that encodes a Finding as that state's layout.
type FindingWriter interface {
Put(f Finding) error
Delete(f Finding) error
}
// UnsaidFinding is the owner's finding of a change of state it has not said on its views family since since: every
// reader of the state stands still at the change before it until it is said, which clears this.
func UnsaidFinding(owner, state, key string, revision uint64, since time.Time, cause string, at time.Time) (string, []byte, error) {
func UnsaidFinding(owner, state, key string, revision uint64, since time.Time, cause string, at time.Time) Finding {
id := owner + "." + state
summary := fmt.Sprintf("%s has not said the change of %s.%s at %d on its views family since %s (%s): its readers stand still before it",
owner, state, key, revision, since.UTC().Format(time.RFC3339), strings.TrimSpace(cause))
@@ -30,7 +49,7 @@ func UnsaidFinding(owner, state, key string, revision uint64, since time.Time, c
}
// TooLargeFinding is the owner's finding of state's key too large to say whole on its views family.
func TooLargeFinding(owner, state, key string, size int, at time.Time) (string, []byte, error) {
func TooLargeFinding(owner, state, key string, size int, at time.Time) Finding {
id := owner + "." + state + "." + key
summary := fmt.Sprintf("%s's %s.%s holds %d bytes, more than one views event carries (%d): every reader asks its first view again at each change of it", owner, state, key, size, MaxValueBytes)
return finding(owner, TooLargeKind, id, summary,
@@ -41,7 +60,7 @@ func TooLargeFinding(owner, state, key string, size int, at time.Time) (string,
// UnansweredFinding is a reader's finding of its first view of s unanswered past its bound (ADR 0615 §6): it names the
// owner and the tool. A first view answered at revision 0 with nothing in it is said too, with the operator's step.
func UnansweredFinding(reader string, s Status, at time.Time) (string, []byte, error) {
func UnansweredFinding(reader string, s Status, at time.Time) Finding {
id := reader + "." + s.Owner + "." + s.State
summary := fmt.Sprintf("%s has no first view of %s's %s: %s has not answered since %s (%s)", reader, s.Owner, s.State, s.Tool,
s.Asked.Format(time.RFC3339), strings.TrimSpace(s.Error))
@@ -51,10 +70,6 @@ func UnansweredFinding(reader string, s Status, at time.Time) (string, []byte, e
"Look at %s's runtime of its own: is it running and connected?", reader, s.Owner, s.State, s.Tool, s.Owner), at)
}
func finding(observer, kind, id, summary, headline, explanation string, at time.Time) (string, []byte, error) {
key := health.FindingKey(health.ScopeModule, id, kind)
value, err := health.EncodeFinding(health.Finding{Key: key, Kind: kind, Subject: health.Subject{Scope: health.ScopeModule, ID: id},
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
func finding(observer, kind, id, summary, headline, explanation string, at time.Time) Finding {
return Finding{Observer: observer, Kind: kind, ID: id, Summary: summary, Headline: headline, Explanation: explanation, At: at.UTC()}
}
+22
View File
@@ -0,0 +1,22 @@
package views
import "errors"
// HandAct is a hand act a part records before it does it: what is done, why, and the cause and verb that name it.
type HandAct struct {
What, Why, Cause, Verb string
}
// HandActs records a hand act (novox/hq ADR 0565 clause 11) before the part does it; a part gives the one that records
// it through its hand-acts client. An act that is not taken returns an error that says nothing was recorded,
// and the part then does nothing. A nil HandActs records nothing, so a re-read by hand is refused.
type HandActs interface {
RecordHandAct(HandAct) error
}
func recordHandAct(acts HandActs, a HandAct) error {
if acts == nil {
return errors.New("no way to record a hand act is given")
}
return acts.RecordHandAct(a)
}
+7 -6
View File
@@ -36,7 +36,7 @@ type ProcessReader struct {
Call func(key string, body any) (json.RawMessage, error)
// Findings is the reader's own state `findings`, where the condition keeper raises and clears the condition of a
// first view unanswered past its bound (ADR 0615 §6); nil says it in the log only.
Findings func() jetstream.KeyValue
Findings func() FindingWriter
Logf func(string, ...any)
// Now, Bound and Wait are for tests: zero are the clock, Bound and five seconds.
Now func() time.Time
@@ -141,11 +141,12 @@ func (r *ProcessReader) finding(s Status, raised bool) {
if r.Now != nil {
at = r.Now()
}
key, value, err := UnansweredFinding(r.Module, s, at)
if err == nil && raised {
_, err = kv.Put(context.Background(), key, value)
} else if err == nil {
err = kv.Delete(context.Background(), key)
f := UnansweredFinding(r.Module, s, at)
var err error
if raised {
err = kv.Put(f)
} else {
err = kv.Delete(f)
}
if err != nil {
r.logf("views: the finding of the first view %s is not written: %v", s.Tool, err)
+27 -30
View File
@@ -10,7 +10,6 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/handacts"
)
// Reader is a module's pictures of the owners' states it used to read (novox/hq ADR 0615 §1): it
@@ -23,16 +22,13 @@ type Reader struct {
Module string
// Findings writes and clears this module's finding of an unanswered first view (its own state `findings`); nil
// says it in the log only.
Findings interface {
Put(key string, value []byte) error
Delete(key string) error
}
Logf func(string, ...any)
Findings FindingWriter
Logf func(string, ...any)
// Changed is told of each key a followed state changes, after the picture holds it (state is `<owner>.<state>`).
Changed func(state, key string, value json.RawMessage, deleted bool)
// Acts records a re-read by hand through the seat mesh-hand-acts before it is asked (the zero Recorder records nothing
// Acts records a re-read by hand before it is asked (nil records nothing
// and the re-read is refused).
Acts handacts.Recorder
Acts HandActs
// Now, Bound and Wait are for tests: zero are the clock, Bound and five seconds.
Now func() time.Time
Bound time.Duration
@@ -94,10 +90,6 @@ func (r *Reader) Follow(owner, state, tool string) *Follower {
return f
}
func (r *Reader) findingKey(s Status) (string, []byte, error) {
return UnansweredFinding(r.Module, s, r.nowTime())
}
func (r *Reader) nowTime() time.Time {
if r.Now != nil {
return r.Now().UTC()
@@ -110,11 +102,7 @@ func (r *Reader) raise(s Status) {
if r.Findings == nil {
return
}
key, value, err := r.findingKey(s)
if err == nil {
err = r.Findings.Put(key, value)
}
if err != nil {
if err := r.Findings.Put(UnansweredFinding(r.Module, s, r.nowTime())); err != nil {
r.logf("the finding of the unanswered first view %s could not be written: %v", s.Tool, err)
}
}
@@ -123,11 +111,7 @@ func (r *Reader) clear(s Status) {
if r.Findings == nil {
return
}
key, _, err := r.findingKey(s)
if err == nil {
err = r.Findings.Delete(key)
}
if err != nil {
if err := r.Findings.Delete(UnansweredFinding(r.Module, s, r.nowTime())); err != nil {
r.logf("the finding of the first view %s could not be cleared: %v", s.Tool, err)
}
}
@@ -310,12 +294,12 @@ func (r *Reader) Tools(prefix string) []stdio.Tool {
if len(fs) == 0 {
return nil, fmt.Errorf("%q is not a state this module follows", want)
}
// A hand act, recorded through the seat mesh-hand-acts before it is done; one that cannot be recorded is
// A hand act, recorded before it is done; one that cannot be recorded is
// said and nothing is asked.
why := "the operator asked"
if _, err := r.Acts.Record(handacts.Request{What: "asked a first view again by hand (" + r.Module + ")", Why: why,
if err := recordHandAct(r.Acts, HandAct{What: "asked a first view again by hand (" + r.Module + ")", Why: why,
Cause: "first-view-again", Verb: "first-view-again"}); err != nil {
return nil, fmt.Errorf("the hand act was not recorded through mesh-hand-acts, so nothing was asked again: %w", err)
return nil, fmt.Errorf("the hand act was not recorded, so nothing was asked again: %w", err)
}
for _, f := range fs {
if err := f.Again(context.Background(), why); err != nil {
@@ -328,14 +312,27 @@ func (r *Reader) Tools(prefix string) []stdio.Tool {
}
// OwnFindings writes a reader's findings into its own state `findings` through its runtime, where the condition keeper
// raises and clears conditions from them: the Findings of a Reader whose module keeps the state `findings`.
type OwnFindings struct{}
// raises and clears conditions from them: the Findings of a Reader whose module keeps the state `findings`. Encode is
// the module's: it turns a Finding into the state's key and value as the seat mesh-conditions' contract lays them out.
type OwnFindings struct {
Encode func(Finding) (key string, value []byte, err error)
}
// Put writes one finding.
func (OwnFindings) Put(key string, value []byte) error {
_, err := stdio.State("findings").Put(key, json.RawMessage(value))
func (o OwnFindings) Put(f Finding) error {
key, value, err := o.Encode(f)
if err != nil {
return err
}
_, err = stdio.State("findings").Put(key, json.RawMessage(value))
return err
}
// Delete clears one finding.
func (OwnFindings) Delete(key string) error { return stdio.State("findings").Delete(key) }
func (o OwnFindings) Delete(f Finding) error {
key, _, err := o.Encode(f)
if err != nil {
return err
}
return stdio.State("findings").Delete(key)
}
+5 -6
View File
@@ -12,7 +12,6 @@ import (
"github.com/nats-io/nats.go/jetstream"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/handacts"
)
// StatusState is a reader's own state of its first views (design 25 § 12.2): at StatusKey each first view's Status, as
@@ -94,9 +93,9 @@ func HearAgain(ctx context.Context, kv jetstream.KeyValue, followers []*Follower
// StatusTools are a reader's read tool of its first views (read) and its re-read tool (again), in its tools bundle,
// over its StatusState: read lists what the process last said of each; again writes a hand ask the process takes,
// naming the caller the runtime proved, and is recorded in the status's by-hand count and why. again is a hand act:
// acts records it through the seat mesh-hand-acts before the ask is written, and one the seat does not take is said and
// nothing is asked (the zero Recorder records nothing, so again refuses).
func StatusTools(reader, read, again string, st stdio.KeptState, acts handacts.Recorder, now func() time.Time) []stdio.Tool {
// acts records it before the ask is written, and one that is not taken is said and nothing is asked (nil acts
// record nothing, so again refuses).
func StatusTools(reader, read, again string, st stdio.KeptState, acts HandActs, now func() time.Time) []stdio.Tool {
return []stdio.Tool{
{Name: read,
Description: fmt.Sprintf("%s's first views of the owners' states it follows (novox/hq ADR 0615): per owner and state, "+
@@ -139,9 +138,9 @@ func StatusTools(reader, read, again string, st stdio.KeptState, acts handacts.R
return nil, errors.New("again names the owner, the state and why")
}
a := HandAsk{V: Version, Owner: owner, State: state, Why: strings.TrimSpace(why), By: stdio.CallerOf(args), At: now().UTC()}
if _, err := acts.Record(handacts.Request{What: fmt.Sprintf("asked %s's first view of %s's %s again by hand", reader, owner, state),
if err := recordHandAct(acts, HandAct{What: fmt.Sprintf("asked %s's first view of %s's %s again by hand", reader, owner, state),
Why: a.Why, Cause: "first-view-again", Verb: again}); err != nil {
return nil, fmt.Errorf("the hand act was not recorded through mesh-hand-acts, so nothing was asked again: %w", err)
return nil, fmt.Errorf("the hand act was not recorded, so nothing was asked again: %w", err)
}
if _, err := st.Put(AgainKey(owner, state), a); err != nil {
return nil, fmt.Errorf("the hand ask is not written: %w", err)
+25 -29
View File
@@ -13,7 +13,6 @@ import (
"github.com/nats-io/nats.go/jetstream"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/handacts"
)
// mapSource is a state held in memory, as a Source.
@@ -252,10 +251,10 @@ func TestViews(t *testing.T) {
t.Run("a module's reader answers Keys and Get from its pictures, refuses a state it does not follow or has no first view of, and writes and clears its finding", func(t *testing.T) {
acted := 0
fs := &findingsMap{}
r := &Reader{Module: "reader", Findings: fs, Bound: time.Nanosecond, Wait: time.Millisecond, Acts: handacts.Recorder{Ask: func(string, any) (json.RawMessage, error) {
r := &Reader{Module: "reader", Findings: fs, Bound: time.Nanosecond, Wait: time.Millisecond, Acts: actsFunc(func(HandAct) error {
acted++
return json.RawMessage(`{"log":"mesh-hand-acts"}`), nil
}}}
return nil
})}
f := r.Follow("o", "modules", "o.indexer_read_modules")
down := 2
f.Ask = func(ctx context.Context, after string) (Answer, error) {
@@ -354,22 +353,13 @@ func TestViews(t *testing.T) {
}
_ = w.Stop()
})
t.Run("a reader's again tool records its hand act through mesh-hand-acts before it writes the ask, and asks nothing when the seat does not take it", func(t *testing.T) {
var recorded []handacts.Request
t.Run("a reader's again tool records its hand act before it writes the ask, and asks nothing when the act is not taken", func(t *testing.T) {
var recorded []HandAct
take := errors.New("no responders")
acts := handacts.Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
if key != "seat:mesh-hand-acts.hand-act" {
return nil, fmt.Errorf("asked %s", key)
}
raw, _ := json.Marshal(body)
var q handacts.Request
_ = json.Unmarshal(raw, &q)
acts := actsFunc(func(q HandAct) error {
recorded = append(recorded, q)
if take != nil {
return nil, take
}
return json.RawMessage(`{"log":"mesh-hand-acts"}`), nil
}}
return take
})
again := StatusTools("reader", "reader_first_views", "reader_first_view_again", stdio.State(StatusState), acts, now)[1]
args := map[string]any{"owner": "o", "state": "modules", "why": "doubted"}
if _, err := again.Run(args); err == nil || !strings.Contains(err.Error(), "nothing was asked again") || len(recorded) != 1 {
@@ -450,15 +440,16 @@ func TestViews(t *testing.T) {
t.Errorf("%d failures: %d findings written, %d cleared, %d left, said %v", c.fails, fs.puts, fs.deletes, len(fs.m), said)
}
if c.puts > 0 {
key, value, _ := UnsaidFinding("o", "s", "a", 1, made, "bus away", clock)
if !strings.Contains(key, UnsaidKind) || !strings.Contains(key, "o.s") || !strings.Contains(string(value), "o has not said the change of s.a at 1") {
t.Errorf("the finding is %s: %s", key, value)
f := UnsaidFinding("o", "s", "a", 1, made, "bus away", clock)
if f.Kind != UnsaidKind || f.ID != "o.s" || f.Observer != "o" || !strings.Contains(f.Summary, "o has not said the change of s.a at 1") {
t.Errorf("the finding is %+v", f)
}
}
}
// A finding a run before a restart raised is cleared by the first change said after it.
key, value, _ := UnsaidFinding("o", "s", "a", 1, made, "bus away", made)
fs := &findingsMap{m: map[string][]byte{key: value}}
left0 := UnsaidFinding("o", "s", "a", 1, made, "bus away", made)
key := left0.Kind + "/" + left0.ID
fs := &findingsMap{m: map[string]Finding{key: left0}}
said, fails = nil, 0
em.Findings = fs
updates = make(chan jetstream.KeyValueEntry, 1)
@@ -484,22 +475,27 @@ func TestViews(t *testing.T) {
})
}
// findingsMap is a module's own findings state in memory.
// findingsMap is a module's own findings state in memory, keyed `<kind>/<id>`.
type findingsMap struct {
m map[string][]byte
m map[string]Finding
puts, deletes int
}
func (f *findingsMap) Put(k string, v []byte) error {
func (f *findingsMap) Put(v Finding) error {
if f.m == nil {
f.m = map[string][]byte{}
f.m = map[string]Finding{}
}
f.m[k] = v
f.m[v.Kind+"/"+v.ID] = v
f.puts++
return nil
}
func (f *findingsMap) Delete(k string) error { delete(f.m, k); f.deletes++; return nil }
func (f *findingsMap) Delete(v Finding) error { delete(f.m, v.Kind+"/"+v.ID); f.deletes++; return nil }
// actsFunc records a hand act through a test's function.
type actsFunc func(HandAct) error
func (f actsFunc) RecordHandAct(a HandAct) error { return f(a) }
// kvEntry is one change of a bucket as a watch hands it.
type kvEntry struct {