diff options
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDetail.tsx | 35 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useDirectives.ts | 9 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 14 | ||||
| -rw-r--r-- | makima/frontend/src/routes/directives.tsx | 3 | ||||
| -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 |
9 files changed, 540 insertions, 3 deletions
diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx index e278939..9305e20 100644 --- a/makima/frontend/src/components/directives/DirectiveDetail.tsx +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -26,6 +26,7 @@ interface DirectiveDetailProps { onDelete: () => void; onRefresh: () => void; onCleanupTasks: () => void; + onPickUpOrders: () => Promise<{ message: string; orderCount: number; taskId: string | null } | null>; } export function DirectiveDetail({ @@ -41,10 +42,13 @@ export function DirectiveDetail({ onDelete, onRefresh, onCleanupTasks, + onPickUpOrders, }: DirectiveDetailProps) { const [editingGoal, setEditingGoal] = useState(false); const [goalText, setGoalText] = useState(directive.goal); const [visibleTaskIds, setVisibleTaskIds] = useState<Set<string> | null>(null); + const [pickingUpOrders, setPickingUpOrders] = useState(false); + const [pickUpResult, setPickUpResult] = useState<string | null>(null); // Sync goalText and reset editing state when directive changes useEffect(() => { @@ -121,6 +125,23 @@ export function DirectiveDetail({ prevHadRunningRef.current = hasRunningTasks; }, [hasRunningTasks]); + const handlePickUpOrders = async () => { + setPickingUpOrders(true); + setPickUpResult(null); + try { + const result = await onPickUpOrders(); + if (result) { + setPickUpResult(result.message); + setTimeout(() => setPickUpResult(null), 5000); + } + } catch (e) { + setPickUpResult(e instanceof Error ? e.message : "Failed to pick up orders"); + setTimeout(() => setPickUpResult(null), 5000); + } finally { + setPickingUpOrders(false); + } + }; + const handleGoalSave = () => { if (goalText.trim() && goalText !== directive.goal) { onUpdateGoal(goalText.trim()); @@ -314,12 +335,26 @@ export function DirectiveDetail({ )} <button type="button" + onClick={handlePickUpOrders} + disabled={pickingUpOrders} + className="text-[10px] font-mono text-[#c084fc] hover:text-[#d8b4fe] border border-[rgba(192,132,252,0.3)] rounded px-2 py-1 disabled:opacity-50" + > + {pickingUpOrders ? "Picking up..." : "Pick Up Orders"} + </button> + <button + type="button" onClick={onDelete} className={`text-[10px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-2 py-1 ${hasTerminalTasks ? "" : "ml-auto"}`} > Delete </button> </div> + + {pickUpResult && ( + <div className="mt-2 px-2 py-1.5 bg-[#1a1030] border border-[rgba(192,132,252,0.2)] rounded"> + <span className="text-[10px] font-mono text-[#c084fc]">{pickUpResult}</span> + </div> + )} </div> {/* Goal */} diff --git a/makima/frontend/src/hooks/useDirectives.ts b/makima/frontend/src/hooks/useDirectives.ts index 0453d14..7e26ec4 100644 --- a/makima/frontend/src/hooks/useDirectives.ts +++ b/makima/frontend/src/hooks/useDirectives.ts @@ -20,6 +20,7 @@ import { skipDirectiveStep, updateDirectiveGoal, cleanupDirectiveTasks, + pickUpOrders as pickUpOrdersApi, } from "../lib/api"; export function useDirectives() { @@ -177,11 +178,19 @@ export function useDirective(id: string | undefined) { await refresh(); }, [id, refresh]); + const pickUpOrdersFn = useCallback(async () => { + if (!id) return null; + const result = await pickUpOrdersApi(id); + await refresh(); + return result; + }, [id, refresh]); + return { directive, loading, error, refresh, update, addStep, removeStep, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanupTasks, + pickUpOrders: pickUpOrdersFn, }; } diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index f88176b..467ee22 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3256,6 +3256,20 @@ export async function cleanupDirectiveTasks(id: string): Promise<{ deleted: numb return res.json(); } +export interface PickUpOrdersResponse { + message: string; + orderCount: number; + taskId: string | null; +} + +export async function pickUpOrders(directiveId: string): Promise<PickUpOrdersResponse> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/pick-up-orders`, { + method: "POST", + }); + if (!res.ok) throw new Error(`Failed to pick up orders: ${res.statusText}`); + return res.json(); +} + // ============================================================================= // Orders API // ============================================================================= diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx index 643cfee..b4ed0cc 100644 --- a/makima/frontend/src/routes/directives.tsx +++ b/makima/frontend/src/routes/directives.tsx @@ -12,7 +12,7 @@ export default function DirectivesPage() { const navigate = useNavigate(); const { id: selectedId } = useParams<{ id: string }>(); const { directives, loading: listLoading, create, remove } = useDirectives(); - const { directive, refresh: refreshDetail, update, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanupTasks } = useDirective(selectedId); + const { directive, refresh: refreshDetail, update, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanupTasks, pickUpOrders } = useDirective(selectedId); const [showCreate, setShowCreate] = useState(false); const [newTitle, setNewTitle] = useState(""); @@ -211,6 +211,7 @@ export default function DirectivesPage() { onDelete={handleDelete} onRefresh={refreshDetail} onCleanupTasks={cleanupTasks} + onPickUpOrders={pickUpOrders} /> ) : ( <div className="flex-1 flex items-center justify-center h-full"> 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", |
