feat: expose workflow execution modes in editor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user