diff options
Diffstat (limited to 'makima/src/server/handlers')
| -rw-r--r-- | makima/src/server/handlers/contract_chat.rs | 29 | ||||
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 2 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 841 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 8 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_chat.rs | 2 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_daemon.rs | 17 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_supervisor.rs | 2 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/transcript_analysis.rs | 4 |
9 files changed, 885 insertions, 21 deletions
diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs index 8153093..2c7a800 100644 --- a/makima/src/server/handlers/contract_chat.rs +++ b/makima/src/server/handlers/contract_chat.rs @@ -1368,6 +1368,8 @@ async fn handle_contract_request( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; match repository::create_task_for_owner(pool, owner_id, create_req).await { @@ -1465,6 +1467,8 @@ async fn handle_contract_request( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; match repository::create_task_for_owner(pool, owner_id, create_req).await { @@ -2217,6 +2221,8 @@ async fn handle_contract_request( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; match repository::create_task_for_owner(pool, owner_id, create_req).await { @@ -2737,6 +2743,8 @@ async fn handle_contract_request( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; if repository::create_task_for_owner(pool, owner_id, task_req).await.is_ok() { @@ -2766,27 +2774,6 @@ async fn handle_contract_request( } - // Chain directive tools - TEMPORARILY DISABLED - // These tools will be reimplemented using the new directive system. - // See the orchestration module for the new implementation. - ContractToolRequest::CreateChainFromDirective { .. } | - ContractToolRequest::AddChainContract { .. } | - ContractToolRequest::SetChainDependencies { .. } | - ContractToolRequest::ModifyChainContract { .. } | - ContractToolRequest::RemoveChainContract { .. } | - ContractToolRequest::PreviewChainDag | - ContractToolRequest::ValidateChainDirective | - ContractToolRequest::FinalizeChainDirective { .. } | - ContractToolRequest::GetChainStatus | - ContractToolRequest::GetUncoveredRequirements | - ContractToolRequest::EvaluateContractCompletion { .. } | - ContractToolRequest::RequestRework { .. } => { - ContractRequestResult { - success: false, - message: "Chain directive tools are temporarily disabled. The directive system is being reimplemented.".to_string(), - data: None, - } - } } } diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs index dc15923..bdd4d40 100644 --- a/makima/src/server/handlers/contracts.rs +++ b/makima/src/server/handlers/contracts.rs @@ -369,6 +369,8 @@ pub async fn create_contract( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Supervisor uses its own worktree + directive_id: None, + directive_step_id: None, }; match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await { diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs new file mode 100644 index 0000000..d48ff74 --- /dev/null +++ b/makima/src/server/handlers/directives.rs @@ -0,0 +1,841 @@ +//! HTTP handlers for directive CRUD and DAG progression. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +use crate::db::models::{ + CreateDirectiveRequest, CreateDirectiveStepRequest, Directive, DirectiveListResponse, + DirectiveStep, DirectiveWithSteps, UpdateDirectiveRequest, UpdateDirectiveStepRequest, + UpdateGoalRequest, +}; +use crate::db::repository; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +// ============================================================================= +// Directive CRUD +// ============================================================================= + +/// List all directives for the authenticated user. +#[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), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn list_directives( + State(state): State<SharedState>, + 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("LIST_FAILED", &e.to_string())), + ) + .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 = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn create_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(req): Json<CreateDirectiveRequest>, +) -> 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("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get a directive with all its steps. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Directive with steps", body = DirectiveWithSteps), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn get_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_directive_with_steps_for_owner(pool, auth.owner_id, id).await { + Ok(Some((directive, steps))) => { + Json(DirectiveWithSteps { directive, steps }).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a 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 = 404, description = "Not found", body = ApiError), + (status = 409, description = "Version conflict", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn update_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateDirectiveRequest>, +) -> 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, auth.owner_id, 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(repository::RepositoryError::VersionConflict { expected, actual }) => ( + StatusCode::CONFLICT, + Json(ApiError::new( + "VERSION_CONFLICT", + &format!("Expected version {}, but current is {}", expected, actual), + )), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &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 = "Deleted"), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn delete_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::delete_directive_for_owner(pool, auth.owner_id, 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: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DELETE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Step CRUD +// ============================================================================= + +/// Create a step in a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = CreateDirectiveStepRequest, + responses( + (status = 201, description = "Step created", body = DirectiveStep), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn create_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CreateDirectiveStepRequest>, +) -> 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 ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::create_directive_step(pool, id, req).await { + Ok(step) => (StatusCode::CREATED, Json(step)).into_response(), + Err(e) => { + tracing::error!("Failed to create step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Batch create steps in a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps/batch", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = Vec<CreateDirectiveStepRequest>, + responses( + (status = 201, description = "Steps created", body = Vec<DirectiveStep>), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn batch_create_steps( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(steps): Json<Vec<CreateDirectiveStepRequest>>, +) -> 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 ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::batch_create_directive_steps(pool, id, steps).await { + Ok(created) => (StatusCode::CREATED, Json(created)).into_response(), + Err(e) => { + tracing::error!("Failed to batch create steps: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a step. +#[utoipa::path( + put, + path = "/api/v1/directives/{id}/steps/{step_id}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + request_body = UpdateDirectiveStepRequest, + responses( + (status = 200, description = "Step updated", body = DirectiveStep), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn update_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, + Json(req): Json<UpdateDirectiveStepRequest>, +) -> 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 ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::update_directive_step(pool, step_id, req).await { + Ok(Some(step)) => Json(step).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a step. +#[utoipa::path( + delete, + path = "/api/v1/directives/{id}/steps/{step_id}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + responses( + (status = 204, description = "Deleted"), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn delete_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::delete_directive_step(pool, step_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DELETE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Directive Lifecycle Actions +// ============================================================================= + +/// Start a directive: sets status=active, advances ready steps. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/start", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Directive started", body = DirectiveWithSteps), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn start_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Set to active + match repository::set_directive_status(pool, auth.owner_id, id, "active").await { + Ok(Some(directive)) => { + // Advance ready steps + let _ = repository::advance_directive_ready_steps(pool, id).await; + let steps = repository::list_directive_steps(pool, id).await.unwrap_or_default(); + Json(DirectiveWithSteps { directive, steps }).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to start directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("START_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Pause a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/pause", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Directive paused", body = Directive), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn pause_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::set_directive_status(pool, auth.owner_id, id, "paused").await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("PAUSE_FAILED", &e.to_string())), + ) + .into_response(), + } +} + +/// Advance a directive: find newly-ready steps. If all steps done, set idle. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/advance", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Advance result", body = DirectiveWithSteps), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn advance_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + let directive = match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(d)) => d, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + // Advance ready steps + let _ = repository::advance_directive_ready_steps(pool, id).await; + + // Check if idle + let _ = repository::check_directive_idle(pool, id).await; + + // Return updated state + let directive = match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(d)) => d, + _ => directive, + }; + let steps = repository::list_directive_steps(pool, id).await.unwrap_or_default(); + Json(DirectiveWithSteps { directive, steps }).into_response() +} + +/// Mark a step as completed. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps/{step_id}/complete", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + responses( + (status = 200, description = "Step completed", body = DirectiveStep), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn complete_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + step_status_change(state, auth, id, step_id, "completed").await +} + +/// Mark a step as failed. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps/{step_id}/fail", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + responses( + (status = 200, description = "Step failed", body = DirectiveStep), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn fail_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + step_status_change(state, auth, id, step_id, "failed").await +} + +/// Mark a step as skipped. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps/{step_id}/skip", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + responses( + (status = 200, description = "Step skipped", body = DirectiveStep), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn skip_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + step_status_change(state, auth, id, step_id, "skipped").await +} + +/// Helper for step status changes. +async fn step_status_change( + state: SharedState, + auth: crate::server::auth::AuthenticatedUser, + directive_id: Uuid, + step_id: Uuid, + new_status: &str, +) -> 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 ownership + match repository::get_directive_for_owner(pool, auth.owner_id, directive_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + let req = UpdateDirectiveStepRequest { + status: Some(new_status.to_string()), + ..Default::default() + }; + + match repository::update_directive_step(pool, step_id, req).await { + Ok(Some(step)) => { + // After step status change, advance the DAG + let _ = repository::advance_directive_ready_steps(pool, directive_id).await; + let _ = repository::check_directive_idle(pool, directive_id).await; + Json(step).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update step status: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a directive's goal (triggers re-planning). +#[utoipa::path( + put, + path = "/api/v1/directives/{id}/goal", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = UpdateGoalRequest, + responses( + (status = 200, description = "Goal updated", body = Directive), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn update_goal( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateGoalRequest>, +) -> 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_goal(pool, auth.owner_id, id, &req.goal).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update goal: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index fe9ffc0..310bec8 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -2626,6 +2626,8 @@ pub async fn reassign_task( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; let new_task = match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { @@ -3402,6 +3404,8 @@ pub async fn fork_task( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; let new_task = match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { @@ -3560,6 +3564,8 @@ pub async fn resume_from_checkpoint( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; let new_task = match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { @@ -3896,6 +3902,8 @@ pub async fn branch_task( branched_from_task_id: Some(source_task_id), conversation_history, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; let task = match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { diff --git a/makima/src/server/handlers/mesh_chat.rs b/makima/src/server/handlers/mesh_chat.rs index a6a3a3c..cf56ab6 100644 --- a/makima/src/server/handlers/mesh_chat.rs +++ b/makima/src/server/handlers/mesh_chat.rs @@ -1021,6 +1021,8 @@ async fn handle_mesh_request( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; match repository::create_task_for_owner(pool, owner_id, create_req).await { diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index 87b5e44..2ea7805 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -1303,6 +1303,23 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re }), ).await; + // Auto-advance directive DAG when a directive step task completes + if let Some(step_id) = updated_task.directive_step_id { + let step_status = if updated_task.status == "done" { "completed" } else { "failed" }; + let step_update = crate::db::models::UpdateDirectiveStepRequest { + status: Some(step_status.to_string()), + ..Default::default() + }; + let _ = repository::update_directive_step(&pool, step_id, step_update).await; + + if let Some(directive_id) = updated_task.directive_id { + // Advance newly-ready steps in the DAG + let _ = repository::advance_directive_ready_steps(&pool, directive_id).await; + // Check if all steps are done → set directive to idle + let _ = repository::check_directive_idle(&pool, directive_id).await; + } + } + } Ok(None) => { tracing::warn!( diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs index 09758bb..8bf2534 100644 --- a/makima/src/server/handlers/mesh_supervisor.rs +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -629,6 +629,8 @@ pub async fn spawn_task( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id, + directive_id: None, + directive_step_id: None, }; // Create task in DB 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/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs index 62c65a6..9261c0c 100644 --- a/makima/src/server/handlers/transcript_analysis.rs +++ b/makima/src/server/handlers/transcript_analysis.rs @@ -370,6 +370,8 @@ pub async fn create_contract_from_analysis( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await { @@ -540,6 +542,8 @@ pub async fn update_contract_from_analysis( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await { |
