import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import { addEdge, applyNodeChanges, useEdgesState, useNodesState, type Connection, type Edge, type Node, type NodeChange, type ReactFlowInstance, } from '@xyflow/react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { dispatchWorkflowDraft, getWorkflowOrderLineContexts, getNodeDefinitions, getWorkflowRunComparison, getWorkflowRuns, preflightWorkflowDraft, type WorkflowConfig, type WorkflowDefinition, type WorkflowExecutionMode, type WorkflowNodeDefinition, type WorkflowOrderLineContextGroup as WorkflowOrderLineContextGroupApi, type WorkflowOrderLineContextOption as WorkflowOrderLineContextOptionApi, type WorkflowParams, type WorkflowPreflightResponse, } from '../../api/workflows' import { applyAutoLayout, buildWorkflowCanvasNodeData, buildCurrentWorkflowConfig, deriveWorkflowAuthoringFamily, findOpenNodePosition, graphNeedsAutoLayout, inferNodeLabel, inferNodeType, inferStepFromNodeType, normalizeWorkflowParams, resolveParamsForStepChange, resolveNodeCollisions, shouldAutoLayoutAfterInsert, type WorkflowCanvasNodeData, validateWorkflowDraft, workflowToGraph, } from './workflowGraphDraft' import { inferWorkflowFamily, } from './workflowBlueprints' import { GRAPH_FAMILY_LABELS, isDefinitionAllowedForGraphFamily, } from './workflowNodeLibrary' import { createWorkflowModuleBundleInsertion, type WorkflowModuleBundleId } from './workflowModuleBundles' import { createWorkflowReferenceBundleInsertion, type WorkflowReferenceBundleId, } from './workflowReferenceBundles' import type { WorkflowUtilityTab } from './WorkflowCanvasUtilitySidebar' export type NodeMenuAnchor = { clientX: number clientY: number flowPosition: { x: number; y: number } } export type WorkflowOrderLineContextOption = { value: string label: string meta: string isRenderable: boolean renderabilityReason: string | null } export type WorkflowOrderLineContextGroup = { orderId: string orderLabel: string options: WorkflowOrderLineContextOption[] } function normalizeOrderLineContextGroups( groups: WorkflowOrderLineContextGroupApi[], ): WorkflowOrderLineContextGroup[] { return groups.map(group => ({ orderId: group.order_id, orderLabel: group.order_label, options: group.options.map((option: WorkflowOrderLineContextOptionApi) => ({ value: option.value, label: option.label, meta: option.meta, isRenderable: option.is_renderable, renderabilityReason: option.renderability_reason, })), })) } type UseWorkflowCanvasControllerArgs = { workflow: WorkflowDefinition onSave: (config: WorkflowConfig) => void } export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCanvasControllerArgs) { const queryClient = useQueryClient() const { data: nodeDefinitionsData } = useQuery({ queryKey: ['workflow-node-definitions'], queryFn: getNodeDefinitions, staleTime: 5 * 60 * 1000, }) const nodeDefinitions = nodeDefinitionsData?.definitions ?? [] const nodeDefinitionsByStep = Object.fromEntries(nodeDefinitions.map(definition => [definition.step, definition])) const definitionsLoaded = nodeDefinitions.length > 0 const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config, nodeDefinitionsByStep) const [nodes, setNodes] = useNodesState(initNodes) const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges) const [selectedNodeId, setSelectedNodeId] = useState(null) const [selectedEdgeIds, setSelectedEdgeIds] = useState([]) const [selectedRunId, setSelectedRunId] = useState(null) const [dispatchContextId, setDispatchContextId] = useState('') const [preflightResult, setPreflightResult] = useState(null) const [lastSuccessfulPreflightFingerprint, setLastSuccessfulPreflightFingerprint] = useState(null) const [executionMode, setExecutionMode] = useState(workflow.config.ui?.execution_mode ?? 'legacy') const [nodeMenuAnchor, setNodeMenuAnchor] = useState(null) const [activeUtilityTab, setActiveUtilityTab] = useState('library') const reactFlowWrapper = useRef(null) const [reactFlowInstance, setReactFlowInstance] = useState | null>(null) const validation = validateWorkflowDraft(nodes, edges, nodeDefinitionsByStep, nodeDefinitions.length > 0) const authoringFamily = useMemo( () => deriveWorkflowAuthoringFamily(workflow, nodes, nodeDefinitionsByStep, definitionsLoaded), [definitionsLoaded, nodeDefinitionsByStep, nodes, workflow], ) const currentWorkflowConfig = useMemo( () => buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode, authoringFamily), [authoringFamily, edges, executionMode, nodes, workflow], ) const graphFamily = useMemo( () => inferWorkflowFamily(currentWorkflowConfig, nodeDefinitionsByStep), [currentWorkflowConfig, nodeDefinitionsByStep], ) const isOrderLineGraph = graphFamily === 'order_line' const { data: workflowOrderLineContexts = [], isFetching: isOrderLineContextsLoading } = useQuery({ queryKey: ['workflow-order-line-contexts'], queryFn: () => getWorkflowOrderLineContexts(50), enabled: isOrderLineGraph, staleTime: 30_000, }) const orderLineContextGroups = useMemo( () => normalizeOrderLineContextGroups(workflowOrderLineContexts).filter(group => group.options.length > 0), [workflowOrderLineContexts], ) const selectedOrderLineContext = useMemo( () => orderLineContextGroups .flatMap(group => group.options) .find(option => option.value === dispatchContextId) ?? null, [dispatchContextId, orderLineContextGroups], ) const dispatchContextLabel = useMemo(() => { if (isOrderLineGraph) return 'Order Line' if (graphFamily === 'cad_file') return 'CAD File' return 'Context' }, [graphFamily, isOrderLineGraph]) const dispatchContextSummary = useMemo(() => { if (isOrderLineGraph) return selectedOrderLineContext?.label ?? null const trimmed = dispatchContextId.trim() if (graphFamily === 'cad_file' && trimmed.length > 0) return 'CAD File' return trimmed.length > 0 ? trimmed : null }, [dispatchContextId, graphFamily, isOrderLineGraph, selectedOrderLineContext]) const dispatchContextMeta = useMemo(() => { if (isOrderLineGraph) { if (!selectedOrderLineContext) return null if (!selectedOrderLineContext.isRenderable && selectedOrderLineContext.renderabilityReason) { return `${selectedOrderLineContext.meta} · blocked: ${selectedOrderLineContext.renderabilityReason}` } return selectedOrderLineContext.meta } if (graphFamily !== 'cad_file') return null const trimmed = dispatchContextId.trim() if (!trimmed) return null if (preflightResult?.context_id === trimmed && preflightResult.resolved_cad_file_id) { return `${preflightResult.resolved_cad_file_id} · validated` } return trimmed }, [dispatchContextId, graphFamily, isOrderLineGraph, preflightResult, selectedOrderLineContext]) const currentDispatchFingerprint = useMemo( () => JSON.stringify({ contextId: dispatchContextId.trim(), config: currentWorkflowConfig }), [currentWorkflowConfig, dispatchContextId], ) const hasFreshSuccessfulPreflight = preflightResult?.graph_dispatch_allowed === true && lastSuccessfulPreflightFingerprint === currentDispatchFingerprint const { data: workflowRuns = [] } = useQuery({ queryKey: ['workflow-runs', workflow.id], queryFn: () => getWorkflowRuns(workflow.id), refetchInterval: 5000, }) const selectedRun = workflowRuns.find(run => run.id === selectedRunId) ?? workflowRuns[0] ?? null const { data: selectedRunComparison, isFetching: isComparisonLoading } = useQuery({ queryKey: ['workflow-run-comparison', selectedRun?.id], queryFn: () => getWorkflowRunComparison(selectedRun!.id), enabled: Boolean(selectedRun?.id && selectedRun.execution_mode === 'shadow'), refetchInterval: selectedRun?.status === 'pending' || selectedRun?.status === 'running' ? 5000 : false, }) const dispatchMutation = useMutation({ mutationFn: ({ contextId, config }: { contextId: string; config: WorkflowConfig }) => dispatchWorkflowDraft({ workflow_id: workflow.id, context_id: contextId, config, }), onSuccess: result => { queryClient.invalidateQueries({ queryKey: ['workflow-runs', workflow.id] }) setSelectedRunId(result.workflow_run.id) toast.success(`Graph run dispatched: ${result.dispatched} task${result.dispatched === 1 ? '' : 's'}`) }, onError: (error: any) => { toast.error(error?.response?.data?.detail || 'Failed to dispatch workflow') }, }) const preflightMutation = useMutation({ mutationFn: ({ contextId, config }: { contextId: string; config: WorkflowConfig }) => preflightWorkflowDraft({ workflow_id: workflow.id, context_id: contextId, config, }), onSuccess: (result, variables) => { setPreflightResult(result) if (result.graph_dispatch_allowed) { setLastSuccessfulPreflightFingerprint(JSON.stringify({ contextId: variables.contextId, config: variables.config })) toast.success(result.summary) } else { setLastSuccessfulPreflightFingerprint(null) toast.error(result.summary) } }, onError: (error: any) => { setPreflightResult(null) setLastSuccessfulPreflightFingerprint(null) toast.error(error?.response?.data?.detail || 'Failed to preflight workflow') }, }) useEffect(() => { setDispatchContextId('') }, [workflow.id]) useEffect(() => { const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep) const nextNodes = graphNeedsAutoLayout(graph.nodes) ? applyAutoLayout(graph.nodes, graph.edges) : graph.nodes setNodes(nextNodes) setEdges(graph.edges) setSelectedNodeId(null) setSelectedEdgeIds([]) setSelectedRunId(null) setNodeMenuAnchor(null) setPreflightResult(null) setLastSuccessfulPreflightFingerprint(null) setExecutionMode(workflow.config.ui?.execution_mode ?? 'legacy') setActiveUtilityTab('library') }, [nodeDefinitionsData, setEdges, setNodes, workflow.config]) useEffect(() => { if (!isOrderLineGraph) return if (dispatchContextId.trim()) return const firstOption = orderLineContextGroups.flatMap(group => group.options).find(option => option.isRenderable) ?? orderLineContextGroups[0]?.options[0] if (firstOption) { setDispatchContextId(firstOption.value) } }, [dispatchContextId, isOrderLineGraph, orderLineContextGroups]) useEffect(() => { if (!selectedRunId && workflowRuns.length > 0) { setSelectedRunId(workflowRuns[0].id) return } if (selectedRunId && !workflowRuns.some(run => run.id === selectedRunId)) { setSelectedRunId(workflowRuns[0]?.id ?? null) } }, [selectedRunId, workflowRuns]) const onConnect = useCallback( (connection: Connection) => { if (!connection.source || !connection.target) return const sourceNode = nodes.find(node => node.id === connection.source) const targetNode = nodes.find(node => node.id === connection.target) const sourceData = sourceNode?.data as WorkflowCanvasNodeData | undefined const targetData = targetNode?.data as WorkflowCanvasNodeData | undefined const sourcePorts = sourceData?.outputPorts ?? [] const targetPorts = targetData?.inputPorts ?? [] if (sourcePorts.length === 0) { toast.error('Selected source node does not expose any downstream outputs.') return } if (targetPorts.length === 0) { toast.error('Selected target node does not declare any upstream inputs.') return } const requestedTargetPort = connection.targetHandle ? targetPorts.find(port => port.id === connection.targetHandle) : undefined const requestedSourcePort = connection.sourceHandle ? sourcePorts.find(port => port.id === connection.sourceHandle) : undefined const matchingTargetPort = requestedTargetPort && sourcePorts.some(sourcePort => requestedTargetPort.roles.some(role => sourcePort.roles.includes(role)), ) ? requestedTargetPort : targetPorts.find(port => sourcePorts.some(sourcePort => port.roles.some(role => sourcePort.roles.includes(role))), ) if (!matchingTargetPort) { toast.error('These nodes do not share a compatible input/output contract.') return } const matchingSourcePort = requestedSourcePort && matchingTargetPort.roles.some(role => requestedSourcePort.roles.includes(role)) ? requestedSourcePort : sourcePorts.find(port => matchingTargetPort.roles.some(role => port.roles.includes(role))) if (!matchingSourcePort) { toast.error('The selected source handle does not satisfy the target input contract.') return } const nextEdgeId = `e_${connection.source}_${connection.target}_${matchingTargetPort.id}` let replacedEdgeLabel: string | null = null let rejectedDuplicate = false setEdges(currentEdges => { const duplicateEdge = currentEdges.some( edge => edge.source === connection.source && edge.target === connection.target && edge.sourceHandle === matchingSourcePort.id && edge.targetHandle === matchingTargetPort.id, ) if (duplicateEdge) { rejectedDuplicate = true return currentEdges } const conflictingTargetEdge = currentEdges.find( edge => edge.target === connection.target && edge.targetHandle === matchingTargetPort.id && edge.id !== nextEdgeId, ) if (conflictingTargetEdge) { replacedEdgeLabel = matchingTargetPort.label } const dedupedEdges = currentEdges.filter( edge => !(edge.source === connection.source && edge.target === connection.target) && !(conflictingTargetEdge && edge.id === conflictingTargetEdge.id), ) return addEdge( { ...connection, id: nextEdgeId, sourceHandle: matchingSourcePort.id, targetHandle: matchingTargetPort.id, }, dedupedEdges, ).map(edge => ({ ...edge, selected: edge.id === nextEdgeId, })) }) if (rejectedDuplicate) { toast.error('This connection is already present.') return } setSelectedNodeId(null) setSelectedEdgeIds([nextEdgeId]) setNodeMenuAnchor(null) if (replacedEdgeLabel) { toast.success(`Reassigned ${replacedEdgeLabel} to the new upstream node.`) } }, [nodes, setEdges], ) const onNodesChange = useCallback( (changes: NodeChange[]) => { setNodes(currentNodes => { const nextNodes = applyNodeChanges(changes, currentNodes) const settledNodeIds = changes .filter((change): change is Extract => change.type === 'position') .filter(change => change.dragging !== true) .map(change => change.id) if (settledNodeIds.length === 0) { return nextNodes } return resolveNodeCollisions(nextNodes, settledNodeIds) }) }, [setNodes], ) const onNodeClick = useCallback((_: ReactMouseEvent, node: Node) => { setNodeMenuAnchor(null) setSelectedEdgeIds([]) setSelectedNodeId(node.id) setActiveUtilityTab('inspector') }, []) const onEdgeClick = useCallback((_: ReactMouseEvent, edge: Edge) => { setNodeMenuAnchor(null) setSelectedEdgeIds([edge.id]) setSelectedNodeId(null) setEdges(currentEdges => currentEdges.map(currentEdge => ({ ...currentEdge, selected: currentEdge.id === edge.id, })), ) }, [setEdges]) const onPaneClick = useCallback(() => { setNodeMenuAnchor(null) setSelectedEdgeIds([]) setSelectedNodeId(null) setEdges(currentEdges => currentEdges.map(edge => ({ ...edge, selected: false, })), ) }, [setEdges]) const handleParamsChange = useCallback( (newParams: WorkflowParams) => { setNodes(currentNodes => currentNodes.map(node => { if (node.id === selectedNodeId) { return { ...node, data: { ...node.data, params: normalizeWorkflowParams(newParams) } } } return node }), ) }, [selectedNodeId, setNodes], ) const handlePipelineStepChange = useCallback( (stepName: string) => { const definition = nodeDefinitionsByStep[stepName] if (definition && !isDefinitionAllowedForGraphFamily(definition, authoringFamily)) { toast.error(`${definition.label} does not belong to the ${GRAPH_FAMILY_LABELS[authoringFamily]} authoring family.`) return } setNodes(currentNodes => currentNodes.map(node => { if (node.id !== selectedNodeId) return node const currentData = (node.data as WorkflowCanvasNodeData | undefined) ?? buildWorkflowCanvasNodeData(stepName) return { ...node, type: definition?.node_type ?? inferNodeType(stepName), data: { ...buildWorkflowCanvasNodeData( stepName || inferStepFromNodeType(node.type), resolveParamsForStepChange(definition, currentData.params), definition, ), step: stepName || inferStepFromNodeType(node.type), }, } }), ) }, [authoringFamily, nodeDefinitionsByStep, selectedNodeId, setNodes], ) const openNodeMenu = useCallback( (clientX: number, clientY: number) => { if (!reactFlowInstance) return setNodeMenuAnchor({ clientX, clientY, flowPosition: reactFlowInstance.screenToFlowPosition({ x: clientX, y: clientY }), }) }, [reactFlowInstance], ) const handlePaneContextMenu = useCallback( (event: MouseEvent | ReactMouseEvent) => { event.preventDefault() setSelectedNodeId(null) openNodeMenu(event.clientX, event.clientY) }, [openNodeMenu], ) const handleNodeContextMenu = useCallback( (event: ReactMouseEvent, node: Node) => { event.preventDefault() setSelectedNodeId(node.id) openNodeMenu(event.clientX, event.clientY) }, [openNodeMenu], ) const insertNode = useCallback( (step: string, preferredPosition?: { x: number; y: number }) => { const definition = nodeDefinitionsByStep[step] if (definition && !isDefinitionAllowedForGraphFamily(definition, authoringFamily)) { toast.error(`${definition.label} cannot be added to a ${GRAPH_FAMILY_LABELS[authoringFamily]} workflow.`) return } const type = definition?.node_type ?? inferNodeType(step) const fallbackX = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.x)) + 312 : 120 const fallbackY = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.y)) + 140 : 120 const position = findOpenNodePosition(nodes, preferredPosition ?? { x: fallbackX, y: fallbackY }) const newNode: Node = { id: `${step}_${Date.now()}`, type, position, data: buildWorkflowCanvasNodeData(step, definition?.defaults ?? {}, definition), } const nextNodes = [...nodes, newNode] const shouldAutoLayout = shouldAutoLayoutAfterInsert(nextNodes, newNode, preferredPosition ?? null) const laidOutNodes = shouldAutoLayout ? applyAutoLayout(nextNodes, edges) : nextNodes setNodes(laidOutNodes) setSelectedNodeId(newNode.id) setNodeMenuAnchor(null) setActiveUtilityTab('inspector') if (shouldAutoLayout) { window.requestAnimationFrame(() => { reactFlowInstance?.fitView({ padding: 0.2, duration: 220 }) }) } }, [authoringFamily, edges, nodeDefinitionsByStep, nodes, reactFlowInstance, setNodes], ) const insertModuleBundle = useCallback( (bundleId: WorkflowModuleBundleId, preferredPosition?: { x: number; y: number }) => { const insertion = createWorkflowModuleBundleInsertion({ bundleId, graphFamily: authoringFamily, nodeDefinitionsByStep, existingNodes: nodes, preferredPosition, }) if (!insertion.ok) { toast.error(insertion.reason) return } const combinedNodes = [...nodes, ...insertion.nodes] const combinedEdges = [...edges, ...insertion.edges] setNodes(graphNeedsAutoLayout(combinedNodes) ? applyAutoLayout(combinedNodes, combinedEdges) : combinedNodes) setEdges(combinedEdges) setSelectedNodeId(insertion.nodes[0]?.id ?? null) setNodeMenuAnchor(null) setActiveUtilityTab('inspector') toast.success(`${insertion.bundle.label} inserted`) }, [authoringFamily, edges, nodeDefinitionsByStep, nodes, setEdges, setNodes], ) const insertReferenceBundle = useCallback( (bundleId: WorkflowReferenceBundleId, preferredPosition?: { x: number; y: number }) => { const insertion = createWorkflowReferenceBundleInsertion({ bundleId, graphFamily: authoringFamily, nodeDefinitionsByStep, existingNodes: nodes, preferredPosition, }) if (!insertion.ok) { toast.error(insertion.reason) return } const combinedNodes = [...nodes, ...insertion.nodes] const combinedEdges = [...edges, ...insertion.edges] setNodes(graphNeedsAutoLayout(combinedNodes) ? applyAutoLayout(combinedNodes, combinedEdges) : combinedNodes) setEdges(combinedEdges) setSelectedNodeId(insertion.nodes[0]?.id ?? null) setNodeMenuAnchor(null) setActiveUtilityTab('inspector') toast.success(`${insertion.bundle.label} inserted`) }, [authoringFamily, edges, nodeDefinitionsByStep, nodes, setEdges, setNodes], ) const handleOpenToolbarNodeMenu = useCallback(() => { if (!reactFlowWrapper.current || !reactFlowInstance) return const bounds = reactFlowWrapper.current.getBoundingClientRect() openNodeMenu(bounds.left + 36, bounds.top + 36) }, [openNodeMenu, reactFlowInstance]) const handleAutoLayout = useCallback(() => { setNodes(currentNodes => applyAutoLayout(currentNodes, edges)) setNodeMenuAnchor(null) window.requestAnimationFrame(() => { reactFlowInstance?.fitView({ padding: 0.2, duration: 250 }) }) }, [edges, reactFlowInstance, setNodes]) const deleteEdgesById = useCallback((edgeIds: string[]) => { if (edgeIds.length === 0) return setEdges(currentEdges => currentEdges.filter(edge => !edgeIds.includes(edge.id))) setSelectedEdgeIds(currentIds => currentIds.filter(edgeId => !edgeIds.includes(edgeId))) setSelectedNodeId(null) setNodeMenuAnchor(null) toast.success(edgeIds.length === 1 ? 'Connection deleted' : `${edgeIds.length} connections deleted`) }, [setEdges]) const handleDeleteSelectedEdges = useCallback(() => { deleteEdgesById(selectedEdgeIds) }, [deleteEdgesById, selectedEdgeIds]) const onEdgeContextMenu = useCallback((event: ReactMouseEvent, edge: Edge) => { event.preventDefault() event.stopPropagation() deleteEdgesById([edge.id]) }, [deleteEdgesById]) const onEdgeDoubleClick = useCallback((event: ReactMouseEvent, edge: Edge) => { event.preventDefault() event.stopPropagation() deleteEdgesById([edge.id]) }, [deleteEdgesById]) const handleSelectionChange = useCallback( ({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => { if (selectedNodes.length > 0) { setSelectedNodeId(selectedNodes[0].id) setSelectedEdgeIds(selectedEdges.map(edge => edge.id)) setActiveUtilityTab('inspector') return } if (selectedEdges.length > 0) { setSelectedNodeId(null) setSelectedEdgeIds(selectedEdges.map(edge => edge.id)) return } setSelectedNodeId(null) setSelectedEdgeIds([]) }, [], ) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null const isEditingField = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || Boolean(target?.closest('[contenteditable="true"]')) if (isEditingField) return if ((event.key === 'Delete' || event.key === 'Backspace') && selectedEdgeIds.length > 0) { event.preventDefault() deleteEdgesById(selectedEdgeIds) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [deleteEdgesById, selectedEdgeIds]) useEffect(() => { if (selectedNodeId) { setActiveUtilityTab('inspector') return } if (activeUtilityTab === 'inspector') { setActiveUtilityTab('library') } }, [activeUtilityTab, selectedNodeId]) const handleSave = useCallback(() => { if (validation.errors.length > 0) { toast.error('Resolve workflow validation errors before saving.') return } onSave(currentWorkflowConfig) }, [currentWorkflowConfig, onSave, validation.errors.length]) const handleDispatch = useCallback(() => { if (!dispatchContextId.trim()) { toast.error('Context ID is required for a graph test run.') return } if (validation.errors.length > 0) { toast.error('Resolve workflow validation errors before dispatching.') return } if (!hasFreshSuccessfulPreflight) { toast.error('Run a fresh Dry Run for the current graph and context before dispatching.') return } dispatchMutation.mutate({ contextId: dispatchContextId.trim(), config: currentWorkflowConfig, }) }, [currentWorkflowConfig, dispatchContextId, dispatchMutation, hasFreshSuccessfulPreflight, validation.errors.length]) const handlePreflight = useCallback(() => { if (!dispatchContextId.trim()) { toast.error('Context ID is required for a graph preflight.') return } if (validation.errors.length > 0) { toast.error('Resolve workflow validation errors before running preflight.') return } preflightMutation.mutate({ contextId: dispatchContextId.trim(), config: currentWorkflowConfig, }) }, [currentWorkflowConfig, dispatchContextId, preflightMutation, validation.errors.length]) const selectedNode = useMemo( () => nodes.find(node => node.id === selectedNodeId), [nodes, selectedNodeId], ) const preflightState = useMemo<'ready' | 'required' | 'stale' | 'blocked'>(() => { if (!dispatchContextId.trim()) return 'required' if (preflightResult && !preflightResult.graph_dispatch_allowed) return 'blocked' if (hasFreshSuccessfulPreflight) return 'ready' if (preflightResult) return 'stale' return 'required' }, [dispatchContextId, hasFreshSuccessfulPreflight, preflightResult]) return { reactFlowWrapper, nodeDefinitions, nodeDefinitionsByStep, nodes, edges, onNodesChange, onEdgesChange, selectedEdgeIds, selectedNode, workflowRuns, selectedRun, selectedRunComparison, isComparisonLoading, dispatchMutation, preflightMutation, dispatchContextId, setDispatchContextId, isOrderLineGraph, isOrderLineContextsLoading, orderLineContextGroups, dispatchContextLabel, dispatchContextSummary, dispatchContextMeta, preflightResult, preflightState, hasFreshSuccessfulPreflight, executionMode, setExecutionMode, nodeMenuAnchor, setNodeMenuAnchor, activeUtilityTab, setActiveUtilityTab, validation, authoringFamily, graphFamily, onConnect, onNodeClick, onEdgeClick, onPaneClick, handleParamsChange, handlePipelineStepChange, handlePaneContextMenu, handleNodeContextMenu, insertNode, insertModuleBundle, insertReferenceBundle, handleOpenToolbarNodeMenu, handleAutoLayout, handleDeleteSelectedEdges, onEdgeContextMenu, onEdgeDoubleClick, handleSelectionChange, handleSave, handleDispatch, handlePreflight, setReactFlowInstance, setSelectedRunId, } }