use std::path::PathBuf;
use std::sync::Arc;
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
};
use crate::daemon::api::types::{
ApplyConfigResponse, CheckpointsResponse, ClaudeConfigResponse, CreateCheckpointResponse,
DeleteCheckpointResponse, DeployProfileResponse, ProfilesResponse, RestartResponse,
RestoreResponse,
};
use crate::daemon::state::DaemonState;
use crate::session_manager::record::{ManagedSessionState, SessionRecord};
#[derive(serde::Deserialize)]
pub struct ClaudeConfigQuery {
pub project: PathBuf,
}
#[utoipa::path(
get,
path = "/claude-config",
tag = "claude-config",
params(("project" = String, Query, description = "Project directory")),
responses((status = 200, description = "Analyzed config plus recommendations"))
)]
pub async fn get_claude_config(
State(_state): State<Arc<DaemonState>>,
Query(query): Query<ClaudeConfigQuery>,
) -> Json<ClaudeConfigResponse> {
use crate::core::claude_config::ClaudeConfigReader;
let paths = ClaudeConfigReader::paths_for_project(&query.project);
let config = crate::daemon::claude_config::ClaudeConfigAnalyzer::read_config(&paths);
let recommendations = crate::daemon::claude_config::ClaudeConfigAnalyzer::analyze(&config);
Json(ClaudeConfigResponse {
config,
recommendations,
})
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct ApplyConfigRequest {
#[schema(value_type = String)]
pub project: PathBuf,
pub recommendation_id: String,
}
#[utoipa::path(
post,
path = "/claude-config/apply",
tag = "claude-config",
request_body = ApplyConfigRequest,
responses(
(status = 200, description = "Recommendation applied; returns checkpoint id"),
(status = 404, description = "No recommendation with that id"),
(status = 500, description = "Applying the recommendation failed"),
)
)]
pub async fn apply_claude_config(
State(_state): State<Arc<DaemonState>>,
Json(body): Json<ApplyConfigRequest>,
) -> Result<Json<ApplyConfigResponse>, StatusCode> {
use crate::core::claude_config::ClaudeConfigReader;
let paths = ClaudeConfigReader::paths_for_project(&body.project);
let config = crate::daemon::claude_config::ClaudeConfigAnalyzer::read_config(&paths);
let recommendations = crate::daemon::claude_config::ClaudeConfigAnalyzer::analyze(&config);
let rec = recommendations
.iter()
.find(|r| r.id == body.recommendation_id)
.ok_or(StatusCode::NOT_FOUND)?;
let checkpoint_id = crate::daemon::claude_config::ClaudeConfigAnalyzer::apply_recommendation(
rec,
&paths,
&body.project,
)
.map_err(|e| {
tracing::warn!("applying recommendation {} failed: {e}", rec.id);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(ApplyConfigResponse {
applied: true,
recommendation_id: body.recommendation_id,
checkpoint_id,
}))
}
#[derive(serde::Deserialize)]
pub struct CheckpointQuery {
pub project: PathBuf,
}
#[utoipa::path(
get,
path = "/claude-config/checkpoints",
tag = "claude-config",
params(("project" = String, Query, description = "Project directory")),
responses((status = 200, description = "Config checkpoints, newest first"))
)]
pub async fn list_checkpoints(
State(_state): State<Arc<DaemonState>>,
Query(query): Query<CheckpointQuery>,
) -> Json<CheckpointsResponse> {
let checkpoints = crate::daemon::claude_config::ConfigCheckpointer::list(&query.project)
.unwrap_or_else(|e| {
tracing::warn!("listing checkpoints failed: {e}");
Vec::new()
});
Json(CheckpointsResponse { checkpoints })
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct CreateCheckpointRequest {
#[schema(value_type = String)]
pub project: PathBuf,
#[serde(default)]
pub label: Option<String>,
}
#[utoipa::path(
post,
path = "/claude-config/checkpoints",
tag = "claude-config",
request_body = CreateCheckpointRequest,
responses(
(status = 200, description = "Checkpoint created; returns its id"),
(status = 500, description = "Creating the checkpoint failed"),
)
)]
pub async fn create_checkpoint(
State(_state): State<Arc<DaemonState>>,
Json(body): Json<CreateCheckpointRequest>,
) -> Result<Json<CreateCheckpointResponse>, StatusCode> {
use crate::core::claude_config::ClaudeConfigReader;
let paths = ClaudeConfigReader::paths_for_project(&body.project);
let id = crate::daemon::claude_config::ConfigCheckpointer::create(
&paths,
&body.project,
body.label.as_deref(),
)
.map_err(|e| {
tracing::warn!("creating checkpoint failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(CreateCheckpointResponse { id }))
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct RestoreRequest {
#[schema(value_type = String)]
pub project: PathBuf,
pub checkpoint_id: String,
}
#[utoipa::path(
post,
path = "/claude-config/restore",
tag = "claude-config",
request_body = RestoreRequest,
responses(
(status = 200, description = "Config restored from the checkpoint"),
(status = 500, description = "Checkpoint missing or restore failed"),
)
)]
pub async fn restore_checkpoint(
State(_state): State<Arc<DaemonState>>,
Json(body): Json<RestoreRequest>,
) -> Result<Json<RestoreResponse>, StatusCode> {
crate::daemon::claude_config::ConfigCheckpointer::restore(&body.project, &body.checkpoint_id)
.map_err(|e| {
tracing::warn!("restoring checkpoint {} failed: {e}", body.checkpoint_id);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(RestoreResponse {
restored: true,
checkpoint_id: body.checkpoint_id,
}))
}
#[utoipa::path(
delete,
path = "/claude-config/checkpoints/{id}",
tag = "claude-config",
params(
("id" = String, Path, description = "Checkpoint id"),
("project" = String, Query, description = "Project directory"),
),
responses(
(status = 200, description = "Checkpoint deleted"),
(status = 404, description = "No checkpoint with that id"),
)
)]
pub async fn delete_checkpoint(
State(_state): State<Arc<DaemonState>>,
Path(id): Path<String>,
Query(query): Query<CheckpointQuery>,
) -> Result<Json<DeleteCheckpointResponse>, StatusCode> {
crate::daemon::claude_config::ConfigCheckpointer::delete(&query.project, &id).map_err(|e| {
tracing::warn!("deleting checkpoint {id} failed: {e}");
StatusCode::NOT_FOUND
})?;
Ok(Json(DeleteCheckpointResponse { deleted: id }))
}
#[utoipa::path(
get,
path = "/claude-config/profiles",
tag = "claude-config",
responses((status = 200, description = "Built-in deployment profiles"))
)]
pub async fn list_profiles(State(_state): State<Arc<DaemonState>>) -> Json<ProfilesResponse> {
let profiles = crate::daemon::claude_config::ProfileDeployer::builtin_profiles();
Json(ProfilesResponse { profiles })
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct DeployProfileRequest {
#[schema(value_type = String)]
pub project: PathBuf,
pub profile_name: String,
#[serde(default)]
pub target: Option<crate::core::claude_config::DeployTarget>,
}
#[utoipa::path(
post,
path = "/claude-config/deploy",
tag = "claude-config",
request_body = DeployProfileRequest,
responses(
(status = 200, description = "Profile deployed; returns checkpoint id"),
(status = 404, description = "No built-in profile with that name"),
(status = 500, description = "Deploying the profile failed"),
)
)]
pub async fn deploy_profile(
State(_state): State<Arc<DaemonState>>,
Json(body): Json<DeployProfileRequest>,
) -> Result<Json<DeployProfileResponse>, StatusCode> {
use crate::core::claude_config::ClaudeConfigReader;
let mut profile = crate::daemon::claude_config::ProfileDeployer::builtin_profiles()
.into_iter()
.find(|p| p.name == body.profile_name)
.ok_or(StatusCode::NOT_FOUND)?;
if let Some(target) = body.target {
profile.target = target;
}
let paths = ClaudeConfigReader::paths_for_project(&body.project);
let checkpoint_id =
crate::daemon::claude_config::ProfileDeployer::deploy(&profile, &paths, &body.project)
.map_err(|e| {
tracing::warn!("deploying profile {} failed: {e}", body.profile_name);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(DeployProfileResponse {
deployed: body.profile_name,
checkpoint_id,
}))
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct RestartRequest {
pub tmux_session: String,
}
fn select_restart_pane_id(records: &[SessionRecord], tmux_session: &str) -> Option<String> {
records
.iter()
.filter(|r| r.tmux_name == tmux_session)
.filter(|r| !matches!(r.state, ManagedSessionState::Decommissioned))
.max_by_key(|r| r.created_at)
.and_then(|r| r.pane_id.clone())
}
#[utoipa::path(
post,
path = "/claude-config/restart",
tag = "claude-config",
request_body = RestartRequest,
responses(
(status = 200, description = "Restart command sent"),
(status = 500, description = "tmux unavailable or restart failed"),
)
)]
pub async fn restart_claude_code(
State(state): State<Arc<DaemonState>>,
Json(body): Json<RestartRequest>,
) -> Result<Json<RestartResponse>, StatusCode> {
let records = state.session_manager().await.list().await;
let pane_id = select_restart_pane_id(&records, &body.tmux_session);
crate::daemon::claude_config::ClaudeCodeRestarter::restart_in_session(
&body.tmux_session,
pane_id.as_deref(),
)
.map_err(|e| {
tracing::warn!("restart in {} failed: {e}", body.tmux_session);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(RestartResponse {
restarted: body.tmux_session,
}))
}
#[cfg(test)]
mod restart_pane_selection_tests {
use super::*;
use crate::session_manager::record::ManagedSessionId;
fn make_record(
tmux_name: &str,
state: ManagedSessionState,
pane_id: Option<&str>,
created_at: chrono::DateTime<chrono::Utc>,
) -> SessionRecord {
SessionRecord {
id: ManagedSessionId::new(),
tmux_name: tmux_name.to_string(),
cwd: PathBuf::from("/tmp"),
task: "task".to_string(),
state,
created_at,
last_activity_at: None,
workspace_path: None,
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: crate::driver::SessionCorrelation::new(),
runtime: crate::runtime::RuntimeKind::default(),
ephemeral: true,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: pane_id.map(str::to_owned),
injection_status: Default::default(),
worktree_owner: None,
}
}
#[test]
fn select_restart_pane_id_skips_decommissioned_record() {
let now = chrono::Utc::now();
let stale = make_record(
"tmpm-proj-1",
ManagedSessionState::Decommissioned,
Some("%old"),
now - chrono::Duration::hours(1),
);
let live = make_record(
"tmpm-proj-1",
ManagedSessionState::Active,
Some("%new"),
now,
);
let records = vec![stale, live];
assert_eq!(
select_restart_pane_id(&records, "tmpm-proj-1"),
Some("%new".to_string())
);
}
#[test]
fn select_restart_pane_id_prefers_most_recent_when_multiple_live_match() {
let now = chrono::Utc::now();
let older = make_record(
"tmpm-proj-1",
ManagedSessionState::Stopped,
Some("%older"),
now - chrono::Duration::minutes(5),
);
let newer = make_record(
"tmpm-proj-1",
ManagedSessionState::Active,
Some("%newer"),
now,
);
let records = vec![older, newer];
assert_eq!(
select_restart_pane_id(&records, "tmpm-proj-1"),
Some("%newer".to_string())
);
}
#[test]
fn select_restart_pane_id_none_when_no_match() {
let now = chrono::Utc::now();
let records = vec![make_record(
"tmpm-other",
ManagedSessionState::Active,
Some("%x"),
now,
)];
assert_eq!(select_restart_pane_id(&records, "tmpm-proj-1"), None);
}
}