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}) }