     1→"""
     2→Features Router
     3→===============
     4→
     5→API endpoints for feature/test case management.
     6→"""
     7→
     8→import logging
     9→import re
    10→from contextlib import contextmanager
    11→from pathlib import Path
    12→
    13→from fastapi import APIRouter, HTTPException
    14→
    15→from ..schemas import (
    16→    FeatureCreate,
    17→    FeatureListResponse,
    18→    FeatureResponse,
    19→)
    20→
    21→# Lazy imports to avoid circular dependencies
    22→_create_database = None
    23→_Feature = None
    24→
    25→logger = logging.getLogger(__name__)
    26→
    27→
    28→def _get_project_path(project_name: str) -> Path:
    29→    """Get project path from registry."""
    30→    import sys
    31→    root = Path(__file__).parent.parent.parent
    32→    if str(root) not in sys.path:
    33→        sys.path.insert(0, str(root))
    34→
    35→    from registry import get_project_path
    36→    return get_project_path(project_name)
    37→
    38→
    39→def _get_db_classes():
    40→    """Lazy import of database classes."""
    41→    global _create_database, _Feature
    42→    if _create_database is None:
    43→        import sys
    44→        from pathlib import Path
    45→        root = Path(__file__).parent.parent.parent
    46→        if str(root) not in sys.path:
    47→            sys.path.insert(0, str(root))
    48→        from api.database import Feature, create_database
    49→        _create_database = create_database
    50→        _Feature = Feature
    51→    return _create_database, _Feature
    52→
    53→
    54→router = APIRouter(prefix="/api/projects/{project_name}/features", tags=["features"])
    55→
    56→
    57→def validate_project_name(name: str) -> str:
    58→    """Validate and sanitize project name to prevent path traversal."""
    59→    if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
    60→        raise HTTPException(
    61→            status_code=400,
    62→            detail="Invalid project name"
    63→        )
    64→    return name
    65→
    66→
    67→@contextmanager
    68→def get_db_session(project_dir: Path):
    69→    """
    70→    Context manager for database sessions.
    71→    Ensures session is always closed, even on exceptions.
    72→    """
    73→    create_database, _ = _get_db_classes()
    74→    _, SessionLocal = create_database(project_dir)
    75→    session = SessionLocal()
    76→    try:
    77→        yield session
    78→    finally:
    79→        session.close()
    80→
    81→
    82→def feature_to_response(f) -> FeatureResponse:
    83→    """Convert a Feature model to a FeatureResponse."""
    84→    return FeatureResponse(
    85→        id=f.id,
    86→        priority=f.priority,
    87→        category=f.category,
    88→        name=f.name,
    89→        description=f.description,
    90→        steps=f.steps if isinstance(f.steps, list) else [],
    91→        passes=f.passes,
    92→        in_progress=f.in_progress,
    93→    )
    94→
    95→
    96→@router.get("", response_model=FeatureListResponse)
    97→async def list_features(project_name: str):
    98→    """
    99→    List all features for a project organized by status.
   100→
   101→    Returns features in three lists:
   102→    - pending: passes=False, not currently being worked on
   103→    - in_progress: features currently being worked on (tracked via agent output)
   104→    - done: passes=True
   105→    """
   106→    project_name = validate_project_name(project_name)
   107→    project_dir = _get_project_path(project_name)
   108→
   109→    if not project_dir:
   110→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   111→
   112→    if not project_dir.exists():
   113→        raise HTTPException(status_code=404, detail="Project directory not found")
   114→
   115→    db_file = project_dir / "features.db"
   116→    if not db_file.exists():
   117→        return FeatureListResponse(pending=[], in_progress=[], done=[])
   118→
   119→    _, Feature = _get_db_classes()
   120→
   121→    try:
   122→        with get_db_session(project_dir) as session:
   123→            all_features = session.query(Feature).order_by(Feature.priority).all()
   124→
   125→            pending = []
   126→            in_progress = []
   127→            done = []
   128→
   129→            for f in all_features:
   130→                feature_response = feature_to_response(f)
   131→                if f.passes:
   132→                    done.append(feature_response)
   133→                elif f.in_progress:
   134→                    in_progress.append(feature_response)
   135→                else:
   136→                    pending.append(feature_response)
   137→
   138→            return FeatureListResponse(
   139→                pending=pending,
   140→                in_progress=in_progress,
   141→                done=done,
   142→            )
   143→    except HTTPException:
   144→        raise
   145→    except Exception:
   146→        logger.exception("Database error in list_features")
   147→        raise HTTPException(status_code=500, detail="Database error occurred")
   148→
   149→
   150→@router.post("", response_model=FeatureResponse)
   151→async def create_feature(project_name: str, feature: FeatureCreate):
   152→    """Create a new feature/test case manually."""
   153→    project_name = validate_project_name(project_name)
   154→    project_dir = _get_project_path(project_name)
   155→
   156→    if not project_dir:
   157→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   158→
   159→    if not project_dir.exists():
   160→        raise HTTPException(status_code=404, detail="Project directory not found")
   161→
   162→    _, Feature = _get_db_classes()
   163→
   164→    try:
   165→        with get_db_session(project_dir) as session:
   166→            # Get next priority if not specified
   167→            if feature.priority is None:
   168→                max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
   169→                priority = (max_priority.priority + 1) if max_priority else 1
   170→            else:
   171→                priority = feature.priority
   172→
   173→            # Create new feature
   174→            db_feature = Feature(
   175→                priority=priority,
   176→                category=feature.category,
   177→                name=feature.name,
   178→                description=feature.description,
   179→                steps=feature.steps,
   180→                passes=False,
   181→            )
   182→
   183→            session.add(db_feature)
   184→            session.commit()
   185→            session.refresh(db_feature)
   186→
   187→            return feature_to_response(db_feature)
   188→    except HTTPException:
   189→        raise
   190→    except Exception:
   191→        logger.exception("Failed to create feature")
   192→        raise HTTPException(status_code=500, detail="Failed to create feature")
   193→
   194→
   195→@router.get("/{feature_id}", response_model=FeatureResponse)
   196→async def get_feature(project_name: str, feature_id: int):
   197→    """Get details of a specific feature."""
   198→    project_name = validate_project_name(project_name)
   199→    project_dir = _get_project_path(project_name)
   200→
   201→    if not project_dir:
   202→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   203→
   204→    if not project_dir.exists():
   205→        raise HTTPException(status_code=404, detail="Project directory not found")
   206→
   207→    db_file = project_dir / "features.db"
   208→    if not db_file.exists():
   209→        raise HTTPException(status_code=404, detail="No features database found")
   210→
   211→    _, Feature = _get_db_classes()
   212→
   213→    try:
   214→        with get_db_session(project_dir) as session:
   215→            feature = session.query(Feature).filter(Feature.id == feature_id).first()
   216→
   217→            if not feature:
   218→                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
   219→
   220→            return feature_to_response(feature)
   221→    except HTTPException:
   222→        raise
   223→    except Exception:
   224→        logger.exception("Database error in get_feature")
   225→        raise HTTPException(status_code=500, detail="Database error occurred")
   226→
   227→
   228→@router.delete("/{feature_id}")
   229→async def delete_feature(project_name: str, feature_id: int):
   230→    """Delete a feature."""
   231→    project_name = validate_project_name(project_name)
   232→    project_dir = _get_project_path(project_name)
   233→
   234→    if not project_dir:
   235→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   236→
   237→    if not project_dir.exists():
   238→        raise HTTPException(status_code=404, detail="Project directory not found")
   239→
   240→    _, Feature = _get_db_classes()
   241→
   242→    try:
   243→        with get_db_session(project_dir) as session:
   244→            feature = session.query(Feature).filter(Feature.id == feature_id).first()
   245→
   246→            if not feature:
   247→                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
   248→
   249→            session.delete(feature)
   250→            session.commit()
   251→
   252→            return {"success": True, "message": f"Feature {feature_id} deleted"}
   253→    except HTTPException:
   254→        raise
   255→    except Exception:
   256→        logger.exception("Failed to delete feature")
   257→        raise HTTPException(status_code=500, detail="Failed to delete feature")
   258→
   259→
   260→@router.post("/reset")
   261→async def reset_features(project_name: str, keep_passing: bool = False):
   262→    """
   263→    Reset all features for a project.
   264→
   265→    Args:
   266→        keep_passing: If True, keep features that have already passed
   267→
   268→    This is useful when you want to re-initialize the project from the app spec.
   269→    """
   270→    project_name = validate_project_name(project_name)
   271→    project_dir = _get_project_path(project_name)
   272→
   273→    if not project_dir:
   274→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   275→
   276→    if not project_dir.exists():
   277→        raise HTTPException(status_code=404, detail="Project directory not found")
   278→
   279→    db_file = project_dir / "features.db"
   280→    if not db_file.exists():
   281→        return {"success": True, "message": "No features to reset", "deleted": 0, "kept": 0}
   282→
   283→    _, Feature = _get_db_classes()
   284→
   285→    try:
   286→        with get_db_session(project_dir) as session:
   287→            if keep_passing:
   288→                # Delete only non-passing features
   289→                deleted = session.query(Feature).filter(Feature.passes == False).delete()
   290→                kept = session.query(Feature).count()
   291→            else:
   292→                # Delete all features
   293→                deleted = session.query(Feature).delete()
   294→                kept = 0
   295→
   296→            session.commit()
   297→
   298→            return {
   299→                "success": True,
   300→                "message": f"Reset features: {deleted} deleted, {kept} kept",
   301→                "deleted": deleted,
   302→                "kept": kept,
   303→            }
   304→    except HTTPException:
   305→        raise
   306→    except Exception:
   307→        logger.exception("Failed to reset features")
   308→        raise HTTPException(status_code=500, detail="Failed to reset features")
   309→
   310→
   311→@router.get("/stats")
   312→async def get_feature_stats(project_name: str):
   313→    """
   314→    Get feature statistics for a project.
   315→
   316→    Returns counts of pending, in_progress, and completed features,
   317→    plus info about whether the project needs initialization.
   318→    """
   319→    project_name = validate_project_name(project_name)
   320→    project_dir = _get_project_path(project_name)
   321→
   322→    if not project_dir:
   323→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   324→
   325→    if not project_dir.exists():
   326→        raise HTTPException(status_code=404, detail="Project directory not found")
   327→
   328→    db_file = project_dir / "features.db"
   329→    app_spec = project_dir / "prompts" / "app_spec.txt"
   330→
   331→    # Check if project has spec but no features (needs init)
   332→    has_spec = app_spec.exists()
   333→    has_features = False
   334→    stats = {"pending": 0, "in_progress": 0, "completed": 0, "total": 0}
   335→
   336→    if db_file.exists():
   337→        _, Feature = _get_db_classes()
   338→        try:
   339→            with get_db_session(project_dir) as session:
   340→                all_features = session.query(Feature).all()
   341→                has_features = len(all_features) > 0
   342→
   343→                for f in all_features:
   344→                    stats["total"] += 1
   345→                    if f.passes:
   346→                        stats["completed"] += 1
   347→                    elif f.in_progress:
   348→                        stats["in_progress"] += 1
   349→                    else:
   350→                        stats["pending"] += 1
   351→        except Exception:
   352→            logger.exception("Error getting feature stats")
   353→
   354→    return {
   355→        "has_spec": has_spec,
   356→        "has_features": has_features,
   357→        "needs_init": has_spec and not has_features,
   358→        "stats": stats,
   359→    }
   360→
   361→
   362→@router.patch("/{feature_id}/skip")
   363→async def skip_feature(project_name: str, feature_id: int):
   364→    """
   365→    Mark a feature as skipped by moving it to the end of the priority queue.
   366→
   367→    This doesn't delete the feature but gives it a very high priority number
   368→    so it will be processed last.
   369→    """
   370→    project_name = validate_project_name(project_name)
   371→    project_dir = _get_project_path(project_name)
   372→
   373→    if not project_dir:
   374→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   375→
   376→    if not project_dir.exists():
   377→        raise HTTPException(status_code=404, detail="Project directory not found")
   378→
   379→    _, Feature = _get_db_classes()
   380→
   381→    try:
   382→        with get_db_session(project_dir) as session:
   383→            feature = session.query(Feature).filter(Feature.id == feature_id).first()
   384→
   385→            if not feature:
   386→                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
   387→
   388→            # Set priority to max + 1000 to push to end
   389→            max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
   390→            feature.priority = (max_priority.priority if max_priority else 0) + 1000
   391→
   392→            session.commit()
   393→
   394→            return {"success": True, "message": f"Feature {feature_id} moved to end of queue"}
   395→    except HTTPException:
   396→        raise
   397→    except Exception:
   398→        logger.exception("Failed to skip feature")
   399→        raise HTTPException(status_code=500, detail="Failed to skip feature")
   400→

