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
+11 -1
View File
@@ -13,6 +13,16 @@ The notifier as a module (novox/hq ADR 0208, research 026/05).
- **Requires no display of its own.** dunst speaks both X11 and Wayland and picks the one the session
has, so it serves an X session and a later sway one alike.
## Answers from a notification
`send` with `actions` runs `notify-send --wait` with one `--action` per answer, answers the id at once and
waits in this bundle for the operator's choice (dunst offers the actions on a middle click, or in its
context menu). The token chosen is said as this module's event `action-chosen`; it becomes the
node-notifier seat's event once seats publish events of their own (novox/hq to-be 46 phase 3). Nothing
is said for a notification closed, expired or replaced without an answer, and a wait ends after seven
days. A restart of this bundle ends the waits it held: the notification stays shown, and its answers do
nothing then (novox/hq ADR 0253).
## Tools
Every tool goes over the account's session bus. None needs the screen, and each answers clearly when
@@ -20,7 +30,7 @@ the account is not logged in.
| tool | does |
|---|---|
| `node-notifier.send` | a notification: title, body, urgency, sender, icon, how long; answers its id |
| `node-notifier.send` | a notification: title, body, urgency, sender, icon, how long, and the answers it offers (`actions`: token and label); answers its id, and says the chosen answer later as the event `action-chosen` {id, token, machine} |
| `node-notifier.history` | what was shown, newest first, with how long ago |
| `dunst_pause` / `dunst_resume` | do not disturb: notifications are held back, not lost |
| `dunst_close_all` | clear the screen; the history keeps them |
+168
View File
@@ -0,0 +1,168 @@
package main
// Answers from a notification (novox/hq ADR 0253, to-be 46 §11 "the desk"): `send` takes actions, each a
// token and a label. dunst offers them on the notification (a middle click, or its context menu, which
// is the node-launcher's menu); the one the operator chooses is emitted as this module's event
// `action-chosen` {id, token, machine}, and the operator channel's holder acts on it. It is this module's
// event until seats publish events of their own (to-be 46 phase 3), when it becomes the node-notifier
// seat's.
//
// notify-send waits for the answer (`--wait`) in this process: the id is answered at once, and the
// answer, if any, comes later. An answer is never invented: a notification closed, expired or replaced
// emits nothing, and a wait ends after AnswerWait. A restart of this bundle ends the waits it held,
// which the README says; the notification stays shown and its answers do nothing then.
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"syscall"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Choice is one action a notification offers.
type Choice struct {
Token string `json:"token"`
Label string `json:"label"`
}
const (
// MostChoices is how many actions one notification offers.
MostChoices = 4
// AnswerWait is how long an answer is waited for.
AnswerWait = 7 * 24 * time.Hour
// ChosenEvent is the event an answer is said as.
ChosenEvent = "action-chosen"
)
var tokenShape = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
// emit says an event as this module; a seam a test replaces.
var emit = func(event string, body any) error { return stdio.Emit(event, body) }
// choicesOf reads `actions`: a list of {token, label}.
func choicesOf(args map[string]any) ([]Choice, error) {
v, present := args["actions"]
if !present || v == nil {
return nil, nil
}
list, ok := v.([]any)
if !ok {
return nil, errors.New("actions is a list of {token, label}")
}
if len(list) > MostChoices {
return nil, fmt.Errorf("at most %d actions", MostChoices)
}
var out []Choice
seen := map[string]bool{}
for _, it := range list {
o, ok := it.(map[string]any)
if !ok {
return nil, errors.New("actions is a list of {token, label}")
}
tok, _ := o["token"].(string)
label, _ := o["label"].(string)
label = strings.TrimSpace(label)
switch {
case !tokenShape.MatchString(tok):
return nil, fmt.Errorf("an action's token is letters, digits, - and _, at most 64: %q", tok)
case seen[tok]:
return nil, fmt.Errorf("the token %q is given twice", tok)
case label == "" || len(label) > 40 || strings.ContainsAny(label, "\n="):
return nil, fmt.Errorf("an action's label is one short line without '=': %q", label)
}
seen[tok] = true
out = append(out, Choice{Token: tok, Label: label})
}
return out, nil
}
// actionArgs are notify-send's arguments for the choices, and its wait.
func actionArgs(choices []Choice) []string {
args := []string{"--wait"}
for _, c := range choices {
args = append(args, "--action="+c.Token+"="+c.Label)
}
return args
}
// sendWaiting runs notify-send with the choices, answers the id it prints first, and waits for the
// answer in the background: the next line it prints, if it is one of the tokens.
func (s Session) sendWaiting(args []string, choices []Choice) (SendResult, error) {
path, err := exec.LookPath("notify-send")
if err != nil {
return SendResult{}, errors.New("notify-send is not installed on this machine")
}
cmd := exec.Command(path, args...)
cmd.Env = s.Env()
if home := operatorHome(); exists(home) {
cmd.Dir = home
}
out, err := cmd.StdoutPipe()
if err != nil {
return SendResult{}, err
}
var errOut capped
cmd.Stderr = &errOut
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
return SendResult{}, fmt.Errorf("notify-send: %w", err)
}
lines := bufio.NewReader(out)
first := make(chan string, 1)
go func() {
line, _ := lines.ReadString('\n')
first <- strings.TrimSpace(line)
}()
var idLine string
select {
case idLine = <-first:
case <-time.After(busTimeout):
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
_ = cmd.Wait()
return SendResult{}, fmt.Errorf("notify-send did not answer an id within %s: %w", busTimeout, ErrTimedOut)
}
id, err := strconv.Atoi(idLine)
if err != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
_ = cmd.Wait()
return SendResult{}, fmt.Errorf("notify-send answered no id: %q %s", idLine, strings.TrimSpace(errOut.String()))
}
go awaitAnswer(cmd, lines, id, choices)
return SendResult{ID: id}, nil
}
// awaitAnswer reads what notify-send says when the notification ends, and says a chosen token.
func awaitAnswer(cmd *exec.Cmd, lines io.Reader, id int, choices []Choice) {
timer := time.AfterFunc(AnswerWait, func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) })
defer timer.Stop()
rest, _ := io.ReadAll(io.LimitReader(lines, 4096))
_ = cmd.Wait()
if tok, ok := chosenToken(string(rest), choices); ok {
body := map[string]any{"id": id, "token": tok, "machine": os.Getenv("MESH_NODE")}
if err := emit(ChosenEvent, body); err != nil {
fmt.Fprintf(os.Stderr, "[dunst] the operator chose an action on notification %d and it could not be said: %v\n", id, err)
}
}
}
// chosenToken is the token notify-send printed after the id, when it is one offered.
func chosenToken(rest string, choices []Choice) (string, bool) {
for _, line := range strings.Split(rest, "\n") {
line = strings.TrimSpace(line)
for _, c := range choices {
if line == c.Token {
return c.Token, true
}
}
}
return "", false
}
@@ -0,0 +1,92 @@
package main
import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// **A notification offers answers, and the chosen one is said as an event** (novox/hq ADR 0253): the id
// is answered at once, the answer comes when the operator chooses, and nothing is said for a
// notification closed without one.
func TestANotificationsChosenAnswerIsSaidAsAnEvent(t *testing.T) {
withBus(t)
bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo 42; sleep 0.2; echo "$CHOSEN"`})
t.Setenv("LOG", filepath.Join(bin, "log"))
t.Setenv("MESH_NODE", "g14")
var mu sync.Mutex
var said []map[string]any
before := emit
emit = func(event string, body any) error {
mu.Lock()
defer mu.Unlock()
if event != ChosenEvent {
t.Errorf("event %q", event)
}
said = append(said, body.(map[string]any))
return nil
}
t.Cleanup(func() { emit = before })
n, err := notificationOf(map[string]any{"summary": "Delivery of hq held for 36 hours", "body": "Needs you: release it, or stop it.",
"actions": []any{map[string]any{"token": "a1b2", "label": "Release"}, map[string]any{"token": "c3d4", "label": "Stop"}}})
if err != nil {
t.Fatal(err)
}
t.Setenv("CHOSEN", "c3d4")
start := time.Now()
got, err := Send(n)
if err != nil || got.ID != 42 || time.Since(start) > 150*time.Millisecond {
t.Fatalf("%+v, %v, after %s: the id is answered before the answer", got, err, time.Since(start))
}
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
if !strings.Contains(string(asked), "[--wait][--action=a1b2=Release][--action=c3d4=Stop][--]") {
t.Fatalf("notify-send was asked %s", asked)
}
deadline := time.Now().Add(3 * time.Second)
for {
mu.Lock()
n := len(said)
mu.Unlock()
if n > 0 || time.Now().After(deadline) {
break
}
time.Sleep(20 * time.Millisecond)
}
mu.Lock()
if len(said) != 1 || said[0]["token"] != "c3d4" || said[0]["id"] != 42 || said[0]["machine"] != "g14" {
t.Fatalf("said %v", said)
}
said = nil
mu.Unlock()
// Closed without an answer, or an answer nobody offered: nothing is said.
for _, chosen := range []string{"", "zzzz"} {
t.Setenv("CHOSEN", chosen)
if _, err := Send(n); err != nil {
t.Fatal(err)
}
}
time.Sleep(600 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
if len(said) != 0 {
t.Fatalf("an answer was invented: %v", said)
}
}
func TestActionsAreRefusedForWhatTheyCannotBe(t *testing.T) {
for _, bad := range []any{"x", []any{"x"}, []any{map[string]any{"token": "a b", "label": "x"}},
[]any{map[string]any{"token": "a", "label": ""}}, []any{map[string]any{"token": "a", "label": "x=y"}},
[]any{map[string]any{"token": "a", "label": "x"}, map[string]any{"token": "a", "label": "y"}},
[]any{map[string]any{"token": "a", "label": "1"}, map[string]any{"token": "b", "label": "2"},
map[string]any{"token": "c", "label": "3"}, map[string]any{"token": "d", "label": "4"},
map[string]any{"token": "e", "label": "5"}}} {
if _, err := notificationOf(map[string]any{"summary": "x", "actions": bad}); err == nil {
t.Errorf("accepted %v", bad)
}
}
}
+11 -1
View File
@@ -27,6 +27,7 @@ type Notification struct {
ExpireMS int
Category string
ReplaceID int
Actions []Choice
}
func notificationOf(args map[string]any) (Notification, error) {
@@ -66,7 +67,10 @@ func notificationOf(args map[string]any) (Notification, error) {
if n.Category, err = text(args, "category", false); err != nil {
return n, err
}
n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1)
if n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1); err != nil {
return n, err
}
n.Actions, err = choicesOf(args)
return n, err
}
@@ -94,11 +98,17 @@ func Send(n Notification) (SendResult, error) {
if n.ReplaceID > 0 {
args = append(args, "--replace-id="+strconv.Itoa(n.ReplaceID))
}
if len(n.Actions) > 0 {
args = append(args, actionArgs(n.Actions)...)
}
// "--" so a title that starts with a dash is a title.
args = append(args, "--", n.Summary)
if n.Body != "" {
args = append(args, n.Body)
}
if len(n.Actions) > 0 {
return s.sendWaiting(args, n.Actions)
}
r, err := s.run(busTimeout, "", "notify-send", args...)
if err != nil {
return SendResult{}, err
+7 -2
View File
@@ -22,8 +22,9 @@ func tools() []stdio.Tool {
{
Name: "node-notifier.send",
Description: "Show a notification on the operator's desktop: a title, a body, an urgency (low, " +
"normal, critical), and optionally the sending application's name, an icon and how long it stays. " +
"Answers the notification's id.",
"normal, critical), and optionally the sending application's name, an icon, how long it stays and " +
"the answers it offers (actions: token and label each). Answers the notification's id; the answer " +
"the operator chooses is said later as the event action-chosen {id, token, machine}.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
@@ -35,6 +36,10 @@ func tools() []stdio.Tool {
"expire_ms": map[string]any{"type": "integer", "description": "how long it stays; 0 until dismissed (default: the urgency's own)"},
"category": map[string]any{"type": "string", "description": "a notification category, e.g. email.arrived"},
"replace_id": map[string]any{"type": "integer", "description": "replace the notification with this id instead of adding one"},
"actions": map[string]any{"type": "array", "description": "at most 4 answers the notification offers, " +
"each {token, label}; the chosen token is said as the event action-chosen",
"items": map[string]any{"type": "object", "properties": map[string]any{
"token": map[string]any{"type": "string"}, "label": map[string]any{"type": "string"}}}},
},
"required": []string{"summary"},
},
@@ -19,6 +19,7 @@ type manifest struct {
Claims []claim `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Emits []string `json:"emits"`
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
+4 -1
View File
@@ -69,5 +69,8 @@
]
}
]
}
},
"emits": [
"action-chosen"
]
}
+14
View File
@@ -44,6 +44,20 @@ condition's resolved line and how long it was open: "Resolved: openrazer works a
A condition from a controller older than plain words, or a module's notice without a headline, is said from
its summary, with code spans taken out (`cmd/messenger/words.go`).
**Every explanation opens with a verdict**: "Nothing for you to do." or "Needs you:" and one thing the
operator can do themselves. **A warning that needs nothing is not sent**: it stays open here and in the
controller's conditions, the history says it was kept quiet, and it is said the moment it needs the
operator (urgent at its bound, or its words now say "Needs you:"). An urgent condition is always said.
**A notification offers answers** (`cmd/messenger/answers.go`): the condition's actions (Release, Stop,
Start, Restart, Silence for a week) and Details. On the desk they are dunst's actions (a middle click on
the notification, or its context menu); the dunst module says the chosen one as its event
`dunst.action-chosen`, and this module calls the action's seat verb itself, with a why naming the
operator, the channel and the label, then edits the notification to say what came of it. Details shows
the summary, the key and since when, on the machine that asked; a line the content rule refuses is said
withheld. A message that offers answers is never folded into a digest. Telegram shows the words but no
buttons yet (to-be 46 phase 6).
| When | What |
|---|---|
| `condition-raised` | one message, deduplicated by the condition's key |
+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)
+7 -2
View File
@@ -27,14 +27,19 @@
"consumes": [
"mesh-controller.condition-raised",
"mesh-controller.condition-changed",
"mesh-controller.condition-cleared"
"mesh-controller.condition-cleared",
"dunst.action-chosen"
],
"emits": [
"refused"
],
"invokes": [
"seat:node-notifier.send",
"seat:mesh-controller.conditions"
"seat:mesh-controller.conditions",
"seat:mesh-delivery.release",
"seat:mesh-delivery.stop",
"seat:mesh-controller.plans",
"seat:node-service-manager.restart"
],
"state": [
"open",