claude-mpm 4.0.34__py3-none-any.whl → 4.1.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.
- claude_mpm/VERSION +1 -1
- claude_mpm/agents/INSTRUCTIONS.md +70 -2
- claude_mpm/agents/OUTPUT_STYLE.md +0 -11
- claude_mpm/agents/WORKFLOW.md +14 -2
- claude_mpm/cli/__init__.py +48 -7
- claude_mpm/cli/commands/agents.py +82 -0
- claude_mpm/cli/commands/cleanup_orphaned_agents.py +150 -0
- claude_mpm/cli/commands/mcp_pipx_config.py +199 -0
- claude_mpm/cli/parsers/agents_parser.py +27 -0
- claude_mpm/cli/parsers/base_parser.py +6 -0
- claude_mpm/cli/startup_logging.py +75 -0
- claude_mpm/dashboard/static/js/components/build-tracker.js +35 -1
- claude_mpm/dashboard/static/js/socket-client.js +7 -5
- claude_mpm/hooks/claude_hooks/connection_pool.py +13 -2
- claude_mpm/hooks/claude_hooks/hook_handler.py +67 -167
- claude_mpm/services/agents/deployment/agent_discovery_service.py +4 -1
- claude_mpm/services/agents/deployment/agent_template_builder.py +2 -1
- claude_mpm/services/agents/deployment/agent_version_manager.py +4 -1
- claude_mpm/services/agents/deployment/multi_source_deployment_service.py +207 -10
- claude_mpm/services/event_bus/config.py +165 -0
- claude_mpm/services/event_bus/event_bus.py +35 -20
- claude_mpm/services/event_bus/relay.py +8 -12
- claude_mpm/services/mcp_gateway/auto_configure.py +372 -0
- claude_mpm/services/socketio/handlers/connection.py +3 -3
- claude_mpm/services/socketio/server/core.py +25 -2
- claude_mpm/services/socketio/server/eventbus_integration.py +189 -0
- claude_mpm/services/socketio/server/main.py +25 -0
- {claude_mpm-4.0.34.dist-info → claude_mpm-4.1.0.dist-info}/METADATA +25 -7
- {claude_mpm-4.0.34.dist-info → claude_mpm-4.1.0.dist-info}/RECORD +33 -28
- {claude_mpm-4.0.34.dist-info → claude_mpm-4.1.0.dist-info}/WHEEL +0 -0
- {claude_mpm-4.0.34.dist-info → claude_mpm-4.1.0.dist-info}/entry_points.txt +0 -0
- {claude_mpm-4.0.34.dist-info → claude_mpm-4.1.0.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-4.0.34.dist-info → claude_mpm-4.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Gateway Auto-Configuration Service
|
|
3
|
+
======================================
|
|
4
|
+
|
|
5
|
+
Provides automatic MCP configuration for pipx installations with user consent.
|
|
6
|
+
Detects unconfigured MCP setups and offers one-time configuration prompts.
|
|
7
|
+
|
|
8
|
+
WHY: Users installing via pipx should have MCP work out-of-the-box with minimal
|
|
9
|
+
friction. This service detects unconfigured installations and offers automatic
|
|
10
|
+
setup with user consent.
|
|
11
|
+
|
|
12
|
+
DESIGN DECISIONS:
|
|
13
|
+
- Only prompts once (saves preference to avoid repeated prompts)
|
|
14
|
+
- Quick timeout with safe default (no configuration)
|
|
15
|
+
- Non-intrusive with environment variable override
|
|
16
|
+
- Creates backups before modifying any configuration
|
|
17
|
+
- Validates JSON before and after modifications
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional, Dict, Any, Tuple
|
|
27
|
+
|
|
28
|
+
from claude_mpm.core.logger import get_logger
|
|
29
|
+
from claude_mpm.config.paths import paths
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MCPAutoConfigurator:
|
|
33
|
+
"""
|
|
34
|
+
Handles automatic MCP configuration for pipx installations.
|
|
35
|
+
|
|
36
|
+
Provides a one-time prompt to configure MCP Gateway with user consent,
|
|
37
|
+
making the experience seamless for pipx users while respecting choice.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self):
|
|
41
|
+
"""Initialize the auto-configurator."""
|
|
42
|
+
self.logger = get_logger("MCPAutoConfig")
|
|
43
|
+
self.config_dir = paths.claude_mpm_dir_hidden
|
|
44
|
+
self.preference_file = self.config_dir / "mcp_auto_config_preference.json"
|
|
45
|
+
self.claude_config_path = Path.home() / ".claude.json"
|
|
46
|
+
|
|
47
|
+
def should_auto_configure(self) -> bool:
|
|
48
|
+
"""
|
|
49
|
+
Check if auto-configuration should be attempted.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
True if auto-configuration should be offered, False otherwise
|
|
53
|
+
"""
|
|
54
|
+
# Check environment variable override
|
|
55
|
+
if os.environ.get("CLAUDE_MPM_NO_AUTO_CONFIG"):
|
|
56
|
+
self.logger.debug("Auto-configuration disabled via environment variable")
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
# Check if already configured
|
|
60
|
+
if self._is_mcp_configured():
|
|
61
|
+
self.logger.debug("MCP already configured")
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
# Check if this is a pipx installation
|
|
65
|
+
if not self._is_pipx_installation():
|
|
66
|
+
self.logger.debug("Not a pipx installation")
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
# Check if we've already asked
|
|
70
|
+
if self._has_user_preference():
|
|
71
|
+
self.logger.debug("User preference already saved")
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
def _is_mcp_configured(self) -> bool:
|
|
77
|
+
"""Check if MCP is already configured in Claude Code."""
|
|
78
|
+
if not self.claude_config_path.exists():
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
with open(self.claude_config_path, 'r') as f:
|
|
83
|
+
config = json.load(f)
|
|
84
|
+
|
|
85
|
+
# Check if claude-mpm-gateway is configured
|
|
86
|
+
mcp_servers = config.get("mcpServers", {})
|
|
87
|
+
return "claude-mpm-gateway" in mcp_servers
|
|
88
|
+
|
|
89
|
+
except (json.JSONDecodeError, IOError):
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def _is_pipx_installation(self) -> bool:
|
|
93
|
+
"""Check if claude-mpm is installed via pipx."""
|
|
94
|
+
# Check if running from pipx virtual environment
|
|
95
|
+
if "pipx" in sys.executable.lower():
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
# Check module path
|
|
99
|
+
try:
|
|
100
|
+
import claude_mpm
|
|
101
|
+
module_path = Path(claude_mpm.__file__).parent
|
|
102
|
+
if "pipx" in str(module_path):
|
|
103
|
+
return True
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
# Check for pipx in PATH for claude-mpm command
|
|
108
|
+
try:
|
|
109
|
+
import subprocess
|
|
110
|
+
import platform
|
|
111
|
+
|
|
112
|
+
# Use appropriate command for OS
|
|
113
|
+
if platform.system() == "Windows":
|
|
114
|
+
cmd = ["where", "claude-mpm"]
|
|
115
|
+
else:
|
|
116
|
+
cmd = ["which", "claude-mpm"]
|
|
117
|
+
|
|
118
|
+
result = subprocess.run(
|
|
119
|
+
cmd,
|
|
120
|
+
capture_output=True,
|
|
121
|
+
text=True,
|
|
122
|
+
timeout=2
|
|
123
|
+
)
|
|
124
|
+
if result.returncode == 0 and "pipx" in result.stdout:
|
|
125
|
+
return True
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def _has_user_preference(self) -> bool:
|
|
132
|
+
"""Check if user has already been asked about auto-configuration."""
|
|
133
|
+
if not self.preference_file.exists():
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
with open(self.preference_file, 'r') as f:
|
|
138
|
+
prefs = json.load(f)
|
|
139
|
+
return prefs.get("asked", False)
|
|
140
|
+
except (json.JSONDecodeError, IOError):
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
def _save_user_preference(self, choice: str):
|
|
144
|
+
"""Save user's preference to avoid asking again."""
|
|
145
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
|
|
147
|
+
prefs = {
|
|
148
|
+
"asked": True,
|
|
149
|
+
"choice": choice,
|
|
150
|
+
"timestamp": datetime.now().isoformat()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
with open(self.preference_file, 'w') as f:
|
|
155
|
+
json.dump(prefs, f, indent=2)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
self.logger.debug(f"Could not save preference: {e}")
|
|
158
|
+
|
|
159
|
+
def prompt_user(self, timeout: int = 10) -> Optional[bool]:
|
|
160
|
+
"""
|
|
161
|
+
Prompt user for auto-configuration with timeout.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
timeout: Seconds to wait for response (default 10)
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
True if user agrees, False if declines, None if timeout
|
|
168
|
+
"""
|
|
169
|
+
print("\n" + "="*60)
|
|
170
|
+
print("🔧 MCP Gateway Configuration")
|
|
171
|
+
print("="*60)
|
|
172
|
+
print("\nClaude MPM can automatically configure MCP Gateway for")
|
|
173
|
+
print("Claude Code integration. This enables advanced features:")
|
|
174
|
+
print(" • File analysis and summarization")
|
|
175
|
+
print(" • System diagnostics")
|
|
176
|
+
print(" • Ticket management")
|
|
177
|
+
print(" • And more...")
|
|
178
|
+
print("\nWould you like to configure it now? (y/n)")
|
|
179
|
+
print(f"(Auto-declining in {timeout} seconds)")
|
|
180
|
+
|
|
181
|
+
# Use threading for cross-platform timeout support
|
|
182
|
+
import threading
|
|
183
|
+
try:
|
|
184
|
+
# Python 3.7+ has queue built-in
|
|
185
|
+
import queue
|
|
186
|
+
except ImportError:
|
|
187
|
+
# Python 2.x fallback
|
|
188
|
+
import Queue as queue
|
|
189
|
+
|
|
190
|
+
user_input = None
|
|
191
|
+
|
|
192
|
+
def get_input():
|
|
193
|
+
nonlocal user_input
|
|
194
|
+
try:
|
|
195
|
+
user_input = input("> ").strip().lower()
|
|
196
|
+
except (EOFError, KeyboardInterrupt):
|
|
197
|
+
user_input = 'n'
|
|
198
|
+
|
|
199
|
+
# Start input thread
|
|
200
|
+
input_thread = threading.Thread(target=get_input)
|
|
201
|
+
input_thread.daemon = True
|
|
202
|
+
input_thread.start()
|
|
203
|
+
|
|
204
|
+
# Wait for input or timeout
|
|
205
|
+
input_thread.join(timeout)
|
|
206
|
+
|
|
207
|
+
if input_thread.is_alive():
|
|
208
|
+
# Timed out
|
|
209
|
+
print("\n(Timed out - declining)")
|
|
210
|
+
return None
|
|
211
|
+
else:
|
|
212
|
+
# Got input
|
|
213
|
+
if user_input in ['y', 'yes']:
|
|
214
|
+
return True
|
|
215
|
+
else:
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
def auto_configure(self) -> bool:
|
|
219
|
+
"""
|
|
220
|
+
Perform automatic MCP configuration.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
True if configuration successful, False otherwise
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
# Create backup if config exists
|
|
227
|
+
if self.claude_config_path.exists():
|
|
228
|
+
backup_path = self._create_backup()
|
|
229
|
+
if backup_path:
|
|
230
|
+
print(f"✅ Backup created: {backup_path}")
|
|
231
|
+
|
|
232
|
+
# Load or create configuration
|
|
233
|
+
config = self._load_or_create_config()
|
|
234
|
+
|
|
235
|
+
# Add MCP Gateway configuration
|
|
236
|
+
if "mcpServers" not in config:
|
|
237
|
+
config["mcpServers"] = {}
|
|
238
|
+
|
|
239
|
+
# Find claude-mpm executable
|
|
240
|
+
executable = self._find_claude_mpm_executable()
|
|
241
|
+
if not executable:
|
|
242
|
+
print("❌ Could not find claude-mpm executable")
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
# Configure MCP server
|
|
246
|
+
config["mcpServers"]["claude-mpm-gateway"] = {
|
|
247
|
+
"command": str(executable),
|
|
248
|
+
"args": ["mcp", "server"],
|
|
249
|
+
"env": {
|
|
250
|
+
"MCP_MODE": "production"
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# Save configuration
|
|
255
|
+
with open(self.claude_config_path, 'w') as f:
|
|
256
|
+
json.dump(config, f, indent=2)
|
|
257
|
+
|
|
258
|
+
print(f"✅ Configuration saved to: {self.claude_config_path}")
|
|
259
|
+
print("\n🎉 MCP Gateway configured successfully!")
|
|
260
|
+
print("\nNext steps:")
|
|
261
|
+
print("1. Restart Claude Code (if running)")
|
|
262
|
+
print("2. Look for the MCP icon in the interface")
|
|
263
|
+
print("3. Try @claude-mpm-gateway in a conversation")
|
|
264
|
+
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
except Exception as e:
|
|
268
|
+
self.logger.error(f"Auto-configuration failed: {e}")
|
|
269
|
+
print(f"❌ Configuration failed: {e}")
|
|
270
|
+
print("\nYou can configure manually with:")
|
|
271
|
+
print(" claude-mpm mcp install")
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
def _create_backup(self) -> Optional[Path]:
|
|
275
|
+
"""Create backup of existing configuration."""
|
|
276
|
+
try:
|
|
277
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
278
|
+
backup_path = self.claude_config_path.with_suffix(f'.backup.{timestamp}.json')
|
|
279
|
+
|
|
280
|
+
import shutil
|
|
281
|
+
shutil.copy2(self.claude_config_path, backup_path)
|
|
282
|
+
return backup_path
|
|
283
|
+
|
|
284
|
+
except Exception as e:
|
|
285
|
+
self.logger.debug(f"Could not create backup: {e}")
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
def _load_or_create_config(self) -> Dict[str, Any]:
|
|
289
|
+
"""Load existing config or create new one."""
|
|
290
|
+
if self.claude_config_path.exists():
|
|
291
|
+
try:
|
|
292
|
+
with open(self.claude_config_path, 'r') as f:
|
|
293
|
+
return json.load(f)
|
|
294
|
+
except json.JSONDecodeError:
|
|
295
|
+
self.logger.warning("Existing config is invalid JSON, creating new")
|
|
296
|
+
|
|
297
|
+
return {}
|
|
298
|
+
|
|
299
|
+
def _find_claude_mpm_executable(self) -> Optional[str]:
|
|
300
|
+
"""Find the claude-mpm executable path."""
|
|
301
|
+
# Try direct command first
|
|
302
|
+
import subprocess
|
|
303
|
+
import platform
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
# Use appropriate command for OS
|
|
307
|
+
if platform.system() == "Windows":
|
|
308
|
+
cmd = ["where", "claude-mpm"]
|
|
309
|
+
else:
|
|
310
|
+
cmd = ["which", "claude-mpm"]
|
|
311
|
+
|
|
312
|
+
result = subprocess.run(
|
|
313
|
+
cmd,
|
|
314
|
+
capture_output=True,
|
|
315
|
+
text=True,
|
|
316
|
+
timeout=2
|
|
317
|
+
)
|
|
318
|
+
if result.returncode == 0:
|
|
319
|
+
executable_path = result.stdout.strip()
|
|
320
|
+
# On Windows, 'where' might return multiple paths
|
|
321
|
+
if platform.system() == "Windows" and '\n' in executable_path:
|
|
322
|
+
executable_path = executable_path.split('\n')[0]
|
|
323
|
+
return executable_path
|
|
324
|
+
except Exception:
|
|
325
|
+
pass
|
|
326
|
+
|
|
327
|
+
# Try to find via shutil.which (more portable)
|
|
328
|
+
import shutil
|
|
329
|
+
claude_mpm_path = shutil.which("claude-mpm")
|
|
330
|
+
if claude_mpm_path:
|
|
331
|
+
return claude_mpm_path
|
|
332
|
+
|
|
333
|
+
# Fallback to Python module invocation
|
|
334
|
+
return sys.executable
|
|
335
|
+
|
|
336
|
+
def run(self) -> bool:
|
|
337
|
+
"""
|
|
338
|
+
Main entry point for auto-configuration.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
True if configured (or already configured), False otherwise
|
|
342
|
+
"""
|
|
343
|
+
if not self.should_auto_configure():
|
|
344
|
+
return True # Already configured or not applicable
|
|
345
|
+
|
|
346
|
+
# Prompt user
|
|
347
|
+
user_choice = self.prompt_user()
|
|
348
|
+
|
|
349
|
+
# Save preference to not ask again
|
|
350
|
+
self._save_user_preference("yes" if user_choice else "no")
|
|
351
|
+
|
|
352
|
+
if user_choice:
|
|
353
|
+
return self.auto_configure()
|
|
354
|
+
else:
|
|
355
|
+
if user_choice is False: # User explicitly said no
|
|
356
|
+
print("\n📝 You can configure MCP later with:")
|
|
357
|
+
print(" claude-mpm mcp install")
|
|
358
|
+
# If timeout (None), don't show additional message
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def check_and_configure_mcp() -> bool:
|
|
363
|
+
"""
|
|
364
|
+
Check and potentially configure MCP for pipx installations.
|
|
365
|
+
|
|
366
|
+
This is the main entry point called during CLI initialization.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
True if MCP is configured (or configuration was successful), False otherwise
|
|
370
|
+
"""
|
|
371
|
+
configurator = MCPAutoConfigurator()
|
|
372
|
+
return configurator.run()
|
|
@@ -99,9 +99,9 @@ class ConnectionEventHandler(BaseEventHandler):
|
|
|
99
99
|
# Connection health tracking
|
|
100
100
|
self.connection_metrics = {}
|
|
101
101
|
self.last_ping_times = {}
|
|
102
|
-
self.ping_interval =
|
|
103
|
-
self.ping_timeout =
|
|
104
|
-
self.stale_check_interval =
|
|
102
|
+
self.ping_interval = 45 # seconds - avoid conflict with Engine.IO pings
|
|
103
|
+
self.ping_timeout = 20 # seconds - more lenient timeout
|
|
104
|
+
self.stale_check_interval = 90 # seconds - less frequent checks
|
|
105
105
|
|
|
106
106
|
# Health monitoring tasks (will be started after event registration)
|
|
107
107
|
self.ping_task = None
|
|
@@ -31,6 +31,9 @@ except ImportError:
|
|
|
31
31
|
aiohttp = None
|
|
32
32
|
web = None
|
|
33
33
|
|
|
34
|
+
# Import VersionService for dynamic version retrieval
|
|
35
|
+
from claude_mpm.services.version_service import VersionService
|
|
36
|
+
|
|
34
37
|
from ....core.constants import (
|
|
35
38
|
NetworkConfig,
|
|
36
39
|
PerformanceConfig,
|
|
@@ -158,11 +161,14 @@ class SocketIOServerCore:
|
|
|
158
161
|
async def _start_server(self):
|
|
159
162
|
"""Start the Socket.IO server with aiohttp."""
|
|
160
163
|
try:
|
|
161
|
-
# Create Socket.IO server
|
|
164
|
+
# Create Socket.IO server with proper ping/pong configuration
|
|
162
165
|
self.sio = socketio.AsyncServer(
|
|
163
166
|
cors_allowed_origins="*",
|
|
164
167
|
logger=False, # Disable Socket.IO's own logging
|
|
165
168
|
engineio_logger=False,
|
|
169
|
+
ping_interval=25, # Send ping every 25 seconds
|
|
170
|
+
ping_timeout=60, # Wait 60 seconds for pong response
|
|
171
|
+
max_http_buffer_size=1e8, # 100MB max buffer
|
|
166
172
|
)
|
|
167
173
|
|
|
168
174
|
# Create aiohttp application
|
|
@@ -255,6 +261,23 @@ class SocketIOServerCore:
|
|
|
255
261
|
return web.Response(text="Dashboard not available", status=404)
|
|
256
262
|
|
|
257
263
|
self.app.router.add_get("/", index_handler)
|
|
264
|
+
|
|
265
|
+
# Serve version.json from dashboard directory
|
|
266
|
+
async def version_handler(request):
|
|
267
|
+
version_file = self.dashboard_path / "version.json"
|
|
268
|
+
if version_file.exists():
|
|
269
|
+
self.logger.debug(f"Serving version.json from: {version_file}")
|
|
270
|
+
return web.FileResponse(version_file)
|
|
271
|
+
else:
|
|
272
|
+
# Return default version info if file doesn't exist
|
|
273
|
+
return web.json_response({
|
|
274
|
+
"version": "1.0.0",
|
|
275
|
+
"build": 1,
|
|
276
|
+
"formatted_build": "0001",
|
|
277
|
+
"full_version": "v1.0.0-0001"
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
self.app.router.add_get("/version.json", version_handler)
|
|
258
281
|
|
|
259
282
|
# Serve static assets (CSS, JS) from the dashboard static directory
|
|
260
283
|
dashboard_static_path = (
|
|
@@ -426,7 +449,7 @@ class SocketIOServerCore:
|
|
|
426
449
|
"total_events": self.stats.get("events_sent", 0),
|
|
427
450
|
"active_sessions": active_sessions,
|
|
428
451
|
"server_info": {
|
|
429
|
-
"version":
|
|
452
|
+
"version": VersionService().get_version(),
|
|
430
453
|
"port": self.port,
|
|
431
454
|
},
|
|
432
455
|
},
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""EventBus integration for Socket.IO server.
|
|
2
|
+
|
|
3
|
+
WHY this integration module:
|
|
4
|
+
- Adds EventBus relay to Socket.IO server without modifying core
|
|
5
|
+
- Maintains backward compatibility with existing server
|
|
6
|
+
- Easy to enable/disable via configuration
|
|
7
|
+
- Provides clean separation of concerns
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from claude_mpm.services.event_bus import EventBus, SocketIORelay
|
|
15
|
+
from claude_mpm.services.event_bus.config import get_config
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EventBusIntegration:
|
|
21
|
+
"""Integrates EventBus relay with Socket.IO server.
|
|
22
|
+
|
|
23
|
+
WHY integration class:
|
|
24
|
+
- Encapsulates EventBus setup and teardown
|
|
25
|
+
- Provides lifecycle management for relay
|
|
26
|
+
- Handles configuration and error cases
|
|
27
|
+
- Can be easily added to existing server
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, server_instance=None):
|
|
31
|
+
"""Initialize EventBus integration.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
server_instance: Optional Socket.IO server instance
|
|
35
|
+
"""
|
|
36
|
+
self.server = server_instance
|
|
37
|
+
self.relay: Optional[SocketIORelay] = None
|
|
38
|
+
self.event_bus: Optional[EventBus] = None
|
|
39
|
+
self.config = get_config()
|
|
40
|
+
self.enabled = self.config.enabled and self.config.relay_enabled
|
|
41
|
+
|
|
42
|
+
def setup(self, port: Optional[int] = None) -> bool:
|
|
43
|
+
"""Set up EventBus and relay.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
port: Optional Socket.IO server port
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
bool: True if setup successful
|
|
50
|
+
"""
|
|
51
|
+
if not self.enabled:
|
|
52
|
+
logger.info("EventBus integration disabled by configuration")
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
# Get EventBus instance
|
|
57
|
+
self.event_bus = EventBus.get_instance()
|
|
58
|
+
|
|
59
|
+
# Apply configuration
|
|
60
|
+
self.config.apply_to_eventbus(self.event_bus)
|
|
61
|
+
|
|
62
|
+
# Create and configure relay
|
|
63
|
+
relay_port = port or self.config.relay_port
|
|
64
|
+
self.relay = SocketIORelay(relay_port)
|
|
65
|
+
self.config.apply_to_relay(self.relay)
|
|
66
|
+
|
|
67
|
+
# Start the relay
|
|
68
|
+
self.relay.start()
|
|
69
|
+
|
|
70
|
+
logger.info(f"EventBus integration setup complete (port: {relay_port})")
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.error(f"Failed to setup EventBus integration: {e}")
|
|
75
|
+
self.enabled = False
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
def teardown(self) -> None:
|
|
79
|
+
"""Tear down EventBus integration."""
|
|
80
|
+
if self.relay:
|
|
81
|
+
try:
|
|
82
|
+
self.relay.stop()
|
|
83
|
+
logger.info("EventBus relay stopped")
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(f"Error stopping relay: {e}")
|
|
86
|
+
finally:
|
|
87
|
+
self.relay = None
|
|
88
|
+
|
|
89
|
+
def is_active(self) -> bool:
|
|
90
|
+
"""Check if integration is active.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
bool: True if relay is active and connected
|
|
94
|
+
"""
|
|
95
|
+
return (
|
|
96
|
+
self.enabled and
|
|
97
|
+
self.relay is not None and
|
|
98
|
+
self.relay.enabled
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def get_stats(self) -> dict:
|
|
102
|
+
"""Get integration statistics.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
dict: Combined stats from EventBus and relay
|
|
106
|
+
"""
|
|
107
|
+
stats = {
|
|
108
|
+
"enabled": self.enabled,
|
|
109
|
+
"active": self.is_active()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if self.event_bus:
|
|
113
|
+
stats["eventbus"] = self.event_bus.get_stats()
|
|
114
|
+
|
|
115
|
+
if self.relay:
|
|
116
|
+
stats["relay"] = self.relay.get_stats()
|
|
117
|
+
|
|
118
|
+
return stats
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def integrate_with_server(server_instance, port: Optional[int] = None) -> EventBusIntegration:
|
|
122
|
+
"""Helper function to integrate EventBus with a Socket.IO server.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
server_instance: Socket.IO server instance
|
|
126
|
+
port: Optional server port
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
EventBusIntegration: The integration instance
|
|
130
|
+
"""
|
|
131
|
+
integration = EventBusIntegration(server_instance)
|
|
132
|
+
integration.setup(port or getattr(server_instance, 'port', 8765))
|
|
133
|
+
return integration
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Monkey-patch helper for existing server
|
|
137
|
+
def patch_socketio_server(server_class):
|
|
138
|
+
"""Monkey-patch an existing Socket.IO server class to add EventBus.
|
|
139
|
+
|
|
140
|
+
WHY monkey-patching:
|
|
141
|
+
- Allows integration without modifying existing code
|
|
142
|
+
- Can be applied selectively based on configuration
|
|
143
|
+
- Easy to remove or disable
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
server_class: The server class to patch
|
|
147
|
+
"""
|
|
148
|
+
original_init = server_class.__init__
|
|
149
|
+
original_start = getattr(server_class, 'start_sync', None) or getattr(server_class, 'start', None)
|
|
150
|
+
original_stop = getattr(server_class, 'stop_sync', None) or getattr(server_class, 'stop', None)
|
|
151
|
+
|
|
152
|
+
def patched_init(self, *args, **kwargs):
|
|
153
|
+
"""Patched __init__ that adds EventBus integration."""
|
|
154
|
+
original_init(self, *args, **kwargs)
|
|
155
|
+
self._eventbus_integration = EventBusIntegration(self)
|
|
156
|
+
|
|
157
|
+
def patched_start(self, *args, **kwargs):
|
|
158
|
+
"""Patched start method that sets up EventBus."""
|
|
159
|
+
# Call original start
|
|
160
|
+
if original_start:
|
|
161
|
+
result = original_start(self, *args, **kwargs)
|
|
162
|
+
else:
|
|
163
|
+
result = None
|
|
164
|
+
|
|
165
|
+
# Setup EventBus integration
|
|
166
|
+
if hasattr(self, '_eventbus_integration'):
|
|
167
|
+
port = getattr(self, 'port', 8765)
|
|
168
|
+
self._eventbus_integration.setup(port)
|
|
169
|
+
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
def patched_stop(self, *args, **kwargs):
|
|
173
|
+
"""Patched stop method that tears down EventBus."""
|
|
174
|
+
# Teardown EventBus first
|
|
175
|
+
if hasattr(self, '_eventbus_integration'):
|
|
176
|
+
self._eventbus_integration.teardown()
|
|
177
|
+
|
|
178
|
+
# Call original stop
|
|
179
|
+
if original_stop:
|
|
180
|
+
return original_stop(self, *args, **kwargs)
|
|
181
|
+
|
|
182
|
+
# Apply patches
|
|
183
|
+
server_class.__init__ = patched_init
|
|
184
|
+
if original_start:
|
|
185
|
+
setattr(server_class, 'start_sync' if hasattr(server_class, 'start_sync') else 'start', patched_start)
|
|
186
|
+
if original_stop:
|
|
187
|
+
setattr(server_class, 'stop_sync' if hasattr(server_class, 'stop_sync') else 'stop', patched_stop)
|
|
188
|
+
|
|
189
|
+
logger.info(f"Patched {server_class.__name__} with EventBus integration")
|
|
@@ -43,6 +43,7 @@ from ...exceptions import SocketIOServerError as MPMConnectionError
|
|
|
43
43
|
from ..handlers import EventHandlerRegistry, FileEventHandler, GitEventHandler
|
|
44
44
|
from .broadcaster import SocketIOEventBroadcaster
|
|
45
45
|
from .core import SocketIOServerCore
|
|
46
|
+
from .eventbus_integration import EventBusIntegration
|
|
46
47
|
|
|
47
48
|
|
|
48
49
|
class SocketIOServer(SocketIOServiceInterface):
|
|
@@ -93,6 +94,9 @@ class SocketIOServer(SocketIOServiceInterface):
|
|
|
93
94
|
|
|
94
95
|
# Active session tracking for heartbeat
|
|
95
96
|
self.active_sessions: Dict[str, Dict[str, Any]] = {}
|
|
97
|
+
|
|
98
|
+
# EventBus integration
|
|
99
|
+
self.eventbus_integration = None
|
|
96
100
|
|
|
97
101
|
def start_sync(self):
|
|
98
102
|
"""Start the Socket.IO server in a background thread (synchronous version)."""
|
|
@@ -144,6 +148,19 @@ class SocketIOServer(SocketIOServiceInterface):
|
|
|
144
148
|
|
|
145
149
|
# Register events
|
|
146
150
|
self._register_events()
|
|
151
|
+
|
|
152
|
+
# Setup EventBus integration
|
|
153
|
+
# WHY: This connects the EventBus to the Socket.IO server, allowing
|
|
154
|
+
# events from other parts of the system to be broadcast to dashboard
|
|
155
|
+
try:
|
|
156
|
+
self.eventbus_integration = EventBusIntegration(self)
|
|
157
|
+
if self.eventbus_integration.setup(self.port):
|
|
158
|
+
self.logger.info("EventBus integration setup successful")
|
|
159
|
+
else:
|
|
160
|
+
self.logger.warning("EventBus integration setup failed or disabled")
|
|
161
|
+
except Exception as e:
|
|
162
|
+
self.logger.error(f"Failed to setup EventBus integration: {e}")
|
|
163
|
+
self.eventbus_integration = None
|
|
147
164
|
|
|
148
165
|
# Update running state
|
|
149
166
|
self.running = self.core.running
|
|
@@ -159,6 +176,14 @@ class SocketIOServer(SocketIOServiceInterface):
|
|
|
159
176
|
if self.broadcaster:
|
|
160
177
|
self.broadcaster.stop_retry_processor()
|
|
161
178
|
|
|
179
|
+
# Teardown EventBus integration
|
|
180
|
+
if self.eventbus_integration:
|
|
181
|
+
try:
|
|
182
|
+
self.eventbus_integration.teardown()
|
|
183
|
+
self.logger.info("EventBus integration teardown complete")
|
|
184
|
+
except Exception as e:
|
|
185
|
+
self.logger.error(f"Error during EventBus teardown: {e}")
|
|
186
|
+
|
|
162
187
|
# Stop health monitoring in connection handler
|
|
163
188
|
if self.event_registry:
|
|
164
189
|
from ..handlers import ConnectionEventHandler
|