From 7e100ed334d9803154bced8b37dd8c0b28b08559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 8 Apr 2026 11:16:47 +0200 Subject: [PATCH] feat: expose graph still workflow in editor --- .../rendering/workflow_config_utils.py | 228 ++- .../domains/test_workflow_config_utils.py | 170 +- frontend/src/__tests__/api/workflows.test.ts | 31 + frontend/src/api/workflows.ts | 144 +- frontend/src/pages/WorkflowEditor.tsx | 1462 ++++++++++++++--- 5 files changed, 1840 insertions(+), 195 deletions(-) create mode 100644 frontend/src/__tests__/api/workflows.test.ts diff --git a/backend/app/domains/rendering/workflow_config_utils.py b/backend/app/domains/rendering/workflow_config_utils.py index ddc94a4..6c16040 100644 --- a/backend/app/domains/rendering/workflow_config_utils.py +++ b/backend/app/domains/rendering/workflow_config_utils.py @@ -11,6 +11,7 @@ WorkflowPresetType = str _PRESET_TYPES = { "still", + "still_graph", "turntable", "multi_angle", "still_with_exports", @@ -18,6 +19,8 @@ _PRESET_TYPES = { } _EXECUTION_MODES = {"legacy", "graph", "shadow"} +_WORKFLOW_BLUEPRINTS = {"cad_intake", "order_rendering"} +_WORKFLOW_STARTERS = {"cad_file", "order_line"} _NODE_TYPE_TO_STEP: dict[str, str] = { "inputNode": StepName.RESOLVE_STEP_PATH.value, @@ -29,7 +32,6 @@ _NODE_TYPE_TO_STEP: dict[str, str] = { "outputNode": StepName.OUTPUT_SAVE.value, } - def _make_node( node_id: str, step: StepName, @@ -91,6 +93,32 @@ def build_preset_workflow_config( {"from": "template", "to": "render"}, {"from": "render", "to": "output"}, ] + elif preset_type == "still_graph": + nodes = [ + _make_node("setup", StepName.ORDER_LINE_SETUP, 0, 100, label="Order Line Setup"), + _make_node("populate_materials", StepName.AUTO_POPULATE_MATERIALS, 220, 100, label="Auto Populate Materials"), + _make_node("template", StepName.RESOLVE_TEMPLATE, 440, 100, label="Resolve Template"), + _make_node("resolve_materials", StepName.MATERIAL_MAP_RESOLVE, 660, 100, label="Resolve Material Map"), + _make_node( + "render", + StepName.BLENDER_STILL, + 880, + 100, + params=render_params, + node_type="renderNode", + label="Still Render", + ), + _make_node("output", StepName.OUTPUT_SAVE, 1100, 70, label="Save Output"), + _make_node("notify", StepName.NOTIFY, 1100, 160, label="Notify Result"), + ] + edges = [ + {"from": "setup", "to": "populate_materials"}, + {"from": "populate_materials", "to": "template"}, + {"from": "template", "to": "resolve_materials"}, + {"from": "resolve_materials", "to": "render"}, + {"from": "render", "to": "output"}, + {"from": "render", "to": "notify"}, + ] elif preset_type == "turntable": nodes = [ _make_node("setup", StepName.ORDER_LINE_SETUP, 0, 100, label="Order Line Setup"), @@ -173,7 +201,162 @@ def build_preset_workflow_config( "edges": edges, "ui": { "preset": preset_type, + "execution_mode": "graph" if preset_type == "still_graph" else "legacy", + }, + } + + +def build_workflow_blueprint_config(blueprint: str) -> dict[str, Any]: + if blueprint not in _WORKFLOW_BLUEPRINTS: + raise ValueError(f"Unknown workflow blueprint: {blueprint!r}") + + if blueprint == "cad_intake": + nodes = [ + _make_node("resolve_step", StepName.RESOLVE_STEP_PATH, 0, 180, label="Resolve STEP Path"), + _make_node("extract_objects", StepName.OCC_OBJECT_EXTRACT, 220, 180, label="Extract STEP Objects"), + _make_node("export_glb", StepName.OCC_GLB_EXPORT, 440, 180, label="Export GLB"), + _make_node("stl_cache", StepName.STL_CACHE_GENERATE, 660, 300, label="Generate STL Cache"), + _make_node( + "blender_thumb", + StepName.BLENDER_RENDER, + 880, + 120, + params={"render_engine": "cycles", "samples": 64, "width": 512, "height": 512}, + node_type="renderNode", + label="Render Thumbnail (Blender)", + ), + _make_node( + "threejs_thumb", + StepName.THREEJS_RENDER, + 880, + 320, + params={"width": 512, "height": 512, "transparent_bg": True}, + node_type="renderNode", + label="Render Thumbnail (Three.js)", + ), + _make_node("save_blender_thumb", StepName.THUMBNAIL_SAVE, 1100, 120, label="Save Blender Thumbnail"), + _make_node("save_threejs_thumb", StepName.THUMBNAIL_SAVE, 1100, 320, label="Save Three.js Thumbnail"), + ] + edges = [ + {"from": "resolve_step", "to": "extract_objects"}, + {"from": "extract_objects", "to": "export_glb"}, + {"from": "export_glb", "to": "stl_cache"}, + {"from": "export_glb", "to": "blender_thumb"}, + {"from": "export_glb", "to": "threejs_thumb"}, + {"from": "blender_thumb", "to": "save_blender_thumb"}, + {"from": "threejs_thumb", "to": "save_threejs_thumb"}, + ] + else: + nodes = [ + _make_node("setup", StepName.ORDER_LINE_SETUP, 0, 220, label="Order Line Setup"), + _make_node("bbox", StepName.GLB_BBOX, 220, 80, label="Compute Bounding Box"), + _make_node("resolve_materials", StepName.MATERIAL_MAP_RESOLVE, 440, 80, label="Resolve Material Map"), + _make_node("populate_materials", StepName.AUTO_POPULATE_MATERIALS, 660, 80, label="Auto Populate Materials"), + _make_node("template", StepName.RESOLVE_TEMPLATE, 880, 220, label="Resolve Template"), + _make_node( + "still_render", + StepName.BLENDER_STILL, + 1120, + 80, + params={"rotation_z": 0}, + node_type="renderNode", + label="Render Still", + ), + _make_node( + "turntable_render", + StepName.BLENDER_TURNTABLE, + 1120, + 220, + params={"fps": 24, "duration_s": 5}, + node_type="renderFramesNode", + label="Render Turntable", + ), + _make_node("blend_export", StepName.EXPORT_BLEND, 1120, 360, label="Export Blend"), + _make_node("save_still", StepName.OUTPUT_SAVE, 1360, 80, label="Save Still Output"), + _make_node("save_turntable", StepName.OUTPUT_SAVE, 1360, 220, label="Save Turntable Output"), + _make_node("notify_still", StepName.NOTIFY, 1600, 80, label="Notify Still Result"), + _make_node("notify_turntable", StepName.NOTIFY, 1600, 220, label="Notify Turntable Result"), + _make_node("notify_export", StepName.NOTIFY, 1360, 360, label="Notify Blend Export"), + ] + edges = [ + {"from": "setup", "to": "bbox"}, + {"from": "bbox", "to": "resolve_materials"}, + {"from": "resolve_materials", "to": "populate_materials"}, + {"from": "populate_materials", "to": "template"}, + {"from": "template", "to": "still_render"}, + {"from": "template", "to": "turntable_render"}, + {"from": "template", "to": "blend_export"}, + {"from": "still_render", "to": "save_still"}, + {"from": "turntable_render", "to": "save_turntable"}, + {"from": "save_still", "to": "notify_still"}, + {"from": "save_turntable", "to": "notify_turntable"}, + {"from": "blend_export", "to": "notify_export"}, + ] + + return { + "version": 1, + "nodes": nodes, + "edges": edges, + "ui": { + "preset": "custom", "execution_mode": "legacy", + "blueprint": blueprint, + }, + } + + +def build_starter_workflow_config(family: str = "order_line") -> dict[str, Any]: + if family not in _WORKFLOW_STARTERS: + raise ValueError(f"Unknown workflow starter family: {family!r}") + + if family == "cad_file": + nodes = [ + _make_node("resolve_step", StepName.RESOLVE_STEP_PATH, 120, 140, label="Resolve STEP Path"), + ] + blueprint = "starter_cad_intake" + else: + nodes = [ + _make_node("setup", StepName.ORDER_LINE_SETUP, 120, 140, label="Order Line Setup"), + ] + blueprint = "starter_order_rendering" + + return { + "version": 1, + "nodes": nodes, + "edges": [], + "ui": { + "preset": "custom", + "execution_mode": "legacy", + "blueprint": blueprint, + }, + } + + +def _build_legacy_custom_render_fallback_config(params: dict[str, Any] | None = None) -> dict[str, Any]: + render_params = _resolution_to_dimensions(params or {}) + render_params.setdefault("use_custom_render_settings", True) + + return { + "version": 1, + "nodes": [ + _make_node("setup", StepName.ORDER_LINE_SETUP, 0, 140, label="Order Line Setup"), + _make_node( + "render", + StepName.BLENDER_STILL, + 240, + 140, + params=render_params, + node_type="renderNode", + label="Still Render", + ), + ], + "edges": [ + {"from": "setup", "to": "render"}, + ], + "ui": { + "preset": "custom", + "execution_mode": "legacy", + "blueprint": "starter_order_rendering", }, } @@ -181,6 +364,18 @@ def build_preset_workflow_config( def _canonicalize_legacy_custom_config(raw: dict[str, Any]) -> dict[str, Any]: legacy_nodes = raw.get("nodes") or [] legacy_edges = raw.get("edges") or [] + raw_ui = raw.get("ui") + + if not legacy_nodes: + canonical = _build_legacy_custom_render_fallback_config(raw.get("params") or {}) + if isinstance(raw_ui, dict): + merged_ui = dict(canonical.get("ui") or {}) + merged_ui.update(raw_ui) + if merged_ui.get("execution_mode") not in _EXECUTION_MODES: + merged_ui["execution_mode"] = "legacy" + canonical["ui"] = merged_ui + return canonical + nodes: list[dict[str, Any]] = [] for legacy_node in legacy_nodes: data = legacy_node.get("data") or {} @@ -213,7 +408,7 @@ def _canonicalize_legacy_custom_config(raw: dict[str, Any]) -> dict[str, Any]: } ) - return { + canonical = { "version": 1, "nodes": nodes, "edges": edges, @@ -222,6 +417,13 @@ def _canonicalize_legacy_custom_config(raw: dict[str, Any]) -> dict[str, Any]: "execution_mode": "legacy", }, } + if isinstance(raw_ui, dict): + merged_ui = dict(canonical.get("ui") or {}) + merged_ui.update(raw_ui) + if merged_ui.get("execution_mode") not in _EXECUTION_MODES: + merged_ui["execution_mode"] = "legacy" + canonical["ui"] = merged_ui + return canonical def canonicalize_workflow_config(raw: dict[str, Any]) -> dict[str, Any]: @@ -242,11 +444,29 @@ def canonicalize_workflow_config(raw: dict[str, Any]) -> dict[str, Any]: if workflow_type in _PRESET_TYPES: if workflow_type == "custom": return _canonicalize_legacy_custom_config(raw) - return build_preset_workflow_config(workflow_type, raw.get("params") or {}) + canonical = build_preset_workflow_config(workflow_type, raw.get("params") or {}) + raw_ui = raw.get("ui") + if isinstance(raw_ui, dict): + merged_ui = dict(canonical.get("ui") or {}) + merged_ui.update(raw_ui) + if merged_ui.get("execution_mode") not in _EXECUTION_MODES: + merged_ui["execution_mode"] = "legacy" + canonical["ui"] = merged_ui + return canonical raise ValueError("Unsupported workflow config format") +def workflow_config_requires_canonicalization(raw: dict[str, Any]) -> bool: + if not isinstance(raw, dict): + return True + + if "version" not in raw or "nodes" not in raw: + return True + + return raw != canonicalize_workflow_config(raw) + + def get_workflow_preset_type(config: dict[str, Any]) -> str | None: canonical = canonicalize_workflow_config(config) ui = canonical.get("ui") or {} @@ -273,7 +493,7 @@ def extract_runtime_workflow(config: dict[str, Any]) -> tuple[str | None, dict[s nodes = canonical.get("nodes") or [] - if preset in {"still", "still_with_exports"}: + if preset in {"still", "still_graph", "still_with_exports"}: for node in nodes: if node.get("step") == StepName.BLENDER_STILL.value: return preset, _resolution_to_dimensions(node.get("params") or {}) diff --git a/backend/tests/domains/test_workflow_config_utils.py b/backend/tests/domains/test_workflow_config_utils.py index ebf0608..d500873 100644 --- a/backend/tests/domains/test_workflow_config_utils.py +++ b/backend/tests/domains/test_workflow_config_utils.py @@ -1,7 +1,11 @@ from app.domains.rendering.workflow_config_utils import ( build_preset_workflow_config, + build_workflow_blueprint_config, + build_starter_workflow_config, canonicalize_workflow_config, extract_runtime_workflow, + get_workflow_execution_mode, + workflow_config_requires_canonicalization, ) @@ -25,20 +29,56 @@ def test_build_preset_workflow_config_creates_canonical_dag(): assert render_node["params"]["height"] == 1080 +def test_build_preset_workflow_config_creates_graph_still_variant(): + config = build_preset_workflow_config( + "still_graph", + {"render_engine": "cycles", "samples": 128, "resolution": [1600, 900]}, + ) + + assert config["version"] == 1 + assert config["ui"]["preset"] == "still_graph" + assert config["ui"]["execution_mode"] == "graph" + assert [node["step"] for node in config["nodes"]] == [ + "order_line_setup", + "auto_populate_materials", + "resolve_template", + "material_map_resolve", + "blender_still", + "output_save", + "notify", + ] + render_node = next(node for node in config["nodes"] if node["step"] == "blender_still") + assert render_node["params"]["width"] == 1600 + assert render_node["params"]["height"] == 900 + assert render_node["params"]["samples"] == 128 + + def test_canonicalize_workflow_config_migrates_legacy_preset(): legacy = { "type": "turntable", "params": {"render_engine": "cycles", "samples": 64, "fps": 24}, + "ui": {"execution_mode": "shadow", "label": "Legacy Turntable"}, } canonical = canonicalize_workflow_config(legacy) assert canonical["version"] == 1 assert canonical["ui"]["preset"] == "turntable" - assert canonical["ui"]["execution_mode"] == "legacy" + assert canonical["ui"]["execution_mode"] == "shadow" + assert canonical["ui"]["label"] == "Legacy Turntable" assert any(node["step"] == "blender_turntable" for node in canonical["nodes"]) +def test_get_workflow_execution_mode_uses_legacy_preset_ui_override(): + legacy = { + "type": "still", + "params": {"width": 1024, "height": 768}, + "ui": {"execution_mode": "graph"}, + } + + assert get_workflow_execution_mode(legacy) == "graph" + + def test_extract_runtime_workflow_uses_canonical_render_node_params(): config = build_preset_workflow_config( "multi_angle", @@ -82,6 +122,34 @@ def test_canonicalize_legacy_custom_config_preserves_edges(): assert canonical["edges"] == [{"id": "e1", "from": "input", "to": "render"}] +def test_canonicalize_legacy_custom_config_without_nodes_builds_render_fallback_graph(): + legacy = { + "type": "custom", + "params": { + "samples": 256, + "resolution": [2048, 2048], + "render_engine": "cycles", + }, + } + + canonical = canonicalize_workflow_config(legacy) + + assert canonical["version"] == 1 + assert canonical["ui"]["preset"] == "custom" + assert canonical["ui"]["execution_mode"] == "legacy" + assert [node["step"] for node in canonical["nodes"]] == [ + "order_line_setup", + "blender_still", + ] + assert canonical["edges"] == [{"from": "setup", "to": "render"}] + render_node = next(node for node in canonical["nodes"] if node["step"] == "blender_still") + assert render_node["params"]["render_engine"] == "cycles" + assert render_node["params"]["samples"] == 256 + assert render_node["params"]["width"] == 2048 + assert render_node["params"]["height"] == 2048 + assert render_node["params"]["use_custom_render_settings"] is True + + def test_extract_runtime_workflow_converts_resolution_to_dimensions(): config = build_preset_workflow_config( "turntable", @@ -96,6 +164,41 @@ def test_extract_runtime_workflow_converts_resolution_to_dimensions(): assert "resolution" not in params +def test_build_preset_workflow_config_keeps_render_overrides_opt_in_disabled_by_default(): + config = build_preset_workflow_config( + "still_with_exports", + {"width": 640, "height": 640, "samples": 32}, + ) + + render_node = next(node for node in config["nodes"] if node["step"] == "blender_still") + + assert "use_custom_render_settings" not in render_node["params"] + assert render_node["params"]["width"] == 640 + assert render_node["params"]["height"] == 640 + assert render_node["params"]["samples"] == 32 + + +def test_canonicalize_workflow_config_preserves_unset_override_intent_for_saved_preset_configs(): + canonical = canonicalize_workflow_config( + { + "version": 1, + "nodes": [ + {"id": "setup", "step": "order_line_setup", "params": {}}, + { + "id": "render", + "step": "blender_still", + "params": {"width": 1024, "height": 768}, + }, + ], + "edges": [{"from": "setup", "to": "render"}], + "ui": {"preset": "still_with_exports", "execution_mode": "graph"}, + } + ) + + render_node = next(node for node in canonical["nodes"] if node["step"] == "blender_still") + assert "use_custom_render_settings" not in render_node["params"] + + def test_canonicalize_workflow_config_defaults_execution_mode_for_canonical_configs(): canonical = canonicalize_workflow_config( { @@ -110,3 +213,68 @@ def test_canonicalize_workflow_config_defaults_execution_mode_for_canonical_conf assert canonical["ui"]["preset"] == "custom" assert canonical["ui"]["execution_mode"] == "legacy" + + +def test_workflow_config_requires_canonicalization_for_legacy_payloads(): + assert workflow_config_requires_canonicalization( + { + "type": "still", + "params": {"width": 1024, "height": 768}, + } + ) + + +def test_workflow_config_requires_canonicalization_skips_already_canonical_configs(): + config = build_preset_workflow_config("still", {"width": 1024, "height": 768}) + + assert workflow_config_requires_canonicalization(config) is False + + +def test_build_workflow_blueprint_config_creates_cad_intake_family_graph(): + config = build_workflow_blueprint_config("cad_intake") + + assert config["version"] == 1 + assert config["ui"]["preset"] == "custom" + assert config["ui"]["blueprint"] == "cad_intake" + assert [node["step"] for node in config["nodes"]] == [ + "resolve_step_path", + "occ_object_extract", + "occ_glb_export", + "stl_cache_generate", + "blender_render", + "threejs_render", + "thumbnail_save", + "thumbnail_save", + ] + + +def test_build_workflow_blueprint_config_creates_order_rendering_family_graph(): + config = build_workflow_blueprint_config("order_rendering") + + assert config["version"] == 1 + assert config["ui"]["preset"] == "custom" + assert config["ui"]["blueprint"] == "order_rendering" + assert any(node["step"] == "blender_still" for node in config["nodes"]) + 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 + + +def test_build_starter_workflow_config_creates_minimal_valid_custom_graph(): + config = build_starter_workflow_config() + + assert config["version"] == 1 + assert config["ui"]["preset"] == "custom" + assert config["ui"]["blueprint"] == "starter_order_rendering" + assert config["nodes"] == [ + { + "id": "setup", + "step": "order_line_setup", + "params": {}, + "ui": { + "type": "processNode", + "position": {"x": 120, "y": 140}, + "label": "Order Line Setup", + }, + } + ] diff --git a/frontend/src/__tests__/api/workflows.test.ts b/frontend/src/__tests__/api/workflows.test.ts new file mode 100644 index 0000000..aa89904 --- /dev/null +++ b/frontend/src/__tests__/api/workflows.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest' + +import { createPresetWorkflowConfig } from '../../api/workflows' + +describe('workflow preset config builders', () => { + test('builds a non-legacy still graph preset', () => { + const config = createPresetWorkflowConfig('still_graph', { + render_engine: 'cycles', + samples: 128, + resolution: [1600, 900], + }) + + expect(config.ui?.preset).toBe('still_graph') + expect(config.ui?.execution_mode).toBe('graph') + expect(config.nodes.map(node => node.step)).toEqual([ + 'order_line_setup', + 'auto_populate_materials', + 'resolve_template', + 'material_map_resolve', + 'blender_still', + 'output_save', + 'notify', + ]) + expect(config.nodes.find(node => node.step === 'blender_still')?.params).toMatchObject({ + render_engine: 'cycles', + samples: 128, + width: 1600, + height: 900, + }) + }) +}) diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts index 68978be..8b3adf3 100644 --- a/frontend/src/api/workflows.ts +++ b/frontend/src/api/workflows.ts @@ -1,6 +1,6 @@ import api from './client' -export type WorkflowPresetType = 'still' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom' +export type WorkflowPresetType = 'still' | 'still_graph' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom' export type WorkflowExecutionMode = 'legacy' | 'graph' | 'shadow' export interface WorkflowDefinition { @@ -21,6 +21,7 @@ export interface WorkflowConfig { export interface WorkflowParams { [key: string]: unknown + use_custom_render_settings?: boolean render_engine?: 'cycles' | 'eevee' samples?: number resolution?: [number, number] @@ -53,6 +54,7 @@ export interface WorkflowEdge { export interface WorkflowUi { preset?: WorkflowPresetType execution_mode?: WorkflowExecutionMode + blueprint?: string } export interface WorkflowCreate { @@ -67,6 +69,7 @@ export interface WorkflowRun { workflow_def_id: string | null order_line_id: string | null celery_task_id: string | null + execution_mode: WorkflowExecutionMode status: 'pending' | 'running' | 'completed' | 'failed' started_at: string | null completed_at: string | null @@ -85,6 +88,84 @@ export interface WorkflowNodeResult { created_at: string } +export interface WorkflowDispatchResponse { + workflow_run: WorkflowRun + context_id: string + execution_mode: WorkflowExecutionMode + dispatched: number + task_ids: string[] +} + +export interface WorkflowPreflightIssue { + severity: 'error' | 'warning' | 'info' + code: string + message: string + node_id: string | null + step: string | null +} + +export interface WorkflowPreflightNode { + node_id: string + step: string + label: string | null + execution_kind: WorkflowNodeExecutionKind + supported: boolean + status: 'ready' | 'warning' | 'error' | 'unsupported' + issues: WorkflowPreflightIssue[] +} + +export interface WorkflowPreflightResponse { + workflow_id: string | null + context_id: string + context_kind: 'order_line' | 'cad_file' | null + expected_context_kind: 'order_line' | 'cad_file' + execution_mode: WorkflowExecutionMode + graph_dispatch_allowed: boolean + summary: string + resolved_order_line_id: string | null + resolved_cad_file_id: string | null + unsupported_node_ids: string[] + issues: WorkflowPreflightIssue[] + nodes: WorkflowPreflightNode[] +} + +export interface WorkflowDraftPreflightRequest { + workflow_id?: string | null + context_id: string + config: WorkflowConfig +} + +export interface WorkflowDraftDispatchRequest { + workflow_id?: string | null + context_id: string + config: WorkflowConfig +} + +export interface WorkflowComparisonArtifact { + path: string | null + storage_key: string | null + exists: boolean + file_size_bytes: number | null + sha256: string | null + mime_type: string | null + image_width: number | null + image_height: number | null +} + +export interface WorkflowRunComparison { + workflow_run_id: string + workflow_def_id: string | null + order_line_id: string | null + execution_mode: WorkflowExecutionMode + status: string + summary: string + authoritative_output: WorkflowComparisonArtifact + observer_output: WorkflowComparisonArtifact + exact_match: boolean | null + dimensions_match: boolean | null + mean_pixel_delta: number | null +} + export const getWorkflows = (): Promise => api.get('/workflows').then(r => r.data.map(normalizeWorkflowDefinition)) @@ -103,10 +184,35 @@ export const deleteWorkflow = (id: string): Promise => export const getWorkflowRuns = (workflowId: string): Promise => api.get(`/workflows/${workflowId}/runs`).then(r => r.data) +export const dispatchWorkflow = ( + workflowId: string, + contextId: string, +): Promise => + api.post(`/workflows/${workflowId}/dispatch`, undefined, { params: { context_id: contextId } }).then(r => r.data) + +export const dispatchWorkflowDraft = ( + data: WorkflowDraftDispatchRequest, +): Promise => + api.post('/workflows/dispatch', data).then(r => r.data) + +export const preflightWorkflow = ( + workflowId: string, + contextId: string, +): Promise => + api.get(`/workflows/${workflowId}/preflight`, { params: { context_id: contextId } }).then(r => r.data) + +export const preflightWorkflowDraft = ( + data: WorkflowDraftPreflightRequest, +): Promise => + api.post('/workflows/preflight', data).then(r => r.data) + +export const getWorkflowRunComparison = (runId: string): Promise => + api.get(`/workflows/runs/${runId}/comparison`).then(r => r.data) + // ─── Node Definitions / Pipeline Steps ─────────────────────────────────────── export type StepCategory = 'input' | 'processing' | 'rendering' | 'output' -export type WorkflowNodeFieldType = 'number' | 'select' | 'boolean' +export type WorkflowNodeFieldType = 'number' | 'select' | 'boolean' | 'text' export type WorkflowNodeExecutionKind = 'native' | 'bridge' export interface WorkflowNodeFieldOption { @@ -189,6 +295,40 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = } } + if (type === 'still_graph') { + return { + version: 1, + ui: { preset: type, execution_mode: 'graph' }, + nodes: [ + { id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } }, + { + id: 'populate_materials', + step: 'auto_populate_materials', + params: {}, + ui: { type: 'processNode', label: 'Auto Populate Materials', position: { x: 220, y: 100 } }, + }, + { id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 440, y: 100 } } }, + { + id: 'resolve_materials', + step: 'material_map_resolve', + params: {}, + ui: { type: 'processNode', label: 'Resolve Material Map', position: { x: 660, y: 100 } }, + }, + { id: 'render', step: 'blender_still', params: renderParams, ui: { type: 'renderNode', label: 'Still Render', position: { x: 880, y: 100 } } }, + { id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 1100, y: 70 } } }, + { id: 'notify', step: 'notify', params: {}, ui: { type: 'outputNode', label: 'Notify Result', position: { x: 1100, y: 160 } } }, + ], + edges: [ + { from: 'setup', to: 'populate_materials' }, + { from: 'populate_materials', to: 'template' }, + { from: 'template', to: 'resolve_materials' }, + { from: 'resolve_materials', to: 'render' }, + { from: 'render', to: 'output' }, + { from: 'render', to: 'notify' }, + ], + } + } + if (type === 'turntable') { return { version: 1, diff --git a/frontend/src/pages/WorkflowEditor.tsx b/frontend/src/pages/WorkflowEditor.tsx index 23a25b4..419ea0e 100644 --- a/frontend/src/pages/WorkflowEditor.tsx +++ b/frontend/src/pages/WorkflowEditor.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent } from 'react' +import { useState, useCallback, useRef, useEffect, useMemo, type ChangeEvent, type DragEvent } from 'react' import { ReactFlow, Background, @@ -23,6 +23,10 @@ import { updateWorkflow, deleteWorkflow, getNodeDefinitions, + getWorkflowRuns, + getWorkflowRunComparison, + dispatchWorkflowDraft, + preflightWorkflowDraft, createPresetWorkflowConfig, getWorkflowPresetType, type WorkflowDefinition, @@ -34,6 +38,9 @@ import { type StepCategory, type WorkflowNodeDefinition, type WorkflowNodeFieldDefinition, + type WorkflowRun, + type WorkflowRunComparison, + type WorkflowPreflightResponse, } from '../api/workflows' import { FileUp, @@ -48,6 +55,10 @@ import { Trash2, GitBranch, X, + Play, + Loader2, + AlertTriangle, + Search, } from 'lucide-react' import { toast } from 'sonner' @@ -276,6 +287,134 @@ const EXECUTION_MODE_HINTS: Record = { shadow: 'Currently stored and exposed, but production dispatch still falls back to legacy until shadow parity lands.', } +const RUN_STATUS_STYLES: Record = { + pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + running: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + queued: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + completed: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', + failed: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + retrying: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + skipped: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300', +} + +type WorkflowValidationResult = { + errors: string[] + warnings: string[] +} + +function formatDateTime(value: string | null | undefined) { + if (!value) return 'n/a' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString() +} + +function getRunStatusClassName(status: string) { + return RUN_STATUS_STYLES[status] ?? 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300' +} + +function getPreflightStatusClassName(status: string) { + if (status === 'ready') return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300' + if (status === 'warning') return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' + if (status === 'error') return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' + return 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300' +} + +function validateWorkflowDraft( + nodes: Node[], + edges: Edge[], + nodeDefinitionsByStep: Record, + definitionsLoaded: boolean, +): WorkflowValidationResult { + const errors: string[] = [] + const warnings: string[] = [] + + if (nodes.length === 0) { + errors.push('Workflow must contain at least one node.') + return { errors, warnings } + } + + const nodeIds = new Set() + const connectedNodeIds = new Set() + const inDegree = new Map() + const adjacency = new Map() + + for (const node of nodes) { + const step = ((node.data as WorkflowCanvasNodeData | undefined)?.step as string | undefined) ?? inferStepFromNodeType(node.type) + const label = ((node.data as WorkflowCanvasNodeData | undefined)?.label as string | undefined) ?? node.id + + if (nodeIds.has(node.id)) { + errors.push(`Duplicate node id "${node.id}" is not allowed.`) + } + nodeIds.add(node.id) + inDegree.set(node.id, 0) + adjacency.set(node.id, []) + + if (definitionsLoaded && !nodeDefinitionsByStep[step]) { + errors.push(`Node "${label}" uses unknown step "${step}".`) + } + } + + const edgePairs = new Set() + for (const edge of edges) { + if (!nodeIds.has(edge.source)) { + errors.push(`Edge references unknown source node "${edge.source}".`) + continue + } + if (!nodeIds.has(edge.target)) { + errors.push(`Edge references unknown target node "${edge.target}".`) + continue + } + if (edge.source === edge.target) { + errors.push(`Node "${edge.source}" cannot point to itself.`) + continue + } + + const pair = `${edge.source}->${edge.target}` + if (edgePairs.has(pair)) { + errors.push(`Duplicate edge "${edge.source}" -> "${edge.target}" is not allowed.`) + continue + } + edgePairs.add(pair) + connectedNodeIds.add(edge.source) + connectedNodeIds.add(edge.target) + adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target]) + inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1) + } + + const queue = Array.from(inDegree.entries()) + .filter(([, degree]) => degree === 0) + .map(([nodeId]) => nodeId) + let processed = 0 + + while (queue.length > 0) { + const nodeId = queue.shift()! + processed += 1 + for (const neighbor of adjacency.get(nodeId) ?? []) { + const nextDegree = (inDegree.get(neighbor) ?? 0) - 1 + inDegree.set(neighbor, nextDegree) + if (nextDegree === 0) { + queue.push(neighbor) + } + } + } + + if (processed !== nodes.length) { + errors.push('Workflow graph contains a cycle.') + } + + if (nodes.length > 1) { + for (const node of nodes) { + const label = ((node.data as WorkflowCanvasNodeData | undefined)?.label as string | undefined) ?? node.id + if (!connectedNodeIds.has(node.id)) { + warnings.push(`Node "${label}" is disconnected.`) + } + } + } + + return { errors, warnings } +} + function workflowToGraph( config: WorkflowConfig, nodeDefinitionsByStep: Record, @@ -297,6 +436,222 @@ function workflowToGraph( } } +function buildCurrentWorkflowConfig( + workflow: WorkflowDefinition, + nodes: Node[], + edges: Edge[], + executionMode: WorkflowExecutionMode, +): WorkflowConfig { + return { + version: workflow.config.version ?? 1, + ui: { + ...(workflow.config.ui ?? {}), + execution_mode: executionMode, + }, + nodes: nodes.map(node => { + const step = + ((node.data as WorkflowCanvasNodeData | undefined)?.step as string | undefined) ?? + inferStepFromNodeType(node.type) + const label = + ((node.data as WorkflowCanvasNodeData | undefined)?.label as string | undefined) ?? + inferNodeLabel(step) + + return { + id: node.id, + step, + params: normalizeWorkflowParams( + (((node.data as WorkflowCanvasNodeData | undefined)?.params as WorkflowParams | undefined) ?? {}), + ), + ui: { + type: node.type, + position: node.position, + label, + }, + } + }), + edges: edges.map(edge => ({ + from: edge.source, + to: edge.target, + })) as WorkflowEdge[], + } +} + +const CATEGORY_LABELS: Record = { + input: 'Input', + processing: 'Processing', + rendering: 'Rendering', + output: 'Output', +} + +const CATEGORY_COLORS: Record = { + input: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + processing: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + rendering: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300', + output: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', +} + +const NODE_CATEGORY_ORDER: StepCategory[] = ['input', 'processing', 'rendering', 'output'] + +type WorkflowNodeFamily = 'cad_file' | 'order_line' +type WorkflowNodeFamilyFilter = 'all' | WorkflowNodeFamily +type WorkflowGraphFamily = WorkflowNodeFamily | 'mixed' + +const FAMILY_FILTER_LABELS: Record = { + all: 'All Nodes', + cad_file: 'CAD Intake', + order_line: 'Order Rendering', +} + +const FAMILY_FILTER_DESCRIPTIONS: Record = { + cad_file: 'Start with a CAD file context and produce previews, caches, or derived assets.', + order_line: 'Start with an order line context and run production rendering/output steps.', +} + +const FAMILY_FILTER_STYLES: Record = { + cad_file: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300', + order_line: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', +} + +const GRAPH_FAMILY_LABELS: Record = { + cad_file: 'CAD Intake', + order_line: 'Order Rendering', + mixed: 'Mixed Family', +} + +const GRAPH_FAMILY_STYLES: Record = { + cad_file: FAMILY_FILTER_STYLES.cad_file, + order_line: FAMILY_FILTER_STYLES.order_line, + mixed: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', +} + +const BLUEPRINT_LABELS: Record = { + cad_intake: 'Reference Blueprint', + order_rendering: 'Reference Blueprint', + starter_cad_intake: 'Starter', + starter_order_rendering: 'Starter', +} + +const BLUEPRINT_DESCRIPTION: Record = { + cad_intake: 'Canonical CAD-file workflow for intake, preview generation, and material discovery.', + order_rendering: 'Canonical order-line workflow for production rendering, exports, and notifications.', + starter_cad_intake: 'Minimal CAD-file starter graph.', + starter_order_rendering: 'Minimal order-line starter graph.', +} + +const CAD_FILE_NODE_STEPS = new Set([ + 'resolve_step_path', + 'occ_object_extract', + 'occ_glb_export', + 'stl_cache_generate', + 'blender_render', + 'threejs_render', + 'thumbnail_save', +]) + +function getNodeFamily(step: string): WorkflowNodeFamily { + return CAD_FILE_NODE_STEPS.has(step) ? 'cad_file' : 'order_line' +} + +function inferWorkflowFamily(config: WorkflowConfig): WorkflowGraphFamily { + const nodes = Array.isArray(config.nodes) ? config.nodes : [] + if (nodes.length > 0) { + const families = new Set(nodes.map(node => getNodeFamily(node.step))) + if (families.size === 1) { + return Array.from(families)[0] + } + return 'mixed' + } + + const presetType = getWorkflowPresetType(config) + if (presetType === 'custom') { + return 'mixed' + } + + return 'order_line' +} + +function getWorkflowBlueprint(config: WorkflowConfig): string | null { + const blueprint = config.ui?.blueprint + return typeof blueprint === 'string' && blueprint.trim().length > 0 ? blueprint : null +} + +function isReferenceBlueprint(config: WorkflowConfig): boolean { + const blueprint = getWorkflowBlueprint(config) + return blueprint === 'cad_intake' || blueprint === 'order_rendering' +} + +function cloneWorkflowConfig(config: WorkflowConfig, options?: { stripBlueprint?: boolean }): WorkflowConfig { + const nextUi = { ...(config.ui ?? {}) } + if (options?.stripBlueprint) { + delete nextUi.blueprint + } + + return { + version: config.version, + nodes: config.nodes.map(node => ({ + ...node, + params: { ...(node.params ?? {}) }, + ui: node.ui ? { ...node.ui } : undefined, + })), + edges: config.edges.map(edge => ({ ...edge })), + ui: nextUi, + } +} + +function compareWorkflows(a: WorkflowDefinition, b: WorkflowDefinition): number { + const blueprintRank = Number(isReferenceBlueprint(b.config)) - Number(isReferenceBlueprint(a.config)) + if (blueprintRank !== 0) return blueprintRank + + const activeRank = Number(b.is_active) - Number(a.is_active) + if (activeRank !== 0) return activeRank + + return a.name.localeCompare(b.name) +} + +function compareNodeDefinitions(a: WorkflowNodeDefinition, b: WorkflowNodeDefinition) { + const categoryDelta = NODE_CATEGORY_ORDER.indexOf(a.category) - NODE_CATEGORY_ORDER.indexOf(b.category) + if (categoryDelta !== 0) return categoryDelta + return a.label.localeCompare(b.label) +} + +function groupDefinitionsByCategory(definitions: WorkflowNodeDefinition[]) { + const grouped: Record = { + input: [], + processing: [], + rendering: [], + output: [], + } + + for (const definition of definitions) { + grouped[definition.category] = [...grouped[definition.category], definition] + } + + for (const category of NODE_CATEGORY_ORDER) { + grouped[category].sort(compareNodeDefinitions) + } + + return grouped +} + +function groupDefinitionsForStepSelect(definitions: WorkflowNodeDefinition[]) { + const groups = new Map() + + for (const definition of [...definitions].sort(compareNodeDefinitions)) { + const family = getNodeFamily(definition.step) + const groupLabel = `${FAMILY_FILTER_LABELS[family]} · ${CATEGORY_LABELS[definition.category]}` + groups.set(groupLabel, [...(groups.get(groupLabel) ?? []), definition]) + } + + return Array.from(groups.entries()).map(([label, options]) => ({ label, options })) +} + +function groupDefinitionsByFamily(definitions: WorkflowNodeDefinition[]) { + return { + cad_file: definitions.filter(definition => getNodeFamily(definition.step) === 'cad_file').sort(compareNodeDefinitions), + order_line: definitions.filter(definition => getNodeFamily(definition.step) === 'order_line').sort(compareNodeDefinitions), + } as Record +} + // ─── Config Sidepanel ───────────────────────────────────────────────────────── function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) { @@ -322,6 +677,9 @@ function ConfigSidepanel({ onStepChange?: (step: string) => void nodeDefinitions: WorkflowNodeDefinition[] }) { + const customRenderSettingsEnabled = Boolean(params.use_custom_render_settings) + const nodeSelectionGroups = groupDefinitionsForStepSelect(nodeDefinitions) + const updateField = (field: WorkflowNodeFieldDefinition, value: unknown) => { onChange( normalizeWorkflowParams({ @@ -345,7 +703,7 @@ function ConfigSidepanel({ const fieldsBySection = groupFieldsBySection(nodeDefinition?.fields ?? []) return ( -
+

Node Configuration

{nodeDefinitions.length > 0 && onStepChange && ( @@ -356,10 +714,14 @@ function ConfigSidepanel({ onChange={event => onStepChange(event.target.value)} className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" > - {nodeDefinitions.map(definition => ( - + {nodeSelectionGroups.map(group => ( + + {group.options.map(definition => ( + + ))} + ))} {nodeDefinition && ( @@ -393,6 +755,11 @@ function ConfigSidepanel({ {fields.map(field => { const rawValue = params[field.key] const value = rawValue ?? field.default + const disableRenderOverrideField = + (step === 'blender_still' || step === 'blender_turntable') && + !customRenderSettingsEnabled && + field.key !== 'use_custom_render_settings' && + (field.section === 'Render' || field.section === 'Output') return (
@@ -404,6 +771,7 @@ function ConfigSidepanel({ updateField(field, event.target.value)} + disabled={disableRenderOverrideField} + className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" + /> + )} {field.description && (

{field.description}

)} + {disableRenderOverrideField && ( +

+ In Graph/Shadow mode this field inherits from Output Type and Template until + Custom Render Settings is enabled. +

+ )}
) })} @@ -449,31 +834,149 @@ function ConfigSidepanel({ // ─── Node Definitions Panel ─────────────────────────────────────────────────── -const CATEGORY_LABELS: Record = { - input: 'Input', - processing: 'Processing', - rendering: 'Rendering', - output: 'Output', -} +function NodePalette({ + definitions, + onDragStep, +}: { + definitions: WorkflowNodeDefinition[] + onDragStep: (event: DragEvent, step: string) => void +}) { + const [query, setQuery] = useState('') + const [familyFilter, setFamilyFilter] = useState('all') -const CATEGORY_COLORS: Record = { - input: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', - processing: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', - rendering: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300', - output: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', + const visibleDefinitions = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase() + + return definitions + .filter(definition => familyFilter === 'all' || getNodeFamily(definition.step) === familyFilter) + .filter(definition => { + if (!normalizedQuery) return true + return [ + definition.label, + definition.step, + definition.description, + CATEGORY_LABELS[definition.category], + FAMILY_FILTER_LABELS[getNodeFamily(definition.step)], + ] + .join(' ') + .toLowerCase() + .includes(normalizedQuery) + }) + .sort(compareNodeDefinitions) + }, [definitions, familyFilter, query]) + + const groupedDefinitions = useMemo( + () => groupDefinitionsByCategory(visibleDefinitions), + [visibleDefinitions], + ) + + return ( +
+
+ Node Library +
+ + setQuery(event.target.value)} + placeholder="Search nodes, steps, or categories" + className="w-full rounded-lg border border-border-default bg-surface px-9 py-2 text-sm text-content focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+ {(['all', 'cad_file', 'order_line'] as WorkflowNodeFamilyFilter[]).map(filter => ( + + ))} +
+ +
+ {(['cad_file', 'order_line'] as WorkflowNodeFamily[]).map(family => ( + + + {FAMILY_FILTER_LABELS[family]} + + {FAMILY_FILTER_DESCRIPTIONS[family]} + + ))} + Mixed node families are currently blocked by preflight and should be modeled as separate workflows. +
+ + {visibleDefinitions.length === 0 ? ( +
+ No nodes match the current search/filter. +
+ ) : ( +
+ {NODE_CATEGORY_ORDER.map(category => { + const categoryDefinitions = groupedDefinitions[category] + if (categoryDefinitions.length === 0) return null + + return ( +
+
+ + {CATEGORY_LABELS[category]} + + {categoryDefinitions.length} +
+
+ {categoryDefinitions.map(definition => { + const family = getNodeFamily(definition.step) + return ( +
onDragStep(event, definition.step)} + className="cursor-grab rounded-lg border border-border-default bg-surface px-3 py-2 transition-colors hover:bg-surface-hover active:cursor-grabbing" + title={definition.description} + > +
+ {renderWorkflowIcon(definition.icon)} +
+
+

{definition.label}

+ + {FAMILY_FILTER_LABELS[family]} + + + {definition.execution_kind === 'bridge' ? 'Bridge' : 'Native'} + +
+

{definition.step}

+

{definition.description}

+
+
+
+ ) + })} +
+
+ ) + })} +
+ )} +
+ ) } function NodeDefinitionsPanel({ definitions }: { definitions: WorkflowNodeDefinition[] }) { - const [expanded, setExpanded] = useState(null) - - const grouped = definitions.reduce>( - (acc, definition) => { - acc[definition.category] = [...(acc[definition.category] ?? []), definition] - return acc - }, - { input: [], processing: [], rendering: [], output: [] }, - ) - + const [expanded, setExpanded] = useState(null) + const definitionsByFamily = groupDefinitionsByFamily(definitions) const categories: StepCategory[] = ['input', 'processing', 'rendering', 'output'] return ( @@ -481,66 +984,337 @@ function NodeDefinitionsPanel({ definitions }: { definitions: WorkflowNodeDefini

Available Nodes

-
- {categories.map(cat => ( -
- - {expanded === cat && ( -
- {grouped[cat].map(definition => ( -
-
-

{definition.label}

- - {definition.execution_kind === 'bridge' ? 'Bridge' : 'Native'} - -
-

{definition.step}

-

{definition.description}

-
- ))} +
+ {(['cad_file', 'order_line'] as WorkflowNodeFamily[]).map(family => { + const familyDefinitions = definitionsByFamily[family] + if (familyDefinitions.length === 0) return null + + const groupedByCategory = groupDefinitionsByCategory(familyDefinitions) + + return ( +
+
+ + {FAMILY_FILTER_LABELS[family]} + + {familyDefinitions.length}
- )} -
- ))} +
+ {categories.map(category => { + const categoryDefinitions = groupedByCategory[category] + if (categoryDefinitions.length === 0) return null + + const sectionKey = `${family}:${category}` + return ( +
+ + {expanded === sectionKey && ( +
+ {categoryDefinitions.map(definition => ( +
+
+

{definition.label}

+ + {definition.execution_kind === 'bridge' ? 'Bridge' : 'Native'} + +
+

{definition.step}

+

{definition.description}

+
+ ))} +
+ )} +
+ ) + })} +
+
+ ) + })}
) } +function WorkflowRunsPanel({ + runs, + selectedRunId, + onSelectRun, + comparison, + isComparisonLoading, +}: { + runs: WorkflowRun[] + selectedRunId: string | null + onSelectRun: (runId: string) => void + comparison?: WorkflowRunComparison + isComparisonLoading: boolean +}) { + const selectedRun = runs.find(run => run.id === selectedRunId) ?? null + + return ( +
+
+

Workflow Runs

+ {runs.length} +
+ + {runs.length === 0 && ( +

+ No workflow runs recorded for this workflow yet. +

+ )} + + {runs.length > 0 && ( +
+ {runs.slice(0, 8).map(run => ( + + ))} +
+ )} + + {selectedRun && ( +
+
+
+

Run {selectedRun.id.slice(0, 8)}

+

+ Started {formatDateTime(selectedRun.started_at ?? selectedRun.created_at)} +

+
+ + {selectedRun.status} + +
+ + {selectedRun.error_message && ( +

{selectedRun.error_message}

+ )} + +
+

+ Node Results +

+ {selectedRun.node_results.map(result => ( +
+
+ {result.node_name} + + {result.status} + +
+ {result.log && ( +

{result.log}

+ )} +
+ ))} +
+ + {selectedRun.execution_mode === 'shadow' && ( +
+
+

+ Shadow Comparison +

+ {isComparisonLoading && } +
+ {comparison && ( +
+

{comparison.summary}

+

Status: {comparison.status}

+

+ Authoritative: {comparison.authoritative_output.image_width ?? '?'} x {comparison.authoritative_output.image_height ?? '?'} +

+

+ Observer: {comparison.observer_output.image_width ?? '?'} x {comparison.observer_output.image_height ?? '?'} +

+ {comparison.mean_pixel_delta != null && ( +

Mean Pixel Delta: {comparison.mean_pixel_delta.toFixed(6)}

+ )} +
+ )} +
+ )} +
+ )} +
+ ) +} + +function WorkflowPreflightPanel({ + preflight, + isLoading, +}: { + preflight: WorkflowPreflightResponse | null + isLoading: boolean +}) { + if (!preflight && !isLoading) { + return null + } + + return ( +
+
+

Graph Preflight

+ {isLoading && } +
+ + {preflight && ( +
+
+
+

{preflight.summary}

+

+ Expected `{preflight.expected_context_kind}` · Resolved `{preflight.context_kind ?? 'n/a'}` +

+
+ + {preflight.graph_dispatch_allowed ? 'ready' : 'blocked'} + +
+ + {(preflight.resolved_order_line_id || preflight.resolved_cad_file_id) && ( +
+ {preflight.resolved_order_line_id &&

Order Line: {preflight.resolved_order_line_id}

} + {preflight.resolved_cad_file_id &&

CAD File: {preflight.resolved_cad_file_id}

} +
+ )} + + {preflight.issues.length > 0 && ( +
+

+ Global Issues +

+ {preflight.issues.map(issue => ( +
+
+ {issue.message} + + {issue.severity} + +
+
+ ))} +
+ )} + +
+

+ Node Checks +

+ {preflight.nodes.map(node => ( +
+
+
+

{node.label ?? node.node_id}

+

{node.step}

+
+ + {node.status} + +
+ {node.issues.length > 0 && ( +
+ {node.issues.map(issue => ( +

+ {issue.message} +

+ ))} +
+ )} +
+ ))} +
+
+ )} +
+ ) +} + // ─── New Workflow Modal ─────────────────────────────────────────────────────── interface NewWorkflowModalProps { + workflows: WorkflowDefinition[] onClose: () => void - onCreate: (name: string, type: WorkflowPresetType) => void + onCreate: (name: string, config: WorkflowConfig) => void isLoading: boolean } -function NewWorkflowModal({ onClose, onCreate, isLoading }: NewWorkflowModalProps) { +function NewWorkflowModal({ workflows, onClose, onCreate, isLoading }: NewWorkflowModalProps) { const [name, setName] = useState('') - const [type, setType] = useState('still') + const [type, setType] = useState('still_graph') + const [selectedBlueprintId, setSelectedBlueprintId] = useState(null) + + const referenceBlueprints = useMemo( + () => workflows.filter(workflow => isReferenceBlueprint(workflow.config)).sort(compareWorkflows), + [workflows], + ) + + const selectedBlueprint = referenceBlueprints.find(workflow => workflow.id === selectedBlueprintId) ?? null + const selectedBlueprintFamily = selectedBlueprint ? inferWorkflowFamily(selectedBlueprint.config) : null + const selectedBlueprintLabel = selectedBlueprint ? BLUEPRINT_LABELS[getWorkflowBlueprint(selectedBlueprint.config) ?? ''] : null + + const handleCreate = () => { + const trimmedName = name.trim() + if (!trimmedName) return + + if (selectedBlueprint) { + const clonedConfig = cloneWorkflowConfig(selectedBlueprint.config, { stripBlueprint: true }) + onCreate(trimmedName, clonedConfig) + return + } + + const defaultParams: WorkflowParams = + type === 'turntable' + ? { fps: 24, duration_s: 5 } + : type === 'multi_angle' + ? { angles: [0, 45, 90] } + : {} + + onCreate(trimmedName, createPresetWorkflowConfig(type, defaultParams)) + } return (
-
+

New Workflow

- +
{([ - { value: 'still', label: 'Still', desc: 'Single PNG image' }, + { value: 'still_graph', label: 'Still (Graph)', desc: 'Single PNG image via graph runtime' }, + { value: 'still', label: 'Still (Legacy)', desc: 'Single PNG image via legacy runtime' }, { value: 'turntable', label: 'Turntable', desc: 'Animation MP4' }, { value: 'multi_angle', label: 'Multi-Angle', desc: 'Multiple angles' }, - { value: 'still_with_exports', label: 'Still + GLB', desc: 'PNG + GLB exports' }, + { value: 'still_with_exports', label: 'Still + Blend', desc: 'PNG + .blend export' }, { value: 'custom', label: 'Custom', desc: 'Free canvas' }, ] as { value: WorkflowPresetType; label: string; desc: string }[]).map(opt => (
+ + {referenceBlueprints.length > 0 && ( +
+
+ + +
+
+ {referenceBlueprints.map(workflow => { + const blueprint = getWorkflowBlueprint(workflow.config) + const family = inferWorkflowFamily(workflow.config) + const isSelected = selectedBlueprintId === workflow.id + return ( + + ) + })} +
+ {selectedBlueprint && ( +

+ New workflow will be cloned from {selectedBlueprint.name} + {selectedBlueprintFamily ? ` (${GRAPH_FAMILY_LABELS[selectedBlueprintFamily]})` : ''} + {selectedBlueprintLabel ? ` as ${selectedBlueprintLabel.toLowerCase()}` : ''}. +

+ )} +
+ )}
@@ -596,7 +1427,7 @@ function NewWorkflowModal({ onClose, onCreate, isLoading }: NewWorkflowModalProp