summaryrefslogtreecommitdiff
path: root/makima/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src')
-rw-r--r--makima/src/db/models.rs30
-rw-r--r--makima/src/db/repository.rs60
-rw-r--r--makima/src/orchestration/directive.rs94
-rw-r--r--makima/src/server/handlers/orders.rs85
-rw-r--r--makima/src/server/mod.rs1
-rw-r--r--makima/src/server/openapi.rs7
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(&current.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,