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 now = chrono::Utc::now().to_rfc3339();
149
150 if let Some(project) = self.find_by_path_mut(path) {
152 project.mcp_connected = true;
154 project.mcp_last_seen = Some(now.clone());
155 project.mcp_agent = agent_name;
156 } else {
157 let name = path
159 .file_name()
160 .and_then(|n| n.to_str())
161 .unwrap_or("unknown")
162 .to_string();
163
164 let db_path = path.join(".intent-engine").join("project.db");
165
166 let project = RegisteredProject {
167 path: path.clone(),
168 name,
169 port: 0, pid: None,
171 started_at: now.clone(),
172 db_path,
173 mcp_connected: true,
174 mcp_last_seen: Some(now),
175 mcp_agent: agent_name,
176 };
177
178 self.projects.push(project);
179 }
180
181 self.save()
182 }
183
184 pub fn update_mcp_heartbeat(&mut self, path: &PathBuf) -> anyhow::Result<()> {
187 if let Some(project) = self.find_by_path_mut(path) {
188 project.mcp_last_seen = Some(chrono::Utc::now().to_rfc3339());
190 project.mcp_connected = true;
191 self.save()?;
192 } else {
193 self.register_mcp_connection(path, Some("mcp-client".to_string()))?;
196 }
197 Ok(())
198 }
199
200 pub fn unregister_mcp_connection(&mut self, path: &PathBuf) -> anyhow::Result<()> {
202 if let Some(project) = self.find_by_path_mut(path) {
203 project.mcp_connected = false;
204 project.mcp_last_seen = None;
205 project.mcp_agent = None;
206
207 self.save()?;
210 }
211 Ok(())
212 }
213
214 pub fn cleanup_dead_processes(&mut self) {
216 self.projects.retain(|project| {
217 if let Some(pid) = project.pid {
218 Self::is_process_alive(pid)
219 } else {
220 true }
222 });
223 }
224
225 pub async fn cleanup_unhealthy_dashboards(&mut self) {
228 let mut unhealthy_projects = Vec::new();
229
230 for project in &self.projects {
231 if project.port == 0 {
233 continue;
234 }
235
236 if !Self::check_health(project.port).await {
238 tracing::debug!(
239 "Dashboard for {} (port {}) is unhealthy, will be cleaned up",
240 project.name,
241 project.port
242 );
243 unhealthy_projects.push(project.path.clone());
244 }
245 }
246
247 for path in unhealthy_projects {
249 self.unregister(&path);
250 }
251 }
252
253 async fn check_health(port: u16) -> bool {
255 let health_url = format!("http://127.0.0.1:{}/api/health", port);
256
257 match reqwest::Client::builder()
258 .timeout(std::time::Duration::from_secs(2))
259 .build()
260 {
261 Ok(client) => match client.get(&health_url).send().await {
262 Ok(resp) if resp.status().is_success() => true,
263 Ok(_) => false,
264 Err(_) => false,
265 },
266 Err(_) => false,
267 }
268 }
269
270 pub fn cleanup_stale_mcp_connections(&mut self) {
272 use chrono::DateTime;
273 let now = chrono::Utc::now();
274 const TIMEOUT_MINUTES: i64 = 5;
275
276 for project in &mut self.projects {
277 if let Some(last_seen) = &project.mcp_last_seen {
278 if let Ok(last_time) = DateTime::parse_from_rfc3339(last_seen) {
279 let duration = now.signed_duration_since(last_time.with_timezone(&chrono::Utc));
280 if duration.num_minutes() > TIMEOUT_MINUTES {
281 project.mcp_connected = false;
282 project.mcp_last_seen = None;
283 project.mcp_agent = None;
284 }
285 }
286 }
287 }
288
289 self.projects.retain(|p| p.port != 0 || p.mcp_connected);
291 }
292
293 #[cfg(unix)]
295 fn is_process_alive(pid: u32) -> bool {
296 use std::process::Command;
297 Command::new("kill")
298 .args(["-0", &pid.to_string()])
299 .output()
300 .map(|output| output.status.success())
301 .unwrap_or(false)
302 }
303
304 #[cfg(windows)]
305 fn is_process_alive(pid: u32) -> bool {
306 use std::process::Command;
307 Command::new("tasklist")
308 .args(["/FI", &format!("PID eq {}", pid)])
309 .output()
310 .map(|output| String::from_utf8_lossy(&output.stdout).contains(&pid.to_string()))
311 .unwrap_or(false)
312 }
313}
314
315impl Default for ProjectRegistry {
316 fn default() -> Self {
317 Self::new()
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use tempfile::TempDir;
325
326 #[test]
327 fn test_new_registry() {
328 let registry = ProjectRegistry::new();
329 assert_eq!(registry.version, VERSION);
330 assert_eq!(registry.projects.len(), 0);
331 }
332
333 #[test]
334 #[serial_test::serial]
335 fn test_allocate_port() {
336 let mut registry = ProjectRegistry::new();
337
338 match registry.allocate_port() {
340 Ok(port) => {
341 assert_eq!(port, DEFAULT_PORT);
343
344 registry.register(RegisteredProject {
346 path: PathBuf::from("/test/project1"),
347 name: "project1".to_string(),
348 port,
349 pid: None,
350 started_at: "2025-01-01T00:00:00Z".to_string(),
351 db_path: PathBuf::from("/test/project1/.intent-engine/intents.db"),
352 mcp_connected: false,
353 mcp_last_seen: None,
354 mcp_agent: None,
355 });
356 },
357 Err(e) => {
358 assert!(
360 e.to_string().contains("already in use"),
361 "Expected 'already in use' error, got: {}",
362 e
363 );
364 },
365 }
366 }
367
368 #[test]
369 fn test_register_and_find() {
370 let mut registry = ProjectRegistry::new();
371
372 let project = RegisteredProject {
373 path: PathBuf::from("/test/project"),
374 name: "test-project".to_string(),
375 port: 11391,
376 pid: Some(12345),
377 started_at: "2025-01-01T00:00:00Z".to_string(),
378 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
379 mcp_connected: false,
380 mcp_last_seen: None,
381 mcp_agent: None,
382 };
383
384 registry.register(project.clone());
385 assert_eq!(registry.projects.len(), 1);
386
387 let found = registry.find_by_path(&PathBuf::from("/test/project"));
389 assert!(found.is_some());
390 assert_eq!(found.unwrap().name, "test-project");
391
392 let found_by_port = registry.find_by_port(11391);
394 assert!(found_by_port.is_some());
395 assert_eq!(found_by_port.unwrap().name, "test-project");
396 }
397
398 #[test]
399 fn test_unregister() {
400 let mut registry = ProjectRegistry::new();
401
402 let project = RegisteredProject {
403 path: PathBuf::from("/test/project"),
404 name: "test-project".to_string(),
405 port: 11391,
406 pid: None,
407 started_at: "2025-01-01T00:00:00Z".to_string(),
408 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
409 mcp_connected: false,
410 mcp_last_seen: None,
411 mcp_agent: None,
412 };
413
414 registry.register(project.clone());
415 assert_eq!(registry.projects.len(), 1);
416
417 registry.unregister(&PathBuf::from("/test/project"));
418 assert_eq!(registry.projects.len(), 0);
419 }
420
421 #[test]
422 fn test_duplicate_path_replaces() {
423 let mut registry = ProjectRegistry::new();
424
425 let project1 = RegisteredProject {
426 path: PathBuf::from("/test/project"),
427 name: "project-v1".to_string(),
428 port: 11391,
429 pid: None,
430 started_at: "2025-01-01T00:00:00Z".to_string(),
431 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
432 mcp_connected: false,
433 mcp_last_seen: None,
434 mcp_agent: None,
435 };
436
437 let project2 = RegisteredProject {
438 path: PathBuf::from("/test/project"),
439 name: "project-v2".to_string(),
440 port: 3031,
441 pid: None,
442 started_at: "2025-01-01T01: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 registry.register(project1);
450 assert_eq!(registry.projects.len(), 1);
451
452 registry.register(project2);
453 assert_eq!(registry.projects.len(), 1);
454
455 let found = registry.find_by_path(&PathBuf::from("/test/project"));
456 assert_eq!(found.unwrap().name, "project-v2");
457 }
458
459 #[test]
460 fn test_save_and_load() {
461 let _temp_dir = TempDir::new().unwrap();
462
463 let mut registry = ProjectRegistry::new();
465
466 let project = RegisteredProject {
467 path: PathBuf::from("/test/project"),
468 name: "test-project".to_string(),
469 port: 11391,
470 pid: Some(12345),
471 started_at: "2025-01-01T00:00:00Z".to_string(),
472 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
473 mcp_connected: false,
474 mcp_last_seen: None,
475 mcp_agent: None,
476 };
477
478 registry.register(project);
479
480 let json = serde_json::to_string_pretty(®istry).unwrap();
482 assert!(json.contains("test-project"));
483 assert!(json.contains("11391"));
484
485 let loaded: ProjectRegistry = serde_json::from_str(&json).unwrap();
487 assert_eq!(loaded.projects.len(), 1);
488 assert_eq!(loaded.projects[0].name, "test-project");
489 assert_eq!(loaded.projects[0].port, 11391);
490 }
491
492 #[test]
493 #[serial_test::serial]
494 fn test_fixed_port() {
495 let mut registry = ProjectRegistry::new();
496
497 match registry.allocate_port() {
499 Ok(port) => {
500 assert_eq!(port, DEFAULT_PORT);
502 },
503 Err(e) => {
504 assert!(
506 e.to_string().contains("already in use"),
507 "Expected 'already in use' error, got: {}",
508 e
509 );
510 },
511 }
512 }
513}