Files
Maven/internal/store/digest.go
T
kami b0f5a16ec9 Add digest as a real outcome: suppressed care nudges get resurfaced, not lost
Vikunja #281. The interruption policy promised four outcomes — deliver_now,
queue, digest, drop — but only three existed: a care candidate the restraint
gate suppressed for quiet hours / away / calendar-busy simply vanished in
loop.Tick's `continue`, with only the trace remembering why.

internal/morning turned out not to be the natural drain: it's a fixed
Item/FactKey checklist engine, not a generic message bundler, so gate-
suppressed nudge text has nowhere to plug into its evidence model. Built a
parallel (but small, reusing the outbox's shape) durable digest instead:

- internal/store: digest_entries table + EnqueueDigestEntry (dedupes by
  rule+body, mirroring the delivery outbox's bodyHash), PendingDigestEntries,
  ExpireStaleDigestEntries, DrainDigestEntries (mark, never delete — an
  audit trail of what she actually said).
- internal/loop: DigestEligible(severity, blockedBy) is the pure boundary —
  only genuine restraint blocks (quiet_hours/calendar_busy/presence) even
  qualify (cooldown/snooze are not "suppression"); within care, Sev2 (break)
  digests, Sev1 (water/meal — stale by the time anyone could resurface them)
  drops. High severity never digests; alarms bypass the gate and deliver
  unchanged, on purpose.
- cmd/mavend/tick.go: each tick scans ExplainTick's trace for eligible
  blocked candidates, enqueues them, sweeps stale entries (24h expiry — the
  care rules are daily-cadence, so anything older is describing a day
  that's over), and drains the bundle only once the suppression reason has
  actually cleared, capped at 3 spoken items plus a trailing count so a
  digest can't turn into the exact nagging it was built to avoid.

Tests: store-level round-trip/restart-survival/dedupe/expiry/drain, loop-
level severity-boundary unit tests, and tick-level integration tests for
the drain-only-when-clear and never-digest-high-severity behavior.
2026-07-31 23:09:22 +04:00

143 lines
5.1 KiB
Go

package store
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
)
// Digest entry statuses. pending = enqueued, waiting for a drain. drained =
// spoken as part of a bundle. expired = the tick loop's expiry sweep found it
// past its expires_ts before a drain happened — dropped, not delivered late.
const (
DigestPending = "pending"
DigestDrained = "drained"
DigestExpired = "expired"
)
// DigestEntry — one gate-suppressed care candidate durably held for later
// bundled delivery.
type DigestEntry struct {
ID int64
Rule string
Severity int
Body string
CreatedTs time.Time
ExpiresTs time.Time
}
// DigestBodyHash is the dedupe key for a digest entry: same rule, same
// wording ⇒ the same suppressed nudge repeating across ticks, and he should
// hear it once, not once per tick it kept getting suppressed.
func DigestBodyHash(rule, body string) string {
sum := sha256.Sum256([]byte(rule + "\x00" + body))
return hex.EncodeToString(sum[:8])
}
// EnqueueDigestEntry durably records a suppressed care candidate worth
// resurfacing later. If a pending entry with the same rule+body already
// exists, this is a no-op that returns the existing id and deduped=true —
// the same suppressed nudge repeating across ticks must not pile up into
// several copies of itself in the eventual bundle.
func (s *Store) EnqueueDigestEntry(ctx context.Context, rule string, severity int, body string, now, expiresAt time.Time) (id int64, deduped bool, err error) {
hash := DigestBodyHash(rule, body)
var existing int64
err = s.db.QueryRowContext(ctx,
`SELECT id FROM digest_entries WHERE status = ? AND rule = ? AND body_hash = ? LIMIT 1`,
DigestPending, rule, hash).Scan(&existing)
if err == nil {
return existing, true, nil
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO digest_entries (rule, severity, body, body_hash, status, created_ts, expires_ts)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
rule, severity, body, hash, DigestPending, now.UnixMilli(), expiresAt.UnixMilli())
if err != nil {
return 0, false, fmt.Errorf("enqueue digest entry: %w", err)
}
id, err = res.LastInsertId()
if err != nil {
return 0, false, fmt.Errorf("enqueue digest entry: last insert id: %w", err)
}
return id, false, nil
}
// PendingDigestEntries returns the live (not yet expired) pending entries,
// oldest first — the order they were suppressed in, which is also the order
// a bundled readout should mention them.
func (s *Store) PendingDigestEntries(ctx context.Context, now time.Time) ([]DigestEntry, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, rule, severity, body, created_ts, expires_ts
FROM digest_entries WHERE status = ? AND expires_ts > ? ORDER BY created_ts ASC`,
DigestPending, now.UnixMilli())
if err != nil {
return nil, fmt.Errorf("pending digest entries: %w", err)
}
defer rows.Close()
var out []DigestEntry
for rows.Next() {
var e DigestEntry
var created, expires int64
if err := rows.Scan(&e.ID, &e.Rule, &e.Severity, &e.Body, &created, &expires); err != nil {
return nil, fmt.Errorf("pending digest entries: scan: %w", err)
}
e.CreatedTs = time.UnixMilli(created)
e.ExpiresTs = time.UnixMilli(expires)
out = append(out, e)
}
return out, rows.Err()
}
// ExpireStaleDigestEntries marks pending entries whose expires_ts has passed
// as expired — stale information (yesterday's battery warning) is noise, not
// news, so it is dropped rather than delivered late. Called once per tick,
// mirroring ReconcileStaleDeliveryAttempts's "sweep, don't guess" shape.
// Returns the count expired, for logging.
func (s *Store) ExpireStaleDigestEntries(ctx context.Context, now time.Time) (int, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE digest_entries SET status = ? WHERE status = ? AND expires_ts <= ?`,
DigestExpired, DigestPending, now.UnixMilli())
if err != nil {
return 0, fmt.Errorf("expire stale digest entries: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("expire stale digest entries: rows affected: %w", err)
}
return int(n), nil
}
// DrainDigestEntries marks the given entries drained — they were folded into
// a bundle that was successfully dispatched. Called only after a successful
// send, same rule as the delivery outbox: a failed dispatch must not mark
// entries drained, or the bundle is lost along with the failed send.
func (s *Store) DrainDigestEntries(ctx context.Context, ids []int64, now time.Time) error {
if len(ids) == 0 {
return nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("drain digest entries: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
stmt, err := tx.PrepareContext(ctx,
`UPDATE digest_entries SET status = ? WHERE id = ? AND status = ?`)
if err != nil {
return fmt.Errorf("drain digest entries: prepare: %w", err)
}
defer stmt.Close()
for _, id := range ids {
if _, err := stmt.ExecContext(ctx, DigestDrained, id, DigestPending); err != nil {
return fmt.Errorf("drain digest entry %d: %w", id, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("drain digest entries: commit: %w", err)
}
return nil
}