summaryrefslogtreecommitdiff
path: root/makima/src
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-16 15:09:25 +0000
committerGitHub <noreply@github.com>2026-02-16 15:09:25 +0000
commitb6a29bb563499b2fd6280c742bd2106d66393112 (patch)
tree6f8d13fe989613b687b12e37277b661ff4d607c8 /makima/src
parent0676468e3e69ff36f1e509d775f191dd41f6080b (diff)
downloadsoryu-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.rs9
-rw-r--r--makima/src/db/repository.rs55
-rw-r--r--makima/src/orchestration/directive.rs217
-rw-r--r--makima/src/server/handlers/directives.rs200
-rw-r--r--makima/src/server/mod.rs1
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",