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
@@ -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,