intent_engine/dashboard/
registry.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5
6const REGISTRY_FILE: &str = ".intent-engine/projects.json";
7const MIN_PORT: u16 = 3030;
8const MAX_PORT: u16 = 3099;
9const VERSION: &str = "1.0";
10
11/// Global project registry for managing multiple Dashboard instances
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ProjectRegistry {
14    pub version: String,
15    pub projects: Vec<RegisteredProject>,
16    pub next_port: u16,
17}
18
19/// A registered project with Dashboard instance
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct RegisteredProject {
22    pub path: PathBuf,
23    pub name: String,
24    pub port: u16,
25    pub pid: Option<u32>,
26    pub started_at: String,
27    pub db_path: PathBuf,
28
29    // MCP connection tracking
30    #[serde(default)]
31    pub mcp_connected: bool,
32    #[serde(default)]
33    pub mcp_last_seen: Option<String>,
34    #[serde(default)]
35    pub mcp_agent: Option<String>,
36}
37
38impl ProjectRegistry {
39    /// Create a new empty registry
40    pub fn new() -> Self {
41        Self {
42            version: VERSION.to_string(),
43            projects: Vec::new(),
44            next_port: MIN_PORT,
45        }
46    }
47
48    /// Get the registry file path
49    fn registry_path() -> Result<PathBuf> {
50        let home = dirs::home_dir().context("Failed to get home directory")?;
51        Ok(home.join(REGISTRY_FILE))
52    }
53
54    /// Load registry from file, or create new if doesn't exist
55    pub fn load() -> Result<Self> {
56        let path = Self::registry_path()?;
57
58        if !path.exists() {
59            // Create parent directory if needed
60            if let Some(parent) = path.parent() {
61                fs::create_dir_all(parent).context("Failed to create registry directory")?;
62            }
63            return Ok(Self::new());
64        }
65
66        let content = fs::read_to_string(&path).context("Failed to read registry file")?;
67
68        let registry: Self =
69            serde_json::from_str(&content).context("Failed to parse registry JSON")?;
70
71        Ok(registry)
72    }
73
74    /// Save registry to file
75    pub fn save(&self) -> Result<()> {
76        let path = Self::registry_path()?;
77
78        // Create parent directory if needed
79        if let Some(parent) = path.parent() {
80            fs::create_dir_all(parent).context("Failed to create registry directory")?;
81        }
82
83        let content = serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
84
85        fs::write(&path, content).context("Failed to write registry file")?;
86
87        Ok(())
88    }
89
90    /// Allocate a new port, checking for conflicts
91    pub fn allocate_port(&mut self) -> Result<u16> {
92        // Try next_port first
93        let mut port = self.next_port;
94        let mut attempts = 0;
95        const MAX_ATTEMPTS: usize = 70; // Total available ports
96
97        while attempts < MAX_ATTEMPTS {
98            if port > MAX_PORT {
99                port = MIN_PORT;
100            }
101
102            // Check if port is already in use
103            if !self.projects.iter().any(|p| p.port == port) {
104                // Check if port is actually available on the system
105                if Self::is_port_available(port) {
106                    self.next_port = if port == MAX_PORT { MIN_PORT } else { port + 1 };
107                    return Ok(port);
108                }
109            }
110
111            port += 1;
112            attempts += 1;
113        }
114
115        anyhow::bail!("No available ports in range {}-{}", MIN_PORT, MAX_PORT)
116    }
117
118    /// Check if a port is available on the system
119    pub fn is_port_available(port: u16) -> bool {
120        use std::net::TcpListener;
121        TcpListener::bind(("127.0.0.1", port)).is_ok()
122    }
123
124    /// Register a new project
125    pub fn register(&mut self, project: RegisteredProject) {
126        // Remove existing entry for the same path if exists
127        self.unregister(&project.path);
128        self.projects.push(project);
129    }
130
131    /// Unregister a project by path
132    pub fn unregister(&mut self, path: &PathBuf) {
133        self.projects.retain(|p| p.path != *path);
134    }
135
136    /// Find project by path
137    pub fn find_by_path(&self, path: &PathBuf) -> Option<&RegisteredProject> {
138        self.projects.iter().find(|p| p.path == *path)
139    }
140
141    /// Find project by path (mutable)
142    pub fn find_by_path_mut(&mut self, path: &PathBuf) -> Option<&mut RegisteredProject> {
143        self.projects.iter_mut().find(|p| p.path == *path)
144    }
145
146    /// Find project by port
147    pub fn find_by_port(&self, port: u16) -> Option<&RegisteredProject> {
148        self.projects.iter().find(|p| p.port == port)
149    }
150
151    /// Get all registered projects
152    pub fn list_all(&self) -> &[RegisteredProject] {
153        &self.projects
154    }
155
156    /// Register or update MCP connection for a project
157    pub fn register_mcp_connection(
158        &mut self,
159        path: &PathBuf,
160        agent_name: Option<String>,
161    ) -> anyhow::Result<()> {
162        let now = chrono::Utc::now().to_rfc3339();
163
164        // Check if project already exists
165        if let Some(project) = self.find_by_path_mut(path) {
166            // Update existing project
167            project.mcp_connected = true;
168            project.mcp_last_seen = Some(now.clone());
169            project.mcp_agent = agent_name;
170        } else {
171            // Create new entry for MCP-only project (no Dashboard running)
172            let name = path
173                .file_name()
174                .and_then(|n| n.to_str())
175                .unwrap_or("unknown")
176                .to_string();
177
178            let db_path = path.join(".intent-engine").join("intents.db");
179
180            let project = RegisteredProject {
181                path: path.clone(),
182                name,
183                port: 0, // No Dashboard server, use 0 as placeholder
184                pid: None,
185                started_at: now.clone(),
186                db_path,
187                mcp_connected: true,
188                mcp_last_seen: Some(now),
189                mcp_agent: agent_name,
190            };
191
192            self.projects.push(project);
193        }
194
195        self.save()
196    }
197
198    /// Update MCP heartbeat
199    pub fn update_mcp_heartbeat(&mut self, path: &PathBuf) -> anyhow::Result<()> {
200        if let Some(project) = self.find_by_path_mut(path) {
201            project.mcp_last_seen = Some(chrono::Utc::now().to_rfc3339());
202            project.mcp_connected = true;
203            self.save()?;
204        }
205        Ok(())
206    }
207
208    /// Unregister MCP connection
209    pub fn unregister_mcp_connection(&mut self, path: &PathBuf) -> anyhow::Result<()> {
210        if let Some(project) = self.find_by_path_mut(path) {
211            project.mcp_connected = false;
212            project.mcp_last_seen = None;
213            project.mcp_agent = None;
214
215            // If no Dashboard is running (port = 0), remove the entry entirely
216            if project.port == 0 {
217                self.unregister(path);
218            }
219
220            self.save()?;
221        }
222        Ok(())
223    }
224
225    /// Clean up projects with dead PIDs
226    pub fn cleanup_dead_processes(&mut self) {
227        self.projects.retain(|project| {
228            if let Some(pid) = project.pid {
229                Self::is_process_alive(pid)
230            } else {
231                true // Keep projects without PID
232            }
233        });
234    }
235
236    /// Clean up stale MCP connections (no heartbeat for 5 minutes)
237    pub fn cleanup_stale_mcp_connections(&mut self) {
238        use chrono::DateTime;
239        let now = chrono::Utc::now();
240        const TIMEOUT_MINUTES: i64 = 5;
241
242        for project in &mut self.projects {
243            if let Some(last_seen) = &project.mcp_last_seen {
244                if let Ok(last_time) = DateTime::parse_from_rfc3339(last_seen) {
245                    let duration = now.signed_duration_since(last_time.with_timezone(&chrono::Utc));
246                    if duration.num_minutes() > TIMEOUT_MINUTES {
247                        project.mcp_connected = false;
248                        project.mcp_last_seen = None;
249                        project.mcp_agent = None;
250                    }
251                }
252            }
253        }
254
255        // Remove MCP-only projects that are disconnected (port = 0 and not connected)
256        self.projects.retain(|p| p.port != 0 || p.mcp_connected);
257    }
258
259    /// Check if a process is alive
260    #[cfg(unix)]
261    fn is_process_alive(pid: u32) -> bool {
262        use std::process::Command;
263        Command::new("kill")
264            .args(["-0", &pid.to_string()])
265            .output()
266            .map(|output| output.status.success())
267            .unwrap_or(false)
268    }
269
270    #[cfg(windows)]
271    fn is_process_alive(pid: u32) -> bool {
272        use std::process::Command;
273        Command::new("tasklist")
274            .args(["/FI", &format!("PID eq {}", pid)])
275            .output()
276            .map(|output| String::from_utf8_lossy(&output.stdout).contains(&pid.to_string()))
277            .unwrap_or(false)
278    }
279}
280
281impl Default for ProjectRegistry {
282    fn default() -> Self {
283        Self::new()
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use tempfile::TempDir;
291
292    #[test]
293    fn test_new_registry() {
294        let registry = ProjectRegistry::new();
295        assert_eq!(registry.version, VERSION);
296        assert_eq!(registry.projects.len(), 0);
297        assert_eq!(registry.next_port, MIN_PORT);
298    }
299
300    #[test]
301    fn test_allocate_port() {
302        let mut registry = ProjectRegistry::new();
303
304        // Allocate first port
305        let port1 = registry.allocate_port().unwrap();
306        assert_eq!(port1, MIN_PORT);
307        assert_eq!(registry.next_port, MIN_PORT + 1);
308
309        // Register a project with that port
310        registry.register(RegisteredProject {
311            path: PathBuf::from("/test/project1"),
312            name: "project1".to_string(),
313            port: port1,
314            pid: None,
315            started_at: "2025-01-01T00:00:00Z".to_string(),
316            db_path: PathBuf::from("/test/project1/.intent-engine/intents.db"),
317            mcp_connected: false,
318            mcp_last_seen: None,
319            mcp_agent: None,
320        });
321
322        // Allocate second port
323        let port2 = registry.allocate_port().unwrap();
324        assert_eq!(port2, MIN_PORT + 1);
325    }
326
327    #[test]
328    fn test_register_and_find() {
329        let mut registry = ProjectRegistry::new();
330
331        let project = RegisteredProject {
332            path: PathBuf::from("/test/project"),
333            name: "test-project".to_string(),
334            port: 3030,
335            pid: Some(12345),
336            started_at: "2025-01-01T00:00:00Z".to_string(),
337            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
338            mcp_connected: false,
339            mcp_last_seen: None,
340            mcp_agent: None,
341        };
342
343        registry.register(project.clone());
344        assert_eq!(registry.projects.len(), 1);
345
346        // Find by path
347        let found = registry.find_by_path(&PathBuf::from("/test/project"));
348        assert!(found.is_some());
349        assert_eq!(found.unwrap().name, "test-project");
350
351        // Find by port
352        let found_by_port = registry.find_by_port(3030);
353        assert!(found_by_port.is_some());
354        assert_eq!(found_by_port.unwrap().name, "test-project");
355    }
356
357    #[test]
358    fn test_unregister() {
359        let mut registry = ProjectRegistry::new();
360
361        let project = RegisteredProject {
362            path: PathBuf::from("/test/project"),
363            name: "test-project".to_string(),
364            port: 3030,
365            pid: None,
366            started_at: "2025-01-01T00:00:00Z".to_string(),
367            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
368            mcp_connected: false,
369            mcp_last_seen: None,
370            mcp_agent: None,
371        };
372
373        registry.register(project.clone());
374        assert_eq!(registry.projects.len(), 1);
375
376        registry.unregister(&PathBuf::from("/test/project"));
377        assert_eq!(registry.projects.len(), 0);
378    }
379
380    #[test]
381    fn test_duplicate_path_replaces() {
382        let mut registry = ProjectRegistry::new();
383
384        let project1 = RegisteredProject {
385            path: PathBuf::from("/test/project"),
386            name: "project-v1".to_string(),
387            port: 3030,
388            pid: None,
389            started_at: "2025-01-01T00:00:00Z".to_string(),
390            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
391            mcp_connected: false,
392            mcp_last_seen: None,
393            mcp_agent: None,
394        };
395
396        let project2 = RegisteredProject {
397            path: PathBuf::from("/test/project"),
398            name: "project-v2".to_string(),
399            port: 3031,
400            pid: None,
401            started_at: "2025-01-01T01:00:00Z".to_string(),
402            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
403            mcp_connected: false,
404            mcp_last_seen: None,
405            mcp_agent: None,
406        };
407
408        registry.register(project1);
409        assert_eq!(registry.projects.len(), 1);
410
411        registry.register(project2);
412        assert_eq!(registry.projects.len(), 1);
413
414        let found = registry.find_by_path(&PathBuf::from("/test/project"));
415        assert_eq!(found.unwrap().name, "project-v2");
416    }
417
418    #[test]
419    fn test_save_and_load() {
420        let _temp_dir = TempDir::new().unwrap();
421
422        // We can't easily override home_dir in tests, so we'll test serialization manually
423        let mut registry = ProjectRegistry::new();
424
425        let project = RegisteredProject {
426            path: PathBuf::from("/test/project"),
427            name: "test-project".to_string(),
428            port: 3030,
429            pid: Some(12345),
430            started_at: "2025-01-01T00:00:00Z".to_string(),
431            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
432            mcp_connected: false,
433            mcp_last_seen: None,
434            mcp_agent: None,
435        };
436
437        registry.register(project);
438
439        // Test serialization
440        let json = serde_json::to_string_pretty(&registry).unwrap();
441        assert!(json.contains("test-project"));
442        assert!(json.contains("3030"));
443
444        // Test deserialization
445        let loaded: ProjectRegistry = serde_json::from_str(&json).unwrap();
446        assert_eq!(loaded.projects.len(), 1);
447        assert_eq!(loaded.projects[0].name, "test-project");
448        assert_eq!(loaded.projects[0].port, 3030);
449    }
450
451    #[test]
452    fn test_port_wraparound() {
453        let mut registry = ProjectRegistry::new();
454        registry.next_port = MAX_PORT;
455
456        // Allocate port at max
457        let port = registry.allocate_port().unwrap();
458        assert_eq!(port, MAX_PORT);
459
460        // Next allocation should wrap to MIN_PORT
461        assert_eq!(registry.next_port, MIN_PORT);
462    }
463}