Files
Pete/internal/web/status.go
T
prosolis 91d25e9da1 adventure: stop dropping dispatches Pete has no words for
An event_type with no template was a 400 at ingest. That reads like caution
and behaves like deletion: gogobee retries a 400 to its cap and then parks the
row forever, so rejecting a type Pete hadn't learned to phrase didn't defer the
event, it destroyed it.

companion_hire went that way. It has been emitted from `!expedition hire` since
the combat-engine work landed and has never once reached the site — the game
logged a successful emit every time, and the queue row simply never sent. The
mitigation on the books was "always deploy Pete first", which is a thing a
person has to remember rather than a property of the system.

So invert it. An unknown type now warns, gets counted, and publishes on a
neutral fallback. 400 is kept for facts that are actually invalid: no guid, or
a name that failed the fact-guard. gogobee can ship a new event type any day of
the week now; the worst case is a thin card until Pete learns the words.

It is thinner than it sounds in practice. gogobee authors dispatch prose from
the fact's fields with no per-type switch, so an unrecognised type still
arrives with a real headline and lede and is allowed to use them. The fallback
only shows through when the model is off or the prose-guard refused the output.

Untemplated types never post live to Matrix, whatever tier they claim. A thin
card among cards is cheap and reversible; pinging everyone in the room with a
dispatch Pete couldn't phrase is neither. The daily digest still carries it,
one line among many, which is the right volume for something we don't
understand yet.

And give companion_hire its template. Pete is the one being hired, so it is
first-person like his duels — third-person Pete filling in as a cleric reads as
somebody else reporting on him.

The admin status page grows a "dispatches with no template" panel, so the next
one of these is a to-do list Pete can see rather than an archaeology dig.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 14:46:02 -07:00

161 lines
4.8 KiB
Go

package web
import (
"log/slog"
"net/http"
"sort"
"time"
"pete/internal/storage"
)
// isAdmin reports whether the request carries a signed-in session whose OIDC
// subject is on the admin allowlist. False when auth is off, the allowlist is
// empty, or the visitor is anonymous.
func (s *Server) isAdmin(r *http.Request) bool {
if s.auth == nil || len(s.adminSubs) == 0 {
return false
}
u := s.auth.userFromRequest(r)
if u == nil {
return false
}
return s.adminSubs[u.Sub]
}
// sourceStatus is one row of the source-health dashboard: the configured feed
// plus its persisted poll health and derived content stats.
type sourceStatus struct {
Name string
Channel string
Healthy bool // last poll succeeded (no consecutive failures)
NeverRun bool // no poll recorded yet
LastPollAt time.Time
LastSuccessAt time.Time
LastError string
Failures int
LastItemCount int
Total int
Classified int
Paywalled int
PaywallRate int // percent of retained stories that are gated
LastSeenAt time.Time
LastPostedAt time.Time
}
type statusPage struct {
pageData
Sources []sourceStatus
DegradedCnt int // sources currently failing
Admin bool // viewer is an admin: show the full diagnostic columns
// Untemplated adventure event types seen since boot, busiest first. Admin-only:
// it names game internals, and it is a to-do list for Pete's vocabulary rather
// than anything a reader wants. Empty in the healthy case.
UnknownAdv []unknownAdvType
}
// unknownAdvType is one event type gogobee sent that Pete had no template for.
type unknownAdvType struct {
EventType string
Count int
}
// handleStatus renders the source-health page. It's public: everyone sees a
// trimmed reader view (per-feed up/stale/idle and when each last updated), while
// admins additionally get the operator diagnostics (poll cadence, item counts,
// paywall rates, posting times, and raw fetch errors).
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
admin := s.isAdmin(r)
s.track(r, "status")
health, err := storage.ListSourceHealth()
if err != nil {
slog.Error("web: source health query failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
stats, err := storage.SourceContentStats()
if err != nil {
slog.Error("web: source content stats failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
rows := make([]sourceStatus, 0, len(s.sources))
degraded := 0
for _, src := range s.sources {
h, hasHealth := health[src.Name]
st := stats[src.Name]
row := sourceStatus{
Name: src.Name,
Channel: src.Channel,
NeverRun: !hasHealth || h.LastPollAt == 0,
LastError: h.LastError,
Failures: h.ConsecutiveFailures,
LastItemCount: h.LastItemCount,
Total: st.Total,
Classified: st.Classified,
Paywalled: st.Paywalled,
}
row.Healthy = hasHealth && h.ConsecutiveFailures == 0
// Raw fetch errors leak feed-specific workarounds and upstream URLs, so
// they stay out of the public payload entirely (not just hidden in CSS).
if !admin {
row.LastError = ""
}
if h.LastPollAt > 0 {
row.LastPollAt = time.Unix(h.LastPollAt, 0)
}
if h.LastSuccessAt > 0 {
row.LastSuccessAt = time.Unix(h.LastSuccessAt, 0)
}
if st.LastSeenAt > 0 {
row.LastSeenAt = time.Unix(st.LastSeenAt, 0)
}
if st.LastPostedAt > 0 {
row.LastPostedAt = time.Unix(st.LastPostedAt, 0)
}
if st.Total > 0 {
row.PaywallRate = st.Paywalled * 100 / st.Total
}
if !row.NeverRun && !row.Healthy {
degraded++
}
rows = append(rows, row)
}
// Failing sources first (most consecutive failures), then healthy ones by
// name, so the owner's eye lands on what needs attention.
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].Failures != rows[j].Failures {
return rows[i].Failures > rows[j].Failures
}
return rows[i].Name < rows[j].Name
})
// Untemplated dispatch types, admin-only. These publish on the neutral
// fallback rather than being rejected (see handleAdventureIngest), so nothing
// is lost by not noticing — but a type sitting here with a rising count means
// the section is carrying thin cards Pete could be writing properly.
var unknownAdv []unknownAdvType
if admin {
for t, n := range AdvUnknownTypeCounts() {
unknownAdv = append(unknownAdv, unknownAdvType{EventType: t, Count: n})
}
sort.SliceStable(unknownAdv, func(i, j int) bool {
if unknownAdv[i].Count != unknownAdv[j].Count {
return unknownAdv[i].Count > unknownAdv[j].Count
}
return unknownAdv[i].EventType < unknownAdv[j].EventType
})
}
base := s.base(r)
base.Active = "status"
s.render(w, "status", statusPage{pageData: base, Sources: rows, DegradedCnt: degraded, Admin: admin,
UnknownAdv: unknownAdv})
}