diff options
Diffstat (limited to 'makima/src/server/handlers/contracts.rs')
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 342 |
1 files changed, 342 insertions, 0 deletions
diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs index 5a87616..01b4610 100644 --- a/makima/src/server/handlers/contracts.rs +++ b/makima/src/server/handlers/contracts.rs @@ -1889,6 +1889,348 @@ async fn cleanup_contract_worktrees( } // ============================================================================= +// Supervisor Status API +// ============================================================================= + +/// Query parameters for supervisor heartbeat history +#[derive(Debug, Deserialize)] +pub struct HeartbeatHistoryQuery { + /// Maximum number of heartbeats to return (default: 10) + pub limit: Option<i32>, + /// Offset for pagination (default: 0) + pub offset: Option<i32>, +} + +/// Get supervisor status for a contract. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/supervisor/status", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Supervisor status", body = crate::db::models::SupervisorStatusResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or 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 = "Contracts" +)] +pub async fn get_supervisor_status( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> 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 belongs to owner + let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Check if contract has a supervisor + let supervisor_task_id = match contract.supervisor_task_id { + Some(task_id) => task_id, + None => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")), + ) + .into_response(); + } + }; + + // Get supervisor status from supervisor_states table + match repository::get_supervisor_status(pool, id, auth.owner_id).await { + Ok(Some(status_info)) => { + // Determine if supervisor is actively running + let is_running = status_info.is_running && status_info.task_status == "running"; + + let response = crate::db::models::SupervisorStatusResponse { + task_id: status_info.task_id, + state: status_info.supervisor_state, + phase: status_info.phase, + current_activity: status_info.current_activity, + progress: None, // We don't track progress percentage yet + last_heartbeat: status_info.last_heartbeat, + pending_task_ids: status_info.pending_task_ids, + is_running, + }; + Json(response).into_response() + } + Ok(None) => { + // No supervisor state record exists, but supervisor task might exist + // Try to get info from the task itself + match repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { + Ok(Some(task)) => { + let is_running = task.daemon_id.is_some() && task.status == "running"; + let response = crate::db::models::SupervisorStatusResponse { + task_id: task.id, + state: task.status.clone(), + phase: contract.phase.clone(), + current_activity: task.progress_summary.clone(), + progress: None, + last_heartbeat: task.updated_at, + pending_task_ids: Vec::new(), + is_running, + }; + Json(response).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("SUPERVISOR_NOT_FOUND", "Supervisor task not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get supervisor task {}: {}", supervisor_task_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } + } + Err(e) => { + tracing::error!("Failed to get supervisor status for contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get supervisor heartbeat history for a contract. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/supervisor/heartbeats", + params( + ("id" = Uuid, Path, description = "Contract ID"), + ("limit" = Option<i32>, Query, description = "Maximum number of heartbeats to return (default: 10)"), + ("offset" = Option<i32>, Query, description = "Offset for pagination (default: 0)") + ), + responses( + (status = 200, description = "Supervisor heartbeat history", body = crate::db::models::SupervisorHeartbeatHistoryResponse), + (status = 401, description = "Unauthorized", 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 = "Contracts" +)] +pub async fn get_supervisor_heartbeats( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + axum::extract::Query(query): axum::extract::Query<HeartbeatHistoryQuery>, +) -> 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 belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + let limit = query.limit.unwrap_or(10).min(100); // Cap at 100 + let offset = query.offset.unwrap_or(0); + + // Get activity history as heartbeats + let activities = match repository::get_supervisor_activity_history(pool, id, limit, offset).await { + Ok(activities) => activities, + Err(e) => { + tracing::error!("Failed to get supervisor heartbeats for contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Get total count for pagination + let total = match repository::count_supervisor_activity_history(pool, id).await { + Ok(count) => count, + Err(e) => { + tracing::warn!("Failed to count supervisor heartbeats: {}", e); + activities.len() as i64 + } + }; + + // Convert to heartbeat entries + let heartbeats: Vec<crate::db::models::SupervisorHeartbeatEntry> = activities + .into_iter() + .map(|a| crate::db::models::SupervisorHeartbeatEntry { + timestamp: a.timestamp, + state: a.state, + activity: a.activity, + progress: a.progress.map(|p| p as u8), + phase: a.phase, + pending_task_ids: a.pending_task_ids, + }) + .collect(); + + Json(crate::db::models::SupervisorHeartbeatHistoryResponse { + heartbeats, + total, + }) + .into_response() +} + +/// Sync supervisor state (refresh last_activity timestamp). +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/supervisor/sync", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Supervisor synced", body = crate::db::models::SupervisorSyncResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or 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 = "Contracts" +)] +pub async fn sync_supervisor( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> 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 belongs to owner + let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Check if contract has a supervisor + if contract.supervisor_task_id.is_none() { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")), + ) + .into_response(); + } + + // Sync supervisor state (update last_activity) + match repository::sync_supervisor_state(pool, id).await { + Ok(Some(_state)) => { + // Get task status to determine current state + let task_status = if let Some(task_id) = contract.supervisor_task_id { + match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { + Ok(Some(task)) => task.status, + _ => "unknown".to_string(), + } + } else { + "unknown".to_string() + }; + + Json(crate::db::models::SupervisorSyncResponse { + synced: true, + state: task_status, + message: Some("Supervisor state synced successfully".to_string()), + }) + .into_response() + } + Ok(None) => { + // No supervisor state exists, return not found + ( + StatusCode::NOT_FOUND, + Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor state found for this contract")), + ) + .into_response() + } + Err(e) => { + tracing::error!("Failed to sync supervisor state for contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= // Tests // ============================================================================= |
