use std::{
fs,
path::{Path, PathBuf},
};
#[cfg(test)]
use crate::subagent::RunStatus;
use crate::subagent::{self, RunState, RESULT_FILE_NAME};
use super::{
index,
persistence::{workspace_key, ResolvedSession, SessionStore, SessionUnit},
SessionSummary,
};
#[derive(Clone, Debug, Default)]
pub struct DeleteOptions {
pub force: bool,
pub protect_session_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeleteOutcome {
pub id: String,
pub cwd: PathBuf,
pub path: PathBuf,
pub deleted_run_count: usize,
pub forced_run_ids: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RunCleanup {
Structural,
Explicit,
}
#[derive(Clone, Debug)]
struct LinkedRun {
dir: PathBuf,
id: String,
state: Option<RunState>,
cleanup: RunCleanup,
}
pub(super) fn list_all_in_root(session_root: &Path) -> anyhow::Result<Vec<SessionSummary>> {
let mut summaries = Vec::new();
if !session_root.is_dir() {
return Ok(summaries);
}
for entry in fs::read_dir(session_root)? {
let workspace_dir = entry?.path();
if !workspace_dir.is_dir() {
continue;
}
for session_entry in fs::read_dir(&workspace_dir)? {
let path = session_entry?.path();
let Some(unit) = SessionUnit::from_path(&path) else {
continue;
};
match super::persistence::summarize_session_file(&unit.transcript_path(), Path::new(""))
{
Ok(record) => summaries.push(record.summary),
Err(_) => continue,
}
}
}
summaries.sort_by(|left, right| {
right
.updated_at
.cmp(&left.updated_at)
.then_with(|| right.created_at.cmp(&left.created_at))
.then_with(|| left.id.cmp(&right.id))
});
Ok(summaries)
}
pub(super) fn delete_in_roots(
session_root: &Path,
subagents_root: &Path,
cwd: &Path,
id_prefix: &str,
options: &DeleteOptions,
) -> anyhow::Result<DeleteOutcome> {
let resolved = SessionStore::new(session_root, cwd).resolve(id_prefix)?;
delete_resolved(session_root, subagents_root, resolved, options)
}
fn delete_resolved(
session_root: &Path,
subagents_root: &Path,
resolved: ResolvedSession,
options: &DeleteOptions,
) -> anyhow::Result<DeleteOutcome> {
if options
.protect_session_id
.as_deref()
.is_some_and(|protected| protected == resolved.id)
{
anyhow::bail!(
"refusing to delete the current session '{}'; start a new session or resume another first",
short_id(&resolved.id)
);
}
let unit = SessionUnit::from_path(&resolved.path).ok_or_else(|| {
anyhow::anyhow!(
"session '{}' has an unrecognized on-disk layout at {}",
resolved.id,
resolved.path.display()
)
})?;
let parent_session_id = resolved.id.clone();
let cleanup_guard = subagent::lock_parent_for_cleanup(subagents_root, &parent_session_id)?;
let mut linked = find_nested_runs(&unit)?;
linked.extend(find_parent_linked_runs(subagents_root, &resolved.id)?);
linked.sort_by(|left, right| {
left.id
.cmp(&right.id)
.then_with(|| left.dir.cmp(&right.dir))
});
let mut forced_run_ids = Vec::new();
for run in &linked {
if run.state.is_some_and(RunState::is_terminal) {
continue;
}
if !options.force {
let crash_hint = if matches!(run.state, Some(RunState::Running | RunState::Starting)) {
" (use --force only for stale runs left after a crash)"
} else {
""
};
let state = run.state.map(RunState::as_str).unwrap_or("unknown");
anyhow::bail!(
"refusing to delete session '{}': related run {} is still {state}{crash_hint}; wait for it to finish or pass --force",
short_id(&resolved.id),
run.id,
);
}
forced_run_ids.push(run.id.clone());
}
unit.delete_from_disk()?;
index::remove_session(session_root, &workspace_key(&resolved.cwd), &resolved.id)?;
let deleted_run_count = linked.len();
for run in linked
.into_iter()
.filter(|run| run.cleanup == RunCleanup::Explicit)
{
match fs::remove_dir_all(&run.dir) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(anyhow::anyhow!(
"deleted session '{}' but failed to remove related run {}: {error}",
resolved.id,
run.id
));
}
}
}
cleanup_guard.clear_index_and_unlock()?;
Ok(DeleteOutcome {
id: resolved.id,
cwd: resolved.cwd,
path: resolved.path,
deleted_run_count,
forced_run_ids,
})
}
fn find_nested_runs(unit: &SessionUnit) -> anyhow::Result<Vec<LinkedRun>> {
let Some(subagents_dir) = unit.subagents_dir() else {
return Ok(Vec::new());
};
if !subagent::is_trusted_directory(&subagents_dir) {
return Ok(Vec::new());
}
let mut runs = Vec::new();
for entry in fs::read_dir(subagents_dir)? {
let dir = entry?.path();
if !subagent::is_trusted_directory(&dir) {
continue;
}
let Some(id) = dir
.file_name()
.and_then(|name| name.to_str())
.and_then(|id| subagent::normalize_id(id).ok())
else {
continue;
};
let state = subagent::read_status(&dir.join(RESULT_FILE_NAME)).map(|status| status.state);
runs.push(LinkedRun {
dir,
id,
state,
cleanup: RunCleanup::Structural,
});
}
Ok(runs)
}
fn find_parent_linked_runs(
subagents_root: &Path,
parent_session_id: &str,
) -> anyhow::Result<Vec<LinkedRun>> {
let mut runs = Vec::new();
if !subagent::is_trusted_directory(subagents_root) {
return Ok(runs);
}
for entry in fs::read_dir(subagents_root)? {
let dir = entry?.path();
if !subagent::is_trusted_directory(&dir) {
continue;
}
let Some(id) = dir
.file_name()
.and_then(|name| name.to_str())
.and_then(|id| subagent::normalize_id(id).ok())
else {
continue;
};
let status_path = dir.join(RESULT_FILE_NAME);
let Some(status) = subagent::read_status(&status_path) else {
continue;
};
if status.parent_session_id.as_deref() != Some(parent_session_id) {
continue;
}
runs.push(LinkedRun {
dir,
id,
state: Some(status.state),
cleanup: RunCleanup::Explicit,
});
}
Ok(runs)
}
pub(super) fn short_id(id: &str) -> String {
id.chars().take(8).collect()
}
pub fn is_cross_project(session_cwd: &Path, cwd: &Path) -> bool {
workspace_key(session_cwd) != workspace_key(cwd)
}
#[cfg(test)]
pub(super) fn write_linked_run_for_tests(
subagents_root: &Path,
run_id: &str,
parent_session_id: &str,
state: RunState,
) -> PathBuf {
let dir = subagents_root.join(run_id);
fs::create_dir_all(&dir).unwrap();
let status = RunStatus {
state,
parent_session_id: Some(parent_session_id.to_string()),
agent_id: Some("worker".into()),
..RunStatus::default()
};
subagent::initialize_status(&dir.join(RESULT_FILE_NAME), &status).unwrap();
dir
}