Files
HartOMat/frontend/src/components/workflows/useWorkflowCanvasController.ts
T
HartmutandClaude Sonnet 4.6 d2e4934cca feat: workflow graph system — blocks 1-20 complete checkpoint
Full graph-based workflow execution engine with:
- WorkflowGraphRuntime with two-phase dispatch (collect → fire Celery tasks)
- Node registry with contract validation, socket types, execution kinds
- WorkflowRuntimeServices: preflight, context resolution, shadow-mode A/B
- WorkflowRouter: CRUD + dispatch/preflight/run-history API endpoints
- OutputTypeContracts: workflow binding, rollout-mode resolution
- Admin router: output-type workflow binding endpoints
- Frontend: complete workflow editor with drag-drop canvas, node inspector,
  module bundles, reference bundles, preflight panel, validation banner,
  authoring guidance, blueprint templates, shadow/rollout gate UI
- Tests: comprehensive coverage for all workflow modules
- Docs: NODE_CONTRACT_AUDIT and VALIDATION_ERROR_INVENTORY

All 20 blocks from NEXT_20_BLOCK_BATCH_PLAN completed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 19:09:54 +02:00

831 lines
28 KiB
TypeScript

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<string | null>(null)
const [selectedEdgeIds, setSelectedEdgeIds] = useState<string[]>([])
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
const [dispatchContextId, setDispatchContextId] = useState('')
const [preflightResult, setPreflightResult] = useState<WorkflowPreflightResponse | null>(null)
const [lastSuccessfulPreflightFingerprint, setLastSuccessfulPreflightFingerprint] = useState<string | null>(null)
const [executionMode, setExecutionMode] = useState<WorkflowExecutionMode>(workflow.config.ui?.execution_mode ?? 'legacy')
const [nodeMenuAnchor, setNodeMenuAnchor] = useState<NodeMenuAnchor | null>(null)
const [activeUtilityTab, setActiveUtilityTab] = useState<WorkflowUtilityTab>('library')
const reactFlowWrapper = useRef<HTMLDivElement>(null)
const [reactFlowInstance, setReactFlowInstance] = useState<ReactFlowInstance<Node, Edge> | 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<WorkflowOrderLineContextGroup[]>(
() => 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<NodeChange, { type: 'position' }> => 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,
}
}