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
14pub 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 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
48pub 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
76pub 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 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 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 }
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
123pub 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 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 match task_mgr
170 .update_task(
171 id,
172 req.name.as_deref(),
173 req.spec.as_deref(),
174 None, req.status.as_deref(),
176 None, 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
194pub 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
230pub 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
266pub 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
302pub async fn spawn_subtask(
305 State(state): State<AppState>,
306 Path(_parent_id): Path<i64>, 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 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
344pub 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
376pub 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
395pub 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 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
427pub 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 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 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
475pub 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 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 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
538pub 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 match sqlx::query_as::<_, crate::db::models::Event>(crate::sql_constants::SELECT_EVENT_BY_ID)
556 .bind(event_id)
557 .fetch_optional(&db_pool)
558 .await
559 {
560 Ok(Some(event)) => {
561 if event.task_id != task_id {
562 return (
563 StatusCode::BAD_REQUEST,
564 Json(ApiError {
565 code: "INVALID_REQUEST".to_string(),
566 message: format!("Event {} does not belong to task {}", event_id, task_id),
567 details: None,
568 }),
569 )
570 .into_response();
571 }
572 },
573 Ok(None) => {
574 return (
575 StatusCode::NOT_FOUND,
576 Json(ApiError {
577 code: "EVENT_NOT_FOUND".to_string(),
578 message: format!("Event {} not found", event_id),
579 details: None,
580 }),
581 )
582 .into_response();
583 },
584 Err(e) => {
585 return (
586 StatusCode::INTERNAL_SERVER_ERROR,
587 Json(ApiError {
588 code: "DATABASE_ERROR".to_string(),
589 message: format!("Database error: {}", e),
590 details: None,
591 }),
592 )
593 .into_response();
594 },
595 }
596
597 match event_mgr.delete_event(event_id).await {
598 Ok(_) => (StatusCode::NO_CONTENT).into_response(),
599 Err(e) => (
600 StatusCode::BAD_REQUEST,
601 Json(ApiError {
602 code: "INVALID_REQUEST".to_string(),
603 message: format!("Failed to delete event: {}", e),
604 details: None,
605 }),
606 )
607 .into_response(),
608 }
609}
610
611pub async fn search(
613 State(state): State<AppState>,
614 Query(query): Query<SearchQuery>,
615) -> impl IntoResponse {
616 let db_pool = state.current_project.read().await.db_pool.clone();
617 let search_mgr = SearchManager::new(&db_pool);
618
619 match search_mgr
620 .unified_search(
621 &query.query,
622 query.include_tasks,
623 query.include_events,
624 query.limit.map(|l| l as i64),
625 )
626 .await
627 {
628 Ok(results) => (StatusCode::OK, Json(ApiResponse { data: results })).into_response(),
629 Err(e) => (
630 StatusCode::INTERNAL_SERVER_ERROR,
631 Json(ApiError {
632 code: "DATABASE_ERROR".to_string(),
633 message: format!("Search failed: {}", e),
634 details: None,
635 }),
636 )
637 .into_response(),
638 }
639}
640
641pub async fn list_projects(State(state): State<AppState>) -> impl IntoResponse {
643 let projects_info = {
645 let current_project = state.current_project.read().await;
646 state
647 .ws_state
648 .get_online_projects_with_current(
649 ¤t_project.project_name,
650 ¤t_project.project_path,
651 ¤t_project.db_path,
652 &state.host_project,
653 state.port,
654 )
655 .await
656 };
657
658 let port = state.port;
660 let pid = std::process::id();
661
662 let projects: Vec<serde_json::Value> = projects_info
663 .iter()
664 .map(|proj| {
665 json!({
666 "name": proj.name,
667 "path": proj.path,
668 "port": port,
669 "pid": pid,
670 "url": format!("http://127.0.0.1:{}", port),
671 "started_at": chrono::Utc::now().to_rfc3339(),
672 "mcp_connected": proj.mcp_connected,
673 "is_online": proj.is_online, "mcp_agent": proj.agent,
675 "mcp_last_seen": if proj.mcp_connected {
676 Some(chrono::Utc::now().to_rfc3339())
677 } else {
678 None::<String>
679 },
680 })
681 })
682 .collect();
683
684 (StatusCode::OK, Json(ApiResponse { data: projects })).into_response()
685}
686
687pub async fn switch_project(
689 State(state): State<AppState>,
690 Json(req): Json<SwitchProjectRequest>,
691) -> impl IntoResponse {
692 use super::server::ProjectContext;
693 use std::path::PathBuf;
694
695 let project_path = PathBuf::from(&req.project_path);
697
698 if !project_path.exists() {
699 return (
700 StatusCode::NOT_FOUND,
701 Json(ApiError {
702 code: "PROJECT_NOT_FOUND".to_string(),
703 message: format!("Project path does not exist: {}", project_path.display()),
704 details: None,
705 }),
706 )
707 .into_response();
708 }
709
710 let db_path = project_path.join(".intent-engine").join("project.db");
712
713 if !db_path.exists() {
714 return (
715 StatusCode::NOT_FOUND,
716 Json(ApiError {
717 code: "DATABASE_NOT_FOUND".to_string(),
718 message: format!(
719 "Database not found at {}. Is this an Intent-Engine project?",
720 db_path.display()
721 ),
722 details: None,
723 }),
724 )
725 .into_response();
726 }
727
728 let new_db_pool = match crate::db::create_pool(&db_path).await {
731 Ok(pool) => pool,
732 Err(e) => {
733 return (
734 StatusCode::INTERNAL_SERVER_ERROR,
735 Json(ApiError {
736 code: "DATABASE_CONNECTION_ERROR".to_string(),
737 message: format!("Failed to connect to database: {}", e),
738 details: None,
739 }),
740 )
741 .into_response();
742 },
743 };
744
745 let project_name = project_path
747 .file_name()
748 .and_then(|n| n.to_str())
749 .unwrap_or("unknown")
750 .to_string();
751
752 let new_context = ProjectContext {
754 db_pool: new_db_pool,
755 project_name: project_name.clone(),
756 project_path: project_path.clone(),
757 db_path: db_path.clone(),
758 };
759
760 {
762 let mut current = state.current_project.write().await;
763 *current = new_context;
764 }
765
766 tracing::info!(
767 "Switched to project: {} at {}",
768 project_name,
769 project_path.display()
770 );
771
772 (
773 StatusCode::OK,
774 Json(ApiResponse {
775 data: json!({
776 "success": true,
777 "project_name": project_name,
778 "project_path": project_path.display().to_string(),
779 "database": db_path.display().to_string(),
780 }),
781 }),
782 )
783 .into_response()
784}