diff options
| -rw-r--r-- | makima/src/daemon/task/manager.rs | 363 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_daemon.rs | 200 |
2 files changed, 562 insertions, 1 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(); diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index 84c7a93..1caf805 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -519,7 +519,7 @@ pub enum DaemonMessage { } /// Information about a changed file in a worktree. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, serde::Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChangedFileInfo { /// File path relative to worktree root @@ -1845,6 +1845,204 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re ); } } + Ok(DaemonMessage::ExportPatchCreated { + task_id, + contract_id, + success, + patch_content, + files_count, + lines_added, + lines_removed, + base_commit_sha, + error, + }) => { + tracing::info!( + task_id = %task_id, + contract_id = %contract_id, + success = success, + files_count = ?files_count, + "Export patch result received" + ); + + if success { + if let (Some(pool), Some(patch)) = (&state.db_pool, patch_content) { + // Get task details for naming + let task_name = match repository::get_task(pool, task_id).await { + Ok(Some(task)) => task.name, + _ => format!("Task {}", task_id), + }; + + // Create contract file with patch content + let file_name = format!("{} - Patch", task_name); + + // Build patch file content with instructions + let body_content = format!( + "# Patch: {}\n\n\ + ## Metadata\n\ + - **Task ID:** `{}`\n\ + - **Base Commit:** `{}`\n\ + - **Files Changed:** {}\n\ + - **Lines Added:** {}\n\ + - **Lines Removed:** {}\n\n\ + ## How to Apply\n\n\ + ```bash\n\ + # Save this patch to a file\n\ + # Then apply with:\n\ + git apply --check patch.diff # Dry run first\n\ + git apply patch.diff # Apply for real\n\ + ```\n\n\ + ## Patch Content\n\n\ + ```diff\n\ + {}\n\ + ```", + task_name, + task_id, + base_commit_sha.as_deref().unwrap_or("unknown"), + files_count.unwrap_or(0), + lines_added.unwrap_or(0), + lines_removed.unwrap_or(0), + patch + ); + + // Convert markdown to body elements + let body = crate::llm::markdown_to_body(&body_content); + + // Create the file request + let create_req = crate::db::models::CreateFileRequest { + name: file_name.clone(), + description: Some(format!( + "Git patch from task '{}' ({} files, +{} -{} lines)", + task_name, + files_count.unwrap_or(0), + lines_added.unwrap_or(0), + lines_removed.unwrap_or(0) + )), + transcript: None, + summary: None, + body: Some(body), + contract_id: Some(contract_id), + duration: None, + repo_file_path: None, + }; + + match repository::create_file_for_owner(pool, owner_id, create_req).await { + Ok(file) => { + tracing::info!( + file_id = %file.id, + task_id = %task_id, + contract_id = %contract_id, + "Created patch file in contract" + ); + + // Broadcast file creation notification + state.broadcast_file_update(crate::server::state::FileUpdateNotification { + file_id: file.id, + version: 1, + updated_fields: vec!["created".to_string()], + updated_by: "daemon".to_string(), + }); + + // Record history event + let _ = repository::record_history_event( + pool, + owner_id, + Some(contract_id), + Some(task_id), + "file", + Some("patch_exported"), + None, + serde_json::json!({ + "fileId": file.id, + "fileName": file_name, + "filesCount": files_count, + "linesAdded": lines_added, + "linesRemoved": lines_removed, + }), + ).await; + + // Notify supervisor if applicable + if let Ok(Some(supervisor)) = repository::get_contract_supervisor_task(pool, contract_id).await { + let msg = format!( + "[INFO] Patch exported successfully!\n\ + File: {}\n\ + Changes: {} files, +{} -{} lines\n\n\ + The patch has been saved to the contract files.", + file_name, + files_count.unwrap_or(0), + lines_added.unwrap_or(0), + lines_removed.unwrap_or(0) + ); + let _ = state.notify_supervisor( + supervisor.id, + supervisor.daemon_id, + &msg, + ).await; + } + } + Err(e) => { + tracing::error!( + task_id = %task_id, + contract_id = %contract_id, + error = %e, + "Failed to create patch file" + ); + } + } + } + } else { + tracing::warn!( + task_id = %task_id, + error = ?error, + "Failed to export patch" + ); + } + } + Ok(DaemonMessage::WorktreeInfo { + task_id, + success, + path, + branch, + base_commit, + files_changed, + has_uncommitted_changes, + error, + }) => { + tracing::info!( + task_id = %task_id, + success = success, + path = ?path, + branch = ?branch, + files_count = ?files_changed.as_ref().map(|f| f.len()), + "Worktree info received" + ); + + // Broadcast as task output for the UI to display + if success { + let info = serde_json::json!({ + "type": "worktreeInfo", + "path": path, + "branch": branch, + "baseCommit": base_commit, + "filesChanged": files_changed, + "hasUncommittedChanges": has_uncommitted_changes, + }); + + state.broadcast_task_output(crate::server::state::TaskOutputNotification { + task_id, + owner_id: Some(owner_id), + message_type: "worktree_info".to_string(), + content: serde_json::to_string_pretty(&info).unwrap_or_default(), + tool_name: None, + tool_input: Some(info), + }); + } else { + tracing::warn!( + task_id = %task_id, + error = ?error, + "Failed to get worktree info" + ); + } + } Err(e) => { tracing::warn!("Failed to parse daemon message: {}", e); } |
