mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
Add the ability to reset a project to its initial state with two options:
- Quick Reset: Clears features.db, assistant.db, and settings files while
preserving app spec and prompts
- Full Reset: Deletes everything including prompts directory, triggering
the setup wizard for project reconfiguration
Backend changes:
- Add POST /{name}/reset endpoint to projects router with full_reset query param
- Validate agent lock file to prevent reset while agent is running (409 Conflict)
- Dispose database engines before deleting files to release Windows file locks
- Add engine caching to api/database.py for better connection management
- Add dispose_engine() functions to both database modules
- Delete WAL mode journal files (*.db-wal, *.db-shm) during reset
Frontend changes:
- Add ResetProjectModal component with toggle between Quick/Full reset modes
- Add ProjectSetupRequired component shown when has_spec is false
- Add resetProject API function and useResetProject React Query hook
- Integrate reset button in header (disabled when agent running)
- Add 'R' keyboard shortcut to open reset modal
- Show ProjectSetupRequired when project needs setup after full reset
This implements the feature from PR #4 directly on master to avoid merge
conflicts.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
492 lines
15 KiB
Python
492 lines
15 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.post("/{name}/reset")
|
|
async def reset_project(name: str, full_reset: bool = False):
|
|
"""
|
|
Reset a project to its initial state.
|
|
|
|
Args:
|
|
name: Project name to reset
|
|
full_reset: If True, also delete prompts/ directory (triggers setup wizard)
|
|
|
|
Returns:
|
|
Dictionary with list of deleted files and reset type
|
|
"""
|
|
_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")
|
|
|
|
# Check if agent is running
|
|
lock_file = project_dir / ".agent.lock"
|
|
if lock_file.exists():
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Cannot reset project while agent is running. Stop the agent first."
|
|
)
|
|
|
|
# Dispose of database engines to release file locks (required on Windows)
|
|
# Import here to avoid circular imports
|
|
from api.database import dispose_engine as dispose_features_engine
|
|
from server.services.assistant_database import dispose_engine as dispose_assistant_engine
|
|
|
|
dispose_features_engine(project_dir)
|
|
dispose_assistant_engine(project_dir)
|
|
|
|
deleted_files: list[str] = []
|
|
|
|
# Files to delete in quick reset
|
|
quick_reset_files = [
|
|
"features.db",
|
|
"features.db-wal", # WAL mode journal file
|
|
"features.db-shm", # WAL mode shared memory file
|
|
"assistant.db",
|
|
"assistant.db-wal",
|
|
"assistant.db-shm",
|
|
".claude_settings.json",
|
|
".claude_assistant_settings.json",
|
|
]
|
|
|
|
for filename in quick_reset_files:
|
|
file_path = project_dir / filename
|
|
if file_path.exists():
|
|
try:
|
|
file_path.unlink()
|
|
deleted_files.append(filename)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to delete {filename}: {e}")
|
|
|
|
# Full reset: also delete prompts directory
|
|
if full_reset:
|
|
prompts_dir = project_dir / "prompts"
|
|
if prompts_dir.exists():
|
|
try:
|
|
shutil.rmtree(prompts_dir)
|
|
deleted_files.append("prompts/")
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to delete prompts/: {e}")
|
|
|
|
return {
|
|
"success": True,
|
|
"reset_type": "full" if full_reset else "quick",
|
|
"deleted_files": deleted_files,
|
|
"message": f"Project '{name}' has been reset" + (" (full reset)" if full_reset else " (quick reset)")
|
|
}
|
|
|
|
|
|
@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),
|
|
)
|