diff options
| -rw-r--r-- | makima/frontend/src/components/directives/TaskSlideOutPanel.tsx | 145 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/GitActionsPanel.tsx | 40 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskDetail.tsx | 41 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx | 7 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 24 | ||||
| -rw-r--r-- | makima/frontend/src/routes/mesh.tsx | 23 | ||||
| -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 |
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<TaskOutputEvent[]>([]); const [isStreaming, setIsStreaming] = useState(false); const [loadingHistory, setLoadingHistory] = useState(false); + const [showDiff, setShowDiff] = useState(false); + const [diffContent, setDiffContent] = useState<string>(""); + 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 */} <div className="flex items-center justify-between px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.25)] shrink-0"> <div className="flex items-center gap-2 min-w-0 flex-1"> + {showingDiff && ( + <button + type="button" + onClick={() => { + setSelectedFileDiff(null); + setSelectedFilePath(null); + setDiffLoading(false); + }} + className="text-[#75aafc] hover:text-white font-mono text-xs transition-colors shrink-0 mr-1" + title="Back to worktree view" + > + ← + </button> + )} <span className="text-[10px] font-mono text-[#75aafc] uppercase tracking-wide shrink-0"> - Task + {showingDiff ? "Diff" : "Task"} </span> <span className="text-[12px] font-mono text-white truncate"> - {taskName || taskId} + {showingDiff ? (selectedFilePath || "Loading...") : (taskName || taskId)} </span> - {isStreaming && ( + {!showingDiff && isStreaming && ( <span className="flex items-center gap-1 px-1.5 py-0.5 bg-green-400/10 border border-green-400/30 shrink-0"> <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" /> <span className="text-green-400 font-mono text-[9px] uppercase"> @@ -141,39 +191,88 @@ export function TaskSlideOutPanel({ )} </div> <button + onClick={async () => { + 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); + } + }} + className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] transition-colors px-1.5 py-0.5 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] shrink-0" + > + {diffLoading ? "Loading..." : "View Diff"} + </button> + <button type="button" onClick={onClose} className="text-[#7788aa] hover:text-white font-mono text-sm transition-colors ml-2 shrink-0 w-6 h-6 flex items-center justify-center" > - ✕ + ✕ </button> </div> {/* Content */} <div className="flex-1 flex flex-col min-h-0 overflow-hidden"> - {/* Task Output section (~60% height) */} - <div className="flex-[3] min-h-0 flex flex-col border-b border-[rgba(117,170,252,0.15)]"> - {loadingHistory ? ( - <div className="flex-1 flex items-center justify-center"> - <span className="font-mono text-xs text-[#555] animate-pulse"> - Loading output... - </span> - </div> - ) : ( - <TaskOutput - entries={entries} - isStreaming={isStreaming} - taskId={taskId} + {showingDiff ? ( + /* Diff view replaces the worktree panel content */ + <div className="flex-1 min-h-0 overflow-y-auto"> + <OverlayDiffViewer + diff={selectedFileDiff || ""} + loading={diffLoading} + onClose={() => { + setSelectedFileDiff(null); + setSelectedFilePath(null); + setDiffLoading(false); + }} + title={selectedFilePath ? `Diff: ${selectedFilePath}` : "File Diff"} /> - )} - </div> + </div> + ) : ( + <> + {/* Task Output section (~60% height) */} + <div className="flex-[3] min-h-0 flex flex-col border-b border-[rgba(117,170,252,0.15)]"> + {loadingHistory ? ( + <div className="flex-1 flex items-center justify-center"> + <span className="font-mono text-xs text-[#555] animate-pulse"> + Loading output... + </span> + </div> + ) : ( + <TaskOutput + entries={entries} + isStreaming={isStreaming} + taskId={taskId} + /> + )} + </div> {/* Worktree Changes section (~40% height) */} <div className="flex-[2] min-h-0 overflow-y-auto"> - {taskId && <WorktreeFilesPanel taskId={taskId} />} + {taskId && <WorktreeFilesPanel taskId={taskId} onFileClick={handleFileClick} />} </div> </div> </div> + + {/* Diff modal */} + {showDiff && ( + <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"> + <div className="max-w-4xl w-full max-h-[80vh]"> + <OverlayDiffViewer + diff={diffContent} + onClose={() => setShowDiff(false)} + title={`Changes in ${taskName || taskId}`} + /> + </div> + </div> + )} </> ); } 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<ToastMessage | null>(null); const [exportedPatch, setExportedPatch] = useState<ExportPatchResponse | null>(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 ( <div className="space-y-3"> {/* Section Header */} @@ -112,6 +131,25 @@ export function GitActionsPanel({ </div> )} + {/* Commit section */} + <div className="flex gap-2"> + <input + type="text" + value={commitMessage} + onChange={(e) => 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(); }} + /> + <button + onClick={handleCommit} + disabled={isCommitting} + className="px-3 py-1.5 font-mono text-xs text-emerald-400 border border-emerald-400/30 hover:border-emerald-400/50 hover:bg-emerald-400/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap" + > + {isCommitting ? "Committing..." : "Commit"} + </button> + </div> + {/* Action buttons */} <div className="flex flex-wrap gap-2"> <button diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx index fdcc58b..00719e2 100644 --- a/makima/frontend/src/components/mesh/TaskDetail.tsx +++ b/makima/frontend/src/components/mesh/TaskDetail.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useMemo, useEffect } from "react"; import type { TaskWithSubtasks, TaskStatus, TaskSummary, CompletionAction, DaemonDirectory } from "../../lib/api"; -import { retryCompletionAction, getDaemonDirectories, cloneWorktree } from "../../lib/api"; +import { retryCompletionAction, getDaemonDirectories, cloneWorktree, getWorktreeDiff } from "../../lib/api"; import { SubtaskTree, SubtaskProgressBar, calculateTreeStats } from "./SubtaskTree"; import { OverlayDiffViewer } from "./OverlayDiffViewer"; import { PRPreview } from "./PRPreview"; @@ -136,6 +136,9 @@ export function TaskDetail({ ); const [showDiff, setShowDiff] = useState(false); const [showPRPreview, setShowPRPreview] = useState(false); + const [worktreeFileDiff, setWorktreeFileDiff] = useState<string | null>(null); + const [worktreeFileDiffPath, setWorktreeFileDiffPath] = useState<string | null>(null); + const [worktreeFileDiffLoading, setWorktreeFileDiffLoading] = useState(false); const [useTreeView, setUseTreeView] = useState(false); // Track which subtask is expanded for inline editing const [expandedSubtaskId, setExpandedSubtaskId] = useState<string | null>(null); @@ -153,6 +156,22 @@ export function TaskDetail({ // Track branch modal state const [showBranchModal, setShowBranchModal] = useState(false); + // Handle clicking a file in the worktree panel to show its diff + const handleWorktreeFileClick = useCallback(async (filePath: string) => { + setWorktreeFileDiffLoading(true); + setWorktreeFileDiffPath(filePath); + setWorktreeFileDiff(null); + try { + const result = await getWorktreeDiff(task.id, filePath); + setWorktreeFileDiff(result.diff); + } catch (e) { + console.error("Failed to fetch worktree diff:", e); + setWorktreeFileDiff(""); + } finally { + setWorktreeFileDiffLoading(false); + } + }, [task.id]); + // Check if task is running const isTaskRunning = task.status === "running" || task.status === "initializing" || task.status === "starting"; // Check if task is in a terminal state (can be continued/reopened) @@ -897,7 +916,7 @@ export function TaskDetail({ {/* Worktree Files Panel - show changed files in the worktree */} {(task.status === "done" || task.status === "failed" || task.status === "merged" || task.status === "running") && ( - <WorktreeFilesPanel taskId={task.id} /> + <WorktreeFilesPanel taskId={task.id} onFileClick={handleWorktreeFileClick} /> )} {/* Patches List Panel - show exported patches for this task */} @@ -920,6 +939,24 @@ export function TaskDetail({ </div> )} + {/* Worktree File Diff Modal */} + {(worktreeFileDiff !== null || worktreeFileDiffLoading) && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> + <div className="max-w-4xl w-full max-h-[80vh]"> + <OverlayDiffViewer + diff={worktreeFileDiff || ""} + loading={worktreeFileDiffLoading} + onClose={() => { + setWorktreeFileDiff(null); + setWorktreeFileDiffPath(null); + setWorktreeFileDiffLoading(false); + }} + title={worktreeFileDiffPath ? `Diff: ${worktreeFileDiffPath}` : "File Diff"} + /> + </div> + </div> + )} + {/* PR Preview Modal */} {showPRPreview && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> 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<WorktreeInfo | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(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 ( <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)]"> @@ -152,7 +154,8 @@ export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) { return ( <div key={file.path} - className="flex items-center gap-2 px-3 py-1.5 hover:bg-[rgba(117,170,252,0.03)]" + className={`flex items-center gap-2 px-3 py-1.5 ${onFileClick ? 'cursor-pointer hover:bg-[rgba(117,170,252,0.08)]' : 'hover:bg-[rgba(117,170,252,0.03)]'}`} + onClick={() => onFileClick?.(file.path)} > {/* Status badge */} <span diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 7968583..aecdac7 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3068,6 +3068,30 @@ export async function getWorktreeInfo(taskId: string): Promise<WorktreeInfo> { 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<string | null>(null); // For supervisor tasks: all tasks in the contract (excluding the supervisor itself) const [contractTasks, setContractTasks] = useState<TaskSummary[]>([]); + // Overlay diff content for viewing worktree changes + const [overlayDiff, setOverlayDiff] = useState<string | undefined>(undefined); // View mode for the split panel layout const [viewMode, setViewMode] = useState<ViewMode>("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} /> </div> )} 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(), } |
