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>(
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
613pub 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
643pub async fn list_projects(State(state): State<AppState>) -> impl IntoResponse {
645 let projects_info = {
647 let current_project = state.current_project.read().await;
648 state
649 .ws_state
650 .get_online_projects_with_current(
651 ¤t_project.project_name,
652 ¤t_project.project_path,
653 ¤t_project.db_path,
654 state.port,
655 )
656 .await
657 };
658
659 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, "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
688pub 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 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 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 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 let project_name = project_path
749 .file_name()
750 .and_then(|n| n.to_str())
751 .unwrap_or("unknown")
752 .to_string();
753
754 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 {
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}