diff options
| author | soryu <soryu@soryu.co> | 2026-02-14 21:29:26 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-14 21:29:26 +0000 |
| commit | 9aadbc7958d39d181c0dd0600e2b7c30bb6c391a (patch) | |
| tree | ef8bed9718c39041191b58a284ee31f5d8d32521 /makima/src | |
| parent | c1e55ce4fec79f9909b957f86bd7fa8b76939746 (diff) | |
| download | soryu-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.md | 6 | ||||
| -rw-r--r-- | makima/src/daemon/task/manager.rs | 23 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 125 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 316 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 88 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 10 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_supervisor.rs | 90 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/orders.rs | 443 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 16 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 33 | ||||
| -rw-r--r-- | makima/src/server/state.rs | 30 |
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(¤t.title); + let description = req.description.as_deref().or(current.description.as_deref()); + let priority = req.priority.as_deref().unwrap_or(¤t.priority); + let status = req.status.as_deref().unwrap_or(¤t.status); + let order_type = req.order_type.as_deref().unwrap_or(¤t.order_type); + let labels = req.labels.as_ref().unwrap_or(¤t.labels); + let directive_id = req.directive_id.or(current.directive_id); + let directive_step_id = req.directive_step_id.or(current.directive_step_id); + let contract_id = req.contract_id.or(current.contract_id); + let repository_url = req.repository_url.as_deref().or(current.repository_url.as_deref()); + + sqlx::query_as::<_, Order>( + 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, |
