diff options
Diffstat (limited to 'makima/src/server/handlers/history.rs')
| -rw-r--r-- | makima/src/server/handlers/history.rs | 268 |
1 files changed, 10 insertions, 258 deletions
diff --git a/makima/src/server/handlers/history.rs b/makima/src/server/handlers/history.rs index bee6b02..46be7ac 100644 --- a/makima/src/server/handlers/history.rs +++ b/makima/src/server/handlers/history.rs @@ -10,10 +10,7 @@ use uuid::Uuid; use crate::{ db::{ - models::{ - flexible_datetime, ContractHistoryResponse, ConversationMessage, HistoryQueryFilters, - SupervisorConversationResponse, TaskConversationResponse, TaskReference, - }, + models::{flexible_datetime, ConversationMessage, HistoryQueryFilters, TaskConversationResponse}, repository, }, server::{auth::Authenticated, messages::ApiError, state::SharedState}, @@ -32,7 +29,6 @@ pub struct TaskConversationParams { #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct TimelineQueryFilters { - pub contract_id: Option<Uuid>, pub task_id: Option<Uuid>, pub include_subtasks: Option<bool>, #[serde(default, deserialize_with = "flexible_datetime::deserialize")] @@ -42,231 +38,6 @@ pub struct TimelineQueryFilters { pub limit: Option<i32>, } -/// GET /api/v1/contracts/{id}/history -/// Returns contract history timeline with filtering and pagination -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/history", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("phase" = Option<String>, Query, description = "Filter by phase"), - ("event_types" = Option<String>, Query, description = "Filter by event types (comma-separated)"), - ("from" = Option<String>, Query, description = "Start date filter"), - ("to" = Option<String>, Query, description = "End date filter"), - ("limit" = Option<i32>, Query, description = "Limit results"), - ), - responses( - (status = 200, description = "Contract history", body = ContractHistoryResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 403, description = "Forbidden", body = ApiError), - (status = 404, description = "Contract 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_contract_history( - State(state): State<SharedState>, - Path(contract_id): Path<Uuid>, - Query(filters): Query<HistoryQueryFilters>, - 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(); - }; - - // Verify contract exists and user has access - let contract = match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", contract_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get history events - match repository::get_contract_history(pool, contract.id, auth.owner_id, &filters).await { - Ok((events, total_count)) => { - Json(ContractHistoryResponse { - contract_id, - entries: events, - total_count, - cursor: None, // TODO: implement cursor pagination - }) - .into_response() - } - Err(e) => { - tracing::error!("Failed to get contract history: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// GET /api/v1/contracts/{id}/supervisor/conversation -/// Returns full supervisor conversation with spawned task references -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/supervisor/conversation", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Supervisor conversation", body = SupervisorConversationResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 403, description = "Forbidden", body = ApiError), - (status = 404, description = "Supervisor 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_supervisor_conversation( - State(state): State<SharedState>, - Path(contract_id): Path<Uuid>, - 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 contract for phase info and ownership check - let contract = match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", contract_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get the supervisor state - let supervisor_state = match repository::get_supervisor_state(pool, contract_id).await { - Ok(Some(s)) => s, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Supervisor not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get supervisor state for {}: {}", contract_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Parse conversation history from JSONB - let messages: Vec<ConversationMessage> = supervisor_state - .conversation_history - .as_array() - .map(|arr| { - arr.iter() - .enumerate() - .map(|(i, v)| ConversationMessage { - id: i.to_string(), - role: v - .get("role") - .and_then(|r| r.as_str()) - .unwrap_or("user") - .to_string(), - content: v - .get("content") - .and_then(|c| c.as_str()) - .unwrap_or("") - .to_string(), - timestamp: supervisor_state.last_activity, - tool_calls: None, - tool_name: None, - tool_input: None, - tool_result: None, - is_error: None, - token_count: None, - cost_usd: None, - }) - .collect() - }) - .unwrap_or_default(); - - // Get spawned tasks - let tasks = match repository::list_tasks_by_contract(pool, contract_id, auth.owner_id).await { - Ok(t) => t, - Err(e) => { - tracing::warn!("Failed to get tasks for contract {}: {}", contract_id, e); - Vec::new() - } - }; - - let spawned_tasks: Vec<TaskReference> = tasks - .into_iter() - .filter(|t| !t.is_supervisor) - .map(|t| TaskReference { - task_id: t.id, - task_name: t.name, - status: t.status, - created_at: t.created_at, - completed_at: t.completed_at, - }) - .collect(); - - Json(SupervisorConversationResponse { - contract_id, - supervisor_task_id: supervisor_state.task_id, - phase: contract.phase, - last_activity: supervisor_state.last_activity, - pending_task_ids: supervisor_state.pending_task_ids, - messages, - spawned_tasks, - }) - .into_response() -} - -/// GET /api/v1/mesh/tasks/{id}/conversation -/// Returns task conversation history #[utoipa::path( get, path = "/api/v1/mesh/tasks/{id}/conversation", @@ -364,28 +135,16 @@ pub async fn get_task_conversation( } /// GET /api/v1/timeline -/// Returns unified timeline for authenticated user +/// Returns unified task-history timeline for the authenticated user. #[utoipa::path( get, path = "/api/v1/timeline", - params( - ("contract_id" = Option<Uuid>, Query, description = "Filter by contract"), - ("task_id" = Option<Uuid>, Query, description = "Filter by task"), - ("include_subtasks" = Option<bool>, Query, description = "Include subtask events"), - ("from" = Option<String>, Query, description = "Start date filter"), - ("to" = Option<String>, Query, description = "End date filter"), - ("limit" = Option<i32>, Query, description = "Limit results"), - ), responses( - (status = 200, description = "Timeline events", body = ContractHistoryResponse), + (status = 200, description = "Timeline events"), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) ), + security(("bearer_auth" = []), ("api_key" = [])), tag = "History" )] pub async fn get_timeline( @@ -402,7 +161,6 @@ pub async fn get_timeline( }; let history_filters = HistoryQueryFilters { - phase: None, event_types: None, from: filters.from, to: filters.to, @@ -410,24 +168,18 @@ pub async fn get_timeline( cursor: None, }; - let result = if let Some(contract_id) = filters.contract_id { - repository::get_contract_history(pool, contract_id, auth.owner_id, &history_filters).await - } else if let Some(task_id) = filters.task_id { + 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(ContractHistoryResponse { - contract_id: filters.contract_id.unwrap_or_default(), - entries: events, - total_count, - cursor: None, - }) - .into_response() - } + Ok((events, total_count)) => Json(serde_json::json!({ + "entries": events, + "totalCount": total_count, + })) + .into_response(), Err(e) => { tracing::error!("Failed to get timeline: {}", e); ( |
