summaryrefslogtreecommitdiff
path: root/makima/src
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-14 21:29:26 +0000
committerGitHub <noreply@github.com>2026-02-14 21:29:26 +0000
commit9aadbc7958d39d181c0dd0600e2b7c30bb6c391a (patch)
treeef8bed9718c39041191b58a284ee31f5d8d32521 /makima/src
parentc1e55ce4fec79f9909b957f86bd7fa8b76939746 (diff)
downloadsoryu-9aadbc7958d39d181c0dd0600e2b7c30bb6c391a.tar.gz
soryu-9aadbc7958d39d181c0dd0600e2b7c30bb6c391a.zip
Makima system improvements: Orders, directive questions, PR creation fix, bug fixes (#62)
* feat: soryu-co/soryu - makima: Fix directive goal update bug - stale closure issue * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Create Orders database schema and backend API * feat: soryu-co/soryu - makima: Fix task Claude instance not receiving user inputs from input box * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Build Orders frontend page replacing the Board page * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Fix directive PR creation system
Diffstat (limited to 'makima/src')
-rw-r--r--makima/src/daemon/skills/directive.md6
-rw-r--r--makima/src/daemon/task/manager.rs23
-rw-r--r--makima/src/db/models.rs125
-rw-r--r--makima/src/db/repository.rs316
-rw-r--r--makima/src/orchestration/directive.rs88
-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
-rw-r--r--makima/src/server/mod.rs16
-rw-r--r--makima/src/server/openapi.rs33
-rw-r--r--makima/src/server/state.rs30
12 files changed, 1135 insertions, 46 deletions
diff --git a/makima/src/daemon/skills/directive.md b/makima/src/daemon/skills/directive.md
index 68d9277..9d2b644 100644
--- a/makima/src/daemon/skills/directive.md
+++ b/makima/src/daemon/skills/directive.md
@@ -76,6 +76,12 @@ Updates the goal and bumps `goalUpdatedAt`. If the directive is `idle`, it react
makima directive pause
```
+### Update Directive Metadata
+```bash
+makima directive update --pr-url "<url>" --pr-branch "<branch>"
+```
+Updates the directive's PR URL and/or PR branch. Used by completion tasks to store the PR URL after creating it.
+
## Memory Commands
Directives have an optional key-value memory system that persists across steps and planning cycles. Use memory to share context, decisions, and learned information between steps — so downstream tasks don't need to re-discover what earlier steps already figured out.
diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs
index ce5a580..76138c1 100644
--- a/makima/src/daemon/task/manager.rs
+++ b/makima/src/daemon/task/manager.rs
@@ -1611,14 +1611,14 @@ impl TaskManager {
}
// Regular message - send to task's stdin
- tracing::info!(task_id = %task_id, message_len = message.len(), "Sending message to task");
+ tracing::info!(task_id = %task_id, message_len = message.len(), "Sending message to task stdin");
// Send message to the task's stdin via the input channel
let inputs = self.task_inputs.read().await;
if let Some(sender) = inputs.get(&task_id) {
if let Err(e) = sender.send(message).await {
- tracing::warn!(task_id = %task_id, error = %e, "Failed to send message to task input channel");
+ tracing::warn!(task_id = %task_id, error = %e, "Failed to send message to task input channel (channel may be closed, stdin forwarder may have exited)");
} else {
- tracing::info!(task_id = %task_id, "Message sent to task successfully");
+ tracing::info!(task_id = %task_id, "Message sent to task input channel successfully, will be forwarded to Claude stdin");
}
} else {
drop(inputs); // Release read lock before checking if we need to respawn
@@ -5192,12 +5192,19 @@ impl TaskManagerInner {
// Check if this is a "result" message indicating task completion
// With --input-format=stream-json, Claude waits for more input after completion
- // We close stdin to signal EOF and let the process exit
if line.json_type.as_deref() == Some("result") {
- tracing::info!(task_id = %task_id, "Received result message, closing stdin to signal completion");
- let mut stdin_guard = stdin_handle_for_completion.lock().await;
- if let Some(mut stdin) = stdin_guard.take() {
- let _ = stdin.shutdown().await;
+ if autonomous_loop {
+ // In autonomous loop mode, close stdin to let the process exit
+ // so we can spawn the next iteration with --continue
+ tracing::info!(task_id = %task_id, "Received result message in autonomous loop, closing stdin to signal completion");
+ let mut stdin_guard = stdin_handle_for_completion.lock().await;
+ if let Some(mut stdin) = stdin_guard.take() {
+ let _ = stdin.shutdown().await;
+ }
+ } else {
+ // In interactive mode, keep stdin open so the user can send
+ // follow-up messages. Claude will stay alive waiting for input.
+ tracing::info!(task_id = %task_id, "Received result message, keeping stdin open for interactive input");
}
}
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index 131dffc..6ec6cf4 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -2714,6 +2714,8 @@ pub struct Directive {
pub pr_url: Option<String>,
pub pr_branch: Option<String>,
pub completion_task_id: Option<Uuid>,
+ /// Whether questions pause execution indefinitely until answered
+ pub reconcile_mode: bool,
pub goal_updated_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub version: i32,
@@ -2763,6 +2765,8 @@ pub struct DirectiveSummary {
pub orchestrator_task_id: Option<Uuid>,
pub pr_url: Option<String>,
pub completion_task_id: Option<Uuid>,
+ /// Whether questions pause execution indefinitely until answered
+ pub reconcile_mode: bool,
pub version: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -2789,6 +2793,8 @@ pub struct CreateDirectiveRequest {
pub repository_url: Option<String>,
pub local_path: Option<String>,
pub base_branch: Option<String>,
+ /// Whether questions pause execution indefinitely until answered
+ pub reconcile_mode: Option<bool>,
}
/// Request to update a directive.
@@ -2804,6 +2810,8 @@ pub struct UpdateDirectiveRequest {
pub orchestrator_task_id: Option<Uuid>,
pub pr_url: Option<String>,
pub pr_branch: Option<String>,
+ /// Whether questions pause execution indefinitely until answered
+ pub reconcile_mode: Option<bool>,
pub version: Option<i32>,
}
@@ -2848,3 +2856,120 @@ pub struct UpdateDirectiveStepRequest {
pub order_index: Option<i32>,
}
+// =============================================================================
+// Order Types
+// =============================================================================
+
+/// 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.
+#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct Order {
+ pub id: Uuid,
+ pub owner_id: Uuid,
+ pub title: String,
+ pub description: Option<String>,
+ /// Priority: critical, high, medium, low, none
+ pub priority: String,
+ /// Status: open, in_progress, done, archived
+ pub status: String,
+ /// Type of work: feature, bug, spike, chore, improvement
+ pub order_type: String,
+ /// Flexible labels as JSON array of strings
+ pub labels: serde_json::Value,
+ /// Linked directive (optional)
+ pub directive_id: Option<Uuid>,
+ /// Linked directive step (optional)
+ pub directive_step_id: Option<Uuid>,
+ /// Linked contract (optional)
+ pub contract_id: Option<Uuid>,
+ /// Repository context
+ pub repository_url: Option<String>,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// Request to create a new order.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateOrderRequest {
+ pub title: String,
+ pub description: Option<String>,
+ pub priority: Option<String>,
+ pub status: Option<String>,
+ 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>,
+ pub repository_url: Option<String>,
+}
+
+/// Default empty JSON array for labels.
+fn default_empty_labels() -> serde_json::Value {
+ serde_json::json!([])
+}
+
+/// Request to update an existing order.
+#[derive(Debug, Default, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateOrderRequest {
+ pub title: Option<String>,
+ pub description: Option<String>,
+ pub priority: Option<String>,
+ pub status: Option<String>,
+ pub order_type: Option<String>,
+ 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>,
+}
+
+/// Response for order list endpoint.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OrderListResponse {
+ pub orders: Vec<Order>,
+ pub total: i64,
+}
+
+/// Query parameters for listing orders.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OrderListQuery {
+ /// Filter by status (e.g., "open", "in_progress", "done", "archived")
+ pub status: Option<String>,
+ /// Filter by order type (e.g., "feature", "bug", "spike", "chore", "improvement")
+ #[serde(rename = "type")]
+ pub order_type: Option<String>,
+ /// Filter by priority (e.g., "critical", "high", "medium", "low", "none")
+ pub priority: Option<String>,
+ /// Filter by linked directive ID
+ pub directive_id: Option<Uuid>,
+ /// Filter by linked contract ID
+ pub contract_id: Option<Uuid>,
+}
+
+/// Request body for linking an order to a directive.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+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 d8168f6..ed4a1fa 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -14,6 +14,7 @@ use super::models::{
DeliverableDefinition, Directive, DirectiveStep, DirectiveSummary,
CreateDirectiveRequest, CreateDirectiveStepRequest, UpdateDirectiveRequest,
UpdateDirectiveStepRequest,
+ CreateOrderRequest, Order, UpdateOrderRequest,
File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters,
MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig,
PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState,
@@ -4929,8 +4930,8 @@ pub async fn create_directive_for_owner(
) -> Result<Directive, sqlx::Error> {
sqlx::query_as::<_, Directive>(
r#"
- INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch)
- VALUES ($1, $2, $3, $4, $5, $6)
+ INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch, reconcile_mode)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
"#,
)
@@ -4940,6 +4941,7 @@ pub async fn create_directive_for_owner(
.bind(&req.repository_url)
.bind(&req.local_path)
.bind(&req.base_branch)
+ .bind(req.reconcile_mode.unwrap_or(false))
.fetch_one(pool)
.await
}
@@ -4992,6 +4994,7 @@ pub async fn list_directives_for_owner(
SELECT
d.id, d.owner_id, d.title, d.goal, d.status, d.repository_url,
d.orchestrator_task_id, d.pr_url, d.completion_task_id,
+ d.reconcile_mode,
d.version, d.created_at, d.updated_at,
COALESCE(s.total_steps, 0) as total_steps,
COALESCE(s.completed_steps, 0) as completed_steps,
@@ -5055,12 +5058,14 @@ pub async fn update_directive_for_owner(
let orchestrator_task_id = req.orchestrator_task_id.or(current.orchestrator_task_id);
let pr_url = req.pr_url.as_deref().or(current.pr_url.as_deref());
let pr_branch = req.pr_branch.as_deref().or(current.pr_branch.as_deref());
+ let reconcile_mode = req.reconcile_mode.unwrap_or(current.reconcile_mode);
let result = sqlx::query_as::<_, Directive>(
r#"
UPDATE directives
SET title = $3, goal = $4, status = $5, repository_url = $6, local_path = $7,
base_branch = $8, orchestrator_task_id = $9, pr_url = $10, pr_branch = $11,
+ reconcile_mode = $12,
version = version + 1, updated_at = NOW()
WHERE id = $1 AND owner_id = $2
RETURNING *
@@ -5077,6 +5082,7 @@ pub async fn update_directive_for_owner(
.bind(orchestrator_task_id)
.bind(pr_url)
.bind(pr_branch)
+ .bind(reconcile_mode)
.fetch_optional(pool)
.await
.map_err(RepositoryError::Database)?;
@@ -5188,6 +5194,7 @@ pub struct CompletedStepTask {
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct DirectiveCompletionCheck {
pub directive_id: Uuid,
+ pub owner_id: Uuid,
pub completion_task_id: Uuid,
pub task_status: String,
pub pr_url: Option<String>,
@@ -5224,7 +5231,7 @@ pub async fn get_completion_tasks_to_check(
) -> Result<Vec<DirectiveCompletionCheck>, sqlx::Error> {
sqlx::query_as::<_, DirectiveCompletionCheck>(
r#"
- SELECT d.id as directive_id, d.completion_task_id, t.status as task_status, d.pr_url
+ SELECT d.id as directive_id, d.owner_id, d.completion_task_id, t.status as task_status, d.pr_url
FROM directives d
JOIN tasks t ON t.id = d.completion_task_id
WHERE d.completion_task_id IS NOT NULL
@@ -5917,3 +5924,306 @@ pub async fn get_directive_max_generation(
Ok(row.0.unwrap_or(0))
}
+// =============================================================================
+// Order CRUD
+// =============================================================================
+
+/// Create a new order for the given owner.
+pub async fn create_order(
+ pool: &PgPool,
+ owner_id: Uuid,
+ req: CreateOrderRequest,
+) -> Result<Order, sqlx::Error> {
+ let priority = req.priority.as_deref().unwrap_or("medium");
+ let status = req.status.as_deref().unwrap_or("open");
+ let order_type = req.order_type.as_deref().unwrap_or("feature");
+
+ 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)
+ RETURNING *
+ "#,
+ )
+ .bind(owner_id)
+ .bind(&req.title)
+ .bind(&req.description)
+ .bind(priority)
+ .bind(status)
+ .bind(order_type)
+ .bind(&req.labels)
+ .bind(req.directive_id)
+ .bind(req.contract_id)
+ .bind(&req.repository_url)
+ .fetch_one(pool)
+ .await
+}
+
+/// List orders for the given owner with optional filters.
+pub async fn list_orders(
+ pool: &PgPool,
+ owner_id: Uuid,
+ status_filter: Option<&str>,
+ type_filter: Option<&str>,
+ priority_filter: Option<&str>,
+ directive_id_filter: Option<Uuid>,
+ contract_id_filter: Option<Uuid>,
+) -> Result<Vec<Order>, sqlx::Error> {
+ // Build dynamic query with optional filters
+ let mut query = String::from("SELECT * FROM orders WHERE owner_id = $1");
+ let mut param_idx = 2u32;
+
+ if status_filter.is_some() {
+ query.push_str(&format!(" AND status = ${}", param_idx));
+ param_idx += 1;
+ }
+ if type_filter.is_some() {
+ query.push_str(&format!(" AND order_type = ${}", param_idx));
+ param_idx += 1;
+ }
+ if priority_filter.is_some() {
+ query.push_str(&format!(" AND priority = ${}", param_idx));
+ param_idx += 1;
+ }
+ if directive_id_filter.is_some() {
+ 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));
+ let _ = param_idx; // suppress unused warning
+ }
+ query.push_str(" ORDER BY created_at DESC");
+
+ let mut q = sqlx::query_as::<_, Order>(&query).bind(owner_id);
+
+ if let Some(s) = status_filter {
+ q = q.bind(s);
+ }
+ if let Some(t) = type_filter {
+ q = q.bind(t);
+ }
+ if let Some(p) = priority_filter {
+ q = q.bind(p);
+ }
+ if let Some(d) = directive_id_filter {
+ q = q.bind(d);
+ }
+ if let Some(c) = contract_id_filter {
+ q = q.bind(c);
+ }
+
+ q.fetch_all(pool).await
+}
+
+/// Get a single order by ID (owner-scoped).
+pub async fn get_order(
+ pool: &PgPool,
+ owner_id: Uuid,
+ order_id: Uuid,
+) -> Result<Option<Order>, sqlx::Error> {
+ sqlx::query_as::<_, Order>(
+ r#"SELECT * FROM orders WHERE id = $1 AND owner_id = $2"#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Update an order (owner-scoped). Uses COALESCE pattern to only update provided fields.
+pub async fn update_order(
+ pool: &PgPool,
+ owner_id: Uuid,
+ order_id: Uuid,
+ req: UpdateOrderRequest,
+) -> Result<Option<Order>, sqlx::Error> {
+ let current = sqlx::query_as::<_, Order>(
+ r#"SELECT * FROM orders WHERE id = $1 AND owner_id = $2"#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await?;
+
+ let current = match current {
+ Some(c) => c,
+ None => return Ok(None),
+ };
+
+ let title = req.title.as_deref().unwrap_or(&current.title);
+ let description = req.description.as_deref().or(current.description.as_deref());
+ let priority = req.priority.as_deref().unwrap_or(&current.priority);
+ let status = req.status.as_deref().unwrap_or(&current.status);
+ let order_type = req.order_type.as_deref().unwrap_or(&current.order_type);
+ 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>(
+ r#"
+ 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()
+ WHERE id = $1 AND owner_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .bind(title)
+ .bind(description)
+ .bind(priority)
+ .bind(status)
+ .bind(order_type)
+ .bind(labels)
+ .bind(directive_id)
+ .bind(directive_step_id)
+ .bind(contract_id)
+ .bind(repository_url)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Delete an order (owner-scoped). Returns true if a row was deleted.
+pub async fn delete_order(
+ pool: &PgPool,
+ owner_id: Uuid,
+ order_id: Uuid,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"DELETE FROM orders WHERE id = $1 AND owner_id = $2"#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Link an order to a directive.
+pub async fn link_order_to_directive(
+ pool: &PgPool,
+ owner_id: Uuid,
+ order_id: Uuid,
+ directive_id: Uuid,
+) -> Result<Option<Order>, sqlx::Error> {
+ sqlx::query_as::<_, Order>(
+ r#"
+ UPDATE orders
+ SET directive_id = $3, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .bind(directive_id)
+ .fetch_optional(pool)
+ .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.
+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>(
+ r#"SELECT * FROM orders WHERE id = $1 AND owner_id = $2"#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await?;
+
+ let order = match order {
+ Some(o) => o,
+ 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"#,
+ )
+ .bind(directive_id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await?;
+
+ if directive.is_none() {
+ return Ok(None);
+ }
+
+ // Get the next order_index for this directive
+ let max_index: (Option<i32>,) = sqlx::query_as(
+ r#"SELECT MAX(order_index) FROM directive_steps WHERE directive_id = $1"#,
+ )
+ .bind(directive_id)
+ .fetch_one(pool)
+ .await?;
+ let next_index = max_index.0.unwrap_or(-1) + 1;
+
+ // Create the directive step from order data
+ let step = sqlx::query_as::<_, DirectiveStep>(
+ r#"
+ INSERT INTO directive_steps (directive_id, name, description, order_index)
+ VALUES ($1, $2, $3, $4)
+ RETURNING *
+ "#,
+ )
+ .bind(directive_id)
+ .bind(&order.title)
+ .bind(&order.description)
+ .bind(next_index)
+ .fetch_one(pool)
+ .await?;
+
+ // Link the order to the directive and the new step
+ sqlx::query(
+ r#"
+ UPDATE orders
+ SET directive_id = $3, directive_step_id = $4, 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?;
+
+ Ok(Some(step))
+}
+
diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs
index ea8009d..0deacca 100644
--- a/makima/src/orchestration/directive.rs
+++ b/makima/src/orchestration/directive.rs
@@ -248,6 +248,16 @@ impl DirectiveOrchestrator {
.await?;
repository::check_directive_idle(&self.pool, step.directive_id).await?;
}
+ "paused" => {
+ // Task is paused (e.g., waiting for user answer in reconcile mode)
+ // Keep step in running status — task will auto-resume when answered
+ tracing::debug!(
+ step_id = %step.step_id,
+ directive_id = %step.directive_id,
+ task_id = %step.task_id,
+ "Step task paused (waiting for user response) — keeping step running"
+ );
+ }
_ => {
// Still running — do nothing
}
@@ -627,6 +637,45 @@ impl DirectiveOrchestrator {
task_id = %check.completion_task_id,
"Completion task finished"
);
+
+ // If directive has no pr_url yet, try to extract from task output
+ if check.pr_url.is_none() {
+ match self.extract_pr_url_from_task(check.completion_task_id).await {
+ Ok(Some(url)) => {
+ tracing::info!(
+ directive_id = %check.directive_id,
+ pr_url = %url,
+ "Extracted PR URL from completion task output"
+ );
+ let update = crate::db::models::UpdateDirectiveRequest {
+ pr_url: Some(url),
+ ..Default::default()
+ };
+ let _ = repository::update_directive_for_owner(
+ &self.pool,
+ check.owner_id,
+ check.directive_id,
+ update,
+ )
+ .await;
+ }
+ Ok(None) => {
+ tracing::warn!(
+ directive_id = %check.directive_id,
+ task_id = %check.completion_task_id,
+ "Completion task finished but no PR URL found in output"
+ );
+ }
+ Err(e) => {
+ tracing::warn!(
+ directive_id = %check.directive_id,
+ error = %e,
+ "Failed to extract PR URL from completion task output"
+ );
+ }
+ }
+ }
+
repository::clear_completion_task(&self.pool, check.directive_id).await?;
}
"failed" | "interrupted" => {
@@ -688,6 +737,36 @@ impl DirectiveOrchestrator {
Ok(task.id)
}
+
+ /// Extract a GitHub PR URL from a completion task's output events.
+ /// Searches task output for patterns like `https://github.com/.../pull/123`.
+ async fn extract_pr_url_from_task(
+ &self,
+ task_id: Uuid,
+ ) -> Result<Option<String>, anyhow::Error> {
+ let events = repository::get_task_output(&self.pool, task_id, Some(500)).await?;
+
+ let pr_url_re = regex::Regex::new(r"https://github\.com/[^/\s]+/[^/\s]+/pull/\d+")?;
+
+ // Search from most recent events backwards for the PR URL
+ for event in events.iter().rev() {
+ if let Some(ref data) = event.event_data {
+ // Check the content field inside event_data JSON
+ if let Some(content) = data.get("content").and_then(|c| c.as_str()) {
+ if let Some(m) = pr_url_re.find(content) {
+ return Ok(Some(m.as_str().to_string()));
+ }
+ }
+ // Also check the raw JSON string representation as fallback
+ let data_str = data.to_string();
+ if let Some(m) = pr_url_re.find(&data_str) {
+ return Ok(Some(m.as_str().to_string()));
+ }
+ }
+ }
+
+ Ok(None)
+ }
}
/// Build the planning prompt for a directive.
@@ -952,10 +1031,15 @@ Then create the PR:
gh pr create --title "{title}" --body "{pr_body}" --head {directive_branch} --base {base_branch}
```
-After creating the PR, store the URL:
+IMPORTANT: After creating the PR, you MUST store the PR URL so the directive system can track it.
+
+1. Run `gh pr create` as shown above and capture its output
+2. The output will contain the PR URL (e.g., https://github.com/owner/repo/pull/123)
+3. Then run this command to store the URL:
```
-makima directive update --pr-url "<the PR URL from gh pr create output>"
+makima directive update --pr-url "https://github.com/..."
```
+Replace the URL with the actual PR URL from the `gh pr create` output. This step is CRITICAL — the PR will not be tracked by the directive system without it.
If there are merge conflicts, resolve them sensibly before pushing.
"#,
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()
+ }
+ }
+}
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index c1e1309..29c55c4 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, 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::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, orders, repository_history, speak, templates, transcript_analysis, users, versions};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -238,6 +238,20 @@ pub fn make_router(state: SharedState) -> Router {
.route("/directives/{id}/steps/{step_id}/skip", post(directives::skip_step))
.route("/directives/{id}/goal", put(directives::update_goal))
.route("/directives/{id}/cleanup-tasks", post(directives::cleanup_tasks))
+ // Order endpoints
+ .route(
+ "/orders",
+ get(orders::list_orders).post(orders::create_order),
+ )
+ .route(
+ "/orders/{id}",
+ get(orders::get_order)
+ .patch(orders::update_order)
+ .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))
// Contract type templates (built-in only)
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index e68286e..b21dab9 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -8,26 +8,30 @@ use crate::db::models::{
ChangePhaseRequest,
Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent,
ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations,
- CleanupTasksResponse,
+ CleanupTasksResponse, ConvertToStepRequest,
CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest,
- CreateManagedRepositoryRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse,
+ CreateManagedRepositoryRequest, CreateOrderRequest, CreateTaskRequest,
+ Daemon, DaemonDirectoriesResponse,
DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse,
DirectiveStep, DirectiveSummary, DirectiveWithSteps,
File, FileListResponse, FileSummary,
+ LinkContractRequest, LinkDirectiveRequest,
MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse,
MergeSkipRequest, MergeStartRequest, MergeStatusResponse, MeshChatConversation,
- MeshChatHistoryResponse, MeshChatMessageRecord, RepositoryHistoryEntry,
+ MeshChatHistoryResponse, MeshChatMessageRecord,
+ Order, OrderListResponse, OrderListQuery,
+ RepositoryHistoryEntry,
RepositoryHistoryListResponse, RepositorySuggestionsQuery, SendMessageRequest,
Task,
TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry,
UpdateContractRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest,
- UpdateFileRequest, UpdateGoalRequest, UpdateTaskRequest,
+ UpdateFileRequest, UpdateGoalRequest, UpdateOrderRequest, UpdateTaskRequest,
};
use crate::server::auth::{
ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse,
RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse,
};
-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::handlers::{api_keys, contract_chat, contract_discuss, contracts, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users};
use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage};
#[derive(OpenApi)]
@@ -125,6 +129,15 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
directives::skip_step,
directives::update_goal,
directives::cleanup_tasks,
+ // Order endpoints
+ orders::list_orders,
+ orders::create_order,
+ orders::get_order,
+ 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,
repository_history::get_repository_suggestions,
@@ -222,6 +235,15 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
CreateDirectiveStepRequest,
UpdateDirectiveStepRequest,
CleanupTasksResponse,
+ // Order schemas
+ Order,
+ OrderListResponse,
+ OrderListQuery,
+ CreateOrderRequest,
+ UpdateOrderRequest,
+ LinkDirectiveRequest,
+ LinkContractRequest,
+ ConvertToStepRequest,
// Repository history schemas
RepositoryHistoryEntry,
RepositoryHistoryListResponse,
@@ -236,6 +258,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
(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 = "Orders", description = "Order management — card-based issue tracking for planned work items"),
(name = "Settings", description = "User settings including repository history"),
)
)]
diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs
index 58e8545..41c336e 100644
--- a/makima/src/server/state.rs
+++ b/makima/src/server/state.rs
@@ -142,8 +142,11 @@ pub struct SupervisorQuestionNotification {
pub question_id: Uuid,
/// Supervisor task that asked the question
pub task_id: Uuid,
- /// Contract this question relates to
+ /// Contract this question relates to (Uuid::nil() for directive context)
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>,
/// Owner ID for data isolation
#[serde(skip)]
pub owner_id: Option<Uuid>,
@@ -170,6 +173,8 @@ pub struct PendingSupervisorQuestion {
pub question_id: Uuid,
pub task_id: Uuid,
pub contract_id: Uuid,
+ /// Directive this question relates to (if from a directive task)
+ pub directive_id: Option<Uuid>,
pub owner_id: Uuid,
pub question: String,
pub choices: Vec<String>,
@@ -819,6 +824,25 @@ impl AppState {
multi_select: bool,
question_type: String,
) -> Uuid {
+ self.add_supervisor_question_with_directive(
+ task_id, contract_id, None, owner_id,
+ question, choices, context, multi_select, question_type,
+ )
+ }
+
+ /// Add a pending supervisor question with optional directive context and broadcast it.
+ pub fn add_supervisor_question_with_directive(
+ &self,
+ task_id: Uuid,
+ contract_id: Uuid,
+ directive_id: Option<Uuid>,
+ owner_id: Uuid,
+ question: String,
+ choices: Vec<String>,
+ context: Option<String>,
+ multi_select: bool,
+ question_type: String,
+ ) -> Uuid {
let question_id = Uuid::new_v4();
let now = chrono::Utc::now();
@@ -829,6 +853,7 @@ impl AppState {
question_id,
task_id,
contract_id,
+ directive_id,
owner_id,
question: question.clone(),
choices: choices.clone(),
@@ -844,6 +869,7 @@ impl AppState {
question_id,
task_id,
contract_id,
+ directive_id,
owner_id: Some(owner_id),
question,
choices,
@@ -857,6 +883,7 @@ impl AppState {
question_id = %question_id,
task_id = %task_id,
contract_id = %contract_id,
+ directive_id = ?directive_id,
question_type = %question_type,
"Supervisor question added"
);
@@ -904,6 +931,7 @@ impl AppState {
question_id,
task_id: question.1.task_id,
contract_id: question.1.contract_id,
+ directive_id: question.1.directive_id,
owner_id: Some(question.1.owner_id),
question: question.1.question,
choices: question.1.choices,