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