Merge pull request 'messenger and dunst: only acknowledgements from a desk click, one-time answer tokens (hq ADR 0258, issue 324)' (#131) from fix/notifications-after-review into main

This commit was merged in pull request #131.
This commit is contained in:
2026-10-08 13:02:25 +00:00
19 changed files with 675 additions and 137 deletions
+4 -2
View File
@@ -20,8 +20,10 @@ waits in this bundle for the operator's choice (dunst offers the actions on a mi
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).
days. A notification that offers answers stays until dismissed (`expire_ms` 0). A restart of this bundle
ends the waits it held; it says `started` {machine} once it serves, and the operator channel shows the
open answers there again (novox/hq ADR 0258). `notify-send` prints the id before it waits, also into a
pipe (checked on the laptop on 2026-10-08: the id at once, and "Wait timeout expired" when it expired).
## Tools
+19 -2
View File
@@ -39,10 +39,27 @@ const (
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"
// ChosenEvent is the event an answer is said as; StartedEvent says this bundle started, so the answers
// an earlier one waited for are lost and the operator channel shows them again (novox/hq ADR 0258).
ChosenEvent = "action-chosen"
StartedEvent = "started"
)
// sayStarted says, once this bundle serves, that it started on this machine; retried while the runtime
// cannot take it yet, and said in the log when it never could.
func sayStarted() {
body := map[string]any{"machine": os.Getenv("MESH_NODE")}
var err error
for wait := time.Second; wait <= time.Minute; wait *= 2 {
time.Sleep(wait)
if err = emit(StartedEvent, body); err == nil {
return
}
}
fmt.Fprintf(os.Stderr, "[dunst] could not say it started (answers waited for before are lost until the "+
"operator channel shows them again): %v\n", err)
}
var tokenShape = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
// emit says an event as this module; a seam a test replaces.
+19 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -43,7 +44,7 @@ func TestANotificationsChosenAnswerIsSaidAsAnEvent(t *testing.T) {
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][--]") {
if !strings.Contains(string(asked), "[--expire-time=0][--wait][--action=a1b2=Release][--action=c3d4=Stop][--]") {
t.Fatalf("notify-send was asked %s", asked)
}
deadline := time.Now().Add(3 * time.Second)
@@ -90,3 +91,20 @@ func TestActionsAreRefusedForWhatTheyCannotBe(t *testing.T) {
}
}
}
// **A bundle that started says so**, so the operator channel shows again the answers lost with the one
// before.
func TestABundleThatStartedSaysSo(t *testing.T) {
t.Setenv("MESH_NODE", "g14")
var said []string
before := emit
emit = func(event string, body any) error {
said = append(said, fmt.Sprintf("%s %v", event, body))
return nil
}
t.Cleanup(func() { emit = before })
sayStarted()
if len(said) != 1 || said[0] != "started map[machine:g14]" {
t.Fatalf("%v", said)
}
}
+5
View File
@@ -89,6 +89,11 @@ func Send(n Notification) (SendResult, error) {
if n.Icon != "" {
args = append(args, "--icon="+n.Icon)
}
if len(n.Actions) > 0 && n.ExpireMS < 0 {
// A notification that offers answers stays until it is dismissed: a warning's answers must not die
// with dunst's ten seconds (novox/hq ADR 0258).
n.ExpireMS = 0
}
if n.ExpireMS >= 0 {
args = append(args, "--expire-time="+strconv.Itoa(n.ExpireMS))
}
+1
View File
@@ -11,6 +11,7 @@ import (
)
func main() {
go sayStarted()
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+2 -1
View File
@@ -71,6 +71,7 @@
]
},
"emits": [
"action-chosen"
"action-chosen",
"started"
]
}
+12 -8
View File
@@ -49,14 +49,18 @@ operator can do themselves. **A warning that needs nothing is not sent**: it sta
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).
**A notification offers answers** (`cmd/messenger/answers.go`), limited by novox/hq ADR 0258: **a desk
click performs only an acknowledgement**. Any process of the operator's account can choose a dunst action
(`dunstctl action`), so a click proves nothing about who chose. Until answers are authorised (to-be 46
phase 5) the only answers offered and performed are **Details** and **Silence for a week** (the allow-list
`answerable`); release, stop, start and restart are said in words ("Needs you: release it, or stop it.")
and offered as no answer. Each answer is a token made for one message of one condition: bound to the
machines it was shown on, good for a day, used once, refused for a condition cleared or silenced, and one
event is acted on once. A refused answer is said on the desk where it was chosen, never dropped. When the
dunst bundle says it started (`dunst.started`), the open notifications that offer answers are shown there
again with fresh tokens. A silence is recorded with the cause `operator-answer`, which the hand-act log
does not count as a repair. A warning kept quiet is also said once after a day. Telegram shows the words
but no buttons.
| When | What |
|---|---|
+213 -68
View File
@@ -1,29 +1,36 @@
package main
// What the operator needs to do, and answering from the notification itself (novox/hq ADR 0253).
// What the operator needs to do, and answering from the notification itself (novox/hq ADR 0253, as
// limited by ADR 0258).
//
// - **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.
// - **A warning that needs nothing is not sent.** It is kept open here and in the controller's
// conditions, the history says it was kept quiet, and it is said when it needs the operator — urgent
// at its bound, or its words changed to "Needs you:" (an event, or a reading of the state) — and once
// after a day in any case (QuietRemindAfter), so nothing stays silent for ever.
// - **A desk click performs only an acknowledgement** (ADR 0234 §8). Any process of the operator's account
// can choose a notification's action (`dunstctl action`), so a click proves nothing about who chose;
// until answers are authorised (to-be 46 phase 5) the only answers offered and performed are Details
// and silencing a condition. Everything else — release, stop, start, restart — is said in words and
// offered as no answer. The allow-list is `answerable`; an action outside it is never offered, and a
// token for one is refused.
// - **A token is an answer's one-time ticket**: made for one message of one condition, bound to the
// machines it was shown on, valid for TokenFor, used once. An answer for a condition cleared or
// silenced, from another machine, used, expired or unknown, is refused — and the refusal is shown where
// the answer came from, so a click is never silently dead. One event is acted on once, whatever its
// redelivery.
// - **A notifier that restarted lost the answers it was waiting for**; when it says it started, the open
// notifications that offer answers are shown there again with fresh tokens (Reoffer).
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
// Action is one answer a condition offers: a label, and the seat verb called with these arguments when
@@ -36,11 +43,44 @@ type Action struct {
Arguments map[string]string `json:"arguments,omitempty"`
}
// DetailsLabel is the answer every condition's notification offers.
const DetailsLabel = "Details"
// Grant is one token's answer: the action (or -1, Details), when it was made, and whether it was used.
type Grant struct {
Index int `json:"index"`
Issued time.Time `json:"issued"`
Used bool `json:"used,omitempty"`
}
// MostActions bounds what a notification offers, Details included.
const MostActions = 4
const (
// DetailsLabel is the answer every condition's notification offers.
DetailsLabel = "Details"
// MostActions bounds what a notification offers, Details included.
MostActions = 4
// TokenFor is how long an answer's token is good for.
TokenFor = 24 * time.Hour
// KeptEvents is how many answered events are remembered, so a redelivered one is acted on once.
KeptEvents = 200
)
// answerable is the allow-list of answers a desk click performs (ADR 0258): silencing a condition, an
// acknowledgement any granted principal may already make. Nothing else, until answers are authorised.
func answerable(a Action) bool {
if a.Verb != "mesh-controller.conditions" || a.Machine != "" || a.Arguments["silence"] == "" {
return false
}
for k := range a.Arguments {
switch k {
case "silence", "for", "why", "cause":
default:
return false
}
}
return true
}
// answerableFor is answerable for this record: a silence of its own condition, and of no other.
func answerableFor(r *Record, a Action) bool {
return answerable(a) && a.Arguments["silence"] == r.Key
}
func sameActions(a, b []Action) bool {
x, _ := json.Marshal(a)
@@ -49,73 +89,105 @@ func sameActions(a, b []Action) bool {
}
// 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.
// you to do.", and only a warning. A record without words (an older controller, a notice) is said.
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.
// choices are the answers a record's next message offers, each with a fresh token; the tokens of its
// earlier messages end with them.
func (h *Holder) choices(r *Record) []Choice {
if r.Origin != "condition" {
return nil
}
now := h.Now()
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 = map[string]Grant{}
var out []Choice
for i, a := range r.Actions {
if !answerableFor(r, a) || len(out) == MostActions-1 {
continue
}
r.Tokens[token()] = -1
t := token()
r.Tokens[t] = Grant{Index: i, Issued: now}
out = append(out, Choice{Token: t, Label: a.Label})
}
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})
t := token()
r.Tokens[t] = Grant{Index: -1, Issued: now}
return append(out, Choice{Token: t, Label: DetailsLabel})
}
func token() string {
var b [12]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// shownOn are the machines a record's desktop message was shown on.
func shownOn(r *Record) []string {
var out []string
for _, part := range strings.Split(r.Sent["desktop"], ",") {
if m, _, ok := strings.Cut(part, "="); ok {
out = append(out, m)
}
}
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) {
// Chosen takes an answer a channel handed back: the token of a choice, on a machine, in an event. It
// performs an allowed answer, shows what came of it — or why it was refused — where it was chosen, and
// answers that in words. The same event is acted on once.
func (h *Holder) Chosen(channel, machine, tok, event string) (string, error) {
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
if event != "" {
if outcome, done := h.answered[event]; done {
h.mu.Unlock()
return outcome, nil
}
}
now := h.Now()
var r *Record
idx := 0
var g Grant
for _, rec := range h.open {
if i, ok := rec.Tokens[tok]; ok {
r, idx = rec, i
if gr, ok := rec.Tokens[tok]; ok {
r, g = rec, gr
}
}
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")
refuse := func(why string) (string, error) {
h.Logf("[messenger] an answer on %s from %s was refused: %s", channel, machine, why)
h.remember(event, "refused: "+why)
h.record(Sent{At: now, Channel: channel, Key: keyOf(r), What: "answer", Outcome: "refused: " + why})
_, _ = h.showOn(channel, machine, Message{Title: "That answer was not taken",
Body: capital(why) + ". Nothing was done.", Quiet: true})
return "refused", errorf("%s", why)
}
now := h.Now()
switch {
case r == nil:
return refuse("it belongs to no open message; the problem may have ended, or a newer message replaced it")
case !r.Cleared.IsZero():
return refuse("the problem it answered has ended")
case r.silenced(now):
return refuse("the problem it answered is silenced")
case g.Used:
return refuse("it was already chosen once")
case now.Sub(g.Issued) > TokenFor:
return refuse("it is more than a day old")
case channel == "desktop" && !contains(shownOn(r), machine):
return refuse("it was not shown on " + machine)
case g.Index >= len(r.Actions) || (g.Index >= 0 && !answerableFor(r, r.Actions[g.Index])):
return refuse("it is not an answer a notification may give")
}
h.mu.Lock()
g.Used = true
r.Tokens[tok] = g
h.mu.Unlock()
headline, _ := wordsOf(r)
if idx == -1 {
if g.Index == -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
@@ -125,36 +197,34 @@ func (h *Holder) Chosen(channel, machine, tok string) (string, error) {
}
lines = append(lines, l)
}
m := Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n")}
_, err := h.showOn(channel, machine, m)
_, err := h.showOn(channel, machine, Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n")})
h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "details", Outcome: outcomeOf(err)})
h.remember(event, "shown")
h.persist(r)
return "shown", err
}
a := r.Actions[idx]
a := r.Actions[g.Index]
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
args["why"] = fmt.Sprintf("chosen on the %s notification %q on %s (a desk click: whoever was at the "+
"operator's session)", channel, headline, 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)
raw, err = h.Ask("seat:"+a.Verb, 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") + "."
body := "Chosen at " + now.In(h.zone()).Format("15:04") + "."
if err != nil {
outcome = "failed: " + err.Error()
title = "Could not " + strings.ToLower(a.Label) + ": " + headline
@@ -176,11 +246,86 @@ func (h *Holder) Chosen(channel, machine, tok string) (string, error) {
} else {
_, _ = h.showOn(channel, machine, m)
}
h.remember(event, outcome)
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)
h.Logf("[messenger] %s was chosen for %s on %s (%s): %s", a.Label, r.Key, channel, machine, outcome)
return outcome, err
}
// Reoffer shows again, on a machine whose notifier restarted and so lost the answers it waited for, every
// open message shown there that offers answers — with fresh tokens, in place of the old one.
func (h *Holder) Reoffer(machine string) int {
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
var recs []*Record
for _, r := range h.open {
if r.Cleared.IsZero() && len(r.Tokens) > 0 && contains(shownOn(r), machine) {
recs = append(recs, r)
}
}
h.mu.Unlock()
sort.Slice(recs, func(i, j int) bool { return recs[i].Key < recs[j].Key })
n := 0
for _, r := range recs {
if r.silenced(h.Now()) || h.Desktop == nil {
continue
}
m := h.say(r, "raised")
id := ""
for _, part := range strings.Split(r.Sent["desktop"], ",") {
if strings.HasPrefix(part, machine+"=") {
id = part
}
}
if err := h.Desktop.Edit(id, m); err != nil {
h.Logf("[messenger] could not offer %s again on %s: %v", r.Key, machine, err)
continue
}
h.persist(r)
n++
}
if n > 0 {
h.Logf("[messenger] the notifier on %s started again: %d message(s) with answers shown there again", machine, n)
}
return n
}
func (h *Holder) remember(event, outcome string) {
if event == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if h.answered == nil {
h.answered = map[string]string{}
}
if len(h.answered) >= KeptEvents {
for k := range h.answered {
delete(h.answered, k)
break
}
}
h.answered[event] = outcome
}
func keyOf(r *Record) string {
if r == nil {
return "(none)"
}
return r.Key
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
func outcomeOf(err error) string {
if err != nil {
return "failed: " + err.Error()
@@ -0,0 +1,299 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
)
// The review of 2026-10-08 (novox/hq ADR 0258): a desk click performs only an acknowledgement, and an
// answer's token is a one-time ticket bound to its condition and its machine.
// answering is a holder with the failed-units condition shown on the desktop of g14, and the calls the
// holder makes recorded.
func answering(t *testing.T, actions ...Action) (*Holder, *fakeChannel, *clock, *[]string, Condition) {
t.Helper()
h, _, dt, c, _ := newHolder(t)
h.Telegram = nil
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
}
e := examples[3]
if len(actions) > 0 {
e.actions = actions
}
k, err := DecodeCondition(EventRaised, e.event(c.now(), false))
if err != nil {
t.Fatal(err)
}
h.Condition(EventRaised, k)
settle(h, c)
if len(dt.sends) != 1 {
t.Fatalf("desktop sends %d", len(dt.sends))
}
return h, dt, c, asked, k
}
func tokenOf(t *testing.T, m Message, label string) string {
t.Helper()
for _, a := range m.Actions {
if a.Label == label {
return a.Token
}
}
t.Fatalf("%q is not offered: %+v", label, m.Actions)
return ""
}
// **Only an acknowledgement is offered or performed.** Release, Stop, Start and Restart are refused by the
// allow-list: never offered, and a token for one (forged into the state) is refused without a call.
func TestOnlyAnAcknowledgementIsAnAnswer(t *testing.T) {
release := Action{Label: "Release", Verb: "mesh-delivery.release", Arguments: map[string]string{"id": "x", "why": ""}}
restart := Action{Label: "Restart", Verb: "node-service-manager.restart", Machine: "g14",
Arguments: map[string]string{"unit": "x.service"}}
start := Action{Label: "Start", Verb: "mesh-controller.plans", Arguments: map[string]string{"go": "p", "why": ""}}
sneaky := Action{Label: "Silence", Verb: "mesh-controller.conditions", Arguments: map[string]string{"silence": "k", "stop": "p"}}
silence := Action{Label: "Silence for a week", Verb: "mesh-controller.conditions",
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": "", "cause": "operator-answer"}}
for _, a := range []Action{release, restart, start, sneaky} {
if answerable(a) {
t.Errorf("%s is answerable", a.Label)
}
}
if !answerable(silence) {
t.Error("silence is not answerable")
}
h, dt, _, asked, _ := answering(t, release, restart, start, silence)
m := dt.sends[0]
var labels []string
for _, a := range m.Actions {
labels = append(labels, a.Label)
}
if strings.Join(labels, ",") != "Silence for a week,Details" {
t.Fatalf("offered %v", labels)
}
// A token for Release, put in the state by hand: refused, nothing called, and said on the desk.
h.mu.Lock()
r := h.open["machine.shanks.units"]
r.Tokens["forged"] = Grant{Index: 0, Issued: h.Now()}
h.mu.Unlock()
before := len(dt.sends)
if out, err := h.Chosen("desktop", "g14", "forged", "e1"); err == nil || out != "refused" || len(*asked) != 0 {
t.Fatalf("a release was performed from a click: %q %v %v", out, err, *asked)
}
if len(dt.sends) != before+1 || dt.sends[len(dt.sends)-1].Title != "That answer was not taken" {
t.Errorf("the refusal was not said where the click was: %+v", dt.sends[len(dt.sends)-1])
}
}
// **At most four answers**, Details included.
func TestANotificationOffersAtMostFourAnswers(t *testing.T) {
var many []Action
for i := 0; i < 6; i++ {
many = append(many, Action{Label: fmt.Sprintf("Silence %d", i), Verb: "mesh-controller.conditions",
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": ""}})
}
_, dt, _, _, _ := answering(t, many...)
if n := len(dt.sends[0].Actions); n != MostActions || dt.sends[0].Actions[n-1].Label != DetailsLabel {
t.Fatalf("offered %+v", dt.sends[0].Actions)
}
}
// **A token is used once, an event acted on once**: the same token twice is refused the second time; the
// same event delivered twice is acted on once and answers what came of it.
func TestATokenIsUsedOnceAndAnEventActedOnOnce(t *testing.T) {
h, dt, _, asked, _ := answering(t)
tok := tokenOf(t, dt.sends[0], "Silence for a week")
if out, err := h.Chosen("desktop", "g14", tok, "e1"); err != nil || out != "done" || len(*asked) != 1 {
t.Fatalf("%q %v %v", out, err, *asked)
}
if out, err := h.Chosen("desktop", "g14", tok, "e1"); err != nil || out != "done" || len(*asked) != 1 {
t.Fatalf("a redelivered event was acted on again: %q %v %v", out, err, *asked)
}
if out, err := h.Chosen("desktop", "g14", tok, "e2"); err == nil || out != "refused" || len(*asked) != 1 {
t.Fatalf("the same token was taken twice: %q %v %v", out, err, *asked)
}
}
// **A token is bound to its machine and its condition's life**: from another machine, for a condition
// cleared or silenced, or past its day, it is refused.
func TestATokenIsRefusedElsewhereAfterItsConditionAndPastItsDay(t *testing.T) {
h, dt, c, asked, k := answering(t)
tok := tokenOf(t, dt.sends[0], "Silence for a week")
if out, _ := h.Chosen("desktop", "shanks", tok, "e1"); out != "refused" {
t.Errorf("an answer from a machine it was not shown on was taken: %q", out)
}
if out, _ := h.Chosen("desktop", "g14", "0123456789abcdef01234567", "e2"); out != "refused" {
t.Errorf("an unknown token was taken: %q", out)
}
c.pass(TokenFor + time.Minute)
if out, _ := h.Chosen("desktop", "g14", tok, "e3"); out != "refused" {
t.Errorf("a token past its day was taken: %q", out)
}
h, dt, c, asked, k = answering(t)
tok = tokenOf(t, dt.sends[0], "Silence for a week")
k.SilencedTill = c.now().Add(time.Hour)
h.Condition(EventChanged, k)
if out, _ := h.Chosen("desktop", "g14", tok, "e4"); out != "refused" {
t.Errorf("an answer for a silenced condition was taken: %q", out)
}
h, dt, c, asked, k = answering(t)
tok = tokenOf(t, dt.sends[0], "Silence for a week")
h.Condition(EventCleared, Condition{Key: k.Key})
if out, _ := h.Chosen("desktop", "g14", tok, "e5"); out != "refused" || len(*asked) != 0 {
t.Errorf("an answer for a cleared condition was taken: %q %v", out, *asked)
}
}
// **A refused verb is said where it was chosen**: the mesh's refusal edits the notification.
func TestAVerbTheMeshRefusesIsSaid(t *testing.T) {
h, dt, _, _, _ := answering(t)
h.Ask = func(string, any) (json.RawMessage, error) {
return json.RawMessage(`{"isError":true,"content":[{"text":"silence is refused: no such condition"}]}`), nil
}
tok := tokenOf(t, dt.sends[0], "Silence for a week")
out, err := h.Chosen("desktop", "g14", tok, "e1")
if err == nil || !strings.HasPrefix(out, "failed") {
t.Fatalf("%q %v", out, err)
}
if m := dt.edits["g14=1"]; !strings.HasPrefix(m.Title, "Could not silence for a week") ||
!strings.Contains(m.Body, "no such condition") {
t.Errorf("the refusal was not said: %+v", m)
}
}
// **A words-only change to "Needs you" after a quiet time is said**, through the event and through a
// reading of the state; and a quiet warning is said once after a day.
func TestAQuietWarningIsSaidWhenItsWordsNeedTheOperator(t *testing.T) {
quiet := examples[0]
needing := quiet
needing.needs = "check that mesh-delivery runs."
needing.explanation = "Needs you: check that mesh-delivery runs. It has waited."
// Through the event.
h, tg, dt, c, _ := newHolder(t)
k, _ := DecodeCondition(EventRaised, quiet.event(c.now(), false))
h.Condition(EventRaised, k)
runFor(h, c, 2*time.Hour)
if len(dt.sends)+len(tg.sends) != 0 {
t.Fatal("said while it needed nothing")
}
k, _ = DecodeCondition(EventChanged, needing.event(c.now(), false))
h.Condition(EventChanged, k)
settle(h, c)
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: check that mesh-delivery runs.") {
t.Fatalf("event: desktop %+v", dt.sends)
}
// Through a reading of the state.
h, tg, dt, c, _ = newHolder(t)
k, _ = DecodeCondition(EventRaised, quiet.event(c.now(), false))
h.Condition(EventRaised, k)
runFor(h, c, 2*time.Hour)
k, _ = DecodeCondition("conditions", needing.event(c.now(), false))
k.At = time.Time{}
h.Sync([]Condition{k}, c.now())
settle(h, c)
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: check that mesh-delivery runs.") {
t.Fatalf("sync: desktop %+v", dt.sends)
}
// After a day, once, even if it still needs nothing.
h, tg, dt, c, _ = newHolder(t)
c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC)
k, _ = DecodeCondition(EventRaised, quiet.event(c.now(), false))
h.Condition(EventRaised, k)
for i := 0; i < 50; i++ {
c.pass(time.Hour)
h.Tick()
settle(h, c)
}
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Still open after a day: ") ||
!strings.HasPrefix(dt.sends[0].Body, "Nothing for you to do.") {
t.Fatalf("after a day: desktop %+v", dt.sends)
}
_ = tg
}
// **A notifier that started again is offered the open answers again**, with fresh tokens: the old ones
// are refused, the new ones taken.
func TestANotifierThatRestartedIsOfferedTheAnswersAgain(t *testing.T) {
h, dt, _, asked, _ := answering(t)
old := tokenOf(t, dt.sends[0], "Silence for a week")
if n := h.Reoffer("g14"); n != 1 {
t.Fatalf("offered again: %d", n)
}
again := dt.edits["g14=1"]
fresh := tokenOf(t, again, "Silence for a week")
if fresh == old || again.Title != dt.sends[0].Title {
t.Fatalf("not offered again: %+v", again)
}
if out, _ := h.Chosen("desktop", "g14", old, "e1"); out != "refused" {
t.Errorf("the lost answer's token still works: %q", out)
}
if out, _ := h.Chosen("desktop", "g14", fresh, "e2"); out != "done" || len(*asked) != 1 {
t.Errorf("the fresh token: %q %v", out, *asked)
}
if n := h.Reoffer("shanks"); n != 0 {
t.Errorf("offered on a machine it was never shown on: %d", n)
}
}
// **A silence answers only its own condition** (ADR 0258): an action silencing another key is not offered,
// and a token for one is refused.
func TestASilenceAnswersOnlyItsOwnCondition(t *testing.T) {
other := Action{Label: "Silence another", Verb: "mesh-controller.conditions",
Arguments: map[string]string{"silence": "machine.ace.silent", "for": "7d", "why": ""}}
own := Action{Label: "Silence for a week", Verb: "mesh-controller.conditions",
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": ""}}
h, dt, _, asked, _ := answering(t, other, own)
var labels []string
for _, a := range dt.sends[0].Actions {
labels = append(labels, a.Label)
}
if strings.Join(labels, ",") != "Silence for a week,Details" {
t.Fatalf("offered %v", labels)
}
h.mu.Lock()
h.open["machine.shanks.units"].Tokens["forged"] = Grant{Index: 0, Issued: h.Now()}
h.mu.Unlock()
if out, _ := h.Chosen("desktop", "g14", "forged", "e1"); out != "refused" || len(*asked) != 0 {
t.Fatalf("another condition was silenced: %q %v", out, *asked)
}
}
// **A record kept by the build before ADR 0258 loads**: its tokens were token -> index; it is read with
// them dropped, never skipped (the old tokens die, as they must).
func TestARecordKeptByTheBuildBeforeLoads(t *testing.T) {
old := `{"key":"machine.shanks.units","kind":"units","subject":"machine shanks","severity":"warning",` +
`"summary":"s","headline":"3 failed services on shanks","origin":"condition","more":"m",` +
`"raised":"2026-10-08T12:38:00Z","sent":{"desktop":"g14=7"},"count":1,` +
`"actions":[{"label":"Release","verb":"mesh-delivery.release","arguments":{"id":"x","why":""}}],` +
`"tokens":{"0123456789abcdef01234567":0,"89abcdef0123456789abcdef":-1}}`
var r Record
if err := json.Unmarshal([]byte(old), &r); err != nil {
t.Fatalf("the old record does not load: %v", err)
}
if r.Key != "machine.shanks.units" || len(r.Tokens) != 0 || r.Sent["desktop"] != "g14=7" {
t.Fatalf("%+v", r)
}
// Loaded into a holder, its old answer is refused and nothing is called.
h, _, _, _, _ := newHolder(t)
asked := 0
h.Ask = func(string, any) (json.RawMessage, error) { asked++; return nil, nil }
st := &memStore{recs: map[string]Record{r.Key: r}}
h.Store = st
if err := h.Load(); err != nil {
t.Fatal(err)
}
if out, _ := h.Chosen("desktop", "g14", "0123456789abcdef01234567", "e1"); out != "refused" || asked != 0 {
t.Fatalf("an old token was taken: %q %d", out, asked)
}
}
@@ -98,6 +98,8 @@ func (d *Desktop) show(m Message, replace map[string]int) (string, error) {
args["replace_id"] = replace[machine]
}
if len(m.Actions) > 0 {
// Answers stay until the operator dismisses them: a warning's answers must not die in ten seconds.
args["expire_ms"] = 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
+23 -7
View File
@@ -56,10 +56,12 @@ import (
const (
RemindUrgent = time.Hour
RemindWarning = 12 * time.Hour
ReopenWindow = 10 * time.Minute
CapPerHour = 20
KeptSends = 200
KeptRefusals = 50
// QuietRemindAfter: a warning kept quiet as needing nothing is said once after this.
QuietRemindAfter = 24 * time.Hour
ReopenWindow = 10 * time.Minute
CapPerHour = 20
KeptSends = 200
KeptRefusals = 50
// FreshFor: an event older than this by its own time is history — state, never a message.
FreshFor = 10 * time.Minute
@@ -134,9 +136,14 @@ type Record struct {
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"`
Needs string `json:"needs,omitempty"`
Actions []Action `json:"actions,omitempty"`
// Kept as "grants": the build before ADR 0258 kept "tokens" as token -> index, which this form cannot
// read; under its own name it is ignored and the record loads (the old tokens die, as they must).
Tokens map[string]Grant `json:"grants,omitempty"`
// QuietReminded: a warning kept quiet as needing nothing was said once after QuietRemindAfter, so
// nothing stays silent for ever.
QuietReminded bool `json:"quiet_reminded,omitempty"`
// Answered is what the operator chose from a notification, and what came of it.
Answered string `json:"answered,omitempty"`
Origin string `json:"origin"`
@@ -246,6 +253,8 @@ type Holder struct {
wantSync bool
history int
stale int
// answered is what came of each answer event, by its id: a redelivered one is acted on once.
answered map[string]string
}
func (h *Holder) init() {
@@ -541,6 +550,13 @@ func (h *Holder) Tick() {
// No channel could take it when it was said: tried again.
h.deliver(r, r.Pending)
h.persist(r)
case needsNothing(r) && !r.told() && !r.QuietReminded && now.Sub(r.Raised) >= QuietRemindAfter:
// Quiet for a day: said once, so nothing stays silent for ever (ADR 0258).
h.mu.Lock()
r.QuietReminded = true
h.mu.Unlock()
h.deliver(r, "quiet-reminder")
h.persist(r)
case !r.Reminded && !r.FirstSent.IsZero() && now.Sub(r.Raised) >= remindAfter(r.Severity) &&
now.Sub(r.FirstSent) >= remindAfter(r.Severity):
// Once more after its bound — counted from its raising, and never sooner after it was
@@ -29,6 +29,9 @@ func (f *fakeChannel) Send(m Message) (string, error) {
}
f.n++
f.sends = append(f.sends, m)
if f.name == "desktop" {
return fmt.Sprintf("g14=%d", f.n), nil // as the desktop answers: the machine and the notification
}
return fmt.Sprint(f.n), nil
}
func (f *fakeChannel) Edit(id string, m Message) error {
+25 -4
View File
@@ -14,6 +14,8 @@ import (
"sync"
"time"
_ "time/tzdata" // the operator's zone is known whatever this machine has installed
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -262,13 +264,28 @@ 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"
// AnswerEvent is the desk's notifier saying which answer was chosen, and StartedEvent that it started (so
// the answers it waited for are lost): the dunst holder of node-notifier emits them as its module's events
// until seats publish events of their own (to-be 46 phase 3).
const (
AnswerEvent = "dunst.action-chosen"
StartedEvent = "dunst.started"
)
// 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 == StartedEvent {
var body struct {
Machine string `json:"machine"`
}
_ = json.Unmarshal(e.Body, &body)
if body.Machine == "" {
body.Machine = e.Node
}
h.Reoffer(body.Machine)
return nil
}
if e.Key != AnswerEvent {
return nil
}
@@ -284,7 +301,11 @@ func listenForAnswers(h *Holder) {
if machine == "" {
machine = e.Node
}
_, _ = h.Chosen("desktop", machine, body.Token)
// The machine is the event's own node, said by the runtime: the body's word is only a hint.
if e.Node != "" {
machine = e.Node
}
_, _ = h.Chosen("desktop", machine, body.Token, e.Headers["x-event-id"])
return nil
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
@@ -56,12 +56,10 @@ func TestItDeclaresAndHoldsTheOperatorChannel(t *testing.T) {
}
if !reflect.DeepEqual(m.Consumes, []string{
ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared,
AnswerEvent}) {
AnswerEvent, StartedEvent}) {
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",
"seat:mesh-delivery.release", "seat:mesh-delivery.stop", "seat:" + ControllerSeat + ".plans",
"seat:node-service-manager.restart"}) {
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions"}) {
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
}
if !reflect.DeepEqual(m.State, []string{"open", "sent"}) {
+1 -1
View File
@@ -27,7 +27,7 @@ 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" {
if needsNothing(r) && what != "escalated" && what != "quiet-reminder" {
// 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
+5
View File
@@ -57,12 +57,14 @@ func (h *Holder) Sync(open []Condition, asked time.Time) SyncResult {
old := h.open[c.Key]
var r *Record
known := old != nil
wasQuiet := false
switch {
case old != nil && old.Seen.After(asked):
h.mu.Unlock()
res.Skipped = append(res.Skipped, c.Key)
continue
case old != nil:
wasQuiet = needsNothing(old) && !old.told()
if !old.Cleared.IsZero() {
// Ended here, open there: back to open, quietly — the reading is not an event.
old.Cleared = time.Time{}
@@ -98,6 +100,9 @@ func (h *Holder) Sync(open []Condition, asked time.Time) SyncResult {
urgent = append(urgent, r)
case !known && now.Sub(r.Raised) <= FreshFor:
fresh = append(fresh, r)
case wasQuiet && !needsNothing(r):
// Kept quiet as needing nothing; its words now need the operator (ADR 0258).
fresh = append(fresh, r)
default:
res.State = append(res.State, r.Key)
}
+3 -1
View File
@@ -47,6 +47,8 @@ func compose(r *Record, what string, now time.Time, loc *time.Location, withhold
return Message{Title: resolvedLine(r) + ", after " + roughly(r.Cleared.Sub(r.Raised)), Quiet: true}
case "reminder":
title = "Still open: " + headline
case "quiet-reminder":
title = "Still open after a day: " + headline
case "escalated":
title = "Now urgent: " + headline
case "reopened":
@@ -61,7 +63,7 @@ func compose(r *Record, what string, now time.Time, loc *time.Location, withhold
}
}
since := "Since " + when(r.Raised, now, loc)
if what == "reminder" {
if what == "reminder" || what == "quiet-reminder" {
since = fmt.Sprintf("Open since %s, for %s.", when(r.Raised, now, loc), roughly(now.Sub(r.Raised)))
} else {
since += "."
+34 -32
View File
@@ -55,15 +55,12 @@ var examples = []example{{
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\""}`,
offers: "Details", choose: "Details",
cleared: "Resolved: openrazer delivery no longer waiting, after 5.0 h", openFor: 5 * time.Hour,
}, {
name: "a module unhealthy",
@@ -74,17 +71,17 @@ var examples = []example{{
subject: map[string]string{"scope": "module", "id": "openrazer.g14", "machine": "g14"},
summary: "openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the account's own " +
"service manager (exit-code)",
headline: "openrazer not working on g14", 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"}}},
headline: "openrazer not working on g14",
needs: "restart its service openrazer-daemon on g14; if it fails again, the details say why.",
explanation: "Needs you: restart its service openrazer-daemon on g14; 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.",
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"}`,
body: "Needs you: restart its service openrazer-daemon on g14; 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: "Details", choose: "Details",
cleared: "Resolved: openrazer works again on g14, after 2.0 h", openFor: 2 * time.Hour,
}, {
name: "failed units on a machine",
@@ -102,14 +99,14 @@ var examples = []example{{
"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": ""}}},
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": "", "cause": "operator-answer"}}},
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\""}`,
asked: `seat:mesh-controller.conditions {"cause":"operator-answer","for":"7d","silence":"machine.shanks.units","why":"chosen on the desktop notification \"3 failed services on shanks\" on g14 (a desk click: whoever was at the operator's session)"}`,
cleared: "Resolved: no failed services on shanks any more, after 45 min", openFor: 45 * time.Minute,
}, {
name: "a healer wanted",
@@ -137,14 +134,11 @@ var examples = []example{{
"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,
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\""}`,
offers: "Details", choose: "Details",
cleared: "Resolved: delivery of hq is no longer held, after 3 min", openFor: 3 * time.Minute,
}}
@@ -219,13 +213,21 @@ func TestRealNotificationsReadInOneGlanceAndAreAnsweredFromTheNotification(t *te
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)
outcome, err := h.Chosen("desktop", "g14", tok, "event-1")
var done Message
if e.choose == DetailsLabel {
done = dt.sends[len(dt.sends)-1]
if err != nil || outcome != "shown" || len(asked) != 0 || done.Title != "Details: "+e.headline {
t.Fatalf("details: %q %v %v %q", outcome, err, asked, done.Title)
}
} else {
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["g14=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))
@@ -233,7 +235,7 @@ func TestRealNotificationsReadInOneGlanceAndAreAnsweredFromTheNotification(t *te
t.Fatal(err)
}
h.Condition(EventCleared, cleared)
end := dt.edits["1"]
end := dt.edits["g14=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)
}
@@ -278,7 +280,7 @@ func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) {
if details.Label != DetailsLabel {
t.Fatalf("%+v", m.Actions)
}
if _, err := h.Chosen("desktop", "g14", details.Token); err != nil {
if _, err := h.Chosen("desktop", "g14", details.Token, ""); err != nil {
t.Fatal(err)
}
shown := dt.sends[len(dt.sends)-1]
@@ -286,7 +288,7 @@ func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) {
!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 {
if _, err := h.Chosen("desktop", "g14", "not-a-token", ""); err == nil {
t.Error("an unknown answer was taken")
}
}
+3 -6
View File
@@ -28,18 +28,15 @@
"mesh-controller.condition-raised",
"mesh-controller.condition-changed",
"mesh-controller.condition-cleared",
"dunst.action-chosen"
"dunst.action-chosen",
"dunst.started"
],
"emits": [
"refused"
],
"invokes": [
"seat:node-notifier.send",
"seat:mesh-controller.conditions",
"seat:mesh-delivery.release",
"seat:mesh-delivery.stop",
"seat:mesh-controller.plans",
"seat:node-service-manager.restart"
"seat:mesh-controller.conditions"
],
"state": [
"open",