| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223 |
- """Task management router for Web-Local system dispatch (P4-M2)."""
- from typing import Optional, List, Dict, Any, Literal
- from fastapi import APIRouter, HTTPException, Depends
- from pydantic import BaseModel, Field
- from sqlalchemy.orm import Session
- from ..database import get_db
- from ..services.task_manager import get_task_manager
- router = APIRouter(prefix="/api/tasks", tags=["Tasks"])
- class CreateTaskRequest(BaseModel):
- """Request to create a new simulation task."""
- plan_id: Optional[int] = Field(default=None, description="Associated plan ID")
- plan_data: Optional[Dict[str, Any]] = Field(default=None, description="Full plan data (auto-loaded from plan_id when omitted)")
- parameters: Optional[List[Dict[str, Any]]] = Field(default=None, description="Parameter sets (auto-expanded from plan_id when omitted)")
- task_name: Optional[str] = Field(default=None, description="Optional task name")
- priority: int = Field(default=5, ge=1, le=10, description="Task priority (1-10)")
- thermal_mode: Literal["off", "steady", "coupled"] = Field(
- default="steady",
- description="Thermal mode: off (EM only) / steady (EM + steady-state "
- "thermal, default) / coupled (magnetic-thermal coupled).",
- )
- class ProgressUpdateRequest(BaseModel):
- """Request to update task progress."""
- current_point: int = Field(..., ge=0, description="Current point index (0-based)")
- total_points: Optional[int] = Field(default=None, description="Total points")
- current_params: Optional[Dict[str, Any]] = Field(default=None)
- elapsed_time: Optional[float] = Field(default=None)
- class ResultsReportRequest(BaseModel):
- """Request to report final results."""
- results: List[Dict[str, Any]] = Field(..., description="List of simulation results")
- metrics: Optional[Dict[str, Any]] = Field(default=None)
- logs: Optional[str] = Field(default=None)
- duration: Optional[float] = Field(default=None)
- status: str = Field(default="completed", description="completed / failed")
- @router.post("")
- def create_task(request: CreateTaskRequest, db: Session = Depends(get_db)):
- """Create a new simulation task.
- When only plan_id is provided, plan_data is loaded from the plan and
- parameters are expanded from its variables (same logic as the plan's
- one-click start), so the UI no longer requires hand-written JSON.
- Explicit plan_data / parameters still take precedence (advanced override).
- """
- plan_data = request.plan_data
- parameters = request.parameters
- if (not plan_data or not parameters) and request.plan_id is not None:
- from ..models.simulation_plan import SimulationPlan
- from .plans import _expand_plan_to_parameters
- plan = db.query(SimulationPlan).filter(SimulationPlan.id == request.plan_id).first()
- if not plan:
- raise HTTPException(status_code=404, detail=f"Plan {request.plan_id} not found")
- if not plan_data:
- plan_data = plan.get_plan_dict()
- if not parameters:
- parameters = _expand_plan_to_parameters(plan_data)
- if not plan_data:
- raise HTTPException(status_code=400, detail="plan_data is required (or provide a valid plan_id)")
- if not parameters:
- raise HTTPException(status_code=400, detail="parameters is empty (plan has no valid variables to simulate)")
- try:
- manager = get_task_manager()
- task = manager.create_task(
- plan_id=request.plan_id,
- plan_data=plan_data,
- parameters=parameters,
- task_name=request.task_name,
- priority=request.priority,
- thermal_mode=request.thermal_mode,
- )
- return task
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"Task creation failed: {str(e)}")
- @router.get("")
- def list_tasks(
- status: Optional[str] = None,
- plan_id: Optional[int] = None,
- limit: int = 50,
- offset: int = 0,
- ):
- """List tasks with optional filters."""
- manager = get_task_manager()
- return manager.list_tasks(status=status, plan_id=plan_id, limit=limit, offset=offset)
- @router.get("/{task_id}")
- def get_task(task_id: str):
- """Get task details by ID."""
- manager = get_task_manager()
- task = manager.get_task(task_id)
- if not task:
- raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
- return task
- @router.post("/{task_id}/dispatch")
- def dispatch_task(task_id: str):
- """Mark task as dispatched (ready for local executor)."""
- try:
- manager = get_task_manager()
- return manager.dispatch_task(task_id)
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
- @router.post("/{task_id}/progress")
- def update_progress(task_id: str, request: ProgressUpdateRequest):
- """Update task progress from local executor."""
- try:
- manager = get_task_manager()
- return manager.update_progress(
- task_id=task_id,
- current_point=request.current_point,
- total_points=request.total_points,
- current_params=request.current_params,
- elapsed_time=request.elapsed_time,
- )
- except ValueError as e:
- raise HTTPException(status_code=404, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
- @router.post("/{task_id}/results")
- def report_results(task_id: str, request: ResultsReportRequest):
- """Report final results from local executor."""
- try:
- manager = get_task_manager()
- return manager.report_results(
- task_id=task_id,
- results=request.results,
- metrics=request.metrics,
- logs=request.logs,
- duration=request.duration,
- status=request.status,
- )
- except ValueError as e:
- raise HTTPException(status_code=404, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
- @router.get("/{task_id}/results")
- def get_task_results(task_id: str):
- """Get task results file content."""
- manager = get_task_manager()
- results = manager.get_task_results(task_id)
- if results is None:
- raise HTTPException(status_code=404, detail=f"Results for task {task_id} not found")
- return results
- @router.post("/{task_id}/cancel")
- def cancel_task(task_id: str):
- """Cancel a pending or running task."""
- try:
- manager = get_task_manager()
- return manager.cancel_task(task_id)
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
- @router.delete("/{task_id}", status_code=204)
- def delete_task(task_id: str):
- """Delete a finished task (terminal status only)."""
- try:
- manager = get_task_manager()
- manager.delete_task(task_id)
- return None
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
- class BatchDeleteTasksRequest(BaseModel):
- """Request to delete multiple finished tasks."""
- task_ids: List[str] = Field(..., description="Task IDs to delete")
- @router.post("/batch-delete")
- def batch_delete_tasks(request: BatchDeleteTasksRequest):
- """Delete multiple finished tasks; per-item failures do not abort the batch."""
- manager = get_task_manager()
- deleted, errors = [], []
- for task_id in request.task_ids:
- try:
- manager.delete_task(task_id)
- deleted.append(task_id)
- except ValueError as e:
- errors.append({"task_id": task_id, "error": str(e)})
- return {"deleted": deleted, "deleted_count": len(deleted), "errors": errors}
- @router.get("/{task_id}/download")
- def download_task_file(task_id: str):
- """Download task JSON file for local executor."""
- manager = get_task_manager()
- task = manager.get_task(task_id)
- if not task or not task.get("task_file"):
- raise HTTPException(status_code=404, detail=f"Task file for {task_id} not found")
- import os
- if not os.path.exists(task["task_file"]):
- raise HTTPException(status_code=404, detail="Task file not found on disk")
- from fastapi.responses import FileResponse
- return FileResponse(task["task_file"], filename=f"{task_id}_task.json", media_type="application/json")
|