diff options
| author | soryu <soryu@soryu.co> | 2026-02-03 23:49:08 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-03 23:49:19 +0000 |
| commit | c732dd048128808cd9f67f6e1176a5b565df5678 (patch) | |
| tree | 6ebf359c9c3f2d8aca264c53da6367b7f0af5fc8 /makima/src/server/handlers/chains.rs | |
| parent | 9ebc9724afcc0482a8e7cd2369c06208fedbcbd1 (diff) | |
| download | soryu-c732dd048128808cd9f67f6e1176a5b565df5678.tar.gz soryu-c732dd048128808cd9f67f6e1176a5b565df5678.zip | |
Allow chain creation via web interface
Diffstat (limited to 'makima/src/server/handlers/chains.rs')
| -rw-r--r-- | makima/src/server/handlers/chains.rs | 648 |
1 files changed, 646 insertions, 2 deletions
diff --git a/makima/src/server/handlers/chains.rs b/makima/src/server/handlers/chains.rs index 136a868..5d26e6a 100644 --- a/makima/src/server/handlers/chains.rs +++ b/makima/src/server/handlers/chains.rs @@ -13,8 +13,10 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::db::models::{ - ChainContractDetail, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary, - ChainWithContracts, CreateChainRequest, UpdateChainRequest, + AddContractDefinitionRequest, ChainContractDefinition, ChainContractDetail, + ChainDefinitionGraphResponse, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary, + ChainWithContracts, CreateChainRequest, StartChainResponse, UpdateChainRequest, + UpdateContractDefinitionRequest, }; use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; @@ -607,3 +609,645 @@ pub async fn get_chain_editor( } } } + +// ============================================================================= +// 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") + ), + 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>, +) -> 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(); + } + + // TODO: Implement chain supervisor spawning + // For now, just update the 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(); + } + } + + // Return response indicating chain has started + // supervisor_task_id is None until we implement the supervisor daemon + Json(StartChainResponse { + chain_id, + supervisor_task_id: None, + contracts_created: vec![], + status: "started".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(); + } + + // 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() + } + } +} |