<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>

"""
Features Router
===============

API endpoints for feature/test case management.
"""

import logging
import re
from contextlib import contextmanager
from pathlib import Path

from fastapi import APIRouter, HTTPException

from ..schemas import (
    FeatureCreate,
    FeatureListResponse,
    FeatureResponse,
)

# Lazy imports to avoid circular dependencies
_create_database = None
_Feature = None

logger = logging.getLogger(__name__)

def _get_project_path(project_name: str) -> Path:
    """Get project path from registry."""
    import sys
    root = Path(__file__).parent.parent.parent
    if str(root) not in sys.path:
        sys.path.insert(0, str(root))

    from registry import get_project_path
    return get_project_path(project_name)

def _get_db_classes():
    """Lazy import of database classes."""
    global _create_database, _Feature
    if _create_database is None:
        import sys
        from pathlib import Path
        root = Path(__file__).parent.parent.parent
        if str(root) not in sys.path:
            sys.path.insert(0, str(root))
        from api.database import Feature, create_database
        _create_database = create_database
        _Feature = Feature
    return _create_database, _Feature

router = APIRouter(prefix="/api/projects/{project_name}/features", tags=["features"])

def validate_project_name(name: str) -> str:
    """Validate and sanitize project name to prevent path traversal."""
    if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
        raise HTTPException(
            status_code=400,
            detail="Invalid project name"
        )
    return name

@contextmanager
def get_db_session(project_dir: Path):
    """
    Context manager for database sessions.
    Ensures session is always closed, even on exceptions.
    """
    create_database, _ = _get_db_classes()
    _, SessionLocal = create_database(project_dir)
    session = SessionLocal()
    try:
        yield session
    finally:
        session.close()

def feature_to_response(f) -> FeatureResponse:
    """Convert a Feature model to a FeatureResponse."""
    return FeatureResponse(
        id=f.id,
        priority=f.priority,
        category=f.category,
        name=f.name,
        description=f.description,
        steps=f.steps if isinstance(f.steps, list) else [],
        passes=f.passes,
        in_progress=f.in_progress,
    )

@router.get("", response_model=FeatureListResponse)
async def list_features(project_name: str):
    """
    List all features for a project organized by status.

    Returns features in three lists:
    - pending: passes=False, not currently being worked on
    - in_progress: features currently being worked on (tracked via agent output)
    - done: passes=True
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    if not db_file.exists():
        return FeatureListResponse(pending=[], in_progress=[], done=[])

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            all_features = session.query(Feature).order_by(Feature.priority).all()

            pending = []
            in_progress = []
            done = []

            for f in all_features:
                feature_response = feature_to_response(f)
                if f.passes:
                    done.append(feature_response)
                elif f.in_progress:
                    in_progress.append(feature_response)
                else:
                    pending.append(feature_response)

            return FeatureListResponse(
                pending=pending,
                in_progress=in_progress,
                done=done,
            )
    except HTTPException:
        raise
    except Exception:
        logger.exception("Database error in list_features")
        raise HTTPException(status_code=500, detail="Database error occurred")

@router.post("", response_model=FeatureResponse)
async def create_feature(project_name: str, feature: FeatureCreate):
    """Create a new feature/test case manually."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            # Get next priority if not specified
            if feature.priority is None:
                max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
                priority = (max_priority.priority + 1) if max_priority else 1
            else:
                priority = feature.priority

            # Create new feature
            db_feature = Feature(
                priority=priority,
                category=feature.category,
                name=feature.name,
                description=feature.description,
                steps=feature.steps,
                passes=False,
            )

            session.add(db_feature)
            session.commit()
            session.refresh(db_feature)

            return feature_to_response(db_feature)
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to create feature")
        raise HTTPException(status_code=500, detail="Failed to create feature")

