summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/server/handlers')
-rw-r--r--makima/src/server/handlers/chains.rs1644
-rw-r--r--makima/src/server/handlers/directives.rs485
2 files changed, 483 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()
+ }
+ }
+}