summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-16 15:45:42 +0000
committersoryu <soryu@soryu.co>2026-02-16 15:45:42 +0000
commit7d2079d7c13804766405af8044574bfc93a86897 (patch)
tree05212cb5cd472eff75ed54b9805a1d6ef5c7d922
parent29ec8e53f2acf56fe4a2cd02d352144c697a6afc (diff)
downloadsoryu-7d2079d7c13804766405af8044574bfc93a86897.tar.gz
soryu-7d2079d7c13804766405af8044574bfc93a86897.zip
Add PR button to directives
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx16
-rw-r--r--makima/frontend/src/hooks/useDirectives.ts8
-rw-r--r--makima/frontend/src/lib/api.ts6
-rw-r--r--makima/frontend/src/routes/directives.tsx3
-rw-r--r--makima/src/orchestration/directive.rs173
-rw-r--r--makima/src/server/handlers/directives.rs82
-rw-r--r--makima/src/server/mod.rs1
-rw-r--r--makima/src/server/openapi.rs1
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,