use std::sync::Arc;
use tracing::warn;
use super::route_outcome_http::outcome_from_response;
use super::*;
use crate::daemon::rpc::managed::outcome::{CODE_PANE_GONE, CODE_WORKSPACE_GONE, RouteOutcome};
fn parse_id_neutral(id_str: &str) -> Result<ManagedSessionId, RouteOutcome> {
parse_id(id_str).map_err(|(code, msg)| RouteOutcome::text(code.as_u16(), msg))
}
fn not_found(id_str: &str) -> RouteOutcome {
RouteOutcome::text(404, format!("session {id_str} not found"))
}
pub(crate) async fn spawn_core(state: &Arc<DaemonState>, req: SpawnRequest) -> RouteOutcome {
if let Some(raw) = req.runtime.as_deref()
&& let Err(e) = raw.parse::<RuntimeKind>()
{
warn!("spawn_session: invalid runtime selector: {e}");
return RouteOutcome::text(400, e.to_string());
}
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 outcome_from_response(resp).await;
}
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,
worktree: req.worktree,
};
if req.background {
return provision_status::accept_async_spawn(Arc::clone(state), 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()),
};
RouteOutcome::created(&resp)
}
Err(e) => RouteOutcome::text(500, e),
}
}
pub(crate) async fn adopt_core(
state: &Arc<DaemonState>,
req: AdoptExistingRequest,
) -> RouteOutcome {
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 RouteOutcome::text(400, e.to_string());
}
},
};
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) => RouteOutcome::created(&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),
}),
Err(crate::session_manager::ManagedError::TmuxSessionMissing(msg)) => {
RouteOutcome::text(404, msg)
}
Err(crate::session_manager::ManagedError::AlreadyAdopted(msg)) => {
RouteOutcome::text(409, msg)
}
Err(e) => RouteOutcome::text(500, e.to_string()),
}
}
pub(crate) async fn list_core(
state: &Arc<DaemonState>,
source_id: Option<&str>,
slim: bool,
) -> RouteOutcome {
let mgr = state.session_manager().await;
mgr.follow_tmux_renames().await;
mgr.rebind_stale_panes().await;
let all_records = mgr.list().await;
let numbered = mgr.numbered_snapshot(&all_records).await;
let sessions = numbered_summaries(numbered, mgr.tmux.as_ref(), source_id, !slim).await;
let store_health = mgr.store_health().await.map(|d| StoreHealthPayload {
message: d.message,
corrupt: d.corrupt,
observed_at: d.observed_at.to_rfc3339(),
});
RouteOutcome::ok(&ListSessionsResponse {
sessions,
store_health,
})
}
pub(crate) async fn get_core(state: &Arc<DaemonState>, id_str: &str) -> RouteOutcome {
let id = match parse_id_neutral(id_str) {
Ok(id) => id,
Err(refusal) => return refusal,
};
let mgr = state.session_manager().await;
mgr.follow_tmux_renames().await; mgr.rebind_stale_panes().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),
);
RouteOutcome::ok(&summary)
}
Err(_) => not_found(id_str),
}
}
pub(crate) async fn send_core(
state: &Arc<DaemonState>,
id_str: &str,
req: SendInputRequest,
) -> RouteOutcome {
let id = match parse_id_neutral(id_str) {
Ok(id) => id,
Err(refusal) => return refusal,
};
let mgr = state.session_manager().await;
let tmux_name = match mgr.get(&id).await {
Ok(r) => r.tmux_name,
Err(_) => return not_found(id_str),
};
match mgr.send_input(&id, &req.text).await {
Ok(()) => RouteOutcome::ok(&SendInputResponse {
sent: true,
tmux_name,
}),
Err(e) => RouteOutcome::text(500, e.to_string()),
}
}
pub(crate) async fn answer_core(
state: &Arc<DaemonState>,
id_str: &str,
req: AnswerRequest,
) -> RouteOutcome {
let id = match parse_id_neutral(id_str) {
Ok(id) => id,
Err(refusal) => return refusal,
};
let mgr = state.session_manager().await;
let record = match mgr.get(&id).await {
Ok(r) => r,
Err(_) => return not_found(id_str),
};
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 RouteOutcome::text(500, e.to_string());
}
let fresh = match mgr.get(&id).await {
Ok(r) => r,
Err(_) => return not_found(id_str),
};
match spawn_runtime_for(state, &fresh).await {
Ok(()) => RouteOutcome::ok(&AnswerResponse {
injected: true,
tmux_name,
}),
Err(e) => RouteOutcome::text(500, e),
}
} else {
match mgr.answer_decision(&id, &req.answer).await {
Ok(()) => RouteOutcome::ok(&AnswerResponse {
injected: true,
tmux_name,
}),
Err(e) => RouteOutcome::text(500, e.to_string()),
}
}
}
pub(crate) async fn attach_cmd_core(state: &Arc<DaemonState>, id_str: &str) -> RouteOutcome {
let id = match parse_id_neutral(id_str) {
Ok(id) => id,
Err(refusal) => return refusal,
};
let mgr = state.session_manager().await;
mgr.follow_tmux_renames().await; mgr.rebind_stale_panes().await; match mgr.get(&id).await {
Ok(record) => RouteOutcome::ok(&AttachCmdResponse {
attach_cmd: attach_cmd_for(&record.tmux_name),
}),
Err(_) => not_found(id_str),
}
}
pub(crate) async fn runtime_stop_core(state: &Arc<DaemonState>, id_str: &str) -> RouteOutcome {
let id = match parse_id_neutral(id_str) {
Ok(id) => id,
Err(refusal) => return refusal,
};
let mgr = state.session_manager().await;
let stop = mgr.stop_reporting(&id, crate::session_manager::StopCause::Deliberate);
match stop.await {
Ok(report) => RouteOutcome::ok(&StopResponse {
summary: record_to_summary(&report.record),
runtime_left_running: report.runtime.left_running().map(str::to_owned),
}),
Err(e @ crate::session_manager::ManagedError::KillRefused(_)) => {
RouteOutcome::text(409, e.to_string())
}
Err(crate::session_manager::ManagedError::InvalidState(_, why)) => {
RouteOutcome::text(409, why)
}
Err(_) => not_found(id_str),
}
}
pub(crate) async fn resume_core(state: &Arc<DaemonState>, id_str: &str) -> RouteOutcome {
let id = match parse_id_neutral(id_str) {
Ok(id) => id,
Err(refusal) => return refusal,
};
match resume_managed(state, &id).await {
Ok(final_record) => RouteOutcome::ok(&record_to_summary(&final_record)),
Err(ResumeManagedError::NotFound(id)) => {
RouteOutcome::text(404, format!("session {id} not found"))
}
Err(ResumeManagedError::InvalidState(reason)) => RouteOutcome::text(409, reason),
Err(e @ ResumeManagedError::AlreadyResuming(_)) => RouteOutcome::text(409, e.to_string()),
Err(ResumeManagedError::WorkspaceGone(msg)) => {
RouteOutcome::text(422, msg).with_rpc_code(CODE_WORKSPACE_GONE)
}
Err(ResumeManagedError::PaneGone(msg)) => {
RouteOutcome::text(422, msg).with_rpc_code(CODE_PANE_GONE)
}
Err(ResumeManagedError::Other(msg)) => RouteOutcome::text(500, msg),
}
}
fn decommission_failure_status(err: &crate::session_manager::ManagedError) -> u16 {
use crate::session_manager::ManagedError;
match err {
ManagedError::ForeignActiveWorkspaceClaim(..) | ManagedError::WorktreeOwnerMismatch(..) => {
409
}
ManagedError::InvalidState(..) | ManagedError::KillRefused(..) => 409,
_ => 500,
}
}
pub(crate) async fn decommission_core(
state: &Arc<DaemonState>,
id_str: &str,
record_only: bool,
dirt_policy: ProvisioningDirt,
) -> RouteOutcome {
let id = match parse_id_neutral(id_str) {
Ok(id) => id,
Err(refusal) => return refusal,
};
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 record_only {
mgr.decommission_record_only(&id)
.await
.map(|(record, workspace_removed)| (record, workspace_removed, None, None))
} else {
mgr.decommission_reporting(&id, None, dirt_policy)
.await
.map(|r| {
(
r.record,
r.workspace_removed,
r.workspace_kept_reason,
r.workspace_kept_by_design,
)
})
};
match outcome {
Ok((record, workspace_removed, workspace_kept_reason, workspace_kept_by_design)) => {
let workspace_path_was = if pre_owned {
pre_ws.map(|p| p.to_string_lossy().into_owned())
} else {
None
};
RouteOutcome::ok(&DecommissionResponse {
summary: record_to_summary(&record),
workspace_removed,
workspace_path_was,
workspace_kept_reason,
workspace_kept_by_design,
})
}
Err(crate::session_manager::ManagedError::SessionNotFound(_)) => not_found(id_str),
Err(e) => RouteOutcome::text(decommission_failure_status(&e), e.to_string()),
}
}
pub(crate) fn resume_http_response(outcome: RouteOutcome) -> axum::response::Response {
let reason = match outcome.rpc_code {
Some(CODE_WORKSPACE_GONE) => Some("workspace_missing"),
Some(CODE_PANE_GONE) => Some("pane_gone"),
_ => None,
};
let mut resp = axum::response::IntoResponse::into_response(outcome);
if let Some(reason) = reason {
resp.headers_mut().insert(
axum::http::HeaderName::from_static("x-trusty-resume-reason"),
axum::http::HeaderValue::from_static(reason),
);
}
resp
}
#[cfg(test)]
mod cores_tests {
use super::*;
use crate::daemon::rpc::managed::outcome::{RouteBody, status_to_rpc_code};
use crate::session_manager::{ManagedError, ManagedSessionState, SessionRecord};
fn active_record_claiming(
id: ManagedSessionId,
workspace_path: &std::path::Path,
) -> SessionRecord {
SessionRecord {
id,
tmux_name: format!("tm-7877-{id}"),
cwd: workspace_path.to_path_buf(),
task: "task".into(),
state: ManagedSessionState::Active,
created_at: chrono::Utc::now(),
last_activity_at: None,
workspace_path: Some(workspace_path.to_path_buf()),
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
#[tokio::test]
async fn a_shared_workspace_guard_refusal_is_a_409_not_a_500() {
let data_root = crate::test_support::hermetic_temp_dir();
let state = Arc::new(
crate::daemon::state::DaemonState::with_root_isolated_managed(
data_root.path().to_path_buf(),
)
.await,
);
let mgr = state.session_manager().await;
let shared = data_root.path().join("shared-7877-workspace");
let sibling = ManagedSessionId::new();
let target = ManagedSessionId::new();
for id in [sibling, target] {
mgr.store
.write()
.await
.upsert(active_record_claiming(id, &shared))
.await
.expect("seed record");
}
let outcome =
decommission_core(&state, &target.to_string(), false, ProvisioningDirt::Refuse).await;
assert_eq!(
outcome.status, 409,
"a guard refusal must be a client-side conflict, not a server fault"
);
match &outcome.body {
RouteBody::Text(msg) => assert!(
msg.contains(&sibling.to_string()),
"the refusal must name the sibling that blocks it, got {msg}"
),
other => panic!("a refusal body must be text, got {other:?}"),
}
assert_eq!(
mgr.get(&target).await.expect("target record").state,
ManagedSessionState::Active
);
}
struct UnlistablePanes;
impl crate::session_manager::ManagedTmuxDriver for UnlistablePanes {
fn create_session(&self, _: &str, _: &str) -> Result<(), ManagedError> {
Ok(())
}
fn kill_session(&self, _: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_line(&self, _: &str, _: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _: &str, _: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(vec![format!("tm-7877-{}", UNPROVEN_ID)])
}
fn pane_exists_checked(&self, _: &str, _: &str) -> Option<bool> {
None
}
}
const UNPROVEN_ID: &str = "00000000-0000-4000-8000-000000008935";
#[tokio::test]
async fn an_unproven_runtime_refusal_is_a_409_that_names_the_reason() {
let data_root = crate::test_support::hermetic_temp_dir();
let state = Arc::new(
crate::daemon::state::DaemonState::with_root_isolated_managed_and_driver(
data_root.path().to_path_buf(),
Arc::new(UnlistablePanes),
)
.await,
);
let mgr = state.session_manager().await;
let id = ManagedSessionId(UNPROVEN_ID.parse().expect("id"));
let mut record = active_record_claiming(id, &data_root.path().join("ws-8935"));
record.pane_id = Some("%9".into());
mgr.store.write().await.upsert(record).await.expect("seed");
let outcome = decommission_core(&state, UNPROVEN_ID, false, ProvisioningDirt::Refuse).await;
assert_eq!(outcome.status, 409);
match &outcome.body {
RouteBody::Text(msg) => assert!(msg.contains("could not be listed"), "{msg}"),
other => panic!("a refusal body must be text, got {other:?}"),
}
assert_eq!(
mgr.get(&id).await.expect("record").state,
ManagedSessionState::Active
);
}
#[test]
fn an_ordinary_decommission_failure_is_still_a_500() {
let err = crate::session_manager::ManagedError::TmuxUnavailable("no tmux".into());
assert_eq!(decommission_failure_status(&err), 500);
}
#[test]
fn resume_http_response_restores_the_reason_header_from_the_rpc_code() {
for (code, expected) in [
(CODE_WORKSPACE_GONE, "workspace_missing"),
(CODE_PANE_GONE, "pane_gone"),
] {
let resp = resume_http_response(RouteOutcome::text(422, "gone").with_rpc_code(code));
assert_eq!(resp.status().as_u16(), 422);
assert_eq!(
resp.headers()
.get("x-trusty-resume-reason")
.and_then(|v| v.to_str().ok()),
Some(expected)
);
}
}
#[test]
fn resume_http_response_adds_no_header_to_an_ordinary_refusal() {
let resp = resume_http_response(RouteOutcome::text(404, "session x not found"));
assert_eq!(resp.status().as_u16(), 404);
assert!(resp.headers().get("x-trusty-resume-reason").is_none());
}
#[test]
fn an_unparseable_id_is_a_400_on_both_transports() {
let refusal = parse_id_neutral("not-a-uuid").expect_err("must refuse");
assert_eq!(refusal.status, 400);
assert_eq!(
status_to_rpc_code(refusal.status),
trusty_common::uds::server::CODE_INVALID_PARAMS
);
}
}