diff options
| author | soryu <soryu@soryu.co> | 2026-01-26 22:12:57 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-26 22:12:57 +0000 |
| commit | d1f5dadb549d499c5aeee9cacf6c9aa0a233c198 (patch) | |
| tree | a47e3d68a6b25bc39044a52b63099a199dce677d /makima/src/server | |
| parent | bc1ce8013bc36a1585be05b928f2386ab56529c2 (diff) | |
| download | soryu-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/server')
| -rw-r--r-- | makima/src/server/handlers/contract_chat.rs | 12 | ||||
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 4 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 46 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_chat.rs | 11 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_daemon.rs | 92 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_supervisor.rs | 13 | ||||
| -rw-r--r-- | makima/src/server/handlers/transcript_analysis.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/state.rs | 3 |
8 files changed, 180 insertions, 2 deletions
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 { |
