From 0302b4596e14210884df5d645df9a179d8f0c1c6 Mon Sep 17 00:00:00 2001 From: soryu Date: Thu, 5 Feb 2026 00:48:38 +0000 Subject: Add multi-repository support for chains Chains can now have multiple repositories attached, with one marked as primary. Repositories are used by contracts created from chain definitions. Backend changes: - Add chain_repositories table migration - Add ChainRepository model with CRUD operations - Add API endpoints for listing, adding, deleting repositories - Add endpoint to set a repository as primary - Update Chain and ChainEditorData models to use repositories - Update chain parser to support repositories in YAML format - Remove deprecated repository_url/local_path from Chain Frontend changes: - Add ChainRepository interface and API functions - Add repository section to ChainEditor showing attached repos - Add modal for adding new repositories (remote or local) - Support setting primary repository and removing repositories Co-Authored-By: Claude Opus 4.5 --- makima/src/daemon/chain/parser.rs | 29 ++- makima/src/daemon/chain/runner.rs | 24 ++- makima/src/db/models.rs | 64 +++++-- makima/src/db/repository.rs | 189 ++++++++++++++++++-- makima/src/server/handlers/chains.rs | 335 ++++++++++++++++++++++++++++++++++- makima/src/server/mod.rs | 13 ++ 6 files changed, 612 insertions(+), 42 deletions(-) (limited to 'makima/src') diff --git a/makima/src/daemon/chain/parser.rs b/makima/src/daemon/chain/parser.rs index 0f16710..3851d1f 100644 --- a/makima/src/daemon/chain/parser.rs +++ b/makima/src/daemon/chain/parser.rs @@ -20,6 +20,27 @@ pub enum ParseError { ValidationError(String), } +/// Repository definition in a chain. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepositoryDefinition { + /// Name of the repository + pub name: String, + /// Repository URL (for remote repos) + pub repository_url: Option, + /// Local path (for local repos) + pub local_path: Option, + /// Source type: remote, local, or managed + #[serde(default = "default_source_type")] + pub source_type: String, + /// Whether this is the primary repository + #[serde(default)] + pub is_primary: bool, +} + +fn default_source_type() -> String { + "remote".to_string() +} + /// Chain definition parsed from YAML. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChainDefinition { @@ -27,11 +48,9 @@ pub struct ChainDefinition { pub name: String, /// Optional description pub description: Option, - /// Repository URL (optional - contracts may have their own repos) - #[serde(alias = "repo")] - pub repository_url: Option, - /// Local path for repository - pub local_path: Option, + /// Repositories for this chain + #[serde(default)] + pub repositories: Vec, /// Contracts in this chain pub contracts: Vec, /// Loop configuration diff --git a/makima/src/daemon/chain/runner.rs b/makima/src/daemon/chain/runner.rs index 9c6f6b4..dfbcfa7 100644 --- a/makima/src/daemon/chain/runner.rs +++ b/makima/src/daemon/chain/runner.rs @@ -14,8 +14,8 @@ use thiserror::Error; use super::dag::{topological_sort, validate_dag, DagError}; use super::parser::{parse_chain_file, ChainDefinition, ParseError}; use crate::db::models::{ - CreateChainContractRequest, CreateChainDeliverableRequest, CreateChainRequest, - CreateChainTaskRequest, + AddChainRepositoryRequest, CreateChainContractRequest, CreateChainDeliverableRequest, + CreateChainRequest, CreateChainTaskRequest, }; /// Error type for chain runner operations. @@ -100,11 +100,27 @@ impl ChainRunner { None => (None, None, None), }; + // Convert repository definitions to API format + let repositories: Vec = chain + .repositories + .iter() + .map(|r| AddChainRepositoryRequest { + name: r.name.clone(), + repository_url: r.repository_url.clone(), + local_path: r.local_path.clone(), + source_type: r.source_type.clone(), + is_primary: r.is_primary, + }) + .collect(); + CreateChainRequest { name: chain.name.clone(), description: chain.description.clone(), - repository_url: chain.repository_url.clone(), - local_path: chain.local_path.clone(), + repositories: if repositories.is_empty() { + None + } else { + Some(repositories) + }, loop_enabled, loop_max_iterations, loop_progress_check, diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 4e569ec..30e1603 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2652,16 +2652,40 @@ pub struct Chain { pub loop_current_iteration: Option, /// Progress check prompt/criteria for evaluating loop completion pub loop_progress_check: Option, - /// Repository URL for contracts in this chain (optional) - pub repository_url: Option, - /// Local path for contracts in this chain (optional) - pub local_path: Option, /// Version for optimistic locking pub version: i32, pub created_at: DateTime, pub updated_at: DateTime, } +/// Chain repository record from the database +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainRepository { + pub id: Uuid, + pub chain_id: Uuid, + pub name: String, + pub repository_url: Option, + pub local_path: Option, + pub source_type: String, + pub status: String, + pub is_primary: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl ChainRepository { + /// Parse source_type string to RepositorySourceType enum + pub fn source_type_enum(&self) -> Result { + self.source_type.parse() + } + + /// Parse status string to RepositoryStatus enum + pub fn status_enum(&self) -> Result { + self.status.parse() + } +} + impl Chain { /// Parse status string to ChainStatus enum pub fn status_enum(&self) -> Result { @@ -2724,6 +2748,7 @@ pub struct ChainWithContracts { #[serde(flatten)] pub chain: Chain, pub contracts: Vec, + pub repositories: Vec, } /// Contract detail within a chain (includes contract info + chain link info) @@ -2790,10 +2815,8 @@ pub struct CreateChainRequest { pub name: String, /// Optional description pub description: Option, - /// Repository URL for contracts in this chain - pub repository_url: Option, - /// Local path for contracts in this chain - pub local_path: Option, + /// Repositories for this chain + pub repositories: Option>, /// Enable loop mode for iterative execution #[serde(default)] pub loop_enabled: Option, @@ -2805,6 +2828,28 @@ pub struct CreateChainRequest { pub contracts: Option>, } +/// Request to add a repository to a chain +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddChainRepositoryRequest { + /// Display name for the repository + pub name: String, + /// Remote repository URL (for remote repos) + pub repository_url: Option, + /// Local filesystem path (for local repos) + pub local_path: Option, + /// Source type: remote, local, or managed + #[serde(default = "default_source_type")] + pub source_type: String, + /// Whether this is the primary repository + #[serde(default)] + pub is_primary: bool, +} + +fn default_source_type() -> String { + "remote".to_string() +} + /// Request to create a contract within a chain #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] @@ -2934,8 +2979,7 @@ pub struct ChainEditorData { pub id: Option, pub name: String, pub description: Option, - pub repository_url: Option, - pub local_path: Option, + pub repositories: Vec, pub loop_enabled: bool, pub loop_max_iterations: Option, pub loop_progress_check: Option, diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index ec233ba..2b595b5 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -6,20 +6,20 @@ use sqlx::PgPool; use uuid::Uuid; use super::models::{ - AddContractDefinitionRequest, AddContractToChainRequest, Chain, ChainContract, - ChainContractDefinition, ChainContractDetail, ChainDefinitionGraphNode, + AddChainRepositoryRequest, AddContractDefinitionRequest, AddContractToChainRequest, Chain, + ChainContract, ChainContractDefinition, ChainContractDetail, ChainDefinitionGraphNode, ChainDefinitionGraphResponse, ChainEditorContract, ChainEditorData, ChainEditorDeliverable, ChainEditorEdge, ChainEditorNode, ChainEditorTask, ChainEvent, ChainGraphEdge, ChainGraphNode, - ChainGraphResponse, ChainSummary, ChainWithContracts, CheckpointPatch, CheckpointPatchInfo, - Contract, ContractChatConversation, ContractChatMessageRecord, ContractEvent, - ContractRepository, ContractSummary, ContractTypeTemplateRecord, ConversationMessage, - ConversationSnapshot, CreateChainRequest, CreateContractRequest, CreateFileRequest, - CreateTaskRequest, CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, - DeliverableDefinition, File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters, - MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, PhaseDefinition, - SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint, TaskEvent, TaskSummary, - UpdateChainRequest, UpdateContractDefinitionRequest, UpdateContractRequest, UpdateFileRequest, - UpdateTaskRequest, UpdateTemplateRequest, + ChainGraphResponse, ChainRepository, ChainSummary, ChainWithContracts, CheckpointPatch, + CheckpointPatchInfo, Contract, ContractChatConversation, ContractChatMessageRecord, + ContractEvent, ContractRepository, ContractSummary, ContractTypeTemplateRecord, + ConversationMessage, ConversationSnapshot, CreateChainRequest, CreateContractRequest, + CreateFileRequest, CreateTaskRequest, CreateTemplateRequest, Daemon, DaemonTaskAssignment, + DaemonWithCapacity, DeliverableDefinition, File, FileSummary, FileVersion, HistoryEvent, + HistoryQueryFilters, MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, + PhaseConfig, PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint, + TaskEvent, TaskSummary, UpdateChainRequest, UpdateContractDefinitionRequest, + UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, UpdateTemplateRequest, }; /// Repository error types. @@ -4917,16 +4917,14 @@ pub async fn create_chain_for_owner( sqlx::query_as::<_, Chain>( r#" - INSERT INTO chains (owner_id, name, description, repository_url, local_path, loop_enabled, loop_max_iterations, loop_progress_check) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO chains (owner_id, name, description, loop_enabled, loop_max_iterations, loop_progress_check) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING * "#, ) .bind(owner_id) .bind(&req.name) .bind(&req.description) - .bind(&req.repository_url) - .bind(&req.local_path) .bind(loop_enabled) .bind(loop_max_iterations) .bind(&req.loop_progress_check) @@ -5181,12 +5179,165 @@ pub async fn get_chain_with_contracts( match chain { Some(chain) => { let contracts = list_chain_contracts(pool, chain_id).await?; - Ok(Some(ChainWithContracts { chain, contracts })) + let repositories = list_chain_repositories(pool, chain_id).await?; + Ok(Some(ChainWithContracts { + chain, + contracts, + repositories, + })) } None => Ok(None), } } +// ============================================================================= +// Chain Repository Operations +// ============================================================================= + +/// List all repositories for a chain. +pub async fn list_chain_repositories( + pool: &PgPool, + chain_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ChainRepository>( + r#" + SELECT * + FROM chain_repositories + WHERE chain_id = $1 + ORDER BY is_primary DESC, created_at ASC + "#, + ) + .bind(chain_id) + .fetch_all(pool) + .await +} + +/// Get a chain repository by ID. +pub async fn get_chain_repository( + pool: &PgPool, + chain_id: Uuid, + repository_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ChainRepository>( + r#" + SELECT * + FROM chain_repositories + WHERE id = $1 AND chain_id = $2 + "#, + ) + .bind(repository_id) + .bind(chain_id) + .fetch_optional(pool) + .await +} + +/// Add a repository to a chain. +pub async fn add_chain_repository( + pool: &PgPool, + chain_id: Uuid, + req: &AddChainRepositoryRequest, +) -> Result { + // If is_primary, clear other primaries first + if req.is_primary { + sqlx::query( + r#" + UPDATE chain_repositories + SET is_primary = false, updated_at = NOW() + WHERE chain_id = $1 AND is_primary = true + "#, + ) + .bind(chain_id) + .execute(pool) + .await?; + } + + sqlx::query_as::<_, ChainRepository>( + r#" + INSERT INTO chain_repositories (chain_id, name, repository_url, local_path, source_type, status, is_primary) + VALUES ($1, $2, $3, $4, $5, 'ready', $6) + RETURNING * + "#, + ) + .bind(chain_id) + .bind(&req.name) + .bind(&req.repository_url) + .bind(&req.local_path) + .bind(&req.source_type) + .bind(req.is_primary) + .fetch_one(pool) + .await +} + +/// Delete a repository from a chain. +pub async fn delete_chain_repository( + pool: &PgPool, + chain_id: Uuid, + repository_id: Uuid, +) -> Result { + let result = sqlx::query( + r#" + DELETE FROM chain_repositories + WHERE id = $1 AND chain_id = $2 + "#, + ) + .bind(repository_id) + .bind(chain_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Set a repository as primary for a chain. +pub async fn set_chain_repository_primary( + pool: &PgPool, + chain_id: Uuid, + repository_id: Uuid, +) -> Result { + // Clear existing primary + sqlx::query( + r#" + UPDATE chain_repositories + SET is_primary = false, updated_at = NOW() + WHERE chain_id = $1 AND is_primary = true + "#, + ) + .bind(chain_id) + .execute(pool) + .await?; + + // Set new primary + sqlx::query_as::<_, ChainRepository>( + r#" + UPDATE chain_repositories + SET is_primary = true, updated_at = NOW() + WHERE id = $1 AND chain_id = $2 + RETURNING * + "#, + ) + .bind(repository_id) + .bind(chain_id) + .fetch_one(pool) + .await +} + +/// Get the primary repository for a chain. +pub async fn get_chain_primary_repository( + pool: &PgPool, + chain_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ChainRepository>( + r#" + SELECT * + FROM chain_repositories + WHERE chain_id = $1 AND is_primary = true + "#, + ) + .bind(chain_id) + .fetch_optional(pool) + .await +} + /// Get chain graph structure for visualization. pub async fn get_chain_graph( pool: &PgPool, @@ -5381,6 +5532,7 @@ pub async fn get_chain_editor_data( match chain { Some(chain) => { let contracts = list_chain_contracts(pool, chain_id).await?; + let repositories = list_chain_repositories(pool, chain_id).await?; // Build nodes let nodes: Vec = contracts @@ -5415,8 +5567,7 @@ pub async fn get_chain_editor_data( id: Some(chain.id), name: chain.name, description: chain.description, - repository_url: chain.repository_url, - local_path: chain.local_path, + repositories, loop_enabled: chain.loop_enabled, loop_max_iterations: chain.loop_max_iterations, loop_progress_check: chain.loop_progress_check, diff --git a/makima/src/server/handlers/chains.rs b/makima/src/server/handlers/chains.rs index 6cef72c..9b32495 100644 --- a/makima/src/server/handlers/chains.rs +++ b/makima/src/server/handlers/chains.rs @@ -13,10 +13,10 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::db::models::{ - AddContractDefinitionRequest, ChainContractDefinition, ChainContractDetail, - ChainDefinitionGraphResponse, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary, - ChainWithContracts, CreateChainRequest, StartChainRequest, StartChainResponse, - UpdateChainRequest, UpdateContractDefinitionRequest, + AddChainRepositoryRequest, AddContractDefinitionRequest, ChainContractDefinition, + ChainContractDetail, ChainDefinitionGraphResponse, ChainEditorData, ChainEvent, + ChainGraphResponse, ChainRepository, ChainSummary, ChainWithContracts, CreateChainRequest, + StartChainRequest, StartChainResponse, UpdateChainRequest, UpdateContractDefinitionRequest, }; use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; @@ -1255,3 +1255,330 @@ pub async fn stop_chain( } } } + +// ============================================================================= +// 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), + (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, + Authenticated(auth): Authenticated, + Path(chain_id): Path, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::list_chain_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, + Authenticated(auth): Authenticated, + Path(chain_id): Path, + Json(req): Json, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Validate 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, + 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, + 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/mod.rs b/makima/src/server/mod.rs index 5dde099..f6d2eda 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -245,6 +245,19 @@ pub fn make_router(state: SharedState) -> Router { // Chain control .route("/chains/{id}/start", post(chains::start_chain)) .route("/chains/{id}/stop", post(chains::stop_chain)) + // Chain repositories + .route( + "/chains/{id}/repositories", + get(chains::list_chain_repositories).post(chains::add_chain_repository), + ) + .route( + "/chains/{chain_id}/repositories/{repository_id}", + axum::routing::delete(chains::delete_chain_repository), + ) + .route( + "/chains/{chain_id}/repositories/{repository_id}/primary", + put(chains::set_chain_repository_primary), + ) // Contract type templates (built-in only) .route("/contract-types", get(templates::list_contract_types)) // Settings endpoints -- cgit v1.2.3