Merge pull request 'Offer only acknowledgements, keep a refused verdict, say a change of words (hq ADR 0258, issue 324)' (#145) from fix/notifications-after-review into main
This commit was merged in pull request #145.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user