kailash 0.1.4__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kailash/__init__.py +1 -1
- kailash/access_control.py +740 -0
- kailash/api/__main__.py +6 -0
- kailash/api/auth.py +668 -0
- kailash/api/custom_nodes.py +285 -0
- kailash/api/custom_nodes_secure.py +377 -0
- kailash/api/database.py +620 -0
- kailash/api/studio.py +915 -0
- kailash/api/studio_secure.py +893 -0
- kailash/mcp/__init__.py +53 -0
- kailash/mcp/__main__.py +13 -0
- kailash/mcp/ai_registry_server.py +712 -0
- kailash/mcp/client.py +447 -0
- kailash/mcp/client_new.py +334 -0
- kailash/mcp/server.py +293 -0
- kailash/mcp/server_new.py +336 -0
- kailash/mcp/servers/__init__.py +12 -0
- kailash/mcp/servers/ai_registry.py +289 -0
- kailash/nodes/__init__.py +4 -2
- kailash/nodes/ai/__init__.py +38 -0
- kailash/nodes/ai/a2a.py +1790 -0
- kailash/nodes/ai/agents.py +116 -2
- kailash/nodes/ai/ai_providers.py +206 -8
- kailash/nodes/ai/intelligent_agent_orchestrator.py +2108 -0
- kailash/nodes/ai/iterative_llm_agent.py +1280 -0
- kailash/nodes/ai/llm_agent.py +324 -1
- kailash/nodes/ai/self_organizing.py +1623 -0
- kailash/nodes/api/http.py +106 -25
- kailash/nodes/api/rest.py +116 -21
- kailash/nodes/base.py +15 -2
- kailash/nodes/base_async.py +45 -0
- kailash/nodes/base_cycle_aware.py +374 -0
- kailash/nodes/base_with_acl.py +338 -0
- kailash/nodes/code/python.py +135 -27
- kailash/nodes/data/readers.py +116 -53
- kailash/nodes/data/writers.py +16 -6
- kailash/nodes/logic/__init__.py +8 -0
- kailash/nodes/logic/async_operations.py +48 -9
- kailash/nodes/logic/convergence.py +642 -0
- kailash/nodes/logic/loop.py +153 -0
- kailash/nodes/logic/operations.py +212 -27
- kailash/nodes/logic/workflow.py +26 -18
- kailash/nodes/mixins/__init__.py +11 -0
- kailash/nodes/mixins/mcp.py +228 -0
- kailash/nodes/mixins.py +387 -0
- kailash/nodes/transform/__init__.py +8 -1
- kailash/nodes/transform/processors.py +119 -4
- kailash/runtime/__init__.py +2 -1
- kailash/runtime/access_controlled.py +458 -0
- kailash/runtime/local.py +106 -33
- kailash/runtime/parallel_cyclic.py +529 -0
- kailash/sdk_exceptions.py +90 -5
- kailash/security.py +845 -0
- kailash/tracking/manager.py +38 -15
- kailash/tracking/models.py +1 -1
- kailash/tracking/storage/filesystem.py +30 -2
- kailash/utils/__init__.py +8 -0
- kailash/workflow/__init__.py +18 -0
- kailash/workflow/convergence.py +270 -0
- kailash/workflow/cycle_analyzer.py +768 -0
- kailash/workflow/cycle_builder.py +573 -0
- kailash/workflow/cycle_config.py +709 -0
- kailash/workflow/cycle_debugger.py +760 -0
- kailash/workflow/cycle_exceptions.py +601 -0
- kailash/workflow/cycle_profiler.py +671 -0
- kailash/workflow/cycle_state.py +338 -0
- kailash/workflow/cyclic_runner.py +985 -0
- kailash/workflow/graph.py +500 -39
- kailash/workflow/migration.py +768 -0
- kailash/workflow/safety.py +365 -0
- kailash/workflow/templates.py +744 -0
- kailash/workflow/validation.py +693 -0
- {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/METADATA +446 -13
- kailash-0.2.0.dist-info/RECORD +125 -0
- kailash/nodes/mcp/__init__.py +0 -11
- kailash/nodes/mcp/client.py +0 -554
- kailash/nodes/mcp/resource.py +0 -682
- kailash/nodes/mcp/server.py +0 -577
- kailash-0.1.4.dist-info/RECORD +0 -85
- {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/WHEEL +0 -0
- {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/entry_points.txt +0 -0
- {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,985 @@
|
|
1
|
+
"""Comprehensive Execution Engine for Cyclic Workflows.
|
2
|
+
|
3
|
+
This module provides the core execution engine for cyclic workflows with
|
4
|
+
advanced parameter propagation, comprehensive task tracking, and performance
|
5
|
+
monitoring. It handles both DAG and cyclic portions of workflows with
|
6
|
+
sophisticated safety mechanisms and detailed execution analytics.
|
7
|
+
|
8
|
+
Design Philosophy:
|
9
|
+
Provides a robust, production-ready execution engine that seamlessly
|
10
|
+
handles both traditional DAG workflows and complex cyclic patterns.
|
11
|
+
Emphasizes safety, observability, and performance with comprehensive
|
12
|
+
tracking at multiple granularity levels.
|
13
|
+
|
14
|
+
Key Features:
|
15
|
+
- Hybrid DAG/Cycle execution with automatic detection
|
16
|
+
- Sophisticated parameter propagation between iterations
|
17
|
+
- Multi-level task tracking (workflow → cycle → iteration → node)
|
18
|
+
- Performance metrics collection with detailed analytics
|
19
|
+
- Safety mechanisms with timeout, memory, and iteration limits
|
20
|
+
- Real-time monitoring and health checks
|
21
|
+
|
22
|
+
Execution Architecture:
|
23
|
+
- WorkflowState: Manages execution state and parameter flow
|
24
|
+
- CyclicWorkflowExecutor: Main execution orchestrator
|
25
|
+
- ExecutionPlan: Optimized execution strategy
|
26
|
+
- CycleGroup: Manages individual cycle execution
|
27
|
+
- Safety integration with CycleSafetyManager
|
28
|
+
|
29
|
+
Task Tracking Hierarchy:
|
30
|
+
1. Workflow Run: Overall execution tracking
|
31
|
+
2. Cycle Groups: Individual cycle execution tracking
|
32
|
+
3. Cycle Iterations: Per-iteration execution tracking
|
33
|
+
4. Node Executions: Individual node execution tracking
|
34
|
+
5. Performance Metrics: Detailed timing and resource usage
|
35
|
+
|
36
|
+
Parameter Propagation:
|
37
|
+
- Initial parameters for first iteration
|
38
|
+
- Cross-iteration parameter flow with mapping
|
39
|
+
- State preservation between iterations
|
40
|
+
- Convergence value tracking
|
41
|
+
- Error handling and recovery
|
42
|
+
|
43
|
+
Upstream Dependencies:
|
44
|
+
- Workflow graph structure and validation
|
45
|
+
- Node implementations and execution contracts
|
46
|
+
- Safety managers for resource limits
|
47
|
+
- Task tracking and metrics collection systems
|
48
|
+
|
49
|
+
Downstream Consumers:
|
50
|
+
- Runtime engines for workflow execution
|
51
|
+
- Monitoring systems for execution tracking
|
52
|
+
- Performance analysis and optimization tools
|
53
|
+
- Debug and development tools
|
54
|
+
|
55
|
+
Safety and Monitoring:
|
56
|
+
- Configurable safety limits (iterations, timeout, memory)
|
57
|
+
- Real-time convergence monitoring
|
58
|
+
- Resource usage tracking and alerting
|
59
|
+
- Graceful degradation and error recovery
|
60
|
+
- Comprehensive logging and debugging support
|
61
|
+
|
62
|
+
Examples:
|
63
|
+
Basic cyclic execution:
|
64
|
+
|
65
|
+
>>> executor = CyclicWorkflowExecutor()
|
66
|
+
>>> # Execute workflow with cycles
|
67
|
+
>>> results, run_id = executor.execute(
|
68
|
+
... workflow,
|
69
|
+
... parameters={"initial_value": 10},
|
70
|
+
... task_manager=task_manager
|
71
|
+
... )
|
72
|
+
|
73
|
+
With safety configuration:
|
74
|
+
|
75
|
+
>>> from kailash.workflow.safety import CycleSafetyManager
|
76
|
+
>>> safety_manager = CycleSafetyManager(
|
77
|
+
... default_max_iterations=100,
|
78
|
+
... default_timeout=300,
|
79
|
+
... default_memory_limit=1024
|
80
|
+
... )
|
81
|
+
>>> executor = CyclicWorkflowExecutor(safety_manager)
|
82
|
+
>>> results, run_id = executor.execute(workflow, parameters)
|
83
|
+
|
84
|
+
With comprehensive tracking:
|
85
|
+
|
86
|
+
>>> from kailash.tracking import TaskManager
|
87
|
+
>>> task_manager = TaskManager()
|
88
|
+
>>> results, run_id = executor.execute(
|
89
|
+
... workflow, parameters, task_manager, run_id="custom_run_001"
|
90
|
+
... )
|
91
|
+
>>> # Access detailed tracking information
|
92
|
+
>>> tasks = task_manager.get_tasks_for_run(run_id)
|
93
|
+
>>> metrics = task_manager.get_metrics_for_run(run_id)
|
94
|
+
|
95
|
+
See Also:
|
96
|
+
- :mod:`kailash.workflow.safety` for safety mechanisms and limits
|
97
|
+
- :mod:`kailash.tracking` for comprehensive execution tracking
|
98
|
+
- :mod:`kailash.workflow.convergence` for convergence conditions
|
99
|
+
"""
|
100
|
+
|
101
|
+
import logging
|
102
|
+
from datetime import datetime, timezone
|
103
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
104
|
+
|
105
|
+
import networkx as nx
|
106
|
+
|
107
|
+
from kailash.sdk_exceptions import WorkflowExecutionError, WorkflowValidationError
|
108
|
+
from kailash.tracking import TaskManager, TaskStatus
|
109
|
+
from kailash.tracking.metrics_collector import MetricsCollector
|
110
|
+
from kailash.tracking.models import TaskMetrics
|
111
|
+
from kailash.workflow.convergence import create_convergence_condition
|
112
|
+
from kailash.workflow.cycle_state import CycleState, CycleStateManager
|
113
|
+
from kailash.workflow.graph import Workflow
|
114
|
+
from kailash.workflow.runner import WorkflowRunner
|
115
|
+
from kailash.workflow.safety import CycleSafetyManager, monitored_cycle
|
116
|
+
|
117
|
+
logger = logging.getLogger(__name__)
|
118
|
+
|
119
|
+
|
120
|
+
class WorkflowState:
|
121
|
+
"""Simple workflow execution state container."""
|
122
|
+
|
123
|
+
def __init__(self, run_id: str):
|
124
|
+
"""Initialize workflow state.
|
125
|
+
|
126
|
+
Args:
|
127
|
+
run_id: Unique execution run ID
|
128
|
+
"""
|
129
|
+
self.run_id = run_id
|
130
|
+
self.node_outputs: Dict[str, Any] = {}
|
131
|
+
self.execution_order: List[str] = []
|
132
|
+
self.metadata: Dict[str, Any] = {}
|
133
|
+
|
134
|
+
|
135
|
+
class CyclicWorkflowExecutor:
|
136
|
+
"""Execution engine supporting cyclic workflows with fixed parameter propagation."""
|
137
|
+
|
138
|
+
def __init__(self, safety_manager: Optional[CycleSafetyManager] = None):
|
139
|
+
"""Initialize cyclic workflow executor.
|
140
|
+
|
141
|
+
Args:
|
142
|
+
safety_manager: Optional safety manager for resource limits
|
143
|
+
"""
|
144
|
+
self.safety_manager = safety_manager or CycleSafetyManager()
|
145
|
+
self.cycle_state_manager = CycleStateManager()
|
146
|
+
self.dag_runner = WorkflowRunner() # For executing DAG portions
|
147
|
+
|
148
|
+
def execute(
|
149
|
+
self,
|
150
|
+
workflow: Workflow,
|
151
|
+
parameters: Optional[Dict[str, Any]] = None,
|
152
|
+
task_manager: Optional[TaskManager] = None,
|
153
|
+
run_id: Optional[str] = None,
|
154
|
+
) -> Tuple[Dict[str, Any], str]:
|
155
|
+
"""Execute workflow with cycle support.
|
156
|
+
|
157
|
+
Args:
|
158
|
+
workflow: Workflow to execute
|
159
|
+
parameters: Initial parameters/overrides
|
160
|
+
task_manager: Optional task manager for tracking execution
|
161
|
+
run_id: Optional run ID to use (if not provided, one will be generated)
|
162
|
+
|
163
|
+
Returns:
|
164
|
+
Tuple of (results dict, run_id)
|
165
|
+
|
166
|
+
Raises:
|
167
|
+
WorkflowExecutionError: If execution fails
|
168
|
+
WorkflowValidationError: If workflow is invalid
|
169
|
+
"""
|
170
|
+
# Validate workflow (including cycles)
|
171
|
+
workflow.validate()
|
172
|
+
|
173
|
+
# Generate run ID if not provided
|
174
|
+
if not run_id:
|
175
|
+
import uuid
|
176
|
+
|
177
|
+
run_id = str(uuid.uuid4())
|
178
|
+
|
179
|
+
logger.info(
|
180
|
+
f"Starting cyclic workflow execution: {workflow.name} (run_id: {run_id})"
|
181
|
+
)
|
182
|
+
|
183
|
+
# Check if workflow has cycles
|
184
|
+
if not workflow.has_cycles():
|
185
|
+
# No cycles, use standard DAG execution
|
186
|
+
logger.info("No cycles detected, using standard DAG execution")
|
187
|
+
return self.dag_runner.run(workflow, parameters), run_id
|
188
|
+
|
189
|
+
# Execute with cycle support
|
190
|
+
try:
|
191
|
+
results = self._execute_with_cycles(
|
192
|
+
workflow, parameters, run_id, task_manager
|
193
|
+
)
|
194
|
+
logger.info(f"Cyclic workflow execution completed: {workflow.name}")
|
195
|
+
return results, run_id
|
196
|
+
|
197
|
+
except Exception as e:
|
198
|
+
logger.error(f"Cyclic workflow execution failed: {e}")
|
199
|
+
raise WorkflowExecutionError(f"Execution failed: {e}") from e
|
200
|
+
|
201
|
+
finally:
|
202
|
+
# Clean up cycle states
|
203
|
+
self.cycle_state_manager.clear()
|
204
|
+
|
205
|
+
def _filter_none_values(self, obj: Any) -> Any:
|
206
|
+
"""Recursively filter None values from nested dictionaries.
|
207
|
+
|
208
|
+
Args:
|
209
|
+
obj: Object to filter (dict, list, or other)
|
210
|
+
|
211
|
+
Returns:
|
212
|
+
Filtered object with None values removed
|
213
|
+
"""
|
214
|
+
if isinstance(obj, dict):
|
215
|
+
return {
|
216
|
+
k: self._filter_none_values(v) for k, v in obj.items() if v is not None
|
217
|
+
}
|
218
|
+
elif isinstance(obj, list):
|
219
|
+
return [self._filter_none_values(item) for item in obj if item is not None]
|
220
|
+
else:
|
221
|
+
return obj
|
222
|
+
|
223
|
+
def _execute_with_cycles(
|
224
|
+
self,
|
225
|
+
workflow: Workflow,
|
226
|
+
parameters: Optional[Dict[str, Any]],
|
227
|
+
run_id: str,
|
228
|
+
task_manager: Optional[TaskManager] = None,
|
229
|
+
) -> Dict[str, Any]:
|
230
|
+
"""Execute workflow with cycle handling.
|
231
|
+
|
232
|
+
Args:
|
233
|
+
workflow: Workflow to execute
|
234
|
+
parameters: Initial parameters
|
235
|
+
run_id: Execution run ID
|
236
|
+
task_manager: Optional task manager for tracking
|
237
|
+
|
238
|
+
Returns:
|
239
|
+
Final execution results
|
240
|
+
"""
|
241
|
+
# Separate DAG and cycle edges
|
242
|
+
dag_edges, cycle_edges = workflow.separate_dag_and_cycle_edges()
|
243
|
+
cycle_groups = workflow.get_cycle_groups()
|
244
|
+
|
245
|
+
# Create execution plan
|
246
|
+
execution_plan = self._create_execution_plan(workflow, dag_edges, cycle_groups)
|
247
|
+
|
248
|
+
# Initialize workflow state
|
249
|
+
state = WorkflowState(run_id=run_id)
|
250
|
+
|
251
|
+
# Store initial parameters separately (don't treat them as outputs!)
|
252
|
+
state.initial_parameters = parameters or {}
|
253
|
+
|
254
|
+
# Execute the plan
|
255
|
+
results = self._execute_plan(workflow, execution_plan, state, task_manager)
|
256
|
+
|
257
|
+
# Log cycle summaries
|
258
|
+
summaries = self.cycle_state_manager.get_all_summaries()
|
259
|
+
for cycle_id, summary in summaries.items():
|
260
|
+
logger.info(f"Cycle {cycle_id} summary: {summary}")
|
261
|
+
|
262
|
+
return results
|
263
|
+
|
264
|
+
def _create_execution_plan(
|
265
|
+
self,
|
266
|
+
workflow: Workflow,
|
267
|
+
dag_edges: List[Tuple],
|
268
|
+
cycle_groups: Dict[str, List[Tuple]],
|
269
|
+
) -> "ExecutionPlan":
|
270
|
+
"""Create execution plan handling cycles.
|
271
|
+
|
272
|
+
Args:
|
273
|
+
workflow: Workflow instance
|
274
|
+
dag_edges: List of DAG edges
|
275
|
+
cycle_groups: Grouped cycle edges
|
276
|
+
|
277
|
+
Returns:
|
278
|
+
ExecutionPlan instance
|
279
|
+
"""
|
280
|
+
plan = ExecutionPlan()
|
281
|
+
|
282
|
+
# Create DAG-only graph for topological analysis
|
283
|
+
dag_graph = nx.DiGraph()
|
284
|
+
dag_graph.add_nodes_from(workflow.graph.nodes(data=True))
|
285
|
+
for source, target, data in dag_edges:
|
286
|
+
dag_graph.add_edge(source, target, **data)
|
287
|
+
|
288
|
+
# Get topological order for DAG portion
|
289
|
+
try:
|
290
|
+
topo_order = list(nx.topological_sort(dag_graph))
|
291
|
+
except nx.NetworkXUnfeasible:
|
292
|
+
raise WorkflowValidationError("DAG portion contains unmarked cycles")
|
293
|
+
|
294
|
+
# Identify cycle entry and exit points
|
295
|
+
for cycle_id, cycle_edges in cycle_groups.items():
|
296
|
+
cycle_nodes = set()
|
297
|
+
entry_nodes = set()
|
298
|
+
exit_nodes = set()
|
299
|
+
|
300
|
+
for source, target, data in cycle_edges:
|
301
|
+
cycle_nodes.add(source)
|
302
|
+
cycle_nodes.add(target)
|
303
|
+
|
304
|
+
# Entry nodes have incoming edges from non-cycle nodes
|
305
|
+
for pred in workflow.graph.predecessors(target):
|
306
|
+
if pred not in cycle_nodes:
|
307
|
+
entry_nodes.add(target)
|
308
|
+
|
309
|
+
# Exit nodes have outgoing edges to non-cycle nodes
|
310
|
+
for succ in workflow.graph.successors(source):
|
311
|
+
if succ not in cycle_nodes:
|
312
|
+
exit_nodes.add(source)
|
313
|
+
|
314
|
+
plan.add_cycle_group(
|
315
|
+
cycle_id=cycle_id,
|
316
|
+
nodes=cycle_nodes,
|
317
|
+
entry_nodes=entry_nodes,
|
318
|
+
exit_nodes=exit_nodes,
|
319
|
+
edges=cycle_edges,
|
320
|
+
)
|
321
|
+
|
322
|
+
# Build execution stages
|
323
|
+
plan.build_stages(topo_order, dag_graph)
|
324
|
+
|
325
|
+
return plan
|
326
|
+
|
327
|
+
def _execute_plan(
|
328
|
+
self,
|
329
|
+
workflow: Workflow,
|
330
|
+
plan: "ExecutionPlan",
|
331
|
+
state: WorkflowState,
|
332
|
+
task_manager: Optional[TaskManager] = None,
|
333
|
+
) -> Dict[str, Any]:
|
334
|
+
"""Execute the workflow plan.
|
335
|
+
|
336
|
+
Args:
|
337
|
+
workflow: Workflow instance
|
338
|
+
plan: Execution plan
|
339
|
+
state: Workflow state
|
340
|
+
task_manager: Optional task manager for tracking
|
341
|
+
|
342
|
+
Returns:
|
343
|
+
Execution results
|
344
|
+
"""
|
345
|
+
results = {}
|
346
|
+
|
347
|
+
logger.info(f"Executing plan with {len(plan.stages)} stages")
|
348
|
+
|
349
|
+
for i, stage in enumerate(plan.stages):
|
350
|
+
logger.info(
|
351
|
+
f"Executing stage {i+1}: is_cycle={stage.is_cycle}, nodes={getattr(stage, 'nodes', 'N/A')}"
|
352
|
+
)
|
353
|
+
if stage.is_cycle:
|
354
|
+
logger.info(
|
355
|
+
f"Stage {i+1} is a cycle group: {stage.cycle_group.cycle_id}"
|
356
|
+
)
|
357
|
+
# Execute cycle group
|
358
|
+
cycle_results = self._execute_cycle_group(
|
359
|
+
workflow, stage.cycle_group, state, task_manager
|
360
|
+
)
|
361
|
+
results.update(cycle_results)
|
362
|
+
else:
|
363
|
+
# Execute DAG nodes
|
364
|
+
for node_id in stage.nodes:
|
365
|
+
if node_id not in state.node_outputs:
|
366
|
+
logger.info(f"Executing DAG node: {node_id}")
|
367
|
+
node_result = self._execute_node(
|
368
|
+
workflow, node_id, state, task_manager=task_manager
|
369
|
+
)
|
370
|
+
results[node_id] = node_result
|
371
|
+
state.node_outputs[node_id] = node_result
|
372
|
+
|
373
|
+
return results
|
374
|
+
|
375
|
+
def _execute_cycle_group(
|
376
|
+
self,
|
377
|
+
workflow: Workflow,
|
378
|
+
cycle_group: "CycleGroup",
|
379
|
+
state: WorkflowState,
|
380
|
+
task_manager: Optional[TaskManager] = None,
|
381
|
+
) -> Dict[str, Any]:
|
382
|
+
"""Execute a cycle group with proper parameter propagation.
|
383
|
+
|
384
|
+
Args:
|
385
|
+
workflow: Workflow instance
|
386
|
+
cycle_group: Cycle group to execute
|
387
|
+
state: Workflow state
|
388
|
+
task_manager: Optional task manager for tracking
|
389
|
+
|
390
|
+
Returns:
|
391
|
+
Cycle execution results
|
392
|
+
"""
|
393
|
+
cycle_id = cycle_group.cycle_id
|
394
|
+
logger.info(f"*** EXECUTING CYCLE GROUP: {cycle_id} ***")
|
395
|
+
logger.info(f"Cycle nodes: {cycle_group.nodes}")
|
396
|
+
logger.info(f"Cycle edges: {cycle_group.edges}")
|
397
|
+
|
398
|
+
# Get cycle configuration from first edge
|
399
|
+
cycle_config = {}
|
400
|
+
convergence_check = None
|
401
|
+
if cycle_group.edges:
|
402
|
+
_, _, edge_data = cycle_group.edges[0]
|
403
|
+
# Extract convergence check separately
|
404
|
+
convergence_check = edge_data.get("convergence_check")
|
405
|
+
# Safety config only includes safety-related parameters
|
406
|
+
cycle_config = {
|
407
|
+
"max_iterations": edge_data.get("max_iterations"),
|
408
|
+
"timeout": edge_data.get("timeout"),
|
409
|
+
"memory_limit": edge_data.get("memory_limit"),
|
410
|
+
}
|
411
|
+
|
412
|
+
# Create convergence condition
|
413
|
+
convergence_condition = None
|
414
|
+
if convergence_check:
|
415
|
+
convergence_condition = create_convergence_condition(convergence_check)
|
416
|
+
|
417
|
+
# Get or create cycle state
|
418
|
+
cycle_state = self.cycle_state_manager.get_or_create_state(cycle_id)
|
419
|
+
|
420
|
+
# Start monitoring
|
421
|
+
with monitored_cycle(self.safety_manager, cycle_id, **cycle_config) as monitor:
|
422
|
+
results = {}
|
423
|
+
|
424
|
+
# Store previous iteration results for parameter propagation
|
425
|
+
previous_iteration_results = {}
|
426
|
+
|
427
|
+
# Create cycle group task if task manager available
|
428
|
+
cycle_task_id = None
|
429
|
+
if task_manager and state.run_id:
|
430
|
+
try:
|
431
|
+
cycle_task = task_manager.create_task(
|
432
|
+
run_id=state.run_id,
|
433
|
+
node_id=f"cycle_group_{cycle_id}",
|
434
|
+
node_type="CycleGroup",
|
435
|
+
started_at=datetime.now(timezone.utc),
|
436
|
+
metadata={
|
437
|
+
"cycle_id": cycle_id,
|
438
|
+
"max_iterations": cycle_config.get("max_iterations"),
|
439
|
+
"nodes_in_cycle": list(cycle_group.nodes),
|
440
|
+
},
|
441
|
+
)
|
442
|
+
if cycle_task:
|
443
|
+
cycle_task_id = cycle_task.task_id
|
444
|
+
task_manager.update_task_status(
|
445
|
+
cycle_task_id, TaskStatus.RUNNING
|
446
|
+
)
|
447
|
+
except Exception as e:
|
448
|
+
logger.warning(f"Failed to create cycle group task: {e}")
|
449
|
+
|
450
|
+
loop_count = 0
|
451
|
+
while True:
|
452
|
+
loop_count += 1
|
453
|
+
logger.info(f"Cycle {cycle_id} - Starting loop iteration {loop_count}")
|
454
|
+
|
455
|
+
# Record iteration
|
456
|
+
monitor.record_iteration()
|
457
|
+
|
458
|
+
# Create iteration task if task manager available
|
459
|
+
iteration_task_id = None
|
460
|
+
if task_manager and state.run_id:
|
461
|
+
try:
|
462
|
+
iteration_task = task_manager.create_task(
|
463
|
+
run_id=state.run_id,
|
464
|
+
node_id=f"cycle_{cycle_id}_iteration_{loop_count}",
|
465
|
+
node_type="CycleIteration",
|
466
|
+
started_at=datetime.now(timezone.utc),
|
467
|
+
metadata={
|
468
|
+
"cycle_id": cycle_id,
|
469
|
+
"iteration": loop_count,
|
470
|
+
"parent_task": cycle_task_id,
|
471
|
+
},
|
472
|
+
)
|
473
|
+
if iteration_task:
|
474
|
+
iteration_task_id = iteration_task.task_id
|
475
|
+
task_manager.update_task_status(
|
476
|
+
iteration_task_id, TaskStatus.RUNNING
|
477
|
+
)
|
478
|
+
except Exception as e:
|
479
|
+
logger.warning(f"Failed to create iteration task: {e}")
|
480
|
+
|
481
|
+
# Execute nodes in cycle
|
482
|
+
iteration_results = {}
|
483
|
+
for node_id in cycle_group.get_execution_order(workflow.graph):
|
484
|
+
node_result = self._execute_node(
|
485
|
+
workflow,
|
486
|
+
node_id,
|
487
|
+
state,
|
488
|
+
cycle_state,
|
489
|
+
cycle_edges=cycle_group.edges,
|
490
|
+
previous_iteration_results=previous_iteration_results,
|
491
|
+
task_manager=task_manager,
|
492
|
+
iteration=loop_count,
|
493
|
+
)
|
494
|
+
iteration_results[node_id] = node_result
|
495
|
+
state.node_outputs[node_id] = node_result
|
496
|
+
|
497
|
+
# Update results for this iteration
|
498
|
+
results.update(iteration_results)
|
499
|
+
|
500
|
+
# Store this iteration's results for next iteration
|
501
|
+
previous_iteration_results = iteration_results.copy()
|
502
|
+
|
503
|
+
# Log iteration info BEFORE state update
|
504
|
+
logger.info(
|
505
|
+
f"Cycle {cycle_id} iteration {cycle_state.iteration} (before update) results: {iteration_results}"
|
506
|
+
)
|
507
|
+
|
508
|
+
# Update cycle state
|
509
|
+
cycle_state.update(iteration_results)
|
510
|
+
|
511
|
+
# Check convergence
|
512
|
+
should_terminate = False
|
513
|
+
|
514
|
+
# Log after update
|
515
|
+
logger.info(
|
516
|
+
f"Cycle {cycle_id} iteration now at {cycle_state.iteration} (after update)"
|
517
|
+
)
|
518
|
+
|
519
|
+
# Check max iterations (built into monitor.record_iteration)
|
520
|
+
if cycle_state.iteration >= cycle_config.get(
|
521
|
+
"max_iterations", float("inf")
|
522
|
+
):
|
523
|
+
logger.info(
|
524
|
+
f"Cycle {cycle_id} reached max iterations: {cycle_state.iteration}"
|
525
|
+
)
|
526
|
+
should_terminate = True
|
527
|
+
|
528
|
+
# Check convergence condition
|
529
|
+
if convergence_condition:
|
530
|
+
converged = convergence_condition.evaluate(
|
531
|
+
iteration_results, cycle_state
|
532
|
+
)
|
533
|
+
logger.info(
|
534
|
+
f"Cycle {cycle_id} convergence check: {convergence_condition.describe()} = {converged}"
|
535
|
+
)
|
536
|
+
if converged:
|
537
|
+
logger.info(
|
538
|
+
f"Cycle {cycle_id} converged: {convergence_condition.describe()}"
|
539
|
+
)
|
540
|
+
should_terminate = True
|
541
|
+
|
542
|
+
# Check safety violations
|
543
|
+
if monitor.check_violations():
|
544
|
+
logger.warning(
|
545
|
+
f"Cycle {cycle_id} safety violation: {monitor.violations}"
|
546
|
+
)
|
547
|
+
should_terminate = True
|
548
|
+
|
549
|
+
# Complete iteration task
|
550
|
+
if iteration_task_id and task_manager:
|
551
|
+
try:
|
552
|
+
task_manager.update_task_status(
|
553
|
+
iteration_task_id,
|
554
|
+
TaskStatus.COMPLETED,
|
555
|
+
ended_at=datetime.now(timezone.utc),
|
556
|
+
result=iteration_results,
|
557
|
+
metadata={
|
558
|
+
"converged": (
|
559
|
+
converged if "converged" in locals() else False
|
560
|
+
),
|
561
|
+
"terminated": should_terminate,
|
562
|
+
},
|
563
|
+
)
|
564
|
+
except Exception as e:
|
565
|
+
logger.warning(f"Failed to update iteration task: {e}")
|
566
|
+
|
567
|
+
if should_terminate:
|
568
|
+
logger.info(
|
569
|
+
f"Cycle {cycle_id} terminating after {loop_count} iterations"
|
570
|
+
)
|
571
|
+
break
|
572
|
+
|
573
|
+
logger.info(f"Cycle {cycle_id} continuing to next iteration")
|
574
|
+
|
575
|
+
# Complete cycle group task
|
576
|
+
if cycle_task_id and task_manager:
|
577
|
+
try:
|
578
|
+
summary = cycle_state.get_summary()
|
579
|
+
task_manager.update_task_status(
|
580
|
+
cycle_task_id,
|
581
|
+
TaskStatus.COMPLETED,
|
582
|
+
ended_at=datetime.now(timezone.utc),
|
583
|
+
result=results,
|
584
|
+
metadata={
|
585
|
+
"total_iterations": loop_count,
|
586
|
+
"converged": (
|
587
|
+
converged if "converged" in locals() else False
|
588
|
+
),
|
589
|
+
"summary": summary,
|
590
|
+
},
|
591
|
+
)
|
592
|
+
except Exception as e:
|
593
|
+
logger.warning(f"Failed to update cycle group task: {e}")
|
594
|
+
|
595
|
+
# Log cycle completion
|
596
|
+
summary = cycle_state.get_summary()
|
597
|
+
logger.info(f"Cycle {cycle_id} completed: {summary}")
|
598
|
+
|
599
|
+
return results
|
600
|
+
|
601
|
+
def _execute_node(
|
602
|
+
self,
|
603
|
+
workflow: Workflow,
|
604
|
+
node_id: str,
|
605
|
+
state: WorkflowState,
|
606
|
+
cycle_state: Optional[CycleState] = None,
|
607
|
+
cycle_edges: Optional[List[Tuple]] = None,
|
608
|
+
previous_iteration_results: Optional[Dict[str, Any]] = None,
|
609
|
+
task_manager: Optional[TaskManager] = None,
|
610
|
+
iteration: Optional[int] = None,
|
611
|
+
) -> Any:
|
612
|
+
"""Execute a single node with proper parameter handling for cycles.
|
613
|
+
|
614
|
+
Args:
|
615
|
+
workflow: Workflow instance
|
616
|
+
node_id: Node to execute
|
617
|
+
state: Workflow state
|
618
|
+
cycle_state: Optional cycle state
|
619
|
+
cycle_edges: List of edges in the current cycle
|
620
|
+
previous_iteration_results: Results from previous cycle iteration
|
621
|
+
|
622
|
+
Returns:
|
623
|
+
Node execution result
|
624
|
+
"""
|
625
|
+
node = workflow.get_node(node_id)
|
626
|
+
if not node:
|
627
|
+
raise WorkflowExecutionError(f"Node not found: {node_id}")
|
628
|
+
|
629
|
+
# Gather inputs from connections
|
630
|
+
inputs = {}
|
631
|
+
|
632
|
+
# Check if we're in a cycle and this is not the first iteration
|
633
|
+
in_cycle = cycle_state is not None
|
634
|
+
is_cycle_iteration = in_cycle and cycle_state.iteration > 0
|
635
|
+
|
636
|
+
for pred, _, edge_data in workflow.graph.in_edges(node_id, data=True):
|
637
|
+
# Check if this edge is a cycle edge (but NOT synthetic)
|
638
|
+
is_cycle_edge = edge_data.get("cycle", False) and not edge_data.get(
|
639
|
+
"synthetic", False
|
640
|
+
)
|
641
|
+
|
642
|
+
# Determine where to get the predecessor output from
|
643
|
+
if is_cycle_edge and is_cycle_iteration and previous_iteration_results:
|
644
|
+
# For cycle edges after first iteration, use previous iteration results
|
645
|
+
pred_output = previous_iteration_results.get(pred)
|
646
|
+
logger.debug(
|
647
|
+
f"Using previous iteration result for {pred} -> {node_id}: {type(pred_output)} keys={list(pred_output.keys()) if isinstance(pred_output, dict) else 'not dict'}"
|
648
|
+
)
|
649
|
+
elif pred in state.node_outputs:
|
650
|
+
# For non-cycle edges or first iteration, use normal state
|
651
|
+
pred_output = state.node_outputs[pred]
|
652
|
+
else:
|
653
|
+
# No output available
|
654
|
+
continue
|
655
|
+
|
656
|
+
if pred_output is None:
|
657
|
+
continue
|
658
|
+
|
659
|
+
# Apply mapping
|
660
|
+
mapping = edge_data.get("mapping", {})
|
661
|
+
if is_cycle_edge and is_cycle_iteration:
|
662
|
+
logger.debug(
|
663
|
+
f"Applying cycle mapping: {mapping} from {pred} to {node_id}"
|
664
|
+
)
|
665
|
+
for src_key, dst_key in mapping.items():
|
666
|
+
# Handle nested output access
|
667
|
+
if "." in src_key:
|
668
|
+
# Navigate nested structure
|
669
|
+
value = pred_output
|
670
|
+
for part in src_key.split("."):
|
671
|
+
if isinstance(value, dict) and part in value:
|
672
|
+
value = value[part]
|
673
|
+
else:
|
674
|
+
value = None
|
675
|
+
break
|
676
|
+
if value is not None:
|
677
|
+
inputs[dst_key] = value
|
678
|
+
elif isinstance(pred_output, dict) and src_key in pred_output:
|
679
|
+
inputs[dst_key] = pred_output[src_key]
|
680
|
+
if is_cycle_edge and is_cycle_iteration:
|
681
|
+
logger.debug(
|
682
|
+
f"Mapped {src_key}={pred_output[src_key]} to {dst_key}"
|
683
|
+
)
|
684
|
+
elif src_key == "output":
|
685
|
+
# Default output mapping
|
686
|
+
inputs[dst_key] = pred_output
|
687
|
+
|
688
|
+
# Create context with cycle information
|
689
|
+
context = {
|
690
|
+
"workflow_id": workflow.workflow_id,
|
691
|
+
"run_id": state.run_id,
|
692
|
+
"node_id": node_id,
|
693
|
+
}
|
694
|
+
|
695
|
+
if cycle_state:
|
696
|
+
cycle_context = {
|
697
|
+
"cycle_id": cycle_state.cycle_id,
|
698
|
+
"iteration": cycle_state.iteration,
|
699
|
+
"elapsed_time": cycle_state.elapsed_time,
|
700
|
+
}
|
701
|
+
# Only add node_state if it's not None to avoid security validation errors
|
702
|
+
node_state = cycle_state.get_node_state(node_id)
|
703
|
+
if node_state is not None:
|
704
|
+
cycle_context["node_state"] = node_state
|
705
|
+
context["cycle"] = cycle_context
|
706
|
+
|
707
|
+
# Recursively filter None values from context to avoid security validation errors
|
708
|
+
context = self._filter_none_values(context)
|
709
|
+
|
710
|
+
# Debug inputs before merging
|
711
|
+
if cycle_state and cycle_state.iteration > 0:
|
712
|
+
logger.debug(f"Inputs gathered from connections: {inputs}")
|
713
|
+
|
714
|
+
# Merge node config with inputs
|
715
|
+
# Order: config < initial_parameters < connection inputs
|
716
|
+
merged_inputs = {**node.config}
|
717
|
+
|
718
|
+
# Add initial parameters if available and node hasn't been executed yet
|
719
|
+
if hasattr(state, "initial_parameters") and node_id in state.initial_parameters:
|
720
|
+
if node_id not in state.node_outputs or (
|
721
|
+
cycle_state and cycle_state.iteration == 0
|
722
|
+
):
|
723
|
+
# Use initial parameters on first execution
|
724
|
+
merged_inputs.update(state.initial_parameters[node_id])
|
725
|
+
|
726
|
+
# Connection inputs override everything
|
727
|
+
merged_inputs.update(inputs)
|
728
|
+
|
729
|
+
# Filter out None values to avoid security validation errors
|
730
|
+
merged_inputs = {k: v for k, v in merged_inputs.items() if v is not None}
|
731
|
+
|
732
|
+
# Create task for node execution if task manager available
|
733
|
+
task = None
|
734
|
+
if task_manager and state.run_id:
|
735
|
+
try:
|
736
|
+
# Build task node ID based on context
|
737
|
+
task_node_id = node_id
|
738
|
+
if cycle_state and iteration:
|
739
|
+
task_node_id = (
|
740
|
+
f"{node_id}_cycle_{cycle_state.cycle_id}_iteration_{iteration}"
|
741
|
+
)
|
742
|
+
|
743
|
+
# Create metadata
|
744
|
+
task_metadata = {
|
745
|
+
"node_type": node.__class__.__name__,
|
746
|
+
}
|
747
|
+
if cycle_state:
|
748
|
+
task_metadata.update(
|
749
|
+
{
|
750
|
+
"cycle_id": cycle_state.cycle_id,
|
751
|
+
"iteration": iteration or cycle_state.iteration,
|
752
|
+
"in_cycle": True,
|
753
|
+
}
|
754
|
+
)
|
755
|
+
|
756
|
+
task = task_manager.create_task(
|
757
|
+
run_id=state.run_id,
|
758
|
+
node_id=task_node_id,
|
759
|
+
node_type=node.__class__.__name__,
|
760
|
+
started_at=datetime.now(timezone.utc),
|
761
|
+
metadata=task_metadata,
|
762
|
+
)
|
763
|
+
if task:
|
764
|
+
task_manager.update_task_status(task.task_id, TaskStatus.RUNNING)
|
765
|
+
except Exception as e:
|
766
|
+
logger.warning(f"Failed to create task for node '{node_id}': {e}")
|
767
|
+
|
768
|
+
# Execute node with metrics collection
|
769
|
+
collector = MetricsCollector()
|
770
|
+
logger.debug(
|
771
|
+
f"Executing node: {node_id} (iteration: {cycle_state.iteration if cycle_state else 'N/A'})"
|
772
|
+
)
|
773
|
+
logger.debug(f"Node inputs: {list(merged_inputs.keys())}")
|
774
|
+
if cycle_state:
|
775
|
+
logger.debug(
|
776
|
+
f"Input values - value: {merged_inputs.get('value')}, counter: {merged_inputs.get('counter')}"
|
777
|
+
)
|
778
|
+
|
779
|
+
try:
|
780
|
+
with collector.collect(node_id=node_id) as metrics_context:
|
781
|
+
result = node.run(context=context, **merged_inputs)
|
782
|
+
|
783
|
+
# Get performance metrics
|
784
|
+
performance_metrics = metrics_context.result()
|
785
|
+
|
786
|
+
# Update task status with metrics
|
787
|
+
if task and task_manager:
|
788
|
+
try:
|
789
|
+
# Convert performance metrics to TaskMetrics format
|
790
|
+
task_metrics_data = performance_metrics.to_task_metrics()
|
791
|
+
task_metrics = TaskMetrics(**task_metrics_data)
|
792
|
+
|
793
|
+
task_manager.update_task_status(
|
794
|
+
task.task_id,
|
795
|
+
TaskStatus.COMPLETED,
|
796
|
+
result=result,
|
797
|
+
ended_at=datetime.now(timezone.utc),
|
798
|
+
metadata={"execution_time": performance_metrics.duration},
|
799
|
+
)
|
800
|
+
|
801
|
+
# Update task metrics
|
802
|
+
task_manager.update_task_metrics(task.task_id, task_metrics)
|
803
|
+
except Exception as e:
|
804
|
+
logger.warning(f"Failed to update task for node '{node_id}': {e}")
|
805
|
+
|
806
|
+
except Exception as e:
|
807
|
+
# Update task status on failure
|
808
|
+
if task and task_manager:
|
809
|
+
try:
|
810
|
+
task_manager.update_task_status(
|
811
|
+
task.task_id,
|
812
|
+
TaskStatus.FAILED,
|
813
|
+
error=str(e),
|
814
|
+
ended_at=datetime.now(timezone.utc),
|
815
|
+
)
|
816
|
+
except Exception as update_error:
|
817
|
+
logger.warning(
|
818
|
+
f"Failed to update task status on error: {update_error}"
|
819
|
+
)
|
820
|
+
raise
|
821
|
+
|
822
|
+
# Store node state if in cycle
|
823
|
+
if cycle_state and isinstance(result, dict) and "_cycle_state" in result:
|
824
|
+
cycle_state.set_node_state(node_id, result["_cycle_state"])
|
825
|
+
|
826
|
+
return result
|
827
|
+
|
828
|
+
|
829
|
+
class ExecutionPlan:
|
830
|
+
"""Execution plan for workflows with cycles."""
|
831
|
+
|
832
|
+
def __init__(self):
|
833
|
+
"""Initialize execution plan."""
|
834
|
+
self.stages: List["ExecutionStage"] = []
|
835
|
+
self.cycle_groups: Dict[str, "CycleGroup"] = {}
|
836
|
+
|
837
|
+
def add_cycle_group(
|
838
|
+
self,
|
839
|
+
cycle_id: str,
|
840
|
+
nodes: Set[str],
|
841
|
+
entry_nodes: Set[str],
|
842
|
+
exit_nodes: Set[str],
|
843
|
+
edges: List[Tuple],
|
844
|
+
) -> None:
|
845
|
+
"""Add a cycle group to the plan.
|
846
|
+
|
847
|
+
Args:
|
848
|
+
cycle_id: Cycle identifier
|
849
|
+
nodes: Nodes in the cycle
|
850
|
+
entry_nodes: Entry points to the cycle
|
851
|
+
exit_nodes: Exit points from the cycle
|
852
|
+
edges: Cycle edges
|
853
|
+
"""
|
854
|
+
self.cycle_groups[cycle_id] = CycleGroup(
|
855
|
+
cycle_id=cycle_id,
|
856
|
+
nodes=nodes,
|
857
|
+
entry_nodes=entry_nodes,
|
858
|
+
exit_nodes=exit_nodes,
|
859
|
+
edges=edges,
|
860
|
+
)
|
861
|
+
|
862
|
+
def build_stages(self, topo_order: List[str], dag_graph: nx.DiGraph) -> None:
|
863
|
+
"""Build execution stages.
|
864
|
+
|
865
|
+
Args:
|
866
|
+
topo_order: Topological order of DAG nodes
|
867
|
+
dag_graph: DAG portion of the graph
|
868
|
+
"""
|
869
|
+
# Track which nodes have been scheduled
|
870
|
+
scheduled = set()
|
871
|
+
|
872
|
+
logger.debug(
|
873
|
+
f"Building stages - cycle_groups: {list(self.cycle_groups.keys())}"
|
874
|
+
)
|
875
|
+
logger.debug(f"Building stages - topo_order: {topo_order}")
|
876
|
+
|
877
|
+
for node_id in topo_order:
|
878
|
+
if node_id in scheduled:
|
879
|
+
continue
|
880
|
+
|
881
|
+
# Check if node is part of a cycle
|
882
|
+
in_cycle_id = None
|
883
|
+
found_cycle_group = None
|
884
|
+
for cycle_id, cycle_group in self.cycle_groups.items():
|
885
|
+
logger.debug(
|
886
|
+
f"Checking node {node_id} against cycle {cycle_id} with nodes {cycle_group.nodes}"
|
887
|
+
)
|
888
|
+
if node_id in cycle_group.nodes:
|
889
|
+
in_cycle_id = cycle_id
|
890
|
+
found_cycle_group = cycle_group
|
891
|
+
logger.debug(f"Node {node_id} found in cycle {cycle_id}")
|
892
|
+
break
|
893
|
+
|
894
|
+
logger.debug(
|
895
|
+
f"in_cycle_id value: {in_cycle_id}, found_cycle_group: {found_cycle_group is not None}"
|
896
|
+
)
|
897
|
+
if found_cycle_group is not None:
|
898
|
+
logger.debug(f"Scheduling cycle group {in_cycle_id} for node {node_id}")
|
899
|
+
# Schedule entire cycle group
|
900
|
+
self.stages.append(
|
901
|
+
ExecutionStage(is_cycle=True, cycle_group=found_cycle_group)
|
902
|
+
)
|
903
|
+
scheduled.update(found_cycle_group.nodes)
|
904
|
+
else:
|
905
|
+
logger.debug(f"Scheduling DAG node {node_id}")
|
906
|
+
# Schedule DAG node
|
907
|
+
self.stages.append(ExecutionStage(is_cycle=False, nodes=[node_id]))
|
908
|
+
scheduled.add(node_id)
|
909
|
+
|
910
|
+
|
911
|
+
class ExecutionStage:
|
912
|
+
"""Single stage in execution plan."""
|
913
|
+
|
914
|
+
def __init__(
|
915
|
+
self,
|
916
|
+
is_cycle: bool,
|
917
|
+
nodes: Optional[List[str]] = None,
|
918
|
+
cycle_group: Optional["CycleGroup"] = None,
|
919
|
+
):
|
920
|
+
"""Initialize execution stage.
|
921
|
+
|
922
|
+
Args:
|
923
|
+
is_cycle: Whether this is a cycle stage
|
924
|
+
nodes: List of nodes (for DAG stage)
|
925
|
+
cycle_group: Cycle group (for cycle stage)
|
926
|
+
"""
|
927
|
+
self.is_cycle = is_cycle
|
928
|
+
self.nodes = nodes or []
|
929
|
+
self.cycle_group = cycle_group
|
930
|
+
|
931
|
+
|
932
|
+
class CycleGroup:
|
933
|
+
"""Group of nodes forming a cycle."""
|
934
|
+
|
935
|
+
def __init__(
|
936
|
+
self,
|
937
|
+
cycle_id: str,
|
938
|
+
nodes: Set[str],
|
939
|
+
entry_nodes: Set[str],
|
940
|
+
exit_nodes: Set[str],
|
941
|
+
edges: List[Tuple],
|
942
|
+
):
|
943
|
+
"""Initialize cycle group.
|
944
|
+
|
945
|
+
Args:
|
946
|
+
cycle_id: Cycle identifier
|
947
|
+
nodes: Nodes in the cycle
|
948
|
+
entry_nodes: Entry points to the cycle
|
949
|
+
exit_nodes: Exit points from the cycle
|
950
|
+
edges: Cycle edges
|
951
|
+
"""
|
952
|
+
self.cycle_id = cycle_id
|
953
|
+
self.nodes = nodes
|
954
|
+
self.entry_nodes = entry_nodes
|
955
|
+
self.exit_nodes = exit_nodes
|
956
|
+
self.edges = edges
|
957
|
+
|
958
|
+
def get_execution_order(self, full_graph: nx.DiGraph) -> List[str]:
|
959
|
+
"""Get execution order for nodes in cycle.
|
960
|
+
|
961
|
+
Args:
|
962
|
+
full_graph: Full workflow graph
|
963
|
+
|
964
|
+
Returns:
|
965
|
+
Ordered list of node IDs
|
966
|
+
"""
|
967
|
+
# Create subgraph with only cycle nodes
|
968
|
+
cycle_subgraph = full_graph.subgraph(self.nodes)
|
969
|
+
|
970
|
+
# Try topological sort on the subgraph (might work if cycle edges removed)
|
971
|
+
try:
|
972
|
+
# Remove cycle edges temporarily
|
973
|
+
temp_graph = cycle_subgraph.copy()
|
974
|
+
for source, target, _ in self.edges:
|
975
|
+
if temp_graph.has_edge(source, target):
|
976
|
+
temp_graph.remove_edge(source, target)
|
977
|
+
|
978
|
+
return list(nx.topological_sort(temp_graph))
|
979
|
+
except (nx.NetworkXError, nx.NetworkXUnfeasible):
|
980
|
+
# Fall back to entry nodes first, then others
|
981
|
+
order = list(self.entry_nodes)
|
982
|
+
for node in self.nodes:
|
983
|
+
if node not in order:
|
984
|
+
order.append(node)
|
985
|
+
return order
|