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