## 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
417 lines
12 KiB
Python
417 lines
12 KiB
Python
"""
|
|
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
|