diff --git a/backend/app/domains/rendering/workflow_config_utils.py b/backend/app/domains/rendering/workflow_config_utils.py index 1490369..ddc94a4 100644 --- a/backend/app/domains/rendering/workflow_config_utils.py +++ b/backend/app/domains/rendering/workflow_config_utils.py @@ -171,7 +171,10 @@ def build_preset_workflow_config( "version": 1, "nodes": nodes, "edges": edges, - "ui": {"preset": preset_type}, + "ui": { + "preset": preset_type, + "execution_mode": "legacy", + }, } @@ -214,7 +217,10 @@ def _canonicalize_legacy_custom_config(raw: dict[str, Any]) -> dict[str, Any]: "version": 1, "nodes": nodes, "edges": edges, - "ui": {"preset": "custom"}, + "ui": { + "preset": "custom", + "execution_mode": "legacy", + }, } @@ -225,6 +231,11 @@ def canonicalize_workflow_config(raw: dict[str, Any]) -> dict[str, Any]: if "version" in raw and "nodes" in raw: normalized = deepcopy(raw) normalized.setdefault("edges", []) + ui = normalized.get("ui") + if not isinstance(ui, dict): + ui = {} + normalized["ui"] = dict(ui) + normalized["ui"].setdefault("execution_mode", "legacy") return normalized workflow_type = raw.get("type") diff --git a/backend/tests/domains/test_workflow_config_utils.py b/backend/tests/domains/test_workflow_config_utils.py index 35bdefd..ebf0608 100644 --- a/backend/tests/domains/test_workflow_config_utils.py +++ b/backend/tests/domains/test_workflow_config_utils.py @@ -13,6 +13,7 @@ def test_build_preset_workflow_config_creates_canonical_dag(): assert config["version"] == 1 assert config["ui"]["preset"] == "still" + assert config["ui"]["execution_mode"] == "legacy" assert [node["step"] for node in config["nodes"]] == [ "order_line_setup", "resolve_template", @@ -34,6 +35,7 @@ def test_canonicalize_workflow_config_migrates_legacy_preset(): assert canonical["version"] == 1 assert canonical["ui"]["preset"] == "turntable" + assert canonical["ui"]["execution_mode"] == "legacy" assert any(node["step"] == "blender_turntable" for node in canonical["nodes"]) @@ -76,6 +78,7 @@ def test_canonicalize_legacy_custom_config_preserves_edges(): canonical = canonicalize_workflow_config(legacy) assert canonical["ui"]["preset"] == "custom" + assert canonical["ui"]["execution_mode"] == "legacy" assert canonical["edges"] == [{"id": "e1", "from": "input", "to": "render"}] @@ -91,3 +94,19 @@ def test_extract_runtime_workflow_converts_resolution_to_dimensions(): assert params["width"] == 1920 assert params["height"] == 1080 assert "resolution" not in params + + +def test_canonicalize_workflow_config_defaults_execution_mode_for_canonical_configs(): + canonical = canonicalize_workflow_config( + { + "version": 1, + "nodes": [ + {"id": "setup", "step": "order_line_setup", "params": {}}, + ], + "edges": [], + "ui": {"preset": "custom"}, + } + ) + + assert canonical["ui"]["preset"] == "custom" + assert canonical["ui"]["execution_mode"] == "legacy" diff --git a/backend/tests/domains/test_workflow_node_registry.py b/backend/tests/domains/test_workflow_node_registry.py index 3cabbfd..c4c9907 100644 --- a/backend/tests/domains/test_workflow_node_registry.py +++ b/backend/tests/domains/test_workflow_node_registry.py @@ -45,3 +45,40 @@ async def test_node_definitions_endpoint_returns_registry(client, auth_headers): ) assert blender_still["node_type"] == "renderNode" assert blender_still["defaults"]["render_engine"] == "cycles" + + +@pytest.mark.asyncio +async def test_workflow_crud_roundtrip_preserves_execution_mode(client, auth_headers): + create_response = await client.post( + "/api/workflows", + headers=auth_headers, + json={ + "name": "Shadow Workflow", + "config": { + "version": 1, + "ui": { + "preset": "custom", + "execution_mode": "shadow", + }, + "nodes": [ + { + "id": "setup", + "step": StepName.ORDER_LINE_SETUP.value, + "params": {}, + } + ], + "edges": [], + }, + "is_active": True, + }, + ) + + assert create_response.status_code == 201 + created = create_response.json() + assert created["config"]["ui"]["execution_mode"] == "shadow" + + get_response = await client.get(f"/api/workflows/{created['id']}", headers=auth_headers) + + assert get_response.status_code == 200 + fetched = get_response.json() + assert fetched["config"]["ui"]["execution_mode"] == "shadow" diff --git a/docs/workflows/WORKFLOW_DELIVERY_CHECKLIST.md b/docs/workflows/WORKFLOW_DELIVERY_CHECKLIST.md index 40e8fe9..d933aeb 100644 --- a/docs/workflows/WORKFLOW_DELIVERY_CHECKLIST.md +++ b/docs/workflows/WORKFLOW_DELIVERY_CHECKLIST.md @@ -29,8 +29,8 @@ - [x] Workflow context introduced - [x] Node outputs are persisted and reusable - [x] Graph runtime supports legacy fallback -- [ ] `legacy`, `graph`, and `shadow` modes exist -- Progress: Graph dispatch now persists retry/failure policy metadata per node, retries bridge-node failures within the configured attempt budget, and the production order-line dispatcher can opt into explicit graph mode with a hard fallback back to legacy dispatch if graph execution preparation or runtime fails. +- [x] `legacy`, `graph`, and `shadow` modes exist +- Progress: Workflow configs now normalize to an explicit execution mode, the editor exposes and persists `legacy`/`graph`/`shadow`, production order-line dispatch can opt into graph mode with hard fallback to legacy on graph failure, and shadow mode is stored safely while still deferring duplicate-safe parity execution to Phase 6. ### Phase 5 diff --git a/docs/workflows/WORKFLOW_IMPLEMENTATION_BACKLOG.md b/docs/workflows/WORKFLOW_IMPLEMENTATION_BACKLOG.md index 0f95214..8e2456f 100644 --- a/docs/workflows/WORKFLOW_IMPLEMENTATION_BACKLOG.md +++ b/docs/workflows/WORKFLOW_IMPLEMENTATION_BACKLOG.md @@ -74,7 +74,7 @@ - `E4-T2` Refactor executor to process nodes against context and node outputs. `completed` - `E4-T3` Persist node-level run records, logs, timings, and outputs. `completed` - `E4-T4` Support retry and failure policies. `completed` -- `E4-T5` Add execution mode switch: `legacy`, `graph`, `shadow`. +- `E4-T5` Add execution mode switch: `legacy`, `graph`, `shadow`. `completed` - `E4-T6` Add hard fallback to legacy dispatch on graph failure. `completed` ## Epic 5: Editor Parity diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts index e1b3219..68978be 100644 --- a/frontend/src/api/workflows.ts +++ b/frontend/src/api/workflows.ts @@ -1,6 +1,7 @@ import api from './client' export type WorkflowPresetType = 'still' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom' +export type WorkflowExecutionMode = 'legacy' | 'graph' | 'shadow' export interface WorkflowDefinition { id: string @@ -51,7 +52,7 @@ export interface WorkflowEdge { export interface WorkflowUi { preset?: WorkflowPresetType - execution_mode?: 'legacy' | 'graph' | 'shadow' + execution_mode?: WorkflowExecutionMode } export interface WorkflowCreate { @@ -173,7 +174,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = if (type === 'still') { return { version: 1, - ui: { preset: type }, + ui: { preset: type, execution_mode: 'legacy' }, nodes: [ { id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } }, { id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } }, @@ -191,7 +192,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = if (type === 'turntable') { return { version: 1, - ui: { preset: type }, + ui: { preset: type, execution_mode: 'legacy' }, nodes: [ { id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } }, { id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } }, @@ -212,7 +213,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = delete sharedParams.angles return { version: 1, - ui: { preset: type }, + ui: { preset: type, execution_mode: 'legacy' }, nodes: [ { id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 195 } } }, { id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 195 } } }, @@ -235,7 +236,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = if (type === 'still_with_exports') { return { version: 1, - ui: { preset: type }, + ui: { preset: type, execution_mode: 'legacy' }, nodes: [ { id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } }, { id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } }, @@ -254,7 +255,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = return { version: 1, - ui: { preset: 'custom' }, + ui: { preset: 'custom', execution_mode: 'legacy' }, nodes: [ { id: 'setup', @@ -276,6 +277,7 @@ function normalizeWorkflowDefinition(raw: WorkflowDefinition): WorkflowDefinitio export function normalizeWorkflowConfig(raw: Record): WorkflowConfig { if ('version' in raw && Array.isArray(raw.nodes)) { + const rawUi = (raw.ui as WorkflowUi | undefined) ?? {} return { version: Number(raw.version ?? 1), nodes: (raw.nodes as WorkflowNode[]).map(node => ({ @@ -283,7 +285,10 @@ export function normalizeWorkflowConfig(raw: Record): WorkflowC params: { ...(node.params ?? {}) }, })), edges: Array.isArray(raw.edges) ? (raw.edges as WorkflowEdge[]) : [], - ui: raw.ui as WorkflowUi | undefined, + ui: { + ...rawUi, + execution_mode: rawUi.execution_mode ?? 'legacy', + }, } } @@ -295,7 +300,7 @@ export function normalizeWorkflowConfig(raw: Record): WorkflowC version: 1, nodes: [], edges: [], - ui: { preset: 'custom' }, + ui: { preset: 'custom', execution_mode: 'legacy' }, } } diff --git a/frontend/src/pages/WorkflowEditor.tsx b/frontend/src/pages/WorkflowEditor.tsx index 78d115e..23a25b4 100644 --- a/frontend/src/pages/WorkflowEditor.tsx +++ b/frontend/src/pages/WorkflowEditor.tsx @@ -28,6 +28,7 @@ import { type WorkflowDefinition, type WorkflowConfig, type WorkflowEdge, + type WorkflowExecutionMode, type WorkflowPresetType, type WorkflowParams, type StepCategory, @@ -257,6 +258,24 @@ function inferStepFromNodeType(type?: string): string { return 'blender_still' } +const EXECUTION_MODE_LABELS: Record = { + legacy: 'Legacy', + graph: 'Graph', + shadow: 'Shadow', +} + +const EXECUTION_MODE_BADGE_STYLES: Record = { + legacy: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300', + graph: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', + shadow: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', +} + +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.', +} + function workflowToGraph( config: WorkflowConfig, nodeDefinitionsByStep: Record, @@ -608,6 +627,7 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { const [nodes, setNodes, onNodesChange] = useNodesState(initNodes) const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges) const [selectedNodeId, setSelectedNodeId] = useState(null) + const [executionMode, setExecutionMode] = useState(workflow.config.ui?.execution_mode ?? 'legacy') const reactFlowWrapper = useRef(null) const [reactFlowInstance, setReactFlowInstance] = useState(null) @@ -616,6 +636,7 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { setNodes(graph.nodes) setEdges(graph.edges) setSelectedNodeId(null) + setExecutionMode(workflow.config.ui?.execution_mode ?? 'legacy') }, [nodeDefinitionsData, setEdges, setNodes, workflow.config]) const onConnect = useCallback( @@ -709,7 +730,10 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { const handleSave = () => { const updatedConfig: WorkflowConfig = { version: workflow.config.version ?? 1, - ui: workflow.config.ui, + ui: { + ...(workflow.config.ui ?? {}), + execution_mode: executionMode, + }, nodes: nodes.map(node => ({ id: node.id, step: ((node.data as any).step as string | undefined) ?? inferStepFromNodeType(node.type), @@ -753,7 +777,21 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { {definition.label} ))} -
+
+
+
+

{EXECUTION_MODE_HINTS[executionMode]}

+
+ {/* Canvas + Sidepanel */}
@@ -922,6 +964,7 @@ export default function WorkflowEditor() { )} {workflows.map(wf => { const presetType = getWorkflowPresetType(wf.config) + const executionMode = wf.config.ui?.execution_mode ?? 'legacy' return (