From 351f8abba7654c87c309345922bda23280fa5fd5 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 13:44:38 +0100 Subject: [PATCH] =?UTF-8?q?views:=20the=20hand-act=20recorder=20and=20the?= =?UTF-8?q?=20finding=20writer=20come=20from=20the=20caller=20as=20interfa?= =?UTF-8?q?ces,=20no=20hand-acts=20or=20health=20import=20(ADR=200635=20?= =?UTF-8?q?=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/views/emit.go | 17 ++---------- go/views/findings.go | 37 +++++++++++++++++-------- go/views/handacts.go | 22 +++++++++++++++ go/views/process_reader.go | 13 +++++---- go/views/reader.go | 57 ++++++++++++++++++-------------------- go/views/status.go | 11 ++++---- go/views/views_test.go | 54 +++++++++++++++++------------------- 7 files changed, 115 insertions(+), 96 deletions(-) create mode 100644 go/views/handacts.go diff --git a/go/views/emit.go b/go/views/emit.go index 2e16fa7..3a68d69 100644 --- a/go/views/emit.go +++ b/go/views/emit.go @@ -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 } diff --git a/go/views/findings.go b/go/views/findings.go index 781186d..db3cfa6 100644 --- a/go/views/findings.go +++ b/go/views/findings.go @@ -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()} } diff --git a/go/views/handacts.go b/go/views/handacts.go new file mode 100644 index 0000000..3b6fdba --- /dev/null +++ b/go/views/handacts.go @@ -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) +} diff --git a/go/views/process_reader.go b/go/views/process_reader.go index a45fcb4..2c63d11 100644 --- a/go/views/process_reader.go +++ b/go/views/process_reader.go @@ -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) diff --git a/go/views/reader.go b/go/views/reader.go index e98b67b..8a67ab6 100644 --- a/go/views/reader.go +++ b/go/views/reader.go @@ -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 `.`). 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) +} diff --git a/go/views/status.go b/go/views/status.go index e040a80..43bd66b 100644 --- a/go/views/status.go +++ b/go/views/status.go @@ -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) diff --git a/go/views/views_test.go b/go/views/views_test.go index 880cea2..2aa94b5 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -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 `/`. 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 {