Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee3e6a9eaf | |||
| 8acb8a97c6 | |||
| 9d8fcf42f3 | |||
| 9e2af3286b |
@@ -1,171 +0,0 @@
|
||||
package dialogue
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Slot names one field of Slots. Named type, not a free string, so a missing
|
||||
// slot cannot be misspelled — the question phrasing switches on these.
|
||||
type Slot string
|
||||
|
||||
const (
|
||||
SlotTime Slot = "time" // Slots.Time / HasTime
|
||||
SlotKey Slot = "key" // Slots.Key / HasKey
|
||||
SlotValue Slot = "value" // Slots.Value (paired with Key)
|
||||
SlotFn Slot = "fn" // Slots.Fn / HasFn
|
||||
SlotText Slot = "text" // Slots.Text
|
||||
)
|
||||
|
||||
// MaxAttempts is 1 because Maven is not a nag (DESIGN.md § Non-goals). She asks
|
||||
// one clarifying question. If the answer still leaves the slot empty she drops
|
||||
// the request instead of asking again.
|
||||
const MaxAttempts = 1
|
||||
|
||||
// PendingQuestion is what Maven holds while she waits for an answer to an open
|
||||
// question. Unlike the yes/no confirms in cmd/mavend/voice.go, the answer here
|
||||
// is free text that fills a missing slot rather than a verdict.
|
||||
type PendingQuestion struct {
|
||||
Intent Intent // what the router already guessed
|
||||
Slots Slots // what it already filled
|
||||
Missing []Slot // what is still empty, in the order to ask about
|
||||
Utterance string // the user's original raw words
|
||||
Asked time.Time
|
||||
TTL time.Duration
|
||||
Attempts int // questions already asked; capped by MaxAttempts
|
||||
}
|
||||
|
||||
func (q *PendingQuestion) IsExpired(now time.Time) bool {
|
||||
return now.After(q.Asked.Add(q.TTL))
|
||||
}
|
||||
|
||||
// CanAsk reports whether Maven may ask another question about this request.
|
||||
func (q *PendingQuestion) CanAsk() bool {
|
||||
return q.Attempts < MaxAttempts
|
||||
}
|
||||
|
||||
// TODO: the daemon will phrase the question text from Missing (one short ru
|
||||
// question per Slot, feminine self-reference) and speak it here.
|
||||
|
||||
// ClarifyStore holds the parked questions. Same shape and locking as
|
||||
// SessionStore: keyed by dialogue id, expired entries dropped on read.
|
||||
type ClarifyStore struct {
|
||||
mu sync.RWMutex
|
||||
questions map[string]*PendingQuestion
|
||||
defaultTTL time.Duration
|
||||
}
|
||||
|
||||
func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore {
|
||||
if defaultTTL <= 0 {
|
||||
// Short, like confirmTTL in voice.go: a clarifying question is a
|
||||
// same-breath gesture, a stale one should not eat a later utterance.
|
||||
defaultTTL = 90 * time.Second
|
||||
}
|
||||
return &ClarifyStore{
|
||||
questions: make(map[string]*PendingQuestion),
|
||||
defaultTTL: defaultTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: the daemon will Put a question here when Decision.Clarify fires, in
|
||||
// place of the flat "не разобрала" reply (cmd/mavend/voice.go).
|
||||
func (s *ClarifyStore) Put(id string, q *PendingQuestion) {
|
||||
if q.TTL <= 0 {
|
||||
q.TTL = s.defaultTTL
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.questions[id] = q
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// TODO: the daemon will Get on the next turn, parse that turn into Slots, call
|
||||
// Answer, and Delete — the open-question twin of resolveConfirm.
|
||||
func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion {
|
||||
s.mu.RLock()
|
||||
q, ok := s.questions[id]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if q.IsExpired(now) {
|
||||
s.Delete(id)
|
||||
return nil
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (s *ClarifyStore) Delete(id string) {
|
||||
s.mu.Lock()
|
||||
delete(s.questions, id)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Answer merges the slots parsed from the user's answer into the parked ones.
|
||||
// Only the slots listed in Missing are filled, and an already filled slot is
|
||||
// never overwritten — the answer completes the original request, it does not
|
||||
// restate it. Parsing the answer text into `answer` is the caller's job; this
|
||||
// package must stay free of internal/router.
|
||||
func (q *PendingQuestion) Answer(text string, answer Slots) Slots {
|
||||
out := q.Slots
|
||||
for _, slot := range q.Missing {
|
||||
switch slot {
|
||||
case SlotTime:
|
||||
if !out.HasTime && answer.HasTime {
|
||||
out.Time = answer.Time
|
||||
out.HasTime = true
|
||||
}
|
||||
case SlotKey:
|
||||
if !out.HasKey && answer.HasKey {
|
||||
out.Key = answer.Key
|
||||
out.HasKey = true
|
||||
}
|
||||
case SlotValue:
|
||||
if out.Value == "" && answer.Value != "" {
|
||||
out.Value = answer.Value
|
||||
}
|
||||
case SlotFn:
|
||||
if !out.HasFn && answer.HasFn {
|
||||
out.Fn = answer.Fn
|
||||
out.HasFn = true
|
||||
if len(out.Args) == 0 {
|
||||
out.Args = append([]string(nil), answer.Args...)
|
||||
}
|
||||
}
|
||||
case SlotText:
|
||||
if out.Text == "" {
|
||||
if answer.Text != "" {
|
||||
out.Text = answer.Text
|
||||
} else {
|
||||
// No parse for a text slot — the raw answer IS the text.
|
||||
out.Text = text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StillMissing lists the slots that are empty in s, out of the ones asked for.
|
||||
// The caller uses it to decide between acting and dropping the request.
|
||||
func StillMissing(want []Slot, s Slots) []Slot {
|
||||
var out []Slot
|
||||
for _, slot := range want {
|
||||
empty := false
|
||||
switch slot {
|
||||
case SlotTime:
|
||||
empty = !s.HasTime
|
||||
case SlotKey:
|
||||
empty = !s.HasKey
|
||||
case SlotValue:
|
||||
empty = s.Value == ""
|
||||
case SlotFn:
|
||||
empty = !s.HasFn
|
||||
case SlotText:
|
||||
empty = s.Text == ""
|
||||
}
|
||||
if empty {
|
||||
out = append(out, slot)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
package dialogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var base = time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestPendingQuestionIsExpired(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ttl time.Duration
|
||||
now time.Time
|
||||
want bool
|
||||
}{
|
||||
{"fresh", time.Minute, base.Add(10 * time.Second), false},
|
||||
{"exactly at ttl", time.Minute, base.Add(time.Minute), false},
|
||||
{"past ttl", time.Minute, base.Add(2 * time.Minute), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
q := &PendingQuestion{Asked: base, TTL: tc.ttl}
|
||||
if got := q.IsExpired(tc.now); got != tc.want {
|
||||
t.Fatalf("IsExpired = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClarifyStoreGetPutDelete(t *testing.T) {
|
||||
s := NewClarifyStore(time.Minute)
|
||||
|
||||
if got := s.Get("voice", base); got != nil {
|
||||
t.Fatalf("empty store returned %+v", got)
|
||||
}
|
||||
|
||||
q := &PendingQuestion{Intent: IntentReminder, Missing: []Slot{SlotTime}, Asked: base}
|
||||
s.Put("voice", q)
|
||||
if q.TTL != time.Minute {
|
||||
t.Fatalf("Put did not apply the default TTL, got %v", q.TTL)
|
||||
}
|
||||
if got := s.Get("voice", base.Add(time.Second)); got != q {
|
||||
t.Fatalf("Get returned %+v, want the parked question", got)
|
||||
}
|
||||
|
||||
// Expired questions are dropped on read, not returned.
|
||||
if got := s.Get("voice", base.Add(2*time.Minute)); got != nil {
|
||||
t.Fatalf("expired Get returned %+v", got)
|
||||
}
|
||||
if got := s.Get("voice", base); got != nil {
|
||||
t.Fatalf("expired question was not deleted: %+v", got)
|
||||
}
|
||||
|
||||
s.Put("voice", &PendingQuestion{Asked: base, TTL: time.Hour})
|
||||
s.Delete("voice")
|
||||
if got := s.Get("voice", base); got != nil {
|
||||
t.Fatalf("Delete left %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClarifyStoreDefaultTTL(t *testing.T) {
|
||||
s := NewClarifyStore(0)
|
||||
q := &PendingQuestion{Asked: base}
|
||||
s.Put("voice", q)
|
||||
if q.TTL != 90*time.Second {
|
||||
t.Fatalf("TTL = %v, want 90s", q.TTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerFillsOnlyMissingSlots(t *testing.T) {
|
||||
answerTime := base.Add(3 * time.Hour)
|
||||
other := base.Add(9 * time.Hour)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
parked Slots
|
||||
missing []Slot
|
||||
text string
|
||||
answer Slots
|
||||
want Slots
|
||||
}{
|
||||
{
|
||||
name: "fills the missing time",
|
||||
parked: Slots{Text: "напомни позвонить"},
|
||||
missing: []Slot{SlotTime},
|
||||
text: "в три",
|
||||
answer: Slots{Time: answerTime, HasTime: true},
|
||||
want: Slots{Text: "напомни позвонить", Time: answerTime, HasTime: true},
|
||||
},
|
||||
{
|
||||
name: "does not overwrite a filled time",
|
||||
parked: Slots{Time: other, HasTime: true},
|
||||
missing: []Slot{SlotTime},
|
||||
text: "в три",
|
||||
answer: Slots{Time: answerTime, HasTime: true},
|
||||
want: Slots{Time: other, HasTime: true},
|
||||
},
|
||||
{
|
||||
name: "ignores slots that were not missing",
|
||||
parked: Slots{Key: "water", HasKey: true},
|
||||
missing: []Slot{SlotValue},
|
||||
text: "два литра",
|
||||
answer: Slots{Key: "sleep", HasKey: true, Value: "2l"},
|
||||
want: Slots{Key: "water", HasKey: true, Value: "2l"},
|
||||
},
|
||||
{
|
||||
name: "fills key when empty",
|
||||
parked: Slots{},
|
||||
missing: []Slot{SlotKey, SlotValue},
|
||||
text: "воды",
|
||||
answer: Slots{Key: "water", HasKey: true, Value: `"drank"`},
|
||||
want: Slots{Key: "water", HasKey: true, Value: `"drank"`},
|
||||
},
|
||||
{
|
||||
name: "fills fn and its args",
|
||||
parked: Slots{},
|
||||
missing: []Slot{SlotFn},
|
||||
text: "перезапусти nginx",
|
||||
answer: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||
want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||
},
|
||||
{
|
||||
name: "keeps existing args when fn was already known",
|
||||
parked: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||
missing: []Slot{SlotFn},
|
||||
text: "останови postgres",
|
||||
answer: Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true},
|
||||
want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||
},
|
||||
{
|
||||
name: "raw answer becomes the text when nothing was parsed",
|
||||
parked: Slots{},
|
||||
missing: []Slot{SlotText},
|
||||
text: "купить хлеб",
|
||||
answer: Slots{},
|
||||
want: Slots{Text: "купить хлеб"},
|
||||
},
|
||||
{
|
||||
name: "parsed text wins over the raw answer",
|
||||
parked: Slots{},
|
||||
missing: []Slot{SlotText},
|
||||
text: "запиши купить хлеб",
|
||||
answer: Slots{Text: "купить хлеб"},
|
||||
want: Slots{Text: "купить хлеб"},
|
||||
},
|
||||
{
|
||||
name: "empty answer leaves the slot missing",
|
||||
parked: Slots{Text: "напомни"},
|
||||
missing: []Slot{SlotTime},
|
||||
text: "не знаю",
|
||||
answer: Slots{},
|
||||
want: Slots{Text: "напомни"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
q := &PendingQuestion{Slots: tc.parked, Missing: tc.missing, Asked: base}
|
||||
got := q.Answer(tc.text, tc.answer)
|
||||
if got.Time != tc.want.Time || got.HasTime != tc.want.HasTime ||
|
||||
got.Key != tc.want.Key || got.HasKey != tc.want.HasKey ||
|
||||
got.Value != tc.want.Value || got.Text != tc.want.Text ||
|
||||
got.Fn != tc.want.Fn || got.HasFn != tc.want.HasFn {
|
||||
t.Fatalf("Answer = %+v, want %+v", got, tc.want)
|
||||
}
|
||||
if len(got.Args) != len(tc.want.Args) {
|
||||
t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args)
|
||||
}
|
||||
for i := range got.Args {
|
||||
if got.Args[i] != tc.want.Args[i] {
|
||||
t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanAskCapsAtOneQuestion(t *testing.T) {
|
||||
if MaxAttempts != 1 {
|
||||
t.Fatalf("MaxAttempts = %d, want 1 (Maven asks once, she is not a nag)", MaxAttempts)
|
||||
}
|
||||
q := &PendingQuestion{Asked: base}
|
||||
if !q.CanAsk() {
|
||||
t.Fatal("a fresh question should be askable")
|
||||
}
|
||||
q.Attempts = MaxAttempts
|
||||
if q.CanAsk() {
|
||||
t.Fatal("the question should not be asked twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStillMissing(t *testing.T) {
|
||||
want := []Slot{SlotTime, SlotKey, SlotValue, SlotFn, SlotText}
|
||||
cases := []struct {
|
||||
name string
|
||||
slots Slots
|
||||
want []Slot
|
||||
}{
|
||||
{"all empty", Slots{}, want},
|
||||
{
|
||||
name: "all filled",
|
||||
slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Value: "1l", Fn: "restart", HasFn: true, Text: "t"},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "only value left",
|
||||
slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Fn: "restart", HasFn: true, Text: "t"},
|
||||
want: []Slot{SlotValue},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := StillMissing(want, tc.slots)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("StillMissing = %v, want %v", got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("StillMissing = %v, want %v", got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ type Slots struct {
|
||||
Time time.Time
|
||||
HasTime bool
|
||||
Key string
|
||||
Value string // payload for a fact key, mirrors router.Slots.Value
|
||||
HasKey bool
|
||||
Text string
|
||||
Fn string
|
||||
@@ -105,9 +104,6 @@ func InheritSlots(prev, cur Slots) Slots {
|
||||
out.Key = prev.Key
|
||||
out.HasKey = true
|
||||
}
|
||||
if out.Value == "" && prev.Value != "" {
|
||||
out.Value = prev.Value
|
||||
}
|
||||
if out.Text == "" && prev.Text != "" {
|
||||
out.Text = prev.Text
|
||||
}
|
||||
|
||||
@@ -113,14 +113,4 @@ func TestInheritSlots(t *testing.T) {
|
||||
if inherited6.Text != "какая погода в москве" {
|
||||
t.Error("should inherit text when current is empty")
|
||||
}
|
||||
|
||||
prevValue := Slots{Key: "water", HasKey: true, Value: `"drank"`}
|
||||
inherited7 := InheritSlots(prevValue, Slots{})
|
||||
if inherited7.Value != `"drank"` {
|
||||
t.Error("should inherit value when current is empty")
|
||||
}
|
||||
kept := InheritSlots(prevValue, Slots{Value: "2l"})
|
||||
if kept.Value != "2l" {
|
||||
t.Error("should keep current value")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Tests for the universal restraint gate.
|
||||
//
|
||||
// DESIGN.md § Trigger model: "the gate is universal, applied by the loop, never
|
||||
// per-rule — quiet-hours, presence, cooldown, snooze, calendar-busy all live in
|
||||
// one fires()." These tests pin the CONSERVATIVE side of that: the cases where
|
||||
// Maven must stay quiet. They exist so nobody loosens the gate by accident.
|
||||
//
|
||||
// Where the code does not yet do what DESIGN.md promises, the test is written to
|
||||
// show the gap and then skipped, with the file and line to fix. Behaviour is not
|
||||
// changed to make a test pass.
|
||||
|
||||
// testRule — a rule at the given severity that always wants to fire, so the
|
||||
// only thing under test is the gate.
|
||||
func testRule(name string, sev Severity) Rule {
|
||||
return Rule{
|
||||
Name: name,
|
||||
Severity: sev,
|
||||
Cooldown: Cooldown{Base: 30 * time.Minute, Min: time.Minute, Max: time.Hour},
|
||||
Predicate: func(State) bool { return true },
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- quiet hours ------------------------------------
|
||||
|
||||
// Quiet hours silence care and leave ops alone. A failed backup at 2am matters;
|
||||
// a water nudge at 2am does not.
|
||||
func TestGateQuietHoursSuppressesCareOnly(t *testing.T) {
|
||||
cases := []struct {
|
||||
sev Severity
|
||||
want bool
|
||||
}{
|
||||
{Sev1, false},
|
||||
{Sev2, false},
|
||||
{Sev3, true},
|
||||
{Sev4, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := State{Now: refTime(), Presence: store.Present, QuietHours: true}
|
||||
if got := Gate(s, testRule("r", c.sev)); got != c.want {
|
||||
t.Errorf("quiet hours sev%d: want fire=%v, got %v", c.sev, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- presence ---------------------------------------
|
||||
|
||||
// DESIGN.md § Delivery: "sev <= 2 drops on away, sev >= 3 holds: a missed water
|
||||
// nudge is noise, a missed backup failure isn't."
|
||||
func TestGateAwayDropsCareHoldsOps(t *testing.T) {
|
||||
cases := []struct {
|
||||
sev Severity
|
||||
want bool
|
||||
}{
|
||||
{Sev1, false},
|
||||
{Sev2, false},
|
||||
{Sev3, true},
|
||||
{Sev4, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := State{Now: refTime(), Presence: store.Away}
|
||||
if got := Gate(s, testRule("r", c.sev)); got != c.want {
|
||||
t.Errorf("away sev%d: want fire=%v, got %v", c.sev, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Care nudges are allowed through when the user is actually there and nothing
|
||||
// else is suppressing. Without this the "quiet" tests above could pass on a
|
||||
// gate that simply never fires.
|
||||
func TestGateAllowsCareWhenPresentAndClear(t *testing.T) {
|
||||
s := State{Now: refTime(), Presence: store.Present}
|
||||
if !Gate(s, testRule("r", Sev1)) {
|
||||
t.Fatal("present and clear: care nudge should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- calendar busy ----------------------------------
|
||||
|
||||
// "Don't nag mid-meeting" is an env predicate in the gate, not the LLM's call.
|
||||
// Ops still gets through — a service being down mid-meeting is worth the
|
||||
// interruption.
|
||||
func TestGateCalendarBusySuppressesCareOnly(t *testing.T) {
|
||||
care := State{Now: refTime(), Presence: store.Present, CalendarBusy: true}
|
||||
if Gate(care, testRule("r", Sev2)) {
|
||||
t.Error("calendar busy: care nudge should be suppressed")
|
||||
}
|
||||
if !Gate(care, testRule("r", Sev4)) {
|
||||
t.Error("calendar busy: ops hard should still fire")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- cooldown ---------------------------------------
|
||||
|
||||
// Cooldown holds for every severity — it is the anti-nag knob, so ops cannot
|
||||
// buy its way past it either.
|
||||
func TestGateCooldownHoldsForAllSeverities(t *testing.T) {
|
||||
now := refTime()
|
||||
for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} {
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
CooldownUntil: map[string]time.Time{"r": now.Add(10 * time.Minute)},
|
||||
}
|
||||
if Gate(s, testRule("r", sev)) {
|
||||
t.Errorf("cooldown sev%d: should be suppressed", sev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cooldown is per-rule: one rule cooling down must not mute another.
|
||||
func TestGateCooldownIsPerRule(t *testing.T) {
|
||||
now := refTime()
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
CooldownUntil: map[string]time.Time{"water": now.Add(10 * time.Minute)},
|
||||
}
|
||||
if Gate(s, testRule("water", Sev1)) {
|
||||
t.Error("water is cooling down and should be suppressed")
|
||||
}
|
||||
if !Gate(s, testRule("meal", Sev1)) {
|
||||
t.Error("meal has no cooldown and should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// The moment the cooldown expires the rule is free again — the gate compares
|
||||
// with Before, so "until" itself is already clear.
|
||||
func TestGateCooldownExpires(t *testing.T) {
|
||||
now := refTime()
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
CooldownUntil: map[string]time.Time{"r": now},
|
||||
}
|
||||
if !Gate(s, testRule("r", Sev1)) {
|
||||
t.Fatal("cooldown at exactly now should already be clear")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- snooze -----------------------------------------
|
||||
|
||||
// Snooze is the user saying "not about this". It beats everything, including
|
||||
// ops hard.
|
||||
func TestGateSnoozeHoldsForAllSeverities(t *testing.T) {
|
||||
now := refTime()
|
||||
for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} {
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
SnoozeUntil: map[string]time.Time{"r": now.Add(time.Hour)},
|
||||
}
|
||||
if Gate(s, testRule("r", sev)) {
|
||||
t.Errorf("snooze sev%d: should be suppressed", sev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- no-data backstop -------------------------------
|
||||
|
||||
// The gate enforces no-data inertness a second time, for any rule that declared
|
||||
// the keys it needs. A predicate that forgets the check still cannot fire.
|
||||
func TestGateNoDataBackstopBeatsAnEagerPredicate(t *testing.T) {
|
||||
now := refTime()
|
||||
eager := Rule{
|
||||
Name: "eager",
|
||||
Severity: Sev4, // even ops hard does not get past missing data
|
||||
Predicate: func(State) bool { return true },
|
||||
InertWhenNoData: []string{"water", "meal"},
|
||||
}
|
||||
// one of the two keys present is not enough.
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, time.Hour)},
|
||||
}
|
||||
if Gate(s, eager) {
|
||||
t.Fatal("a rule missing one of its keys must stay inert")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- one nudge per tick -----------------------------
|
||||
|
||||
// All five default rules want to fire at once. The tick must still emit exactly
|
||||
// one candidate, the loudest — never a dogpile.
|
||||
func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
|
||||
now := refTime()
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
||||
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
||||
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
||||
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
||||
},
|
||||
}
|
||||
// sanity: every rule really does want to fire, so the pick is a real choice.
|
||||
for _, r := range DefaultRules() {
|
||||
if !r.Predicate(s) {
|
||||
t.Fatalf("setup: rule %q does not want to fire", r.Name)
|
||||
}
|
||||
}
|
||||
got := Tick(s, DefaultRules())
|
||||
if got == nil {
|
||||
t.Fatal("all rules firing: want one candidate, got nil")
|
||||
}
|
||||
if got.Rule.Name != "service_down" || got.Severity != Sev4 {
|
||||
t.Fatalf("want the loudest (service_down/sev4), got %s/sev%d", got.Rule.Name, got.Severity)
|
||||
}
|
||||
}
|
||||
|
||||
// Tick returns a single Candidate by type, so "one per tick" cannot be violated
|
||||
// by count — what can drift is WHICH one. Equal severities tie-break by name so
|
||||
// the choice is deterministic across ticks.
|
||||
func TestTickTieBreaksByNameForDeterminism(t *testing.T) {
|
||||
s := State{Now: refTime(), Presence: store.Present}
|
||||
rules := []Rule{testRule("zebra", Sev2), testRule("apple", Sev2), testRule("mango", Sev2)}
|
||||
for i := 0; i < 5; i++ {
|
||||
got := Tick(s, rules)
|
||||
if got == nil || got.Rule.Name != "apple" {
|
||||
t.Fatalf("tie-break: want apple every time, got %+v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The loudest candidate wins even when the quiet one is listed first.
|
||||
func TestTickOrderOfRulesDoesNotMatter(t *testing.T) {
|
||||
s := State{Now: refTime(), Presence: store.Present}
|
||||
first := Tick(s, []Rule{testRule("care", Sev1), testRule("ops", Sev4)})
|
||||
second := Tick(s, []Rule{testRule("ops", Sev4), testRule("care", Sev1)})
|
||||
if first == nil || second == nil {
|
||||
t.Fatal("want a candidate from both orderings")
|
||||
}
|
||||
if first.Rule.Name != "ops" || second.Rule.Name != "ops" {
|
||||
t.Fatalf("order changed the pick: %s then %s", first.Rule.Name, second.Rule.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- reminders bypass the gate ----------------------
|
||||
|
||||
// DESIGN.md § User reminders: "bypasses the restraint gate — 'wake me 7' fires
|
||||
// in quiet hours; that's the point." Every suppressor set at once, and the
|
||||
// reminder still comes through.
|
||||
func TestRemindersBypassEverySuppressor(t *testing.T) {
|
||||
now := refTime()
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Away,
|
||||
QuietHours: true,
|
||||
CalendarBusy: true,
|
||||
CooldownUntil: map[string]time.Time{"reminder": now.Add(time.Hour)},
|
||||
}
|
||||
due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}}
|
||||
got := RemindDecisions(s, due)
|
||||
if len(got) != 1 || got[0].Reminder.ID != 7 {
|
||||
t.Fatalf("reminder must bypass the gate, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// GAP — DESIGN.md § User reminders ends "Snooze still applies." RemindDecisions
|
||||
// passes every due reminder straight through with no snooze check, so a snoozed
|
||||
// reminder fires anyway. The test below is what the contract asks for.
|
||||
func TestRemindersStillHonourSnooze(t *testing.T) {
|
||||
|
||||
now := refTime()
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
SnoozeUntil: map[string]time.Time{"reminder:7": now.Add(time.Hour)},
|
||||
}
|
||||
due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}}
|
||||
if got := RemindDecisions(s, due); len(got) != 0 {
|
||||
t.Fatalf("snoozed reminder should not be delivered, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// GAP — the gate reads State.SnoozeUntil, but the Gatherer hard-codes it to nil
|
||||
// (internal/loop/gather.go:153), so snooze is dead in the running daemon: the
|
||||
// unit tests above pass while nothing can ever populate the map. This asserts
|
||||
// the Gatherer actually produces a snooze map.
|
||||
func TestGathererPopulatesSnoozeUntil(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir()+"/m.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
g := NewGatherer(st, DefaultRules())
|
||||
snap, _, err := g.GatherState(ctx, refTime())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap.SnoozeUntil == nil {
|
||||
t.Fatal("Gatherer returned a nil SnoozeUntil map")
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,14 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
|
||||
return State{}, nil, err
|
||||
}
|
||||
|
||||
// live snoozes — "leave me alone until X", per rule. The `snoozed` outcome
|
||||
// on the nudges table is the whole record; the store turns it into an
|
||||
// expiry. Absent rules mean "not snoozed", which is what the gate reads.
|
||||
snoozeUntil, err := g.store.SnoozedUntil(ctx, now)
|
||||
if err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
|
||||
// env flags — QuietHours / CalendarBusy as config facts.
|
||||
// QuietHours: presence != reachability, sleep/quiet-hours handled separately
|
||||
// in the gate. We read a config `quiet_hours` fact for the boolean.
|
||||
@@ -150,7 +158,7 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
|
||||
PresenceScore: score,
|
||||
Facts: facts,
|
||||
LastNudge: lastNudge,
|
||||
SnoozeUntil: nil, // no snooze persistence yet — daemon wires in
|
||||
SnoozeUntil: snoozeUntil,
|
||||
CooldownUntil: cooldownUntil,
|
||||
QuietHours: quiet,
|
||||
CalendarBusy: calBusy,
|
||||
|
||||
+35
-6
@@ -1,6 +1,7 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -106,25 +107,53 @@ func Tick(s State, rules []Rule) *Candidate {
|
||||
|
||||
// ReminderDecision — a due reminder the daemon should deliver now.
|
||||
// NOT gated by the universal Gate (per spec: "wake me 7" fires in quiet hours;
|
||||
// that's the point). Snooze still applies — represented by a separate
|
||||
// snooze-until the gatherer consults; for the scaffold, fired-reminders move
|
||||
// straight to MarkReminder(fired).
|
||||
// that's the point). Snooze is the one part of restraint that still applies.
|
||||
type ReminderDecision struct {
|
||||
Reminder store.Reminder
|
||||
State State
|
||||
}
|
||||
|
||||
// RemindDecisions — returns all due reminders (without gating their delivery
|
||||
// by restraint). Pure: accepts an already-filtered (due) list. The Gatherer
|
||||
// produces that list from `fire_ts <= now AND pending`.
|
||||
// ReminderSnoozeKey — the SnoozeUntil key that holds back every due reminder.
|
||||
// Reminders have no rule name, so they share one key. A snooze aimed at a
|
||||
// single reminder uses ReminderSnoozeKeyFor instead.
|
||||
const ReminderSnoozeKey = "reminder"
|
||||
|
||||
// ReminderSnoozeKeyFor — the SnoozeUntil key for one reminder by id.
|
||||
func ReminderSnoozeKeyFor(id int64) string {
|
||||
return fmt.Sprintf("%s:%d", ReminderSnoozeKey, id)
|
||||
}
|
||||
|
||||
// RemindDecisions — returns the due reminders the daemon should deliver.
|
||||
// Pure: accepts an already-filtered (due) list. The Gatherer produces that
|
||||
// list from `fire_ts <= now AND pending`.
|
||||
//
|
||||
// Quiet hours, presence and cooldown are deliberately NOT consulted — a
|
||||
// reminder must wake you at 7 even in the middle of quiet hours. Only snooze
|
||||
// holds one back. A held reminder stays pending, so it comes back once the
|
||||
// snooze runs out.
|
||||
func RemindDecisions(s State, due []store.Reminder) []ReminderDecision {
|
||||
out := make([]ReminderDecision, 0, len(due))
|
||||
for _, r := range due {
|
||||
if reminderSnoozed(s, r) {
|
||||
continue
|
||||
}
|
||||
out = append(out, ReminderDecision{Reminder: r, State: s})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// reminderSnoozed — true when a snooze on this reminder, or on reminders as a
|
||||
// class, is still running.
|
||||
func reminderSnoozed(s State, r store.Reminder) bool {
|
||||
keys := []string{ReminderSnoozeKey, ReminderSnoozeKeyFor(r.ID)}
|
||||
for _, k := range keys {
|
||||
if until, ok := s.SnoozeUntil[k]; ok && s.Now.Before(until) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CooldownFor — helper for the Gatherer: given the active cooldown base
|
||||
// (the rule's static Base, OR the feedback tuner's persisted tuning) and the
|
||||
// last send ts, compute the wall-clock "cooldown-until" the gate will check.
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Direct tests for the five default rule predicates.
|
||||
//
|
||||
// A predicate is pure — (State) -> bool, no I/O — so these need no store and no
|
||||
// daemon. They test the predicate ALONE: the restraint gate is tested in
|
||||
// loop_test.go and gate_test.go, never here.
|
||||
//
|
||||
// Every rule gets the same three questions plus its own edges:
|
||||
// - does it fire when it should?
|
||||
// - does it stay quiet when it should?
|
||||
// - is it silent when the key it needs has no data at all?
|
||||
//
|
||||
// The last one is load-bearing. DESIGN.md: "since(key)==null → don't fire.
|
||||
// Silence on no-data is 'shuts up when uncertain'."
|
||||
|
||||
// stateWith builds a snapshot at refTime() holding just the given facts.
|
||||
// Presence and the env flags are left zero — the predicate must not read them.
|
||||
func stateWith(facts map[string]store.Fact) State {
|
||||
return State{Now: refTime(), Facts: facts}
|
||||
}
|
||||
|
||||
// ago is a fact for key written `d` before refTime().
|
||||
func ago(key, source, value string, d time.Duration) store.Fact {
|
||||
return factAt(key, source, value, refTime().Add(-d))
|
||||
}
|
||||
|
||||
// ---------------------------- since-based care rules -------------------------
|
||||
|
||||
// The three care rules share one shape: "fire when it has been at least N since
|
||||
// the last fact for key". One table drives all of them.
|
||||
func TestCareRulePredicates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rule Rule
|
||||
facts map[string]store.Fact
|
||||
want bool
|
||||
}{
|
||||
// water — threshold 3h.
|
||||
{
|
||||
name: "water fires at 4h",
|
||||
rule: WaterRule(),
|
||||
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "water fires exactly at the 3h threshold",
|
||||
rule: WaterRule(),
|
||||
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "water quiet just under 3h",
|
||||
rule: WaterRule(),
|
||||
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour-time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "water quiet on no data",
|
||||
rule: WaterRule(),
|
||||
facts: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "water quiet on a zero-timestamp fact",
|
||||
rule: WaterRule(),
|
||||
facts: map[string]store.Fact{"water": {Key: "water", Source: "tap:water", Value: `"250ml"`}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "water quiet when the only fact is for another key",
|
||||
rule: WaterRule(),
|
||||
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 9*time.Hour)},
|
||||
want: false,
|
||||
},
|
||||
|
||||
// meal — threshold 6h.
|
||||
{
|
||||
name: "meal fires at 7h",
|
||||
rule: MealRule(),
|
||||
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "meal fires exactly at the 6h threshold",
|
||||
rule: MealRule(),
|
||||
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "meal quiet just under 6h",
|
||||
rule: MealRule(),
|
||||
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour-time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "meal quiet on no data",
|
||||
rule: MealRule(),
|
||||
facts: nil,
|
||||
want: false,
|
||||
},
|
||||
|
||||
// break — needs BOTH anchors: at the desk now, and no break for 90min.
|
||||
{
|
||||
name: "break fires when at desk and no break for 2h",
|
||||
rule: BreakRule(),
|
||||
facts: map[string]store.Fact{
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "break fires exactly at both thresholds",
|
||||
rule: BreakRule(),
|
||||
facts: map[string]store.Fact{
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 2*time.Minute),
|
||||
"break": ago("break", "voice", `"walk"`, 90*time.Minute),
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "break quiet when the desk signal is stale (user left)",
|
||||
rule: BreakRule(),
|
||||
facts: map[string]store.Fact{
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 10*time.Minute),
|
||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "break quiet when the last break was recent",
|
||||
rule: BreakRule(),
|
||||
facts: map[string]store.Fact{
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 20*time.Minute),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "break quiet with only the desk anchor",
|
||||
rule: BreakRule(),
|
||||
facts: map[string]store.Fact{
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "break quiet with only the break anchor",
|
||||
rule: BreakRule(),
|
||||
facts: map[string]store.Fact{
|
||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "break quiet on no data",
|
||||
rule: BreakRule(),
|
||||
facts: nil,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := c.rule.Predicate(stateWith(c.facts)); got != c.want {
|
||||
t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- ops rules --------------------------------------
|
||||
|
||||
// The two ops rules match on a value AND on which poller wrote it. DESIGN.md:
|
||||
// "a compromised poller must not be able to forge a trigger." Half of this
|
||||
// table is forgery attempts; all of them must be refused.
|
||||
func TestOpsRulePredicates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rule Rule
|
||||
facts map[string]store.Fact
|
||||
want bool
|
||||
}{
|
||||
// service_down — only poll:uptimekuma may say a service is down.
|
||||
{
|
||||
name: "service_down fires on a kuma down fact",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "service_down quiet when kuma says up",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"up"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down quiet on no data",
|
||||
rule: ServiceDownRule(),
|
||||
facts: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down quiet on a zero-timestamp fact",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": {Key: "service_down", Source: "poll:uptimekuma", Value: `"down"`}},
|
||||
want: false,
|
||||
},
|
||||
// forgery attempts — right value, wrong writer.
|
||||
{
|
||||
name: "service_down refuses a forgery from the netdata poller",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:netdata", `"down"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses a forgery from ambient audio",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "ambient:other", `"down"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses a forgery from the user's own voice",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "voice", `"down"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses a source that only looks like kuma",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma-staging", `"down"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses an unquoted down value",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `down`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
|
||||
// netdata_critical — only poll:netdata may raise a critical alarm.
|
||||
{
|
||||
name: "netdata_critical fires on a netdata critical alarm",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical quiet on a warning alarm",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"warning"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical quiet on a cleared alarm",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"clear"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical quiet on no data",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical quiet on a zero-timestamp fact",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_alarm": {Key: "netdata_alarm", Source: "poll:netdata", Value: `"critical"`}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical refuses a forgery from the kuma poller",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:uptimekuma", `"critical"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical refuses a forgery from ambient audio",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "ambient:other", `"critical"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical reads netdata_alarm, not netdata_critical",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_critical": ago("netdata_critical", "poll:netdata", `"critical"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := c.rule.Predicate(stateWith(c.facts)); got != c.want {
|
||||
t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- rule metadata ----------------------------------
|
||||
|
||||
// Every default rule must declare the keys it needs. The gate uses that list as
|
||||
// a second no-data backstop, so a rule that forgets it loses the safety net
|
||||
// even if its predicate happens to check.
|
||||
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
|
||||
for _, r := range DefaultRules() {
|
||||
if len(r.InertWhenNoData) == 0 {
|
||||
t.Errorf("rule %q declares no InertWhenNoData keys", r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A no-data snapshot must make EVERY default rule quiet, predicate alone, with
|
||||
// the gate out of the picture. This is the whole-set version of the per-rule
|
||||
// no-data cases above.
|
||||
func TestNoDefaultRuleFiresOnEmptyState(t *testing.T) {
|
||||
empty := stateWith(nil)
|
||||
for _, r := range DefaultRules() {
|
||||
if r.Predicate(empty) {
|
||||
t.Errorf("rule %q fires on an empty snapshot", r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Severities are the delivery contract (DESIGN.md § Delivery / channel
|
||||
// routing): care is sev1-2 and drops when away, ops is sev3-4 and holds. Pin
|
||||
// them so a change to a rule's insistence has to be deliberate.
|
||||
func TestDefaultRuleSeverities(t *testing.T) {
|
||||
want := map[string]Severity{
|
||||
"water": Sev1,
|
||||
"meal": Sev1,
|
||||
"break": Sev2,
|
||||
"service_down": Sev4,
|
||||
"netdata_critical": Sev3,
|
||||
}
|
||||
got := map[string]Severity{}
|
||||
for _, r := range DefaultRules() {
|
||||
got[r.Name] = r.Severity
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("rule count changed: want %d, got %d", len(want), len(got))
|
||||
}
|
||||
for name, sev := range want {
|
||||
if got[name] != sev {
|
||||
t.Errorf("rule %q severity: want %d, got %d", name, sev, got[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cooldown bounds keep the feedback tuner honest — DESIGN.md wants
|
||||
// `cooldown in [min,max]` "so a weird week can't mutate Maven silent or
|
||||
// stalker". A base outside its own envelope would make that meaningless.
|
||||
func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
|
||||
for _, r := range DefaultRules() {
|
||||
c := r.Cooldown
|
||||
if c.Min <= 0 || c.Base <= 0 || c.Max <= 0 {
|
||||
t.Errorf("rule %q has a non-positive cooldown: %+v", r.Name, c)
|
||||
continue
|
||||
}
|
||||
if c.Base < c.Min || c.Base > c.Max {
|
||||
t.Errorf("rule %q base %v outside envelope [%v, %v]", r.Name, c.Base, c.Min, c.Max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A predicate must read only the snapshot it is handed. Same snapshot twice
|
||||
// (and a snapshot shared between two rules) must give the same answer — no
|
||||
// hidden state, no clock reads.
|
||||
func TestPredicatesArePure(t *testing.T) {
|
||||
s := stateWith(map[string]store.Fact{
|
||||
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
|
||||
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
||||
})
|
||||
for _, r := range DefaultRules() {
|
||||
first := r.Predicate(s)
|
||||
for i := 0; i < 3; i++ {
|
||||
if again := r.Predicate(s); again != first {
|
||||
t.Fatalf("rule %q predicate is not pure: %v then %v", r.Name, first, again)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
CHECK (resolution_state IN ('none','pending','resolved','ambiguous','not_found'));
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_entity_id ON facts (entity_id) WHERE entity_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_nudges_snoozed ON nudges (outcome_ts) WHERE outcome = 'snoozed';`, // #8 — SnoozedUntil runs every tick; keep it off a full scan (Vikunja #364)
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
@@ -28,6 +28,20 @@ const (
|
||||
NudgeIgnored = "ignored"
|
||||
)
|
||||
|
||||
// SnoozeDuration — how long one `snoozed` outcome keeps its rule quiet.
|
||||
//
|
||||
// The nudges table records THAT a snooze happened and when, never for how
|
||||
// long: nothing upstream can supply a length. ResolveNudge takes only
|
||||
// (id, outcome, ts), and so do the IPC method and the web/telegram callers
|
||||
// behind it. So a fixed default it is, rather than a new column no writer
|
||||
// could fill.
|
||||
//
|
||||
// Two hours: longer than every rule's base cooldown (15–60m) so a snooze
|
||||
// actually buys quiet instead of being swallowed by the cooldown, and short
|
||||
// enough that a snooze the operator forgets about clears the same day. A
|
||||
// snooze can never outlive this window, so Maven cannot go quiet forever.
|
||||
const SnoozeDuration = 2 * time.Hour
|
||||
|
||||
var (
|
||||
ErrNudgeNotFound = errors.New("store: nudge not found")
|
||||
ErrNudgeOutcome = errors.New("store: nudge already resolved")
|
||||
@@ -138,6 +152,37 @@ func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SnoozedUntil — per rule, when its most recent snooze runs out. This is the
|
||||
// read behind the gate's snooze check: the `snoozed` outcome already in the
|
||||
// nudges table IS the restraint memory, so there is no snooze table.
|
||||
//
|
||||
// Rules with no live snooze are absent from the map, which is what the gate
|
||||
// wants (a missing key means "not snoozed"). Expired snoozes are filtered out
|
||||
// in SQL, so an old snooze can never come back as a silent forever-mute.
|
||||
//
|
||||
// Called every tick (~60s). One indexed lookup over the snoozed rows only.
|
||||
func (s *Store) SnoozedUntil(ctx context.Context, now time.Time) (map[string]time.Time, error) {
|
||||
cutoff := now.Add(-SnoozeDuration).UnixMilli()
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT rule, MAX(outcome_ts) FROM nudges
|
||||
WHERE outcome = 'snoozed' AND outcome_ts > ?
|
||||
GROUP BY rule`, cutoff)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("snoozed until: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]time.Time)
|
||||
for rows.Next() {
|
||||
var rule string
|
||||
var tsMilli int64
|
||||
if err := rows.Scan(&rule, &tsMilli); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[rule] = time.UnixMilli(tsMilli).UTC().Add(SnoozeDuration)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RecentNudges — the newest n nudges across all rules, with outcomes, for the
|
||||
// monitoring dash. Newest first.
|
||||
func (s *Store) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// snoozeNudge records a nudge and immediately snoozes it at ts.
|
||||
func snoozeNudge(t *testing.T, s *Store, rule string, ts time.Time) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
id, err := s.RecordNudge(ctx, rule, "voice", "drink water", ts)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordNudge: %v", err)
|
||||
}
|
||||
if err := s.ResolveNudge(ctx, id, NudgeSnoozed, ts); err != nil {
|
||||
t.Fatalf("ResolveNudge: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnoozedUntilPerRule(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
snoozeNudge(t, s, "water", now.Add(-10*time.Minute))
|
||||
snoozeNudge(t, s, "break", now.Add(-30*time.Minute))
|
||||
|
||||
got, err := s.SnoozedUntil(context.Background(), now)
|
||||
if err != nil {
|
||||
t.Fatalf("SnoozedUntil: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 snoozed rules, got %v", got)
|
||||
}
|
||||
wantWater := now.Add(-10 * time.Minute).Add(SnoozeDuration)
|
||||
if !got["water"].Equal(wantWater) {
|
||||
t.Fatalf("water until = %v, want %v", got["water"], wantWater)
|
||||
}
|
||||
}
|
||||
|
||||
// The map must only ever hold the newest snooze for a rule, so a stale one
|
||||
// can't shorten (or lengthen) the live one.
|
||||
func TestSnoozedUntilUsesNewestSnooze(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
snoozeNudge(t, s, "water", now.Add(-90*time.Minute))
|
||||
snoozeNudge(t, s, "water", now.Add(-5*time.Minute))
|
||||
|
||||
got, err := s.SnoozedUntil(context.Background(), now)
|
||||
if err != nil {
|
||||
t.Fatalf("SnoozedUntil: %v", err)
|
||||
}
|
||||
want := now.Add(-5 * time.Minute).Add(SnoozeDuration)
|
||||
if !got["water"].Equal(want) {
|
||||
t.Fatalf("water until = %v, want %v", got["water"], want)
|
||||
}
|
||||
}
|
||||
|
||||
// A snooze must expire. If this ever regresses Maven goes quiet forever and
|
||||
// nobody can tell why.
|
||||
func TestSnoozedUntilExpires(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
snoozeNudge(t, s, "water", now.Add(-SnoozeDuration-time.Minute))
|
||||
|
||||
got, err := s.SnoozedUntil(context.Background(), now)
|
||||
if err != nil {
|
||||
t.Fatalf("SnoozedUntil: %v", err)
|
||||
}
|
||||
if _, ok := got["water"]; ok {
|
||||
t.Fatalf("expired snooze still active: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Other outcomes are not snoozes.
|
||||
func TestSnoozedUntilIgnoresOtherOutcomes(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
for _, outcome := range []string{NudgeActed, NudgeIgnored} {
|
||||
id, err := s.RecordNudge(ctx, "water", "voice", "drink water", now)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordNudge: %v", err)
|
||||
}
|
||||
if err := s.ResolveNudge(ctx, id, outcome, now); err != nil {
|
||||
t.Fatalf("ResolveNudge: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := s.RecordNudge(ctx, "break", "voice", "stand up", now); err != nil {
|
||||
t.Fatalf("RecordNudge: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.SnoozedUntil(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("SnoozedUntil: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("want no snoozes, got %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user