This commit is contained in:
Auto
2025-12-30 11:56:39 +02:00
parent dd7c1ddd82
commit a2efec159d
40 changed files with 9112 additions and 3 deletions

View File

@@ -0,0 +1,12 @@
"""
API Routers
===========
FastAPI routers for different API endpoints.
"""
from .projects import router as projects_router
from .features import router as features_router
from .agent import router as agent_router
__all__ = ["projects_router", "features_router", "agent_router"]

128
server/routers/agent.py Normal file
View File

@@ -0,0 +1,128 @@
"""
Agent Router
============
API endpoints for agent control (start/stop/pause/resume).
"""
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException
from ..schemas import AgentStatus, AgentActionResponse
from ..services.process_manager import get_manager
# Lazy import to avoid sys.path manipulation at module level
_GENERATIONS_DIR = None
def _get_generations_dir():
"""Lazy import of GENERATIONS_DIR."""
global _GENERATIONS_DIR
if _GENERATIONS_DIR is None:
import sys
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from start import GENERATIONS_DIR
_GENERATIONS_DIR = GENERATIONS_DIR
return _GENERATIONS_DIR
router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
# Root directory for process manager
ROOT_DIR = Path(__file__).parent.parent.parent
def validate_project_name(name: str) -> str:
"""Validate and sanitize project name to prevent path traversal."""
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
raise HTTPException(
status_code=400,
detail="Invalid project name"
)
return name
def get_project_manager(project_name: str):
"""Get the process manager for a project."""
project_name = validate_project_name(project_name)
project_dir = _get_generations_dir() / project_name
if not project_dir.exists():
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
return get_manager(project_name, ROOT_DIR)
@router.get("/status", response_model=AgentStatus)
async def get_agent_status(project_name: str):
"""Get the current status of the agent for a project."""
manager = get_project_manager(project_name)
# Run healthcheck to detect crashed processes
await manager.healthcheck()
return AgentStatus(
status=manager.status,
pid=manager.pid,
started_at=manager.started_at,
)
@router.post("/start", response_model=AgentActionResponse)
async def start_agent(project_name: str):
"""Start the agent for a project."""
manager = get_project_manager(project_name)
success, message = await manager.start()
return AgentActionResponse(
success=success,
status=manager.status,
message=message,
)
@router.post("/stop", response_model=AgentActionResponse)
async def stop_agent(project_name: str):
"""Stop the agent for a project."""
manager = get_project_manager(project_name)
success, message = await manager.stop()
return AgentActionResponse(
success=success,
status=manager.status,
message=message,
)
@router.post("/pause", response_model=AgentActionResponse)
async def pause_agent(project_name: str):
"""Pause the agent for a project."""
manager = get_project_manager(project_name)
success, message = await manager.pause()
return AgentActionResponse(
success=success,
status=manager.status,
message=message,
)
@router.post("/resume", response_model=AgentActionResponse)
async def resume_agent(project_name: str):
"""Resume a paused agent."""
manager = get_project_manager(project_name)
success, message = await manager.resume()
return AgentActionResponse(
success=success,
status=manager.status,
message=message,
)

282
server/routers/features.py Normal file
View File

