708a69375f
A task extracted from mail deduped on the live-norm index only, so once he finished it the row left the live set and the next poll of the same immutable message re-extracted it as a fresh candidate. mavmaild is a read-only reader and marks nothing read, so that repeats forever. Derived rows now carry an ext_id built from the message uid and the extracted span, unique across every status, while voice keeps live-only norm dedupe because saying an errand again is the recurrence signal. A derived source can no longer capture straight to open, and saying a task out loud that Maven had only proposed promotes the candidate instead of answering that it is already in the list. SetTaskStatus was classified AuthRead. Resolving a task is not additive, it erases work off his list, so it is a write, and the row now records the caller that moved it. ListTasks was unbounded. The list-query matcher claimed any utterance with "что мне делать", including "с чем мне помочь", and the urgency stripper matched inside words. Found in review of #60.
383 lines
14 KiB
Go
383 lines
14 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
// Tasks — the capture store (Vikunja #130). One row per piece of work, with a
|
|
// lifecycle instead of a valid-time: captured, then either done or dropped.
|
|
//
|
|
// Why not facts: a fact is a claim about the world and a correction supersedes
|
|
// it (append-only, voids_id). A task is not a claim — it is work, it has a
|
|
// state that moves forward once, and the read the prioritiser needs is "every
|
|
// live task right now", which over an append-only log would mean replaying
|
|
// history on every question.
|
|
//
|
|
// Nothing in this file schedules, fires or announces anything. Capture is a
|
|
// store, not a trigger: a task exists to be answered when asked about, and the
|
|
// owner's reminders remain the only thing that speaks unprompted.
|
|
const (
|
|
// TaskCandidate — Maven derived this task from something she read (mail,
|
|
// once the email reader exists) and the owner has not confirmed it. A
|
|
// candidate is inert: it is listed as a candidate and never counted as work
|
|
// he agreed to.
|
|
TaskCandidate = "candidate"
|
|
// TaskOpen — work the owner stated himself, or a candidate he confirmed.
|
|
TaskOpen = "open"
|
|
// TaskDone — finished.
|
|
TaskDone = "done"
|
|
// TaskDropped — declined, or a candidate rejected. Kept for provenance, so
|
|
// the same mail cannot resurrect it silently; nothing re-proposes a
|
|
// dropped task.
|
|
TaskDropped = "dropped"
|
|
)
|
|
|
|
// Task — one captured piece of work.
|
|
//
|
|
// Source is provenance in the same vocabulary facts use: "tap:voice" for
|
|
// something he said, "tap:web" for the review page, "email:<account>" for a
|
|
// mail-derived candidate. Evidence is the free-text trail a derived task came
|
|
// from (a subject line), empty for anything he stated himself — it is what
|
|
// makes a candidate reviewable instead of mysterious.
|
|
//
|
|
// Due is optional. Weight is an explicit importance hint (0 = none), which the
|
|
// prioritiser reads; capture never invents one.
|
|
// ExternalID is the identity of the thing this task was derived FROM — a
|
|
// message id plus the extracted span, for a source that re-reads the same
|
|
// immutable text forever. Empty for anything he stated himself.
|
|
//
|
|
// ResolvedBy names the caller that moved the task to its terminal state, in the
|
|
// source vocabulary. Empty while the task is live.
|
|
type Task struct {
|
|
ID int64
|
|
CreatedTs time.Time
|
|
Text string
|
|
Source string
|
|
Evidence string
|
|
ExternalID string
|
|
Status string
|
|
Due *time.Time
|
|
Weight int
|
|
ResolvedTs *time.Time
|
|
ResolvedBy string
|
|
}
|
|
|
|
// CaptureResult — what CaptureTask did. Created is a new row. Promoted is an
|
|
// existing candidate this capture turned into open work: he stated out loud a
|
|
// task Maven had only proposed, which is a confirmation, and the caller says so
|
|
// instead of "уже в списке".
|
|
type CaptureResult struct {
|
|
ID int64
|
|
Created bool
|
|
Promoted bool
|
|
}
|
|
|
|
var (
|
|
ErrTaskNotFound = errors.New("store: task not found")
|
|
ErrTaskEmpty = errors.New("store: task text is empty")
|
|
ErrTaskStatus = errors.New("store: invalid task status")
|
|
)
|
|
|
|
// liveTaskStatuses — the two statuses that count as outstanding work.
|
|
var liveTaskStatuses = []string{TaskCandidate, TaskOpen}
|
|
|
|
// derivedSourcePrefixes — provenance that means "Maven read this somewhere",
|
|
// as opposed to "he said it". A task from one of these is a candidate and
|
|
// nothing else; see CaptureTask.
|
|
var derivedSourcePrefixes = []string{"email:"}
|
|
|
|
// IsDerivedSource reports whether a task source means Maven inferred the task
|
|
// from something she read rather than being told it.
|
|
func IsDerivedSource(source string) bool {
|
|
for _, p := range derivedSourcePrefixes {
|
|
if strings.HasPrefix(source, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// CaptureTask inserts a task, or returns the existing one when the same work is
|
|
// already there. The result says which happened, so a caller can tell the owner
|
|
// "уже в списке" instead of pretending it wrote something.
|
|
//
|
|
// Two dedupe keys, because voice and mail have different intake semantics:
|
|
//
|
|
// - ExternalID, unique over EVERY row whatever its status. A source that
|
|
// re-reads the same immutable text forever must never resurrect work he has
|
|
// already finished. This is the property the email intake depends on: it
|
|
// may call CaptureTask for every message it extracts from, as often as it
|
|
// likes, without growing the list.
|
|
// - The normalised text among LIVE rows only (the partial unique index in
|
|
// migration #14), for anything with no external identity. A weekly errand
|
|
// captured again once the last one is done must produce a new row, because
|
|
// him saying it again IS the recurrence signal.
|
|
//
|
|
// A capture with Status open over an existing candidate PROMOTES it. Stating
|
|
// the work out loud is a confirmation, and leaving it a candidate would have
|
|
// Maven read it back as something he never confirmed.
|
|
//
|
|
// A derived source may only ever capture a candidate. The doc on the intake
|
|
// seam said "must NOT set Status open"; this is where that stops being an
|
|
// honour system, so a compromised reader cannot file work he never reviewed.
|
|
func (s *Store) CaptureTask(ctx context.Context, t Task) (CaptureResult, error) {
|
|
text := strings.TrimSpace(t.Text)
|
|
if text == "" {
|
|
return CaptureResult{}, ErrTaskEmpty
|
|
}
|
|
status := t.Status
|
|
if status == "" {
|
|
status = TaskOpen
|
|
}
|
|
if status != TaskCandidate && status != TaskOpen {
|
|
// Capturing straight into a resolved state is meaningless — a task is
|
|
// captured live and moved later.
|
|
return CaptureResult{}, fmt.Errorf("%w: capture status %q", ErrTaskStatus, status)
|
|
}
|
|
if status == TaskOpen && IsDerivedSource(t.Source) {
|
|
return CaptureResult{}, fmt.Errorf("%w: derived source %q may only capture a candidate", ErrTaskStatus, t.Source)
|
|
}
|
|
norm := NormalizeTaskText(text)
|
|
created2 := t.CreatedTs
|
|
if created2.IsZero() {
|
|
created2 = time.Now()
|
|
}
|
|
var due sql.NullInt64
|
|
if t.Due != nil {
|
|
due = sql.NullInt64{Int64: t.Due.UnixMilli(), Valid: true}
|
|
}
|
|
var ext sql.NullString
|
|
if e := strings.TrimSpace(t.ExternalID); e != "" {
|
|
ext = sql.NullString{String: e, Valid: true}
|
|
}
|
|
|
|
// Untargeted DO NOTHING: either unique index may be the one that fires, and
|
|
// the lookup below sorts out which.
|
|
res, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO tasks (created_ts, text, norm, source, evidence, ext_id, status, due_ts, weight)
|
|
VALUES (?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT DO NOTHING`,
|
|
created2.UnixMilli(), text, norm, t.Source, t.Evidence, ext, status, due, t.Weight)
|
|
if err != nil {
|
|
return CaptureResult{}, fmt.Errorf("capture task: %w", err)
|
|
}
|
|
if n, err := res.RowsAffected(); err != nil {
|
|
return CaptureResult{}, fmt.Errorf("capture task: rows affected: %w", err)
|
|
} else if n > 0 {
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return CaptureResult{}, fmt.Errorf("capture task: last insert id: %w", err)
|
|
}
|
|
return CaptureResult{ID: id, Created: true}, nil
|
|
}
|
|
|
|
// Something already holds one of the keys. External identity first: that
|
|
// row may be resolved, in which case the answer is "already handled", not a
|
|
// new task.
|
|
var existing Task
|
|
if ext.Valid {
|
|
existing, err = s.lookupTaskByExternalID(ctx, ext.String)
|
|
if err != nil && !errors.Is(err, ErrTaskNotFound) {
|
|
return CaptureResult{}, err
|
|
}
|
|
}
|
|
if existing.ID == 0 {
|
|
existing, err = s.lookupLiveTaskByNorm(ctx, norm)
|
|
if err != nil {
|
|
return CaptureResult{}, err
|
|
}
|
|
}
|
|
if status == TaskOpen && existing.Status == TaskCandidate {
|
|
if err := s.SetTaskStatus(ctx, existing.ID, TaskOpen, created2, t.Source); err != nil {
|
|
return CaptureResult{}, fmt.Errorf("capture task: promote candidate: %w", err)
|
|
}
|
|
return CaptureResult{ID: existing.ID, Promoted: true}, nil
|
|
}
|
|
return CaptureResult{ID: existing.ID}, nil
|
|
}
|
|
|
|
// lookupTaskByExternalID finds a task by the identity of what it was derived
|
|
// from, in ANY status. Resolved rows count: the whole point of the key is that
|
|
// re-reading the mail that produced a finished task produces nothing.
|
|
func (s *Store) lookupTaskByExternalID(ctx context.Context, ext string) (Task, error) {
|
|
row := s.db.QueryRowContext(ctx, taskSelect+` WHERE ext_id = ?`, ext)
|
|
t, err := scanTask(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Task{}, ErrTaskNotFound
|
|
}
|
|
if err != nil {
|
|
return Task{}, fmt.Errorf("lookup task by external id: %w", err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// lookupLiveTaskByNorm finds the outstanding task with this normalised text.
|
|
func (s *Store) lookupLiveTaskByNorm(ctx context.Context, norm string) (Task, error) {
|
|
row := s.db.QueryRowContext(ctx, taskSelect+`
|
|
WHERE norm = ? AND status IN ('candidate','open')`, norm)
|
|
t, err := scanTask(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Task{}, ErrTaskNotFound
|
|
}
|
|
if err != nil {
|
|
return Task{}, fmt.Errorf("lookup live task: %w", err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
const taskSelect = `SELECT id, created_ts, text, source, evidence, COALESCE(ext_id,''), status, due_ts, weight, resolved_ts, resolved_by FROM tasks`
|
|
|
|
// LookupTask returns one task by id.
|
|
func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
|
|
row := s.db.QueryRowContext(ctx, taskSelect+` WHERE id = ?`, id)
|
|
t, err := scanTask(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Task{}, fmt.Errorf("%w: id=%d", ErrTaskNotFound, id)
|
|
}
|
|
if err != nil {
|
|
return Task{}, fmt.Errorf("lookup task: %w", err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// MaxTaskRows — the hard bound on one ListTasks read. The live set is a list a
|
|
// person keeps by hand and never approaches this; the resolved set grows for as
|
|
// long as the box runs, and an unbounded read of it is a page that gets slower
|
|
// every month. Newest first, so the bound drops the oldest finished work.
|
|
const MaxTaskRows = 500
|
|
|
|
// ListTasks returns tasks in one status, newest first, at most MaxTaskRows of
|
|
// them. An empty status returns every row; "live" returns candidate + open,
|
|
// which is what every read path that means "outstanding work" wants.
|
|
func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
|
q := taskSelect
|
|
var args []any
|
|
switch status {
|
|
case "":
|
|
case "live":
|
|
q += ` WHERE status IN (?,?)`
|
|
args = append(args, liveTaskStatuses[0], liveTaskStatuses[1])
|
|
default:
|
|
q += ` WHERE status = ?`
|
|
args = append(args, status)
|
|
}
|
|
q += fmt.Sprintf(` ORDER BY created_ts DESC, id DESC LIMIT %d`, MaxTaskRows)
|
|
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list tasks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []Task
|
|
for rows.Next() {
|
|
t, err := scanTask(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list tasks: %w", err)
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// SetTaskStatus moves a task once, forward. The legal moves are:
|
|
//
|
|
// candidate → open (the owner confirms a derived task)
|
|
// candidate → dropped (he rejects it)
|
|
// open → done (finished)
|
|
// open → dropped (abandoned)
|
|
//
|
|
// Anything else — including re-resolving a resolved task — is refused with
|
|
// ErrTaskNotFound-wrapped detail, the same one-way shape proposed_routines and
|
|
// tools use: an answered question is not answered twice.
|
|
//
|
|
// Resolving frees the NORM dedupe key, which is the point: the work can recur
|
|
// when he says it again. It does not free an external identity — see
|
|
// CaptureTask for why a re-read mailbox must not resurrect finished work.
|
|
//
|
|
// by names the caller making the move, in the source vocabulary ("tap:web",
|
|
// "tap:voice"). It is recorded on the row, so a task that turns up resolved
|
|
// says what resolved it.
|
|
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
|
var from []string
|
|
switch status {
|
|
case TaskOpen:
|
|
from = []string{TaskCandidate}
|
|
case TaskDone:
|
|
from = []string{TaskOpen}
|
|
case TaskDropped:
|
|
from = []string{TaskCandidate, TaskOpen}
|
|
default:
|
|
return fmt.Errorf("%w: %q", ErrTaskStatus, status)
|
|
}
|
|
|
|
// resolved_ts is only meaningful for a terminal state; confirming a
|
|
// candidate leaves it null (the task is still live).
|
|
var resolved sql.NullInt64
|
|
if status == TaskDone || status == TaskDropped {
|
|
resolved = sql.NullInt64{Int64: ts.UnixMilli(), Valid: true}
|
|
}
|
|
|
|
q := `UPDATE tasks SET status = ?, resolved_ts = ?, resolved_by = ? WHERE id = ? AND status IN (?` +
|
|
strings.Repeat(",?", len(from)-1) + `)`
|
|
args := []any{status, resolved, by, id}
|
|
for _, f := range from {
|
|
args = append(args, f)
|
|
}
|
|
res, err := s.db.ExecContext(ctx, q, args...)
|
|
if err != nil {
|
|
return fmt.Errorf("set task status: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("set task status: rows affected: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return fmt.Errorf("%w: id=%d not in %v", ErrTaskNotFound, id, from)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NormalizeTaskText is the dedupe key: lowercased, punctuation dropped,
|
|
// whitespace collapsed. Exported because the intake seam (and its tests) needs
|
|
// to reason about what will and will not be treated as the same task.
|
|
//
|
|
// Deliberately shallow — no stemming, no synonyms. Russian morphology would
|
|
// need a real lemmatiser to do better, and a normaliser that guesses would
|
|
// silently swallow two different tasks. This only catches the case that
|
|
// actually happens: the same sentence arriving twice with different casing or
|
|
// punctuation.
|
|
func NormalizeTaskText(s string) string {
|
|
var b strings.Builder
|
|
space := true // leading space collapses to nothing
|
|
for _, r := range strings.ToLower(s) {
|
|
switch {
|
|
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
|
b.WriteRune(r)
|
|
space = false
|
|
case !space:
|
|
b.WriteRune(' ')
|
|
space = true
|
|
}
|
|
}
|
|
return strings.TrimSpace(b.String())
|
|
}
|
|
|
|
func scanTask(sc scanner) (Task, error) {
|
|
var t Task
|
|
var created int64
|
|
var due, resolved sql.NullInt64
|
|
if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.ExternalID, &t.Status, &due, &t.Weight, &resolved, &t.ResolvedBy); err != nil {
|
|
return Task{}, err
|
|
}
|
|
t.CreatedTs = time.UnixMilli(created).UTC()
|
|
t.Due = millisToTime(due)
|
|
t.ResolvedTs = millisToTime(resolved)
|
|
return t, nil
|
|
}
|