summaryrefslogtreecommitdiff
path: root/makima/src/daemon
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-26 22:12:57 +0000
committerGitHub <noreply@github.com>2026-01-26 22:12:57 +0000
commitd1f5dadb549d499c5aeee9cacf6c9aa0a233c198 (patch)
treea47e3d68a6b25bc39044a52b63099a199dce677d /makima/src/daemon
parentbc1ce8013bc36a1585be05b928f2386ab56529c2 (diff)
downloadsoryu-d1f5dadb549d499c5aeee9cacf6c9aa0a233c198.tar.gz
soryu-d1f5dadb549d499c5aeee9cacf6c9aa0a233c198.zip
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com>
Diffstat (limited to 'makima/src/daemon')
-rw-r--r--makima/src/daemon/storage/mod.rs5
-rw-r--r--makima/src/daemon/storage/patch.rs303
-rw-r--r--makima/src/daemon/task/manager.rs106
-rw-r--r--makima/src/daemon/ws/protocol.rs38
4 files changed, 447 insertions, 5 deletions
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 {