From d071aecda6df6823a1ad48bb5e08f17730132ef0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 09:45:26 -0700 Subject: [PATCH] feat(dashboard): visual graph node editor for workflows (U6, U7) Add a React Flow (@xyflow/react) based WorkflowNodeEditor: a lazy-loaded modal with a workflow list, a node palette (prompt/script/gate/merge-boundary), drag-to-connect edges, a per-node inspector, and save with compile-validation that surfaces non-linear graphs as a banner. Pure irToFlow/flowToIr mapping round-trips the v1 IR plus editor layout. Reachable via a Graph editor button in the Workflow Steps manager; mounted in AppModals behind a new modal-manager flag. Adds a vendor-reactflow Vite chunk and a feature changeset. --- .changeset/graph-custom-workflows.md | 5 + .../dashboard/app/components/AppModals.tsx | 18 + .../app/components/WorkflowNodeEditor.css | 297 +++++++++++++++ .../app/components/WorkflowNodeEditor.tsx | 359 ++++++++++++++++++ .../app/components/WorkflowStepManager.css | 22 ++ .../app/components/WorkflowStepManager.tsx | 17 +- .../__tests__/WorkflowNodeEditor.test.tsx | 91 +++++ .../components/nodes/WorkflowNodeTypes.tsx | 48 +++ .../app/components/workflow-flow-mapping.ts | 94 +++++ .../dashboard/app/hooks/useModalManager.ts | 10 + packages/dashboard/package.json | 11 +- packages/dashboard/vite.config.ts | 4 + packages/dashboard/vitest.config.ts | 1 + pnpm-lock.yaml | 239 ++++++++++-- 14 files changed, 1184 insertions(+), 32 deletions(-) create mode 100644 .changeset/graph-custom-workflows.md create mode 100644 packages/dashboard/app/components/WorkflowNodeEditor.css create mode 100644 packages/dashboard/app/components/WorkflowNodeEditor.tsx create mode 100644 packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx create mode 100644 packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx create mode 100644 packages/dashboard/app/components/workflow-flow-mapping.ts diff --git a/.changeset/graph-custom-workflows.md b/.changeset/graph-custom-workflows.md new file mode 100644 index 0000000000..e19e1e1efc --- /dev/null +++ b/.changeset/graph-custom-workflows.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add executable custom workflows with a visual graph node editor. Author a workflow as a graph (start → prompt/script/gate steps → end) in a new React Flow–based editor, then select it per task or set a project default. Selected workflows compile to the existing WorkflowStep engine and run at the pre/post-merge boundaries — no changes to the scheduler/executor/merger. Non-linear graphs are rejected with a clear message and reserved for the (deferred) graph interpreter. diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index ee8e5f0bed..9b543557c1 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -29,6 +29,7 @@ import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; const SetupWizardModal = lazy(() => import("./SetupWizardModal").then((m) => ({ default: m.SetupWizardModal }))); const SettingsModal = lazy(() => import("./SettingsModal").then((m) => ({ default: m.SettingsModal }))); +const WorkflowNodeEditor = lazy(() => import("./WorkflowNodeEditor").then((m) => ({ default: m.WorkflowNodeEditor }))); function prefetchSettingsModal() { const idle: (cb: () => void, opts?: { timeout?: number }) => number = @@ -378,9 +379,26 @@ export function AppModals({ onClose={modalManager.closeWorkflowSteps} addToast={addToast} projectId={projectId} + onOpenGraphEditor={() => { + modalManager.closeWorkflowSteps(); + modalManager.openWorkflowEditor(); + }} /> + {modalManager.workflowEditorOpen && ( + + + + + + )} + void; + addToast: (message: string, type?: ToastType) => void; + projectId?: string; +} + +let nodeSeq = 0; +function newNodeId(): string { + nodeSeq += 1; + return `n-${Date.now().toString(36)}-${nodeSeq}`; +} + +const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof MessageSquare }> = [ + { kind: "prompt", label: "Prompt", icon: MessageSquare }, + { kind: "script", label: "Script", icon: Terminal }, + { kind: "gate", label: "Gate", icon: Shield }, + { kind: "merge", label: "Merge boundary", icon: GitMerge }, +]; + +function InnerEditor({ + onClose, + addToast, + projectId, + modalRef, +}: Omit & { modalRef: React.RefObject }) { + const [workflows, setWorkflows] = useState([]); + const [activeId, setActiveId] = useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [validationError, setValidationError] = useState(null); + const [nodes, setNodes, onNodesChange] = useNodesState>([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [selectedNodeId, setSelectedNodeId] = useState(null); + + const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]); + + const loadWorkflows = useCallback(async () => { + setLoading(true); + try { + const data = await fetchWorkflows(projectId); + setWorkflows(data); + setActiveId((prev) => prev ?? data[0]?.id ?? null); + } catch (err) { + addToast(getErrorMessage(err) || "Failed to load workflows", "error"); + } finally { + setLoading(false); + } + }, [projectId, addToast]); + + useEffect(() => { + void loadWorkflows(); + }, [loadWorkflows]); + + // Load the active workflow graph into the canvas. + useEffect(() => { + if (!activeWorkflow) { + setNodes([]); + setEdges([]); + return; + } + const flow = irToFlow(activeWorkflow); + setNodes(flow.nodes); + setEdges(flow.edges); + setSelectedNodeId(null); + setValidationError(null); + }, [activeWorkflow, setNodes, setEdges]); + + const onConnect = useCallback( + (connection: Connection) => { + setEdges((eds) => + addEdge({ ...connection, label: "success", data: { condition: "success" } }, eds), + ); + }, + [setEdges], + ); + + const addNode = useCallback( + (kind: WorkflowEditorNodeKind) => { + const id = newNodeId(); + const label = kind === "merge" ? "Merge boundary" : kind.charAt(0).toUpperCase() + kind.slice(1); + setNodes((ns) => [ + ...ns, + { + id, + type: kind, + position: { x: 200 + ns.length * 40, y: 240 + (ns.length % 3) * 70 }, + data: { kind, label, config: kind === "gate" ? { gateMode: "gate" } : {} }, + deletable: true, + }, + ]); + setSelectedNodeId(id); + }, + [setNodes], + ); + + const updateSelectedData = useCallback( + (patch: Partial | { config: Record }) => { + if (!selectedNodeId) return; + setNodes((ns) => + ns.map((n) => + n.id === selectedNodeId + ? { + ...n, + data: { + ...n.data, + ...("config" in patch ? { config: { ...n.data.config, ...patch.config } } : patch), + }, + } + : n, + ), + ); + }, + [selectedNodeId, setNodes], + ); + + const handleCreateWorkflow = useCallback(async () => { + const name = window.prompt("New workflow name"); + if (!name?.trim()) return; + try { + const created = await createWorkflow( + { name: name.trim(), ir: emptyWorkflowIr(name.trim()), layout: emptyWorkflowLayout() }, + projectId, + ); + setWorkflows((ws) => [...ws, created]); + setActiveId(created.id); + addToast(`Created workflow "${created.name}"`, "success"); + } catch (err) { + addToast(getErrorMessage(err) || "Failed to create workflow", "error"); + } + }, [projectId, addToast]); + + const handleDeleteWorkflow = useCallback(async () => { + if (!activeWorkflow) return; + if (!window.confirm(`Delete workflow "${activeWorkflow.name}"?`)) return; + try { + await deleteWorkflow(activeWorkflow.id, projectId); + setWorkflows((ws) => ws.filter((w) => w.id !== activeWorkflow.id)); + setActiveId(null); + addToast("Workflow deleted", "success"); + } catch (err) { + addToast(getErrorMessage(err) || "Failed to delete workflow", "error"); + } + }, [activeWorkflow, projectId, addToast]); + + const handleSave = useCallback(async () => { + if (!activeWorkflow) return; + setSaving(true); + setValidationError(null); + try { + const { ir, layout } = flowToIr(activeWorkflow.name, nodes, edges); + const updated = await updateWorkflow(activeWorkflow.id, { ir, layout }, projectId); + setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w))); + // Validate by compiling — surfaces non-linear graphs as a banner. + try { + await compileWorkflow(updated.id, projectId); + addToast("Workflow saved", "success"); + } catch (compileErr) { + setValidationError(getErrorMessage(compileErr) || "Workflow saved but cannot be compiled"); + } + } catch (err) { + const message = getErrorMessage(err) || "Failed to save workflow"; + setValidationError(message); + addToast(message, "error"); + } finally { + setSaving(false); + } + }, [activeWorkflow, nodes, edges, projectId, addToast]); + + const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null; + const overlayProps = useOverlayDismiss(onClose); + + return ( +
+
e.stopPropagation()}> +
+

Workflows

+ +
+ +
+ + +
+ {activeWorkflow ? ( + <> +
+
+ {PALETTE.map(({ kind, label, icon: Icon }) => ( + + ))} +
+
+ + +
+
+ + {validationError && ( +
+ {validationError} +
+ )} + +
+ setSelectedNodeId(node.id)} + onPaneClick={() => setSelectedNodeId(null)} + fitView + > + + + + +
+ + ) : ( +
+ Select or create a workflow to start editing. +
+ )} +
+ + {selectedNode && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end" && ( +