| action | object | every | |
+noticed {{len .Proposed}}
+{{if .Proposed}}
{{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"