Offer only acknowledgements as answers, keep a refused verdict, and say a change of words
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/notifications-after-review delivered: every member is delivered
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/notifications-after-review delivered: every member is delivered
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).
This commit is contained in:
@@ -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