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