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:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user