@router.get("/{feature_id}", response_model=FeatureResponse)
async def get_feature(project_name: str, feature_id: int):
    """Get details of a specific feature."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    if not db_file.exists():
        raise HTTPException(status_code=404, detail="No features database found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            feature = session.query(Feature).filter(Feature.id == feature_id).first()

            if not feature:
                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

            return feature_to_response(feature)
    except HTTPException:
        raise
    except Exception:
        logger.exception("Database error in get_feature")
        raise HTTPException(status_code=500, detail="Database error occurred")

@router.delete("/{feature_id}")
async def delete_feature(project_name: str, feature_id: int):
    """Delete a feature."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            feature = session.query(Feature).filter(Feature.id == feature_id).first()

            if not feature:
                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

            session.delete(feature)
            session.commit()

            return {"success": True, "message": f"Feature {feature_id} deleted"}
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to delete feature")
        raise HTTPException(status_code=500, detail="Failed to delete feature")

@router.post("/reset")
async def reset_features(project_name: str, keep_passing: bool = False):
    """
    Reset all features for a project.

    Args:
        keep_passing: If True, keep features that have already passed

    This is useful when you want to re-initialize the project from the app spec.
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    if not db_file.exists():
        return {"success": True, "message": "No features to reset", "deleted": 0, "kept": 0}

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            if keep_passing:
                # Delete only non-passing features
                deleted = session.query(Feature).filter(Feature.passes == False).delete()
                kept = session.query(Feature).count()
            else:
                # Delete all features
                deleted = session.query(Feature).delete()
                kept = 0

            session.commit()

            return {
                "success": True,
                "message": f"Reset features: {deleted} deleted, {kept} kept",
                "deleted": deleted,
                "kept": kept,
            }
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to reset features")
        raise HTTPException(status_code=500, detail="Failed to reset features")

@router.get("/stats")
async def get_feature_stats(project_name: str):
    """
    Get feature statistics for a project.

    Returns counts of pending, in_progress, and completed features,
    plus info about whether the project needs initialization.
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    app_spec = project_dir / "prompts" / "app_spec.txt"

    # Check if project has spec but no features (needs init)
    has_spec = app_spec.exists()
    has_features = False
    stats = {"pending": 0, "in_progress": 0, "completed": 0, "total": 0}

    if db_file.exists():
        _, Feature = _get_db_classes()
        try:
            with get_db_session(project_dir) as session:
                all_features = session.query(Feature).all()
                has_features = len(all_features) > 0

                for f in all_features:
                    stats["total"] += 1
                    if f.passes:
                        stats["completed"] += 1
                    elif f.in_progress:
                        stats["in_progress"] += 1
                    else:
                        stats["pending"] += 1
        except Exception:
            logger.exception("Error getting feature stats")

    return {
        "has_spec": has_spec,
        "has_features": has_features,
        "needs_init": has_spec and not has_features,
        "stats": stats,
    }

@router.patch("/{feature_id}/skip")
async def skip_feature(project_name: str, feature_id: int):
    """
    Mark a feature as skipped by moving it to the end of the priority queue.

    This doesn't delete the feature but gives it a very high priority number
    so it will be processed last.
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            feature = session.query(Feature).filter(Feature.id == feature_id).first()

            if not feature:
                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

            # Set priority to max + 1000 to push to end
            max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
            feature.priority = (max_priority.priority if max_priority else 0) + 1000

            session.commit()

            return {"success": True, "message": f"Feature {feature_id} moved to end of queue"}
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to skip feature")
        raise HTTPException(status_code=500, detail="Failed to skip feature")


I need to move `/stats` and `/reset` routes BEFORE the `/{feature_id}` routes. The easiest way is to:

1. Move `/stats` (lines 311-359) to right after `create_feature` (after line 193)
2. Move `/reset` (lines 260-308) to right after the moved `/stats`

Then the parameterized routes `/{feature_id}` will come after.

Let me rewrite the file with proper ordering.


I'll rewrite the file with proper route ordering - static routes (`/stats`, `/reset`) must come before parameterized routes (`/{feature_id}`):


Write

"""
Features Router
===============

API endpoints for feature/test case management.
"""

import logging
import re
from contextlib import contextmanager
from pathlib import Path

from fastapi import APIRouter, HTTPException

from ..schemas import (
    FeatureCreate,
    FeatureListResponse,
    FeatureResponse,
)

# Lazy imports to avoid circular dependencies
_create_database = None
_Feature = None

logger = logging.getLogger(__name__)

def _get_project_path(project_name: str) -> Path:
    """Get project path from registry."""
    import sys
    root = Path(__file__).parent.parent.parent
    if str(root) not in sys.path:
        sys.path.insert(0, str(root))

    from registry import get_project_path
    return get_project_path(project_name)

def _get_db_classes():
    """Lazy import of database classes."""
    global _create_database, _Feature
    if _create_database is None:
        import sys
        from pathlib import Path
        root = Path(__file__).parent.parent.parent
        if str(root) not in sys.path:
            sys.path.insert(0, str(root))
        from api.database import Feature, create_database
        _create_database = create_database
        _Feature = Feature
    return _create_database, _Feature

router = APIRouter(prefix="/api/projects/{project_name}/features", tags=["features"])

def validate_project_name(name: str) -> str:
    """Validate and sanitize project name to prevent path traversal."""
    if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
        raise HTTPException(
            status_code=400,
            detail="Invalid project name"
        )
    return name

@contextmanager
def get_db_session(project_dir: Path):
    """
    Context manager for database sessions.
    Ensures session is always closed, even on exceptions.
    """
    create_database, _ = _get_db_classes()
    _, SessionLocal = create_database(project_dir)
    session = SessionLocal()
    try:
        yield session
    finally:
        session.close()

def feature_to_response(f) -> FeatureResponse:
    """Convert a Feature model to a FeatureResponse."""
    return FeatureResponse(
        id=f.id,
        priority=f.priority,
        category=f.category,
        name=f.name,
        description=f.description,
        steps=f.steps if isinstance(f.steps, list) else [],
        passes=f.passes,
        in_progress=f.in_progress,
    )

# ============================================================================
# Static routes MUST come before parameterized routes
# ============================================================================

@router.get("", response_model=FeatureListResponse)
async def list_features(project_name: str):
    """
    List all features for a project organized by status.

    Returns features in three lists:
    - pending: passes=False, not currently being worked on
    - in_progress: features currently being worked on (tracked via agent output)
    - done: passes=True
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    if not db_file.exists():
        return FeatureListResponse(pending=[], in_progress=[], done=[])

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            all_features = session.query(Feature).order_by(Feature.priority).all()

            pending = []
            in_progress = []
            done = []

            for f in all_features:
                feature_response = feature_to_response(f)
                if f.passes:
                    done.append(feature_response)
                elif f.in_progress:
                    in_progress.append(feature_response)
                else:
                    pending.append(feature_response)

            return FeatureListResponse(
                pending=pending,
                in_progress=in_progress,
                done=done,
            )
    except HTTPException:
        raise
    except Exception:
        logger.exception("Database error in list_features")
        raise HTTPException(status_code=500, detail="Database error occurred")

