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        // Serialize to JSON first (fail fast if serialization fails)
81        let content = serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
82
83        // Backup existing file if it exists
84        let backup_path = path.with_extension("json.backup");
85        if path.exists() {
86            fs::copy(&path, &backup_path).context("Failed to create backup")?;
87            tracing::debug!("Created registry backup at: {}", backup_path.display());
88        }
89
90        // Write to file
91        match fs::write(&path, &content) {
92            Ok(_) => {
93                // Verify the written file can be parsed back
94                match fs::read_to_string(&path) {
95                    Ok(written_content) => {
96                        match serde_json::from_str::<ProjectRegistry>(&written_content) {
97                            Ok(_) => {
98                                // Success - remove backup
99                                if backup_path.exists() {
100                                    let _ = fs::remove_file(&backup_path);
101                                }
102                                tracing::debug!("Registry saved and verified successfully");
103                                Ok(())
104                            },
105                            Err(e) => {
106                                // Verification failed - rollback
107                                tracing::error!("Registry verification failed after write - rolling back. Error: {}, OS: {}, Path: {}",
108                                e,
109                                std::env::consts::OS,
110                                path.display());
111                                if backup_path.exists() {
112                                    fs::copy(&backup_path, &path)
113                                        .context("Failed to rollback from backup")?;
114                                    tracing::warn!("Rolled back registry from backup");
115                                }
116                                anyhow::bail!("Registry verification failed: {}", e)
117                            },
118                        }
119                    },
120                    Err(e) => {
121                        // Read failed - rollback
122                        tracing::error!("Failed to read registry after write - rolling back. Error: {}, OS: {}, Path: {}",
123                        e,
124                        std::env::consts::OS,
125                        path.display());
126                        if backup_path.exists() {
127                            fs::copy(&backup_path, &path)
128                                .context("Failed to rollback from backup")?;
129                            tracing::warn!("Rolled back registry from backup");
130                        }
131                        anyhow::bail!("Failed to read registry after write: {}", e)
132                    },
133                }
134            },
135            Err(e) => {
136                // Write failed
137                tracing::error!(
138                    "Failed to write registry. Error: {}, OS: {}, Path: {}",
139                    e,
140                    std::env::consts::OS,
141                    path.display()
142                );
143                anyhow::bail!("Failed to write registry file: {}", e)
144            },
145        }
146    }
147
148    /// Allocate port (always uses DEFAULT_PORT)
149    pub fn allocate_port(&mut self) -> Result<u16> {
150        // Always use the default fixed port
151        let port = DEFAULT_PORT;
152
153        // Check if port is available on the system
154        if Self::is_port_available(port) {
155            Ok(port)
156        } else {
157            anyhow::bail!(
158                "Port {} is already in use. Please stop the existing Dashboard instance first.",
159                port
160            )
161        }
162    }
163
164    /// Check if a port is available on the system
165    pub fn is_port_available(port: u16) -> bool {
166        use std::net::TcpListener;
167        TcpListener::bind(("127.0.0.1", port)).is_ok()
168    }
169
170    /// Register a new project
171    pub fn register(&mut self, project: RegisteredProject) {
172        // Remove existing entry for the same path if exists
173        self.unregister(&project.path);
174        self.projects.push(project);
175    }
176
177    /// Unregister a project by path
178    pub fn unregister(&mut self, path: &PathBuf) {
179        self.projects.retain(|p| p.path != *path);
180    }
181
182    /// Find project by path
183    pub fn find_by_path(&self, path: &PathBuf) -> Option<&RegisteredProject> {
184        self.projects.iter().find(|p| p.path == *path)
185    }
186
187    /// Find project by path (mutable)
188    pub fn find_by_path_mut(&mut self, path: &PathBuf) -> Option<&mut RegisteredProject> {
189        self.projects.iter_mut().find(|p| p.path == *path)
190    }
191
192    /// Find project by port
193    pub fn find_by_port(&self, port: u16) -> Option<&RegisteredProject> {
194        self.projects.iter().find(|p| p.port == port)
195    }
196
197    /// Get all registered projects
198    pub fn list_all(&self) -> &[RegisteredProject] {
199        &self.projects
200    }
201
202    /// Register or update MCP connection for a project
203    /// This will create a project entry if none exists (for MCP-only projects)
204    pub fn register_mcp_connection(
205        &mut self,
206        path: &PathBuf,
207        agent_name: Option<String>,
208    ) -> anyhow::Result<()> {
209        // Validate project path - reject temporary directories (Defense Layer 6)
210        // This prevents test environments from polluting the Dashboard registry
211        let normalized_path = path.canonicalize().unwrap_or_else(|_| path.clone());
212        // IMPORTANT: Canonicalize temp_dir to match normalized_path format (fixes Windows UNC paths)
213        let temp_dir = std::env::temp_dir()
214            .canonicalize()
215            .unwrap_or_else(|_| std::env::temp_dir());
216        if normalized_path.starts_with(&temp_dir) {
217            tracing::debug!(
218                "Rejecting MCP connection registration for temporary path: {}",
219                path.display()
220            );
221            return Ok(()); // Silently skip - non-fatal
222        }
223
224        let now = chrono::Utc::now().to_rfc3339();
225
226        // Check if project already exists
227        if let Some(project) = self.find_by_path_mut(path) {
228            // Update existing project's MCP status
229            project.mcp_connected = true;
230            project.mcp_last_seen = Some(now.clone());
231            project.mcp_agent = agent_name;
232        } else {
233            // Create MCP-only project entry (no Dashboard server, port: 0)
234            let name = path
235                .file_name()
236                .and_then(|n| n.to_str())
237                .unwrap_or("unknown")
238                .to_string();
239
240            let db_path = path.join(".intent-engine").join("project.db");
241
242            let project = RegisteredProject {
243                path: path.clone(),
244                name,
245                port: 0, // No Dashboard server
246                pid: None,
247                started_at: now.clone(),
248                db_path,
249                mcp_connected: true,
250                mcp_last_seen: Some(now),
251                mcp_agent: agent_name,
252            };
253
254            self.projects.push(project);
255        }
256
257        self.save()
258    }
259
260    /// Update MCP heartbeat
261    /// If the project doesn't exist, it will be auto-registered as an MCP-only project
262    pub fn update_mcp_heartbeat(&mut self, path: &PathBuf) -> anyhow::Result<()> {
263        if let Some(project) = self.find_by_path_mut(path) {
264            // Project exists - update heartbeat
265            project.mcp_last_seen = Some(chrono::Utc::now().to_rfc3339());
266            project.mcp_connected = true;
267            self.save()?;
268        } else {
269            // Project doesn't exist - auto-register it as MCP-only project
270            // This handles the case where Registry was recreated after Dashboard restart
271            self.register_mcp_connection(path, Some("mcp-client".to_string()))?;
272        }
273        Ok(())
274    }
275
276    /// Unregister MCP connection
277    pub fn unregister_mcp_connection(&mut self, path: &PathBuf) -> anyhow::Result<()> {
278        if let Some(project) = self.find_by_path_mut(path) {
279            project.mcp_connected = false;
280            project.mcp_last_seen = None;
281            project.mcp_agent = None;
282
283            // Don't delete the entry - keep it for tracking purposes
284            // This allows MCP-only projects to persist in the registry
285            self.save()?;
286        }
287        Ok(())
288    }
289
290    /// Clean up projects with dead PIDs
291    pub fn cleanup_dead_processes(&mut self) {
292        self.projects.retain(|project| {
293            if let Some(pid) = project.pid {
294                Self::is_process_alive(pid)
295            } else {
296                true // Keep projects without PID
297            }
298        });
299    }
300
301    /// Clean up projects that are not responding to health checks
302    /// This is more reliable than PID-based checking
303    pub async fn cleanup_unhealthy_dashboards(&mut self) {
304        let mut unhealthy_projects = Vec::new();
305
306        for project in &self.projects {
307            // Skip projects without a port (MCP-only connections)
308            if project.port == 0 {
309                continue;
310            }
311
312            // Check if dashboard is healthy via HTTP
313            if !Self::check_health(project.port).await {
314                tracing::debug!(
315                    "Dashboard for {} (port {}) is unhealthy, will be cleaned up",
316                    project.name,
317                    project.port
318                );
319                unhealthy_projects.push(project.path.clone());
320            }
321        }
322
323        // Remove unhealthy projects
324        for path in unhealthy_projects {
325            self.unregister(&path);
326        }
327    }
328
329    /// Check if a Dashboard at the given port is healthy
330    async fn check_health(port: u16) -> bool {
331        let health_url = format!("http://127.0.0.1:{}/api/health", port);
332
333        match reqwest::Client::builder()
334            .timeout(std::time::Duration::from_secs(2))
335            .build()
336        {
337            Ok(client) => match client.get(&health_url).send().await {
338                Ok(resp) if resp.status().is_success() => true,
339                Ok(_) => false,
340                Err(_) => false,
341            },
342            Err(_) => false,
343        }
344    }
345
346    /// Clean up stale MCP connections (no heartbeat for 5 minutes)
347    pub fn cleanup_stale_mcp_connections(&mut self) {
348        use chrono::DateTime;
349        let now = chrono::Utc::now();
350        const TIMEOUT_MINUTES: i64 = 5;
351
352        for project in &mut self.projects {
353            if let Some(last_seen) = &project.mcp_last_seen {
354                if let Ok(last_time) = DateTime::parse_from_rfc3339(last_seen) {
355                    let duration = now.signed_duration_since(last_time.with_timezone(&chrono::Utc));
356                    if duration.num_minutes() > TIMEOUT_MINUTES {
357                        project.mcp_connected = false;
358                        project.mcp_last_seen = None;
359                        project.mcp_agent = None;
360                    }
361                }
362            }
363        }
364
365        // Remove MCP-only projects that are disconnected (port = 0 and not connected)
366        self.projects.retain(|p| p.port != 0 || p.mcp_connected);
367    }
368
369    /// Check if a process is alive
370    #[cfg(unix)]
371    fn is_process_alive(pid: u32) -> bool {
372        use std::process::Command;
373        Command::new("kill")
374            .args(["-0", &pid.to_string()])
375            .output()
376            .map(|output| output.status.success())
377            .unwrap_or(false)
378    }
379
380    #[cfg(windows)]
381    fn is_process_alive(pid: u32) -> bool {
382        use std::process::Command;
383        Command::new("tasklist")
384            .args(["/FI", &format!("PID eq {}", pid)])
385            .output()
386            .map(|output| String::from_utf8_lossy(&output.stdout).contains(&pid.to_string()))
387            .unwrap_or(false)
388    }
389}
390
391impl Default for ProjectRegistry {
392    fn default() -> Self {
393        Self::new()
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use tempfile::TempDir;
401
402    #[test]
403    fn test_new_registry() {
404        let registry = ProjectRegistry::new();
405        assert_eq!(registry.version, VERSION);
406        assert_eq!(registry.projects.len(), 0);
407    }
408
409    #[test]
410    #[serial_test::serial]
411    fn test_allocate_port() {
412        let mut registry = ProjectRegistry::new();
413
414        // Attempt to allocate port - may fail if Dashboard is running
415        match registry.allocate_port() {
416            Ok(port) => {
417                // Port is available - verify it's the default port
418                assert_eq!(port, DEFAULT_PORT);
419
420                // Verify we can register a project with that port
421                registry.register(RegisteredProject {
422                    path: PathBuf::from("/test/project1"),
423                    name: "project1".to_string(),
424                    port,
425                    pid: None,
426                    started_at: "2025-01-01T00:00:00Z".to_string(),
427                    db_path: PathBuf::from("/test/project1/.intent-engine/intents.db"),
428                    mcp_connected: false,
429                    mcp_last_seen: None,
430                    mcp_agent: None,
431                });
432            },
433            Err(e) => {
434                // Port in use is acceptable - verifies is_port_available() works correctly
435                assert!(
436                    e.to_string().contains("already in use"),
437                    "Expected 'already in use' error, got: {}",
438                    e
439                );
440            },
441        }
442    }
443
444    #[test]
445    fn test_register_and_find() {
446        let mut registry = ProjectRegistry::new();
447
448        let project = RegisteredProject {
449            path: PathBuf::from("/test/project"),
450            name: "test-project".to_string(),
451            port: 11391,
452            pid: Some(12345),
453            started_at: "2025-01-01T00:00:00Z".to_string(),
454            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
455            mcp_connected: false,
456            mcp_last_seen: None,
457            mcp_agent: None,
458        };
459
460        registry.register(project.clone());
461        assert_eq!(registry.projects.len(), 1);
462
463        // Find by path
464        let found = registry.find_by_path(&PathBuf::from("/test/project"));
465        assert!(found.is_some());
466        assert_eq!(found.unwrap().name, "test-project");
467
468        // Find by port
469        let found_by_port = registry.find_by_port(11391);
470        assert!(found_by_port.is_some());
471        assert_eq!(found_by_port.unwrap().name, "test-project");
472    }
473
474    #[test]
475    fn test_unregister() {
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: None,
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.clone());
491        assert_eq!(registry.projects.len(), 1);
492
493        registry.unregister(&PathBuf::from("/test/project"));
494        assert_eq!(registry.projects.len(), 0);
495    }
496
497    #[test]
498    fn test_duplicate_path_replaces() {
499        let mut registry = ProjectRegistry::new();
500
501        let project1 = RegisteredProject {
502            path: PathBuf::from("/test/project"),
503            name: "project-v1".to_string(),
504            port: 11391,
505            pid: None,
506            started_at: "2025-01-01T00:00:00Z".to_string(),
507            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
508            mcp_connected: false,
509            mcp_last_seen: None,
510            mcp_agent: None,
511        };
512
513        let project2 = RegisteredProject {
514            path: PathBuf::from("/test/project"),
515            name: "project-v2".to_string(),
516            port: 3031,
517            pid: None,
518            started_at: "2025-01-01T01:00:00Z".to_string(),
519            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
520            mcp_connected: false,
521            mcp_last_seen: None,
522            mcp_agent: None,
523        };
524
525        registry.register(project1);
526        assert_eq!(registry.projects.len(), 1);
527
528        registry.register(project2);
529        assert_eq!(registry.projects.len(), 1);
530
531        let found = registry.find_by_path(&PathBuf::from("/test/project"));
532        assert_eq!(found.unwrap().name, "project-v2");
533    }
534
535    #[test]
536    fn test_save_and_load() {
537        let _temp_dir = TempDir::new().unwrap();
538
539        // We can't easily override home_dir in tests, so we'll test serialization manually
540        let mut registry = ProjectRegistry::new();
541
542        let project = RegisteredProject {
543            path: PathBuf::from("/test/project"),
544            name: "test-project".to_string(),
545            port: 11391,
546            pid: Some(12345),
547            started_at: "2025-01-01T00:00:00Z".to_string(),
548            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
549            mcp_connected: false,
550            mcp_last_seen: None,
551            mcp_agent: None,
552        };
553
554        registry.register(project);
555
556        // Test serialization
557        let json = serde_json::to_string_pretty(&registry).unwrap();
558        assert!(json.contains("test-project"));
559        assert!(json.contains("11391"));
560
561        // Test deserialization
562        let loaded: ProjectRegistry = serde_json::from_str(&json).unwrap();
563        assert_eq!(loaded.projects.len(), 1);
564        assert_eq!(loaded.projects[0].name, "test-project");
565        assert_eq!(loaded.projects[0].port, 11391);
566    }
567
568    #[test]
569    #[serial_test::serial]
570    fn test_fixed_port() {
571        let mut registry = ProjectRegistry::new();
572
573        // Attempt to allocate port - may fail if Dashboard is running
574        match registry.allocate_port() {
575            Ok(port) => {
576                // Port is available - verify it's the default port
577                assert_eq!(port, DEFAULT_PORT);
578            },
579            Err(e) => {
580                // Port in use is acceptable - verifies is_port_available() works correctly
581                assert!(
582                    e.to_string().contains("already in use"),
583                    "Expected 'already in use' error, got: {}",
584                    e
585                );
586            },
587        }
588    }
589}