devloop 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devloop/__init__.py +3 -0
- devloop/agents/__init__.py +33 -0
- devloop/agents/agent_health_monitor.py +105 -0
- devloop/agents/ci_monitor.py +237 -0
- devloop/agents/code_rabbit.py +248 -0
- devloop/agents/doc_lifecycle.py +374 -0
- devloop/agents/echo.py +24 -0
- devloop/agents/file_logger.py +46 -0
- devloop/agents/formatter.py +511 -0
- devloop/agents/git_commit_assistant.py +421 -0
- devloop/agents/linter.py +399 -0
- devloop/agents/performance_profiler.py +284 -0
- devloop/agents/security_scanner.py +322 -0
- devloop/agents/snyk.py +292 -0
- devloop/agents/test_runner.py +484 -0
- devloop/agents/type_checker.py +242 -0
- devloop/cli/__init__.py +1 -0
- devloop/cli/commands/__init__.py +1 -0
- devloop/cli/commands/custom_agents.py +144 -0
- devloop/cli/commands/feedback.py +161 -0
- devloop/cli/commands/summary.py +50 -0
- devloop/cli/main.py +430 -0
- devloop/cli/main_v1.py +144 -0
- devloop/collectors/__init__.py +17 -0
- devloop/collectors/base.py +55 -0
- devloop/collectors/filesystem.py +126 -0
- devloop/collectors/git.py +171 -0
- devloop/collectors/manager.py +159 -0
- devloop/collectors/process.py +221 -0
- devloop/collectors/system.py +195 -0
- devloop/core/__init__.py +21 -0
- devloop/core/agent.py +206 -0
- devloop/core/agent_template.py +498 -0
- devloop/core/amp_integration.py +166 -0
- devloop/core/auto_fix.py +224 -0
- devloop/core/config.py +272 -0
- devloop/core/context.py +0 -0
- devloop/core/context_store.py +530 -0
- devloop/core/contextual_feedback.py +311 -0
- devloop/core/custom_agent.py +439 -0
- devloop/core/debug_trace.py +289 -0
- devloop/core/event.py +105 -0
- devloop/core/event_store.py +316 -0
- devloop/core/feedback.py +311 -0
- devloop/core/learning.py +351 -0
- devloop/core/manager.py +219 -0
- devloop/core/performance.py +433 -0
- devloop/core/proactive_feedback.py +302 -0
- devloop/core/summary_formatter.py +159 -0
- devloop/core/summary_generator.py +275 -0
- devloop-0.2.0.dist-info/METADATA +705 -0
- devloop-0.2.0.dist-info/RECORD +55 -0
- devloop-0.2.0.dist-info/WHEEL +4 -0
- devloop-0.2.0.dist-info/entry_points.txt +3 -0
- devloop-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Filesystem event collector using watchdog."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
8
|
+
from watchdog.observers import Observer
|
|
9
|
+
|
|
10
|
+
from devloop.collectors.base import BaseCollector
|
|
11
|
+
from devloop.core.event import EventBus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FileSystemCollector(BaseCollector, FileSystemEventHandler):
|
|
15
|
+
"""Collects filesystem events and emits them to the event bus."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, event_bus: EventBus, config: Dict[str, Any] | None = None):
|
|
18
|
+
super().__init__("filesystem", event_bus, config)
|
|
19
|
+
|
|
20
|
+
self.watch_paths = self.config.get("watch_paths", ["."])
|
|
21
|
+
self.ignore_patterns = self.config.get(
|
|
22
|
+
"ignore_patterns",
|
|
23
|
+
[
|
|
24
|
+
"*/.git/*",
|
|
25
|
+
"*/__pycache__/*",
|
|
26
|
+
"*/.devloop/*",
|
|
27
|
+
"*/node_modules/*",
|
|
28
|
+
"*/.venv/*",
|
|
29
|
+
"*/venv/*",
|
|
30
|
+
],
|
|
31
|
+
)
|
|
32
|
+
self.observer = Observer()
|
|
33
|
+
self._loop = None # Store reference to the event loop
|
|
34
|
+
|
|
35
|
+
def should_ignore(self, path: str) -> bool:
|
|
36
|
+
"""Check if path should be ignored."""
|
|
37
|
+
path_obj = Path(path)
|
|
38
|
+
|
|
39
|
+
for pattern in self.ignore_patterns:
|
|
40
|
+
# Simple pattern matching (could be improved with fnmatch)
|
|
41
|
+
pattern_clean = pattern.replace("*/", "").replace("/*", "")
|
|
42
|
+
if pattern_clean in str(path_obj):
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
def on_created(self, event: FileSystemEvent) -> None:
|
|
48
|
+
"""Handle file/directory created."""
|
|
49
|
+
if event.is_directory or self.should_ignore(event.src_path):
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
self._emit_event_sync("file:created", event.src_path)
|
|
53
|
+
|
|
54
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
55
|
+
"""Handle file/directory modified."""
|
|
56
|
+
if event.is_directory or self.should_ignore(event.src_path):
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
self._emit_event_sync("file:modified", event.src_path)
|
|
60
|
+
|
|
61
|
+
def on_deleted(self, event: FileSystemEvent) -> None:
|
|
62
|
+
"""Handle file/directory deleted."""
|
|
63
|
+
if event.is_directory or self.should_ignore(event.src_path):
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
self._emit_event_sync("file:deleted", event.src_path)
|
|
67
|
+
|
|
68
|
+
def on_moved(self, event: FileSystemEvent) -> None:
|
|
69
|
+
"""Handle file/directory moved/renamed."""
|
|
70
|
+
if event.is_directory or self.should_ignore(event.src_path):
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
self._emit_event_sync(
|
|
74
|
+
"file:moved",
|
|
75
|
+
event.src_path,
|
|
76
|
+
{"dest_path": event.dest_path if hasattr(event, "dest_path") else None},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _emit_event_sync(
|
|
80
|
+
self, event_type: str, path: str, extra_payload: Dict[str, Any] | None = None
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Emit a filesystem event to the event bus (synchronous version for watchdog threads)."""
|
|
83
|
+
payload = {"path": path, "absolute_path": str(Path(path).absolute())}
|
|
84
|
+
|
|
85
|
+
if extra_payload:
|
|
86
|
+
payload.update(extra_payload)
|
|
87
|
+
|
|
88
|
+
# Schedule coroutine from watchdog thread to asyncio event loop
|
|
89
|
+
# This is thread-safe and handles the watchdog (threading) -> asyncio bridge
|
|
90
|
+
if self._loop and self._loop.is_running():
|
|
91
|
+
asyncio.run_coroutine_threadsafe(
|
|
92
|
+
self._emit_event(event_type, payload, "normal", "filesystem"),
|
|
93
|
+
self._loop,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def start(self) -> None:
|
|
97
|
+
"""Start watching filesystem."""
|
|
98
|
+
if self.is_running:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
self._set_running(True)
|
|
102
|
+
|
|
103
|
+
# Capture the current event loop for thread-safe event emission
|
|
104
|
+
self._loop = asyncio.get_running_loop()
|
|
105
|
+
|
|
106
|
+
# Schedule watches for all paths
|
|
107
|
+
for path in self.watch_paths:
|
|
108
|
+
watch_path = Path(path).absolute()
|
|
109
|
+
if watch_path.exists():
|
|
110
|
+
self.observer.schedule(self, str(watch_path), recursive=True)
|
|
111
|
+
self.logger.info(f"Watching: {watch_path}")
|
|
112
|
+
else:
|
|
113
|
+
self.logger.warning(f"Path does not exist: {watch_path}")
|
|
114
|
+
|
|
115
|
+
self.observer.start()
|
|
116
|
+
self.logger.info("Filesystem collector started")
|
|
117
|
+
|
|
118
|
+
async def stop(self) -> None:
|
|
119
|
+
"""Stop watching filesystem."""
|
|
120
|
+
if not self.is_running:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
self._set_running(False)
|
|
124
|
+
self.observer.stop()
|
|
125
|
+
self.observer.join()
|
|
126
|
+
self.logger.info("Filesystem collector stopped")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Git event collector using git hooks and monitoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess # nosec B404 - Required for git operations
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from devloop.collectors.base import BaseCollector
|
|
10
|
+
from devloop.core.event import EventBus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitCollector(BaseCollector):
|
|
14
|
+
"""Collects git-related events through hooks and monitoring."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, event_bus: EventBus, config: Optional[Dict[str, Any]] = None):
|
|
17
|
+
super().__init__("git", event_bus, config)
|
|
18
|
+
self.git_hooks = [
|
|
19
|
+
"pre-commit",
|
|
20
|
+
"prepare-commit-msg",
|
|
21
|
+
"commit-msg",
|
|
22
|
+
"post-commit",
|
|
23
|
+
"pre-rebase",
|
|
24
|
+
"post-checkout",
|
|
25
|
+
"post-merge",
|
|
26
|
+
"pre-push",
|
|
27
|
+
"post-rewrite",
|
|
28
|
+
]
|
|
29
|
+
self.repo_path = Path(self.config.get("repo_path", ".")).absolute()
|
|
30
|
+
self.installed_hooks: Dict[str, Path] = {}
|
|
31
|
+
|
|
32
|
+
def _is_git_repo(self) -> bool:
|
|
33
|
+
"""Check if current directory is a git repository."""
|
|
34
|
+
try:
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
["git", "rev-parse", "--git-dir"], # nosec
|
|
37
|
+
cwd=self.repo_path,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
check=True,
|
|
41
|
+
)
|
|
42
|
+
return result.returncode == 0
|
|
43
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def _get_hooks_dir(self) -> Path:
|
|
47
|
+
"""Get the git hooks directory."""
|
|
48
|
+
return self.repo_path / ".git" / "hooks"
|
|
49
|
+
|
|
50
|
+
def _install_hook(self, hook_name: str) -> bool:
|
|
51
|
+
"""Install a git hook script."""
|
|
52
|
+
hooks_dir = self._get_hooks_dir()
|
|
53
|
+
hook_path = hooks_dir / hook_name
|
|
54
|
+
|
|
55
|
+
# Create hook script content
|
|
56
|
+
hook_script = f"""#!/bin/bash
|
|
57
|
+
# Claude Agents Git Hook - {hook_name}
|
|
58
|
+
|
|
59
|
+
# Export environment for Python
|
|
60
|
+
export PYTHONPATH="$(dirname $(dirname $(dirname $(dirname $(dirname "$0")))))/src"
|
|
61
|
+
|
|
62
|
+
# Call the collector
|
|
63
|
+
python3 -c "
|
|
64
|
+
import asyncio
|
|
65
|
+
import sys
|
|
66
|
+
import os
|
|
67
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))))
|
|
68
|
+
from devloop.collectors.git import GitCollector
|
|
69
|
+
|
|
70
|
+
async def emit_hook_event():
|
|
71
|
+
# Import here to avoid circular imports
|
|
72
|
+
from devloop.core.event import EventBus, Event, Priority
|
|
73
|
+
import os
|
|
74
|
+
|
|
75
|
+
event_bus = EventBus()
|
|
76
|
+
collector = GitCollector(event_bus)
|
|
77
|
+
|
|
78
|
+
payload = {{
|
|
79
|
+
'hook': '{hook_name}',
|
|
80
|
+
'repo_path': '{str(self.repo_path)}',
|
|
81
|
+
'args': list(sys.argv[1:]),
|
|
82
|
+
'env': dict(os.environ)
|
|
83
|
+
}}
|
|
84
|
+
|
|
85
|
+
await collector._emit_event(f'git:{hook_name}', payload, 'high')
|
|
86
|
+
|
|
87
|
+
asyncio.run(emit_hook_event())
|
|
88
|
+
"
|
|
89
|
+
|
|
90
|
+
# Continue with original hook if it exists
|
|
91
|
+
if [ -f "$0.original" ]; then
|
|
92
|
+
exec "$0.original" "$@"
|
|
93
|
+
fi
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
# Backup original hook if it exists
|
|
98
|
+
if hook_path.exists():
|
|
99
|
+
backup_path = hook_path.with_suffix(".original")
|
|
100
|
+
if not backup_path.exists():
|
|
101
|
+
hook_path.rename(backup_path)
|
|
102
|
+
self.logger.info(f"Backed up original {hook_name} hook")
|
|
103
|
+
|
|
104
|
+
# Write new hook
|
|
105
|
+
hook_path.write_text(hook_script)
|
|
106
|
+
hook_path.chmod(0o755)
|
|
107
|
+
|
|
108
|
+
self.installed_hooks[hook_name] = hook_path
|
|
109
|
+
self.logger.info(f"Installed {hook_name} hook")
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
except Exception as e:
|
|
113
|
+
self.logger.error(f"Failed to install {hook_name} hook: {e}")
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
def _uninstall_hooks(self) -> None:
|
|
117
|
+
"""Uninstall all git hooks."""
|
|
118
|
+
for hook_name, hook_path in self.installed_hooks.items():
|
|
119
|
+
try:
|
|
120
|
+
# Restore original hook if it exists
|
|
121
|
+
original_path = hook_path.with_suffix(".original")
|
|
122
|
+
if original_path.exists():
|
|
123
|
+
original_path.rename(hook_path)
|
|
124
|
+
self.logger.info(f"Restored original {hook_name} hook")
|
|
125
|
+
elif hook_path.exists():
|
|
126
|
+
hook_path.unlink()
|
|
127
|
+
self.logger.info(f"Removed {hook_name} hook")
|
|
128
|
+
|
|
129
|
+
except Exception as e:
|
|
130
|
+
self.logger.error(f"Failed to uninstall {hook_name} hook: {e}")
|
|
131
|
+
|
|
132
|
+
self.installed_hooks.clear()
|
|
133
|
+
|
|
134
|
+
async def start(self) -> None:
|
|
135
|
+
"""Start the git collector."""
|
|
136
|
+
if self.is_running:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
if not self._is_git_repo():
|
|
140
|
+
self.logger.warning(f"Not a git repository: {self.repo_path}")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
self._set_running(True)
|
|
144
|
+
|
|
145
|
+
# Install git hooks
|
|
146
|
+
hooks_dir = self._get_hooks_dir()
|
|
147
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
|
|
149
|
+
installed_count = 0
|
|
150
|
+
for hook_name in self.git_hooks:
|
|
151
|
+
if self.config.get("auto_install_hooks", True):
|
|
152
|
+
if self._install_hook(hook_name):
|
|
153
|
+
installed_count += 1
|
|
154
|
+
|
|
155
|
+
self.logger.info(f"Git collector started - installed {installed_count} hooks")
|
|
156
|
+
|
|
157
|
+
async def stop(self) -> None:
|
|
158
|
+
"""Stop the git collector."""
|
|
159
|
+
if not self.is_running:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
# Uninstall hooks if we installed them
|
|
163
|
+
if self.config.get("auto_install_hooks", True):
|
|
164
|
+
self._uninstall_hooks()
|
|
165
|
+
|
|
166
|
+
self._set_running(False)
|
|
167
|
+
self.logger.info("Git collector stopped")
|
|
168
|
+
|
|
169
|
+
async def emit_git_event(self, event_type: str, payload: Dict[str, Any]) -> None:
|
|
170
|
+
"""Manually emit a git event (for testing or external triggers)."""
|
|
171
|
+
await self._emit_event(f"git:{event_type}", payload, "normal", "git")
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Collector manager for coordinating all event collectors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Dict, List, Optional, Type
|
|
8
|
+
|
|
9
|
+
from devloop.collectors.base import BaseCollector
|
|
10
|
+
from devloop.collectors.filesystem import FileSystemCollector
|
|
11
|
+
from devloop.collectors.git import GitCollector
|
|
12
|
+
from devloop.collectors.process import ProcessCollector
|
|
13
|
+
from devloop.collectors.system import SystemCollector
|
|
14
|
+
from devloop.core.event import EventBus
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CollectorManager:
|
|
18
|
+
"""Manages all event collectors and their lifecycle."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, event_bus: EventBus):
|
|
21
|
+
self.event_bus = event_bus
|
|
22
|
+
self.collectors: Dict[str, BaseCollector] = {}
|
|
23
|
+
self.logger = logging.getLogger("collector_manager")
|
|
24
|
+
self._running = False
|
|
25
|
+
|
|
26
|
+
# Register built-in collectors
|
|
27
|
+
self._collector_classes: Dict[str, Type[BaseCollector]] = {
|
|
28
|
+
"filesystem": FileSystemCollector,
|
|
29
|
+
"git": GitCollector,
|
|
30
|
+
"process": ProcessCollector,
|
|
31
|
+
"system": SystemCollector,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def register_collector_class(
|
|
35
|
+
self, name: str, collector_class: Type[BaseCollector]
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Register a new collector class."""
|
|
38
|
+
self._collector_classes[name] = collector_class
|
|
39
|
+
self.logger.info(f"Registered collector class: {name}")
|
|
40
|
+
|
|
41
|
+
def create_collector(
|
|
42
|
+
self, name: str, config: Optional[Dict[str, Any]] = None
|
|
43
|
+
) -> Optional[BaseCollector]:
|
|
44
|
+
"""Create a collector instance."""
|
|
45
|
+
if name not in self._collector_classes:
|
|
46
|
+
self.logger.error(f"Unknown collector type: {name}")
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
collector_class = self._collector_classes[name]
|
|
51
|
+
collector = collector_class(self.event_bus, config)
|
|
52
|
+
self.collectors[name] = collector
|
|
53
|
+
self.logger.info(f"Created collector: {name}")
|
|
54
|
+
return collector
|
|
55
|
+
except Exception as e:
|
|
56
|
+
self.logger.error(f"Failed to create collector {name}: {e}")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
async def start_collector(self, name: str) -> bool:
|
|
60
|
+
"""Start a specific collector."""
|
|
61
|
+
if name not in self.collectors:
|
|
62
|
+
self.logger.error(f"Collector not found: {name}")
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
await self.collectors[name].start()
|
|
67
|
+
self.logger.info(f"Started collector: {name}")
|
|
68
|
+
return True
|
|
69
|
+
except Exception as e:
|
|
70
|
+
self.logger.error(f"Failed to start collector {name}: {e}")
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
async def stop_collector(self, name: str) -> bool:
|
|
74
|
+
"""Stop a specific collector."""
|
|
75
|
+
if name not in self.collectors:
|
|
76
|
+
self.logger.warning(f"Collector not found: {name}")
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
await self.collectors[name].stop()
|
|
81
|
+
self.logger.info(f"Stopped collector: {name}")
|
|
82
|
+
return True
|
|
83
|
+
except Exception as e:
|
|
84
|
+
self.logger.error(f"Failed to stop collector {name}: {e}")
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
async def start_all(self) -> None:
|
|
88
|
+
"""Start all registered collectors."""
|
|
89
|
+
if self._running:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
self._running = True
|
|
93
|
+
self.logger.info("Starting all collectors...")
|
|
94
|
+
|
|
95
|
+
# Start collectors in dependency order
|
|
96
|
+
start_order = ["filesystem", "git", "process"] # filesystem first, then others
|
|
97
|
+
|
|
98
|
+
started_count = 0
|
|
99
|
+
for name in start_order:
|
|
100
|
+
if name in self.collectors:
|
|
101
|
+
if await self.start_collector(name):
|
|
102
|
+
started_count += 1
|
|
103
|
+
|
|
104
|
+
# Start any remaining collectors not in the ordered list
|
|
105
|
+
for name, collector in self.collectors.items():
|
|
106
|
+
if name not in start_order and not collector.is_running:
|
|
107
|
+
if await self.start_collector(name):
|
|
108
|
+
started_count += 1
|
|
109
|
+
|
|
110
|
+
self.logger.info(f"Started {started_count}/{len(self.collectors)} collectors")
|
|
111
|
+
|
|
112
|
+
async def stop_all(self) -> None:
|
|
113
|
+
"""Stop all collectors."""
|
|
114
|
+
if not self._running:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
self._running = False
|
|
118
|
+
self.logger.info("Stopping all collectors...")
|
|
119
|
+
|
|
120
|
+
# Stop in reverse order
|
|
121
|
+
stop_tasks = []
|
|
122
|
+
for collector in self.collectors.values():
|
|
123
|
+
if collector.is_running:
|
|
124
|
+
stop_tasks.append(self._safe_stop_collector(collector))
|
|
125
|
+
|
|
126
|
+
if stop_tasks:
|
|
127
|
+
await asyncio.gather(*stop_tasks, return_exceptions=True)
|
|
128
|
+
|
|
129
|
+
self.logger.info("All collectors stopped")
|
|
130
|
+
|
|
131
|
+
async def _safe_stop_collector(self, collector: BaseCollector) -> None:
|
|
132
|
+
"""Safely stop a collector with error handling."""
|
|
133
|
+
try:
|
|
134
|
+
await collector.stop()
|
|
135
|
+
except Exception as e:
|
|
136
|
+
self.logger.error(f"Error stopping collector {collector.name}: {e}")
|
|
137
|
+
|
|
138
|
+
def get_status(self) -> Dict[str, Dict[str, Any]]:
|
|
139
|
+
"""Get status of all collectors."""
|
|
140
|
+
return {
|
|
141
|
+
name: {
|
|
142
|
+
"running": collector.is_running,
|
|
143
|
+
"type": type(collector).__name__,
|
|
144
|
+
"config": collector.config,
|
|
145
|
+
}
|
|
146
|
+
for name, collector in self.collectors.items()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def get_collector(self, name: str) -> Optional[BaseCollector]:
|
|
150
|
+
"""Get a collector by name."""
|
|
151
|
+
return self.collectors.get(name)
|
|
152
|
+
|
|
153
|
+
def list_available_collectors(self) -> List[str]:
|
|
154
|
+
"""List all available collector types."""
|
|
155
|
+
return list(self._collector_classes.keys())
|
|
156
|
+
|
|
157
|
+
def list_active_collectors(self) -> List[str]:
|
|
158
|
+
"""List names of active collectors."""
|
|
159
|
+
return list(self.collectors.keys())
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Process event collector using psutil monitoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import psutil
|
|
11
|
+
|
|
12
|
+
HAS_PSUTIL = True
|
|
13
|
+
except ImportError:
|
|
14
|
+
HAS_PSUTIL = False
|
|
15
|
+
psutil = None
|
|
16
|
+
|
|
17
|
+
from devloop.collectors.base import BaseCollector
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProcessCollector(BaseCollector):
|
|
21
|
+
"""Collects process-related events like script completion and build events."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
event_bus: Any, # EventBus type (avoiding circular import)
|
|
26
|
+
config: Optional[Dict[str, Any]] = None,
|
|
27
|
+
):
|
|
28
|
+
super().__init__("process", event_bus, config)
|
|
29
|
+
|
|
30
|
+
if not HAS_PSUTIL:
|
|
31
|
+
self.logger.warning("psutil not available - process monitoring disabled")
|
|
32
|
+
self._psutil_available = False
|
|
33
|
+
else:
|
|
34
|
+
self._psutil_available = True
|
|
35
|
+
|
|
36
|
+
self.monitored_processes: Dict[int, Dict[str, Any]] = {}
|
|
37
|
+
self.monitoring_patterns = self.config.get(
|
|
38
|
+
"patterns",
|
|
39
|
+
[
|
|
40
|
+
"pytest",
|
|
41
|
+
"python",
|
|
42
|
+
"node",
|
|
43
|
+
"npm",
|
|
44
|
+
"yarn",
|
|
45
|
+
"make",
|
|
46
|
+
"gradle",
|
|
47
|
+
"maven",
|
|
48
|
+
"cargo",
|
|
49
|
+
"go",
|
|
50
|
+
"rustc",
|
|
51
|
+
],
|
|
52
|
+
)
|
|
53
|
+
self._monitoring_task: Optional[asyncio.Task] = None
|
|
54
|
+
|
|
55
|
+
def _should_monitor_process(self, process: Any) -> bool:
|
|
56
|
+
"""Check if a process should be monitored."""
|
|
57
|
+
if not self._psutil_available:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
cmdline = process.cmdline()
|
|
62
|
+
process_name = process.name().lower()
|
|
63
|
+
|
|
64
|
+
# Check if process name matches any pattern
|
|
65
|
+
for pattern in self.monitoring_patterns:
|
|
66
|
+
if pattern.lower() in process_name:
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
# Check command line for build/dev scripts
|
|
70
|
+
cmdline_str = " ".join(cmdline).lower()
|
|
71
|
+
if any(
|
|
72
|
+
script in cmdline_str
|
|
73
|
+
for script in ["test", "build", "lint", "format", "check", "run"]
|
|
74
|
+
):
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
except (psutil.AccessDenied, psutil.NoSuchProcess):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
async def _monitor_processes(self) -> None:
|
|
83
|
+
"""Monitor running processes and track their completion."""
|
|
84
|
+
if not self._psutil_available:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
while self.is_running:
|
|
88
|
+
try:
|
|
89
|
+
# Get current processes
|
|
90
|
+
current_pids = set()
|
|
91
|
+
|
|
92
|
+
for process in psutil.process_iter(
|
|
93
|
+
["pid", "name", "cmdline", "create_time"]
|
|
94
|
+
):
|
|
95
|
+
try:
|
|
96
|
+
pid = process.info["pid"]
|
|
97
|
+
current_pids.add(pid)
|
|
98
|
+
|
|
99
|
+
# Check if we should monitor this process
|
|
100
|
+
if self._should_monitor_process(process):
|
|
101
|
+
if pid not in self.monitored_processes:
|
|
102
|
+
# New process to monitor
|
|
103
|
+
self.monitored_processes[pid] = {
|
|
104
|
+
"info": process.info,
|
|
105
|
+
"start_time": process.info.get(
|
|
106
|
+
"create_time", time.time()
|
|
107
|
+
),
|
|
108
|
+
}
|
|
109
|
+
self.logger.debug(
|
|
110
|
+
f"Started monitoring process {pid}: {process.info['name']}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
except (psutil.AccessDenied, psutil.NoSuchProcess):
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# Check for completed processes
|
|
117
|
+
completed_pids = []
|
|
118
|
+
for pid, process_data in self.monitored_processes.items():
|
|
119
|
+
if pid not in current_pids:
|
|
120
|
+
# Process completed
|
|
121
|
+
completed_pids.append(pid)
|
|
122
|
+
await self._handle_process_completion(pid, process_data)
|
|
123
|
+
|
|
124
|
+
# Remove completed processes
|
|
125
|
+
for pid in completed_pids:
|
|
126
|
+
del self.monitored_processes[pid]
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
self.logger.error(f"Error monitoring processes: {e}")
|
|
130
|
+
|
|
131
|
+
await asyncio.sleep(1.0) # Check every second
|
|
132
|
+
|
|
133
|
+
async def _handle_process_completion(
|
|
134
|
+
self, pid: int, process_data: Dict[str, Any]
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Handle process completion event."""
|
|
137
|
+
try:
|
|
138
|
+
info = process_data["info"]
|
|
139
|
+
start_time = process_data["start_time"]
|
|
140
|
+
duration = time.time() - start_time
|
|
141
|
+
|
|
142
|
+
# Determine event type based on process
|
|
143
|
+
event_type = "process:completed"
|
|
144
|
+
process_name = info.get("name", "unknown")
|
|
145
|
+
|
|
146
|
+
# Categorize the process
|
|
147
|
+
if any(term in process_name.lower() for term in ["pytest", "test"]):
|
|
148
|
+
event_type = "test:completed"
|
|
149
|
+
elif any(
|
|
150
|
+
term in process_name.lower()
|
|
151
|
+
for term in ["lint", "flake8", "ruff", "eslint"]
|
|
152
|
+
):
|
|
153
|
+
event_type = "lint:completed"
|
|
154
|
+
elif any(
|
|
155
|
+
term in process_name.lower() for term in ["format", "black", "prettier"]
|
|
156
|
+
):
|
|
157
|
+
event_type = "format:completed"
|
|
158
|
+
elif any(
|
|
159
|
+
term in process_name.lower()
|
|
160
|
+
for term in ["build", "make", "gradle", "maven", "cargo"]
|
|
161
|
+
):
|
|
162
|
+
event_type = "build:completed"
|
|
163
|
+
|
|
164
|
+
payload = {
|
|
165
|
+
"pid": pid,
|
|
166
|
+
"name": process_name,
|
|
167
|
+
"cmdline": info.get("cmdline", []),
|
|
168
|
+
"duration": duration,
|
|
169
|
+
"start_time": start_time,
|
|
170
|
+
"end_time": time.time(),
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await self._emit_event(event_type, payload, "normal", "process")
|
|
174
|
+
self.logger.info(
|
|
175
|
+
f"Process completed: {process_name} (PID: {pid}, duration: {duration:.2f}s)"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
except Exception as e:
|
|
179
|
+
self.logger.error(f"Error handling process completion for PID {pid}: {e}")
|
|
180
|
+
|
|
181
|
+
async def start(self) -> None:
|
|
182
|
+
"""Start the process collector."""
|
|
183
|
+
if self.is_running:
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
if not self._psutil_available:
|
|
187
|
+
self.logger.error("Cannot start process collector - psutil not available")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
self._set_running(True)
|
|
191
|
+
|
|
192
|
+
# Start monitoring task
|
|
193
|
+
self._monitoring_task = asyncio.create_task(self._monitor_processes())
|
|
194
|
+
|
|
195
|
+
self.logger.info("Process collector started")
|
|
196
|
+
|
|
197
|
+
async def stop(self) -> None:
|
|
198
|
+
"""Stop the process collector."""
|
|
199
|
+
if not self.is_running:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
self._set_running(False)
|
|
203
|
+
|
|
204
|
+
# Cancel monitoring task
|
|
205
|
+
if self._monitoring_task:
|
|
206
|
+
self._monitoring_task.cancel()
|
|
207
|
+
try:
|
|
208
|
+
await self._monitoring_task
|
|
209
|
+
except asyncio.CancelledError:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
# Clear monitored processes
|
|
213
|
+
self.monitored_processes.clear()
|
|
214
|
+
|
|
215
|
+
self.logger.info("Process collector stopped")
|
|
216
|
+
|
|
217
|
+
async def emit_process_event(
|
|
218
|
+
self, event_type: str, payload: Dict[str, Any]
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Manually emit a process event (for testing or external triggers)."""
|
|
221
|
+
await self._emit_event(f"process:{event_type}", payload, "normal", "process")
|