//! 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<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()
}
}
}
/// 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();
};
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<Uuid> = 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<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()
}
}
}