@@ -0,0 +1,282 @@
"""
Features Router
===============
API endpoints for feature/test case management.
"""
import re
import logging
from pathlib import Path
from contextlib import contextmanager
from fastapi import APIRouter, HTTPException
from ..schemas import (
FeatureCreate,
FeatureResponse,
FeatureListResponse,
)
# Lazy imports to avoid circular dependencies
_GENERATIONS_DIR = None
_create_database = None
_Feature = None
logger = logging.getLogger(__name__)
def _get_generations_dir():
"""Lazy import of GENERATIONS_DIR."""
global _GENERATIONS_DIR
if _GENERATIONS_DIR is None:
import sys
from pathlib import Path
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from start import GENERATIONS_DIR
_GENERATIONS_DIR = GENERATIONS_DIR
return _GENERATIONS_DIR
def _get_db_classes():
"""Lazy import of database classes."""
global _create_database, _Feature
if _create_database is None:
import sys
from pathlib import Path
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from api.database import create_database, Feature
_create_database = create_database
_Feature = Feature
return _create_database, _Feature
router = APIRouter(prefix="/api/projects/{project_name}/features", tags=["features"])
def validate_project_name(name: str) -> str:
"""Validate and sanitize project name to prevent path traversal."""
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
raise HTTPException(
status_code=400,
detail="Invalid project name"
)
return name
@contextmanager
def get_db_session(project_dir: Path):
"""
Context manager for database sessions.
Ensures session is always closed, even on exceptions.
"""
create_database, _ = _get_db_classes()
_, SessionLocal = create_database(project_dir)
session = SessionLocal()
try:
yield session
finally:
session.close()
def feature_to_response(f) -> FeatureResponse:
"""Convert a Feature model to a FeatureResponse."""
return FeatureResponse(
id=f.id,
priority=f.priority,
category=f.category,
name=f.name,
description=f.description,
steps=f.steps if isinstance(f.steps, list) else [],
passes=f.passes,
)
@router.get("", response_model=FeatureListResponse)
async def list_features(project_name: str):
"""
List all features for a project organized by status.
Returns features in three lists:
- pending: passes=False, not currently being worked on
- in_progress: features currently being worked on (tracked via agent output)
- done: passes=True
"""
project_name = validate_project_name(project_name)
project_dir = _get_generations_dir() / project_name
if not project_dir.exists():
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
db_file = project_dir / "features.db"
if not db_file.exists():
return FeatureListResponse(pending=[], in_progress=[], done=[])
_, Feature = _get_db_classes()
try:
with get_db_session(project_dir) as session:
all_features = session.query(Feature).order_by(Feature.priority).all()
pending = []
done = []
for f in all_features:
feature_response = feature_to_response(f)
if f.passes:
done.append(feature_response)
else:
pending.append(feature_response)
return FeatureListResponse(
pending=pending,
in_progress=[],
done=done,
)
except HTTPException:
raise
except Exception as e:
logger.exception("Database error in list_features")
raise HTTPException(status_code=500, detail="Database error occurred")
@router.post("", response_model=FeatureResponse)
async def create_feature(project_name: str, feature: FeatureCreate):
"""Create a new feature/test case manually."""
project_name = validate_project_name(project_name)
project_dir = _get_generations_dir() / project_name
if not project_dir.exists():
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
_, Feature = _get_db_classes()
try:
with get_db_session(project_dir) as session:
# Get next priority if not specified
if feature.priority is None:
max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
priority = (max_priority.priority + 1) if max_priority else 1
else:
priority = feature.priority
# Create new feature
db_feature = Feature(
priority=priority,
category=feature.category,
name=feature.name,
description=feature.description,
steps=feature.steps,
passes=False,
)
session.add(db_feature)
session.commit()
session.refresh(db_feature)
return feature_to_response(db_feature)
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to create feature")
raise HTTPException(status_code=500, detail="Failed to create feature")
@router.get("/{feature_id}", response_model=FeatureResponse)
async def get_feature(project_name: str, feature_id: int):
"""Get details of a specific feature."""
project_name = validate_project_name(project_name)
project_dir = _get_generations_dir() / project_name
if not project_dir.exists():
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
db_file = project_dir / "features.db"
if not db_file.exists():
raise HTTPException(status_code=404, detail="No features database found")
_, Feature = _get_db_classes()
try:
with get_db_session(project_dir) as session:
feature = session.query(Feature).filter(Feature.id == feature_id).first()
if not feature:
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
return feature_to_response(feature)
except HTTPException:
raise
except Exception as e:
logger.exception("Database error in get_feature")
raise HTTPException(status_code=500, detail="Database error occurred")
@router.delete("/{feature_id}")
async def delete_feature(project_name: str, feature_id: int):
"""Delete a feature."""
project_name = validate_project_name(project_name)
project_dir = _get_generations_dir() / project_name
if not project_dir.exists():
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
_, Feature = _get_db_classes()
try:
with get_db_session(project_dir) as session:
feature = session.query(Feature).filter(Feature.id == feature_id).first()
if not feature:
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
session.delete(feature)
session.commit()
return {"success": True, "message": f"Feature {feature_id} deleted"}
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to delete feature")
raise HTTPException(status_code=500, detail="Failed to delete feature")
@router.patch("/{feature_id}/skip")
async def skip_feature(project_name: str, feature_id: int):
"""
Mark a feature as skipped by moving it to the end of the priority queue.
This doesn't delete the feature but gives it a very high priority number
so it will be processed last.
"""
project_name = validate_project_name(project_name)
project_dir = _get_generations_dir() / project_name
if not project_dir.exists():
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
_, Feature = _get_db_classes()
try:
with get_db_session(project_dir) as session:
feature = session.query(Feature).filter(Feature.id == feature_id).first()
if not feature:
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
# Set priority to max + 1000 to push to end
max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
feature.priority = (max_priority.priority if max_priority else 0) + 1000
session.commit()
return {"success": True, "message": f"Feature {feature_id} moved to end of queue"}
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to skip feature")
raise HTTPException(status_code=500, detail="Failed to skip feature")

239
server/routers/projects.py Normal file
View File

