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>
This commit is contained in:
@@ -71,6 +71,8 @@ export type WorkflowOrderLineContextOption = {
|
||||
value: string
|
||||
label: string
|
||||
meta: string
|
||||
isRenderable: boolean
|
||||
renderabilityReason: string | null
|
||||
}
|
||||
|
||||
export type WorkflowOrderLineContextGroup = {
|
||||
@@ -89,6 +91,8 @@ function normalizeOrderLineContextGroups(
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
meta: option.meta,
|
||||
isRenderable: option.is_renderable,
|
||||
renderabilityReason: option.renderability_reason,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
@@ -169,7 +173,13 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}, [dispatchContextId, graphFamily, isOrderLineGraph, selectedOrderLineContext])
|
||||
const dispatchContextMeta = useMemo(() => {
|
||||
if (isOrderLineGraph) return selectedOrderLineContext?.meta ?? null
|
||||
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
|
||||
@@ -242,6 +252,10 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setDispatchContextId('')
|
||||
}, [workflow.id])
|
||||
|
||||
useEffect(() => {
|
||||
const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep)
|
||||
const nextNodes = graphNeedsAutoLayout(graph.nodes) ? applyAutoLayout(graph.nodes, graph.edges) : graph.nodes
|
||||
@@ -260,7 +274,9 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
useEffect(() => {
|
||||
if (!isOrderLineGraph) return
|
||||
if (dispatchContextId.trim()) return
|
||||
const firstOption = orderLineContextGroups[0]?.options[0]
|
||||
const firstOption =
|
||||
orderLineContextGroups.flatMap(group => group.options).find(option => option.isRenderable) ??
|
||||
orderLineContextGroups[0]?.options[0]
|
||||
if (firstOption) {
|
||||
setDispatchContextId(firstOption.value)
|
||||
}
|
||||
@@ -330,24 +346,65 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
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 =>
|
||||
edge.source === connection.source &&
|
||||
edge.target === connection.target &&
|
||||
edge.sourceHandle === matchingSourcePort.id &&
|
||||
edge.targetHandle === matchingTargetPort.id,
|
||||
)
|
||||
if (duplicateEdge) {
|
||||
toast.error('A connection between these nodes already exists.')
|
||||
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,
|
||||
},
|
||||
currentEdges,
|
||||
)
|
||||
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],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user