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,
"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")
@@ -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"
@@ -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"
@@ -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
@@ -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
+13 -8
View File
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): WorkflowC
version: 1,
nodes: [],
edges: [],
ui: { preset: 'custom' },
ui: { preset: 'custom', execution_mode: 'legacy' },
}
}
+62 -3
View File
@@ -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<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(
config: WorkflowConfig,
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>,
@@ -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<string | null>(null)
const [executionMode, setExecutionMode] = useState<WorkflowExecutionMode>(workflow.config.ui?.execution_mode ?? 'legacy')
const reactFlowWrapper = useRef<HTMLDivElement>(null)
const [reactFlowInstance, setReactFlowInstance] = useState<any>(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}
</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
onClick={handleSave}
disabled={isSaving}
@@ -765,6 +803,10 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
</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 */}
<div className="flex flex-1 min-h-0">
<div ref={reactFlowWrapper} className="flex-1" onDrop={onDrop} onDragOver={onDragOver}>
@@ -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 (
<button
key={wf.id}
@@ -962,6 +1005,13 @@ export default function WorkflowEditor() {
>
{typeLabel[presetType]}
</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 && (
<span className="ml-1 text-xs text-content-muted">(inactive)</span>
)}
@@ -978,7 +1028,16 @@ export default function WorkflowEditor() {
<div>
<h1 className="text-xl font-semibold text-content">Workflow Editor</h1>
{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>
<button