@@ -0,0 +1,239 @@
"""
Projects Router
===============
API endpoints for project management.
"""
import re
import shutil
from pathlib import Path
from fastapi import APIRouter, HTTPException
from ..schemas import (
ProjectCreate,
ProjectSummary,
ProjectDetail,
ProjectPrompts,
ProjectPromptsUpdate,
ProjectStats,
)
# Lazy imports to avoid sys.path manipulation at module level
_imports_initialized = False
_GENERATIONS_DIR = None
_get_existing_projects = None
_check_spec_exists = None
_scaffold_project_prompts = None
_get_project_prompts_dir = None
_count_passing_tests = None
def _init_imports():
"""Lazy import of project-level modules."""
global _imports_initialized, _GENERATIONS_DIR, _get_existing_projects
global _check_spec_exists, _scaffold_project_prompts, _get_project_prompts_dir
global _count_passing_tests
if _imports_initialized:
return
import sys
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from start import GENERATIONS_DIR, get_existing_projects, check_spec_exists
from prompts import scaffold_project_prompts, get_project_prompts_dir
from progress import count_passing_tests
_GENERATIONS_DIR = GENERATIONS_DIR
_get_existing_projects = get_existing_projects
_check_spec_exists = check_spec_exists
_scaffold_project_prompts = scaffold_project_prompts
_get_project_prompts_dir = get_project_prompts_dir
_count_passing_tests = count_passing_tests
_imports_initialized = True
router = APIRouter(prefix="/api/projects", tags=["projects"])
def validate_project_name(name: str) -> str:
"""Validate and sanitize project name to prevent path traversal."""
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
raise HTTPException(
status_code=400,
detail="Invalid project name. Use only letters, numbers, hyphens, and underscores (1-50 chars)."
)
return name
def get_project_stats(project_dir: Path) -> ProjectStats:
"""Get statistics for a project."""
_init_imports()
passing, total = _count_passing_tests(project_dir)
percentage = (passing / total * 100) if total > 0 else 0.0
return ProjectStats(passing=passing, total=total, percentage=round(percentage, 1))
@router.get("", response_model=list[ProjectSummary])
async def list_projects():
"""List all projects in the generations directory."""
_init_imports()
projects = _get_existing_projects()
result = []
for name in projects:
project_dir = _GENERATIONS_DIR / name
has_spec = _check_spec_exists(project_dir)
stats = get_project_stats(project_dir)
result.append(ProjectSummary(
name=name,
has_spec=has_spec,
stats=stats,
))
return result
@router.post("", response_model=ProjectSummary)
async def create_project(project: ProjectCreate):
"""Create a new project with scaffolded prompts."""
_init_imports()
name = validate_project_name(project.name)
project_dir = _GENERATIONS_DIR / name
if project_dir.exists():
raise HTTPException(
status_code=409,
detail=f"Project '{name}' already exists"
)
# Create project directory
project_dir.mkdir(parents=True, exist_ok=True)
# Scaffold prompts
_scaffold_project_prompts(project_dir)
return ProjectSummary(
name=name,
has_spec=False, # Just created, no spec yet
stats=ProjectStats(passing=0, total=0, percentage=0.0),
)
@router.get("/{name}", response_model=ProjectDetail)
async def get_project(name: str):
"""Get detailed information about a project."""
_init_imports()
name = validate_project_name(name)
project_dir = _GENERATIONS_DIR / name
if not project_dir.exists():
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
has_spec = _check_spec_exists(project_dir)
stats = get_project_stats(project_dir)
prompts_dir = _get_project_prompts_dir(project_dir)
return ProjectDetail(
name=name,
has_spec=has_spec,
stats=stats,
prompts_dir=str(prompts_dir),
)
@router.delete("/{name}")
async def delete_project(name: str):
"""Delete a project and all its files."""
_init_imports()
name = validate_project_name(name)
project_dir = _GENERATIONS_DIR / name
if not project_dir.exists():
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
# Check if agent is running
lock_file = project_dir / ".agent.lock"
if lock_file.exists():
raise HTTPException(
status_code=409,
detail="Cannot delete project while agent is running. Stop the agent first."
)
try:
shutil.rmtree(project_dir)
return {"success": True, "message": f"Project '{name}' deleted"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete project: {e}")
@router.get("/{name}/prompts", response_model=ProjectPrompts)
async def get_project_prompts(name: str):
"""Get the content of project prompt files."""
_init_imports()
name = validate_project_name(name)
project_dir = _GENERATIONS_DIR / name
if not project_dir.exists():
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
prompts_dir = _get_project_prompts_dir(project_dir)
def read_file(filename: str) -> str:
filepath = prompts_dir / filename
if filepath.exists():
try:
return filepath.read_text(encoding="utf-8")
except Exception:
return ""
return ""
return ProjectPrompts(
app_spec=read_file("app_spec.txt"),
initializer_prompt=read_file("initializer_prompt.md"),
coding_prompt=read_file("coding_prompt.md"),
)
@router.put("/{name}/prompts")
async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate):
"""Update project prompt files."""
_init_imports()
name = validate_project_name(name)
project_dir = _GENERATIONS_DIR / name
if not project_dir.exists():
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
prompts_dir = _get_project_prompts_dir(project_dir)
prompts_dir.mkdir(parents=True, exist_ok=True)
def write_file(filename: str, content: str | None):
if content is not None:
filepath = prompts_dir / filename
filepath.write_text(content, encoding="utf-8")
write_file("app_spec.txt", prompts.app_spec)
write_file("initializer_prompt.md", prompts.initializer_prompt)
write_file("coding_prompt.md", prompts.coding_prompt)
return {"success": True, "message": "Prompts updated"}
@router.get("/{name}/stats", response_model=ProjectStats)
async def get_project_stats_endpoint(name: str):
"""Get current progress statistics for a project."""
_init_imports()
name = validate_project_name(name)
project_dir = _GENERATIONS_DIR / name
if not project_dir.exists():
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
return get_project_stats(project_dir)