//! HTTP handlers for history and conversation APIs. use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, Json, }; use uuid::Uuid; use crate::{ db::{ models::{flexible_datetime, ConversationMessage, HistoryQueryFilters, TaskConversationResponse}, repository, }, server::{auth::Authenticated, messages::ApiError, state::SharedState}, }; /// Query parameters for task conversation #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct TaskConversationParams { pub include_tool_calls: Option, pub include_tool_results: Option, pub limit: Option, } /// Query parameters for timeline #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct TimelineQueryFilters { pub task_id: Option, pub include_subtasks: Option, #[serde(default, deserialize_with = "flexible_datetime::deserialize")] pub from: Option>, #[serde(default, deserialize_with = "flexible_datetime::deserialize")] pub to: Option>, pub limit: Option, } #[utoipa::path( get, path = "/api/v1/mesh/tasks/{id}/conversation", params( ("id" = Uuid, Path, description = "Task ID"), ("include_tool_calls" = Option, Query, description = "Include tool call messages"), ("include_tool_results" = Option, Query, description = "Include tool result messages"), ("limit" = Option, Query, description = "Limit messages"), ), responses( (status = 200, description = "Task conversation", body = TaskConversationResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 403, description = "Forbidden", body = ApiError), (status = 404, description = "Task not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "History" )] pub async fn get_task_conversation( State(state): State, Path(task_id): Path, Query(params): Query, Authenticated(auth): Authenticated, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Get task and verify ownership let task = match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { Ok(Some(t)) => t, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Task not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get task {}: {}", task_id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; // Get conversation messages let messages = match repository::get_task_conversation( pool, task_id, params.include_tool_calls.unwrap_or(true), params.include_tool_results.unwrap_or(true), params.limit, ) .await { Ok(m) => m, Err(e) => { tracing::error!("Failed to get task conversation: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; // Calculate totals let total_cost: f64 = messages.iter().filter_map(|m| m.cost_usd).sum(); Json(TaskConversationResponse { task_id, task_name: task.name, status: task.status, messages, total_tokens: None, total_cost: if total_cost > 0.0 { Some(total_cost) } else { None }, }) .into_response() } /// GET /api/v1/timeline /// Returns unified task-history timeline for the authenticated user. #[utoipa::path( get, path = "/api/v1/timeline", responses( (status = 200, description = "Timeline events"), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security(("bearer_auth" = []), ("api_key" = [])), tag = "History" )] pub async fn get_timeline( State(state): State, Query(filters): Query, Authenticated(auth): Authenticated, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; let history_filters = HistoryQueryFilters { event_types: None, from: filters.from, to: filters.to, limit: filters.limit, cursor: None, }; let result = if let Some(task_id) = filters.task_id { repository::get_task_history(pool, task_id, auth.owner_id, &history_filters).await } else { repository::get_timeline(pool, auth.owner_id, &history_filters).await }; match result { Ok((events, total_count)) => Json(serde_json::json!({ "entries": events, "totalCount": total_count, })) .into_response(), Err(e) => { tracing::error!("Failed to get timeline: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } }