loop: let config turn a nudge rule off
The kuma service_down nudge cannot name the service. mavpoll folds the whole monitor_status gauge into one boolean fact keyed `service_down`, and the phraser names a service only when the fact key is the service name, so the message is always the generic "a service on homesrv is down". Every fifteen minutes, with nothing to act on. A fact per monitor is the real fix and it is filed as Vikunja #444; this is what to do until then. `disabled_rules` in mavend.json subtracts from loop.DefaultRules by name. Config only subtracts — rules stay code, the set stays canonical and ordered as written. A disabled rule is not gathered for either, since the gatherer derives its key set from the rules it was given. Unknown names are ignored so deleting a rule cannot brick a config that still lists it, and the boot log says what was dropped, because a rule that vanishes silently looks exactly like a rule that is broken. Deploy turns service_down off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
+14
-2
@@ -236,7 +236,7 @@ func run(args []string) error {
|
||||
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
|
||||
|
||||
if !locked {
|
||||
rules = loop.DefaultRules()
|
||||
rules = wireRules(cfg)
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
@@ -508,7 +508,7 @@ func run(args []string) error {
|
||||
}
|
||||
|
||||
// Wire everything.
|
||||
rules = loop.DefaultRules()
|
||||
rules = wireRules(cfg)
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
@@ -810,3 +810,15 @@ func waitWorkers(wg *sync.WaitGroup, d time.Duration) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// wireRules builds the nudge rule set, minus anything config turned off. The
|
||||
// drop is logged because a rule vanishing silently is indistinguishable from a
|
||||
// rule that is broken, and the next person to wonder why she stopped nudging
|
||||
// should find the answer in the boot log.
|
||||
func wireRules(cfg *config.Config) []loop.Rule {
|
||||
rules, dropped := loop.RulesExcept(cfg.DisabledRules)
|
||||
for _, name := range dropped {
|
||||
log.Printf("loop: rule %q disabled by config", name)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
"socket_path": "/run/maven/mavend.sock",
|
||||
"state_dir": "/var/lib/maven",
|
||||
|
||||
"//disabled_rules": [
|
||||
"Nudge rules that are not wired at all. Names come from loop.DefaultRules:",
|
||||
"water, meal, break, service_down, netdata_critical.",
|
||||
"service_down is off because it cannot say WHICH service — mavpoll folds the",
|
||||
"whole kuma gauge into one boolean, so the nudge is always the generic 'a",
|
||||
"service on homesrv is down'. Nothing to act on, every fifteen minutes.",
|
||||
"Turn it back on once Vikunja #444 lands a fact per monitor."
|
||||
],
|
||||
"disabled_rules": ["service_down"],
|
||||
|
||||
"phraser": {
|
||||
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
||||
"bin_path": "llama-server",
|
||||
|
||||
@@ -144,6 +144,22 @@ type Config struct {
|
||||
// nil ⇒ quiet hours only activate via the voice toggle.
|
||||
QuietHours *QuietHoursConfig `json:"quiet_hours,omitempty"`
|
||||
|
||||
// DisabledRules — nudge rules that are not wired at all, by name
|
||||
// ("service_down", "netdata_critical", "water", "meal", "break").
|
||||
//
|
||||
// Rules are code, not config (see loop.DefaultRules), and that stays true:
|
||||
// this only subtracts. It exists because a rule can be right in principle
|
||||
// and useless in practice — kuma's service_down cannot name the service it
|
||||
// is nudging about (Vikunja #444), so being told "a service on homesrv is
|
||||
// down" every fifteen minutes is noise with no action attached. Turning it
|
||||
// off beats learning to ignore her.
|
||||
//
|
||||
// A disabled rule is never gathered for, never evaluated, and never
|
||||
// delivered on any channel. Unknown names are ignored, so removing a rule
|
||||
// from the code does not break a config that still lists it.
|
||||
// Empty ⇒ every rule runs, which is the default.
|
||||
DisabledRules []string `json:"disabled_rules,omitempty"`
|
||||
|
||||
// Digest — notification batching / digest mode. nil ⇒ digest disabled
|
||||
// (every nudge is sent as it fires — legacy behaviour).
|
||||
Digest *DigestConfig `json:"digest,omitempty"`
|
||||
|
||||
+35
-1
@@ -1,6 +1,9 @@
|
||||
package loop
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Rule — a proactive rule. Rules are CODE, not a DSL config — until ~30 rules
|
||||
// and you feel the pain (per spec). A Rule has a name (ids it in nudges.outcome
|
||||
@@ -137,6 +140,37 @@ func NetdataCriticalRule() Rule {
|
||||
}
|
||||
}
|
||||
|
||||
// RulesExcept returns DefaultRules minus the named ones (config's
|
||||
// `disabled_rules`). Config subtracts from the canonical set; it never adds to
|
||||
// it and never reorders it, so the "rules are code" line above still holds.
|
||||
//
|
||||
// Names are matched exactly and an unknown one is ignored, on purpose: a
|
||||
// config that still lists a rule someone deleted must not stop the daemon from
|
||||
// booting. The logging of what was actually dropped belongs to the caller,
|
||||
// which knows whether anyone is listening.
|
||||
func RulesExcept(disabled []string) ([]Rule, []string) {
|
||||
all := DefaultRules()
|
||||
if len(disabled) == 0 {
|
||||
return all, nil
|
||||
}
|
||||
off := make(map[string]bool, len(disabled))
|
||||
for _, n := range disabled {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
off[n] = true
|
||||
}
|
||||
}
|
||||
out := make([]Rule, 0, len(all))
|
||||
var dropped []string
|
||||
for _, r := range all {
|
||||
if off[r.Name] {
|
||||
dropped = append(dropped, r.Name)
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, dropped
|
||||
}
|
||||
|
||||
// DefaultRules — the canonical set the daemon wires. Add more as code, not config.
|
||||
// Order here is NOT load-bearing — the loop picks max severity, ties broken by
|
||||
// (severity desc, name asc) for deterministic output.
|
||||
|
||||
@@ -392,3 +392,45 @@ func TestPredicatesArePure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ruleNames(rs []Rule) []string {
|
||||
out := make([]string, len(rs))
|
||||
for i, r := range rs {
|
||||
out[i] = r.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRulesExceptDropsOnlyTheNamed(t *testing.T) {
|
||||
rules, dropped := RulesExcept([]string{"service_down"})
|
||||
if len(rules) != len(DefaultRules())-1 {
|
||||
t.Fatalf("got %v", ruleNames(rules))
|
||||
}
|
||||
for _, r := range rules {
|
||||
if r.Name == "service_down" {
|
||||
t.Error("a disabled rule was still wired")
|
||||
}
|
||||
}
|
||||
if len(dropped) != 1 || dropped[0] != "service_down" {
|
||||
t.Errorf("dropped = %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
// A config that names a rule nobody wrote must not stop the daemon booting,
|
||||
// and must not quietly drop a real rule alongside it.
|
||||
func TestRulesExceptIgnoresUnknownNames(t *testing.T) {
|
||||
rules, dropped := RulesExcept([]string{"no_such_rule", " ", ""})
|
||||
if len(rules) != len(DefaultRules()) {
|
||||
t.Errorf("an unknown name removed something: %v", ruleNames(rules))
|
||||
}
|
||||
if len(dropped) != 0 {
|
||||
t.Errorf("dropped = %v, want nothing", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesExceptEmptyKeepsEverything(t *testing.T) {
|
||||
rules, dropped := RulesExcept(nil)
|
||||
if len(rules) != len(DefaultRules()) || dropped != nil {
|
||||
t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user