Files
Maven/internal/store/nudges.go
T
kami d52f60c54e maven: fix test mocks for CalendarEvents interface (verification)
- Add CalendarEvents method to recordingAPI in auth_test.go
- Add CalendarEvents method to fakeCore in handlers_test.go

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-06 04:20:16 +04:00

185 lines
5.9 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"
)
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()
}
// 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
}