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 project = state.current_project.read().await;
82    let db_pool = project.db_pool.clone();
83    let project_path = project.project_path.to_string_lossy().to_string();
84    drop(project);
85
86    let task_mgr = TaskManager::with_websocket(
87        &db_pool,
88        std::sync::Arc::new(state.ws_state.clone()),
89        project_path,
90    );
91
92    // Note: add_task doesn't support priority - it's set separately via update_task
93    let result = task_mgr
94        .add_task(&req.name, req.spec.as_deref(), req.parent_id)
95        .await;
96
97    match result {
98        Ok(mut task) => {
99            // If priority was requested, update it
100            if let Some(priority) = req.priority {
101                if let Ok(updated_task) = task_mgr
102                    .update_task(task.id, None, None, None, None, None, Some(priority))
103                    .await
104                {
105                    task = updated_task;
106                }
107                // Ignore priority update errors
108            }
109            (StatusCode::CREATED, Json(ApiResponse { data: task })).into_response()
110        },
111        Err(e) => (
112            StatusCode::BAD_REQUEST,
113            Json(ApiError {
114                code: "INVALID_REQUEST".to_string(),
115                message: format!("Failed to create task: {}", e),
116                details: None,
117            }),
118        )
119            .into_response(),
120    }
121}
122
123/// Update a task
124pub async fn update_task(
125    State(state): State<AppState>,
126    Path(id): Path<i64>,
127    Json(req): Json<UpdateTaskRequest>,
128) -> impl IntoResponse {
129    let project = state.current_project.read().await;
130    let db_pool = project.db_pool.clone();
131    let project_path = project.project_path.to_string_lossy().to_string();
132    drop(project);
133
134    let task_mgr = TaskManager::with_websocket(
135        &db_pool,
136        std::sync::Arc::new(state.ws_state.clone()),
137        project_path,
138    );
139
140    // First check if task exists
141    match task_mgr.get_task(id).await {
142        Err(e) if e.to_string().contains("not found") => {
143            return (
144                StatusCode::NOT_FOUND,
145                Json(ApiError {
146                    code: "TASK_NOT_FOUND".to_string(),
147                    message: format!("Task {} not found", id),
148                    details: None,
149                }),
150            )
151                .into_response()
152        },
153        Err(e) => {
154            return (
155                StatusCode::INTERNAL_SERVER_ERROR,
156                Json(ApiError {
157                    code: "DATABASE_ERROR".to_string(),
158                    message: format!("Database error: {}", e),
159                    details: None,
160                }),
161            )
162                .into_response()
163        },
164        Ok(_) => {},
165    }
166
167    // Update task fields
168    // Signature: update_task(id, name, spec, parent_id, status, complexity, priority)
169    match task_mgr
170        .update_task(
171            id,
172            req.name.as_deref(),
173            req.spec.as_deref(),
174            None, // parent_id - not supported via update API
175            req.status.as_deref(),
176            None, // complexity - not exposed in API
177            req.priority,
178        )
179        .await
180    {
181        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
182        Err(e) => (
183            StatusCode::BAD_REQUEST,
184            Json(ApiError {
185                code: "INVALID_REQUEST".to_string(),
186                message: format!("Failed to update task: {}", e),
187                details: None,
188            }),
189        )
190            .into_response(),
191    }
192}
193
194/// Delete a task
195pub async fn delete_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
196    let project = state.current_project.read().await;
197    let db_pool = project.db_pool.clone();
198    let project_path = project.project_path.to_string_lossy().to_string();
199    drop(project);
200
201    let task_mgr = TaskManager::with_websocket(
202        &db_pool,
203        std::sync::Arc::new(state.ws_state.clone()),
204        project_path,
205    );
206
207    match task_mgr.delete_task(id).await {
208        Ok(_) => (StatusCode::NO_CONTENT).into_response(),
209        Err(e) if e.to_string().contains("not found") => (
210            StatusCode::NOT_FOUND,
211            Json(ApiError {
212                code: "TASK_NOT_FOUND".to_string(),
213                message: format!("Task {} not found", id),
214                details: None,
215            }),
216        )
217            .into_response(),
218        Err(e) => (
219            StatusCode::BAD_REQUEST,
220            Json(ApiError {
221                code: "INVALID_REQUEST".to_string(),
222                message: format!("Failed to delete task: {}", e),
223                details: None,
224            }),
225        )
226            .into_response(),
227    }
228}
229
230/// Start a task (set as current)
231pub async fn start_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
232    let project = state.current_project.read().await;
233    let db_pool = project.db_pool.clone();
234    let project_path = project.project_path.to_string_lossy().to_string();
235    drop(project);
236
237    let task_mgr = TaskManager::with_websocket(
238        &db_pool,
239        std::sync::Arc::new(state.ws_state.clone()),
240        project_path,
241    );
242
243    match task_mgr.start_task(id, false).await {
244        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
245        Err(e) if e.to_string().contains("not found") => (
246            StatusCode::NOT_FOUND,
247            Json(ApiError {
248                code: "TASK_NOT_FOUND".to_string(),
249                message: format!("Task {} not found", id),
250                details: None,
251            }),
252        )
253            .into_response(),
254        Err(e) => (
255            StatusCode::BAD_REQUEST,
256            Json(ApiError {
257                code: "INVALID_REQUEST".to_string(),
258                message: format!("Failed to start task: {}", e),
259                details: None,
260            }),
261        )
262            .into_response(),
263    }
264}
265
266/// Complete the current task
267pub async fn done_task(State(state): State<AppState>) -> impl IntoResponse {
268    let project = state.current_project.read().await;
269    let db_pool = project.db_pool.clone();
270    let project_path = project.project_path.to_string_lossy().to_string();
271    drop(project);
272
273    let task_mgr = TaskManager::with_websocket(
274        &db_pool,
275        std::sync::Arc::new(state.ws_state.clone()),
276        project_path,
277    );
278
279    match task_mgr.done_task().await {
280        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
281        Err(e) if e.to_string().contains("No current task") => (
282            StatusCode::BAD_REQUEST,
283            Json(ApiError {
284                code: "NO_CURRENT_TASK".to_string(),
285                message: "No current task to complete".to_string(),
286                details: None,
287            }),
288        )
289            .into_response(),
290        Err(e) => (
291            StatusCode::BAD_REQUEST,
292            Json(ApiError {
293                code: "INVALID_REQUEST".to_string(),
294                message: format!("Failed to complete task: {}", e),
295                details: None,
296            }),
297        )
298            .into_response(),
299    }
300}
301
302/// Spawn a subtask and switch to it
303/// Note: This creates a subtask of the CURRENT task, not an arbitrary parent
304pub async fn spawn_subtask(
305    State(state): State<AppState>,
306    Path(_parent_id): Path<i64>, // Ignored - uses current task
307    Json(req): Json<SpawnSubtaskRequest>,
308) -> impl IntoResponse {
309    let project = state.current_project.read().await;
310    let db_pool = project.db_pool.clone();
311    let project_path = project.project_path.to_string_lossy().to_string();
312    drop(project);
313
314    let task_mgr = TaskManager::with_websocket(
315        &db_pool,
316        std::sync::Arc::new(state.ws_state.clone()),
317        project_path,
318    );
319
320    // spawn_subtask uses the current task as parent automatically
321    match task_mgr.spawn_subtask(&req.name, req.spec.as_deref()).await {
322        Ok(response) => (StatusCode::CREATED, Json(ApiResponse { data: response })).into_response(),
323        Err(e) if e.to_string().contains("No current task") => (
324            StatusCode::BAD_REQUEST,
325            Json(ApiError {
326                code: "NO_CURRENT_TASK".to_string(),
327                message: "No current task to spawn subtask from".to_string(),
328                details: None,
329            }),
330        )
331            .into_response(),
332        Err(e) => (
333            StatusCode::BAD_REQUEST,
334            Json(ApiError {
335                code: "INVALID_REQUEST".to_string(),
336                message: format!("Failed to spawn subtask: {}", e),
337                details: None,
338            }),
339        )
340            .into_response(),
341    }
342}
343
344/// Get current task
345pub async fn get_current_task(State(state): State<AppState>) -> impl IntoResponse {
346    let db_pool = state.current_project.read().await.db_pool.clone();
347    let workspace_mgr = WorkspaceManager::new(&db_pool);
348
349    match workspace_mgr.get_current_task().await {
350        Ok(response) => {
351            if response.task.is_some() {
352                (StatusCode::OK, Json(ApiResponse { data: response })).into_response()
353            } else {
354                (
355                    StatusCode::OK,
356                    Json(json!({
357                        "data": null,
358                        "message": "No current task"
359                    })),
360                )
361                    .into_response()
362            }
363        },
364        Err(e) => (
365            StatusCode::INTERNAL_SERVER_ERROR,
366            Json(ApiError {
367                code: "DATABASE_ERROR".to_string(),
368                message: format!("Failed to get current task: {}", e),
369                details: None,
370            }),
371        )
372            .into_response(),
373    }
374}
375
376/// Pick next task recommendation
377pub async fn pick_next_task(State(state): State<AppState>) -> impl IntoResponse {
378    let db_pool = state.current_project.read().await.db_pool.clone();
379    let task_mgr = TaskManager::new(&db_pool);
380
381    match task_mgr.pick_next().await {
382        Ok(response) => (StatusCode::OK, Json(ApiResponse { data: response })).into_response(),
383        Err(e) => (
384            StatusCode::INTERNAL_SERVER_ERROR,
385            Json(ApiError {
386                code: "DATABASE_ERROR".to_string(),
387                message: format!("Failed to pick next task: {}", e),
388                details: None,
389            }),
390        )
391            .into_response(),
392    }
393}
394
395/// List events for a task
396pub async fn list_events(
397    State(state): State<AppState>,
398    Path(task_id): Path<i64>,
399    Query(query): Query<EventListQuery>,
400) -> impl IntoResponse {
401    let db_pool = state.current_project.read().await.db_pool.clone();
402    let event_mgr = EventManager::new(&db_pool);
403
404    // Signature: list_events(task_id, limit, log_type, since)
405    match event_mgr
406        .list_events(
407            Some(task_id),
408            query.limit.map(|l| l as i64),
409            query.event_type,
410            query.since,
411        )
412        .await
413    {
414        Ok(events) => (StatusCode::OK, Json(ApiResponse { data: events })).into_response(),
415        Err(e) => (
416            StatusCode::INTERNAL_SERVER_ERROR,
417            Json(ApiError {
418                code: "DATABASE_ERROR".to_string(),
419                message: format!("Failed to list events: {}", e),
420                details: None,
421            }),
422        )
423            .into_response(),
424    }
425}
426
427/// Add an event to a task
428pub async fn create_event(
429    State(state): State<AppState>,
430    Path(task_id): Path<i64>,
431    Json(req): Json<CreateEventRequest>,
432) -> impl IntoResponse {
433    let project = state.current_project.read().await;
434    let db_pool = project.db_pool.clone();
435    let project_path = project.project_path.to_string_lossy().to_string();
436    drop(project);
437
438    let event_mgr = EventManager::with_websocket(
439        &db_pool,
440        std::sync::Arc::new(state.ws_state.clone()),
441        project_path,
442    );
443
444    // Validate event type
445    if !["decision", "blocker", "milestone", "note"].contains(&req.event_type.as_str()) {
446        return (
447            StatusCode::BAD_REQUEST,
448            Json(ApiError {
449                code: "INVALID_REQUEST".to_string(),
450                message: format!("Invalid event type: {}", req.event_type),
451                details: None,
452            }),
453        )
454            .into_response();
455    }
456
457    // add_event signature: (task_id, log_type, discussion_data)
458    match event_mgr
459        .add_event(task_id, &req.event_type, &req.data)
460        .await
461    {
462        Ok(event) => (StatusCode::CREATED, Json(ApiResponse { data: event })).into_response(),
463        Err(e) => (
464            StatusCode::BAD_REQUEST,
465            Json(ApiError {
466                code: "INVALID_REQUEST".to_string(),
467                message: format!("Failed to create event: {}", e),
468                details: None,
469            }),
470        )
471            .into_response(),
472    }
473}
474
475/// Update an event
476pub async fn update_event(
477    State(state): State<AppState>,
478    Path((task_id, event_id)): Path<(i64, i64)>,
479    Json(req): Json<UpdateEventRequest>,
480) -> impl IntoResponse {
481    let project = state.current_project.read().await;
482    let db_pool = project.db_pool.clone();
483    let project_path = project.project_path.to_string_lossy().to_string();
484    drop(project);
485
486    let event_mgr = EventManager::with_websocket(
487        &db_pool,
488        std::sync::Arc::new(state.ws_state.clone()),
489        project_path,
490    );
491
492    // Validate event type if provided
493    if let Some(ref event_type) = req.event_type {
494        if !["decision", "blocker", "milestone", "note"].contains(&event_type.as_str()) {
495            return (
496                StatusCode::BAD_REQUEST,
497                Json(ApiError {
498                    code: "INVALID_REQUEST".to_string(),
499                    message: format!("Invalid event type: {}", event_type),
500                    details: None,
501                }),
502            )
503                .into_response();
504        }
505    }
506
507    match event_mgr
508        .update_event(event_id, req.event_type.as_deref(), req.data.as_deref())
509        .await
510    {
511        Ok(event) => {
512            // Verify the event belongs to the specified task
513            if event.task_id != task_id {
514                return (
515                    StatusCode::BAD_REQUEST,
516                    Json(ApiError {
517                        code: "INVALID_REQUEST".to_string(),
518                        message: format!("Event {} does not belong to task {}", event_id, task_id),
519                        details: None,
520                    }),
521                )
522                    .into_response();
523            }
524            (StatusCode::OK, Json(ApiResponse { data: event })).into_response()
525        },
526        Err(e) => (
527            StatusCode::BAD_REQUEST,
528            Json(ApiError {
529                code: "INVALID_REQUEST".to_string(),
530                message: format!("Failed to update event: {}", e),
531                details: None,
532            }),
533        )
534            .into_response(),
535    }
536}
537
538/// Delete an event
539pub async fn delete_event(
540    State(state): State<AppState>,
541    Path((task_id, event_id)): Path<(i64, i64)>,
542) -> impl IntoResponse {
543    let project = state.current_project.read().await;
544    let db_pool = project.db_pool.clone();
545    let project_path = project.project_path.to_string_lossy().to_string();
546    drop(project);
547
548    let event_mgr = EventManager::with_websocket(
549        &db_pool,
550        std::sync::Arc::new(state.ws_state.clone()),
551        project_path,
552    );
553
554    // First verify the event exists and belongs to the task
555    match sqlx::query_as::<_, crate::db::models::Event>(
556        "SELECT id, task_id, timestamp, log_type, discussion_data FROM events WHERE id = ?",
557    )
558    .bind(event_id)
559    .fetch_optional(&db_pool)
560    .await
561    {
562        Ok(Some(event)) => {
563            if event.task_id != task_id {
564                return (
565                    StatusCode::BAD_REQUEST,
566                    Json(ApiError {
567                        code: "INVALID_REQUEST".to_string(),
568                        message: format!("Event {} does not belong to task {}", event_id, task_id),
569                        details: None,
570                    }),
571                )
572                    .into_response();
573            }
574        },
575        Ok(None) => {
576            return (
577                StatusCode::NOT_FOUND,
578                Json(ApiError {
579                    code: "EVENT_NOT_FOUND".to_string(),
580                    message: format!("Event {} not found", event_id),
581                    details: None,
582                }),
583            )
584                .into_response();
585        },
586        Err(e) => {
587            return (
588                StatusCode::INTERNAL_SERVER_ERROR,
589                Json(ApiError {
590                    code: "DATABASE_ERROR".to_string(),
591                    message: format!("Database error: {}", e),
592                    details: None,
593                }),
594            )
595                .into_response();
596        },
597    }
598
599    match event_mgr.delete_event(event_id).await {
600        Ok(_) => (StatusCode::NO_CONTENT).into_response(),
601        Err(e) => (
602            StatusCode::BAD_REQUEST,
603            Json(ApiError {
604                code: "INVALID_REQUEST".to_string(),
605                message: format!("Failed to delete event: {}", e),
606                details: None,
607            }),
608        )
609            .into_response(),
610    }
611}
612
613/// Unified search across tasks and events
614pub async fn search(
615    State(state): State<AppState>,
616    Query(query): Query<SearchQuery>,
617) -> impl IntoResponse {
618    let db_pool = state.current_project.read().await.db_pool.clone();
619    let search_mgr = SearchManager::new(&db_pool);
620
621    match search_mgr
622        .unified_search(
623            &query.query,
624            query.include_tasks,
625            query.include_events,
626            query.limit.map(|l| l as i64),
627        )
628        .await
629    {
630        Ok(results) => (StatusCode::OK, Json(ApiResponse { data: results })).into_response(),
631        Err(e) => (
632            StatusCode::INTERNAL_SERVER_ERROR,
633            Json(ApiError {
634                code: "DATABASE_ERROR".to_string(),
635                message: format!("Search failed: {}", e),
636                details: None,
637            }),
638        )
639            .into_response(),
640    }
641}
642
643/// List all registered projects (from in-memory state)
644pub async fn list_projects(State(state): State<AppState>) -> impl IntoResponse {
645    // Use the same method as WebSocket init for consistency
646    let projects_info = {
647        let current_project = state.current_project.read().await;
648        state
649            .ws_state
650            .get_online_projects_with_current(
651                &current_project.project_name,
652                &current_project.project_path,
653                &current_project.db_path,
654                state.port,
655            )
656            .await
657    };
658
659    // Convert ProjectInfo to API response format with additional metadata
660    let port = state.port;
661    let pid = std::process::id();
662
663    let projects: Vec<serde_json::Value> = projects_info
664        .iter()
665        .map(|proj| {
666            json!({
667                "name": proj.name,
668                "path": proj.path,
669                "port": port,
670                "pid": pid,
671                "url": format!("http://127.0.0.1:{}", port),
672                "started_at": chrono::Utc::now().to_rfc3339(),
673                "mcp_connected": proj.mcp_connected,
674                "is_online": proj.is_online,  // Now included!
675                "mcp_agent": proj.agent,
676                "mcp_last_seen": if proj.mcp_connected {
677                    Some(chrono::Utc::now().to_rfc3339())
678                } else {
679                    None::<String>
680                },
681            })
682        })
683        .collect();
684
685    (StatusCode::OK, Json(ApiResponse { data: projects })).into_response()
686}
687
688/// Switch to a different project database dynamically
689pub async fn switch_project(
690    State(state): State<AppState>,
691    Json(req): Json<SwitchProjectRequest>,
692) -> impl IntoResponse {
693    use super::server::ProjectContext;
694    use sqlx::SqlitePool;
695    use std::path::PathBuf;
696
697    // Parse and validate project path
698    let project_path = PathBuf::from(&req.project_path);
699
700    if !project_path.exists() {
701        return (
702            StatusCode::NOT_FOUND,
703            Json(ApiError {
704                code: "PROJECT_NOT_FOUND".to_string(),
705                message: format!("Project path does not exist: {}", project_path.display()),
706                details: None,
707            }),
708        )
709            .into_response();
710    }
711
712    // Construct database path
713    let db_path = project_path.join(".intent-engine").join("project.db");
714
715    if !db_path.exists() {
716        return (
717            StatusCode::NOT_FOUND,
718            Json(ApiError {
719                code: "DATABASE_NOT_FOUND".to_string(),
720                message: format!(
721                    "Database not found at {}. Is this an Intent-Engine project?",
722                    db_path.display()
723                ),
724                details: None,
725            }),
726        )
727            .into_response();
728    }
729
730    // Create new database connection
731    let db_url = format!("sqlite://{}", db_path.display());
732    let new_db_pool = match SqlitePool::connect(&db_url).await {
733        Ok(pool) => pool,
734        Err(e) => {
735            return (
736                StatusCode::INTERNAL_SERVER_ERROR,
737                Json(ApiError {
738                    code: "DATABASE_CONNECTION_ERROR".to_string(),
739                    message: format!("Failed to connect to database: {}", e),
740                    details: None,
741                }),
742            )
743                .into_response();
744        },
745    };
746
747    // Extract project name
748    let project_name = project_path
749        .file_name()
750        .and_then(|n| n.to_str())
751        .unwrap_or("unknown")
752        .to_string();
753
754    // Create new project context
755    let new_context = ProjectContext {
756        db_pool: new_db_pool,
757        project_name: project_name.clone(),
758        project_path: project_path.clone(),
759        db_path: db_path.clone(),
760    };
761
762    // Update the current project (write lock)
763    {
764        let mut current = state.current_project.write().await;
765        *current = new_context;
766    }
767
768    tracing::info!(
769        "Switched to project: {} at {}",
770        project_name,
771        project_path.display()
772    );
773
774    (
775        StatusCode::OK,
776        Json(ApiResponse {
777            data: json!({
778                "success": true,
779                "project_name": project_name,
780                "project_path": project_path.display().to_string(),
781                "database": db_path.display().to_string(),
782            }),
783        }),
784    )
785        .into_response()
786}