chore: initialize insar management system v2
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from ..config import read_int_env
|
||||
from ..database import get_db
|
||||
from ..models import AuthUserORM, WorkflowRunORM, WorkflowStepORM
|
||||
from ..services.workflow_service import workflow_service
|
||||
from .dependencies import _require_admin
|
||||
|
||||
router = APIRouter()
|
||||
WORKFLOW_MAX_STEPS = read_int_env(
|
||||
"WORKFLOW_MAX_STEPS",
|
||||
200,
|
||||
minimum=1,
|
||||
maximum=5000,
|
||||
)
|
||||
WORKFLOW_MAX_DEPENDS = read_int_env(
|
||||
"WORKFLOW_MAX_DEPENDS",
|
||||
32,
|
||||
minimum=0,
|
||||
maximum=500,
|
||||
)
|
||||
WORKFLOW_TEXT_MAX_LENGTH = read_int_env(
|
||||
"WORKFLOW_TEXT_MAX_LENGTH",
|
||||
128,
|
||||
minimum=16,
|
||||
maximum=1024,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowStepCreate(BaseModel):
|
||||
step_id: str = Field(max_length=WORKFLOW_TEXT_MAX_LENGTH)
|
||||
step_name: Optional[str] = Field(default=None, max_length=WORKFLOW_TEXT_MAX_LENGTH)
|
||||
job_type: str = Field(max_length=WORKFLOW_TEXT_MAX_LENGTH)
|
||||
payload: Optional[dict] = None
|
||||
depends_on: List[str] = Field(default_factory=list)
|
||||
optional: bool = False
|
||||
task_id: Optional[str] = Field(default=None, max_length=WORKFLOW_TEXT_MAX_LENGTH)
|
||||
|
||||
@field_validator("step_id", "job_type", mode="before")
|
||||
@classmethod
|
||||
def _normalize_required_text(cls, value):
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("Field must not be empty.")
|
||||
return normalized
|
||||
|
||||
@field_validator("step_name", "task_id", mode="before")
|
||||
@classmethod
|
||||
def _normalize_optional_text(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
return normalized or None
|
||||
|
||||
@field_validator("depends_on", mode="before")
|
||||
@classmethod
|
||||
def _validate_depends_on(cls, value):
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("depends_on must be a list.")
|
||||
if len(value) > WORKFLOW_MAX_DEPENDS:
|
||||
raise ValueError(f"depends_on exceeds max count ({WORKFLOW_MAX_DEPENDS}).")
|
||||
|
||||
normalized: List[str] = []
|
||||
for raw in value:
|
||||
dep = str(raw or "").strip()
|
||||
if not dep:
|
||||
continue
|
||||
if len(dep) > WORKFLOW_TEXT_MAX_LENGTH:
|
||||
raise ValueError(
|
||||
f"depends_on item is too long (max {WORKFLOW_TEXT_MAX_LENGTH} chars)."
|
||||
)
|
||||
if dep not in normalized:
|
||||
normalized.append(dep)
|
||||
return normalized
|
||||
|
||||
|
||||
class WorkflowRunCreate(BaseModel):
|
||||
workflow_name: str = Field(max_length=WORKFLOW_TEXT_MAX_LENGTH)
|
||||
steps: List[WorkflowStepCreate]
|
||||
params: Optional[dict] = None
|
||||
tags: Optional[dict] = None
|
||||
created_by: Optional[str] = Field(default=None, max_length=WORKFLOW_TEXT_MAX_LENGTH)
|
||||
|
||||
@field_validator("workflow_name", mode="before")
|
||||
@classmethod
|
||||
def _normalize_workflow_name(cls, value):
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("workflow_name must not be empty.")
|
||||
return normalized
|
||||
|
||||
@field_validator("created_by", mode="before")
|
||||
@classmethod
|
||||
def _normalize_created_by(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
return normalized or None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_steps(self):
|
||||
if not self.steps:
|
||||
raise ValueError("steps must not be empty.")
|
||||
if len(self.steps) > WORKFLOW_MAX_STEPS:
|
||||
raise ValueError(f"steps exceeds max count ({WORKFLOW_MAX_STEPS}).")
|
||||
|
||||
step_ids = [step.step_id for step in self.steps]
|
||||
if len(step_ids) != len(set(step_ids)):
|
||||
raise ValueError("step_id values must be unique within one workflow run.")
|
||||
|
||||
step_id_set = set(step_ids)
|
||||
graph = {}
|
||||
for step in self.steps:
|
||||
graph[step.step_id] = list(step.depends_on or [])
|
||||
for dep in step.depends_on:
|
||||
if dep not in step_id_set:
|
||||
raise ValueError(
|
||||
f"Step '{step.step_id}' depends on unknown step_id '{dep}'."
|
||||
)
|
||||
if dep == step.step_id:
|
||||
raise ValueError(
|
||||
f"Step '{step.step_id}' cannot depend on itself."
|
||||
)
|
||||
|
||||
# Detect dependency cycles early to avoid runs stuck in PENDING forever.
|
||||
visited = set()
|
||||
in_stack = set()
|
||||
|
||||
def _dfs(node: str) -> None:
|
||||
if node in in_stack:
|
||||
raise ValueError(f"Workflow dependency cycle detected at step '{node}'.")
|
||||
if node in visited:
|
||||
return
|
||||
in_stack.add(node)
|
||||
for dep in graph.get(node, []):
|
||||
_dfs(dep)
|
||||
in_stack.remove(node)
|
||||
visited.add(node)
|
||||
|
||||
for step_id in step_ids:
|
||||
_dfs(step_id)
|
||||
return self
|
||||
|
||||
|
||||
@router.post("/workflow/runs")
|
||||
async def create_workflow_run(request: WorkflowRunCreate, admin_user: AuthUserORM = Depends(_require_admin)):
|
||||
"""
|
||||
创建一个工作流实例并自动入队可执行步骤。
|
||||
"""
|
||||
steps_payload = [
|
||||
{
|
||||
"step_id": s.step_id,
|
||||
"step_name": s.step_name or s.step_id,
|
||||
"job_type": s.job_type,
|
||||
"payload": s.payload or {},
|
||||
"depends_on": s.depends_on or [],
|
||||
"optional": s.optional,
|
||||
"task_id": s.task_id,
|
||||
}
|
||||
for s in request.steps
|
||||
]
|
||||
try:
|
||||
run_id = await workflow_service.create_run(
|
||||
workflow_name=request.workflow_name,
|
||||
steps=steps_payload,
|
||||
params=request.params,
|
||||
tags=request.tags,
|
||||
created_by=request.created_by,
|
||||
)
|
||||
return {"run_id": run_id}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/workflow/runs/{run_id}")
|
||||
async def get_workflow_run(run_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
获取工作流运行状态与步骤列表。
|
||||
"""
|
||||
run_res = await db.execute(
|
||||
select(WorkflowRunORM).where(WorkflowRunORM.run_id == run_id)
|
||||
)
|
||||
run = run_res.scalar_one_or_none()
|
||||
if not run:
|
||||
raise HTTPException(status_code=404, detail="Workflow run not found.")
|
||||
|
||||
steps_res = await db.execute(
|
||||
select(WorkflowStepORM).where(WorkflowStepORM.run_id == run_id).order_by(WorkflowStepORM.id)
|
||||
)
|
||||
steps = steps_res.scalars().all()
|
||||
|
||||
return {
|
||||
"run": {
|
||||
"run_id": run.run_id,
|
||||
"workflow_name": run.workflow_name,
|
||||
"status": run.status,
|
||||
"params": run.params,
|
||||
"tags": run.tags,
|
||||
"created_by": run.created_by,
|
||||
"created_at": run.created_at,
|
||||
"updated_at": run.updated_at,
|
||||
"started_at": run.started_at,
|
||||
"ended_at": run.ended_at,
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"step_id": s.step_id,
|
||||
"step_name": s.step_name,
|
||||
"status": s.status,
|
||||
"depends_on": s.depends_on,
|
||||
"params": s.params,
|
||||
"outputs": s.outputs,
|
||||
"error": s.error,
|
||||
"created_at": s.created_at,
|
||||
"updated_at": s.updated_at,
|
||||
"started_at": s.started_at,
|
||||
"ended_at": s.ended_at,
|
||||
}
|
||||
for s in steps
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user