Compare commits

..

2 Commits

Author SHA1 Message Date
kami 7d8b0af99d Test voice fallthrough and delivery durability
Fallthrough is checked per severity through the outbox trail, so sev3/sev4
reroute and sev1/sev2 still drop. Durability uses a real store on a temp file:
a crash between Begin and Complete becomes unknown, is not resent, is not
dropped, and a late Complete cannot overwrite it. One skipped test marks a real
gap: a panic mid-send leaves a permanent pending row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:27:33 +04:00
kami a2835bbdf6 Cover every cell of the delivery routing table
Table-driven tests for all four severity bands crossed with present and away,
both as the pure table and end to end through the dispatcher. Three tests are
written to DESIGN.md and skipped because the code does not keep the claim: the
care-away drop is recorded nowhere, and the minimal body is enforced per-sink
rather than by the dispatcher. Also gofmt'd dispatcher_test.go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:24:43 +04:00
10 changed files with 523 additions and 904 deletions
+5 -5
View File
@@ -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
+247
View File
@@ -0,0 +1,247 @@
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)
}
}
+264
View File
@@ -0,0 +1,264 @@
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)
}
}
-310
View File
@@ -1,310 +0,0 @@
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")
}
}
+1 -9
View File
@@ -119,14 +119,6 @@ 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.
@@ -158,7 +150,7 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
PresenceScore: score,
Facts: facts,
LastNudge: lastNudge,
SnoozeUntil: snoozeUntil,
SnoozeUntil: nil, // no snooze persistence yet — daemon wires in
CooldownUntil: cooldownUntil,
QuietHours: quiet,
CalendarBusy: calBusy,
+6 -35
View File
@@ -1,7 +1,6 @@
package loop
import (
"fmt"
"time"
"github.com/kami/maven/internal/store"
@@ -107,53 +106,25 @@ 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 is the one part of restraint that still applies.
// 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).
type ReminderDecision struct {
Reminder store.Reminder
State State
}
// 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.
// 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`.
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.
-394
View File
@@ -1,394 +0,0 @@
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)
}
}
}
}
-2
View File
@@ -70,8 +70,6 @@ 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
-45
View File
@@ -28,20 +28,6 @@ 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 (1560m) 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")
@@ -152,37 +138,6 @@ 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) {
-104
View File
@@ -1,104 +0,0 @@
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)
}
}