mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-29 22:02:05 +00:00
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>
411 lines
12 KiB
Python
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),
|
|
)
|