Explore the autocoder+ backend to understand project creation and spec WebSocket. Focus on:

1. backend/app/routers/projects.py - project CRUD endpoints
2. backend/app/routers/websocket.py - spec creation WebSocket handler
3. backend/app/main.py - how routes are registered
4. Any filesystem/folder browsing endpoints

I need to understand:
- How projects are created in the database
- How the spec creation WebSocket session works
- What context/data is passed to Claude during spec creation
- How we could add an "import project" endpoint that registers an existing folder

Base path: C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocode-fork\backend


I'll explore the backend to understand the project creation flow and WebSocket implementation. Let me search through the key files systematically.


     1→"""
     2→Projects Router
     3→===============
     4→
     5→API endpoints for project management.
     6→"""
     7→
     8→from datetime import datetime
     9→from typing import Optional, List
    10→
    11→from fastapi import APIRouter, Depends, HTTPException, status
    12→from pydantic import BaseModel, Field
    13→from sqlalchemy import select
    14→from sqlalchemy.ext.asyncio import AsyncSession
    15→
    16→from ..database import get_db
    17→from ..models import Project, Permission, Infrastructure
    18→from ..path_security import validate_project_path
    19→
    20→router = APIRouter(prefix="/api/projects", tags=["projects"])
    21→
    22→
    23→# ============================================================================
    24→# Pydantic Schemas
    25→# ============================================================================
    26→
    27→class ProjectCreate(BaseModel):
    28→    """Schema for creating a new project."""
    29→    name: str = Field(..., min_length=1, max_length=255, description="Unique project name")
    30→    path: str = Field(..., min_length=1, max_length=1024, description="Project directory path")
    31→
    32→
    33→class ProjectUpdate(BaseModel):
    34→    """Schema for updating a project."""
    35→    path: Optional[str] = Field(None, min_length=1, max_length=1024)
    36→
    37→
    38→class ProjectResponse(BaseModel):
    39→    """Schema for project response."""
    40→    id: int
    41→    name: str
    42→    path: str
    43→    created_at: datetime
    44→    updated_at: datetime
    45→    spec_version: int
    46→
    47→    class Config:
    48→        from_attributes = True
    49→
    50→
    51→class ProjectListResponse(BaseModel):
    52→    """Schema for project list response."""
    53→    projects: List[ProjectResponse]
    54→    count: int
    55→
    56→
    57→# ============================================================================
    58→# API Endpoints
    59→# ============================================================================
    60→
    61→@router.get("", response_model=ProjectListResponse)
    62→async def list_projects(db: AsyncSession = Depends(get_db)):
    63→    """List all projects."""
    64→    result = await db.execute(select(Project).order_by(Project.name))
    65→    projects = result.scalars().all()
    66→    return ProjectListResponse(
    67→        projects=[ProjectResponse.model_validate(p) for p in projects],
    68→        count=len(projects)
    69→    )
    70→
    71→
    72→@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
    73→async def create_project(project: ProjectCreate, db: AsyncSession = Depends(get_db)):
    74→    """Create a new project."""
    75→    # Validate path for security
    76→    validate_project_path(project.path)
    77→    # Check if project with this name already exists
    78→    result = await db.execute(select(Project).where(Project.name == project.name))
    79→    if result.scalar_one_or_none():
    80→        raise HTTPException(
    81→            status_code=status.HTTP_409_CONFLICT,
    82→            detail=f"Project with name '{project.name}' already exists"
    83→        )
    84→
    85→    # Create new project
    86→    db_project = Project(
    87→        name=project.name,
    88→        path=project.path,
    89→    )
    90→    db.add(db_project)
    91→    await db.commit()
    92→    await db.refresh(db_project)
    93→
    94→    return ProjectResponse.model_validate(db_project)
    95→
    96→
    97→@router.get("/{name}", response_model=ProjectResponse)
    98→async def get_project(name: str, db: AsyncSession = Depends(get_db)):
    99→    """Get a project by name."""
   100→    result = await db.execute(select(Project).where(Project.name == name))
   101→    project = result.scalar_one_or_none()
   102→
   103→    if not project:
   104→        raise HTTPException(
   105→            status_code=status.HTTP_404_NOT_FOUND,
   106→            detail=f"Project '{name}' not found"
   107→        )
   108→
   109→    return ProjectResponse.model_validate(project)
   110→
   111→
   112→@router.put("/{name}", response_model=ProjectResponse)
   113→async def update_project(name: str, project_update: ProjectUpdate, db: AsyncSession = Depends(get_db)):
   114→    """Update a project by name."""
   115→    result = await db.execute(select(Project).where(Project.name == name))
   116→    project = result.scalar_one_or_none()
   117→
   118→    if not project:
   119→        raise HTTPException(
   120→            status_code=status.HTTP_404_NOT_FOUND,
   121→            detail=f"Project '{name}' not found"
   122→        )
   123→
   124→    if project_update.path is not None:
   125→        # Validate path for security
   126→        validate_project_path(project_update.path)
   127→        project.path = project_update.path
   128→
   129→    project.updated_at = datetime.utcnow()
   130→    await db.commit()
   131→    await db.refresh(project)
   132→
   133→    return ProjectResponse.model_validate(project)
   134→
   135→
   136→@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT)
   137→async def delete_project(name: str, db: AsyncSession = Depends(get_db)):
   138→    """Delete a project by name."""
   139→    result = await db.execute(select(Project).where(Project.name == name))
   140→    project = result.scalar_one_or_none()
   141→
   142→    if not project:
   143→        raise HTTPException(
   144→            status_code=status.HTTP_404_NOT_FOUND,
   145→            detail=f"Project '{name}' not found"
   146→        )
   147→
   148→    await db.delete(project)
   149→    await db.commit()
   150→
   151→
   152→class DuplicateProjectRequest(BaseModel):
   153→    new_name: str
   154→
   155→
   156→@router.post("/{name}/duplicate", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
   157→async def duplicate_project(name: str, request: DuplicateProjectRequest, db: AsyncSession = Depends(get_db)):
   158→    """Duplicate a project with a new name, including permissions and infrastructure."""
   159→    new_name = request.new_name
   160→    # Get source project
   161→    result = await db.execute(select(Project).where(Project.name == name))
   162→    source_project = result.scalar_one_or_none()
   163→
   164→    if not source_project:
   165→        raise HTTPException(
   166→            status_code=status.HTTP_404_NOT_FOUND,
   167→            detail=f"Project '{name}' not found"
   168→        )
   169→
   170→    # Check if new name already exists
   171→    result = await db.execute(select(Project).where(Project.name == new_name))
   172→    if result.scalar_one_or_none():
   173→        raise HTTPException(
   174→            status_code=status.HTTP_409_CONFLICT,
   175→            detail=f"Project with name '{new_name}' already exists"
   176→        )
   177→
   178→    # Create duplicate project
   179→    new_project = Project(
   180→        name=new_name,
   181→        path=source_project.path,
   182→    )
   183→    db.add(new_project)
   184→    await db.flush()  # Get the new project ID without committing
   185→
   186→    # Copy permissions from source project
   187→    permissions_result = await db.execute(
   188→        select(Permission).where(Permission.project_id == source_project.id)
   189→    )
   190→    source_permissions = permissions_result.scalars().all()
   191→    for perm in source_permissions:
   192→        new_perm = Permission(
   193→            project_id=new_project.id,
   194→            permission_type=perm.permission_type,
   195→            permission_value=perm.permission_value,
   196→            granted=perm.granted,
   197→        )
   198→        db.add(new_perm)
   199→
   200→    # Copy infrastructure from source project
   201→    infra_result = await db.execute(
   202→        select(Infrastructure).where(Infrastructure.project_id == source_project.id)
   203→    )
   204→    source_infrastructure = infra_result.scalars().all()
   205→    for infra in source_infrastructure:
   206→        new_infra = Infrastructure(
   207→            project_id=new_project.id,
   208→            service_type=infra.service_type,
   209→            service_name=infra.service_name,
   210→            host=infra.host,
   211→            port=infra.port,
   212→            credentials_ref=infra.credentials_ref,
   213→            health_check_url=infra.health_check_url,
   214→            is_healthy=False,  # Reset health status for new project
   215→        )
   216→        db.add(new_infra)
   217→
   218→    await db.commit()
   219→    await db.refresh(new_project)
   220→
   221→    return ProjectResponse.model_validate(new_project)
   222→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Projects Router
===============

API endpoints for project management.
"""

from datetime import datetime
from typing import Optional, List

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from ..database import get_db
from ..models import Project, Permission, Infrastructure
from ..path_security import validate_project_path

router = APIRouter(prefix="/api/projects", tags=["projects"])

# ============================================================================
# Pydantic Schemas
# ============================================================================

class ProjectCreate(BaseModel):
    """Schema for creating a new project."""
    name: str = Field(..., min_length=1, max_length=255, description="Unique project name")
    path: str = Field(..., min_length=1, max_length=1024, description="Project directory path")

class ProjectUpdate(BaseModel):
    """Schema for updating a project."""
    path: Optional[str] = Field(None, min_length=1, max_length=1024)

class ProjectResponse(BaseModel):
    """Schema for project response."""
    id: int
    name: str
    path: str
    created_at: datetime
    updated_at: datetime
    spec_version: int

    class Config:
        from_attributes = True

class ProjectListResponse(BaseModel):
    """Schema for project list response."""
    projects: List[ProjectResponse]
    count: int

# ============================================================================
# API Endpoints
# ============================================================================

@router.get("", response_model=ProjectListResponse)
async def list_projects(db: AsyncSession = Depends(get_db)):
    """List all projects."""
    result = await db.execute(select(Project).order_by(Project.name))
    projects = result.scalars().all()
    return ProjectListResponse(
        projects=[ProjectResponse.model_validate(p) for p in projects],
        count=len(projects)
    )

@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_project(project: ProjectCreate, db: AsyncSession = Depends(get_db)):
    """Create a new project."""
    # Validate path for security
    validate_project_path(project.path)
    # Check if project with this name already exists
    result = await db.execute(select(Project).where(Project.name == project.name))
    if result.scalar_one_or_none():
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"Project with name '{project.name}' already exists"
        )

    # Create new project
    db_project = Project(
        name=project.name,
        path=project.path,
    )
    db.add(db_project)
    await db.commit()
    await db.refresh(db_project)

    return ProjectResponse.model_validate(db_project)

@router.get("/{name}", response_model=ProjectResponse)
async def get_project(name: str, db: AsyncSession = Depends(get_db)):
    """Get a project by name."""
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    return ProjectResponse.model_validate(project)

@router.put("/{name}", response_model=ProjectResponse)
async def update_project(name: str, project_update: ProjectUpdate, db: AsyncSession = Depends(get_db)):
    """Update a project by name."""
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    if project_update.path is not None:
        # Validate path for security
        validate_project_path(project_update.path)
        project.path = project_update.path

    project.updated_at = datetime.utcnow()
    await db.commit()
    await db.refresh(project)

    return ProjectResponse.model_validate(project)

@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(name: str, db: AsyncSession = Depends(get_db)):
    """Delete a project by name."""
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    await db.delete(project)
    await db.commit()

class DuplicateProjectRequest(BaseModel):
    new_name: str

@router.post("/{name}/duplicate", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def duplicate_project(name: str, request: DuplicateProjectRequest, db: AsyncSession = Depends(get_db)):
    """Duplicate a project with a new name, including permissions and infrastructure."""
    new_name = request.new_name
    # Get source project
    result = await db.execute(select(Project).where(Project.name == name))
    source_project = result.scalar_one_or_none()

    if not source_project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Check if new name already exists
    result = await db.execute(select(Project).where(Project.name == new_name))
    if result.scalar_one_or_none():
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"Project with name '{new_name}' already exists"
        )

    # Create duplicate project
    new_project = Project(
        name=new_name,
        path=source_project.path,
    )
    db.add(new_project)
    await db.flush()  # Get the new project ID without committing

    # Copy permissions from source project
    permissions_result = await db.execute(
        select(Permission).where(Permission.project_id == source_project.id)
    )
    source_permissions = permissions_result.scalars().all()
    for perm in source_permissions:
        new_perm = Permission(
            project_id=new_project.id,
            permission_type=perm.permission_type,
            permission_value=perm.permission_value,
            granted=perm.granted,
        )
        db.add(new_perm)

    # Copy infrastructure from source project
    infra_result = await db.execute(
        select(Infrastructure).where(Infrastructure.project_id == source_project.id)
    )
    source_infrastructure = infra_result.scalars().all()
    for infra in source_infrastructure:
        new_infra = Infrastructure(
            project_id=new_project.id,
            service_type=infra.service_type,
            service_name=infra.service_name,
            host=infra.host,
            port=infra.port,
            credentials_ref=infra.credentials_ref,
            health_check_url=infra.health_check_url,
            is_healthy=False,  # Reset health status for new project
        )
        db.add(new_infra)

    await db.commit()
    await db.refresh(new_project)

    return ProjectResponse.model_validate(new_project)


     1→"""
     2→Autocoder+ FastAPI Application
     3→==============================
     4→
     5→Main entry point for the Autocoder+ backend server.
     6→Provides REST API, WebSocket connections, and serves the frontend.
     7→"""
     8→
     9→from contextlib import asynccontextmanager
    10→from datetime import datetime
    11→from pathlib import Path
    12→
    13→from fastapi import FastAPI, HTTPException, Request
    14→from fastapi.middleware.cors import CORSMiddleware
    15→from fastapi.responses import FileResponse
    16→from fastapi.staticfiles import StaticFiles
    17→from sqlalchemy import text
    18→
    19→from .config import settings
    20→from .database import init_db, engine
    21→from .models import *  # Import all models to register them with Base
    22→from .routers import projects_router, websocket_router, agents_router, features_router, permissions_router, spec_router, infrastructure_router, git_router
    23→
    24→# Paths
    25→ROOT_DIR = Path(__file__).parent.parent
    26→FRONTEND_DIST_DIR = ROOT_DIR.parent / "frontend" / "dist"
    27→
    28→
    29→async def run_migrations():
    30→    """Run database migrations for new columns."""
    31→    async with engine.begin() as conn:
    32→        # Check if depends_on column exists in features table
    33→        result = await conn.execute(text("PRAGMA table_info(features)"))
    34→        columns = [row[1] for row in result.fetchall()]
    35→
    36→        if 'depends_on' not in columns:
    37→            print("Adding depends_on column to features table...")
    38→            await conn.execute(text('ALTER TABLE features ADD COLUMN depends_on JSON DEFAULT "[]"'))
    39→            print("depends_on column added successfully")
    40→
    41→        if 'files_modified' not in columns:
    42→            print("Adding files_modified column to features table...")
    43→            await conn.execute(text('ALTER TABLE features ADD COLUMN files_modified JSON DEFAULT "[]"'))
    44→            print("files_modified column added successfully")
    45→
    46→
    47→@asynccontextmanager
    48→async def lifespan(app: FastAPI):
    49→    """Lifespan context manager for startup and shutdown."""
    50→    # Startup
    51→    print(f"Autocoder+ server starting on {settings.host}:{settings.port}")
    52→    print("Initializing database...")
    53→    await init_db()
    54→    print("Database initialized successfully")
    55→    print("Running migrations...")
    56→    await run_migrations()
    57→    print("Migrations complete")
    58→    yield
    59→    # Shutdown
    60→    print("Autocoder+ server shutting down...")
    61→
    62→
    63→# Create FastAPI app
    64→app = FastAPI(
    65→    title="Autocoder+",
    66→    description="Enhanced autonomous coding agent with permission management and dual-agent execution",
    67→    version="1.0.0",
    68→    lifespan=lifespan,
    69→)
    70→
    71→# CORS middleware
    72→app.add_middleware(
    73→    CORSMiddleware,
    74→    allow_origins=settings.allowed_origins,
    75→    allow_credentials=True,
    76→    allow_methods=["*"],
    77→    allow_headers=["*"],
    78→)
    79→
    80→
    81→# ============================================================================
    82→# Security Middleware
    83→# ============================================================================
    84→
    85→@app.middleware("http")
    86→async def require_localhost(request: Request, call_next):
    87→    """Only allow requests from localhost for security."""
    88→    client_host = request.client.host if request.client else None
    89→
    90→    # Allow localhost connections
    91→    if client_host not in ("127.0.0.1", "::1", "localhost", None):
    92→        raise HTTPException(status_code=403, detail="Localhost access only")
    93→
    94→    return await call_next(request)
    95→
    96→
    97→# ============================================================================
    98→# Health Check Endpoint
    99→# ============================================================================
   100→
   101→@app.get("/health")
   102→async def health_check():
   103→    """Health check endpoint for monitoring server status."""
   104→    return {
   105→        "status": "healthy",
   106→        "service": "autocoder-plus",
   107→        "timestamp": datetime.utcnow().isoformat(),
   108→        "version": "1.0.0"
   109→    }
   110→
   111→
   112→@app.get("/api/health")
   113→async def api_health_check():
   114→    """Alternative health check endpoint under /api prefix."""
   115→    return await health_check()
   116→
   117→
   118→# ============================================================================
   119→# Database Debug Endpoint
   120→# ============================================================================
   121→
   122→@app.get("/api/debug/tables")
   123→async def get_database_tables():
   124→    """Debug endpoint to list all database tables."""
   125→    async with engine.begin() as conn:
   126→        result = await conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"))
   127→        tables = [row[0] for row in result.fetchall()]
   128→
   129→    return {"tables": tables, "count": len(tables)}
   130→
   131→
   132→# ============================================================================
   133→# API Routers
   134→# ============================================================================
   135→
   136→app.include_router(projects_router)
   137→app.include_router(websocket_router)
   138→app.include_router(agents_router)
   139→app.include_router(features_router)
   140→app.include_router(permissions_router)
   141→app.include_router(spec_router)
   142→app.include_router(infrastructure_router)
   143→app.include_router(git_router)
   144→
   145→
   146→# ============================================================================
   147→# Static File Serving (Production)
   148→# ============================================================================
   149→
   150→# Serve React build files if they exist
   151→if FRONTEND_DIST_DIR.exists():
   152→    # Mount static assets
   153→    assets_dir = FRONTEND_DIST_DIR / "assets"
   154→    if assets_dir.exists():
   155→        app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
   156→
   157→    @app.get("/")
   158→    async def serve_index():
   159→        """Serve the React app index.html."""
   160→        return FileResponse(FRONTEND_DIST_DIR / "index.html")
   161→
   162→    @app.get("/{path:path}")
   163→    async def serve_spa(path: str):
   164→        """Serve static files or fall back to index.html for SPA routing."""
   165→        # API routes shouldn't reach here
   166→        if path.startswith("api/") or path.startswith("ws/") or path == "health":
   167→            raise HTTPException(status_code=404)
   168→
   169→        # Try to serve the file directly
   170→        file_path = FRONTEND_DIST_DIR / path
   171→        if file_path.exists() and file_path.is_file():
   172→            return FileResponse(file_path)
   173→
   174→        # Fall back to index.html for SPA routing
   175→        return FileResponse(FRONTEND_DIST_DIR / "index.html")
   176→
   177→
   178→# ============================================================================
   179→# Main Entry Point
   180→# ============================================================================
   181→
   182→if __name__ == "__main__":
   183→    import uvicorn
   184→    uvicorn.run(
   185→        "app.main:app",
   186→        host=settings.host,
   187→        port=settings.port,
   188→        reload=settings.debug,
   189→    )
   190→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Autocoder+ FastAPI Application
==============================

Main entry point for the Autocoder+ backend server.
Provides REST API, WebSocket connections, and serves the frontend.
"""

from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path

from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text

from .config import settings
from .database import init_db, engine
from .models import *  # Import all models to register them with Base
from .routers import projects_router, websocket_router, agents_router, features_router, permissions_router, spec_router, infrastructure_router, git_router

# Paths
ROOT_DIR = Path(__file__).parent.parent
FRONTEND_DIST_DIR = ROOT_DIR.parent / "frontend" / "dist"

async def run_migrations():
    """Run database migrations for new columns."""
    async with engine.begin() as conn:
        # Check if depends_on column exists in features table
        result = await conn.execute(text("PRAGMA table_info(features)"))
        columns = [row[1] for row in result.fetchall()]

        if 'depends_on' not in columns:
            print("Adding depends_on column to features table...")
            await conn.execute(text('ALTER TABLE features ADD COLUMN depends_on JSON DEFAULT "[]"'))
            print("depends_on column added successfully")

        if 'files_modified' not in columns:
            print("Adding files_modified column to features table...")
            await conn.execute(text('ALTER TABLE features ADD COLUMN files_modified JSON DEFAULT "[]"'))
            print("files_modified column added successfully")

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Lifespan context manager for startup and shutdown."""
    # Startup
    print(f"Autocoder+ server starting on {settings.host}:{settings.port}")
    print("Initializing database...")
    await init_db()
    print("Database initialized successfully")
    print("Running migrations...")
    await run_migrations()
    print("Migrations complete")
    yield
    # Shutdown
    print("Autocoder+ server shutting down...")

# Create FastAPI app
app = FastAPI(
    title="Autocoder+",
    description="Enhanced autonomous coding agent with permission management and dual-agent execution",
    version="1.0.0",
    lifespan=lifespan,
)

# CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.allowed_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ============================================================================
# Security Middleware
# ============================================================================

@app.middleware("http")
async def require_localhost(request: Request, call_next):
    """Only allow requests from localhost for security."""
    client_host = request.client.host if request.client else None

    # Allow localhost connections
    if client_host not in ("127.0.0.1", "::1", "localhost", None):
        raise HTTPException(status_code=403, detail="Localhost access only")

    return await call_next(request)

# ============================================================================
# Health Check Endpoint
# ============================================================================

@app.get("/health")
async def health_check():
    """Health check endpoint for monitoring server status."""
    return {
        "status": "healthy",
        "service": "autocoder-plus",
        "timestamp": datetime.utcnow().isoformat(),
        "version": "1.0.0"
    }

@app.get("/api/health")
async def api_health_check():
    """Alternative health check endpoint under /api prefix."""
    return await health_check()

# ============================================================================
# Database Debug Endpoint
# ============================================================================

@app.get("/api/debug/tables")
async def get_database_tables():
    """Debug endpoint to list all database tables."""
    async with engine.begin() as conn:
        result = await conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"))
        tables = [row[0] for row in result.fetchall()]

    return {"tables": tables, "count": len(tables)}

# ============================================================================
# API Routers
# ============================================================================

app.include_router(projects_router)
app.include_router(websocket_router)
app.include_router(agents_router)
app.include_router(features_router)
app.include_router(permissions_router)
app.include_router(spec_router)
app.include_router(infrastructure_router)
app.include_router(git_router)

# ============================================================================
# Static File Serving (Production)
# ============================================================================

# Serve React build files if they exist
if FRONTEND_DIST_DIR.exists():
    # Mount static assets
    assets_dir = FRONTEND_DIST_DIR / "assets"
    if assets_dir.exists():
        app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")

    @app.get("/")
    async def serve_index():
        """Serve the React app index.html."""
        return FileResponse(FRONTEND_DIST_DIR / "index.html")

    @app.get("/{path:path}")
    async def serve_spa(path: str):
        """Serve static files or fall back to index.html for SPA routing."""
        # API routes shouldn't reach here
        if path.startswith("api/") or path.startswith("ws/") or path == "health":
            raise HTTPException(status_code=404)

        # Try to serve the file directly
        file_path = FRONTEND_DIST_DIR / path
        if file_path.exists() and file_path.is_file():
            return FileResponse(file_path)

        # Fall back to index.html for SPA routing
        return FileResponse(FRONTEND_DIST_DIR / "index.html")

# ============================================================================
# Main Entry Point
# ============================================================================

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(
        "app.main:app",
        host=settings.host,
        port=settings.port,
        reload=settings.debug,
    )


Now let me check the WebSocket router and look for other relevant files:


<tool_use_error>File does not exist. Did you mean models?</tool_use_error>

Error: File does not exist. Did you mean models?


     1→"""
     2→WebSocket Router
     3→================
     4→
     5→WebSocket endpoints for real-time updates.
     6→"""
     7→
     8→import asyncio
     9→import json
    10→from datetime import datetime
    11→from typing import Dict, Set
    12→
    13→from fastapi import APIRouter, WebSocket, WebSocketDisconnect
    14→from sqlalchemy import select
    15→
    16→from ..database import async_session_maker
    17→from ..models import Project
    18→
    19→router = APIRouter()
    20→
    21→
    22→# ============================================================================
    23→# Connection Manager
    24→# ============================================================================
    25→
    26→class ConnectionManager:
    27→    """Manages WebSocket connections for projects."""
    28→
    29→    def __init__(self):
    30→        # Map project name to set of active connections
    31→        self.active_connections: Dict[str, Set[WebSocket]] = {}
    32→        # Track heartbeat tasks
    33→        self.heartbeat_tasks: Dict[WebSocket, asyncio.Task] = {}
    34→
    35→    async def connect(self, websocket: WebSocket, project_name: str) -> bool:
    36→        """Accept connection and add to project's connection list."""
    37→        await websocket.accept()
    38→
    39→        if project_name not in self.active_connections:
    40→            self.active_connections[project_name] = set()
    41→
    42→        self.active_connections[project_name].add(websocket)
    43→
    44→        # Start heartbeat task
    45→        task = asyncio.create_task(self._heartbeat_loop(websocket, project_name))
    46→        self.heartbeat_tasks[websocket] = task
    47→
    48→        return True
    49→
    50→    def disconnect(self, websocket: WebSocket, project_name: str):
    51→        """Remove connection from project's connection list."""
    52→        if project_name in self.active_connections:
    53→            self.active_connections[project_name].discard(websocket)
    54→            if not self.active_connections[project_name]:
    55→                del self.active_connections[project_name]
    56→
    57→        # Cancel heartbeat task
    58→        if websocket in self.heartbeat_tasks:
    59→            self.heartbeat_tasks[websocket].cancel()
    60→            del self.heartbeat_tasks[websocket]
    61→
    62→    async def broadcast_to_project(self, project_name: str, message: dict):
    63→        """Send message to all connections for a project."""
    64→        if project_name in self.active_connections:
    65→            disconnected = set()
    66→            for connection in self.active_connections[project_name]:
    67→                try:
    68→                    await connection.send_json(message)
    69→                except Exception:
    70→                    disconnected.add(connection)
    71→
    72→            # Clean up disconnected connections
    73→            for conn in disconnected:
    74→                self.disconnect(conn, project_name)
    75→
    76→    async def _heartbeat_loop(self, websocket: WebSocket, project_name: str):
    77→        """Send periodic heartbeat messages."""
    78→        try:
    79→            while True:
    80→                await asyncio.sleep(30)  # Send heartbeat every 30 seconds
    81→                try:
    82→                    await websocket.send_json({
    83→                        "type": "heartbeat",
    84→                        "timestamp": datetime.utcnow().isoformat(),
    85→                        "project": project_name
    86→                    })
    87→                except Exception:
    88→                    break
    89→        except asyncio.CancelledError:
    90→            pass
    91→
    92→
    93→# Global connection manager
    94→manager = ConnectionManager()
    95→
    96→
    97→# ============================================================================
    98→# WebSocket Endpoints
    99→# ============================================================================
   100→
   101→@router.websocket("/ws/projects/{project_name}")
   102→async def websocket_endpoint(websocket: WebSocket, project_name: str):
   103→    """
   104→    WebSocket endpoint for real-time project updates.
   105→
   106→    Message types:
   107→    - heartbeat: Periodic keepalive messages
   108→    - agent_log: Log output from agents
   109→    - agent_status: Agent status changes
   110→    - feature_update: Feature status changes
   111→    - permission_request: New permission request from agent
   112→    """
   113→    # Verify project exists
   114→    async with async_session_maker() as session:
   115→        result = await session.execute(
   116→            select(Project).where(Project.name == project_name)
   117→        )
   118→        project = result.scalar_one_or_none()
   119→
   120→        if not project:
   121→            await websocket.close(code=4004, reason=f"Project '{project_name}' not found")
   122→            return
   123→
   124→    # Accept connection
   125→    await manager.connect(websocket, project_name)
   126→
   127→    # Send initial connection confirmation
   128→    await websocket.send_json({
   129→        "type": "connected",
   130→        "project": project_name,
   131→        "timestamp": datetime.utcnow().isoformat(),
   132→        "message": f"Connected to project '{project_name}'"
   133→    })
   134→
   135→    try:
   136→        while True:
   137→            # Wait for messages from client
   138→            data = await websocket.receive_text()
   139→            try:
   140→                message = json.loads(data)
   141→                # Handle client messages if needed
   142→                if message.get("type") == "ping":
   143→                    await websocket.send_json({
   144→                        "type": "pong",
   145→                        "timestamp": datetime.utcnow().isoformat()
   146→                    })
   147→            except json.JSONDecodeError:
   148→                await websocket.send_json({
   149→                    "type": "error",
   150→                    "message": "Invalid JSON"
   151→                })
   152→    except WebSocketDisconnect:
   153→        manager.disconnect(websocket, project_name)
   154→
   155→
   156→# ============================================================================
   157→# Utility Functions for Broadcasting
   158→# ============================================================================
   159→
   160→async def broadcast_agent_log(project_name: str, agent_id: str, log_type: str, content: str):
   161→    """Broadcast agent log message to all project connections."""
   162→    await manager.broadcast_to_project(project_name, {
   163→        "type": "agent_log",
   164→        "agent_id": agent_id,
   165→        "log_type": log_type,  # "info", "error", "tool", "permission"
   166→        "content": content,
   167→        "timestamp": datetime.utcnow().isoformat()
   168→    })
   169→
   170→
   171→async def broadcast_agent_status(project_name: str, agent_id: str, status: str):
   172→    """Broadcast agent status change to all project connections."""
   173→    await manager.broadcast_to_project(project_name, {
   174→        "type": "agent_status",
   175→        "agent_id": agent_id,
   176→        "status": status,  # "running", "paused", "stopped", "stalled"
   177→        "timestamp": datetime.utcnow().isoformat()
   178→    })
   179→
   180→
   181→async def broadcast_feature_update(project_name: str, feature_id: int, changes: dict):
   182→    """Broadcast feature update to all project connections."""
   183→    await manager.broadcast_to_project(project_name, {
   184→        "type": "feature_update",
   185→        "feature_id": feature_id,
   186→        "changes": changes,
   187→        "timestamp": datetime.utcnow().isoformat()
   188→    })
   189→
   190→
   191→async def broadcast_permission_request(project_name: str, request_id: int, permission_type: str, permission_value: str, agent_id: str):
   192→    """Broadcast permission request to all project connections."""
   193→    await manager.broadcast_to_project(project_name, {
   194→        "type": "permission_request",
   195→        "request_id": request_id,
   196→        "permission_type": permission_type,
   197→        "permission_value": permission_value,
   198→        "agent_id": agent_id,
   199→        "timestamp": datetime.utcnow().isoformat()
   200→    })
   201→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
WebSocket Router
================

WebSocket endpoints for real-time updates.
"""

import asyncio
import json
from datetime import datetime
from typing import Dict, Set

from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select

from ..database import async_session_maker
from ..models import Project

router = APIRouter()

# ============================================================================
# Connection Manager
# ============================================================================

class ConnectionManager:
    """Manages WebSocket connections for projects."""

    def __init__(self):
        # Map project name to set of active connections
        self.active_connections: Dict[str, Set[WebSocket]] = {}
        # Track heartbeat tasks
        self.heartbeat_tasks: Dict[WebSocket, asyncio.Task] = {}

    async def connect(self, websocket: WebSocket, project_name: str) -> bool:
        """Accept connection and add to project's connection list."""
        await websocket.accept()

        if project_name not in self.active_connections:
            self.active_connections[project_name] = set()

        self.active_connections[project_name].add(websocket)

        # Start heartbeat task
        task = asyncio.create_task(self._heartbeat_loop(websocket, project_name))
        self.heartbeat_tasks[websocket] = task

        return True

    def disconnect(self, websocket: WebSocket, project_name: str):
        """Remove connection from project's connection list."""
        if project_name in self.active_connections:
            self.active_connections[project_name].discard(websocket)
            if not self.active_connections[project_name]:
                del self.active_connections[project_name]

        # Cancel heartbeat task
        if websocket in self.heartbeat_tasks:
            self.heartbeat_tasks[websocket].cancel()
            del self.heartbeat_tasks[websocket]

    async def broadcast_to_project(self, project_name: str, message: dict):
        """Send message to all connections for a project."""
        if project_name in self.active_connections:
            disconnected = set()
            for connection in self.active_connections[project_name]:
                try:
                    await connection.send_json(message)
                except Exception:
                    disconnected.add(connection)

            # Clean up disconnected connections
            for conn in disconnected:
                self.disconnect(conn, project_name)

    async def _heartbeat_loop(self, websocket: WebSocket, project_name: str):
        """Send periodic heartbeat messages."""
        try:
            while True:
                await asyncio.sleep(30)  # Send heartbeat every 30 seconds
                try:
                    await websocket.send_json({
                        "type": "heartbeat",
                        "timestamp": datetime.utcnow().isoformat(),
                        "project": project_name
                    })
                except Exception:
                    break
        except asyncio.CancelledError:
            pass

# Global connection manager
manager = ConnectionManager()

# ============================================================================
# WebSocket Endpoints
# ============================================================================

@router.websocket("/ws/projects/{project_name}")
async def websocket_endpoint(websocket: WebSocket, project_name: str):
    """
    WebSocket endpoint for real-time project updates.

    Message types:
    - heartbeat: Periodic keepalive messages
    - agent_log: Log output from agents
    - agent_status: Agent status changes
    - feature_update: Feature status changes
    - permission_request: New permission request from agent
    """
    # Verify project exists
    async with async_session_maker() as session:
        result = await session.execute(
            select(Project).where(Project.name == project_name)
        )
        project = result.scalar_one_or_none()

        if not project:
            await websocket.close(code=4004, reason=f"Project '{project_name}' not found")
            return

    # Accept connection
    await manager.connect(websocket, project_name)

    # Send initial connection confirmation
    await websocket.send_json({
        "type": "connected",
        "project": project_name,
        "timestamp": datetime.utcnow().isoformat(),
        "message": f"Connected to project '{project_name}'"
    })

    try:
        while True:
            # Wait for messages from client
            data = await websocket.receive_text()
            try:
                message = json.loads(data)
                # Handle client messages if needed
                if message.get("type") == "ping":
                    await websocket.send_json({
                        "type": "pong",
                        "timestamp": datetime.utcnow().isoformat()
                    })
            except json.JSONDecodeError:
                await websocket.send_json({
                    "type": "error",
                    "message": "Invalid JSON"
                })
    except WebSocketDisconnect:
        manager.disconnect(websocket, project_name)

# ============================================================================
# Utility Functions for Broadcasting
# ============================================================================

async def broadcast_agent_log(project_name: str, agent_id: str, log_type: str, content: str):
    """Broadcast agent log message to all project connections."""
    await manager.broadcast_to_project(project_name, {
        "type": "agent_log",
        "agent_id": agent_id,
        "log_type": log_type,  # "info", "error", "tool", "permission"
        "content": content,
        "timestamp": datetime.utcnow().isoformat()
    })

async def broadcast_agent_status(project_name: str, agent_id: str, status: str):
    """Broadcast agent status change to all project connections."""
    await manager.broadcast_to_project(project_name, {
        "type": "agent_status",
        "agent_id": agent_id,
        "status": status,  # "running", "paused", "stopped", "stalled"
        "timestamp": datetime.utcnow().isoformat()
    })

async def broadcast_feature_update(project_name: str, feature_id: int, changes: dict):
    """Broadcast feature update to all project connections."""
    await manager.broadcast_to_project(project_name, {
        "type": "feature_update",
        "feature_id": feature_id,
        "changes": changes,
        "timestamp": datetime.utcnow().isoformat()
    })

async def broadcast_permission_request(project_name: str, request_id: int, permission_type: str, permission_value: str, agent_id: str):
    """Broadcast permission request to all project connections."""
    await manager.broadcast_to_project(project_name, {
        "type": "permission_request",
        "request_id": request_id,
        "permission_type": permission_type,
        "permission_value": permission_value,
        "agent_id": agent_id,
        "timestamp": datetime.utcnow().isoformat()
    })


Let me find the correct models file:


Bash

find "C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocode-fork\backend" -name "models.py" -type f 2>/dev/null | head -5

Find models.py file in backend directory


     1→"""
     2→Spec Router
     3→===========
     4→
     5→API endpoints for project specification management.
     6→"""
     7→
     8→from datetime import datetime
     9→from typing import Optional, List
    10→
    11→from fastapi import APIRouter, Depends, HTTPException, status
    12→from pydantic import BaseModel, Field
    13→from sqlalchemy import select, and_, func
    14→from sqlalchemy.ext.asyncio import AsyncSession
    15→
    16→from ..database import get_db
    17→from ..models import Project, SpecHistory, Feature
    18→
    19→router = APIRouter(prefix="/api/projects", tags=["spec"])
    20→
    21→
    22→# ============================================================================
    23→# Pydantic Schemas
    24→# ============================================================================
    25→
    26→class SpecResponse(BaseModel):
    27→    """Schema for current spec response."""
    28→    project_id: int
    29→    version: int
    30→    content: str
    31→    created_at: datetime
    32→    change_summary: Optional[str]
    33→
    34→    class Config:
    35→        from_attributes = True
    36→
    37→
    38→class SpecHistoryResponse(BaseModel):
    39→    """Schema for spec history entry."""
    40→    id: int
    41→    project_id: int
    42→    version: int
    43→    content: str
    44→    created_at: datetime
    45→    change_summary: Optional[str]
    46→
    47→    class Config:
    48→        from_attributes = True
    49→
    50→
    51→class SpecHistoryListResponse(BaseModel):
    52→    """Schema for spec history list."""
    53→    history: List[SpecHistoryResponse]
    54→    count: int
    55→
    56→
    57→class SpecUpdate(BaseModel):
    58→    """Schema for updating a spec."""
    59→    content: str = Field(..., min_length=1)
    60→    change_summary: Optional[str] = None
    61→
    62→
    63→class AffectedFeatureResponse(BaseModel):
    64→    """Schema for an affected feature in change analysis."""
    65→    id: int
    66→    name: str
    67→    category: str
    68→    reason: str
    69→
    70→    class Config:
    71→        from_attributes = True
    72→
    73→
    74→class SpecAnalysisResponse(BaseModel):
    75→    """Schema for spec change analysis response."""
    76→    affected_features: List[AffectedFeatureResponse]
    77→    affected_count: int
    78→    change_summary: str
    79→
    80→
    81→# ============================================================================
    82→# API Endpoints
    83→# ============================================================================
    84→
    85→@router.get("/{name}/spec", response_model=SpecResponse)
    86→async def get_current_spec(name: str, db: AsyncSession = Depends(get_db)):
    87→    """Get the current (latest) spec for a project."""
    88→    # Verify project exists
    89→    result = await db.execute(select(Project).where(Project.name == name))
    90→    project = result.scalar_one_or_none()
    91→
    92→    if not project:
    93→        raise HTTPException(
    94→            status_code=status.HTTP_404_NOT_FOUND,
    95→            detail=f"Project '{name}' not found"
    96→        )
    97→
    98→    # Get latest spec version
    99→    result = await db.execute(
   100→        select(SpecHistory)
   101→        .where(SpecHistory.project_id == project.id)
   102→        .order_by(SpecHistory.version.desc())
   103→        .limit(1)
   104→    )
   105→    spec = result.scalar_one_or_none()
   106→
   107→    if not spec:
   108→        # Return empty spec if none exists
   109→        return SpecResponse(
   110→            project_id=project.id,
   111→            version=0,
   112→            content="",
   113→            created_at=project.created_at,
   114→            change_summary="No spec yet"
   115→        )
   116→
   117→    return SpecResponse(
   118→        project_id=spec.project_id,
   119→        version=spec.version,
   120→        content=spec.content,
   121→        created_at=spec.created_at,
   122→        change_summary=spec.change_summary
   123→    )
   124→
   125→
   126→@router.put("/{name}/spec", response_model=SpecResponse)
   127→async def update_spec(name: str, spec_update: SpecUpdate, db: AsyncSession = Depends(get_db)):
   128→    """Update the spec (creates a new version)."""
   129→    # Verify project exists
   130→    result = await db.execute(select(Project).where(Project.name == name))
   131→    project = result.scalar_one_or_none()
   132→
   133→    if not project:
   134→        raise HTTPException(
   135→            status_code=status.HTTP_404_NOT_FOUND,
   136→            detail=f"Project '{name}' not found"
   137→        )
   138→
   139→    # Get current version
   140→    result = await db.execute(
   141→        select(func.max(SpecHistory.version))
   142→        .where(SpecHistory.project_id == project.id)
   143→    )
   144→    current_version = result.scalar() or 0
   145→
   146→    # Create new version
   147→    new_version = current_version + 1
   148→    new_spec = SpecHistory(
   149→        project_id=project.id,
   150→        version=new_version,
   151→        content=spec_update.content,
   152→        change_summary=spec_update.change_summary,
   153→    )
   154→    db.add(new_spec)
   155→
   156→    # Update project's spec_version
   157→    project.spec_version = new_version
   158→    project.updated_at = datetime.utcnow()
   159→
   160→    await db.commit()
   161→    await db.refresh(new_spec)
   162→
   163→    return SpecResponse(
   164→        project_id=new_spec.project_id,
   165→        version=new_spec.version,
   166→        content=new_spec.content,
   167→        created_at=new_spec.created_at,
   168→        change_summary=new_spec.change_summary
   169→    )
   170→
   171→
   172→@router.get("/{name}/spec/history", response_model=SpecHistoryListResponse)
   173→async def get_spec_history(name: str, db: AsyncSession = Depends(get_db)):
   174→    """Get the spec version history for a project."""
   175→    # Verify project exists
   176→    result = await db.execute(select(Project).where(Project.name == name))
   177→    project = result.scalar_one_or_none()
   178→
   179→    if not project:
   180→        raise HTTPException(
   181→            status_code=status.HTTP_404_NOT_FOUND,
   182→            detail=f"Project '{name}' not found"
   183→        )
   184→
   185→    # Get all spec versions
   186→    result = await db.execute(
   187→        select(SpecHistory)
   188→        .where(SpecHistory.project_id == project.id)
   189→        .order_by(SpecHistory.version.desc())
   190→    )
   191→    history = result.scalars().all()
   192→
   193→    return SpecHistoryListResponse(
   194→        history=[SpecHistoryResponse.model_validate(h) for h in history],
   195→        count=len(history)
   196→    )
   197→
   198→
   199→@router.post("/{name}/spec/rollback/{version}", response_model=SpecResponse)
   200→async def rollback_spec(name: str, version: int, db: AsyncSession = Depends(get_db)):
   201→    """Rollback to a previous spec version."""
   202→    # Verify project exists
   203→    result = await db.execute(select(Project).where(Project.name == name))
   204→    project = result.scalar_one_or_none()
   205→
   206→    if not project:
   207→        raise HTTPException(
   208→            status_code=status.HTTP_404_NOT_FOUND,
   209→            detail=f"Project '{name}' not found"
   210→        )
   211→
   212→    # Get the target version
   213→    result = await db.execute(
   214→        select(SpecHistory).where(and_(
   215→            SpecHistory.project_id == project.id,
   216→            SpecHistory.version == version
   217→        ))
   218→    )
   219→    target_spec = result.scalar_one_or_none()
   220→
   221→    if not target_spec:
   222→        raise HTTPException(
   223→            status_code=status.HTTP_404_NOT_FOUND,
   224→            detail=f"Spec version {version} not found"
   225→        )
   226→
   227→    # Get current version number
   228→    result = await db.execute(
   229→        select(func.max(SpecHistory.version))
   230→        .where(SpecHistory.project_id == project.id)
   231→    )
   232→    current_version = result.scalar() or 0
   233→
   234→    # Create new version with the old content
   235→    new_version = current_version + 1
   236→    new_spec = SpecHistory(
   237→        project_id=project.id,
   238→        version=new_version,
   239→        content=target_spec.content,
   240→        change_summary=f"Rollback to version {version}",
   241→    )
   242→    db.add(new_spec)
   243→
   244→    # Update project's spec_version
   245→    project.spec_version = new_version
   246→    project.updated_at = datetime.utcnow()
   247→
   248→    await db.commit()
   249→    await db.refresh(new_spec)
   250→
   251→    return SpecResponse(
   252→        project_id=new_spec.project_id,
   253→        version=new_spec.version,
   254→        content=new_spec.content,
   255→        created_at=new_spec.created_at,
   256→        change_summary=new_spec.change_summary
   257→    )
   258→
   259→
   260→@router.post("/{name}/spec/analyze-changes", response_model=SpecAnalysisResponse)
   261→async def analyze_spec_changes(name: str, spec_update: SpecUpdate, db: AsyncSession = Depends(get_db)):
   262→    """Preview the impact of spec changes before applying them."""
   263→    # Verify project exists
   264→    result = await db.execute(select(Project).where(Project.name == name))
   265→    project = result.scalar_one_or_none()
   266→
   267→    if not project:
   268→        raise HTTPException(
   269→            status_code=status.HTTP_404_NOT_FOUND,
   270→            detail=f"Project '{name}' not found"
   271→        )
   272→
   273→    # Get current spec content for comparison
   274→    result = await db.execute(
   275→        select(SpecHistory)
   276→        .where(SpecHistory.project_id == project.id)
   277→        .order_by(SpecHistory.version.desc())
   278→        .limit(1)
   279→    )
   280→    current_spec = result.scalar_one_or_none()
   281→    current_content = current_spec.content if current_spec else ""
   282→
   283→    # Get all features for this project
   284→    result = await db.execute(
   285→        select(Feature)
   286→        .where(Feature.project_id == project.id)
   287→    )
   288→    features = result.scalars().all()
   289→
   290→    # Analyze which features might be affected by the spec change
   291→    # Simple heuristic: features whose names or descriptions contain words that appear
   292→    # in the changed parts of the spec
   293→    affected = []
   294→    new_content = spec_update.content.lower()
   295→    old_content = current_content.lower()
   296→
   297→    # Find words that are new in the spec
   298→    new_words = set(new_content.split()) - set(old_content.split())
   299→    changed_words = set(new_content.split()) ^ set(old_content.split())  # symmetric difference
   300→
   301→    for feature in features:
   302→        feature_text = f"{feature.name} {feature.description or ''} {feature.category}".lower()
   303→        feature_words = set(feature_text.split())
   304→
   305→        # Check if any changed words appear in the feature
   306→        matching_words = feature_words & changed_words
   307→        if matching_words:
   308→            affected.append(AffectedFeatureResponse(
   309→                id=feature.id,
   310→                name=feature.name,
   311→                category=feature.category,
   312→                reason=f"Contains changed terms: {', '.join(list(matching_words)[:3])}"
   313→            ))
   314→
   315→    # Generate change summary
   316→    if current_content:
   317→        lines_old = len(current_content.split('\n'))
   318→        lines_new = len(spec_update.content.split('\n'))
   319→        line_diff = lines_new - lines_old
   320→        change_summary = spec_update.change_summary or f"Spec change: {line_diff:+d} lines, {len(changed_words)} terms changed"
   321→    else:
   322→        change_summary = spec_update.change_summary or "Initial spec creation"
   323→
   324→    return SpecAnalysisResponse(
   325→        affected_features=affected,
   326→        affected_count=len(affected),
   327→        change_summary=change_summary
   328→    )
   329→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Spec Router
===========

API endpoints for project specification management.
"""

from datetime import datetime
from typing import Optional, List

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession

from ..database import get_db
from ..models import Project, SpecHistory, Feature

router = APIRouter(prefix="/api/projects", tags=["spec"])

# ============================================================================
# Pydantic Schemas
# ============================================================================

class SpecResponse(BaseModel):
    """Schema for current spec response."""
    project_id: int
    version: int
    content: str
    created_at: datetime
    change_summary: Optional[str]

    class Config:
        from_attributes = True

class SpecHistoryResponse(BaseModel):
    """Schema for spec history entry."""
    id: int
    project_id: int
    version: int
    content: str
    created_at: datetime
    change_summary: Optional[str]

    class Config:
        from_attributes = True

class SpecHistoryListResponse(BaseModel):
    """Schema for spec history list."""
    history: List[SpecHistoryResponse]
    count: int

class SpecUpdate(BaseModel):
    """Schema for updating a spec."""
    content: str = Field(..., min_length=1)
    change_summary: Optional[str] = None

class AffectedFeatureResponse(BaseModel):
    """Schema for an affected feature in change analysis."""
    id: int
    name: str
    category: str
    reason: str

    class Config:
        from_attributes = True

class SpecAnalysisResponse(BaseModel):
    """Schema for spec change analysis response."""
    affected_features: List[AffectedFeatureResponse]
    affected_count: int
    change_summary: str

# ============================================================================
# API Endpoints
# ============================================================================

@router.get("/{name}/spec", response_model=SpecResponse)
async def get_current_spec(name: str, db: AsyncSession = Depends(get_db)):
    """Get the current (latest) spec for a project."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get latest spec version
    result = await db.execute(
        select(SpecHistory)
        .where(SpecHistory.project_id == project.id)
        .order_by(SpecHistory.version.desc())
        .limit(1)
    )
    spec = result.scalar_one_or_none()

    if not spec:
        # Return empty spec if none exists
        return SpecResponse(
            project_id=project.id,
            version=0,
            content="",
            created_at=project.created_at,
            change_summary="No spec yet"
        )

    return SpecResponse(
        project_id=spec.project_id,
        version=spec.version,
        content=spec.content,
        created_at=spec.created_at,
        change_summary=spec.change_summary
    )

@router.put("/{name}/spec", response_model=SpecResponse)
async def update_spec(name: str, spec_update: SpecUpdate, db: AsyncSession = Depends(get_db)):
    """Update the spec (creates a new version)."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get current version
    result = await db.execute(
        select(func.max(SpecHistory.version))
        .where(SpecHistory.project_id == project.id)
    )
    current_version = result.scalar() or 0

    # Create new version
    new_version = current_version + 1
    new_spec = SpecHistory(
        project_id=project.id,
        version=new_version,
        content=spec_update.content,
        change_summary=spec_update.change_summary,
    )
    db.add(new_spec)

    # Update project's spec_version
    project.spec_version = new_version
    project.updated_at = datetime.utcnow()

    await db.commit()
    await db.refresh(new_spec)

    return SpecResponse(
        project_id=new_spec.project_id,
        version=new_spec.version,
        content=new_spec.content,
        created_at=new_spec.created_at,
        change_summary=new_spec.change_summary
    )

@router.get("/{name}/spec/history", response_model=SpecHistoryListResponse)
async def get_spec_history(name: str, db: AsyncSession = Depends(get_db)):
    """Get the spec version history for a project."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get all spec versions
    result = await db.execute(
        select(SpecHistory)
        .where(SpecHistory.project_id == project.id)
        .order_by(SpecHistory.version.desc())
    )
    history = result.scalars().all()

    return SpecHistoryListResponse(
        history=[SpecHistoryResponse.model_validate(h) for h in history],
        count=len(history)
    )

@router.post("/{name}/spec/rollback/{version}", response_model=SpecResponse)
async def rollback_spec(name: str, version: int, db: AsyncSession = Depends(get_db)):
    """Rollback to a previous spec version."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get the target version
    result = await db.execute(
        select(SpecHistory).where(and_(
            SpecHistory.project_id == project.id,
            SpecHistory.version == version
        ))
    )
    target_spec = result.scalar_one_or_none()

    if not target_spec:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Spec version {version} not found"
        )

    # Get current version number
    result = await db.execute(
        select(func.max(SpecHistory.version))
        .where(SpecHistory.project_id == project.id)
    )
    current_version = result.scalar() or 0

    # Create new version with the old content
    new_version = current_version + 1
    new_spec = SpecHistory(
        project_id=project.id,
        version=new_version,
        content=target_spec.content,
        change_summary=f"Rollback to version {version}",
    )
    db.add(new_spec)

    # Update project's spec_version
    project.spec_version = new_version
    project.updated_at = datetime.utcnow()

    await db.commit()
    await db.refresh(new_spec)

    return SpecResponse(
        project_id=new_spec.project_id,
        version=new_spec.version,
        content=new_spec.content,
        created_at=new_spec.created_at,
        change_summary=new_spec.change_summary
    )

@router.post("/{name}/spec/analyze-changes", response_model=SpecAnalysisResponse)
async def analyze_spec_changes(name: str, spec_update: SpecUpdate, db: AsyncSession = Depends(get_db)):
    """Preview the impact of spec changes before applying them."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get current spec content for comparison
    result = await db.execute(
        select(SpecHistory)
        .where(SpecHistory.project_id == project.id)
        .order_by(SpecHistory.version.desc())
        .limit(1)
    )
    current_spec = result.scalar_one_or_none()
    current_content = current_spec.content if current_spec else ""

    # Get all features for this project
    result = await db.execute(
        select(Feature)
        .where(Feature.project_id == project.id)
    )
    features = result.scalars().all()

    # Analyze which features might be affected by the spec change
    # Simple heuristic: features whose names or descriptions contain words that appear
    # in the changed parts of the spec
    affected = []
    new_content = spec_update.content.lower()
    old_content = current_content.lower()

    # Find words that are new in the spec
    new_words = set(new_content.split()) - set(old_content.split())
    changed_words = set(new_content.split()) ^ set(old_content.split())  # symmetric difference

    for feature in features:
        feature_text = f"{feature.name} {feature.description or ''} {feature.category}".lower()
        feature_words = set(feature_text.split())

        # Check if any changed words appear in the feature
        matching_words = feature_words & changed_words
        if matching_words:
            affected.append(AffectedFeatureResponse(
                id=feature.id,
                name=feature.name,
                category=feature.category,
                reason=f"Contains changed terms: {', '.join(list(matching_words)[:3])}"
            ))

    # Generate change summary
    if current_content:
        lines_old = len(current_content.split('\n'))
        lines_new = len(spec_update.content.split('\n'))
        line_diff = lines_new - lines_old
        change_summary = spec_update.change_summary or f"Spec change: {line_diff:+d} lines, {len(changed_words)} terms changed"
    else:
        change_summary = spec_update.change_summary or "Initial spec creation"

    return SpecAnalysisResponse(
        affected_features=affected,
        affected_count=len(affected),
        change_summary=change_summary
    )


No files found


Let me search for the database models:


find "C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocode-fork\backend\app" -name "*.py" -type f | grep -v venv | head -20

Find Python files in backend app


Perfect! Now let me read the model files:


     1→"""
     2→Database Models
     3→===============
     4→
     5→SQLAlchemy ORM models for Autocoder+.
     6→"""
     7→
     8→from .project import Project
     9→from .feature import Feature
    10→from .permission import Permission, PermissionRequest
    11→from .infrastructure import Infrastructure
    12→from .spec_history import SpecHistory
    13→
    14→__all__ = [
    15→    "Project",
    16→    "Feature",
    17→    "Permission",
    18→    "PermissionRequest",
    19→    "Infrastructure",
    20→    "SpecHistory",
    21→]
    22→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Database Models
===============

SQLAlchemy ORM models for Autocoder+.
"""

from .project import Project
from .feature import Feature
from .permission import Permission, PermissionRequest
from .infrastructure import Infrastructure
from .spec_history import SpecHistory

__all__ = [
    "Project",
    "Feature",
    "Permission",
    "PermissionRequest",
    "Infrastructure",
    "SpecHistory",
]


     1→"""
     2→Project Model
     3→=============
     4→
     5→Database model for projects.
     6→"""
     7→
     8→from datetime import datetime
     9→from typing import TYPE_CHECKING
    10→
    11→from sqlalchemy import String, Integer, DateTime
    12→from sqlalchemy.orm import Mapped, mapped_column, relationship
    13→
    14→from ..database import Base
    15→
    16→if TYPE_CHECKING:
    17→    from .feature import Feature
    18→    from .permission import Permission, PermissionRequest
    19→    from .infrastructure import Infrastructure
    20→    from .spec_history import SpecHistory
    21→
    22→
    23→class Project(Base):
    24→    """Project model representing an Autocoder+ project."""
    25→
    26→    __tablename__ = "projects"
    27→
    28→    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    29→    name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
    30→    path: Mapped[str] = mapped_column(String(1024), nullable=False)
    31→    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
    32→    updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    33→    spec_version: Mapped[int] = mapped_column(Integer, default=1)
    34→
    35→    # Relationships
    36→    features: Mapped[list["Feature"]] = relationship("Feature", back_populates="project", cascade="all, delete-orphan")
    37→    permissions: Mapped[list["Permission"]] = relationship("Permission", back_populates="project", cascade="all, delete-orphan")
    38→    permission_requests: Mapped[list["PermissionRequest"]] = relationship("PermissionRequest", back_populates="project", cascade="all, delete-orphan")
    39→    infrastructure: Mapped[list["Infrastructure"]] = relationship("Infrastructure", back_populates="project", cascade="all, delete-orphan")
    40→    spec_history: Mapped[list["SpecHistory"]] = relationship("SpecHistory", back_populates="project", cascade="all, delete-orphan")
    41→
    42→    def __repr__(self) -> str:
    43→        return f"<Project(id={self.id}, name='{self.name}')>"
    44→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Project Model
=============

Database model for projects.
"""

from datetime import datetime
from typing import TYPE_CHECKING

from sqlalchemy import String, Integer, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship

from ..database import Base

if TYPE_CHECKING:
    from .feature import Feature
    from .permission import Permission, PermissionRequest
    from .infrastructure import Infrastructure
    from .spec_history import SpecHistory

class Project(Base):
    """Project model representing an Autocoder+ project."""

    __tablename__ = "projects"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
    path: Mapped[str] = mapped_column(String(1024), nullable=False)
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
    updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    spec_version: Mapped[int] = mapped_column(Integer, default=1)

    # Relationships
    features: Mapped[list["Feature"]] = relationship("Feature", back_populates="project", cascade="all, delete-orphan")
    permissions: Mapped[list["Permission"]] = relationship("Permission", back_populates="project", cascade="all, delete-orphan")
    permission_requests: Mapped[list["PermissionRequest"]] = relationship("PermissionRequest", back_populates="project", cascade="all, delete-orphan")
    infrastructure: Mapped[list["Infrastructure"]] = relationship("Infrastructure", back_populates="project", cascade="all, delete-orphan")
    spec_history: Mapped[list["SpecHistory"]] = relationship("SpecHistory", back_populates="project", cascade="all, delete-orphan")

    def __repr__(self) -> str:
        return f"<Project(id={self.id}, name='{self.name}')>"


     1→"""
     2→Database Configuration
     3→======================
     4→
     5→SQLAlchemy async database setup for Autocoder+.
     6→Uses SQLite with aiosqlite for async operations.
     7→"""
     8→
     9→from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
    10→from sqlalchemy.orm import DeclarativeBase
    11→
    12→from .config import settings
    13→
    14→
    15→# Create async engine
    16→engine = create_async_engine(
    17→    settings.database_url,
    18→    echo=settings.debug,
    19→    future=True,
    20→)
    21→
    22→# Create async session factory
    23→async_session_maker = async_sessionmaker(
    24→    engine,
    25→    class_=AsyncSession,
    26→    expire_on_commit=False,
    27→)
    28→
    29→
    30→class Base(DeclarativeBase):
    31→    """Base class for all SQLAlchemy models."""
    32→    pass
    33→
    34→
    35→async def init_db():
    36→    """Initialize the database by creating all tables."""
    37→    async with engine.begin() as conn:
    38→        await conn.run_sync(Base.metadata.create_all)
    39→
    40→
    41→async def get_db() -> AsyncSession:
    42→    """Dependency for getting database sessions."""
    43→    async with async_session_maker() as session:
    44→        try:
    45→            yield session
    46→        finally:
    47→            await session.close()
    48→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Database Configuration
======================

SQLAlchemy async database setup for Autocoder+.
Uses SQLite with aiosqlite for async operations.
"""

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase

from .config import settings

# Create async engine
engine = create_async_engine(
    settings.database_url,
    echo=settings.debug,
    future=True,
)

# Create async session factory
async_session_maker = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,
)

class Base(DeclarativeBase):
    """Base class for all SQLAlchemy models."""
    pass

async def init_db():
    """Initialize the database by creating all tables."""
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

async def get_db() -> AsyncSession:
    """Dependency for getting database sessions."""
    async with async_session_maker() as session:
        try:
            yield session
        finally:
            await session.close()


Let me read the remaining key files:


     1→"""
     2→Spec History Model
     3→==================
     4→
     5→Database model for tracking project specification versions.
     6→"""
     7→
     8→from datetime import datetime
     9→from typing import TYPE_CHECKING, Optional
    10→
    11→from sqlalchemy import String, Integer, Text, DateTime, ForeignKey
    12→from sqlalchemy.orm import Mapped, mapped_column, relationship
    13→
    14→from ..database import Base
    15→
    16→if TYPE_CHECKING:
    17→    from .project import Project
    18→
    19→
    20→class SpecHistory(Base):
    21→    """Spec history model for tracking project specification versions."""
    22→
    23→    __tablename__ = "spec_history"
    24→
    25→    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    26→    project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
    27→    version: Mapped[int] = mapped_column(Integer, nullable=False)
    28→    content: Mapped[str] = mapped_column(Text, nullable=False)
    29→    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
    30→    change_summary: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
    31→
    32→    # Relationships
    33→    project: Mapped["Project"] = relationship("Project", back_populates="spec_history")
    34→
    35→    def __repr__(self) -> str:
    36→        return f"<SpecHistory(id={self.id}, project_id={self.project_id}, version={self.version})>"
    37→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Spec History Model
==================

Database model for tracking project specification versions.
"""

from datetime import datetime
from typing import TYPE_CHECKING, Optional

from sqlalchemy import String, Integer, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship

from ..database import Base

if TYPE_CHECKING:
    from .project import Project

class SpecHistory(Base):
    """Spec history model for tracking project specification versions."""

    __tablename__ = "spec_history"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
    version: Mapped[int] = mapped_column(Integer, nullable=False)
    content: Mapped[str] = mapped_column(Text, nullable=False)
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
    change_summary: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)

    # Relationships
    project: Mapped["Project"] = relationship("Project", back_populates="spec_history")

    def __repr__(self) -> str:
        return f"<SpecHistory(id={self.id}, project_id={self.project_id}, version={self.version})>"


     1→"""
     2→Agents Router
     3→=============
     4→
     5→API endpoints for agent management and control.
     6→"""
     7→
     8→from datetime import datetime
     9→from typing import Optional, Dict, Any, List
    10→
    11→from fastapi import APIRouter, Depends, HTTPException, status
    12→from pydantic import BaseModel, Field
    13→from sqlalchemy import select, and_
    14→from sqlalchemy.ext.asyncio import AsyncSession
    15→
    16→from ..database import get_db
    17→from ..models import Project, Feature
    18→from .websocket import broadcast_agent_log
    19→
    20→router = APIRouter(prefix="/api/projects", tags=["agents"])
    21→
    22→
    23→# ============================================================================
    24→# Agent State Storage (in-memory for now)
    25→# ============================================================================
    26→
    27→# Store agent states in memory (will be replaced with persistent storage later)
    28→_agent_states: Dict[str, Dict[str, Dict[str, Any]]] = {}
    29→
    30→
    31→def get_agent_state(project_name: str, agent_id: str) -> Dict[str, Any]:
    32→    """Get agent state for a project."""
    33→    if project_name not in _agent_states:
    34→        _agent_states[project_name] = {
    35→            "A": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
    36→            "B": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
    37→        }
    38→    return _agent_states[project_name].get(agent_id, {"status": "stopped"})
    39→
    40→
    41→def set_agent_state(project_name: str, agent_id: str, state: Dict[str, Any]):
    42→    """Set agent state for a project."""
    43→    if project_name not in _agent_states:
    44→        _agent_states[project_name] = {
    45→            "A": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
    46→            "B": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
    47→        }
    48→    _agent_states[project_name][agent_id] = state
    49→
    50→
    51→# ============================================================================
    52→# Pydantic Schemas
    53→# ============================================================================
    54→
    55→class AgentStatus(BaseModel):
    56→    """Schema for agent status."""
    57→    status: str  # "stopped", "running", "paused", "stalled", "error"
    58→    started_at: Optional[datetime] = None
    59→    paused_at: Optional[datetime] = None
    60→    error: Optional[str] = None
    61→    crashed_at: Optional[datetime] = None
    62→
    63→
    64→class AgentStatusResponse(BaseModel):
    65→    """Schema for agent status response (both agents)."""
    66→    agent_a: AgentStatus
    67→    agent_b: AgentStatus
    68→
    69→
    70→class AgentActionResponse(BaseModel):
    71→    """Schema for agent action response."""
    72→    agent_id: str
    73→    status: str
    74→    message: str
    75→
    76→
    77→class TestLogRequest(BaseModel):
    78→    """Schema for test log request."""
    79→    log_type: str = "info"  # "info", "error", "tool", "permission"
    80→    content: str
    81→
    82→
    83→class ReportCrashRequest(BaseModel):
    84→    """Schema for reporting agent crash."""
    85→    error_message: str = Field(..., description="Error message describing the crash")
    86→    cleanup_features: bool = Field(default=True, description="Whether to return in-progress features to pending")
    87→
    88→
    89→class CrashResponse(BaseModel):
    90→    """Schema for crash response."""
    91→    agent_id: str
    92→    status: str
    93→    error: str
    94→    features_cleaned: int
    95→    message: str
    96→
    97→
    98→# ============================================================================
    99→# API Endpoints
   100→# ============================================================================
   101→
   102→@router.get("/{name}/agents/status", response_model=AgentStatusResponse)
   103→async def get_agents_status(name: str, db: AsyncSession = Depends(get_db)):
   104→    """Get status of both agents for a project."""
   105→    # Verify project exists
   106→    result = await db.execute(select(Project).where(Project.name == name))
   107→    project = result.scalar_one_or_none()
   108→
   109→    if not project:
   110→        raise HTTPException(
   111→            status_code=status.HTTP_404_NOT_FOUND,
   112→            detail=f"Project '{name}' not found"
   113→        )
   114→
   115→    agent_a = get_agent_state(name, "A")
   116→    agent_b = get_agent_state(name, "B")
   117→
   118→    return AgentStatusResponse(
   119→        agent_a=AgentStatus(**agent_a),
   120→        agent_b=AgentStatus(**agent_b),
   121→    )
   122→
   123→
   124→@router.post("/{name}/agents/{agent_id}/start", response_model=AgentActionResponse)
   125→async def start_agent(name: str, agent_id: str, db: AsyncSession = Depends(get_db)):
   126→    """Start an agent for a project."""
   127→    # Validate agent_id
   128→    if agent_id not in ("A", "B"):
   129→        raise HTTPException(
   130→            status_code=status.HTTP_400_BAD_REQUEST,
   131→            detail="Agent ID must be 'A' or 'B'"
   132→        )
   133→
   134→    # Verify project exists
   135→    result = await db.execute(select(Project).where(Project.name == name))
   136→    project = result.scalar_one_or_none()
   137→
   138→    if not project:
   139→        raise HTTPException(
   140→            status_code=status.HTTP_404_NOT_FOUND,
   141→            detail=f"Project '{name}' not found"
   142→        )
   143→
   144→    current_state = get_agent_state(name, agent_id)
   145→
   146→    if current_state["status"] == "running":
   147→        raise HTTPException(
   148→            status_code=status.HTTP_400_BAD_REQUEST,
   149→            detail=f"Agent {agent_id} is already running"
   150→        )

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Agents Router
=============

