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/// Switch to a different task
263pub async fn switch_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
264    let db_pool = state.current_project.read().await.db_pool.clone();
265    let task_mgr = TaskManager::new(&db_pool);
266
267    match task_mgr.switch_to_task(id).await {
268        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
269        Err(e) if e.to_string().contains("not found") => (
270            StatusCode::NOT_FOUND,
271            Json(ApiError {
272                code: "TASK_NOT_FOUND".to_string(),
273                message: format!("Task {} not found", id),
274                details: None,
275            }),
276        )
277            .into_response(),
278        Err(e) => (
279            StatusCode::BAD_REQUEST,
280            Json(ApiError {
281                code: "INVALID_REQUEST".to_string(),
282                message: format!("Failed to switch task: {}", e),
283                details: None,
284            }),
285        )
286            .into_response(),
287    }
288}
289
290/// Spawn a subtask and switch to it
291/// Note: This creates a subtask of the CURRENT task, not an arbitrary parent
292pub async fn spawn_subtask(
293    State(state): State<AppState>,
294    Path(_parent_id): Path<i64>, // Ignored - uses current task
295    Json(req): Json<SpawnSubtaskRequest>,
296) -> impl IntoResponse {
297    let db_pool = state.current_project.read().await.db_pool.clone();
298    let task_mgr = TaskManager::new(&db_pool);
299
300    // spawn_subtask uses the current task as parent automatically
301    match task_mgr.spawn_subtask(&req.name, req.spec.as_deref()).await {
302        Ok(response) => (StatusCode::CREATED, Json(ApiResponse { data: response })).into_response(),
303        Err(e) if e.to_string().contains("No current task") => (
304            StatusCode::BAD_REQUEST,
305            Json(ApiError {
306                code: "NO_CURRENT_TASK".to_string(),
307                message: "No current task to spawn subtask from".to_string(),
308                details: None,
309            }),
310        )
311            .into_response(),
312        Err(e) => (
313            StatusCode::BAD_REQUEST,
314            Json(ApiError {
315                code: "INVALID_REQUEST".to_string(),
316                message: format!("Failed to spawn subtask: {}", e),
317                details: None,
318            }),
319        )
320            .into_response(),
321    }
322}
323
324/// Get current task
325pub async fn get_current_task(State(state): State<AppState>) -> impl IntoResponse {
326    let db_pool = state.current_project.read().await.db_pool.clone();
327    let workspace_mgr = WorkspaceManager::new(&db_pool);
328
329    match workspace_mgr.get_current_task().await {
330        Ok(response) => {
331            if response.task.is_some() {
332                (StatusCode::OK, Json(ApiResponse { data: response })).into_response()
333            } else {
334                (
335                    StatusCode::OK,
336                    Json(json!({
337                        "data": null,
338                        "message": "No current task"
339                    })),
340                )
341                    .into_response()
342            }
343        },
344        Err(e) => (
345            StatusCode::INTERNAL_SERVER_ERROR,
346            Json(ApiError {
347                code: "DATABASE_ERROR".to_string(),
348                message: format!("Failed to get current task: {}", e),
349                details: None,
350            }),
351        )
352            .into_response(),
353    }
354}
355
356/// Pick next task recommendation
357pub async fn pick_next_task(State(state): State<AppState>) -> impl IntoResponse {
358    let db_pool = state.current_project.read().await.db_pool.clone();
359    let task_mgr = TaskManager::new(&db_pool);
360
361    match task_mgr.pick_next().await {
362        Ok(response) => (StatusCode::OK, Json(ApiResponse { data: response })).into_response(),
363        Err(e) => (
364            StatusCode::INTERNAL_SERVER_ERROR,
365            Json(ApiError {
366                code: "DATABASE_ERROR".to_string(),
367                message: format!("Failed to pick next task: {}", e),
368                details: None,
369            }),
370        )
371            .into_response(),
372    }
373}
374
375/// List events for a task
376pub async fn list_events(
377    State(state): State<AppState>,
378    Path(task_id): Path<i64>,
379    Query(query): Query<EventListQuery>,
380) -> impl IntoResponse {
381    let db_pool = state.current_project.read().await.db_pool.clone();
382    let event_mgr = EventManager::new(&db_pool);
383
384    // Signature: list_events(task_id, limit, log_type, since)
385    match event_mgr
386        .list_events(
387            Some(task_id),
388            query.limit.map(|l| l as i64),
389            query.event_type,
390            query.since,
391        )
392        .await
393    {
394        Ok(events) => (StatusCode::OK, Json(ApiResponse { data: events })).into_response(),
395        Err(e) => (
396            StatusCode::INTERNAL_SERVER_ERROR,
397            Json(ApiError {
398                code: "DATABASE_ERROR".to_string(),
399                message: format!("Failed to list events: {}", e),
400                details: None,
401            }),
402        )
403            .into_response(),
404    }
405}
406
407/// Add an event to a task
408pub async fn create_event(
409    State(state): State<AppState>,
410    Path(task_id): Path<i64>,
411    Json(req): Json<CreateEventRequest>,
412) -> impl IntoResponse {
413    let db_pool = state.current_project.read().await.db_pool.clone();
414    let event_mgr = EventManager::new(&db_pool);
415
416    // Validate event type
417    if !["decision", "blocker", "milestone", "note"].contains(&req.event_type.as_str()) {
418        return (
419            StatusCode::BAD_REQUEST,
420            Json(ApiError {
421                code: "INVALID_REQUEST".to_string(),
422                message: format!("Invalid event type: {}", req.event_type),
423                details: None,
424            }),
425        )
426            .into_response();
427    }
428
429    // add_event signature: (task_id, log_type, discussion_data)
430    match event_mgr
431        .add_event(task_id, &req.event_type, &req.data)
432        .await
433    {
434        Ok(event) => (StatusCode::CREATED, Json(ApiResponse { data: event })).into_response(),
435        Err(e) => (
436            StatusCode::BAD_REQUEST,
437            Json(ApiError {
438                code: "INVALID_REQUEST".to_string(),
439                message: format!("Failed to create event: {}", e),
440                details: None,
441            }),
442        )
443            .into_response(),
444    }
445}
446
447/// Unified search across tasks and events
448pub async fn search(
449    State(state): State<AppState>,
450    Query(query): Query<SearchQuery>,
451) -> impl IntoResponse {
452    let db_pool = state.current_project.read().await.db_pool.clone();
453    let search_mgr = SearchManager::new(&db_pool);
454
455    match search_mgr
456        .unified_search(
457            &query.query,
458            query.include_tasks,
459            query.include_events,
460            query.limit.map(|l| l as i64),
461        )
462        .await
463    {
464        Ok(results) => (StatusCode::OK, Json(ApiResponse { data: results })).into_response(),
465        Err(e) => (
466            StatusCode::INTERNAL_SERVER_ERROR,
467            Json(ApiError {
468                code: "DATABASE_ERROR".to_string(),
469                message: format!("Search failed: {}", e),
470                details: None,
471            }),
472        )
473            .into_response(),
474    }
475}
476
477/// List all registered projects
478pub async fn list_projects() -> impl IntoResponse {
479    match crate::dashboard::registry::ProjectRegistry::load() {
480        Ok(mut registry) => {
481            // Clean up stale MCP connections before returning
482            registry.cleanup_stale_mcp_connections();
483            if let Err(e) = registry.save() {
484                eprintln!("⚠ Failed to save registry after cleanup: {}", e);
485            }
486
487            let projects: Vec<serde_json::Value> = registry
488                .projects
489                .iter()
490                .map(|p| {
491                    json!({
492                        "name": p.name,
493                        "path": p.path.display().to_string(),
494                        "port": p.port,
495                        "pid": p.pid,
496                        "url": format!("http://127.0.0.1:{}", p.port),
497                        "started_at": p.started_at,
498                        "mcp_connected": p.mcp_connected,
499                        "mcp_agent": p.mcp_agent,
500                        "mcp_last_seen": p.mcp_last_seen,
501                    })
502                })
503                .collect();
504
505            (StatusCode::OK, Json(ApiResponse { data: projects })).into_response()
506        },
507        Err(e) => (
508            StatusCode::INTERNAL_SERVER_ERROR,
509            Json(ApiError {
510                code: "REGISTRY_ERROR".to_string(),
511                message: format!("Failed to load project registry: {}", e),
512                details: None,
513            }),
514        )
515            .into_response(),
516    }
517}
518
519/// Switch to a different project database dynamically
520pub async fn switch_project(
521    State(state): State<AppState>,
522    Json(req): Json<SwitchProjectRequest>,
523) -> impl IntoResponse {
524    use super::server::ProjectContext;
525    use sqlx::SqlitePool;
526    use std::path::PathBuf;
527
528    // Parse and validate project path
529    let project_path = PathBuf::from(&req.project_path);
530
531    if !project_path.exists() {
532        return (
533            StatusCode::NOT_FOUND,
534            Json(ApiError {
535                code: "PROJECT_NOT_FOUND".to_string(),
536                message: format!("Project path does not exist: {}", project_path.display()),
537                details: None,
538            }),
539        )
540            .into_response();
541    }
542
543    // Construct database path
544    let db_path = project_path.join(".intent-engine").join("project.db");
545
546    if !db_path.exists() {
547        return (
548            StatusCode::NOT_FOUND,
549            Json(ApiError {
550                code: "DATABASE_NOT_FOUND".to_string(),
551                message: format!(
552                    "Database not found at {}. Is this an Intent-Engine project?",
553                    db_path.display()
554                ),
555                details: None,
556            }),
557        )
558            .into_response();
559    }
560
561    // Create new database connection
562    let db_url = format!("sqlite://{}", db_path.display());
563    let new_db_pool = match SqlitePool::connect(&db_url).await {
564        Ok(pool) => pool,
565        Err(e) => {
566            return (
567                StatusCode::INTERNAL_SERVER_ERROR,
568                Json(ApiError {
569                    code: "DATABASE_CONNECTION_ERROR".to_string(),
570                    message: format!("Failed to connect to database: {}", e),
571                    details: None,
572                }),
573            )
574                .into_response();
575        },
576    };
577
578    // Extract project name
579    let project_name = project_path
580        .file_name()
581        .and_then(|n| n.to_str())
582        .unwrap_or("unknown")
583        .to_string();
584
585    // Create new project context
586    let new_context = ProjectContext {
587        db_pool: new_db_pool,
588        project_name: project_name.clone(),
589        project_path: project_path.clone(),
590        db_path: db_path.clone(),
591    };
592
593    // Update the current project (write lock)
594    {
595        let mut current = state.current_project.write().await;
596        *current = new_context;
597    }
598
599    tracing::info!(
600        "Switched to project: {} at {}",
601        project_name,
602        project_path.display()
603    );
604
605    (
606        StatusCode::OK,
607        Json(ApiResponse {
608            data: json!({
609                "success": true,
610                "project_name": project_name,
611                "project_path": project_path.display().to_string(),
612                "database": db_path.display().to_string(),
613            }),
614        }),
615    )
616        .into_response()
617}