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;
pub mod activity;
mod deliverable_link;
mod deployment_check;
mod fleet;
pub mod front_gate;
pub mod inproject;
pub mod inproject_hygiene;
mod launch_on_main;
mod lifecycle;
mod mcp_spawn_gate;
mod project_registry_routes;
mod project_status;
pub mod provision_status;
pub mod proxy;
pub mod prune;
mod reactivate;
pub mod reconcile;
pub mod rename;
mod resume_error;
mod session_summary;
mod summary;
pub mod sync_assets;
pub use activity::{ActivityResponse, get_session_activity};
pub use fleet::{FleetByProjectResponse, FleetProjectGroup, fleet_by_project_route};
pub use front_gate::{
ConformanceGate, FrontGateOutcome, HeadlessApproval, IsrConformanceGate, run_front_gate,
};
pub use lifecycle::{
SpawnParams, is_local_workdir, resume_managed, spawn_managed, spawn_runtime_for, write_task_md,
};
pub use project_registry_routes::{
PatchProjectBody, ProjectsListResponse, RegisterProjectBody, get_project_registry_route,
list_projects_registry_route, patch_project_registry_route, register_project_registry_route,
};
pub use project_status::{
DeliverableStatusCounts, MilestoneStatusCounts, ProjectConfigFlags, ProjectStatusResponse,
SessionStateCounts, aggregate_project_status, project_status_route,
};
pub use proxy::{
DirectManagedBackend, ProxyFocusRequest, ProxyFocusResponse, ProxyMessageRequest,
ProxyMessageResponse, ProxySummaryResponse, ProxyTargetWire, ProxyUnfocusRequest,
ProxyUnfocusResponse, proxy_focus, proxy_get_focus, proxy_message, proxy_router, proxy_summary,
proxy_unfocus,
};
pub use prune::{
PruneRequest, decommission_ephemeral_route, prune_managed_route, prune_worktrees_route,
};
pub use reactivate::reactivate_managed_session;
pub use rename::{RenameRequest, rename_managed_session};
pub use resume_error::ResumeManagedError;
pub use session_summary::SessionSummary;
pub use summary::record_to_json;
use summary::{
attach_cmd_for, checked_summaries, numbered_summaries, parse_id, reconcile_against_tmux,
record_to_summary, record_to_summary_checked,
};
#[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>,
#[serde(default)]
pub ephemeral: Option<bool>,
#[serde(default)]
pub inject_task: Option<bool>,
#[serde(default)]
pub deliverable_id: Option<String>,
#[serde(default)]
pub force_new: bool,
#[serde(default)]
pub background: bool,
}
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deliverable_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AdoptExistingRequest {
pub tmux_name: String,
pub cwd: String,
#[serde(default)]
pub task: Option<String>,
#[serde(default)]
pub runtime: Option<String>,
#[serde(default)]
pub ephemeral: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct AdoptExistingResponse {
pub id: String,
pub name: String,
pub state: String,
pub cwd: String,
pub runtime: String,
pub attach_cmd: String,
}
#[derive(Debug, Serialize)]
pub struct ListSessionsResponse {
pub sessions: Vec<SessionSummary>,
}
#[derive(Debug, Serialize)]
pub struct DecommissionResponse {
#[serde(flatten)]
pub summary: SessionSummary,
pub workspace_removed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_path_was: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct DeleteQuery {
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Serialize)]
pub struct DeleteResponse {
#[serde(flatten)]
pub summary: SessionSummary,
pub deleted: bool,
}
#[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,
}
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();
}
if let Some(deliverable_id) = req.deliverable_id.as_deref()
&& let Err(resp) =
deliverable_link::validate_deliverable_scope(&state, &req.repo_url, deliverable_id)
.await
{
return resp;
}
let params = SpawnParams {
repo_url: req.repo_url,
git_ref: req.git_ref,
task: req.task,
name_hint: req.name_hint,
runtime: req.runtime,
ephemeral: req.ephemeral,
mcp_initiated: false,
inject_task: req.inject_task,
deliverable_id: req.deliverable_id,
force_new: req.force_new,
};
if req.background {
return provision_status::accept_async_spawn(state.clone(), params);
}
match spawn_managed(&state, ManagedSessionId::new(), 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(),
deliverable_id: final_record.deliverable_id.map(|id| id.to_string()),
};
(StatusCode::CREATED, Json(resp)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
pub async fn adopt_existing_session(
State(state): State<Arc<DaemonState>>,
Json(req): Json<AdoptExistingRequest>,
) -> impl IntoResponse {
let runtime = match req.runtime.as_deref() {
None => RuntimeKind::default(),
Some(raw) => match raw.parse::<RuntimeKind>() {
Ok(rk) => rk,
Err(e) => {
warn!("adopt_existing: invalid runtime selector: {e}");
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
}
},
};
let task = req.task.unwrap_or_default();
let cwd = std::path::PathBuf::from(&req.cwd);
let mgr = state.session_manager().await;
match mgr
.adopt_existing(
&req.tmux_name,
cwd,
task,
runtime,
req.ephemeral.unwrap_or(false),
)
.await
{
Ok(record) => {
let resp = AdoptExistingResponse {
id: record.id.to_string(),
name: record.tmux_name.clone(),
state: record.state.to_string(),
cwd: record.cwd.to_string_lossy().to_string(),
runtime: record.runtime.as_str().to_owned(),
attach_cmd: attach_cmd_for(&record.tmux_name),
};
(StatusCode::CREATED, Json(resp)).into_response()
}
Err(crate::session_manager::ManagedError::TmuxSessionMissing(msg)) => {
(StatusCode::NOT_FOUND, msg).into_response()
}
Err(crate::session_manager::ManagedError::AlreadyAdopted(msg)) => {
(StatusCode::CONFLICT, msg).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
pub async fn list_managed_sessions(
State(state): State<Arc<DaemonState>>,
axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
let mgr = state.session_manager().await;
let sid_filter = q.get("source_id").map(String::as_str);
let slim = q.get("slim").is_some_and(|v| v == "true" || v == "1");
let all_records = mgr.list().await;
let numbered = mgr.numbered_snapshot(&all_records).await;
let sessions = numbered_summaries(numbered, mgr.tmux.as_ref(), sid_filter, !slim).await;
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) => {
let mut summary = record_to_summary_checked(&record).await;
reconcile_against_tmux(
mgr.tmux.as_ref(),
std::slice::from_mut(&mut summary),
std::slice::from_ref(&record),
);
Json(summary).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 record = match mgr.get(&id).await {
Ok(r) => r,
Err(_) => {
return (StatusCode::NOT_FOUND, format!("session {id_str} not found")).into_response();
}
};
let tmux_name = record.tmux_name.clone();
let is_front_gate_escalation = record.state
== crate::session_manager::ManagedSessionState::Provisioning
&& record.pending_decision.is_some();
if is_front_gate_escalation {
if let Err(e) = mgr.clear_pending_decision(&id).await {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
let fresh = match mgr.get(&id).await {
Ok(r) => r,
Err(_) => {
return (StatusCode::NOT_FOUND, format!("session {id_str} not found"))
.into_response();
}
};
match spawn_runtime_for(&state, &fresh).await {
Ok(()) => Json(AnswerResponse {
injected: true,
tmux_name,
})
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
} else {
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(e) => e.into_response(),
}
}
#[derive(Debug, serde::Deserialize)]
pub struct DecommissionQuery {
#[serde(default)]
pub record_only: bool,
}
pub async fn decommission_managed_session(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
axum::extract::Query(q): axum::extract::Query<DecommissionQuery>,
) -> 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 pre = mgr.get(&id).await.ok();
let pre_owned = pre.as_ref().map(|r| r.workspace_owned).unwrap_or(false);
let pre_ws = pre.and_then(|r| r.workspace_path);
let outcome = if q.record_only {
mgr.decommission_record_only(&id).await
} else {
mgr.decommission(&id, None).await
};
match outcome {
Ok((record, workspace_removed)) => {
let workspace_path_was = if pre_owned {
pre_ws.map(|p| p.to_string_lossy().into_owned())
} else {
None
};
Json(DecommissionResponse {
summary: record_to_summary(&record),
workspace_removed,
workspace_path_was,
})
.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 delete_managed_session(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
axum::extract::Query(q): axum::extract::Query<DeleteQuery>,
) -> 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.delete_record(&id, q.force).await {
Ok(record) => Json(DeleteResponse {
summary: record_to_summary(&record),
deleted: true,
})
.into_response(),
Err(crate::session_manager::ManagedError::SessionNotFound(_)) => {
(StatusCode::NOT_FOUND, format!("session {id_str} not found")).into_response()
}
Err(crate::session_manager::ManagedError::InvalidState(_, reason)) => {
(StatusCode::CONFLICT, reason).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
}
#[cfg(test)]
mod staleness_bench_tests;
#[cfg(test)]
mod tests;