forked from Rockachopa/Timmy-time-dashboard
feat: automatic error feedback loop with bug report tracker (#80)
Errors and uncaught exceptions are now automatically captured, deduplicated, persisted to a rotating log file, and filed as bug report tasks in the existing task queue — giving Timmy a sovereign, local issue tracker with zero new dependencies. - Add RotatingFileHandler writing errors to logs/errors.log (5MB rotate, 5 backups) - Add error capture module with stack-trace hashing and 5-min dedup window - Add FastAPI exception middleware + global exception handler - Instrument all background loops (briefing, thinking, task processor) with capture_error() - Extend task queue with bug_report task type and auto-approve rule - Fix auto-approve type matching (was ignoring task_type field entirely) - Add /bugs dashboard page and /api/bugs JSON endpoints - Add ERROR_CAPTURED and BUG_REPORT_CREATED event types for real-time feed - Add BUGS nav link to desktop and mobile navigation - Add 16 tests covering error capture, deduplication, and bug report routes Co-authored-by: Alexander Payne <apayne@MM.local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
6545b7e26a
commit
aa3263bc3b
@@ -40,13 +40,51 @@ from dashboard.routes.models import router as models_router
|
||||
from dashboard.routes.models import api_router as models_api_router
|
||||
from dashboard.routes.chat_api import router as chat_api_router
|
||||
from dashboard.routes.thinking import router as thinking_router
|
||||
from dashboard.routes.bugs import router as bugs_router
|
||||
from infrastructure.router.api import router as cascade_router
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-8s %(name)s — %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
def _configure_logging() -> None:
|
||||
"""Configure logging with console and optional rotating file handler."""
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.INFO)
|
||||
|
||||
# Console handler (existing behavior)
|
||||
console = logging.StreamHandler()
|
||||
console.setLevel(logging.INFO)
|
||||
console.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s %(levelname)-8s %(name)s — %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
)
|
||||
root_logger.addHandler(console)
|
||||
|
||||
# Rotating file handler for errors
|
||||
if settings.error_log_enabled:
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
log_dir = Path(settings.repo_root) / settings.error_log_dir
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
error_file = log_dir / "errors.log"
|
||||
|
||||
file_handler = RotatingFileHandler(
|
||||
error_file,
|
||||
maxBytes=settings.error_log_max_bytes,
|
||||
backupCount=settings.error_log_backup_count,
|
||||
)
|
||||
file_handler.setLevel(logging.ERROR)
|
||||
file_handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s %(levelname)-8s %(name)s — %(message)s\n"
|
||||
" File: %(pathname)s:%(lineno)d\n"
|
||||
" Function: %(funcName)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
|
||||
_configure_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_DIR = Path(__file__).parent
|
||||
@@ -77,6 +115,11 @@ async def _briefing_scheduler() -> None:
|
||||
logger.info("Briefing is fresh; skipping generation.")
|
||||
except Exception as exc:
|
||||
logger.error("Briefing scheduler error: %s", exc)
|
||||
try:
|
||||
from infrastructure.error_capture import capture_error
|
||||
capture_error(exc, source="briefing_scheduler")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(_BRIEFING_INTERVAL_HOURS * 3600)
|
||||
|
||||
@@ -110,6 +153,11 @@ async def _thinking_loop() -> None:
|
||||
logger.debug("Created thought task in queue")
|
||||
except Exception as exc:
|
||||
logger.error("Thinking loop error: %s", exc)
|
||||
try:
|
||||
from infrastructure.error_capture import capture_error
|
||||
capture_error(exc, source="thinking_loop")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(settings.thinking_interval_seconds)
|
||||
|
||||
@@ -156,6 +204,11 @@ async def _task_processor_loop() -> None:
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error("Chat response failed: %s", e)
|
||||
try:
|
||||
from infrastructure.error_capture import capture_error
|
||||
capture_error(e, source="chat_response_handler")
|
||||
except Exception:
|
||||
pass
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
def handle_thought(task):
|
||||
@@ -167,12 +220,22 @@ async def _task_processor_loop() -> None:
|
||||
return str(result) if result else "Thought completed"
|
||||
except Exception as e:
|
||||
logger.error("Thought processing failed: %s", e)
|
||||
try:
|
||||
from infrastructure.error_capture import capture_error
|
||||
capture_error(e, source="thought_handler")
|
||||
except Exception:
|
||||
pass
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
def handle_bug_report(task):
|
||||
"""Handler for bug_report tasks - acknowledge and mark completed."""
|
||||
return f"Bug report acknowledged: {task.title}"
|
||||
|
||||
# Register handlers
|
||||
task_processor.register_handler("chat_response", handle_chat_response)
|
||||
task_processor.register_handler("thought", handle_thought)
|
||||
task_processor.register_handler("internal", handle_thought)
|
||||
task_processor.register_handler("bug_report", handle_bug_report)
|
||||
|
||||
# ── Startup drain: iterate through all pending tasks immediately ──
|
||||
logger.info("Draining task queue on startup…")
|
||||
@@ -204,6 +267,11 @@ async def _task_processor_loop() -> None:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.error("Startup drain failed: %s", exc)
|
||||
try:
|
||||
from infrastructure.error_capture import capture_error
|
||||
capture_error(exc, source="task_processor_startup")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Steady-state: poll for new tasks ──
|
||||
logger.info("Task processor entering steady-state loop")
|
||||
@@ -388,6 +456,55 @@ app.include_router(models_api_router)
|
||||
app.include_router(chat_api_router)
|
||||
app.include_router(thinking_router)
|
||||
app.include_router(cascade_router)
|
||||
app.include_router(bugs_router)
|
||||
|
||||
|
||||
# ── Error capture middleware ──────────────────────────────────────────────
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class ErrorCaptureMiddleware(BaseHTTPMiddleware):
|
||||
"""Catch unhandled exceptions and feed them into the error feedback loop."""
|
||||
|
||||
async def dispatch(self, request: StarletteRequest, call_next):
|
||||
try:
|
||||
return await call_next(request)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Unhandled exception on %s %s: %s",
|
||||
request.method, request.url.path, exc,
|
||||
)
|
||||
try:
|
||||
from infrastructure.error_capture import capture_error
|
||||
capture_error(
|
||||
exc,
|
||||
source="http_middleware",
|
||||
context={
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"query": str(request.query_params),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass # Never crash the middleware itself
|
||||
raise # Re-raise so FastAPI's default handler returns 500
|
||||
|
||||
|
||||
app.add_middleware(ErrorCaptureMiddleware)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
"""Safety net for uncaught exceptions."""
|
||||
logger.error("Unhandled exception: %s", exc, exc_info=True)
|
||||
try:
|
||||
from infrastructure.error_capture import capture_error
|
||||
capture_error(exc, source="exception_handler", context={"path": str(request.url)})
|
||||
except Exception:
|
||||
pass
|
||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
|
||||
86
src/dashboard/routes/bugs.py
Normal file
86
src/dashboard/routes/bugs.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Bug Report routes -- error feedback loop dashboard.
|
||||
|
||||
GET /bugs -- Bug reports dashboard page
|
||||
GET /api/bugs -- List bug reports (JSON)
|
||||
GET /api/bugs/stats -- Bug report statistics
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from swarm.task_queue.models import list_tasks
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["bugs"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
|
||||
def _get_bug_reports(status: Optional[str] = None, limit: int = 50) -> list:
|
||||
"""Get bug report tasks from the task queue."""
|
||||
all_tasks = list_tasks(limit=limit)
|
||||
bugs = [t for t in all_tasks if t.task_type == "bug_report"]
|
||||
if status:
|
||||
bugs = [t for t in bugs if t.status.value == status]
|
||||
return bugs
|
||||
|
||||
|
||||
@router.get("/bugs", response_class=HTMLResponse)
|
||||
async def bugs_page(request: Request, status: Optional[str] = None):
|
||||
"""Bug reports dashboard page."""
|
||||
bugs = _get_bug_reports(status=status, limit=200)
|
||||
|
||||
# Count by status
|
||||
all_bugs = _get_bug_reports(limit=500)
|
||||
stats: dict[str, int] = {}
|
||||
for bug in all_bugs:
|
||||
s = bug.status.value
|
||||
stats[s] = stats.get(s, 0) + 1
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"bugs.html",
|
||||
{
|
||||
"page_title": "Bug Reports",
|
||||
"bugs": bugs,
|
||||
"stats": stats,
|
||||
"total": len(all_bugs),
|
||||
"filter_status": status,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/bugs", response_class=JSONResponse)
|
||||
async def api_list_bugs(status: Optional[str] = None, limit: int = 50):
|
||||
"""List bug reports as JSON."""
|
||||
bugs = _get_bug_reports(status=status, limit=limit)
|
||||
return {
|
||||
"bugs": [
|
||||
{
|
||||
"id": b.id,
|
||||
"title": b.title,
|
||||
"description": b.description,
|
||||
"status": b.status.value,
|
||||
"priority": b.priority.value,
|
||||
"created_at": b.created_at,
|
||||
"result": b.result,
|
||||
}
|
||||
for b in bugs
|
||||
],
|
||||
"count": len(bugs),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/bugs/stats", response_class=JSONResponse)
|
||||
async def api_bug_stats():
|
||||
"""Bug report statistics."""
|
||||
all_bugs = _get_bug_reports(limit=500)
|
||||
stats: dict[str, int] = {}
|
||||
for bug in all_bugs:
|
||||
s = bug.status.value
|
||||
stats[s] = stats.get(s, 0) + 1
|
||||
return {"stats": stats, "total": len(all_bugs)}
|
||||
@@ -37,6 +37,7 @@
|
||||
<a href="/marketplace/ui" class="mc-test-link">MARKET</a>
|
||||
<a href="/tools" class="mc-test-link">TOOLS</a>
|
||||
<a href="/swarm/events" class="mc-test-link">EVENTS</a>
|
||||
<a href="/bugs" class="mc-test-link" style="color:#ff6b6b;">BUGS</a>
|
||||
<a href="/lightning/ledger" class="mc-test-link">LEDGER</a>
|
||||
<a href="/memory" class="mc-test-link">MEMORY</a>
|
||||
<a href="/router/status" class="mc-test-link">ROUTER</a>
|
||||
@@ -73,6 +74,7 @@
|
||||
<a href="/marketplace/ui" class="mc-mobile-link">MARKET</a>
|
||||
<a href="/tools" class="mc-mobile-link">TOOLS</a>
|
||||
<a href="/swarm/events" class="mc-mobile-link">EVENTS</a>
|
||||
<a href="/bugs" class="mc-mobile-link">BUGS</a>
|
||||
<a href="/lightning/ledger" class="mc-mobile-link">LEDGER</a>
|
||||
<a href="/memory" class="mc-mobile-link">MEMORY</a>
|
||||
<a href="/work-orders/queue" class="mc-mobile-link">WORK ORDERS</a>
|
||||
|
||||
67
src/dashboard/templates/bugs.html
Normal file
67
src/dashboard/templates/bugs.html
Normal file
@@ -0,0 +1,67 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bug Reports — Timmy Time{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mc-panel">
|
||||
<div class="mc-panel-header">
|
||||
<h1 class="page-title" style="color:#ff6b6b;">BUG REPORTS</h1>
|
||||
<p class="mc-text-secondary">Automatic error feedback loop — errors are captured, deduped, and filed here.</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px;">
|
||||
<div class="mc-stat-card" style="min-width:80px;text-align:center;padding:8px 12px;">
|
||||
<div style="font-size:1.4rem;font-weight:700;">{{ total }}</div>
|
||||
<div style="font-size:0.65rem;opacity:0.7;">TOTAL</div>
|
||||
</div>
|
||||
{% for status_name, count in stats.items() %}
|
||||
<div class="mc-stat-card" style="min-width:80px;text-align:center;padding:8px 12px;">
|
||||
<div style="font-size:1.4rem;font-weight:700;">{{ count }}</div>
|
||||
<div style="font-size:0.65rem;opacity:0.7;">{{ status_name | replace("_", " ") | upper }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div style="margin-bottom:16px;">
|
||||
<form method="get" action="/bugs" style="display:inline-flex;gap:8px;align-items:center;">
|
||||
<label style="font-size:0.75rem;opacity:0.7;">Filter:</label>
|
||||
<select name="status" class="mc-select" style="font-size:0.75rem;padding:4px 8px;background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:4px;" onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="approved" {% if filter_status == 'approved' %}selected{% endif %}>Open</option>
|
||||
<option value="completed" {% if filter_status == 'completed' %}selected{% endif %}>Resolved</option>
|
||||
<option value="failed" {% if filter_status == 'failed' %}selected{% endif %}>Failed</option>
|
||||
<option value="pending_approval" {% if filter_status == 'pending_approval' %}selected{% endif %}>Pending</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bug list -->
|
||||
{% if bugs %}
|
||||
{% for bug in bugs %}
|
||||
<div style="background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:6px;padding:12px;margin-bottom:8px;border-left:3px solid #ff6b6b;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
|
||||
<div style="font-size:0.85rem;font-weight:500;flex:1;">{{ bug.title | e }}</div>
|
||||
<div style="display:flex;gap:4px;flex-shrink:0;">
|
||||
<span style="font-size:0.6rem;padding:2px 6px;border-radius:3px;background:{% if bug.status.value == 'completed' %}#22c55e{% elif bug.status.value == 'failed' %}#ef4444{% elif bug.status.value == 'approved' %}#3b82f6{% else %}#6b7280{% endif %};color:#fff;">{{ bug.status.value | replace("_"," ") | upper }}</span>
|
||||
<span style="font-size:0.6rem;padding:2px 6px;border-radius:3px;background:{% if bug.priority.value == 'urgent' %}#ef4444{% elif bug.priority.value == 'high' %}#f59e0b{% else %}#374151{% endif %};color:#fff;">{{ bug.priority.value | upper }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if bug.description %}
|
||||
<details style="margin-top:6px;">
|
||||
<summary style="cursor:pointer;font-size:0.7rem;color:var(--text-secondary);">Stack trace & details</summary>
|
||||
<pre style="font-size:0.65rem;white-space:pre-wrap;word-break:break-all;max-height:300px;overflow:auto;margin-top:4px;padding:8px;background:var(--bg-tertiary,#111);border-radius:4px;border:1px solid var(--border-color);">{{ bug.description | e }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
<div style="font-size:0.6rem;opacity:0.5;margin-top:4px;">{{ bug.created_at[:19].replace("T", " ") }} UTC</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align:center;padding:40px 20px;opacity:0.6;">
|
||||
<p style="font-size:1.2rem;">No bug reports found.</p>
|
||||
<p style="font-size:0.8rem;">The system is running clean.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user