summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-26 19:00:20 +0000
committersoryu <soryu@soryu.co>2026-01-26 19:00:20 +0000
commit39c743467391e00c7c970753e6165b025784af76 (patch)
treececb31cbc57c9ee046b1f8b9aed5934c3c2570bb
parentcb4f2fc40dbabb40de948512eee74c7e46264665 (diff)
downloadsoryu-39c743467391e00c7c970753e6165b025784af76.tar.gz
soryu-39c743467391e00c7c970753e6165b025784af76.zip
[WIP] Heartbeat checkpoint - 2026-01-26 19:00:20 UTC
-rw-r--r--makima/src/daemon/ws/protocol.rs87
-rw-r--r--makima/src/server/handlers/mesh.rs611
-rw-r--r--makima/src/server/handlers/mesh_daemon.rs62
-rw-r--r--makima/src/server/mod.rs5
-rw-r--r--makima/src/server/state.rs28
5 files changed, 793 insertions, 0 deletions
diff --git a/makima/src/daemon/ws/protocol.rs b/makima/src/daemon/ws/protocol.rs
index 2e7caef..7e26bac 100644
--- a/makima/src/daemon/ws/protocol.rs
+++ b/makima/src/daemon/ws/protocol.rs
@@ -328,6 +328,74 @@ pub enum DaemonMessage {
/// Error message if failed
error: Option<String>,
},
+
+ // =========================================================================
+ // Export Patch Response Messages
+ // =========================================================================
+
+ /// Response to CreateExportPatch command.
+ ExportPatchCreated {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "contractId")]
+ contract_id: Uuid,
+ success: bool,
+ /// The patch content (uncompressed git diff)
+ #[serde(rename = "patchContent")]
+ patch_content: Option<String>,
+ /// Number of files in the patch
+ #[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>,
+ /// Base commit SHA for applying the patch
+ #[serde(rename = "baseCommitSha")]
+ base_commit_sha: Option<String>,
+ /// Error message if failed
+ error: Option<String>,
+ },
+
+ /// Response to GetWorktreeInfo command.
+ WorktreeInfo {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ /// Worktree path
+ path: Option<String>,
+ /// Current branch name
+ branch: Option<String>,
+ /// Base commit SHA
+ #[serde(rename = "baseCommit")]
+ base_commit: Option<String>,
+ /// List of changed files
+ #[serde(rename = "filesChanged")]
+ files_changed: Option<Vec<ChangedFileInfo>>,
+ /// Whether there are uncommitted changes
+ #[serde(rename = "hasUncommittedChanges")]
+ has_uncommitted_changes: Option<bool>,
+ /// Error message if failed
+ error: Option<String>,
+ },
+}
+
+/// Information about a changed file in a worktree.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ChangedFileInfo {
+ /// File path relative to worktree root
+ pub path: String,
+ /// Status: "added", "modified", "deleted"
+ pub status: String,
+ /// Lines added
+ #[serde(rename = "linesAdded")]
+ pub lines_added: i32,
+ /// Lines removed
+ #[serde(rename = "linesRemoved")]
+ pub lines_removed: i32,
}
/// Information about a branch (used in BranchList message).
@@ -654,6 +722,25 @@ pub enum DaemonCommand {
source_dir: Option<String>,
},
+ // =========================================================================
+ // Export Patch Commands
+ // =========================================================================
+
+ /// Create an export patch for a task's changes.
+ CreateExportPatch {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Contract ID for creating the patch file.
+ #[serde(rename = "contractId")]
+ contract_id: Uuid,
+ },
+
+ /// Get worktree information for a task.
+ GetWorktreeInfo {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+
/// Error response.
Error {
code: String,
diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs
index 3d64eb4..aa63e1c 100644
--- a/makima/src/server/handlers/mesh.rs
+++ b/makima/src/server/handlers/mesh.rs
@@ -3716,3 +3716,614 @@ pub async fn restart_daemon(
})
.into_response()
}
+
+// =============================================================================
+// Manual Git Action Endpoints
+// =============================================================================
+
+/// Request for exporting a patch.
+#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ExportPatchRequest {
+ /// Optional custom name for the patch file.
+ pub file_name: Option<String>,
+}
+
+/// Response for export patch request.
+#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ExportPatchResponse {
+ /// Whether the export command was sent successfully.
+ pub success: bool,
+ /// The task ID.
+ pub task_id: Uuid,
+ /// Message describing the result.
+ pub message: String,
+}
+
+/// Request for pushing a branch.
+#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PushBranchRequest {
+ /// Target repository path (defaults to task's target_repo_path).
+ pub target_repo_path: Option<String>,
+ /// Branch name to push to (defaults to task branch name).
+ pub branch_name: Option<String>,
+}
+
+/// Response for push branch request.
+#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PushBranchResponse {
+ /// Whether the push command was sent successfully.
+ pub success: bool,
+ /// The task ID.
+ pub task_id: Uuid,
+ /// Message describing the result.
+ pub message: String,
+}
+
+/// Request for creating a pull request.
+#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreatePRRequest {
+ /// Optional custom PR title (defaults to task name).
+ pub title: Option<String>,
+ /// Optional custom PR body.
+ pub body: Option<String>,
+ /// Base branch for the PR (defaults to main).
+ pub base_branch: Option<String>,
+}
+
+/// Response for create PR request.
+#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreatePRResponse {
+ /// Whether the PR command was sent successfully.
+ pub success: bool,
+ /// The task ID.
+ pub task_id: Uuid,
+ /// Message describing the result.
+ pub message: String,
+}
+
+/// Information about a changed file.
+#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChangedFile {
+ /// File path relative to worktree root.
+ pub path: String,
+ /// Status: "added", "modified", "deleted".
+ pub status: String,
+ /// Lines added.
+ pub lines_added: i32,
+ /// Lines removed.
+ pub lines_removed: i32,
+}
+
+/// Response for worktree info request.
+#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct WorktreeInfoResponse {
+ /// Whether the request was successful.
+ pub success: bool,
+ /// The task ID.
+ pub task_id: Uuid,
+ /// Worktree path (if known locally, otherwise message about async response).
+ pub path: Option<String>,
+ /// Current branch name.
+ pub branch: Option<String>,
+ /// Base commit SHA.
+ pub base_commit: Option<String>,
+ /// List of changed files.
+ pub files_changed: Option<Vec<ChangedFile>>,
+ /// Whether there are uncommitted changes.
+ pub has_uncommitted_changes: Option<bool>,
+ /// Message describing the result.
+ pub message: String,
+}
+
+/// Export a patch for a task's changes.
+///
+/// POST /api/v1/mesh/tasks/{id}/export-patch
+///
+/// Generates a git diff patch for the task's worktree changes and creates
+/// a contract file containing the patch with apply instructions.
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/export-patch",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ request_body = ExportPatchRequest,
+ responses(
+ (status = 202, description = "Export patch command sent", body = ExportPatchResponse),
+ (status = 400, description = "Invalid request (task has no contract)", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 503, description = "Database not configured or no daemon connected", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn export_patch(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(_req): Json<ExportPatchRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get the task (scoped by owner)
+ let task = match repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get task {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Task must have a contract to create a patch file
+ let contract_id = match task.contract_id {
+ Some(cid) => cid,
+ None => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "NO_CONTRACT",
+ "Task must be associated with a contract to export a patch file",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Find daemon to execute the command
+ let daemon_id = match task.daemon_id {
+ Some(did) if state.is_daemon_connected(did) => did,
+ _ => {
+ // Try to find any connected daemon for this owner
+ match state.get_any_daemon_for_owner(auth.owner_id) {
+ Some(daemon_id) => daemon_id,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "NO_DAEMON",
+ "No daemons connected for your account",
+ )),
+ )
+ .into_response();
+ }
+ }
+ }
+ };
+
+ // Send CreateExportPatch command to daemon
+ let command = DaemonCommand::CreateExportPatch {
+ task_id: id,
+ contract_id,
+ };
+
+ if let Err(e) = state.send_daemon_command(daemon_id, command).await {
+ tracing::error!("Failed to send CreateExportPatch command: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DAEMON_ERROR", e)),
+ )
+ .into_response();
+ }
+
+ tracing::info!(
+ task_id = %id,
+ contract_id = %contract_id,
+ "Export patch command sent"
+ );
+
+ (
+ StatusCode::ACCEPTED,
+ Json(ExportPatchResponse {
+ success: true,
+ task_id: id,
+ message: "Export patch command sent. The patch file will be created when ready.".to_string(),
+ }),
+ )
+ .into_response()
+}
+
+/// Push a task's branch to the target repository.
+///
+/// POST /api/v1/mesh/tasks/{id}/push-branch
+///
+/// Pushes the task's worktree changes to the target repository as a branch.
+/// This reuses the existing RetryCompletionAction mechanism with action="branch".
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/push-branch",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ request_body = PushBranchRequest,
+ responses(
+ (status = 200, description = "Push branch command sent", body = PushBranchResponse),
+ (status = 400, description = "Invalid request", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 503, description = "Database not configured or no daemon connected", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn push_branch(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<PushBranchRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get the task (scoped by owner)
+ let task = match repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get task {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Determine target repo path
+ let target_repo_path = match req.target_repo_path.or_else(|| task.target_repo_path.clone()) {
+ Some(path) if !path.is_empty() => path,
+ _ => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "NO_TARGET_REPO",
+ "Target repository path must be specified or set on the task",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Find daemon to execute the command
+ let daemon_id = match task.daemon_id {
+ Some(did) if state.is_daemon_connected(did) => did,
+ _ => {
+ match state.get_any_daemon_for_owner(auth.owner_id) {
+ Some(daemon_id) => daemon_id,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "NO_DAEMON",
+ "No daemons connected for your account",
+ )),
+ )
+ .into_response();
+ }
+ }
+ }
+ };
+
+ // Send RetryCompletionAction command with action="branch"
+ let command = DaemonCommand::RetryCompletionAction {
+ task_id: id,
+ task_name: task.name.clone(),
+ action: "branch".to_string(),
+ target_repo_path: target_repo_path.clone(),
+ target_branch: req.branch_name.or_else(|| task.target_branch.clone()),
+ };
+
+ if let Err(e) = state.send_daemon_command(daemon_id, command).await {
+ tracing::error!("Failed to send push branch command: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DAEMON_ERROR", e)),
+ )
+ .into_response();
+ }
+
+ tracing::info!(
+ task_id = %id,
+ target_repo = %target_repo_path,
+ "Push branch command sent"
+ );
+
+ (
+ StatusCode::OK,
+ Json(PushBranchResponse {
+ success: true,
+ task_id: id,
+ message: "Push branch command sent. Check task output for results.".to_string(),
+ }),
+ )
+ .into_response()
+}
+
+/// Create a pull request for a task's changes.
+///
+/// POST /api/v1/mesh/tasks/{id}/create-pr
+///
+/// Creates a pull request for the task's worktree changes.
+/// This reuses the existing RetryCompletionAction mechanism with action="pr".
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/create-pr",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ request_body = CreatePRRequest,
+ responses(
+ (status = 200, description = "Create PR command sent", body = CreatePRResponse),
+ (status = 400, description = "Invalid request", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 503, description = "Database not configured or no daemon connected", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn create_pr(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<CreatePRRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get the task (scoped by owner)
+ let task = match repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get task {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Determine target repo path
+ let target_repo_path = match task.target_repo_path.clone() {
+ Some(path) if !path.is_empty() => path,
+ _ => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "NO_TARGET_REPO",
+ "Target repository path must be set on the task",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Find daemon to execute the command
+ let daemon_id = match task.daemon_id {
+ Some(did) if state.is_daemon_connected(did) => did,
+ _ => {
+ match state.get_any_daemon_for_owner(auth.owner_id) {
+ Some(daemon_id) => daemon_id,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "NO_DAEMON",
+ "No daemons connected for your account",
+ )),
+ )
+ .into_response();
+ }
+ }
+ }
+ };
+
+ // Determine target branch for PR
+ let target_branch = req.base_branch.or_else(|| task.target_branch.clone());
+
+ // Send RetryCompletionAction command with action="pr"
+ let command = DaemonCommand::RetryCompletionAction {
+ task_id: id,
+ task_name: req.title.unwrap_or_else(|| task.name.clone()),
+ action: "pr".to_string(),
+ target_repo_path: target_repo_path.clone(),
+ target_branch,
+ };
+
+ if let Err(e) = state.send_daemon_command(daemon_id, command).await {
+ tracing::error!("Failed to send create PR command: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DAEMON_ERROR", e)),
+ )
+ .into_response();
+ }
+
+ tracing::info!(
+ task_id = %id,
+ target_repo = %target_repo_path,
+ "Create PR command sent"
+ );
+
+ (
+ StatusCode::OK,
+ Json(CreatePRResponse {
+ success: true,
+ task_id: id,
+ message: "Create PR command sent. Check task output for results.".to_string(),
+ }),
+ )
+ .into_response()
+}
+
+/// Get worktree information for a task.
+///
+/// GET /api/v1/mesh/tasks/{id}/worktree-info
+///
+/// Returns information about the task's worktree including changed files,
+/// current branch, and whether there are uncommitted changes.
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/tasks/{id}/worktree-info",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 202, description = "Worktree info command sent", body = WorktreeInfoResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 503, description = "Database not configured or no daemon connected", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn get_worktree_info(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get the task (scoped by owner)
+ let task = match repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get task {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Find daemon to execute the command
+ let daemon_id = match task.daemon_id {
+ Some(did) if state.is_daemon_connected(did) => did,
+ _ => {
+ match state.get_any_daemon_for_owner(auth.owner_id) {
+ Some(daemon_id) => daemon_id,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "NO_DAEMON",
+ "No daemons connected for your account",
+ )),
+ )
+ .into_response();
+ }
+ }
+ }
+ };
+
+ // Send GetWorktreeInfo command to daemon
+ let command = DaemonCommand::GetWorktreeInfo { task_id: id };
+
+ if let Err(e) = state.send_daemon_command(daemon_id, command).await {
+ tracing::error!("Failed to send GetWorktreeInfo command: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DAEMON_ERROR", e)),
+ )
+ .into_response();
+ }
+
+ tracing::info!(
+ task_id = %id,
+ "Get worktree info command sent"
+ );
+
+ (
+ StatusCode::ACCEPTED,
+ Json(WorktreeInfoResponse {
+ success: true,
+ task_id: id,
+ path: None,
+ branch: None,
+ base_commit: None,
+ files_changed: None,
+ has_uncommitted_changes: None,
+ message: "Worktree info command sent. Response will be streamed via task events.".to_string(),
+ }),
+ )
+ .into_response()
+}
diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs
index f5a3c10..84c7a93 100644
--- a/makima/src/server/handlers/mesh_daemon.rs
+++ b/makima/src/server/handlers/mesh_daemon.rs
@@ -470,6 +470,68 @@ pub enum DaemonMessage {
#[serde(rename = "prNumber")]
pr_number: Option<i32>,
},
+ /// Response to CreateExportPatch command
+ ExportPatchCreated {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "contractId")]
+ contract_id: Uuid,
+ success: bool,
+ /// The patch content (uncompressed git diff)
+ #[serde(rename = "patchContent")]
+ patch_content: Option<String>,
+ /// Number of files in the patch
+ #[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>,
+ /// Base commit SHA for applying the patch
+ #[serde(rename = "baseCommitSha")]
+ base_commit_sha: Option<String>,
+ /// Error message if failed
+ error: Option<String>,
+ },
+ /// Response to GetWorktreeInfo command
+ WorktreeInfo {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ /// Worktree path
+ path: Option<String>,
+ /// Current branch name
+ branch: Option<String>,
+ /// Base commit SHA
+ #[serde(rename = "baseCommit")]
+ base_commit: Option<String>,
+ /// List of changed files
+ #[serde(rename = "filesChanged")]
+ files_changed: Option<Vec<ChangedFileInfo>>,
+ /// Whether there are uncommitted changes
+ #[serde(rename = "hasUncommittedChanges")]
+ has_uncommitted_changes: Option<bool>,
+ /// Error message if failed
+ error: Option<String>,
+ },
+}
+
+/// Information about a changed file in a worktree.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ChangedFileInfo {
+ /// File path relative to worktree root
+ pub path: String,
+ /// Status: "added", "modified", "deleted"
+ pub status: String,
+ /// Lines added
+ #[serde(rename = "linesAdded")]
+ pub lines_added: i32,
+ /// Lines removed
+ #[serde(rename = "linesRemoved")]
+ pub lines_removed: i32,
}
/// Validated daemon authentication result.
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index 75f64c6..f6745c0 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -84,6 +84,11 @@ pub fn make_router(state: SharedState) -> Router {
.route("/mesh/tasks/{id}/check-target", post(mesh::check_target_exists))
.route("/mesh/tasks/{id}/reassign", post(mesh::reassign_task))
.route("/mesh/tasks/{id}/continue", post(mesh::continue_task))
+ // Manual git action endpoints
+ .route("/mesh/tasks/{id}/export-patch", post(mesh::export_patch))
+ .route("/mesh/tasks/{id}/push-branch", post(mesh::push_branch))
+ .route("/mesh/tasks/{id}/create-pr", post(mesh::create_pr))
+ .route("/mesh/tasks/{id}/worktree-info", get(mesh::get_worktree_info))
.route("/mesh/chat", post(mesh_chat::mesh_toplevel_chat_handler))
.route(
"/mesh/chat/history",
diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs
index b954efe..81adc83 100644
--- a/makima/src/server/state.rs
+++ b/makima/src/server/state.rs
@@ -442,6 +442,25 @@ pub enum DaemonCommand {
source_dir: Option<String>,
},
+ // =========================================================================
+ // Export Patch Commands
+ // =========================================================================
+
+ /// Create an export patch for a task's changes
+ CreateExportPatch {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Contract ID for creating the patch file
+ #[serde(rename = "contractId")]
+ contract_id: Uuid,
+ },
+
+ /// Get worktree information for a task
+ GetWorktreeInfo {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+
/// Error response
Error { code: String, message: String },
@@ -1003,6 +1022,15 @@ impl AppState {
.any(|entry| entry.value().id == daemon_id)
}
+ /// Get any connected daemon for a specific owner.
+ /// Returns the daemon ID if found.
+ pub fn get_any_daemon_for_owner(&self, owner_id: Uuid) -> Option<Uuid> {
+ self.daemon_connections
+ .iter()
+ .find(|entry| entry.value().owner_id == owner_id)
+ .map(|entry| entry.value().id)
+ }
+
// =========================================================================
// Tool Key Management
// =========================================================================