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