@router.post("", response_model=FeatureResponse)
async def create_feature(project_name: str, feature: FeatureCreate):
    """Create a new feature/test case manually."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            # Get next priority if not specified
            if feature.priority is None:
                max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
                priority = (max_priority.priority + 1) if max_priority else 1
            else:
                priority = feature.priority

            # Create new feature
            db_feature = Feature(
                priority=priority,
                category=feature.category,
                name=feature.name,
                description=feature.description,
                steps=feature.steps,
                passes=False,
            )

            session.add(db_feature)
            session.commit()
            session.refresh(db_feature)

            return feature_to_response(db_feature)
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to create feature")
        raise HTTPException(status_code=500, detail="Failed to create feature")

@router.get("/stats")
async def get_feature_stats(project_name: str):
    """
    Get feature statistics for a project.

    Returns counts of pending, in_progress, and completed features,
    plus info about whether the project needs initialization.
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    app_spec = project_dir / "prompts" / "app_spec.txt"

    # Check if project has spec but no features (needs init)
    has_spec = app_spec.exists()
    has_features = False
    stats = {"pending": 0, "in_progress": 0, "completed": 0, "total": 0}

    if db_file.exists():
        _, Feature = _get_db_classes()
        try:
            with get_db_session(project_dir) as session:
                all_features = session.query(Feature).all()
                has_features = len(all_features) > 0

                for f in all_features:
                    stats["total"] += 1
                    if f.passes:
                        stats["completed"] += 1
                    elif f.in_progress:
                        stats["in_progress"] += 1
                    else:
                        stats["pending"] += 1
        except Exception:
            logger.exception("Error getting feature stats")

    return {
        "has_spec": has_spec,
        "has_features": has_features,
        "needs_init": has_spec and not has_features,
        "stats": stats,
    }

