diff options
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/db/models.rs | 30 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 60 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 94 | ||||
| -rw-r--r-- | makima/src/server/handlers/orders.rs | 85 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 7 |
6 files changed, 92 insertions, 185 deletions
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index bfed942..2951159 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2880,8 +2880,8 @@ pub struct UpdateDirectiveStepRequest { // ============================================================================= /// An order — a card-based work item (feature, bug, spike, chore, improvement) -/// similar to GitHub Issues or Linear cards. Orders can be linked to directives -/// or contracts for execution. +/// similar to GitHub Issues or Linear cards. Orders are linked to directives +/// for execution. #[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct Order { @@ -2901,8 +2901,8 @@ pub struct Order { pub directive_id: Option<Uuid>, /// Linked directive step (optional) pub directive_step_id: Option<Uuid>, - /// Linked contract (optional) - pub contract_id: Option<Uuid>, + /// Denormalized directive name for searchability (auto-populated by DB trigger) + pub directive_name: Option<String>, /// Repository context pub repository_url: Option<String>, pub created_at: DateTime<Utc>, @@ -2920,8 +2920,8 @@ pub struct CreateOrderRequest { pub order_type: Option<String>, #[serde(default = "default_empty_labels")] pub labels: serde_json::Value, - pub directive_id: Option<Uuid>, - pub contract_id: Option<Uuid>, + /// Directive ID is required for new orders. + pub directive_id: Uuid, pub repository_url: Option<String>, } @@ -2942,7 +2942,6 @@ pub struct UpdateOrderRequest { pub labels: Option<serde_json::Value>, pub directive_id: Option<Uuid>, pub directive_step_id: Option<Uuid>, - pub contract_id: Option<Uuid>, pub repository_url: Option<String>, } @@ -2967,8 +2966,8 @@ pub struct OrderListQuery { pub priority: Option<String>, /// Filter by linked directive ID pub directive_id: Option<Uuid>, - /// Filter by linked contract ID - pub contract_id: Option<Uuid>, + /// Text search across title, description, and directive_name (case-insensitive) + pub search: Option<String>, } /// Request body for linking an order to a directive. @@ -2978,17 +2977,4 @@ pub struct LinkDirectiveRequest { pub directive_id: Uuid, } -/// Request body for linking an order to a contract. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct LinkContractRequest { - pub contract_id: Uuid, -} - -/// Request body for converting an order to a directive step. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ConvertToStepRequest { - pub directive_id: Uuid, -} diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 2ef3fbc..cb6a0c6 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -6032,8 +6032,8 @@ pub async fn create_order( sqlx::query_as::<_, Order>( r#" - INSERT INTO orders (owner_id, title, description, priority, status, order_type, labels, directive_id, contract_id, repository_url) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + INSERT INTO orders (owner_id, title, description, priority, status, order_type, labels, directive_id, repository_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING * "#, ) @@ -6045,7 +6045,6 @@ pub async fn create_order( .bind(order_type) .bind(&req.labels) .bind(req.directive_id) - .bind(req.contract_id) .bind(&req.repository_url) .fetch_one(pool) .await @@ -6059,7 +6058,7 @@ pub async fn list_orders( type_filter: Option<&str>, priority_filter: Option<&str>, directive_id_filter: Option<Uuid>, - contract_id_filter: Option<Uuid>, + search_filter: Option<&str>, ) -> Result<Vec<Order>, sqlx::Error> { // Build dynamic query with optional filters let mut query = String::from("SELECT * FROM orders WHERE owner_id = $1"); @@ -6081,8 +6080,11 @@ pub async fn list_orders( query.push_str(&format!(" AND directive_id = ${}", param_idx)); param_idx += 1; } - if contract_id_filter.is_some() { - query.push_str(&format!(" AND contract_id = ${}", param_idx)); + if search_filter.is_some() { + query.push_str(&format!( + " AND (title ILIKE ${p} OR description ILIKE ${p} OR directive_name ILIKE ${p})", + p = param_idx + )); let _ = param_idx; // suppress unused warning } query.push_str(" ORDER BY created_at DESC"); @@ -6101,8 +6103,8 @@ pub async fn list_orders( if let Some(d) = directive_id_filter { q = q.bind(d); } - if let Some(c) = contract_id_filter { - q = q.bind(c); + if let Some(s) = search_filter { + q = q.bind(format!("%{}%", s)); } q.fetch_all(pool).await @@ -6151,7 +6153,6 @@ pub async fn update_order( let labels = req.labels.as_ref().unwrap_or(¤t.labels); let directive_id = req.directive_id.or(current.directive_id); let directive_step_id = req.directive_step_id.or(current.directive_step_id); - let contract_id = req.contract_id.or(current.contract_id); let repository_url = req.repository_url.as_deref().or(current.repository_url.as_deref()); sqlx::query_as::<_, Order>( @@ -6159,7 +6160,7 @@ pub async fn update_order( UPDATE orders SET title = $3, description = $4, priority = $5, status = $6, order_type = $7, labels = $8, directive_id = $9, directive_step_id = $10, - contract_id = $11, repository_url = $12, updated_at = NOW() + repository_url = $11, updated_at = NOW() WHERE id = $1 AND owner_id = $2 RETURNING * "#, @@ -6174,7 +6175,6 @@ pub async fn update_order( .bind(labels) .bind(directive_id) .bind(directive_step_id) - .bind(contract_id) .bind(repository_url) .fetch_optional(pool) .await @@ -6219,36 +6219,13 @@ pub async fn link_order_to_directive( .await } -/// Link an order to a contract. -pub async fn link_order_to_contract( - pool: &PgPool, - owner_id: Uuid, - order_id: Uuid, - contract_id: Uuid, -) -> Result<Option<Order>, sqlx::Error> { - sqlx::query_as::<_, Order>( - r#" - UPDATE orders - SET contract_id = $3, updated_at = NOW() - WHERE id = $1 AND owner_id = $2 - RETURNING * - "#, - ) - .bind(order_id) - .bind(owner_id) - .bind(contract_id) - .fetch_optional(pool) - .await -} - /// Convert an order to a directive step. Creates a new DirectiveStep from the order's -/// title and description, links the order to both the directive and the new step, -/// and returns the created step. +/// title and description, links the order to the new step, and returns the created step. +/// Uses the order's existing directive_id (which is required for new orders). pub async fn convert_order_to_step( pool: &PgPool, owner_id: Uuid, order_id: Uuid, - directive_id: Uuid, ) -> Result<Option<DirectiveStep>, sqlx::Error> { // Verify the order exists and belongs to this owner let order = sqlx::query_as::<_, Order>( @@ -6264,6 +6241,12 @@ pub async fn convert_order_to_step( None => return Ok(None), }; + // Get the directive_id from the order (required for new orders, but legacy data may have NULL) + let directive_id = match order.directive_id { + Some(id) => id, + None => return Ok(None), + }; + // Verify the directive exists and belongs to this owner let directive = sqlx::query_as::<_, Directive>( r#"SELECT * FROM directives WHERE id = $1 AND owner_id = $2"#, @@ -6301,17 +6284,16 @@ pub async fn convert_order_to_step( .fetch_one(pool) .await?; - // Link the order to the directive and the new step + // Link the order to the new step sqlx::query( r#" UPDATE orders - SET directive_id = $3, directive_step_id = $4, updated_at = NOW() + SET directive_step_id = $3, updated_at = NOW() WHERE id = $1 AND owner_id = $2 "#, ) .bind(order_id) .bind(owner_id) - .bind(directive_id) .bind(step.id) .execute(pool) .await?; diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 736715d..020c2e4 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -580,20 +580,11 @@ impl DirectiveOrchestrator { for directive in directives { if let Err(e) = async { - // Atomically claim this directive for completion using a placeholder. - // This prevents a concurrent tick from also spawning a completion task. - let placeholder_id = Uuid::new_v4(); - let claimed = repository::claim_directive_for_completion( - &self.pool, - directive.id, - placeholder_id, - ) - .await?; - - if !claimed { + // Skip if already claimed (completion_task_id is set) + if directive.completion_task_id.is_some() { tracing::debug!( directive_id = %directive.id, - "Directive already claimed for completion — skipping" + "Directive already has a completion task — skipping" ); return Ok::<(), anyhow::Error>(()); } @@ -606,7 +597,6 @@ impl DirectiveOrchestrator { let step_tasks = repository::get_completed_step_tasks(&self.pool, directive.id).await?; if step_tasks.is_empty() { - let _ = repository::clear_completion_task(&self.pool, directive.id).await; return Ok(()); } @@ -640,6 +630,7 @@ impl DirectiveOrchestrator { base_branch, ); + // Create the task FIRST so we have a real task ID for the FK match self .spawn_completion_task( directive.id, @@ -652,6 +643,22 @@ impl DirectiveOrchestrator { .await { Ok(task_id) => { + // Atomically claim with the REAL task ID (satisfies FK constraint) + let claimed = repository::claim_directive_for_completion( + &self.pool, + directive.id, + task_id, + ) + .await?; + + if !claimed { + tracing::debug!( + directive_id = %directive.id, + "Directive already claimed for completion — task will be orphaned" + ); + return Ok(()); + } + let update = crate::db::models::UpdateDirectiveRequest { pr_branch: Some(directive_branch.clone()), ..Default::default() @@ -663,15 +670,13 @@ impl DirectiveOrchestrator { update, ) .await; - repository::assign_completion_task(&self.pool, directive.id, task_id).await?; } Err(e) => { tracing::warn!( directive_id = %directive.id, error = %e, - "Failed to spawn completion task — releasing claim" + "Failed to spawn completion task" ); - let _ = repository::clear_completion_task(&self.pool, directive.id).await; } } Ok(()) @@ -773,15 +778,8 @@ impl DirectiveOrchestrator { for directive in verify_directives { if let Err(e) = async { - let placeholder_id = Uuid::new_v4(); - let claimed = repository::claim_directive_for_completion( - &self.pool, - directive.id, - placeholder_id, - ) - .await?; - - if !claimed { + // Skip if already claimed + if directive.completion_task_id.is_some() { return Ok::<(), anyhow::Error>(()); } @@ -795,6 +793,7 @@ impl DirectiveOrchestrator { let base_branch = directive.base_branch.as_deref().unwrap_or("main"); let prompt = build_verification_prompt(&directive, pr_branch, base_branch); + // Create the task FIRST so we have a real task ID for the FK match self .spawn_completion_task( directive.id, @@ -807,15 +806,27 @@ impl DirectiveOrchestrator { .await { Ok(task_id) => { - repository::assign_completion_task(&self.pool, directive.id, task_id).await?; + // Atomically claim with the REAL task ID (satisfies FK constraint) + let claimed = repository::claim_directive_for_completion( + &self.pool, + directive.id, + task_id, + ) + .await?; + + if !claimed { + tracing::debug!( + directive_id = %directive.id, + "Directive already claimed for verification — task will be orphaned" + ); + } } Err(e) => { tracing::warn!( directive_id = %directive.id, error = %e, - "Failed to spawn verification task — releasing claim" + "Failed to spawn verification task" ); - let _ = repository::clear_completion_task(&self.pool, directive.id).await; } } Ok(()) @@ -906,9 +917,9 @@ impl DirectiveOrchestrator { /// This is the public entry point used by both the orchestrator tick and the /// manual "create PR" API handler. It encapsulates the full flow: /// 1. Validate the directive has completed step tasks -/// 2. Claim the directive for completion (returns error if already claimed) -/// 3. Build branch names and prompt -/// 4. Spawn the completion task and assign it +/// 2. Create the completion task (so we have a real task ID) +/// 3. Atomically claim the directive for completion with the real task ID +/// 4. Dispatch the task to a daemon /// /// Returns the created task ID on success. pub async fn trigger_completion_task( @@ -931,14 +942,6 @@ pub async fn trigger_completion_task( anyhow::bail!("No completed steps with tasks found"); } - // Claim for completion - let placeholder_id = Uuid::new_v4(); - let claimed = - repository::claim_directive_for_completion(pool, directive_id, placeholder_id).await?; - if !claimed { - anyhow::bail!("Directive already claimed for completion"); - } - let base_branch = directive.base_branch.as_deref().unwrap_or("main"); let directive_branch = format!( @@ -970,7 +973,7 @@ pub async fn trigger_completion_task( format!("PR: {}", directive.title) }; - // Create the completion task + // Create the completion task FIRST so we have a real task ID for the FK let req = CreateTaskRequest { contract_id: None, name: task_name, @@ -997,6 +1000,14 @@ pub async fn trigger_completion_task( let task = repository::create_task_for_owner(pool, owner_id, req).await?; + // Atomically claim the directive with the REAL task ID (satisfies FK constraint). + // This prevents concurrent ticks from also spawning a completion task. + let claimed = + repository::claim_directive_for_completion(pool, directive_id, task.id).await?; + if !claimed { + anyhow::bail!("Directive already claimed for completion"); + } + // Update pr_branch on the directive let update = crate::db::models::UpdateDirectiveRequest { pr_branch: Some(directive_branch), @@ -1004,9 +1015,6 @@ pub async fn trigger_completion_task( }; let _ = repository::update_directive_for_owner(pool, owner_id, directive_id, update).await; - // Assign the real task as the completion task - repository::assign_completion_task(pool, directive_id, task.id).await?; - // Try to dispatch to a daemon if let Some(daemon_id) = state.find_alternative_daemon(owner_id, &[]) { let update_req = crate::db::models::UpdateTaskRequest { diff --git a/makima/src/server/handlers/orders.rs b/makima/src/server/handlers/orders.rs index c43c406..cddf6a6 100644 --- a/makima/src/server/handlers/orders.rs +++ b/makima/src/server/handlers/orders.rs @@ -11,7 +11,7 @@ use axum::{ use uuid::Uuid; use crate::db::models::{ - ConvertToStepRequest, CreateOrderRequest, DirectiveStep, LinkContractRequest, + CreateOrderRequest, DirectiveStep, LinkDirectiveRequest, Order, OrderListQuery, OrderListResponse, UpdateOrderRequest, }; use crate::db::repository; @@ -32,7 +32,7 @@ use crate::server::state::SharedState; ("type" = Option<String>, Query, description = "Filter by order type"), ("priority" = Option<String>, Query, description = "Filter by priority"), ("directive_id" = Option<Uuid>, Query, description = "Filter by directive ID"), - ("contract_id" = Option<Uuid>, Query, description = "Filter by contract ID"), + ("search" = Option<String>, Query, description = "Text search across title, description, and directive name"), ), responses( (status = 200, description = "List of orders", body = OrderListResponse), @@ -62,7 +62,7 @@ pub async fn list_orders( query.order_type.as_deref(), query.priority.as_deref(), query.directive_id, - query.contract_id, + query.search.as_deref(), ) .await { @@ -327,80 +327,13 @@ pub async fn link_to_directive( } } -/// Link an order to a contract. -#[utoipa::path( - post, - path = "/api/v1/orders/{id}/link-contract", - params(("id" = Uuid, Path, description = "Order ID")), - request_body = LinkContractRequest, - responses( - (status = 200, description = "Order linked to contract", body = Order), - (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 = "Orders" -)] -pub async fn link_to_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<LinkContractRequest>, -) -> 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 contract exists and belongs to this owner - match repository::get_contract_for_owner(pool, auth.owner_id, req.contract_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("GET_FAILED", &e.to_string())), - ) - .into_response(); - } - } - - match repository::link_order_to_contract(pool, auth.owner_id, id, req.contract_id).await { - Ok(Some(order)) => Json(order).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Order not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to link order to contract: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("LINK_FAILED", &e.to_string())), - ) - .into_response() - } - } -} - /// Convert an order to a directive step. -/// Creates a new step in the specified directive using the order's title/description, -/// and links the order to both the directive and the new step. +/// Creates a new step in the order's linked directive using the order's title/description, +/// and links the order to the new step. The order must have a directive_id set. #[utoipa::path( post, path = "/api/v1/orders/{id}/convert-to-step", params(("id" = Uuid, Path, description = "Order ID")), - request_body = ConvertToStepRequest, responses( (status = 201, description = "Directive step created from order", body = DirectiveStep), (status = 404, description = "Order or directive not found", body = ApiError), @@ -414,7 +347,6 @@ pub async fn convert_to_step( State(state): State<SharedState>, Authenticated(auth): Authenticated, Path(id): Path<Uuid>, - Json(req): Json<ConvertToStepRequest>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( @@ -424,11 +356,14 @@ pub async fn convert_to_step( .into_response(); }; - match repository::convert_order_to_step(pool, auth.owner_id, id, req.directive_id).await { + match repository::convert_order_to_step(pool, auth.owner_id, id).await { Ok(Some(step)) => (StatusCode::CREATED, Json(step)).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Order or directive not found")), + Json(ApiError::new( + "NOT_FOUND", + "Order not found or has no linked directive", + )), ) .into_response(), Err(e) => { diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index c963618..6bd5ae0 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -252,7 +252,6 @@ pub fn make_router(state: SharedState) -> Router { .delete(orders::delete_order), ) .route("/orders/{id}/link-directive", post(orders::link_to_directive)) - .route("/orders/{id}/link-contract", post(orders::link_to_contract)) .route("/orders/{id}/convert-to-step", post(orders::convert_to_step)) // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 9c6463a..87ca9c5 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -8,14 +8,14 @@ use crate::db::models::{ ChangePhaseRequest, Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, - CleanupTasksResponse, ConvertToStepRequest, + CleanupTasksResponse, CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, CreateManagedRepositoryRequest, CreateOrderRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse, DirectiveStep, DirectiveSummary, DirectiveWithSteps, File, FileListResponse, FileSummary, - LinkContractRequest, LinkDirectiveRequest, + LinkDirectiveRequest, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse, MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, @@ -137,7 +137,6 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage orders::update_order, orders::delete_order, orders::link_to_directive, - orders::link_to_contract, orders::convert_to_step, // Repository history/settings endpoints repository_history::list_repository_history, @@ -243,8 +242,6 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage CreateOrderRequest, UpdateOrderRequest, LinkDirectiveRequest, - LinkContractRequest, - ConvertToStepRequest, // Repository history schemas RepositoryHistoryEntry, RepositoryHistoryListResponse, |
