//! 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::{ AddContractDefinitionRequest, ChainContractDefinition, ChainContractDetail, ChainDefinitionGraphResponse, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary, ChainWithContracts, CreateChainRequest, CreateTaskRequest, 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() } } } /// 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(); }; let req = body.map(|b| b.0).unwrap_or(StartChainRequest { with_supervisor: false, repository_url: None, }); // 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(); } } // Create supervisor task if requested let mut supervisor_task_id: Option = None; if req.with_supervisor { let supervisor_name = format!("Chain Supervisor: {}", chain.name); let supervisor_plan = format!( r#"You are the supervisor for chain "{}". ## Environment Variables - MAKIMA_CHAIN_ID={} - MAKIMA_API_URL (configured) - MAKIMA_API_KEY (configured) ## Your Responsibilities 1. Monitor chain progress by periodically checking chain status 2. Validate that contracts are completing successfully 3. Identify and report any issues or blockers 4. Track the overall chain progress through the DAG ## Available Commands Use these makima CLI commands to monitor the chain: ```bash # Check chain status makima chain status {} # List contracts in the chain makima chain contracts {} # View the chain DAG with current status makima chain graph {} --with-status ``` ## Monitoring Loop 1. Check chain status every few minutes 2. If a contract fails, investigate the issue 3. Report progress to the user when milestones are reached 4. Mark the chain as complete when all contracts finish ## Current Chain Info - Chain ID: {} - Chain Name: {} - Total definitions: {} Begin monitoring the chain. Check the initial status and report what you find."#, chain.name, chain_id, chain_id, chain_id, chain_id, chain_id, chain.name, definitions.len() ); let supervisor_req = CreateTaskRequest { name: supervisor_name, description: Some(format!("Supervisor task for chain: {}", chain.name)), plan: supervisor_plan, repository_url: req.repository_url.clone(), base_branch: None, target_branch: None, parent_task_id: None, contract_id: None, // Chain supervisor is not tied to a specific contract target_repo_path: None, completion_action: None, continue_from_task_id: None, copy_files: None, is_supervisor: true, checkpoint_sha: None, priority: 0, merge_mode: None, branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, }; match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await { Ok(supervisor_task) => { tracing::info!( chain_id = %chain_id, supervisor_task_id = %supervisor_task.id, "Created supervisor task for chain" ); // Update chain with supervisor_task_id if let Err(e) = repository::set_chain_supervisor_task(pool, chain_id, Some(supervisor_task.id)) .await { tracing::warn!( chain_id = %chain_id, error = %e, "Failed to link supervisor task to chain" ); } supervisor_task_id = Some(supervisor_task.id); } Err(e) => { tracing::warn!( chain_id = %chain_id, error = %e, "Failed to create supervisor task for chain" ); } } } // 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, supervisor_task_id, contracts_created: vec![], status: "active".to_string(), }) .into_response(); } }; Json(StartChainResponse { chain_id, supervisor_task_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(); } // TODO: Kill the supervisor task if running // Clear supervisor task ID and set status to archived match repository::set_chain_supervisor_task(pool, chain_id, None).await { Ok(_) => {} Err(e) => { tracing::error!("Failed to clear chain supervisor: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } 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() } } }