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 db::models::TaskSortBy, events::EventManager, search::SearchManager, tasks::TaskManager,
12 workspace::WorkspaceManager,
13};
14
15pub async fn list_tasks(
17 State(state): State<AppState>,
18 Query(query): Query<TaskListQuery>,
19) -> impl IntoResponse {
20 let db_pool = state.current_project.read().await.db_pool.clone();
21 let task_mgr = TaskManager::new(&db_pool);
22
23 let parent_filter = query.parent.as_deref().map(|p| {
25 if p == "null" {
26 None
27 } else {
28 p.parse::<i64>().ok()
29 }
30 });
31
32 let sort_by = match query.sort_by.as_deref() {
34 Some("id") => Some(TaskSortBy::Id),
35 Some("priority") => Some(TaskSortBy::Priority),
36 Some("time") => Some(TaskSortBy::Time),
37 Some("focus") => Some(TaskSortBy::FocusAware),
38 _ => Some(TaskSortBy::FocusAware), };
40
41 match task_mgr
42 .find_tasks(
43 query.status.as_deref(),
44 parent_filter,
45 sort_by,
46 query.limit,
47 query.offset,
48 )
49 .await
50 {
51 Ok(result) => (StatusCode::OK, Json(ApiResponse { data: result })).into_response(),
52 Err(e) => {
53 tracing::error!("Failed to fetch tasks: {}", e);
54 (
55 StatusCode::INTERNAL_SERVER_ERROR,
56 Json(ApiError {
57 code: "DATABASE_ERROR".to_string(),
58 message: format!("Failed to list tasks: {}", e),
59 details: None,
60 }),
61 )
62 .into_response()
63 },
64 }
65}
66
67pub async fn get_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
69 let db_pool = state.current_project.read().await.db_pool.clone();
70 let task_mgr = TaskManager::new(&db_pool);
71
72 match task_mgr.get_task(id).await {
73 Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
74 Err(e) if e.to_string().contains("not found") => (
75 StatusCode::NOT_FOUND,
76 Json(ApiError {
77 code: "TASK_NOT_FOUND".to_string(),
78 message: format!("Task {} not found", id),
79 details: None,
80 }),
81 )
82 .into_response(),
83 Err(e) => (
84 StatusCode::INTERNAL_SERVER_ERROR,
85 Json(ApiError {
86 code: "DATABASE_ERROR".to_string(),
87 message: format!("Failed to get task: {}", e),
88 details: None,
89 }),
90 )
91 .into_response(),
92 }
93}
94
95pub async fn create_task(
97 State(state): State<AppState>,
98 Json(req): Json<CreateTaskRequest>,
99) -> impl IntoResponse {
100 let project = state.current_project.read().await;
101 let db_pool = project.db_pool.clone();
102 let project_path = project.project_path.to_string_lossy().to_string();
103 drop(project);
104
105 let task_mgr = TaskManager::with_websocket(
106 &db_pool,
107 std::sync::Arc::new(state.ws_state.clone()),
108 project_path,
109 );
110
111 let result = task_mgr
114 .add_task(&req.name, req.spec.as_deref(), req.parent_id, None)
115 .await;
116
117 match result {
118 Ok(mut task) => {
119 if let Some(priority) = req.priority {
121 if let Ok(updated_task) = task_mgr
122 .update_task(task.id, None, None, None, None, None, Some(priority))
123 .await
124 {
125 task = updated_task;
126 }
127 }
129 (StatusCode::CREATED, Json(ApiResponse { data: task })).into_response()
130 },
131 Err(e) => (
132 StatusCode::BAD_REQUEST,
133 Json(ApiError {
134 code: "INVALID_REQUEST".to_string(),
135 message: format!("Failed to create task: {}", e),
136 details: None,
137 }),
138 )
139 .into_response(),
140 }
141}
142
143pub async fn update_task(
145 State(state): State<AppState>,
146 Path(id): Path<i64>,
147 Json(req): Json<UpdateTaskRequest>,
148) -> impl IntoResponse {
149 let project = state.current_project.read().await;
150 let db_pool = project.db_pool.clone();
151 let project_path = project.project_path.to_string_lossy().to_string();
152 drop(project);
153
154 let task_mgr = TaskManager::with_websocket(
155 &db_pool,
156 std::sync::Arc::new(state.ws_state.clone()),
157 project_path,
158 );
159
160 match task_mgr.get_task(id).await {
162 Err(e) if e.to_string().contains("not found") => {
163 return (
164 StatusCode::NOT_FOUND,
165 Json(ApiError {
166 code: "TASK_NOT_FOUND".to_string(),
167 message: format!("Task {} not found", id),
168 details: None,
169 }),
170 )
171 .into_response()
172 },
173 Err(e) => {
174 return (
175 StatusCode::INTERNAL_SERVER_ERROR,
176 Json(ApiError {
177 code: "DATABASE_ERROR".to_string(),
178 message: format!("Database error: {}", e),
179 details: None,
180 }),
181 )
182 .into_response()
183 },
184 Ok(_) => {},
185 }
186
187 match task_mgr
190 .update_task(
191 id,
192 req.name.as_deref(),
193 req.spec.as_deref(),
194 None, req.status.as_deref(),
196 None, req.priority,
198 )
199 .await
200 {
201 Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
202 Err(e) => (
203 StatusCode::BAD_REQUEST,
204 Json(ApiError {
205 code: "INVALID_REQUEST".to_string(),
206 message: format!("Failed to update task: {}", e),
207 details: None,
208 }),
209 )
210 .into_response(),
211 }
212}
213
214pub async fn delete_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
216 let project = state.current_project.read().await;
217 let db_pool = project.db_pool.clone();
218 let project_path = project.project_path.to_string_lossy().to_string();
219 drop(project);
220
221 let task_mgr = TaskManager::with_websocket(
222 &db_pool,
223 std::sync::Arc::new(state.ws_state.clone()),
224 project_path,
225 );
226
227 match task_mgr.delete_task(id).await {
228 Ok(_) => (StatusCode::NO_CONTENT).into_response(),
229 Err(e) if e.to_string().contains("not found") => (
230 StatusCode::NOT_FOUND,
231 Json(ApiError {
232 code: "TASK_NOT_FOUND".to_string(),
233 message: format!("Task {} not found", id),
234 details: None,
235 }),
236 )
237 .into_response(),
238 Err(e) => (
239 StatusCode::BAD_REQUEST,
240 Json(ApiError {
241 code: "INVALID_REQUEST".to_string(),
242 message: format!("Failed to delete task: {}", e),
243 details: None,
244 }),
245 )
246 .into_response(),
247 }
248}
249
250pub async fn start_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
252 let project = state.current_project.read().await;
253 let db_pool = project.db_pool.clone();
254 let project_path = project.project_path.to_string_lossy().to_string();
255 drop(project);
256
257 let task_mgr = TaskManager::with_websocket(
258 &db_pool,
259 std::sync::Arc::new(state.ws_state.clone()),
260 project_path,
261 );
262
263 match task_mgr.start_task(id, false).await {
264 Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
265 Err(e) if e.to_string().contains("not found") => (
266 StatusCode::NOT_FOUND,
267 Json(ApiError {
268 code: "TASK_NOT_FOUND".to_string(),
269 message: format!("Task {} not found", id),
270 details: None,
271 }),
272 )
273 .into_response(),
274 Err(e) => (
275 StatusCode::BAD_REQUEST,
276 Json(ApiError {
277 code: "INVALID_REQUEST".to_string(),
278 message: format!("Failed to start task: {}", e),
279 details: None,
280 }),
281 )
282 .into_response(),
283 }
284}
285
286pub async fn done_task(State(state): State<AppState>) -> impl IntoResponse {
288 let project = state.current_project.read().await;
289 let db_pool = project.db_pool.clone();
290 let project_path = project.project_path.to_string_lossy().to_string();
291 drop(project);
292
293 let task_mgr = TaskManager::with_websocket(
294 &db_pool,
295 std::sync::Arc::new(state.ws_state.clone()),
296 project_path,
297 );
298
299 match task_mgr.done_task(false).await {
301 Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
302 Err(e) if e.to_string().contains("No current task") => (
303 StatusCode::BAD_REQUEST,
304 Json(ApiError {
305 code: "NO_CURRENT_TASK".to_string(),
306 message: "No current task to complete".to_string(),
307 details: None,
308 }),
309 )
310 .into_response(),
311 Err(e) => (
312 StatusCode::BAD_REQUEST,
313 Json(ApiError {
314 code: "INVALID_REQUEST".to_string(),
315 message: format!("Failed to complete task: {}", e),
316 details: None,
317 }),
318 )
319 .into_response(),
320 }
321}
322
323pub async fn spawn_subtask(
326 State(state): State<AppState>,
327 Path(_parent_id): Path<i64>, Json(req): Json<SpawnSubtaskRequest>,
329) -> impl IntoResponse {
330 let project = state.current_project.read().await;
331 let db_pool = project.db_pool.clone();
332 let project_path = project.project_path.to_string_lossy().to_string();
333 drop(project);
334
335 let task_mgr = TaskManager::with_websocket(
336 &db_pool,
337 std::sync::Arc::new(state.ws_state.clone()),
338 project_path,
339 );
340
341 match task_mgr.spawn_subtask(&req.name, req.spec.as_deref()).await {
343 Ok(response) => (StatusCode::CREATED, Json(ApiResponse { data: response })).into_response(),
344 Err(e) if e.to_string().contains("No current task") => (
345 StatusCode::BAD_REQUEST,
346 Json(ApiError {
347 code: "NO_CURRENT_TASK".to_string(),
348 message: "No current task to spawn subtask from".to_string(),
349 details: None,
350 }),
351 )
352 .into_response(),
353 Err(e) => (
354 StatusCode::BAD_REQUEST,
355 Json(ApiError {
356 code: "INVALID_REQUEST".to_string(),
357 message: format!("Failed to spawn subtask: {}", e),
358 details: None,
359 }),
360 )
361 .into_response(),
362 }
363}
364
365pub async fn get_current_task(State(state): State<AppState>) -> impl IntoResponse {
367 let db_pool = state.current_project.read().await.db_pool.clone();
368 let workspace_mgr = WorkspaceManager::new(&db_pool);
369
370 match workspace_mgr.get_current_task().await {
371 Ok(response) => {
372 if response.task.is_some() {
373 (StatusCode::OK, Json(ApiResponse { data: response })).into_response()
374 } else {
375 (
376 StatusCode::OK,
377 Json(json!({
378 "data": null,
379 "message": "No current task"
380 })),
381 )
382 .into_response()
383 }
384 },
385 Err(e) => (
386 StatusCode::INTERNAL_SERVER_ERROR,
387 Json(ApiError {
388 code: "DATABASE_ERROR".to_string(),
389 message: format!("Failed to get current task: {}", e),
390 details: None,
391 }),
392 )
393 .into_response(),
394 }
395}
396
397pub async fn pick_next_task(State(state): State<AppState>) -> impl IntoResponse {
399 let db_pool = state.current_project.read().await.db_pool.clone();
400 let task_mgr = TaskManager::new(&db_pool);
401
402 match task_mgr.pick_next().await {
403 Ok(response) => (StatusCode::OK, Json(ApiResponse { data: response })).into_response(),
404 Err(e) => (
405 StatusCode::INTERNAL_SERVER_ERROR,
406 Json(ApiError {
407 code: "DATABASE_ERROR".to_string(),
408 message: format!("Failed to pick next task: {}", e),
409 details: None,
410 }),
411 )
412 .into_response(),
413 }
414}
415
416pub async fn list_events(
418 State(state): State<AppState>,
419 Path(task_id): Path<i64>,
420 Query(query): Query<EventListQuery>,
421) -> impl IntoResponse {
422 let db_pool = state.current_project.read().await.db_pool.clone();
423 let event_mgr = EventManager::new(&db_pool);
424
425 match event_mgr
427 .list_events(
428 Some(task_id),
429 query.limit.map(|l| l as i64),
430 query.event_type,
431 query.since,
432 )
433 .await
434 {
435 Ok(events) => (StatusCode::OK, Json(ApiResponse { data: events })).into_response(),
436 Err(e) => (
437 StatusCode::INTERNAL_SERVER_ERROR,
438 Json(ApiError {
439 code: "DATABASE_ERROR".to_string(),
440 message: format!("Failed to list events: {}", e),
441 details: None,
442 }),
443 )
444 .into_response(),
445 }
446}
447
448pub async fn create_event(
450 State(state): State<AppState>,
451 Path(task_id): Path<i64>,
452 Json(req): Json<CreateEventRequest>,
453) -> impl IntoResponse {
454 let project = state.current_project.read().await;
455 let db_pool = project.db_pool.clone();
456 let project_path = project.project_path.to_string_lossy().to_string();
457 drop(project);
458
459 let event_mgr = EventManager::with_websocket(
460 &db_pool,
461 std::sync::Arc::new(state.ws_state.clone()),
462 project_path,
463 );
464
465 if !["decision", "blocker", "milestone", "note"].contains(&req.event_type.as_str()) {
467 return (
468 StatusCode::BAD_REQUEST,
469 Json(ApiError {
470 code: "INVALID_REQUEST".to_string(),
471 message: format!("Invalid event type: {}", req.event_type),
472 details: None,
473 }),
474 )
475 .into_response();
476 }
477
478 match event_mgr
480 .add_event(task_id, &req.event_type, &req.data)
481 .await
482 {
483 Ok(event) => (StatusCode::CREATED, Json(ApiResponse { data: event })).into_response(),
484 Err(e) => (
485 StatusCode::BAD_REQUEST,
486 Json(ApiError {
487 code: "INVALID_REQUEST".to_string(),
488 message: format!("Failed to create event: {}", e),
489 details: None,
490 }),
491 )
492 .into_response(),
493 }
494}
495
496pub async fn update_event(
498 State(state): State<AppState>,
499 Path((task_id, event_id)): Path<(i64, i64)>,
500 Json(req): Json<UpdateEventRequest>,
501) -> impl IntoResponse {
502 let project = state.current_project.read().await;
503 let db_pool = project.db_pool.clone();
504 let project_path = project.project_path.to_string_lossy().to_string();
505 drop(project);
506
507 let event_mgr = EventManager::with_websocket(
508 &db_pool,
509 std::sync::Arc::new(state.ws_state.clone()),
510 project_path,
511 );
512
513 if let Some(ref event_type) = req.event_type {
515 if !["decision", "blocker", "milestone", "note"].contains(&event_type.as_str()) {
516 return (
517 StatusCode::BAD_REQUEST,
518 Json(ApiError {
519 code: "INVALID_REQUEST".to_string(),
520 message: format!("Invalid event type: {}", event_type),
521 details: None,
522 }),
523 )
524 .into_response();
525 }
526 }
527
528 match event_mgr
529 .update_event(event_id, req.event_type.as_deref(), req.data.as_deref())
530 .await
531 {
532 Ok(event) => {
533 if event.task_id != task_id {
535 return (
536 StatusCode::BAD_REQUEST,
537 Json(ApiError {
538 code: "INVALID_REQUEST".to_string(),
539 message: format!("Event {} does not belong to task {}", event_id, task_id),
540 details: None,
541 }),
542 )
543 .into_response();
544 }
545 (StatusCode::OK, Json(ApiResponse { data: event })).into_response()
546 },
547 Err(e) => (
548 StatusCode::BAD_REQUEST,
549 Json(ApiError {
550 code: "INVALID_REQUEST".to_string(),
551 message: format!("Failed to update event: {}", e),
552 details: None,
553 }),
554 )
555 .into_response(),
556 }
557}
558
559pub async fn delete_event(
561 State(state): State<AppState>,
562 Path((task_id, event_id)): Path<(i64, i64)>,
563) -> impl IntoResponse {
564 let project = state.current_project.read().await;
565 let db_pool = project.db_pool.clone();
566 let project_path = project.project_path.to_string_lossy().to_string();
567 drop(project);
568
569 let event_mgr = EventManager::with_websocket(
570 &db_pool,
571 std::sync::Arc::new(state.ws_state.clone()),
572 project_path,
573 );
574
575 match sqlx::query_as::<_, crate::db::models::Event>(crate::sql_constants::SELECT_EVENT_BY_ID)
577 .bind(event_id)
578 .fetch_optional(&db_pool)
579 .await
580 {
581 Ok(Some(event)) => {
582 if event.task_id != task_id {
583 return (
584 StatusCode::BAD_REQUEST,
585 Json(ApiError {
586 code: "INVALID_REQUEST".to_string(),
587 message: format!("Event {} does not belong to task {}", event_id, task_id),
588 details: None,
589 }),
590 )
591 .into_response();
592 }
593 },
594 Ok(None) => {
595 return (
596 StatusCode::NOT_FOUND,
597 Json(ApiError {
598 code: "EVENT_NOT_FOUND".to_string(),
599 message: format!("Event {} not found", event_id),
600 details: None,
601 }),
602 )
603 .into_response();
604 },
605 Err(e) => {
606 return (
607 StatusCode::INTERNAL_SERVER_ERROR,
608 Json(ApiError {
609 code: "DATABASE_ERROR".to_string(),
610 message: format!("Database error: {}", e),
611 details: None,
612 }),
613 )
614 .into_response();
615 },
616 }
617
618 match event_mgr.delete_event(event_id).await {
619 Ok(_) => (StatusCode::NO_CONTENT).into_response(),
620 Err(e) => (
621 StatusCode::BAD_REQUEST,
622 Json(ApiError {
623 code: "INVALID_REQUEST".to_string(),
624 message: format!("Failed to delete event: {}", e),
625 details: None,
626 }),
627 )
628 .into_response(),
629 }
630}
631
632pub async fn search(
634 State(state): State<AppState>,
635 Query(query): Query<SearchQuery>,
636) -> impl IntoResponse {
637 let db_pool = state.current_project.read().await.db_pool.clone();
638 let search_mgr = SearchManager::new(&db_pool);
639
640 match search_mgr
641 .search(
642 &query.query,
643 query.include_tasks,
644 query.include_events,
645 query.limit,
646 query.offset,
647 false,
648 )
649 .await
650 {
651 Ok(results) => (StatusCode::OK, Json(ApiResponse { data: results })).into_response(),
652 Err(e) => (
653 StatusCode::INTERNAL_SERVER_ERROR,
654 Json(ApiError {
655 code: "DATABASE_ERROR".to_string(),
656 message: format!("Search failed: {}", e),
657 details: None,
658 }),
659 )
660 .into_response(),
661 }
662}
663
664pub async fn list_projects(State(state): State<AppState>) -> impl IntoResponse {
666 let projects_info = {
668 let current_project = state.current_project.read().await;
669 state
670 .ws_state
671 .get_online_projects_with_current(
672 ¤t_project.project_name,
673 ¤t_project.project_path,
674 ¤t_project.db_path,
675 &state.host_project,
676 state.port,
677 )
678 .await
679 };
680
681 let port = state.port;
683 let pid = std::process::id();
684
685 let projects: Vec<serde_json::Value> = projects_info
686 .iter()
687 .map(|proj| {
688 json!({
689 "name": proj.name,
690 "path": proj.path,
691 "port": port,
692 "pid": pid,
693 "url": format!("http://127.0.0.1:{}", port),
694 "started_at": chrono::Utc::now().to_rfc3339(),
695 "mcp_connected": proj.mcp_connected,
696 "is_online": proj.is_online, "mcp_agent": proj.agent,
698 "mcp_last_seen": if proj.mcp_connected {
699 Some(chrono::Utc::now().to_rfc3339())
700 } else {
701 None::<String>
702 },
703 })
704 })
705 .collect();
706
707 (StatusCode::OK, Json(ApiResponse { data: projects })).into_response()
708}
709
710pub async fn switch_project(
712 State(state): State<AppState>,
713 Json(req): Json<SwitchProjectRequest>,
714) -> impl IntoResponse {
715 use super::server::ProjectContext;
716 use std::path::PathBuf;
717
718 let project_path = PathBuf::from(&req.project_path);
720
721 if !project_path.exists() {
722 return (
723 StatusCode::NOT_FOUND,
724 Json(ApiError {
725 code: "PROJECT_NOT_FOUND".to_string(),
726 message: format!("Project path does not exist: {}", project_path.display()),
727 details: None,
728 }),
729 )
730 .into_response();
731 }
732
733 let db_path = project_path.join(".intent-engine").join("project.db");
735
736 if !db_path.exists() {
737 return (
738 StatusCode::NOT_FOUND,
739 Json(ApiError {
740 code: "DATABASE_NOT_FOUND".to_string(),
741 message: format!(
742 "Database not found at {}. Is this an Intent-Engine project?",
743 db_path.display()
744 ),
745 details: None,
746 }),
747 )
748 .into_response();
749 }
750
751 let new_db_pool = match crate::db::create_pool(&db_path).await {
754 Ok(pool) => pool,
755 Err(e) => {
756 return (
757 StatusCode::INTERNAL_SERVER_ERROR,
758 Json(ApiError {
759 code: "DATABASE_CONNECTION_ERROR".to_string(),
760 message: format!("Failed to connect to database: {}", e),
761 details: None,
762 }),
763 )
764 .into_response();
765 },
766 };
767
768 let project_name = project_path
770 .file_name()
771 .and_then(|n| n.to_str())
772 .unwrap_or("unknown")
773 .to_string();
774
775 let new_context = ProjectContext {
777 db_pool: new_db_pool,
778 project_name: project_name.clone(),
779 project_path: project_path.clone(),
780 db_path: db_path.clone(),
781 };
782
783 {
785 let mut current = state.current_project.write().await;
786 *current = new_context;
787 }
788
789 tracing::info!(
790 "Switched to project: {} at {}",
791 project_name,
792 project_path.display()
793 );
794
795 (
796 StatusCode::OK,
797 Json(ApiResponse {
798 data: json!({
799 "success": true,
800 "project_name": project_name,
801 "project_path": project_path.display().to_string(),
802 "database": db_path.display().to_string(),
803 }),
804 }),
805 )
806 .into_response()
807}
808
809pub async fn get_task_context(
811 State(state): State<AppState>,
812 Path(id): Path<i64>,
813) -> impl IntoResponse {
814 let db_pool = state.current_project.read().await.db_pool.clone();
815 let task_mgr = TaskManager::new(&db_pool);
816
817 match task_mgr.get_task_context(id).await {
818 Ok(context) => (StatusCode::OK, Json(ApiResponse { data: context })).into_response(),
819 Err(e) if e.to_string().contains("not found") => (
820 StatusCode::NOT_FOUND,
821 Json(ApiError {
822 code: "TASK_NOT_FOUND".to_string(),
823 message: format!("Task {} not found", id),
824 details: None,
825 }),
826 )
827 .into_response(),
828 Err(e) => (
829 StatusCode::INTERNAL_SERVER_ERROR,
830 Json(ApiError {
831 code: "DATABASE_ERROR".to_string(),
832 message: format!("Failed to get task context: {}", e),
833 details: None,
834 }),
835 )
836 .into_response(),
837 }
838}