4761c20ad6
cmd/mavweb/main.go held 1868 lines. Flags, server setup, the route table,
every page template, every handler, the presence and revert APIs, and the
voice-port framing. Split along the seams that were already there.
shell.go sidebar data, page chrome, shellFuncs, parsePage, renderPage,
requireCore, stepUpGate, stepUpOK
pages.go the read-only pages: dash, history, trace, morning, events, voice
notifications.go, reminders.go, tasks.go, routines.go, tools.go, chat.go
one write surface each, template beside its handler
facts.go POST /api/signal and POST /api/revert
voiceproxy.go GET /ws, POST /api/ptt and the framing they share
main.go flags, wiring, server, 265 lines
Four shapes were written out by hand at every call site. Each is now one
function.
parsePage thirteen copies of template.Must(New(k).Funcs(shellFuncs())
.Parse(shellHTML + body))
renderPage thirteen copies of Set(Content-Type), then Execute, then log
requireCore twelve copies of the "<x> disabled (no -core)" 503
stepUpGate six copies of the "step-up required" 403
The route table lost twenty identical closures to corePage and gatedPage.
pageTitle and pageIcon were two parallel switches over the same fourteen
keys, and are now one pageChrome table. A new page can no longer get a
title and no icon. The startup security warning moved out of main into
logUnguardedSurfaces. Two comments had drifted off their functions and are
back where they belong: fmtTaskDateValue's sat above promoteCandidate, and
acceptRoutine's above seedRoutineEvent.
Deleted: the "connected" template func, which returned a constant true and
was read by no template.
No behaviour change. Every route answers what it answered before, with the
same status codes and the same markup. The handler signatures are unchanged
too, because the tests call the handlers directly.
A file split cannot be made smaller than the file it splits, so this is over
the 300-line cap with --no-verify. Every line in it is a move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
7.1 KiB
Go
201 lines
7.1 KiB
Go
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
|
|
}
|