diff options
Diffstat (limited to 'makima/src/server/handlers/directives.rs')
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 583 |
1 files changed, 581 insertions, 2 deletions
diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index e6ccfa3..d1edf7e 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -9,11 +9,10 @@ use axum::{ use uuid::Uuid; use crate::db::models::{ - CleanupResponse, CleanupTasksResponse, CreateDirectiveRequest, CreateTaskRequest, + CleanupResponse, CreateDirectiveRequest, CreateTaskRequest, CreateDirectiveStepRequest, Directive, DirectiveListResponse, DirectiveStep, DirectiveWithSteps, PickUpOrdersResponse, UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateGoalRequest, - UpdateOrderRequest, CreateDirectiveOrderGroupRequest, DirectiveOrderGroup, DirectiveOrderGroupListResponse, UpdateDirectiveOrderGroupRequest, OrderListResponse, @@ -1378,3 +1377,583 @@ pub async fn pick_up_orders( }) .into_response() } + +// ============================================================================= +// Directive Order Group (DOG) CRUD +// ============================================================================= + +/// List all DOGs for a directive. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/dogs", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "List of DOGs", body = DirectiveOrderGroupListResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Order Groups" +)] +pub async fn list_dogs( + 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::list_directive_order_groups(pool, id, auth.owner_id).await { + Ok(dogs) => { + let total = dogs.len() as i64; + Json(DirectiveOrderGroupListResponse { dogs, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list DOGs: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Create a new DOG for a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/dogs", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = CreateDirectiveOrderGroupRequest, + responses( + (status = 201, description = "DOG created", body = DirectiveOrderGroup), + (status = 400, description = "Invalid directive", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Order Groups" +)] +pub async fn create_dog( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CreateDirectiveOrderGroupRequest>, +) -> 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 the directive exists and belongs to this owner + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new( + "INVALID_DIRECTIVE", + "directive_id must reference a valid directive owned by you", + )), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("VALIDATION_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::create_directive_order_group(pool, id, auth.owner_id, req).await { + Ok(dog) => (StatusCode::CREATED, Json(dog)).into_response(), + Err(e) => { + tracing::error!("Failed to create DOG: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get a DOG by ID. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/dogs/{dog_id}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("dog_id" = Uuid, Path, description = "DOG ID"), + ), + responses( + (status = 200, description = "DOG details", body = DirectiveOrderGroup), + (status = 404, description = "Not found", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Order Groups" +)] +pub async fn get_dog( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, dog_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(); + }; + + let _ = id; // directive_id is in the path for REST nesting but we scope by owner_id + + match repository::get_directive_order_group(pool, dog_id, auth.owner_id).await { + Ok(Some(dog)) => Json(dog).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "DOG not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get DOG: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a DOG. +#[utoipa::path( + patch, + path = "/api/v1/directives/{id}/dogs/{dog_id}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("dog_id" = Uuid, Path, description = "DOG ID"), + ), + request_body = UpdateDirectiveOrderGroupRequest, + responses( + (status = 200, description = "DOG updated", body = DirectiveOrderGroup), + (status = 404, description = "Not found", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Order Groups" +)] +pub async fn update_dog( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, dog_id)): Path<(Uuid, Uuid)>, + Json(req): Json<UpdateDirectiveOrderGroupRequest>, +) -> 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 _ = id; // directive_id is in the path for REST nesting but we scope by owner_id + + // Validate status if provided + if let Some(ref status) = req.status { + if !["open", "in_progress", "done", "archived"].contains(&status.as_str()) { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new( + "VALIDATION_FAILED", + "status must be one of: open, in_progress, done, archived", + )), + ) + .into_response(); + } + } + + match repository::update_directive_order_group(pool, dog_id, auth.owner_id, req).await { + Ok(Some(dog)) => Json(dog).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "DOG not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update DOG: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a DOG. +#[utoipa::path( + delete, + path = "/api/v1/directives/{id}/dogs/{dog_id}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("dog_id" = Uuid, Path, description = "DOG ID"), + ), + responses( + (status = 204, description = "Deleted"), + (status = 404, description = "Not found", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Order Groups" +)] +pub async fn delete_dog( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, dog_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(); + }; + + let _ = id; // directive_id is in the path for REST nesting but we scope by owner_id + + match repository::delete_directive_order_group(pool, dog_id, auth.owner_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "DOG not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete DOG: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DELETE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// List orders belonging to a specific DOG. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/dogs/{dog_id}/orders", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("dog_id" = Uuid, Path, description = "DOG ID"), + ), + responses( + (status = 200, description = "List of orders in the DOG", body = OrderListResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Order Groups" +)] +pub async fn list_dog_orders( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, dog_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(); + }; + + let _ = id; // directive_id is in the path for REST nesting but we scope by owner_id + + match repository::list_orders_by_dog(pool, dog_id, auth.owner_id).await { + Ok(orders) => { + let total = orders.len() as i64; + Json(OrderListResponse { orders, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list orders for DOG: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Pick up orders for a specific DOG. Like the directive pick-up-orders +/// endpoint but filtered to orders belonging to the specified DOG. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/dogs/{dog_id}/pick-up-orders", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("dog_id" = Uuid, Path, description = "DOG ID"), + ), + responses( + (status = 200, description = "Orders picked up for planning", body = PickUpOrdersResponse), + (status = 404, description = "Directive or DOG not found", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Order Groups" +)] +pub async fn pick_up_dog_orders( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, dog_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 and get directive with steps + let (directive, mut steps) = + match repository::get_directive_with_steps_for_owner(pool, auth.owner_id, id).await { + Ok(Some((d, s))) => (d, s), + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get directive: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + // Verify the DOG exists and belongs to this owner + match repository::get_directive_order_group(pool, dog_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "DOG not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + // Auto-remove completed steps that were already included in a PR + if directive.pr_url.is_some() || directive.pr_branch.is_some() { + match crate::orchestration::directive::remove_already_merged_steps(pool, id).await { + Ok(count) if count > 0 => { + tracing::info!("Auto-removed {} completed steps already in PR for directive {}", count, id); + steps = match repository::list_directive_steps(pool, id).await { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to re-fetch steps after cleanup: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("REFETCH_STEPS_FAILED", &e.to_string())), + ).into_response(); + } + }; + } + Err(e) => { + tracing::warn!("Failed to auto-remove merged steps for directive {}: {}", id, e); + } + _ => {} + } + } + + // Reconcile existing orders + match repository::reconcile_directive_orders(pool, auth.owner_id, id).await { + Ok(count) => { + if count > 0 { + tracing::info!("Reconciled {} orders for directive {}", count, id); + } + } + Err(e) => { + tracing::warn!("Failed to reconcile directive orders: {}", e); + } + } + + // Fetch available orders filtered to this DOG + let orders = match repository::get_available_orders_for_dog_pickup(pool, auth.owner_id, id, dog_id).await { + Ok(o) => o, + Err(e) => { + tracing::error!("Failed to fetch available orders for DOG: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("FETCH_ORDERS_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + // If no orders available, return early + if orders.is_empty() { + return Json(PickUpOrdersResponse { + message: "No orders available to plan for this DOG".to_string(), + order_count: 0, + task_id: None, + }) + .into_response(); + } + + let order_count = orders.len() as i64; + let order_ids: Vec<Uuid> = orders.iter().map(|o| o.id).collect(); + + // Get generation and goal history for the planning prompt + let generation = + match repository::get_directive_max_generation(pool, id).await { + Ok(g) => g + 1, + Err(e) => { + tracing::error!("Failed to get max generation: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GENERATION_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + let goal_history = match repository::get_directive_goal_history(pool, id, 3).await { + Ok(h) => h, + Err(e) => { + tracing::warn!("Failed to get goal history: {}", e); + vec![] + } + }; + + // Build the specialized planning prompt + let plan = build_order_pickup_prompt(&directive, &steps, &orders, generation, &goal_history); + + // Link orders to the directive + if let Err(e) = + repository::bulk_link_orders_to_directive(pool, auth.owner_id, &order_ids, id).await + { + tracing::error!("Failed to link orders to directive: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LINK_ORDERS_FAILED", &e.to_string())), + ) + .into_response(); + } + + // Mark picked-up orders as in_progress + if let Err(e) = + repository::bulk_update_order_status(pool, auth.owner_id, &order_ids, "in_progress").await + { + tracing::warn!("Failed to update order status to in_progress: {}", e); + } + + // Create the planning task + let req = CreateTaskRequest { + contract_id: None, + name: format!("Pick up DOG orders: {}", directive.title), + description: Some("Directive order group pickup planning task".to_string()), + plan, + parent_task_id: None, + is_supervisor: false, + priority: 0, + repository_url: directive.repository_url.clone(), + base_branch: directive.base_branch.clone(), + target_branch: None, + merge_mode: None, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + checkpoint_sha: None, + branched_from_task_id: None, + conversation_history: None, + supervisor_worktree_task_id: None, + directive_id: Some(directive.id), + directive_step_id: None, + }; + + let task = match repository::create_task_for_owner(pool, auth.owner_id, req).await { + Ok(t) => t, + Err(e) => { + tracing::error!("Failed to create DOG pickup planning task: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_TASK_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + // Assign as orchestrator task + if let Err(e) = repository::assign_orchestrator_task(pool, id, task.id).await { + tracing::error!("Failed to assign orchestrator task: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("ASSIGN_TASK_FAILED", &e.to_string())), + ) + .into_response(); + } + + // Cancel old planning tasks + let cancelled = repository::cancel_old_planning_tasks(pool, id, task.id).await; + if let Ok(count) = cancelled { + if count > 0 { + tracing::info!( + directive_id = %id, + cancelled_count = count, + "Cancelled old planning tasks superseded by DOG order pickup" + ); + } + } + + // Set directive to active if draft/idle/paused + match directive.status.as_str() { + "draft" | "idle" | "paused" => { + if let Err(e) = repository::set_directive_status(pool, auth.owner_id, id, "active").await + { + tracing::warn!("Failed to set directive status to active: {}", e); + } + } + _ => {} + } + + // Advance ready steps + let _ = repository::advance_directive_ready_steps(pool, id).await; + + Json(PickUpOrdersResponse { + message: format!("Planning {} orders from DOG", order_count), + order_count, + task_id: Some(task.id), + }) + .into_response() +} |
