mcp-hangar 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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Event Sourced Repository implementation.
|
|
2
|
+
|
|
3
|
+
Stores providers by persisting their domain events and rebuilding state on load.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from ..domain.events import DomainEvent
|
|
10
|
+
from ..domain.model.event_sourced_provider import EventSourcedProvider, ProviderSnapshot
|
|
11
|
+
from ..domain.repository import IProviderRepository, ProviderLike
|
|
12
|
+
from ..logging_config import get_logger
|
|
13
|
+
from .event_bus import EventBus, get_event_bus
|
|
14
|
+
from .event_store import EventStore, EventStoreSnapshot, get_event_store, StoredEvent
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProviderConfigStore:
|
|
20
|
+
"""Stores provider configuration (command, image, env, etc.)"""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self._configs: Dict[str, Dict[str, Any]] = {}
|
|
24
|
+
self._lock = threading.RLock()
|
|
25
|
+
|
|
26
|
+
def save(self, provider_id: str, config: Dict[str, Any]) -> None:
|
|
27
|
+
"""Save provider configuration."""
|
|
28
|
+
with self._lock:
|
|
29
|
+
self._configs[provider_id] = dict(config)
|
|
30
|
+
|
|
31
|
+
def load(self, provider_id: str) -> Optional[Dict[str, Any]]:
|
|
32
|
+
"""Load provider configuration."""
|
|
33
|
+
with self._lock:
|
|
34
|
+
if provider_id in self._configs:
|
|
35
|
+
return dict(self._configs[provider_id])
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
def remove(self, provider_id: str) -> bool:
|
|
39
|
+
"""Remove provider configuration."""
|
|
40
|
+
with self._lock:
|
|
41
|
+
if provider_id in self._configs:
|
|
42
|
+
del self._configs[provider_id]
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def get_all_ids(self) -> List[str]:
|
|
47
|
+
"""Get all provider IDs."""
|
|
48
|
+
with self._lock:
|
|
49
|
+
return list(self._configs.keys())
|
|
50
|
+
|
|
51
|
+
def clear(self) -> None:
|
|
52
|
+
"""Clear all configurations."""
|
|
53
|
+
with self._lock:
|
|
54
|
+
self._configs.clear()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EventSourcedProviderRepository(IProviderRepository):
|
|
58
|
+
"""
|
|
59
|
+
Repository that persists providers using event sourcing.
|
|
60
|
+
|
|
61
|
+
Features:
|
|
62
|
+
- Stores events in EventStore
|
|
63
|
+
- Rebuilds provider state from events
|
|
64
|
+
- Supports snapshots for performance
|
|
65
|
+
- Publishes events to EventBus after save
|
|
66
|
+
- Caches loaded providers
|
|
67
|
+
|
|
68
|
+
Thread-safe implementation.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
event_store: Optional[EventStore] = None,
|
|
74
|
+
event_bus: Optional[EventBus] = None,
|
|
75
|
+
snapshot_store: Optional[EventStoreSnapshot] = None,
|
|
76
|
+
snapshot_interval: int = 50,
|
|
77
|
+
):
|
|
78
|
+
"""
|
|
79
|
+
Initialize the event sourced repository.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
event_store: Event store for persistence (defaults to global)
|
|
83
|
+
event_bus: Event bus for publishing (defaults to global)
|
|
84
|
+
snapshot_store: Optional snapshot store for performance
|
|
85
|
+
snapshot_interval: Events between snapshots
|
|
86
|
+
"""
|
|
87
|
+
self._event_store = event_store or get_event_store()
|
|
88
|
+
self._event_bus = event_bus or get_event_bus()
|
|
89
|
+
self._snapshot_store = snapshot_store
|
|
90
|
+
self._snapshot_interval = snapshot_interval
|
|
91
|
+
|
|
92
|
+
# Configuration store (for non-event data like command, env)
|
|
93
|
+
self._config_store = ProviderConfigStore()
|
|
94
|
+
|
|
95
|
+
# In-memory cache for loaded providers
|
|
96
|
+
self._cache: Dict[str, EventSourcedProvider] = {}
|
|
97
|
+
self._lock = threading.RLock()
|
|
98
|
+
|
|
99
|
+
def add(self, provider_id: str, provider: ProviderLike) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Add or update a provider by persisting its uncommitted events.
|
|
102
|
+
|
|
103
|
+
If provider has uncommitted events, they are appended to the event store.
|
|
104
|
+
Then the events are published to the event bus.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
provider_id: Provider identifier
|
|
108
|
+
provider: Provider instance (should be EventSourcedProvider)
|
|
109
|
+
"""
|
|
110
|
+
if not provider_id:
|
|
111
|
+
raise ValueError("Provider ID cannot be empty")
|
|
112
|
+
|
|
113
|
+
with self._lock:
|
|
114
|
+
# Save configuration if it's a new provider or config changed
|
|
115
|
+
self._save_config(provider_id, provider)
|
|
116
|
+
|
|
117
|
+
# Handle non-event-sourced providers
|
|
118
|
+
if not isinstance(provider, EventSourcedProvider):
|
|
119
|
+
# For backward compatibility, just cache it
|
|
120
|
+
self._cache[provider_id] = provider
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
# Get uncommitted events
|
|
124
|
+
events = provider.get_uncommitted_events()
|
|
125
|
+
|
|
126
|
+
if events:
|
|
127
|
+
# Get current version from event store
|
|
128
|
+
current_version = self._event_store.get_version(provider_id)
|
|
129
|
+
|
|
130
|
+
# Append events
|
|
131
|
+
new_version = self._event_store.append(
|
|
132
|
+
stream_id=provider_id,
|
|
133
|
+
events=events,
|
|
134
|
+
expected_version=current_version,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Mark events as committed
|
|
138
|
+
provider.mark_events_committed()
|
|
139
|
+
|
|
140
|
+
# Create snapshot if needed
|
|
141
|
+
if self._snapshot_store:
|
|
142
|
+
events_since_snapshot = self._get_events_since_snapshot(provider_id)
|
|
143
|
+
if events_since_snapshot >= self._snapshot_interval:
|
|
144
|
+
self._create_snapshot(provider)
|
|
145
|
+
|
|
146
|
+
# Publish events
|
|
147
|
+
for event in events:
|
|
148
|
+
self._event_bus.publish(event)
|
|
149
|
+
|
|
150
|
+
logger.debug(
|
|
151
|
+
f"Saved {len(events)} events for provider {provider_id}, version {current_version} -> {new_version}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Update cache
|
|
155
|
+
self._cache[provider_id] = provider
|
|
156
|
+
|
|
157
|
+
def get(self, provider_id: str) -> Optional[ProviderLike]:
|
|
158
|
+
"""
|
|
159
|
+
Load a provider by rebuilding from events.
|
|
160
|
+
|
|
161
|
+
First checks cache, then loads from event store.
|
|
162
|
+
Uses snapshots if available for performance.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
provider_id: Provider identifier
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Provider if found, None otherwise
|
|
169
|
+
"""
|
|
170
|
+
with self._lock:
|
|
171
|
+
# Check cache first
|
|
172
|
+
if provider_id in self._cache:
|
|
173
|
+
return self._cache[provider_id]
|
|
174
|
+
|
|
175
|
+
# Load from event store
|
|
176
|
+
provider = self._load_from_events(provider_id)
|
|
177
|
+
|
|
178
|
+
if provider:
|
|
179
|
+
self._cache[provider_id] = provider
|
|
180
|
+
|
|
181
|
+
return provider
|
|
182
|
+
|
|
183
|
+
def _load_from_events(self, provider_id: str) -> Optional[EventSourcedProvider]:
|
|
184
|
+
"""Load provider from event store."""
|
|
185
|
+
# Load configuration
|
|
186
|
+
config = self._config_store.load(provider_id)
|
|
187
|
+
if not config:
|
|
188
|
+
# Check if there are events for this provider
|
|
189
|
+
if not self._event_store.stream_exists(provider_id):
|
|
190
|
+
return None
|
|
191
|
+
# Use default config
|
|
192
|
+
config = {"mode": "subprocess"}
|
|
193
|
+
|
|
194
|
+
# Try loading from snapshot first
|
|
195
|
+
snapshot = None
|
|
196
|
+
snapshot_version = -1
|
|
197
|
+
|
|
198
|
+
if self._snapshot_store:
|
|
199
|
+
snapshot_data = self._snapshot_store.load_snapshot(provider_id)
|
|
200
|
+
if snapshot_data:
|
|
201
|
+
snapshot = ProviderSnapshot.from_dict(snapshot_data["state"])
|
|
202
|
+
snapshot_version = snapshot_data["version"]
|
|
203
|
+
|
|
204
|
+
# Load events (from snapshot version or beginning)
|
|
205
|
+
events = self._event_store.load(stream_id=provider_id, from_version=snapshot_version + 1)
|
|
206
|
+
|
|
207
|
+
# Convert stored events to domain events
|
|
208
|
+
domain_events = self._hydrate_events(events)
|
|
209
|
+
|
|
210
|
+
if snapshot:
|
|
211
|
+
# Load from snapshot + subsequent events
|
|
212
|
+
provider = EventSourcedProvider.from_snapshot(snapshot, domain_events)
|
|
213
|
+
else:
|
|
214
|
+
if not domain_events and not self._event_store.stream_exists(provider_id):
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
# Load from scratch
|
|
218
|
+
provider = EventSourcedProvider.from_events(
|
|
219
|
+
provider_id=provider_id,
|
|
220
|
+
mode=config.get("mode", "subprocess"),
|
|
221
|
+
events=domain_events,
|
|
222
|
+
command=config.get("command"),
|
|
223
|
+
image=config.get("image"),
|
|
224
|
+
endpoint=config.get("endpoint"),
|
|
225
|
+
env=config.get("env"),
|
|
226
|
+
idle_ttl_s=config.get("idle_ttl_s", 300),
|
|
227
|
+
health_check_interval_s=config.get("health_check_interval_s", 60),
|
|
228
|
+
max_consecutive_failures=config.get("max_consecutive_failures", 3),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return provider
|
|
232
|
+
|
|
233
|
+
def _hydrate_events(self, stored_events: List[StoredEvent]) -> List[DomainEvent]:
|
|
234
|
+
"""Convert stored events to domain events."""
|
|
235
|
+
from ..domain.events import (
|
|
236
|
+
HealthCheckFailed,
|
|
237
|
+
HealthCheckPassed,
|
|
238
|
+
ProviderDegraded,
|
|
239
|
+
ProviderIdleDetected,
|
|
240
|
+
ProviderStarted,
|
|
241
|
+
ProviderStateChanged,
|
|
242
|
+
ProviderStopped,
|
|
243
|
+
ToolInvocationCompleted,
|
|
244
|
+
ToolInvocationFailed,
|
|
245
|
+
ToolInvocationRequested,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
event_classes = {
|
|
249
|
+
"ProviderStarted": ProviderStarted,
|
|
250
|
+
"ProviderStopped": ProviderStopped,
|
|
251
|
+
"ProviderDegraded": ProviderDegraded,
|
|
252
|
+
"ProviderStateChanged": ProviderStateChanged,
|
|
253
|
+
"ToolInvocationRequested": ToolInvocationRequested,
|
|
254
|
+
"ToolInvocationCompleted": ToolInvocationCompleted,
|
|
255
|
+
"ToolInvocationFailed": ToolInvocationFailed,
|
|
256
|
+
"HealthCheckPassed": HealthCheckPassed,
|
|
257
|
+
"HealthCheckFailed": HealthCheckFailed,
|
|
258
|
+
"ProviderIdleDetected": ProviderIdleDetected,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
domain_events = []
|
|
262
|
+
|
|
263
|
+
for stored in stored_events:
|
|
264
|
+
event_class = event_classes.get(stored.event_type)
|
|
265
|
+
if event_class:
|
|
266
|
+
# Extract event data (remove event_type from data dict)
|
|
267
|
+
event_data = {
|
|
268
|
+
k: v for k, v in stored.data.items() if k not in ("event_type", "event_id", "occurred_at")
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
event = event_class(**event_data)
|
|
273
|
+
# Restore original event_id and occurred_at
|
|
274
|
+
event.event_id = stored.event_id
|
|
275
|
+
event.occurred_at = stored.occurred_at
|
|
276
|
+
domain_events.append(event)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logger.warning(f"Failed to hydrate event {stored.event_type}: {e}")
|
|
279
|
+
|
|
280
|
+
return domain_events
|
|
281
|
+
|
|
282
|
+
def _save_config(self, provider_id: str, provider: ProviderLike) -> None:
|
|
283
|
+
"""Save provider configuration."""
|
|
284
|
+
if hasattr(provider, "_command"):
|
|
285
|
+
config = {
|
|
286
|
+
"mode": getattr(provider, "_mode", "subprocess"),
|
|
287
|
+
"command": getattr(provider, "_command", None),
|
|
288
|
+
"image": getattr(provider, "_image", None),
|
|
289
|
+
"endpoint": getattr(provider, "_endpoint", None),
|
|
290
|
+
"env": getattr(provider, "_env", {}),
|
|
291
|
+
"idle_ttl_s": getattr(provider, "_idle_ttl_s", 300),
|
|
292
|
+
"health_check_interval_s": getattr(provider, "_health_check_interval_s", 60),
|
|
293
|
+
"max_consecutive_failures": (
|
|
294
|
+
getattr(provider._health, "_max_consecutive_failures", 3) if hasattr(provider, "_health") else 3
|
|
295
|
+
),
|
|
296
|
+
}
|
|
297
|
+
self._config_store.save(provider_id, config)
|
|
298
|
+
|
|
299
|
+
def _get_events_since_snapshot(self, provider_id: str) -> int:
|
|
300
|
+
"""Get number of events since last snapshot."""
|
|
301
|
+
if not self._snapshot_store:
|
|
302
|
+
return self._event_store.get_version(provider_id) + 1
|
|
303
|
+
|
|
304
|
+
snapshot_data = self._snapshot_store.load_snapshot(provider_id)
|
|
305
|
+
snapshot_version = snapshot_data["version"] if snapshot_data else -1
|
|
306
|
+
|
|
307
|
+
current_version = self._event_store.get_version(provider_id)
|
|
308
|
+
return current_version - snapshot_version
|
|
309
|
+
|
|
310
|
+
def _create_snapshot(self, provider: EventSourcedProvider) -> None:
|
|
311
|
+
"""Create a snapshot for the provider."""
|
|
312
|
+
if not self._snapshot_store:
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
snapshot = provider.create_snapshot()
|
|
316
|
+
version = self._event_store.get_version(provider.provider_id)
|
|
317
|
+
|
|
318
|
+
self._snapshot_store.save_snapshot(stream_id=provider.provider_id, version=version, state=snapshot.to_dict())
|
|
319
|
+
|
|
320
|
+
logger.debug(f"Created snapshot for provider {provider.provider_id} at version {version}")
|
|
321
|
+
|
|
322
|
+
def exists(self, provider_id: str) -> bool:
|
|
323
|
+
"""Check if provider exists."""
|
|
324
|
+
with self._lock:
|
|
325
|
+
if provider_id in self._cache:
|
|
326
|
+
return True
|
|
327
|
+
return self._event_store.stream_exists(provider_id) or self._config_store.load(provider_id) is not None
|
|
328
|
+
|
|
329
|
+
def remove(self, provider_id: str) -> bool:
|
|
330
|
+
"""
|
|
331
|
+
Remove a provider.
|
|
332
|
+
|
|
333
|
+
Note: In event sourcing, we typically don't delete events.
|
|
334
|
+
This removes from cache and config store only.
|
|
335
|
+
"""
|
|
336
|
+
with self._lock:
|
|
337
|
+
removed = False
|
|
338
|
+
|
|
339
|
+
if provider_id in self._cache:
|
|
340
|
+
del self._cache[provider_id]
|
|
341
|
+
removed = True
|
|
342
|
+
|
|
343
|
+
if self._config_store.remove(provider_id):
|
|
344
|
+
removed = True
|
|
345
|
+
|
|
346
|
+
return removed
|
|
347
|
+
|
|
348
|
+
def get_all(self) -> Dict[str, ProviderLike]:
|
|
349
|
+
"""Get all providers."""
|
|
350
|
+
with self._lock:
|
|
351
|
+
# Get all known provider IDs
|
|
352
|
+
provider_ids = set(self._cache.keys())
|
|
353
|
+
provider_ids.update(self._event_store.get_all_stream_ids())
|
|
354
|
+
provider_ids.update(self._config_store.get_all_ids())
|
|
355
|
+
|
|
356
|
+
result = {}
|
|
357
|
+
for pid in provider_ids:
|
|
358
|
+
provider = self.get(pid)
|
|
359
|
+
if provider:
|
|
360
|
+
result[pid] = provider
|
|
361
|
+
|
|
362
|
+
return result
|
|
363
|
+
|
|
364
|
+
def get_all_ids(self) -> List[str]:
|
|
365
|
+
"""Get all provider IDs."""
|
|
366
|
+
with self._lock:
|
|
367
|
+
provider_ids = set(self._cache.keys())
|
|
368
|
+
provider_ids.update(self._event_store.get_all_stream_ids())
|
|
369
|
+
provider_ids.update(self._config_store.get_all_ids())
|
|
370
|
+
return list(provider_ids)
|
|
371
|
+
|
|
372
|
+
def count(self) -> int:
|
|
373
|
+
"""Get number of providers."""
|
|
374
|
+
return len(self.get_all_ids())
|
|
375
|
+
|
|
376
|
+
def clear(self) -> None:
|
|
377
|
+
"""Clear all providers from cache and config store."""
|
|
378
|
+
with self._lock:
|
|
379
|
+
self._cache.clear()
|
|
380
|
+
self._config_store.clear()
|
|
381
|
+
# Note: Event store is not cleared as events are immutable
|
|
382
|
+
|
|
383
|
+
def invalidate_cache(self, provider_id: Optional[str] = None) -> None:
|
|
384
|
+
"""Invalidate cache to force reload from event store."""
|
|
385
|
+
with self._lock:
|
|
386
|
+
if provider_id:
|
|
387
|
+
self._cache.pop(provider_id, None)
|
|
388
|
+
else:
|
|
389
|
+
self._cache.clear()
|
|
390
|
+
|
|
391
|
+
def get_event_history(self, provider_id: str) -> List[StoredEvent]:
|
|
392
|
+
"""
|
|
393
|
+
Get full event history for a provider.
|
|
394
|
+
|
|
395
|
+
Useful for debugging and audit.
|
|
396
|
+
"""
|
|
397
|
+
return self._event_store.load(provider_id)
|
|
398
|
+
|
|
399
|
+
def replay_provider(self, provider_id: str, to_version: int) -> Optional[EventSourcedProvider]:
|
|
400
|
+
"""
|
|
401
|
+
Replay provider to a specific version (time travel).
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
provider_id: Provider identifier
|
|
405
|
+
to_version: Target version to replay to
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Provider at the target version, or None if not found
|
|
409
|
+
"""
|
|
410
|
+
config = self._config_store.load(provider_id)
|
|
411
|
+
if not config:
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
events = self._event_store.load(provider_id, from_version=0, to_version=to_version)
|
|
415
|
+
domain_events = self._hydrate_events(events)
|
|
416
|
+
|
|
417
|
+
return EventSourcedProvider.from_events(
|
|
418
|
+
provider_id=provider_id,
|
|
419
|
+
mode=config.get("mode", "subprocess"),
|
|
420
|
+
events=domain_events,
|
|
421
|
+
command=config.get("command"),
|
|
422
|
+
image=config.get("image"),
|
|
423
|
+
endpoint=config.get("endpoint"),
|
|
424
|
+
env=config.get("env"),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# Singleton instance
|
|
429
|
+
_event_sourced_repository: Optional[EventSourcedProviderRepository] = None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def get_event_sourced_repository() -> EventSourcedProviderRepository:
|
|
433
|
+
"""Get the global event sourced repository instance."""
|
|
434
|
+
global _event_sourced_repository
|
|
435
|
+
if _event_sourced_repository is None:
|
|
436
|
+
_event_sourced_repository = EventSourcedProviderRepository()
|
|
437
|
+
return _event_sourced_repository
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def set_event_sourced_repository(repository: EventSourcedProviderRepository) -> None:
|
|
441
|
+
"""Set the global event sourced repository instance."""
|
|
442
|
+
global _event_sourced_repository
|
|
443
|
+
_event_sourced_repository = repository
|