summaryrefslogtreecommitdiff
path: root/makima/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src')
-rw-r--r--makima/src/db/models.rs107
-rw-r--r--makima/src/db/repository.rs299
-rw-r--r--makima/src/server/handlers/chains.rs648
-rw-r--r--makima/src/server/mod.rs18
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