feat: execute workflow bridge nodes in graph runtime

This commit is contained in:
2026-04-07 10:42:59 +02:00
parent 6ad34ceed2
commit c17b7d2e8f
7 changed files with 699 additions and 22 deletions
@@ -1,6 +1,7 @@
from __future__ import annotations
import uuid
from pathlib import Path
import pytest
from sqlalchemy import select
@@ -8,7 +9,7 @@ from sqlalchemy.orm import selectinload
from app.config import settings
from app.domains.orders.models import Order, OrderLine
from app.domains.products.models import Product
from app.domains.products.models import CadFile, Product
from app.domains.rendering.dispatch_service import dispatch_render_with_workflow
from app.domains.rendering.models import OutputType, WorkflowDefinition, WorkflowRun
from app.domains.rendering.workflow_config_utils import build_preset_workflow_config
@@ -70,6 +71,47 @@ async def _seed_order_line(
}
async def _seed_renderable_order_line(
db,
admin_user,
tmp_path: Path,
) -> OrderLine:
step_path = tmp_path / "dispatch" / "product.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
cad_file = CadFile(
original_name="product.step",
stored_path=str(step_path),
file_hash=f"hash-{uuid.uuid4().hex}",
parsed_objects={"objects": ["Body"]},
)
product = Product(
pim_id=f"PIM-{uuid.uuid4().hex[:8]}",
name="Dispatch Product",
category_key="dispatch",
cad_file=cad_file,
cad_part_materials=[{"part_name": "Body", "material": "Steel"}],
)
output_type = OutputType(
name=f"Workflow Output {uuid.uuid4().hex[:8]}",
render_backend="auto",
)
order = Order(
order_number=f"WF-{uuid.uuid4().hex[:10]}",
created_by=admin_user.id,
)
order_line = OrderLine(
order=order,
product=product,
output_type=output_type,
)
db.add_all([cad_file, product, output_type, order, order_line])
await db.commit()
await db.refresh(order_line)
return order_line
@pytest.mark.asyncio
async def test_dispatch_render_with_workflow_falls_back_to_legacy_without_workflow_definition(
db,
@@ -182,9 +224,13 @@ async def test_dispatch_render_with_workflow_falls_back_when_workflow_runtime_pr
async def test_workflow_dispatch_endpoint_returns_workflow_run_with_node_results(
client,
db,
admin_user,
auth_headers,
tmp_path,
monkeypatch,
):
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
order_line = await _seed_renderable_order_line(db, admin_user, tmp_path)
workflow_definition = WorkflowDefinition(
name=f"Dispatch Workflow {uuid.uuid4().hex[:8]}",
config=build_preset_workflow_config("still_with_exports", {"width": 640, "height": 640}),
@@ -200,7 +246,7 @@ async def test_workflow_dispatch_endpoint_returns_workflow_run_with_node_results
calls.append((task_name, args, kwargs))
return type("Result", (), {"id": f"task-{len(calls)}"})()
context_id = str(uuid.uuid4())
context_id = str(order_line.id)
monkeypatch.setattr("app.tasks.celery_app.celery_app.send_task", _fake_send_task)
response = await client.post(
f"/api/workflows/{workflow_definition.id}/dispatch",
@@ -235,6 +281,8 @@ async def test_workflow_dispatch_endpoint_returns_workflow_run_with_node_results
assert node_results["render"]["output"]["task_id"] == "task-1"
assert node_results["blend"]["status"] == "queued"
assert node_results["blend"]["output"]["task_id"] == "task-2"
assert node_results["setup"]["status"] == "skipped"
assert node_results["template"]["status"] == "skipped"
assert node_results["setup"]["status"] == "completed"
assert node_results["setup"]["output"]["order_line_id"] == str(order_line.id)
assert node_results["template"]["status"] == "completed"
assert node_results["template"]["output"]["use_materials"] is False
assert node_results["output"]["status"] == "skipped"
@@ -0,0 +1,241 @@
from __future__ import annotations
import os
import uuid
from pathlib import Path
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine, select, text
from sqlalchemy.orm import Session, selectinload
from app.database import Base
from app.domains.auth.models import User, UserRole
from app.domains.materials.models import AssetLibrary
from app.domains.orders.models import Order, OrderLine, OrderStatus
from app.domains.products.models import CadFile, Product
from app.domains.rendering.models import OutputType, RenderTemplate, WorkflowRun
from app.domains.rendering.workflow_executor import prepare_workflow_context
from app.domains.rendering.workflow_graph_runtime import execute_graph_workflow
from app.domains.rendering.workflow_run_service import create_workflow_run
import app.models # noqa: F401
TEST_DB_URL = os.environ.get(
"TEST_DATABASE_URL",
"postgresql+asyncpg://hartomat:hartomat@localhost:5432/hartomat_test",
).replace("+asyncpg", "")
@pytest.fixture
def sync_session():
engine = create_engine(TEST_DB_URL)
with engine.begin() as conn:
Base.metadata.create_all(conn)
session = Session(engine)
try:
yield session
finally:
session.close()
with engine.begin() as conn:
conn.execute(text("DROP SCHEMA public CASCADE"))
conn.execute(text("CREATE SCHEMA public"))
engine.dispose()
def _seed_renderable_order_line(
session: Session,
tmp_path: Path,
*,
with_blank_materials: bool = False,
) -> OrderLine:
step_path = tmp_path / "cad" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
user = User(
id=uuid.uuid4(),
email=f"graph-{uuid.uuid4().hex[:8]}@test.local",
password_hash="hash",
full_name="Graph Runtime Tester",
role=UserRole.admin,
is_active=True,
)
cad_file = CadFile(
id=uuid.uuid4(),
original_name="bearing.step",
stored_path=str(step_path),
file_hash=f"hash-{uuid.uuid4().hex}",
parsed_objects={"objects": ["InnerRing", "OuterRing"]},
)
product = Product(
id=uuid.uuid4(),
pim_id=f"P-{uuid.uuid4().hex[:8]}",
name="Bearing A",
category_key="bearings",
cad_file_id=cad_file.id,
cad_file=cad_file,
components=[
{"part_name": "InnerRing", "material": "Steel"},
{"part_name": "OuterRing", "material": "Rubber"},
],
cad_part_materials=(
[]
if with_blank_materials
else [
{"part_name": "InnerRing", "material": "Steel raw"},
{"part_name": "OuterRing", "material": "Steel raw"},
]
),
)
output_type = OutputType(
id=uuid.uuid4(),
name=f"Still-{uuid.uuid4().hex[:6]}",
renderer="blender",
output_format="png",
render_settings={"width": 1600, "height": 900},
)
order = Order(
id=uuid.uuid4(),
order_number=f"ORD-{uuid.uuid4().hex[:8]}",
status=OrderStatus.processing,
created_by=user.id,
)
line = OrderLine(
id=uuid.uuid4(),
order_id=order.id,
product_id=product.id,
product=product,
output_type_id=output_type.id,
output_type=output_type,
render_status="pending",
)
session.add_all([user, cad_file, product, output_type, order, line])
session.flush()
session.add(
AssetLibrary(
id=uuid.uuid4(),
name="Default Library",
blend_file_path="/libraries/materials.blend",
is_active=True,
)
)
session.add(
RenderTemplate(
id=uuid.uuid4(),
name="Bearing Studio",
category_key="bearings",
blend_file_path="/templates/bearing.blend",
original_filename="bearing.blend",
target_collection="Product",
material_replace_enabled=True,
lighting_only=False,
is_active=True,
output_types=[output_type],
)
)
session.commit()
return line
def test_execute_graph_workflow_persists_bridge_outputs_and_queues_render_task(
sync_session,
tmp_path,
monkeypatch,
):
from app.config import settings
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
queued_thumbnail: list[tuple[str, dict[str, str]]] = []
line = _seed_renderable_order_line(sync_session, tmp_path, with_blank_materials=True)
monkeypatch.setattr(
"app.domains.pipeline.tasks.render_thumbnail.regenerate_thumbnail.delay",
lambda cad_file_id, part_colors: queued_thumbnail.append((cad_file_id, part_colors)),
)
monkeypatch.setattr(
"app.domains.rendering.workflow_runtime_services.extract_bbox_from_step_cadquery",
lambda step_path: {
"dimensions_mm": {"x": 12.5, "y": 20.0, "z": 7.5},
"bbox_center_mm": {"x": 6.25, "y": 10.0, "z": 3.75},
},
)
monkeypatch.setattr(
"app.tasks.celery_app.celery_app.send_task",
lambda task_name, args, kwargs: SimpleNamespace(id=f"task-{len(args)}"),
)
workflow_context = prepare_workflow_context(
{
"version": 1,
"nodes": [
{"id": "setup", "step": "order_line_setup", "params": {}},
{"id": "template", "step": "resolve_template", "params": {}},
{"id": "materials", "step": "material_map_resolve", "params": {}},
{"id": "autofill", "step": "auto_populate_materials", "params": {}},
{"id": "bbox", "step": "glb_bbox", "params": {}},
{"id": "render", "step": "blender_still", "params": {"width": 1024, "height": 1024}},
],
"edges": [
{"from": "setup", "to": "template"},
{"from": "template", "to": "materials"},
{"from": "materials", "to": "autofill"},
{"from": "autofill", "to": "bbox"},
{"from": "bbox", "to": "render"},
],
},
context_id=str(line.id),
execution_mode="graph",
)
run = create_workflow_run(
sync_session,
workflow_def_id=None,
order_line_id=line.id,
workflow_context=workflow_context,
)
dispatch_result = execute_graph_workflow(sync_session, workflow_context)
sync_session.commit()
refreshed_run = sync_session.execute(
select(WorkflowRun)
.where(WorkflowRun.id == run.id)
.options(selectinload(WorkflowRun.node_results))
).scalar_one()
node_results = {node_result.node_name: node_result for node_result in refreshed_run.node_results}
sync_session.refresh(line.product)
assert dispatch_result.task_ids == ["task-1"]
assert refreshed_run.status == "pending"
assert refreshed_run.celery_task_id == "task-1"
assert node_results["setup"].status == "completed"
assert node_results["setup"].output["cad_file_id"] == str(line.product.cad_file_id)
assert node_results["template"].status == "completed"
assert node_results["template"].output["template_name"] == "Bearing Studio"
assert node_results["materials"].status == "completed"
assert node_results["materials"].output["material_map_count"] == 0
assert node_results["autofill"].status == "completed"
assert node_results["autofill"].output["updated_product_count"] == 1
assert node_results["autofill"].output["queued_thumbnail_regeneration"] is True
assert node_results["bbox"].status == "completed"
assert node_results["bbox"].output["has_bbox"] is True
assert node_results["render"].status == "queued"
assert node_results["render"].output["task_id"] == "task-1"
assert line.product.cad_part_materials == [
{"part_name": "InnerRing", "material": "Steel"},
{"part_name": "OuterRing", "material": "Rubber"},
]
assert queued_thumbnail == [
(
str(line.product.cad_file_id),
{"InnerRing": "Steel", "OuterRing": "Rubber"},
)
]