diff options
| author | soryu <soryu@soryu.co> | 2026-02-16 15:09:25 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-16 15:09:25 +0000 |
| commit | b6a29bb563499b2fd6280c742bd2106d66393112 (patch) | |
| tree | 6f8d13fe989613b687b12e37277b661ff4d607c8 /makima/src | |
| parent | 0676468e3e69ff36f1e509d775f191dd41f6080b (diff) | |
| download | soryu-b6a29bb563499b2fd6280c742bd2106d66393112.tar.gz soryu-b6a29bb563499b2fd6280c742bd2106d66393112.zip | |
Add pick-up-orders feature for directives (#64)
* WIP: heartbeat checkpoint
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Add frontend pick-up-orders button and API integration
* feat: soryu-co/soryu - makima: Add pick-up-orders backend endpoint and repository functions
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/db/models.rs | 9 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 55 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 217 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 200 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 1 |
5 files changed, 480 insertions, 2 deletions
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 19ebb13..bfed942 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2839,6 +2839,15 @@ pub struct CleanupTasksResponse { pub deleted: i64, } +/// Response for pick_up_orders endpoint. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PickUpOrdersResponse { + pub message: String, + pub order_count: i64, + pub task_id: Option<Uuid>, +} + /// Request to create a directive step. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index b5888c9..2ef3fbc 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -6319,3 +6319,58 @@ pub async fn convert_order_to_step( Ok(Some(step)) } +// ============================================================================= +// Order Pickup +// ============================================================================= + +/// Get available orders for pickup: open orders with no directive assigned, +/// sorted by priority (critical first) then creation date. +pub async fn get_available_orders_for_pickup( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<Order>, sqlx::Error> { + sqlx::query_as::<_, Order>( + r#" + SELECT * + FROM orders + WHERE owner_id = $1 + AND status = 'open' + AND directive_id IS NULL + ORDER BY CASE priority + WHEN 'critical' THEN 0 + WHEN 'high' THEN 1 + WHEN 'medium' THEN 2 + WHEN 'low' THEN 3 + ELSE 4 + END ASC, created_at ASC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Bulk-link orders to a directive by setting directive_id on matching orders. +/// Returns the count of updated rows. +pub async fn bulk_link_orders_to_directive( + pool: &PgPool, + owner_id: Uuid, + order_ids: &[Uuid], + directive_id: Uuid, +) -> Result<i64, sqlx::Error> { + let result = sqlx::query( + r#" + UPDATE orders + SET directive_id = $1, updated_at = NOW() + WHERE id = ANY($2) + AND owner_id = $3 + "#, + ) + .bind(directive_id) + .bind(order_ids) + .bind(owner_id) + .execute(pool) + .await?; + Ok(result.rows_affected() as i64) +} + diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 9113fd4..6e0d83d 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -1232,3 +1232,220 @@ IMPORTANT: You MUST run `makima directive update` with either `--pr-url` or `--s base_branch = base_branch, ) } + +/// Build a specialized planning prompt for picking up open orders. +/// +/// This prompt instructs the planner to evaluate available orders, select an +/// adequate number based on priority and directive capacity, and create steps +/// to fulfil them. +pub fn build_order_pickup_prompt( + directive: &crate::db::models::Directive, + existing_steps: &[crate::db::models::DirectiveStep], + orders: &[crate::db::models::Order], + generation: i32, + goal_history: &[crate::db::models::DirectiveGoalHistory], +) -> String { + let mut prompt = String::new(); + + // ── Directive context ────────────────────────────────────────── + prompt.push_str(&format!( + "You are planning work for directive \"{title}\".\n\n\ + GOAL: {goal}\n\ + {repo_section}\n", + title = directive.title, + goal = directive.goal, + repo_section = match &directive.repository_url { + Some(url) => format!("REPOSITORY: {}\n", url), + None => String::new(), + }, + )); + + // ── Goal history (if any) ───────────────────────────────────── + if !goal_history.is_empty() { + prompt.push_str("-- GOAL CHANGES --\n"); + for (i, entry) in goal_history.iter().enumerate() { + if i == 0 { + prompt.push_str(&format!( + "PREVIOUS GOAL (replaced at {}):\n{}\n\n", + entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"), + entry.goal + )); + } else { + prompt.push_str(&format!( + "OLDER GOAL (version from {}):\n{}\n\n", + entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"), + entry.goal + )); + } + } + } + + // ── Orders being picked up ─────────────────────────────────── + prompt.push_str("== ORDERS AVAILABLE FOR PICKUP ==\n"); + prompt.push_str("The following open orders have been linked to this directive. \ + Review them and create steps to fulfil them.\n\n"); + for (i, order) in orders.iter().enumerate() { + prompt.push_str(&format!( + " {}. [{}] [{}] {} (id: {})\n", + i + 1, + order.priority, + order.order_type, + order.title, + order.id, + )); + if let Some(ref desc) = order.description { + prompt.push_str(&format!(" Description: {}\n", desc)); + } + } + prompt.push('\n'); + + // ── Existing steps ─────────────────────────────────────────── + if !existing_steps.is_empty() { + let mut completed: Vec<&crate::db::models::DirectiveStep> = Vec::new(); + let mut running: Vec<&crate::db::models::DirectiveStep> = Vec::new(); + let mut pending_ready: Vec<&crate::db::models::DirectiveStep> = Vec::new(); + let mut failed: Vec<&crate::db::models::DirectiveStep> = Vec::new(); + + for step in existing_steps { + match step.status.as_str() { + "completed" => completed.push(step), + "running" => running.push(step), + "pending" | "ready" => pending_ready.push(step), + "failed" => failed.push(step), + _ => pending_ready.push(step), + } + } + + prompt.push_str("== EXISTING STEPS ==\n"); + + if !completed.is_empty() { + prompt.push_str("\n── COMPLETED steps (work already done) ──\n"); + let mut last_completed_id: Option<uuid::Uuid> = None; + for step in &completed { + prompt.push_str(&format!( + " ✅ {} (id: {}): {}\n", + step.name, + step.id, + step.description.as_deref().unwrap_or("(no description)") + )); + last_completed_id = Some(step.id); + } + if let Some(last_id) = last_completed_id { + prompt.push_str(&format!( + "\nNew steps that build on previous work SHOULD use --depends-on \"{}\" \ + so their worktree inherits all prior changes.\n", + last_id + )); + } + } + + if !running.is_empty() { + prompt.push_str("\n── RUNNING steps (in progress) ──\n"); + for step in &running { + prompt.push_str(&format!( + " 🔄 {} (id: {}): {}\n", + step.name, + step.id, + step.description.as_deref().unwrap_or("(no description)") + )); + } + } + + if !pending_ready.is_empty() { + prompt.push_str("\n── PENDING/READY steps (not yet started) ──\n"); + for step in &pending_ready { + prompt.push_str(&format!( + " ⏳ [{}] {} (id: {}): {}\n", + step.status, + step.name, + step.id, + step.description.as_deref().unwrap_or("(no description)") + )); + } + } + + if !failed.is_empty() { + prompt.push_str("\n── FAILED steps ──\n"); + for step in &failed { + prompt.push_str(&format!( + " ❌ {} (id: {}): {}\n", + step.name, + step.id, + step.description.as_deref().unwrap_or("(no description)") + )); + } + } + + // Determine whether to create fresh steps or combine with existing + let all_terminal = existing_steps + .iter() + .all(|s| matches!(s.status.as_str(), "completed" | "failed" | "skipped")); + + if all_terminal { + prompt.push_str( + "\nAll existing steps are in terminal state (completed/failed/skipped). \ + Create FRESH steps from the orders above.\n\n", + ); + } else if !pending_ready.is_empty() || !running.is_empty() { + prompt.push_str( + "\nThere are existing active/pending steps. Evaluate whether to KEEP them \ + and ADD new steps from the orders, creating a combined plan. \ + Do not duplicate work already covered by existing steps.\n\n", + ); + } + } + + // ── Order selection guidance ───────────────────────────────── + prompt.push_str(&format!( + "== ORDER SELECTION GUIDANCE ==\n\ + You do NOT need to pick up ALL orders. Select an ADEQUATE number based on:\n\ + - Priority: prefer critical and high priority orders first\n\ + - Directive scope: consider the directive's current goal and capacity\n\ + - Avoid overloading: don't assign too many orders to a single directive\n\ + - The orders are already linked to this directive — focus on creating steps\n\n\ + If some orders are not relevant to this directive's goal or would overload it, \ + you may leave them for a future pickup cycle.\n\n" + )); + + // ── Step creation instructions ─────────────────────────────── + prompt.push_str(&format!( + r#"== STEP CREATION INSTRUCTIONS == +For each order (or group of related orders), create one or more steps: +- name: Short imperative title (e.g., "Add user authentication middleware") +- description: What to do and acceptance criteria +- taskPlan: Full instructions for the Claude instance (include file paths, patterns to follow) +- dependsOn: UUIDs of steps this depends on (use IDs from previous add-step responses) +- orderIndex: Execution phase number. Steps only start after ALL steps with a lower orderIndex complete. + Steps with the same orderIndex run in parallel. Use ascending values (0, 1, 2, ...) to create sequential phases. + +Submit steps using generation {generation}: + makima directive add-step "Step Name" --description "..." --task-plan "..." --generation {generation} + (Use --depends-on "uuid1,uuid2" for dependencies) + +Or batch: + makima directive batch-add-steps --json '[{{"name":"...","description":"...","taskPlan":"...","dependsOn":[],"orderIndex":0,"generation":{generation}}}]' + +DEPENDENCY WORKTREE CONTINUATION: +Each step runs in its own git worktree. How that worktree is initialised depends on dependsOn: +- With dependsOn: the step continues from the first dependency's worktree (inheriting all committed and + uncommitted changes). Additional dependencies are merged in as branches before work starts. +- Without dependsOn: the step starts from a FRESH worktree based on the base branch (or the PR branch if + a PR already exists from previous completions). + +Because of this, you MUST chain steps using dependsOn whenever one step's work builds on another's. +If step B modifies files created/changed by step A, step B MUST list step A in its dependsOn — otherwise +step B will start from a blank worktree and won't see step A's changes at all. + +Guidelines: +- For sequential work, create a linear chain: step1 → step2 → step3 (each depends on the previous). +- Only omit dependsOn for truly independent steps that can start from a fresh checkout. +- Parallel steps that share no files can omit mutual dependencies, but if they both build on a prior step + they should BOTH list that prior step in dependsOn. + +IMPORTANT: Each step's taskPlan must be self-contained. The executing instance won't have your planning context. +"#, + generation = generation, + )); + + prompt +} diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index 6060171..f03dccf 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -9,12 +9,13 @@ use axum::{ use uuid::Uuid; use crate::db::models::{ - CleanupTasksResponse, CreateDirectiveRequest, + CleanupTasksResponse, CreateDirectiveRequest, CreateTaskRequest, CreateDirectiveStepRequest, Directive, DirectiveListResponse, - DirectiveStep, DirectiveWithSteps, + DirectiveStep, DirectiveWithSteps, PickUpOrdersResponse, UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateGoalRequest, }; use crate::db::repository; +use crate::orchestration::directive::build_order_pickup_prompt; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::SharedState; @@ -927,3 +928,198 @@ pub async fn cleanup_tasks( } } } + +// ============================================================================= +// Order Pickup +// ============================================================================= + +/// Pick up available open orders for a directive by spawning a planning task. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/pick-up-orders", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Orders picked up", body = PickUpOrdersResponse), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn pick_up_orders( + 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(); + }; + + // Verify directive ownership and get directive with steps + let (directive, steps) = + match repository::get_directive_with_steps_for_owner(pool, auth.owner_id, id).await { + Ok(Some((d, s))) => (d, s), + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get directive: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + // Fetch available orders + let orders = match repository::get_available_orders_for_pickup(pool, auth.owner_id).await { + Ok(o) => o, + Err(e) => { + tracing::error!("Failed to fetch available orders: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("FETCH_ORDERS_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + // If no orders available, return early + if orders.is_empty() { + return Json(PickUpOrdersResponse { + message: "No orders available to pick up".to_string(), + order_count: 0, + task_id: None, + }) + .into_response(); + } + + let order_count = orders.len() as i64; + let order_ids: Vec<Uuid> = orders.iter().map(|o| o.id).collect(); + + // Get generation and goal history for the planning prompt + let generation = + match repository::get_directive_max_generation(pool, id).await { + Ok(g) => g + 1, + Err(e) => { + tracing::error!("Failed to get max generation: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GENERATION_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + let goal_history = match repository::get_directive_goal_history(pool, id, 3).await { + Ok(h) => h, + Err(e) => { + tracing::warn!("Failed to get goal history: {}", e); + vec![] + } + }; + + // Build the specialized planning prompt + let plan = build_order_pickup_prompt(&directive, &steps, &orders, generation, &goal_history); + + // Link orders to the directive + if let Err(e) = + repository::bulk_link_orders_to_directive(pool, auth.owner_id, &order_ids, id).await + { + tracing::error!("Failed to link orders to directive: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LINK_ORDERS_FAILED", &e.to_string())), + ) + .into_response(); + } + + // Create the planning task + let req = CreateTaskRequest { + contract_id: None, + name: format!("Pick up orders: {}", directive.title), + description: Some("Directive order pickup planning task".to_string()), + plan, + parent_task_id: None, + is_supervisor: false, + priority: 0, + repository_url: directive.repository_url.clone(), + base_branch: directive.base_branch.clone(), + target_branch: None, + merge_mode: None, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + checkpoint_sha: None, + branched_from_task_id: None, + conversation_history: None, + supervisor_worktree_task_id: None, + directive_id: Some(directive.id), + directive_step_id: None, + }; + + let task = match repository::create_task_for_owner(pool, auth.owner_id, req).await { + Ok(t) => t, + Err(e) => { + tracing::error!("Failed to create pickup planning task: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_TASK_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + // Assign as orchestrator task + if let Err(e) = repository::assign_orchestrator_task(pool, id, task.id).await { + tracing::error!("Failed to assign orchestrator task: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("ASSIGN_TASK_FAILED", &e.to_string())), + ) + .into_response(); + } + + // Cancel old planning tasks + let cancelled = repository::cancel_old_planning_tasks(pool, id, task.id).await; + if let Ok(count) = cancelled { + if count > 0 { + tracing::info!( + directive_id = %id, + cancelled_count = count, + "Cancelled old planning tasks superseded by order pickup" + ); + } + } + + // Set directive to active if draft/idle/paused + match directive.status.as_str() { + "draft" | "idle" | "paused" => { + if let Err(e) = repository::set_directive_status(pool, auth.owner_id, id, "active").await + { + tracing::warn!("Failed to set directive status to active: {}", e); + } + } + _ => {} + } + + // Advance ready steps + let _ = repository::advance_directive_ready_steps(pool, id).await; + + Json(PickUpOrdersResponse { + message: format!("Picked up {} orders", order_count), + order_count, + task_id: Some(task.id), + }) + .into_response() +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 29c55c4..ce18620 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -238,6 +238,7 @@ 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)) + .route("/directives/{id}/pick-up-orders", post(directives::pick_up_orders)) // Order endpoints .route( "/orders", |
