diff --git a/backend/app/api/routers/admin.py b/backend/app/api/routers/admin.py index 92171a5..000af14 100644 --- a/backend/app/api/routers/admin.py +++ b/backend/app/api/routers/admin.py @@ -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)) diff --git a/backend/app/domains/rendering/output_type_contracts.py b/backend/app/domains/rendering/output_type_contracts.py index db15fe6..fbf3d3b 100644 --- a/backend/app/domains/rendering/output_type_contracts.py +++ b/backend/app/domains/rendering/output_type_contracts.py @@ -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): diff --git a/backend/app/domains/rendering/schemas.py b/backend/app/domains/rendering/schemas.py index d99b757..5bc4463 100644 --- a/backend/app/domains/rendering/schemas.py +++ b/backend/app/domains/rendering/schemas.py @@ -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): diff --git a/backend/app/domains/rendering/workflow_config_utils.py b/backend/app/domains/rendering/workflow_config_utils.py index e85f24f..858a122 100644 --- a/backend/app/domains/rendering/workflow_config_utils.py +++ b/backend/app/domains/rendering/workflow_config_utils.py @@ -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, }, diff --git a/backend/app/domains/rendering/workflow_graph_runtime.py b/backend/app/domains/rendering/workflow_graph_runtime.py index aba13e7..af66a31 100644 --- a/backend/app/domains/rendering/workflow_graph_runtime.py +++ b/backend/app/domains/rendering/workflow_graph_runtime.py @@ -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) diff --git a/backend/app/domains/rendering/workflow_node_registry.py b/backend/app/domains/rendering/workflow_node_registry.py index b84405d..476a1fb 100644 --- a/backend/app/domains/rendering/workflow_node_registry.py +++ b/backend/app/domains/rendering/workflow_node_registry.py @@ -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"], ), ] diff --git a/backend/app/domains/rendering/workflow_router.py b/backend/app/domains/rendering/workflow_router.py index 11e744a..5818702 100644 --- a/backend/app/domains/rendering/workflow_router.py +++ b/backend/app/domains/rendering/workflow_router.py @@ -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, diff --git a/backend/tests/domains/test_output_types_api.py b/backend/tests/domains/test_output_types_api.py index fd48330..cf24b1b 100644 --- a/backend/tests/domains/test_output_types_api.py +++ b/backend/tests/domains/test_output_types_api.py @@ -244,6 +244,79 @@ async def test_create_output_type_rejects_workflow_artifact_mismatch( assert "blend_asset" in response.json()["detail"] +@pytest.mark.asyncio +async def test_create_output_type_accepts_blend_asset_binding_for_still_graph_blend_blueprint( + client, + db, + auth_headers, +): + workflow = WorkflowDefinition( + name=f"Still Blend Graph {uuid.uuid4().hex[:8]}", + config=build_workflow_blueprint_config("still_graph_blend_reference"), + is_active=True, + ) + db.add(workflow) + await db.commit() + await db.refresh(workflow) + + response = await client.post( + "/api/output-types", + json={ + "name": f"Blend Graph {uuid.uuid4().hex[:8]}", + "renderer": "blender", + "output_format": "blend", + "render_backend": "celery", + "workflow_family": "order_line", + "artifact_kind": "blend_asset", + "workflow_definition_id": str(workflow.id), + "workflow_rollout_mode": "graph", + }, + headers=auth_headers, + ) + + assert response.status_code == 201, response.text + payload = response.json() + assert payload["artifact_kind"] == "blend_asset" + assert payload["workflow_definition_id"] == str(workflow.id) + + +@pytest.mark.asyncio +async def test_create_output_type_accepts_still_binding_for_alpha_graph_blueprint( + client, + db, + auth_headers, +): + workflow = WorkflowDefinition( + name=f"Still Alpha Graph {uuid.uuid4().hex[:8]}", + config=build_workflow_blueprint_config("still_graph_alpha_reference"), + is_active=True, + ) + db.add(workflow) + await db.commit() + await db.refresh(workflow) + + response = await client.post( + "/api/output-types", + json={ + "name": f"Still Alpha {uuid.uuid4().hex[:8]}", + "renderer": "blender", + "output_format": "png", + "render_backend": "celery", + "workflow_family": "order_line", + "artifact_kind": "still_image", + "workflow_definition_id": str(workflow.id), + "workflow_rollout_mode": "graph", + "transparent_bg": True, + }, + headers=auth_headers, + ) + + assert response.status_code == 201, response.text + payload = response.json() + assert payload["artifact_kind"] == "still_image" + assert payload["transparent_bg"] is True + + @pytest.mark.asyncio async def test_create_output_type_rejects_artifact_kind_incompatible_with_family( client, diff --git a/backend/tests/domains/test_workflow_config_utils.py b/backend/tests/domains/test_workflow_config_utils.py index e01d4bb..198506f 100644 --- a/backend/tests/domains/test_workflow_config_utils.py +++ b/backend/tests/domains/test_workflow_config_utils.py @@ -7,6 +7,7 @@ from app.domains.rendering.workflow_config_utils import ( get_workflow_execution_mode, workflow_config_requires_canonicalization, ) +from app.domains.rendering.output_type_contracts import derive_supported_artifact_kinds_from_workflow_config def test_build_preset_workflow_config_creates_canonical_dag(): @@ -368,6 +369,11 @@ def test_build_workflow_blueprint_config_creates_order_rendering_family_graph(): assert any(node["step"] == "blender_turntable" for node in config["nodes"]) assert any(node["step"] == "export_blend" for node in config["nodes"]) assert sum(1 for node in config["nodes"] if node["step"] == "notify") == 3 + assert derive_supported_artifact_kinds_from_workflow_config(config) == ( + "blend_asset", + "still_image", + "turntable_video", + ) def test_build_workflow_blueprint_config_creates_still_graph_reference(): @@ -390,6 +396,33 @@ def test_build_workflow_blueprint_config_creates_still_graph_reference(): ] render_node = next(node for node in config["nodes"] if node["step"] == "blender_still") assert render_node["params"]["use_custom_render_settings"] is False + assert derive_supported_artifact_kinds_from_workflow_config(config) == ("still_image",) + + +def test_build_workflow_blueprint_config_creates_still_graph_alpha_reference(): + config = build_workflow_blueprint_config("still_graph_alpha_reference") + + assert config["version"] == 1 + assert config["ui"]["blueprint"] == "still_graph_alpha_reference" + assert config["ui"]["execution_mode"] == "graph" + render_node = next(node for node in config["nodes"] if node["step"] == "blender_still") + assert render_node["params"]["transparent_bg"] is True + assert derive_supported_artifact_kinds_from_workflow_config(config) == ("still_image",) + + +def test_build_workflow_blueprint_config_creates_still_graph_blend_reference(): + config = build_workflow_blueprint_config("still_graph_blend_reference") + + assert config["version"] == 1 + assert config["ui"]["blueprint"] == "still_graph_blend_reference" + assert config["ui"]["execution_mode"] == "graph" + assert any(node["step"] == "export_blend" for node in config["nodes"]) + save_blend = next(node for node in config["nodes"] if node["id"] == "save_blend") + assert save_blend["params"]["expected_artifact_role"] == "blend_export" + assert derive_supported_artifact_kinds_from_workflow_config(config) == ( + "blend_asset", + "still_image", + ) def test_build_starter_workflow_config_creates_minimal_valid_custom_graph(): diff --git a/backend/tests/domains/test_workflow_dispatch_service.py b/backend/tests/domains/test_workflow_dispatch_service.py index 32d62da..2134cf5 100644 --- a/backend/tests/domains/test_workflow_dispatch_service.py +++ b/backend/tests/domains/test_workflow_dispatch_service.py @@ -143,6 +143,14 @@ def _derive_rollout_mode_from_config(workflow_config: dict | None) -> str: return "legacy_only" +def _assert_generated_task_ids(task_ids: list[str], expected_count: int) -> list[str]: + assert len(task_ids) == expected_count + assert len(set(task_ids)) == expected_count + for task_id in task_ids: + uuid.UUID(task_id) + return task_ids + + async def _seed_order_line( db, admin_user, @@ -420,7 +428,7 @@ async def test_dispatch_render_with_workflow_graph_mode_dispatches_supported_cus monkeypatch.setattr( "app.tasks.celery_app.celery_app.send_task", - lambda task_name, args, kwargs: type("Result", (), {"id": "graph-task-1"})(), + lambda task_name, args, kwargs, **task_options: type("Result", (), {"id": "graph-task-1"})(), ) result = dispatch_render_with_workflow(str(order_line.id)) @@ -437,7 +445,7 @@ async def test_dispatch_render_with_workflow_graph_mode_dispatches_supported_cus assert result["backend"] == "workflow_graph" assert result["execution_mode"] == "graph" - assert result["task_ids"] == ["graph-task-1"] + generated_task_ids = _assert_generated_task_ids(result["task_ids"], 1) assert result["rollout_gate_status"] == "graph_authoritative" assert result["rollout_gate_verdict"] == "pass" assert result["workflow_rollout_ready"] is True @@ -447,6 +455,7 @@ async def test_dispatch_render_with_workflow_graph_mode_dispatches_supported_cus assert node_results["setup"].status == "completed" assert node_results["template"].status == "completed" assert node_results["render"].status == "queued" + assert node_results["render"].output["task_id"] == generated_task_ids[0] assert node_results["render"].output["publish_asset_enabled"] is True assert node_results["render"].output["graph_authoritative_output_enabled"] is False @@ -476,7 +485,7 @@ async def test_dispatch_render_with_workflow_graph_mode_uses_output_save_as_auth calls: list[tuple[str, list[str], dict]] = [] - def _fake_send_task(task_name: str, args: list[str], kwargs: dict): + def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options): calls.append((task_name, args, kwargs)) return type("Result", (), {"id": "graph-output-save-task-1"})() @@ -495,13 +504,14 @@ async def test_dispatch_render_with_workflow_graph_mode_uses_output_save_as_auth node_results = {node_result.node_name: node_result for node_result in run.node_results} assert result["backend"] == "workflow_graph" - assert result["task_ids"] == ["graph-output-save-task-1"] + generated_task_ids = _assert_generated_task_ids(result["task_ids"], 1) assert len(calls) == 1 assert calls[0][0] == "app.domains.rendering.tasks.render_order_line_still_task" assert calls[0][1] == [str(order_line.id)] assert calls[0][2]["publish_asset_enabled"] is False assert calls[0][2]["graph_authoritative_output_enabled"] is True assert calls[0][2]["graph_output_node_ids"] == ["output"] + assert node_results["render"].output["task_id"] == generated_task_ids[0] assert node_results["output"].status == "pending" assert node_results["output"].output["publication_mode"] == "awaiting_graph_authoritative_save" assert node_results["output"].output["handoff_state"] == "armed" @@ -537,7 +547,7 @@ async def test_dispatch_render_with_workflow_graph_mode_canonicalizes_legacy_pre monkeypatch.setattr( "app.tasks.celery_app.celery_app.send_task", - lambda task_name, args, kwargs: type("Result", (), {"id": "legacy-graph-task-1"})(), + lambda task_name, args, kwargs, **task_options: type("Result", (), {"id": "legacy-graph-task-1"})(), ) result = dispatch_render_with_workflow(str(order_line.id)) @@ -554,11 +564,12 @@ async def test_dispatch_render_with_workflow_graph_mode_canonicalizes_legacy_pre assert result["backend"] == "workflow_graph" assert result["execution_mode"] == "graph" - assert result["task_ids"] == ["legacy-graph-task-1"] + generated_task_ids = _assert_generated_task_ids(result["task_ids"], 1) assert run.execution_mode == "graph" assert node_results["setup"].status == "completed" assert node_results["template"].status == "completed" assert node_results["render"].status == "queued" + assert node_results["render"].output["task_id"] == generated_task_ids[0] assert node_results["output"].status == "pending" @@ -674,7 +685,7 @@ async def test_dispatch_render_with_workflow_shadow_mode_keeps_legacy_authoritat calls: list[tuple[str, list[str], dict]] = [] - def _fake_send_task(task_name: str, args: list[str], kwargs: dict): + def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options): calls.append((task_name, args, kwargs)) return type("Result", (), {"id": "shadow-task-1"})() @@ -694,18 +705,20 @@ async def test_dispatch_render_with_workflow_shadow_mode_keeps_legacy_authoritat .options(selectinload(WorkflowRun.node_results)) ) run = run_result.scalar_one() + node_results = {node_result.node_name: node_result for node_result in run.node_results} render_call = calls[0] assert result["backend"] == "legacy" assert result["execution_mode"] == "shadow" assert result["shadow_status"] == "dispatched" - assert result["shadow_task_ids"] == ["shadow-task-1"] + shadow_task_ids = _assert_generated_task_ids(result["shadow_task_ids"], 1) assert result["rollout_gate_status"] == "pending_shadow_verdict" assert result["rollout_gate_verdict"] is None assert result["workflow_rollout_ready"] is False assert result["output_type_rollout_ready"] is False assert run.execution_mode == "shadow" assert run.status == "pending" + assert node_results["render"].output["task_id"] == shadow_task_ids[0] assert render_call[0] == "app.domains.rendering.tasks.render_order_line_still_task" assert render_call[1] == [str(order_line.id)] assert render_call[2]["publish_asset_enabled"] is False @@ -745,7 +758,7 @@ async def test_dispatch_render_with_workflow_shadow_mode_canonicalizes_legacy_pr calls: list[tuple[str, list[str], dict]] = [] - def _fake_send_task(task_name: str, args: list[str], kwargs: dict): + def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options): calls.append((task_name, args, kwargs)) return type("Result", (), {"id": "legacy-shadow-task-1"})() @@ -1021,7 +1034,7 @@ def test_dispatch_render_with_workflow_unit_marks_shadow_dispatch_as_pending_rol ) monkeypatch.setattr( "app.domains.rendering.workflow_graph_runtime.execute_graph_workflow", - lambda *args, **kwargs: SimpleNamespace(task_ids=["shadow-task-1"]), + lambda *args, **kwargs: SimpleNamespace(task_ids=["shadow-task-1"], task_specs=[]), ) result = dispatch_render_with_workflow(order_line_id) @@ -1065,7 +1078,7 @@ async def test_workflow_dispatch_endpoint_returns_workflow_run_with_node_results calls: list[tuple[str, list[str], dict]] = [] - def _fake_send_task(task_name: str, args: list[str], kwargs: dict): + def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options): calls.append((task_name, args, kwargs)) return type("Result", (), {"id": f"task-{len(calls)}"})() @@ -1083,7 +1096,7 @@ async def test_workflow_dispatch_endpoint_returns_workflow_run_with_node_results assert body["context_id"] == context_id assert body["execution_mode"] == "graph" assert body["dispatched"] == 2 - assert body["task_ids"] == ["task-1", "task-2"] + generated_task_ids = _assert_generated_task_ids(body["task_ids"], 2) assert [call[0] for call in calls] == [ "app.domains.rendering.tasks.render_order_line_still_task", "app.domains.rendering.tasks.export_blend_for_order_line_task", @@ -1103,12 +1116,12 @@ async def test_workflow_dispatch_endpoint_returns_workflow_run_with_node_results node_results = {node["node_name"]: node for node in body["workflow_run"]["node_results"]} assert body["workflow_run"]["status"] == "pending" assert body["workflow_run"]["execution_mode"] == "graph" - assert body["workflow_run"]["celery_task_id"] == "task-1" + assert body["workflow_run"]["celery_task_id"] == generated_task_ids[0] assert body["workflow_run"]["order_line_id"] == str(order_line.id) assert node_results["render"]["status"] == "queued" - assert node_results["render"]["output"]["task_id"] == "task-1" + assert node_results["render"]["output"]["task_id"] == generated_task_ids[0] assert node_results["blend"]["status"] == "queued" - assert node_results["blend"]["output"]["task_id"] == "task-2" + assert node_results["blend"]["output"]["task_id"] == generated_task_ids[1] assert node_results["setup"]["status"] == "completed" assert node_results["setup"]["output"]["order_line_id"] == str(order_line.id) assert node_results["template"]["status"] == "completed" @@ -1120,7 +1133,7 @@ async def test_workflow_dispatch_endpoint_returns_workflow_run_with_node_results @pytest.mark.asyncio -async def test_workflow_dispatch_endpoint_rejects_output_save_for_export_blend_only_graph( +async def test_workflow_dispatch_endpoint_arms_output_save_for_export_blend_only_graph( client, db, admin_user, @@ -1141,7 +1154,7 @@ async def test_workflow_dispatch_endpoint_rejects_output_save_for_export_blend_o calls: list[tuple[str, list[str], dict]] = [] - def _fake_send_task(task_name: str, args: list[str], kwargs: dict): + def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options): calls.append((task_name, args, kwargs)) return type("Result", (), {"id": f"task-{len(calls)}"})() @@ -1153,9 +1166,35 @@ async def test_workflow_dispatch_endpoint_rejects_output_save_for_export_blend_o headers=auth_headers, ) - assert response.status_code == 422 - assert "output_save" in response.json()["detail"] - assert calls == [] + assert response.status_code == 200 + body = response.json() + + assert body["context_id"] == context_id + assert body["execution_mode"] == "graph" + assert body["dispatched"] == 1 + generated_task_ids = _assert_generated_task_ids(body["task_ids"], 1) + assert len(calls) == 1 + assert calls[0][0] == "app.domains.rendering.tasks.export_blend_for_order_line_task" + assert calls[0][1] == [context_id] + assert calls[0][2]["workflow_node_id"] == "blend" + assert calls[0][2]["publish_asset_enabled"] is False + assert calls[0][2]["graph_authoritative_output_enabled"] is True + assert calls[0][2]["graph_output_node_ids"] == ["output"] + assert "workflow_run_id" in calls[0][2] + + node_results = {node["node_name"]: node for node in body["workflow_run"]["node_results"]} + assert body["workflow_run"]["status"] == "pending" + assert body["workflow_run"]["execution_mode"] == "graph" + assert body["workflow_run"]["celery_task_id"] == generated_task_ids[0] + assert body["workflow_run"]["order_line_id"] == str(order_line.id) + assert node_results["setup"]["status"] == "completed" + assert node_results["template"]["status"] == "completed" + assert node_results["blend"]["status"] == "queued" + assert node_results["blend"]["output"]["task_id"] == generated_task_ids[0] + assert node_results["output"]["status"] == "pending" + assert node_results["output"]["output"]["publication_mode"] == "awaiting_graph_authoritative_save" + assert node_results["output"]["output"]["handoff_state"] == "armed" + assert node_results["output"]["output"]["handoff_node_ids"] == ["blend"] @pytest.mark.asyncio @@ -1180,7 +1219,7 @@ async def test_workflow_dispatch_endpoint_arms_output_save_for_turntable( calls: list[tuple[str, list[str], dict]] = [] - def _fake_send_task(task_name: str, args: list[str], kwargs: dict): + def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options): calls.append((task_name, args, kwargs)) return type("Result", (), {"id": f"task-{len(calls)}"})() @@ -1198,7 +1237,7 @@ async def test_workflow_dispatch_endpoint_arms_output_save_for_turntable( assert body["context_id"] == context_id assert body["execution_mode"] == "graph" assert body["dispatched"] == 1 - assert body["task_ids"] == ["task-1"] + generated_task_ids = _assert_generated_task_ids(body["task_ids"], 1) assert calls[0][0] == "app.domains.rendering.tasks.render_turntable_task" assert calls[0][1] == [context_id] assert calls[0][2]["workflow_run_id"] == body["workflow_run"]["id"] @@ -1210,6 +1249,7 @@ async def test_workflow_dispatch_endpoint_arms_output_save_for_turntable( node_results = {node["node_name"]: node for node in body["workflow_run"]["node_results"]} assert node_results["turntable"]["status"] == "queued" + assert node_results["turntable"]["output"]["task_id"] == generated_task_ids[0] assert node_results["turntable"]["output"]["predicted_asset_type"] == "turntable" assert node_results["turntable"]["output"]["publish_asset_enabled"] is False assert node_results["turntable"]["output"]["graph_authoritative_output_enabled"] is True @@ -1242,7 +1282,7 @@ async def test_workflow_dispatch_endpoint_arms_notify_handoff_for_render_node( calls: list[tuple[str, list[str], dict]] = [] - def _fake_send_task(task_name: str, args: list[str], kwargs: dict): + def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options): calls.append((task_name, args, kwargs)) return type("Result", (), {"id": "task-1"})() @@ -1260,7 +1300,7 @@ async def test_workflow_dispatch_endpoint_arms_notify_handoff_for_render_node( assert body["context_id"] == context_id assert body["execution_mode"] == "graph" assert body["dispatched"] == 1 - assert body["task_ids"] == ["task-1"] + generated_task_ids = _assert_generated_task_ids(body["task_ids"], 1) assert len(calls) == 1 assert calls[0][0] == "app.domains.rendering.tasks.render_order_line_still_task" assert calls[0][1] == [context_id] @@ -1271,6 +1311,7 @@ async def test_workflow_dispatch_endpoint_arms_notify_handoff_for_render_node( node_results = {node["node_name"]: node for node in body["workflow_run"]["node_results"]} assert node_results["render"]["status"] == "queued" + assert node_results["render"]["output"]["task_id"] == generated_task_ids[0] assert node_results["render"]["output"]["graph_notify_node_ids"] == ["notify"] assert node_results["notify"]["status"] == "pending" assert node_results["notify"]["output"]["notification_mode"] == "deferred_to_render_task" @@ -1321,6 +1362,54 @@ async def test_workflow_preflight_endpoint_reports_render_graph_readiness( assert node_checks["blend"]["status"] == "ready" +@pytest.mark.asyncio +async def test_workflow_order_line_contexts_keep_completed_orders_renderable_for_editor_reruns( + client, + db, + admin_user, + auth_headers, + tmp_path, +): + ready_line = await _seed_renderable_order_line(db, admin_user, tmp_path) + completed_line = await _seed_renderable_order_line(db, admin_user, tmp_path) + blocked_line = await _seed_renderable_order_line(db, admin_user, tmp_path) + + completed_order = await db.get(Order, completed_line.order_id) + assert completed_order is not None + completed_order.status = OrderStatus.completed + + blocked_order = await db.get(Order, blocked_line.order_id) + assert blocked_order is not None + blocked_order.status = OrderStatus.rejected + await db.commit() + + response = await client.get( + "/api/workflows/contexts/order-lines", + headers=auth_headers, + ) + + assert response.status_code == 200 + body = response.json() + flattened = [ + option + for group in body + for option in group["options"] + ] + by_id = {option["value"]: option for option in flattened} + + assert by_id[str(ready_line.id)]["is_renderable"] is True + assert by_id[str(ready_line.id)]["renderability_reason"] is None + assert by_id[str(completed_line.id)]["is_renderable"] is True + assert by_id[str(completed_line.id)]["renderability_reason"] is None + assert by_id[str(blocked_line.id)]["is_renderable"] is False + assert by_id[str(blocked_line.id)]["renderability_reason"] == "order_closed" + + renderable_ids = {option["value"] for option in flattened if option["is_renderable"]} + assert str(ready_line.id) in renderable_ids + assert str(completed_line.id) in renderable_ids + assert str(blocked_line.id) not in renderable_ids + + @pytest.mark.asyncio async def test_workflow_draft_dispatch_endpoint_dispatches_unsaved_render_graph( client, @@ -1342,7 +1431,7 @@ async def test_workflow_draft_dispatch_endpoint_dispatches_unsaved_render_graph( calls: list[tuple[str, list[str], dict]] = [] - def _fake_send_task(task_name: str, args: list[str], kwargs: dict): + def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options): calls.append((task_name, args, kwargs)) return type("Result", (), {"id": f"draft-task-{len(calls)}"})() @@ -1364,7 +1453,7 @@ async def test_workflow_draft_dispatch_endpoint_dispatches_unsaved_render_graph( assert body["context_id"] == str(order_line.id) assert body["execution_mode"] == "graph" assert body["dispatched"] == 1 - assert body["task_ids"] == ["draft-task-1"] + generated_task_ids = _assert_generated_task_ids(body["task_ids"], 1) assert body["workflow_run"]["workflow_def_id"] == str(workflow_definition.id) assert body["workflow_run"]["execution_mode"] == "graph" assert body["workflow_run"]["order_line_id"] == str(order_line.id) @@ -1375,6 +1464,7 @@ async def test_workflow_draft_dispatch_endpoint_dispatches_unsaved_render_graph( assert node_results["setup"]["status"] == "completed" assert node_results["template"]["status"] == "completed" assert node_results["render"]["status"] == "queued" + assert node_results["render"]["output"]["task_id"] == generated_task_ids[0] @pytest.mark.asyncio @@ -1394,7 +1484,7 @@ async def test_workflow_draft_dispatch_endpoint_marks_submitted_order_processing monkeypatch.setattr( "app.tasks.celery_app.celery_app.send_task", - lambda task_name, args, kwargs: type("Result", (), {"id": "draft-task-1"})(), + lambda task_name, args, kwargs, **task_options: type("Result", (), {"id": "draft-task-1"})(), ) response = await client.post( "/api/workflows/dispatch", @@ -1412,6 +1502,44 @@ async def test_workflow_draft_dispatch_endpoint_marks_submitted_order_processing assert order_line.order.completed_at is None +@pytest.mark.asyncio +async def test_workflow_preflight_endpoint_allows_completed_order_context_for_editor_reruns( + client, + db, + admin_user, + auth_headers, + tmp_path, +): + order_line = await _seed_renderable_order_line(db, admin_user, tmp_path) + order = await db.get(Order, order_line.order_id) + assert order is not None + order.status = OrderStatus.completed + await db.commit() + + workflow_definition = WorkflowDefinition( + name=f"Completed Order Preflight {uuid.uuid4().hex[:8]}", + config=_build_valid_custom_still_graph(), + is_active=True, + ) + db.add(workflow_definition) + await db.commit() + await db.refresh(workflow_definition) + + response = await client.get( + f"/api/workflows/{workflow_definition.id}/preflight", + headers=auth_headers, + params={"context_id": str(order_line.id)}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["graph_dispatch_allowed"] is True + assert body["summary"] == "Preflight passed with warnings." + node_checks = {node["node_id"]: node for node in body["nodes"]} + assert node_checks["setup"]["status"] == "ready" + assert not any(issue["code"] == "order_line_skipped" for issue in body["issues"]) + + @pytest.mark.asyncio async def test_workflow_draft_dispatch_endpoint_rejects_invalid_graph_config( client, diff --git a/backend/tests/domains/test_workflow_executor.py b/backend/tests/domains/test_workflow_executor.py index e405191..daa6293 100644 --- a/backend/tests/domains/test_workflow_executor.py +++ b/backend/tests/domains/test_workflow_executor.py @@ -102,7 +102,7 @@ def test_dispatch_workflow_preserves_mapped_task_order_around_phase_3_bridge_nod {"id": "setup", "step": StepName.ORDER_LINE_SETUP.value, "params": {}}, {"id": "render", "step": StepName.BLENDER_STILL.value, "params": {"width": 1024}}, {"id": "save", "step": StepName.OUTPUT_SAVE.value, "params": {}}, - {"id": "export", "step": StepName.EXPORT_BLEND.value, "params": {"include_textures": True}}, + {"id": "export", "step": StepName.EXPORT_BLEND.value, "params": {"output_name_suffix": "client"}}, {"id": "notify", "step": StepName.NOTIFY.value, "params": {}}, ], "edges": [ @@ -125,6 +125,6 @@ def test_dispatch_workflow_preserves_mapped_task_order_around_phase_3_bridge_nod ( "app.domains.rendering.tasks.export_blend_for_order_line_task", ["line-456"], - {"include_textures": True}, + {"output_name_suffix": "client"}, ), ] diff --git a/backend/tests/domains/test_workflow_node_registry.py b/backend/tests/domains/test_workflow_node_registry.py index da2575c..01953da 100644 --- a/backend/tests/domains/test_workflow_node_registry.py +++ b/backend/tests/domains/test_workflow_node_registry.py @@ -3,7 +3,16 @@ import pytest from app.core.process_steps import StepName from app.domains.rendering.models import OutputType, WorkflowDefinition, WorkflowRun from app.domains.rendering.workflow_config_utils import build_preset_workflow_config -from app.domains.rendering.workflow_graph_runtime import _STILL_TASK_KEYS, _TURNTABLE_TASK_KEYS +from app.domains.rendering.workflow_graph_runtime import ( + _AUTO_POPULATE_RUNTIME_KEYS, + _GLB_BBOX_RUNTIME_KEYS, + _MATERIAL_MAP_RUNTIME_KEYS, + _NOTIFY_RUNTIME_KEYS, + _OUTPUT_SAVE_RUNTIME_KEYS, + _RESOLVE_TEMPLATE_RUNTIME_KEYS, + _STILL_TASK_KEYS, + _TURNTABLE_TASK_KEYS, +) from app.domains.rendering.workflow_node_registry import ( get_node_definition, list_node_definitions, @@ -121,6 +130,29 @@ def test_graph_render_node_fields_are_supported_by_runtime_dispatch(): assert turntable_runtime_fields <= _TURNTABLE_TASK_KEYS +def test_bridge_node_fields_match_runtime_dispatch_contracts(): + resolve_template = get_node_definition(StepName.RESOLVE_TEMPLATE) + material_map = get_node_definition(StepName.MATERIAL_MAP_RESOLVE) + auto_populate = get_node_definition(StepName.AUTO_POPULATE_MATERIALS) + glb_bbox = get_node_definition(StepName.GLB_BBOX) + output_save = get_node_definition(StepName.OUTPUT_SAVE) + notify = get_node_definition(StepName.NOTIFY) + + assert resolve_template is not None + assert material_map is not None + assert auto_populate is not None + assert glb_bbox is not None + assert output_save is not None + assert notify is not None + + assert {field.key for field in resolve_template.fields} == _RESOLVE_TEMPLATE_RUNTIME_KEYS + assert {field.key for field in material_map.fields} == _MATERIAL_MAP_RUNTIME_KEYS + assert {field.key for field in auto_populate.fields} == _AUTO_POPULATE_RUNTIME_KEYS + assert {field.key for field in glb_bbox.fields} == _GLB_BBOX_RUNTIME_KEYS + assert {field.key for field in output_save.fields} == _OUTPUT_SAVE_RUNTIME_KEYS + assert {field.key for field in notify.fields} == _NOTIFY_RUNTIME_KEYS + + def test_order_line_setup_and_template_contracts_expose_runtime_outputs(): setup = get_node_definition(StepName.ORDER_LINE_SETUP) template = get_node_definition(StepName.RESOLVE_TEMPLATE) @@ -178,7 +210,13 @@ def test_order_line_setup_and_template_contracts_expose_runtime_outputs(): "include_populated_products", } assert output.input_contract["requires"] == ["order_line_context"] - assert output.input_contract["requires_any"] == ["rendered_image", "rendered_frames", "rendered_video"] + assert output.input_contract["requires_any"] == [ + "rendered_image", + "rendered_frames", + "rendered_video", + "blend_asset", + ] + assert "blend_asset" in output.artifact_roles_consumed assert set(output.output_contract["provides"]) >= {"media_asset", "workflow_result"} assert {field.key for field in output.fields} == { "expected_artifact_role", @@ -193,6 +231,7 @@ def test_order_line_setup_and_template_contracts_expose_runtime_outputs(): "rendered_frames", "rendered_video", "workflow_result", + "blend_asset", ] assert {field.key for field in notify.fields} == {"channel", "require_armed_render"} diff --git a/backend/tests/domains/test_workflow_runtime_services.py b/backend/tests/domains/test_workflow_runtime_services.py index 6786689..6a44f03 100644 --- a/backend/tests/domains/test_workflow_runtime_services.py +++ b/backend/tests/domains/test_workflow_runtime_services.py @@ -581,6 +581,32 @@ def test_prepare_order_line_render_context_skips_closed_orders(sync_session, tmp assert line.render_status == "cancelled" +def test_prepare_order_line_render_context_allows_completed_rerender_for_workflow_editor( + sync_session, + tmp_path, + monkeypatch, +): + from app.config import settings + + monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads")) + line = _seed_order_line_graph(sync_session, tmp_path) + line.order.status = OrderStatus.completed + sync_session.commit() + + result = prepare_order_line_render_context( + sync_session, + str(line.id), + persist_state=False, + allow_completed_order_rerender=True, + ) + sync_session.refresh(line) + + assert result.status == "ready" + assert result.reason is None + assert result.is_ready is True + assert line.render_status == "pending" + + def test_build_order_line_render_invocation_applies_output_and_line_overrides(tmp_path): step_path = tmp_path / "parts" / "bearing.step" step_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/backend/tests/domains/test_workflow_schema.py b/backend/tests/domains/test_workflow_schema.py index 07168e4..ac2fdf3 100644 --- a/backend/tests/domains/test_workflow_schema.py +++ b/backend/tests/domains/test_workflow_schema.py @@ -399,6 +399,54 @@ def test_workflow_schema_accepts_transitive_contract_wiring(): assert config.ui.execution_mode == "graph" +def test_workflow_schema_accepts_export_blend_notify_contract_wiring(): + config = WorkflowConfig.model_validate( + { + "version": 1, + "nodes": [ + {"id": "setup", "step": "order_line_setup", "params": {}}, + {"id": "template", "step": "resolve_template", "params": {}}, + {"id": "export", "step": "export_blend", "params": {"output_name_suffix": "review"}}, + {"id": "notify", "step": "notify", "params": {}}, + ], + "edges": [ + {"from": "setup", "to": "template"}, + {"from": "setup", "to": "export"}, + {"from": "template", "to": "export"}, + {"from": "export", "to": "notify"}, + ], + "ui": {"family": "order_line", "execution_mode": "graph"}, + } + ) + + assert config.ui is not None + assert config.ui.execution_mode == "graph" + + +def test_workflow_schema_accepts_export_blend_output_save_contract_wiring(): + config = WorkflowConfig.model_validate( + { + "version": 1, + "nodes": [ + {"id": "setup", "step": "order_line_setup", "params": {}}, + {"id": "template", "step": "resolve_template", "params": {}}, + {"id": "export", "step": "export_blend", "params": {"output_name_suffix": "review"}}, + {"id": "output", "step": "output_save", "params": {"expected_artifact_role": "blend_export"}}, + ], + "edges": [ + {"from": "setup", "to": "template"}, + {"from": "setup", "to": "export"}, + {"from": "template", "to": "export"}, + {"from": "export", "to": "output"}, + ], + "ui": {"family": "order_line", "execution_mode": "graph"}, + } + ) + + assert config.ui is not None + assert config.ui.execution_mode == "graph" + + def test_workflow_schema_accepts_cad_intake_contract_wiring_with_shared_bbox_node(): config = WorkflowConfig.model_validate( { diff --git a/docs/workflows/NODE_CONTRACT_AUDIT_2026-04-12.md b/docs/workflows/NODE_CONTRACT_AUDIT_2026-04-12.md new file mode 100644 index 0000000..7dc3d8a --- /dev/null +++ b/docs/workflows/NODE_CONTRACT_AUDIT_2026-04-12.md @@ -0,0 +1,244 @@ +# Workflow Node Contract Audit + +Stand: April 12, 2026 + +## Scope + +Geprüft wurden: + +- [`backend/app/domains/rendering/workflow_node_registry.py`](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/rendering/workflow_node_registry.py) +- [`backend/app/domains/rendering/workflow_schema.py`](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/rendering/workflow_schema.py) +- [`backend/app/domains/rendering/workflow_graph_runtime.py`](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/rendering/workflow_graph_runtime.py) +- [`frontend/src/components/workflows/workflowNodeContracts.ts`](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/components/workflows/workflowNodeContracts.ts) +- [`frontend/src/components/workflows/WorkflowNodeInspector.tsx`](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/components/workflows/WorkflowNodeInspector.tsx) +- [`frontend/src/components/workflows/workflowGraphDraft.ts`](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/components/workflows/workflowGraphDraft.ts) + +## Findings + +### 1. Root-context inputs were modeled like normal upstream sockets + +Betroffen: + +- `resolve_step_path` mit `cad_file_record` +- `order_line_setup` mit `order_line_record` + +Root cause: + +- Das Backend seedingt diese Eingänge implizit aus dem Workflow-Kontext. +- Der Editor behandelte sie trotzdem als normale Canvas-Ports. + +Folge: + +- Entry-Nodes sahen aus, als müssten sie zusätzlich verkabelt werden. +- Das erzeugte genau die Art von unklaren Input-Anforderungen, die im aktuellen Workflow-Editor stören. + +Maßnahme: + +- Root-context inputs werden im Frontend separat als Kontextanforderung modelliert. +- Sie werden nicht mehr als normale verkabelbare Upstream-Sockets dargestellt. + +### 2. Node authoring semantics were implicit instead of explicit + +Root cause: + +- Der Editor wusste bislang zwar, welche Felder und Ports existieren, aber nicht, welches Authoring-Muster eine Node eigentlich hat. + +Folge: + +- `0 inspector vars` oder `1 input socket` war im UI technisch korrekt, aber semantisch oft unverständlich. + +Maßnahme: + +- Nodes werden jetzt explizit als `Context Entry`, `Connection-Driven`, `Inspector-Driven` oder `Hybrid` beschrieben. + +### 3. Registry coverage is broad, but some nodes are intentionally connection-only + +Nodes mit `0` Inspector-Feldern: + +- `resolve_step_path` +- `occ_object_extract` +- `occ_glb_export` +- `thumbnail_save` +- `order_line_setup` +- `stl_cache_generate` + +Bewertung: + +- Das ist nicht automatisch ein Defekt. +- Diese Nodes brauchen vor allem klare Authoring-Erklärung und keine künstlichen Dummy-Einstellungen. + +### 4. Render/runtime parameter alignment is already strong for core render nodes + +Der bestehende Testpfad deckt insbesondere für `blender_still` und `blender_turntable` bereits ab, dass deklarierte Felder von der Runtime unterstützt werden. + +Nächste Lücke: + +- systematische Prüfung der restlichen Bridge-/Output-Nodes gegen Runtime-Parameter und Template-Inputs + +## Current Batch Outcome + +Batch A konzentriert sich zuerst auf: + +1. sichtbaren 20-Block-Plan +2. Audit-Dokumentation +3. saubere Trennung von Root-Kontext und Upstream-Wiring +4. explizite Authoring-Semantik im Editor + +## Block 5 Inventory: Implicit Requirement Nodes + +### A. Korrekt feldlose Nodes, die nur bessere Authoring-Semantik brauchten + +- `resolve_step_path` + - echter `Context Entry` + - braucht nur `cad_file_record` aus dem Workflow-Kontext + - keine zusätzlichen Inspector-Variablen sinnvoll +- `order_line_setup` + - echter `Context Entry` + - braucht nur `order_line_record` aus dem Workflow-Kontext + - liefert den Großteil des Order-Line-Arbeitskontexts +- `occ_object_extract` + - reine `Connection-Driven` Node + - braucht nur `step_path` + - keine zusätzlichen lokalen Einstellungen in der Runtime vorhanden +- `occ_glb_export` + - reine `Connection-Driven` Node + - braucht nur `step_path` + - Registry beschreibt bereits korrekt, dass per-Node-Tessellation-Overrides noch nicht existieren +- `thumbnail_save` + - reine `Connection-Driven` Node + - braucht nur `rendered_image` + - Verhalten kommt aus dem angeschlossenen Thumbnail-Request, nicht aus lokalen Feldern +- `stl_cache_generate` + - reine `Connection-Driven` Kompatibilitäts-Node + - kein echter Produktionsschritt im HartOMat-Graph + - Runtime ist bewusst ein `compatibility_noop` + +### B. Nodes ohne große Feldoberfläche, aber mit wichtiger Laufzeitsemantik + +- `output_save` + - hat nur wenige lokale Felder, aber relevante Handoff-Semantik + - Verhalten hängt von angeschlossenen Render-Artefakten, Shadow/Graph-Mode und Publish-Handoff ab + - UI muss klarer kommunizieren, wann diese Node `pending`, `completed` oder `failed` wird +- `notify` + - hat nur minimale lokale Konfiguration, ist aber stark vom bewaffneten Render-Handoff abhängig + - in `shadow` wird die Node bewusst unterdrückt + - braucht vor allem bessere Preflight-/Inspector-Erklärung, nicht mehr Freitextfelder +- `export_blend` + - aktuell nur ein bewusst schmaler Bridge-Export + - nur Dateinamensuffix ist pro Workflow authorbar + - größere Feldoberfläche wäre aktuell Fake-Konfiguration ohne Runtime-Nutzen + +### C. Nodes mit dynamischen statt statischen Inputs + +- `resolve_template` + - statische Inspector-Felder sind vorhanden + - zusätzliche Inputs entstehen dynamisch über `workflow_input_schema` + - das ist keine 0-Felder-Node, aber eine wichtige Ursache für Verwirrung, wenn Template-Inputs im Editor nicht klar sichtbar werden + +### D. Bewertung + +- Für Batch A/B ist die Hauptlücke nicht "mehr Felder um jeden Preis". +- Die Hauptlücke ist: + - Root-Kontext korrekt modellieren + - Connection-vs-Inspector-Semantik explizit machen + - Handoff-/Template-/Shadow-Semantik sichtbarer machen +- Echte neue Eingabevariablen werden erst dort ergänzt, wo Runtime und Template-System sie tatsächlich unterstützen. + +## Finding 5: `notify` had a real frontend/backend contract drift + +Betroffen: + +- Frontend-Authoring erlaubte `export_blend -> notify` +- Backend-Schema ließ `notify` bislang nicht auf `blend_asset` reagieren + +Root cause: + +- Frontend ergänzte `blend_asset` als alternatives `requires_any` +- Backend-Registry führte für `notify` nur Render-Artefakte und `workflow_result` + +Folge: + +- derselbe Graph konnte im Editor plausibel aussehen, aber beim Backend-Schema scheitern +- besonders Blend-Export-Workflows waren dadurch inkonsistent authorbar + +Maßnahme: + +- `notify.input_contract.requires_any` enthält jetzt auch `blend_asset` +- Registry führt `blend_asset` auch als konsumiertes Artefakt +- Schema- und Executor-Tests decken `export_blend -> notify` jetzt explizit ab + +## Finding 6: Bridge-node runtime params need explicit anti-drift guards + +Betroffen: + +- `resolve_template` +- `material_map_resolve` +- `auto_populate_materials` +- `glb_bbox` +- `output_save` +- `notify` + +Root cause: + +- Core-Render-Nodes waren bereits per Runtime-Key-Tests abgesichert. +- Bridge-Nodes hatten zwar Registry-Felder, aber keinen zentralen Runtime-Param-Contract gegen Drift. + +Folge: + +- zukünftige Änderungen in Runtime oder Registry könnten still auseinanderlaufen +- besonders gefährlich für Inspector-Felder, die klein wirken, aber produktionskritische Handoff-Semantik steuern + +Maßnahme: + +- Runtime-Key-Sets für die Bridge-Nodes wurden in `workflow_graph_runtime.py` zentralisiert +- Registry-Tests prüfen diese Nodes jetzt 1:1 gegen die Runtime + +Ergebnis: + +- Block 6 ist auf Contract-Ebene abgeschlossen +- weitere Batch-B-Arbeit kann sich jetzt auf Template-/Output-Semantik statt auf Grundsatzdrift konzentrieren + +## Finding 7: Template workflow inputs were only fully visible after forcing a concrete template override + +Betroffen: + +- `resolve_template` im Workflow-Inspector + +Root cause: + +- Template-definierte Produktionsvariablen kamen technisch aus `workflow_input_schema`, wurden im Editor aber primär erst nach Auswahl eines festen Template-Overrides sichtbar. +- Damit blieb ein Teil des realen Authoring-Vertrags für automatische Template-Auflösung zu implizit. + +Folge: + +- Autoren konnten schwer erkennen, welche Workflow-Variablen aktive Templates grundsätzlich bereits verlangen oder anbieten. +- Das machte Template-First-Graphen unnötig intransparent, obwohl die Runtime die Inputs bereits unterstützt. + +Maßnahme: + +- der Inspector zeigt jetzt zusätzlich eine automatische Abdeckungsansicht über aktive Templates mit Workflow-Inputs +- potenzielle Template-Variablen werden vor Auswahl eines festen Overrides als reale Produktionsvariablen sichtbar +- gezielte Frontend-Tests prüfen sowohl explizite Override-Inputs als auch die automatische Coverage + +## Finding 8: Render override fields could be edited although runtime discarded them + +Betroffen: + +- `blender_still` +- `blender_turntable` + +Root cause: + +- der Inspector behandelte mehrere renderautoritative Felder wie normale Node-Variablen +- die Runtime verwirft diese Werte jedoch bewusst, solange `use_custom_render_settings` deaktiviert bleibt und Output Type bzw. Template autoritativ sind + +Folge: + +- Autoren konnten Konfigurationen eingeben, die im Lauf keine Wirkung hatten +- das war eine echte Contract-Lücke zwischen UI und Runtime, nicht nur eine Darstellungsfrage + +Maßnahme: + +- renderautoritative Felder werden nun gesperrt, bis `use_custom_render_settings` aktiviert ist +- `output_save` und `notify` dokumentieren zusätzlich ihre Handoff-Semantik im Inspector explizit, um Shadow-/Graph-/Legacy-Verhalten klarer zu machen +- fokussierte Frontend- und Backend-Tests sichern diese Contract-Regeln gegen Regressions ab diff --git a/docs/workflows/VALIDATION_ERROR_INVENTORY_2026-04-12.md b/docs/workflows/VALIDATION_ERROR_INVENTORY_2026-04-12.md new file mode 100644 index 0000000..2f5469c --- /dev/null +++ b/docs/workflows/VALIDATION_ERROR_INVENTORY_2026-04-12.md @@ -0,0 +1,85 @@ +# Workflow Validation Error Inventory + +Stand: April 12, 2026 + +Dieses Inventar beschreibt die derzeit real existierenden Graph-Preflight- und Validation-Fehlerklassen im Workflow-System. Ziel ist, Backend-Preflight, Editor-Hinweise und Autoren-Debugging auf dieselbe Sprache zu bringen. + +## Kategorien + +### Context + +Diese Fehler bedeuten, dass der Graph mit dem falschen Basiskontext oder mit einem ungültigen Kontext gestartet wird. + +| Code | Severity | Root Cause | Erwartete Abhilfe | +| --- | --- | --- | --- | +| `invalid_context_id` | error | Die angegebene Context-ID ist keine UUID. | Gültige UUID aus Order Line oder CAD File verwenden. | +| `context_not_found` | error | Die UUID zeigt auf keinen vorhandenen Datensatz. | Vorhandenen Datensatz wählen oder Seed-/Import-Daten prüfen. | +| `context_kind_mismatch` | error | Workflow-Familie und übergebener Kontext passen nicht zusammen. | Order-Line-Graph mit Order Line starten, CAD-Graph mit CAD File. | +| `invalid_context_kind` | error | Einzelne Node verlangt `order_line`, der Graph läuft aber nicht in diesem Kontext. | Kontext oder Node-Familie korrigieren. | +| `cad_file_only_node` | error | CAD-Entry-Node wurde in einem Order-Line-Graph platziert. | Node in CAD-Workflow verschieben oder order-line-taugliche Alternative nutzen. | + +### Setup Chain + +Diese Fehler zeigen, dass die notwendige Vorbereitungslogik für den Renderpfad fehlt oder nicht renderbar ist. + +| Code | Severity | Root Cause | Erwartete Abhilfe | +| --- | --- | --- | --- | +| `order_line_missing` | error | Order Line konnte nicht geladen werden. | Datensatz und FK-Kette prüfen. | +| `order_line_not_renderable` | error | Legacy-Setup erkennt harte Renderblocker. | Voraussetzungen der Order Line reparieren. | +| `order_line_skipped` | error | Legacy-Setup würde den Renderpfad bewusst überspringen. | Skip-Grund beseitigen. | +| `missing_order_line_setup` | error | Downstream-Node hat keinen vorgelagerten `order_line_setup`. | Setup-Node früher im Graph platzieren. | +| `setup_not_ready` | error | Setup ist vorhanden, aber nicht in einem lauffähigen Zustand. | Setup-Ursache beheben und erneut preflighten. | + +### Data Source + +Diese Fehler entstehen durch unvollständige oder nicht mehr erreichbare Eingabedaten. + +| Code | Severity | Root Cause | Erwartete Abhilfe | +| --- | --- | --- | --- | +| `cad_file_missing_path` | error | CAD File hat keinen gespeicherten STEP-Pfad. | STEP-Referenz reparieren oder neu importieren. | +| `cad_file_step_missing` | error | Gespeicherter STEP-Pfad existiert auf dem Dateisystem nicht. | Storage-/Mount-/Importpfad reparieren. | +| `bbox_unresolved` | warning | Bounding Box konnte nicht aus GLB oder STEP abgeleitet werden. | GLB-Upstream, Exportpfad oder STEP-Quelle prüfen. | + +### Runtime Gap + +Diese Fehler bedeuten, dass der Graph aktuell noch keine echte Runtime-Implementierung für den Schritt hat. + +| Code | Severity | Root Cause | Erwartete Abhilfe | +| --- | --- | --- | --- | +| `unsupported_node` | error | Node ist registriert, aber in der Graph-Runtime noch nicht ausführbar. | Legacy/Bridge behalten oder native Graph-Implementierung ergänzen. | + +### Legacy Drift + +Diese Warnungen markieren Stellen, an denen der Graph zwar laufen kann, aber vom bisherigen Legacy-Verhalten abweichen könnte. + +| Code | Severity | Root Cause | Erwartete Abhilfe | +| --- | --- | --- | --- | +| `missing_resolve_template` | warning | Render-/Export-Pfad läuft ohne vorgelagertes `resolve_template`. | `resolve_template` vor Render-/Export-Nodes ergänzen. | +| `template_missing` | warning | Für die Order Line wurde kein Template aufgelöst. | Template zuordnen oder Override setzen. | + +### Artifact Flow + +Diese Klasse deckt aktuell generische Vertrags- und Upstream-Probleme ab, die aus Message oder Code als Artefaktfluss erkennbar sind. + +| Erkennung | Severity | Root Cause | Erwartete Abhilfe | +| --- | --- | --- | --- | +| `code` oder `message` enthält `artifact` | meist warning/error | Ein benötigtes Artefakt wurde upstream nicht produziert oder nicht verbunden. | Fehlende Node/Verbindung ergänzen und Contract im Editor prüfen. | + +## Blocking-Regeln + +- `error` blockiert Graph-Dispatch. +- `warning` blockiert nicht automatisch, muss aber vor Rollout-Parität bewertet werden. +- `unsupported_node` ist inhaltlich ein Runtime-Gap und wird als blockierend behandelt. + +## UI-Sprachregelung + +- Editor und Preflight sollen immer beide Ebenen zeigen: + - Schweregrad: `error`, `warning`, `info` + - Typ: `Context`, `Setup Chain`, `Data Source`, `Runtime Gap`, `Legacy Drift`, `Artifact Flow` +- Action-Hints müssen immer direkt sagen, welche Node, welcher Kontext oder welche Upstream-Voraussetzung fehlt. + +## Nächste Folgeschritte + +1. Node-Katalog und Inspector mit denselben Kategorien annotieren. +2. Validation bereits im Authoring vor Preflight so früh wie möglich sichtbar machen. +3. Für echte `Artifact Flow`-Fehler langfristig explizite Codes statt Message-Heuristik einführen. diff --git a/frontend/src/__tests__/api/outputTypes.test.ts b/frontend/src/__tests__/api/outputTypes.test.ts index 7c87003..329dba4 100644 --- a/frontend/src/__tests__/api/outputTypes.test.ts +++ b/frontend/src/__tests__/api/outputTypes.test.ts @@ -250,4 +250,27 @@ describe('output type contract helpers', () => { expect.objectContaining({ id: 'wf-1' }), ]) }) + + test('accepts graph workflows that advertise blend artifact support', () => { + expect(getCompatibleWorkflowsForOutputTypeContract( + [ + { + id: 'wf-blend', + name: 'Still + Blend Graph', + family: 'order_line', + supported_artifact_kinds: ['still_image', 'blend_asset'], + }, + { + id: 'wf-still', + name: 'Still Graph', + family: 'order_line', + supported_artifact_kinds: ['still_image'], + }, + ], + 'order_line', + 'blend_asset', + )).toEqual([ + expect.objectContaining({ id: 'wf-blend' }), + ]) + }) }) diff --git a/frontend/src/__tests__/api/workflows.test.ts b/frontend/src/__tests__/api/workflows.test.ts index 2b3a528..5ac519c 100644 --- a/frontend/src/__tests__/api/workflows.test.ts +++ b/frontend/src/__tests__/api/workflows.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, vi } from 'vitest' import { + buildWorkflowBlueprintConfig, createPresetWorkflowConfig, createStarterWorkflowConfig, normalizeWorkflowConfig, @@ -127,6 +128,59 @@ describe('workflow preset config builders', () => { expect(config.nodes.map(node => node.step)).toEqual(['order_line_setup']) }) + test('builds alpha and blend graph blueprints for still-render authoring', () => { + const alphaConfig = buildWorkflowBlueprintConfig('still_graph_alpha_reference') + const blendConfig = buildWorkflowBlueprintConfig('still_graph_blend_reference') + + expect(alphaConfig.ui?.execution_mode).toBe('graph') + expect(alphaConfig.nodes.find(node => node.step === 'blender_still')?.params).toMatchObject({ + transparent_bg: true, + use_custom_render_settings: false, + }) + + expect(blendConfig.ui?.execution_mode).toBe('graph') + expect(blendConfig.nodes.map(node => node.step)).toEqual( + expect.arrayContaining(['blender_still', 'export_blend', 'output_save', 'notify']), + ) + expect(blendConfig.nodes.find(node => node.id === 'save_blend')?.params).toMatchObject({ + expected_artifact_role: 'blend_export', + }) + }) + + test('rebuilds new still graph reference variants during normalization', () => { + const alphaConfig = normalizeWorkflowConfig({ + version: 1, + ui: { + preset: 'custom', + execution_mode: 'graph', + blueprint: 'still_graph_alpha_reference', + }, + nodes: [], + edges: [], + }) + const blendConfig = normalizeWorkflowConfig({ + version: 1, + ui: { + preset: 'custom', + execution_mode: 'graph', + blueprint: 'still_graph_blend_reference', + }, + nodes: [], + edges: [], + }) + + expect(alphaConfig.ui?.blueprint).toBe('still_graph_alpha_reference') + expect(alphaConfig.nodes.find(node => node.step === 'blender_still')?.params).toMatchObject({ + transparent_bg: true, + }) + + expect(blendConfig.ui?.blueprint).toBe('still_graph_blend_reference') + expect(blendConfig.nodes.some(node => node.step === 'export_blend')).toBe(true) + expect(blendConfig.nodes.find(node => node.id === 'save_blend')?.params).toMatchObject({ + expected_artifact_role: 'blend_export', + }) + }) + test('normalizes workflow rollout summary from the API payload', async () => { vi.mocked(api.get).mockResolvedValueOnce({ data: [ diff --git a/frontend/src/__tests__/components/OutputTypeTable.test.tsx b/frontend/src/__tests__/components/OutputTypeTable.test.tsx new file mode 100644 index 0000000..df59519 --- /dev/null +++ b/frontend/src/__tests__/components/OutputTypeTable.test.tsx @@ -0,0 +1,228 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor, within } from '@testing-library/react' +import { describe, expect, test, vi } from 'vitest' + +import type { Material } from '../../api/materials' +import type { OutputType, OutputTypeContractCatalog } from '../../api/outputTypes' +import type { PricingTier } from '../../api/pricing' +import type { WorkflowDefinition } from '../../api/workflows' +import OutputTypeTable from '../../components/admin/OutputTypeTable' + +const listOutputTypesMock = vi.fn<() => Promise>() +const getOutputTypeContractCatalogMock = vi.fn<() => Promise>() +const listMaterialsMock = vi.fn<() => Promise>() +const listPricingTiersMock = vi.fn<() => Promise>() +const getWorkflowsMock = vi.fn<() => Promise>() + +vi.mock('../../api/outputTypes', async () => { + const actual = await vi.importActual('../../api/outputTypes') + return { + ...actual, + listOutputTypes: () => listOutputTypesMock(), + getOutputTypeContractCatalog: () => getOutputTypeContractCatalogMock(), + getCachedOutputTypeContractCatalog: () => actual.getCachedOutputTypeContractCatalog(), + createOutputType: vi.fn(), + updateOutputType: vi.fn(), + deleteOutputType: vi.fn(), + } +}) + +vi.mock('../../api/materials', () => ({ + listMaterials: () => listMaterialsMock(), +})) + +vi.mock('../../api/pricing', () => ({ + listPricingTiers: () => listPricingTiersMock(), +})) + +vi.mock('../../api/workflows', async () => { + const actual = await vi.importActual('../../api/workflows') + return { + ...actual, + getWorkflows: () => getWorkflowsMock(), + } +}) + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +function renderTable() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + render( + + + , + ) + + return { queryClient } +} + +describe('OutputTypeTable', () => { + test('does not flag valid linked workflows as unresolved', async () => { + const workflowId = '9a4e65ff-ecb8-4840-9c52-c07b45236d2d' + const now = '2026-04-15T07:00:00Z' + + listOutputTypesMock.mockResolvedValue([ + { + id: '29af9864-a4b3-4fd5-a02b-53232ce81fa7', + name: '[Workflow Golden] Canonical Still Graph', + description: null, + renderer: 'blender', + render_settings: {}, + invocation_overrides: { + width: 1024, + height: 1024, + samples: 64, + }, + output_format: 'png', + sort_order: 0, + compatible_categories: [], + render_backend: 'celery', + is_animation: false, + transparent_bg: false, + workflow_family: 'order_line', + artifact_kind: 'still_image', + cycles_device: 'gpu', + pricing_tier_id: null, + pricing_tier_name: null, + price_per_item: null, + workflow_definition_id: workflowId, + workflow_rollout_mode: 'graph', + workflow_name: '[Workflow Golden] Canonical Still Graph', + material_override: null, + invocation_profile: { + renderer: 'blender', + render_backend: 'celery', + workflow_family: 'order_line', + artifact_kind: 'still_image', + output_format: 'png', + is_animation: false, + workflow_definition_id: workflowId, + workflow_rollout_mode: 'graph', + transparent_bg: false, + cycles_device: 'gpu', + material_override: null, + allowed_override_keys: ['width', 'height', 'samples', 'engine'], + invocation_overrides: { + width: 1024, + height: 1024, + samples: 64, + engine: 'cycles', + }, + }, + is_active: true, + created_at: now, + updated_at: now, + }, + ]) + + getOutputTypeContractCatalogMock.mockResolvedValue({ + workflow_families: ['cad_file', 'order_line'], + workflow_rollout_modes: ['legacy_only', 'shadow', 'graph'], + artifact_kinds: ['still_image', 'turntable_video', 'model_export', 'thumbnail_image', 'blend_asset', 'package', 'custom'], + allowed_artifact_kinds_by_family: { + cad_file: ['thumbnail_image', 'model_export', 'package', 'custom'], + order_line: ['still_image', 'turntable_video', 'blend_asset', 'model_export', 'package', 'custom'], + }, + allowed_output_formats_by_family: { + cad_file: ['png', 'jpg', 'webp', 'glb', 'gltf', 'stl', 'obj', 'usd', 'usdz'], + order_line: ['png', 'jpg', 'webp', 'mp4', 'webm', 'blend', 'glb', 'gltf', 'stl', 'obj', 'usd', 'usdz'], + }, + allowed_invocation_override_keys_by_artifact_kind: { + still_image: ['width', 'height', 'engine', 'samples'], + turntable_video: ['width', 'height', 'engine', 'samples', 'frame_count', 'fps', 'turntable_axis'], + thumbnail_image: ['width', 'height'], + blend_asset: [], + model_export: [], + package: [], + custom: [], + }, + default_output_format_by_artifact_kind: { + still_image: 'png', + turntable_video: 'mp4', + thumbnail_image: 'png', + blend_asset: 'blend', + model_export: 'glb', + package: 'zip', + custom: 'png', + }, + parameter_ownership: { + output_type_profile_keys: [], + template_runtime_keys: [], + workflow_node_keys_by_step: {}, + }, + }) + + listMaterialsMock.mockResolvedValue([]) + listPricingTiersMock.mockResolvedValue([]) + getWorkflowsMock.mockResolvedValue([ + { + id: workflowId, + name: '[Workflow Golden] Canonical Still Graph', + output_type_id: null, + config: { + version: 1, + nodes: [ + { id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 0 } } }, + { id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 200, y: 0 } } }, + { id: 'render', step: 'blender_still', params: {}, ui: { label: 'Still Render', position: { x: 400, y: 0 } } }, + { id: 'output', step: 'output_save', params: {}, ui: { label: 'Save Output', position: { x: 600, y: 0 } } }, + ], + edges: [ + { from: 'setup', to: 'template' }, + { from: 'template', to: 'render' }, + { from: 'render', to: 'output' }, + ], + ui: { + execution_mode: 'graph', + family: 'order_line', + blueprint: 'still_graph_reference', + }, + }, + family: 'order_line', + supported_artifact_kinds: ['still_image'], + rollout_summary: { + linked_output_type_count: 1, + active_output_type_count: 1, + linked_output_type_names: ['[Workflow Golden] Canonical Still Graph'], + linked_output_types: [], + rollout_modes: ['graph'], + has_blocking_contracts: false, + blocking_reasons: [], + latest_run: null, + latest_shadow_run: null, + latest_rollout_gate_verdict: null, + latest_rollout_ready: null, + latest_rollout_status: null, + latest_rollout_reasons: [], + }, + is_active: true, + created_at: now, + }, + ]) + + renderTable() + + const [rowLabel] = await screen.findAllByText('[Workflow Golden] Canonical Still Graph') + const row = rowLabel.closest('tr') + expect(row).not.toBeNull() + const scoped = within(row as HTMLTableRowElement) + + await waitFor(() => { + expect(scoped.getByText('Graph drives production with legacy fallback armed.')).toBeInTheDocument() + }) + expect(scoped.getByText('Graph Authoritative')).toBeInTheDocument() + expect(scoped.queryByText(/The selected workflow definition could not be resolved\./)).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/components/WorkflowListSidebar.test.tsx b/frontend/src/__tests__/components/WorkflowListSidebar.test.tsx new file mode 100644 index 0000000..4a17eae --- /dev/null +++ b/frontend/src/__tests__/components/WorkflowListSidebar.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, test, vi } from 'vitest' + +import { WorkflowListSidebar } from '../../components/workflows/WorkflowListSidebar' + +describe('WorkflowListSidebar', () => { + test('separates workflow selection from destructive actions', async () => { + const user = userEvent.setup() + const onSelectWorkflow = vi.fn() + const onCreateWorkflow = vi.fn() + const onDeleteWorkflow = vi.fn() + + render( + , + ) + + const selectedWorkflowButton = screen + .getAllByRole('button', { name: /still graph blueprint/i }) + .find(button => button.getAttribute('aria-pressed') === 'true') + const deleteButton = screen.getByRole('button', { + name: /delete workflow still graph blueprint/i, + }) + + expect(selectedWorkflowButton).toBeDefined() + expect(selectedWorkflowButton).toHaveAttribute('aria-pressed', 'true') + expect(deleteButton.closest('button')).toBe(deleteButton) + + await user.click(deleteButton) + + expect(onDeleteWorkflow).toHaveBeenCalledWith('wf-selected', 'Still Graph Blueprint') + expect(onSelectWorkflow).not.toHaveBeenCalled() + + const secondaryWorkflowButton = screen + .getAllByRole('button', { name: /still legacy/i }) + .find(button => button.getAttribute('aria-pressed') === 'false') + + expect(secondaryWorkflowButton).toBeDefined() + + await user.click(secondaryWorkflowButton!) + + expect(onSelectWorkflow).toHaveBeenCalledWith('wf-secondary') + }) +}) diff --git a/frontend/src/__tests__/components/WorkflowNodeInspector.test.tsx b/frontend/src/__tests__/components/WorkflowNodeInspector.test.tsx index 5aef837..8e3f8bb 100644 --- a/frontend/src/__tests__/components/WorkflowNodeInspector.test.tsx +++ b/frontend/src/__tests__/components/WorkflowNodeInspector.test.tsx @@ -71,6 +71,331 @@ const notifyDefinition: WorkflowNodeDefinition = { legacy_source: 'legacy.notify', } +const orderLineSetupDefinition: WorkflowNodeDefinition = { + step: 'order_line_setup', + label: 'Order Line Setup', + family: 'order_line', + module_key: 'orders.setup', + category: 'input', + description: 'Resolve order-line context and setup metadata.', + node_type: 'inputNode', + icon: 'package-search', + defaults: {}, + fields: [], + execution_kind: 'native', + legacy_compatible: true, + input_contract: { context: 'order_line', requires: ['order_line_record'] }, + output_contract: { context: 'order_line', provides: ['order_line_context'] }, + artifact_roles_consumed: [], + artifact_roles_produced: ['order_line_context'], + legacy_source: 'legacy.order_line_setup', +} + +const outputSaveDefinition: WorkflowNodeDefinition = { + step: 'output_save', + label: 'Save Output', + family: 'order_line', + module_key: 'media.save_output', + category: 'output', + description: 'Persist the selected render artifact.', + node_type: 'outputNode', + icon: 'download', + defaults: { + expected_artifact_role: '', + require_upstream_artifact: false, + }, + fields: [ + { + key: 'expected_artifact_role', + label: 'Expected Artifact Role', + type: 'select', + description: 'Restrict the accepted upstream artifact.', + section: 'Output', + default: '', + min: null, + max: null, + step: null, + unit: null, + options: [ + { value: '', label: 'Any Connected Artifact' }, + { value: 'render_output', label: 'Still Output' }, + { value: 'turntable_output', label: 'Turntable Output' }, + ], + allow_blank: true, + max_length: null, + text_format: null, + }, + { + key: 'require_upstream_artifact', + label: 'Require Upstream Artifact', + type: 'boolean', + description: 'Fail when no matching artifact is wired.', + section: 'Output', + default: false, + min: null, + max: null, + step: null, + unit: null, + options: [], + allow_blank: true, + max_length: null, + text_format: null, + }, + ], + execution_kind: 'bridge', + legacy_compatible: true, + input_contract: { context: 'order_line', requires: ['order_line_context'], requires_any: ['rendered_image'] }, + output_contract: { context: 'order_line', provides: ['workflow_result'] }, + artifact_roles_consumed: ['rendered_image'], + artifact_roles_produced: ['workflow_result'], + legacy_source: 'legacy.output_save', +} + +const exportBlendDefinition: WorkflowNodeDefinition = { + step: 'export_blend', + label: 'Export Blend', + family: 'order_line', + module_key: 'media.export_blend', + category: 'output', + description: 'Persist the generated .blend file.', + node_type: 'outputNode', + icon: 'download', + defaults: { + output_name_suffix: '', + }, + fields: [ + { + key: 'output_name_suffix', + label: 'Output Name Suffix', + type: 'text', + description: 'Optional suffix appended to the emitted filename.', + section: 'Output', + default: '', + min: null, + max: null, + step: null, + unit: null, + options: [], + allow_blank: true, + max_length: 64, + text_format: 'safe_filename_suffix', + }, + ], + execution_kind: 'bridge', + legacy_compatible: true, + input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template'] }, + output_contract: { context: 'order_line', provides: ['blend_asset'] }, + artifact_roles_consumed: ['order_line_context', 'render_template'], + artifact_roles_produced: ['blend_asset'], + legacy_source: 'legacy.export_blend', +} + +const blenderStillDefinition: WorkflowNodeDefinition = { + step: 'blender_still', + label: 'Render Still', + family: 'order_line', + module_key: 'render.production.still', + category: 'rendering', + description: 'Render a still image.', + node_type: 'renderNode', + icon: 'camera', + defaults: { use_custom_render_settings: false }, + fields: [ + { + key: 'use_custom_render_settings', + label: 'Custom Render Settings', + type: 'boolean', + description: 'Enable explicit render overrides.', + section: 'Render', + default: false, + min: null, + max: null, + step: null, + unit: null, + options: [], + allow_blank: true, + max_length: null, + text_format: null, + }, + { + key: 'samples', + label: 'Samples', + type: 'number', + description: 'Render samples.', + section: 'Render', + default: 256, + min: 1, + max: 4096, + step: 1, + unit: null, + options: [], + allow_blank: true, + max_length: null, + text_format: null, + }, + { + key: 'noise_threshold', + label: 'Noise Threshold', + type: 'text', + description: 'Adaptive sampling threshold.', + section: 'Denoising', + default: '', + min: null, + max: null, + step: null, + unit: null, + options: [], + allow_blank: true, + max_length: null, + text_format: null, + }, + { + key: 'focal_length_mm', + label: 'Focal Length', + type: 'number', + description: 'Lens override.', + section: 'Camera', + default: null, + min: 1, + max: 500, + step: 0.1, + unit: 'mm', + options: [], + allow_blank: true, + max_length: null, + text_format: null, + }, + { + key: 'target_collection', + label: 'Target Collection', + type: 'text', + description: 'Collection name.', + section: 'Scene', + default: 'Product', + min: null, + max: null, + step: null, + unit: null, + options: [], + allow_blank: true, + max_length: null, + text_format: null, + }, + ], + execution_kind: 'native', + legacy_compatible: true, + input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template', 'bbox'] }, + output_contract: { context: 'order_line', provides: ['rendered_image'] }, + artifact_roles_consumed: ['order_line_context', 'render_template', 'bbox'], + artifact_roles_produced: ['rendered_image'], + legacy_source: 'legacy.blender_still', +} + +const blenderTurntableDefinition: WorkflowNodeDefinition = { + step: 'blender_turntable', + label: 'Render Turntable', + family: 'order_line', + module_key: 'render.production.turntable', + category: 'rendering', + description: 'Render a turntable animation.', + node_type: 'renderNode', + icon: 'video', + defaults: { use_custom_render_settings: false }, + fields: [ + { + key: 'use_custom_render_settings', + label: 'Custom Render Settings', + type: 'boolean', + description: 'Enable explicit render overrides.', + section: 'Render', + default: false, + min: null, + max: null, + step: null, + unit: null, + options: [], + allow_blank: true, + max_length: null, + text_format: null, + }, + { + key: 'fps', + label: 'Frames Per Second', + type: 'number', + description: 'Playback speed.', + section: 'Animation', + default: 24, + min: 1, + max: 120, + step: 1, + unit: null, + options: [], + allow_blank: true, + max_length: null, + text_format: null, + }, + { + key: 'turntable_axis', + label: 'Turntable Axis', + type: 'select', + description: 'Rotation axis.', + section: 'Animation', + default: 'z', + min: null, + max: null, + step: null, + unit: null, + options: [ + { value: 'x', label: 'X' }, + { value: 'y', label: 'Y' }, + { value: 'z', label: 'Z' }, + ], + allow_blank: false, + max_length: null, + text_format: null, + }, + { + key: 'bg_color', + label: 'Background Color', + type: 'text', + description: 'Background override.', + section: 'Render', + default: '#ffffff', + min: null, + max: null, + step: null, + unit: null, + options: [], + allow_blank: true, + max_length: null, + text_format: null, + }, + { + key: 'camera_orbit', + label: 'Camera Orbit', + type: 'boolean', + description: 'Orbit camera around the product.', + section: 'Scene', + default: true, + min: null, + max: null, + step: null, + unit: null, + options: [], + allow_blank: true, + max_length: null, + text_format: null, + }, + ], + execution_kind: 'native', + legacy_compatible: true, + input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template', 'bbox'] }, + output_contract: { context: 'order_line', provides: ['rendered_video'] }, + artifact_roles_consumed: ['order_line_context', 'render_template', 'bbox'], + artifact_roles_produced: ['rendered_video'], + legacy_source: 'legacy.blender_turntable', +} + function createRenderTemplate(overrides: Partial = {}): RenderTemplate { return { id: '0d87b85f-c454-4d61-a124-d5b59e6a43a2', @@ -256,14 +581,60 @@ describe('WorkflowNodeInspector', () => { ) expect(screen.getByText('This node has no editor settings.')).toBeInTheDocument() - expect(screen.getByText(/each required upstream input gets its own socket/i)).toBeInTheDocument() - expect(screen.getByText(/0 local variables by design/i)).toBeInTheDocument() - expect(screen.getByText('Socket 1')).toBeInTheDocument() + expect( + screen.getAllByText( + 'This node accepts one upstream artifact from any of: rendered image / rendered frames / rendered video / workflow result / blend asset.', + ).length, + ).toBeGreaterThan(0) + expect(screen.getAllByText(/0 local variables by design/i).length).toBeGreaterThan(0) + expect(screen.getByText('Alternative Groups')).toBeInTheDocument() + expect(screen.getByText('Group 1')).toBeInTheDocument() + expect(screen.getByText('Validation Watchpoints')).toBeInTheDocument() + expect(screen.getByText('Watch Artifact Flow')).toBeInTheDocument() + expect(screen.getByText('Watch Runtime Gap')).toBeInTheDocument() expect( screen.getAllByText('Any of: Rendered Image / Rendered Frames / Rendered Video / Workflow Result / Blend Asset').length, ).toBeGreaterThan(0) }) + test('explains context-entry nodes as context-supplied instead of misconfigured', async () => { + listRenderTemplates.mockResolvedValue([]) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + render( + + + , + ) + + expect(screen.getByText('Context Entry')).toBeInTheDocument() + expect( + screen.getAllByText( + 'Workflow context supplies Order Line Record. No additional upstream sockets are required.', + ).length, + ).toBeGreaterThan(0) + expect(screen.getAllByText('Workflow context already provides Order Line Record.').length).toBeGreaterThan(0) + expect(screen.getAllByText(/0 local variables by design/i).length).toBeGreaterThan(0) + expect(screen.getByText('Validation Watchpoints')).toBeInTheDocument() + expect(screen.getByText('Watch Context')).toBeInTheDocument() + expect(screen.queryByText('Socket 1')).not.toBeInTheDocument() + }) + test('summarizes wired inputs and inspector variables separately', async () => { listRenderTemplates.mockResolvedValue([ createRenderTemplate({ @@ -298,10 +669,289 @@ describe('WorkflowNodeInspector', () => { }) await user.selectOptions(templateOverride, '0d87b85f-c454-4d61-a124-d5b59e6a43a2') - expect(await screen.findByText(/1 canvas socket is required/i)).toBeInTheDocument() + expect(await screen.findByText(/1 required socket is exposed on the canvas/i)).toBeInTheDocument() + expect(screen.getAllByText('This node waits for Order Line Context from upstream.').length).toBeGreaterThan(0) + expect(screen.getByText('Required Sockets')).toBeInTheDocument() expect(screen.getByText('Socket 1')).toBeInTheDocument() expect(await screen.findByText(/2 local variables are edited in the inspector/i)).toBeInTheDocument() expect(screen.getByText(/Static: Template Override/i)).toBeInTheDocument() expect(screen.getByText(/Template-driven: Studio Variant/i)).toBeInTheDocument() + expect(screen.getByText('Watch Legacy Drift')).toBeInTheDocument() + }) + + test('shows automatic template variable coverage before a template override is selected', async () => { + listRenderTemplates.mockResolvedValue([ + createRenderTemplate({ + id: 'template-a', + name: 'Bearing Studio', + output_type_names: ['Still'], + workflow_input_schema: [ + { + key: 'studio_variant', + label: 'Studio Variant', + type: 'select', + section: 'Template Inputs', + description: 'Choose the blend lighting preset.', + default: 'default', + min: null, + max: null, + step: null, + unit: null, + options: [ + { value: 'default', label: 'Default' }, + { value: 'warm', label: 'Warm' }, + ], + allow_blank: false, + }, + ], + }), + createRenderTemplate({ + id: 'template-b', + name: 'Shadow Studio', + output_type_names: ['Shadow Still'], + workflow_input_schema: [ + { + key: 'shadow_density', + label: 'Shadow Density', + type: 'number', + section: 'Template Inputs', + description: 'Shadow catcher strength.', + default: 0.65, + min: 0, + max: 1, + step: 0.05, + unit: null, + options: [], + allow_blank: false, + }, + { + key: 'studio_variant', + label: 'Studio Variant', + type: 'select', + section: 'Template Inputs', + description: 'Choose the blend lighting preset.', + default: 'default', + min: null, + max: null, + step: null, + unit: null, + options: [ + { value: 'default', label: 'Default' }, + { value: 'dramatic', label: 'Dramatic' }, + ], + allow_blank: false, + }, + ], + }), + ]) + + renderInspector({}) + + expect(await screen.findByText('Automatic Resolution Coverage')).toBeInTheDocument() + expect(screen.getByText(/2 active templates expose 2 unique workflow variables/i)).toBeInTheDocument() + expect(screen.getByText('Potential Template Variables')).toBeInTheDocument() + expect(screen.getAllByText('Studio Variant').length).toBeGreaterThan(0) + expect(screen.getByText(/Available via Bearing Studio, Shadow Studio \(Still, Shadow Still\)\./i)).toBeInTheDocument() + expect(screen.getByText(/Available via Shadow Studio \(Shadow Still\)\./i)).toBeInTheDocument() + }) + + test('disables contract-owned still render fields until custom render settings are enabled', async () => { + listRenderTemplates.mockResolvedValue([]) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + render( + + + , + ) + + expect(screen.getByText('Render Override Scope')).toBeInTheDocument() + expect(screen.getByLabelText('Samples')).toBeDisabled() + expect(screen.getByLabelText('Noise Threshold')).toBeDisabled() + expect(screen.getByLabelText('Focal Length (mm)')).toBeDisabled() + expect(screen.getByLabelText('Target Collection')).toBeDisabled() + }) + + test('disables contract-owned turntable fields until custom render settings are enabled', async () => { + listRenderTemplates.mockResolvedValue([]) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + render( + + + , + ) + + expect(screen.getByLabelText('Frames Per Second')).toBeDisabled() + expect(screen.getByLabelText('Turntable Axis')).toBeDisabled() + expect(screen.getByLabelText('Background Color')).toBeDisabled() + expect(screen.getByLabelText('Camera Orbit')).toBeDisabled() + }) + + test('explains output handoff semantics for output_save nodes', async () => { + listRenderTemplates.mockResolvedValue([]) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + render( + + + , + ) + + expect(screen.getByText('Output Handoff')).toBeInTheDocument() + expect(screen.getByText(/does not render files itself/i)).toBeInTheDocument() + expect(screen.getByText('render_output')).toBeInTheDocument() + expect(screen.getByText(/shadow runs stay observer-only/i)).toBeInTheDocument() + expect( + screen.getAllByText( + 'This node requires Order Line Context and one additional upstream artifact from any of: rendered image.', + ).length, + ).toBeGreaterThan(0) + expect(screen.getByText(/1 required socket and 1 alternative group are exposed on the canvas/i)).toBeInTheDocument() + expect(screen.getByText('Required Sockets')).toBeInTheDocument() + expect(screen.getByText('Alternative Groups')).toBeInTheDocument() + }) + + test('explains blend delivery semantics for export_blend nodes', async () => { + listRenderTemplates.mockResolvedValue([]) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + render( + + + , + ) + + expect(screen.getAllByText('Blend Delivery').length).toBeGreaterThan(0) + expect(screen.getByText(/does not render pixels itself/i)).toBeInTheDocument() + expect(screen.getByText('studio')).toBeInTheDocument() + expect(screen.getByText(/save output/i)).toBeInTheDocument() + }) + + test('explains notification handoff semantics for notify nodes', async () => { + listRenderTemplates.mockResolvedValue([]) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + render( + + + , + ) + + expect(screen.getAllByText('Notification Handoff').length).toBeGreaterThan(0) + expect(screen.getByText(/does not emit independently/i)).toBeInTheDocument() + expect(screen.getByText('audit_log')).toBeInTheDocument() + expect(screen.getByText(/shadow runs suppress user notifications entirely/i)).toBeInTheDocument() }) }) diff --git a/frontend/src/__tests__/components/workflowAuthoringGuidance.test.ts b/frontend/src/__tests__/components/workflowAuthoringGuidance.test.ts index b3a8b34..b097caa 100644 --- a/frontend/src/__tests__/components/workflowAuthoringGuidance.test.ts +++ b/frontend/src/__tests__/components/workflowAuthoringGuidance.test.ts @@ -166,13 +166,14 @@ describe('workflow authoring guidance', () => { test('derives a single shared order-line authoring plan', () => { const plan = getWorkflowAuthoringPlan(definitions, 'order_line', ['blender_still']) - expect(plan.referenceBundles).toHaveLength(1) - expect(plan.moduleBundles).toHaveLength(2) + expect(plan.referenceBundles).toHaveLength(2) + expect(plan.moduleBundles).toHaveLength(4) expect(plan.referenceBundles[0]?.presentCount).toBe(1) expect(plan.moduleBundles.find(bundle => bundle.id === 'still_render_core')?.presentCount).toBe(1) expect(plan.stageProgress.map(stage => stage.id)).toEqual([ 'still_render_reference', - 'still_render_core', + 'scene_prep_core', + 'materials_core', 'output_publish_notify', 'order_line_setup', ]) @@ -209,6 +210,8 @@ describe('workflow authoring guidance', () => { ]) expect(surface.plan.referenceBundles[0]?.id).toBe('still_render_reference') expect(surface.plan.moduleBundles.map(bundle => bundle.id)).toEqual([ + 'scene_prep_core', + 'materials_core', 'still_render_core', 'output_publish_notify', ]) diff --git a/frontend/src/__tests__/components/workflowEditorUi.test.tsx b/frontend/src/__tests__/components/workflowEditorUi.test.tsx index 27f104e..697cf5b 100644 --- a/frontend/src/__tests__/components/workflowEditorUi.test.tsx +++ b/frontend/src/__tests__/components/workflowEditorUi.test.tsx @@ -12,6 +12,7 @@ import { NodeCommandMenu } from '../../components/workflows/NodeCommandMenu' import { NodeDefinitionsPanel } from '../../components/workflows/NodeDefinitionsPanel' import { WorkflowCanvasToolbar } from '../../components/workflows/WorkflowCanvasToolbar' import { WorkflowNodeContractCard } from '../../components/workflows/WorkflowNodeContractCard' +import { WorkflowValidationBanner } from '../../components/workflows/WorkflowValidationBanner' import { WorkflowPreflightPanel } from '../../components/workflows/WorkflowPreflightPanel' import { WorkflowRunsPanel } from '../../components/workflows/WorkflowRunsPanel' import { @@ -392,13 +393,29 @@ describe('WorkflowNodeContractCard', () => { runtimeClassName="bg-green-100 text-green-700" legacyCompatible legacySource="legacy.still_render" - inputContextLabel="Order Rendering" - outputContextLabel="Order Rendering" - requiredInputs={['order_line', 'render_template']} - requiredAnyInputs={[['rendered_image', 'rendered_frames']]} - consumedArtifacts={['cad_preview']} - providedOutputs={['render_image']} - producedArtifacts={['png_output']} + contract={{ + inputContextLabel: 'Order Line', + outputContextLabel: 'Order Line', + contextInputs: [], + requiredInputs: ['order_line', 'render_template'], + requiredAnyInputs: [['rendered_image', 'rendered_frames']], + consumedArtifacts: ['cad_preview'], + providedOutputs: ['render_image'], + producedArtifacts: ['png_output'], + editableFieldCount: 0, + editableFieldLabels: [], + dynamicVariableHint: null, + authoringPatternLabel: 'Connection-Driven', + authoringPatternDescription: + 'Configure this node by wiring upstream artifacts. It has no local inspector variables.', + }} + validationWatchpoints={[ + { + kind: 'legacy-drift', + label: 'Legacy Drift', + reason: 'Template resolution must stay aligned with the legacy path.', + }, + ]} />, ) @@ -410,8 +427,30 @@ describe('WorkflowNodeContractCard', () => { expect(screen.getByText('Render Template')).toBeInTheDocument() expect(screen.getByText('Any of: Rendered Image / Rendered Frames')).toBeInTheDocument() expect(screen.getByText('CAD Preview')).toBeInTheDocument() - expect(screen.getByText('Render Image')).toBeInTheDocument() + expect(screen.getAllByText('Render Image').length).toBeGreaterThan(0) expect(screen.getByText('Png Output')).toBeInTheDocument() + expect(screen.getByText('Validation Watchpoints')).toBeInTheDocument() + expect(screen.getByText('Watch Legacy Drift')).toBeInTheDocument() + }) +}) + +describe('WorkflowValidationBanner', () => { + test('surfaces validation categories before preflight', () => { + render( + , + ) + + expect(screen.getAllByText('Artifact Flow: 1').length).toBeGreaterThan(0) + expect(screen.getAllByText('Context: 1').length).toBeGreaterThan(0) + expect(screen.getAllByText('Legacy Drift: 1').length).toBeGreaterThan(0) }) }) @@ -473,8 +512,20 @@ describe('WorkflowCanvasToolbar', () => { orderId: 'order-1', orderLabel: 'ORD-1001', options: [ - { value: 'line-1', label: 'Product A · Still', meta: 'ORD-1001 · pending' }, - { value: 'line-2', label: 'Product B · Still', meta: 'ORD-1001 · completed' }, + { + value: 'line-1', + label: 'Product A · Still', + meta: 'ORD-1001 · pending', + isRenderable: true, + renderabilityReason: null, + }, + { + value: 'line-2', + label: 'Product B · Still', + meta: 'ORD-1001 · completed', + isRenderable: false, + renderabilityReason: 'order_closed', + }, ], }, ]} @@ -498,7 +549,7 @@ describe('WorkflowCanvasToolbar', () => { authoringEntryAction={{ label: 'Author', title: 'Open guided workflow authoring browser', - helper: 'Open reference paths, production modules, starter steps, and raw nodes.', + helper: 'Open reference paths, stage modules, starter steps, and raw nodes.', icon: () => null, }} onDispatchContextIdChange={onDispatchContextIdChange} @@ -523,7 +574,7 @@ describe('WorkflowCanvasToolbar', () => { expect(screen.getByText('Legacy Archive Output')).toBeInTheDocument() expect(screen.getAllByText('Order Line').length).toBeGreaterThan(0) expect(screen.getAllByText('Product A · Still').length).toBeGreaterThan(0) - expect(screen.getByText('Right-click to add')).toBeInTheDocument() + expect(screen.getByText('Add nodes')).toBeInTheDocument() expect(screen.getByText('Preflight ready')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Delete (2)' })).toBeEnabled() const rollbackButtons = screen.getAllByRole('button', { name: 'Set Legacy' }) @@ -594,8 +645,8 @@ describe('WorkflowCanvasToolbar', () => { authoringActions={{ openNodeMenu: vi.fn() }} authoringEntryAction={{ label: 'Node', - title: 'Open raw node browser', - helper: 'Open the searchable node catalog directly on the canvas.', + title: 'Open raw node catalog', + helper: 'Open the searchable raw node catalog directly on the canvas.', icon: () => null, }} onDispatchContextIdChange={vi.fn()} @@ -634,12 +685,13 @@ describe('NodeCommandMenu', () => { ) await user.click(screen.getByRole('button', { name: 'Graph' })) - await user.type(screen.getByPlaceholderText('Search nodes'), 'blender{enter}') + await user.type(screen.getByPlaceholderText('Search raw nodes'), 'blender{enter}') expect(onSelectStep).toHaveBeenCalledWith('blender_still') expect(screen.getByRole('button', { name: 'All Categories' })).toBeInTheDocument() expect(screen.getByText('Quick Insert')).toBeInTheDocument() expect(screen.getByText('Graph Nodes')).toBeInTheDocument() + expect(screen.getByText('Guided First')).toBeInTheDocument() }) test('supports module insertion directly from the canvas authoring menu', async () => { @@ -664,27 +716,31 @@ describe('NodeCommandMenu', () => { ) expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Paths' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Modules' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Starter' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Nodes' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Reference Paths' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Stage Modules' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Starter Steps' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Raw Nodes' })).toBeInTheDocument() expect(screen.getByText('Recommended Path')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Insert Still Reference' })).toBeInTheDocument() + expect(screen.getByText('Reference Baselines')).toBeInTheDocument() + expect(screen.getAllByText('Stage Modules').length).toBeGreaterThan(0) + expect(screen.getAllByRole('button', { name: 'Insert Scene Prep' }).length).toBeGreaterThan(0) + expect(screen.getAllByRole('button', { name: 'Insert Materials' }).length).toBeGreaterThan(0) await user.click(screen.getByRole('button', { name: 'Insert Still Reference' })) expect(onInsertReferencePath).toHaveBeenCalledWith('still_render_reference') - await user.click(screen.getByRole('button', { name: 'Paths' })) - expect(screen.getByText('Reference Paths')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Reference Paths' })) + expect(screen.getAllByText('Reference Paths').length).toBeGreaterThan(0) expect(screen.getByRole('button', { name: 'Insert Still Render Reference' })).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Insert Still Render Reference' })) expect(onInsertReferencePath).toHaveBeenNthCalledWith(2, 'still_render_reference') - await user.click(screen.getByRole('button', { name: 'Modules' })) - expect(screen.getByText('Production Modules')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Stage Modules' })) + expect(screen.getAllByText('Stage Modules').length).toBeGreaterThan(0) await user.click(screen.getByRole('button', { name: 'Insert Still Render Core' })) @@ -697,33 +753,36 @@ describe('NodeDefinitionsPanel', () => { const user = userEvent.setup() render() - expect(screen.getByText('Node Library')).toBeInTheDocument() expect(screen.getByText('Authoring Browser')).toBeInTheDocument() expect(screen.getByText('Authoring Flow')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Paths' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Modules' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Nodes' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Reference Paths' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Stage Modules' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Raw Nodes' })).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Paths' })) - expect(screen.getByText('Reference Paths')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Reference Paths' })) + expect(screen.getAllByText('Reference Paths').length).toBeGreaterThan(0) expect(screen.getByText('Still Render Reference')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Modules' })) - expect(screen.getByText('Production Modules')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Stage Modules' })) + expect(screen.getAllByText('Stage Modules').length).toBeGreaterThan(0) expect(screen.getByText('Still Render Core')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Nodes' })) + await user.click(screen.getByRole('button', { name: 'Raw Nodes' })) expect(screen.getAllByText('Raw Node Catalog').length).toBeGreaterThan(0) expect(screen.getByText('Quick Insert')).toBeInTheDocument() expect(screen.getByText('Runtime')).toBeInTheDocument() expect(screen.getByText('Family')).toBeInTheDocument() expect(screen.getByText('Category')).toBeInTheDocument() + expect(screen.getByText('Stage Coverage')).toBeInTheDocument() expect(screen.getByPlaceholderText('Search modules')).toBeInTheDocument() expect(screen.getAllByText('CAD Intake').length).toBeGreaterThan(0) expect(screen.getAllByText('Order Rendering').length).toBeGreaterThan(0) expect(screen.getByText('Legacy Nodes')).toBeInTheDocument() expect(screen.getByText('Graph Nodes')).toBeInTheDocument() expect(screen.getAllByText('Blender Still').length).toBeGreaterThan(0) + expect(screen.getAllByText('Wiring').length).toBeGreaterThan(0) + expect(screen.getAllByText('Variables').length).toBeGreaterThan(0) + expect(screen.getAllByText(/Watch Artifact Flow/).length).toBeGreaterThan(0) expect(screen.getAllByText('Graph').length).toBeGreaterThan(0) expect(screen.getByRole('button', { name: 'All Modules' })).toBeInTheDocument() expect(screen.getAllByText('Cad').length).toBeGreaterThan(0) @@ -759,18 +818,22 @@ describe('NodeDefinitionsPanel', () => { ).toBeTruthy() expect(screen.getByText('Still Render Reference')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Reapply Still Reference' })).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: 'Insert Scene Prep' }).length).toBeGreaterThan(0) + expect(screen.getAllByRole('button', { name: 'Insert Materials' }).length).toBeGreaterThan(0) expect(screen.getAllByRole('button', { name: 'Insert Publish' }).length).toBeGreaterThan(0) expect(screen.getAllByRole('button', { name: 'Add Order Line Setup' }).length).toBeGreaterThan(0) await user.click(screen.getByRole('button', { name: 'Reapply Still Reference' })) + await user.click(screen.getAllByRole('button', { name: 'Insert Scene Prep' })[0] as HTMLElement) await user.click(screen.getAllByRole('button', { name: 'Insert Publish' })[0] as HTMLElement) await user.click(screen.getAllByRole('button', { name: 'Add Order Line Setup' })[0] as HTMLElement) expect(onInsertReferencePath).toHaveBeenCalledWith('still_render_reference') + expect(onInsertModule).toHaveBeenCalledWith('scene_prep_core') expect(onInsertModule).toHaveBeenCalledWith('output_publish_notify') expect(onSelectStep).toHaveBeenCalledWith('order_line_setup') - await user.click(screen.getByRole('button', { name: 'Starter' })) + await user.click(screen.getByRole('button', { name: 'Starter Steps' })) expect(screen.getByText('Starter Path')).toBeInTheDocument() expect(screen.getAllByText('Still-render assembly').length).toBeGreaterThan(0) expect(screen.getAllByText('1/8 present').length).toBeGreaterThan(0) @@ -810,11 +873,11 @@ describe('NodeDefinitionsPanel', () => { expect(onInsertModule).not.toHaveBeenCalled() expect(onSelectStep).toHaveBeenCalledWith('occ_object_extract') - await user.click(screen.getByRole('button', { name: 'Paths' })) - expect(screen.getByText('Reference Paths')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Reference Paths' })) + expect(screen.getAllByText('Reference Paths').length).toBeGreaterThan(0) expect(screen.getByRole('button', { name: 'Insert CAD Intake Reference' })).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Starter' })) + await user.click(screen.getByRole('button', { name: 'Starter Steps' })) expect(screen.getByText('Starter Path')).toBeInTheDocument() expect(screen.getAllByText('CAD intake assembly').length).toBeGreaterThan(0) expect(screen.getAllByText('1/5 present').length).toBeGreaterThan(0) @@ -833,7 +896,7 @@ describe('NodeDefinitionsPanel', () => { />, ) - await user.click(screen.getByRole('button', { name: 'Nodes' })) + await user.click(screen.getByRole('button', { name: 'Raw Nodes' })) const blenderCard = screen.getAllByText('Blender Still')[0]?.closest('div.rounded-lg') expect(blenderCard).not.toBeNull() await user.click(within(blenderCard as HTMLElement).getByRole('button', { name: 'Insert Blender Still' })) @@ -854,7 +917,7 @@ describe('NodeDefinitionsPanel', () => { />, ) - await user.click(screen.getByRole('button', { name: 'Modules' })) + await user.click(screen.getByRole('button', { name: 'Stage Modules' })) await user.click(screen.getByRole('button', { name: 'Insert Still Render Core' })) expect(onInsertModule).toHaveBeenCalledWith('still_render_core') @@ -909,7 +972,7 @@ describe('workflowAuthoringActions', () => { expect(guidedEntry.label).toBe('Author') expect(guidedEntry.title).toContain('guided workflow authoring') expect(rawEntry.label).toBe('Node') - expect(rawEntry.title).toContain('raw node browser') + expect(rawEntry.title).toContain('raw node catalog') }) }) @@ -953,14 +1016,109 @@ describe('WorkflowPreflightPanel', () => { expect(screen.getByText('Graph Preflight')).toBeInTheDocument() expect(screen.getByText('Graph requires one missing upstream artifact.')).toBeInTheDocument() - expect(screen.getByText('Missing cad_preview artifact.')).toBeInTheDocument() + expect(screen.getAllByText('Missing cad_preview artifact.').length).toBeGreaterThan(0) expect(screen.getByText('Mode: graph')).toBeInTheDocument() + expect(screen.getByText('Blocking: 0')).toBeInTheDocument() + expect(screen.getByText('Warnings: 2')).toBeInTheDocument() + expect(screen.getByText('Next Actions')).toBeInTheDocument() + expect(screen.getByText('Warnings can be addressed incrementally')).toBeInTheDocument() + expect( + screen.getAllByText( + 'Add the missing upstream node or connection so the required artifact is available before this step.', + ).length, + ).toBeGreaterThan(0) + expect(screen.getAllByText('Artifact Flow: 2').length).toBeGreaterThan(0) + expect(screen.getAllByText('Type: Artifact Flow').length).toBeGreaterThan(0) expect(screen.getByText('Unsupported Node IDs')).toBeInTheDocument() expect(screen.getByText('node-legacy-1')).toBeInTheDocument() - expect(screen.getByText('Code: missing-artifact')).toBeInTheDocument() + expect(screen.getAllByText('Code: missing-artifact').length).toBeGreaterThan(0) expect(screen.getByText('Runtime: native')).toBeInTheDocument() expect(screen.getByText('Supported: yes')).toBeInTheDocument() expect(screen.getByText('cad_preview must be produced upstream.')).toBeInTheDocument() expect(screen.getByText('blocked')).toBeInTheDocument() }) + + test('separates context, runtime-gap, and legacy-drift guidance', () => { + const richerPreflight: WorkflowPreflightResponse = { + workflow_id: 'wf-2', + context_id: 'bad-context', + context_kind: null, + expected_context_kind: 'order_line', + execution_mode: 'graph', + graph_dispatch_allowed: false, + summary: 'Preflight found blocking issues that would prevent a safe graph dispatch.', + resolved_order_line_id: null, + resolved_cad_file_id: null, + unsupported_node_ids: ['node-native-gap'], + issues: [ + { + severity: 'error', + code: 'context_not_found', + message: 'Context ID did not match an existing order line or CAD file.', + node_id: null, + step: null, + }, + ], + nodes: [ + { + node_id: 'node-native-gap', + step: 'notify', + label: 'Notify Result', + execution_kind: 'bridge', + supported: false, + status: 'unsupported', + issues: [ + { + severity: 'error', + code: 'unsupported_node', + message: "Graph runtime has no executable implementation for step 'notify'.", + node_id: 'node-native-gap', + step: 'notify', + }, + ], + }, + { + node_id: 'node-template', + step: 'blender_still', + label: 'Blender Still', + execution_kind: 'native', + supported: true, + status: 'warning', + issues: [ + { + severity: 'warning', + code: 'missing_resolve_template', + message: 'No earlier resolve_template node found. Render defaults may drift from legacy behavior.', + node_id: 'node-template', + step: 'blender_still', + }, + ], + }, + ], + } + + render() + + expect(screen.getAllByText('Context: 1').length).toBeGreaterThan(0) + expect(screen.getAllByText('Runtime Gap: 1').length).toBeGreaterThan(0) + expect(screen.getAllByText('Legacy Drift: 1').length).toBeGreaterThan(0) + expect( + screen.getAllByText( + 'Pick an existing order line or CAD file. The supplied ID does not resolve to a stored workflow context.', + ).length, + ).toBeGreaterThan(0) + expect( + screen.getAllByText( + 'Keep this path on legacy/bridge execution for now, or replace the node with a supported graph module.', + ).length, + ).toBeGreaterThan(0) + expect( + screen.getAllByText( + 'Add a "Resolve Template" node before render or export nodes to keep graph behavior aligned with legacy.', + ).length, + ).toBeGreaterThan(0) + expect(screen.getAllByText('Type: Context').length).toBeGreaterThan(0) + expect(screen.getAllByText('Type: Runtime Gap').length).toBeGreaterThan(0) + expect(screen.getAllByText('Type: Legacy Drift').length).toBeGreaterThan(0) + }) }) diff --git a/frontend/src/__tests__/components/workflowGraphDraft.test.ts b/frontend/src/__tests__/components/workflowGraphDraft.test.ts index ece37e5..a344e62 100644 --- a/frontend/src/__tests__/components/workflowGraphDraft.test.ts +++ b/frontend/src/__tests__/components/workflowGraphDraft.test.ts @@ -3,12 +3,16 @@ import { describe, expect, test } from 'vitest' import type { WorkflowNodeDefinition } from '../../api/workflows' import { + applyAutoLayout, buildWorkflowCanvasNodeData, + findOpenNodePosition, graphNeedsAutoLayout, resolveParamsForStepChange, resolveNodeCollisions, validateWorkflowDraft, + WORKFLOW_NODE_HORIZONTAL_GAP, WORKFLOW_NODE_MIN_HEIGHT, + WORKFLOW_NODE_WIDTH, WORKFLOW_NODE_VERTICAL_GAP, workflowToGraph, } from '../../components/workflows/workflowGraphDraft' @@ -472,6 +476,53 @@ describe('validateWorkflowDraft', () => { }) }) +describe('buildWorkflowCanvasNodeData', () => { + test('reuses normalized contract sockets for alternative and provided roles', () => { + const nodeData = buildWorkflowCanvasNodeData('output_save', {}, { + ...definitions.output_save, + input_contract: { + context: 'order_line', + requires: ['order_line_context'], + }, + output_contract: { + context: 'order_line', + provides: ['media_asset', 'workflow_result'], + }, + artifact_roles_consumed: ['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset'], + artifact_roles_produced: ['media_asset', 'workflow_result'], + }) + + expect(nodeData.inputPorts).toEqual([ + { + id: 'input:order_line_context', + label: 'Order Line Context', + roles: ['order_line_context'], + kind: 'required', + }, + { + id: 'input-any:rendered_image|rendered_frames|rendered_video|blend_asset', + label: 'Any of: Rendered Image / Rendered Frames / Rendered Video / Blend Asset', + roles: ['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset'], + kind: 'alternative', + }, + ]) + expect(nodeData.outputPorts).toEqual([ + { + id: 'output:media_asset', + label: 'Media Asset', + roles: ['media_asset'], + kind: 'provided', + }, + { + id: 'output:workflow_result', + label: 'Workflow Result', + roles: ['workflow_result'], + kind: 'provided', + }, + ]) + }) +}) + describe('resolveParamsForStepChange', () => { test('keeps only parameters supported by the target step schema', () => { const next = resolveParamsForStepChange(definitions.blender_still, { @@ -503,6 +554,18 @@ describe('resolveParamsForStepChange', () => { }) describe('resolveNodeCollisions', () => { + test('prefers structured placement to the right or below before searching left/up', () => { + const nodes = [ + createPositionedNode('anchor', 'order_line_setup', 56, 48, 'Anchor'), + createPositionedNode('right', 'resolve_template', 56 + WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP, 48, 'Right'), + ] + + const nextPosition = findOpenNodePosition(nodes, { x: 56, y: 48 }) + + expect(nextPosition.x).toBe(56) + expect(nextPosition.y).toBeGreaterThanOrEqual(48 + WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP) + }) + test('pushes overlapping nodes away from a settled anchor without moving the anchor', () => { const nodes = [ createPositionedNode('anchor', 'order_line_setup', 56, 48, 'Anchor'), @@ -537,7 +600,53 @@ describe('resolveNodeCollisions', () => { }) }) +describe('applyAutoLayout', () => { + test('separates disconnected components into deterministic vertical bands', () => { + const nodes = [ + createPositionedNode('a', 'resolve_step_path', 0, 0, 'Resolve STEP Path'), + createPositionedNode('b', 'glb_bbox', 0, 0, 'Compute Bounding Box'), + createPositionedNode('c', 'order_line_setup', 0, 0, 'Order Line Setup'), + createPositionedNode('d', 'resolve_template', 0, 0, 'Resolve Template'), + ] + const edges = [createEdge('a', 'b'), createEdge('c', 'd')] + + const laidOut = applyAutoLayout(nodes, edges) + const firstComponentBottom = Math.max( + ...laidOut + .filter(node => node.id === 'a' || node.id === 'b') + .map(node => node.position.y + WORKFLOW_NODE_MIN_HEIGHT), + ) + const secondComponentTop = Math.min( + ...laidOut + .filter(node => node.id === 'c' || node.id === 'd') + .map(node => node.position.y), + ) + + expect(secondComponentTop - firstComponentBottom).toBeGreaterThanOrEqual( + WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP * 2, + ) + expect(laidOut.find(node => node.id === 'a')?.position.x).toBe(laidOut.find(node => node.id === 'c')?.position.x) + expect(laidOut.find(node => node.id === 'b')?.position.x).toBeGreaterThan( + laidOut.find(node => node.id === 'a')?.position.x ?? 0, + ) + expect(laidOut.find(node => node.id === 'd')?.position.x).toBeGreaterThan( + laidOut.find(node => node.id === 'c')?.position.x ?? 0, + ) + }) +}) + describe('workflowToGraph', () => { + test('keeps workflow-root record requirements out of canvas input sockets', () => { + const data = buildWorkflowCanvasNodeData('order_line_setup', {}, definitions.order_line_setup) + + expect(data.contextInputs).toEqual(['order_line_record']) + expect(data.inputPorts).toEqual([]) + expect(data.authoringPatternLabel).toBe('Context Entry') + expect(data.variableSummaryText).toBe( + 'This node has 0 local variables by design. Its behavior is driven entirely by connections and runtime context.', + ) + }) + test('derives explicit input and output ports from the node contract', () => { const data = buildWorkflowCanvasNodeData('blender_still', {}, definitions.blender_still) @@ -549,6 +658,9 @@ describe('workflowToGraph', () => { ]) expect(data.outputPorts?.map(port => port.label)).toEqual(['Rendered Image']) expect(data.editableFieldCount).toBe(2) + expect(data.socketRequirementDescription).toBe( + 'Wire 4 required upstream sockets: Order Line Context, Render Template, Material Assignments, and Bounding Box.', + ) }) test('assigns semantic handle ids to edges based on matching contracts', () => { diff --git a/frontend/src/__tests__/components/workflowModuleBundles.test.ts b/frontend/src/__tests__/components/workflowModuleBundles.test.ts index 1b0a98a..43a6965 100644 --- a/frontend/src/__tests__/components/workflowModuleBundles.test.ts +++ b/frontend/src/__tests__/components/workflowModuleBundles.test.ts @@ -160,6 +160,25 @@ const definitions: WorkflowNodeDefinition[] = [ artifact_roles_consumed: [], legacy_source: 'legacy.notify', }, + { + step: 'export_blend', + label: 'Export Blend', + family: 'order_line', + module_key: 'media.export_blend', + category: 'output', + description: 'Export blend.', + node_type: 'outputNode', + icon: 'download', + defaults: {}, + fields: [], + execution_kind: 'bridge', + legacy_compatible: true, + input_contract: { context: 'order_line', requires: ['render_template'] }, + output_contract: { context: 'order_line', provides: ['blend_asset'] }, + artifact_roles_produced: [], + artifact_roles_consumed: [], + legacy_source: 'legacy.export_blend', + }, ] const cadDefinitions: WorkflowNodeDefinition[] = [ @@ -302,12 +321,18 @@ describe('workflowModuleBundles', () => { test('exposes family-scoped bundles when required steps exist', () => { const bundles = getWorkflowModuleBundles(definitions, 'order_line') - expect(bundles.map(bundle => bundle.id)).toEqual(['still_render_core', 'output_publish_notify']) + expect(bundles.map(bundle => bundle.id)).toEqual([ + 'scene_prep_core', + 'materials_core', + 'still_render_core', + 'output_publish_notify', + 'blend_export_publish', + ]) }) - test('creates a connected bundle insertion graph for still-render authoring', () => { + test('creates a connected bundle insertion graph for scene-prep authoring', () => { const insertion = createWorkflowModuleBundleInsertion({ - bundleId: 'still_render_core', + bundleId: 'scene_prep_core', graphFamily: 'order_line', nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])), existingNodes: [], @@ -317,22 +342,44 @@ describe('workflowModuleBundles', () => { expect(insertion.ok).toBe(true) if (!insertion.ok) return - expect(insertion.nodes).toHaveLength(6) - expect(insertion.edges).toHaveLength(5) + expect(insertion.nodes).toHaveLength(3) + expect(insertion.edges).toHaveLength(2) expect(insertion.nodes[0].position).toEqual({ x: 200, y: 320 }) expect(insertion.nodes[1].position).toEqual({ x: 420, y: 320 }) expect(insertion.nodes[0].data).toMatchObject({ step: 'order_line_setup', label: 'Order Line Setup' }) - expect(insertion.nodes[5].data).toMatchObject({ step: 'blender_still', label: 'Blender Still' }) + expect(insertion.nodes[2].data).toMatchObject({ step: 'glb_bbox', label: 'Compute Bounding Box' }) expect(insertion.edges[0]).toMatchObject({ source: insertion.nodes[0].id, target: insertion.nodes[1].id, }) }) + test('creates a single-node still-render module for stage-level insertion', () => { + const insertion = createWorkflowModuleBundleInsertion({ + bundleId: 'still_render_core', + graphFamily: 'order_line', + nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])), + existingNodes: [], + preferredPosition: { x: 640, y: 180 }, + }) + + expect(insertion.ok).toBe(true) + if (!insertion.ok) return + + expect(insertion.nodes).toHaveLength(1) + expect(insertion.edges).toHaveLength(0) + expect(insertion.nodes[0].position).toEqual({ x: 640, y: 180 }) + expect(insertion.nodes[0].data).toMatchObject({ step: 'blender_still', label: 'Blender Still' }) + }) + test('exposes full reference paths for complete non-legacy authoring flows', () => { const bundles = getWorkflowReferenceBundles(definitions, 'order_line') - expect(bundles.map(bundle => bundle.id)).toEqual(['still_render_reference']) + expect(bundles.map(bundle => bundle.id)).toEqual([ + 'still_render_reference', + 'still_render_alpha_reference', + 'still_render_blend_reference', + ]) }) test('creates the canonical still-render reference graph with branched edges', () => { @@ -354,7 +401,13 @@ describe('workflowModuleBundles', () => { expect(insertion.nodes[5].data).toMatchObject({ step: 'blender_still', label: 'Still Render', - params: { use_custom_render_settings: true }, + params: { + use_custom_render_settings: false, + render_engine: 'cycles', + samples: 256, + width: 1920, + height: 1080, + }, }) expect(insertion.edges).toEqual( expect.arrayContaining([ @@ -386,12 +439,13 @@ describe('workflowModuleBundles', () => { expect(insertion.ok).toBe(true) if (!insertion.ok) return - expect(insertion.nodes).toHaveLength(8) - expect(insertion.edges).toHaveLength(7) + expect(insertion.nodes).toHaveLength(9) + expect(insertion.edges).toHaveLength(9) expect(insertion.nodes.map(node => node.data.step)).toEqual([ 'resolve_step_path', 'occ_object_extract', 'occ_glb_export', + 'glb_bbox', 'stl_cache_generate', 'blender_render', 'threejs_render', @@ -402,11 +456,15 @@ describe('workflowModuleBundles', () => { expect.arrayContaining([ expect.objectContaining({ source: insertion.nodes[2].id, - target: insertion.nodes[4].id, + target: insertion.nodes[5].id, }), expect.objectContaining({ source: insertion.nodes[2].id, - target: insertion.nodes[5].id, + target: insertion.nodes[6].id, + }), + expect.objectContaining({ + source: insertion.nodes[3].id, + target: insertion.nodes[6].id, }), ]), ) diff --git a/frontend/src/__tests__/components/workflowNodeContracts.test.ts b/frontend/src/__tests__/components/workflowNodeContracts.test.ts new file mode 100644 index 0000000..fd30c04 --- /dev/null +++ b/frontend/src/__tests__/components/workflowNodeContracts.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, test } from 'vitest' + +import type { WorkflowNodeDefinition } from '../../api/workflows' +import { + getWorkflowNodeContractPresentation, + getWorkflowNodeContractSummary, + getWorkflowNodeDeclaredOutputCount, + getWorkflowNodeContractSignals, + getWorkflowNodeInputSocketDescriptors, + getWorkflowNodeInputMetric, + getWorkflowNodeNoSettingsDescription, + getWorkflowNodeOutputSocketDescriptors, + getWorkflowNodeOutputMetric, + getWorkflowNodeSocketRequirementDescription, + getWorkflowNodeTotalVariableCount, + getWorkflowNodeValidationWatchpoints, + getWorkflowNodeVariableSummaryText, + getWorkflowNodeVariableMetric, +} from '../../components/workflows/workflowNodeContracts' + +function buildDefinition(overrides: Partial): WorkflowNodeDefinition { + return { + step: 'test_step', + label: 'Test Step', + family: 'order_line', + module_key: 'test.module', + category: 'processing', + description: 'Test definition', + node_type: 'processNode', + icon: 'box', + defaults: {}, + fields: [], + execution_kind: 'native', + legacy_compatible: false, + input_contract: {}, + output_contract: {}, + artifact_roles_produced: [], + artifact_roles_consumed: [], + legacy_source: null, + ...overrides, + } +} + +describe('workflowNodeContracts', () => { + test('normalizes output_save fallback alternative inputs into one contract summary', () => { + const summary = getWorkflowNodeContractSummary( + buildDefinition({ + step: 'output_save', + input_contract: { context: 'order_line', requires: ['order_line_context'] }, + output_contract: { context: 'order_line', provides: ['media_asset', 'workflow_result'] }, + }), + ) + + expect(summary.requiredInputs).toEqual(['order_line_context']) + expect(summary.requiredAnyInputs).toEqual([ + ['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset'], + ]) + expect(summary.inputContextLabel).toBe('Order Line') + expect(summary.outputContextLabel).toBe('Order Line') + }) + + test('normalizes notify fallback alternatives and avoids duplicate required roles', () => { + const summary = getWorkflowNodeContractSummary( + buildDefinition({ + step: 'notify', + input_contract: { context: 'order_line', requires: ['order_line_context', 'workflow_result'] }, + output_contract: { context: 'order_line', provides: ['notification_event'] }, + }), + ) + + expect(summary.requiredInputs).toEqual(['order_line_context']) + expect(summary.requiredAnyInputs).toEqual([ + ['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset'], + ]) + }) + + test('surfaces editable fields and dynamic template hints from declared contracts', () => { + const summary = getWorkflowNodeContractSummary( + buildDefinition({ + step: 'resolve_template', + fields: [ + { + key: 'samples', + label: 'Samples', + type: 'number', + description: 'Render samples', + section: 'Render', + default: 64, + min: 1, + max: 1024, + step: 1, + unit: null, + options: [], + }, + ], + output_contract: { context: 'order_line', provides: ['render_template', 'template_inputs'] }, + }), + ) + + expect(summary.editableFieldCount).toBe(1) + expect(summary.editableFieldLabels).toEqual(['Samples']) + expect(summary.dynamicVariableHint).toBe('Template-selected variables appear after choosing a template.') + expect(summary.authoringPatternLabel).toBe('Inspector-Driven') + }) + + test('treats workflow-root records as context inputs instead of canvas sockets', () => { + const summary = getWorkflowNodeContractSummary( + buildDefinition({ + step: 'resolve_step_path', + family: 'cad_file', + input_contract: { context: 'cad_file', requires: ['cad_file_record'] }, + output_contract: { context: 'cad_file', provides: ['step_path'] }, + }), + ) + + expect(summary.contextInputs).toEqual(['cad_file_record']) + expect(summary.requiredInputs).toEqual([]) + expect(summary.requiredAnyInputs).toEqual([]) + expect(summary.authoringPatternLabel).toBe('Context Entry') + }) + + test('builds consistent catalog metrics for context-only nodes', () => { + const summary = getWorkflowNodeContractSummary( + buildDefinition({ + step: 'order_line_setup', + input_contract: { context: 'order_line', requires: ['order_line_record'] }, + output_contract: { context: 'order_line', provides: ['order_line_context'] }, + }), + ) + + expect(getWorkflowNodeInputMetric(summary)).toEqual({ + value: 'Context only (1)', + title: 'Order Line Record', + }) + expect(getWorkflowNodeVariableMetric(summary)).toEqual({ + value: '0 inspector', + title: 'No local inspector variables', + }) + expect(getWorkflowNodeOutputMetric(summary)).toEqual({ + value: '1 role', + title: 'Order Line Context', + }) + }) + + test('builds consistent catalog metrics for hybrid nodes with dynamic variables', () => { + const summary = getWorkflowNodeContractSummary( + buildDefinition({ + step: 'resolve_template', + fields: [ + { + key: 'template_id_override', + label: 'Template Override', + type: 'text', + description: 'Template override', + section: 'General', + default: '', + min: null, + max: null, + step: null, + unit: null, + options: [], + }, + ], + input_contract: { context: 'order_line', requires: ['order_line_context'] }, + output_contract: { context: 'order_line', provides: ['render_template', 'template_inputs'] }, + }), + ) + + expect(getWorkflowNodeInputMetric(summary)).toEqual({ + value: '1 socket', + title: 'Required: 1, alternative groups: 0', + }) + expect(getWorkflowNodeVariableMetric(summary)).toEqual({ + value: '2 inspector', + title: 'Template Override', + }) + expect(getWorkflowNodeOutputMetric(summary)).toEqual({ + value: '2 roles', + title: 'Render Template, Template Inputs', + }) + expect(getWorkflowNodeDeclaredOutputCount(summary)).toBe(2) + expect(getWorkflowNodeTotalVariableCount(summary, ['Studio Variant'])).toBe(2) + }) + + test('builds one shared presentation model for contract metrics and authoring copy', () => { + const summary = getWorkflowNodeContractSummary( + buildDefinition({ + step: 'resolve_template', + fields: [ + { + key: 'template_id_override', + label: 'Template Override', + type: 'text', + description: 'Template override', + section: 'General', + default: '', + min: null, + max: null, + step: null, + unit: null, + options: [], + }, + ], + input_contract: { context: 'order_line', requires: ['order_line_context'] }, + output_contract: { context: 'order_line', provides: ['render_template', 'template_inputs'] }, + }), + ) + + expect(getWorkflowNodeContractPresentation(summary, ['Studio Variant'])).toEqual({ + inputMetric: { + value: '1 socket', + title: 'Required: 1, alternative groups: 0', + }, + variableMetric: { + value: '2 inspector', + title: 'Template Override', + }, + outputMetric: { + value: '2 roles', + title: 'Render Template, Template Inputs', + }, + socketRequirementDescription: 'This node waits for Order Line Context from upstream.', + variableSummaryText: '2 local variables are edited in the inspector.', + noSettingsDescription: 'This node waits for Order Line Context from upstream.', + socketCount: 1, + variableCount: 2, + declaredOutputCount: 2, + }) + }) + + test('derives reusable input and output socket descriptors from the shared contract summary', () => { + const summary = getWorkflowNodeContractSummary( + buildDefinition({ + step: 'output_save', + input_contract: { context: 'order_line', requires: ['order_line_context'] }, + output_contract: { context: 'order_line', provides: ['media_asset', 'workflow_result'] }, + artifact_roles_produced: ['media_asset', 'workflow_result'], + }), + ) + + expect(getWorkflowNodeInputSocketDescriptors(summary)).toEqual([ + { + id: 'input:order_line_context', + label: 'Order Line Context', + roles: ['order_line_context'], + kind: 'required', + }, + { + id: 'input-any:rendered_image|rendered_frames|rendered_video|blend_asset', + label: 'Any of: Rendered Image / Rendered Frames / Rendered Video / Blend Asset', + roles: ['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset'], + kind: 'alternative', + }, + ]) + expect(getWorkflowNodeOutputSocketDescriptors(summary)).toEqual([ + { + id: 'output:media_asset', + label: 'Media Asset', + roles: ['media_asset'], + kind: 'provided', + }, + { + id: 'output:workflow_result', + label: 'Workflow Result', + roles: ['workflow_result'], + kind: 'provided', + }, + ]) + }) + + test('describes context-only and single-socket authoring expectations clearly', () => { + const contextSummary = getWorkflowNodeContractSummary( + buildDefinition({ + step: 'order_line_setup', + input_contract: { context: 'order_line', requires: ['order_line_record'] }, + output_contract: { context: 'order_line', provides: ['order_line_context'] }, + }), + ) + const singleSocketSummary = getWorkflowNodeContractSummary( + buildDefinition({ + step: 'occ_object_extract', + family: 'cad_file', + input_contract: { context: 'cad_file', requires: ['step_path'] }, + output_contract: { context: 'cad_file', provides: ['occ_object'] }, + }), + ) + + expect(getWorkflowNodeSocketRequirementDescription(contextSummary)).toBe( + 'Workflow context supplies Order Line Record. No additional upstream sockets are required.', + ) + expect(getWorkflowNodeNoSettingsDescription(contextSummary)).toBe( + 'Workflow context already provides Order Line Record.', + ) + expect(getWorkflowNodeSocketRequirementDescription(singleSocketSummary)).toBe( + 'This node waits for STEP Path from upstream.', + ) + }) + + test('describes mixed required and alternative socket requirements clearly', () => { + const mixedSummary = getWorkflowNodeContractSummary( + buildDefinition({ + step: 'output_save', + input_contract: { context: 'order_line', requires: ['order_line_context'] }, + output_contract: { context: 'order_line', provides: ['workflow_result'] }, + }), + ) + + expect(getWorkflowNodeSocketRequirementDescription(mixedSummary)).toBe( + 'This node requires Order Line Context and one additional upstream artifact from any of: rendered image / rendered frames / rendered video / blend asset.', + ) + }) + + test('describes multi-socket render prerequisites without collapsing them into generic text', () => { + const renderSummary = getWorkflowNodeContractSummary( + buildDefinition({ + step: 'blender_still', + input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template', 'bbox'] }, + output_contract: { context: 'order_line', provides: ['rendered_image'] }, + }), + ) + + expect(getWorkflowNodeSocketRequirementDescription(renderSummary)).toBe( + 'Wire 3 required upstream sockets: Order Line Context, Render Template, and Bounding Box.', + ) + }) + + test('builds variable summaries and validation watchpoints from the shared contract model', () => { + const definition = buildDefinition({ + step: 'resolve_template', + label: 'Resolve Template', + execution_kind: 'bridge', + input_contract: { context: 'order_line', requires: ['order_line_context'] }, + output_contract: { context: 'order_line', provides: ['render_template', 'template_inputs'] }, + artifact_roles_consumed: ['order_line_context'], + artifact_roles_produced: ['render_template'], + fields: [ + { + key: 'template_id_override', + label: 'Template Override', + type: 'text', + description: 'Template override', + section: 'General', + default: '', + min: null, + max: null, + step: null, + unit: null, + options: [], + }, + ], + }) + const summary = getWorkflowNodeContractSummary(definition) + const watchpoints = getWorkflowNodeValidationWatchpoints(definition, summary) + + expect(getWorkflowNodeVariableSummaryText(summary, ['Studio Variant'])).toBe( + '2 local variables are edited in the inspector.', + ) + expect(watchpoints.map(watchpoint => watchpoint.label)).toEqual([ + 'Context', + 'Runtime Gap', + 'Legacy Drift', + 'Artifact Flow', + ]) + expect(getWorkflowNodeContractSignals(definition, summary).map(signal => signal.label)).toEqual([ + 'Hybrid', + 'Template Inputs', + 'In Order Line', + 'Out Order Line', + 'Requires Order Line Context', + 'Provides Render Template', + 'Provides Template Inputs', + 'Consumes Order Line Context', + 'Produces Render Template', + 'Watch Context', + 'Watch Runtime Gap', + ]) + }) +}) diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts index 2b3a361..b9e7e08 100644 --- a/frontend/src/api/workflows.ts +++ b/frontend/src/api/workflows.ts @@ -4,7 +4,12 @@ import type { OutputTypeArtifactKind, OutputTypeWorkflowRolloutMode } from './ou export type WorkflowPresetType = 'still' | 'still_graph' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom' export type WorkflowExecutionMode = 'legacy' | 'graph' | 'shadow' export type WorkflowStarterFamily = 'cad_file' | 'order_line' -export type WorkflowBlueprintType = 'cad_intake' | 'order_rendering' | 'still_graph_reference' +export type WorkflowBlueprintType = + | 'cad_intake' + | 'order_rendering' + | 'still_graph_reference' + | 'still_graph_alpha_reference' + | 'still_graph_blend_reference' export type WorkflowCanonicalBlueprintType = WorkflowBlueprintType | 'starter_cad_intake' | 'starter_order_rendering' export interface WorkflowRolloutLatestRun { @@ -173,6 +178,8 @@ export interface WorkflowOrderLineContextOption { value: string label: string meta: string + is_renderable: boolean + renderability_reason: string | null } export interface WorkflowOrderLineContextGroup { @@ -379,49 +386,89 @@ function extractRenderParamsFromNodes(nodes: WorkflowNode[], step: string): Work return normalizeRenderParams(match?.params ?? {}) } -function buildOrderLineStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } { +function buildOrderLineStillGraphNodes( + renderParams: WorkflowParams, + options: { + transparentBg?: boolean + includeBlendExport?: boolean + } = {}, +): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } { + const resolvedRenderParams = { + use_custom_render_settings: false, + ...renderParams, + ...(options.transparentBg !== undefined ? { transparent_bg: options.transparentBg } : {}), + } + + const nodes: WorkflowNode[] = [ + buildWorkflowNode('setup', 'order_line_setup', 0, 160, { label: 'Order Line Setup' }), + buildWorkflowNode('template', 'resolve_template', 220, 160, { label: 'Resolve Template' }), + buildWorkflowNode('populate_materials', 'auto_populate_materials', 220, 320, { + label: 'Auto Populate Materials', + type: 'processNode', + }), + buildWorkflowNode('bbox', 'glb_bbox', 220, 40, { + label: 'Compute Bounding Box', + type: 'processNode', + }), + buildWorkflowNode('resolve_materials', 'material_map_resolve', 440, 200, { + label: 'Resolve Material Map', + type: 'processNode', + }), + buildWorkflowNode('render', 'blender_still', 680, 160, { + label: 'Still Render', + type: 'renderNode', + params: resolvedRenderParams, + }), + buildWorkflowNode('output', 'output_save', 920, 120, { + label: 'Save Output', + type: 'outputNode', + }), + buildWorkflowNode('notify', 'notify', 920, 220, { + label: 'Notify Result', + type: 'outputNode', + }), + ] + + const edges: WorkflowEdge[] = [ + { from: 'setup', to: 'template' }, + { from: 'setup', to: 'populate_materials' }, + { from: 'setup', to: 'bbox' }, + { from: 'template', to: 'resolve_materials' }, + { from: 'populate_materials', to: 'resolve_materials' }, + { from: 'resolve_materials', to: 'render' }, + { from: 'bbox', to: 'render' }, + { from: 'template', to: 'render' }, + { from: 'render', to: 'output' }, + { from: 'render', to: 'notify' }, + ] + + if (options.includeBlendExport) { + nodes.push( + buildWorkflowNode('blend_export', 'export_blend', 920, 360, { + label: 'Export Blend', + type: 'outputNode', + }), + buildWorkflowNode('save_blend', 'output_save', 1160, 320, { + label: 'Save Blend Output', + type: 'outputNode', + params: { expected_artifact_role: 'blend_export' }, + }), + buildWorkflowNode('notify_blend', 'notify', 1160, 420, { + label: 'Notify Blend Export', + type: 'outputNode', + }), + ) + edges.push( + { from: 'setup', to: 'blend_export' }, + { from: 'template', to: 'blend_export' }, + { from: 'blend_export', to: 'save_blend' }, + { from: 'blend_export', to: 'notify_blend' }, + ) + } + return { - nodes: [ - buildWorkflowNode('setup', 'order_line_setup', 0, 160, { label: 'Order Line Setup' }), - buildWorkflowNode('template', 'resolve_template', 220, 160, { label: 'Resolve Template' }), - buildWorkflowNode('populate_materials', 'auto_populate_materials', 220, 320, { - label: 'Auto Populate Materials', - type: 'processNode', - }), - buildWorkflowNode('bbox', 'glb_bbox', 220, 40, { - label: 'Compute Bounding Box', - type: 'processNode', - }), - buildWorkflowNode('resolve_materials', 'material_map_resolve', 440, 200, { - label: 'Resolve Material Map', - type: 'processNode', - }), - buildWorkflowNode('render', 'blender_still', 680, 160, { - label: 'Still Render', - type: 'renderNode', - params: { use_custom_render_settings: false, ...renderParams }, - }), - buildWorkflowNode('output', 'output_save', 920, 120, { - label: 'Save Output', - type: 'outputNode', - }), - buildWorkflowNode('notify', 'notify', 920, 220, { - label: 'Notify Result', - type: 'outputNode', - }), - ], - edges: [ - { from: 'setup', to: 'template' }, - { from: 'setup', to: 'populate_materials' }, - { from: 'setup', to: 'bbox' }, - { from: 'template', to: 'resolve_materials' }, - { from: 'populate_materials', to: 'resolve_materials' }, - { from: 'resolve_materials', to: 'render' }, - { from: 'bbox', to: 'render' }, - { from: 'template', to: 'render' }, - { from: 'render', to: 'output' }, - { from: 'render', to: 'notify' }, - ], + nodes, + edges, } } @@ -681,12 +728,22 @@ export function buildWorkflowBlueprintConfig(blueprint: WorkflowBlueprintType): } } - const { nodes, edges } = buildOrderLineStillGraphNodes({ + const stillReferenceParams: WorkflowParams = { render_engine: 'cycles', samples: 256, width: 1920, height: 1080, - }) + } + const buildStillReference = () => { + if (blueprint === 'still_graph_alpha_reference') { + return buildOrderLineStillGraphNodes(stillReferenceParams, { transparentBg: true }) + } + if (blueprint === 'still_graph_blend_reference') { + return buildOrderLineStillGraphNodes(stillReferenceParams, { includeBlendExport: true }) + } + return buildOrderLineStillGraphNodes(stillReferenceParams) + } + const { nodes, edges } = buildStillReference() return { version: 1, @@ -819,7 +876,13 @@ export function normalizeWorkflowConfig(raw: Record): WorkflowC } } - if (rawUi.blueprint === 'cad_intake' || rawUi.blueprint === 'order_rendering' || rawUi.blueprint === 'still_graph_reference') { + if ( + rawUi.blueprint === 'cad_intake' || + rawUi.blueprint === 'order_rendering' || + rawUi.blueprint === 'still_graph_reference' || + rawUi.blueprint === 'still_graph_alpha_reference' || + rawUi.blueprint === 'still_graph_blend_reference' + ) { const canonical = buildWorkflowBlueprintConfig(rawUi.blueprint) return { ...canonical, diff --git a/frontend/src/components/workflows/NodeCommandMenu.tsx b/frontend/src/components/workflows/NodeCommandMenu.tsx index 2ae4803..eb76cd5 100644 --- a/frontend/src/components/workflows/NodeCommandMenu.tsx +++ b/frontend/src/components/workflows/NodeCommandMenu.tsx @@ -1,16 +1,17 @@ -import { useEffect, type ReactNode } from 'react' +import { useEffect, useRef, type ReactNode } from 'react' import { Boxes, Milestone, X } from 'lucide-react' import type { WorkflowNodeDefinition } from '../../api/workflows' import type { WorkflowGraphFamily } from './workflowNodeLibrary' import { WorkflowAuthoringSectionContent } from './WorkflowAuthoringSectionContent' +import { WorkflowAuthoringSectionSelector } from './WorkflowAuthoringSectionSelector' import { type WorkflowAuthoringActions, type WorkflowAuthoringPosition, } from './workflowAuthoringActions' import { useWorkflowAuthoringSurface } from './workflowAuthoringSurface' -export const NODE_COMMAND_MENU_WIDTH = 360 +export const NODE_COMMAND_MENU_WIDTH = 380 interface NodeCommandMenuProps { definitions: WorkflowNodeDefinition[] @@ -31,7 +32,8 @@ export function NodeCommandMenu({ onClose, renderIcon, }: NodeCommandMenuProps) { - const { activeSection, insertBindings, plan, sections, setActiveSection } = + const containerRef = useRef(null) + const { activeSection, activeSectionDetail, chrome, insertBindings, plan, sections, setActiveSection } = useWorkflowAuthoringSurface({ definitions, graphFamily, @@ -52,19 +54,28 @@ export function NodeCommandMenu({ return () => window.removeEventListener('keydown', handleKeyDown) }, [onClose]) + useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + if (!containerRef.current) return + if (containerRef.current.contains(event.target as Node)) return + onClose() + } + + window.addEventListener('pointerdown', handlePointerDown) + return () => window.removeEventListener('pointerdown', handlePointerDown) + }, [onClose]) + return (
-
+
-
-

Workflow Authoring

-

- Use complete reference paths, modules, starter steps, or the raw node library directly on the canvas. -

-
+
+
+

{chrome.menuTitle}

{activeSteps.length} on canvas @@ -80,7 +91,13 @@ export function NodeCommandMenu({ {plan.moduleBundles.length} modules )} + + Esc closes +
+

+ {chrome.menuHelper} +

-
-
-
- {sections.map(section => { - const Icon = section.icon - const isActive = activeSection === section.key - return ( - - ) - })} +
+
+
+
+
+

+ {chrome.guideEyebrow} +

+

+ {activeSectionDetail?.helper ?? chrome.guideHelper} +

+
+ + {activeSectionDetail?.label ?? activeSectionDetail?.tone ?? 'Guided'} + +
+
diff --git a/frontend/src/components/workflows/NodeDefinitionsPanel.tsx b/frontend/src/components/workflows/NodeDefinitionsPanel.tsx index bc6411f..d83b065 100644 --- a/frontend/src/components/workflows/NodeDefinitionsPanel.tsx +++ b/frontend/src/components/workflows/NodeDefinitionsPanel.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react' -import { ArrowRight } from 'lucide-react' import type { WorkflowNodeDefinition } from '../../api/workflows' import { WorkflowAuthoringSectionContent } from './WorkflowAuthoringSectionContent' +import { WorkflowAuthoringSectionSelector } from './WorkflowAuthoringSectionSelector' import { type WorkflowAuthoringActions } from './workflowAuthoringActions' import type { WorkflowGraphFamily } from './workflowNodeLibrary' import { useWorkflowAuthoringSurface } from './workflowAuthoringSurface' @@ -30,7 +30,8 @@ export function NodeDefinitionsPanel({ }: NodeDefinitionsPanelProps) { const { activeSection, - activeSectionMeta, + activeSectionDetail, + chrome, defaultSection, insertBindings, plan: authoringPlan, @@ -45,17 +46,20 @@ export function NodeDefinitionsPanel({ const authoringFlow: AuthoringFlowStep[] = authoringPlan.authoringFlow const presentStepCount = activeSteps.length + const isOverviewSection = activeSection === defaultSection || activeSection === 'overview' + const isRawNodeSection = activeSectionDetail?.isRawNodeSection ?? activeSection === 'nodes' + const activeSectionTone = activeSectionDetail?.tone ?? 'Guided' + const activeSectionDescription = activeSectionDetail?.helper + const browserStatusLabel = insertBindings.onSelectStep ? 'Insert mode' : 'Browse mode' + const activeSectionLabel = activeSectionDetail?.summaryLabel ?? activeSection return (
-
+
-

- Node Library -

-
-

Authoring Browser

+
+

{chrome.browserTitle}

{definitions.length} definitions @@ -63,66 +67,38 @@ export function NodeDefinitionsPanel({ {presentStepCount} on canvas
-

- Start from reference paths or production modules, then drop to starter steps and raw nodes only when needed. -

-
- - {insertBindings.onSelectStep ? 'Insert enabled' : 'Browse only'} - -
- -
- {sections.map(section => { - const Icon = section.icon - const isActive = activeSection === section.key - - return ( - - ) - })} -
-
- - {activeSectionMeta && ( -
-
-
-

- Current Section -

-

- {activeSectionMeta.label}: {activeSectionMeta.helper} -

+ + )}
+
+
- Active + {activeSectionTone}
- )} - {activeSection === defaultSection && ( + +
+ + {isOverviewSection && (
@@ -130,7 +106,7 @@ export function NodeDefinitionsPanel({ Authoring Flow

- Keep the legacy workflow safe by starting from graph-safe assemblies, then drilling down only when the module-level path is in place. + Start high-level, then descend only when the path is already stable.

@@ -156,21 +132,8 @@ export function NodeDefinitionsPanel({
)} - {activeSection === 'nodes' ? ( -
-
-
-

- Raw Node Catalog -

-

- Advanced mode for inserting individual legacy, bridge, or graph nodes after the higher-level path is established. -

-
- - Escape Hatch - -
+ {isRawNodeSection ? ( +
void } +function compareStages(left: WorkflowAuthoringStage, right: WorkflowAuthoringStage) { + return AUTHORING_STAGE_ORDER.indexOf(left) - AUTHORING_STAGE_ORDER.indexOf(right) +} + export function WorkflowAuthoringOverview({ definitions, graphFamily, @@ -24,6 +34,7 @@ export function WorkflowAuthoringOverview({ onSelectStep, }: WorkflowAuthoringOverviewProps) { const { + authoringFlow, description, gapFillDefinitions, moduleBundles, @@ -33,24 +44,42 @@ export function WorkflowAuthoringOverview({ title, } = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps) const priorityIcons = [Milestone, Boxes, Library] as const + const orderedModuleBundles = [...moduleBundles].sort((left, right) => { + const stageDelta = compareStages(left.stageId, right.stageId) + if (stageDelta !== 0) return stageDelta + return left.label.localeCompare(right.label) + }) + const hasIncompleteStage = stageProgress.some(stage => stage.present < stage.total) + const shouldExpandQuickStart = activeSteps.length === 0 + const shouldExpandRecommendedPath = activeSteps.length < 2 + const flowSummary = authoringFlow.map(item => item.title).join(' -> ') + const incompleteStages = stageProgress.filter(stage => stage.present < stage.total).length return (
{graphFamily !== 'mixed' && stageProgress.length > 0 && ( -
-
-
-

- Stage Status -

-

- Track the canonical authoring path stage by stage and fill only the missing parts. -

+
+ +
+
+

+ Stage Status +

+ + {incompleteStages === 0 + ? 'All stages covered' + : `${incompleteStages} stage${incompleteStages === 1 ? '' : 's'} open`} + +
+

{flowSummary}

- Operational + {hasIncompleteStage ? 'Needs attention' : 'Operational'} -
+
{stageProgress.map(stage => { @@ -61,12 +90,14 @@ export function WorkflowAuthoringOverview({ return (
-
+
- + {stage.title} @@ -74,7 +105,7 @@ export function WorkflowAuthoringOverview({ {progressLabel}
-

{stage.description}

+

{stage.description}

{stage.actionKind === 'reference' && stage.bundleId && onInsertReferencePath && ( @@ -114,33 +145,40 @@ export function WorkflowAuthoringOverview({ ) })}
-
+ )} -
-
-
-
+
+ +
+

Recommended Path

+ + {priorities.length} priorities +

{title}

{description}

+

{flowSummary}

{activeSteps.length} on canvas -
+ -
+
{priorities.map((priority, index) => { const Icon = priorityIcons[index] ?? Library return (
@@ -151,70 +189,150 @@ export function WorkflowAuthoringOverview({ {priority.title} -

{priority.description}

+

{priority.description}

) })}
-
+ {(referenceBundles.length > 0 || moduleBundles.length > 0) && ( -
-
-
-

- Quick Start -

+
+ +
+
+

+ Quick Start +

+ + {referenceBundles.length + orderedModuleBundles.length} insert options + +

- Insert the recommended baseline first, then add stage bundles only where the path should diverge. + Start from a reference path, then add only the stage bundles you need.

Guided -
+ -
- {referenceBundles.map(bundle => ( - - ))} +
+ {referenceBundles.length > 0 && ( +
+
+
+
+

+ Reference Baselines +

+ + {referenceBundles.length} path{referenceBundles.length === 1 ? '' : 's'} + +
+

+ Insert a known-good full path before swapping single stages. +

+
+
- {moduleBundles.slice(0, 2).map(bundle => ( - - ))} +
+ {referenceBundles.map(bundle => ( + + ))} +
+
+ )} + + {orderedModuleBundles.length > 0 && ( +
+
+
+
+

+ Stage Modules +

+ + {orderedModuleBundles.length} module{orderedModuleBundles.length === 1 ? '' : 's'} + +
+

+ Replace one production stage at a time without rewiring the full path. +

+
+
+ +
+ {orderedModuleBundles.map(bundle => ( +
+
+ + {bundle.stage} + + + {bundle.presentCount}/{bundle.totalCount} present + +
+
+
+

{bundle.label}

+

+ {bundle.description || AUTHORING_STAGE_DESCRIPTIONS[bundle.stageId]} +

+

+ {bundle.stepIds.join(' -> ')} +

+
+ +
+
+ ))} +
+
+ )}
-
+ )} - {graphFamily !== 'mixed' && onSelectStep && ( + {graphFamily !== 'mixed' && onSelectStep && gapFillDefinitions.length > 0 && (
-
-
-

- Gap Fill -

-

- Use starter-safe inserts only for missing links in the recommended chain. +

+
+
+

+ Gap Fill +

+ + {gapFillDefinitions.length} starter-safe step{gapFillDefinitions.length === 1 ? '' : 's'} + +
+

+ Insert only the missing links for the canonical chain.

@@ -222,18 +340,18 @@ export function WorkflowAuthoringOverview({
-
+
{gapFillDefinitions.map(definition => ( - - ))} + + ))}
)} diff --git a/frontend/src/components/workflows/WorkflowAuthoringSectionContent.tsx b/frontend/src/components/workflows/WorkflowAuthoringSectionContent.tsx index 008bea5..76817b4 100644 --- a/frontend/src/components/workflows/WorkflowAuthoringSectionContent.tsx +++ b/frontend/src/components/workflows/WorkflowAuthoringSectionContent.tsx @@ -33,54 +33,30 @@ export function WorkflowAuthoringSectionContent({ searchPlaceholder, autoFocusSearch = false, }: WorkflowAuthoringSectionContentProps) { - if (activeSection === 'overview') { - return ( + const sharedProps = { + definitions, + graphFamily, + activeSteps, + } + + const contentBySection: Record = { + overview: ( - ) - } - - if (activeSection === 'paths') { - return ( + ), + paths: ( - ) - } - - if (activeSection === 'modules') { - return ( - - ) - } - - if (activeSection === 'starter') { - return ( - - ) - } - - if (activeSection === 'nodes') { - return ( + ), + modules: , + starter: , + nodes: ( - ) + ), } - return null + return contentBySection[activeSection] ?? null } diff --git a/frontend/src/components/workflows/WorkflowAuthoringSectionSelector.tsx b/frontend/src/components/workflows/WorkflowAuthoringSectionSelector.tsx new file mode 100644 index 0000000..ebea8c6 --- /dev/null +++ b/frontend/src/components/workflows/WorkflowAuthoringSectionSelector.tsx @@ -0,0 +1,103 @@ +import { ArrowRight } from 'lucide-react' + +import type { + WorkflowAuthoringSection, + WorkflowAuthoringSectionConfig, +} from './workflowAuthoringSections' + +type WorkflowAuthoringSectionSelectorProps = { + sections: WorkflowAuthoringSectionConfig[] + activeSection: WorkflowAuthoringSection + onSelectSection: (section: WorkflowAuthoringSection) => void + variant?: 'pill' | 'card' +} + +export function WorkflowAuthoringSectionSelector({ + sections, + activeSection, + onSelectSection, + variant = 'pill', +}: WorkflowAuthoringSectionSelectorProps) { + if (variant === 'card') { + return ( +
+ {sections.map(section => { + const Icon = section.icon + const isActive = activeSection === section.key + const toneLabel = section.tone === 'Escape Hatch' ? 'Direct' : section.tone + + return ( + + ) + })} +
+ ) + } + + return ( +
+ {sections.map(section => { + const Icon = section.icon + const isActive = activeSection === section.key + return ( + + ) + })} +
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowBundleCatalog.tsx b/frontend/src/components/workflows/WorkflowBundleCatalog.tsx new file mode 100644 index 0000000..08442ca --- /dev/null +++ b/frontend/src/components/workflows/WorkflowBundleCatalog.tsx @@ -0,0 +1,93 @@ +import type { LucideIcon } from 'lucide-react' +import { Wand2 } from 'lucide-react' + +type WorkflowBundleCatalogItem = { + id: string + label: string + stage: string + description: string + stepIds: string[] + presentCount: number + totalCount: number +} + +type WorkflowBundleCatalogProps = { + bundles: TBundle[] + emptyLabel: string + icon: LucideIcon + title: string + description: string + countLabel: string + insertLabel?: string + onInsert?: (bundleId: TBundle['id']) => void +} + +export function WorkflowBundleCatalog({ + bundles, + emptyLabel, + icon: Icon, + title, + description, + countLabel, + insertLabel = 'Insert', + onInsert, +}: WorkflowBundleCatalogProps) { + if (bundles.length === 0) return null + + return ( +
+
+
+
+ +

+ {title} +

+ + {bundles.length} {countLabel} + +
+

{description}

+
+
+ +
+ {bundles.map(bundle => ( +
+
+
+
+

{bundle.label}

+ + {bundle.stage} + + + {bundle.presentCount}/{bundle.totalCount} present + +
+

{bundle.description}

+

+ {bundle.stepIds.length > 0 ? bundle.stepIds.join(' -> ') : emptyLabel} +

+
+ {onInsert ? ( + + ) : null} +
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowCanvas.tsx b/frontend/src/components/workflows/WorkflowCanvas.tsx new file mode 100644 index 0000000..90cfb26 --- /dev/null +++ b/frontend/src/components/workflows/WorkflowCanvas.tsx @@ -0,0 +1,418 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { + Background, + Controls, + MiniMap, + ReactFlow, + type Edge, + type Node, +} from '@xyflow/react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { updateOutputType } from '../../api/outputTypes' +import type { + WorkflowConfig, + WorkflowDefinition, + WorkflowExecutionMode, +} from '../../api/workflows' +import { useThemeStore, resolveTheme } from '../../store/theme' +import { NodeCommandMenu, NODE_COMMAND_MENU_WIDTH } from './NodeCommandMenu' +import { renderWorkflowIcon, workflowCanvasNodeTypes } from './WorkflowCanvasNodes' +import { WorkflowCanvasToolbar } from './WorkflowCanvasToolbar' +import { WorkflowCanvasUtilitySidebar } from './WorkflowCanvasUtilitySidebar' +import { WorkflowValidationBanner } from './WorkflowValidationBanner' +import { + BLUEPRINT_DESCRIPTION, + BLUEPRINT_LABELS, + getWorkflowBlueprint, +} from './workflowBlueprints' +import { + getWorkflowAuthoringEntryAction, + type WorkflowAuthoringActions, +} from './workflowAuthoringActions' +import { type WorkflowCanvasNodeData } from './workflowGraphDraft' +import { + GRAPH_FAMILY_LABELS, + GRAPH_FAMILY_STYLES, +} from './workflowNodeLibrary' +import { + EXECUTION_MODE_BADGE_STYLES, + EXECUTION_MODE_LABELS, +} from './workflowRunPresentation' +import { getWorkflowRolloutPresentation } from './workflowRolloutPresentation' +import { getWorkflowAuthoringSurfaceModel } from './workflowAuthoringSurface' +import { summarizeWorkflowDraftValidationBySeverity } from './workflowValidationPresentation' +import { useWorkflowCanvasController } from './useWorkflowCanvasController' + +const EXECUTION_MODE_HINTS: Record = { + legacy: 'Preset dispatcher remains authoritative for production runs.', + graph: 'Production dispatch uses graph runtime with hard fallback to legacy on failure.', + shadow: 'Currently stored and exposed, but production dispatch still falls back to legacy until shadow parity lands.', +} + +type WorkflowCanvasProps = { + workflow: WorkflowDefinition + onSave: (config: WorkflowConfig) => void + isSaving: boolean +} + +export function WorkflowCanvas({ workflow, onSave, isSaving }: WorkflowCanvasProps) { + const queryClient = useQueryClient() + const { + reactFlowWrapper, + nodeDefinitions, + nodeDefinitionsByStep, + nodes, + edges, + onNodesChange, + onEdgesChange, + selectedEdgeIds, + selectedNode, + workflowRuns, + selectedRun, + setSelectedRunId, + selectedRunComparison, + isComparisonLoading, + dispatchMutation, + preflightMutation, + dispatchContextId, + setDispatchContextId, + isOrderLineGraph, + isOrderLineContextsLoading, + orderLineContextGroups, + dispatchContextLabel, + dispatchContextSummary, + dispatchContextMeta, + preflightResult, + preflightState, + hasFreshSuccessfulPreflight, + executionMode, + setExecutionMode, + nodeMenuAnchor, + setNodeMenuAnchor, + activeUtilityTab, + setActiveUtilityTab, + validation, + authoringFamily, + graphFamily, + onConnect, + onNodeClick, + onEdgeClick, + onPaneClick, + handleSelectionChange, + handleParamsChange, + handlePipelineStepChange, + handlePaneContextMenu, + handleNodeContextMenu, + insertNode, + insertModuleBundle, + insertReferenceBundle, + handleOpenToolbarNodeMenu, + handleAutoLayout, + handleDeleteSelectedEdges, + onEdgeContextMenu, + onEdgeDoubleClick, + handleSave, + handleDispatch, + handlePreflight, + setReactFlowInstance, + } = useWorkflowCanvasController({ workflow, onSave }) + const [isCanvasReady, setIsCanvasReady] = useState(false) + const [nodeMenuElement, setNodeMenuElement] = useState(null) + const [nodeMenuSize, setNodeMenuSize] = useState({ width: NODE_COMMAND_MENU_WIDTH, height: 420 }) + + useEffect(() => { + const wrapper = reactFlowWrapper.current + if (!wrapper) return + + const updateCanvasReadiness = () => { + const bounds = wrapper.getBoundingClientRect() + setIsCanvasReady(bounds.width > 0 && bounds.height > 0) + } + + updateCanvasReadiness() + + const resizeObserver = new ResizeObserver(() => { + updateCanvasReadiness() + }) + resizeObserver.observe(wrapper) + + return () => { + resizeObserver.disconnect() + } + }, [reactFlowWrapper, workflow.id]) + + useLayoutEffect(() => { + if (!nodeMenuElement) return + + const measure = () => { + const bounds = nodeMenuElement.getBoundingClientRect() + setNodeMenuSize({ + width: Math.max(Math.ceil(bounds.width), NODE_COMMAND_MENU_WIDTH), + height: Math.max(Math.ceil(bounds.height), 320), + }) + } + + measure() + const resizeObserver = new ResizeObserver(() => { + measure() + }) + resizeObserver.observe(nodeMenuElement) + + return () => { + resizeObserver.disconnect() + } + }, [nodeMenuElement, nodeMenuAnchor]) + + const nodeMenuRef = useCallback((element: HTMLDivElement | null) => { + setNodeMenuElement(element) + }, []) + + const nodeMenuStyle = useMemo(() => { + if (!nodeMenuAnchor || typeof window === 'undefined') return null + + const horizontalMargin = 16 + const verticalMargin = 16 + const width = nodeMenuSize.width + const height = Math.min(nodeMenuSize.height, window.innerHeight - verticalMargin * 2) + + const left = Math.min( + Math.max(nodeMenuAnchor.clientX, horizontalMargin), + Math.max(window.innerWidth - width - horizontalMargin, horizontalMargin), + ) + const top = Math.min( + Math.max(nodeMenuAnchor.clientY, verticalMargin), + Math.max(window.innerHeight - height - verticalMargin, verticalMargin), + ) + + return { left, top } + }, [nodeMenuAnchor, nodeMenuSize.height, nodeMenuSize.width]) + + const { mode } = useThemeStore() + const isDark = resolveTheme(mode) === 'dark' + const canvasEdges = useMemo( + () => + edges.map(edge => ({ + ...edge, + selectable: true, + focusable: true, + interactionWidth: (edge as Edge & { interactionWidth?: number }).interactionWidth ?? 44, + style: { + ...(edge.style ?? {}), + strokeWidth: edge.selected ? 3.25 : ((edge.style?.strokeWidth as number | undefined) ?? 2.25), + stroke: edge.selected ? (isDark ? '#f59e0b' : '#d97706') : edge.style?.stroke, + opacity: edge.selected ? 1 : 0.95, + }, + zIndex: edge.selected ? 20 : edge.zIndex, + })), + [edges, isDark], + ) + const activeSteps = useMemo( + () => + nodes + .map(node => (node.data as WorkflowCanvasNodeData | undefined)?.step) + .filter((step): step is string => Boolean(step)), + [nodes], + ) + const authoringActions = useMemo( + () => ({ + openNodeMenu: handleOpenToolbarNodeMenu, + insertNode, + insertModule: insertModuleBundle, + insertReferencePath: insertReferenceBundle, + }), + [handleOpenToolbarNodeMenu, insertModuleBundle, insertNode, insertReferenceBundle], + ) + const authoringSurfaceModel = useMemo( + () => + getWorkflowAuthoringSurfaceModel({ + definitions: nodeDefinitions, + graphFamily: authoringFamily, + activeSteps, + }), + [activeSteps, authoringFamily, nodeDefinitions], + ) + const authoringEntryAction = useMemo( + () => getWorkflowAuthoringEntryAction(authoringSurfaceModel), + [authoringSurfaceModel], + ) + const rolloutPresentation = useMemo( + () => getWorkflowRolloutPresentation(workflow.rollout_summary), + [workflow.rollout_summary], + ) + const validationSummary = useMemo( + () => + summarizeWorkflowDraftValidationBySeverity({ + errors: validation.errors, + warnings: validation.warnings, + }), + [validation.errors, validation.warnings], + ) + const rollbackOutputTypeMutation = useMutation({ + mutationFn: ({ outputTypeId }: { outputTypeId: string }) => + updateOutputType(outputTypeId, { workflow_rollout_mode: 'legacy_only' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workflows'] }) + queryClient.invalidateQueries({ queryKey: ['output-types'] }) + toast.success('Output type rollout reverted to legacy') + }, + onError: () => { + toast.error('Failed to revert output type rollout') + }, + }) + + return ( +
+ ({ + value: mode, + label: EXECUTION_MODE_LABELS[mode], + }))} + selectedEdgeCount={selectedEdgeIds.length} + canAutoLayout={nodes.length > 0} + canPreflight={dispatchContextId.trim().length > 0} + canDispatch={dispatchContextId.trim().length > 0 && hasFreshSuccessfulPreflight} + hasValidationErrors={validation.errors.length > 0} + validationSummary={validationSummary} + isPreflightPending={preflightMutation.isPending} + isDispatchPending={dispatchMutation.isPending} + isContextOptionsLoading={isOrderLineContextsLoading} + isSaving={isSaving} + rollbackPendingOutputTypeId={rollbackOutputTypeMutation.variables?.outputTypeId ?? null} + preflightState={preflightState} + authoringActions={authoringActions} + authoringEntryAction={authoringEntryAction} + onDispatchContextIdChange={setDispatchContextId} + onExecutionModeChange={value => setExecutionMode(value as WorkflowExecutionMode)} + onAutoLayout={handleAutoLayout} + onDeleteSelectedEdges={handleDeleteSelectedEdges} + onPreflight={handlePreflight} + onDispatch={handleDispatch} + onSave={handleSave} + onRollbackOutputType={outputTypeId => rollbackOutputTypeMutation.mutate({ outputTypeId })} + /> + + + +
+
event.preventDefault()} + > + {isCanvasReady ? ( + + + + + + ) : ( +
+ Preparing workflow canvas… +
+ )} +
+ + +
+ + {nodeMenuAnchor && nodeMenuStyle && + createPortal( +
+ setNodeMenuAnchor(null)} + renderIcon={renderWorkflowIcon} + /> +
, + document.body, + )} +
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowCanvasNodes.tsx b/frontend/src/components/workflows/WorkflowCanvasNodes.tsx new file mode 100644 index 0000000..f211707 --- /dev/null +++ b/frontend/src/components/workflows/WorkflowCanvasNodes.tsx @@ -0,0 +1,357 @@ +import type { ReactNode } from 'react' +import { Handle, Position, type NodeTypes } from '@xyflow/react' +import { + Bell, + Camera, + Download, + FileUp, + Film, + Layers, + RefreshCw, +} from 'lucide-react' +import { + WORKFLOW_NODE_MIN_HEIGHT, + WORKFLOW_NODE_WIDTH, + type WorkflowCanvasNodeData, +} from './workflowGraphDraft' +import { + getWorkflowNodePortBadgeLabel, + getWorkflowNodePortTitle, +} from './workflowNodePresentation' +import { formatContractValue } from './workflowNodeContracts' + +export function renderWorkflowIcon(iconName?: string, size = 14) { + switch (iconName) { + case 'file-up': + return + case 'film': + return + case 'layers': + return + case 'download': + return + case 'bell': + return + case 'camera': + return + case 'refresh-cw': + default: + return + } +} + +interface BaseNodeProps { + data: WorkflowCanvasNodeData + icon: ReactNode + accentClass: string + selected?: boolean +} + +function getHandleOffset(index: number, total: number) { + if (total <= 1) return '50%' + const topPadding = 22 + const bottomPadding = 22 + const usableHeight = WORKFLOW_NODE_MIN_HEIGHT - topPadding - bottomPadding + const step = usableHeight / (total - 1) + return `${topPadding + step * index}px` +} + +type NodeBadge = { + id: string + label: string + title?: string + tone?: 'default' | 'muted' +} + +function BadgeList({ + badges, + emptyLabel, + badgeClassName, + maxVisibleBadges = 3, +}: { + badges: NodeBadge[] + emptyLabel: string + badgeClassName: string + maxVisibleBadges?: number +}) { + if (badges.length === 0) { + return

{emptyLabel}

+ } + + const visibleBadges = badges.slice(0, maxVisibleBadges) + const hiddenCount = badges.length - visibleBadges.length + + return ( +
+ {visibleBadges.map(badge => ( + + {badge.label} + + ))} + {hiddenCount > 0 && ( + badge.title ?? badge.label).join(', ')} + > + +{hiddenCount} + + )} +
+ ) +} + +function NodeSummaryRow({ + label, + badges, + emptyLabel, + badgeClassName, + maxVisibleBadges, +}: { + label: string + badges: NodeBadge[] + emptyLabel: string + badgeClassName: string + maxVisibleBadges?: number +}) { + return ( +
+

+ {label} +

+
+ +
+
+ ) +} + +function BaseNode({ data, icon, accentClass, selected }: BaseNodeProps) { + const inputPorts = data.inputPorts ?? [] + const outputPorts = data.outputPorts ?? [] + const contextInputs = data.contextInputs ?? [] + const requiredInputCount = inputPorts.filter(port => port.kind === 'required').length + const alternativeInputCount = inputPorts.filter(port => port.kind === 'alternative').length + const hasExplicitInspectorVariables = (data.editableFieldLabels?.length ?? 0) > 0 + const hasDynamicVariables = Boolean(data.dynamicVariableHint) + const contextBadges: NodeBadge[] = contextInputs.map(input => ({ + id: `context:${input}`, + label: formatContractValue(input), + title: formatContractValue(input), + })) + const variableBadges: NodeBadge[] = [ + ...(data.editableFieldLabels ?? []).map(label => ({ + id: `variable:${label}`, + label, + title: label, + })), + ...(data.dynamicVariableHint + ? [ + { + id: 'variable:dynamic-hint', + label: 'Template Inputs', + title: data.dynamicVariableHint, + tone: 'muted' as const, + }, + ] + : []), + ] + const inputBadges: NodeBadge[] = inputPorts.map(port => ({ + id: port.id, + label: getWorkflowNodePortBadgeLabel(port), + title: getWorkflowNodePortTitle(port), + })) + const outputBadges: NodeBadge[] = outputPorts.map(port => ({ + id: port.id, + label: getWorkflowNodePortBadgeLabel(port), + title: getWorkflowNodePortTitle(port), + })) + + return ( +
+ {inputPorts.map((port, index) => ( + + ))} + {outputPorts.map((port, index) => ( + + ))} +
+ {icon} + {data.label} +
+
+ {data.description &&

{data.description}

} +
+
+ 0 ? ` · ${contextInputs.length}` : ''}`} + badges={contextBadges} + emptyLabel="No workflow context" + badgeClassName="border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-900/40 dark:text-amber-300" + maxVisibleBadges={3} + /> + 0 + ? `Inputs · ${requiredInputCount}${alternativeInputCount > 0 ? ` + ${alternativeInputCount} alt` : ''}` + : 'Inputs' + } + badges={inputBadges} + emptyLabel="No upstream sockets" + badgeClassName="border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/40 dark:bg-sky-900/40 dark:text-sky-300" + maxVisibleBadges={5} + /> + + 0 ? ` · ${outputPorts.length}` : ''}`} + badges={outputBadges} + emptyLabel="Terminal node" + badgeClassName="border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-900/40 dark:text-emerald-300" + maxVisibleBadges={4} + /> +
+
+

+ {(inputPorts.length > 0 || contextInputs.length > 0 + ? data.socketRequirementDescription + : data.authoringPatternDescription) ?? + (inputPorts.length > 0 + ? `Canvas requires ${inputPorts.length} upstream ${inputPorts.length === 1 ? 'connection' : 'connections'}.` + : contextInputs.length > 0 + ? 'Workflow context supplies the record for this node.' + : 'This node can start without upstream graph inputs.')} +

+
+
+ ) +} + +function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { + return ( + + ) +} + +function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { + return ( + + ) +} + +function ProcessNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { + return ( + + ) +} + +function RenderNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { + const params = data.params ?? {} + return ( + + ) +} + +function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { + const params = data.params ?? {} + return ( + + ) +} + +function OutputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { + return ( + + ) +} + +export const workflowCanvasNodeTypes: NodeTypes = { + inputNode: InputNode as never, + convertNode: ConvertNode as never, + processNode: ProcessNode as never, + renderNode: RenderNode as never, + renderFramesNode: RenderFramesNode as never, + outputNode: OutputNode as never, +} diff --git a/frontend/src/components/workflows/WorkflowCanvasToolbar.tsx b/frontend/src/components/workflows/WorkflowCanvasToolbar.tsx index 5f5495b..8f33da6 100644 --- a/frontend/src/components/workflows/WorkflowCanvasToolbar.tsx +++ b/frontend/src/components/workflows/WorkflowCanvasToolbar.tsx @@ -3,6 +3,10 @@ import { GitBranch, LayoutGrid, Loader2, MousePointer2, Play, RefreshCw, Save, T import type { WorkflowRolloutLinkedOutputType } from '../../api/workflows' import { getOutputTypeRolloutPresentation } from '../admin/outputTypeRolloutPresentation' +import { + getWorkflowValidationSummaryToneClassName, + type WorkflowValidationSummaryItem, +} from './workflowValidationPresentation' import type { WorkflowOrderLineContextGroup } from './useWorkflowCanvasController' import type { WorkflowAuthoringActions, WorkflowAuthoringEntryAction } from './workflowAuthoringActions' @@ -11,6 +15,16 @@ type WorkflowExecutionModeOption = { label: string } +function getValidationCalloutLabel( + hasValidationErrors: boolean, + validationSummary: WorkflowValidationSummaryItem[], +) { + if (validationSummary.length === 0) return null + return hasValidationErrors + ? 'Validation blocks save and runtime actions until the draft issues are cleared.' + : 'Validation passed with watch items. Preflight before dispatching to confirm runtime readiness.' +} + function ToolbarBadge({ children, className = '', @@ -22,7 +36,7 @@ function ToolbarBadge({ }) { return ( {children} @@ -38,7 +52,7 @@ function ToolbarField({ children: ReactNode }) { return ( -