feat: Add DevLoop automation and HTML Reports
## DevLoop - Closed-Loop Development System - Orchestrator for plan → build → test → analyze cycle - Gemini planning via OpenCode CLI - Claude implementation via CLI bridge - Playwright browser testing integration - Test runner with API, filesystem, and browser tests - Persistent state in .devloop/ directory - CLI tool: tools/devloop_cli.py Usage: python tools/devloop_cli.py start 'Create new feature' python tools/devloop_cli.py plan 'Fix bug in X' python tools/devloop_cli.py test --study support_arm python tools/devloop_cli.py browser --level full ## HTML Reports (optimization_engine/reporting/) - Interactive Plotly-based reports - Convergence plot, Pareto front, parallel coordinates - Parameter importance analysis - Self-contained HTML (offline-capable) - Tailwind CSS styling ## Playwright E2E Tests - Home page tests - Test results in test-results/ ## LAC Knowledge Base Updates - Session insights (failures, workarounds, patterns) - Optimization memory for arm support study
This commit is contained in:
416
atomizer-dashboard/backend/api/routes/devloop.py
Normal file
416
atomizer-dashboard/backend/api/routes/devloop.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""
|
||||
DevLoop API Endpoints - Closed-loop development orchestration.
|
||||
|
||||
Provides REST API and WebSocket for:
|
||||
- Starting/stopping development cycles
|
||||
- Monitoring progress
|
||||
- Executing single phases
|
||||
- Viewing history and learnings
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, BackgroundTasks
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any, Dict, List, Optional
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Add project root to path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent))
|
||||
|
||||
router = APIRouter(prefix="/devloop", tags=["devloop"])
|
||||
|
||||
# Global orchestrator instance
|
||||
_orchestrator = None
|
||||
_active_cycle = None
|
||||
_websocket_clients: List[WebSocket] = []
|
||||
|
||||
|
||||
def get_orchestrator():
|
||||
"""Get or create the DevLoop orchestrator."""
|
||||
global _orchestrator
|
||||
if _orchestrator is None:
|
||||
from optimization_engine.devloop import DevLoopOrchestrator
|
||||
|
||||
_orchestrator = DevLoopOrchestrator(
|
||||
{
|
||||
"dashboard_url": "http://localhost:8000",
|
||||
"websocket_url": "ws://localhost:8000",
|
||||
"studies_dir": str(Path(__file__).parent.parent.parent.parent.parent / "studies"),
|
||||
"learning_enabled": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Subscribe to state updates
|
||||
_orchestrator.subscribe(_broadcast_state_update)
|
||||
|
||||
return _orchestrator
|
||||
|
||||
|
||||
def _broadcast_state_update(state):
|
||||
"""Broadcast state updates to all WebSocket clients."""
|
||||
asyncio.create_task(
|
||||
_send_to_all_clients(
|
||||
{
|
||||
"type": "state_update",
|
||||
"state": {
|
||||
"phase": state.phase.value,
|
||||
"iteration": state.iteration,
|
||||
"current_task": state.current_task,
|
||||
"last_update": state.last_update,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _send_to_all_clients(message: Dict):
|
||||
"""Send message to all connected WebSocket clients."""
|
||||
disconnected = []
|
||||
for client in _websocket_clients:
|
||||
try:
|
||||
await client.send_json(message)
|
||||
except Exception:
|
||||
disconnected.append(client)
|
||||
|
||||
# Clean up disconnected clients
|
||||
for client in disconnected:
|
||||
if client in _websocket_clients:
|
||||
_websocket_clients.remove(client)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request/Response Models
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class StartCycleRequest(BaseModel):
|
||||
"""Request to start a development cycle."""
|
||||
|
||||
objective: str = Field(..., description="What to achieve")
|
||||
context: Optional[Dict[str, Any]] = Field(default=None, description="Additional context")
|
||||
max_iterations: Optional[int] = Field(default=10, description="Maximum iterations")
|
||||
|
||||
|
||||
class StepRequest(BaseModel):
|
||||
"""Request to execute a single step."""
|
||||
|
||||
phase: str = Field(..., description="Phase to execute: plan, implement, test, analyze")
|
||||
data: Optional[Dict[str, Any]] = Field(default=None, description="Phase-specific data")
|
||||
|
||||
|
||||
class CycleStatusResponse(BaseModel):
|
||||
"""Response with cycle status."""
|
||||
|
||||
active: bool
|
||||
phase: str
|
||||
iteration: int
|
||||
current_task: Optional[str]
|
||||
last_update: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REST Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status() -> CycleStatusResponse:
|
||||
"""Get current DevLoop status."""
|
||||
orchestrator = get_orchestrator()
|
||||
state = orchestrator.get_state()
|
||||
|
||||
return CycleStatusResponse(
|
||||
active=state["phase"] != "idle",
|
||||
phase=state["phase"],
|
||||
iteration=state["iteration"],
|
||||
current_task=state.get("current_task"),
|
||||
last_update=state["last_update"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def start_cycle(request: StartCycleRequest, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Start a new development cycle.
|
||||
|
||||
The cycle runs in the background and broadcasts progress via WebSocket.
|
||||
"""
|
||||
global _active_cycle
|
||||
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
# Check if already running
|
||||
if orchestrator.state.phase.value != "idle":
|
||||
raise HTTPException(status_code=409, detail="A development cycle is already running")
|
||||
|
||||
# Start cycle in background
|
||||
async def run_cycle():
|
||||
global _active_cycle
|
||||
try:
|
||||
result = await orchestrator.run_development_cycle(
|
||||
objective=request.objective,
|
||||
context=request.context,
|
||||
max_iterations=request.max_iterations,
|
||||
)
|
||||
_active_cycle = result
|
||||
|
||||
# Broadcast completion
|
||||
await _send_to_all_clients(
|
||||
{
|
||||
"type": "cycle_complete",
|
||||
"result": {
|
||||
"objective": result.objective,
|
||||
"status": result.status,
|
||||
"iterations": len(result.iterations),
|
||||
"duration_seconds": result.total_duration_seconds,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
await _send_to_all_clients({"type": "cycle_error", "error": str(e)})
|
||||
|
||||
background_tasks.add_task(run_cycle)
|
||||
|
||||
return {
|
||||
"message": "Development cycle started",
|
||||
"objective": request.objective,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop_cycle():
|
||||
"""Stop the current development cycle."""
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
if orchestrator.state.phase.value == "idle":
|
||||
raise HTTPException(status_code=400, detail="No active cycle to stop")
|
||||
|
||||
# Set state to idle (will stop at next phase boundary)
|
||||
orchestrator._update_state(phase=orchestrator.state.phase.__class__.IDLE, task="Stopping...")
|
||||
|
||||
return {"message": "Cycle stop requested"}
|
||||
|
||||
|
||||
@router.post("/step")
|
||||
async def execute_step(request: StepRequest):
|
||||
"""
|
||||
Execute a single phase step.
|
||||
|
||||
Useful for manual control or debugging.
|
||||
"""
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
if request.phase == "plan":
|
||||
objective = request.data.get("objective", "") if request.data else ""
|
||||
context = request.data.get("context") if request.data else None
|
||||
result = await orchestrator.step_plan(objective, context)
|
||||
|
||||
elif request.phase == "implement":
|
||||
plan = request.data if request.data else {}
|
||||
result = await orchestrator.step_implement(plan)
|
||||
|
||||
elif request.phase == "test":
|
||||
scenarios = request.data.get("scenarios", []) if request.data else []
|
||||
result = await orchestrator.step_test(scenarios)
|
||||
|
||||
elif request.phase == "analyze":
|
||||
test_results = request.data if request.data else {}
|
||||
result = await orchestrator.step_analyze(test_results)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown phase: {request.phase}. Valid: plan, implement, test, analyze",
|
||||
)
|
||||
|
||||
return {"phase": request.phase, "result": result}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_history():
|
||||
"""Get history of past development cycles."""
|
||||
orchestrator = get_orchestrator()
|
||||
return orchestrator.export_history()
|
||||
|
||||
|
||||
@router.get("/last-cycle")
|
||||
async def get_last_cycle():
|
||||
"""Get details of the most recent cycle."""
|
||||
global _active_cycle
|
||||
|
||||
if _active_cycle is None:
|
||||
raise HTTPException(status_code=404, detail="No cycle has been run yet")
|
||||
|
||||
return {
|
||||
"objective": _active_cycle.objective,
|
||||
"status": _active_cycle.status,
|
||||
"start_time": _active_cycle.start_time,
|
||||
"end_time": _active_cycle.end_time,
|
||||
"iterations": [
|
||||
{
|
||||
"iteration": it.iteration,
|
||||
"success": it.success,
|
||||
"duration_seconds": it.duration_seconds,
|
||||
"has_plan": it.plan is not None,
|
||||
"has_tests": it.test_results is not None,
|
||||
"has_fixes": it.fixes is not None,
|
||||
}
|
||||
for it in _active_cycle.iterations
|
||||
],
|
||||
"total_duration_seconds": _active_cycle.total_duration_seconds,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""Check DevLoop system health."""
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
# Check dashboard connection
|
||||
from optimization_engine.devloop import DashboardTestRunner
|
||||
|
||||
runner = DashboardTestRunner()
|
||||
dashboard_health = await runner.run_health_check()
|
||||
|
||||
return {
|
||||
"devloop": "healthy",
|
||||
"orchestrator_state": orchestrator.get_state()["phase"],
|
||||
"dashboard": dashboard_health,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# WebSocket Endpoint
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""
|
||||
WebSocket endpoint for real-time DevLoop updates.
|
||||
|
||||
Messages sent:
|
||||
- state_update: Phase/iteration changes
|
||||
- cycle_complete: Cycle finished
|
||||
- cycle_error: Cycle failed
|
||||
- test_progress: Individual test results
|
||||
"""
|
||||
await websocket.accept()
|
||||
_websocket_clients.append(websocket)
|
||||
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
try:
|
||||
# Send initial state
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "connection_ack",
|
||||
"state": orchestrator.get_state(),
|
||||
}
|
||||
)
|
||||
|
||||
# Handle incoming messages
|
||||
while True:
|
||||
try:
|
||||
data = await asyncio.wait_for(websocket.receive_json(), timeout=30.0)
|
||||
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
elif msg_type == "get_state":
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "state",
|
||||
"state": orchestrator.get_state(),
|
||||
}
|
||||
)
|
||||
|
||||
elif msg_type == "start_cycle":
|
||||
# Allow starting cycle via WebSocket
|
||||
objective = data.get("objective", "")
|
||||
context = data.get("context")
|
||||
|
||||
asyncio.create_task(orchestrator.run_development_cycle(objective, context))
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "cycle_started",
|
||||
"objective": objective,
|
||||
}
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Send heartbeat
|
||||
await websocket.send_json({"type": "heartbeat"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
if websocket in _websocket_clients:
|
||||
_websocket_clients.remove(websocket)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Convenience Endpoints for Common Tasks
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post("/create-study")
|
||||
async def create_study_cycle(
|
||||
study_name: str,
|
||||
problem_statement: Optional[str] = None,
|
||||
background_tasks: BackgroundTasks = None,
|
||||
):
|
||||
"""
|
||||
Convenience endpoint to start a study creation cycle.
|
||||
|
||||
This is a common workflow that combines planning, implementation, and testing.
|
||||
"""
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
context = {
|
||||
"study_name": study_name,
|
||||
"task_type": "create_study",
|
||||
}
|
||||
|
||||
if problem_statement:
|
||||
context["problem_statement"] = problem_statement
|
||||
|
||||
# Start the cycle
|
||||
async def run_cycle():
|
||||
result = await orchestrator.run_development_cycle(
|
||||
objective=f"Create optimization study: {study_name}",
|
||||
context=context,
|
||||
)
|
||||
return result
|
||||
|
||||
if background_tasks:
|
||||
background_tasks.add_task(run_cycle)
|
||||
return {"message": f"Study creation cycle started for '{study_name}'"}
|
||||
else:
|
||||
result = await run_cycle()
|
||||
return {
|
||||
"message": f"Study '{study_name}' creation completed",
|
||||
"status": result.status,
|
||||
"iterations": len(result.iterations),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/run-tests")
|
||||
async def run_tests(scenarios: List[Dict[str, Any]]):
|
||||
"""
|
||||
Run a set of test scenarios directly.
|
||||
|
||||
Useful for testing specific features without a full cycle.
|
||||
"""
|
||||
from optimization_engine.devloop import DashboardTestRunner
|
||||
|
||||
runner = DashboardTestRunner()
|
||||
results = await runner.run_test_suite(scenarios)
|
||||
|
||||
return results
|
||||
Reference in New Issue
Block a user