Skip to main content

intent_engine/dashboard/
server.rs

1use anyhow::{Context, Result};
2use axum::{
3    extract::{Path, State},
4    http::{header, Method, StatusCode},
5    response::{Html, IntoResponse, Json, Response},
6    routing::get,
7    Router,
8};
9use rust_embed::RustEmbed;
10use serde::Serialize;
11use sqlx::SqlitePool;
12use std::collections::HashMap;
13use std::path::PathBuf;
14use std::sync::Arc;
15use tokio::sync::RwLock;
16use tower_http::{
17    cors::{Any, CorsLayer},
18    trace::TraceLayer,
19};
20
21use super::websocket;
22
23/// Canonicalize a path, falling back to the original if the path does not
24/// exist yet.  On Windows, `Path::canonicalize()` prepends the `\\?\`
25/// extended-path prefix, so every key stored in `known_projects` and every
26/// value stored in `active_project_path` must go through this helper.
27///
28/// Invariant: ALL keys in `known_projects` and `active_project_path` are
29/// canonical.  Methods that write to these data structures call this helper;
30/// methods that read from them perform direct lookups.
31fn canonical_path(path: &std::path::Path) -> PathBuf {
32    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
33}
34
35/// Embedded static assets (HTML, CSS, JS)
36#[derive(RustEmbed)]
37#[folder = "static/"]
38struct StaticAssets;
39
40/// Minimal project info (no connection pool - SQLite is fast enough to open on demand)
41#[derive(Clone, Debug)]
42pub struct ProjectInfo {
43    pub name: String,
44    pub path: PathBuf,
45    pub db_path: PathBuf,
46}
47
48/// Dashboard server state shared across handlers
49#[derive(Clone)]
50pub struct AppState {
51    /// Known projects (path -> info). No connection pools - SQLite opens fast.
52    pub known_projects: Arc<RwLock<HashMap<PathBuf, ProjectInfo>>>,
53    /// Currently active project path (for UI display)
54    pub active_project_path: Arc<RwLock<PathBuf>>,
55    /// The project that started the Dashboard (always considered online)
56    pub host_project: super::websocket::ProjectInfo,
57    pub port: u16,
58    /// WebSocket state for real-time connections
59    pub ws_state: super::websocket::WebSocketState,
60    /// Shutdown signal sender (for graceful shutdown via HTTP)
61    pub shutdown_tx: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
62}
63
64impl AppState {
65    /// Get database pool for a project (opens on demand - SQLite is fast)
66    pub async fn get_db_pool(&self, project_path: &std::path::Path) -> Result<SqlitePool, String> {
67        // Normalize the lookup key: known_projects is keyed by canonical paths.
68        let key = canonical_path(project_path);
69        let projects = self.known_projects.read().await;
70        if let Some(info) = projects.get(&key) {
71            let db_url = format!("sqlite://{}", info.db_path.display());
72            SqlitePool::connect(&db_url)
73                .await
74                .map_err(|e| format!("Failed to connect to database: {}", e))
75        } else {
76            Err(format!("Project not found: {}", project_path.display()))
77        }
78    }
79
80    /// Get database pool for the active project
81    pub async fn get_active_db_pool(&self) -> Result<SqlitePool, String> {
82        let active_path = self.active_project_path.read().await.clone();
83        self.get_db_pool(&active_path).await
84    }
85
86    /// Add a new project (or update existing)
87    pub async fn add_project(&self, path: PathBuf) -> Result<(), String> {
88        if !path.exists() {
89            return Err(format!("Project path does not exist: {}", path.display()));
90        }
91
92        let db_path = path.join(".intent-engine").join("project.db");
93        if !db_path.exists() {
94            return Err(format!("Database not found: {}", db_path.display()));
95        }
96
97        let name = path
98            .file_name()
99            .and_then(|n| n.to_str())
100            .unwrap_or("unknown")
101            .to_string();
102
103        // Canonicalize before inserting so the key matches all other entries.
104        // db_path is also canonicalized: derived paths inherit the Windows \\?\
105        // prefix from their base, and future callers may compare db_path values.
106        let canonical = canonical_path(&path);
107        let db_path = canonical_path(&db_path);
108        let info = ProjectInfo {
109            name,
110            path: canonical.clone(),
111            db_path,
112        };
113
114        let mut projects = self.known_projects.write().await;
115        projects.insert(canonical, info);
116        Ok(())
117    }
118
119    /// Get active project info
120    pub async fn get_active_project(&self) -> Option<ProjectInfo> {
121        let active_path = self.active_project_path.read().await;
122        let projects = self.known_projects.read().await;
123        projects.get(&*active_path).cloned()
124    }
125
126    /// Switch active project
127    pub async fn switch_active_project(&self, path: PathBuf) -> Result<(), String> {
128        let canonical = canonical_path(&path);
129        let projects = self.known_projects.read().await;
130        if !projects.contains_key(&canonical) {
131            return Err(format!("Project not registered: {}", path.display()));
132        }
133        drop(projects);
134
135        let mut active = self.active_project_path.write().await;
136        *active = canonical;
137        Ok(())
138    }
139
140    /// Remove a project from known projects and global registry
141    pub async fn remove_project(&self, path: &std::path::Path) -> Result<(), String> {
142        let canonical = canonical_path(path);
143
144        // Don't allow removing the host project. Compare canonical forms on
145        // both sides: host_project.path may be a non-canonical display string.
146        let host_canonical = canonical_path(std::path::Path::new(&self.host_project.path));
147        if canonical == host_canonical {
148            return Err("Cannot remove the host project".to_string());
149        }
150
151        // Remove from known projects. Use canonical key — non-canonical remove
152        // silently returns None and the project stays in the map.
153        let mut projects = self.known_projects.write().await;
154        projects.remove(&canonical);
155
156        // Remove from global registry.  global_projects::remove_project
157        // canonicalizes the path internally, but passing canonical here is
158        // harmless and makes the intent explicit.
159        let path_str = canonical.to_string_lossy().to_string();
160        crate::global_projects::remove_project(&path_str);
161
162        Ok(())
163    }
164
165    /// Get active project's db_pool and path (backward compatibility helper)
166    /// Returns (db_pool, project_path_string)
167    pub async fn get_active_project_context(&self) -> Result<(SqlitePool, String), String> {
168        let db_pool = self.get_active_db_pool().await?;
169        let project_path = self
170            .get_active_project()
171            .await
172            .map(|p| p.path.to_string_lossy().to_string())
173            .unwrap_or_default();
174        Ok((db_pool, project_path))
175    }
176}
177
178/// Dashboard server instance
179pub struct DashboardServer {
180    port: u16,
181    db_path: PathBuf,
182    project_name: String,
183    project_path: PathBuf,
184}
185
186/// Health check response
187#[derive(Serialize)]
188struct HealthResponse {
189    status: String,
190    service: String,
191    version: String,
192}
193
194/// Project info response (for API)
195#[derive(Serialize)]
196struct ProjectInfoResponse {
197    name: String,
198    path: String,
199    database: String,
200    port: u16,
201    is_online: bool,
202    mcp_connected: bool,
203}
204
205impl DashboardServer {
206    /// Create a new Dashboard server instance
207    pub async fn new(port: u16, project_path: PathBuf, db_path: PathBuf) -> Result<Self> {
208        // Determine project name from path
209        let project_name = project_path
210            .file_name()
211            .and_then(|n| n.to_str())
212            .unwrap_or("unknown")
213            .to_string();
214
215        if !db_path.exists() {
216            anyhow::bail!(
217                "Database not found at {}. Is this an Intent-Engine project?",
218                db_path.display()
219            );
220        }
221
222        Ok(Self {
223            port,
224            db_path,
225            project_name,
226            project_path,
227        })
228    }
229
230    /// Run the Dashboard server
231    pub async fn run(self) -> Result<()> {
232        // Initialize known projects with the host project.
233        // canonical_path() is the single normalization point: every key written
234        // into this map goes through it, so all reads can do plain lookups.
235        let mut known_projects = HashMap::new();
236        let host_canonical = canonical_path(&self.project_path);
237        let host_info = ProjectInfo {
238            name: self.project_name.clone(),
239            path: host_canonical.clone(),
240            db_path: self.db_path.clone(),
241        };
242        known_projects.insert(host_canonical.clone(), host_info);
243
244        // Load projects from global registry
245        let registry = crate::global_projects::ProjectsRegistry::load();
246        for entry in registry.projects {
247            let path = PathBuf::from(&entry.path);
248            let key = canonical_path(&path);
249            // Skip if already added (e.g. host project)
250            if known_projects.contains_key(&key) {
251                continue;
252            }
253            let db_path = path.join(".intent-engine").join("project.db");
254            if db_path.exists() {
255                let name = entry.name.unwrap_or_else(|| {
256                    path.file_name()
257                        .and_then(|n| n.to_str())
258                        .unwrap_or("unknown")
259                        .to_string()
260                });
261                known_projects.insert(
262                    key,
263                    ProjectInfo {
264                        name,
265                        path,
266                        db_path,
267                    },
268                );
269            }
270        }
271        tracing::info!(
272            "Loaded {} projects from global registry",
273            known_projects.len()
274        );
275
276        // Create shared state
277        let ws_state = websocket::WebSocketState::new();
278
279        let host_project_info = websocket::ProjectInfo {
280            name: self.project_name.clone(),
281            path: self.project_path.display().to_string(),
282            db_path: self.db_path.display().to_string(),
283            agent: None,
284            mcp_connected: false, // Will be updated dynamically
285            is_online: true,      // Host is always online
286        };
287
288        // Create shutdown channel for graceful shutdown
289        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
290
291        let state = AppState {
292            known_projects: Arc::new(RwLock::new(known_projects)),
293            // active_project_path must match the HashMap key (already canonical).
294            active_project_path: Arc::new(RwLock::new(host_canonical)),
295            host_project: host_project_info,
296            port: self.port,
297            ws_state,
298            shutdown_tx: Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))),
299        };
300
301        // Build router
302        let app = create_router(state);
303
304        // Bind to address
305        // Bind to 0.0.0.0 to allow external access (e.g., from Windows host when running in WSL)
306        let addr = format!("0.0.0.0:{}", self.port);
307        let listener = tokio::net::TcpListener::bind(&addr)
308            .await
309            .with_context(|| format!("Failed to bind to {}", addr))?;
310
311        tracing::info!(address = %addr, "Dashboard server listening");
312        tracing::warn!(
313            port = self.port,
314            "⚠️  Dashboard is accessible from external IPs"
315        );
316        tracing::info!(project = %self.project_name, "Project loaded");
317        tracing::info!(db_path = %self.db_path.display(), "Database path");
318
319        // Ignore SIGHUP signal on Unix systems to prevent termination when terminal closes
320        #[cfg(unix)]
321        {
322            unsafe {
323                libc::signal(libc::SIGHUP, libc::SIG_IGN);
324            }
325        }
326
327        // Run server with graceful shutdown
328        tracing::info!("Starting server with graceful shutdown support");
329        axum::serve(listener, app)
330            .with_graceful_shutdown(async {
331                shutdown_rx.await.ok();
332                tracing::info!("Shutdown signal received, initiating graceful shutdown");
333            })
334            .await
335            .context("Server error")?;
336
337        tracing::info!("Dashboard server shut down successfully");
338        Ok(())
339    }
340}
341
342/// Create the Axum router with all routes and middleware
343fn create_router(state: AppState) -> Router {
344    use super::routes;
345
346    // Combine basic API routes with full API routes
347    let api_routes = Router::new()
348        .route("/health", get(health_handler))
349        .route("/info", get(info_handler))
350        .merge(routes::api_routes());
351
352    // Main router - all routes share the same AppState
353    Router::new()
354        // Root route - serve index.html
355        .route("/", get(serve_index))
356        // Static files under /static prefix (embedded)
357        .route("/static/*path", get(serve_static))
358        // Vite assets under /assets prefix
359        .route("/assets/*path", get(serve_assets))
360        // API routes under /api prefix
361        .nest("/api", api_routes)
362        // WebSocket routes (now use full AppState)
363        .route("/ws/mcp", get(websocket::handle_mcp_websocket))
364        .route("/ws/ui", get(websocket::handle_ui_websocket))
365        // Fallback to 404
366        .fallback(not_found_handler)
367        // Add state
368        .with_state(state)
369        // Add middleware
370        .layer(
371            CorsLayer::new()
372                .allow_origin(Any)
373                .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
374                .allow_headers(Any),
375        )
376        .layer(TraceLayer::new_for_http())
377}
378
379/// Serve the main index.html file from embedded assets
380async fn serve_index() -> impl IntoResponse {
381    match StaticAssets::get("index.html") {
382        Some(content) => {
383            let body = content.data.to_vec();
384            Response::builder()
385                .status(StatusCode::OK)
386                .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
387                .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
388                .header(header::PRAGMA, "no-cache")
389                .header(header::EXPIRES, "0")
390                .body(body.into())
391                .unwrap()
392        },
393        None => (
394            StatusCode::INTERNAL_SERVER_ERROR,
395            Html("<h1>Error: index.html not found</h1>".to_string()),
396        )
397            .into_response(),
398    }
399}
400
401/// Serve static files from embedded assets
402async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
403    // Remove leading slash if present
404    let path = path.trim_start_matches('/');
405
406    match StaticAssets::get(path) {
407        Some(content) => {
408            let mime = mime_guess::from_path(path).first_or_octet_stream();
409            let body = content.data.to_vec();
410            Response::builder()
411                .status(StatusCode::OK)
412                .header(header::CONTENT_TYPE, mime.as_ref())
413                .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
414                .header(header::PRAGMA, "no-cache")
415                .header(header::EXPIRES, "0")
416                .body(body.into())
417                .unwrap()
418        },
419        None => (
420            StatusCode::NOT_FOUND,
421            Json(serde_json::json!({
422                "error": "File not found",
423                "code": "NOT_FOUND",
424                "path": path
425            })),
426        )
427            .into_response(),
428    }
429}
430
431/// Serve assets from embedded assets (for Vite)
432async fn serve_assets(Path(path): Path<String>) -> impl IntoResponse {
433    // Remove leading slash if present
434    let path = path.trim_start_matches('/');
435    // Prepend "assets/" if not present (though the route is /assets/*path, so path usually won't have it unless we strip it in route)
436    // Actually, the route is /assets/*path. If we request /assets/index.css, path is index.css.
437    // We need to look up "assets/index.css" in StaticAssets.
438    let full_path = format!("assets/{}", path);
439
440    match StaticAssets::get(&full_path) {
441        Some(content) => {
442            let mime = mime_guess::from_path(&full_path).first_or_octet_stream();
443            let body = content.data.to_vec();
444            Response::builder()
445                .status(StatusCode::OK)
446                .header(header::CONTENT_TYPE, mime.as_ref())
447                .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
448                .header(header::PRAGMA, "no-cache")
449                .header(header::EXPIRES, "0")
450                .body(body.into())
451                .unwrap()
452        },
453        None => (
454            StatusCode::NOT_FOUND,
455            Json(serde_json::json!({
456                "error": "Asset not found",
457                "code": "NOT_FOUND",
458                "path": full_path
459            })),
460        )
461            .into_response(),
462    }
463}
464
465/// Health check handler
466async fn health_handler() -> Json<HealthResponse> {
467    Json(HealthResponse {
468        status: "healthy".to_string(),
469        service: "intent-engine-dashboard".to_string(),
470        version: env!("CARGO_PKG_VERSION").to_string(),
471    })
472}
473
474/// Project info handler
475/// Returns current Dashboard project info from the single source of truth (WebSocketState)
476async fn info_handler(State(state): State<AppState>) -> Json<ProjectInfoResponse> {
477    let active_project = state.get_active_project().await;
478
479    match active_project {
480        Some(project) => {
481            // Get project info from WebSocketState (single source of truth)
482            let projects = state
483                .ws_state
484                .get_online_projects_with_current(
485                    &project.name,
486                    &project.path,
487                    &project.db_path,
488                    &state.host_project,
489                    state.port,
490                )
491                .await;
492
493            // Return the first project (which is always the current Dashboard project)
494            let current_project = projects.first().expect("Current project must exist");
495
496            Json(ProjectInfoResponse {
497                name: current_project.name.clone(),
498                path: current_project.path.clone(),
499                database: current_project.db_path.clone(),
500                port: state.port,
501                is_online: current_project.is_online,
502                mcp_connected: current_project.mcp_connected,
503            })
504        },
505        None => Json(ProjectInfoResponse {
506            name: "unknown".to_string(),
507            path: "".to_string(),
508            database: "".to_string(),
509            port: state.port,
510            is_online: false,
511            mcp_connected: false,
512        }),
513    }
514}
515
516/// 404 Not Found handler
517async fn not_found_handler() -> impl IntoResponse {
518    (
519        StatusCode::NOT_FOUND,
520        Json(serde_json::json!({
521            "error": "Not found",
522            "code": "NOT_FOUND"
523        })),
524    )
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    #[test]
532    fn test_health_response_serialization() {
533        let response = HealthResponse {
534            status: "healthy".to_string(),
535            service: "test".to_string(),
536            version: "1.0.0".to_string(),
537        };
538
539        let json = serde_json::to_string(&response).unwrap();
540        assert!(json.contains("healthy"));
541        assert!(json.contains("test"));
542    }
543
544    #[test]
545    fn test_project_info_response_serialization() {
546        let info = ProjectInfoResponse {
547            name: "test-project".to_string(),
548            path: "/path/to/project".to_string(),
549            database: "/path/to/db".to_string(),
550            port: 11391,
551            is_online: true,
552            mcp_connected: false,
553        };
554
555        let json = serde_json::to_string(&info).unwrap();
556        assert!(json.contains("test-project"));
557        assert!(json.contains("11391"));
558    }
559}