use super::state::{ChangeStatus, ControlCommand, WebState};
use crate::worktree_ops;
use axum::{
extract::{Path, State},
http::{header, StatusCode},
response::IntoResponse,
Json,
};
use serde::Serialize;
use std::sync::Arc;
#[cfg(feature = "web-monitoring")]
use utoipa::ToSchema;
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "web-monitoring", derive(ToSchema))]
pub struct HealthResponse {
pub status: &'static str,
pub version: String,
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
get,
path = "/api/health",
tag = "health",
responses(
(status = 200, description = "Health check successful", body = HealthResponse)
)
)
)]
pub async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok",
version: format!("v{} ({})", env!("CARGO_PKG_VERSION"), env!("BUILD_NUMBER")),
})
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
get,
path = "/api/state",
tag = "state",
responses(
(status = 200, description = "Current orchestrator state", body = crate::web::state::OrchestratorStateSnapshot)
)
)
)]
pub async fn get_state(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let _ = state.refresh_from_disk().await;
let snapshot = state.get_state().await;
([(header::CACHE_CONTROL, "no-store")], Json(snapshot))
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
get,
path = "/api/changes",
tag = "changes",
responses(
(status = 200, description = "List of all changes", body = Vec<ChangeStatus>)
)
)
)]
pub async fn list_changes(State(state): State<Arc<WebState>>) -> Json<Vec<ChangeStatus>> {
let _ = state.refresh_from_disk().await;
Json(state.list_changes().await)
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "web-monitoring", derive(ToSchema))]
pub struct ErrorResponse {
pub error: String,
}
pub fn not_found_response(change_id: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Change '{}' not found", change_id),
}),
)
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
get,
path = "/api/changes/{id}",
tag = "changes",
params(
("id" = String, Path, description = "Change ID")
),
responses(
(status = 200, description = "Change found", body = ChangeStatus),
(status = 404, description = "Change not found", body = ErrorResponse)
)
)
)]
pub async fn get_change(
State(state): State<Arc<WebState>>,
Path(id): Path<String>,
) -> Result<Json<ChangeStatus>, (StatusCode, Json<ErrorResponse>)> {
let _ = state.refresh_from_disk().await;
match state.get_change(&id).await {
Some(change) => Ok(Json(change)),
None => Err(not_found_response(&id)),
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "web-monitoring", derive(ToSchema))]
pub struct ControlResponse {
pub success: bool,
pub message: String,
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
post,
path = "/api/control/start",
tag = "control",
responses(
(status = 200, description = "Processing started", body = ControlResponse),
(status = 409, description = "Already running or stopping", body = ErrorResponse),
(status = 500, description = "Control channel error", body = ErrorResponse)
)
)
)]
pub async fn control_start(
State(state): State<Arc<WebState>>,
) -> Result<Json<ControlResponse>, (StatusCode, Json<ErrorResponse>)> {
let current_mode = {
let s = state.get_state().await;
s.app_mode.clone()
};
if current_mode == "running" || current_mode == "stopping" {
return Err((
StatusCode::CONFLICT,
Json(ErrorResponse {
error: format!("Cannot start: already {}", current_mode),
}),
));
}
match state.send_control_command(ControlCommand::Start) {
Ok(()) => Ok(Json(ControlResponse {
success: true,
message: "Processing started".to_string(),
})),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to start processing: {}", e),
}),
)),
}
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
post,
path = "/api/control/stop",
tag = "control",
responses(
(status = 200, description = "Stop initiated", body = ControlResponse),
(status = 409, description = "Not running", body = ErrorResponse),
(status = 500, description = "Control channel error", body = ErrorResponse)
)
)
)]
pub async fn control_stop(
State(state): State<Arc<WebState>>,
) -> Result<Json<ControlResponse>, (StatusCode, Json<ErrorResponse>)> {
let current_mode = {
let s = state.get_state().await;
s.app_mode.clone()
};
if current_mode != "running" {
return Err((
StatusCode::CONFLICT,
Json(ErrorResponse {
error: format!("Cannot stop: not running (current mode: {})", current_mode),
}),
));
}
match state.send_control_command(ControlCommand::Stop) {
Ok(()) => Ok(Json(ControlResponse {
success: true,
message: "Stopping after current change completes...".to_string(),
})),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to stop processing: {}", e),
}),
)),
}
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
post,
path = "/api/control/cancel-stop",
tag = "control",
responses(
(status = 200, description = "Stop canceled", body = ControlResponse),
(status = 409, description = "Not in stopping mode", body = ErrorResponse),
(status = 500, description = "Control channel error", body = ErrorResponse)
)
)
)]
pub async fn control_cancel_stop(
State(state): State<Arc<WebState>>,
) -> Result<Json<ControlResponse>, (StatusCode, Json<ErrorResponse>)> {
let current_mode = {
let s = state.get_state().await;
s.app_mode.clone()
};
if current_mode != "stopping" {
return Err((
StatusCode::CONFLICT,
Json(ErrorResponse {
error: format!(
"Cannot cancel stop: not stopping (current mode: {})",
current_mode
),
}),
));
}
match state.send_control_command(ControlCommand::CancelStop) {
Ok(()) => Ok(Json(ControlResponse {
success: true,
message: "Stop canceled, continuing...".to_string(),
})),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to cancel stop: {}", e),
}),
)),
}
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
post,
path = "/api/control/force-stop",
tag = "control",
responses(
(status = 200, description = "Force stop initiated", body = ControlResponse),
(status = 409, description = "Not running or stopping", body = ErrorResponse),
(status = 500, description = "Control channel error", body = ErrorResponse)
)
)
)]
pub async fn control_force_stop(
State(state): State<Arc<WebState>>,
) -> Result<Json<ControlResponse>, (StatusCode, Json<ErrorResponse>)> {
let current_mode = {
let s = state.get_state().await;
s.app_mode.clone()
};
if current_mode != "running" && current_mode != "stopping" {
return Err((
StatusCode::CONFLICT,
Json(ErrorResponse {
error: format!(
"Cannot force stop: not running or stopping (current mode: {})",
current_mode
),
}),
));
}
match state.send_control_command(ControlCommand::ForceStop) {
Ok(()) => Ok(Json(ControlResponse {
success: true,
message: "Force stopping...".to_string(),
})),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to force stop: {}", e),
}),
)),
}
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
post,
path = "/api/control/retry",
tag = "control",
responses(
(status = 200, description = "Retry initiated", body = ControlResponse),
(status = 409, description = "Not in error mode", body = ErrorResponse),
(status = 500, description = "Control channel error", body = ErrorResponse)
)
)
)]
pub async fn control_retry(
State(state): State<Arc<WebState>>,
) -> Result<Json<ControlResponse>, (StatusCode, Json<ErrorResponse>)> {
let current_mode = {
let s = state.get_state().await;
s.app_mode.clone()
};
if current_mode != "error" {
return Err((
StatusCode::CONFLICT,
Json(ErrorResponse {
error: format!(
"Cannot retry: not in error mode (current mode: {})",
current_mode
),
}),
));
}
match state.send_control_command(ControlCommand::Retry) {
Ok(()) => Ok(Json(ControlResponse {
success: true,
message: "Retrying error changes...".to_string(),
})),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to retry: {}", e),
}),
)),
}
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
get,
path = "/api/worktrees",
tag = "worktrees",
responses(
(status = 200, description = "List of worktrees", body = Vec<crate::tui::types::WorktreeInfo>)
)
)
)]
pub async fn list_worktrees(
) -> Result<Json<Vec<crate::tui::types::WorktreeInfo>>, (StatusCode, Json<ErrorResponse>)> {
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tracing::error;
let start = Instant::now();
let request_id = format!(
"list_wt_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_micros()
);
let repo_root = std::env::current_dir().map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "list_worktrees",
worktree_name = "<all>",
error = %e,
duration_ms = duration_ms,
"Failed to get current directory"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to get current directory: {}", e),
}),
)
})?;
let worktrees = worktree_ops::get_worktrees(&repo_root).await.map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "list_worktrees",
worktree_name = "<all>",
error = %e,
duration_ms = duration_ms,
"Failed to list worktrees"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to list worktrees: {}", e),
}),
)
})?;
let duration_ms = start.elapsed().as_millis();
tracing::debug!(
request_id = %request_id,
operation = "list_worktrees",
worktree_name = "<all>",
worktree_count = worktrees.len(),
duration_ms = duration_ms,
"Listed worktrees successfully"
);
Ok(Json(worktrees))
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
post,
path = "/api/worktrees/refresh",
tag = "worktrees",
responses(
(status = 200, description = "Worktrees refreshed", body = Vec<crate::tui::types::WorktreeInfo>)
)
)
)]
pub async fn refresh_worktrees(
) -> Result<Json<Vec<crate::tui::types::WorktreeInfo>>, (StatusCode, Json<ErrorResponse>)> {
list_worktrees().await
}
#[derive(Debug, serde::Deserialize)]
#[cfg_attr(feature = "web-monitoring", derive(utoipa::ToSchema))]
pub struct CreateWorktreeRequest {
pub change_id: String,
#[serde(default)]
pub base_commit: Option<String>,
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
post,
path = "/api/worktrees/create",
tag = "worktrees",
request_body = CreateWorktreeRequest,
responses(
(status = 200, description = "Worktree created", body = crate::tui::types::WorktreeInfo),
(status = 409, description = "Worktree already exists", body = ErrorResponse),
(status = 500, description = "Failed to create worktree", body = ErrorResponse)
)
)
)]
pub async fn create_worktree(
Json(req): Json<CreateWorktreeRequest>,
) -> Result<Json<crate::tui::types::WorktreeInfo>, (StatusCode, Json<ErrorResponse>)> {
use crate::vcs::git::commands;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tracing::error;
let start = Instant::now();
let request_id = format!(
"create_wt_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_micros()
);
let worktree_name = req.change_id.clone();
let repo_root = std::env::current_dir().map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "create_worktree",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to get current directory"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to get current directory: {}", e),
}),
)
})?;
let exists = worktree_ops::worktree_exists(&repo_root, &req.change_id)
.await
.map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "create_worktree",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to check worktree existence"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to check worktree existence: {}", e),
}),
)
})?;
if exists {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "create_worktree",
worktree_name = %worktree_name,
error = "Worktree already exists",
duration_ms = duration_ms,
"Worktree creation failed - already exists"
);
return Err((
StatusCode::CONFLICT,
Json(ErrorResponse {
error: format!("Worktree for '{}' already exists", req.change_id),
}),
));
}
let config = crate::config::OrchestratorConfig::load(None).map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "create_worktree",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to load config"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to load config: {}", e),
}),
)
})?;
let workspace_base_dir = config
.get_workspace_base_dir()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| crate::config::defaults::default_workspace_base_dir(Some(&repo_root)));
let branch_name = req.change_id.replace(['/', '\\', ' '], "-");
let worktree_path = workspace_base_dir.join(&branch_name);
std::fs::create_dir_all(&workspace_base_dir).map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "create_worktree",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to create workspace directory"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to create workspace directory: {}", e),
}),
)
})?;
let base_commit = match req.base_commit {
Some(commit) => commit,
None => commands::get_current_commit(&repo_root)
.await
.map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "create_worktree",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to get current commit"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to get current commit: {}", e),
}),
)
})?,
};
commands::worktree_add(
&repo_root,
worktree_path.to_str().unwrap(),
&branch_name,
&base_commit,
)
.await
.map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "create_worktree",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to create worktree"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to create worktree: {}", e),
}),
)
})?;
let _ = commands::run_worktree_setup(&repo_root, &worktree_path).await;
let duration_ms = start.elapsed().as_millis();
tracing::info!(
request_id = %request_id,
operation = "create_worktree",
worktree_name = %worktree_name,
duration_ms = duration_ms,
"Worktree created successfully"
);
Ok(Json(crate::tui::types::WorktreeInfo {
path: worktree_path,
head: base_commit[..8.min(base_commit.len())].to_string(),
branch: branch_name,
is_detached: false,
is_main: false,
merge_conflict: None,
has_commits_ahead: false,
is_merging: false,
}))
}
#[derive(Debug, serde::Deserialize)]
#[cfg_attr(feature = "web-monitoring", derive(utoipa::ToSchema))]
pub struct DeleteWorktreeRequest {
pub branch_name: String,
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
post,
path = "/api/worktrees/delete",
tag = "worktrees",
request_body = DeleteWorktreeRequest,
responses(
(status = 200, description = "Worktree deleted", body = ControlResponse),
(status = 404, description = "Worktree not found", body = ErrorResponse),
(status = 409, description = "Cannot delete worktree (validation failed)", body = ErrorResponse),
(status = 500, description = "Failed to delete worktree", body = ErrorResponse)
)
)
)]
pub async fn delete_worktree(
Json(req): Json<DeleteWorktreeRequest>,
) -> Result<Json<ControlResponse>, (StatusCode, Json<ErrorResponse>)> {
use crate::vcs::git::commands;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tracing::error;
let start = Instant::now();
let request_id = format!(
"delete_wt_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_micros()
);
let worktree_name = req.branch_name.clone();
let repo_root = std::env::current_dir().map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "delete_worktree",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to get current directory"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to get current directory: {}", e),
}),
)
})?;
let worktrees = worktree_ops::get_worktrees(&repo_root).await.map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "delete_worktree",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to list worktrees"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to list worktrees: {}", e),
}),
)
})?;
let worktree = worktrees
.iter()
.find(|wt| wt.branch == req.branch_name)
.ok_or_else(|| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "delete_worktree",
worktree_name = %worktree_name,
error = "Worktree not found",
duration_ms = duration_ms,
"Worktree deletion failed - not found"
);
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Worktree '{}' not found", req.branch_name),
}),
)
})?;
let (can_delete, reason) = worktree_ops::can_delete_worktree(worktree).await;
if !can_delete {
let duration_ms = start.elapsed().as_millis();
let error_msg = reason.unwrap_or_else(|| "Cannot delete worktree".to_string());
error!(
request_id = %request_id,
operation = "delete_worktree",
worktree_name = %worktree_name,
error = %error_msg,
duration_ms = duration_ms,
"Worktree deletion failed - validation failed"
);
return Err((
StatusCode::CONFLICT,
Json(ErrorResponse { error: error_msg }),
));
}
commands::worktree_remove(&repo_root, worktree.path.to_str().unwrap())
.await
.map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "delete_worktree",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to remove worktree"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to remove worktree: {}", e),
}),
)
})?;
let _ = commands::branch_delete(&repo_root, &req.branch_name).await;
let duration_ms = start.elapsed().as_millis();
tracing::info!(
request_id = %request_id,
operation = "delete_worktree",
worktree_name = %worktree_name,
duration_ms = duration_ms,
"Worktree deleted successfully"
);
Ok(Json(ControlResponse {
success: true,
message: format!("Worktree '{}' deleted successfully", req.branch_name),
}))
}
#[derive(Debug, serde::Deserialize)]
#[cfg_attr(feature = "web-monitoring", derive(utoipa::ToSchema))]
pub struct MergeWorktreeRequest {
pub branch_name: String,
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
post,
path = "/api/worktrees/merge",
tag = "worktrees",
request_body = MergeWorktreeRequest,
responses(
(status = 200, description = "Worktree merged", body = ControlResponse),
(status = 404, description = "Worktree not found", body = ErrorResponse),
(status = 409, description = "Cannot merge worktree (validation failed)", body = ErrorResponse),
(status = 500, description = "Failed to merge worktree", body = ErrorResponse)
)
)
)]
pub async fn merge_worktree(
Json(req): Json<MergeWorktreeRequest>,
) -> Result<Json<ControlResponse>, (StatusCode, Json<ErrorResponse>)> {
use crate::vcs::git::commands;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tracing::error;
let start = Instant::now();
let request_id = format!(
"merge_wt_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_micros()
);
let worktree_name = req.branch_name.clone();
let repo_root = std::env::current_dir().map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "merge_worktree",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to get current directory"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to get current directory: {}", e),
}),
)
})?;
let worktrees = worktree_ops::get_worktrees(&repo_root).await.map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "merge_worktree",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to list worktrees"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to list worktrees: {}", e),
}),
)
})?;
let worktree = worktrees
.iter()
.find(|wt| wt.branch == req.branch_name)
.ok_or_else(|| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "merge_worktree",
worktree_name = %worktree_name,
error = "Worktree not found",
duration_ms = duration_ms,
"Worktree merge failed - not found"
);
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Worktree '{}' not found", req.branch_name),
}),
)
})?;
let (can_merge, reason) = worktree_ops::can_merge_worktree(worktree);
if !can_merge {
let duration_ms = start.elapsed().as_millis();
let error_msg = reason.unwrap_or_else(|| "Cannot merge worktree".to_string());
error!(
request_id = %request_id,
operation = "merge_worktree",
worktree_name = %worktree_name,
error = %error_msg,
duration_ms = duration_ms,
"Worktree merge failed - validation failed"
);
return Err((
StatusCode::CONFLICT,
Json(ErrorResponse { error: error_msg }),
));
}
let base_branch = worktrees
.iter()
.find(|wt| wt.is_main)
.map(|wt| wt.branch.clone())
.ok_or_else(|| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "merge_worktree",
worktree_name = %worktree_name,
error = "Failed to determine base branch",
duration_ms = duration_ms,
"Worktree merge failed - no base branch found"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to determine base branch".to_string(),
}),
)
})?;
commands::checkout(&repo_root, &base_branch)
.await
.map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "merge_worktree",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to checkout base branch"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to checkout base branch: {}", e),
}),
)
})?;
commands::merge(&repo_root, &req.branch_name)
.await
.map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "merge_worktree",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to merge branch"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to merge branch: {}", e),
}),
)
})?;
let duration_ms = start.elapsed().as_millis();
tracing::info!(
request_id = %request_id,
operation = "merge_worktree",
worktree_name = %worktree_name,
duration_ms = duration_ms,
"Worktree merged successfully"
);
Ok(Json(ControlResponse {
success: true,
message: format!(
"Branch '{}' merged into '{}' successfully",
req.branch_name, base_branch
),
}))
}
#[derive(Debug, serde::Deserialize)]
#[cfg_attr(feature = "web-monitoring", derive(utoipa::ToSchema))]
pub struct WorktreeCommandRequest {
pub branch_name: String,
pub command: String,
}
#[cfg_attr(
feature = "web-monitoring",
utoipa::path(
post,
path = "/api/worktrees/command",
tag = "worktrees",
request_body = WorktreeCommandRequest,
responses(
(status = 200, description = "Command executed", body = ControlResponse),
(status = 404, description = "Worktree not found", body = ErrorResponse),
(status = 500, description = "Command execution failed", body = ErrorResponse)
)
)
)]
pub async fn execute_worktree_command(
Json(req): Json<WorktreeCommandRequest>,
) -> Result<Json<ControlResponse>, (StatusCode, Json<ErrorResponse>)> {
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tracing::error;
let start = Instant::now();
let request_id = format!(
"cmd_wt_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_micros()
);
let worktree_name = req.branch_name.clone();
let repo_root = std::env::current_dir().map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "command",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to get current directory"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to get current directory: {}", e),
}),
)
})?;
let worktrees = worktree_ops::get_worktrees(&repo_root).await.map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "command",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to list worktrees"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to list worktrees: {}", e),
}),
)
})?;
let worktree = worktrees
.iter()
.find(|wt| wt.branch == req.branch_name)
.ok_or_else(|| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "command",
worktree_name = %worktree_name,
error = "Worktree not found",
duration_ms = duration_ms,
"Command execution failed - worktree not found"
);
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Worktree '{}' not found", req.branch_name),
}),
)
})?;
let mut shell_cmd = crate::shell_command::build_login_shell_command(&req.command);
shell_cmd
.current_dir(&worktree.path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let output = shell_cmd.output().await.map_err(|e| {
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "command",
worktree_name = %worktree_name,
error = %e,
duration_ms = duration_ms,
"Failed to execute command"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to execute command: {}", e),
}),
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let duration_ms = start.elapsed().as_millis();
error!(
request_id = %request_id,
operation = "command",
worktree_name = %worktree_name,
error = %stderr,
duration_ms = duration_ms,
"Command failed"
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Command failed: {}", stderr),
}),
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let duration_ms = start.elapsed().as_millis();
tracing::info!(
request_id = %request_id,
operation = "command",
worktree_name = %worktree_name,
duration_ms = duration_ms,
"Command executed successfully"
);
Ok(Json(ControlResponse {
success: true,
message: format!("Command executed successfully:\n{}", stdout),
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openspec::{Change, ProposalMetadata};
use crate::web::state::OrchestratorStateSnapshot;
fn create_test_change(id: &str, completed: u32, total: u32) -> Change {
Change {
id: id.to_string(),
completed_tasks: completed,
total_tasks: total,
last_modified: "1m ago".to_string(),
dependencies: Vec::new(),
metadata: ProposalMetadata::default(),
}
}
#[tokio::test]
async fn test_health_endpoint() {
let response = health().await;
assert_eq!(response.status, "ok");
}
#[tokio::test]
async fn test_get_state_endpoint() {
let changes = vec![create_test_change("test", 2, 5)];
let web_state = Arc::new(WebState::new(&changes));
let response = get_state(State(web_state)).await.into_response();
assert_eq!(
response.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let _state: OrchestratorStateSnapshot = serde_json::from_slice(&body).unwrap();
}
#[tokio::test]
async fn test_list_changes_endpoint() {
let changes = vec![
create_test_change("change-a", 1, 3),
create_test_change("change-b", 2, 4),
];
let web_state = Arc::new(WebState::new(&changes));
let _response = list_changes(State(web_state)).await;
}
#[tokio::test]
async fn test_get_change_found() {
let changes = vec![create_test_change("my-change", 3, 5)];
let web_state = Arc::new(WebState::new(&changes));
let result = get_change(
State(web_state),
Path("update-web-dashboard-state-refresh".to_string()),
)
.await;
if let Ok(change) = result {
assert_eq!(change.id, "update-web-dashboard-state-refresh");
}
}
#[tokio::test]
async fn test_get_change_not_found() {
let web_state = Arc::new(WebState::new(&[]));
let result = get_change(State(web_state), Path("nonexistent".to_string())).await;
assert!(result.is_err());
let (status, error) = result.unwrap_err();
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(error.error.contains("nonexistent"));
}
#[test]
fn test_not_found_response_helper() {
let (status, error) = not_found_response("test-change-id");
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(error.error, "Change 'test-change-id' not found");
}
#[test]
fn test_not_found_response_consistency() {
let change_id = "my-test-change";
let (status, error) = not_found_response(change_id);
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(error.error, format!("Change '{}' not found", change_id));
}
}