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,
};
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;
mgr.stop(&id)
.await
.map(|r| record_to_json(&r))
.map_err(managed_err)
}
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;
mgr.decommission(&id, None)
.await
.map(|(r, _workspace_removed)| record_to_json(&r))
.map_err(managed_err)
}
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)?;
mgr.send_input(&id, text).await.map_err(managed_err)?;
Ok(json!({
"id": record.id.to_string(),
"sent": true,
"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)
.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::*;
fn state() -> Arc<DaemonState> {
DaemonState::shared()
}
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 s = state();
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 s = state();
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_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"
);
}
#[tokio::test]
async fn session_new_invalid_runtime_errors() {
let s = state();
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}");
}
}