feat(phase7.3-ext): workflow executor + config validation
- workflow_schema.py: WorkflowConfig Pydantic model validates all node
step values against StepName enum, edges reference declared node IDs,
unique node IDs enforced; WorkflowEdge uses "from"/"to" aliases
- workflow_executor.py: dispatch_workflow() validates config, topological-
sorts nodes (Kahn's algorithm, raises on cycles), maps StepName →
Celery task name via STEP_TASK_MAP (all 15 StepName values covered),
dispatches via celery_app.send_task()
- workflow_router.py: config validation on POST/PUT (422 on invalid);
POST /{id}/dispatch?context_id= endpoint (PM+) dispatches workflow
steps as Celery tasks for a given entity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,15 +12,15 @@ Endpoints:
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.domains.auth.models import User
|
||||
from app.utils.auth import get_current_user, require_admin, require_admin_or_pm
|
||||
from app.utils.auth import get_current_user, require_admin, require_admin_or_pm, require_pm_or_above
|
||||
from app.domains.rendering.models import WorkflowDefinition, WorkflowRun
|
||||
from app.domains.rendering.schemas import (
|
||||
WorkflowDefinitionCreate,
|
||||
@@ -28,6 +28,7 @@ from app.domains.rendering.schemas import (
|
||||
WorkflowDefinitionOut,
|
||||
WorkflowRunOut,
|
||||
)
|
||||
from app.domains.rendering.workflow_schema import WorkflowConfig
|
||||
from app.core.process_steps import StepName
|
||||
|
||||
|
||||
@@ -142,6 +143,11 @@ async def create_workflow(
|
||||
_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if body.config:
|
||||
try:
|
||||
WorkflowConfig.model_validate(body.config)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid workflow config: {exc.errors()}")
|
||||
wf = WorkflowDefinition(
|
||||
name=body.name,
|
||||
output_type_id=body.output_type_id,
|
||||
@@ -171,6 +177,10 @@ async def update_workflow(
|
||||
if body.name is not None:
|
||||
wf.name = body.name
|
||||
if body.config is not None:
|
||||
try:
|
||||
WorkflowConfig.model_validate(body.config)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid workflow config: {exc.errors()}")
|
||||
wf.config = body.config
|
||||
if body.is_active is not None:
|
||||
wf.is_active = body.is_active
|
||||
@@ -216,3 +226,50 @@ async def list_workflow_runs(
|
||||
.order_by(WorkflowRun.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class WorkflowDispatchResponse(BaseModel):
|
||||
dispatched: int
|
||||
task_ids: list[str]
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/dispatch", response_model=WorkflowDispatchResponse)
|
||||
async def dispatch_workflow_endpoint(
|
||||
workflow_id: uuid.UUID,
|
||||
context_id: str = Query(
|
||||
...,
|
||||
description=(
|
||||
"UUID of the entity to process. "
|
||||
"For STEP/thumbnail steps this is a cad_file_id; "
|
||||
"for render steps this is an order_line_id."
|
||||
),
|
||||
),
|
||||
_user: User = Depends(require_pm_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Dispatch a workflow's steps as Celery tasks for a given context entity.
|
||||
|
||||
Each node in the workflow config is dispatched as an individual Celery task
|
||||
in topological (dependency) order. Returns the list of Celery task IDs so
|
||||
the caller can track progress.
|
||||
"""
|
||||
from pydantic import ValidationError as _ValidationError
|
||||
from app.domains.rendering.workflow_executor import dispatch_workflow
|
||||
|
||||
result = await db.execute(
|
||||
select(WorkflowDefinition).where(WorkflowDefinition.id == workflow_id)
|
||||
)
|
||||
wf = result.scalar_one_or_none()
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow definition not found")
|
||||
if not wf.config:
|
||||
raise HTTPException(status_code=400, detail="Workflow has no config")
|
||||
|
||||
try:
|
||||
task_ids = dispatch_workflow(wf.config, context_id)
|
||||
except _ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid workflow config: {exc.errors()}")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
return WorkflowDispatchResponse(dispatched=len(task_ids), task_ids=task_ids)
|
||||
|
||||
Reference in New Issue
Block a user