Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee3e6a9eaf | |||
| 8acb8a97c6 | |||
| 9d8fcf42f3 | |||
| 9e2af3286b |
@@ -639,11 +639,11 @@ func TestDispatchRecurringReminderReschedules(t *testing.T) {
|
||||
// ----------------------------- durable outbox --------------------------------
|
||||
|
||||
type outboxAttempt struct {
|
||||
kind, rule string
|
||||
reminderID int64
|
||||
channel, hash string
|
||||
status string
|
||||
begunAt, doneAt time.Time
|
||||
kind, rule string
|
||||
reminderID int64
|
||||
channel, hash string
|
||||
status string
|
||||
begunAt, doneAt time.Time
|
||||
}
|
||||
|
||||
// fakeOutbox — an in-memory Outbox that also lets a test simulate a crash
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// panicSink — a sink that dies mid-send. Models the ugly case: the process is
|
||||
// still alive, so startup reconciliation will not run, but the attempt row was
|
||||
// already begun.
|
||||
type panicSink struct{ calls int }
|
||||
|
||||
func (p *panicSink) Send(_ context.Context, _ Sendable) error {
|
||||
p.calls++
|
||||
panic("sink exploded mid-send")
|
||||
}
|
||||
|
||||
// ------------------------- voice fallthrough, per severity -------------------
|
||||
|
||||
// TestVoiceNoSessionFallthroughLeavesOutboxTrail — the fallthrough must be
|
||||
// visible in the ledger too: the voice attempt closes as failed and the away
|
||||
// attempt is a separate row, so an operator can see the reroute happened.
|
||||
func TestVoiceNoSessionFallthroughLeavesOutboxTrail(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sev loop.Severity
|
||||
wantAt []string // channel per outbox attempt, in order
|
||||
wantEnd []string // status per attempt, in order
|
||||
}{
|
||||
{"sev3 falls through to ntfy", loop.Sev3,
|
||||
[]string{"voice", "ntfy"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
||||
{"sev4 falls through to telegram", loop.Sev4,
|
||||
[]string{"voice", "telegram"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
||||
{"sev1 does not fall through", loop.Sev1,
|
||||
[]string{"voice"}, []string{store.DeliveryFailed}},
|
||||
{"sev2 does not fall through", loop.Sev2,
|
||||
[]string{"voice"}, []string{store.DeliveryFailed}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
voice := &fakeSink{err: ErrVoiceNoSession}
|
||||
ntfy, telegram := &fakeSink{}, &fakeSink{}
|
||||
ob := &fakeOutbox{}
|
||||
d := NewDispatcher(Config{
|
||||
Voice: voice, Ntfy: ntfy, Telegram: telegram,
|
||||
Ack: newFakeAck(), Outbox: ob,
|
||||
})
|
||||
|
||||
if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||
Candidate: candidate("some_rule", c.sev, store.Present),
|
||||
Body: "detail", Summary: "short",
|
||||
}, refNow()); err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if len(ob.attempts) != len(c.wantAt) {
|
||||
t.Fatalf("want %d outbox attempts, got %d (%+v)", len(c.wantAt), len(ob.attempts), ob.attempts)
|
||||
}
|
||||
for i, a := range ob.attempts {
|
||||
if a.channel != c.wantAt[i] || a.status != c.wantEnd[i] {
|
||||
t.Fatalf("attempt %d: want %s/%s, got %s/%s", i, c.wantAt[i], c.wantEnd[i], a.channel, a.status)
|
||||
}
|
||||
}
|
||||
// care severities must not reach an away channel — that would
|
||||
// defeat the drop rule.
|
||||
if c.sev <= loop.Sev2 && (len(ntfy.sends) != 0 || len(telegram.sends) != 0) {
|
||||
t.Fatalf("care nudge escaped to an away channel: ntfy=%d telegram=%d",
|
||||
len(ntfy.sends), len(telegram.sends))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------- crash between Begin and Complete ------------------
|
||||
|
||||
// openTestStore — a real store on a temp file. The reconciliation promise is a
|
||||
// SQL promise, so a fake would only test the fake.
|
||||
func openTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "maven.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
// attemptStatus reads one attempt row back. Returns ok=false when the row is
|
||||
// gone, which would itself be a broken promise (a dropped attempt).
|
||||
func attemptStatus(t *testing.T, st *store.Store, id int64) (status string, completed bool, ok bool) {
|
||||
t.Helper()
|
||||
tx, err := st.DB(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("read tx: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var completedTS *int64
|
||||
err = tx.QueryRowContext(context.Background(),
|
||||
`SELECT status, completed_ts FROM delivery_attempts WHERE id = ?`, id).Scan(&status, &completedTS)
|
||||
if err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
return status, completedTS != nil, true
|
||||
}
|
||||
|
||||
// TestCrashBetweenBeginAndCompleteBecomesUnknown — simulate the crash window:
|
||||
// Begin lands, the process dies before Complete. Startup reconciliation must
|
||||
// turn that row into "unknown" — neither resent nor dropped, because Maven
|
||||
// cannot know whether the message left the box.
|
||||
func TestCrashBetweenBeginAndCompleteBecomesUnknown(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
sink := &fakeSink{}
|
||||
|
||||
// the crash: intent recorded, no completion.
|
||||
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
if s, _, ok := attemptStatus(t, st, id); !ok || s != store.DeliveryPending {
|
||||
t.Fatalf("before reconcile: want pending, got %q ok=%v", s, ok)
|
||||
}
|
||||
|
||||
// restart.
|
||||
n, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow().Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("reconcile: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("want 1 row reconciled, got %d", n)
|
||||
}
|
||||
s, completed, ok := attemptStatus(t, st, id)
|
||||
if !ok {
|
||||
t.Fatal("reconciliation dropped the row; the promise is it is never dropped")
|
||||
}
|
||||
if s != store.DeliveryUnknown {
|
||||
t.Fatalf("want status unknown, got %q", s)
|
||||
}
|
||||
if !completed {
|
||||
t.Fatal("reconciled row should carry a completed_ts")
|
||||
}
|
||||
// not resent: reconciliation is bookkeeping only, it must never push.
|
||||
if len(sink.sends) != 0 {
|
||||
t.Fatalf("reconciliation must not resend, got %d sends", len(sink.sends))
|
||||
}
|
||||
|
||||
// idempotent: a second restart must not churn the row again.
|
||||
n2, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow().Add(2*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("reconcile again: %v", err)
|
||||
}
|
||||
if n2 != 0 {
|
||||
t.Fatalf("second reconcile should find nothing, got %d", n2)
|
||||
}
|
||||
if s2, _, _ := attemptStatus(t, st, id); s2 != store.DeliveryUnknown {
|
||||
t.Fatalf("unknown must stay unknown, got %q", s2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownIsNeverResolvedToSentOrFailed — the "never guess" half of the
|
||||
// promise: nothing may quietly turn an unknown into a definite outcome.
|
||||
func TestUnknownIsNeverResolvedToSentOrFailed(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
if _, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow()); err != nil {
|
||||
t.Fatalf("reconcile: %v", err)
|
||||
}
|
||||
// a late Complete from the old in-flight send must not win.
|
||||
if err := st.CompleteDeliveryAttempt(ctx, id, store.DeliverySent, refNow().Add(time.Minute)); err != nil {
|
||||
t.Fatalf("late complete: %v", err)
|
||||
}
|
||||
if s, _, _ := attemptStatus(t, st, id); s != store.DeliveryUnknown {
|
||||
t.Fatalf("late complete overwrote an unknown outcome: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------- the boring failure modes --------------------------
|
||||
|
||||
// TestSendTimeoutResolvesTheAttempt — a send that times out is a definite
|
||||
// failure from Maven's side, so the row must not be left pending.
|
||||
func TestSendTimeoutResolvesTheAttempt(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // the deadline already blew
|
||||
ob := &fakeOutbox{}
|
||||
d := NewDispatcher(Config{Ntfy: &fakeSink{err: context.DeadlineExceeded}, Outbox: ob})
|
||||
|
||||
if _, err := d.DispatchNudge(ctx, PhrasedNudge{
|
||||
Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
|
||||
Body: "detail", Summary: "short",
|
||||
}, refNow()); err == nil {
|
||||
t.Fatal("want a timeout error to propagate")
|
||||
}
|
||||
if len(ob.attempts) != 1 || ob.attempts[0].status != store.DeliveryFailed {
|
||||
t.Fatalf("timed-out send must close the attempt as failed, got %+v", ob.attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompleteFailureLeavesRowPendingForReconciliation — if Complete itself
|
||||
// fails, the row stays pending on purpose. That is the correct ambiguous state
|
||||
// and startup reconciliation is what resolves it.
|
||||
func TestCompleteFailureLeavesRowPendingForReconciliation(t *testing.T) {
|
||||
ob := &fakeOutbox{completeErr: errors.New("db busy")}
|
||||
d := NewDispatcher(Config{Ntfy: &fakeSink{}, Outbox: ob})
|
||||
|
||||
if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||
Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
|
||||
Body: "detail", Summary: "short",
|
||||
}, refNow()); err != nil {
|
||||
t.Fatalf("a failed outbox complete must not fail the dispatch: %v", err)
|
||||
}
|
||||
if len(ob.attempts) != 1 || ob.attempts[0].status != store.DeliveryPending {
|
||||
t.Fatalf("want the row left pending, got %+v", ob.attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPanicMidSendResolvesTheAttempt — a sink that panics leaves the attempt
|
||||
// pending forever while the process keeps running: the dispatcher has no
|
||||
// recover, and reconciliation only runs at startup. Written to the promise
|
||||
// ("never silently resent or dropped" implies every attempt gets resolved),
|
||||
// skipped because the code does not keep it.
|
||||
func TestPanicMidSendResolvesTheAttempt(t *testing.T) {
|
||||
t.Skip("real gap: dispatcher.go:168 has no recover around Send, so a panicking sink leaves a permanent pending row (reconciliation only runs at startup, cmd/mavend/main.go:330)")
|
||||
|
||||
ob := &fakeOutbox{}
|
||||
d := NewDispatcher(Config{Ntfy: &panicSink{}, Outbox: ob})
|
||||
|
||||
func() {
|
||||
defer func() { _ = recover() }()
|
||||
_, _ = d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||
Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
|
||||
Body: "detail", Summary: "short",
|
||||
}, refNow())
|
||||
}()
|
||||
if len(ob.attempts) != 1 || ob.attempts[0].status == store.DeliveryPending {
|
||||
t.Fatalf("a panic mid-send must still resolve the attempt, got %+v", ob.attempts)
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// This file walks every cell of the DESIGN.md § "Delivery / channel routing"
|
||||
// table, once as the pure table and once through the dispatcher, so a change
|
||||
// to either side has to break a named cell.
|
||||
//
|
||||
// present away
|
||||
// sev1-2 (care) voice drop
|
||||
// sev3 (soft) voice ntfy, once
|
||||
// sev4 (hard) voice + ntfy telegram, repeat til ack
|
||||
|
||||
type tableCell struct {
|
||||
name string
|
||||
sev loop.Severity
|
||||
presence store.Bucket
|
||||
want []Channel
|
||||
}
|
||||
|
||||
func allTableCells() []tableCell {
|
||||
return []tableCell{
|
||||
{"sev1 present", loop.Sev1, store.Present, []Channel{ChannelVoice}},
|
||||
{"sev2 present", loop.Sev2, store.Present, []Channel{ChannelVoice}},
|
||||
{"sev3 present", loop.Sev3, store.Present, []Channel{ChannelVoice}},
|
||||
{"sev4 present", loop.Sev4, store.Present, []Channel{ChannelVoice, ChannelNtfy}},
|
||||
{"sev1 away", loop.Sev1, store.Away, []Channel{ChannelDrop}},
|
||||
{"sev2 away", loop.Sev2, store.Away, []Channel{ChannelDrop}},
|
||||
{"sev3 away", loop.Sev3, store.Away, []Channel{ChannelNtfy}},
|
||||
{"sev4 away", loop.Sev4, store.Away, []Channel{ChannelTelegram}},
|
||||
}
|
||||
}
|
||||
|
||||
func sameChannels(got, want []Channel) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestChannelsForEveryTableCell(t *testing.T) {
|
||||
for _, c := range allTableCells() {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := ChannelsFor(c.sev, c.presence)
|
||||
if !sameChannels(got, c.want) {
|
||||
t.Fatalf("%s: want %v, got %v", c.name, c.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchNudgeEveryTableCell — the same eight cells end to end: exactly
|
||||
// the wanted channels get a send, and every other channel gets none.
|
||||
func TestDispatchNudgeEveryTableCell(t *testing.T) {
|
||||
for _, c := range allTableCells() {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
voice, ntfy, telegram := &fakeSink{}, &fakeSink{}, &fakeSink{}
|
||||
rec := &fakeNudgeRecorder{}
|
||||
d := NewDispatcher(Config{
|
||||
Voice: voice, Ntfy: ntfy, Telegram: telegram,
|
||||
Ack: newFakeAck(), Nudges: rec,
|
||||
})
|
||||
|
||||
out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||
Candidate: candidate("some_rule", c.sev, c.presence),
|
||||
Body: "full detail body",
|
||||
Summary: "short form",
|
||||
}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
|
||||
sent := map[Channel]int{
|
||||
ChannelVoice: len(voice.sends),
|
||||
ChannelNtfy: len(ntfy.sends),
|
||||
ChannelTelegram: len(telegram.sends),
|
||||
}
|
||||
for ch, n := range sent {
|
||||
want := 0
|
||||
for _, w := range c.want {
|
||||
if w == ch {
|
||||
want = 1
|
||||
}
|
||||
}
|
||||
if n != want {
|
||||
t.Fatalf("%s: channel %s got %d sends, want %d", c.name, ch, n, want)
|
||||
}
|
||||
}
|
||||
|
||||
// one dispatch and one nudge row per real (non-drop) channel.
|
||||
wantDispatches := 0
|
||||
for _, w := range c.want {
|
||||
if w != ChannelDrop {
|
||||
wantDispatches++
|
||||
}
|
||||
}
|
||||
if len(out) != wantDispatches {
|
||||
t.Fatalf("%s: want %d dispatches, got %d", c.name, wantDispatches, len(out))
|
||||
}
|
||||
if len(rec.rows) != wantDispatches {
|
||||
t.Fatalf("%s: want %d nudge rows, got %d", c.name, wantDispatches, len(rec.rows))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSev3AwayIsNtfyExactlyOnce — "ntfy, once": one send, and nothing on the
|
||||
// sendable asks for a repeat, so the daemon's repeat driver has no reason to
|
||||
// pick it up.
|
||||
func TestSev3AwayIsNtfyExactlyOnce(t *testing.T) {
|
||||
ntfy := &fakeSink{}
|
||||
ack := newFakeAck()
|
||||
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: &fakeSink{}, Ack: ack})
|
||||
|
||||
out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||
Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
|
||||
Body: "cert detail", Summary: "cert expiring",
|
||||
}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if len(ntfy.sends) != 1 {
|
||||
t.Fatalf("sev3 away: want exactly 1 ntfy send, got %d", len(ntfy.sends))
|
||||
}
|
||||
if out[0].Sendable.RepeatUntilAck {
|
||||
t.Fatalf("sev3 away must not repeat til ack")
|
||||
}
|
||||
if _, ok := ack.lastSent["cert_expiring"]; ok {
|
||||
t.Fatalf("sev3 away must not enter the ack/repeat tracker")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSev4AwayRepeatsUntilAcked — "telegram, repeat til ack": the initial send
|
||||
// arms the ack clock, the repeat driver re-sends while un-acked, and an ack
|
||||
// stops it.
|
||||
func TestSev4AwayRepeatsUntilAcked(t *testing.T) {
|
||||
telegram := &fakeSink{}
|
||||
ack := newFakeAck()
|
||||
d := NewDispatcher(Config{Telegram: telegram, Ack: ack})
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := d.DispatchNudge(ctx, PhrasedNudge{
|
||||
Candidate: candidate("disk_low", loop.Sev4, store.Away),
|
||||
Body: "disk detail", Summary: "disk low on homesrv",
|
||||
}, refNow()); err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
|
||||
// two intervals pass, still un-acked → two more sends.
|
||||
for i := 1; i <= 2; i++ {
|
||||
at := refNow().Add(time.Duration(i) * 10 * time.Minute)
|
||||
if _, err := d.RepeatUnacked(ctx, []string{"disk_low"}, at, 5*time.Minute, "disk detail", "disk low on homesrv"); err != nil {
|
||||
t.Fatalf("repeat %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if len(telegram.sends) != 3 {
|
||||
t.Fatalf("want 1 initial + 2 repeats = 3 telegram sends, got %d", len(telegram.sends))
|
||||
}
|
||||
|
||||
// acked → no further sends, however long we wait.
|
||||
_ = ack.MarkAcked(ctx, "disk_low")
|
||||
if _, err := d.RepeatUnacked(ctx, []string{"disk_low"}, refNow().Add(time.Hour), 5*time.Minute, "b", "s"); err != nil {
|
||||
t.Fatalf("repeat after ack: %v", err)
|
||||
}
|
||||
if len(telegram.sends) != 3 {
|
||||
t.Fatalf("ack must stop the repeat; got %d sends", len(telegram.sends))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAwayChannelsGetMinimalBody — what leaves the box is the short form, for
|
||||
// every away cell of the table. messageForChannel is the last-mile choice both
|
||||
// away sinks make too.
|
||||
func TestAwayChannelsGetMinimalBody(t *testing.T) {
|
||||
detail := "disk /mnt/hdd1 on homesrv at 97% — 12GB free, biggest offender /var/lib/docker"
|
||||
short := "disk low on homesrv"
|
||||
|
||||
for _, ch := range []Channel{ChannelNtfy, ChannelTelegram} {
|
||||
t.Run(string(ch), func(t *testing.T) {
|
||||
msg := messageForChannel(Sendable{Channel: ch, Body: detail, Summary: short})
|
||||
if msg != short {
|
||||
t.Fatalf("%s message: want %q, got %q", ch, short, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := messageForChannel(Sendable{Channel: ChannelVoice, Body: detail, Summary: short}); got != detail {
|
||||
t.Fatalf("voice is local and gets the full body, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSev4AwaySendableCarriesNoDetail — DESIGN.md § Delivery: away channels
|
||||
// leave the box, so a sev4-away message must not carry detail beyond the short
|
||||
// form. Today the dispatcher hands the away sink the FULL Body as well as the
|
||||
// Summary (dispatcher.go:153-162 copies pn.Body into every Sendable) and
|
||||
// trusts each sink to pick Summary. That works for the two sinks in-tree, but
|
||||
// the minimal body is not enforced at the dispatcher, so a new away sink that
|
||||
// reads Body exfils by default.
|
||||
func TestSev4AwaySendableCarriesNoDetail(t *testing.T) {
|
||||
t.Skip("not enforced: dispatcher.go:159 puts the full Body on away sendables; minimal body is only enforced per-sink (ntfysink.go:77, telegramsink.go:148)")
|
||||
|
||||
telegram := &fakeSink{}
|
||||
d := NewDispatcher(Config{Telegram: telegram, Ack: newFakeAck()})
|
||||
detail := "disk /mnt/hdd1 at 97%, biggest offender /var/lib/docker"
|
||||
|
||||
if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||
Candidate: candidate("disk_low", loop.Sev4, store.Away),
|
||||
Body: detail, Summary: "disk low on homesrv",
|
||||
}, refNow()); err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if strings.Contains(telegram.sends[0].Body, "/var/lib/docker") {
|
||||
t.Fatalf("away sendable carries detail: %q", telegram.sends[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAwayFallsBackToFullBodyWhenSummaryEmpty — the other half of the same
|
||||
// gap: with no Summary, the full body leaves the box. The code chooses that on
|
||||
// purpose ("a terse full message is better than no message",
|
||||
// dispatcher.go:345-357), which contradicts the spec's minimal-body rule.
|
||||
// Written to the spec, skipped because the code disagrees.
|
||||
func TestAwayFallsBackToFullBodyWhenSummaryEmpty(t *testing.T) {
|
||||
t.Skip("by design today: dispatcher.go:356 and ntfysink.go:79 fall back to the full Body when Summary is empty, so detail can leave the box")
|
||||
|
||||
msg := messageForChannel(Sendable{
|
||||
Channel: ChannelNtfy,
|
||||
Body: "internal detail that should never leave the box",
|
||||
})
|
||||
if msg != "" {
|
||||
t.Fatalf("empty summary must not fall back to body, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCareAwayDropIsRecorded — DESIGN.md's drop is a decision ("a missed water
|
||||
// nudge is noise, a missed backup failure isn't"), so it should be visible
|
||||
// rather than vanish. Today drop is a bare `continue`: no nudge row, no outbox
|
||||
// attempt, no log — nothing an operator can see afterwards.
|
||||
func TestCareAwayDropIsRecorded(t *testing.T) {
|
||||
t.Skip("not implemented: dispatcher.go:149-151 skips a Drop channel with no record; there is no 'dropped' outcome in store/delivery.go:16-21")
|
||||
|
||||
ob := &fakeOutbox{}
|
||||
d := NewDispatcher(Config{Voice: &fakeSink{}, Nudges: &fakeNudgeRecorder{}, Outbox: ob})
|
||||
|
||||
if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||
Candidate: candidate("water", loop.Sev1, store.Away),
|
||||
Body: "drink water", Summary: "water",
|
||||
}, refNow()); err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if len(ob.attempts) != 1 || ob.attempts[0].channel != string(ChannelDrop) {
|
||||
t.Fatalf("care-away drop should leave a visible record, got %+v", ob.attempts)
|
||||
}
|
||||
}
|
||||
@@ -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