Capture tasks, with one intake seam mail can call later (#130)
A task is not a fact and not a note. A fact is a claim about the world that a correction supersedes; a note is something to recall by meaning. A task is work with a lifecycle, and the read that matters is "everything outstanding right now" — which over an append-only log would mean replaying history on every question. So: a tasks table, migration #14, statuses candidate/open/done/dropped that each move forward exactly once. Dedupe is on normalised text among LIVE rows only, via a partial unique index. That is the property the mail side needs: an extractor may call CaptureTask for every message it reads, as often as it likes, without growing the list — while a weekly errand is still capturable again once the last one is done. Three ways in, one seam. ipc.CaptureTaskReq is it: the voice path (router.ParseTaskCapture on an explicit marker — "добавь в задачи …", never "надо бы поспать"), the /tasks form, and the email reader from #246 when it exists. Mail-derived items set Source "email:<account>", Status "candidate" and Evidence to whatever makes the row reviewable; a candidate is inert until he confirms it on /tasks, and Maven names it as unconfirmed when she recites the list rather than putting words in his mouth. No new intent — the router enum is a contract with the relabelling prompt, so capture rides the note intent and the list rides a query source, both matched deterministically like the calendar and plan matchers already are. Nothing here speaks. No tick rule reads tasks; the list is answered when asked about, which is why /tasks POST is not step-up gated the way /tools and /routines are — a task write moves no boundary. Vikunja #130
This commit is contained in:
+10
-1
@@ -65,7 +65,16 @@ func Requirement(m ipc.Method) Authority {
|
||||
ipc.MethodCreateReminder,
|
||||
ipc.MethodMarkReminder,
|
||||
ipc.MethodRecordNudge,
|
||||
ipc.MethodResolveNudge:
|
||||
ipc.MethodResolveNudge,
|
||||
// Task capture (Vikunja #130). Listed explicitly rather than left to
|
||||
// the default so the intent is on the record: capturing a task is a
|
||||
// module write, not an allowlist mutation and not a new standing reason
|
||||
// for Maven to speak — nothing in the tick loop reads tasks. It stays
|
||||
// at AuthRead, the same rung as CreateReminder, which is the closest
|
||||
// existing analogue.
|
||||
ipc.MethodCaptureTask,
|
||||
ipc.MethodListTasks,
|
||||
ipc.MethodSetTaskStatus:
|
||||
return AuthRead
|
||||
}
|
||||
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
|
||||
|
||||
@@ -93,6 +93,63 @@ type WriteFactReq struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
}
|
||||
|
||||
// Task — one captured piece of work (Vikunja #130). Status is
|
||||
// "candidate" (Maven derived it and it is unconfirmed), "open" (his work),
|
||||
// "done" or "dropped". Source is provenance in the facts vocabulary:
|
||||
// "tap:voice", "tap:web", "email:<account>". Evidence is the trail a derived
|
||||
// task came from, empty for anything he stated himself.
|
||||
type Task struct {
|
||||
ID int64 `json:"id"`
|
||||
CreatedTs time.Time `json:"created_ts"`
|
||||
Text string `json:"text"`
|
||||
Source string `json:"source"`
|
||||
Evidence string `json:"evidence,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Due *time.Time `json:"due,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
Resolved *time.Time `json:"resolved,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
|
||||
// through this one shape: the voice path, the web form, and (Vikunja #246) the
|
||||
// email reader, which has not been built yet.
|
||||
//
|
||||
// An extractor that reads mail sets Source "email:<account>", Status
|
||||
// "candidate", and Evidence to whatever makes the task reviewable (the subject
|
||||
// line). It must NOT set Status "open" — work Maven inferred from something she
|
||||
// read is a suggestion until the owner confirms it on the /tasks page. Capture
|
||||
// is idempotent on normalised text among live tasks, so re-reading the same
|
||||
// mailbox is free.
|
||||
type CaptureTaskReq struct {
|
||||
Text string `json:"text"`
|
||||
Source string `json:"source"`
|
||||
Evidence string `json:"evidence,omitempty"`
|
||||
Status string `json:"status,omitempty"` // "" ⇒ open
|
||||
Due *time.Time `json:"due,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
Ts time.Time `json:"ts"`
|
||||
}
|
||||
|
||||
// CaptureTaskResp — Created is false when the same live task already existed,
|
||||
// in which case ID is the existing row. A caller tells the owner "уже в
|
||||
// списке" rather than claiming it saved something new.
|
||||
type CaptureTaskResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Created bool `json:"created"`
|
||||
}
|
||||
|
||||
type listTasksReq struct {
|
||||
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
|
||||
}
|
||||
type listTasksResp struct {
|
||||
Tasks []Task `json:"tasks"`
|
||||
}
|
||||
type setTaskStatusReq struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Ts time.Time `json:"ts"`
|
||||
}
|
||||
|
||||
// idReq — methods keyed by a single id.
|
||||
type idReq struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -294,6 +351,18 @@ type CoreAPI interface {
|
||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||
|
||||
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
||||
// intake seam for the voice path, the web form and the future email
|
||||
// extractor. Idempotent per live normalised text; the response says
|
||||
// whether a row was actually created.
|
||||
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
||||
// ListTasks returns tasks in one status, newest first. "" is every row,
|
||||
// "live" is candidate + open (outstanding work).
|
||||
ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
||||
// open→done|dropped. Any other move is refused.
|
||||
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error
|
||||
|
||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
||||
// this after every tick; the store adapter returns an error (trace is not
|
||||
// persisted — it's a daemon-level cache).
|
||||
|
||||
@@ -69,6 +69,7 @@ var readOnlyMethods = map[Method]bool{
|
||||
MethodLookupTool: true,
|
||||
MethodListTools: true,
|
||||
MethodListProposedRoutines: true,
|
||||
MethodListTasks: true,
|
||||
MethodTickTrace: true,
|
||||
MethodMorningStatus: true,
|
||||
MethodDayPlan: true,
|
||||
@@ -427,6 +428,26 @@ func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, e
|
||||
return r.Routines, nil
|
||||
}
|
||||
|
||||
func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
var r CaptureTaskResp
|
||||
if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
|
||||
return CaptureTaskResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
var r listTasksResp
|
||||
if err := c.call(ctx, MethodListTasks, listTasksReq{Status: status}, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Tasks, nil
|
||||
}
|
||||
|
||||
func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
||||
}
|
||||
|
||||
@@ -231,6 +231,48 @@ func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
return mapErr(a.s.DeleteTool(ctx, name))
|
||||
}
|
||||
|
||||
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
id, created, err := a.s.CaptureTask(ctx, store.Task{
|
||||
CreatedTs: req.Ts,
|
||||
Text: req.Text,
|
||||
Source: req.Source,
|
||||
Evidence: req.Evidence,
|
||||
Status: req.Status,
|
||||
Due: req.Due,
|
||||
Weight: req.Weight,
|
||||
})
|
||||
if err != nil {
|
||||
return CaptureTaskResp{}, mapErr(err)
|
||||
}
|
||||
return CaptureTaskResp{ID: id, Created: created}, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
ts, err := a.s.ListTasks(ctx, status)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Task, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = Task{
|
||||
ID: t.ID,
|
||||
CreatedTs: t.CreatedTs,
|
||||
Text: t.Text,
|
||||
Source: t.Source,
|
||||
Evidence: t.Evidence,
|
||||
Status: t.Status,
|
||||
Due: t.Due,
|
||||
Weight: t.Weight,
|
||||
Resolved: t.ResolvedTs,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts))
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
rs, err := a.s.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
@@ -700,6 +742,22 @@ var methodTable = map[Method]handlerFunc{
|
||||
MethodDeleteTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p disableToolReq) error {
|
||||
return api.DeleteTool(ctx, p.Name)
|
||||
}),
|
||||
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
return api.CaptureTask(ctx, p)
|
||||
}),
|
||||
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
|
||||
out, err := api.ListTasks(ctx, p.Status)
|
||||
if err != nil {
|
||||
return listTasksResp{}, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Task{}
|
||||
}
|
||||
return listTasksResp{Tasks: out}, nil
|
||||
}),
|
||||
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
|
||||
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts)
|
||||
}),
|
||||
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
|
||||
out, err := api.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -89,6 +89,15 @@ func (UnimplementedCoreAPI) DisableTool(ctx context.Context, name string) error
|
||||
func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
return CaptureTaskResp{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ const (
|
||||
MethodMorningStatus Method = "morning_status"
|
||||
MethodDayPlan Method = "day_plan"
|
||||
MethodChat Method = "chat"
|
||||
MethodCaptureTask Method = "capture_task"
|
||||
MethodListTasks Method = "list_tasks"
|
||||
MethodSetTaskStatus Method = "set_task_status"
|
||||
)
|
||||
|
||||
// Request — one frame from module to core. Params is the JSON-encoded argument
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
|
||||
// Task capture and task listing, matched deterministically (Vikunja #130).
|
||||
//
|
||||
// No new intent. The router's intent enum is a contract shared with the
|
||||
// relabelling prompt in the training workspace (`llm/check_prompt_parity.py`
|
||||
// enforces it), so adding an eighth intent would mean retraining before a task
|
||||
// could be captured at all. A task phrased out loud is a note-shaped or
|
||||
// query-shaped utterance with an explicit marker in it, and the marker is a
|
||||
// lookup — the same reasoning the calendar, plan and habit matchers already
|
||||
// follow. What the model classifies is unchanged; what these functions decide
|
||||
// is which store the turn lands in.
|
||||
|
||||
// taskCapturePrefixes — the leading phrases that mean "put this on the list".
|
||||
// A prefix, not a keyword anywhere in the sentence: "добавь в задачи купить
|
||||
// молоко" is a capture, "я не добавил молоко в список" is him talking, and only
|
||||
// position tells them apart.
|
||||
//
|
||||
// Everything here is an explicit instruction. There is deliberately no entry
|
||||
// for "надо" / "нужно" — "надо бы поспать" is a thing he says, not a task he
|
||||
// files, and a capture path that guesses would fill the list with his moods.
|
||||
var taskCapturePrefixes = []string{
|
||||
"добавь в задачи",
|
||||
"добавь в список задач",
|
||||
"добавь в список дел",
|
||||
"добавь в список",
|
||||
"добавь задачу",
|
||||
"запиши в задачи",
|
||||
"запиши задачу",
|
||||
"новая задача",
|
||||
"в задачи",
|
||||
"add a task",
|
||||
"add task",
|
||||
"add to my tasks",
|
||||
"add to tasks",
|
||||
"new task",
|
||||
}
|
||||
|
||||
// ParseTaskCapture reports whether an utterance explicitly files a task, and
|
||||
// returns the task text with the marker stripped. A marker with nothing after it
|
||||
// is not a capture (there is no task in "добавь в задачи") — the caller falls
|
||||
// through to whatever it would otherwise have done with the turn.
|
||||
func ParseTaskCapture(text string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
lower := strings.ToLower(trimmed)
|
||||
best := ""
|
||||
for _, p := range taskCapturePrefixes {
|
||||
if strings.HasPrefix(lower, p) && len(p) > len(best) {
|
||||
best = p
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return "", false
|
||||
}
|
||||
// Cut on the rune length of the matched prefix. ToLower does not change the
|
||||
// byte length of Russian or English letters, so the index carries over.
|
||||
rest := strings.TrimSpace(trimmed[len(best):])
|
||||
rest = strings.TrimLeft(rest, ":—- ")
|
||||
rest = strings.TrimSpace(rest)
|
||||
rest = strings.TrimRight(rest, ".!")
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// taskListWords — the nouns that make a question be about the task list.
|
||||
var taskListWords = []string{"задачи", "задачах", "задач", "задачам", "дела", "делах", "дел", "tasks", "todo", "todos"}
|
||||
|
||||
// taskListVerbs — the asks that pair with those nouns. "что мне нужно сделать?"
|
||||
// has no task noun in it at all, so it is matched as a phrase below.
|
||||
var taskListWordsShortcut = []string{"задачи", "задач", "tasks"}
|
||||
|
||||
// IsTaskListQuery reports whether an utterance asks for the outstanding task
|
||||
// list — "какие у меня задачи?", "что мне нужно сделать?", "список дел".
|
||||
//
|
||||
// Narrow on purpose. "как дела?" is a greeting, not a query about work, and it
|
||||
// contains a task noun; it is excluded explicitly. Anything that mentions a
|
||||
// task noun without asking for the list falls through to ordinary recall.
|
||||
func IsTaskListQuery(text string) bool {
|
||||
toks := planTokens(text)
|
||||
if len(toks) == 0 {
|
||||
return false
|
||||
}
|
||||
// "как дела" — the greeting. Excluded before anything else matches.
|
||||
if hasTok(toks, "как") && (hasTok(toks, "дела") || hasTok(toks, "делишки")) {
|
||||
return false
|
||||
}
|
||||
// "что мне нужно сделать" / "что мне делать" — no task noun at all.
|
||||
if (hasTok(toks, "что") || hasTok(toks, "чем")) &&
|
||||
(hasTok(toks, "сделать") || hasTok(toks, "заняться")) {
|
||||
return true
|
||||
}
|
||||
if hasTok(toks, "what") && hasTok(toks, "do") {
|
||||
return true
|
||||
}
|
||||
hasNoun := false
|
||||
for _, t := range toks {
|
||||
for _, w := range taskListWords {
|
||||
if t == w {
|
||||
hasNoun = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasNoun {
|
||||
return false
|
||||
}
|
||||
// A task noun plus any of: a question word, "список", or a bare
|
||||
// one/two-word ask ("задачи", "мои задачи").
|
||||
if hasTok(toks, "какие") || hasTok(toks, "какая") || hasTok(toks, "что") ||
|
||||
hasTok(toks, "сколько") || hasTok(toks, "список") || hasTok(toks, "покажи") ||
|
||||
hasTok(toks, "напомни") || hasTok(toks, "my") || hasTok(toks, "list") ||
|
||||
hasTok(toks, "show") {
|
||||
return true
|
||||
}
|
||||
if len(toks) <= 2 {
|
||||
for _, t := range toks {
|
||||
for _, w := range taskListWordsShortcut {
|
||||
if t == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTaskCapture(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
text string
|
||||
ok bool
|
||||
}{
|
||||
{"добавь в задачи купить молоко", "купить молоко", true},
|
||||
{"Добавь в список дел: позвонить в банк", "позвонить в банк", true},
|
||||
{"запиши задачу починить кран.", "починить кран", true},
|
||||
{"новая задача — оплатить интернет", "оплатить интернет", true},
|
||||
{"add a task buy milk", "buy milk", true},
|
||||
// A marker with nothing after it files nothing.
|
||||
{"добавь в задачи", "", false},
|
||||
{"новая задача", "", false},
|
||||
// Not a capture: he is talking, not filing.
|
||||
{"надо бы поспать", "", false},
|
||||
{"я не добавил молоко в список", "", false},
|
||||
{"какие у меня задачи?", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
text, ok := ParseTaskCapture(c.in)
|
||||
if ok != c.ok || text != c.text {
|
||||
t.Errorf("ParseTaskCapture(%q) = (%q, %v), want (%q, %v)", c.in, text, ok, c.text, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTaskListQuery(t *testing.T) {
|
||||
yes := []string{
|
||||
"какие у меня задачи?",
|
||||
"что мне нужно сделать?",
|
||||
"покажи список дел",
|
||||
"сколько у меня задач?",
|
||||
"задачи",
|
||||
"мои задачи",
|
||||
"what should I do",
|
||||
}
|
||||
for _, s := range yes {
|
||||
if !IsTaskListQuery(s) {
|
||||
t.Errorf("IsTaskListQuery(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
no := []string{
|
||||
"как дела?",
|
||||
"какая погода?",
|
||||
"напомни мне позвонить маме в шесть",
|
||||
"я сделал зарядку",
|
||||
"",
|
||||
}
|
||||
for _, s := range no {
|
||||
if IsTaskListQuery(s) {
|
||||
t.Errorf("IsTaskListQuery(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,35 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
expires_ts INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_digest_entries_status ON digest_entries (status);`,
|
||||
|
||||
// #14 — the task capture store (Vikunja #130). Deliberately NOT facts:
|
||||
// a fact is a claim about the world that gets superseded, a task is a
|
||||
// piece of work with a lifecycle (captured → open → done), and the
|
||||
// prioritiser needs to read the live set cheaply.
|
||||
//
|
||||
// status: 'candidate' is a task Maven derived from something she read
|
||||
// (mail, later) and has NOT been confirmed by the owner; 'open' is a task
|
||||
// he actually stated (or confirmed). Nothing schedules or announces off
|
||||
// this table — capture is not a nag.
|
||||
//
|
||||
// norm is the normalised dedupe key. The unique index is PARTIAL, over
|
||||
// live rows only: re-capturing "купить молоко" after last week's one is
|
||||
// done must work, while the same mail arriving twice must not produce two
|
||||
// rows.
|
||||
`CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_ts INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
norm TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
evidence TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('candidate','open','done','dropped')),
|
||||
due_ts INTEGER,
|
||||
weight INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_ts INTEGER
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_live_norm ON tasks (norm) WHERE status IN ('candidate','open');
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status, created_ts DESC);`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
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.
|
||||
type Task struct {
|
||||
ID int64
|
||||
CreatedTs time.Time
|
||||
Text string
|
||||
Source string
|
||||
Evidence string
|
||||
Status string
|
||||
Due *time.Time
|
||||
Weight int
|
||||
ResolvedTs *time.Time
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
// CaptureTask inserts a task, or returns the existing live task when the same
|
||||
// work is already outstanding. created reports which happened, so a caller can
|
||||
// tell the owner "уже в списке" instead of pretending it wrote something.
|
||||
//
|
||||
// Dedupe is on the normalised text among LIVE rows only (see the partial unique
|
||||
// index in migration #14): a weekly errand can be captured again once the last
|
||||
// one is done, but a mail that gets re-read produces no second row. 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.
|
||||
func (s *Store) CaptureTask(ctx context.Context, t Task) (id int64, created bool, err error) {
|
||||
text := strings.TrimSpace(t.Text)
|
||||
if text == "" {
|
||||
return 0, false, 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 0, false, fmt.Errorf("%w: capture status %q", ErrTaskStatus, status)
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO tasks (created_ts, text, norm, source, evidence, status, due_ts, weight)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT (norm) WHERE status IN ('candidate','open') DO NOTHING`,
|
||||
created2.UnixMilli(), text, norm, t.Source, t.Evidence, status, due, t.Weight)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("capture task: %w", err)
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return 0, false, fmt.Errorf("capture task: rows affected: %w", err)
|
||||
} else if n > 0 {
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("capture task: last insert id: %w", err)
|
||||
}
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
// Already live — hand back the row that won.
|
||||
existing, err := s.lookupLiveTaskByNorm(ctx, norm)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return existing.ID, false, 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, status, due_ts, weight, resolved_ts 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
|
||||
}
|
||||
|
||||
// ListTasks returns tasks in one status, newest first. 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 += ` ORDER BY created_ts DESC, id DESC`
|
||||
|
||||
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 dedupe key, which is the point: the work can recur.
|
||||
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) 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 = ? WHERE id = ? AND status IN (?` +
|
||||
strings.Repeat(",?", len(from)-1) + `)`
|
||||
args := []any{status, resolved, 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.Status, &due, &t.Weight, &resolved); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
t.CreatedTs = time.UnixMilli(created).UTC()
|
||||
t.Due = millisToTime(due)
|
||||
t.ResolvedTs = millisToTime(resolved)
|
||||
return t, nil
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCaptureTaskDedupesLiveWork(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
id, created, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("first capture must create a row")
|
||||
}
|
||||
|
||||
// Same work, different casing and punctuation — one task, not two.
|
||||
again, created, err := st.CaptureTask(ctx, Task{Text: "Купить молоко!", Source: "email:kami", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created {
|
||||
t.Error("second capture of the same live work must not create a row")
|
||||
}
|
||||
if again != id {
|
||||
t.Errorf("dedupe returned id %d, want the existing %d", again, id)
|
||||
}
|
||||
|
||||
live, err := st.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(live) != 1 {
|
||||
t.Fatalf("live tasks = %d, want 1", len(live))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskAfterDoneIsANewTask(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
id, _, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, id, TaskDone, now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The dedupe key is free again: a recurring errand must be capturable.
|
||||
id2, created, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now.AddDate(0, 0, 7)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !created || id2 == id {
|
||||
t.Fatalf("re-capture after done: created=%v id=%d (previous %d)", created, id2, id)
|
||||
}
|
||||
live, err := st.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(live) != 1 || live[0].ID != id2 {
|
||||
t.Fatalf("live = %+v, want only the new task %d", live, id2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
due := now.Add(48 * time.Hour)
|
||||
|
||||
id, _, err := st.CaptureTask(ctx, Task{
|
||||
Text: "продлить страховку",
|
||||
Source: "email:kami",
|
||||
Evidence: "Re: страховой полис истекает",
|
||||
Status: TaskCandidate,
|
||||
Due: &due,
|
||||
Weight: 2,
|
||||
CreatedTs: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.LookupTask(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != TaskCandidate {
|
||||
t.Errorf("status = %q, want candidate", got.Status)
|
||||
}
|
||||
if got.Evidence != "Re: страховой полис истекает" {
|
||||
t.Errorf("evidence = %q", got.Evidence)
|
||||
}
|
||||
if got.Due == nil || !got.Due.Equal(due.UTC()) {
|
||||
t.Errorf("due = %v, want %v", got.Due, due.UTC())
|
||||
}
|
||||
if got.Weight != 2 {
|
||||
t.Errorf("weight = %d, want 2", got.Weight)
|
||||
}
|
||||
if got.ResolvedTs != nil {
|
||||
t.Errorf("resolved_ts = %v on a live task, want nil", got.ResolvedTs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
cand, _, err := st.CaptureTask(ctx, Task{Text: "записаться к врачу", Source: "email:kami", Status: TaskCandidate, CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// candidate → done is not a legal move: he has to confirm it first.
|
||||
if err := st.SetTaskStatus(ctx, cand, TaskDone, now); !errors.Is(err, ErrTaskNotFound) {
|
||||
t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, cand, TaskOpen, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, cand, TaskDone, now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Already resolved — a second resolve must not move it again.
|
||||
if err := st.SetTaskStatus(ctx, cand, TaskDropped, now.Add(2*time.Hour)); !errors.Is(err, ErrTaskNotFound) {
|
||||
t.Errorf("second resolve err = %v, want ErrTaskNotFound", err)
|
||||
}
|
||||
got, err := st.LookupTask(ctx, cand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != TaskDone {
|
||||
t.Errorf("status = %q, want done", got.Status)
|
||||
}
|
||||
if got.ResolvedTs == nil || !got.ResolvedTs.Equal(now.Add(time.Hour).UTC()) {
|
||||
t.Errorf("resolved_ts = %v, want %v", got.ResolvedTs, now.Add(time.Hour).UTC())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
id, _, err := st.CaptureTask(ctx, Task{Text: "что-то", Source: "tap:web"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, id, "candidate", time.Now()); !errors.Is(err, ErrTaskStatus) {
|
||||
t.Errorf("→candidate err = %v, want ErrTaskStatus", err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, id, "urgent", time.Now()); !errors.Is(err, ErrTaskStatus) {
|
||||
t.Errorf("→urgent err = %v, want ErrTaskStatus", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskRejectsEmptyText(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
if _, _, err := st.CaptureTask(ctx, Task{Text: " ", Source: "tap:voice"}); !errors.Is(err, ErrTaskEmpty) {
|
||||
t.Errorf("err = %v, want ErrTaskEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTasksFiltersByStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
open1, _, _ := st.CaptureTask(ctx, Task{Text: "первая", Source: "tap:voice", CreatedTs: now})
|
||||
_, _, _ = st.CaptureTask(ctx, Task{Text: "вторая", Source: "email:kami", Status: TaskCandidate, CreatedTs: now.Add(time.Minute)})
|
||||
done, _, _ := st.CaptureTask(ctx, Task{Text: "третья", Source: "tap:voice", CreatedTs: now.Add(2 * time.Minute)})
|
||||
if err := st.SetTaskStatus(ctx, done, TaskDone, now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cands, err := st.ListTasks(ctx, TaskCandidate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cands) != 1 || cands[0].Text != "вторая" {
|
||||
t.Fatalf("candidates = %+v", cands)
|
||||
}
|
||||
opens, err := st.ListTasks(ctx, TaskOpen)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(opens) != 1 || opens[0].ID != open1 {
|
||||
t.Fatalf("open = %+v", opens)
|
||||
}
|
||||
all, err := st.ListTasks(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("all = %d, want 3", len(all))
|
||||
}
|
||||
// Newest first.
|
||||
if all[0].Text != "третья" {
|
||||
t.Errorf("first = %q, want newest ('третья')", all[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTaskText(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"Купить молоко!", "купить молоко"},
|
||||
{" купить МОЛОКО ", "купить молоко"},
|
||||
{"позвонить в банк (важно)", "позвонить в банк важно"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := NormalizeTaskText(c.in); got != c.want {
|
||||
t.Errorf("NormalizeTaskText(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user