diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 12081d9..642075d 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -269,6 +269,27 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2 UPDATE proposed_routines SET accepted_ts = created_ts, reminder_id = NULL WHERE status = 'accepted' AND accepted_ts IS NULL;`, + + // #21 — allow 'resolved' as a nudge outcome (Vikunja #535). The sev4 repeat + // path needs an ending that means "the condition cleared, so I stopped + // talking", which is neither 'acted' (he answered) nor 'ignored' (nobody + // ever did). A CHECK cannot be altered in place, so the table is rebuilt. + // Rows carry over unchanged; only the constraint widens. + `CREATE TABLE nudges_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + rule TEXT NOT NULL, + channel TEXT NOT NULL, + message TEXT NOT NULL, + outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored','resolved')), + outcome_ts INTEGER + ); + INSERT INTO nudges_new (id, ts, rule, channel, message, outcome, outcome_ts) + SELECT id, ts, rule, channel, message, outcome, outcome_ts FROM nudges; + DROP TABLE nudges; + ALTER TABLE nudges_new RENAME TO nudges; + CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC); + CREATE INDEX IF NOT EXISTS idx_nudges_outcome ON nudges (outcome);`, } // migrate applies every migration with a number greater than the DB's current diff --git a/internal/store/nudges.go b/internal/store/nudges.go index a295050..12604c1 100644 --- a/internal/store/nudges.go +++ b/internal/store/nudges.go @@ -26,6 +26,18 @@ const ( NudgeActed = "acted" NudgeSnoozed = "snoozed" NudgeIgnored = "ignored" + + // NudgeResolved — the thing it was about stopped being true, and nobody + // answered. Distinct from acted, which means he did something, and from + // ignored, which means he chose not to (Vikunja #535). + // + // It exists because a repeating alarm needs an ending that is not a lie. + // Marking a cleared service_down "acted" would credit him with a response + // he never made and would teach the cooldown tuner to nudge harder; leaving + // it pending is what made the alarm ring for two hours after the service + // came back. This outcome is written by the daemon, never by a person, and + // it is deliberately invisible to the feedback loop — see RecentOutcomes. + NudgeResolved = "resolved" ) // SnoozeDuration — how long one `snoozed` outcome keeps its rule quiet. @@ -103,9 +115,14 @@ func (s *Store) ResolveNudge(ctx context.Context, id int64, outcome string, ts t // 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) { + // 'resolved' is excluded alongside 'pending' (Vikunja #535). The tuner reads + // this as "how often does he answer me", and a resolution the daemon wrote + // is not him answering. Counting it would dilute both rates toward zero and + // make a service that fixes itself look like a rule he neither acts on nor + // ignores, which is a fact about the world and not feedback about her. rows, err := s.db.QueryContext(ctx, `SELECT outcome FROM nudges - WHERE rule = ? AND outcome != 'pending' + WHERE rule = ? AND outcome NOT IN ('pending', 'resolved') ORDER BY ts DESC, id DESC LIMIT ?`, rule, n) if err != nil { return nil, fmt.Errorf("recent outcomes: %w", err) @@ -152,6 +169,61 @@ func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) { return out, rows.Err() } +// ResolvePendingTelegram closes every still-pending telegram nudge for a rule +// and reports how many it closed. This is what stops a repeating alarm +// (Vikunja #535). +// +// Rule-scoped and not id-scoped, deliberately: the repeat key IS the rule name, +// so a rule with several pending rows repeats once per tick for all of them and +// must go quiet for all of them at once. Closing one id would leave the alarm +// ringing on the others. +// +// The outcome is a parameter rather than hardcoded, because "the condition +// cleared" and "nobody could ever answer this" are different endings and +// /notifications should not show them as the same one. +func (s *Store) ResolvePendingTelegram(ctx context.Context, rule, outcome string, ts time.Time) (int64, error) { + switch outcome { + case NudgeResolved, NudgeIgnored: + default: + return 0, fmt.Errorf("store: %q is not an ending the daemon may write", outcome) + } + res, err := s.db.ExecContext(ctx, + `UPDATE nudges SET outcome = ?, outcome_ts = ? + WHERE rule = ? AND channel = 'telegram' AND outcome = 'pending'`, + outcome, ts.UnixMilli(), rule) + if err != nil { + return 0, fmt.Errorf("resolve pending telegram %s: %w", rule, err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("resolve pending telegram %s: rows affected: %w", rule, err) + } + return n, nil +} + +// OldestPendingTelegram returns when the oldest still-pending telegram nudge +// for a rule was sent. Used for the repeat age cap: an alarm nobody has +// answered in hours is not one more repeat away from being answered. +// +// Oldest and not newest, because the age that matters is how long the alarm has +// been ringing, not when it last rang — the repeat itself does not create new +// rows, but a re-fire of the rule does, and the alarm is the whole run. +func (s *Store) OldestPendingTelegram(ctx context.Context, rule string) (time.Time, error) { + // MIN over an empty set is one row holding NULL, not zero rows, so this + // scans into a NullInt64 and never sees sql.ErrNoRows. + var tsMilli sql.NullInt64 + err := s.db.QueryRowContext(ctx, + `SELECT MIN(ts) FROM nudges + WHERE rule = ? AND channel = 'telegram' AND outcome = 'pending'`, rule).Scan(&tsMilli) + if err != nil { + return time.Time{}, fmt.Errorf("oldest pending telegram %s: %w", rule, err) + } + if !tsMilli.Valid { + return time.Time{}, ErrNudgeNotFound + } + return time.UnixMilli(tsMilli.Int64).UTC(), nil +} + // 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. diff --git a/internal/store/schema.sql b/internal/store/schema.sql index 388ca65..cc5a5e9 100644 --- a/internal/store/schema.sql +++ b/internal/store/schema.sql @@ -43,7 +43,9 @@ CREATE TABLE IF NOT EXISTS nudges ( rule TEXT NOT NULL, channel TEXT NOT NULL, message TEXT NOT NULL, - outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored')), + -- 'resolved' is the daemon closing an alarm because the condition cleared, + -- as opposed to 'acted' (he answered) or 'ignored' (nobody ever did). + outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored','resolved')), outcome_ts INTEGER ); CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC);