feat: expose workflow execution modes in editor

This commit is contained in:
2026-04-07 11:10:58 +02:00
parent f9d4da52b9
commit 26046fb2d6
7 changed files with 147 additions and 16 deletions
@@ -171,7 +171,10 @@ def build_preset_workflow_config(
"version": 1, "version": 1,
"nodes": nodes, "nodes": nodes,
"edges": edges, "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, "version": 1,
"nodes": nodes, "nodes": nodes,
"edges": edges, "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: if "version" in raw and "nodes" in raw:
normalized = deepcopy(raw) normalized = deepcopy(raw)
normalized.setdefault("edges", []) 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 return normalized
workflow_type = raw.get("type") workflow_type = raw.get("type")
@@ -13,6 +13,7 @@ def test_build_preset_workflow_config_creates_canonical_dag():
assert config["version"] == 1 assert config["version"] == 1
assert config["ui"]["preset"] == "still" assert config["ui"]["preset"] == "still"
assert config["ui"]["execution_mode"] == "legacy"
assert [node["step"] for node in config["nodes"]] == [ assert [node["step"] for node in config["nodes"]] == [
"order_line_setup", "order_line_setup",
"resolve_template", "resolve_template",
@@ -34,6 +35,7 @@ def test_canonicalize_workflow_config_migrates_legacy_preset():
assert canonical["version"] == 1 assert canonical["version"] == 1
assert canonical["ui"]["preset"] == "turntable" assert canonical["ui"]["preset"] == "turntable"
assert canonical["ui"]["execution_mode"] == "legacy"
assert any(node["step"] == "blender_turntable" for node in canonical["nodes"]) 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) canonical = canonicalize_workflow_config(legacy)
assert canonical["ui"]["preset"] == "custom" assert canonical["ui"]["preset"] == "custom"
assert canonical["ui"]["execution_mode"] == "legacy"
assert canonical["edges"] == [{"id": "e1", "from": "input", "to": "render"}] 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["width"] == 1920
assert params["height"] == 1080 assert params["height"] == 1080
assert "resolution" not in params 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"
@@ -45,3 +45,40 @@ async def test_node_definitions_endpoint_returns_registry(client, auth_headers):
) )
assert blender_still["node_type"] == "renderNode" assert blender_still["node_type"] == "renderNode"
assert blender_still["defaults"]["render_engine"] == "cycles" 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"
@@ -29,8 +29,8 @@
- [x] Workflow context introduced - [x] Workflow context introduced
- [x] Node outputs are persisted and reusable - [x] Node outputs are persisted and reusable
- [x] Graph runtime supports legacy fallback - [x] Graph runtime supports legacy fallback
- [ ] `legacy`, `graph`, and `shadow` modes exist - [x] `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. - 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 ### Phase 5
@@ -74,7 +74,7 @@
- `E4-T2` Refactor executor to process nodes against context and node outputs. `completed` - `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-T3` Persist node-level run records, logs, timings, and outputs. `completed`
- `E4-T4` Support retry and failure policies. `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` - `E4-T6` Add hard fallback to legacy dispatch on graph failure. `completed`
## Epic 5: Editor Parity ## Epic 5: Editor Parity
+13 -8
View File
@@ -1,6 +1,7 @@
import api from './client' import api from './client'
export type WorkflowPresetType = 'still' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom' export type WorkflowPresetType = 'still' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom'
export type WorkflowExecutionMode = 'legacy' | 'graph' | 'shadow'
export interface WorkflowDefinition { export interface WorkflowDefinition {
id: string id: string
@@ -51,7 +52,7 @@ export interface WorkflowEdge {
export interface WorkflowUi { export interface WorkflowUi {
preset?: WorkflowPresetType preset?: WorkflowPresetType
execution_mode?: 'legacy' | 'graph' | 'shadow' execution_mode?: WorkflowExecutionMode
} }
export interface WorkflowCreate { export interface WorkflowCreate {
@@ -173,7 +174,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
if (type === 'still') { if (type === 'still') {
return { return {
version: 1, version: 1,
ui: { preset: type }, ui: { preset: type, execution_mode: 'legacy' },
nodes: [ nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } }, { 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 } } }, { 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') { if (type === 'turntable') {
return { return {
version: 1, version: 1,
ui: { preset: type }, ui: { preset: type, execution_mode: 'legacy' },
nodes: [ nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } }, { 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 } } }, { 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 delete sharedParams.angles
return { return {
version: 1, version: 1,
ui: { preset: type }, ui: { preset: type, execution_mode: 'legacy' },
nodes: [ nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 195 } } }, { 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 } } }, { 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') { if (type === 'still_with_exports') {
return { return {
version: 1, version: 1,
ui: { preset: type }, ui: { preset: type, execution_mode: 'legacy' },
nodes: [ nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } }, { 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 } } }, { 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 { return {
version: 1, version: 1,
ui: { preset: 'custom' }, ui: { preset: 'custom', execution_mode: 'legacy' },
nodes: [ nodes: [
{ {
id: 'setup', id: 'setup',
@@ -276,6 +277,7 @@ function normalizeWorkflowDefinition(raw: WorkflowDefinition): WorkflowDefinitio
export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowConfig { export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowConfig {
if ('version' in raw && Array.isArray(raw.nodes)) { if ('version' in raw && Array.isArray(raw.nodes)) {
const rawUi = (raw.ui as WorkflowUi | undefined) ?? {}
return { return {
version: Number(raw.version ?? 1), version: Number(raw.version ?? 1),
nodes: (raw.nodes as WorkflowNode[]).map(node => ({ nodes: (raw.nodes as WorkflowNode[]).map(node => ({
@@ -283,7 +285,10 @@ export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowC
params: { ...(node.params ?? {}) }, params: { ...(node.params ?? {}) },
})), })),
edges: Array.isArray(raw.edges) ? (raw.edges as WorkflowEdge[]) : [], 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<string, unknown>): WorkflowC
version: 1, version: 1,
nodes: [], nodes: [],
edges: [], edges: [],
ui: { preset: 'custom' }, ui: { preset: 'custom', execution_mode: 'legacy' },
} }
} }
+62 -3
View File
@@ -28,6 +28,7 @@ import {
type WorkflowDefinition, type WorkflowDefinition,
type WorkflowConfig, type WorkflowConfig,
type WorkflowEdge, type WorkflowEdge,
type WorkflowExecutionMode,
type WorkflowPresetType, type WorkflowPresetType,
type WorkflowParams, type WorkflowParams,
type StepCategory, type StepCategory,
@@ -257,6 +258,24 @@ function inferStepFromNodeType(type?: string): string {
return 'blender_still' return 'blender_still'
} }
const EXECUTION_MODE_LABELS: Record<WorkflowExecutionMode, string> = {
legacy: 'Legacy',
graph: 'Graph',
shadow: 'Shadow',
}
const EXECUTION_MODE_BADGE_STYLES: Record<WorkflowExecutionMode, string> = {
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<WorkflowExecutionMode, string> = {
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( function workflowToGraph(
config: WorkflowConfig, config: WorkflowConfig,
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>, nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>,
@@ -608,6 +627,7 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
const [nodes, setNodes, onNodesChange] = useNodesState(initNodes) const [nodes, setNodes, onNodesChange] = useNodesState(initNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges) const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null) const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [executionMode, setExecutionMode] = useState<WorkflowExecutionMode>(workflow.config.ui?.execution_mode ?? 'legacy')
const reactFlowWrapper = useRef<HTMLDivElement>(null) const reactFlowWrapper = useRef<HTMLDivElement>(null)
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null) const [reactFlowInstance, setReactFlowInstance] = useState<any>(null)
@@ -616,6 +636,7 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
setNodes(graph.nodes) setNodes(graph.nodes)
setEdges(graph.edges) setEdges(graph.edges)
setSelectedNodeId(null) setSelectedNodeId(null)
setExecutionMode(workflow.config.ui?.execution_mode ?? 'legacy')
}, [nodeDefinitionsData, setEdges, setNodes, workflow.config]) }, [nodeDefinitionsData, setEdges, setNodes, workflow.config])
const onConnect = useCallback( const onConnect = useCallback(
@@ -709,7 +730,10 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
const handleSave = () => { const handleSave = () => {
const updatedConfig: WorkflowConfig = { const updatedConfig: WorkflowConfig = {
version: workflow.config.version ?? 1, version: workflow.config.version ?? 1,
ui: workflow.config.ui, ui: {
...(workflow.config.ui ?? {}),
execution_mode: executionMode,
},
nodes: nodes.map(node => ({ nodes: nodes.map(node => ({
id: node.id, id: node.id,
step: ((node.data as any).step as string | undefined) ?? inferStepFromNodeType(node.type), step: ((node.data as any).step as string | undefined) ?? inferStepFromNodeType(node.type),
@@ -753,7 +777,21 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
{definition.label} {definition.label}
</div> </div>
))} ))}
<div className="ml-auto"> <div className="ml-auto flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-content-secondary whitespace-nowrap">
<span>Execution Mode</span>
<select
value={executionMode}
onChange={event => setExecutionMode(event.target.value as WorkflowExecutionMode)}
className="border border-border-default rounded-lg px-2.5 py-1.5 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
>
{(['legacy', 'graph', 'shadow'] as WorkflowExecutionMode[]).map(mode => (
<option key={mode} value={mode}>
{EXECUTION_MODE_LABELS[mode]}
</option>
))}
</select>
</label>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={isSaving} disabled={isSaving}
@@ -765,6 +803,10 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
</div> </div>
</div> </div>
<div className="px-4 py-2 border-b border-border-default bg-surface-hover/40">
<p className="text-xs text-content-muted">{EXECUTION_MODE_HINTS[executionMode]}</p>
</div>
{/* Canvas + Sidepanel */} {/* Canvas + Sidepanel */}
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<div ref={reactFlowWrapper} className="flex-1" onDrop={onDrop} onDragOver={onDragOver}> <div ref={reactFlowWrapper} className="flex-1" onDrop={onDrop} onDragOver={onDragOver}>
@@ -922,6 +964,7 @@ export default function WorkflowEditor() {
)} )}
{workflows.map(wf => { {workflows.map(wf => {
const presetType = getWorkflowPresetType(wf.config) const presetType = getWorkflowPresetType(wf.config)
const executionMode = wf.config.ui?.execution_mode ?? 'legacy'
return ( return (
<button <button
key={wf.id} key={wf.id}
@@ -962,6 +1005,13 @@ export default function WorkflowEditor() {
> >
{typeLabel[presetType]} {typeLabel[presetType]}
</span> </span>
<span
className={`inline-block mt-1 ml-1 text-xs px-1.5 py-0.5 rounded-full font-medium ${
EXECUTION_MODE_BADGE_STYLES[executionMode]
}`}
>
{EXECUTION_MODE_LABELS[executionMode]}
</span>
{!wf.is_active && ( {!wf.is_active && (
<span className="ml-1 text-xs text-content-muted">(inactive)</span> <span className="ml-1 text-xs text-content-muted">(inactive)</span>
)} )}
@@ -978,7 +1028,16 @@ export default function WorkflowEditor() {
<div> <div>
<h1 className="text-xl font-semibold text-content">Workflow Editor</h1> <h1 className="text-xl font-semibold text-content">Workflow Editor</h1>
{selectedWorkflow && ( {selectedWorkflow && (
<p className="text-sm text-content-muted mt-0.5">{selectedWorkflow.name}</p> <div className="mt-0.5 flex items-center gap-2">
<p className="text-sm text-content-muted">{selectedWorkflow.name}</p>
<span
className={`inline-block text-xs px-1.5 py-0.5 rounded-full font-medium ${
EXECUTION_MODE_BADGE_STYLES[selectedWorkflow.config.ui?.execution_mode ?? 'legacy']
}`}
>
{EXECUTION_MODE_LABELS[selectedWorkflow.config.ui?.execution_mode ?? 'legacy']}
</span>
</div>
)} )}
</div> </div>
<button <button