32687b3712
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
230 lines
7.8 KiB
Go
230 lines
7.8 KiB
Go
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 (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")
|
||
)
|
||
|
||
// 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
|
||
}
|