diff options
Diffstat (limited to 'makima/src/server')
| -rw-r--r-- | makima/src/server/handlers/chains.rs | 1644 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 485 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 6 |
3 files changed, 489 insertions, 1646 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() - } - } -} diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index 4a78ab5..52422cd 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -19,8 +19,9 @@ use std::time::Duration; use uuid::Uuid; use crate::db::models::{ - AddStepRequest, CreateDirectiveRequest, CreateVerifierRequest, UpdateDirectiveRequest, - UpdateStepRequest, UpdateVerifierRequest, + AddStepRequest, CreateDirectiveRequest, CreateVerifierRequest, ReworkStepRequest, + UpdateCriteriaRequest, UpdateDirectiveRequest, UpdateRequirementsRequest, UpdateStepRequest, + UpdateVerifierRequest, }; use crate::db::repository; use crate::server::auth::Authenticated; @@ -1567,3 +1568,483 @@ pub async fn deny_request( } } } + +// ============================================================================= +// Step Evaluation & Rework +// ============================================================================= + +/// Force re-evaluation of a step +/// POST /api/v1/directives/:id/steps/:step_id/evaluate +pub async fn evaluate_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_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_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Set step to evaluating status + match repository::update_step_status(pool, step_id, "evaluating").await { + Ok(_) => {} + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Trigger evaluation via engine + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine.on_contract_completed(step_id).await { + Ok(()) => { + // Return updated step + match repository::get_chain_step(pool, step_id).await { + Ok(Some(step)) => Json(step).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + } + } + Err(e) => { + tracing::error!("Failed to evaluate step: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("EVALUATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Trigger manual rework for a step +/// POST /api/v1/directives/:id/steps/:step_id/rework +pub async fn rework_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, + Json(req): Json<ReworkStepRequest>, +) -> 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_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Set step to rework status and increment rework count + match repository::update_step_status(pool, step_id, "rework").await { + Ok(_) => {} + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let _ = repository::increment_step_rework_count(pool, step_id).await; + + // Emit rework event + let _ = repository::emit_directive_event( + pool, + id, + None, + Some(step_id), + "step_rework", + "info", + Some(serde_json::json!({ + "step_id": step_id, + "instructions": req.instructions, + "initiated_by": "user", + })), + "user", + Some(auth.owner_id), + ) + .await; + + // Return updated step + match repository::get_chain_step(pool, step_id).await { + Ok(Some(step)) => Json(step).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + } +} + +// ============================================================================= +// Auto-detect Verifiers +// ============================================================================= + +/// Auto-detect verifiers for a directive based on repository content +/// POST /api/v1/directives/:id/verifiers/auto-detect +pub async fn auto_detect_verifiers( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get directive with ownership check + let directive = match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + }; + + // Get repository path + let repo_path = directive + .local_path + .as_ref() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + + // Auto-detect verifiers + let detected = crate::orchestration::auto_detect_verifiers(&repo_path).await; + + // Save detected verifiers to the database + let mut created = Vec::new(); + for verifier in &detected { + let info = verifier.info(); + match repository::create_directive_verifier( + pool, + id, + &info.name, + &info.verifier_type, + Some(&info.command), + info.working_directory.as_deref(), + true, // auto_detect + info.detect_files.clone(), + info.weight, + info.required, + ) + .await + { + Ok(v) => created.push(v), + Err(e) => { + tracing::warn!("Failed to create detected verifier '{}': {}", info.name, e); + } + } + } + + Json(serde_json::json!({ + "detected": created.len(), + "verifiers": created, + })) + .into_response() +} + +// ============================================================================= +// Requirements & Criteria +// ============================================================================= + +/// Update directive requirements +/// PUT /api/v1/directives/:id/requirements +pub async fn update_requirements( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateRequirementsRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get directive with ownership check to get current version + let directive = match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + }; + + // Build update request with just requirements + let update = UpdateDirectiveRequest { + title: None, + goal: None, + requirements: Some(serde_json::to_value(&req.requirements).unwrap_or_default()), + acceptance_criteria: None, + constraints: None, + external_dependencies: None, + autonomy_level: None, + confidence_threshold_green: None, + confidence_threshold_yellow: None, + max_total_cost_usd: None, + max_wall_time_minutes: None, + max_rework_cycles: None, + max_chain_regenerations: None, + version: directive.version, + }; + + match repository::update_directive_for_owner(pool, id, auth.owner_id, update).await { + Ok(directive) => Json(directive).into_response(), + Err(repository::RepositoryError::VersionConflict { expected, actual }) => ( + StatusCode::CONFLICT, + Json(ApiError::new( + "VERSION_CONFLICT", + &format!("Version conflict: expected {}, got {}", expected, actual), + )), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update requirements: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update directive acceptance criteria +/// PUT /api/v1/directives/:id/criteria +pub async fn update_criteria( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateCriteriaRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get directive with ownership check to get current version + let directive = match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + }; + + // Build update request with just acceptance criteria + let update = UpdateDirectiveRequest { + title: None, + goal: None, + requirements: None, + acceptance_criteria: Some( + serde_json::to_value(&req.acceptance_criteria).unwrap_or_default(), + ), + constraints: None, + external_dependencies: None, + autonomy_level: None, + confidence_threshold_green: None, + confidence_threshold_yellow: None, + max_total_cost_usd: None, + max_wall_time_minutes: None, + max_rework_cycles: None, + max_chain_regenerations: None, + version: directive.version, + }; + + match repository::update_directive_for_owner(pool, id, auth.owner_id, update).await { + Ok(directive) => Json(directive).into_response(), + Err(repository::RepositoryError::VersionConflict { expected, actual }) => ( + StatusCode::CONFLICT, + Json(ApiError::new( + "VERSION_CONFLICT", + &format!("Version conflict: expected {}, got {}", expected, actual), + )), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update criteria: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Spec Generation +// ============================================================================= + +/// Generate a specification from the directive's goal using LLM +/// POST /api/v1/directives/:id/generate-spec +pub async fn generate_spec( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get directive with ownership check + let directive = match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + }; + + // Use the planner to generate a spec from the goal + let planner = crate::orchestration::ChainPlanner::new(pool.clone()); + match planner.generate_spec(&directive).await { + Ok(spec) => { + // Update the directive with the generated spec + let update = UpdateDirectiveRequest { + title: spec.title, + goal: None, + requirements: Some(spec.requirements), + acceptance_criteria: Some(spec.acceptance_criteria), + constraints: spec.constraints, + external_dependencies: None, + autonomy_level: None, + confidence_threshold_green: None, + confidence_threshold_yellow: None, + max_total_cost_usd: None, + max_wall_time_minutes: None, + max_rework_cycles: None, + max_chain_regenerations: None, + version: directive.version, + }; + + match repository::update_directive_for_owner(pool, id, auth.owner_id, update).await { + Ok(updated) => Json(updated).into_response(), + Err(e) => { + tracing::error!("Failed to save generated spec: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + } + Err(e) => { + tracing::error!("Failed to generate spec: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("SPEC_GENERATION_FAILED", &e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 927e9a5..463a5f5 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -229,6 +229,9 @@ pub fn make_router(state: SharedState) -> Router { .route("/directives/{id}/pause", post(directives::pause_directive)) .route("/directives/{id}/resume", post(directives::resume_directive)) .route("/directives/{id}/stop", post(directives::stop_directive)) + .route("/directives/{id}/requirements", axum::routing::put(directives::update_requirements)) + .route("/directives/{id}/criteria", axum::routing::put(directives::update_criteria)) + .route("/directives/{id}/generate-spec", post(directives::generate_spec)) // Directive chain management .route("/directives/{id}/chain", get(directives::get_chain)) .route("/directives/{id}/chain/graph", get(directives::get_chain_graph)) @@ -245,12 +248,15 @@ pub fn make_router(state: SharedState) -> Router { .delete(directives::delete_step), ) .route("/directives/{id}/chain/steps/{step_id}/skip", post(directives::skip_step)) + .route("/directives/{id}/chain/steps/{step_id}/evaluate", post(directives::evaluate_step)) + .route("/directives/{id}/chain/steps/{step_id}/rework", post(directives::rework_step)) // Directive evaluations .route("/directives/{id}/evaluations", get(directives::list_evaluations)) // Directive events .route("/directives/{id}/events", get(directives::list_events)) .route("/directives/{id}/events/stream", get(directives::stream_events)) // Directive verifiers + .route("/directives/{id}/verifiers/auto-detect", post(directives::auto_detect_verifiers)) .route( "/directives/{id}/verifiers", get(directives::list_verifiers).post(directives::add_verifier), |
