35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
207 lines
6.4 KiB
Go
207 lines
6.4 KiB
Go
package main
|
|
|
|
import (
|
|
"cmp"
|
|
_ "embed"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
)
|
|
|
|
// The read-only pages: dash, history, trace, morning, events, and the voice
|
|
// page mounted at "/". Each is GET-only, reads through CoreAPI and renders.
|
|
// The write surfaces live next to their own handlers (tasks.go, tools.go,
|
|
// routines.go, chat.go).
|
|
|
|
//go:embed dash.html
|
|
var dashHTML string
|
|
|
|
//go:embed history.html
|
|
var historyHTML string
|
|
|
|
//go:embed trace.html
|
|
var traceHTML string
|
|
|
|
//go:embed morning.html
|
|
var morningHTML string
|
|
|
|
//go:embed events.html
|
|
var eventsHTML string
|
|
|
|
//go:embed voice.html
|
|
var voiceHTML string
|
|
|
|
//go:embed ecosystem.html
|
|
var ecosystemHTML string
|
|
|
|
// dashTmpl — the monitoring read surface, server-rendered from dash.html;
|
|
// a small fetch loop refreshes the tables in place. html/template escapes the
|
|
// user text in facts/nudges. Read-only: browses the append-only store via
|
|
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
|
|
var dashTmpl = parsePage("dash", dashHTML, nil)
|
|
|
|
var historyTmpl = parsePage("history", historyHTML, nil)
|
|
|
|
var traceTmpl = parsePage("trace", traceHTML, template.FuncMap{
|
|
"fmtTime": func(t *time.Time) string {
|
|
if t == nil || t.IsZero() {
|
|
return "—"
|
|
}
|
|
return t.Format("15:04:05")
|
|
},
|
|
"join": strings.Join,
|
|
})
|
|
|
|
// morningTmpl — read-only view of today's checklist state per configured
|
|
// morning routine (internal/morning). Same shape as trace.html: a plain
|
|
// server-rendered page, refreshed on reload — no live-update loop, since
|
|
// checklist state changes on the scale of minutes, not seconds.
|
|
var morningTmpl = parsePage("morning", morningHTML, nil)
|
|
|
|
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
|
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
|
var eventsTmpl = parsePage("events", eventsHTML, nil)
|
|
|
|
var voiceTmpl = parsePage("voice", voiceHTML, nil)
|
|
|
|
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
|
|
// human surface is here (they ship no web UI of their own).
|
|
var ecosystemTmpl = parsePage("ecosystem", ecosystemHTML, nil)
|
|
|
|
func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if !requireCore(w, r, core, "dash") {
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
pres, err1 := core.Presence(ctx)
|
|
facts, err2 := core.RecentFacts(ctx, 50)
|
|
nudges, err3 := core.RecentNudges(ctx, 50)
|
|
notes, err4 := core.RecentNotes(ctx, 50)
|
|
if err := cmp.Or(err1, err2, err3, err4); err != nil {
|
|
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
|
"core read failed", fmt.Errorf("read dashboard: %w", err))
|
|
return
|
|
}
|
|
renderPage(w, dashTmpl, struct {
|
|
Presence ipc.Presence
|
|
Facts []ipc.Fact
|
|
Nudges []ipc.Nudge
|
|
Notes []ipc.Note
|
|
}{pres, facts, nudges, notes})
|
|
}
|
|
|
|
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if !requireCore(w, r, core, "history") {
|
|
return
|
|
}
|
|
facts, err := core.RecentFacts(r.Context(), 200)
|
|
if err != nil {
|
|
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
|
"core read failed", fmt.Errorf("read fact history: %w", err))
|
|
return
|
|
}
|
|
renderPage(w, historyTmpl, struct {
|
|
Facts []ipc.Fact
|
|
}{facts})
|
|
}
|
|
|
|
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if !requireCore(w, r, core, "trace") {
|
|
return
|
|
}
|
|
trace, err := core.TickTrace(r.Context())
|
|
if err != nil {
|
|
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
|
"core read failed", fmt.Errorf("read tick trace: %w", err))
|
|
return
|
|
}
|
|
// The turn records share this page rather than getting one of their own
|
|
// (V-564). Both answer the same question, who won and who lost and why, and
|
|
// one is about nudges while the other is about utterances. A read failure
|
|
// here is not fatal to the page. The rule trace above it still renders, and
|
|
// a daemon too old to know the method is the ordinary case during a rolling
|
|
// deploy.
|
|
turns, err := core.TurnDecisions(r.Context(), 25)
|
|
if err != nil {
|
|
logProblem(r, http.StatusOK, problemCoreReadFailed,
|
|
"turn decisions unavailable", fmt.Errorf("read turn decisions: %w", err))
|
|
}
|
|
renderPage(w, traceTmpl, traceData{Tick: trace, Turns: turns})
|
|
}
|
|
|
|
// traceData — what trace.html renders: the last tick's rule arbitration and the
|
|
// last turns' claim arbitration.
|
|
type traceData struct {
|
|
Tick ipc.TickTrace
|
|
Turns []ipc.TurnDecision
|
|
}
|
|
|
|
// morningView — what /morning renders: today's plan on top, the checklist
|
|
// state under it. PlanErr is set instead of Plan when the core could not build
|
|
// a plan, so the page says so rather than showing an empty day.
|
|
type morningView struct {
|
|
Plan *ipc.DayPlan
|
|
PlanErr string
|
|
Routines []ipc.MorningRoutineStatus
|
|
}
|
|
|
|
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if !requireCore(w, r, core, "morning") {
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
status, err := core.MorningStatus(ctx)
|
|
if err != nil {
|
|
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
|
"core read failed", fmt.Errorf("read morning status: %w", err))
|
|
return
|
|
}
|
|
view := morningView{Routines: status}
|
|
// The day plan (#128) shows on this page because it is the same question at
|
|
// a different scale. A plan read that fails must not take the checklist
|
|
// down with it — the page degrades to what it had before.
|
|
plan, err := core.DayPlan(ctx)
|
|
if err != nil {
|
|
view.PlanErr = inlineProblem(r, problemCoreReadFailed,
|
|
"day plan unavailable", fmt.Errorf("read day plan: %w", err))
|
|
} else {
|
|
view.Plan = &plan
|
|
}
|
|
renderPage(w, morningTmpl, view)
|
|
}
|
|
|
|
// eventsView — what /events renders. Err is set instead of Events when the
|
|
// core could not serve the journal, so the page says why rather than showing an
|
|
// empty intake and implying nothing arrived.
|
|
type eventsView struct {
|
|
Events []ipc.IntakeEvent
|
|
Err string
|
|
}
|
|
|
|
// eventsPageLimit — how many envelopes the page shows. The ring holds more; a
|
|
// page is for scanning what just happened, not for archaeology.
|
|
const eventsPageLimit = 200
|
|
|
|
func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if !requireCore(w, r, core, "intake journal") {
|
|
return
|
|
}
|
|
var view eventsView
|
|
evs, err := core.RecentEvents(r.Context(), eventsPageLimit)
|
|
if err != nil {
|
|
view.Err = inlineProblem(r, problemCoreReadFailed,
|
|
"intake journal unavailable", fmt.Errorf("read intake journal: %w", err))
|
|
} else {
|
|
view.Events = evs
|
|
}
|
|
renderPage(w, eventsTmpl, view)
|
|
}
|
|
|
|
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
|
renderPage(w, voiceTmpl, nil)
|
|
}
|