//! 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, /// 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, 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, Authenticated(auth): Authenticated, Query(query): Query, ) -> 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, Authenticated(auth): Authenticated, Json(req): Json, ) -> 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, Authenticated(auth): Authenticated, Json(req): Json, ) -> 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, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> 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, Authenticated(auth): Authenticated, Path(chain_id): Path, Json(req): Json, ) -> 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, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> 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), (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, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> 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, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> 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), (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, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> 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, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> 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), (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, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> 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, Authenticated(auth): Authenticated, Path(chain_id): Path, Json(req): Json, ) -> 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, Authenticated(auth): Authenticated, Path((chain_id, definition_id)): Path<(Uuid, Uuid)>, Json(req): Json, ) -> 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, 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, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> 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, 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, Authenticated(auth): Authenticated, Path(chain_id): Path, _body: Option>, ) -> 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, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> 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), (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, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> 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, Authenticated(auth): Authenticated, Path(chain_id): Path, Json(req): Json, ) -> 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, 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, 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() } } }