Say what the operator needs to do, keep quiet what needs nothing, and take answers from the desk
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 delivered
mesh/delivery-group group feat/plain-notifications delivered: every member is delivered
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 delivered
mesh/delivery-group group feat/plain-notifications delivered: every member is delivered
The operator could not tell from a notification whether to act, nor act without opening a session. Each message now opens with a verdict, a warning that needs nothing is not sent, and a notification offers answers the messenger performs through their seat verbs; dunst's send gains actions and says the chosen one (hq ADR 0253).
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
package main
|
||||
|
||||
// What the operator needs to do, and answering from the notification itself (novox/hq ADR 0253).
|
||||
//
|
||||
// - **Every explanation opens with a verdict**, written by the controller: "Nothing for you to do." or
|
||||
// "Needs you:" and one thing the operator can do themselves.
|
||||
// - **A warning that needs nothing is not sent.** The mesh handles it or it passes by itself; it is
|
||||
// kept as open here (`operator-channel.open`, the history says "quiet") and in the controller's
|
||||
// conditions, and said the moment it needs the operator: when it becomes urgent at its bound, or its
|
||||
// words change to "Needs you:". An urgent condition is always said.
|
||||
// - **A notification offers answers**: the condition's actions (Release, Stop, Start, Restart, Silence
|
||||
// for a week) and Details. Each is a token; a channel that can take an answer (the desk, through the
|
||||
// node-notifier seat's actions) hands the token back, and this holder calls the action's seat verb
|
||||
// itself, with a why that names the operator, the channel and the label, then edits the notification
|
||||
// to say what came of it. Details shows the summary and the key, for whoever wants them: the desk is a
|
||||
// private surface (ADR 0234 §6).
|
||||
// - None of these verbs declares an authorising tier yet (to-be 46 phase 5): each is a verb any granted
|
||||
// principal may already call, which is ADR 0234's acknowledge. When one declares a tier, its answer goes
|
||||
// through the controller's authorise request and answer instead, and the desk asks for the code.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Action is one answer a condition offers: a label, and the seat verb called with these arguments when
|
||||
// it is chosen. Machine is set for a seat every machine holds. An argument "why" given empty is filled
|
||||
// with the operator's words.
|
||||
type Action struct {
|
||||
Label string `json:"label"`
|
||||
Verb string `json:"verb"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Arguments map[string]string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// DetailsLabel is the answer every condition's notification offers.
|
||||
const DetailsLabel = "Details"
|
||||
|
||||
// MostActions bounds what a notification offers, Details included.
|
||||
const MostActions = 4
|
||||
|
||||
func sameActions(a, b []Action) bool {
|
||||
x, _ := json.Marshal(a)
|
||||
y, _ := json.Marshal(b)
|
||||
return string(x) == string(y)
|
||||
}
|
||||
|
||||
// needsNothing is a record the operator need not hear of now: words of its own that open "Nothing for
|
||||
// you to do.", and only a warning. A record without words (an older controller, a notice) is said as
|
||||
// before.
|
||||
func needsNothing(r *Record) bool {
|
||||
return r.Headline != "" && r.Needs == "" && r.Severity == Warning && r.Origin == "condition"
|
||||
}
|
||||
|
||||
// choices are the answers a record's message offers, each with its token, made once and kept.
|
||||
func (h *Holder) choices(r *Record) []Choice {
|
||||
if r.Origin != "condition" {
|
||||
return nil
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if r.Tokens == nil {
|
||||
r.Tokens = map[string]int{}
|
||||
for i := range r.Actions {
|
||||
if i == MostActions-1 {
|
||||
break
|
||||
}
|
||||
r.Tokens[token()] = i
|
||||
}
|
||||
r.Tokens[token()] = -1
|
||||
}
|
||||
out := make([]Choice, 0, len(r.Tokens))
|
||||
for i := range r.Actions {
|
||||
for t, n := range r.Tokens {
|
||||
if n == i {
|
||||
out = append(out, Choice{Token: t, Label: r.Actions[i].Label})
|
||||
}
|
||||
}
|
||||
}
|
||||
for t, n := range r.Tokens {
|
||||
if n == -1 {
|
||||
out = append(out, Choice{Token: t, Label: DetailsLabel})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func token() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// Chosen takes an answer a channel handed back: the token of a choice, on a machine. It performs the
|
||||
// action, says what came of it where the question was shown, and answers that in words.
|
||||
func (h *Holder) Chosen(channel, machine, tok string) (string, error) {
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.mu.Lock()
|
||||
h.init()
|
||||
var r *Record
|
||||
idx := 0
|
||||
for _, rec := range h.open {
|
||||
if i, ok := rec.Tokens[tok]; ok {
|
||||
r, idx = rec, i
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if r == nil {
|
||||
h.Logf("[messenger] an answer on %s came back for no open message (it ended, or the token is unknown)", channel)
|
||||
return "", errorf("no open message offers that answer: it may have ended meanwhile")
|
||||
}
|
||||
now := h.Now()
|
||||
headline, _ := wordsOf(r)
|
||||
if idx == -1 {
|
||||
// Each line held to the content rule on its own: a line that carries what may not leave is said
|
||||
// withheld, and the rest is still shown.
|
||||
var lines []string
|
||||
for _, l := range []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")} {
|
||||
if refusal, ok := Check(l); !ok {
|
||||
l = "(a line carrying " + refusal.What + " is withheld; the controller's conditions hold it)"
|
||||
}
|
||||
lines = append(lines, l)
|
||||
}
|
||||
m := Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n")}
|
||||
_, err := h.showOn(channel, machine, m)
|
||||
h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "details", Outcome: outcomeOf(err)})
|
||||
return "shown", err
|
||||
}
|
||||
a := r.Actions[idx]
|
||||
args := map[string]any{}
|
||||
for k, v := range a.Arguments {
|
||||
args[k] = v
|
||||
}
|
||||
if v, ok := a.Arguments["why"]; ok && v == "" {
|
||||
args["why"] = fmt.Sprintf("the operator chose %s on the %s notification %q", a.Label, channel, headline)
|
||||
}
|
||||
address := "seat:" + a.Verb
|
||||
if a.Machine != "" {
|
||||
address += "@" + a.Machine
|
||||
}
|
||||
var err error
|
||||
if h.Ask == nil {
|
||||
err = errorf("this holder cannot call the mesh")
|
||||
} else {
|
||||
var raw json.RawMessage
|
||||
raw, err = h.Ask(address, args)
|
||||
if err == nil {
|
||||
err = refusalIn(raw)
|
||||
}
|
||||
}
|
||||
outcome := "done"
|
||||
title := a.Label + ": done. " + headline
|
||||
body := "You chose " + a.Label + " at " + now.In(h.zone()).Format("15:04") + "."
|
||||
if err != nil {
|
||||
outcome = "failed: " + err.Error()
|
||||
title = "Could not " + strings.ToLower(a.Label) + ": " + headline
|
||||
body = "The mesh refused it: " + firstLineOf(err.Error())
|
||||
if _, ok := Check(body); !ok {
|
||||
body = "The mesh refused it; the reason carries what may not be shown here."
|
||||
}
|
||||
}
|
||||
h.mu.Lock()
|
||||
r.Answered = a.Label + ": " + outcome
|
||||
sent := r.Sent[channel]
|
||||
h.mu.Unlock()
|
||||
h.persist(r)
|
||||
m := Message{Title: title, Body: body, Quiet: err == nil}
|
||||
if ch := h.channelNamed(channel); ch != nil && sent != "" && ch.CanEdit() {
|
||||
if e := ch.Edit(sent, m); e != nil {
|
||||
_, _ = h.showOn(channel, machine, m)
|
||||
}
|
||||
} else {
|
||||
_, _ = h.showOn(channel, machine, m)
|
||||
}
|
||||
h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "answer " + a.Label, Outcome: outcome})
|
||||
h.Logf("[messenger] the operator chose %s for %s on %s: %s", a.Label, r.Key, channel, outcome)
|
||||
return outcome, err
|
||||
}
|
||||
|
||||
func outcomeOf(err error) string {
|
||||
if err != nil {
|
||||
return "failed: " + err.Error()
|
||||
}
|
||||
return "sent"
|
||||
}
|
||||
|
||||
func firstLineOf(s string) string {
|
||||
s, _, _ = strings.Cut(s, "\n")
|
||||
if len(s) > 160 {
|
||||
s = s[:160] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// refusalIn reads a tool's answer for a refusal: an error result is an error.
|
||||
func refusalIn(raw json.RawMessage) error {
|
||||
var wrapped struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(raw, &wrapped) == nil {
|
||||
if wrapped.IsError && len(wrapped.Content) > 0 {
|
||||
return errorf("%s", wrapped.Content[0].Text)
|
||||
}
|
||||
if wrapped.Error != "" {
|
||||
return errorf("%s", wrapped.Error)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Holder) channelNamed(name string) Channel {
|
||||
for _, ch := range h.channels() {
|
||||
if ch.Name() == name {
|
||||
return ch
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// showOn shows a message of the holder's own on one channel, on one machine where the channel has several.
|
||||
func (h *Holder) showOn(channel, machine string, m Message) (string, error) {
|
||||
ch := h.channelNamed(channel)
|
||||
if ch == nil {
|
||||
return "", errorf("no channel %s", channel)
|
||||
}
|
||||
if d, ok := ch.(*Desktop); ok && machine != "" {
|
||||
return d.showOne(machine, m)
|
||||
}
|
||||
return ch.Send(m)
|
||||
}
|
||||
@@ -19,6 +19,8 @@ package main
|
||||
// - Times are RFC 3339.
|
||||
// - `headline`, `explanation` and `resolved` are the plain words the operator reads (novox/hq ADR
|
||||
// 0253): a condition from a controller older than them has none, and is said from its summary.
|
||||
// - `needs` is what the operator does, empty for nothing (the explanation opens with the verdict);
|
||||
// `actions` are the answers a notification offers, each {label, verb, machine, arguments}.
|
||||
// - `key` is `<scope>.<id>.<kind>`. When it is absent it is made from subject and kind; when
|
||||
// neither gives one the event is unreadable.
|
||||
// - A cleared event carries the condition as last held, and may add `cleared` (its time).
|
||||
@@ -65,6 +67,8 @@ type Condition struct {
|
||||
Headline string
|
||||
Explanation string
|
||||
Resolved string
|
||||
Needs string
|
||||
Actions []Action
|
||||
Source string
|
||||
Resolver string
|
||||
Raised time.Time
|
||||
@@ -172,6 +176,12 @@ func DecodeCondition(event string, body []byte) (Condition, error) {
|
||||
c.Headline = str("headline")
|
||||
c.Explanation = str("explanation")
|
||||
c.Resolved = str("resolved")
|
||||
c.Needs = str("needs")
|
||||
if v, ok := raw["actions"]; ok && string(v) != "null" {
|
||||
if e := json.Unmarshal(v, &c.Actions); e != nil {
|
||||
return Condition{}, fmt.Errorf("%s: actions is not a list of {label, verb, machine, arguments}", event)
|
||||
}
|
||||
}
|
||||
c.Resolver = str("resolver")
|
||||
c.Raised = when("raised")
|
||||
c.LastObserved = when("last-observed", "last_observed", "lastObserved")
|
||||
|
||||
@@ -97,6 +97,11 @@ func (d *Desktop) show(m Message, replace map[string]int) (string, error) {
|
||||
if replace != nil {
|
||||
args["replace_id"] = replace[machine]
|
||||
}
|
||||
if len(m.Actions) > 0 {
|
||||
// The answers the notification offers (node-notifier's actions): the notifier hands the chosen
|
||||
// token back as an event, which the holder takes (answers.go).
|
||||
args["actions"] = m.Actions
|
||||
}
|
||||
raw, err := d.Ask("seat:node-notifier.send@"+machine, args)
|
||||
if err == nil {
|
||||
var id int
|
||||
@@ -140,6 +145,14 @@ func (d *Desktop) LastAnswers() map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// showOne shows a message on one machine only: an answer's outcome, or details, where it was asked.
|
||||
func (d *Desktop) showOne(machine string, m Message) (string, error) {
|
||||
only := &Desktop{Machines: func() []string { return []string{machine} }, Ask: d.Ask}
|
||||
id, err := only.Send(m)
|
||||
d.note(machine, err)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// notificationID reads the notifier's answer, {"id": N}, whether the runtime hands it over bare or in
|
||||
// the tool reply's text content.
|
||||
func notificationID(raw json.RawMessage) (int, error) {
|
||||
|
||||
@@ -80,12 +80,19 @@ const (
|
||||
DigestLines = 15
|
||||
)
|
||||
|
||||
// Message is what a channel shows: a title line and a body.
|
||||
// Message is what a channel shows: a title line and a body, and the answers it offers.
|
||||
type Message struct {
|
||||
Title string
|
||||
Body string
|
||||
Urgent bool
|
||||
Quiet bool // a clearing: shown without urgency
|
||||
Title string
|
||||
Body string
|
||||
Urgent bool
|
||||
Quiet bool // a clearing: shown without urgency
|
||||
Actions []Choice
|
||||
}
|
||||
|
||||
// Choice is one answer a message offers: the token a channel hands back when it is chosen, and its label.
|
||||
type Choice struct {
|
||||
Token string `json:"token"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
func (m Message) Text() string {
|
||||
@@ -122,9 +129,16 @@ type Record struct {
|
||||
Summary string `json:"summary"`
|
||||
// Headline, Explanation and Resolved are the plain words the operator reads (novox/hq ADR 0253):
|
||||
// the controller's, or a module's through notify; empty, the message is said from the summary.
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Explanation string `json:"explanation,omitempty"`
|
||||
Resolved string `json:"resolved,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Explanation string `json:"explanation,omitempty"`
|
||||
Resolved string `json:"resolved,omitempty"`
|
||||
// Needs is what the operator does, empty for nothing; Actions the answers offered, each by a token
|
||||
// (Tokens: token -> the action's index, or -1 for the details) a channel hands back when chosen.
|
||||
Needs string `json:"needs,omitempty"`
|
||||
Actions []Action `json:"actions,omitempty"`
|
||||
Tokens map[string]int `json:"tokens,omitempty"`
|
||||
// Answered is what the operator chose from a notification, and what came of it.
|
||||
Answered string `json:"answered,omitempty"`
|
||||
Origin string `json:"origin"`
|
||||
More string `json:"more"`
|
||||
Raised time.Time `json:"raised"`
|
||||
@@ -146,13 +160,17 @@ type Record struct {
|
||||
// recordOf is a condition as this holder keeps it.
|
||||
func recordOf(c Condition) Record {
|
||||
return Record{Key: c.Key, Kind: c.Kind, Subject: c.SubjectWords(), Severity: c.Severity, Summary: c.Summary,
|
||||
Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Origin: "condition",
|
||||
More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill}
|
||||
Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Needs: c.Needs, Actions: c.Actions,
|
||||
Origin: "condition",
|
||||
More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill}
|
||||
}
|
||||
|
||||
// takeWords keeps the plain words current, as the summary is: a condition's words follow what is seen.
|
||||
func (r *Record) takeWords(from Record) {
|
||||
r.Headline, r.Explanation, r.Resolved = from.Headline, from.Explanation, from.Resolved
|
||||
r.Headline, r.Explanation, r.Resolved, r.Needs = from.Headline, from.Explanation, from.Resolved, from.Needs
|
||||
if !sameActions(r.Actions, from.Actions) {
|
||||
r.Actions, r.Tokens = from.Actions, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Record) silenced(now time.Time) bool {
|
||||
@@ -196,6 +214,8 @@ type Holder struct {
|
||||
Logf func(string, ...any)
|
||||
// Emit states a fact as this module (refused); nil states nothing.
|
||||
Emit func(event string, body any) error
|
||||
// Ask calls a seat verb through the mesh, as this module: the answers the operator chooses.
|
||||
Ask Asker
|
||||
// Zone is the operator's time zone, which every time in a message is said in; nil is this machine's.
|
||||
Zone func() *time.Location
|
||||
|
||||
@@ -367,8 +387,10 @@ func (h *Holder) raised(rec Record, quiet bool) {
|
||||
old := h.open[rec.Key]
|
||||
h.mu.Unlock()
|
||||
if old != nil && old.Cleared.IsZero() {
|
||||
// Said again while open: the same message. Its words are kept current; nothing is sent.
|
||||
// Said again while open: the same message. Its words are kept current; nothing is sent — unless
|
||||
// it was kept quiet as needing nothing and now needs the operator.
|
||||
h.mu.Lock()
|
||||
wasQuiet := needsNothing(old)
|
||||
old.Summary, old.Subject, old.Kind, old.SilencedTill = rec.Summary, rec.Subject, rec.Kind, rec.SilencedTill
|
||||
old.takeWords(rec)
|
||||
if rec.Severity != "" {
|
||||
@@ -376,6 +398,7 @@ func (h *Holder) raised(rec Record, quiet bool) {
|
||||
}
|
||||
old.Seen = now
|
||||
h.mu.Unlock()
|
||||
h.nowNeeded(old, wasQuiet, quiet)
|
||||
h.persist(old)
|
||||
return
|
||||
}
|
||||
@@ -415,6 +438,13 @@ func (h *Holder) raised(rec Record, quiet bool) {
|
||||
h.persist(&r)
|
||||
}
|
||||
|
||||
// nowNeeded says a record kept quiet as needing nothing, once its words say it needs the operator.
|
||||
func (h *Holder) nowNeeded(r *Record, wasQuiet, quiet bool) {
|
||||
if wasQuiet && !needsNothing(r) && !r.told() && !quiet && !r.silenced(h.Now()) && !h.queued(r.Key) {
|
||||
h.deliver(r, "raised")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Holder) changed(rec Record, quiet bool) {
|
||||
h.mu.Lock()
|
||||
old := h.open[rec.Key]
|
||||
@@ -428,6 +458,7 @@ func (h *Holder) changed(rec Record, quiet bool) {
|
||||
}
|
||||
h.mu.Lock()
|
||||
escalated := old.Severity == Warning && rec.Severity == Urgent
|
||||
wasQuiet := needsNothing(old)
|
||||
old.Summary, old.Subject, old.SilencedTill = rec.Summary, rec.Subject, rec.SilencedTill
|
||||
old.takeWords(rec)
|
||||
if rec.Severity != "" {
|
||||
@@ -435,9 +466,12 @@ func (h *Holder) changed(rec Record, quiet bool) {
|
||||
}
|
||||
old.Seen = h.Now()
|
||||
h.mu.Unlock()
|
||||
if escalated && !quiet && !old.silenced(h.Now()) {
|
||||
switch {
|
||||
case escalated && !quiet && !old.silenced(h.Now()):
|
||||
// Routing differs for urgent: said once more, to both channels.
|
||||
h.deliver(old, "escalated")
|
||||
default:
|
||||
h.nowNeeded(old, wasQuiet, quiet)
|
||||
}
|
||||
h.persist(old)
|
||||
}
|
||||
@@ -556,6 +590,9 @@ func (h *Holder) say(r *Record, what string) Message {
|
||||
m := compose(r, what, now, h.zone(), 0)
|
||||
refusal, ok := Check(m.Text())
|
||||
if ok {
|
||||
if what != "cleared" {
|
||||
m.Actions = h.choices(r)
|
||||
}
|
||||
return m
|
||||
}
|
||||
h.mu.Lock()
|
||||
|
||||
@@ -73,7 +73,8 @@ func start() *clock { return &clock{t: time.Date(2026, 10, 6,
|
||||
func cond(key, sev, summary string) Condition {
|
||||
parts := strings.Split(key, ".")
|
||||
return Condition{Key: key, Scope: parts[0], ID: parts[1], Kind: parts[len(parts)-1], Severity: sev, Summary: summary,
|
||||
Headline: headlineOf(key), Explanation: "What it means: " + summary + "."}
|
||||
Headline: headlineOf(key), Explanation: "Needs you: look at it. What it means: " + summary + ".",
|
||||
Needs: "look at it."}
|
||||
}
|
||||
|
||||
// headlineOf is the headline a test condition carries: its key in words, so a message can be told by it
|
||||
@@ -122,7 +123,7 @@ func TestARaisedConditionIsSentOnceByItsKey(t *testing.T) {
|
||||
t.Fatalf("urgent: telegram %d, desktop %d; want one each", len(tg.sends), len(dt.sends))
|
||||
}
|
||||
keyless(t, tg.sends...)
|
||||
if tg.sends[0].Title != "Urgent: machine ace silent" || !strings.HasPrefix(tg.sends[0].Body, "What it means: ") ||
|
||||
if tg.sends[0].Title != "Urgent: machine ace silent" || !strings.HasPrefix(tg.sends[0].Body, "Needs you: look at it. What it means: ") ||
|
||||
!strings.Contains(tg.sends[0].Body, "\nSince ") {
|
||||
t.Fatalf("message: %q", tg.sends[0].Text())
|
||||
}
|
||||
|
||||
@@ -171,6 +171,7 @@ func main() {
|
||||
Ask: stdio.Ask,
|
||||
},
|
||||
Store: stateStore{},
|
||||
Ask: stdio.Ask,
|
||||
Logf: logf,
|
||||
Zone: func() *time.Location {
|
||||
loc, err := zoneOf(settings().TimeZone)
|
||||
@@ -247,6 +248,7 @@ func run(h *Holder, l *listening) {
|
||||
h.Condition(event, c)
|
||||
return nil
|
||||
}
|
||||
go listenForAnswers(h)
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.Subscribe(ControllerSeat+".*", handle)
|
||||
if err == nil {
|
||||
@@ -260,6 +262,42 @@ func run(h *Holder, l *listening) {
|
||||
}
|
||||
}
|
||||
|
||||
// AnswerEvent is the desk's notifier saying which answer the operator chose: the dunst holder of
|
||||
// node-notifier emits it as its module's event until seats publish events of their own (to-be 46 phase 3).
|
||||
const AnswerEvent = "dunst.action-chosen"
|
||||
|
||||
// listenForAnswers takes the answers the operator chooses on the desk, retried until it can.
|
||||
func listenForAnswers(h *Holder) {
|
||||
handle := func(e stdio.Envelope) error {
|
||||
if e.Key != AnswerEvent {
|
||||
return nil
|
||||
}
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
Machine string `json:"machine"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Body, &body); err != nil || body.Token == "" {
|
||||
logf("[messenger] an answer from the desk could not be read: %s", firstLineOf(string(e.Body)))
|
||||
return nil
|
||||
}
|
||||
machine := body.Machine
|
||||
if machine == "" {
|
||||
machine = e.Node
|
||||
}
|
||||
_, _ = h.Chosen("desktop", machine, body.Token)
|
||||
return nil
|
||||
}
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
if err := stdio.Subscribe("dunst.*", handle); err == nil {
|
||||
logf("[messenger] listening for the operator's answers on the desk")
|
||||
return
|
||||
} else {
|
||||
logf("[messenger] not hearing the desk's answers yet (%v); asking again in %s", err, wait)
|
||||
}
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
|
||||
// syncFromController reads the controller's open conditions and gives them to the holder.
|
||||
func syncFromController(h *Holder) {
|
||||
asked := h.Now()
|
||||
|
||||
@@ -55,10 +55,13 @@ func TestItDeclaresAndHoldsTheOperatorChannel(t *testing.T) {
|
||||
t.Fatalf("seats %+v claims %+v", m.Seats, m.Claims)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Consumes, []string{
|
||||
ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared}) {
|
||||
ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared,
|
||||
AnswerEvent}) {
|
||||
t.Fatalf("consumes %v", m.Consumes)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions"}) {
|
||||
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions",
|
||||
"seat:mesh-delivery.release", "seat:mesh-delivery.stop", "seat:" + ControllerSeat + ".plans",
|
||||
"seat:node-service-manager.restart"}) {
|
||||
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
|
||||
}
|
||||
if !reflect.DeepEqual(m.State, []string{"open", "sent"}) {
|
||||
|
||||
@@ -27,6 +27,11 @@ func (i item) urgent() bool { return i.Severity == Urgent && i.What != "cleared"
|
||||
// the desktop to Telegram when no session there takes it. No channel at all: it is unsent, said in
|
||||
// the status, and tried again every minute.
|
||||
func (h *Holder) deliver(r *Record, what string) {
|
||||
if needsNothing(r) && what != "escalated" {
|
||||
// Nothing for the operator to do, and only a warning: kept, not sent (ADR 0253).
|
||||
h.record(Sent{At: h.Now(), Channel: "-", Key: r.Key, What: what, Outcome: "quiet: nothing for the operator to do"})
|
||||
return
|
||||
}
|
||||
var to []Channel
|
||||
if r.Severity == Urgent {
|
||||
for _, ch := range h.channels() {
|
||||
@@ -231,11 +236,6 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
|
||||
if len(list) == 0 || (!force && !h.due(ch, list, now)) {
|
||||
return
|
||||
}
|
||||
type entry struct {
|
||||
it item
|
||||
r *Record
|
||||
msg Message
|
||||
}
|
||||
var entries []entry
|
||||
for _, it := range list {
|
||||
r, m := h.resolve(it, now)
|
||||
@@ -279,6 +279,48 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(entries) > 1 {
|
||||
// A message that asks the operator something goes alone, so its answers come with it.
|
||||
var alone, rest []entry
|
||||
for _, e := range entries {
|
||||
if len(e.msg.Actions) > 1 {
|
||||
alone = append(alone, e)
|
||||
} else {
|
||||
rest = append(rest, e)
|
||||
}
|
||||
}
|
||||
if len(alone) > 0 {
|
||||
for _, e := range alone {
|
||||
if !h.sendEntries(ch, []entry{e}, now) {
|
||||
h.failed(ch, list, taken, now, errorf("could not send"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(rest) > 0 && !h.sendEntries(ch, rest, now) {
|
||||
h.failed(ch, list, taken, now, errorf("could not send"))
|
||||
return
|
||||
}
|
||||
h.drop(name, taken)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !h.sendEntries(ch, entries, now) {
|
||||
h.failed(ch, list, taken, now, errorf("could not send"))
|
||||
return
|
||||
}
|
||||
h.drop(name, taken)
|
||||
}
|
||||
|
||||
// entry is one held item resolved to its record and message.
|
||||
type entry struct {
|
||||
it item
|
||||
r *Record
|
||||
msg Message
|
||||
}
|
||||
|
||||
// sendEntries sends entries as one message — itself when alone, a digest when several — and records it.
|
||||
func (h *Holder) sendEntries(ch Channel, entries []entry, now time.Time) bool {
|
||||
name := ch.Name()
|
||||
var m Message
|
||||
if len(entries) == 1 {
|
||||
m = entries[0].msg
|
||||
@@ -293,10 +335,8 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
|
||||
id, err := ch.Send(m)
|
||||
h.noteChannel(name, err)
|
||||
if err != nil {
|
||||
h.failed(ch, list, taken, now, err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
h.drop(name, taken)
|
||||
h.mu.Lock()
|
||||
h.lastSend[name] = now
|
||||
h.lastTry[name] = time.Time{}
|
||||
@@ -342,6 +382,7 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
|
||||
h.persist(e.r)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// drop removes the first n held items of a channel: those this flush took.
|
||||
|
||||
@@ -14,37 +14,57 @@ import (
|
||||
// commit, verb or markup — and a clearance in one line.
|
||||
|
||||
type example struct {
|
||||
name, before string
|
||||
key, kind, severity, summary string
|
||||
subject map[string]string
|
||||
headline, explanation, resolved string
|
||||
title, body string // what the desktop shows
|
||||
cleared string // the clearance's one line
|
||||
openFor time.Duration
|
||||
name, before string
|
||||
key, kind, severity, summary string
|
||||
subject map[string]string
|
||||
headline, explanation string
|
||||
needs, resolved string
|
||||
actions []Action
|
||||
popup bool // shown at all: a warning that needs nothing is not
|
||||
title, body string // what the desktop shows
|
||||
offers string // the answers it offers, by label
|
||||
choose string // the answer chosen in the test
|
||||
asked string // the seat verb that answer calls, and its arguments
|
||||
cleared string // the clearance's one line
|
||||
openFor time.Duration
|
||||
}
|
||||
|
||||
const waitingSummary = "the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word to start: " +
|
||||
"`mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " +
|
||||
"plan-1791454185265004861 --why …` starts it by hand"
|
||||
|
||||
const waitingBefore = "CLEARED after 26 min: the walk of novox/mesh-catalog a6385479 has waited 56m0s for " +
|
||||
"mesh-delivery's word to start: `mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " +
|
||||
"plan-1791454185265004861 --why ...` starts it by hand\nabout: plan plan-1791454185265004861 (waiting)\n" +
|
||||
"since: 2026-10-08 10:40 UTC\nkey: plan.plan-1791454185265004861.waiting\nmore: conditions show " +
|
||||
"plan.plan-1791454... 65004861.waiting"
|
||||
|
||||
var examples = []example{{
|
||||
name: "a delivery waiting",
|
||||
before: "CLEARED after 26 min: the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word " +
|
||||
"to start: `mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " +
|
||||
"plan-1791454185265004861 --why ...` starts it by hand\nabout: plan plan-1791454185265004861 (waiting)\n" +
|
||||
"since: 2026-10-08 10:40 UTC\nkey: plan.plan-1791454185265004861.waiting\nmore: conditions show " +
|
||||
"plan.plan-1791454... 65004861.waiting",
|
||||
name: "a delivery waiting, under its bound", before: waitingBefore,
|
||||
key: "plan.plan-1791454185265004861.waiting", kind: "waiting", severity: Warning,
|
||||
subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"},
|
||||
summary: "the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word to start: " +
|
||||
"`mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " +
|
||||
"plan-1791454185265004861 --why …` starts it by hand",
|
||||
subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"}, summary: waitingSummary,
|
||||
headline: "openrazer delivery waiting to start",
|
||||
explanation: "The change to openrazer is merged and built, and has waited 56 minutes for mesh-delivery (the " +
|
||||
"module that decides when a delivery goes out) to let it start. Nothing to do yet; it becomes urgent after " +
|
||||
"4 hours. To start it now, have an agent start it by hand.",
|
||||
resolved: "openrazer delivery no longer waiting",
|
||||
title: "openrazer delivery waiting to start",
|
||||
body: "The change to openrazer is merged and built, and has waited 56 minutes for mesh-delivery (the module " +
|
||||
"that decides when a delivery goes out) to let it start. Nothing to do yet; it becomes urgent after 4 hours. " +
|
||||
"To start it now, have an agent start it by hand.\nSince 12:40.",
|
||||
cleared: "Resolved: openrazer delivery no longer waiting, after 26 min", openFor: 26 * time.Minute,
|
||||
explanation: "Nothing for you to do. The change to openrazer is merged and built, and has waited 56 minutes for " +
|
||||
"mesh-delivery (the module that decides when a delivery goes out) to let it start. It becomes a question for " +
|
||||
"you if it still waits after 4 hours.",
|
||||
resolved: "openrazer delivery no longer waiting", popup: false, openFor: 26 * time.Minute,
|
||||
}, {
|
||||
name: "a delivery waiting, past its bound", before: waitingBefore,
|
||||
key: "plan.plan-1791454185265004861.waiting", kind: "waiting", severity: Urgent,
|
||||
subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"}, summary: waitingSummary,
|
||||
headline: "openrazer delivery waiting to start", needs: "start it, or stop it.",
|
||||
explanation: "Needs you: start it, or stop it. The change to openrazer is merged and built, and mesh-delivery " +
|
||||
"(the module that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may be stuck.",
|
||||
actions: []Action{
|
||||
{Label: "Start", Verb: "mesh-controller.plans", Arguments: map[string]string{"go": "plan-1791454185265004861", "why": ""}},
|
||||
{Label: "Stop", Verb: "mesh-controller.plans", Arguments: map[string]string{"stop": "plan-1791454185265004861", "why": ""}}},
|
||||
resolved: "openrazer delivery no longer waiting", popup: true,
|
||||
title: "Urgent: openrazer delivery waiting to start",
|
||||
body: "Needs you: start it, or stop it. The change to openrazer is merged and built, and mesh-delivery (the module " +
|
||||
"that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may be stuck.\nSince 12:40.",
|
||||
offers: "Start, Stop, Details", choose: "Start",
|
||||
asked: `seat:mesh-controller.plans {"go":"plan-1791454185265004861","why":"the operator chose Start on the desktop notification \"openrazer delivery waiting to start\""}`,
|
||||
cleared: "Resolved: openrazer delivery no longer waiting, after 5.0 h", openFor: 5 * time.Hour,
|
||||
}, {
|
||||
name: "a module unhealthy",
|
||||
before: "WARNING: openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the account's own " +
|
||||
@@ -54,13 +74,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",
|
||||
explanation: "openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as " +
|
||||
"soon as it runs again; if it stays, it needs a look at why it fails.",
|
||||
resolved: "openrazer works again on g14",
|
||||
title: "openrazer not working on g14",
|
||||
body: "openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as soon " +
|
||||
"as it runs again; if it stays, it needs a look at why it fails.\nSince 12:40.",
|
||||
headline: "openrazer not working on g14", needs: "restart it; if it fails again, the details say why.",
|
||||
explanation: "Needs you: restart it; if it fails again, the details say why. openrazer on g14 is not healthy: its " +
|
||||
"service openrazer-daemon stopped with an error. It clears as soon as it runs again.",
|
||||
actions: []Action{{Label: "Restart", Verb: "node-service-manager.restart", Machine: "g14",
|
||||
Arguments: map[string]string{"unit": "openrazer-daemon.service", "scope": "user"}}},
|
||||
resolved: "openrazer works again on g14", popup: true,
|
||||
title: "openrazer not working on g14",
|
||||
body: "Needs you: restart it; if it fails again, the details say why. openrazer on g14 is not healthy: its service " +
|
||||
"openrazer-daemon stopped with an error. It clears as soon as it runs again.\nSince 12:40.",
|
||||
offers: "Restart, Details", choose: "Restart",
|
||||
asked: `seat:node-service-manager.restart@g14 {"scope":"user","unit":"openrazer-daemon.service"}`,
|
||||
cleared: "Resolved: openrazer works again on g14, after 2.0 h", openFor: 2 * time.Hour,
|
||||
}, {
|
||||
name: "failed units on a machine",
|
||||
@@ -73,13 +97,19 @@ var examples = []example{{
|
||||
summary: "shanks's service manager is degraded: 3 failed unit(s) no module places — mnt-recalbox.mount, " +
|
||||
"storage-media.mount, greenclip.service (the account's own manager). Each is the machine's own: mend or " +
|
||||
"remove it there, or have a module place it",
|
||||
headline: "3 failed services on shanks",
|
||||
explanation: "On shanks, mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module " +
|
||||
"manages them, so the mesh does not repair them: mend or remove them on shanks.",
|
||||
resolved: "No failed services on shanks any more",
|
||||
title: "3 failed services on shanks",
|
||||
body: "On shanks, mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, " +
|
||||
"so the mesh does not repair them: mend or remove them on shanks.\nSince 12:40.",
|
||||
headline: "3 failed services on shanks", needs: "mend or remove them on shanks, or silence this if they do not matter.",
|
||||
explanation: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, " +
|
||||
"mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh " +
|
||||
"does not repair them.",
|
||||
actions: []Action{{Label: "Silence for a week", Verb: "mesh-controller.conditions",
|
||||
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": ""}}},
|
||||
resolved: "No failed services on shanks any more", popup: true,
|
||||
title: "3 failed services on shanks",
|
||||
body: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, mnt-recalbox " +
|
||||
"(a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh does not repair " +
|
||||
"them.\nSince 12:40.",
|
||||
offers: "Silence for a week, Details", choose: "Silence for a week",
|
||||
asked: `seat:mesh-controller.conditions {"for":"7d","silence":"machine.shanks.units","why":"the operator chose Silence for a week on the desktop notification \"3 failed services on shanks\""}`,
|
||||
cleared: "Resolved: no failed services on shanks any more, after 45 min", openFor: 45 * time.Minute,
|
||||
}, {
|
||||
name: "a healer wanted",
|
||||
@@ -92,19 +122,37 @@ var examples = []example{{
|
||||
summary: "\"push\" was repaired by hand 35 times in 14 days, the last by g14.node-tools, through the " +
|
||||
"mesh-controller seat: a healer is wanted for it",
|
||||
headline: "Push keeps being fixed by hand",
|
||||
explanation: "A person repaired push by hand 35 times in 14 days. A healer (an automatic repair) is wanted for " +
|
||||
"it; nothing is broken now.",
|
||||
resolved: "Resolved: no more hand repairs of push",
|
||||
title: "Push keeps being fixed by hand",
|
||||
body: "A person repaired push by hand 35 times in 14 days. A healer (an automatic repair) is wanted for it; " +
|
||||
"nothing is broken now.\nSince 12:40.",
|
||||
cleared: "Resolved: no more hand repairs of push, after 3 days", openFor: 3 * 24 * time.Hour,
|
||||
explanation: "Nothing for you to do. A person repaired push by hand 35 times in 14 days, so an automatic repair " +
|
||||
"is wanted for it. Nothing is broken now.",
|
||||
resolved: "Resolved: no more hand repairs of push", popup: false, openFor: 3 * 24 * time.Hour,
|
||||
}, {
|
||||
name: "a delivery held",
|
||||
before: "WARNING: the delivery novox/hq@055550802096 has been held for 36h2m6s, past its bound of 24h0m0s (it " +
|
||||
"waits for the operator): healer H2 may none: the state is the operator's — `mesh-delivery.show " +
|
||||
"novox/hq@055550802096`\nabout: delivery novox/hq@055550802096 (stalled)\nsince: 2026-10-07 00:38 UTC\n" +
|
||||
"key: delivery.novox/hq_055550802096.stalled\nmore: conditions show delivery.novox/hq_055550802096.stalled",
|
||||
key: "delivery.novox/hq_055550802096.stalled", kind: "stalled", severity: Warning,
|
||||
subject: map[string]string{"scope": "delivery", "id": "novox/hq@055550802096"},
|
||||
summary: "the delivery novox/hq@055550802096 has been held for 36h2m6s, past its bound of 24h0m0s (it waits for " +
|
||||
"the operator): healer H2 may none: the state is the operator's — `mesh-delivery.show novox/hq@055550802096`",
|
||||
headline: "Delivery of hq held for 36 hours", needs: "release it, or stop it.",
|
||||
explanation: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours, past its limit.",
|
||||
actions: []Action{
|
||||
{Label: "Release", Verb: "mesh-delivery.release", Arguments: map[string]string{"id": "novox/hq@055550802096", "why": ""}},
|
||||
{Label: "Stop", Verb: "mesh-delivery.stop", Arguments: map[string]string{"id": "novox/hq@055550802096", "why": ""}}},
|
||||
resolved: "Delivery of hq is no longer held", popup: true,
|
||||
title: "Delivery of hq held for 36 hours",
|
||||
body: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours, past its limit.\nSince 12:40.",
|
||||
offers: "Release, Stop, Details", choose: "Release",
|
||||
asked: `seat:mesh-delivery.release {"id":"novox/hq@055550802096","why":"the operator chose Release on the desktop notification \"Delivery of hq held for 36 hours\""}`,
|
||||
cleared: "Resolved: delivery of hq is no longer held, after 3 min", openFor: 3 * time.Minute,
|
||||
}}
|
||||
|
||||
// event is the body a controller with plain words sends for a condition.
|
||||
func (e example) event(at time.Time, cleared bool) []byte {
|
||||
body := map[string]any{"key": e.key, "kind": e.kind, "subject": e.subject, "severity": e.severity,
|
||||
"summary": e.summary, "headline": e.headline, "explanation": e.explanation, "resolved": e.resolved,
|
||||
"needs": e.needs, "actions": e.actions,
|
||||
"raised": "2026-10-08T10:40:00Z", "at": at.Format(time.RFC3339), "silenced": nil}
|
||||
if cleared {
|
||||
body["cleared"] = at.Format(time.RFC3339)
|
||||
@@ -113,16 +161,21 @@ func (e example) event(at time.Time, cleared bool) []byte {
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestRealNotificationsReadInOneGlance(t *testing.T) {
|
||||
func TestRealNotificationsReadInOneGlanceAndAreAnsweredFromTheNotification(t *testing.T) {
|
||||
brussels, err := time.LoadLocation("Europe/Brussels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range examples {
|
||||
t.Run(e.name, func(t *testing.T) {
|
||||
h, _, dt, c, _ := newHolder(t)
|
||||
h.Telegram = nil
|
||||
h, tg, dt, c, _ := newHolder(t)
|
||||
h.Zone = func() *time.Location { return brussels }
|
||||
var asked []string
|
||||
h.Ask = func(address string, body any) (json.RawMessage, error) {
|
||||
raw, _ := json.Marshal(body)
|
||||
asked = append(asked, address+" "+string(raw))
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
}
|
||||
c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC)
|
||||
raised, err := DecodeCondition(EventRaised, e.event(c.now(), false))
|
||||
if err != nil {
|
||||
@@ -130,34 +183,114 @@ func TestRealNotificationsReadInOneGlance(t *testing.T) {
|
||||
}
|
||||
h.Condition(EventRaised, raised)
|
||||
settle(h, c)
|
||||
if !e.popup {
|
||||
if len(dt.sends)+len(tg.sends) != 0 {
|
||||
t.Fatalf("a warning that needs nothing was shown: %+v %+v", dt.sends, tg.sends)
|
||||
}
|
||||
hist := h.History(5)["sends"].([]Sent)
|
||||
if len(hist) == 0 || !strings.HasPrefix(hist[0].Outcome, "quiet") {
|
||||
t.Errorf("not kept as quiet in the history: %+v", hist)
|
||||
}
|
||||
t.Logf("\nBEFORE:\n%s\n\nAFTER: no popup. Kept in the controller's conditions and the channel's history:\n%s\n%s",
|
||||
e.before, e.headline, e.explanation)
|
||||
return
|
||||
}
|
||||
if len(dt.sends) != 1 {
|
||||
t.Fatalf("desktop sends %d", len(dt.sends))
|
||||
}
|
||||
m := dt.sends[0]
|
||||
keyless(t, m)
|
||||
if m.Title != e.title || m.Body != e.body {
|
||||
t.Errorf("shows\n %q\n %q\nwant\n %q\n %q", m.Title, m.Body, e.title, e.body)
|
||||
var labels []string
|
||||
for _, a := range m.Actions {
|
||||
labels = append(labels, a.Label)
|
||||
}
|
||||
for _, w := range []string{"`", "UTC", "key:", "more:", "about:", "plan-", "a6385479"} {
|
||||
if m.Title != e.title || m.Body != e.body || strings.Join(labels, ", ") != e.offers {
|
||||
t.Errorf("shows\n %q\n %q\n %v\nwant\n %q\n %q\n %s", m.Title, m.Body, labels, e.title, e.body, e.offers)
|
||||
}
|
||||
for _, w := range []string{"`", "UTC", "key:", "more:", "about:", "plan-", "a6385479", "agent", "by hand"} {
|
||||
if strings.Contains(m.Text(), w) {
|
||||
t.Errorf("it still says %q: %q", w, m.Text())
|
||||
}
|
||||
}
|
||||
// The operator answers from the notification.
|
||||
var tok string
|
||||
for _, a := range m.Actions {
|
||||
if a.Label == e.choose {
|
||||
tok = a.Token
|
||||
}
|
||||
}
|
||||
outcome, err := h.Chosen("desktop", "g14", tok)
|
||||
if err != nil || outcome != "done" || len(asked) != 1 || asked[0] != e.asked {
|
||||
t.Fatalf("chose %s: %q %v\nasked %v\nwant %s", e.choose, outcome, err, asked, e.asked)
|
||||
}
|
||||
done := dt.edits["1"]
|
||||
if done.Title != e.choose+": done. "+e.headline {
|
||||
t.Errorf("the answer's outcome: %q", done.Title)
|
||||
}
|
||||
c.pass(e.openFor - BurstWindow)
|
||||
cleared, err := DecodeCondition(EventCleared, e.event(c.now(), true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.Condition(EventCleared, cleared)
|
||||
done := dt.edits["1"]
|
||||
if done.Title != e.cleared || done.Body != "" {
|
||||
t.Errorf("clearance %q / %q, want one line %q", done.Title, done.Body, e.cleared)
|
||||
end := dt.edits["1"]
|
||||
if end.Title != e.cleared || end.Body != "" || len(end.Actions) != 0 {
|
||||
t.Errorf("clearance %q / %q / %v, want one line %q", end.Title, end.Body, end.Actions, e.cleared)
|
||||
}
|
||||
t.Logf("\nBEFORE:\n%s\n\nAFTER:\n%s\n%s\n\nCLEARED:\n%s", e.before, m.Title, m.Body, done.Title)
|
||||
t.Logf("\nBEFORE:\n%s\n\nAFTER:\n%s\n%s\n[%s]\n\nCHOSEN %s:\n%s\n%s\n\nCLEARED:\n%s", e.before, m.Title,
|
||||
m.Body, strings.Join(labels, "] ["), e.choose, done.Title, done.Body, end.Title)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// **A warning kept quiet is said once it needs the operator**: at its bound it becomes urgent, and then
|
||||
// its words say "Needs you:".
|
||||
func TestAQuietWarningIsSaidWhenItBecomesUrgent(t *testing.T) {
|
||||
h, tg, dt, c, _ := newHolder(t)
|
||||
e := examples[0]
|
||||
raised, _ := DecodeCondition(EventRaised, e.event(c.now(), false))
|
||||
h.Condition(EventRaised, raised)
|
||||
settle(h, c)
|
||||
if len(dt.sends)+len(tg.sends) != 0 {
|
||||
t.Fatal("said while it needed nothing")
|
||||
}
|
||||
past := examples[1]
|
||||
c.pass(4 * time.Hour)
|
||||
changed, _ := DecodeCondition(EventChanged, past.event(c.now(), false))
|
||||
h.Condition(EventChanged, changed)
|
||||
settle(h, c)
|
||||
if len(dt.sends) != 1 || len(tg.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: start it, or stop it.") {
|
||||
t.Fatalf("desktop %+v telegram %d", dt.sends, len(tg.sends))
|
||||
}
|
||||
}
|
||||
|
||||
// **Details shows the summary and the key, where it was asked**, and an answer for a message that ended
|
||||
// is refused in words.
|
||||
func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) {
|
||||
h, _, dt, c, _ := newHolder(t)
|
||||
h.Zone = func() *time.Location { return time.UTC }
|
||||
e := examples[5]
|
||||
raised, _ := DecodeCondition(EventRaised, e.event(c.now(), false))
|
||||
h.Condition(EventRaised, raised)
|
||||
settle(h, c)
|
||||
m := dt.sends[0]
|
||||
details := m.Actions[len(m.Actions)-1]
|
||||
if details.Label != DetailsLabel {
|
||||
t.Fatalf("%+v", m.Actions)
|
||||
}
|
||||
if _, err := h.Chosen("desktop", "g14", details.Token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shown := dt.sends[len(dt.sends)-1]
|
||||
if shown.Title != "Details: "+e.headline || !strings.HasPrefix(shown.Body, e.summary) ||
|
||||
!strings.Contains(shown.Body, "Since: 8 Oct 10:40") {
|
||||
t.Errorf("details: %q / %q", shown.Title, shown.Body)
|
||||
}
|
||||
if _, err := h.Chosen("desktop", "g14", "not-a-token"); err == nil {
|
||||
t.Error("an unknown answer was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// **A condition from a controller without plain words is said from its summary**, its markup taken out.
|
||||
func TestAConditionWithoutPlainWordsIsSaidFromItsSummary(t *testing.T) {
|
||||
h, tg, _, c, _ := newHolder(t)
|
||||
|
||||
Reference in New Issue
Block a user