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).
401 lines
14 KiB
Go
401 lines
14 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. 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": "", "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.
|
|
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`)
|
|
byHand = regexp.MustCompile(`(?i)\bby hand\b`)
|
|
latinAbbr = regexp.MustCompile(`\b(e\.g|i\.e|etc)\.`)
|
|
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
|
|
}
|
|
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") || !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
|
|
}
|
|
}
|
|
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
|
|
}
|
|
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) + byHand.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)
|
|
}
|
|
// 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")
|
|
}
|
|
w = scopeWords(o)
|
|
} else if why, ok := PlainWords(w, machines...); !ok {
|
|
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 == "" {
|
|
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:]
|
|
}
|