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 {
167 use std::net::TcpListener;
168 TcpListener::bind(("0.0.0.0", port)).is_ok()
169 }
170
171 pub fn register(&mut self, project: RegisteredProject) {
173 self.unregister(&project.path);
175 self.projects.push(project);
176 }
177
178 pub fn unregister(&mut self, path: &PathBuf) {
180 self.projects.retain(|p| p.path != *path);
181 }
182
183 pub fn find_by_path(&self, path: &PathBuf) -> Option<&RegisteredProject> {
185 self.projects.iter().find(|p| p.path == *path)
186 }
187
188 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 pub fn find_by_port(&self, port: u16) -> Option<&RegisteredProject> {
195 self.projects.iter().find(|p| p.port == port)
196 }
197
198 pub fn list_all(&self) -> &[RegisteredProject] {
200 &self.projects
201 }
202
203 pub fn register_mcp_connection(
206 &mut self,
207 path: &PathBuf,
208 agent_name: Option<String>,
209 ) -> anyhow::Result<()> {
210 let normalized_path = path.canonicalize().unwrap_or_else(|_| path.clone());
213 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(()); }
224
225 let now = chrono::Utc::now().to_rfc3339();
226
227 if let Some(project) = self.find_by_path_mut(path) {
229 project.mcp_connected = true;
231 project.mcp_last_seen = Some(now.clone());
232 project.mcp_agent = agent_name;
233 } else {
234 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, 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 pub fn update_mcp_heartbeat(&mut self, path: &PathBuf) -> anyhow::Result<()> {
264 if let Some(project) = self.find_by_path_mut(path) {
265 project.mcp_last_seen = Some(chrono::Utc::now().to_rfc3339());
267 project.mcp_connected = true;
268 self.save()?;
269 } else {
270 self.register_mcp_connection(path, Some("mcp-client".to_string()))?;
273 }
274 Ok(())
275 }
276
277 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 self.save()?;
287 }
288 Ok(())
289 }
290
291 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 }
299 });
300 }
301
302 pub async fn cleanup_unhealthy_dashboards(&mut self) {
305 let mut unhealthy_projects = Vec::new();
306
307 for project in &self.projects {
308 if project.port == 0 {
310 continue;
311 }
312
313 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 for path in unhealthy_projects {
326 self.unregister(&path);
327 }
328 }
329
330 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 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 self.projects.retain(|p| p.port != 0 || p.mcp_connected);
368 }
369
370 #[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 match registry.allocate_port() {
417 Ok(port) => {
418 assert_eq!(port, DEFAULT_PORT);
420
421 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 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 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 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 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 let json = serde_json::to_string_pretty(®istry).unwrap();
559 assert!(json.contains("test-project"));
560 assert!(json.contains("11391"));
561
562 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 match registry.allocate_port() {
576 Ok(port) => {
577 assert_eq!(port, DEFAULT_PORT);
579 },
580 Err(e) => {
581 assert!(
583 e.to_string().contains("already in use"),
584 "Expected 'already in use' error, got: {}",
585 e
586 );
587 },
588 }
589 }
590}