Files
autocoder/server/routers/projects.py
Auto f6ddffa6e2 feat: persist concurrent agents slider at project level
Add `default_concurrency` column to the projects table in the registry
database, allowing each project to remember its preferred concurrency
setting (1-5 agents). The value persists across page refreshes and
app restarts.

Backend changes:
- Add `default_concurrency` column to Project model in registry.py
- Add database migration for existing databases (ALTER TABLE)
- Add get/set_project_concurrency() CRUD functions
- Add ProjectSettingsUpdate schema with validation
- Add PATCH /{name}/settings endpoint in projects router
- Include default_concurrency in ProjectSummary/ProjectDetail responses

Frontend changes:
- Add default_concurrency to ProjectSummary TypeScript interface
- Add ProjectSettingsUpdate type and updateProjectSettings API function
- Add useUpdateProjectSettings React Query mutation hook
- Update AgentControl to accept defaultConcurrency prop
- Sync local state when project changes via useEffect
- Debounce slider changes (500ms) before saving to backend
- Pass defaultConcurrency from selectedProjectData in App.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:08:17 +02:00

411 lines
12 KiB
Python

"""
Projects Router
===============
API endpoints for project management.
Uses project registry for path lookups instead of fixed generations/ directory.
"""
import re
import shutil
import sys
from pathlib import Path
from fastapi import APIRouter, HTTPException
from ..schemas import (
ProjectCreate,
ProjectDetail,
ProjectPrompts,
ProjectPromptsUpdate,
ProjectSettingsUpdate,
ProjectStats,
ProjectSummary,
)
# Lazy imports to avoid circular dependencies
_imports_initialized = False
_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, _check_spec_exists
global _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 progress import count_passing_tests
from prompts import get_project_prompts_dir, scaffold_project_prompts
from start import check_spec_exists
_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
def _get_registry_functions():
"""Get registry functions with lazy import."""
import sys
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from registry import (
get_project_concurrency,
get_project_path,
list_registered_projects,
register_project,
set_project_concurrency,
unregister_project,
validate_project_path,
)
return (
register_project,
unregister_project,
get_project_path,
list_registered_projects,
validate_project_path,
get_project_concurrency,
set_project_concurrency,
)
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, in_progress, total = _count_passing_tests(project_dir)
percentage = (passing / total * 100) if total > 0 else 0.0
return ProjectStats(
passing=passing,
in_progress=in_progress,
total=total,
percentage=round(percentage, 1)
)
@router.get("", response_model=list[ProjectSummary])
async def list_projects():
"""List all registered projects."""
_init_imports()
(_, _, _, list_registered_projects, validate_project_path,
get_project_concurrency, _) = _get_registry_functions()
projects = list_registered_projects()
result = []
for name, info in projects.items():
project_dir = Path(info["path"])
# Skip if path no longer exists
is_valid, _ = validate_project_path(project_dir)
if not is_valid:
continue
has_spec = _check_spec_exists(project_dir)
stats = get_project_stats(project_dir)
result.append(ProjectSummary(
name=name,
path=info["path"],
has_spec=has_spec,
stats=stats,
default_concurrency=info.get("default_concurrency", 3),
))
return result
@router.post("", response_model=ProjectSummary)
async def create_project(project: ProjectCreate):
"""Create a new project at the specified path."""
_init_imports()
(register_project, _, get_project_path, list_registered_projects,
_, _, _) = _get_registry_functions()
name = validate_project_name(project.name)
project_path = Path(project.path).resolve()
# Check if project name already registered
existing = get_project_path(name)
if existing:
raise HTTPException(
status_code=409,
detail=f"Project '{name}' already exists at {existing}"
)
# Check if path already registered under a different name
all_projects = list_registered_projects()
for existing_name, info in all_projects.items():
existing_path = Path(info["path"]).resolve()
# Case-insensitive comparison on Windows
if sys.platform == "win32":
paths_match = str(existing_path).lower() == str(project_path).lower()
else:
paths_match = existing_path == project_path
if paths_match:
raise HTTPException(
status_code=409,
detail=f"Path '{project_path}' is already registered as project '{existing_name}'"
)
# Security: Check if path is in a blocked location
from .filesystem import is_path_blocked
if is_path_blocked(project_path):
raise HTTPException(
status_code=403,
detail="Cannot create project in system or sensitive directory"
)
# Validate the path is usable
if project_path.exists():
if not project_path.is_dir():
raise HTTPException(
status_code=400,
detail="Path exists but is not a directory"
)
else:
# Create the directory
try:
project_path.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to create directory: {e}"
)
# Scaffold prompts
_scaffold_project_prompts(project_path)
# Register in registry
try:
register_project(name, project_path)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to register project: {e}"
)
return ProjectSummary(
name=name,
path=project_path.as_posix(),
has_spec=False, # Just created, no spec yet
stats=ProjectStats(passing=0, total=0, percentage=0.0),
default_concurrency=3,
)
@router.get("/{name}", response_model=ProjectDetail)
async def get_project(name: str):
"""Get detailed information about a project."""
_init_imports()
(_, _, get_project_path, _, _, get_project_concurrency, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found in registry")
if not project_dir.exists():
raise HTTPException(status_code=404, detail=f"Project directory no longer exists: {project_dir}")
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,
path=project_dir.as_posix(),
has_spec=has_spec,
stats=stats,
prompts_dir=str(prompts_dir),
default_concurrency=get_project_concurrency(name),
)
@router.delete("/{name}")
async def delete_project(name: str, delete_files: bool = False):
"""
Delete a project from the registry.
Args:
name: Project name to delete
delete_files: If True, also delete the project directory and files
"""
_init_imports()
(_, unregister_project, get_project_path, _, _, _, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
if not project_dir:
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."
)
# Optionally delete files
if delete_files and project_dir.exists():
try:
shutil.rmtree(project_dir)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete project files: {e}")
# Unregister from registry
unregister_project(name)
return {
"success": True,
"message": f"Project '{name}' deleted" + (" (files removed)" if delete_files else " (files preserved)")
}
@router.get("/{name}/prompts", response_model=ProjectPrompts)
async def get_project_prompts(name: str):
"""Get the content of project prompt files."""
_init_imports()
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory 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()
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory 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()
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
return get_project_stats(project_dir)
@router.patch("/{name}/settings", response_model=ProjectDetail)
async def update_project_settings(name: str, settings: ProjectSettingsUpdate):
"""Update project-level settings (concurrency, etc.)."""
_init_imports()
(_, _, get_project_path, _, _, get_project_concurrency,
set_project_concurrency) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
# Update concurrency if provided
if settings.default_concurrency is not None:
success = set_project_concurrency(name, settings.default_concurrency)
if not success:
raise HTTPException(status_code=500, detail="Failed to update concurrency")
# Return updated project details
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,
path=project_dir.as_posix(),
has_spec=has_spec,
stats=stats,
prompts_dir=str(prompts_dir),
default_concurrency=get_project_concurrency(name),
)