crackerjack 0.30.3__py3-none-any.whl → 0.31.7__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.
Potentially problematic release.
This version of crackerjack might be problematic. Click here for more details.
- crackerjack/CLAUDE.md +1005 -0
- crackerjack/RULES.md +380 -0
- crackerjack/__init__.py +42 -13
- crackerjack/__main__.py +227 -299
- crackerjack/agents/__init__.py +41 -0
- crackerjack/agents/architect_agent.py +281 -0
- crackerjack/agents/base.py +170 -0
- crackerjack/agents/coordinator.py +512 -0
- crackerjack/agents/documentation_agent.py +498 -0
- crackerjack/agents/dry_agent.py +388 -0
- crackerjack/agents/formatting_agent.py +245 -0
- crackerjack/agents/import_optimization_agent.py +281 -0
- crackerjack/agents/performance_agent.py +669 -0
- crackerjack/agents/proactive_agent.py +104 -0
- crackerjack/agents/refactoring_agent.py +788 -0
- crackerjack/agents/security_agent.py +529 -0
- crackerjack/agents/test_creation_agent.py +657 -0
- crackerjack/agents/test_specialist_agent.py +486 -0
- crackerjack/agents/tracker.py +212 -0
- crackerjack/api.py +560 -0
- crackerjack/cli/__init__.py +24 -0
- crackerjack/cli/facade.py +104 -0
- crackerjack/cli/handlers.py +267 -0
- crackerjack/cli/interactive.py +471 -0
- crackerjack/cli/options.py +409 -0
- crackerjack/cli/utils.py +18 -0
- crackerjack/code_cleaner.py +618 -928
- crackerjack/config/__init__.py +19 -0
- crackerjack/config/hooks.py +218 -0
- crackerjack/core/__init__.py +0 -0
- crackerjack/core/async_workflow_orchestrator.py +406 -0
- crackerjack/core/autofix_coordinator.py +200 -0
- crackerjack/core/container.py +104 -0
- crackerjack/core/enhanced_container.py +542 -0
- crackerjack/core/performance.py +243 -0
- crackerjack/core/phase_coordinator.py +585 -0
- crackerjack/core/proactive_workflow.py +316 -0
- crackerjack/core/session_coordinator.py +289 -0
- crackerjack/core/workflow_orchestrator.py +826 -0
- crackerjack/dynamic_config.py +94 -103
- crackerjack/errors.py +263 -41
- crackerjack/executors/__init__.py +11 -0
- crackerjack/executors/async_hook_executor.py +431 -0
- crackerjack/executors/cached_hook_executor.py +242 -0
- crackerjack/executors/hook_executor.py +345 -0
- crackerjack/executors/individual_hook_executor.py +669 -0
- crackerjack/intelligence/__init__.py +44 -0
- crackerjack/intelligence/adaptive_learning.py +751 -0
- crackerjack/intelligence/agent_orchestrator.py +551 -0
- crackerjack/intelligence/agent_registry.py +414 -0
- crackerjack/intelligence/agent_selector.py +502 -0
- crackerjack/intelligence/integration.py +290 -0
- crackerjack/interactive.py +576 -315
- crackerjack/managers/__init__.py +11 -0
- crackerjack/managers/async_hook_manager.py +135 -0
- crackerjack/managers/hook_manager.py +137 -0
- crackerjack/managers/publish_manager.py +433 -0
- crackerjack/managers/test_command_builder.py +151 -0
- crackerjack/managers/test_executor.py +443 -0
- crackerjack/managers/test_manager.py +258 -0
- crackerjack/managers/test_manager_backup.py +1124 -0
- crackerjack/managers/test_progress.py +114 -0
- crackerjack/mcp/__init__.py +0 -0
- crackerjack/mcp/cache.py +336 -0
- crackerjack/mcp/client_runner.py +104 -0
- crackerjack/mcp/context.py +621 -0
- crackerjack/mcp/dashboard.py +636 -0
- crackerjack/mcp/enhanced_progress_monitor.py +479 -0
- crackerjack/mcp/file_monitor.py +336 -0
- crackerjack/mcp/progress_components.py +569 -0
- crackerjack/mcp/progress_monitor.py +949 -0
- crackerjack/mcp/rate_limiter.py +332 -0
- crackerjack/mcp/server.py +22 -0
- crackerjack/mcp/server_core.py +244 -0
- crackerjack/mcp/service_watchdog.py +501 -0
- crackerjack/mcp/state.py +395 -0
- crackerjack/mcp/task_manager.py +257 -0
- crackerjack/mcp/tools/__init__.py +17 -0
- crackerjack/mcp/tools/core_tools.py +249 -0
- crackerjack/mcp/tools/error_analyzer.py +308 -0
- crackerjack/mcp/tools/execution_tools.py +372 -0
- crackerjack/mcp/tools/execution_tools_backup.py +1097 -0
- crackerjack/mcp/tools/intelligence_tool_registry.py +80 -0
- crackerjack/mcp/tools/intelligence_tools.py +314 -0
- crackerjack/mcp/tools/monitoring_tools.py +502 -0
- crackerjack/mcp/tools/proactive_tools.py +384 -0
- crackerjack/mcp/tools/progress_tools.py +217 -0
- crackerjack/mcp/tools/utility_tools.py +341 -0
- crackerjack/mcp/tools/workflow_executor.py +565 -0
- crackerjack/mcp/websocket/__init__.py +14 -0
- crackerjack/mcp/websocket/app.py +39 -0
- crackerjack/mcp/websocket/endpoints.py +559 -0
- crackerjack/mcp/websocket/jobs.py +253 -0
- crackerjack/mcp/websocket/server.py +116 -0
- crackerjack/mcp/websocket/websocket_handler.py +78 -0
- crackerjack/mcp/websocket_server.py +10 -0
- crackerjack/models/__init__.py +31 -0
- crackerjack/models/config.py +93 -0
- crackerjack/models/config_adapter.py +230 -0
- crackerjack/models/protocols.py +118 -0
- crackerjack/models/task.py +154 -0
- crackerjack/monitoring/ai_agent_watchdog.py +450 -0
- crackerjack/monitoring/regression_prevention.py +638 -0
- crackerjack/orchestration/__init__.py +0 -0
- crackerjack/orchestration/advanced_orchestrator.py +970 -0
- crackerjack/orchestration/coverage_improvement.py +223 -0
- crackerjack/orchestration/execution_strategies.py +341 -0
- crackerjack/orchestration/test_progress_streamer.py +636 -0
- crackerjack/plugins/__init__.py +15 -0
- crackerjack/plugins/base.py +200 -0
- crackerjack/plugins/hooks.py +246 -0
- crackerjack/plugins/loader.py +335 -0
- crackerjack/plugins/managers.py +259 -0
- crackerjack/py313.py +8 -3
- crackerjack/services/__init__.py +22 -0
- crackerjack/services/cache.py +314 -0
- crackerjack/services/config.py +358 -0
- crackerjack/services/config_integrity.py +99 -0
- crackerjack/services/contextual_ai_assistant.py +516 -0
- crackerjack/services/coverage_ratchet.py +356 -0
- crackerjack/services/debug.py +736 -0
- crackerjack/services/dependency_monitor.py +617 -0
- crackerjack/services/enhanced_filesystem.py +439 -0
- crackerjack/services/file_hasher.py +151 -0
- crackerjack/services/filesystem.py +421 -0
- crackerjack/services/git.py +176 -0
- crackerjack/services/health_metrics.py +611 -0
- crackerjack/services/initialization.py +873 -0
- crackerjack/services/log_manager.py +286 -0
- crackerjack/services/logging.py +174 -0
- crackerjack/services/metrics.py +578 -0
- crackerjack/services/pattern_cache.py +362 -0
- crackerjack/services/pattern_detector.py +515 -0
- crackerjack/services/performance_benchmarks.py +653 -0
- crackerjack/services/security.py +163 -0
- crackerjack/services/server_manager.py +234 -0
- crackerjack/services/smart_scheduling.py +144 -0
- crackerjack/services/tool_version_service.py +61 -0
- crackerjack/services/unified_config.py +437 -0
- crackerjack/services/version_checker.py +248 -0
- crackerjack/slash_commands/__init__.py +14 -0
- crackerjack/slash_commands/init.md +122 -0
- crackerjack/slash_commands/run.md +163 -0
- crackerjack/slash_commands/status.md +127 -0
- crackerjack-0.31.7.dist-info/METADATA +742 -0
- crackerjack-0.31.7.dist-info/RECORD +149 -0
- crackerjack-0.31.7.dist-info/entry_points.txt +2 -0
- crackerjack/.gitignore +0 -34
- crackerjack/.libcst.codemod.yaml +0 -18
- crackerjack/.pdm.toml +0 -1
- crackerjack/crackerjack.py +0 -3805
- crackerjack/pyproject.toml +0 -286
- crackerjack-0.30.3.dist-info/METADATA +0 -1290
- crackerjack-0.30.3.dist-info/RECORD +0 -16
- {crackerjack-0.30.3.dist-info → crackerjack-0.31.7.dist-info}/WHEEL +0 -0
- {crackerjack-0.30.3.dist-info → crackerjack-0.31.7.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
import typing as t
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from crackerjack.core.workflow_orchestrator import WorkflowOrchestrator
|
|
16
|
+
|
|
17
|
+
from .cache import ErrorCache
|
|
18
|
+
from .rate_limiter import RateLimitConfig, RateLimitMiddleware
|
|
19
|
+
from .state import StateManager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BatchedStateSaver:
|
|
23
|
+
def __init__(self, debounce_delay: float = 1.0, max_batch_size: int = 10) -> None:
|
|
24
|
+
self.debounce_delay = debounce_delay
|
|
25
|
+
self.max_batch_size = max_batch_size
|
|
26
|
+
|
|
27
|
+
self._pending_saves: dict[str, t.Callable[[], None]] = {}
|
|
28
|
+
self._last_save_time: dict[str, float] = {}
|
|
29
|
+
|
|
30
|
+
self._save_task: asyncio.Task | None = None
|
|
31
|
+
self._running = False
|
|
32
|
+
self._lock = asyncio.Lock()
|
|
33
|
+
|
|
34
|
+
async def start(self) -> None:
|
|
35
|
+
if self._running:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
self._running = True
|
|
39
|
+
self._save_task = asyncio.create_task(self._save_loop())
|
|
40
|
+
|
|
41
|
+
async def stop(self) -> None:
|
|
42
|
+
self._running = False
|
|
43
|
+
|
|
44
|
+
if self._save_task:
|
|
45
|
+
self._save_task.cancel()
|
|
46
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
47
|
+
await self._save_task
|
|
48
|
+
|
|
49
|
+
await self._flush_saves()
|
|
50
|
+
|
|
51
|
+
async def schedule_save(
|
|
52
|
+
self,
|
|
53
|
+
save_id: str,
|
|
54
|
+
save_func: t.Callable[[], None],
|
|
55
|
+
) -> None:
|
|
56
|
+
async with self._lock:
|
|
57
|
+
self._pending_saves[save_id] = save_func
|
|
58
|
+
self._last_save_time[save_id] = time.time()
|
|
59
|
+
|
|
60
|
+
if len(self._pending_saves) >= self.max_batch_size:
|
|
61
|
+
await self._flush_saves()
|
|
62
|
+
|
|
63
|
+
async def _save_loop(self) -> None:
|
|
64
|
+
while self._running:
|
|
65
|
+
try:
|
|
66
|
+
await asyncio.sleep(self.debounce_delay)
|
|
67
|
+
ready_saves = await self._get_ready_saves()
|
|
68
|
+
|
|
69
|
+
if ready_saves:
|
|
70
|
+
await self._execute_saves(ready_saves)
|
|
71
|
+
|
|
72
|
+
except asyncio.CancelledError:
|
|
73
|
+
break
|
|
74
|
+
except Exception:
|
|
75
|
+
await asyncio.sleep(1)
|
|
76
|
+
|
|
77
|
+
async def _get_ready_saves(self) -> list[str]:
|
|
78
|
+
now = time.time()
|
|
79
|
+
ready_saves = []
|
|
80
|
+
|
|
81
|
+
async with self._lock:
|
|
82
|
+
for save_id, last_time in list(self._last_save_time.items()):
|
|
83
|
+
if now - last_time >= self.debounce_delay:
|
|
84
|
+
ready_saves.append(save_id)
|
|
85
|
+
|
|
86
|
+
return ready_saves
|
|
87
|
+
|
|
88
|
+
async def _execute_saves(self, save_ids: list[str]) -> None:
|
|
89
|
+
async with self._lock:
|
|
90
|
+
saves_to_execute = []
|
|
91
|
+
|
|
92
|
+
for save_id in save_ids:
|
|
93
|
+
if save_id in self._pending_saves:
|
|
94
|
+
saves_to_execute.append((save_id, self._pending_saves.pop(save_id)))
|
|
95
|
+
self._last_save_time.pop(save_id, None)
|
|
96
|
+
|
|
97
|
+
for save_id, save_func in saves_to_execute:
|
|
98
|
+
with contextlib.suppress(Exception):
|
|
99
|
+
save_func()
|
|
100
|
+
|
|
101
|
+
async def _flush_saves(self) -> None:
|
|
102
|
+
async with self._lock:
|
|
103
|
+
save_ids = list(self._pending_saves.keys())
|
|
104
|
+
|
|
105
|
+
if save_ids:
|
|
106
|
+
await self._execute_saves(save_ids)
|
|
107
|
+
|
|
108
|
+
def get_stats(self) -> dict[str, t.Any]:
|
|
109
|
+
return {
|
|
110
|
+
"running": self._running,
|
|
111
|
+
"pending_saves": len(self._pending_saves),
|
|
112
|
+
"debounce_delay": self.debounce_delay,
|
|
113
|
+
"max_batch_size": self.max_batch_size,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class MCPServerConfig:
|
|
119
|
+
project_path: Path
|
|
120
|
+
progress_dir: Path | None = None
|
|
121
|
+
rate_limit_config: RateLimitConfig | None = None
|
|
122
|
+
stdio_mode: bool = True
|
|
123
|
+
state_dir: Path | None = None
|
|
124
|
+
cache_dir: Path | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class MCPServerContext:
|
|
128
|
+
def __init__(self, config: MCPServerConfig) -> None:
|
|
129
|
+
self.config = config
|
|
130
|
+
|
|
131
|
+
self.console: Console | None = None
|
|
132
|
+
self.cli_runner = None
|
|
133
|
+
self.state_manager: StateManager | None = None
|
|
134
|
+
self.error_cache: ErrorCache | None = None
|
|
135
|
+
self.rate_limiter: RateLimitMiddleware | None = None
|
|
136
|
+
self.batched_saver: BatchedStateSaver = BatchedStateSaver()
|
|
137
|
+
|
|
138
|
+
self.progress_dir = config.progress_dir or (
|
|
139
|
+
Path(tempfile.gettempdir()) / "crackerjack-mcp-progress"
|
|
140
|
+
)
|
|
141
|
+
self.progress_queue: asyncio.Queue[dict[str, t.Any]] = asyncio.Queue(
|
|
142
|
+
maxsize=1000,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
self.websocket_server_process: subprocess.Popen[bytes] | None = None
|
|
146
|
+
self.websocket_server_port: int = int(
|
|
147
|
+
os.environ.get("CRACKERJACK_WEBSOCKET_PORT", "8675"),
|
|
148
|
+
)
|
|
149
|
+
self._websocket_process_lock = asyncio.Lock()
|
|
150
|
+
self._websocket_cleanup_registered = False
|
|
151
|
+
self._websocket_health_check_task: asyncio.Task | None = None
|
|
152
|
+
|
|
153
|
+
self._initialized = False
|
|
154
|
+
self._startup_tasks: list[t.Callable[[], t.Awaitable[None]]] = []
|
|
155
|
+
self._shutdown_tasks: list[t.Callable[[], t.Awaitable[None]]] = []
|
|
156
|
+
|
|
157
|
+
async def initialize(self) -> None:
|
|
158
|
+
if self._initialized:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
if self.config.stdio_mode:
|
|
163
|
+
null_file = io.StringIO()
|
|
164
|
+
self.console = Console(file=null_file, force_terminal=False)
|
|
165
|
+
else:
|
|
166
|
+
self.console = Console(force_terminal=True)
|
|
167
|
+
|
|
168
|
+
self.progress_dir.mkdir(exist_ok=True)
|
|
169
|
+
|
|
170
|
+
self.cli_runner = WorkflowOrchestrator(
|
|
171
|
+
console=self.console,
|
|
172
|
+
pkg_path=self.config.project_path,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
self.state_manager = StateManager(
|
|
176
|
+
self.config.state_dir or Path.home() / ".cache" / "crackerjack-mcp",
|
|
177
|
+
self.batched_saver,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
self.error_cache = ErrorCache(
|
|
181
|
+
self.config.cache_dir or Path.home() / ".cache" / "crackerjack-mcp",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
self.rate_limiter = RateLimitMiddleware(self.config.rate_limit_config)
|
|
185
|
+
|
|
186
|
+
await self.batched_saver.start()
|
|
187
|
+
|
|
188
|
+
for task in self._startup_tasks:
|
|
189
|
+
await task()
|
|
190
|
+
|
|
191
|
+
self._initialized = True
|
|
192
|
+
|
|
193
|
+
except Exception as e:
|
|
194
|
+
self.cli_runner = None
|
|
195
|
+
self.state_manager = None
|
|
196
|
+
self.error_cache = None
|
|
197
|
+
self.rate_limiter = None
|
|
198
|
+
msg = f"Failed to initialize MCP server context: {e}"
|
|
199
|
+
raise RuntimeError(msg) from e
|
|
200
|
+
|
|
201
|
+
async def shutdown(self) -> None:
|
|
202
|
+
if not self._initialized:
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
for task in reversed(self._shutdown_tasks):
|
|
206
|
+
try:
|
|
207
|
+
await task()
|
|
208
|
+
except Exception as e:
|
|
209
|
+
if self.console:
|
|
210
|
+
self.console.print(f"[red]Error during shutdown: {e}[/red]")
|
|
211
|
+
|
|
212
|
+
if self._websocket_health_check_task:
|
|
213
|
+
self._websocket_health_check_task.cancel()
|
|
214
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
215
|
+
await self._websocket_health_check_task
|
|
216
|
+
self._websocket_health_check_task = None
|
|
217
|
+
|
|
218
|
+
await self._stop_websocket_server()
|
|
219
|
+
|
|
220
|
+
if self.rate_limiter:
|
|
221
|
+
await self.rate_limiter.stop()
|
|
222
|
+
|
|
223
|
+
await self.batched_saver.stop()
|
|
224
|
+
|
|
225
|
+
self._initialized = False
|
|
226
|
+
|
|
227
|
+
def add_startup_task(self, task: t.Callable[[], t.Awaitable[None]]) -> None:
|
|
228
|
+
self._startup_tasks.append(task)
|
|
229
|
+
|
|
230
|
+
def add_shutdown_task(self, task: t.Callable[[], t.Awaitable[None]]) -> None:
|
|
231
|
+
self._shutdown_tasks.append(task)
|
|
232
|
+
|
|
233
|
+
def validate_job_id(self, job_id: str) -> bool:
|
|
234
|
+
if not job_id:
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
import uuid
|
|
238
|
+
from contextlib import suppress
|
|
239
|
+
|
|
240
|
+
with suppress(ValueError):
|
|
241
|
+
uuid.UUID(job_id)
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
import re
|
|
245
|
+
|
|
246
|
+
if not re.match(r"^[a-zA-Z0-9_-]+$", job_id):
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
if ".." in job_id or "/" in job_id or "\\" in job_id:
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
import os
|
|
253
|
+
|
|
254
|
+
return os.path.basename(job_id) == job_id
|
|
255
|
+
|
|
256
|
+
async def check_websocket_server_running(self) -> bool:
|
|
257
|
+
import socket
|
|
258
|
+
|
|
259
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
260
|
+
sock.settimeout(1.0)
|
|
261
|
+
result = sock.connect_ex(("localhost", self.websocket_server_port))
|
|
262
|
+
return result == 0
|
|
263
|
+
|
|
264
|
+
async def start_websocket_server(self) -> bool:
|
|
265
|
+
async with self._websocket_process_lock:
|
|
266
|
+
if await self._check_existing_websocket_server():
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
if self.console:
|
|
270
|
+
self.console.print(
|
|
271
|
+
f"🚀 Starting WebSocket server on localhost:{self.websocket_server_port}...",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
await self._spawn_websocket_process()
|
|
276
|
+
await self._register_websocket_cleanup()
|
|
277
|
+
return await self._wait_for_websocket_startup()
|
|
278
|
+
|
|
279
|
+
except Exception as e:
|
|
280
|
+
if self.console:
|
|
281
|
+
self.console.print(f"❌ Failed to start WebSocket server: {e}")
|
|
282
|
+
await self._cleanup_dead_websocket_process()
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
async def _check_existing_websocket_server(self) -> bool:
|
|
286
|
+
if (
|
|
287
|
+
self.websocket_server_process
|
|
288
|
+
and self.websocket_server_process.poll() is None
|
|
289
|
+
):
|
|
290
|
+
if await self.check_websocket_server_running():
|
|
291
|
+
if self.console:
|
|
292
|
+
self.console.print(
|
|
293
|
+
f"✅ WebSocket server already running on port {self.websocket_server_port}",
|
|
294
|
+
)
|
|
295
|
+
return True
|
|
296
|
+
await self._cleanup_dead_websocket_process()
|
|
297
|
+
|
|
298
|
+
if await self.check_websocket_server_running():
|
|
299
|
+
if self.console:
|
|
300
|
+
self.console.print(
|
|
301
|
+
f"⚠️ Port {self.websocket_server_port} already in use by another process",
|
|
302
|
+
)
|
|
303
|
+
return True
|
|
304
|
+
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
async def _spawn_websocket_process(self) -> None:
|
|
308
|
+
import sys
|
|
309
|
+
|
|
310
|
+
self.websocket_server_process = subprocess.Popen(
|
|
311
|
+
[
|
|
312
|
+
sys.executable,
|
|
313
|
+
"-m",
|
|
314
|
+
"crackerjack",
|
|
315
|
+
"--start-websocket-server",
|
|
316
|
+
"--websocket-port",
|
|
317
|
+
str(self.websocket_server_port),
|
|
318
|
+
],
|
|
319
|
+
stdout=subprocess.DEVNULL,
|
|
320
|
+
stderr=subprocess.DEVNULL,
|
|
321
|
+
start_new_session=True,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
async def _register_websocket_cleanup(self) -> None:
|
|
325
|
+
if not self._websocket_cleanup_registered:
|
|
326
|
+
self.add_shutdown_task(self._stop_websocket_server)
|
|
327
|
+
self._websocket_cleanup_registered = True
|
|
328
|
+
|
|
329
|
+
if not self._websocket_health_check_task:
|
|
330
|
+
self._websocket_health_check_task = asyncio.create_task(
|
|
331
|
+
self._websocket_health_monitor(),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
async def _wait_for_websocket_startup(self) -> bool:
|
|
335
|
+
max_attempts = 10
|
|
336
|
+
for _attempt in range(max_attempts):
|
|
337
|
+
await asyncio.sleep(0.5)
|
|
338
|
+
|
|
339
|
+
if self.websocket_server_process.poll() is not None:
|
|
340
|
+
return_code = self.websocket_server_process.returncode
|
|
341
|
+
if self.console:
|
|
342
|
+
self.console.print(
|
|
343
|
+
f"❌ WebSocket server process died during startup (exit code: {return_code})",
|
|
344
|
+
)
|
|
345
|
+
self.websocket_server_process = None
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
if await self.check_websocket_server_running():
|
|
349
|
+
if self.console:
|
|
350
|
+
self.console.print(
|
|
351
|
+
f"✅ WebSocket server started successfully on port {self.websocket_server_port}",
|
|
352
|
+
)
|
|
353
|
+
self.console.print(
|
|
354
|
+
f"📊 Progress available at: ws://localhost:{self.websocket_server_port}/ws/progress/{{job_id}}",
|
|
355
|
+
)
|
|
356
|
+
return True
|
|
357
|
+
|
|
358
|
+
if self.console:
|
|
359
|
+
self.console.print(
|
|
360
|
+
f"❌ WebSocket server failed to start within {max_attempts * 0.5}s",
|
|
361
|
+
)
|
|
362
|
+
await self._cleanup_dead_websocket_process()
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
async def _cleanup_dead_websocket_process(self) -> None:
|
|
366
|
+
if self.websocket_server_process:
|
|
367
|
+
try:
|
|
368
|
+
if self.websocket_server_process.poll() is None:
|
|
369
|
+
self.websocket_server_process.terminate()
|
|
370
|
+
try:
|
|
371
|
+
self.websocket_server_process.wait(timeout=2)
|
|
372
|
+
except subprocess.TimeoutExpired:
|
|
373
|
+
self.websocket_server_process.kill()
|
|
374
|
+
self.websocket_server_process.wait(timeout=1)
|
|
375
|
+
|
|
376
|
+
if self.console:
|
|
377
|
+
self.console.print("🧹 Cleaned up dead WebSocket server process")
|
|
378
|
+
except Exception as e:
|
|
379
|
+
if self.console:
|
|
380
|
+
self.console.print(f"⚠️ Error cleaning up WebSocket process: {e}")
|
|
381
|
+
finally:
|
|
382
|
+
self.websocket_server_process = None
|
|
383
|
+
|
|
384
|
+
async def _stop_websocket_server(self) -> None:
|
|
385
|
+
async with self._websocket_process_lock:
|
|
386
|
+
if not self.websocket_server_process:
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
if self.websocket_server_process.poll() is None:
|
|
391
|
+
await self._terminate_live_websocket_process()
|
|
392
|
+
else:
|
|
393
|
+
await self._handle_dead_websocket_process_cleanup()
|
|
394
|
+
|
|
395
|
+
except Exception as e:
|
|
396
|
+
if self.console:
|
|
397
|
+
self.console.print(f"⚠️ Error stopping WebSocket server: {e}")
|
|
398
|
+
finally:
|
|
399
|
+
self.websocket_server_process = None
|
|
400
|
+
|
|
401
|
+
async def _terminate_live_websocket_process(self) -> None:
|
|
402
|
+
if self.console:
|
|
403
|
+
self.console.print("🛑 Stopping WebSocket server...")
|
|
404
|
+
|
|
405
|
+
self.websocket_server_process.terminate()
|
|
406
|
+
|
|
407
|
+
if await self._wait_for_graceful_termination():
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
await self._force_kill_websocket_process()
|
|
411
|
+
|
|
412
|
+
async def _wait_for_graceful_termination(self) -> bool:
|
|
413
|
+
try:
|
|
414
|
+
self.websocket_server_process.wait(timeout=5)
|
|
415
|
+
if self.console:
|
|
416
|
+
self.console.print("✅ WebSocket server stopped gracefully")
|
|
417
|
+
return True
|
|
418
|
+
except subprocess.TimeoutExpired:
|
|
419
|
+
return False
|
|
420
|
+
|
|
421
|
+
async def _force_kill_websocket_process(self) -> None:
|
|
422
|
+
if self.console:
|
|
423
|
+
self.console.print("⚡ Force killing unresponsive WebSocket server...")
|
|
424
|
+
|
|
425
|
+
self.websocket_server_process.kill()
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
self.websocket_server_process.wait(timeout=2)
|
|
429
|
+
if self.console:
|
|
430
|
+
self.console.print("💀 WebSocket server force killed")
|
|
431
|
+
except subprocess.TimeoutExpired:
|
|
432
|
+
if self.console:
|
|
433
|
+
self.console.print("⚠️ WebSocket server process may be zombified")
|
|
434
|
+
|
|
435
|
+
async def _handle_dead_websocket_process_cleanup(self) -> None:
|
|
436
|
+
if self.console:
|
|
437
|
+
self.console.print("💀 WebSocket server process was already dead")
|
|
438
|
+
|
|
439
|
+
async def get_websocket_server_status(self) -> dict[str, t.Any]:
|
|
440
|
+
async with self._websocket_process_lock:
|
|
441
|
+
status = {
|
|
442
|
+
"port": self.websocket_server_port,
|
|
443
|
+
"process_exists": self.websocket_server_process is not None,
|
|
444
|
+
"process_alive": False,
|
|
445
|
+
"server_responding": False,
|
|
446
|
+
"process_id": None,
|
|
447
|
+
"return_code": None,
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if self.websocket_server_process:
|
|
451
|
+
status["process_id"] = self.websocket_server_process.pid
|
|
452
|
+
poll_result = self.websocket_server_process.poll()
|
|
453
|
+
status["process_alive"] = poll_result is None
|
|
454
|
+
if poll_result is not None:
|
|
455
|
+
status["return_code"] = poll_result
|
|
456
|
+
|
|
457
|
+
status["server_responding"] = await self.check_websocket_server_running()
|
|
458
|
+
|
|
459
|
+
return status
|
|
460
|
+
|
|
461
|
+
async def _websocket_health_monitor(self) -> None:
|
|
462
|
+
while True:
|
|
463
|
+
try:
|
|
464
|
+
await asyncio.sleep(30)
|
|
465
|
+
await self._check_and_restart_websocket()
|
|
466
|
+
except asyncio.CancelledError:
|
|
467
|
+
break
|
|
468
|
+
except Exception as e:
|
|
469
|
+
if self.console:
|
|
470
|
+
self.console.print(f"⚠️ Error in WebSocket health monitor: {e}")
|
|
471
|
+
await asyncio.sleep(60)
|
|
472
|
+
|
|
473
|
+
async def _check_and_restart_websocket(self) -> None:
|
|
474
|
+
async with self._websocket_process_lock:
|
|
475
|
+
if not self.websocket_server_process:
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
if self.websocket_server_process.poll() is not None:
|
|
479
|
+
await self._handle_dead_websocket_process()
|
|
480
|
+
return
|
|
481
|
+
|
|
482
|
+
if not await self.check_websocket_server_running():
|
|
483
|
+
await self._handle_unresponsive_websocket_server()
|
|
484
|
+
|
|
485
|
+
async def _handle_dead_websocket_process(self) -> None:
|
|
486
|
+
return_code = self.websocket_server_process.returncode
|
|
487
|
+
if self.console:
|
|
488
|
+
self.console.print(
|
|
489
|
+
f"⚠️ WebSocket server process died (exit code: {return_code}), attempting restart...",
|
|
490
|
+
)
|
|
491
|
+
self.websocket_server_process = None
|
|
492
|
+
await self._restart_websocket_server()
|
|
493
|
+
|
|
494
|
+
async def _handle_unresponsive_websocket_server(self) -> None:
|
|
495
|
+
if self.console:
|
|
496
|
+
self.console.print("⚠️ WebSocket server not responding, restarting...")
|
|
497
|
+
await self._cleanup_dead_websocket_process()
|
|
498
|
+
await self._restart_websocket_server()
|
|
499
|
+
|
|
500
|
+
async def _restart_websocket_server(self) -> None:
|
|
501
|
+
if await self.start_websocket_server():
|
|
502
|
+
if self.console:
|
|
503
|
+
self.console.print("✅ WebSocket server restarted successfully")
|
|
504
|
+
elif self.console:
|
|
505
|
+
self.console.print("❌ Failed to restart WebSocket server")
|
|
506
|
+
|
|
507
|
+
def safe_print(self, *args, **kwargs) -> None:
|
|
508
|
+
if not self.config.stdio_mode and self.console:
|
|
509
|
+
self.console.print(*args, **kwargs)
|
|
510
|
+
|
|
511
|
+
def create_progress_file_path(self, job_id: str) -> Path:
|
|
512
|
+
if not self.validate_job_id(job_id):
|
|
513
|
+
msg = f"Invalid job_id: {job_id}"
|
|
514
|
+
raise ValueError(msg)
|
|
515
|
+
return self.progress_dir / f"job-{job_id}.json"
|
|
516
|
+
|
|
517
|
+
async def schedule_state_save(
|
|
518
|
+
self,
|
|
519
|
+
save_id: str,
|
|
520
|
+
save_func: t.Callable[[], None],
|
|
521
|
+
) -> None:
|
|
522
|
+
await self.batched_saver.schedule_save(save_id, save_func)
|
|
523
|
+
|
|
524
|
+
def get_current_time(self) -> str:
|
|
525
|
+
"""Get current timestamp as string for progress tracking."""
|
|
526
|
+
import datetime
|
|
527
|
+
|
|
528
|
+
return datetime.datetime.now().isoformat()
|
|
529
|
+
|
|
530
|
+
def get_context_stats(self) -> dict[str, t.Any]:
|
|
531
|
+
return {
|
|
532
|
+
"initialized": self._initialized,
|
|
533
|
+
"stdio_mode": self.config.stdio_mode,
|
|
534
|
+
"project_path": str(self.config.project_path),
|
|
535
|
+
"progress_dir": str(self.progress_dir),
|
|
536
|
+
"components": {
|
|
537
|
+
"cli_runner": self.cli_runner is not None,
|
|
538
|
+
"state_manager": self.state_manager is not None,
|
|
539
|
+
"error_cache": self.error_cache is not None,
|
|
540
|
+
"rate_limiter": self.rate_limiter is not None,
|
|
541
|
+
"batched_saver": self.batched_saver is not None,
|
|
542
|
+
},
|
|
543
|
+
"websocket_server": {
|
|
544
|
+
"port": self.websocket_server_port,
|
|
545
|
+
"process_exists": self.websocket_server_process is not None,
|
|
546
|
+
"health_monitor_running": self._websocket_health_check_task is not None
|
|
547
|
+
and not self._websocket_health_check_task.done(),
|
|
548
|
+
"cleanup_registered": self._websocket_cleanup_registered,
|
|
549
|
+
},
|
|
550
|
+
"progress_queue": {
|
|
551
|
+
"maxsize": self.progress_queue.maxsize,
|
|
552
|
+
"current_size": self.progress_queue.qsize(),
|
|
553
|
+
"full": self.progress_queue.full(),
|
|
554
|
+
},
|
|
555
|
+
"startup_tasks": len(self._startup_tasks),
|
|
556
|
+
"shutdown_tasks": len(self._shutdown_tasks),
|
|
557
|
+
"batched_saving": self.batched_saver.get_stats(),
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class MCPContextManager:
|
|
562
|
+
def __init__(self, config: MCPServerConfig) -> None:
|
|
563
|
+
self.context = MCPServerContext(config)
|
|
564
|
+
|
|
565
|
+
async def __aenter__(self) -> MCPServerContext:
|
|
566
|
+
await self.context.initialize()
|
|
567
|
+
return self.context
|
|
568
|
+
|
|
569
|
+
async def __aexit__(
|
|
570
|
+
self,
|
|
571
|
+
exc_type: type[BaseException] | None,
|
|
572
|
+
exc_val: BaseException | None,
|
|
573
|
+
_exc_tb: TracebackType | None,
|
|
574
|
+
) -> None:
|
|
575
|
+
await self.context.shutdown()
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
_global_context: MCPServerContext | None = None
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def get_context() -> MCPServerContext:
|
|
582
|
+
if _global_context is None:
|
|
583
|
+
msg = "MCP server context not initialized. Call set_context() first."
|
|
584
|
+
raise RuntimeError(
|
|
585
|
+
msg,
|
|
586
|
+
)
|
|
587
|
+
return _global_context
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def set_context(context: MCPServerContext) -> None:
|
|
591
|
+
global _global_context
|
|
592
|
+
_global_context = context
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def clear_context() -> None:
|
|
596
|
+
global _global_context
|
|
597
|
+
_global_context = None
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def get_console() -> Console:
|
|
601
|
+
return get_context().console or Console()
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def get_state_manager() -> StateManager | None:
|
|
605
|
+
return get_context().state_manager
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def get_error_cache() -> ErrorCache | None:
|
|
609
|
+
return get_context().error_cache
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def get_rate_limiter() -> RateLimitMiddleware | None:
|
|
613
|
+
return get_context().rate_limiter
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def safe_print(*args, **kwargs) -> None:
|
|
617
|
+
get_context().safe_print(*args, **kwargs)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def validate_job_id(job_id: str) -> bool:
|
|
621
|
+
return get_context().validate_job_id(job_id)
|