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.
Files changed (55) hide show
  1. devloop/__init__.py +3 -0
  2. devloop/agents/__init__.py +33 -0
  3. devloop/agents/agent_health_monitor.py +105 -0
  4. devloop/agents/ci_monitor.py +237 -0
  5. devloop/agents/code_rabbit.py +248 -0
  6. devloop/agents/doc_lifecycle.py +374 -0
  7. devloop/agents/echo.py +24 -0
  8. devloop/agents/file_logger.py +46 -0
  9. devloop/agents/formatter.py +511 -0
  10. devloop/agents/git_commit_assistant.py +421 -0
  11. devloop/agents/linter.py +399 -0
  12. devloop/agents/performance_profiler.py +284 -0
  13. devloop/agents/security_scanner.py +322 -0
  14. devloop/agents/snyk.py +292 -0
  15. devloop/agents/test_runner.py +484 -0
  16. devloop/agents/type_checker.py +242 -0
  17. devloop/cli/__init__.py +1 -0
  18. devloop/cli/commands/__init__.py +1 -0
  19. devloop/cli/commands/custom_agents.py +144 -0
  20. devloop/cli/commands/feedback.py +161 -0
  21. devloop/cli/commands/summary.py +50 -0
  22. devloop/cli/main.py +430 -0
  23. devloop/cli/main_v1.py +144 -0
  24. devloop/collectors/__init__.py +17 -0
  25. devloop/collectors/base.py +55 -0
  26. devloop/collectors/filesystem.py +126 -0
  27. devloop/collectors/git.py +171 -0
  28. devloop/collectors/manager.py +159 -0
  29. devloop/collectors/process.py +221 -0
  30. devloop/collectors/system.py +195 -0
  31. devloop/core/__init__.py +21 -0
  32. devloop/core/agent.py +206 -0
  33. devloop/core/agent_template.py +498 -0
  34. devloop/core/amp_integration.py +166 -0
  35. devloop/core/auto_fix.py +224 -0
  36. devloop/core/config.py +272 -0
  37. devloop/core/context.py +0 -0
  38. devloop/core/context_store.py +530 -0
  39. devloop/core/contextual_feedback.py +311 -0
  40. devloop/core/custom_agent.py +439 -0
  41. devloop/core/debug_trace.py +289 -0
  42. devloop/core/event.py +105 -0
  43. devloop/core/event_store.py +316 -0
  44. devloop/core/feedback.py +311 -0
  45. devloop/core/learning.py +351 -0
  46. devloop/core/manager.py +219 -0
  47. devloop/core/performance.py +433 -0
  48. devloop/core/proactive_feedback.py +302 -0
  49. devloop/core/summary_formatter.py +159 -0
  50. devloop/core/summary_generator.py +275 -0
  51. devloop-0.2.0.dist-info/METADATA +705 -0
  52. devloop-0.2.0.dist-info/RECORD +55 -0
  53. devloop-0.2.0.dist-info/WHEEL +4 -0
  54. devloop-0.2.0.dist-info/entry_points.txt +3 -0
  55. 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")