hugsbun's picture
first commit
ba52031
Raw
History Blame Contribute Delete
13.7 kB
"""
Tasks API endpoints
Provides task metadata, episode management, and source file access for the debug-env benchmark.
"""
import logging
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException, status, Depends
from pydantic import ValidationError
from sqlalchemy.orm import Session
from debug_env.server.tasks.data import get_available_task_ids, get_task_by_id, validate_task_id
from debug_env.server.tasks.loader import TaskLoader
from debug_env.server.database.db import get_db
from debug_env.server.database.managers.task_manager import get_task_manager
from debug_env.server.schemas.task_schemas import (
TaskListResponse,
TaskResponse,
EpisodeResponse,
EpisodeListResponse,
EpisodeCreateRequest,
EpisodePatchRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tasks", tags=["tasks"])
def _format_validation_error(e: ValidationError) -> str:
"""Format a Pydantic ValidationError into a human-readable string."""
messages = []
for error in e.errors():
field = " -> ".join(str(loc) for loc in error["loc"])
messages.append(f"{field}: {error['msg']}")
return "Validation failed: " + "; ".join(messages)
@router.get("", response_model=Dict[str, Any])
async def list_tasks(
db: Session = Depends(get_db),
maxResults: int = 50,
pageToken: str = None,
syncToken: str = None,
):
"""
Returns metadata for all available debug tasks with pagination support.
Query Parameters:
- maxResults: Maximum tasks per page (default: 50)
- pageToken: Pagination token from previous response
- syncToken: Sync token for incremental updates
GET /tasks
"""
try:
# Get user_id from header (default to "default_user" if not provided)
user_id = "default_user"
# Use TaskManager for listing with pagination
manager = get_task_manager(db, user_id)
response = manager.list_tasks(
max_results=maxResults,
page_token=pageToken,
sync_token=syncToken,
)
# Convert to dict response matching schema
return {
"etag": response.etag,
"items": [TaskResponse.model_validate(item).model_dump() for item in response.items],
"nextPageToken": response.nextPageToken,
"nextSyncToken": response.nextSyncToken,
}
except ValueError as e:
logger.error(f"Validation error listing tasks: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
logger.error(f"Error listing tasks: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error occurred while listing tasks",
)
@router.get("/{taskId}", response_model=Dict[str, Any])
async def get_task(taskId: str):
"""
Returns metadata for a specific debug task.
GET /tasks/{taskId}
"""
try:
if not validate_task_id(taskId):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task '{taskId}' not found. Available: {get_available_task_ids()}",
)
task = get_task_by_id(taskId)
logger.info(f"Retrieved task metadata for '{taskId}'")
return task
except HTTPException:
raise
except ValidationError as e:
# Handle unexpected schema validation errors
logger.error(f"Schema validation error for task '{taskId}': {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=_format_validation_error(e),
)
except Exception as e:
logger.error(f"Error getting task '{taskId}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error occurred while retrieving task",
)
@router.get("/{taskId}/files", response_model=List[str])
async def list_task_files(taskId: str):
"""
Returns the list of editable source files for a task (excludes test files).
GET /tasks/{taskId}/files
"""
try:
if not validate_task_id(taskId):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task '{taskId}' not found. Available: {get_available_task_ids()}",
)
files = TaskLoader.list_source_files(taskId)
logger.info(f"Listed {len(files)} source files for task '{taskId}'")
return files
except HTTPException:
raise
except ValueError as e:
# Handle business logic validation errors (invalid task ID)
logger.error(f"Validation error listing files for task '{taskId}': {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid task or request parameters")
except FileNotFoundError as e:
# Task is registered but files are missing from disk
logger.error(f"Task files missing on disk for '{taskId}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error occurred while listing task files",
)
except Exception as e:
logger.error(f"Error listing files for task '{taskId}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error occurred while listing task files",
)
@router.get("/{taskId}/files/{filename}", response_model=Dict[str, str])
async def get_task_file(taskId: str, filename: str):
"""
Returns the canonical (pre-episode) content of a source file.
Use this to inspect the original broken code before starting an episode.
During an episode, use the read_file tool via POST /step instead.
GET /tasks/{taskId}/files/{filename}
"""
try:
if not validate_task_id(taskId):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task '{taskId}' not found. Available: {get_available_task_ids()}",
)
content = TaskLoader.read_source_file(taskId, filename)
logger.info(f"Read source file '{filename}' for task '{taskId}'")
return {"task_id": taskId, "filename": filename, "content": content}
except HTTPException:
raise
except ValueError as e:
# Handle business logic validation errors (invalid filename, path traversal)
logger.error(f"Validation error reading file '{filename}' for task '{taskId}': {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid filename or request parameters")
except FileNotFoundError as e:
# File not found within the task
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except Exception as e:
logger.error(f"Error reading file '{filename}' for task '{taskId}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error occurred while reading task file",
)
# ── Episode Management Endpoints ───────────────────────────────────────────
@router.post("/{taskId}/episodes", response_model=Dict[str, Any])
async def create_episode(
taskId: str,
db: Session = Depends(get_db),
):
"""
Create a new episode (task run) for the current user.
POST /tasks/{taskId}/episodes
Returns:
- id: Episode identifier (uuid4)
- task_id: Task identifier
- status: "active"
- workdir: Path to working directory with task files
- etag: Cache validation tag
"""
try:
if not validate_task_id(taskId):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task '{taskId}' not found",
)
user_id = "default_user"
manager = get_task_manager(db, user_id)
# Load task files into temp directory
workdir = TaskLoader.load(taskId)
# Create episode record
episode = manager.create_episode(taskId, workdir)
logger.info(f"Created episode {episode.id} for task {taskId}")
return EpisodeResponse.model_validate(episode).model_dump()
except HTTPException:
raise
except ValueError as e:
logger.error(f"Validation error creating episode for task {taskId}: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f"Error creating episode for task {taskId}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error occurred while creating episode",
)
@router.get("/{taskId}/episodes", response_model=Dict[str, Any])
async def list_episodes(
taskId: str,
db: Session = Depends(get_db),
maxResults: int = 50,
pageToken: str = None,
):
"""
List episodes for a task with pagination.
GET /tasks/{taskId}/episodes
Query Parameters:
- maxResults: Maximum episodes per page (default: 50)
- pageToken: Pagination token from previous response
"""
try:
if not validate_task_id(taskId):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task '{taskId}' not found",
)
user_id = "default_user"
manager = get_task_manager(db, user_id)
response = manager.list_episodes(taskId, max_results=maxResults, page_token=pageToken)
return {
"items": [EpisodeResponse.model_validate(item).model_dump() for item in response.items],
"nextPageToken": response.nextPageToken,
}
except HTTPException:
raise
except ValueError as e:
logger.error(f"Validation error listing episodes for task {taskId}: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f"Error listing episodes for task {taskId}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error occurred while listing episodes",
)
@router.get("/{taskId}/episodes/{episodeId}", response_model=Dict[str, Any])
async def get_episode(
taskId: str,
episodeId: str,
db: Session = Depends(get_db),
):
"""
Retrieve a specific episode.
GET /tasks/{taskId}/episodes/{episodeId}
"""
try:
if not validate_task_id(taskId):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task '{taskId}' not found",
)
user_id = "default_user"
manager = get_task_manager(db, user_id)
episode = manager.get_episode(taskId, episodeId)
if not episode:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Episode '{episodeId}' not found for task '{taskId}'",
)
return EpisodeResponse.model_validate(episode).model_dump()
except HTTPException:
raise
except Exception as e:
logger.error(f"Error retrieving episode {episodeId} for task {taskId}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error occurred while retrieving episode",
)
@router.patch("/{taskId}/episodes/{episodeId}", response_model=Dict[str, Any])
async def update_episode(
taskId: str,
episodeId: str,
request: EpisodePatchRequest,
db: Session = Depends(get_db),
):
"""
Update an episode's status and/or pass_rate.
PATCH /tasks/{taskId}/episodes/{episodeId}
Request Body:
- status: Optional, new status ("active", "passed", "failed", "abandoned")
- pass_rate: Optional, pass rate (0.0-1.0)
"""
try:
if not validate_task_id(taskId):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task '{taskId}' not found",
)
user_id = "default_user"
manager = get_task_manager(db, user_id)
# Get current episode
episode = manager.get_episode(taskId, episodeId)
if not episode:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Episode '{episodeId}' not found for task '{taskId}'",
)
# Update with provided fields
status = request.status if request.status else episode.status
pass_rate = request.pass_rate if request.pass_rate is not None else episode.pass_rate
updated_episode = manager.update_episode(taskId, episodeId, status, pass_rate)
logger.info(f"Updated episode {episodeId} for task {taskId}")
return EpisodeResponse.model_validate(updated_episode).model_dump()
except HTTPException:
raise
except ValueError as e:
logger.error(f"Validation error updating episode {episodeId}: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f"Error updating episode {episodeId}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error occurred while updating episode",
)