API endpoints for agent management and control.
"""

from datetime import datetime
from typing import Optional, Dict, Any, List

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession

from ..database import get_db
from ..models import Project, Feature
from .websocket import broadcast_agent_log

router = APIRouter(prefix="/api/projects", tags=["agents"])

# ============================================================================
# Agent State Storage (in-memory for now)
# ============================================================================

# Store agent states in memory (will be replaced with persistent storage later)
_agent_states: Dict[str, Dict[str, Dict[str, Any]]] = {}

def get_agent_state(project_name: str, agent_id: str) -> Dict[str, Any]:
    """Get agent state for a project."""
    if project_name not in _agent_states:
        _agent_states[project_name] = {
            "A": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
            "B": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
        }
    return _agent_states[project_name].get(agent_id, {"status": "stopped"})

def set_agent_state(project_name: str, agent_id: str, state: Dict[str, Any]):
    """Set agent state for a project."""
    if project_name not in _agent_states:
        _agent_states[project_name] = {
            "A": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
            "B": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
        }
    _agent_states[project_name][agent_id] = state

# ============================================================================
# Pydantic Schemas
# ============================================================================

class AgentStatus(BaseModel):
    """Schema for agent status."""
    status: str  # "stopped", "running", "paused", "stalled", "error"
    started_at: Optional[datetime] = None
    paused_at: Optional[datetime] = None
    error: Optional[str] = None
    crashed_at: Optional[datetime] = None

class AgentStatusResponse(BaseModel):
    """Schema for agent status response (both agents)."""
    agent_a: AgentStatus
    agent_b: AgentStatus

class AgentActionResponse(BaseModel):
    """Schema for agent action response."""
    agent_id: str
    status: str
    message: str

class TestLogRequest(BaseModel):
    """Schema for test log request."""
    log_type: str = "info"  # "info", "error", "tool", "permission"
    content: str

class ReportCrashRequest(BaseModel):
    """Schema for reporting agent crash."""
    error_message: str = Field(..., description="Error message describing the crash")
    cleanup_features: bool = Field(default=True, description="Whether to return in-progress features to pending")

class CrashResponse(BaseModel):
    """Schema for crash response."""
    agent_id: str
    status: str
    error: str
    features_cleaned: int
    message: str

# ============================================================================
# API Endpoints
# ============================================================================

@router.get("/{name}/agents/status", response_model=AgentStatusResponse)
async def get_agents_status(name: str, db: AsyncSession = Depends(get_db)):
    """Get status of both agents for a project."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    agent_a = get_agent_state(name, "A")
    agent_b = get_agent_state(name, "B")

    return AgentStatusResponse(
        agent_a=AgentStatus(**agent_a),
        agent_b=AgentStatus(**agent_b),
    )

@router.post("/{name}/agents/{agent_id}/start", response_model=AgentActionResponse)
async def start_agent(name: str, agent_id: str, db: AsyncSession = Depends(get_db)):
    """Start an agent for a project."""
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    current_state = get_agent_state(name, agent_id)

    if current_state["status"] == "running":
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Agent {agent_id} is already running"
        )