mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-15 17:06:06 +00:00
enforce workflow data edge order
This commit is contained in:
@@ -116,6 +116,16 @@ class WorkflowService:
|
|||||||
|
|
||||||
workflow_uuid = str(uuid.uuid4())
|
workflow_uuid = str(uuid.uuid4())
|
||||||
|
|
||||||
|
definition = workflow_data.get(
|
||||||
|
'definition',
|
||||||
|
{
|
||||||
|
'nodes': [],
|
||||||
|
'edges': [],
|
||||||
|
'variables': {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._validate_workflow_definition(definition)
|
||||||
|
|
||||||
# Prepare workflow data
|
# Prepare workflow data
|
||||||
new_workflow = {
|
new_workflow = {
|
||||||
'uuid': workflow_uuid,
|
'uuid': workflow_uuid,
|
||||||
@@ -124,14 +134,7 @@ class WorkflowService:
|
|||||||
'emoji': workflow_data.get('emoji', '💼'),
|
'emoji': workflow_data.get('emoji', '💼'),
|
||||||
'version': 1,
|
'version': 1,
|
||||||
'is_enabled': workflow_data.get('is_enabled', True),
|
'is_enabled': workflow_data.get('is_enabled', True),
|
||||||
'definition': workflow_data.get(
|
'definition': definition,
|
||||||
'definition',
|
|
||||||
{
|
|
||||||
'nodes': [],
|
|
||||||
'edges': [],
|
|
||||||
'variables': {},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
'global_config': workflow_data.get('global_config', {}),
|
'global_config': workflow_data.get('global_config', {}),
|
||||||
'extensions_preferences': workflow_data.get(
|
'extensions_preferences': workflow_data.get(
|
||||||
'extensions_preferences',
|
'extensions_preferences',
|
||||||
@@ -190,6 +193,7 @@ class WorkflowService:
|
|||||||
|
|
||||||
# Increment version if definition changed
|
# Increment version if definition changed
|
||||||
if 'definition' in workflow_data:
|
if 'definition' in workflow_data:
|
||||||
|
self._validate_workflow_definition(workflow_data['definition'])
|
||||||
workflow_data['version'] = current.get('version', 0) + 1
|
workflow_data['version'] = current.get('version', 0) + 1
|
||||||
|
|
||||||
# Save version history
|
# Save version history
|
||||||
@@ -877,6 +881,79 @@ class WorkflowService:
|
|||||||
|
|
||||||
return result
|
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:
|
def _serialize_execution(self, execution) -> dict:
|
||||||
data = self.ap.persistence_mgr.serialize_model(
|
data = self.ap.persistence_mgr.serialize_model(
|
||||||
persistence_workflow.WorkflowExecution,
|
persistence_workflow.WorkflowExecution,
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import {
|
|||||||
SelectionMode,
|
SelectionMode,
|
||||||
MarkerType,
|
MarkerType,
|
||||||
} from '@xyflow/react';
|
} 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 '@xyflow/react/dist/style.css';
|
||||||
import {
|
import {
|
||||||
useWorkflowStore,
|
useWorkflowStore,
|
||||||
@@ -78,6 +83,7 @@ function WorkflowEditorInner() {
|
|||||||
onNodesChange,
|
onNodesChange,
|
||||||
onEdgesChange,
|
onEdgesChange,
|
||||||
onConnect,
|
onConnect,
|
||||||
|
isConnectionValid,
|
||||||
addNode,
|
addNode,
|
||||||
selectNode,
|
selectNode,
|
||||||
selectEdge,
|
selectEdge,
|
||||||
@@ -388,6 +394,17 @@ function WorkflowEditorInner() {
|
|||||||
return categoryColors[category] || '#6b7280';
|
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 displayNodes = nodes.map((node) => {
|
||||||
const executionResult = nodeExecutionResults[node.id];
|
const executionResult = nodeExecutionResults[node.id];
|
||||||
|
|
||||||
@@ -441,6 +458,7 @@ function WorkflowEditorInner() {
|
|||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
isValidConnection={handleIsValidConnection}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
onEdgeClick={handleEdgeClick}
|
onEdgeClick={handleEdgeClick}
|
||||||
onPaneClick={handlePaneClick}
|
onPaneClick={handlePaneClick}
|
||||||
@@ -452,8 +470,8 @@ function WorkflowEditorInner() {
|
|||||||
snapToGrid
|
snapToGrid
|
||||||
snapGrid={[15, 15]}
|
snapGrid={[15, 15]}
|
||||||
selectionMode={SelectionMode.Partial}
|
selectionMode={SelectionMode.Partial}
|
||||||
selectionOnDrag
|
selectionOnDrag={false}
|
||||||
panOnDrag={[2]} // Right click to pan (left click is for node selection)
|
panOnDrag
|
||||||
selectNodesOnDrag={false}
|
selectNodesOnDrag={false}
|
||||||
defaultEdgeOptions={{
|
defaultEdgeOptions={{
|
||||||
type: 'default',
|
type: 'default',
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ interface WorkflowState {
|
|||||||
onNodesChange: (changes: NodeChange<WorkflowNode>[]) => void;
|
onNodesChange: (changes: NodeChange<WorkflowNode>[]) => void;
|
||||||
onEdgesChange: (changes: EdgeChange<WorkflowEdge>[]) => void;
|
onEdgesChange: (changes: EdgeChange<WorkflowEdge>[]) => void;
|
||||||
onConnect: (connection: Connection) => void;
|
onConnect: (connection: Connection) => void;
|
||||||
|
isConnectionValid: (connection: Connection) => boolean;
|
||||||
|
|
||||||
addNode: (type: string, position: { x: number; y: number }) => string;
|
addNode: (type: string, position: { x: number; y: number }) => string;
|
||||||
updateNodeConfig: (nodeId: string, config: Record<string, unknown>) => void;
|
updateNodeConfig: (nodeId: string, config: Record<string, unknown>) => void;
|
||||||
@@ -206,6 +207,91 @@ const generateEdgeId = () => `edge_${generateUuidLikeId()}`;
|
|||||||
const getWorkflowEdgeType = (edge: WorkflowEdge): WorkflowEdgeType =>
|
const getWorkflowEdgeType = (edge: WorkflowEdge): WorkflowEdgeType =>
|
||||||
normalizeWorkflowEdgeType(edge.data?.edgeType);
|
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 withWorkflowEdgeStyle = (edge: WorkflowEdge): WorkflowEdge => {
|
||||||
const edgeType = getWorkflowEdgeType(edge);
|
const edgeType = getWorkflowEdgeType(edge);
|
||||||
|
|
||||||
@@ -327,6 +413,10 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onConnect: (connection) => {
|
onConnect: (connection) => {
|
||||||
|
if (!get().isConnectionValid(connection)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const edgeType = inferWorkflowEdgeType(
|
const edgeType = inferWorkflowEdgeType(
|
||||||
connection.sourceHandle,
|
connection.sourceHandle,
|
||||||
connection.targetHandle,
|
connection.targetHandle,
|
||||||
@@ -355,6 +445,10 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
|||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
isConnectionValid: (connection) => {
|
||||||
|
return isWorkflowConnectionValid(connection, get().edges);
|
||||||
|
},
|
||||||
|
|
||||||
// Add new node
|
// Add new node
|
||||||
addNode: (type, position) => {
|
addNode: (type, position) => {
|
||||||
const { nodeTypes } = get();
|
const { nodeTypes } = get();
|
||||||
|
|||||||
Reference in New Issue
Block a user