Merge pull request 'messenger and dunst: plain notifications that say whether you are needed, and take the answer from the desk (hq ADR 0253, issue 320)' (#126) from feat/plain-notifications into main

This commit was merged in pull request #126.
This commit is contained in:
2026-10-08 12:20:36 +00:00
22 changed files with 1419 additions and 140 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"
]
}
+27 -1
View File
@@ -35,6 +35,29 @@ what is said is decided by the state now and by when a thing happened, never by
- **Start grace.** Nothing is sent in the **first minute** after start but that one urgent message;
what arrives meanwhile goes out, coalesced, when the minute ends.
**A message reads in one glance** (novox/hq ADR 0253). Its title is the condition's headline — a few
plain words such as "openrazer not working on g14" — prefixed "Urgent:", "Still open:" or "Now urgent:"
where that applies; its body is the condition's explanation (what happened, what it means, whether the
operator needs to act) and since when, in the operator's time zone. No key, id, commit, command or markup:
those stay in the controller's `conditions` and in this module's history. A clearance is one line, the
condition's resolved line and how long it was open: "Resolved: openrazer works again on g14, after 26 min".
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 |
@@ -76,6 +99,9 @@ Nothing is sent until these are given; `messenger_status` says which is missing.
2. **The chat id**, as a setting: `settings` for `messenger` with `{"telegram-chat-id": "<id>"}`.
3. **The desktop machines**, as a setting: `{"desktop-machines": ["<the machine the operator sits at>", …]}`.
Without it, warnings go to Telegram.
4. **The time zone**, as a setting: `{"time-zone": "<an IANA zone name>"}`. Every time a message says is
said in it; not given, the zone of the machine this runs on. A name this machine does not know is
logged and that zone used instead.
## Tools
@@ -83,7 +109,7 @@ Nothing is sent until these are given; `messenger_status` says which is missing.
|---|---|
| `operator-channel.open` | what is open now, urgent first, with where it went, silenced, reminded, held, refused, unsent |
| `operator-channel.history` | what was said lately, and the refusals |
| `operator-channel.notify` | a message from a module using the seat: key, severity, summary; `clear` to end it |
| `operator-channel.notify` | a message from a module using the seat: key, severity, summary, and best a plain `headline`, `explanation` and `resolved` line; `clear` to end it |
| `messenger_status` | whether the operator can be reached and why not; `check` asks Telegram whether the token works |
| `messenger_recent` | the recent sends, edits, digests and failures |
| `messenger_test` | a test message now, to telegram, desktop or both |
+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)
}
@@ -17,6 +17,10 @@ package main
// - `severity` is `urgent` or `warning`. Anything else is not guessed: the event is unreadable.
// - `silenced` is absent, null, or an object {until, by, why}; `until` in the past is not silenced.
// - 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).
@@ -60,6 +64,11 @@ type Condition struct {
Machine string
Severity string
Summary string
Headline string
Explanation string
Resolved string
Needs string
Actions []Action
Source string
Resolver string
Raised time.Time
@@ -164,6 +173,15 @@ func DecodeCondition(event string, body []byte) (Condition, error) {
c.Kind = str("kind")
c.Severity = str("severity")
c.Summary = str("summary")
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) {
@@ -205,7 +205,7 @@ func TestTodaysBacklogWithAnUrgentStillOpenIsOneMessage(t *testing.T) {
if len(tg.sends) != 1 || len(dt.sends) != 1 || len(tg.edits)+len(dt.edits) != 0 {
t.Fatalf("telegram %d desktop %d", len(tg.sends), len(dt.sends))
}
if !strings.HasPrefix(tg.sends[0].Title, "URGENT: ") || !strings.Contains(tg.sends[0].Text(), "merge.mesh-catalog.f2b51686.not-acted") {
if !strings.HasPrefix(tg.sends[0].Title, "Urgent: ") || !strings.Contains(tg.sends[0].Text(), "Merge not acted") {
t.Fatalf("message: %q", tg.sends[0].Text())
}
if got := len(h.Open()); got != 4 {
@@ -280,11 +280,11 @@ func TestABurstIsOneMessage(t *testing.T) {
t.Fatalf("sent inside the window: %d", len(tg.sends))
}
runFor(h, c, time.Minute)
if len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "WARNING: 5 new warnings") {
if len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "Mesh: 5 new problems") {
t.Fatalf("sends %d: %+v", len(tg.sends), tg.sends)
}
for i := 0; i < 5; i++ {
if !strings.Contains(tg.sends[0].Body, fmt.Sprintf("seat.s%d.silent", i)) {
if !strings.Contains(tg.sends[0].Body, fmt.Sprintf("seat s%d silent", i)) {
t.Fatalf("the digest does not name s%d: %q", i, tg.sends[0].Body)
}
}
@@ -293,7 +293,7 @@ func TestABurstIsOneMessage(t *testing.T) {
h.Condition(EventCleared, Condition{Key: fmt.Sprintf("seat.s%d.silent", i)})
}
runFor(h, c, time.Minute)
if len(tg.sends) != 2 || len(tg.edits) != 0 || !strings.HasPrefix(tg.sends[1].Title, "CLEARED: 3 cleared") {
if len(tg.sends) != 2 || len(tg.edits) != 0 || !strings.HasPrefix(tg.sends[1].Title, "Mesh: 3 resolved") {
t.Fatalf("sends %d edits %d: %q", len(tg.sends), len(tg.edits), tg.sends[len(tg.sends)-1].Title)
}
// A burst of urgent ones: the first at once, the rest as one.
@@ -303,7 +303,7 @@ func TestABurstIsOneMessage(t *testing.T) {
c.pass(time.Second)
}
runFor(h, c, time.Minute)
if len(tg.sends) != 4 || !strings.HasPrefix(tg.sends[3].Title, "URGENT: 5 new urgent") {
if len(tg.sends) != 4 || !strings.HasPrefix(tg.sends[3].Title, "Urgent: 5 urgent problems") {
t.Fatalf("sends %d: %q", len(tg.sends), tg.sends[len(tg.sends)-1].Title)
}
}
@@ -322,7 +322,7 @@ func TestTheDesktopIsGentle(t *testing.T) {
// Urgent is not held to the desktop's rhythm.
h.Condition(EventRaised, cond("machine.ace.silent", Urgent, "the home server is silent"))
h.Flush()
if len(dt.sends) != 2 || !strings.HasPrefix(dt.sends[1].Title, "URGENT: ") || len(tg.sends) != 1 {
if len(dt.sends) != 2 || !strings.HasPrefix(dt.sends[1].Title, "Urgent: ") || len(tg.sends) != 1 {
t.Fatalf("urgent: desktop %d telegram %d", len(dt.sends), len(tg.sends))
}
// The urgent message took the held warnings along — one notification, read in one go. What
@@ -358,7 +358,7 @@ func TestNothingIsSentInTheFirstMinuteButTheUrgentOpenAsOne(t *testing.T) {
if len(res.Said) != 2 || len(res.State) != 1 || len(res.News) != 1 {
t.Fatalf("sync: %+v", res)
}
if len(tg.sends) != 1 || len(dt.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "URGENT: 2 new urgent") {
if len(tg.sends) != 1 || len(dt.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "Urgent: 2 urgent problems") {
t.Fatalf("telegram %d desktop %d: %+v", len(tg.sends), len(dt.sends), tg.sends)
}
c.pass(10 * time.Second)
@@ -377,8 +377,8 @@ func TestNothingIsSentInTheFirstMinuteButTheUrgentOpenAsOne(t *testing.T) {
if len(dt.sends) != 2 || len(tg.sends) != 2 {
t.Fatalf("after the grace: telegram %d desktop %d", len(tg.sends), len(dt.sends))
}
if d := dt.sends[1]; !strings.Contains(d.Body, "plan.42.stalled") || !strings.Contains(d.Body, "plan.43.stalled") ||
!strings.Contains(d.Body, "machine.shanks.silent") || strings.Contains(d.Body, "plan.41.stalled") {
if d := dt.sends[1]; !strings.Contains(d.Body, "plan 42 stalled") || !strings.Contains(d.Body, "plan 43 stalled") ||
!strings.Contains(d.Body, "machine shanks silent") || strings.Contains(d.Body, "plan 41 stalled") {
t.Fatalf("desktop digest: %q", d.Text())
}
}
+79 -70
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 {
@@ -115,11 +122,23 @@ func editsSilently(ch Channel) bool {
// Record is one open message, kept in the module's own state so a restart forgets nothing.
type Record struct {
Key string `json:"key"`
Kind string `json:"kind"`
Subject string `json:"subject"`
Severity string `json:"severity"`
Summary string `json:"summary"`
Key string `json:"key"`
Kind string `json:"kind"`
Subject string `json:"subject"`
Severity string `json:"severity"`
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"`
// 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"`
@@ -138,6 +157,22 @@ type Record struct {
Seen time.Time `json:"seen,omitempty"`
}
// 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, 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, 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 {
return !r.SilencedTill.IsZero() && now.Before(r.SilencedTill)
}
@@ -179,6 +214,10 @@ 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
work sync.Mutex // one event, call or tick at a time
mu sync.Mutex // what the status reads
@@ -289,10 +328,7 @@ func (h *Holder) Condition(event string, c Condition) {
h.lastHeard = now
synced := h.syncedAt
h.mu.Unlock()
rec := Record{
Key: c.Key, Kind: c.Kind, Subject: c.SubjectWords(), Severity: c.Severity, Summary: c.Summary,
Origin: "condition", More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill,
}
rec := recordOf(c)
when := c.When(event)
quiet := false
switch {
@@ -339,9 +375,9 @@ func (h *Holder) Unreadable(key string, err error) {
h.mu.Unlock()
h.Logf("[messenger] refused %s: %v (unreadable events since start: %d)", key, err, n)
h.sayOnce("messenger.unreadable-event", time.Hour, Message{
Title: "WARNING: a condition event could not be read",
Body: fmt.Sprintf("the operator-channel's holder could not read %d condition event(s) from the controller; "+
"what was wrong is in messenger_status. A condition may be open that was not said.", n),
Title: "A mesh message could not be read",
Body: fmt.Sprintf("The messenger could not read %d update(s) from the controller, so a problem may be open "+
"that was not said. An agent can find out why in the messenger's status.", n),
})
}
@@ -351,14 +387,18 @@ 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 != "" {
old.Severity = rec.Severity
}
old.Seen = now
h.mu.Unlock()
h.nowNeeded(old, wasQuiet, quiet)
h.persist(old)
return
}
@@ -368,6 +408,7 @@ func (h *Holder) raised(rec Record, quiet bool) {
old.Cleared = time.Time{}
old.Count++
old.Summary, old.Severity, old.SilencedTill = rec.Summary, rec.Severity, rec.SilencedTill
old.takeWords(rec)
old.Seen = now
h.mu.Unlock()
if !quiet && !old.silenced(now) && old.told() {
@@ -397,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]
@@ -410,15 +458,20 @@ 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 != "" {
old.Severity = rec.Severity
}
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)
}
@@ -521,52 +574,6 @@ func remindAfter(severity string) time.Duration {
return RemindWarning
}
// compose is the message for a record. withhold names what the content rule refused, so the words
// that carried it are not sent.
func compose(r *Record, what string, now time.Time, withhold int) Message {
sev := strings.ToUpper(r.Severity)
if sev == "" {
sev = "WARNING"
}
summary := r.Summary
if withhold > 0 {
summary = "channel-refused: this message carried " + r.Refused + ", so its words are withheld"
}
var title string
switch what {
case "raised":
title = sev + ": " + summary
case "reminder":
title = "STILL OPEN after " + roughly(now.Sub(r.Raised)) + ": " + summary
case "escalated":
title = "NOW URGENT: " + summary
case "reopened":
title = sev + " (open again, " + fmt.Sprint(r.Count) + " times): " + summary
case "cleared":
title = "CLEARED after " + roughly(r.Cleared.Sub(r.Raised)) + ": " + summary
default:
title = sev + ": " + summary
}
lines := []string{}
if withhold < 3 && r.Subject != "" {
about := "about: " + r.Subject
if r.Kind != "" {
about += " (" + r.Kind + ")"
}
lines = append(lines, about)
}
lines = append(lines, "since: "+r.Raised.UTC().Format("2006-01-02 15:04")+" UTC")
if withhold < 2 {
lines = append(lines, "key: "+r.Key)
if r.More != "" {
lines = append(lines, "more: "+r.More)
}
} else {
lines = append(lines, "more: conditions (the open ones, through the mesh)")
}
return Message{Title: title, Body: strings.Join(lines, "\n"), Urgent: r.Severity == Urgent, Quiet: what == "cleared"}
}
// say composes a record's message under the content rule: refused, it is composed again with less of
// it, until what remains may leave. The refusal is recorded and stated once per record.
func (h *Holder) say(r *Record, what string) Message {
@@ -574,15 +581,18 @@ func (h *Holder) say(r *Record, what string) Message {
if r.Refused != "" {
// Refused before: its words stay withheld in every later message too.
for w := 1; w <= 3; w++ {
m := compose(r, what, now, w)
m := compose(r, what, now, h.zone(), w)
if _, ok := Check(m.Text()); ok {
return m
}
}
}
m := compose(r, what, now, 0)
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()
@@ -605,13 +615,12 @@ func (h *Holder) say(r *Record, what string) Message {
}
}
for w := 1; w <= 3; w++ {
m := compose(r, what, now, w)
m := compose(r, what, now, h.zone(), w)
if _, ok := Check(m.Text()); ok {
return m
}
}
return Message{Title: "WARNING: channel-refused: a message carried " + refusal.What + " and was withheld",
Body: "more: conditions (the open ones, through the mesh)", Urgent: r.Severity == Urgent}
return withheld(refusal.What, r.Severity == Urgent)
}
// deliver sends a record's message where its severity routes it.
+32 -12
View File
@@ -72,7 +72,25 @@ func (c *clock) pass(d time.Duration) { c.t = c.t.Add(d) }
func start() *clock { return &clock{t: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)} }
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}
return Condition{Key: key, Scope: parts[0], ID: parts[1], Kind: parts[len(parts)-1], Severity: sev, Summary: 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
// and never carries the key itself (novox/hq ADR 0253).
func headlineOf(key string) string { return strings.ReplaceAll(key, ".", " ") }
// keyless fails a message that carries a condition's key: the operator reads words, never keys.
func keyless(t *testing.T, ms ...Message) {
t.Helper()
for _, m := range ms {
for _, w := range strings.Fields(m.Text()) {
if strings.Count(w, ".") >= 2 && !strings.HasSuffix(w, ".") {
t.Errorf("a message carries a key %q: %q", w, m.Text())
}
}
}
}
// settle lets a burst window pass and sends what is due.
@@ -104,7 +122,9 @@ func TestARaisedConditionIsSentOnceByItsKey(t *testing.T) {
if len(tg.sends) != 1 || len(dt.sends) != 1 {
t.Fatalf("urgent: telegram %d, desktop %d; want one each", len(tg.sends), len(dt.sends))
}
if !strings.Contains(tg.sends[0].Text(), "machine.ace.silent") || !strings.HasPrefix(tg.sends[0].Title, "URGENT: ") {
keyless(t, tg.sends...)
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())
}
if open := h.Open(); len(open) != 1 || open[0].Count != 1 {
@@ -151,7 +171,7 @@ func TestTheCapIsALastLineAndIsSaidOnce(t *testing.T) {
if len(tg.sends) != CapPerHour+1 {
t.Fatalf("sent %d, want the cap of %d and one saying so", len(tg.sends), CapPerHour)
}
if last := tg.sends[CapPerHour]; !strings.HasPrefix(last.Title, "HELD BACK: telegram is at its cap") {
if last := tg.sends[CapPerHour]; !strings.HasPrefix(last.Title, "Mesh messages paused for this hour") {
t.Fatalf("the cap was not said: %q", last.Title)
}
st := h.Status("listening")
@@ -172,11 +192,11 @@ func TestTheCapIsALastLineAndIsSaidOnce(t *testing.T) {
t.Fatalf("sends %d", len(tg.sends))
}
d := tg.sends[len(tg.sends)-1]
if !strings.HasPrefix(d.Title, "URGENT: 5 new urgent") {
if !strings.HasPrefix(d.Title, "Urgent: 5 urgent problems") {
t.Fatalf("digest: %q", d.Title)
}
for i := 20; i < 25; i++ {
if !strings.Contains(d.Body, fmt.Sprintf("machine.m%d.silent", i)) {
if !strings.Contains(d.Body, fmt.Sprintf("machine m%d silent", i)) {
t.Fatalf("the digest does not name m%d: %q", i, d.Body)
}
}
@@ -194,7 +214,7 @@ func TestAMessageCarryingAnAddressIsRefusedAndSaidWithItsWordsWithheld(t *testin
if strings.Contains(text, "192.168") || strings.Contains(text, "token=") {
t.Fatalf("the address left: %q", text)
}
if !strings.Contains(text, "channel-refused") || !strings.Contains(text, "provider.keycloak.ace.failing") {
if !strings.Contains(text, "withheld, because it carried an IPv4 address") || !strings.Contains(text, "provider keycloak ace failing") {
t.Fatalf("not said as refused, by key: %q", text)
}
hist := h.History(10)["refusals"].([]RefusalNote)
@@ -242,7 +262,7 @@ func TestStillOpenPastItsBoundItIsSaidOnceMore(t *testing.T) {
}
c.pass(2 * time.Minute)
h.Tick()
if len(tg.sends) != 3 || !strings.HasPrefix(tg.sends[2].Title, "STILL OPEN after 61 min") {
if len(tg.sends) != 3 || (tg.sends[2].Title != "Still open: bus controller slow-consumer" || !strings.Contains(tg.sends[2].Body, "for 61 min")) {
t.Fatalf("urgent reminder: %d %q", len(tg.sends), tg.sends[len(tg.sends)-1].Title)
}
c.pass(3 * time.Hour)
@@ -253,7 +273,7 @@ func TestStillOpenPastItsBoundItIsSaidOnceMore(t *testing.T) {
c.pass(9 * time.Hour) // the warning is now 13 h old
h.Tick()
settle(h, c)
if len(tg.sends) != 4 || !strings.Contains(tg.sends[3].Text(), "plan.41.stalled") {
if len(tg.sends) != 4 || tg.sends[3].Title != "Still open: plan 41 stalled" {
t.Fatalf("warning reminder: %d", len(tg.sends))
}
}
@@ -268,7 +288,7 @@ func TestClearedEditsTheFirstMessageAndReopenedWithinTenMinutesIsNotNew(t *testi
if len(tg.sends) != 1 || len(tg.edits) != 1 || len(dt.edits) != 1 {
t.Fatalf("telegram sends %d edits %d, desktop edits %d", len(tg.sends), len(tg.edits), len(dt.edits))
}
if m := tg.edits["1"]; !strings.HasPrefix(m.Title, "CLEARED after 14 min") {
if m := tg.edits["1"]; m.Title != "Resolved: machine ace silent, after 14 min" || m.Body != "" {
t.Fatalf("edit: %q", m.Title)
}
if len(h.Open()) != 0 {
@@ -277,7 +297,7 @@ func TestClearedEditsTheFirstMessageAndReopenedWithinTenMinutesIsNotNew(t *testi
c.pass(5 * time.Minute)
k.At = c.now() // a reopening keeps its first raising time; the event says when it happened
h.Condition(EventRaised, k)
if len(tg.sends) != 1 || !strings.Contains(tg.edits["1"].Title, "open again, 2 times") {
if len(tg.sends) != 1 || !strings.Contains(tg.edits["1"].Title, "(again, 2nd time)") {
t.Fatalf("reopened as new: sends %d, edit %q", len(tg.sends), tg.edits["1"].Title)
}
h.Condition(EventCleared, Condition{Key: k.Key})
@@ -326,7 +346,7 @@ func TestEscalationIsSaidOnce(t *testing.T) {
h.Condition(EventChanged, k)
h.Condition(EventChanged, k)
settle(h, c)
if len(dt.sends) != 2 || len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "NOW URGENT") {
if len(dt.sends) != 2 || len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "Now urgent: ") {
t.Fatalf("desktop %d telegram %d", len(dt.sends), len(tg.sends))
}
// Escalated while the warning still waited its window: one message, the urgent one.
@@ -335,7 +355,7 @@ func TestEscalationIsSaidOnce(t *testing.T) {
k2.Severity = Urgent
h.Condition(EventChanged, k2)
settle(h, c)
if len(dt.sends) != 3 || !strings.HasPrefix(dt.sends[2].Title, "NOW URGENT") {
if len(dt.sends) != 3 || !strings.HasPrefix(dt.sends[2].Title, "Now urgent: ") {
t.Fatalf("desktop %d: %q", len(dt.sends), dt.sends[len(dt.sends)-1].Title)
}
}
+80 -4
View File
@@ -25,6 +25,22 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
type Settings struct {
TelegramChatID string `json:"telegram-chat-id"`
DesktopMachines []string `json:"desktop-machines"`
// TimeZone is the operator's, an IANA name such as Europe/Brussels: every time a message says is said
// in it. Empty is this machine's own zone.
TimeZone string `json:"time-zone"`
}
// zoneOf is the location a time-zone setting names, or this machine's own when it names none or one
// that does not exist (said, never guessed silently).
func zoneOf(name string) (*time.Location, error) {
if name == "" {
return time.Local, nil
}
loc, err := time.LoadLocation(name)
if err != nil {
return time.Local, fmt.Errorf("the time-zone %q is not one this machine knows; times are said in its own zone", name)
}
return loc, nil
}
func readSettings(path string) (Settings, error) {
@@ -47,6 +63,9 @@ func readSettings(path string) (Settings, error) {
case float64:
s.TelegramChatID = fmt.Sprintf("%.0f", v)
}
if tz, ok := loose["time-zone"].(string); ok {
s.TimeZone = strings.TrimSpace(tz)
}
if list, ok := loose["desktop-machines"].([]any); ok {
for _, m := range list {
if name, ok := m.(string); ok && strings.TrimSpace(name) != "" {
@@ -128,7 +147,7 @@ func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.n
func main() {
settingsFile := os.Getenv("MESH_MESSENGER_SETTINGS")
var said sync.Mutex
lastSaid := ""
lastSaid, zoneSaid := "", ""
settings := func() Settings {
s, err := readSettings(settingsFile)
said.Lock()
@@ -152,8 +171,22 @@ func main() {
Ask: stdio.Ask,
},
Store: stateStore{},
Ask: stdio.Ask,
Logf: logf,
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
Zone: func() *time.Location {
loc, err := zoneOf(settings().TimeZone)
said.Lock()
defer said.Unlock()
if err != nil && err.Error() != zoneSaid {
logf("[messenger] %v", err)
}
zoneSaid = ""
if err != nil {
zoneSaid = err.Error()
}
return loc
},
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
}
h.init()
l := &listening{now: "not yet: starting"}
@@ -215,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 {
@@ -228,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()
@@ -286,7 +356,8 @@ func tools(h *Holder, l *listening) []stdio.Tool {
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 50)), nil }},
{Name: "operator-channel.notify",
Description: "Tell the operator something, as a module that uses the seat: a key (the same key is the same " +
"message), urgent or warning, one line in the mesh's words, and what it is about. clear says it is over. " +
"message), urgent or warning, one line in the mesh's words, and what it is about; and best, in plain " +
"words, a headline, an explanation and the line said when it is over. clear says it is over. " +
"Roles and words only: an address, a path or a secret is refused. Deduplicated, capped and routed " +
"like the controller's conditions.",
Input: map[string]any{
@@ -294,11 +365,16 @@ func tools(h *Holder, l *listening) []stdio.Tool {
"severity": map[string]any{"type": "string", "enum": []string{Urgent, Warning}},
"summary": str("one line in the mesh's words"),
"subject": str("what it is about: a machine's role, a module, a plan"),
"headline": str("a few plain words the operator reads as the title, e.g. backup failed on ace: no ids, keys or commands"),
"explanation": str("one or two plain sentences: what happened, what it means for the operator, and whether " +
"they need to act"),
"resolved": str("the one short line said when it is over, e.g. backups run again on ace"),
"clear": map[string]any{"type": "boolean", "description": "it is over: the message is edited to say so"},
},
Run: func(a map[string]any) (any, error) {
clear, _ := a["clear"].(bool)
return h.Notify(strArg(a, "key"), strArg(a, "severity"), strArg(a, "summary"), strArg(a, "subject"), clear)
return h.Notify(strArg(a, "key"), strArg(a, "severity"), strArg(a, "summary"), strArg(a, "subject"), clear,
strArg(a, "headline"), strArg(a, "explanation"), strArg(a, "resolved"))
}},
{Name: "messenger_status",
Description: "Whether the operator can be reached, and why not: each channel — can it send, what it lacks " +
@@ -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"}) {
+59 -26
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)
@@ -265,9 +265,9 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
said := h.capSaid[name]
h.mu.Unlock()
if said.IsZero() || now.Sub(said) >= time.Hour {
m := Message{Title: fmt.Sprintf("HELD BACK: %s is at its cap of %d messages an hour", name, CapPerHour),
Body: fmt.Sprintf("%d message(s) are held and go out as one when the hour allows.\n"+
"more: conditions (through the mesh)", len(entries)), Urgent: true}
m := Message{Title: "Mesh messages paused for this hour",
Body: fmt.Sprintf("The mesh sent %d messages here this hour, its limit. %d more are held and come as "+
"one message when the hour allows.", CapPerHour, len(entries)), Urgent: true}
_, err := ch.Send(m)
h.noteChannel(name, err)
if err == nil {
@@ -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.
@@ -414,32 +455,24 @@ func digest(items []item, msgs []Message) Message {
parts = append(parts, fmt.Sprintf("%d %s", n, many))
}
}
add(newU, "new urgent", "new urgent")
add(newW, "new warning", "new warnings")
add(newU, "urgent problem", "urgent problems")
add(newW, "new problem", "new problems")
add(still, "still open", "still open")
add(cleared, "cleared", "cleared")
add(cleared, "resolved", "resolved")
add(notices, "notice", "notices")
prefix := "WARNING"
prefix := "Mesh"
if urgent {
prefix = "URGENT"
} else if quiet {
prefix = "CLEARED"
prefix = "Urgent"
}
// Each line is one message's title: a headline, or a resolved line — plain words, no key (ADR 0253).
var lines []string
for i, m := range msgs {
if i == DigestLines {
lines = append(lines, fmt.Sprintf("and %d more", len(msgs)-DigestLines))
break
}
line := m.Title
if key := items[i].Key; items[i].Msg == nil {
if _, ok := Check(key); ok {
line += " [" + key + "]"
}
}
lines = append(lines, "- "+line)
lines = append(lines, "- "+m.Title)
}
lines = append(lines, "more: conditions (through the mesh)")
return Message{Title: prefix + ": " + strings.Join(parts, ", "), Body: strings.Join(lines, "\n"), Urgent: urgent, Quiet: quiet}
}
+20 -4
View File
@@ -36,6 +36,7 @@ type Status struct {
Stale int `json:"events_taken_as_state"` // older than the freshness bound: recorded, never said
Grace bool `json:"in_start_grace,omitempty"`
StateProblem string `json:"state_problem,omitempty"`
TimeZone string `json:"time_zone"` // the zone every time in a message is said in
Rules []string `json:"rules"`
}
@@ -95,7 +96,10 @@ func (h *Holder) Status(listening string) Status {
h.mu.Unlock()
sort.Strings(st.Unsent)
st.Listening = listening
st.TimeZone = h.zone().String()
st.Rules = []string{
"a message reads in one glance: the condition's headline as its title, its explanation, and since when in the operator's time zone; no key, id, commit or command — those stay in the controller's conditions and in this holder's history (novox/hq ADR 0253)",
"a clearance is one line: the condition's resolved line and how long it was open",
"history is never news: what is open is read from the controller on start, every 10 min and when an old event arrives; an event older than that reading is not acted on, one older than 10 min by its own time is state, never a message",
"a clearing of something never said says nothing; an urgent condition open and never said is said, all of them in one message",
"nothing is sent in the first minute after start but that one message",
@@ -213,7 +217,7 @@ func (h *Holder) Test(channel, text string) map[string]string {
out["refused"] = "it carried " + refusal.String() + "; nothing was sent"
return out
}
m := Message{Title: "TEST: " + text, Body: "nothing is wrong; this was asked for"}
m := Message{Title: "Test message: " + text, Body: "Nothing is wrong; this was asked for."}
for _, ch := range h.channels() {
if channel != "" && channel != "both" && channel != ch.Name() {
continue
@@ -240,7 +244,7 @@ func (h *Holder) Test(channel, text string) map[string]string {
// Notify takes a message from a module that uses the seat (research 028 Q5, modules as sources):
// the same shape, rule, cap and deduplication as a condition. Its key is put under `notify.` so it
// can never be taken for one of the controller's conditions.
func (h *Holder) Notify(key, severity, summary, subject string, clear bool) (map[string]any, error) {
func (h *Holder) Notify(key, severity, summary, subject string, clear bool, plain ...string) (map[string]any, error) {
if key == "" {
return nil, errorf("key is required: the same key is the same message")
}
@@ -266,8 +270,20 @@ func (h *Holder) Notify(key, severity, summary, subject string, clear bool) (map
if summary == "" {
return nil, errorf("summary is required: one line in the mesh's words")
}
h.raised(Record{Key: key, Kind: "notice", Subject: subject, Severity: severity, Summary: summary,
Origin: "notify", More: "operator-channel.open"}, false)
rec := Record{Key: key, Kind: "notice", Subject: subject, Severity: severity, Summary: summary,
Origin: "notify", More: "operator-channel.open"}
// The plain words a module may give (novox/hq ADR 0253): a headline, an explanation, a resolved line.
for i, w := range plain {
switch i {
case 0:
rec.Headline = w
case 1:
rec.Explanation = w
case 2:
rec.Resolved = w
}
}
h.raised(rec, false)
h.mu.Lock()
r := h.open[key]
var sent []string
+2 -4
View File
@@ -52,10 +52,7 @@ func (h *Holder) Sync(open []Condition, asked time.Time) SyncResult {
var urgent []*Record
var fresh []*Record
for _, c := range open {
rec := Record{
Key: c.Key, Kind: c.Kind, Subject: c.SubjectWords(), Severity: c.Severity, Summary: c.Summary,
Origin: "condition", More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill,
}
rec := recordOf(c)
h.mu.Lock()
old := h.open[c.Key]
var r *Record
@@ -72,6 +69,7 @@ func (h *Holder) Sync(open []Condition, asked time.Time) SyncResult {
old.Count++
}
old.Summary, old.Subject, old.Kind, old.SilencedTill = rec.Summary, rec.Subject, rec.Kind, rec.SilencedTill
old.takeWords(rec)
if rec.Severity != "" {
old.Severity = rec.Severity
}
+211
View File
@@ -0,0 +1,211 @@
package main
// What a message says (novox/hq ADR 0253): **it reads in one glance.** A title in plain words, one or two
// plain sentences on what happened, what it means and whether to act, and when it began in the
// operator's own time. No key, id, commit, verb or markup: the condition's key and summary stay in the
// controller (`conditions`) and in this holder's history, for whoever looks closer.
//
// - The words are the condition's own: its headline, explanation and resolved line, made by the
// controller where the condition is made, so every channel says the same thing. A condition from a
// controller older than them, or a module's notice without a headline, is said from its summary,
// with what the operator cannot read taken out (plainSummary).
// - A clearance is one short line: "Resolved: openrazer works again on g14, after 26 min".
// - A reminder, an escalation and a reopening say so in the title, before the same headline.
// - Times are the operator's (the setting time-zone; this machine's zone when not given), and said as a
// clock time today, a weekday this week, a date before that.
import (
"fmt"
"regexp"
"strings"
"time"
)
func (h *Holder) zone() *time.Location {
if h.Zone != nil {
if loc := h.Zone(); loc != nil {
return loc
}
}
return time.Local
}
// compose is the message for a record. withhold > 0 when the content rule refused what it said: 1 says
// the headline alone, more says only that something was withheld.
func compose(r *Record, what string, now time.Time, loc *time.Location, withhold int) Message {
headline, explanation := wordsOf(r)
urgent := r.Severity == Urgent
if withhold >= 2 {
return withheld(r.Refused, urgent)
}
if withhold == 1 {
explanation = "Part of this message was withheld, because it carried " + r.Refused + "."
}
var title string
switch what {
case "cleared":
return Message{Title: resolvedLine(r) + ", after " + roughly(r.Cleared.Sub(r.Raised)), Quiet: true}
case "reminder":
title = "Still open: " + headline
case "escalated":
title = "Now urgent: " + headline
case "reopened":
title = headline + " (again" + times(r.Count) + ")"
if urgent {
title = "Urgent: " + title
}
default:
title = headline
if urgent {
title = "Urgent: " + headline
}
}
since := "Since " + when(r.Raised, now, loc)
if what == "reminder" {
since = fmt.Sprintf("Open since %s, for %s.", when(r.Raised, now, loc), roughly(now.Sub(r.Raised)))
} else {
since += "."
}
body := since
if explanation != "" {
body = explanation + "\n" + since
}
return Message{Title: title, Body: body, Urgent: urgent}
}
// withheld is what is sent in place of a message the content rule refused.
func withheld(carried string, urgent bool) Message {
return Message{Title: "A mesh message was withheld",
Body: "It carried " + carried + ", which may not leave the mesh. An agent can read it among the mesh's " +
"open conditions.", Urgent: urgent}
}
// times is " 2nd time" for a count of 2, "" for 1.
func times(n int) string {
if n < 2 {
return ""
}
suffix := "th"
switch {
case n%100 >= 11 && n%100 <= 13:
case n%10 == 1:
suffix = "st"
case n%10 == 2:
suffix = "nd"
case n%10 == 3:
suffix = "rd"
}
return fmt.Sprintf(", %d%s time", n, suffix)
}
// wordsOf is a record's headline and explanation: its own, or made from its summary.
func wordsOf(r *Record) (string, string) {
if r.Headline != "" {
return r.Headline, r.Explanation
}
summary := plainSummary(r.Summary)
if r.Origin == "notify" && len(summary) <= 80 {
return capital(summary), ""
}
return fallbackHeadline(r), capital(summary)
}
// resolvedLine is the line a clearance says: the condition's own, or made from its headline.
func resolvedLine(r *Record) string {
line := r.Resolved
if line == "" {
headline, _ := wordsOf(r)
line = "Resolved: " + lowerFirst(headline)
}
if !strings.HasPrefix(line, "Resolved") {
line = "Resolved: " + lowerFirst(line)
}
return line
}
// fallbackHeadline is a title for something with no headline: what it is about and its kind, in words.
func fallbackHeadline(r *Record) string {
what := strings.ReplaceAll(r.Kind, "-", " ")
if what == "" || what == "notice" {
what = "a notice"
}
about := ""
if r.Origin == "notify" {
about = r.Subject
} else if machine := machineOf(r.Subject); machine != "" {
about = machine
} else {
// A seat, a provider or a module is named by a name; a plan, a delivery or a call by an id, never said.
for _, scope := range []string{"seat ", "provider ", "module "} {
if name, ok := strings.CutPrefix(r.Subject, scope); ok {
about = name
}
}
}
if about == "" {
return capital(what)
}
return capital(what) + " on " + about
}
// machineOf is the machine a record's subject words name: "machine ace" or "x on ace".
func machineOf(subject string) string {
if _, on, ok := strings.Cut(subject, " on "); ok {
return on
}
if m, ok := strings.CutPrefix(subject, "machine "); ok {
return m
}
return ""
}
var (
codeSpan = regexp.MustCompile("`([^`]*)`")
noise = regexp.MustCompile(`\s+`)
)
// plainSummary is a summary with what the operator cannot read taken out: markup and the verbs in code
// spans. Used only where the condition says no plain words of its own.
func plainSummary(s string) string {
s = codeSpan.ReplaceAllStringFunc(s, func(m string) string {
inner := strings.Trim(m, "`")
if strings.ContainsAny(inner, ". ") {
return "a command"
}
return inner
})
s = strings.NewReplacer("**", "", "`", "").Replace(s)
return strings.TrimSpace(noise.ReplaceAllString(s, " "))
}
// when is a moment as the operator reads it, in their zone: "10:40" today, "Tue 10:40" this week,
// "1 Oct 10:40" before.
func when(t, now time.Time, loc *time.Location) string {
if t.IsZero() {
return "now"
}
lt, ln := t.In(loc), now.In(loc)
y1, m1, d1 := lt.Date()
y2, m2, d2 := ln.Date()
switch {
case y1 == y2 && m1 == m2 && d1 == d2:
return lt.Format("15:04")
case ln.Sub(lt) < 6*24*time.Hour:
return lt.Format("Mon 15:04")
}
return lt.Format("2 Jan 15:04")
}
func capital(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func lowerFirst(s string) string {
if s == "" || (len(s) > 1 && strings.ToUpper(s[:2]) == s[:2]) {
return s
}
return strings.ToLower(s[:1]) + s[1:]
}
@@ -0,0 +1,324 @@
package main
import (
"encoding/json"
"strings"
"testing"
"time"
_ "time/tzdata"
)
// The notifications the operator could not read on 2026-10-08, said again from the events a controller
// with plain words sends (novox/hq ADR 0253). BEFORE is the popup as it appeared; AFTER is what the desktop
// shows now: a title in plain words, an explanation, since when in the operator's time — no key, id,
// 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 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, under its bound", before: waitingBefore,
key: "plan.plan-1791454185265004861.waiting", kind: "waiting", severity: Warning,
subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"}, summary: waitingSummary,
headline: "openrazer delivery waiting to start",
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 " +
"service manager (exit-code)\nabout: module openrazer.g14 on g14 (module-unhealthy)\nsince: 2026-10-08 " +
"10:40 UTC\nkey: module.openrazer.g14.unhealthy\nmore: conditions show module.openrazer.g14.unhealthy",
key: "module.openrazer.g14.unhealthy", kind: "module-unhealthy", severity: Warning,
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", 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",
before: "WARNING: 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\nabout: machine shanks on shanks (machine-units)\nsince: " +
"2026-10-08 10:40 UTC\nkey: machine.shanks.units\nmore: conditions show machine.shanks.units",
key: "machine.shanks.units", kind: "machine-units", severity: Warning,
subject: map[string]string{"scope": "machine", "id": "shanks", "machine": "shanks"},
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", 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",
before: "WARNING: \"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\nabout: mesh hand-acts.push (healer-wanted)\nsince: " +
"2026-10-08 10:40 UTC\nkey: mesh.hand-acts.push.healer-wanted\nmore: conditions show " +
"mesh.hand-acts.push.healer-wanted",
key: "mesh.hand-acts.push.healer-wanted", kind: "healer-wanted", severity: Warning,
subject: map[string]string{"scope": "mesh", "id": "hand-acts.push"},
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: "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)
}
raw, _ := json.Marshal(body)
return raw
}
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, 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 {
t.Fatal(err)
}
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)
var labels []string
for _, a := range m.Actions {
labels = append(labels, a.Label)
}
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)
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[%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)
h.Zone = func() *time.Location { return time.UTC }
k := Condition{Key: "machine.ace.silent", Scope: "machine", ID: "ace", Kind: "silent", Severity: Urgent,
Summary: "ace has not been heard from: `push ace` sends it again", Raised: c.now()}
h.Condition(EventRaised, k)
m := tg.sends[0]
if m.Title != "Urgent: Silent on ace" || m.Body != "Ace has not been heard from: a command sends it again\nSince 12:00." {
t.Errorf("%q / %q", m.Title, m.Body)
}
keyless(t, m)
}
// **Times are the operator's**: today a clock time, this week a weekday, before that a date.
func TestTimesAreSaidInTheOperatorsZone(t *testing.T) {
brussels, _ := time.LoadLocation("Europe/Brussels")
now := time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC)
for at, want := range map[time.Time]string{
now.Add(-56 * time.Minute): "11:44",
now.Add(-26 * time.Hour): "Wed 10:40",
now.Add(-10 * 24 * time.Hour): "28 Sep 12:40",
} {
if got := when(at, now, brussels); got != want {
t.Errorf("%s: %q, want %q", at, got, want)
}
}
if loc, err := zoneOf("Nowhere/Atlantis"); err == nil || loc != time.Local {
t.Errorf("a zone that does not exist was not said: %v", err)
}
}
+8 -3
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",
@@ -62,7 +67,7 @@
"path": "${dir:state}/settings.json",
"mode": "0600",
"merge": "json",
"content": "{\n \"telegram-chat-id\": \"\",\n \"desktop-machines\": []\n}\n"
"content": "{\n \"telegram-chat-id\": \"\",\n \"desktop-machines\": [],\n \"time-zone\": \"\"\n}\n"
}
],
"build": {