35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
201 lines
7.2 KiB
Go
201 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"errors"
|
|
"fmt"
|
|
"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, r, 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 {
|
|
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
|
"routines unavailable", fmt.Errorf("list proposed routines: %w", err))
|
|
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, r, session, requireStepUp) {
|
|
return "", false
|
|
}
|
|
out, err := seedRoutineEvent(ctx, core, r)
|
|
if err != nil {
|
|
writeProblem(w, r, http.StatusBadGateway, problemRoutinesChange,
|
|
"seed failed", fmt.Errorf("seed routine event: %w", err))
|
|
return "", false
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
rid, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
|
|
if err != nil || rid <= 0 {
|
|
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
|
"invalid id", err)
|
|
return "", false
|
|
}
|
|
switch action {
|
|
case "accept":
|
|
if !stepUpGate(w, r, session, requireStepUp) {
|
|
return "", false
|
|
}
|
|
if err := acceptRoutine(ctx, core, rid); err != nil {
|
|
writeProblem(w, r, http.StatusBadGateway, problemRoutinesChange,
|
|
"accept failed", fmt.Errorf("accept routine %d: %w", rid, err))
|
|
return "", false
|
|
}
|
|
return "accepted routine — maven will remind you", true
|
|
case "dismiss":
|
|
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
|
writeProblem(w, r, http.StatusBadGateway, problemRoutinesChange,
|
|
"dismiss failed", fmt.Errorf("dismiss routine %d: %w", rid, err))
|
|
return "", false
|
|
}
|
|
return "dismissed routine", true
|
|
default:
|
|
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
|
"unknown action", nil)
|
|
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
|
|
}
|