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:
+11
-1
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -69,5 +69,8 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"emits": [
|
||||
"action-chosen"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user