use std::sync::Arc;
use axum::{
Json,
extract::{Path as AxumPath, State},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::daemon::state::DaemonState;
use crate::runtime::RuntimeKind;
use crate::session_manager::{ManagedSessionId, SessionRecord};
mod lifecycle;
pub use lifecycle::{ResumeManagedError, SpawnParams, resume_managed, spawn_managed};
#[derive(Debug, Deserialize)]
pub struct SpawnRequest {
pub repo_url: String,
#[serde(rename = "ref")]
pub git_ref: String,
pub task: String,
pub name_hint: Option<String>,
pub runtime: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SpawnResponse {
pub id: String,
pub name: String,
pub workspace_path: Option<String>,
pub repo_url: Option<String>,
pub branch: Option<String>,
pub state: String,
pub created_at: String,
pub attach_cmd: String,
pub runtime: String,
}
#[derive(Debug, Serialize)]
pub struct ListSessionsResponse {
pub sessions: Vec<SessionSummary>,
}
#[derive(Debug, Serialize)]
pub struct SessionSummary {
pub id: String,
pub name: String,
pub state: String,
pub workspace_path: Option<String>,
pub repo_url: Option<String>,
pub branch: Option<String>,
pub created_at: String,
pub last_activity_at: Option<String>,
pub pending_decision: Option<String>,
pub proposed_default: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SendInputRequest {
pub text: String,
}
#[derive(Debug, Serialize)]
pub struct SendInputResponse {
pub sent: bool,
pub tmux_name: String,
}
#[derive(Debug, Deserialize)]
pub struct AnswerRequest {
pub answer: String,
}
#[derive(Debug, Serialize)]
pub struct AnswerResponse {
pub injected: bool,
pub tmux_name: String,
}
#[derive(Debug, Serialize)]
pub struct AttachCmdResponse {
pub attach_cmd: String,
}
#[derive(Debug, Serialize)]
pub struct ActivityResponse {
pub raw_pane: String,
pub runtime_active: bool,
pub state: String,
pub summary: String,
pub confidence: f32,
pub cache_hit: bool,
pub input_tokens: u32,
pub output_tokens: u32,
pub latency_ms: u64,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub classification: Option<String>,
pub pending_decision: Option<String>,
pub proposed_default: Option<String>,
}
pub fn record_to_json(r: &SessionRecord) -> serde_json::Value {
serde_json::json!({
"id": r.id.to_string(),
"name": r.tmux_name,
"state": r.state.to_string(),
"workspace_path": r.workspace_path.as_ref().map(|p| p.to_string_lossy().to_string()),
"repo_url": r.repo_url,
"branch": r.branch,
"created_at": r.created_at.to_rfc3339(),
"last_activity_at": r.last_activity_at.map(|t| t.to_rfc3339()),
"attach_cmd": attach_cmd_for(&r.tmux_name),
"runtime": r.runtime.as_str(),
"pending_decision": r.pending_decision,
"proposed_default": r.proposed_default,
})
}
fn record_to_summary(r: &SessionRecord) -> SessionSummary {
SessionSummary {
id: r.id.to_string(),
name: r.tmux_name.clone(),
state: r.state.to_string(),
workspace_path: r
.workspace_path
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
repo_url: r.repo_url.clone(),
branch: r.branch.clone(),
created_at: r.created_at.to_rfc3339(),
last_activity_at: r.last_activity_at.map(|t| t.to_rfc3339()),
pending_decision: r.pending_decision.clone(),
proposed_default: r.proposed_default.clone(),
}
}
fn attach_cmd_for(tmux_name: &str) -> String {
format!("tmux attach-session -t {tmux_name}")
}
fn parse_id(id_str: &str) -> Result<ManagedSessionId, (StatusCode, String)> {
id_str
.parse::<uuid::Uuid>()
.map(ManagedSessionId::from)
.map_err(|_| {
(
StatusCode::BAD_REQUEST,
format!("invalid session id: {id_str}"),
)
})
}
pub async fn spawn_session(
State(state): State<Arc<DaemonState>>,
Json(req): Json<SpawnRequest>,
) -> impl IntoResponse {
if let Some(raw) = req.runtime.as_deref()
&& let Err(e) = raw.parse::<RuntimeKind>()
{
warn!("spawn_session: invalid runtime selector: {e}");
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
}
let params = SpawnParams {
repo_url: req.repo_url,
git_ref: req.git_ref,
task: req.task,
name_hint: req.name_hint,
runtime: req.runtime,
};
match spawn_managed(&state, params).await {
Ok(final_record) => {
let resp = SpawnResponse {
id: final_record.id.to_string(),
name: final_record.tmux_name.clone(),
workspace_path: final_record
.workspace_path
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
repo_url: final_record.repo_url.clone(),
branch: final_record.branch.clone(),
state: final_record.state.to_string(),
created_at: final_record.created_at.to_rfc3339(),
attach_cmd: attach_cmd_for(&final_record.tmux_name),
runtime: final_record.runtime.as_str().to_owned(),
};
(StatusCode::CREATED, Json(resp)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
pub async fn list_managed_sessions(State(state): State<Arc<DaemonState>>) -> impl IntoResponse {
let mgr = state.session_manager().await;
let sessions: Vec<SessionSummary> = mgr.list().await.iter().map(record_to_summary).collect();
Json(ListSessionsResponse { sessions })
}
pub async fn get_managed_session(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
) -> impl IntoResponse {
let id = match parse_id(&id_str) {
Ok(id) => id,
Err((code, msg)) => return (code, msg).into_response(),
};
let mgr = state.session_manager().await;
match mgr.get(&id).await {
Ok(record) => Json(record_to_summary(&record)).into_response(),
Err(_) => (StatusCode::NOT_FOUND, format!("session {id_str} not found")).into_response(),
}
}
pub async fn send_to_session(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
Json(req): Json<SendInputRequest>,
) -> impl IntoResponse {
let id = match parse_id(&id_str) {
Ok(id) => id,
Err((code, msg)) => return (code, msg).into_response(),
};
let mgr = state.session_manager().await;
let tmux_name = match mgr.get(&id).await {
Ok(r) => r.tmux_name,
Err(_) => {
return (StatusCode::NOT_FOUND, format!("session {id_str} not found")).into_response();
}
};
match mgr.send_input(&id, &req.text).await {
Ok(()) => Json(SendInputResponse {
sent: true,
tmux_name,
})
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
pub async fn answer_session_decision(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
Json(req): Json<AnswerRequest>,
) -> impl IntoResponse {
let id = match parse_id(&id_str) {
Ok(id) => id,
Err((code, msg)) => return (code, msg).into_response(),
};
let mgr = state.session_manager().await;
let tmux_name = match mgr.get(&id).await {
Ok(r) => r.tmux_name,
Err(_) => {
return (StatusCode::NOT_FOUND, format!("session {id_str} not found")).into_response();
}
};
match mgr.answer_decision(&id, &req.answer).await {
Ok(()) => Json(AnswerResponse {
injected: true,
tmux_name,
})
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
pub async fn get_attach_cmd(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
) -> impl IntoResponse {
let id = match parse_id(&id_str) {
Ok(id) => id,
Err((code, msg)) => return (code, msg).into_response(),
};
let mgr = state.session_manager().await;
match mgr.get(&id).await {
Ok(record) => {
let attach_cmd = attach_cmd_for(&record.tmux_name);
Json(AttachCmdResponse { attach_cmd }).into_response()
}
Err(_) => (StatusCode::NOT_FOUND, format!("session {id_str} not found")).into_response(),
}
}
pub async fn stop_managed_session_runtime(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
) -> impl IntoResponse {
let id = match parse_id(&id_str) {
Ok(id) => id,
Err((code, msg)) => return (code, msg).into_response(),
};
let mgr = state.session_manager().await;
match mgr.stop(&id).await {
Ok(record) => Json(record_to_summary(&record)).into_response(),
Err(_) => (StatusCode::NOT_FOUND, format!("session {id_str} not found")).into_response(),
}
}
pub async fn resume_managed_session(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
) -> impl IntoResponse {
let id = match parse_id(&id_str) {
Ok(id) => id,
Err((code, msg)) => return (code, msg).into_response(),
};
match resume_managed(&state, &id).await {
Ok(final_record) => Json(record_to_summary(&final_record)).into_response(),
Err(ResumeManagedError::NotFound(_)) => {
(StatusCode::NOT_FOUND, format!("session {id_str} not found")).into_response()
}
Err(ResumeManagedError::InvalidState(reason)) => {
(StatusCode::CONFLICT, reason).into_response()
}
Err(ResumeManagedError::Other(msg)) => {
(StatusCode::INTERNAL_SERVER_ERROR, msg).into_response()
}
}
}
pub async fn decommission_managed_session(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
) -> impl IntoResponse {
let id = match parse_id(&id_str) {
Ok(id) => id,
Err((code, msg)) => return (code, msg).into_response(),
};
let mgr = state.session_manager().await;
match mgr.decommission(&id).await {
Ok(record) => Json(record_to_summary(&record)).into_response(),
Err(crate::session_manager::ManagedError::SessionNotFound(_)) => {
(StatusCode::NOT_FOUND, format!("session {id_str} not found")).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
pub async fn stop_managed_session(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
) -> impl IntoResponse {
stop_managed_session_runtime(State(state), AxumPath(id_str)).await
}
pub async fn get_session_activity(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
) -> impl IntoResponse {
let id = match parse_id(&id_str) {
Ok(id) => id,
Err((code, msg)) => return (code, msg).into_response(),
};
let mgr = state.session_manager().await;
let record = match mgr.get(&id).await {
Ok(r) => r,
Err(_) => {
return (StatusCode::NOT_FOUND, format!("session {id_str} not found")).into_response();
}
};
let pane_text = mgr
.capture_pane(&id, 60)
.await
.unwrap_or_else(|_| String::new());
let runtime_active = mgr.tmux_driver().session_exists(&record.tmux_name);
let monitor = state.activity_monitor();
let result = match monitor.check(&id_str, &pane_text).await {
Ok(r) => r,
Err(e) => {
warn!(session = %id_str, "activity check error (non-key): {e}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("activity check failed: {e}"),
)
.into_response();
}
};
let api_key_present = std::env::var("OPENROUTER_API_KEY").is_ok();
let classification = if api_key_present {
Some(format!("{:?}", result.verdict.state).to_lowercase())
} else {
None
};
Json(ActivityResponse {
raw_pane: pane_text,
runtime_active,
state: format!("{:?}", result.verdict.state).to_lowercase(),
summary: result.verdict.summary,
confidence: result.verdict.confidence,
cache_hit: result.cache_hit,
input_tokens: result.cost.input_tokens,
output_tokens: result.cost.output_tokens,
latency_ms: result.cost.latency_ms,
total_input_tokens: result.tally.total_input_tokens,
total_output_tokens: result.tally.total_output_tokens,
classification,
pending_decision: record.pending_decision,
proposed_default: record.proposed_default,
})
.into_response()
}