From 9e9f18884c78c21f5785908fb7ccd00e2fa5436b Mon Sep 17 00:00:00 2001 From: soryu Date: Sat, 7 Feb 2026 01:11:26 +0000 Subject: Add new directive initial implementation --- makima/src/server/handlers/directives.rs | 440 +++++++++++++++++++++++++++++++ makima/src/server/handlers/mod.rs | 1 + makima/src/server/mod.rs | 15 +- makima/src/server/openapi.rs | 41 ++- 4 files changed, 486 insertions(+), 11 deletions(-) create mode 100644 makima/src/server/handlers/directives.rs (limited to 'makima/src/server') diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs new file mode 100644 index 0000000..a74f8ff --- /dev/null +++ b/makima/src/server/handlers/directives.rs @@ -0,0 +1,440 @@ +//! HTTP handlers for directive CRUD operations. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +use crate::db::models::{ + ChainWithSteps, CreateDirectiveRequest, Directive, DirectiveChain, + DirectiveListResponse, DirectiveWithChains, UpdateDirectiveRequest, +}; +use crate::db::repository::{self, RepositoryError}; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +/// List all directives for the authenticated user's owner. +#[utoipa::path( + get, + path = "/api/v1/directives", + responses( + (status = 200, description = "List of directives", body = DirectiveListResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Directives" +)] +pub async fn list_directives( + State(state): State, + Authenticated(auth): Authenticated, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::list_directives_for_owner(pool, auth.owner_id).await { + Ok(directives) => { + let total = directives.len() as i64; + Json(DirectiveListResponse { directives, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list directives: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get a directive by ID with its chains. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}", + params( + ("id" = Uuid, Path, description = "Directive ID") + ), + responses( + (status = 200, description = "Directive details with chains", body = DirectiveWithChains), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Directive 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 = "Directives" +)] +pub async fn get_directive( + State(state): State, + Authenticated(auth): Authenticated, + Path(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(); + }; + + let directive = match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get directive {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + let chains = match repository::list_chains_for_directive(pool, id).await { + Ok(c) => c, + Err(e) => { + tracing::warn!("Failed to get chains for directive {}: {}", id, e); + Vec::new() + } + }; + + Json(DirectiveWithChains { directive, chains }).into_response() +} + +/// Create a new directive. +#[utoipa::path( + post, + path = "/api/v1/directives", + request_body = CreateDirectiveRequest, + responses( + (status = 201, description = "Directive created", body = Directive), + (status = 400, description = "Invalid request", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Directives" +)] +pub async fn create_directive( + State(state): State, + Authenticated(auth): Authenticated, + 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(); + }; + + match repository::create_directive_for_owner(pool, auth.owner_id, req).await { + Ok(directive) => (StatusCode::CREATED, Json(directive)).into_response(), + Err(e) => { + tracing::error!("Failed to create directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Update an existing directive. +#[utoipa::path( + put, + path = "/api/v1/directives/{id}", + params( + ("id" = Uuid, Path, description = "Directive ID") + ), + request_body = UpdateDirectiveRequest, + responses( + (status = 200, description = "Directive updated", body = Directive), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Directive not found", body = ApiError), + (status = 409, description = "Version conflict", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Directives" +)] +pub async fn update_directive( + State(state): State, + Authenticated(auth): Authenticated, + Path(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(); + }; + + match repository::update_directive_for_owner(pool, id, auth.owner_id, req).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(RepositoryError::VersionConflict { expected, actual }) => ( + StatusCode::CONFLICT, + Json(ApiError::new( + "VERSION_CONFLICT", + format!( + "Version conflict: expected {}, actual {}", + expected, actual + ), + )), + ) + .into_response(), + Err(RepositoryError::Database(e)) => { + tracing::error!("Failed to update directive {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a directive. +#[utoipa::path( + delete, + path = "/api/v1/directives/{id}", + params( + ("id" = Uuid, Path, description = "Directive ID") + ), + responses( + (status = 204, description = "Directive deleted"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Directive 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 = "Directives" +)] +pub async fn delete_directive( + State(state): State, + Authenticated(auth): Authenticated, + Path(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(); + }; + + match repository::delete_directive_for_owner(pool, id, auth.owner_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete directive {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// List chains for a directive. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/chains", + params( + ("id" = Uuid, Path, description = "Directive ID") + ), + responses( + (status = 200, description = "List of chains", body = Vec), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Directive 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 = "Directives" +)] +pub async fn list_chains( + State(state): State, + Authenticated(auth): Authenticated, + Path(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 directive exists and belongs to owner + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get directive {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::list_chains_for_directive(pool, id).await { + Ok(chains) => Json(chains).into_response(), + Err(e) => { + tracing::error!("Failed to list chains for directive {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get a chain with its steps. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/chains/{chain_id}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("chain_id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "Chain with steps", body = ChainWithSteps), + (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 = "Directives" +)] +pub async fn get_chain( + State(state): State, + Authenticated(auth): Authenticated, + Path((id, chain_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 directive exists and belongs to owner + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get directive {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Get the chain and verify it belongs to this directive + let chains = match repository::list_chains_for_directive(pool, id).await { + Ok(c) => c, + Err(e) => { + tracing::error!("Failed to list chains: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + let chain = match chains.into_iter().find(|c| c.id == chain_id) { + Some(c) => c, + None => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + }; + + let steps = match repository::list_steps_for_chain(pool, chain_id).await { + Ok(s) => s, + Err(e) => { + tracing::warn!("Failed to get steps for chain {}: {}", chain_id, e); + Vec::new() + } + }; + + Json(ChainWithSteps { chain, steps }).into_response() +} diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index ae370c9..29cd09f 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod contract_chat; pub mod contract_daemon; pub mod contract_discuss; pub mod contracts; +pub mod directives; pub mod file_ws; pub mod files; pub mod history; diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index b7a4156..a429612 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -170,6 +170,19 @@ pub fn make_router(state: SharedState) -> Router { "/contracts/{id}/chat/history", get(contract_chat::get_contract_chat_history).delete(contract_chat::clear_contract_chat_history), ) + // Directive endpoints + .route( + "/directives", + get(directives::list_directives).post(directives::create_directive), + ) + .route( + "/directives/{id}", + get(directives::get_directive) + .put(directives::update_directive) + .delete(directives::delete_directive), + ) + .route("/directives/{id}/chains", get(directives::list_chains)) + .route("/directives/{id}/chains/{chain_id}", get(directives::get_chain)) // Contract supervisor resume endpoints .route("/contracts/{id}/supervisor/resume", post(mesh_supervisor::resume_supervisor)) .route("/contracts/{id}/supervisor/conversation/rewind", post(mesh_supervisor::rewind_conversation)) diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index a70342b..0e6912a 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -4,23 +4,25 @@ use utoipa::OpenApi; use crate::db::models::{ AddLocalRepositoryRequest, AddRemoteRepositoryRequest, BranchInfo, BranchListResponse, - BranchTaskRequest, BranchTaskResponse, ChangePhaseRequest, Contract, - ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent, ContractListResponse, - ContractRepository, ContractSummary, ContractWithRelations, CreateContractRequest, - CreateFileRequest, CreateManagedRepositoryRequest, CreateTaskRequest, Daemon, - DaemonDirectoriesResponse, DaemonDirectory, DaemonListResponse, File, FileListResponse, - FileSummary, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, - MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse, - MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, RepositoryHistoryEntry, + BranchTaskRequest, BranchTaskResponse, ChainStep, ChainWithSteps, ChangePhaseRequest, + Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent, + ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, + CreateContractRequest, CreateDirectiveRequest, CreateFileRequest, + CreateManagedRepositoryRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, + DaemonDirectory, DaemonListResponse, Directive, DirectiveChain, DirectiveListResponse, + DirectiveSummary, DirectiveWithChains, File, FileListResponse, FileSummary, + MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, + MergeSkipRequest, MergeStartRequest, MergeStatusResponse, MeshChatConversation, + MeshChatHistoryResponse, MeshChatMessageRecord, RepositoryHistoryEntry, RepositoryHistoryListResponse, RepositorySuggestionsQuery, SendMessageRequest, Task, TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry, - UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, + UpdateContractRequest, UpdateDirectiveRequest, UpdateFileRequest, UpdateTaskRequest, }; use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users}; +use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, directives, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -103,6 +105,14 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage repository_history::list_repository_history, repository_history::get_repository_suggestions, repository_history::delete_repository_history, + // Directive endpoints + directives::list_directives, + directives::get_directive, + directives::create_directive, + directives::update_directive, + directives::delete_directive, + directives::list_chains, + directives::get_chain, ), components( schemas( @@ -187,6 +197,16 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage RepositoryHistoryEntry, RepositoryHistoryListResponse, RepositorySuggestionsQuery, + // Directive schemas + Directive, + DirectiveSummary, + DirectiveListResponse, + DirectiveWithChains, + DirectiveChain, + ChainStep, + ChainWithSteps, + CreateDirectiveRequest, + UpdateDirectiveRequest, ) ), tags( @@ -197,6 +217,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage (name = "API Keys", description = "API key management for programmatic access"), (name = "Users", description = "User account management"), (name = "Settings", description = "User settings including repository history"), + (name = "Directives", description = "Directive management for autonomous goal-driven execution"), ) )] pub struct ApiDoc; -- cgit v1.2.3