//! Merge operation handlers for orchestrator tasks. //! //! These endpoints allow orchestrators to merge subtask branches. //! Commands are forwarded to the daemon via WebSocket; the daemon //! responds asynchronously through the WebSocket channel. use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, Json, }; use uuid::Uuid; use crate::db::models::{ BranchListResponse, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse, }; use crate::db::repository; use crate::server::messages::ApiError; use crate::server::state::{DaemonCommand, SharedState}; /// Get the daemon ID for a task, returning error if not found. async fn get_task_daemon_id( state: &SharedState, task_id: Uuid, ) -> Result)> { let pool = state.db_pool.as_ref().ok_or_else(|| { ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("service_unavailable", "Database not configured")), ) })?; // Get task and its daemon_id let task = repository::get_task(pool, task_id) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("database_error", format!("Database error: {}", e))), ) })? .ok_or_else(|| { ( StatusCode::NOT_FOUND, Json(ApiError::new("not_found", format!("Task {} not found", task_id))), ) })?; task.daemon_id.ok_or_else(|| { ( StatusCode::BAD_REQUEST, Json(ApiError::new("bad_request", "Task has no assigned daemon")), ) }) } /// List all subtask branches for a task. /// /// GET /api/v1/mesh/tasks/{id}/branches #[utoipa::path( get, path = "/api/v1/mesh/tasks/{id}/branches", params( ("id" = Uuid, Path, description = "Task ID") ), responses( (status = 202, description = "Command sent to daemon"), (status = 404, description = "Task not found"), (status = 503, description = "Database not configured") ), tag = "Mesh" )] pub async fn list_branches( State(state): State, Path(task_id): Path, ) -> impl IntoResponse { let daemon_id = match get_task_daemon_id(&state, task_id).await { Ok(id) => id, Err(e) => return e.into_response(), }; let command = DaemonCommand::ListBranches { task_id }; match state.send_daemon_command(daemon_id, command).await { Ok(()) => ( StatusCode::ACCEPTED, Json(BranchListResponse { branches: vec![] }), ) .into_response(), Err(e) => ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("daemon_error", e)), ) .into_response(), } } /// Start merging a subtask branch. /// /// POST /api/v1/mesh/tasks/{id}/merge/start #[utoipa::path( post, path = "/api/v1/mesh/tasks/{id}/merge/start", params( ("id" = Uuid, Path, description = "Task ID") ), request_body = MergeStartRequest, responses( (status = 202, description = "Merge command sent"), (status = 404, description = "Task not found"), (status = 503, description = "Database not configured or daemon not connected") ), tag = "Mesh" )] pub async fn merge_start( State(state): State, Path(task_id): Path, Json(req): Json, ) -> impl IntoResponse { let daemon_id = match get_task_daemon_id(&state, task_id).await { Ok(id) => id, Err(e) => return e.into_response(), }; let command = DaemonCommand::MergeStart { task_id, source_branch: req.source_branch, }; match state.send_daemon_command(daemon_id, command).await { Ok(()) => ( StatusCode::ACCEPTED, Json(MergeResultResponse { success: true, message: "Merge command sent".to_string(), commit_sha: None, conflicts: None, }), ) .into_response(), Err(e) => ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("daemon_error", e)), ) .into_response(), } } /// Get current merge status. /// /// GET /api/v1/mesh/tasks/{id}/merge/status #[utoipa::path( get, path = "/api/v1/mesh/tasks/{id}/merge/status", params( ("id" = Uuid, Path, description = "Task ID") ), responses( (status = 202, description = "Status request sent"), (status = 404, description = "Task not found"), (status = 503, description = "Database not configured or daemon not connected") ), tag = "Mesh" )] pub async fn merge_status( State(state): State, Path(task_id): Path, ) -> impl IntoResponse { let daemon_id = match get_task_daemon_id(&state, task_id).await { Ok(id) => id, Err(e) => return e.into_response(), }; let command = DaemonCommand::MergeStatus { task_id }; match state.send_daemon_command(daemon_id, command).await { Ok(()) => ( StatusCode::ACCEPTED, Json(MergeStatusResponse { in_progress: false, source_branch: None, conflicted_files: vec![], }), ) .into_response(), Err(e) => ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("daemon_error", e)), ) .into_response(), } } /// Resolve a merge conflict. /// /// POST /api/v1/mesh/tasks/{id}/merge/resolve #[utoipa::path( post, path = "/api/v1/mesh/tasks/{id}/merge/resolve", params( ("id" = Uuid, Path, description = "Task ID") ), request_body = MergeResolveRequest, responses( (status = 202, description = "Resolve command sent"), (status = 404, description = "Task not found"), (status = 503, description = "Database not configured or daemon not connected") ), tag = "Mesh" )] pub async fn merge_resolve( State(state): State, Path(task_id): Path, Json(req): Json, ) -> impl IntoResponse { let daemon_id = match get_task_daemon_id(&state, task_id).await { Ok(id) => id, Err(e) => return e.into_response(), }; let command = DaemonCommand::MergeResolve { task_id, file: req.file, strategy: req.strategy, }; match state.send_daemon_command(daemon_id, command).await { Ok(()) => ( StatusCode::ACCEPTED, Json(MergeResultResponse { success: true, message: "Resolve command sent".to_string(), commit_sha: None, conflicts: None, }), ) .into_response(), Err(e) => ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("daemon_error", e)), ) .into_response(), } } /// Commit the current merge. /// /// POST /api/v1/mesh/tasks/{id}/merge/commit #[utoipa::path( post, path = "/api/v1/mesh/tasks/{id}/merge/commit", params( ("id" = Uuid, Path, description = "Task ID") ), request_body = MergeCommitRequest, responses( (status = 202, description = "Commit command sent"), (status = 404, description = "Task not found"), (status = 503, description = "Database not configured or daemon not connected") ), tag = "Mesh" )] pub async fn merge_commit( State(state): State, Path(task_id): Path, Json(req): Json, ) -> impl IntoResponse { let daemon_id = match get_task_daemon_id(&state, task_id).await { Ok(id) => id, Err(e) => return e.into_response(), }; let command = DaemonCommand::MergeCommit { task_id, message: req.message, }; match state.send_daemon_command(daemon_id, command).await { Ok(()) => ( StatusCode::ACCEPTED, Json(MergeResultResponse { success: true, message: "Commit command sent".to_string(), commit_sha: None, conflicts: None, }), ) .into_response(), Err(e) => ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("daemon_error", e)), ) .into_response(), } } /// Abort the current merge. /// /// POST /api/v1/mesh/tasks/{id}/merge/abort #[utoipa::path( post, path = "/api/v1/mesh/tasks/{id}/merge/abort", params( ("id" = Uuid, Path, description = "Task ID") ), responses( (status = 202, description = "Abort command sent"), (status = 404, description = "Task not found"), (status = 503, description = "Database not configured or daemon not connected") ), tag = "Mesh" )] pub async fn merge_abort( State(state): State, Path(task_id): Path, ) -> impl IntoResponse { let daemon_id = match get_task_daemon_id(&state, task_id).await { Ok(id) => id, Err(e) => return e.into_response(), }; let command = DaemonCommand::MergeAbort { task_id }; match state.send_daemon_command(daemon_id, command).await { Ok(()) => ( StatusCode::ACCEPTED, Json(MergeResultResponse { success: true, message: "Abort command sent".to_string(), commit_sha: None, conflicts: None, }), ) .into_response(), Err(e) => ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("daemon_error", e)), ) .into_response(), } } /// Skip merging a subtask branch. /// /// POST /api/v1/mesh/tasks/{id}/merge/skip #[utoipa::path( post, path = "/api/v1/mesh/tasks/{id}/merge/skip", params( ("id" = Uuid, Path, description = "Task ID") ), request_body = MergeSkipRequest, responses( (status = 202, description = "Skip command sent"), (status = 404, description = "Task not found"), (status = 503, description = "Database not configured or daemon not connected") ), tag = "Mesh" )] pub async fn merge_skip( State(state): State, Path(task_id): Path, Json(req): Json, ) -> impl IntoResponse { let daemon_id = match get_task_daemon_id(&state, task_id).await { Ok(id) => id, Err(e) => return e.into_response(), }; let command = DaemonCommand::MergeSkip { task_id, subtask_id: req.subtask_id, reason: req.reason, }; match state.send_daemon_command(daemon_id, command).await { Ok(()) => ( StatusCode::ACCEPTED, Json(MergeResultResponse { success: true, message: "Skip command sent".to_string(), commit_sha: None, conflicts: None, }), ) .into_response(), Err(e) => ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("daemon_error", e)), ) .into_response(), } } /// Check if all branches are merged or skipped. /// /// GET /api/v1/mesh/tasks/{id}/merge/check #[utoipa::path( get, path = "/api/v1/mesh/tasks/{id}/merge/check", params( ("id" = Uuid, Path, description = "Task ID") ), responses( (status = 202, description = "Check command sent"), (status = 404, description = "Task not found"), (status = 503, description = "Database not configured or daemon not connected") ), tag = "Mesh" )] pub async fn merge_check( State(state): State, Path(task_id): Path, ) -> impl IntoResponse { let daemon_id = match get_task_daemon_id(&state, task_id).await { Ok(id) => id, Err(e) => return e.into_response(), }; let command = DaemonCommand::CheckMergeComplete { task_id }; match state.send_daemon_command(daemon_id, command).await { Ok(()) => ( StatusCode::ACCEPTED, Json(MergeCompleteCheckResponse { can_complete: true, unmerged_branches: vec![], merged_count: 0, skipped_count: 0, }), ) .into_response(), Err(e) => ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("daemon_error", e)), ) .into_response(), } }