Read the recorded snooze outcomes back out of the nudges table (#364)
The gate honours State.SnoozeUntil but nothing ever filled it. New store.SnoozedUntil returns, per rule, when the newest snooze runs out. Reviewer: the fixed 2h SnoozeDuration and its reasoning in nudges.go — nothing upstream can supply a per-nudge length, so no new column. Expired snoozes are dropped in SQL, so silence can never be permanent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
This commit is contained in:
@@ -70,6 +70,8 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
|||||||
CHECK (resolution_state IN ('none','pending','resolved','ambiguous','not_found'));
|
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_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_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
|
// migrate applies every migration with a number greater than the DB's current
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ const (
|
|||||||
NudgeIgnored = "ignored"
|
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 (
|
var (
|
||||||
ErrNudgeNotFound = errors.New("store: nudge not found")
|
ErrNudgeNotFound = errors.New("store: nudge not found")
|
||||||
ErrNudgeOutcome = errors.New("store: nudge already resolved")
|
ErrNudgeOutcome = errors.New("store: nudge already resolved")
|
||||||
@@ -138,6 +152,37 @@ func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) {
|
|||||||
return out, rows.Err()
|
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
|
// RecentNudges — the newest n nudges across all rules, with outcomes, for the
|
||||||
// monitoring dash. Newest first.
|
// monitoring dash. Newest first.
|
||||||
func (s *Store) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
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