From 7b2b96b957f996469ed1c593c7424e4629edbdb7 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 02:33:47 +0400 Subject: [PATCH] Capture tasks, with one intake seam mail can call later (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:", 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 --- cmd/mavend/actions_note.go | 6 + cmd/mavend/actions_query.go | 6 + cmd/mavend/actions_task.go | 99 +++++++++++ cmd/mavend/actions_task_test.go | 188 +++++++++++++++++++++ cmd/mavweb/main.go | 154 +++++++++++++++++ cmd/mavweb/tasks.html | 76 +++++++++ cmd/mavweb/tasks_test.go | 166 +++++++++++++++++++ internal/auth/policy.go | 11 +- internal/ipc/api.go | 69 ++++++++ internal/ipc/client.go | 21 +++ internal/ipc/server.go | 58 +++++++ internal/ipc/unimplemented.go | 9 + internal/ipc/wire.go | 3 + internal/router/task.go | 128 +++++++++++++++ internal/router/task_test.go | 60 +++++++ internal/store/migrations.go | 29 ++++ internal/store/tasks.go | 281 ++++++++++++++++++++++++++++++++ internal/store/tasks_test.go | 221 +++++++++++++++++++++++++ 18 files changed, 1584 insertions(+), 1 deletion(-) create mode 100644 cmd/mavend/actions_task.go create mode 100644 cmd/mavend/actions_task_test.go create mode 100644 cmd/mavweb/tasks.html create mode 100644 cmd/mavweb/tasks_test.go create mode 100644 internal/router/task.go create mode 100644 internal/router/task_test.go create mode 100644 internal/store/tasks.go create mode 100644 internal/store/tasks_test.go diff --git a/cmd/mavend/actions_note.go b/cmd/mavend/actions_note.go index 40e41cb..c7c20a5 100644 --- a/cmd/mavend/actions_note.go +++ b/cmd/mavend/actions_note.go @@ -11,6 +11,12 @@ import ( // actionNote handles router.IntentNote: embed the note, persist it, and // index it for recall. func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string { + // An utterance that explicitly files a task is work, not recall, and + // belongs in the task store (Vikunja #130). Checked before the embedding + // is paid for. Everything else is a note, exactly as before. + if reply, ok := h.captureTaskFromNote(ctx, dec); ok { + return reply + } // embed the note text with the same model the classifier uses, persist // via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not // facts — no predicate reads it (spec's two-memory split). diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index df1f5d5..5e27c58 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -56,6 +56,12 @@ var querySources = []querySource{ // habit marker ("обычно", "каждый", …), so a question about this coming // Wednesday still reaches the calendar. {"habits", (*reactiveHandler).queryHabits}, + // Before "calendar" and before the recall sources: "что мне нужно + // сделать?" is a question about the task list, and the notes pass would + // otherwise answer it with whatever note happens to be nearest. Its + // matcher requires a task noun or an explicit "что … сделать", so a + // date-bearing question still reaches the calendar. + {"tasks", (*reactiveHandler).queryTasks}, {"calendar", (*reactiveHandler).queryCalendar}, {"weather", (*reactiveHandler).queryWeather}, {"embed", (*reactiveHandler).queryEmbed}, diff --git a/cmd/mavend/actions_task.go b/cmd/mavend/actions_task.go new file mode 100644 index 0000000..93bc056 --- /dev/null +++ b/cmd/mavend/actions_task.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "log" + "strings" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/store" +) + +// Task capture on the voice/chat path (Vikunja #130). +// +// Two halves, both deliberately small: +// +// - captureTaskFromNote runs at the top of actionNote. An utterance that +// explicitly files a task ("добавь в задачи купить молоко") goes to the task +// store instead of the note store. Anything without an explicit marker is +// still a note — see router.ParseTaskCapture for why "надо бы поспать" must +// not become a task. +// - queryTasks is a query source that reads the list back. +// +// Nothing here speaks unprompted. Tasks are answered when asked about; no tick +// rule reads the table. + +// captureTaskFromNote claims the turn when the utterance explicitly files a +// task, returning the reply. ("", false) hands the turn back to the note path. +func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.Decision) (string, bool) { + text, ok := router.ParseTaskCapture(dec.Utterance) + if !ok { + return "", false + } + resp, err := h.api.CaptureTask(ctx, ipc.CaptureTaskReq{ + Text: text, + Source: "tap:voice", + Status: store.TaskOpen, // he stated it himself — not a candidate + Ts: h.now(), + }) + if err != nil { + log.Printf("voice: capture task: %v", err) + return "не получилось записать задачу.", true + } + if !resp.Created { + return "это уже в списке.", true + } + return "записала: " + text, true +} + +// queryTasks — "какие у меня задачи?", "что мне нужно сделать?". +// +// Reads the live set and recites it. Newest first, which is the order the store +// returns: this source has no opinion about which task matters more, and +// pretending otherwise would be a guess. Ranking is Vikunja #129. +func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, bool) { + if !router.IsTaskListQuery(t.dec.Utterance) { + return "", false + } + tasks, err := h.api.ListTasks(ctx, "live") + if err != nil { + log.Printf("voice: list tasks: %v", err) + return "не получилось посмотреть задачи.", true + } + return formatTaskListRU(tasks), true +} + +// formatTaskListRU renders the live task list the way Maven says it. Candidates +// are named as candidates — a task she pulled out of his mail is something she +// suggests, and saying it in the same breath as work he actually stated would +// put words in his mouth. +func formatTaskListRU(tasks []ipc.Task) string { + var open, cands []string + for _, t := range tasks { + switch t.Status { + case store.TaskCandidate: + cands = append(cands, t.Text) + default: + open = append(open, t.Text) + } + } + if len(open) == 0 && len(cands) == 0 { + return "задач нет." + } + var b strings.Builder + if len(open) > 0 { + b.WriteString("в списке: ") + b.WriteString(strings.Join(open, "; ")) + b.WriteString(".") + } + if len(cands) > 0 { + if b.Len() > 0 { + b.WriteString(" ") + } + b.WriteString("ещё я нашла, но ты не подтвердил: ") + b.WriteString(strings.Join(cands, "; ")) + b.WriteString(".") + } + return b.String() +} diff --git a/cmd/mavend/actions_task_test.go b/cmd/mavend/actions_task_test.go new file mode 100644 index 0000000..8e1fa1e --- /dev/null +++ b/cmd/mavend/actions_task_test.go @@ -0,0 +1,188 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" +) + +// taskAPI answers only the three task methods; every other call is +// unimplemented, which is the assertion that capture needs nothing else — in +// particular no embedder, so a filed task costs no model call. +type taskAPI struct { + ipc.UnimplementedCoreAPI + + captured []ipc.CaptureTaskReq + created bool + capErr error + + tasks []ipc.Task + listArg string + listErr error +} + +func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) { + a.captured = append(a.captured, req) + if a.capErr != nil { + return ipc.CaptureTaskResp{}, a.capErr + } + return ipc.CaptureTaskResp{ID: 1, Created: a.created}, nil +} + +func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) { + a.listArg = status + return a.tasks, a.listErr +} + +func taskNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) } + +func taskHandler(api ipc.CoreAPI) *reactiveHandler { + return &reactiveHandler{api: api, now: taskNow} +} + +func TestCaptureTaskFromNoteFilesTheTask(t *testing.T) { + api := &taskAPI{created: true} + h := taskHandler(api) + reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{ + Intent: router.IntentNote, Utterance: "добавь в задачи купить молоко", + }) + if !ok { + t.Fatal("an explicit capture must claim the turn") + } + if len(api.captured) != 1 { + t.Fatalf("captured %d, want 1", len(api.captured)) + } + got := api.captured[0] + if got.Text != "купить молоко" { + t.Errorf("text = %q, want the marker stripped", got.Text) + } + if got.Source != "tap:voice" { + t.Errorf("source = %q, want tap:voice", got.Source) + } + if got.Status != "open" { + t.Errorf("status = %q — work he stated is open, never a candidate", got.Status) + } + if !got.Ts.Equal(taskNow()) { + t.Errorf("ts = %v, want the handler clock", got.Ts) + } + if !strings.Contains(reply, "купить молоко") { + t.Errorf("reply = %q, want it to read the task back", reply) + } +} + +// A note is still a note: capture only fires on an explicit marker, so +// ordinary recall is untouched. +func TestCaptureTaskFromNotePassesOrdinaryNotes(t *testing.T) { + api := &taskAPI{} + h := taskHandler(api) + for _, u := range []string{"надо бы поспать", "мне понравился этот фильм", "запиши что я пил воду"} { + if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: u}); ok { + t.Errorf("%q was captured as a task", u) + } + } + if len(api.captured) != 0 { + t.Errorf("captured %d requests, want none", len(api.captured)) + } +} + +func TestCaptureTaskFromNoteSaysAlreadyOnTheList(t *testing.T) { + h := taskHandler(&taskAPI{created: false}) + reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь в задачи купить молоко"}) + if !ok { + t.Fatal("expected the capture path to claim it") + } + if !strings.Contains(reply, "уже") { + t.Errorf("reply = %q — a deduped capture must not claim it saved something new", reply) + } +} + +func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) { + h := taskHandler(&taskAPI{capErr: errors.New("db is on fire")}) + reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь задачу починить кран"}) + if !ok { + t.Fatal("a failed capture still claims the turn — the note path must not double-write") + } + if !strings.Contains(reply, "не получилось") { + t.Errorf("reply = %q, want an honest failure", reply) + } +} + +func TestQueryTasksRecitesTheLiveList(t *testing.T) { + api := &taskAPI{tasks: []ipc.Task{ + {ID: 1, Text: "купить молоко", Status: "open"}, + {ID: 2, Text: "продлить страховку", Status: "candidate"}, + }} + h := taskHandler(api) + reply, ok := h.queryTasks(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"}, + }) + if !ok { + t.Fatal("the task source must claim a task-list question") + } + if api.listArg != "live" { + t.Errorf("ListTasks(%q), want \"live\" — a resolved task is not outstanding work", api.listArg) + } + if !strings.Contains(reply, "купить молоко") || !strings.Contains(reply, "продлить страховку") { + t.Errorf("reply = %q, want both tasks", reply) + } + // The candidate must be named as unconfirmed, not recited as his work. + openIdx := strings.Index(reply, "купить молоко") + candIdx := strings.Index(reply, "продлить страховку") + if !(openIdx < candIdx) { + t.Errorf("reply = %q, want confirmed work before candidates", reply) + } + if !strings.Contains(reply, "не подтвердил") { + t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply) + } +} + +func TestQueryTasksEmptyList(t *testing.T) { + h := taskHandler(&taskAPI{}) + reply, ok := h.queryTasks(context.Background(), &queryTurn{ + dec: router.Decision{Utterance: "что мне нужно сделать?"}, + }) + if !ok { + t.Fatal("expected the task source to claim it") + } + if reply != "задач нет." { + t.Errorf("reply = %q", reply) + } +} + +func TestQueryTasksPassesOtherQuestions(t *testing.T) { + api := &taskAPI{} + h := taskHandler(api) + for _, u := range []string{"как дела?", "какая погода в москве?", "что у меня сегодня?"} { + if _, ok := h.queryTasks(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok { + t.Errorf("the task source claimed %q", u) + } + } + if api.listArg != "" { + t.Error("a non-task question must not read the task list") + } +} + +// The chain must reach the task source before the recall sources, or "что мне +// нужно сделать?" gets answered by whatever note is nearest. +func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) { + var tasksAt, notesAt = -1, -1 + for i, src := range querySources { + switch src.name { + case "tasks": + tasksAt = i + case "notes": + notesAt = i + } + } + if tasksAt < 0 || notesAt < 0 { + t.Fatalf("sources missing: tasks=%d notes=%d", tasksAt, notesAt) + } + if tasksAt > notesAt { + t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt) + } +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 9086f4b..953482d 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -58,6 +58,9 @@ var notificationsHTML string //go:embed reminders.html var remindersHTML string +//go:embed tasks.html +var tasksHTML string + //go:embed voice.html var voiceHTML string @@ -97,6 +100,7 @@ var sidebarSections = []struct { Pages: []struct{ Label, URL, Key string }{ {Label: "Rule Trace", URL: "/trace", Key: "trace"}, {Label: "Notifications", URL: "/notifications", Key: "notifications"}, + {Label: "Tasks", URL: "/tasks", Key: "tasks"}, {Label: "Reminders", URL: "/reminders", Key: "reminders"}, {Label: "Routines", URL: "/routines", Key: "routines"}, {Label: "Morning", URL: "/morning", Key: "morning"}, @@ -170,6 +174,8 @@ func pageIcon(key string) string { return `` case "notifications": return `` + case "tasks": + return `` case "reminders": return `` case "routines": @@ -202,6 +208,8 @@ func pageTitle(key string) string { return "Rule Trace" case "notifications": return "Notifications" + case "tasks": + return "Tasks" case "reminders": return "Reminders" case "routines": @@ -408,6 +416,11 @@ func main() { mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) { handleReminders(w, r, core) }) + // /tasks — capture + review. POST is not step-up gated; see handleTasks for + // why a task write is not in the same class as /tools or /routines. + mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) { + handleTasks(w, r, core) + }) mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) { handleMorning(w, r, core) }) @@ -744,6 +757,8 @@ var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Pars var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML)) +var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML)) + var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML)) var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap { @@ -814,6 +829,145 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { } } +// taskRow is one line on /tasks, with every timestamp already formatted so the +// template holds no date logic. +type taskRow struct { + ID int64 + Text string + Source string + Evidence string + Status string + Due string + Created string + Resolved string +} + +// handleTasks serves the task review surface (GET) and the four writes it +// offers (POST): add, confirm, done, drop. +// +// Not step-up gated, unlike /tools and /routines, and the difference is the +// point: enabling a tool defines argv Maven will execute, and accepting a +// routine hands the tick loop a new standing reason to interrupt him. A task is +// neither — nothing in the tick loop reads the tasks table, so the worst a +// weaker caller can do here is write a line onto a list he reads himself. It +// still sits behind whatever transport auth fronts mavweb, like every other +// page. +// +// "confirm" is the only interesting move: it promotes a candidate Maven derived +// from something she read into work he owns. That review step is why derived +// tasks are captured as candidates in the first place. +func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if core == nil { + http.Error(w, "tasks disabled (no -core)", http.StatusServiceUnavailable) + return + } + ctx := r.Context() + var msg, errMsg string + if r.Method == http.MethodPost { + var err error + msg, err = applyTaskPost(ctx, core, r) + if err != nil { + log.Printf("tasks: %v", err) + errMsg = err.Error() + } + } + + all, err := core.ListTasks(ctx, "") + if err != nil { + log.Printf("tasks: list: %v", err) + http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway) + return + } + var cands, open, resolved []taskRow + for _, t := range all { + row := taskRow{ + ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence, + Status: t.Status, Created: fmtTaskTime(&t.CreatedTs), + Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved), + } + switch t.Status { + case "candidate": + cands = append(cands, row) + case "open": + open = append(open, row) + default: + resolved = append(resolved, row) + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tasksTmpl.Execute(w, struct { + Msg, Err string + Candidates []taskRow + Open []taskRow + Resolved []taskRow + }{msg, errMsg, cands, open, resolved}); err != nil { + log.Printf("tasks render: %v", err) + } +} + +// applyTaskPost performs one write and returns the message to show. A bad +// request returns an error, which the page renders inline rather than as a +// bare 400 — this is a form surface, not an API. +func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) { + action := r.FormValue("action") + if action == "add" { + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + return "", errors.New("empty task text") + } + req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: time.Now()} + if d := r.FormValue("due"); d != "" { + due, err := time.ParseInLocation("2006-01-02", d, time.Local) + if err != nil { + return "", fmt.Errorf("bad due date %q", d) + } + req.Due = &due + } + resp, err := core.CaptureTask(ctx, req) + if err != nil { + return "", err + } + if !resp.Created { + return "already on the list", nil + } + return "added task", nil + } + + var id int64 + if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 { + return "", errors.New("invalid id") + } + var status, msg string + switch action { + case "confirm": + status, msg = "open", "confirmed task" + case "done": + status, msg = "done", "task done" + case "drop": + status, msg = "dropped", "dropped task" + default: + return "", fmt.Errorf("unknown action %q", action) + } + if err := core.SetTaskStatus(ctx, id, status, time.Now()); err != nil { + return "", err + } + return msg, nil +} + +func fmtTaskTime(t *time.Time) string { + if t == nil || t.IsZero() { + return "—" + } + return t.Local().Format("02 Jan 15:04") +} + +func fmtTaskDate(t *time.Time) string { + if t == nil || t.IsZero() { + return "—" + } + return t.Local().Format("02 Jan") +} + // routineRow is one line on the page: what maven noticed, in her words, and // how long ago she noticed it. type routineRow struct { diff --git a/cmd/mavweb/tasks.html b/cmd/mavweb/tasks.html new file mode 100644 index 0000000..5ebfb96 --- /dev/null +++ b/cmd/mavweb/tasks.html @@ -0,0 +1,76 @@ +{{template "shellTop" "tasks"}} +

Tasks

+{{if .Msg}}
{{.Msg}}
{{end}} +{{if .Err}}
{{.Err}}
{{end}} + +
+

add

+
+ + + + +
+
+ +{{if .Candidates}} +
+

found, not confirmed {{len .Candidates}}

+
maven derived these from something she read. nothing counts as your work until you confirm it.
+
+ +{{range .Candidates}} + + + + + + +{{end}}
taskwhere fromduecaptured
{{.Text}}{{.Source}}{{if .Evidence}} — {{.Evidence}}{{end}}{{.Due}}{{.Created}}
+ + +
+ + +
+
+{{end}} + +
+

open {{len .Open}}

+{{if .Open}}
+ +{{range .Open}} + + + + + + +{{end}}
taskfromduecaptured
{{.Text}}{{.Source}}{{.Due}}{{.Created}}
+ + +
+ + +
+{{else}}
+ +
no open tasks
+
add one above, or tell maven "добавь в задачи …"
+
{{end}} +
+ +{{if .Resolved}} +
+

resolved {{len .Resolved}}

+
+ +{{range .Resolved}} + + + +{{end}}
taskstatuswhen
{{.Text}}{{.Status}}{{.Resolved}}
+
+{{end}} +{{template "shellBottom"}} diff --git a/cmd/mavweb/tasks_test.go b/cmd/mavweb/tasks_test.go new file mode 100644 index 0000000..cdd5da7 --- /dev/null +++ b/cmd/mavweb/tasks_test.go @@ -0,0 +1,166 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" +) + +// fakeTaskCore serves the /tasks handler: a canned list plus a log of the +// writes the page made. +type fakeTaskCore struct { + ipc.UnimplementedCoreAPI + + tasks []ipc.Task + listErr error + + captured []ipc.CaptureTaskReq + created bool + captureErr error + + statusID int64 + statusVal string + statusErr error +} + +func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) { + if f.listErr != nil { + return nil, f.listErr + } + return f.tasks, nil +} + +func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) { + f.captured = append(f.captured, req) + if f.captureErr != nil { + return ipc.CaptureTaskResp{}, f.captureErr + } + return ipc.CaptureTaskResp{ID: 7, Created: f.created}, nil +} + +func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time) error { + f.statusID, f.statusVal = id, status + return f.statusErr +} + +func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) { + now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) + resolved := now.Add(time.Hour) + core := &fakeTaskCore{tasks: []ipc.Task{ + {ID: 1, Text: "купить молоко", Source: "tap:voice", Status: "open", CreatedTs: now}, + {ID: 2, Text: "продлить страховку", Source: "email:kami", Evidence: "полис истекает", Status: "candidate", CreatedTs: now}, + {ID: 3, Text: "полить цветы", Source: "tap:web", Status: "done", CreatedTs: now, Resolved: &resolved}, + }} + rec := httptest.NewRecorder() + handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + "купить молоко", "продлить страховку", "полить цветы", + "полис истекает", // the evidence trail is visible for review + "found, not confirmed", // candidates get their own section + } { + if !strings.Contains(body, want) { + t.Errorf("body missing %q", want) + } + } + // The candidate must offer confirm, and the open task must not. + if !strings.Contains(body, "value=confirm") { + t.Error("candidate row has no confirm action") + } +} + +func TestHandleTasksAddCaptures(t *testing.T) { + core := &fakeTaskCore{created: true} + form := url.Values{"action": {"add"}, "text": {" позвонить в банк "}, "due": {"2026-08-05"}} + req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handleTasks(rec, req, core) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if len(core.captured) != 1 { + t.Fatalf("captured %d requests, want 1", len(core.captured)) + } + got := core.captured[0] + if got.Text != "позвонить в банк" { + t.Errorf("text = %q, want trimmed", got.Text) + } + if got.Source != "tap:web" { + t.Errorf("source = %q, want tap:web", got.Source) + } + if got.Status != "open" { + t.Errorf("status = %q — a task he typed himself is open, not a candidate", got.Status) + } + if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" { + t.Errorf("due = %v", got.Due) + } + if !strings.Contains(rec.Body.String(), "added task") { + t.Error("no confirmation message") + } +} + +func TestHandleTasksAddSaysAlreadyOnTheList(t *testing.T) { + core := &fakeTaskCore{created: false} + form := url.Values{"action": {"add"}, "text": {"купить молоко"}} + req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handleTasks(rec, req, core) + if !strings.Contains(rec.Body.String(), "already on the list") { + t.Error("a deduped capture must not claim it saved something new") + } +} + +func TestHandleTasksStatusActions(t *testing.T) { + for _, tc := range []struct{ action, want string }{ + {"confirm", "open"}, + {"done", "done"}, + {"drop", "dropped"}, + } { + core := &fakeTaskCore{} + form := url.Values{"action": {tc.action}, "id": {"42"}} + req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + handleTasks(httptest.NewRecorder(), req, core) + if core.statusID != 42 || core.statusVal != tc.want { + t.Errorf("%s → SetTaskStatus(%d, %q), want (42, %q)", tc.action, core.statusID, core.statusVal, tc.want) + } + } +} + +func TestHandleTasksRejectsBadPost(t *testing.T) { + core := &fakeTaskCore{} + form := url.Values{"action": {"explode"}, "id": {"1"}} + req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handleTasks(rec, req, core) + // The page still renders, with the error inline — and nothing was written. + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if core.statusVal != "" || len(core.captured) != 0 { + t.Error("an unknown action must write nothing") + } + if !strings.Contains(rec.Body.String(), "unknown action") { + t.Error("error not surfaced on the page") + } +} + +func TestHandleTasksNoCore(t *testing.T) { + rec := httptest.NewRecorder() + handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), nil) + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", rec.Code) + } +} diff --git a/internal/auth/policy.go b/internal/auth/policy.go index bafe402..33873a3 100644 --- a/internal/auth/policy.go +++ b/internal/auth/policy.go @@ -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 diff --git a/internal/ipc/api.go b/internal/ipc/api.go index fe779e4..c89d520 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -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:". 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:", 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). diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 5717ee1..7048795 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -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) } diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 21fbb1f..59043f7 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -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 { diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go index daef767..cd0ed11 100644 --- a/internal/ipc/unimplemented.go +++ b/internal/ipc/unimplemented.go @@ -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 } diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index e1c4dad..a3d89c5 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -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 diff --git a/internal/router/task.go b/internal/router/task.go new file mode 100644 index 0000000..248627a --- /dev/null +++ b/internal/router/task.go @@ -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 +} diff --git a/internal/router/task_test.go b/internal/router/task_test.go new file mode 100644 index 0000000..2fb6984 --- /dev/null +++ b/internal/router/task_test.go @@ -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) + } + } +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go index d59b54a..163ad83 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -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 diff --git a/internal/store/tasks.go b/internal/store/tasks.go new file mode 100644 index 0000000..b211ff6 --- /dev/null +++ b/internal/store/tasks.go @@ -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:" 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 +} diff --git a/internal/store/tasks_test.go b/internal/store/tasks_test.go new file mode 100644 index 0000000..9cf92c8 --- /dev/null +++ b/internal/store/tasks_test.go @@ -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) + } + } +}