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.
- hexdag/__init__.py +116 -0
- hexdag/__main__.py +30 -0
- hexdag/adapters/executors/__init__.py +5 -0
- hexdag/adapters/executors/local_executor.py +316 -0
- hexdag/builtin/__init__.py +6 -0
- hexdag/builtin/adapters/__init__.py +51 -0
- hexdag/builtin/adapters/anthropic/__init__.py +5 -0
- hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
- hexdag/builtin/adapters/database/__init__.py +6 -0
- hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
- hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
- hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
- hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
- hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
- hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
- hexdag/builtin/adapters/local/README.md +59 -0
- hexdag/builtin/adapters/local/__init__.py +7 -0
- hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
- hexdag/builtin/adapters/memory/__init__.py +47 -0
- hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
- hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
- hexdag/builtin/adapters/memory/schemas.py +57 -0
- hexdag/builtin/adapters/memory/session_memory.py +178 -0
- hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
- hexdag/builtin/adapters/memory/state_memory.py +280 -0
- hexdag/builtin/adapters/mock/README.md +89 -0
- hexdag/builtin/adapters/mock/__init__.py +15 -0
- hexdag/builtin/adapters/mock/hexdag.toml +50 -0
- hexdag/builtin/adapters/mock/mock_database.py +225 -0
- hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
- hexdag/builtin/adapters/mock/mock_llm.py +177 -0
- hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
- hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
- hexdag/builtin/adapters/openai/__init__.py +5 -0
- hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
- hexdag/builtin/adapters/secret/__init__.py +7 -0
- hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
- hexdag/builtin/adapters/unified_tool_router.py +280 -0
- hexdag/builtin/macros/__init__.py +17 -0
- hexdag/builtin/macros/conversation_agent.py +390 -0
- hexdag/builtin/macros/llm_macro.py +151 -0
- hexdag/builtin/macros/reasoning_agent.py +423 -0
- hexdag/builtin/macros/tool_macro.py +380 -0
- hexdag/builtin/nodes/__init__.py +38 -0
- hexdag/builtin/nodes/_discovery.py +123 -0
- hexdag/builtin/nodes/agent_node.py +696 -0
- hexdag/builtin/nodes/base_node_factory.py +242 -0
- hexdag/builtin/nodes/composite_node.py +926 -0
- hexdag/builtin/nodes/data_node.py +201 -0
- hexdag/builtin/nodes/expression_node.py +487 -0
- hexdag/builtin/nodes/function_node.py +454 -0
- hexdag/builtin/nodes/llm_node.py +491 -0
- hexdag/builtin/nodes/loop_node.py +920 -0
- hexdag/builtin/nodes/mapped_input.py +518 -0
- hexdag/builtin/nodes/port_call_node.py +269 -0
- hexdag/builtin/nodes/tool_call_node.py +195 -0
- hexdag/builtin/nodes/tool_utils.py +390 -0
- hexdag/builtin/prompts/__init__.py +68 -0
- hexdag/builtin/prompts/base.py +422 -0
- hexdag/builtin/prompts/chat_prompts.py +303 -0
- hexdag/builtin/prompts/error_correction_prompts.py +320 -0
- hexdag/builtin/prompts/tool_prompts.py +160 -0
- hexdag/builtin/tools/builtin_tools.py +84 -0
- hexdag/builtin/tools/database_tools.py +164 -0
- hexdag/cli/__init__.py +17 -0
- hexdag/cli/__main__.py +7 -0
- hexdag/cli/commands/__init__.py +27 -0
- hexdag/cli/commands/build_cmd.py +812 -0
- hexdag/cli/commands/create_cmd.py +208 -0
- hexdag/cli/commands/docs_cmd.py +293 -0
- hexdag/cli/commands/generate_types_cmd.py +252 -0
- hexdag/cli/commands/init_cmd.py +188 -0
- hexdag/cli/commands/pipeline_cmd.py +494 -0
- hexdag/cli/commands/plugin_dev_cmd.py +529 -0
- hexdag/cli/commands/plugins_cmd.py +441 -0
- hexdag/cli/commands/studio_cmd.py +101 -0
- hexdag/cli/commands/validate_cmd.py +221 -0
- hexdag/cli/main.py +84 -0
- hexdag/core/__init__.py +83 -0
- hexdag/core/config/__init__.py +20 -0
- hexdag/core/config/loader.py +479 -0
- hexdag/core/config/models.py +150 -0
- hexdag/core/configurable.py +294 -0
- hexdag/core/context/__init__.py +37 -0
- hexdag/core/context/execution_context.py +378 -0
- hexdag/core/docs/__init__.py +26 -0
- hexdag/core/docs/extractors.py +678 -0
- hexdag/core/docs/generators.py +890 -0
- hexdag/core/docs/models.py +120 -0
- hexdag/core/domain/__init__.py +10 -0
- hexdag/core/domain/dag.py +1225 -0
- hexdag/core/exceptions.py +234 -0
- hexdag/core/expression_parser.py +569 -0
- hexdag/core/logging.py +449 -0
- hexdag/core/models/__init__.py +17 -0
- hexdag/core/models/base.py +138 -0
- hexdag/core/orchestration/__init__.py +46 -0
- hexdag/core/orchestration/body_executor.py +481 -0
- hexdag/core/orchestration/components/__init__.py +97 -0
- hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
- hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
- hexdag/core/orchestration/components/execution_coordinator.py +360 -0
- hexdag/core/orchestration/components/health_check_manager.py +176 -0
- hexdag/core/orchestration/components/input_mapper.py +143 -0
- hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
- hexdag/core/orchestration/components/node_executor.py +377 -0
- hexdag/core/orchestration/components/secret_manager.py +202 -0
- hexdag/core/orchestration/components/wave_executor.py +158 -0
- hexdag/core/orchestration/constants.py +17 -0
- hexdag/core/orchestration/events/README.md +312 -0
- hexdag/core/orchestration/events/__init__.py +104 -0
- hexdag/core/orchestration/events/batching.py +330 -0
- hexdag/core/orchestration/events/decorators.py +139 -0
- hexdag/core/orchestration/events/events.py +573 -0
- hexdag/core/orchestration/events/observers/__init__.py +30 -0
- hexdag/core/orchestration/events/observers/core_observers.py +690 -0
- hexdag/core/orchestration/events/observers/models.py +111 -0
- hexdag/core/orchestration/events/taxonomy.py +269 -0
- hexdag/core/orchestration/hook_context.py +237 -0
- hexdag/core/orchestration/hooks.py +437 -0
- hexdag/core/orchestration/models.py +418 -0
- hexdag/core/orchestration/orchestrator.py +910 -0
- hexdag/core/orchestration/orchestrator_factory.py +275 -0
- hexdag/core/orchestration/port_wrappers.py +327 -0
- hexdag/core/orchestration/prompt/__init__.py +32 -0
- hexdag/core/orchestration/prompt/template.py +332 -0
- hexdag/core/pipeline_builder/__init__.py +21 -0
- hexdag/core/pipeline_builder/component_instantiator.py +386 -0
- hexdag/core/pipeline_builder/include_tag.py +265 -0
- hexdag/core/pipeline_builder/pipeline_config.py +133 -0
- hexdag/core/pipeline_builder/py_tag.py +223 -0
- hexdag/core/pipeline_builder/tag_discovery.py +268 -0
- hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
- hexdag/core/pipeline_builder/yaml_validator.py +569 -0
- hexdag/core/ports/__init__.py +65 -0
- hexdag/core/ports/api_call.py +133 -0
- hexdag/core/ports/database.py +489 -0
- hexdag/core/ports/embedding.py +215 -0
- hexdag/core/ports/executor.py +237 -0
- hexdag/core/ports/file_storage.py +117 -0
- hexdag/core/ports/healthcheck.py +87 -0
- hexdag/core/ports/llm.py +551 -0
- hexdag/core/ports/memory.py +70 -0
- hexdag/core/ports/observer_manager.py +130 -0
- hexdag/core/ports/secret.py +145 -0
- hexdag/core/ports/tool_router.py +94 -0
- hexdag/core/ports_builder.py +623 -0
- hexdag/core/protocols.py +273 -0
- hexdag/core/resolver.py +304 -0
- hexdag/core/schema/__init__.py +9 -0
- hexdag/core/schema/generator.py +742 -0
- hexdag/core/secrets.py +242 -0
- hexdag/core/types.py +413 -0
- hexdag/core/utils/async_warnings.py +206 -0
- hexdag/core/utils/schema_conversion.py +78 -0
- hexdag/core/utils/sql_validation.py +86 -0
- hexdag/core/validation/secure_json.py +148 -0
- hexdag/core/yaml_macro.py +517 -0
- hexdag/mcp_server.py +3120 -0
- hexdag/studio/__init__.py +10 -0
- hexdag/studio/build_ui.py +92 -0
- hexdag/studio/server/__init__.py +1 -0
- hexdag/studio/server/main.py +100 -0
- hexdag/studio/server/routes/__init__.py +9 -0
- hexdag/studio/server/routes/execute.py +208 -0
- hexdag/studio/server/routes/export.py +558 -0
- hexdag/studio/server/routes/files.py +207 -0
- hexdag/studio/server/routes/plugins.py +419 -0
- hexdag/studio/server/routes/validate.py +220 -0
- hexdag/studio/ui/index.html +13 -0
- hexdag/studio/ui/package-lock.json +2992 -0
- hexdag/studio/ui/package.json +31 -0
- hexdag/studio/ui/postcss.config.js +6 -0
- hexdag/studio/ui/public/hexdag.svg +5 -0
- hexdag/studio/ui/src/App.tsx +251 -0
- hexdag/studio/ui/src/components/Canvas.tsx +408 -0
- hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
- hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
- hexdag/studio/ui/src/components/Header.tsx +181 -0
- hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
- hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
- hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
- hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
- hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
- hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
- hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
- hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
- hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
- hexdag/studio/ui/src/components/index.ts +8 -0
- hexdag/studio/ui/src/index.css +92 -0
- hexdag/studio/ui/src/main.tsx +10 -0
- hexdag/studio/ui/src/types/index.ts +123 -0
- hexdag/studio/ui/src/vite-env.d.ts +1 -0
- hexdag/studio/ui/tailwind.config.js +29 -0
- hexdag/studio/ui/tsconfig.json +37 -0
- hexdag/studio/ui/tsconfig.node.json +13 -0
- hexdag/studio/ui/vite.config.ts +35 -0
- hexdag/visualization/__init__.py +69 -0
- hexdag/visualization/dag_visualizer.py +1020 -0
- hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
- hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
- hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
- hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
- hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
- hexdag_plugins/.gitignore +43 -0
- hexdag_plugins/README.md +73 -0
- hexdag_plugins/__init__.py +1 -0
- hexdag_plugins/azure/LICENSE +21 -0
- hexdag_plugins/azure/README.md +414 -0
- hexdag_plugins/azure/__init__.py +21 -0
- hexdag_plugins/azure/azure_blob_adapter.py +450 -0
- hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
- hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
- hexdag_plugins/azure/azure_openai_adapter.py +415 -0
- hexdag_plugins/azure/pyproject.toml +107 -0
- hexdag_plugins/azure/tests/__init__.py +1 -0
- hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
- hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
- hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
- hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
- hexdag_plugins/hexdag_etl/README.md +168 -0
- hexdag_plugins/hexdag_etl/__init__.py +53 -0
- hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
- hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
- hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
- hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
- hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
- hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
- hexdag_plugins/hexdag_etl/test_transform.py +54 -0
- hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
- hexdag_plugins/mysql_adapter/LICENSE +21 -0
- hexdag_plugins/mysql_adapter/README.md +224 -0
- hexdag_plugins/mysql_adapter/__init__.py +6 -0
- hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
- hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
- hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
- hexdag_plugins/storage/README.md +184 -0
- hexdag_plugins/storage/__init__.py +19 -0
- hexdag_plugins/storage/file/__init__.py +5 -0
- hexdag_plugins/storage/file/local.py +325 -0
- hexdag_plugins/storage/ports/__init__.py +5 -0
- hexdag_plugins/storage/ports/vector_store.py +236 -0
- hexdag_plugins/storage/sql/__init__.py +7 -0
- hexdag_plugins/storage/sql/base.py +187 -0
- hexdag_plugins/storage/sql/mysql.py +27 -0
- hexdag_plugins/storage/sql/postgresql.py +27 -0
- hexdag_plugins/storage/tests/__init__.py +1 -0
- hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
- hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
- hexdag_plugins/storage/vector/__init__.py +7 -0
- hexdag_plugins/storage/vector/chromadb.py +223 -0
- hexdag_plugins/storage/vector/in_memory.py +285 -0
- 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]
|