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:
2026-03-08 20:41:26 +01:00
parent 1cc10d4bbb
commit b41e70cdad
3 changed files with 290 additions and 3 deletions
@@ -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)