web: seed a backdated event from the routines page (V-518)
A "seed" action on the existing POST /routines, taking key, value and ago-in-hours. That route is already step-up gated and already the place a proposed routine is accepted or dismissed, so seeding lands next to the thing it produces. No new page and no second gated surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
This commit is contained in:
+78
-20
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user