# #region TasksRouter [C:3] [TYPE Module] [SEMANTICS fastapi, task, api, search, create-task-request, resolve-task-request] # @BRIEF Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks. # @LAYER: API # @RELATION DEPENDS_ON -> [TaskManager] # @RELATION DEPENDS_ON -> [ConfigManager] # @RELATION DEPENDS_ON -> [LLMProviderService] from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from ...core.config_manager import ConfigManager from ...core.logger import belief_scope from ...core.task_manager import Task, TaskManager, TaskStatus from ...core.task_manager.models import LogFilter, LogStats from ...dependencies import ( get_config_manager, get_current_user, get_task_manager, has_permission, ) from ...services.llm_prompt_templates import ( is_multimodal_model, normalize_llm_settings, resolve_bound_provider_id, ) router = APIRouter() TASK_TYPE_PLUGIN_MAP = { "llm_validation": ["llm_dashboard_validation"], "backup": ["superset-backup"], "migration": ["superset-migration"], } class CreateTaskRequest(BaseModel): plugin_id: str params: dict[str, Any] class ResolveTaskRequest(BaseModel): resolution_params: dict[str, Any] class ResumeTaskRequest(BaseModel): passwords: dict[str, str] # #region create_task [C:3] [TYPE Function] # @BRIEF Create and start a new task for a given plugin. # @PRE: plugin_id must exist and params must be valid for that plugin. # @POST: A new task is created and started. # @RELATION CALLS -> [TaskManager] # @RELATION DEPENDS_ON -> [ConfigManager] # @RELATION DEPENDS_ON -> [LLMProviderService] @router.post("", response_model=Task, status_code=status.HTTP_201_CREATED) async def create_task( request: CreateTaskRequest, task_manager: TaskManager = Depends(get_task_manager), current_user=Depends(get_current_user), config_manager: ConfigManager = Depends(get_config_manager), ): # Dynamic permission check based on plugin_id has_permission(f"plugin:{request.plugin_id}", "EXECUTE")(current_user) with belief_scope("create_task"): try: # Special handling for LLM tasks to resolve provider config by task binding. if request.plugin_id in {"llm_dashboard_validation", "llm_documentation"}: from ...core.database import SessionLocal from ...services.llm_provider import LLMProviderService db = SessionLocal() try: llm_service = LLMProviderService(db) provider_id = request.params.get("provider_id") if not provider_id: llm_settings = normalize_llm_settings( config_manager.get_config().settings.llm ) binding_key = ( "dashboard_validation" if request.plugin_id == "llm_dashboard_validation" else "documentation" ) provider_id = resolve_bound_provider_id( llm_settings, binding_key ) if provider_id: request.params["provider_id"] = provider_id if not provider_id: providers = llm_service.get_all_providers() active_provider = next( (p for p in providers if p.is_active), None ) if active_provider: provider_id = active_provider.id request.params["provider_id"] = provider_id if provider_id: db_provider = llm_service.get_provider(provider_id) if not db_provider: raise ValueError(f"LLM Provider {provider_id} not found") if ( request.plugin_id == "llm_dashboard_validation" and not is_multimodal_model( db_provider.default_model, db_provider.provider_type, ) ): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Selected provider model is not multimodal for dashboard validation", ) finally: db.close() task = await task_manager.create_task( plugin_id=request.plugin_id, params=request.params ) return task except ValueError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) # #endregion create_task # #region list_tasks [C:2] [TYPE Function] # @BRIEF Retrieve a list of tasks with pagination and optional status filter. # @PRE: task_manager must be available. # @POST: Returns a list of tasks. # @RELATION CALLS -> [TaskManager] # @RELATION BINDS_TO -> [TASK_TYPE_PLUGIN_MAP] @router.get("", response_model=list[Task]) async def list_tasks( limit: int = 10, offset: int = 0, status_filter: TaskStatus | None = Query(None, alias="status"), task_type: str | None = Query( None, description="Task category: llm_validation, backup, migration" ), plugin_id: list[str] | None = Query( None, description="Filter by plugin_id (repeatable query param)" ), completed_only: bool = Query( False, description="Return only completed tasks (SUCCESS/FAILED)" ), task_manager: TaskManager = Depends(get_task_manager), _=Depends(has_permission("tasks", "READ")), ): with belief_scope("list_tasks"): plugin_filters = list(plugin_id) if plugin_id else [] if task_type: if task_type not in TASK_TYPE_PLUGIN_MAP: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unsupported task_type '{task_type}'. Allowed: {', '.join(TASK_TYPE_PLUGIN_MAP.keys())}", ) plugin_filters.extend(TASK_TYPE_PLUGIN_MAP[task_type]) return task_manager.get_tasks( limit=limit, offset=offset, status=status_filter, plugin_ids=plugin_filters or None, completed_only=completed_only, ) # #endregion list_tasks # #region get_task [C:2] [TYPE Function] # @BRIEF Retrieve the details of a specific task. # @PRE: task_id must exist. # @POST: Returns task details or raises 404. # @RELATION CALLS -> [TaskManager] @router.get("/{task_id}", response_model=Task) async def get_task( task_id: str, task_manager: TaskManager = Depends(get_task_manager), _=Depends(has_permission("tasks", "READ")), ): with belief_scope("get_task"): task = task_manager.get_task(task_id) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" ) return task # #endregion get_task # #region get_task_logs [C:5] [TYPE Function] # @BRIEF Retrieve logs for a specific task with optional filtering. # @PRE: task_id must exist. # @POST: Returns a list of log entries or raises 404. # @RELATION CALLS -> [TaskManager] # @RELATION DEPENDS_ON -> [LogFilter] # @TEST_CONTRACT: TaskLogQueryInput -> List[LogEntry] # @TEST_SCENARIO: existing_task_logs_filtered -> Returns filtered logs by level/source/search with pagination. # @TEST_FIXTURE: valid_task_with_mixed_logs -> backend/tests/fixtures/task_logs/valid_task_with_mixed_logs.json # @TEST_EDGE: missing_task -> Unknown task_id returns 404 Task not found. # @TEST_EDGE: invalid_level_type -> Non-string/invalid level query rejected by validation or yields empty result. # @TEST_EDGE: pagination_bounds -> offset=0 and limit=1000 remain within API bounds and do not overflow. # @TEST_INVARIANT: logs_only_for_existing_task -> VERIFIED_BY: [existing_task_logs_filtered, missing_task] @router.get("/{task_id}/logs") async def get_task_logs( task_id: str, level: str | None = Query( None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)" ), source: str | None = Query(None, description="Filter by source component"), search: str | None = Query(None, description="Text search in message"), offset: int = Query(0, ge=0, description="Number of logs to skip"), limit: int = Query( 100, ge=1, le=1000, description="Maximum number of logs to return" ), task_manager: TaskManager = Depends(get_task_manager), ): with belief_scope("get_task_logs"): task = task_manager.get_task(task_id) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" ) log_filter = LogFilter( level=level.upper() if level else None, source=source, search=search, offset=offset, limit=limit, ) return task_manager.get_task_logs(task_id, log_filter) # #endregion get_task_logs # #region get_task_log_stats [C:2] [TYPE Function] # @BRIEF Get statistics about logs for a task (counts by level and source). # @PRE: task_id must exist. # @POST: Returns log statistics or raises 404. # @RELATION CALLS -> [TaskManager] # @RELATION DEPENDS_ON -> [LogStats] @router.get("/{task_id}/logs/stats", response_model=LogStats) async def get_task_log_stats( task_id: str, task_manager: TaskManager = Depends(get_task_manager), ): with belief_scope("get_task_log_stats"): task = task_manager.get_task(task_id) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" ) stats_payload = task_manager.get_task_log_stats(task_id) if isinstance(stats_payload, LogStats): return stats_payload if isinstance(stats_payload, dict) and ( "total_count" in stats_payload or "by_level" in stats_payload or "by_source" in stats_payload ): return LogStats( total_count=int(stats_payload.get("total_count", 0) or 0), by_level=dict(stats_payload.get("by_level") or {}), by_source=dict(stats_payload.get("by_source") or {}), ) flat_by_level = ( dict(stats_payload or {}) if isinstance(stats_payload, dict) else {} ) return LogStats( total_count=sum(int(value or 0) for value in flat_by_level.values()), by_level={ str(key): int(value or 0) for key, value in flat_by_level.items() }, by_source={}, ) # #endregion get_task_log_stats # #region get_task_log_sources [C:2] [TYPE Function] # @BRIEF Get unique sources for a task's logs. # @PRE: task_id must exist. # @POST: Returns list of unique source names or raises 404. # @RELATION CALLS -> [TaskManager] @router.get("/{task_id}/logs/sources", response_model=list[str]) async def get_task_log_sources( task_id: str, task_manager: TaskManager = Depends(get_task_manager), ): with belief_scope("get_task_log_sources"): task = task_manager.get_task(task_id) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" ) return task_manager.get_task_log_sources(task_id) # #endregion get_task_log_sources # #region resolve_task [C:2] [TYPE Function] # @BRIEF Resolve a task that is awaiting mapping. # @PRE: task must be in AWAITING_MAPPING status. # @POST: Task is resolved and resumes execution. # @RELATION CALLS -> [TaskManager] @router.post("/{task_id}/resolve", response_model=Task) async def resolve_task( task_id: str, request: ResolveTaskRequest, task_manager: TaskManager = Depends(get_task_manager), _=Depends(has_permission("tasks", "WRITE")), ): with belief_scope("resolve_task"): try: await task_manager.resolve_task(task_id, request.resolution_params) return task_manager.get_task(task_id) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) # #endregion resolve_task # #region resume_task [C:2] [TYPE Function] # @BRIEF Resume a task that is awaiting input (e.g., passwords). # @PRE: task must be in AWAITING_INPUT status. # @POST: Task resumes execution with provided input. # @RELATION CALLS -> [TaskManager] @router.post("/{task_id}/resume", response_model=Task) async def resume_task( task_id: str, request: ResumeTaskRequest, task_manager: TaskManager = Depends(get_task_manager), _=Depends(has_permission("tasks", "WRITE")), ): with belief_scope("resume_task"): try: task_manager.resume_task_with_password(task_id, request.passwords) return task_manager.get_task(task_id) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) # #endregion resume_task # #region clear_tasks [C:2] [TYPE Function] # @BRIEF Clear tasks matching the status filter. # @PRE: task_manager is available. # @POST: Tasks are removed from memory/persistence. # @RELATION CALLS -> [TaskManager] @router.delete("", status_code=status.HTTP_204_NO_CONTENT) async def clear_tasks( status: TaskStatus | None = None, task_manager: TaskManager = Depends(get_task_manager), _=Depends(has_permission("tasks", "WRITE")), ): with belief_scope("clear_tasks", f"status={status}"): task_manager.clear_tasks(status) return # #endregion clear_tasks # #endregion TasksRouter