summaryrefslogtreecommitdiff
path: root/makima/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src')
-rw-r--r--makima/src/daemon/task/manager.rs338
-rw-r--r--makima/src/daemon/ws/protocol.rs17
-rw-r--r--makima/src/server/handlers/mesh.rs275
-rw-r--r--makima/src/server/handlers/mesh_daemon.rs60
-rw-r--r--makima/src/server/mod.rs2
-rw-r--r--makima/src/server/state.rs43
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(),
}