Files
claude aaf1f0236b store/tasks: dedupe live-status literal against the named constants (V-581)
lookupLiveTaskByNorm hardcoded 'candidate','open' in SQL, drifting from
liveTaskStatuses which ListTasks already uses for the same query. Bind
the constants instead so there is one place that names the live set.
2026-08-06 02:10:22 +04:00

499 lines
19 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
// DoneWhen — the acceptance criterion, in his words. It must be able to
// close on either outcome: "it already works" counts as complete, and a
// criterion only one result satisfies is a wish rather than a definition
// (Vikunja #510). Empty until he writes one.
DoneWhen string
// BlockedOn — a canonical Nexus entity id, never a name. Identity lives in
// Nexus, so storing "Саша" here would be a second answer to a question
// Nexus already owns. Empty when nothing blocks the task.
BlockedOn 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")
// ErrTaskNoDoneWhen — a candidate cannot be promoted to open without a
// definition of done (Vikunja #510). Same refusal ParseTaskCapture makes
// for a capture marker with nothing after it: confirming work whose
// finish line nobody wrote is how a board fills with rows that can never
// leave it. Dropping such a candidate stays legal.
ErrTaskNoDoneWhen = errors.New("store: task has no definition of done")
// ErrTaskDuplicate — an edit would give this task the normalised text of
// another live row (Vikunja #509). A refusal, not a merge: two live rows
// carry two provenances, two capture times and possibly two external
// identities, and merging picks a winner for all three silently. The
// surface tells the owner which row already holds the text and lets him
// drop one.
ErrTaskDuplicate = errors.New("store: another live task already has this text")
// ErrTaskResolved — a resolved task is not editable. Its text is the
// record of what was finished, and rewriting it rewrites history.
ErrTaskResolved = errors.New("store: task is resolved")
)
// 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, done_when, blocked_on)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT DO NOTHING`,
created2.UnixMilli(), text, norm, t.Source, t.Evidence, ext, status, due, t.Weight, t.DoneWhen, t.BlockedOn)
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, false); 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 (?,?)`, norm, liveTaskStatuses[0], liveTaskStatuses[1])
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, done_when, blocked_on 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 {
return s.setTaskStatus(ctx, id, status, ts, by, true)
}
// setTaskStatus — the move, with the promotion gate optional.
//
// It is optional for exactly one caller: CaptureTask promoting a candidate he
// stated out loud (Vikunja #510). Refusing there would deny intake rather than
// ask for a criterion, and a direct open capture never had one either — the gate
// belongs to the deliberate promotion on /tasks, where there is a form to fill.
func (s *Store) setTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string, gateDoneWhen bool) 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)
}
if status == TaskOpen && gateDoneWhen {
// Promotion needs an acceptance criterion. Checked in the same
// statement rather than read-then-write, so two callers confirming one
// candidate cannot race past it; the row is read afterwards only to say
// WHICH refusal this was.
q += ` AND done_when <> ''`
}
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 {
if status == TaskOpen && gateDoneWhen {
if t, lookErr := s.LookupTask(ctx, id); lookErr == nil && t.Status == TaskCandidate && t.DoneWhen == "" {
return fmt.Errorf("%w: id=%d", ErrTaskNoDoneWhen, id)
}
}
return fmt.Errorf("%w: id=%d not in %v", ErrTaskNotFound, id, from)
}
return nil
}
// EditTask rewrites the three fields capture set and nothing else: text, due
// date and weight (Vikunja #509). Status stays the one-way ladder SetTaskStatus
// owns, and a resolved task is refused outright — its text is the record of
// what was finished.
//
// Editing text re-normalises the dedupe key, which can collide with another
// live row. That is ErrTaskDuplicate and it is a refusal: merging would pick
// one row's provenance, capture time and external identity over the other's
// with nobody asked.
//
// due nil clears the date. Clearing has to be sayable, so an absent date and
// "remove the date" cannot be the same argument.
func (s *Store) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
text = strings.TrimSpace(text)
if text == "" {
return ErrTaskEmpty
}
cur, err := s.LookupTask(ctx, id)
if err != nil {
return err
}
if cur.Status != TaskCandidate && cur.Status != TaskOpen {
return fmt.Errorf("%w: id=%d is %s", ErrTaskResolved, id, cur.Status)
}
norm := NormalizeTaskText(text)
if norm != NormalizeTaskText(cur.Text) {
if other, err := s.lookupLiveTaskByNorm(ctx, norm); err == nil && other.ID != id {
return fmt.Errorf("%w: id=%d holds it", ErrTaskDuplicate, other.ID)
} else if err != nil && !errors.Is(err, ErrTaskNotFound) {
return err
}
}
var dueVal sql.NullInt64
if due != nil {
dueVal = sql.NullInt64{Int64: due.UnixMilli(), Valid: true}
}
if _, err := s.db.ExecContext(ctx,
`UPDATE tasks SET text = ?, norm = ?, due_ts = ?, weight = ? WHERE id = ?`,
text, norm, dueVal, weight, id); err != nil {
return fmt.Errorf("edit task: %w", err)
}
return nil
}
// SetTaskFields writes the two board columns. Separate from SetTaskStatus
// because a status move is one-way and these are not: he may sharpen a
// definition of done, and a blocker clears when the person answers.
//
// blockedOn is a canonical Nexus entity id or empty. Free text does not belong
// here — identity lives in Nexus, and a local name would be a second answer to
// a question Nexus already owns. The caller resolves before it writes.
func (s *Store) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE tasks SET done_when = ?, blocked_on = ? WHERE id = ?`,
strings.TrimSpace(doneWhen), strings.TrimSpace(blockedOn), id)
if err != nil {
return fmt.Errorf("set task fields: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("set task fields: rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("%w: id=%d", ErrTaskNotFound, id)
}
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, &t.DoneWhen, &t.BlockedOn); err != nil {
return Task{}, err
}
t.CreatedTs = time.UnixMilli(created).UTC()
t.Due = millisToTime(due)
t.ResolvedTs = millisToTime(resolved)
return t, nil
}