summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/server/handlers')
-rw-r--r--makima/src/server/handlers/mesh.rs10
-rw-r--r--makima/src/server/handlers/mesh_supervisor.rs90
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/handlers/orders.rs443
4 files changed, 518 insertions, 26 deletions
diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs
index eb87e17..c840676 100644
--- a/makima/src/server/handlers/mesh.rs
+++ b/makima/src/server/handlers/mesh.rs
@@ -1070,17 +1070,19 @@ pub async fn send_message(
}
};
- // Check if task is running (except for AUTH_CODE messages and supervisor tasks)
- // Supervisor tasks can receive messages even when not running - daemon will respawn Claude
+ // Check if task is in a state that can receive messages
+ // Allow "running" and "starting" (to handle race between status update and message send)
+ // Also allow AUTH_CODE messages and supervisor tasks regardless of status
let is_auth_code = req.message.starts_with("AUTH_CODE:");
let is_supervisor = task.is_supervisor;
- if task.status != "running" && !is_auth_code && !is_supervisor {
+ let can_receive_message = task.status == "running" || task.status == "starting";
+ if !can_receive_message && !is_auth_code && !is_supervisor {
return (
StatusCode::BAD_REQUEST,
Json(ApiError::new(
"INVALID_STATE",
format!(
- "Cannot send message to task in status: {}. Task must be running.",
+ "Cannot send message to task in status: {}. Task must be running or starting.",
task.status
),
)),
diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs
index c9cb849..90c6dc7 100644
--- a/makima/src/server/handlers/mesh_supervisor.rs
+++ b/makima/src/server/handlers/mesh_supervisor.rs
@@ -129,6 +129,9 @@ pub struct PendingQuestionSummary {
pub question_id: Uuid,
pub task_id: Uuid,
pub contract_id: Uuid,
+ /// Directive this question relates to (if from a directive task)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub directive_id: Option<Uuid>,
pub question: String,
pub choices: Vec<String>,
pub context: Option<String>,
@@ -257,11 +260,11 @@ async fn verify_supervisor_auth(
)
})?;
- // Verify task is a supervisor
- if !task.is_supervisor {
+ // Verify task is a supervisor or a directive task
+ if !task.is_supervisor && task.directive_id.is_none() {
return Err((
StatusCode::FORBIDDEN,
- Json(ApiError::new("NOT_SUPERVISOR", "Only supervisor tasks can use these endpoints")),
+ Json(ApiError::new("NOT_SUPERVISOR", "Only supervisor or directive tasks can use these endpoints")),
));
}
@@ -1694,17 +1697,43 @@ pub async fn ask_question(
}
};
- let Some(contract_id) = supervisor.contract_id else {
+ // Determine context: contract or directive
+ let contract_id = supervisor.contract_id;
+ let directive_id = supervisor.directive_id;
+
+ if contract_id.is_none() && directive_id.is_none() {
return (
StatusCode::BAD_REQUEST,
- Json(ApiError::new("NO_CONTRACT", "Supervisor has no associated contract")),
+ Json(ApiError::new("NO_CONTEXT", "Supervisor has no associated contract or directive")),
).into_response();
+ }
+
+ let is_directive_context = directive_id.is_some() && contract_id.is_none();
+
+ // For directive context, check reconcile_mode to determine behavior
+ let directive_reconcile_mode = if let Some(did) = directive_id {
+ if is_directive_context {
+ match repository::get_directive_for_owner(pool, owner_id, did).await {
+ Ok(Some(d)) => d.reconcile_mode,
+ Ok(None) => false,
+ Err(e) => {
+ tracing::warn!(error = %e, "Failed to get directive for reconcile_mode check");
+ false
+ }
+ }
+ } else {
+ false
+ }
+ } else {
+ false
};
- // Add the question
- let question_id = state.add_supervisor_question(
+ // Add the question (use Uuid::nil() for contract_id in directive-only context)
+ let effective_contract_id = contract_id.unwrap_or(Uuid::nil());
+ let question_id = state.add_supervisor_question_with_directive(
supervisor_id,
- contract_id,
+ effective_contract_id,
+ directive_id,
owner_id,
request.question.clone(),
request.choices.clone(),
@@ -1714,15 +1743,18 @@ pub async fn ask_question(
);
// Save state: question asked is a key save point (Task 3.3)
- let pending_question = PendingQuestion {
- id: question_id,
- question: request.question.clone(),
- choices: request.choices.clone(),
- context: request.context.clone(),
- question_type: request.question_type.clone(),
- asked_at: chrono::Utc::now(),
- };
- save_state_on_question_asked(pool, contract_id, pending_question).await;
+ // Only for contract context — directive tasks don't use supervisor_states table
+ if let Some(cid) = contract_id {
+ let pending_question = PendingQuestion {
+ id: question_id,
+ question: request.question.clone(),
+ choices: request.choices.clone(),
+ context: request.context.clone(),
+ question_type: request.question_type.clone(),
+ asked_at: chrono::Utc::now(),
+ };
+ save_state_on_question_asked(pool, cid, pending_question).await;
+ }
// Broadcast question as task output entry for the task's chat
let question_data = serde_json::json!({
@@ -1775,9 +1807,10 @@ pub async fn ask_question(
).into_response();
}
- // If phaseguard is enabled, pause the supervisor task and return
+ // If phaseguard is enabled (or directive reconcile mode), pause the supervisor task and return
// The task will be auto-resumed when a message is sent to it (e.g., when user answers)
- if request.phaseguard {
+ let use_phaseguard = request.phaseguard || (is_directive_context && directive_reconcile_mode);
+ if use_phaseguard {
// Pause the supervisor task
if let Some(daemon_id) = supervisor.daemon_id {
let cmd = DaemonCommand::PauseTask { task_id: supervisor_id };
@@ -1808,7 +1841,13 @@ pub async fn ask_question(
}
// Poll for response with timeout
- let timeout_duration = std::time::Duration::from_secs(request.timeout_seconds.max(1) as u64);
+ // For directive tasks without reconcile mode, use 30s default timeout
+ let timeout_secs = if is_directive_context && !directive_reconcile_mode {
+ 30
+ } else {
+ request.timeout_seconds.max(1) as u64
+ };
+ let timeout_duration = std::time::Duration::from_secs(timeout_secs);
let start = std::time::Instant::now();
let poll_interval = std::time::Duration::from_millis(500);
@@ -1819,7 +1858,10 @@ pub async fn ask_question(
state.cleanup_question_response(question_id);
// Clear pending question from supervisor state (Task 3.3)
- clear_pending_question(pool, contract_id, question_id).await;
+ // Skip for directive context — no supervisor_states for directives
+ if let Some(cid) = contract_id {
+ clear_pending_question(pool, cid, question_id).await;
+ }
return (
StatusCode::OK,
@@ -1837,7 +1879,10 @@ pub async fn ask_question(
state.remove_pending_question(question_id);
// Clear pending question from supervisor state on timeout (Task 3.3)
- clear_pending_question(pool, contract_id, question_id).await;
+ // Skip for directive context — no supervisor_states for directives
+ if let Some(cid) = contract_id {
+ clear_pending_question(pool, cid, question_id).await;
+ }
return (
StatusCode::REQUEST_TIMEOUT,
@@ -1880,6 +1925,7 @@ pub async fn list_pending_questions(
question_id: q.question_id,
task_id: q.task_id,
contract_id: q.contract_id,
+ directive_id: q.directive_id,
question: q.question,
choices: q.choices,
context: q.context,
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index 29cd09f..8b06a28 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -13,6 +13,7 @@ pub mod history;
pub mod listen;
pub mod mesh;
pub mod mesh_chat;
+pub mod orders;
pub mod mesh_daemon;
pub mod mesh_merge;
pub mod mesh_supervisor;
diff --git a/makima/src/server/handlers/orders.rs b/makima/src/server/handlers/orders.rs
new file mode 100644
index 0000000..c43c406
--- /dev/null
+++ b/makima/src/server/handlers/orders.rs
@@ -0,0 +1,443 @@
+//! HTTP handlers for order CRUD operations.
+//! Orders are card-based work items (features, bugs, spikes) similar to
+//! GitHub Issues or Linear cards.
+
+use axum::{
+ extract::{Path, Query, State},
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use uuid::Uuid;
+
+use crate::db::models::{
+ ConvertToStepRequest, CreateOrderRequest, DirectiveStep, LinkContractRequest,
+ LinkDirectiveRequest, Order, OrderListQuery, OrderListResponse, UpdateOrderRequest,
+};
+use crate::db::repository;
+use crate::server::auth::Authenticated;
+use crate::server::messages::ApiError;
+use crate::server::state::SharedState;
+
+// =============================================================================
+// Order CRUD
+// =============================================================================
+
+/// List all orders for the authenticated user.
+#[utoipa::path(
+ get,
+ path = "/api/v1/orders",
+ params(
+ ("status" = Option<String>, Query, description = "Filter by status"),
+ ("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"),
+ ),
+ responses(
+ (status = 200, description = "List of orders", body = OrderListResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Orders"
+)]
+pub async fn list_orders(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Query(query): Query<OrderListQuery>,
+) -> 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_orders(
+ pool,
+ auth.owner_id,
+ query.status.as_deref(),
+ query.order_type.as_deref(),
+ query.priority.as_deref(),
+ query.directive_id,
+ query.contract_id,
+ )
+ .await
+ {
+ Ok(orders) => {
+ let total = orders.len() as i64;
+ Json(OrderListResponse { orders, total }).into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list orders: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("LIST_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Create a new order.
+#[utoipa::path(
+ post,
+ path = "/api/v1/orders",
+ request_body = CreateOrderRequest,
+ responses(
+ (status = 201, description = "Order created", body = Order),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Orders"
+)]
+pub async fn create_order(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Json(req): Json<CreateOrderRequest>,
+) -> 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_order(pool, auth.owner_id, req).await {
+ Ok(order) => (StatusCode::CREATED, Json(order)).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to create order: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("CREATE_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get an order by ID.
+#[utoipa::path(
+ get,
+ path = "/api/v1/orders/{id}",
+ params(("id" = Uuid, Path, description = "Order ID")),
+ responses(
+ (status = 200, description = "Order details", 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 get_order(
+ 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_order(pool, auth.owner_id, 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 get order: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("GET_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Update an order.
+#[utoipa::path(
+ patch,
+ path = "/api/v1/orders/{id}",
+ params(("id" = Uuid, Path, description = "Order ID")),
+ request_body = UpdateOrderRequest,
+ responses(
+ (status = 200, description = "Order updated", 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 update_order(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<UpdateOrderRequest>,
+) -> 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_order(pool, auth.owner_id, id, req).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 update order: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("UPDATE_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Delete an order.
+#[utoipa::path(
+ delete,
+ path = "/api/v1/orders/{id}",
+ params(("id" = Uuid, Path, description = "Order 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 = "Orders"
+)]
+pub async fn delete_order(
+ 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_order(pool, auth.owner_id, id).await {
+ Ok(true) => StatusCode::NO_CONTENT.into_response(),
+ Ok(false) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Order not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to delete order: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DELETE_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+// =============================================================================
+// Order Linking & Conversion
+// =============================================================================
+
+/// Link an order to a directive.
+#[utoipa::path(
+ post,
+ path = "/api/v1/orders/{id}/link-directive",
+ params(("id" = Uuid, Path, description = "Order ID")),
+ request_body = LinkDirectiveRequest,
+ responses(
+ (status = 200, description = "Order linked to directive", 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_directive(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<LinkDirectiveRequest>,
+) -> 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, req.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();
+ }
+ }
+
+ match repository::link_order_to_directive(pool, auth.owner_id, id, req.directive_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 directive: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("LINK_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// 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.
+#[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),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Orders"
+)]
+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 (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::convert_order_to_step(pool, auth.owner_id, id, req.directive_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")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to convert order to step: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("CONVERT_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}