diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 87b3665..2ecf38d 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -1094,34 +1094,53 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se var msg string if r.Method == http.MethodPost { action := r.FormValue("action") - idStr := r.FormValue("id") - var rid int64 - if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 { - http.Error(w, "invalid id", http.StatusBadRequest) - return - } - switch action { - case "accept": + // "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 !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) + 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 } - 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) - http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway) + msg = out + } else { + idStr := r.FormValue("id") + var rid int64 + if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 { + http.Error(w, "invalid id", http.StatusBadRequest) + 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) + http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway) + return + } + msg = "dismissed routine" + default: + http.Error(w, "unknown action", http.StatusBadRequest) return } - msg = "dismissed routine" - default: - http.Error(w, "unknown action", http.StatusBadRequest) - return } } proposed, err := core.ListProposedRoutines(ctx) @@ -1158,6 +1177,45 @@ func toRoutineViews(rs []ipc.ProposedRoutine) []routineView { // 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. +// 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 +} + func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error { proposed, err := core.ListProposedRoutines(ctx) if err != nil {