Say what the operator needs to do, keep quiet what needs nothing, and take answers from the desk
mesh/merge-gate pass: builds dunst, messenger → g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
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, nor act without
opening a session. Each message now opens with a verdict, a warning that needs
nothing is not sent, and a notification offers answers the messenger performs
through their seat verbs; dunst's send gains actions and says the chosen one
(hq ADR 0253).
This commit is contained in:
jochen
2026-10-08 13:57:33 +02:00
parent a6296dfd6f
commit f00b5a19b1
18 changed files with 911 additions and 89 deletions
+238
View File
@@ -0,0 +1,238 @@
package main
// What the operator needs to do, and answering from the notification itself (novox/hq ADR 0253).
//
// - **Every explanation opens with a verdict**, written by the controller: "Nothing for you to do." or
// "Needs you:" and one thing the operator can do themselves.
// - **A warning that needs nothing is not sent.** The mesh handles it or it passes by itself; it is
// kept as open here (`operator-channel.open`, the history says "quiet") and in the controller's
// conditions, and said the moment it needs the operator: when it becomes urgent at its bound, or its
// words change to "Needs you:". An urgent condition is always said.
// - **A notification offers answers**: the condition's actions (Release, Stop, Start, Restart, Silence
// for a week) and Details. Each is a token; a channel that can take an answer (the desk, through the
// node-notifier seat's actions) hands the token back, and this holder calls the action's seat verb
// itself, with a why that names the operator, the channel and the label, then edits the notification
// to say what came of it. Details shows the summary and the key, for whoever wants them: the desk is a
// private surface (ADR 0234 §6).
// - None of these verbs declares an authorising tier yet (to-be 46 phase 5): each is a verb any granted
// principal may already call, which is ADR 0234's acknowledge. When one declares a tier, its answer goes
// through the controller's authorise request and answer instead, and the desk asks for the code.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
)
// Action is one answer a condition offers: a label, and the seat verb called with these arguments when
// it is chosen. Machine is set for a seat every machine holds. An argument "why" given empty is filled
// with the operator's words.
type Action struct {
Label string `json:"label"`
Verb string `json:"verb"`
Machine string `json:"machine,omitempty"`
Arguments map[string]string `json:"arguments,omitempty"`
}
// DetailsLabel is the answer every condition's notification offers.
const DetailsLabel = "Details"
// MostActions bounds what a notification offers, Details included.
const MostActions = 4
func sameActions(a, b []Action) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
}
// needsNothing is a record the operator need not hear of now: words of its own that open "Nothing for
// you to do.", and only a warning. A record without words (an older controller, a notice) is said as
// before.
func needsNothing(r *Record) bool {
return r.Headline != "" && r.Needs == "" && r.Severity == Warning && r.Origin == "condition"
}
// choices are the answers a record's message offers, each with its token, made once and kept.
func (h *Holder) choices(r *Record) []Choice {
if r.Origin != "condition" {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
if r.Tokens == nil {
r.Tokens = map[string]int{}
for i := range r.Actions {
if i == MostActions-1 {
break
}
r.Tokens[token()] = i
}
r.Tokens[token()] = -1
}
out := make([]Choice, 0, len(r.Tokens))
for i := range r.Actions {
for t, n := range r.Tokens {
if n == i {
out = append(out, Choice{Token: t, Label: r.Actions[i].Label})
}
}
}
for t, n := range r.Tokens {
if n == -1 {
out = append(out, Choice{Token: t, Label: DetailsLabel})
}
}
return out
}
func token() string {
var b [8]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// Chosen takes an answer a channel handed back: the token of a choice, on a machine. It performs the
// action, says what came of it where the question was shown, and answers that in words.
func (h *Holder) Chosen(channel, machine, tok string) (string, error) {
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
var r *Record
idx := 0
for _, rec := range h.open {
if i, ok := rec.Tokens[tok]; ok {
r, idx = rec, i
}
}
h.mu.Unlock()
if r == nil {
h.Logf("[messenger] an answer on %s came back for no open message (it ended, or the token is unknown)", channel)
return "", errorf("no open message offers that answer: it may have ended meanwhile")
}
now := h.Now()
headline, _ := wordsOf(r)
if idx == -1 {
// Each line held to the content rule on its own: a line that carries what may not leave is said
// withheld, and the rest is still shown.
var lines []string
for _, l := range []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")} {
if refusal, ok := Check(l); !ok {
l = "(a line carrying " + refusal.What + " is withheld; the controller's conditions hold it)"
}
lines = append(lines, l)
}
m := Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n")}
_, err := h.showOn(channel, machine, m)
h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "details", Outcome: outcomeOf(err)})
return "shown", err
}
a := r.Actions[idx]
args := map[string]any{}
for k, v := range a.Arguments {
args[k] = v
}
if v, ok := a.Arguments["why"]; ok && v == "" {
args["why"] = fmt.Sprintf("the operator chose %s on the %s notification %q", a.Label, channel, headline)
}
address := "seat:" + a.Verb
if a.Machine != "" {
address += "@" + a.Machine
}
var err error
if h.Ask == nil {
err = errorf("this holder cannot call the mesh")
} else {
var raw json.RawMessage
raw, err = h.Ask(address, args)
if err == nil {
err = refusalIn(raw)
}
}
outcome := "done"
title := a.Label + ": done. " + headline
body := "You chose " + a.Label + " at " + now.In(h.zone()).Format("15:04") + "."
if err != nil {
outcome = "failed: " + err.Error()
title = "Could not " + strings.ToLower(a.Label) + ": " + headline
body = "The mesh refused it: " + firstLineOf(err.Error())
if _, ok := Check(body); !ok {
body = "The mesh refused it; the reason carries what may not be shown here."
}
}
h.mu.Lock()
r.Answered = a.Label + ": " + outcome
sent := r.Sent[channel]
h.mu.Unlock()
h.persist(r)
m := Message{Title: title, Body: body, Quiet: err == nil}
if ch := h.channelNamed(channel); ch != nil && sent != "" && ch.CanEdit() {
if e := ch.Edit(sent, m); e != nil {
_, _ = h.showOn(channel, machine, m)
}
} else {
_, _ = h.showOn(channel, machine, m)
}
h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "answer " + a.Label, Outcome: outcome})
h.Logf("[messenger] the operator chose %s for %s on %s: %s", a.Label, r.Key, channel, outcome)
return outcome, err
}
func outcomeOf(err error) string {
if err != nil {
return "failed: " + err.Error()
}
return "sent"
}
func firstLineOf(s string) string {
s, _, _ = strings.Cut(s, "\n")
if len(s) > 160 {
s = s[:160] + "…"
}
return s
}
// refusalIn reads a tool's answer for a refusal: an error result is an error.
func refusalIn(raw json.RawMessage) error {
var wrapped struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
Error string `json:"error"`
}
if json.Unmarshal(raw, &wrapped) == nil {
if wrapped.IsError && len(wrapped.Content) > 0 {
return errorf("%s", wrapped.Content[0].Text)
}
if wrapped.Error != "" {
return errorf("%s", wrapped.Error)
}
}
return nil
}
func (h *Holder) channelNamed(name string) Channel {
for _, ch := range h.channels() {
if ch.Name() == name {
return ch
}
}
return nil
}
// showOn shows a message of the holder's own on one channel, on one machine where the channel has several.
func (h *Holder) showOn(channel, machine string, m Message) (string, error) {
ch := h.channelNamed(channel)
if ch == nil {
return "", errorf("no channel %s", channel)
}
if d, ok := ch.(*Desktop); ok && machine != "" {
return d.showOne(machine, m)
}
return ch.Send(m)
}
@@ -19,6 +19,8 @@ package main
// - Times are RFC 3339.
// - `headline`, `explanation` and `resolved` are the plain words the operator reads (novox/hq ADR
// 0253): a condition from a controller older than them has none, and is said from its summary.
// - `needs` is what the operator does, empty for nothing (the explanation opens with the verdict);
// `actions` are the answers a notification offers, each {label, verb, machine, arguments}.
// - `key` is `<scope>.<id>.<kind>`. When it is absent it is made from subject and kind; when
// neither gives one the event is unreadable.
// - A cleared event carries the condition as last held, and may add `cleared` (its time).
@@ -65,6 +67,8 @@ type Condition struct {
Headline string
Explanation string
Resolved string
Needs string
Actions []Action
Source string
Resolver string
Raised time.Time
@@ -172,6 +176,12 @@ func DecodeCondition(event string, body []byte) (Condition, error) {
c.Headline = str("headline")
c.Explanation = str("explanation")
c.Resolved = str("resolved")
c.Needs = str("needs")
if v, ok := raw["actions"]; ok && string(v) != "null" {
if e := json.Unmarshal(v, &c.Actions); e != nil {
return Condition{}, fmt.Errorf("%s: actions is not a list of {label, verb, machine, arguments}", event)
}
}
c.Resolver = str("resolver")
c.Raised = when("raised")
c.LastObserved = when("last-observed", "last_observed", "lastObserved")
@@ -97,6 +97,11 @@ func (d *Desktop) show(m Message, replace map[string]int) (string, error) {
if replace != nil {
args["replace_id"] = replace[machine]
}
if len(m.Actions) > 0 {
// The answers the notification offers (node-notifier's actions): the notifier hands the chosen
// token back as an event, which the holder takes (answers.go).
args["actions"] = m.Actions
}
raw, err := d.Ask("seat:node-notifier.send@"+machine, args)
if err == nil {
var id int
@@ -140,6 +145,14 @@ func (d *Desktop) LastAnswers() map[string]string {
return out
}
// showOne shows a message on one machine only: an answer's outcome, or details, where it was asked.
func (d *Desktop) showOne(machine string, m Message) (string, error) {
only := &Desktop{Machines: func() []string { return []string{machine} }, Ask: d.Ask}
id, err := only.Send(m)
d.note(machine, err)
return id, err
}
// notificationID reads the notifier's answer, {"id": N}, whether the runtime hands it over bare or in
// the tool reply's text content.
func notificationID(raw json.RawMessage) (int, error) {
+50 -13
View File
@@ -80,12 +80,19 @@ const (
DigestLines = 15
)
// Message is what a channel shows: a title line and a body.
// Message is what a channel shows: a title line and a body, and the answers it offers.
type Message struct {
Title string
Body string
Urgent bool
Quiet bool // a clearing: shown without urgency
Title string
Body string
Urgent bool
Quiet bool // a clearing: shown without urgency
Actions []Choice
}
// Choice is one answer a message offers: the token a channel hands back when it is chosen, and its label.
type Choice struct {
Token string `json:"token"`
Label string `json:"label"`
}
func (m Message) Text() string {
@@ -122,9 +129,16 @@ type Record struct {
Summary string `json:"summary"`
// Headline, Explanation and Resolved are the plain words the operator reads (novox/hq ADR 0253):
// the controller's, or a module's through notify; empty, the message is said from the summary.
Headline string `json:"headline,omitempty"`
Explanation string `json:"explanation,omitempty"`
Resolved string `json:"resolved,omitempty"`
Headline string `json:"headline,omitempty"`
Explanation string `json:"explanation,omitempty"`
Resolved string `json:"resolved,omitempty"`
// Needs is what the operator does, empty for nothing; Actions the answers offered, each by a token
// (Tokens: token -> the action's index, or -1 for the details) a channel hands back when chosen.
Needs string `json:"needs,omitempty"`
Actions []Action `json:"actions,omitempty"`
Tokens map[string]int `json:"tokens,omitempty"`
// Answered is what the operator chose from a notification, and what came of it.
Answered string `json:"answered,omitempty"`
Origin string `json:"origin"`
More string `json:"more"`
Raised time.Time `json:"raised"`
@@ -146,13 +160,17 @@ type Record struct {
// recordOf is a condition as this holder keeps it.
func recordOf(c Condition) Record {
return Record{Key: c.Key, Kind: c.Kind, Subject: c.SubjectWords(), Severity: c.Severity, Summary: c.Summary,
Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Origin: "condition",
More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill}
Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Needs: c.Needs, Actions: c.Actions,
Origin: "condition",
More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill}
}
// takeWords keeps the plain words current, as the summary is: a condition's words follow what is seen.
func (r *Record) takeWords(from Record) {
r.Headline, r.Explanation, r.Resolved = from.Headline, from.Explanation, from.Resolved
r.Headline, r.Explanation, r.Resolved, r.Needs = from.Headline, from.Explanation, from.Resolved, from.Needs
if !sameActions(r.Actions, from.Actions) {
r.Actions, r.Tokens = from.Actions, nil
}
}
func (r *Record) silenced(now time.Time) bool {
@@ -196,6 +214,8 @@ type Holder struct {
Logf func(string, ...any)
// Emit states a fact as this module (refused); nil states nothing.
Emit func(event string, body any) error
// Ask calls a seat verb through the mesh, as this module: the answers the operator chooses.
Ask Asker
// Zone is the operator's time zone, which every time in a message is said in; nil is this machine's.
Zone func() *time.Location
@@ -367,8 +387,10 @@ func (h *Holder) raised(rec Record, quiet bool) {
old := h.open[rec.Key]
h.mu.Unlock()
if old != nil && old.Cleared.IsZero() {
// Said again while open: the same message. Its words are kept current; nothing is sent.
// Said again while open: the same message. Its words are kept current; nothing is sent — unless
// it was kept quiet as needing nothing and now needs the operator.
h.mu.Lock()
wasQuiet := needsNothing(old)
old.Summary, old.Subject, old.Kind, old.SilencedTill = rec.Summary, rec.Subject, rec.Kind, rec.SilencedTill
old.takeWords(rec)
if rec.Severity != "" {
@@ -376,6 +398,7 @@ func (h *Holder) raised(rec Record, quiet bool) {
}
old.Seen = now
h.mu.Unlock()
h.nowNeeded(old, wasQuiet, quiet)
h.persist(old)
return
}
@@ -415,6 +438,13 @@ func (h *Holder) raised(rec Record, quiet bool) {
h.persist(&r)
}
// nowNeeded says a record kept quiet as needing nothing, once its words say it needs the operator.
func (h *Holder) nowNeeded(r *Record, wasQuiet, quiet bool) {
if wasQuiet && !needsNothing(r) && !r.told() && !quiet && !r.silenced(h.Now()) && !h.queued(r.Key) {
h.deliver(r, "raised")
}
}
func (h *Holder) changed(rec Record, quiet bool) {
h.mu.Lock()
old := h.open[rec.Key]
@@ -428,6 +458,7 @@ func (h *Holder) changed(rec Record, quiet bool) {
}
h.mu.Lock()
escalated := old.Severity == Warning && rec.Severity == Urgent
wasQuiet := needsNothing(old)
old.Summary, old.Subject, old.SilencedTill = rec.Summary, rec.Subject, rec.SilencedTill
old.takeWords(rec)
if rec.Severity != "" {
@@ -435,9 +466,12 @@ func (h *Holder) changed(rec Record, quiet bool) {
}
old.Seen = h.Now()
h.mu.Unlock()
if escalated && !quiet && !old.silenced(h.Now()) {
switch {
case escalated && !quiet && !old.silenced(h.Now()):
// Routing differs for urgent: said once more, to both channels.
h.deliver(old, "escalated")
default:
h.nowNeeded(old, wasQuiet, quiet)
}
h.persist(old)
}
@@ -556,6 +590,9 @@ func (h *Holder) say(r *Record, what string) Message {
m := compose(r, what, now, h.zone(), 0)
refusal, ok := Check(m.Text())
if ok {
if what != "cleared" {
m.Actions = h.choices(r)
}
return m
}
h.mu.Lock()
@@ -73,7 +73,8 @@ func start() *clock { return &clock{t: time.Date(2026, 10, 6,
func cond(key, sev, summary string) Condition {
parts := strings.Split(key, ".")
return Condition{Key: key, Scope: parts[0], ID: parts[1], Kind: parts[len(parts)-1], Severity: sev, Summary: summary,
Headline: headlineOf(key), Explanation: "What it means: " + summary + "."}
Headline: headlineOf(key), Explanation: "Needs you: look at it. What it means: " + summary + ".",
Needs: "look at it."}
}
// headlineOf is the headline a test condition carries: its key in words, so a message can be told by it
@@ -122,7 +123,7 @@ func TestARaisedConditionIsSentOnceByItsKey(t *testing.T) {
t.Fatalf("urgent: telegram %d, desktop %d; want one each", len(tg.sends), len(dt.sends))
}
keyless(t, tg.sends...)
if tg.sends[0].Title != "Urgent: machine ace silent" || !strings.HasPrefix(tg.sends[0].Body, "What it means: ") ||
if tg.sends[0].Title != "Urgent: machine ace silent" || !strings.HasPrefix(tg.sends[0].Body, "Needs you: look at it. What it means: ") ||
!strings.Contains(tg.sends[0].Body, "\nSince ") {
t.Fatalf("message: %q", tg.sends[0].Text())
}
+38
View File
@@ -171,6 +171,7 @@ func main() {
Ask: stdio.Ask,
},
Store: stateStore{},
Ask: stdio.Ask,
Logf: logf,
Zone: func() *time.Location {
loc, err := zoneOf(settings().TimeZone)
@@ -247,6 +248,7 @@ func run(h *Holder, l *listening) {
h.Condition(event, c)
return nil
}
go listenForAnswers(h)
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(ControllerSeat+".*", handle)
if err == nil {
@@ -260,6 +262,42 @@ func run(h *Holder, l *listening) {
}
}
// AnswerEvent is the desk's notifier saying which answer the operator chose: the dunst holder of
// node-notifier emits it as its module's event until seats publish events of their own (to-be 46 phase 3).
const AnswerEvent = "dunst.action-chosen"
// listenForAnswers takes the answers the operator chooses on the desk, retried until it can.
func listenForAnswers(h *Holder) {
handle := func(e stdio.Envelope) error {
if e.Key != AnswerEvent {
return nil
}
var body struct {
Token string `json:"token"`
Machine string `json:"machine"`
}
if err := json.Unmarshal(e.Body, &body); err != nil || body.Token == "" {
logf("[messenger] an answer from the desk could not be read: %s", firstLineOf(string(e.Body)))
return nil
}
machine := body.Machine
if machine == "" {
machine = e.Node
}
_, _ = h.Chosen("desktop", machine, body.Token)
return nil
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.Subscribe("dunst.*", handle); err == nil {
logf("[messenger] listening for the operator's answers on the desk")
return
} else {
logf("[messenger] not hearing the desk's answers yet (%v); asking again in %s", err, wait)
}
time.Sleep(wait)
}
}
// syncFromController reads the controller's open conditions and gives them to the holder.
func syncFromController(h *Holder) {
asked := h.Now()
@@ -55,10 +55,13 @@ func TestItDeclaresAndHoldsTheOperatorChannel(t *testing.T) {
t.Fatalf("seats %+v claims %+v", m.Seats, m.Claims)
}
if !reflect.DeepEqual(m.Consumes, []string{
ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared}) {
ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared,
AnswerEvent}) {
t.Fatalf("consumes %v", m.Consumes)
}
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions"}) {
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions",
"seat:mesh-delivery.release", "seat:mesh-delivery.stop", "seat:" + ControllerSeat + ".plans",
"seat:node-service-manager.restart"}) {
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
}
if !reflect.DeepEqual(m.State, []string{"open", "sent"}) {
+49 -8
View File
@@ -27,6 +27,11 @@ func (i item) urgent() bool { return i.Severity == Urgent && i.What != "cleared"
// the desktop to Telegram when no session there takes it. No channel at all: it is unsent, said in
// the status, and tried again every minute.
func (h *Holder) deliver(r *Record, what string) {
if needsNothing(r) && what != "escalated" {
// Nothing for the operator to do, and only a warning: kept, not sent (ADR 0253).
h.record(Sent{At: h.Now(), Channel: "-", Key: r.Key, What: what, Outcome: "quiet: nothing for the operator to do"})
return
}
var to []Channel
if r.Severity == Urgent {
for _, ch := range h.channels() {
@@ -231,11 +236,6 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
if len(list) == 0 || (!force && !h.due(ch, list, now)) {
return
}
type entry struct {
it item
r *Record
msg Message
}
var entries []entry
for _, it := range list {
r, m := h.resolve(it, now)
@@ -279,6 +279,48 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
}
return
}
if len(entries) > 1 {
// A message that asks the operator something goes alone, so its answers come with it.
var alone, rest []entry
for _, e := range entries {
if len(e.msg.Actions) > 1 {
alone = append(alone, e)
} else {
rest = append(rest, e)
}
}
if len(alone) > 0 {
for _, e := range alone {
if !h.sendEntries(ch, []entry{e}, now) {
h.failed(ch, list, taken, now, errorf("could not send"))
return
}
}
if len(rest) > 0 && !h.sendEntries(ch, rest, now) {
h.failed(ch, list, taken, now, errorf("could not send"))
return
}
h.drop(name, taken)
return
}
}
if !h.sendEntries(ch, entries, now) {
h.failed(ch, list, taken, now, errorf("could not send"))
return
}
h.drop(name, taken)
}
// entry is one held item resolved to its record and message.
type entry struct {
it item
r *Record
msg Message
}
// sendEntries sends entries as one message — itself when alone, a digest when several — and records it.
func (h *Holder) sendEntries(ch Channel, entries []entry, now time.Time) bool {
name := ch.Name()
var m Message
if len(entries) == 1 {
m = entries[0].msg
@@ -293,10 +335,8 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
id, err := ch.Send(m)
h.noteChannel(name, err)
if err != nil {
h.failed(ch, list, taken, now, err)
return
return false
}
h.drop(name, taken)
h.mu.Lock()
h.lastSend[name] = now
h.lastTry[name] = time.Time{}
@@ -342,6 +382,7 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
h.persist(e.r)
}
}
return true
}
// drop removes the first n held items of a channel: those this flush took.
+190 -57
View File
@@ -14,37 +14,57 @@ import (
// commit, verb or markup — and a clearance in one line.
type example struct {
name, before string
key, kind, severity, summary string
subject map[string]string
headline, explanation, resolved string
title, body string // what the desktop shows
cleared string // the clearance's one line
openFor time.Duration
name, before string
key, kind, severity, summary string
subject map[string]string
headline, explanation string
needs, resolved string
actions []Action
popup bool // shown at all: a warning that needs nothing is not
title, body string // what the desktop shows
offers string // the answers it offers, by label
choose string // the answer chosen in the test
asked string // the seat verb that answer calls, and its arguments
cleared string // the clearance's one line
openFor time.Duration
}
const waitingSummary = "the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word to start: " +
"`mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " +
"plan-1791454185265004861 --why …` starts it by hand"
const waitingBefore = "CLEARED after 26 min: the walk of novox/mesh-catalog a6385479 has waited 56m0s for " +
"mesh-delivery's word to start: `mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " +
"plan-1791454185265004861 --why ...` starts it by hand\nabout: plan plan-1791454185265004861 (waiting)\n" +
"since: 2026-10-08 10:40 UTC\nkey: plan.plan-1791454185265004861.waiting\nmore: conditions show " +
"plan.plan-1791454... 65004861.waiting"
var examples = []example{{
name: "a delivery waiting",
before: "CLEARED after 26 min: the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word " +
"to start: `mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " +
"plan-1791454185265004861 --why ...` starts it by hand\nabout: plan plan-1791454185265004861 (waiting)\n" +
"since: 2026-10-08 10:40 UTC\nkey: plan.plan-1791454185265004861.waiting\nmore: conditions show " +
"plan.plan-1791454... 65004861.waiting",
name: "a delivery waiting, under its bound", before: waitingBefore,
key: "plan.plan-1791454185265004861.waiting", kind: "waiting", severity: Warning,
subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"},
summary: "the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word to start: " +
"`mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " +
"plan-1791454185265004861 --why …` starts it by hand",
subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"}, summary: waitingSummary,
headline: "openrazer delivery waiting to start",
explanation: "The change to openrazer is merged and built, and has waited 56 minutes for mesh-delivery (the " +
"module that decides when a delivery goes out) to let it start. Nothing to do yet; it becomes urgent after " +
"4 hours. To start it now, have an agent start it by hand.",
resolved: "openrazer delivery no longer waiting",
title: "openrazer delivery waiting to start",
body: "The change to openrazer is merged and built, and has waited 56 minutes for mesh-delivery (the module " +
"that decides when a delivery goes out) to let it start. Nothing to do yet; it becomes urgent after 4 hours. " +
"To start it now, have an agent start it by hand.\nSince 12:40.",
cleared: "Resolved: openrazer delivery no longer waiting, after 26 min", openFor: 26 * time.Minute,
explanation: "Nothing for you to do. The change to openrazer is merged and built, and has waited 56 minutes for " +
"mesh-delivery (the module that decides when a delivery goes out) to let it start. It becomes a question for " +
"you if it still waits after 4 hours.",
resolved: "openrazer delivery no longer waiting", popup: false, openFor: 26 * time.Minute,
}, {
name: "a delivery waiting, past its bound", before: waitingBefore,
key: "plan.plan-1791454185265004861.waiting", kind: "waiting", severity: Urgent,
subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"}, summary: waitingSummary,
headline: "openrazer delivery waiting to start", needs: "start it, or stop it.",
explanation: "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.",
actions: []Action{
{Label: "Start", Verb: "mesh-controller.plans", Arguments: map[string]string{"go": "plan-1791454185265004861", "why": ""}},
{Label: "Stop", Verb: "mesh-controller.plans", Arguments: map[string]string{"stop": "plan-1791454185265004861", "why": ""}}},
resolved: "openrazer delivery no longer waiting", popup: true,
title: "Urgent: openrazer delivery waiting to start",
body: "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.\nSince 12:40.",
offers: "Start, Stop, Details", choose: "Start",
asked: `seat:mesh-controller.plans {"go":"plan-1791454185265004861","why":"the operator chose Start on the desktop notification \"openrazer delivery waiting to start\""}`,
cleared: "Resolved: openrazer delivery no longer waiting, after 5.0 h", openFor: 5 * time.Hour,
}, {
name: "a module unhealthy",
before: "WARNING: openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the account's own " +
@@ -54,13 +74,17 @@ var examples = []example{{
subject: map[string]string{"scope": "module", "id": "openrazer.g14", "machine": "g14"},
summary: "openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the account's own " +
"service manager (exit-code)",
headline: "openrazer not working on g14",
explanation: "openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as " +
"soon as it runs again; if it stays, it needs a look at why it fails.",
resolved: "openrazer works again on g14",
title: "openrazer not working on g14",
body: "openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as soon " +
"as it runs again; if it stays, it needs a look at why it fails.\nSince 12:40.",
headline: "openrazer not working on g14", needs: "restart it; if it fails again, the details say why.",
explanation: "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.",
actions: []Action{{Label: "Restart", Verb: "node-service-manager.restart", Machine: "g14",
Arguments: map[string]string{"unit": "openrazer-daemon.service", "scope": "user"}}},
resolved: "openrazer works again on g14", popup: true,
title: "openrazer not working on g14",
body: "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.\nSince 12:40.",
offers: "Restart, Details", choose: "Restart",
asked: `seat:node-service-manager.restart@g14 {"scope":"user","unit":"openrazer-daemon.service"}`,
cleared: "Resolved: openrazer works again on g14, after 2.0 h", openFor: 2 * time.Hour,
}, {
name: "failed units on a machine",
@@ -73,13 +97,19 @@ var examples = []example{{
summary: "shanks's service manager is degraded: 3 failed unit(s) no module places — mnt-recalbox.mount, " +
"storage-media.mount, greenclip.service (the account's own manager). Each is the machine's own: mend or " +
"remove it there, or have a module place it",
headline: "3 failed services on shanks",
explanation: "On shanks, mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module " +
"manages them, so the mesh does not repair them: mend or remove them on shanks.",
resolved: "No failed services on shanks any more",
title: "3 failed services on shanks",
body: "On shanks, mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, " +
"so the mesh does not repair them: mend or remove them on shanks.\nSince 12:40.",
headline: "3 failed services on shanks", needs: "mend or remove them on shanks, or silence this if they do not matter.",
explanation: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, " +
"mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh " +
"does not repair them.",
actions: []Action{{Label: "Silence for a week", Verb: "mesh-controller.conditions",
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": ""}}},
resolved: "No failed services on shanks any more", popup: true,
title: "3 failed services on shanks",
body: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, mnt-recalbox " +
"(a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh does not repair " +
"them.\nSince 12:40.",
offers: "Silence for a week, Details", choose: "Silence for a week",
asked: `seat:mesh-controller.conditions {"for":"7d","silence":"machine.shanks.units","why":"the operator chose Silence for a week on the desktop notification \"3 failed services on shanks\""}`,
cleared: "Resolved: no failed services on shanks any more, after 45 min", openFor: 45 * time.Minute,
}, {
name: "a healer wanted",
@@ -92,19 +122,37 @@ var examples = []example{{
summary: "\"push\" was repaired by hand 35 times in 14 days, the last by g14.node-tools, through the " +
"mesh-controller seat: a healer is wanted for it",
headline: "Push keeps being fixed by hand",
explanation: "A person repaired push by hand 35 times in 14 days. A healer (an automatic repair) is wanted for " +
"it; nothing is broken now.",
resolved: "Resolved: no more hand repairs of push",
title: "Push keeps being fixed by hand",
body: "A person repaired push by hand 35 times in 14 days. A healer (an automatic repair) is wanted for it; " +
"nothing is broken now.\nSince 12:40.",
cleared: "Resolved: no more hand repairs of push, after 3 days", openFor: 3 * 24 * time.Hour,
explanation: "Nothing for you to do. A person repaired push by hand 35 times in 14 days, so an automatic repair " +
"is wanted for it. Nothing is broken now.",
resolved: "Resolved: no more hand repairs of push", popup: false, openFor: 3 * 24 * time.Hour,
}, {
name: "a delivery held",
before: "WARNING: the delivery novox/hq@055550802096 has been held for 36h2m6s, past its bound of 24h0m0s (it " +
"waits for the operator): healer H2 may none: the state is the operator's — `mesh-delivery.show " +
"novox/hq@055550802096`\nabout: delivery novox/hq@055550802096 (stalled)\nsince: 2026-10-07 00:38 UTC\n" +
"key: delivery.novox/hq_055550802096.stalled\nmore: conditions show delivery.novox/hq_055550802096.stalled",
key: "delivery.novox/hq_055550802096.stalled", kind: "stalled", severity: Warning,
subject: map[string]string{"scope": "delivery", "id": "novox/hq@055550802096"},
summary: "the delivery novox/hq@055550802096 has been held for 36h2m6s, past its bound of 24h0m0s (it waits for " +
"the operator): healer H2 may none: the state is the operator's — `mesh-delivery.show novox/hq@055550802096`",
headline: "Delivery of hq held for 36 hours", needs: "release it, or stop it.",
explanation: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours, past its limit.",
actions: []Action{
{Label: "Release", Verb: "mesh-delivery.release", Arguments: map[string]string{"id": "novox/hq@055550802096", "why": ""}},
{Label: "Stop", Verb: "mesh-delivery.stop", Arguments: map[string]string{"id": "novox/hq@055550802096", "why": ""}}},
resolved: "Delivery of hq is no longer held", popup: true,
title: "Delivery of hq held for 36 hours",
body: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours, past its limit.\nSince 12:40.",
offers: "Release, Stop, Details", choose: "Release",
asked: `seat:mesh-delivery.release {"id":"novox/hq@055550802096","why":"the operator chose Release on the desktop notification \"Delivery of hq held for 36 hours\""}`,
cleared: "Resolved: delivery of hq is no longer held, after 3 min", openFor: 3 * time.Minute,
}}
// event is the body a controller with plain words sends for a condition.
func (e example) event(at time.Time, cleared bool) []byte {
body := map[string]any{"key": e.key, "kind": e.kind, "subject": e.subject, "severity": e.severity,
"summary": e.summary, "headline": e.headline, "explanation": e.explanation, "resolved": e.resolved,
"needs": e.needs, "actions": e.actions,
"raised": "2026-10-08T10:40:00Z", "at": at.Format(time.RFC3339), "silenced": nil}
if cleared {
body["cleared"] = at.Format(time.RFC3339)
@@ -113,16 +161,21 @@ func (e example) event(at time.Time, cleared bool) []byte {
return raw
}
func TestRealNotificationsReadInOneGlance(t *testing.T) {
func TestRealNotificationsReadInOneGlanceAndAreAnsweredFromTheNotification(t *testing.T) {
brussels, err := time.LoadLocation("Europe/Brussels")
if err != nil {
t.Fatal(err)
}
for _, e := range examples {
t.Run(e.name, func(t *testing.T) {
h, _, dt, c, _ := newHolder(t)
h.Telegram = nil
h, tg, dt, c, _ := newHolder(t)
h.Zone = func() *time.Location { return brussels }
var asked []string
h.Ask = func(address string, body any) (json.RawMessage, error) {
raw, _ := json.Marshal(body)
asked = append(asked, address+" "+string(raw))
return json.RawMessage(`{"ok":true}`), nil
}
c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC)
raised, err := DecodeCondition(EventRaised, e.event(c.now(), false))
if err != nil {
@@ -130,34 +183,114 @@ func TestRealNotificationsReadInOneGlance(t *testing.T) {
}
h.Condition(EventRaised, raised)
settle(h, c)
if !e.popup {
if len(dt.sends)+len(tg.sends) != 0 {
t.Fatalf("a warning that needs nothing was shown: %+v %+v", dt.sends, tg.sends)
}
hist := h.History(5)["sends"].([]Sent)
if len(hist) == 0 || !strings.HasPrefix(hist[0].Outcome, "quiet") {
t.Errorf("not kept as quiet in the history: %+v", hist)
}
t.Logf("\nBEFORE:\n%s\n\nAFTER: no popup. Kept in the controller's conditions and the channel's history:\n%s\n%s",
e.before, e.headline, e.explanation)
return
}
if len(dt.sends) != 1 {
t.Fatalf("desktop sends %d", len(dt.sends))
}
m := dt.sends[0]
keyless(t, m)
if m.Title != e.title || m.Body != e.body {
t.Errorf("shows\n %q\n %q\nwant\n %q\n %q", m.Title, m.Body, e.title, e.body)
var labels []string
for _, a := range m.Actions {
labels = append(labels, a.Label)
}
for _, w := range []string{"`", "UTC", "key:", "more:", "about:", "plan-", "a6385479"} {
if m.Title != e.title || m.Body != e.body || strings.Join(labels, ", ") != e.offers {
t.Errorf("shows\n %q\n %q\n %v\nwant\n %q\n %q\n %s", m.Title, m.Body, labels, e.title, e.body, e.offers)
}
for _, w := range []string{"`", "UTC", "key:", "more:", "about:", "plan-", "a6385479", "agent", "by hand"} {
if strings.Contains(m.Text(), w) {
t.Errorf("it still says %q: %q", w, m.Text())
}
}
// The operator answers from the notification.
var tok string
for _, a := range m.Actions {
if a.Label == e.choose {
tok = a.Token
}
}
outcome, err := h.Chosen("desktop", "g14", tok)
if err != nil || outcome != "done" || len(asked) != 1 || asked[0] != e.asked {
t.Fatalf("chose %s: %q %v\nasked %v\nwant %s", e.choose, outcome, err, asked, e.asked)
}
done := dt.edits["1"]
if done.Title != e.choose+": done. "+e.headline {
t.Errorf("the answer's outcome: %q", done.Title)
}
c.pass(e.openFor - BurstWindow)
cleared, err := DecodeCondition(EventCleared, e.event(c.now(), true))
if err != nil {
t.Fatal(err)
}
h.Condition(EventCleared, cleared)
done := dt.edits["1"]
if done.Title != e.cleared || done.Body != "" {
t.Errorf("clearance %q / %q, want one line %q", done.Title, done.Body, e.cleared)
end := dt.edits["1"]
if end.Title != e.cleared || end.Body != "" || len(end.Actions) != 0 {
t.Errorf("clearance %q / %q / %v, want one line %q", end.Title, end.Body, end.Actions, e.cleared)
}
t.Logf("\nBEFORE:\n%s\n\nAFTER:\n%s\n%s\n\nCLEARED:\n%s", e.before, m.Title, m.Body, done.Title)
t.Logf("\nBEFORE:\n%s\n\nAFTER:\n%s\n%s\n[%s]\n\nCHOSEN %s:\n%s\n%s\n\nCLEARED:\n%s", e.before, m.Title,
m.Body, strings.Join(labels, "] ["), e.choose, done.Title, done.Body, end.Title)
})
}
}
// **A warning kept quiet is said once it needs the operator**: at its bound it becomes urgent, and then
// its words say "Needs you:".
func TestAQuietWarningIsSaidWhenItBecomesUrgent(t *testing.T) {
h, tg, dt, c, _ := newHolder(t)
e := examples[0]
raised, _ := DecodeCondition(EventRaised, e.event(c.now(), false))
h.Condition(EventRaised, raised)
settle(h, c)
if len(dt.sends)+len(tg.sends) != 0 {
t.Fatal("said while it needed nothing")
}
past := examples[1]
c.pass(4 * time.Hour)
changed, _ := DecodeCondition(EventChanged, past.event(c.now(), false))
h.Condition(EventChanged, changed)
settle(h, c)
if len(dt.sends) != 1 || len(tg.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: start it, or stop it.") {
t.Fatalf("desktop %+v telegram %d", dt.sends, len(tg.sends))
}
}
// **Details shows the summary and the key, where it was asked**, and an answer for a message that ended
// is refused in words.
func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) {
h, _, dt, c, _ := newHolder(t)
h.Zone = func() *time.Location { return time.UTC }
e := examples[5]
raised, _ := DecodeCondition(EventRaised, e.event(c.now(), false))
h.Condition(EventRaised, raised)
settle(h, c)
m := dt.sends[0]
details := m.Actions[len(m.Actions)-1]
if details.Label != DetailsLabel {
t.Fatalf("%+v", m.Actions)
}
if _, err := h.Chosen("desktop", "g14", details.Token); err != nil {
t.Fatal(err)
}
shown := dt.sends[len(dt.sends)-1]
if shown.Title != "Details: "+e.headline || !strings.HasPrefix(shown.Body, e.summary) ||
!strings.Contains(shown.Body, "Since: 8 Oct 10:40") {
t.Errorf("details: %q / %q", shown.Title, shown.Body)
}
if _, err := h.Chosen("desktop", "g14", "not-a-token"); err == nil {
t.Error("an unknown answer was taken")
}
}
// **A condition from a controller without plain words is said from its summary**, its markup taken out.
func TestAConditionWithoutPlainWordsIsSaidFromItsSummary(t *testing.T) {
h, tg, _, c, _ := newHolder(t)