Merge branch 'master' into feature/expand-project-with-ai

This commit is contained in:
Leon van Zyl
2026-01-10 10:22:12 +02:00
committed by GitHub
23 changed files with 985 additions and 183 deletions

View File

@@ -22,12 +22,13 @@ from .routers import (
features_router,
filesystem_router,
projects_router,
settings_router,
spec_creation_router,
)
from .schemas import SetupStatus
from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
from .services.expand_chat_session import cleanup_all_expand_sessions
from .services.process_manager import cleanup_all_managers
from .services.process_manager import cleanup_all_managers, cleanup_orphaned_locks
from .websocket import project_websocket
# Paths
@@ -38,7 +39,8 @@ UI_DIST_DIR = ROOT_DIR / "ui" / "dist"
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown."""
# Startup
# Startup - clean up orphaned lock files from previous runs
cleanup_orphaned_locks()
yield
# Shutdown - cleanup all running agents and sessions
await cleanup_all_managers()
@@ -96,6 +98,7 @@ app.include_router(spec_creation_router)
app.include_router(expand_project_router)
app.include_router(filesystem_router)
app.include_router(assistant_chat_router)
app.include_router(settings_router)
# ============================================================================

View File

@@ -11,6 +11,7 @@ from .expand_project import router as expand_project_router
from .features import router as features_router
from .filesystem import router as filesystem_router
from .projects import router as projects_router
from .settings import router as settings_router
from .spec_creation import router as spec_creation_router
__all__ = [
@@ -21,4 +22,5 @@ __all__ = [
"expand_project_router",
"filesystem_router",
"assistant_chat_router",
"settings_router",
]

View File

@@ -26,6 +26,21 @@ def _get_project_path(project_name: str) -> Path:
return get_project_path(project_name)
def _get_settings_defaults() -> tuple[bool, str]:
"""Get YOLO mode and model defaults from global settings."""
import sys
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from registry import DEFAULT_MODEL, get_all_settings
settings = get_all_settings()
yolo_mode = (settings.get("yolo_mode") or "false").lower() == "true"
model = settings.get("model", DEFAULT_MODEL)
return yolo_mode, model
router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
# Root directory for process manager
@@ -69,6 +84,7 @@ async def get_agent_status(project_name: str):
pid=manager.pid,
started_at=manager.started_at,
yolo_mode=manager.yolo_mode,
model=manager.model,
)
@@ -80,7 +96,12 @@ async def start_agent(
"""Start the agent for a project."""
manager = get_project_manager(project_name)
success, message = await manager.start(yolo_mode=request.yolo_mode)
# Get defaults from global settings if not provided in request
default_yolo, default_model = _get_settings_defaults()
yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
model = request.model if request.model else default_model
success, message = await manager.start(yolo_mode=yolo_mode, model=model)
return AgentActionResponse(
success=success,

View File

@@ -0,0 +1,74 @@
"""
Settings Router
===============
API endpoints for global settings management.
Settings are stored in the registry database and shared across all projects.
"""
import sys
from pathlib import Path
from fastapi import APIRouter
from ..schemas import ModelInfo, ModelsResponse, SettingsResponse, SettingsUpdate
# Add root to path for registry import
ROOT_DIR = Path(__file__).parent.parent.parent
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from registry import (
AVAILABLE_MODELS,
DEFAULT_MODEL,
get_all_settings,
set_setting,
)
router = APIRouter(prefix="/api/settings", tags=["settings"])
def _parse_yolo_mode(value: str | None) -> bool:
"""Parse YOLO mode string to boolean."""
return (value or "false").lower() == "true"
@router.get("/models", response_model=ModelsResponse)
async def get_available_models():
"""Get list of available models.
Frontend should call this to get the current list of models
instead of hardcoding them.
"""
return ModelsResponse(
models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS],
default=DEFAULT_MODEL,
)
@router.get("", response_model=SettingsResponse)
async def get_settings():
"""Get current global settings."""
all_settings = get_all_settings()
return SettingsResponse(
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
model=all_settings.get("model", DEFAULT_MODEL),
)
@router.patch("", response_model=SettingsResponse)
async def update_settings(update: SettingsUpdate):
"""Update global settings."""
if update.yolo_mode is not None:
set_setting("yolo_mode", "true" if update.yolo_mode else "false")
if update.model is not None:
set_setting("model", update.model)
# Return updated settings
all_settings = get_all_settings()
return SettingsResponse(
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
model=all_settings.get("model", DEFAULT_MODEL),
)

View File

@@ -6,11 +6,20 @@ Request/Response models for the API endpoints.
"""
import base64
import sys
from datetime import datetime
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field, field_validator
# Import model constants from registry (single source of truth)
_root = Path(__file__).parent.parent
if str(_root) not in sys.path:
sys.path.insert(0, str(_root))
from registry import DEFAULT_MODEL, VALID_MODELS
# ============================================================================
# Project Schemas
# ============================================================================
@@ -114,7 +123,16 @@ class FeatureBulkCreateResponse(BaseModel):
class AgentStartRequest(BaseModel):
"""Request schema for starting the agent."""
yolo_mode: bool = False
yolo_mode: bool | None = None # None means use global settings
model: str | None = None # None means use global settings
@field_validator('model')
@classmethod
def validate_model(cls, v: str | None) -> str | None:
"""Validate model is in the allowed list."""
if v is not None and v not in VALID_MODELS:
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
return v
class AgentStatus(BaseModel):
@@ -123,6 +141,7 @@ class AgentStatus(BaseModel):
pid: int | None = None
started_at: datetime | None = None
yolo_mode: bool = False
model: str | None = None # Model being used by running agent
class AgentActionResponse(BaseModel):
@@ -251,3 +270,41 @@ class CreateDirectoryRequest(BaseModel):
"""Request to create a new directory."""
parent_path: str
name: str = Field(..., min_length=1, max_length=255)
# ============================================================================
# Settings Schemas
# ============================================================================
# Note: VALID_MODELS and DEFAULT_MODEL are imported from registry at the top of this file
class ModelInfo(BaseModel):
"""Information about an available model."""
id: str
name: str
class SettingsResponse(BaseModel):
"""Response schema for global settings."""
yolo_mode: bool = False
model: str = DEFAULT_MODEL
class ModelsResponse(BaseModel):
"""Response schema for available models list."""
models: list[ModelInfo]
default: str
class SettingsUpdate(BaseModel):
"""Request schema for updating global settings."""
yolo_mode: bool | None = None
model: str | None = None
@field_validator('model')
@classmethod
def validate_model(cls, v: str | None) -> str | None:
if v is not None and v not in VALID_MODELS:
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
return v

