summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-26 19:05:20 +0000
committersoryu <soryu@soryu.co>2026-01-26 19:05:20 +0000
commit6391c777bc812a9a2a2af40ff85930740446a405 (patch)
treecdafbc6670624e97df34bd2a771240016e095c60
parent39c743467391e00c7c970753e6165b025784af76 (diff)
downloadsoryu-6391c777bc812a9a2a2af40ff85930740446a405.tar.gz
soryu-6391c777bc812a9a2a2af40ff85930740446a405.zip
[WIP] Heartbeat checkpoint - 2026-01-26 19:05:20 UTCmakima/task-task-579ce72a-579ce72a
-rw-r--r--makima/src/daemon/task/manager.rs363
-rw-r--r--makima/src/server/handlers/mesh_daemon.rs200
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);
}