diff options
| author | soryu <soryu@soryu.co> | 2026-02-16 15:45:42 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-16 15:45:42 +0000 |
| commit | 7d2079d7c13804766405af8044574bfc93a86897 (patch) | |
| tree | 05212cb5cd472eff75ed54b9805a1d6ef5c7d922 | |
| parent | 29ec8e53f2acf56fe4a2cd02d352144c697a6afc (diff) | |
| download | soryu-7d2079d7c13804766405af8044574bfc93a86897.tar.gz soryu-7d2079d7c13804766405af8044574bfc93a86897.zip | |
Add PR button to directives
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDetail.tsx | 16 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useDirectives.ts | 8 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 6 | ||||
| -rw-r--r-- | makima/frontend/src/routes/directives.tsx | 3 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 173 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 82 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 1 |
8 files changed, 289 insertions, 1 deletions
diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx index 9305e20..c8da7a0 100644 --- a/makima/frontend/src/components/directives/DirectiveDetail.tsx +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -27,6 +27,7 @@ interface DirectiveDetailProps { onRefresh: () => void; onCleanupTasks: () => void; onPickUpOrders: () => Promise<{ message: string; orderCount: number; taskId: string | null } | null>; + onCreatePR: () => Promise<void>; } export function DirectiveDetail({ @@ -43,12 +44,14 @@ export function DirectiveDetail({ onRefresh, onCleanupTasks, onPickUpOrders, + onCreatePR, }: 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); + const [creatingPR, setCreatingPR] = useState(false); // Sync goalText and reset editing state when directive changes useEffect(() => { @@ -333,6 +336,19 @@ export function DirectiveDetail({ Clean up tasks </button> )} + {completedSteps > 0 && !directive.completionTaskId && ( + <button + type="button" + onClick={async () => { + setCreatingPR(true); + try { await onCreatePR(); } catch (e) { console.error("Failed to create PR:", e); } finally { setCreatingPR(false); } + }} + disabled={creatingPR} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-1 disabled:opacity-50" + > + {creatingPR ? "Creating..." : directive.prUrl ? "Update PR" : "Create PR"} + </button> + )} <button type="button" onClick={handlePickUpOrders} diff --git a/makima/frontend/src/hooks/useDirectives.ts b/makima/frontend/src/hooks/useDirectives.ts index 7e26ec4..18544da 100644 --- a/makima/frontend/src/hooks/useDirectives.ts +++ b/makima/frontend/src/hooks/useDirectives.ts @@ -21,6 +21,7 @@ import { updateDirectiveGoal, cleanupDirectiveTasks, pickUpOrders as pickUpOrdersApi, + createDirectivePR, } from "../lib/api"; export function useDirectives() { @@ -185,6 +186,12 @@ export function useDirective(id: string | undefined) { return result; }, [id, refresh]); + const createPR = useCallback(async () => { + if (!id) return; + await createDirectivePR(id); + await refresh(); + }, [id, refresh]); + return { directive, loading, error, refresh, update, addStep, removeStep, @@ -192,5 +199,6 @@ export function useDirective(id: string | undefined) { completeStep, failStep, skipStep, updateGoal, cleanupTasks, pickUpOrders: pickUpOrdersFn, + createPR, }; } diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 467ee22..a496412 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3262,6 +3262,12 @@ export interface PickUpOrdersResponse { taskId: string | null; } +export async function createDirectivePR(id: string): Promise<DirectiveWithSteps> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/create-pr`, { method: "POST" }); + if (!res.ok) throw new Error(`Failed to create PR: ${res.statusText}`); + return res.json(); +} + export async function pickUpOrders(directiveId: string): Promise<PickUpOrdersResponse> { const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/pick-up-orders`, { method: "POST", diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx index b4ed0cc..2bb673c 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, pickUpOrders } = useDirective(selectedId); + const { directive, refresh: refreshDetail, update, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanupTasks, pickUpOrders, createPR } = useDirective(selectedId); const [showCreate, setShowCreate] = useState(false); const [newTitle, setNewTitle] = useState(""); @@ -212,6 +212,7 @@ export default function DirectivesPage() { onRefresh={refreshDetail} onCleanupTasks={cleanupTasks} onPickUpOrders={pickUpOrders} + onCreatePR={createPR} /> ) : ( <div className="flex-1 flex items-center justify-center h-full"> diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 25aaf1b..736715d 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -901,6 +901,179 @@ impl DirectiveOrchestrator { } } +/// Trigger a completion task (PR creation/update) for a directive. +/// +/// This is the public entry point used by both the orchestrator tick and the +/// manual "create PR" API handler. It encapsulates the full flow: +/// 1. Validate the directive has completed step tasks +/// 2. Claim the directive for completion (returns error if already claimed) +/// 3. Build branch names and prompt +/// 4. Spawn the completion task and assign it +/// +/// Returns the created task ID on success. +pub async fn trigger_completion_task( + pool: &PgPool, + state: &SharedState, + directive_id: Uuid, + owner_id: Uuid, +) -> Result<Uuid, anyhow::Error> { + let directive = repository::get_directive_for_owner(pool, owner_id, directive_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Directive not found"))?; + + // Check for already-running completion task + if directive.completion_task_id.is_some() { + anyhow::bail!("A completion task is already running for this directive"); + } + + let step_tasks = repository::get_completed_step_tasks(pool, directive_id).await?; + if step_tasks.is_empty() { + anyhow::bail!("No completed steps with tasks found"); + } + + // Claim for completion + let placeholder_id = Uuid::new_v4(); + let claimed = + repository::claim_directive_for_completion(pool, directive_id, placeholder_id).await?; + if !claimed { + anyhow::bail!("Directive already claimed for completion"); + } + + let base_branch = directive.base_branch.as_deref().unwrap_or("main"); + + let directive_branch = format!( + "makima/directive-{}-{}", + crate::daemon::worktree::sanitize_name(&directive.title), + crate::daemon::worktree::short_uuid(directive.id), + ); + + let step_branches: Vec<String> = step_tasks + .iter() + .map(|st| { + format!( + "makima/{}-{}", + crate::daemon::worktree::sanitize_name(&st.task_name), + crate::daemon::worktree::short_uuid(st.task_id), + ) + }) + .collect(); + + let prompt = if directive.pr_url.is_some() { + build_verification_prompt(&directive, &directive_branch, base_branch) + } else { + build_completion_prompt(&directive, &step_tasks, &step_branches, &directive_branch, base_branch) + }; + + let task_name = if directive.pr_url.is_some() { + format!("Update PR: {}", directive.title) + } else { + format!("PR: {}", directive.title) + }; + + // Create the completion task + let req = CreateTaskRequest { + contract_id: None, + name: task_name, + description: Some("Directive PR completion task".to_string()), + plan: prompt, + 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 = repository::create_task_for_owner(pool, owner_id, req).await?; + + // Update pr_branch on the directive + let update = crate::db::models::UpdateDirectiveRequest { + pr_branch: Some(directive_branch), + ..Default::default() + }; + let _ = repository::update_directive_for_owner(pool, owner_id, directive_id, update).await; + + // Assign the real task as the completion task + repository::assign_completion_task(pool, directive_id, task.id).await?; + + // Try to dispatch to a daemon + if let Some(daemon_id) = state.find_alternative_daemon(owner_id, &[]) { + let update_req = crate::db::models::UpdateTaskRequest { + status: Some("starting".to_string()), + daemon_id: Some(daemon_id), + version: Some(task.version), + ..Default::default() + }; + + if let Ok(Some(updated_task)) = + repository::update_task_for_owner(pool, task.id, owner_id, update_req).await + { + let (patch_data, patch_base_sha) = + if let Some(from_id) = updated_task.continue_from_task_id { + match repository::get_latest_checkpoint_patch(pool, from_id).await { + Ok(Some(patch)) => { + let encoded = base64::engine::general_purpose::STANDARD + .encode(&patch.patch_data); + (Some(encoded), Some(patch.base_commit_sha)) + } + _ => (None, None), + } + } else { + (None, None) + }; + + let command = DaemonCommand::SpawnTask { + task_id: task.id, + task_name: task.name.clone(), + plan: task.plan.clone(), + repo_url: updated_task.repository_url.clone(), + base_branch: updated_task.base_branch.clone(), + target_branch: updated_task.target_branch.clone(), + parent_task_id: None, + depth: 0, + is_orchestrator: false, + target_repo_path: None, + completion_action: updated_task.completion_action.clone(), + continue_from_task_id: updated_task.continue_from_task_id, + copy_files: None, + contract_id: None, + is_supervisor: false, + autonomous_loop: false, + resume_session: false, + conversation_history: None, + patch_data, + patch_base_sha, + local_only: false, + auto_merge_local: false, + supervisor_worktree_task_id: None, + directive_id: updated_task.directive_id, + }; + + if let Err(e) = state.send_daemon_command(daemon_id, command).await { + tracing::warn!( + task_id = %task.id, + daemon_id = %daemon_id, + error = %e, + "Failed to dispatch completion task to daemon" + ); + } + } + } + + Ok(task.id) +} + /// Build the planning prompt for a directive. fn build_planning_prompt( directive: &crate::db::models::Directive, diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index f03dccf..960da94 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -930,6 +930,88 @@ pub async fn cleanup_tasks( } // ============================================================================= +// PR Creation +// ============================================================================= + +/// Trigger PR creation or update for a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/create-pr", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "PR task spawned", body = DirectiveWithSteps), + (status = 404, description = "Not found", body = ApiError), + (status = 409, description = "Completion task already running", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn create_pr( + 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 crate::orchestration::directive::trigger_completion_task( + pool, + &state, + id, + auth.owner_id, + ) + .await + { + Ok(_task_id) => { + // Return the updated directive with steps + match repository::get_directive_with_steps_for_owner(pool, auth.owner_id, id).await { + Ok(Some((directive, steps))) => { + Json(DirectiveWithSteps { directive, steps }).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(), + } + } + Err(e) => { + let msg = e.to_string(); + if msg.contains("not found") { + ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", &msg)), + ) + .into_response() + } else if msg.contains("already running") || msg.contains("already claimed") { + ( + StatusCode::CONFLICT, + Json(ApiError::new("COMPLETION_IN_PROGRESS", &msg)), + ) + .into_response() + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_PR_FAILED", &msg)), + ) + .into_response() + } + } + } +} + +// ============================================================================= // Order Pickup // ============================================================================= diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index ce18620..c963618 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}/create-pr", post(directives::create_pr)) .route("/directives/{id}/pick-up-orders", post(directives::pick_up_orders)) // Order endpoints .route( diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index b21dab9..9c6463a 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -129,6 +129,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directives::skip_step, directives::update_goal, directives::cleanup_tasks, + directives::create_pr, // Order endpoints orders::list_orders, orders::create_order, |
