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")?;
81
82 fs::write(&path, content).context("Failed to write registry file")?;
83
84 Ok(())
85 }
86
87 pub fn allocate_port(&mut self) -> Result<u16> {
89 let port = DEFAULT_PORT;
91
92 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 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 pub fn register(&mut self, project: RegisteredProject) {
111 self.unregister(&project.path);
113 self.projects.push(project);
114 }
115
116 pub fn unregister(&mut self, path: &PathBuf) {
118 self.projects.retain(|p| p.path != *path);
119 }
120
121 pub fn find_by_path(&self, path: &PathBuf) -> Option<&RegisteredProject> {
123 self.projects.iter().find(|p| p.path == *path)
124 }
125
126 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 pub fn find_by_port(&self, port: u16) -> Option<&RegisteredProject> {
133 self.projects.iter().find(|p| p.port == port)
134 }
135
136 pub fn list_all(&self) -> &[RegisteredProject] {
138 &self.projects
139 }
140
141 pub fn register_mcp_connection(
144 &mut self,
145 path: &PathBuf,
146 agent_name: Option<String>,
147 ) -> anyhow::Result<()> {
148 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(()); }
159
160 let now = chrono::Utc::now().to_rfc3339();
161
162 if let Some(project) = self.find_by_path_mut(path) {
164 project.mcp_connected = true;
166 project.mcp_last_seen = Some(now.clone());
167 project.mcp_agent = agent_name;
168 } else {
169 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, 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 pub fn update_mcp_heartbeat(&mut self, path: &PathBuf) -> anyhow::Result<()> {
199 if let Some(project) = self.find_by_path_mut(path) {
200 project.mcp_last_seen = Some(chrono::Utc::now().to_rfc3339());
202 project.mcp_connected = true;
203 self.save()?;
204 } else {
205 self.register_mcp_connection(path, Some("mcp-client".to_string()))?;
208 }
209 Ok(())
210 }
211
212 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 self.save()?;
222 }
223 Ok(())
224 }
225
226 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 }
234 });
235 }
236
237 pub async fn cleanup_unhealthy_dashboards(&mut self) {
240 let mut unhealthy_projects = Vec::new();
241
242 for project in &self.projects {
243 if project.port == 0 {
245 continue;
246 }
247
248 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 for path in unhealthy_projects {
261 self.unregister(&path);
262 }
263 }
264
265 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 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 self.projects.retain(|p| p.port != 0 || p.mcp_connected);
303 }
304
305 #[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 match registry.allocate_port() {
352 Ok(port) => {
353 assert_eq!(port, DEFAULT_PORT);
355
356 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 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 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 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 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 let json = serde_json::to_string_pretty(®istry).unwrap();
494 assert!(json.contains("test-project"));
495 assert!(json.contains("11391"));
496
497 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 match registry.allocate_port() {
511 Ok(port) => {
512 assert_eq!(port, DEFAULT_PORT);
514 },
515 Err(e) => {
516 assert!(
518 e.to_string().contains("already in use"),
519 "Expected 'already in use' error, got: {}",
520 e
521 );
522 },
523 }
524 }
525}