claude-mpm 4.5.8__py3-none-any.whl → 4.5.12__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/__init__.py +20 -5
- claude_mpm/agents/agent_loader.py +19 -2
- claude_mpm/agents/base_agent_loader.py +5 -5
- claude_mpm/agents/frontmatter_validator.py +4 -4
- claude_mpm/agents/templates/agent-manager.json +3 -3
- claude_mpm/agents/templates/agentic-coder-optimizer.json +3 -3
- claude_mpm/agents/templates/api_qa.json +1 -1
- claude_mpm/agents/templates/clerk-ops.json +3 -3
- claude_mpm/agents/templates/code_analyzer.json +3 -3
- claude_mpm/agents/templates/dart_engineer.json +294 -0
- claude_mpm/agents/templates/data_engineer.json +3 -3
- claude_mpm/agents/templates/documentation.json +2 -2
- claude_mpm/agents/templates/engineer.json +2 -2
- claude_mpm/agents/templates/gcp_ops_agent.json +2 -2
- claude_mpm/agents/templates/imagemagick.json +1 -1
- claude_mpm/agents/templates/local_ops_agent.json +319 -41
- claude_mpm/agents/templates/memory_manager.json +2 -2
- claude_mpm/agents/templates/nextjs_engineer.json +2 -2
- claude_mpm/agents/templates/ops.json +2 -2
- claude_mpm/agents/templates/php-engineer.json +1 -1
- claude_mpm/agents/templates/project_organizer.json +1 -1
- claude_mpm/agents/templates/prompt-engineer.json +6 -4
- claude_mpm/agents/templates/python_engineer.json +2 -2
- claude_mpm/agents/templates/qa.json +1 -1
- claude_mpm/agents/templates/react_engineer.json +3 -3
- claude_mpm/agents/templates/refactoring_engineer.json +3 -3
- claude_mpm/agents/templates/research.json +2 -2
- claude_mpm/agents/templates/security.json +2 -2
- claude_mpm/agents/templates/ticketing.json +2 -2
- claude_mpm/agents/templates/typescript_engineer.json +2 -2
- claude_mpm/agents/templates/vercel_ops_agent.json +2 -2
- claude_mpm/agents/templates/version_control.json +2 -2
- claude_mpm/agents/templates/web_qa.json +6 -6
- claude_mpm/agents/templates/web_ui.json +3 -3
- claude_mpm/cli/__init__.py +49 -19
- claude_mpm/cli/commands/agent_manager.py +3 -3
- claude_mpm/cli/commands/agents.py +6 -6
- claude_mpm/cli/commands/aggregate.py +4 -4
- claude_mpm/cli/commands/analyze.py +2 -2
- claude_mpm/cli/commands/analyze_code.py +1 -1
- claude_mpm/cli/commands/cleanup.py +3 -3
- claude_mpm/cli/commands/config.py +2 -2
- claude_mpm/cli/commands/configure.py +605 -21
- claude_mpm/cli/commands/dashboard.py +1 -1
- claude_mpm/cli/commands/debug.py +3 -3
- claude_mpm/cli/commands/doctor.py +1 -1
- claude_mpm/cli/commands/mcp.py +7 -7
- claude_mpm/cli/commands/mcp_command_router.py +1 -1
- claude_mpm/cli/commands/mcp_config.py +2 -2
- claude_mpm/cli/commands/mcp_external_commands.py +2 -2
- claude_mpm/cli/commands/mcp_install_commands.py +3 -3
- claude_mpm/cli/commands/mcp_pipx_config.py +2 -2
- claude_mpm/cli/commands/mcp_setup_external.py +3 -3
- claude_mpm/cli/commands/monitor.py +1 -1
- claude_mpm/cli/commands/mpm_init_handler.py +1 -1
- claude_mpm/cli/interactive/agent_wizard.py +1 -1
- claude_mpm/cli/parsers/configure_parser.py +5 -0
- claude_mpm/cli/parsers/search_parser.py +1 -1
- claude_mpm/cli/shared/argument_patterns.py +2 -2
- claude_mpm/cli/shared/base_command.py +1 -1
- claude_mpm/cli/startup_logging.py +4 -4
- claude_mpm/config/experimental_features.py +4 -4
- claude_mpm/config/socketio_config.py +2 -2
- claude_mpm/core/__init__.py +53 -17
- claude_mpm/core/agent_session_manager.py +2 -2
- claude_mpm/core/api_validator.py +3 -3
- claude_mpm/core/base_service.py +10 -1
- claude_mpm/core/cache.py +2 -2
- claude_mpm/core/config.py +5 -5
- claude_mpm/core/config_aliases.py +4 -4
- claude_mpm/core/config_constants.py +1 -1
- claude_mpm/core/error_handler.py +1 -1
- claude_mpm/core/file_utils.py +5 -5
- claude_mpm/core/framework/formatters/capability_generator.py +5 -5
- claude_mpm/core/framework/loaders/agent_loader.py +1 -1
- claude_mpm/core/framework/processors/metadata_processor.py +1 -1
- claude_mpm/core/framework/processors/template_processor.py +3 -3
- claude_mpm/core/framework_loader.py +2 -2
- claude_mpm/core/log_manager.py +11 -4
- claude_mpm/core/logger.py +2 -2
- claude_mpm/core/optimized_startup.py +1 -1
- claude_mpm/core/output_style_manager.py +1 -1
- claude_mpm/core/service_registry.py +2 -2
- claude_mpm/core/session_manager.py +3 -3
- claude_mpm/core/shared/config_loader.py +1 -1
- claude_mpm/core/socketio_pool.py +2 -2
- claude_mpm/core/unified_agent_registry.py +2 -2
- claude_mpm/core/unified_config.py +6 -6
- claude_mpm/core/unified_paths.py +2 -2
- claude_mpm/dashboard/api/simple_directory.py +1 -1
- claude_mpm/generators/agent_profile_generator.py +1 -1
- claude_mpm/hooks/claude_hooks/event_handlers.py +2 -2
- claude_mpm/hooks/claude_hooks/installer.py +9 -9
- claude_mpm/hooks/claude_hooks/response_tracking.py +16 -11
- claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +16 -13
- claude_mpm/hooks/claude_hooks/tool_analysis.py +2 -2
- claude_mpm/hooks/memory_integration_hook.py +1 -1
- claude_mpm/hooks/validation_hooks.py +1 -1
- claude_mpm/init.py +4 -4
- claude_mpm/models/agent_session.py +1 -1
- claude_mpm/scripts/socketio_daemon.py +5 -5
- claude_mpm/services/__init__.py +145 -161
- claude_mpm/services/agent_capabilities_service.py +1 -1
- claude_mpm/services/agents/agent_builder.py +4 -4
- claude_mpm/services/agents/deployment/agent_lifecycle_manager.py +1 -1
- claude_mpm/services/agents/deployment/agent_metrics_collector.py +1 -1
- claude_mpm/services/agents/deployment/agent_record_service.py +3 -3
- claude_mpm/services/agents/deployment/deployment_config_loader.py +21 -0
- claude_mpm/services/agents/deployment/deployment_wrapper.py +1 -1
- claude_mpm/services/agents/deployment/pipeline/steps/target_directory_step.py +2 -2
- claude_mpm/services/agents/loading/agent_profile_loader.py +2 -2
- claude_mpm/services/agents/loading/base_agent_manager.py +12 -2
- claude_mpm/services/agents/local_template_manager.py +5 -5
- claude_mpm/services/agents/registry/deployed_agent_discovery.py +1 -1
- claude_mpm/services/agents/registry/modification_tracker.py +19 -11
- claude_mpm/services/async_session_logger.py +3 -3
- claude_mpm/services/claude_session_logger.py +4 -4
- claude_mpm/services/cli/agent_listing_service.py +3 -3
- claude_mpm/services/cli/agent_validation_service.py +1 -1
- claude_mpm/services/cli/session_manager.py +2 -2
- claude_mpm/services/core/path_resolver.py +1 -1
- claude_mpm/services/diagnostics/checks/agent_check.py +1 -1
- claude_mpm/services/diagnostics/checks/claude_code_check.py +2 -2
- claude_mpm/services/diagnostics/checks/common_issues_check.py +3 -3
- claude_mpm/services/diagnostics/checks/configuration_check.py +2 -2
- claude_mpm/services/diagnostics/checks/installation_check.py +1 -1
- claude_mpm/services/diagnostics/checks/mcp_check.py +1 -1
- claude_mpm/services/diagnostics/checks/mcp_services_check.py +9 -9
- claude_mpm/services/diagnostics/checks/monitor_check.py +1 -1
- claude_mpm/services/diagnostics/doctor_reporter.py +1 -1
- claude_mpm/services/event_aggregator.py +1 -1
- claude_mpm/services/event_bus/event_bus.py +7 -2
- claude_mpm/services/events/consumers/dead_letter.py +2 -2
- claude_mpm/services/framework_claude_md_generator/__init__.py +1 -1
- claude_mpm/services/framework_claude_md_generator/deployment_manager.py +3 -3
- claude_mpm/services/framework_claude_md_generator/version_manager.py +1 -1
- claude_mpm/services/hook_installer_service.py +7 -7
- claude_mpm/services/infrastructure/context_preservation.py +7 -7
- claude_mpm/services/infrastructure/daemon_manager.py +5 -5
- claude_mpm/services/mcp_config_manager.py +169 -48
- claude_mpm/services/mcp_gateway/__init__.py +98 -94
- claude_mpm/services/mcp_gateway/auto_configure.py +5 -5
- claude_mpm/services/mcp_gateway/config/config_loader.py +2 -2
- claude_mpm/services/mcp_gateway/config/configuration.py +3 -3
- claude_mpm/services/mcp_gateway/core/process_pool.py +3 -3
- claude_mpm/services/mcp_gateway/core/singleton_manager.py +2 -2
- claude_mpm/services/mcp_gateway/core/startup_verification.py +1 -1
- claude_mpm/services/mcp_gateway/main.py +1 -1
- claude_mpm/services/mcp_gateway/registry/service_registry.py +4 -2
- claude_mpm/services/mcp_gateway/registry/tool_registry.py +2 -1
- claude_mpm/services/mcp_gateway/server/stdio_handler.py +1 -1
- claude_mpm/services/mcp_gateway/tools/document_summarizer.py +1 -1
- claude_mpm/services/mcp_gateway/tools/health_check_tool.py +1 -1
- claude_mpm/services/mcp_gateway/tools/hello_world.py +1 -1
- claude_mpm/services/mcp_gateway/utils/package_version_checker.py +5 -5
- claude_mpm/services/mcp_gateway/utils/update_preferences.py +2 -2
- claude_mpm/services/mcp_service_verifier.py +1 -1
- claude_mpm/services/memory/builder.py +1 -1
- claude_mpm/services/memory/cache/shared_prompt_cache.py +2 -1
- claude_mpm/services/memory/indexed_memory.py +3 -3
- claude_mpm/services/monitor/daemon.py +1 -1
- claude_mpm/services/monitor/daemon_manager.py +9 -9
- claude_mpm/services/monitor/event_emitter.py +1 -1
- claude_mpm/services/monitor/handlers/file.py +1 -1
- claude_mpm/services/monitor/handlers/hooks.py +3 -3
- claude_mpm/services/monitor/management/lifecycle.py +7 -7
- claude_mpm/services/monitor/server.py +2 -2
- claude_mpm/services/orphan_detection.py +788 -0
- claude_mpm/services/port_manager.py +2 -2
- claude_mpm/services/project/analyzer.py +3 -3
- claude_mpm/services/project/archive_manager.py +13 -13
- claude_mpm/services/project/dependency_analyzer.py +4 -4
- claude_mpm/services/project/documentation_manager.py +4 -4
- claude_mpm/services/project/enhanced_analyzer.py +8 -8
- claude_mpm/services/project/registry.py +4 -4
- claude_mpm/services/project_port_allocator.py +597 -0
- claude_mpm/services/response_tracker.py +1 -1
- claude_mpm/services/session_management_service.py +1 -1
- claude_mpm/services/session_manager.py +6 -4
- claude_mpm/services/socketio/event_normalizer.py +1 -1
- claude_mpm/services/socketio/handlers/code_analysis.py +14 -12
- claude_mpm/services/socketio/handlers/file.py +1 -1
- claude_mpm/services/socketio/migration_utils.py +1 -1
- claude_mpm/services/socketio/server/core.py +1 -1
- claude_mpm/services/unified/analyzer_strategies/code_analyzer.py +1 -1
- claude_mpm/services/unified/analyzer_strategies/dependency_analyzer.py +4 -4
- claude_mpm/services/unified/analyzer_strategies/structure_analyzer.py +1 -1
- claude_mpm/services/unified/config_strategies/config_schema.py +4 -4
- claude_mpm/services/unified/config_strategies/context_strategy.py +6 -6
- claude_mpm/services/unified/config_strategies/error_handling_strategy.py +10 -10
- claude_mpm/services/unified/config_strategies/file_loader_strategy.py +5 -5
- claude_mpm/services/unified/config_strategies/unified_config_service.py +8 -8
- claude_mpm/services/unified/config_strategies/validation_strategy.py +15 -15
- claude_mpm/services/unified/deployment_strategies/base.py +4 -4
- claude_mpm/services/unified/deployment_strategies/cloud_strategies.py +15 -15
- claude_mpm/services/unified/deployment_strategies/local.py +9 -9
- claude_mpm/services/unified/deployment_strategies/utils.py +9 -9
- claude_mpm/services/unified/deployment_strategies/vercel.py +7 -7
- claude_mpm/services/unified/unified_config.py +5 -5
- claude_mpm/services/unified/unified_deployment.py +2 -2
- claude_mpm/services/utility_service.py +1 -1
- claude_mpm/services/version_control/conflict_resolution.py +2 -2
- claude_mpm/services/version_control/git_operations.py +3 -3
- claude_mpm/services/version_control/semantic_versioning.py +13 -13
- claude_mpm/services/version_control/version_parser.py +1 -1
- claude_mpm/storage/state_storage.py +12 -13
- claude_mpm/tools/code_tree_analyzer.py +5 -5
- claude_mpm/tools/code_tree_builder.py +4 -4
- claude_mpm/tools/socketio_debug.py +1 -1
- claude_mpm/utils/agent_dependency_loader.py +4 -4
- claude_mpm/utils/common.py +2 -2
- claude_mpm/utils/config_manager.py +3 -3
- claude_mpm/utils/dependency_cache.py +2 -2
- claude_mpm/utils/dependency_strategies.py +6 -6
- claude_mpm/utils/file_utils.py +11 -11
- claude_mpm/utils/log_cleanup.py +1 -1
- claude_mpm/utils/path_operations.py +1 -1
- claude_mpm/validation/agent_validator.py +2 -2
- claude_mpm/validation/frontmatter_validator.py +1 -1
- {claude_mpm-4.5.8.dist-info → claude_mpm-4.5.12.dist-info}/METADATA +1 -1
- {claude_mpm-4.5.8.dist-info → claude_mpm-4.5.12.dist-info}/RECORD +226 -223
- {claude_mpm-4.5.8.dist-info → claude_mpm-4.5.12.dist-info}/WHEEL +0 -0
- {claude_mpm-4.5.8.dist-info → claude_mpm-4.5.12.dist-info}/entry_points.txt +0 -0
- {claude_mpm-4.5.8.dist-info → claude_mpm-4.5.12.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-4.5.8.dist-info → claude_mpm-4.5.12.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,597 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
Project Port Allocator Service
|
4
|
+
==============================
|
5
|
+
|
6
|
+
Provides deterministic, hash-based port allocation for local development projects.
|
7
|
+
Ensures each project gets a consistent port across sessions while avoiding conflicts.
|
8
|
+
|
9
|
+
Part of local-ops agent improvements for single port per project allocation.
|
10
|
+
|
11
|
+
WHY: Manual port assignment is error-prone and leads to conflicts. Hash-based
|
12
|
+
allocation provides predictable, consistent port assignments while avoiding
|
13
|
+
collisions through linear probing.
|
14
|
+
|
15
|
+
DESIGN DECISIONS:
|
16
|
+
- Hash-based allocation: Projects get same port consistently (SHA-256 of path)
|
17
|
+
- Port range: 3000-3999 (1000 ports for user projects)
|
18
|
+
- Linear probing: Handles hash collisions gracefully
|
19
|
+
- Global registry: Prevents conflicts across multiple projects
|
20
|
+
- Persistent state: Survives restarts and maintains history
|
21
|
+
- Atomic operations: Prevents race conditions in port allocation
|
22
|
+
"""
|
23
|
+
|
24
|
+
import hashlib
|
25
|
+
import json
|
26
|
+
import os
|
27
|
+
from datetime import datetime, timezone
|
28
|
+
from pathlib import Path
|
29
|
+
from typing import Any, Dict, Optional
|
30
|
+
|
31
|
+
import psutil
|
32
|
+
|
33
|
+
from .core.base import SyncBaseService
|
34
|
+
|
35
|
+
|
36
|
+
class ProjectPortAllocator(SyncBaseService):
|
37
|
+
"""
|
38
|
+
Manages port allocation for local development projects.
|
39
|
+
|
40
|
+
Features:
|
41
|
+
- Deterministic port allocation based on project path hash
|
42
|
+
- Persistent state tracking across sessions
|
43
|
+
- Global registry to prevent cross-project conflicts
|
44
|
+
- Orphan detection and cleanup
|
45
|
+
- Linear probing for conflict resolution
|
46
|
+
"""
|
47
|
+
|
48
|
+
# Port range for user projects (avoiding system ports and Claude MPM services)
|
49
|
+
DEFAULT_PORT_RANGE_START = 3000
|
50
|
+
DEFAULT_PORT_RANGE_END = 3999
|
51
|
+
|
52
|
+
# Claude MPM services use 8765-8785, keep these protected
|
53
|
+
PROTECTED_PORT_RANGES = [(8765, 8785)]
|
54
|
+
|
55
|
+
# State file names
|
56
|
+
STATE_FILE_NAME = "deployment-state.json"
|
57
|
+
GLOBAL_REGISTRY_FILE = "global-port-registry.json"
|
58
|
+
|
59
|
+
def __init__(
|
60
|
+
self,
|
61
|
+
project_root: Optional[Path] = None,
|
62
|
+
port_range_start: Optional[int] = None,
|
63
|
+
port_range_end: Optional[int] = None,
|
64
|
+
):
|
65
|
+
"""
|
66
|
+
Initialize the port allocator.
|
67
|
+
|
68
|
+
Args:
|
69
|
+
project_root: Project directory (default: current working directory)
|
70
|
+
port_range_start: Start of port range (default: 3000)
|
71
|
+
port_range_end: End of port range (default: 3999)
|
72
|
+
"""
|
73
|
+
super().__init__(service_name="ProjectPortAllocator")
|
74
|
+
|
75
|
+
self.project_root = (project_root or Path.cwd()).resolve()
|
76
|
+
self.port_range_start = port_range_start or self.DEFAULT_PORT_RANGE_START
|
77
|
+
self.port_range_end = port_range_end or self.DEFAULT_PORT_RANGE_END
|
78
|
+
|
79
|
+
# Project-local state directory
|
80
|
+
self.state_dir = self.project_root / ".claude-mpm"
|
81
|
+
self.state_file = self.state_dir / self.STATE_FILE_NAME
|
82
|
+
|
83
|
+
# Global registry in user home directory
|
84
|
+
self.global_registry_dir = Path.home() / ".claude-mpm"
|
85
|
+
self.global_registry_file = self.global_registry_dir / self.GLOBAL_REGISTRY_FILE
|
86
|
+
|
87
|
+
# Ensure directories exist
|
88
|
+
self.state_dir.mkdir(parents=True, exist_ok=True)
|
89
|
+
self.global_registry_dir.mkdir(parents=True, exist_ok=True)
|
90
|
+
|
91
|
+
def initialize(self) -> bool:
|
92
|
+
"""
|
93
|
+
Initialize the service.
|
94
|
+
|
95
|
+
Returns:
|
96
|
+
True if initialization successful
|
97
|
+
"""
|
98
|
+
try:
|
99
|
+
# Cleanup any dead registrations on startup
|
100
|
+
self.cleanup_dead_registrations()
|
101
|
+
self._initialized = True
|
102
|
+
self.log_info("ProjectPortAllocator initialized successfully")
|
103
|
+
return True
|
104
|
+
except Exception as e:
|
105
|
+
self.log_error(f"Failed to initialize: {e}")
|
106
|
+
return False
|
107
|
+
|
108
|
+
def shutdown(self) -> None:
|
109
|
+
"""Shutdown the service gracefully."""
|
110
|
+
self._shutdown = True
|
111
|
+
self.log_info("ProjectPortAllocator shutdown")
|
112
|
+
|
113
|
+
def _compute_project_hash(self, project_path: Path) -> str:
|
114
|
+
"""
|
115
|
+
Compute deterministic hash for a project path.
|
116
|
+
|
117
|
+
Args:
|
118
|
+
project_path: Absolute path to project
|
119
|
+
|
120
|
+
Returns:
|
121
|
+
SHA-256 hash of the project path
|
122
|
+
"""
|
123
|
+
# Use absolute path for consistency
|
124
|
+
absolute_path = project_path.resolve()
|
125
|
+
path_str = str(absolute_path)
|
126
|
+
|
127
|
+
# Compute SHA-256 hash
|
128
|
+
hash_obj = hashlib.sha256(path_str.encode("utf-8"))
|
129
|
+
return hash_obj.hexdigest()
|
130
|
+
|
131
|
+
def _hash_to_port(self, project_hash: str) -> int:
|
132
|
+
"""
|
133
|
+
Convert project hash to a port number in the allowed range.
|
134
|
+
|
135
|
+
Args:
|
136
|
+
project_hash: SHA-256 hash of project path
|
137
|
+
|
138
|
+
Returns:
|
139
|
+
Port number in the configured range
|
140
|
+
"""
|
141
|
+
# Use first 8 hex chars as integer
|
142
|
+
hash_int = int(project_hash[:8], 16)
|
143
|
+
|
144
|
+
# Map to port range
|
145
|
+
port_range = self.port_range_end - self.port_range_start + 1
|
146
|
+
return self.port_range_start + (hash_int % port_range)
|
147
|
+
|
148
|
+
|
149
|
+
def _is_port_available(self, port: int) -> bool:
|
150
|
+
"""
|
151
|
+
Check if a port is available for binding.
|
152
|
+
|
153
|
+
Args:
|
154
|
+
port: Port number to check
|
155
|
+
|
156
|
+
Returns:
|
157
|
+
True if port is available
|
158
|
+
"""
|
159
|
+
try:
|
160
|
+
import socket
|
161
|
+
|
162
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
163
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
164
|
+
sock.bind(("localhost", port))
|
165
|
+
return True
|
166
|
+
except OSError:
|
167
|
+
return False
|
168
|
+
|
169
|
+
def _is_protected_port(self, port: int) -> bool:
|
170
|
+
"""
|
171
|
+
Check if port is in a protected range.
|
172
|
+
|
173
|
+
Args:
|
174
|
+
port: Port number to check
|
175
|
+
|
176
|
+
Returns:
|
177
|
+
True if port is protected
|
178
|
+
"""
|
179
|
+
return any(start <= port <= end for start, end in self.PROTECTED_PORT_RANGES)
|
180
|
+
|
181
|
+
def _load_project_state(self) -> Dict[str, Any]:
|
182
|
+
"""
|
183
|
+
Load project deployment state.
|
184
|
+
|
185
|
+
Returns:
|
186
|
+
State dictionary or empty dict if not found
|
187
|
+
"""
|
188
|
+
try:
|
189
|
+
if self.state_file.exists():
|
190
|
+
with self.state_file.open() as f:
|
191
|
+
return json.load(f)
|
192
|
+
except Exception as e:
|
193
|
+
self.log_warning(f"Failed to load project state: {e}")
|
194
|
+
|
195
|
+
return {}
|
196
|
+
|
197
|
+
def _save_project_state(self, state: Dict[str, Any]) -> None:
|
198
|
+
"""
|
199
|
+
Save project deployment state atomically.
|
200
|
+
|
201
|
+
Args:
|
202
|
+
state: State dictionary to save
|
203
|
+
"""
|
204
|
+
try:
|
205
|
+
# Write to temporary file first
|
206
|
+
temp_file = self.state_file.with_suffix(".tmp")
|
207
|
+
with temp_file.open("w") as f:
|
208
|
+
json.dump(state, f, indent=2)
|
209
|
+
|
210
|
+
# Atomic rename
|
211
|
+
temp_file.replace(self.state_file)
|
212
|
+
|
213
|
+
except Exception as e:
|
214
|
+
self.log_error(f"Failed to save project state: {e}")
|
215
|
+
raise
|
216
|
+
|
217
|
+
def _load_global_registry(self) -> Dict[str, Any]:
|
218
|
+
"""
|
219
|
+
Load global port registry.
|
220
|
+
|
221
|
+
Returns:
|
222
|
+
Registry dictionary or empty dict if not found
|
223
|
+
"""
|
224
|
+
try:
|
225
|
+
if self.global_registry_file.exists():
|
226
|
+
with self.global_registry_file.open() as f:
|
227
|
+
return json.load(f)
|
228
|
+
except Exception as e:
|
229
|
+
self.log_warning(f"Failed to load global registry: {e}")
|
230
|
+
|
231
|
+
return {"allocations": {}, "last_updated": None}
|
232
|
+
|
233
|
+
def _save_global_registry(self, registry: Dict[str, Any]) -> None:
|
234
|
+
"""
|
235
|
+
Save global port registry atomically.
|
236
|
+
|
237
|
+
Args:
|
238
|
+
registry: Registry dictionary to save
|
239
|
+
"""
|
240
|
+
try:
|
241
|
+
# Update timestamp
|
242
|
+
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
243
|
+
|
244
|
+
# Write to temporary file first
|
245
|
+
temp_file = self.global_registry_file.with_suffix(".tmp")
|
246
|
+
with temp_file.open("w") as f:
|
247
|
+
json.dump(registry, f, indent=2)
|
248
|
+
|
249
|
+
# Atomic rename
|
250
|
+
temp_file.replace(self.global_registry_file)
|
251
|
+
|
252
|
+
except Exception as e:
|
253
|
+
self.log_error(f"Failed to save global registry: {e}")
|
254
|
+
raise
|
255
|
+
|
256
|
+
def get_project_port(
|
257
|
+
self,
|
258
|
+
project_path: Optional[Path] = None,
|
259
|
+
service_name: str = "main",
|
260
|
+
respect_env_override: bool = True,
|
261
|
+
) -> int:
|
262
|
+
"""
|
263
|
+
Get the allocated port for a project service.
|
264
|
+
|
265
|
+
This is the main entry point for port allocation. It:
|
266
|
+
1. Checks for environment variable override (PROJECT_PORT)
|
267
|
+
2. Checks existing allocation in state files
|
268
|
+
3. Computes hash-based port with linear probing for conflicts
|
269
|
+
|
270
|
+
Args:
|
271
|
+
project_path: Path to project (default: self.project_root)
|
272
|
+
service_name: Name of the service (default: "main")
|
273
|
+
respect_env_override: Whether to respect PROJECT_PORT env var
|
274
|
+
|
275
|
+
Returns:
|
276
|
+
Allocated port number
|
277
|
+
|
278
|
+
Raises:
|
279
|
+
RuntimeError: If no available port found
|
280
|
+
"""
|
281
|
+
project_path = (project_path or self.project_root).resolve()
|
282
|
+
|
283
|
+
# Check environment variable override
|
284
|
+
if respect_env_override:
|
285
|
+
env_port = os.environ.get("PROJECT_PORT")
|
286
|
+
if env_port:
|
287
|
+
try:
|
288
|
+
port = int(env_port)
|
289
|
+
self.log_info(
|
290
|
+
f"Using port {port} from PROJECT_PORT environment variable"
|
291
|
+
)
|
292
|
+
return port
|
293
|
+
except ValueError:
|
294
|
+
self.log_warning(f"Invalid PROJECT_PORT value: {env_port}")
|
295
|
+
|
296
|
+
# Check existing allocation
|
297
|
+
state = self._load_project_state()
|
298
|
+
deployments = state.get("deployments", {})
|
299
|
+
|
300
|
+
if service_name in deployments:
|
301
|
+
existing_port = deployments[service_name].get("port")
|
302
|
+
if existing_port and self._is_port_available(existing_port):
|
303
|
+
self.log_info(
|
304
|
+
f"Reusing existing port {existing_port} for {service_name}"
|
305
|
+
)
|
306
|
+
return existing_port
|
307
|
+
|
308
|
+
# Compute hash-based port with linear probing
|
309
|
+
project_hash = self._compute_project_hash(project_path)
|
310
|
+
base_port = self._hash_to_port(project_hash)
|
311
|
+
|
312
|
+
# Try base port first
|
313
|
+
port = self._find_available_port(base_port, project_path, service_name)
|
314
|
+
|
315
|
+
self.log_info(
|
316
|
+
f"Allocated port {port} for {service_name} "
|
317
|
+
f"(hash: {project_hash[:8]}, base: {base_port})"
|
318
|
+
)
|
319
|
+
|
320
|
+
return port
|
321
|
+
|
322
|
+
def _find_available_port(
|
323
|
+
self,
|
324
|
+
start_port: int,
|
325
|
+
project_path: Path,
|
326
|
+
service_name: str,
|
327
|
+
) -> int:
|
328
|
+
"""
|
329
|
+
Find available port using linear probing.
|
330
|
+
|
331
|
+
Args:
|
332
|
+
start_port: Starting port from hash
|
333
|
+
project_path: Project path
|
334
|
+
service_name: Service name
|
335
|
+
|
336
|
+
Returns:
|
337
|
+
Available port number
|
338
|
+
|
339
|
+
Raises:
|
340
|
+
RuntimeError: If no available port found
|
341
|
+
"""
|
342
|
+
max_probes = self.port_range_end - self.port_range_start + 1
|
343
|
+
|
344
|
+
for offset in range(max_probes):
|
345
|
+
port = start_port + offset
|
346
|
+
|
347
|
+
# Wrap around if we exceed range
|
348
|
+
if port > self.port_range_end:
|
349
|
+
port = self.port_range_start + (port - self.port_range_end - 1)
|
350
|
+
|
351
|
+
# Skip protected ports
|
352
|
+
if self._is_protected_port(port):
|
353
|
+
continue
|
354
|
+
|
355
|
+
# Check if port is available
|
356
|
+
if self._is_port_available(port):
|
357
|
+
return port
|
358
|
+
|
359
|
+
raise RuntimeError(
|
360
|
+
f"No available ports in range {self.port_range_start}-{self.port_range_end}"
|
361
|
+
)
|
362
|
+
|
363
|
+
def register_port(
|
364
|
+
self,
|
365
|
+
port: int,
|
366
|
+
service_name: str = "main",
|
367
|
+
deployment_info: Optional[Dict[str, Any]] = None,
|
368
|
+
project_path: Optional[Path] = None,
|
369
|
+
) -> None:
|
370
|
+
"""
|
371
|
+
Register a port allocation for a project service.
|
372
|
+
|
373
|
+
Args:
|
374
|
+
port: Port number
|
375
|
+
service_name: Service name
|
376
|
+
deployment_info: Additional deployment information
|
377
|
+
project_path: Project path (default: self.project_root)
|
378
|
+
"""
|
379
|
+
project_path = (project_path or self.project_root).resolve()
|
380
|
+
project_hash = self._compute_project_hash(project_path)
|
381
|
+
|
382
|
+
# Update project state
|
383
|
+
state = self._load_project_state()
|
384
|
+
|
385
|
+
if "project_path" not in state:
|
386
|
+
state["project_path"] = str(project_path)
|
387
|
+
state["project_hash"] = project_hash
|
388
|
+
state["deployments"] = {}
|
389
|
+
state["port_history"] = []
|
390
|
+
|
391
|
+
# Merge deployment info
|
392
|
+
deployment_data = deployment_info or {}
|
393
|
+
deployment_data.update(
|
394
|
+
{
|
395
|
+
"port": port,
|
396
|
+
"service_name": service_name,
|
397
|
+
"registered_at": datetime.now(timezone.utc).isoformat(),
|
398
|
+
}
|
399
|
+
)
|
400
|
+
|
401
|
+
state["deployments"][service_name] = deployment_data
|
402
|
+
|
403
|
+
# Track port history
|
404
|
+
if port not in state.get("port_history", []):
|
405
|
+
state.setdefault("port_history", []).append(port)
|
406
|
+
|
407
|
+
state["last_updated"] = datetime.now(timezone.utc).isoformat()
|
408
|
+
|
409
|
+
self._save_project_state(state)
|
410
|
+
|
411
|
+
# Update global registry
|
412
|
+
registry = self._load_global_registry()
|
413
|
+
|
414
|
+
registry.setdefault("allocations", {})[str(port)] = {
|
415
|
+
"project_path": str(project_path),
|
416
|
+
"project_hash": project_hash,
|
417
|
+
"service_name": service_name,
|
418
|
+
"registered_at": datetime.now(timezone.utc).isoformat(),
|
419
|
+
}
|
420
|
+
|
421
|
+
self._save_global_registry(registry)
|
422
|
+
|
423
|
+
self.log_info(f"Registered port {port} for {service_name}")
|
424
|
+
|
425
|
+
def release_port(
|
426
|
+
self,
|
427
|
+
port: int,
|
428
|
+
service_name: str = "main",
|
429
|
+
project_path: Optional[Path] = None,
|
430
|
+
) -> None:
|
431
|
+
"""
|
432
|
+
Release a port allocation.
|
433
|
+
|
434
|
+
Args:
|
435
|
+
port: Port number to release
|
436
|
+
service_name: Service name
|
437
|
+
project_path: Project path (default: self.project_root)
|
438
|
+
"""
|
439
|
+
project_path = (project_path or self.project_root).resolve()
|
440
|
+
|
441
|
+
# Update project state
|
442
|
+
state = self._load_project_state()
|
443
|
+
deployments = state.get("deployments", {})
|
444
|
+
|
445
|
+
if service_name in deployments:
|
446
|
+
del deployments[service_name]
|
447
|
+
state["last_updated"] = datetime.now(timezone.utc).isoformat()
|
448
|
+
self._save_project_state(state)
|
449
|
+
|
450
|
+
# Update global registry
|
451
|
+
registry = self._load_global_registry()
|
452
|
+
allocations = registry.get("allocations", {})
|
453
|
+
|
454
|
+
if str(port) in allocations:
|
455
|
+
del allocations[str(port)]
|
456
|
+
self._save_global_registry(registry)
|
457
|
+
|
458
|
+
self.log_info(f"Released port {port} for {service_name}")
|
459
|
+
|
460
|
+
def cleanup_dead_registrations(self) -> int:
|
461
|
+
"""
|
462
|
+
Clean up registrations for dead processes.
|
463
|
+
|
464
|
+
Returns:
|
465
|
+
Number of registrations cleaned up
|
466
|
+
"""
|
467
|
+
cleaned = 0
|
468
|
+
|
469
|
+
# Clean project state
|
470
|
+
state = self._load_project_state()
|
471
|
+
deployments = state.get("deployments", {})
|
472
|
+
dead_services = []
|
473
|
+
|
474
|
+
for service_name, deployment in deployments.items():
|
475
|
+
pid = deployment.get("pid")
|
476
|
+
if pid and not self._is_process_alive(pid):
|
477
|
+
dead_services.append(service_name)
|
478
|
+
cleaned += 1
|
479
|
+
|
480
|
+
for service_name in dead_services:
|
481
|
+
self.log_info(f"Cleaning up dead deployment: {service_name}")
|
482
|
+
del deployments[service_name]
|
483
|
+
|
484
|
+
if dead_services:
|
485
|
+
state["last_updated"] = datetime.now(timezone.utc).isoformat()
|
486
|
+
self._save_project_state(state)
|
487
|
+
|
488
|
+
# Clean global registry
|
489
|
+
registry = self._load_global_registry()
|
490
|
+
allocations = registry.get("allocations", {})
|
491
|
+
dead_ports = []
|
492
|
+
|
493
|
+
for port_str, allocation in allocations.items():
|
494
|
+
project_path = Path(allocation.get("project_path", ""))
|
495
|
+
|
496
|
+
# Check if project still exists
|
497
|
+
if not project_path.exists():
|
498
|
+
dead_ports.append(port_str)
|
499
|
+
cleaned += 1
|
500
|
+
continue
|
501
|
+
|
502
|
+
# Check project state file
|
503
|
+
state_file = project_path / ".claude-mpm" / self.STATE_FILE_NAME
|
504
|
+
if state_file.exists():
|
505
|
+
try:
|
506
|
+
with state_file.open() as f:
|
507
|
+
project_state = json.load(f)
|
508
|
+
|
509
|
+
# Check if service still registered
|
510
|
+
service_name = allocation.get("service_name")
|
511
|
+
if service_name not in project_state.get("deployments", {}):
|
512
|
+
dead_ports.append(port_str)
|
513
|
+
cleaned += 1
|
514
|
+
|
515
|
+
except Exception as e:
|
516
|
+
self.log_warning(f"Error checking state for port {port_str}: {e}")
|
517
|
+
|
518
|
+
for port_str in dead_ports:
|
519
|
+
self.log_info(f"Cleaning up dead global allocation: port {port_str}")
|
520
|
+
del allocations[port_str]
|
521
|
+
|
522
|
+
if dead_ports:
|
523
|
+
self._save_global_registry(registry)
|
524
|
+
|
525
|
+
if cleaned > 0:
|
526
|
+
self.log_info(f"Cleaned up {cleaned} dead registrations")
|
527
|
+
|
528
|
+
return cleaned
|
529
|
+
|
530
|
+
def _is_process_alive(self, pid: int) -> bool:
|
531
|
+
"""
|
532
|
+
Check if a process is alive.
|
533
|
+
|
534
|
+
Args:
|
535
|
+
pid: Process ID
|
536
|
+
|
537
|
+
Returns:
|
538
|
+
True if process is alive
|
539
|
+
"""
|
540
|
+
try:
|
541
|
+
return psutil.pid_exists(pid)
|
542
|
+
except Exception:
|
543
|
+
return False
|
544
|
+
|
545
|
+
def get_allocation_info(
|
546
|
+
self,
|
547
|
+
service_name: str = "main",
|
548
|
+
project_path: Optional[Path] = None,
|
549
|
+
) -> Optional[Dict[str, Any]]:
|
550
|
+
"""
|
551
|
+
Get allocation information for a service.
|
552
|
+
|
553
|
+
Args:
|
554
|
+
service_name: Service name
|
555
|
+
project_path: Project path (default: self.project_root)
|
556
|
+
|
557
|
+
Returns:
|
558
|
+
Allocation info dict or None if not found
|
559
|
+
"""
|
560
|
+
project_path = (project_path or self.project_root).resolve()
|
561
|
+
state = self._load_project_state()
|
562
|
+
deployments = state.get("deployments", {})
|
563
|
+
|
564
|
+
return deployments.get(service_name)
|
565
|
+
|
566
|
+
def list_project_allocations(
|
567
|
+
self,
|
568
|
+
project_path: Optional[Path] = None,
|
569
|
+
) -> Dict[str, Any]:
|
570
|
+
"""
|
571
|
+
List all port allocations for a project.
|
572
|
+
|
573
|
+
Args:
|
574
|
+
project_path: Project path (default: self.project_root)
|
575
|
+
|
576
|
+
Returns:
|
577
|
+
Dictionary of service allocations
|
578
|
+
"""
|
579
|
+
project_path = (project_path or self.project_root).resolve()
|
580
|
+
state = self._load_project_state()
|
581
|
+
|
582
|
+
return {
|
583
|
+
"project_path": str(project_path),
|
584
|
+
"project_hash": state.get("project_hash"),
|
585
|
+
"deployments": state.get("deployments", {}),
|
586
|
+
"port_history": state.get("port_history", []),
|
587
|
+
"last_updated": state.get("last_updated"),
|
588
|
+
}
|
589
|
+
|
590
|
+
def list_global_allocations(self) -> Dict[str, Any]:
|
591
|
+
"""
|
592
|
+
List all global port allocations.
|
593
|
+
|
594
|
+
Returns:
|
595
|
+
Global registry data
|
596
|
+
"""
|
597
|
+
return self._load_global_registry()
|
@@ -80,7 +80,7 @@ class ResponseTracker:
|
|
80
80
|
from claude_mpm.services.claude_session_logger import get_session_logger
|
81
81
|
|
82
82
|
self.session_logger = get_session_logger(config)
|
83
|
-
logger.
|
83
|
+
logger.debug(
|
84
84
|
f"Response tracker initialized with base directory: {base_dir}"
|
85
85
|
)
|
86
86
|
except Exception as e:
|
@@ -187,7 +187,7 @@ class SessionManagementService(BaseService, SessionManagementInterface):
|
|
187
187
|
event_data["timestamp"] = datetime.now(timezone.utc).isoformat()
|
188
188
|
|
189
189
|
# Append to log file as JSONL
|
190
|
-
with open(
|
190
|
+
with log_file.open("a") as f:
|
191
191
|
f.write(json.dumps(event_data) + "\n")
|
192
192
|
|
193
193
|
except Exception as e:
|
@@ -67,7 +67,7 @@ class SessionManager:
|
|
67
67
|
# Mark as initialized
|
68
68
|
self.__class__._initialized = True
|
69
69
|
|
70
|
-
logger.
|
70
|
+
logger.debug(
|
71
71
|
f"SessionManager initialized with session ID: {self._session_id}"
|
72
72
|
)
|
73
73
|
|
@@ -86,12 +86,12 @@ class SessionManager:
|
|
86
86
|
for env_var in env_vars:
|
87
87
|
session_id = os.environ.get(env_var)
|
88
88
|
if session_id:
|
89
|
-
logger.
|
89
|
+
logger.debug(f"Using session ID from {env_var}: {session_id}")
|
90
90
|
return session_id
|
91
91
|
|
92
92
|
# Generate timestamp-based session ID
|
93
93
|
session_id = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
94
|
-
logger.
|
94
|
+
logger.debug(f"Generated new session ID: {session_id}")
|
95
95
|
return session_id
|
96
96
|
|
97
97
|
def get_session_id(self) -> str:
|
@@ -130,7 +130,9 @@ class SessionManager:
|
|
130
130
|
self._session_id = session_id
|
131
131
|
logger.warning(f"Session ID changed from {old_id} to {session_id}")
|
132
132
|
else:
|
133
|
-
logger.debug(
|
133
|
+
logger.debug(
|
134
|
+
f"Session ID already set to {session_id}, no change needed"
|
135
|
+
)
|
134
136
|
|
135
137
|
@classmethod
|
136
138
|
def reset(cls) -> None:
|
@@ -342,7 +342,7 @@ class EventNormalizer:
|
|
342
342
|
|
343
343
|
return "unknown"
|
344
344
|
|
345
|
-
def _map_event_name(self, event_name: str) -> Tuple[str, str]:
|
345
|
+
def _map_event_name(self, event_name: str) -> Tuple[str, str]:
|
346
346
|
"""Map event name to (type, subtype) tuple.
|
347
347
|
|
348
348
|
WHY: Consistent categorization helps clients filter and handle events.
|