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:
2026-07-21 19:09:54 +02:00
co-authored by Claude Sonnet 4.6
parent c51dd8cd67
commit d2e4934cca
63 changed files with 7035 additions and 2140 deletions
@@ -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],
)