Open every explanation with what the operator needs to do, and offer the answers
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 feat/plain-notifications 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 feat/plain-notifications delivered: every member is delivered
The operator could not tell from a notification whether to act, and was told to have an agent do it. Each condition now says "Nothing for you to do." or "Needs you:" with one thing they can do themselves, and carries the actions the operator channel performs when chosen (hq ADR 0253).
This commit is contained in:
@@ -134,6 +134,10 @@ type Condition struct {
|
||||
Headline string `json:"headline"`
|
||||
Explanation string `json:"explanation"`
|
||||
Resolved string `json:"resolved"`
|
||||
// Needs is what the operator does about it, one sentence; empty when nothing (the explanation then
|
||||
// opens "Nothing for you to do."). Actions are the answers a notification offers (plain.go).
|
||||
Needs string `json:"needs"`
|
||||
Actions []Action `json:"actions"`
|
||||
// Evidence is the newest observations, at most KeptEvidence, newest first.
|
||||
Evidence []Evidence `json:"evidence"`
|
||||
// Source is the signals-table row, probe or event that raised it: `S1`, `D3`, `provisioner.failing`.
|
||||
@@ -187,6 +191,8 @@ type Observation struct {
|
||||
Headline string
|
||||
Explanation string
|
||||
Resolved string
|
||||
Needs string
|
||||
Actions []Action
|
||||
// Said is this observation's evidence, in the mesh's words; Summary when empty. **Detail goes
|
||||
// here, never in Summary**: an address, a socket's error, a path or a name with its domain is
|
||||
// kept in the condition's evidence, which stays inside the mesh. The summary leaves it — to the
|
||||
|
||||
@@ -11,7 +11,12 @@ package conditions
|
||||
// g14"), the title of every message about it;
|
||||
// - an **explanation**: one or two plain sentences — what happened, what it means for the operator,
|
||||
// and whether they need to do anything;
|
||||
// - a **resolved line**: the one short line said when it clears ("openrazer runs again on g14").
|
||||
// - a **resolved line**: the one short line said when it clears ("openrazer runs again on g14");
|
||||
// - a **verdict**, which every explanation opens with: "Nothing for you to do." or "Needs you:" and one
|
||||
// concrete thing the operator can do themselves (needs). Never "have an agent …": the operator is not
|
||||
// asked to open a session to understand or answer a notification;
|
||||
// - **actions**: what the operator may answer from the notification itself (Release, Stop, Restart,
|
||||
// Silence), each the seat verb the operator channel calls when it is chosen, naming the operator.
|
||||
//
|
||||
// They are made here, where the condition is made, so every channel gets them — the desktop today,
|
||||
// others later — and none has to guess what a key means. A producer may say them itself; otherwise the
|
||||
@@ -29,17 +34,45 @@ import (
|
||||
"github.com/novox/mesh-controller/internal/outward"
|
||||
)
|
||||
|
||||
// Words are what the operator reads of a condition.
|
||||
// Words are what the operator reads of a condition. Explanation is said after the verdict, which
|
||||
// plainly writes from Needs: empty is "Nothing for you to do.", else "Needs you: " and Needs.
|
||||
type Words struct {
|
||||
Headline string
|
||||
Explanation string
|
||||
Resolved string
|
||||
Needs string
|
||||
Actions []Action
|
||||
}
|
||||
|
||||
// Action is one answer the operator may give from a notification: a label, and the seat verb the
|
||||
// operator channel calls with these arguments (and a why naming the operator and the label) when it is
|
||||
// chosen. Machine is set for a seat every machine holds. An argument "why" given empty is the operator
|
||||
// channel's to fill: it names the operator, the channel and the label chosen.
|
||||
type Action struct {
|
||||
Label string `json:"label"`
|
||||
Verb string `json:"verb"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Arguments map[string]string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// The two verdicts an explanation opens with.
|
||||
const (
|
||||
NothingToDo = "Nothing for you to do."
|
||||
NeedsYou = "Needs you:"
|
||||
)
|
||||
|
||||
// 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.
|
||||
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": ""}}
|
||||
}
|
||||
|
||||
// Bounds of the plain words: a headline fits a notification's title line, an explanation two sentences.
|
||||
const (
|
||||
HeadlineMax = 60
|
||||
ExplanationMax = 360
|
||||
ExplanationMax = 420
|
||||
NeedsMax = 120
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -75,6 +108,7 @@ 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`)
|
||||
markup = regexp.MustCompile("[`*<>{}\\[\\]|#]|(^|\\s)_|_(\\s|$)")
|
||||
)
|
||||
|
||||
@@ -124,6 +158,27 @@ 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 w.Needs != "" {
|
||||
if why, ok := Plain(w.Needs, machines...); !ok {
|
||||
return "needs: " + why, false
|
||||
}
|
||||
if m := agentWord.FindString(w.Needs); m != "" {
|
||||
return "needs: not something the operator does themselves (" + m + ")", false
|
||||
}
|
||||
if len(w.Needs) > NeedsMax || !strings.HasSuffix(w.Needs, ".") {
|
||||
return fmt.Sprintf("needs: one sentence of at most %d characters, ending in a full stop", NeedsMax), false
|
||||
}
|
||||
}
|
||||
for _, a := range w.Actions {
|
||||
if a.Label == "" || len(a.Label) > 24 || a.Verb == "" {
|
||||
return fmt.Sprintf("action %q: a short label and a verb", a.Label), false
|
||||
}
|
||||
}
|
||||
if why, ok := Plain(w.Resolved, machines...); !ok {
|
||||
return "resolved: " + why, false
|
||||
}
|
||||
@@ -136,7 +191,8 @@ func PlainWords(w Words, machines ...string) (string, bool) {
|
||||
// plainly gives an observation its plain words: its own, its kind's, or its scope's.
|
||||
func plainly(o Observation) Observation {
|
||||
machines := append([]string{o.Machine}, o.Also...)
|
||||
given := Words{o.Headline, o.Explanation, o.Resolved}
|
||||
given := Words{Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved, Needs: o.Needs,
|
||||
Actions: o.Actions}
|
||||
var w Words
|
||||
from := ""
|
||||
switch {
|
||||
@@ -154,6 +210,12 @@ func plainly(o Observation) Observation {
|
||||
if given.Resolved != "" {
|
||||
w.Resolved = given.Resolved
|
||||
}
|
||||
if given.Needs != "" {
|
||||
w.Needs = given.Needs
|
||||
}
|
||||
if len(given.Actions) > 0 {
|
||||
w.Actions = given.Actions
|
||||
}
|
||||
}
|
||||
}
|
||||
if w.Headline != "" && w.Resolved == "" {
|
||||
@@ -170,17 +232,27 @@ func plainly(o Observation) Observation {
|
||||
}
|
||||
w = scopeWords(o)
|
||||
}
|
||||
o.Headline, o.Explanation, o.Resolved = w.Headline, w.Explanation, w.Resolved
|
||||
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
|
||||
}
|
||||
|
||||
// Escalated is the sentence a condition's explanation ends with once a healer gave up on it.
|
||||
const Escalated = "The mesh tried to repair it and could not: it needs you now."
|
||||
// Verdict is an explanation opened by its verdict.
|
||||
func Verdict(needs, explanation string) string {
|
||||
if needs == "" {
|
||||
return strings.TrimSpace(NothingToDo + " " + explanation)
|
||||
}
|
||||
return strings.TrimSpace(NeedsYou + " " + needs + " " + explanation)
|
||||
}
|
||||
|
||||
// escalatedWords says in the explanation that a healer gave up, whatever the words said before.
|
||||
// Escalated is what a condition needs once a healer gave up on it, where its words needed nothing.
|
||||
const Escalated = "the mesh tried to repair this and could not; read the details to decide what to do."
|
||||
|
||||
// escalatedWords turns "Nothing for you to do." into "Needs you:" once a healer gave up.
|
||||
func escalatedWords(c *Condition) {
|
||||
if c.Escalated() && !strings.HasSuffix(c.Explanation, Escalated) {
|
||||
c.Explanation = strings.TrimSpace(c.Explanation + " " + Escalated)
|
||||
if c.Escalated() && c.Needs == "" {
|
||||
c.Needs = Escalated
|
||||
c.Explanation = Verdict(c.Needs, strings.TrimSpace(strings.TrimPrefix(c.Explanation, NothingToDo)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,13 +263,14 @@ func scopeWords(o Observation) Words {
|
||||
what := strings.ReplaceAll(o.Kind, "-", " ")
|
||||
w := Words{
|
||||
Headline: Capital(thing) + " needs a look",
|
||||
Explanation: fmt.Sprintf("The mesh noticed a problem it calls %q with %s. %s", what, thing, ResolverWords(o)),
|
||||
Explanation: fmt.Sprintf("The mesh noticed a problem it calls %q with %s.", what, thing),
|
||||
Resolved: "Resolved: " + thing + " is fine again",
|
||||
Needs: ResolverNeeds(o),
|
||||
}
|
||||
if _, ok := PlainWords(w, append([]string{o.Machine}, o.Also...)...); !ok {
|
||||
w = Words{Headline: "Something in the mesh needs a look",
|
||||
Explanation: "The mesh noticed a problem it has no plain words for yet. " + ResolverWords(o),
|
||||
Resolved: "Resolved: the mesh is fine again"}
|
||||
Explanation: "The mesh noticed a problem it has no plain words for yet.",
|
||||
Resolved: "Resolved: the mesh is fine again", Needs: ResolverNeeds(o)}
|
||||
}
|
||||
return w
|
||||
}
|
||||
@@ -244,17 +317,12 @@ func ThingWords(o Observation) string {
|
||||
return "the mesh"
|
||||
}
|
||||
|
||||
// ResolverWords says whether the operator needs to act, from who resolves it.
|
||||
func ResolverWords(o Observation) string {
|
||||
switch {
|
||||
case o.Resolver == ResolverOperator:
|
||||
return "It needs you: the mesh does not repair this by itself."
|
||||
case strings.HasPrefix(o.Resolver, "healer:"):
|
||||
return "A healer is working on it; nothing to do unless it stays."
|
||||
case o.Severity == Urgent:
|
||||
return "It clears by itself once it is fixed, but needs a look now."
|
||||
// ResolverNeeds is what the operator needs to do, from who resolves it: nothing, unless only a person can.
|
||||
func ResolverNeeds(o Observation) string {
|
||||
if o.Resolver == ResolverOperator {
|
||||
return "the mesh does not repair this by itself; read the details to decide what to do."
|
||||
}
|
||||
return "Nothing to do yet: it clears by itself once it is fixed."
|
||||
return ""
|
||||
}
|
||||
|
||||
// Capital is s with its first letter upper case.
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestAConditionCarriesItsPlainWords(t *testing.T) {
|
||||
}
|
||||
said := settled(t, told, 1)
|
||||
body, _ := json.Marshal(said[0])
|
||||
for _, field := range []string{`"headline":"ace is not answering"`, `"explanation":"The mesh has not heard from it."`,
|
||||
for _, field := range []string{`"headline":"ace is not answering"`, `"explanation":"Nothing for you to do. The mesh has not heard from it."`,
|
||||
`"resolved":"ace answers again"`} {
|
||||
if !strings.Contains(string(body), field) {
|
||||
t.Errorf("the event does not carry %s: %s", field, body)
|
||||
@@ -100,7 +100,7 @@ func TestAConditionCarriesItsPlainWords(t *testing.T) {
|
||||
// Words that are not plain, and a kind with none, are said from the scope — and reported.
|
||||
c, _ = k.Observe(t.Context(), Observation{Scope: ScopeModule, ID: "openrazer.g14", Token: "x", Kind: "test-unworded",
|
||||
Machine: "g14", Severity: Warning, Source: "health", Summary: "openrazer on g14 is not healthy"})
|
||||
if c.Headline != "Openrazer on g14 needs a look" || !strings.Contains(c.Explanation, "Nothing to do yet") {
|
||||
if c.Headline != "Openrazer on g14 needs a look" || !strings.HasPrefix(c.Explanation, NothingToDo) {
|
||||
t.Errorf("the scope's words: %+v", c)
|
||||
}
|
||||
c, _ = k.Observe(t.Context(), Observation{Scope: ScopePlan, ID: "plan-1791454185265004861", Kind: "test-silent",
|
||||
@@ -113,13 +113,39 @@ func TestAConditionCarriesItsPlainWords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// **An explanation opens with its verdict, and never sends the operator to an agent.**
|
||||
func TestEveryExplanationOpensWithItsVerdict(t *testing.T) {
|
||||
if got := Verdict("", "It may be asleep."); got != "Nothing for you to do. It may be asleep." {
|
||||
t.Errorf("%q", got)
|
||||
}
|
||||
if got := Verdict("release it, or stop it.", "It is held."); got != "Needs you: release it, or stop it. It is held." {
|
||||
t.Errorf("%q", got)
|
||||
}
|
||||
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"}}},
|
||||
} {
|
||||
if _, ok := PlainWords(w); ok {
|
||||
t.Errorf("passed: %+v", w)
|
||||
}
|
||||
}
|
||||
if why, ok := PlainWords(Words{Headline: "push keeps being fixed by hand", Explanation: "A person repaired " +
|
||||
"push by hand 35 times.", Resolved: "Resolved", Needs: "release it, or stop it.",
|
||||
Actions: []Action{{Label: "Release", Verb: "mesh-delivery.release"}}}); !ok {
|
||||
t.Errorf("refused: %s", why)
|
||||
}
|
||||
}
|
||||
|
||||
// **A healer that gave up says so in the explanation**: the words of the kind said "nothing to do"; once the
|
||||
// budget is spent, the operator is needed.
|
||||
func TestAnEscalatedConditionSaysItNeedsTheOperator(t *testing.T) {
|
||||
k, _, _, _ := keeper(t)
|
||||
ctx := t.Context()
|
||||
o := Observation{Scope: ScopeMachine, ID: "ace", Kind: "silent", Machine: "ace", Severity: Warning, Source: "S1",
|
||||
Summary: "ace is silent", Headline: "ace is not answering", Explanation: "Nothing to do yet."}
|
||||
Summary: "ace is silent", Headline: "ace is not answering", Explanation: "It may be asleep."}
|
||||
if _, err := k.Observe(ctx, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -127,11 +153,11 @@ func TestAnEscalatedConditionSaysItNeedsTheOperator(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(c.Explanation, Escalated) {
|
||||
t.Fatalf("escalated: %q", c.Explanation)
|
||||
if want := "Needs you: " + Escalated + " It may be asleep."; c.Explanation != want || c.Needs != Escalated {
|
||||
t.Fatalf("escalated: %q, want %q", c.Explanation, want)
|
||||
}
|
||||
c, _ = k.Observe(ctx, o)
|
||||
if strings.Count(c.Explanation, Escalated) != 1 {
|
||||
if strings.Count(c.Explanation, Escalated) != 1 || strings.Contains(c.Explanation, NothingToDo) {
|
||||
t.Errorf("seen again after escalation: %q", c.Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ func (k *Keeper) Observe(ctx context.Context, o Observation) (Condition, error)
|
||||
if !found {
|
||||
c := Condition{Key: key, Kind: o.Kind, Subject: Subject{Scope: o.Scope, ID: o.ID, Machine: o.Machine, Also: o.Also},
|
||||
Severity: o.Severity, Summary: o.Summary, Headline: o.Headline, Explanation: o.Explanation,
|
||||
Resolved: o.Resolved, Evidence: []Evidence{{At: now, Said: said}},
|
||||
Resolved: o.Resolved, Needs: o.Needs, Actions: o.Actions, Evidence: []Evidence{{At: now, Said: said}},
|
||||
Source: o.Source, Raised: now, LastObserved: now, Observations: 1, Count: 1,
|
||||
Resolver: orSelf(o.Resolver)}
|
||||
change := ChangeRaised
|
||||
@@ -240,7 +240,7 @@ 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
|
||||
c.Headline, c.Explanation, c.Resolved = o.Headline, o.Explanation, o.Resolved
|
||||
c.Headline, c.Explanation, c.Resolved, c.Needs, c.Actions = o.Headline, o.Explanation, o.Resolved, o.Needs, o.Actions
|
||||
escalatedWords(&c)
|
||||
if o.Machine != "" {
|
||||
c.Subject.Machine = o.Machine
|
||||
|
||||
Reference in New Issue
Block a user