diff options
Diffstat (limited to 'makima/src/server/handlers/mesh_merge.rs')
| -rw-r--r-- | makima/src/server/handlers/mesh_merge.rs | 441 |
1 files changed, 441 insertions, 0 deletions
diff --git a/makima/src/server/handlers/mesh_merge.rs b/makima/src/server/handlers/mesh_merge.rs new file mode 100644 index 0000000..2d7c742 --- /dev/null +++ b/makima/src/server/handlers/mesh_merge.rs @@ -0,0 +1,441 @@ +//! 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<Uuid, (StatusCode, Json<ApiError>)> { + 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<SharedState>, + Path(task_id): Path<Uuid>, +) -> 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<SharedState>, + Path(task_id): Path<Uuid>, + Json(req): Json<MergeStartRequest>, +) -> 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<SharedState>, + Path(task_id): Path<Uuid>, +) -> 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<SharedState>, + Path(task_id): Path<Uuid>, + Json(req): Json<MergeResolveRequest>, +) -> 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<SharedState>, + Path(task_id): Path<Uuid>, + Json(req): Json<MergeCommitRequest>, +) -> 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<SharedState>, + Path(task_id): Path<Uuid>, +) -> 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<SharedState>, + Path(task_id): Path<Uuid>, + Json(req): Json<MergeSkipRequest>, +) -> 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<SharedState>, + Path(task_id): Path<Uuid>, +) -> 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(), + } +} |
