5bca435146
allMemVectorMetas (memory.go) replaces the identical query-then-scan block ReembedAll and RepairFactVectors each had for reading id+meta out of memory_vectors — same query, same json.Unmarshal, different structs built from the result. Two RowsAffected() errors were silently dropped with `_`, inconsistent with every other call site in the same files: AcceptProposedRoutine now wraps the error instead of treating it as zero rows, and MarkAcked had it stranded behind a dead branch (both arms returned nil) removed along with the swallowed error. No behavior change; internal/store and internal/memory pass with -race.
273 lines
9.9 KiB
Go
273 lines
9.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// RoutineStatus — the state a proposal is in. A defined type, not a bare
|
|
// string, because the legal set used to live in a comment: nothing caught a
|
|
// typo at compile time, nothing enumerated the set for a test, and a bad value
|
|
// surfaced as a /routines row that neither accepts nor dismisses (Vikunja #46,
|
|
// #410).
|
|
type RoutineStatus string
|
|
|
|
// The three states a proposal can be in. A proposal starts proposed and moves
|
|
// once, either way, and never moves again.
|
|
const (
|
|
RoutineProposed RoutineStatus = "proposed"
|
|
RoutineAccepted RoutineStatus = "accepted"
|
|
RoutineDismissed RoutineStatus = "dismissed"
|
|
)
|
|
|
|
// RoutineStatuses — the legal set, and the single source of truth a test can
|
|
// range over. Adding a state means adding it here.
|
|
var RoutineStatuses = []RoutineStatus{RoutineProposed, RoutineAccepted, RoutineDismissed}
|
|
|
|
// Valid reports whether s is one of RoutineStatuses.
|
|
func (s RoutineStatus) Valid() bool {
|
|
for _, v := range RoutineStatuses {
|
|
if s == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s RoutineStatus) String() string { return string(s) }
|
|
|
|
// ProposedRoutine — a detected pattern the system wants to nudge about on a
|
|
// repeating interval. Status 'proposed' means awaiting human confirmation;
|
|
// 'accepted' means the human confirmed and the tick loop now owns the schedule;
|
|
// 'dismissed' means the human declined and we won't re-propose.
|
|
//
|
|
// AcceptedTs is when the human said yes; it is the clock start for the first
|
|
// nudge. LastFiredTs is when the last nudge went out, nil until the first one.
|
|
// ReminderID is only set on rows accepted before Vikunja #366, when accepting
|
|
// created a one-shot reminder instead.
|
|
type ProposedRoutine struct {
|
|
ID int64
|
|
Action string
|
|
Object string
|
|
IntervalDays float64
|
|
Status RoutineStatus
|
|
CreatedTs time.Time
|
|
ReminderID *int64
|
|
AcceptedTs *time.Time
|
|
LastFiredTs *time.Time
|
|
}
|
|
|
|
var (
|
|
ErrProposedRoutineNotFound = errors.New("store: proposed routine not found")
|
|
ErrProposedRoutineExists = errors.New("store: proposed routine already exists for this action+object")
|
|
ErrRoutineStatus = errors.New("store: unknown routine status")
|
|
)
|
|
|
|
// CreateProposedRoutine inserts a new proposed routine. Returns
|
|
// ErrProposedRoutineExists if one already exists for this action+object (any
|
|
// status) — the pattern detector should only propose once per pair.
|
|
//
|
|
// action+object is the "same routine" key. It is UNIQUE in the table, so a
|
|
// routine the human already dismissed can never come back: the detector will
|
|
// keep finding the pattern, and every re-propose is refused here. Maven is not
|
|
// a nag.
|
|
//
|
|
// The object stays a local string. It is not resolved against Nexus and it
|
|
// carries no canonical entity ref (asked on the PR 4 review, decided here,
|
|
// Vikunja #410). Nexus owns identity for things the ecosystem acts on, and
|
|
// nothing acts on a routine object: it is the word he used, replayed back to
|
|
// him in a nudge, and compared only against itself for the UNIQUE key. Two
|
|
// spellings of the same watering can are two routines, and that is the right
|
|
// answer when the point is to say the sentence he would say. Canonical refs
|
|
// arrive here only if a routine ever drives a Hexis call, which is Vikunja
|
|
// #272, not this.
|
|
//
|
|
// Vikunja #43: this is called both from the voice fact-write path (for the
|
|
// immediate spoken confirmation) and from the digestion tick's proactive
|
|
// scan (cmd/mavend/tick.go's detectPatterns, via patterns.go's
|
|
// detectAndPropose), so a pattern gets noticed even with nobody at the mic.
|
|
func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string, intervalDays float64, ts time.Time) (int64, error) {
|
|
res, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts)
|
|
VALUES (?,?,?,'proposed',?)
|
|
ON CONFLICT(action, object) DO NOTHING`,
|
|
action, object, intervalDays, ts.UnixMilli())
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create proposed routine: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create proposed routine: rows affected: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return 0, ErrProposedRoutineExists
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create proposed routine: last insert id: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// LookupProposedRoutine returns the proposed routine for action+object, or
|
|
// nil (no error) when no row exists.
|
|
func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string) (*ProposedRoutine, error) {
|
|
row := s.db.QueryRowContext(ctx, `
|
|
SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
|
|
FROM proposed_routines
|
|
WHERE action = ? AND object = ?`, action, object)
|
|
r, err := scanProposedRoutine(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
// ListProposedRoutines returns the routines still waiting for an answer,
|
|
// newest first. This is what the /routines page shows.
|
|
func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
|
return s.ListProposedRoutinesByStatus(ctx, RoutineProposed)
|
|
}
|
|
|
|
// ListProposedRoutinesByStatus returns routines in one status, newest first.
|
|
// An empty status returns every row; an unknown one is refused rather than
|
|
// silently answering with nothing, since a typo and a genuinely empty state
|
|
// read the same otherwise.
|
|
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status RoutineStatus) ([]ProposedRoutine, error) {
|
|
if status != "" && !status.Valid() {
|
|
return nil, fmt.Errorf("%w: %q", ErrRoutineStatus, status)
|
|
}
|
|
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
|
|
FROM proposed_routines`
|
|
var args []any
|
|
if status != "" {
|
|
q += ` WHERE status = ?`
|
|
args = append(args, status)
|
|
}
|
|
q += ` ORDER BY created_ts DESC, id DESC`
|
|
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list proposed routines: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []ProposedRoutine
|
|
for rows.Next() {
|
|
r, err := scanProposedRoutine(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Status changes below are an in-place UPDATE, on purpose. Facts are
|
|
// append-only (a correction writes a new row and sets voids_id) because a fact
|
|
// is a claim about the world and the old claim is still history worth keeping.
|
|
// A proposal is not a claim, it is a question with one answer, and the same
|
|
// shape already exists for tools (tools.status flips in place). The guard
|
|
// `AND status = 'proposed'` makes the move one-way: an answered proposal can
|
|
// never be answered again.
|
|
//
|
|
// AcceptProposedRoutine flips status to 'accepted' and records when. From that
|
|
// timestamp the tick loop owns the schedule: it re-reads accepted rows every
|
|
// tick and nudges when the interval has passed. Returns an error if the row is
|
|
// not in 'proposed' status.
|
|
func (s *Store) AcceptProposedRoutine(ctx context.Context, id int64, ts time.Time) error {
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE proposed_routines SET status = 'accepted', accepted_ts = ? WHERE id = ? AND status = 'proposed'`,
|
|
ts.UnixMilli(), id)
|
|
if err != nil {
|
|
return fmt.Errorf("accept proposed routine: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("accept proposed routine: rows affected: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return fmt.Errorf("%w: id=%d not in 'proposed' status", ErrProposedRoutineNotFound, id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListAcceptedRoutines returns every accepted routine, oldest first. The tick
|
|
// loop reads this each tick and decides which ones are due.
|
|
func (s *Store) ListAcceptedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
|
|
FROM proposed_routines
|
|
WHERE status = 'accepted'
|
|
ORDER BY id`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list accepted routines: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []ProposedRoutine
|
|
for rows.Next() {
|
|
r, err := scanProposedRoutine(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// MarkRoutineFired records that a routine just nudged. The stored time is the
|
|
// nudge time, not the time it was theoretically due, so a routine that was
|
|
// silent for a while starts its next interval from now — missed occurrences are
|
|
// dropped, never replayed as a backlog.
|
|
func (s *Store) MarkRoutineFired(ctx context.Context, id int64, ts time.Time) error {
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`UPDATE proposed_routines SET last_fired_ts = ? WHERE id = ?`,
|
|
ts.UnixMilli(), id); err != nil {
|
|
return fmt.Errorf("mark routine fired: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DismissProposedRoutine flips status to 'dismissed'. Idempotent.
|
|
func (s *Store) DismissProposedRoutine(ctx context.Context, id int64) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE proposed_routines SET status = 'dismissed' WHERE id = ? AND status = 'proposed'`,
|
|
id)
|
|
if err != nil {
|
|
return fmt.Errorf("dismiss proposed routine: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// scanProposedRoutine scans a row into ProposedRoutine.
|
|
func scanProposedRoutine(sc scanner) (ProposedRoutine, error) {
|
|
var r ProposedRoutine
|
|
var created int64
|
|
var reminderID sql.NullInt64
|
|
var accepted, lastFired sql.NullInt64
|
|
if err := sc.Scan(&r.ID, &r.Action, &r.Object, &r.IntervalDays, &r.Status, &created, &reminderID, &accepted, &lastFired); err != nil {
|
|
return ProposedRoutine{}, err
|
|
}
|
|
r.CreatedTs = time.UnixMilli(created).UTC()
|
|
if reminderID.Valid {
|
|
r.ReminderID = &reminderID.Int64
|
|
}
|
|
r.AcceptedTs = millisToTime(accepted)
|
|
r.LastFiredTs = millisToTime(lastFired)
|
|
return r, nil
|
|
}
|
|
|
|
// millisToTime turns a nullable unix-millis column into a *time.Time.
|
|
func millisToTime(v sql.NullInt64) *time.Time {
|
|
if !v.Valid {
|
|
return nil
|
|
}
|
|
t := time.UnixMilli(v.Int64).UTC()
|
|
return &t
|
|
}
|