From d1f5dadb549d499c5aeee9cacf6c9aa0a233c198 Mon Sep 17 00:00:00 2001 From: soryu Date: Mon, 26 Jan 2026 22:12:57 +0000 Subject: Add local-only mode for contracts with patch export support (#34) * Add local_only flag to contracts database and models Co-Authored-By: Claude Opus 4.5 * Task completion checkpoint * Skip automatic completion actions in local_only mode Add `local_only` flag to contracts that prevents automatic completion actions (branch, merge, pr) from executing when tasks complete. This allows users to manually handle code changes via patch files or other means when operating in local-only mode. Changes: - Add `local_only` field to Contract model and request types - Add database migration for the new column - Add `local_only` parameter to SpawnTask command in both state.rs and daemon protocol.rs - Modify task manager to skip completion action execution when `local_only` is true, with appropriate logging - Pass `local_only` flag through all task spawning paths: - mesh_supervisor.rs (task spawn, retry, resume) - mesh.rs (task start, reassign, continue) - mesh_chat.rs (run task) - contract_chat.rs (run task) - Update repository create/update functions to handle `local_only` Co-Authored-By: Claude Opus 4.5 * Task completion checkpoint * Implement core patch export system Add functionality to create uncompressed, human-readable git patches for export. This enables users to generate patches that can be manually applied or shared, without the compression used for internal checkpoints. Changes: - Add ExportPatchResult struct with patch content, file count, and line stats - Add create_export_patch() function that generates diffs against a base SHA - Add get_head_sha() utility function - Add parse_diff_stat() helper to extract line counts from git output - Add CreateExportPatch command to daemon protocol - Add ExportPatchCreated response message to protocol - Add handler in task manager to process export patch requests - Add server-side handling to broadcast patch results to UI The export patch system automatically finds the merge-base when no base SHA is provided, trying upstream tracking branch first, then common default branches (origin/main, origin/master, main, master). Co-Authored-By: Claude Opus 4.5 * Task completion checkpoint * Add GitActionsPanel frontend component * Add WorktreeFilesPanel and PatchesListPanel components * Add local-only mode toggle to contract creation --------- Co-authored-by: Claude Opus 4.5 --- .../src/components/contracts/ContractDetail.tsx | 5 + .../src/components/contracts/ContractList.tsx | 15 +- .../src/components/mesh/GitActionsPanel.tsx | 181 ++++++++++++ .../src/components/mesh/PatchesListPanel.tsx | 236 ++++++++++++++++ makima/frontend/src/components/mesh/TaskDetail.tsx | 25 ++ .../src/components/mesh/WorktreeFilesPanel.tsx | 197 ++++++++++++++ makima/frontend/src/lib/api.ts | 215 +++++++++++++++ makima/frontend/src/routes/contracts.tsx | 44 +++ makima/frontend/tsconfig.tsbuildinfo | 2 +- .../migrations/20250127000000_add_local_only.sql | 8 + .../20260126000000_add_local_only_mode.sql | 7 + makima/src/daemon/storage/mod.rs | 5 +- makima/src/daemon/storage/patch.rs | 303 +++++++++++++++++++++ makima/src/daemon/task/manager.rs | 106 ++++++- makima/src/daemon/ws/protocol.rs | 38 +++ makima/src/db/models.rs | 18 ++ makima/src/db/repository.rs | 19 +- makima/src/server/handlers/contract_chat.rs | 12 + makima/src/server/handlers/contracts.rs | 4 + makima/src/server/handlers/mesh.rs | 46 ++++ makima/src/server/handlers/mesh_chat.rs | 11 + makima/src/server/handlers/mesh_daemon.rs | 92 +++++++ makima/src/server/handlers/mesh_supervisor.rs | 13 +- makima/src/server/handlers/transcript_analysis.rs | 1 + makima/src/server/state.rs | 3 + 25 files changed, 1587 insertions(+), 19 deletions(-) create mode 100644 makima/frontend/src/components/mesh/GitActionsPanel.tsx create mode 100644 makima/frontend/src/components/mesh/PatchesListPanel.tsx create mode 100644 makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx create mode 100644 makima/migrations/20250127000000_add_local_only.sql create mode 100644 makima/migrations/20260126000000_add_local_only_mode.sql 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} + {contract.localOnly && ( + + Local-Only + + )} {contract.description && (

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({ `} >

-

- {contract.name} -

+
+

+ {contract.name} +

+ {contract.localOnly && ( + + Local + + )} +
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(null); + const [exportedPatch, setExportedPatch] = useState(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 ( +
+ {/* Section Header */} +
+ Git Actions +
+ + {/* Local-only mode alert */} + {isLocalOnly && ( +
+ + Contract is in local-only mode. Push/PR actions disabled. +
+ )} + + {/* Toast notification */} + {toast && ( +
+ {toast.message} +
+ )} + + {/* Action buttons */} +
+ + + + + +
+ + {/* Export result info */} + {exportedPatch && ( +
+
Exported Patch
+
+ File: {exportedPatch.fileName} +
+ {exportedPatch.filePath && ( +
+ Path: {exportedPatch.filePath} +
+ )} + {exportedPatch.patchSize && ( +
+ Size: {formatBytes(exportedPatch.patchSize)} +
+ )} +
+ )} +
+ ); +} + +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 { + 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [copiedPatchId, setCopiedPatchId] = useState(null); + const [expandedPatchId, setExpandedPatchId] = useState(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 ( +
+
+ Exported Patches +
+
+ Loading patches... +
+
+ ); + } + + if (error) { + return ( +
+
+
+ Exported Patches +
+ +
+
+ {error} +
+
+ ); + } + + if (patches.length === 0) { + return ( +
+
+
+ Exported Patches +
+ +
+
+ No patches exported yet +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ Exported Patches +
+
+ + {patches.length} patch{patches.length !== 1 ? "es" : ""} + + +
+
+ + {/* Patch list */} +
+ {patches.map((patch) => ( +
+ {/* Patch header */} +
+
+
+ {patch.name} +
+
+ + {formatDate(patch.createdAt)} + + + {patch.filesCount} file{patch.filesCount !== 1 ? "s" : ""} + + +{patch.linesAdded} + -{patch.linesRemoved} +
+
+
+ + {/* Action buttons */} +
+ + +
+ + {/* Expanded patch content */} + {expandedPatchId === patch.id && patch.description && ( +
+
+                  {patch.description}
+                
+
+ )} + + {/* Expanded - show files if available */} + {expandedPatchId === patch.id && patch.files && patch.files.length > 0 && ( +
+
Changed files:
+
+ {patch.files.map((file, idx) => ( +
+ {file} +
+ ))} +
+
+ )} +
+ ))} +
+
+ ); +} 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; /** 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({ )}
)} + + {/* Git Actions Panel for manual git operations */} + {task.status === "done" && ( + + )} + + {/* Worktree Files Panel - show changed files in the worktree */} + {(task.status === "done" || task.status === "failed" || task.status === "merged" || task.status === "running") && ( + + )} + + {/* Patches List Panel - show exported patches for this task */} + {task.contractId && (task.status === "done" || task.status === "failed" || task.status === "merged") && ( + + )} {/* 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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+ Worktree Changes +
+
+ Loading worktree info... +
+
+ ); + } + + if (error) { + return ( +
+
+
+ Worktree Changes +
+ +
+
+ {error} +
+
+ ); + } + + if (!worktreeInfo || worktreeInfo.files.length === 0) { + return ( +
+
+
+ Worktree Changes +
+ +
+
+ No changes in worktree +
+
+ ); + } + + const { stats, files } = worktreeInfo; + const displayFiles = expanded ? files : files.slice(0, 10); + + return ( +
+ {/* Header */} +
+
+ Worktree Changes +
+
+ {/* Stats */} +
+ + {stats.filesChanged} file{stats.filesChanged !== 1 ? "s" : ""} + + +{stats.insertions} + -{stats.deletions} +
+ +
+
+ + {/* File list */} +
+ {displayFiles.map((file) => { + const statusStyle = getStatusStyle(file.status); + return ( +
+ {/* Status badge */} + + {statusStyle.label} + + + {/* File path */} + + {file.path} + + + {/* Line stats */} +
+ {file.linesAdded > 0 && ( + +{file.linesAdded} + )} + {file.linesRemoved > 0 && ( + -{file.linesRemoved} + )} +
+
+ ); + })} +
+ + {/* Show more/less button */} + {files.length > 10 && ( +
+ +
+ )} +
+ ); +} 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 { + const body: Record = {}; + 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 { + const body: Record = {}; + 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 { + const reqBody: Record = {}; + 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 { 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 { + 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 { + 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([]); 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() {

+ {/* Local-Only Mode */} +
+
+ + +
+

+ When enabled, tasks won't automatically push to remote or create PRs. + Use patch files to export changes. +

+
+ {/* Repository Configuration */}