Files
Maven/cmd/mavweb/ecosystem.go
T
kami 5aaecd2a53 store: give ecosystem traces their own table
Traces were written as facts. A single Praxis action wrote several of
them, so machine-rate rows crowded out the bounded fact readers that
humans and evaluation consume. The habit profile window of 2000 facts
and the memeval snapshot both filled with call records instead of what
Maven learned about the owner. Traces now go to ecosystem_traces, with
correlation, causation, duration and HTTP status as columns, pruned to
the most recent 5000. The new reader is exposed over IPC and rendered
as the Calls card on the ecosystem page, so it is a table someone
actually looks at.

Found in review of #84.
2026-08-01 14:13:58 +04:00

125 lines
3.5 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
"github.com/kami/maven/internal/ipc"
)
// The three sibling services Maven coordinates are headless JSON APIs (no web UI
// of their own — arch §16: mavweb IS their human surface). This page is that
// surface: a read-only observability panel that GETs each sibling's list
// endpoints and renders them. Never mutates — enabling/executing stays on the
// authed /tools + voice paths, never here.
// ecoURLs holds the sibling base URLs; empty ⇒ that panel shows "not configured".
type ecoURLs struct {
nexus, praxis, hexis string
}
var ecoClient = &http.Client{Timeout: 6 * time.Second}
// getEco GETs path off base and decodes the JSON array into out. Returns a
// human error string (empty on success) — honest per-panel state, no spinner.
func getEco(ctx context.Context, base, path string, out any) string {
if base == "" {
return "not configured"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
if err != nil {
return err.Error()
}
resp, err := ecoClient.Do(req)
if err != nil {
return "unreachable"
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Sprintf("http %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return "bad json"
}
return ""
}
// Minimal projections of each sibling's list payload — only the columns the
// panel shows. Extra JSON fields are ignored.
type ecoEntity struct {
ID string `json:"id"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
State string `json:"state"`
UpdatedAt time.Time `json:"updated_at"`
}
type ecoItem struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
State string `json:"state"`
Importance int `json:"importance"`
LastSeenAt time.Time `json:"last_seen_at"`
}
type ecoCap struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Operation string `json:"operation"`
Risk string `json:"risk"`
ReadOnly bool `json:"read_only"`
}
type ecoPanel[T any] struct {
Rows []T
Err string
}
type ecoData struct {
Nexus ecoPanel[ecoEntity]
Praxis ecoPanel[ecoItem]
Hexis ecoPanel[ecoCap]
Calls ecoPanel[ipc.EcosystemTrace]
}
// handleEcosystem renders the three sibling panels plus Maven's own log of the
// calls she made to them. The call log comes from core, not from the siblings:
// it is what Maven saw, including the hops that never got an answer.
func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs, core ipc.CoreAPI) {
ctx := r.Context()
var d ecoData
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
d.Nexus.Err = getEco(ctx, urls.nexus, "/api/v1/entities?limit=50", &d.Nexus.Rows)
}()
go func() {
defer wg.Done()
d.Praxis.Err = getEco(ctx, urls.praxis, "/api/v1/items?limit=50", &d.Praxis.Rows)
}()
go func() { defer wg.Done(); d.Hexis.Err = getEco(ctx, urls.hexis, "/api/v1/capabilities", &d.Hexis.Rows) }()
wg.Wait()
if core == nil {
d.Calls.Err = "not configured"
} else if rows, err := core.RecentEcosystemTraces(ctx, 50); err != nil {
log.Printf("ecosystem traces: %v", err)
d.Calls.Err = "core read failed"
} else {
d.Calls.Rows = rows
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := ecosystemTmpl.Execute(w, d); err != nil {
log.Printf("ecosystem render: %v", err)
}
}