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>
831 lines
28 KiB
TypeScript
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,
|
|
}
|
|
}
|