//! 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(),
}
}