diff options
Diffstat (limited to 'makima/src/server/handlers/chains.rs')
| -rw-r--r-- | makima/src/server/handlers/chains.rs | 1644 |
1 files changed, 0 insertions, 1644 deletions
diff --git a/makima/src/server/handlers/chains.rs b/makima/src/server/handlers/chains.rs deleted file mode 100644 index b8716ca..0000000 --- a/makima/src/server/handlers/chains.rs +++ /dev/null @@ -1,1644 +0,0 @@ -//! HTTP handlers for chain CRUD operations. -//! -//! Chains are DAGs (directed acyclic graphs) of contracts for multi-contract orchestration. - -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::Deserialize; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::models::{ - AddChainRepositoryRequest, AddContractDefinitionRequest, ChainContractDefinition, - ChainContractDetail, ChainDefinitionGraphResponse, ChainEditorData, ChainEvent, - ChainGraphResponse, ChainRepository, ChainSummary, ChainWithContracts, CreateChainRequest, - InitChainRequest, InitChainResponse, StartChainRequest, StartChainResponse, UpdateChainRequest, - UpdateContractDefinitionRequest, -}; -use crate::db::repository::{self, RepositoryError}; -use crate::server::auth::Authenticated; -use crate::server::messages::ApiError; -use crate::server::state::SharedState; - -// ============================================================================= -// Query Parameters -// ============================================================================= - -/// Query parameters for listing chains. -#[derive(Debug, Deserialize, ToSchema)] -pub struct ListChainsQuery { - /// Filter by status (active, completed, archived) - pub status: Option<String>, - /// Maximum number of results - #[serde(default = "default_limit")] - pub limit: i32, - /// Offset for pagination - #[serde(default)] - pub offset: i32, -} - -fn default_limit() -> i32 { - 50 -} - -// ============================================================================= -// Response Types -// ============================================================================= - -/// Response for listing chains. -#[derive(Debug, serde::Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChainListResponse { - pub chains: Vec<ChainSummary>, - pub total: i64, -} - -// ============================================================================= -// Handlers -// ============================================================================= - -/// List chains for the authenticated user. -/// -/// GET /api/v1/chains -#[utoipa::path( - get, - path = "/api/v1/chains", - responses( - (status = 200, description = "List of chains", body = ChainListResponse), - (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" = []) - ), - tag = "Chains" -)] -pub async fn list_chains( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Query(query): Query<ListChainsQuery>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::list_chains_for_owner(pool, auth.owner_id).await { - Ok(mut chains) => { - // Apply filters - if let Some(status) = &query.status { - chains.retain(|c| c.status == *status); - } - // Apply pagination - let total = chains.len() as i64; - let chains: Vec<_> = chains - .into_iter() - .skip(query.offset as usize) - .take(query.limit as usize) - .collect(); - Json(ChainListResponse { chains, total }).into_response() - } - Err(e) => { - tracing::error!("Failed to list chains: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Create a new chain with contracts. -/// -/// POST /api/v1/chains -#[utoipa::path( - post, - path = "/api/v1/chains", - request_body = CreateChainRequest, - responses( - (status = 201, description = "Chain created"), - (status = 400, description = "Invalid request", body = ApiError), - (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" = []) - ), - tag = "Chains" -)] -pub async fn create_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(req): Json<CreateChainRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Validate the request - if req.name.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("VALIDATION_ERROR", "Chain name cannot be empty")), - ) - .into_response(); - } - - match repository::create_chain_for_owner(pool, auth.owner_id, req).await { - Ok(chain) => (StatusCode::CREATED, Json(chain)).into_response(), - Err(e) => { - tracing::error!("Failed to create chain: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Initialize a directive-driven chain. -/// -/// Creates a directive contract that will research, plan, create, and orchestrate -/// a chain of contracts to accomplish the given goal. The directive contract goes -/// through Research -> Specify -> Plan -> Execute -> Review phases. -/// -/// POST /api/v1/chains/init -#[utoipa::path( - post, - path = "/api/v1/chains/init", - request_body = InitChainRequest, - responses( - (status = 201, description = "Directive chain initialized", body = InitChainResponse), - (status = 400, description = "Invalid request", body = ApiError), - (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" = []) - ), - tag = "Chains" -)] -pub async fn init_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(req): Json<InitChainRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Validate the request - if req.goal.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("VALIDATION_ERROR", "Goal cannot be empty")), - ) - .into_response(); - } - - match repository::init_chain_for_owner(pool, auth.owner_id, req).await { - Ok(response) => (StatusCode::CREATED, Json(response)).into_response(), - Err(e) => { - tracing::error!("Failed to initialize directive chain: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get a chain by ID. -/// -/// GET /api/v1/chains/{id} -#[utoipa::path( - get, - path = "/api/v1/chains/{id}", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Chain with contracts", body = ChainWithContracts), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain 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 = "Chains" -)] -pub async fn get_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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(); - }; - - match repository::get_chain_with_contracts(pool, chain_id, auth.owner_id).await { - Ok(Some(chain)) => Json(chain).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get chain: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Update a chain. -/// -/// PUT /api/v1/chains/{id} -#[utoipa::path( - put, - path = "/api/v1/chains/{id}", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - request_body = UpdateChainRequest, - responses( - (status = 200, description = "Chain updated"), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 409, description = "Version conflict", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn update_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_id): Path<Uuid>, - Json(req): Json<UpdateChainRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::update_chain_for_owner(pool, chain_id, auth.owner_id, req).await { - Ok(chain) => Json(chain).into_response(), - Err(RepositoryError::VersionConflict { expected, actual }) => ( - StatusCode::CONFLICT, - Json(ApiError::new( - "VERSION_CONFLICT", - format!("Version conflict: expected {}, found {}", expected, actual), - )), - ) - .into_response(), - Err(RepositoryError::Database(e)) => { - // Check if it's a "row not found" error - let error_str = e.to_string(); - if error_str.contains("no rows") || error_str.contains("RowNotFound") { - ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response() - } else { - tracing::error!("Failed to update chain: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } - } -} - -/// Delete (archive) a chain. -/// -/// DELETE /api/v1/chains/{id} -#[utoipa::path( - delete, - path = "/api/v1/chains/{id}", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Chain archived"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain 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 = "Chains" -)] -pub async fn delete_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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(); - }; - - match repository::delete_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(true) => Json(serde_json::json!({"archived": true})).into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to delete chain: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get contracts in a chain. -/// -/// GET /api/v1/chains/{id}/contracts -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/contracts", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "List of contracts in chain", body = Vec<ChainContractDetail>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain 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 = "Chains" -)] -pub async fn get_chain_contracts( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_chain_contracts(pool, chain_id).await { - Ok(contracts) => Json(contracts).into_response(), - Err(e) => { - tracing::error!("Failed to list chain contracts: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get chain DAG structure for visualization. -/// -/// GET /api/v1/chains/{id}/graph -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/graph", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Chain graph structure", body = ChainGraphResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain 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 = "Chains" -)] -pub async fn get_chain_graph( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 ownership first - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::get_chain_graph(pool, chain_id).await { - Ok(Some(graph)) => Json(graph).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get chain graph: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get chain events. -/// -/// GET /api/v1/chains/{id}/events -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/events", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Chain events", body = Vec<ChainEvent>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain 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 = "Chains" -)] -pub async fn get_chain_events( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_chain_events(pool, chain_id).await { - Ok(events) => Json(events).into_response(), - Err(e) => { - tracing::error!("Failed to list chain events: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get chain editor data. -/// -/// GET /api/v1/chains/{id}/editor -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/editor", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Chain editor data", body = ChainEditorData), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain 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 = "Chains" -)] -pub async fn get_chain_editor( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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(); - }; - - match repository::get_chain_editor_data(pool, chain_id, auth.owner_id).await { - Ok(Some(editor_data)) => Json(editor_data).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get chain editor data: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Contract Definition Handlers -// ============================================================================= - -/// List contract definitions for a chain. -/// -/// GET /api/v1/chains/{id}/definitions -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/definitions", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "List of contract definitions", body = Vec<ChainContractDefinition>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain 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 = "Chains" -)] -pub async fn list_chain_definitions( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(definitions) => Json(definitions).into_response(), - Err(e) => { - tracing::error!("Failed to list chain definitions: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Create a contract definition for a chain. -/// -/// POST /api/v1/chains/{id}/definitions -#[utoipa::path( - post, - path = "/api/v1/chains/{id}/definitions", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - request_body = AddContractDefinitionRequest, - responses( - (status = 201, description = "Contract definition created", body = ChainContractDefinition), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain 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 = "Chains" -)] -pub async fn create_chain_definition( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_id): Path<Uuid>, - Json(req): Json<AddContractDefinitionRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Validate the request - if req.name.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("VALIDATION_ERROR", "Definition name cannot be empty")), - ) - .into_response(); - } - - // Verify ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::create_chain_contract_definition(pool, chain_id, req).await { - Ok(definition) => (StatusCode::CREATED, Json(definition)).into_response(), - Err(e) => { - tracing::error!("Failed to create chain definition: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Update a contract definition. -/// -/// PUT /api/v1/chains/{chain_id}/definitions/{definition_id} -#[utoipa::path( - put, - path = "/api/v1/chains/{chain_id}/definitions/{definition_id}", - params( - ("chain_id" = Uuid, Path, description = "Chain ID"), - ("definition_id" = Uuid, Path, description = "Definition ID") - ), - request_body = UpdateContractDefinitionRequest, - responses( - (status = 200, description = "Contract definition updated", body = ChainContractDefinition), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain or definition 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 = "Chains" -)] -pub async fn update_chain_definition( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((chain_id, definition_id)): Path<(Uuid, Uuid)>, - Json(req): Json<UpdateContractDefinitionRequest>, -) -> 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 ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Verify definition belongs to this chain - match repository::get_chain_contract_definition(pool, definition_id).await { - Ok(Some(def)) if def.chain_id == chain_id => {} - Ok(Some(_)) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Definition not found in this chain")), - ) - .into_response(); - } - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Definition not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get chain definition: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::update_chain_contract_definition(pool, definition_id, req).await { - Ok(definition) => Json(definition).into_response(), - Err(e) => { - tracing::error!("Failed to update chain definition: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Delete a contract definition. -/// -/// DELETE /api/v1/chains/{chain_id}/definitions/{definition_id} -#[utoipa::path( - delete, - path = "/api/v1/chains/{chain_id}/definitions/{definition_id}", - params( - ("chain_id" = Uuid, Path, description = "Chain ID"), - ("definition_id" = Uuid, Path, description = "Definition ID") - ), - responses( - (status = 200, description = "Contract definition deleted"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain or definition 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 = "Chains" -)] -pub async fn delete_chain_definition( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((chain_id, definition_id)): Path<(Uuid, 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 ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Verify definition belongs to this chain before deleting - match repository::get_chain_contract_definition(pool, definition_id).await { - Ok(Some(def)) if def.chain_id == chain_id => {} - Ok(Some(_)) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Definition not found in this chain")), - ) - .into_response(); - } - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Definition not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get chain definition: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::delete_chain_contract_definition(pool, definition_id).await { - Ok(true) => Json(serde_json::json!({"deleted": true})).into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Definition not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to delete chain definition: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get definition graph for a chain (shows definitions + instantiation status). -/// -/// GET /api/v1/chains/{id}/definitions/graph -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/definitions/graph", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Definition graph structure", body = ChainDefinitionGraphResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain 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 = "Chains" -)] -pub async fn get_chain_definition_graph( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 ownership first - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::get_chain_definition_graph(pool, chain_id).await { - Ok(Some(graph)) => Json(graph).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get chain definition graph: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Chain Control Handlers -// ============================================================================= - -/// Start a chain (spawns supervisor and creates root contracts). -/// -/// POST /api/v1/chains/{id}/start -#[utoipa::path( - post, - path = "/api/v1/chains/{id}/start", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - request_body(content = Option<StartChainRequest>, description = "Optional start options"), - responses( - (status = 200, description = "Chain started", body = StartChainResponse), - (status = 400, description = "Chain cannot be started", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain 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 = "Chains" -)] -pub async fn start_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_id): Path<Uuid>, - _body: Option<Json<StartChainRequest>>, -) -> 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 ownership and get chain - let chain = match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get chain: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Check if chain can be started - if chain.status == "active" { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("ALREADY_ACTIVE", "Chain is already active")), - ) - .into_response(); - } - if chain.status == "completed" { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("ALREADY_COMPLETED", "Chain is already completed")), - ) - .into_response(); - } - - // Get definitions to check if there are any - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(d) => d, - Err(e) => { - tracing::error!("Failed to list chain definitions: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - if definitions.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_DEFINITIONS", "Chain has no contract definitions")), - ) - .into_response(); - } - - // Update chain status to active - match repository::update_chain_status(pool, chain_id, "active").await { - Ok(_) => {} - Err(e) => { - tracing::error!("Failed to update chain status: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Progress the chain - this creates root contracts (definitions with no dependencies) - let progression = match repository::progress_chain(pool, chain_id, auth.owner_id).await { - Ok(p) => p, - Err(e) => { - tracing::error!("Failed to progress chain: {}", e); - // Chain is active but no contracts created - return partial success - return Json(StartChainResponse { - chain_id, - contracts_created: vec![], - status: "active".to_string(), - }) - .into_response(); - } - }; - - Json(StartChainResponse { - chain_id, - contracts_created: progression.contracts_created, - status: if progression.chain_completed { - "completed".to_string() - } else { - "active".to_string() - }, - }) - .into_response() -} - -/// Stop a chain (kills supervisor, marks as archived). -/// -/// POST /api/v1/chains/{id}/stop -#[utoipa::path( - post, - path = "/api/v1/chains/{id}/stop", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Chain stopped"), - (status = 400, description = "Chain cannot be stopped", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain 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 = "Chains" -)] -pub async fn stop_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 ownership and get chain - let chain = match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get chain: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Check if chain can be stopped - if chain.status != "active" { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new( - "NOT_ACTIVE", - format!("Chain is not active (status: {})", chain.status), - )), - ) - .into_response(); - } - - // Archive the chain - match repository::update_chain_status(pool, chain_id, "archived").await { - Ok(_) => Json(serde_json::json!({"stopped": true, "status": "archived"})).into_response(), - Err(e) => { - tracing::error!("Failed to update chain status: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Chain Repository Handlers -// ============================================================================= - -/// List repositories for a chain. -/// -/// GET /api/v1/chains/{id}/repositories -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/repositories", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "List of repositories", body = Vec<ChainRepository>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain 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 = "Chains" -)] -pub async fn list_chain_repositories( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_chain_repositories(pool, chain_id).await { - Ok(repos) => Json(repos).into_response(), - Err(e) => { - tracing::error!("Failed to list chain repositories: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Add a repository to a chain. -/// -/// POST /api/v1/chains/{id}/repositories -#[utoipa::path( - post, - path = "/api/v1/chains/{id}/repositories", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - request_body = AddChainRepositoryRequest, - responses( - (status = 201, description = "Repository added", body = ChainRepository), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain 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 = "Chains" -)] -pub async fn add_chain_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_id): Path<Uuid>, - Json(req): Json<AddChainRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Validate request - if req.name.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("VALIDATION_ERROR", "Repository name cannot be empty")), - ) - .into_response(); - } - - // Must have either repository_url or local_path - if req.repository_url.is_none() && req.local_path.is_none() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new( - "VALIDATION_ERROR", - "Repository must have either repository_url or local_path", - )), - ) - .into_response(); - } - - // Verify ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_chain_repository(pool, chain_id, &req).await { - Ok(repo) => (StatusCode::CREATED, Json(repo)).into_response(), - Err(e) => { - tracing::error!("Failed to add chain repository: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Delete a repository from a chain. -/// -/// DELETE /api/v1/chains/{chain_id}/repositories/{repository_id} -#[utoipa::path( - delete, - path = "/api/v1/chains/{chain_id}/repositories/{repository_id}", - params( - ("chain_id" = Uuid, Path, description = "Chain ID"), - ("repository_id" = Uuid, Path, description = "Repository ID") - ), - responses( - (status = 200, description = "Repository deleted"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain or repository 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 = "Chains" -)] -pub async fn delete_chain_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((chain_id, repository_id)): Path<(Uuid, 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 ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::delete_chain_repository(pool, chain_id, repository_id).await { - Ok(true) => Json(serde_json::json!({"deleted": true})).into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Repository not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to delete chain repository: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Set a repository as primary for a chain. -/// -/// PUT /api/v1/chains/{chain_id}/repositories/{repository_id}/primary -#[utoipa::path( - put, - path = "/api/v1/chains/{chain_id}/repositories/{repository_id}/primary", - params( - ("chain_id" = Uuid, Path, description = "Chain ID"), - ("repository_id" = Uuid, Path, description = "Repository ID") - ), - responses( - (status = 200, description = "Repository set as primary", body = ChainRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain or repository 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 = "Chains" -)] -pub async fn set_chain_repository_primary( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((chain_id, repository_id)): Path<(Uuid, 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 ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Verify repository exists for this chain - match repository::get_chain_repository(pool, chain_id, repository_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Repository not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get chain repository: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::set_chain_repository_primary(pool, chain_id, repository_id).await { - Ok(repo) => Json(repo).into_response(), - Err(e) => { - tracing::error!("Failed to set chain repository as primary: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} |
