Files
ss-tools/backend/src/api/routes/tasks.py
2026-05-19 18:36:15 +03:00

377 lines
14 KiB
Python
Executable File

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