use std::sync::Arc;
use serde_json::{Value, json};
use crate::daemon::managed_routes::{SpawnParams, record_to_json, spawn_managed};
use crate::daemon::state::DaemonState;
use crate::session_manager::{ManagedError, ManagedSessionId, PruneFilter};
fn parse_managed_id(raw: &str) -> Result<ManagedSessionId, String> {
raw.parse::<uuid::Uuid>()
.map(ManagedSessionId::from)
.map_err(|_| format!("`{raw}` is not a valid managed session id (expected a UUID)"))
}
fn managed_err(e: ManagedError) -> String {
e.to_string()
}
pub async fn session_new(
state: &Arc<DaemonState>,
repo_url: &str,
git_ref: &str,
task: &str,
name_hint: Option<&str>,
runtime: Option<&str>,
ephemeral: Option<bool>,
) -> Result<Value, String> {
let params = SpawnParams {
repo_url: repo_url.to_string(),
git_ref: git_ref.to_string(),
task: task.to_string(),
name_hint: name_hint.map(str::to_string),
runtime: runtime.map(str::to_string),
ephemeral,
mcp_initiated: true,
inject_task: None,
deliverable_id: None,
force_new: false,
worktree: false,
};
let record = spawn_managed(
state,
crate::session_manager::ManagedSessionId::new(),
params,
)
.await?;
Ok(record_to_json(&record))
}
pub async fn session_stop(state: &Arc<DaemonState>, session_id: &str) -> Result<Value, String> {
let id = parse_managed_id(session_id)?;
let mgr = state.session_manager().await;
let report = mgr
.stop_reporting(&id, crate::session_manager::StopCause::Deliberate)
.await
.map_err(managed_err)?;
let mut json = record_to_json(&report.record);
if let Some(obj) = json.as_object_mut() {
obj.insert(
"runtime_left_running".into(),
report.runtime.left_running().into(),
);
}
Ok(json)
}
pub async fn session_resume(state: &Arc<DaemonState>, session_id: &str) -> Result<Value, String> {
let id = parse_managed_id(session_id)?;
let record = crate::daemon::managed_routes::resume_managed(state, &id)
.await
.map_err(|e| e.to_string())?;
Ok(record_to_json(&record))
}
pub async fn session_decommission(
state: &Arc<DaemonState>,
session_id: &str,
) -> Result<Value, String> {
let id = parse_managed_id(session_id)?;
let mgr = state.session_manager().await;
let report = mgr
.decommission_reporting(
&id,
None,
crate::session_manager::decommission_force::ProvisioningDirt::Refuse,
)
.await
.map_err(managed_err)?;
let mut value = record_to_json(&report.record);
if let Some(obj) = value.as_object_mut() {
obj.insert(
"workspace_removed".to_string(),
Value::Bool(report.workspace_removed),
);
obj.insert(
"workspace_kept_reason".to_string(),
report
.workspace_kept_reason
.map_or(Value::Null, Value::String),
);
obj.insert(
"workspace_kept_by_design".to_string(),
report
.workspace_kept_by_design
.map_or(Value::Null, Value::String),
);
}
Ok(value)
}
pub async fn session_delete(
state: &Arc<DaemonState>,
session_id: &str,
force: bool,
) -> Result<Value, String> {
let id = parse_managed_id(session_id)?;
let mgr = state.session_manager().await;
let record = mgr.delete_record(&id, force).await.map_err(managed_err)?;
let mut value = record_to_json(&record);
if let Some(obj) = value.as_object_mut() {
obj.insert("deleted".to_string(), Value::Bool(true));
}
Ok(value)
}
pub async fn session_activity(
state: &Arc<DaemonState>,
session_id: &str,
lines: u32,
) -> Result<Value, String> {
let id = parse_managed_id(session_id)?;
let mgr = state.session_manager().await;
let record = mgr.get(&id).await.map_err(managed_err)?;
let raw_pane = mgr
.capture_pane(&id, usize::try_from(lines).unwrap_or(usize::MAX))
.await
.unwrap_or_default();
let runtime_active = mgr.tmux_driver().session_exists(&record.tmux_name);
Ok(json!({
"id": record.id.to_string(),
"name": record.tmux_name,
"state": record.state.to_string(),
"raw_pane": raw_pane,
"lines": lines,
"runtime_active": runtime_active,
"pending_decision": record.pending_decision,
"proposed_default": record.proposed_default,
}))
}
pub async fn session_send(
state: &Arc<DaemonState>,
session_id: &str,
text: &str,
) -> Result<Value, String> {
let id = parse_managed_id(session_id)?;
let mgr = state.session_manager().await;
let record = mgr.get(&id).await.map_err(managed_err)?;
let submit = mgr
.send_input_observed(&id, text)
.await
.map_err(managed_err)?;
Ok(json!({
"id": record.id.to_string(),
"sent": submit.delivered(),
"submit_state": submit.as_str(),
"tmux_name": record.tmux_name,
}))
}
pub async fn session_decommission_ephemeral(state: &Arc<DaemonState>) -> Result<Value, String> {
let mgr = state.session_manager().await;
let count = mgr
.decommission_all_ephemeral()
.await
.map_err(managed_err)?;
Ok(json!({ "decommissioned": count }))
}
pub async fn session_prune(
state: &Arc<DaemonState>,
filter: &str,
dry_run: bool,
include_active: bool,
) -> Result<Value, String> {
let filter = PruneFilter::parse(filter)?;
let mgr = state.session_manager().await;
let outcome = mgr
.prune_managed(filter, dry_run, include_active, None, None)
.await
.map_err(managed_err)?;
serde_json::to_value(&outcome).map_err(|e| format!("failed to serialize prune outcome: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
async fn state() -> (tempfile::TempDir, Arc<DaemonState>) {
let tmp = tempfile::tempdir().expect("isolated daemon root");
let state = DaemonState::with_root_isolated_managed(tmp.path().to_path_buf()).await;
(tmp, Arc::new(state))
}
struct LiveTrackingTmux {
live: std::sync::Mutex<std::collections::HashSet<String>>,
}
impl LiveTrackingTmux {
fn new() -> Arc<Self> {
Arc::new(Self {
live: std::sync::Mutex::new(std::collections::HashSet::new()),
})
}
}
impl crate::session_manager::ManagedTmuxDriver for LiveTrackingTmux {
fn create_session(&self, name: &str, _workdir: &str) -> Result<(), ManagedError> {
self.live.lock().unwrap().insert(name.to_owned());
Ok(())
}
fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
self.live.lock().unwrap().remove(name);
Ok(())
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(self.live.lock().unwrap().iter().cloned().collect())
}
}
#[test]
fn parse_managed_id_rejects_garbage() {
let err = parse_managed_id("not-a-uuid").unwrap_err();
assert!(err.contains("not a valid managed session id"), "{err}");
}
#[tokio::test]
async fn unknown_id_errors_for_all_single_id_tools() {
let (_root, s) = state().await;
let id = uuid::Uuid::new_v4().to_string();
assert!(
session_stop(&s, &id)
.await
.unwrap_err()
.contains("not found")
);
assert!(
session_resume(&s, &id)
.await
.unwrap_err()
.contains("not found")
);
assert!(
session_decommission(&s, &id)
.await
.unwrap_err()
.contains("not found")
);
assert!(
session_delete(&s, &id, false)
.await
.unwrap_err()
.contains("not found")
);
assert!(
session_activity(&s, &id, 60)
.await
.unwrap_err()
.contains("not found")
);
assert!(
session_send(&s, &id, "hi")
.await
.unwrap_err()
.contains("not found")
);
}
#[tokio::test]
async fn garbage_id_rejected_for_all_single_id_tools() {
let (_root, s) = state().await;
for r in [
session_stop(&s, "xx").await,
session_resume(&s, "xx").await,
session_decommission(&s, "xx").await,
session_delete(&s, "xx", false).await,
session_activity(&s, "xx", 60).await,
session_send(&s, "xx", "t").await,
] {
assert!(r.unwrap_err().contains("valid managed session id"));
}
}
#[tokio::test]
async fn session_stop_says_an_unproven_runtime_was_left_running() {
let root = tempfile::TempDir::new().expect("tempdir");
let tmux = LiveTrackingTmux::new();
let s = Arc::new(
DaemonState::with_root_isolated_managed_and_driver(
root.path().to_path_buf(),
tmux.clone(),
)
.await,
);
let id = ManagedSessionId::new();
let ws = root.path().join(format!("{id}-mcp-stop"));
s.session_manager()
.await
.create_with_id(
id,
"mcp session_stop test".to_string(),
Some(ws.clone()),
None,
Some(ws),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
let json = session_stop(&s, &id.to_string()).await.expect("stop");
let why = json["runtime_left_running"]
.as_str()
.unwrap_or_else(|| panic!("the stop names the live session: {json}"));
assert!(why.contains("no pane id"), "{why}");
assert_eq!(json["state"], "stopped", "{json}");
assert!(
!tmux.live.lock().unwrap().is_empty(),
"the session was killed"
);
}
#[tokio::test]
async fn session_delete_refuses_running_then_force_bypasses() {
use crate::runtime::RuntimeKind;
let root = tempfile::TempDir::new().expect("tempdir");
let s = Arc::new(
DaemonState::with_root_isolated_managed_and_driver(
root.path().to_path_buf(),
LiveTrackingTmux::new(),
)
.await,
);
let mgr = s.session_manager().await;
let id = ManagedSessionId::new();
let ws = root.path().join(format!("{id}-mcp-delete"));
mgr.create_with_id(
id,
"mcp session_delete test".to_string(),
Some(ws.clone()),
None,
Some(ws),
Some("https://example.com/r.git".to_string()),
Some("main".to_string()),
RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
let err = session_delete(&s, &id.to_string(), false)
.await
.expect_err("must refuse to delete a running session without force");
assert!(err.contains(&id.to_string()), "{err}");
assert!(mgr.get(&id).await.is_ok(), "record must survive refusal");
let value = session_delete(&s, &id.to_string(), true)
.await
.expect("force must bypass the running guard");
assert_eq!(value["deleted"], serde_json::json!(true));
assert_eq!(
mgr.get(&id).await.expect("record still tracked").state,
crate::session_manager::ManagedSessionState::Deleted,
"record must be marked --deleted-- after forced delete"
);
}
struct PastedPaneTmux {
live: std::sync::Mutex<std::collections::HashSet<String>>,
}
impl crate::session_manager::ManagedTmuxDriver for PastedPaneTmux {
fn create_session(&self, name: &str, _workdir: &str) -> Result<(), ManagedError> {
self.live.lock().unwrap().insert(name.to_owned());
Ok(())
}
fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
self.live.lock().unwrap().remove(name);
Ok(())
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
Ok("❯ [Pasted text #1][Pasted text #2]".to_string())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(self.live.lock().unwrap().iter().cloned().collect())
}
fn get_pane_id(&self, name: &str) -> Option<String> {
Some(crate::test_support::self_describing_pane(name))
}
fn pane_identity(
&self,
pane_id: &str,
) -> Result<crate::session_manager::pane_identity::PaneIdentity, ManagedError> {
Ok(crate::test_support::self_describing_identity(pane_id))
}
fn capture_pane(&self, name: &str, _p: &str, lines: usize) -> Result<String, ManagedError> {
self.capture(name, lines)
}
}
#[tokio::test]
async fn session_send_reports_an_unsubmitted_paste() {
use crate::runtime::RuntimeKind;
let root = tempfile::TempDir::new().expect("tempdir");
let s = Arc::new(
DaemonState::with_root_isolated_managed_and_driver(
root.path().to_path_buf(),
Arc::new(PastedPaneTmux {
live: std::sync::Mutex::new(std::collections::HashSet::new()),
}),
)
.await,
);
let mgr = s.session_manager().await;
let id = ManagedSessionId::new();
let ws = root.path().join(format!("{id}-mcp-send"));
mgr.create_with_id(
id,
"mcp session_send test".to_string(),
Some(ws.clone()),
None,
Some(ws),
Some("https://example.com/r.git".to_string()),
Some("main".to_string()),
RuntimeKind::Tcode,
false,
false,
)
.await
.expect("seed session");
{
let mut store = mgr.store.write().await;
let mut r = store.get(&id).await.expect("seeded record");
r.state = crate::session_manager::ManagedSessionState::Active;
store.upsert(r).await.expect("mark active");
}
let value = session_send(&s, &id.to_string(), "a long coordination message")
.await
.expect("the characters reached the pane — this is a delivery verdict");
assert_eq!(
value["sent"],
serde_json::json!(false),
"pre-fix this is a hardcoded `true`: {value}"
);
assert_eq!(
value["submit_state"],
serde_json::json!("unsubmitted_paste"),
"pre-fix the key does not exist: {value}"
);
}
#[tokio::test]
async fn session_new_invalid_runtime_errors() {
let (_root, s) = state().await;
let err = session_new(
&s,
"https://example.com/r.git",
"main",
"t",
None,
Some("bogus"),
None,
)
.await
.unwrap_err();
assert!(err.contains("unknown runtime"), "{err}");
}
struct HomeGuard(Option<String>);
impl HomeGuard {
fn redirect(to: &std::path::Path) -> Self {
let prior = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", to) };
Self(prior)
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.0 {
Some(ref prior) => unsafe { std::env::set_var("HOME", prior) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
#[tokio::test]
#[serial_test::serial]
async fn session_decommission_prunes_stale_worktree_bookkeeping() {
fn git(dir: &std::path::Path, args: &[&str]) -> std::process::Output {
std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap_or_else(|e| panic!("git {args:?} could not run: {e}"))
}
let home = tempfile::tempdir().expect("home tempdir");
let _home_guard = HomeGuard::redirect(home.path());
let workspace_root = trusty_common::workspace_layout::resolve_workspace_root(None);
let base = workspace_root.join("tm-5949-base");
std::fs::create_dir_all(&base).expect("create base repo dir");
if !git(&base, &["init", "--quiet"]).status.success() {
eprintln!("session_decommission_prunes_stale_worktree_bookkeeping: no git, skipping");
return;
}
assert!(
git(&base, &["config", "user.email", "ci@test.invalid"])
.status
.success()
);
assert!(git(&base, &["config", "user.name", "CI"]).status.success());
assert!(
git(&base, &["config", "commit.gpgsign", "false"])
.status
.success()
);
std::fs::write(base.join("README.md"), "seed\n").expect("write README");
assert!(git(&base, &["add", "README.md"]).status.success());
assert!(
git(&base, &["commit", "--quiet", "-m", "seed"])
.status
.success()
);
let remote = workspace_root.join("tm-5949-remote.git");
assert!(
git(
&workspace_root,
&["init", "--quiet", "--bare", "tm-5949-remote.git"]
)
.status
.success()
);
let remote = remote.to_string_lossy();
for args in [
&["remote", "add", "origin", &remote][..],
&["push", "--quiet", "origin", "HEAD"],
&["fetch", "--quiet", "origin"],
] {
assert!(git(&base, args).status.success(), "git {args:?}");
}
let id = crate::session_manager::ManagedSessionId::new();
let leaf = format!("tm-5949-{id}");
let ws = base.join(".worktrees").join(&leaf);
assert!(
git(
&base,
&[
"worktree",
"add",
"--quiet",
"-b",
&format!("session/{leaf}"),
&ws.to_string_lossy(),
],
)
.status
.success(),
"fixture: the worktree must be registered"
);
let listed = |dir: &std::path::Path| {
String::from_utf8_lossy(&git(dir, &["worktree", "list", "--porcelain"]).stdout)
.into_owned()
};
assert!(
listed(&base).contains(&leaf),
"fixture invariant: git must list the worktree before decommission"
);
let (_root, s) = state().await;
s.session_manager()
.await
.create_with_id(
id,
"regression: #5949 MCP decommission".to_string(),
Some(ws.clone()),
None,
Some(ws.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
true,
)
.await
.expect("seed session");
let json = session_decommission(&s, &id.to_string())
.await
.expect("decommission");
assert_eq!(json["state"], "decommissioned", "{json}");
assert_eq!(json["workspace_removed"], Value::Bool(true), "{json}");
assert_eq!(
json.get("workspace_kept_reason"),
Some(&Value::Null),
"{json}"
);
assert!(!ws.exists(), "the workspace must be gone: {}", ws.display());
assert!(
!listed(&base).contains(&leaf),
"the MCP decommission left a stale worktree entry for {leaf} — the \
bookkeeping repair did not run (#5949)"
);
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
#[serial_test::serial]
async fn session_decommission_reports_a_kept_workspace() {
let home = tempfile::tempdir().expect("home tempdir");
let _home_guard = HomeGuard::redirect(home.path());
let workspace_root = trusty_common::workspace_layout::resolve_workspace_root(None);
let ws = workspace_root
.join("owner")
.join("repo")
.join("tm-8663-kept");
std::fs::create_dir_all(&ws).expect("create workspace");
std::fs::write(ws.join("notes.md"), "agent work\n").expect("write notes");
let (_root, s) = state().await;
let id = crate::session_manager::ManagedSessionId::new();
s.session_manager()
.await
.create_with_id(
id,
"regression: #8663 MCP kept workspace".to_string(),
Some(ws.clone()),
None,
Some(ws.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
true,
)
.await
.expect("seed session");
let json = session_decommission(&s, &id.to_string())
.await
.expect("a refusal is not an error");
assert_eq!(json["state"], "decommissioned", "{json}");
assert_eq!(json["workspace_removed"], Value::Bool(false), "{json}");
let reason = json["workspace_kept_reason"]
.as_str()
.unwrap_or_else(|| panic!("the kept reason is a string: {json}"));
assert!(reason.contains("notes.md"), "{reason}");
assert!(ws.join("notes.md").exists(), "the work must survive");
assert_eq!(
json.get("workspace_kept_by_design"),
Some(&Value::Null),
"{json}"
);
}
#[tokio::test]
async fn session_decommission_reports_a_workspace_kept_by_design() {
let ws = tempfile::tempdir().expect("user directory");
let (_root, s) = state().await;
let id = crate::session_manager::ManagedSessionId::new();
s.session_manager()
.await
.create_with_id(
id,
"regression: #8663 MCP kept by design".to_string(),
Some(ws.path().to_path_buf()),
None,
Some(ws.path().to_path_buf()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
let json = session_decommission(&s, &id.to_string())
.await
.expect("decommission");
let by_design = json["workspace_kept_by_design"]
.as_str()
.unwrap_or_else(|| panic!("the by-design reason is a string: {json}"));
assert!(by_design.contains("kept by design"), "{by_design}");
assert_eq!(json["workspace_removed"], Value::Bool(false), "{json}");
assert!(ws.path().exists(), "the user's directory must survive");
}
}