View File

@@ -74,6 +74,7 @@ class AgentProcessManager:
self.started_at: datetime | None = None
self._output_task: asyncio.Task | None = None
self.yolo_mode: bool = False # YOLO mode for rapid prototyping
self.model: str | None = None # Model being used
# Support multiple callbacks (for multiple WebSocket clients)
self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
@@ -214,12 +215,13 @@ class AgentProcessManager:
self.status = "stopped"
self._remove_lock()
async def start(self, yolo_mode: bool = False) -> tuple[bool, str]:
async def start(self, yolo_mode: bool = False, model: str | None = None) -> tuple[bool, str]:
"""
Start the agent as a subprocess.
Args:
yolo_mode: If True, run in YOLO mode (no browser testing)
model: Model to use (e.g., claude-opus-4-5-20251101)
Returns:
Tuple of (success, message)
@@ -230,8 +232,9 @@ class AgentProcessManager:
if not self._check_lock():
return False, "Another agent instance is already running for this project"
# Store YOLO mode for status queries
# Store for status queries
self.yolo_mode = yolo_mode
self.model = model
# Build command - pass absolute path to project directory
cmd = [
@@ -241,6 +244,10 @@ class AgentProcessManager:
str(self.project_dir.resolve()),
]
# Add --model flag if model is specified
if model:
cmd.extend(["--model", model])
# Add --yolo flag if YOLO mode is enabled
if yolo_mode:
cmd.append("--yolo")
@@ -306,6 +313,7 @@ class AgentProcessManager:
self.process = None
self.started_at = None
self.yolo_mode = False # Reset YOLO mode
self.model = None # Reset model
return True, "Agent stopped"
except Exception as e:
@@ -387,6 +395,7 @@ class AgentProcessManager:
"pid": self.pid,
"started_at": self.started_at.isoformat() if self.started_at else None,
"yolo_mode": self.yolo_mode,
"model": self.model,
}
@@ -423,3 +432,73 @@ async def cleanup_all_managers() -> None:
with _managers_lock:
_managers.clear()
def cleanup_orphaned_locks() -> int:
"""
Clean up orphaned lock files from previous server runs.
Scans all registered projects for .agent.lock files and removes them
if the referenced process is no longer running.
Returns:
Number of orphaned lock files cleaned up
"""
import sys
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from registry import list_registered_projects
cleaned = 0
try:
projects = list_registered_projects()
for name, info in projects.items():
project_path = Path(info.get("path", ""))
if not project_path.exists():
continue
lock_file = project_path / ".agent.lock"
if not lock_file.exists():
continue
try:
pid_str = lock_file.read_text().strip()
pid = int(pid_str)
# Check if process is still running
if psutil.pid_exists(pid):
try:
proc = psutil.Process(pid)
cmdline = " ".join(proc.cmdline())
if "autonomous_agent_demo.py" in cmdline:
# Process is still running, don't remove
logger.info(
"Found running agent for project '%s' (PID %d)",
name, pid
)
continue
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
# Process not running or not our agent - remove stale lock
lock_file.unlink(missing_ok=True)
cleaned += 1
logger.info("Removed orphaned lock file for project '%s'", name)
except (ValueError, OSError) as e:
# Invalid lock file content - remove it
logger.warning(
"Removing invalid lock file for project '%s': %s", name, e
)
lock_file.unlink(missing_ok=True)
cleaned += 1
except Exception as e:
logger.error("Error during orphan cleanup: %s", e)
if cleaned:
logger.info("Cleaned up %d orphaned lock file(s)", cleaned)
return cleaned