Files
Maven/internal/store/nudges.go
T
kami 32687b3712 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
2026-07-31 02:29:51 +04:00

230 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// Nudge — one proactive send, with deferred outcome. outcomes are: pending |
// acted | snoozed | ignored. outcome_ts set when resolved. the outcome column
// IS the restraint-memory signal — no separate table for the feedback loop.
type Nudge struct {
ID int64
Ts time.Time // sent ts
Rule string
Channel string
Message string
Outcome string
OutcomeTs sql.NullInt64
}
const (
NudgePending = "pending"
NudgeActed = "acted"
NudgeSnoozed = "snoozed"
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")
)
// RecordNudge inserts a pending nudge (sent). returns the id. the loop writes
// one row per proactive send per tick (one nudge per tick, max severity).
func (s *Store) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO nudges (ts, rule, channel, message, outcome) VALUES (?,?,?,?, 'pending')`,
ts.UnixMilli(), rule, channel, message)
if err != nil {
return 0, fmt.Errorf("record nudge: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("record nudge: last insert id: %w", err)
}
return id, nil
}
// ResolveNudge sets the outcome of a still-pending nudge. acted | snoozed |
// ignored — caller decides by user response (or lack of it). idempotency is
// rejected here: a nudge can only be resolved once, by design — re-resolving
// would silently corrupt the feedback signal (the table IS the learning input).
func (s *Store) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
switch outcome {
case NudgeActed, NudgeSnoozed, NudgeIgnored:
default:
return fmt.Errorf("store: unknown outcome %q", outcome)
}
res, err := s.db.ExecContext(ctx,
`UPDATE nudges SET outcome = ?, outcome_ts = ? WHERE id = ? AND outcome = 'pending'`,
outcome, ts.UnixMilli(), id)
if err != nil {
return fmt.Errorf("resolve nudge: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("resolve nudge: rows affected: %w", err)
}
if n == 0 {
// either no such row, or it was already resolved — distinguish so callers
// can tell a bug from a race.
var cur string
err := s.db.QueryRowContext(ctx, "SELECT outcome FROM nudges WHERE id = ?", id).Scan(&cur)
if errors.Is(err, sql.ErrNoRows) {
return ErrNudgeNotFound
}
if err != nil {
return err
}
return fmt.Errorf("%w: currently %s", ErrNudgeOutcome, cur)
}
return nil
}
// RecentOutcomes returns the last N outcomes (in reverse chronological order)
// for a given rule — the feedback loop's only input. used to compute
// ignored_rate → cooldown sizing. dead simple at mvp: a ratio over last N.
func (s *Store) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT outcome FROM nudges
WHERE rule = ? AND outcome != 'pending'
ORDER BY ts DESC, id DESC LIMIT ?`, rule, n)
if err != nil {
return nil, fmt.Errorf("recent outcomes: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var o string
if err := rows.Scan(&o); err != nil {
return nil, err
}
out = append(out, o)
}
return out, rows.Err()
}
// UnackedTelegramRules returns rule names that have at least one still-pending
// (un-acked) nudge sent over the telegram channel. the dispatcher's
// RepeatUnacked re-sends these each tick until MarkAcked.
//
// telegram is the sev4-away channel by routing-table construction
// (ChannelsFor sends sev4 away → telegram, nothing else to telegram), so
// `channel='telegram' AND outcome='pending'` already implies sev4 ops-hard —
// no severity column on the nudges table, and none needed: the routing table
// is the authority. grouped by rule so the repeat stream is one-per-rule
// (the ack key IS the rule name).
func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT rule FROM nudges
WHERE channel = 'telegram' AND outcome = 'pending'
GROUP BY rule ORDER BY rule`)
if err != nil {
return nil, fmt.Errorf("unacked telegram rules: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var r string
if err := rows.Scan(&r); err != nil {
return nil, err
}
out = append(out, r)
}
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) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, ts, rule, channel, message, outcome, outcome_ts
FROM nudges ORDER BY ts DESC, id DESC LIMIT ?`, n)
if err != nil {
return nil, fmt.Errorf("recent nudges: %w", err)
}
defer rows.Close()
var out []Nudge
for rows.Next() {
var ng Nudge
var tsMilli int64
if err := rows.Scan(&ng.ID, &tsMilli, &ng.Rule, &ng.Channel, &ng.Message, &ng.Outcome, &ng.OutcomeTs); err != nil {
return nil, err
}
ng.Ts = time.UnixMilli(tsMilli).UTC()
out = append(out, ng)
}
return out, rows.Err()
}
// LastNudge — newest nudge for a rule regardless of outcome. used by the gate
// for cooldown enforcement. returns ErrNudgeNotFound if the rule has never fired.
func (s *Store) LastNudge(ctx context.Context, rule string) (Nudge, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, ts, rule, channel, message, outcome, outcome_ts
FROM nudges WHERE rule = ? ORDER BY ts DESC, id DESC LIMIT 1`, rule)
var n Nudge
var tsMilli int64
var outcomeTs sql.NullInt64
if err := row.Scan(&n.ID, &tsMilli, &n.Rule, &n.Channel, &n.Message, &n.Outcome, &outcomeTs); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Nudge{}, ErrNudgeNotFound
}
return Nudge{}, err
}
n.Ts = time.UnixMilli(tsMilli).UTC()
if outcomeTs.Valid {
n.OutcomeTs = outcomeTs
}
return n, nil
}