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).
342 lines
12 KiB
Go
342 lines
12 KiB
Go
package conditions
|
|
|
|
// Plain words (novox/hq ADR 0253): **a condition carries what the operator reads, in plain words, beside
|
|
// what an agent reads.** The summary is one line in the mesh's words for whoever looks closer — it names
|
|
// plans, commits and the verbs that act, and stays so. The operator reads a notification between other
|
|
// work: a popup that said "the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's
|
|
// word to start: `mesh-delivery.show` …" was a wall of identifiers nobody could act on. So every condition
|
|
// also carries:
|
|
//
|
|
// - a **headline**: a few plain words naming the thing and what is wrong ("openrazer not running on
|
|
// 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 **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
|
|
// wording registered for its kind says them; otherwise a plain sentence made from its scope does, and a
|
|
// test suite is told (Unworded) so the kind gets words of its own. Each is held to the plain rule (Plain):
|
|
// no identifiers, hashes, keys, verb syntax, markup or clock times — a message names no time of its own,
|
|
// since the channel says when, in the operator's time.
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/novox/mesh-controller/internal/outward"
|
|
)
|
|
|
|
// 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 = 420
|
|
NeedsMax = 120
|
|
)
|
|
|
|
var (
|
|
wordingsMu sync.RWMutex
|
|
wordings = map[string]func(Observation) Words{}
|
|
)
|
|
|
|
// Wording registers the plain words of one condition kind.
|
|
func Wording(kind string, words func(Observation) Words) {
|
|
wordingsMu.Lock()
|
|
defer wordingsMu.Unlock()
|
|
wordings[kind] = words
|
|
}
|
|
|
|
// Worded says whether a kind has words of its own.
|
|
func Worded(kind string) bool {
|
|
wordingsMu.RLock()
|
|
defer wordingsMu.RUnlock()
|
|
_, ok := wordings[kind]
|
|
return ok
|
|
}
|
|
|
|
// Unworded is told of every observation said in borrowed words: its kind has none registered, or the
|
|
// words it was given break the plain rule. The keeper says it plainly anyway; a test suite sets this to
|
|
// fail the producer.
|
|
var Unworded func(o Observation, why string)
|
|
|
|
// The plain rule's shapes.
|
|
var (
|
|
hexID = regexp.MustCompile(`\b[0-9a-f]{7,40}\b`)
|
|
numberedID = regexp.MustCompile(`\b[a-z]+-[0-9]{6,}\b`)
|
|
dotted = regexp.MustCompile(`\b[a-z][a-z0-9_-]*\.[a-z][a-z0-9_-]*\b`)
|
|
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|$)")
|
|
)
|
|
|
|
// Plain says whether text is plain words the operator reads at a glance, and if not, what is not: an
|
|
// identifier (a hash, a numbered id, a dotted name such as a key or a verb), a Go duration, a clock time
|
|
// or date (the channel says when, in the operator's time), a command's flag, markup, a line break, or
|
|
// anything the operator's channel withholds (ADR 0234 §6). machines are names that may appear.
|
|
func Plain(text string, machines ...string) (string, bool) {
|
|
if strings.TrimSpace(text) == "" {
|
|
return "nothing is said", false
|
|
}
|
|
if strings.ContainsAny(text, "\n\r\t") {
|
|
return "a line break", false
|
|
}
|
|
if m := markup.FindString(text); m != "" {
|
|
return "markup (" + m + ")", false
|
|
}
|
|
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
|
|
}
|
|
return shape.what + " (" + strings.TrimSpace(m) + ")", false
|
|
}
|
|
}
|
|
if r, ok := outward.Check(text, machines...); !ok {
|
|
return r.String(), false
|
|
}
|
|
return "", true
|
|
}
|
|
|
|
// PlainWords checks all three, with their bounds.
|
|
func PlainWords(w Words, machines ...string) (string, bool) {
|
|
if why, ok := Plain(w.Headline, machines...); !ok {
|
|
return "headline: " + why, false
|
|
}
|
|
if len(w.Headline) > HeadlineMax {
|
|
return fmt.Sprintf("headline: longer than %d characters", HeadlineMax), false
|
|
}
|
|
if why, ok := Plain(w.Explanation, machines...); !ok {
|
|
return "explanation: " + why, false
|
|
}
|
|
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
|
|
}
|
|
if len(w.Resolved) > HeadlineMax+20 {
|
|
return fmt.Sprintf("resolved: longer than %d characters", HeadlineMax+20), false
|
|
}
|
|
return "", true
|
|
}
|
|
|
|
// 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{Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved, Needs: o.Needs,
|
|
Actions: o.Actions}
|
|
var w Words
|
|
from := ""
|
|
switch {
|
|
case given.Headline != "":
|
|
w, from = given, "its source"
|
|
default:
|
|
wordingsMu.RLock()
|
|
fn := wordings[o.Kind]
|
|
wordingsMu.RUnlock()
|
|
if fn != nil {
|
|
w, from = fn(o), "the wording of "+o.Kind
|
|
if given.Explanation != "" {
|
|
w.Explanation = given.Explanation
|
|
}
|
|
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 == "" {
|
|
w.Resolved = "Resolved: " + lowerFirst(w.Headline)
|
|
}
|
|
if from == "" {
|
|
if Unworded != nil {
|
|
Unworded(o, "the kind "+o.Kind+" has no plain words")
|
|
}
|
|
w = scopeWords(o)
|
|
} else if why, ok := PlainWords(w, machines...); !ok {
|
|
if Unworded != nil {
|
|
Unworded(o, from+" is not plain: "+why)
|
|
}
|
|
w = scopeWords(o)
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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() && c.Needs == "" {
|
|
c.Needs = Escalated
|
|
c.Explanation = Verdict(c.Needs, strings.TrimSpace(strings.TrimPrefix(c.Explanation, NothingToDo)))
|
|
}
|
|
}
|
|
|
|
// scopeWords is what is said of a kind with no words of its own: what kind of thing, and which one
|
|
// where its name is a name (a machine, a module), never an id.
|
|
func scopeWords(o Observation) Words {
|
|
thing := ThingWords(o)
|
|
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.", 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.",
|
|
Resolved: "Resolved: the mesh is fine again", Needs: ResolverNeeds(o)}
|
|
}
|
|
return w
|
|
}
|
|
|
|
// ThingWords is what a condition is about, in plain words: "the machine ace", "openrazer on g14", "a
|
|
// delivery". An id that is not a name (a plan's, a delivery's, a call's) is never said.
|
|
func ThingWords(o Observation) string {
|
|
switch o.Scope {
|
|
case ScopeMachine:
|
|
if o.Machine != "" {
|
|
return o.Machine
|
|
}
|
|
return "a machine"
|
|
case ScopeModule:
|
|
module := strings.TrimSuffix(o.ID, "."+o.Machine)
|
|
if o.Machine != "" && module != "" && !strings.Contains(module, ".") {
|
|
return module + " on " + o.Machine
|
|
}
|
|
return "a module"
|
|
case ScopeProvider:
|
|
if module, _, _ := strings.Cut(o.ID, "."); module != "" && o.Machine != "" {
|
|
return module + " on " + o.Machine
|
|
}
|
|
return "a provider"
|
|
case ScopePlan:
|
|
return "a walk"
|
|
case ScopeDelivery:
|
|
return "a delivery"
|
|
case ScopeBuild:
|
|
return "a build"
|
|
case ScopeCall:
|
|
return "a tool call"
|
|
case ScopeMerge:
|
|
return "a merge"
|
|
case ScopeSeat:
|
|
return "a seat's holder"
|
|
case ScopeBus:
|
|
return "the bus"
|
|
case ScopeCore:
|
|
return "the controller"
|
|
case ScopeProbe:
|
|
return "the self-check"
|
|
}
|
|
return "the mesh"
|
|
}
|
|
|
|
// 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 ""
|
|
}
|
|
|
|
// Capital is s with its first letter upper case.
|
|
func Capital(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
return strings.ToUpper(s[:1]) + s[1:]
|
|
}
|
|
|
|
func lowerFirst(s string) string {
|
|
if s == "" || (len(s) > 1 && strings.ToUpper(s[:2]) == s[:2]) {
|
|
return s
|
|
}
|
|
return strings.ToLower(s[:1]) + s[1:]
|
|
}
|