mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-14 16:36:07 +00:00
enforce workflow data edge order
This commit is contained in:
@@ -116,6 +116,16 @@ class WorkflowService:
|
||||
|
||||
workflow_uuid = str(uuid.uuid4())
|
||||
|
||||
definition = workflow_data.get(
|
||||
'definition',
|
||||
{
|
||||
'nodes': [],
|
||||
'edges': [],
|
||||
'variables': {},
|
||||
},
|
||||
)
|
||||
self._validate_workflow_definition(definition)
|
||||
|
||||
# Prepare workflow data
|
||||
new_workflow = {
|
||||
'uuid': workflow_uuid,
|
||||
@@ -124,14 +134,7 @@ class WorkflowService:
|
||||
'emoji': workflow_data.get('emoji', '💼'),
|
||||
'version': 1,
|
||||
'is_enabled': workflow_data.get('is_enabled', True),
|
||||
'definition': workflow_data.get(
|
||||
'definition',
|
||||
{
|
||||
'nodes': [],
|
||||
'edges': [],
|
||||
'variables': {},
|
||||
},
|
||||
),
|
||||
'definition': definition,
|
||||
'global_config': workflow_data.get('global_config', {}),
|
||||
'extensions_preferences': workflow_data.get(
|
||||
'extensions_preferences',
|
||||
@@ -190,6 +193,7 @@ class WorkflowService:
|
||||
|
||||
# Increment version if definition changed
|
||||
if 'definition' in workflow_data:
|
||||
self._validate_workflow_definition(workflow_data['definition'])
|
||||
workflow_data['version'] = current.get('version', 0) + 1
|
||||
|
||||
# Save version history
|
||||
@@ -877,6 +881,79 @@ class WorkflowService:
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _definition_edge_type(edge: dict) -> str:
|
||||
edge_type = edge.get('edge_type')
|
||||
if not edge_type:
|
||||
edge_type = (edge.get('data', {}) or {}).get('edgeType')
|
||||
if edge_type in {'control', 'data', 'legacy'}:
|
||||
return edge_type
|
||||
return 'legacy'
|
||||
|
||||
@staticmethod
|
||||
def _definition_edge_source(edge: dict) -> str:
|
||||
return edge.get('source_node', '') or edge.get('source', '')
|
||||
|
||||
@staticmethod
|
||||
def _definition_edge_target(edge: dict) -> str:
|
||||
return edge.get('target_node', '') or edge.get('target', '')
|
||||
|
||||
@classmethod
|
||||
def _definition_has_control_path(
|
||||
cls,
|
||||
source_node: str,
|
||||
target_node: str,
|
||||
edges: list[dict],
|
||||
) -> bool:
|
||||
if not source_node or not target_node or source_node == target_node:
|
||||
return False
|
||||
|
||||
adjacency: dict[str, list[str]] = {}
|
||||
for edge in edges:
|
||||
if cls._definition_edge_type(edge) not in {'control', 'legacy'}:
|
||||
continue
|
||||
source = cls._definition_edge_source(edge)
|
||||
target = cls._definition_edge_target(edge)
|
||||
if not source or not target:
|
||||
continue
|
||||
adjacency.setdefault(source, []).append(target)
|
||||
|
||||
visited: set[str] = set()
|
||||
stack = list(adjacency.get(source_node, []))
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if current in visited:
|
||||
continue
|
||||
if current == target_node:
|
||||
return True
|
||||
|
||||
visited.add(current)
|
||||
stack.extend(adjacency.get(current, []))
|
||||
|
||||
return False
|
||||
|
||||
def _validate_workflow_definition(self, definition: dict) -> None:
|
||||
if not isinstance(definition, dict):
|
||||
raise ValueError('Workflow definition must be an object')
|
||||
|
||||
edges = definition.get('edges', [])
|
||||
if not isinstance(edges, list):
|
||||
raise ValueError('Workflow definition edges must be a list')
|
||||
|
||||
for edge in edges:
|
||||
if not isinstance(edge, dict):
|
||||
continue
|
||||
if self._definition_edge_type(edge) != 'data':
|
||||
continue
|
||||
|
||||
source = self._definition_edge_source(edge)
|
||||
target = self._definition_edge_target(edge)
|
||||
if not self._definition_has_control_path(source, target, edges):
|
||||
raise ValueError(
|
||||
'Invalid data edge: data outputs can only connect from a node '
|
||||
'that appears earlier in the control flow to a later node input'
|
||||
)
|
||||
|
||||
def _serialize_execution(self, execution) -> dict:
|
||||
data = self.ap.persistence_mgr.serialize_model(
|
||||
persistence_workflow.WorkflowExecution,
|
||||
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
SelectionMode,
|
||||
MarkerType,
|
||||
} from '@xyflow/react';
|
||||
import type { Node, NodeTypes, OnSelectionChangeParams } from '@xyflow/react';
|
||||
import type {
|
||||
Connection,
|
||||
Node,
|
||||
NodeTypes,
|
||||
OnSelectionChangeParams,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import {
|
||||
useWorkflowStore,
|
||||
@@ -78,6 +83,7 @@ function WorkflowEditorInner() {
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
isConnectionValid,
|
||||
addNode,
|
||||
selectNode,
|
||||
selectEdge,
|
||||
@@ -388,6 +394,17 @@ function WorkflowEditorInner() {
|
||||
return categoryColors[category] || '#6b7280';
|
||||
}, []);
|
||||
|
||||
const handleIsValidConnection = useCallback(
|
||||
(connection: Connection | WorkflowEdge) =>
|
||||
isConnectionValid({
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
sourceHandle: connection.sourceHandle ?? null,
|
||||
targetHandle: connection.targetHandle ?? null,
|
||||
}),
|
||||
[isConnectionValid],
|
||||
);
|
||||
|
||||
const displayNodes = nodes.map((node) => {
|
||||
const executionResult = nodeExecutionResults[node.id];
|
||||
|
||||
@@ -441,6 +458,7 @@ function WorkflowEditorInner() {
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
isValidConnection={handleIsValidConnection}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
@@ -452,8 +470,8 @@ function WorkflowEditorInner() {
|
||||
snapToGrid
|
||||
snapGrid={[15, 15]}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
selectionOnDrag
|
||||
panOnDrag={[2]} // Right click to pan (left click is for node selection)
|
||||
selectionOnDrag={false}
|
||||
panOnDrag
|
||||
selectNodesOnDrag={false}
|
||||
defaultEdgeOptions={{
|
||||
type: 'default',
|
||||
|
||||
@@ -128,6 +128,7 @@ interface WorkflowState {
|
||||
onNodesChange: (changes: NodeChange<WorkflowNode>[]) => void;
|
||||
onEdgesChange: (changes: EdgeChange<WorkflowEdge>[]) => void;
|
||||
onConnect: (connection: Connection) => void;
|
||||
isConnectionValid: (connection: Connection) => boolean;
|
||||
|
||||
addNode: (type: string, position: { x: number; y: number }) => string;
|
||||
updateNodeConfig: (nodeId: string, config: Record<string, unknown>) => void;
|
||||
@@ -206,6 +207,91 @@ const generateEdgeId = () => `edge_${generateUuidLikeId()}`;
|
||||
const getWorkflowEdgeType = (edge: WorkflowEdge): WorkflowEdgeType =>
|
||||
normalizeWorkflowEdgeType(edge.data?.edgeType);
|
||||
|
||||
const isControlOrderEdge = (edge: WorkflowEdge): boolean => {
|
||||
const edgeType = getWorkflowEdgeType(edge);
|
||||
return edgeType === 'control' || edgeType === 'legacy';
|
||||
};
|
||||
|
||||
const hasForwardControlPath = (
|
||||
sourceNodeId: string,
|
||||
targetNodeId: string,
|
||||
edges: WorkflowEdge[],
|
||||
): boolean => {
|
||||
if (!sourceNodeId || !targetNodeId || sourceNodeId === targetNodeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const adjacency = new Map<string, string[]>();
|
||||
edges.forEach((edge) => {
|
||||
if (!isControlOrderEdge(edge)) {
|
||||
return;
|
||||
}
|
||||
if (!edge.source || !edge.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = adjacency.get(edge.source) || [];
|
||||
targets.push(edge.target);
|
||||
adjacency.set(edge.source, targets);
|
||||
});
|
||||
|
||||
const visited = new Set<string>();
|
||||
const stack = [...(adjacency.get(sourceNodeId) || [])];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current || visited.has(current)) {
|
||||
continue;
|
||||
}
|
||||
if (current === targetNodeId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
visited.add(current);
|
||||
stack.push(...(adjacency.get(current) || []));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isWorkflowConnectionValid = (
|
||||
connection: Connection,
|
||||
edges: WorkflowEdge[],
|
||||
): boolean => {
|
||||
if (!connection.source || !connection.target) {
|
||||
return false;
|
||||
}
|
||||
if (connection.source === connection.target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const edgeType = inferWorkflowEdgeType(
|
||||
connection.sourceHandle,
|
||||
connection.targetHandle,
|
||||
);
|
||||
if (!edgeType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const duplicateEdge = edges.some(
|
||||
(edge) =>
|
||||
edge.source === connection.source &&
|
||||
edge.target === connection.target &&
|
||||
(edge.sourceHandle || null) === (connection.sourceHandle || null) &&
|
||||
(edge.targetHandle || null) === (connection.targetHandle || null) &&
|
||||
getWorkflowEdgeType(edge) === edgeType,
|
||||
);
|
||||
if (duplicateEdge) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (edgeType === 'data') {
|
||||
return hasForwardControlPath(connection.source, connection.target, edges);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const withWorkflowEdgeStyle = (edge: WorkflowEdge): WorkflowEdge => {
|
||||
const edgeType = getWorkflowEdgeType(edge);
|
||||
|
||||
@@ -327,6 +413,10 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
||||
},
|
||||
|
||||
onConnect: (connection) => {
|
||||
if (!get().isConnectionValid(connection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const edgeType = inferWorkflowEdgeType(
|
||||
connection.sourceHandle,
|
||||
connection.targetHandle,
|
||||
@@ -355,6 +445,10 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
||||
get().pushHistory();
|
||||
},
|
||||
|
||||
isConnectionValid: (connection) => {
|
||||
return isWorkflowConnectionValid(connection, get().edges);
|
||||
},
|
||||
|
||||
// Add new node
|
||||
addNode: (type, position) => {
|
||||
const { nodeTypes } = get();
|
||||
|
||||
Reference in New Issue
Block a user