diff options
| author | soryu <soryu@soryu.co> | 2026-01-26 19:05:20 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-26 19:05:20 +0000 |
| commit | 6391c777bc812a9a2a2af40ff85930740446a405 (patch) | |
| tree | cdafbc6670624e97df34bd2a771240016e095c60 /makima/src/daemon | |
| parent | 39c743467391e00c7c970753e6165b025784af76 (diff) | |
| download | soryu-6391c777bc812a9a2a2af40ff85930740446a405.tar.gz soryu-6391c777bc812a9a2a2af40ff85930740446a405.zip | |
[WIP] Heartbeat checkpoint - 2026-01-26 19:05:20 UTCmakima/task-task-579ce72a-579ce72a
Diffstat (limited to 'makima/src/daemon')
| -rw-r--r-- | makima/src/daemon/task/manager.rs | 363 |
1 files changed, 363 insertions, 0 deletions
diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index 74a37bf..ca550f8 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -1987,6 +1987,14 @@ impl TaskManager { tracing::info!(source_dir = ?source_dir, "Inheriting git config"); self.handle_inherit_git_config(source_dir).await?; } + DaemonCommand::CreateExportPatch { task_id, contract_id } => { + tracing::info!(task_id = %task_id, contract_id = %contract_id, "Creating export patch"); + self.handle_create_export_patch(task_id, contract_id).await?; + } + DaemonCommand::GetWorktreeInfo { task_id } => { + tracing::info!(task_id = %task_id, "Getting worktree info"); + self.handle_get_worktree_info(task_id).await?; + } DaemonCommand::RestartDaemon => { tracing::info!("Received restart command from server, initiating daemon restart..."); // Shutdown all running tasks gracefully @@ -3443,6 +3451,361 @@ impl TaskManager { Ok(()) } + /// Handle CreateExportPatch command - create a git diff patch and send to server. + async fn handle_create_export_patch( + &self, + task_id: Uuid, + contract_id: Uuid, + ) -> Result<(), DaemonError> { + // Find the worktree for this task + let worktree_path = self.worktree_manager.find_worktree_by_task_id(task_id).await; + + let Some(worktree_path) = worktree_path else { + tracing::warn!(task_id = %task_id, "No worktree found for task"); + let msg = DaemonMessage::ExportPatchCreated { + task_id, + contract_id, + success: false, + patch_content: None, + files_count: None, + lines_added: None, + lines_removed: None, + base_commit_sha: None, + error: Some("No worktree found for task".to_string()), + }; + let _ = self.ws_tx.send(msg).await; + return Ok(()); + }; + + // Get the base commit (find merge-base with origin/main or use first commit) + let base_sha = self.get_base_commit_for_diff(&worktree_path).await; + + // Generate the diff + let diff_args = match &base_sha { + Some(sha) => vec!["diff", sha.as_str()], + None => vec!["diff", "--cached"], + }; + + let diff_result = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(&diff_args) + .output() + .await; + + let patch_content = match diff_result { + Ok(output) if output.status.success() => { + String::from_utf8_lossy(&output.stdout).to_string() + } + Ok(output) => { + let error = String::from_utf8_lossy(&output.stderr).to_string(); + tracing::warn!(task_id = %task_id, error = %error, "Failed to generate diff"); + let msg = DaemonMessage::ExportPatchCreated { + task_id, + contract_id, + success: false, + patch_content: None, + files_count: None, + lines_added: None, + lines_removed: None, + base_commit_sha: None, + error: Some(format!("Failed to generate diff: {}", error)), + }; + let _ = self.ws_tx.send(msg).await; + return Ok(()); + } + Err(e) => { + tracing::warn!(task_id = %task_id, error = %e, "Failed to run git diff"); + let msg = DaemonMessage::ExportPatchCreated { + task_id, + contract_id, + success: false, + patch_content: None, + files_count: None, + lines_added: None, + lines_removed: None, + base_commit_sha: None, + error: Some(format!("Failed to run git diff: {}", e)), + }; + let _ = self.ws_tx.send(msg).await; + return Ok(()); + } + }; + + // Get diff stats + let stat_args = match &base_sha { + Some(sha) => vec!["diff", "--stat", sha.as_str()], + None => vec!["diff", "--cached", "--stat"], + }; + + let stat_result = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(&stat_args) + .output() + .await; + + let (files_count, lines_added, lines_removed) = if let Ok(output) = stat_result { + self.parse_diff_stats(&String::from_utf8_lossy(&output.stdout)) + } else { + (0, 0, 0) + }; + + // Send the patch content back to the server + let msg = DaemonMessage::ExportPatchCreated { + task_id, + contract_id, + success: true, + patch_content: Some(patch_content), + files_count: Some(files_count), + lines_added: Some(lines_added), + lines_removed: Some(lines_removed), + base_commit_sha: base_sha, + error: None, + }; + let _ = self.ws_tx.send(msg).await; + + Ok(()) + } + + /// Parse git diff --stat output to extract file count and line changes. + fn parse_diff_stats(&self, stat_output: &str) -> (usize, usize, usize) { + let lines: Vec<&str> = stat_output.lines().collect(); + if lines.is_empty() { + return (0, 0, 0); + } + + // The last line usually looks like: + // "X files changed, Y insertions(+), Z deletions(-)" + let last_line = lines.last().unwrap_or(&""); + + let mut files = 0usize; + let mut added = 0usize; + let mut removed = 0usize; + + // Parse files changed + if let Some(pos) = last_line.find("files changed") { + if let Some(start) = last_line[..pos].trim().split_whitespace().last() { + files = start.parse().unwrap_or(0); + } + } else if let Some(pos) = last_line.find("file changed") { + if let Some(start) = last_line[..pos].trim().split_whitespace().last() { + files = start.parse().unwrap_or(0); + } + } + + // Parse insertions + if let Some(pos) = last_line.find("insertions(+)") { + let before = &last_line[..pos]; + if let Some(num_str) = before.split(',').last() { + if let Some(n) = num_str.trim().split_whitespace().next() { + added = n.parse().unwrap_or(0); + } + } + } else if let Some(pos) = last_line.find("insertion(+)") { + let before = &last_line[..pos]; + if let Some(num_str) = before.split(',').last() { + if let Some(n) = num_str.trim().split_whitespace().next() { + added = n.parse().unwrap_or(0); + } + } + } + + // Parse deletions + if let Some(pos) = last_line.find("deletions(-)") { + let before = &last_line[..pos]; + if let Some(num_str) = before.split(',').last() { + if let Some(n) = num_str.trim().split_whitespace().next() { + removed = n.parse().unwrap_or(0); + } + } + } else if let Some(pos) = last_line.find("deletion(-)") { + let before = &last_line[..pos]; + if let Some(num_str) = before.split(',').last() { + if let Some(n) = num_str.trim().split_whitespace().next() { + removed = n.parse().unwrap_or(0); + } + } + } + + (files, added, removed) + } + + /// Get the base commit to diff against for a worktree. + async fn get_base_commit_for_diff(&self, worktree_path: &std::path::Path) -> Option<String> { + // Try to find merge-base with origin/main, origin/master, or just use HEAD~ + for remote_branch in &["origin/main", "origin/master"] { + let result = tokio::process::Command::new("git") + .current_dir(worktree_path) + .args(["merge-base", "HEAD", remote_branch]) + .output() + .await; + + if let Ok(output) = result { + if output.status.success() { + let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !sha.is_empty() { + return Some(sha); + } + } + } + } + + // Fall back to first commit in the worktree + let result = tokio::process::Command::new("git") + .current_dir(worktree_path) + .args(["rev-list", "--max-parents=0", "HEAD"]) + .output() + .await; + + if let Ok(output) = result { + if output.status.success() { + let sha = String::from_utf8_lossy(&output.stdout).lines().next() + .map(|s| s.trim().to_string()); + return sha; + } + } + + None + } + + /// Handle GetWorktreeInfo command - get information about a task's worktree. + async fn handle_get_worktree_info( + &self, + task_id: Uuid, + ) -> Result<(), DaemonError> { + // Find the worktree for this task + let worktree_path = self.worktree_manager.find_worktree_by_task_id(task_id).await; + + let Some(worktree_path) = worktree_path else { + tracing::warn!(task_id = %task_id, "No worktree found for task"); + let msg = DaemonMessage::WorktreeInfo { + task_id, + success: false, + path: None, + branch: None, + base_commit: None, + files_changed: None, + has_uncommitted_changes: None, + error: Some("No worktree found for task".to_string()), + }; + let _ = self.ws_tx.send(msg).await; + return Ok(()); + }; + + // Get current branch + let branch_result = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(["branch", "--show-current"]) + .output() + .await; + let branch = branch_result.ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); + + // Get base commit + let base_commit = self.get_base_commit_for_diff(&worktree_path).await; + + // Check for uncommitted changes + let status_result = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(["status", "--porcelain"]) + .output() + .await; + let has_uncommitted = status_result.ok() + .map(|o| !o.stdout.is_empty()) + .unwrap_or(false); + + // Get list of changed files with stats + let diff_args = match &base_commit { + Some(sha) => vec!["diff", "--numstat", sha.as_str()], + None => vec!["diff", "--cached", "--numstat"], + }; + + let files_result = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(&diff_args) + .output() + .await; + + let files_changed = if let Ok(output) = files_result { + if output.status.success() { + let numstat = String::from_utf8_lossy(&output.stdout); + Some(self.parse_numstat(&numstat, &worktree_path).await) + } else { + None + } + } else { + None + }; + + let msg = DaemonMessage::WorktreeInfo { + task_id, + success: true, + path: Some(worktree_path.to_string_lossy().to_string()), + branch, + base_commit, + files_changed, + has_uncommitted_changes: Some(has_uncommitted), + error: None, + }; + let _ = self.ws_tx.send(msg).await; + + Ok(()) + } + + /// Parse git diff --numstat output into ChangedFileInfo vec. + async fn parse_numstat( + &self, + numstat: &str, + worktree_path: &std::path::Path, + ) -> Vec<crate::daemon::ws::ChangedFileInfo> { + let mut files = Vec::new(); + + // Get name-status as well to determine add/modify/delete status + let name_status_result = tokio::process::Command::new("git") + .current_dir(worktree_path) + .args(["diff", "--name-status", "HEAD"]) + .output() + .await; + + let mut status_map = std::collections::HashMap::new(); + if let Ok(output) = name_status_result { + if output.status.success() { + for line in String::from_utf8_lossy(&output.stdout).lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let status = match parts[0].chars().next() { + Some('A') => "added", + Some('M') => "modified", + Some('D') => "deleted", + Some('R') => "renamed", + _ => "modified", + }; + status_map.insert(parts[1].to_string(), status.to_string()); + } + } + } + } + + for line in numstat.lines() { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() >= 3 { + let added: i32 = parts[0].parse().unwrap_or(0); + let removed: i32 = parts[1].parse().unwrap_or(0); + let path = parts[2].to_string(); + let status = status_map.get(&path).cloned().unwrap_or_else(|| "modified".to_string()); + + files.push(crate::daemon::ws::ChangedFileInfo { + path, + status, + lines_added: added, + lines_removed: removed, + }); + } + } + + files + } + /// Apply inherited git config to a worktree directory. pub async fn apply_git_config(&self, worktree_path: &std::path::Path) -> Result<(), DaemonError> { let email = self.git_user_email.read().await.clone(); |
