hexdag 0.5.0.dev1__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.
Files changed (261) hide show
  1. hexdag/__init__.py +116 -0
  2. hexdag/__main__.py +30 -0
  3. hexdag/adapters/executors/__init__.py +5 -0
  4. hexdag/adapters/executors/local_executor.py +316 -0
  5. hexdag/builtin/__init__.py +6 -0
  6. hexdag/builtin/adapters/__init__.py +51 -0
  7. hexdag/builtin/adapters/anthropic/__init__.py +5 -0
  8. hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
  9. hexdag/builtin/adapters/database/__init__.py +6 -0
  10. hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
  11. hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
  12. hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
  13. hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
  14. hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
  15. hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
  16. hexdag/builtin/adapters/local/README.md +59 -0
  17. hexdag/builtin/adapters/local/__init__.py +7 -0
  18. hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
  19. hexdag/builtin/adapters/memory/__init__.py +47 -0
  20. hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
  21. hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
  22. hexdag/builtin/adapters/memory/schemas.py +57 -0
  23. hexdag/builtin/adapters/memory/session_memory.py +178 -0
  24. hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
  25. hexdag/builtin/adapters/memory/state_memory.py +280 -0
  26. hexdag/builtin/adapters/mock/README.md +89 -0
  27. hexdag/builtin/adapters/mock/__init__.py +15 -0
  28. hexdag/builtin/adapters/mock/hexdag.toml +50 -0
  29. hexdag/builtin/adapters/mock/mock_database.py +225 -0
  30. hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
  31. hexdag/builtin/adapters/mock/mock_llm.py +177 -0
  32. hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
  33. hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
  34. hexdag/builtin/adapters/openai/__init__.py +5 -0
  35. hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
  36. hexdag/builtin/adapters/secret/__init__.py +7 -0
  37. hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
  38. hexdag/builtin/adapters/unified_tool_router.py +280 -0
  39. hexdag/builtin/macros/__init__.py +17 -0
  40. hexdag/builtin/macros/conversation_agent.py +390 -0
  41. hexdag/builtin/macros/llm_macro.py +151 -0
  42. hexdag/builtin/macros/reasoning_agent.py +423 -0
  43. hexdag/builtin/macros/tool_macro.py +380 -0
  44. hexdag/builtin/nodes/__init__.py +38 -0
  45. hexdag/builtin/nodes/_discovery.py +123 -0
  46. hexdag/builtin/nodes/agent_node.py +696 -0
  47. hexdag/builtin/nodes/base_node_factory.py +242 -0
  48. hexdag/builtin/nodes/composite_node.py +926 -0
  49. hexdag/builtin/nodes/data_node.py +201 -0
  50. hexdag/builtin/nodes/expression_node.py +487 -0
  51. hexdag/builtin/nodes/function_node.py +454 -0
  52. hexdag/builtin/nodes/llm_node.py +491 -0
  53. hexdag/builtin/nodes/loop_node.py +920 -0
  54. hexdag/builtin/nodes/mapped_input.py +518 -0
  55. hexdag/builtin/nodes/port_call_node.py +269 -0
  56. hexdag/builtin/nodes/tool_call_node.py +195 -0
  57. hexdag/builtin/nodes/tool_utils.py +390 -0
  58. hexdag/builtin/prompts/__init__.py +68 -0
  59. hexdag/builtin/prompts/base.py +422 -0
  60. hexdag/builtin/prompts/chat_prompts.py +303 -0
  61. hexdag/builtin/prompts/error_correction_prompts.py +320 -0
  62. hexdag/builtin/prompts/tool_prompts.py +160 -0
  63. hexdag/builtin/tools/builtin_tools.py +84 -0
  64. hexdag/builtin/tools/database_tools.py +164 -0
  65. hexdag/cli/__init__.py +17 -0
  66. hexdag/cli/__main__.py +7 -0
  67. hexdag/cli/commands/__init__.py +27 -0
  68. hexdag/cli/commands/build_cmd.py +812 -0
  69. hexdag/cli/commands/create_cmd.py +208 -0
  70. hexdag/cli/commands/docs_cmd.py +293 -0
  71. hexdag/cli/commands/generate_types_cmd.py +252 -0
  72. hexdag/cli/commands/init_cmd.py +188 -0
  73. hexdag/cli/commands/pipeline_cmd.py +494 -0
  74. hexdag/cli/commands/plugin_dev_cmd.py +529 -0
  75. hexdag/cli/commands/plugins_cmd.py +441 -0
  76. hexdag/cli/commands/studio_cmd.py +101 -0
  77. hexdag/cli/commands/validate_cmd.py +221 -0
  78. hexdag/cli/main.py +84 -0
  79. hexdag/core/__init__.py +83 -0
  80. hexdag/core/config/__init__.py +20 -0
  81. hexdag/core/config/loader.py +479 -0
  82. hexdag/core/config/models.py +150 -0
  83. hexdag/core/configurable.py +294 -0
  84. hexdag/core/context/__init__.py +37 -0
  85. hexdag/core/context/execution_context.py +378 -0
  86. hexdag/core/docs/__init__.py +26 -0
  87. hexdag/core/docs/extractors.py +678 -0
  88. hexdag/core/docs/generators.py +890 -0
  89. hexdag/core/docs/models.py +120 -0
  90. hexdag/core/domain/__init__.py +10 -0
  91. hexdag/core/domain/dag.py +1225 -0
  92. hexdag/core/exceptions.py +234 -0
  93. hexdag/core/expression_parser.py +569 -0
  94. hexdag/core/logging.py +449 -0
  95. hexdag/core/models/__init__.py +17 -0
  96. hexdag/core/models/base.py +138 -0
  97. hexdag/core/orchestration/__init__.py +46 -0
  98. hexdag/core/orchestration/body_executor.py +481 -0
  99. hexdag/core/orchestration/components/__init__.py +97 -0
  100. hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
  101. hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
  102. hexdag/core/orchestration/components/execution_coordinator.py +360 -0
  103. hexdag/core/orchestration/components/health_check_manager.py +176 -0
  104. hexdag/core/orchestration/components/input_mapper.py +143 -0
  105. hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
  106. hexdag/core/orchestration/components/node_executor.py +377 -0
  107. hexdag/core/orchestration/components/secret_manager.py +202 -0
  108. hexdag/core/orchestration/components/wave_executor.py +158 -0
  109. hexdag/core/orchestration/constants.py +17 -0
  110. hexdag/core/orchestration/events/README.md +312 -0
  111. hexdag/core/orchestration/events/__init__.py +104 -0
  112. hexdag/core/orchestration/events/batching.py +330 -0
  113. hexdag/core/orchestration/events/decorators.py +139 -0
  114. hexdag/core/orchestration/events/events.py +573 -0
  115. hexdag/core/orchestration/events/observers/__init__.py +30 -0
  116. hexdag/core/orchestration/events/observers/core_observers.py +690 -0
  117. hexdag/core/orchestration/events/observers/models.py +111 -0
  118. hexdag/core/orchestration/events/taxonomy.py +269 -0
  119. hexdag/core/orchestration/hook_context.py +237 -0
  120. hexdag/core/orchestration/hooks.py +437 -0
  121. hexdag/core/orchestration/models.py +418 -0
  122. hexdag/core/orchestration/orchestrator.py +910 -0
  123. hexdag/core/orchestration/orchestrator_factory.py +275 -0
  124. hexdag/core/orchestration/port_wrappers.py +327 -0
  125. hexdag/core/orchestration/prompt/__init__.py +32 -0
  126. hexdag/core/orchestration/prompt/template.py +332 -0
  127. hexdag/core/pipeline_builder/__init__.py +21 -0
  128. hexdag/core/pipeline_builder/component_instantiator.py +386 -0
  129. hexdag/core/pipeline_builder/include_tag.py +265 -0
  130. hexdag/core/pipeline_builder/pipeline_config.py +133 -0
  131. hexdag/core/pipeline_builder/py_tag.py +223 -0
  132. hexdag/core/pipeline_builder/tag_discovery.py +268 -0
  133. hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
  134. hexdag/core/pipeline_builder/yaml_validator.py +569 -0
  135. hexdag/core/ports/__init__.py +65 -0
  136. hexdag/core/ports/api_call.py +133 -0
  137. hexdag/core/ports/database.py +489 -0
  138. hexdag/core/ports/embedding.py +215 -0
  139. hexdag/core/ports/executor.py +237 -0
  140. hexdag/core/ports/file_storage.py +117 -0
  141. hexdag/core/ports/healthcheck.py +87 -0
  142. hexdag/core/ports/llm.py +551 -0
  143. hexdag/core/ports/memory.py +70 -0
  144. hexdag/core/ports/observer_manager.py +130 -0
  145. hexdag/core/ports/secret.py +145 -0
  146. hexdag/core/ports/tool_router.py +94 -0
  147. hexdag/core/ports_builder.py +623 -0
  148. hexdag/core/protocols.py +273 -0
  149. hexdag/core/resolver.py +304 -0
  150. hexdag/core/schema/__init__.py +9 -0
  151. hexdag/core/schema/generator.py +742 -0
  152. hexdag/core/secrets.py +242 -0
  153. hexdag/core/types.py +413 -0
  154. hexdag/core/utils/async_warnings.py +206 -0
  155. hexdag/core/utils/schema_conversion.py +78 -0
  156. hexdag/core/utils/sql_validation.py +86 -0
  157. hexdag/core/validation/secure_json.py +148 -0
  158. hexdag/core/yaml_macro.py +517 -0
  159. hexdag/mcp_server.py +3120 -0
  160. hexdag/studio/__init__.py +10 -0
  161. hexdag/studio/build_ui.py +92 -0
  162. hexdag/studio/server/__init__.py +1 -0
  163. hexdag/studio/server/main.py +100 -0
  164. hexdag/studio/server/routes/__init__.py +9 -0
  165. hexdag/studio/server/routes/execute.py +208 -0
  166. hexdag/studio/server/routes/export.py +558 -0
  167. hexdag/studio/server/routes/files.py +207 -0
  168. hexdag/studio/server/routes/plugins.py +419 -0
  169. hexdag/studio/server/routes/validate.py +220 -0
  170. hexdag/studio/ui/index.html +13 -0
  171. hexdag/studio/ui/package-lock.json +2992 -0
  172. hexdag/studio/ui/package.json +31 -0
  173. hexdag/studio/ui/postcss.config.js +6 -0
  174. hexdag/studio/ui/public/hexdag.svg +5 -0
  175. hexdag/studio/ui/src/App.tsx +251 -0
  176. hexdag/studio/ui/src/components/Canvas.tsx +408 -0
  177. hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
  178. hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
  179. hexdag/studio/ui/src/components/Header.tsx +181 -0
  180. hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
  181. hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
  182. hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
  183. hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
  184. hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
  185. hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
  186. hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
  187. hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
  188. hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
  189. hexdag/studio/ui/src/components/index.ts +8 -0
  190. hexdag/studio/ui/src/index.css +92 -0
  191. hexdag/studio/ui/src/main.tsx +10 -0
  192. hexdag/studio/ui/src/types/index.ts +123 -0
  193. hexdag/studio/ui/src/vite-env.d.ts +1 -0
  194. hexdag/studio/ui/tailwind.config.js +29 -0
  195. hexdag/studio/ui/tsconfig.json +37 -0
  196. hexdag/studio/ui/tsconfig.node.json +13 -0
  197. hexdag/studio/ui/vite.config.ts +35 -0
  198. hexdag/visualization/__init__.py +69 -0
  199. hexdag/visualization/dag_visualizer.py +1020 -0
  200. hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
  201. hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
  202. hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
  203. hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
  204. hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
  205. hexdag_plugins/.gitignore +43 -0
  206. hexdag_plugins/README.md +73 -0
  207. hexdag_plugins/__init__.py +1 -0
  208. hexdag_plugins/azure/LICENSE +21 -0
  209. hexdag_plugins/azure/README.md +414 -0
  210. hexdag_plugins/azure/__init__.py +21 -0
  211. hexdag_plugins/azure/azure_blob_adapter.py +450 -0
  212. hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
  213. hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
  214. hexdag_plugins/azure/azure_openai_adapter.py +415 -0
  215. hexdag_plugins/azure/pyproject.toml +107 -0
  216. hexdag_plugins/azure/tests/__init__.py +1 -0
  217. hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
  218. hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
  219. hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
  220. hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
  221. hexdag_plugins/hexdag_etl/README.md +168 -0
  222. hexdag_plugins/hexdag_etl/__init__.py +53 -0
  223. hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
  224. hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
  225. hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
  226. hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
  227. hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
  228. hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
  229. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
  230. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
  231. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
  232. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
  233. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
  234. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
  235. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
  236. hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
  237. hexdag_plugins/hexdag_etl/test_transform.py +54 -0
  238. hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
  239. hexdag_plugins/mysql_adapter/LICENSE +21 -0
  240. hexdag_plugins/mysql_adapter/README.md +224 -0
  241. hexdag_plugins/mysql_adapter/__init__.py +6 -0
  242. hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
  243. hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
  244. hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
  245. hexdag_plugins/storage/README.md +184 -0
  246. hexdag_plugins/storage/__init__.py +19 -0
  247. hexdag_plugins/storage/file/__init__.py +5 -0
  248. hexdag_plugins/storage/file/local.py +325 -0
  249. hexdag_plugins/storage/ports/__init__.py +5 -0
  250. hexdag_plugins/storage/ports/vector_store.py +236 -0
  251. hexdag_plugins/storage/sql/__init__.py +7 -0
  252. hexdag_plugins/storage/sql/base.py +187 -0
  253. hexdag_plugins/storage/sql/mysql.py +27 -0
  254. hexdag_plugins/storage/sql/postgresql.py +27 -0
  255. hexdag_plugins/storage/tests/__init__.py +1 -0
  256. hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
  257. hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
  258. hexdag_plugins/storage/vector/__init__.py +7 -0
  259. hexdag_plugins/storage/vector/chromadb.py +223 -0
  260. hexdag_plugins/storage/vector/in_memory.py +285 -0
  261. hexdag_plugins/storage/vector/pgvector.py +502 -0
