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.
Files changed (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. 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