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
mcp_hangar/progress.py ADDED
@@ -0,0 +1,542 @@
1
+ """Real-time operation feedback with progress events.
2
+
3
+ This module provides streaming progress updates for long-running
4
+ operations, giving users visibility into:
5
+
6
+ - Cold starts and provider launches
7
+ - Container initialization
8
+ - Tool discovery
9
+ - Network calls and execution
10
+
11
+ Usage with callback::
12
+
13
+ from mcp_hangar import ProgressTracker, ProgressStage
14
+
15
+ def on_progress(stage: str, message: str, elapsed_ms: float):
16
+ print(f"⏳ [{stage}] {message} ({elapsed_ms:.0f}ms)")
17
+
18
+ tracker = ProgressTracker(callback=on_progress)
19
+ tracker.report(ProgressStage.LAUNCHING, "Starting...")
20
+ tracker.complete(result)
21
+
22
+ See docs/guides/UX_IMPROVEMENTS.md for more examples.
23
+ """
24
+
25
+ import asyncio
26
+ from dataclasses import dataclass, field
27
+ from enum import Enum
28
+ import threading
29
+ import time
30
+ from typing import Any, AsyncIterator, Callable, Dict, Iterator, List, Optional
31
+
32
+ from .logging_config import get_logger
33
+
34
+ logger = get_logger(__name__)
35
+
36
+
37
+ class ProgressStage(str, Enum):
38
+ """Stages of operation progress."""
39
+
40
+ # Pre-execution stages
41
+ COLD_START = "cold_start"
42
+ LAUNCHING = "launching"
43
+ INITIALIZING = "initializing"
44
+ DISCOVERING_TOOLS = "discovering_tools"
45
+ CONNECTING = "connecting"
46
+
47
+ # Execution stages
48
+ READY = "ready"
49
+ EXECUTING = "executing"
50
+ PROCESSING = "processing"
51
+
52
+ # Completion stages
53
+ COMPLETE = "complete"
54
+ FAILED = "failed"
55
+ RETRYING = "retrying"
56
+
57
+
58
+ class EventType(str, Enum):
59
+ """Types of events in the stream."""
60
+
61
+ PROGRESS = "progress"
62
+ RESULT = "result"
63
+ ERROR = "error"
64
+
65
+
66
+ @dataclass
67
+ class ProgressEvent:
68
+ """A single progress update event.
69
+
70
+ Attributes:
71
+ type: Event type (progress, result, error)
72
+ stage: Current operation stage
73
+ message: Human-readable description
74
+ elapsed_ms: Time since operation started
75
+ details: Additional context data
76
+ timestamp: Event timestamp
77
+ """
78
+
79
+ type: EventType = EventType.PROGRESS
80
+ stage: ProgressStage = ProgressStage.EXECUTING
81
+ message: str = ""
82
+ elapsed_ms: float = 0.0
83
+ details: Dict[str, Any] = field(default_factory=dict)
84
+ timestamp: float = field(default_factory=time.time)
85
+
86
+ # For result/error events
87
+ data: Any = None
88
+ exception: Optional[Exception] = None
89
+
90
+ def to_dict(self) -> Dict[str, Any]:
91
+ """Convert to dictionary for serialization."""
92
+ result: Dict[str, Any] = {
93
+ "type": self.type.value,
94
+ "stage": self.stage.value,
95
+ "message": self.message,
96
+ "elapsed_ms": round(self.elapsed_ms, 2),
97
+ "timestamp": self.timestamp,
98
+ }
99
+ if self.details:
100
+ result["details"] = self.details
101
+ if self.data is not None:
102
+ result["data"] = self.data
103
+ if self.exception is not None:
104
+ result["error"] = str(self.exception)
105
+ return result
106
+
107
+
108
+ # Type alias for progress callback
109
+ ProgressCallback = Callable[[str, str, float], None]
110
+
111
+
112
+ class ProgressTracker:
113
+ """Tracks and reports progress for a single operation.
114
+
115
+ Can be used with callbacks or as a streaming iterator.
116
+
117
+ Usage with callback:
118
+ tracker = ProgressTracker(callback=my_callback)
119
+ tracker.report(ProgressStage.COLD_START, "Launching container...")
120
+ tracker.report(ProgressStage.READY, "Provider ready")
121
+ tracker.complete(result)
122
+
123
+ Usage as iterator:
124
+ tracker = ProgressTracker()
125
+ # In one thread/task:
126
+ tracker.report(ProgressStage.LAUNCHING, "Starting...")
127
+ tracker.complete(result)
128
+ # In another:
129
+ for event in tracker:
130
+ logger.info("progress_event", event=event.to_dict())
131
+ """
132
+
133
+ def __init__(
134
+ self,
135
+ callback: Optional[ProgressCallback] = None,
136
+ provider: str = "",
137
+ operation: str = "",
138
+ ):
139
+ """Initialize progress tracker.
140
+
141
+ Args:
142
+ callback: Optional callback for progress updates
143
+ provider: Provider name for context
144
+ operation: Operation name for context
145
+ """
146
+ self._callback = callback
147
+ self._provider = provider
148
+ self._operation = operation
149
+ self._start_time = time.time()
150
+ self._events: List[ProgressEvent] = []
151
+ self._completed = False
152
+ self._result: Any = None
153
+ self._error: Optional[Exception] = None
154
+
155
+ # For iterator support
156
+ self._event_queue: asyncio.Queue = asyncio.Queue()
157
+ self._sync_queue: List[ProgressEvent] = []
158
+ self._lock = threading.Lock()
159
+ self._done = threading.Event()
160
+
161
+ @property
162
+ def elapsed_ms(self) -> float:
163
+ """Time elapsed since operation started."""
164
+ return (time.time() - self._start_time) * 1000
165
+
166
+ def report(
167
+ self,
168
+ stage: ProgressStage,
169
+ message: str,
170
+ details: Optional[Dict[str, Any]] = None,
171
+ ) -> None:
172
+ """Report a progress update.
173
+
174
+ Args:
175
+ stage: Current progress stage
176
+ message: Human-readable description
177
+ details: Additional context
178
+ """
179
+ elapsed = self.elapsed_ms
180
+ event = ProgressEvent(
181
+ type=EventType.PROGRESS,
182
+ stage=stage,
183
+ message=message,
184
+ elapsed_ms=elapsed,
185
+ details=details or {},
186
+ )
187
+
188
+ with self._lock:
189
+ self._events.append(event)
190
+ self._sync_queue.append(event)
191
+
192
+ # Try to put in async queue (non-blocking)
193
+ try:
194
+ self._event_queue.put_nowait(event)
195
+ except asyncio.QueueFull:
196
+ pass
197
+
198
+ # Invoke callback if set
199
+ if self._callback:
200
+ try:
201
+ self._callback(stage.value, message, elapsed)
202
+ except Exception as e:
203
+ logger.debug("progress_callback_error", error=str(e))
204
+
205
+ logger.debug(
206
+ "progress_reported",
207
+ provider=self._provider,
208
+ operation=self._operation,
209
+ stage=stage.value,
210
+ message=message,
211
+ elapsed_ms=elapsed,
212
+ )
213
+
214
+ def complete(self, result: Any) -> None:
215
+ """Mark operation as complete with result.
216
+
217
+ Args:
218
+ result: The operation result
219
+ """
220
+ elapsed = self.elapsed_ms
221
+ event = ProgressEvent(
222
+ type=EventType.RESULT,
223
+ stage=ProgressStage.COMPLETE,
224
+ message=f"Operation completed (total: {elapsed:.0f}ms)",
225
+ elapsed_ms=elapsed,
226
+ data=result,
227
+ )
228
+
229
+ with self._lock:
230
+ self._events.append(event)
231
+ self._sync_queue.append(event)
232
+ self._completed = True
233
+ self._result = result
234
+
235
+ try:
236
+ self._event_queue.put_nowait(event)
237
+ except asyncio.QueueFull:
238
+ pass
239
+
240
+ self._done.set()
241
+
242
+ if self._callback:
243
+ try:
244
+ self._callback(ProgressStage.COMPLETE.value, event.message, elapsed)
245
+ except Exception:
246
+ pass
247
+
248
+ logger.debug(
249
+ "progress_complete",
250
+ provider=self._provider,
251
+ operation=self._operation,
252
+ elapsed_ms=elapsed,
253
+ )
254
+
255
+ def fail(self, error: Exception) -> None:
256
+ """Mark operation as failed with error.
257
+
258
+ Args:
259
+ error: The exception that caused failure
260
+ """
261
+ elapsed = self.elapsed_ms
262
+ event = ProgressEvent(
263
+ type=EventType.ERROR,
264
+ stage=ProgressStage.FAILED,
265
+ message=f"Operation failed: {str(error)[:100]}",
266
+ elapsed_ms=elapsed,
267
+ exception=error,
268
+ )
269
+
270
+ with self._lock:
271
+ self._events.append(event)
272
+ self._sync_queue.append(event)
273
+ self._completed = True
274
+ self._error = error
275
+
276
+ try:
277
+ self._event_queue.put_nowait(event)
278
+ except asyncio.QueueFull:
279
+ pass
280
+
281
+ self._done.set()
282
+
283
+ if self._callback:
284
+ try:
285
+ self._callback(ProgressStage.FAILED.value, event.message, elapsed)
286
+ except (TypeError, ValueError, RuntimeError) as e:
287
+ logger.debug("progress_callback_error", stage="failed", error=str(e))
288
+
289
+ logger.debug(
290
+ "progress_failed",
291
+ provider=self._provider,
292
+ operation=self._operation,
293
+ error=str(error)[:200],
294
+ elapsed_ms=elapsed,
295
+ )
296
+
297
+ def __iter__(self) -> Iterator[ProgressEvent]:
298
+ """Iterate over events synchronously."""
299
+ while True:
300
+ with self._lock:
301
+ if self._sync_queue:
302
+ event = self._sync_queue.pop(0)
303
+ yield event
304
+ if event.type in (EventType.RESULT, EventType.ERROR):
305
+ return
306
+ elif self._completed:
307
+ return
308
+
309
+ # Wait a bit before checking again
310
+ if not self._done.wait(timeout=0.01):
311
+ continue
312
+
313
+ async def __aiter__(self) -> AsyncIterator[ProgressEvent]:
314
+ """Iterate over events asynchronously."""
315
+ while True:
316
+ try:
317
+ event = await asyncio.wait_for(
318
+ self._event_queue.get(),
319
+ timeout=0.1,
320
+ )
321
+ yield event
322
+ if event.type in (EventType.RESULT, EventType.ERROR):
323
+ return
324
+ except asyncio.TimeoutError:
325
+ if self._completed:
326
+ return
327
+
328
+ def get_all_events(self) -> List[ProgressEvent]:
329
+ """Get all recorded events."""
330
+ with self._lock:
331
+ return list(self._events)
332
+
333
+
334
+ # =============================================================================
335
+ # Progress-Aware Operation Wrapper
336
+ # =============================================================================
337
+
338
+
339
+ class ProgressOperation:
340
+ """Context manager for progress-tracked operations.
341
+
342
+ Usage:
343
+ async with ProgressOperation("math", "add", callback=my_cb) as progress:
344
+ progress.report(ProgressStage.LAUNCHING, "Starting provider...")
345
+ result = await do_work()
346
+ progress.complete(result)
347
+ """
348
+
349
+ def __init__(
350
+ self,
351
+ provider: str,
352
+ operation: str,
353
+ callback: Optional[ProgressCallback] = None,
354
+ ):
355
+ self.provider = provider
356
+ self.operation = operation
357
+ self.tracker = ProgressTracker(
358
+ callback=callback,
359
+ provider=provider,
360
+ operation=operation,
361
+ )
362
+
363
+ def __enter__(self) -> ProgressTracker:
364
+ return self.tracker
365
+
366
+ def __exit__(self, exc_type, exc_val, exc_tb):
367
+ if exc_val is not None and not self.tracker._completed:
368
+ self.tracker.fail(exc_val)
369
+ return False
370
+
371
+ async def __aenter__(self) -> ProgressTracker:
372
+ return self.tracker
373
+
374
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
375
+ if exc_val is not None and not self.tracker._completed:
376
+ self.tracker.fail(exc_val)
377
+ return False
378
+
379
+
380
+ # =============================================================================
381
+ # Standard Progress Messages
382
+ # =============================================================================
383
+
384
+ # Pre-defined messages for common operations
385
+ PROGRESS_MESSAGES = {
386
+ ProgressStage.COLD_START: "Provider is cold, launching...",
387
+ ProgressStage.LAUNCHING: "Starting {mode} provider...",
388
+ ProgressStage.INITIALIZING: "Container started (PID: {pid}), initializing...",
389
+ ProgressStage.DISCOVERING_TOOLS: "Discovering available tools...",
390
+ ProgressStage.CONNECTING: "Connecting to provider...",
391
+ ProgressStage.READY: "Provider ready, executing request...",
392
+ ProgressStage.EXECUTING: "Calling tool '{tool}'...",
393
+ ProgressStage.PROCESSING: "Processing response...",
394
+ ProgressStage.COMPLETE: "Operation completed (total: {elapsed_ms:.0f}ms)",
395
+ ProgressStage.FAILED: "Operation failed: {error}",
396
+ ProgressStage.RETRYING: "Retrying (attempt {attempt}/{max_attempts})...",
397
+ }
398
+
399
+
400
+ def get_stage_message(
401
+ stage: ProgressStage,
402
+ **kwargs,
403
+ ) -> str:
404
+ """Get formatted message for a progress stage.
405
+
406
+ Args:
407
+ stage: The progress stage
408
+ **kwargs: Format arguments for message template
409
+
410
+ Returns:
411
+ Formatted progress message
412
+ """
413
+ template = PROGRESS_MESSAGES.get(stage, stage.value)
414
+ try:
415
+ return template.format(**kwargs)
416
+ except KeyError:
417
+ return template
418
+
419
+
420
+ # =============================================================================
421
+ # Event Bus Integration
422
+ # =============================================================================
423
+
424
+
425
+ class ProgressEventHandler:
426
+ """Event handler that forwards domain events to progress tracker.
427
+
428
+ This bridges the gap between domain events and progress reporting,
429
+ allowing existing code to emit progress updates without modification.
430
+ """
431
+
432
+ # Map domain event types to progress stages
433
+ EVENT_STAGE_MAP = {
434
+ "ProviderStarted": ProgressStage.READY,
435
+ "ProviderStopped": ProgressStage.COMPLETE,
436
+ "ProviderStateChanged": ProgressStage.INITIALIZING,
437
+ "ToolInvocationRequested": ProgressStage.EXECUTING,
438
+ "ToolInvocationCompleted": ProgressStage.COMPLETE,
439
+ "ToolInvocationFailed": ProgressStage.FAILED,
440
+ }
441
+
442
+ def __init__(self):
443
+ self._trackers: Dict[str, ProgressTracker] = {}
444
+ self._lock = threading.Lock()
445
+
446
+ def register_tracker(self, correlation_id: str, tracker: ProgressTracker) -> None:
447
+ """Register a tracker for a correlation ID."""
448
+ with self._lock:
449
+ self._trackers[correlation_id] = tracker
450
+
451
+ def unregister_tracker(self, correlation_id: str) -> None:
452
+ """Unregister a tracker."""
453
+ with self._lock:
454
+ self._trackers.pop(correlation_id, None)
455
+
456
+ def handle(self, event: Any) -> None:
457
+ """Handle a domain event and forward to appropriate tracker."""
458
+ event_type = type(event).__name__
459
+ stage = self.EVENT_STAGE_MAP.get(event_type)
460
+
461
+ if not stage:
462
+ return
463
+
464
+ # Try to find correlation ID
465
+ correlation_id = getattr(event, "correlation_id", None)
466
+ if not correlation_id:
467
+ return
468
+
469
+ with self._lock:
470
+ tracker = self._trackers.get(correlation_id)
471
+
472
+ if tracker:
473
+ message = self._format_event_message(event, event_type)
474
+ tracker.report(stage, message, self._extract_details(event))
475
+
476
+ def _format_event_message(self, event: Any, event_type: str) -> str:
477
+ """Format event into progress message."""
478
+ if event_type == "ProviderStarted":
479
+ return f"Provider started ({getattr(event, 'tools_count', 0)} tools)"
480
+ elif event_type == "ProviderStateChanged":
481
+ new_state = getattr(event, "new_state", "unknown")
482
+ return f"Provider state: {new_state}"
483
+ elif event_type == "ToolInvocationRequested":
484
+ tool = getattr(event, "tool_name", "unknown")
485
+ return f"Invoking tool: {tool}"
486
+ elif event_type == "ToolInvocationCompleted":
487
+ duration = getattr(event, "duration_ms", 0)
488
+ return f"Tool completed ({duration:.0f}ms)"
489
+ elif event_type == "ToolInvocationFailed":
490
+ error = getattr(event, "error_message", "unknown error")
491
+ return f"Tool failed: {error[:50]}"
492
+ return event_type
493
+
494
+ def _extract_details(self, event: Any) -> Dict[str, Any]:
495
+ """Extract relevant details from event."""
496
+ details = {}
497
+ for attr in ["provider_id", "tool_name", "duration_ms", "error_type"]:
498
+ if hasattr(event, attr):
499
+ details[attr] = getattr(event, attr)
500
+ return details
501
+
502
+
503
+ # Global progress event handler instance
504
+ _progress_handler: Optional[ProgressEventHandler] = None
505
+
506
+
507
+ def get_progress_handler() -> ProgressEventHandler:
508
+ """Get or create the global progress event handler."""
509
+ global _progress_handler
510
+ if _progress_handler is None:
511
+ _progress_handler = ProgressEventHandler()
512
+ return _progress_handler
513
+
514
+
515
+ def create_progress_tracker(
516
+ provider: str = "",
517
+ operation: str = "",
518
+ callback: Optional[ProgressCallback] = None,
519
+ correlation_id: Optional[str] = None,
520
+ ) -> ProgressTracker:
521
+ """Create a progress tracker with optional event bus integration.
522
+
523
+ Args:
524
+ provider: Provider name
525
+ operation: Operation name
526
+ callback: Optional progress callback
527
+ correlation_id: Optional correlation ID for event bus integration
528
+
529
+ Returns:
530
+ New ProgressTracker instance
531
+ """
532
+ tracker = ProgressTracker(
533
+ callback=callback,
534
+ provider=provider,
535
+ operation=operation,
536
+ )
537
+
538
+ if correlation_id:
539
+ handler = get_progress_handler()
540
+ handler.register_tracker(correlation_id, tracker)
541
+
542
+ return tracker