Take only acknowledgements from a desk click, and make each answer a one-time ticket
mesh/merge-gate pass: builds dunst, messenger → g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/notifications-after-review ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request

Review found that any process of the operator's account can choose a dunst action,
so a click could release or stop a delivery in the operator's name; that tokens
lived for ever; that a warning's answers died in ten seconds; and that a restarted
notifier left dead buttons (hq ADR 0258).
This commit is contained in:
jochen
2026-10-08 14:38:24 +02:00
parent 541b24d96e
commit d83711f11e
19 changed files with 616 additions and 137 deletions
+208 -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,39 @@ 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
}
func sameActions(a, b []Action) bool {
x, _ := json.Marshal(a)
@@ -49,73 +84,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 !answerable(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 && !answerable(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 +192,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 +241,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,247 @@
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)
}
}
@@ -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
+21 -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,12 @@ 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"`
Tokens map[string]Grant `json:"tokens,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 +251,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 +548,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 && !r.History && 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")
}
}