diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index ddebb49..52cc464 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -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 +} diff --git a/deploy/mavend.json b/deploy/mavend.json index 372ff78..c9e6c84 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -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", diff --git a/internal/config/config.go b/internal/config/config.go index d04437d..745a4c8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` diff --git a/internal/loop/rules.go b/internal/loop/rules.go index f7a6344..f144e2d 100644 --- a/internal/loop/rules.go +++ b/internal/loop/rules.go @@ -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. diff --git a/internal/loop/rules_test.go b/internal/loop/rules_test.go index 0da19e2..af765a6 100644 --- a/internal/loop/rules_test.go +++ b/internal/loop/rules_test.go @@ -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) + } +}