diff options
| author | soryu <soryu@soryu.co> | 2026-02-09 21:21:10 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-09 21:21:21 +0000 |
| commit | 526edf672aae73c3670ab6141253bf92f1fbfe8c (patch) | |
| tree | ceb32352f5f38be4662564126e135724850dbc31 | |
| parent | 76bb9da745f6c12c8e7e587a9096677bbf98f395 (diff) | |
| download | soryu-526edf672aae73c3670ab6141253bf92f1fbfe8c.tar.gz soryu-526edf672aae73c3670ab6141253bf92f1fbfe8c.zip | |
Add auto-PR creation for remote repos in contracts
| -rw-r--r-- | makima/src/daemon/storage/mod.rs | 4 | ||||
| -rw-r--r-- | makima/src/daemon/storage/patch.rs | 179 | ||||
| -rw-r--r-- | makima/src/daemon/task/manager.rs | 176 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_daemon.rs | 108 |
4 files changed, 312 insertions, 155 deletions
diff --git a/makima/src/daemon/storage/mod.rs b/makima/src/daemon/storage/mod.rs index e5457f7..0397e91 100644 --- a/makima/src/daemon/storage/mod.rs +++ b/makima/src/daemon/storage/mod.rs @@ -6,6 +6,6 @@ mod patch; pub use patch::{ - apply_patch, create_export_patch, create_patch, get_head_sha, get_parent_sha, ExportPatchResult, - PatchError, + apply_patch, create_export_patch, create_patch, get_head_sha, get_merge_base_sha, + get_parent_sha, ExportPatchResult, PatchError, }; diff --git a/makima/src/daemon/storage/patch.rs b/makima/src/daemon/storage/patch.rs index b374d15..05c45a3 100644 --- a/makima/src/daemon/storage/patch.rs +++ b/makima/src/daemon/storage/patch.rs @@ -161,6 +161,99 @@ pub async fn get_head_sha(worktree_path: &Path) -> Result<String, PatchError> { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +/// Resolve the merge-base SHA for diffing against the main/master branch. +/// +/// Tries in order: +/// 1. Upstream tracking branch merge-base +/// 2. Common branches: origin/main, origin/master, main, master +/// 3. Fallback: HEAD~1 +/// +/// Returns `Err(PatchError::EmptyPatch)` if the merge-base equals HEAD (no diff). +pub async fn get_merge_base_sha(worktree_path: &Path) -> Result<String, PatchError> { + // Try to get the upstream tracking branch + let upstream_result = Command::new("git") + .current_dir(worktree_path) + .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) + .output() + .await; + + let base = if let Ok(output) = upstream_result { + if output.status.success() { + let upstream = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // Get merge-base with upstream + let merge_base = Command::new("git") + .current_dir(worktree_path) + .args(["merge-base", "HEAD", &upstream]) + .output() + .await; + + if let Ok(mb_output) = merge_base { + if mb_output.status.success() { + Some( + String::from_utf8_lossy(&mb_output.stdout) + .trim() + .to_string(), + ) + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + }; + + // Get current HEAD SHA for comparison + let head_sha = Command::new("git") + .current_dir(worktree_path) + .args(["rev-parse", "HEAD"]) + .output() + .await + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); + + // If we couldn't find upstream, try common default branches + let base = if base.is_none() { + let default_branches = ["origin/main", "origin/master", "main", "master"]; + let mut found_base = None; + + for branch in default_branches { + let merge_base = Command::new("git") + .current_dir(worktree_path) + .args(["merge-base", "HEAD", branch]) + .output() + .await; + + if let Ok(output) = merge_base { + if output.status.success() { + let mb_sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // Skip if merge-base equals HEAD (would result in empty diff) + if head_sha.as_ref() != Some(&mb_sha) { + found_base = Some(mb_sha); + break; + } + } + } + } + found_base + } else { + // Also check upstream base + if base.as_ref() == head_sha.as_ref() { + None + } else { + base + } + }; + + // If still nothing, fall back to HEAD~1 + Ok(base.unwrap_or_else(|| "HEAD~1".to_string())) +} + /// Result of creating an export patch. #[derive(Debug, Clone)] pub struct ExportPatchResult { @@ -192,91 +285,7 @@ pub async fn create_export_patch( // Determine the base SHA to diff against let resolved_base_sha = match base_sha { Some(sha) => sha.to_string(), - None => { - // Try to find the merge-base with the default branch - // First, try to get the upstream tracking branch - let upstream_result = Command::new("git") - .current_dir(worktree_path) - .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) - .output() - .await; - - let base = if let Ok(output) = upstream_result { - if output.status.success() { - let upstream = String::from_utf8_lossy(&output.stdout).trim().to_string(); - // Get merge-base with upstream - let merge_base = Command::new("git") - .current_dir(worktree_path) - .args(["merge-base", "HEAD", &upstream]) - .output() - .await; - - if let Ok(mb_output) = merge_base { - if mb_output.status.success() { - Some(String::from_utf8_lossy(&mb_output.stdout).trim().to_string()) - } else { - None - } - } else { - None - } - } else { - None - } - } else { - None - }; - - // Get current HEAD SHA for comparison - let head_sha = Command::new("git") - .current_dir(worktree_path) - .args(["rev-parse", "HEAD"]) - .output() - .await - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); - - // If we couldn't find upstream, try common default branches - let base = if base.is_none() { - let default_branches = ["origin/main", "origin/master", "main", "master"]; - let mut found_base = None; - - for branch in default_branches { - let merge_base = Command::new("git") - .current_dir(worktree_path) - .args(["merge-base", "HEAD", branch]) - .output() - .await; - - if let Ok(output) = merge_base { - if output.status.success() { - let mb_sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); - // Skip if merge-base equals HEAD (would result in empty diff) - if head_sha.as_ref() != Some(&mb_sha) { - found_base = Some(mb_sha); - break; - } - } - } - } - found_base - } else { - // Also check upstream base - if base.as_ref() == head_sha.as_ref() { - None - } else { - base - } - }; - - // If still nothing, get the first commit or use HEAD~1 - base.unwrap_or_else(|| { - // This will be used, but if HEAD~1 doesn't exist (only one commit), - // git diff will handle it gracefully - "HEAD~1".to_string() - }) - } + None => get_merge_base_sha(worktree_path).await?, }; // Get diff stats using --stat diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index 68c5c42..a24f527 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -5548,6 +5548,11 @@ impl TaskManagerInner { ); let _ = self.ws_tx.send(msg).await; } else { + // Create completion patch before notifying server + if let Err(e) = self.create_completion_patch(task_id, &working_dir).await { + tracing::debug!(task_id = %task_id, error = %e, "No completion patch created"); + } + let error = if success { None } else { @@ -5825,83 +5830,52 @@ impl TaskManagerInner { } } - /// Create an ephemeral patch of uncommitted changes and send to the server. - /// This does NOT create git commits or push - patches are stored in PostgreSQL only. + /// Create an ephemeral patch of all changes (committed + uncommitted) since the + /// merge-base with main/master and send to the server. + /// Stages and commits any uncommitted changes, then diffs against the merge-base. /// Returns the number of files changed on success, or an error message if nothing to patch. async fn create_ephemeral_patch( &self, task_id: Uuid, worktree_path: &std::path::Path, ) -> Result<i32, String> { - // 1. Get current HEAD SHA (base for the patch) - let base_sha_output = tokio::process::Command::new("git") - .current_dir(worktree_path) - .args(["rev-parse", "HEAD"]) - .output() - .await - .map_err(|e| format!("Failed to run git rev-parse: {}", e))?; - - if !base_sha_output.status.success() { - let stderr = String::from_utf8_lossy(&base_sha_output.stderr); - return Err(format!("git rev-parse failed: {}", stderr)); + if !self.checkpoint_patches.enabled { + return Err("Checkpoint patches disabled".into()); } - let base_sha = String::from_utf8_lossy(&base_sha_output.stdout).trim().to_string(); - - // 2. Check for uncommitted changes using git status --porcelain - let status_output = tokio::process::Command::new("git") - .current_dir(worktree_path) - .args(["status", "--porcelain"]) - .output() + // 1. Find merge-base with main/master (the fork point) + let base_sha = storage::get_merge_base_sha(worktree_path) .await - .map_err(|e| format!("Failed to run git status: {}", e))?; - - if !status_output.status.success() { - let stderr = String::from_utf8_lossy(&status_output.stderr); - return Err(format!("git status failed: {}", stderr)); - } - - let status = String::from_utf8_lossy(&status_output.stdout); - if status.trim().is_empty() { - return Err("No changes to patch".into()); - } + .map_err(|e| format!("Failed to get merge-base: {}", e))?; - // Count files with changes - let files_count = status.lines().count() as i32; - - // 3. Stage all changes (required for diff to include untracked files) - let add_output = tokio::process::Command::new("git") + // 2. Stage and commit any uncommitted changes so they're included in the diff + let _ = tokio::process::Command::new("git") .current_dir(worktree_path) .args(["add", "-A"]) .output() - .await - .map_err(|e| format!("Failed to run git add: {}", e))?; - - if !add_output.status.success() { - let stderr = String::from_utf8_lossy(&add_output.stderr); - return Err(format!("git add failed: {}", stderr)); - } + .await; - // 4. Create patch (diff of staged changes against HEAD) - if !self.checkpoint_patches.enabled { - // Reset staged changes and return - let _ = tokio::process::Command::new("git") - .current_dir(worktree_path) - .args(["reset", "HEAD"]) - .output() - .await; - return Err("Checkpoint patches disabled".into()); - } + // Check if there are staged changes to commit + let staged_check = tokio::process::Command::new("git") + .current_dir(worktree_path) + .args(["diff", "--cached", "--quiet"]) + .output() + .await; - match storage::create_patch(worktree_path, &base_sha).await { - Ok((compressed_patch, patch_files_count)) => { - // Reset staged changes (we don't want to commit) + if let Ok(output) = staged_check { + if !output.status.success() { + // There are staged changes - commit them let _ = tokio::process::Command::new("git") .current_dir(worktree_path) - .args(["reset", "HEAD"]) + .args(["commit", "-m", "WIP: heartbeat checkpoint"]) .output() .await; + } + } + // 3. Create patch (diff merge-base..HEAD captures all work) + match storage::create_patch(worktree_path, &base_sha).await { + Ok((compressed_patch, patch_files_count)) => { // Check size limit if compressed_patch.len() > self.checkpoint_patches.max_patch_size_bytes { tracing::warn!( @@ -5929,10 +5903,10 @@ impl TaskManagerInner { let msg = DaemonMessage::CheckpointCreated { task_id, success: true, - commit_sha: None, // No git commit + commit_sha: None, branch_name: None, - checkpoint_number: None, // Server will assign - files_changed: None, // Detailed file info not tracked for ephemeral patches + checkpoint_number: None, + files_changed: None, lines_added: None, lines_removed: None, error: None, @@ -5943,18 +5917,92 @@ impl TaskManagerInner { }; let _ = self.ws_tx.send(msg).await; - Ok(files_count) + Ok(patch_files_count as i32) } Err(e) => { - // Reset staged changes + Err(format!("Failed to create patch: {}", e)) + } + } + } + + /// Create a completion patch capturing all changes (committed + uncommitted) since + /// the merge-base with main/master. Sent before TaskComplete so the server always + /// has a recoverable patch. All errors are non-fatal (logged, not propagated). + async fn create_completion_patch( + &self, + task_id: Uuid, + worktree_path: &std::path::Path, + ) -> Result<(), String> { + // 1. Stage all changes + let _ = tokio::process::Command::new("git") + .current_dir(worktree_path) + .args(["add", "-A"]) + .output() + .await; + + // 2. Commit any staged changes so HEAD contains everything + let staged_check = tokio::process::Command::new("git") + .current_dir(worktree_path) + .args(["diff", "--cached", "--quiet"]) + .output() + .await; + + if let Ok(output) = staged_check { + if !output.status.success() { let _ = tokio::process::Command::new("git") .current_dir(worktree_path) - .args(["reset", "HEAD"]) + .args(["commit", "-m", "WIP: task completion checkpoint"]) .output() .await; - Err(format!("Failed to create patch: {}", e)) } } + + // 3. Find merge-base with main/master + let base_sha = storage::get_merge_base_sha(worktree_path) + .await + .map_err(|e| format!("Failed to get merge-base: {}", e))?; + + // 4. Create patch (diff merge-base..HEAD) + let (compressed_patch, patch_files_count) = storage::create_patch(worktree_path, &base_sha) + .await + .map_err(|e| format!("Failed to create patch: {}", e))?; + + // 5. Check size limit + if compressed_patch.len() > self.checkpoint_patches.max_patch_size_bytes { + return Err(format!( + "Patch too large: {} bytes (max: {})", + compressed_patch.len(), + self.checkpoint_patches.max_patch_size_bytes + )); + } + + // 6. Send to server + let patch_data = base64::engine::general_purpose::STANDARD.encode(&compressed_patch); + let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"); + let msg = DaemonMessage::CheckpointCreated { + task_id, + success: true, + commit_sha: None, + branch_name: None, + checkpoint_number: None, + files_changed: None, + lines_added: None, + lines_removed: None, + error: None, + message: format!("Completion patch - {}", timestamp), + patch_data: Some(patch_data), + patch_base_sha: Some(base_sha), + patch_files_count: Some(patch_files_count as i32), + }; + let _ = self.ws_tx.send(msg).await; + + tracing::info!( + task_id = %task_id, + files_count = patch_files_count, + "Created completion patch" + ); + + Ok(()) } } diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index 60de2e6..743a1ca 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -626,10 +626,7 @@ async fn compute_action_directive( if all_tasks_done && !pr_deliverable_complete { let done_count = task_infos.len(); return Some(format!( - "[ACTION REQUIRED] All {} task(s) completed successfully.\n\ - You MUST now create a PR and mark the 'pull-request' deliverable as complete:\n\ - 1. Ensure all changes are merged to your branch\n\ - 2. Create PR and then call mark_deliverable_complete with deliverable_id='pull-request'", + "[INFO] All {} task(s) completed. System is auto-creating PR.", done_count )); } @@ -638,6 +635,98 @@ async fn compute_action_directive( None } +/// Automatically create a PR when all non-supervisor tasks for a contract are done. +/// Only applies to remote-repo contracts in the "execute" phase. +/// Fires as a best-effort operation — errors are logged but not propagated. +async fn auto_create_pr_if_ready( + pool: &sqlx::PgPool, + state: &SharedState, + contract_id: Uuid, + owner_id: Uuid, +) { + // 1. Load contract — must be remote (not local_only) and in execute phase + let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { + Ok(Some(c)) => c, + _ => return, + }; + if contract.local_only || contract.phase != "execute" { + return; + } + + // 2. Load non-supervisor tasks — all must be done + let tasks = match repository::list_tasks_by_contract(pool, contract_id, owner_id).await { + Ok(t) => t, + _ => return, + }; + let non_supervisor_tasks: Vec<_> = tasks.iter().filter(|t| !t.is_supervisor).collect(); + if non_supervisor_tasks.is_empty() || !non_supervisor_tasks.iter().all(|t| t.status == "done") { + return; + } + + // 3. Check pull-request deliverable not already complete + let completed_deliverables = contract.get_completed_deliverables(&contract.phase); + if completed_deliverables.contains(&"pull-request".to_string()) { + return; + } + + // 4. Check at least one repository has a remote URL + let repos = match repository::list_contract_repositories(pool, contract_id).await { + Ok(r) => r, + _ => return, + }; + if !repos.iter().any(|r| r.repository_url.is_some()) { + return; + } + + // 5. Load supervisor task + let supervisor = match repository::get_contract_supervisor_task(pool, contract_id).await { + Ok(Some(s)) => s, + _ => return, + }; + + // Need supervisor's daemon_id to send command + let daemon_id = match supervisor.daemon_id { + Some(id) => id, + None => return, + }; + + // 6. Construct branch name + let sanitized_name: String = supervisor + .name + .chars() + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }) + .collect::<String>() + .to_lowercase(); + let short_id = &supervisor.id.to_string()[..8]; + let branch = format!("makima/{}-{}", sanitized_name, short_id); + + // 7. Send CreatePR command to supervisor's daemon + let command = DaemonCommand::CreatePR { + task_id: supervisor.id, + title: contract.name.clone(), + body: contract.description.clone(), + base_branch: supervisor.base_branch.clone(), + branch, + }; + + match state.send_daemon_command(daemon_id, command).await { + Ok(()) => { + tracing::info!( + contract_id = %contract_id, + supervisor_id = %supervisor.id, + "Auto-PR: sent CreatePR command to supervisor daemon" + ); + } + Err(e) => { + tracing::warn!( + contract_id = %contract_id, + error = %e, + "Auto-PR: failed to send CreatePR command" + ); + } + } +} + /// Validate an API key and return (user_id, owner_id). async fn validate_daemon_api_key(pool: &sqlx::PgPool, key: &str) -> Result<DaemonAuthResult, String> { let key_hash = hash_api_key(key); @@ -1286,6 +1375,17 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re } } + // Auto-create PR if all tasks are done and repo is remote + if updated_task.status == "done" { + if let Some(contract_id) = updated_task.contract_id { + let pool_c = pool.clone(); + let state_c = state.clone(); + tokio::spawn(async move { + auto_create_pr_if_ready(&pool_c, &state_c, contract_id, owner_id).await; + }); + } + } + // Record history event for task completion let subtype = if updated_task.status == "done" { "completed" } else { "failed" }; let _ = repository::record_history_event( |
