diff options
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/db/models.rs | 107 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 299 | ||||
| -rw-r--r-- | makima/src/server/handlers/chains.rs | 648 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 18 |
4 files changed, 1058 insertions, 14 deletions
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 45ddb52..eeb30e4 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2767,7 +2767,7 @@ pub struct ChainGraphNode { } /// Edge in chain DAG graph -#[derive(Debug, Serialize, ToSchema)] +#[derive(Debug, Clone, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ChainGraphEdge { pub from: Uuid, @@ -2942,6 +2942,111 @@ pub struct ChainEditorEdge { } // ============================================================================= +// Chain Contract Definitions (stored specs for on-demand contract creation) +// ============================================================================= + +/// Contract definition within a chain - stored spec before actual contract is created +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainContractDefinition { + pub id: Uuid, + pub chain_id: Uuid, + pub name: String, + pub description: Option<String>, + pub contract_type: String, + pub initial_phase: Option<String>, + /// Names of other definitions this depends on + #[sqlx(default)] + pub depends_on_names: Vec<String>, + /// Task definitions as JSON: [{name, plan}, ...] + pub tasks: Option<serde_json::Value>, + /// Deliverable definitions as JSON: [{id, name, priority}, ...] + pub deliverables: Option<serde_json::Value>, + /// Position in GUI editor + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, + pub order_index: i32, + pub created_at: DateTime<Utc>, +} + +/// Request to add a contract definition to a chain +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddContractDefinitionRequest { + pub name: String, + pub description: Option<String>, + #[serde(default = "default_contract_type")] + pub contract_type: String, + pub initial_phase: Option<String>, + /// Names of other definitions this depends on + pub depends_on: Option<Vec<String>>, + /// Task definitions + pub tasks: Option<Vec<CreateChainTaskRequest>>, + /// Deliverable definitions + pub deliverables: Option<Vec<CreateChainDeliverableRequest>>, + /// Position in GUI editor + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, +} + +fn default_contract_type() -> String { + "simple".to_string() +} + +/// Request to update a contract definition +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateContractDefinitionRequest { + pub name: Option<String>, + pub description: Option<String>, + pub contract_type: Option<String>, + pub initial_phase: Option<String>, + pub depends_on: Option<Vec<String>>, + pub tasks: Option<Vec<CreateChainTaskRequest>>, + pub deliverables: Option<Vec<CreateChainDeliverableRequest>>, + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, +} + +/// Response when starting a chain +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct StartChainResponse { + pub chain_id: Uuid, + pub supervisor_task_id: Option<Uuid>, + /// Root contracts created (those with no dependencies) + pub contracts_created: Vec<Uuid>, + pub status: String, +} + +/// Graph node for definitions (before contracts are created) +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainDefinitionGraphNode { + pub id: Uuid, + pub name: String, + pub contract_type: String, + pub x: f64, + pub y: f64, + /// Whether this definition has been instantiated as a contract + pub is_instantiated: bool, + /// The contract ID if instantiated + pub contract_id: Option<Uuid>, + pub contract_status: Option<String>, +} + +/// Graph response for definitions +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainDefinitionGraphResponse { + pub chain_id: Uuid, + pub chain_name: String, + pub chain_status: String, + pub nodes: Vec<ChainDefinitionGraphNode>, + pub edges: Vec<ChainGraphEdge>, +} + +// ============================================================================= // Unit Tests // ============================================================================= diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 48b0714..85af178 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -6,19 +6,20 @@ use sqlx::PgPool; use uuid::Uuid; use super::models::{ - AddContractToChainRequest, Chain, ChainContract, ChainContractDetail, 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, + 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, UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, - UpdateTemplateRequest, + UpdateChainRequest, UpdateContractDefinitionRequest, UpdateContractRequest, UpdateFileRequest, + UpdateTaskRequest, UpdateTemplateRequest, }; /// Repository error types. @@ -5426,3 +5427,281 @@ pub async fn get_chain_editor_data( None => Ok(None), } } + +// ============================================================================= +// Chain Contract Definition Operations +// ============================================================================= + +/// Create a new contract definition in a chain. +pub async fn create_chain_contract_definition( + pool: &PgPool, + chain_id: Uuid, + req: AddContractDefinitionRequest, +) -> Result<ChainContractDefinition, sqlx::Error> { + // Get the next order index + let max_order: Option<i32> = sqlx::query_scalar( + "SELECT MAX(order_index) FROM chain_contract_definitions WHERE chain_id = $1", + ) + .bind(chain_id) + .fetch_one(pool) + .await?; + + let order_index = max_order.unwrap_or(-1) + 1; + + // Convert tasks and deliverables to JSON + let tasks_json = req.tasks.as_ref().map(|t| serde_json::to_value(t).unwrap()); + let deliverables_json = req + .deliverables + .as_ref() + .map(|d| serde_json::to_value(d).unwrap()); + let depends_on_names: Vec<String> = req.depends_on.unwrap_or_default(); + + sqlx::query_as::<_, ChainContractDefinition>( + r#" + INSERT INTO chain_contract_definitions + (chain_id, name, description, contract_type, initial_phase, depends_on_names, tasks, deliverables, editor_x, editor_y, order_index) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING * + "#, + ) + .bind(chain_id) + .bind(&req.name) + .bind(&req.description) + .bind(&req.contract_type) + .bind(&req.initial_phase) + .bind(&depends_on_names) + .bind(&tasks_json) + .bind(&deliverables_json) + .bind(req.editor_x) + .bind(req.editor_y) + .bind(order_index) + .fetch_one(pool) + .await +} + +/// List all contract definitions in a chain. +pub async fn list_chain_contract_definitions( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Vec<ChainContractDefinition>, sqlx::Error> { + sqlx::query_as::<_, ChainContractDefinition>( + r#" + SELECT * FROM chain_contract_definitions + WHERE chain_id = $1 + ORDER BY order_index ASC + "#, + ) + .bind(chain_id) + .fetch_all(pool) + .await +} + +/// Get a specific contract definition. +pub async fn get_chain_contract_definition( + pool: &PgPool, + definition_id: Uuid, +) -> Result<Option<ChainContractDefinition>, sqlx::Error> { + sqlx::query_as::<_, ChainContractDefinition>( + "SELECT * FROM chain_contract_definitions WHERE id = $1", + ) + .bind(definition_id) + .fetch_optional(pool) + .await +} + +/// Update a contract definition. +pub async fn update_chain_contract_definition( + pool: &PgPool, + definition_id: Uuid, + req: UpdateContractDefinitionRequest, +) -> Result<ChainContractDefinition, sqlx::Error> { + let tasks_json = req.tasks.as_ref().map(|t| serde_json::to_value(t).unwrap()); + let deliverables_json = req + .deliverables + .as_ref() + .map(|d| serde_json::to_value(d).unwrap()); + + sqlx::query_as::<_, ChainContractDefinition>( + r#" + UPDATE chain_contract_definitions SET + name = COALESCE($2, name), + description = COALESCE($3, description), + contract_type = COALESCE($4, contract_type), + initial_phase = COALESCE($5, initial_phase), + depends_on_names = COALESCE($6, depends_on_names), + tasks = COALESCE($7, tasks), + deliverables = COALESCE($8, deliverables), + editor_x = COALESCE($9, editor_x), + editor_y = COALESCE($10, editor_y) + WHERE id = $1 + RETURNING * + "#, + ) + .bind(definition_id) + .bind(&req.name) + .bind(&req.description) + .bind(&req.contract_type) + .bind(&req.initial_phase) + .bind(&req.depends_on) + .bind(&tasks_json) + .bind(&deliverables_json) + .bind(req.editor_x) + .bind(req.editor_y) + .fetch_one(pool) + .await +} + +/// Delete a contract definition. +pub async fn delete_chain_contract_definition( + pool: &PgPool, + definition_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query("DELETE FROM chain_contract_definitions WHERE id = $1") + .bind(definition_id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Get definitions that are ready to be instantiated (all dependencies are satisfied). +/// A definition is ready if all definitions it depends on have been instantiated as contracts +/// and those contracts have completed. +pub async fn get_ready_definitions( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Vec<ChainContractDefinition>, sqlx::Error> { + sqlx::query_as::<_, ChainContractDefinition>( + r#" + SELECT d.* + FROM chain_contract_definitions d + WHERE d.chain_id = $1 + -- Not already instantiated + AND NOT EXISTS ( + SELECT 1 FROM chain_contracts cc + WHERE cc.definition_id = d.id + ) + -- All dependencies satisfied (either no deps, or all deps have completed contracts) + AND ( + cardinality(d.depends_on_names) = 0 + OR NOT EXISTS ( + SELECT 1 FROM unnest(d.depends_on_names) AS dep_name + WHERE NOT EXISTS ( + SELECT 1 FROM chain_contract_definitions dep_def + JOIN chain_contracts cc ON cc.definition_id = dep_def.id + JOIN contracts c ON c.id = cc.contract_id + WHERE dep_def.chain_id = d.chain_id + AND dep_def.name = dep_name + AND c.status = 'completed' + ) + ) + ) + ORDER BY d.order_index ASC + "#, + ) + .bind(chain_id) + .fetch_all(pool) + .await +} + +/// Get the definition graph for visualization. +pub async fn get_chain_definition_graph( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Option<ChainDefinitionGraphResponse>, sqlx::Error> { + let chain = sqlx::query_as::<_, Chain>("SELECT * FROM chains WHERE id = $1") + .bind(chain_id) + .fetch_optional(pool) + .await?; + + let Some(chain) = chain else { + return Ok(None); + }; + + let definitions = list_chain_contract_definitions(pool, chain_id).await?; + + // Get instantiated contracts for each definition + let chain_contracts = list_chain_contracts(pool, chain_id).await?; + let instantiated: std::collections::HashMap<Uuid, &ChainContractDetail> = chain_contracts + .iter() + .filter_map(|cc| { + // Find definition_id from cc - we need to query this + // For now, match by name + definitions + .iter() + .find(|d| d.name == cc.contract_name) + .map(|d| (d.id, cc)) + }) + .collect(); + + let nodes: Vec<ChainDefinitionGraphNode> = definitions + .iter() + .map(|d| { + let cc = instantiated.get(&d.id); + ChainDefinitionGraphNode { + id: d.id, + name: d.name.clone(), + contract_type: d.contract_type.clone(), + x: d.editor_x.unwrap_or(0.0), + y: d.editor_y.unwrap_or(0.0), + is_instantiated: cc.is_some(), + contract_id: cc.map(|c| c.contract_id), + contract_status: cc.map(|c| c.contract_status.clone()), + } + }) + .collect(); + + // Build edges from depends_on_names + let name_to_id: std::collections::HashMap<&str, Uuid> = + definitions.iter().map(|d| (d.name.as_str(), d.id)).collect(); + + let edges: Vec<ChainGraphEdge> = definitions + .iter() + .flat_map(|d| { + let target_id = d.id; + let name_to_id = &name_to_id; + d.depends_on_names.iter().filter_map(move |dep_name| { + name_to_id + .get(dep_name.as_str()) + .map(|&from_id| ChainGraphEdge { from: from_id, to: target_id }) + }) + }) + .collect(); + + Ok(Some(ChainDefinitionGraphResponse { + chain_id: chain.id, + chain_name: chain.name, + chain_status: chain.status, + nodes, + edges, + })) +} + +/// Set the supervisor task ID for a chain. +pub async fn set_chain_supervisor_task( + pool: &PgPool, + chain_id: Uuid, + supervisor_task_id: Option<Uuid>, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE chains SET supervisor_task_id = $2, updated_at = NOW() WHERE id = $1", + ) + .bind(chain_id) + .bind(supervisor_task_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Update chain status. +pub async fn update_chain_status( + pool: &PgPool, + chain_id: Uuid, + status: &str, +) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE chains SET status = $2, updated_at = NOW() WHERE id = $1") + .bind(chain_id) + .bind(status) + .execute(pool) + .await?; + Ok(()) +} diff --git a/makima/src/server/handlers/chains.rs b/makima/src/server/handlers/chains.rs index 136a868..5d26e6a 100644 --- a/makima/src/server/handlers/chains.rs +++ b/makima/src/server/handlers/chains.rs @@ -13,8 +13,10 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::db::models::{ - ChainContractDetail, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary, - ChainWithContracts, CreateChainRequest, UpdateChainRequest, + AddContractDefinitionRequest, ChainContractDefinition, ChainContractDetail, + ChainDefinitionGraphResponse, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary, + ChainWithContracts, CreateChainRequest, StartChainResponse, UpdateChainRequest, + UpdateContractDefinitionRequest, }; use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; @@ -607,3 +609,645 @@ pub async fn get_chain_editor( } } } + +// ============================================================================= +// Contract Definition Handlers +// ============================================================================= + +/// List contract definitions for a chain. +/// +/// GET /api/v1/chains/{id}/definitions +#[utoipa::path( + get, + path = "/api/v1/chains/{id}/definitions", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "List of contract definitions", body = Vec<ChainContractDefinition>), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn list_chain_definitions( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::list_chain_contract_definitions(pool, chain_id).await { + Ok(definitions) => Json(definitions).into_response(), + Err(e) => { + tracing::error!("Failed to list chain definitions: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Create a contract definition for a chain. +/// +/// POST /api/v1/chains/{id}/definitions +#[utoipa::path( + post, + path = "/api/v1/chains/{id}/definitions", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + request_body = AddContractDefinitionRequest, + responses( + (status = 201, description = "Contract definition created", body = ChainContractDefinition), + (status = 400, description = "Invalid request", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn create_chain_definition( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, + Json(req): Json<AddContractDefinitionRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Validate the request + if req.name.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("VALIDATION_ERROR", "Definition name cannot be empty")), + ) + .into_response(); + } + + // Verify ownership + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::create_chain_contract_definition(pool, chain_id, req).await { + Ok(definition) => (StatusCode::CREATED, Json(definition)).into_response(), + Err(e) => { + tracing::error!("Failed to create chain definition: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Update a contract definition. +/// +/// PUT /api/v1/chains/{chain_id}/definitions/{definition_id} +#[utoipa::path( + put, + path = "/api/v1/chains/{chain_id}/definitions/{definition_id}", + params( + ("chain_id" = Uuid, Path, description = "Chain ID"), + ("definition_id" = Uuid, Path, description = "Definition ID") + ), + request_body = UpdateContractDefinitionRequest, + responses( + (status = 200, description = "Contract definition updated", body = ChainContractDefinition), + (status = 400, description = "Invalid request", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain or definition not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn update_chain_definition( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((chain_id, definition_id)): Path<(Uuid, Uuid)>, + Json(req): Json<UpdateContractDefinitionRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Verify definition belongs to this chain + match repository::get_chain_contract_definition(pool, definition_id).await { + Ok(Some(def)) if def.chain_id == chain_id => {} + Ok(Some(_)) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Definition not found in this chain")), + ) + .into_response(); + } + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Definition not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get chain definition: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::update_chain_contract_definition(pool, definition_id, req).await { + Ok(definition) => Json(definition).into_response(), + Err(e) => { + tracing::error!("Failed to update chain definition: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a contract definition. +/// +/// DELETE /api/v1/chains/{chain_id}/definitions/{definition_id} +#[utoipa::path( + delete, + path = "/api/v1/chains/{chain_id}/definitions/{definition_id}", + params( + ("chain_id" = Uuid, Path, description = "Chain ID"), + ("definition_id" = Uuid, Path, description = "Definition ID") + ), + responses( + (status = 200, description = "Contract definition deleted"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain or definition not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn delete_chain_definition( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((chain_id, definition_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Verify definition belongs to this chain before deleting + match repository::get_chain_contract_definition(pool, definition_id).await { + Ok(Some(def)) if def.chain_id == chain_id => {} + Ok(Some(_)) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Definition not found in this chain")), + ) + .into_response(); + } + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Definition not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get chain definition: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::delete_chain_contract_definition(pool, definition_id).await { + Ok(true) => Json(serde_json::json!({"deleted": true})).into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Definition not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete chain definition: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get definition graph for a chain (shows definitions + instantiation status). +/// +/// GET /api/v1/chains/{id}/definitions/graph +#[utoipa::path( + get, + path = "/api/v1/chains/{id}/definitions/graph", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "Definition graph structure", body = ChainDefinitionGraphResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn get_chain_definition_graph( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership first + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::get_chain_definition_graph(pool, chain_id).await { + Ok(Some(graph)) => Json(graph).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get chain definition graph: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Chain Control Handlers +// ============================================================================= + +/// Start a chain (spawns supervisor and creates root contracts). +/// +/// POST /api/v1/chains/{id}/start +#[utoipa::path( + post, + path = "/api/v1/chains/{id}/start", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "Chain started", body = StartChainResponse), + (status = 400, description = "Chain cannot be started", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn start_chain( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership and get chain + let chain = match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get chain: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Check if chain can be started + if chain.status == "active" { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("ALREADY_ACTIVE", "Chain is already active")), + ) + .into_response(); + } + if chain.status == "completed" { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("ALREADY_COMPLETED", "Chain is already completed")), + ) + .into_response(); + } + + // Get definitions to check if there are any + let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { + Ok(d) => d, + Err(e) => { + tracing::error!("Failed to list chain definitions: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + if definitions.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("NO_DEFINITIONS", "Chain has no contract definitions")), + ) + .into_response(); + } + + // TODO: Implement chain supervisor spawning + // For now, just update the chain status to active + match repository::update_chain_status(pool, chain_id, "active").await { + Ok(_) => {} + Err(e) => { + tracing::error!("Failed to update chain status: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Return response indicating chain has started + // supervisor_task_id is None until we implement the supervisor daemon + Json(StartChainResponse { + chain_id, + supervisor_task_id: None, + contracts_created: vec![], + status: "started".to_string(), + }) + .into_response() +} + +/// Stop a chain (kills supervisor, marks as archived). +/// +/// POST /api/v1/chains/{id}/stop +#[utoipa::path( + post, + path = "/api/v1/chains/{id}/stop", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "Chain stopped"), + (status = 400, description = "Chain cannot be stopped", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn stop_chain( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership and get chain + let chain = match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get chain: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Check if chain can be stopped + if chain.status != "active" { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new( + "NOT_ACTIVE", + format!("Chain is not active (status: {})", chain.status), + )), + ) + .into_response(); + } + + // TODO: Kill the supervisor task if running + // Clear supervisor task ID and set status to archived + match repository::set_chain_supervisor_task(pool, chain_id, None).await { + Ok(_) => {} + Err(e) => { + tracing::error!("Failed to clear chain supervisor: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::update_chain_status(pool, chain_id, "archived").await { + Ok(_) => Json(serde_json::json!({"stopped": true, "status": "archived"})).into_response(), + Err(e) => { + tracing::error!("Failed to update chain status: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 553797f..5dde099 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -9,7 +9,7 @@ pub mod state; use axum::{ http::StatusCode, response::IntoResponse, - routing::{get, post}, + routing::{get, post, put}, Json, Router, }; use serde::Serialize; @@ -229,6 +229,22 @@ pub fn make_router(state: SharedState) -> Router { .route("/chains/{id}/graph", get(chains::get_chain_graph)) .route("/chains/{id}/events", get(chains::get_chain_events)) .route("/chains/{id}/editor", get(chains::get_chain_editor)) + // Chain contract definitions + .route( + "/chains/{id}/definitions", + get(chains::list_chain_definitions).post(chains::create_chain_definition), + ) + .route( + "/chains/{chain_id}/definitions/{definition_id}", + put(chains::update_chain_definition).delete(chains::delete_chain_definition), + ) + .route( + "/chains/{id}/definitions/graph", + get(chains::get_chain_definition_graph), + ) + // Chain control + .route("/chains/{id}/start", post(chains::start_chain)) + .route("/chains/{id}/stop", post(chains::stop_chain)) // Contract type templates (built-in only) .route("/contract-types", get(templates::list_contract_types)) // Settings endpoints |
