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

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:
jochen
2026-10-08 13:57:33 +02:00
parent a6296dfd6f
commit f00b5a19b1
18 changed files with 911 additions and 89 deletions
+168
View File
@@ -0,0 +1,168 @@
package main
// Answers from a notification (novox/hq ADR 0253, to-be 46 §11 "the desk"): `send` takes actions, each a
// token and a label. dunst offers them on the notification (a middle click, or its context menu, which
// is the node-launcher's menu); the one the operator chooses is emitted as this module's event
// `action-chosen` {id, token, machine}, and the operator channel's holder acts on it. It is this module's
// event until seats publish events of their own (to-be 46 phase 3), when it becomes the node-notifier
// seat's.
//
// notify-send waits for the answer (`--wait`) in this process: the id is answered at once, and the
// answer, if any, comes later. An answer is never invented: a notification closed, expired or replaced
// emits nothing, and a wait ends after AnswerWait. A restart of this bundle ends the waits it held,
// which the README says; the notification stays shown and its answers do nothing then.
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"syscall"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Choice is one action a notification offers.
type Choice struct {
Token string `json:"token"`
Label string `json:"label"`
}
const (
// MostChoices is how many actions one notification offers.
MostChoices = 4
// AnswerWait is how long an answer is waited for.
AnswerWait = 7 * 24 * time.Hour
// ChosenEvent is the event an answer is said as.
ChosenEvent = "action-chosen"
)
var tokenShape = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
// emit says an event as this module; a seam a test replaces.
var emit = func(event string, body any) error { return stdio.Emit(event, body) }
// choicesOf reads `actions`: a list of {token, label}.
func choicesOf(args map[string]any) ([]Choice, error) {
v, present := args["actions"]
if !present || v == nil {
return nil, nil
}
list, ok := v.([]any)
if !ok {
return nil, errors.New("actions is a list of {token, label}")
}
if len(list) > MostChoices {
return nil, fmt.Errorf("at most %d actions", MostChoices)
}
var out []Choice
seen := map[string]bool{}
for _, it := range list {
o, ok := it.(map[string]any)
if !ok {
return nil, errors.New("actions is a list of {token, label}")
}
tok, _ := o["token"].(string)
label, _ := o["label"].(string)
label = strings.TrimSpace(label)
switch {
case !tokenShape.MatchString(tok):
return nil, fmt.Errorf("an action's token is letters, digits, - and _, at most 64: %q", tok)
case seen[tok]:
return nil, fmt.Errorf("the token %q is given twice", tok)
case label == "" || len(label) > 40 || strings.ContainsAny(label, "\n="):
return nil, fmt.Errorf("an action's label is one short line without '=': %q", label)
}
seen[tok] = true
out = append(out, Choice{Token: tok, Label: label})
}
return out, nil
}
// actionArgs are notify-send's arguments for the choices, and its wait.
func actionArgs(choices []Choice) []string {
args := []string{"--wait"}
for _, c := range choices {
args = append(args, "--action="+c.Token+"="+c.Label)
}
return args
}
// sendWaiting runs notify-send with the choices, answers the id it prints first, and waits for the
// answer in the background: the next line it prints, if it is one of the tokens.
func (s Session) sendWaiting(args []string, choices []Choice) (SendResult, error) {
path, err := exec.LookPath("notify-send")
if err != nil {
return SendResult{}, errors.New("notify-send is not installed on this machine")
}
cmd := exec.Command(path, args...)
cmd.Env = s.Env()
if home := operatorHome(); exists(home) {
cmd.Dir = home
}
out, err := cmd.StdoutPipe()
if err != nil {
return SendResult{}, err
}
var errOut capped
cmd.Stderr = &errOut
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
return SendResult{}, fmt.Errorf("notify-send: %w", err)
}
lines := bufio.NewReader(out)
first := make(chan string, 1)
go func() {
line, _ := lines.ReadString('\n')
first <- strings.TrimSpace(line)
}()
var idLine string
select {
case idLine = <-first:
case <-time.After(busTimeout):
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
_ = cmd.Wait()
return SendResult{}, fmt.Errorf("notify-send did not answer an id within %s: %w", busTimeout, ErrTimedOut)
}
id, err := strconv.Atoi(idLine)
if err != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
_ = cmd.Wait()
return SendResult{}, fmt.Errorf("notify-send answered no id: %q %s", idLine, strings.TrimSpace(errOut.String()))
}
go awaitAnswer(cmd, lines, id, choices)
return SendResult{ID: id}, nil
}
// awaitAnswer reads what notify-send says when the notification ends, and says a chosen token.
func awaitAnswer(cmd *exec.Cmd, lines io.Reader, id int, choices []Choice) {
timer := time.AfterFunc(AnswerWait, func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) })
defer timer.Stop()
rest, _ := io.ReadAll(io.LimitReader(lines, 4096))
_ = cmd.Wait()
if tok, ok := chosenToken(string(rest), choices); ok {
body := map[string]any{"id": id, "token": tok, "machine": os.Getenv("MESH_NODE")}
if err := emit(ChosenEvent, body); err != nil {
fmt.Fprintf(os.Stderr, "[dunst] the operator chose an action on notification %d and it could not be said: %v\n", id, err)
}
}
}
// chosenToken is the token notify-send printed after the id, when it is one offered.
func chosenToken(rest string, choices []Choice) (string, bool) {
for _, line := range strings.Split(rest, "\n") {
line = strings.TrimSpace(line)
for _, c := range choices {
if line == c.Token {
return c.Token, true
}
}
}
return "", false
}
@@ -0,0 +1,92 @@
package main
import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// **A notification offers answers, and the chosen one is said as an event** (novox/hq ADR 0253): the id
// is answered at once, the answer comes when the operator chooses, and nothing is said for a
// notification closed without one.
func TestANotificationsChosenAnswerIsSaidAsAnEvent(t *testing.T) {
withBus(t)
bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo 42; sleep 0.2; echo "$CHOSEN"`})
t.Setenv("LOG", filepath.Join(bin, "log"))
t.Setenv("MESH_NODE", "g14")
var mu sync.Mutex
var said []map[string]any
before := emit
emit = func(event string, body any) error {
mu.Lock()
defer mu.Unlock()
if event != ChosenEvent {
t.Errorf("event %q", event)
}
said = append(said, body.(map[string]any))
return nil
}
t.Cleanup(func() { emit = before })
n, err := notificationOf(map[string]any{"summary": "Delivery of hq held for 36 hours", "body": "Needs you: release it, or stop it.",
"actions": []any{map[string]any{"token": "a1b2", "label": "Release"}, map[string]any{"token": "c3d4", "label": "Stop"}}})
if err != nil {
t.Fatal(err)
}
t.Setenv("CHOSEN", "c3d4")
start := time.Now()
got, err := Send(n)
if err != nil || got.ID != 42 || time.Since(start) > 150*time.Millisecond {
t.Fatalf("%+v, %v, after %s: the id is answered before the answer", got, err, time.Since(start))
}
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
if !strings.Contains(string(asked), "[--wait][--action=a1b2=Release][--action=c3d4=Stop][--]") {
t.Fatalf("notify-send was asked %s", asked)
}
deadline := time.Now().Add(3 * time.Second)
for {
mu.Lock()
n := len(said)
mu.Unlock()
if n > 0 || time.Now().After(deadline) {
break
}
time.Sleep(20 * time.Millisecond)
}
mu.Lock()
if len(said) != 1 || said[0]["token"] != "c3d4" || said[0]["id"] != 42 || said[0]["machine"] != "g14" {
t.Fatalf("said %v", said)
}
said = nil
mu.Unlock()
// Closed without an answer, or an answer nobody offered: nothing is said.
for _, chosen := range []string{"", "zzzz"} {
t.Setenv("CHOSEN", chosen)
if _, err := Send(n); err != nil {
t.Fatal(err)
}
}
time.Sleep(600 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
if len(said) != 0 {
t.Fatalf("an answer was invented: %v", said)
}
}
func TestActionsAreRefusedForWhatTheyCannotBe(t *testing.T) {
for _, bad := range []any{"x", []any{"x"}, []any{map[string]any{"token": "a b", "label": "x"}},
[]any{map[string]any{"token": "a", "label": ""}}, []any{map[string]any{"token": "a", "label": "x=y"}},
[]any{map[string]any{"token": "a", "label": "x"}, map[string]any{"token": "a", "label": "y"}},
[]any{map[string]any{"token": "a", "label": "1"}, map[string]any{"token": "b", "label": "2"},
map[string]any{"token": "c", "label": "3"}, map[string]any{"token": "d", "label": "4"},
map[string]any{"token": "e", "label": "5"}}} {
if _, err := notificationOf(map[string]any{"summary": "x", "actions": bad}); err == nil {
t.Errorf("accepted %v", bad)
}
}
}
+11 -1
View File
@@ -27,6 +27,7 @@ type Notification struct {
ExpireMS int
Category string
ReplaceID int
Actions []Choice
}
func notificationOf(args map[string]any) (Notification, error) {
@@ -66,7 +67,10 @@ func notificationOf(args map[string]any) (Notification, error) {
if n.Category, err = text(args, "category", false); err != nil {
return n, err
}
n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1)
if n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1); err != nil {
return n, err
}
n.Actions, err = choicesOf(args)
return n, err
}
@@ -94,11 +98,17 @@ func Send(n Notification) (SendResult, error) {
if n.ReplaceID > 0 {
args = append(args, "--replace-id="+strconv.Itoa(n.ReplaceID))
}
if len(n.Actions) > 0 {
args = append(args, actionArgs(n.Actions)...)
}
// "--" so a title that starts with a dash is a title.
args = append(args, "--", n.Summary)
if n.Body != "" {
args = append(args, n.Body)
}
if len(n.Actions) > 0 {
return s.sendWaiting(args, n.Actions)
}
r, err := s.run(busTimeout, "", "notify-send", args...)
if err != nil {
return SendResult{}, err
+7 -2
View File
@@ -22,8 +22,9 @@ func tools() []stdio.Tool {
{
Name: "node-notifier.send",
Description: "Show a notification on the operator's desktop: a title, a body, an urgency (low, " +
"normal, critical), and optionally the sending application's name, an icon and how long it stays. " +
"Answers the notification's id.",
"normal, critical), and optionally the sending application's name, an icon, how long it stays and " +
"the answers it offers (actions: token and label each). Answers the notification's id; the answer " +
"the operator chooses is said later as the event action-chosen {id, token, machine}.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
@@ -35,6 +36,10 @@ func tools() []stdio.Tool {
"expire_ms": map[string]any{"type": "integer", "description": "how long it stays; 0 until dismissed (default: the urgency's own)"},
"category": map[string]any{"type": "string", "description": "a notification category, e.g. email.arrived"},
"replace_id": map[string]any{"type": "integer", "description": "replace the notification with this id instead of adding one"},
"actions": map[string]any{"type": "array", "description": "at most 4 answers the notification offers, " +
"each {token, label}; the chosen token is said as the event action-chosen",
"items": map[string]any{"type": "object", "properties": map[string]any{
"token": map[string]any{"type": "string"}, "label": map[string]any{"type": "string"}}}},
},
"required": []string{"summary"},
},
@@ -19,6 +19,7 @@ type manifest struct {
Claims []claim `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Emits []string `json:"emits"`
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`