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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
go sayStarted()
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
]
|
||||
},
|
||||
"emits": [
|
||||
"action-chosen"
|
||||
"action-chosen",
|
||||
"started"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 += "."
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user