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.
134 lines
4.2 KiB
Go
134 lines
4.2 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 {
|
|
log.Printf("ecosystem panel request_id=%s build %q: %v", requestIDFromContext(ctx), path, err)
|
|
return "invalid endpoint"
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("X-Requested-By", "mavweb")
|
|
// These are direct browser-surface reads rather than an action initiated in
|
|
// mavend, so the HTTP request ID is the natural correlation root. Calls that
|
|
// pass through core mint their action correlation inside mavend instead.
|
|
if id := requestIDFromContext(ctx); id != "" {
|
|
req.Header.Set("X-Correlation-ID", id)
|
|
}
|
|
resp, err := ecoClient.Do(req)
|
|
if err != nil {
|
|
log.Printf("ecosystem panel request_id=%s GET %s: %v", requestIDFromContext(ctx), path, err)
|
|
return "unreachable"
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
log.Printf("ecosystem panel request_id=%s GET %s: HTTP %d", requestIDFromContext(ctx), path, resp.StatusCode)
|
|
return fmt.Sprintf("http %d", resp.StatusCode)
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
log.Printf("ecosystem panel request_id=%s decode %s: %v", requestIDFromContext(ctx), path, err)
|
|
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 request_id=%s: %v", requestIDFromContext(ctx), err)
|
|
d.Calls.Err = "core read failed"
|
|
} else {
|
|
d.Calls.Rows = rows
|
|
}
|
|
|
|
renderPage(w, ecosystemTmpl, d)
|
|
}
|