From 76cfbac7a1ebb8061a7ebc050014e0f98062e8f2 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 14:36:06 +0200 Subject: [PATCH] Offer only acknowledgements as answers, keep a refused verdict, and say a change of words Review found that a desk click proves nothing about who chose, that refused words could turn "Needs you" into "Nothing for you to do", that sound words were refused, and that a quiet warning whose words came to need the operator was never said (hq ADR 0258). --- cmd/mesh-controller/handacts.go | 7 +- cmd/mesh-controller/module_health.go | 3 +- cmd/mesh-controller/plain_words.go | 50 +++++--------- cmd/mesh-controller/plain_words_test.go | 62 +++++++++++------ cmd/mesh-controller/signals.go | 1 - internal/conditions/events.go | 3 + internal/conditions/plain.go | 79 +++++++++++++++++++--- internal/conditions/plain_test.go | 88 ++++++++++++++++++++++++- internal/conditions/store.go | 4 ++ 9 files changed, 225 insertions(+), 72 deletions(-) diff --git a/cmd/mesh-controller/handacts.go b/cmd/mesh-controller/handacts.go index e127d58e..babf46a6 100644 --- a/cmd/mesh-controller/handacts.go +++ b/cmd/mesh-controller/handacts.go @@ -15,6 +15,7 @@ import ( "github.com/nats-io/nats.go" "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/link" ) @@ -65,8 +66,10 @@ var handActVerbs = []handActVerb{ // not trusted with it — either is a repair the owner should have made. {Verb: "plans go"}, {Verb: "broker consumer-reset"}, - // Silencing the same condition twice says the condition, or what it watches, wants mending. - {Verb: "conditions silence"}, + // Silencing the same condition twice says the condition, or what it watches, wants mending — unless + // it is the operator's answer on a notification: a decision to live with it (novox/hq ADR 0258). + {Verb: "conditions silence", Decision: "the operator's answer on a notification is their decision, " + + "not a repair (ADR 0258)", DecidedFor: []string{conditions.CauseOperatorAnswer}}, // An act done outside the mesh: the mesh cannot tell a repair from a decision there, so it counts — // except a drill recorded through it before `hand-act drill` existed (2026-10-07). It refuses the // cause since, so no act recorded through it now carries it. diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index f408af19..7596f4a6 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -281,7 +281,7 @@ func reloginWords(module, node string) words { // the machine and what is wrong with each resource; the detail — targets, streaks, since — is evidence. func moduleUnhealthyObservation(module, node string, rs []inventory.ResourceHealth) conditions.Observation { var words, said, plain []string - needs, actions := moduleNeeds(node, rs) + needs := moduleNeeds(node, rs) for _, r := range rs { plain = append(plain, resourcePlainWords(r)) if r.Kind == link.KindUnit { @@ -305,7 +305,6 @@ func moduleUnhealthyObservation(module, node string, rs []inventory.ResourceHeal Explanation: fmt.Sprintf("%s on %s is not healthy: %s. It clears as soon as it runs again.", module, node, namesWords(plain, 3)), Needs: needs, - Actions: actions, Resolved: fmt.Sprintf("%s works again on %s", module, node)} } diff --git a/cmd/mesh-controller/plain_words.go b/cmd/mesh-controller/plain_words.go index 6ece69aa..56803b12 100644 --- a/cmd/mesh-controller/plain_words.go +++ b/cmd/mesh-controller/plain_words.go @@ -642,43 +642,28 @@ func waitingNeeds(severity conditions.Severity) string { return "" } -// waitingActions are the answers to a walk waiting past its urgent bound: the controller's own verb, since -// the module that should have said go is the one not answering. -func waitingActions(w waitFacts, severity conditions.Severity) []conditions.Action { - if severity != conditions.Urgent { - return nil - } - return []conditions.Action{ - {Label: "Start", Verb: "mesh-controller.plans", Arguments: map[string]string{"go": w.id, "why": ""}}, - {Label: "Stop", Verb: "mesh-controller.plans", Arguments: map[string]string{"stop": w.id, "why": ""}}, - } -} - // moduleNeeds is what the operator can do about a module unhealthy on a machine: log in again where its // account's groups wait for it (ADR 0252), restart a failed service, or nothing where the mesh restarts it. -func moduleNeeds(node string, rs []inventory.ResourceHealth) (string, []conditions.Action) { - var actions []conditions.Action +// No answer is offered for a restart: a desk click performs only an acknowledgement (ADR 0258). +func moduleNeeds(node string, rs []inventory.ResourceHealth) string { + unit := "" for _, r := range rs { if strings.Contains(r.Reason, "relogin needed") { - return fmt.Sprintf("log out of every session on %s and log in again.", node), nil + return reloginNeeds(node) } - if r.Kind == link.KindUnit && len(actions) < 2 { - scope := "system" - if strings.Contains(r.Reason, "account's own") { - scope = "user" - } - label := "Restart" - if len(actions) > 0 { - label = "Restart " + unitPlainWords(r.Target) - } - actions = append(actions, conditions.Action{Label: label, Verb: "node-service-manager.restart", - Machine: node, Arguments: map[string]string{"unit": r.Target, "scope": scope}}) + if r.Kind == link.KindUnit && unit == "" { + unit = unitPlainWords(r.Target) } } - if len(actions) > 0 { - return "restart it; if it fails again, the details say why.", actions + if unit != "" { + return fmt.Sprintf("restart its service %s on %s; if it fails again, the details say why.", unit, node) } - return "", nil + return "" +} + +// reloginNeeds is what an account waiting for its groups needs (ADR 0252). +func reloginNeeds(node string) string { + return fmt.Sprintf("log out of every session on %s and log in again.", node) } // stalledWords are the plain words of a delivery held past its bound, as mesh-delivery says it. @@ -695,18 +680,15 @@ func stalledWords(l stalledLine, o conditions.Observation) (headline, explanatio long = "for " + humanDuration(d) } if o.Resolver == conditions.ResolverOperator { + // Words only: releasing or stopping a delivery is not an acknowledgement, so no desk click + // performs it (ADR 0258). switch held { case "held": needs = "release it, or stop it." - actions = []conditions.Action{ - {Label: "Release", Verb: "mesh-delivery.release", Arguments: map[string]string{"id": l.ID, "why": ""}}, - {Label: "Stop", Verb: "mesh-delivery.stop", Arguments: map[string]string{"id": l.ID, "why": ""}}, - } case "ready", "checked": needs = "merge its pull request, or close it." default: needs = "stop it, or read the details to see what it waits for." - actions = []conditions.Action{{Label: "Stop", Verb: "mesh-delivery.stop", Arguments: map[string]string{"id": l.ID, "why": ""}}} } } return fmt.Sprintf("Delivery of %s %s %s", name, held, long), diff --git a/cmd/mesh-controller/plain_words_test.go b/cmd/mesh-controller/plain_words_test.go index 8a2c653a..16c6747e 100644 --- a/cmd/mesh-controller/plain_words_test.go +++ b/cmd/mesh-controller/plain_words_test.go @@ -70,13 +70,7 @@ func TestADeliveryWaitingNeedsNothingUntilItsBoundThenOffersStartAndStop(t *test plainExample(t, got[0], "openrazer delivery waiting to start", "Needs you: start it, or stop it. The change to openrazer is merged and built, and mesh-delivery (the "+ "module that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may "+ - "be stuck.", "Start", "Stop") - if a := got[0].Actions[0]; a.Verb != "mesh-controller.plans" || a.Arguments["go"] != "plan-1791454185265004861" { - t.Errorf("start: %+v", a) - } - if a := got[0].Actions[1]; a.Verb != "mesh-controller.plans" || a.Arguments["stop"] != "plan-1791454185265004861" { - t.Errorf("stop: %+v", a) - } + "be stuck.") // Many modules are counted, not listed in the headline. f.waits[0].modules = []string{"a", "b", "c", "d"} @@ -87,24 +81,32 @@ func TestADeliveryWaitingNeedsNothingUntilItsBoundThenOffersStartAndStop(t *test } // **A module unhealthy**: "openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the -// account's own service manager (exit-code)". The operator restarts it from the notification. -func TestAModuleUnhealthyOffersARestartOfItsService(t *testing.T) { +// account's own service manager (exit-code)". Restarting is not an acknowledgement, so it is said in words +// and offered as no answer (ADR 0258). +func TestAModuleUnhealthyAsksForARestartInWords(t *testing.T) { o := moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: link.KindUnit, Resource: "openrazer-daemon", Target: "openrazer-daemon.service", Reason: "failed in the account's own service manager (exit-code)", Since: time.Now()}}) plainExample(t, o, "openrazer not working on g14", - "Needs you: restart it; if it fails again, the details say why. openrazer on g14 is not healthy: its "+ - "service openrazer-daemon stopped with an error. It clears as soon as it runs again.", "Restart") - if a := o.Actions[0]; a.Verb != "node-service-manager.restart" || a.Machine != "g14" || - a.Arguments["unit"] != "openrazer-daemon.service" || a.Arguments["scope"] != "user" { - t.Errorf("restart: %+v", a) - } - // An account waiting for a new login (ADR 0252) asks for the login, which no button can give. + "Needs you: restart its service openrazer-daemon on g14; if it fails again, the details say why. "+ + "openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as soon "+ + "as it runs again.") + // An account waiting for a new login (ADR 0252) asks for the login, held to the plain rule. o = moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: "account", Resource: "operator-in-group", Target: "jochen", Reason: "relogin needed: the account is in the group"}}) if o.Needs != "log out of every session on g14 and log in again." || len(o.Actions) != 0 { t.Errorf("relogin: %q %+v", o.Needs, o.Actions) } + w := conditions.Words{Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved, Needs: o.Needs} + if why, ok := conditions.PlainWords(w, "g14"); !ok { + t.Errorf("the relogin words are not plain: %s", why) + } + // And the kind issue 318 raises for it (ADR 0254). + rw := plainWordings["relogin-needed"](conditions.Observation{Scope: conditions.ScopeModule, ID: "openrazer.g14", + Machine: "g14", Kind: "relogin-needed", Severity: conditions.Warning}) + if why, ok := conditions.PlainWords(rw, "g14"); !ok || rw.Needs == "" || len(rw.Actions) != 0 { + t.Errorf("relogin-needed: %s %+v", why, rw) + } } // **Failed units on a machine**: "shanks's service manager is degraded: 3 failed unit(s) no module places — @@ -143,15 +145,12 @@ func TestAHealerWantedNeedsNothingFromTheOperator(t *testing.T) { // **A delivery held past its bound**, as mesh-delivery says it: "the delivery novox/hq@055550802096 has been // held for 36h2m6s, past its bound of 24h0m0s (it waits for the operator): healer H2 may none: …". -func TestADeliveryHeldOffersReleaseAndStop(t *testing.T) { +func TestADeliveryHeldAsksForReleaseOrStopInWords(t *testing.T) { got := stalledObservations([]stalledLine{{ID: "novox/hq@055550802096", State: "held", For: "36h2m6s", Bound: "24h0m0s", H2: "none: the state is the operator's", Says: "it waits for the operator"}}) plainExample(t, got[0], "Delivery of hq held for 36 hours", "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours, past its limit.", - "Release", "Stop") - if a := got[0].Actions[0]; a.Verb != "mesh-delivery.release" || a.Arguments["id"] != "novox/hq@055550802096" { - t.Errorf("release: %+v", a) - } + ) } // **Every kind the controller raises has plain words**, and its words are plain for a subject of every @@ -184,3 +183,24 @@ func TestEveryWordingIsPlain(t *testing.T) { } } } + +// **An answer on a notification is no repair** (novox/hq ADR 0258): silencing chosen by the operator on the +// desk does not count toward a healer wanted; the same silence by hand for another cause still does. +func TestAnOperatorsAnswerIsNoHandRepair(t *testing.T) { + now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) + f := calm(now) + for i := 0; i < 3; i++ { + a := actByHand(now.Add(-time.Duration(i+1)*time.Hour), conditions.CauseOperatorAnswer) + a.Verb = "conditions silence" + f.handActs = append(f.handActs, a) + } + if got := watchHandActs(f); len(got) != 0 { + t.Fatalf("an answer counted as a repair: %+v", got) + } + for i := range f.handActs { + f.handActs[i].Cause = "machine-units" + } + if got := watchHandActs(f); len(got) != 1 { + t.Fatalf("a silence by hand stopped counting: %+v", got) + } +} diff --git a/cmd/mesh-controller/signals.go b/cmd/mesh-controller/signals.go index 1ea068d3..875c4940 100644 --- a/cmd/mesh-controller/signals.go +++ b/cmd/mesh-controller/signals.go @@ -361,7 +361,6 @@ func watchWaits(f *signalFacts) []conditions.Observation { Headline: deliveryName(w.modules, w.repository) + " waiting to start", Explanation: walkWaitingWords(w, in, severity), Needs: waitingNeeds(severity), - Actions: waitingActions(w, severity), Resolved: deliveryName(w.modules, w.repository) + " no longer waiting"}) } return out diff --git a/internal/conditions/events.go b/internal/conditions/events.go index 053177eb..00e13211 100644 --- a/internal/conditions/events.go +++ b/internal/conditions/events.go @@ -36,6 +36,9 @@ const ( ChangeSilenced = "silenced" ChangeUnsilenced = "silence-ended" ChangeCleared = "cleared" + // ChangeWords is what the operator needs, or the answers offered, said differently (novox/hq ADR + // 0258): a warning kept quiet as needing nothing is said once its words need the operator. + ChangeWords = "words" ) // Event is the body of every condition event, and the shape a history entry keeps: **the condition diff --git a/internal/conditions/plain.go b/internal/conditions/plain.go index 19ce4ee0..1a2dca67 100644 --- a/internal/conditions/plain.go +++ b/internal/conditions/plain.go @@ -62,10 +62,37 @@ const ( ) // SilenceAction is the action that stops a condition's messages for a week, with the operator's why: the -// answer to a condition the operator decided to live with. +// answer to a condition the operator decided to live with. It is an acknowledgement (ADR 0234 §8): the +// only kind of answer a desk click performs until answers are authorised (novox/hq ADR 0258). Its cause +// marks it as an answer, which the hand-act log does not count as a repair. func SilenceAction(key string) Action { return Action{Label: "Silence for a week", Verb: "mesh-controller.conditions", - Arguments: map[string]string{"silence": key, "for": "7d", "why": ""}} + Arguments: map[string]string{"silence": key, "for": "7d", "why": "", "cause": CauseOperatorAnswer}} +} + +// CauseOperatorAnswer is the cause an answer chosen on a notification is recorded with. +const CauseOperatorAnswer = "operator-answer" + +// FallbackNeeds is what a condition needs when its producer's words needed the operator and could not +// be said: the verdict is kept, never turned into "Nothing for you to do." (ADR 0258). +const FallbackNeeds = "read the details to see what to do." + +func sameActions(a, b []Action) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Label != b[i].Label || a[i].Verb != b[i].Verb || a[i].Machine != b[i].Machine || + len(a[i].Arguments) != len(b[i].Arguments) { + return false + } + for k, v := range a[i].Arguments { + if b[i].Arguments[k] != v { + return false + } + } + } + return true } // Bounds of the plain words: a headline fits a notification's title line, an explanation two sentences. @@ -108,7 +135,9 @@ var ( goDuration = regexp.MustCompile(`\b[0-9]+(h[0-9]+m|m[0-9]+s|h[0-9]+m[0-9]+s)\b|\b[0-9]+(\.[0-9]+)?(ns|ms|µs)\b`) clockTime = regexp.MustCompile(`\b[0-9]{1,2}:[0-9]{2}\b|\b[0-9]{4}-[0-9]{2}-[0-9]{2}\b|\bUTC\b`) flag = regexp.MustCompile(`(^|\s)--?[a-z]`) - agentWord = regexp.MustCompile(`(?i)\bagents?\b|\bby hand\b|\bsession\b`) + agentWord = regexp.MustCompile(`(?i)\bagents?\b`) + byHand = regexp.MustCompile(`(?i)\bby hand\b`) + latinAbbr = regexp.MustCompile(`\b(e\.g|i\.e|etc)\.`) markup = regexp.MustCompile("[`*<>{}\\[\\]|#]|(^|\\s)_|_(\\s|$)") ) @@ -126,14 +155,15 @@ func Plain(text string, machines ...string) (string, bool) { if m := markup.FindString(text); m != "" { return "markup (" + m + ")", false } + text = latinAbbr.ReplaceAllString(text, "eg") for _, shape := range []struct { re *regexp.Regexp what string }{{hexID, "a hash"}, {numberedID, "a numbered id"}, {dotted, "a dotted name"}, {goDuration, "a duration in code"}, {clockTime, "a clock time or date"}, {flag, "a command's flag"}} { if m := shape.re.FindString(text); m != "" { - if shape.re == hexID && !strings.ContainsAny(m, "0123456789") { - continue // a word of letters a to f only + if shape.re == hexID && (!strings.ContainsAny(m, "0123456789") || !strings.ContainsAny(m, "abcdef")) { + continue // letters a to f only, or a number: words and counts, not a hash } return shape.what + " (" + strings.TrimSpace(m) + ")", false } @@ -158,16 +188,14 @@ func PlainWords(w Words, machines ...string) (string, bool) { if len(w.Explanation) > ExplanationMax { return fmt.Sprintf("explanation: longer than %d characters", ExplanationMax), false } - for _, m := range agentWord.FindAllString(w.Explanation, -1) { - if !strings.EqualFold(m, "by hand") { // "repaired by hand" is a fact; telling to act by hand is not - return "explanation: sends the operator elsewhere (" + m + ")", false - } + if m := agentWord.FindString(w.Explanation); m != "" { + return "explanation: sends the operator elsewhere (" + m + ")", false } if w.Needs != "" { if why, ok := Plain(w.Needs, machines...); !ok { return "needs: " + why, false } - if m := agentWord.FindString(w.Needs); m != "" { + if m := agentWord.FindString(w.Needs) + byHand.FindString(w.Needs); m != "" { return "needs: not something the operator does themselves (" + m + ")", false } if len(w.Needs) > NeedsMax || !strings.HasSuffix(w.Needs, ".") { @@ -221,6 +249,8 @@ func plainly(o Observation) Observation { if w.Headline != "" && w.Resolved == "" { w.Resolved = "Resolved: " + lowerFirst(w.Headline) } + // An explanation too long is cut, never refused: what it says first is what matters. + w.Explanation = cut(w.Explanation, ExplanationMax-len(Verdict(w.Needs, ""))-1) if from == "" { if Unworded != nil { Unworded(o, "the kind "+o.Kind+" has no plain words") @@ -230,13 +260,42 @@ func plainly(o Observation) Observation { if Unworded != nil { Unworded(o, from+" is not plain: "+why) } + // The scope's words, but never a weaker verdict: what needed the operator still does, and its + // answers are kept where they are themselves sound. + needed, actions := w.Needs != "", soundActions(w.Actions) w = scopeWords(o) + if needed { + w.Needs, w.Actions = FallbackNeeds, actions + } } o.Headline, o.Resolved, o.Needs, o.Actions = w.Headline, w.Resolved, w.Needs, w.Actions o.Explanation = Verdict(w.Needs, w.Explanation) return o } +// soundActions are the actions with a label and a verb. +func soundActions(list []Action) []Action { + var out []Action + for _, a := range list { + if a.Label != "" && len(a.Label) <= 24 && a.Verb != "" { + out = append(out, a) + } + } + return out +} + +// cut shortens text to at most n bytes at a word, ending it with an ellipsis. +func cut(text string, n int) string { + if len(text) <= n || n < 10 { + return text + } + t := text[:n-3] + if i := strings.LastIndexByte(t, ' '); i > n/2 { + t = t[:i] + } + return strings.TrimRight(t, " ,;:") + "…" +} + // Verdict is an explanation opened by its verdict. func Verdict(needs, explanation string) string { if needs == "" { diff --git a/internal/conditions/plain_test.go b/internal/conditions/plain_test.go index 9a129c67..f7312e5b 100644 --- a/internal/conditions/plain_test.go +++ b/internal/conditions/plain_test.go @@ -11,7 +11,7 @@ import ( func TestThePlainRuleRefusesIdentifiersCodeAndTimes(t *testing.T) { refused := map[string]string{ "the walk of novox/mesh-catalog a6385479 waits": "a hash", - "plan plan-1791454185265004861 is waiting": "a hash", + "plan plan-1791454185265004861 is waiting": "a numbered id", "see `mesh-delivery.show` for why": "markup", "call mesh-delivery.show for why": "a dotted name", "key plan.x.waiting": "a dotted name", @@ -124,7 +124,6 @@ func TestEveryExplanationOpensWithItsVerdict(t *testing.T) { for _, w := range []Words{ {Headline: "x waits", Explanation: "Have an agent start it.", Resolved: "x started"}, {Headline: "x waits", Explanation: "It waits.", Needs: "start it by hand.", Resolved: "x started"}, - {Headline: "x waits", Explanation: "It waits.", Needs: "open a session and start it.", Resolved: "x started"}, {Headline: "x waits", Explanation: "It waits.", Needs: "start it", Resolved: "x started"}, {Headline: "x waits", Explanation: "It waits.", Resolved: "x started", Actions: []Action{{Label: "Start"}}}, } { @@ -161,3 +160,88 @@ func TestAnEscalatedConditionSaysItNeedsTheOperator(t *testing.T) { t.Errorf("seen again after escalation: %q", c.Explanation) } } + +// **What the plain rule refused wrongly** (novox/hq ADR 0258): a login session is the operator's own word, +// "e.g." is not a dotted name, a long count is not a hash, and a long explanation is cut, not refused. +func TestThePlainRuleRefusesNoPlainWords(t *testing.T) { + for _, text := range []string{ + "log out of every session on g14 and log in again.", + "A module, e.g. the lighting daemon, waits.", + "It holds 12345678 files.", + "A person repaired push by hand 35 times in 14 days.", + } { + if why, ok := Plain(text, "g14"); !ok { + t.Errorf("%q refused: %s", text, why) + } + } + if _, ok := Plain("commit 0123abc landed"); ok { + t.Error("a hash passed") + } + k, _, _, _ := keeper(t) + long := strings.Repeat("The bus refused a message. ", 30) + Wording("test-long", func(o Observation) Words { + return Words{Headline: "A long one", Explanation: long, Resolved: "Done"} + }) + before := Unworded + var unworded []string + Unworded = func(o Observation, why string) { unworded = append(unworded, why) } + t.Cleanup(func() { Unworded = before }) + c, err := k.Observe(t.Context(), Observation{Scope: ScopeBus, ID: "x", Kind: "test-long", Severity: Warning, + Source: "test", Summary: "long"}) + if err != nil { + t.Fatal(err) + } + if c.Headline != "A long one" || len(c.Explanation) > ExplanationMax || !strings.HasSuffix(c.Explanation, "…") || + len(unworded) != 0 { + t.Errorf("a long explanation was not cut: %d %q %v", len(c.Explanation), c.Headline, unworded) + } +} + +// **Words refused keep their verdict and their answers** (ADR 0258): a producer that needed the operator +// still does, said as "read the details", and its sound answers stay. +func TestRefusedWordsKeepTheirVerdictAndAnswers(t *testing.T) { + k, _, _, _ := keeper(t) + before := Unworded + Unworded = func(Observation, string) {} + t.Cleanup(func() { Unworded = before }) + silence := SilenceAction("module.openrazer.g14.x") + c, err := k.Observe(t.Context(), Observation{Scope: ScopeModule, ID: "openrazer.g14", Token: "x", Kind: "test-refused", + Machine: "g14", Severity: Warning, Source: "test", Summary: "s", + Headline: "openrazer at 0123abc failed", Needs: "restart it.", Explanation: "It failed.", + Actions: []Action{silence}}) + if err != nil { + t.Fatal(err) + } + if c.Needs != FallbackNeeds || !strings.HasPrefix(c.Explanation, NeedsYou+" "+FallbackNeeds) || + len(c.Actions) != 1 || c.Actions[0].Label != silence.Label || strings.Contains(c.Headline, "0123abc") { + t.Errorf("the verdict or the answers were lost: %+v", c) + } + // Words that needed nothing stay "Nothing for you to do.". + c, _ = k.Observe(t.Context(), Observation{Scope: ScopeModule, ID: "openrazer.g14", Token: "y", Kind: "test-refused", + Machine: "g14", Severity: Warning, Source: "test", Summary: "s", Headline: "at 0123abc", Explanation: "x."}) + if c.Needs != "" || !strings.HasPrefix(c.Explanation, NothingToDo) { + t.Errorf("%+v", c) + } +} + +// **Words that now need the operator are said as a change** (ADR 0258): a condition whose needs or answers +// change emits condition-changed, so a warning kept quiet is said. +func TestAChangeOfWordsIsSaid(t *testing.T) { + k, _, told, _ := keeper(t) + o := Observation{Scope: ScopeMachine, ID: "ace", Kind: "silent", Machine: "ace", Severity: Warning, Source: "S1", + Summary: "ace is silent", Headline: "ace is not answering", Explanation: "It may be asleep."} + if _, err := k.Observe(t.Context(), o); err != nil { + t.Fatal(err) + } + if _, err := k.Observe(t.Context(), o); err != nil { // the same words: no event + t.Fatal(err) + } + o.Needs = "check that ace is on." + if _, err := k.Observe(t.Context(), o); err != nil { + t.Fatal(err) + } + said := settled(t, told, 2) + if len(said) != 2 || said[1].Change != ChangeWords || said[1].Event != EventChanged || said[1].Needs != o.Needs { + t.Fatalf("%+v", said) + } +} diff --git a/internal/conditions/store.go b/internal/conditions/store.go index ebecde5b..bdee8e69 100644 --- a/internal/conditions/store.go +++ b/internal/conditions/store.go @@ -240,8 +240,12 @@ func (k *Keeper) Observe(ctx context.Context, o Observation) (Condition, error) // The kind as the source says it now: a source that gave the same key a kind of its own since // (a probe's finding split out for a healer) is read by that kind from its next observation. c.Kind, c.Summary, c.Source, c.LastObserved = o.Kind, o.Summary, o.Source, now + wasNeeds, wasActions := c.Needs, c.Actions c.Headline, c.Explanation, c.Resolved, c.Needs, c.Actions = o.Headline, o.Explanation, o.Resolved, o.Needs, o.Actions escalatedWords(&c) + if c.Needs != wasNeeds || !sameActions(c.Actions, wasActions) { + changes = append(changes, Event{Change: ChangeWords, Was: wasNeeds}) + } if o.Machine != "" { c.Subject.Machine = o.Machine }