178 lines
5.7 KiB
Go
178 lines
5.7 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, _ := res.LastInsertId()
|
|
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, _ := res.RowsAffected()
|
|
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
|
|
} |