From d60a51c9e7543aa30c65395bcd85410794085760 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 03:22:34 +0400 Subject: [PATCH] store, mavweb: the delivery outbox can be read (V-390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table was write-only. Rows were recorded and nothing could show them, so the tests for #368 and #370 had to reach past the store into store.DB — if a test can only see it that way, so can nobody else. A durable record nobody reads answers no question, and why Maven went quiet is supposed to be a query. ListDeliveryAttempts returns recent rows newest first, filtered by status. Status is the filter worth having because the two real questions are "what got dropped" and "what is still pending", and neither is answerable by reading the whole list on a busy day. It reaches mavweb over IPC as DeliveryAttempts. The section goes on /notifications, which already answers "what did she send", rather than on a page of its own. Shared ui.css, the nav partial, the table in div.scroll. A failed outbox read leaves a log line and still renders the nudge list, because half the page beats none of it. --- cmd/mavweb/handlers_test.go | 36 +++++++++++++++++- cmd/mavweb/main.go | 49 ++++++++++++++++++++++++- cmd/mavweb/notifications.html | 22 +++++++++++ internal/ipc/api.go | 22 +++++++++++ internal/ipc/client.go | 9 +++++ internal/ipc/server.go | 29 +++++++++++++++ internal/ipc/unimplemented.go | 3 ++ internal/ipc/wire.go | 1 + internal/store/delivery.go | 65 +++++++++++++++++++++++++++++++++ internal/store/delivery_test.go | 50 +++++++++++++++++++++++++ 10 files changed, 284 insertions(+), 2 deletions(-) diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 53aa286..45db011 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -54,7 +54,9 @@ type fakeCore struct { revertErr error // for handleNotifications tests - nudgesErr error + nudgesErr error + attempts []ipc.DeliveryAttempt + attemptStatus string // for handleHistory tests historyFacts []ipc.Fact @@ -1251,3 +1253,35 @@ func TestHandleWS_AssertedSession_PassesGate(t *testing.T) { t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String()) } } + +func (f *fakeCore) DeliveryAttempts(_ context.Context, status string, _ int) ([]ipc.DeliveryAttempt, error) { + f.attemptStatus = status + return f.attempts, nil +} + +// TestHandleNotifications_ShowsTheOutbox — the outbox was written and never +// read, so a dropped or failed send was invisible (Vikunja #390). +func TestHandleNotifications_ShowsTheOutbox(t *testing.T) { + done := time.Date(2026, 8, 4, 9, 0, 30, 0, time.UTC) + core := &fakeCore{ + attempts: []ipc.DeliveryAttempt{ + {Kind: "nudge", Rule: "care-check", Channel: "telegram", Status: "dropped", + Created: done.Add(-30 * time.Second), Completed: &done}, + {Kind: "reminder", ReminderID: 7, Channel: "voice", Status: "pending", Created: done}, + }, + } + rr := httptest.NewRecorder() + handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications?status=dropped", nil), core) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + if core.attemptStatus != "dropped" { + t.Errorf("status filter = %q, want it passed through", core.attemptStatus) + } + body := rr.Body.String() + for _, want := range []string{"care-check", "dropped", "reminder #7", "Delivery outbox"} { + if !strings.Contains(body, want) { + t.Errorf("rendered outbox missing %q", want) + } + } +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index a80d951..9d8368c 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -893,12 +893,59 @@ func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAP http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway) return } + // The outbox, on the page that already answers "what did she send". + // A failed or dropped attempt is why she went quiet, and until now it was + // recorded and unreadable (Vikunja #390). Filter with ?status=dropped. + status := r.URL.Query().Get("status") + attempts, err := core.DeliveryAttempts(ctx, status, 50) + if err != nil { + // The nudge list is still worth showing, so this is a note on the page + // rather than a dead page. + log.Printf("notifications: delivery attempts: %v", err) + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := notificationsTmpl.Execute(w, map[string]any{"Nudges": nudges}); err != nil { + if err := notificationsTmpl.Execute(w, map[string]any{ + "Nudges": nudges, + "Attempts": deliveryRows(attempts), + "Status": status, + }); err != nil { log.Printf("notifications template: %v", err) } } +// deliveryRow is one outbox line, with every timestamp already formatted so +// the template holds no date logic — same shape as taskRow. +type deliveryRow struct { + Kind string + Target string + Channel string + Status string + Created string + Completed string +} + +func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow { + out := make([]deliveryRow, 0, len(as)) + for _, a := range as { + target := a.Rule + if target == "" && a.ReminderID != 0 { + target = "reminder #" + strconv.FormatInt(a.ReminderID, 10) + } + row := deliveryRow{ + Kind: a.Kind, + Target: target, + Channel: a.Channel, + Status: a.Status, + Created: a.Created.Format("02.01 15:04"), + } + if a.Completed != nil { + row.Completed = a.Completed.Format("15:04") + } + out = append(out, row) + } + return out +} + func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable) diff --git a/cmd/mavweb/notifications.html b/cmd/mavweb/notifications.html index 8d81cd9..4ebf044 100644 --- a/cmd/mavweb/notifications.html +++ b/cmd/mavweb/notifications.html @@ -14,5 +14,27 @@
no notifications yet
check back later or ask maven a question
{{end}} +

Delivery outbox

+

+ every send is recorded before it leaves, so a failure is visible rather than silent. + all · + dropped · + failed · + pending · + unknown +

+{{if .Attempts}}
+ +{{range .Attempts}} + + + + + + +{{end}}
startedkindrulechannelstatusfinished
{{.Created}}{{.Kind}}{{.Target}}{{.Channel}}{{.Status}}{{.Completed}}
+{{else}}
+
no delivery attempts{{if .Status}} with status {{.Status}}{{end}}
+
{{end}} {{template "shellBottom"}} diff --git a/internal/ipc/api.go b/internal/ipc/api.go index eb3def0..1eb8178 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -60,6 +60,19 @@ type Nudge struct { OutcomeTs *int64 `json:"outcome_ts,omitempty"` } +// DeliveryAttempt — one row of the delivery outbox. Times are formatted by the +// reader; Completed is nil while the attempt is still pending. +type DeliveryAttempt struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + Rule string `json:"rule,omitempty"` + ReminderID int64 `json:"reminder_id,omitempty"` + Channel string `json:"channel"` + Status string `json:"status"` + Created time.Time `json:"created"` + Completed *time.Time `json:"completed,omitempty"` +} + // Note — a recall/preference item; ranked by embedding cosine on query. // Score is set by QueryNotes (0 on the write path). type Note struct { @@ -521,6 +534,12 @@ type outcomesReq struct { type nReq struct { N int `json:"n"` } + +// deliveryAttemptsReq — the outbox read. Status is empty for every status. +type deliveryAttemptsReq struct { + Status string `json:"status,omitempty"` + N int `json:"n"` +} type kindNReq struct { Kind string `json:"kind"` N int `json:"n"` @@ -685,6 +704,9 @@ type CoreAPI interface { RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) RecentNudges(ctx context.Context, n int) ([]Nudge, error) + // DeliveryAttempts reads the outbox, newest first. An empty status means + // every status (Vikunja #390). + DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) // RecentEcosystemTraces reads the ecosystem call log, which lives in its // own table so machine-rate traces never crowd out human-rate facts. diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 4cba25c..4a36322 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -68,6 +68,7 @@ var readOnlyMethods = map[Method]bool{ MethodRecentActiveFacts: true, MethodCalendarEvents: true, MethodRecentNudges: true, + MethodDeliveryAttempts: true, MethodRecentEcoTraces: true, MethodQueryNotes: true, MethodRecentNotes: true, @@ -373,6 +374,14 @@ func (c *Client) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemT return out, nil } +func (c *Client) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) { + var out []DeliveryAttempt + if err := c.call(ctx, MethodDeliveryAttempts, deliveryAttemptsReq{Status: status, N: n}, &out); err != nil { + return nil, err + } + return out, nil +} + func (c *Client) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { var out []Nudge if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil { diff --git a/internal/ipc/server.go b/internal/ipc/server.go index f9b2692..ae596a5 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -173,6 +173,25 @@ func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { return out, nil } +func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) { + as, err := a.s.ListDeliveryAttempts(ctx, status, n) + if err != nil { + return nil, mapErr(err) + } + out := make([]DeliveryAttempt, len(as)) + for i, at := range as { + out[i] = DeliveryAttempt{ + ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID, + Channel: at.Channel, Status: at.Status, Created: at.Created, + } + if at.HasComplete { + t := at.Completed + out[i].Completed = &t + } + } + return out, nil +} + func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) { id, err := a.s.WriteNote(ctx, ts, text, embedding, source) return id, mapErr(err) @@ -863,6 +882,16 @@ var methodTable = map[Method]handlerFunc{ } return out, nil }), + MethodDeliveryAttempts: withParams(func(ctx context.Context, api CoreAPI, p deliveryAttemptsReq) ([]DeliveryAttempt, error) { + out, err := api.DeliveryAttempts(ctx, p.Status, p.N) + if err != nil { + return nil, err + } + if out == nil { + out = []DeliveryAttempt{} + } + return out, nil + }), MethodRecentNudges: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) { out, err := api.RecentNudges(ctx, p.N) if err != nil { diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go index 45575f3..4279c67 100644 --- a/internal/ipc/unimplemented.go +++ b/internal/ipc/unimplemented.go @@ -68,6 +68,9 @@ func (UnimplementedCoreAPI) RecentActiveFactsByKind(ctx context.Context, kind st func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) { return nil, ErrNotImplemented } +func (UnimplementedCoreAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) { + return nil, ErrNotImplemented +} func (UnimplementedCoreAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { return nil, ErrNotImplemented } diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index c55a7ec..50e2f15 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -28,6 +28,7 @@ const ( MethodRecentActiveFacts Method = "recent_active_facts_by_kind" MethodCalendarEvents Method = "calendar_events" MethodRecentNudges Method = "recent_nudges" + MethodDeliveryAttempts Method = "delivery_attempts" MethodRecentEcoTraces Method = "recent_ecosystem_traces" MethodWriteNote Method = "write_note" MethodQueryNotes Method = "query_notes" diff --git a/internal/store/delivery.go b/internal/store/delivery.go index b0b46df..4c016c7 100644 --- a/internal/store/delivery.go +++ b/internal/store/delivery.go @@ -87,3 +87,68 @@ func (s *Store) ReconcileStaleDeliveryAttempts(ctx context.Context, now time.Tim } return int(n), nil } + +// DeliveryAttempt — one row of the outbox, as a reader sees it. +type DeliveryAttempt struct { + ID int64 + Kind string // nudge|reminder + Rule string // set for nudges + ReminderID int64 // set for reminders + Channel string + Status string // one of the Delivery* constants + Created time.Time + Completed time.Time // zero while pending + HasComplete bool +} + +// ListDeliveryAttempts returns recent attempts, newest first. An empty status +// means every status; anything else filters on it. +// +// The table was write-only until 04-08-2026: rows were recorded and nothing +// could read them, so the tests for #368 and #370 had to reach past the store +// into store.DB, which is the tell (Vikunja #390). A durable record nobody can +// read answers no question, and "why did Maven go quiet" is supposed to be a +// query rather than a mystery. +// +// Status is the filter that earns its place, because the two questions actually +// asked are "what got dropped" and "what is still pending". Neither is +// answerable by reading the whole list on a busy day. +func (s *Store) ListDeliveryAttempts(ctx context.Context, status string, limit int) ([]DeliveryAttempt, error) { + if limit <= 0 { + limit = 50 + } + q := `SELECT id, kind, rule, reminder_id, channel, status, created_ts, completed_ts + FROM delivery_attempts` + args := []any{} + if status != "" { + q += ` WHERE status = ?` + args = append(args, status) + } + q += ` ORDER BY created_ts DESC, id DESC LIMIT ?` + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("list delivery attempts: %w", err) + } + defer rows.Close() + + var out []DeliveryAttempt + for rows.Next() { + var a DeliveryAttempt + var created int64 + var completed *int64 + if err := rows.Scan(&a.ID, &a.Kind, &a.Rule, &a.ReminderID, &a.Channel, &a.Status, &created, &completed); err != nil { + return nil, fmt.Errorf("list delivery attempts: scan: %w", err) + } + a.Created = time.UnixMilli(created) + if completed != nil { + a.Completed, a.HasComplete = time.UnixMilli(*completed), true + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list delivery attempts: %w", err) + } + return out, nil +} diff --git a/internal/store/delivery_test.go b/internal/store/delivery_test.go index 4a7e23d..d261448 100644 --- a/internal/store/delivery_test.go +++ b/internal/store/delivery_test.go @@ -32,3 +32,53 @@ func TestDroppedDeliveryAttemptRoundTrips(t *testing.T) { t.Fatalf("status: want %q, got %q", DeliveryDropped, status) } } + +// TestListDeliveryAttempts — the read path the outbox lacked until #390. The +// two questions it must answer are "what was dropped" and "what is pending". +func TestListDeliveryAttempts(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + base := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) + + sent, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "telegram", "h1", base) + if err != nil { + t.Fatal(err) + } + if err := s.CompleteDeliveryAttempt(ctx, sent, DeliverySent, base.Add(time.Second)); err != nil { + t.Fatal(err) + } + dropped, err := s.BeginDeliveryAttempt(ctx, "nudge", "care", 0, "telegram", "h2", base.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + if err := s.CompleteDeliveryAttempt(ctx, dropped, DeliveryDropped, base.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if _, err := s.BeginDeliveryAttempt(ctx, "reminder", "", 7, "voice", "h3", base.Add(2*time.Minute)); err != nil { + t.Fatal(err) + } + + all, err := s.ListDeliveryAttempts(ctx, "", 10) + if err != nil || len(all) != 3 { + t.Fatalf("ListDeliveryAttempts = %d rows, err=%v, want 3", len(all), err) + } + // Newest first. + if all[0].Kind != "reminder" || all[0].ReminderID != 7 { + t.Fatalf("newest row is %+v, want the reminder", all[0]) + } + if all[0].HasComplete { + t.Fatalf("a pending row must have no completion time: %+v", all[0]) + } + if !all[2].HasComplete || !all[2].Completed.Equal(base.Add(time.Second)) { + t.Fatalf("completed row lost its time: %+v", all[2]) + } + + only, err := s.ListDeliveryAttempts(ctx, DeliveryDropped, 10) + if err != nil || len(only) != 1 || only[0].Rule != "care" { + t.Fatalf("dropped filter = %+v, err=%v", only, err) + } + pending, err := s.ListDeliveryAttempts(ctx, DeliveryPending, 10) + if err != nil || len(pending) != 1 || pending[0].Kind != "reminder" { + t.Fatalf("pending filter = %+v, err=%v", pending, err) + } +} -- 2.52.0