@router.post("/reset")
async def reset_features(project_name: str, keep_passing: bool = False):
    """
    Reset all features for a project.

    Args:
        keep_passing: If True, keep features that have already passed

    This is useful when you want to re-initialize the project from the app spec.
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    if not db_file.exists():
        return {"success": True, "message": "No features to reset", "deleted": 0, "kept": 0}

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            if keep_passing:
                # Delete only non-passing features
                deleted = session.query(Feature).filter(Feature.passes == False).delete()
                kept = session.query(Feature).count()
            else:
                # Delete all features
                deleted = session.query(Feature).delete()
                kept = 0

            session.commit()

            return {
                "success": True,
                "message": f"Reset features: {deleted} deleted, {kept} kept",
                "deleted": deleted,
                "kept": kept,
            }
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to reset features")
        raise HTTPException(status_code=500, detail="Failed to reset features")

# ============================================================================
# Parameterized routes come after static routes
# ============================================================================

@router.get("/{feature_id}", response_model=FeatureResponse)
async def get_feature(project_name: str, feature_id: int):
    """Get details of a specific feature."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    if not db_file.exists():
        raise HTTPException(status_code=404, detail="No features database found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            feature = session.query(Feature).filter(Feature.id == feature_id).first()

            if not feature:
                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

            return feature_to_response(feature)
    except HTTPException:
        raise
    except Exception:
        logger.exception("Database error in get_feature")
        raise HTTPException(status_code=500, detail="Database error occurred")

@router.delete("/{feature_id}")
async def delete_feature(project_name: str, feature_id: int):
    """Delete a feature."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            feature = session.query(Feature).filter(Feature.id == feature_id).first()

            if not feature:
                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

            session.delete(feature)
            session.commit()

            return {"success": True, "message": f"Feature {feature_id} deleted"}
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to delete feature")
        raise HTTPException(status_code=500, detail="Failed to delete feature")

@router.patch("/{feature_id}/skip")
async def skip_feature(project_name: str, feature_id: int):
    """
    Mark a feature as skipped by moving it to the end of the priority queue.

    This doesn't delete the feature but gives it a very high priority number
    so it will be processed last.
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            feature = session.query(Feature).filter(Feature.id == feature_id).first()

            if not feature:
                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

            # Set priority to max + 1000 to push to end
            max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
            feature.priority = (max_priority.priority if max_priority else 0) + 1000

            session.commit()

            return {"success": True, "message": f"Feature {feature_id} moved to end of queue"}
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to skip feature")
        raise HTTPException(status_code=500, detail="Failed to skip feature")


The file C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\routers\features.py has been updated. Here's the result of running `cat -n` on a snippet of the edited file:
     1→"""
     2→Features Router
     3→===============
     4→
     5→API endpoints for feature/test case management.
     6→"""
     7→
     8→import logging
     9→import re
    10→from contextlib import contextmanager
    11→from pathlib import Path
    12→
    13→from fastapi import APIRouter, HTTPException
    14→
    15→from ..schemas import (
    16→    FeatureCreate,
    17→    FeatureListResponse,
    18→    FeatureResponse,
    19→)
    20→
    21→# Lazy imports to avoid circular dependencies
    22→_create_database = None
    23→_Feature = None
    24→
    25→logger = logging.getLogger(__name__)
    26→
    27→
    28→def _get_project_path(project_name: str) -> Path:
    29→    """Get project path from registry."""
    30→    import sys
    31→    root = Path(__file__).parent.parent.parent
    32→    if str(root) not in sys.path:
    33→        sys.path.insert(0, str(root))
    34→
    35→    from registry import get_project_path
    36→    return get_project_path(project_name)
    37→
    38→
    39→def _get_db_classes():
    40→    """Lazy import of database classes."""
    41→    global _create_database, _Feature
    42→    if _create_database is None:
    43→        import sys
    44→        from pathlib import Path
    45→        root = Path(__file__).parent.parent.parent
    46→        if str(root) not in sys.path:
    47→            sys.path.insert(0, str(root))
    48→        from api.database import Feature, create_database
    49→        _create_database = create_database
    50→        _Feature = Feature
    51→    return _create_database, _Feature
    52→
    53→
    54→router = APIRouter(prefix="/api/projects/{project_name}/features", tags=["features"])
    55→
    56→
    57→def validate_project_name(name: str) -> str:
    58→    """Validate and sanitize project name to prevent path traversal."""
    59→    if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
    60→        raise HTTPException(
    61→            status_code=400,
    62→            detail="Invalid project name"
    63→        )
    64→    return name
    65→
    66→
    67→@contextmanager
    68→def get_db_session(project_dir: Path):
    69→    """
    70→    Context manager for database sessions.
    71→    Ensures session is always closed, even on exceptions.
    72→    """
    73→    create_database, _ = _get_db_classes()
    74→    _, SessionLocal = create_database(project_dir)
    75→    session = SessionLocal()
    76→    try:
    77→        yield session
    78→    finally:
    79→        session.close()
    80→
    81→
    82→def feature_to_response(f) -> FeatureResponse:
    83→    """Convert a Feature model to a FeatureResponse."""
    84→    return FeatureResponse(
    85→        id=f.id,
    86→        priority=f.priority,
    87→        category=f.category,
    88→        name=f.name,
    89→        description=f.description,
    90→        steps=f.steps if isinstance(f.steps, list) else [],
    91→        passes=f.passes,
    92→        in_progress=f.in_progress,
    93→    )
    94→
    95→
    96→# ============================================================================
    97→# Static routes MUST come before parameterized routes
    98→# ============================================================================
    99→
   100→@router.get("", response_model=FeatureListResponse)
   101→async def list_features(project_name: str):
   102→    """
   103→    List all features for a project organized by status.
   104→
   105→    Returns features in three lists:
   106→    - pending: passes=False, not currently being worked on
   107→    - in_progress: features currently being worked on (tracked via agent output)
   108→    - done: passes=True
   109→    """
   110→    project_name = validate_project_name(project_name)
   111→    project_dir = _get_project_path(project_name)
   112→
   113→    if not project_dir:
   114→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   115→
   116→    if not project_dir.exists():
   117→        raise HTTPException(status_code=404, detail="Project directory not found")
   118→
   119→    db_file = project_dir / "features.db"
   120→    if not db_file.exists():
   121→        return FeatureListResponse(pending=[], in_progress=[], done=[])
   122→
   123→    _, Feature = _get_db_classes()
   124→
   125→    try:
   126→        with get_db_session(project_dir) as session:
   127→            all_features = session.query(Feature).order_by(Feature.priority).all()
   128→
   129→            pending = []
   130→            in_progress = []
   131→            done = []
   132→
   133→            for f in all_features:
   134→                feature_response = feature_to_response(f)
   135→                if f.passes:
   136→                    done.append(feature_response)
   137→                elif f.in_progress:
   138→                    in_progress.append(feature_response)
   139→                else:
   140→                    pending.append(feature_response)
   141→
   142→            return FeatureListResponse(
   143→                pending=pending,
   144→                in_progress=in_progress,
   145→                done=done,
   146→            )
   147→    except HTTPException:
   148→        raise
   149→    except Exception:
   150→        logger.exception("Database error in list_features")
   151→        raise HTTPException(status_code=500, detail="Database error occurred")
   152→
   153→
   154→@router.post("", response_model=FeatureResponse)
   155→async def create_feature(project_name: str, feature: FeatureCreate):
   156→    """Create a new feature/test case manually."""
   157→    project_name = validate_project_name(project_name)
   158→    project_dir = _get_project_path(project_name)
   159→
   160→    if not project_dir:
   161→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   162→
   163→    if not project_dir.exists():
   164→        raise HTTPException(status_code=404, detail="Project directory not found")
   165→
   166→    _, Feature = _get_db_classes()
   167→
   168→    try:
   169→        with get_db_session(project_dir) as session:
   170→            # Get next priority if not specified
   171→            if feature.priority is None:
   172→                max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
   173→                priority = (max_priority.priority + 1) if max_priority else 1
   174→            else:
   175→                priority = feature.priority
   176→
   177→            # Create new feature
   178→            db_feature = Feature(
   179→                priority=priority,
   180→                category=feature.category,
   181→                name=feature.name,
   182→                description=feature.description,
   183→                steps=feature.steps,
   184→                passes=False,
   185→            )
   186→
   187→            session.add(db_feature)
   188→            session.commit()
   189→            session.refresh(db_feature)
   190→
   191→            return feature_to_response(db_feature)
   192→    except HTTPException:
   193→        raise
   194→    except Exception:
   195→        logger.exception("Failed to create feature")
   196→        raise HTTPException(status_code=500, detail="Failed to create feature")
   197→
   198→
   199→@router.get("/stats")
   200→async def get_feature_stats(project_name: str):
   201→    """
   202→    Get feature statistics for a project.
   203→
   204→    Returns counts of pending, in_progress, and completed features,
   205→    plus info about whether the project needs initialization.
   206→    """
   207→    project_name = validate_project_name(project_name)
   208→    project_dir = _get_project_path(project_name)
   209→
   210→    if not project_dir:
   211→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   212→
   213→    if not project_dir.exists():
   214→        raise HTTPException(status_code=404, detail="Project directory not found")
   215→
   216→    db_file = project_dir / "features.db"
   217→    app_spec = project_dir / "prompts" / "app_spec.txt"
   218→
   219→    # Check if project has spec but no features (needs init)
   220→    has_spec = app_spec.exists()
   221→    has_features = False
   222→    stats = {"pending": 0, "in_progress": 0, "completed": 0, "total": 0}
   223→
   224→    if db_file.exists():
   225→        _, Feature = _get_db_classes()
   226→        try:
   227→            with get_db_session(project_dir) as session:
   228→                all_features = session.query(Feature).all()
   229→                has_features = len(all_features) > 0
   230→
   231→                for f in all_features:
   232→                    stats["total"] += 1
   233→                    if f.passes:
   234→                        stats["completed"] += 1
   235→                    elif f.in_progress:
   236→                        stats["in_progress"] += 1
   237→                    else:
   238→                        stats["pending"] += 1
   239→        except Exception:
   240→            logger.exception("Error getting feature stats")
   241→
   242→    return {
   243→        "has_spec": has_spec,
   244→        "has_features": has_features,
   245→        "needs_init": has_spec and not has_features,
   246→        "stats": stats,
   247→    }
   248→
   249→
   250→@router.post("/reset")
   251→async def reset_features(project_name: str, keep_passing: bool = False):
   252→    """
   253→    Reset all features for a project.
   254→
   255→    Args:
   256→        keep_passing: If True, keep features that have already passed
   257→
   258→    This is useful when you want to re-initialize the project from the app spec.
   259→    """
   260→    project_name = validate_project_name(project_name)
   261→    project_dir = _get_project_path(project_name)
   262→
   263→    if not project_dir:
   264→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   265→
   266→    if not project_dir.exists():
   267→        raise HTTPException(status_code=404, detail="Project directory not found")
   268→
   269→    db_file = project_dir / "features.db"
   270→    if not db_file.exists():
   271→        return {"success": True, "message": "No features to reset", "deleted": 0, "kept": 0}
   272→
   273→    _, Feature = _get_db_classes()
   274→
   275→    try:
   276→        with get_db_session(project_dir) as session:
   277→            if keep_passing:
   278→                # Delete only non-passing features
   279→                deleted = session.query(Feature).filter(Feature.passes == False).delete()
   280→                kept = session.query(Feature).count()
   281→            else:
   282→                # Delete all features
   283→                deleted = session.query(Feature).delete()
   284→                kept = 0
   285→
   286→            session.commit()
   287→
   288→            return {
   289→                "success": True,
   290→                "message": f"Reset features: {deleted} deleted, {kept} kept",
   291→                "deleted": deleted,
   292→                "kept": kept,
   293→            }
   294→    except HTTPException:
   295→        raise
   296→    except Exception:
   297→        logger.exception("Failed to reset features")
   298→        raise HTTPException(status_code=500, detail="Failed to reset features")
   299→
   300→
   301→# ============================================================================
   302→# Parameterized routes come after static routes
   303→# ============================================================================
   304→
   305→@router.get("/{feature_id}", response_model=FeatureResponse)
   306→async def get_feature(project_name: str, feature_id: int):
   307→    """Get details of a specific feature."""
   308→    project_name = validate_project_name(project_name)
   309→    project_dir = _get_project_path(project_name)
   310→
   311→    if not project_dir:
   312→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   313→
   314→    if not project_dir.exists():
   315→        raise HTTPException(status_code=404, detail="Project directory not found")
   316→
   317→    db_file = project_dir / "features.db"
   318→    if not db_file.exists():
   319→        raise HTTPException(status_code=404, detail="No features database found")
   320→
   321→    _, Feature = _get_db_classes()
   322→
   323→    try:
   324→        with get_db_session(project_dir) as session:
   325→            feature = session.query(Feature).filter(Feature.id == feature_id).first()
   326→
   327→            if not feature:
   328→                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
   329→
   330→            return feature_to_response(feature)
   331→    except HTTPException:
   332→        raise
   333→    except Exception:
   334→        logger.exception("Database error in get_feature")
   335→        raise HTTPException(status_code=500, detail="Database error occurred")
   336→
   337→
   338→@router.delete("/{feature_id}")
   339→async def delete_feature(project_name: str, feature_id: int):
   340→    """Delete a feature."""
   341→    project_name = validate_project_name(project_name)
   342→    project_dir = _get_project_path(project_name)
   343→
   344→    if not project_dir:
   345→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   346→
   347→    if not project_dir.exists():
   348→        raise HTTPException(status_code=404, detail="Project directory not found")
   349→
   350→    _, Feature = _get_db_classes()
   351→
   352→    try:
   353→        with get_db_session(project_dir) as session:
   354→            feature = session.query(Feature).filter(Feature.id == feature_id).first()
   355→
   356→            if not feature:
   357→                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
   358→
   359→            session.delete(feature)
   360→            session.commit()
   361→
   362→            return {"success": True, "message": f"Feature {feature_id} deleted"}
   363→    except HTTPException:
   364→        raise
   365→    except Exception:
   366→        logger.exception("Failed to delete feature")
   367→        raise HTTPException(status_code=500, detail="Failed to delete feature")
   368→
   369→
   370→@router.patch("/{feature_id}/skip")
   371→async def skip_feature(project_name: str, feature_id: int):
   372→    """
   373→    Mark a feature as skipped by moving it to the end of the priority queue.
   374→
   375→    This doesn't delete the feature but gives it a very high priority number
   376→    so it will be processed last.
   377→    """
   378→    project_name = validate_project_name(project_name)
   379→    project_dir = _get_project_path(project_name)
   380→
   381→    if not project_dir:
   382→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
   383→
   384→    if not project_dir.exists():
   385→        raise HTTPException(status_code=404, detail="Project directory not found")
   386→
   387→    _, Feature = _get_db_classes()
   388→
   389→    try:
   390→        with get_db_session(project_dir) as session:
   391→            feature = session.query(Feature).filter(Feature.id == feature_id).first()
   392→
   393→            if not feature:
   394→                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
   395→
   396→            # Set priority to max + 1000 to push to end
   397→            max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
   398→            feature.priority = (max_priority.priority if max_priority else 0) + 1000
   399→
   400→            session.commit()
   401→
   402→            return {"success": True, "message": f"Feature {feature_id} moved to end of queue"}
   403→    except HTTPException:
   404→        raise
   405→    except Exception:
   406→        logger.exception("Failed to skip feature")
   407→        raise HTTPException(status_code=500, detail="Failed to skip feature")
   408→

"""
Features Router
===============

API endpoints for feature/test case management.
"""

import logging
import re
from contextlib import contextmanager
from pathlib import Path

from fastapi import APIRouter, HTTPException

from ..schemas import (
    FeatureCreate,
    FeatureListResponse,
    FeatureResponse,
)

# Lazy imports to avoid circular dependencies
_create_database = None
_Feature = None

logger = logging.getLogger(__name__)

def _get_project_path(project_name: str) -> Path:
    """Get project path from registry."""
    import sys
    root = Path(__file__).parent.parent.parent
    if str(root) not in sys.path:
        sys.path.insert(0, str(root))

    from registry import get_project_path
    return get_project_path(project_name)

def _get_db_classes():
    """Lazy import of database classes."""
    global _create_database, _Feature
    if _create_database is None:
        import sys
        from pathlib import Path
        root = Path(__file__).parent.parent.parent
        if str(root) not in sys.path:
            sys.path.insert(0, str(root))
        from api.database import Feature, create_database
        _create_database = create_database
        _Feature = Feature
    return _create_database, _Feature

router = APIRouter(prefix="/api/projects/{project_name}/features", tags=["features"])

def validate_project_name(name: str) -> str:
    """Validate and sanitize project name to prevent path traversal."""
    if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
        raise HTTPException(
            status_code=400,
            detail="Invalid project name"
        )
    return name

@contextmanager
def get_db_session(project_dir: Path):
    """
    Context manager for database sessions.
    Ensures session is always closed, even on exceptions.
    """
    create_database, _ = _get_db_classes()
    _, SessionLocal = create_database(project_dir)
    session = SessionLocal()
    try:
        yield session
    finally:
        session.close()

def feature_to_response(f) -> FeatureResponse:
    """Convert a Feature model to a FeatureResponse."""
    return FeatureResponse(
        id=f.id,
        priority=f.priority,
        category=f.category,
        name=f.name,
        description=f.description,
        steps=f.steps if isinstance(f.steps, list) else [],
        passes=f.passes,
        in_progress=f.in_progress,
    )

# ============================================================================
# Static routes MUST come before parameterized routes
# ============================================================================

@router.get("", response_model=FeatureListResponse)
async def list_features(project_name: str):
    """
    List all features for a project organized by status.

    Returns features in three lists:
    - pending: passes=False, not currently being worked on
    - in_progress: features currently being worked on (tracked via agent output)
    - done: passes=True
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    if not db_file.exists():
        return FeatureListResponse(pending=[], in_progress=[], done=[])

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            all_features = session.query(Feature).order_by(Feature.priority).all()

            pending = []
            in_progress = []
            done = []

            for f in all_features:
                feature_response = feature_to_response(f)
                if f.passes:
                    done.append(feature_response)
                elif f.in_progress:
                    in_progress.append(feature_response)
                else:
                    pending.append(feature_response)

            return FeatureListResponse(
                pending=pending,
                in_progress=in_progress,
                done=done,
            )
    except HTTPException:
        raise
    except Exception:
        logger.exception("Database error in list_features")
        raise HTTPException(status_code=500, detail="Database error occurred")

@router.post("", response_model=FeatureResponse)
async def create_feature(project_name: str, feature: FeatureCreate):
    """Create a new feature/test case manually."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            # Get next priority if not specified
            if feature.priority is None:
                max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
                priority = (max_priority.priority + 1) if max_priority else 1
            else:
                priority = feature.priority

            # Create new feature
            db_feature = Feature(
                priority=priority,
                category=feature.category,
                name=feature.name,
                description=feature.description,
                steps=feature.steps,
                passes=False,
            )

            session.add(db_feature)
            session.commit()
            session.refresh(db_feature)

            return feature_to_response(db_feature)
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to create feature")
        raise HTTPException(status_code=500, detail="Failed to create feature")

@router.get("/stats")
async def get_feature_stats(project_name: str):
    """
    Get feature statistics for a project.

    Returns counts of pending, in_progress, and completed features,
    plus info about whether the project needs initialization.
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    app_spec = project_dir / "prompts" / "app_spec.txt"

    # Check if project has spec but no features (needs init)
    has_spec = app_spec.exists()
    has_features = False
    stats = {"pending": 0, "in_progress": 0, "completed": 0, "total": 0}

    if db_file.exists():
        _, Feature = _get_db_classes()
        try:
            with get_db_session(project_dir) as session:
                all_features = session.query(Feature).all()
                has_features = len(all_features) > 0

                for f in all_features:
                    stats["total"] += 1
                    if f.passes:
                        stats["completed"] += 1
                    elif f.in_progress:
                        stats["in_progress"] += 1
                    else:
                        stats["pending"] += 1
        except Exception:
            logger.exception("Error getting feature stats")

    return {
        "has_spec": has_spec,
        "has_features": has_features,
        "needs_init": has_spec and not has_features,
        "stats": stats,
    }

@router.post("/reset")
async def reset_features(project_name: str, keep_passing: bool = False):
    """
    Reset all features for a project.

    Args:
        keep_passing: If True, keep features that have already passed

    This is useful when you want to re-initialize the project from the app spec.
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    if not db_file.exists():
        return {"success": True, "message": "No features to reset", "deleted": 0, "kept": 0}

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            if keep_passing:
                # Delete only non-passing features
                deleted = session.query(Feature).filter(Feature.passes == False).delete()
                kept = session.query(Feature).count()
            else:
                # Delete all features
                deleted = session.query(Feature).delete()
                kept = 0

            session.commit()

            return {
                "success": True,
                "message": f"Reset features: {deleted} deleted, {kept} kept",
                "deleted": deleted,
                "kept": kept,
            }
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to reset features")
        raise HTTPException(status_code=500, detail="Failed to reset features")

# ============================================================================
# Parameterized routes come after static routes
# ============================================================================

@router.get("/{feature_id}", response_model=FeatureResponse)
async def get_feature(project_name: str, feature_id: int):
    """Get details of a specific feature."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    if not db_file.exists():
        raise HTTPException(status_code=404, detail="No features database found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            feature = session.query(Feature).filter(Feature.id == feature_id).first()

            if not feature:
                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

            return feature_to_response(feature)
    except HTTPException:
        raise
    except Exception:
        logger.exception("Database error in get_feature")
        raise HTTPException(status_code=500, detail="Database error occurred")

@router.delete("/{feature_id}")
async def delete_feature(project_name: str, feature_id: int):
    """Delete a feature."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            feature = session.query(Feature).filter(Feature.id == feature_id).first()

            if not feature:
                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

            session.delete(feature)
            session.commit()

            return {"success": True, "message": f"Feature {feature_id} deleted"}
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to delete feature")
        raise HTTPException(status_code=500, detail="Failed to delete feature")

@router.patch("/{feature_id}/skip")
async def skip_feature(project_name: str, feature_id: int):
    """
    Mark a feature as skipped by moving it to the end of the priority queue.

    This doesn't delete the feature but gives it a very high priority number
    so it will be processed last.
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            feature = session.query(Feature).filter(Feature.id == feature_id).first()

            if not feature:
                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

            # Set priority to max + 1000 to push to end
            max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
            feature.priority = (max_priority.priority if max_priority else 0) + 1000

            session.commit()

            return {"success": True, "message": f"Feature {feature_id} moved to end of queue"}
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to skip feature")
        raise HTTPException(status_code=500, detail="Failed to skip feature")

+# Static routes MUST come before parameterized routes

async def list_features(project_name: str):

-async def get_feature(project_name: str, feature_id: int):

-    """Get details of a specific feature."""

+async def get_feature_stats(project_name: str):

+    Get feature statistics for a project.

+    Returns counts of pending, in_progress, and completed features,

+    plus info about whether the project needs initialization.

-        raise HTTPException(status_code=404, detail="No features database found")

+    app_spec = project_dir / "prompts" / "app_spec.txt"

+    # Check if project has spec but no features (needs init)

+    stats = {"pending": 0, "in_progress": 0, "completed": 0, "total": 0}

-        with get_db_session(project_dir) as session:

-            feature = session.query(Feature).filter(Feature.id == feature_id).first()

+            with get_db_session(project_dir) as session:

+                all_features = session.query(Feature).all()

+                has_features = len(all_features) > 0

-                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

+                    stats["total"] += 1

+                        stats["completed"] += 1

+                    elif f.in_progress:

+                        stats["in_progress"] += 1

+                        stats["pending"] += 1

+            logger.exception("Error getting feature stats")

-            return feature_to_response(feature)

-        logger.exception("Database error in get_feature")

-        raise HTTPException(status_code=500, detail="Database error occurred")

+        "needs_init": has_spec and not has_features,

-async def delete_feature(project_name: str, feature_id: int):

-    project_name = validate_project_name(project_name)

-    project_dir = _get_project_path(project_name)

-        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

-        raise HTTPException(status_code=404, detail="Project directory not found")

-            return {"success": True, "message": f"Feature {feature_id} deleted"}

-        logger.exception("Failed to delete feature")

-        raise HTTPException(status_code=500, detail="Failed to delete feature")

-async def get_feature_stats(project_name: str):

-    Get feature statistics for a project.

+# Parameterized routes come after static routes

-    Returns counts of pending, in_progress, and completed features,

-    plus info about whether the project needs initialization.

+async def get_feature(project_name: str, feature_id: int):

+    """Get details of a specific feature."""

-    app_spec = project_dir / "prompts" / "app_spec.txt"

+        raise HTTPException(status_code=404, detail="No features database found")

-    # Check if project has spec but no features (needs init)

-    stats = {"pending": 0, "in_progress": 0, "completed": 0, "total": 0}

-                all_features = session.query(Feature).all()

-                has_features = len(all_features) > 0

+            feature = session.query(Feature).filter(Feature.id == feature_id).first()

-                    stats["total"] += 1

-                        stats["completed"] += 1

-                    elif f.in_progress:

-                        stats["in_progress"] += 1

-                        stats["pending"] += 1

-            logger.exception("Error getting feature stats")

+                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

-        "needs_init": has_spec and not has_features,

+            return feature_to_response(feature)

+        logger.exception("Database error in get_feature")

+        raise HTTPException(status_code=500, detail="Database error occurred")

+async def delete_feature(project_name: str, feature_id: int):

+    project_name = validate_project_name(project_name)

+    project_dir = _get_project_path(project_name)

+        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

+        raise HTTPException(status_code=404, detail="Project directory not found")

+            return {"success": True, "message": f"Feature {feature_id} deleted"}

+        logger.exception("Failed to delete feature")

+        raise HTTPException(status_code=500, detail="Failed to delete feature")

"""
Features Router
===============

API endpoints for feature/test case management.
"""

import logging
import re
from contextlib import contextmanager
from pathlib import Path

from fastapi import APIRouter, HTTPException

from ..schemas import (
    FeatureCreate,
    FeatureListResponse,
    FeatureResponse,
)

# Lazy imports to avoid circular dependencies
_create_database = None
_Feature = None

logger = logging.getLogger(__name__)

def _get_project_path(project_name: str) -> Path:
    """Get project path from registry."""
    import sys
    root = Path(__file__).parent.parent.parent
    if str(root) not in sys.path:
        sys.path.insert(0, str(root))

    from registry import get_project_path
    return get_project_path(project_name)

def _get_db_classes():
    """Lazy import of database classes."""
    global _create_database, _Feature
    if _create_database is None:
        import sys
        from pathlib import Path
        root = Path(__file__).parent.parent.parent
        if str(root) not in sys.path:
            sys.path.insert(0, str(root))
        from api.database import Feature, create_database
        _create_database = create_database
        _Feature = Feature
    return _create_database, _Feature

router = APIRouter(prefix="/api/projects/{project_name}/features", tags=["features"])

def validate_project_name(name: str) -> str:
    """Validate and sanitize project name to prevent path traversal."""
    if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
        raise HTTPException(
            status_code=400,
            detail="Invalid project name"
        )
    return name

@contextmanager
def get_db_session(project_dir: Path):
    """
    Context manager for database sessions.
    Ensures session is always closed, even on exceptions.
    """
    create_database, _ = _get_db_classes()
    _, SessionLocal = create_database(project_dir)
    session = SessionLocal()
    try:
        yield session
    finally:
        session.close()

def feature_to_response(f) -> FeatureResponse:
    """Convert a Feature model to a FeatureResponse."""
    return FeatureResponse(
        id=f.id,
        priority=f.priority,
        category=f.category,
        name=f.name,
        description=f.description,
        steps=f.steps if isinstance(f.steps, list) else [],
        passes=f.passes,
        in_progress=f.in_progress,
    )

@router.get("", response_model=FeatureListResponse)
async def list_features(project_name: str):
    """
    List all features for a project organized by status.

    Returns features in three lists:
    - pending: passes=False, not currently being worked on
    - in_progress: features currently being worked on (tracked via agent output)
    - done: passes=True
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    if not db_file.exists():
        return FeatureListResponse(pending=[], in_progress=[], done=[])

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            all_features = session.query(Feature).order_by(Feature.priority).all()

            pending = []
            in_progress = []
            done = []

            for f in all_features:
                feature_response = feature_to_response(f)
                if f.passes:
                    done.append(feature_response)
                elif f.in_progress:
                    in_progress.append(feature_response)
                else:
                    pending.append(feature_response)

            return FeatureListResponse(
                pending=pending,
                in_progress=in_progress,
                done=done,
            )
    except HTTPException:
        raise
    except Exception:
        logger.exception("Database error in list_features")
        raise HTTPException(status_code=500, detail="Database error occurred")

@router.post("", response_model=FeatureResponse)
async def create_feature(project_name: str, feature: FeatureCreate):
    """Create a new feature/test case manually."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            # Get next priority if not specified
            if feature.priority is None:
                max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
                priority = (max_priority.priority + 1) if max_priority else 1
            else:
                priority = feature.priority

            # Create new feature
            db_feature = Feature(
                priority=priority,
                category=feature.category,
                name=feature.name,
                description=feature.description,
                steps=feature.steps,
                passes=False,
            )

            session.add(db_feature)
            session.commit()
            session.refresh(db_feature)

            return feature_to_response(db_feature)
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to create feature")
        raise HTTPException(status_code=500, detail="Failed to create feature")

@router.get("/{feature_id}", response_model=FeatureResponse)
async def get_feature(project_name: str, feature_id: int):
    """Get details of a specific feature."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    if not db_file.exists():
        raise HTTPException(status_code=404, detail="No features database found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            feature = session.query(Feature).filter(Feature.id == feature_id).first()

            if not feature:
                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

            return feature_to_response(feature)
    except HTTPException:
        raise
    except Exception:
        logger.exception("Database error in get_feature")
        raise HTTPException(status_code=500, detail="Database error occurred")

@router.delete("/{feature_id}")
async def delete_feature(project_name: str, feature_id: int):
    """Delete a feature."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            feature = session.query(Feature).filter(Feature.id == feature_id).first()

            if not feature:
                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

            session.delete(feature)
            session.commit()

            return {"success": True, "message": f"Feature {feature_id} deleted"}
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to delete feature")
        raise HTTPException(status_code=500, detail="Failed to delete feature")

@router.post("/reset")
async def reset_features(project_name: str, keep_passing: bool = False):
    """
    Reset all features for a project.

    Args:
        keep_passing: If True, keep features that have already passed

    This is useful when you want to re-initialize the project from the app spec.
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    if not db_file.exists():
        return {"success": True, "message": "No features to reset", "deleted": 0, "kept": 0}

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            if keep_passing:
                # Delete only non-passing features
                deleted = session.query(Feature).filter(Feature.passes == False).delete()
                kept = session.query(Feature).count()
            else:
                # Delete all features
                deleted = session.query(Feature).delete()
                kept = 0

            session.commit()

            return {
                "success": True,
                "message": f"Reset features: {deleted} deleted, {kept} kept",
                "deleted": deleted,
                "kept": kept,
            }
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to reset features")
        raise HTTPException(status_code=500, detail="Failed to reset features")

@router.get("/stats")
async def get_feature_stats(project_name: str):
    """
    Get feature statistics for a project.

    Returns counts of pending, in_progress, and completed features,
    plus info about whether the project needs initialization.
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    db_file = project_dir / "features.db"
    app_spec = project_dir / "prompts" / "app_spec.txt"

    # Check if project has spec but no features (needs init)
    has_spec = app_spec.exists()
    has_features = False
    stats = {"pending": 0, "in_progress": 0, "completed": 0, "total": 0}

    if db_file.exists():
        _, Feature = _get_db_classes()
        try:
            with get_db_session(project_dir) as session:
                all_features = session.query(Feature).all()
                has_features = len(all_features) > 0

                for f in all_features:
                    stats["total"] += 1
                    if f.passes:
                        stats["completed"] += 1
                    elif f.in_progress:
                        stats["in_progress"] += 1
                    else:
                        stats["pending"] += 1
        except Exception:
            logger.exception("Error getting feature stats")

    return {
        "has_spec": has_spec,
        "has_features": has_features,
        "needs_init": has_spec and not has_features,
        "stats": stats,
    }

@router.patch("/{feature_id}/skip")
async def skip_feature(project_name: str, feature_id: int):
    """
    Mark a feature as skipped by moving it to the end of the priority queue.

    This doesn't delete the feature but gives it a very high priority number
    so it will be processed last.
    """
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    _, Feature = _get_db_classes()

    try:
        with get_db_session(project_dir) as session:
            feature = session.query(Feature).filter(Feature.id == feature_id).first()

            if not feature:
                raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")

            # Set priority to max + 1000 to push to end
            max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
            feature.priority = (max_priority.priority if max_priority else 0) + 1000

            session.commit()

            return {"success": True, "message": f"Feature {feature_id} moved to end of queue"}
    except HTTPException:
        raise
    except Exception:
        logger.exception("Failed to skip feature")
        raise HTTPException(status_code=500, detail="Failed to skip feature")


Good, fixed the route ordering in features.py. Now I also saw the 405 error for `/agent/reinit`. Let me check the agent router to make sure the reinit endpoint is properly registered and the main.py includes the agent router.


Now let me check the agent router and server main.py to fix the 405 error on `/reinit`:


     1→"""
     2→FastAPI Main Application
     3→========================
     4→
     5→Main entry point for the Autonomous Coding UI server.
     6→Provides REST API, WebSocket, and static file serving.
     7→"""
     8→
     9→import shutil
    10→from contextlib import asynccontextmanager
    11→from pathlib import Path
    12→
    13→from fastapi import FastAPI, HTTPException, Request, WebSocket
    14→from fastapi.middleware.cors import CORSMiddleware
    15→from fastapi.responses import FileResponse
    16→from fastapi.staticfiles import StaticFiles
    17→
    18→from .routers import (
    19→    agent_router,
    20→    assistant_chat_router,
    21→    features_router,
    22→    filesystem_router,
    23→    projects_router,
    24→    spec_creation_router,
    25→    terminal_router,
    26→    git_router,
    27→    scheduler_router,
    28→    system_router,
    29→)
    30→from .schemas import SetupStatus
    31→from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
    32→from .services.process_manager import cleanup_all_managers
    33→from .websocket import project_websocket
    34→
    35→# Paths
    36→ROOT_DIR = Path(__file__).parent.parent
    37→UI_DIST_DIR = ROOT_DIR / "ui" / "dist"
    38→
    39→
    40→@asynccontextmanager
    41→async def lifespan(app: FastAPI):
    42→    """Lifespan context manager for startup and shutdown."""
    43→    # Startup
    44→    yield
    45→    # Shutdown - cleanup all running agents and assistant sessions
    46→    await cleanup_all_managers()
    47→    await cleanup_assistant_sessions()
    48→
    49→
    50→# Create FastAPI app
    51→app = FastAPI(
    52→    title="Autonomous Coding UI",
    53→    description="Web UI for the Autonomous Coding Agent",
    54→    version="1.0.0",
    55→    lifespan=lifespan,
    56→)
    57→
    58→# CORS - allow only localhost origins for security
    59→app.add_middleware(
    60→    CORSMiddleware,
    61→    allow_origins=[
    62→        "http://localhost:5173",      # Vite dev server
    63→        "http://127.0.0.1:5173",
    64→        "http://localhost:8888",      # Production
    65→        "http://127.0.0.1:8888",
    66→    ],
    67→    allow_credentials=True,
    68→    allow_methods=["*"],
    69→    allow_headers=["*"],
    70→)
    71→
    72→
    73→# ============================================================================
    74→# Security Middleware
    75→# ============================================================================
    76→
    77→@app.middleware("http")
    78→async def require_localhost(request: Request, call_next):
    79→    """Only allow requests from localhost."""
    80→    client_host = request.client.host if request.client else None
    81→
    82→    # Allow localhost connections
    83→    if client_host not in ("127.0.0.1", "::1", "localhost", None):
    84→        raise HTTPException(status_code=403, detail="Localhost access only")
    85→
    86→    return await call_next(request)
    87→
    88→
    89→# ============================================================================
    90→# Include Routers
    91→# ============================================================================
    92→
    93→app.include_router(projects_router)
    94→app.include_router(features_router)
    95→app.include_router(agent_router)
    96→app.include_router(spec_creation_router)
    97→app.include_router(filesystem_router)
    98→app.include_router(assistant_chat_router)
    99→app.include_router(terminal_router)
   100→app.include_router(git_router)

<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>

"""
FastAPI Main Application
========================

Main entry point for the Autonomous Coding UI server.
Provides REST API, WebSocket, and static file serving.
"""

import shutil
from contextlib import asynccontextmanager
from pathlib import Path

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

from .routers import (
    agent_router,
    assistant_chat_router,
    features_router,
    filesystem_router,
    projects_router,
    spec_creation_router,
    terminal_router,
    git_router,
    scheduler_router,
    system_router,
)
from .schemas import SetupStatus
from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
from .services.process_manager import cleanup_all_managers
from .websocket import project_websocket

# Paths
ROOT_DIR = Path(__file__).parent.parent
UI_DIST_DIR = ROOT_DIR / "ui" / "dist"

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Lifespan context manager for startup and shutdown."""
    # Startup
    yield
    # Shutdown - cleanup all running agents and assistant sessions
    await cleanup_all_managers()
    await cleanup_assistant_sessions()

# Create FastAPI app
app = FastAPI(
    title="Autonomous Coding UI",
    description="Web UI for the Autonomous Coding Agent",
    version="1.0.0",
    lifespan=lifespan,
)

# CORS - allow only localhost origins for security
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:5173",      # Vite dev server
        "http://127.0.0.1:5173",
        "http://localhost:8888",      # Production
        "http://127.0.0.1:8888",
    ],
    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."""
    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)

# ============================================================================
# Include Routers
# ============================================================================

app.include_router(projects_router)
app.include_router(features_router)
app.include_router(agent_router)
app.include_router(spec_creation_router)
app.include_router(filesystem_router)
app.include_router(assistant_chat_router)
app.include_router(terminal_router)
app.include_router(git_router)


Good, the agent_router is included. The issue might be that we're getting a 405 because the route isn't being registered properly. Let me check the agent router initialization in the routers __init__.py, and also check the agent router file itself.