use std::collections::HashSet;
use std::sync::Arc;
use serde_json::{Value, json};
use crate::core::session::SessionId;
use crate::daemon::managed_routes::record_to_json;
use crate::daemon::state::DaemonState;
pub async fn session_delete_records(
state: &Arc<DaemonState>,
session_ids: &[String],
) -> Result<Value, String> {
if session_ids.is_empty() {
return Err("`session_ids` must name at least one session".to_string());
}
let mgr = state.session_manager().await;
let live_tmux: Result<HashSet<String>, String> = mgr
.tmux_driver()
.list_sessions()
.map(|names| names.into_iter().collect())
.map_err(|e| {
format!("tmux enumeration failed ({e}) — cannot prove this session is stopped")
});
let mut seen: HashSet<&str> = HashSet::new();
let mut results: Vec<Value> = Vec::new();
let mut deleted = 0usize;
let mut failed = 0usize;
for raw in session_ids {
if !seen.insert(raw.as_str()) {
continue;
}
let outcome = delete_one(state, raw, live_tmux.as_ref()).await;
match &outcome {
Outcome::Deleted { .. } => deleted += 1,
Outcome::Failed { .. } => failed += 1,
}
results.push(outcome.to_json(raw));
}
Ok(json!({
"requested": results.len(),
"deleted": deleted,
"failed": failed,
"results": results,
}))
}
enum Outcome {
Deleted {
kind: &'static str,
record: Value,
},
Failed {
kind: Option<&'static str>,
error: String,
},
}
impl Outcome {
fn to_json(&self, session_id: &str) -> Value {
match self {
Self::Deleted { kind, record } => json!({
"session_id": session_id,
"kind": kind,
"deleted": true,
"error": Value::Null,
"record": record,
}),
Self::Failed { kind, error } => json!({
"session_id": session_id,
"kind": kind,
"deleted": false,
"error": error,
}),
}
}
}
async fn delete_one(
state: &Arc<DaemonState>,
raw: &str,
live_tmux: Result<&HashSet<String>, &String>,
) -> Outcome {
let Ok(uuid) = raw.parse::<uuid::Uuid>() else {
return Outcome::Failed {
kind: None,
error: format!("`{raw}` is not a valid session id (expected a UUID)"),
};
};
let managed_id = crate::session_manager::ManagedSessionId::from(uuid);
let mgr = state.session_manager().await;
if mgr.get(&managed_id).await.is_ok() {
return match mgr.delete_record(&managed_id, false).await {
Ok(record) => Outcome::Deleted {
kind: "managed",
record: record_to_json(&record),
},
Err(e) => Outcome::Failed {
kind: Some("managed"),
error: e.to_string(),
},
};
}
let Some(session) = state.session(SessionId(uuid)) else {
return Outcome::Failed {
kind: None,
error: format!("no session record found for `{raw}` in either registry"),
};
};
match legacy_is_running(&session, live_tmux) {
Err(reason) => {
return Outcome::Failed {
kind: Some("legacy"),
error: reason,
};
}
Ok(true) => {
return Outcome::Failed {
kind: Some("legacy"),
error: format!(
"session `{}` is still running — stop it first, then delete its record",
session.tmux_name
),
};
}
Ok(false) => {}
}
let managed_sibling = managed_sibling_of(state, &session.tmux_name).await;
match state.remove_session(SessionId(uuid)) {
Some(session) => Outcome::Deleted {
kind: "legacy",
record: json!({
"id": session.id.0.to_string(),
"name": session.tmux_name,
"status": session.status.to_string(),
"managed_sibling": managed_sibling,
}),
},
None => Outcome::Failed {
kind: Some("legacy"),
error: format!("session record `{raw}` disappeared before it could be deleted"),
},
}
}
fn legacy_is_running(
session: &crate::core::session::Session,
live_tmux: Result<&HashSet<String>, &String>,
) -> Result<bool, String> {
use crate::core::session::SessionHost;
match session.origin {
SessionHost::Tmux => match live_tmux {
Ok(live) => Ok(live.contains(&session.tmux_name)),
Err(reason) => Err(reason.clone()),
},
SessionHost::Native => match session.pid {
Some(pid) => Ok(crate::core::process::is_process_alive(pid)),
None => Err(format!(
"native session `{}` has no tracked pid — cannot prove it is stopped",
session.tmux_name
)),
},
}
}
async fn managed_sibling_of(state: &Arc<DaemonState>, tmux_name: &str) -> Option<String> {
let mgr = state.session_manager().await;
mgr.list()
.await
.into_iter()
.find(|r| r.tmux_name == tmux_name && !r.state.is_terminal())
.map(|r| r.id.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::daemon::state::DaemonState;
use crate::runtime::RuntimeKind;
use crate::session_manager::{ManagedError, ManagedSessionId, ManagedSessionState};
async fn isolated(root: &tempfile::TempDir) -> Arc<DaemonState> {
Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await)
}
struct LiveTrackingTmux {
live: std::sync::Mutex<HashSet<String>>,
}
impl LiveTrackingTmux {
fn new() -> Arc<Self> {
Arc::new(Self {
live: std::sync::Mutex::new(HashSet::new()),
})
}
}
impl crate::session_manager::ManagedTmuxDriver for LiveTrackingTmux {
fn create_session(&self, name: &str, _workdir: &str) -> Result<(), ManagedError> {
self.live.lock().expect("live set").insert(name.to_owned());
Ok(())
}
fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
self.live.lock().expect("live set").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()
.expect("live set")
.iter()
.cloned()
.collect())
}
}
async fn isolated_live_tracking(root: &tempfile::TempDir) -> Arc<DaemonState> {
Arc::new(
DaemonState::with_root_isolated_managed_and_driver(
root.path().to_path_buf(),
LiveTrackingTmux::new(),
)
.await,
)
}
async fn seed_managed(
state: &Arc<DaemonState>,
root: &tempfile::TempDir,
label: &str,
) -> (ManagedSessionId, std::path::PathBuf) {
let id = ManagedSessionId::new();
let ws = root.path().join(format!("{label}-{id}"));
std::fs::create_dir_all(&ws).expect("workspace dir");
std::fs::write(ws.join("KEEPME.txt"), b"real work").expect("workspace file");
let mgr = state.session_manager().await;
mgr.create_with_id(
id,
format!("#6431 {label}"),
Some(ws.clone()),
None,
Some(ws.clone()),
Some("https://example.com/r.git".to_string()),
Some("main".to_string()),
RuntimeKind::default(),
false,
false,
)
.await
.expect("seed managed session");
(id, ws)
}
fn seed_legacy(
state: &Arc<DaemonState>,
tmux_name: &str,
origin: crate::core::session::SessionHost,
pid: Option<u32>,
) -> SessionId {
use crate::core::session::{ControlModel, Session, SessionStatus};
let id = SessionId::new();
let mut session = Session::new(id, "/tmp/legacy", ControlModel::Tmux, None);
session.status = SessionStatus::Active;
session.tmux_name = tmux_name.to_string();
session.origin = origin;
session.pid = pid;
state.register_session(session);
id
}
#[tokio::test]
async fn bulk_delete_leaves_the_workspace_on_disk() {
let root = tempfile::TempDir::new().expect("tempdir");
let state = isolated(&root).await;
let (id, ws) = seed_managed(&state, &root, "record-only").await;
let out = session_delete_records(&state, &[id.to_string()])
.await
.expect("bulk delete");
assert_eq!(out["deleted"], json!(1), "{out}");
assert_eq!(out["failed"], json!(0), "{out}");
assert_eq!(out["results"][0]["kind"], json!("managed"), "{out}");
assert!(ws.exists(), "the workspace directory must survive: {ws:?}");
assert!(
ws.join("KEEPME.txt").exists(),
"workspace contents must survive: {ws:?}"
);
let mgr = state.session_manager().await;
let after = mgr.get(&id).await.expect("record stays as a tombstone");
assert_eq!(after.state, ManagedSessionState::Deleted);
}
#[tokio::test]
async fn bulk_delete_reports_partial_failure() {
let root = tempfile::TempDir::new().expect("tempdir");
let state = isolated_live_tracking(&root).await;
let (running, ws) = seed_managed(&state, &root, "running").await;
let ghost = uuid::Uuid::new_v4().to_string();
let out = session_delete_records(
&state,
&[running.to_string(), ghost.clone(), "not-a-uuid".to_string()],
)
.await
.expect("bulk delete");
assert_eq!(out["requested"], json!(3), "{out}");
assert_eq!(out["deleted"], json!(0), "{out}");
assert_eq!(out["failed"], json!(3), "{out}");
let rows = out["results"].as_array().expect("results array");
assert_eq!(rows[0]["kind"], json!("managed"), "{out}");
assert_eq!(rows[0]["deleted"], json!(false), "{out}");
assert!(
rows[0]["error"]
.as_str()
.unwrap_or_default()
.contains(&running.to_string()),
"the refusal must name the session: {out}"
);
assert_eq!(rows[1]["kind"], Value::Null, "{out}");
assert_eq!(rows[2]["kind"], Value::Null, "{out}");
let mgr = state.session_manager().await;
assert!(mgr.get(&running).await.is_ok());
assert!(ws.join("KEEPME.txt").exists());
}
#[tokio::test]
async fn bulk_delete_refuses_a_live_legacy_record() {
use crate::core::session::SessionHost;
let root = tempfile::TempDir::new().expect("tempdir");
let state = isolated_live_tracking(&root).await;
let id = seed_legacy(&state, "tm-legacy-live", SessionHost::Tmux, None);
state
.session_manager()
.await
.tmux_driver()
.create_session("tm-legacy-live", "/tmp")
.expect("create tmux session");
let out = session_delete_records(&state, &[id.0.to_string()])
.await
.expect("bulk delete");
assert_eq!(out["deleted"], json!(0), "{out}");
assert_eq!(out["failed"], json!(1), "{out}");
assert_eq!(out["results"][0]["kind"], json!("legacy"), "{out}");
assert!(
out["results"][0]["error"]
.as_str()
.unwrap_or_default()
.contains("still running"),
"{out}"
);
assert_eq!(
state.list_sessions().len(),
1,
"a live legacy record must survive the refusal"
);
}
#[tokio::test]
async fn bulk_delete_deletes_a_dead_legacy_record() {
use crate::core::session::SessionHost;
let root = tempfile::TempDir::new().expect("tempdir");
let state = isolated_live_tracking(&root).await;
let id = seed_legacy(&state, "tm-legacy-dead", SessionHost::Tmux, None);
let out = session_delete_records(&state, &[id.0.to_string()])
.await
.expect("bulk delete");
assert_eq!(out["deleted"], json!(1), "{out}");
assert_eq!(out["results"][0]["kind"], json!("legacy"), "{out}");
assert!(
state.list_sessions().is_empty(),
"registry entry must be gone"
);
}
#[tokio::test]
async fn bulk_delete_refuses_a_native_legacy_record_whose_process_is_alive() {
use crate::core::session::SessionHost;
let root = tempfile::TempDir::new().expect("tempdir");
let state = isolated_live_tracking(&root).await;
let id = seed_legacy(
&state,
"session-native",
SessionHost::Native,
Some(std::process::id()),
);
let out = session_delete_records(&state, &[id.0.to_string()])
.await
.expect("bulk delete");
assert_eq!(out["deleted"], json!(0), "{out}");
assert_eq!(out["failed"], json!(1), "{out}");
assert_eq!(state.list_sessions().len(), 1);
let orphan = seed_legacy(&state, "session-native-nopid", SessionHost::Native, None);
let out = session_delete_records(&state, &[orphan.0.to_string()])
.await
.expect("bulk delete");
assert_eq!(out["deleted"], json!(0), "{out}");
assert!(
out["results"][0]["error"]
.as_str()
.unwrap_or_default()
.contains("no tracked pid"),
"{out}"
);
}
#[tokio::test]
async fn bulk_delete_refuses_every_legacy_row_when_tmux_enumeration_fails() {
use crate::core::session::SessionHost;
struct BlindTmux;
impl crate::session_manager::ManagedTmuxDriver for BlindTmux {
fn create_session(&self, _n: &str, _w: &str) -> Result<(), ManagedError> {
Ok(())
}
fn kill_session(&self, _n: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_line(&self, _n: &str, _t: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _n: &str, _l: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Err(ManagedError::TmuxUnavailable(
"tmux server unreachable".to_string(),
))
}
}
let root = tempfile::TempDir::new().expect("tempdir");
let state = Arc::new(
DaemonState::with_root_isolated_managed_and_driver(
root.path().to_path_buf(),
Arc::new(BlindTmux),
)
.await,
);
let id = seed_legacy(&state, "tm-legacy-unprovable", SessionHost::Tmux, None);
let out = session_delete_records(&state, &[id.0.to_string()])
.await
.expect("bulk delete");
assert_eq!(out["deleted"], json!(0), "{out}");
assert_eq!(out["failed"], json!(1), "{out}");
assert!(
out["results"][0]["error"]
.as_str()
.unwrap_or_default()
.contains("cannot prove"),
"{out}"
);
assert_eq!(state.list_sessions().len(), 1);
}
#[tokio::test]
async fn bulk_delete_reports_a_managed_sibling_without_touching_it() {
use crate::core::session::SessionHost;
let root = tempfile::TempDir::new().expect("tempdir");
let state = isolated(&root).await;
let (managed_id, ws) = seed_managed(&state, &root, "twin").await;
let managed_name = state
.session_manager()
.await
.get(&managed_id)
.await
.expect("managed record")
.tmux_name;
let legacy_id = seed_legacy(&state, &managed_name, SessionHost::Tmux, None);
let out = session_delete_records(&state, &[legacy_id.0.to_string()])
.await
.expect("bulk delete");
assert_eq!(out["deleted"], json!(1), "{out}");
assert_eq!(
out["results"][0]["record"]["managed_sibling"],
json!(managed_id.to_string()),
"the twin must be named in the result: {out}"
);
let twin = state
.session_manager()
.await
.get(&managed_id)
.await
.expect("managed twin must survive");
assert!(!twin.state.is_terminal(), "twin must not be decommissioned");
assert!(
ws.join("KEEPME.txt").exists(),
"twin's workspace must stand"
);
}
#[tokio::test]
async fn bulk_delete_deduplicates_repeated_ids() {
let root = tempfile::TempDir::new().expect("tempdir");
let state = isolated(&root).await;
let (id, _ws) = seed_managed(&state, &root, "dupe").await;
let out = session_delete_records(&state, &[id.to_string(), id.to_string()])
.await
.expect("bulk delete");
assert_eq!(out["requested"], json!(1), "{out}");
assert_eq!(out["deleted"], json!(1), "{out}");
assert_eq!(out["failed"], json!(0), "{out}");
}
#[tokio::test]
async fn bulk_delete_rejects_an_empty_id_list() {
let root = tempfile::TempDir::new().expect("tempdir");
let state = isolated(&root).await;
assert!(
session_delete_records(&state, &[])
.await
.unwrap_err()
.contains("at least one session")
);
}
}