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    /// If the project doesn't exist, it will be auto-registered as an MCP-only project
186    pub fn update_mcp_heartbeat(&mut self, path: &PathBuf) -> anyhow::Result<()> {
187        if let Some(project) = self.find_by_path_mut(path) {
188            // Project exists - update heartbeat
189            project.mcp_last_seen = Some(chrono::Utc::now().to_rfc3339());
190            project.mcp_connected = true;
191            self.save()?;
192        } else {
193            // Project doesn't exist - auto-register it as MCP-only project
194            // This handles the case where Registry was recreated after Dashboard restart
195            self.register_mcp_connection(path, Some("mcp-client".to_string()))?;
196        }
197        Ok(())
198    }
199
200    /// Unregister MCP connection
201    pub fn unregister_mcp_connection(&mut self, path: &PathBuf) -> anyhow::Result<()> {
202        if let Some(project) = self.find_by_path_mut(path) {
203            project.mcp_connected = false;
204            project.mcp_last_seen = None;
205            project.mcp_agent = None;
206
207            // Don't delete the entry - keep it for tracking purposes
208            // This allows MCP-only projects to persist in the registry
209            self.save()?;
210        }
211        Ok(())
212    }
213
214    /// Clean up projects with dead PIDs
215    pub fn cleanup_dead_processes(&mut self) {
216        self.projects.retain(|project| {
217            if let Some(pid) = project.pid {
218                Self::is_process_alive(pid)
219            } else {
220                true // Keep projects without PID
221            }
222        });
223    }
224
225    /// Clean up projects that are not responding to health checks
226    /// This is more reliable than PID-based checking
227    pub async fn cleanup_unhealthy_dashboards(&mut self) {
228        let mut unhealthy_projects = Vec::new();
229
230        for project in &self.projects {
231            // Skip projects without a port (MCP-only connections)
232            if project.port == 0 {
233                continue;
234            }
235
236            // Check if dashboard is healthy via HTTP
237            if !Self::check_health(project.port).await {
238                tracing::debug!(
239                    "Dashboard for {} (port {}) is unhealthy, will be cleaned up",
240                    project.name,
241                    project.port
242                );
243                unhealthy_projects.push(project.path.clone());
244            }
245        }
246
247        // Remove unhealthy projects
248        for path in unhealthy_projects {
249            self.unregister(&path);
250        }
251    }
252
253    /// Check if a Dashboard at the given port is healthy
254    async fn check_health(port: u16) -> bool {
255        let health_url = format!("http://127.0.0.1:{}/api/health", port);
256
257        match reqwest::Client::builder()
258            .timeout(std::time::Duration::from_secs(2))
259            .build()
260        {
261            Ok(client) => match client.get(&health_url).send().await {
262                Ok(resp) if resp.status().is_success() => true,
263                Ok(_) => false,
264                Err(_) => false,
265            },
266            Err(_) => false,
267        }
268    }
269
270    /// Clean up stale MCP connections (no heartbeat for 5 minutes)
271    pub fn cleanup_stale_mcp_connections(&mut self) {
272        use chrono::DateTime;
273        let now = chrono::Utc::now();
274        const TIMEOUT_MINUTES: i64 = 5;
275
276        for project in &mut self.projects {
277            if let Some(last_seen) = &project.mcp_last_seen {
278                if let Ok(last_time) = DateTime::parse_from_rfc3339(last_seen) {
279                    let duration = now.signed_duration_since(last_time.with_timezone(&chrono::Utc));
280                    if duration.num_minutes() > TIMEOUT_MINUTES {
281                        project.mcp_connected = false;
282                        project.mcp_last_seen = None;
283                        project.mcp_agent = None;
284                    }
285                }
286            }
287        }
288
289        // Remove MCP-only projects that are disconnected (port = 0 and not connected)
290        self.projects.retain(|p| p.port != 0 || p.mcp_connected);
291    }
292
293    /// Check if a process is alive
294    #[cfg(unix)]
295    fn is_process_alive(pid: u32) -> bool {
296        use std::process::Command;
297        Command::new("kill")
298            .args(["-0", &pid.to_string()])
299            .output()
300            .map(|output| output.status.success())
301            .unwrap_or(false)
302    }
303
304    #[cfg(windows)]
305    fn is_process_alive(pid: u32) -> bool {
306        use std::process::Command;
307        Command::new("tasklist")
308            .args(["/FI", &format!("PID eq {}", pid)])
309            .output()
310            .map(|output| String::from_utf8_lossy(&output.stdout).contains(&pid.to_string()))
311            .unwrap_or(false)
312    }
313}
314
315impl Default for ProjectRegistry {
316    fn default() -> Self {
317        Self::new()
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use tempfile::TempDir;
325
326    #[test]
327    fn test_new_registry() {
328        let registry = ProjectRegistry::new();
329        assert_eq!(registry.version, VERSION);
330        assert_eq!(registry.projects.len(), 0);
331    }
332
333    #[test]
334    #[serial_test::serial]
335    fn test_allocate_port() {
336        let mut registry = ProjectRegistry::new();
337
338        // Attempt to allocate port - may fail if Dashboard is running
339        match registry.allocate_port() {
340            Ok(port) => {
341                // Port is available - verify it's the default port
342                assert_eq!(port, DEFAULT_PORT);
343
344                // Verify we can register a project with that port
345                registry.register(RegisteredProject {
346                    path: PathBuf::from("/test/project1"),
347                    name: "project1".to_string(),
348                    port,
349                    pid: None,
350                    started_at: "2025-01-01T00:00:00Z".to_string(),
351                    db_path: PathBuf::from("/test/project1/.intent-engine/intents.db"),
352                    mcp_connected: false,
353                    mcp_last_seen: None,
354                    mcp_agent: None,
355                });
356            },
357            Err(e) => {
358                // Port in use is acceptable - verifies is_port_available() works correctly
359                assert!(
360                    e.to_string().contains("already in use"),
361                    "Expected 'already in use' error, got: {}",
362                    e
363                );
364            },
365        }
366    }
367
368    #[test]
369    fn test_register_and_find() {
370        let mut registry = ProjectRegistry::new();
371
372        let project = RegisteredProject {
373            path: PathBuf::from("/test/project"),
374            name: "test-project".to_string(),
375            port: 11391,
376            pid: Some(12345),
377            started_at: "2025-01-01T00:00:00Z".to_string(),
378            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
379            mcp_connected: false,
380            mcp_last_seen: None,
381            mcp_agent: None,
382        };
383
384        registry.register(project.clone());
385        assert_eq!(registry.projects.len(), 1);
386
387        // Find by path
388        let found = registry.find_by_path(&PathBuf::from("/test/project"));
389        assert!(found.is_some());
390        assert_eq!(found.unwrap().name, "test-project");
391
392        // Find by port
393        let found_by_port = registry.find_by_port(11391);
394        assert!(found_by_port.is_some());
395        assert_eq!(found_by_port.unwrap().name, "test-project");
396    }
397
398    #[test]
399    fn test_unregister() {
400        let mut registry = ProjectRegistry::new();
401
402        let project = RegisteredProject {
403            path: PathBuf::from("/test/project"),
404            name: "test-project".to_string(),
405            port: 11391,
406            pid: None,
407            started_at: "2025-01-01T00:00:00Z".to_string(),
408            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
409            mcp_connected: false,
410            mcp_last_seen: None,
411            mcp_agent: None,
412        };
413
414        registry.register(project.clone());
415        assert_eq!(registry.projects.len(), 1);
416
417        registry.unregister(&PathBuf::from("/test/project"));
418        assert_eq!(registry.projects.len(), 0);
419    }
420
421    #[test]
422    fn test_duplicate_path_replaces() {
423        let mut registry = ProjectRegistry::new();
424
425        let project1 = RegisteredProject {
426            path: PathBuf::from("/test/project"),
427            name: "project-v1".to_string(),
428            port: 11391,
429            pid: None,
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        let project2 = RegisteredProject {
438            path: PathBuf::from("/test/project"),
439            name: "project-v2".to_string(),
440            port: 3031,
441            pid: None,
442            started_at: "2025-01-01T01:00:00Z".to_string(),
443            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
444            mcp_connected: false,
445            mcp_last_seen: None,
446            mcp_agent: None,
447        };
448
449        registry.register(project1);
450        assert_eq!(registry.projects.len(), 1);
451
452        registry.register(project2);
453        assert_eq!(registry.projects.len(), 1);
454
455        let found = registry.find_by_path(&PathBuf::from("/test/project"));
456        assert_eq!(found.unwrap().name, "project-v2");
457    }
458
459    #[test]
460    fn test_save_and_load() {
461        let _temp_dir = TempDir::new().unwrap();
462
463        // We can't easily override home_dir in tests, so we'll test serialization manually
464        let mut registry = ProjectRegistry::new();
465
466        let project = RegisteredProject {
467            path: PathBuf::from("/test/project"),
468            name: "test-project".to_string(),
469            port: 11391,
470            pid: Some(12345),
471            started_at: "2025-01-01T00:00:00Z".to_string(),
472            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
473            mcp_connected: false,
474            mcp_last_seen: None,
475            mcp_agent: None,
476        };
477
478        registry.register(project);
479
480        // Test serialization
481        let json = serde_json::to_string_pretty(&registry).unwrap();
482        assert!(json.contains("test-project"));
483        assert!(json.contains("11391"));
484
485        // Test deserialization
486        let loaded: ProjectRegistry = serde_json::from_str(&json).unwrap();
487        assert_eq!(loaded.projects.len(), 1);
488        assert_eq!(loaded.projects[0].name, "test-project");
489        assert_eq!(loaded.projects[0].port, 11391);
490    }
491
492    #[test]
493    #[serial_test::serial]
494    fn test_fixed_port() {
495        let mut registry = ProjectRegistry::new();
496
497        // Attempt to allocate port - may fail if Dashboard is running
498        match registry.allocate_port() {
499            Ok(port) => {
500                // Port is available - verify it's the default port
501                assert_eq!(port, DEFAULT_PORT);
502            },
503            Err(e) => {
504                // Port in use is acceptable - verifies is_port_available() works correctly
505                assert!(
506                    e.to_string().contains("already in use"),
507                    "Expected 'already in use' error, got: {}",
508                    e
509                );
510            },
511        }
512    }
513}