diff options
| author | soryu <soryu@soryu.co> | 2026-02-09 00:11:51 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-09 00:11:51 +0000 |
| commit | 8c23b3ab6f7fabca01b0468911bae073aa5ced32 (patch) | |
| tree | f50159aee13b13f0b55618ac09e9be1f89a41bb2 /makima/src/server | |
| parent | 3662b334dfd68cfdf00ed44ae88927c2e1b2aabe (diff) | |
| download | soryu-8c23b3ab6f7fabca01b0468911bae073aa5ced32.tar.gz soryu-8c23b3ab6f7fabca01b0468911bae073aa5ced32.zip | |
Add new directive mechanism v3
Diffstat (limited to 'makima/src/server')
| -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 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 26 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 39 |
11 files changed, 945 insertions, 26 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 { diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index b7a4156..9e1ee50 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; @@ -212,6 +212,30 @@ pub fn make_router(state: SharedState) -> Router { "/contracts/{id}/tasks/{task_id}", post(contracts::add_task_to_contract).delete(contracts::remove_task_from_contract), ) + // 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}/steps", post(directives::create_step)) + .route("/directives/{id}/steps/batch", post(directives::batch_create_steps)) + .route( + "/directives/{id}/steps/{step_id}", + put(directives::update_step).delete(directives::delete_step), + ) + .route("/directives/{id}/start", post(directives::start_directive)) + .route("/directives/{id}/pause", post(directives::pause_directive)) + .route("/directives/{id}/advance", post(directives::advance_directive)) + .route("/directives/{id}/steps/{step_id}/complete", post(directives::complete_step)) + .route("/directives/{id}/steps/{step_id}/fail", post(directives::fail_step)) + .route("/directives/{id}/steps/{step_id}/skip", post(directives::skip_step)) + .route("/directives/{id}/goal", put(directives::update_goal)) // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) // Contract type templates (built-in only) diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 0b6bfba..4e3b85b 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -8,9 +8,10 @@ use crate::db::models::{ ChangePhaseRequest, Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, - CreateContractRequest, CreateFileRequest, + CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, CreateManagedRepositoryRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, - DaemonDirectory, DaemonListResponse, + DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse, DirectiveStep, + DirectiveSummary, DirectiveWithSteps, File, FileListResponse, FileSummary, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse, MeshChatConversation, @@ -18,13 +19,14 @@ use crate::db::models::{ RepositoryHistoryListResponse, RepositorySuggestionsQuery, SendMessageRequest, Task, TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry, - UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, + UpdateContractRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest, + UpdateFileRequest, UpdateGoalRequest, 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,23 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage contract_chat::clear_contract_chat_history, // Contract discuss endpoint contract_discuss::discuss_contract_handler, + // Directive endpoints + directives::list_directives, + directives::create_directive, + directives::get_directive, + directives::update_directive, + directives::delete_directive, + directives::create_step, + directives::batch_create_steps, + directives::update_step, + directives::delete_step, + directives::start_directive, + directives::pause_directive, + directives::advance_directive, + directives::complete_step, + directives::fail_step, + directives::skip_step, + directives::update_goal, // Repository history/settings endpoints repository_history::list_repository_history, repository_history::get_repository_suggestions, @@ -187,6 +206,17 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage AddLocalRepositoryRequest, CreateManagedRepositoryRequest, ChangePhaseRequest, + // Directive schemas + Directive, + DirectiveStep, + DirectiveWithSteps, + DirectiveSummary, + DirectiveListResponse, + CreateDirectiveRequest, + UpdateDirectiveRequest, + UpdateGoalRequest, + CreateDirectiveStepRequest, + UpdateDirectiveStepRequest, // Repository history schemas RepositoryHistoryEntry, RepositoryHistoryListResponse, @@ -200,6 +230,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage (name = "Contracts", description = "Contract management with workflow phases"), (name = "API Keys", description = "API key management for programmatic access"), (name = "Users", description = "User account management"), + (name = "Directives", description = "Directive management with DAG-based step progression"), (name = "Settings", description = "User settings including repository history"), ) )] |
