chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
This commit is contained in:
Executable
+218
@@ -0,0 +1,218 @@
|
||||
"""Cycle detection and management for workflow graphs."""
|
||||
|
||||
from typing import Dict, List, Set, Optional, Any
|
||||
from dataclasses import dataclass, field
|
||||
from entity.configs import Node
|
||||
|
||||
|
||||
@dataclass
|
||||
class CycleInfo:
|
||||
"""Information about a detected cycle in the workflow graph."""
|
||||
cycle_id: str
|
||||
nodes: Set[str] # Node IDs in the cycle
|
||||
entry_nodes: Set[str] # Nodes that can enter the cycle (kept for compatibility)
|
||||
exit_edges: List[Dict[str, Any]] # Edges that can exit the cycle
|
||||
iteration_count: int = 0
|
||||
max_iterations: Optional[int] = None # Safety limit
|
||||
is_active: bool = False
|
||||
execution_state: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# New fields for refactored cycle execution
|
||||
initial_node: Optional[str] = None # The unique initial node when first entering the cycle
|
||||
configured_entry_node: Optional[str] = None # User-configured entry node (if any)
|
||||
max_iterations_default: int = 100 # Default maximum iterations if max_iterations is None
|
||||
|
||||
def add_node(self, node_id: str) -> None:
|
||||
"""Add a node to the cycle."""
|
||||
self.nodes.add(node_id)
|
||||
|
||||
def add_entry_node(self, node_id: str) -> None:
|
||||
"""Add an entry node to the cycle."""
|
||||
self.entry_nodes.add(node_id)
|
||||
|
||||
def add_exit_edge(self, edge_config: Dict[str, Any]) -> None:
|
||||
"""Add an exit edge configuration."""
|
||||
self.exit_edges.append(edge_config)
|
||||
|
||||
def increment_iteration(self) -> None:
|
||||
"""Increment the iteration counter."""
|
||||
self.iteration_count += 1
|
||||
|
||||
def is_within_iteration_limit(self) -> bool:
|
||||
"""Check if the cycle should continue executing."""
|
||||
max_iter = self.max_iterations if self.max_iterations is not None else self.max_iterations_default
|
||||
return self.iteration_count < max_iter
|
||||
|
||||
def get_max_iterations(self) -> int:
|
||||
"""Get the effective maximum iterations."""
|
||||
return self.max_iterations if self.max_iterations is not None else self.max_iterations_default
|
||||
|
||||
def reset_iteration_count(self) -> None:
|
||||
"""Reset the iteration counter."""
|
||||
self.iteration_count = 0
|
||||
|
||||
def is_node_in_cycle(self, node_id: str) -> bool:
|
||||
"""Check if a node is part of this cycle."""
|
||||
return node_id in self.nodes
|
||||
|
||||
def is_entry_node(self, node_id: str) -> bool:
|
||||
"""Check if a node is an entry node for this cycle."""
|
||||
return node_id in self.entry_nodes
|
||||
|
||||
|
||||
class CycleDetector:
|
||||
"""Detects cycles in workflow graphs using Tarjan's algorithm."""
|
||||
|
||||
def __init__(self):
|
||||
self.index_counter = 0
|
||||
self.index: Dict[str, int] = {}
|
||||
self.low_link: Dict[str, int] = {}
|
||||
self.stack: List[str] = []
|
||||
self.on_stack: Set[str] = set()
|
||||
self.cycles: List[Set[str]] = []
|
||||
|
||||
def detect_cycles(self, nodes: Dict[str, Node]) -> List[Set[str]]:
|
||||
"""Detect all cycles in the graph using Tarjan's strongly connected components' algorithm."""
|
||||
self.cycles.clear()
|
||||
self.index_counter = 0
|
||||
self.index.clear()
|
||||
self.low_link.clear()
|
||||
self.stack.clear()
|
||||
self.on_stack.clear()
|
||||
|
||||
for node_id in nodes:
|
||||
if node_id not in self.index:
|
||||
self._strong_connect(node_id, nodes)
|
||||
|
||||
# return [cycle for cycle in self.cycles if len(cycle) > 1 or self._has_self_loop(next(iter(cycle)), nodes)]
|
||||
return self.cycles
|
||||
|
||||
def _has_self_loop(self, node_id: str, nodes: Dict[str, Node]) -> bool:
|
||||
"""Check if a node has a self-loop."""
|
||||
node = nodes.get(node_id)
|
||||
if not node:
|
||||
return False
|
||||
return any(edge_link.target.id == node_id for edge_link in node.iter_outgoing_edges())
|
||||
|
||||
def _strong_connect(self, node_id: str, nodes: Dict[str, Node]) -> None:
|
||||
"""Recursive part of Tarjan's algorithm."""
|
||||
self.index[node_id] = self.index_counter
|
||||
self.low_link[node_id] = self.index_counter
|
||||
self.index_counter += 1
|
||||
self.stack.append(node_id)
|
||||
self.on_stack.add(node_id)
|
||||
|
||||
node = nodes.get(node_id)
|
||||
if not node:
|
||||
return
|
||||
|
||||
# Consider successors of node
|
||||
for edge_link in node.iter_outgoing_edges():
|
||||
successor_id = edge_link.target.id
|
||||
|
||||
if successor_id not in self.index:
|
||||
self._strong_connect(successor_id, nodes)
|
||||
self.low_link[node_id] = min(self.low_link[node_id], self.low_link[successor_id])
|
||||
elif successor_id in self.on_stack:
|
||||
self.low_link[node_id] = min(self.low_link[node_id], self.index[successor_id])
|
||||
|
||||
# If node is a root node, pop the stack and generate an SCC
|
||||
if self.low_link[node_id] == self.index[node_id]:
|
||||
cycle = set()
|
||||
while True:
|
||||
w = self.stack.pop()
|
||||
self.on_stack.remove(w)
|
||||
cycle.add(w)
|
||||
if w == node_id:
|
||||
break
|
||||
|
||||
if len(cycle) > 1 or self._has_self_loop(node_id, nodes):
|
||||
self.cycles.append(cycle)
|
||||
|
||||
|
||||
class CycleManager:
|
||||
"""Manages execution of cycles in the workflow graph."""
|
||||
|
||||
def __init__(self):
|
||||
self.cycles: Dict[str, CycleInfo] = {}
|
||||
self.node_to_cycle: Dict[str, str] = {} # Maps node ID to cycle ID
|
||||
self.active_cycles: Set[str] = set()
|
||||
|
||||
def initialize_cycles(self, cycles: List[Set[str]], nodes: Dict[str, Node]) -> None:
|
||||
"""Initialize cycle information from detected cycles."""
|
||||
self.cycles.clear()
|
||||
self.node_to_cycle.clear()
|
||||
|
||||
for i, cycle_nodes in enumerate(cycles):
|
||||
cycle_id = f"cycle_{i}_{cycle_nodes}"
|
||||
cycle_info = CycleInfo(
|
||||
cycle_id=cycle_id,
|
||||
nodes=set(cycle_nodes),
|
||||
entry_nodes=set(),
|
||||
exit_edges=[]
|
||||
)
|
||||
|
||||
# Find entry nodes and exit edges
|
||||
self._analyze_cycle_structure(cycle_info, nodes)
|
||||
|
||||
self.cycles[cycle_id] = cycle_info
|
||||
|
||||
# Map nodes to their cycle
|
||||
for node_id in cycle_nodes:
|
||||
self.node_to_cycle[node_id] = cycle_id
|
||||
|
||||
def _analyze_cycle_structure(self, cycle: CycleInfo, nodes: Dict[str, Node]) -> None:
|
||||
"""Analyze cycle structure to find entry nodes and exit edges."""
|
||||
cycle_nodes = cycle.nodes
|
||||
|
||||
# Find entry nodes (nodes with predecessors outside the cycle)
|
||||
def judge_entry_predecessor(_predecessor: Node, _predecessor_id: str) -> bool:
|
||||
if _predecessor_id in cycle_nodes:
|
||||
return False
|
||||
for _edge_link in _predecessor.iter_outgoing_edges():
|
||||
if _edge_link.target.id == node_id:
|
||||
if _edge_link.trigger:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
return False
|
||||
|
||||
for node_id in cycle_nodes:
|
||||
node = nodes.get(node_id)
|
||||
if not node:
|
||||
continue
|
||||
|
||||
for predecessor in node.predecessors:
|
||||
if judge_entry_predecessor(predecessor, predecessor.id):
|
||||
cycle.add_entry_node(node_id)
|
||||
break
|
||||
|
||||
# Find exit edges (edges from cycle nodes to nodes outside the cycle)
|
||||
for node_id in cycle_nodes:
|
||||
node = nodes.get(node_id)
|
||||
if not node:
|
||||
continue
|
||||
|
||||
for edge_link in node.iter_outgoing_edges():
|
||||
if edge_link.target.id not in cycle_nodes and edge_link.trigger:
|
||||
exit_edge = {
|
||||
"from": node_id,
|
||||
"to": edge_link.target.id,
|
||||
"condition": edge_link.condition,
|
||||
"trigger": edge_link.trigger,
|
||||
"config": edge_link.config
|
||||
}
|
||||
cycle.add_exit_edge(exit_edge)
|
||||
|
||||
def activate_cycle(self, cycle_id: str) -> None:
|
||||
"""Activate a cycle for execution."""
|
||||
if cycle_id in self.cycles:
|
||||
self.cycles[cycle_id].is_active = True
|
||||
self.active_cycles.add(cycle_id)
|
||||
|
||||
def deactivate_cycle(self, cycle_id: str) -> None:
|
||||
"""Deactivate a cycle."""
|
||||
if cycle_id in self.cycles:
|
||||
self.cycles[cycle_id].is_active = False
|
||||
self.cycles[cycle_id].iteration_count = 0
|
||||
self.active_cycles.discard(cycle_id)
|
||||
Reference in New Issue
Block a user