""" Session management API endpoints. """ import os from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import selectinload from app.database.connection import get_db from app.models.project import Project from app.models.session import Session from app.api.schemas import SessionStartRequest, SessionEndRequest, SessionResponse router = APIRouter() async def get_or_create_project( db: AsyncSession, working_directory: str, git_repo: Optional[str] = None ) -> Project: """Get existing project or create a new one based on working directory.""" # Try to find existing project by path result = await db.execute( select(Project).where(Project.path == working_directory) ) project = result.scalars().first() if project: return project # Create new project project_name = os.path.basename(working_directory) or "Unknown Project" # Try to infer languages from directory (simple heuristic) languages = [] try: for root, dirs, files in os.walk(working_directory): for file in files: ext = os.path.splitext(file)[1].lower() if ext == ".py": languages.append("python") elif ext in [".js", ".jsx"]: languages.append("javascript") elif ext in [".ts", ".tsx"]: languages.append("typescript") elif ext == ".go": languages.append("go") elif ext == ".rs": languages.append("rust") elif ext == ".java": languages.append("java") elif ext in [".cpp", ".cc", ".cxx"]: languages.append("cpp") elif ext == ".c": languages.append("c") # Don't traverse too deep if len(root.replace(working_directory, "").split(os.sep)) > 2: break languages = list(set(languages))[:5] # Keep unique, limit to 5 except (OSError, PermissionError): # If we can't read the directory, that's okay pass project = Project( name=project_name, path=working_directory, git_repo=git_repo, languages=languages if languages else None ) db.add(project) await db.commit() await db.refresh(project) return project @router.post("/session/start", response_model=SessionResponse, status_code=status.HTTP_201_CREATED) async def start_session( request: SessionStartRequest, db: AsyncSession = Depends(get_db) ): """ Start a new development session. This endpoint is called by Claude Code hooks when a session begins. It will create a project if one doesn't exist for the working directory. """ try: # Get or create project project = await get_or_create_project( db=db, working_directory=request.working_directory, git_repo=request.git_repo ) # Create new session session = Session( project_id=project.id, session_type=request.session_type, working_directory=request.working_directory, git_branch=request.git_branch, environment=request.environment ) db.add(session) await db.commit() await db.refresh(session) # Store session ID in temp file for hooks to use session_file = "/tmp/claude-session-id" try: with open(session_file, "w") as f: f.write(str(session.id)) except OSError: # If we can't write the session file, log but don't fail pass return SessionResponse( session_id=session.id, project_id=project.id, status="started", message=f"Session started for project '{project.name}'" ) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to start session: {str(e)}" ) @router.post("/session/end", response_model=SessionResponse) async def end_session( request: SessionEndRequest, db: AsyncSession = Depends(get_db) ): """ End an active development session. This endpoint calculates final session statistics and updates project-level metrics. """ try: # Find the session result = await db.execute( select(Session) .options(selectinload(Session.project)) .where(Session.id == request.session_id) ) session = result.scalars().first() if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {request.session_id} not found" ) if not session.is_active: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Session is already ended" ) # End the session session.end_session(end_reason=request.end_reason) await db.commit() # Clean up session ID file session_file = "/tmp/claude-session-id" try: if os.path.exists(session_file): os.remove(session_file) except OSError: pass return SessionResponse( session_id=session.id, project_id=session.project_id, status="ended", message=f"Session ended after {session.duration_minutes} minutes" ) except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to end session: {str(e)}" ) @router.get("/sessions/{session_id}", response_model=dict) async def get_session( session_id: int, db: AsyncSession = Depends(get_db) ): """Get detailed information about a specific session.""" try: result = await db.execute( select(Session) .options( selectinload(Session.project), selectinload(Session.conversations), selectinload(Session.activities), selectinload(Session.waiting_periods), selectinload(Session.git_operations) ) .where(Session.id == session_id) ) session = result.scalars().first() if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {session_id} not found" ) return { "id": session.id, "project": { "id": session.project.id, "name": session.project.name, "path": session.project.path }, "start_time": session.start_time, "end_time": session.end_time, "duration_minutes": session.calculated_duration_minutes, "session_type": session.session_type, "git_branch": session.git_branch, "activity_count": session.activity_count, "conversation_count": session.conversation_count, "is_active": session.is_active, "statistics": { "conversations": len(session.conversations), "activities": len(session.activities), "waiting_periods": len(session.waiting_periods), "git_operations": len(session.git_operations), "files_touched": len(session.files_touched or []) } } except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get session: {str(e)}" )