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; const VERSION: &str = "1.0";
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ProjectRegistry {
13 pub version: String,
14 pub projects: Vec<RegisteredProject>,
15}
16
17#[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 #[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 pub fn new() -> Self {
39 Self {
40 version: VERSION.to_string(),
41 projects: Vec::new(),
42 }
43 }
44
45 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 pub fn load() -> Result<Self> {
53 let path = Self::registry_path()?;
54
55 if !path.exists() {
56 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 pub fn save(&self) -> Result<()> {
73 let path = Self::registry_path()?;
74
75 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")?;
82
83 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 match fs::write(&path, &content) {
92 Ok(_) => {
93 match fs::read_to_string(&path) {
95 Ok(written_content) => {
96 match serde_json::from_str::<ProjectRegistry>(&written_content) {
97 Ok(_) => {
98 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 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 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 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 pub fn allocate_port(&mut self) -> Result<u16> {
150 let port = DEFAULT_PORT;
152
153 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 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 pub fn register(&mut self, project: RegisteredProject) {
172 self.unregister(&project.path);
174 self.projects.push(project);
175 }
176
177 pub fn unregister(&mut self, path: &PathBuf) {
179 self.projects.retain(|p| p.path != *path);
180 }
181
182 pub fn find_by_path(&self, path: &PathBuf) -> Option<&RegisteredProject> {
184 self.projects.iter().find(|p| p.path == *path)
185 }
186
187 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 pub fn find_by_port(&self, port: u16) -> Option<&RegisteredProject> {
194 self.projects.iter().find(|p| p.port == port)
195 }
196
197 pub fn list_all(&self) -> &[RegisteredProject] {
199 &self.projects
200 }
201
202 pub fn register_mcp_connection(
205 &mut self,
206 path: &PathBuf,
207 agent_name: Option<String>,
208 ) -> anyhow::Result<()> {
209 let normalized_path = path.canonicalize().unwrap_or_else(|_| path.clone());
212 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(()); }
223
224 let now = chrono::Utc::now().to_rfc3339();
225
226 if let Some(project) = self.find_by_path_mut(path) {
228 project.mcp_connected = true;
230 project.mcp_last_seen = Some(now.clone());
231 project.mcp_agent = agent_name;
232 } else {
233 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, 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 pub fn update_mcp_heartbeat(&mut self, path: &PathBuf) -> anyhow::Result<()> {
263 if let Some(project) = self.find_by_path_mut(path) {
264 project.mcp_last_seen = Some(chrono::Utc::now().to_rfc3339());
266 project.mcp_connected = true;
267 self.save()?;
268 } else {
269 self.register_mcp_connection(path, Some("mcp-client".to_string()))?;
272 }
273 Ok(())
274 }
275
276 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 self.save()?;
286 }
287 Ok(())
288 }
289
290 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 }
298 });
299 }
300
301 pub async fn cleanup_unhealthy_dashboards(&mut self) {
304 let mut unhealthy_projects = Vec::new();
305
306 for project in &self.projects {
307 if project.port == 0 {
309 continue;
310 }
311
312 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 for path in unhealthy_projects {
325 self.unregister(&path);
326 }
327 }
328
329 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 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 self.projects.retain(|p| p.port != 0 || p.mcp_connected);
367 }
368
369 #[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 match registry.allocate_port() {
416 Ok(port) => {
417 assert_eq!(port, DEFAULT_PORT);
419
420 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 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 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 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 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 let json = serde_json::to_string_pretty(®istry).unwrap();
558 assert!(json.contains("test-project"));
559 assert!(json.contains("11391"));
560
561 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 match registry.allocate_port() {
575 Ok(port) => {
576 assert_eq!(port, DEFAULT_PORT);
578 },
579 Err(e) => {
580 assert!(
582 e.to_string().contains("already in use"),
583 "Expected 'already in use' error, got: {}",
584 e
585 );
586 },
587 }
588 }
589}