intent_engine/dashboard/
handlers.rs

1use axum::{
2    extract::{Path, Query, State},
3    http::StatusCode,
4    response::{IntoResponse, Json},
5};
6use serde_json::json;
7
8use super::models::*;
9use super::server::AppState;
10use crate::{
11    events::EventManager, search::SearchManager, tasks::TaskManager, workspace::WorkspaceManager,
12};
13
14/// Get all tasks with optional filters
15pub async fn list_tasks(
16    State(state): State<AppState>,
17    Query(query): Query<TaskListQuery>,
18) -> impl IntoResponse {
19    let db_pool = state.current_project.read().await.db_pool.clone();
20    let task_mgr = TaskManager::new(&db_pool);
21
22    // Convert parent filter to Option<Option<i64>>
23    let parent_filter = query.parent.as_deref().map(|p| {
24        if p == "null" {
25            None
26        } else {
27            p.parse::<i64>().ok()
28        }
29    });
30
31    match task_mgr
32        .find_tasks(query.status.as_deref(), parent_filter)
33        .await
34    {
35        Ok(tasks) => (StatusCode::OK, Json(ApiResponse { data: tasks })).into_response(),
36        Err(e) => (
37            StatusCode::INTERNAL_SERVER_ERROR,
38            Json(ApiError {
39                code: "DATABASE_ERROR".to_string(),
40                message: format!("Failed to list tasks: {}", e),
41                details: None,
42            }),
43        )
44            .into_response(),
45    }
46}
47
48/// Get a single task by ID
49pub async fn get_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
50    let db_pool = state.current_project.read().await.db_pool.clone();
51    let task_mgr = TaskManager::new(&db_pool);
52
53    match task_mgr.get_task(id).await {
54        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
55        Err(e) if e.to_string().contains("not found") => (
56            StatusCode::NOT_FOUND,
57            Json(ApiError {
58                code: "TASK_NOT_FOUND".to_string(),
59                message: format!("Task {} not found", id),
60                details: None,
61            }),
62        )
63            .into_response(),
64        Err(e) => (
65            StatusCode::INTERNAL_SERVER_ERROR,
66            Json(ApiError {
67                code: "DATABASE_ERROR".to_string(),
68                message: format!("Failed to get task: {}", e),
69                details: None,
70            }),
71        )
72            .into_response(),
73    }
74}
75
76/// Create a new task
77pub async fn create_task(
78    State(state): State<AppState>,
79    Json(req): Json<CreateTaskRequest>,
80) -> impl IntoResponse {
81    let db_pool = state.current_project.read().await.db_pool.clone();
82    let task_mgr = TaskManager::new(&db_pool);
83
84    // Note: add_task doesn't support priority - it's set separately via update_task
85    let result = task_mgr
86        .add_task(&req.name, req.spec.as_deref(), req.parent_id)
87        .await;
88
89    match result {
90        Ok(mut task) => {
91            // If priority was requested, update it
92            if let Some(priority) = req.priority {
93                if let Ok(updated_task) = task_mgr
94                    .update_task(task.id, None, None, None, None, None, Some(priority))
95                    .await
96                {
97                    task = updated_task;
98                }
99                // Ignore priority update errors
100            }
101            (StatusCode::CREATED, Json(ApiResponse { data: task })).into_response()
102        },
103        Err(e) => (
104            StatusCode::BAD_REQUEST,
105            Json(ApiError {
106                code: "INVALID_REQUEST".to_string(),
107                message: format!("Failed to create task: {}", e),
108                details: None,
109            }),
110        )
111            .into_response(),
112    }
113}
114
115/// Update a task
116pub async fn update_task(
117    State(state): State<AppState>,
118    Path(id): Path<i64>,
119    Json(req): Json<UpdateTaskRequest>,
120) -> impl IntoResponse {
121    let db_pool = state.current_project.read().await.db_pool.clone();
122    let task_mgr = TaskManager::new(&db_pool);
123
124    // First check if task exists
125    match task_mgr.get_task(id).await {
126        Err(e) if e.to_string().contains("not found") => {
127            return (
128                StatusCode::NOT_FOUND,
129                Json(ApiError {
130                    code: "TASK_NOT_FOUND".to_string(),
131                    message: format!("Task {} not found", id),
132                    details: None,
133                }),
134            )
135                .into_response()
136        },
137        Err(e) => {
138            return (
139                StatusCode::INTERNAL_SERVER_ERROR,
140                Json(ApiError {
141                    code: "DATABASE_ERROR".to_string(),
142                    message: format!("Database error: {}", e),
143                    details: None,
144                }),
145            )
146                .into_response()
147        },
148        Ok(_) => {},
149    }
150
151    // Update task fields
152    // Signature: update_task(id, name, spec, parent_id, status, complexity, priority)
153    match task_mgr
154        .update_task(
155            id,
156            req.name.as_deref(),
157            req.spec.as_deref(),
158            None, // parent_id - not supported via update API
159            req.status.as_deref(),
160            None, // complexity - not exposed in API
161            req.priority,
162        )
163        .await
164    {
165        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
166        Err(e) => (
167            StatusCode::BAD_REQUEST,
168            Json(ApiError {
169                code: "INVALID_REQUEST".to_string(),
170                message: format!("Failed to update task: {}", e),
171                details: None,
172            }),
173        )
174            .into_response(),
175    }
176}
177
178/// Delete a task
179pub async fn delete_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
180    let db_pool = state.current_project.read().await.db_pool.clone();
181    let task_mgr = TaskManager::new(&db_pool);
182
183    match task_mgr.delete_task(id).await {
184        Ok(_) => (StatusCode::NO_CONTENT).into_response(),
185        Err(e) if e.to_string().contains("not found") => (
186            StatusCode::NOT_FOUND,
187            Json(ApiError {
188                code: "TASK_NOT_FOUND".to_string(),
189                message: format!("Task {} not found", id),
190                details: None,
191            }),
192        )
193            .into_response(),
194        Err(e) => (
195            StatusCode::BAD_REQUEST,
196            Json(ApiError {
197                code: "INVALID_REQUEST".to_string(),
198                message: format!("Failed to delete task: {}", e),
199                details: None,
200            }),
201        )
202            .into_response(),
203    }
204}
205
206/// Start a task (set as current)
207pub async fn start_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
208    let db_pool = state.current_project.read().await.db_pool.clone();
209    let task_mgr = TaskManager::new(&db_pool);
210
211    match task_mgr.start_task(id, false).await {
212        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
213        Err(e) if e.to_string().contains("not found") => (
214            StatusCode::NOT_FOUND,
215            Json(ApiError {
216                code: "TASK_NOT_FOUND".to_string(),
217                message: format!("Task {} not found", id),
218                details: None,
219            }),
220        )
221            .into_response(),
222        Err(e) => (
223            StatusCode::BAD_REQUEST,
224            Json(ApiError {
225                code: "INVALID_REQUEST".to_string(),
226                message: format!("Failed to start task: {}", e),
227                details: None,
228            }),
229        )
230            .into_response(),
231    }
232}
233
234/// Complete the current task
235pub async fn done_task(State(state): State<AppState>) -> impl IntoResponse {
236    let db_pool = state.current_project.read().await.db_pool.clone();
237    let task_mgr = TaskManager::new(&db_pool);
238
239    match task_mgr.done_task().await {
240        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
241        Err(e) if e.to_string().contains("No current task") => (
242            StatusCode::BAD_REQUEST,
243            Json(ApiError {
244                code: "NO_CURRENT_TASK".to_string(),
245                message: "No current task to complete".to_string(),
246                details: None,
247            }),
248        )
249            .into_response(),
250        Err(e) => (
251            StatusCode::BAD_REQUEST,
252            Json(ApiError {
253                code: "INVALID_REQUEST".to_string(),
254                message: format!("Failed to complete task: {}", e),
255                details: None,
256            }),
257        )
258            .into_response(),
259    }
260}
261
262/// Spawn a subtask and switch to it
263/// Note: This creates a subtask of the CURRENT task, not an arbitrary parent
264pub async fn spawn_subtask(
265    State(state): State<AppState>,
266    Path(_parent_id): Path<i64>, // Ignored - uses current task
267    Json(req): Json<SpawnSubtaskRequest>,
268) -> impl IntoResponse {
269    let db_pool = state.current_project.read().await.db_pool.clone();
270    let task_mgr = TaskManager::new(&db_pool);
271
272    // spawn_subtask uses the current task as parent automatically
273    match task_mgr.spawn_subtask(&req.name, req.spec.as_deref()).await {
274        Ok(response) => (StatusCode::CREATED, Json(ApiResponse { data: response })).into_response(),
275        Err(e) if e.to_string().contains("No current task") => (
276            StatusCode::BAD_REQUEST,
277            Json(ApiError {
278                code: "NO_CURRENT_TASK".to_string(),
279                message: "No current task to spawn subtask from".to_string(),
280                details: None,
281            }),
282        )
283            .into_response(),
284        Err(e) => (
285            StatusCode::BAD_REQUEST,
286            Json(ApiError {
287                code: "INVALID_REQUEST".to_string(),
288                message: format!("Failed to spawn subtask: {}", e),
289                details: None,
290            }),
291        )
292            .into_response(),
293    }
294}
295
296/// Get current task
297pub async fn get_current_task(State(state): State<AppState>) -> impl IntoResponse {
298    let db_pool = state.current_project.read().await.db_pool.clone();
299    let workspace_mgr = WorkspaceManager::new(&db_pool);
300
301    match workspace_mgr.get_current_task().await {
302        Ok(response) => {
303            if response.task.is_some() {
304                (StatusCode::OK, Json(ApiResponse { data: response })).into_response()
305            } else {
306                (
307                    StatusCode::OK,
308                    Json(json!({
309                        "data": null,
310                        "message": "No current task"
311                    })),
312                )
313                    .into_response()
314            }
315        },
316        Err(e) => (
317            StatusCode::INTERNAL_SERVER_ERROR,
318            Json(ApiError {
319                code: "DATABASE_ERROR".to_string(),
320                message: format!("Failed to get current task: {}", e),
321                details: None,
322            }),
323        )
324            .into_response(),
325    }
326}
327
328/// Pick next task recommendation
329pub async fn pick_next_task(State(state): State<AppState>) -> impl IntoResponse {
330    let db_pool = state.current_project.read().await.db_pool.clone();
331    let task_mgr = TaskManager::new(&db_pool);
332
333    match task_mgr.pick_next().await {
334        Ok(response) => (StatusCode::OK, Json(ApiResponse { data: response })).into_response(),
335        Err(e) => (
336            StatusCode::INTERNAL_SERVER_ERROR,
337            Json(ApiError {
338                code: "DATABASE_ERROR".to_string(),
339                message: format!("Failed to pick next task: {}", e),
340                details: None,
341            }),
342        )
343            .into_response(),
344    }
345}
346
347/// List events for a task
348pub async fn list_events(
349    State(state): State<AppState>,
350    Path(task_id): Path<i64>,
351    Query(query): Query<EventListQuery>,
352) -> impl IntoResponse {
353    let db_pool = state.current_project.read().await.db_pool.clone();
354    let event_mgr = EventManager::new(&db_pool);
355
356    // Signature: list_events(task_id, limit, log_type, since)
357    match event_mgr
358        .list_events(
359            Some(task_id),
360            query.limit.map(|l| l as i64),
361            query.event_type,
362            query.since,
363        )
364        .await
365    {
366        Ok(events) => (StatusCode::OK, Json(ApiResponse { data: events })).into_response(),
367        Err(e) => (
368            StatusCode::INTERNAL_SERVER_ERROR,
369            Json(ApiError {
370                code: "DATABASE_ERROR".to_string(),
371                message: format!("Failed to list events: {}", e),
372                details: None,
373            }),
374        )
375            .into_response(),
376    }
377}
378
379/// Add an event to a task
380pub async fn create_event(
381    State(state): State<AppState>,
382    Path(task_id): Path<i64>,
383    Json(req): Json<CreateEventRequest>,
384) -> impl IntoResponse {
385    let db_pool = state.current_project.read().await.db_pool.clone();
386    let event_mgr = EventManager::new(&db_pool);
387
388    // Validate event type
389    if !["decision", "blocker", "milestone", "note"].contains(&req.event_type.as_str()) {
390        return (
391            StatusCode::BAD_REQUEST,
392            Json(ApiError {
393                code: "INVALID_REQUEST".to_string(),
394                message: format!("Invalid event type: {}", req.event_type),
395                details: None,
396            }),
397        )
398            .into_response();
399    }
400
401    // add_event signature: (task_id, log_type, discussion_data)
402    match event_mgr
403        .add_event(task_id, &req.event_type, &req.data)
404        .await
405    {
406        Ok(event) => (StatusCode::CREATED, Json(ApiResponse { data: event })).into_response(),
407        Err(e) => (
408            StatusCode::BAD_REQUEST,
409            Json(ApiError {
410                code: "INVALID_REQUEST".to_string(),
411                message: format!("Failed to create event: {}", e),
412                details: None,
413            }),
414        )
415            .into_response(),
416    }
417}
418
419/// Unified search across tasks and events
420pub async fn search(
421    State(state): State<AppState>,
422    Query(query): Query<SearchQuery>,
423) -> impl IntoResponse {
424    let db_pool = state.current_project.read().await.db_pool.clone();
425    let search_mgr = SearchManager::new(&db_pool);
426
427    match search_mgr
428        .unified_search(
429            &query.query,
430            query.include_tasks,
431            query.include_events,
432            query.limit.map(|l| l as i64),
433        )
434        .await
435    {
436        Ok(results) => (StatusCode::OK, Json(ApiResponse { data: results })).into_response(),
437        Err(e) => (
438            StatusCode::INTERNAL_SERVER_ERROR,
439            Json(ApiError {
440                code: "DATABASE_ERROR".to_string(),
441                message: format!("Search failed: {}", e),
442                details: None,
443            }),
444        )
445            .into_response(),
446    }
447}
448
449/// List all registered projects
450pub async fn list_projects() -> impl IntoResponse {
451    match crate::dashboard::registry::ProjectRegistry::load() {
452        Ok(mut registry) => {
453            // Clean up stale MCP connections before returning
454            registry.cleanup_stale_mcp_connections();
455            if let Err(e) = registry.save() {
456                eprintln!("⚠ Failed to save registry after cleanup: {}", e);
457            }
458
459            let projects: Vec<serde_json::Value> = registry
460                .projects
461                .iter()
462                .map(|p| {
463                    json!({
464                        "name": p.name,
465                        "path": p.path.display().to_string(),
466                        "port": p.port,
467                        "pid": p.pid,
468                        "url": format!("http://127.0.0.1:{}", p.port),
469                        "started_at": p.started_at,
470                        "mcp_connected": p.mcp_connected,
471                        "mcp_agent": p.mcp_agent,
472                        "mcp_last_seen": p.mcp_last_seen,
473                    })
474                })
475                .collect();
476
477            (StatusCode::OK, Json(ApiResponse { data: projects })).into_response()
478        },
479        Err(e) => (
480            StatusCode::INTERNAL_SERVER_ERROR,
481            Json(ApiError {
482                code: "REGISTRY_ERROR".to_string(),
483                message: format!("Failed to load project registry: {}", e),
484                details: None,
485            }),
486        )
487            .into_response(),
488    }
489}
490
491/// Switch to a different project database dynamically
492pub async fn switch_project(
493    State(state): State<AppState>,
494    Json(req): Json<SwitchProjectRequest>,
495) -> impl IntoResponse {
496    use super::server::ProjectContext;
497    use sqlx::SqlitePool;
498    use std::path::PathBuf;
499
500    // Parse and validate project path
501    let project_path = PathBuf::from(&req.project_path);
502
503    if !project_path.exists() {
504        return (
505            StatusCode::NOT_FOUND,
506            Json(ApiError {
507                code: "PROJECT_NOT_FOUND".to_string(),
508                message: format!("Project path does not exist: {}", project_path.display()),
509                details: None,
510            }),
511        )
512            .into_response();
513    }
514
515    // Construct database path
516    let db_path = project_path.join(".intent-engine").join("project.db");
517
518    if !db_path.exists() {
519        return (
520            StatusCode::NOT_FOUND,
521            Json(ApiError {
522                code: "DATABASE_NOT_FOUND".to_string(),
523                message: format!(
524                    "Database not found at {}. Is this an Intent-Engine project?",
525                    db_path.display()
526                ),
527                details: None,
528            }),
529        )
530            .into_response();
531    }
532
533    // Create new database connection
534    let db_url = format!("sqlite://{}", db_path.display());
535    let new_db_pool = match SqlitePool::connect(&db_url).await {
536        Ok(pool) => pool,
537        Err(e) => {
538            return (
539                StatusCode::INTERNAL_SERVER_ERROR,
540                Json(ApiError {
541                    code: "DATABASE_CONNECTION_ERROR".to_string(),
542                    message: format!("Failed to connect to database: {}", e),
543                    details: None,
544                }),
545            )
546                .into_response();
547        },
548    };
549
550    // Extract project name
551    let project_name = project_path
552        .file_name()
553        .and_then(|n| n.to_str())
554        .unwrap_or("unknown")
555        .to_string();
556
557    // Create new project context
558    let new_context = ProjectContext {
559        db_pool: new_db_pool,
560        project_name: project_name.clone(),
561        project_path: project_path.clone(),
562        db_path: db_path.clone(),
563    };
564
565    // Update the current project (write lock)
566    {
567        let mut current = state.current_project.write().await;
568        *current = new_context;
569    }
570
571    tracing::info!(
572        "Switched to project: {} at {}",
573        project_name,
574        project_path.display()
575    );
576
577    (
578        StatusCode::OK,
579        Json(ApiResponse {
580            data: json!({
581                "success": true,
582                "project_name": project_name,
583                "project_path": project_path.display().to_string(),
584                "database": db_path.display().to_string(),
585            }),
586        }),
587    )
588        .into_response()
589}