package main import ( "context" _ "embed" "errors" "fmt" "log" "net/http" "strconv" "strings" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/pattern" "github.com/kami/maven/internal/webauthn" ) //go:embed routines.html var routinesHTML string // routinesTmpl — the proposed-routine review surface. One row per thing maven // noticed, in her words, with at most two actions: accept or dismiss. var routinesTmpl = parsePage("routines", routinesHTML, nil) // routineView is one line on the page: what maven noticed, in her words, and // how long ago she noticed it. A view model, not a database row — the template // never formats an interval or a timestamp itself. type routineView 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 !requireCore(w, core, "routines") { return } ctx := r.Context() var msg string if r.Method == http.MethodPost { var ok bool if msg, ok = applyRoutinePost(w, r, core, session, requireStepUp); !ok { return } } proposed, err := core.ListProposedRoutines(ctx) if err != nil { log.Printf("routines: list: %v", err) http.Error(w, "routines error: "+err.Error(), http.StatusBadGateway) return } renderPage(w, routinesTmpl, struct { Msg string Proposed []routineView }{msg, toRoutineViews(proposed)}) } // applyRoutinePost performs one write and returns the message to show. Unlike // the task form, a bad request here is an HTTP status rather than an inline // note, so the second return says whether the response was already written. func applyRoutinePost(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) (string, bool) { ctx := r.Context() action := r.FormValue("action") // "seed" is the one action with no routine to act on — it is what // MAKES a routine (Vikunja #518), so it runs before the id parse. It // lives on this route rather than a page of its own because it is // already the step-up-gated surface for this table, and a second gated // surface is a second thing to get wrong. if action == "seed" { if !stepUpGate(w, session, requireStepUp) { return "", false } out, err := seedRoutineEvent(ctx, core, r) if err != nil { log.Printf("routines: seed: %v", err) http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway) return "", false } return out, true } idStr := r.FormValue("id") var rid int64 if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 { http.Error(w, "invalid id", http.StatusBadRequest) return "", false } switch action { case "accept": if !stepUpGate(w, session, requireStepUp) { return "", false } 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 "", false } return "accepted routine — maven will remind you", true case "dismiss": if err := core.DismissProposedRoutine(ctx, rid); err != nil { log.Printf("routines: dismiss %d: %v", rid, err) http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway) return "", false } return "dismissed routine", true default: http.Error(w, "unknown action", http.StatusBadRequest) return "", false } } // toRoutineViews turns the wire rows into view models. The phrase comes from // pattern.PhraseRoutine so the page says the same thing maven's voice says. func toRoutineViews(rs []ipc.ProposedRoutine) []routineView { out := make([]routineView, 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, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed}) } return out } // acceptRoutine marks a proposal accepted. This page is the ONLY surface that // may do it (Vikunja #367): accepting gives the tick loop a standing new // reason to speak, which DESIGN.md puts at layer 3, and the button here is // behind step-up. Voice can park the question and dismiss, never accept. 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") } // No reminder is created here. Accepting only flips the status; the tick // loop reads accepted routines and nudges on the interval (Vikunja #366). // The old code made a one-shot reminder, so a non-weekly routine fired // once and then went quiet forever. return core.AcceptProposedRoutine(ctx, id) } // seedRoutineEvent drives one backdated fact write through core (Vikunja #518), // so the pattern detector can be exercised against a running daemon instead of // over real days. Refused unless mavend was started with -allow-seed; on an // ordinary box the error says so and nothing is written. // // Takes "ago" rather than an absolute timestamp — hours before now, as a float // so a QA sitting can space four seeds three hours apart without doing clock // arithmetic. The detector's floor is two hours, and "0" is a legal answer // meaning now. func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) { key := strings.TrimSpace(r.FormValue("key")) value := strings.TrimSpace(r.FormValue("value")) if key == "" || value == "" { return "", errors.New("seed needs a key and a value") } agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64) if err != nil { return "", fmt.Errorf("seed: bad ago (hours before now): %w", err) } if agoHours < 0 { return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative") } resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{ Key: key, Value: value, Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))), }) if err != nil { return "", err } if !resp.Extracted { return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil } if !resp.Proposed { return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil } return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil }