tasks.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. """Task management router for Web-Local system dispatch (P4-M2)."""
  2. from typing import Optional, List, Dict, Any, Literal
  3. from fastapi import APIRouter, HTTPException, Depends
  4. from pydantic import BaseModel, Field
  5. from sqlalchemy.orm import Session
  6. from ..database import get_db
  7. from ..services.task_manager import get_task_manager
  8. router = APIRouter(prefix="/api/tasks", tags=["Tasks"])
  9. class CreateTaskRequest(BaseModel):
  10. """Request to create a new simulation task."""
  11. plan_id: Optional[int] = Field(default=None, description="Associated plan ID")
  12. plan_data: Optional[Dict[str, Any]] = Field(default=None, description="Full plan data (auto-loaded from plan_id when omitted)")
  13. parameters: Optional[List[Dict[str, Any]]] = Field(default=None, description="Parameter sets (auto-expanded from plan_id when omitted)")
  14. task_name: Optional[str] = Field(default=None, description="Optional task name")
  15. priority: int = Field(default=5, ge=1, le=10, description="Task priority (1-10)")
  16. thermal_mode: Literal["off", "steady", "coupled"] = Field(
  17. default="steady",
  18. description="Thermal mode: off (EM only) / steady (EM + steady-state "
  19. "thermal, default) / coupled (magnetic-thermal coupled).",
  20. )
  21. class ProgressUpdateRequest(BaseModel):
  22. """Request to update task progress."""
  23. current_point: int = Field(..., ge=0, description="Current point index (0-based)")
  24. total_points: Optional[int] = Field(default=None, description="Total points")
  25. current_params: Optional[Dict[str, Any]] = Field(default=None)
  26. elapsed_time: Optional[float] = Field(default=None)
  27. class ResultsReportRequest(BaseModel):
  28. """Request to report final results."""
  29. results: List[Dict[str, Any]] = Field(..., description="List of simulation results")
  30. metrics: Optional[Dict[str, Any]] = Field(default=None)
  31. logs: Optional[str] = Field(default=None)
  32. duration: Optional[float] = Field(default=None)
  33. status: str = Field(default="completed", description="completed / failed")
  34. @router.post("")
  35. def create_task(request: CreateTaskRequest, db: Session = Depends(get_db)):
  36. """Create a new simulation task.
  37. When only plan_id is provided, plan_data is loaded from the plan and
  38. parameters are expanded from its variables (same logic as the plan's
  39. one-click start), so the UI no longer requires hand-written JSON.
  40. Explicit plan_data / parameters still take precedence (advanced override).
  41. """
  42. plan_data = request.plan_data
  43. parameters = request.parameters
  44. if (not plan_data or not parameters) and request.plan_id is not None:
  45. from ..models.simulation_plan import SimulationPlan
  46. from .plans import _expand_plan_to_parameters
  47. plan = db.query(SimulationPlan).filter(SimulationPlan.id == request.plan_id).first()
  48. if not plan:
  49. raise HTTPException(status_code=404, detail=f"Plan {request.plan_id} not found")
  50. if not plan_data:
  51. plan_data = plan.get_plan_dict()
  52. if not parameters:
  53. parameters = _expand_plan_to_parameters(plan_data)
  54. if not plan_data:
  55. raise HTTPException(status_code=400, detail="plan_data is required (or provide a valid plan_id)")
  56. if not parameters:
  57. raise HTTPException(status_code=400, detail="parameters is empty (plan has no valid variables to simulate)")
  58. try:
  59. manager = get_task_manager()
  60. task = manager.create_task(
  61. plan_id=request.plan_id,
  62. plan_data=plan_data,
  63. parameters=parameters,
  64. task_name=request.task_name,
  65. priority=request.priority,
  66. thermal_mode=request.thermal_mode,
  67. )
  68. return task
  69. except Exception as e:
  70. raise HTTPException(status_code=500, detail=f"Task creation failed: {str(e)}")
  71. @router.get("")
  72. def list_tasks(
  73. status: Optional[str] = None,
  74. plan_id: Optional[int] = None,
  75. limit: int = 50,
  76. offset: int = 0,
  77. ):
  78. """List tasks with optional filters."""
  79. manager = get_task_manager()
  80. return manager.list_tasks(status=status, plan_id=plan_id, limit=limit, offset=offset)
  81. @router.get("/{task_id}")
  82. def get_task(task_id: str):
  83. """Get task details by ID."""
  84. manager = get_task_manager()
  85. task = manager.get_task(task_id)
  86. if not task:
  87. raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
  88. return task
  89. @router.post("/{task_id}/dispatch")
  90. def dispatch_task(task_id: str):
  91. """Mark task as dispatched (ready for local executor)."""
  92. try:
  93. manager = get_task_manager()
  94. return manager.dispatch_task(task_id)
  95. except ValueError as e:
  96. raise HTTPException(status_code=400, detail=str(e))
  97. except Exception as e:
  98. raise HTTPException(status_code=500, detail=str(e))
  99. @router.post("/{task_id}/progress")
  100. def update_progress(task_id: str, request: ProgressUpdateRequest):
  101. """Update task progress from local executor."""
  102. try:
  103. manager = get_task_manager()
  104. return manager.update_progress(
  105. task_id=task_id,
  106. current_point=request.current_point,
  107. total_points=request.total_points,
  108. current_params=request.current_params,
  109. elapsed_time=request.elapsed_time,
  110. )
  111. except ValueError as e:
  112. raise HTTPException(status_code=404, detail=str(e))
  113. except Exception as e:
  114. raise HTTPException(status_code=500, detail=str(e))
  115. @router.post("/{task_id}/results")
  116. def report_results(task_id: str, request: ResultsReportRequest):
  117. """Report final results from local executor."""
  118. try:
  119. manager = get_task_manager()
  120. return manager.report_results(
  121. task_id=task_id,
  122. results=request.results,
  123. metrics=request.metrics,
  124. logs=request.logs,
  125. duration=request.duration,
  126. status=request.status,
  127. )
  128. except ValueError as e:
  129. raise HTTPException(status_code=404, detail=str(e))
  130. except Exception as e:
  131. raise HTTPException(status_code=500, detail=str(e))
  132. @router.get("/{task_id}/results")
  133. def get_task_results(task_id: str):
  134. """Get task results file content."""
  135. manager = get_task_manager()
  136. results = manager.get_task_results(task_id)
  137. if results is None:
  138. raise HTTPException(status_code=404, detail=f"Results for task {task_id} not found")
  139. return results
  140. @router.post("/{task_id}/cancel")
  141. def cancel_task(task_id: str):
  142. """Cancel a pending or running task."""
  143. try:
  144. manager = get_task_manager()
  145. return manager.cancel_task(task_id)
  146. except ValueError as e:
  147. raise HTTPException(status_code=400, detail=str(e))
  148. except Exception as e:
  149. raise HTTPException(status_code=500, detail=str(e))
  150. @router.delete("/{task_id}", status_code=204)
  151. def delete_task(task_id: str):
  152. """Delete a finished task (terminal status only)."""
  153. try:
  154. manager = get_task_manager()
  155. manager.delete_task(task_id)
  156. return None
  157. except ValueError as e:
  158. raise HTTPException(status_code=400, detail=str(e))
  159. except Exception as e:
  160. raise HTTPException(status_code=500, detail=str(e))
  161. class BatchDeleteTasksRequest(BaseModel):
  162. """Request to delete multiple finished tasks."""
  163. task_ids: List[str] = Field(..., description="Task IDs to delete")
  164. @router.post("/batch-delete")
  165. def batch_delete_tasks(request: BatchDeleteTasksRequest):
  166. """Delete multiple finished tasks; per-item failures do not abort the batch."""
  167. manager = get_task_manager()
  168. deleted, errors = [], []
  169. for task_id in request.task_ids:
  170. try:
  171. manager.delete_task(task_id)
  172. deleted.append(task_id)
  173. except ValueError as e:
  174. errors.append({"task_id": task_id, "error": str(e)})
  175. return {"deleted": deleted, "deleted_count": len(deleted), "errors": errors}
  176. @router.get("/{task_id}/download")
  177. def download_task_file(task_id: str):
  178. """Download task JSON file for local executor."""
  179. manager = get_task_manager()
  180. task = manager.get_task(task_id)
  181. if not task or not task.get("task_file"):
  182. raise HTTPException(status_code=404, detail=f"Task file for {task_id} not found")
  183. import os
  184. if not os.path.exists(task["task_file"]):
  185. raise HTTPException(status_code=404, detail="Task file not found on disk")
  186. from fastapi.responses import FileResponse
  187. return FileResponse(task["task_file"], filename=f"{task_id}_task.json", media_type="application/json")