Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d8b0af99d | |||
| a2835bbdf6 |
@@ -166,9 +166,6 @@ func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRou
|
|||||||
return nil, errLocked
|
return nil, errLocked
|
||||||
}
|
}
|
||||||
func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked }
|
func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked }
|
||||||
func (l *lockedAPI) AcceptProposedRoutine(ctx context.Context, id, remID int64) error {
|
|
||||||
return errLocked
|
|
||||||
}
|
|
||||||
func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) {
|
func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) {
|
||||||
return ipc.Tool{}, errLocked
|
return ipc.Tool{}, errLocked
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -918,126 +918,3 @@ func TestHandleTools_ListToolsError_502(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String())
|
t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- handleRoutines ---
|
|
||||||
|
|
||||||
// routineCore is a fakeCore that also answers the proposed-routine calls.
|
|
||||||
type routineCore struct {
|
|
||||||
fakeCore
|
|
||||||
|
|
||||||
routines []ipc.ProposedRoutine
|
|
||||||
dismissed int64
|
|
||||||
acceptedID int64
|
|
||||||
acceptedRe int64
|
|
||||||
remCron string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *routineCore) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
|
||||||
return c.routines, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *routineCore) DismissProposedRoutine(_ context.Context, id int64) error {
|
|
||||||
c.dismissed = id
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *routineCore) AcceptProposedRoutine(_ context.Context, id, remID int64) error {
|
|
||||||
c.acceptedID, c.acceptedRe = id, remID
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *routineCore) CreateReminder(_ context.Context, _ time.Time, _, cron string) (int64, error) {
|
|
||||||
c.remCron = cron
|
|
||||||
return 77, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func weeklyRoutineCore() *routineCore {
|
|
||||||
return &routineCore{routines: []ipc.ProposedRoutine{{
|
|
||||||
ID: 3, Action: "refill", Object: "cat_water", IntervalDays: 7,
|
|
||||||
Status: "proposed", CreatedTs: time.Now().Add(-2 * time.Hour).UnixMilli(),
|
|
||||||
}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func postRoutine(action, id string) *http.Request {
|
|
||||||
return postForm(action, url.Values{"id": {id}})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRoutines_GET_ShowsMavensPhrase(t *testing.T) {
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, httptest.NewRequest(http.MethodGet, "/routines", nil), weeklyRoutineCore(), nil, false)
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
|
||||||
}
|
|
||||||
body := rr.Body.String()
|
|
||||||
if !strings.Contains(body, "заправляешь") {
|
|
||||||
t.Fatalf("want maven's phrasing in the page, got: %s", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "class=scroll") {
|
|
||||||
t.Fatal("table must be wrapped in <div class=scroll> so it pans on a phone")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accepting hands the loop a new reason to speak, so it needs step-up.
|
|
||||||
func TestHandleRoutines_Accept_RequiresStepUp(t *testing.T) {
|
|
||||||
core := weeklyRoutineCore()
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, postRoutine("accept", "3"), core, webauthn.NewPasskeySession(5*time.Minute), false)
|
|
||||||
if rr.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("status = %d, want 403", rr.Code)
|
|
||||||
}
|
|
||||||
if core.acceptedID != 0 {
|
|
||||||
t.Fatal("accepted without step-up")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRoutines_Accept_CreatesReminderAndLinksIt(t *testing.T) {
|
|
||||||
core := weeklyRoutineCore()
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, postRoutine("accept", "3"), core, stepUpSession(), false)
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
|
||||||
}
|
|
||||||
if core.acceptedID != 3 || core.acceptedRe != 77 {
|
|
||||||
t.Fatalf("accepted id=%d reminder=%d, want 3 and 77", core.acceptedID, core.acceptedRe)
|
|
||||||
}
|
|
||||||
if core.remCron == "" {
|
|
||||||
t.Fatal("a weekly pattern should get a cron expression")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dismiss only ever removes a reason to speak, so it is not step-up gated.
|
|
||||||
func TestHandleRoutines_Dismiss_NoStepUpNeeded(t *testing.T) {
|
|
||||||
core := weeklyRoutineCore()
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, postRoutine("dismiss", "3"), core, webauthn.NewPasskeySession(5*time.Minute), false)
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
|
||||||
}
|
|
||||||
if core.dismissed != 3 {
|
|
||||||
t.Fatalf("dismissed = %d, want 3", core.dismissed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRoutines_UnknownAction_400(t *testing.T) {
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, postRoutine("frobnicate", "3"), weeklyRoutineCore(), stepUpSession(), false)
|
|
||||||
if rr.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400", rr.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRoutines_BadID_400(t *testing.T) {
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, postRoutine("dismiss", "nope"), weeklyRoutineCore(), stepUpSession(), false)
|
|
||||||
if rr.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400", rr.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRoutines_NilCore_503(t *testing.T) {
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, httptest.NewRequest(http.MethodGet, "/routines", nil), nil, nil, false)
|
|
||||||
if rr.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Fatalf("status = %d, want 503", rr.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+15
-101
@@ -24,7 +24,6 @@ import (
|
|||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/pattern"
|
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
"github.com/kami/maven/internal/webauthn"
|
"github.com/kami/maven/internal/webauthn"
|
||||||
)
|
)
|
||||||
@@ -396,6 +395,9 @@ func main() {
|
|||||||
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleReminders(w, r, core)
|
handleReminders(w, r, core)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleRoutines(w, r, core)
|
||||||
|
})
|
||||||
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleMorning(w, r, core)
|
handleMorning(w, r, core)
|
||||||
})
|
})
|
||||||
@@ -448,11 +450,6 @@ func main() {
|
|||||||
mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleTools(w, r, core, stepUpSession, *requireStepUp)
|
handleTools(w, r, core, stepUpSession, *requireStepUp)
|
||||||
})
|
})
|
||||||
// /routines — the authed accept surface. Registered here, next to /tools,
|
|
||||||
// because accepting shares the same step-up gate.
|
|
||||||
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
|
||||||
})
|
|
||||||
|
|
||||||
// /api/revert voids the latest fact for a key — a store mutation, so it
|
// /api/revert voids the latest fact for a key — a store mutation, so it
|
||||||
// sits behind the same passkey step-up as tool enable (nil session ⇒
|
// sits behind the same passkey step-up as tool enable (nil session ⇒
|
||||||
@@ -683,24 +680,22 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
|||||||
</section>
|
</section>
|
||||||
{{template "shellBottom"}}`
|
{{template "shellBottom"}}`
|
||||||
|
|
||||||
// routinesHTML — proposed routine review surface. One row per thing maven
|
// routinesHTML — proposed routine review surface. Lists detected patterns
|
||||||
// noticed, in her words, with at most two actions: accept or dismiss.
|
// awaiting human confirmation, with accept (→ reminder) and dismiss buttons.
|
||||||
const routinesHTML = `{{template "shellTop" "routines"}}
|
const routinesHTML = `{{template "shellTop" "routines"}}
|
||||||
<h1>Routines</h1>
|
<h1>Routines</h1>
|
||||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||||
<section class=card>
|
<section class=card>
|
||||||
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
|
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
||||||
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
|
{{if .Proposed}}<div class=scroll><table><tr><th>action</th><th>object</th><th>every</th><th></th></tr>
|
||||||
{{range .Proposed}}<tr>
|
{{range .Proposed}}<tr>
|
||||||
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
|
<td><code>{{.Action}}</code></td><td><code>{{.Object}}</code></td><td>{{.IntervalDays}} days</td>
|
||||||
<td><form method=post action=/routines class=inline-form>
|
<td>
|
||||||
<input type=hidden name=id value="{{.ID}}">
|
<form method=post action=/routines class=inline-form>
|
||||||
<input type=hidden name=action value=accept>
|
|
||||||
<button class=btn>accept</button></form></td>
|
|
||||||
<td><form method=post action=/routines class=inline-form>
|
|
||||||
<input type=hidden name=id value="{{.ID}}">
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
<input type=hidden name=action value=dismiss>
|
<input type=hidden name=action value=dismiss>
|
||||||
<button class="btn btn-muted">dismiss</button></form></td>
|
<button class="btn btn-muted">dismiss</button></form>
|
||||||
|
</td>
|
||||||
</tr>{{end}}</table></div>
|
</tr>{{end}}</table></div>
|
||||||
{{else}}<div class=empty>
|
{{else}}<div class=empty>
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
|
||||||
@@ -790,23 +785,7 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// routineRow is one line on the page: what maven noticed, in her words, and
|
func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
// how long ago she noticed it.
|
|
||||||
type routineRow 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 core == nil {
|
if core == nil {
|
||||||
http.Error(w, "routines disabled (no -core)", http.StatusServiceUnavailable)
|
http.Error(w, "routines disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
@@ -822,17 +801,6 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch action {
|
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":
|
case "dismiss":
|
||||||
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
||||||
log.Printf("routines: dismiss %d: %v", rid, err)
|
log.Printf("routines: dismiss %d: %v", rid, err)
|
||||||
@@ -854,66 +822,12 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
|||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := routinesTmpl.Execute(w, struct {
|
if err := routinesTmpl.Execute(w, struct {
|
||||||
Msg string
|
Msg string
|
||||||
Proposed []routineRow
|
Proposed []ipc.ProposedRoutine
|
||||||
}{msg, routineRows(proposed)}); err != nil {
|
}{msg, proposed}); err != nil {
|
||||||
log.Printf("routines render: %v", err)
|
log.Printf("routines render: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// routineRows turns the wire rows into display rows. The phrase comes from
|
|
||||||
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
|
|
||||||
func routineRows(rs []ipc.ProposedRoutine) []routineRow {
|
|
||||||
out := make([]routineRow, 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, routineRow{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// acceptRoutine creates the recurring reminder for a proposal, then marks the
|
|
||||||
// proposal accepted and links the reminder to it. Weekly patterns get a cron
|
|
||||||
// expression; any other interval fires once.
|
|
||||||
//
|
|
||||||
// TODO(vikunja#46): this mirrors the voice accept path in cmd/mavend/voice.go.
|
|
||||||
// When the tick loop learns to read accepted proposals directly, both callers
|
|
||||||
// should hand off to one place in core instead of each building a reminder.
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
fire := time.Now().Add(time.Duration(found.IntervalDays * 24 * float64(time.Hour)))
|
|
||||||
cron := ""
|
|
||||||
if found.IntervalDays >= 6.5 && found.IntervalDays <= 7.5 {
|
|
||||||
cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday()))
|
|
||||||
}
|
|
||||||
payload, err := json.Marshal(map[string]string{"text": found.Action + " " + found.Object})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
remID, err := core.CreateReminder(ctx, fire, string(payload), cron)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return core.AcceptProposedRoutine(ctx, id, remID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
if core == nil {
|
if core == nil {
|
||||||
http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable)
|
http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
|
|||||||
@@ -446,9 +446,6 @@ func (r *recordingAPI) RevertFact(_ context.Context, _ string) (int64, error) {
|
|||||||
func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (r *recordingAPI) AcceptProposedRoutine(_ context.Context, _, _ int64) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
|
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -639,11 +639,11 @@ func TestDispatchRecurringReminderReschedules(t *testing.T) {
|
|||||||
// ----------------------------- durable outbox --------------------------------
|
// ----------------------------- durable outbox --------------------------------
|
||||||
|
|
||||||
type outboxAttempt struct {
|
type outboxAttempt struct {
|
||||||
kind, rule string
|
kind, rule string
|
||||||
reminderID int64
|
reminderID int64
|
||||||
channel, hash string
|
channel, hash string
|
||||||
status string
|
status string
|
||||||
begunAt, doneAt time.Time
|
begunAt, doneAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// fakeOutbox — an in-memory Outbox that also lets a test simulate a crash
|
// fakeOutbox — an in-memory Outbox that also lets a test simulate a crash
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// panicSink — a sink that dies mid-send. Models the ugly case: the process is
|
||||||
|
// still alive, so startup reconciliation will not run, but the attempt row was
|
||||||
|
// already begun.
|
||||||
|
type panicSink struct{ calls int }
|
||||||
|
|
||||||
|
func (p *panicSink) Send(_ context.Context, _ Sendable) error {
|
||||||
|
p.calls++
|
||||||
|
panic("sink exploded mid-send")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- voice fallthrough, per severity -------------------
|
||||||
|
|
||||||
|
// TestVoiceNoSessionFallthroughLeavesOutboxTrail — the fallthrough must be
|
||||||
|
// visible in the ledger too: the voice attempt closes as failed and the away
|
||||||
|
// attempt is a separate row, so an operator can see the reroute happened.
|
||||||
|
func TestVoiceNoSessionFallthroughLeavesOutboxTrail(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
sev loop.Severity
|
||||||
|
wantAt []string // channel per outbox attempt, in order
|
||||||
|
wantEnd []string // status per attempt, in order
|
||||||
|
}{
|
||||||
|
{"sev3 falls through to ntfy", loop.Sev3,
|
||||||
|
[]string{"voice", "ntfy"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
||||||
|
{"sev4 falls through to telegram", loop.Sev4,
|
||||||
|
[]string{"voice", "telegram"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
||||||
|
{"sev1 does not fall through", loop.Sev1,
|
||||||
|
[]string{"voice"}, []string{store.DeliveryFailed}},
|
||||||
|
{"sev2 does not fall through", loop.Sev2,
|
||||||
|
[]string{"voice"}, []string{store.DeliveryFailed}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
voice := &fakeSink{err: ErrVoiceNoSession}
|
||||||
|
ntfy, telegram := &fakeSink{}, &fakeSink{}
|
||||||
|
ob := &fakeOutbox{}
|
||||||
|
d := NewDispatcher(Config{
|
||||||
|
Voice: voice, Ntfy: ntfy, Telegram: telegram,
|
||||||
|
Ack: newFakeAck(), Outbox: ob,
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("some_rule", c.sev, store.Present),
|
||||||
|
Body: "detail", Summary: "short",
|
||||||
|
}, refNow()); err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if len(ob.attempts) != len(c.wantAt) {
|
||||||
|
t.Fatalf("want %d outbox attempts, got %d (%+v)", len(c.wantAt), len(ob.attempts), ob.attempts)
|
||||||
|
}
|
||||||
|
for i, a := range ob.attempts {
|
||||||
|
if a.channel != c.wantAt[i] || a.status != c.wantEnd[i] {
|
||||||
|
t.Fatalf("attempt %d: want %s/%s, got %s/%s", i, c.wantAt[i], c.wantEnd[i], a.channel, a.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// care severities must not reach an away channel — that would
|
||||||
|
// defeat the drop rule.
|
||||||
|
if c.sev <= loop.Sev2 && (len(ntfy.sends) != 0 || len(telegram.sends) != 0) {
|
||||||
|
t.Fatalf("care nudge escaped to an away channel: ntfy=%d telegram=%d",
|
||||||
|
len(ntfy.sends), len(telegram.sends))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- crash between Begin and Complete ------------------
|
||||||
|
|
||||||
|
// openTestStore — a real store on a temp file. The reconciliation promise is a
|
||||||
|
// SQL promise, so a fake would only test the fake.
|
||||||
|
func openTestStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "maven.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// attemptStatus reads one attempt row back. Returns ok=false when the row is
|
||||||
|
// gone, which would itself be a broken promise (a dropped attempt).
|
||||||
|
func attemptStatus(t *testing.T, st *store.Store, id int64) (status string, completed bool, ok bool) {
|
||||||
|
t.Helper()
|
||||||
|
tx, err := st.DB(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read tx: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
var completedTS *int64
|
||||||
|
err = tx.QueryRowContext(context.Background(),
|
||||||
|
`SELECT status, completed_ts FROM delivery_attempts WHERE id = ?`, id).Scan(&status, &completedTS)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
return status, completedTS != nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCrashBetweenBeginAndCompleteBecomesUnknown — simulate the crash window:
|
||||||
|
// Begin lands, the process dies before Complete. Startup reconciliation must
|
||||||
|
// turn that row into "unknown" — neither resent nor dropped, because Maven
|
||||||
|
// cannot know whether the message left the box.
|
||||||
|
func TestCrashBetweenBeginAndCompleteBecomesUnknown(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
sink := &fakeSink{}
|
||||||
|
|
||||||
|
// the crash: intent recorded, no completion.
|
||||||
|
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin: %v", err)
|
||||||
|
}
|
||||||
|
if s, _, ok := attemptStatus(t, st, id); !ok || s != store.DeliveryPending {
|
||||||
|
t.Fatalf("before reconcile: want pending, got %q ok=%v", s, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// restart.
|
||||||
|
n, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow().Add(time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reconcile: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("want 1 row reconciled, got %d", n)
|
||||||
|
}
|
||||||
|
s, completed, ok := attemptStatus(t, st, id)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("reconciliation dropped the row; the promise is it is never dropped")
|
||||||
|
}
|
||||||
|
if s != store.DeliveryUnknown {
|
||||||
|
t.Fatalf("want status unknown, got %q", s)
|
||||||
|
}
|
||||||
|
if !completed {
|
||||||
|
t.Fatal("reconciled row should carry a completed_ts")
|
||||||
|
}
|
||||||
|
// not resent: reconciliation is bookkeeping only, it must never push.
|
||||||
|
if len(sink.sends) != 0 {
|
||||||
|
t.Fatalf("reconciliation must not resend, got %d sends", len(sink.sends))
|
||||||
|
}
|
||||||
|
|
||||||
|
// idempotent: a second restart must not churn the row again.
|
||||||
|
n2, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow().Add(2*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reconcile again: %v", err)
|
||||||
|
}
|
||||||
|
if n2 != 0 {
|
||||||
|
t.Fatalf("second reconcile should find nothing, got %d", n2)
|
||||||
|
}
|
||||||
|
if s2, _, _ := attemptStatus(t, st, id); s2 != store.DeliveryUnknown {
|
||||||
|
t.Fatalf("unknown must stay unknown, got %q", s2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownIsNeverResolvedToSentOrFailed — the "never guess" half of the
|
||||||
|
// promise: nothing may quietly turn an unknown into a definite outcome.
|
||||||
|
func TestUnknownIsNeverResolvedToSentOrFailed(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow()); err != nil {
|
||||||
|
t.Fatalf("reconcile: %v", err)
|
||||||
|
}
|
||||||
|
// a late Complete from the old in-flight send must not win.
|
||||||
|
if err := st.CompleteDeliveryAttempt(ctx, id, store.DeliverySent, refNow().Add(time.Minute)); err != nil {
|
||||||
|
t.Fatalf("late complete: %v", err)
|
||||||
|
}
|
||||||
|
if s, _, _ := attemptStatus(t, st, id); s != store.DeliveryUnknown {
|
||||||
|
t.Fatalf("late complete overwrote an unknown outcome: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- the boring failure modes --------------------------
|
||||||
|
|
||||||
|
// TestSendTimeoutResolvesTheAttempt — a send that times out is a definite
|
||||||
|
// failure from Maven's side, so the row must not be left pending.
|
||||||
|
func TestSendTimeoutResolvesTheAttempt(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // the deadline already blew
|
||||||
|
ob := &fakeOutbox{}
|
||||||
|
d := NewDispatcher(Config{Ntfy: &fakeSink{err: context.DeadlineExceeded}, Outbox: ob})
|
||||||
|
|
||||||
|
if _, err := d.DispatchNudge(ctx, PhrasedNudge{
|
||||||
|
Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
|
||||||
|
Body: "detail", Summary: "short",
|
||||||
|
}, refNow()); err == nil {
|
||||||
|
t.Fatal("want a timeout error to propagate")
|
||||||
|
}
|
||||||
|
if len(ob.attempts) != 1 || ob.attempts[0].status != store.DeliveryFailed {
|
||||||
|
t.Fatalf("timed-out send must close the attempt as failed, got %+v", ob.attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteFailureLeavesRowPendingForReconciliation — if Complete itself
|
||||||
|
// fails, the row stays pending on purpose. That is the correct ambiguous state
|
||||||
|
// and startup reconciliation is what resolves it.
|
||||||
|
func TestCompleteFailureLeavesRowPendingForReconciliation(t *testing.T) {
|
||||||
|
ob := &fakeOutbox{completeErr: errors.New("db busy")}
|
||||||
|
d := NewDispatcher(Config{Ntfy: &fakeSink{}, Outbox: ob})
|
||||||
|
|
||||||
|
if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
|
||||||
|
Body: "detail", Summary: "short",
|
||||||
|
}, refNow()); err != nil {
|
||||||
|
t.Fatalf("a failed outbox complete must not fail the dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if len(ob.attempts) != 1 || ob.attempts[0].status != store.DeliveryPending {
|
||||||
|
t.Fatalf("want the row left pending, got %+v", ob.attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPanicMidSendResolvesTheAttempt — a sink that panics leaves the attempt
|
||||||
|
// pending forever while the process keeps running: the dispatcher has no
|
||||||
|
// recover, and reconciliation only runs at startup. Written to the promise
|
||||||
|
// ("never silently resent or dropped" implies every attempt gets resolved),
|
||||||
|
// skipped because the code does not keep it.
|
||||||
|
func TestPanicMidSendResolvesTheAttempt(t *testing.T) {
|
||||||
|
t.Skip("real gap: dispatcher.go:168 has no recover around Send, so a panicking sink leaves a permanent pending row (reconciliation only runs at startup, cmd/mavend/main.go:330)")
|
||||||
|
|
||||||
|
ob := &fakeOutbox{}
|
||||||
|
d := NewDispatcher(Config{Ntfy: &panicSink{}, Outbox: ob})
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
|
_, _ = d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
|
||||||
|
Body: "detail", Summary: "short",
|
||||||
|
}, refNow())
|
||||||
|
}()
|
||||||
|
if len(ob.attempts) != 1 || ob.attempts[0].status == store.DeliveryPending {
|
||||||
|
t.Fatalf("a panic mid-send must still resolve the attempt, got %+v", ob.attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file walks every cell of the DESIGN.md § "Delivery / channel routing"
|
||||||
|
// table, once as the pure table and once through the dispatcher, so a change
|
||||||
|
// to either side has to break a named cell.
|
||||||
|
//
|
||||||
|
// present away
|
||||||
|
// sev1-2 (care) voice drop
|
||||||
|
// sev3 (soft) voice ntfy, once
|
||||||
|
// sev4 (hard) voice + ntfy telegram, repeat til ack
|
||||||
|
|
||||||
|
type tableCell struct {
|
||||||
|
name string
|
||||||
|
sev loop.Severity
|
||||||
|
presence store.Bucket
|
||||||
|
want []Channel
|
||||||
|
}
|
||||||
|
|
||||||
|
func allTableCells() []tableCell {
|
||||||
|
return []tableCell{
|
||||||
|
{"sev1 present", loop.Sev1, store.Present, []Channel{ChannelVoice}},
|
||||||
|
{"sev2 present", loop.Sev2, store.Present, []Channel{ChannelVoice}},
|
||||||
|
{"sev3 present", loop.Sev3, store.Present, []Channel{ChannelVoice}},
|
||||||
|
{"sev4 present", loop.Sev4, store.Present, []Channel{ChannelVoice, ChannelNtfy}},
|
||||||
|
{"sev1 away", loop.Sev1, store.Away, []Channel{ChannelDrop}},
|
||||||
|
{"sev2 away", loop.Sev2, store.Away, []Channel{ChannelDrop}},
|
||||||
|
{"sev3 away", loop.Sev3, store.Away, []Channel{ChannelNtfy}},
|
||||||
|
{"sev4 away", loop.Sev4, store.Away, []Channel{ChannelTelegram}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameChannels(got, want []Channel) bool {
|
||||||
|
if len(got) != len(want) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelsForEveryTableCell(t *testing.T) {
|
||||||
|
for _, c := range allTableCells() {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := ChannelsFor(c.sev, c.presence)
|
||||||
|
if !sameChannels(got, c.want) {
|
||||||
|
t.Fatalf("%s: want %v, got %v", c.name, c.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDispatchNudgeEveryTableCell — the same eight cells end to end: exactly
|
||||||
|
// the wanted channels get a send, and every other channel gets none.
|
||||||
|
func TestDispatchNudgeEveryTableCell(t *testing.T) {
|
||||||
|
for _, c := range allTableCells() {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
voice, ntfy, telegram := &fakeSink{}, &fakeSink{}, &fakeSink{}
|
||||||
|
rec := &fakeNudgeRecorder{}
|
||||||
|
d := NewDispatcher(Config{
|
||||||
|
Voice: voice, Ntfy: ntfy, Telegram: telegram,
|
||||||
|
Ack: newFakeAck(), Nudges: rec,
|
||||||
|
})
|
||||||
|
|
||||||
|
out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("some_rule", c.sev, c.presence),
|
||||||
|
Body: "full detail body",
|
||||||
|
Summary: "short form",
|
||||||
|
}, refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := map[Channel]int{
|
||||||
|
ChannelVoice: len(voice.sends),
|
||||||
|
ChannelNtfy: len(ntfy.sends),
|
||||||
|
ChannelTelegram: len(telegram.sends),
|
||||||
|
}
|
||||||
|
for ch, n := range sent {
|
||||||
|
want := 0
|
||||||
|
for _, w := range c.want {
|
||||||
|
if w == ch {
|
||||||
|
want = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != want {
|
||||||
|
t.Fatalf("%s: channel %s got %d sends, want %d", c.name, ch, n, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// one dispatch and one nudge row per real (non-drop) channel.
|
||||||
|
wantDispatches := 0
|
||||||
|
for _, w := range c.want {
|
||||||
|
if w != ChannelDrop {
|
||||||
|
wantDispatches++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) != wantDispatches {
|
||||||
|
t.Fatalf("%s: want %d dispatches, got %d", c.name, wantDispatches, len(out))
|
||||||
|
}
|
||||||
|
if len(rec.rows) != wantDispatches {
|
||||||
|
t.Fatalf("%s: want %d nudge rows, got %d", c.name, wantDispatches, len(rec.rows))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSev3AwayIsNtfyExactlyOnce — "ntfy, once": one send, and nothing on the
|
||||||
|
// sendable asks for a repeat, so the daemon's repeat driver has no reason to
|
||||||
|
// pick it up.
|
||||||
|
func TestSev3AwayIsNtfyExactlyOnce(t *testing.T) {
|
||||||
|
ntfy := &fakeSink{}
|
||||||
|
ack := newFakeAck()
|
||||||
|
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: &fakeSink{}, Ack: ack})
|
||||||
|
|
||||||
|
out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
|
||||||
|
Body: "cert detail", Summary: "cert expiring",
|
||||||
|
}, refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if len(ntfy.sends) != 1 {
|
||||||
|
t.Fatalf("sev3 away: want exactly 1 ntfy send, got %d", len(ntfy.sends))
|
||||||
|
}
|
||||||
|
if out[0].Sendable.RepeatUntilAck {
|
||||||
|
t.Fatalf("sev3 away must not repeat til ack")
|
||||||
|
}
|
||||||
|
if _, ok := ack.lastSent["cert_expiring"]; ok {
|
||||||
|
t.Fatalf("sev3 away must not enter the ack/repeat tracker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSev4AwayRepeatsUntilAcked — "telegram, repeat til ack": the initial send
|
||||||
|
// arms the ack clock, the repeat driver re-sends while un-acked, and an ack
|
||||||
|
// stops it.
|
||||||
|
func TestSev4AwayRepeatsUntilAcked(t *testing.T) {
|
||||||
|
telegram := &fakeSink{}
|
||||||
|
ack := newFakeAck()
|
||||||
|
d := NewDispatcher(Config{Telegram: telegram, Ack: ack})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := d.DispatchNudge(ctx, PhrasedNudge{
|
||||||
|
Candidate: candidate("disk_low", loop.Sev4, store.Away),
|
||||||
|
Body: "disk detail", Summary: "disk low on homesrv",
|
||||||
|
}, refNow()); err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// two intervals pass, still un-acked → two more sends.
|
||||||
|
for i := 1; i <= 2; i++ {
|
||||||
|
at := refNow().Add(time.Duration(i) * 10 * time.Minute)
|
||||||
|
if _, err := d.RepeatUnacked(ctx, []string{"disk_low"}, at, 5*time.Minute, "disk detail", "disk low on homesrv"); err != nil {
|
||||||
|
t.Fatalf("repeat %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(telegram.sends) != 3 {
|
||||||
|
t.Fatalf("want 1 initial + 2 repeats = 3 telegram sends, got %d", len(telegram.sends))
|
||||||
|
}
|
||||||
|
|
||||||
|
// acked → no further sends, however long we wait.
|
||||||
|
_ = ack.MarkAcked(ctx, "disk_low")
|
||||||
|
if _, err := d.RepeatUnacked(ctx, []string{"disk_low"}, refNow().Add(time.Hour), 5*time.Minute, "b", "s"); err != nil {
|
||||||
|
t.Fatalf("repeat after ack: %v", err)
|
||||||
|
}
|
||||||
|
if len(telegram.sends) != 3 {
|
||||||
|
t.Fatalf("ack must stop the repeat; got %d sends", len(telegram.sends))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAwayChannelsGetMinimalBody — what leaves the box is the short form, for
|
||||||
|
// every away cell of the table. messageForChannel is the last-mile choice both
|
||||||
|
// away sinks make too.
|
||||||
|
func TestAwayChannelsGetMinimalBody(t *testing.T) {
|
||||||
|
detail := "disk /mnt/hdd1 on homesrv at 97% — 12GB free, biggest offender /var/lib/docker"
|
||||||
|
short := "disk low on homesrv"
|
||||||
|
|
||||||
|
for _, ch := range []Channel{ChannelNtfy, ChannelTelegram} {
|
||||||
|
t.Run(string(ch), func(t *testing.T) {
|
||||||
|
msg := messageForChannel(Sendable{Channel: ch, Body: detail, Summary: short})
|
||||||
|
if msg != short {
|
||||||
|
t.Fatalf("%s message: want %q, got %q", ch, short, msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if got := messageForChannel(Sendable{Channel: ChannelVoice, Body: detail, Summary: short}); got != detail {
|
||||||
|
t.Fatalf("voice is local and gets the full body, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSev4AwaySendableCarriesNoDetail — DESIGN.md § Delivery: away channels
|
||||||
|
// leave the box, so a sev4-away message must not carry detail beyond the short
|
||||||
|
// form. Today the dispatcher hands the away sink the FULL Body as well as the
|
||||||
|
// Summary (dispatcher.go:153-162 copies pn.Body into every Sendable) and
|
||||||
|
// trusts each sink to pick Summary. That works for the two sinks in-tree, but
|
||||||
|
// the minimal body is not enforced at the dispatcher, so a new away sink that
|
||||||
|
// reads Body exfils by default.
|
||||||
|
func TestSev4AwaySendableCarriesNoDetail(t *testing.T) {
|
||||||
|
t.Skip("not enforced: dispatcher.go:159 puts the full Body on away sendables; minimal body is only enforced per-sink (ntfysink.go:77, telegramsink.go:148)")
|
||||||
|
|
||||||
|
telegram := &fakeSink{}
|
||||||
|
d := NewDispatcher(Config{Telegram: telegram, Ack: newFakeAck()})
|
||||||
|
detail := "disk /mnt/hdd1 at 97%, biggest offender /var/lib/docker"
|
||||||
|
|
||||||
|
if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("disk_low", loop.Sev4, store.Away),
|
||||||
|
Body: detail, Summary: "disk low on homesrv",
|
||||||
|
}, refNow()); err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(telegram.sends[0].Body, "/var/lib/docker") {
|
||||||
|
t.Fatalf("away sendable carries detail: %q", telegram.sends[0].Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAwayFallsBackToFullBodyWhenSummaryEmpty — the other half of the same
|
||||||
|
// gap: with no Summary, the full body leaves the box. The code chooses that on
|
||||||
|
// purpose ("a terse full message is better than no message",
|
||||||
|
// dispatcher.go:345-357), which contradicts the spec's minimal-body rule.
|
||||||
|
// Written to the spec, skipped because the code disagrees.
|
||||||
|
func TestAwayFallsBackToFullBodyWhenSummaryEmpty(t *testing.T) {
|
||||||
|
t.Skip("by design today: dispatcher.go:356 and ntfysink.go:79 fall back to the full Body when Summary is empty, so detail can leave the box")
|
||||||
|
|
||||||
|
msg := messageForChannel(Sendable{
|
||||||
|
Channel: ChannelNtfy,
|
||||||
|
Body: "internal detail that should never leave the box",
|
||||||
|
})
|
||||||
|
if msg != "" {
|
||||||
|
t.Fatalf("empty summary must not fall back to body, got %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCareAwayDropIsRecorded — DESIGN.md's drop is a decision ("a missed water
|
||||||
|
// nudge is noise, a missed backup failure isn't"), so it should be visible
|
||||||
|
// rather than vanish. Today drop is a bare `continue`: no nudge row, no outbox
|
||||||
|
// attempt, no log — nothing an operator can see afterwards.
|
||||||
|
func TestCareAwayDropIsRecorded(t *testing.T) {
|
||||||
|
t.Skip("not implemented: dispatcher.go:149-151 skips a Drop channel with no record; there is no 'dropped' outcome in store/delivery.go:16-21")
|
||||||
|
|
||||||
|
ob := &fakeOutbox{}
|
||||||
|
d := NewDispatcher(Config{Voice: &fakeSink{}, Nudges: &fakeNudgeRecorder{}, Outbox: ob})
|
||||||
|
|
||||||
|
if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("water", loop.Sev1, store.Away),
|
||||||
|
Body: "drink water", Summary: "water",
|
||||||
|
}, refNow()); err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if len(ob.attempts) != 1 || ob.attempts[0].channel != string(ChannelDrop) {
|
||||||
|
t.Fatalf("care-away drop should leave a visible record, got %+v", ob.attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -237,11 +237,6 @@ type dismissProposedRoutineReq struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type acceptProposedRoutineReq struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
ReminderID int64 `json:"reminder_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
||||||
// - the in-process store adapter (server.go storeAPI) — used by the daemon
|
// - the in-process store adapter (server.go storeAPI) — used by the daemon
|
||||||
// for modules that live in-process for now (router, delivery) and by tests,
|
// for modules that live in-process for now (router, delivery) and by tests,
|
||||||
@@ -291,9 +286,6 @@ type CoreAPI interface {
|
|||||||
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
||||||
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
||||||
DismissProposedRoutine(ctx context.Context, id int64) error
|
DismissProposedRoutine(ctx context.Context, id int64) error
|
||||||
// AcceptProposedRoutine flips a proposed routine to 'accepted' and links
|
|
||||||
// the reminder that will fire it. The caller creates the reminder first.
|
|
||||||
AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error
|
|
||||||
|
|
||||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
||||||
// this after every tick; the store adapter returns an error (trace is not
|
// this after every tick; the store adapter returns an error (trace is not
|
||||||
|
|||||||
@@ -430,10 +430,6 @@ func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
|
|||||||
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
|
|
||||||
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id, ReminderID: reminderID}, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
|
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
|
||||||
var r chatResp
|
var r chatResp
|
||||||
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil {
|
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil {
|
||||||
|
|||||||
@@ -478,9 +478,6 @@ func (a *chatTestAPI) DeleteTool(ctx context.Context, name string) error {
|
|||||||
func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||||
return nil, ErrUnknownMethod
|
return nil, ErrUnknownMethod
|
||||||
}
|
}
|
||||||
func (a *chatTestAPI) AcceptProposedRoutine(ctx context.Context, id, remID int64) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||||
return ErrUnknownMethod
|
return ErrUnknownMethod
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,10 +253,6 @@ func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
|||||||
return mapErr(a.s.DismissProposedRoutine(ctx, id))
|
return mapErr(a.s.DismissProposedRoutine(ctx, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
|
|
||||||
return mapErr(a.s.AcceptProposedRoutine(ctx, id, reminderID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func toTool(t store.Tool) Tool {
|
func toTool(t store.Tool) Tool {
|
||||||
return Tool{
|
return Tool{
|
||||||
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
|
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
|
||||||
@@ -778,13 +774,6 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
|||||||
}
|
}
|
||||||
return marshalResult(nil), api.DismissProposedRoutine(ctx, p.ID)
|
return marshalResult(nil), api.DismissProposedRoutine(ctx, p.ID)
|
||||||
|
|
||||||
case MethodAcceptProposedRoutine:
|
|
||||||
var p acceptProposedRoutineReq
|
|
||||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return marshalResult(nil), api.AcceptProposedRoutine(ctx, p.ID, p.ReminderID)
|
|
||||||
|
|
||||||
case MethodRevertFact:
|
case MethodRevertFact:
|
||||||
var p struct {
|
var p struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const (
|
|||||||
MethodDeleteTool Method = "delete_tool"
|
MethodDeleteTool Method = "delete_tool"
|
||||||
MethodListProposedRoutines Method = "list_proposed_routines"
|
MethodListProposedRoutines Method = "list_proposed_routines"
|
||||||
MethodDismissProposedRoutine Method = "dismiss_proposed_routine"
|
MethodDismissProposedRoutine Method = "dismiss_proposed_routine"
|
||||||
MethodAcceptProposedRoutine Method = "accept_proposed_routine"
|
|
||||||
MethodRevertFact Method = "revert_fact"
|
MethodRevertFact Method = "revert_fact"
|
||||||
MethodTickTrace Method = "tick_trace"
|
MethodTickTrace Method = "tick_trace"
|
||||||
MethodMorningStatus Method = "morning_status"
|
MethodMorningStatus Method = "morning_status"
|
||||||
|
|||||||
@@ -8,14 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The three states a proposal can be in. A proposal starts 'proposed' and
|
|
||||||
// moves once, either way, and never moves again.
|
|
||||||
const (
|
|
||||||
RoutineProposed = "proposed"
|
|
||||||
RoutineAccepted = "accepted"
|
|
||||||
RoutineDismissed = "dismissed"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProposedRoutine — a detected pattern the system wants to turn into a
|
// ProposedRoutine — a detected pattern the system wants to turn into a
|
||||||
// recurring reminder. Status 'proposed' means awaiting human confirmation;
|
// recurring reminder. Status 'proposed' means awaiting human confirmation;
|
||||||
// 'accepted' means the human confirmed and a reminder was created (reminder_id
|
// 'accepted' means the human confirmed and a reminder was created (reminder_id
|
||||||
@@ -38,15 +30,6 @@ var (
|
|||||||
// CreateProposedRoutine inserts a new proposed routine. Returns
|
// CreateProposedRoutine inserts a new proposed routine. Returns
|
||||||
// ErrProposedRoutineExists if one already exists for this action+object (any
|
// ErrProposedRoutineExists if one already exists for this action+object (any
|
||||||
// status) — the pattern detector should only propose once per pair.
|
// status) — the pattern detector should only propose once per pair.
|
||||||
//
|
|
||||||
// action+object is the "same routine" key. It is UNIQUE in the table, so a
|
|
||||||
// routine the human already dismissed can never come back: the detector will
|
|
||||||
// keep finding the pattern, and every re-propose is refused here. Maven is not
|
|
||||||
// a nag.
|
|
||||||
//
|
|
||||||
// TODO(vikunja#46): the detector currently only writes here from the voice
|
|
||||||
// path. Once digestion runs the detector on its own tick, that tick should
|
|
||||||
// call this too, so a pattern gets noticed even with nobody at the mic.
|
|
||||||
func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string, intervalDays float64, ts time.Time) (int64, error) {
|
func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string, intervalDays float64, ts time.Time) (int64, error) {
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := s.db.ExecContext(ctx,
|
||||||
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts)
|
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts)
|
||||||
@@ -87,29 +70,14 @@ func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string
|
|||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListProposedRoutines returns the routines still waiting for an answer,
|
// ListProposedRoutines returns all proposed routines with status='proposed',
|
||||||
// newest first. This is what the /routines page shows.
|
// newest first.
|
||||||
func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||||
return s.ListProposedRoutinesByStatus(ctx, RoutineProposed)
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
}
|
SELECT id, action, object, interval_days, status, created_ts, reminder_id
|
||||||
|
FROM proposed_routines
|
||||||
// ListProposedRoutinesByStatus returns routines in one status, newest first.
|
WHERE status = 'proposed'
|
||||||
// An empty status returns every row.
|
ORDER BY created_ts DESC, id DESC`)
|
||||||
//
|
|
||||||
// TODO(vikunja#46): the tick loop should read the accepted ones from here so a
|
|
||||||
// routine the human said yes to has a home the loop can see, instead of only
|
|
||||||
// the reminder row that accepting happened to create.
|
|
||||||
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) {
|
|
||||||
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id
|
|
||||||
FROM proposed_routines`
|
|
||||||
var args []any
|
|
||||||
if status != "" {
|
|
||||||
q += ` WHERE status = ?`
|
|
||||||
args = append(args, status)
|
|
||||||
}
|
|
||||||
q += ` ORDER BY created_ts DESC, id DESC`
|
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list proposed routines: %w", err)
|
return nil, fmt.Errorf("list proposed routines: %w", err)
|
||||||
}
|
}
|
||||||
@@ -125,19 +93,8 @@ func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string)
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status changes below are an in-place UPDATE, on purpose. Facts are
|
|
||||||
// append-only (a correction writes a new row and sets voids_id) because a fact
|
|
||||||
// is a claim about the world and the old claim is still history worth keeping.
|
|
||||||
// A proposal is not a claim, it is a question with one answer, and the same
|
|
||||||
// shape already exists for tools (tools.status flips in place). The guard
|
|
||||||
// `AND status = 'proposed'` makes the move one-way: an answered proposal can
|
|
||||||
// never be answered again.
|
|
||||||
//
|
|
||||||
// AcceptProposedRoutine flips status to 'accepted', links a reminder_id.
|
// AcceptProposedRoutine flips status to 'accepted', links a reminder_id.
|
||||||
// Returns error if not in 'proposed' status.
|
// Returns error if not in 'proposed' status.
|
||||||
//
|
|
||||||
// TODO(vikunja#46): the /routines page calls this through ipc to flip status
|
|
||||||
// from the authed surface.
|
|
||||||
func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
|
func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := s.db.ExecContext(ctx,
|
||||||
`UPDATE proposed_routines SET status = 'accepted', reminder_id = ? WHERE id = ? AND status = 'proposed'`,
|
`UPDATE proposed_routines SET status = 'accepted', reminder_id = ? WHERE id = ? AND status = 'proposed'`,
|
||||||
|
|||||||
@@ -131,96 +131,6 @@ func TestListProposedRoutines(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A dismissed routine must never be proposed again. The detector will keep
|
|
||||||
// finding the same pattern; the store is what stops maven nagging about it.
|
|
||||||
func TestDismissedProposedRoutineStaysDismissed(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
id, err := s.CreateProposedRoutine(ctx, "clean", "litter_box", 3.0, now)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProposedRoutine: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.DismissProposedRoutine(ctx, id); err != nil {
|
|
||||||
t.Fatalf("DismissProposedRoutine: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The detector re-proposes the same pattern.
|
|
||||||
_, err = s.CreateProposedRoutine(ctx, "clean", "litter_box", 3.0, now.Add(24*time.Hour))
|
|
||||||
if !errors.Is(err, ErrProposedRoutineExists) {
|
|
||||||
t.Fatalf("want ErrProposedRoutineExists on re-propose, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And it must not reappear on the review page.
|
|
||||||
list, err := s.ListProposedRoutines(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListProposedRoutines: %v", err)
|
|
||||||
}
|
|
||||||
if len(list) != 0 {
|
|
||||||
t.Fatalf("want 0 proposed, got %d", len(list))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dismissing again is a no-op, and accepting is refused.
|
|
||||||
if err := s.DismissProposedRoutine(ctx, id); err != nil {
|
|
||||||
t.Fatalf("second DismissProposedRoutine: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.AcceptProposedRoutine(ctx, id, 1); !errors.Is(err, ErrProposedRoutineNotFound) {
|
|
||||||
t.Fatalf("want ErrProposedRoutineNotFound accepting a dismissed routine, got %v", err)
|
|
||||||
}
|
|
||||||
r, err := s.LookupProposedRoutine(ctx, "clean", "litter_box")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LookupProposedRoutine: %v", err)
|
|
||||||
}
|
|
||||||
if r.Status != RoutineDismissed {
|
|
||||||
t.Fatalf("want status=dismissed, got %s", r.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListProposedRoutinesByStatus(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
keep, err := s.CreateProposedRoutine(ctx, "water", "plants", 4.0, now)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProposedRoutine: %v", err)
|
|
||||||
}
|
|
||||||
drop, err := s.CreateProposedRoutine(ctx, "walk", "dog", 1.0, now.Add(time.Hour))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProposedRoutine: %v", err)
|
|
||||||
}
|
|
||||||
remID, err := s.CreateReminder(ctx, now.Add(4*24*time.Hour), `{"text":"water plants"}`, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateReminder: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.AcceptProposedRoutine(ctx, keep, remID); err != nil {
|
|
||||||
t.Fatalf("AcceptProposedRoutine: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.DismissProposedRoutine(ctx, drop); err != nil {
|
|
||||||
t.Fatalf("DismissProposedRoutine: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
status string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{RoutineProposed, 0},
|
|
||||||
{RoutineAccepted, 1},
|
|
||||||
{RoutineDismissed, 1},
|
|
||||||
{"", 2}, // empty status ⇒ every row
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
list, err := s.ListProposedRoutinesByStatus(ctx, c.status)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListProposedRoutinesByStatus(%q): %v", c.status, err)
|
|
||||||
}
|
|
||||||
if len(list) != c.want {
|
|
||||||
t.Fatalf("status %q: want %d, got %d", c.status, c.want, len(list))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLookupMissingProposedRoutine(t *testing.T) {
|
func TestLookupMissingProposedRoutine(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
Reference in New Issue
Block a user