From f49aaa39a32661b54c109ba002d24cbdf73f4ea3 Mon Sep 17 00:00:00 2001 From: soryu Date: Mon, 9 Mar 2026 17:20:52 +0000 Subject: feat: worktree diff/commit endpoints and frontend diff viewing (#88) * feat: soryu-co/soryu - makima: Fix worktree info failing when origin ref is missing * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Add worktree commit endpoint and diff endpoint for regular users * feat: soryu-co/soryu - makima: Add frontend diff viewing with clickable worktree files --- .../components/directives/TaskSlideOutPanel.tsx | 145 +++++++-- .../src/components/mesh/GitActionsPanel.tsx | 40 ++- makima/frontend/src/components/mesh/TaskDetail.tsx | 41 ++- .../src/components/mesh/WorktreeFilesPanel.tsx | 7 +- makima/frontend/src/lib/api.ts | 24 ++ makima/frontend/src/routes/mesh.tsx | 23 +- makima/src/daemon/task/manager.rs | 338 +++++++++++++++++++-- makima/src/daemon/ws/protocol.rs | 17 ++ makima/src/server/handlers/mesh.rs | 275 +++++++++++++++++ makima/src/server/handlers/mesh_daemon.rs | 60 ++++ makima/src/server/mod.rs | 2 + makima/src/server/state.rs | 43 +++ 12 files changed, 955 insertions(+), 60 deletions(-) diff --git a/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx b/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx index 29fce23..176728c 100644 --- a/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx +++ b/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx @@ -3,7 +3,8 @@ import { useTaskSubscription } from "../../hooks/useTaskSubscription"; import type { TaskOutputEvent } from "../../hooks/useTaskSubscription"; import { TaskOutput } from "../mesh/TaskOutput"; import { WorktreeFilesPanel } from "../mesh/WorktreeFilesPanel"; -import { getTaskOutput } from "../../lib/api"; +import { OverlayDiffViewer } from "../mesh/OverlayDiffViewer"; +import { getTaskOutput, getTaskDiff } from "../../lib/api"; interface TaskSlideOutPanelProps { taskId: string; @@ -21,21 +22,37 @@ export function TaskSlideOutPanel({ const [entries, setEntries] = useState([]); const [isStreaming, setIsStreaming] = useState(false); const [loadingHistory, setLoadingHistory] = useState(false); + const [showDiff, setShowDiff] = useState(false); + const [diffContent, setDiffContent] = useState(""); + const [diffLoading, setDiffLoading] = useState(false); // Escape key handler useEffect(() => { const handleEsc = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + // If diff is showing, close diff first; otherwise close panel + if (e.key === "Escape") { + if (selectedFileDiff !== null || diffLoading) { + setSelectedFileDiff(null); + setSelectedFilePath(null); + setDiffLoading(false); + } else { + onClose(); + } + } }; if (isOpen) document.addEventListener("keydown", handleEsc); return () => document.removeEventListener("keydown", handleEsc); - }, [isOpen, onClose]); + }, [isOpen, onClose, selectedFileDiff, diffLoading]); // Load historical output when panel opens with a taskId useEffect(() => { if (!isOpen || !taskId) { setEntries([]); setIsStreaming(false); + // Reset diff state when panel closes + setSelectedFileDiff(null); + setSelectedFilePath(null); + setDiffLoading(false); return; } @@ -98,6 +115,23 @@ export function TaskSlideOutPanel({ [] ); + // Handle file click to show diff + const handleFileClick = useCallback(async (_filePath: string) => { + if (!taskId) return; + setDiffLoading(true); + try { + const result = await getTaskDiff(taskId); + if (result.success && result.diff) { + setDiffContent(result.diff); + setShowDiff(true); + } + } catch (e) { + console.error("Failed to get diff:", e); + } finally { + setDiffLoading(false); + } + }, [taskId]); + // Subscribe to live output useTaskSubscription({ taskId: isOpen ? taskId : null, @@ -106,6 +140,8 @@ export function TaskSlideOutPanel({ onUpdate: handleUpdate, }); + const showingDiff = selectedFileDiff !== null || diffLoading; + return ( <> {/* Backdrop overlay */} @@ -125,13 +161,27 @@ export function TaskSlideOutPanel({ {/* Header */}
+ {showingDiff && ( + + )} - Task + {showingDiff ? "Diff" : "Task"} - {taskName || taskId} + {showingDiff ? (selectedFilePath || "Loading...") : (taskName || taskId)} - {isStreaming && ( + {!showingDiff && isStreaming && ( @@ -140,40 +190,89 @@ export function TaskSlideOutPanel({ )}
+
{/* Content */}
- {/* Task Output section (~60% height) */} -
- {loadingHistory ? ( -
- - Loading output... - -
- ) : ( - + { + setSelectedFileDiff(null); + setSelectedFilePath(null); + setDiffLoading(false); + }} + title={selectedFilePath ? `Diff: ${selectedFilePath}` : "File Diff"} /> - )} -
+
+ ) : ( + <> + {/* Task Output section (~60% height) */} +
+ {loadingHistory ? ( +
+ + Loading output... + +
+ ) : ( + + )} +
{/* Worktree Changes section (~40% height) */}
- {taskId && } + {taskId && }
+ + {/* Diff modal */} + {showDiff && ( +
+
+ setShowDiff(false)} + title={`Changes in ${taskName || taskId}`} + /> +
+
+ )} ); } diff --git a/makima/frontend/src/components/mesh/GitActionsPanel.tsx b/makima/frontend/src/components/mesh/GitActionsPanel.tsx index be2e06d..ff1dc7d 100644 --- a/makima/frontend/src/components/mesh/GitActionsPanel.tsx +++ b/makima/frontend/src/components/mesh/GitActionsPanel.tsx @@ -1,5 +1,5 @@ import { useState, useCallback } from "react"; -import { exportTaskPatch, pushTaskBranch, createTaskPR, type ExportPatchResponse } from "../../lib/api"; +import { exportTaskPatch, pushTaskBranch, createTaskPR, commitWorktree, type ExportPatchResponse } from "../../lib/api"; interface GitActionsPanelProps { taskId: string; @@ -20,6 +20,8 @@ export function GitActionsPanel({ const [isExporting, setIsExporting] = useState(false); const [isPushing, setIsPushing] = useState(false); const [isCreatingPR, setIsCreatingPR] = useState(false); + const [isCommitting, setIsCommitting] = useState(false); + const [commitMessage, setCommitMessage] = useState(""); const [toast, setToast] = useState(null); const [exportedPatch, setExportedPatch] = useState(null); @@ -82,6 +84,23 @@ export function GitActionsPanel({ } }, [taskId, isLocalOnly]); + const handleCommit = useCallback(async () => { + setIsCommitting(true); + try { + const result = await commitWorktree(taskId, commitMessage || undefined); + if (result.success) { + showToast("success", `Committed: ${result.commitSha?.substring(0, 8) || "done"}`); + setCommitMessage(""); + } else { + showToast("error", result.error || "Failed to commit"); + } + } catch (e) { + showToast("error", e instanceof Error ? e.message : "Failed to commit"); + } finally { + setIsCommitting(false); + } + }, [taskId, commitMessage]); + return (
{/* Section Header */} @@ -112,6 +131,25 @@ export function GitActionsPanel({
)} + {/* Commit section */} +
+ setCommitMessage(e.target.value)} + placeholder="Commit message (optional)" + className="flex-1 px-2 py-1.5 font-mono text-xs bg-transparent border border-[rgba(117,170,252,0.2)] text-[#dbe7ff] placeholder:text-[#555] focus:border-[#75aafc] focus:outline-none" + onKeyDown={(e) => { if (e.key === "Enter") handleCommit(); }} + /> + +
+ {/* Action buttons */}
)} + {/* Worktree File Diff Modal */} + {(worktreeFileDiff !== null || worktreeFileDiffLoading) && ( +
+
+ { + setWorktreeFileDiff(null); + setWorktreeFileDiffPath(null); + setWorktreeFileDiffLoading(false); + }} + title={worktreeFileDiffPath ? `Diff: ${worktreeFileDiffPath}` : "File Diff"} + /> +
+
+ )} + {/* PR Preview Modal */} {showPRPreview && (
diff --git a/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx index b529588..bb3361d 100644 --- a/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx +++ b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx @@ -4,6 +4,7 @@ import { getWorktreeInfo } from "../../lib/api"; interface WorktreeFilesPanelProps { taskId: string; + onFileClick?: (filePath: string) => void; } /** Get status badge styling based on file status */ @@ -35,7 +36,7 @@ function getStatusStyle(status: string): { color: string; bgColor: string; label } } -export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) { +export function WorktreeFilesPanel({ taskId, onFileClick }: WorktreeFilesPanelProps) { const [worktreeInfo, setWorktreeInfo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -116,6 +117,7 @@ export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) { const { stats, files } = worktreeInfo; const displayFiles = expanded ? files : files.slice(0, 10); + const isClickable = !!onFileClick; return (
@@ -152,7 +154,8 @@ export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) { return (
onFileClick?.(file.path)} > {/* Status badge */} { return res.json(); } +/** Get the diff for a task's worktree changes */ +export async function getTaskDiff(taskId: string): Promise<{ taskId: string; success: boolean; diff: string | null; error: string | null }> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/diff`); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to get task diff: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** Commit changes in a task's worktree */ +export async function commitWorktree(taskId: string, message?: string): Promise<{ taskId: string; success: boolean; commitSha: string | null; error: string | null }> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/worktree-commit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message }), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to commit worktree: ${errorText || res.statusText}`); + } + return res.json(); +} + // ============================================================================= // Patch Types and Functions // ============================================================================= diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index 67129f9..f210227 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -9,7 +9,7 @@ import { ContractCompleteQuestion } from "../components/mesh/ContractCompleteQue import { useTasks } from "../hooks/useTasks"; import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription"; import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory, TaskSummary, RepositoryHistoryEntry } from "../lib/api"; -import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories, continueTask as continueTaskApi, resumeSupervisor, branchTask, getRepositorySuggestions } from "../lib/api"; +import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories, continueTask as continueTaskApi, resumeSupervisor, branchTask, getRepositorySuggestions, getTaskDiff } from "../lib/api"; import { DirectoryInput } from "../components/mesh/DirectoryInput"; import { useAuth } from "../contexts/AuthContext"; import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext"; @@ -136,6 +136,8 @@ export default function MeshPage() { const [viewingSubtaskName, setViewingSubtaskName] = useState(null); // For supervisor tasks: all tasks in the contract (excluding the supervisor itself) const [contractTasks, setContractTasks] = useState([]); + // Overlay diff content for viewing worktree changes + const [overlayDiff, setOverlayDiff] = useState(undefined); // View mode for the split panel layout const [viewMode, setViewMode] = useState("split"); // Width of the task panel as a percentage (0-100) @@ -297,6 +299,7 @@ export default function MeshPage() { useEffect(() => { setViewingSubtaskId(null); setViewingSubtaskName(null); + setOverlayDiff(undefined); }, [id]); // Toggle viewing a subtask's output (for running subtasks) @@ -315,6 +318,22 @@ export default function MeshPage() { [viewingSubtaskId] ); + // Request diff for the current task + const handleRequestDiff = useCallback(async () => { + if (!id) return; + try { + const result = await getTaskDiff(id); + if (result.success && result.diff) { + setOverlayDiff(result.diff); + } else { + setOverlayDiff(result.error || "Failed to get diff"); + } + } catch (e) { + console.error("Failed to get diff:", e); + setOverlayDiff(e instanceof Error ? e.message : "Failed to get diff"); + } + }, [id]); + // Load task detail when URL has an id useEffect(() => { if (id) { @@ -810,6 +829,8 @@ export default function MeshPage() { onViewContract={(contractId) => navigate(`/contracts/${contractId}`)} onBranch={handleBranch} contractTasks={taskDetail.isSupervisor ? contractTasks : undefined} + overlayDiff={overlayDiff} + onRequestDiff={handleRequestDiff} />
)} 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, + ) -> 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 = 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, + ) -> 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, }, + /// Response to CommitWorktree command. + WorktreeCommitResult { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + #[serde(rename = "commitSha")] + commit_sha: Option, + error: Option, + }, + /// 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, + }, + /// 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 @@ -2098,6 +2098,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, + /// Error message if failed. + pub error: Option, +} + +/// 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, + Authenticated(auth): Authenticated, + Path(id): Path, +) -> 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, +} + +/// 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, + /// Error message if failed. + pub error: Option, +} + +/// 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, + Authenticated(auth): Authenticated, + Path(id): Path, + Json(body): Json, +) -> 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, }, + /// Response to GetWorktreeDiff command + WorktreeDiffResult { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + diff: Option, + error: Option, + }, /// Response to GetWorktreeInfo command WorktreeInfoResult { #[serde(rename = "taskId")] @@ -557,6 +565,23 @@ pub enum DaemonMessage { /// Error message if failed error: Option, }, + /// Response to GetTaskDiff command + TaskDiff { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + diff: Option, + error: Option, + }, + /// Response to CommitWorktree command + WorktreeCommitResult { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + #[serde(rename = "commitSha")] + commit_sha: Option, + error: Option, + }, /// 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, } +/// 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, +} + /// Worktree info response from daemon #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -211,6 +221,26 @@ pub struct WorktreeInfoResponse { pub error: Option, } +/// 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, + pub error: Option, +} + +/// 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, + pub error: Option, +} + /// 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, + }, + /// Create a git checkpoint (stage changes, commit, record stats) CreateCheckpoint { #[serde(rename = "taskId")] @@ -636,6 +673,10 @@ pub struct AppState { pub jwt_verifier: Option, /// Pending worktree info requests awaiting daemon response (keyed by task_id) pub pending_worktree_info: DashMap>, + /// Pending task diff requests awaiting daemon response (keyed by task_id) + pub pending_task_diff: DashMap>, + /// Pending worktree commit requests awaiting daemon response (keyed by task_id) + pub pending_worktree_commit: DashMap>, /// Lazily-loaded TTS engine (initialized on first Speak connection) pub tts_engine: OnceCell>, /// 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(), } -- cgit v1.2.3