so it pans on a phone")
+ }
+}
+
+// Accepting hands the loop a new reason to speak, so it needs step-up.
+func TestHandleRoutines_Accept_RequiresStepUp(t *testing.T) {
+ core := weeklyRoutineCore()
+ rr := httptest.NewRecorder()
+ handleRoutines(rr, postRoutine("accept", "3"), core, webauthn.NewPasskeySession(5*time.Minute), false)
+ if rr.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want 403", rr.Code)
+ }
+ if core.acceptedID != 0 {
+ t.Fatal("accepted without step-up")
+ }
+}
+
+func TestHandleRoutines_Accept_CreatesReminderAndLinksIt(t *testing.T) {
+ core := weeklyRoutineCore()
+ rr := httptest.NewRecorder()
+ handleRoutines(rr, postRoutine("accept", "3"), core, stepUpSession(), false)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
+ }
+ if core.acceptedID != 3 || core.acceptedRe != 77 {
+ t.Fatalf("accepted id=%d reminder=%d, want 3 and 77", core.acceptedID, core.acceptedRe)
+ }
+ if core.remCron == "" {
+ t.Fatal("a weekly pattern should get a cron expression")
+ }
+}
+
+// Dismiss only ever removes a reason to speak, so it is not step-up gated.
+func TestHandleRoutines_Dismiss_NoStepUpNeeded(t *testing.T) {
+ core := weeklyRoutineCore()
+ rr := httptest.NewRecorder()
+ handleRoutines(rr, postRoutine("dismiss", "3"), core, webauthn.NewPasskeySession(5*time.Minute), false)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
+ }
+ if core.dismissed != 3 {
+ t.Fatalf("dismissed = %d, want 3", core.dismissed)
+ }
+}
+
+func TestHandleRoutines_UnknownAction_400(t *testing.T) {
+ rr := httptest.NewRecorder()
+ handleRoutines(rr, postRoutine("frobnicate", "3"), weeklyRoutineCore(), stepUpSession(), false)
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", rr.Code)
+ }
+}
+
+func TestHandleRoutines_BadID_400(t *testing.T) {
+ rr := httptest.NewRecorder()
+ handleRoutines(rr, postRoutine("dismiss", "nope"), weeklyRoutineCore(), stepUpSession(), false)
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", rr.Code)
+ }
+}
+
+func TestHandleRoutines_NilCore_503(t *testing.T) {
+ rr := httptest.NewRecorder()
+ handleRoutines(rr, httptest.NewRequest(http.MethodGet, "/routines", nil), nil, nil, false)
+ if rr.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503", rr.Code)
+ }
+}
diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go
index a6ae3a9..bfda129 100644
--- a/cmd/mavweb/main.go
+++ b/cmd/mavweb/main.go
@@ -24,6 +24,7 @@ import (
"github.com/coder/websocket"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/voice"
"github.com/kami/maven/internal/webauthn"
)
@@ -395,9 +396,6 @@ func main() {
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
handleReminders(w, r, core)
})
- mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
- handleRoutines(w, r, core)
- })
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
handleMorning(w, r, core)
})
@@ -450,6 +448,11 @@ func main() {
mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) {
handleTools(w, r, core, stepUpSession, *requireStepUp)
})
+ // /routines — the authed accept surface. Registered here, next to /tools,
+ // because accepting shares the same step-up gate.
+ mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
+ handleRoutines(w, r, core, stepUpSession, *requireStepUp)
+ })
// /api/revert voids the latest fact for a key — a store mutation, so it
// sits behind the same passkey step-up as tool enable (nil session ⇒
@@ -680,22 +683,24 @@ const toolsHTML = `{{template "shellTop" "tools"}}
{{template "shellBottom"}}`
-// routinesHTML — proposed routine review surface. Lists detected patterns
-// awaiting human confirmation, with accept (→ reminder) and dismiss buttons.
+// routinesHTML — proposed routine review surface. One row per thing maven
+// noticed, in her words, with at most two actions: accept or dismiss.
const routinesHTML = `{{template "shellTop" "routines"}}
Routines
{{if .Msg}}
{{.Msg}}
{{end}}
-
proposed {{len .Proposed}}
-{{if .Proposed}}
action
object
every
+
noticed {{len .Proposed}}
+{{if .Proposed}}
maven noticed
when
{{range .Proposed}}
-
{{.Action}}
{{.Object}}
{{.IntervalDays}} days
-
-
{{end}}
{{else}}
@@ -785,7 +790,23 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
}
}
-func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
+// routineRow is one line on the page: what maven noticed, in her words, and
+// how long ago she noticed it.
+type routineRow struct {
+ ID int64
+ Phrase string
+ Noticed string
+}
+
+// handleRoutines serves the routine review surface (GET) and answers a
+// proposal (POST id + action=accept|dismiss).
+//
+// Accept is gated at step-up, the same tier as enabling a tool: saying yes
+// hands the trigger loop a new standing reason to speak to the human, so it
+// moves the boundary and only an authed surface may do it. Dismiss is not
+// gated — it only ever removes a reason to speak, so the worst a weaker caller
+// can do is make maven quieter.
+func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
if core == nil {
http.Error(w, "routines disabled (no -core)", http.StatusServiceUnavailable)
return
@@ -801,6 +822,17 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
return
}
switch action {
+ case "accept":
+ if !stepUpOK(session, requireStepUp) {
+ http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
+ return
+ }
+ if err := acceptRoutine(ctx, core, rid); err != nil {
+ log.Printf("routines: accept %d: %v", rid, err)
+ http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ msg = "accepted routine — maven will remind you"
case "dismiss":
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
log.Printf("routines: dismiss %d: %v", rid, err)
@@ -822,12 +854,66 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := routinesTmpl.Execute(w, struct {
Msg string
- Proposed []ipc.ProposedRoutine
- }{msg, proposed}); err != nil {
+ Proposed []routineRow
+ }{msg, routineRows(proposed)}); err != nil {
log.Printf("routines render: %v", err)
}
}
+// routineRows turns the wire rows into display rows. The phrase comes from
+// pattern.PhraseRoutine so the page says the same thing maven's voice says.
+func routineRows(rs []ipc.ProposedRoutine) []routineRow {
+ out := make([]routineRow, 0, len(rs))
+ for _, r := range rs {
+ p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
+ noticed := "just now"
+ if r.CreatedTs > 0 {
+ noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
+ }
+ out = append(out, routineRow{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
+ }
+ return out
+}
+
+// acceptRoutine creates the recurring reminder for a proposal, then marks the
+// proposal accepted and links the reminder to it. Weekly patterns get a cron
+// expression; any other interval fires once.
+//
+// TODO(vikunja#46): this mirrors the voice accept path in cmd/mavend/voice.go.
+// When the tick loop learns to read accepted proposals directly, both callers
+// should hand off to one place in core instead of each building a reminder.
+func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
+ proposed, err := core.ListProposedRoutines(ctx)
+ if err != nil {
+ return err
+ }
+ var found *ipc.ProposedRoutine
+ for i := range proposed {
+ if proposed[i].ID == id {
+ found = &proposed[i]
+ break
+ }
+ }
+ if found == nil {
+ return errors.New("no such proposed routine")
+ }
+
+ fire := time.Now().Add(time.Duration(found.IntervalDays * 24 * float64(time.Hour)))
+ cron := ""
+ if found.IntervalDays >= 6.5 && found.IntervalDays <= 7.5 {
+ cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday()))
+ }
+ payload, err := json.Marshal(map[string]string{"text": found.Action + " " + found.Object})
+ if err != nil {
+ return err
+ }
+ remID, err := core.CreateReminder(ctx, fire, string(payload), cron)
+ if err != nil {
+ return err
+ }
+ return core.AcceptProposedRoutine(ctx, id, remID)
+}
+
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable)
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
index 45acc73..8cd85ec 100644
--- a/internal/auth/auth_test.go
+++ b/internal/auth/auth_test.go
@@ -446,6 +446,9 @@ func (r *recordingAPI) RevertFact(_ context.Context, _ string) (int64, error) {
func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
return nil, nil
}
+func (r *recordingAPI) AcceptProposedRoutine(_ context.Context, _, _ int64) error {
+ return nil
+}
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
return nil
}
diff --git a/internal/ipc/api.go b/internal/ipc/api.go
index 88488a8..000aef4 100644
--- a/internal/ipc/api.go
+++ b/internal/ipc/api.go
@@ -237,6 +237,11 @@ type dismissProposedRoutineReq struct {
ID int64 `json:"id"`
}
+type acceptProposedRoutineReq struct {
+ ID int64 `json:"id"`
+ ReminderID int64 `json:"reminder_id"`
+}
+
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
// - the in-process store adapter (server.go storeAPI) — used by the daemon
// for modules that live in-process for now (router, delivery) and by tests,
@@ -286,6 +291,9 @@ type CoreAPI interface {
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
DismissProposedRoutine(ctx context.Context, id int64) error
+ // AcceptProposedRoutine flips a proposed routine to 'accepted' and links
+ // the reminder that will fire it. The caller creates the reminder first.
+ AcceptProposedRoutine(ctx context.Context, id, reminderID int64) 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
diff --git a/internal/ipc/client.go b/internal/ipc/client.go
index 4423484..54aa639 100644
--- a/internal/ipc/client.go
+++ b/internal/ipc/client.go
@@ -430,6 +430,10 @@ func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
}
+func (c *Client) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
+ return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id, ReminderID: reminderID}, nil)
+}
+
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
var r chatResp
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil {
diff --git a/internal/ipc/ipc_test.go b/internal/ipc/ipc_test.go
index e793ecf..7d106be 100644
--- a/internal/ipc/ipc_test.go
+++ b/internal/ipc/ipc_test.go
@@ -478,6 +478,9 @@ func (a *chatTestAPI) DeleteTool(ctx context.Context, name string) error {
func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
return nil, ErrUnknownMethod
}
+func (a *chatTestAPI) AcceptProposedRoutine(ctx context.Context, id, remID int64) error {
+ return nil
+}
func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
return ErrUnknownMethod
}
diff --git a/internal/ipc/server.go b/internal/ipc/server.go
index 775d06b..b67c302 100644
--- a/internal/ipc/server.go
+++ b/internal/ipc/server.go
@@ -253,6 +253,10 @@ func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
return mapErr(a.s.DismissProposedRoutine(ctx, id))
}
+func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
+ return mapErr(a.s.AcceptProposedRoutine(ctx, id, reminderID))
+}
+
func toTool(t store.Tool) Tool {
return Tool{
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
@@ -774,6 +778,13 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
}
return marshalResult(nil), api.DismissProposedRoutine(ctx, p.ID)
+ case MethodAcceptProposedRoutine:
+ var p acceptProposedRoutineReq
+ if err := unmarshalParams(req.Params, &p); err != nil {
+ return nil, err
+ }
+ return marshalResult(nil), api.AcceptProposedRoutine(ctx, p.ID, p.ReminderID)
+
case MethodRevertFact:
var p struct {
Key string `json:"key"`
diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go
index 695dda4..a30e304 100644
--- a/internal/ipc/wire.go
+++ b/internal/ipc/wire.go
@@ -41,6 +41,7 @@ const (
MethodDeleteTool Method = "delete_tool"
MethodListProposedRoutines Method = "list_proposed_routines"
MethodDismissProposedRoutine Method = "dismiss_proposed_routine"
+ MethodAcceptProposedRoutine Method = "accept_proposed_routine"
MethodRevertFact Method = "revert_fact"
MethodTickTrace Method = "tick_trace"
MethodMorningStatus Method = "morning_status"
diff --git a/internal/store/proposed_routines.go b/internal/store/proposed_routines.go
index 6233817..a941737 100644
--- a/internal/store/proposed_routines.go
+++ b/internal/store/proposed_routines.go
@@ -8,6 +8,14 @@ import (
"time"
)
+// The three states a proposal can be in. A proposal starts 'proposed' and
+// moves once, either way, and never moves again.
+const (
+ RoutineProposed = "proposed"
+ RoutineAccepted = "accepted"
+ RoutineDismissed = "dismissed"
+)
+
// ProposedRoutine — a detected pattern the system wants to turn into a
// recurring reminder. Status 'proposed' means awaiting human confirmation;
// 'accepted' means the human confirmed and a reminder was created (reminder_id
@@ -30,6 +38,15 @@ var (
// CreateProposedRoutine inserts a new proposed routine. Returns
// ErrProposedRoutineExists if one already exists for this action+object (any
// status) — the pattern detector should only propose once per pair.
+//
+// action+object is the "same routine" key. It is UNIQUE in the table, so a
+// routine the human already dismissed can never come back: the detector will
+// keep finding the pattern, and every re-propose is refused here. Maven is not
+// a nag.
+//
+// TODO(vikunja#46): the detector currently only writes here from the voice
+// path. Once digestion runs the detector on its own tick, that tick should
+// call this too, so a pattern gets noticed even with nobody at the mic.
func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string, intervalDays float64, ts time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts)
@@ -70,14 +87,29 @@ func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string
return &r, nil
}
-// ListProposedRoutines returns all proposed routines with status='proposed',
-// newest first.
+// ListProposedRoutines returns the routines still waiting for an answer,
+// newest first. This is what the /routines page shows.
func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
- rows, err := s.db.QueryContext(ctx, `
- SELECT id, action, object, interval_days, status, created_ts, reminder_id
- FROM proposed_routines
- WHERE status = 'proposed'
- ORDER BY created_ts DESC, id DESC`)
+ return s.ListProposedRoutinesByStatus(ctx, RoutineProposed)
+}
+
+// ListProposedRoutinesByStatus returns routines in one status, newest first.
+// An empty status returns every row.
+//
+// TODO(vikunja#46): the tick loop should read the accepted ones from here so a
+// routine the human said yes to has a home the loop can see, instead of only
+// the reminder row that accepting happened to create.
+func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) {
+ q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id
+ FROM proposed_routines`
+ var args []any
+ if status != "" {
+ q += ` WHERE status = ?`
+ args = append(args, status)
+ }
+ q += ` ORDER BY created_ts DESC, id DESC`
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list proposed routines: %w", err)
}
@@ -93,8 +125,19 @@ func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, er
return out, rows.Err()
}
+// Status changes below are an in-place UPDATE, on purpose. Facts are
+// append-only (a correction writes a new row and sets voids_id) because a fact
+// is a claim about the world and the old claim is still history worth keeping.
+// A proposal is not a claim, it is a question with one answer, and the same
+// shape already exists for tools (tools.status flips in place). The guard
+// `AND status = 'proposed'` makes the move one-way: an answered proposal can
+// never be answered again.
+//
// AcceptProposedRoutine flips status to 'accepted', links a reminder_id.
// Returns error if not in 'proposed' status.
+//
+// TODO(vikunja#46): the /routines page calls this through ipc to flip status
+// from the authed surface.
func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
res, err := s.db.ExecContext(ctx,
`UPDATE proposed_routines SET status = 'accepted', reminder_id = ? WHERE id = ? AND status = 'proposed'`,
diff --git a/internal/store/proposed_routines_test.go b/internal/store/proposed_routines_test.go
index fe29c97..f81ac4b 100644
--- a/internal/store/proposed_routines_test.go
+++ b/internal/store/proposed_routines_test.go
@@ -131,6 +131,96 @@ func TestListProposedRoutines(t *testing.T) {
}
}
+// A dismissed routine must never be proposed again. The detector will keep
+// finding the same pattern; the store is what stops maven nagging about it.
+func TestDismissedProposedRoutineStaysDismissed(t *testing.T) {
+ s := newTestStore(t)
+ ctx := context.Background()
+ now := time.Now().UTC()
+
+ id, err := s.CreateProposedRoutine(ctx, "clean", "litter_box", 3.0, now)
+ if err != nil {
+ t.Fatalf("CreateProposedRoutine: %v", err)
+ }
+ if err := s.DismissProposedRoutine(ctx, id); err != nil {
+ t.Fatalf("DismissProposedRoutine: %v", err)
+ }
+
+ // The detector re-proposes the same pattern.
+ _, err = s.CreateProposedRoutine(ctx, "clean", "litter_box", 3.0, now.Add(24*time.Hour))
+ if !errors.Is(err, ErrProposedRoutineExists) {
+ t.Fatalf("want ErrProposedRoutineExists on re-propose, got %v", err)
+ }
+
+ // And it must not reappear on the review page.
+ list, err := s.ListProposedRoutines(ctx)
+ if err != nil {
+ t.Fatalf("ListProposedRoutines: %v", err)
+ }
+ if len(list) != 0 {
+ t.Fatalf("want 0 proposed, got %d", len(list))
+ }
+
+ // Dismissing again is a no-op, and accepting is refused.
+ if err := s.DismissProposedRoutine(ctx, id); err != nil {
+ t.Fatalf("second DismissProposedRoutine: %v", err)
+ }
+ if err := s.AcceptProposedRoutine(ctx, id, 1); !errors.Is(err, ErrProposedRoutineNotFound) {
+ t.Fatalf("want ErrProposedRoutineNotFound accepting a dismissed routine, got %v", err)
+ }
+ r, err := s.LookupProposedRoutine(ctx, "clean", "litter_box")
+ if err != nil {
+ t.Fatalf("LookupProposedRoutine: %v", err)
+ }
+ if r.Status != RoutineDismissed {
+ t.Fatalf("want status=dismissed, got %s", r.Status)
+ }
+}
+
+func TestListProposedRoutinesByStatus(t *testing.T) {
+ s := newTestStore(t)
+ ctx := context.Background()
+ now := time.Now().UTC()
+
+ keep, err := s.CreateProposedRoutine(ctx, "water", "plants", 4.0, now)
+ if err != nil {
+ t.Fatalf("CreateProposedRoutine: %v", err)
+ }
+ drop, err := s.CreateProposedRoutine(ctx, "walk", "dog", 1.0, now.Add(time.Hour))
+ if err != nil {
+ t.Fatalf("CreateProposedRoutine: %v", err)
+ }
+ remID, err := s.CreateReminder(ctx, now.Add(4*24*time.Hour), `{"text":"water plants"}`, "")
+ if err != nil {
+ t.Fatalf("CreateReminder: %v", err)
+ }
+ if err := s.AcceptProposedRoutine(ctx, keep, remID); err != nil {
+ t.Fatalf("AcceptProposedRoutine: %v", err)
+ }
+ if err := s.DismissProposedRoutine(ctx, drop); err != nil {
+ t.Fatalf("DismissProposedRoutine: %v", err)
+ }
+
+ cases := []struct {
+ status string
+ want int
+ }{
+ {RoutineProposed, 0},
+ {RoutineAccepted, 1},
+ {RoutineDismissed, 1},
+ {"", 2}, // empty status ⇒ every row
+ }
+ for _, c := range cases {
+ list, err := s.ListProposedRoutinesByStatus(ctx, c.status)
+ if err != nil {
+ t.Fatalf("ListProposedRoutinesByStatus(%q): %v", c.status, err)
+ }
+ if len(list) != c.want {
+ t.Fatalf("status %q: want %d, got %d", c.status, c.want, len(list))
+ }
+ }
+}
+
func TestLookupMissingProposedRoutine(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()