use std::path::PathBuf;
use chrono::Utc;
use super::super::record::{
ManagedSessionId, ManagedSessionState, SessionRecord, UNRESOLVED_PATH_SENTINEL,
};
use super::super::tests::{make_manager, seed_record};
use super::PruneFilter;
async fn seed_live(
mgr: &super::SessionManager,
id: ManagedSessionId,
tmux_name: &str,
cwd: PathBuf,
workspace_path: Option<PathBuf>,
pane_id: Option<&str>,
) {
let record = SessionRecord {
id,
tmux_name: tmux_name.to_owned(),
cwd,
task: "adopted session (unmanaged — workspace path could not be resolved)".into(),
state: ManagedSessionState::Active,
created_at: Utc::now(),
last_activity_at: None,
workspace_path,
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: pane_id.map(str::to_owned),
tmux_server: pane_id.map(|_| super::super::tests::FAKE_TMUX_SERVER.to_owned()),
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
};
mgr.tmux
.create_session(tmux_name, "/tmp")
.expect("seed: register live tmux session");
mgr.store
.write()
.await
.upsert(record)
.await
.expect("seed: persist record");
}
async fn seed_ghost(mgr: &super::SessionManager, id: ManagedSessionId, tmux_name: &str) {
seed_live(
mgr,
id,
tmux_name,
PathBuf::from(UNRESOLVED_PATH_SENTINEL),
None,
Some("%1"),
)
.await;
}
fn selected(outcome: &super::PruneOutcome) -> Vec<String> {
let mut ids: Vec<String> = outcome.sessions.iter().map(|s| s.id.clone()).collect();
ids.sort();
ids
}
#[tokio::test]
async fn unresolvable_filter_selects_a_live_ghost_pane() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let ghost = ManagedSessionId::new();
seed_ghost(&mgr, ghost, "tm-ghost-live").await;
assert!(
super::is_running(
&mgr.get(&ghost).await.expect("ghost record"),
mgr.tmux.as_ref()
)
.expect("probe"),
"PREMISE: the ghost's tmux session must be live"
);
let outcome = mgr
.prune_managed(PruneFilter::Unresolvable, false, false, None, None)
.await
.expect("prune unresolvable");
assert_eq!(
selected(&outcome),
vec![ghost.to_string()],
"the live ghost must be selected with no --include-active"
);
assert_eq!(
mgr.get(&ghost)
.await
.expect("record survives as tombstone")
.state,
ManagedSessionState::Decommissioned
);
}
#[tokio::test]
async fn healthy_active_session_is_never_selected_by_the_unresolvable_filter() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let healthy = ManagedSessionId::new();
seed_record(&mgr, &dir, healthy, ManagedSessionState::Active, false).await;
let ghost = ManagedSessionId::new();
seed_ghost(&mgr, ghost, "tm-ghost-beside-healthy").await;
let outcome = mgr
.prune_managed(PruneFilter::Unresolvable, false, false, None, None)
.await
.expect("prune unresolvable");
assert_eq!(
selected(&outcome),
vec![ghost.to_string()],
"CONTROL: the ghost must be selected and the healthy session must not"
);
assert_eq!(
mgr.get(&healthy).await.expect("healthy record").state,
ManagedSessionState::Active,
"a healthy Active session must be untouched"
);
}
#[tokio::test]
async fn unresolvable_filter_keeps_a_record_that_still_names_a_workspace() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let half = ManagedSessionId::new();
seed_live(
&mgr,
half,
"tm-half-resolvable",
PathBuf::from(UNRESOLVED_PATH_SENTINEL),
Some(dir.path().join("still-here")),
None,
)
.await;
let outcome = mgr
.prune_managed(PruneFilter::Unresolvable, false, false, None, None)
.await
.expect("prune unresolvable");
assert!(
outcome.sessions.is_empty(),
"a record still naming a workspace is not unresolvable: {:?}",
selected(&outcome)
);
assert_eq!(
mgr.get(&half).await.expect("record").state,
ManagedSessionState::Active
);
}
#[tokio::test]
async fn unresolvable_filter_needs_no_include_active() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let ghost = ManagedSessionId::new();
seed_ghost(&mgr, ghost, "tm-ghost-flagless").await;
seed_record(
&mgr,
&dir,
ManagedSessionId::new(),
ManagedSessionState::Active,
false,
)
.await;
let without = mgr
.prune_managed(PruneFilter::Unresolvable, true, false, None, None)
.await
.expect("dry-run without the flag");
let with = mgr
.prune_managed(PruneFilter::Unresolvable, true, true, None, None)
.await
.expect("dry-run with the flag");
assert_eq!(selected(&without), vec![ghost.to_string()]);
assert_eq!(
selected(&without),
selected(&with),
"--include-active must not change what `unresolvable` selects"
);
}
#[tokio::test]
async fn unresolvable_dry_run_selects_exactly_what_the_real_run_does() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let a = ManagedSessionId::new();
let b = ManagedSessionId::new();
seed_ghost(&mgr, a, "tm-ghost-parity-a").await;
seed_ghost(&mgr, b, "tm-ghost-parity-b").await;
seed_record(
&mgr,
&dir,
ManagedSessionId::new(),
ManagedSessionState::Active,
false,
)
.await;
let preview = mgr
.prune_managed(PruneFilter::Unresolvable, true, false, None, None)
.await
.expect("dry run");
assert!(
preview.dry_run,
"the outcome must report itself as a dry run"
);
assert_eq!(
selected(&preview).len(),
2,
"both ghosts must be previewed, or the comparison below proves nothing: {:?}",
selected(&preview)
);
for id in [a, b] {
assert_eq!(
mgr.get(&id).await.expect("record").state,
ManagedSessionState::Active,
"the dry run must not have touched {id}"
);
}
let real = mgr
.prune_managed(PruneFilter::Unresolvable, false, false, None, None)
.await
.expect("real run");
assert_eq!(
selected(&preview),
selected(&real),
"the preview and the real run must select the same records"
);
let actions = |o: &super::PruneOutcome| {
let mut v: Vec<String> = o
.sessions
.iter()
.map(|s| format!("{} {}", s.id, s.action.as_str()))
.collect();
v.sort();
v
};
assert_eq!(
actions(&preview),
actions(&real),
"the preview must predict the same action per record"
);
}
#[tokio::test]
async fn ephemeral_dry_run_selects_exactly_what_the_real_run_does() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let eph = ManagedSessionId::new();
let durable = ManagedSessionId::new();
seed_record(&mgr, &dir, eph, ManagedSessionState::Active, true).await;
super::super::tests::bind_pane(&mgr, &eph).await;
seed_record(&mgr, &dir, durable, ManagedSessionState::Active, false).await;
let preview = mgr.sweep_all_ephemeral(true).await.expect("preview");
assert_eq!(selected(&preview), vec![eph.to_string()]);
assert_eq!(
mgr.get(&eph).await.expect("record").state,
ManagedSessionState::Active,
"the preview must not tear the ephemeral session down"
);
let real = mgr.sweep_all_ephemeral(false).await.expect("real sweep");
assert_eq!(
selected(&preview),
selected(&real),
"the ephemeral preview and sweep must select the same records"
);
assert_eq!(
mgr.get(&durable).await.expect("record").state,
ManagedSessionState::Active,
"a durable session is unreachable by this path"
);
}
#[tokio::test]
async fn prune_never_selects_the_invoking_session() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let mine = ManagedSessionId::new();
let peer = ManagedSessionId::new();
seed_record(&mgr, &dir, mine, ManagedSessionState::Active, false).await;
seed_record(&mgr, &dir, peer, ManagedSessionState::Active, false).await;
super::super::tests::bind_pane(&mgr, &peer).await;
let outcome = mgr
.prune_managed(PruneFilter::All, false, true, None, Some(mine))
.await
.expect("prune all");
assert_eq!(
selected(&outcome),
vec![peer.to_string()],
"CONTROL: the peer must be taken (so the sweep really ran) and the invoker spared"
);
assert_eq!(
mgr.get(&mine).await.expect("invoker record").state,
ManagedSessionState::Active,
"the invoking session must still be running"
);
}
#[tokio::test]
async fn unresolvable_duplicates_sharing_a_pane_are_both_tombstoned() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let first = ManagedSessionId::new();
let second = ManagedSessionId::new();
seed_ghost(&mgr, first, "tm-ghost-dup").await;
seed_ghost(&mgr, second, "tm-ghost-dup").await;
let outcome = mgr
.prune_managed(PruneFilter::Unresolvable, false, false, None, None)
.await
.expect("prune unresolvable");
let mut expected = vec![first.to_string(), second.to_string()];
expected.sort();
assert_eq!(
selected(&outcome),
expected,
"both duplicate records must be selected in one pass"
);
for id in [first, second] {
assert_eq!(
mgr.get(&id).await.expect("record").state,
ManagedSessionState::Decommissioned,
"{id} must be tombstoned"
);
}
assert!(
!mgr.tmux
.session_exists_checked("tm-ghost-dup")
.expect("probe"),
"the shared pane must be reclaimed"
);
}
#[test]
fn unresolvable_filter_parses_and_is_listed_in_the_error() {
assert_eq!(
PruneFilter::parse("unresolvable").expect("parses"),
PruneFilter::Unresolvable
);
assert_eq!(PruneFilter::Unresolvable.as_str(), "unresolvable");
let err = PruneFilter::parse("active").expect_err("`active` is still not a filter");
assert!(
err.contains("unresolvable"),
"the rejection must name the new filter: {err}"
);
assert!(
PruneFilter::Unresolvable.selects_regardless_of_liveness(),
"this is the one filter that ignores liveness"
);
for f in [
PruneFilter::Ephemeral,
PruneFilter::Stopped,
PruneFilter::Decommissioned,
PruneFilter::Deleted,
PruneFilter::All,
] {
assert!(
!f.selects_regardless_of_liveness(),
"{f} must keep its liveness gate"
);
}
}