summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--makima/frontend/src/components/directives/TaskSlideOutPanel.tsx145
-rw-r--r--makima/frontend/src/components/mesh/GitActionsPanel.tsx40
-rw-r--r--makima/frontend/src/components/mesh/TaskDetail.tsx41
-rw-r--r--makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx7
-rw-r--r--makima/frontend/src/lib/api.ts24
-rw-r--r--makima/frontend/src/routes/mesh.tsx23
-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
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"
+ >
+ &larr;
+ </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"
>
- ✕
+ &#x2715;
</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(),
}