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::error::DaemonError;
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> {
Json(get_claude_config_op(&state, query))
}
pub fn get_claude_config_op(
_state: &Arc<DaemonState>,
query: ClaudeConfigQuery,
) -> 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);
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> {
apply_claude_config_op(&state, body)
.map(Json)
.map_err(|e| e.status())
}
pub fn apply_claude_config_op(
_state: &Arc<DaemonState>,
body: ApplyConfigRequest,
) -> Result<ApplyConfigResponse, DaemonError> {
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_else(|| {
DaemonError::NotFound(format!("recommendation `{}`", body.recommendation_id))
})?;
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);
DaemonError::Internal(format!("applying recommendation {} failed: {e}", rec.id))
})?;
Ok(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> {
Json(list_checkpoints_op(&state, query))
}
pub fn list_checkpoints_op(
_state: &Arc<DaemonState>,
query: CheckpointQuery,
) -> CheckpointsResponse {
let checkpoints = crate::daemon::claude_config::ConfigCheckpointer::list(&query.project)
.unwrap_or_else(|e| {
tracing::warn!("listing checkpoints failed: {e}");
Vec::new()
});
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> {
create_checkpoint_op(&state, body)
.map(Json)
.map_err(|e| e.status())
}
pub fn create_checkpoint_op(
_state: &Arc<DaemonState>,
body: CreateCheckpointRequest,
) -> Result<CreateCheckpointResponse, DaemonError> {
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}");
DaemonError::Internal(format!("creating checkpoint failed: {e}"))
})?;
Ok(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> {
restore_checkpoint_op(&state, body)
.map(Json)
.map_err(|e| e.status())
}
pub fn restore_checkpoint_op(
_state: &Arc<DaemonState>,
body: RestoreRequest,
) -> Result<RestoreResponse, DaemonError> {
crate::daemon::claude_config::ConfigCheckpointer::restore(&body.project, &body.checkpoint_id)
.map_err(|e| {
tracing::warn!("restoring checkpoint {} failed: {e}", body.checkpoint_id);
DaemonError::Internal(format!(
"restoring checkpoint {} failed: {e}",
body.checkpoint_id
))
})?;
Ok(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> {
delete_checkpoint_op(
&state,
DeleteCheckpointParams {
project: query.project,
id,
},
)
.map(Json)
.map_err(|e| e.status())
}
#[derive(serde::Deserialize)]
pub struct DeleteCheckpointParams {
pub project: PathBuf,
pub id: String,
}
pub fn delete_checkpoint_op(
_state: &Arc<DaemonState>,
params: DeleteCheckpointParams,
) -> Result<DeleteCheckpointResponse, DaemonError> {
crate::daemon::claude_config::ConfigCheckpointer::delete(¶ms.project, ¶ms.id).map_err(
|e| {
tracing::warn!("deleting checkpoint {} failed: {e}", params.id);
DaemonError::CheckpointNotFound {
id: params.id.clone(),
}
},
)?;
Ok(DeleteCheckpointResponse { deleted: params.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> {
Json(list_profiles_op(&state))
}
pub fn list_profiles_op(_state: &Arc<DaemonState>) -> ProfilesResponse {
let profiles = crate::daemon::claude_config::ProfileDeployer::builtin_profiles();
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> {
deploy_profile_op(&state, body)
.map(Json)
.map_err(|e| e.status())
}
pub fn deploy_profile_op(
_state: &Arc<DaemonState>,
body: DeployProfileRequest,
) -> Result<DeployProfileResponse, DaemonError> {
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_else(|| DaemonError::NotFound(format!("profile `{}`", body.profile_name)))?;
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);
DaemonError::Internal(format!(
"deploying profile {} failed: {e}",
body.profile_name
))
})?;
Ok(DeployProfileResponse {
deployed: body.profile_name,
checkpoint_id,
})
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct RestartRequest {
pub tmux_session: String,
}
fn select_restart_record<'a>(
records: &'a [SessionRecord],
tmux_session: &str,
) -> Option<&'a SessionRecord> {
records
.iter()
.filter(|r| r.tmux_name == tmux_session)
.filter(|r| !matches!(r.state, ManagedSessionState::Decommissioned))
.max_by_key(|r| r.created_at)
}
async fn restart_pane_for(
state: &Arc<DaemonState>,
tmux_session: &str,
) -> Result<Option<String>, DaemonError> {
let mgr = state.session_manager().await;
let records = mgr.list().await;
let Some(record) = select_restart_record(&records, tmux_session) else {
return Ok(None);
};
mgr.owned_pane(&record.id, record).map(Some).map_err(|e| {
tracing::warn!("restart in {tmux_session} refused: {e}");
DaemonError::SessionNotActive {
id: tmux_session.to_owned(),
status: format!("restart refused: {e}"),
}
})
}
#[utoipa::path(
post,
path = "/claude-config/restart",
tag = "claude-config",
request_body = RestartRequest,
responses(
(status = 200, description = "Restart command sent"),
(status = 409, description = "the recorded pane is not proven the record's own"),
(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> {
restart_claude_code_op(&state, body)
.await
.map(Json)
.map_err(|e| e.status())
}
pub async fn restart_claude_code_op(
state: &Arc<DaemonState>,
body: RestartRequest,
) -> Result<RestartResponse, DaemonError> {
crate::core::tmux::check_session_name(&body.tmux_session)
.map_err(|e| DaemonError::InvalidRequest(e.to_string()))?;
let pane_id = restart_pane_for(state, &body.tmux_session).await?;
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);
DaemonError::Internal(format!("restart in {} failed: {e}", body.tmux_session))
})?;
Ok(RestartResponse {
restarted: body.tmux_session,
})
}
#[cfg(test)]
mod restart_pane_selection_tests {
use super::*;
#[tokio::test]
async fn restart_route_rejects_an_empty_tmux_session() {
let dir = tempfile::tempdir().expect("temp dir");
let paths = crate::core::paths::FrameworkPaths::under(dir.path());
let state = Arc::new(DaemonState::with_paths(&paths));
for name in ["", "=", " "] {
let status = restart_claude_code(
State(state.clone()),
Json(RestartRequest {
tmux_session: name.to_string(),
}),
)
.await
.err();
assert_eq!(status, Some(StatusCode::BAD_REQUEST), "{name:?}");
}
}
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),
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
#[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_record(&records, "tmpm-proj-1").and_then(|r| r.pane_id.clone()),
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_record(&records, "tmpm-proj-1").and_then(|r| r.pane_id.clone()),
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!(select_restart_record(&records, "tmpm-proj-1").is_none());
}
}