feat: make output types workflow-first contracts

This commit is contained in:
2026-04-08 21:43:55 +02:00
parent bd18cccb5e
commit 8c9648d5dc
8 changed files with 1049 additions and 110 deletions
+159 -2
View File
@@ -9,11 +9,24 @@ from sqlalchemy.dialects.postgresql import JSONB
from app.database import get_db
from app.models.order_line import OrderLine
from app.models.output_type import OutputType, VALID_RENDER_BACKENDS
from app.models.output_type import (
OUTPUT_TYPE_ARTIFACT_KINDS,
OUTPUT_TYPE_WORKFLOW_FAMILIES,
OutputType,
VALID_RENDER_BACKENDS,
)
from app.schemas.output_type import OutputTypeCreate, OutputTypeOut, OutputTypePatch
from app.utils.auth import get_current_user, require_admin_or_pm
from app.models.user import User
from app.domains.rendering.models import WorkflowDefinition
from app.domains.rendering.output_type_contracts import (
apply_invocation_overrides_to_render_settings,
infer_output_type_artifact_kind,
infer_workflow_family_from_config,
merge_output_type_invocation_overrides,
normalize_invocation_overrides,
validate_output_type_contract,
)
router = APIRouter(prefix="/output-types", tags=["output-types"])
@@ -44,6 +57,54 @@ async def _enrich_workflow_names(db: AsyncSession, items: list[OutputTypeOut]) -
return items
async def _validate_output_type_workflow_link(
db: AsyncSession,
*,
workflow_definition_id: uuid.UUID | None,
workflow_family: str,
) -> None:
if workflow_definition_id is None:
return
workflow_definition = await db.get(WorkflowDefinition, workflow_definition_id)
if workflow_definition is None:
raise HTTPException(400, detail="Workflow definition not found")
try:
workflow_family_inferred = infer_workflow_family_from_config(workflow_definition.config)
except Exception as exc:
raise HTTPException(400, detail=f"Workflow definition has invalid config: {exc}") from exc
if workflow_family_inferred == "mixed":
raise HTTPException(400, detail="Output types cannot link mixed-family workflows")
if workflow_family_inferred is not None and workflow_family_inferred != workflow_family:
raise HTTPException(
400,
detail=(
f"Workflow family mismatch: output type expects '{workflow_family}', "
f"but workflow '{workflow_definition.name}' is '{workflow_family_inferred}'"
),
)
def _ensure_output_type_contract_is_valid(
*,
workflow_family: str,
artifact_kind: str,
output_format: str | None,
is_animation: bool,
) -> None:
try:
validate_output_type_contract(
workflow_family=workflow_family,
artifact_kind=artifact_kind,
output_format=output_format,
is_animation=is_animation,
)
except ValueError as exc:
raise HTTPException(400, detail=str(exc)) from exc
@router.get("", response_model=list[OutputTypeOut])
async def list_output_types(
include_inactive: bool = Query(False),
@@ -80,12 +141,48 @@ async def create_output_type(
):
if body.render_backend not in VALID_RENDER_BACKENDS:
raise HTTPException(400, detail=f"Invalid render_backend. Choose: {', '.join(sorted(VALID_RENDER_BACKENDS))}")
if body.workflow_family not in OUTPUT_TYPE_WORKFLOW_FAMILIES:
raise HTTPException(
400,
detail=f"Invalid workflow_family. Choose: {', '.join(sorted(OUTPUT_TYPE_WORKFLOW_FAMILIES))}",
)
existing = await db.execute(select(OutputType).where(OutputType.name == body.name))
if existing.scalar_one_or_none():
raise HTTPException(409, detail=f"Output type '{body.name}' already exists")
ot = OutputType(**body.model_dump())
data = body.model_dump()
explicit_invocation = normalize_invocation_overrides(body.invocation_overrides)
if not explicit_invocation:
explicit_invocation = normalize_invocation_overrides(body.render_settings)
data["invocation_overrides"] = explicit_invocation
data["render_settings"] = apply_invocation_overrides_to_render_settings(
body.render_settings,
explicit_invocation,
)
data["artifact_kind"] = data.get("artifact_kind") or infer_output_type_artifact_kind(
body.output_format,
body.is_animation,
body.workflow_family,
)
if data["artifact_kind"] not in OUTPUT_TYPE_ARTIFACT_KINDS:
raise HTTPException(
400,
detail=f"Invalid artifact_kind. Choose: {', '.join(sorted(OUTPUT_TYPE_ARTIFACT_KINDS))}",
)
_ensure_output_type_contract_is_valid(
workflow_family=body.workflow_family,
artifact_kind=data["artifact_kind"],
output_format=body.output_format,
is_animation=body.is_animation,
)
await _validate_output_type_workflow_link(
db,
workflow_definition_id=body.workflow_definition_id,
workflow_family=body.workflow_family,
)
ot = OutputType(**data)
db.add(ot)
await db.commit()
await db.refresh(ot)
@@ -112,6 +209,66 @@ async def update_output_type(
data = body.model_dump(exclude_unset=True)
if "render_backend" in data and data["render_backend"] not in VALID_RENDER_BACKENDS:
raise HTTPException(400, detail=f"Invalid render_backend. Choose: {', '.join(sorted(VALID_RENDER_BACKENDS))}")
if "workflow_family" in data and data["workflow_family"] not in OUTPUT_TYPE_WORKFLOW_FAMILIES:
raise HTTPException(
400,
detail=f"Invalid workflow_family. Choose: {', '.join(sorted(OUTPUT_TYPE_WORKFLOW_FAMILIES))}",
)
candidate_workflow_family = data.get("workflow_family", ot.workflow_family)
candidate_workflow_definition_id = data.get("workflow_definition_id", ot.workflow_definition_id)
candidate_output_format = data.get("output_format", ot.output_format)
candidate_is_animation = data.get("is_animation", ot.is_animation)
candidate_artifact_kind = data.get("artifact_kind", ot.artifact_kind)
render_settings_supplied = "render_settings" in data
invocation_supplied = "invocation_overrides" in data
if render_settings_supplied or invocation_supplied:
candidate_render_settings = data.get("render_settings", ot.render_settings)
if invocation_supplied:
candidate_invocation_overrides = normalize_invocation_overrides(data.get("invocation_overrides"))
else:
candidate_invocation_overrides = merge_output_type_invocation_overrides(
candidate_render_settings,
None,
)
data["invocation_overrides"] = candidate_invocation_overrides
data["render_settings"] = apply_invocation_overrides_to_render_settings(
candidate_render_settings,
candidate_invocation_overrides,
)
should_recompute_artifact_kind = (
"artifact_kind" in data
or "workflow_family" in data
or "output_format" in data
or "is_animation" in data
)
if should_recompute_artifact_kind:
data["artifact_kind"] = data.get("artifact_kind") or infer_output_type_artifact_kind(
candidate_output_format,
candidate_is_animation,
candidate_workflow_family,
)
candidate_artifact_kind = data["artifact_kind"]
if candidate_artifact_kind not in OUTPUT_TYPE_ARTIFACT_KINDS:
raise HTTPException(
400,
detail=f"Invalid artifact_kind. Choose: {', '.join(sorted(OUTPUT_TYPE_ARTIFACT_KINDS))}",
)
_ensure_output_type_contract_is_valid(
workflow_family=candidate_workflow_family,
artifact_kind=candidate_artifact_kind,
output_format=candidate_output_format,
is_animation=candidate_is_animation,
)
await _validate_output_type_workflow_link(
db,
workflow_definition_id=candidate_workflow_definition_id,
workflow_family=candidate_workflow_family,
)
for field_name, value in data.items():
setattr(ot, field_name, value)