diff options
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/daemon/task/manager.rs | 338 | ||||
| -rw-r--r-- | makima/src/daemon/ws/protocol.rs | 17 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 275 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_daemon.rs | 60 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 2 | ||||
| -rw-r--r-- | makima/src/server/state.rs | 43 |
6 files changed, 704 insertions, 31 deletions
diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index dd7df8a..acdf4ad 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -1925,6 +1925,10 @@ impl TaskManager { tracing::info!(task_id = %task_id, "Getting worktree info"); self.handle_get_worktree_info(task_id).await?; } + DaemonCommand::CommitWorktree { task_id, message } => { + tracing::info!(task_id = %task_id, "Committing worktree changes"); + self.handle_commit_worktree(task_id, message).await?; + } DaemonCommand::CreateCheckpoint { task_id, message, @@ -3322,6 +3326,96 @@ impl TaskManager { Ok(()) } + /// Handle CommitWorktree command - stage and commit changes in a task's worktree. + async fn handle_commit_worktree( + &self, + task_id: Uuid, + message: Option<String>, + ) -> Result<(), DaemonError> { + // Get task's worktree path + let worktree_path = { + let tasks = self.tasks.read().await; + tasks.get(&task_id) + .and_then(|t| t.worktree.as_ref()) + .map(|w| w.path.clone()) + }; + + let (success, commit_sha, error) = if let Some(path) = worktree_path { + // Step 1: Check if there are changes to commit + let status_output = tokio::process::Command::new("git") + .current_dir(&path) + .args(["status", "--porcelain"]) + .output() + .await; + + let has_changes = match &status_output { + Ok(output) => !output.stdout.is_empty(), + Err(_) => false, + }; + + if !has_changes { + (true, None, Some("No changes to commit".to_string())) + } else { + // Step 2: Stage all changes + let add_result = tokio::process::Command::new("git") + .current_dir(&path) + .args(["add", "-A"]) + .output() + .await; + + match add_result { + Ok(output) if output.status.success() => { + // Step 3: Commit + let commit_msg = message.unwrap_or_else(|| "Worktree commit".to_string()); + let commit_result = tokio::process::Command::new("git") + .current_dir(&path) + .args(["commit", "-m", &commit_msg]) + .output() + .await; + + match commit_result { + Ok(output) if output.status.success() => { + // Step 4: Get commit SHA + let sha_output = tokio::process::Command::new("git") + .current_dir(&path) + .args(["rev-parse", "HEAD"]) + .output() + .await; + + let sha = sha_output.ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); + + (true, sha, None) + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + (false, None, Some(format!("Git commit failed: {}", stderr))) + } + Err(e) => (false, None, Some(format!("Failed to run git commit: {}", e))), + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + (false, None, Some(format!("Failed to stage changes: {}", stderr))) + } + Err(e) => (false, None, Some(format!("Failed to run git add: {}", e))), + } + } + } else { + (false, None, Some(format!("Task {} not found or has no worktree", task_id))) + }; + + let msg = DaemonMessage::WorktreeCommitResult { + task_id, + success, + commit_sha, + error, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + /// Handle GetWorktreeInfo command - get worktree files, stats, branch info. async fn handle_get_worktree_info( &self, @@ -3451,35 +3545,57 @@ impl TaskManager { }; if let Some(ref base) = effective_base_branch { - // Get committed changes using git diff --name-status - let diff_base = format!("origin/{}...HEAD", base); - let name_status_output = tokio::process::Command::new("git") - .current_dir(&path) - .args(["diff", "--name-status", &diff_base]) - .output() - .await; + // Resolve the best diff base reference, handling missing remote refs + let resolved_diff_base = Self::resolve_diff_base(&path, base).await; + + if let Some(ref diff_base) = resolved_diff_base { + // Get committed changes using git diff --name-status + let name_status_output = tokio::process::Command::new("git") + .current_dir(&path) + .args(["diff", "--name-status", diff_base]) + .output() + .await; + + let committed_status_lines: Vec<(String, String)> = match name_status_output { + Ok(output) if output.status.success() => { + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.splitn(2, '\t').collect(); + if parts.len() >= 2 { + let status = parts[0].trim().to_string(); + let file_path = parts[1].to_string(); + Some((file_path, status)) + } else { + None + } + }) + .collect() + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + diff_base = %diff_base, + stderr = %stderr, + "git diff --name-status failed with resolved diff base", + ); + vec![] + } + Err(e) => { + tracing::warn!( + error = %e, + diff_base = %diff_base, + "Failed to execute git diff --name-status", + ); + vec![] + } + }; - let committed_status_lines: Vec<(String, String)> = match name_status_output { - Ok(output) if output.status.success() => { - String::from_utf8_lossy(&output.stdout) - .lines() - .filter_map(|line| { - let parts: Vec<&str> = line.splitn(2, '\t').collect(); - if parts.len() >= 2 { - let status = parts[0].trim().to_string(); - let file_path = parts[1].to_string(); - Some((file_path, status)) - } else { - None - } - }) - .collect() + if !committed_status_lines.is_empty() { + (committed_status_lines, resolved_diff_base) + } else { + (vec![], None) } - _ => vec![], - }; - - if !committed_status_lines.is_empty() { - (committed_status_lines, Some(base.clone())) } else { (vec![], None) } @@ -3489,15 +3605,14 @@ impl TaskManager { }; // Get numstat for line counts - // If we have effective_base_for_diff, compare against origin/{base_branch} + // If we have effective_base_for_diff (a resolved diff base string), use it directly // Otherwise compare against HEAD for uncommitted changes let mut file_stats: std::collections::HashMap<String, (i32, i32)> = std::collections::HashMap::new(); - let numstat_output = if let Some(ref base) = effective_base_for_diff { - let diff_base = format!("origin/{}...HEAD", base); + let numstat_output = if let Some(ref diff_base) = effective_base_for_diff { tokio::process::Command::new("git") .current_dir(&path) - .args(["diff", "--numstat", &diff_base]) + .args(["diff", "--numstat", diff_base]) .output() .await } else { @@ -3557,6 +3672,167 @@ impl TaskManager { Ok(()) } + /// Handle GetWorktreeDiff command - get git diff for a task's worktree. + async fn handle_get_worktree_diff( + &self, + task_id: Uuid, + file_path: Option<String>, + ) -> Result<(), DaemonError> { + // Get task's worktree path, branch, and base_branch + // If the task shares a supervisor's worktree, use the supervisor's worktree info + let task_info = { + let tasks = self.tasks.read().await; + if let Some(task) = tasks.get(&task_id) { + if let Some(supervisor_task_id) = task.supervisor_worktree_task_id { + tasks.get(&supervisor_task_id).map(|supervisor| ( + supervisor.worktree.as_ref().map(|w| w.path.clone()), + supervisor.base_branch.clone(), + )) + } else { + Some(( + task.worktree.as_ref().map(|w| w.path.clone()), + task.base_branch.clone(), + )) + } + } else { + None + } + }; + + let (worktree_path, base_branch) = match task_info { + Some((Some(path), base_branch)) => (path, base_branch), + _ => { + let msg = DaemonMessage::WorktreeDiffResult { + task_id, + success: true, + diff: Some(String::new()), + error: None, + }; + let _ = self.ws_tx.send(msg).await; + return Ok(()); + } + }; + + if !worktree_path.exists() { + let msg = DaemonMessage::WorktreeDiffResult { + task_id, + success: false, + diff: None, + error: Some("Worktree path does not exist".to_string()), + }; + let _ = self.ws_tx.send(msg).await; + return Ok(()); + } + + // Check for uncommitted changes first + let status_output = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(["status", "--porcelain"]) + .output() + .await; + + let has_uncommitted = match &status_output { + Ok(output) if output.status.success() => { + !String::from_utf8_lossy(&output.stdout).trim().is_empty() + } + _ => false, + }; + + let diff_result = if has_uncommitted { + // Get diff for uncommitted changes (both staged and unstaged) + let mut args = vec!["diff".to_string(), "HEAD".to_string()]; + if let Some(ref fp) = file_path { + args.push("--".to_string()); + args.push(fp.clone()); + } + let output = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(&args) + .output() + .await; + + match output { + Ok(out) if out.status.success() => { + let diff = String::from_utf8_lossy(&out.stdout).to_string(); + // If diff is empty (e.g., for new untracked files), try git diff (no HEAD) + // and also try to show untracked file content + if diff.is_empty() { + // Try to show untracked files as diffs + let mut args2 = vec!["diff".to_string()]; + if let Some(ref fp) = file_path { + args2.push("--".to_string()); + args2.push(fp.clone()); + } + let output2 = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(&args2) + .output() + .await; + match output2 { + Ok(out2) if out2.status.success() => { + Ok(String::from_utf8_lossy(&out2.stdout).to_string()) + } + _ => Ok(diff), + } + } else { + Ok(diff) + } + } + Ok(out) => Err(String::from_utf8_lossy(&out.stderr).to_string()), + Err(e) => Err(format!("Failed to run git diff: {}", e)), + } + } else { + // No uncommitted changes - compare against base branch + let effective_base_branch = if let Some(ref base) = base_branch { + Some(base.clone()) + } else { + self.worktree_manager.detect_default_branch(&worktree_path).await.ok() + }; + + if let Some(ref base) = effective_base_branch { + let diff_base = format!("origin/{}...HEAD", base); + let mut args = vec!["diff".to_string(), diff_base]; + if let Some(ref fp) = file_path { + args.push("--".to_string()); + args.push(fp.clone()); + } + let output = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(&args) + .output() + .await; + + match output { + Ok(out) if out.status.success() => { + Ok(String::from_utf8_lossy(&out.stdout).to_string()) + } + Ok(out) => Err(String::from_utf8_lossy(&out.stderr).to_string()), + Err(e) => Err(format!("Failed to run git diff: {}", e)), + } + } else { + Ok(String::new()) + } + }; + + let msg = match diff_result { + Ok(diff) => DaemonMessage::WorktreeDiffResult { + task_id, + success: true, + diff: Some(diff), + error: None, + }, + Err(e) => DaemonMessage::WorktreeDiffResult { + task_id, + success: false, + diff: None, + error: Some(e), + }, + }; + + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + /// Handle CreateCheckpoint command - stage all changes, commit, and get stats. async fn handle_create_checkpoint( &self, diff --git a/makima/src/daemon/ws/protocol.rs b/makima/src/daemon/ws/protocol.rs index 1611f52..0583783 100644 --- a/makima/src/daemon/ws/protocol.rs +++ b/makima/src/daemon/ws/protocol.rs @@ -310,6 +310,16 @@ pub enum DaemonMessage { error: Option<String>, }, + /// Response to CommitWorktree command. + WorktreeCommitResult { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + #[serde(rename = "commitSha")] + commit_sha: Option<String>, + error: Option<String>, + }, + /// Response to GetWorktreeInfo command. WorktreeInfoResult { #[serde(rename = "taskId")] @@ -758,6 +768,13 @@ pub enum DaemonCommand { task_id: Uuid, }, + /// Commit changes in a task worktree. + CommitWorktree { + #[serde(rename = "taskId")] + task_id: Uuid, + message: Option<String>, + }, + /// Create a checkpoint (stage changes, commit, get stats). CreateCheckpoint { #[serde(rename = "taskId")] diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index 0e72bdf..1a5b9c1 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -2099,6 +2099,281 @@ pub async fn get_worktree_info( } // ============================================================================= +// Task Diff +// ============================================================================= + +/// Response for the task diff endpoint. +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskDiffApiResponse { + /// Task ID. + pub task_id: Uuid, + /// Whether the diff was retrieved successfully. + pub success: bool, + /// The diff content. + pub diff: Option<String>, + /// Error message if failed. + pub error: Option<String>, +} + +/// Get the diff for a task's changes. +#[utoipa::path( + get, + path = "/api/v1/mesh/tasks/{id}/diff", + params( + ("id" = Uuid, Path, description = "Task ID") + ), + responses( + (status = 200, description = "Task diff", body = TaskDiffApiResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Task not found", body = ApiError), + (status = 503, description = "Database not configured or daemon not connected", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Mesh" +)] +pub async fn get_task_diff( + 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(); + }; + + // Get the task (scoped by owner) + let task = match repository::get_task_for_owner(pool, id, auth.owner_id).await { + Ok(Some(t)) => t, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get task {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Get daemon running the task + let Some(daemon_id) = task.daemon_id else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("NO_DAEMON", "Task has no assigned daemon")), + ) + .into_response(); + }; + + // Create oneshot channel for response + let (tx, rx) = oneshot::channel(); + + // Store the sender for the daemon message handler to use + state.pending_task_diff.insert(id, tx); + + // Send GetTaskDiff command to daemon + let command = DaemonCommand::GetTaskDiff { task_id: id }; + + if let Err(e) = state.send_daemon_command(daemon_id, command).await { + // Clean up pending request on error + state.pending_task_diff.remove(&id); + tracing::error!("Failed to send GetTaskDiff command: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DAEMON_ERROR", e)), + ) + .into_response(); + } + + // Wait for daemon response with timeout + match tokio::time::timeout(Duration::from_secs(15), rx).await { + Ok(Ok(response)) => { + Json(TaskDiffApiResponse { + task_id: id, + success: response.success, + diff: response.diff, + error: response.error, + }) + .into_response() + } + Ok(Err(_)) => { + // Channel was dropped (sender side closed) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DAEMON_DISCONNECTED", "Daemon disconnected before responding")), + ) + .into_response() + } + Err(_) => { + // Timeout - clean up pending request + state.pending_task_diff.remove(&id); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ApiError::new("TIMEOUT", "Daemon did not respond in time")), + ) + .into_response() + } + } +} + +// ============================================================================= +// Worktree Commit +// ============================================================================= + +/// Request body for worktree commit. +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CommitWorktreeRequest { + /// Optional commit message. Defaults to "Worktree commit" if not provided. + pub message: Option<String>, +} + +/// Response for the worktree commit endpoint. +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CommitWorktreeApiResponse { + /// Task ID. + pub task_id: Uuid, + /// Whether the commit was successful. + pub success: bool, + /// The commit SHA if successful. + pub commit_sha: Option<String>, + /// Error message if failed. + pub error: Option<String>, +} + +/// Commit changes in a task's worktree. +#[utoipa::path( + post, + path = "/api/v1/mesh/tasks/{id}/worktree-commit", + params( + ("id" = Uuid, Path, description = "Task ID") + ), + request_body = CommitWorktreeRequest, + responses( + (status = 200, description = "Worktree commit result", body = CommitWorktreeApiResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Task not found", body = ApiError), + (status = 503, description = "Database not configured or daemon not connected", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Mesh" +)] +pub async fn commit_worktree( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(body): Json<CommitWorktreeRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get the task (scoped by owner) + let task = match repository::get_task_for_owner(pool, id, auth.owner_id).await { + Ok(Some(t)) => t, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get task {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Get daemon running the task + let Some(daemon_id) = task.daemon_id else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("NO_DAEMON", "Task has no assigned daemon")), + ) + .into_response(); + }; + + // Create oneshot channel for response + let (tx, rx) = oneshot::channel(); + + // Store the sender for the daemon message handler to use + state.pending_worktree_commit.insert(id, tx); + + // Send CommitWorktree command to daemon + let command = DaemonCommand::CommitWorktree { + task_id: id, + message: body.message, + }; + + if let Err(e) = state.send_daemon_command(daemon_id, command).await { + // Clean up pending request on error + state.pending_worktree_commit.remove(&id); + tracing::error!("Failed to send CommitWorktree command: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DAEMON_ERROR", e)), + ) + .into_response(); + } + + // Wait for daemon response with timeout + match tokio::time::timeout(Duration::from_secs(15), rx).await { + Ok(Ok(response)) => { + Json(CommitWorktreeApiResponse { + task_id: id, + success: response.success, + commit_sha: response.commit_sha, + error: response.error, + }) + .into_response() + } + Ok(Err(_)) => { + // Channel was dropped (sender side closed) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DAEMON_DISCONNECTED", "Daemon disconnected before responding")), + ) + .into_response() + } + Err(_) => { + // Timeout - clean up pending request + state.pending_worktree_commit.remove(&id); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ApiError::new("TIMEOUT", "Daemon did not respond in time")), + ) + .into_response() + } + } +} + +// ============================================================================= // Task Patches // ============================================================================= diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index d5ef1f9..139db70 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -530,6 +530,14 @@ pub enum DaemonMessage { #[serde(rename = "prNumber")] pr_number: Option<i32>, }, + /// Response to GetWorktreeDiff command + WorktreeDiffResult { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + diff: Option<String>, + error: Option<String>, + }, /// Response to GetWorktreeInfo command WorktreeInfoResult { #[serde(rename = "taskId")] @@ -557,6 +565,23 @@ pub enum DaemonMessage { /// Error message if failed error: Option<String>, }, + /// Response to GetTaskDiff command + TaskDiff { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + diff: Option<String>, + error: Option<String>, + }, + /// Response to CommitWorktree command + WorktreeCommitResult { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + #[serde(rename = "commitSha")] + commit_sha: Option<String>, + error: Option<String>, + }, /// Request to merge a task's patch to supervisor's worktree (cross-daemon case). /// Sent when a task completes on a different daemon than its supervisor. MergePatchToSupervisor { @@ -2358,6 +2383,41 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re let _ = tx.send(response); } } + Ok(DaemonMessage::TaskDiff { task_id, success, diff, error }) => { + tracing::debug!( + task_id = %task_id, + success = success, + "Task diff result received" + ); + + // Fulfill pending task diff request if any + if let Some((_, tx)) = state.pending_task_diff.remove(&task_id) { + let _ = tx.send(crate::server::state::TaskDiffResult { + task_id, + success, + diff, + error, + }); + } + } + Ok(DaemonMessage::WorktreeCommitResult { task_id, success, commit_sha, error }) => { + tracing::debug!( + task_id = %task_id, + success = success, + commit_sha = ?commit_sha, + "Worktree commit result received" + ); + + // Fulfill pending worktree commit request if any + if let Some((_, tx)) = state.pending_worktree_commit.remove(&task_id) { + let _ = tx.send(crate::server::state::WorktreeCommitResponse { + task_id, + success, + commit_sha, + error, + }); + } + } Ok(DaemonMessage::MergePatchToSupervisor { task_id, supervisor_task_id, diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 6321518..b382f04 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -83,6 +83,8 @@ pub fn make_router(state: SharedState) -> Router { .route("/mesh/tasks/{id}/retry-completion", post(mesh::retry_completion_action)) .route("/mesh/tasks/{id}/clone", post(mesh::clone_worktree)) .route("/mesh/tasks/{id}/worktree-info", get(mesh::get_worktree_info)) + .route("/mesh/tasks/{id}/diff", get(mesh::get_task_diff)) + .route("/mesh/tasks/{id}/worktree-commit", post(mesh::commit_worktree)) .route("/mesh/tasks/{id}/patches", get(mesh::list_task_patches)) .route("/mesh/tasks/{id}/patch-data", get(mesh::get_task_patch_data)) .route("/mesh/tasks/{id}/check-target", post(mesh::check_target_exists)) diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs index 5c5e24f..83ac2e8 100644 --- a/makima/src/server/state.rs +++ b/makima/src/server/state.rs @@ -194,6 +194,16 @@ pub struct SupervisorQuestionResponse { pub responded_at: chrono::DateTime<chrono::Utc>, } +/// Worktree diff response from daemon +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeDiffResponse { + pub task_id: Uuid, + pub success: bool, + pub diff: String, + pub error: Option<String>, +} + /// Worktree info response from daemon #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -211,6 +221,26 @@ pub struct WorktreeInfoResponse { pub error: Option<String>, } +/// Task diff result from daemon +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskDiffResult { + pub task_id: Uuid, + pub success: bool, + pub diff: Option<String>, + pub error: Option<String>, +} + +/// Worktree commit response from daemon +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeCommitResponse { + pub task_id: Uuid, + pub success: bool, + pub commit_sha: Option<String>, + pub error: Option<String>, +} + /// Command sent from server to daemon. #[derive(Debug, Clone, serde::Serialize)] #[serde(tag = "type", rename_all = "camelCase")] @@ -491,6 +521,13 @@ pub enum DaemonCommand { task_id: Uuid, }, + /// Commit changes in a task worktree + CommitWorktree { + #[serde(rename = "taskId")] + task_id: Uuid, + message: Option<String>, + }, + /// Create a git checkpoint (stage changes, commit, record stats) CreateCheckpoint { #[serde(rename = "taskId")] @@ -636,6 +673,10 @@ pub struct AppState { pub jwt_verifier: Option<JwtVerifier>, /// Pending worktree info requests awaiting daemon response (keyed by task_id) pub pending_worktree_info: DashMap<Uuid, oneshot::Sender<WorktreeInfoResponse>>, + /// Pending task diff requests awaiting daemon response (keyed by task_id) + pub pending_task_diff: DashMap<Uuid, oneshot::Sender<TaskDiffResult>>, + /// Pending worktree commit requests awaiting daemon response (keyed by task_id) + pub pending_worktree_commit: DashMap<Uuid, oneshot::Sender<WorktreeCommitResponse>>, /// Lazily-loaded TTS engine (initialized on first Speak connection) pub tts_engine: OnceCell<Box<dyn TtsEngine>>, /// Daemon reauth status storage (keyed by (daemon_id, request_id)) @@ -717,6 +758,8 @@ impl AppState { tool_keys: DashMap::new(), jwt_verifier, pending_worktree_info: DashMap::new(), + pending_task_diff: DashMap::new(), + pending_worktree_commit: DashMap::new(), tts_engine: OnceCell::new(), daemon_reauth_status: DashMap::new(), } |
