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 db_pool = state.current_project.read().await.db_pool.clone();
82 let task_mgr = TaskManager::new(&db_pool);
83
84 let result = task_mgr
86 .add_task(&req.name, req.spec.as_deref(), req.parent_id)
87 .await;
88
89 match result {
90 Ok(mut task) => {
91 if let Some(priority) = req.priority {
93 if let Ok(updated_task) = task_mgr
94 .update_task(task.id, None, None, None, None, None, Some(priority))
95 .await
96 {
97 task = updated_task;
98 }
99 }
101 (StatusCode::CREATED, Json(ApiResponse { data: task })).into_response()
102 },
103 Err(e) => (
104 StatusCode::BAD_REQUEST,
105 Json(ApiError {
106 code: "INVALID_REQUEST".to_string(),
107 message: format!("Failed to create task: {}", e),
108 details: None,
109 }),
110 )
111 .into_response(),
112 }
113}
114
115pub async fn update_task(
117 State(state): State<AppState>,
118 Path(id): Path<i64>,
119 Json(req): Json<UpdateTaskRequest>,
120) -> impl IntoResponse {
121 let db_pool = state.current_project.read().await.db_pool.clone();
122 let task_mgr = TaskManager::new(&db_pool);
123
124 match task_mgr.get_task(id).await {
126 Err(e) if e.to_string().contains("not found") => {
127 return (
128 StatusCode::NOT_FOUND,
129 Json(ApiError {
130 code: "TASK_NOT_FOUND".to_string(),
131 message: format!("Task {} not found", id),
132 details: None,
133 }),
134 )
135 .into_response()
136 },
137 Err(e) => {
138 return (
139 StatusCode::INTERNAL_SERVER_ERROR,
140 Json(ApiError {
141 code: "DATABASE_ERROR".to_string(),
142 message: format!("Database error: {}", e),
143 details: None,
144 }),
145 )
146 .into_response()
147 },
148 Ok(_) => {},
149 }
150
151 match task_mgr
154 .update_task(
155 id,
156 req.name.as_deref(),
157 req.spec.as_deref(),
158 None, req.status.as_deref(),
160 None, req.priority,
162 )
163 .await
164 {
165 Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
166 Err(e) => (
167 StatusCode::BAD_REQUEST,
168 Json(ApiError {
169 code: "INVALID_REQUEST".to_string(),
170 message: format!("Failed to update task: {}", e),
171 details: None,
172 }),
173 )
174 .into_response(),
175 }
176}
177
178pub async fn delete_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
180 let db_pool = state.current_project.read().await.db_pool.clone();
181 let task_mgr = TaskManager::new(&db_pool);
182
183 match task_mgr.delete_task(id).await {
184 Ok(_) => (StatusCode::NO_CONTENT).into_response(),
185 Err(e) if e.to_string().contains("not found") => (
186 StatusCode::NOT_FOUND,
187 Json(ApiError {
188 code: "TASK_NOT_FOUND".to_string(),
189 message: format!("Task {} not found", id),
190 details: None,
191 }),
192 )
193 .into_response(),
194 Err(e) => (
195 StatusCode::BAD_REQUEST,
196 Json(ApiError {
197 code: "INVALID_REQUEST".to_string(),
198 message: format!("Failed to delete task: {}", e),
199 details: None,
200 }),
201 )
202 .into_response(),
203 }
204}
205
206pub async fn start_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
208 let db_pool = state.current_project.read().await.db_pool.clone();
209 let task_mgr = TaskManager::new(&db_pool);
210
211 match task_mgr.start_task(id, false).await {
212 Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
213 Err(e) if e.to_string().contains("not found") => (
214 StatusCode::NOT_FOUND,
215 Json(ApiError {
216 code: "TASK_NOT_FOUND".to_string(),
217 message: format!("Task {} not found", id),
218 details: None,
219 }),
220 )
221 .into_response(),
222 Err(e) => (
223 StatusCode::BAD_REQUEST,
224 Json(ApiError {
225 code: "INVALID_REQUEST".to_string(),
226 message: format!("Failed to start task: {}", e),
227 details: None,
228 }),
229 )
230 .into_response(),
231 }
232}
233
234pub async fn done_task(State(state): State<AppState>) -> impl IntoResponse {
236 let db_pool = state.current_project.read().await.db_pool.clone();
237 let task_mgr = TaskManager::new(&db_pool);
238
239 match task_mgr.done_task().await {
240 Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
241 Err(e) if e.to_string().contains("No current task") => (
242 StatusCode::BAD_REQUEST,
243 Json(ApiError {
244 code: "NO_CURRENT_TASK".to_string(),
245 message: "No current task to complete".to_string(),
246 details: None,
247 }),
248 )
249 .into_response(),
250 Err(e) => (
251 StatusCode::BAD_REQUEST,
252 Json(ApiError {
253 code: "INVALID_REQUEST".to_string(),
254 message: format!("Failed to complete task: {}", e),
255 details: None,
256 }),
257 )
258 .into_response(),
259 }
260}
261
262pub async fn switch_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
264 let db_pool = state.current_project.read().await.db_pool.clone();
265 let task_mgr = TaskManager::new(&db_pool);
266
267 match task_mgr.switch_to_task(id).await {
268 Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
269 Err(e) if e.to_string().contains("not found") => (
270 StatusCode::NOT_FOUND,
271 Json(ApiError {
272 code: "TASK_NOT_FOUND".to_string(),
273 message: format!("Task {} not found", id),
274 details: None,
275 }),
276 )
277 .into_response(),
278 Err(e) => (
279 StatusCode::BAD_REQUEST,
280 Json(ApiError {
281 code: "INVALID_REQUEST".to_string(),
282 message: format!("Failed to switch task: {}", e),
283 details: None,
284 }),
285 )
286 .into_response(),
287 }
288}
289
290pub async fn spawn_subtask(
293 State(state): State<AppState>,
294 Path(_parent_id): Path<i64>, Json(req): Json<SpawnSubtaskRequest>,
296) -> impl IntoResponse {
297 let db_pool = state.current_project.read().await.db_pool.clone();
298 let task_mgr = TaskManager::new(&db_pool);
299
300 match task_mgr.spawn_subtask(&req.name, req.spec.as_deref()).await {
302 Ok(response) => (StatusCode::CREATED, Json(ApiResponse { data: response })).into_response(),
303 Err(e) if e.to_string().contains("No current task") => (
304 StatusCode::BAD_REQUEST,
305 Json(ApiError {
306 code: "NO_CURRENT_TASK".to_string(),
307 message: "No current task to spawn subtask from".to_string(),
308 details: None,
309 }),
310 )
311 .into_response(),
312 Err(e) => (
313 StatusCode::BAD_REQUEST,
314 Json(ApiError {
315 code: "INVALID_REQUEST".to_string(),
316 message: format!("Failed to spawn subtask: {}", e),
317 details: None,
318 }),
319 )
320 .into_response(),
321 }
322}
323
324pub async fn get_current_task(State(state): State<AppState>) -> impl IntoResponse {
326 let db_pool = state.current_project.read().await.db_pool.clone();
327 let workspace_mgr = WorkspaceManager::new(&db_pool);
328
329 match workspace_mgr.get_current_task().await {
330 Ok(response) => {
331 if response.task.is_some() {
332 (StatusCode::OK, Json(ApiResponse { data: response })).into_response()
333 } else {
334 (
335 StatusCode::OK,
336 Json(json!({
337 "data": null,
338 "message": "No current task"
339 })),
340 )
341 .into_response()
342 }
343 },
344 Err(e) => (
345 StatusCode::INTERNAL_SERVER_ERROR,
346 Json(ApiError {
347 code: "DATABASE_ERROR".to_string(),
348 message: format!("Failed to get current task: {}", e),
349 details: None,
350 }),
351 )
352 .into_response(),
353 }
354}
355
356pub async fn pick_next_task(State(state): State<AppState>) -> impl IntoResponse {
358 let db_pool = state.current_project.read().await.db_pool.clone();
359 let task_mgr = TaskManager::new(&db_pool);
360
361 match task_mgr.pick_next().await {
362 Ok(response) => (StatusCode::OK, Json(ApiResponse { data: response })).into_response(),
363 Err(e) => (
364 StatusCode::INTERNAL_SERVER_ERROR,
365 Json(ApiError {
366 code: "DATABASE_ERROR".to_string(),
367 message: format!("Failed to pick next task: {}", e),
368 details: None,
369 }),
370 )
371 .into_response(),
372 }
373}
374
375pub async fn list_events(
377 State(state): State<AppState>,
378 Path(task_id): Path<i64>,
379 Query(query): Query<EventListQuery>,
380) -> impl IntoResponse {
381 let db_pool = state.current_project.read().await.db_pool.clone();
382 let event_mgr = EventManager::new(&db_pool);
383
384 match event_mgr
386 .list_events(
387 Some(task_id),
388 query.limit.map(|l| l as i64),
389 query.event_type,
390 query.since,
391 )
392 .await
393 {
394 Ok(events) => (StatusCode::OK, Json(ApiResponse { data: events })).into_response(),
395 Err(e) => (
396 StatusCode::INTERNAL_SERVER_ERROR,
397 Json(ApiError {
398 code: "DATABASE_ERROR".to_string(),
399 message: format!("Failed to list events: {}", e),
400 details: None,
401 }),
402 )
403 .into_response(),
404 }
405}
406
407pub async fn create_event(
409 State(state): State<AppState>,
410 Path(task_id): Path<i64>,
411 Json(req): Json<CreateEventRequest>,
412) -> impl IntoResponse {
413 let db_pool = state.current_project.read().await.db_pool.clone();
414 let event_mgr = EventManager::new(&db_pool);
415
416 if !["decision", "blocker", "milestone", "note"].contains(&req.event_type.as_str()) {
418 return (
419 StatusCode::BAD_REQUEST,
420 Json(ApiError {
421 code: "INVALID_REQUEST".to_string(),
422 message: format!("Invalid event type: {}", req.event_type),
423 details: None,
424 }),
425 )
426 .into_response();
427 }
428
429 match event_mgr
431 .add_event(task_id, &req.event_type, &req.data)
432 .await
433 {
434 Ok(event) => (StatusCode::CREATED, Json(ApiResponse { data: event })).into_response(),
435 Err(e) => (
436 StatusCode::BAD_REQUEST,
437 Json(ApiError {
438 code: "INVALID_REQUEST".to_string(),
439 message: format!("Failed to create event: {}", e),
440 details: None,
441 }),
442 )
443 .into_response(),
444 }
445}
446
447pub async fn search(
449 State(state): State<AppState>,
450 Query(query): Query<SearchQuery>,
451) -> impl IntoResponse {
452 let db_pool = state.current_project.read().await.db_pool.clone();
453 let search_mgr = SearchManager::new(&db_pool);
454
455 match search_mgr
456 .unified_search(
457 &query.query,
458 query.include_tasks,
459 query.include_events,
460 query.limit.map(|l| l as i64),
461 )
462 .await
463 {
464 Ok(results) => (StatusCode::OK, Json(ApiResponse { data: results })).into_response(),
465 Err(e) => (
466 StatusCode::INTERNAL_SERVER_ERROR,
467 Json(ApiError {
468 code: "DATABASE_ERROR".to_string(),
469 message: format!("Search failed: {}", e),
470 details: None,
471 }),
472 )
473 .into_response(),
474 }
475}
476
477pub async fn list_projects() -> impl IntoResponse {
479 match crate::dashboard::registry::ProjectRegistry::load() {
480 Ok(mut registry) => {
481 registry.cleanup_stale_mcp_connections();
483 if let Err(e) = registry.save() {
484 eprintln!("⚠ Failed to save registry after cleanup: {}", e);
485 }
486
487 let projects: Vec<serde_json::Value> = registry
488 .projects
489 .iter()
490 .map(|p| {
491 json!({
492 "name": p.name,
493 "path": p.path.display().to_string(),
494 "port": p.port,
495 "pid": p.pid,
496 "url": format!("http://127.0.0.1:{}", p.port),
497 "started_at": p.started_at,
498 "mcp_connected": p.mcp_connected,
499 "mcp_agent": p.mcp_agent,
500 "mcp_last_seen": p.mcp_last_seen,
501 })
502 })
503 .collect();
504
505 (StatusCode::OK, Json(ApiResponse { data: projects })).into_response()
506 },
507 Err(e) => (
508 StatusCode::INTERNAL_SERVER_ERROR,
509 Json(ApiError {
510 code: "REGISTRY_ERROR".to_string(),
511 message: format!("Failed to load project registry: {}", e),
512 details: None,
513 }),
514 )
515 .into_response(),
516 }
517}
518
519pub async fn switch_project(
521 State(state): State<AppState>,
522 Json(req): Json<SwitchProjectRequest>,
523) -> impl IntoResponse {
524 use super::server::ProjectContext;
525 use sqlx::SqlitePool;
526 use std::path::PathBuf;
527
528 let project_path = PathBuf::from(&req.project_path);
530
531 if !project_path.exists() {
532 return (
533 StatusCode::NOT_FOUND,
534 Json(ApiError {
535 code: "PROJECT_NOT_FOUND".to_string(),
536 message: format!("Project path does not exist: {}", project_path.display()),
537 details: None,
538 }),
539 )
540 .into_response();
541 }
542
543 let db_path = project_path.join(".intent-engine").join("project.db");
545
546 if !db_path.exists() {
547 return (
548 StatusCode::NOT_FOUND,
549 Json(ApiError {
550 code: "DATABASE_NOT_FOUND".to_string(),
551 message: format!(
552 "Database not found at {}. Is this an Intent-Engine project?",
553 db_path.display()
554 ),
555 details: None,
556 }),
557 )
558 .into_response();
559 }
560
561 let db_url = format!("sqlite://{}", db_path.display());
563 let new_db_pool = match SqlitePool::connect(&db_url).await {
564 Ok(pool) => pool,
565 Err(e) => {
566 return (
567 StatusCode::INTERNAL_SERVER_ERROR,
568 Json(ApiError {
569 code: "DATABASE_CONNECTION_ERROR".to_string(),
570 message: format!("Failed to connect to database: {}", e),
571 details: None,
572 }),
573 )
574 .into_response();
575 },
576 };
577
578 let project_name = project_path
580 .file_name()
581 .and_then(|n| n.to_str())
582 .unwrap_or("unknown")
583 .to_string();
584
585 let new_context = ProjectContext {
587 db_pool: new_db_pool,
588 project_name: project_name.clone(),
589 project_path: project_path.clone(),
590 db_path: db_path.clone(),
591 };
592
593 {
595 let mut current = state.current_project.write().await;
596 *current = new_context;
597 }
598
599 tracing::info!(
600 "Switched to project: {} at {}",
601 project_name,
602 project_path.display()
603 );
604
605 (
606 StatusCode::OK,
607 Json(ApiResponse {
608 data: json!({
609 "success": true,
610 "project_name": project_name,
611 "project_path": project_path.display().to_string(),
612 "database": db_path.display().to_string(),
613 }),
614 }),
615 )
616 .into_response()
617}