diff options
25 files changed, 1587 insertions, 19 deletions
diff --git a/makima/frontend/src/components/contracts/ContractDetail.tsx b/makima/frontend/src/components/contracts/ContractDetail.tsx index 90b6967..6e31c84 100644 --- a/makima/frontend/src/components/contracts/ContractDetail.tsx +++ b/makima/frontend/src/components/contracts/ContractDetail.tsx @@ -177,6 +177,11 @@ export function ContractDetail({ > {statusConfig[contract.status].label} </span> + {contract.localOnly && ( + <span className="px-2 py-0.5 font-mono text-[10px] uppercase text-amber-400 border border-amber-400/30 bg-amber-400/10"> + Local-Only + </span> + )} </div> {contract.description && ( <p className="font-mono text-sm text-[#9bc3ff] mb-3"> diff --git a/makima/frontend/src/components/contracts/ContractList.tsx b/makima/frontend/src/components/contracts/ContractList.tsx index ebde497..98f8ff6 100644 --- a/makima/frontend/src/components/contracts/ContractList.tsx +++ b/makima/frontend/src/components/contracts/ContractList.tsx @@ -127,11 +127,18 @@ export function ContractList({ `} > <div className="flex items-start justify-between gap-2 mb-2"> - <h3 className="font-mono text-sm text-[#dbe7ff] truncate"> - {contract.name} - </h3> + <div className="flex items-center gap-2 min-w-0"> + <h3 className="font-mono text-sm text-[#dbe7ff] truncate"> + {contract.name} + </h3> + {contract.localOnly && ( + <span className="px-1.5 py-0.5 font-mono text-[9px] uppercase text-amber-400 border border-amber-400/30 bg-amber-400/10 shrink-0"> + Local + </span> + )} + </div> <span - className={`text-[10px] font-mono uppercase ${ + className={`text-[10px] font-mono uppercase shrink-0 ${ statusColors[contract.status] }`} > diff --git a/makima/frontend/src/components/mesh/GitActionsPanel.tsx b/makima/frontend/src/components/mesh/GitActionsPanel.tsx new file mode 100644 index 0000000..be2e06d --- /dev/null +++ b/makima/frontend/src/components/mesh/GitActionsPanel.tsx @@ -0,0 +1,181 @@ +import { useState, useCallback } from "react"; +import { exportTaskPatch, pushTaskBranch, createTaskPR, type ExportPatchResponse } from "../../lib/api"; + +interface GitActionsPanelProps { + taskId: string; + isLocalOnly: boolean; + taskStatus: string; +} + +interface ToastMessage { + type: "success" | "error" | "info"; + message: string; +} + +export function GitActionsPanel({ + taskId, + isLocalOnly, + taskStatus, +}: GitActionsPanelProps) { + const [isExporting, setIsExporting] = useState(false); + const [isPushing, setIsPushing] = useState(false); + const [isCreatingPR, setIsCreatingPR] = useState(false); + const [toast, setToast] = useState<ToastMessage | null>(null); + const [exportedPatch, setExportedPatch] = useState<ExportPatchResponse | null>(null); + + // Only show for completed tasks + if (taskStatus !== "done") return null; + + const showToast = (type: ToastMessage["type"], message: string) => { + setToast({ type, message }); + setTimeout(() => setToast(null), 5000); + }; + + const handleExportPatch = useCallback(async () => { + setIsExporting(true); + try { + const result = await exportTaskPatch(taskId); + setExportedPatch(result); + showToast("success", `Patch generated: ${result.fileName}`); + } catch (e) { + showToast("error", e instanceof Error ? e.message : "Failed to generate patch"); + } finally { + setIsExporting(false); + } + }, [taskId]); + + const handlePushBranch = useCallback(async () => { + if (isLocalOnly) { + showToast("error", "Push disabled in local-only mode"); + return; + } + setIsPushing(true); + try { + const result = await pushTaskBranch(taskId); + showToast("success", `Branch pushed: ${result.branchName}`); + } catch (e) { + showToast("error", e instanceof Error ? e.message : "Failed to push branch"); + } finally { + setIsPushing(false); + } + }, [taskId, isLocalOnly]); + + const handleCreatePR = useCallback(async () => { + if (isLocalOnly) { + showToast("error", "PR creation disabled in local-only mode"); + return; + } + setIsCreatingPR(true); + try { + const result = await createTaskPR(taskId); + if (result.prUrl) { + showToast("success", `PR created: ${result.prUrl}`); + // Open PR in new tab + window.open(result.prUrl, "_blank", "noopener,noreferrer"); + } else { + showToast("success", "PR creation initiated"); + } + } catch (e) { + showToast("error", e instanceof Error ? e.message : "Failed to create PR"); + } finally { + setIsCreatingPR(false); + } + }, [taskId, isLocalOnly]); + + return ( + <div className="space-y-3"> + {/* Section Header */} + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Git Actions + </div> + + {/* Local-only mode alert */} + {isLocalOnly && ( + <div className="bg-yellow-400/10 border border-yellow-400/30 px-3 py-2 font-mono text-xs text-yellow-400 flex items-center gap-2"> + <span className="w-1.5 h-1.5 bg-yellow-400 rounded-full" /> + Contract is in local-only mode. Push/PR actions disabled. + </div> + )} + + {/* Toast notification */} + {toast && ( + <div + className={`px-3 py-2 font-mono text-xs border ${ + toast.type === "success" + ? "bg-green-400/10 border-green-400/30 text-green-400" + : toast.type === "error" + ? "bg-red-400/10 border-red-400/30 text-red-400" + : "bg-blue-400/10 border-blue-400/30 text-blue-400" + }`} + > + {toast.message} + </div> + )} + + {/* Action buttons */} + <div className="flex flex-wrap gap-2"> + <button + onClick={handleExportPatch} + disabled={isExporting} + className="px-3 py-1.5 font-mono text-xs text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + {isExporting ? "Generating..." : "Generate Patch"} + </button> + + <button + onClick={handlePushBranch} + disabled={isPushing || isLocalOnly} + className={`px-3 py-1.5 font-mono text-xs border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${ + isLocalOnly + ? "text-[#555] border-[rgba(117,170,252,0.15)] cursor-not-allowed" + : "text-cyan-400 border-cyan-400/30 hover:border-cyan-400/50 hover:bg-cyan-400/10" + }`} + title={isLocalOnly ? "Disabled in local-only mode" : "Push changes to remote branch"} + > + {isPushing ? "Pushing..." : "Push Remote Branch"} + </button> + + <button + onClick={handleCreatePR} + disabled={isCreatingPR || isLocalOnly} + className={`px-3 py-1.5 font-mono text-xs border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${ + isLocalOnly + ? "text-[#555] border-[rgba(117,170,252,0.15)] cursor-not-allowed" + : "text-green-400 border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10" + }`} + title={isLocalOnly ? "Disabled in local-only mode" : "Create pull request"} + > + {isCreatingPR ? "Creating PR..." : "Create PR"} + </button> + </div> + + {/* Export result info */} + {exportedPatch && ( + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-2"> + <div className="font-mono text-xs text-[#555]">Exported Patch</div> + <div className="font-mono text-xs text-[#75aafc] break-all"> + <span className="text-[#555]">File:</span> {exportedPatch.fileName} + </div> + {exportedPatch.filePath && ( + <div className="font-mono text-xs text-[#75aafc] break-all"> + <span className="text-[#555]">Path:</span> {exportedPatch.filePath} + </div> + )} + {exportedPatch.patchSize && ( + <div className="font-mono text-xs text-[#75aafc]"> + <span className="text-[#555]">Size:</span> {formatBytes(exportedPatch.patchSize)} + </div> + )} + </div> + )} + </div> + ); +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +} diff --git a/makima/frontend/src/components/mesh/PatchesListPanel.tsx b/makima/frontend/src/components/mesh/PatchesListPanel.tsx new file mode 100644 index 0000000..b63d7e7 --- /dev/null +++ b/makima/frontend/src/components/mesh/PatchesListPanel.tsx @@ -0,0 +1,236 @@ +import { useState, useEffect, useCallback } from "react"; +import type { PatchSummary } from "../../lib/api"; +import { listTaskPatches } from "../../lib/api"; + +interface PatchesListPanelProps { + taskId: string; + contractId: string; +} + +/** Format a date for display */ +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** Copy text to clipboard and show feedback */ +async function copyToClipboard(text: string): Promise<boolean> { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fallback for older browsers + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + textArea.style.top = "-999999px"; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand("copy"); + return true; + } catch { + return false; + } finally { + document.body.removeChild(textArea); + } + } +} + +export function PatchesListPanel({ taskId, contractId }: PatchesListPanelProps) { + const [patches, setPatches] = useState<PatchSummary[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [copiedPatchId, setCopiedPatchId] = useState<string | null>(null); + const [expandedPatchId, setExpandedPatchId] = useState<string | null>(null); + + const fetchPatches = useCallback(async () => { + try { + setLoading(true); + setError(null); + const patchList = await listTaskPatches(taskId, contractId); + setPatches(patchList); + } catch (e) { + console.error("Failed to fetch patches:", e); + setError(e instanceof Error ? e.message : "Failed to fetch patches"); + } finally { + setLoading(false); + } + }, [taskId, contractId]); + + useEffect(() => { + fetchPatches(); + }, [fetchPatches]); + + const handleCopyApplyCommand = useCallback(async (patch: PatchSummary) => { + // Generate the apply command for this patch + const command = `makima patch apply ${patch.id}`; + const success = await copyToClipboard(command); + if (success) { + setCopiedPatchId(patch.id); + setTimeout(() => setCopiedPatchId(null), 2000); + } + }, []); + + const handleViewPatch = useCallback((patchId: string) => { + setExpandedPatchId(expandedPatchId === patchId ? null : patchId); + }, [expandedPatchId]); + + if (loading) { + return ( + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase mb-2"> + Exported Patches + </div> + <div className="font-mono text-xs text-[#555] animate-pulse"> + Loading patches... + </div> + </div> + ); + } + + if (error) { + return ( + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3"> + <div className="flex items-center justify-between mb-2"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Exported Patches + </div> + <button + onClick={fetchPatches} + className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + Retry + </button> + </div> + <div className="font-mono text-xs text-red-400"> + {error} + </div> + </div> + ); + } + + if (patches.length === 0) { + return ( + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3"> + <div className="flex items-center justify-between mb-2"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Exported Patches + </div> + <button + onClick={fetchPatches} + className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + Refresh + </button> + </div> + <div className="font-mono text-xs text-[#555]"> + No patches exported yet + </div> + </div> + ); + } + + return ( + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)]"> + {/* Header */} + <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Exported Patches + </div> + <div className="flex items-center gap-2"> + <span className="font-mono text-[10px] text-[#75aafc]"> + {patches.length} patch{patches.length !== 1 ? "es" : ""} + </span> + <button + onClick={fetchPatches} + className="font-mono text-[10px] text-[#555] hover:text-[#75aafc] transition-colors" + title="Refresh" + > + <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> + </svg> + </button> + </div> + </div> + + {/* Patch list */} + <div className="divide-y divide-[rgba(117,170,252,0.1)]"> + {patches.map((patch) => ( + <div + key={patch.id} + className="p-3" + > + {/* Patch header */} + <div className="flex items-start justify-between gap-2 mb-2"> + <div className="flex-1 min-w-0"> + <div className="font-mono text-sm text-[#dbe7ff] truncate" title={patch.name}> + {patch.name} + </div> + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 font-mono text-[10px] text-[#555]"> + <span title={new Date(patch.createdAt).toLocaleString()}> + {formatDate(patch.createdAt)} + </span> + <span> + {patch.filesCount} file{patch.filesCount !== 1 ? "s" : ""} + </span> + <span className="text-green-400">+{patch.linesAdded}</span> + <span className="text-red-400">-{patch.linesRemoved}</span> + </div> + </div> + </div> + + {/* Action buttons */} + <div className="flex flex-wrap gap-2"> + <button + onClick={() => handleViewPatch(patch.id)} + className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors" + > + {expandedPatchId === patch.id ? "Hide" : "View"} + </button> + <button + onClick={() => handleCopyApplyCommand(patch)} + className={`px-2 py-1 font-mono text-[10px] border transition-colors ${ + copiedPatchId === patch.id + ? "text-green-400 border-green-400/30 bg-green-400/10" + : "text-[#9bc3ff] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)]" + }`} + > + {copiedPatchId === patch.id ? "Copied!" : "Copy Apply Command"} + </button> + </div> + + {/* Expanded patch content */} + {expandedPatchId === patch.id && patch.description && ( + <div className="mt-3 p-2 bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.1)]"> + <pre className="font-mono text-[10px] text-[#75aafc] whitespace-pre-wrap overflow-x-auto"> + {patch.description} + </pre> + </div> + )} + + {/* Expanded - show files if available */} + {expandedPatchId === patch.id && patch.files && patch.files.length > 0 && ( + <div className="mt-2"> + <div className="font-mono text-[10px] text-[#555] mb-1">Changed files:</div> + <div className="space-y-0.5"> + {patch.files.map((file, idx) => ( + <div key={idx} className="font-mono text-[10px] text-[#75aafc] pl-2"> + {file} + </div> + ))} + </div> + </div> + )} + </div> + ))} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx index 8936d28..fdcc58b 100644 --- a/makima/frontend/src/components/mesh/TaskDetail.tsx +++ b/makima/frontend/src/components/mesh/TaskDetail.tsx @@ -7,6 +7,9 @@ import { PRPreview } from "./PRPreview"; import { InlineSubtaskEditor } from "./InlineSubtaskEditor"; import { DirectoryInput } from "./DirectoryInput"; import { BranchTaskModal } from "./BranchTaskModal"; +import { GitActionsPanel } from "./GitActionsPanel"; +import { WorktreeFilesPanel } from "./WorktreeFilesPanel"; +import { PatchesListPanel } from "./PatchesListPanel"; interface TaskDetailProps { task: TaskWithSubtasks; @@ -36,6 +39,8 @@ interface TaskDetailProps { fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>; /** For supervisor tasks: all tasks in the contract (excluding the supervisor itself) */ contractTasks?: TaskSummary[]; + /** Whether the contract is in local-only mode (no push/PR) */ + isLocalOnly?: boolean; } function formatDate(dateStr: string): string { @@ -119,6 +124,7 @@ export function TaskDetail({ onAutoMerge, fetchSubtasks, contractTasks, + isLocalOnly = false, }: TaskDetailProps) { const [isEditing, setIsEditing] = useState(false); const [editName, setEditName] = useState(task.name); @@ -879,6 +885,25 @@ export function TaskDetail({ )} </div> )} + + {/* Git Actions Panel for manual git operations */} + {task.status === "done" && ( + <GitActionsPanel + taskId={task.id} + isLocalOnly={isLocalOnly} + taskStatus={task.status} + /> + )} + + {/* 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} /> + )} + + {/* Patches List Panel - show exported patches for this task */} + {task.contractId && (task.status === "done" || task.status === "failed" || task.status === "merged") && ( + <PatchesListPanel taskId={task.id} contractId={task.contractId} /> + )} </div> {/* Overlay Diff Modal */} diff --git a/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx new file mode 100644 index 0000000..b529588 --- /dev/null +++ b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx @@ -0,0 +1,197 @@ +import { useState, useEffect, useCallback } from "react"; +import type { WorktreeInfo } from "../../lib/api"; +import { getWorktreeInfo } from "../../lib/api"; + +interface WorktreeFilesPanelProps { + taskId: string; +} + +/** Get status badge styling based on file status */ +function getStatusStyle(status: string): { color: string; bgColor: string; label: string } { + switch (status) { + case "M": + case "modified": + return { color: "text-yellow-400", bgColor: "bg-yellow-400/10", label: "M" }; + case "A": + case "added": + return { color: "text-green-400", bgColor: "bg-green-400/10", label: "A" }; + case "D": + case "deleted": + return { color: "text-red-400", bgColor: "bg-red-400/10", label: "D" }; + case "R": + case "renamed": + return { color: "text-cyan-400", bgColor: "bg-cyan-400/10", label: "R" }; + case "C": + case "copied": + return { color: "text-purple-400", bgColor: "bg-purple-400/10", label: "C" }; + case "U": + case "unmerged": + return { color: "text-orange-400", bgColor: "bg-orange-400/10", label: "U" }; + case "?": + case "untracked": + return { color: "text-[#555]", bgColor: "bg-[#555]/10", label: "?" }; + default: + return { color: "text-[#9bc3ff]", bgColor: "bg-[rgba(117,170,252,0.1)]", label: status.charAt(0).toUpperCase() }; + } +} + +export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) { + const [worktreeInfo, setWorktreeInfo] = useState<WorktreeInfo | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [expanded, setExpanded] = useState(false); + + const fetchWorktreeInfo = useCallback(async () => { + try { + setLoading(true); + setError(null); + const info = await getWorktreeInfo(taskId); + setWorktreeInfo(info); + } catch (e) { + console.error("Failed to fetch worktree info:", e); + setError(e instanceof Error ? e.message : "Failed to fetch worktree info"); + } finally { + setLoading(false); + } + }, [taskId]); + + useEffect(() => { + fetchWorktreeInfo(); + }, [fetchWorktreeInfo]); + + if (loading) { + return ( + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase mb-2"> + Worktree Changes + </div> + <div className="font-mono text-xs text-[#555] animate-pulse"> + Loading worktree info... + </div> + </div> + ); + } + + if (error) { + return ( + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3"> + <div className="flex items-center justify-between mb-2"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Worktree Changes + </div> + <button + onClick={fetchWorktreeInfo} + className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + Retry + </button> + </div> + <div className="font-mono text-xs text-red-400"> + {error} + </div> + </div> + ); + } + + if (!worktreeInfo || worktreeInfo.files.length === 0) { + return ( + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3"> + <div className="flex items-center justify-between mb-2"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Worktree Changes + </div> + <button + onClick={fetchWorktreeInfo} + className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + Refresh + </button> + </div> + <div className="font-mono text-xs text-[#555]"> + No changes in worktree + </div> + </div> + ); + } + + const { stats, files } = worktreeInfo; + const displayFiles = expanded ? files : files.slice(0, 10); + + return ( + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)]"> + {/* Header */} + <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Worktree Changes + </div> + <div className="flex items-center gap-3"> + {/* Stats */} + <div className="flex items-center gap-2 font-mono text-[10px]"> + <span className="text-[#75aafc]"> + {stats.filesChanged} file{stats.filesChanged !== 1 ? "s" : ""} + </span> + <span className="text-green-400">+{stats.insertions}</span> + <span className="text-red-400">-{stats.deletions}</span> + </div> + <button + onClick={fetchWorktreeInfo} + className="font-mono text-[10px] text-[#555] hover:text-[#75aafc] transition-colors" + title="Refresh" + > + <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> + </svg> + </button> + </div> + </div> + + {/* File list */} + <div className="divide-y divide-[rgba(117,170,252,0.05)]"> + {displayFiles.map((file) => { + const statusStyle = getStatusStyle(file.status); + return ( + <div + key={file.path} + className="flex items-center gap-2 px-3 py-1.5 hover:bg-[rgba(117,170,252,0.03)]" + > + {/* Status badge */} + <span + className={`w-5 h-5 flex items-center justify-center font-mono text-[10px] font-medium ${statusStyle.color} ${statusStyle.bgColor} border border-current/20`} + title={file.status} + > + {statusStyle.label} + </span> + + {/* File path */} + <span className="flex-1 font-mono text-xs text-[#dbe7ff] truncate" title={file.path}> + {file.path} + </span> + + {/* Line stats */} + <div className="flex items-center gap-1.5 font-mono text-[10px] shrink-0"> + {file.linesAdded > 0 && ( + <span className="text-green-400">+{file.linesAdded}</span> + )} + {file.linesRemoved > 0 && ( + <span className="text-red-400">-{file.linesRemoved}</span> + )} + </div> + </div> + ); + })} + </div> + + {/* Show more/less button */} + {files.length > 10 && ( + <div className="px-3 py-2 border-t border-[rgba(117,170,252,0.1)]"> + <button + onClick={() => setExpanded(!expanded)} + className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + {expanded ? `Show less` : `Show ${files.length - 10} more files...`} + </button> + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index b3c18a5..7c9fcd6 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -811,6 +811,112 @@ export async function retryCompletionAction( return res.json(); } +// ============================================================================= +// Git Actions for Tasks (Manual) +// ============================================================================= + +/** Response from export patch */ +export interface ExportPatchResponse { + success: boolean; + taskId: string; + fileName: string; + filePath?: string; + patchSize?: number; + message: string; +} + +/** + * Export a task's changes as a patch file. + * The patch will be saved to the contract's patch directory. + */ +export async function exportTaskPatch( + taskId: string, + fileName?: string +): Promise<ExportPatchResponse> { + const body: Record<string, unknown> = {}; + if (fileName) { + body.fileName = fileName; + } + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/export-patch`, { + method: "POST", + body: JSON.stringify(body), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to export patch: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** Response from push branch */ +export interface PushBranchResponse { + success: boolean; + taskId: string; + branchName: string; + remote?: string; + message: string; +} + +/** + * Push a task's changes to a remote branch. + * Creates a branch if it doesn't exist and pushes the commits. + */ +export async function pushTaskBranch( + taskId: string, + branchName?: string +): Promise<PushBranchResponse> { + const body: Record<string, unknown> = {}; + if (branchName) { + body.branchName = branchName; + } + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/push-branch`, { + method: "POST", + body: JSON.stringify(body), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to push branch: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** Response from create PR */ +export interface CreatePRResponse { + success: boolean; + taskId: string; + prUrl?: string; + prNumber?: number; + branchName?: string; + message: string; +} + +/** + * Create a pull request for a task's changes. + * First pushes the branch if needed, then creates the PR. + */ +export async function createTaskPR( + taskId: string, + title?: string, + body?: string +): Promise<CreatePRResponse> { + const reqBody: Record<string, unknown> = {}; + if (title) { + reqBody.title = title; + } + if (body) { + reqBody.body = body; + } + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/create-pr`, { + method: "POST", + body: JSON.stringify(reqBody), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to create PR: ${errorText || res.statusText}`); + } + return res.json(); +} + /** A suggested directory from a connected daemon */ export interface DaemonDirectory { /** Path to the directory */ @@ -1567,6 +1673,8 @@ export interface ContractSummary { status: ContractStatus; /** Supervisor task ID for contract orchestration */ supervisorTaskId: string | null; + /** When true, tasks won't auto-push or create PRs - use patch files instead */ + localOnly: boolean; fileCount: number; taskCount: number; repositoryCount: number; @@ -1589,6 +1697,8 @@ export interface Contract { autonomousLoop: boolean; /** Whether to wait for user confirmation before progressing to the next phase */ phaseGuard: boolean; + /** When true, tasks won't auto-push or create PRs - use patch files instead */ + localOnly: boolean; version: number; createdAt: string; updatedAt: string; @@ -1622,6 +1732,8 @@ export interface CreateContractRequest { contractType?: ContractType; /** Initial phase to start in (defaults based on contract type) */ initialPhase?: ContractPhase; + /** When true, tasks won't auto-push or create PRs - use patch files instead */ + localOnly?: boolean; } export interface UpdateContractRequest { @@ -2710,3 +2822,106 @@ export function getSupervisorStatus( export async function dismissTask(taskId: string): Promise<Task> { return updateTask(taskId, { hidden: true }); } + +// ============================================================================= +// Worktree Info Types and Functions +// ============================================================================= + +/** File status in the worktree (git status) */ +export type FileStatus = "M" | "A" | "D" | "R" | "C" | "U" | "?" | "modified" | "added" | "deleted" | "renamed" | "copied" | "unmerged" | "untracked"; + +/** A single changed file in the worktree */ +export interface WorktreeFile { + /** File path relative to worktree root */ + path: string; + /** Git status code (M=modified, A=added, D=deleted, R=renamed, C=copied, U=unmerged, ?=untracked) */ + status: FileStatus; + /** Lines added (0 if deleted or unavailable) */ + linesAdded: number; + /** Lines removed (0 if added or unavailable) */ + linesRemoved: number; +} + +/** Statistics about worktree changes */ +export interface WorktreeStats { + /** Number of files changed */ + filesChanged: number; + /** Total lines inserted */ + insertions: number; + /** Total lines deleted */ + deletions: number; +} + +/** Worktree information for a task */ +export interface WorktreeInfo { + /** Task ID */ + taskId: string; + /** Path to the worktree directory */ + worktreePath: string | null; + /** Whether the worktree exists on the daemon */ + exists: boolean; + /** Aggregate statistics */ + stats: WorktreeStats; + /** Changed files list */ + files: WorktreeFile[]; + /** Current branch name */ + branch: string | null; + /** Current HEAD commit SHA */ + headSha: string | null; +} + +/** + * Get worktree information for a task. + * Returns changed files, stats, and metadata about the worktree. + */ +export async function getWorktreeInfo(taskId: string): Promise<WorktreeInfo> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/worktree-info`); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to get worktree info: ${errorText || res.statusText}`); + } + return res.json(); +} + +// ============================================================================= +// Patch Types and Functions +// ============================================================================= + +/** Summary of a patch file (contract file of type "patch") */ +export interface PatchSummary { + /** Patch/file ID */ + id: string; + /** Patch name */ + name: string; + /** Optional description */ + description: string | null; + /** Task ID this patch was created from */ + taskId: string | null; + /** Contract ID */ + contractId: string; + /** Number of files in the patch */ + filesCount: number; + /** Total lines added */ + linesAdded: number; + /** Total lines removed */ + linesRemoved: number; + /** List of file paths in the patch (if available) */ + files: string[] | null; + /** When the patch was created */ + createdAt: string; + /** When the patch was last updated */ + updatedAt: string; +} + +/** + * List patches for a task. + * Returns contract files of type "patch" associated with the task. + */ +export async function listTaskPatches(taskId: string, contractId: string): Promise<PatchSummary[]> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/patches?contractId=${contractId}`); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to list patches: ${errorText || res.statusText}`); + } + return res.json(); +} diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx index 36eb980..aa62bd9 100644 --- a/makima/frontend/src/routes/contracts.tsx +++ b/makima/frontend/src/routes/contracts.tsx @@ -92,6 +92,7 @@ function ContractsPageContent() { const [showRepoSuggestions, setShowRepoSuggestions] = useState(false); const [contractTypes, setContractTypes] = useState<ContractTypeTemplate[]>([]); const [contractTypesLoading, setContractTypesLoading] = useState(false); + const [localOnly, setLocalOnly] = useState(false); // Fetch contract types when modal opens - merges built-in types with user templates useEffect(() => { @@ -263,6 +264,7 @@ function ContractsPageContent() { description: newContractDescription.trim() || undefined, contractType: contractType, initialPhase: initialPhase !== defaultPhaseForType ? initialPhase : undefined, + localOnly: localOnly || undefined, }; try { @@ -303,6 +305,7 @@ function ContractsPageContent() { setRepoName(""); setRepoUrl(""); setRepoPath(""); + setLocalOnly(false); navigate(`/contracts/${contract.id}`); } } catch (err) { @@ -336,6 +339,7 @@ function ContractsPageContent() { setRepoName(""); setRepoUrl(""); setRepoPath(""); + setLocalOnly(false); setCreateError(null); }, []); @@ -661,6 +665,46 @@ function ContractsPageContent() { </p> </div> + {/* Local-Only Mode */} + <div className="space-y-2"> + <div className="flex items-center space-x-3"> + <button + type="button" + onClick={() => setLocalOnly(!localOnly)} + className={`w-5 h-5 flex items-center justify-center border transition-colors ${ + localOnly + ? "bg-[#0f3c78] border-[#75aafc] text-[#dbe7ff]" + : "bg-[#0d1b2d] border-[#3f6fb3] text-transparent" + }`} + > + {localOnly && ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="3" + strokeLinecap="round" + strokeLinejoin="round" + className="w-3 h-3" + > + <polyline points="20 6 9 17 4 12" /> + </svg> + )} + </button> + <label + className="font-mono text-sm text-[#dbe7ff] cursor-pointer select-none" + onClick={() => setLocalOnly(!localOnly)} + > + Local-Only Mode + </label> + </div> + <p className="font-mono text-xs text-[#8b949e] pl-8"> + When enabled, tasks won't automatically push to remote or create PRs. + Use patch files to export changes. + </p> + </div> + {/* Repository Configuration */} <div className="border-t border-[rgba(117,170,252,0.2)] pt-4"> <label className="block font-mono text-xs text-[#75aafc] uppercase mb-3"> diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo index 538c301..eff7718 100644 --- a/makima/frontend/tsconfig.tsbuildinfo +++ b/makima/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/autopilotpanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/templates/templateeditor.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/templates.tsx","./src/routes/workflow.tsx","./src/types/messages.ts","./src/types/templates.ts"],"errors":true,"version":"5.9.3"} +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/autopilotpanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/templates/templateeditor.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/templates.tsx","./src/routes/workflow.tsx","./src/types/messages.ts","./src/types/templates.ts"],"errors":true,"version":"5.9.3"}
\ No newline at end of file diff --git a/makima/migrations/20250127000000_add_local_only.sql b/makima/migrations/20250127000000_add_local_only.sql new file mode 100644 index 0000000..2cd594e --- /dev/null +++ b/makima/migrations/20250127000000_add_local_only.sql @@ -0,0 +1,8 @@ +-- Add local_only column to contracts table +-- When enabled, automatic completion actions (branch, merge, pr) are skipped, +-- allowing users to manually handle code changes via patch files or other means. + +ALTER TABLE contracts +ADD COLUMN IF NOT EXISTS local_only BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN contracts.local_only IS 'Whether to skip automatic completion actions (branch, merge, pr) for this contract'; diff --git a/makima/migrations/20260126000000_add_local_only_mode.sql b/makima/migrations/20260126000000_add_local_only_mode.sql new file mode 100644 index 0000000..27af087 --- /dev/null +++ b/makima/migrations/20260126000000_add_local_only_mode.sql @@ -0,0 +1,7 @@ +-- Add local_only column to contracts +ALTER TABLE contracts ADD COLUMN local_only BOOLEAN NOT NULL DEFAULT false; + +-- Index for filtering by local_only mode +CREATE INDEX idx_contracts_local_only ON contracts(local_only) WHERE local_only = true; + +COMMENT ON COLUMN contracts.local_only IS 'When true, tasks do not auto-execute completion actions and work stays in worktrees'; diff --git a/makima/src/daemon/storage/mod.rs b/makima/src/daemon/storage/mod.rs index cc5441a..e5457f7 100644 --- a/makima/src/daemon/storage/mod.rs +++ b/makima/src/daemon/storage/mod.rs @@ -5,4 +5,7 @@ mod patch; -pub use patch::{create_patch, apply_patch, PatchError}; +pub use patch::{ + apply_patch, create_export_patch, create_patch, get_head_sha, get_parent_sha, ExportPatchResult, + PatchError, +}; diff --git a/makima/src/daemon/storage/patch.rs b/makima/src/daemon/storage/patch.rs index 45624b5..0da4eda 100644 --- a/makima/src/daemon/storage/patch.rs +++ b/makima/src/daemon/storage/patch.rs @@ -141,6 +141,223 @@ pub async fn get_parent_sha(worktree_path: &Path) -> Result<String, PatchError> Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +/// Get the current HEAD commit SHA from a worktree. +pub async fn get_head_sha(worktree_path: &Path) -> Result<String, PatchError> { + let output = Command::new("git") + .current_dir(worktree_path) + .args(["rev-parse", "HEAD"]) + .output() + .await + .map_err(|e| PatchError::GitCommand(format!("Failed to get HEAD SHA: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(PatchError::GitCommand(format!( + "git rev-parse HEAD failed: {}", + stderr + ))); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Result of creating an export patch. +#[derive(Debug, Clone)] +pub struct ExportPatchResult { + /// The uncompressed, human-readable patch content. + pub patch_content: String, + /// Number of files changed in the patch. + pub files_count: usize, + /// Number of lines added. + pub lines_added: usize, + /// Number of lines removed. + pub lines_removed: usize, + /// The base commit SHA that the patch is diffed against. + pub base_commit_sha: String, +} + +/// Create an uncompressed git diff patch for export. +/// +/// This creates a human-readable patch that can be applied manually or +/// shared as a file. Unlike `create_patch`, this version is not compressed +/// and is suitable for display or export. +/// +/// If `base_sha` is provided, the diff is between that commit and HEAD. +/// If `base_sha` is None, it attempts to find the merge-base with the default branch +/// or falls back to diffing uncommitted changes against HEAD. +pub async fn create_export_patch( + worktree_path: &Path, + base_sha: Option<&str>, +) -> Result<ExportPatchResult, PatchError> { + // Determine the base SHA to diff against + let resolved_base_sha = match base_sha { + Some(sha) => sha.to_string(), + None => { + // Try to find the merge-base with the default branch + // First, try to get the upstream tracking branch + let upstream_result = Command::new("git") + .current_dir(worktree_path) + .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) + .output() + .await; + + let base = if let Ok(output) = upstream_result { + if output.status.success() { + let upstream = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // Get merge-base with upstream + let merge_base = Command::new("git") + .current_dir(worktree_path) + .args(["merge-base", "HEAD", &upstream]) + .output() + .await; + + if let Ok(mb_output) = merge_base { + if mb_output.status.success() { + Some(String::from_utf8_lossy(&mb_output.stdout).trim().to_string()) + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + }; + + // If we couldn't find upstream, try common default branches + let base = if base.is_none() { + let default_branches = ["origin/main", "origin/master", "main", "master"]; + let mut found_base = None; + + for branch in default_branches { + let merge_base = Command::new("git") + .current_dir(worktree_path) + .args(["merge-base", "HEAD", branch]) + .output() + .await; + + if let Ok(output) = merge_base { + if output.status.success() { + found_base = Some(String::from_utf8_lossy(&output.stdout).trim().to_string()); + break; + } + } + } + found_base + } else { + base + }; + + // If still nothing, get the first commit or use HEAD~1 + base.unwrap_or_else(|| { + // This will be used, but if HEAD~1 doesn't exist (only one commit), + // git diff will handle it gracefully + "HEAD~1".to_string() + }) + } + }; + + // Get diff stats using --stat + let stat_output = Command::new("git") + .current_dir(worktree_path) + .args(["diff", "--stat", &resolved_base_sha, "HEAD"]) + .output() + .await + .map_err(|e| PatchError::GitCommand(format!("Failed to run git diff --stat: {}", e)))?; + + // Parse the stat output to get line counts + let (lines_added, lines_removed) = if stat_output.status.success() { + parse_diff_stat(&String::from_utf8_lossy(&stat_output.stdout)) + } else { + (0, 0) + }; + + // Get the actual diff content + let diff_output = Command::new("git") + .current_dir(worktree_path) + .args(["diff", &resolved_base_sha, "HEAD"]) + .output() + .await + .map_err(|e| PatchError::GitCommand(format!("Failed to run git diff: {}", e)))?; + + if !diff_output.status.success() { + let stderr = String::from_utf8_lossy(&diff_output.stderr); + return Err(PatchError::GitCommand(format!("git diff failed: {}", stderr))); + } + + let patch_content = String::from_utf8_lossy(&diff_output.stdout).to_string(); + + // Check for empty patch + if patch_content.trim().is_empty() { + return Err(PatchError::EmptyPatch); + } + + // Count files changed + let files_output = Command::new("git") + .current_dir(worktree_path) + .args(["diff", "--name-only", &resolved_base_sha, "HEAD"]) + .output() + .await + .map_err(|e| PatchError::GitCommand(format!("Failed to count files: {}", e)))?; + + let files_count = if files_output.status.success() { + String::from_utf8_lossy(&files_output.stdout) + .lines() + .filter(|l| !l.is_empty()) + .count() + } else { + 0 + }; + + Ok(ExportPatchResult { + patch_content, + files_count, + lines_added, + lines_removed, + base_commit_sha: resolved_base_sha, + }) +} + +/// Parse git diff --stat output to extract lines added and removed. +/// The last line typically looks like: " 3 files changed, 45 insertions(+), 12 deletions(-)" +fn parse_diff_stat(stat_output: &str) -> (usize, usize) { + let mut lines_added = 0; + let mut lines_removed = 0; + + // Look for the summary line at the end + for line in stat_output.lines().rev() { + let line = line.trim(); + if line.contains("changed") || line.contains("insertion") || line.contains("deletion") { + // Parse insertions + if let Some(idx) = line.find("insertion") { + let before = &line[..idx]; + if let Some(num_str) = before.split_whitespace().last() { + if let Ok(num) = num_str.parse::<usize>() { + lines_added = num; + } + } + } + // Parse deletions + if let Some(idx) = line.find("deletion") { + let before = &line[..idx]; + if let Some(num_str) = before.split(',').last() { + if let Some(num_str) = num_str.trim().split_whitespace().next() { + if let Ok(num) = num_str.parse::<usize>() { + lines_removed = num; + } + } + } + } + break; + } + } + + (lines_added, lines_removed) +} + /// Checkout a specific commit in the worktree. pub async fn checkout_commit(worktree_path: &Path, sha: &str) -> Result<(), PatchError> { let output = Command::new("git") @@ -290,4 +507,90 @@ mod tests { let result = create_patch(path, &head_sha).await; assert!(matches!(result, Err(PatchError::EmptyPatch))); } + + #[tokio::test] + async fn test_create_export_patch() { + let dir = setup_test_repo().await; + let path = dir.path(); + + // Get the initial commit SHA before making changes + let initial_sha = get_head_sha(path).await.unwrap(); + + // Make some changes and commit + fs::write(path.join("file.txt"), "modified content").unwrap(); + fs::write(path.join("new_file.txt"), "new file content").unwrap(); + Command::new("git") + .current_dir(path) + .args(["add", "."]) + .output() + .await + .unwrap(); + Command::new("git") + .current_dir(path) + .args(["commit", "-m", "changes for export"]) + .output() + .await + .unwrap(); + + // Create export patch with explicit base + let result = create_export_patch(path, Some(&initial_sha)).await.unwrap(); + + // Verify the result + assert!(!result.patch_content.is_empty()); + assert_eq!(result.files_count, 2); // file.txt and new_file.txt + assert!(result.lines_added > 0); + assert_eq!(result.base_commit_sha, initial_sha); + + // The patch should contain diff headers + assert!(result.patch_content.contains("diff --git")); + assert!(result.patch_content.contains("new_file.txt")); + } + + #[tokio::test] + async fn test_create_export_patch_no_base() { + let dir = setup_test_repo().await; + let path = dir.path(); + + // Make a second commit so we have something to diff + fs::write(path.join("file.txt"), "modified").unwrap(); + Command::new("git") + .current_dir(path) + .args(["add", "."]) + .output() + .await + .unwrap(); + Command::new("git") + .current_dir(path) + .args(["commit", "-m", "second commit"]) + .output() + .await + .unwrap(); + + // Create export patch without explicit base (will use HEAD~1) + let result = create_export_patch(path, None).await.unwrap(); + + // Verify the result + assert!(!result.patch_content.is_empty()); + assert_eq!(result.files_count, 1); + assert!(result.patch_content.contains("diff --git")); + } + + #[tokio::test] + async fn test_parse_diff_stat() { + // Test the parse_diff_stat function with various formats + let stat1 = " 3 files changed, 45 insertions(+), 12 deletions(-)"; + let (added, removed) = parse_diff_stat(stat1); + assert_eq!(added, 45); + assert_eq!(removed, 12); + + let stat2 = " 1 file changed, 10 insertions(+)"; + let (added, removed) = parse_diff_stat(stat2); + assert_eq!(added, 10); + assert_eq!(removed, 0); + + let stat3 = " 2 files changed, 5 deletions(-)"; + let (added, removed) = parse_diff_stat(stat3); + assert_eq!(added, 0); + assert_eq!(removed, 5); + } } diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index 8abff3f..bbcf661 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -995,6 +995,8 @@ pub struct ManagedTask { pub concurrency_key: Uuid, /// Whether to run in autonomous loop mode. pub autonomous_loop: bool, + /// Whether the contract is in local-only mode (skips automatic completion actions). + pub local_only: bool, /// Time task was created. pub created_at: Instant, /// Time task started running. @@ -1692,6 +1694,7 @@ impl TaskManager { conversation_history, patch_data, patch_base_sha, + local_only, } => { tracing::info!( task_id = %task_id, @@ -1718,7 +1721,7 @@ impl TaskManager { parent_task_id, depth, is_orchestrator, is_supervisor, target_repo_path, completion_action, continue_from_task_id, copy_files, contract_id, autonomous_loop, resume_session, - conversation_history, patch_data, patch_base_sha, + conversation_history, patch_data, patch_base_sha, local_only, ).await?; } DaemonCommand::PauseTask { task_id } => { @@ -1796,6 +1799,7 @@ impl TaskManager { let target_repo_path = task.target_repo_path.clone(); let completion_action = task.completion_action.clone(); let contract_id = task.contract_id; + let local_only = task.local_only; // Spawn in background to not block the command handler tokio::spawn(async move { @@ -1818,6 +1822,7 @@ impl TaskManager { None, // conversation_history - not needed for fresh respawn None, // patch_data - not available for respawn None, // patch_base_sha - not available for respawn + local_only, ).await { tracing::error!( task_id = %task_id, @@ -2009,6 +2014,10 @@ impl TaskManager { tracing::info!(source_dir = ?source_dir, "Inheriting git config"); self.handle_inherit_git_config(source_dir).await?; } + DaemonCommand::CreateExportPatch { task_id, base_sha } => { + tracing::info!(task_id = %task_id, base_sha = ?base_sha, "Creating export patch"); + self.handle_create_export_patch(task_id, base_sha).await?; + } DaemonCommand::RestartDaemon => { tracing::info!("Received restart command from server, initiating daemon restart..."); // Shutdown all running tasks gracefully @@ -2046,6 +2055,7 @@ impl TaskManager { conversation_history: Option<serde_json::Value>, patch_data: Option<String>, patch_base_sha: Option<String>, + local_only: bool, ) -> TaskResult<()> { tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, is_supervisor = is_supervisor, depth = depth, patch_available = patch_data.is_some(), "=== SPAWN_TASK START ==="); @@ -2096,6 +2106,7 @@ impl TaskManager { contract_id, concurrency_key, autonomous_loop, + local_only, created_at: Instant::now(), started_at: None, completed_at: None, @@ -2122,7 +2133,7 @@ impl TaskManager { task_id, task_name, plan, repo_url, base_branch, target_branch, is_orchestrator, is_supervisor, target_repo_path, completion_action, continue_from_task_id, copy_files, contract_id, autonomous_loop, resume_session, - conversation_history, patch_data, patch_base_sha, + conversation_history, patch_data, patch_base_sha, local_only, ).await { tracing::error!(task_id = %task_id, error = %e, "Task execution failed"); inner.mark_failed(task_id, &e.to_string()).await; @@ -2729,6 +2740,86 @@ impl TaskManager { Ok(()) } + /// Handle CreateExportPatch command. + /// + /// Creates an uncompressed, human-readable git patch for export. + async fn handle_create_export_patch( + &self, + task_id: Uuid, + base_sha: Option<String>, + ) -> Result<(), DaemonError> { + // Get task's worktree path + let worktree_result = self.get_task_worktree_path(task_id).await; + + let msg = match worktree_result { + Ok(worktree_path) => { + // Create the export patch + match storage::create_export_patch(&worktree_path, base_sha.as_deref()).await { + Ok(result) => { + tracing::info!( + task_id = %task_id, + files_count = result.files_count, + lines_added = result.lines_added, + lines_removed = result.lines_removed, + base_commit_sha = %result.base_commit_sha, + "Export patch created successfully" + ); + + DaemonMessage::ExportPatchCreated { + task_id, + success: true, + patch_content: Some(result.patch_content), + files_count: Some(result.files_count), + lines_added: Some(result.lines_added), + lines_removed: Some(result.lines_removed), + base_commit_sha: Some(result.base_commit_sha), + error: None, + } + } + Err(e) => { + tracing::warn!( + task_id = %task_id, + error = %e, + "Failed to create export patch" + ); + + DaemonMessage::ExportPatchCreated { + task_id, + success: false, + patch_content: None, + files_count: None, + lines_added: None, + lines_removed: None, + base_commit_sha: None, + error: Some(e.to_string()), + } + } + } + } + Err(e) => { + tracing::warn!( + task_id = %task_id, + error = %e, + "Failed to get worktree path for export patch" + ); + + DaemonMessage::ExportPatchCreated { + task_id, + success: false, + patch_content: None, + files_count: None, + lines_added: None, + lines_removed: None, + base_commit_sha: None, + error: Some(format!("Task not found or has no worktree: {}", e)), + } + } + }; + + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + /// Handle ReadRepoFile command. /// /// Reads a file from a repository on the daemon's filesystem and sends @@ -3570,6 +3661,7 @@ impl TaskManagerInner { conversation_history: Option<serde_json::Value>, patch_data: Option<String>, patch_base_sha: Option<String>, + local_only: bool, ) -> Result<(), DaemonError> { tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, is_supervisor = is_supervisor, resume_session = resume_session, has_patch = patch_data.is_some(), "=== RUN_TASK START ==="); @@ -4704,9 +4796,15 @@ impl TaskManagerInner { } } - // Execute completion action if task succeeded + // Execute completion action if task succeeded (skip in local_only mode) let completion_result = if success { - if let Some(ref action) = completion_action { + if local_only { + tracing::info!( + task_id = %task_id, + "Skipping completion action - contract is in local_only mode" + ); + Ok(None) + } else if let Some(ref action) = completion_action { if action != "none" { self.execute_completion_action( task_id, diff --git a/makima/src/daemon/ws/protocol.rs b/makima/src/daemon/ws/protocol.rs index 2e7caef..018dc7b 100644 --- a/makima/src/daemon/ws/protocol.rs +++ b/makima/src/daemon/ws/protocol.rs @@ -316,6 +316,30 @@ pub enum DaemonMessage { message: String, }, + /// Response to CreateExportPatch command. + ExportPatchCreated { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + /// The uncompressed, human-readable patch content. + #[serde(rename = "patchContent")] + patch_content: Option<String>, + /// Number of files changed. + #[serde(rename = "filesCount")] + files_count: Option<usize>, + /// Lines added. + #[serde(rename = "linesAdded")] + lines_added: Option<usize>, + /// Lines removed. + #[serde(rename = "linesRemoved")] + lines_removed: Option<usize>, + /// The base commit SHA that the patch is diffed against. + #[serde(rename = "baseCommitSha")] + base_commit_sha: Option<String>, + /// Error message if failed. + error: Option<String>, + }, + /// Response to InheritGitConfig command. GitConfigInherited { success: bool, @@ -422,6 +446,9 @@ pub enum DaemonCommand { /// Commit SHA to apply the patch on top of. #[serde(rename = "patchBaseSha", default, skip_serializing_if = "Option::is_none")] patch_base_sha: Option<String>, + /// Whether the contract is in local-only mode (skips automatic completion actions). + #[serde(rename = "localOnly", default)] + local_only: bool, }, /// Pause a running task. @@ -646,6 +673,17 @@ pub enum DaemonCommand { delete_branch: bool, }, + /// Create an uncompressed git patch for export. + /// Returns a human-readable patch that can be applied manually or shared. + CreateExportPatch { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Optional base SHA to diff against. If not provided, will try to find + /// the merge-base with the default branch. + #[serde(rename = "baseSha")] + base_sha: Option<String>, + }, + /// Inherit git config (user.email, user.name) from a directory. /// This config will be applied to all future worktrees. InheritGitConfig { diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 95517a1..9c2d072 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -1326,6 +1326,11 @@ pub struct Contract { #[sqlx(json)] #[serde(default)] pub completed_deliverables: serde_json::Value, + /// Whether this contract operates in local-only mode. + /// When enabled, automatic completion actions (branch, merge, pr) are skipped, + /// allowing users to manually handle code changes via patch files or other means. + #[serde(default)] + pub local_only: bool, pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, @@ -1441,6 +1446,9 @@ pub struct ContractSummary { pub status: String, /// Supervisor task ID for contract orchestration pub supervisor_task_id: Option<Uuid>, + /// When true, tasks do not auto-execute completion actions and work stays in worktrees. + #[serde(default)] + pub local_only: bool, pub file_count: i64, pub task_count: i64, pub repository_count: i64, @@ -1495,6 +1503,11 @@ pub struct CreateContractRequest { /// phase outputs before progressing to the next phase. #[serde(default)] pub phase_guard: Option<bool>, + /// Enable local-only mode for this contract. + /// When enabled, automatic completion actions (branch, merge, pr) are skipped, + /// allowing users to manually handle code changes via patch files or other means. + #[serde(default)] + pub local_only: Option<bool>, } /// Request payload for updating a contract @@ -1516,6 +1529,11 @@ pub struct UpdateContractRequest { /// phase outputs before progressing to the next phase. #[serde(default)] pub phase_guard: Option<bool>, + /// Enable or disable local-only mode for this contract. + /// When enabled, automatic completion actions (branch, merge, pr) are skipped, + /// allowing users to manually handle code changes via patch files or other means. + #[serde(default)] + pub local_only: Option<bool>, /// Version for optimistic locking pub version: Option<i32>, } diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index b55b05e..6d6642b 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -2175,11 +2175,12 @@ pub async fn create_contract_for_owner( let autonomous_loop = req.autonomous_loop.unwrap_or(false); let phase_guard = req.phase_guard.unwrap_or(false); + let local_only = req.local_only.unwrap_or(false); sqlx::query_as::<_, Contract>( r#" - INSERT INTO contracts (owner_id, name, description, contract_type, phase, autonomous_loop, phase_guard) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO contracts (owner_id, name, description, contract_type, phase, autonomous_loop, phase_guard, local_only) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING * "#, ) @@ -2190,6 +2191,7 @@ pub async fn create_contract_for_owner( .bind(phase) .bind(autonomous_loop) .bind(phase_guard) + .bind(local_only) .fetch_one(pool) .await } @@ -2222,7 +2224,7 @@ pub async fn list_contracts_for_owner( r#" SELECT c.id, c.name, c.description, c.contract_type, c.phase, c.status, - c.supervisor_task_id, c.version, c.created_at, + c.supervisor_task_id, c.local_only, c.version, c.created_at, (SELECT COUNT(*) FROM files WHERE contract_id = c.id) as file_count, (SELECT COUNT(*) FROM tasks WHERE contract_id = c.id) as task_count, (SELECT COUNT(*) FROM contract_repositories WHERE contract_id = c.id) as repository_count @@ -2246,7 +2248,7 @@ pub async fn get_contract_summary_for_owner( r#" SELECT c.id, c.name, c.description, c.contract_type, c.phase, c.status, - c.supervisor_task_id, c.version, c.created_at, + c.supervisor_task_id, c.local_only, c.version, c.created_at, (SELECT COUNT(*) FROM files WHERE contract_id = c.id) as file_count, (SELECT COUNT(*) FROM tasks WHERE contract_id = c.id) as task_count, (SELECT COUNT(*) FROM contract_repositories WHERE contract_id = c.id) as repository_count @@ -2290,14 +2292,15 @@ pub async fn update_contract_for_owner( let supervisor_task_id = req.supervisor_task_id.or(existing.supervisor_task_id); let autonomous_loop = req.autonomous_loop.unwrap_or(existing.autonomous_loop); let phase_guard = req.phase_guard.unwrap_or(existing.phase_guard); + let local_only = req.local_only.unwrap_or(existing.local_only); let result = if req.version.is_some() { sqlx::query_as::<_, Contract>( r#" UPDATE contracts SET name = $3, description = $4, phase = $5, status = $6, - supervisor_task_id = $7, autonomous_loop = $8, phase_guard = $9, version = version + 1, updated_at = NOW() - WHERE id = $1 AND owner_id = $2 AND version = $10 + supervisor_task_id = $7, autonomous_loop = $8, phase_guard = $9, local_only = $10, version = version + 1, updated_at = NOW() + WHERE id = $1 AND owner_id = $2 AND version = $11 RETURNING * "#, ) @@ -2310,6 +2313,7 @@ pub async fn update_contract_for_owner( .bind(supervisor_task_id) .bind(autonomous_loop) .bind(phase_guard) + .bind(local_only) .bind(req.version.unwrap()) .fetch_optional(pool) .await? @@ -2318,7 +2322,7 @@ pub async fn update_contract_for_owner( r#" UPDATE contracts SET name = $3, description = $4, phase = $5, status = $6, - supervisor_task_id = $7, autonomous_loop = $8, phase_guard = $9, version = version + 1, updated_at = NOW() + supervisor_task_id = $7, autonomous_loop = $8, phase_guard = $9, local_only = $10, version = version + 1, updated_at = NOW() WHERE id = $1 AND owner_id = $2 RETURNING * "#, @@ -2332,6 +2336,7 @@ pub async fn update_contract_for_owner( .bind(supervisor_task_id) .bind(autonomous_loop) .bind(phase_guard) + .bind(local_only) .fetch_optional(pool) .await? }; diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs index e035368..e6ee8d4 100644 --- a/makima/src/server/handlers/contract_chat.rs +++ b/makima/src/server/handlers/contract_chat.rs @@ -1567,6 +1567,16 @@ async fn handle_contract_request( } }; + // Get local_only from contract if task has one + let local_only = if let Some(contract_id) = task.contract_id { + match repository::get_contract_for_owner(pool, contract_id, owner_id).await { + Ok(Some(contract)) => contract.local_only, + _ => false, + } + } else { + false + }; + // Send SpawnTask command to daemon let command = DaemonCommand::SpawnTask { task_id, @@ -1589,6 +1599,7 @@ async fn handle_contract_request( conversation_history: None, patch_data: None, patch_base_sha: None, + local_only, }; if let Err(e) = command_sender.send(command).await { @@ -2574,6 +2585,7 @@ async fn handle_contract_request( initial_phase: Some("research".to_string()), autonomous_loop: None, phase_guard: None, + local_only: None, }; let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await { diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs index de3164c..3498063 100644 --- a/makima/src/server/handlers/contracts.rs +++ b/makima/src/server/handlers/contracts.rs @@ -366,6 +366,7 @@ pub async fn create_contract( phase: contract.phase, status: contract.status, supervisor_task_id: contract.supervisor_task_id, + local_only: contract.local_only, file_count: 0, task_count: 0, repository_count: 0, @@ -387,6 +388,7 @@ pub async fn create_contract( phase: contract.phase, status: contract.status, supervisor_task_id: contract.supervisor_task_id, + local_only: contract.local_only, file_count: 0, task_count: 0, repository_count: 0, @@ -515,6 +517,7 @@ pub async fn update_contract( phase: contract.phase, status: contract.status, supervisor_task_id: contract.supervisor_task_id, + local_only: contract.local_only, file_count: 0, task_count: 0, repository_count: 0, @@ -1399,6 +1402,7 @@ pub async fn change_phase( phase: updated_contract.phase, status: updated_contract.status, supervisor_task_id: updated_contract.supervisor_task_id, + local_only: updated_contract.local_only, file_count: 0, task_count: 0, repository_count: 0, diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index 545d1ea..19958e7 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -599,6 +599,16 @@ pub async fn start_task( .into_response(); } + // Get local_only flag from contract if task has one + let local_only = if let Some(contract_id) = task.contract_id { + match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { + Ok(Some(contract)) => contract.local_only, + _ => false, + } + } else { + false + }; + // Get list of daemons that have previously failed this task let mut exclude_daemon_ids: Vec<Uuid> = task.failed_daemon_ids.clone().unwrap_or_default(); @@ -694,6 +704,7 @@ pub async fn start_task( conversation_history: None, patch_data: None, patch_base_sha: None, + local_only, }; tracing::info!( @@ -746,6 +757,7 @@ pub async fn start_task( conversation_history: None, patch_data: None, patch_base_sha: None, + local_only, }; if state.send_daemon_command(alt_daemon_id, alt_command).await.is_ok() { @@ -1128,6 +1140,16 @@ pub async fn send_message( }; if let Ok(Some(updated_task)) = repository::update_task_for_owner(pool, id, auth.owner_id, update_req).await { + // Get local_only from contract if task has one + let local_only = if let Some(contract_id) = updated_task.contract_id { + match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { + Ok(Some(contract)) => contract.local_only, + _ => false, + } + } else { + false + }; + // Send spawn command to new daemon let spawn_cmd = DaemonCommand::SpawnTask { task_id: id, @@ -1150,6 +1172,7 @@ pub async fn send_message( conversation_history: None, patch_data: None, patch_base_sha: None, + local_only, }; if state.send_daemon_command(new_daemon_id, spawn_cmd).await.is_ok() { @@ -2293,6 +2316,16 @@ pub async fn reassign_task( } }; + // Get local_only from contract if task has one + let local_only = if let Some(contract_id) = task.contract_id { + match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { + Ok(Some(contract)) => contract.local_only, + _ => false, + } + } else { + false + }; + // Send SpawnTask command to daemon for the new task let command = DaemonCommand::SpawnTask { task_id: new_task.id, @@ -2315,6 +2348,7 @@ pub async fn reassign_task( conversation_history: None, patch_data, patch_base_sha, + local_only, }; tracing::info!( @@ -2620,6 +2654,16 @@ pub async fn continue_task( }; let is_orchestrator = task.depth == 0 && subtask_count > 0; + // Get local_only from contract if task has one + let local_only = if let Some(contract_id) = task.contract_id { + match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { + Ok(Some(contract)) => contract.local_only, + _ => false, + } + } else { + false + }; + // Send SpawnTask command to daemon let command = DaemonCommand::SpawnTask { task_id: id, @@ -2642,6 +2686,7 @@ pub async fn continue_task( conversation_history: None, patch_data: None, patch_base_sha: None, + local_only, }; tracing::info!( @@ -3562,6 +3607,7 @@ pub async fn branch_task( conversation_history: updated_task.conversation_state.clone(), patch_data, patch_base_sha, + local_only: false, // No contract, so not local_only }; if let Err(e) = state.send_daemon_command(target_daemon_id, command).await { diff --git a/makima/src/server/handlers/mesh_chat.rs b/makima/src/server/handlers/mesh_chat.rs index 1ff0724..eb35728 100644 --- a/makima/src/server/handlers/mesh_chat.rs +++ b/makima/src/server/handlers/mesh_chat.rs @@ -1131,6 +1131,16 @@ async fn handle_mesh_request( } }; + // Get local_only from contract if task has one + let local_only = if let Some(contract_id) = task.contract_id { + match repository::get_contract_for_owner(pool, contract_id, owner_id).await { + Ok(Some(contract)) => contract.local_only, + _ => false, + } + } else { + false + }; + // Send SpawnTask command to daemon let command = DaemonCommand::SpawnTask { task_id, @@ -1153,6 +1163,7 @@ async fn handle_mesh_request( conversation_history: None, patch_data: None, patch_base_sha: None, + local_only, }; match state.send_daemon_command(target_daemon_id, command).await { diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index 0ba37d2..0aea40e 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -448,6 +448,29 @@ pub enum DaemonMessage { /// Error message if operation failed error: Option<String>, }, + /// Response to CreateExportPatch command + ExportPatchCreated { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + /// The uncompressed, human-readable patch content + #[serde(rename = "patchContent")] + patch_content: Option<String>, + /// Number of files changed + #[serde(rename = "filesCount")] + files_count: Option<usize>, + /// Lines added + #[serde(rename = "linesAdded")] + lines_added: Option<usize>, + /// Lines removed + #[serde(rename = "linesRemoved")] + lines_removed: Option<usize>, + /// The base commit SHA that the patch is diffed against + #[serde(rename = "baseCommitSha")] + base_commit_sha: Option<String>, + /// Error message if failed + error: Option<String>, + }, /// Response to MergeTaskToTarget command MergeToTargetResult { #[serde(rename = "taskId")] @@ -1778,6 +1801,75 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re ); } } + Ok(DaemonMessage::ExportPatchCreated { + task_id, + success, + patch_content, + files_count, + lines_added, + lines_removed, + base_commit_sha, + error, + }) => { + if success { + tracing::info!( + task_id = %task_id, + files_count = ?files_count, + lines_added = ?lines_added, + lines_removed = ?lines_removed, + base_commit_sha = ?base_commit_sha, + patch_len = patch_content.as_ref().map(|p| p.len()), + "Export patch created successfully" + ); + + // Broadcast as task output so UI can access the result + let output_text = format!( + "✓ Export patch created: {} files changed, +{} -{} lines (base: {})", + files_count.unwrap_or(0), + lines_added.unwrap_or(0), + lines_removed.unwrap_or(0), + base_commit_sha.as_deref().unwrap_or("unknown") + ); + state.broadcast_task_output(TaskOutputNotification { + task_id, + owner_id: Some(owner_id), + message_type: "export_patch".to_string(), + content: output_text, + tool_name: None, + tool_input: Some(serde_json::json!({ + "patchContent": patch_content, + "filesCount": files_count, + "linesAdded": lines_added, + "linesRemoved": lines_removed, + "baseCommitSha": base_commit_sha, + })), + is_error: None, + cost_usd: None, + duration_ms: None, + is_partial: false, + }); + } else { + tracing::warn!( + task_id = %task_id, + error = ?error, + "Failed to create export patch" + ); + + // Broadcast error + state.broadcast_task_output(TaskOutputNotification { + task_id, + owner_id: Some(owner_id), + message_type: "error".to_string(), + content: format!("✗ Export patch failed: {}", error.unwrap_or_else(|| "Unknown error".to_string())), + tool_name: None, + tool_input: None, + is_error: Some(true), + cost_usd: None, + duration_ms: None, + is_partial: false, + }); + } + } Err(e) => { tracing::warn!("Failed to parse daemon message: {}", e); } diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs index d1a1a99..a654a05 100644 --- a/makima/src/server/handlers/mesh_supervisor.rs +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -297,6 +297,12 @@ pub async fn try_start_pending_task( return Ok(None); } + // Get contract to check local_only flag + let contract = repository::get_contract_for_owner(pool, contract_id, owner_id) + .await + .map_err(|e| format!("Failed to get contract: {}", e))? + .ok_or_else(|| "Contract not found".to_string())?; + // Try each pending task until we find one we can start for task in &pending_tasks { // Get excluded daemon IDs for this task (daemons that have already failed it) @@ -399,6 +405,7 @@ pub async fn try_start_pending_task( conversation_history: None, patch_data, patch_base_sha, + local_only: contract.local_only, }; if let Err(e) = state.send_daemon_command(daemon.id, cmd).await { @@ -532,8 +539,8 @@ pub async fn spawn_task( let pool = state.db_pool.as_ref().unwrap(); - // Verify contract exists - let _contract = match repository::get_contract_for_owner(pool, request.contract_id, owner_id).await { + // Verify contract exists and get local_only flag + let contract = match repository::get_contract_for_owner(pool, request.contract_id, owner_id).await { Ok(Some(c)) => c, Ok(None) => { return ( @@ -711,6 +718,7 @@ pub async fn spawn_task( conversation_history: None, patch_data: None, patch_base_sha: None, + local_only: contract.local_only, }; if let Err(e) = state.send_daemon_command(daemon.id, cmd).await { @@ -2133,6 +2141,7 @@ pub async fn resume_supervisor( conversation_history: Some(supervisor_state.conversation_history.clone()), // Fallback if worktree missing patch_data, patch_base_sha, + local_only: contract.local_only, }; if let Err(e) = state.send_daemon_command(target_daemon_id, command).await { diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs index 3b71eca..8eb50c7 100644 --- a/makima/src/server/handlers/transcript_analysis.rs +++ b/makima/src/server/handlers/transcript_analysis.rs @@ -278,6 +278,7 @@ pub async fn create_contract_from_analysis( initial_phase: Some("research".to_string()), autonomous_loop: None, phase_guard: None, + local_only: None, }; let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await { diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs index 988f657..c579f0f 100644 --- a/makima/src/server/state.rs +++ b/makima/src/server/state.rs @@ -238,6 +238,9 @@ pub enum DaemonCommand { /// Commit SHA to apply the patch on top of #[serde(rename = "patchBaseSha", default, skip_serializing_if = "Option::is_none")] patch_base_sha: Option<String>, + /// Whether the contract is in local-only mode (skips automatic completion actions) + #[serde(rename = "localOnly", default)] + local_only: bool, }, /// Pause a running task PauseTask { |
