diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index 02a4a66..9c9098f 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -12,18 +12,43 @@ Pull request(s): $ARGUMENTS 1. **Retrieve PR Details** - Use the GH CLI tool to retrieve the details (descriptions, diffs, comments, feedback, reviews, etc) -2. **Analyze Codebase Impact** - - Use 3 deepdive subagents to analyze the impact on the codebase +2. **Assess PR Complexity** -3. **Vision Alignment Check** + After retrieving PR details, assess complexity based on: + - Number of files changed + - Lines added/removed + - Number of contributors/commits + - Whether changes touch core/architectural files + + ### Complexity Tiers + + **Simple** (no deep dive agents needed): + - ≤5 files changed AND ≤100 lines changed AND single author + - Review directly without spawning agents + + **Medium** (1-2 deep dive agents): + - 6-15 files changed, OR 100-500 lines, OR 2 contributors + - Spawn 1 agent for focused areas, 2 if changes span multiple domains + + **Complex** (up to 3 deep dive agents): + - >15 files, OR >500 lines, OR >2 contributors, OR touches core architecture + - Spawn up to 3 agents to analyze different aspects (e.g., security, performance, architecture) + +3. **Analyze Codebase Impact** + - Based on the complexity tier determined above, spawn the appropriate number of deep dive subagents + - For Simple PRs: analyze directly without spawning agents + - For Medium PRs: spawn 1-2 agents focusing on the most impacted areas + - For Complex PRs: spawn up to 3 agents to cover security, performance, and architectural concerns + +4. **Vision Alignment Check** - Read the project's README.md and CLAUDE.md to understand the application's core purpose - Assess whether this PR aligns with the application's intended functionality - If the changes deviate significantly from the core vision or add functionality that doesn't serve the application's purpose, note this in the review - This is not a blocker, but should be flagged for the reviewer's consideration -4. **Safety Assessment** +5. **Safety Assessment** - Provide a review on whether the PR is safe to merge as-is - Provide any feedback in terms of risk level -5. **Improvements** +6. **Improvements** - Propose any improvements in terms of importance and complexity \ No newline at end of file diff --git a/registry.py b/registry.py index 20d31df..f84803e 100644 --- a/registry.py +++ b/registry.py @@ -16,7 +16,7 @@ from datetime import datetime from pathlib import Path from typing import Any -from sqlalchemy import Column, DateTime, String, create_engine +from sqlalchemy import Column, DateTime, Integer, String, create_engine, text from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker @@ -85,6 +85,7 @@ class Project(Base): name = Column(String(50), primary_key=True, index=True) path = Column(String, nullable=False) # POSIX format for cross-platform created_at = Column(DateTime, nullable=False) + default_concurrency = Column(Integer, nullable=False, default=3) class Settings(Base): @@ -146,12 +147,26 @@ def _get_engine(): } ) Base.metadata.create_all(bind=_engine) + _migrate_add_default_concurrency(_engine) _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine) logger.debug("Initialized registry database at: %s", db_path) return _engine, _SessionLocal +def _migrate_add_default_concurrency(engine) -> None: + """Add default_concurrency column if missing (for existing databases).""" + with engine.connect() as conn: + result = conn.execute(text("PRAGMA table_info(projects)")) + columns = [row[1] for row in result.fetchall()] + if "default_concurrency" not in columns: + conn.execute(text( + "ALTER TABLE projects ADD COLUMN default_concurrency INTEGER DEFAULT 3" + )) + conn.commit() + logger.info("Migrated projects table: added default_concurrency column") + + @contextmanager def _get_session(): """ @@ -307,7 +322,8 @@ def list_registered_projects() -> dict[str, dict[str, Any]]: return { p.name: { "path": p.path, - "created_at": p.created_at.isoformat() if p.created_at else None + "created_at": p.created_at.isoformat() if p.created_at else None, + "default_concurrency": getattr(p, 'default_concurrency', 3) or 3 } for p in projects } @@ -333,7 +349,8 @@ def get_project_info(name: str) -> dict[str, Any] | None: return None return { "path": project.path, - "created_at": project.created_at.isoformat() if project.created_at else None + "created_at": project.created_at.isoformat() if project.created_at else None, + "default_concurrency": getattr(project, 'default_concurrency', 3) or 3 } finally: session.close() @@ -362,6 +379,55 @@ def update_project_path(name: str, new_path: Path) -> bool: return True +def get_project_concurrency(name: str) -> int: + """ + Get project's default concurrency (1-5). + + Args: + name: The project name. + + Returns: + The default concurrency value (defaults to 3 if not set or project not found). + """ + _, SessionLocal = _get_engine() + session = SessionLocal() + try: + project = session.query(Project).filter(Project.name == name).first() + if project is None: + return 3 + return getattr(project, 'default_concurrency', 3) or 3 + finally: + session.close() + + +def set_project_concurrency(name: str, concurrency: int) -> bool: + """ + Set project's default concurrency (1-5). + + Args: + name: The project name. + concurrency: The concurrency value (1-5). + + Returns: + True if updated, False if project wasn't found. + + Raises: + ValueError: If concurrency is not between 1 and 5. + """ + if concurrency < 1 or concurrency > 5: + raise ValueError("concurrency must be between 1 and 5") + + with _get_session() as session: + project = session.query(Project).filter(Project.name == name).first() + if not project: + return False + + project.default_concurrency = concurrency + + logger.info("Set project '%s' default_concurrency to %d", name, concurrency) + return True + + # ============================================================================= # Validation Functions # ============================================================================= diff --git a/server/routers/projects.py b/server/routers/projects.py index 68cf526..70e27cc 100644 --- a/server/routers/projects.py +++ b/server/routers/projects.py @@ -18,6 +18,7 @@ from ..schemas import ( ProjectDetail, ProjectPrompts, ProjectPromptsUpdate, + ProjectSettingsUpdate, ProjectStats, ProjectSummary, ) @@ -63,13 +64,23 @@ def _get_registry_functions(): 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 + 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"]) @@ -102,7 +113,8 @@ def get_project_stats(project_dir: Path) -> ProjectStats: async def list_projects(): """List all registered projects.""" _init_imports() - _, _, _, list_registered_projects, validate_project_path = _get_registry_functions() + (_, _, _, list_registered_projects, validate_project_path, + get_project_concurrency, _) = _get_registry_functions() projects = list_registered_projects() result = [] @@ -123,6 +135,7 @@ async def list_projects(): path=info["path"], has_spec=has_spec, stats=stats, + default_concurrency=info.get("default_concurrency", 3), )) return result @@ -132,7 +145,8 @@ async def list_projects(): 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() + (register_project, _, get_project_path, list_registered_projects, + _, _, _) = _get_registry_functions() name = validate_project_name(project.name) project_path = Path(project.path).resolve() @@ -203,6 +217,7 @@ async def create_project(project: ProjectCreate): 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, ) @@ -210,7 +225,7 @@ async def create_project(project: ProjectCreate): async def get_project(name: str): """Get detailed information about a project.""" _init_imports() - _, _, get_project_path, _, _ = _get_registry_functions() + (_, _, get_project_path, _, _, get_project_concurrency, _) = _get_registry_functions() name = validate_project_name(name) project_dir = get_project_path(name) @@ -231,6 +246,7 @@ async def get_project(name: str): has_spec=has_spec, stats=stats, prompts_dir=str(prompts_dir), + default_concurrency=get_project_concurrency(name), ) @@ -244,7 +260,7 @@ async def delete_project(name: str, delete_files: bool = False): delete_files: If True, also delete the project directory and files """ _init_imports() - _, unregister_project, get_project_path, _, _ = _get_registry_functions() + (_, unregister_project, get_project_path, _, _, _, _) = _get_registry_functions() name = validate_project_name(name) project_dir = get_project_path(name) @@ -280,7 +296,7 @@ async def delete_project(name: str, delete_files: bool = False): async def get_project_prompts(name: str): """Get the content of project prompt files.""" _init_imports() - _, _, get_project_path, _, _ = _get_registry_functions() + (_, _, get_project_path, _, _, _, _) = _get_registry_functions() name = validate_project_name(name) project_dir = get_project_path(name) @@ -313,7 +329,7 @@ async def get_project_prompts(name: str): async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate): """Update project prompt files.""" _init_imports() - _, _, get_project_path, _, _ = _get_registry_functions() + (_, _, get_project_path, _, _, _, _) = _get_registry_functions() name = validate_project_name(name) project_dir = get_project_path(name) @@ -343,7 +359,7 @@ async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate): async def get_project_stats_endpoint(name: str): """Get current progress statistics for a project.""" _init_imports() - _, _, get_project_path, _, _ = _get_registry_functions() + (_, _, get_project_path, _, _, _, _) = _get_registry_functions() name = validate_project_name(name) project_dir = get_project_path(name) @@ -355,3 +371,40 @@ async def get_project_stats_endpoint(name: str): 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), + ) diff --git a/server/schemas.py b/server/schemas.py index 0a2807c..03e73ef 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -45,6 +45,7 @@ class ProjectSummary(BaseModel): path: str has_spec: bool stats: ProjectStats + default_concurrency: int = 3 class ProjectDetail(BaseModel): @@ -54,6 +55,7 @@ class ProjectDetail(BaseModel): has_spec: bool stats: ProjectStats prompts_dir: str + default_concurrency: int = 3 class ProjectPrompts(BaseModel): @@ -70,6 +72,18 @@ class ProjectPromptsUpdate(BaseModel): coding_prompt: str | None = None +class ProjectSettingsUpdate(BaseModel): + """Request schema for updating project-level settings.""" + default_concurrency: int | None = None + + @field_validator('default_concurrency') + @classmethod + def validate_concurrency(cls, v: int | None) -> int | None: + if v is not None and (v < 1 or v > 5): + raise ValueError("default_concurrency must be between 1 and 5") + return v + + # ============================================================================ # Feature Schemas # ============================================================================ diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ddde90f..476539c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -268,6 +268,7 @@ function App() { { + setConcurrency(defaultConcurrency) + }, [defaultConcurrency]) + + // Debounced save for concurrency changes + const updateProjectSettings = useUpdateProjectSettings(projectName) + const saveTimeoutRef = useRef | null>(null) + + const handleConcurrencyChange = useCallback((newConcurrency: number) => { + setConcurrency(newConcurrency) + + // Clear previous timeout + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current) + } + + // Debounce save (500ms) + saveTimeoutRef.current = setTimeout(() => { + updateProjectSettings.mutate({ default_concurrency: newConcurrency }) + }, 500) + }, [updateProjectSettings]) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current) + } + } + }, []) const startAgent = useStartAgent(projectName) const stopAgent = useStopAgent(projectName) @@ -57,7 +91,7 @@ export function AgentControl({ projectName, status }: AgentControlProps) { min={1} max={5} value={concurrency} - onChange={(e) => setConcurrency(Number(e.target.value))} + onChange={(e) => handleConcurrencyChange(Number(e.target.value))} disabled={isLoading} className="w-16 h-2 accent-primary cursor-pointer" title={`${concurrency} concurrent agent${concurrency > 1 ? 's' : ''}`} diff --git a/ui/src/hooks/useProjects.ts b/ui/src/hooks/useProjects.ts index 0af7763..4ed3914 100644 --- a/ui/src/hooks/useProjects.ts +++ b/ui/src/hooks/useProjects.ts @@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import * as api from '../lib/api' -import type { FeatureCreate, FeatureUpdate, ModelsResponse, Settings, SettingsUpdate } from '../lib/types' +import type { FeatureCreate, FeatureUpdate, ModelsResponse, ProjectSettingsUpdate, Settings, SettingsUpdate } from '../lib/types' // ============================================================================ // Projects @@ -48,6 +48,19 @@ export function useDeleteProject() { }) } +export function useUpdateProjectSettings(projectName: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (settings: ProjectSettingsUpdate) => + api.updateProjectSettings(projectName, settings), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects'] }) + queryClient.invalidateQueries({ queryKey: ['project', projectName] }) + }, + }) +} + // ============================================================================ // Features // ============================================================================ diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 7ef9a8a..ce3354e 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -6,6 +6,7 @@ import type { ProjectSummary, ProjectDetail, ProjectPrompts, + ProjectSettingsUpdate, FeatureListResponse, Feature, FeatureCreate, @@ -100,6 +101,16 @@ export async function updateProjectPrompts( }) } +export async function updateProjectSettings( + name: string, + settings: ProjectSettingsUpdate +): Promise { + return fetchJSON(`/projects/${encodeURIComponent(name)}/settings`, { + method: 'PATCH', + body: JSON.stringify(settings), + }) +} + // ============================================================================ // Features API // ============================================================================ diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index d883432..269c2ef 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -15,6 +15,7 @@ export interface ProjectSummary { path: string has_spec: boolean stats: ProjectStats + default_concurrency: number } export interface ProjectDetail extends ProjectSummary { @@ -536,6 +537,10 @@ export interface SettingsUpdate { testing_agent_ratio?: number } +export interface ProjectSettingsUpdate { + default_concurrency?: number +} + // ============================================================================ // Schedule Types // ============================================================================