@@ -0,0 +1,377 @@
1
+ """Node executor for individual node execution.
2
+
3
+ This module provides the NodeExecutor class that handles executing individual
4
+ nodes with full lifecycle management including validation, timeout, and events.
5
+ """
6
+
7
+ import asyncio
8
+ import contextvars
9
+ import time
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from hexdag.core.ports.observer_manager import ObserverManagerPort
14
+ else:
15
+ ObserverManagerPort = Any
16
+
17
+ from hexdag.core.context import get_observer_manager, set_current_node_name
18
+ from hexdag.core.domain.dag import NodeSpec, ValidationError
19
+ from hexdag.core.expression_parser import ExpressionError, compile_expression
20
+ from hexdag.core.logging import get_logger
21
+ from hexdag.core.orchestration.components.execution_coordinator import ExecutionCoordinator
22
+ from hexdag.core.orchestration.events import (
23
+ NodeCancelled,
24
+ NodeCompleted,
25
+ NodeFailed,
26
+ NodeSkipped,
27
+ NodeStarted,
28
+ )
29
+ from hexdag.core.orchestration.models import NodeExecutionContext
30
+
31
+ logger = get_logger(__name__)
32
+
33
+
34
+ class NodeExecutionError(Exception):
35
+ """Exception raised when a node fails to execute."""
36
+
37
+ def __init__(self, node_name: str, original_error: Exception) -> None:
38
+ self.node_name = node_name
39
+ self.original_error = original_error
40
+ super().__init__(f"Node '{node_name}' failed: {original_error}")
41
+
42
+
43
+ class NodeTimeoutError(NodeExecutionError):
44
+ """Exception raised when a node exceeds its timeout."""
45
+
46
+ def __init__(self, node_name: str, timeout: float, original_error: TimeoutError) -> None:
47
+ self.timeout = timeout
48
+ super().__init__(node_name, original_error)
49
+
50
+
51
+ class NodeExecutor:
52
+ """Handles individual node execution with validation, timeout, and retry logic.
53
+
54
+ This component is responsible for executing a single node with full lifecycle
55
+ management:
56
+
57
+ - **Input validation**: Validates input data using node's input model
58
+ - **Event emission**: Fires NodeStarted, NodeCompleted, NodeFailed events
59
+ - **Timeout handling**: Enforces per-node and global timeouts
60
+ - **Retry logic**: Exponential backoff retry on failure
61
+ - **Output validation**: Validates output data using node's output model
62
+ - **Error handling**: Converts exceptions to NodeExecutionError
63
+
64
+ Single Responsibility: Execute a single node with all its lifecycle concerns.
65
+
66
+ Examples
67
+ --------
68
+ Example usage::
69
+
70
+ executor = NodeExecutor(strict_validation=True, default_node_timeout=30.0)
71
+
72
+ result = await executor.execute_node(
73
+ node_name="my_node",
74
+ node_spec=NodeSpec("my_node", my_function),
75
+ node_input={"data": "value"},
76
+ context=execution_context,
77
+ coordinator=coordinator,
78
+ wave_index=0
79
+ )
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ strict_validation: bool = False,
85
+ default_node_timeout: float | None = None,
86
+ ) -> None:
87
+ """Initialize node executor.
88
+
89
+ Parameters
90
+ ----------
91
+ strict_validation : bool, default=False
92
+ If True, raise errors on validation failure.
93
+ If False, log warnings and continue with unvalidated data.
94
+ default_node_timeout : float | None, default=None
95
+ Default timeout in seconds for each node.
96
+ Can be overridden per-node via NodeSpec.timeout.
97
+ None means no timeout.
98
+
99
+ Examples
100
+ --------
101
+ Strict validation, 30 second default timeout::
102
+
103
+ executor = NodeExecutor(strict_validation=True, default_node_timeout=30.0)
104
+
105
+ Lenient validation, no timeout::
106
+
107
+ executor = NodeExecutor(strict_validation=False)
108
+ """
109
+ self.strict_validation = strict_validation
110
+ self.default_node_timeout = default_node_timeout
111
+
112
+ async def execute_node(
113
+ self,
114
+ node_name: str,
115
+ node_spec: NodeSpec,
116
+ node_input: Any,
117
+ context: NodeExecutionContext,
118
+ coordinator: ExecutionCoordinator,
119
+ wave_index: int = 0,
120
+ validate: bool = True,
121
+ **kwargs: Any,
122
+ ) -> Any:
123
+ """Execute a single node with full lifecycle management.
124
+
125
+ Parameters
126
+ ----------
127
+ node_name : str
128
+ Name of the node being executed.
129
+ node_spec : NodeSpec
130
+ Node specification containing function and validation.
131
+ node_input : Any
132
+ Input data for the node.
133
+ context : NodeExecutionContext
134
+ Execution context with ports and configuration.
135
+ coordinator : ExecutionCoordinator
136
+ Coordinator for observer notifications.
137
+ wave_index : int, default=0
138
+ Index of the execution wave.
139
+ validate : bool, default=True
140
+ Whether to validate input/output.
141
+ **kwargs : Any
142
+ Additional keyword arguments passed to the node function.
143
+
144
+ Returns
145
+ -------
146
+ Any
147
+ The validated output from the node execution.
148
+ """
149
+ node_start_time = time.time()
150
+
151
+ observer_mgr = get_observer_manager()
152
+
153
+ try:
154
+ # Input validation
155
+ if validate:
156
+ try:
157
+ validated_input = node_spec.validate_input(node_input)
158
+ except ValidationError as e:
159
+ if self.strict_validation:
160
+ raise
161
+ logger.debug(
162
+ "Input validation failed for node '{node}': {error}",
163
+ node=node_name,
164
+ error=e,
165
+ )
166
+ validated_input = node_input
167
+ else:
168
+ validated_input = node_input
169
+
170
+ # Evaluate when clause - skip node if condition evaluates to False
171
+ if node_spec.when:
172
+ try:
173
+ predicate = compile_expression(node_spec.when)
174
+ # Build data context from validated input
175
+ data_context = validated_input if isinstance(validated_input, dict) else {}
176
+ condition_result = predicate(data_context, {})
177
+
178
+ if not condition_result:
179
+ logger.info(
180
+ "Node '{node}' skipped: when clause '{when}' evaluated to False",
181
+ node=node_name,
182
+ when=node_spec.when,
183
+ )
184
+ # Emit NodeSkipped event
185
+ skip_event = NodeSkipped(
186
+ name=node_name,
187
+ wave_index=wave_index,
188
+ reason=f"when clause '{node_spec.when}' evaluated to False",
189
+ )
190
+ await coordinator.notify_observer(observer_mgr, skip_event)
191
+
192
+ return {
193
+ "_skipped": True,
194
+ "reason": f"when clause '{node_spec.when}' evaluated to False",
195
+ }
196
+ except ExpressionError as e:
197
+ logger.error(
198
+ "Invalid when clause expression for node '{node}': {error}",
199
+ node=node_name,
200
+ error=e,
201
+ )
202
+ raise NodeExecutionError(
203
+ node_name, ValueError(f"Invalid when clause: {e}")
204
+ ) from e
205
+
206
+ # Set current node name for port-level event attribution
207
+ set_current_node_name(node_name)
208
+
209
+ # Fire node started event
210
+ start_event = NodeStarted(
211
+ name=node_name,
212
+ wave_index=wave_index,
213
+ dependencies=tuple(node_spec.deps),
214
+ )
215
+ await coordinator.notify_observer(observer_mgr, start_event)
216
+
217
+ # Determine timeout: node_spec.timeout > orchestrator default
218
+ node_timeout = node_spec.timeout or self.default_node_timeout
219
+
220
+ # Determine max retries: node_spec.max_retries or 1 (no retries)
221
+ max_retries = node_spec.max_retries or 1
222
+
223
+ # Exponential backoff configuration (with sensible defaults)
224
+ retry_delay = node_spec.retry_delay or 1.0 # Initial delay: 1 second
225
+ retry_backoff = node_spec.retry_backoff or 2.0 # Backoff multiplier: 2x
226
+ retry_max_delay = node_spec.retry_max_delay or 60.0 # Max delay: 60 seconds
227
+
228
+ last_error: Exception | None = None
229
+ raw_output: Any = None # Initialize to satisfy type checker
230
+
231
+ for attempt in range(1, max_retries + 1):
232
+ try:
233
+ if node_timeout:
234
+ async with asyncio.timeout(node_timeout):
235
+ raw_output = await self._execute_function(
236
+ node_spec, validated_input, kwargs
237
+ )
238
+ else:
239
+ raw_output = await self._execute_function(
240
+ node_spec, validated_input, kwargs
241
+ )
242
+ break # Success - exit retry loop
243
+ except TimeoutError as e:
244
+ # node_timeout is guaranteed to be set here because TimeoutError
245
+ # only occurs when timeout is set
246
+ timeout_value = node_timeout if node_timeout is not None else 0.0
247
+ last_error = NodeTimeoutError(node_name, timeout_value, e)
248
+ if attempt < max_retries:
249
+ # Calculate exponential backoff delay
250
+ delay = min(
251
+ retry_delay * (retry_backoff ** (attempt - 1)),
252
+ retry_max_delay,
253
+ )
254
+ logger.debug(
255
+ "Node '{node}' timeout ({attempt}/{max_retries}), "
256
+ "retrying in {delay:.2f}s...",
257
+ node=node_name,
258
+ attempt=attempt,
259
+ max_retries=max_retries,
260
+ delay=delay,
261
+ )
262
+ await asyncio.sleep(delay)
263
+ continue
264
+ raise last_error from e
265
+ except Exception as e:
266
+ last_error = e
267
+ if attempt < max_retries:
268
+ # Calculate exponential backoff delay
269
+ delay = min(
270
+ retry_delay * (retry_backoff ** (attempt - 1)),
271
+ retry_max_delay,
272
+ )
273
+ logger.debug(
274
+ "Node '{node}' error ({attempt}/{max_retries}): {error}, "
275
+ "retrying in {delay:.2f}s...",
276
+ node=node_name,
277
+ attempt=attempt,
278
+ max_retries=max_retries,
279
+ error=e,
280
+ delay=delay,
281
+ )
282
+ await asyncio.sleep(delay)
283
+ continue
284
+ raise
285
+ else:
286
+ # Loop completed without break - should not happen but handle it
287
+ if last_error:
288
+ raise last_error
289
+
290
+ # Output validation
291
+ if validate:
292
+ try:
293
+ validated_output = node_spec.validate_output(raw_output)
294
+ except ValidationError as e:
295
+ if self.strict_validation:
296
+ raise
297
+ logger.debug(
298
+ "Output validation failed for node '{node}': {error}",
299
+ node=node_name,
300
+ error=e,
301
+ )
302
+ validated_output = raw_output
303
+ else:
304
+ validated_output = raw_output
305
+
306
+ # Fire node completed event
307
+ complete_event = NodeCompleted(
308
+ name=node_name,
309
+ wave_index=wave_index,
310
+ result=validated_output,
311
+ duration_ms=(time.time() - node_start_time) * 1000,
312
+ )
313
+ await coordinator.notify_observer(observer_mgr, complete_event)
314
+
315
+ # Clear current node name
316
+ set_current_node_name(None)
317
+
318
+ return validated_output
319
+
320
+ except NodeTimeoutError:
321
+ # Node timed out - emit cancelled event and re-raise
322
+ cancel_event = NodeCancelled(
323
+ name=node_name,
324
+ wave_index=wave_index,
325
+ reason="timeout",
326
+ )
327
+ await coordinator.notify_observer(observer_mgr, cancel_event)
328
+ set_current_node_name(None) # Clear on timeout
329
+ raise # Re-raise original timeout error
330
+
331
+ except NodeExecutionError:
332
+ # Already wrapped - just re-raise
333
+ set_current_node_name(None) # Clear on error
334
+ raise
335
+
336
+ except (ValidationError, ValueError, TypeError, KeyError, AttributeError) as validation_err:
337
+ # Validation/type errors - emit failure event
338
+ fail_event = NodeFailed(
339
+ name=node_name,
340
+ wave_index=wave_index,
341
+ error=validation_err,
342
+ )
343
+ await coordinator.notify_observer(observer_mgr, fail_event)
344
+
345
+ # Wrap and propagate
346
+ set_current_node_name(None) # Clear on validation error
347
+ raise NodeExecutionError(node_name, validation_err) from validation_err
348
+
349
+ except RuntimeError as runtime_err:
350
+ # Runtime execution errors
351
+ fail_event = NodeFailed(
352
+ name=node_name,
353
+ wave_index=wave_index,
354
+ error=runtime_err,
355
+ )
356
+ await coordinator.notify_observer(observer_mgr, fail_event)
357
+
358
+ set_current_node_name(None) # Clear on runtime error
359
+ raise NodeExecutionError(node_name, runtime_err) from runtime_err
360
+
361
+ async def _execute_function(
362
+ self,
363
+ node_spec: NodeSpec,
364
+ validated_input: Any,
365
+ kwargs: dict[str, Any],
366
+ ) -> Any:
367
+ """Execute node function. Ports accessed via ExecutionContext, not parameters."""
368
+ if asyncio.iscoroutinefunction(node_spec.fn):
369
+ return await node_spec.fn(validated_input, **kwargs)
370
+ # Run sync functions in executor to avoid blocking event loop
371
+ # IMPORTANT: Copy context so ContextVars propagate to thread pool
372
+ ctx = contextvars.copy_context()
373
+
374
+ def _run_sync() -> Any:
375
+ return node_spec.fn(validated_input, **kwargs)
376
+
377
+ return await asyncio.get_running_loop().run_in_executor(None, ctx.run, _run_sync)
@@ -0,0 +1,202 @@
1
+ """Secret manager for loading and cleaning up secrets in Memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from hexdag.core.logging import get_logger
8
+
9
+ if TYPE_CHECKING:
10
+ from hexdag.core.ports.memory import Memory
11
+ from hexdag.core.ports.secret import SecretPort
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ class SecretManager:
17
+ """Manages secret injection and cleanup lifecycle.
18
+
19
+ Responsibilities:
20
+ - Load secrets from SecretPort into Memory
21
+ - Track loaded secret keys per pipeline
22
+ - Clean up secrets after pipeline execution
23
+
24
+ Examples
25
+ --------
26
+ Example usage::
27
+
28
+ manager = SecretManager(prefix="secret:", keys=["OPENAI_API_KEY"])
29
+ # Load secrets
30
+ mapping = await manager.load_secrets(
31
+ secret_port=keyvault,
32
+ memory=memory,
33
+ dag_id="my_pipeline"
34
+ )
35
+ # Clean up after execution
36
+ await manager.cleanup_secrets(
37
+ memory=memory,
38
+ dag_id="my_pipeline"
39
+ )
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ secret_keys: list[str] | None = None,
45
+ secret_prefix: str = "secret:", # nosec B107 - Not a password, it's a key prefix
46
+ ):
47
+ """Initialize secret manager.
48
+
49
+ Parameters
50
+ ----------
51
+ secret_keys : list[str] | None, default=None
52
+ Specific secret keys to load. If None, loads all available secrets.
53
+ secret_prefix : str, default="secret:"
54
+ Prefix for secret keys in memory
55
+ """
56
+ self.secret_keys = secret_keys
57
+ self.secret_prefix = secret_prefix
58
+ self._loaded_secret_keys: dict[str, list[str]] = {} # dag_id -> memory_keys
59
+
60
+ async def load_secrets(
61
+ self,
62
+ secret_port: SecretPort | None,
63
+ memory: Memory | None,
64
+ dag_id: str,
65
+ ) -> dict[str, str]:
66
+ """Load secrets from SecretPort into Memory.
67
+
68
+ Parameters
69
+ ----------
70
+ secret_port : SecretPort | None
71
+ Secret port instance (KeyVault, etc.)
72
+ memory : Memory | None
73
+ Memory port instance to store secrets in
74
+ dag_id : str
75
+ DAG identifier for tracking
76
+
77
+ Returns
78
+ -------
79
+ dict[str, str]
80
+ Mapping of secret key → memory key
81
+
82
+ Examples
83
+ --------
84
+ Example usage::
85
+
86
+ mapping = await manager.load_secrets(
87
+ secret_port=keyvault,
88
+ memory=memory,
89
+ dag_id="my_pipeline"
90
+ )
91
+ # Returns: {"OPENAI_API_KEY": "secret:OPENAI_API_KEY", ...}
92
+ """
93
+ if not secret_port:
94
+ logger.debug("No secret port configured, skipping secret injection")
95
+ return {}
96
+
97
+ if not memory:
98
+ logger.warning("Secret port configured but no memory port available")
99
+ return {}
100
+
101
+ try:
102
+ # Load secrets into memory
103
+ mapping = await secret_port.aload_secrets_to_memory(
104
+ memory=memory, prefix=self.secret_prefix, keys=self.secret_keys
105
+ )
106
+
107
+ memory_keys = list(mapping.values())
108
+ self._loaded_secret_keys[dag_id] = memory_keys
109
+
110
+ logger.info(
111
+ f"Loaded {len(mapping)} secrets into memory with prefix '{self.secret_prefix}'"
112
+ )
113
+ logger.debug(f"Secret keys loaded: {list(mapping.keys())}")
114
+
115
+ return mapping
116
+
117
+ except (ValueError, KeyError, RuntimeError) as e:
118
+ # Secret loading errors
119
+ logger.error(f"Failed to inject secrets: {e}", exc_info=True)
120
+ raise
121
+
122
+ async def cleanup_secrets(
123
+ self,
124
+ memory: Memory | None,
125
+ dag_id: str,
126
+ ) -> dict[str, Any]:
127
+ """Remove secrets from Memory for security.
128
+
129
+ Parameters
130
+ ----------
131
+ memory : Memory | None
132
+ Memory port instance
133
+ dag_id : str
134
+ DAG identifier
135
+
136
+ Returns
137
+ -------
138
+ dict[str, Any]
139
+ Cleanup results with keys_removed count
140
+
141
+ Examples
142
+ --------
143
+ Example usage::
144
+
145
+ result = await manager.cleanup_secrets(
146
+ memory=memory,
147
+ dag_id="my_pipeline"
148
+ )
149
+ # {"cleaned": True, "keys_removed": 2}
150
+ """
151
+ if not memory:
152
+ logger.debug("No memory port available for secret cleanup")
153
+ return {"cleaned": False, "reason": "No memory port"}
154
+
155
+ secret_keys = self.get_loaded_secret_keys(dag_id)
156
+
157
+ if not secret_keys:
158
+ logger.debug("No secrets were loaded for this pipeline")
159
+ return {"cleaned": True, "keys_removed": 0}
160
+
161
+ # Remove each secret from memory
162
+ removed_count = 0
163
+ for secret_key in secret_keys:
164
+ try:
165
+ await memory.aset(secret_key, None)
166
+ removed_count += 1
167
+ logger.debug(f"Removed secret from memory: {secret_key}")
168
+ except (RuntimeError, ValueError, KeyError) as e:
169
+ # Secret removal errors - log but continue cleanup
170
+ logger.warning(f"Failed to remove secret '{secret_key}': {e}")
171
+
172
+ # Clean up tracked keys
173
+ self.clear_loaded_secret_keys(dag_id)
174
+
175
+ logger.info(f"Secret cleanup: Removed {removed_count} secret(s) from memory")
176
+ return {"cleaned": True, "keys_removed": removed_count}
177
+
178
+ def get_loaded_secret_keys(self, dag_id: str) -> list[str]:
179
+ """Get the list of secret keys loaded for a specific pipeline.
180
+
181
+ Parameters
182
+ ----------
183
+ dag_id : str
184
+ The DAG identifier
185
+
186
+ Returns
187
+ -------
188
+ list[str]
189
+ List of memory keys where secrets were stored
190
+ """
191
+ return self._loaded_secret_keys.get(dag_id, [])
192
+
193
+ def clear_loaded_secret_keys(self, dag_id: str) -> None:
194
+ """Clear the tracked secret keys for a specific pipeline.
195
+
196
+ Parameters
197
+ ----------
198
+ dag_id : str
199
+ The DAG identifier
200
+ """
201
+ if dag_id in self._loaded_secret_keys:
202
+ del self._loaded_secret_keys[dag_id]