feat: workflow graph system — blocks 1-20 complete checkpoint

Full graph-based workflow execution engine with:
- WorkflowGraphRuntime with two-phase dispatch (collect → fire Celery tasks)
- Node registry with contract validation, socket types, execution kinds
- WorkflowRuntimeServices: preflight, context resolution, shadow-mode A/B
- WorkflowRouter: CRUD + dispatch/preflight/run-history API endpoints
- OutputTypeContracts: workflow binding, rollout-mode resolution
- Admin router: output-type workflow binding endpoints
- Frontend: complete workflow editor with drag-drop canvas, node inspector,
  module bundles, reference bundles, preflight panel, validation banner,
  authoring guidance, blueprint templates, shadow/rollout gate UI
- Tests: comprehensive coverage for all workflow modules
- Docs: NODE_CONTRACT_AUDIT and VALIDATION_ERROR_INVENTORY

All 20 blocks from NEXT_20_BLOCK_BATCH_PLAN completed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 19:09:54 +02:00
co-authored by Claude Sonnet 4.6
parent c51dd8cd67
commit d2e4934cca
63 changed files with 7035 additions and 2140 deletions
+8
View File
@@ -735,6 +735,14 @@ async def seed_workflows(
"name": "Still Graph Blueprint",
"config": build_workflow_blueprint_config("still_graph_reference"),
},
{
"name": "Still Graph Alpha Blueprint",
"config": build_workflow_blueprint_config("still_graph_alpha_reference"),
},
{
"name": "Still Graph Blend Blueprint",
"config": build_workflow_blueprint_config("still_graph_blend_reference"),
},
]
existing_result = await db.execute(select(WorkflowDefinition))
@@ -395,20 +395,35 @@ def derive_supported_artifact_kinds_from_workflow_config(
return {"model_export"}
if step == StepName.THUMBNAIL_SAVE.value:
return {"thumbnail_image"}
upstream_steps = _collect_upstream_steps(node_id)
if step == StepName.NOTIFY.value:
kinds: set[OutputTypeArtifactKind] = set()
if StepName.BLENDER_STILL.value in upstream_steps:
kinds.add("still_image")
if StepName.BLENDER_TURNTABLE.value in upstream_steps:
kinds.add("turntable_video")
if StepName.EXPORT_BLEND.value in upstream_steps:
kinds.add("blend_asset")
return kinds
if step != StepName.OUTPUT_SAVE.value:
return set()
upstream_steps = _collect_upstream_steps(node_id)
has_still = StepName.BLENDER_STILL.value in upstream_steps
has_turntable = StepName.BLENDER_TURNTABLE.value in upstream_steps
has_blend = StepName.EXPORT_BLEND.value in upstream_steps
kinds: set[OutputTypeArtifactKind] = set()
if has_blend:
kinds.add("blend_asset")
if has_still and has_turntable:
return set()
return kinds
if has_turntable:
return {"turntable_video"}
kinds.add("turntable_video")
return kinds
if has_still:
return {"still_image"}
return set()
kinds.add("still_image")
return kinds
supported: set[OutputTypeArtifactKind] = set()
for terminal_id in derive_workflow_terminal_node_ids(normalized):
+2
View File
@@ -347,6 +347,8 @@ class WorkflowOrderLineContextOptionOut(BaseModel):
value: uuid.UUID
label: str
meta: str
is_renderable: bool = True
renderability_reason: str | None = None
class WorkflowOrderLineContextGroupOut(BaseModel):
@@ -19,7 +19,13 @@ _PRESET_TYPES = {
}
_EXECUTION_MODES = {"legacy", "graph", "shadow"}
_WORKFLOW_BLUEPRINTS = {"cad_intake", "order_rendering", "still_graph_reference"}
_WORKFLOW_BLUEPRINTS = {
"cad_intake",
"order_rendering",
"still_graph_reference",
"still_graph_alpha_reference",
"still_graph_blend_reference",
}
_WORKFLOW_STARTERS = {"cad_file", "order_line"}
_WORKFLOW_STARTER_BLUEPRINTS = {
"starter_cad_intake": "cad_file",
@@ -74,9 +80,16 @@ def _extract_render_params_from_nodes(nodes: list[dict[str, Any]], step: StepNam
return {}
def _build_order_line_still_graph_nodes(render_params: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
def _build_order_line_still_graph_nodes(
render_params: dict[str, Any],
*,
transparent_bg: bool | None = None,
include_blend_export: bool = False,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
graph_render_params = deepcopy(render_params)
graph_render_params.setdefault("use_custom_render_settings", False)
if transparent_bg is not None:
graph_render_params["transparent_bg"] = transparent_bg
nodes = [
_make_node("setup", StepName.ORDER_LINE_SETUP, 0, 160, label="Order Line Setup"),
@@ -108,6 +121,29 @@ def _build_order_line_still_graph_nodes(render_params: dict[str, Any]) -> tuple[
{"from": "render", "to": "output"},
{"from": "render", "to": "notify"},
]
if include_blend_export:
nodes.extend(
[
_make_node("blend_export", StepName.EXPORT_BLEND, 920, 360, label="Export Blend"),
_make_node(
"save_blend",
StepName.OUTPUT_SAVE,
1160,
320,
params={"expected_artifact_role": "blend_export"},
label="Save Blend Output",
),
_make_node("notify_blend", StepName.NOTIFY, 1160, 420, label="Notify Blend Export"),
]
)
edges.extend(
[
{"from": "setup", "to": "blend_export"},
{"from": "template", "to": "blend_export"},
{"from": "blend_export", "to": "save_blend"},
{"from": "blend_export", "to": "notify_blend"},
]
)
return nodes, edges
@@ -329,6 +365,16 @@ def build_workflow_blueprint_config(blueprint: str) -> dict[str, Any]:
nodes, edges = _build_order_line_still_graph_nodes(
{"render_engine": "cycles", "samples": 256, "width": 1920, "height": 1080}
)
elif blueprint == "still_graph_alpha_reference":
nodes, edges = _build_order_line_still_graph_nodes(
{"render_engine": "cycles", "samples": 256, "width": 1920, "height": 1080},
transparent_bg=True,
)
elif blueprint == "still_graph_blend_reference":
nodes, edges = _build_order_line_still_graph_nodes(
{"render_engine": "cycles", "samples": 256, "width": 1920, "height": 1080},
include_blend_export=True,
)
return {
"version": 1,
@@ -336,7 +382,7 @@ def build_workflow_blueprint_config(blueprint: str) -> dict[str, Any]:
"edges": edges,
"ui": {
"preset": "custom",
"execution_mode": "graph" if blueprint == "still_graph_reference" else "legacy",
"execution_mode": "graph" if blueprint.startswith("still_graph_") else "legacy",
"family": "cad_file" if blueprint == "cad_intake" else "order_line",
"blueprint": blueprint,
},
@@ -142,6 +142,44 @@ _THUMBNAIL_TASK_KEYS = {
"transparent_bg",
}
_RESOLVE_TEMPLATE_RUNTIME_KEYS = {
"template_id_override",
"material_library_path",
"require_template",
"disable_materials",
"target_collection",
"material_replace_mode",
"lighting_only_mode",
"shadow_catcher_mode",
"camera_orbit_mode",
}
_MATERIAL_MAP_RUNTIME_KEYS = {
"material_override",
"disable_materials",
}
_AUTO_POPULATE_RUNTIME_KEYS = {
"persist_updates",
"refresh_material_source",
"include_populated_products",
}
_GLB_BBOX_RUNTIME_KEYS = {
"glb_path",
"source_preference",
}
_OUTPUT_SAVE_RUNTIME_KEYS = {
"expected_artifact_role",
"require_upstream_artifact",
}
_NOTIFY_RUNTIME_KEYS = {
"channel",
"require_armed_render",
}
_AUTHORITATIVE_RENDER_SETTING_KEYS = {
"render_engine",
"engine",
@@ -1135,16 +1173,25 @@ def _execute_glb_bbox(
node_params: dict[str, Any],
) -> tuple[dict[str, Any], str, str | None]:
del node
del session, workflow_context
if state.setup is None or state.setup.cad_file is None:
if state.setup is not None and state.setup.status == "skip":
return _serialize_setup_result(state.setup), "skipped", state.setup.reason
raise WorkflowGraphRuntimeError("glb_bbox requires a resolved cad_file")
cad_file: CadFile | None = None
glb_path = node_params.get("glb_path")
step_path = state.setup.cad_file.stored_path
if state.setup is not None and state.setup.cad_file is not None:
if state.setup.status == "skip":
return _serialize_setup_result(state.setup), "skipped", state.setup.reason
cad_file = state.setup.cad_file
else:
cad_file = _resolve_cad_file_context(session, workflow_context, state)
step_path = cad_file.stored_path
glb_path = node_params.get("glb_path")
source_preference = str(node_params.get("source_preference") or "auto")
if glb_path is None and source_preference != "step_only" and state.setup.glb_reuse_path is not None:
if (
glb_path is None
and source_preference != "step_only"
and state.setup is not None
and state.setup.glb_reuse_path is not None
):
glb_path = str(state.setup.glb_reuse_path)
elif glb_path is None and source_preference != "step_only":
step_file = Path(step_path)
@@ -1058,10 +1058,10 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
input_contract={
"context": "order_line",
"requires": ["order_line_context"],
"requires_any": ["rendered_image", "rendered_frames", "rendered_video"],
"requires_any": ["rendered_image", "rendered_frames", "rendered_video", "blend_asset"],
},
output_contract={"context": "order_line", "provides": ["media_asset", "workflow_result"]},
artifact_roles_consumed=["rendered_image", "rendered_frames", "rendered_video"],
artifact_roles_consumed=["rendered_image", "rendered_frames", "rendered_video", "blend_asset"],
artifact_roles_produced=["media_asset", "workflow_result"],
),
_definition(
@@ -1140,10 +1140,16 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
input_contract={
"context": "order_line",
"requires": ["order_line_context"],
"requires_any": ["rendered_image", "rendered_frames", "rendered_video", "workflow_result"],
"requires_any": [
"rendered_image",
"rendered_frames",
"rendered_video",
"workflow_result",
"blend_asset",
],
},
output_contract={"context": "order_line", "provides": ["notification_event"]},
artifact_roles_consumed=["workflow_result"],
artifact_roles_consumed=["workflow_result", "blend_asset"],
artifact_roles_produced=["notification_event"],
),
]
@@ -105,7 +105,6 @@ _ORDER_LINE_SETUP_REQUIRED_STEPS = {
StepName.RESOLVE_TEMPLATE,
StepName.MATERIAL_MAP_RESOLVE,
StepName.AUTO_POPULATE_MATERIALS,
StepName.GLB_BBOX,
StepName.BLENDER_STILL,
StepName.BLENDER_TURNTABLE,
StepName.OUTPUT_SAVE,
@@ -322,6 +321,23 @@ def _format_order_line_context_label(order: Order, line: OrderLine) -> tuple[str
)
def _get_order_line_context_renderability(
order: Order,
line: OrderLine,
*,
allow_completed_order_rerender: bool = False,
) -> tuple[bool, str | None]:
if line.render_status == "cancelled":
return False, "line_cancelled"
if order.status == OrderStatus.rejected:
return False, "order_closed"
if order.status == OrderStatus.completed and not allow_completed_order_rerender:
return False, "order_closed"
if line.product is None or line.product.cad_file_id is None:
return False, "missing_cad_file"
return True, None
def _issue(
severity: str,
code: str,
@@ -350,6 +366,16 @@ def _node_status(issues: list[WorkflowPreflightIssueOut], *, supported: bool) ->
def _infer_expected_context_kind(ordered_nodes: list) -> str:
concrete_families = {
definition.family
for node in ordered_nodes
if (definition := get_node_definition(node.step)) is not None
and definition.family in {"cad_file", "order_line"}
}
if concrete_families == {"order_line"}:
return "order_line"
if concrete_families == {"cad_file"}:
return "cad_file"
if any(node.step in _ORDER_LINE_RUNTIME_STEPS for node in ordered_nodes):
return "order_line"
return "cad_file"
@@ -444,7 +470,12 @@ def _build_workflow_preflight_for_config(
)
if context_kind == "order_line" and order_line is not None:
setup = prepare_order_line_render_context(session, str(order_line.id), persist_state=False)
setup = prepare_order_line_render_context(
session,
str(order_line.id),
persist_state=False,
allow_completed_order_rerender=True,
)
if setup.order_line is not None:
resolved_order_line_id = setup.order_line.id
if setup.cad_file is not None:
@@ -567,6 +598,35 @@ def _build_workflow_preflight_for_config(
)
)
if node.step == StepName.GLB_BBOX and context_kind == "order_line":
setup_indices = [
node_indices[candidate.id]
for candidate in workflow_context.ordered_nodes
if candidate.step == StepName.ORDER_LINE_SETUP
]
has_prior_setup = any(index < node_indices[node.id] for index in setup_indices)
if not has_prior_setup:
node_issues.append(
_issue(
"error",
"missing_order_line_setup",
"This node requires an earlier order_line_setup node when used with an order-line context.",
node_id=node.id,
step=node.step.value,
)
)
elif setup is None or not setup.is_ready:
reason = setup.reason if setup is not None else "order_line_setup_not_executed"
node_issues.append(
_issue(
"error",
"setup_not_ready",
f"Order-line setup is not ready for this node: {reason}.",
node_id=node.id,
step=node.step.value,
)
)
if node.step in _RESOLVE_TEMPLATE_RECOMMENDED_STEPS:
template_indices = [
node_indices[candidate.id]
@@ -763,15 +823,23 @@ async def list_workflow_order_line_contexts(
options: list[WorkflowOrderLineContextOptionOut] = []
for line in order.lines:
label, meta = _format_order_line_context_label(order, line)
is_renderable, renderability_reason = _get_order_line_context_renderability(
order,
line,
allow_completed_order_rerender=True,
)
options.append(
WorkflowOrderLineContextOptionOut(
value=line.id,
label=label,
meta=meta,
is_renderable=is_renderable,
renderability_reason=renderability_reason,
)
)
if options:
options.sort(key=lambda option: (not option.is_renderable, option.label.lower()))
groups.append(
WorkflowOrderLineContextGroupOut(
order_id=order.id,
@@ -780,6 +848,12 @@ async def list_workflow_order_line_contexts(
)
)
groups.sort(
key=lambda group: (
not any(option.is_renderable for option in group.options),
group.order_label.lower(),
)
)
return groups
@@ -948,7 +1022,10 @@ async def _dispatch_workflow_for_config(
workflow_config: dict,
context_id: str,
) -> WorkflowDispatchResponse:
from app.domains.rendering.workflow_executor import prepare_workflow_context
from app.domains.rendering.workflow_executor import (
prepare_workflow_context,
submit_prepared_workflow_tasks,
)
from app.domains.rendering.workflow_graph_runtime import execute_graph_workflow
from app.domains.rendering.workflow_run_service import (
create_workflow_run,