Files
jarvis-home/cmd/homehub/debug.go
T
2026-08-29 17:02:35 +02:00

235 lines
7.8 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"strconv"
"strings"
"time"
"homehub/internal/debugtrace"
"homehub/internal/store"
)
type captureWriter struct {
http.ResponseWriter
status int
buf bytes.Buffer
max int
}
func (w *captureWriter) WriteHeader(code int) {
if w.status == 0 {
w.status = code
}
w.ResponseWriter.WriteHeader(code)
}
func (w *captureWriter) Write(p []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
if w.buf.Len() < w.max {
remain := w.max - w.buf.Len()
if remain > len(p) {
remain = len(p)
}
_, _ = w.buf.Write(p[:remain])
}
return w.ResponseWriter.Write(p)
}
func (a *app) traceHTTP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if a.debug == nil || !a.debug.Enabled() || !strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/api/debug") {
next.ServeHTTP(w, r)
return
}
if (r.URL.Path == "/api/state" || r.URL.Path == "/api/health") && !a.debug.Config().LogHTTPState {
next.ServeHTTP(w, r)
return
}
ctx, traceID := debugtrace.EnsureTrace(r.Context(), "http")
r = r.WithContext(ctx)
w.Header().Set("X-JARVIS-Trace-ID", traceID)
started := time.Now()
var requestData any = map[string]any{"method": r.Method, "path": r.URL.Path, "query": r.URL.Query()}
ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if r.Body != nil && (ct == "application/json" || ct == "text/plain" || ct == "application/x-www-form-urlencoded") {
raw, _ := io.ReadAll(io.LimitReader(r.Body, 512<<10))
r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(raw))
if len(raw) > 0 {
var v any
if json.Unmarshal(raw, &v) == nil {
requestData = map[string]any{"method": r.Method, "path": r.URL.Path, "query": r.URL.Query(), "body": v}
} else {
requestData = map[string]any{"method": r.Method, "path": r.URL.Path, "query": r.URL.Query(), "body_text": string(raw)}
}
}
} else if strings.HasPrefix(ct, "multipart/") {
requestData = map[string]any{"method": r.Method, "path": r.URL.Path, "content_type": ct, "content_length": r.ContentLength, "note": "multipart raw bytes intentionally not logged"}
}
a.debug.Record(ctx, "http", "request", "input", "info", requestData, nil, 0)
cw := &captureWriter{ResponseWriter: w, max: 512 << 10}
next.ServeHTTP(cw, r)
respData := map[string]any{"status": cw.status, "content_type": cw.Header().Get("Content-Type")}
body := bytes.TrimSpace(cw.buf.Bytes())
if len(body) > 0 {
var v any
if json.Unmarshal(body, &v) == nil {
respData["body"] = v
} else if strings.HasPrefix(cw.Header().Get("Content-Type"), "text/") {
respData["body_text"] = string(body)
} else {
respData["body_bytes_captured"] = len(body)
}
}
a.debug.Record(ctx, "http", "response", "output", "info", respData, nil, time.Since(started))
})
}
func traceBackground(ctx context.Context) context.Context {
id := debugtrace.TraceID(ctx)
if id == "" {
return context.Background()
}
return debugtrace.WithTrace(context.Background(), id)
}
func (a *app) trace(ctx context.Context, component, stage, direction string, data any) {
if a.debug != nil {
a.debug.Record(ctx, component, stage, direction, "info", data, nil, 0)
}
}
func (a *app) traceErr(ctx context.Context, component, stage string, data any, err error, started time.Time) {
if a.debug != nil {
a.debug.Record(ctx, component, stage, "output", "error", data, err, time.Since(started))
}
}
func (a *app) traceTimed(ctx context.Context, component, stage, direction string, data any, started time.Time) {
if a.debug != nil {
a.debug.Record(ctx, component, stage, direction, "info", data, nil, time.Since(started))
}
}
func (a *app) debugRuntimeState() map[string]any {
a.pendingMu.Lock()
var pending any
if a.pending != nil {
cp := *a.pending
cp.Items = append([]pendingItem(nil), a.pending.Items...)
pending = cp
}
a.pendingMu.Unlock()
a.recentMu.Lock()
var recent any
if a.recent != nil {
recent = *a.recent
}
a.recentMu.Unlock()
a.confirmMu.Lock()
var confirmation any
if a.confirm != nil {
cp := *a.confirm
cp.Arguments = map[string]any{}
for k, v := range a.confirm.Arguments {
cp.Arguments[k] = v
}
confirmation = cp
}
a.confirmMu.Unlock()
return map[string]any{"pending_command": pending, "pending_tool_confirmation": confirmation, "recent_mutation": recent}
}
func debugStateSnapshot(st store.State) map[string]any {
docs := make([]map[string]any, 0, len(st.Documents))
for _, d := range st.Documents {
docs = append(docs, map[string]any{
"id": d.ID, "name": d.Name, "mime": d.Mime, "tags": d.Tags,
"chunkCount": d.ChunkCount, "indexedAt": d.IndexedAt, "createdAt": d.CreatedAt,
"textChars": len([]rune(d.Text)),
})
}
return map[string]any{
"tasks": st.Tasks, "events": st.Events, "reminders": st.Reminders,
"groceries": st.Groceries, "recipes": st.Recipes, "mealPlans": st.MealPlans,
"kanban": st.Kanban, "documents": docs, "chat": st.Chat, "activity": st.Activity,
}
}
func (a *app) handleDebugExport(w http.ResponseWriter, r *http.Request) {
if a.debug == nil || !a.debug.Enabled() {
http.Error(w, "Debug tracing ist deaktiviert", 404)
return
}
limit := 5000
if v, _ := strconv.Atoi(r.URL.Query().Get("limit")); v > 0 && v <= 50000 {
limit = v
}
events, err := a.debug.Events(limit)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
st := a.db.Snapshot()
ms := a.ai.Status()
runtimeState := a.debugRuntimeState()
manifest := map[string]any{
"schema": "jarvis.debug.export.v3", "generated_at": time.Now().In(a.timezone).Format(time.RFC3339), "timezone": a.timezone.String(),
"events": len(events), "ollama": ms, "context": a.context, "debug": a.debug.Config(), "rag_chunks": a.rag.Count(),
"state_summary": map[string]any{"events": len(st.Events), "tasks": len(st.Tasks), "reminders": len(st.Reminders), "groceries": len(st.Groceries), "recipes": len(st.Recipes), "meal_plans": len(st.MealPlans), "kanban": len(st.Kanban), "documents": len(st.Documents), "chat_turns": len(st.Chat)},
"runtime": runtimeState,
"agent": map[string]any{"mode": "skill-mesh-agent", "max_steps": a.context.AgentMaxSteps, "tools": toolNames(a.skills.Definitions())},
"skills": a.skills.Status(),
"mesh": a.mesh.Status(),
"docker_skills": a.docker.Status(r.Context()),
"home_state": debugStateSnapshot(st),
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=jarvis-debug-%s.json", time.Now().Format("20060102-150405")))
w.WriteHeader(200)
_, _ = w.Write([]byte("{\n \"manifest\": "))
mb, _ := json.MarshalIndent(manifest, " ", " ")
_, _ = w.Write(mb)
_, _ = w.Write([]byte(",\n \"events\": [\n"))
for i, e := range events {
if i > 0 {
_, _ = w.Write([]byte(",\n"))
}
_, _ = w.Write([]byte(" "))
_, _ = w.Write(e)
}
_, _ = w.Write([]byte("\n ]\n}\n"))
}
func (a *app) handleDebugExportJSONL(w http.ResponseWriter, r *http.Request) {
if a.debug == nil || !a.debug.Enabled() {
http.Error(w, "Debug tracing ist deaktiviert", 404)
return
}
limit := 10000
if v, _ := strconv.Atoi(r.URL.Query().Get("limit")); v > 0 && v <= 100000 {
limit = v
}
w.Header().Set("Content-Type", "application/x-ndjson")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=jarvis-debug-%s.jsonl", time.Now().Format("20060102-150405")))
if err := a.debug.WriteJSONL(w, limit); err != nil {
http.Error(w, err.Error(), 500)
}
}
func (a *app) handleDebugClear(w http.ResponseWriter, r *http.Request) {
if a.debug == nil || !a.debug.Enabled() {
http.Error(w, "Debug tracing ist deaktiviert", 404)
return
}
if err := a.debug.Clear(); err != nil {
http.Error(w, err.Error(), 500)
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}