Files
Maven/internal/store/migrations.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

165 lines
7.5 KiB
Go

package store
import (
"context"
"database/sql"
"fmt"
)
// migrations are ordered, forward-only schema steps applied after schema.sql.
// Index i (1-based) is the user_version the step at migrations[i-1] brings the
// DB TO; there is no step 0 — schema.sql is the idempotent baseline (version 0).
// An empty slice is a clean no-op that leaves user_version at 0.
//
// To add migration #1 (e.g. the sqlcipher rekey), append its SQL:
//
// var migrations = []string{
// `ALTER TABLE ...;`, // #1
// }
var migrations = []string{
`ALTER TABLE tools ADD COLUMN scope TEXT NOT NULL DEFAULT 'homelab';`, // #1
`ALTER TABLE reminders ADD COLUMN cron TEXT;
ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
`CREATE TABLE memory_vectors (
id TEXT PRIMARY KEY,
vec BLOB NOT NULL,
meta TEXT NOT NULL DEFAULT '{}',
created_ts INTEGER NOT NULL
);`, // #3 — long-term vector memory (persistent backend for internal/memory)
`CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fact_id INTEGER NOT NULL REFERENCES facts(id),
action TEXT NOT NULL,
object TEXT NOT NULL,
ts INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_action_object ON events (action, object, ts DESC);
CREATE TABLE IF NOT EXISTS proposed_routines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
object TEXT NOT NULL,
interval_days REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN ('proposed','accepted','dismissed')),
created_ts INTEGER NOT NULL,
reminder_id INTEGER REFERENCES reminders(id),
UNIQUE(action, object)
);`, // #4 — event extraction + pattern inference
`CREATE TABLE IF NOT EXISTS ack_sends (
rule TEXT NOT NULL,
sent_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ack_sends_rule ON ack_sends (rule, sent_at DESC);`, // #5 — sev4 telegram repeat-til-ack tracking
`CREATE TABLE IF NOT EXISTS delivery_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK (kind IN ('nudge','reminder')),
rule TEXT NOT NULL DEFAULT '',
reminder_id INTEGER NOT NULL DEFAULT 0,
channel TEXT NOT NULL,
body_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','unknown')),
created_ts INTEGER NOT NULL,
completed_ts INTEGER
);
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_status ON delivery_attempts (status);`, // #6 — durable delivery outbox
`ALTER TABLE facts ADD COLUMN subject TEXT NOT NULL DEFAULT '';
ALTER TABLE facts ADD COLUMN entity_id TEXT;
ALTER TABLE facts ADD COLUMN resolution_state TEXT NOT NULL DEFAULT 'none'
CHECK (resolution_state IN ('none','pending','resolved','ambiguous','not_found'));
CREATE INDEX IF NOT EXISTS idx_facts_entity_id ON facts (entity_id) WHERE entity_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async
`CREATE INDEX IF NOT EXISTS idx_nudges_snoozed ON nudges (outcome_ts) WHERE outcome = 'snoozed';`, // #8 — SnoozedUntil runs every tick; keep it off a full scan (Vikunja #364)
`ALTER TABLE proposed_routines ADD COLUMN accepted_ts INTEGER;
ALTER TABLE proposed_routines ADD COLUMN last_fired_ts INTEGER;`, // #9 — accepted routines keep firing (Vikunja #366): the tick loop needs to know when a routine was accepted and when it last nudged
`CREATE TABLE IF NOT EXISTS dialogue_sessions (
id TEXT PRIMARY KEY,
data BLOB NOT NULL,
ts INTEGER NOT NULL,
ttl_ms INTEGER NOT NULL,
expires_ts INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dialogue_sessions_expires ON dialogue_sessions (expires_ts);`, // #10 — the follow-up session survives a restart (Vikunja #363); small, TTL-pruned table, not a history log
`CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);`, // #11 — small key/value table for facts about the DB itself; first key is embedder_id (Vikunja #378)
// #12 — a suppressed nudge gets a 'dropped' row (Vikunja #370). sqlite
// can't widen a CHECK constraint in place, so the table is rebuilt; the
// index goes with the old table and is recreated. The columns are listed
// out rather than `SELECT *` — copying by position would silently shuffle
// every row if the old table's column order ever differed from this one.
`CREATE TABLE delivery_attempts_v12 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK (kind IN ('nudge','reminder')),
rule TEXT NOT NULL DEFAULT '',
reminder_id INTEGER NOT NULL DEFAULT 0,
channel TEXT NOT NULL,
body_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','unknown','dropped')),
created_ts INTEGER NOT NULL,
completed_ts INTEGER
);
INSERT INTO delivery_attempts_v12
(id, kind, rule, reminder_id, channel, body_hash, status, created_ts, completed_ts)
SELECT id, kind, rule, reminder_id, channel, body_hash, status, created_ts, completed_ts
FROM delivery_attempts;
DROP TABLE delivery_attempts;
ALTER TABLE delivery_attempts_v12 RENAME TO delivery_attempts;
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_status ON delivery_attempts (status);`,
// #13 — durable digest outbox (Vikunja #281). A care nudge the restraint
// gate suppresses (quiet hours / away / calendar-busy) is not necessarily
// lost: if it's worth resurfacing, it lands here instead, and gets spoken
// as one bundle at the next moment speaking is appropriate. body_hash
// dedupes repeat suppressions of the "same" nudge; expires_ts bounds how
// stale an entry may get before it's worthless and must be dropped rather
// than delivered late.
`CREATE TABLE IF NOT EXISTS digest_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule TEXT NOT NULL,
severity INTEGER NOT NULL,
body TEXT NOT NULL,
body_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','drained','expired')),
created_ts INTEGER NOT NULL,
expires_ts INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_digest_entries_status ON digest_entries (status);`,
}
// migrate applies every migration with a number greater than the DB's current
// user_version, each in its own transaction that also bumps user_version. Fails
// closed: the first erroring step aborts and leaves prior steps committed.
func migrate(ctx context.Context, db *sql.DB) error {
var current int
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&current); err != nil {
return fmt.Errorf("read user_version: %w", err)
}
for i := current; i < len(migrations); i++ {
version := i + 1 // 1-based: migrations[i] brings DB to `version`
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("migration %d begin: %w", version, err)
}
if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
_ = tx.Rollback()
return fmt.Errorf("migration %d: %w", version, err)
}
// PRAGMA user_version can't be parameterized; version is our own int.
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", version)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("migration %d bump: %w", version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("migration %d commit: %w", version, err)
}
}
return nil
}