diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index f02d01c..b0c96d4 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -166,6 +166,9 @@ func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRou return nil, errLocked } func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked } +func (l *lockedAPI) AcceptProposedRoutine(ctx context.Context, id, remID int64) error { + return errLocked +} func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) { return ipc.Tool{}, errLocked } diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 79cfbc2..7306797 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -918,3 +918,126 @@ func TestHandleTools_ListToolsError_502(t *testing.T) { t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String()) } } + +// --- handleRoutines --- + +// routineCore is a fakeCore that also answers the proposed-routine calls. +type routineCore struct { + fakeCore + + routines []ipc.ProposedRoutine + dismissed int64 + acceptedID int64 + acceptedRe int64 + remCron string +} + +func (c *routineCore) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) { + return c.routines, nil +} + +func (c *routineCore) DismissProposedRoutine(_ context.Context, id int64) error { + c.dismissed = id + return nil +} + +func (c *routineCore) AcceptProposedRoutine(_ context.Context, id, remID int64) error { + c.acceptedID, c.acceptedRe = id, remID + return nil +} + +func (c *routineCore) CreateReminder(_ context.Context, _ time.Time, _, cron string) (int64, error) { + c.remCron = cron + return 77, nil +} + +func weeklyRoutineCore() *routineCore { + return &routineCore{routines: []ipc.ProposedRoutine{{ + ID: 3, Action: "refill", Object: "cat_water", IntervalDays: 7, + Status: "proposed", CreatedTs: time.Now().Add(-2 * time.Hour).UnixMilli(), + }}} +} + +func postRoutine(action, id string) *http.Request { + return postForm(action, url.Values{"id": {id}}) +} + +func TestHandleRoutines_GET_ShowsMavensPhrase(t *testing.T) { + rr := httptest.NewRecorder() + handleRoutines(rr, httptest.NewRequest(http.MethodGet, "/routines", nil), weeklyRoutineCore(), nil, false) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "заправляешь") { + t.Fatalf("want maven's phrasing in the page, got: %s", body) + } + if !strings.Contains(body, "class=scroll") { + t.Fatal("table must be wrapped in
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}}
+

noticed {{len .Proposed}}

+{{if .Proposed}}
actionobjectevery
{{range .Proposed}} - - + + +{{end}}
maven noticedwhen
{{.Action}}{{.Object}}{{.IntervalDays}} days -
+
{{.Phrase}}{{.Noticed}} + + +
-
-
{{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()